Repository Public
1import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from 'node:fs';2import { dirname } from 'node:path';3import { appendAudit, DEFAULT_POLICY, PolicySchema, type AuditRecord, type CctpBridge, type Mandate, type Policy } from '@spw/core';45export interface StoredCredential { id: string; publicKey: string; counter: number; transports?: string[]; addedAt: string }6export interface SignerState {7 v: 1;8 halted: boolean;9 setupTokenHash: string | null;10 credentials: StoredCredential[];11 /** proposalId -> expiresAt (unix s); single-use replay cache */12 used: Record<string, number>;13 /** "<YYYY-MM-DD>|<network>:<symbol>" -> base units signed that UTC day */14 spent: Record<string, string>;15 journal: AuditRecord[];16 /** agent mandates approved by the owner; usage is counted per UTC day */17 mandates?: Record<string, StoredMandate>;18 /** bridges approved by the owner (proposalId -> record): each authorises the one matching mint on the destination, with no expiry */19 bridges?: Record<string, StoredBridge>;20 /** live derivatives: venue authorizations approved by the owner (authorizationId -> record). Never deleted: a revoked one stays revoked. */21 venues?: Record<string, StoredVenueAuthorization>;22 /** venue withdrawals signed by the main key after a passkey approval (withdrawalId -> record), kept 30 days (venue-collateral.ts) */23 venueWithdrawals?: Record<string, import('./venue-collateral.ts').StoredVenueWithdrawal>;24 /** derivative mandates approved by the owner (mandateId -> record), each extending one venue authorization. Never deleted. */25 venueMandates?: Record<string, StoredVenueMandate>;26}27/** an order signed with a trading key that can still add exposure on the venue: kept until its venue expiry + a grace (venue-mandate.ts) */28export interface VenuePendingOrder { clientId: string; instrument: string; side: 'buy' | 'sell'; size: string; price: string; /** unix ms */ until: number; /** unix ms */ signedAt?: number }29/**30 * `usage`: per UTC day — orders that can add exposure, reduce-only orders, cancels, leverage changes, and the summed notional of31 * the first. `hour`: unix seconds of the exposure-adding orders signed in the last hour.32 */33export interface StoredVenueMandate { mandate: import('@spw/derivatives/live').VenueMandate; hash: string; approvedAt: string; revoked: boolean; revokedAt?: string;34 usage: Record<string, { orders: number; reduceOnly: number; cancels: number; leverage: number; turnover: string }>; hour: number[] }35/**36 * `approve`: the one approveAgent signature the main key made for this authorization (kept so that a lost answer gets the same37 * bytes again, never a second signature). `nonces`: the highest venue nonces signed with the trading key (ascending, at most 100 — the venue's own rule). `clientIds`: client38 * order id -> unix day number it was signed (replay; kept three days). `usage`: orders signed per UTC day.39 */40export interface StoredVenueAuthorization { authorization: import('@spw/derivatives/live').VenueAuthorization; hash: string; approvedAt: string; revoked: boolean; revokedAt?: string;41 approve: { action: unknown; signature: { r: string; s: string; v: number }; nonce: number }; nonces: number[]; clientIds: Record<string, number>; usage: Record<string, { orders: number; cancels: number; leverage: number }>;42 /** exposure ledger shared by manual and mandate orders of this trading key (read only by mandates) */ pending?: VenuePendingOrder[] }43/**44 * `mint` is set when a receiveMessage transaction has been signed for this bridge. `raw` is kept so that an identical45 * request (lost answer, restart) gets the same bytes back instead of a second signature.46 */47export interface StoredBridge { bridge: CctpBridge; approvedAt: number; burnTxid: string;48 mint?: { messageHash: string; txid: string; raw: string; params: string; signedAt: number; signatures: number } }49export interface StoredMandate { mandate: Mandate; approvedAt: string; revoked: boolean; revokedAt?: string; usage: Record<string, { sold: Record<string, string>; orders: number }> }5051const EMPTY: SignerState = { v: 1, halted: false, setupTokenHash: null, credentials: [], used: {}, spent: {}, journal: [] };5253export class Store {54 state: SignerState;55 policy: Policy;56 constructor(private statePath: string, private policyPath: string) {57 this.state = existsSync(statePath) ? (JSON.parse(readFileSync(statePath, 'utf8')) as SignerState) : structuredClone(EMPTY);58 if (existsSync(policyPath)) this.policy = PolicySchema.parse(JSON.parse(readFileSync(policyPath, 'utf8')));59 else { this.policy = DEFAULT_POLICY; this.atomicWrite(policyPath, JSON.stringify(DEFAULT_POLICY, null, 2)); }60 }61 private atomicWrite(path: string, data: string): void {62 mkdirSync(dirname(path), { recursive: true, mode: 0o700 });63 const tmp = `${path}.tmp`;64 const fd = openSync(tmp, 'w', 0o600);65 try { writeSync(fd, data); fsyncSync(fd); } finally { closeSync(fd); }66 renameSync(tmp, path);67 }68 /** Durably persist before any signature is released. */69 save(): void { this.atomicWrite(this.statePath, JSON.stringify(this.state)); }70 log(type: string, data: Record<string, unknown>): void {71 const j = this.state.journal;72 j.push(appendAudit(j[j.length - 1] ?? null, type, 'signer', data));73 }74 prune(nowSec: number): void {75 for (const [k, exp] of Object.entries(this.state.used)) if (exp + 86400 < nowSec) delete this.state.used[k];76 const keep = new Date((nowSec - 3 * 86400) * 1000).toISOString().slice(0, 10);77 for (const k of Object.keys(this.state.spent)) if (k.slice(0, 10) < keep) delete this.state.spent[k];78 for (const m of Object.values(this.state.mandates ?? {})) for (const d of Object.keys(m.usage)) if (d < keep) delete m.usage[d];79 // a bridge whose mint was signed is kept 90 more days (retries); one that was never minted is kept for ever80 for (const [k, b] of Object.entries(this.state.bridges ?? {})) if (b.mint && b.mint.signedAt + 90 * 86400 < nowSec) delete this.state.bridges![k];81 // venue authorizations: daily counters and client order ids older than three days go (an order approval is valid two minutes,82 // and the venue nonce only moves forward, so an old client id cannot come back without a new passkey approval)83 for (const v of Object.values(this.state.venues ?? {})) {84 for (const d of Object.keys(v.usage)) if (d < keep) delete v.usage[d];85 for (const [c, d] of Object.entries(v.clientIds)) if (d < Math.floor(nowSec / 86400) - 3) delete v.clientIds[c];86 if (v.pending) v.pending = v.pending.filter((p) => p.until > nowSec * 1000);87 }88 for (const m of Object.values(this.state.venueMandates ?? {})) {89 for (const d of Object.keys(m.usage)) if (d < keep) delete m.usage[d];90 m.hour = m.hour.filter((t) => t > nowSec - 3600);91 }92 }93}94