From 112494ba36ef12fac1eece1c915412a052ef71fe Mon Sep 17 00:00:00 2001 From: Anambraboi-1 <262577401+Anambraboi-1@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:44:14 +0100 Subject: [PATCH] fix(#821): agent matches balances by issuer, never by code getBalances() keyed trustlines as CODE:ISSUER but nothing told the model which were genuine, so a counterfeit USDT0 read the same as the real one. The agent had no asset registry, so the isRegisteredIssuer gate the issue describes did not exist yet. - src/assetRegistry.ts: issuers pinned per network (mainnet USDC, USDY, USDT0; testnet USDC), mirroring frontend/mobile/lib/assets.ts, with getRegisteredAsset, isRegisteredIssuer (code AND issuer) and classifyHolding. - getBalances() returns { balances, holdings }. Balances stay keyed CODE:ISSUER, never by bare code; each trustline is classified by issuer. A verified holding names its issuer; anything else is UNVERIFIED with its issuer shown (and, for a fake of a registered code, the real issuer). Liquidity-pool shares are skipped instead of emitting undefined:undefined. - The balance tool description and a system-prompt rule tell the model to relay those notes and never call an unverified holding by a registered name. Closes #821 --- .../agent/src/__tests__/assetRegistry.test.ts | 59 ++++++++++ .../agent/src/__tests__/txBuilder.test.ts | 55 ++++++++- packages/agent/src/agent.ts | 7 +- packages/agent/src/assetRegistry.ts | 107 ++++++++++++++++++ packages/agent/src/txBuilder.ts | 26 ++++- 5 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 packages/agent/src/__tests__/assetRegistry.test.ts create mode 100644 packages/agent/src/assetRegistry.ts diff --git a/packages/agent/src/__tests__/assetRegistry.test.ts b/packages/agent/src/__tests__/assetRegistry.test.ts new file mode 100644 index 00000000..5a387208 --- /dev/null +++ b/packages/agent/src/__tests__/assetRegistry.test.ts @@ -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) + }) +}) diff --git a/packages/agent/src/__tests__/txBuilder.test.ts b/packages/agent/src/__tests__/txBuilder.test.ts index aa2113ff..cd84ea5d 100644 --- a/packages/agent/src/__tests__/txBuilder.test.ts +++ b/packages/agent/src/__tests__/txBuilder.test.ts @@ -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 ─────────────────────────────────────────────────────────────────── @@ -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) + }) +}) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 35d46a69..efcd8471 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -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: { @@ -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.` } /** diff --git a/packages/agent/src/assetRegistry.ts b/packages/agent/src/assetRegistry.ts new file mode 100644 index 00000000..8f06a3b5 --- /dev/null +++ b/packages/agent/src/assetRegistry.ts @@ -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> = { + 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.`, + } +} diff --git a/packages/agent/src/txBuilder.ts b/packages/agent/src/txBuilder.ts index b519ac64..932b99d0 100644 --- a/packages/agent/src/txBuilder.ts +++ b/packages/agent/src/txBuilder.ts @@ -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. @@ -145,13 +146,24 @@ export async function buildPayment(input: PaymentInput): Promise { * 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 + holdings: Holding[] +} + export async function getBalances( feePayerAddress: string, contractAddress?: string, -): Promise> { +): Promise { const result: Record = {} + const holdings: Holding[] = [] // ── 1. Fee-payer G... account via Horizon ──────────────────────────────── const account = await horizon.loadAccount(feePayerAddress) @@ -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)) } } @@ -192,5 +208,5 @@ export async function getBalances( // ── 3. Combined XLM total ───────────────────────────────────────────────── result['XLM'] = (feePayerXlm + contractXlm).toFixed(7) - return result + return { balances: result, holdings } }