SPB Git

Repository Public

signer.ts 32.7 KB 427 lines typescript

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

Theme