Skip to content

Commit 9be9d6c

Browse files
authored
Merge pull request #218 from stableprogrammer/fix/asset-identity-spoofing
fix: identify Stellar assets by (code, issuer) pair to prevent token spoofing
2 parents 49df10f + 1a5a61e commit 9be9d6c

4 files changed

Lines changed: 170 additions & 8 deletions

File tree

__tests__/api/stellar/activity.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ describe("GET /api/stellar/activity", () => {
142142
expect(response.status).toBe(200)
143143
expect(loadAccount).toHaveBeenCalledWith(user.stellarPublicKey)
144144
expect(payload.balances).toEqual([
145-
{ asset: "XLM", balance: "150.00", type: "native", issuer: null },
146-
{ asset: "USDC", balance: "20.00", type: "credit_alphanum4", issuer: "GDUSDC" },
145+
{ asset: "XLM", issuer: null, verified: true, canonicalId: "XLM:native", balance: "150.00", type: "native" },
146+
{ asset: "USDC", issuer: "GDUSDC", verified: false, canonicalId: "USDC:GDUSDC", balance: "20.00", type: "credit_alphanum4" },
147147
])
148148
expect(payload.activities[0].id).toBe("tx-1")
149149
expect(payload.activities[0].title).toBe("Repayment Settlement")
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it } from "vitest"
2+
import { classifyAsset, XLM_CANONICAL_ID } from "@/lib/stellar/asset-identity"
3+
4+
const CANONICAL_ISSUER = "GDMXNQBJMS3FYI4PFSYCCB4XODQMNMTKFFUALHGTP3LYUHDQR55NGMH"
5+
const SPOOF_ISSUER = "GBSPOOFERXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
6+
7+
const config = {
8+
assetCode: "CMOVE",
9+
issuerPublicKey: CANONICAL_ISSUER,
10+
}
11+
12+
describe("classifyAsset — native XLM", () => {
13+
it('returns verified=true and canonicalId="XLM:native" for native XLM', () => {
14+
const id = classifyAsset("XLM", null, config)
15+
expect(id.asset).toBe("XLM")
16+
expect(id.issuer).toBeNull()
17+
expect(id.verified).toBe(true)
18+
expect(id.canonicalId).toBe(XLM_CANONICAL_ID)
19+
})
20+
21+
it("treats null/undefined assetCode as XLM", () => {
22+
expect(classifyAsset(null, null, config).asset).toBe("XLM")
23+
expect(classifyAsset(undefined, null, config).asset).toBe("XLM")
24+
})
25+
26+
it("is case-insensitive for XLM", () => {
27+
expect(classifyAsset("xlm", null, config).verified).toBe(true)
28+
expect(classifyAsset("Xlm", null, config).canonicalId).toBe(XLM_CANONICAL_ID)
29+
})
30+
})
31+
32+
describe("classifyAsset — canonical platform asset (CMOVE)", () => {
33+
it("returns verified=true when code and issuer both match config", () => {
34+
const id = classifyAsset("CMOVE", CANONICAL_ISSUER, config)
35+
expect(id.asset).toBe("CMOVE")
36+
expect(id.issuer).toBe(CANONICAL_ISSUER)
37+
expect(id.verified).toBe(true)
38+
expect(id.canonicalId).toBe(`CMOVE:${CANONICAL_ISSUER}`)
39+
})
40+
41+
it("is case-insensitive for the asset code", () => {
42+
const id = classifyAsset("cmove", CANONICAL_ISSUER, config)
43+
expect(id.asset).toBe("CMOVE")
44+
expect(id.verified).toBe(true)
45+
})
46+
})
47+
48+
describe("classifyAsset — token spoofing prevention", () => {
49+
it("returns verified=false when code matches but issuer differs (spoof attack)", () => {
50+
const id = classifyAsset("CMOVE", SPOOF_ISSUER, config)
51+
expect(id.asset).toBe("CMOVE")
52+
expect(id.issuer).toBe(SPOOF_ISSUER)
53+
expect(id.verified).toBe(false)
54+
expect(id.canonicalId).toBe(`CMOVE:${SPOOF_ISSUER}`)
55+
})
56+
57+
it("returns verified=false when code matches but issuer is null", () => {
58+
const id = classifyAsset("CMOVE", null, config)
59+
expect(id.verified).toBe(false)
60+
expect(id.canonicalId).toBe("CMOVE:unknown")
61+
})
62+
63+
it("returns verified=false for a completely different asset (USDC)", () => {
64+
const usdcIssuer = "GBBD47IF2H737MZRLT27725J5N5F3GZLU54B7S5XZPZ2GCK4V72UUMOO"
65+
const id = classifyAsset("USDC", usdcIssuer, config)
66+
expect(id.verified).toBe(false)
67+
expect(id.asset).toBe("USDC")
68+
expect(id.issuer).toBe(usdcIssuer)
69+
expect(id.canonicalId).toBe(`USDC:${usdcIssuer}`)
70+
})
71+
72+
it("distinguishes two CMOVE tokens from different issuers by canonicalId", () => {
73+
const real = classifyAsset("CMOVE", CANONICAL_ISSUER, config)
74+
const spoof = classifyAsset("CMOVE", SPOOF_ISSUER, config)
75+
expect(real.canonicalId).not.toBe(spoof.canonicalId)
76+
expect(real.verified).toBe(true)
77+
expect(spoof.verified).toBe(false)
78+
})
79+
})
80+
81+
describe("classifyAsset — missing config values", () => {
82+
it("returns verified=false if config.issuerPublicKey is empty", () => {
83+
const noIssuerConfig = { assetCode: "CMOVE", issuerPublicKey: "" }
84+
const id = classifyAsset("CMOVE", CANONICAL_ISSUER, noIssuerConfig)
85+
expect(id.verified).toBe(false)
86+
})
87+
88+
it("returns verified=false if config.assetCode is empty", () => {
89+
const noCodeConfig = { assetCode: "", issuerPublicKey: CANONICAL_ISSUER }
90+
const id = classifyAsset("CMOVE", CANONICAL_ISSUER, noCodeConfig)
91+
expect(id.verified).toBe(false)
92+
})
93+
94+
it("XLM is still verified even with an empty config", () => {
95+
const emptyConfig = { assetCode: "", issuerPublicKey: "" }
96+
const id = classifyAsset("XLM", null, emptyConfig)
97+
expect(id.verified).toBe(true)
98+
})
99+
})

app/api/stellar/activity/route.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getStellarClient } from "@/lib/stellar/client"
44
import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard"
55
import dbConnect from "@/lib/dbConnect"
66
import StellarIndexedEvent from "@/models/StellarIndexedEvent"
7+
import { classifyAsset } from "@/lib/stellar/asset-identity"
78

89
export async function GET(request: Request) {
910
try {
@@ -29,10 +30,12 @@ export async function GET(request: Request) {
2930
// Return mock balances and activities
3031
const demoAccount = user.stellarPublicKey || "GD3MOCKACCOUNT123456789"
3132

33+
const cmoveIssuer = config.issuerPublicKey || "GCFZPZ2GCK4V72UUMOO000000000000000000000000000000000000000"
34+
const usdcIssuer = "GBBD47IF2H737MZRLT27725J5N5F3GZLU54B7S5XZPZ2GCK4V72UUMOO"
3235
const mockBalances = [
33-
{ asset: "XLM", balance: "10000.00", type: "native" },
34-
{ asset: "USDC", balance: "2450.50", type: "credit", issuer: "GBBD47IF2H737MZRLT27725J5N5F3GZLU54B7S5XZPZ2GCK4V72UUMOO" },
35-
{ asset: "CMOVE", balance: "5000.00", type: "credit", issuer: config.issuerPublicKey || "GCFZPZ2GCK4V72UUMOO000000000000000000000000000000000000000" }
36+
{ ...classifyAsset("XLM", null, config), balance: "10000.00", type: "native" },
37+
{ ...classifyAsset("USDC", usdcIssuer, config), balance: "2450.50", type: "credit" },
38+
{ ...classifyAsset(config.assetCode, cmoveIssuer, config), balance: "5000.00", type: "credit" },
3639
]
3740

3841
const mockActivities = [
@@ -144,10 +147,13 @@ export async function GET(request: Request) {
144147
const client = getStellarClient(config)
145148
const accountInfo = await client.horizon.loadAccount(stellarPublicKey)
146149
balances = accountInfo.balances.map((b: any) => ({
147-
asset: b.asset_type === "native" ? "XLM" : b.asset_code,
150+
...classifyAsset(
151+
b.asset_type === "native" ? "XLM" : b.asset_code,
152+
b.asset_issuer,
153+
config,
154+
),
148155
balance: b.balance,
149156
type: b.asset_type,
150-
issuer: b.asset_issuer || null,
151157
}))
152158
} catch (err: any) {
153159
if (err?.response?.status === 404) {
@@ -201,13 +207,19 @@ export async function GET(request: Request) {
201207
break
202208
}
203209

210+
const rawIssuer = (e.raw?.asset_issuer as string | undefined) ?? null
211+
const assetId = classifyAsset(e.asset, rawIssuer, config)
212+
204213
return {
205214
id: e._id,
206215
chainMoveRecordType: e.chainMoveRecordType,
207216
eventType: e.eventType,
208217
title,
209218
amount: e.amount || "0.00",
210-
asset: e.asset || "XLM",
219+
asset: assetId.asset,
220+
assetIssuer: assetId.issuer,
221+
assetVerified: assetId.verified,
222+
assetCanonicalId: assetId.canonicalId,
211223
date: e.stellarCreatedAt || e.createdAt.toISOString(),
212224
status: "Confirmed",
213225
sourceAccount: e.sourceAccount,

lib/stellar/asset-identity.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { StellarConfig } from "@/lib/stellar/config"
2+
3+
export interface AssetIdentity {
4+
asset: string
5+
issuer: string | null
6+
verified: boolean
7+
/** Globally unique identifier: "<CODE>:<ISSUER>" or "XLM:native" */
8+
canonicalId: string
9+
}
10+
11+
export const XLM_CANONICAL_ID = "XLM:native"
12+
13+
/**
14+
* Returns the canonical identity of a Stellar asset, flagging it as verified
15+
* only when its issuer matches the configured canonical issuer.
16+
*
17+
* On Stellar, asset identity is the (code, issuer) pair — identifying assets
18+
* by code alone enables token spoofing (e.g. an attacker-minted "CMOVE" token
19+
* from a different issuer would appear indistinguishable from the real one).
20+
*/
21+
export function classifyAsset(
22+
assetCode: string | null | undefined,
23+
assetIssuer: string | null | undefined,
24+
config: Pick<StellarConfig, "assetCode" | "issuerPublicKey">,
25+
): AssetIdentity {
26+
if (!assetCode || assetCode.toUpperCase() === "XLM") {
27+
return {
28+
asset: "XLM",
29+
issuer: null,
30+
verified: true,
31+
canonicalId: XLM_CANONICAL_ID,
32+
}
33+
}
34+
35+
const code = assetCode.toUpperCase()
36+
const issuer = assetIssuer ?? null
37+
const canonicalCode = config.assetCode?.toUpperCase() ?? ""
38+
39+
const verified =
40+
code === canonicalCode &&
41+
!!issuer &&
42+
!!config.issuerPublicKey &&
43+
issuer === config.issuerPublicKey
44+
45+
return {
46+
asset: code,
47+
issuer,
48+
verified,
49+
canonicalId: issuer ? `${code}:${issuer}` : `${code}:unknown`,
50+
}
51+
}

0 commit comments

Comments
 (0)