Skip to content
Open
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
59 changes: 59 additions & 0 deletions packages/agent/src/__tests__/assetRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from '@jest/globals'
import { StrKey } from '@stellar/stellar-sdk'

import {
ASSET_REGISTRY,
USDC_MAINNET_ISSUER,
USDT0_MAINNET_ISSUER,
classifyHolding,
getRegisteredAsset,
isRegisteredIssuer,
} from '../assetRegistry.js'

/** A well-formed G… that no registry entry uses: an impostor issuer. */
const FAKE_ISSUER = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 0x21))

describe('asset registry (#821)', () => {
it('pins only well-formed issuers', () => {
for (const entries of Object.values(ASSET_REGISTRY)) {
for (const asset of Object.values(entries)) {
expect(StrKey.isValidEd25519PublicKey(asset.issuer)).toBe(true)
}
}
})

it('matches on issuer, not on code', () => {
expect(isRegisteredIssuer('USDT0', USDT0_MAINNET_ISSUER, 'mainnet')).toBe(true)
expect(isRegisteredIssuer('USDT0', FAKE_ISSUER, 'mainnet')).toBe(false)
expect(isRegisteredIssuer('USDC', USDT0_MAINNET_ISSUER, 'mainnet')).toBe(false)
})

it('has no testnet USDT0, so no testnet issuer can be verified as it', () => {
expect(getRegisteredAsset('USDT0', 'testnet')).toBeNull()
expect(isRegisteredIssuer('USDT0', USDT0_MAINNET_ISSUER, 'testnet')).toBe(false)
})
})

describe('classifyHolding', () => {
it('names the issuer when reporting a registered asset', () => {
const h = classifyHolding('USDC', USDC_MAINNET_ISSUER, '12.5', 'mainnet')
expect(h).toMatchObject({ verified: true, issuer: USDC_MAINNET_ISSUER, issuerName: 'Circle', name: 'USD Coin' })
expect(h.note).toContain(USDC_MAINNET_ISSUER)
})

it('reports a counterfeit of a registered code as unverified, with both issuers', () => {
const h = classifyHolding('USDC', FAKE_ISSUER, '1000', 'mainnet')
expect(h.verified).toBe(false)
expect(h.name).toBeUndefined()
expect(h.note).toMatch(/^UNVERIFIED/)
expect(h.note).toContain(FAKE_ISSUER)
expect(h.note).toContain(USDC_MAINNET_ISSUER)
})

it('reports an unlisted asset as unverified, with its issuer', () => {
const h = classifyHolding('AQUA', FAKE_ISSUER, '3', 'mainnet')
expect(h.verified).toBe(false)
expect(h.note).toMatch(/^UNVERIFIED/)
expect(h.note).toContain(FAKE_ISSUER)
})
})
55 changes: 54 additions & 1 deletion packages/agent/src/__tests__/txBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,12 @@ jest.unstable_mockModule('@stellar/stellar-sdk', () => {
}
})

// The registry is per network; pin the one these tests assert against.
process.env.STELLAR_NETWORK = 'mainnet'

// Dynamic import AFTER mock registration so txBuilder receives the mock
const { buildPayment, buildSwap } = await import('../txBuilder.js')
const { buildPayment, buildSwap, getBalances } = await import('../txBuilder.js')
const { USDT0_MAINNET_ISSUER } = await import('../assetRegistry.js')
import type { PaymentInput, SwapInput } from '../txBuilder.js'

// ── Helpers ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -245,3 +249,52 @@ describe('buildSwap', () => {
await expect(buildSwap(input)).rejects.toThrow('Account not found')
})
})

// ── getBalances (#821) ────────────────────────────────────────────────────────

describe('getBalances — assets are matched by issuer, never by code', () => {
/** A well-formed G… that is not the registered USDT0 issuer: an impostor. */
const FAKE_ISSUER = 'GAQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCB5Q'

beforeEach(() => {
jest.clearAllMocks()
mockLoadAccount.mockResolvedValue(
makeAccount([
{ asset_type: 'native', balance: '10.0000000' },
{ asset_type: 'credit_alphanum12', asset_code: 'USDT0', asset_issuer: USDT0_MAINNET_ISSUER, balance: '25.0000000' },
{ asset_type: 'credit_alphanum12', asset_code: 'USDT0', asset_issuer: FAKE_ISSUER, balance: '5000.0000000' },
{ asset_type: 'liquidity_pool_shares', balance: '1.0000000' },
]),
)
})

it('never aliases either trustline under the bare registry code', async () => {
const { balances } = await getBalances('GFEEPAYER')
expect(balances).not.toHaveProperty('USDT0')
expect(balances[`USDT0:${USDT0_MAINNET_ISSUER}`]).toBe('25.0000000')
expect(balances[`USDT0:${FAKE_ISSUER}`]).toBe('5000.0000000')
})

it('reports the real holding as verified, naming its issuer', async () => {
const { holdings } = await getBalances('GFEEPAYER')
const real = holdings.find((h) => h.issuer === USDT0_MAINNET_ISSUER)
expect(real).toMatchObject({ code: 'USDT0', balance: '25.0000000', verified: true, issuerName: 'Tether' })
expect(real?.note).toContain(USDT0_MAINNET_ISSUER)
})

it('reports the counterfeit as unverified, with its issuer, and not under the registry label', async () => {
const { holdings } = await getBalances('GFEEPAYER')
const fake = holdings.find((h) => h.issuer === FAKE_ISSUER)
expect(fake).toMatchObject({ code: 'USDT0', balance: '5000.0000000', verified: false })
expect(fake?.name).toBeUndefined()
expect(fake?.issuerName).toBeUndefined()
expect(fake?.note).toMatch(/^UNVERIFIED/)
expect(fake?.note).toContain(FAKE_ISSUER)
})

it('skips liquidity-pool shares, which are not holdings of an asset', async () => {
const { balances, holdings } = await getBalances('GFEEPAYER')
expect(holdings).toHaveLength(2)
expect(Object.keys(balances).some((k) => k.includes('undefined'))).toBe(false)
})
})
7 changes: 5 additions & 2 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ const tools: ToolSpec[] = [
},
{
name: 'get_wallet_balance',
description: 'Get current XLM and token balances for a wallet address. Free.',
description:
'Get current XLM and token balances for a wallet address. Free. Token holdings are checked by code AND issuer: ' +
'each has verified, issuer and a note to relay. An unverified holding is not the registered asset of that code.',
input_schema: {
type: 'object' as const,
properties: {
Expand Down Expand Up @@ -243,7 +245,8 @@ RULES:
5. Format amounts clearly: "500 XLM", "47.3 USDC".
6. If you need a recipient address and the user hasn't provided one, ask before building.
7. Keep responses concise. Use bullet points for multi-step flows.
8. Always use the fee-payer address (not the contract address) as wallet_address when calling build_payment.`
8. Always use the fee-payer address (not the contract address) as wallet_address when calling build_payment.
9. Report token balances from get_wallet_balance's holdings, relaying each holding's note. Always name the issuer. A holding with verified: false is UNVERIFIED — say so, show its issuer, and never call it by a registered asset's name just because the code matches.`
}

/**
Expand Down
107 changes: 107 additions & 0 deletions packages/agent/src/assetRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { NETWORK, type StellarNetwork } from './network.js'

/**
* The assets the agent will call by name, pinned by ISSUER (#821).
*
* An asset code is not an identity: anyone can issue an asset called USDC or
* USDT0, and eight issuers publish a USDT0 on mainnet. A trustline whose code
* matches an entry here but whose issuer does not is the standard impostor
* vector, so nothing in the agent may report a holding under a registry label
* unless {@link isRegisteredIssuer} says the issuer matches too.
*
* Mirrors `frontend/mobile/lib/assets.ts`. Every issuer is checked with
* `StrKey.isValidEd25519PublicKey` in `__tests__/assetRegistry.test.ts`.
*/
export interface RegisteredAsset {
code: string
issuer: string
name: string
issuerName: string
}

export const USDC_MAINNET_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'
export const USDC_TESTNET_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'
export const USDY_MAINNET_ISSUER = 'GAJMPX5NBOG6TQFPQGRABJEEB2YE7RFRLUKJDZAZGAD5GFX4J7TADAZ6'
export const USDT0_MAINNET_ISSUER = 'GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q'

export const ASSET_REGISTRY: Record<StellarNetwork, Record<string, RegisteredAsset>> = {
mainnet: {
USDC: { code: 'USDC', issuer: USDC_MAINNET_ISSUER, name: 'USD Coin', issuerName: 'Circle' },
USDY: { code: 'USDY', issuer: USDY_MAINNET_ISSUER, name: 'Ondo US Dollar Yield', issuerName: 'Ondo Finance' },
USDT0: { code: 'USDT0', issuer: USDT0_MAINNET_ISSUER, name: 'Tether USD', issuerName: 'Tether' },
},
// USDY and USDT0 have no testnet issuer: any testnet holding of those codes is unverified.
testnet: {
USDC: { code: 'USDC', issuer: USDC_TESTNET_ISSUER, name: 'USD Coin', issuerName: 'Circle' },
},
}

/**
* The registry entry for `code` on `network`, looked up by code alone. Use it
* only to say what the real asset is — never to label a holding; for that the
* issuer has to match, via {@link isRegisteredIssuer}.
*/
export function getRegisteredAsset(code: string, network: StellarNetwork = NETWORK): RegisteredAsset | null {
return ASSET_REGISTRY[network][code.toUpperCase()] ?? null
}

/** Whether `issuer` is the registered issuer of `code` on `network`. */
export function isRegisteredIssuer(code: string, issuer: string, network: StellarNetwork = NETWORK): boolean {
const entry = getRegisteredAsset(code, network)
return entry !== null && entry.issuer === issuer
}

/** One issued-asset trustline, as the agent reports it. */
export interface Holding {
code: string
/** Always present: the issuer is what identifies the asset. */
issuer: string
balance: string
/** True only when code AND issuer match a registry entry. */
verified: boolean
/** Registry name, set only for a verified holding. */
name?: string
/** Registry issuer name, set only for a verified holding. */
issuerName?: string
/** The line the agent should relay, naming the issuer either way. */
note: string
}

/** Classify one trustline. Never matches on code alone. */
export function classifyHolding(
code: string,
issuer: string,
balance: string,
network: StellarNetwork = NETWORK,
): Holding {
const entry = getRegisteredAsset(code, network)
if (entry && isRegisteredIssuer(code, issuer, network)) {
return {
code,
issuer,
balance,
verified: true,
name: entry.name,
issuerName: entry.issuerName,
note: `${balance} ${entry.code} (${entry.name}), verified: issued by ${entry.issuerName}, ${issuer}.`,
}
}
if (entry) {
return {
code,
issuer,
balance,
verified: false,
note:
`UNVERIFIED: ${balance} of an asset called ${code} issued by ${issuer}. ` +
`This is not ${entry.name} — the registered ${entry.code} issuer is ${entry.issuer}. Do not call it ${entry.code}.`,
}
}
return {
code,
issuer,
balance,
verified: false,
note: `UNVERIFIED: ${balance} ${code} issued by ${issuer}. This asset is not in Veil's registry.`,
}
}
26 changes: 21 additions & 5 deletions packages/agent/src/txBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
scValToNative,
} from '@stellar/stellar-sdk'
import { HORIZON_URL, NETWORK_PASSPHRASE, SOROBAN_RPC_URL } from './network.js'
import { classifyHolding, type Holding } from './assetRegistry.js'

// Network and endpoints come from one place (network.ts). Deciding them here
// separately is how mainnet signing ended up paired with testnet Horizon.
Expand Down Expand Up @@ -145,13 +146,24 @@ export async function buildPayment(input: PaymentInput): Promise<string> {
* XLM_contract — native XLM held in the smart wallet contract
* XLM_feepayer — native XLM in the fee-payer classic account
* XLM — combined total
* plus any token balances (e.g. USDC:ISSUER)
* plus any token balances, keyed CODE:ISSUER — never by bare code
*
* and `holdings`: every trustline checked against the asset registry by code
* AND issuer (#821). A trustline whose code matches a registered asset but
* whose issuer does not is an impostor; it is reported as unverified, with its
* issuer, and never under the registry's label.
*/
export interface WalletBalances {
balances: Record<string, string>
holdings: Holding[]
}

export async function getBalances(
feePayerAddress: string,
contractAddress?: string,
): Promise<Record<string, string>> {
): Promise<WalletBalances> {
const result: Record<string, string> = {}
const holdings: Holding[] = []

// ── 1. Fee-payer G... account via Horizon ────────────────────────────────
const account = await horizon.loadAccount(feePayerAddress)
Expand All @@ -161,8 +173,12 @@ export async function getBalances(
feePayerXlm = parseFloat(balance.balance)
result['XLM_feepayer'] = balance.balance
} else {
const key = `${(balance as any).asset_code}:${(balance as any).asset_issuer}`
result[key] = balance.balance
const code: unknown = (balance as any).asset_code
const issuer: unknown = (balance as any).asset_issuer
// Liquidity-pool shares carry neither; they are not holdings of an asset.
if (typeof code !== 'string' || typeof issuer !== 'string') continue
result[`${code}:${issuer}`] = balance.balance
holdings.push(classifyHolding(code, issuer, balance.balance))
}
}

Expand Down Expand Up @@ -192,5 +208,5 @@ export async function getBalances(
// ── 3. Combined XLM total ─────────────────────────────────────────────────
result['XLM'] = (feePayerXlm + contractXlm).toFixed(7)

return result
return { balances: result, holdings }
}
Loading