Repository Public
1// V5: mandates on every execution-enabled EVM network. Every check of the signer must hold network by network: chain id,2// pinned router, fee ceiling of the policy for THAT network, the reduced router of HyperEVM, and the price check against3// the UNDERLYING of a wrapped asset.4import { createHash } from 'node:crypto';5import { mkdtempSync } from 'node:fs';6import { tmpdir } from 'node:os';7import { join } from 'node:path';8import { describe, expect, it } from 'vitest';9import { encodeFunctionData, parseTransaction, recoverTransactionAddress } from 'viem';10import { cashLegs, DEFAULT_POLICY, getNetwork, KYBER_NATIVE, KYBER_ROUTER, KYBER_ROUTER_ABI, MANDATE_NETWORKS, mandateHash, swapDataHash, vaultKeyId, type Intent, type Mandate } from '@spw/core';11import { TestPasskey } from '../../../tests/helpers/passkey.ts';12import { deriveKeys } from '../src/keys.ts';13import { Signer } from '../src/signer.ts';14import { Store } from '../src/store.ts';1516const RP = 'wallet.test', ORIGIN = 'https://wallet.test';17const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');18let n = 0;19const uuid = () => `8b2d2f0e-8f5f-4b1e-9c56-${String(++n).padStart(12, '0')}`;20const PX: Record<string, number> = { ETH: 2000, BTC: 60_000, POL: 0.5, BNB: 600, AVAX: 20, HYPE: 25, LINK: 10, USDC: 1, USDT: 1 };2122async function setup(policy?: (maxFee: Record<string, string>) => Record<string, string>) {23 const t = { now: Math.floor(Date.now() / 1000) }, asked: string[][] = [];24 const dir = mkdtempSync(join(tmpdir(), 'spw-mandate-net-'));25 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));26 store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');27 if (policy) store.policy = { ...store.policy, maxFee: policy({ ...store.policy.maxFee }) };28 // like the real view: a symbol it cannot price throws29 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t.now, { get: async (syms) => { asked.push(syms); return Object.fromEntries(syms.map((s) => { if (PX[s] === undefined) throw new Error(`no price source for ${s}`); return [s, PX[s]]; })); } });30 const pk = new TestPasskey(RP, ORIGIN);31 await signer.enroll({ credential: pk.credential(), setupToken: 'tok' });32 return { signer, store, pk, t, asked };33}34const asset = (network: string, id: string) => { const net = getNetwork(network); return id === 'native' ? net.native : net.tokens.find((x) => x.id === id)!; };35const mandate = (network: string, index: number, assets: string[], now: number, o: Partial<Mandate> = {}): Mandate => ({ v: 1, mandateId: uuid(), keyId: keys.keyId, vaultIndex: index, vault: keys.vault(index).address, network, provider: 'kyberswap', assets,36 maxOrder: Object.fromEntries(assets.map((a) => [a, (10n ** BigInt(asset(network, a).decimals + 2)).toString()])), maxDaily: Object.fromEntries(assets.map((a) => [a, (10n ** BigInt(asset(network, a).decimals + 3)).toString()])),37 maxOrdersPerDay: 20, maxSlippageBps: 150, createdAt: now, expiresAt: now + 30 * 86400, strategySha256: 'cd'.repeat(32), label: `agent on ${network}`, ...o });38/** A router order of `amount` base units of `from` for `to`, guaranteeing `keep` of the input's value at the prices `pIn` / `pOut`. */39function order(network: string, index: number, now: number, from: string, to: string, amount: bigint, pIn: number, pOut: number, o: { keep?: number; fn?: 'swap' | 'swapGeneric'; flags?: bigint; chainRef?: string; maxFee?: string; maxFeePerGas?: string } = {}) {40 const src = asset(network, from), dst = asset(network, to), vault = keys.vault(index).address;41 const minOut = BigInt(Math.floor((Number(amount) / 10 ** src.decimals) * pIn / pOut * (o.keep ?? 0.99) * 1e8)) * 10n ** BigInt(dst.decimals) / 10n ** 8n;42 const tok = (a: string) => (a === 'native' ? KYBER_NATIVE : a);43 const desc = { srcToken: tok(from), dstToken: tok(to), srcReceivers: [], srcAmounts: [], feeReceivers: [], feeAmounts: [], dstReceiver: vault, amount, minReturnAmount: minOut, flags: o.flags ?? 0n, permit: '0x' };44 const data = encodeFunctionData({ abi: KYBER_ROUTER_ABI, functionName: o.fn ?? 'swap', args: [{ callTarget: '0x8F10B468b06c6FD214B65F87778827F7D113f996', approveTarget: '0x0000000000000000000000000000000000000000', targetData: '0xabcd', desc, clientData: '0x' }] as never });45 const maxFeePerGas = o.maxFeePerGas ?? '200000000';46 const intent: Intent = { v: 1, proposalId: uuid(), keyId: vaultKeyId(keys.keyId, index), network, chainRef: o.chainRef ?? getNetwork(network).chainRef, asset: from, symbol: src.symbol, decimals: src.decimals, amount: amount.toString(),47 from: vault, to: vault, maxFee: o.maxFee ?? (670_000n * BigInt(maxFeePerGas)).toString(), createdAt: now, expiresAt: now + 90, policyVersion: DEFAULT_POLICY.version,48 swap: { provider: 'kyberswap', toAsset: to, minOut: minOut.toString(), router: KYBER_ROUTER, dataHash: swapDataHash(data), approveGas: from === 'native' ? '0' : '70000' },49 evm: { nonce: 0, gasLimit: '600000', maxFeePerGas, maxPriorityFeePerGas: '1000000' } };50 return { intent, aux: { swapData: data } };51}52const tokenId = (network: string, symbol: string) => getNetwork(network).tokens.find((x) => x.symbol === symbol)!.id;5354describe('delegated orders, network by network', () => {55 it('signs for the mandate network only: its chain id, the pinned router, the vault of that agent', async () => {56 const { signer, pk, t, asked } = await setup();57 const withCash = MANDATE_NETWORKS.filter((id) => cashLegs(getNetwork(id)).length > 0);58 // derived from the registry, so a network that gains a cash leg is exercised with no change here; these eight must be there59 // (BNB Smart Chain since registry v5: Binance-Peg USDC, 18 decimals — the only cash leg that is not in 6 decimals)60 expect(withCash).toEqual(expect.arrayContaining(['base', 'ethereum', 'arbitrum', 'polygon', 'optimism', 'bsc', 'avalanche', 'hyperevm']));61 expect(cashLegs(getNetwork('bsc'))[0]).toMatchObject({ symbol: 'USDC', decimals: 18 });62 const ten = (id: string) => 10n * 10n ** BigInt(cashLegs(getNetwork(id))[0]!.decimals); // 10 units of that network's cash leg, in ITS decimals63 const made: Record<string, { m: Mandate; index: number; cash: string }> = {};64 for (const [k, id] of withCash.entries()) {65 const index = k + 1, cash = cashLegs(getNetwork(id))[0]!, m = mandate(id, index, ['native', cash.id], t.now); // one vault index per agent, whatever its network66 await signer.approveMandate(m, pk.assert(mandateHash(m)));67 made[id] = { m, index, cash: cash.id };68 }69 for (const id of withCash) {70 const { m, index, cash } = made[id]!, net = getNetwork(id);71 const o = order(id, index, t.now, cash, 'native', ten(id), 1, PX[net.native.symbol]!);72 expect(BigInt(o.intent.swap!.minOut) > 0n, id).toBe(true);73 const r = await signer.signDelegated(m.mandateId, o.intent, o.aux);74 const tx = parseTransaction(r.raw as `0x${string}`), pre = parseTransaction(r.pre!.raw as `0x${string}`);75 expect(tx.chainId, id).toBe(Number(net.chainRef)); expect(pre.chainId, id).toBe(Number(net.chainRef)); // a signature valid on that chain only76 expect(tx.to?.toLowerCase(), id).toBe(KYBER_ROUTER.toLowerCase());77 expect(pre.to?.toLowerCase(), id).toBe(cash.toLowerCase()); // exact-amount approval on that network's own token78 expect(await recoverTransactionAddress({ serializedTransaction: r.raw as `0x02${string}` }), id).toBe(keys.vault(index).address);79 expect(asked.at(-1), id).toEqual(['USDC', net.native.symbol]);80 }81 // decimals are the asset's own, network by network: 10 USDC on BNB Smart Chain is 10^19 base units, and the price check values82 // it as 10 USD — an order guaranteeing 90 % of that is refused there exactly as on a 6-decimal network83 for (const id of ['bsc', 'base']) { const { m, index, cash } = made[id]!, net = getNetwork(id);84 const thin = order(id, index, t.now, cash, 'native', ten(id), 1, PX[net.native.symbol]!, { keep: 0.9 });85 await expect(signer.signDelegated(m.mandateId, thin.intent, thin.aux), id).rejects.toThrow(/guaranteed output is worth 90\.00 %/);86 const big = order(id, index, t.now, cash, 'native', 101n * 10n ** BigInt(cashLegs(net)[0]!.decimals), 1, PX[net.native.symbol]!); // the helper's mandate caps an order at 100 units87 await expect(signer.signDelegated(m.mandateId, big.intent, big.aux), id).rejects.toThrow(/order above the per-order limit/); }88 // a mandate never serves another network, even for the same vault key and the same assets by symbol89 const b = made.base!, a = made.arbitrum!;90 const cross = order('arbitrum', b.index, t.now, a.cash, 'native', 10_000000n, 1, 2000);91 await expect(signer.signDelegated(b.m.mandateId, cross.intent, cross.aux)).rejects.toThrow(/network outside the mandate/);92 const wrongChain = order('base', b.index, t.now, b.cash, 'native', 10_000000n, 1, 2000, { chainRef: '42161' });93 await expect(signer.signDelegated(b.m.mandateId, wrongChain.intent, wrongChain.aux)).rejects.toThrow(/chain identity mismatch/);94 const otherVault = order('arbitrum', b.index, t.now, a.cash, 'native', 10_000000n, 1, 2000); // Base agent's key on the Arbitrum mandate95 await expect(signer.signDelegated(a.m.mandateId, otherVault.intent, otherVault.aux)).rejects.toThrow(/not for this vault key/);96 });9798 it('applies the fee ceiling of the policy for the order\'s own network', async () => {99 const { signer, store, pk, t } = await setup();100 const eth = mandate('ethereum', 1, ['native', tokenId('ethereum', 'USDC')], t.now), arb = mandate('arbitrum', 2, ['native', tokenId('arbitrum', 'USDC')], t.now);101 await signer.approveMandate(eth, pk.assert(mandateHash(eth))); await signer.approveMandate(arb, pk.assert(mandateHash(arb)));102 expect(BigInt(store.policy.maxFee.ethereum!)).toBeGreaterThan(BigInt(store.policy.maxFee.arbitrum!));103 const fee = { maxFeePerGas: '2000000000', maxFee: (670_000n * 2_000_000_000n).toString() }; // 2 gwei: 0.00134 ETH — fine on Ethereum, far above the Arbitrum ceiling104 const onArb = order('arbitrum', 2, t.now, tokenId('arbitrum', 'USDC'), 'native', 10_000000n, 1, 2000, fee);105 await expect(signer.signDelegated(arb.mandateId, onArb.intent, onArb.aux)).rejects.toThrow(/fee ceiling above policy/);106 const onEth = order('ethereum', 1, t.now, tokenId('ethereum', 'USDC'), 'native', 10_000000n, 1, 2000, fee);107 expect((await signer.signDelegated(eth.mandateId, onEth.intent, onEth.aux)).txid).toMatch(/^0x/);108 const above = order('ethereum', 1, t.now, tokenId('ethereum', 'USDC'), 'native', 10_000000n, 1, 2000, { maxFeePerGas: '20000000000', maxFee: (670_000n * 20_000_000_000n).toString() });109 await expect(signer.signDelegated(eth.mandateId, above.intent, above.aux)).rejects.toThrow(/fee ceiling above policy/); // Ethereum has a ceiling too110 });111112 it('refuses a mandate on a network for which the policy sets no fee ceiling', async () => {113 const { signer, pk, t } = await setup((f) => { delete f.optimism; return f; });114 const m = mandate('optimism', 1, ['native', tokenId('optimism', 'USDC')], t.now);115 await expect(signer.approveMandate(m, pk.assert(mandateHash(m)))).rejects.toThrow(/no fee ceiling/);116 const ok = mandate('base', 1, ['native', tokenId('base', 'USDC')], t.now);117 await signer.approveMandate(ok, pk.assert(mandateHash(ok)));118 });119120 it('HyperEVM runs a reduced router: swapGeneric and partial fill are refused there, and only there', async () => {121 const { signer, pk, t } = await setup();122 const hl = mandate('hyperevm', 1, ['native', tokenId('hyperevm', 'USDC')], t.now), base = mandate('base', 2, ['native', tokenId('base', 'USDC')], t.now);123 await signer.approveMandate(hl, pk.assert(mandateHash(hl))); await signer.approveMandate(base, pk.assert(mandateHash(base)));124 const at = (net: string, idx: number, o: { fn?: 'swap' | 'swapGeneric'; flags?: bigint }) => order(net, idx, t.now, tokenId(net, 'USDC'), 'native', 10_000000n, 1, net === 'hyperevm' ? 25 : 2000, o);125 for (const bad of [{ fn: 'swapGeneric' as const }, { flags: 0x01n }]) {126 const o = at('hyperevm', 1, bad);127 await expect(signer.signDelegated(hl.mandateId, o.intent, o.aux), JSON.stringify(bad.fn ?? 'partial fill')).rejects.toThrow(/does not exist on this network/);128 const same = at('base', 2, bad); // the full router of Base has both129 expect((await signer.signDelegated(base.mandateId, same.intent, same.aux)).txid).toMatch(/^0x/);130 }131 const plain = at('hyperevm', 1, {});132 expect(parseTransaction((await signer.signDelegated(hl.mandateId, plain.intent, plain.aux)).raw as `0x${string}`).chainId).toBe(999);133 });134});135136describe('price check against the underlying (issuer risk)', () => {137 it('values a wrapped asset at the exchange price of what it tracks; a depeg makes the signer refuse', async () => {138 const { signer, pk, t, asked } = await setup();139 const WBTC = tokenId('ethereum', 'WBTC'), WETH = tokenId('ethereum', 'WETH'), USDC = tokenId('ethereum', 'USDC');140 const m = mandate('ethereum', 1, [USDC, WBTC, WETH], t.now);141 await signer.approveMandate(m, pk.assert(mandateHash(m)));142 // selling 0.001 WBTC at the BTC price: accepted, and the signer asked for BTC, never for "WBTC"143 const fair = order('ethereum', 1, t.now, WBTC, USDC, 100_000n, PX.BTC!, 1);144 expect((await signer.signDelegated(m.mandateId, fair.intent, fair.aux)).txid).toMatch(/^0x/);145 expect(asked.at(-1)).toEqual(['BTC', 'USDC']);146 // WBTC has lost its peg: the market only gives 90 % of the bitcoin price for it → the order is refused (fail closed)147 const depegSell = order('ethereum', 1, t.now, WBTC, USDC, 100_000n, PX.BTC!, 1, { keep: 0.9 });148 await expect(signer.signDelegated(m.mandateId, depegSell.intent, depegSell.aux)).rejects.toThrow(/guaranteed output is worth 90\.00 %/);149 // buying WETH is checked at the ETH price: a route delivering too little wrapped ether is refused too150 const buy = order('ethereum', 1, t.now, USDC, WETH, 20_000000n, 1, PX.ETH!);151 expect((await signer.signDelegated(m.mandateId, buy.intent, buy.aux)).txid).toMatch(/^0x/);152 expect(asked.at(-1)).toEqual(['USDC', 'ETH']);153 const thin = order('ethereum', 1, t.now, USDC, WETH, 20_000000n, 1, PX.ETH!, { keep: 0.97 });154 await expect(signer.signDelegated(m.mandateId, thin.intent, thin.aux)).rejects.toThrow(/mandate requires at least 98\.50 %/);155 expect(signer.status().mandates[0]!.today.orders).toBe(2); // refused orders are not counted156 });157158 it('refuses any order on an asset it cannot price (no second exchange): fail closed', async () => {159 const { signer, pk, t, asked } = await setup();160 const EURC = tokenId('base', 'EURC'), USDC = tokenId('base', 'USDC');161 const m = mandate('base', 1, [USDC, EURC], t.now);162 await signer.approveMandate(m, pk.assert(mandateHash(m)));163 const o = order('base', 1, t.now, USDC, EURC, 10_000000n, 1, 1.1);164 await expect(signer.signDelegated(m.mandateId, o.intent, o.aux)).rejects.toThrow(/market price unavailable \(no price source for EUR\)/);165 expect(asked.at(-1)).toEqual(['USDC', 'EUR']); // EURC is priced as the euro, which this view does not carry166 expect(signer.status().mandates[0]!.today.orders).toBe(0);167 });168});169