SPB Git

Repository Public

mandate-networks.test.ts 15.0 KB 169 lines typescript

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
Files main
Menu

Theme