/** * `1sat serve` command — launch wallet server and/or monitor. * * 1sat serve Wallet server + monitor daemon * 1sat serve wallet Wallet server only * 1sat serve monitor Monitor daemon only * 1sat serve messagebox BSV message-box server * * The server wraps the same wallet instance the CLI uses. Storage, active * remote, and backups all come from `~/.1sat/cli/config.json` via the same * `createNodeWallet` factory `1sat wallet` commands use. * * Server-only settings live under `server.*` in the config: * 1sat config set server.port 8100 * 1sat config set server.host 0.0.0.0 * 1sat config set server.accounts.enabled true * 1sat config set server.messagebox.port 8771 */ import { join } from 'node:path' import { type NodeWalletResult, type NodeWalletStorageConfig, createNodeWallet, } from '@1sat/wallet-node' import { createWalletServer } from '@1sat/wallet-server' import type { PrivateKey } from '@bsv/sdk' import { initLogger } from 'evlog' import type { GlobalFlags } from '../args' import { type ServerAccountsConfig, type ServerStorageConfig, loadConfig, setConfigPath, } from '../config' import { ensureDataDir } from '../config' import { printCommandHelp } from '../help' import { loadKey } from '../keys' import { clearMonitorPid, writeMonitorPid } from '../monitor-lock' import { fatal } from '../output' import { buildPriceUpdateTask, createAccountsConfigLoader, resolveRateProvider, } from '../repricer' import { startMessagebox } from './serve-messagebox' const DEFAULT_HOST = '127.0.0.1' const DEFAULT_PORT = 8100 const DEFAULT_ONESAT_URL = 'https://api.1sat.app/1sat' const DEFAULT_BASELINE_BYTES = 1024 * 1024 * 1024 // 1 GB const DEFAULT_PURCHASE_UNIT_BYTES = 1_073_741_824 // 1 GB chunks for production const DEFAULT_SATS_PER_UNIT = 1_000_000 const DEFAULT_DURATION_BLOCKS = 4383 const DEFAULT_STORAGE_IDENTITY_KEY = '1sat-cli-default' type ServeMode = 'all' | 'wallet' | 'monitor' | 'messagebox' interface ResolvedServe { chain: 'main' | 'test' host: string port: number onesatURL: string storage: ServerStorageConfig dataDir: string sqliteFilename: string storageIdentityKey: string activeRemote?: string backups?: string[] accounts: ResolvedAccounts privateKey: PrivateKey } interface ResolvedAccounts { enabled: boolean baselineBytes: number purchaseUnitBytes: number satsPerUnit: number durationBlocks: number freeIdentityKeys: string[] repricer?: { enabled?: boolean targetUsd?: number intervalMs?: number provider?: string maxMovePct?: number minSats?: number } } export async function handleServeCommand( args: string[], opts: GlobalFlags, ): Promise { const [subcommand] = args const mode = resolveMode(subcommand) if (mode === null) { printCommandHelp('serve', opts.json) if (subcommand && subcommand !== 'help') process.exit(1) return } initLogger({ env: { service: `1sat-cli-serve-${mode}` } }) if (mode === 'messagebox') { const handle = await startMessagebox(opts) try { await waitForShutdown() } finally { try { await handle.stop() } catch (err) { console.error(`Error during shutdown: ${(err as Error).message}`) } } return } const resolved = await resolveServe(opts) const handles: Stoppable[] = [] try { handles.push(await runWithStorage(resolved, mode)) await waitForShutdown() } finally { for (const h of handles.reverse()) { try { await h.stop() } catch (err) { console.error(`Error during shutdown: ${(err as Error).message}`) } } } } function resolveMode(subcommand: string | undefined): ServeMode | null { if (!subcommand) return 'all' switch (subcommand) { case 'wallet': case 'monitor': case 'messagebox': return subcommand default: return null } } /** * Load the CLI config, apply serve defaults, and resolve the server identity * key via the existing keyring mechanism. */ async function resolveServe(opts: GlobalFlags): Promise { const config = loadConfig() const server = config.server ?? {} const storage: ServerStorageConfig = server.storage ?? { provider: 'bun-sqlite', } if (storage.provider === 'pg' && !storage.dbUrl) { fatal( 'server.storage.provider is pg but server.storage.dbUrl is not set. ' + 'Set it with: 1sat config set server.storage.dbUrl postgres://…', ) } const dataDir = ensureDataDir() const chain = opts.chain ?? config.chain ?? 'main' let privateKey: PrivateKey try { privateKey = await loadKey() } catch (err) { fatal((err as Error).message) } return { chain, host: server.host ?? DEFAULT_HOST, port: resolvePort(server.port), onesatURL: DEFAULT_ONESAT_URL, storage, dataDir, sqliteFilename: deriveSqliteFilename(dataDir, chain), storageIdentityKey: config.storageIdentityKey ?? DEFAULT_STORAGE_IDENTITY_KEY, activeRemote: config.activeRemote, backups: config.backups, accounts: resolveAccounts(server.accounts), privateKey, } } function deriveSqliteFilename(dataDir: string, chain: string): string { return join(dataDir, `wallet-${chain}.db`) } function resolvePort(configured: number | undefined): number { const fromEnv = process.env.ONESAT_PORT if (fromEnv && fromEnv.trim() !== '') { const parsed = Number(fromEnv) if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) { fatal(`ONESAT_PORT must be a valid port number, got: ${fromEnv}`) } return parsed } return configured ?? DEFAULT_PORT } function resolveWalletStorageConfig( resolved: ResolvedServe, ): NodeWalletStorageConfig { const storage = resolved.storage if (storage.provider === 'bun-sqlite') { return { provider: 'bun-sqlite', filename: resolved.sqliteFilename } } if (storage.provider === 'pg') { return { provider: 'pg', dbUrl: storage.dbUrl } } fatal( `server.storage.provider '${storage.provider}' is not supported. Use 'bun-sqlite' or 'pg'.`, ) } function resolveAccounts(accounts?: ServerAccountsConfig): ResolvedAccounts { return { enabled: accounts?.enabled ?? false, baselineBytes: accounts?.baselineBytes ?? DEFAULT_BASELINE_BYTES, purchaseUnitBytes: accounts?.purchaseUnitBytes ?? DEFAULT_PURCHASE_UNIT_BYTES, satsPerUnit: accounts?.satsPerUnit ?? DEFAULT_SATS_PER_UNIT, durationBlocks: accounts?.durationBlocks ?? DEFAULT_DURATION_BLOCKS, freeIdentityKeys: accounts?.freeIdentityKeys ?? [], repricer: accounts?.repricer, } } interface Stoppable { stop(): Promise } /** * Construct the wallet via the same `createNodeWallet` factory the CLI * uses, with storage provider (bun-sqlite / pg) chosen from config. * Server + monitor operate on that single wallet instance, so * `activeRemote`, `backups`, and `storageIdentityKey` behave identically * to `1sat wallet `. */ async function runWithStorage( resolved: ResolvedServe, mode: ServeMode, ): Promise { const storage = resolveWalletStorageConfig(resolved) const walletResult = await createNodeWallet({ privateKey: resolved.privateKey, chain: resolved.chain, storageIdentityKey: resolved.storageIdentityKey, storage, activeRemote: resolved.activeRemote, backups: resolved.backups, // Every serve mode manages monitor work explicitly: wallet mode // does none, monitor/all modes run startTasks. The factory's // initial runOnce is redundant in all three cases. skipInitialMonitor: true, }) const accounts = mode === 'monitor' ? undefined : await buildAccountsForServer(resolved, walletResult) const serverHandle = mode === 'monitor' ? undefined : await startWalletServer(resolved, walletResult, accounts) if (mode !== 'wallet') { const accountsCfg = resolved.accounts const r = accountsCfg.repricer if ( accountsCfg.enabled && r?.enabled && typeof r.targetUsd === 'number' && r.targetUsd > 0 ) { walletResult.monitor.addTask( buildPriceUpdateTask({ monitor: walletResult.monitor, rateProvider: resolveRateProvider(r.provider ?? 'whatsonchain', { chain: resolved.chain, }), intervalMs: r.intervalMs ?? 15 * 60 * 1000, targetUsd: r.targetUsd, bounds: { maxMovePct: r.maxMovePct ?? 25, minSats: r.minSats ?? 1, }, readCurrentSats: () => loadConfig().server?.accounts?.satsPerUnit ?? DEFAULT_SATS_PER_UNIT, onPersist: async (sats) => { setConfigPath('server.accounts.satsPerUnit', sats) }, }), ) console.log( `[repricer] enabled — $${r.targetUsd}/unit every ${Math.round( (r.intervalMs ?? 900_000) / 1000, )}s via ${r.provider ?? 'whatsonchain'}`, ) } } if (mode !== 'wallet') { // startTasks loops until stopTasks flips its flag. Fire without // awaiting so the caller can install shutdown handlers and write // the monitor pid file. walletResult.monitor.startTasks().catch((err: unknown) => { console.error('[monitor] task loop exited:', err) }) writeMonitorPid(resolved.dataDir) console.log('[monitor] started') } return { async stop() { if (mode !== 'wallet') { walletResult.monitor.stopTasks() clearMonitorPid(resolved.dataDir) } if (serverHandle) await serverHandle.stop() // Accounts shares the wallet's connection — walletResult.destroy // below closes it. No separate teardown needed. await walletResult.destroy() }, } } async function startWalletServer( resolved: ResolvedServe, walletResult: NodeWalletResult, accounts: AccountsRuntime | undefined, ): Promise<{ stop(): Promise }> { const handle = createWalletServer({ wallet: walletResult.wallet, storage: walletResult.getActiveStorage(), serverIdentityKey: walletResult.wallet.identityKey, listen: { port: resolved.port, host: resolved.host }, publicPath: '/', internalPath: null, accounts: accounts?.walletServerAccounts, }) const port = await handle.start() const accountsNote = resolved.accounts.enabled ? ' (accounts: on)' : '' console.log(`[wallet] listening on ${resolved.host}:${port}${accountsNote}`) return { stop: () => handle.stop() } } interface AccountsRuntime { walletServerAccounts: NonNullable< Parameters[0]['accounts'] > } async function buildAccountsForServer( resolved: ResolvedServe, walletResult: NodeWalletResult, ): Promise { // When the wallet is remote-primary the server is fronting someone else's // storage; accounts semantics don't apply. if (resolved.activeRemote) return undefined const getConfig = createAccountsConfigLoader({ ttlMs: 60_000, read: () => { const fresh = loadConfig() const a = fresh.server?.accounts return { enabled: a?.enabled ?? false, baselineBytes: a?.baselineBytes ?? DEFAULT_BASELINE_BYTES, purchaseUnitBytes: a?.purchaseUnitBytes ?? DEFAULT_PURCHASE_UNIT_BYTES, satsPerUnit: a?.satsPerUnit ?? DEFAULT_SATS_PER_UNIT, durationBlocks: a?.durationBlocks ?? DEFAULT_DURATION_BLOCKS, freeIdentityKeys: a?.freeIdentityKeys ?? [], } }, }) return { walletServerAccounts: { getConfig, currentBlock: () => walletResult.services.chaintracks.currentHeight(), }, } } function waitForShutdown(): Promise { return new Promise((resolve) => { const handler = (sig: string) => { console.log(`Received ${sig}, shutting down...`) resolve() } process.once('SIGINT', () => handler('SIGINT')) process.once('SIGTERM', () => handler('SIGTERM')) }) }