Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions __tests__/api/stellar/activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
99 changes: 99 additions & 0 deletions __tests__/lib/stellar/asset-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
24 changes: 18 additions & 6 deletions app/api/stellar/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = [
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions lib/stellar/asset-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { StellarConfig } from "@/lib/stellar/config"

export interface AssetIdentity {
asset: string
issuer: string | null
verified: boolean
/** Globally unique identifier: "<CODE>:<ISSUER>" 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<StellarConfig, "assetCode" | "issuerPublicKey">,
): 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`,
}
}
Loading