SPB Git

Repository Public

swap-sol.test.ts 9.3 KB 105 lines typescript

1// The signer and a Jupiter swap on Solana: recorded mainnet transactions re-addressed to the test key. No network.2import { createHash } from 'node:crypto';3import { mkdtempSync } from 'node:fs';4import { tmpdir } from 'node:os';5import { join } from 'node:path';6import { describe, expect, it } from 'vitest';7import { ed25519 } from '@noble/curves/ed25519.js';8import { base58 } from '@scure/base';9import { compileSolV1Message, decodeSolV1Message, DEFAULT_POLICY, intentHash, JUPITER_PROGRAM, solMessageHash, verifyAuditChain, type Intent } from '@spw/core';10import { ixInputs, jupFixture, readdress, solAta } from '../../../tests/helpers/jupiter-fixtures.ts';11import { TestPasskey } from '../../../tests/helpers/passkey.ts';12import { deriveKeys } from '../src/keys.ts';13import { Signer, SignerError } from '../src/signer.ts';14import { Store } from '../src/store.ts';1516const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'); // public test vector, holds nothing17const ME = keys.addresses.solana!, RP = 'wallet.test', ORIGIN = 'https://wallet.test';18function setup(now: number) {19  const dir = mkdtempSync(join(tmpdir(), 'spw-signer-jup-'));20  const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));21  store.state.setupTokenHash = createHash('sha256').update('t').digest('hex');22  const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => now), pk = new TestPasskey(RP, ORIGIN);23  return { store, signer, pk, ready: signer.enroll({ credential: pk.credential(), setupToken: 't' }) };24}25let seq = 0;26const fresh = (name: string, o: Partial<Intent> = {}) => { const f = jupFixture(name); return { f, ...readdress(f, ME, keys.keyId, { proposalId: `7a1c1f0e-8f5f-4b1e-9c56-${String(++seq).padStart(12, '0')}`, ...o }) }; };27const code = async (p: Promise<unknown>) => p.then(() => 'signed', (e: SignerError) => `${e.code}: ${e.message}`);2829describe('signing a Jupiter swap', () => {30  it.each(['usdc-bonk', 'usdc-sol', 'sol-usdc', 'usdc-pyusd', 'usdc-jup'])('%s: decodes, checks and signs the exact approved bytes with the wallet key', async (name) => {31    const { intent, message, msg } = fresh(name), s = setup(intent.createdAt + 5); await s.ready;32    const r = await s.signer.sign(intent, s.pk.assert(intentHash(intent)), { swapMessage: message });33    const wire = Buffer.from(r.raw, 'base64'), sig = wire.subarray(wire.length - 64);34    expect(wire.subarray(0, wire.length - 64).equals(Buffer.from(msg))).toBe(true); // v1 wire format: message, then the signature35    expect(ed25519.verify(sig, msg, base58.decode(ME))).toBe(true);36    expect(r.txid).toBe(base58.encode(sig)); expect(r.hash).toBe(Buffer.from(intentHash(intent)).toString('hex')); expect(r.pre).toBeUndefined();37    const last = s.store.state.journal.at(-1)!;38    expect(last.type).toBe('sign.ok'); expect(last.data).toMatchObject({ swap: 'jupiter', toAsset: intent.swap!.toAsset, minOut: intent.swap!.minOut, amount: intent.amount, network: 'solana' });39    expect(verifyAuditChain(s.store.state.journal)).toBeNull(); // no broken link40    // counted against the daily limit like a send, and never signed twice41    expect(s.store.state.spent[`${new Date((intent.createdAt + 5) * 1000).toISOString().slice(0, 10)}|solana:${intent.symbol}`]).toBe(intent.amount);42    expect(await code(s.signer.sign(intent, s.pk.assert(intentHash(intent)), { swapMessage: message }))).toMatch(/^replay/);43  });4445  it('refuses without the message, with another message, and with a message for another wallet', async () => {46    const { intent, message, f } = fresh('usdc-bonk'), s = setup(intent.createdAt + 5); await s.ready;47    const ok = () => s.pk.assert(intentHash(intent));48    expect(await code(s.signer.sign(intent, ok()))).toMatch(/^unsupported: swap message is not base64/);49    expect(await code(s.signer.sign(intent, ok(), { swapMessage: fresh('usdc-jup').message }))).toMatch(/^unsupported: message does not match the approved hash/);50    expect(await code(s.signer.sign(intent, ok(), { swapData: message }))).toMatch(/^unsupported: swap message is not base64/); // the EVM helper name is not read51    // the recorded transaction itself (for the public address it was built for): its payer is not this signer's wallet52    const foreign = { ...f.intent, keyId: keys.keyId, proposalId: intent.proposalId, policyVersion: intent.policyVersion }; // the recording, under the policy version in force today53    expect(await code(s.signer.sign(foreign, s.pk.assert(intentHash(foreign)), { swapMessage: f.message }))).toMatch(/^wrong_source/);54    const mine = { ...foreign, from: ME, to: ME }; // intent claims the wallet, message still pays from the other address55    expect(await code(s.signer.sign(mine, s.pk.assert(intentHash(mine)), { swapMessage: f.message }))).toMatch(/^unsupported: fee payer is not the wallet/);56    expect(await code(s.signer.sign({ ...intent, to: f.wallet }, ok(), { swapMessage: message }))).toMatch(/^self_send: a swap must deliver to the wallet itself/);57    expect(s.store.state.journal.filter((j) => j.type === 'sign.ok')).toHaveLength(0);58    expect(await code(s.signer.sign(intent, ok(), { swapMessage: message }))).toBe('signed'); // none of the refusals burned the proposal59  });6061  it('a malicious API cannot get a mutated transaction signed, whatever intent it presents', async () => {62    const { intent, msg } = fresh('usdc-bonk'), s = setup(intent.createdAt + 5); await s.ready;63    const m = decodeSolV1Message(msg), third = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM';64    const tries: [string, (x: ReturnType<typeof ixInputs>, k: number) => void, RegExp][] = [65      ['destination', (x, k) => { x[k]!.accounts[5]!.pubkey = solAta(third, 'BONK'); }, /destination token account is not the wallet's own/],66      ['minimum', (x, k) => { const d = Buffer.from(x[k]!.data); d.writeUInt16LE(900, 25); x[k]!.data = new Uint8Array(d); }, /less than the approved minimum/],67      ['fee', (x, k) => { const d = Buffer.from(x[k]!.data); d.writeUInt16LE(100, 27); x[k]!.data = new Uint8Array(d); }, /platform fee/],68      ['transfer', (x) => { x.push({ program: '11111111111111111111111111111111', accounts: [{ pubkey: ME, signer: true, writable: true }, { pubkey: third, signer: false, writable: true }], data: new Uint8Array([2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]) }); }, /unexpected program/],69      ['wallet in the route', (x, k) => { x[k]!.accounts.push({ pubkey: ME, signer: true, writable: false }); }, /reaches into the wallet's accounts/],70    ];71    for (const [, mut, why] of tries) {72      const x = ixInputs(m), k = x.findIndex((q) => q.program === JUPITER_PROGRAM); mut(x, k);73      const bad = compileSolV1Message({ payer: ME, blockhash: m.blockhash, instructions: x, computeUnitLimit: m.computeUnitLimit, priorityFeeLamports: m.priorityFeeLamports, loadedAccountsDataSizeLimit: m.loadedAccountsDataSizeLimit });74      const i2: Intent = { ...intent, proposalId: `7a1c1f0e-8f5f-4b1e-9c56-${String(++seq).padStart(12, '0')}`, swap: { ...intent.swap!, dataHash: solMessageHash(bad) } };75      expect(await code(s.signer.sign(i2, s.pk.assert(intentHash(i2)), { swapMessage: Buffer.from(bad).toString('base64') }))).toMatch(why);76    }77    expect(s.store.state.journal.filter((j) => j.type === 'sign.denied')).toHaveLength(tries.length);78    expect(Object.keys(s.store.state.spent)).toHaveLength(0);79  });8081  it('policy, approval and halt apply as for a send', async () => {82    const { intent, message } = fresh('usdc-bonk'), s = setup(intent.createdAt + 5); await s.ready;83    const sign = (i: Intent, a = s.pk.assert(intentHash(i))) => code(s.signer.sign(i, a, { swapMessage: message }));84    expect(await sign({ ...intent, amount: '60000000' })).toMatch(/^policy: amount above per-transaction limit for solana:USDC/);85    expect(await sign({ ...intent, maxFee: '3000001' })).toMatch(/^policy: fee ceiling above policy/);86    expect(await sign({ ...intent, expiresAt: intent.createdAt + 1 })).toMatch(/^policy: approval window expired/);87    for (const v of [DEFAULT_POLICY.version - 1, DEFAULT_POLICY.version + 1]) expect(await sign({ ...intent, policyVersion: v }), String(v)).toMatch(/^policy: policy version changed/); // an intent prepared under another policy, older or newer88    expect(await sign({ ...intent, keyId: 'spw-0000000000000000' })).toMatch(/^wrong_key/);89    expect(() => intentHash({ ...intent, network: 'base', chainRef: '8453' })).toThrow(/does not match network/); // a Jupiter intent cannot even be hashed for an EVM network90    const other: Intent = { ...intent, swap: { ...intent.swap!, minOut: '1' } };91    expect(await sign(intent, s.pk.assert(intentHash(other)))).toMatch(/^bad_approval/); // the passkey approved other terms92    expect(await sign(intent, new TestPasskey(RP, ORIGIN).assert(intentHash(intent)))).toMatch(/^unknown_credential/);93    s.store.state.spent[`${new Date((intent.createdAt + 5) * 1000).toISOString().slice(0, 10)}|solana:USDC`] = '80000000';94    expect(await sign(intent)).toMatch(/^policy: daily limit exceeded/);95    s.store.state.spent = {};96    s.signer.halt();97    expect(await sign(intent)).toMatch(/^policy: emergency halt is active/);98  });99100  it('an agent mandate cannot sign a Solana swap', async () => {101    const { intent, message } = fresh('usdc-bonk'), s = setup(intent.createdAt + 5); await s.ready;102    await expect(s.signer.signDelegated('no-such-mandate', intent, { swapMessage: message })).rejects.toThrow(/unknown mandate/);103  });104});105
Files main
Menu

Theme