Repository Public
1// V8 hardening — an agent-vault withdrawal is a TRACKED operation.2//3// Before: POST /agents/:id/withdraw signed, broadcast, and answered `{ ok: true, txid }` — it never looked at the receipt, and4// with broadcasting off it still said ok. Now each requested withdrawal is one row of `agent_withdrawals`:5//6// requested ──► signed ──► broadcast ──► confirmed (receipt status 1 AND the payment to the owner seen in it)7// │ │ └──────► failed (reverted, or the vault's nonce was used by another transaction)8// │ └───────────────────► not_sent (broadcasting is off on this server, or it could not be relayed in time)9// └────────────────────────────────► failed (the signer refused, or the process died while signing)10//11// ONE id per requested withdrawal: the row is inserted (state `requested`) BEFORE the signer is called, its id is the intent's12// proposalId (the signer's replay rule is per proposal id), and a partial unique index allows one open withdrawal per13// (agent, network, asset). A double tap — or a client retry with the same idempotency key — gets the SAME row back and14// nothing is signed twice. Nothing here changes what the signer verifies: the intent is built exactly as before.15import { randomUUID } from 'node:crypto';16import { decodeEventLog, erc20Abi, hexToBigInt, hexToNumber } from 'viem';17import { formatUnits, getAsset, getNetwork, IntentSchema, vaultKeyId } from '@spw/core';18import { adapterFor } from '../chains/index.ts';19import { rpcAny, rpcQuorum } from '../chains/rpc.ts';20import { config, UserError } from '../config.ts';21import { audit, sql } from '../db.ts';22import { signer } from '../signer-client.ts';2324export type WithdrawalState = 'requested' | 'signed' | 'broadcast' | 'confirmed' | 'failed' | 'not_sent';25export const WITHDRAWAL_OPEN: WithdrawalState[] = ['requested', 'signed', 'broadcast'];26export interface WithdrawalRow {27 id: string; agent_id: string; network: string; asset: string; symbol: string; decimals: number; amount: string; from_addr: string; to_addr: string; nonce: number;28 state: WithdrawalState; raw: string | null; txid: string | null; error: string | null; idempotency_key: string | null; arrival: { verified: boolean; received: string | null; block: number | null } | null;29 created_at: Date; updated_at: Date; broadcast_at: Date | null; last_broadcast_at: Date | null; confirmed_at: Date | null;30}31// (table `agent_withdrawals`: db.ts, "v8 hardening")3233export interface WithdrawalView { id: string; ok: boolean; state: WithdrawalState; network: string; asset: string; symbol: string; amount: string; txid: string | null; explorer: string | null; error: string | null;34 /** true only when the transaction is mined AND the payment to the owner's address was seen in it */35 arrived: boolean; createdAt: string; updatedAt: string; confirmedAt: string | null;36 /** this answer is an existing withdrawal (double tap / retry): nothing new was signed */37 duplicate?: boolean }38export function view(r: WithdrawalRow, duplicate = false): WithdrawalView {39 const n = getNetwork(r.network);40 // `ok` = it is on its way or done. A withdrawal that was not sent, or failed, is never ok.41 return { id: r.id, ok: r.state === 'broadcast' || r.state === 'confirmed', state: r.state, network: r.network, asset: r.asset, symbol: r.symbol, amount: formatUnits(BigInt(r.amount), r.decimals),42 txid: r.state === 'requested' ? null : r.txid, explorer: r.txid && (r.state === 'broadcast' || r.state === 'confirmed' || (r.state === 'failed' && r.broadcast_at)) ? n.explorerTx + r.txid : null, error: r.error,43 arrived: r.state === 'confirmed' && r.arrival?.verified === true, createdAt: r.created_at.toISOString(), updatedAt: r.updated_at.toISOString(), confirmedAt: r.confirmed_at?.toISOString() ?? null, ...(duplicate ? { duplicate: true } : {}) };44}45const one = async (id: string): Promise<WithdrawalRow> => (await sql<WithdrawalRow[]>`select * from agent_withdrawals where id = ${id}`)[0]!;46async function setState(r: WithdrawalRow, state: WithdrawalState, patch: { error?: string | null; arrival?: WithdrawalRow['arrival'] } = {}): Promise<WithdrawalRow> {47 const [row] = await sql<WithdrawalRow[]>`update agent_withdrawals set state = ${state}, error = ${patch.error ?? null}, arrival = ${patch.arrival ? sql.json(patch.arrival as never) : null},48 broadcast_at = case when ${state} = 'broadcast' then coalesce(broadcast_at, now()) else broadcast_at end, last_broadcast_at = case when ${state} = 'broadcast' then now() else last_broadcast_at end,49 confirmed_at = case when ${state} = 'confirmed' then now() else confirmed_at end, updated_at = now()50 where id = ${r.id} and state = ${r.state} returning *`;51 if (!row) return one(r.id); // another pass moved it first52 if (state === 'confirmed' || state === 'failed' || state === 'not_sent')53 await audit(`agent.withdraw_${state}`, state === 'confirmed' ? 'chain' : 'engine', { agent: r.agent_id, id: r.id, network: r.network, symbol: r.symbol, amount: r.amount, txid: r.txid, ...(patch.error ? { reason: patch.error } : {}) });54 return row;55}5657interface AgentLike { id: string; state: string; network: string; vault_index: number; vault_address: string }58/** Request a withdrawal of the whole balance of one asset to the owner's own address. Idempotent (see the header). */59export async function requestWithdrawal(a: AgentLike, b: { asset: string; network?: string; idempotencyKey?: string }): Promise<WithdrawalView> {60 // any EVM network: funds sent to the vault's address on another network than the agent's can always come back61 const n = getNetwork(b.network ?? a.network);62 if (n.family !== 'evm') throw new UserError('unsupported_network', 'agent wallets exist on EVM networks only', 422);63 if (a.state === 'LIVE' && n.id === a.network) throw new UserError('bad_state', 'revoke the mandate before withdrawing', 409);64 const asset = getAsset(n.id, b.asset);65 // a retry with the same key, or a second tap while one is in flight: the same operation, nothing new is signed66 if (b.idempotencyKey) { const [same] = await sql<WithdrawalRow[]>`select * from agent_withdrawals where agent_id = ${a.id} and idempotency_key = ${b.idempotencyKey}`; if (same) return view(same, true); }67 const [open] = await sql<WithdrawalRow[]>`select * from agent_withdrawals where agent_id = ${a.id} and network = ${n.id} and asset = ${asset.id} and state in ${sql(WITHDRAWAL_OPEN)}`;68 if (open) return view(open, true);6970 const st = await signer.status(true), owner = st.addresses[n.id]!;71 const bal = await adapterFor(n.id).balances(a.vault_address);72 const nonce = hexToNumber(await rpcQuorum<`0x${string}`>(n.rpc, 'eth_getTransactionCount', [a.vault_address, 'pending']));73 const block = await rpcAny<{ baseFeePerGas?: `0x${string}` }>(n.rpc, 'eth_getBlockByNumber', ['latest', false]);74 const baseFee = hexToBigInt(block.baseFeePerGas ?? '0x0'); let tip = 1_000_000n; try { tip = hexToBigInt(await rpcAny<`0x${string}`>(n.rpc, 'eth_maxPriorityFeePerGas', [])); } catch { /* floor */ }75 const maxFeePerGas = baseFee * 2n + tip, gas = asset.id === 'native' ? 21_000n : 90_000n, fee = gas * maxFeePerGas;76 const amount = asset.id === 'native' ? (bal.native ?? 0n) - fee : (bal[asset.id] ?? 0n);77 if (amount <= 0n) throw new UserError('insufficient_funds', `nothing to withdraw in ${asset.symbol}`);78 if (asset.id !== 'native' && (bal.native ?? 0n) < fee) throw new UserError('insufficient_gas', `the vault needs a little ${n.native.symbol} to pay the network fee`);79 const now = Math.floor(Date.now() / 1000), id = randomUUID();80 const intent = IntentSchema.parse({ v: 1, proposalId: id, keyId: vaultKeyId(st.keyId, a.vault_index), network: n.id, chainRef: n.chainRef, asset: asset.id, symbol: asset.symbol, decimals: asset.decimals, amount: amount.toString(),81 from: a.vault_address, to: owner, maxFee: fee.toString(), createdAt: now, expiresAt: now + 120, policyVersion: st.policy.version,82 evm: { nonce, gasLimit: gas.toString(), maxFeePerGas: maxFeePerGas.toString(), maxPriorityFeePerGas: tip.toString() } });8384 // the row exists before anything is signed; the partial unique index makes a concurrent second request lose here85 const ins = await sql<WithdrawalRow[]>`insert into agent_withdrawals (id, agent_id, network, asset, symbol, decimals, amount, from_addr, to_addr, nonce, state, idempotency_key)86 values (${id}, ${a.id}, ${n.id}, ${asset.id}, ${asset.symbol}, ${asset.decimals}, ${amount.toString()}, ${a.vault_address}, ${owner}, ${nonce}, 'requested', ${b.idempotencyKey ?? null})87 on conflict do nothing returning *`;88 if (!ins.length) {89 const [other] = await sql<WithdrawalRow[]>`select * from agent_withdrawals where agent_id = ${a.id} and ((network = ${n.id} and asset = ${asset.id} and state in ${sql(WITHDRAWAL_OPEN)}) or idempotency_key = ${b.idempotencyKey ?? ''}) order by created_at desc limit 1`;90 if (other) return view(other, true);91 throw new UserError('busy', 'a withdrawal of this asset is already being prepared', 409);92 }93 let row = ins[0]!, signed;94 try { signed = await signer.vaultReturn(a.vault_index, intent); } // needs no mandate: the signer only ever pays the owner's own address95 catch (e) { await setState(row, 'failed', { error: `refused by the signer: ${(e as Error).message.slice(0, 200)}` }); throw e; }96 row = (await sql<WithdrawalRow[]>`update agent_withdrawals set state = 'signed', raw = ${signed.raw}, txid = ${signed.txid}, updated_at = now() where id = ${id} and state = 'requested' returning *`)[0] ?? await one(id);97 await audit('agent.withdraw', 'owner', { agent: a.id, id, network: n.id, symbol: asset.symbol, amount: amount.toString(), txid: signed.txid });98 return view(await advanceWithdrawal(row));99}100101type Receipt = { status: `0x${string}`; blockNumber: `0x${string}`; logs: { address: string; topics: [`0x${string}`, ...`0x${string}`[]]; data: `0x${string}` }[] } | null;102const lower = (s: string): string => s.toLowerCase();103/** Did the mined transaction pay the owner? Token: a Transfer(vault → owner, amount) log of the token. Native: the104 * transaction itself (to = owner, value = amount). */105async function arrival(r: WithdrawalRow, rc: NonNullable<Receipt>): Promise<NonNullable<WithdrawalRow['arrival']>> {106 const n = getNetwork(r.network), block = hexToNumber(rc.blockNumber);107 if (r.asset === 'native') {108 const tx = await rpcAny<{ to: string | null; value: `0x${string}` } | null>(n.rpc, 'eth_getTransactionByHash', [r.txid]).catch(() => null);109 const ok = !!tx && !!tx.to && lower(tx.to) === lower(r.to_addr) && hexToBigInt(tx.value) === BigInt(r.amount);110 return { verified: ok, received: ok ? r.amount : null, block };111 }112 for (const l of rc.logs) { if (lower(l.address) !== lower(r.asset)) continue;113 try { const ev = decodeEventLog({ abi: erc20Abi, data: l.data, topics: l.topics }) as unknown as { eventName: string; args: { from: string; to: string; value: bigint } };114 if (ev.eventName === 'Transfer' && lower(ev.args.from) === lower(r.from_addr) && lower(ev.args.to) === lower(r.to_addr)) return { verified: ev.args.value === BigInt(r.amount), received: ev.args.value.toString(), block }; } catch { /* another event */ } }115 return { verified: false, received: null, block };116}117118/** Move one withdrawal forward by at most one step. Never throws for a network reason: it is tried again at the next pass. */119export async function advanceWithdrawal(r: WithdrawalRow, now = Date.now()): Promise<WithdrawalRow> {120 const n = getNetwork(r.network), age = now - r.created_at.getTime();121 if (r.state === 'requested') return age > 2 * 60_000 ? setState(r, 'failed', { error: 'interrupted during signing; nothing was sent' }) : r; // the signer's replay rule: it cannot be signed again122 if (r.state === 'signed') {123 if (!r.raw || !r.txid) return setState(r, 'failed', { error: 'incomplete withdrawal; nothing was sent' });124 if (!config.broadcast) return setState(r, 'not_sent', { error: 'broadcast is disabled on this server; nothing was sent' });125 if (age > 10 * 60_000) return setState(r, 'not_sent', { error: 'the transaction could not be relayed in time; nothing was sent' });126 try { await adapterFor(n.id).broadcast(r.raw); } catch (e) {127 // "already known" / "nonce too low" may mean an earlier attempt did reach the network: the receipt decides128 const rc = await rpcAny<Receipt>(n.rpc, 'eth_getTransactionReceipt', [r.txid]).catch(() => undefined);129 if (!rc && !/already known|known transaction|already imported/i.test((e as Error).message)) { await sql`update agent_withdrawals set error = ${`not relayed yet: ${(e as Error).message.slice(0, 160)}`}, updated_at = now() where id = ${r.id} and state = 'signed'`; return one(r.id); }130 }131 return setState(r, 'broadcast');132 }133 if (r.state === 'broadcast') {134 const rc = await rpcAny<Receipt>(n.rpc, 'eth_getTransactionReceipt', [r.txid]).catch(() => undefined);135 if (rc === undefined) return r; // the node did not answer: nothing is concluded136 if (rc) {137 if (hexToNumber(rc.status) !== 1) return setState(r, 'failed', { error: 'the transaction reverted on-chain; only the network fee was spent' });138 const arr = await arrival(r, rc);139 return setState(r, 'confirmed', { arrival: arr, error: arr.verified ? null : 'mined, but the payment to your address was not found in the transaction: check the explorer' });140 }141 // not mined yet. If the vault's nonce moved past ours without this transaction, it can never be mined.142 const used = await rpcQuorum<`0x${string}`>(n.rpc, 'eth_getTransactionCount', [r.from_addr, 'latest']).then(hexToNumber, () => null);143 if (used !== null && used > r.nonce) {144 const again = await rpcAny<Receipt>(n.rpc, 'eth_getTransactionReceipt', [r.txid]).catch(() => undefined);145 if (again === null) return setState(r, 'failed', { error: 'another transaction of the vault used the same nonce; this withdrawal was not executed' });146 return r;147 }148 if (r.raw && now - (r.last_broadcast_at?.getTime() ?? 0) > 45_000) { await adapterFor(n.id).broadcast(r.raw).catch(() => {}); await sql`update agent_withdrawals set last_broadcast_at = now() where id = ${r.id}`; }149 }150 return r;151}152153/** Worker step: every open withdrawal, one step each. Errors never cross withdrawals. */154export async function advanceWithdrawals(now = Date.now()): Promise<void> {155 for (const r of await sql<WithdrawalRow[]>`select * from agent_withdrawals where state in ${sql(WITHDRAWAL_OPEN)} order by created_at`) {156 try { await advanceWithdrawal(r, now); } catch (e) { console.log(`[worker] withdrawal ${r.id}: ${(e as Error).message.slice(0, 160)}`); }157 }158}159export const listWithdrawals = async (agentId: string, limit = 20): Promise<WithdrawalView[]> =>160 (await sql<WithdrawalRow[]>`select * from agent_withdrawals where agent_id = ${agentId} order by created_at desc limit ${limit}`).map((r) => view(r));161export const getWithdrawal = async (agentId: string, id: string): Promise<WithdrawalView | null> => { const [r] = await sql<WithdrawalRow[]>`select * from agent_withdrawals where id = ${id} and agent_id = ${agentId}`; return r ? view(r) : null; };162