Repository Public
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