SPB Git

Repository Public

intent.ts 9.5 KB 143 lines typescript

1import { sha256 } from '@noble/hashes/sha2.js';2import { z } from 'zod';3import { baseUnits } from './amounts.ts';4import { getNetwork } from './networks.ts';56const U = z.string().regex(/^(0|[1-9]\d{0,77})$/);7export const IntentSchema = z.strictObject({8  v: z.literal(1),9  proposalId: z.uuid(),10  keyId: z.string().min(8).max(80),11  network: z.string().min(2).max(24).regex(/^[a-z0-9-]+$/),12  chainRef: z.string().min(1).max(80),13  asset: z.string().min(1).max(80),14  symbol: z.string().min(1).max(16),15  decimals: z.number().int().min(0).max(36),16  amount: U,17  from: z.string().min(20).max(100),18  /** shortest valid destination: a 2-character NEAR account name. Every builder validates the address format of its own chain. */19  to: z.string().min(2).max(100),20  maxFee: U,21  createdAt: z.number().int().positive(),22  expiresAt: z.number().int().positive(),23  policyVersion: z.number().int().nonnegative(),24  /** destination memo / tag, when the network has one */25  memo: z.string().max(120).optional(),26  /** chain parameters for plugin families: flat string map, serialized in key order */27  x: z.record(z.string().regex(/^[a-zA-Z][a-zA-Z0-9_.]{0,39}$/), z.string().max(20000)).optional(),28  /**29   * Present when the intent is a swap instead of a transfer. `asset`/`amount` are the input, `to` must equal `from`30   * (the output is delivered to the wallet itself). `dataHash` commits to the exact provider calldata the signer will verify.31   */32  swap: z.strictObject({33    provider: z.enum(['kyberswap', 'jupiter']), toAsset: z.string().min(1).max(80), minOut: U, router: z.string().min(20).max(100),34    dataHash: z.string().regex(/^[0-9a-f]{64}$/), approveGas: U,35  }).optional(),36  /**37   * Present when the intent is a cross-chain USDC transfer (Circle CCTP) from the wallet to itself. `asset`/`amount` are the38   * USDC burned on `network`, `to` must equal `from` (the wallet's own address, the same on every EVM network). The signer39   * builds the burn calldata itself from these fields; `maxFee` here is the CCTP fee ceiling in USDC base units (0 = Standard).40   */41  bridge: z.strictObject({42    provider: z.enum(['cctp']), toNetwork: z.string().min(2).max(24).regex(/^[a-z0-9-]+$/), toDomain: z.number().int().min(0).max(0xffffffff),43    maxFee: U, minFinalityThreshold: z.number().int().min(1).max(0xffffffff), messenger: z.string().min(20).max(100), transmitter: z.string().min(20).max(100), approveGas: U,44  }).optional(),45  /**46   * Present when the intent funds the owner's account on a derivatives venue (v6): a plain token transfer from the wallet's own47   * address to the venue's bridge contract. The signer checks every field against a table pinned in its bundle48   * (@spw/derivatives/live, `checkVenueDepositIntent`): venue network → chain, native USDC only, `to` = the pinned bridge,49   * amount ≥ `minAmount` = its own floor above the venue minimum. An ordinary transfer to a venue bridge is refused.50   */51  venueDeposit: z.strictObject({52    venue: z.enum(['hyperliquid']), venueNetwork: z.enum(['mainnet', 'testnet']), route: z.string().regex(/^[a-z0-9-]{3,32}$/), bridge: z.string().min(20).max(100), minAmount: U,53  }).optional(),54  /**55   * Present when the intent is a native cross-chain swap through THORChain, from the wallet to itself (packages/core/src/thorchain.ts).56   * `asset`/`amount` are the input; `to` is the Asgard vault that receives it and must equal `vault`, which must be the address of57   * `vaultPubKey`. The signer rebuilds `memo` itself (destination = the wallet's own address on `toNetwork`, `limit` in 1e8 units58   * worth at least `minOut` base units of `toAsset`) and refuses any difference. `router` is '' for a UTXO source.59   */60  xswap: z.strictObject({61    provider: z.enum(['thorchain']), toNetwork: z.string().min(2).max(24).regex(/^[a-z0-9-]+$/), toAsset: z.string().min(1).max(80), minOut: U, limit: U,62    memo: z.string().min(5).max(250), vault: z.string().min(20).max(100), vaultPubKey: z.string().min(20).max(120), router: z.string().max(100), expiry: z.number().int().positive(), approveGas: U,63  }).optional(),64  evm: z.strictObject({65    nonce: z.number().int().nonnegative(), gasLimit: U, maxFeePerGas: U, maxPriorityFeePerGas: U,66  }).optional(),67  btc: z.strictObject({68    inputs: z.array(z.strictObject({ txid: z.string().regex(/^[0-9a-f]{64}$/), vout: z.number().int().nonnegative(), value: U })).min(1).max(50),69    change: U, fee: U,70  }).optional(),71  sol: z.strictObject({72    recentBlockhash: z.string().min(32).max(44), lastValidBlockHeight: z.number().int().positive(),73    createAta: z.boolean(), computeUnitLimit: z.number().int().min(1).max(400_000), computeUnitPrice: U,74  }).optional(),75});76export type Intent = z.infer<typeof IntentSchema>;7778const DOMAIN = 'SPW-INTENT-v1';79const enc = new TextEncoder();8081/** Flatten to an ordered list of (name, value) pairs. Order is fixed by this code, never by JSON key order. */82function fields(i: Intent): [string, string][] {83  const f: [string, string][] = [84    ['v', String(i.v)], ['proposalId', i.proposalId], ['keyId', i.keyId], ['network', i.network],85    ['chainRef', i.chainRef], ['asset', i.asset], ['symbol', i.symbol], ['decimals', String(i.decimals)],86    ['amount', i.amount], ['from', i.from], ['to', i.to], ['maxFee', i.maxFee],87    ['createdAt', String(i.createdAt)], ['expiresAt', String(i.expiresAt)], ['policyVersion', String(i.policyVersion)],88  ];89  if (i.evm) f.push(['evm.nonce', String(i.evm.nonce)], ['evm.gasLimit', i.evm.gasLimit],90    ['evm.maxFeePerGas', i.evm.maxFeePerGas], ['evm.maxPriorityFeePerGas', i.evm.maxPriorityFeePerGas]);91  if (i.btc) {92    f.push(['btc.inputs.count', String(i.btc.inputs.length)]);93    i.btc.inputs.forEach((x, k) => f.push([`btc.inputs.${k}.txid`, x.txid], [`btc.inputs.${k}.vout`, String(x.vout)], [`btc.inputs.${k}.value`, x.value]));94    f.push(['btc.change', i.btc.change], ['btc.fee', i.btc.fee]);95  }96  if (i.sol) f.push(['sol.recentBlockhash', i.sol.recentBlockhash], ['sol.lastValidBlockHeight', String(i.sol.lastValidBlockHeight)],97    ['sol.createAta', i.sol.createAta ? '1' : '0'], ['sol.computeUnitLimit', String(i.sol.computeUnitLimit)],98    ['sol.computeUnitPrice', i.sol.computeUnitPrice]);99  if (i.swap) f.push(['swap.provider', i.swap.provider], ['swap.toAsset', i.swap.toAsset], ['swap.minOut', i.swap.minOut], ['swap.router', i.swap.router], ['swap.dataHash', i.swap.dataHash], ['swap.approveGas', i.swap.approveGas]);100  if (i.bridge) f.push(['bridge.provider', i.bridge.provider], ['bridge.toNetwork', i.bridge.toNetwork], ['bridge.toDomain', String(i.bridge.toDomain)], ['bridge.maxFee', i.bridge.maxFee],101    ['bridge.minFinalityThreshold', String(i.bridge.minFinalityThreshold)], ['bridge.messenger', i.bridge.messenger], ['bridge.transmitter', i.bridge.transmitter], ['bridge.approveGas', i.bridge.approveGas]);102  if (i.xswap) f.push(['xswap.provider', i.xswap.provider], ['xswap.toNetwork', i.xswap.toNetwork], ['xswap.toAsset', i.xswap.toAsset], ['xswap.minOut', i.xswap.minOut], ['xswap.limit', i.xswap.limit],103    ['xswap.memo', i.xswap.memo], ['xswap.vault', i.xswap.vault], ['xswap.vaultPubKey', i.xswap.vaultPubKey], ['xswap.router', i.xswap.router], ['xswap.expiry', String(i.xswap.expiry)], ['xswap.approveGas', i.xswap.approveGas]);104  if (i.venueDeposit) f.push(['venueDeposit.venue', i.venueDeposit.venue], ['venueDeposit.venueNetwork', i.venueDeposit.venueNetwork], ['venueDeposit.route', i.venueDeposit.route],105    ['venueDeposit.bridge', i.venueDeposit.bridge], ['venueDeposit.minAmount', i.venueDeposit.minAmount]);106  if (i.memo !== undefined) f.push(['memo', i.memo]);107  if (i.x) for (const k of Object.keys(i.x).sort()) f.push([`x.${k}`, i.x[k]!]);108  return f;109}110111/** Deterministic, length-prefixed, domain-separated serialization. */112export function canonicalBytes(intent: Intent): Uint8Array {113  const i = IntentSchema.parse(intent);114  const fam = getNetwork(i.network).family; // throws on unknown network115  const plugin = fam !== 'evm' && fam !== 'btc' && fam !== 'sol';116  if ((fam === 'evm') !== !!i.evm || (fam === 'btc') !== !!i.btc || (fam === 'sol') !== !!i.sol || plugin !== !!i.x)117    throw new Error('intent chain section does not match network');118  if (i.swap && (i.swap.provider === 'jupiter' ? fam !== 'sol' : fam !== 'evm')) throw new Error('swaps are only defined for EVM networks (KyberSwap) and Solana (Jupiter)');119  if (i.bridge && (fam !== 'evm' || i.swap)) throw new Error('bridges are only defined for EVM networks and exclude a swap');120  if (i.xswap && ((fam !== 'evm' && fam !== 'btc') || i.swap || i.bridge)) throw new Error('cross-chain swaps are only defined for EVM and Bitcoin-style sources and exclude a swap or a bridge');121  if (i.venueDeposit && (fam !== 'evm' || i.swap || i.bridge || i.xswap || i.memo !== undefined)) throw new Error('a venue deposit is a plain EVM transfer: no swap, no bridge, no cross-chain swap, no memo');122  baseUnits(i.amount);123  const parts: Uint8Array[] = [enc.encode(DOMAIN), new Uint8Array([0])];124  for (const [k, v] of fields(i)) {125    const kb = enc.encode(k), vb = enc.encode(v);126    const head = new Uint8Array(6);127    new DataView(head.buffer).setUint16(0, kb.length);128    new DataView(head.buffer).setUint32(2, vb.length);129    parts.push(head, kb, vb);130  }131  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));132  let o = 0;133  for (const p of parts) { out.set(p, o); o += p.length; }134  return out;135}136137export function intentHash(intent: Intent): Uint8Array {138  return sha256(canonicalBytes(intent));139}140141export const toB64u = (b: Uint8Array) => Buffer.from(b).toString('base64url');142export const toHex = (b: Uint8Array) => Buffer.from(b).toString('hex');143
Files main
Menu

Theme