SPB Git

Repository Public

mockCollateral.ts 16.4 KB 168 lines typescript

1/**2 * Browser mock of the collateral routes (deposit / withdraw) — development and browser tests only, loaded with the rest of the3 * mock (`?mock=1`), never part of a production build. It answers with the shapes of services/api/src/derivatives/collateral.ts.4 *5 * REAL in it: the pinned route table, the floor and the fee, the canonical withdrawal and its hash, the `withdraw3` action6 * (`buildVenueWithdraw`), the passkey challenge check, the order of the refusals.7 * NOT real: there is no chain, no venue and no signer. Time does the work of the network: a deposit is "confirmed" after8 * 1.2 s and "credited" after `creditMs` more (or never); a withdrawal "arrives" after `payoutMs`. The deposit's hash is a9 * SHA-256 over the mock's own encoding, not the wallet's intent hash (the core package is not loaded in the browser).10 */11import {12  buildVenueWithdraw, HL_BRIDGE_PINS, HL_COLLATERAL_PINNED_AT, HL_DEPOSIT_FLOOR_USDC, HL_DEPOSIT_MIN_USDC, HL_DEPOSIT_ROUTE, HL_WITHDRAW_FEE_USDC, usdcDecimal, usdcUnits, venueDepositSection, venueWithdrawalHash,13  type VenueWithdrawal,14} from '../../../../packages/derivatives/src/live/collateral.ts';15import { hex } from '../../../../packages/derivatives/src/live/canonical.ts';1617type Net = 'testnet' | 'mainnet';18export interface MockAcct { id: string; network: Net; account: string }19export interface CollateralMockEnv {20  now: () => number; mainnet: () => boolean; keyId: string;21  /** what the simulated venue holds for the account: equity and withdrawable, null when the venue cannot be read */22  balance: (a: MockAcct) => { equity: string; withdrawable: string } | null;23  /** the simulated venue credits (+) or debits (−) the account */24  move: (a: MockAcct, deltaUsdc: string) => void;25  options: (hashHex: string) => unknown;26  /** the simulated signer's passkey check: null when the assertion carries this hash */27  refusal: (assertion: unknown, hashHex: string) => Error | null;28  refuse: (code: string, message: string, status: number) => Error;29}30export interface CollateralHooks {31  /** USDC of the wallet on Arbitrum, and what it holds on other networks */ wallet: (usdc: string, elsewhere?: { network: string; name: string; usdc: string }[]) => void;32  /** ms between the on-chain confirmation and the venue's credit; null = the credit never comes */ creditAfter: (ms: number | null) => void;33  /** ms between the venue's acceptance and the arrival on the wallet; null = never */ payoutAfter: (ms: number | null) => void;34  /** the next deposit is treated as sent this long ago (to show the overdue state) */ age: (ms: number) => void;35}36interface Op { id: string; account: string; kind: 'deposit' | 'withdraw'; amount: bigint; hash: string; createdAt: number; expiresAt: number; approvedAt: number | null; state: string; final: boolean; error: string | null;37  before: number; after: number | null; settled: boolean; txid: string | null; withdrawal?: VenueWithdrawal; creditMs: number | null; payoutMs: number | null; venueAt: number | null }3839export function createCollateralMock(env: CollateralMockEnv) {40  const pin = HL_BRIDGE_PINS.mainnet!, OPS: Op[] = [];41  let USDC = usdcUnits('40'), ELSE: { network: string; name: string; usdc: string }[] = [], CREDIT: number | null = 2500, PAYOUT: number | null = 3000, AGE = 0, SPENT = 0n;42  const u = (x: bigint) => usdcDecimal(x), PER_TX = usdcUnits('50'), PER_DAY = usdcUnits('100'), FLOOR = usdcUnits(HL_DEPOSIT_FLOOR_USDC), FEE = usdcUnits(HL_WITHDRAW_FEE_USDC);43  const rnd = (n: number) => Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => b.toString(16).padStart(2, '0')).join('');44  const bal = (a: MockAcct) => { const b = env.balance(a); return { abstraction: 'default', equity: b?.equity ?? null, withdrawable: b?.withdrawable ?? null, spotUsdc: null, total: b?.equity ?? null, error: b ? null : 'hyperliquid: request timed out after 8000 ms', ts: env.now() }; };45  const num = (x: string | null) => (x === null ? 0 : Number(x));4647  /** time moves an operation forward: read on every request, like the backend re-reads the venue and the chain */48  function advance(o: Op, a: MockAcct): void {49    const now = env.now();50    if (o.final) return;51    if (o.approvedAt === null) { if (now > o.expiresAt) { o.state = 'expired'; o.final = true; o.error = 'expired before approval; nothing was signed'; } return; }52    const since = now - o.approvedAt;53    if (o.kind === 'deposit') {54      o.state = since < 1200 ? 'sent' : 'awaiting_credit';55      if (o.creditMs !== null && since >= 1200 + o.creditMs) { if (!o.settled) { env.move(a, u(o.amount)); o.settled = true; } o.state = 'credited'; o.final = true; o.venueAt = now; o.after = num(env.balance(a)?.equity ?? null); }56    } else {57      o.state = 'submitted';58      if (o.payoutMs !== null && since >= o.payoutMs) { if (!o.settled) { USDC += o.amount - FEE; o.settled = true; } o.state = 'completed'; o.final = true; o.venueAt = now; o.after = num(env.balance(a)?.equity ?? null); o.txid = `0x${rnd(32)}`; }59    }60  }61  function view(o: Op) {62    const now = env.now(), sent = o.approvedAt, dep = o.kind === 'deposit';63    const overdue = !o.final && sent !== null && (dep ? o.state === 'awaiting_credit' && now - sent > 600_000 : o.state === 'submitted' && now - sent > 900_000);64    const b = (v: number | null) => (v === null ? null : { abstraction: 'default', equity: String(v), withdrawable: String(v), spotUsdc: null, total: String(v), error: null, ts: now });65    return { id: o.id, kind: o.kind, state: o.state, final: o.final, venueNetwork: 'mainnet', amount: u(o.amount), asset: 'USDC', fee: dep ? null : HL_WITHDRAW_FEE_USDC, expectedReceive: dep ? null : u(o.amount - FEE),66      chainNetwork: 'arbitrum', chainNetworkName: 'Arbitrum One', bridge: dep ? pin.bridge : null, txid: o.txid, explorer: o.txid ? `https://arbiscan.io/tx/${o.txid}` : null, hash: o.hash, proposalId: null, proposalState: null, confirmations: null,67      createdAt: o.createdAt, expiresAt: o.expiresAt, sentAt: sent, venueAt: o.venueAt, doneAt: o.final ? o.venueAt ?? now : null, checkedAt: now, expectedSeconds: dep ? 60 : 240, overdue,68      check: overdue ? (dep ? ['tx_success', 'tx_to_bridge', 'amount_min', 'venue_ledger', 'venue_status'] : ['venue_ledger', 'bridge_paused', 'wallet_scan', 'venue_status']) : [],69      balanceBefore: b(o.before), balanceAfter: b(o.after), ledger: o.final && o.venueAt ? { time: o.venueAt, hash: o.txid ?? '', type: dep ? 'deposit' : 'withdraw', usdc: dep ? u(o.amount) : u(o.amount - FEE), fee: dep ? null : '1.0' } : null,70      arrival: !dep && o.state === 'completed' ? { txid: o.txid!, amount: (o.amount - FEE).toString(), seenAt: o.venueAt! } : null, venueAnswer: null, error: o.error, attempts: sent === null ? 0 : 1, arrivalTracked: dep ? null : true };71  }72  const mine = (a: MockAcct) => OPS.filter((o) => o.account === a.id);73  const find = (a: MockAcct, id: string, kind?: string) => { const o = mine(a).find((x) => x.id === id && (!kind || x.kind === kind)); if (!o) throw env.refuse('unknown_operation', 'unknown collateral operation', 404); advance(o, a); return o; };74  const needMainnet = (a: MockAcct) => { if (a.network === 'mainnet' && !env.mainnet()) throw env.refuse('mainnet_disabled', 'moving collateral on Hyperliquid MAINNET is disabled on this server (SPW_DERIV_LIVE_MAINNET is not "on"): nothing was built, signed or sent', 403); };75  const amountOf = (b: any): bigint => { if (typeof b?.amount !== 'string' || !/^(0|[1-9]\d{0,8})(\.\d{1,6})?$/.test(b.amount) || Object.keys(b).length !== 1) throw env.refuse('bad_request', 'invalid request', 400); return usdcUnits(b.amount); };7677  function handle(method: string, rest: string, body: any, a: MockAcct): unknown {78    const main = a.network === 'mainnet', now = env.now();79    for (const o of mine(a)) advance(o, a);80    if (rest === '/status' && method === 'GET') {81      const room = PER_DAY > SPENT ? PER_DAY - SPENT : 0n, maxNow = [PER_TX, room, USDC].reduce((x, y) => (y < x ? y : x));82      const reason = !main ? 'no_route' : !env.mainnet() ? 'mainnet_disabled' : USDC < FLOOR ? (ELSE.length ? 'usdc_elsewhere' : 'no_usdc') : room < FLOOR ? 'limit_reached' : null, b = bal(a);83      return { account: a.account, accountId: a.id, venue: 'hyperliquid', network: a.network, mainnetEnabled: env.mainnet(), venueBalance: b,84        route: main ? { id: HL_DEPOSIT_ROUTE, network: 'arbitrum', networkName: 'Arbitrum One', bridge: pin.bridge, bridgeExplorer: `https://arbiscan.io/address/${pin.bridge}`, asset: 'USDC', token: pin.usdc, venueMinimum: HL_DEPOSIT_MIN_USDC,85          minimum: HL_DEPOSIT_FLOOR_USDC, expectedSeconds: 60, credits: 'sender', venueFee: '0', legacy: true, pinnedAt: HL_COLLATERAL_PINNED_AT, codeSha256: pin.codeSha256 } : null,86        withdrawal: { fee: HL_WITHDRAW_FEE_USDC, expectedSeconds: 240, destination: a.account, payoutNetworkName: main ? 'Arbitrum One' : 'Arbitrum Sepolia', arrivalTracked: main, available: b.withdrawable },87        wallet: main ? { address: a.account, network: 'arbitrum', networkName: 'Arbitrum One', usdc: u(USDC), gas: '0.002', gasSymbol: 'ETH', limits: { perTransfer: u(PER_TX), perDay: u(PER_DAY), spentToday: u(SPENT), maxNow: u(maxNow) }, elsewhere: USDC < FLOOR ? ELSE : [] } : null,88        walletError: null, deposit: { available: reason === null, reason, bridgeFrom: USDC < FLOOR ? ELSE : [] },89        withdraw: { available: !(main && !env.mainnet()) && !b.error, reason: main && !env.mainnet() ? 'mainnet_disabled' : b.error ? 'venue_unreadable' : null },90        operations: [...mine(a)].reverse().slice(0, 20).map(view), ts: now };91    }92    let m = /^\/operations\/([0-9a-f-]{36})(\/cancel)?$/.exec(rest);93    if (m) {94      const o = find(a, m[1]!);95      if (m[2]) { if (o.state !== 'pending_approval') throw env.refuse('bad_state', `the operation is ${o.state}: it can no longer be cancelled here`, 409); o.state = 'cancelled'; o.final = true; o.error = 'cancelled before approval; nothing was signed'; }96      return { operation: view(o) };97    }98    if (rest === '/deposit/prepare') {99      if (!main) throw env.refuse('no_route', 'no deposit route exists in this wallet for Hyperliquid testnet: testnet collateral is mock USDC from the venue\'s faucet', 422);100      needMainnet(a);101      const amount = amountOf(body);102      if (amount < FLOOR) throw env.refuse('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);103      if (mine(a).some((o) => o.kind === 'deposit' && !o.final && o.state !== 'pending_approval')) throw env.refuse('deposit_in_progress', 'a deposit is already on its way to the venue; wait for the credit before sending another', 409);104      if (USDC < amount) throw env.refuse('insufficient_usdc', `the wallet holds ${u(USDC)} USDC on Arbitrum One`, 422);105      if (amount > PER_TX || SPENT + amount > PER_DAY) throw env.refuse('policy', 'amount above per-transaction limit for arbitrum:USDC', 422);106      for (const o of mine(a)) if (o.kind === 'deposit' && o.state === 'pending_approval') { o.state = 'cancelled'; o.final = true; }107      const t = Math.floor(now / 1000);108      const intent = { v: 1, proposalId: crypto.randomUUID(), keyId: env.keyId, network: 'arbitrum', chainRef: '42161', asset: pin.usdc, symbol: 'USDC', decimals: 6, amount: amount.toString(), from: a.account, to: pin.bridge, maxFee: '9000000000000',109        createdAt: t, expiresAt: t + 180, policyVersion: 3, venueDeposit: venueDepositSection('mainnet'), evm: { nonce: 7, gasLimit: '90000', maxFeePerGas: '100000000', maxPriorityFeePerGas: '1000000' } };110      const before = num(env.balance(a)?.equity ?? null);111      // NOT the wallet's intent hash (see the header): a hash of this mock's own bytes, enough to bind the simulated passkey112      const hash = hex(venueWithdrawalHash({ v: 1, withdrawalId: intent.proposalId, keyId: env.keyId, venue: 'hyperliquid', network: 'mainnet', account: a.account, destination: a.account, amount: u(amount), fee: '1', time: now, createdAt: t, expiresAt: t + 180 }));113      const o: Op = { id: crypto.randomUUID(), account: a.id, kind: 'deposit', amount, hash, createdAt: now, expiresAt: now + 180_000, approvedAt: null, state: 'pending_approval', final: false, error: null, before, after: null, settled: false,114        txid: null, creditMs: CREDIT, payoutMs: null, venueAt: null };115      OPS.push(o);116      return { operation: view(o), intent, route: { network: 'arbitrum', networkName: 'Arbitrum One', bridge: pin.bridge, minimum: HL_DEPOSIT_FLOOR_USDC, venueMinimum: HL_DEPOSIT_MIN_USDC, expectedSeconds: 60 },117        fee: { estimate: '0.0000045', max: '0.000009', symbol: 'ETH' }, liveness: { ok: true, checkedAt: now, checked: 3, credited: 3, detail: 'mock: 3 of the 3 most recent deposits into the bridge are in the venue\'s ledger', samples: [] },118        venueBalance: bal(a), options: env.options(hash) };119    }120    m = /^\/deposit\/([0-9a-f-]{36})\/approve$/.exec(rest);121    if (m) {122      needMainnet(a);123      const o = find(a, m[1]!, 'deposit');124      if (o.state !== 'pending_approval') throw env.refuse(o.state === 'expired' ? 'expired' : 'bad_state', `the deposit is ${o.state}`, o.state === 'expired' ? 410 : 409);125      const no = env.refusal(body?.assertion, o.hash);126      if (no) { o.state = 'failed'; o.final = true; o.error = `the signer refused: ${no.message}. Nothing was signed or sent.`; throw no; }127      o.approvedAt = now - AGE; AGE = 0; o.txid = `0x${rnd(32)}`; USDC -= o.amount; SPENT += o.amount;128      advance(o, a);129      return { operation: view(o) };130    }131    if (rest === '/withdraw/prepare') {132      needMainnet(a);133      const amount = amountOf(body), b = env.balance(a);134      if (amount <= FEE) throw env.refuse('below_fee', `the venue keeps ${HL_WITHDRAW_FEE_USDC} USDC of every withdrawal: the amount must be greater than that`, 422);135      if (mine(a).some((o) => o.kind === 'withdraw' && !o.final && o.state !== 'pending_approval')) throw env.refuse('withdrawal_in_progress', 'a withdrawal is already in progress; wait for it to arrive before asking for another', 409);136      if (!b) throw env.refuse('venue_unavailable', 'the venue account could not be read; nothing was prepared', 503);137      if (usdcUnits(Number(b.withdrawable).toFixed(6).replace(/\.?0+$/, '')) < amount) throw env.refuse('insufficient_collateral', `the venue reports ${b.withdrawable} USDC withdrawable on this account`, 422);138      for (const o of mine(a)) if (o.kind === 'withdraw' && o.state === 'pending_approval') { o.state = 'cancelled'; o.final = true; }139      const t = Math.floor(now / 1000);140      const w: VenueWithdrawal = { v: 1, withdrawalId: crypto.randomUUID(), keyId: env.keyId, venue: 'hyperliquid', network: a.network, account: a.account, destination: a.account, amount: u(amount), fee: HL_WITHDRAW_FEE_USDC, time: now, createdAt: t, expiresAt: t + 180 };141      const built = buildVenueWithdraw(w, a.account), hash = hex(venueWithdrawalHash(w));142      const o: Op = { id: w.withdrawalId, account: a.id, kind: 'withdraw', amount, hash, createdAt: now, expiresAt: now + 180_000, approvedAt: null, state: 'pending_approval', final: false, error: null, before: num(b.equity), after: null, settled: false,143        txid: null, withdrawal: w, creditMs: null, payoutMs: PAYOUT, venueAt: null };144      OPS.push(o);145      return { operation: view(o), withdrawal: w, hash, whatIsSigned: { byMainKey: `Hyperliquid withdraw3 (${built.action.hyperliquidChain}): ${built.action.amount} USDC to ${built.action.destination}, time ${built.action.time}`,146        byPasskey: `SHA-256 of the canonical withdrawal ${hash}`, action: built.action }, receive: u(amount - FEE), venueBalance: bal(a), options: env.options(hash) };147    }148    m = /^\/withdraw\/([0-9a-f-]{36})\/approve$/.exec(rest);149    if (m) {150      needMainnet(a);151      const o = find(a, m[1]!, 'withdraw');152      if (o.state !== 'pending_approval') throw env.refuse(o.state === 'expired' ? 'expired' : 'bad_state', `the withdrawal is ${o.state}`, o.state === 'expired' ? 410 : 409);153      const no = env.refusal(body?.assertion, o.hash);154      if (no) { o.state = 'failed'; o.final = true; o.error = `the signer refused: ${no.message}. Nothing was sent to the venue.`; throw no; }155      env.move(a, `-${u(o.amount)}`);      // the venue debits the account when it accepts the withdrawal156      o.approvedAt = now - AGE; AGE = 0;157      advance(o, a);158      return { operation: view(o) };159    }160    throw env.refuse('not_found', 'no such collateral route', 404);161  }162  const hooks: CollateralHooks = {163    wallet: (usdc, elsewhere = []) => { USDC = usdcUnits(usdc); ELSE = elsewhere; },164    creditAfter: (ms) => { CREDIT = ms; }, payoutAfter: (ms) => { PAYOUT = ms; }, age: (ms) => { AGE = ms; },165  };166  return { handle, hooks };167}168
Files main
Menu

Theme