SPB Git

Repository Public

signer-client.ts 8.6 KB 79 lines typescript

1import { createHash, createHmac, randomUUID } from 'node:crypto';2import type { Mandate, Policy } from '@spw/core';3import { config, UserError } from './config.ts';45export interface SignerStatus {6  keyId: string; addresses: Record<string, string>; halted: boolean; enrollmentOpen: boolean;7  credentials: { id: string; addedAt: string }[]; policy: Policy; spentToday: Record<string, string>; journalHead: string | null; journalLength: number;8  mandates?: { mandate: Mandate; approvedAt: string; revoked: boolean; revokedAt: string | null; expired: boolean; today: { sold: Record<string, string>; orders: number } }[];9  venueMandates?: { mandate: { mandateId: string; authorizationId: string }; hash: string; approvedAt: string; revoked: boolean; revokedAt: string | null; expired: boolean; phase: 'active' | 'wind_down' | 'ended'; windDownUntil: number | null;10    today: { orders: number; reduceOnly: number; cancels: number; leverage: number; turnover: string }; ordersLastHour: number; pendingOrders: number }[];11  /** v8: GMX venue policies stored by the signer */12  gmx?: import('./derivatives/gmx-exec.ts').GmxSignerAuth[];13  venues?: { authorization: { authorizationId: string; venue: string; network: string }; hash: string; approvedAt: string; revoked: boolean; revokedAt: string | null; expired: boolean; today: { orders: number; cancels: number; leverage: number } }[];14}1516interface SolTxParams { recentBlockhash: string; lastValidBlockHeight: number; computeUnitLimit: number; computeUnitPrice: string }17async function call<T>(method: 'GET' | 'POST', path: string, payload?: unknown): Promise<T> {18  const body = payload === undefined ? '' : JSON.stringify(payload);19  const ts = Math.floor(Date.now() / 1000), nonce = randomUUID();20  const mac = createHmac('sha256', config.signerSecret).update([method, path, ts, nonce, createHash('sha256').update(body).digest('hex')].join('\n')).digest('hex');21  let r: Response;22  try {23    // one connection per request: never reuse an idle keep-alive socket the signer may have closed24    r = await fetch(config.signerUrl + path, { method, headers: { 'x-spw-auth': `t=${ts},n=${nonce},s=${mac}`, 'content-type': 'application/json', connection: 'close' }, body: body || undefined, signal: AbortSignal.timeout(15000) });25  } catch (e) {26    const err = e as Error & { cause?: { code?: string; message?: string } };27    console.error(`[signer-client] ${method} ${path} failed: ${err.name} ${err.message} ${err.cause?.code ?? ''} ${err.cause?.message ?? ''}`);28    throw new UserError('signer_unreachable', 'the signer is unreachable; nothing was signed', 503);29  }30  const j = (await r.json().catch(() => ({}))) as { error?: string; message?: string };31  if (!r.ok) throw new UserError(`signer_${j.error ?? 'error'}`, j.message ?? 'signer refused', r.status === 401 ? 502 : 422);32  return j as T;33}3435let cached: { at: number; v: SignerStatus } | null = null;36export const signer = {37  async status(fresh = false): Promise<SignerStatus> {38    if (!fresh && cached && Date.now() - cached.at < 5000) return cached.v;39    const v = await call<SignerStatus>('GET', '/status');40    cached = { at: Date.now(), v };41    return v;42  },43  enroll: (p: unknown) => { cached = null; return call<{ ok: true }>('POST', '/enroll', p); },44  sign: (intent: unknown, assertion: unknown, aux?: unknown) => { cached = null; return call<{ raw: string; txid: string; hash: string; pre?: { raw: string; txid: string }; eventAccount?: string }>('POST', '/sign', { intent, assertion, ...(aux ? { aux } : {}) }); },45  bridgeMint: (p: { proposalId: string; message: string; attestation: string; evm?: { nonce: number; gasLimit: string; maxFeePerGas: string; maxPriorityFeePerGas: string }; /** v8: a mint on Solana */ sol?: SolTxParams; assertion?: unknown }) => {46    cached = null; return call<{ raw: string; txid: string; messageHash: string; minted: string; replayed: boolean }>('POST', '/bridge/mint', p); },47  // v8 (Solana ↔ EVM): reclaim of the event-account deposit of a burn made on Solana, and the wallet's own USDC token account — passkey each48  bridgeReclaim: (p: { proposalId: string; message: string; attestation: string; sol: SolTxParams; assertion: unknown }) => { cached = null; return call<{ raw: string; txid: string; eventAccount: string }>('POST', '/bridge/reclaim', p); },49  solTokenAccount: (request: unknown, assertion: unknown) => { cached = null; return call<{ raw: string; txid: string; hash: string; tokenAccount: string }>('POST', '/sol/token-account', { request, assertion }); },50  halt: () => { cached = null; return call<{ ok: true }>('POST', '/halt', {}); },51  // agent vaults and mandates52  vaultAddress: (index: number) => call<{ address: string }>('POST', '/vault/address', { index }),53  mandateApprove: (mandate: unknown, assertion: unknown) => { cached = null; return call<{ mandateId: string; hash: string }>('POST', '/mandate/approve', { mandate, assertion }); },54  mandateRevoke: (mandateId: string) => { cached = null; return call<{ ok: true }>('POST', '/mandate/revoke', { mandateId }); },55  mandateSign: (mandateId: string, intent: unknown, aux?: unknown) => { cached = null; return call<{ raw: string; txid: string; hash: string; pre?: { raw: string; txid: string } }>('POST', '/mandate/sign', { mandateId, intent, ...(aux ? { aux } : {}) }); },56  vaultReturn: (index: number, intent: unknown) => { cached = null; return call<{ raw: string; txid: string; hash: string }>('POST', '/vault/return', { index, intent }); },57  mandateReturn: (mandateId: string, intent: unknown) => { cached = null; return call<{ raw: string; txid: string; hash: string }>('POST', '/mandate/return', { mandateId, intent }); },  // live derivatives: venue trading key, authorization (passkey), one signature per venue action, revocation58  venueKey: (venue: string, network: string) => call<{ venue: string; network: string; address: string; path: string; account: string }>('POST', '/venue/key', { venue, network }),59  venueAuthorize: (authorization: unknown, assertion: unknown) => { cached = null; return call<{ authorizationId: string; hash: string; agentAddress: string; approve: { action: unknown; signature: { r: string; s: string; v: number }; nonce: number } }>('POST', '/venue/authorize', { authorization, assertion }); },60  venueSign: (p: { authorizationId: string; sign: unknown; context?: unknown; assertion?: unknown }) => { cached = null; return call<{ signature: { r: string; s: string; v: number }; signer: string; kind: string; hash: string }>('POST', '/venue/sign', p); },61  venueRevoke: (authorizationId: string) => { cached = null; return call<{ revoked: true; revokedAt: string }>('POST', '/venue/revoke', { authorizationId }); },62  // collateral: withdraw3 to the wallet's own address, signed by the main key after the passkey approved the canonical withdrawal63  venueWithdraw: (withdrawal: unknown, assertion: unknown) => { cached = null; return call<{ withdrawalId: string; hash: string; action: unknown; signature: { r: string; s: string; v: number }; nonce: number; signer: string }>('POST', '/venue/withdraw', { withdrawal, assertion }); },64  // derivative mandates: approval (passkey), one signature per venue action WITHOUT a passkey inside the limits the signer enforces, revocation65  venueMandateApprove: (mandate: unknown, assertion: unknown) => { cached = null; return call<{ mandateId: string; hash: string; authorizationId: string }>('POST', '/venue/mandate/approve', { mandate, assertion }); },66  venueMandateSign: (p: { mandateId: string; sign: unknown; context?: unknown }) => { cached = null; return call<{ signature: { r: string; s: string; v: number }; signer: string; kind: string; hash: string; mandateId: string; phase: string }>('POST', '/venue/mandate/sign', p); },67  // v8 GMX v2: the venue policy (passkey) and its revocation; orders go through `sign` with an intent carrying a `gmx` section68  gmxAuthorize: (authorization: unknown, assertion: unknown) => { cached = null; return call<{ authorizationId: string; hash: string }>('POST', '/venue/gmx/authorize', { authorization, assertion }); },69  gmxRevoke: (authorizationId: string) => { cached = null; return call<{ revoked: true; revokedAt: string }>('POST', '/venue/gmx/revoke', { authorizationId }); },70  venueMandateRevoke: (mandateId: string) => { cached = null; return call<{ revoked: true; revokedAt: string; windDownUntil: number }>('POST', '/venue/mandate/revoke', { mandateId }); },71};7273/** The wallet's address on a network; fails closed when the signer has no key for it. */74export function addressFor(st: SignerStatus, network: string): string {75  const a = st.addresses[network];76  if (!a) throw new UserError('unsupported_network', 'the signer has no key for this network yet', 503);77  return a;78}79
Files main
Menu

Theme