SPB Git

Repository Public

mandate.test.ts 16.0 KB 192 lines typescript

1import { createHash } from 'node:crypto';2import { mkdtempSync } from 'node:fs';3import { tmpdir } from 'node:os';4import { join } from 'node:path';5import { describe, expect, it } from 'vitest';6import { decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction, recoverTransactionAddress } from 'viem';7import { intentHash, KYBER_NATIVE, KYBER_ROUTER, KYBER_ROUTER_ABI, mandateHash, NETWORKS, swapDataHash, vaultKeyId, type Intent, type Mandate } from '@spw/core';8import { TestPasskey } from '../../../tests/helpers/passkey.ts';9import { deriveKeys } from '../src/keys.ts';10import { Signer } from '../src/signer.ts';11import { Store } from '../src/store.ts';1213const RP = 'wallet.test', ORIGIN = 'https://wallet.test';14const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');15const USDC = NETWORKS.base.tokens.find((t) => t.symbol === 'USDC')!.id, LINK = NETWORKS.base.tokens.find((t) => t.symbol === 'LINK')!.id;16const VAULT = keys.vault(1).address;17let n = 0;18const uuid = () => `7a1c1f0e-8f5f-4b1e-9c56-${String(++n).padStart(12, '0')}`;1920async function setup() {21  const t = { now: Math.floor(Date.now() / 1000) };22  const dir = mkdtempSync(join(tmpdir(), 'spw-mandate-'));23  const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));24  store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');25  const px = { ETH: 2000, USDC: 1, LINK: 10, fail: '' };26  const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t.now, { get: async (syms) => { if (px.fail) throw new Error(px.fail); return Object.fromEntries(syms.map((s) => [s, (px as unknown as Record<string, number>)[s]!])); } });27  const pk = new TestPasskey(RP, ORIGIN);28  await signer.enroll({ credential: pk.credential(), setupToken: 'tok' });29  return { signer, store, pk, t, px };30}31const mandate = (now: number, o: Partial<Mandate> = {}): Mandate => ({ v: 1, mandateId: uuid(), keyId: keys.keyId, vaultIndex: 1, vault: VAULT, network: 'base', provider: 'kyberswap',32  assets: ['native', USDC], maxOrder: { [USDC]: '20000000', native: '10000000000000000' }, maxDaily: { [USDC]: '50000000', native: '20000000000000000' }, maxOrdersPerDay: 4, maxSlippageBps: 150,33  createdAt: now, expiresAt: now + 30 * 86400, strategySha256: 'ab'.repeat(32), label: 'ETH trend', ...o });34/** A swap order for the vault: USDC -> ETH by default, with router calldata paying `receiver`. */35function order(now: number, o: { amount?: bigint; to?: string; receiver?: string; from?: string; fromAsset?: string; toAsset?: string; keyId?: string; minOut?: bigint; network?: string } = {}) {36  const amount = o.amount ?? 10_000000n, fromAsset = o.fromAsset ?? USDC, toAsset = o.toAsset ?? 'native', minOut = o.minOut ?? (amount * 10n ** 12n * 99n) / (2000n * 100n); // USDC -> ETH at 2000 USD/ETH, guaranteeing 99 % of the value37  const tok = (a: string) => (a === 'native' ? KYBER_NATIVE : a);38  const desc = { srcToken: tok(fromAsset), dstToken: tok(toAsset), srcReceivers: [], srcAmounts: [], feeReceivers: [], feeAmounts: [], dstReceiver: o.receiver ?? VAULT, amount, minReturnAmount: minOut, flags: 0n, permit: '0x' };39  const data = encodeFunctionData({ abi: KYBER_ROUTER_ABI, functionName: 'swap', args: [{ callTarget: '0x8F10B468b06c6FD214B65F87778827F7D113f996', approveTarget: '0x0000000000000000000000000000000000000000', targetData: '0xabcd', desc, clientData: '0x' }] as never });40  const src = fromAsset === 'native' ? NETWORKS.base.native : NETWORKS.base.tokens.find((t) => t.id === fromAsset)!;41  const intent: Intent = { v: 1, proposalId: uuid(), keyId: o.keyId ?? vaultKeyId(keys.keyId, 1), network: o.network ?? 'base', chainRef: '8453', asset: fromAsset, symbol: src.symbol, decimals: src.decimals, amount: amount.toString(),42    from: o.from ?? VAULT, to: o.to ?? VAULT, maxFee: '200000000000000', createdAt: now, expiresAt: now + 90, policyVersion: 4,43    swap: { provider: 'kyberswap', toAsset, minOut: minOut.toString(), router: KYBER_ROUTER, dataHash: swapDataHash(data), approveGas: fromAsset === 'native' ? '0' : '70000' },44    evm: { nonce: 0, gasLimit: '600000', maxFeePerGas: '200000000', maxPriorityFeePerGas: '1000000' } };45  return { intent, aux: { swapData: data } };46}4748describe('agent vaults', () => {49  it('a vault is a separate key from the owner wallet and from other vaults', () => {50    expect(VAULT).not.toBe(keys.addresses.evm);51    expect(keys.vault(2).address).not.toBe(VAULT);52    expect(() => keys.vault(0)).toThrow(); // account 0 is the owner's wallet: never a vault53  });54});5556describe('mandate approval (passkey over the canonical hash)', () => {57  it('needs the owner passkey, the right vault derivation and sane limits', async () => {58    const { signer, pk, t } = await setup();59    const m = mandate(t.now);60    await expect(signer.approveMandate(m, new TestPasskey(RP, ORIGIN).assert(mandateHash(m)))).rejects.toThrow(/enrolled/);61    await expect(signer.approveMandate(m, pk.assert(mandateHash(mandate(t.now, { maxDaily: { [USDC]: '99999000000', native: '20000000000000000' } }))))).rejects.toThrow(); // approval for other limits62    for (const bad of [{ vault: keys.addresses.evm }, { vault: keys.vault(2).address }, { keyId: 'spw-ffffffffffffffff' }, { network: 'ethereum' }, { expiresAt: t.now + 200 * 86400 }, { expiresAt: t.now - 1 },63      { assets: ['native', '0xdAC17F958D2ee523a2206206994597C13D831ec7'] }, { maxOrder: { [USDC]: '60000000' }, maxDaily: { [USDC]: '50000000' } }, { maxOrder: { [LINK]: '1' }, maxDaily: { [LINK]: '1' } }, { createdAt: t.now - 3600 }] as Partial<Mandate>[]) {64      const x = mandate(t.now, bad);65      await expect(signer.approveMandate(x, pk.assert(mandateHash(x))), JSON.stringify(bad)).rejects.toThrow();66    }67    const ok = await signer.approveMandate(m, pk.assert(mandateHash(m)));68    expect(ok.mandateId).toBe(m.mandateId);69    await expect(signer.approveMandate(m, pk.assert(mandateHash(m)))).rejects.toThrow(/already recorded/);70    expect(signer.status().mandates.length).toBe(1);71  });72  it('every field is part of the hash', () => {73    const now = 1_800_000_000, base = mandate(now), h = Buffer.from(mandateHash(base)).toString('hex');74    const muts: Mandate[] = [{ ...base, maxOrdersPerDay: 5 }, { ...base, expiresAt: base.expiresAt + 1 }, { ...base, maxOrder: { ...base.maxOrder, [USDC]: '20000001' } }, { ...base, maxDaily: { ...base.maxDaily, native: '20000000000000001' } },75      { ...base, assets: ['native', USDC, LINK] }, { ...base, strategySha256: 'cd'.repeat(32) }, { ...base, vaultIndex: 2 }, { ...base, label: 'x' }];76    const hs = new Set(muts.map((m) => Buffer.from(mandateHash(m)).toString('hex')));77    expect(hs.size).toBe(muts.length); expect(hs.has(h)).toBe(false);78    expect(Buffer.from(mandateHash({ ...base, assets: [USDC, 'native'] })).toString('hex')).toBe(h); // order of the list is not meaningful79  });80});8182describe('delegated orders', () => {83  it('signs an order that fits the mandate, with the vault key, without a passkey', async () => {84    const { signer, pk, t, store } = await setup();85    const m = mandate(t.now); await signer.approveMandate(m, pk.assert(mandateHash(m)));86    const { intent, aux } = order(t.now);87    const r = await signer.signDelegated(m.mandateId, intent, aux);88    expect(await recoverTransactionAddress({ serializedTransaction: r.raw as never })).toBe(VAULT);89    expect(await recoverTransactionAddress({ serializedTransaction: r.pre!.raw as never })).toBe(VAULT);90    const approve = parseTransaction(r.pre!.raw as `0x${string}`);91    expect(decodeFunctionData({ abi: erc20Abi, data: approve.data! }).args).toEqual([KYBER_ROUTER, 10_000000n]);92    expect(parseTransaction(r.raw as `0x${string}`).to!.toLowerCase()).toBe(KYBER_ROUTER.toLowerCase());93    expect(store.state.journal.some((j) => j.type === 'delegated.ok')).toBe(true);94    expect(signer.status().mandates[0]!.today).toEqual({ sold: { [USDC]: '10000000' }, orders: 1 });95  });96  it('refuses everything outside the mandate', async () => {97    const { signer, pk, t } = await setup();98    const m = mandate(t.now); await signer.approveMandate(m, pk.assert(mandateHash(m)));99    const no = async (label: string, o: Parameters<typeof order>[1], re: RegExp) => { const x = order(t.now, o); await expect(signer.signDelegated(m.mandateId, x.intent, x.aux), label).rejects.toThrow(re); };100    await no('above the per-order limit', { amount: 20_000001n }, /per-order/);101    await no('asset outside the mandate', { toAsset: LINK }, /outside the mandate/);102    await no('output paid elsewhere (calldata)', { receiver: keys.addresses.evm }, /another address/);103    await no('intent recipient is not the vault', { to: keys.addresses.evm }, /vault itself/);104    await no('spending from the owner wallet', { from: keys.addresses.evm, to: keys.addresses.evm, receiver: keys.addresses.evm }, /vault itself/);105    await no('owner key id', { keyId: keys.keyId }, /vault key/);106    await no('another vault key id', { keyId: vaultKeyId(keys.keyId, 2) }, /vault key/);107    await no('zero minimum output', { minOut: 0n }, /positive/);108    await no('sold far below market: guarantees 80 % of the value', { minOut: 4_000000000000000n }, /at market price/);109    await expect(signer.signDelegated('7a1c1f0e-8f5f-4b1e-9c56-999999999999', order(t.now).intent, order(t.now).aux)).rejects.toThrow(/unknown mandate/);110    // a plain transfer is not a delegated order111    const x = order(t.now); const transfer = { ...x.intent, swap: undefined, to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' };112    await expect(signer.signDelegated(m.mandateId, transfer, {})).rejects.toThrow(/only swaps/);113    // tampered calldata114    const y = order(t.now); await expect(signer.signDelegated(m.mandateId, y.intent, { swapData: y.aux.swapData.slice(0, -2) + '11' })).rejects.toThrow(/approved hash/);115  });116  it('enforces daily volume, order count, replay, expiry, revocation and halt', async () => {117    const { signer, pk, t } = await setup();118    const m = mandate(t.now); await signer.approveMandate(m, pk.assert(mandateHash(m)));119    const go = (o?: Parameters<typeof order>[1]) => { const x = order(t.now, o); return signer.signDelegated(m.mandateId, x.intent, x.aux); };120    const first = order(t.now, { amount: 20_000000n });121    await signer.signDelegated(m.mandateId, first.intent, first.aux);122    await expect(signer.signDelegated(m.mandateId, first.intent, first.aux)).rejects.toThrow(/already signed/);123    await go({ amount: 20_000000n });124    await expect(go({ amount: 10_000001n })).rejects.toThrow(/daily volume/); // 40 + 10.000001 > 50125    await go({ amount: 5_000000n }); await go({ amount: 1_000000n });126    await expect(go({ amount: 1n })).rejects.toThrow(/order count/); // 4 orders today127    t.now += 86400; await go({ amount: 1_000000n }); // limits are per UTC day128    signer.revokeMandate(m.mandateId);129    await expect(go({ amount: 1_000000n })).rejects.toThrow(/revoked/);130    // an expired mandate, and a halted signer131    const m2 = mandate(t.now, { expiresAt: t.now + 3600 }); await signer.approveMandate(m2, pk.assert(mandateHash(m2)));132    t.now += 3601; const late = order(t.now); await expect(signer.signDelegated(m2.mandateId, late.intent, late.aux)).rejects.toThrow(/expired/);133    const m3 = mandate(t.now); await signer.approveMandate(m3, pk.assert(mandateHash(m3)));134    signer.halt(); const h = order(t.now); await expect(signer.signDelegated(m3.mandateId, h.intent, h.aux)).rejects.toThrow(/halt/);135    const m4 = mandate(t.now); await expect(signer.approveMandate(m4, pk.assert(mandateHash(m4)))).rejects.toThrow(/halt/);136  });137  it('the owner path cannot be used for a vault, and a vault order cannot be signed with an owner approval', async () => {138    const { signer, pk, t } = await setup();139    const m = mandate(t.now); await signer.approveMandate(m, pk.assert(mandateHash(m)));140    const x = order(t.now);141    await expect(signer.sign(x.intent, pk.assert(intentHash(x.intent)), x.aux)).rejects.toThrow(/another key|not controlled/);142  });143});144145describe('the signer\'s own price view', () => {146  it('refuses an order when prices cannot be established, and applies the mandate tolerance exactly', async () => {147    const { signer, pk, t, px } = await setup();148    const m = mandate(t.now); await signer.approveMandate(m, pk.assert(mandateHash(m)));149    const at = (minOut: bigint) => { const x = order(t.now, { minOut }); return signer.signDelegated(m.mandateId, x.intent, x.aux); };150    await expect(at(4_924000000000000n)).rejects.toThrow(/at market price/); // 98.48 % < 98.5 %151    await at(4_926000000000000n);                                             // 98.52 % ≥ 98.5 %152    px.ETH = 1000; // the market halves: the same guaranteed ETH is now worth half the USDC given up153    await expect(at(4_950000000000000n)).rejects.toThrow(/at market price/);154    px.ETH = 2000; px.fail = 'price sources disagree on ETH';155    await expect(at(4_950000000000000n)).rejects.toThrow(/market price unavailable/);156    px.fail = '';157    await at(4_950000000000000n);158    expect(signer.status().mandates[0]!.today.orders).toBe(2); // refused orders are not counted159  });160});161162describe('returning funds', () => {163  it('without any mandate, on any EVM network: a vault still only pays the owner (funds sent to a vault on another network can come back)', async () => {164    const { signer, t } = await setup();165    const ETH_USDC = NETWORKS.ethereum.tokens.find((x) => x.symbol === 'USDC')!.id;166    const ret = (to: string, o: Partial<Intent> = {}): Intent => ({ v: 1, proposalId: uuid(), keyId: vaultKeyId(keys.keyId, 1), network: 'ethereum', chainRef: '1', asset: ETH_USDC, symbol: 'USDC', decimals: 6, amount: '20000000',167      from: VAULT, to, maxFee: '2000000000000000', createdAt: t.now, expiresAt: t.now + 90, policyVersion: 2, evm: { nonce: 0, gasLimit: '90000', maxFeePerGas: '2000000000', maxPriorityFeePerGas: '1000000' }, ...o });168    await expect(signer.signVaultReturn(1, ret('0x70997970C51812dc3A010C7d01b50e0d17dc79C8'))).rejects.toThrow(/owner/);169    await expect(signer.signVaultReturn(1, ret(keys.addresses.evm, { from: keys.addresses.evm }))).rejects.toThrow(/vault/);170    await expect(signer.signVaultReturn(2, ret(keys.addresses.evm))).rejects.toThrow(/not for this vault/);          // another vault's key cannot be borrowed171    await expect(signer.signVaultReturn(0, ret(keys.addresses.evm))).rejects.toThrow(/vault index/);                 // index 0 is the owner's own key172    await expect(signer.signVaultReturn(1, ret(keys.addresses.evm, { maxFee: '900000000000000000' }))).rejects.toThrow(/fee ceiling/);173    await expect(signer.signVaultReturn(1, ret(keys.addresses.evm, { network: 'solana', chainRef: 'x' }))).rejects.toThrow();174    const ok = ret(keys.addresses.evm), r = await signer.signVaultReturn(1, ok);175    expect(await recoverTransactionAddress({ serializedTransaction: r.raw as never })).toBe(VAULT);176    await expect(signer.signVaultReturn(1, ok)).rejects.toThrow(/already signed/);177  });178  it('a vault only ever pays the owner\'s own address, even after revocation', async () => {179    const { signer, pk, t } = await setup();180    const m = mandate(t.now); await signer.approveMandate(m, pk.assert(mandateHash(m)));181    const ret = (to: string, o: Partial<Intent> = {}): Intent => ({ v: 1, proposalId: uuid(), keyId: vaultKeyId(keys.keyId, 1), network: 'base', chainRef: '8453', asset: USDC, symbol: 'USDC', decimals: 6, amount: '30000000',182      from: VAULT, to, maxFee: '200000000000000', createdAt: t.now, expiresAt: t.now + 90, policyVersion: 4, evm: { nonce: 3, gasLimit: '90000', maxFeePerGas: '200000000', maxPriorityFeePerGas: '1000000' }, ...o });183    await expect(signer.signReturn(m.mandateId, ret('0x70997970C51812dc3A010C7d01b50e0d17dc79C8'))).rejects.toThrow(/owner/);184    await expect(signer.signReturn(m.mandateId, ret(keys.addresses.evm, { from: keys.addresses.evm }))).rejects.toThrow(/vault/);185    signer.revokeMandate(m.mandateId);186    const r = await signer.signReturn(m.mandateId, ret(keys.addresses.evm));187    expect(await recoverTransactionAddress({ serializedTransaction: r.raw as never })).toBe(VAULT);188    const tx = parseTransaction(r.raw as `0x${string}`);189    expect(decodeFunctionData({ abi: erc20Abi, data: tx.data! }).args).toEqual([keys.addresses.evm, 30_000000n]); // more than any order limit: returning is not limited190  });191});192
Files main
Menu

Theme