SPB Git

Repository Public

collateral.ts 12.8 KB 131 lines typescript

1/**2 * Collateral of a venue account: typed client of the routes under /derivatives/live/accounts/:id/collateral/… (shapes:3 * docs/v4/API-LIVE-NOTES.md, "Collateral") and the pure rules of the two sheets — what may be typed, what the review must4 * equal before the passkey is asked, and the ONE place that decides which words describe an operation. A deposit is "done"5 * only in the state `credited` (the venue's ledger shows it); a withdrawal only in `completed` (the wallet saw the USDC).6 */7import { get, post } from '../api';8import { D, dec, parseInput, type DecStr } from './dec';9import type { LiveNetwork, PasskeyOptions } from './live';1011export interface VenueBalance { abstraction: string | null; equity: DecStr | null; withdrawable: DecStr | null; spotUsdc: DecStr | null; total: DecStr | null; error: string | null; ts: number }12export interface CollateralOp {13  id: string; kind: 'deposit' | 'withdraw'; state: string; final: boolean; venueNetwork: LiveNetwork; amount: DecStr; asset: string; fee: DecStr | null; expectedReceive: DecStr | null;14  chainNetwork: string | null; chainNetworkName: string | null; bridge: string | null; txid: string | null; explorer: string | null; hash: string | null; proposalState: string | null; confirmations: number | null;15  createdAt: number; expiresAt: number | null; sentAt: number | null; venueAt: number | null; doneAt: number | null; expectedSeconds: number; overdue: boolean; check: string[];16  balanceBefore: VenueBalance | null; balanceAfter: VenueBalance | null; ledger: { time: number; hash: string; type: string; usdc: string | null; fee: string | null } | null;17  arrival: { txid: string; amount: string; seenAt: number } | null; error: string | null; attempts: number; arrivalTracked: boolean | null;18}19export interface CollateralStatus {20  account: string; accountId: string; venue: string; network: LiveNetwork; mainnetEnabled: boolean; venueBalance: VenueBalance;21  route: { id: string; network: string; networkName: string; bridge: string; bridgeExplorer: string; asset: string; token: string; venueMinimum: DecStr; minimum: DecStr; expectedSeconds: number; credits: 'sender'; venueFee: DecStr; legacy: boolean; pinnedAt: string; codeSha256: string } | null;22  withdrawal: { fee: DecStr; expectedSeconds: number; destination: string; payoutNetworkName: string; arrivalTracked: boolean; available: DecStr | null };23  wallet: { address: string; network: string; networkName: string; usdc: DecStr; gas: DecStr; gasSymbol: string; limits: { perTransfer: DecStr; perDay: DecStr; spentToday: DecStr; maxNow: DecStr }; elsewhere: { network: string; name: string; usdc: DecStr }[] } | null;24  walletError: string | null;25  deposit: { available: boolean; reason: string | null; bridgeFrom: { network: string; name: string; usdc: DecStr }[] }; withdraw: { available: boolean; reason: string | null };26  operations: CollateralOp[]; ts: number;27}28export interface DepositPrepared {29  operation: CollateralOp; options: PasskeyOptions; venueBalance: VenueBalance;30  intent: { network: string; chainRef: string; asset: string; symbol: string; decimals: number; amount: string; from: string; to: string; expiresAt: number; venueDeposit?: { venue: string; venueNetwork: string; route: string; bridge: string; minAmount: string } };31  route: { network: string; networkName: string; bridge: string; minimum: DecStr; venueMinimum: DecStr; expectedSeconds: number }; fee: { estimate: DecStr; max: DecStr; symbol: string };32  liveness: { ok: boolean; checkedAt: number; checked: number; credited: number; detail: string };33}34export interface WithdrawPrepared {35  operation: CollateralOp; options: PasskeyOptions; venueBalance: VenueBalance; hash: string; receive: DecStr;36  withdrawal: { network: LiveNetwork; account: string; destination: string; amount: DecStr; fee: DecStr; time: number; expiresAt: number };37  whatIsSigned: { byMainKey: string; byPasskey: string; action: { type: string; hyperliquidChain: string; destination: string; amount: string; time: number } };38}3940const C = (id: string) => `/derivatives/live/accounts/${encodeURIComponent(id)}/collateral`;41export const capi = {42  status: (id: string) => get<CollateralStatus>(`${C(id)}/status`),43  operation: async (id: string, op: string): Promise<CollateralOp> => (await get(`${C(id)}/operations/${op}`)).operation,44  prepareDeposit: (id: string, amount: DecStr) => post<DepositPrepared>(`${C(id)}/deposit/prepare`, { amount }),45  approveDeposit: async (id: string, op: string, assertion: unknown): Promise<CollateralOp> => (await post(`${C(id)}/deposit/${op}/approve`, { assertion })).operation,46  prepareWithdraw: (id: string, amount: DecStr) => post<WithdrawPrepared>(`${C(id)}/withdraw/prepare`, { amount }),47  approveWithdraw: async (id: string, op: string, assertion: unknown): Promise<CollateralOp> => (await post(`${C(id)}/withdraw/${op}/approve`, { assertion })).operation,48  cancel: async (id: string, op: string): Promise<CollateralOp> => (await post(`${C(id)}/operations/${op}/cancel`)).operation,49};5051const lt = (a: DecStr, b: DecStr) => D.lt(D.from(a), D.from(b));52/** a typed USDC amount as a decimal string with at most 6 decimals, or null */53export function usdcInput(raw: string): DecStr | null {54  const x = parseInput(raw);55  return x !== null && /^(0|[1-9]\d{0,8})(\.\d{1,6})?$/.test(x) && D.isPos(D.from(x)) ? x : null;56}5758export type DepositProblem = 'hlcErrAmount' | 'hlcErrBelowMin' | 'hlcErrAboveBalance' | 'hlcErrAboveLimit' | 'hlcErrAboveDay';59/** what stops a deposit of this amount, from the status alone (the server and the signer check again) */60export function depositProblem(raw: string, s: Pick<CollateralStatus, 'route' | 'wallet'>): { amount: DecStr | null; problem: DepositProblem | null } {61  if (raw.trim() === '') return { amount: null, problem: null };62  const amount = usdcInput(raw);63  if (!amount || !s.route || !s.wallet) return { amount: null, problem: 'hlcErrAmount' };64  // below the minimum the venue credits nothing and the money is lost: this is the first thing said65  if (lt(amount, s.route.minimum)) return { amount: null, problem: 'hlcErrBelowMin' };66  if (lt(s.wallet.usdc, amount)) return { amount: null, problem: 'hlcErrAboveBalance' };67  if (lt(s.wallet.limits.perTransfer, amount)) return { amount: null, problem: 'hlcErrAboveLimit' };68  if (lt(D.str(D.sub(D.from(s.wallet.limits.perDay), D.from(s.wallet.limits.spentToday))), amount)) return { amount: null, problem: 'hlcErrAboveDay' };69  return { amount, problem: null };70}71export type WithdrawProblem = 'hlcErrAmount' | 'hlcErrBelowFee' | 'hlcErrAboveVenue';72export function withdrawProblem(raw: string, s: Pick<CollateralStatus, 'withdrawal'>): { amount: DecStr | null; receive: DecStr | null; problem: WithdrawProblem | null } {73  if (raw.trim() === '') return { amount: null, receive: null, problem: null };74  const amount = usdcInput(raw);75  if (!amount) return { amount: null, receive: null, problem: 'hlcErrAmount' };76  if (!D.gt(D.from(amount), D.from(s.withdrawal.fee))) return { amount: null, receive: null, problem: 'hlcErrBelowFee' };77  if (s.withdrawal.available !== null && dec(s.withdrawal.available) && lt(s.withdrawal.available, amount)) return { amount: null, receive: null, problem: 'hlcErrAboveVenue' };78  return { amount, receive: D.str(D.sub(D.from(amount), D.from(s.withdrawal.fee))), problem: null };79}8081const units = (x: DecStr): string => { const [w, f = ''] = x.split('.'); return (BigInt(w!) * 1_000_000n + BigInt(f.padEnd(6, '0') || '0')).toString(); };82/**83 * What is on the review sheet must be what the passkey commits to. Returns null, or what differs: the sheet then refuses to84 * ask for the passkey. (The signer checks the same things from its own pinned table; this is the screen's own reading.)85 */86export function depositIntegrity(p: DepositPrepared, s: CollateralStatus, amount: DecStr): 'amount' | 'bridge' | 'account' | 'asset' | 'minimum' | null {87  const i = p.intent, r = s.route;88  if (!r || !s.wallet) return 'bridge';89  if (i.amount !== units(amount) || p.operation.amount !== D.str(D.from(amount))) return 'amount';90  if (i.to !== r.bridge || i.venueDeposit?.bridge !== r.bridge || p.route.bridge !== r.bridge || i.network !== r.network || i.venueDeposit?.venueNetwork !== s.network || i.venueDeposit?.venue !== 'hyperliquid') return 'bridge';91  if (i.from !== s.wallet.address || i.from.toLowerCase() !== s.account.toLowerCase()) return 'account';   // the venue credits the sender: it must be the venue account itself92  if (i.asset !== r.token || i.symbol !== 'USDC' || i.decimals !== 6) return 'asset';93  if (i.venueDeposit.minAmount !== units(r.minimum) || lt(amount, r.minimum)) return 'minimum';94  return null;95}96export function withdrawIntegrity(p: WithdrawPrepared, s: CollateralStatus, amount: DecStr): 'amount' | 'destination' | 'fee' | 'network' | null {97  const w = p.withdrawal, a = p.whatIsSigned.action, me = s.account.toLowerCase();98  if (D.str(D.from(w.amount)) !== D.str(D.from(amount)) || a.amount !== w.amount || p.operation.amount !== D.str(D.from(amount))) return 'amount';99  if (w.destination !== me || w.account !== me || a.destination !== me || a.type !== 'withdraw3') return 'destination';   // only ever the wallet's own address100  if (D.str(D.from(w.fee)) !== D.str(D.from(s.withdrawal.fee)) || p.receive !== D.str(D.sub(D.from(w.amount), D.from(w.fee)))) return 'fee';101  if (w.network !== s.network || a.hyperliquidChain !== (s.network === 'mainnet' ? 'Mainnet' : 'Testnet') || a.time !== w.time) return 'network';102  return null;103}104105export interface OpWords { /** i18n key of the one sentence that says where the operation is */ key: string; tone: 'ok' | 'info' | 'warn' | 'danger'; /** money has left and has not been seen on the other side yet */ waiting: boolean; /** 0-based step reached, of the 4 shown */ step: number }106const DEPOSIT: Record<string, OpWords> = {107  pending_approval: { key: 'hlcOp_dep_pending_approval', tone: 'info', waiting: false, step: 0 }, signed: { key: 'hlcOp_dep_signed', tone: 'info', waiting: true, step: 1 },108  sent: { key: 'hlcOp_dep_sent', tone: 'info', waiting: true, step: 1 }, awaiting_credit: { key: 'hlcOp_dep_awaiting_credit', tone: 'info', waiting: true, step: 2 },109  credited: { key: 'hlcOp_dep_credited', tone: 'ok', waiting: false, step: 3 }, failed: { key: 'hlcOp_dep_failed', tone: 'danger', waiting: false, step: 0 },110  expired: { key: 'hlcOp_expired', tone: 'warn', waiting: false, step: 0 }, cancelled: { key: 'hlcOp_cancelled', tone: 'warn', waiting: false, step: 0 },111};112const WITHDRAW: Record<string, OpWords> = {113  pending_approval: { key: 'hlcOp_wd_pending_approval', tone: 'info', waiting: false, step: 0 }, approved: { key: 'hlcOp_wd_signed', tone: 'info', waiting: true, step: 1 }, signed: { key: 'hlcOp_wd_signed', tone: 'info', waiting: true, step: 1 },114  submitted: { key: 'hlcOp_wd_submitted', tone: 'info', waiting: true, step: 2 }, unknown: { key: 'hlcOp_wd_unknown', tone: 'warn', waiting: true, step: 1 },115  completed: { key: 'hlcOp_wd_completed', tone: 'ok', waiting: false, step: 3 }, rejected: { key: 'hlcOp_wd_rejected', tone: 'danger', waiting: false, step: 0 }, failed: { key: 'hlcOp_wd_failed', tone: 'danger', waiting: false, step: 0 },116  expired: { key: 'hlcOp_expired', tone: 'warn', waiting: false, step: 0 }, cancelled: { key: 'hlcOp_cancelled', tone: 'warn', waiting: false, step: 0 },117};118/** The words for an operation. Success (`ok`) exists for exactly two states; an unknown state is never good news. */119export function opWords(op: Pick<CollateralOp, 'kind' | 'state' | 'overdue' | 'final' | 'arrivalTracked'>): OpWords {120  const w = (op.kind === 'deposit' ? DEPOSIT : WITHDRAW)[op.state] ?? { key: 'hlcOp_other', tone: 'warn' as const, waiting: !op.final, step: 0 };121  if (op.overdue) return { ...w, key: op.kind === 'deposit' ? 'hlcOp_dep_overdue' : 'hlcOp_wd_overdue', tone: 'warn' };122  if (op.kind === 'withdraw' && op.state === 'completed' && op.arrivalTracked === false) return { ...w, key: 'hlcOp_wd_completed_untracked' };123  return w;124}125export const opDone = (op: Pick<CollateralOp, 'kind' | 'state'>): boolean => (op.kind === 'deposit' ? op.state === 'credited' : op.state === 'completed');126/** link to the wallet's cross-network USDC transfer (CCTP), pre-filled: from the network that holds USDC to the deposit network */127export const bridgeLink = (from: string, to: string, amount?: DecStr | null): string => `/swap?bridgeFrom=${encodeURIComponent(from)}&bridgeTo=${encodeURIComponent(to)}${amount ? `&amount=${encodeURIComponent(amount)}` : ''}`;128/** the codes the collateral routes answer with, each with its sentence (`hlcE_<code>`); anything else falls back to the generic notice */129export const COLLATERAL_CODES: ReadonlySet<string> = new Set(['below_minimum', 'no_route', 'mainnet_disabled', 'contract_changed', 'bridge_paused', 'route_not_proven', 'insufficient_usdc', 'policy', 'deposit_in_progress', 'pending_tx',130  'below_fee', 'insufficient_collateral', 'withdrawal_in_progress', 'expired', 'wrong_account', 'account_has_code', 'signer_venue_deposit', 'signer_wrong_destination', 'signer_bad_approval', 'insufficient_gas', 'insufficient_funds']);131
Files main
Menu

Theme