SPB Git

Repository Public

collateral.ts 52.1 KB 603 lines typescript

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

Theme