Repository Public
1import { createHash, timingSafeEqual } from 'node:crypto';2import { verifyAuthenticationResponse, type AuthenticationResponseJSON } from '@simplewebauthn/server';3import { ed25519 } from '@noble/curves/ed25519.js';4import { base58 } from '@scure/base';5import { keccak256 } from 'viem';6import { privateKeyToAccount } from 'viem/accounts';7import {8 baseUnits, bridgeOf, buildBtcTx, buildCctpBurnTxs, buildCctpMintTx, cctpMintRetryBytes, verifyCctpMint, buildEvmSwapTxs, buildEvmTx, checkMandateOrder, checkOrderPrice, getAsset, MandateSchema, mandateHash, priceSymbolOf, validateMandate, vaultKeyId, buildSolMessage, checkPolicy, getNetwork, IntentSchema, intentHash, pluginFor,9 solWireTransaction, toB64u, type CctpMintGas, type Intent, type Mandate,10} from '@spw/core';11import { buildSolSwapMessage, solV1Wire } from '@spw/core'; // Jupiter swaps on Solana (kept apart from the shared import line)12import { buildXswapBtcTx, buildXswapEvmTxs, verifyXswap, type NetworkDef } from '@spw/core'; // THORChain native swaps13import { noVaults, type VaultView } from './vaults.ts';14import type { Keys } from './keys.ts';15import { noPrices, type PriceView } from './prices.ts';16import type { Store, StoredCredential } from './store.ts';17import { venueAuthorize, venueKeyAddress, venueRevoke, venueSign, venueStatus, type VenueCtx, type VenueSignRequest } from './venue.ts';18import { venueDepositVerdict, venueWithdraw } from './venue-collateral.ts';19import { venueMandateApprove, venueMandateRevoke, venueMandateSign, venueMandateStatus, type VenueMandateSignRequest } from './venue-mandate.ts';20import { noVenueReader, type VenueReader } from './venue-read.ts';2122export class SignerError extends Error { constructor(public code: string, msg: string) { super(msg); } }23const sha = (s: string | Uint8Array) => createHash('sha256').update(s).digest();24export interface SignerConfig { rpId: string; origin: string }25export interface SignResult { raw: string; txid: string; hash: string; /** swaps with a token input: the exact-amount approval that must be mined first */ pre?: { raw: string; txid: string } }2627export class Signer {28 constructor(private keys: Keys, private store: Store, private cfg: SignerConfig, private now: () => number = () => Math.floor(Date.now() / 1000), private prices: PriceView = noPrices, private venueInfo: VenueReader = noVenueReader, private vaults: VaultView = noVaults) {}2930 status() {31 const s = this.store.state, today = new Date(this.now() * 1000).toISOString().slice(0, 10);32 return {33 keyId: this.keys.keyId, addresses: this.keys.addresses, halted: s.halted,34 enrollmentOpen: s.credentials.length === 0 && s.setupTokenHash !== null,35 credentials: s.credentials.map((c) => ({ id: c.id, addedAt: c.addedAt })),36 policy: this.store.policy,37 spentToday: Object.fromEntries(Object.entries(s.spent).filter(([k]) => k.startsWith(today)).map(([k, v]) => [k.slice(11), v])),38 journalHead: s.journal[s.journal.length - 1]?.hash ?? null, journalLength: s.journal.length,39 bridges: Object.entries(s.bridges ?? {}).map(([id, b]) => ({ id, ...b.bridge, approvedAt: b.approvedAt, burnTxid: b.burnTxid, mintTxid: b.mint?.txid ?? null, mintSignatures: b.mint?.signatures ?? 0 })),40 mandates: Object.values(s.mandates ?? {}).map((m) => ({ mandate: m.mandate, approvedAt: m.approvedAt, revoked: m.revoked, revokedAt: m.revokedAt ?? null,41 expired: this.now() >= m.mandate.expiresAt, today: m.usage[today] ?? { sold: {}, orders: 0 } })),42 venues: venueStatus(this.venueCtx()),43 venueMandates: venueMandateStatus(this.venueCtx()),44 };45 }4647 // ---------------------------------------------------------------------------------------------------------------------48 // Live derivatives (venue.ts). The trading key is a dedicated key; the main key signs only the venue's agent approval,49 // inside venueAuthorize, after the owner's passkey approved the canonical authorization.50 private venueCtx(): VenueCtx {51 return { keys: this.keys, store: this.store, now: this.now, prices: this.prices, venueInfo: this.venueInfo, verify: (a, c) => this.verifyAssertion(a, c), fail: (code, msg) => new SignerError(code, msg) };52 }53 venueKey(venue: unknown, network: unknown) { return venueKeyAddress(this.venueCtx(), venue, network); }54 venueAuthorize(authorization: unknown, assertion?: AuthenticationResponseJSON) { return venueAuthorize(this.venueCtx(), authorization, assertion); }55 venueRevoke(authorizationId: string) { return venueRevoke(this.venueCtx(), authorizationId); }56 venueSign(req: VenueSignRequest) { return venueSign(this.venueCtx(), req); }57 /** collateral: withdraw3 to the wallet's own address, the second (and last) kind of venue message the main key signs */58 venueWithdraw(withdrawal: unknown, assertion?: AuthenticationResponseJSON) { return venueWithdraw(this.venueCtx(), withdrawal, assertion); }59 // derivative mandates (venue-mandate.ts): one passkey approval, then orders without a passkey inside limits enforced here60 venueMandateApprove(mandate: unknown, assertion?: AuthenticationResponseJSON) { return venueMandateApprove(this.venueCtx(), mandate, assertion); }61 venueMandateSign(req: VenueMandateSignRequest) { return venueMandateSign(this.venueCtx(), req); }62 venueMandateRevoke(mandateId: string) { return venueMandateRevoke(this.venueCtx(), mandateId); }6364 private async verifyAssertion(assertion: AuthenticationResponseJSON, challenge: Uint8Array): Promise<StoredCredential> {65 const cred = this.store.state.credentials.find((c) => c.id === assertion.id);66 if (!cred) throw new SignerError('unknown_credential', 'approval was not made with an enrolled passkey');67 let v;68 try {69 v = await verifyAuthenticationResponse({70 response: assertion, expectedChallenge: toB64u(challenge), expectedOrigin: this.cfg.origin, expectedRPID: this.cfg.rpId,71 credential: { id: cred.id, publicKey: new Uint8Array(Buffer.from(cred.publicKey, 'base64url')), counter: cred.counter, transports: cred.transports as never },72 requireUserVerification: true,73 });74 } catch (e) { throw new SignerError('bad_approval', `approval verification failed: ${(e as Error).message}`); }75 if (!v.verified || !v.authenticationInfo.userVerified) throw new SignerError('bad_approval', 'approval not verified');76 cred.counter = v.authenticationInfo.newCounter;77 return cred;78 }7980 /** First passkey needs the one-time setup token; later ones need an assertion from an already enrolled passkey. */81 async enroll(req: { credential: { id: string; publicKey: string; counter: number; transports?: string[] }; setupToken?: string; assertion?: AuthenticationResponseJSON }): Promise<void> {82 const s = this.store.state, c = req.credential;83 if (!/^[A-Za-z0-9_-]{16,1024}$/.test(c.id) || !/^[A-Za-z0-9_-]{40,2048}$/.test(c.publicKey)) throw new SignerError('bad_request', 'malformed credential');84 if (s.credentials.some((x) => x.id === c.id)) throw new SignerError('conflict', 'credential already enrolled');85 if (s.credentials.length === 0) {86 const given = sha(req.setupToken ?? ''), want = Buffer.from(s.setupTokenHash ?? '', 'hex');87 if (want.length !== 32 || !timingSafeEqual(given, want)) throw new SignerError('forbidden', 'invalid setup token');88 s.setupTokenHash = null; // single use89 } else {90 if (!req.assertion) throw new SignerError('forbidden', 'existing passkey approval required');91 await this.verifyAssertion(req.assertion, enrollChallenge(c.id, c.publicKey));92 }93 s.credentials.push({ id: c.id, publicKey: c.publicKey, counter: c.counter, transports: c.transports, addedAt: new Date().toISOString() });94 this.store.log('credential.enrolled', { id: c.id });95 this.store.save();96 }9798 // ---------------------------------------------------------------------------------------------------------------------99 // Agent vaults and mandates. A vault key signs only (a) swaps that fit a mandate the owner approved with a passkey, and100 // (b) transfers back to the owner's own address. The owner's key (account 0) is never reachable from these paths.101102 vaultAddress(index: number): string { return this.keys.vault(index).address; }103104 /** The owner approves a mandate with a passkey assertion over its canonical hash. Limits can only be set here. */105 async approveMandate(raw: unknown, assertion: AuthenticationResponseJSON): Promise<{ mandateId: string; hash: string }> {106 const st = this.store, now = this.now();107 const parsed = MandateSchema.safeParse(raw);108 if (!parsed.success) throw new SignerError('bad_request', 'malformed mandate');109 const m: Mandate = parsed.data;110 const hash = mandateHash(m);111 const deny = (code: string, msg: string): never => { st.log('mandate.denied', { mandateId: m.mandateId, code, msg }); st.save(); throw new SignerError(code, msg); };112 if (st.state.halted) deny('policy', 'emergency halt is active');113 if (m.keyId !== this.keys.keyId) deny('wrong_key', 'mandate targets another key');114 if (m.vault !== this.vaultAddress(m.vaultIndex)) deny('wrong_vault', 'vault address does not match this signer\'s derivation');115 if (st.state.mandates?.[m.mandateId]) deny('replay', 'mandate already recorded');116 try { validateMandate(m, now); } catch (e) { deny('unsupported', (e as Error).message); }117 // v5 (agents on every execution-enabled network): no mandate on a network whose fee ceiling this signer's policy does not set118 if (st.policy.maxFee[m.network] === undefined) deny('policy', 'no fee ceiling in the policy for the mandate\'s network');119 try { await this.verifyAssertion(assertion, hash); } catch (e) { deny((e as SignerError).code ?? 'bad_approval', (e as Error).message); }120 (st.state.mandates ??= {})[m.mandateId] = { mandate: m, approvedAt: new Date(now * 1000).toISOString(), revoked: false, usage: {} };121 st.log('mandate.approved', { mandateId: m.mandateId, hash: Buffer.from(hash).toString('hex'), vaultIndex: m.vaultIndex, vault: m.vault, network: m.network, assets: m.assets.length, expiresAt: m.expiresAt, strategy: m.strategySha256 });122 st.save();123 return { mandateId: m.mandateId, hash: Buffer.from(hash).toString('hex') };124 }125126 /** Stopping is always safe: no passkey needed. A revoked mandate can never be re-activated. */127 revokeMandate(mandateId: string): void {128 const sm = this.store.state.mandates?.[mandateId];129 if (!sm) throw new SignerError('not_found', 'unknown mandate');130 if (!sm.revoked) { sm.revoked = true; sm.revokedAt = new Date(this.now() * 1000).toISOString(); this.store.log('mandate.revoked', { mandateId }); this.store.save(); }131 }132133 /** Sign one order for an agent vault. No passkey: the mandate is the authorization, and every bound is checked here. */134 async signDelegated(mandateId: string, rawIntent: unknown, aux?: Record<string, unknown>): Promise<SignResult> {135 const st = this.store, now = this.now();136 const sm = st.state.mandates?.[mandateId];137 if (!sm) throw new SignerError('not_found', 'unknown mandate');138 const parsed = IntentSchema.safeParse(rawIntent);139 if (!parsed.success) throw new SignerError('bad_request', 'malformed intent');140 const i: Intent = parsed.data, m = sm.mandate, hash = intentHash(i);141 const deny = (code: string, msg: string): never => { st.log('delegated.denied', { mandateId, proposalId: i.proposalId, code, msg }); st.save(); throw new SignerError(code, msg); };142 if (st.state.used[i.proposalId] !== undefined) deny('replay', 'order already signed');143 let net; try { net = getNetwork(i.network); } catch { return deny('wrong_chain', 'unknown network'); }144 if (i.chainRef !== net.chainRef) deny('wrong_chain', 'chain identity mismatch');145 const vault = this.keys.vault(m.vaultIndex);146 if (vault.address !== m.vault) deny('wrong_vault', 'vault derivation mismatch');147 const day = new Date(now * 1000).toISOString().slice(0, 10);148 const usage = sm.usage[day] ?? { sold: {}, orders: 0 };149 const verdict = checkMandateOrder(m, i, usage, now, { halted: st.state.halted, revoked: sm.revoked, maxFee: st.policy.maxFee[i.network] });150 if (!verdict.ok) deny('mandate', verdict.reason);151 let txs;152 try { txs = buildEvmSwapTxs(i, typeof aux?.swapData === 'string' ? aux.swapData : ''); } // recipient = vault, minimum output, exact amount: same checks as for the owner153 catch (e) { return deny('unsupported', (e as Error).message); }154 // the signer's own price check: the guaranteed output must be worth close to what is given up, at prices it fetched itself155 // v5: each asset is valued at the exchange price of the market symbol it TRACKS (WETH as ETH, cbBTC as BTC…): a wrapper156 // that has lost its peg cannot deliver that value, so the check below refuses the order (fail closed)157 const outAsset = getAsset(i.network, i.swap!.toAsset);158 let px: Record<string, number>;159 try {160 const pIn = priceSymbolOf(getAsset(i.network, i.asset)).symbol, pOut = priceSymbolOf(outAsset).symbol;161 const got = await this.prices.get([pIn, pOut]);162 px = { [i.symbol]: got[pIn]!, [outAsset.symbol]: got[pOut]! };163 } catch (e) { return deny('no_price', `market price unavailable (${(e as Error).message.slice(0, 80)}); order refused`); }164 const fair = checkOrderPrice(m, i, px, outAsset.symbol, outAsset.decimals);165 if (!fair.ok) deny('price', fair.reason);166 // count the order before releasing a signature167 usage.sold[i.asset] = (BigInt(usage.sold[i.asset] ?? '0') + baseUnits(i.amount)).toString(); usage.orders += 1; sm.usage[day] = usage;168 st.state.used[i.proposalId] = i.expiresAt; st.prune(now);169 const acct = privateKeyToAccount(`0x${Buffer.from(vault.priv).toString('hex')}`);170 const raw = await acct.signTransaction(txs.swap), pre = txs.approve ? await acct.signTransaction(txs.approve) : null;171 const out: SignResult = { raw, txid: keccak256(raw), hash: Buffer.from(hash).toString('hex'), ...(pre ? { pre: { raw: pre, txid: keccak256(pre) } } : {}) };172 st.log('delegated.ok', { mandateId, proposalId: i.proposalId, hash: out.hash, symbol: i.symbol, amount: i.amount, toAsset: i.swap!.toAsset, minOut: i.swap!.minOut, txid: out.txid });173 st.save();174 return out;175 }176177 /** Move funds out of a vault — only ever to the owner's own address. Works for revoked and expired mandates too. */178 async signReturn(mandateId: string, rawIntent: unknown): Promise<SignResult> {179 const st = this.store, now = this.now();180 const sm = st.state.mandates?.[mandateId];181 if (!sm) throw new SignerError('not_found', 'unknown mandate');182 const parsed = IntentSchema.safeParse(rawIntent);183 if (!parsed.success) throw new SignerError('bad_request', 'malformed intent');184 const i: Intent = parsed.data, m = sm.mandate, hash = intentHash(i);185 const deny = (code: string, msg: string): never => { st.log('return.denied', { mandateId, proposalId: i.proposalId, code, msg }); st.save(); throw new SignerError(code, msg); };186 if (st.state.used[i.proposalId] !== undefined) deny('replay', 'already signed');187 if (i.swap) deny('unsupported', 'a return is a plain transfer');188 if (i.keyId !== vaultKeyId(m.keyId, m.vaultIndex) || i.network !== m.network) deny('wrong_key', 'intent is not for this vault');189 const vault = this.keys.vault(m.vaultIndex);190 if (i.from !== vault.address) deny('wrong_source', 'source is not the vault');191 if (i.to !== this.keys.addresses.evm) deny('wrong_destination', 'a vault can only pay the owner\'s own address');192 if (i.expiresAt <= now || i.expiresAt - i.createdAt > 300) deny('policy', 'validity window is not acceptable');193 const fee = st.policy.maxFee[i.network];194 if (fee === undefined || baseUnits(i.maxFee) > baseUnits(fee)) deny('policy', 'fee ceiling above policy');195 let tx; try { tx = buildEvmTx(i); } catch (e) { return deny('unsupported', (e as Error).message); }196 st.state.used[i.proposalId] = i.expiresAt; st.prune(now);197 const raw = await privateKeyToAccount(`0x${Buffer.from(vault.priv).toString('hex')}`).signTransaction(tx);198 const out = { raw, txid: keccak256(raw), hash: Buffer.from(hash).toString('hex') };199 st.log('return.ok', { mandateId, proposalId: i.proposalId, symbol: i.symbol, amount: i.amount, txid: out.txid });200 st.save();201 return out;202 }203204 /**205 * Bring funds back from an agent vault to the owner, on any EVM network, with or without a mandate (a vault that never had206 * one, or funds sent to the vault's address on another network than the agent's). No passkey: the only possible destination207 * is the owner's own address, so the worst a compromised API can do with this is move the owner's vault funds to the owner.208 */209 async signVaultReturn(index: number, rawIntent: unknown): Promise<SignResult> {210 const st = this.store, now = this.now();211 if (!Number.isInteger(index) || index < 1) throw new SignerError('bad_request', 'bad vault index');212 const parsed = IntentSchema.safeParse(rawIntent);213 if (!parsed.success) throw new SignerError('bad_request', 'malformed intent');214 const i: Intent = parsed.data, hash = intentHash(i);215 const deny = (code: string, msg: string): never => { st.log('return.denied', { vaultIndex: index, proposalId: i.proposalId, code, msg }); st.save(); throw new SignerError(code, msg); };216 if (st.state.used[i.proposalId] !== undefined) deny('replay', 'already signed');217 if (i.swap || (i as { bridge?: unknown }).bridge) deny('unsupported', 'a return is a plain transfer');218 let net; try { net = getNetwork(i.network); } catch { return deny('wrong_chain', 'unknown network'); }219 if (net.family !== 'evm' || i.chainRef !== net.chainRef) deny('wrong_chain', 'vaults exist on EVM networks only');220 if (i.keyId !== vaultKeyId(this.keys.keyId, index)) deny('wrong_key', 'intent is not for this vault');221 const vault = this.keys.vault(index);222 if (i.from !== vault.address) deny('wrong_source', 'source is not the vault');223 if (i.to !== this.keys.addresses.evm) deny('wrong_destination', 'a vault can only pay the owner\'s own address');224 if (i.expiresAt <= now || i.expiresAt - i.createdAt > 300) deny('policy', 'validity window is not acceptable');225 const fee = st.policy.maxFee[i.network];226 if (fee === undefined || baseUnits(i.maxFee) > baseUnits(fee)) deny('policy', 'fee ceiling above policy');227 let tx; try { tx = buildEvmTx(i); } catch (e) { return deny('unsupported', (e as Error).message); }228 st.state.used[i.proposalId] = i.expiresAt; st.prune(now);229 const raw = await privateKeyToAccount(`0x${Buffer.from(vault.priv).toString('hex')}`).signTransaction(tx);230 const out = { raw, txid: keccak256(raw), hash: Buffer.from(hash).toString('hex') };231 st.log('return.ok', { vaultIndex: index, network: i.network, proposalId: i.proposalId, symbol: i.symbol, amount: i.amount, txid: out.txid });232 st.save();233 return out;234 }235236 // ---------------------------------------------------------------------------------------------------------------------237 // Second leg of a bridge. The owner's passkey approval of the burn committed to the whole transfer (route, amount, fee238 // ceiling, recipient = the wallet). This signs the matching receiveMessage on the destination: it can only mint the239 // wallet's own burned USDC to the wallet's own address, and costs at most the policy fee ceiling of that network.240 // - exactly one signature per bridge without a passkey; an identical request returns the same bytes again;241 // - any further signature (stuck or replaced transaction) needs a fresh passkey assertion over the exact new parameters;242 // - one message serves one bridge only.243 async signBridgeMint(req: BridgeMintRequest): Promise<BridgeMintResult> {244 const st = this.store, now = this.now();245 const id = String(req?.proposalId ?? '');246 const deny = (code: string, msg: string): never => { st.log('bridge.mint.denied', { proposalId: id, code, msg }); st.save(); throw new SignerError(code, msg); };247 const rec = st.state.bridges?.[id];248 if (!rec) throw new SignerError('not_found', 'no approved bridge with this id');249 if (st.state.halted) deny('policy', 'emergency halt is active');250 const g = req.evm;251 if (!g || typeof g.nonce !== 'number' || [g.gasLimit, g.maxFeePerGas, g.maxPriorityFeePerGas].some((v) => typeof v !== 'string' || !/^(0|[1-9]\d{0,77})$/.test(v))) deny('bad_request', 'malformed gas parameters');252 if (typeof req.message !== 'string' || typeof req.attestation !== 'string' || req.message.length > 4000 || req.attestation.length > 2000) deny('bad_request', 'malformed message');253 const b = rec.bridge;254 if (b.wallet !== this.keys.addresses.evm || this.keys.addresses[b.toNetwork] !== b.wallet) deny('wrong_source', 'bridge is not for this wallet');255 const ceiling = st.policy.maxFee[b.toNetwork];256 if (ceiling === undefined) deny('policy', 'no fee ceiling in the policy for the destination network');257 let tx, v;258 try { v = verifyCctpMint(b, req.message, req.attestation); tx = buildCctpMintTx(b, req.message, req.attestation, g, baseUnits(ceiling!)); }259 catch (e) { return deny('unsupported', (e as Error).message); }260 for (const [other, r] of Object.entries(st.state.bridges ?? {})) if (other !== id && r.mint?.messageHash === v.messageHash) deny('replay', 'this message already served another bridge');261 const params = [v.messageHash, req.attestation.toLowerCase(), g.nonce, g.gasLimit, g.maxFeePerGas, g.maxPriorityFeePerGas].join('|');262 let signatures = 1;263 if (rec.mint) {264 if (rec.mint.params === params && !req.assertion) return { raw: rec.mint.raw, txid: rec.mint.txid, messageHash: v.messageHash, minted: v.minted.toString(), replayed: true }; // same bytes, no new signature265 if (!req.assertion) deny('replay', 'a mint was already signed for this bridge; another signature needs the owner\'s passkey');266 if (rec.mint.signatures >= 6) deny('policy', 'too many mint signatures for this bridge');267 try { await this.verifyAssertion(req.assertion!, new Uint8Array(sha(cctpMintRetryBytes(id, v.messageHash, g)))); } catch (e) { deny((e as SignerError).code ?? 'bad_approval', (e as Error).message); }268 signatures = rec.mint.signatures + 1;269 }270 const raw = await privateKeyToAccount(`0x${Buffer.from(this.keys.evmPriv).toString('hex')}`).signTransaction(tx!);271 const txid = keccak256(raw);272 rec.mint = { messageHash: v.messageHash, txid, raw, params, signedAt: now, signatures };273 st.prune(now);274 st.log('bridge.mint.ok', { proposalId: id, messageHash: v.messageHash, fromNetwork: b.fromNetwork, toNetwork: b.toNetwork, amount: b.amount, minted: v.minted.toString(), feeExecuted: v.msg.body.feeExecuted.toString(), txid, signatures, burnTxid: rec.burnTxid });275 st.save(); // durable before the signature leaves276 return { raw, txid, messageHash: v.messageHash, minted: v.minted.toString(), replayed: false };277 }278279 // ---------------------------------------------------------------------------------------------------------------------280 // Native cross-chain swap (THORChain). Everything is rebuilt from the intent: the memo pays the wallet's own address on the281 // destination chain at least the approved minimum, the transaction pays the committed vault and nothing else. The vault key282 // itself cannot be proven offline, so it must be confirmed, now, by every THORNode operator this signer can reach (at least283 // three, its own list, no API in the path). Fails closed. Runs before the passkey is looked at; nothing is recorded on refusal.284 private async xswapBuild(i: Intent, net: NetworkDef, aux?: Record<string, unknown>): Promise<() => Promise<{ raw: string; txid: string; pre?: { raw: string; txid: string } }>> {285 const dest = this.keys.addresses[i.xswap!.toNetwork] ?? '';286 const plan = verifyXswap(i, dest);287 const utxo = net.family === 'btc' ? buildXswapBtcTx(i, dest, aux) : null, evm = net.family === 'evm' ? buildXswapEvmTxs(i, dest) : null;288 if (!utxo && !evm) throw new Error('THORChain swaps are not defined for this network family');289 let v;290 try { v = await this.vaults.check(plan.want); } catch (e) { throw new SignerError('vault_unconfirmed', `the vault is not confirmed by the signer's THORNode operators: ${(e as Error).message.slice(0, 200)}`); }291 return async () => {292 this.store.log('xswap.vault_confirmed', { proposalId: i.proposalId, vault: i.xswap!.vault, vaultPubKey: i.xswap!.vaultPubKey, operators: v.operators, silent: v.silent, height: v.height, churnHeight: v.statusSince });293 if (utxo) { utxo.sign(this.keys.utxoPriv(net)); utxo.finalize(); return { raw: utxo.hex, txid: utxo.id }; }294 const acct = privateKeyToAccount(`0x${Buffer.from(this.keys.evmPriv).toString('hex')}`);295 const raw = await acct.signTransaction(evm!.deposit), pre = evm!.approve ? await acct.signTransaction(evm!.approve) : null;296 return { raw, txid: keccak256(raw), ...(pre ? { pre: { raw: pre, txid: keccak256(pre) } } : {}) };297 };298 }299300 halt(): void {301 this.store.state.halted = true;302 this.store.log('halt.on', {});303 this.store.save();304 }305306 async sign(rawIntent: unknown, assertion: AuthenticationResponseJSON, aux?: Record<string, unknown>): Promise<SignResult> {307 const st = this.store, now = this.now();308 const parsed = IntentSchema.safeParse(rawIntent);309 if (!parsed.success) throw new SignerError('bad_request', 'malformed intent');310 const i: Intent = parsed.data;311 const hash = intentHash(i);312 const deny = (code: string, msg: string): never => {313 st.log('sign.denied', { proposalId: i.proposalId, hash: Buffer.from(hash).toString('hex'), code, msg });314 st.save();315 throw new SignerError(code, msg);316 };317 if (i.keyId !== this.keys.keyId) deny('wrong_key', 'intent targets another key');318 let net;319 try { net = getNetwork(i.network); } catch { return deny('wrong_chain', 'unknown network'); }320 if (i.chainRef !== net.chainRef) deny('wrong_chain', 'chain identity mismatch');321 const own = this.keys.addresses[net.id];322 if (!own) deny('wrong_chain', 'no key for this network');323 if (i.from !== own) deny('wrong_source', 'source address is not controlled by this signer');324 if (i.swap || i.bridge ? i.to !== own : i.to === own) deny('self_send', i.swap ? 'a swap must deliver to the wallet itself' : i.bridge ? 'a bridge must deliver to the wallet itself' : 'destination equals source');325 if (st.state.used[i.proposalId] !== undefined) deny('replay', 'proposal already signed');326 // venue deposits (venue-collateral.ts): pinned bridge, native USDC, floor above the venue minimum; a plain transfer to a venue bridge is refused327 const vd = venueDepositVerdict(i, own!);328 if (vd) deny('venue_deposit', vd);329330 const day = new Date(now * 1000).toISOString().slice(0, 10), spendKey = `${day}|${i.network}:${i.symbol}`;331 const verdict = checkPolicy(st.policy, i, BigInt(st.state.spent[spendKey] ?? '0'), now, st.state.halted);332 if (!verdict.ok) deny('policy', verdict.reason);333334 // Rebuild the chain transaction from the intent alone; any unsupported shape throws here.335 let build: () => Promise<{ raw: string; txid: string; pre?: { raw: string; txid: string } }>;336 if (i.xswap) {337 // native cross-chain swap: memo and transaction are rebuilt here, and the vault is confirmed by this signer's own THORNode operators338 try { build = await this.xswapBuild(i, net, aux); } catch (e) { return deny(e instanceof SignerError ? e.code : 'unsupported', (e as Error).message); }339 } else340 try {341 if (i.bridge) {342 // nothing provider-built: approval and burn are rebuilt from the intent and the pinned tables343 if (i.swap) throw new Error('an intent is a swap or a bridge, not both');344 const txs = buildCctpBurnTxs(i);345 if (this.keys.addresses[i.bridge.toNetwork] !== own) throw new Error('the destination network is not served by the same wallet address');346 if (st.policy.maxFee[i.bridge.toNetwork] === undefined) throw new Error('no fee ceiling in the policy for the destination network');347 if (st.state.bridges?.[i.proposalId]) throw new Error('bridge already recorded');348 build = async () => {349 const acct = privateKeyToAccount(`0x${Buffer.from(this.keys.evmPriv).toString('hex')}`);350 const raw = await acct.signTransaction(txs.burn);351 const pre = txs.approve ? await acct.signTransaction(txs.approve) : null;352 // the approved bridge is recorded with the burn it authorised: it is the only authority for the later mint353 (st.state.bridges ??= {})[i.proposalId] = { bridge: bridgeOf(i), approvedAt: now, burnTxid: keccak256(raw) };354 return { raw, txid: keccak256(raw), ...(pre ? { pre: { raw: pre, txid: keccak256(pre) } } : {}) };355 };356 } else if (i.swap && net.family === 'sol') {357 // Jupiter on Solana: the message bytes come as unauthenticated helper data; the signer decodes them and checks the358 // single signer, every instruction, the wallet's own token accounts, the exact input and the minimum output359 const msg = buildSolSwapMessage(i, typeof aux?.swapMessage === 'string' ? aux.swapMessage : '');360 build = async () => {361 const sig = ed25519.sign(msg, this.keys.solSeed);362 return { raw: Buffer.from(solV1Wire(sig, msg)).toString('base64'), txid: base58.encode(sig) };363 };364 } else if (i.swap) {365 // the provider calldata comes as unauthenticated helper data: it must hash to the approved value and pass every check366 const data = typeof aux?.swapData === 'string' ? aux.swapData : '';367 const txs = buildEvmSwapTxs(i, data);368 build = async () => {369 const acct = privateKeyToAccount(`0x${Buffer.from(this.keys.evmPriv).toString('hex')}`);370 const raw = await acct.signTransaction(txs.swap);371 const pre = txs.approve ? await acct.signTransaction(txs.approve) : null;372 return { raw, txid: keccak256(raw), ...(pre ? { pre: { raw: pre, txid: keccak256(pre) } } : {}) };373 };374 } else if (net.family === 'evm') {375 const tx = buildEvmTx(i);376 build = async () => {377 const raw = await privateKeyToAccount(`0x${Buffer.from(this.keys.evmPriv).toString('hex')}`).signTransaction(tx);378 return { raw, txid: keccak256(raw) };379 };380 } else if (net.family === 'btc') {381 const tx = buildBtcTx(i, aux);382 build = async () => { tx.sign(this.keys.utxoPriv(net)); tx.finalize(); return { raw: tx.hex, txid: tx.id }; };383 } else if (net.family === 'sol') {384 const msg = buildSolMessage(i);385 build = async () => {386 const sig = ed25519.sign(msg, this.keys.solSeed);387 return { raw: Buffer.from(solWireTransaction(sig, msg)).toString('base64'), txid: base58.encode(sig) };388 };389 } else {390 const plugin = pluginFor(net);391 plugin.build(i, net, aux); // must throw on anything unsupported, before the approval is even looked at392 build = async () => plugin.sign(i, net, this.keys.handle(net), aux);393 }394 } catch (e) { return deny('unsupported', (e as Error).message); }395396 // The passkey assertion's challenge IS the intent hash: approval is bound to these exact fields.397 try { await this.verifyAssertion(assertion, hash); } catch (e) { deny((e as SignerError).code ?? 'bad_approval', (e as Error).message); }398399 // Persist replay marker + spend + journal BEFORE releasing a signature.400 st.state.used[i.proposalId] = i.expiresAt;401 st.state.spent[spendKey] = (BigInt(st.state.spent[spendKey] ?? '0') + baseUnits(i.amount)).toString();402 st.prune(now);403 const out = await build!();404 st.log('sign.ok', { proposalId: i.proposalId, hash: Buffer.from(hash).toString('hex'), network: i.network, symbol: i.symbol, amount: i.amount, to: i.to, txid: out.txid, ...(i.swap ? { swap: i.swap.provider, toAsset: i.swap.toAsset, minOut: i.swap.minOut } : {}),405 ...(i.xswap ? { xswap: i.xswap.provider, toNetwork: i.xswap.toNetwork, toAsset: i.xswap.toAsset, minOut: i.xswap.minOut, vault: i.xswap.vault, vaultPubKey: i.xswap.vaultPubKey, memo: i.xswap.memo } : {}),406 ...(i.bridge ? { bridge: i.bridge.provider, toNetwork: i.bridge.toNetwork, cctpMaxFee: i.bridge.maxFee, minFinalityThreshold: i.bridge.minFinalityThreshold } : {}),407 ...(i.venueDeposit ? { venueDeposit: i.venueDeposit.venue, venueNetwork: i.venueDeposit.venueNetwork, route: i.venueDeposit.route } : {}) });408 st.save();409 return { ...out, hash: Buffer.from(hash).toString('hex') };410 }411}412413export interface BridgeMintRequest { proposalId: string; message: string; attestation: string; evm: CctpMintGas; assertion?: AuthenticationResponseJSON }414export interface BridgeMintResult { raw: string; txid: string; messageHash: string; minted: string; replayed: boolean }415416export interface DelegatedSigner {417 vaultAddress(index: number): string;418 approveMandate(raw: unknown, assertion: AuthenticationResponseJSON): Promise<{ mandateId: string; hash: string }>;419 revokeMandate(mandateId: string): void;420 signDelegated(mandateId: string, rawIntent: unknown, aux?: Record<string, unknown>): Promise<SignResult>;421 signReturn(mandateId: string, rawIntent: unknown): Promise<SignResult>;422}423424export function enrollChallenge(credId: string, publicKey: string): Uint8Array {425 return new Uint8Array(sha(`SPW-ENROLL-v1\n${credId}\n${publicKey}`));426}427