Repository Public
1// Collateral of a venue account (Hyperliquid): DEPOSIT USDC from the wallet, WITHDRAW it back. Facts, sources and evidence:2// docs/v4/venues/hyperliquid.md section 15; routes and shapes: docs/v4/API-LIVE-NOTES.md ("Collateral").3//4// Deposit = an ordinary wallet proposal (table `proposals`: passkey → signer → broadcast → receipt, the wallet's own worker5// tracks it) whose intent carries a `venueDeposit` section, plus a row here that follows the money to the venue: the credit is6// read from the venue's own ledger (`userNonFundingLedgerUpdates`: a `deposit` whose hash is our transaction hash). Success is7// never claimed before the venue shows it.8// Withdrawal = the canonical `VenueWithdrawal` approved by passkey, signed by the main key inside the signer (`withdraw3`, to the9// wallet's own address), sent to the venue, then followed twice: the venue's ledger (`withdraw`, fee) and the wallet's own10// chain scan (the USDC really arrived from the bridge).11//12// What this file can NOT do: it holds no key. A deposit goes to the bridge pinned in the SIGNER's bundle or is refused there;13// a withdrawal goes to the wallet's own address or is refused there. Mainnet needs SPW_DERIV_LIVE_MAINNET=on.14import { createHash, randomUUID } from 'node:crypto';15import { generateAuthenticationOptions } from '@simplewebauthn/server';16import { decodeFunctionResult, encodeFunctionData, formatUnits, hexToBigInt, hexToNumber, pad } from 'viem';17import { z } from 'zod';18import { buildEvmTx, CCTP_ENABLED, cctpUsdc, checkPolicy, getNetwork, IntentSchema, intentHash, toB64u, toHex, type Intent, type Policy } from '@spw/core';19import { hlExchangeAnswer, parseHl, VenueError } from '@spw/derivatives';20import {21 buildVenueWithdraw, checkVenueDepositIntent, hex, HL_BRIDGE_PINS, HL_COLLATERAL_PINNED_AT, HL_DEPOSIT_FLOOR_USDC, HL_DEPOSIT_MIN_USDC, HL_DEPOSIT_ROUTE, HL_WITHDRAW_FEE_USDC, recoverTypedDataSigner, usdcDecimal, usdcUnits,22 validateVenueWithdrawal, venueDepositSection, venueWithdrawalHash, VenueWithdrawalSchema, type HlBridgePin, type HlNetwork, type VenueWithdrawal,23} from '@spw/derivatives/live';24import { adapterFor } from '../chains/index.ts';25import { rpcAny, rpcQuorum, type Prepared } from '../chains/rpc.ts';26import { UserError } from '../config.ts';27import { audit, sql } from '../db.ts';28import { broadcastNow, move, type ProposalRow } from '../transfers.ts';29import { signer } from '../signer-client.ts';30import { loop } from '../worker.ts';31import { venueFailure } from './data.ts';32import type { LiveService } from './exec.ts';33import { live } from './live.ts';3435export const COLLATERAL_STEP_MS = 10_000;36/** "credited to the account that sent it in less than 1 minute" (venue docs); 6–10 s observed on 2026-11-11 */37export const DEPOSIT_EXPECTED_SEC = 60;38/** after this long without a credit the screen says exactly what to check (polling goes on) */39export const DEPOSIT_OVERDUE_MS = 10 * 60_000;40/** "the funds arrive in the user wallet in 3-4 minutes" (venue docs); about 232 s observed on 2026-11-11 */41export const WITHDRAW_EXPECTED_SEC = 240;42export const WITHDRAW_OVERDUE_MS = 15 * 60_000;43/** a withdrawal whose submission got no readable answer is given up (never assumed sent) after this long without any trace */44export const WITHDRAW_UNKNOWN_GIVE_UP_MS = 30 * 60_000;45const WITHDRAW_TTL_SEC = 180, RESUBMIT_EVERY_MS = 20_000, MAX_SUBMITS = 3, LEDGER_EVERY_MS = 8_000, LIVENESS_TTL_MS = 5 * 60_000, ABSTRACTION_TTL_MS = 10 * 60_000;46const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';47const MAINNET_OFF = 'moving collateral on Hyperliquid MAINNET is disabled on this server (SPW_DERIV_LIVE_MAINNET is not "on"): nothing was built, signed or sent';48const sha = (hexStr: string) => createHash('sha256').update(Buffer.from(hexStr.slice(2), 'hex')).digest('hex');49const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e)).slice(0, 300);5051export type DepositState = 'pending_approval' | 'signed' | 'sent' | 'awaiting_credit' | 'credited' | 'failed' | 'expired' | 'cancelled';52export type WithdrawState = 'pending_approval' | 'approved' | 'signed' | 'submitted' | 'unknown' | 'completed' | 'rejected' | 'failed' | 'expired' | 'cancelled';5354interface Row {55 id: string; account_id: string; kind: 'deposit' | 'withdraw'; state: string; final: boolean; venue_network: HlNetwork; amount: string; fee: string | null; chain_network: string | null;56 proposal_id: string | null; txid: string | null; op_hash: string | null; request: Record<string, any>; action: unknown; signature: { r: string; s: string; v: number } | null; venue_answer: unknown; nonce: string | null;57 balance_before: VenueBalance | null; balance_after: VenueBalance | null; ledger: LedgerEntry | null; arrival: Arrival | null; error: string | null; attempts: number;58 expires_at: Date | null; sent_at: Date | null; venue_at: Date | null; done_at: Date | null; checked_at: Date | null; created_at: Date; updated_at: Date;59}60interface AccountRow { id: string; venue: 'hyperliquid'; network: HlNetwork; account: string; label: string; mode: string }61export interface VenueBalance { abstraction: string | null; equity: string | null; withdrawable: string | null; spotUsdc: string | null; /** what the account holds as collateral, where this account keeps it */ total: string | null; error: string | null; ts: number }62export interface LedgerEntry { time: number; hash: string; type: string; usdc: string | null; fee: string | null; nonce: number | null }63export interface Arrival { txid: string; amount: string; seenAt: number }64export interface Liveness { ok: boolean; checkedAt: number; checked: number; credited: number; detail: string; samples: { tx: string; usdc: string; credited: boolean }[] }6566const ledgerAnswer = z.array(z.looseObject({ time: z.number(), hash: z.string(), delta: z.looseObject({ type: z.string(), usdc: z.string().optional(), fee: z.string().optional(), nonce: z.number().optional() }) }));67const spotAnswer = z.looseObject({ balances: z.array(z.looseObject({ coin: z.string(), total: z.string(), hold: z.string().optional() })) });6869// ---------------------------------------------------------------------------------------------------------------------------70// Ports: the signer, and the wallet's chain side (replaced by fakes in tests; the real one below reads the registry RPC endpoints)7172export interface CollateralSignerPort {73 status(fresh?: boolean): Promise<{ keyId: string; halted: boolean; addresses: Record<string, string>; policy: Policy; spentToday: Record<string, string> }>;74 sign(intent: unknown, assertion: unknown): Promise<{ raw: string; txid: string; hash: string }>;75 venueWithdraw(withdrawal: unknown, assertion: unknown): Promise<{ withdrawalId: string; hash: string; action: unknown; signature: { r: string; s: string; v: number }; nonce: number; signer: string }>;76}77export interface CollateralChain {78 /** the bridge is the pinned bytecode on every RPC source, not paused, pays the pinned token; the wallet address carries no code. Throws UserError. */79 guards(pin: HlBridgePin, me: string): Promise<void>;80 /** is the route crediting deposits NOW: recent transfers into the bridge matched with the venue's ledger */81 liveness(pin: HlBridgePin, ledger: (user: string, sinceMs: number) => Promise<LedgerEntry[]>): Promise<Liveness>;82 balances(network: string, address: string): Promise<{ usdc: bigint; native: bigint }>;83 /** native USDC the wallet holds on the other networks the cross-network transfer (CCTP) can bring from */84 elsewhere(address: string, except: string): Promise<{ network: string; name: string; usdc: string }[]>;85 prepare(pin: HlBridgePin, from: string, amount: bigint): Promise<Prepared>;86 /** USDC received by the wallet FROM the bridge since `sinceMs`, as the wallet's own chain scan recorded it */87 arrivals(pin: HlBridgePin, me: string, sinceMs: number): Promise<Arrival[]>;88}8990const bridgeAbi = [91 { type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },92 { type: 'function', name: 'usdcToken', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },93] as const;94export function realChain(now: () => number = Date.now): CollateralChain {95 let cached: Liveness | null = null;96 return {97 async guards(pin, me) {98 const n = getNetwork(pin.network);99 const q = <T>(m: string, p: unknown[]) => rpcQuorum<T>(n.rpc, m, p);100 const changed = (what: string) => new UserError('contract_changed', `${what} on ${n.name} is not what was reviewed; deposits are refused until it is reviewed again`, 503);101 if (sha(await q<string>('eth_getCode', [pin.bridge, 'latest'])) !== pin.codeSha256) throw changed('the bytecode of the venue bridge');102 const call = async (fn: 'paused' | 'usdcToken') => decodeFunctionResult({ abi: bridgeAbi, functionName: fn, data: await q<`0x${string}`>('eth_call', [{ to: pin.bridge, data: encodeFunctionData({ abi: bridgeAbi, functionName: fn }) }, 'latest']) });103 if (String(await call('usdcToken')).toLowerCase() !== pin.usdc.toLowerCase()) throw changed('the token of the venue bridge');104 if (await call('paused')) throw new UserError('bridge_paused', 'the venue bridge is locked (paused) by its validators: withdrawals are blocked there, so nothing is deposited', 503);105 // an EIP-7702 delegation on the wallet address would run someone's code on every coin received106 if ((await q<string>('eth_getCode', [me, 'latest'])) !== '0x') throw new UserError('account_has_code', 'this wallet address carries code (delegation); transfers are refused', 503);107 },108 async liveness(pin, ledger) {109 if (cached && cached.ok && now() - cached.checkedAt < LIVENESS_TTL_MS) return cached;110 const n = getNetwork(pin.network), min = usdcUnits(HL_DEPOSIT_MIN_USDC);111 const head = hexToNumber(await rpcAny<`0x${string}`>(n.rpc, 'eth_blockNumber', []));112 const hx = (v: number) => `0x${v.toString(16)}`, samples: Liveness['samples'] = [];113 // Arbitrum makes about 4 blocks a second: the last 10 minutes first, then the last hour; transfers younger than a minute are left out114 for (const span of [2400, 14_400]) {115 const logs = await rpcAny<{ transactionHash: string; topics: string[]; data: `0x${string}`; removed?: boolean }[]>(n.rpc, 'eth_getLogs',116 [{ address: pin.usdc, topics: [TRANSFER_TOPIC, null, pad(pin.bridge as `0x${string}`).toLowerCase()], fromBlock: hx(head - span), toBlock: hx(head - 240) }]);117 const picked = logs.filter((l) => !l.removed && l.topics.length === 3 && hexToBigInt(l.data) >= min).slice(-3).reverse();118 if (!picked.length) continue;119 for (const l of picked) {120 const user = `0x${l.topics[1]!.slice(26)}`;121 const led = await ledger(user, now() - 3 * 3_600_000);122 samples.push({ tx: l.transactionHash, usdc: formatUnits(hexToBigInt(l.data), 6), credited: led.some((x) => x.type === 'deposit' && x.hash.toLowerCase() === l.transactionHash.toLowerCase()) });123 }124 break;125 }126 const credited = samples.filter((s) => s.credited).length;127 const out: Liveness = { ok: credited > 0, checkedAt: now(), checked: samples.length, credited, samples,128 detail: samples.length === 0 ? 'no deposit of at least 5 USDC reached the bridge in the last hour: the route cannot be shown to work right now' : credited > 0129 ? `${credited} of the ${samples.length} most recent deposits into the bridge are in the venue's ledger` : `none of the ${samples.length} most recent deposits into the bridge is in the venue's ledger` };130 if (out.ok) cached = out;131 return out;132 },133 async balances(network, address) {134 const b = await adapterFor(network).balances(address), usdc = b[cctpUsdc(network).id];135 if (usdc === undefined) throw new UserError('rpc_degraded', 'the USDC balance could not be read', 503);136 return { usdc, native: b.native ?? 0n };137 },138 async elsewhere(address, except) {139 const nets = Object.keys(CCTP_ENABLED).filter((id) => id !== except);140 const rs = await Promise.allSettled(nets.map(async (id) => ({ network: id, name: getNetwork(id).name, usdc: (await adapterFor(id).balances(address))[cctpUsdc(id).id] ?? 0n })));141 return rs.flatMap((r) => (r.status === 'fulfilled' && r.value.usdc > 0n ? [{ network: r.value.network, name: r.value.name, usdc: formatUnits(r.value.usdc, 6) }] : []));142 },143 prepare: (pin, from, amount) => adapterFor(pin.network).prepare({ asset: pin.usdc, from, to: pin.bridge, amount }),144 async arrivals(pin, me, sinceMs) {145 // `deposits` is filled by the wallet's own worker from its chain adapter (Transfer logs to the wallet address)146 void me;147 const rows = await sql<{ txid: string; amount: string; seen_at: Date }[]>`select txid, amount, seen_at from deposits where network = ${pin.network} and lower(asset) = ${pin.usdc.toLowerCase()}148 and lower(from_addr) = ${pin.bridge.toLowerCase()} and seen_at >= ${new Date(sinceMs)} order by seen_at`;149 return rows.map((r) => ({ txid: r.txid, amount: String(r.amount), seenAt: r.seen_at.getTime() }));150 },151 };152}153154// ---------------------------------------------------------------------------------------------------------------------------155156export const AmountBodySchema = z.strictObject({ amount: z.string().regex(/^(0|[1-9]\d{0,8})(\.\d{1,6})?$/, 'a USDC amount with at most 6 decimals') });157export const CollateralAssertionSchema = z.strictObject({ assertion: z.record(z.string(), z.unknown()) });158export interface CollateralDeps { live: LiveService; signer: CollateralSignerPort; chain: CollateralChain }159160export class CollateralService {161 readonly deps: CollateralDeps;162 readonly now: () => number;163 private readonly abstraction = new Map<string, { at: number; v: string | null }>();164 constructor(deps: CollateralDeps) { this.deps = deps; this.now = deps.live.now; }165166 // ------------------------------------------------------------------ helpers167168 private async account(id: string): Promise<AccountRow> {169 const [a] = await sql<AccountRow[]>`select id, venue, network, account, label, mode from deriv_accounts where id = ${id}`;170 if (!a) throw new UserError('unknown_account', 'unknown venue account', 404);171 return a;172 }173 private async row(accountId: string, opId: string, kind?: 'deposit' | 'withdraw'): Promise<Row> {174 const [r] = await sql<Row[]>`select * from deriv_collateral where id = ${opId} and account_id = ${accountId}`;175 if (!r || (kind && r.kind !== kind)) throw new UserError('unknown_operation', 'unknown collateral operation', 404);176 return r;177 }178 private async set(r: Row, patch: Record<string, unknown>, from?: string): Promise<Row> {179 const p = { ...patch, updated_at: new Date(this.now()) };180 const [u] = from === undefined ? await sql<Row[]>`update deriv_collateral set ${sql(p as never)} where id = ${r.id} returning *`181 : await sql<Row[]>`update deriv_collateral set ${sql(p as never)} where id = ${r.id} and state = ${from} returning *`;182 if (!u) throw new UserError('conflict', 'the operation changed concurrently; reload', 409);183 return u;184 }185 private log(type: string, r: { id: string; account_id: string; kind: string; amount: string; venue_network: string }, data: Record<string, unknown> = {}, actor = 'system'): Promise<void> {186 return audit(`deriv.collateral.${type}`, actor, { account: r.account_id, id: r.id, kind: r.kind, amount: String(r.amount), network: r.venue_network, ...data }).catch((e: Error) => console.error(`[collateral] audit ${type} failed: ${e.message}`));187 }188 private needMainnet(network: HlNetwork): void { if (network === 'mainnet' && !this.deps.live.deps.mainnetEnabled()) throw new UserError('mainnet_disabled', MAINNET_OFF, 403); }189 private venue(network: HlNetwork) { return this.deps.live.deps.venues.get(network); }190 private async options(hash: Uint8Array, ttlSec: number) {191 const creds = await this.deps.live.deps.credentials();192 const options = await generateAuthenticationOptions({ rpID: this.deps.live.deps.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: ttlSec * 1000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });193 if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');194 return options;195 }196197 /** the venue's own record of money movements of an address (deposits, withdrawals) */198 async ledger(network: HlNetwork, user: string, sinceMs: number): Promise<LedgerEntry[]> {199 try {200 const a = parseHl(ledgerAnswer, await this.venue(network).adapter.http.info({ type: 'userNonFundingLedgerUpdates', user: user.toLowerCase(), startTime: Math.max(0, Math.floor(sinceMs)) }), 'userNonFundingLedgerUpdates');201 return a.map((x) => ({ time: x.time, hash: x.hash, type: x.delta.type, usdc: x.delta.usdc ?? null, fee: x.delta.fee ?? null, nonce: x.delta.nonce ?? null }));202 } catch (e) { throw venueFailure(e); }203 }204205 /**206 * What the account holds at the venue. A new account is in the venue's `default` mode: a bridge deposit lands in the perp207 * margin (`clearinghouseState`), ready to trade. Under the venue's "unified account" / "portfolio margin" modes the same208 * money is reported in the spot state instead — read too, so that a credit is never missed.209 */210 async venueBalance(a: AccountRow): Promise<VenueBalance> {211 const v = this.venue(a.network), ts = this.now();212 try {213 let mode = this.abstraction.get(a.id);214 if (!mode || ts - mode.at > ABSTRACTION_TTL_MS) {215 const raw = await v.adapter.http.info({ type: 'userAbstraction', user: a.account }).catch(() => null);216 mode = { at: ts, v: typeof raw === 'string' ? raw.slice(0, 40) : null };217 this.abstraction.set(a.id, mode);218 }219 const st = await v.adapter.account(a.account);220 let spotUsdc: string | null = null;221 if (mode.v !== 'default') {222 const s = spotAnswer.safeParse(await v.adapter.http.info({ type: 'spotClearinghouseState', user: a.account }).catch(() => null));223 spotUsdc = s.success ? (s.data.balances.find((b) => b.coin === 'USDC')?.total ?? '0') : null;224 }225 const unified = mode.v === 'unifiedAccount' || mode.v === 'portfolioMargin';226 return { abstraction: mode.v, equity: st.equity, withdrawable: st.withdrawable, spotUsdc, total: unified && spotUsdc !== null ? spotUsdc : st.equity, error: null, ts };227 } catch (e) { return { abstraction: null, equity: null, withdrawable: null, spotUsdc: null, total: null, error: msg(venueFailure(e)), ts }; }228 }229230 // ------------------------------------------------------------------ views231232 private view(r: Row, p?: ProposalRow | null) {233 const now = this.now(), pin = HL_BRIDGE_PINS[r.venue_network], n = r.chain_network ? getNetwork(r.chain_network) : null;234 const sent = r.sent_at?.getTime() ?? null;235 const overdue = !r.final && sent !== null && (r.kind === 'deposit' ? r.state === 'awaiting_credit' && now - sent > DEPOSIT_OVERDUE_MS : (r.state === 'submitted' || r.state === 'unknown') && now - sent > WITHDRAW_OVERDUE_MS);236 const fee = r.fee === null ? null : usdcDecimal(usdcUnits(D6(r.fee)));237 return {238 id: r.id, kind: r.kind, state: r.state, final: r.final, venueNetwork: r.venue_network, amount: usdcDecimal(usdcUnits(D6(r.amount))), asset: 'USDC', fee,239 /** withdrawal: what should arrive on the wallet — the venue takes its fee out of the amount (ledger: `usdc` received + `fee`) */240 expectedReceive: r.kind === 'withdraw' && fee !== null ? usdcDecimal(usdcUnits(D6(r.amount)) - usdcUnits(fee)) : null,241 chainNetwork: r.chain_network, chainNetworkName: n?.name ?? null, bridge: r.kind === 'deposit' ? pin?.bridge ?? null : null,242 txid: r.txid, explorer: r.txid && n ? n.explorerTx + r.txid : null, hash: r.op_hash, proposalId: r.proposal_id, proposalState: p?.state ?? null, confirmations: p?.confirmations ?? null,243 createdAt: r.created_at.getTime(), expiresAt: r.expires_at?.getTime() ?? null, sentAt: sent, venueAt: r.venue_at?.getTime() ?? null, doneAt: r.done_at?.getTime() ?? null, checkedAt: r.checked_at?.getTime() ?? null,244 expectedSeconds: r.kind === 'deposit' ? DEPOSIT_EXPECTED_SEC : WITHDRAW_EXPECTED_SEC, overdue,245 /** when overdue: what to look at, in order (machine codes; the screen has the words) */246 check: overdue ? (r.kind === 'deposit' ? ['tx_success', 'tx_to_bridge', 'amount_min', 'venue_ledger', 'venue_status'] : ['venue_ledger', 'bridge_paused', 'wallet_scan', 'venue_status']) : [],247 balanceBefore: r.balance_before, balanceAfter: r.balance_after, ledger: r.ledger, arrival: r.arrival, venueAnswer: r.venue_answer ?? null, error: r.error, attempts: r.attempts,248 arrivalTracked: r.kind === 'withdraw' ? !!pin : null,249 };250 }251 private async views(rows: Row[]) {252 const ids = rows.flatMap((r) => (r.proposal_id ? [r.proposal_id] : []));253 const ps = ids.length ? await sql<ProposalRow[]>`select * from proposals where id in ${sql(ids)}` : [];254 return rows.map((r) => this.view(r, ps.find((p) => p.id === r.proposal_id) ?? null));255 }256257 /** everything the two sheets need: venue balance, the route and its facts, the wallet side, the limits, the operations */258 async status(id: string) {259 const a = await this.account(id), pin = HL_BRIDGE_PINS[a.network] ?? null, mainnetEnabled = this.deps.live.deps.mainnetEnabled();260 await this.refreshOpen(id).catch(() => {});261 const [balance, rows] = await Promise.all([this.venueBalance(a), sql<Row[]>`select * from deriv_collateral where account_id = ${id} order by created_at desc limit 20`]);262 let wallet: { address: string; network: string; networkName: string; usdc: string; gas: string; gasSymbol: string; limits: { perTransfer: string; perDay: string; spentToday: string; maxNow: string }; elsewhere: { network: string; name: string; usdc: string }[] } | null = null;263 let walletError: string | null = null, depositReason: string | null = null, signerHalted = false;264 if (pin) {265 try {266 const st = await this.deps.signer.status(), me = st.addresses[pin.network], n = getNetwork(pin.network), key = `${pin.network}:USDC`;267 signerHalted = st.halted;268 if (!me || me.toLowerCase() !== a.account) throw new UserError('wrong_account', 'the venue account is not this wallet\'s address', 409);269 const b = await this.deps.chain.balances(pin.network, me);270 const perTx = BigInt(st.policy.perTx[key] ?? '0'), perDay = BigInt(st.policy.daily[key] ?? '0'), spent = BigInt(st.spentToday[key] ?? '0');271 const room = perDay > spent ? perDay - spent : 0n, maxNow = [perTx, room, b.usdc].reduce((x, y) => (y < x ? y : x));272 const floor = usdcUnits(HL_DEPOSIT_FLOOR_USDC);273 wallet = { address: me, network: pin.network, networkName: n.name, usdc: formatUnits(b.usdc, 6), gas: formatUnits(b.native, n.native.decimals), gasSymbol: n.native.symbol,274 limits: { perTransfer: formatUnits(perTx, 6), perDay: formatUnits(perDay, 6), spentToday: formatUnits(spent, 6), maxNow: formatUnits(maxNow, 6) },275 elsewhere: b.usdc < floor ? await this.deps.chain.elsewhere(me, pin.network) : [] };276 depositReason = b.usdc < floor ? (wallet.elsewhere.length ? 'usdc_elsewhere' : 'no_usdc') : b.native === 0n ? 'no_gas' : (perTx < floor || room < floor) ? 'limit_reached' : null;277 } catch (e) { walletError = msg(e); depositReason = 'wallet_unreadable'; }278 }279 const depositBlock = !pin ? 'no_route' : !mainnetEnabled && a.network === 'mainnet' ? 'mainnet_disabled' : signerHalted ? 'halted' : depositReason;280 const withdrawBlock = a.network === 'mainnet' && !mainnetEnabled ? 'mainnet_disabled' : signerHalted ? 'halted' : balance.error ? 'venue_unreadable' : null;281 return {282 account: a.account, accountId: a.id, venue: a.venue, network: a.network, mainnetEnabled, venueBalance: balance,283 /** deposit before the authorization: the venue refuses every action of an account that never deposited ("Must deposit before performing actions") */284 route: pin ? { id: HL_DEPOSIT_ROUTE, network: pin.network, networkName: getNetwork(pin.network).name, bridge: pin.bridge, bridgeExplorer: getNetwork(pin.network).explorerAddr + pin.bridge, asset: 'USDC', token: pin.usdc,285 venueMinimum: HL_DEPOSIT_MIN_USDC, minimum: HL_DEPOSIT_FLOOR_USDC, expectedSeconds: DEPOSIT_EXPECTED_SEC, credits: 'sender' as const, venueFee: '0', legacy: true, pinnedAt: HL_COLLATERAL_PINNED_AT, codeSha256: pin.codeSha256 } : null,286 withdrawal: { fee: HL_WITHDRAW_FEE_USDC, expectedSeconds: WITHDRAW_EXPECTED_SEC, destination: a.account, payoutNetworkName: a.network === 'mainnet' ? 'Arbitrum One' : 'Arbitrum Sepolia', arrivalTracked: !!pin,287 available: balance.abstraction === 'unifiedAccount' || balance.abstraction === 'portfolioMargin' ? balance.spotUsdc : balance.withdrawable },288 wallet, walletError,289 deposit: { available: depositBlock === null, reason: depositBlock, bridgeFrom: wallet?.elsewhere ?? [] },290 withdraw: { available: withdrawBlock === null, reason: withdrawBlock },291 operations: await this.views(rows), ts: this.now(),292 };293 }294295 async operation(id: string, opId: string) {296 let r = await this.row(id, opId);297 if (!r.final) r = await this.refresh(r, true).catch(() => r);298 return { operation: (await this.views([r]))[0]! };299 }300301 /** before the passkey only: nothing was signed */302 async cancel(id: string, opId: string) {303 let r = await this.row(id, opId);304 if (r.state !== 'pending_approval') throw new UserError('bad_state', `the operation is ${r.state}: it can no longer be cancelled here`, 409);305 if (r.proposal_id) { const [p] = await sql<ProposalRow[]>`select * from proposals where id = ${r.proposal_id}`; if (p && p.state === 'pending_approval') await move(p, 'rejected', { error: 'rejected by owner' }); }306 r = await this.set(r, { state: 'cancelled', final: true, done_at: new Date(this.now()), error: 'cancelled before approval; nothing was signed' }, 'pending_approval');307 await this.log('cancelled', r, {}, 'owner');308 return { operation: (await this.views([r]))[0]! };309 }310311 // ------------------------------------------------------------------ deposit312313 async prepareDeposit(id: string, body: z.infer<typeof AmountBodySchema>) {314 const a = await this.account(id), pin = HL_BRIDGE_PINS[a.network];315 if (!pin) throw new UserError('no_route', `no deposit route exists in this wallet for Hyperliquid ${a.network}: testnet collateral is mock USDC from the venue's faucet`, 422);316 this.needMainnet(a.network);317 const amount = usdcUnits(body.amount), floor = usdcUnits(HL_DEPOSIT_FLOOR_USDC);318 if (amount < floor) throw new UserError('below_minimum', `the smallest deposit is ${HL_DEPOSIT_FLOOR_USDC} USDC: the venue credits nothing under ${HL_DEPOSIT_MIN_USDC} USDC and such a transfer would be lost`, 422);319 const st = await this.deps.signer.status(true), n = getNetwork(pin.network), me = st.addresses[pin.network];320 if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);321 if (!me || me.toLowerCase() !== a.account) throw new UserError('wrong_account', 'the venue account is not this wallet\'s own address: the venue credits the sender, so nothing is deposited', 409);322 const [open] = await sql<Row[]>`select * from deriv_collateral where account_id = ${id} and kind = 'deposit' and not final and state <> 'pending_approval' limit 1`;323 if (open) throw new UserError('deposit_in_progress', 'a deposit is already on its way to the venue; wait for the credit before sending another', 409);324 const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${pin.network} and state in ('approved', 'signed', 'broadcast') limit 1`;325 if (busy) throw new UserError('pending_tx', `a previous transfer on ${n.name} is not confirmed yet; wait for it before depositing`, 409);326 // 1. the contract is the reviewed one, on every RPC source; 2. the route credits deposits right now327 await this.deps.chain.guards(pin, me);328 const live = await this.deps.chain.liveness(pin, (user, since) => this.ledger(a.network, user, since));329 if (!live.ok) throw new UserError('route_not_proven', `the deposit route could not be shown to work right now (${live.detail}); nothing was prepared`, 503);330 const bal = await this.deps.chain.balances(pin.network, me);331 if (bal.usdc < amount) {332 const el = await this.deps.chain.elsewhere(me, pin.network).catch(() => []);333 throw new UserError('insufficient_usdc', `the wallet holds ${formatUnits(bal.usdc, 6)} USDC on ${n.name}${el.length ? `; it holds USDC on ${el.map((x) => `${x.name} (${x.usdc})`).join(', ')}: move it to ${n.name} first with the cross-network transfer` : ''}`, 422);334 }335 const prep = await this.deps.chain.prepare(pin, me, amount);336 const now = Math.floor(this.now() / 1000), proposalId = randomUUID();337 const intent: Intent = IntentSchema.parse({ v: 1, proposalId, keyId: st.keyId, network: n.id, chainRef: n.chainRef, asset: pin.usdc, symbol: 'USDC', decimals: 6, amount: amount.toString(), from: me, to: pin.bridge,338 maxFee: prep.maxFee.toString(), createdAt: now, expiresAt: now + prep.ttlSec, policyVersion: st.policy.version, venueDeposit: venueDepositSection(a.network), ...prep.section });339 // the same rules and the same bytes as the signer, before the owner is asked340 try { checkVenueDepositIntent(intent, me); buildEvmTx(intent); } catch (e) { throw new UserError('simulation_failed', msg(e), 422); }341 const verdict = checkPolicy(st.policy, intent, BigInt(st.spentToday[`${n.id}:USDC`] ?? '0'), now, st.halted);342 if (!verdict.ok) throw new UserError('policy', verdict.reason, 422);343 const hash = intentHash(intent), before = await this.venueBalance(a), opId = randomUUID(), created = new Date(this.now()), expires = new Date(intent.expiresAt * 1000);344 // one deposit under review at a time: the previous unapproved one is withdrawn345 for (const old of await sql<Row[]>`select * from deriv_collateral where account_id = ${id} and kind = 'deposit' and state = 'pending_approval'`) await this.cancel(id, old.id).catch(() => {});346 await sql`insert into proposals (id, idempotency_key, state, network, asset, symbol, decimals, amount, from_addr, to_addr, note, fee_estimate, max_fee, warnings, intent, intent_hash, expires_at, created_at, updated_at)347 values (${proposalId}, ${`venue-deposit-${opId}`}, 'pending_approval', ${n.id}, ${pin.usdc}, 'USDC', 6, ${amount.toString()}, ${me}, ${pin.bridge}, ${`Hyperliquid ${a.network} deposit`}, ${prep.feeEstimate.toString()}, ${prep.maxFee.toString()},348 ${sql.json(['venue_deposit'])}, ${sql.json(intent as never)}, ${toHex(hash)}, ${expires}, ${created}, ${created})`;349 const [r] = await sql<Row[]>`insert into deriv_collateral (id, account_id, kind, state, venue_network, amount, chain_network, proposal_id, op_hash, request, balance_before, expires_at, created_at, updated_at)350 values (${opId}, ${id}, 'deposit', 'pending_approval', ${a.network}, ${formatUnits(amount, 6)}, ${n.id}, ${proposalId}, ${toHex(hash)}, ${sql.json({ liveness: live } as never)}, ${sql.json(before as never)}, ${expires}, ${created}, ${created}) returning *`;351 await this.log('deposit_prepared', r!, { proposal: proposalId, hash: toHex(hash), bridge: pin.bridge, chain: n.id, liveness: live.detail }, 'owner');352 return { operation: (await this.views([r!]))[0]!, intent, route: { network: n.id, networkName: n.name, bridge: pin.bridge, minimum: HL_DEPOSIT_FLOOR_USDC, venueMinimum: HL_DEPOSIT_MIN_USDC, expectedSeconds: DEPOSIT_EXPECTED_SEC },353 fee: { estimate: formatUnits(prep.feeEstimate, n.native.decimals), max: formatUnits(prep.maxFee, n.native.decimals), symbol: n.native.symbol }, liveness: live, venueBalance: before,354 options: await this.options(hash, prep.ttlSec) };355 }356357 /** The HTTP call is not the approval: the passkey assertion over the intent hash is, and only the signer judges it. */358 async approveDeposit(id: string, opId: string, assertion: unknown) {359 const a = await this.account(id), pin = HL_BRIDGE_PINS[a.network];360 let r = await this.row(id, opId, 'deposit');361 if (!pin || !r.proposal_id) throw new UserError('no_route', 'no deposit route', 422);362 this.needMainnet(a.network);363 let [p] = await sql<ProposalRow[]>`select * from proposals where id = ${r.proposal_id}`;364 if (r.state !== 'pending_approval' || !p || p.state !== 'pending_approval' || !p.intent) throw new UserError('bad_state', `the deposit is ${r.state}`, 409);365 if (p.expires_at && p.expires_at.getTime() < this.now()) { await this.refresh(r, true); throw new UserError('expired', 'approval window expired; start again', 410); }366 const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${p.network} and id <> ${p.id} and state in ('approved', 'signed', 'broadcast') limit 1`;367 if (busy) throw new UserError('pending_tx', 'a previous transfer on this network is not confirmed yet', 409);368 await this.deps.chain.guards(pin, p.from_addr); // the contract again, at the moment of signing369 r = await this.set(r, { state: 'signed' }, 'pending_approval');370 p = await move(p, 'approved');371 let signed: { raw: string; txid: string; hash: string };372 try { signed = await this.deps.signer.sign(p.intent, assertion); }373 catch (e) {374 await move(p, 'failed', { error: msg(e) });375 r = await this.set(r, { state: 'failed', final: true, done_at: new Date(this.now()), error: `the signer refused: ${msg(e)}. Nothing was signed or sent.` });376 await this.log('deposit_refused', r, { error: msg(e) });377 throw e;378 }379 if (signed.hash !== p.intent_hash) { await move(p, 'failed', { error: 'signer hash mismatch' }); await this.set(r, { state: 'failed', final: true, error: 'signer hash mismatch; nothing was sent' }); throw new UserError('hash_mismatch', 'signer returned a different intent hash', 500); }380 p = await move(p, 'signed', { raw: signed.raw, txid: signed.txid });381 r = await this.set(r, { txid: signed.txid });382 await this.log('deposit_signed', r, { txid: signed.txid, hash: signed.hash }, 'owner');383 p = await broadcastNow(p);384 r = await this.refresh(r, true);385 return { operation: (await this.views([r]))[0]! };386 }387388 // ------------------------------------------------------------------ withdrawal389390 async prepareWithdraw(id: string, body: z.infer<typeof AmountBodySchema>) {391 const a = await this.account(id);392 this.needMainnet(a.network);393 const amount = usdcUnits(body.amount), fee = usdcUnits(HL_WITHDRAW_FEE_USDC);394 if (amount <= fee) throw new UserError('below_fee', `the venue keeps ${HL_WITHDRAW_FEE_USDC} USDC of every withdrawal: the amount must be greater than that`, 422);395 const st = await this.deps.signer.status(true);396 if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);397 if ((st.addresses.evm ?? '').toLowerCase() !== a.account) throw new UserError('wrong_account', 'the venue account is not this wallet\'s own address', 409);398 const [open] = await sql<Row[]>`select * from deriv_collateral where account_id = ${id} and kind = 'withdraw' and not final and state <> 'pending_approval' limit 1`;399 if (open) throw new UserError('withdrawal_in_progress', 'a withdrawal is already in progress; wait for it to arrive before asking for another', 409);400 const before = await this.venueBalance(a);401 if (before.error) throw new UserError('venue_unavailable', `the venue account could not be read (${before.error}); nothing was prepared`, 503);402 const unified = before.abstraction === 'unifiedAccount' || before.abstraction === 'portfolioMargin', available = unified ? before.spotUsdc : before.withdrawable;403 if (available !== null && usdcUnits(D6(available)) < amount) throw new UserError('insufficient_collateral', `the venue reports ${available} USDC withdrawable on this account`, 422);404 const [nr] = await sql<{ last_nonce: string }[]>`update deriv_accounts set last_nonce = greatest(last_nonce + 1, ${this.now()}::bigint) where id = ${id} returning last_nonce`;405 const time = Number(nr!.last_nonce), createdAt = Math.floor(this.now() / 1000);406 const w: VenueWithdrawal = VenueWithdrawalSchema.parse({ v: 1, withdrawalId: randomUUID(), keyId: st.keyId, venue: 'hyperliquid', network: a.network, account: a.account, destination: a.account,407 amount: usdcDecimal(amount), fee: HL_WITHDRAW_FEE_USDC, time, createdAt, expiresAt: createdAt + WITHDRAW_TTL_SEC });408 let built: ReturnType<typeof buildVenueWithdraw>;409 try { validateVenueWithdrawal(w, createdAt); built = buildVenueWithdraw(w, a.account); } catch (e) { throw new UserError('bad_request', msg(e), 422); }410 const hash = venueWithdrawalHash(w), created = new Date(this.now()), pin = HL_BRIDGE_PINS[a.network];411 for (const old of await sql<Row[]>`select * from deriv_collateral where account_id = ${id} and kind = 'withdraw' and state = 'pending_approval'`) await this.cancel(id, old.id).catch(() => {});412 const [r] = await sql<Row[]>`insert into deriv_collateral (id, account_id, kind, state, venue_network, amount, fee, chain_network, op_hash, request, nonce, balance_before, expires_at, created_at, updated_at)413 values (${w.withdrawalId}, ${id}, 'withdraw', 'pending_approval', ${a.network}, ${w.amount}, ${HL_WITHDRAW_FEE_USDC}, ${pin?.network ?? null}, ${hex(hash)}, ${sql.json({ withdrawal: w } as never)}, ${time}, ${sql.json(before as never)},414 ${new Date(w.expiresAt * 1000)}, ${created}, ${created}) returning *`;415 await this.log('withdraw_prepared', r!, { hash: hex(hash), destination: w.destination, time }, 'owner');416 return { operation: (await this.views([r!]))[0]!, withdrawal: w, hash: hex(hash),417 whatIsSigned: { byMainKey: `Hyperliquid withdraw3 (${built.action.hyperliquidChain}): ${built.action.amount} USDC to ${built.action.destination}, time ${built.action.time}`, byPasskey: `SHA-256 of the canonical withdrawal ${hex(hash)}`, action: built.action },418 receive: usdcDecimal(amount - fee), venueBalance: before, options: await this.options(hash, WITHDRAW_TTL_SEC) };419 }420421 async approveWithdraw(id: string, opId: string, assertion: unknown) {422 const a = await this.account(id);423 let r = await this.row(id, opId, 'withdraw');424 this.needMainnet(a.network);425 if (r.state !== 'pending_approval') throw new UserError('bad_state', `the withdrawal is ${r.state}`, 409);426 if (r.expires_at && r.expires_at.getTime() < this.now()) { await this.refresh(r, true); throw new UserError('expired', 'approval window expired; start again', 410); }427 const w = VenueWithdrawalSchema.parse(r.request.withdrawal);428 r = await this.set(r, { state: 'approved' }, 'pending_approval');429 const fail = async (why: string, e?: unknown): Promise<never> => {430 r = await this.set(r, { state: 'failed', final: true, done_at: new Date(this.now()), error: `${why}. Nothing was sent to the venue.` });431 await this.log('withdraw_refused', r, { error: why });432 throw e ?? new UserError('signer_mismatch', why, 502);433 };434 let s: Awaited<ReturnType<CollateralSignerPort['venueWithdraw']>>;435 try { s = await this.deps.signer.venueWithdraw(w, assertion); } catch (e) { return fail(`the signer refused: ${msg(e)}`, e); }436 // the API checks the signer's answer before anything leaves: same canonical hash, the action it expects, a signature of the account437 const built = buildVenueWithdraw(w, a.account);438 if (s.hash !== r.op_hash || JSON.stringify(s.action) !== JSON.stringify(built.action) || s.nonce !== w.time) return fail('the signer returned another action than the approved withdrawal');439 if ((await recoverTypedDataSigner(built.typedData, s.signature).catch(() => '')) !== a.account) return fail('the signature does not recover to the venue account');440 // json, not jsonb: the action is kept with its keys in the venue's order (sql.json would go through jsonb and reorder them)441 const [signedRow] = await sql<Row[]>`update deriv_collateral set state = 'signed', action = ${s.action as never}::json, signature = ${sql.json(s.signature as never)}, updated_at = ${new Date(this.now())} where id = ${r.id} and state = 'approved' returning *`;442 if (!signedRow) throw new UserError('conflict', 'the operation changed concurrently; reload', 409);443 r = signedRow;444 await this.log('withdraw_signed', r, { hash: s.hash, destination: built.action.destination, time: w.time }, 'owner');445 r = await this.submit(a, r);446 return { operation: (await this.views([r]))[0]! };447 }448449 /** Send the stored, signed action. The same bytes can be sent again: the venue accepts a nonce once. Mainnet needs the server setting. */450 private async submit(a: AccountRow, r: Row): Promise<Row> {451 this.needMainnet(a.network);452 const first = r.attempts === 0, at = new Date(this.now());453 r = await this.set(r, { attempts: r.attempts + 1, checked_at: at, request: sql.json({ ...r.request, lastSubmitAt: at.getTime() } as never), ...(r.sent_at ? {} : { sent_at: at }) });454 let raw: unknown;455 try { raw = await this.venue(a.network).adapter.http.exchange({ action: r.action, nonce: Number(r.nonce), signature: r.signature, vaultAddress: null, expiresAfter: null }, 1); }456 catch (e) {457 // an HTTP refusal of the request itself (malformed for the venue) on the first attempt is a refusal; anything else is unknown458 if (first && e instanceof VenueError && e.code === 'rejected') { r = await this.set(r, { state: 'rejected', final: true, done_at: at, error: `the venue refused the request: ${msg(e)}`, venue_answer: sql.json({ error: msg(e) } as never) }); await this.log('withdraw_rejected', r, { error: msg(e) }); return r; }459 r = await this.set(r, { state: 'unknown', error: `no readable answer from the venue (${msg(e)}); the outcome is unknown until the venue's ledger or the wallet shows it` });460 await this.log('withdraw_unknown', r, { error: msg(e) });461 return r;462 }463 const ans = hlExchangeAnswer.safeParse(raw);464 if (!ans.success) { r = await this.set(r, { state: 'unknown', venue_answer: sql.json({ raw } as never), error: 'the venue\'s answer could not be read; the outcome is unknown until the venue\'s ledger or the wallet shows it' }); await this.log('withdraw_unknown', r); return r; }465 if (ans.data.status === 'ok') { r = await this.set(r, { state: 'submitted', venue_answer: sql.json(raw as never), error: null }); await this.log('withdraw_submitted', r, { attempt: r.attempts }); return r; }466 const said = String(ans.data.response).slice(0, 300);467 if (first) { r = await this.set(r, { state: 'rejected', final: true, done_at: at, venue_answer: sql.json(raw as never), error: `the venue refused the withdrawal: ${said}` }); await this.log('withdraw_rejected', r, { error: said }); return r; }468 // a refusal of a RE-submission proves nothing about the first attempt (it may be "nonce already used"): keep watching469 return this.set(r, { state: 'unknown', venue_answer: sql.json(raw as never), error: `the venue answered "${said}" to the repeated request; the first one may have been accepted — watching the ledger and the wallet` });470 }471472 // ------------------------------------------------------------------ reconciliation473474 /** One operation forward, from the venue's ledger and the wallet's own chain records. Never signs. */475 private async refresh(r: Row, force = false): Promise<Row> {476 if (r.final) return r;477 const now = this.now(), a = await this.account(r.account_id), pin = HL_BRIDGE_PINS[r.venue_network];478 const due = force || !r.checked_at || now - r.checked_at.getTime() >= LEDGER_EVERY_MS;479480 if (r.kind === 'deposit') {481 const [p] = r.proposal_id ? await sql<ProposalRow[]>`select * from proposals where id = ${r.proposal_id}` : [];482 if (!p) return this.set(r, { state: 'failed', final: true, error: 'the wallet proposal of this deposit no longer exists' });483 let [p0] = [p];484 // the wallet's worker expires stale proposals; done here too so that the screen never waits for it485 if (p0.state === 'pending_approval' && p0.expires_at && p0.expires_at.getTime() < now) p0 = await move(p0, 'expired', { error: 'expired before approval' }).catch(() => p0);486 return this.refreshDeposit(r, p0, a, now, due);487 }488 return this.refreshWithdraw(r, a, pin ?? null, now, due);489 }490491 private async refreshDeposit(r: Row, p: ProposalRow, a: AccountRow, now: number, due: boolean): Promise<Row> {492 {493 const dead = ['failed', 'replaced', 'expired', 'rejected'].includes(p.state);494 if (dead) {495 const never = !p.txid || p.state === 'expired' || p.state === 'rejected';496 const state = p.state === 'expired' ? 'expired' : p.state === 'rejected' ? 'cancelled' : 'failed';497 r = await this.set(r, { state, final: true, done_at: new Date(now), txid: p.txid, error: `${p.error ?? p.state}${never ? '; nothing left the wallet' : '; the transfer did not happen on-chain: nothing reached the venue'}` });498 await this.log(`deposit_${state}`, r, { error: p.error });499 return r;500 }501 const state = p.state === 'pending_approval' ? 'pending_approval' : p.state === 'approved' || p.state === 'signed' ? 'signed' : p.state === 'confirming' || p.state === 'finalized' ? 'awaiting_credit' : 'sent';502 if (state !== r.state || p.txid !== r.txid) {503 const patch: Record<string, unknown> = { state, txid: p.txid };504 if ((state === 'sent' || state === 'awaiting_credit') && !r.sent_at) patch.sent_at = new Date(now);505 r = await this.set(r, patch);506 if (state === 'awaiting_credit') await this.log('deposit_confirmed_onchain', r, { txid: p.txid, confirmations: p.confirmations });507 }508 if ((r.state !== 'sent' && r.state !== 'awaiting_credit') || !r.txid || !due) return r;509 // the venue's ledger names the Arbitrum transaction it credited: `hash` of a `deposit` entry = our transaction hash510 const led = await this.ledger(r.venue_network, a.account, r.created_at.getTime() - 60_000);511 r = await this.set(r, { checked_at: new Date(now) });512 const hit = led.find((x) => x.type === 'deposit' && x.hash.toLowerCase() === r.txid!.toLowerCase());513 if (!hit) return r;514 const same = hit.usdc !== null && usdcUnits(D6(hit.usdc)) === usdcUnits(D6(r.amount));515 r = await this.set(r, { state: 'credited', final: true, venue_at: new Date(hit.time), done_at: new Date(now), ledger: sql.json(hit as never), balance_after: sql.json(await this.venueBalance(a) as never),516 error: same ? null : `the venue credited ${hit.usdc} USDC, not the ${usdcDecimal(usdcUnits(D6(r.amount)))} USDC sent` });517 await this.log('deposit_credited', r, { txid: r.txid, venueHash: hit.hash, venueTime: hit.time, usdc: hit.usdc });518 return r;519 }520 }521522 private async refreshWithdraw(r: Row, a: AccountRow, pin: HlBridgePin | null, now: number, due: boolean): Promise<Row> {523 if (r.state === 'pending_approval') {524 if (r.expires_at && r.expires_at.getTime() < now) { r = await this.set(r, { state: 'expired', final: true, done_at: new Date(now), error: 'expired before approval; nothing was signed' }, 'pending_approval'); await this.log('withdraw_expired', r); }525 return r;526 }527 if (r.state === 'approved') {528 // the process died while asking the signer: without the signature nothing can have been sent529 if (now - r.updated_at.getTime() > 60_000) { r = await this.set(r, { state: 'failed', final: true, done_at: new Date(now), error: 'interrupted while asking the signer; nothing was sent to the venue' }, 'approved'); await this.log('withdraw_interrupted', r); }530 return r;531 }532 if (!due) return r;533 const time = Number(r.nonce), amount = usdcUnits(D6(r.amount)), fee = usdcUnits(D6(r.fee ?? '0'));534 const patch: Record<string, unknown> = { checked_at: new Date(now) };535 if (!r.ledger) {536 const led = await this.ledger(r.venue_network, a.account, r.created_at.getTime() - 60_000).catch(() => [] as LedgerEntry[]);537 // read live 2026-11-11: `nonce` is the action's `time` in microseconds (time × 1000); the plain value is accepted too538 const hit = led.find((x) => x.type === 'withdraw' && (x.nonce === time || x.nonce === time * 1000));539 if (hit) { patch.ledger = sql.json(hit as never); patch.venue_at = new Date(hit.time); r = { ...r, ledger: hit }; await this.log('withdraw_in_venue_ledger', r, { venueHash: hit.hash, usdc: hit.usdc, fee: hit.fee }); }540 }541 if (pin && !r.arrival) {542 // the wallet's own chain scan: USDC from the bridge, for the amount less the fee (or the full amount, should the venue charge the fee on top)543 const got = await this.deps.chain.arrivals(pin, a.account, r.created_at.getTime() - 60_000).catch(() => [] as Arrival[]);544 const used = (await sql<{ txid: string }[]>`select arrival->>'txid' as txid from deriv_collateral where account_id = ${r.account_id} and id <> ${r.id} and arrival is not null`).map((x) => x.txid);545 const want = r.ledger?.usdc ? [usdcUnits(D6(r.ledger.usdc))] : [amount - fee, amount];546 const hit = got.find((g) => !used.includes(g.txid) && want.includes(BigInt(g.amount)));547 if (hit) { patch.arrival = sql.json(hit as never); r = { ...r, arrival: hit }; await this.log('withdraw_arrived', r, { txid: hit.txid, received: formatUnits(BigInt(hit.amount), 6) }); }548 }549 const seen = pin ? r.arrival : r.ledger; // testnet: the payout chain is not one of the wallet's networks, the venue's ledger is the last word available550 if (seen && (r.ledger || !pin || now - (r.arrival?.seenAt ?? now) > 10 * 60_000)) {551 const feeSeen = r.ledger?.fee ? usdcUnits(D6(r.ledger.fee)) : null;552 r = await this.set(r, { ...patch, state: 'completed', final: true, done_at: new Date(now), txid: r.arrival?.txid ?? null, balance_after: sql.json(await this.venueBalance(a) as never),553 error: feeSeen !== null && feeSeen !== fee ? `the venue charged ${r.ledger!.fee} USDC, not the ${usdcDecimal(fee)} USDC shown` : null });554 await this.log('withdraw_completed', r, { txid: r.arrival?.txid ?? null, received: r.arrival ? formatUnits(BigInt(r.arrival.amount), 6) : r.ledger?.usdc ?? null, fee: r.ledger?.fee ?? null });555 return r;556 }557 if (r.arrival || r.ledger) return this.set(r, { ...patch, ...(r.state === 'unknown' || r.state === 'signed' ? { state: 'submitted', error: null } : {}) });558 r = await this.set(r, patch);559 if (r.state === 'signed' || r.state === 'unknown') {560 const sent = r.sent_at?.getTime() ?? r.updated_at.getTime();561 if (r.state === 'unknown' && now - sent > WITHDRAW_UNKNOWN_GIVE_UP_MS) {562 r = await this.set(r, { state: 'rejected', final: true, done_at: new Date(now), error: `${WITHDRAW_UNKNOWN_GIVE_UP_MS / 60_000} minutes without any trace of this withdrawal in the venue's ledger or on the wallet: it is taken as not made. ${r.error ?? ''}`.trim() });563 await this.log('withdraw_given_up', r);564 return r;565 }566 const mainnetOk = a.network !== 'mainnet' || this.deps.live.deps.mainnetEnabled();567 // a signed action that never got a readable answer: the identical bytes again (the venue takes a nonce once), a few times568 if (mainnetOk && r.attempts < MAX_SUBMITS && (r.attempts === 0 || now - Number(r.request.lastSubmitAt ?? sent) >= RESUBMIT_EVERY_MS)) return this.submit(a, r);569 }570 return r;571 }572573 private async refreshOpen(accountId?: string): Promise<{ checked: number; errors: string[] }> {574 const rows = accountId ? await sql<Row[]>`select * from deriv_collateral where not final and account_id = ${accountId} order by created_at` : await sql<Row[]>`select * from deriv_collateral where not final order by created_at`;575 const errors: string[] = [];576 for (const r of rows) await this.refresh(r).catch((e) => { errors.push(`${r.id}: ${msg(e)}`); });577 return { checked: rows.length, errors };578 }579 /** the worker's pass: every operation in flight, resumable after a restart (all state is in the database) */580 step(): Promise<{ checked: number; errors: string[] }> { return this.refreshOpen(); }581}582583/** numeric columns come back as strings with trailing zeros ("12.300000"): the canonical decimal with at most 6 places */584function D6(x: string): string {585 const [w, f = ''] = String(x).split('.');586 const frac = f.replace(/0+$/, '');587 if (frac.length > 6) throw new Error('more than 6 decimals');588 return frac ? `${w}.${frac}` : w!;589}590591let shared: CollateralService | null = null;592/** the process-wide instance: the live service's venues and settings, the real signer client, the registry RPC endpoints */593export async function collateral(): Promise<CollateralService> { return (shared ??= new CollateralService({ live: live(), signer, chain: realChain() })); }594export function startCollateralWorker(): void {595 let last = '';596 loop('deriv-collateral', async () => {597 const s = await (await collateral()).step();598 const err = s.errors[0] ?? '';599 if (err && err !== last) console.log(`[derivatives] collateral: ${s.errors.length} error(s) (${err})`);600 last = err;601 }, COLLATERAL_STEP_MS);602}603