SPB Git

Repository Public

main.ts 5.6 KB 89 lines typescript

1// SP Wallet isolated signer — CLI + daemon. Holds the only copy of signing keys.2import { createHash, randomBytes } from 'node:crypto';3import { existsSync, readFileSync } from 'node:fs';4import { homedir } from 'node:os';5import { join } from 'node:path';6import { createInterface } from 'node:readline/promises';7import { verifyAuditChain } from '@spw/core';8import dns from 'node:dns';9import net from 'node:net';10import { deriveKeys } from './keys.ts';11import { exchangePrices } from './prices.ts';12import { hyperliquidReader } from './venue-read.ts';13import { initKeystore, loadMnemonic } from './keystore.ts';14import { createSignerServer } from './server.ts';15import { Signer } from './signer.ts';16import { Store } from './store.ts';17import { thorVaults } from './vaults.ts';1819const dir = process.env.SPW_SIGNER_DIR ?? join(homedir(), 'sp-wallet-signer-data');20const wrapKey = process.env.SPW_SIGNER_WRAP_KEY ?? join(homedir(), '.config', 'sp-wallet-signer', 'wrap.key');21const paths = { keystore: join(dir, 'keystore.json'), state: join(dir, 'state.json'), policy: join(dir, 'policy.json'), secret: join(dir, 'gateway.secret') };22const need = (k: string) => { const v = process.env[k]; if (!v) { console.error(`missing env ${k}`); process.exit(2); } return v; };23process.umask(0o077);2425const cmd = process.argv[2] ?? 'serve';26const open = () => {27  const keys = deriveKeys(loadMnemonic(paths.keystore, wrapKey));28  const store = new Store(paths.state, paths.policy);29  return { keys, store };30};3132if (cmd === 'init') {33  initKeystore(paths.keystore, wrapKey);34  const { keys } = open();35  console.log(JSON.stringify({ ok: true, keyId: keys.keyId, addresses: keys.addresses }, null, 2));36  console.log('Keystore created. The recovery phrase was NOT displayed. Run `export-recovery` yourself in a private terminal and write it on paper.');37} else if (cmd === 'setup-token') {38  const { store } = open();39  if (store.state.credentials.length > 0) { console.error('a passkey is already enrolled; setup token refused'); process.exit(1); }40  const token = randomBytes(18).toString('base64url');41  store.state.setupTokenHash = createHash('sha256').update(token).digest('hex');42  store.log('setup_token.issued', {});43  store.save();44  console.log(token);45} else if (cmd === 'status') {46  const { keys, store } = open();47  const s = new Signer(keys, store, { rpId: 'unused', origin: 'unused' }).status();48  console.log(JSON.stringify({ ...s, journalIntact: verifyAuditChain(store.state.journal) === null }, null, 2));49} else if (cmd === 'halt' || cmd === 'unhalt') {50  const { store } = open();51  store.state.halted = cmd === 'halt';52  store.log(cmd === 'halt' ? 'halt.on' : 'halt.off', { via: 'cli' });53  store.save();54  console.log(`halted=${store.state.halted} (restart the signer daemon to apply)`);55} else if (cmd === 'reset-passkeys') {56  const { store } = open();57  store.state.credentials = [];58  store.log('credentials.reset', { via: 'cli' });59  store.save();60  console.log('all passkeys removed; issue a new setup-token and restart the daemon');61} else if (cmd === 'export-recovery') {62  if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error('export-recovery only runs in an interactive terminal (never piped, logged or run by an agent)'); process.exit(1); }63  const rl = createInterface({ input: process.stdin, output: process.stdout });64  const a = await rl.question('This prints the 24-word recovery phrase. Nobody is watching, no screen sharing, no AI session? Type SHOW: ');65  rl.close();66  if (a.trim() !== 'SHOW') process.exit(1);67  const words = loadMnemonic(paths.keystore, wrapKey).split(' ');68  console.log('\n' + words.map((w, k) => `${String(k + 1).padStart(2)}. ${w}`).join('\n') + '\n\nWrite it on paper, store it offline, then clear this terminal (Cmd-K).');69} else if (cmd === 'serve') {70  if (!existsSync(paths.keystore)) { console.error('no keystore: run `init` first'); process.exit(1); }71  const { keys, store } = open();72  const broken = verifyAuditChain(store.state.journal);73  if (broken !== null) { console.error(`signer journal hash chain broken at seq ${broken} — refusing to start`); process.exit(1); }74  const secret = existsSync(paths.secret) ? readFileSync(paths.secret, 'utf8').trim() : need('SPW_SIGNER_SECRET');75  // outbound HTTPS is used for two things only: reading public exchange prices to check agent orders and live venue orders, and —76  // for derivative mandates — reading the venue account (positions, account value, resting orders) from the venue's public info77  // endpoint (venue-read.ts: POST /info, no key). The signer never calls a venue's trading endpoint: the API sends what it signs.78  dns.setDefaultResultOrder('ipv4first'); net.setDefaultAutoSelectFamilyAttemptTimeout(2500);79  const signer = new Signer(keys, store, { rpId: need('SPW_RP_ID'), origin: need('SPW_ORIGIN') }, undefined, exchangePrices(), hyperliquidReader(),80    thorVaults()); // further outbound use: asking independent THORNode operators whether a vault is genuine before signing a cross-chain swap (vaults.ts)81  const host = process.env.SPW_SIGNER_HOST ?? '127.0.0.1', port = Number(process.env.SPW_SIGNER_PORT ?? 8651);82  const allowed = (process.env.SPW_SIGNER_ALLOW_IPS ?? '').split(',').map((s) => s.trim()).filter(Boolean);83  createSignerServer(signer, secret, allowed).listen(port, host, () => console.log(`signer ${keys.keyId} listening on ${host}:${port} halted=${store.state.halted}`));84  process.on('uncaughtException', (e) => { console.error('fatal:', e.message); process.exit(1); });85} else {86  console.error('usage: signer init | setup-token | status | serve | halt | unhalt | reset-passkeys | export-recovery');87  process.exit(2);88}89
Files main
Menu

Theme