SPB Git

Repository Public

store.ts 6.7 KB 94 lines typescript

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
Files main
Menu

Theme