From 1a5a61ed2a15586f80ee689ac05ecfeebb076d3e Mon Sep 17 00:00:00 2001 From: stableprogrammer Date: Mon, 24 Aug 2026 01:28:23 +0100 Subject: [PATCH] feat: identify Stellar assets by (code, issuer) pair to prevent token spoofing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #202 — asset_code alone is insufficient to identify a Stellar asset; an attacker can mint a token named CMOVE or USDC under a different issuer. - Add lib/stellar/asset-identity.ts with classifyAsset() returning asset, issuer, verified, and canonicalId fields. verified=true only when both the asset code AND issuer match the configured canonical issuer (or native XLM). - Update app/api/stellar/activity/route.ts to use classifyAsset() for live Horizon balances and indexed activity events. Live balances now carry verified and canonicalId. Activity entries now include assetIssuer, assetVerified, and assetCanonicalId pulled from raw Horizon payload. - Add __tests__/lib/stellar/asset-identity.test.ts covering native XLM, canonical asset, spoofed same-code token from different issuer, missing issuer, and missing/empty config values. - Update __tests__/api/stellar/activity.test.ts to assert the new fields. --- __tests__/api/stellar/activity.test.ts | 4 +- __tests__/lib/stellar/asset-identity.test.ts | 99 ++++++++++++++++++++ app/api/stellar/activity/route.ts | 24 +++-- lib/stellar/asset-identity.ts | 51 ++++++++++ 4 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 __tests__/lib/stellar/asset-identity.test.ts create mode 100644 lib/stellar/asset-identity.ts diff --git a/__tests__/api/stellar/activity.test.ts b/__tests__/api/stellar/activity.test.ts index 4fd89504..798f1dac 100644 --- a/__tests__/api/stellar/activity.test.ts +++ b/__tests__/api/stellar/activity.test.ts @@ -142,8 +142,8 @@ describe("GET /api/stellar/activity", () => { expect(response.status).toBe(200) expect(loadAccount).toHaveBeenCalledWith(user.stellarPublicKey) expect(payload.balances).toEqual([ - { asset: "XLM", balance: "150.00", type: "native", issuer: null }, - { asset: "USDC", balance: "20.00", type: "credit_alphanum4", issuer: "GDUSDC" }, + { asset: "XLM", issuer: null, verified: true, canonicalId: "XLM:native", balance: "150.00", type: "native" }, + { asset: "USDC", issuer: "GDUSDC", verified: false, canonicalId: "USDC:GDUSDC", balance: "20.00", type: "credit_alphanum4" }, ]) expect(payload.activities[0].id).toBe("tx-1") expect(payload.activities[0].title).toBe("Repayment Settlement") diff --git a/__tests__/lib/stellar/asset-identity.test.ts b/__tests__/lib/stellar/asset-identity.test.ts new file mode 100644 index 00000000..e351fcfa --- /dev/null +++ b/__tests__/lib/stellar/asset-identity.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest" +import { classifyAsset, XLM_CANONICAL_ID } from "@/lib/stellar/asset-identity" + +const CANONICAL_ISSUER = "GDMXNQBJMS3FYI4PFSYCCB4XODQMNMTKFFUALHGTP3LYUHDQR55NGMH" +const SPOOF_ISSUER = "GBSPOOFERXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + +const config = { + assetCode: "CMOVE", + issuerPublicKey: CANONICAL_ISSUER, +} + +describe("classifyAsset — native XLM", () => { + it('returns verified=true and canonicalId="XLM:native" for native XLM', () => { + const id = classifyAsset("XLM", null, config) + expect(id.asset).toBe("XLM") + expect(id.issuer).toBeNull() + expect(id.verified).toBe(true) + expect(id.canonicalId).toBe(XLM_CANONICAL_ID) + }) + + it("treats null/undefined assetCode as XLM", () => { + expect(classifyAsset(null, null, config).asset).toBe("XLM") + expect(classifyAsset(undefined, null, config).asset).toBe("XLM") + }) + + it("is case-insensitive for XLM", () => { + expect(classifyAsset("xlm", null, config).verified).toBe(true) + expect(classifyAsset("Xlm", null, config).canonicalId).toBe(XLM_CANONICAL_ID) + }) +}) + +describe("classifyAsset — canonical platform asset (CMOVE)", () => { + it("returns verified=true when code and issuer both match config", () => { + const id = classifyAsset("CMOVE", CANONICAL_ISSUER, config) + expect(id.asset).toBe("CMOVE") + expect(id.issuer).toBe(CANONICAL_ISSUER) + expect(id.verified).toBe(true) + expect(id.canonicalId).toBe(`CMOVE:${CANONICAL_ISSUER}`) + }) + + it("is case-insensitive for the asset code", () => { + const id = classifyAsset("cmove", CANONICAL_ISSUER, config) + expect(id.asset).toBe("CMOVE") + expect(id.verified).toBe(true) + }) +}) + +describe("classifyAsset — token spoofing prevention", () => { + it("returns verified=false when code matches but issuer differs (spoof attack)", () => { + const id = classifyAsset("CMOVE", SPOOF_ISSUER, config) + expect(id.asset).toBe("CMOVE") + expect(id.issuer).toBe(SPOOF_ISSUER) + expect(id.verified).toBe(false) + expect(id.canonicalId).toBe(`CMOVE:${SPOOF_ISSUER}`) + }) + + it("returns verified=false when code matches but issuer is null", () => { + const id = classifyAsset("CMOVE", null, config) + expect(id.verified).toBe(false) + expect(id.canonicalId).toBe("CMOVE:unknown") + }) + + it("returns verified=false for a completely different asset (USDC)", () => { + const usdcIssuer = "GBBD47IF2H737MZRLT27725J5N5F3GZLU54B7S5XZPZ2GCK4V72UUMOO" + const id = classifyAsset("USDC", usdcIssuer, config) + expect(id.verified).toBe(false) + expect(id.asset).toBe("USDC") + expect(id.issuer).toBe(usdcIssuer) + expect(id.canonicalId).toBe(`USDC:${usdcIssuer}`) + }) + + it("distinguishes two CMOVE tokens from different issuers by canonicalId", () => { + const real = classifyAsset("CMOVE", CANONICAL_ISSUER, config) + const spoof = classifyAsset("CMOVE", SPOOF_ISSUER, config) + expect(real.canonicalId).not.toBe(spoof.canonicalId) + expect(real.verified).toBe(true) + expect(spoof.verified).toBe(false) + }) +}) + +describe("classifyAsset — missing config values", () => { + it("returns verified=false if config.issuerPublicKey is empty", () => { + const noIssuerConfig = { assetCode: "CMOVE", issuerPublicKey: "" } + const id = classifyAsset("CMOVE", CANONICAL_ISSUER, noIssuerConfig) + expect(id.verified).toBe(false) + }) + + it("returns verified=false if config.assetCode is empty", () => { + const noCodeConfig = { assetCode: "", issuerPublicKey: CANONICAL_ISSUER } + const id = classifyAsset("CMOVE", CANONICAL_ISSUER, noCodeConfig) + expect(id.verified).toBe(false) + }) + + it("XLM is still verified even with an empty config", () => { + const emptyConfig = { assetCode: "", issuerPublicKey: "" } + const id = classifyAsset("XLM", null, emptyConfig) + expect(id.verified).toBe(true) + }) +}) diff --git a/app/api/stellar/activity/route.ts b/app/api/stellar/activity/route.ts index 13cfeea2..06b18f4f 100644 --- a/app/api/stellar/activity/route.ts +++ b/app/api/stellar/activity/route.ts @@ -4,6 +4,7 @@ import { getStellarClient } from "@/lib/stellar/client" import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard" import dbConnect from "@/lib/dbConnect" import StellarIndexedEvent from "@/models/StellarIndexedEvent" +import { classifyAsset } from "@/lib/stellar/asset-identity" export async function GET(request: Request) { try { @@ -29,10 +30,12 @@ export async function GET(request: Request) { // Return mock balances and activities const demoAccount = user.stellarPublicKey || "GD3MOCKACCOUNT123456789" + const cmoveIssuer = config.issuerPublicKey || "GCFZPZ2GCK4V72UUMOO000000000000000000000000000000000000000" + const usdcIssuer = "GBBD47IF2H737MZRLT27725J5N5F3GZLU54B7S5XZPZ2GCK4V72UUMOO" const mockBalances = [ - { asset: "XLM", balance: "10000.00", type: "native" }, - { asset: "USDC", balance: "2450.50", type: "credit", issuer: "GBBD47IF2H737MZRLT27725J5N5F3GZLU54B7S5XZPZ2GCK4V72UUMOO" }, - { asset: "CMOVE", balance: "5000.00", type: "credit", issuer: config.issuerPublicKey || "GCFZPZ2GCK4V72UUMOO000000000000000000000000000000000000000" } + { ...classifyAsset("XLM", null, config), balance: "10000.00", type: "native" }, + { ...classifyAsset("USDC", usdcIssuer, config), balance: "2450.50", type: "credit" }, + { ...classifyAsset(config.assetCode, cmoveIssuer, config), balance: "5000.00", type: "credit" }, ] const mockActivities = [ @@ -144,10 +147,13 @@ export async function GET(request: Request) { const client = getStellarClient(config) const accountInfo = await client.horizon.loadAccount(stellarPublicKey) balances = accountInfo.balances.map((b: any) => ({ - asset: b.asset_type === "native" ? "XLM" : b.asset_code, + ...classifyAsset( + b.asset_type === "native" ? "XLM" : b.asset_code, + b.asset_issuer, + config, + ), balance: b.balance, type: b.asset_type, - issuer: b.asset_issuer || null, })) } catch (err: any) { if (err?.response?.status === 404) { @@ -201,13 +207,19 @@ export async function GET(request: Request) { break } + const rawIssuer = (e.raw?.asset_issuer as string | undefined) ?? null + const assetId = classifyAsset(e.asset, rawIssuer, config) + return { id: e._id, chainMoveRecordType: e.chainMoveRecordType, eventType: e.eventType, title, amount: e.amount || "0.00", - asset: e.asset || "XLM", + asset: assetId.asset, + assetIssuer: assetId.issuer, + assetVerified: assetId.verified, + assetCanonicalId: assetId.canonicalId, date: e.stellarCreatedAt || e.createdAt.toISOString(), status: "Confirmed", sourceAccount: e.sourceAccount, diff --git a/lib/stellar/asset-identity.ts b/lib/stellar/asset-identity.ts new file mode 100644 index 00000000..e12ab247 --- /dev/null +++ b/lib/stellar/asset-identity.ts @@ -0,0 +1,51 @@ +import type { StellarConfig } from "@/lib/stellar/config" + +export interface AssetIdentity { + asset: string + issuer: string | null + verified: boolean + /** Globally unique identifier: ":" or "XLM:native" */ + canonicalId: string +} + +export const XLM_CANONICAL_ID = "XLM:native" + +/** + * Returns the canonical identity of a Stellar asset, flagging it as verified + * only when its issuer matches the configured canonical issuer. + * + * On Stellar, asset identity is the (code, issuer) pair — identifying assets + * by code alone enables token spoofing (e.g. an attacker-minted "CMOVE" token + * from a different issuer would appear indistinguishable from the real one). + */ +export function classifyAsset( + assetCode: string | null | undefined, + assetIssuer: string | null | undefined, + config: Pick, +): AssetIdentity { + if (!assetCode || assetCode.toUpperCase() === "XLM") { + return { + asset: "XLM", + issuer: null, + verified: true, + canonicalId: XLM_CANONICAL_ID, + } + } + + const code = assetCode.toUpperCase() + const issuer = assetIssuer ?? null + const canonicalCode = config.assetCode?.toUpperCase() ?? "" + + const verified = + code === canonicalCode && + !!issuer && + !!config.issuerPublicKey && + issuer === config.issuerPublicKey + + return { + asset: code, + issuer, + verified, + canonicalId: issuer ? `${code}:${issuer}` : `${code}:unknown`, + } +}