SPB Git

Repository Public

venue-collateral.ts 6.4 KB 80 lines typescript

1// Collateral of a venue account (Hyperliquid): the signer side of DEPOSIT and WITHDRAW. Docs: docs/v4/venues/hyperliquid.md2// section 15, docs/v4/DERIVATIVES-ARCHITECTURE.md ("Signing").3//4// DEPOSIT is an ordinary wallet intent (`Signer.sign`: passkey over the intent hash, per-transfer and daily limits, fee ceiling,5// allowlist, replay cache — all unchanged) carrying a `venueDeposit` section. `venueDepositVerdict` adds what a free-form send6// lacks: the destination must be the bridge contract PINNED in this bundle, the chain and the token the pinned ones (native7// USDC), the amount at least the wallet's floor above the venue minimum (below it the venue credits nothing and the money is8// lost). And an intent WITHOUT the section that pays a venue bridge is refused, so the minimum cannot be bypassed by a plain send.9//10// WITHDRAW is the second — and last — kind of venue message the MAIN wallet key signs (the first is `approveAgent`, venue.ts):11// Hyperliquid's user-signed `withdraw3`, built HERE from the canonical withdrawal, only after the owner's passkey approved that12// canonical object, and only with `destination` = this signer's own address. There is no parameter through which another13// destination, another action (`usdSend`, `spotSend`, `sendAsset`, `usdClassTransfer`, `approveAgent` …) or another chain name14// could reach the key: nothing received is signed, only what `buildVenueWithdraw` produced and read back.15import type { AuthenticationResponseJSON } from '@simplewebauthn/server';16import { sign } from 'viem/accounts';17import { cctpUsdc, type Intent } from '@spw/core';18import {19  buildVenueWithdraw, checkVenueDepositIntent, hex, HL_WITHDRAWALS_PER_DAY, signatureFromHex, typedDataDigest, validateVenueWithdrawal, venueWithdrawalHash, VenueWithdrawalSchema,20  type HlSignature, type HlWithdrawBuilt, type VenueWithdrawal,21} from '@spw/derivatives/live';22import type { VenueCtx } from './venue.ts';2324/** `time` is the venue nonce of the main key for this action; records are kept 30 days (the venue refuses a nonce older than 2 days) */25export interface StoredVenueWithdrawal { withdrawal: VenueWithdrawal; hash: string; signedAt: number; action: HlWithdrawBuilt['action']; signature: HlSignature }26export interface VenueWithdrawResult { withdrawalId: string; hash: string; action: HlWithdrawBuilt['action']; signature: HlSignature; nonce: number; signer: string }27const KEEP_SEC = 30 * 86400;28const day = (sec: number) => new Date(sec * 1000).toISOString().slice(0, 10);2930/**31 * Called by `Signer.sign` for EVERY intent, before the policy and the passkey. Returns null when the intent may proceed (not a32 * venue deposit and not addressed to a venue bridge, or a venue deposit that matches the pinned table), otherwise the reason.33 */34export function venueDepositVerdict(i: Intent, own: string): string | null {35  try {36    const pin = checkVenueDepositIntent(i, own);37    if (!pin) return null;38    // the pinned token must be the registry's native USDC of that network: two tables, one answer39    if (cctpUsdc(pin.network).id !== pin.usdc) return 'the pinned token is not the registry\'s native USDC on this network';40    if (!i.evm) return 'a venue deposit is an EVM transfer';41    return null;42  } catch (e) { return (e as Error).message; }43}4445/**46 * Sign ONE withdrawal from the venue to the wallet's own address. Passkey assertion over the canonical withdrawal; the action47 * is built here from that object alone. The counters and the record are durable before the signature is returned.48 */49export async function venueWithdraw(ctx: VenueCtx, raw: unknown, assertion: AuthenticationResponseJSON | undefined): Promise<VenueWithdrawResult> {50  const st = ctx.store, now = ctx.now();51  const parsed = VenueWithdrawalSchema.safeParse(raw);52  if (!parsed.success) throw ctx.fail('bad_request', 'malformed venue withdrawal');53  const w: VenueWithdrawal = parsed.data, hash = venueWithdrawalHash(w), id = w.withdrawalId, own = ctx.keys.addresses.evm.toLowerCase();54  const deny = (code: string, msg: string): never => { st.log('venue.withdraw.denied', { withdrawalId: id, venue: w.venue, network: w.network, amount: w.amount, destination: w.destination, code, msg: msg.slice(0, 300) }); st.save(); throw ctx.fail(code, msg); };55  if (st.state.halted) deny('policy', 'emergency halt is active');56  if (w.keyId !== ctx.keys.keyId) deny('wrong_key', 'withdrawal targets another key');57  if (w.account !== own) deny('wrong_source', 'the venue account is not this wallet\'s own address');58  if (w.destination !== own) deny('wrong_destination', 'a withdrawal is paid to the wallet\'s own address only: any other destination is refused');59  try { validateVenueWithdrawal(w, now); } catch (e) { deny(/expired|stale|future|window/.test((e as Error).message) ? 'policy' : 'unsupported', (e as Error).message); }60  const all = (st.state.venueWithdrawals ??= {});61  if (all[id]) deny('replay', 'withdrawal already signed');62  const mine = Object.values(all).filter((r) => r.withdrawal.network === w.network);63  // the venue nonce of the main key only moves forward here: an older or equal `time` is never signed again64  if (mine.some((r) => r.withdrawal.time >= w.time)) deny('stale_nonce', 'a withdrawal with the same or a later time was already signed');65  if (Object.values(all).filter((r) => day(r.signedAt) === day(now)).length >= HL_WITHDRAWALS_PER_DAY) deny('policy', 'daily withdrawal count reached');6667  let built: HlWithdrawBuilt;68  try { built = buildVenueWithdraw(w, own); } catch (e) { return deny('unsupported', (e as Error).message); }6970  if (!assertion) deny('bad_approval', 'the owner\'s passkey approval is required');71  try { await ctx.verify(assertion!, hash); } catch (e) { deny((e as { code?: string }).code ?? 'bad_approval', (e as Error).message); }7273  const signature = signatureFromHex(await sign({ hash: typedDataDigest(built.typedData), privateKey: `0x${Buffer.from(ctx.keys.evmPriv).toString('hex')}`, to: 'hex' }));74  for (const [k, r] of Object.entries(all)) if (r.signedAt + KEEP_SEC < now) delete all[k];75  all[id] = { withdrawal: w, hash: hex(hash), signedAt: now, action: built.action, signature };76  st.log('venue.withdraw.ok', { withdrawalId: id, hash: hex(hash), venue: w.venue, network: w.network, account: w.account, destination: built.action.destination, amount: built.action.amount, fee: w.fee, time: w.time });77  st.save(); // durable before the signature leaves78  return { withdrawalId: id, hash: hex(hash), action: built.action, signature, nonce: built.nonce, signer: own };79}80
Files main
Menu

Theme