Repository Public
1// Funding a venue account from the wallet, and bringing the money back (v6). Shared by the isolated signer and the API; pure:2// no network, no clock, no key. Facts, sources and the evidence that the route works: docs/v4/venues/hyperliquid.md, section 15.3//4// - DEPOSIT (Hyperliquid mainnet): a plain transfer of native USDC on Arbitrum One, FROM the wallet's own address (the venue5// credits the sender: the venue account IS that address) TO the venue's bridge contract, pinned below. It travels as an6// ordinary wallet intent with a `venueDeposit` section (packages/core/src/intent.ts); `checkVenueDepositIntent` is what the7// signer applies before it builds the transaction, and what the API applies before it asks.8// - WITHDRAWAL: the venue's user-signed `withdraw3` action, signed by the wallet key, only to the wallet's own address. Its9// canonical object (`VenueWithdrawal`) is what the passkey commits to.10//11// The pinned table is a TRUST INPUT of the signer: it is shipped in its bundle and never received from the API.12import { sha256 } from '@noble/hashes/sha2.js';13import { z } from 'zod';14import { D, type DecStr } from '../decimal.ts';15import { DecStrSchema } from '../models.ts';16import { buildWithdraw3TypedDataOnly, HL_DEFAULT_SIGNATURE_CHAIN_ID, HL_PRIMARY, hlChainName, type HlNetwork, type HlTypedData, type HlWithdraw3Action } from '../venues/hyperliquid/signing.ts';1718// ---------------------------------------------------------------------------------------------------------------------------19// Pinned route2021export const HL_COLLATERAL_PINNED_AT = '2026-11-11';22/** the venue's documented minimum: "The minimum deposit amount is 5 USDC. If you send an amount less than this, it will not be credited and be lost forever." */23export const HL_DEPOSIT_MIN_USDC = '5';24/** the wallet's own floor, above the venue minimum: a smaller deposit is refused by the API and by the signer */25export const HL_DEPOSIT_FLOOR_USDC = '6';26/** "There is a $1 fee for withdrawing at the time of this writing" (exchange-endpoint); the venue ledger showed `fee: "1.0"` on 2026-11-11 */27export const HL_WITHDRAW_FEE_USDC = '1';28/** USDC has 6 decimals on Arbitrum; amounts are whole base units */29export const HL_USDC_DECIMALS = 6;30/** withdrawal signatures per UTC day (each one costs the venue fee) */31export const HL_WITHDRAWALS_PER_DAY = 10;32/** a withdrawal approval is valid at most this long (`time` → signature) */33export const HL_WITHDRAW_MAX_TTL_MS = 300_000;34export const HL_DEPOSIT_ROUTE = 'arbitrum-bridge2';3536export interface HlBridgePin {37 /** wallet network id of the chain the bridge lives on */38 readonly network: string; readonly chainId: number;39 /** Bridge2 (EIP-55). Verified source on the explorer = hyperliquid-dex/contracts Bridge2.sol; not a proxy, no owner. */40 readonly bridge: string;41 /** the only token the bridge pays out and the venue credits: native USDC */42 readonly usdc: string;43 /** SHA-256 of the bridge's runtime bytecode, read on both registry RPC endpoints (scripts/check-hl-bridge.mts) */44 readonly codeSha256: string; readonly codeBytes: number;45}46/**47 * Mainnet only. The testnet bridge (0x08cfc1B6b2dCF36A1480b99353A354AA8AC56f89, Arbitrum Sepolia) is deliberately NOT here: the48 * wallet has no Arbitrum Sepolia network, and testnet collateral is mock USDC from the venue's faucet, not a token to deposit.49 */50export const HL_BRIDGE_PINS: Readonly<Partial<Record<HlNetwork, HlBridgePin>>> = {51 mainnet: {52 network: 'arbitrum', chainId: 42161,53 bridge: '0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7',54 usdc: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',55 codeSha256: '37ff6a58c3c80d99fd85ba0898ba3a5ae8d55b82c4a4d758dd258af3b05fc3c7', codeBytes: 19394,56 },57};58/** every bridge address the venue documents, on any network, lower case: an ordinary transfer to one of them is refused */59export const HL_BRIDGE_ADDRESSES: readonly string[] = ['0x2df1c51e09aecf9cacb7bc98cb1742757f163df7', '0x08cfc1b6b2dcf36a1480b99353a354aa8ac56f89'];60export const isVenueBridgeAddress = (a: string): boolean => HL_BRIDGE_ADDRESSES.includes(String(a).toLowerCase());6162const pow10 = (n: number): bigint => 10n ** BigInt(n);63/** whole USDC (decimal string) → base units */64export const usdcUnits = (x: DecStr): bigint => {65 if (!/^(0|[1-9]\d*)(\.\d{1,6})?$/.test(x)) throw new Error('a USDC amount has at most 6 decimals');66 const [w, f = ''] = x.split('.');67 return BigInt(w!) * pow10(HL_USDC_DECIMALS) + BigInt(f.padEnd(HL_USDC_DECIMALS, '0') || '0');68};69/** base units → the venue's plain decimal string (no trailing zeros) */70export const usdcDecimal = (v: bigint): DecStr => {71 const w = v / pow10(HL_USDC_DECIMALS), f = (v % pow10(HL_USDC_DECIMALS)).toString().padStart(HL_USDC_DECIMALS, '0').replace(/0+$/, '');72 return f ? `${w}.${f}` : `${w}`;73};7475// ---------------------------------------------------------------------------------------------------------------------------76// Deposit: the `venueDeposit` section of a wallet intent7778/** the section as it sits in the intent (same shape as the zod schema of packages/core/src/intent.ts) */79export interface VenueDepositSection { venue: 'hyperliquid'; venueNetwork: HlNetwork; route: string; bridge: string; minAmount: string }80/** the fields of a wallet intent this file reads (structural: the derivatives package does not import the core package) */81export interface VenueDepositIntent {82 network: string; chainRef: string; asset: string; symbol: string; decimals: number; amount: string; from: string; to: string;83 memo?: string | undefined; swap?: unknown; bridge?: unknown; /** native cross-chain swap (THORChain) */ xswap?: unknown; venueDeposit?: VenueDepositSection | undefined;84}8586export function venueDepositSection(venueNetwork: HlNetwork): VenueDepositSection {87 const pin = HL_BRIDGE_PINS[venueNetwork];88 if (!pin) throw new Error(`no deposit route is pinned for Hyperliquid ${venueNetwork}`);89 return { venue: 'hyperliquid', venueNetwork, route: HL_DEPOSIT_ROUTE, bridge: pin.bridge, minAmount: usdcUnits(HL_DEPOSIT_FLOOR_USDC).toString() };90}9192/**93 * Everything a venue deposit must be, checked against the pinned table. `own` is the wallet's own address on the intent's94 * network (the signer derives it; the API reads it from the signer). Throws with the reason; returns the pin used.95 * An intent WITHOUT the section that pays a venue bridge address is refused too: such a transfer would skip the minimum.96 */97export function checkVenueDepositIntent(i: VenueDepositIntent, own: string): HlBridgePin | null {98 const s = i.venueDeposit;99 if (!s) {100 if (isVenueBridgeAddress(i.to)) throw new Error('this destination is a venue bridge: funds reach the venue only through a venue deposit, which checks the network, the token and the minimum');101 return null;102 }103 if (i.swap || i.bridge || i.xswap) throw new Error('a venue deposit is neither a swap nor a cross-chain transfer');104 if (i.memo !== undefined) throw new Error('a venue deposit carries no memo');105 if (s.venue !== 'hyperliquid') throw new Error('unknown venue');106 const pin = HL_BRIDGE_PINS[s.venueNetwork];107 if (!pin) throw new Error(`no deposit route is pinned for Hyperliquid ${s.venueNetwork}`);108 if (s.route !== HL_DEPOSIT_ROUTE) throw new Error('unknown deposit route');109 if (i.network !== pin.network || i.chainRef !== String(pin.chainId)) throw new Error(`a Hyperliquid ${s.venueNetwork} deposit is made on ${pin.network} only`);110 if (i.asset !== pin.usdc || i.symbol !== 'USDC' || i.decimals !== HL_USDC_DECIMALS) throw new Error('only native USDC can be deposited: any other token sent to the bridge is lost');111 if (s.bridge !== pin.bridge || i.to !== pin.bridge) throw new Error('the destination is not the pinned bridge contract of the venue');112 if (i.from !== own) throw new Error('the venue credits the sending address: a deposit is sent from the wallet\'s own address only');113 if (i.from.toLowerCase() === i.to.toLowerCase()) throw new Error('destination equals source');114 const floor = usdcUnits(HL_DEPOSIT_FLOOR_USDC);115 if (s.minAmount !== floor.toString()) throw new Error('the minimum shown for approval is not the one this signer enforces');116 if (!/^(0|[1-9]\d{0,77})$/.test(i.amount)) throw new Error('malformed amount');117 if (BigInt(i.amount) < floor) throw new Error(`a deposit below ${HL_DEPOSIT_FLOOR_USDC} USDC is refused: the venue credits nothing under ${HL_DEPOSIT_MIN_USDC} USDC and the funds would be lost`);118 return pin;119}120121// ---------------------------------------------------------------------------------------------------------------------------122// Withdrawal: canonical object, hash, and the one venue action it allows123124const enc = new TextEncoder();125const ADDRESS = /^0x[0-9a-f]{40}$/;126const UsdAmount = DecStrSchema.refine((x) => /^(0|[1-9]\d*)(\.\d{1,6})?$/.test(x) && /[1-9]/.test(x), 'a positive USDC amount with at most 6 decimals');127128export const VenueWithdrawalSchema = z.strictObject({129 v: z.literal(1),130 withdrawalId: z.uuid(),131 /** the owner key (signer key id) */132 keyId: z.string().min(8).max(80),133 venue: z.literal('hyperliquid'),134 network: z.enum(['mainnet', 'testnet']),135 /** the venue account = the wallet's own EVM address (lower case) */136 account: z.string().regex(ADDRESS),137 /** where the bridge pays out on Arbitrum. The signer accepts exactly one value: the wallet's own address. */138 destination: z.string().regex(ADDRESS),139 /** USDC asked from the venue, as the venue receives it (plain decimal string) */140 amount: UsdAmount,141 /** the venue's withdrawal fee that was shown (USDC) */142 fee: UsdAmount,143 /** `time` of the withdraw3 action = the venue nonce (unix ms) */144 time: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),145 /** unix seconds: the approval window */146 createdAt: z.number().int().positive(), expiresAt: z.number().int().positive(),147});148export type VenueWithdrawal = z.infer<typeof VenueWithdrawalSchema>;149150export function venueWithdrawalBytes(w: VenueWithdrawal): Uint8Array {151 const x = VenueWithdrawalSchema.parse(w);152 const fields: [string, string][] = [153 ['v', '1'], ['withdrawalId', x.withdrawalId], ['keyId', x.keyId], ['venue', x.venue], ['network', x.network], ['account', x.account], ['destination', x.destination],154 ['amount', D.str(D.from(x.amount))], ['fee', D.str(D.from(x.fee))], ['time', String(x.time)], ['createdAt', String(x.createdAt)], ['expiresAt', String(x.expiresAt)],155 ];156 const parts: Uint8Array[] = [enc.encode('SPW-VENUE-WITHDRAW-v1'), new Uint8Array([0])];157 for (const [k, v] of fields) {158 const kb = enc.encode(k), vb = enc.encode(v), head = new Uint8Array(6);159 new DataView(head.buffer).setUint16(0, kb.length);160 new DataView(head.buffer).setUint32(2, vb.length);161 parts.push(head, kb, vb);162 }163 const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));164 let at = 0;165 for (const p of parts) { out.set(p, at); at += p.length; }166 return out;167}168export const venueWithdrawalHash = (w: VenueWithdrawal): Uint8Array => sha256(venueWithdrawalBytes(w));169170/** Structural validity at approval time. The signer additionally checks the key id, that account and destination are ITS OWN address, the passkey, replay and its daily count. */171export function validateVenueWithdrawal(w: VenueWithdrawal, nowSec: number): void {172 if (w.destination !== w.account) throw new Error('a withdrawal is paid to the wallet\'s own address only: any other destination is refused');173 if (D.str(D.from(w.fee)) !== HL_WITHDRAW_FEE_USDC) throw new Error(`the fee shown is not the venue's documented withdrawal fee (${HL_WITHDRAW_FEE_USDC} USDC)`);174 if (!D.gt(D.from(w.amount), D.from(w.fee))) throw new Error(`the amount must be greater than the venue's withdrawal fee (${HL_WITHDRAW_FEE_USDC} USDC): nothing would arrive`);175 if (w.expiresAt <= nowSec) throw new Error('approval window expired');176 if (w.createdAt > nowSec + 60) throw new Error('withdrawal dated in the future');177 if ((w.expiresAt - w.createdAt) * 1000 > HL_WITHDRAW_MAX_TTL_MS) throw new Error('approval window too long');178 // the venue's rule is "(T − 2 days, T + 1 day)"; the wallet is much stricter so that a signature cannot be kept for later179 const nowMs = nowSec * 1000;180 if (w.time > nowMs + 60_000) throw new Error('withdrawal time is dated in the future');181 if (w.time < nowMs - HL_WITHDRAW_MAX_TTL_MS) throw new Error('withdrawal time is stale');182}183184export interface HlWithdrawBuilt { action: HlWithdraw3Action; typedData: HlTypedData; nonce: number }185/**186 * The ONE venue action a canonical withdrawal allows, built from the canonical object alone and read back field by field:187 * `withdraw3` to `own`, on the chain name of the withdrawal's network, with the SDK's signature chain id. `own` is given by188 * the caller from its own knowledge (the signer: its derived address), never taken from the withdrawal.189 */190export function buildVenueWithdraw(w: VenueWithdrawal, own: string): HlWithdrawBuilt {191 const me = String(own).toLowerCase();192 if (!ADDRESS.test(me)) throw new Error('own address is not an EVM address');193 if (w.account !== me || w.destination !== me) throw new Error('a withdrawal is paid to the wallet\'s own address only: any other destination is refused');194 const built = buildWithdraw3TypedDataOnly({ network: w.network, destination: me, amount: D.str(D.from(w.amount)), time: w.time });195 const m = built.typedData.message, a = built.action, chain = hlChainName(w.network);196 const ok = built.typedData.primaryType === HL_PRIMARY.withdraw && built.typedData.domain.name === 'HyperliquidSignTransaction' && built.typedData.domain.chainId === parseInt(HL_DEFAULT_SIGNATURE_CHAIN_ID, 16)197 && Object.keys(built.typedData.types).sort().join() === ['EIP712Domain', HL_PRIMARY.withdraw].sort().join()198 && Object.keys(m).join() === 'hyperliquidChain,destination,amount,time' && m.hyperliquidChain === chain && m.destination === me && m.amount === D.str(D.from(w.amount)) && m.time === w.time199 && Object.keys(a).join() === 'type,hyperliquidChain,signatureChainId,destination,amount,time' && a.type === 'withdraw3' && a.hyperliquidChain === chain && a.signatureChainId === HL_DEFAULT_SIGNATURE_CHAIN_ID200 && a.destination === me && a.amount === m.amount && a.time === w.time;201 if (!ok) throw new Error('the withdrawal action does not read back as expected');202 return { action: a, typedData: built.typedData, nonce: w.time };203}204