diff --git a/scripts/theme-baseline.json b/scripts/theme-baseline.json index 6f7651b3..39c476f2 100644 --- a/scripts/theme-baseline.json +++ b/scripts/theme-baseline.json @@ -12,6 +12,5 @@ "src/workspaces/officerdev/src/apps/Preview": 4, "src/workspaces/officerdev/src/apps/QrTransfer": 5, "src/workspaces/officerdev/src/apps/SystemMonitor": 1, - "src/workspaces/officerdev/src/apps/Terminal": 10, - "src/workspaces/officerdev/src/apps/Wallet": 68 + "src/workspaces/officerdev/src/apps/Terminal": 10 } diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index e09bd5db..bdf714ae 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -107,8 +107,6 @@ export function App() { } /> } /> } /> - } /> - } /> } /> } /> {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx deleted file mode 100644 index 5f612ace..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Navigate, useLocation, useParams } from 'react-router'; -import type { LayoutNode } from 'officerdev'; -import { WorkspaceView, DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection } from 'officerdev'; -import { useDashboardState } from 'state/useDashboardState'; -import { defaultLayout } from './defaultLayout'; - -// /wallet uses the Workspace/Panel system (like /command-hq): the wallet list and section nav on the left, -// the section itself on the right. Both talk to the officer-wallet sidecar through the /api/wallet auth -// proxy, which holds no key material of its own — seeds live encrypted in the sidecar and are decrypted -// there only for the length of an unlock window. -// -// The open section is :section in the URL, the open wallet is ?wallet= and any coin-control selection is -// ?coins=, so both panels read the URL with useParams/useSearchParams rather than passing state between -// themselves over a channel. This screen backs both /wallet and /wallet/:section and is the single place -// that decides what an absent or bogus section means. - -export const WalletScreen = () => { - const { section } = useParams(); - const { search } = useLocation(); - const workspace = useDashboardState('screens/wallet', defaultLayout); - - // Bare /wallet, or a section that doesn't exist, resolves to a canonical URL rather than rendering a - // default while the address bar says something else — the nav highlight is derived from the URL. The - // ?wallet= param is deliberately carried through rather than dropped: /wallet?wallet=3 is a legitimate - // deep link and canonicalising the section must not silently change which wallet is open. - if (!isWalletSection(section)) { - return ; - } - - return ( -
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts deleted file mode 100644 index ebdad736..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { LayoutNode } from 'officerdev'; - -export const defaultLayout: LayoutNode = { - type: 'group', - id: 'wallet-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'wallet-nav', appType: 'wallet-nav' }, size: 24 }, - { node: { type: 'panel', id: 'wallet-view', appType: 'wallet-view' }, size: 76 }, - ], -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx b/src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx deleted file mode 100644 index b39d800e..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './WalletScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 902d20c6..37410a2a 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -15,7 +15,6 @@ export * from './Calendar'; export * from './Contacts'; export * from './CommandHQ'; export * from './Gitea'; -export * from './Wallet'; export * from './SystemMonitor'; export * from './Activity'; export * from './CodeEditor'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 94c72a89..117eec9c 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -25,7 +25,6 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/plugins'), title: 'Marketplace' }, { match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' }, { match: (p) => p.startsWith('/gitea'), title: 'Gitea' }, - { match: (p) => p.startsWith('/wallet'), title: 'Wallet' }, { match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' }, { match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' }, { match: (p) => p.startsWith('/activity'), title: 'Activity' }, diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index b14ea104..fe9850cc 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -47,7 +47,6 @@ export * from './user-data'; export * from './dav'; export * from './notify'; -export * from './wallet'; // `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was // deleted end to end on 2026-08-13 after `task-logger.ts` turned out to have no callers — a full read diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 57e4d6f4..efa142d2 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections // export * from './notify/schema'; // push_devices officer-notify // export * from './dav/schema'; // dav_app_passwords officer-caldav -// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache // ── Plugin tables are NOT here ─────────────────────────────────────────────────────────────────── // diff --git a/src/databases/officer_db/src/wallet/index.ts b/src/databases/officer_db/src/wallet/index.ts deleted file mode 100644 index 53d8d137..00000000 --- a/src/databases/officer_db/src/wallet/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { - listWallets, - getWallet, - getActiveWallet, - getWalletSecrets, - getSealedSeed, - createWallet, - updateWallet, - replaceSealedSeed, - setActiveWallet, - deleteWallet, - getWalletLabels, - setWalletLabel, - getFrozenOutpoints, - setUtxoFrozen, - getWalletChainCache, - saveWalletChainCache, - recordWalletChainError, -} from './queries'; - -export type { - WalletKind, - WalletSummary, - WalletSecrets, - WalletLabel, - CreateWalletParams, - WalletChainCache, -} from './queries'; - -export type { WalletChainSnapshot } from './schema'; diff --git a/src/databases/officer_db/src/wallet/queries.ts b/src/databases/officer_db/src/wallet/queries.ts deleted file mode 100644 index 76a5fbfa..00000000 --- a/src/databases/officer_db/src/wallet/queries.ts +++ /dev/null @@ -1,349 +0,0 @@ -import type { WalletChainSnapshot } from './schema'; -import { eq, and, desc } from 'drizzle-orm'; -import { db } from '../db'; -import { walletWallets, walletLabels, walletFrozenUtxos, walletChainCache } from './schema'; -import { encryptSecret, decryptSecret } from '../crypto'; - -// Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the at-rest layer ('wallet' -// purpose in the secret store) is -// applied and stripped here, so route handlers never touch crypto. See ../crypto.ts, ../schema/wallet.ts. -// -// Note what "plaintext" means for `seedEnvelope`: it is the passphrase-sealed envelope, which is itself -// still ciphertext. This layer only removes the SECOND wrapping. Nothing in this file can read a mnemonic, -// and that is intentional — only servers/sidecar/wallet/keys.ts can, and only with the owner passphrase. -// -// THREE return types, and the projection is what enforces the separation: -// WalletSummary — safe to serialize to the browser. No config, no seed envelope, at all. -// WalletSecrets — decrypted config for the sidecar's own upstream calls. Never returned by a handler. -// SealedSeed — the sealed envelope, for keys.ts to open with the owner passphrase. -// A bare `select()` would leak both ciphertext columns into every list response the moment someone -// forgot to strip them, so no function here uses one. - -export type WalletKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc'; - -export type WalletSummary = { - id: number; - name: string; - kind: WalletKind; - network: string; - fingerprint: string | null; - xpubs: Record | null; - defaultBip: number; - isActive: boolean; - /** Whether this wallet holds a seed at all — i.e. whether unlock/lock apply to it. */ - hasSeed: boolean; - createdAt: Date; -}; - -export type WalletSecrets = { id: number; kind: WalletKind; network: string; config: Record | null }; - -const walletCols = { - id: walletWallets.id, - name: walletWallets.name, - kind: walletWallets.kind, - network: walletWallets.network, - fingerprint: walletWallets.fingerprint, - xpubs: walletWallets.xpubs, - defaultBip: walletWallets.defaultBip, - isActive: walletWallets.isActive, - createdAt: walletWallets.createdAt, - seedEnvelope: walletWallets.seedEnvelope, -}; - -function toSummary(row: { - id: number; - name: string; - kind: string; - network: string; - fingerprint: string | null; - xpubs: Record | null; - defaultBip: number; - isActive: boolean; - createdAt: Date; - seedEnvelope: string | null; -}): WalletSummary { - return { - id: row.id, - name: row.name, - kind: row.kind as WalletKind, - network: row.network, - fingerprint: row.fingerprint, - xpubs: row.xpubs, - defaultBip: row.defaultBip, - isActive: row.isActive, - hasSeed: row.seedEnvelope !== null, - createdAt: row.createdAt, - }; -} - -/** Every wallet the owner has registered, active first then newest. Never includes secrets. */ -export async function listWallets(userId: number): Promise { - const rows = await db - .select(walletCols) - .from(walletWallets) - .where(eq(walletWallets.userId, userId)) - .orderBy(desc(walletWallets.isActive), desc(walletWallets.createdAt)); - return rows.map(toSummary); -} - -export async function getWallet(userId: number, id: number): Promise { - const [row] = await db - .select(walletCols) - .from(walletWallets) - .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id))); - return row ? toSummary(row) : null; -} - -export async function getActiveWallet(userId: number): Promise { - const [row] = await db - .select(walletCols) - .from(walletWallets) - .where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true))); - return row ? toSummary(row) : null; -} - -/** Decrypted backend connection config, for the sidecar's upstream calls. Never leaves the sidecar. */ -export async function getWalletSecrets(userId: number, id: number): Promise { - const [row] = await db - .select({ - id: walletWallets.id, - kind: walletWallets.kind, - network: walletWallets.network, - config: walletWallets.config, - }) - .from(walletWallets) - .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id))); - if (!row) return null; - return { - id: row.id, - kind: row.kind as WalletKind, - network: row.network, - config: row.config ? (JSON.parse(decryptSecret('wallet', row.config)) as Record) : null, - }; -} - -/** - * The passphrase-sealed seed envelope, still sealed. Returns null for wallets that hold no seed. - * The ONLY caller should be the sidecar's unlock path. - */ -export async function getSealedSeed(userId: number, id: number): Promise { - const [row] = await db - .select({ seedEnvelope: walletWallets.seedEnvelope }) - .from(walletWallets) - .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id))); - if (!row?.seedEnvelope) return null; - return decryptSecret('wallet', row.seedEnvelope); -} - -export type CreateWalletParams = { - userId: number; - name: string; - kind: WalletKind; - network: string; - /** Plaintext; encrypted here. */ - config?: Record | null; - /** The already-sealed envelope as JSON; encrypted again here. */ - sealedSeed?: string | null; - fingerprint?: string | null; - xpubs?: Record | null; - defaultBip?: number; - makeActive?: boolean; -}; - -export async function createWallet(params: CreateWalletParams): Promise { - return db.transaction(async (tx) => { - if (params.makeActive) { - await tx - .update(walletWallets) - .set({ isActive: false }) - .where(and(eq(walletWallets.userId, params.userId), eq(walletWallets.isActive, true))); - } - const [row] = await tx - .insert(walletWallets) - .values({ - userId: params.userId, - name: params.name, - kind: params.kind, - network: params.network, - config: params.config ? encryptSecret('wallet', JSON.stringify(params.config)) : null, - seedEnvelope: params.sealedSeed ? encryptSecret('wallet', params.sealedSeed) : null, - fingerprint: params.fingerprint ?? null, - xpubs: params.xpubs ?? null, - defaultBip: params.defaultBip ?? 84, - isActive: params.makeActive ?? false, - }) - .returning(walletCols); - return toSummary(row!); - }); -} - -/** - * Mutable wallet metadata. CANNOT TOUCH THE SEED — see replaceSealedSeed below for why that is a - * separate function rather than one more optional field here. - */ -export async function updateWallet( - userId: number, - id: number, - patch: { name?: string; config?: Record; defaultBip?: number }, -): Promise { - const set: Record = { updatedAt: new Date() }; - if (patch.name !== undefined) set.name = patch.name; - if (patch.defaultBip !== undefined) set.defaultBip = patch.defaultBip; - if (patch.config !== undefined) set.config = encryptSecret('wallet', JSON.stringify(patch.config)); - - const [row] = await db - .update(walletWallets) - .set(set) - .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id))) - .returning(walletCols); - return row ? toSummary(row) : null; -} - -/** - * Overwrite the encrypted seed. The ONLY caller is the passphrase change, which reseals the same words - * under a new passphrase and has already proved knowledge of the old one. - * - * It is a function of its own, rather than a fourth field on updateWallet, because it was one: the PATCH - * route typed its request body as `{name, defaultBip, config}` and passed the parsed object straight - * through, and a TypeScript annotation strips nothing at runtime. Any authenticated caller could send a - * `sealedSeed` key and replace the seed — no passphrase, no unlock, no confirmation — and since - * encryptSecret happily encrypts nonsense, the damage only surfaced at the next unlock attempt. Keeping - * the two apart means the next field added to that body cannot re-open it. - */ -export async function replaceSealedSeed(userId: number, id: number, sealedSeed: string): Promise { - await db - .update(walletWallets) - .set({ seedEnvelope: encryptSecret('wallet', sealedSeed), updatedAt: new Date() }) - .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id))); -} - -/** Exactly one active wallet per owner. Cleared and set in one transaction; the partial unique index is the backstop. */ -export async function setActiveWallet(userId: number, id: number): Promise { - await db.transaction(async (tx) => { - await tx - .update(walletWallets) - .set({ isActive: false }) - .where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true))); - await tx - .update(walletWallets) - .set({ isActive: true, updatedAt: new Date() }) - .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id))); - }); -} - -export async function deleteWallet(userId: number, id: number): Promise { - const rows = await db - .delete(walletWallets) - .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id))) - .returning({ id: walletWallets.id }); - return rows.length > 0; -} - -// ── labels ─────────────────────────────────────────────────────────────────────────────────────── - -export type WalletLabel = { kind: 'address' | 'tx'; ref: string; label: string }; - -export async function getWalletLabels(walletId: number): Promise { - const rows = await db - .select({ kind: walletLabels.kind, ref: walletLabels.ref, label: walletLabels.label }) - .from(walletLabels) - .where(eq(walletLabels.walletId, walletId)); - return rows.map((r) => ({ kind: r.kind as 'address' | 'tx', ref: r.ref, label: r.label })); -} - -export async function setWalletLabel( - walletId: number, - kind: 'address' | 'tx', - ref: string, - label: string, -): Promise { - // An empty label is a delete — the UI clears a field rather than pressing a separate button. - if (!label.trim()) { - await db - .delete(walletLabels) - .where(and(eq(walletLabels.walletId, walletId), eq(walletLabels.kind, kind), eq(walletLabels.ref, ref))); - return; - } - await db - .insert(walletLabels) - .values({ walletId, kind, ref, label }) - .onConflictDoUpdate({ target: [walletLabels.walletId, walletLabels.kind, walletLabels.ref], set: { label } }); -} - -// ── frozen UTXOs ───────────────────────────────────────────────────────────────────────────────── - -export async function getFrozenOutpoints(walletId: number): Promise { - const rows = await db - .select({ outpoint: walletFrozenUtxos.outpoint }) - .from(walletFrozenUtxos) - .where(eq(walletFrozenUtxos.walletId, walletId)); - return rows.map((r) => r.outpoint); -} - -export async function setUtxoFrozen( - walletId: number, - outpoint: string, - frozen: boolean, - reason?: string, -): Promise { - if (!frozen) { - await db - .delete(walletFrozenUtxos) - .where(and(eq(walletFrozenUtxos.walletId, walletId), eq(walletFrozenUtxos.outpoint, outpoint))); - return; - } - await db - .insert(walletFrozenUtxos) - .values({ walletId, outpoint, reason: reason ?? null }) - .onConflictDoNothing(); -} - -// ── chain cache ────────────────────────────────────────────────────────────────────────────────── -// -// Unlike everything above, none of this is authoritative — see the comment on the table. These three -// functions are deliberately total: a cache read or write must never be able to fail a wallet request, -// so the sidecar treats every one of them as best-effort. - -export type WalletChainCache = { - snapshot: WalletChainSnapshot | null; - syncedAt: Date | null; - lastError: string | null; - lastErrorAt: Date | null; -}; - -export async function getWalletChainCache(walletId: number): Promise { - const [row] = await db - .select({ - snapshot: walletChainCache.snapshot, - syncedAt: walletChainCache.syncedAt, - lastError: walletChainCache.lastError, - lastErrorAt: walletChainCache.lastErrorAt, - }) - .from(walletChainCache) - .where(eq(walletChainCache.walletId, walletId)); - return row ?? null; -} - -/** A successful refresh. Clears the error columns — the upstream just answered, so nothing is wrong now. */ -export async function saveWalletChainCache(walletId: number, snapshot: WalletChainSnapshot): Promise { - const syncedAt = new Date(); - await db - .insert(walletChainCache) - .values({ walletId, snapshot, syncedAt, lastError: null, lastErrorAt: null }) - .onConflictDoUpdate({ - target: walletChainCache.walletId, - set: { snapshot, syncedAt, lastError: null, lastErrorAt: null }, - }); -} - -/** A failed refresh. Touches only the error columns, so the last good snapshot and its age survive. */ -export async function recordWalletChainError(walletId: number, message: string): Promise { - const lastErrorAt = new Date(); - // Bounded because this is an upstream message verbatim, and an HTML error page from a misconfigured - // proxy would otherwise land whole in the column. - const lastError = message.slice(0, 500); - await db - .insert(walletChainCache) - .values({ walletId, snapshot: null, syncedAt: null, lastError, lastErrorAt }) - .onConflictDoUpdate({ target: walletChainCache.walletId, set: { lastError, lastErrorAt } }); -} diff --git a/src/databases/officer_db/src/wallet/schema.ts b/src/databases/officer_db/src/wallet/schema.ts deleted file mode 100644 index 40ebf6f6..00000000 --- a/src/databases/officer_db/src/wallet/schema.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core'; -import { sql } from 'drizzle-orm'; -import { users } from '../auth/schema'; - -// Bitcoin wallets for the officer-wallet sidecar. The owner registers one or more wallets — either a -// self-custodial on-chain wallet whose seed lives here, or a connection to a node (LND / Core Lightning / -// LNDHub / NWC) exactly as Zeus models them — and switches between them. -// -// TWO COLUMNS HOLD SPENDING AUTHORITY AND THEY ARE PROTECTED DIFFERENTLY. This asymmetry is deliberate: -// -// `config` — node credentials (macaroon, rune, LNDHub password, NWC URI). Encrypted at rest with -// the 'wallet' store key via ../crypto.ts, the way offscale_servers.api_key uses its -// own. It CANNOT be -// passphrase-protected: background balance polling needs it without the owner present. -// -// `seed_envelope` — a BIP39 mnemonic that is ALREADY sealed under an owner passphrase by the sidecar -// (see servers/sidecar/wallet/keys.ts) before it ever arrives here, and is then -// encrypted AGAIN with the 'wallet' store key on the way into this column. Two independent -// secrets, neither sufficient alone. A database dump does not spend; a leaked .env -// does not spend. -// -// `xpubs` and `fingerprint` are stored in the CLEAR, on purpose. They are what lets the wallet show -// balances, history and fresh receive addresses while locked — the watch-only-when-locked property. An -// xpub leak costs privacy (an observer can enumerate the wallet's addresses), never funds. -// -// Every table is `wallet_`-prefixed and this file holds nothing else, so it can move into -// src/servers/sidecar/wallet/ wholesale when sidecars own their schema. Only officer-wallet reads these. - -export const walletWallets = pgTable( - 'wallet_wallets', - { - id: serial('id').primaryKey(), - userId: integer('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - name: text('name').notNull(), - // BackendKind: 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc'. TS-only enum, no DB CHECK — - // matches how pipeline_jobs.mode is handled elsewhere in this schema. - kind: text('kind').notNull(), - // BitcoinNetwork: 'bitcoin' | 'testnet' | 'signet' | 'regtest'. - network: text('network').notNull().default('bitcoin'), - // Backend connection config as JSON, encrypted. Null for a pure on-chain wallet, which connects to - // nothing but the Esplora endpoint the sidecar is configured with. - config: text('config'), - // The passphrase-sealed SeedEnvelope, encrypted again. Null for every remote-node wallet — those - // hold no seed, the node does. - seedEnvelope: text('seed_envelope'), - // BIP32 master fingerprint (8 hex chars), for PSBT construction and hardware-wallet pairing. - fingerprint: text('fingerprint'), - // { "44": "xpub…", "49": "ypub…", "84": "zpub…", "86": "xpub…" } — plaintext, see header. - xpubs: jsonb('xpubs').$type>(), - // Which derivation standard new receive addresses use. 84 (native segwit) is the default. - defaultBip: integer('default_bip').notNull().default(84), - isActive: boolean('is_active').notNull().default(false), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [ - uniqueIndex('uq_wallet_wallets_user_name').on(t.userId, t.name), - // At most one active wallet per owner, enforced by the DB rather than convention — a partial unique - // index over active rows only, mirroring uq_offscale_servers_one_active. - uniqueIndex('uq_wallet_wallets_one_active') - .on(t.userId) - .where(sql`${t.isActive}`), - ], -); - -// Owner-assigned labels for addresses and transactions. Zeus keeps these client-side; here they belong to -// the server so every client sees the same annotations. -export const walletLabels = pgTable( - 'wallet_labels', - { - id: serial('id').primaryKey(), - walletId: integer('wallet_id') - .notNull() - .references(() => walletWallets.id, { onDelete: 'cascade' }), - // 'address' | 'tx' - kind: text('kind').notNull(), - // The address string or txid being labelled. - ref: text('ref').notNull(), - label: text('label').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [uniqueIndex('uq_wallet_labels_wallet_kind_ref').on(t.walletId, t.kind, t.ref)], -); - -// Frozen UTXOs, excluded from automatic coin selection. Separate from labels because this one affects -// what the wallet will actually spend — losing a label is cosmetic, losing a freeze spends a coin the -// owner meant to keep back. -// -// NOTE the contrast with wallet_chain_cache below: this table is POLICY (the owner's decision, only -// they can restate it) and that one is OBSERVATION (the chain's, refetchable at any time). That is why -// only one of them can be safely truncated. -export const walletFrozenUtxos = pgTable( - 'wallet_frozen_utxos', - { - id: serial('id').primaryKey(), - walletId: integer('wallet_id') - .notNull() - .references(() => walletWallets.id, { onDelete: 'cascade' }), - // `txid:vout` - outpoint: text('outpoint').notNull(), - reason: text('reason'), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [uniqueIndex('uq_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)], -); - -/** - * The last thing the chain told us about an on-chain wallet, so a wallet screen paints real numbers - * immediately instead of nothing while a gap-limit scan runs. - * - * Every field is DERIVED and rebuildable — it is recomputed from the account xpubs above on each - * successful refresh, so this table holds no secret and nothing authoritative. Truncating it costs one - * slow load and nothing else. - * - * Only the chain's own facts are stored. Addresses, scripts and pubkeys are NOT: they are pure EC - * derivation from the xpub, cheaper to recompute on load than to persist, and re-deriving them means a - * restored snapshot can never disagree with the wallet's actual keys. - */ -export type WalletChainSnapshot = { - /** Bumped when the shape below changes; a snapshot at any other version is discarded, not migrated. */ - v: 1; - tipHeight: number; - /** One entry per address the scan visited, keyed by its derivation coordinates. */ - addresses: { - /** AddressType: 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh'. */ - type: string; - /** BIP44 chain index: 0 receive, 1 change. */ - chain: number; - index: number; - confirmed: number; - unconfirmed: number; - txCount: number; - }[]; - utxos: { - txid: string; - vout: number; - value: number; - type: string; - chain: number; - index: number; - /** Null while unconfirmed. Confirmations are recomputed against tipHeight, never stored. */ - blockHeight: number | null; - }[]; - /** The OnchainTx shape from servers/sidecar/wallet/types.ts, restated here so this package imports nothing. */ - transactions: { - txid: string; - amount: number; - feeSats: number | null; - blockHeight: number | null; - timestamp: number | null; - confirmations: number; - label: string | null; - destAddresses: string[]; - rawHex: string | null; - }[]; -}; - -export const walletChainCache = pgTable('wallet_chain_cache', { - // One row per wallet, so a refresh is a single atomic upsert with no partially-written window. - walletId: integer('wallet_id') - .primaryKey() - .references(() => walletWallets.id, { onDelete: 'cascade' }), - // Nullable: a wallet whose very first refresh failed has an error to report and no data to report it - // against, and that row still has to exist for the UI to say why the screen is empty. - snapshot: jsonb('snapshot').$type(), - /** When the chain was last read successfully — the age the UI shows. Not when the row was written. */ - syncedAt: timestamp('synced_at', { withTimezone: true }), - // Kept ALONGSIDE the last good snapshot rather than replacing it. A failure that wiped the data would - // reproduce the exact bug this table exists to fix: an unreachable upstream rendering as a zero balance - // instead of as a stale one. - lastError: text('last_error'), - lastErrorAt: timestamp('last_error_at', { withTimezone: true }), -}); diff --git a/src/servers/api/wallet/router.ts b/src/servers/api/wallet/router.ts deleted file mode 100644 index 17f6486d..00000000 --- a/src/servers/api/wallet/router.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createSidecarProxy } from '../../sidecar/create-proxy'; - -// /api/wallet/* — auth, then forward to officer-wallet. No routes of its own, and no wallet knowledge -// whatsoever: not a seed, not a node credential, not an xpub. The platform cannot spend and cannot read a -// balance except by asking the sidecar. -// -// The unlock passphrase DOES transit this proxy — the browser has to send it somewhere — but it is -// forwarded as an opaque body and never parsed, logged or retained. See sidecar/create-proxy.ts, which -// deliberately performs no body inspection; that restraint matters more for this sidecar than any other. - -const proxy = createSidecarProxy({ name: 'wallet', prefix: '/api/wallet' }); - -export const walletRouter = proxy.router; - -/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ -export const getWalletServerUrl = proxy.getHttpUrl; diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index 9d2609b2..9f2454c7 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -257,17 +257,6 @@ export const CATALOGUE: CatalogueEntry[] = [ // `[open]` This is the "two dock sources" seam. The store keeps its own catalogue while the plugin // system builds tiles from manifests, and the self endpoint concatenates both. One when the store is // rebuilt on the plugin system. - { - id: 'wallet', - ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] }, - process: 'officer-wallet', - label: 'Wallet', - summary: 'Bitcoin and Lightning, with keys held by the sidecar alone', - members: 'none', - modes: ['config'], - permission: 'wallet', - configFields: [], - }, { id: 'notify', process: 'officer-notify', diff --git a/src/servers/hono.ts b/src/servers/hono.ts index fcbddf18..e9cb5e1f 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -24,7 +24,6 @@ import { devProxyRouter } from './api/dev-proxy/router'; import { pluginsRouter } from './api/plugins/router'; import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths'; import { agentHandoffRouter } from './api/agent-handoff/router'; -// import { walletRouter } from './api/wallet/router'; import { terminalRouter } from './api/terminal/sidecar-server'; import { caldavRouter } from './api/dav/sidecar-server'; // import { memosRouter } from './api/memos/router'; @@ -147,7 +146,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) // ['/notify', notifyRouter], // plugin — switched off 2026-08-13 // ['/photos', photosRouter], // plugin — switched off 2026-08-13 - // ['/wallet', walletRouter], // plugin — switched off 2026-08-13 ['/system-monitor', systemMonitorRouter], ['/activity', activityRouter], ['/dock', dockRouter], diff --git a/src/servers/permissions/registry.test.ts b/src/servers/permissions/registry.test.ts index 1d9a44c1..cc78e969 100644 --- a/src/servers/permissions/registry.test.ts +++ b/src/servers/permissions/registry.test.ts @@ -202,7 +202,9 @@ describe('self-service routes', () => { }); test('permissions without the field are unaffected', () => { - expect(isSelfServiceRoute(PERMISSION_BY_KEY.get('wallet')!, 'PUT', '/api/wallet')).toBe(false); + // Was `wallet` until 2026-08-19, when it became `plugins/wallet/`. Any permission with no + // selfService field will do — this pins the absence, not the key. + expect(isSelfServiceRoute(PERMISSION_BY_KEY.get('terminal')!, 'PUT', '/api/terminal')).toBe(false); }); }); @@ -242,9 +244,11 @@ describe('kinds', () => { test('admin permissions are never grantable', () => { const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key)); - // `headscale` was here until 2026-08-15, when it left with offscale. A plugin's permissions are - // registered at install from its manifest, so they are not in this compile-time list by design. - for (const key of ['user-admin', 'server-admin', 'wallet']) { + // `headscale` was here until 2026-08-15 when it left with offscale, and `wallet` until 2026-08-19. + // A plugin's permissions are registered at install from its manifest, so they are not in this + // compile-time list by design — and a plugin's are ALWAYS `app`, which is exactly why the wallet + // becoming one had to be a deliberate decision rather than a side effect. + for (const key of ['user-admin', 'server-admin']) { expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('admin'); expect(grantable.has(key)).toBe(false); } diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 5f62d114..5aa0319f 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -381,15 +381,6 @@ const CORE_REGISTRY: Permission[] = [ // `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares // its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The // platform no longer knows this permission exists, which is the entire point. - { - key: 'wallet', - label: 'Wallet', - description: "The server owner's bitcoin", - kind: 'admin', - // api: ['/wallet'], // plugin — switched off 2026-08-13 - api: [], - routes: ['/wallet'], - }, ]; // ── Derived lookups ─────────────────────────────────────────────────────────────────────────────── diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index 322c9f8f..07fb2cad 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -212,6 +212,28 @@ const BUNDLED: CatalogueEntry[] = [ // shows rather than the daemon behind it. See its PLUGIN.md for what that cost. has: { api: true, schema: true, sidecar: true, web: true }, }, + { + appName: 'bitcoin-wallet', + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + label: 'Bitcoin Wallet', + summary: 'A self-custodial bitcoin wallet — keys sealed on the server, never on a device', + color: '#f7931a', + icon: 'Bitcoin', + source: 'https://gitea.officer.dev/plugins/bitcoin-wallet.git', + permissions: [ + { + key: 'bitcoin-wallet', + label: 'Bitcoin Wallet', + description: "Bitcoin wallet — each account gets its own, sealed under that account's own passphrase", + }, + ], + // Grantable, unlike the `kind: 'admin'` it had in the core registry — every query is scoped by + // user_id, so granting it gives an account its OWN wallet rather than access to anyone else's. + // See its PLUGIN.md; this was a deliberate decision, not a side effect of extraction. + has: { api: true, schema: true, sidecar: true, web: true }, + }, { appName: 'vault', publisher: 'officerdev', diff --git a/src/servers/sidecar/wallet/aezeed/aez.ts b/src/servers/sidecar/wallet/aezeed/aez.ts deleted file mode 100644 index 85105fda..00000000 --- a/src/servers/sidecar/wallet/aezeed/aez.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { blake2b } from '@noble/hashes/blake2.js'; - -// AEZ v5 — the wide-block cipher LND's aezeed uses to encrypt a cipher seed. -// -// SCOPE, deliberately narrow. This is a DECRYPT-ONLY implementation of the SHORT-INPUT path: -// -// - No encrypt. Officer never mints aezeed seeds — it only imports ones LND/Zeus already created. -// A new Officer wallet gets a BIP39 mnemonic, which is the portable format. -// - No aezCore. An aezeed ciphertext is a FIXED 23 bytes (33 total, minus the 1-byte version, the -// 5-byte salt and the 4-byte CRC), and AEZ dispatches on length: anything under 32 bytes takes the -// `aezTiny` Feistel path. `aezCore` is unreachable here, so shipping it would mean carrying ~120 -// lines of untested cipher in a seed-recovery path. `decrypt` throws rather than guess. -// - No aezPRF. Only reached when the ciphertext is exactly `tau` bytes (empty plaintext). -// -// Every line below is exercised by the tests. That is the point of the narrowness — a subtle bug in -// unreachable crypto is a bug nobody finds until it eats someone's wallet. -// -// Ported from the reference implementation (Yawning/aez, as used by lnd/aezeed). Faithful to the point -// of preserving its integer-truncation quirks; where the original relied on a JavaScript accident, the -// comment says so. - -const BLOCK_SIZE = 16; -const EXTRACTED_KEY_SIZE = 48; -const ZERO = new Uint8Array(BLOCK_SIZE); - -// ── AES round tables ───────────────────────────────────────────────────────────────────────────── -// -// AEZ is built on reduced-round AES (4 and 10 rounds, always with MixColumns and no key schedule — -// the "keys" are just the extracted key material). The four 256-entry T-tables are pure functions of -// the AES S-box: TE0[i] = [2·S[i], S[i], S[i], 3·S[i]] big-endian, and TE1..TE3 are TE0 rotated right -// by 8, 16 and 24 bits. Deriving them costs microseconds once and removes 1024 hand-copied constants -// from a file where a single wrong digit silently produces the wrong wallet. - -// prettier-ignore -const SBOX_HEX = - '637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0' + - 'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275' + - '09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf' + - 'd0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2' + - 'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb' + - 'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08' + - 'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e' + - 'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16'; - -const SBOX = Uint8Array.from(SBOX_HEX.match(/../g)!, (h) => parseInt(h, 16)); - -/** GF(2^8) doubling — the `xtime` of the AES spec. */ -const xtime = (b: number): number => ((b << 1) ^ (b & 0x80 ? 0x1b : 0)) & 0xff; - -function buildTables(): [Uint32Array, Uint32Array, Uint32Array, Uint32Array] { - const te0 = new Uint32Array(256); - for (let i = 0; i < 256; i++) { - const s = SBOX[i]!; - te0[i] = ((xtime(s) << 24) | (s << 16) | (s << 8) | (xtime(s) ^ s)) >>> 0; - } - const ror8 = (x: number): number => ((x >>> 8) | (x << 24)) >>> 0; - const te1 = te0.map(ror8); - const te2 = te1.map(ror8); - const te3 = te2.map(ror8); - return [te0, te1, te2, te3]; -} - -const [TE0, TE1, TE2, TE3] = buildTables(); - -// ── block helpers ──────────────────────────────────────────────────────────────────────────────── - -const block = (size = BLOCK_SIZE): Uint8Array => new Uint8Array(size); - -function xor16(a: Uint8Array, b: Uint8Array, dst: Uint8Array): void { - for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]!; -} - -function xor4x16(a: Uint8Array, b: Uint8Array, c: Uint8Array, d: Uint8Array, dst: Uint8Array): void { - for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]! ^ c[i]! ^ d[i]!; -} - -/** Multiply a block by x in GF(2^128), in place. */ -function doubleBlock(p: Uint8Array): void { - const carry = p[0]!; - for (let i = 0; i < 15; i++) p[i] = ((p[i]! << 1) | (p[i + 1]! >> 7)) & 0xff; - p[15] = ((p[15]! << 1) ^ (carry >> 7 ? 135 : 0)) & 0xff; -} - -/** Multiply a block by the integer `x` in GF(2^128) — repeated doubling, accumulating odd bits. */ -function multBlock(x: number, src: Uint8Array, dst: Uint8Array): void { - const t = block(); - const r = block(); - t.set(src); - let n = x; - while (n !== 0) { - if (n & 1) xor16(r, t, r); - doubleBlock(t); - n >>= 1; - } - dst.set(r); -} - -const readU32BE = (b: Uint8Array, off: number): number => - ((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0; - -function writeU32BE(b: Uint8Array, off: number, v: number): void { - b[off] = (v >>> 24) & 0xff; - b[off + 1] = (v >>> 16) & 0xff; - b[off + 2] = (v >>> 8) & 0xff; - b[off + 3] = v & 0xff; -} - -// ── reduced-round AES ──────────────────────────────────────────────────────────────────────────── - -type AesKeys = { aes4: Uint32Array; aes10: Uint32Array }; - -/** Lay the 48-byte extracted key out into the fixed round-key schedules AEZ prescribes. */ -function aesKeys(extracted: Uint8Array): AesKeys { - const k = new Uint32Array(12); - for (let i = 0; i < 12; i++) k[i] = readU32BE(extracted, 4 * i); - - const aes10 = new Uint32Array(40); - aes10.set(k, 0); - aes10.set(k, 12); - aes10.set(k, 24); - aes10.set(k.subarray(0, 4), 36); - - const aes4 = new Uint32Array(16); - aes4.set(k.subarray(4, 8), 0); - aes4.set(k.subarray(0, 4), 4); - aes4.set(k.subarray(8, 12), 8); - - return { aes4, aes10 }; -} - -/** AES rounds with MixColumns applied on every round, including the last (as AEZ specifies). */ -function rounds(keys: Uint32Array, count: number, b: Uint8Array): void { - let s0 = readU32BE(b, 0); - let s1 = readU32BE(b, 4); - let s2 = readU32BE(b, 8); - let s3 = readU32BE(b, 12); - - for (let r = 0; r < count; r++) { - const o = r * 4; - const t0 = - TE0[(s0 >>> 24) & 0xff]! ^ TE1[(s1 >>> 16) & 0xff]! ^ TE2[(s2 >>> 8) & 0xff]! ^ TE3[s3 & 0xff]! ^ keys[o]!; - const t1 = - TE0[(s1 >>> 24) & 0xff]! ^ TE1[(s2 >>> 16) & 0xff]! ^ TE2[(s3 >>> 8) & 0xff]! ^ TE3[s0 & 0xff]! ^ keys[o + 1]!; - const t2 = - TE0[(s2 >>> 24) & 0xff]! ^ TE1[(s3 >>> 16) & 0xff]! ^ TE2[(s0 >>> 8) & 0xff]! ^ TE3[s1 & 0xff]! ^ keys[o + 2]!; - const t3 = - TE0[(s3 >>> 24) & 0xff]! ^ TE1[(s0 >>> 16) & 0xff]! ^ TE2[(s1 >>> 8) & 0xff]! ^ TE3[s2 & 0xff]! ^ keys[o + 3]!; - s0 = t0 >>> 0; - s1 = t1 >>> 0; - s2 = t2 >>> 0; - s3 = t3 >>> 0; - } - - writeU32BE(b, 0, s0); - writeU32BE(b, 4, s1); - writeU32BE(b, 8, s2); - writeU32BE(b, 12, s3); -} - -function aes4(k: AesKeys, j: Uint8Array, i: Uint8Array, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void { - xor4x16(j, i, l, src, dst); - rounds(k.aes4, 4, dst); -} - -function aes10(k: AesKeys, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void { - xor16(src, l, dst); - rounds(k.aes10, 10, dst); -} - -// ── AEZ state ──────────────────────────────────────────────────────────────────────────────────── - -type AezState = { I: Uint8Array[]; J: Uint8Array[]; L: Uint8Array[]; aes: AesKeys }; - -/** AEZ tolerates any key length by hashing it to 48 bytes; a 48-byte key is used verbatim. */ -function extractKey(key: Uint8Array): Uint8Array { - return key.length === EXTRACTED_KEY_SIZE ? key : blake2b(key, { dkLen: EXTRACTED_KEY_SIZE }); -} - -function init(key: Uint8Array): AezState { - const ext = extractKey(key); - const I = [block(), block()]; - const J = [block(), block(), block()]; - const L = Array.from({ length: 8 }, () => block()); - - I[0]!.set(ext.subarray(0, 16)); - multBlock(2, I[0]!, I[1]!); - - J[0]!.set(ext.subarray(16, 32)); - multBlock(2, J[0]!, J[1]!); - multBlock(2, J[1]!, J[2]!); - - // L[0] is deliberately left as the zero block — the reference never assigns it. - L[1]!.set(ext.subarray(32, 48)); - multBlock(2, L[1]!, L[2]!); - xor16(L[2]!, L[1]!, L[3]!); - multBlock(2, L[2]!, L[4]!); - xor16(L[4]!, L[1]!, L[5]!); - multBlock(2, L[3]!, L[6]!); - xor16(L[6]!, L[1]!, L[7]!); - - return { I, J, L, aes: aesKeys(ext) }; -} - -/** The AXU hash binding the tag length, the nonce and each associated-data string into `delta`. */ -function aezHash(st: AezState, nonce: Uint8Array | null, ad: Uint8Array[], tauBits: number): Uint8Array { - const buf = block(); - const sum = block(); - const I = block(); - const J = block(); - - writeU32BE(buf, 12, tauBits >>> 0); - xor16(st.J[0]!, st.J[1]!, J); - aes4(st.aes, J, st.I[1]!, st.L[1]!, buf, sum); - - const nonceEmpty = !nonce || nonce.length === 0; - let n = nonce ?? new Uint8Array(0); - let nBytes = n.length; - I.set(st.I[1]!); - for (let i = 1; nBytes >= BLOCK_SIZE; i++, nBytes -= BLOCK_SIZE) { - aes4(st.aes, st.J[2]!, I, st.L[i % 8]!, n.subarray(0, BLOCK_SIZE), buf); - xor16(sum, buf, sum); - n = n.subarray(BLOCK_SIZE); - if (i % 8 === 0) doubleBlock(I); - } - if (nBytes > 0 || nonceEmpty) { - buf.fill(0); - if (!nonceEmpty) buf.set(n.subarray(0, nBytes)); - buf[nBytes] = 0x80; - aes4(st.aes, st.J[2]!, st.I[0]!, st.L[0]!, buf, buf); - xor16(sum, buf, sum); - } - - ad.forEach((entry, k) => { - let p = entry ?? new Uint8Array(0); - const adEmpty = p.length === 0; - let bytes = p.length; - I.set(st.I[1]!); - multBlock(5 + k, st.J[0]!, J); - for (let i = 1; bytes >= BLOCK_SIZE; i++, bytes -= BLOCK_SIZE) { - aes4(st.aes, J, I, st.L[i % 8]!, p.subarray(0, BLOCK_SIZE), buf); - xor16(sum, buf, sum); - p = p.subarray(BLOCK_SIZE); - if (i % 8 === 0) doubleBlock(I); - } - if (bytes > 0 || adEmpty) { - buf.fill(0); - if (!adEmpty) buf.set(p.subarray(0, bytes)); - buf[bytes] = 0x80; - aes4(st.aes, J, st.I[0]!, st.L[0]!, buf, buf); - xor16(sum, buf, sum); - } - }); - - return sum; -} - -/** - * The short-input (< 32 byte) balanced Feistel network. `d` is 0 to encipher, 1 to decipher — the only - * difference is which end of the round counter it starts from. - */ -function aezTiny(st: AezState, delta: Uint8Array, input: Uint8Array, d: 0 | 1, dst: Uint8Array): void { - const inBytes = input.length; - const buf = block(2 * BLOCK_SIZE); - const L = block(); - const R = block(); - const tmp = block(); - let mask = 0x00; - let pad = 0x80; - - // Integer halves. The reference indexes with `inBytes/2` and leans on truncation; spelling the two - // halves out makes the odd-length case readable instead of accidental. - const half = inBytes >> 1; - const halfUp = (inBytes + 1) >> 1; - - let i = 7; - let roundCount: number; - if (inBytes === 1) roundCount = 24; - else if (inBytes === 2) roundCount = 16; - else if (inBytes < 16) roundCount = 10; - else { - i = 6; - roundCount = 8; - } - - L.set(input.subarray(0, halfUp)); - R.set(input.subarray(half, half + halfUp)); - - if (inBytes & 1) { - // Odd length: the two halves overlap by a nibble, so shift R left by 4 bits and switch to - // nibble-granular padding. - for (let k = 0; k < half; k++) R[k] = ((R[k]! << 4) | (R[k + 1]! >> 4)) & 0xff; - R[half] = (R[half]! << 4) & 0xff; - pad = 0x08; - mask = 0xf0; - } - - let j: number; - let step: number; - if (d !== 0) { - if (inBytes < 16) { - buf.set(input.subarray(0, BLOCK_SIZE)); - buf[0] = buf[0]! | 0x80; - xor16(delta, buf, buf); - aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp); - L[0] = L[0]! ^ (tmp[0]! & 0x80); - } - j = roundCount - 1; - step = -1; - } else { - j = 0; - step = 1; - } - - for (let k = 0; k < roundCount / 2; k++, j += step * 2) { - buf.fill(0, 0, BLOCK_SIZE); - buf.set(R.subarray(0, halfUp)); - buf[half] = (buf[half]! & mask) | pad; - xor16(buf, delta, buf); - buf[15] = buf[15]! ^ (j & 0xff); - aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp); - xor16(L, tmp, L); - - buf.fill(0, 0, BLOCK_SIZE); - buf.set(L.subarray(0, halfUp)); - buf[half] = (buf[half]! & mask) | pad; - xor16(buf, delta, buf); - buf[15] = buf[15]! ^ ((j + step) & 0xff); - aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp); - xor16(R, tmp, R); - } - - buf.set(R.subarray(0, half), 0); - buf.set(L.subarray(0, halfUp), half); - if (inBytes & 1) { - for (let k = inBytes - 1; k > half; k--) buf[k] = ((buf[k]! >> 4) | (buf[k - 1]! << 4)) & 0xff; - buf[half] = (L[0]! >> 4) | (R[half]! & 0xf0); - } - - dst.set(buf.subarray(0, inBytes)); - - if (inBytes < 16 && d === 0) { - buf.fill(0, inBytes, BLOCK_SIZE); - buf[0] = buf[0]! | 0x80; - xor16(delta, buf, buf); - aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp); - dst[0] = dst[0]! ^ (tmp[0]! & 0x80); - } -} - -/** - * AEZ decrypt-and-verify. Returns the plaintext, or `null` when the `tau`-byte authenticator does not - * check out — which is the signal that the key (and therefore the seed passphrase) is wrong. - * - * Throws for inputs outside the aezeed envelope rather than silently taking an untested path; see the - * scope note at the top of this file. - */ -export function aezDecrypt( - key: Uint8Array, - nonce: Uint8Array | null, - ad: Uint8Array[], - tau: number, - ciphertext: Uint8Array, -): Uint8Array | null { - if (ciphertext.length >= 32 || ciphertext.length <= tau) { - throw new Error(`aez: unsupported ciphertext length ${ciphertext.length} (decrypt is short-input only)`); - } - - const st = init(key); - const delta = aezHash(st, nonce, ad, tau * 8); - const x = new Uint8Array(ciphertext.length); - aezTiny(st, delta, ciphertext, 1, x); - - // Constant-time-ish: fold every authenticator byte before deciding, so the comparison does not - // short-circuit on the first mismatch. - let sum = 0; - for (let i = 0; i < tau; i++) sum |= x[ciphertext.length - tau + i]!; - if (sum !== 0) return null; - - return x.slice(0, ciphertext.length - tau); -} diff --git a/src/servers/sidecar/wallet/aezeed/aezeed.test.ts b/src/servers/sidecar/wallet/aezeed/aezeed.test.ts deleted file mode 100644 index e27976a7..00000000 --- a/src/servers/sidecar/wallet/aezeed/aezeed.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { generateMnemonic } from '@scure/bip39'; -import { wordlist } from '@scure/bip39/wordlists/english'; -import { - AezeedError, - birthdayToDate, - decipherMnemonic, - isAezeedPhrase, - mnemonicToCipherSeedBytes, -} from './cipher-seed'; - -// Fixtures were produced by the `aezeed` npm package (an INDEPENDENT implementation, different author -// and codebase, itself matching lnd/aezeed). Deterministic entropy and salt make them reproducible, so -// agreement here is genuine cross-implementation evidence rather than this port agreeing with itself. -// -// entropy 000102...0f, salt 0102030405, internalVersion 0, birthday 5593 -const ENTROPY_HEX = '000102030405060708090a0b0c0d0e0f'; -const DEFAULT_PW = - 'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen'; -const CUSTOM_PW = - 'above shop hair museum laugh diamond win skin habit inspire hero box category crime boss black nest useless dog alcohol doctor radar urban split'; -const CUSTOM_PASSPHRASE = 'correct horse battery staple'; -// entropy ffee...00, salt a1b2c3d4e5, internalVersion 1, birthday 0 -const BIRTHDAY_ZERO = - 'abstract three say vacant about cable tent neck other jeans certain accident arrest market couch front inflict laptop hole marble exact okay owner number'; - -const split = (phrase: string): string[] => phrase.split(' '); -const hex = (b: Uint8Array): string => Buffer.from(b).toString('hex'); - -describe('isAezeedPhrase', () => { - test('accepts aezeed cipher seeds', () => { - expect(isAezeedPhrase(split(DEFAULT_PW))).toBe(true); - expect(isAezeedPhrase(split(CUSTOM_PW))).toBe(true); - expect(isAezeedPhrase(split(BIRTHDAY_ZERO))).toBe(true); - }); - - test('is passphrase-independent — structure only, no scrypt', () => { - // Both fixtures hold the SAME entropy under different passphrases. Detection must not care. - expect(isAezeedPhrase(split(DEFAULT_PW))).toBe(isAezeedPhrase(split(CUSTOM_PW))); - }); - - test('rejects genuine BIP39 mnemonics', () => { - // The whole point: a 24-word BIP39 phrase is also 33 bytes, so only the CRC separates them. - for (let i = 0; i < 25; i++) { - expect(isAezeedPhrase(split(generateMnemonic(wordlist, 256)))).toBe(false); - } - }); - - test('rejects wrong lengths and non-wordlist words', () => { - expect(isAezeedPhrase(split(DEFAULT_PW).slice(0, 12))).toBe(false); - expect(isAezeedPhrase([...split(DEFAULT_PW).slice(0, 23), 'notaword'])).toBe(false); - expect(isAezeedPhrase([])).toBe(false); - }); - - test('rejects a single transposed word', () => { - const words = split(DEFAULT_PW); - const swapped = [...words]; - swapped[3] = words[4]!; - swapped[4] = words[3]!; - expect(isAezeedPhrase(swapped)).toBe(false); - }); -}); - -describe('decipherMnemonic', () => { - test('recovers entropy under the default passphrase', async () => { - const seed = await decipherMnemonic(split(DEFAULT_PW)); - expect(hex(seed.entropy)).toBe(ENTROPY_HEX); - expect(seed.entropy).toHaveLength(16); - expect(seed.internalVersion).toBe(0); - expect(seed.birthday).toBe(5593); - }); - - test('recovers the same entropy under a custom passphrase', async () => { - const seed = await decipherMnemonic(split(CUSTOM_PW), CUSTOM_PASSPHRASE); - expect(hex(seed.entropy)).toBe(ENTROPY_HEX); - }); - - test('decodes internalVersion 1 and a zero birthday', async () => { - const seed = await decipherMnemonic(split(BIRTHDAY_ZERO)); - expect(hex(seed.entropy)).toBe('ffeeddccbbaa99887766554433221100'); - expect(seed.internalVersion).toBe(1); - expect(seed.birthday).toBe(0); - }); - - test('a wrong passphrase fails the AEZ tag rather than returning junk', async () => { - // This is the property that makes the whole port safe: a bad key CANNOT yield plausible-looking - // entropy, so we can never silently derive the wrong wallet. - const err = await decipherMnemonic(split(CUSTOM_PW), 'not the passphrase').catch((e) => e); - expect(err).toBeInstanceOf(AezeedError); - expect(err.code).toBe('BAD_PASSPHRASE'); - }); - - test('the default passphrase is not silently accepted for a custom-passphrase seed', async () => { - const err = await decipherMnemonic(split(CUSTOM_PW)).catch((e) => e); - expect(err.code).toBe('BAD_PASSPHRASE'); - }); - - test('a corrupted checksum is caught before scrypt', async () => { - const bytes = mnemonicToCipherSeedBytes(split(DEFAULT_PW))!; - bytes[32] = bytes[32]! ^ 0xff; - const { decipherCipherSeed } = await import('./cipher-seed'); - const err = await decipherCipherSeed(bytes, 'aezeed').catch((e) => e); - expect(err.code).toBe('BAD_CHECKSUM'); - }); - - test('rejects a non-aezeed phrase', async () => { - const err = await decipherMnemonic(split(generateMnemonic(wordlist, 256))).catch((e) => e); - expect(err).toBeInstanceOf(AezeedError); - }); -}); - -describe('framing', () => { - test('24 words pack into exactly 33 bytes', () => { - const bytes = mnemonicToCipherSeedBytes(split(DEFAULT_PW)); - expect(bytes).toHaveLength(33); - expect(bytes![0]).toBe(0); // aezeed version - }); - - test('birthday maps to a real date', () => { - // 5593 days after the genesis block. - expect(birthdayToDate(5593).toISOString().slice(0, 10)).toBe('2024-04-27'); - expect(birthdayToDate(0).toISOString().slice(0, 10)).toBe('2009-01-03'); - }); -}); diff --git a/src/servers/sidecar/wallet/aezeed/cipher-seed.ts b/src/servers/sidecar/wallet/aezeed/cipher-seed.ts deleted file mode 100644 index ccca58a3..00000000 --- a/src/servers/sidecar/wallet/aezeed/cipher-seed.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { scrypt as scryptCb } from 'node:crypto'; -import { promisify } from 'node:util'; -import { wordlist } from '@scure/bip39/wordlists/english'; -import { aezDecrypt } from './aez'; - -// LND's aezeed cipher seed — the format Zeus's embedded LND node hands out as a "backup phrase". -// -// It is NOT BIP39, and that is the entire problem this module exists to solve. An aezeed phrase is 24 -// words drawn from the very same BIP39 English wordlist, so it is visually indistinguishable from a -// BIP39 mnemonic, but the bits underneath are a different structure entirely: -// -// byte 0 version (0) -// bytes 1..24 ciphertext (19-byte plaintext + 4-byte AEZ authenticator) -// bytes 24..29 salt (scrypt salt for the passphrase) -// bytes 29..33 CRC32-Castagnoli over bytes 0..29 -// ───── -// 33 bytes = 24 words x 11 bits -// -// A BIP39 24-word phrase is also 33 bytes, but its last 8 bits are a SHA-256 checksum. So an aezeed -// phrase fails BIP39 validation every single time, with an error that reads exactly like "you mistyped -// a word" — which is unfixable advice, because nothing is mistyped. -// -// The deciphered plaintext is: -// -// byte 0 internal version -// bytes 1..3 birthday (days since the Bitcoin genesis block, big-endian uint16) -// bytes 3..19 entropy (16 bytes) -// -// That 16-byte entropy is used DIRECTLY as the BIP32 master seed — LND does not run it through BIP39's -// PBKDF2. This is why an aezeed cannot be converted into an equivalent BIP39 phrase: doing so would -// change the derived keys and produce an empty wallet at different addresses. - -const scrypt = promisify(scryptCb) as ( - password: string | Buffer, - salt: Buffer, - keylen: number, - options: { N: number; r: number; p: number; maxmem: number }, -) => Promise; - -export const CIPHER_SEED_VERSION = 0; -export const ENCIPHERED_SIZE = 33; -export const ENTROPY_SIZE = 16; -const PLAINTEXT_SIZE = 19; -const SALT_SIZE = 5; -const CHECKSUM_SIZE = 4; -const CIPHERTEXT_EXPANSION = 4; -const SALT_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE - SALT_SIZE; // 24 -const CHECKSUM_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE; // 29 - -/** - * LND's default when the user declines to set a seed passphrase. Not a secret — it is a literal - * constant in lnd/aezeed, and every Zeus wallet created without an explicit passphrase uses it. - */ -export const DEFAULT_AEZEED_PASSPHRASE = 'aezeed'; - -// scrypt parameters for version 0, straight from lnd/aezeed/params.go. -const SCRYPT_N = 32768; -const SCRYPT_R = 8; -const SCRYPT_P = 1; -const KEY_LEN = 32; -// 128 * N * r is ~33.5 MB here, over Node's 32 MB default, which fails with a bare MEMORY_LIMIT_EXCEEDED. -const SCRYPT_MAXMEM = 64 * 1024 * 1024; - -/** LND counts a seed's birthday in days from this instant. */ -const BITCOIN_GENESIS = Date.UTC(2009, 0, 3, 18, 15, 5); -const ONE_DAY_MS = 86_400_000; - -// ── CRC32-Castagnoli ───────────────────────────────────────────────────────────────────────────── -// -// Castagnoli (0x82F63B78), NOT the far more common IEEE polynomial. Getting this wrong makes every -// valid seed look corrupt. - -const CRC32C_TABLE = Uint32Array.from({ length: 256 }, (_, n) => { - let c = n; - for (let k = 0; k < 8; k++) c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1; - return c >>> 0; -}); - -function crc32c(bytes: Uint8Array): number { - let c = 0xffffffff; - for (const b of bytes) c = CRC32C_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8); - return (c ^ 0xffffffff) >>> 0; -} - -// ── mnemonic <-> bytes ─────────────────────────────────────────────────────────────────────────── - -const WORD_INDEX = new Map(wordlist.map((w, i) => [w, i])); - -/** - * Pack 24 words into the 33 raw bytes, 11 bits each. Returns null when any word is outside the - * wordlist or the count is wrong — i.e. when this cannot be an aezeed phrase at all. - */ -export function mnemonicToCipherSeedBytes(words: string[]): Uint8Array | null { - if (words.length !== 24) return null; - - const bits: number[] = []; - for (const word of words) { - const index = WORD_INDEX.get(word); - if (index === undefined) return null; - for (let b = 10; b >= 0; b--) bits.push((index >> b) & 1); - } - - const bytes = new Uint8Array(ENCIPHERED_SIZE); - for (let i = 0; i < ENCIPHERED_SIZE; i++) { - let v = 0; - for (let b = 0; b < 8; b++) v = (v << 1) | bits[i * 8 + b]!; - bytes[i] = v; - } - return bytes; -} - -/** - * Whether a phrase is an aezeed cipher seed. Cheap, offline, and decisive: it checks the version byte - * and the CRC32C, so it never touches scrypt or the passphrase. Safe to run on every phrase the owner - * types, and safe to ship to a client — it reveals nothing and needs no secret. - */ -export function isAezeedPhrase(words: string[]): boolean { - const bytes = mnemonicToCipherSeedBytes(words); - if (!bytes) return false; - if (bytes[0] !== CIPHER_SEED_VERSION) return false; - return crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) === readU32BE(bytes, CHECKSUM_OFFSET); -} - -const readU32BE = (b: Uint8Array, off: number): number => - ((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0; - -// ── decipher ───────────────────────────────────────────────────────────────────────────────────── - -export type CipherSeed = { - internalVersion: number; - /** Days since the Bitcoin genesis block. */ - birthday: number; - /** The BIP32 master seed. 16 bytes. Caller is responsible for zeroing it. */ - entropy: Uint8Array; -}; - -export class AezeedError extends Error { - constructor( - message: string, - readonly code: 'BAD_VERSION' | 'BAD_CHECKSUM' | 'BAD_PASSPHRASE' | 'BAD_LENGTH', - ) { - super(message); - this.name = 'AezeedError'; - } -} - -/** Convert a birthday (days since genesis) to a date — useful as a rescan floor. */ -export function birthdayToDate(birthday: number): Date { - return new Date(BITCOIN_GENESIS + birthday * ONE_DAY_MS); -} - -/** - * Decipher a 33-byte aezeed. Throws AezeedError with a code the caller can turn into a useful message — - * in particular BAD_PASSPHRASE, which is the difference between "your words are wrong" (they are not) - * and "this seed has a passphrase set and we need it". - */ -export async function decipherCipherSeed(bytes: Uint8Array, passphrase: string): Promise { - if (bytes.length !== ENCIPHERED_SIZE) { - throw new AezeedError(`aezeed must be ${ENCIPHERED_SIZE} bytes, got ${bytes.length}`, 'BAD_LENGTH'); - } - if (bytes[0] !== CIPHER_SEED_VERSION) { - throw new AezeedError(`unsupported aezeed version ${bytes[0]}`, 'BAD_VERSION'); - } - if (crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) !== readU32BE(bytes, CHECKSUM_OFFSET)) { - throw new AezeedError('aezeed checksum mismatch', 'BAD_CHECKSUM'); - } - - const salt = Buffer.from(bytes.subarray(SALT_OFFSET, SALT_OFFSET + SALT_SIZE)); - const key = await scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, { - N: SCRYPT_N, - r: SCRYPT_R, - p: SCRYPT_P, - maxmem: SCRYPT_MAXMEM, - }); - - // Associated data is the version byte followed by the salt, so a seed cannot be replayed under a - // different version or salt. - const ad = new Uint8Array(1 + SALT_SIZE); - ad[0] = CIPHER_SEED_VERSION; - ad.set(salt, 1); - - try { - const plaintext = aezDecrypt(key, null, [ad], CIPHERTEXT_EXPANSION, bytes.subarray(1, SALT_OFFSET)); - if (!plaintext || plaintext.length !== PLAINTEXT_SIZE) { - throw new AezeedError('incorrect seed passphrase', 'BAD_PASSPHRASE'); - } - return { - internalVersion: plaintext[0]!, - birthday: (plaintext[1]! << 8) | plaintext[2]!, - entropy: plaintext.slice(3, 3 + ENTROPY_SIZE), - }; - } finally { - key.fill(0); - } -} - -/** Convenience: words straight to a deciphered seed. */ -export async function decipherMnemonic(words: string[], passphrase = DEFAULT_AEZEED_PASSPHRASE): Promise { - const bytes = mnemonicToCipherSeedBytes(words); - if (!bytes) throw new AezeedError('not a 24-word phrase from the BIP39 wordlist', 'BAD_LENGTH'); - return decipherCipherSeed(bytes, passphrase); -} diff --git a/src/servers/sidecar/wallet/backends/base.ts b/src/servers/sidecar/wallet/backends/base.ts deleted file mode 100644 index 1f5bd79f..00000000 --- a/src/servers/sidecar/wallet/backends/base.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - BackendError, - type Balances, - type BackendKind, - type Capability, - type Channel, - type CreateInvoiceRequest, - type DecodedInvoice, - type FeeEstimates, - type Invoice, - type KeysendRequest, - type NewAddressRequest, - type NodeInfo, - type OnchainTx, - type PayInvoiceRequest, - type Payment, - type Peer, - type SendCoinsRequest, - type SendCoinsResult, - type SignMessageResult, - type Utxo, - type VerifyMessageResult, - type WalletBackend, - type AddressType, -} from '../types'; - -/** - * Shared backend base. Declares its capability set once and turns every unimplemented method into a - * clean 501 — the counterpart to Zeus's `BackendUtils.call()` returning `false` for a missing method - * (utils/BackendUtils.ts:56-58), but loud instead of silent. - */ -export abstract class BaseBackend implements WalletBackend { - abstract readonly kind: BackendKind; - protected abstract readonly capabilities: ReadonlySet; - - supports(cap: Capability): boolean { - return this.capabilities.has(cap); - } - - protected notSupported(op: string): never { - throw new BackendError(`${this.kind} does not support ${op}`, 501, 'NOT_SUPPORTED'); - } - - abstract getInfo(): Promise; - abstract getBalances(): Promise; - - getTransactions(_opts?: { limit?: number }): Promise { - return this.notSupported('on-chain transaction history'); - } - getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { - return this.notSupported('on-chain receive'); - } - getUtxos(): Promise { - return this.notSupported('coin control'); - } - estimateFees(): Promise { - return this.notSupported('fee estimation'); - } - sendCoins(_req: SendCoinsRequest): Promise { - return this.notSupported('on-chain send'); - } - - getInvoices(_opts?: { limit?: number }): Promise { - return this.notSupported('invoice listing'); - } - createInvoice(_req: CreateInvoiceRequest): Promise { - return this.notSupported('lightning receive'); - } - lookupInvoice(_paymentHash: string): Promise { - return this.notSupported('invoice lookup'); - } - decodeInvoice(_bolt11: string): Promise { - return this.notSupported('invoice decoding'); - } - getPayments(_opts?: { limit?: number }): Promise { - return this.notSupported('payment history'); - } - payInvoice(_req: PayInvoiceRequest): Promise { - return this.notSupported('lightning send'); - } - sendKeysend(_req: KeysendRequest): Promise { - return this.notSupported('keysend'); - } - - getChannels(): Promise { - return this.notSupported('channels'); - } - getPeers(): Promise { - return this.notSupported('peers'); - } - - signMessage(_message: string): Promise { - return this.notSupported('message signing'); - } - verifyMessage(_message: string, _signature: string): Promise { - return this.notSupported('message verification'); - } -} - -// ── HTTP ───────────────────────────────────────────────────────────────────────────────────────── - -// Zeus reaches its REST backends through `react-native-blob-util` with a `trusty: true` flag, which -// disables TLS verification wholesale so a node with a self-signed cert is reachable (backends/LND.ts). -// Bun's fetch takes an equivalent per-request `tls` option, so no extra HTTP client is needed. -// -// Verification is only relaxed when the owner explicitly opts in for a specific node, and it is a real -// trade: it buys reachability for a node with a self-signed cert at the cost of MITM protection on that -// connection. Acceptable over loopback or a tailnet, not over the open internet — which is why it is a -// per-wallet flag the owner sets deliberately, never a default. -type BunFetchInit = RequestInit & { tls?: { rejectUnauthorized?: boolean } }; - -export type HttpOptions = { - method?: string; - path: string; - base: string; - headers?: Record; - body?: unknown; - query?: Record; - allowSelfSigned?: boolean; - timeoutMs?: number; - /** Return the raw Response instead of parsed JSON. */ - raw?: boolean; -}; - -const DEFAULT_TIMEOUT_MS = 30_000; - -/** The single door every REST backend goes through. Normalizes errors into BackendError. */ -export async function httpJson(opts: HttpOptions): Promise { - const base = opts.base.replace(/\/+$/, ''); - const url = new URL(`${base}${opts.path.startsWith('/') ? opts.path : `/${opts.path}`}`); - for (const [k, v] of Object.entries(opts.query ?? {})) { - if (v !== undefined) url.searchParams.set(k, String(v)); - } - - const headers: Record = { Accept: 'application/json', ...opts.headers }; - let body: string | undefined; - if (opts.body !== undefined) { - body = JSON.stringify(opts.body); - headers['Content-Type'] ??= 'application/json'; - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); - - const init: BunFetchInit = { - method: opts.method ?? 'GET', - headers, - body, - signal: controller.signal, - }; - if (opts.allowSelfSigned) init.tls = { rejectUnauthorized: false }; - - let res: Response; - try { - res = await fetch(url, init); - } catch (err) { - const msg = controller.signal.aborted ? `timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` : String(err); - throw new BackendError(`request to ${url.host} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE'); - } finally { - clearTimeout(timer); - } - - if (opts.raw) return res as unknown as T; - - const text = await res.text(); - let parsed: unknown = null; - if (text) { - try { - parsed = JSON.parse(text); - } catch { - if (!res.ok) throw new BackendError(`upstream ${res.status}: ${text.slice(0, 200)}`, res.status); - throw new BackendError(`upstream returned non-JSON: ${text.slice(0, 200)}`, 502); - } - } - - if (!res.ok) { - // LND puts {error, code}; CLN puts {message}; LNDHub puts {error, message}. Try all three. - const p = parsed as { error?: string; message?: string; code?: number } | null; - const msg = p?.error ?? p?.message ?? `upstream returned ${res.status}`; - throw new BackendError(msg, res.status, p?.code != null ? String(p.code) : undefined); - } - - return parsed as T; -} - -/** Hex → base64. LND's REST API takes bytes fields base64-encoded; callers hold hex. */ -export function hexToBase64(hex: string): string { - return Buffer.from(hex, 'hex').toString('base64'); -} - -/** Base64 → hex, for reading LND's bytes fields back out. */ -export function base64ToHex(b64: string): string { - return Buffer.from(b64, 'base64').toString('hex'); -} diff --git a/src/servers/sidecar/wallet/backends/clnrest.ts b/src/servers/sidecar/wallet/backends/clnrest.ts deleted file mode 100644 index 8eabd636..00000000 --- a/src/servers/sidecar/wallet/backends/clnrest.ts +++ /dev/null @@ -1,1012 +0,0 @@ -// Core Lightning REST backend — ported from Zeus's `backends/CLNRest.ts` together with -// `backends/CoreLightningRequestHandler.ts`, which holds all of the actual shaping logic (balances, -// channels, peers, chain transactions) that CLNRest.ts delegates to. -// -// CLNRest is POST-only: every command is `POST /v1/` with a JSON body (`{}` when the command -// takes no arguments) and a `Rune: ` header. There are no GET endpoints and no path parameters. -// -// UNITS. CLN reports millisatoshis two ways depending on the endpoint and on the node's deprecated-API -// setting: a bare JSON number (`1234`) or a suffixed string (`"1234msat"`). Zeus assumes the number form -// everywhere — `opArray[i].amount_msat / 1000` in CoreLightningRequestHandler — which yields NaN against a -// node still emitting the string form. `parseMsat` below accepts both. -// -// INTENTIONAL BEHAVIOURAL DIFFERENCES FROM ZEUS -// -// 1. No Tor, no in-flight call de-duplication, no `trusty:`-style blanket TLS bypass — see the same three -// notes in `lnd.ts`. Self-signed certs are accepted only when the owner opts in per node. -// 2. `payLightningInvoice` no longer sends `amount_msat: Number(data.amt && data.amt * 1000)`. When `amt` -// is undefined that expression is `0`, and CLN rejects an explicit zero amount on an invoice that -// already carries one. We omit the field unless an amount was actually requested. -// 3. `feeLimitMsat` is honoured via `pay`'s `maxfee`. Zeus declares `supportsCustomFeeLimit = () => false` -// for CLN and only ever sends `maxfeepercent`; both forms are supported here. -// 4. `signMessage` returns the **zbase** signature. CLN's signmessage returns `signature` (hex), -// `recid` and `zbase`, but its own `checkmessage` only accepts zbase — returning the hex form would -// produce a signature this backend could not verify. -// 5. Channel states are reported rather than dropped. Zeus's `listPeerChannels` filters ONCHAIN, CLOSED -// and CHANNELD_AWAITING_LOCKIN out of the list entirely because its flat model has nowhere to put -// them; `Channel.status` does, so awaiting-lockin surfaces as 'pending-open'. -// 6. Chain-transaction confirmations are `tip - height + 1`. Zeus computes `tip - height`, which reports -// a transaction in the tip block as having zero confirmations. -// 7. `getInvoices` recovers each invoice's creation time by reading the 35-bit timestamp straight out of -// the BOLT11 data part. Zeus does the same thing via `Bolt11Utils.decode`; listinvoices genuinely has -// no creation-time column, only `paid_at` and `expires_at`. -// 8. `customPreimages` is NOT declared, matching Zeus's `supportsCustomPreimages = () => false`, even -// though CLN's `invoice` command does accept a `preimage` argument. -// 9. `accounts` is NOT declared — `supportsAccounts = () => false` in Zeus. CLN has no account concept. -// 10. `NewAddressRequest.peek` is ignored: `newaddr` always derives a fresh address. -// 11. `SendCoinsRequest.rbf` and `.label` are ignored — `withdraw` exposes neither. -// 12. BOLT12 offer management (`listoffers`, `offer`, `disableoffer`, `fetchinvoice`, `invoicerequest`, -// `sendinvoice`) and the askrene/getroutes/sendpay routing surface are not ported: `WalletBackend` -// has no methods for them. The `offers` capability is still declared, since the node supports them. -// -// DEPENDENCIES ON CLN PLUGINS. `getInvoices`, `getPayments` and `getTransactions` go through the **sql** -// plugin, exactly as Zeus does; `getTransactions` additionally reads the **bookkeeper**'s -// `bkpr_accountevents` table. Both plugins ship with CLN and are enabled by default, but a node that has -// disabled either will fail those three calls and no others. - -import * as bitcoin from 'bitcoinjs-lib'; -import { - BackendError, - type AddressType, - type Balances, - type BackendKind, - type BitcoinNetwork, - type Capability, - type Channel, - type CreateInvoiceRequest, - type DecodedInvoice, - type FeeEstimates, - type Invoice, - type InvoiceState, - type KeysendRequest, - type NewAddressRequest, - type NodeInfo, - type OnchainTx, - type PayInvoiceRequest, - type Payment, - type PaymentStatus, - type Peer, - type SendCoinsRequest, - type SendCoinsResult, - type SignMessageResult, - type Utxo, - type VerifyMessageResult, -} from '../types'; -import { BaseBackend, httpJson } from './base'; - -// ── upstream wire types ────────────────────────────────────────────────────────────────────────── -// -// Field names are lightningd's own, verbatim. `Msat` marks every field that may arrive as either a number -// or a `"…msat"` string — see the UNITS note above. - -type Msat = number | string; - -type ClnGetInfo = { - id?: string; - alias?: string; - version?: string; - blockheight?: number; - network?: string; - fees_collected_msat?: Msat; - warning_bitcoind_sync?: string; - warning_lightningd_sync?: string; -}; - -type ClnFundsOutput = { - txid?: string; - output?: number; - amount_msat?: Msat; - scriptpubkey?: string; - address?: string; - status?: string; - blockheight?: number; - reserved?: boolean; -}; - -type ClnFundsChannel = { - peer_id?: string; - our_amount_msat?: Msat; - amount_msat?: Msat; - connected?: boolean; - state?: string; -}; - -type ClnListFunds = { outputs?: ClnFundsOutput[]; channels?: ClnFundsChannel[] }; - -type ClnPeerChannel = { - peer_id?: string; - peer_connected?: boolean; - state?: string; - channel_id?: string; - short_channel_id?: string; - funding_txid?: string; - funding_outnum?: number; - total_msat?: Msat; - to_us_msat?: Msat; - private?: boolean; -}; - -type ClnPeer = { id?: string; connected?: boolean; num_channels?: number; netaddr?: string[] }; - -type ClnNode = { nodeid?: string; alias?: string }; - -type ClnTxOutput = { index?: number; amount_msat?: Msat; scriptPubKey?: string }; - -type ClnTransaction = { - hash?: string; - rawtx?: string; - blockheight?: number; - txindex?: number; - outputs?: ClnTxOutput[]; -}; - -/** The sql plugin answers with positional rows in the order the SELECT names its columns. */ -type ClnSqlResult = { rows?: SqlCell[][] }; - -type ClnFeerateEstimate = { blockcount?: number; feerate?: number; smoothed_feerate?: number }; - -type ClnFeerates = { - perkb?: { - min_acceptable?: number; - max_acceptable?: number; - floor?: number; - opening?: number; - estimates?: ClnFeerateEstimate[]; - }; -}; - -type ClnNewAddr = { bech32?: string; p2tr?: string }; - -type ClnWithdraw = { tx?: string; txid?: string; psbt?: string }; - -type ClnInvoiceResponse = { - bolt11?: string; - payment_hash?: string; - payment_secret?: string; - expires_at?: number; - created_index?: number; -}; - -type ClnListInvoice = { - label?: string; - bolt11?: string; - bolt12?: string; - payment_hash?: string; - amount_msat?: Msat; - status?: string; - amount_received_msat?: Msat; - paid_at?: number; - payment_preimage?: string; - description?: string; - expires_at?: number; -}; - -type ClnDecoded = { - type?: string; - valid?: boolean; - created_at?: number; - expiry?: number; - payee?: string; - amount_msat?: Msat; - payment_hash?: string; - description?: string; - min_final_cltv_expiry?: number; - features?: string; - routes?: unknown[]; -}; - -type ClnPayResult = { - payment_preimage?: string; - payment_hash?: string; - created_at?: number; - parts?: number; - amount_msat?: Msat; - amount_sent_msat?: Msat; - status?: string; - destination?: string; -}; - -type ClnSignMessage = { signature?: string; recid?: number; zbase?: string }; - -type ClnCheckMessage = { verified?: boolean; pubkey?: string }; - -// ── helpers ────────────────────────────────────────────────────────────────────────────────────── - -/** Accepts `1234`, `"1234"` and `"1234msat"`; returns a decimal msat string, never a number. */ -function parseMsat(value: Msat | null | undefined): string | null { - if (value == null) return null; - if (typeof value === 'number') return Number.isFinite(value) ? Math.round(value).toString() : null; - const match = /^(\d+)\s*(msat)?$/i.exec(value.trim()); - return match?.[1] ?? null; -} - -/** msat (either form) → whole satoshis, truncated the way CLN's own sat-denominated views truncate. */ -function msatToSats(value: Msat | null | undefined): number { - const msat = parseMsat(value); - return msat == null ? 0 : Math.floor(Number(msat) / 1000); -} - -function toNumber(value: unknown): number { - const n = Number(value); - return Number.isFinite(n) ? n : 0; -} - -/** - * Caller-supplied msat decimal string → the JSON number CLN's API expects. - * - * CLN takes amounts as JSON numbers, so a msat value above 2^53 would silently lose precision on the - * way out — a request to pay 1 msat more than MAX_SAFE_INTEGER would be rounded rather than rejected. - * The amounts involved are absurd (2^53 msat ≈ 90,000 BTC) but the failure is silent and the input is - * caller-controlled, so it is checked rather than assumed. Mirrors toMsatNumber() in nwc.ts. - */ -function msatToApiNumber(msat: string, field: string): number { - let value: bigint; - try { - value = BigInt(msat); - } catch { - throw new BackendError(`${field} must be a decimal msat string, got "${msat}"`, 400, 'BAD_AMOUNT'); - } - if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new BackendError(`${field}=${msat}msat is out of range`, 400, 'BAD_AMOUNT'); - } - return Number(value); -} - -function toNetwork(network: string | undefined): BitcoinNetwork { - switch (network) { - case 'testnet': - case 'testnet4': - return 'testnet'; - case 'signet': - return 'signet'; - case 'regtest': - return 'regtest'; - default: - return 'bitcoin'; - } -} - -const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; - -/** - * BOLT11 puts a 35-bit creation timestamp in the first seven characters of the data part, immediately - * after the separator (the LAST '1' in the string, per the spec — the human-readable part may contain - * one). Reading it directly avoids pulling in a full bech32 decoder just for `listinvoices`, which has no - * creation-time column at all. - */ -function bolt11Timestamp(bolt11: string | null | undefined): number | null { - if (!bolt11) return null; - const invoice = bolt11.toLowerCase(); - const separator = invoice.lastIndexOf('1'); - if (separator < 0) return null; - const data = invoice.slice(separator + 1); - if (data.length < 7) return null; - - let timestamp = 0; - for (let i = 0; i < 7; i++) { - const char = data[i]; - // indexOf('') would answer 0, so the empty case has to be rejected before the lookup. - if (!char) return null; - const value = BECH32_CHARSET.indexOf(char); - if (value < 0) return null; - timestamp = timestamp * 32 + value; - } - return timestamp; -} - -// ── sql plugin cells ───────────────────────────────────────────────────────────────────────────── -// -// The sql plugin returns positional rows, so every read is an index into an array of loosely-typed cells. -// These three readers keep that in one place instead of scattering casts through the mappers. - -type SqlCell = string | number | boolean | null | undefined; - -function cellStr(cell: SqlCell): string | null { - return typeof cell === 'string' && cell.length > 0 ? cell : null; -} - -function cellNum(cell: SqlCell): number | null { - if (cell == null || typeof cell === 'boolean') return null; - const n = Number(cell); - return Number.isFinite(n) ? n : null; -} - -function cellMsat(cell: SqlCell): Msat | undefined { - return typeof cell === 'number' || typeof cell === 'string' ? cell : undefined; -} - -/** - * The sql plugin takes a whole SQL string, not bound parameters, so `limit` is the one caller-supplied - * value that reaches it. Force it to a bounded integer before it is ever interpolated. - */ -function clampLimit(limit: number | undefined, fallback: number): number { - const n = Math.floor(Number(limit)); - if (!Number.isFinite(n) || n <= 0) return fallback; - return Math.min(n, 5000); -} - -/** - * Address type by prefix. `listfunds` reports the address but not its kind, and the prefix is an exact - * discriminator across every network CLN runs on, so no lookup is needed. - */ -function addressType(address: string | undefined): AddressType | null { - if (!address) return null; - const addr = address.toLowerCase(); - const bech32Prefix = /^(bc1|tb1|bcrt1)/.exec(addr); - if (bech32Prefix) return addr[bech32Prefix[0].length] === 'p' ? 'p2tr' : 'p2wpkh'; - if (addr.startsWith('3') || addr.startsWith('2')) return 'p2sh-p2wpkh'; - if (addr.startsWith('1') || addr.startsWith('m') || addr.startsWith('n')) return 'p2pkh'; - return null; -} - -/** bitcoinjs has no signet parameter set; signet reuses testnet's version bytes and 'tb' hrp. */ -function bitcoinjsNetwork(network: BitcoinNetwork): bitcoin.Network { - switch (network) { - case 'regtest': - return bitcoin.networks.regtest; - case 'testnet': - case 'signet': - return bitcoin.networks.testnet; - default: - return bitcoin.networks.bitcoin; - } -} - -function toPaymentStatus(status: string | undefined): PaymentStatus { - switch (status) { - case 'complete': - return 'succeeded'; - case 'failed': - return 'failed'; - default: - return 'pending'; - } -} - -/** - * CLN's channel state machine → the coarse status in `Channel`. Zeus never models this; it filters the - * terminal states out and treats everything else as open. - */ -function toChannelStatus(state: string | undefined): string { - switch (state) { - case 'CHANNELD_NORMAL': - return 'open'; - case 'OPENINGD': - case 'CHANNELD_AWAITING_LOCKIN': - case 'DUALOPEND_OPEN_INIT': - case 'DUALOPEND_AWAITING_LOCKIN': - case 'DUALOPEND_OPEN_COMMITTED': - case 'DUALOPEND_OPEN_COMMIT_READY': - return 'pending-open'; - case 'CHANNELD_SHUTTING_DOWN': - case 'CLOSINGD_SIGEXCHANGE': - case 'CLOSINGD_COMPLETE': - return 'pending-close'; - case 'AWAITING_UNILATERAL': - case 'FUNDING_SPEND_SEEN': - case 'ONCHAIN': - return 'force-closing'; - case 'CLOSED': - return 'closed'; - default: - return state ?? 'open'; - } -} - -/** Column indices for the two SQL projections below — positional rows are otherwise unreadable. */ -const INVOICE_COL = { - label: 0, - bolt11: 1, - bolt12: 2, - paymentHash: 3, - amountMsat: 4, - status: 5, - amountReceivedMsat: 6, - paidAt: 7, - preimage: 8, - description: 9, - expiresAt: 10, -} as const; - -const PAYMENT_COL = { - paymentHash: 0, - groupId: 1, - status: 2, - destination: 3, - createdAt: 4, - description: 5, - bolt11: 6, - bolt12: 7, - amountSentMsat: 8, - amountMsat: 9, - preimage: 10, -} as const; - -const ACCOUNT_EVENT_COL = { - account: 0, - tag: 1, - outpoint: 2, - creditMsat: 3, - debitMsat: 4, - timestamp: 5, - blockheight: 6, -} as const; - -/** TLV record carrying a free-text note alongside a keysend payment. */ -const KEYSEND_MESSAGE_RECORD = '34349334'; - -export type ClnRestConfig = { - /** Full base URL including scheme and port, e.g. `https://192.168.1.5:3010`. */ - url: string; - /** The rune authorising this connection. Sent as the `Rune` header. */ - rune: string; - allowSelfSigned?: boolean; -}; - -// ── the backend ────────────────────────────────────────────────────────────────────────────────── - -export class ClnRestBackend extends BaseBackend { - readonly kind: BackendKind = 'cln-rest'; - - protected readonly capabilities: ReadonlySet = new Set([ - 'onchainReceive', - 'onchainSend', - 'coinControl', // Zeus: supportsCoinControl / supportsChannelCoinControl - 'sweep', // `withdraw … satoshi: "all"` - 'lightningReceive', - 'lightningSend', - 'keysend', - 'offers', // Zeus: supportsOffers / supportsListingOffers / supportsBolt12Address - 'channels', - 'peers', - 'routing', - 'signMessage', - // Deliberately absent: psbt, bumpFee (supportsBumpFee = false), accounts (supportsAccounts = false), - // customPreimages (supportsCustomPreimages = false). - ]); - - constructor(private readonly config: ClnRestConfig) { - super(); - } - - // ── transport ────────────────────────────────────────────────────────────────────────────────── - - /** Every CLNRest command is a POST with a JSON body; commands without arguments still need `{}`. */ - private call(command: string, body: Record = {}, timeoutMs?: number): Promise { - return httpJson({ - base: this.config.url, - path: `/v1/${command}`, - method: 'POST', - body, - headers: { Rune: this.config.rune }, - allowSelfSigned: this.config.allowSelfSigned, - timeoutMs, - }); - } - - private sql(query: string): Promise { - return this.call('sql', { query }); - } - - /** - * Resolve node aliases in one pass. Zeus issues a `listnodes` per channel and per peer inside - * `Promise.all` and lets a single failure reject the whole list; we de-duplicate by pubkey and swallow - * individual lookups, because a missing alias must not cost you the channel list. - */ - private async resolveAliases(pubkeys: string[]): Promise> { - const unique = [...new Set(pubkeys.filter(Boolean))]; - const entries = await Promise.all( - unique.map(async (id): Promise<[string, string] | null> => { - try { - const res = await this.call<{ nodes?: ClnNode[] }>('listnodes', { id }); - const alias = res.nodes?.[0]?.alias; - return alias ? [id, alias] : null; - } catch { - return null; - } - }), - ); - return new Map(entries.filter((entry): entry is [string, string] => entry !== null)); - } - - // ── node / balances ──────────────────────────────────────────────────────────────────────────── - - override async getInfo(): Promise { - const info = await this.call('getinfo'); - return { - kind: this.kind, - pubkey: info.id ?? null, - alias: info.alias ?? null, - version: info.version ?? null, - network: toNetwork(info.network), - blockHeight: info.blockheight ?? null, - // CLN reports lag by ADDING a warning field; their absence is the only "synced" signal there is. - synced: !info.warning_bitcoind_sync && !info.warning_lightningd_sync, - }; - } - - override async getBalances(): Promise { - // One listfunds call answers both halves — Zeus makes two, one via getBalance and one via - // getOffchainBalance, each re-fetching the identical payload. - const funds = await this.call('listfunds'); - - let onchainConfirmed = 0; - let onchainUnconfirmed = 0; - for (const output of funds.outputs ?? []) { - // 'spent' and 'immature' outputs are also listed; neither is spendable balance. - if (output.status === 'confirmed') onchainConfirmed += msatToSats(output.amount_msat); - else if (output.status === 'unconfirmed') onchainUnconfirmed += msatToSats(output.amount_msat); - } - - let lightningBalance = 0; - let lightningInbound = 0; - for (const channel of funds.channels ?? []) { - // Zeus counts only connected CHANNELD_NORMAL channels towards the spendable balance; funds in - // disconnected or still-opening channels are real but not usable right now. - if (channel.state !== 'CHANNELD_NORMAL' || channel.connected !== true) continue; - const ours = Number(parseMsat(channel.our_amount_msat) ?? '0'); - const total = Number(parseMsat(channel.amount_msat) ?? '0'); - lightningBalance += Math.floor(ours / 1000); - lightningInbound += Math.floor((total - ours) / 1000); - } - - // Core Lightning reserves coins itself; Officer freezing is the route's overlay, so 0 here. - return { onchainConfirmed, onchainUnconfirmed, onchainFrozen: 0, lightningBalance, lightningInbound }; - } - - // ── on-chain ─────────────────────────────────────────────────────────────────────────────────── - - /** - * Port of `CoreLightningRequestHandler.getChainTransactions`. - * - * CLN has no single "wallet transactions" command. `listtransactions` knows every transaction the node - * has seen but not what it MEANT; the bookkeeper's `bkpr_accountevents` knows the meaning (deposit to - * the wallet vs. payment out to an external account) but is keyed by outpoint. So the two are joined on - * txid, and any transaction that is really a channel open or close is excluded — otherwise a channel - * funding would show up as an ordinary withdrawal. - */ - override async getTransactions(opts?: { limit?: number }): Promise { - const limit = clampLimit(opts?.limit, 150); - const query = - 'SELECT account, tag, outpoint, credit_msat, debit_msat, timestamp, blockheight ' + - "FROM bkpr_accountevents WHERE (tag='deposit' OR tag='to_them' OR tag='channel_open' " + - `OR tag='channel_close') ORDER BY timestamp DESC LIMIT ${limit}`; - - // Zeus uses allSettled here so a missing bookkeeper or sql plugin degrades instead of failing; only - // getinfo is load-bearing (it supplies the chain tip and the network for address decoding). - const [eventsResult, txsResult, infoResult] = await Promise.allSettled([ - this.sql(query), - this.call<{ transactions?: ClnTransaction[] }>('listtransactions'), - this.call('getinfo'), - ]); - - if (infoResult.status !== 'fulfilled') return []; - const tip = infoResult.value.blockheight ?? 0; - const network = bitcoinjsNetwork(toNetwork(infoResult.value.network)); - - const rows = eventsResult.status === 'fulfilled' ? (eventsResult.value.rows ?? []) : []; - const transactions = txsResult.status === 'fulfilled' ? (txsResult.value.transactions ?? []) : []; - - const txidOf = (row: SqlCell[]): string | null => { - const outpoint = cellStr(row[ACCOUNT_EVENT_COL.outpoint]); - return outpoint ? (outpoint.split(':')[0] ?? null) : null; - }; - - const deposits = rows.filter((row) => row[ACCOUNT_EVENT_COL.tag] === 'deposit'); - const walletDeposits = deposits.filter((row) => row[ACCOUNT_EVENT_COL.account] === 'wallet'); - const externalDeposits = deposits.filter((row) => row[ACCOUNT_EVENT_COL.account] === 'external'); - - const isChannelChange = (txid: string): boolean => - rows.some( - (row) => - txidOf(row) === txid && - (row[ACCOUNT_EVENT_COL.tag] === 'channel_open' || row[ACCOUNT_EVENT_COL.tag] === 'channel_close'), - ); - - const result: OnchainTx[] = []; - for (const tx of transactions) { - const txid = tx.hash ?? ''; - if (!txid || isChannelChange(txid)) continue; - - // A deposit booked against the 'external' account is money leaving us; against 'wallet', arriving. - const outgoing = externalDeposits.find((row) => txidOf(row) === txid); - const incoming = outgoing ? undefined : walletDeposits.find((row) => txidOf(row) === txid); - const row = outgoing ?? incoming; - if (!row) continue; - - const credit = Number(parseMsat(cellMsat(row[ACCOUNT_EVENT_COL.creditMsat])) ?? '0'); - const amount = Math.floor((outgoing ? -Math.abs(credit) : credit) / 1000); - const height = cellNum(row[ACCOUNT_EVENT_COL.blockheight]) || null; - - const destAddresses: string[] = []; - for (const output of tx.outputs ?? []) { - if (!output.scriptPubKey) continue; - try { - destAddresses.push(bitcoin.address.fromOutputScript(Buffer.from(output.scriptPubKey, 'hex'), network)); - } catch { - // Non-standard or unrecognised script — CLN reports it, we simply have no address for it. - } - } - - result.push({ - txid, - amount, - // bkpr records the fee as its own accountevent tag, not on the deposit row. - feeSats: null, - blockHeight: height, - timestamp: cellNum(row[ACCOUNT_EVENT_COL.timestamp]) || null, - confirmations: height ? Math.max(0, tip - height + 1) : 0, - label: null, - destAddresses, - rawHex: tx.rawtx ?? null, - }); - } - - return result.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); - } - - override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { - const type: AddressType = req?.type ?? 'p2wpkh'; - // CLN dropped p2sh-segwit issuance in v23.05 and has never issued legacy p2pkh - // (Zeus: supportsNestedSegWit = () => false). - const addresstype = type === 'p2wpkh' ? 'bech32' : type === 'p2tr' ? 'p2tr' : null; - if (!addresstype) { - throw new BackendError(`core lightning cannot derive a ${type} address`, 400, 'UNSUPPORTED_ADDRESS_TYPE'); - } - - const res = await this.call('newaddr', { addresstype }); - // newaddr keys the response by the address type it produced, not by a generic `address` field. - const address = addresstype === 'p2tr' ? res.p2tr : res.bech32; - if (!address) throw new BackendError('core lightning returned no address', 502); - return { address, type }; - } - - override async getUtxos(): Promise { - const [funds, info] = await Promise.all([this.call('listfunds'), this.call('getinfo')]); - const tip = info.blockheight ?? 0; - - return (funds.outputs ?? []) - .filter((output) => output.status === 'confirmed' || output.status === 'unconfirmed') - .map((output) => ({ - txid: output.txid ?? '', - vout: output.output ?? 0, - amountSats: msatToSats(output.amount_msat), - address: output.address ?? '', - addressType: addressType(output.address), - confirmations: output.blockheight ? Math.max(0, tip - output.blockheight + 1) : 0, - // CLN's listfunds does not expose the derivation path for its own outputs. - derivationPath: null, - // `reserved` is CLN's exclusion flag: an output held for an in-flight psbt or channel open. - frozen: output.reserved === true, - })); - } - - override async estimateFees(): Promise { - const res = await this.call('feerates', { style: 'perkb' }); - const perkb = res.perkb; - const estimates = perkb?.estimates ?? []; - - // perkb is sat per 1000 vbytes; the interface wants sat/vB, and never below the 1 sat/vB relay floor. - const toVbyte = (perKb: number | undefined): number => Math.max(1, Math.ceil(toNumber(perKb) / 1000)); - - /** Cheapest estimate that still confirms within `blocks`, falling back to the slowest we have. */ - const forTarget = (blocks: number): number | undefined => { - const eligible = estimates.filter((e) => (e.blockcount ?? 0) <= blocks && e.feerate != null); - const best = eligible.length > 0 ? eligible[eligible.length - 1] : estimates[estimates.length - 1]; - return best?.feerate ?? perkb?.opening; - }; - - return { - fastestFee: toVbyte(forTarget(2)), - halfHourFee: toVbyte(forTarget(3)), - hourFee: toVbyte(forTarget(6)), - economyFee: toVbyte(forTarget(144)), - minimumFee: toVbyte(perkb?.floor ?? perkb?.min_acceptable), - }; - } - - override async sendCoins(req: SendCoinsRequest): Promise { - const body: Record = { - destination: req.address, - // CLN takes a feerate with an explicit unit; sat/vB × 1000 is the perkb form. - feerate: `${Math.round(req.satPerVbyte * 1000)}perkb`, - satoshi: req.sendAll ? 'all' : (req.amountSats ?? 0), - }; - // CLN's utxos are plain `txid:vout` strings, unlike LND's structured outpoints. - if (req.outpoints?.length) body.utxos = req.outpoints; - if (req.spendUnconfirmed) body.minconf = 0; - - const res = await this.call('withdraw', body); - if (!res.txid) throw new BackendError('core lightning returned no txid', 502); - // `withdraw` returns the signed transaction but not the fee it chose. - return { txid: res.txid, feeSats: 0, rawHex: res.tx ?? null }; - } - - // ── lightning ────────────────────────────────────────────────────────────────────────────────── - - private toInvoice(inv: ClnListInvoice): Invoice { - const expiresAt = inv.expires_at ?? null; - - let state: InvoiceState; - switch (inv.status) { - case 'paid': - state = 'settled'; - break; - case 'expired': - state = 'expired'; - break; - default: - state = expiresAt != null && expiresAt * 1000 < Date.now() ? 'expired' : 'open'; - } - - const bolt11 = inv.bolt11 ?? ''; - return { - paymentHash: inv.payment_hash ?? '', - // A BOLT12-only invoice has no bolt11; fall back to the offer-issued string so the row is still - // addressable rather than blank. - bolt11: bolt11 || (inv.bolt12 ?? ''), - amountMsat: parseMsat(inv.amount_msat), - amountPaidMsat: parseMsat(inv.amount_received_msat), - memo: inv.description ? inv.description : null, - state, - // See difference 7: listinvoices has no creation-time column. - createdAt: bolt11Timestamp(bolt11) ?? inv.paid_at ?? 0, - expiresAt, - settledAt: inv.paid_at ?? null, - preimage: inv.payment_preimage ?? null, - // CLN does not flag keysend-created invoices, and has no AMP at all (Zeus: supportsAMP = false). - isKeysend: false, - isAmp: false, - }; - } - - override async getInvoices(opts?: { limit?: number }): Promise { - const limit = clampLimit(opts?.limit, 150); - const res = await this.sql( - 'SELECT label, bolt11, bolt12, payment_hash, amount_msat, status, amount_received_msat, ' + - `paid_at, payment_preimage, description, expires_at FROM invoices ORDER BY created_index DESC LIMIT ${limit};`, - ); - - return (res.rows ?? []).map((row) => - this.toInvoice({ - label: cellStr(row[INVOICE_COL.label]) ?? undefined, - bolt11: cellStr(row[INVOICE_COL.bolt11]) ?? undefined, - bolt12: cellStr(row[INVOICE_COL.bolt12]) ?? undefined, - payment_hash: cellStr(row[INVOICE_COL.paymentHash]) ?? undefined, - amount_msat: cellMsat(row[INVOICE_COL.amountMsat]), - status: cellStr(row[INVOICE_COL.status]) ?? undefined, - amount_received_msat: cellMsat(row[INVOICE_COL.amountReceivedMsat]), - paid_at: cellNum(row[INVOICE_COL.paidAt]) ?? undefined, - payment_preimage: cellStr(row[INVOICE_COL.preimage]) ?? undefined, - description: cellStr(row[INVOICE_COL.description]) ?? undefined, - expires_at: cellNum(row[INVOICE_COL.expiresAt]) ?? undefined, - }), - ); - } - - override async createInvoice(req: CreateInvoiceRequest): Promise { - if (req.preimage) this.notSupported('custom preimages'); - if (req.isAmp) this.notSupported('AMP invoices'); - - const res = await this.call('invoice', { - description: req.memo ?? '', - // `label` is CLN's primary key for an invoice and must be unique per node. - label: `officer.${Date.now()}.${Math.floor(Math.random() * 1_000_000)}`, - // The literal string 'any' is how CLN expresses a zero-amount (donation) invoice. - amount_msat: req.amountMsat ? msatToApiNumber(req.amountMsat, 'amountMsat') : 'any', - expiry: req.expirySeconds, - // Zeus hardcodes this to true so an invoice is payable through unannounced channels — without it a - // node whose inbound liquidity is all private is unreachable. - exposeprivatechannels: req.private ?? true, - }); - - const now = Math.floor(Date.now() / 1000); - return { - paymentHash: res.payment_hash ?? '', - bolt11: res.bolt11 ?? '', - amountMsat: req.amountMsat ?? null, - amountPaidMsat: null, - memo: req.memo ?? null, - state: 'open', - createdAt: bolt11Timestamp(res.bolt11) ?? now, - expiresAt: res.expires_at ?? (req.expirySeconds != null ? now + req.expirySeconds : null), - settledAt: null, - preimage: null, - isKeysend: false, - isAmp: false, - }; - } - - override async lookupInvoice(paymentHash: string): Promise { - const res = await this.call<{ invoices?: ClnListInvoice[] }>('listinvoices', { payment_hash: paymentHash }); - const invoice = res.invoices?.[0]; - return invoice ? this.toInvoice(invoice) : null; - } - - override async decodeInvoice(bolt11: string): Promise { - // `decode` handles bolt11 and bolt12 alike but arrives with the offers plugin; `decodepay` is the - // classic bolt11-only command and is always present. Try the general one, fall back to the old one. - let decoded: ClnDecoded; - try { - decoded = await this.call('decode', { string: bolt11 }); - if (decoded.valid === false) throw new BackendError('invoice failed to decode', 400); - } catch (err) { - if (err instanceof BackendError && err.status === 400) throw err; - decoded = await this.call('decodepay', { bolt11 }); - } - - return { - bolt11, - paymentHash: decoded.payment_hash ?? '', - amountMsat: parseMsat(decoded.amount_msat), - description: decoded.description ? decoded.description : null, - destination: decoded.payee ?? '', - timestamp: decoded.created_at ?? 0, - expiry: decoded.expiry ?? 0, - cltvExpiry: decoded.min_final_cltv_expiry ?? null, - routeHints: (decoded.routes ?? []).length > 0, - // CLN hands back a raw hex feature BITMAP where LND names each feature; expand it to the indices of - // the set bits so the shape still matches and nothing is invented. - features: featureBits(decoded.features), - }; - } - - override async getPayments(opts?: { limit?: number }): Promise { - const limit = clampLimit(opts?.limit, 150); - // Grouping by (payment_hash, groupid) collapses the parts of an MPP payment back into one row; the - // per-part rows in `sendpays` would otherwise each look like a separate payment. - const res = await this.sql( - 'select sp.payment_hash, sp.groupid, min(sp.status) as status, min(sp.destination) as destination, ' + - 'min(sp.created_at) as created_at, min(sp.description) as description, min(sp.bolt11) as bolt11, ' + - "min(sp.bolt12) as bolt12, sum(case when sp.status = 'complete' then sp.amount_sent_msat else null end) " + - "as amount_sent_msat, sum(case when sp.status = 'complete' then sp.amount_msat else 0 end) as amount_msat, " + - `max(sp.payment_preimage) as preimage from sendpays sp group by sp.payment_hash, sp.groupid ` + - `order by created_index desc limit ${limit}`, - ); - - return (res.rows ?? []).map((row) => { - const amountMsat = parseMsat(cellMsat(row[PAYMENT_COL.amountMsat])) ?? '0'; - const sentMsat = parseMsat(cellMsat(row[PAYMENT_COL.amountSentMsat])) ?? amountMsat; - // The routing fee is the difference between what left us and what reached the payee. - const fee = BigInt(sentMsat) - BigInt(amountMsat); - - return { - paymentHash: cellStr(row[PAYMENT_COL.paymentHash]) ?? '', - preimage: cellStr(row[PAYMENT_COL.preimage]), - amountMsat, - feeMsat: (fee > 0n ? fee : 0n).toString(), - status: toPaymentStatus(cellStr(row[PAYMENT_COL.status]) ?? undefined), - createdAt: cellNum(row[PAYMENT_COL.createdAt]) ?? 0, - destination: cellStr(row[PAYMENT_COL.destination]), - memo: cellStr(row[PAYMENT_COL.description]), - // sendpays records no failure text; a failed attempt only carries a status. - failureReason: null, - }; - }); - } - - private toPayment(res: ClnPayResult, fallbackDestination: string | null, memo: string | null): Payment { - const amountMsat = parseMsat(res.amount_msat) ?? '0'; - const sentMsat = parseMsat(res.amount_sent_msat) ?? amountMsat; - const fee = BigInt(sentMsat) - BigInt(amountMsat); - - return { - paymentHash: res.payment_hash ?? '', - preimage: res.payment_preimage ?? null, - amountMsat, - feeMsat: (fee > 0n ? fee : 0n).toString(), - status: toPaymentStatus(res.status), - // created_at is a float in CLN (sub-second resolution); the interface is unix seconds. - createdAt: Math.floor(toNumber(res.created_at)), - destination: res.destination ?? fallbackDestination, - memo, - failureReason: null, - }; - } - - override async payInvoice(req: PayInvoiceRequest): Promise { - if (req.feeLimitMsat && req.feeLimitPercent != null) { - throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400); - } - - const timeoutSeconds = req.timeoutSeconds ?? 60; - const body: Record = { bolt11: req.bolt11, retry_for: timeoutSeconds }; - // Only send an amount when one was actually requested — see difference 2. - if (req.amountMsat) body.amount_msat = msatToApiNumber(req.amountMsat, 'amountMsat'); - if (req.feeLimitMsat) body.maxfee = msatToApiNumber(req.feeLimitMsat, 'feeLimitMsat'); - else if (req.feeLimitPercent != null) body.maxfeepercent = req.feeLimitPercent; - - // `retry_for` bounds CLN's own retrying; give the socket a little more than that. - const res = await this.call('pay', body, (timeoutSeconds + 10) * 1000); - return this.toPayment(res, null, null); - } - - override async sendKeysend(req: KeysendRequest): Promise { - const timeoutSeconds = 60; - const body: Record = { - destination: req.destination, - amount_msat: msatToApiNumber(req.amountMsat, 'amountMsat'), - retry_for: timeoutSeconds, - }; - if (req.feeLimitMsat) body.maxfee = msatToApiNumber(req.feeLimitMsat, 'feeLimitMsat'); - // `extratlvs` values are hex-encoded by CLN's convention, not base64 as in LND's custom records. - if (req.message) { - body.extratlvs = { [KEYSEND_MESSAGE_RECORD]: Buffer.from(req.message, 'utf8').toString('hex') }; - } - - const res = await this.call('keysend', body, (timeoutSeconds + 10) * 1000); - return this.toPayment(res, req.destination, req.message ?? null); - } - - // ── channels / peers ─────────────────────────────────────────────────────────────────────────── - - override async getChannels(): Promise { - const res = await this.call<{ channels?: ClnPeerChannel[] }>('listpeerchannels'); - const channels = res.channels ?? []; - const aliases = await this.resolveAliases(channels.map((chan) => chan.peer_id ?? '')); - - return channels - .filter((chan) => chan.state !== 'CLOSED' && chan.state !== 'ONCHAIN') - .map((chan) => { - const total = Number(parseMsat(chan.total_msat) ?? '0'); - const ours = Number(parseMsat(chan.to_us_msat) ?? '0'); - return { - // short_channel_id is the network-visible id but is absent until the funding confirms. - channelId: chan.short_channel_id ?? chan.channel_id ?? '', - channelPoint: chan.funding_txid ? `${chan.funding_txid}:${chan.funding_outnum ?? 0}` : null, - remotePubkey: chan.peer_id ?? '', - remoteAlias: aliases.get(chan.peer_id ?? '') ?? null, - capacitySats: Math.floor(total / 1000), - localBalanceSats: Math.floor(ours / 1000), - remoteBalanceSats: Math.floor((total - ours) / 1000), - active: chan.peer_connected === true && chan.state === 'CHANNELD_NORMAL', - // CLN's `private` is the inverse of the gossip announcement; unset means announced. - private: chan.private === true, - status: toChannelStatus(chan.state), - }; - }); - } - - override async getPeers(): Promise { - const res = await this.call<{ peers?: ClnPeer[] }>('listpeers'); - const peers = res.peers ?? []; - const aliases = await this.resolveAliases(peers.map((peer) => peer.id ?? '')); - - return peers.map((peer) => ({ - pubkey: peer.id ?? '', - address: peer.netaddr?.[0] ?? '', - alias: aliases.get(peer.id ?? '') ?? null, - // listpeers does not report which side dialled; LND's `inbound` has no CLN equivalent. - inbound: false, - })); - } - - // ── signing ──────────────────────────────────────────────────────────────────────────────────── - - override async signMessage(message: string): Promise { - const res = await this.call('signmessage', { message }); - // zbase, not the hex `signature` — see difference 4. - if (!res.zbase) throw new BackendError('core lightning returned no signature', 502); - return { signature: res.zbase }; - } - - override async verifyMessage(message: string, signature: string): Promise { - const res = await this.call('checkmessage', { message, zbase: signature }); - return { valid: res.verified === true, pubkey: res.pubkey ?? null }; - } -} - -/** Hex feature bitmap → the indices of its set bits, least-significant bit first. */ -function featureBits(hex: string | undefined): string[] { - if (!hex) return []; - const bytes = Buffer.from(hex, 'hex'); - const bits: string[] = []; - // The bitmap is big-endian: the LAST byte holds bits 0-7. - for (let i = 0; i < bytes.length; i++) { - const byte = bytes[bytes.length - 1 - i] ?? 0; - for (let bit = 0; bit < 8; bit++) { - if (byte & (1 << bit)) bits.push(String(i * 8 + bit)); - } - } - return bits; -} diff --git a/src/servers/sidecar/wallet/backends/lnd.ts b/src/servers/sidecar/wallet/backends/lnd.ts deleted file mode 100644 index 5ce0483e..00000000 --- a/src/servers/sidecar/wallet/backends/lnd.ts +++ /dev/null @@ -1,860 +0,0 @@ -// LND REST backend — ported from Zeus's `backends/LND.ts` (+ `utils/LndUtils.ts` for the address-type -// mapping). Zeus's class is a thin, untyped RPC shim: every method returns the upstream JSON verbatim and -// the view layer does the unit conversion. Here the conversion happens once, at the edge, against the -// `WalletBackend` contract in `../types.ts`. -// -// INTENTIONAL BEHAVIOURAL DIFFERENCES FROM ZEUS -// -// 1. No Tor. Zeus routes through `doTorRequest` when `enableTor` is set. A sidecar on the owner's own box -// reaches its node directly; if Tor is ever needed it belongs in the dispatcher, not per backend. -// 2. No in-flight call de-duplication. Zeus keys a module-level `calls` Map by url+body so a second -// identical request joins the first, and needs `clearCachedCalls()` to escape a poisoned entry. That -// cache is a mobile-battery optimisation and a footgun (a failed call can be re-awaited); dropped. -// 3. No `forcedTimeout` race. Zeus races `payLightningInvoice` against a promise that RESOLVES with a fake -// `{payment_error: 'timed out'}` after timeout+1s — so a slow payment silently reports failure while -// still in flight. We pass a real deadline to `httpJson` and let it abort, surfacing a 502. -// 4. TLS verification is opt-in per node (`allowSelfSigned`), not the blanket `trusty: !certVerification`. -// 5. Version gating is OPTIMISTIC until `getInfo()` runs. Zeus reads `nodeInfoStore.version`, which is -// empty before the first getinfo, so every version-gated `supportsX()` answers false. Here the gated -// capabilities (coinControl, accounts, offers) start ON and are switched OFF once getInfo reports an -// older node — a pre-getInfo call to an old node fails upstream instead of 501-ing on a modern one. -// 6. `offers` is declared for v0.18+. Zeus hardcodes `supportsOffers = () => false` for LND. -// 7. Websocket-only endpoints are not ported: `openChannelStream`, `initChanAcceptor`, -// `subscribeCustomMessages`, `subscribeInvoice`, `subscribeTransactions`. `WalletBackend` has no -// streaming surface. -// 8. `sendCoins` reports `feeSats: 0`. LND's SendCoinsResponse carries only `txid`; the fee is only -// observable afterwards through GetTransactions, and we do not block the send on a second round trip. -// 9. `SendCoinsRequest.rbf` is ignored — lnrpc.SendCoins has no replaceability flag to forward. -// 10. `Utxo.derivationPath` is always null. Zeus reads paths from a separate ListAddresses call (v0.18+); -// joining that per-UTXO here would double the request count for a field nothing currently consumes. -// 11. `Peer.alias` is always null, matching Zeus's `listPeers` — /v1/peers has no alias, and resolving one -// costs a graph lookup per peer. Channels DO get an alias, via ListChannels' own `peer_alias_lookup`. -// 12. `InvoiceState.expired` is derived (unsettled + past `creation_date + expiry`); lnrpc's Invoice.state -// enum has no EXPIRED member. -// 13. `estimateFees` uses walletrpc EstimateFee. Zeus does not ask LND for on-chain fees at all — it reads -// mempool.space in the app layer. - -import { createHash, randomBytes } from 'node:crypto'; -import { - BackendError, - type AddressType, - type Balances, - type BackendKind, - type BitcoinNetwork, - type Capability, - type Channel, - type CreateInvoiceRequest, - type DecodedInvoice, - type FeeEstimates, - type Invoice, - type InvoiceState, - type KeysendRequest, - type NewAddressRequest, - type NodeInfo, - type OnchainTx, - type PayInvoiceRequest, - type Payment, - type PaymentStatus, - type Peer, - type SendCoinsRequest, - type SendCoinsResult, - type SignMessageResult, - type Utxo, - type VerifyMessageResult, -} from '../types'; -import { BaseBackend, base64ToHex, hexToBase64, httpJson } from './base'; - -// ── upstream wire types ────────────────────────────────────────────────────────────────────────── -// -// Field names are lnrpc's own, verbatim. grpc-gateway renders every int64 as a decimal STRING and every -// `bytes` field as base64 — both quirks are load-bearing below, so the types record them exactly. - -type LndChain = { chain?: string; network?: string }; - -type LndGetInfo = { - version?: string; - identity_pubkey?: string; - alias?: string; - block_height?: number; - synced_to_chain?: boolean; - testnet?: boolean; - chains?: LndChain[]; -}; - -type LndBlockchainBalance = { - total_balance?: string; - confirmed_balance?: string; - unconfirmed_balance?: string; -}; - -/** lnrpc.Amount — the same sat/msat pair LND uses everywhere it reports a channel-side balance. */ -type LndAmount = { sat?: string; msat?: string }; - -type LndChannelBalance = { - balance?: string; - local_balance?: LndAmount; - remote_balance?: LndAmount; -}; - -type LndTransaction = { - tx_hash?: string; - amount?: string; - num_confirmations?: number; - block_height?: number; - time_stamp?: string; - total_fees?: string; - dest_addresses?: string[]; - raw_tx_hex?: string; - label?: string; -}; - -type LndUtxo = { - address_type?: string; - address?: string; - amount_sat?: string; - confirmations?: string; - outpoint?: { txid_str?: string; output_index?: number }; -}; - -type LndEstimateFee = { sat_per_kw?: string; min_relay_fee_sat_per_kw?: string }; - -type LndInvoice = { - memo?: string; - r_preimage?: string; - r_hash?: string; - value?: string; - value_msat?: string; - creation_date?: string; - settle_date?: string; - payment_request?: string; - expiry?: string; - amt_paid_msat?: string; - state?: string; - is_keysend?: boolean; - is_amp?: boolean; -}; - -type LndAddInvoiceResponse = { r_hash?: string; payment_request?: string; payment_addr?: string }; - -type LndFeature = { name?: string; is_required?: boolean; is_known?: boolean }; - -type LndPayReq = { - destination?: string; - payment_hash?: string; - num_msat?: string; - num_satoshis?: string; - timestamp?: string; - expiry?: string; - description?: string; - cltv_expiry?: string; - route_hints?: unknown[]; - features?: Record; -}; - -type LndHop = { pub_key?: string }; -type LndHtlcAttempt = { route?: { hops?: LndHop[] } }; - -type LndPayment = { - payment_hash?: string; - payment_preimage?: string; - value_msat?: string; - fee_msat?: string; - status?: string; - creation_date?: string; - creation_time_ns?: string; - failure_reason?: string; - htlcs?: LndHtlcAttempt[]; -}; - -/** One newline-delimited frame of the SendPaymentV2 stream. */ -type LndRouterFrame = { result?: LndPayment; error?: { message?: string; code?: number } }; - -type LndChannel = { - chan_id?: string; - channel_point?: string; - remote_pubkey?: string; - peer_alias?: string; - capacity?: string; - local_balance?: string; - remote_balance?: string; - active?: boolean; - private?: boolean; -}; - -type LndPendingChannel = { - remote_node_pub?: string; - channel_point?: string; - capacity?: string; - local_balance?: string; - remote_balance?: string; - private?: boolean; -}; - -type LndPendingChannels = { - pending_open_channels?: { channel?: LndPendingChannel }[]; - pending_force_closing_channels?: { channel?: LndPendingChannel }[]; - waiting_close_channels?: { channel?: LndPendingChannel }[]; -}; - -type LndPeer = { pub_key?: string; address?: string; inbound?: boolean }; - -// ── constants ──────────────────────────────────────────────────────────────────────────────────── - -/** Zeus's LndUtils.LNRPC_NEW_ADDRESS_TYPE_NAMES, verbatim. See that file for why each row exists. */ -const LNRPC_NEW_ADDRESS_TYPE_NAMES: Record = { - '0': 'WITNESS_PUBKEY_HASH', - '1': 'NESTED_PUBKEY_HASH', - '2': 'UNUSED_WITNESS_PUBKEY_HASH', - '3': 'UNUSED_NESTED_PUBKEY_HASH', - '4': 'TAPROOT_PUBKEY', - '5': 'UNUSED_TAPROOT_PUBKEY', - NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH', - HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH', -}; - -/** - * Zeus's `toLnrpcAddressType`. The numeric strings matter: LND REST's grpc-gateway silently treats an - * unrecognised `type` as WITNESS_PUBKEY_HASH, so sending '1' quietly yields a native segwit address. - */ -function toLnrpcAddressType(value: string | number | undefined | null): string | undefined { - if (value == null) return undefined; - const key = String(value); - return LNRPC_NEW_ADDRESS_TYPE_NAMES[key] ?? key; -} - -/** Our AddressType → the lnrpc enum index, in normal and `peek` (UNUSED_*, non-advancing) form. */ -const ADDRESS_TYPE_INDEX: Record = { - p2wpkh: { fresh: '0', peek: '2' }, - 'p2sh-p2wpkh': { fresh: '1', peek: '3' }, - p2tr: { fresh: '4', peek: '5' }, - // lnrpc.NewAddress has no legacy p2pkh member — LND has never handed out a base58 receive address. - p2pkh: null, -}; - -/** walletrpc's AddressType enum (ListUnspent) → ours. The HYBRID_ variant is still a p2sh-p2wpkh. */ -const WALLETRPC_ADDRESS_TYPE: Record = { - WITNESS_PUBKEY_HASH: 'p2wpkh', - NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh', - HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh', - TAPROOT_PUBKEY: 'p2tr', -}; - -/** BOLT spec TLV records for keysend: the preimage the receiver settles with, and the free-text message. */ -const KEYSEND_PREIMAGE_RECORD = '5482373484'; -const KEYSEND_MESSAGE_RECORD = '34349334'; - -/** A 32-byte all-zero preimage is LND's "not settled yet" placeholder, not a real preimage. */ -const ZERO_PREIMAGE_HEX = '0'.repeat(64); - -// ── helpers ────────────────────────────────────────────────────────────────────────────────────── - -/** int64-as-string → number. Used only for sat-denominated fields, which cannot overflow a double. */ -function toSats(value: string | number | undefined | null): number { - if (value == null || value === '') return 0; - const n = Number(value); - return Number.isFinite(n) ? n : 0; -} - -/** int64-as-string → msat decimal string. Never widened to a number: 2.1e18 does not fit a double. */ -function toMsat(value: string | number | undefined | null): string { - if (value == null || value === '') return '0'; - return typeof value === 'number' ? Math.round(value).toString() : value; -} - -/** `0.18.3-beta commit=v0.18.3-beta` → [0, 18, 3]. Zeus does the same in VersionUtils. */ -function parseVersion(version: string | null): [number, number, number] | null { - if (!version) return null; - const m = /(\d+)\.(\d+)\.(\d+)/.exec(version); - if (!m) return null; - return [Number(m[1]), Number(m[2]), Number(m[3])]; -} - -/** Unknown version counts as "new enough" — see difference 5 in the header. */ -function atLeast(version: string | null, min: string): boolean { - const have = parseVersion(version); - const want = parseVersion(min); - if (!have || !want) return true; - for (let i = 0; i < 3; i++) { - const h = have[i] ?? 0; - const w = want[i] ?? 0; - if (h !== w) return h > w; - } - return true; -} - -function toNetwork(info: LndGetInfo): BitcoinNetwork { - const raw = info.chains?.[0]?.network ?? (info.testnet ? 'testnet' : 'mainnet'); - switch (raw) { - case 'testnet': - case 'testnet3': - case 'testnet4': - return 'testnet'; - case 'signet': - return 'signet'; - // simnet is btcd's private-chain mode; it behaves like regtest for everything we surface. - case 'regtest': - case 'simnet': - return 'regtest'; - default: - return 'bitcoin'; - } -} - -function toPaymentStatus(status: string | undefined): PaymentStatus { - switch (status) { - case 'SUCCEEDED': - return 'succeeded'; - case 'FAILED': - return 'failed'; - // UNKNOWN / IN_FLIGHT / INITIATED all mean "not resolved yet". - default: - return 'pending'; - } -} - -export type LndConfig = { - /** Full base URL including scheme and port, e.g. `https://192.168.1.5:8080`. */ - url: string; - /** Admin (or narrower) macaroon, hex. Sent as `Grpc-Metadata-macaroon`. */ - macaroonHex: string; - allowSelfSigned?: boolean; -}; - -// ── the backend ────────────────────────────────────────────────────────────────────────────────── - -export class LndBackend extends BaseBackend { - readonly kind: BackendKind = 'lnd'; - - // Mutated in place by `applyVersionGates`; `capabilities` is the same object seen read-only. - private readonly caps = new Set([ - 'onchainReceive', - 'onchainSend', - 'coinControl', - 'psbt', - 'bumpFee', - 'sweep', - 'accounts', - 'lightningReceive', - 'lightningSend', - 'keysend', - 'customPreimages', - 'offers', - 'channels', - 'peers', - 'routing', - 'signMessage', - ]); - protected readonly capabilities: ReadonlySet = this.caps; - - constructor(private readonly config: LndConfig) { - super(); - } - - // ── transport ────────────────────────────────────────────────────────────────────────────────── - - private get(path: string, query?: Record): Promise { - return httpJson({ - base: this.config.url, - path, - query, - method: 'GET', - headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex }, - allowSelfSigned: this.config.allowSelfSigned, - }); - } - - private post(path: string, body: unknown, timeoutMs?: number): Promise { - return httpJson({ - base: this.config.url, - path, - method: 'POST', - body: body ?? {}, - headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex }, - allowSelfSigned: this.config.allowSelfSigned, - timeoutMs, - }); - } - - /** - * SendPaymentV2 (`/v2/router/send`) is a SERVER-STREAMING rpc. grpc-gateway renders it as - * newline-delimited JSON — one `{"result": …}` frame per payment state change — which `JSON.parse` on - * the whole body cannot read. Zeus handles this in `restReq` by splitting on '\n' and taking - * `split[length - 2]` (the body has a trailing newline); we take the last non-empty line, which is the - * same frame without depending on the trailing newline being there. - * - * We also set `no_inflight_updates`, so in practice there is exactly one frame: the terminal one. - */ - private async routerSend(body: Record, timeoutMs: number): Promise { - const res = await httpJson({ - base: this.config.url, - path: '/v2/router/send', - method: 'POST', - body, - headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex }, - allowSelfSigned: this.config.allowSelfSigned, - timeoutMs, - raw: true, - }); - - const text = await res.text(); - const lines = text.split('\n').filter((line) => line.trim().length > 0); - const last = lines[lines.length - 1]; - - if (!last) { - throw new BackendError(`router/send returned an empty body (HTTP ${res.status})`, res.ok ? 502 : res.status); - } - - let frame: LndRouterFrame; - try { - frame = JSON.parse(last) as LndRouterFrame; - } catch { - throw new BackendError(`router/send returned non-JSON: ${last.slice(0, 200)}`, res.ok ? 502 : res.status); - } - - // A stream error arrives as a frame, not as a non-2xx status, so check it before `res.ok`. - if (frame.error) { - throw new BackendError( - frame.error.message ?? 'payment failed', - res.ok ? 502 : res.status, - frame.error.code != null ? String(frame.error.code) : undefined, - ); - } - if (!res.ok || !frame.result) { - throw new BackendError(`router/send failed: ${last.slice(0, 200)}`, res.ok ? 502 : res.status); - } - return frame.result; - } - - /** Zeus's version predicates, evaluated once per getInfo instead of once per `supportsX()` call. */ - private applyVersionGates(version: string | null): void { - const gate = (cap: Capability, min: string) => { - if (atLeast(version, min)) this.caps.add(cap); - else this.caps.delete(cap); - }; - gate('coinControl', '0.12.0'); // Zeus: supportsCoinControl - gate('accounts', '0.13.0'); // Zeus: supportsAccounts - gate('offers', '0.18.0'); // Zeus hardcodes false; see difference 6 - } - - // ── node / balances ──────────────────────────────────────────────────────────────────────────── - - override async getInfo(): Promise { - const info = await this.get('/v1/getinfo'); - const version = info.version ?? null; - this.applyVersionGates(version); - return { - kind: this.kind, - pubkey: info.identity_pubkey ?? null, - alias: info.alias ?? null, - version, - network: toNetwork(info), - blockHeight: info.block_height ?? null, - synced: info.synced_to_chain === true, - }; - } - - override async getBalances(): Promise { - const [chain, channels] = await Promise.all([ - this.get('/v1/balance/blockchain'), - this.get('/v1/balance/channels'), - ]); - return { - onchainConfirmed: toSats(chain.confirmed_balance), - onchainFrozen: 0, - onchainUnconfirmed: toSats(chain.unconfirmed_balance), - // `balance` is the deprecated flat field; the nested Amount is authoritative on v0.11+. - lightningBalance: toSats(channels.local_balance?.sat ?? channels.balance), - lightningInbound: toSats(channels.remote_balance?.sat), - }; - } - - // ── on-chain ─────────────────────────────────────────────────────────────────────────────────── - - override async getTransactions(opts?: { limit?: number }): Promise { - // `end_height=-1` is LND's "up to and including unconfirmed" sentinel — without it the mempool is - // excluded and a just-broadcast send is invisible. - const res = await this.get<{ transactions?: LndTransaction[] }>('/v1/transactions', { - end_height: -1, - max_transactions: opts?.limit ?? 500, - }); - return (res.transactions ?? []).map((tx) => ({ - txid: tx.tx_hash ?? '', - amount: toSats(tx.amount), - feeSats: tx.total_fees != null ? toSats(tx.total_fees) : null, - blockHeight: tx.block_height ? tx.block_height : null, - timestamp: tx.time_stamp ? toSats(tx.time_stamp) : null, - confirmations: tx.num_confirmations ?? 0, - label: tx.label ? tx.label : null, - destAddresses: tx.dest_addresses ?? [], - rawHex: tx.raw_tx_hex ?? null, - })); - } - - override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { - const type: AddressType = req?.type ?? 'p2wpkh'; - const index = ADDRESS_TYPE_INDEX[type]; - if (!index) throw new BackendError(`lnd cannot derive a ${type} address`, 400, 'UNSUPPORTED_ADDRESS_TYPE'); - - // `peek` maps onto the UNUSED_* enum members, which return the current address without advancing the - // derivation index — LND's only form of "show me the same address again". - const lnrpcType = toLnrpcAddressType(req?.peek ? index.peek : index.fresh); - const res = await this.get<{ address?: string }>('/v1/newaddress', { type: lnrpcType }); - if (!res.address) throw new BackendError('lnd returned no address', 502); - return { address: res.address, type }; - } - - override async getUtxos(): Promise { - // max_confs must be set explicitly: walletrpc defaults it to 0, which matches nothing. - const res = await this.post<{ utxos?: LndUtxo[] }>('/v2/wallet/utxos', { - min_confs: 0, - max_confs: 0x7fffffff, - }); - return (res.utxos ?? []).map((utxo) => ({ - txid: utxo.outpoint?.txid_str ?? '', - vout: utxo.outpoint?.output_index ?? 0, - amountSats: toSats(utxo.amount_sat), - address: utxo.address ?? '', - addressType: utxo.address_type ? (WALLETRPC_ADDRESS_TYPE[utxo.address_type] ?? null) : null, - confirmations: toSats(utxo.confirmations), - derivationPath: null, - // ListUnspent already omits leased outputs, so anything we can see here is selectable. - frozen: false, - })); - } - - override async estimateFees(): Promise { - // walletrpc quotes sat/kw (weight units). 1 vB = 4 wu, so sat/vB = sat_per_kw / 250. - const perVbyte = (satPerKw: string | undefined): number => Math.max(1, Math.ceil(toSats(satPerKw) / 250)); - - const targets = [2, 3, 6, 144] as const; - const [fastest, halfHour, hour, economy] = await Promise.all( - targets.map((target) => this.get(`/v2/wallet/estimatefee/${target}`)), - ); - - return { - fastestFee: perVbyte(fastest?.sat_per_kw), - halfHourFee: perVbyte(halfHour?.sat_per_kw), - hourFee: perVbyte(hour?.sat_per_kw), - economyFee: perVbyte(economy?.sat_per_kw), - // min_relay_fee_sat_per_kw only exists on v0.18+; 253 sat/kw is bitcoind's floor (~1 sat/vB). - minimumFee: perVbyte(fastest?.min_relay_fee_sat_per_kw ?? '253'), - }; - } - - override async sendCoins(req: SendCoinsRequest): Promise { - if (req.outpoints?.length && !this.supports('coinControl')) this.notSupported('coin control'); - - const body: Record = { - addr: req.address, - sat_per_vbyte: String(req.satPerVbyte), - spend_unconfirmed: req.spendUnconfirmed === true, - label: req.label, - }; - if (req.sendAll) body.send_all = true; - else body.amount = String(req.amountSats ?? 0); - - if (req.outpoints?.length) { - body.outpoints = req.outpoints.map((outpoint) => { - const [txid, vout] = outpoint.split(':'); - return { txid_str: txid, output_index: Number(vout ?? 0) }; - }); - } - - const res = await this.post<{ txid?: string }>('/v1/transactions', body); - if (!res.txid) throw new BackendError('lnd returned no txid', 502); - return { txid: res.txid, feeSats: 0, rawHex: null }; - } - - // ── lightning ────────────────────────────────────────────────────────────────────────────────── - - private toInvoice(inv: LndInvoice): Invoice { - const createdAt = toSats(inv.creation_date); - const expiry = toSats(inv.expiry); - const expiresAt = expiry > 0 ? createdAt + expiry : null; - const settledAt = toSats(inv.settle_date); - const preimageHex = inv.r_preimage ? base64ToHex(inv.r_preimage) : ''; - - let state: InvoiceState; - switch (inv.state) { - case 'SETTLED': - state = 'settled'; - break; - case 'CANCELED': - state = 'canceled'; - break; - case 'ACCEPTED': - state = 'accepted'; - break; - default: - // lnrpc has no EXPIRED member; an unsettled invoice past its deadline is reported as OPEN. - state = expiresAt != null && expiresAt * 1000 < Date.now() ? 'expired' : 'open'; - } - - return { - paymentHash: inv.r_hash ? base64ToHex(inv.r_hash) : '', - bolt11: inv.payment_request ?? '', - amountMsat: inv.value_msat && inv.value_msat !== '0' ? toMsat(inv.value_msat) : null, - amountPaidMsat: inv.amt_paid_msat && inv.amt_paid_msat !== '0' ? toMsat(inv.amt_paid_msat) : null, - memo: inv.memo ? inv.memo : null, - state, - createdAt, - expiresAt, - settledAt: settledAt > 0 ? settledAt : null, - // LND ships an all-zero r_preimage for anything it has not settled — that is a placeholder, not a - // secret, and passing it on would let a caller believe it can claim the HTLC. - preimage: preimageHex && preimageHex !== ZERO_PREIMAGE_HEX ? preimageHex : null, - isKeysend: inv.is_keysend === true, - isAmp: inv.is_amp === true, - }; - } - - override async getInvoices(opts?: { limit?: number }): Promise { - // reversed=true walks the add_index backwards, i.e. newest first. - const res = await this.get<{ invoices?: LndInvoice[] }>('/v1/invoices', { - reversed: true, - num_max_invoices: opts?.limit ?? 500, - }); - return (res.invoices ?? []).map((inv) => this.toInvoice(inv)); - } - - override async createInvoice(req: CreateInvoiceRequest): Promise { - const res = await this.post('/v1/invoices', { - memo: req.memo, - value_msat: req.amountMsat, - expiry: req.expirySeconds != null ? String(req.expirySeconds) : undefined, - is_amp: req.isAmp, - private: req.private, - r_preimage: req.preimage ? hexToBase64(req.preimage) : undefined, - }); - - const paymentHash = res.r_hash ? base64ToHex(res.r_hash) : ''; - - // AddInvoiceResponse carries only the hash, the request string and the payment address — no - // timestamps and no state — so read the invoice back to answer with a complete record. - const stored = paymentHash ? await this.lookupInvoice(paymentHash).catch(() => null) : null; - if (stored) return stored; - - const now = Math.floor(Date.now() / 1000); - return { - paymentHash, - bolt11: res.payment_request ?? '', - amountMsat: req.amountMsat ?? null, - amountPaidMsat: null, - memo: req.memo ?? null, - state: 'open', - createdAt: now, - expiresAt: req.expirySeconds != null ? now + req.expirySeconds : null, - settledAt: null, - preimage: null, - isKeysend: false, - isAmp: req.isAmp === true, - }; - } - - override async lookupInvoice(paymentHash: string): Promise { - try { - // The path segment is `r_hash_str` — hex, not the base64 used by the body fields. - const inv = await this.get(`/v1/invoice/${paymentHash}`); - return this.toInvoice(inv); - } catch (err) { - if (err instanceof BackendError && err.status === 404) return null; - throw err; - } - } - - override async decodeInvoice(bolt11: string): Promise { - const res = await this.get(`/v1/payreq/${encodeURIComponent(bolt11)}`); - return { - bolt11, - // PayReq.payment_hash is a proto `string` (already hex), unlike Invoice.r_hash which is `bytes`. - paymentHash: res.payment_hash ?? '', - amountMsat: res.num_msat && res.num_msat !== '0' ? toMsat(res.num_msat) : null, - description: res.description ? res.description : null, - destination: res.destination ?? '', - timestamp: toSats(res.timestamp), - expiry: toSats(res.expiry), - cltvExpiry: res.cltv_expiry != null ? toSats(res.cltv_expiry) : null, - routeHints: (res.route_hints ?? []).length > 0, - features: Object.values(res.features ?? {}) - .map((feature) => feature.name) - .filter((name): name is string => !!name), - }; - } - - private toPayment(payment: LndPayment): Payment { - const preimage = payment.payment_preimage ?? ''; - // creation_date (seconds) was deprecated in favour of creation_time_ns; accept either. - const createdAt = payment.creation_date - ? toSats(payment.creation_date) - : Math.floor(toSats(payment.creation_time_ns) / 1e9); - const hops = payment.htlcs?.[0]?.route?.hops ?? []; - - return { - // ListPayments renders payment_hash/payment_preimage as proto `string`s — already hex. - paymentHash: payment.payment_hash ?? '', - preimage: preimage && preimage !== ZERO_PREIMAGE_HEX ? preimage : null, - amountMsat: toMsat(payment.value_msat), - feeMsat: toMsat(payment.fee_msat), - status: toPaymentStatus(payment.status), - createdAt, - // lnrpc.Payment has no destination field; the last hop of the first attempt's route is the payee. - destination: hops[hops.length - 1]?.pub_key ?? null, - // Recovering the memo would mean decoding payment_request on every row. - memo: null, - failureReason: - payment.failure_reason && payment.failure_reason !== 'FAILURE_REASON_NONE' ? payment.failure_reason : null, - }; - } - - override async getPayments(opts?: { limit?: number }): Promise { - const res = await this.get<{ payments?: LndPayment[] }>('/v1/payments', { - include_incomplete: true, - max_payments: opts?.limit ?? 500, - reversed: true, - }); - return (res.payments ?? []).map((payment) => this.toPayment(payment)); - } - - override async payInvoice(req: PayInvoiceRequest): Promise { - if (req.feeLimitMsat && req.feeLimitPercent != null) { - throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400); - } - - const timeoutSeconds = req.timeoutSeconds ?? 60; - const body: Record = { - payment_request: req.bolt11, - timeout_seconds: timeoutSeconds, - // Zeus sets this so a payment to one's own node is not rejected as a self-payment. - allow_self_payment: true, - // Collapses the stream to a single terminal frame — see `routerSend`. - no_inflight_updates: true, - }; - if (req.amountMsat) body.amt_msat = req.amountMsat; - - if (req.feeLimitMsat) { - body.fee_limit_msat = req.feeLimitMsat; - } else if (req.feeLimitPercent != null) { - // SendPaymentRequest has no percentage form (that is a CLN concept), so resolve the invoice amount - // and turn the percentage into the absolute cap LND wants. - const amountMsat = req.amountMsat ?? (await this.decodeInvoice(req.bolt11)).amountMsat; - if (!amountMsat) { - throw new BackendError('feeLimitPercent needs an amount: the invoice is zero-amount', 400); - } - const limit = (BigInt(amountMsat) * BigInt(Math.round(req.feeLimitPercent * 100))) / 10_000n; - body.fee_limit_msat = limit.toString(); - } - - // Give the HTTP call a little more room than LND's own deadline so the node, not the socket, decides. - const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000); - return this.toPayment(result); - } - - override async sendKeysend(req: KeysendRequest): Promise { - // Keysend is a spontaneous payment: WE pick the preimage, hash it ourselves, and ship the preimage to - // the receiver in TLV 5482373484 so it can settle an HTLC it never issued an invoice for. - const preimage = randomBytes(32); - const paymentHash = createHash('sha256').update(preimage).digest(); - - const customRecords: Record = { - [KEYSEND_PREIMAGE_RECORD]: preimage.toString('base64'), - }; - if (req.message) customRecords[KEYSEND_MESSAGE_RECORD] = Buffer.from(req.message, 'utf8').toString('base64'); - - const timeoutSeconds = 60; - const body: Record = { - dest: hexToBase64(req.destination), - amt_msat: req.amountMsat, - payment_hash: paymentHash.toString('base64'), - dest_custom_records: customRecords, - // Without a route hint there is no invoice to read a final CLTV delta from; 40 is LND's own default. - final_cltv_delta: 40, - timeout_seconds: timeoutSeconds, - allow_self_payment: true, - no_inflight_updates: true, - }; - if (req.feeLimitMsat) body.fee_limit_msat = req.feeLimitMsat; - - const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000); - return this.toPayment(result); - } - - // ── channels / peers ─────────────────────────────────────────────────────────────────────────── - - override async getChannels(): Promise { - const [open, pending] = await Promise.all([ - // peer_alias_lookup makes LND resolve each peer's graph alias for us (v0.15.1+); on older nodes the - // parameter is ignored and `peer_alias` simply comes back absent. - this.get<{ channels?: LndChannel[] }>('/v1/channels', { peer_alias_lookup: true }), - this.get('/v1/channels/pending'), - ]); - - const channels: Channel[] = (open.channels ?? []).map((chan) => ({ - channelId: chan.chan_id ?? chan.channel_point ?? '', - channelPoint: chan.channel_point ?? null, - remotePubkey: chan.remote_pubkey ?? '', - remoteAlias: chan.peer_alias ? chan.peer_alias : null, - capacitySats: toSats(chan.capacity), - localBalanceSats: toSats(chan.local_balance), - remoteBalanceSats: toSats(chan.remote_balance), - active: chan.active === true, - private: chan.private === true, - status: 'open', - })); - - const fromPending = (entries: { channel?: LndPendingChannel }[] | undefined, status: string): Channel[] => - (entries ?? []) - .map((entry) => entry.channel) - .filter((chan): chan is LndPendingChannel => !!chan) - .map((chan) => ({ - // A pending channel has no short channel id yet — the funding outpoint is its only identity. - channelId: chan.channel_point ?? '', - channelPoint: chan.channel_point ?? null, - remotePubkey: chan.remote_node_pub ?? '', - remoteAlias: null, - capacitySats: toSats(chan.capacity), - localBalanceSats: toSats(chan.local_balance), - remoteBalanceSats: toSats(chan.remote_balance), - active: false, - private: chan.private === true, - status, - })); - - return [ - ...channels, - ...fromPending(pending.pending_open_channels, 'pending-open'), - // "waiting close" is a cooperative close whose closing tx has not confirmed; force-closing is the - // unilateral path with a timelock still to run. - ...fromPending(pending.waiting_close_channels, 'pending-close'), - ...fromPending(pending.pending_force_closing_channels, 'force-closing'), - ]; - } - - override async getPeers(): Promise { - const res = await this.get<{ peers?: LndPeer[] }>('/v1/peers'); - return (res.peers ?? []).map((peer) => ({ - pubkey: peer.pub_key ?? '', - address: peer.address ?? '', - alias: null, - inbound: peer.inbound === true, - })); - } - - // ── signing ──────────────────────────────────────────────────────────────────────────────────── - - override async signMessage(message: string): Promise { - // lnrpc.SignMessageRequest.msg is `bytes`, so the payload is base64 even though it is plain text. - const res = await this.post<{ signature?: string }>('/v1/signmessage', { - msg: Buffer.from(message, 'utf8').toString('base64'), - }); - if (!res.signature) throw new BackendError('lnd returned no signature', 502); - return { signature: res.signature }; - } - - override async verifyMessage(message: string, signature: string): Promise { - const res = await this.post<{ valid?: boolean; pubkey?: string }>('/v1/verifymessage', { - msg: Buffer.from(message, 'utf8').toString('base64'), - signature, - }); - return { valid: res.valid === true, pubkey: res.pubkey ?? null }; - } -} diff --git a/src/servers/sidecar/wallet/backends/lndhub.ts b/src/servers/sidecar/wallet/backends/lndhub.ts deleted file mode 100644 index 8e020e76..00000000 --- a/src/servers/sidecar/wallet/backends/lndhub.ts +++ /dev/null @@ -1,602 +0,0 @@ -// LNDHub — port of Zeus's backends/LndHub.ts (which `extends LND` and overrides ~10 methods). -// -// LNDHub is a CUSTODIAL account API, not a node. The server holds the keys; this backend is a thin -// client over a REST facade (BlueWallet/LndHub, LNbits' lndhub extension, Alby, lntxbot, …). There is -// no node identity, no channel view, no UTXO set and no on-chain spend — only a balance, invoices, -// payments and (on some deployments) a single deposit address. -// -// Differences from Zeus, all deliberate: -// -// 1. AUTH. Zeus logs in from SettingsStore (SettingsStore.ts:2090-2130), parks `access_token` in an -// observable and then never refreshes it — an expired token surfaces as a "bad auth" error string -// in the UI. Here the token is cached in memory, acquired lazily, and re-acquired transparently on -// the first auth failure of any request, through a single-flight promise so N concurrent calls -// produce one login rather than N. -// 2. ERRORS. LNDHub reports failure as **HTTP 200 with `{error, code, message}`** (Zeus checks for -// this ad hoc in three different stores: InvoicesStore.ts:410, TransactionsStore.ts:795). httpJson -// only maps non-2xx, so every response goes through `unwrap()` first. Error code 1 ("bad auth") is -// re-raised as a 401 so it feeds the same re-auth path as a real 401. -// 3. lookupInvoice / getTransactions. Zeus *inherits* LND's `/v1/invoice/:r_hash` and -// `/v1/transactions`, neither of which exists on an LNDHub server — those calls simply fail -// upstream. lookupInvoice here scans `/getuserinvoices`; on-chain history is read out of the -// `bitcoind_tx` entries that `/gettxs` interleaves with lightning payments. -// 4. decodeInvoice. Zeus decodes BOLT11 locally (Bolt11Utils). The sidecar has no bolt11 decoder, so -// this uses the server's own `/decodeinvoice`, which returns LND's payreq shape. -// 5. The `lnurlAuth` signing modes (Alby vs BlueWallet key derivation) are not ported — LNURL-auth is -// not part of the WalletBackend contract. -// -// UNITS. LNDHub is sloppy here and each field has to be taken on its own terms: -// • /balance → BTC.AvailableBalance, satoshis -// • /getuserinvoices → `amt`, satoshis -// • /gettxs (payment) → `value` and `fee`, satoshis, where `value` already includes `fee` -// • /gettxs (onchain) → `amount`, **BTC** as a float (verbatim from bitcoind's listtransactions) -// • /decodeinvoice → `num_satoshis` and `num_msat` side by side -// Everything is normalised to the contract's sats-as-number / msats-as-decimal-string rule on the way out. - -import { - BackendError, - type AddressType, - type Balances, - type BackendKind, - type Capability, - type CreateInvoiceRequest, - type DecodedInvoice, - type Invoice, - type InvoiceState, - type NewAddressRequest, - type NodeInfo, - type OnchainTx, - type PayInvoiceRequest, - type Payment, - type BitcoinNetwork, -} from '../types'; -import { BaseBackend, base64ToHex, httpJson } from './base'; -import { decodeBolt11 } from '../bolt11'; - -export type LndHubConfig = { - /** Base URL of the LNDHub server, e.g. https://lndhub.example.com or https://ln.getalby.com/lndhub. */ - url: string; - login: string; - password: string; - /** Opt-in TLS relaxation for a self-hosted server with a self-signed cert. Never default. */ - allowSelfSigned?: boolean; -}; - -// ── upstream wire shapes ───────────────────────────────────────────────────────────────────────── - -/** Every LNDHub response can be this instead of the documented shape, with HTTP 200. */ -type LndHubErrorBody = { error?: string | boolean | number; code?: number; message?: string }; - -/** node's `JSON.stringify(Buffer)` — LNDHub leaks raw Buffers for hash/preimage fields. */ -type LndHubBuffer = { type?: string; data?: number[] }; -type LndHubBytes = string | LndHubBuffer; - -type LndHubAuth = { access_token?: string; refresh_token?: string }; - -type LndHubBalance = { BTC?: { AvailableBalance?: number; TotalBalance?: number } }; - -type LndHubGetInfo = { - identity_pubkey?: string; - alias?: string; - version?: string; - block_height?: number; - testnet?: boolean; - synced_to_chain?: boolean; - chains?: { chain?: string; network?: string }[]; -}; - -type LndHubUserInvoice = { - payment_request?: string; - pay_req?: string; - r_hash?: LndHubBytes; - payment_hash?: LndHubBytes; - description?: string; - memo?: string; - ispaid?: boolean; - /** satoshis */ - amt?: number | string; - amt_paid_sat?: number | string; - amt_paid_msat?: number | string; - /** seconds of validity, relative to `timestamp` */ - expire_time?: number; - /** unix seconds */ - timestamp?: number | string; - settled_at?: number; - type?: string; -}; - -/** `/gettxs` interleaves outgoing lightning payments and imported on-chain deposits. */ -type LndHubTx = { - type?: string; - // paid_invoice - payment_preimage?: LndHubBytes; - payment_hash?: LndHubBytes; - payment_request?: string; - /** satoshis, and LNDHub folds `fee` into it (User.getTxs) */ - value?: number | string; - /** satoshis */ - fee?: number | string; - memo?: string; - description?: string; - timestamp?: number | string; - // bitcoind_tx - txid?: string; - /** BTC, float, straight from bitcoind */ - amount?: number; - confirmations?: number; - address?: string; - category?: string; - time?: number; - blockheight?: number; -}; - -type LndHubAddress = { address?: string }; - -type LndHubDecoded = { - destination?: string; - payment_hash?: string; - num_satoshis?: string | number; - num_msat?: string | number; - timestamp?: string | number; - expiry?: string | number; - description?: string; - cltv_expiry?: string | number; - route_hints?: unknown[]; - features?: Record; -}; - -/** `/payinvoice` passes LND's sendPaymentSync response through, mostly. */ -type LndHubPayResult = { - payment_error?: string; - payment_preimage?: LndHubBytes; - payment_hash?: LndHubBytes; - payment_route?: { - total_amt?: number | string; - total_fees?: number | string; - total_amt_msat?: number | string; - total_fees_msat?: number | string; - }; - decoded?: LndHubDecoded; - /** some forks answer a zero-amount pay with the amount they chose */ - num_satoshis?: number | string; -}; - -type LndHubCall = { - path: string; - method?: 'GET' | 'POST'; - body?: unknown; - query?: Record; -}; - -// ── helpers ────────────────────────────────────────────────────────────────────────────────────── - -const HEX32 = /^[0-9a-f]{64}$/i; - -/** LNDHub returns a 32-byte field as hex, base64, url-safe base64 or a stringified Buffer. */ -function bytesToHex(value: LndHubBytes | undefined | null): string { - if (!value) return ''; - if (typeof value === 'string') { - if (HEX32.test(value)) return value.toLowerCase(); - return base64ToHex(value.replace(/-/g, '+').replace(/_/g, '/')); - } - if (Array.isArray(value.data)) return Buffer.from(value.data).toString('hex'); - return ''; -} - -const num = (value: number | string | undefined | null): number => { - const n = typeof value === 'string' ? Number(value) : value; - return typeof n === 'number' && Number.isFinite(n) ? n : 0; -}; - -const satsToMsat = (sats: number): string => (BigInt(Math.round(sats)) * 1000n).toString(); - -/** msat string → whole satoshis. LNDHub cannot express sub-satoshi amounts anywhere. */ -function msatToSats(msat: string, field: string): number { - const value = BigInt(msat); - if (value % 1000n !== 0n) { - throw new BackendError(`lndhub cannot express sub-satoshi amounts (${field}=${msat}msat)`, 400, 'BAD_AMOUNT'); - } - return Number(value / 1000n); -} - -/** LNDHub tells us nothing about the address it hands out, so classify it by prefix. */ -function addressType(address: string): AddressType { - const a = address.toLowerCase(); - if (a.startsWith('bc1p') || a.startsWith('tb1p') || a.startsWith('bcrt1p')) return 'p2tr'; - if (a.startsWith('bc1') || a.startsWith('tb1') || a.startsWith('bcrt1')) return 'p2wpkh'; - if (a.startsWith('3') || a.startsWith('2')) return 'p2sh-p2wpkh'; - return 'p2pkh'; -} - -// ── backend ────────────────────────────────────────────────────────────────────────────────────── - -export class LndHubBackend extends BaseBackend { - readonly kind: BackendKind = 'lndhub'; - - // Custodial: receive and send over lightning, plus a deposit address on deployments that expose - // /getbtc. No on-chain send, no coin control, no PSBT, no channels, no peers, no message signing — - // the wallet does not hold the keys those would need. - protected readonly capabilities: ReadonlySet = new Set([ - 'lightningReceive', - 'lightningSend', - 'onchainReceive', - ]); - - private accessToken: string | null = null; - /** In-flight login, shared by every caller that needs a token — the single-flight refresh. */ - private pendingLogin: Promise | null = null; - - constructor(private readonly config: LndHubConfig) { - super(); - } - - // ── auth ─────────────────────────────────────────────────────────────────────────────────────── - - /** - * `POST /auth?type=auth {login, password}` → `{access_token, refresh_token}`. The refresh_token - * grant (`type=refresh_token`) is deliberately not used: we hold the password, so a fresh login is - * one round-trip either way and has one failure mode instead of two. - */ - private async login(): Promise { - const res = await httpJson({ - base: this.config.url, - path: '/auth', - method: 'POST', - query: { type: 'auth' }, - body: { login: this.config.login, password: this.config.password }, - allowSelfSigned: this.config.allowSelfSigned, - }); - if (res?.error || !res?.access_token) { - const message = res?.message ?? (typeof res?.error === 'string' ? res.error : 'lndhub rejected the login'); - throw new BackendError(message, 401, 'LNDHUB_AUTH_FAILED'); - } - this.accessToken = res.access_token; - return res.access_token; - } - - private async token(force = false): Promise { - if (!force && this.accessToken) return this.accessToken; - // Concurrent 401s all await the same login; whoever loses the race gets the winner's token. - this.pendingLogin ??= this.login().finally(() => { - this.pendingLogin = null; - }); - return this.pendingLogin; - } - - /** LNDHub's 200-with-`{error}` channel, folded back into the exception path. */ - private unwrap(payload: T): T { - const body = payload as LndHubErrorBody | null; - if (body && typeof body === 'object' && !Array.isArray(body) && body.error) { - const message = body.message || (typeof body.error === 'string' ? body.error : 'lndhub returned an error'); - // code 1 is LNDHub's "bad auth"; some forks only put the phrase in the message. - if (body.code === 1 || /bad auth/i.test(message)) { - throw new BackendError(message, 401, 'LNDHUB_BAD_AUTH'); - } - throw new BackendError(message, 502, body.code != null ? `LNDHUB_${body.code}` : undefined); - } - return payload; - } - - private async call(req: LndHubCall): Promise { - const send = async (token: string): Promise => - this.unwrap( - await httpJson({ - base: this.config.url, - path: req.path, - method: req.method ?? 'GET', - body: req.body, - query: req.query, - headers: { Authorization: `Bearer ${token}` }, - allowSelfSigned: this.config.allowSelfSigned, - }), - ); - - try { - return await send(await this.token()); - } catch (err) { - // One retry, and only for auth — a second 401 with a token minted seconds ago is a real failure. - if (!(err instanceof BackendError) || err.status !== 401) throw err; - this.accessToken = null; - return send(await this.token(true)); - } - } - - // ── node / balances ──────────────────────────────────────────────────────────────────────────── - - /** - * Best-effort. `/getinfo` exists on BlueWallet-derived servers but not on every fork (Alby, LNbits), - * so a failure degrades to an anonymous descriptor rather than breaking connect. Zeus sidesteps this - * by reporting `supportsNodeInfo() = false` and never calling it. - */ - override async getInfo(): Promise { - let info: LndHubGetInfo | null = null; - try { - info = await this.call({ path: '/getinfo' }); - } catch { - info = null; - } - - const chain = info?.chains?.[0]?.network; - const network: BitcoinNetwork = - chain === 'testnet' || chain === 'signet' || chain === 'regtest' - ? chain - : info?.testnet === true - ? 'testnet' - : 'bitcoin'; - - return { - kind: this.kind, - // The account is custodial — the pubkey, if any, belongs to the operator's node, not to us. - pubkey: info?.identity_pubkey ?? null, - alias: info?.alias ?? null, - version: info?.version ?? null, - network, - blockHeight: info?.block_height ?? null, - synced: info?.synced_to_chain ?? true, - }; - } - - override async getBalances(): Promise { - const res = await this.call({ path: '/balance' }); - return { - // A custodial account has no on-chain balance of its own; deposits land in the lightning balance. - onchainConfirmed: 0, - onchainFrozen: 0, - onchainUnconfirmed: 0, - lightningBalance: num(res?.BTC?.AvailableBalance), - // Inbound liquidity is the operator's problem and is never reported. - lightningInbound: null, - }; - } - - // ── on-chain ─────────────────────────────────────────────────────────────────────────────────── - - /** - * `/gettxs` mixes `paid_invoice` (lightning, outgoing) with `bitcoind_tx` (on-chain deposits copied - * out of bitcoind's listtransactions). Only the latter belong here. - */ - override async getTransactions(opts?: { limit?: number }): Promise { - const txs = await this.fetchTxs(opts?.limit); - return txs - .filter((tx) => tx.type === 'bitcoind_tx' || (!!tx.txid && tx.payment_preimage === undefined)) - .map((tx) => ({ - txid: tx.txid ?? '', - // `amount` here is BTC as a float, not sats. Round after scaling — 0.1 BTC is not exact. - amount: Math.round(num(tx.amount) * 1e8), - feeSats: null, - blockHeight: tx.blockheight ?? null, - timestamp: tx.time ?? (tx.timestamp != null ? num(tx.timestamp) : null), - confirmations: tx.confirmations ?? 0, - label: null, - destAddresses: tx.address ? [tx.address] : [], - rawHex: null, - })); - } - - /** - * `/getbtc` returns the account's single deposit address as a one-element array (Zeus reads - * `data[0].address`, InvoicesStore.ts:620). It is static per account, so `peek` is meaningless and - * the requested address type is ignored — the operator chose it. On the deployments that hand back an - * empty array until an address has been allocated, `/newbtc` allocates one. - */ - override async getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { - let address = await this.fetchBtcAddress(); - if (!address) { - await this.call({ path: '/newbtc', method: 'POST', body: {} }); - address = await this.fetchBtcAddress(); - } - if (!address) { - throw new BackendError('lndhub server does not offer on-chain deposits', 501, 'NOT_SUPPORTED'); - } - return { address, type: addressType(address) }; - } - - private async fetchBtcAddress(): Promise { - const res = await this.call({ path: '/getbtc' }); - if (Array.isArray(res)) return res[0]?.address ?? ''; - return res?.address ?? ''; - } - - // ── lightning ────────────────────────────────────────────────────────────────────────────────── - - override async getInvoices(opts?: { limit?: number }): Promise { - const res = await this.call({ - path: '/getuserinvoices', - query: { limit: opts?.limit }, - }); - return (Array.isArray(res) ? res : []).map((inv) => this.toInvoice(inv)); - } - - override async createInvoice(req: CreateInvoiceRequest): Promise { - if (req.preimage) return this.notSupported('custom preimages'); - if (req.isAmp) return this.notSupported('AMP invoices'); - // LNDHub answers a zero-amount invoice request with "Bad arguments" (Zeus special-cases the string - // in InvoicesStore.ts:425); reject it here with something legible instead. - if (!req.amountMsat || req.amountMsat === '0') { - throw new BackendError('lndhub requires an invoice amount', 400, 'AMOUNT_REQUIRED'); - } - const sats = msatToSats(req.amountMsat, 'amountMsat'); - // `expirySeconds` and `private` have no equivalent — the server picks both (Zeus: - // supportsSettingInvoiceExpiration() = false). - const res = await this.call({ - path: '/addinvoice', - method: 'POST', - body: { amt: String(sats), memo: req.memo ?? '' }, - }); - - const bolt11 = res.payment_request ?? res.pay_req ?? ''; - let paymentHash = bytesToHex(res.r_hash ?? res.payment_hash); - // A few forks omit r_hash on /addinvoice. The hash is already in the invoice we were just handed, so - // read it locally rather than spending a second authenticated round-trip — and a second failure mode - // — on `/decodeinvoice`, which those same forks are the least likely to implement. - if (!paymentHash && bolt11) { - paymentHash = decodeBolt11(bolt11).paymentHash; - } - - const createdAt = res.timestamp != null ? num(res.timestamp) : Math.floor(Date.now() / 1000); - return { - paymentHash, - bolt11, - amountMsat: satsToMsat(sats), - amountPaidMsat: null, - memo: req.memo ?? res.description ?? null, - state: 'open', - createdAt, - expiresAt: res.expire_time ? createdAt + res.expire_time : null, - settledAt: null, - preimage: null, - isKeysend: false, - isAmp: false, - }; - } - - /** - * LNDHub has no lookup endpoint — Zeus inherits LND's `/v1/invoice/:r_hash`, which 404s here. Scan - * the invoice list instead. Bounded at 200 entries: an older invoice reports as not found rather - * than paging the whole history on every poll. - */ - override async lookupInvoice(paymentHash: string): Promise { - const wanted = paymentHash.toLowerCase(); - const res = await this.call({ path: '/getuserinvoices', query: { limit: 200 } }); - const found = (Array.isArray(res) ? res : []).find((inv) => bytesToHex(inv.r_hash ?? inv.payment_hash) === wanted); - return found ? this.toInvoice(found) : null; - } - - /** - * Zeus decodes BOLT11 client-side (Bolt11Utils) and never calls this endpoint. The sidecar has no - * decoder, and the server's `/decodeinvoice?invoice=` answers with LND's payreq shape, which maps - * onto DecodedInvoice one-for-one. - */ - override async decodeInvoice(bolt11: string): Promise { - const res = await this.call({ path: '/decodeinvoice', query: { invoice: bolt11 } }); - const msat = res.num_msat != null ? String(num(res.num_msat)) : null; - return { - bolt11, - paymentHash: res.payment_hash ?? '', - amountMsat: msat ?? (res.num_satoshis != null ? satsToMsat(num(res.num_satoshis)) : null), - description: res.description ?? null, - destination: res.destination ?? '', - timestamp: num(res.timestamp), - expiry: res.expiry != null ? num(res.expiry) : 3600, - cltvExpiry: res.cltv_expiry != null ? num(res.cltv_expiry) : null, - routeHints: Array.isArray(res.route_hints) && res.route_hints.length > 0, - features: Object.values(res.features ?? {}).map((f) => f?.name ?? ''), - }; - } - - override async getPayments(opts?: { limit?: number }): Promise { - const txs = await this.fetchTxs(opts?.limit); - return txs - .filter((tx) => tx.type === 'paid_invoice' || tx.payment_preimage !== undefined) - .map((tx) => { - // LNDHub's User.getTxs() sets `value = payment_route.total_amt + payment_route.total_fees`, - // so the destination amount is `value - fee`. Both are satoshis. - const feeSats = num(tx.fee); - const amountSats = Math.max(num(tx.value) - feeSats, 0); - return { - paymentHash: bytesToHex(tx.payment_hash), - preimage: bytesToHex(tx.payment_preimage) || null, - amountMsat: satsToMsat(amountSats), - feeMsat: satsToMsat(feeSats), - // /gettxs only records payments that went through; failures are never persisted. - status: 'succeeded' as const, - createdAt: num(tx.timestamp ?? tx.time), - destination: null, - memo: tx.memo ?? tx.description ?? null, - failureReason: null, - }; - }); - } - - /** - * `POST /payinvoice {invoice, amount}` — `amount` is satoshis and only consulted for a zero-amount - * invoice. Fee limits and timeouts are the operator's policy (Zeus: supportsCustomFeeLimit() = - * false), so feeLimitMsat / feeLimitPercent / timeoutSeconds are accepted and ignored. - */ - override async payInvoice(req: PayInvoiceRequest): Promise { - const amountSats = req.amountMsat ? msatToSats(req.amountMsat, 'amountMsat') : undefined; - const res = await this.call({ - path: '/payinvoice', - method: 'POST', - body: { invoice: req.bolt11, amount: amountSats }, - }); - - const route = res.payment_route ?? {}; - const feeMsat = - route.total_fees_msat != null ? String(num(route.total_fees_msat)) : satsToMsat(num(route.total_fees)); - // Prefer the invoice's own amount: `total_amt` is ambiguous across forks about whether fees are - // included, whereas the decoded payreq is not. - const decodedMsat = - res.decoded?.num_msat != null - ? String(num(res.decoded.num_msat)) - : res.decoded?.num_satoshis != null - ? satsToMsat(num(res.decoded.num_satoshis)) - : null; - const routedMsat = - route.total_amt_msat != null ? String(num(route.total_amt_msat)) : satsToMsat(num(route.total_amt)); - - const preimage = bytesToHex(res.payment_preimage) || null; - // Hard failures arrive as `{error, message}` and have already thrown in unwrap(). `payment_error` - // is LND's soft channel — a routing failure, reported as a failed Payment rather than an exception. - const failed = !!res.payment_error; - - return { - paymentHash: bytesToHex(res.payment_hash) || res.decoded?.payment_hash || '', - preimage, - amountMsat: decodedMsat ?? req.amountMsat ?? routedMsat, - feeMsat, - status: failed ? 'failed' : 'succeeded', - createdAt: Math.floor(Date.now() / 1000), - destination: res.decoded?.destination ?? null, - memo: res.decoded?.description ?? null, - failureReason: failed ? (res.payment_error ?? null) : null, - }; - } - - // ── shared ───────────────────────────────────────────────────────────────────────────────────── - - private async fetchTxs(limit?: number): Promise { - const res = await this.call({ path: '/gettxs', query: { limit } }); - return Array.isArray(res) ? res : []; - } - - private toInvoice(inv: LndHubUserInvoice): Invoice { - const createdAt = num(inv.timestamp); - const expiresAt = inv.expire_time ? createdAt + inv.expire_time : null; - const paid = inv.ispaid === true; - // LNDHub has no cancel and no hold invoices, so 'canceled' and 'accepted' are unreachable; an - // unpaid invoice past its expiry is reported as expired rather than left open. - const state: InvoiceState = paid - ? 'settled' - : expiresAt != null && expiresAt < Math.floor(Date.now() / 1000) - ? 'expired' - : 'open'; - - const amountMsat = inv.amt != null ? satsToMsat(num(inv.amt)) : null; - const paidMsat = - inv.amt_paid_msat != null - ? String(num(inv.amt_paid_msat)) - : inv.amt_paid_sat != null - ? satsToMsat(num(inv.amt_paid_sat)) - : paid - ? amountMsat - : null; - - return { - paymentHash: bytesToHex(inv.r_hash ?? inv.payment_hash), - bolt11: inv.payment_request ?? inv.pay_req ?? '', - amountMsat, - amountPaidMsat: paidMsat, - memo: inv.description ?? inv.memo ?? null, - state, - createdAt, - expiresAt, - // The settle time is not recorded upstream; only the ispaid flag is. - settledAt: inv.settled_at ?? null, - // Preimages of received payments stay with the custodian. - preimage: null, - isKeysend: false, - isAmp: false, - }; - } -} diff --git a/src/servers/sidecar/wallet/backends/nwc.ts b/src/servers/sidecar/wallet/backends/nwc.ts deleted file mode 100644 index 478443f3..00000000 --- a/src/servers/sidecar/wallet/backends/nwc.ts +++ /dev/null @@ -1,408 +0,0 @@ -// Nostr Wallet Connect (NIP-47) — port of Zeus's backends/NostrWalletConnect.ts. -// -// A NWC connection is a capability grant, not a node: a `nostr+walletconnect://` URI carries a wallet -// service pubkey, one or more relays and a shared secret. Every operation is an encrypted nostr event -// round-tripped through a relay, so the whole surface is lightning-only and the wallet decides which -// commands it will honour. -// -// CAPABILITIES ARE DYNAMIC. This is the one backend whose `capabilities` set is not a constant: the -// wallet advertises its methods in the `get_info` response (falling back to the kind-13194 info event), -// and the set is rebuilt from that list on connect. Zeus hardcodes `supportsKeysend() = false` even -// though plenty of NWC wallets do keysend; here `keysend` appears iff the wallet lists `pay_keysend`. -// Before the first connect the set holds the NIP-47 baseline (make_invoice + pay_invoice), because -// `supports()` is synchronous and cannot await the handshake. -// -// Differences from Zeus, all deliberate: -// -// 1. TRANSPORT. Zeus drives `NostrWebLNProvider` from `@getalby/sdk`, a WebLN shim that silently -// converts sats↔msats. This uses `NWCClient` from the same package (v8), which speaks raw NIP-47 -// — every amount in and out is millisatoshis, matching this contract's msat-as-string rule with -// no lossy hop through sats. Bun/Node 22 both provide the global WebSocket that nostr-tools needs. -// 2. lookupInvoice. Zeus passes `Base64Utils.hexToBase64(r_hash)` (NostrWalletConnect.ts:78) — NIP-47 -// specifies payment_hash as **hex**, so that call cannot match. Hex is sent here. -// 3. INVOICE/PAYMENT SPLIT. Zeus calls `list_transactions` and filters `type` client-side, twice. -// NIP-47 takes `type` as a request parameter; the filter is pushed to the wallet, with the -// client-side filter kept as a guard for wallets that ignore it. -// 4. payInvoice. NIP-47 answers a payment with `{preimage, fees_paid}` and nothing else, so Zeus -// returns a Payment with no hash. The payment hash is sha256(preimage) by definition, so it is -// computed locally; the amount is filled from a best-effort `lookup_invoice` when the wallet -// supports it, otherwise from the request. -// 5. decodeInvoice is LOCAL. NIP-47 has no decode command, so this is the one operation that never -// touches the relay: `../bolt11` (ported from Zeus's Bolt11Utils) decodes in-process. On-chain, -// channels, peers and message signing do stay at BaseBackend's 501 — NIP-47 `sign_message` signs -// with the node key, which is not what the contract's signMessage means. - -import { createHash } from 'node:crypto'; -import { Nip47Error, Nip47TimeoutError, NWCClient, type Nip47Method, type Nip47Transaction } from '@getalby/sdk/nwc'; -import { decodeBolt11 } from '../bolt11'; -import { - BackendError, - type Balances, - type BackendKind, - type BitcoinNetwork, - type Capability, - type CreateInvoiceRequest, - type DecodedInvoice, - type Invoice, - type InvoiceState, - type KeysendRequest, - type NodeInfo, - type PayInvoiceRequest, - type Payment, - type PaymentStatus, -} from '../types'; -import { BaseBackend } from './base'; - -export type NwcConfig = { - /** A `nostr+walletconnect://?relay=…&secret=…` URI. */ - connectionUri: string; -}; - -// ── upstream wire shapes ───────────────────────────────────────────────────────────────────────── - -/** - * The SDK types `state` as required, but it was added late to NIP-47 and pre-1.0 wallets omit it — - * their only settlement signal is a non-zero `settled_at`. Amounts are millisatoshis throughout. - */ -type NwcTransaction = Omit & { state?: Nip47Transaction['state'] }; - -/** NIP-47 error codes, mapped onto HTTP so routes.ts can answer honestly. */ -const ERROR_STATUS: Record = { - NOT_IMPLEMENTED: 501, - UNSUPPORTED_ENCRYPTION: 501, - UNAUTHORIZED: 403, - RESTRICTED: 403, - INSUFFICIENT_BALANCE: 402, - QUOTA_EXCEEDED: 402, - PAYMENT_FAILED: 502, - NOT_FOUND: 404, - RATE_LIMITED: 429, - INTERNAL: 502, - OTHER: 502, -}; - -/** The keysend TLV that carries a human-readable message (the de-facto standard record). */ -const KEYSEND_MESSAGE_TLV = 34349334; - -// ── helpers ────────────────────────────────────────────────────────────────────────────────────── - -const now = (): number => Math.floor(Date.now() / 1000); - -/** payment_hash = sha256(preimage) — how NIP-47 lets us recover a hash it never sends back. */ -function hashFromPreimage(preimage: string | undefined): string { - if (!preimage) return ''; - return createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex'); -} - -/** msat decimal string → the integer msats NIP-47 wants, with the JS-safe range enforced. */ -function toMsatNumber(msat: string, field: string): number { - const value = BigInt(msat); - if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new BackendError(`${field}=${msat}msat is out of range for NIP-47`, 400, 'BAD_AMOUNT'); - } - return Number(value); -} - -function toBackendError(err: unknown, op: string): BackendError { - if (err instanceof BackendError) return err; - if (err instanceof Nip47TimeoutError) { - return new BackendError(`nwc ${op} timed out: ${err.message}`, 504, err.code); - } - if (err instanceof Nip47Error) { - return new BackendError(`nwc ${op} failed: ${err.message}`, ERROR_STATUS[err.code] ?? 502, err.code); - } - return new BackendError(`nwc ${op} failed: ${String(err)}`, 502, 'NWC_ERROR'); -} - -function toInvoiceState(tx: NwcTransaction): InvoiceState { - if (tx.state === 'settled') return 'settled'; - if (tx.state === 'accepted') return 'accepted'; - // NIP-47 has no explicit cancel; a hold invoice that was released comes back as failed. - if (tx.state === 'failed') return 'canceled'; - if (tx.settled_at) return 'settled'; - if (tx.expires_at && tx.expires_at < now()) return 'expired'; - return 'open'; -} - -function toPaymentStatus(tx: NwcTransaction): PaymentStatus { - if (tx.state === 'settled') return 'succeeded'; - if (tx.state === 'failed') return 'failed'; - if (tx.state) return 'pending'; - return tx.settled_at ? 'succeeded' : 'pending'; -} - -function toInvoice(tx: NwcTransaction): Invoice { - const amountMsat = tx.amount != null ? String(tx.amount) : null; - const state = toInvoiceState(tx); - return { - paymentHash: tx.payment_hash ?? '', - bolt11: tx.invoice ?? '', - amountMsat, - amountPaidMsat: state === 'settled' ? amountMsat : null, - memo: tx.description || null, - state, - createdAt: tx.created_at ?? now(), - expiresAt: tx.expires_at || null, - settledAt: tx.settled_at || null, - preimage: tx.preimage || null, - // An incoming payment with no BOLT11 attached to it can only have been a keysend. - isKeysend: !tx.invoice && !!tx.payment_hash, - // NIP-47 has no AMP concept. - isAmp: false, - }; -} - -function toPayment(tx: NwcTransaction): Payment { - return { - paymentHash: tx.payment_hash ?? '', - preimage: tx.preimage || null, - amountMsat: String(tx.amount ?? 0), - feeMsat: String(tx.fees_paid ?? 0), - status: toPaymentStatus(tx), - createdAt: tx.created_at ?? now(), - // The wallet never names the payee; only the invoice it paid. - destination: null, - memo: tx.description || null, - failureReason: null, - }; -} - -// ── backend ────────────────────────────────────────────────────────────────────────────────────── - -export class NwcBackend extends BaseBackend { - readonly kind: BackendKind = 'nwc'; - - /** - * Mutable behind a ReadonlySet view — `capabilities` and `caps` are the same object, so rebuilding - * the set after the get_info handshake is visible through `supports()` without reassigning a - * readonly field. Seeded with the NIP-47 baseline until the wallet says otherwise. - */ - private readonly caps = new Set(['lightningReceive', 'lightningSend']); - protected readonly capabilities: ReadonlySet = this.caps; - - private client: NWCClient | null = null; - private connecting: Promise | null = null; - private methods: ReadonlySet = new Set(); - - constructor(private readonly config: NwcConfig) { - super(); - } - - // ── connection ───────────────────────────────────────────────────────────────────────────────── - - /** Single-flight connect: the relay subscription and the get_info handshake happen exactly once. */ - private async connect(): Promise { - if (this.client) return this.client; - this.connecting ??= this.open().finally(() => { - this.connecting = null; - }); - return this.connecting; - } - - private async open(): Promise { - let client: NWCClient; - try { - client = new NWCClient({ nostrWalletConnectUrl: this.config.connectionUri }); - } catch (err) { - throw new BackendError(`invalid NWC connection URI: ${String(err)}`, 400, 'BAD_CONFIG'); - } - - try { - const info = await client.getInfo(); - this.applyMethods(info.methods); - } catch (err) { - // Wallets that do not implement get_info still publish a kind-13194 info event listing their - // capabilities. If neither is reachable the connection itself is broken — surface that. - try { - const service = await client.getWalletServiceInfo(); - this.applyMethods(service.capabilities.filter((cap): cap is Nip47Method => cap !== 'notifications')); - } catch { - client.close(); - throw toBackendError(err, 'connect'); - } - } - - this.client = client; - return client; - } - - /** Capability negotiation: the whole point of NWC's get_info. */ - private applyMethods(methods: Nip47Method[] | undefined): void { - if (!methods?.length) return; - this.methods = new Set(methods); - this.caps.clear(); - if (this.methods.has('make_invoice')) this.caps.add('lightningReceive'); - if (this.methods.has('pay_invoice')) this.caps.add('lightningSend'); - if (this.methods.has('pay_keysend')) this.caps.add('keysend'); - } - - private async run(op: string, fn: (client: NWCClient) => Promise): Promise { - const client = await this.connect(); - try { - return await fn(client); - } catch (err) { - throw toBackendError(err, op); - } - } - - /** Drop the relay subscription. Not part of WalletBackend; the factory calls it on teardown. */ - close(): void { - this.client?.close(); - this.client = null; - this.methods = new Set(); - } - - // ── node / balances ──────────────────────────────────────────────────────────────────────────── - - override async getInfo(): Promise { - const info = await this.run('get_info', (client) => client.getInfo()); - const network = info.network === 'mainnet' || !info.network ? 'bitcoin' : info.network; - return { - kind: this.kind, - // The pubkey belongs to the wallet service's node, not to this connection. - pubkey: info.pubkey || null, - alias: info.alias || null, - // NIP-47 carries no implementation version. - version: null, - network: (['bitcoin', 'testnet', 'signet', 'regtest'] as const).includes(network as BitcoinNetwork) - ? (network as BitcoinNetwork) - : 'bitcoin', - blockHeight: info.block_height ?? null, - // A wallet that answers at all is by definition usable; there is no sync flag in NIP-47. - synced: true, - }; - } - - override async getBalances(): Promise { - const res = await this.run('get_balance', (client) => client.getBalance()); - return { - // NWC is lightning-only; there is no on-chain side to report. - onchainConfirmed: 0, - onchainFrozen: 0, - onchainUnconfirmed: 0, - // `balance` is msats (the WebLN shim Zeus uses divides by 1000 before the UI ever sees it). - lightningBalance: Math.floor((res.balance ?? 0) / 1000), - lightningInbound: null, - }; - } - - // ── lightning ────────────────────────────────────────────────────────────────────────────────── - - override async getInvoices(opts?: { limit?: number }): Promise { - const res = await this.run('list_transactions', (client) => - client.listTransactions({ type: 'incoming', limit: opts?.limit, unpaid: true }), - ); - // The `type` filter is a request parameter, but older wallets ignore it — filter again. - return (res.transactions ?? []).filter((tx) => tx.type !== 'outgoing').map(toInvoice); - } - - override async createInvoice(req: CreateInvoiceRequest): Promise { - if (!this.caps.has('lightningReceive')) return this.notSupported('lightning receive'); - // No NIP-47 wallet accepts a caller-supplied preimage or an AMP invoice, and `private` has no - // equivalent — the wallet picks its own route hints. - if (req.preimage) return this.notSupported('custom preimages'); - if (req.isAmp) return this.notSupported('AMP invoices'); - if (!req.amountMsat || req.amountMsat === '0') { - throw new BackendError('nwc requires an invoice amount', 400, 'AMOUNT_REQUIRED'); - } - const amount = toMsatNumber(req.amountMsat, 'amountMsat'); - - const tx = await this.run('make_invoice', (client) => - client.makeInvoice({ amount, description: req.memo, expiry: req.expirySeconds }), - ); - return toInvoice(tx); - } - - override async lookupInvoice(paymentHash: string): Promise { - if (!this.methods.size || this.methods.has('lookup_invoice')) { - const tx = await this.lookup({ payment_hash: paymentHash }); - return tx ? toInvoice(tx) : null; - } - return this.notSupported('invoice lookup'); - } - - /** - * Purely local — no relay round-trip, and it works while the wallet is unreachable. The invoice is - * self-describing, so there is nothing the wallet service could add beyond what the bytes already say. - */ - override async decodeInvoice(bolt11: string): Promise { - return decodeBolt11(bolt11); - } - - override async getPayments(opts?: { limit?: number }): Promise { - const res = await this.run('list_transactions', (client) => - client.listTransactions({ type: 'outgoing', limit: opts?.limit, unpaid_outgoing: true }), - ); - return (res.transactions ?? []).filter((tx) => tx.type !== 'incoming').map(toPayment); - } - - override async payInvoice(req: PayInvoiceRequest): Promise { - if (!this.caps.has('lightningSend')) return this.notSupported('lightning send'); - // Fee limits and timeouts are the wallet's own budget policy; NIP-47 has no field for either. - const amount = req.amountMsat ? toMsatNumber(req.amountMsat, 'amountMsat') : undefined; - const res = await this.run('pay_invoice', (client) => client.payInvoice({ invoice: req.bolt11, amount })); - - const paymentHash = hashFromPreimage(res.preimage); - // pay_invoice returns only {preimage, fees_paid}. Recover the rest from the wallet's own record - // when it keeps one — best effort, never fatal, since the payment has already settled. - const record = paymentHash ? await this.lookupQuietly({ payment_hash: paymentHash }) : null; - - return { - paymentHash, - preimage: res.preimage || null, - amountMsat: record?.amount != null ? String(record.amount) : (req.amountMsat ?? '0'), - feeMsat: String(res.fees_paid ?? record?.fees_paid ?? 0), - // A NIP-47 pay_invoice that resolves has succeeded; a failure arrives as a Nip47WalletError. - status: 'succeeded', - createdAt: record?.created_at ?? now(), - destination: null, - memo: record?.description || null, - failureReason: null, - }; - } - - override async sendKeysend(req: KeysendRequest): Promise { - if (!this.caps.has('keysend')) return this.notSupported('keysend'); - const amount = toMsatNumber(req.amountMsat, 'amountMsat'); - // NIP-47 carries TLV values as hex. - const tlvRecords = req.message - ? [{ type: KEYSEND_MESSAGE_TLV, value: Buffer.from(req.message, 'utf8').toString('hex') }] - : undefined; - - const res = await this.run('pay_keysend', (client) => - client.payKeysend({ pubkey: req.destination, amount, tlv_records: tlvRecords }), - ); - return { - paymentHash: hashFromPreimage(res.preimage), - preimage: res.preimage || null, - amountMsat: req.amountMsat, - feeMsat: String(res.fees_paid ?? 0), - status: 'succeeded', - createdAt: now(), - destination: req.destination, - memo: req.message ?? null, - failureReason: null, - }; - } - - // ── shared ───────────────────────────────────────────────────────────────────────────────────── - - private async lookup(request: { payment_hash?: string; invoice?: string }): Promise { - try { - return await this.run('lookup_invoice', (client) => client.lookupInvoice(request)); - } catch (err) { - // A wallet that has never seen the hash answers NOT_FOUND; that is an absence, not a failure. - if (err instanceof BackendError && err.status === 404) return null; - throw err; - } - } - - private async lookupQuietly(request: { payment_hash?: string; invoice?: string }): Promise { - if (this.methods.size && !this.methods.has('lookup_invoice')) return null; - try { - return await this.lookup(request); - } catch { - return null; - } - } -} diff --git a/src/servers/sidecar/wallet/backends/onchain.ts b/src/servers/sidecar/wallet/backends/onchain.ts deleted file mode 100644 index 1f86e329..00000000 --- a/src/servers/sidecar/wallet/backends/onchain.ts +++ /dev/null @@ -1,822 +0,0 @@ -// The native on-chain wallet backend. -// -// This is the server-side replacement for Zeus's two embedded backends. EmbeddedLND -// (backends/EmbeddedLND.ts) and LdkNode (backends/LdkNode.ts) are thin JS shims over React Native -// native modules — `lndmobile`, `ldk-node-rn` — that bundle an actual node into the app process. -// Neither exists off a phone, so neither can be ported into a Bun sidecar. Instead this backend is a -// real wallet in its own right: it derives addresses from a BIP32 account xpub, reads the chain through -// an injected WalletChainSource (chain-source.ts), and builds/signs its own transactions with -// bitcoinjs-lib (psbt.ts). -// -// It owns the KEYS and the MONEY: derivation, the snapshot cache, coin selection, PSBT construction, -// signing. It owns no HTTP at all. Which indexer answers — a public Esplora, the owner's own node behind -// NBXplorer — is a constructor argument, and nothing below this line knows the difference. -// -// WATCH-ONLY WHILE LOCKED is the load-bearing design property. Everything a UI polls — -// getInfo / getBalances / getTransactions / getNewAddress / getUtxos / estimateFees — is derived from -// the account XPUB and public chain data, so it works with the wallet locked and no key material in -// memory. `signer.withRoot` is reached from exactly two methods, sendCoins and signMessage, and both -// fail fast with WalletLockedError before doing any work. Nothing else in this file touches the signer. -// -// There is no lightning here at all: the capability set omits every lightning flag, so invoices, -// payments, channels and peers all fall through to BaseBackend's 501. - -import type { WalletChainSnapshot } from 'officerdb'; -import { HDKey } from '@scure/bip32'; -import * as bitcoin from 'bitcoinjs-lib'; -import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; -import * as ecc from '@bitcoinerlab/secp256k1'; -import { - mapLimit, - MAX_SCAN_INDEX, - REQUEST_CONCURRENCY, - type AddressEntry, - type ChainIndex, - type ScanContext, - type ScannedAddress, - type WalletChainSource, -} from '../chain-source'; -import { - buildPsbt, - coinTypeFor, - initEcc, - networkFor, - outputScriptFor, - scriptType, - selectCoins, - signAndFinalize, - type PsbtInputSource, - type PsbtOutputSpec, - type SpendableUtxo, -} from '../psbt'; -import { - BackendError, - WalletLockedError, - type AddressType, - type Balances, - type BackendKind, - type BitcoinNetwork, - type Capability, - type FeeEstimates, - type NewAddressRequest, - type NodeInfo, - type OnchainTx, - type RescanOptions, - type RescanState, - type SendCoinsRequest, - type SendCoinsResult, - type SignMessageResult, - type SyncState, - type Utxo, - type VerifyMessageResult, -} from '../types'; -import { BaseBackend } from './base'; - -// ── the signer boundary ────────────────────────────────────────────────────────────────────────── - -/** - * The wallet's key custodian, implemented by keys.ts and injected here. - * - * This backend never sees a seed, a mnemonic, a passphrase or a file path. It receives a root HDKey for - * the duration of one synchronous callback and nothing more, which is what keeps the watch-only path - * genuinely key-free rather than key-free by convention. - * - * `withRoot` MUST throw `WalletLockedError` when the wallet is locked, and MUST NOT be async — an async - * borrow would pin the root in memory across arbitrary awaits. - */ -export interface WalletSigner { - isUnlocked(): boolean; - withRoot(fn: (root: HDKey) => T): T; -} - -// ── the persistence boundary ───────────────────────────────────────────────────────────────────── - -/** - * Durable storage for the last snapshot this backend read off the chain. Injected the same way the - * signer is, and for the same reason: this file stays a pure wallet with no idea which database it is - * attached to, or that it has a wallet id at all. resolve.ts binds the real implementation. - * - * Every method is best-effort by contract. A cache that is unreachable, corrupt or empty must degrade - * to today's behaviour — a slow first load — and must never fail a wallet request. - */ -export interface ChainCacheStore { - load(): Promise<{ snapshot: WalletChainSnapshot | null; syncedAt: Date | null; lastError: string | null } | null>; - save(snapshot: WalletChainSnapshot): Promise; - recordError(message: string): Promise; -} - -// ── extended key parsing ───────────────────────────────────────────────────────────────────────── - -/** - * SLIP-132 version bytes. @scure/bip32 refuses an extended key whose version does not match the - * `versions` it was handed, so the prefix has to be recognised before the key can be imported. Wallets - * export the purpose-tagged forms (zpub for BIP84, ypub for BIP49, vpub/upub on testnet) as often as - * they export a plain xpub, and all of them are the same key with different four leading bytes. - */ -const XPUB_VERSIONS: Record = { - xpub: { public: 0x0488b21e, private: 0x0488ade4 }, - ypub: { public: 0x049d7cb2, private: 0x049d7878 }, - zpub: { public: 0x04b24746, private: 0x04b2430c }, - tpub: { public: 0x043587cf, private: 0x04358394 }, - upub: { public: 0x044a5262, private: 0x044a4e28 }, - vpub: { public: 0x045f1cf6, private: 0x045f18bc }, -}; - -function parseAccountXpub(key: string): HDKey { - const prefix = key.slice(0, 4).toLowerCase(); - const versions = XPUB_VERSIONS[prefix]; - if (!versions) throw new BackendError(`unrecognised extended key prefix '${prefix}'`, 400, 'BAD_XPUB'); - try { - return HDKey.fromExtendedKey(key, versions); - } catch (err) { - throw new BackendError(`could not parse the account extended key: ${String(err)}`, 400, 'BAD_XPUB'); - } -} - -// ── derivation ─────────────────────────────────────────────────────────────────────────────────── - -/** BIP44 purpose per script type: 44' legacy, 49' wrapped segwit, 84' native segwit, 86' taproot. */ -const PURPOSE: Record = { - p2pkh: 44, - 'p2sh-p2wpkh': 49, - p2wpkh: 84, - p2tr: 86, -}; - -/** Preference order when the caller does not name a script type. Native segwit first. */ -const TYPE_PREFERENCE: readonly AddressType[] = ['p2wpkh', 'p2tr', 'p2sh-p2wpkh', 'p2pkh']; - -type Account = { - type: AddressType; - node: HDKey; - /** The extended key exactly as it was given. Wallet-level chain sources index by this string. */ - accountXpub: string; - /** Account-level path from the wallet root, e.g. `m/84'/0'/0'`. */ - basePath: string; -}; - -/** - * One coherent read of the chain: the address scan and everything derived from it, taken together. - * - * Balances, UTXOs and history used to be fetched by three separate endpoints on top of a shared scan, - * which meant the three queries a wallet screen fires on mount could each observe a different moment. - * Building them as one snapshot costs the same requests — collectUtxos only asks about funded addresses - * and collectTxs only about touched ones — and makes the whole thing a single unit to cache, serve stale - * and persist. - */ -type Snapshot = { - /** Unix ms the chain was read. Survives a restart: a restored snapshot carries its original time. */ - at: number; - tipHeight: number; - addresses: ScannedAddress[]; - utxos: SpendableUtxo[]; - txs: OnchainTx[]; -}; - -// ── tuning ─────────────────────────────────────────────────────────────────────────────────────── - -/** How long a snapshot is served without kicking off a refresh behind it. */ -const SCAN_TTL_MS = 30_000; - -const DEFAULT_TX_LIMIT = 100; - -// ── the backend ────────────────────────────────────────────────────────────────────────────────── - -export type OnchainBackendOptions = { - chain: WalletChainSource; - network: BitcoinNetwork; - /** - * The account-level extended public key. A bare string is taken as the BIP84 (p2wpkh) account; pass a - * map to enable more than one script type, e.g. `{ p2wpkh: 'zpub…', p2tr: 'xpub…' }`. - */ - accountXpub: string | Partial>; - signer: WalletSigner; - /** Omit for a backend that should hold its chain view in memory only — tests do. */ - cache?: ChainCacheStore; -}; - -export class OnchainBackend extends BaseBackend { - readonly kind: BackendKind = 'onchain'; - - protected readonly capabilities: ReadonlySet = new Set([ - 'onchainReceive', - 'onchainSend', - 'coinControl', - 'psbt', - 'sweep', - 'signMessage', - ]); - - private readonly chain: WalletChainSource; - private readonly network: BitcoinNetwork; - private readonly btcNetwork: bitcoin.Network; - private readonly signer: WalletSigner; - private readonly accounts: Map; - private readonly defaultType: AddressType; - - /** Derivation is pure EC math over a fixed xpub, so every address is derived at most once. */ - private readonly derived = new Map(); - - /** In-memory issuance high-water mark per `type:chain`, so consecutive getNewAddress calls advance. - * Deliberately not persisted: after a restart the first unused address is recomputed from the chain, - * which is correct as soon as an issued address has actually been paid. */ - private readonly issued = new Map(); - - private readonly cache: ChainCacheStore | null; - - private current: Snapshot | null = null; - private inflight: Promise | null = null; - /** Memoised so concurrent first reads do one cache load between them, not one each. */ - private hydration: Promise | null = null; - private lastError: string | null = null; - - constructor(opts: OnchainBackendOptions) { - super(); - initEcc(); - this.chain = opts.chain; - this.network = opts.network; - this.btcNetwork = networkFor(opts.network); - this.signer = opts.signer; - this.cache = opts.cache ?? null; - - const coin = coinTypeFor(opts.network); - const raw = typeof opts.accountXpub === 'string' ? { p2wpkh: opts.accountXpub } : opts.accountXpub; - - this.accounts = new Map(); - for (const type of TYPE_PREFERENCE) { - const xpub = raw[type]; - if (!xpub) continue; - this.accounts.set(type, { - type, - node: parseAccountXpub(xpub), - accountXpub: xpub, - basePath: `m/${PURPOSE[type]}'/${coin}'/0'`, - }); - } - - const first = TYPE_PREFERENCE.find((t) => this.accounts.has(t)); - if (!first) throw new BackendError('the on-chain backend needs at least one account xpub', 400, 'BAD_XPUB'); - this.defaultType = first; - } - - // ── derivation helpers ───────────────────────────────────────────────────────────────────────── - - private accountFor(type: AddressType): Account { - const account = this.accounts.get(type); - if (!account) { - const have = [...this.accounts.keys()].join(', ') || 'none'; - throw new BackendError(`no ${type} account is configured (have: ${have})`, 400, 'NO_ACCOUNT'); - } - return account; - } - - private derive(type: AddressType, chain: ChainIndex, index: number): AddressEntry { - const cacheKey = `${type}:${chain}:${index}`; - const hit = this.derived.get(cacheKey); - if (hit) return hit; - - const account = this.accountFor(type); - const node = account.node.derive(`m/${chain}/${index}`); - const pub = node.publicKey; - if (!pub) throw new BackendError(`failed to derive ${type} ${chain}/${index}`, 500); - const pubkey = Buffer.from(pub); - const network = this.btcNetwork; - - const payment = - type === 'p2wpkh' - ? bitcoin.payments.p2wpkh({ pubkey, network }) - : type === 'p2tr' - ? bitcoin.payments.p2tr({ internalPubkey: toXOnly(pubkey), network }) - : type === 'p2sh-p2wpkh' - ? bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey, network }), network }) - : bitcoin.payments.p2pkh({ pubkey, network }); - - if (!payment.address || !payment.output) throw new BackendError(`failed to build a ${type} address`, 500); - - const entry: AddressEntry = { - type, - chain, - index, - address: payment.address, - pubkeyHex: pubkey.toString('hex'), - scriptPubKeyHex: payment.output.toString('hex'), - path: `${account.basePath}/${chain}/${index}`, - relPath: `${chain}/${index}`, - }; - this.derived.set(cacheKey, entry); - return entry; - } - - // ── the snapshot: cache, refresh, persist ────────────────────────────────────────────────────── - - getSyncState(): SyncState { - return { - syncedAt: this.current?.at ?? null, - stale: this.current !== null && Date.now() - this.current.at >= SCAN_TTL_MS, - lastError: this.lastError, - canRescan: this.chain.startRescan !== undefined, - rescan: this.chain.rescanState?.() ?? null, - }; - } - - /** - * Ask the chain source to go looking for this wallet's coins from scratch. - * - * Returns as soon as the search is queued — it takes minutes, and the caller is an HTTP route. The - * refresh chained onto its completion is the part that matters: a rescan that found coins has changed - * nothing visible until the snapshot in front of it is rebuilt, and without this the owner would be - * staring at the same zero until the TTL happened to expire. - */ - async startRescan(opts: RescanOptions = {}): Promise { - const start = this.chain.startRescan?.bind(this.chain); - if (!start) this.notSupported('rescanning the chain'); - const { state, done } = start(this.scanContext().accounts, opts); - void done - .then(() => this.refresh()) - // The rescan's own failure is already on its state and reported through getSyncState; a failed - // refresh behind it lands on lastError the same way. Neither should surface as an unhandled - // rejection in the sidecar's log. - .catch(() => {}); - return state; - } - - /** - * Re-attach to a rescan that outlived this process, and put the refresh back on the end of it. - * - * Read-only with respect to the upstream: it never starts a scan, so calling it when nothing is running - * costs one request per account and answers null. - */ - async adoptRescan(): Promise { - const adopt = this.chain.adoptRescan?.bind(this.chain); - if (!adopt) return null; - const handle = await adopt(this.scanContext().accounts); - if (!handle) return null; - // The same chaining startRescan does, and for the same reason: coins recovered by a scan are invisible - // until the snapshot in front of them is rebuilt. - void handle.done.then(() => this.refresh()).catch(() => {}); - return handle.state; - } - - /** - * The wallet's view of the chain, stale-while-revalidate. - * - * A snapshot within SCAN_TTL_MS is served as-is. An older one is served IMMEDIATELY with a refresh - * started behind it — that is the property the persistence exists for: after a restart, or while the - * Esplora upstream is slow or unreachable, the screen shows the last real numbers with their age - * rather than a blank wallet. Only a wallet that has genuinely never been read blocks on the network, - * and only that case can surface an error to the caller. - * - * `fresh` opts out for the two paths where stale data would be wrong rather than merely old. - */ - private async snapshot(opts?: { fresh?: boolean }): Promise { - if (opts?.fresh) return this.refresh(); - - await this.hydrate(); - - const have = this.current; - if (have && Date.now() - have.at < SCAN_TTL_MS) return have; - - const running = this.refresh(); - if (!have) return running; - - // The refresh outlives this response. Its failure is recorded on `lastError` and reported through - // getSyncState, so it is handled here only to keep it from surfacing as an unhandled rejection. - void running.catch(() => {}); - return have; - } - - /** Load the persisted snapshot into memory. Runs at most once, and never throws. */ - private hydrate(): Promise { - this.hydration ??= (async () => { - const store = this.cache; - if (!store || this.current) return; - try { - const row = await store.load(); - if (!row) return; - this.lastError = row.lastError; - // A refresh may have completed while this load was in flight; live data always wins. A snapshot - // written by an older shape is ignored, not migrated — it is a cache, and the next refresh - // replaces it for free. - if (row.snapshot?.v === 1 && row.syncedAt && !this.current) { - this.current = this.fromPersisted(row.snapshot, row.syncedAt.getTime()); - } - } catch (err) { - // A cache failure costs a slow load, nothing more. It must not become a wallet failure. - console.error('[wallet] chain cache load failed:', err instanceof Error ? err.message : err); - } - })(); - return this.hydration; - } - - /** Read the chain. Concurrent callers share one walk rather than each starting their own. */ - private refresh(): Promise { - this.inflight ??= this.buildSnapshot().then( - (snap) => { - this.inflight = null; - this.current = snap; - this.lastError = null; - void this.persist(snap); - return snap; - }, - (err: unknown) => { - this.inflight = null; - const message = err instanceof Error ? err.message : String(err); - this.lastError = message; - void this.cache?.recordError(message).catch((e: unknown) => { - console.error('[wallet] chain cache error write failed:', e instanceof Error ? e.message : e); - }); - throw err; - }, - ); - return this.inflight; - } - - private async persist(snap: Snapshot): Promise { - if (!this.cache) return; - try { - await this.cache.save(toPersisted(snap)); - } catch (err) { - console.error('[wallet] chain cache write failed:', err instanceof Error ? err.message : err); - } - } - - /** - * Rebuild a snapshot from what was stored. Only the chain's own facts were persisted; every address, - * script and pubkey is re-derived here from the account xpub, so a restored snapshot cannot disagree - * with the wallet's actual keys. An entry naming a script type this wallet no longer has an xpub for - * is dropped rather than faked. - */ - private fromPersisted(stored: WalletChainSnapshot, at: number): Snapshot { - const addresses: ScannedAddress[] = []; - for (const a of stored.addresses) { - const entry = this.tryDerive(a.type, a.chain, a.index); - if (!entry) continue; - addresses.push({ - ...entry, - confirmedSats: a.confirmed, - unconfirmedSats: a.unconfirmed, - txCount: a.txCount, - used: a.txCount > 0, - }); - } - - const utxos: SpendableUtxo[] = []; - for (const u of stored.utxos) { - const entry = this.tryDerive(u.type, u.chain, u.index); - if (!entry) continue; - utxos.push({ - txid: u.txid, - vout: u.vout, - amountSats: u.value, - address: entry.address, - addressType: entry.type, - confirmations: u.blockHeight === null ? 0 : Math.max(0, stored.tipHeight - u.blockHeight + 1), - derivationPath: entry.path, - frozen: false, - scriptPubKeyHex: entry.scriptPubKeyHex, - pubkeyHex: entry.pubkeyHex, - }); - } - - return { at, tipHeight: stored.tipHeight, addresses, utxos, txs: stored.transactions }; - } - - /** Derive from untrusted stored coordinates — returns null rather than throwing on anything unusable. */ - private tryDerive(type: string, chain: number, index: number): AddressEntry | null { - if (!isAddressType(type) || (chain !== 0 && chain !== 1)) return null; - if (!Number.isInteger(index) || index < 0 || index >= MAX_SCAN_INDEX) return null; - if (!this.accounts.has(type)) return null; - try { - return this.derive(type, chain, index); - } catch { - return null; - } - } - - /** - * What the source is allowed to know: the account keys, which type is ours, and how to derive. - * - * `derive` is bound to this backend on purpose — a source that derived addresses itself could disagree - * with the wallet about which addresses are the wallet's, and the resulting bug would lose coins rather - * than fail loudly. - */ - private scanContext(): ScanContext { - return { - accounts: [...this.accounts.values()].map((a) => ({ type: a.type, accountXpub: a.accountXpub })), - defaultType: this.defaultType, - derive: (type, chain, index) => this.derive(type, chain, index), - }; - } - - /** One full read of the chain, stamped with the moment it was taken. */ - private async buildSnapshot(): Promise { - const scan = await this.chain.scan(this.scanContext()); - return { at: Date.now(), ...scan }; - } - - /** Drop everything after a send, so the spent coins cannot reappear from cache on the next read. */ - private invalidateScan(): void { - this.current = null; - } - - // ── watch-only reads (no key material required) ──────────────────────────────────────────────── - - async getInfo(): Promise { - // Deliberately does not force a scan: this is the cheapest liveness probe the wallet has. - const blockHeight = await this.chain.getTipHeight(); - return { - kind: 'onchain', - // An Esplora-backed wallet has no node identity, no alias and no upstream version to report. - pubkey: null, - alias: null, - version: null, - network: this.network, - blockHeight, - synced: true, - }; - } - - override async getBalances(): Promise { - const snap = await this.snapshot(); - let confirmed = 0; - let unconfirmed = 0; - for (const entry of snap.addresses) { - confirmed += entry.confirmedSats; - unconfirmed += entry.unconfirmedSats; - } - return { - onchainConfirmed: confirmed, - // Filled in by the route, which is the only layer that knows what the owner froze. - onchainFrozen: 0, - onchainUnconfirmed: unconfirmed, - // No channels exist, and null is the contract's "this backend has no lightning" value. - lightningBalance: null, - lightningInbound: null, - }; - } - - override async estimateFees(): Promise { - return this.chain.getFeeEstimates(); - } - - override async getUtxos(): Promise { - const snap = await this.snapshot(); - return snap.utxos.map((u) => ({ - txid: u.txid, - vout: u.vout, - amountSats: u.amountSats, - address: u.address, - addressType: u.addressType, - confirmations: u.confirmations, - // The public contract wants the path relative to the account xpub, not the absolute one the - // signer uses, so it is recomputed from the tail of the full path. - derivationPath: u.derivationPath.split('/').slice(-2).join('/'), - // Freezing is a policy layer above this backend; nothing on-chain marks a coin frozen. - frozen: false, - })); - } - - override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { - const type = req?.type ?? this.defaultType; - this.accountFor(type); - const entry = await this.nextUnused(type, 0, req?.peek === true); - return { address: entry.address, type }; - } - - override async getTransactions(opts?: { limit?: number }): Promise { - const snap = await this.snapshot(); - return snap.txs.slice(0, opts?.limit ?? DEFAULT_TX_LIMIT); - } - - /** History for every address the scan found had been touched. Only those can appear in it. */ - // ── spending (requires the root key) ─────────────────────────────────────────────────────────── - - override async sendCoins(req: SendCoinsRequest): Promise { - // Fail before any network work rather than after building a PSBT we cannot sign. - if (!this.signer.isUnlocked()) throw new WalletLockedError(); - if (!Number.isFinite(req.satPerVbyte) || req.satPerVbyte <= 0) { - throw new BackendError('satPerVbyte must be a positive number', 400, 'INVALID_FEE_RATE'); - } - - const recipientScript = outputScriptFor(req.address, this.btcNetwork); - const recipientType = scriptType(recipientScript); - - // Never stale. Selecting coins from a cached UTXO set would build a transaction spending outputs - // that may already be gone, and the failure would arrive as a broadcast rejection after signing. - const snap = await this.snapshot({ fresh: true }); - - // The snapshot comes off the chain, which knows nothing about freezing, so every UTXO in it claims - // `frozen: false`. selectCoins has always honoured the flag — nothing ever set it, which is what made - // a plain send happily spend a coin the owner had frozen, and made "send everything" sweep it. - const frozen = new Set(req.frozenOutpoints ?? []); - const utxos = frozen.size - ? snap.utxos.map((u) => (frozen.has(`${u.txid}:${u.vout}`) ? { ...u, frozen: true } : u)) - : snap.utxos; - - const selection = selectCoins({ - utxos, - targetSats: req.amountSats ?? 0, - sendAll: req.sendAll === true, - satPerVbyte: req.satPerVbyte, - recipientType, - changeType: this.defaultType, - outpoints: req.outpoints, - spendUnconfirmed: req.spendUnconfirmed, - }); - - // Legacy inputs commit to the whole previous transaction, so it has to be fetched; segwit inputs - // carry their own value and script in the PSBT and need nothing extra. - const inputs = await mapLimit(selection.inputs, REQUEST_CONCURRENCY, async (u) => ({ - txid: u.txid, - vout: u.vout, - amountSats: u.amountSats, - addressType: u.addressType, - scriptPubKeyHex: u.scriptPubKeyHex, - pubkeyHex: u.pubkeyHex, - derivationPath: u.derivationPath, - prevTxHex: u.addressType === 'p2pkh' ? await this.chain.getTxHex(u.txid) : undefined, - })); - - const outputs: PsbtOutputSpec[] = [{ address: req.address, amountSats: selection.outputSats }]; - if (selection.changeSats !== null) { - const change = await this.nextUnused(this.defaultType, 1, false); - outputs.push({ address: change.address, amountSats: selection.changeSats }); - // Change-is-always-last is a well-known chain-analysis heuristic. One shuffle removes it. - if (Math.random() < 0.5) outputs.reverse(); - } - - const { psbt, inputPaths } = buildPsbt({ - network: this.btcNetwork, - inputs, - outputs, - rbf: req.rbf, - }); - - // The only place a root key enters this file. Synchronous, so the key is not held across an await. - const signed = this.signer.withRoot((root) => signAndFinalize(psbt, root, inputPaths)); - - const txid = await this.chain.broadcast(signed.rawHex); - this.invalidateScan(); - return { txid, feeSats: signed.feeSats, rawHex: signed.rawHex }; - } - - override async signMessage(message: string): Promise { - if (!this.signer.isUnlocked()) throw new WalletLockedError(); - // BIP137-style: the identity key is the first receive key of the default account, which is what - // every other wallet that signs with a derived key uses. - const entry = this.derive(this.defaultType, 0, 0); - const hash = bitcoinMessageHash(message, this.btcNetwork); - - const signature = this.signer.withRoot((root) => { - const node = root.derive(entry.path.replace(/[hH]/g, "'")); - const priv = node.privateKey; - if (!priv) throw new BackendError('derived node has no private key', 500); - const { signature: sig, recoveryId } = ecc.signRecoverable(hash, priv); - // Header byte: 27 + recovery id, +4 because the key is compressed. - const header = Buffer.from([27 + recoveryId + 4]); - return Buffer.concat([header, Buffer.from(sig)]).toString('base64'); - }); - - return { signature }; - } - - /** - * Verification needs no key material — only the xpub — so it stays available while locked. - * - * The contract takes no claimed address, and a recoverable signature *always* recovers some pubkey, - * so "did this recover" is not a meaningful answer: a tampered message would still report valid. - * `valid` therefore means "this wallet signed this message" — the recovered key is compared against - * the identity key `signMessage` uses. The recovered pubkey is returned either way, so a caller - * verifying a third party's signature can do its own comparison. - */ - override async verifyMessage(message: string, signature: string): Promise { - const sig = Buffer.from(signature, 'base64'); - if (sig.length !== 65) return { valid: false, pubkey: null }; - - const header = sig[0]; - if (header === undefined || header < 27 || header > 42) return { valid: false, pubkey: null }; - const recoveryId = ((header - 27) & 3) as 0 | 1 | 2 | 3; - const compressed = ((header - 27) & 4) !== 0; - const compact = sig.subarray(1); - const hash = bitcoinMessageHash(message, this.btcNetwork); - - try { - const recovered = ecc.recover(hash, compact, recoveryId, compressed); - if (!recovered || !ecc.verify(hash, recovered, compact)) return { valid: false, pubkey: null }; - const pubkey = Buffer.from(recovered).toString('hex'); - return { valid: pubkey === this.derive(this.defaultType, 0, 0).pubkeyHex, pubkey }; - } catch { - return { valid: false, pubkey: null }; - } - } - - // ── shared internals ─────────────────────────────────────────────────────────────────────────── - - /** Fetch UTXOs for every scanned address that still holds a balance. */ - /** - * First address on a chain that the blockchain has never seen, at or beyond the in-memory issuance - * mark. `peek` reads without consuming; otherwise the mark advances so the next call hands out a - * different address even before the current one is paid. - */ - private async nextUnused(type: AddressType, chain: ChainIndex, peek: boolean): Promise { - // Deliberately never served from cache. Handing out an address whose stale record says "unused" but - // which has been paid since is silent address reuse — a privacy leak the owner cannot see and cannot - // undo. Failing loudly while the upstream is down is the better of the two, so this is the one read - // that stops working offline, and on purpose. - const snap = await this.snapshot({ fresh: true }); - const key = `${type}:${chain}`; - const hint = this.issued.get(key) ?? 0; - - const used = new Set(); - let highest = -1; - for (const entry of snap.addresses) { - if (entry.type !== type || entry.chain !== chain) continue; - highest = Math.max(highest, entry.index); - if (entry.used) used.add(entry.index); - } - - let index = hint; - // Anything past the scanned window is unused by definition — the gap limit is what ended the scan. - while (index <= highest && used.has(index)) index++; - - // ...but "unused" is not the same as "findable". The mark advances on every issuance, paid or not, - // so twenty unpaid addresses walk it past the end of the window the next scan covers, and a payment - // to the twenty-first would never be seen again. Rather than hand out an address the wallet could - // lose money on, stop and re-offer the last safe one: every index in that run is still virgin, so - // repeating one is not address reuse in the sense that matters — nothing was ever received on it. - // The floor of 0 covers a wallet the chain has never seen: Esplora short-circuits an account with no - // history and returns no addresses at all, leaving `highest` at -1, and index 0 is then the only one - // it will look at. Handing that out until it is paid is exactly right. - const ceiling = Math.max(0, highest + this.chain.issueAhead); - if (index > ceiling) index = ceiling; - - if (!peek) this.issued.set(key, index + 1); - return this.derive(type, chain, index); - } -} - -// ── helpers ────────────────────────────────────────────────────────────────────────────────────── - -function isAddressType(value: string): value is AddressType { - return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh'; -} - -/** - * Reduce a snapshot to the chain facts worth storing. Addresses, scripts and pubkeys are dropped: they - * are pure derivation from the account xpub, so re-deriving them on load is both cheaper than storing - * them and the only way a restored snapshot is guaranteed to match the wallet's real keys. - */ -function toPersisted(snap: Snapshot): WalletChainSnapshot { - return { - v: 1, - tipHeight: snap.tipHeight, - addresses: snap.addresses.map((a) => ({ - type: a.type, - chain: a.chain, - index: a.index, - confirmed: a.confirmedSats, - unconfirmed: a.unconfirmedSats, - txCount: a.txCount, - })), - utxos: snap.utxos.map((u) => { - // The last two path elements are the BIP44 chain and address index — the same tail getUtxos - // reports as the relative path. - const tail = u.derivationPath.split('/'); - return { - txid: u.txid, - vout: u.vout, - value: u.amountSats, - type: u.addressType, - chain: Number(tail.at(-2)), - index: Number(tail.at(-1)), - // Stored as a height, not a count, so confirmations stay correct as the tip moves on. - blockHeight: u.confirmations > 0 ? snap.tipHeight - u.confirmations + 1 : null, - }; - }), - transactions: snap.txs, - }; -} - -/** Varint, for the length prefix in the Bitcoin signed-message preimage. */ -function varint(n: number): Buffer { - if (n < 0xfd) return Buffer.from([n]); - if (n <= 0xffff) { - const b = Buffer.alloc(3); - b[0] = 0xfd; - b.writeUInt16LE(n, 1); - return b; - } - const b = Buffer.alloc(5); - b[0] = 0xfe; - b.writeUInt32LE(n, 1); - return b; -} - -/** sha256d(messagePrefix || varint(len) || message) — the standard signed-message preimage. */ -function bitcoinMessageHash(message: string, network: bitcoin.Network): Buffer { - const prefix = Buffer.isBuffer(network.messagePrefix) - ? network.messagePrefix - : Buffer.from(network.messagePrefix, 'utf8'); - const body = Buffer.from(message, 'utf8'); - return bitcoin.crypto.hash256(Buffer.concat([prefix, varint(body.length), body])); -} diff --git a/src/servers/sidecar/wallet/bolt11.test.ts b/src/servers/sidecar/wallet/bolt11.test.ts deleted file mode 100644 index d6219013..00000000 --- a/src/servers/sidecar/wallet/bolt11.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -// Vectors: the BOLT 11 spec examples (lightning/bolts, 11-payment-encoding.md § Examples), plus the -// regtest and signet invoices Zeus uses in its own utils/Bolt11Utils.test.ts. Every spec invoice below is -// signed with priv_key e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734. - -import { describe, expect, it } from 'bun:test'; -import { clearBolt11Cache, decodeBolt11, decodeBolt11Invoice } from './bolt11'; -import { BackendError } from './types'; - -// ── spec vectors ───────────────────────────────────────────────────────────────────────────────── - -/** no amount, description "Please consider supporting this project" */ -const NO_AMOUNT = - 'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql'; - -/** 2500u, description "1 cup coffee", expiry 60 */ -const COFFEE_250U = - 'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh'; - -/** 2500u, UTF-8 description, expiry 60 */ -const NONSENSE_250U = - 'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr'; - -/** 20m, description_hash instead of a description */ -const HASHED_20M = - 'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44'; - -/** the same on testnet, with a P2PKH fallback address */ -const TESTNET_20M_FALLBACK = - 'lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8'; - -/** 20m with a fallback address and a two-hop route hint */ -const MAINNET_20M_ROUTES = - 'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0'; - -/** 9678785340p — a msat amount that is not a whole number of sats */ -const PICO_9678785340P = - 'lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz'; - -/** 25m advertising features 8, 14 and 99 */ -const FEATURES_25M = - 'lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa'; - -/** the same invoice in the all-uppercase QR form */ -const FEATURES_25M_UPPER = - 'LNBC25M1PVJLUEZPP5QQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQYPQDQ5VDHKVEN9V5SXYETPDEESSP5ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYGS9Q5SQQQQQQQQQQQQQQQQSGQ2A25DXL5HRNTDTN6ZVYDT7D66HYZSYHQS4WDYNAVYS42XGL6SGX9C4G7ME86A27T07MDTFRY458RTJR0V92CNMSWPSJSCGT2VCSE3SGPZ3UAPA'; - -/** 10m carrying payment metadata 0x01fafaf0 */ -const METADATA_10M = - 'lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc'; - -/** a valid invoice whose signature is high-S */ -const HIGH_S_SIGNATURE = - 'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap2r09nt4ndd0unm3z9u5t48y6ucv4r5sg7lk98c77ctvjczkspk5qprc90gx'; - -/** a high-S signature that does not match the invoice's own `n` payee field */ -const PAYEE_MISMATCH = - 'lnbc25m1p70xwfzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaqnp4q0n326hr8v9zprg8gsvezcch06gfaqqhde2aj730yg0durunfhv66sp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsp5cfzp9ugllvk03rltd6hvndxj26ux6gcxc5azyxk060rj9tzghct5zvjlps76gx8wpq5yuu79688k8gnm2c0al6v608s96l0xzrrlqqwnzxmu'; - -/** bech32 checksum is invalid */ -const BAD_CHECKSUM = - 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt'; - -/** signature is not recoverable */ -const UNRECOVERABLE_SIGNATURE = - 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqwgt7mcn5yqw3yx0w94pswkpq6j9uh6xfqqqtsk4tnarugeektd4hg5975x9am52rz4qskukxdmjemg92vvqz8nvmsye63r5ykel43pgz7zq0g2'; - -/** string is too short */ -const TOO_SHORT = - 'lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh'; - -/** invalid amount multiplier */ -const BAD_MULTIPLIER = - 'lnbc2500x1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqrrzc4cvfue4zp3hggxp47ag7xnrlr8vgcmkjxk3j5jqethnumgkpqp23z9jclu3v0a7e0aruz366e9wqdykw6dxhdzcjjhldxq0w6wgqcnu43j'; - -/** invalid sub-millisatoshi precision */ -const SUB_MSAT_PRECISION = - 'lnbc2500000001p1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uv0m3umg28gclm5lqxtqqwk32uuk4k6673k6n5kfvx3d2h8s295fad45fdhmusm8sjudfhlf6dcsxmfvkeywmjdkxcp99202x'; - -// ── Zeus fixtures (utils/Bolt11Utils.test.ts) ──────────────────────────────────────────────────── - -/** regtest, 1230n = 123 sat, no expiry field */ -const REGTEST_1230N = - 'lnbcrt1230n1pj429x7pp57t97q4awqj3f529snr0pa6senk83sq5pp760qf5a4jzvd7xgwcksdqqcqzzsxqrrsssp57eqtv7vxr46arupna3w4ct0lkf2mqmz9wt044cwkks0rwlnhfr5s9qyyssqragwpwav7nfwv2xyuuamxxj4pnnpzv2hlw7j473repd3sq7st698ta9kmzmygt0w7tmncl56a6mnma0w7e5dlpqd0wy6x3v35rssldspjhh8p0'; - -/** signet, 567780n = 56778 sat */ -const SIGNET_567780N = - 'lntbs567780n1pnqr26ypp5c0wcrpzwxwqnwu2nld5q36dfc9yjrfdp87nn9d5y093jjncvqresdq0w3jhxar8v3n8xeccqzpuxqrrsssp5r94e3nwnw63gjaxc8wex38ufv2m6442vnrw49m7dad9jdum3tdsq9qyyssqk4dvvuk7zhhju8ztf7nfc2hzqq9gqtzuyc0ljz8nl93laxwv4869lt9fsxkxacje6eh4ur5ymg83hvakn4tfpzdu6fq49705sar7fxspga8qjp'; - -/** Every spec vector shares this payee, payment hash and timestamp. */ -const SPEC_PAYEE = '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad'; -const SPEC_PAYMENT_HASH = '0001020304050607080900010203040506070809000102030405060708090102'; -const SPEC_TIMESTAMP = 1496314658; -// ── amounts ────────────────────────────────────────────────────────────────────────────────────── - -describe('decodeBolt11 amounts', () => { - it('returns null for a zero-amount invoice', () => { - expect(decodeBolt11(NO_AMOUNT).amountMsat).toBeNull(); - }); - - it('parses every multiplier as an exact msat string', () => { - // 20m = 0.02 BTC, 2500u = 0.0025 BTC, 1230n = 123 sat, 9678785340p = 967878534 msat. - expect(decodeBolt11(HASHED_20M).amountMsat).toBe('2000000000'); - expect(decodeBolt11(COFFEE_250U).amountMsat).toBe('250000000'); - expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000'); - expect(decodeBolt11(PICO_9678785340P).amountMsat).toBe('967878534'); - }); - - it('keeps amountMsat a string and never widens it to a number', () => { - const amount = decodeBolt11(PICO_9678785340P).amountMsat; - expect(typeof amount).toBe('string'); - // A pico amount need not be a whole sat: the msat string is exact where `satoshis` cannot be. - expect(decodeBolt11Invoice(PICO_9678785340P).satoshis).toBeNull(); - expect(decodeBolt11Invoice(REGTEST_1230N).satoshis).toBe(123); - }); - - it('rejects an unknown multiplier', () => { - expect(() => decodeBolt11(BAD_MULTIPLIER)).toThrow(BackendError); - }); - - it('rejects sub-millisatoshi precision instead of rounding it', () => { - expect(() => decodeBolt11(SUB_MSAT_PRECISION)).toThrow(BackendError); - }); -}); - -// ── fields ─────────────────────────────────────────────────────────────────────────────────────── - -describe('decodeBolt11 fields', () => { - it('extracts the payment hash, destination and timestamp', () => { - const decoded = decodeBolt11(NO_AMOUNT); - expect(decoded.paymentHash).toBe(SPEC_PAYMENT_HASH); - expect(decoded.destination).toBe(SPEC_PAYEE); - expect(decoded.timestamp).toBe(SPEC_TIMESTAMP); - }); - - it('recovers a key from a high-S signature instead of rejecting it', () => { - // The spec's high-S vector is the donation invoice re-signed with s negated while keeping recovery - // flag 1, so it recovers a *different* (valid) key. What matters is that recovery runs at all — - // implementations that enforce low-S during recovery fail here. - expect(decodeBolt11(HIGH_S_SIGNATURE).destination).toMatch(/^0[23][0-9a-f]{64}$/); - expect(decodeBolt11(HIGH_S_SIGNATURE).destination).not.toBe(SPEC_PAYEE); - expect(decodeBolt11(HIGH_S_SIGNATURE).paymentHash).toBe(SPEC_PAYMENT_HASH); - }); - - it('returns the description for a `d` invoice and null for an `h` one', () => { - expect(decodeBolt11(NO_AMOUNT).description).toBe('Please consider supporting this project'); - expect(decodeBolt11(COFFEE_250U).description).toBe('1 cup coffee'); - expect(decodeBolt11(NONSENSE_250U).description).toBe('ナンセンス 1杯'); - - const hashed = decodeBolt11Invoice(HASHED_20M); - expect(decodeBolt11(HASHED_20M).description).toBeNull(); - expect(hashed.descriptionHash).toBe('3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1'); - }); - - it('defaults expiry to 3600 when there is no `x` field', () => { - expect(decodeBolt11(NO_AMOUNT).expiry).toBe(3600); - expect(decodeBolt11Invoice(NO_AMOUNT).expiry).toBeNull(); - expect(decodeBolt11(REGTEST_1230N).expiry).toBe(3600); - }); - - it('reads an explicit expiry', () => { - expect(decodeBolt11(COFFEE_250U).expiry).toBe(60); - expect(decodeBolt11(PICO_9678785340P).expiry).toBe(604800); - expect(decodeBolt11Invoice(COFFEE_250U).expiresAt).toBe(SPEC_TIMESTAMP + 60); - }); - - it('expands the feature bitmap to the indices of the set bits', () => { - expect(decodeBolt11(NO_AMOUNT).features).toEqual(['8', '14']); - expect(decodeBolt11(FEATURES_25M).features).toEqual(['8', '14', '99']); - }); - - it('reports route hints and parses their hops', () => { - expect(decodeBolt11(NO_AMOUNT).routeHints).toBe(false); - - const decoded = decodeBolt11Invoice(MAINNET_20M_ROUTES); - expect(decodeBolt11(MAINNET_20M_ROUTES).routeHints).toBe(true); - expect(decoded.routes).toHaveLength(1); - expect(decoded.routes[0]).toEqual([ - { - pubkey: '029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255', - shortChannelId: '66051x263430x1800', - feeBaseMsat: 1, - feeProportionalMillionths: 20, - cltvExpiryDelta: 3, - }, - { - pubkey: '039e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255', - shortChannelId: '197637x395016x2314', - feeBaseMsat: 2, - feeProportionalMillionths: 30, - cltvExpiryDelta: 4, - }, - ]); - }); - - it('parses payment secret, metadata and fallback addresses', () => { - expect(decodeBolt11Invoice(NO_AMOUNT).paymentSecret).toBe( - '1111111111111111111111111111111111111111111111111111111111111111', - ); - expect(decodeBolt11Invoice(METADATA_10M).metadata).toBe('01fafaf0'); - - const fallback = decodeBolt11Invoice(TESTNET_20M_FALLBACK).fallbacks[0]; - // 17 is the P2PKH marker; the program is the 20-byte hash160 of mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP. - expect(fallback?.version).toBe(17); - expect(fallback?.programHex).toHaveLength(40); - }); - - it('leaves cltvExpiry null when the invoice omits `c`', () => { - expect(decodeBolt11(NO_AMOUNT).cltvExpiry).toBeNull(); - }); -}); - -// ── networks ───────────────────────────────────────────────────────────────────────────────────── - -describe('decodeBolt11 networks', () => { - it('resolves every bech32 network prefix', () => { - expect(decodeBolt11Invoice(NO_AMOUNT).network).toBe('bitcoin'); - expect(decodeBolt11Invoice(TESTNET_20M_FALLBACK).network).toBe('testnet'); - expect(decodeBolt11Invoice(SIGNET_567780N).network).toBe('signet'); - expect(decodeBolt11Invoice(REGTEST_1230N).network).toBe('regtest'); - }); - - it('parses the amount out of a multi-letter network prefix', () => { - // `lnbcrt1230n` and `lntbs567780n` are the cases where a greedy hrp match eats the amount. - expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000'); - expect(decodeBolt11(SIGNET_567780N).amountMsat).toBe('56778000'); - }); - - it('rejects an unknown network prefix', () => { - expect(() => decodeBolt11(NO_AMOUNT.replace(/^lnbc/, 'lnxyz'))).toThrow(BackendError); - }); -}); - -// ── malformed input ────────────────────────────────────────────────────────────────────────────── - -describe('decodeBolt11 rejections', () => { - const expectBadInvoice = (invoice: string) => { - let caught: unknown; - try { - decodeBolt11(invoice); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(BackendError); - expect((caught as BackendError).status).toBe(400); - expect((caught as BackendError).code).toBe('BAD_INVOICE'); - }; - - it('throws BackendError(400, BAD_INVOICE) on a corrupted checksum', () => { - expectBadInvoice(BAD_CHECKSUM); - // The same invoice with one data character flipped. - expectBadInvoice(`${COFFEE_250U.slice(0, -1)}${COFFEE_250U.endsWith('h') ? 'w' : 'h'}`); - }); - - it('throws on a signature that cannot be recovered', () => { - expectBadInvoice(UNRECOVERABLE_SIGNATURE); - }); - - it('throws when the recovered key contradicts an explicit `n` payee field', () => { - expectBadInvoice(PAYEE_MISMATCH); - }); - - it('throws on a truncated payment request', () => { - expectBadInvoice(TOO_SHORT); - }); - - it('throws on input that is not a payment request at all', () => { - expectBadInvoice(''); - expectBadInvoice('not an invoice'); - expectBadInvoice(NO_AMOUNT.replace(/^ln/, '')); - }); - - it('throws on a mixed-case payment request but accepts the uppercase QR form', () => { - expectBadInvoice(`LNBC${NO_AMOUNT.slice(4)}`); - expect(decodeBolt11(FEATURES_25M_UPPER)).toEqual(decodeBolt11(FEATURES_25M)); - // The contract's `bolt11` is always the canonical lowercase form. - expect(decodeBolt11(FEATURES_25M_UPPER).bolt11).toBe(FEATURES_25M); - }); -}); - -// ── cache ──────────────────────────────────────────────────────────────────────────────────────── - -describe('the LRU cache', () => { - it('returns the same object for a repeat decode and survives a clear', () => { - clearBolt11Cache(); - const first = decodeBolt11Invoice(COFFEE_250U); - expect(decodeBolt11Invoice(COFFEE_250U)).toBe(first); - expect(decodeBolt11Invoice(COFFEE_250U.toUpperCase())).toBe(first); - - clearBolt11Cache(); - const afterClear = decodeBolt11Invoice(COFFEE_250U); - expect(afterClear).not.toBe(first); - expect(afterClear).toEqual(first); - }); - - it('hands every decodeBolt11 caller its own object', () => { - const first = decodeBolt11(COFFEE_250U); - const second = decodeBolt11(COFFEE_250U); - expect(second).not.toBe(first); - expect(second).toEqual(first); - }); -}); diff --git a/src/servers/sidecar/wallet/bolt11.ts b/src/servers/sidecar/wallet/bolt11.ts deleted file mode 100644 index 43f4cc8b..00000000 --- a/src/servers/sidecar/wallet/bolt11.ts +++ /dev/null @@ -1,516 +0,0 @@ -// BOLT11 invoice decoding, in-process — no node round-trip. -// -// Ported from Zeus's `utils/Bolt11Utils.ts`, which is itself derived from **light-bolt11-decoder**: -// Copyright (c) 2021 bitcoinjs contributors, fiatjaf. MIT licence. -// The bech32 walk, the human-readable-part amount grammar and the signature preimage construction are -// that upstream's work and are preserved verbatim in behaviour. -// -// INTENTIONAL DIFFERENCES from Zeus's port: -// -// 1. ERRORS. Zeus mixes `throw new Error(...)` with silent `undefined` returns (a bad multiplier sets -// `satoshis = null` and carries on; a `fromWordsUnsafe` failure yields `undefined`). Every failure -// here is a `BackendError(msg, 400, 'BAD_INVOICE')`, so routes.ts answers 400 rather than leaking a -// half-decoded invoice with null amounts into a payment flow. -// 2. NO bignumber.js. All amount arithmetic is BigInt: msat tops out at 2.1e18, which a double cannot -// hold, and BigInt does the same job exactly with one less dependency. -// 3. NO @noble/hashes. The single sha256 comes from `node:crypto`, which Bun provides natively. -// 4. EAGER signature recovery. Zeus installs lazy getters for `destination`/`signature` because its -// Activity list decodes hundreds of invoices to read only the timestamp. Here `DecodedInvoice` -// requires a non-null `destination` on every call, so laziness would never pay off; the LRU cache -// (which is what actually collapses repeat recoveries) is kept and sized for a server. -// 5. NO `sections` array. Zeus keeps light-bolt11-decoder's positional `{name, letters, value?: any}` -// list for back-compat. Nothing here consumes it, and it was the source of the `any`. Every tag is -// exposed as a named, typed field instead, with anything unrecognised in `unknownTags`. -// 6. MORE TAGS PARSED. Zeus leaves `r` (route hints), `9` (features) and `f` (fallback address) as -// unknown tags. All three are parsed, because the contract's `routeHints` and `features` fields need -// them. Feature bits are expanded to the indices of the set bits, matching how backends/clnrest.ts -// renders CLN's bitmap. -// 7. SPEC-CONFORMANT LENIENCY/STRICTNESS. Known tags whose `data_length` is wrong are skipped and -// unknown tags ignored (BOLT 11 requires both). An all-uppercase invoice — the QR form — is accepted -// and lowercased; a MIXED-case one is rejected, as the spec requires and Zeus's blanket -// `.toLowerCase()` did not. An explicit `n` payee is checked against the recovered key rather than -// trusted blindly, which is the check Zeus skips. -// 8. NO SIMNET. Zeus carries the btcd-only `sb` prefix; `BitcoinNetwork` has no member for it. - -import { createHash } from 'node:crypto'; -import { bech32 } from 'bech32'; -import { recoverPublicKey } from '@noble/secp256k1'; -import { BackendError, type BitcoinNetwork, type DecodedInvoice } from './types'; - -// ── shape ──────────────────────────────────────────────────────────────────────────────────────── - -export type Bolt11NetworkParams = { - /** The bech32 human-readable part that follows `ln`. */ - readonly bech32: string; - readonly network: BitcoinNetwork; - readonly pubKeyHash: number; - readonly scriptHash: number; - readonly validWitnessVersions: readonly number[]; -}; - -/** One hop of an `r` field. Multiple `r` fields each describe a separate route. */ -export type Bolt11RouteHop = { - readonly pubkey: string; - /** `block x tx x output`, the standard short_channel_id rendering. */ - readonly shortChannelId: string; - readonly feeBaseMsat: number; - readonly feeProportionalMillionths: number; - readonly cltvExpiryDelta: number; -}; - -/** An `f` field. Left as witness-version + program rather than re-encoded to an address string. */ -export type Bolt11Fallback = { - /** 0-16 are segwit witness versions; 17 is P2PKH and 18 is P2SH. */ - readonly version: number; - readonly programHex: string; -}; - -export type Bolt11UnknownTag = { readonly tagCode: number; readonly words: readonly number[] }; - -/** The full decode. `decodeBolt11` projects this onto the narrower `DecodedInvoice` contract. */ -export type Bolt11Invoice = { - /** The invoice, lowercased. */ - readonly paymentRequest: string; - /** The whole human-readable part, e.g. `lnbc2500u`. */ - readonly prefix: string; - readonly network: BitcoinNetwork; - readonly networkParams: Bolt11NetworkParams; - readonly timestamp: number; - /** Decimal msat string, or null for a zero-amount ("any amount") invoice. Never a number. */ - readonly millisatoshis: string | null; - /** Convenience only, and null whenever the amount is not a whole number of sats. */ - readonly satoshis: number | null; - readonly paymentHash: string; - readonly paymentSecret: string | null; - readonly description: string | null; - readonly descriptionHash: string | null; - /** The explicit `n` payee field, when the invoice carries one. */ - readonly payee: string | null; - /** `payee` when present, else recovered from the signature. 33-byte compressed pubkey, hex. */ - readonly destination: string; - /** The raw `x` field; null when absent. `expirySeconds` applies the spec's 3600 default. */ - readonly expiry: number | null; - readonly expirySeconds: number; - readonly expiresAt: number; - readonly cltvExpiry: number | null; - readonly metadata: string | null; - /** Indices of the set feature bits, as decimal strings. */ - readonly featureBits: readonly string[]; - readonly routes: readonly (readonly Bolt11RouteHop[])[]; - readonly fallbacks: readonly Bolt11Fallback[]; - /** 64-byte compact signature, hex. */ - readonly signature: string; - readonly recoveryFlag: number; - readonly unknownTags: readonly Bolt11UnknownTag[]; -}; - -// ── constants ──────────────────────────────────────────────────────────────────────────────────── - -/** - * Zeus caches 256 entries for a phone's Activity list. A server decodes the same invoice from several - * call sites (list → detail → pay), so the cache earns its keep the same way; 1024 entries of a few - * hundred bytes each is a rounding error against a Bun heap. - */ -const CACHE_LIMIT = 1024; -const cache = new Map(); - -const MSAT_PER_BTC = 100_000_000_000n; -const MAX_MSAT = 2_100_000_000_000_000_000n; -const DIVISORS: Readonly> = { - m: 1_000n, - u: 1_000_000n, - n: 1_000_000_000n, - p: 1_000_000_000_000n, -}; - -const NETWORKS: readonly Bolt11NetworkParams[] = [ - { bech32: 'bc', network: 'bitcoin', pubKeyHash: 0x00, scriptHash: 0x05, validWitnessVersions: [0, 1] }, - { bech32: 'tb', network: 'testnet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] }, - { bech32: 'tbs', network: 'signet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] }, - { bech32: 'bcrt', network: 'regtest', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] }, -]; - -const TAG_NAMES = { - 1: 'payment_hash', - 3: 'route_hint', - 5: 'feature_bits', - 6: 'expiry', - 9: 'fallback_address', - 13: 'description', - 16: 'payment_secret', - 19: 'payee', - 23: 'description_hash', - 24: 'min_final_cltv_expiry', - 27: 'metadata', -} as const satisfies Record; - -/** BOLT 11: a reader MUST skip a `p`, `s`, `h` or `n` field whose data_length is not the fixed size. */ -const FIXED_TAG_WORDS: Readonly> = { 1: 52, 16: 52, 23: 52, 19: 53 }; - -/** The signature occupies the last 104 words — 65 bytes: 64-byte compact sig + 1 recovery byte. */ -const SIGNATURE_WORDS = 104; -const TIMESTAMP_WORDS = 7; - -/** BOLT 11 default when no `x` field is present. */ -const DEFAULT_EXPIRY_SECONDS = 3600; - -const ROUTE_HOP_BYTES = 51; - -// ── helpers ────────────────────────────────────────────────────────────────────────────────────── - -function badInvoice(message: string): never { - throw new BackendError(`invalid bolt11 invoice: ${message}`, 400, 'BAD_INVOICE'); -} - -/** Big-endian base-32. BigInt because a hostile tag can claim far more than 53 bits. */ -function wordsToInt(words: readonly number[]): number { - let value = 0n; - for (const word of words) value = value * 32n + BigInt(word); - if (value > BigInt(Number.MAX_SAFE_INTEGER)) badInvoice('a numeric field is out of range'); - return Number(value); -} - -function wordsToBytes(words: readonly number[]): Uint8Array { - const bytes = bech32.fromWordsUnsafe(words); - if (bytes == null) badInvoice('a field is not a whole number of bytes'); - return Uint8Array.from(bytes); -} - -const wordsToHex = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('hex'); - -const wordsToUtf8 = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('utf8'); - -/** - * The `9` field is a big-endian bit vector whose LAST bit is feature 0, so a word's position from the - * end fixes the base index. Rendered as decimal strings to match backends/clnrest.ts's `featureBits`. - */ -function wordsToFeatureBits(words: readonly number[]): string[] { - const bits: string[] = []; - for (let i = words.length - 1; i >= 0; i--) { - const word = words[i] ?? 0; - const base = (words.length - 1 - i) * 5; - for (let bit = 0; bit < 5; bit++) { - if (word & (1 << bit)) bits.push(String(base + bit)); - } - } - return bits.sort((a, b) => Number(a) - Number(b)); -} - -function wordsToRoute(words: readonly number[]): Bolt11RouteHop[] { - const bytes = Buffer.from(wordsToBytes(words)); - if (bytes.length === 0 || bytes.length % ROUTE_HOP_BYTES !== 0) badInvoice('a route hint is malformed'); - - const hops: Bolt11RouteHop[] = []; - for (let offset = 0; offset < bytes.length; offset += ROUTE_HOP_BYTES) { - const hop = bytes.subarray(offset, offset + ROUTE_HOP_BYTES); - const scid = hop.subarray(33, 41); - const block = scid.readUIntBE(0, 3); - const tx = scid.readUIntBE(3, 3); - const output = scid.readUInt16BE(6); - hops.push({ - pubkey: hop.subarray(0, 33).toString('hex'), - shortChannelId: `${block}x${tx}x${output}`, - feeBaseMsat: hop.readUInt32BE(41), - feeProportionalMillionths: hop.readUInt32BE(45), - cltvExpiryDelta: hop.readUInt16BE(49), - }); - } - return hops; -} - -function wordsToFallback(words: readonly number[]): Bolt11Fallback | null { - const version = words[0]; - // A fallback with no program is meaningless; the spec says to ignore an unparseable one. - if (version === undefined || words.length < 2) return null; - return { version, programHex: wordsToHex(words.slice(1)) }; -} - -/** - * The amount grammar from the human-readable part: ``, where the multiplier - * divides one bitcoin. Integer arithmetic throughout — a `p` amount that is not a multiple of 10 would - * be sub-millisatoshi and is invalid rather than rounded. - */ -function hrpToMsat(amount: string, multiplier: string): string { - if (!/^\d+$/.test(amount)) badInvoice(`"${amount}" is not a valid amount`); - if (multiplier && !(multiplier in DIVISORS)) badInvoice(`"${multiplier}" is not a valid amount multiplier`); - - const value = BigInt(amount); - const divisor = multiplier ? DIVISORS[multiplier] : undefined; - const msat = divisor === undefined ? value * MSAT_PER_BTC : (value * MSAT_PER_BTC) / divisor; - - if (multiplier === 'p' && value % 10n !== 0n) badInvoice('amount has sub-millisatoshi precision'); - if (msat > MAX_MSAT) badInvoice('amount is outside of valid range'); - return msat.toString(); -} - -/** 5-bit → 8-bit, right-padded with zeros: the preimage the signature covers. */ -function convertBits(words: readonly number[]): Uint8Array { - let value = 0; - let bits = 0; - const result: number[] = []; - for (const word of words) { - value = (value << 5) | word; - bits += 5; - while (bits >= 8) { - bits -= 8; - result.push((value >> bits) & 0xff); - } - } - if (bits > 0) result.push((value << (8 - bits)) & 0xff); - return Uint8Array.from(result); -} - -type Recovered = { destination: string; signature: string; recoveryFlag: number }; - -/** The signed message is sha256( utf8(prefix) || convertBits(dataWords) ). SEC1 recovery from there. */ -function recoverPayee(prefix: string, sigWords: readonly number[], signedWords: readonly number[]): Recovered { - const sigBytes = wordsToBytes(sigWords); - const recoveryFlag = sigBytes[64]; - if (sigBytes.length !== 65 || recoveryFlag === undefined || recoveryFlag > 3) { - badInvoice('signature is malformed'); - } - - const preimage = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convertBits(signedWords))]); - const hash = createHash('sha256').update(preimage).digest(); - - // @noble/secp256k1 v3 wants the recovery byte FIRST; bech32 carries it last. - const recoverable = Buffer.concat([Buffer.from([recoveryFlag]), Buffer.from(sigBytes.subarray(0, 64))]); - - let pubkey: Uint8Array; - try { - pubkey = recoverPublicKey(recoverable, hash, { prehash: false }); - } catch { - badInvoice('signature is not recoverable'); - } - - return { - destination: Buffer.from(pubkey).toString('hex'), - signature: Buffer.from(sigBytes.subarray(0, 64)).toString('hex'), - recoveryFlag, - }; -} - -/** `lnbc2500u` → the network params and the amount. */ -function parsePrefix(prefix: string): { params: Bolt11NetworkParams; millisatoshis: string | null } { - // Non-greedy on the hrp so the trailing digits+multiplier win: `lnbcrt500u` → bcrt / 500 / u. A prefix - // with no amount ends in a letter the first pattern would eat, hence the amount-less second attempt. - let matches = /^ln(\S+?)(\d+)([a-z]?)$/.exec(prefix); - let amount = matches?.[2] ?? ''; - let multiplier = matches?.[3] ?? ''; - if (!matches) { - matches = /^ln(\S+)$/.exec(prefix); - amount = ''; - multiplier = ''; - } - - const hrp = matches?.[1]; - if (!hrp) badInvoice('not a lightning payment request'); - - const params = NETWORKS.find((candidate) => candidate.bech32 === hrp); - if (!params) badInvoice(`unknown network prefix "ln${hrp}"`); - - return { params, millisatoshis: amount ? hrpToMsat(amount, multiplier) : null }; -} - -type Tags = { - paymentHash: string | null; - paymentSecret: string | null; - description: string | null; - descriptionHash: string | null; - payee: string | null; - expiry: number | null; - cltvExpiry: number | null; - metadata: string | null; - featureBits: string[]; - routes: Bolt11RouteHop[][]; - fallbacks: Bolt11Fallback[]; - unknownTags: Bolt11UnknownTag[]; -}; - -/** Walks the tagged fields. First occurrence of a field wins; unknown and wrong-length fields are skipped. */ -function parseTags(dataWords: readonly number[]): Tags { - const tags: Tags = { - paymentHash: null, - paymentSecret: null, - description: null, - descriptionHash: null, - payee: null, - expiry: null, - cltvExpiry: null, - metadata: null, - featureBits: [], - routes: [], - fallbacks: [], - unknownTags: [], - }; - - let words = dataWords; - while (words.length > 0) { - const tagCode = words[0]; - if (tagCode === undefined) break; - if (words.length < 3) badInvoice('a tagged field is truncated'); - - const length = wordsToInt(words.slice(1, 3)); - words = words.slice(3); - if (length > words.length) badInvoice('a tagged field overruns the invoice'); - const tagWords = words.slice(0, length); - words = words.slice(length); - - const expected = FIXED_TAG_WORDS[tagCode]; - if (expected !== undefined && length !== expected) continue; - - const name: string | undefined = TAG_NAMES[tagCode as keyof typeof TAG_NAMES]; - switch (name) { - case 'payment_hash': - tags.paymentHash ??= wordsToHex(tagWords); - break; - case 'payment_secret': - tags.paymentSecret ??= wordsToHex(tagWords); - break; - case 'description': - tags.description ??= wordsToUtf8(tagWords); - break; - case 'description_hash': - tags.descriptionHash ??= wordsToHex(tagWords); - break; - case 'payee': - tags.payee ??= wordsToHex(tagWords); - break; - case 'expiry': - tags.expiry ??= wordsToInt(tagWords); - break; - case 'min_final_cltv_expiry': - tags.cltvExpiry ??= wordsToInt(tagWords); - break; - case 'metadata': - tags.metadata ??= wordsToHex(tagWords); - break; - case 'feature_bits': - if (tags.featureBits.length === 0) tags.featureBits = wordsToFeatureBits(tagWords); - break; - case 'route_hint': - tags.routes.push(wordsToRoute(tagWords)); - break; - case 'fallback_address': { - const fallback = wordsToFallback(tagWords); - if (fallback) tags.fallbacks.push(fallback); - break; - } - default: - tags.unknownTags.push({ tagCode, words: tagWords }); - } - } - - return tags; -} - -// ── the decoder ────────────────────────────────────────────────────────────────────────────────── - -/** - * Full decode, cached. The returned object is shared with every other caller for the same invoice and is - * typed readonly throughout — treat it as frozen. - */ -export function decodeBolt11Invoice(paymentRequest: string): Bolt11Invoice { - if (typeof paymentRequest !== 'string') badInvoice('expected a string'); - - const trimmed = paymentRequest.trim(); - // The QR form is all-uppercase and legal; a mixed-case string is not (BOLT 11 / BIP-173). - if (/[a-z]/.test(trimmed) && /[A-Z]/.test(trimmed)) badInvoice('mixed-case payment request'); - const normalized = trimmed.toLowerCase(); - - const cached = cache.get(normalized); - if (cached) { - // Re-insert to move to the MRU end of the Map's insertion order. - cache.delete(normalized); - cache.set(normalized, cached); - return cached; - } - - if (!normalized.startsWith('ln')) badInvoice('not a lightning payment request'); - - let decoded: { prefix: string; words: number[] }; - try { - decoded = bech32.decode(normalized, Number.MAX_SAFE_INTEGER); - } catch (err) { - badInvoice(err instanceof Error ? err.message.toLowerCase() : 'bech32 decode failed'); - } - - if (decoded.words.length < SIGNATURE_WORDS + TIMESTAMP_WORDS) badInvoice('payment request is too short'); - - const { params, millisatoshis } = parsePrefix(decoded.prefix); - - const signedWords = decoded.words.slice(0, -SIGNATURE_WORDS); - const sigWords = decoded.words.slice(-SIGNATURE_WORDS); - const timestamp = wordsToInt(signedWords.slice(0, TIMESTAMP_WORDS)); - const tags = parseTags(signedWords.slice(TIMESTAMP_WORDS)); - - if (!tags.paymentHash) badInvoice('no payment hash'); - - const recovered = recoverPayee(decoded.prefix, sigWords, signedWords); - // BOLT 11: when an `n` field is present a reader MUST still check the signature against it. A correct - // signer always recovers to its own key, so a mismatch means the invoice was tampered with. - if (tags.payee && tags.payee !== recovered.destination) badInvoice('signature does not match the payee'); - - const msat = millisatoshis === null ? null : BigInt(millisatoshis); - const expirySeconds = tags.expiry ?? DEFAULT_EXPIRY_SECONDS; - - const invoice: Bolt11Invoice = { - paymentRequest: normalized, - prefix: decoded.prefix, - network: params.network, - networkParams: params, - timestamp, - millisatoshis, - satoshis: msat !== null && msat % 1000n === 0n ? Number(msat / 1000n) : null, - paymentHash: tags.paymentHash, - paymentSecret: tags.paymentSecret, - description: tags.description, - descriptionHash: tags.descriptionHash, - payee: tags.payee, - destination: tags.payee ?? recovered.destination, - expiry: tags.expiry, - expirySeconds, - expiresAt: timestamp + expirySeconds, - cltvExpiry: tags.cltvExpiry, - metadata: tags.metadata, - featureBits: tags.featureBits, - routes: tags.routes, - fallbacks: tags.fallbacks, - signature: recovered.signature, - recoveryFlag: recovered.recoveryFlag, - unknownTags: tags.unknownTags, - }; - - if (cache.size >= CACHE_LIMIT) { - const oldest = cache.keys().next(); - if (!oldest.done) cache.delete(oldest.value); - } - cache.set(normalized, invoice); - - return invoice; -} - -/** - * The contract surface: a BOLT11 payment request as the backends report it. Throws - * `BackendError(…, 400, 'BAD_INVOICE')` for anything that does not decode. - */ -export function decodeBolt11(bolt11: string): DecodedInvoice { - const invoice = decodeBolt11Invoice(bolt11); - return { - bolt11: invoice.paymentRequest, - paymentHash: invoice.paymentHash, - amountMsat: invoice.millisatoshis, - description: invoice.description, - destination: invoice.destination, - timestamp: invoice.timestamp, - expiry: invoice.expirySeconds, - cltvExpiry: invoice.cltvExpiry, - routeHints: invoice.routes.length > 0, - features: [...invoice.featureBits], - }; -} - -/** Test seam: the LRU is process-global, so a test that measures recovery cost needs to reset it. */ -export function clearBolt11Cache(): void { - cache.clear(); -} diff --git a/src/servers/sidecar/wallet/chain-source-esplora.test.ts b/src/servers/sidecar/wallet/chain-source-esplora.test.ts deleted file mode 100644 index add5d585..00000000 --- a/src/servers/sidecar/wallet/chain-source-esplora.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -// Behavioural pins for the Esplora chain source, driven through a real OnchainBackend. -// -// These exist because the scan used to live inside the backend and was moved out wholesale; nothing in -// the suite covered it before, so a silent behaviour change during the move would have shipped. The stub -// below is a full stand-in for the HTTP client, which means the whole path — gap-limit walk, balance -// summation, UTXO mapping, transaction scoring, address issuance — runs with no network at all. - -import { describe, expect, test } from 'bun:test'; -import { HDKey } from '@scure/bip32'; -import { mnemonicToSeedSync } from '@scure/bip39'; -import * as bitcoin from 'bitcoinjs-lib'; -import type { EsploraAddress, EsploraChain, EsploraTx, EsploraUtxo } from './chain'; -import { EsploraChainSource } from './chain-source-esplora'; -import { OnchainBackend, type WalletSigner } from './backends/onchain'; -import { initEcc } from './psbt'; - -initEcc(); - -// NEVER PUT A REAL ACCOUNT KEY IN A TEST. An xpub cannot spend, but it reveals every address a wallet -// will ever use and its entire history, permanently and irrevocably — and a test file is the easiest -// place in a repo for one to hide, because it looks like a fixture. This one is derived here, in the -// open, from the BIP84 specification's own published mnemonic, so its provenance is checkable rather -// than asserted. If you need a different fixture, derive it the same way; do not paste one in. -const PUBLIC_TEST_MNEMONIC = - 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; - -const ACCOUNT_XPUB = HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC)).derive( - "m/84'/0'/0'", -).publicExtendedKey; - -/** Derive the same p2wpkh address the backend will, independently of the backend. */ -function addressAt(chain: 0 | 1, index: number): string { - const node = HDKey.fromExtendedKey(ACCOUNT_XPUB).deriveChild(chain).deriveChild(index); - const pubkey = Buffer.from(node.publicKey!); - return bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin }).address!; -} - -function scriptAt(chain: 0 | 1, index: number): string { - const node = HDKey.fromExtendedKey(ACCOUNT_XPUB).deriveChild(chain).deriveChild(index); - const pubkey = Buffer.from(node.publicKey!); - return bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin }).output!.toString('hex'); -} - -const EMPTY_STATS = { funded_txo_count: 0, funded_txo_sum: 0, spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0 }; - -type Funding = { confirmed?: number; spent?: number; mempool?: number; txCount?: number }; - -/** - * A stand-in for EsploraChain that serves a fixed world: a map of address → what the chain says about it, - * and everything else derived from that. Also counts requests, because the request count IS the behaviour - * under test for the gap-limit walk. - */ -class StubEsplora { - readonly calls: string[] = []; - constructor( - private readonly world: Map, - private readonly tip = 800_000, - ) {} - - async getTipHeight(): Promise { - this.calls.push('tip'); - return this.tip; - } - - async getAddress(address: string): Promise { - this.calls.push(`addr:${address}`); - const f = this.world.get(address); - if (!f) return { address, chain_stats: { ...EMPTY_STATS }, mempool_stats: { ...EMPTY_STATS } }; - return { - address, - chain_stats: { - ...EMPTY_STATS, - funded_txo_sum: f.confirmed ?? 0, - spent_txo_sum: f.spent ?? 0, - tx_count: f.txCount ?? 1, - }, - mempool_stats: { ...EMPTY_STATS, funded_txo_sum: f.mempool ?? 0, tx_count: f.mempool ? 1 : 0 }, - }; - } - - async getAddressUtxos(address: string): Promise { - this.calls.push(`utxo:${address}`); - const f = this.world.get(address); - const value = (f?.confirmed ?? 0) - (f?.spent ?? 0); - if (!f || value <= 0) return []; - return [{ txid: 'a'.repeat(64), vout: 0, value, status: { confirmed: true, block_height: this.tip - 5 } }]; - } - - async getAddressTxs(address: string): Promise { - this.calls.push(`txs:${address}`); - const f = this.world.get(address); - if (!f) return []; - const idx = [...this.world.keys()].indexOf(address); - return [ - { - txid: String(idx).padStart(64, 'b'), - version: 2, - locktime: 0, - fee: 200, - vin: [{ txid: 'c'.repeat(64), vout: 0, prevout: null, scriptsig: '', sequence: 0xffffffff, witness: [] }], - vout: [ - { scriptpubkey: scriptFor(address), scriptpubkey_asm: '', scriptpubkey_type: 'v0_p2wpkh', value: 50_000 }, - ], - status: { confirmed: true, block_height: this.tip - 5, block_time: 1_700_000_000 }, - } as unknown as EsploraTx, - ]; - } - - async getFeeEstimates() { - return { fastestFee: 10, halfHourFee: 8, hourFee: 5, economyFee: 2, minimumFee: 1 }; - } - - async getTxHex(): Promise { - throw new Error('not needed'); - } - - /** Accepts anything and reports a fixed txid — the spend tests care what got selected, not what got sent. */ - async broadcast(): Promise { - this.calls.push('broadcast'); - return 'd'.repeat(64); - } -} - -/** Reverse-lookup the script for an address the stub was configured with. */ -function scriptFor(address: string): string { - for (const chain of [0, 1] as const) { - for (let i = 0; i < 40; i++) if (addressAt(chain, i) === address) return scriptAt(chain, i); - } - return 'ff'; -} - -const LOCKED_SIGNER: WalletSigner = { - isUnlocked: () => false, - withRoot: () => { - throw new Error('locked'); - }, -}; - -/** - * A signer holding the fixture's own root key. Only the spend tests need one — everything else runs - * locked, which is the state the wallet is in almost all of the time. - */ -const UNLOCKED_SIGNER: WalletSigner = { - isUnlocked: () => true, - withRoot: (fn: (root: HDKey) => T): T => fn(HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC))), -}; - -function backendFor(world: Map, signer: WalletSigner = LOCKED_SIGNER) { - const stub = new StubEsplora(world); - const source = new EsploraChainSource({ - chain: stub as unknown as EsploraChain, - label: 'esplora(stub)', - }); - const backend = new OnchainBackend({ - chain: source, - network: 'bitcoin', - accountXpub: { p2wpkh: ACCOUNT_XPUB }, - signer, - }); - return { backend, stub }; -} - -describe('EsploraChainSource through OnchainBackend', () => { - test('the fixture key really is the published BIP84 vector, not somebody’s wallet', () => { - // The spec's own first receive address. If this fails, the mnemonic above is not what it claims and - // whatever replaced it must be treated as live key material until proven otherwise. - expect(addressAt(0, 0)).toBe('bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu'); - }); - - test('an empty wallet costs one gap-limit window per chain and reports zero', async () => { - const { backend, stub } = backendFor(new Map()); - - expect(await backend.getBalances()).toEqual({ - onchainConfirmed: 0, - onchainUnconfirmed: 0, - onchainFrozen: 0, - lightningBalance: null, - lightningInbound: null, - }); - expect(await backend.getUtxos()).toEqual([]); - expect(await backend.getTransactions()).toEqual([]); - - // One script type, two chains, one gap-limit window each: exactly 40 address lookups and no more. - expect(stub.calls.filter((c) => c.startsWith('addr:'))).toHaveLength(40); - // Nothing held a balance, so not a single UTXO call was worth making. - expect(stub.calls.filter((c) => c.startsWith('utxo:'))).toHaveLength(0); - }); - - test('unused script types cost one probe each, not a full walk', async () => { - // The same xpub under four script types: every one derives valid addresses, and only the default - // (p2wpkh) should be walked. This is the optimisation that keeps a scan at 43 requests instead of 160. - const stub = new StubEsplora(new Map()); - const backend = new OnchainBackend({ - chain: new EsploraChainSource({ chain: stub as unknown as EsploraChain, label: 'esplora(stub)' }), - network: 'bitcoin', - accountXpub: { - p2wpkh: ACCOUNT_XPUB, - p2tr: ACCOUNT_XPUB, - 'p2sh-p2wpkh': ACCOUNT_XPUB, - p2pkh: ACCOUNT_XPUB, - }, - signer: LOCKED_SIGNER, - }); - - await backend.getBalances(); - // 40 for the walked default + 1 probe each for the three that have never been used. - expect(stub.calls.filter((c) => c.startsWith('addr:'))).toHaveLength(43); - }); - - test('balances sum the confirmed and mempool deltas across the scan', async () => { - const world = new Map([ - [addressAt(0, 0), { confirmed: 100_000, spent: 40_000 }], - [addressAt(0, 3), { confirmed: 25_000 }], - [addressAt(1, 0), { confirmed: 0, mempool: 7_000 }], - ]); - const { backend } = backendFor(world); - - const balances = await backend.getBalances(); - expect(balances.onchainConfirmed).toBe(85_000); - expect(balances.onchainUnconfirmed).toBe(7_000); - - const utxos = await backend.getUtxos(); - expect(utxos.map((u) => u.amountSats).sort((a, b) => a - b)).toEqual([25_000, 60_000]); - // The public contract reports the path relative to the account xpub, not the absolute one. - expect(utxos.every((u) => /^[01]\/\d+$/.test(u.derivationPath ?? ''))).toBe(true); - expect(utxos.every((u) => u.confirmations === 6)).toBe(true); - }); - - test('a used address resets the gap, so funds past the first window are still found', async () => { - // Index 25 is beyond the initial 20-address window: it is only reachable if a hit at 19 reset the gap. - const world = new Map([ - [addressAt(0, 19), { confirmed: 1_000 }], - [addressAt(0, 25), { confirmed: 2_000 }], - ]); - const { backend } = backendFor(world); - - expect((await backend.getBalances()).onchainConfirmed).toBe(3_000); - }); - - test('getNewAddress peeks without consuming and advances only when asked', async () => { - const world = new Map([[addressAt(0, 0), { confirmed: 5_000 }]]); - const { backend } = backendFor(world); - - // 0/0 is used, so the first unused is 0/1 — and peeking twice must not move past it. - expect((await backend.getNewAddress({ peek: true })).address).toBe(addressAt(0, 1)); - expect((await backend.getNewAddress({ peek: true })).address).toBe(addressAt(0, 1)); - - expect((await backend.getNewAddress()).address).toBe(addressAt(0, 1)); - expect((await backend.getNewAddress()).address).toBe(addressAt(0, 2)); - }); - - // Issuance used to be unbounded: the mark moved on every call, paid or not, so a run of unpaid - // addresses walked it past the end of the window the next scan covers. A payment there would have been - // invisible forever — Esplora has no rescan to go and look. - test('issuance stops at the far edge of the scanned window instead of walking past it', async () => { - const world = new Map([[addressAt(0, 0), { confirmed: 5_000 }]]); - const { backend } = backendFor(world); - - const issued: string[] = []; - for (let i = 0; i < 30; i++) issued.push((await backend.getNewAddress()).address); - - // 0/0 is used, so the walk covers 0..20 — one full gap limit of misses past it — and stops. - expect(issued[0]).toBe(addressAt(0, 1)); - expect(issued[19]).toBe(addressAt(0, 20)); - // Everything after that re-offers the last address the scan can still see. All of them are virgin, - // so repeating one is not address reuse in the sense that loses privacy. - expect(issued.at(-1)).toBe(addressAt(0, 20)); - const scanned = new Set(Array.from({ length: 21 }, (_, i) => addressAt(0, i))); - expect(issued.filter((a) => !scanned.has(a))).toEqual([]); - }); - - test('transactions are scored against the wallet and credit is positive', async () => { - const world = new Map([[addressAt(0, 0), { confirmed: 50_000 }]]); - const { backend } = backendFor(world); - - const txs = await backend.getTransactions(); - expect(txs).toHaveLength(1); - const tx = txs[0]!; - expect(tx.amount).toBe(50_000); - expect(tx.confirmations).toBe(6); - expect(tx.destAddresses).toEqual([addressAt(0, 0)]); - // We funded no input, so the fee was the sender's and must not be attributed to this wallet. - expect(tx.feeSats).toBeNull(); - }); - - // Freezing a coin is a promise that it will not be spent. It was not being kept: the route checked the - // frozen list only against outpoints the caller named explicitly, so coin control was safe while a - // plain send — the ordinary case — picked inputs from a snapshot on which every UTXO claimed - // `frozen: false`, because the chain has no idea what Officer froze. Sweeping was the worst of it: a - // "send everything" emptied the frozen coin too. - // - // These run unlocked and reach the signer, which is why the fixture key had to be the published test - // vector and nothing else. - describe('frozen coins', () => { - const fundedWorld = () => new Map([[addressAt(0, 0), { confirmed: 50_000 }]]); - /** The single outpoint the stub serves for a funded address. */ - const OUTPOINT = `${'a'.repeat(64)}:0`; - const TO = 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu'; - - test('automatic selection will not spend one', async () => { - const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER); - await expect( - backend.sendCoins({ address: TO, amountSats: 10_000, satPerVbyte: 5, frozenOutpoints: [OUTPOINT] }), - ).rejects.toThrow(/no spendable UTXOs/); - // Nothing was signed and nothing left the machine. - expect(stub.calls).not.toContain('broadcast'); - }); - - test('sendAll will not sweep one', async () => { - const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER); - await expect( - backend.sendCoins({ address: TO, sendAll: true, satPerVbyte: 5, frozenOutpoints: [OUTPOINT] }), - ).rejects.toThrow(/no spendable UTXOs/); - expect(stub.calls).not.toContain('broadcast'); - }); - - test('the same send succeeds once the coin is not frozen', async () => { - // The control. Without it the two tests above would still pass if sending were broken outright. - const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER); - const result = await backend.sendCoins({ address: TO, amountSats: 10_000, satPerVbyte: 5 }); - expect(result.txid).toBe('d'.repeat(64)); - expect(stub.calls).toContain('broadcast'); - }); - }); - - test('fee estimates pass through untouched', async () => { - const { backend } = backendFor(new Map()); - expect(await backend.estimateFees()).toEqual({ - fastestFee: 10, - halfHourFee: 8, - hourFee: 5, - economyFee: 2, - minimumFee: 1, - }); - }); -}); diff --git a/src/servers/sidecar/wallet/chain-source-esplora.ts b/src/servers/sidecar/wallet/chain-source-esplora.ts deleted file mode 100644 index 88d3a91e..00000000 --- a/src/servers/sidecar/wallet/chain-source-esplora.ts +++ /dev/null @@ -1,238 +0,0 @@ -// The Esplora chain source: everything that used to be the on-chain backend's scanning half. -// -// Esplora answers about ONE address at a time and has no concept of a wallet, so finding the wallet is -// this file's job: derive addresses, ask about each, and stop after enough consecutive misses. That walk -// is why the file exists, and why it is the expensive source of the two. - -import type { EsploraAddress, EsploraChain, EsploraTx } from './chain'; -import type { SpendableUtxo } from './psbt'; -import { - mapLimit, - MAX_SCAN_INDEX, - REQUEST_CONCURRENCY, - type AddressEntry, - type ChainIndex, - type ScanContext, - type ScannedAddress, - type ScanResult, - type WalletChainSource, -} from './chain-source'; -import type { AddressType, FeeEstimates, OnchainTx } from './types'; - -/** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */ -const GAP_LIMIT = 20; - -/** Cap on the history returned. A wallet with years of activity should not grow an unbounded blob. */ -const MAX_CACHED_TXS = 500; - -/** The scan proper, before the coins and history that hang off it. */ -type AddressScan = { - tipHeight: number; - addresses: ScannedAddress[]; - /** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. */ - byScript: Map; -}; - -export type EsploraChainSourceOptions = { chain: EsploraChain; label: string }; - -export class EsploraChainSource implements WalletChainSource { - readonly label: string; - - /** The walk already runs a full GAP_LIMIT past the last used address, and stops dead there. */ - readonly issueAhead = 0; - - private readonly chain: EsploraChain; - - constructor(opts: EsploraChainSourceOptions) { - this.chain = opts.chain; - this.label = opts.label; - } - - getTipHeight(): Promise { - return this.chain.getTipHeight(); - } - - getFeeEstimates(): Promise { - return this.chain.getFeeEstimates(); - } - - getTxHex(txid: string): Promise { - return this.chain.getTxHex(txid); - } - - broadcast(rawHex: string): Promise { - return this.chain.broadcast(rawHex); - } - - async scan(ctx: ScanContext): Promise { - const scan = await this.runScan(ctx); - // Both fan out over the scanned address set and neither depends on the other, so they overlap. - const [utxos, txs] = await Promise.all([this.collectUtxos(scan), this.collectTxs(scan)]); - return { tipHeight: scan.tipHeight, addresses: scan.addresses, utxos, txs }; - } - - private async runScan(ctx: ScanContext): Promise { - const tipHeight = await this.chain.getTipHeight(); - const addresses: ScannedAddress[] = []; - - // A wallet holds an account xpub for all four BIP purposes, but a full gap-limit walk of every one - // costs 4 types x 2 chains x 20 addresses = 160 requests, which trips the rate limit on every public - // Esplora instance. Only the wallet's own script type is walked unconditionally; the others are - // probed at receive index 0 first (one request each) and walked only if that address has ever been - // used. A freshly generated seed therefore costs 43 requests instead of 160, while a seed recovered - // from a wallet that used a different script type is still found rather than silently reported empty. - for (const account of ctx.accounts) { - if (account.type !== ctx.defaultType && !(await this.hasHistory(ctx, account.type))) continue; - for (const chain of [0, 1] as ChainIndex[]) { - addresses.push(...(await this.scanChain(ctx, account.type, chain))); - } - } - - const byScript = new Map(); - for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry); - - return { tipHeight, addresses, byScript }; - } - - /** - * Has this script type ever been used at all? One request against receive index 0, which is the - * address any wallet hands out first — so a used account is essentially never missed, and an unused - * one costs a single call instead of a forty-address walk. - */ - private async hasHistory(ctx: ScanContext, type: AddressType): Promise { - const entry = ctx.derive(type, 0, 0); - return toScannedAddress(entry, await this.chain.getAddress(entry.address)).used; - } - - /** Walk one (type, chain) pair a gap-limit window at a time until GAP_LIMIT consecutive misses. */ - private async scanChain(ctx: ScanContext, type: AddressType, chain: ChainIndex): Promise { - const found: ScannedAddress[] = []; - let index = 0; - let gap = 0; - - while (gap < GAP_LIMIT && index < MAX_SCAN_INDEX) { - const window = Array.from({ length: GAP_LIMIT }, (_, i) => ctx.derive(type, chain, index + i)); - const stats = await mapLimit(window, REQUEST_CONCURRENCY, (entry) => this.chain.getAddress(entry.address)); - - for (let i = 0; i < window.length; i++) { - const entry = window[i]; - const stat = stats[i]; - if (!entry || !stat) continue; - const scanned = toScannedAddress(entry, stat); - found.push(scanned); - gap = scanned.used ? 0 : gap + 1; - if (gap >= GAP_LIMIT) break; - } - index += window.length; - } - - return found; - } - - /** Fetch UTXOs for every scanned address that still holds a balance. */ - private async collectUtxos(scan: AddressScan): Promise { - // An address whose funded and spent counts match holds nothing; asking Esplora about it is a - // wasted round trip, and on a wallet with long history that is most of the address set. - const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0); - const sets = await mapLimit(funded, REQUEST_CONCURRENCY, async (entry) => { - const utxos = await this.chain.getAddressUtxos(entry.address); - return utxos.map((u) => ({ - txid: u.txid, - vout: u.vout, - amountSats: u.value, - address: entry.address, - addressType: entry.type, - confirmations: - u.status.confirmed && u.status.block_height !== undefined - ? Math.max(0, scan.tipHeight - u.status.block_height + 1) - : 0, - derivationPath: entry.path, - frozen: false, - scriptPubKeyHex: entry.scriptPubKeyHex, - pubkeyHex: entry.pubkeyHex, - })); - }); - return sets.flat(); - } - - /** History for every address the scan found had been touched. Only those can appear in it. */ - private async collectTxs(scan: AddressScan): Promise { - const touched = scan.addresses.filter((a) => a.used); - const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address)); - - const seen = new Map(); - for (const page of pages) { - for (const tx of page) if (!seen.has(tx.txid)) seen.set(tx.txid, tx); - } - - const txs = [...seen.values()].map((tx) => toOnchainTx(tx, scan)); - txs.sort((a, b) => { - // Unconfirmed first (they have no height), then newest block, then newest timestamp. - const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER; - const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER; - if (ah !== bh) return bh - ah; - return (b.timestamp ?? 0) - (a.timestamp ?? 0); - }); - return txs.slice(0, MAX_CACHED_TXS); - } -} - -// ── helpers ────────────────────────────────────────────────────────────────────────────────────── - -function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress { - const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum; - // The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is - // exactly what the Balances contract wants in onchainUnconfirmed. - const mempoolBalance = stat.mempool_stats.funded_txo_sum - stat.mempool_stats.spent_txo_sum; - const txCount = stat.chain_stats.tx_count + stat.mempool_stats.tx_count; - return { - ...entry, - confirmedSats: chainBalance, - unconfirmedSats: mempoolBalance, - txCount, - used: txCount > 0, - }; -} - -/** Score one Esplora transaction against the wallet's own scripts. */ -function toOnchainTx(tx: EsploraTx, scan: AddressScan): OnchainTx { - let credit = 0; - let debit = 0; - const ours: string[] = []; - const theirs: string[] = []; - - for (const out of tx.vout) { - const mine = scan.byScript.get(out.scriptpubkey); - if (mine) { - credit += out.value; - ours.push(mine.address); - } else if (out.scriptpubkey_address) { - theirs.push(out.scriptpubkey_address); - } - } - for (const input of tx.vin) { - const prevout = input.prevout; - if (prevout && scan.byScript.has(prevout.scriptpubkey)) debit += prevout.value; - } - - const height = tx.status.confirmed ? (tx.status.block_height ?? null) : null; - const confirmations = height === null ? 0 : Math.max(0, scan.tipHeight - height + 1); - const amount = credit - debit; - - return { - txid: tx.txid, - amount, - // The fee is only ours to report when we funded an input; for an incoming payment the sender - // paid it and attributing it to this wallet would be a lie. - feeSats: debit > 0 ? tx.fee : null, - blockHeight: height, - timestamp: tx.status.block_time ?? null, - confirmations, - // No label store in this backend; SendCoinsRequest.label is accepted and dropped. - label: null, - destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours, - // Fetching /tx/{txid}/hex per transaction would double the request count for a list view. The - // contract permits null, and the raw hex is fetched on demand where it is actually needed. - rawHex: null, - }; -} diff --git a/src/servers/sidecar/wallet/chain-source-nbxplorer.ts b/src/servers/sidecar/wallet/chain-source-nbxplorer.ts deleted file mode 100644 index 0757ee49..00000000 --- a/src/servers/sidecar/wallet/chain-source-nbxplorer.ts +++ /dev/null @@ -1,471 +0,0 @@ -// The NBXplorer chain source: the owner's own bitcoind, read through NBXplorer's wallet-level index. -// -// The contrast with chain-source-esplora.ts is the whole reason the WalletChainSource seam exists. There -// is no gap-limit walk here and no address fan-out, because NBXplorer already knows what the wallet owns: -// the account xpub is registered once, and after that a scan is three calls per account regardless of how -// many addresses have been used. What that costs instead is reassembly — NBXplorer answers wallet-wide, -// so the per-address view the rest of the wallet expects has to be rebuilt from UTXO key paths. -// -// REGISTRATION IS NOT RECOVERY, and this is the trap: a newly tracked xpub only picks up activity from -// the moment it is registered. An imported seed therefore reads as a real, quiet, empty wallet — every -// call succeeds, nothing errors, and the coins are simply not in the index. `startRescan` below is the -// answer for balance and spending: `scantxoutset` walks the node's current UTXO set directly, so it finds -// coins regardless of when the account was registered or how far back the node is pruned. -// -// What a rescan does NOT recover is spent-transaction history predating registration; that is a block -// rescan, a much heavier thing, and this source will keep reporting it as absent rather than as an error. -// Esplora stays available for the owner who wants the full history back. - -import * as bitcoin from 'bitcoinjs-lib'; -import { - type AddressEntry, - type ChainIndex, - type RescanHandle, - type ScanAccount, - type ScanContext, - type ScannedAddress, - type ScanResult, - type WalletChainSource, -} from './chain-source'; -import { derivationScheme, type NbxplorerChain, type NbxTransaction, type NbxUtxo } from './nbxplorer'; -import type { SpendableUtxo } from './psbt'; -import { networkFor } from './psbt'; -import { - BackendError, - type AddressType, - type BitcoinNetwork, - type FeeEstimates, - type OnchainTx, - type RescanOptions, - type RescanState, -} from './types'; - -/** Confirmation targets, in blocks, behind the five fee tiers the wallet reports. */ -const FEE_TARGETS = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 25, minimumFee: 144 } as const; - -/** How often to ask how a `scantxoutset` is going. Frequent enough that a finished result is never missed. */ -const SCAN_POLL_MS = 2_000; - -/** Per-variant ceiling. A scan of a large UTXO set is minutes; anything past this is a wedged node. */ -const SCAN_TIMEOUT_MS = 10 * 60_000; - -/** Consecutive 404s tolerated before a variant has ever reported a status — see `scanVariant`. */ -const SCAN_NULL_GRACE = 5; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export type NbxplorerChainSourceOptions = { chain: NbxplorerChain; network: BitcoinNetwork; label: string }; - -export class NbxplorerChainSource implements WalletChainSource { - readonly label: string; - - /** - * A scan reports up to NBXplorer's unused mark, but NBXplorer itself watches a gap beyond it - * (`MinGapSize`, 20 by default) and extends as addresses are used, so an address a little past the - * mark is still seen. Deliberately below that default rather than equal to it, because the setting - * belongs to the node operator and is not readable from here — and unlike Esplora, a miss on this - * source is recoverable: `utxos/scan` walks the node's whole UTXO set to a gap limit of 1000. - */ - readonly issueAhead = 10; - - private readonly chain: NbxplorerChain; - private readonly btcNetwork: bitcoin.Network; - - /** - * Accounts seen by the most recent scan. - * - * `getTxHex` is the reason this exists: NBXplorer has no global transaction lookup, only a per-scheme - * one, but the WalletChainSource signature carries a txid and nothing else. Every caller of getTxHex - * runs inside sendCoins, which forces a fresh scan before it selects a single coin, so the map is - * always populated by the time it is read. It is a memo of the last scan, never a source of truth. - */ - private accounts: readonly ScanAccount[] = []; - - /** The rescan in flight, or the last one's outcome. Mutated in place as the scan progresses. */ - private rescan: RescanState | null = null; - private rescanRun: Promise | null = null; - - constructor(opts: NbxplorerChainSourceOptions) { - this.chain = opts.chain; - this.btcNetwork = networkFor(opts.network); - this.label = opts.label; - } - - async getTipHeight(): Promise { - const status = await this.chain.getStatus(); - return status.chainHeight; - } - - /** - * NBXplorer answers one confirmation target per call, so the five tiers are five requests. - * - * In a quiet mempool every target returns the same number and the tiers collapse to one value. That is - * the node telling the truth, not a bug — there is genuinely no fee premium worth paying when the next - * block will clear the queue. The clamp only enforces that a slower target is never quoted higher than - * a faster one, which estimatesmartfee can otherwise produce at the boundaries. - */ - async getFeeEstimates(): Promise { - const entries = Object.entries(FEE_TARGETS) as [keyof FeeEstimates, number][]; - const rates = await Promise.all(entries.map(([, blocks]) => this.chain.getFeeRate(blocks))); - - const out = {} as FeeEstimates; - let ceiling = Number.POSITIVE_INFINITY; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - const rate = rates[i]; - if (!entry || rate === undefined) continue; - ceiling = Math.min(ceiling, rate); - out[entry[0]] = ceiling; - } - return out; - } - - async getTxHex(txid: string): Promise { - if (this.accounts.length === 0) { - throw new BackendError('nbxplorer source was asked for a transaction before any scan', 500, 'NO_SCAN'); - } - // Which account funded the input is not known here, and asking the wrong one 404s rather than - // returning something wrong, so trying each in turn is safe. In practice the first one answers. - let last: unknown = null; - for (const account of this.accounts) { - try { - return await this.chain.getTxHex(account.accountXpub, account.type, txid); - } catch (err) { - last = err; - } - } - throw last instanceof Error ? last : new BackendError(`no account knows transaction ${txid}`, 502); - } - - async broadcast(rawHex: string): Promise { - await this.chain.broadcast(rawHex); - // NBXplorer's broadcast returns only success/failure, so the txid is computed locally. It is a hash - // of the bytes we just sent, so it cannot disagree with what was accepted. - return bitcoin.Transaction.fromHex(rawHex).getId(); - } - - async scan(ctx: ScanContext): Promise { - this.accounts = ctx.accounts; - - const perAccount = await Promise.all(ctx.accounts.map((account) => this.scanAccount(ctx, account))); - - const tipHeight = Math.max(0, ...perAccount.map((r) => r.tipHeight)); - const addresses = perAccount.flatMap((r) => r.addresses); - const utxos = perAccount.flatMap((r) => r.utxos); - - const byScript = new Map(); - for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry); - - const txs = perAccount - .flatMap((r) => r.txs) - .map((tx) => this.toOnchainTx(tx, byScript, tipHeight)) - .sort((a, b) => { - // Unconfirmed first (they have no height), then newest block, then newest timestamp. - const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER; - const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER; - if (ah !== bh) return bh - ah; - return (b.timestamp ?? 0) - (a.timestamp ?? 0); - }); - - return { tipHeight, addresses, utxos, txs }; - } - - rescanState(): RescanState | null { - return this.rescan; - } - - /** - * Run `scantxoutset` over every account variant, one at a time. - * - * ALL FOUR VARIANTS, ALWAYS. A recovered seed's coins can sit on any script type, and nothing here can - * tell which until the node has looked — the p2sh account of a wallet that only ever used native segwit - * costs one wasted scan, whereas skipping it costs the owner their money. The node serialises - * `scantxoutset` anyway, so scanning them sequentially is what happens regardless of what we ask for; - * doing it explicitly is what makes `done`/`total` mean something on screen. - * - * A second call while one is running returns the same handle rather than queueing a duplicate. - */ - startRescan(accounts: readonly ScanAccount[], opts: RescanOptions = {}): RescanHandle { - const existing = this.rescan; - if (existing?.running && this.rescanRun) return { state: existing, done: this.rescanRun }; - - const state: RescanState = { - running: true, - done: 0, - total: accounts.length, - current: accounts[0]?.type ?? null, - found: 0, - startedAt: Date.now(), - finishedAt: null, - error: null, - }; - this.rescan = state; - this.rescanRun = this.runRescan(accounts, state, opts); - return { state, done: this.rescanRun }; - } - - /** - * Pick up a scan that survived a restart of this sidecar. - * - * `scantxoutset` runs on the node and outlives us; only the handle to it was ever in memory. So the - * upstream is asked directly, and if anything is Queued or Pending the run is rebuilt around the same - * polling loop with starting suppressed — a duplicate POST would sit behind the real scan for its whole - * duration, since the node runs one at a time. - * - * `found` restarts from zero and a variant that finished before the restart contributes nothing to it: - * its result has expired upstream and there is no way to recover the count. The number under-reports - * for that one run, which is the honest answer — and the refresh chained on the end is what actually - * puts the coins on screen. - */ - async adoptRescan(accounts: readonly ScanAccount[]): Promise { - if (this.rescan?.running && this.rescanRun) return { state: this.rescan, done: this.rescanRun }; - - const live = await Promise.all( - accounts.map(async (account) => { - // A probe failing is not an answer either way, and must not be reported as "a scan is running". - const status = await this.chain.getUtxoScanStatus(account.accountXpub, account.type).catch(() => null); - return status?.status === 'Queued' || status?.status === 'Pending'; - }), - ); - if (!live.some(Boolean)) return null; - - const state: RescanState = { - running: true, - done: 0, - total: accounts.length, - current: accounts[0]?.type ?? null, - found: 0, - startedAt: Date.now(), - finishedAt: null, - error: null, - }; - this.rescan = state; - this.rescanRun = this.runRescan(accounts, state, null); - return { state, done: this.rescanRun }; - } - - /** `opts` null means adopt: poll the variants without ever queueing one. */ - private async runRescan( - accounts: readonly ScanAccount[], - state: RescanState, - opts: RescanOptions | null, - ): Promise { - try { - for (const account of accounts) { - state.current = account.type; - state.found += opts ? await this.scanVariant(account, opts) : await this.awaitVariant(account); - state.done += 1; - } - } catch (err) { - // One variant failing stops the run, and the partial `found` stays on the state: coins already - // pulled in by an earlier variant are really there, and reporting zero would understate the wallet. - state.error = err instanceof Error ? err.message : String(err); - } finally { - state.running = false; - state.current = null; - state.finishedAt = Date.now(); - } - return state; - } - - /** Queue one variant's scan and wait it out. Resolves with how many UTXOs it pulled in. */ - private async scanVariant(account: ScanAccount, opts: RescanOptions): Promise { - await this.chain.startUtxoScan(account.accountXpub, account.type, { gapLimit: opts.gapLimit }); - return this.awaitVariant(account); - } - - /** - * Wait out a scan that is already queued upstream, without starting one. - * - * Split out of scanVariant for adoption: a scan this process did not start must never be re-queued, - * because scantxoutset is single-threaded node-wide and a duplicate would sit behind the real one for - * its whole duration. An account that already finished answers 404 or Complete and returns at once. - */ - private async awaitVariant(account: ScanAccount): Promise { - const { accountXpub, type } = account; - - // The timeout bounds how long a scan may RUN, not how long it may wait its turn. scantxoutset is - // single-threaded node-wide, so four variants queue behind each other and the last one could spend - // most of its ten minutes not started — timing out while the node was never asked to do anything - // wrong. The deadline is therefore pushed forward for as long as the status reads Queued. - let deadline = Date.now() + SCAN_TIMEOUT_MS; - let found = 0; - let seen = false; - let nulls = 0; - - while (Date.now() < deadline) { - await sleep(SCAN_POLL_MS); - const status = await this.chain.getUtxoScanStatus(accountXpub, type); - - // 404 means "no scan is running" — which before the first sighting is a race with the queue, and - // after it means the result has expired. Only the second reading is a finish, hence the flag; the - // grace bounds the first so a scan the node silently dropped does not hold the run for ten minutes. - if (!status) { - if (seen) return found; - if (++nulls >= SCAN_NULL_GRACE) return found; - continue; - } - - seen = true; - nulls = 0; - found = status.progress?.found ?? found; - - if (status.status === 'Queued') deadline = Date.now() + SCAN_TIMEOUT_MS; - if (status.status === 'Complete') return found; - if (status.status === 'Error') { - throw new BackendError(`utxo scan of the ${type} account failed: ${status.error ?? 'unknown'}`, 502, 'SCAN'); - } - } - - throw new BackendError(`utxo scan of the ${type} account did not finish in time`, 504, 'SCAN_TIMEOUT'); - } - - private async scanAccount(ctx: ScanContext, account: ScanAccount) { - const { accountXpub, type } = account; - // track() is idempotent and every one of these calls makes it first, so registration cannot be - // skipped — which matters because an unregistered scheme answers 200-with-zeros, not 404. - const [utxoChanges, history, unusedDeposit, unusedChange] = await Promise.all([ - this.chain.getUtxos(accountXpub, type), - this.chain.getTransactions(accountXpub, type), - this.chain.getUnusedAddress(accountXpub, type, 'Deposit'), - this.chain.getUnusedAddress(accountXpub, type, 'Change'), - ]); - - const tipHeight = utxoChanges.currentHeight; - - // A coin already being spent by something in the mempool is not spendable again. NBXplorer still - // lists it as a confirmed UTXO, so it has to be excluded explicitly or coin selection would build a - // transaction that double-spends the owner's own unconfirmed one. - const spentUnconfirmed = new Set(utxoChanges.spentUnconfirmed.map((u) => u.outpoint)); - - const confirmed = utxoChanges.confirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint)); - const unconfirmed = utxoChanges.unconfirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint)); - - // The per-address view the rest of the wallet works in. NBXplorer's own answer to "what is unused" is - // taken as authoritative for where each chain ends — everything below that index has been seen, which - // is what makes it unsafe to hand out again. - const highest: Record = { - 0: indexOf(unusedDeposit.keyPath), - 1: indexOf(unusedChange.keyPath), - }; - for (const u of [...confirmed, ...unconfirmed, ...utxoChanges.spentUnconfirmed]) { - const at = parseKeyPath(u.keyPath); - if (at) highest[at.chain] = Math.max(highest[at.chain], at.index); - } - - const sats = new Map(); - const bump = (u: NbxUtxo, field: 'confirmed' | 'unconfirmed', sign: 1 | -1) => { - const cur = sats.get(u.keyPath) ?? { confirmed: 0, unconfirmed: 0 }; - cur[field] += sign * u.value; - sats.set(u.keyPath, cur); - }; - for (const u of confirmed) bump(u, 'confirmed', 1); - for (const u of unconfirmed) bump(u, 'unconfirmed', 1); - // Matches Esplora's signed mempool delta: an unconfirmed spend of a confirmed coin reads negative, - // which is exactly what the Balances contract wants in onchainUnconfirmed. - for (const u of utxoChanges.spentUnconfirmed) bump(u, 'unconfirmed', -1); - - const addresses: ScannedAddress[] = []; - for (const chain of [0, 1] as ChainIndex[]) { - for (let index = 0; index <= highest[chain]; index++) { - const entry = ctx.derive(type, chain, index); - const held = sats.get(`${chain}/${index}`); - addresses.push({ - ...entry, - confirmedSats: held?.confirmed ?? 0, - unconfirmedSats: held?.unconfirmed ?? 0, - // Below the unused mark means NBXplorer has seen this address; the mark itself has not. - txCount: index < highest[chain] ? 1 : 0, - used: index < highest[chain], - }); - } - } - - const utxos = [...confirmed, ...unconfirmed].flatMap((u) => { - const at = parseKeyPath(u.keyPath); - if (!at) return []; - const entry = ctx.derive(type, at.chain, at.index); - return [ - { - txid: u.transactionHash, - vout: u.index, - amountSats: u.value, - address: entry.address, - addressType: entry.type, - confirmations: Math.max(0, u.confirmations), - derivationPath: entry.path, - frozen: false, - scriptPubKeyHex: entry.scriptPubKeyHex, - pubkeyHex: entry.pubkeyHex, - }, - ]; - }); - - const txs = [...history.confirmedTransactions.transactions, ...history.unconfirmedTransactions.transactions]; - return { tipHeight, addresses, utxos, txs }; - } - - /** - * NBXplorer already scores the transaction for us — `balanceChange` is signed and wallet-wide, which is - * precisely what the Esplora source computes by hand from credits and debits. - * - * The fee is NOT recoverable here, and reporting a wrong one would be worse than reporting none: the - * response carries no input values, and fetching every prevout to derive it would reintroduce the - * per-transaction fan-out this source exists to avoid. The contract permits null. - */ - private toOnchainTx(tx: NbxTransaction, byScript: Map, tipHeight: number): OnchainTx { - const amount = tx.balanceChange; - const ours: string[] = []; - const theirs: string[] = []; - - const hex = tx.transaction?.trim(); - if (hex) { - try { - for (const out of bitcoin.Transaction.fromHex(hex).outs) { - const mine = byScript.get(out.script.toString('hex')); - if (mine) { - ours.push(mine.address); - continue; - } - try { - theirs.push(bitcoin.address.fromOutputScript(out.script, this.btcNetwork)); - } catch { - // OP_RETURN and other unspendable scripts have no address. Not an error, just not a payee. - } - } - } catch { - // A transaction we cannot parse still has a correct amount and height; only the payee list is - // lost, and an empty one renders as "unknown" rather than as something false. - } - } - - const height = tx.confirmations > 0 ? (tx.height ?? null) : null; - return { - txid: tx.transactionId, - amount, - feeSats: null, - blockHeight: height, - timestamp: tx.timestamp, - confirmations: Math.max(0, tx.confirmations), - label: null, - destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours, - rawHex: null, - }; - } -} - -// ── helpers ────────────────────────────────────────────────────────────────────────────────────── - -/** `"0/7"` → `{ chain: 0, index: 7 }`. Anything else is not an address this wallet derives. */ -function parseKeyPath(keyPath: string): { chain: ChainIndex; index: number } | null { - const parts = keyPath.split('/'); - const chain = Number(parts.at(-2)); - const index = Number(parts.at(-1)); - if ((chain !== 0 && chain !== 1) || !Number.isInteger(index) || index < 0) return null; - return { chain, index }; -} - -/** The address index out of a key path, or 0 when it is unreadable. */ -function indexOf(keyPath: string): number { - return parseKeyPath(keyPath)?.index ?? 0; -} diff --git a/src/servers/sidecar/wallet/chain-source.ts b/src/servers/sidecar/wallet/chain-source.ts deleted file mode 100644 index 681bb578..00000000 --- a/src/servers/sidecar/wallet/chain-source.ts +++ /dev/null @@ -1,172 +0,0 @@ -// What a chain source owes the on-chain wallet, and the vocabulary the two halves speak. -// -// THE SPLIT IS AT THE SCAN, NOT AT THE HTTP CLIENT. This is the whole point of the file and it is not -// obvious, so: Esplora is address-level — you ask about one script at a time, and finding a wallet means -// walking the gap limit yourself, 43-160 requests a refresh. NBXplorer is wallet-level — you register the -// account xpub once and then every question is a single call. There is no common denominator at the -// request layer, because NBXplorer simply has no "tell me about this one address" endpoint to implement. -// -// So the seam is drawn one level up, at `scan()`: "read this wallet off the chain and hand back what you -// found". Esplora satisfies it with a gap-limit walk; NBXplorer satisfies it with three calls and no walk -// at all. Everything downstream — balances, coin selection, PSBT construction, signing, broadcast — works -// off the ScanResult and does not know or care which one answered. -// -// Deriving addresses is NOT a source's job. The keys belong to the backend, and `ScanContext.derive` is -// how a source asks for one. A source that could derive independently could disagree with the wallet -// about which addresses are the wallet's, which is the kind of bug that loses coins rather than failing. - -import type { SpendableUtxo } from './psbt'; -import type { AddressType, FeeEstimates, OnchainTx, RescanOptions, RescanState } from './types'; - -/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */ -export type ChainIndex = 0 | 1; - -/** Hard stop on a runaway scan — a misconfigured xpub against a busy chain must not loop forever. */ -export const MAX_SCAN_INDEX = 1_000; - -/** Parallel upstream requests. Public Esplora instances rate-limit, so this stays modest. */ -export const REQUEST_CONCURRENCY = 6; - -/** One address the wallet owns, fully derived. Produced by the backend, consumed by the source. */ -export type AddressEntry = { - type: AddressType; - chain: ChainIndex; - index: number; - address: string; - /** Compressed 33-byte pubkey, hex. */ - pubkeyHex: string; - scriptPubKeyHex: string; - /** Full path from the wallet root — what the signer derives with. */ - path: string; - /** Path relative to the account xpub, which is what the public Utxo type carries. */ - relPath: string; -}; - -/** An address the source looked up, with what the chain says about it. */ -export type ScannedAddress = AddressEntry & { - confirmedSats: number; - unconfirmedSats: number; - txCount: number; - used: boolean; -}; - -/** - * The account keys, in the form a wallet-level source needs them. - * - * `accountXpub` is the string as stored, which for every BIP this wallet supports is plain `xpub`-version - * bytes — NBXplorer's derivation schemes are built straight from it with no SLIP-132 conversion. - */ -export type ScanAccount = { type: AddressType; accountXpub: string }; - -/** - * Everything a source is handed to perform one scan. Passed per call rather than held at construction so - * a source stays stateless with respect to the wallet — the same source instance is safe to share. - */ -export type ScanContext = { - accounts: readonly ScanAccount[]; - /** The wallet's own script type. Address-level sources walk this one unconditionally. */ - defaultType: AddressType; - derive: (type: AddressType, chain: ChainIndex, index: number) => AddressEntry; -}; - -/** - * A rescan in flight. - * - * `state` is the SAME object the source keeps and mutates as the scan progresses, so a caller that holds - * it sees the counters move without asking again. `done` is how the wallet knows to re-read the chain: - * a rescan that found coins has changed nothing until the snapshot behind it is rebuilt. - */ -export type RescanHandle = { state: RescanState; done: Promise }; - -/** One coherent read of the chain — balances, coins and history as of the same moment. */ -export type ScanResult = { - tipHeight: number; - addresses: ScannedAddress[]; - utxos: SpendableUtxo[]; - txs: OnchainTx[]; -}; - -/** - * The chain, as the on-chain wallet needs it. - * - * Five methods, and only `scan` is interesting. The other four are point lookups that every backend in - * existence offers in some form, so they map one-to-one onto whatever is upstream. - */ -export interface WalletChainSource { - /** Human-readable, for logs and the sync-state surface. e.g. `esplora(mempool.space)`. */ - readonly label: string; - - /** - * How many indices past the highest one `scan` reported this source will still find a payment on. - * - * Issuance must not outrun the search that finds the money again. `nextUnused` advances a mark every - * time an address is handed out, so a run of addresses issued and never paid walks the mark forward - * with nothing marking those indices used — and a later payment to one of them lands beyond the - * window the next scan covers. On a source that cannot rescan, that is an invisible, unrecoverable - * balance, from nothing worse than clicking "new address" too many times. - * - * Esplora is 0: its walk already extends a full gap limit past the last used address, so every index - * `scan` returned is covered and none beyond it is. NBXplorer reports only up to its own unused mark - * but watches a gap beyond it, so a modest overrun is still seen. - */ - readonly issueAhead: number; - - /** Current best block height. The cheapest liveness probe the wallet has. */ - getTipHeight(): Promise; - - getFeeEstimates(): Promise; - - /** - * Raw hex of one transaction. Called only for `p2pkh` inputs, which commit to the whole previous - * transaction and so need it in the PSBT; segwit inputs carry their own value and script. - */ - getTxHex(txid: string): Promise; - - /** Publish a signed transaction. Resolves to the txid, throws if the network rejected it. */ - broadcast(rawHex: string): Promise; - - /** Read the whole wallet off the chain. */ - scan(ctx: ScanContext): Promise; - - /** - * Search the chain for this wallet's coins from scratch, rather than from whenever the upstream - * started watching it. Returns immediately — a rescan takes minutes, and the caller is an HTTP route. - * - * `opts.gapLimit` widens the search past the source's own default, for a wallet restored from one that - * issued addresses in bulk. Ignored by a source with no such knob. - * - * Optional, and Esplora does not implement it — it has nothing to rescan, because a gap-limit walk - * already asks about every address every time. This exists for an upstream that *indexes*, where a - * newly registered account starts empty and stays empty until told to go and look. - */ - startRescan?(accounts: readonly ScanAccount[], opts?: RescanOptions): RescanHandle; - - /** - * Find a scan already in flight upstream and return a handle to it, without starting one. - * - * Same handle contract as `startRescan`, so the caller chains its refresh onto `done` identically — - * which is the point: an adopted scan has to end in a snapshot rebuild too, or the coins it recovered - * stay invisible. Null when nothing is running. - */ - adoptRescan?(accounts: readonly ScanAccount[]): Promise; - - /** The rescan in flight, or the last one's outcome. Null when this source has never run one. */ - rescanState?(): RescanState | null; -} - -/** Bounded-concurrency map that preserves input order. Address-level sources fan out wide. */ -export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { - const out = new Array(items.length); - let cursor = 0; - const worker = async (): Promise => { - for (;;) { - const i = cursor++; - if (i >= items.length) return; - const item = items[i]; - if (item === undefined) continue; - out[i] = await fn(item); - } - }; - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); - return out; -} diff --git a/src/servers/sidecar/wallet/chain.ts b/src/servers/sidecar/wallet/chain.ts deleted file mode 100644 index 50df0177..00000000 --- a/src/servers/sidecar/wallet/chain.ts +++ /dev/null @@ -1,281 +0,0 @@ -// Esplora REST client — the chain data source for the native on-chain wallet backend. -// -// Zeus's embedded backends (EmbeddedLND, LdkNode) get chain data from a node bound to a React Native -// native module. Nothing about that ports to a Bun server process, so the server-side wallet reads the -// chain over Esplora's HTTP API instead: mempool.space, blockstream.info, or a self-hosted esplora. -// Every call is a plain unauthenticated GET, which is why the backend can serve watch-only requests -// without ever touching key material. -// -// Two Esplora endpoints are NOT JSON and are handled explicitly below: -// GET /tx/{txid}/hex → the raw transaction as a bare hex string -// POST /tx → request body is bare hex, response body is the bare txid -// Everything else is JSON, and every wire shape it returns is typed in this file. - -import { BackendError, type BitcoinNetwork, type FeeEstimates } from './types'; - -// ── configuration ──────────────────────────────────────────────────────────────────────────────── - -export type EsploraConfig = { - /** Base URL of the Esplora API root, e.g. `https://mempool.space/api`. Trailing slashes are trimmed. */ - baseUrl: string; - network: BitcoinNetwork; - timeoutMs?: number; -}; - -const DEFAULT_TIMEOUT_MS = 20_000; - -// ── wire shapes ────────────────────────────────────────────────────────────────────────────────── - -/** Confirmation status shared by /tx, /address/{a}/txs and /address/{a}/utxo. */ -export type EsploraTxStatus = { - confirmed: boolean; - block_height?: number; - block_hash?: string; - /** Unix seconds of the containing block. Absent while unconfirmed. */ - block_time?: number; -}; - -/** Aggregate funded/spent counters. Esplora reports one set for the chain and one for the mempool. */ -export type EsploraAddressStats = { - funded_txo_count: number; - funded_txo_sum: number; - spent_txo_count: number; - spent_txo_sum: number; - tx_count: number; -}; - -export type EsploraAddress = { - address: string; - chain_stats: EsploraAddressStats; - mempool_stats: EsploraAddressStats; -}; - -export type EsploraVout = { - /** scriptPubKey, hex. */ - scriptpubkey: string; - scriptpubkey_asm: string; - /** Esplora's own classification: 'v0_p2wpkh' | 'v1_p2tr' | 'p2pkh' | 'p2sh' | 'op_return' | … */ - scriptpubkey_type: string; - scriptpubkey_address?: string; - value: number; -}; - -export type EsploraVin = { - txid: string; - vout: number; - /** The output being spent. Null for coinbase inputs. */ - prevout: EsploraVout | null; - scriptsig: string; - scriptsig_asm: string; - witness?: string[]; - is_coinbase: boolean; - sequence: number; - inner_redeemscript_asm?: string; - inner_witnessscript_asm?: string; -}; - -export type EsploraTx = { - txid: string; - version: number; - locktime: number; - vin: EsploraVin[]; - vout: EsploraVout[]; - size: number; - weight: number; - /** Absolute fee in sats. Zero for coinbase. */ - fee: number; - status: EsploraTxStatus; -}; - -export type EsploraUtxo = { - txid: string; - vout: number; - value: number; - status: EsploraTxStatus; -}; - -/** /fee-estimates: confirmation target (in blocks, as a string key) → sat/vB, as a float. */ -export type EsploraFeeEstimates = Record; - -// ── fee mapping ────────────────────────────────────────────────────────────────────────────────── - -// Esplora publishes an estimate per confirmation target; FeeEstimates in types.ts is mempool.space's -// five named tiers. These are the targets each tier maps onto. -const FEE_TARGETS = { - fastestFee: 1, - halfHourFee: 3, - hourFee: 6, - economyFee: 144, - minimumFee: 1008, -} as const satisfies Record; - -/** Nothing below the default minimum relay fee will propagate, so 1 sat/vB is the hard floor. */ -const MIN_SAT_VB = 1; - -/** - * Pick the estimate for a confirmation target. Esplora only publishes some targets (1..25, then 144, - * 504, 1008), so when the exact key is missing we take the largest published target *at or below* the - * one asked for — a shorter target always quotes a higher rate, so this errs towards confirming. - */ -function pickForTarget(raw: EsploraFeeEstimates, target: number): number | null { - const exact = raw[String(target)]; - if (typeof exact === 'number' && Number.isFinite(exact)) return exact; - - let best: number | null = null; - let bestKey = -1; - for (const [key, rate] of Object.entries(raw)) { - const k = Number(key); - if (!Number.isFinite(k) || !Number.isFinite(rate)) continue; - if (k <= target && k > bestKey) { - bestKey = k; - best = rate; - } - } - return best; -} - -/** - * Map Esplora's target→rate map onto the five named tiers. Exported separately from the client so the - * mapping can be unit-tested against a captured /fee-estimates payload with no network involved. - */ -export function mapFeeEstimates(raw: EsploraFeeEstimates): FeeEstimates { - const at = (target: number): number => Math.max(MIN_SAT_VB, Math.ceil(pickForTarget(raw, target) ?? MIN_SAT_VB)); - - const fastestFee = at(FEE_TARGETS.fastestFee); - // A tier must never quote more than a shorter-target tier: some esplora deployments serve a stale or - // partial map where a longer target reads higher, and a UI that shows "1 hour" above "fastest" is a - // bug report. Clamp each tier down to its faster neighbour. - const halfHourFee = Math.min(fastestFee, at(FEE_TARGETS.halfHourFee)); - const hourFee = Math.min(halfHourFee, at(FEE_TARGETS.hourFee)); - const economyFee = Math.min(hourFee, at(FEE_TARGETS.economyFee)); - const minimumFee = Math.min(economyFee, at(FEE_TARGETS.minimumFee)); - - return { fastestFee, halfHourFee, hourFee, economyFee, minimumFee }; -} - -// ── the client ─────────────────────────────────────────────────────────────────────────────────── - -type RequestInitLite = { - method?: 'GET' | 'POST'; - /** Sent verbatim as the body — Esplora's POST /tx takes bare hex, not JSON. */ - body?: string; - contentType?: string; -}; - -export class EsploraChain { - readonly network: BitcoinNetwork; - private readonly baseUrl: string; - private readonly timeoutMs: number; - - constructor(cfg: EsploraConfig) { - this.baseUrl = cfg.baseUrl.replace(/\/+$/, ''); - this.network = cfg.network; - this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS; - } - - /** Every request goes through here so timeouts and upstream errors become one BackendError shape. */ - private async text(path: string, init: RequestInitLite = {}): Promise { - const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.timeoutMs); - - let res: Response; - try { - res = await fetch(url, { - method: init.method ?? 'GET', - body: init.body, - headers: init.contentType ? { 'Content-Type': init.contentType } : undefined, - signal: controller.signal, - }); - } catch (err) { - const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err); - throw new BackendError(`esplora request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE'); - } finally { - clearTimeout(timer); - } - - const body = await res.text(); - if (!res.ok) { - // Esplora reports every failure as plain text: 'Transaction not found', 'sendrawtransaction RPC - // error: {"code":-26,...}'. There is no JSON envelope to unwrap, so the text is the message. - throw new BackendError(`esplora ${res.status} on ${path}: ${body.slice(0, 300)}`, res.status, 'ESPLORA_ERROR'); - } - return body; - } - - private async json(path: string): Promise { - const body = await this.text(path); - try { - return JSON.parse(body) as T; - } catch { - throw new BackendError(`esplora returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'ESPLORA_ERROR'); - } - } - - // ── endpoints ────────────────────────────────────────────────────────────────────────────────── - - /** GET /address/{addr} — funded/spent counters for the chain and the mempool. */ - getAddress(address: string): Promise { - return this.json(`/address/${encodeURIComponent(address)}`); - } - - /** - * GET /address/{addr}/txs — the newest ~50 transactions (all mempool ones, then 25 confirmed). - * Pass `afterTxid` to page further back through confirmed history via /txs/chain/{last_seen_txid}. - */ - getAddressTxs(address: string, afterTxid?: string): Promise { - const addr = encodeURIComponent(address); - const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`; - return this.json(path); - } - - /** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */ - getAddressUtxos(address: string): Promise { - return this.json(`/address/${encodeURIComponent(address)}/utxo`); - } - - /** GET /tx/{txid}. */ - getTx(txid: string): Promise { - return this.json(`/tx/${encodeURIComponent(txid)}`); - } - - /** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */ - async getTxHex(txid: string): Promise { - const hex = (await this.text(`/tx/${encodeURIComponent(txid)}/hex`)).trim(); - if (!/^[0-9a-fA-F]+$/.test(hex)) { - throw new BackendError(`esplora returned a non-hex body for tx ${txid}`, 502, 'ESPLORA_ERROR'); - } - return hex; - } - - /** GET /blocks/tip/height — plain integer in the body. */ - async getTipHeight(): Promise { - const body = (await this.text('/blocks/tip/height')).trim(); - const height = Number(body); - if (!Number.isInteger(height) || height < 0) { - throw new BackendError(`esplora returned a bad tip height: ${body.slice(0, 60)}`, 502, 'ESPLORA_ERROR'); - } - return height; - } - - /** GET /fee-estimates, folded onto the five named tiers. */ - async getFeeEstimates(): Promise { - return mapFeeEstimates(await this.json('/fee-estimates')); - } - - /** - * POST /tx — the body is the raw transaction hex with no JSON wrapper, and the 200 response body is - * the bare txid. A rejection comes back as a 400 with bitcoind's `sendrawtransaction RPC error` text, - * which `text()` above surfaces verbatim; that message is the only diagnosis a caller gets. - */ - async broadcast(rawHex: string): Promise { - const txid = (await this.text('/tx', { method: 'POST', body: rawHex, contentType: 'text/plain' })).trim(); - if (!/^[0-9a-fA-F]{64}$/.test(txid)) { - throw new BackendError(`broadcast did not return a txid: ${txid.slice(0, 200)}`, 502, 'ESPLORA_ERROR'); - } - return txid; - } -} - -/** Alias kept for call sites that read better as a client than as a chain source. */ -export { EsploraChain as EsploraClient }; diff --git a/src/servers/sidecar/wallet/config.ts b/src/servers/sidecar/wallet/config.ts deleted file mode 100644 index de68dc0c..00000000 --- a/src/servers/sidecar/wallet/config.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb'; -import { invalidateAll } from './resolve'; -import type { ChainKind } from './upstream'; -import { CHAIN_KINDS, getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream'; -import type { BitcoinNetwork } from './types'; - -// `/_config` — which endpoint this owner's on-chain wallets read the chain from, and which protocol it -// speaks. -// -// This is the one wallet setting that is NOT per-wallet and NOT key material: an endpoint serves every -// on-chain wallet the owner has, and it holds no secret, because both supported kinds are unauthenticated -// read APIs. It went from an env var to a row for one reason — it is the setting most likely to need -// changing, and the reason to change it (a public explorer rate-limiting you, or seeing every address you -// own) shows up long after the deployment that set it. -// -// TWO KINDS, ONE ROW. Esplora and NBXplorer are stored as separate services but are mutually exclusive: -// saving one deletes the other, so "which endpoint is in use" never depends on precedence. See -// upstream.ts::getChainSource. -// -// PRIVACY, NOT CUSTODY. A chain endpoint learns which addresses you ask about, so pointing this at your -// own indexer is a real privacy upgrade. It is never a custody risk: the endpoint sees no key, and a -// hostile one can lie about balances but cannot move a coin. - -export type ChainProbe = { ok: boolean; blockHeight?: number | null; error?: string; ms?: number }; - -/** What each kind reports itself as, versus what this deployment is configured for. */ -const NETWORK_ALIASES: Record = { - bitcoin: ['mainnet', 'main', 'bitcoin'], - testnet: ['testnet', 'test'], - signet: ['signet'], - regtest: ['regtest'], -}; - -/** Does this endpoint answer, does it speak the protocol we think, and is it on OUR chain? */ -export async function probe(baseUrl: string, kind: ChainKind): Promise { - return kind === 'nbxplorer' ? probeNbxplorer(baseUrl) : probeEsplora(baseUrl); -} - -/** - * `/blocks/tip/height` is the cheapest endpoint Esplora has and the one every implementation has — a - * bare number in the body. Anything else answering (a bitcoind RPC port, a mempool.space *web* root, an - * nginx default page) fails here rather than at the first address scan, which is the whole point: the - * owner finds out while looking at the form. - * - * Esplora does not say which chain it serves, so unlike NBXplorer there is nothing to cross-check here. - */ -async function probeEsplora(baseUrl: string): Promise { - const started = Date.now(); - try { - const res = await fetch(`${baseUrl}/blocks/tip/height`, { - headers: { Accept: 'text/plain' }, - signal: AbortSignal.timeout(8_000), - redirect: 'manual', - }); - const body = (await res.text()).trim(); - if (!res.ok) { - return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started }; - } - const height = Number(body); - if (!Number.isFinite(height) || height <= 0) { - return { - ok: false, - error: `that URL answered, but not like an Esplora API (expected a block height, got "${body.slice(0, 60)}")`, - ms: Date.now() - started, - }; - } - return { ok: true, blockHeight: height, ms: Date.now() - started }; - } catch (err) { - return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started }; - } -} - -/** - * `/v1/cryptos/BTC/status` — NBXplorer's own liveness endpoint, and richer than Esplora's. - * - * It names the chain it is indexing, so a testnet NBXplorer accepted for a mainnet wallet is caught HERE - * rather than by the owner wondering why their balance is zero. That mistake is easy to make (one docker - * flag) and expensive to discover late, so the mismatch is a hard failure, not a warning. - * - * A node still catching up is allowed through with its height reported: it will be correct shortly, and - * refusing to save a config because of a temporary sync state would be its own kind of wrong. - */ -async function probeNbxplorer(baseUrl: string): Promise { - const started = Date.now(); - try { - const res = await fetch(`${baseUrl}/v1/cryptos/BTC/status`, { - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(8_000), - redirect: 'manual', - }); - const body = (await res.text()).trim(); - if (!res.ok) { - return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started }; - } - - let status: { chainHeight?: unknown; networkType?: unknown; isFullySynched?: unknown }; - try { - status = JSON.parse(body) as typeof status; - } catch { - return { - ok: false, - error: `that URL answered, but not like NBXplorer (expected JSON, got "${body.slice(0, 60)}")`, - ms: Date.now() - started, - }; - } - - const height = Number(status.chainHeight); - if (!Number.isFinite(height) || height <= 0) { - return { ok: false, error: 'that endpoint answered but reported no chain height', ms: Date.now() - started }; - } - - const want = getConfig().network; - const got = String(status.networkType ?? '').toLowerCase(); - if (got && !NETWORK_ALIASES[want].includes(got)) { - return { - ok: false, - error: `that NBXplorer is indexing ${got}, but this wallet is on ${want}. Refusing to point a ${want} wallet at a ${got} node.`, - ms: Date.now() - started, - }; - } - - return { ok: true, blockHeight: height, ms: Date.now() - started }; - } catch (err) { - return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started }; - } -} - -const bad = (error: string, status = 400) => Response.json({ error }, { status }); - -const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); - -/** - * The endpoint in use, which protocol it speaks, and whether it is the owner's or the built-in default. - * No secrets exist here at all. - * - * `esploraUrl` is kept as a field name despite now possibly holding an NBXplorer URL: it is the URL in - * use, and renaming it would break the stored dashboard state of anything already reading it. `kind` - * says what it actually is. - */ -async function state(userId: number): Promise { - const source = await getChainSource(userId); - const connection = source.custom ? await getServiceConnection(userId, source.kind) : null; - return Response.json({ - configured: !!connection, - connection, - kind: source.kind, - esploraUrl: source.baseUrl, - custom: source.custom, - defaultUrl: getConfig().esploraUrl, - network: getConfig().network, - }); -} - -type ConfigBody = { url?: unknown; kind?: unknown }; - -const isChainKind = (v: unknown): v is ChainKind => - typeof v === 'string' && (CHAIN_KINDS as readonly string[]).includes(v); - -async function save(req: Request, userId: number): Promise { - const body = ((await req.json().catch(() => null)) as ConfigBody | null) ?? {}; - const url = typeof body.url === 'string' ? normalizeEsploraUrl(body.url) : ''; - // Absent kind means a client written before the picker existed; those only ever spoke Esplora. - const kind: ChainKind = isChainKind(body.kind) ? body.kind : 'esplora'; - - if (!url) return bad('url is required'); - if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); - - const result = await probe(url, kind); - if (!result.ok) return bad(result.error ?? 'that endpoint did not answer'); - - await saveServiceConnection({ userId, service: kind, url }); - // Exactly one chain source exists at a time. Retiring the other kind here is what lets getChainSource - // answer without a precedence rule, and what stops a stale row from resurfacing if this one is deleted. - for (const other of CHAIN_KINDS) if (other !== kind) await deleteServiceConnection(userId, other); - - invalidateChainSource(userId); - // Every on-chain backend holds a chain source built from the old URL, and a cached gap-limit scan taken - // through it. Both are now wrong, so drop the instances rather than trying to patch them. - invalidateAll(); - - return Response.json({ ...(await state(userId).then((r) => r.json())), probe: result }); -} - -/** `/_config` — GET what is in use, PUT to change it, DELETE to fall back to the built-in default. */ -export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { - if (subpath && subpath !== '/') return bad('not found', 404); - - if (req.method === 'GET') return state(userId); - if (req.method === 'POST' || req.method === 'PUT') return save(req, userId); - - if (req.method === 'DELETE') { - // Both, not just the active one: "reset to default" has to mean it regardless of which row exists. - for (const kind of CHAIN_KINDS) await deleteServiceConnection(userId, kind); - invalidateChainSource(userId); - invalidateAll(); - return state(userId); - } - - return bad('method not allowed', 405); -} diff --git a/src/servers/sidecar/wallet/index.ts b/src/servers/sidecar/wallet/index.ts deleted file mode 100644 index b809befa..00000000 --- a/src/servers/sidecar/wallet/index.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { SidecarCommand, SidecarEvent } from '../protocol'; -import type { ChainSource } from './upstream'; -import { createSidecarConnector } from '../connect'; -import { handleConfigRoute, probe } from './config'; -import { handleOfficerRoute } from './routes'; -import { getChainSource, getConfig, hasStoreKey } from './upstream'; -import { lockAll } from './keys'; -import { invalidateAll } from './resolve'; -import { API_URL } from '../../officer-url.mjs'; - -// The officer-wallet sidecar. A bitcoin wallet in the shape Zeus models one — several interchangeable -// backends behind one interface — but server-side, with the key material held here and nowhere else. -// -// WHY THIS PROCESS EXISTS SEPARATELY. Seeds and node credentials never enter the main Officer process. -// The platform is a thin auth proxy (src/servers/api/wallet/router.ts) that forwards to this port and -// holds nothing: no seed, no macaroon, no xpub. Compromising `officer` gets an attacker the ability to -// *call* this sidecar as the authenticated owner — it does not get them a key, and it cannot spend from a -// locked wallet, because a locked wallet has no key material in memory at all. -// -// BACKENDS (mirroring _references/zeus/backends/): -// onchain — self-custodial. BIP39 seed sealed under an owner passphrase (keys.ts), BIP84/86/49 -// derivation, Esplora for chain data, bitcoinjs-lib for PSBT construction. This is the -// server analogue of Zeus's EmbeddedLND/LdkNode, which are native-module-bound and cannot -// be ported. Watch-only while locked; unlock only to sign. -// lnd — LND REST + macaroon (ported from backends/LND.ts) -// cln-rest — Core Lightning CLNRest + rune (ported from backends/CLNRest.ts) -// lndhub — custodial LNDHub/BlueWallet REST (ported from backends/LndHub.ts) -// nwc — Nostr Wallet Connect, NIP-47 (ported from backends/NostrWalletConnect.ts) -// -// When the owner runs their own node, it registers as an `lnd` or `cln-rest` wallet — no code change. -// -// ───────────────────────────────────────────────────────────────────────────────────────────────── -// HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding. -// -// GET /_health ours. Reports network, chain reachability, store key. -// GET /_config the owner's chain endpoint (or the built-in default) -// PUT /_config {url, kind} — probed as that kind before it is stored -// DELETE /_config fall back to the built-in default -// GET /_officer/config network + unlock TTL + storeKeyConfigured -// -// GET /_officer/wallets list. Never includes secrets. -// POST /_officer/wallets create. Seeded wallets return the mnemonic ONCE, -// and only when the sidecar generated it. -// GET /_officer/wallets/active the currently selected wallet -// GET /_officer/wallets/:id one wallet -// PATCH /_officer/wallets/:id rename / defaultBip / config -// DELETE /_officer/wallets/:id {passphrase} required when the wallet holds a seed -// POST /_officer/wallets/:id/activate -// -// GET /_officer/wallets/:id/lock-state {hasSeed, unlocked, secondsRemaining} -// POST /_officer/wallets/:id/unlock {passphrase, ttlSec?} -// POST /_officer/wallets/:id/lock -// POST /_officer/wallets/:id/passphrase {oldPassphrase, newPassphrase} -// POST /_officer/wallets/:id/export-seed {passphrase} → the mnemonic. Logged as a warning. -// -// GET /_officer/wallets/:id/capabilities what this backend can actually do -// GET /_officer/wallets/:id/info node/chain identity + sync state -// GET /_officer/wallets/:id/balances on-chain confirmed/unconfirmed + lightning local/inbound -// GET /_officer/wallets/:id/transactions?limit on-chain history, owner labels overlaid -// GET /_officer/wallets/:id/address?peek fresh receive address -// GET /_officer/wallets/:id/utxos coin control view, freeze flags + labels overlaid -// POST /_officer/wallets/:id/utxos/freeze {outpoint, frozen, reason?} -// GET /_officer/wallets/:id/fees sat/vB estimates -// POST /_officer/wallets/:id/send on-chain spend. Requires an unlocked wallet. -// GET /_officer/wallets/:id/invoices?limit -// POST /_officer/wallets/:id/invoices create -// GET /_officer/wallets/:id/invoices/:hash lookup -// POST /_officer/wallets/:id/decode {bolt11} -// GET /_officer/wallets/:id/payments?limit -// POST /_officer/wallets/:id/pay {bolt11, amountMsat?, feeLimit…} -// POST /_officer/wallets/:id/keysend {destination, amountMsat} -// GET /_officer/wallets/:id/channels -// GET /_officer/wallets/:id/peers -// POST /_officer/wallets/:id/sign {message} -// POST /_officer/wallets/:id/verify {message, signature} -// GET|POST /_officer/wallets/:id/labels owner annotations for addresses and txids -// -// anything else 404 -// -// Operations a backend cannot perform return 501 with code NOT_SUPPORTED, checked against its declared -// capability set before dispatch — never a confusing upstream error. A locked wallet returns 423 -// WALLET_LOCKED from signing paths only; every read above keeps working. -// ───────────────────────────────────────────────────────────────────────────────────────────────── - -/** Grab an ephemeral free port by briefly binding one and releasing it. */ -function getFreePort(): number { - const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); - const p = probeServer.port; - probeServer.stop(true); - if (p == null) throw new Error('failed to acquire a free port'); - return p; -} - -const port = getFreePort(); - -const server = Bun.serve({ - port, - hostname: '127.0.0.1', - // Wallet payloads are small — PSBTs and invoices, never file uploads. A tight cap is free hardening. - maxRequestBodySize: 1 * 1024 * 1024, - // Bun's default is 10s, which is SHORTER than the Esplora client's own 20s per-request timeout. That - // inversion is why a slow or unreachable upstream showed up as a wallet with no data at all rather than - // as an error: Bun killed the response before the scan could either finish or report why it hadn't. - // A gap-limit scan is many sequential rounds, so the ceiling has to sit above the whole walk, not one - // request. Bun caps this at 255s; every other sidecar already uses that. - idleTimeout: 255, - async fetch(req) { - const url = new URL(req.url); - - // `/_config` is the chain endpoint, which is per-owner — so unlike /_health it needs the header. - if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { - const officerUser = req.headers.get('X-Officer-User'); - const userId = Number(officerUser); - if (!officerUser || !Number.isInteger(userId) || userId <= 0) { - return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); - } - try { - return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); - } catch (err) { - console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - if (url.pathname === '/_health') { - const cfg = getConfig(); - // Health has to probe the endpoint the wallets ACTUALLY use, not the built-in default — reporting - // the default green while the owner's own indexer is down would be worse than no health at all. - // The header is optional here (health predates it), so fall back rather than 401. - const healthUser = Number(req.headers.get('X-Officer-User')); - const chain: ChainSource = - Number.isInteger(healthUser) && healthUser > 0 - ? await getChainSource(healthUser) - : { kind: 'esplora', baseUrl: cfg.esploraUrl, custom: false }; - - // Same probe the config form runs, so "saved successfully" and "healthy" can never disagree about - // what a working endpoint is — including the network cross-check on NBXplorer. - const result = await probe(chain.baseUrl, chain.kind); - - return Response.json( - { - ok: result.ok, - network: cfg.network, - kind: chain.kind, - esplora: chain.baseUrl, - custom: chain.custom, - blockHeight: result.blockHeight ?? null, - ...(result.error ? { error: result.error } : {}), - // Surfaced because wallet creation is refused without it, and that failure would otherwise - // look like a bug rather than a missing config line. - storeKeyConfigured: hasStoreKey(), - ms: result.ms, - }, - { status: result.ok ? 200 : 502 }, - ); - } - - if (url.pathname.startsWith('/_officer/')) { - try { - const res = await handleOfficerRoute(req, url); - if (res) return res; - return Response.json({ error: 'not found' }, { status: 404 }); - } catch (err) { - // Method and path only. Bodies on this sidecar carry passphrases and mnemonics. - console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - return Response.json({ error: 'not found' }, { status: 404 }); - }, -}); - -// No esplora URL in the banner: it is per-owner state read from the database now, not a boot constant. -console.log(`[wallet] listening on 127.0.0.1:${port} — network=${getConfig().network}`); -if (!hasStoreKey()) { - console.warn('[wallet] the secret store is unreachable — wallet creation will be refused until it is'); -} - -type ReplyFn = (msg: SidecarEvent) => void; - -function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { - switch (cmd.type) { - case 'ping': - reply({ type: 'pong', id: cmd.id }); - break; - default: - reply({ - type: 'error', - id: (cmd as SidecarCommand).id, - error: `Unknown command type: ${(cmd as Record).type}`, - }); - } -} - -const connection = createSidecarConnector({ - apiUrl: `${API_URL}/api/sidecar/register`, - name: 'wallet', - handles: ['wallet'], - onCommand(cmd, reply) { - handleCommand(cmd as SidecarCommand, reply as ReplyFn); - }, - onConnected() { - connection.send({ type: 'wallet:server', port }); - console.log(`[wallet] reported server port ${port} to API`); - }, -}); - -function shutdown(signal: string) { - console.log(`[wallet] ${signal} received, locking all wallets and shutting down...`); - // Wipe key material before anything else. This is best-effort — see the caveat in keys.ts — but it - // costs nothing and closes the obvious window on a graceful restart. - lockAll(); - invalidateAll(); - try { - server.stop(true); - } catch { - /* already stopped */ - } - connection.destroy(); - process.exit(0); -} - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/sidecar/wallet/keys.test.ts b/src/servers/sidecar/wallet/keys.test.ts deleted file mode 100644 index 96ec2d57..00000000 --- a/src/servers/sidecar/wallet/keys.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { describe, test, expect } from 'bun:test'; -import { - sealSeed, - generateSeed, - deriveAccountXpubs, - UnlockSession, - verifyPassphrase, - exportMnemonic, - changePassphrase, -} from './keys'; - -/** - * The seed custody core. This is the one file in the wallet where a bug is unrecoverable rather than - * merely wrong — a mistake here either loses the owner's coins or leaks the key that spends them, so the - * properties below are asserted rather than assumed. - * - * scrypt at N=2^17 makes each seal/open deliberately expensive (~1s), which is the entire point of the - * parameter and also why this file carries a raised timeout instead of a smaller N. - */ - -// BIP39's canonical all-`abandon` vector, and the BIP32 root fingerprint it must produce. -const VECTOR = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; -const ROOT_FINGERPRINT = '73c5da0a'; -const PASS = 'correct horse battery staple'; - -const SLOW = 60_000; - -describe('sealSeed / deriveAccountXpubs', () => { - test( - 'seals the vector and derives all four accounts', - async () => { - const env = await sealSeed(VECTOR, PASS); - const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin'); - - expect(env.v).toBe(1); - expect(fingerprint).toBe(ROOT_FINGERPRINT); - for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy(); - }, - SLOW, - ); - - test( - 'the envelope never contains the plaintext mnemonic', - async () => { - const env = await sealSeed(VECTOR, PASS); - expect(JSON.stringify(env)).not.toContain('abandon'); - }, - SLOW, - ); - - test( - 'sealing is non-deterministic but derivation is stable', - async () => { - // Fresh salt and nonce per seal, so two seals of one seed must not be byte-identical — otherwise a - // DB dump would reveal which wallets share a seed. - const [a, b] = [await sealSeed(VECTOR, PASS), await sealSeed(VECTOR, PASS)]; - expect(JSON.stringify(a)).not.toBe(JSON.stringify(b)); - - const [xa, xb] = [await deriveAccountXpubs(a, PASS, 'bitcoin'), await deriveAccountXpubs(b, PASS, 'bitcoin')]; - expect(xb.xpubs[84]).toBe(xa.xpubs[84]); - }, - SLOW, - ); - - test( - 'rejects a mnemonic that fails its checksum', - async () => { - const bad = VECTOR.replace(/about$/, 'abandon'); - await expect(sealSeed(bad, PASS)).rejects.toThrow(/BIP39/i); - }, - SLOW, - ); - - test( - 'rejects a passphrase under the minimum length', - async () => { - await expect(sealSeed(VECTOR, 'short')).rejects.toThrow(/8 characters/); - }, - SLOW, - ); - - test( - 'testnet derives a different account under the same root', - async () => { - const env = await sealSeed(VECTOR, PASS); - const [main, test_] = [ - await deriveAccountXpubs(env, PASS, 'bitcoin'), - await deriveAccountXpubs(env, PASS, 'testnet'), - ]; - // Coin type 1' vs 0' — a different account, but the same seed, so the same root fingerprint. - expect(test_.xpubs[84]).not.toBe(main.xpubs[84]); - expect(test_.fingerprint).toBe(main.fingerprint); - }, - SLOW, - ); -}); - -/** - * A throwaway wallet id per call. Every passphrase check is now rate-limited per wallet id in module - * state, so tests that share one would start locking each other out after five wrong guesses. - */ -let idSeq = 0; -const wid = () => ++idSeq; - -describe('passphrase handling', () => { - test( - 'accepts the right passphrase and rejects a wrong one', - async () => { - const env = await sealSeed(VECTOR, PASS); - expect(await verifyPassphrase(wid(), env, PASS)).toBe(true); - expect(await verifyPassphrase(wid(), env, 'not the passphrase')).toBe(false); - }, - SLOW, - ); - - test( - 'locks out after five wrong guesses, on export as well as unlock', - async () => { - const env = await sealSeed(VECTOR, PASS); - const id = wid(); - - // The backoff used to live inside UnlockSession.unlock only, so export-seed — which returns the - // words in the clear — took unlimited guesses. Exercised through exportMnemonic for that reason. - for (let i = 0; i < 5; i++) { - await expect(exportMnemonic(id, env, `wrong ${i}`)).rejects.toThrow(/incorrect passphrase/); - } - await expect(exportMnemonic(id, env, `wrong 5`)).rejects.toThrow(/too many failed attempts/); - // The right passphrase is refused too — otherwise the lockout would only slow a guesser down. - await expect(exportMnemonic(id, env, PASS)).rejects.toThrow(/too many failed attempts/); - // And it is not folded into a bland `false` by the boolean-returning sibling. - await expect(verifyPassphrase(id, env, PASS)).rejects.toThrow(/too many failed attempts/); - }, - SLOW, - ); - - test( - 'round-trips the mnemonic exactly', - async () => { - const env = await sealSeed(VECTOR, PASS); - expect(await exportMnemonic(wid(), env, PASS)).toBe(VECTOR); - }, - SLOW, - ); - - test( - 'rotation swaps the passphrase without disturbing the seed', - async () => { - const env = await sealSeed(VECTOR, PASS); - const next = 'an entirely different passphrase'; - const rotated = await changePassphrase(wid(), env, PASS, next); - - expect(await verifyPassphrase(wid(), rotated, next)).toBe(true); - expect(await verifyPassphrase(wid(), rotated, PASS)).toBe(false); - expect(await exportMnemonic(wid(), rotated, next)).toBe(VECTOR); - - // The xpubs are stored alongside the envelope; if rotation changed them the wallet would silently - // start watching a different account and report a zero balance. - const [before, after] = [ - await deriveAccountXpubs(env, PASS, 'bitcoin'), - await deriveAccountXpubs(rotated, next, 'bitcoin'), - ]; - expect(after.xpubs[84]).toBe(before.xpubs[84]); - expect(after.fingerprint).toBe(before.fingerprint); - }, - SLOW, - ); -}); - -describe('UnlockSession', () => { - test('holds no key material until unlocked', async () => { - const s = new UnlockSession(1); - expect(s.isUnlocked()).toBe(false); - expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i); - }); - - test( - 'unlocks, derives the stored xpub, then relocks', - async () => { - const env = await sealSeed(VECTOR, PASS); - const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin'); - - const s = new UnlockSession(1); - await s.unlock(env, PASS, 60); - expect(s.isUnlocked()).toBe(true); - expect(s.secondsRemaining()).toBeGreaterThan(0); - expect(s.secondsRemaining()).toBeLessThanOrEqual(60); - - // Proves the unlocked root is the same key the watch-only xpub came from. - expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]); - - s.lock(); - expect(s.isUnlocked()).toBe(false); - expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i); - }, - SLOW, - ); - - test( - 'a failed unlock leaves the session locked', - async () => { - const env = await sealSeed(VECTOR, PASS); - const s = new UnlockSession(1); - await expect(s.unlock(env, 'the wrong passphrase', 60)).rejects.toThrow(); - expect(s.isUnlocked()).toBe(false); - }, - SLOW, - ); -}); - -describe('aezeed seeds', () => { - // The same fixture the aezeed suite uses: entropy 000102…0f under the default passphrase. That entropy - // is BIP32 test vector 1's master seed, so the fingerprint below is a published constant from a third - // specification — this asserts the whole chain (words → AEZ → BIP32) against something outside Officer. - const AEZEED = - 'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen'; - const BIP32_VECTOR_1_FINGERPRINT = '3442193e'; - - test( - 'seals as v2 and derives the BIP32 vector root', - async () => { - const env = await sealSeed(AEZEED, PASS); - expect(env.v).toBe(2); - - const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin'); - expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT); - for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy(); - }, - SLOW, - ); - - test( - 'does NOT derive what the BIP39 path would have', - async () => { - // The failure this whole `kind` mechanism exists to prevent. An aezeed phrase is 24 valid wordlist - // words, so `mnemonicToSeedSync` accepts it happily and returns a perfectly real — and completely - // wrong — master key, pointing at an empty wallet the owner cannot tell apart from a bad rescan. - const { mnemonicToSeedSync } = await import('@scure/bip39'); - const { HDKey } = await import('@scure/bip32'); - const wrong = HDKey.fromMasterSeed(mnemonicToSeedSync(AEZEED)); - const wrongFingerprint = Buffer.from(new Uint8Array(new Uint32Array([wrong.fingerprint]).buffer)) - .reverse() - .toString('hex'); - expect(wrongFingerprint).not.toBe(BIP32_VECTOR_1_FINGERPRINT); - - const env = await sealSeed(AEZEED, PASS); - const { fingerprint } = await deriveAccountXpubs(env, PASS, 'bitcoin'); - expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT); - }, - SLOW, - ); - - test( - 'the words survive an export round trip', - async () => { - const env = await sealSeed(AEZEED, PASS); - expect(await exportMnemonic(wid(), env, PASS)).toBe(AEZEED); - }, - SLOW, - ); - - test( - 'unlocking derives the same root as the stored xpub', - async () => { - const env = await sealSeed(AEZEED, PASS); - const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin'); - - const s = new UnlockSession(2); - await s.unlock(env, PASS, 60); - expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]); - s.lock(); - }, - SLOW, - ); - - test( - 'a wrong seed passphrase is refused at seal time, not silently accepted', - async () => { - // AEZ's 4-byte tag makes this loud. Without the check the wallet would seal fine and only reveal - // itself later as an empty balance — the single most expensive way to find out. - await expect(sealSeed(AEZEED, PASS, 'not the seed passphrase')).rejects.toThrow(/passphrase/i); - }, - SLOW, - ); - - test( - 'a phrase that is neither BIP39 nor aezeed is still rejected', - async () => { - const bad = AEZEED.split(' '); - [bad[3], bad[4]] = [bad[4]!, bad[3]!]; - await expect(sealSeed(bad.join(' '), PASS)).rejects.toThrow(/BIP39/i); - }, - SLOW, - ); - - test( - 'rotation preserves the aezeed kind and its derivation', - async () => { - const env = await sealSeed(AEZEED, PASS); - const next = 'an entirely different passphrase'; - const rotated = await changePassphrase(wid(), env, PASS, next); - - expect(rotated.v).toBe(2); - const after = await deriveAccountXpubs(rotated, next, 'bitcoin'); - expect(after.fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT); - }, - SLOW, - ); -}); - -describe('generateSeed', () => { - test( - 'produces a distinct, valid mnemonic that survives a round trip', - async () => { - const mnemonic = generateSeed(); - expect([12, 24]).toContain(mnemonic.split(' ').length); - expect(generateSeed()).not.toBe(mnemonic); - - const env = await sealSeed(mnemonic, 'a sufficiently long passphrase'); - expect(await exportMnemonic(wid(), env, 'a sufficiently long passphrase')).toBe(mnemonic); - }, - SLOW, - ); -}); diff --git a/src/servers/sidecar/wallet/keys.ts b/src/servers/sidecar/wallet/keys.ts deleted file mode 100644 index 17901c06..00000000 --- a/src/servers/sidecar/wallet/keys.ts +++ /dev/null @@ -1,484 +0,0 @@ -import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; -import { promisify } from 'node:util'; -import { HDKey } from '@scure/bip32'; -import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; -import { wordlist } from '@scure/bip39/wordlists/english'; -import type { BitcoinNetwork } from './types'; -import { AezeedError, DEFAULT_AEZEED_PASSPHRASE, decipherMnemonic, isAezeedPhrase } from './aezeed/cipher-seed'; -import { BackendError, WalletLockedError } from './types'; - -// Seed custody for the wallet sidecar. -// -// THE THREAT MODEL, stated plainly, because it is the whole point of this file: -// -// Zeus stores its seed phrases as plaintext inside a JSON settings blob and leans entirely on the OS -// keychain (storage/index.ts + stores/SettingsStore.ts:32-67 — `seedPhrase?: string[]`). A phone has a -// secure enclave and a screen lock; a server has neither. So none of Zeus's key handling is reusable -// here and this is written from scratch. -// -// The seed is protected by TWO independent secrets, and an attacker needs BOTH: -// -// 1. An owner passphrase, which is never persisted anywhere. It derives a KEK via scrypt and that -// KEK wraps the random per-wallet DEK that actually encrypts the mnemonic. -// 2. the 'wallet' key from the secret store, applied by queries/wallet.ts (../../databases/officer_db) -// over the already-encrypted envelope before it touches Postgres. -// -// Consequence: a stolen database dump is useless without .env, a stolen .env is useless without the -// passphrase, and a full server compromise still cannot spend while the wallet is locked, because a -// locked wallet holds no key material in memory at all. -// -// WATCH-ONLY WHILE LOCKED. The account xpubs are stored in the clear on purpose. Balances, history and -// receive addresses therefore work with the wallet locked and the passphrase nowhere on the machine — -// unlocking is required only to SIGN. This is the single most important property here: the wallet spends -// almost all of its life locked and still fully readable. -// -// WHAT THIS CANNOT DO. Once unlocked, the root key is in the Bun process's heap and Node gives no way to -// pin or reliably wipe it — GC may have copied it. `zeroize()` scrubs the buffers we own, which shrinks -// the window but does not close it. That is why the unlock TTL is short and defaults tight. - -const scrypt = promisify(scryptCb) as ( - password: string | Buffer, - salt: Buffer, - keylen: number, - options: { N: number; r: number; p: number; maxmem: number }, -) => Promise; - -// N=2^17 / r=8 / p=1 → ~128 MiB and ~1s per attempt on this class of hardware. Deliberately painful: -// this is the only thing standing between a leaked database + .env and the coins. Node's default maxmem -// is 32 MiB, which these parameters blow through, so it must be raised explicitly or scrypt throws. -const SCRYPT_N = 1 << 17; -const SCRYPT_R = 8; -const SCRYPT_P = 1; -const SCRYPT_MAXMEM = 256 * 1024 * 1024; -const KEY_LEN = 32; - -/** Bump when the KDF parameters or envelope layout change, so old envelopes can be migrated on unlock. */ -// Two seed formats, two envelope versions. -// -// v1 — BIP39 mnemonic. The master seed is PBKDF2(mnemonic, "mnemonic" + passphrase). -// v2 — LND aezeed cipher seed (what Zeus's embedded node produces). Its 16-byte entropy IS the BIP32 -// master seed, with no PBKDF2 step at all. -// -// The bump is not cosmetic. Both formats are 24 words from the same wordlist, so a build that predates -// aezeed support would happily read a v2 envelope, run the words through BIP39 derivation, and produce a -// completely different — empty — wallet without erroring. Refusing an unknown version is what makes a -// platform rollback safe. -const ENVELOPE_VERSION_BIP39 = 1; -const ENVELOPE_VERSION_AEZEED = 2; - -export type SeedKind = 'bip39' | 'aezeed'; - -export type SeedEnvelope = { - v: number; - /** base64, 16 bytes — scrypt salt for the KEK. */ - salt: string; - /** base64(iv[12] | tag[16] | ciphertext) — the DEK, wrapped under the passphrase-derived KEK. */ - wrappedDek: string; - /** base64(iv[12] | tag[16] | ciphertext) — the seed phrase, encrypted under the DEK. */ - seed: string; - /** - * Whether a seed passphrase is part of this seed — the BIP39 "25th word", or the aezeed passphrase. - * Affects derivation, not secrecy. - */ - hasBip39Passphrase: boolean; -}; - -// ── AES-256-GCM primitives ─────────────────────────────────────────────────────────────────────── - -function gcmEncrypt(key: Buffer, plaintext: Buffer): string { - const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', key, iv); - const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); - return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64'); -} - -function gcmDecrypt(key: Buffer, blob: string): Buffer { - const buf = Buffer.from(blob, 'base64'); - if (buf.length < 29) throw new BackendError('malformed ciphertext', 500, 'BAD_ENVELOPE'); - const decipher = createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12)); - decipher.setAuthTag(buf.subarray(12, 28)); - return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]); -} - -/** Best-effort scrub of a buffer we own. See the caveat in the header comment. */ -function zeroize(buf: Buffer | null): void { - if (buf) buf.fill(0); -} - -// ── envelope construction ──────────────────────────────────────────────────────────────────────── - -async function deriveKek(passphrase: string, salt: Buffer): Promise { - return scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, { - N: SCRYPT_N, - r: SCRYPT_R, - p: SCRYPT_P, - maxmem: SCRYPT_MAXMEM, - }); -} - -/** - * Prove an aezeed actually opens before we seal it. Sealing an undecipherable seed would store something - * permanently unusable behind the owner's passphrase and only surface at unlock — possibly months later, - * with the original backup long since put away. - */ -async function assertAezeedOpens(words: string[], seedPassphrase: string | undefined): Promise { - try { - const seed = await decipherMnemonic(words, seedPassphrase || DEFAULT_AEZEED_PASSPHRASE); - seed.entropy.fill(0); - } catch (err) { - if (err instanceof AezeedError && err.code === 'BAD_PASSPHRASE') { - throw new BackendError( - 'this is an LND (aezeed) seed and the passphrase is wrong — leave it blank if you never set one', - 400, - 'BAD_SEED_PASSPHRASE', - ); - } - throw new BackendError('not a valid aezeed cipher seed', 400, 'BAD_MNEMONIC'); - } -} - -/** - * Wrap a seed phrase into a sealed envelope. The phrase is validated first — importing a typo'd phrase - * silently produces a valid-but-wrong wallet that shows a zero balance, which is a genuinely awful - * failure mode to debug. - * - * The format is detected from the phrase itself rather than asked of the caller. A BIP39 mnemonic and an - * LND aezeed are both 24 words drawn from the same wordlist and are indistinguishable by eye, so the - * owner cannot reasonably be expected to know which one their wallet gave them — but the bits can tell - * us for certain (SHA-256 checksum vs CRC32-Castagnoli). - */ -export async function sealSeed( - mnemonic: string, - ownerPassphrase: string, - bip39Passphrase?: string, -): Promise { - const normalized = mnemonic.trim().replace(/\s+/g, ' ').toLowerCase(); - const words = normalized.split(' '); - - let kind: SeedKind; - if (validateMnemonic(normalized, wordlist)) { - kind = 'bip39'; - } else if (isAezeedPhrase(words)) { - kind = 'aezeed'; - await assertAezeedOpens(words, bip39Passphrase); - } else { - throw new BackendError('not a valid BIP39 mnemonic (checksum or wordlist mismatch)', 400, 'BAD_MNEMONIC'); - } - - if (ownerPassphrase.length < 8) { - throw new BackendError('unlock passphrase must be at least 8 characters', 400, 'WEAK_PASSPHRASE'); - } - - const salt = randomBytes(16); - const kek = await deriveKek(ownerPassphrase, salt); - const dek = randomBytes(KEY_LEN); - // The BIP39 passphrase lives inside the encrypted payload, not beside it: it is as sensitive as the - // words themselves, since together they are the wallet. - const payload = Buffer.from( - JSON.stringify({ mnemonic: normalized, bip39Passphrase: bip39Passphrase ?? '', kind }), - 'utf8', - ); - - try { - return { - v: kind === 'aezeed' ? ENVELOPE_VERSION_AEZEED : ENVELOPE_VERSION_BIP39, - salt: salt.toString('base64'), - wrappedDek: gcmEncrypt(kek, dek), - seed: gcmEncrypt(dek, payload), - hasBip39Passphrase: Boolean(bip39Passphrase), - }; - } finally { - zeroize(kek); - zeroize(dek); - zeroize(payload); - } -} - -/** Generate a fresh 12- or 24-word mnemonic. 24 words (256 bits) is the default. */ -export function generateSeed(words: 12 | 24 = 24): string { - return generateMnemonic(wordlist, words === 12 ? 128 : 256); -} - -type OpenedSeed = { mnemonic: string; bip39Passphrase: string; kind?: SeedKind }; - -async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise { - if (env.v !== ENVELOPE_VERSION_BIP39 && env.v !== ENVELOPE_VERSION_AEZEED) { - throw new BackendError(`unsupported seed envelope version ${env.v}`, 500, 'BAD_ENVELOPE'); - } - const kek = await deriveKek(ownerPassphrase, Buffer.from(env.salt, 'base64')); - let dek: Buffer | null = null; - try { - // A wrong passphrase fails here, as a GCM tag mismatch. That is the ONLY signal — we never store a - // verifier hash of the passphrase, because a verifier is an offline-crackable oracle. - dek = gcmDecrypt(kek, env.wrappedDek); - const payload = gcmDecrypt(dek, env.seed); - try { - const opened = JSON.parse(payload.toString('utf8')) as OpenedSeed; - // Envelopes sealed before aezeed support carry no `kind`; the version is the fallback authority, - // and v1 has only ever meant BIP39. - return { ...opened, kind: opened.kind ?? (env.v === ENVELOPE_VERSION_AEZEED ? 'aezeed' : 'bip39') }; - } finally { - zeroize(payload); - } - } catch (err) { - if (err instanceof BackendError) throw err; - throw new BackendError('incorrect passphrase', 401, 'BAD_PASSPHRASE'); - } finally { - zeroize(kek); - zeroize(dek); - } -} - -// ── derivation ─────────────────────────────────────────────────────────────────────────────────── - -export type Bip = 44 | 49 | 84 | 86; - -/** Mainnet is coin type 0; every test network shares coin type 1 (BIP44). */ -export function coinType(network: BitcoinNetwork): 0 | 1 { - return network === 'bitcoin' ? 0 : 1; -} - -export function accountPath(bip: Bip, network: BitcoinNetwork, account = 0): string { - return `m/${bip}'/${coinType(network)}'/${account}'`; -} - -/** - * The one place the two seed formats diverge, and the reason `kind` has to be carried at all. - * - * BIP39 stretches the words into a 64-byte master seed via PBKDF2. aezeed does not stretch anything — - * the 16 bytes recovered from the cipher seed ARE the BIP32 master seed, exactly as lnd/btcwallet uses - * them. Feeding an aezeed phrase through the BIP39 path would succeed, derive real-looking keys, and - * point at an entirely different (empty) wallet. - */ -async function rootFromSeed(opened: OpenedSeed): Promise { - if (opened.kind === 'aezeed') { - const seed = await decipherMnemonic( - opened.mnemonic.split(' '), - opened.bip39Passphrase || DEFAULT_AEZEED_PASSPHRASE, - ); - try { - return HDKey.fromMasterSeed(seed.entropy); - } finally { - seed.entropy.fill(0); - } - } - - const seed = Buffer.from(mnemonicToSeedSync(opened.mnemonic, opened.bip39Passphrase || undefined)); - try { - return HDKey.fromMasterSeed(seed); - } finally { - zeroize(seed); - } -} - -/** - * Derive the public account descriptors WITHOUT retaining any private material. Called once at import - * time; the returned xpubs are stored in the clear and are what makes watch-only-while-locked work. - */ -export async function deriveAccountXpubs( - env: SeedEnvelope, - ownerPassphrase: string, - network: BitcoinNetwork, -): Promise<{ fingerprint: string; xpubs: Record }> { - const opened = await openEnvelope(env, ownerPassphrase); - const root = await rootFromSeed(opened); - try { - const fingerprint = Buffer.from(new Uint8Array(new Uint32Array([root.fingerprint]).buffer)) - .reverse() - .toString('hex'); - const xpubs = {} as Record; - for (const bip of [44, 49, 84, 86] as const) { - const node = root.derive(accountPath(bip, network)); - xpubs[bip] = node.publicExtendedKey; - } - return { fingerprint, xpubs }; - } finally { - root.wipePrivateData(); - } -} - -// ── the unlock session ─────────────────────────────────────────────────────────────────────────── - -const DEFAULT_TTL_SEC = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900); - -// Brute-force resistance. scrypt already makes each guess cost ~1s and ~128 MiB, but an attacker with -// the DB and .env can grind offline anyway — this only protects the live endpoint. Backoff is per -// wallet id and resets on success. -const MAX_ATTEMPTS = 5; -const LOCKOUT_MS = 60_000; - -type Attempts = { count: number; lockedUntil: number }; -const attempts = new Map(); - -function checkLockout(walletId: number): void { - const a = attempts.get(walletId); - if (a && a.lockedUntil > Date.now()) { - const secs = Math.ceil((a.lockedUntil - Date.now()) / 1000); - throw new BackendError(`too many failed attempts, retry in ${secs}s`, 429, 'LOCKED_OUT'); - } -} - -function recordFailure(walletId: number): void { - const a = attempts.get(walletId) ?? { count: 0, lockedUntil: 0 }; - a.count += 1; - if (a.count >= MAX_ATTEMPTS) { - a.lockedUntil = Date.now() + LOCKOUT_MS; - a.count = 0; - } - attempts.set(walletId, a); -} - -/** - * A live, unlocked wallet. Holds the derived root key in memory and nothing else — the mnemonic itself - * is decrypted, converted to a root key, and dropped inside `unlock()`; it is never retained. - * - * Structurally satisfies the `Signer` interface that backends/onchain.ts consumes. - */ -export class UnlockSession { - private root: HDKey | null = null; - private timer: ReturnType | null = null; - private expiresAt = 0; - - constructor(readonly walletId: number) {} - - isUnlocked(): boolean { - return this.root !== null && Date.now() < this.expiresAt; - } - - /** Seconds until auto-lock, or 0 when locked. For the UI's countdown. */ - secondsRemaining(): number { - if (!this.isUnlocked()) return 0; - return Math.max(0, Math.ceil((this.expiresAt - Date.now()) / 1000)); - } - - async unlock(env: SeedEnvelope, ownerPassphrase: string, ttlSec = DEFAULT_TTL_SEC): Promise { - const opened = await openGuarded(this.walletId, env, ownerPassphrase); - - this.lock(); // replace any existing session rather than leaking the old root - this.root = await rootFromSeed(opened); - // Drop the words immediately — the root key is all any signing operation needs. - opened.mnemonic = ''; - opened.bip39Passphrase = ''; - this.arm(ttlSec); - } - - private arm(ttlSec: number): void { - this.expiresAt = Date.now() + ttlSec * 1000; - this.timer = setTimeout(() => this.lock(), ttlSec * 1000); - // Don't hold the event loop open just to auto-lock; shutdown wipes memory anyway. - this.timer.unref?.(); - } - - lock(): void { - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } - this.root?.wipePrivateData(); - this.root = null; - this.expiresAt = 0; - } - - /** - * Run `fn` with the root key. The ONLY way key material leaves this class, and it never escapes as a - * return value by construction — callers get a derived signature, not the key. - * - * Deliberately does NOT slide the TTL. An unlock is a bounded window the owner opened on purpose; - * refreshing it on use would let a compromised session stay open indefinitely by signing. - */ - withRoot(fn: (root: HDKey) => T): T { - if (!this.isUnlocked() || !this.root) { - this.lock(); - throw new WalletLockedError(); - } - return fn(this.root); - } -} - -// One session per wallet id, process-wide. The sidecar is a single process, so this map IS the unlock -// state — there is no cross-process sharing and deliberately no persistence: a sidecar restart relocks -// every wallet, which is the correct default. -const sessions = new Map(); - -export function sessionFor(walletId: number): UnlockSession { - let s = sessions.get(walletId); - if (!s) { - s = new UnlockSession(walletId); - sessions.set(walletId, s); - } - return s; -} - -export function lockAll(): void { - for (const s of sessions.values()) s.lock(); -} - -/** - * Open an envelope under the same backoff `unlock()` uses. - * - * EVERY passphrase check goes through here, not just the session one. The lockout used to live inside - * UnlockSession.unlock alone, which left export-seed — the one endpoint that returns the words in the - * clear — accepting unlimited guesses, while /unlock capped at five a minute. An attacker with a session - * cookie would simply never have used /unlock. - */ -async function openGuarded(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise { - checkLockout(walletId); - let opened: OpenedSeed; - try { - opened = await openEnvelope(env, ownerPassphrase); - } catch (err) { - recordFailure(walletId); - throw err; - } - attempts.delete(walletId); - return opened; -} - -/** - * Verify a passphrase without opening a session — used before destructive operations (seed export, - * wallet deletion) so they need a fresh confirmation even when the wallet is already unlocked. - * - * Rethrows a LOCKED_OUT BackendError rather than folding it into `false`: "wrong passphrase" and "stop - * guessing" are different answers, and a caller that showed the first for both would loop forever. - */ -export async function verifyPassphrase(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise { - try { - await openGuarded(walletId, env, ownerPassphrase); - return true; - } catch (err) { - if (err instanceof BackendError && err.code === 'LOCKED_OUT') throw err; - return false; - } -} - -/** - * Reveal the mnemonic. The only function that returns raw seed words, and it exists solely so the owner - * can back up or migrate. Always requires the passphrase even if a session is open, and callers must - * gate it behind a fresh confirmation. - */ -export async function exportMnemonic(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise { - const opened = await openGuarded(walletId, env, ownerPassphrase); - return opened.mnemonic; -} - -/** - * Re-seal an existing seed under a new passphrase. Requires the old one. Note this mints a FRESH salt, - * DEK and IVs rather than merely re-wrapping the existing DEK — so a copy of the old envelope, plus the - * old passphrase, cannot decrypt anything written after a rotation. - */ -export async function changePassphrase( - walletId: number, - env: SeedEnvelope, - oldPassphrase: string, - newPassphrase: string, -): Promise { - const opened = await openGuarded(walletId, env, oldPassphrase); - return sealSeed(opened.mnemonic, newPassphrase, opened.bip39Passphrase || undefined); -} - -/** Constant-time compare for any confirmation token we hand out and take back. */ -export function safeEqual(a: string, b: string): boolean { - const ab = Buffer.from(a); - const bb = Buffer.from(b); - return ab.length === bb.length && timingSafeEqual(ab, bb); -} diff --git a/src/servers/sidecar/wallet/nbxplorer.ts b/src/servers/sidecar/wallet/nbxplorer.ts deleted file mode 100644 index 29264149..00000000 --- a/src/servers/sidecar/wallet/nbxplorer.ts +++ /dev/null @@ -1,401 +0,0 @@ -import type { AddressType, BitcoinNetwork } from './types'; -import { BackendError } from './types'; - -// NBXplorer client — the second chain source, alongside EsploraChain. -// -// THE MODEL IS DIFFERENT, AND THAT IS THE POINT. Esplora is address-level: you ask about one address at a -// time, so finding a wallet's coins means walking the gap limit yourself (43–160 requests per refresh, see -// backends/onchain.ts). NBXplorer is scheme-level: you register the account xpub once, it indexes the -// wallet server-side, and every question after that — balance, utxos, history, next unused address — is a -// single call. That is why this is not an implementation of EsploraChain's interface: there is no sensible -// per-address query to implement, and emulating one would throw away the entire advantage. -// -// It talks to a bitcoind that the owner runs. Everything below was verified against NBXplorer 2.6.9. - -/** - * NBXplorer names an account by its xpub plus a suffix for the script type. Native segwit is the bare - * xpub; the rest carry a bracketed tag. - * - * Taproot is `-[taproot]`, which is NOT in NBXplorer's own README table — verified against a live 2.6.9 - * instance, which accepts it and derives the same addresses we do. - */ -const SCHEME_SUFFIX: Record = { - p2wpkh: '', - 'p2sh-p2wpkh': '-[p2sh]', - p2pkh: '-[legacy]', - p2tr: '-[taproot]', -}; - -/** - * The account identifier NBXplorer indexes under. - * - * The xpub must carry `xpub` version bytes for every script type — NOT ypub/zpub. That is already what - * keys.ts::deriveAccountXpubs stores, and changing it would silently point NBXplorer at a different - * account than the one we derive from. - */ -export const derivationScheme = (accountXpub: string, type: AddressType): string => - `${accountXpub}${SCHEME_SUFFIX[type]}`; - -/** NBXplorer's two address roles. `Deposit` is the receive chain, `Change` is internal. */ -export type DerivationFeature = 'Deposit' | 'Change'; - -export type NbxStatus = { - chainHeight: number; - syncHeight: number; - isFullySynched: boolean; - networkType: string; - cryptoCode: string; - version: string; - bitcoinStatus?: { blocks: number; headers: number; verificationProgress: number; isSynched: boolean } | null; -}; - -export type NbxBalance = { - /** Satoshis. NBXplorer emits plain numbers here, not strings. */ - unconfirmed: number; - confirmed: number; - total: number; - immature: number; - available: number; -}; - -export type NbxUtxo = { - feature: DerivationFeature; - /** `"-"`. */ - outpoint: string; - index: number; - transactionHash: string; - scriptPubKey: string; - address: string; - /** Relative to the account, e.g. `"0/7"` — chain then index. */ - keyPath: string; - keyIndex: number; - value: number; - confirmations: number; - timestamp: number; - redeem?: string | null; -}; - -type NbxUtxoGroup = { utxOs: NbxUtxo[]; spentOutpoints: string[] }; - -export type NbxUtxoChanges = { - currentHeight: number; - unconfirmed: NbxUtxoGroup; - confirmed: NbxUtxoGroup; - spentUnconfirmed: NbxUtxo[]; -}; - -export type NbxTransaction = { - transactionId: string; - /** Raw hex. Present on wallet transactions, which is the only kind NBXplorer stores. */ - transaction?: string | null; - blockHash?: string | null; - confirmations: number; - height?: number | null; - timestamp: number; - /** Signed satoshis: negative when the wallet spent. */ - balanceChange: number; - replaceable?: boolean; -}; - -export type NbxTransactions = { - height: number; - confirmedTransactions: { transactions: NbxTransaction[] }; - unconfirmedTransactions: { transactions: NbxTransaction[] }; - replacedTransactions: { transactions: NbxTransaction[] }; -}; - -export type NbxAddress = { - feature: DerivationFeature; - keyPath: string; - scriptPubKey: string; - address: string; - index: number; - redeem?: string | null; -}; - -/** - * `GET …/utxos/scan` — how a `scantxoutset` sweep is going. - * - * Every field below `status` is optional on purpose: NBXplorer fills `progress` only once the node has - * actually started, and a queued scan reports nothing but its place in the queue. - */ -export type NbxScanStatus = { - status: 'Queued' | 'Pending' | 'Complete' | 'Error'; - error?: string | null; - queuedAt?: string; - progress?: { - startedAt?: string; - completedAt?: string | null; - /** UTXOs pulled in. This is the number that answers "did the scan find my coins". */ - found?: number; - batchNumber?: number; - remainingBatches?: number; - currentBatchProgress?: number; - overallProgress?: number; - remainingSeconds?: number; - highestKeyIndexFound?: Partial>; - } | null; -}; - -/** - * `POST …/utxos/scan` parameters. - * - * The defaults are the values the node operator verified live. `gapLimit` 1000 is generous for any - * wallet that has not used addresses beyond index 1000; raising it costs node CPU, not correctness. - */ -export type UtxoScanOptions = { batchSize?: number; gapLimit?: number; from?: number }; - -const SCAN_DEFAULTS = { batchSize: 1000, gapLimit: 1000, from: 0 } as const; - -/** A failed broadcast comes back as HTTP 200 with `success: false` — never as an HTTP error. */ -export type NbxBroadcastResult = { - success: boolean; - rpcCode?: number | null; - rpcCodeMessage?: string | null; - rpcMessage?: string | null; -}; - -const DEFAULT_TIMEOUT_MS = 20_000; - -export type NbxplorerConfig = { - baseUrl: string; - network: BitcoinNetwork; - /** Only ever 'BTC' here, but NBXplorer is multi-chain and the route carries it. */ - cryptoCode?: string; - timeoutMs?: number; -}; - -type RequestInitLite = { method?: 'GET' | 'POST'; body?: string }; - -export class NbxplorerChain { - readonly network: BitcoinNetwork; - private readonly baseUrl: string; - private readonly cryptoCode: string; - private readonly timeoutMs: number; - /** Schemes already registered this process. Tracking is idempotent but not free — see `track`. */ - private readonly tracked = new Set(); - - constructor(cfg: NbxplorerConfig) { - this.baseUrl = cfg.baseUrl.replace(/\/+$/, ''); - this.network = cfg.network; - this.cryptoCode = cfg.cryptoCode ?? 'BTC'; - this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS; - } - - private get root(): string { - return `/v1/cryptos/${this.cryptoCode}`; - } - - /** Every request funnels through here, so a timeout and an upstream failure share one error shape. */ - private async send(path: string, init: RequestInitLite): Promise<{ status: number; body: string }> { - const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.timeoutMs); - - let res: Response; - try { - res = await fetch(url, { - method: init.method ?? 'GET', - body: init.body, - headers: init.body ? { 'Content-Type': 'application/json' } : undefined, - signal: controller.signal, - }); - } catch (err) { - const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err); - throw new BackendError(`nbxplorer request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE'); - } finally { - clearTimeout(timer); - } - - return { status: res.status, body: await res.text() }; - } - - private fail(path: string, status: number, body: string): never { - // NBXplorer answers with {"code","message"} where it can, and bare text where it cannot. - let message = body.slice(0, 300); - try { - const parsed = JSON.parse(body) as { message?: unknown }; - if (typeof parsed.message === 'string' && parsed.message) message = parsed.message; - } catch { - /* not its JSON envelope */ - } - throw new BackendError(`nbxplorer ${status} on ${path}: ${message}`, status, 'NBXPLORER_ERROR'); - } - - private async raw(path: string, init: RequestInitLite = {}): Promise { - const { status, body } = await this.send(path, init); - if (status < 200 || status >= 300) this.fail(path, status, body); - return body; - } - - private async json(path: string, init?: RequestInitLite): Promise { - const body = await this.raw(path, init); - return this.parse(path, body); - } - - /** - * Like `json`, but a 404 is an answer rather than a failure. - * - * Only the scan-status endpoint needs this, and it needs it badly: NBXplorer 404s both when no scan is - * running and once a finished scan's result has expired. Treating that as an error would turn the two - * most ordinary moments of a rescan — before it starts, and a while after it ends — into upstream - * failures on the wallet screen. - */ - private async jsonOrNull(path: string, init?: RequestInitLite): Promise { - const { status, body } = await this.send(path, init ?? {}); - if (status === 404) return null; - if (status < 200 || status >= 300) this.fail(path, status, body); - return this.parse(path, body); - } - - private parse(path: string, body: string): T { - try { - return JSON.parse(body) as T; - } catch { - throw new BackendError(`nbxplorer returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'NBXPLORER_ERROR'); - } - } - - private scheme(accountXpub: string, type: AddressType): string { - return encodeURIComponent(derivationScheme(accountXpub, type)); - } - - // ── endpoints ────────────────────────────────────────────────────────────────────────────────── - - /** `GET /v1/cryptos/BTC/status` — the liveness probe, and the only call that needs no scheme. */ - getStatus(): Promise { - return this.json(`${this.root}/status`); - } - - /** - * `POST …/derivations/{scheme}` — register an account so NBXplorer starts indexing it. - * - * THIS IS NOT OPTIONAL AND ITS ABSENCE IS SILENT. Querying an untracked scheme returns HTTP 200 with - * every figure zeroed, which is indistinguishable from a real empty wallet — a registration that never - * happened would read as "you have no coins". So every read path calls this first, and the in-process - * set only skips the repeat calls, never the first one after a restart. - */ - async track(accountXpub: string, type: AddressType): Promise { - const key = derivationScheme(accountXpub, type); - if (this.tracked.has(key)) return; - await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}`, { method: 'POST' }); - this.tracked.add(key); - } - - /** `GET …/balance` — confirmed/unconfirmed/total for the whole account, in one call. */ - async getBalance(accountXpub: string, type: AddressType): Promise { - await this.track(accountXpub, type); - return this.json(`${this.root}/derivations/${this.scheme(accountXpub, type)}/balance`); - } - - /** `GET …/utxos` — every unspent output with its keyPath, which is what the PSBT builder needs. */ - async getUtxos(accountXpub: string, type: AddressType): Promise { - await this.track(accountXpub, type); - return this.json(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos`); - } - - /** `GET …/transactions` — wallet history, already grouped by confirmation state. */ - async getTransactions(accountXpub: string, type: AddressType): Promise { - await this.track(accountXpub, type); - return this.json(`${this.root}/derivations/${this.scheme(accountXpub, type)}/transactions`); - } - - /** - * `GET …/addresses/unused` — the next address NBXplorer believes is unused. - * - * `reserve=true` makes it hand out the next one on the following call, which is the server-side - * equivalent of the issued-index mark onchain.ts keeps. Leave it false to peek. - */ - async getUnusedAddress( - accountXpub: string, - type: AddressType, - feature: DerivationFeature = 'Deposit', - reserve = false, - ): Promise { - await this.track(accountXpub, type); - const q = `feature=${feature}&reserve=${reserve ? 'true' : 'false'}`; - return this.json(`${this.root}/derivations/${this.scheme(accountXpub, type)}/addresses/unused?${q}`); - } - - /** - * `GET …/transactions/{txid}` — the raw hex of ONE transaction. - * - * Only wallet-relevant transactions exist here; an arbitrary mainnet txid 404s, verified. That is - * enough for our only caller, which wants `nonWitnessUtxo` for a legacy input — the transaction that - * funded our own address is by definition a wallet transaction. - */ - async getTxHex(accountXpub: string, type: AddressType, txid: string): Promise { - await this.track(accountXpub, type); - const info = await this.json( - `${this.root}/derivations/${this.scheme(accountXpub, type)}/transactions/${encodeURIComponent(txid)}`, - ); - const hex = info.transaction?.trim(); - if (!hex || !/^[0-9a-f]+$/i.test(hex)) { - throw new BackendError(`nbxplorer has no raw hex for tx ${txid}`, 502, 'NBXPLORER_ERROR'); - } - return hex; - } - - /** - * `POST …/utxos/scan` — sweep the node's whole UTXO set for this account's coins. - * - * WHY THIS EXISTS AT ALL. Registering a scheme only makes NBXplorer index it *from now on*. An - * imported xpub with a history therefore reads as a real, quiet, zero-balance wallet: every call - * succeeds, nothing errors, and the coins are simply not there. This endpoint is the fix — it runs - * bitcoind's `scantxoutset`, which walks the current UTXO set directly and finds coins regardless of - * when the account was registered or how far back the node is pruned. - * - * It finds spendable coins, NOT history. Spent-transaction history predating registration stays - * missing; recovering that is a block rescan, which is a different and much heavier thing. - * - * Returns as soon as the scan is queued. `scantxoutset` is single-threaded and IO-heavy on the node, - * so concurrent scans queue and run one after another — poll `getUtxoScanStatus` for the outcome. - */ - async startUtxoScan(accountXpub: string, type: AddressType, opts: UtxoScanOptions = {}): Promise { - await this.track(accountXpub, type); - const q = new URLSearchParams({ - batchSize: String(opts.batchSize ?? SCAN_DEFAULTS.batchSize), - gapLimit: String(opts.gapLimit ?? SCAN_DEFAULTS.gapLimit), - from: String(opts.from ?? SCAN_DEFAULTS.from), - }); - await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan?${q}`, { method: 'POST' }); - } - - /** - * `GET …/utxos/scan` — the running scan, or null. - * - * Null means "nothing to report": no scan is running, or one finished long enough ago that NBXplorer - * has dropped the result. Both are 404s and neither is a failure, so poll promptly and read a null - * after a `Complete` as "it is over", not as "it vanished". - */ - getUtxoScanStatus(accountXpub: string, type: AddressType): Promise { - return this.jsonOrNull(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan`); - } - - /** `GET /v1/cryptos/BTC/fees/{blockCount}` — one target per call, sat/vB as a float. */ - async getFeeRate(blockCount: number): Promise { - const res = await this.json<{ feeRate: number; blockCount: number }>(`${this.root}/fees/${blockCount}`); - if (!Number.isFinite(res.feeRate) || res.feeRate <= 0) { - throw new BackendError(`nbxplorer returned a bad fee rate for ${blockCount} blocks`, 502, 'NBXPLORER_ERROR'); - } - return res.feeRate; - } - - /** - * `POST /v1/cryptos/BTC/transactions` — broadcast. - * - * The body is `{hex}`: the handler accepts raw bytes, a bare JSON string or this object, and the object - * is the only one of the three with no parsing ambiguity. A REJECTED broadcast still returns HTTP 200 - * with `success: false`, so the status code alone would report a bounced transaction as sent. - */ - async broadcast(rawHex: string): Promise { - const res = await this.json(`${this.root}/transactions`, { - method: 'POST', - body: JSON.stringify({ hex: rawHex }), - }); - if (!res.success) { - const detail = res.rpcCodeMessage || res.rpcMessage || 'the node rejected the transaction'; - throw new BackendError(`broadcast failed: ${detail}`, 502, 'BROADCAST_REJECTED'); - } - } -} diff --git a/src/servers/sidecar/wallet/psbt.ts b/src/servers/sidecar/wallet/psbt.ts deleted file mode 100644 index 75ea41d0..00000000 --- a/src/servers/sidecar/wallet/psbt.ts +++ /dev/null @@ -1,570 +0,0 @@ -// Coin selection and PSBT construction/signing for the native on-chain wallet backend. -// -// This is the raw-transaction half of the wallet: given a set of UTXOs discovered from the chain (see -// chain.ts) and a BIP32 root supplied by the caller, it picks inputs, builds a bitcoinjs-lib Psbt, -// signs it, and hands back a finalised transaction ready for `EsploraClient.broadcast`. -// -// It is modelled on Zeus's SweepStore (stores/SweepStore.ts) — the same p2pkh / p2sh-p2wpkh / p2wpkh / -// p2tr input construction, the same `toXOnly` from bitcoinjs-lib/src/psbt/bip371 — with three -// differences that matter: -// -// 1. Zeus sweeps a single WIF key and estimates the fee by signing a throwaway PSBT. Here the fee is -// estimated analytically from per-script-type weights (see WEIGHTS below), because selection has -// to know the fee before it knows the input set, and signing to find out costs a key. -// 2. Zeus refuses p2tr sweeps (ZEUS-3276). Taproot key-path spends work here: the private key is -// BIP341-tweaked before signing and the signer exposes `signSchnorr`. -// 3. SECURITY: no function in this file reads a seed from disk, env or module state. `signAndFinalize` -// takes the root HDKey as a parameter and the caller — keys.ts, via the backend's signer interface -// — decides whether it is willing to hand one over. Broadcast deliberately lives in chain.ts so -// the root is never held across an `await`. - -import type { HDKey } from '@scure/bip32'; -import * as bitcoin from 'bitcoinjs-lib'; -import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; -import * as ecc from '@bitcoinerlab/secp256k1'; -import { BackendError, type AddressType, type BitcoinNetwork } from './types'; - -// bitcoinjs-lib needs an ECC backend for anything taproot (x-only point tweaking). Module-level and -// idempotent — `initEcc()` is exported so a consumer that only touches address derivation can force it -// without depending on import order. -let eccReady = false; - -export function initEcc(): void { - if (eccReady) return; - bitcoin.initEccLib(ecc); - eccReady = true; -} - -initEcc(); - -// ── networks ───────────────────────────────────────────────────────────────────────────────────── - -/** - * bitcoinjs-lib ships bitcoin/testnet/regtest only. Signet shares testnet's address parameters exactly - * (`tb` bech32 HRP, 0x6f p2pkh version, 0xc4 p2sh version) — only the genesis block and message magic - * differ, and neither is used for address encoding or signing — so signet maps onto testnet. - */ -export function networkFor(network: BitcoinNetwork): bitcoin.Network { - switch (network) { - case 'bitcoin': - return bitcoin.networks.bitcoin; - case 'regtest': - return bitcoin.networks.regtest; - case 'testnet': - case 'signet': - return bitcoin.networks.testnet; - } -} - -/** BIP44 coin type: 0 for mainnet, 1 for every test chain (SLIP-44 "Testnet (all coins)"). */ -export function coinTypeFor(network: BitcoinNetwork): 0 | 1 { - return network === 'bitcoin' ? 0 : 1; -} - -// ── script classification ──────────────────────────────────────────────────────────────────────── - -/** - * Classify a scriptPubKey. Returns null for anything the wallet cannot own or size precisely (p2wsh, - * bare multisig, op_return, future witness versions). - * - * Note that a bare p2sh script is reported as 'p2sh-p2wpkh'. On the *output* side that is exact — every - * p2sh output is 23 bytes regardless of what redeems it — and on the input side the wallet only ever - * owns the wrapped-segwit form, so the conflation is safe in both directions. - */ -export function scriptType(script: Uint8Array): AddressType | null { - const b = script; - if (b.length === 25 && b[0] === 0x76 && b[1] === 0xa9 && b[2] === 0x14 && b[23] === 0x88 && b[24] === 0xac) { - return 'p2pkh'; - } - if (b.length === 23 && b[0] === 0xa9 && b[1] === 0x14 && b[22] === 0x87) return 'p2sh-p2wpkh'; - if (b.length === 22 && b[0] === 0x00 && b[1] === 0x14) return 'p2wpkh'; - if (b.length === 34 && b[0] === 0x51 && b[1] === 0x20) return 'p2tr'; - return null; -} - -/** Same, from hex. */ -export function scriptTypeFromHex(hex: string): AddressType | null { - return scriptType(Buffer.from(hex, 'hex')); -} - -/** Classify a destination address by the script it encodes. Throws when the address is not valid here. */ -export function addressScriptType(address: string, network: bitcoin.Network): AddressType | null { - return scriptType(outputScriptFor(address, network)); -} - -/** `bitcoin.address.toOutputScript` with the failure turned into a 400 instead of a bare Error. */ -export function outputScriptFor(address: string, network: bitcoin.Network): Buffer { - try { - return bitcoin.address.toOutputScript(address, network); - } catch { - throw new BackendError(`invalid address for this network: ${address}`, 400, 'INVALID_ADDRESS'); - } -} - -// ── size and dust constants ────────────────────────────────────────────────────────────────────── - -// Everything here is in *weight units* (4 wu = 1 vbyte) so segwit's quarter-vbyte discount survives the -// arithmetic instead of being rounded away per input. A flat 148-in/34-out estimate — the usual -// shortcut — overpays a p2wpkh spend by ~2.2x and a p2tr spend by ~2.6x, which at any real fee rate is -// money handed to a miner for nothing. -// -// Input weights, assuming a 72-byte low-R DER signature (71 is common, 72 is the worst case) and a -// 33-byte compressed pubkey: -// -// p2pkh 32 txid + 4 vout + 1 len + 107 scriptSig + 4 seq = 148 vB → 592 wu -// p2sh-p2wpkh 64 base vB (scriptSig = 23-byte redeemScript push) + 108 wu witness → 364 wu -// p2wpkh 41 base vB + 108 wu witness (2 items: 72-byte sig, 33-byte key) → 272 wu -// p2tr key-path 41 base vB + 66 wu witness (1 item: 64-byte schnorr sig) → 230 wu -// -// which come out at 148 / 91 / 68 / 57.5 vbytes — the same numbers Bitcoin Core assumes. -const INPUT_WEIGHT: Record = { - p2pkh: 592, - 'p2sh-p2wpkh': 364, - p2wpkh: 272, - p2tr: 230, -}; - -// Output weights: 8-byte value + 1-byte script length + the script itself, all ×4. -// p2pkh 25B script → 34 vB, p2sh 23B → 32 vB, p2wpkh 22B → 31 vB, p2tr 34B → 43 vB. -const OUTPUT_WEIGHT: Record = { - p2pkh: 136, - 'p2sh-p2wpkh': 128, - p2wpkh: 124, - p2tr: 172, -}; - -/** Unclassifiable output (p2wsh, future witness versions): charge the 43-vbyte p2tr/p2wsh size. */ -const OUTPUT_WEIGHT_UNKNOWN = 172; - -/** version(4) + locktime(4), ×4. The input/output count varints are added separately. */ -const TX_OVERHEAD_WEIGHT = 32; - -/** Segwit marker + flag: 1 byte each, but they live in the witness so they weigh 1 wu each. */ -const SEGWIT_MARKER_WEIGHT = 2; - -/** - * Bitcoin Core's dust threshold: 3 sat/vB (the default dustRelayFee) times the size of the output plus - * the size of the input that would eventually spend it — witness inputs are counted as a flat 67 vB. - * p2pkh (34+148)*3 = 546 · p2sh (32+148)*3 = 540 · p2wpkh (31+67)*3 = 294 · p2tr (43+67)*3 = 330 - * A change output below its threshold is not created; the remainder is donated to the fee instead. - */ -export const DUST_THRESHOLD: Record = { - p2pkh: 546, - 'p2sh-p2wpkh': 540, - p2wpkh: 294, - p2tr: 330, -}; - -/** Dust floor for an output we could not classify — use the most demanding value we know. */ -const DUST_UNKNOWN = 546; - -/** The network will not relay below this, so a caller asking for less gets bumped rather than stuck. */ -const MIN_SAT_VB = 1; - -function varintWeight(n: number): number { - if (n < 0xfd) return 4; - if (n <= 0xffff) return 12; - return 20; -} - -function isSegwit(type: AddressType): boolean { - return type !== 'p2pkh'; -} - -export type VsizeParams = { - inputs: AddressType[]; - /** Output script types, in order. `null` means "unclassifiable" and is charged 43 vbytes. */ - outputs: (AddressType | null)[]; -}; - -/** - * Virtual size of the transaction these inputs and outputs would produce, rounded up. Accurate to - * within one vbyte per input (the DER signature is occasionally 71 bytes rather than 72), always in the - * conservative direction, so the realised fee rate lands at or just above what was asked for. - */ -export function estimateVsize({ inputs, outputs }: VsizeParams): number { - let weight = TX_OVERHEAD_WEIGHT + varintWeight(inputs.length) + varintWeight(outputs.length); - if (inputs.some(isSegwit)) weight += SEGWIT_MARKER_WEIGHT; - for (const type of inputs) weight += INPUT_WEIGHT[type]; - for (const type of outputs) weight += type === null ? OUTPUT_WEIGHT_UNKNOWN : OUTPUT_WEIGHT[type]; - return Math.ceil(weight / 4); -} - -// ── coin selection ─────────────────────────────────────────────────────────────────────────────── - -/** - * A UTXO the selector may spend. `derivationPath` is the FULL path from the wallet root - * (`m/84'/0'/0'/0/7`), not the account-relative form the public Utxo type carries, because it is used - * verbatim to derive the signing key. - */ -export type SpendableUtxo = { - txid: string; - vout: number; - amountSats: number; - address: string; - addressType: AddressType; - confirmations: number; - derivationPath: string; - frozen: boolean; - /** scriptPubKey of the output being spent, hex. */ - scriptPubKeyHex: string; - /** Compressed 33-byte pubkey of the owning address, hex. */ - pubkeyHex: string; -}; - -export type CoinSelectionParams = { - utxos: SpendableUtxo[]; - /** Sats to pay the recipient. Ignored when `sendAll` is set. */ - targetSats: number; - sendAll?: boolean; - satPerVbyte: number; - /** Script type of the recipient output; null when it is a script we cannot size exactly. */ - recipientType: AddressType | null; - /** Script type the change output would use. */ - changeType: AddressType; - /** Coin control: restrict the input set to these `txid:vout` outpoints. */ - outpoints?: string[]; - spendUnconfirmed?: boolean; -}; - -export type CoinSelection = { - inputs: SpendableUtxo[]; - /** Sats actually paid to the recipient. Equals `targetSats` unless `sendAll`. */ - outputSats: number; - /** Sats returned to the wallet, or null when no change output is created. */ - changeSats: number | null; - feeSats: number; - /** The vsize the fee was computed from. */ - vsize: number; -}; - -const sumSats = (utxos: SpendableUtxo[]): number => utxos.reduce((acc, u) => acc + u.amountSats, 0); - -const outpointOf = (u: SpendableUtxo): string => `${u.txid}:${u.vout}`; - -function dustFor(type: AddressType | null): number { - return type === null ? DUST_UNKNOWN : DUST_THRESHOLD[type]; -} - -/** - * Coin selection: a two-phase accumulative selector. Deliberately not branch-and-bound — BnB's payoff - * is finding changeless solutions in a large UTXO set, and it needs a waste metric plus a fallback - * anyway. Instead: - * - * Phase 1 — smallest sufficient single input. Walk the eligible UTXOs smallest-first and take the - * first one that can cover the payment on its own. One input is the cheapest possible - * transaction, and going smallest-first quietly consolidates the wallet's dust over time. - * Phase 2 — largest-first accumulation. Nothing single-handedly covers it, so add UTXOs - * largest-first (confirmed before unconfirmed) until the total covers payment + fee. Going - * largest-first minimises the input count, and each input costs real money. - * - * Both phases decide change the same way: prefer a change output, but if what is left after the - * with-change fee falls under the dust threshold, drop the change output and donate the remainder to - * the fee. The donation is bounded by roughly (one output's fee + one dust threshold), so it can never - * quietly become a large overpayment. - */ -export function selectCoins(params: CoinSelectionParams): CoinSelection { - const rate = Math.max(params.satPerVbyte, MIN_SAT_VB); - const allow = params.outpoints && params.outpoints.length > 0 ? new Set(params.outpoints) : null; - - const eligible = params.utxos.filter((u) => { - // Frozen is never overridable, not even by naming the outpoint. Freezing is the owner saying "this - // coin does not exist for spending purposes" — a reservation against an open channel, a coin held - // for tax lots, dust being kept out of the way — and a rule that a sufficiently explicit request - // can bypass is not a rule. The route refuses a named frozen outpoint before ever reaching here; - // this is what makes that true for any future caller too. - if (u.frozen) return false; - // An explicit coin-control pick is otherwise authoritative: it overrides the confirmed-only - // default, because the user named this exact outpoint and can see what it is. - if (allow) return allow.has(outpointOf(u)); - if (!params.spendUnconfirmed && u.confirmations < 1) return false; - return true; - }); - - if (eligible.length === 0) { - const why = allow ? 'none of the selected outpoints are spendable' : 'no spendable UTXOs'; - throw new BackendError(why, 400, 'INSUFFICIENT_FUNDS'); - } - - if (params.sendAll) return sweepAll(eligible, rate, params.recipientType); - - if (!Number.isInteger(params.targetSats) || params.targetSats <= 0) { - throw new BackendError('amountSats must be a positive integer', 400, 'INVALID_AMOUNT'); - } - - const attempt = (inputs: SpendableUtxo[]): CoinSelection | null => { - const total = sumSats(inputs); - const types = inputs.map((u) => u.addressType); - const withChange = estimateVsize({ inputs: types, outputs: [params.recipientType, params.changeType] }); - const noChange = estimateVsize({ inputs: types, outputs: [params.recipientType] }); - const feeWithChange = Math.ceil(withChange * rate); - const feeNoChange = Math.ceil(noChange * rate); - - const change = total - params.targetSats - feeWithChange; - if (change >= DUST_THRESHOLD[params.changeType]) { - return { - inputs: [...inputs], - outputSats: params.targetSats, - changeSats: change, - feeSats: feeWithChange, - vsize: withChange, - }; - } - if (total >= params.targetSats + feeNoChange) { - // Changeless: everything above the payment is fee. Smaller transaction, no dust output created. - return { - inputs: [...inputs], - outputSats: params.targetSats, - changeSats: null, - feeSats: total - params.targetSats, - vsize: noChange, - }; - } - return null; - }; - - // Phase 1 — smallest sufficient single input. - const ascending = [...eligible].sort((a, b) => a.amountSats - b.amountSats); - for (const utxo of ascending) { - const single = attempt([utxo]); - if (single) return single; - } - - // Phase 2 — largest-first accumulation, confirmed coins ahead of unconfirmed ones. - const ordered = [...eligible].sort((a, b) => { - const aPending = a.confirmations > 0 ? 0 : 1; - const bPending = b.confirmations > 0 ? 0 : 1; - if (aPending !== bPending) return aPending - bPending; - return b.amountSats - a.amountSats; - }); - - const chosen: SpendableUtxo[] = []; - for (const utxo of ordered) { - chosen.push(utxo); - const selection = attempt(chosen); - if (selection) return selection; - } - - const available = sumSats(eligible); - throw new BackendError( - `insufficient funds: ${available} sat available, ${params.targetSats} sat requested plus fees`, - 400, - 'INSUFFICIENT_FUNDS', - ); -} - -/** sendAll: every eligible coin in, one output out, the fee taken off that output. */ -function sweepAll(inputs: SpendableUtxo[], rate: number, recipientType: AddressType | null): CoinSelection { - const total = sumSats(inputs); - const vsize = estimateVsize({ inputs: inputs.map((u) => u.addressType), outputs: [recipientType] }); - const feeSats = Math.ceil(vsize * rate); - const outputSats = total - feeSats; - if (outputSats < dustFor(recipientType)) { - throw new BackendError( - `sweep leaves ${outputSats} sat after a ${feeSats} sat fee, below the dust threshold`, - 400, - 'INSUFFICIENT_FUNDS', - ); - } - return { inputs: [...inputs], outputSats, changeSats: null, feeSats, vsize }; -} - -// ── PSBT construction ──────────────────────────────────────────────────────────────────────────── - -/** RBF-signalling sequence (BIP125): any input below 0xfffffffe marks the whole transaction replaceable. */ -const SEQUENCE_RBF = 0xfffffffd; -const SEQUENCE_FINAL = 0xffffffff; - -export type PsbtInputSource = { - txid: string; - vout: number; - amountSats: number; - addressType: AddressType; - /** scriptPubKey being spent, hex. */ - scriptPubKeyHex: string; - /** Compressed 33-byte pubkey of the owning address, hex. */ - pubkeyHex: string; - /** Full BIP32 path from the wallet root — returned in `inputPaths` for `signAndFinalize`. */ - derivationPath: string; - /** Whole previous transaction, hex. Required for p2pkh inputs, unused otherwise. */ - prevTxHex?: string; -}; - -export type PsbtOutputSpec = { address: string; amountSats: number }; - -export type BuildPsbtParams = { - network: bitcoin.Network; - inputs: PsbtInputSource[]; - outputs: PsbtOutputSpec[]; - /** Signal RBF. Default true. */ - rbf?: boolean; - locktime?: number; -}; - -export type BuiltPsbt = { - psbt: bitcoin.Psbt; - /** Derivation paths in input order — pass straight to `signAndFinalize`. */ - inputPaths: string[]; -}; - -/** - * Build an unsigned PSBT. Input construction is per script type, exactly as Zeus's SweepStore does it: - * legacy p2pkh needs the whole previous transaction (`nonWitnessUtxo`), segwit needs only the output - * being spent (`witnessUtxo`), wrapped segwit additionally needs the p2wpkh `redeemScript`, and taproot - * needs the x-only internal key so the signer can be matched against the output key. - */ -export function buildPsbt(params: BuildPsbtParams): BuiltPsbt { - initEcc(); - const { network } = params; - if (params.inputs.length === 0) throw new BackendError('cannot build a PSBT with no inputs', 400); - if (params.outputs.length === 0) throw new BackendError('cannot build a PSBT with no outputs', 400); - - const psbt = new bitcoin.Psbt({ network }); - psbt.setVersion(2); - psbt.setLocktime(params.locktime ?? 0); - - const sequence = params.rbf === false ? SEQUENCE_FINAL : SEQUENCE_RBF; - const inputPaths: string[] = []; - - for (const source of params.inputs) { - const pubkey = Buffer.from(source.pubkeyHex, 'hex'); - const script = Buffer.from(source.scriptPubKeyHex, 'hex'); - const base = { hash: source.txid, index: source.vout, sequence }; - - switch (source.addressType) { - case 'p2pkh': { - if (!source.prevTxHex) { - throw new BackendError(`p2pkh input ${source.txid}:${source.vout} needs the previous tx hex`, 500); - } - psbt.addInput({ ...base, nonWitnessUtxo: Buffer.from(source.prevTxHex, 'hex') }); - break; - } - case 'p2wpkh': { - psbt.addInput({ ...base, witnessUtxo: { script, value: source.amountSats } }); - break; - } - case 'p2sh-p2wpkh': { - const redeem = bitcoin.payments.p2wpkh({ pubkey, network }); - if (!redeem.output) throw new BackendError('failed to build the p2wpkh redeemScript', 500); - psbt.addInput({ - ...base, - witnessUtxo: { script, value: source.amountSats }, - redeemScript: redeem.output, - }); - break; - } - case 'p2tr': { - psbt.addInput({ - ...base, - witnessUtxo: { script, value: source.amountSats }, - tapInternalKey: toXOnly(pubkey), - }); - break; - } - } - - inputPaths.push(source.derivationPath); - } - - for (const output of params.outputs) { - // Validate against the configured network before adding — bitcoinjs would accept a foreign-network - // address encoded for a chain with the same prefixes and silently burn the funds. - outputScriptFor(output.address, network); - psbt.addOutput({ address: output.address, value: output.amountSats }); - } - - return { psbt, inputPaths }; -} - -// ── signing ────────────────────────────────────────────────────────────────────────────────────── - -export type SignedTx = { - txid: string; - rawHex: string; - /** Realised vsize of the signed transaction — compare against the estimate to audit fee accuracy. */ - vsize: number; - feeSats: number; -}; - -/** - * BIP341 key-path tweak. The output key is `P + H_TapTweak(P) * G` where P is the x-only internal key, - * so the private key has to be tweaked the same way before it can produce a matching schnorr signature. - * If the internal point has odd Y the scalar is negated first, since x-only keys are always even-Y. - */ -function tweakPrivateKey(priv: Uint8Array, pubkey: Buffer): Uint8Array { - const even = pubkey[0] === 0x02 ? priv : ecc.privateNegate(priv); - const tweak = bitcoin.crypto.taggedHash('TapTweak', toXOnly(pubkey)); - const tweaked = ecc.privateAdd(even, tweak); - if (!tweaked) throw new BackendError('taproot tweak produced an invalid key', 500); - return tweaked; -} - -function keyMaterial(node: HDKey): { priv: Uint8Array; pubkey: Buffer } { - const priv = node.privateKey; - const pub = node.publicKey; - if (!priv || !pub) throw new BackendError('derived node has no private key — cannot sign', 500); - return { priv, pubkey: Buffer.from(pub) }; -} - -/** ECDSA signer for p2pkh / p2sh-p2wpkh / p2wpkh inputs. */ -function ecdsaSigner(node: HDKey): bitcoin.Signer { - const { priv, pubkey } = keyMaterial(node); - return { - publicKey: pubkey, - sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, priv)), - }; -} - -/** - * Schnorr signer for a taproot key-path spend. `publicKey` must be the TWEAKED key: bitcoinjs matches - * `toXOnly(signer.publicKey)` against the output key in the prevout (psbt.js getTaprootHashesForSig), - * and the untweaked internal key would simply not match, failing with "Can not sign for input". - */ -function taprootSigner(node: HDKey): bitcoin.Signer { - const { priv, pubkey } = keyMaterial(node); - const tweakedPriv = tweakPrivateKey(priv, pubkey); - const tweakedPub = ecc.pointFromScalar(tweakedPriv, true); - if (!tweakedPub) throw new BackendError('taproot tweak produced an invalid point', 500); - return { - publicKey: Buffer.from(tweakedPub), - sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, tweakedPriv)), - signSchnorr: (hash: Buffer) => Buffer.from(ecc.signSchnorr(hash, tweakedPriv)), - }; -} - -/** @scure/bip32 only accepts the apostrophe form of a hardened index. */ -function normalizePath(path: string): string { - return path.replace(/[hH]/g, "'"); -} - -/** - * Sign every input from keys derived off `root`, finalise, and extract the transaction. - * - * `root` is a PARAMETER and is never cached, stored or closed over past this call: the caller holds the - * key material and this function borrows it for the duration of a synchronous signing pass. Nothing - * here awaits, so the root cannot be pinned alive by a pending network call. - */ -export function signAndFinalize(psbt: bitcoin.Psbt, root: HDKey, inputPaths: string[]): SignedTx { - initEcc(); - const count = psbt.data.inputs.length; - if (inputPaths.length !== count) { - throw new BackendError(`expected ${count} derivation paths, got ${inputPaths.length}`, 500); - } - - for (let i = 0; i < count; i++) { - const path = inputPaths[i]; - const input = psbt.data.inputs[i]; - if (path === undefined || input === undefined) { - throw new BackendError(`missing derivation path for input #${i}`, 500); - } - const node = root.derive(normalizePath(path)); - // `tapInternalKey` is set only by the p2tr branch of buildPsbt, so it is the authoritative marker - // for "this input needs a schnorr signature over a tweaked key". - psbt.signInput(i, input.tapInternalKey ? taprootSigner(node) : ecdsaSigner(node)); - } - - psbt.finalizeAllInputs(); - const feeSats = psbt.getFee(); - const tx = psbt.extractTransaction(); - return { txid: tx.getId(), rawHex: tx.toHex(), vsize: tx.virtualSize(), feeSats }; -} diff --git a/src/servers/sidecar/wallet/resolve.ts b/src/servers/sidecar/wallet/resolve.ts deleted file mode 100644 index 7f38356a..00000000 --- a/src/servers/sidecar/wallet/resolve.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { ChainCacheStore } from './backends/onchain'; -import type { WalletChainSource } from './chain-source'; -import type { ChainSource } from './upstream'; -import { - getWallet, - getWalletSecrets, - getWalletChainCache, - saveWalletChainCache, - recordWalletChainError, - type WalletSummary, -} from 'officerdb'; -import { EsploraChain } from './chain'; -import { EsploraChainSource } from './chain-source-esplora'; -import { NbxplorerChain } from './nbxplorer'; -import { NbxplorerChainSource } from './chain-source-nbxplorer'; -import { LndBackend } from './backends/lnd'; -import { ClnRestBackend } from './backends/clnrest'; -import { LndHubBackend } from './backends/lndhub'; -import { NwcBackend } from './backends/nwc'; -import { OnchainBackend } from './backends/onchain'; -import { sessionFor } from './keys'; -import { getChainSource } from './upstream'; -import { BackendError, BIP_ADDRESS_TYPE, type AddressType, type BitcoinNetwork, type WalletBackend } from './types'; - -// Turns a stored wallet row into a live backend instance. This is the one place that knows the mapping -// from `kind` to a class, and the one place node credentials are decrypted — getWalletSecrets() is -// called here and the plaintext never travels further than the constructor it is handed to. -// -// Instances are cached per wallet id. Backends hold connection state worth reusing (LNDHub's access -// token, NWC's relay socket, the on-chain gap-limit scan), and rebuilding one per request would both -// re-authenticate constantly and defeat the address-scan cache. The cache is invalidated whenever the -// wallet's config changes — see `invalidate()`, called from the update/delete routes. - -type Cached = { backend: WalletBackend; configVersion: string }; -const cache = new Map(); - -export function invalidate(walletId: number): void { - cache.delete(walletId); -} - -export function invalidateAll(): void { - cache.clear(); -} - -/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */ -function versionOf(wallet: WalletSummary, config: Record | null, source: ChainSource): string { - // The kind is in here as well as the URL: switching protocol at the same host is a different backend - // with a differently-shaped cache behind it, and a fingerprint on the URL alone would miss it. - return JSON.stringify([ - wallet.kind, - wallet.network, - wallet.defaultBip, - wallet.xpubs, - config, - source.kind, - source.baseUrl, - ]); -} - -export type Resolved = { wallet: WalletSummary; backend: WalletBackend }; - -export async function resolveBackend(userId: number, walletId: number): Promise { - const wallet = await getWallet(userId, walletId); - if (!wallet) throw new BackendError('wallet not found', 404, 'NOT_FOUND'); - - const secrets = await getWalletSecrets(userId, walletId); - const config = secrets?.config ?? null; - // Read per resolve rather than per build: the source is in the fingerprint, so pointing the owner at a - // different indexer rebuilds every backend and drops the scan taken through the old one. - const source = await getChainSource(userId); - const version = versionOf(wallet, config, source); - - const hit = cache.get(walletId); - if (hit && hit.configVersion === version) return { wallet, backend: hit.backend }; - - const backend = build(wallet, config, source); - cache.set(walletId, { backend, configVersion: version }); - return { wallet, backend }; -} - -/** - * Binds the chain cache queries to one wallet id. This is the only thing that gives the on-chain - * backend a persistent identity — the backend itself never learns which wallet it is, exactly as it - * never learns which seed it derives from. - */ -function chainCacheFor(walletId: number): ChainCacheStore { - return { - load: () => getWalletChainCache(walletId), - save: (snapshot) => saveWalletChainCache(walletId, snapshot), - recordError: (message) => recordWalletChainError(walletId, message), - }; -} - -function required(config: Record | null, key: string, kind: string): string { - const v = config?.[key]; - if (typeof v !== 'string' || !v) { - throw new BackendError(`${kind} wallet is missing required config "${key}"`, 400, 'BAD_CONFIG'); - } - return v; -} - -function build(wallet: WalletSummary, config: Record | null, source: ChainSource): WalletBackend { - const network = wallet.network as BitcoinNetwork; - - switch (wallet.kind) { - case 'lnd': - return new LndBackend({ - url: required(config, 'url', 'lnd'), - macaroonHex: required(config, 'macaroonHex', 'lnd'), - allowSelfSigned: config?.allowSelfSigned === true, - }); - - case 'cln-rest': - return new ClnRestBackend({ - url: required(config, 'url', 'cln-rest'), - rune: required(config, 'rune', 'cln-rest'), - allowSelfSigned: config?.allowSelfSigned === true, - }); - - case 'lndhub': - return new LndHubBackend({ - url: required(config, 'url', 'lndhub'), - login: required(config, 'login', 'lndhub'), - password: required(config, 'password', 'lndhub'), - }); - - case 'nwc': - return new NwcBackend({ connectionUri: required(config, 'connectionUri', 'nwc') }); - - case 'onchain': { - // Every xpub the wallet holds is handed over, not just the default BIP's. A seed derives all four - // accounts (keys.ts::deriveAccountXpubs), and coins can legitimately sit on any of them — a - // recovered seed may have been used with a p2tr wallet before, or received to a legacy address. - // Scanning only the default account would silently under-report the balance and leave those UTXOs - // unspendable. `defaultBip` then means only "which script type new receive addresses use". - const accountXpub: Partial> = {}; - for (const [bip, type] of Object.entries(BIP_ADDRESS_TYPE)) { - const xpub = wallet.xpubs?.[bip]; - if (xpub) accountXpub[type] = xpub; - } - if (Object.keys(accountXpub).length === 0) { - throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG'); - } - return new OnchainBackend({ - chain: chainSourceFor(source, network), - network, - accountXpub, - // The session is the signer. While locked it holds no key material, so watch-only reads below - // still work and only sendCoins/signMessage will throw WalletLockedError. - signer: sessionFor(wallet.id), - cache: chainCacheFor(wallet.id), - }); - } - - default: - throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG'); - } -} - -/** - * The one place the owner's chosen protocol turns into an implementation. Both satisfy the same - * interface, and OnchainBackend cannot tell which it was handed — that is the entire point of the seam - * (chain-source.ts). Adding a third kind is a case here and a file next to it, and nothing else. - */ -function chainSourceFor(source: ChainSource, network: BitcoinNetwork): WalletChainSource { - const label = `${source.kind}(${hostOf(source.baseUrl)})`; - - if (source.kind === 'nbxplorer') { - return new NbxplorerChainSource({ - chain: new NbxplorerChain({ baseUrl: source.baseUrl, network }), - network, - label, - }); - } - - return new EsploraChainSource({ chain: new EsploraChain({ baseUrl: source.baseUrl, network }), label }); -} - -/** Host only, for a chain-source label. A malformed URL is labelled with itself rather than throwing. */ -function hostOf(url: string): string { - try { - return new URL(url).host; - } catch { - return url; - } -} diff --git a/src/servers/sidecar/wallet/routes.ts b/src/servers/sidecar/wallet/routes.ts deleted file mode 100644 index 91749d54..00000000 --- a/src/servers/sidecar/wallet/routes.ts +++ /dev/null @@ -1,658 +0,0 @@ -import { - listWallets, - getWallet, - getSealedSeed, - createWallet, - updateWallet, - replaceSealedSeed, - setActiveWallet, - deleteWallet, - getActiveWallet, - getWalletLabels, - setWalletLabel, - getFrozenOutpoints, - setUtxoFrozen, - type WalletKind, -} from 'officerdb'; -import { resolveBackend, invalidate } from './resolve'; -import { - changePassphrase, - deriveAccountXpubs, - exportMnemonic, - generateSeed, - sealSeed, - sessionFor, - verifyPassphrase, - type SeedEnvelope, -} from './keys'; -import { getConfig, hasStoreKey } from './upstream'; -import { - asAddressType, - BackendError, - WalletLockedError, - BIP_ADDRESS_TYPE, - type BitcoinNetwork, - type Capability, - type Balances, - type SyncState, - type Utxo, - type WalletBackend, -} from './types'; - -// The wallet sidecar's route surface. Every route is scoped to the authenticated owner via X-Officer-User, -// which the platform proxy injects (src/servers/api/wallet/router.ts) and which is trustworthy because the -// sidecar binds loopback only. -// -// Route ordering matters here: the seed/lock routes are matched BEFORE the generic wallet operations, so a -// wallet named "unlock" can never shadow the unlock endpoint. -// -// SECURITY NOTES that apply to this whole file: -// - No route ever returns a mnemonic, a sealed envelope, a node macaroon, or an unlock passphrase, -// except /export-seed, which exists for backup and demands the passphrase every single time. -// - Passphrases arrive in request bodies and are never logged. The catch-all handler at the bottom logs -// the method and path only, deliberately not the body. -// - Capability checks happen before dispatch so an unsupported operation is a clean 501. - -export type OfficerContext = { req: Request; url: URL; userId: number }; - -function json(data: unknown, status = 200): Response { - return Response.json(data as Record, { status }); -} - -function badRequest(message: string): Response { - return json({ error: message }, 400); -} - -async function body(req: Request): Promise { - try { - return (await req.json()) as T; - } catch { - throw new BackendError('expected a JSON body', 400, 'BAD_BODY'); - } -} - -/** - * Freshness of the data a read just returned, or null from a backend that has no cache to be stale. - * Attached to balances, transactions and utxos so the UI can distinguish an empty wallet from one it - * could not reach — those rendered identically before, as a zero balance. - * - * Must be read AFTER the response it describes: it reports the snapshot that was actually served. - */ -function syncOf(backend: WalletBackend): SyncState | null { - return backend.getSyncState?.() ?? null; -} - -/** Guard a capability before dispatching, so callers get 501 rather than a confusing upstream error. */ -function requireCap(backend: WalletBackend, cap: Capability, op: string): void { - if (!backend.supports(cap)) { - throw new BackendError(`this wallet does not support ${op}`, 501, 'NOT_SUPPORTED'); - } -} - -// ── entry point ────────────────────────────────────────────────────────────────────────────────── - -export async function handleOfficerRoute(req: Request, url: URL): Promise { - const officerUser = req.headers.get('X-Officer-User'); - if (!officerUser) return json({ error: 'missing X-Officer-User' }, 401); - const userId = Number(officerUser); - if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User'); - - const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean); - if (segments.length === 0) return null; - const ctx: OfficerContext = { req, url, userId }; - - try { - switch (segments[0]) { - case 'config': - return handleConfig(); - case 'wallets': - return await handleWallets(ctx, segments.slice(1)); - default: - return null; - } - } catch (err) { - if (err instanceof BackendError) { - return json({ error: err.message, code: err.code }, err.status); - } - throw err; - } -} - -/** - * Non-secret deployment facts the UI needs before any wallet exists. - * - * No esplora URL: that is per-owner now and lives at `/_config`, which is also the only place that can - * say whether it is the owner's endpoint or the fallback. Reporting the default here would have shown - * the wrong URL to anyone who had set their own. - */ -function handleConfig(): Response { - const cfg = getConfig(); - return json({ - network: cfg.network, - unlockTtlSec: cfg.unlockTtlSec, - // The UI blocks wallet creation on this rather than letting the first write fail on a crypto error. - storeKeyConfigured: hasStoreKey(), - }); -} - -// ── /wallets ───────────────────────────────────────────────────────────────────────────────────── - -const KINDS: readonly WalletKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc']; - -/** Highest gap limit a rescan may be asked for. Four variants at this width is already tens of minutes. */ -const MAX_GAP_LIMIT = 100_000; - -async function handleWallets(ctx: OfficerContext, seg: string[]): Promise { - const { req, userId } = ctx; - - // /_officer/wallets - if (seg.length === 0) { - if (req.method === 'GET') return json({ wallets: await listWallets(userId) }); - if (req.method === 'POST') return await createWalletRoute(ctx); - return json({ error: 'method not allowed' }, 405); - } - - // /_officer/wallets/active — resolves to whichever wallet is currently selected - if (seg[0] === 'active' && seg.length === 1 && req.method === 'GET') { - const active = await getActiveWallet(userId); - return json({ wallet: active }); - } - - const walletId = Number(seg[0]); - if (!Number.isInteger(walletId) || walletId <= 0) return badRequest('invalid wallet id'); - const rest = seg.slice(1); - - // /_officer/wallets/:id - if (rest.length === 0) { - if (req.method === 'GET') { - const wallet = await getWallet(userId, walletId); - return wallet ? json({ wallet }) : json({ error: 'wallet not found' }, 404); - } - if (req.method === 'PATCH') { - const patch = await body<{ name?: string; defaultBip?: number; config?: Record }>(req); - // A rename goes through the same rule as a create: trimmed, and never blank. Without this a stray - // empty string would leave a wallet with no name anywhere in the UI and no way to type one back. - if (patch.name !== undefined) { - const name = patch.name.trim(); - if (!name) return badRequest('name cannot be empty'); - if (name.length > 64) return badRequest('name is too long (64 characters max)'); - patch.name = name; - } - // Rebuilt field by field rather than forwarded: `body` is a cast, so the parsed object holds - // whatever the caller sent, not what the type says. updateWallet can no longer write the seed - // either — that is the belt to this brace. - const updated = await updateWallet(userId, walletId, { - name: patch.name, - defaultBip: patch.defaultBip, - config: patch.config, - }); - invalidate(walletId); - return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404); - } - if (req.method === 'DELETE') return await deleteWalletRoute(ctx, walletId); - return json({ error: 'method not allowed' }, 405); - } - - // Seed / lock lifecycle — matched before the generic operations below. - switch (rest[0]) { - case 'activate': - if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - await setActiveWallet(userId, walletId); - return json({ ok: true }); - - case 'lock-state': { - const wallet = await getWallet(userId, walletId); - if (!wallet) return json({ error: 'wallet not found' }, 404); - const session = sessionFor(walletId); - return json({ - hasSeed: wallet.hasSeed, - unlocked: session.isUnlocked(), - secondsRemaining: session.secondsRemaining(), - }); - } - - case 'unlock': - return await unlockRoute(ctx, walletId); - - case 'lock': - if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - sessionFor(walletId).lock(); - return json({ ok: true, unlocked: false }); - - case 'passphrase': - return await changePassphraseRoute(ctx, walletId); - - case 'export-seed': - return await exportSeedRoute(ctx, walletId); - } - - // Everything else needs a live backend. - const { wallet, backend } = await resolveBackend(userId, walletId); - - switch (rest[0]) { - case 'capabilities': { - const all: Capability[] = [ - 'onchainReceive', - 'onchainSend', - 'coinControl', - 'psbt', - 'bumpFee', - 'sweep', - 'accounts', - 'lightningReceive', - 'lightningSend', - 'keysend', - 'customPreimages', - 'offers', - 'channels', - 'peers', - 'routing', - 'signMessage', - ]; - return json({ kind: wallet.kind, capabilities: all.filter((c) => backend.supports(c)) }); - } - - case 'info': - return json({ info: await backend.getInfo() }); - - case 'balances': - return json({ balances: await balancesWithFrozen(walletId, backend), sync: syncOf(backend) }); - - case 'transactions': { - const limit = Number(ctx.url.searchParams.get('limit') ?? 50); - const txs = await backend.getTransactions({ limit }); - // Overlay owner labels, which live in Officer's DB rather than any backend. - const labels = await getWalletLabels(walletId); - const byRef = new Map(labels.filter((l) => l.kind === 'tx').map((l) => [l.ref, l.label])); - return json({ - transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })), - sync: syncOf(backend), - }); - } - - case 'address': { - requireCap(backend, 'onchainReceive', 'receiving on-chain'); - const peek = ctx.url.searchParams.get('peek') === 'true'; - // An explicit ?type= wins; otherwise the wallet's defaultBip decides. Without this the on-chain - // backend would always fall back to its own preference order (native segwit), silently ignoring an - // owner who set the wallet to taproot or legacy. - const asked = ctx.url.searchParams.get('type'); - if (asked && !asAddressType(asked)) return badRequest(`unknown address type "${asked}"`); - const type = asked ? asAddressType(asked) : BIP_ADDRESS_TYPE[String(wallet.defaultBip)]; - return json(await backend.getNewAddress({ peek, type })); - } - - case 'utxos': - return await utxosRoute(ctx, walletId, backend, rest.slice(1)); - - // POST starts a deep rescan, GET reads how it is going. Both answer with the same `rescan` block that - // rides on balances/transactions/utxos, so the UI has one shape to render and can poll whichever it - // was already polling. A backend with nothing to rescan is a clean 501 rather than a silent no-op. - case 'rescan': { - // No local state means either "never rescanned" or "this sidecar restarted while one was running", - // and only the upstream can tell those apart. Probing costs a request per account, so it happens - // only in that gap — once a scan is adopted or finished, the local state answers. - if (ctx.req.method === 'GET') { - const local = syncOf(backend)?.rescan ?? null; - return json({ rescan: local ?? (await backend.adoptRescan?.()) ?? null }); - } - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - if (!backend.startRescan) { - throw new BackendError('this wallet has nothing to rescan', 501, 'NOT_SUPPORTED'); - } - // The gap limit is the one thing about a rescan only the owner can know — how many addresses their - // old wallet issued without ever being paid to. The ceiling is a node-CPU guard, not a correctness - // one: past it the scan takes longer than anyone will wait for it. - const { gapLimit } = await body<{ gapLimit?: number }>(ctx.req); - if (gapLimit !== undefined && (!Number.isInteger(gapLimit) || gapLimit < 1 || gapLimit > MAX_GAP_LIMIT)) { - return badRequest(`gapLimit must be a whole number between 1 and ${MAX_GAP_LIMIT}`); - } - return json({ rescan: await backend.startRescan({ gapLimit }) }); - } - - case 'fees': - return json({ fees: await backend.estimateFees() }); - - case 'send': { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - requireCap(backend, 'onchainSend', 'sending on-chain'); - // The lock gate comes before body validation, not after. The backend checks it too, but only once - // the request has already passed every field check here — so a locked wallet was answering "your - // fee rate is wrong" instead of "unlock me first", which sends the UI down the wrong path. - if (wallet.hasSeed && !sessionFor(walletId).isUnlocked()) throw new WalletLockedError(); - const req2 = await body[0]>(ctx.req); - if (!req2.address) return badRequest('address is required'); - if (!req2.sendAll && !req2.amountSats) return badRequest('amountSats or sendAll is required'); - if (!req2.satPerVbyte || req2.satPerVbyte < 1) return badRequest('satPerVbyte must be at least 1'); - // Never spend a frozen coin, even if the caller passed no explicit outpoint list. - const frozenList = await getFrozenOutpoints(walletId); - const frozen = new Set(frozenList); - if (req2.outpoints?.some((o) => frozen.has(o))) return badRequest('refusing to spend a frozen UTXO'); - // Spread last so a caller cannot supply its own frozen list: the check above only ever covered - // outpoints the caller named explicitly, which left automatic selection free to pick a frozen coin. - const result = await backend.sendCoins({ ...req2, frozenOutpoints: frozenList }); - if (req2.label) await setWalletLabel(walletId, 'tx', result.txid, req2.label); - return json(result); - } - - case 'invoices': - return await invoicesRoute(ctx, backend, rest.slice(1)); - - case 'decode': { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - const { bolt11 } = await body<{ bolt11?: string }>(ctx.req); - if (!bolt11) return badRequest('bolt11 is required'); - return json({ decoded: await backend.decodeInvoice(bolt11) }); - } - - case 'payments': { - const limit = Number(ctx.url.searchParams.get('limit') ?? 50); - return json({ payments: await backend.getPayments({ limit }) }); - } - - case 'pay': { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - requireCap(backend, 'lightningSend', 'paying lightning invoices'); - const payReq = await body[0]>(ctx.req); - if (!payReq.bolt11) return badRequest('bolt11 is required'); - if (payReq.feeLimitMsat && payReq.feeLimitPercent) { - return badRequest('feeLimitMsat and feeLimitPercent are mutually exclusive'); - } - return json({ payment: await backend.payInvoice(payReq) }); - } - - case 'keysend': { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - requireCap(backend, 'keysend', 'keysend'); - const ks = await body[0]>(ctx.req); - if (!ks.destination || !ks.amountMsat) return badRequest('destination and amountMsat are required'); - return json({ payment: await backend.sendKeysend(ks) }); - } - - case 'channels': - requireCap(backend, 'channels', 'channels'); - return json({ channels: await backend.getChannels() }); - - case 'peers': - requireCap(backend, 'peers', 'peers'); - return json({ peers: await backend.getPeers() }); - - case 'sign': { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - requireCap(backend, 'signMessage', 'message signing'); - const { message } = await body<{ message?: string }>(ctx.req); - if (!message) return badRequest('message is required'); - return json(await backend.signMessage(message)); - } - - case 'verify': { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - requireCap(backend, 'signMessage', 'message verification'); - const { message, signature } = await body<{ message?: string; signature?: string }>(ctx.req); - if (!message || !signature) return badRequest('message and signature are required'); - return json(await backend.verifyMessage(message, signature)); - } - - case 'labels': { - if (ctx.req.method === 'GET') return json({ labels: await getWalletLabels(walletId) }); - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - const { kind, ref, label } = await body<{ kind?: string; ref?: string; label?: string }>(ctx.req); - if (kind !== 'address' && kind !== 'tx') return badRequest('kind must be "address" or "tx"'); - if (!ref) return badRequest('ref is required'); - await setWalletLabel(walletId, kind, ref, label ?? ''); - return json({ ok: true }); - } - - default: - return null; - } -} - -// ── wallet lifecycle ───────────────────────────────────────────────────────────────────────────── - -type CreateBody = { - name?: string; - kind?: string; - network?: string; - /** onchain only: omit `mnemonic` to generate a fresh seed. */ - mnemonic?: string; - words?: 12 | 24; - passphrase?: string; - bip39Passphrase?: string; - defaultBip?: number; - config?: Record; - makeActive?: boolean; -}; - -async function createWalletRoute(ctx: OfficerContext): Promise { - if (!hasStoreKey()) { - throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY'); - } - const b = await body(ctx.req); - if (!b.name?.trim()) return badRequest('name is required'); - // Same cap as the rename route, so a name you can create is always a name you can type back. - if (b.name.trim().length > 64) return badRequest('name is too long (64 characters max)'); - if (!b.kind || !(KINDS as readonly string[]).includes(b.kind)) { - return badRequest(`kind must be one of ${KINDS.join(', ')}`); - } - const kind = b.kind as WalletKind; - const network = (b.network ?? getConfig().network) as BitcoinNetwork; - - // Remote-node wallets: store the connection config, no seed involved. - if (kind !== 'onchain') { - if (!b.config) return badRequest(`${kind} wallets require a config object`); - const wallet = await createWallet({ - userId: ctx.userId, - name: b.name.trim(), - kind, - network, - config: b.config, - makeActive: b.makeActive ?? true, - }); - return json({ wallet }, 201); - } - - // Self-custodial on-chain wallet: seal a seed under the owner passphrase. - if (!b.passphrase) return badRequest('passphrase is required for a seeded wallet'); - const mnemonic = b.mnemonic?.trim() || generateSeed(b.words ?? 24); - const envelope = await sealSeed(mnemonic, b.passphrase, b.bip39Passphrase); - const { fingerprint, xpubs } = await deriveAccountXpubs(envelope, b.passphrase, network); - - const wallet = await createWallet({ - userId: ctx.userId, - name: b.name.trim(), - kind, - network, - sealedSeed: JSON.stringify(envelope), - fingerprint, - xpubs: Object.fromEntries(Object.entries(xpubs).map(([k, v]) => [k, v])), - defaultBip: b.defaultBip ?? 84, - makeActive: b.makeActive ?? true, - }); - - // An IMPORTED seed is the one case that needs the chain searched from scratch: an indexing upstream - // only watches an account from the moment it is registered, so a wallet with a history would otherwise - // show a confident, wrong zero. A freshly generated seed has no history to find, and a rescan for it - // would burn several minutes of the node's CPU to confirm nothing. - if (b.mnemonic) void kickOffRescan(ctx.userId, wallet.id); - - // Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and - // has no other chance to see it without re-entering the passphrase. An imported mnemonic is never - // echoed back: the caller already has it, and echoing would put it in a response log for no reason. - return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201); -} - -/** - * Start a rescan behind the response that created the wallet. - * - * Never throws: a chain source that cannot rescan, or an upstream that is down, must not turn a - * successful wallet import into a failed one. The owner can trigger it by hand from Settings either way. - */ -async function kickOffRescan(userId: number, walletId: number): Promise { - try { - const { backend } = await resolveBackend(userId, walletId); - await backend.startRescan?.(); - } catch (err) { - console.error('[wallet] rescan after import failed to start:', err instanceof Error ? err.message : err); - } -} - -async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise { - const wallet = await getWallet(ctx.userId, walletId); - if (!wallet) return json({ error: 'wallet not found' }, 404); - - // A bodyless DELETE is a caller that skipped the confirmation, not a malformed request — it should be - // told which field is missing rather than that its JSON did not parse. - type DeleteBody = { passphrase?: string; confirmName?: string }; - const { passphrase, confirmName } = await body(ctx.req).catch((): DeleteBody => ({})); - - // Deleting a seeded wallet destroys the only copy of the key material Officer holds. Require the - // passphrase, even when the wallet is already unlocked — an open session must not be enough. - if (wallet.hasSeed) { - if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet'); - const sealed = await getSealedSeed(ctx.userId, walletId); - if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED'); - if (!(await verifyPassphrase(walletId, JSON.parse(sealed) as SeedEnvelope, passphrase))) { - return json({ error: 'incorrect passphrase' }, 401); - } - } else if (confirmName?.trim() !== wallet.name) { - // A wallet with no seed of its own still holds the node credential, the labels and the freezes, and - // it deleted on one unconfirmed request — the name check lived only in the dialog, so anything that - // was not the dialog skipped it. Naming the wallet is the same bar the UI already asks for, now - // enforced where it cannot be bypassed. - return badRequest('confirmName must match the wallet name'); - } - - sessionFor(walletId).lock(); - invalidate(walletId); - const deleted = await deleteWallet(ctx.userId, walletId); - return deleted ? json({ ok: true }) : json({ error: 'wallet not found' }, 404); -} - -// ── lock lifecycle ─────────────────────────────────────────────────────────────────────────────── - -async function loadEnvelope(userId: number, walletId: number): Promise { - const sealed = await getSealedSeed(userId, walletId); - if (!sealed) throw new BackendError('this wallet holds no seed', 400, 'NO_SEED'); - return JSON.parse(sealed) as SeedEnvelope; -} - -async function unlockRoute(ctx: OfficerContext, walletId: number): Promise { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - const { passphrase, ttlSec } = await body<{ passphrase?: string; ttlSec?: number }>(ctx.req); - if (!passphrase) return badRequest('passphrase is required'); - - const env = await loadEnvelope(ctx.userId, walletId); - const session = sessionFor(walletId); - const max = getConfig().unlockTtlSec; - // A caller may shorten the window but never extend it past the deployment's configured maximum. - await session.unlock(env, passphrase, Math.min(ttlSec ?? max, max)); - - return json({ ok: true, unlocked: true, secondsRemaining: session.secondsRemaining() }); -} - -async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Promise { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - const { oldPassphrase, newPassphrase } = await body<{ oldPassphrase?: string; newPassphrase?: string }>(ctx.req); - if (!oldPassphrase || !newPassphrase) return badRequest('oldPassphrase and newPassphrase are required'); - - const env = await loadEnvelope(ctx.userId, walletId); - const resealed = await changePassphrase(walletId, env, oldPassphrase, newPassphrase); - await replaceSealedSeed(ctx.userId, walletId, JSON.stringify(resealed)); - // Force a re-unlock under the new passphrase rather than leaving a session opened by the old one. - sessionFor(walletId).lock(); - return json({ ok: true }); -} - -async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - const { passphrase } = await body<{ passphrase?: string }>(ctx.req); - if (!passphrase) return badRequest('passphrase is required'); - - const env = await loadEnvelope(ctx.userId, walletId); - const mnemonic = await exportMnemonic(walletId, env, passphrase); - console.warn(`[wallet] seed exported for wallet ${walletId} by user ${ctx.userId}`); - return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase }); -} - -/** - * Balances, plus how much of the confirmed figure the owner has frozen. - * - * Only asks the backend for its UTXOs when something is actually frozen, so a wallet that never uses the - * feature pays nothing for it — on LND that is a real extra RPC, and this runs on every balance poll. - */ -async function balancesWithFrozen(walletId: number, backend: WalletBackend): Promise { - const balances = await backend.getBalances(); - const frozenList = await getFrozenOutpoints(walletId); - if (frozenList.length === 0) return balances; - - const frozen = new Set(frozenList); - const utxos = await backend.getUtxos(); - // Confirmed only, to match what it is a component of. An unconfirmed frozen coin is not spendable for - // a reason that has nothing to do with freezing. - const sats = utxos - .filter((u) => frozen.has(`${u.txid}:${u.vout}`) && u.confirmations > 0) - .reduce((sum, u) => sum + u.amountSats, 0); - return { ...balances, onchainFrozen: sats }; -} - -// ── utxos ──────────────────────────────────────────────────────────────────────────────────────── - -async function utxosRoute( - ctx: OfficerContext, - walletId: number, - backend: WalletBackend, - seg: string[], -): Promise { - requireCap(backend, 'coinControl', 'coin control'); - - if (seg[0] === 'freeze') { - if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); - const { outpoint, frozen, reason } = await body<{ outpoint?: string; frozen?: boolean; reason?: string }>(ctx.req); - if (!outpoint || !/^[0-9a-f]{64}:\d+$/i.test(outpoint)) return badRequest('outpoint must be "txid:vout"'); - await setUtxoFrozen(walletId, outpoint, frozen !== false, reason); - return json({ ok: true }); - } - - const [utxos, frozenList, labels] = await Promise.all([ - backend.getUtxos(), - getFrozenOutpoints(walletId), - getWalletLabels(walletId), - ]); - const frozen = new Set(frozenList); - const byAddr = new Map(labels.filter((l) => l.kind === 'address').map((l) => [l.ref, l.label])); - - // The freeze flag is Officer's, not the backend's — overlay it here so coin control is consistent - // across every backend, including ones with no freeze concept of their own. - const merged: (Utxo & { label: string | null })[] = utxos.map((u) => ({ - ...u, - frozen: frozen.has(`${u.txid}:${u.vout}`), - label: byAddr.get(u.address) ?? null, - })); - return json({ utxos: merged, sync: syncOf(backend) }); -} - -// ── invoices ───────────────────────────────────────────────────────────────────────────────────── - -async function invoicesRoute(ctx: OfficerContext, backend: WalletBackend, seg: string[]): Promise { - // /_officer/wallets/:id/invoices/:paymentHash - if (seg.length === 1) { - const invoice = await backend.lookupInvoice(seg[0]!); - return invoice ? json({ invoice }) : json({ error: 'invoice not found' }, 404); - } - - if (ctx.req.method === 'GET') { - requireCap(backend, 'lightningReceive', 'lightning'); - const limit = Number(ctx.url.searchParams.get('limit') ?? 50); - return json({ invoices: await backend.getInvoices({ limit }) }); - } - - if (ctx.req.method === 'POST') { - requireCap(backend, 'lightningReceive', 'creating invoices'); - const req = await body[0]>(ctx.req); - if (req.preimage) requireCap(backend, 'customPreimages', 'custom preimages'); - return json({ invoice: await backend.createInvoice(req) }, 201); - } - - return json({ error: 'method not allowed' }, 405); -} diff --git a/src/servers/sidecar/wallet/types.ts b/src/servers/sidecar/wallet/types.ts deleted file mode 100644 index cf04561f..00000000 --- a/src/servers/sidecar/wallet/types.ts +++ /dev/null @@ -1,423 +0,0 @@ -// The wallet sidecar's backend contract — a typed re-statement of what Zeus's utils/BackendUtils.ts -// dispatches to duck-typed across its seven backends. -// -// Two things are deliberately NOT copied from Zeus: -// -// 1. Zeus's `call(funcName, args)` returns `false` when a backend lacks a method, so an unsupported -// feature and a typo'd method name are indistinguishable at the call site. Here the surface is a -// TypeScript interface and capability negotiation is explicit via `supports()`. -// 2. Zeus threads URL path segments through the interface — `decodePaymentRequest(urlParams[0])`, -// `closeChannel(urlParams)`. Every operation below takes a named request object. -// -// UNITS. Amounts are satoshis as `number` (max 2.1e15, safely inside 2^53) and millisatoshis as -// decimal `string` (2.1e18 overflows a double). Never widen a msat to a number. - -export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc'; - -export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest'; - -/** - * Explicit capability flags, replacing Zeus's ~60 `supportsX()` predicates (BackendUtils.ts:200-270). - * A backend declares these once; routes.ts checks before dispatching so an unsupported operation is a - * clean 501 rather than a mystery failure deep in an upstream call. - */ -export type Capability = - // on-chain - | 'onchainReceive' - | 'onchainSend' - | 'coinControl' - | 'psbt' - | 'bumpFee' - | 'sweep' - | 'accounts' - // lightning - | 'lightningReceive' - | 'lightningSend' - | 'keysend' - | 'customPreimages' - | 'offers' - // node operation - | 'channels' - | 'peers' - | 'routing' - | 'signMessage'; - -// ── node / balances ────────────────────────────────────────────────────────────────────────────── - -export type NodeInfo = { - kind: BackendKind; - /** Node pubkey, or null for backends with no node identity of their own (onchain, lndhub). */ - pubkey: string | null; - alias: string | null; - /** Upstream implementation version, verbatim. Null when the backend cannot report one. */ - version: string | null; - network: BitcoinNetwork; - blockHeight: number | null; - synced: boolean; -}; - -export type Balances = { - /** Confirmed on-chain, in sats. Includes anything frozen — see `onchainFrozen`. */ - onchainConfirmed: number; - onchainUnconfirmed: number; - /** - * How much of `onchainConfirmed` sits in UTXOs the owner froze, and so cannot be spent. - * - * A component of the confirmed figure rather than a deduction from it, because the coins are still - * the wallet's — freezing is a spending policy, not a loss. Officer's overlay: freezing lives in - * Postgres and no backend knows about it, so every backend reports 0 and the route fills it in. - * Without this the UI shows a balance a max-value send then refuses to spend, with no visible reason. - */ - onchainFrozen: number; - /** Sum of local balance across active channels, in sats. Null when the backend has no channels. */ - lightningBalance: number | null; - /** Sum of remote balance — i.e. inbound liquidity — in sats. */ - lightningInbound: number | null; -}; - -// ── on-chain ───────────────────────────────────────────────────────────────────────────────────── - -export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh'; - -/** - * BIP44 purpose → the script type it derives. Keyed by the string form, because that is how a wallet's - * `xpubs` map arrives from the database (JSON object keys are always strings). - */ -export const BIP_ADDRESS_TYPE: Record = { - '44': 'p2pkh', - '49': 'p2sh-p2wpkh', - '84': 'p2wpkh', - '86': 'p2tr', -}; - -/** Narrow caller-supplied text to an AddressType, or undefined if it names none. */ -export function asAddressType(value: string): AddressType | undefined { - return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh' ? value : undefined; -} - -export type OnchainTx = { - txid: string; - /** Net effect on this wallet in sats — negative for a spend. */ - amount: number; - feeSats: number | null; - blockHeight: number | null; - /** Unix seconds. Null while unconfirmed on backends that do not timestamp the mempool. */ - timestamp: number | null; - confirmations: number; - label: string | null; - destAddresses: string[]; - /** Raw tx hex when the backend supplies it, for RBF/inspection. */ - rawHex: string | null; -}; - -export type Utxo = { - txid: string; - vout: number; - amountSats: number; - address: string; - addressType: AddressType | null; - confirmations: number; - /** BIP32 path relative to the account xpub, when this UTXO belongs to a derived address. */ - derivationPath: string | null; - /** Frozen UTXOs are excluded from automatic coin selection. */ - frozen: boolean; -}; - -export type FeeEstimates = { - /** sat/vB, keyed by confirmation target in blocks. */ - fastestFee: number; - halfHourFee: number; - hourFee: number; - economyFee: number; - minimumFee: number; -}; - -export type NewAddressRequest = { - type?: AddressType; - /** Return the current unused address instead of advancing the derivation index. */ - peek?: boolean; -}; - -export type SendCoinsRequest = { - address: string; - /** Ignored when `sendAll` is set. */ - amountSats?: number; - sendAll?: boolean; - satPerVbyte: number; - /** Coin control: restrict inputs to these outpoints (`txid:vout`). Requires the `coinControl` capability. */ - outpoints?: string[]; - spendUnconfirmed?: boolean; - /** Opt out of RBF signalling. Default is replaceable. */ - rbf?: boolean; - label?: string; - /** - * Outpoints automatic selection must not touch. SET BY THE ROUTE FROM OFFICER'S FROZEN LIST, never by - * the caller — whatever arrives in the request body is overwritten. - * - * Freezing is Officer policy stored in Postgres, and a backend has no business reading that table; so - * the frozen set has to travel with the request. Note it only ever restricts what may be spent, so - * even a caller who did smuggle a value in could not widen the selection. - */ - frozenOutpoints?: readonly string[]; -}; - -export type SendCoinsResult = { - txid: string; - feeSats: number; - rawHex: string | null; -}; - -// ── lightning ──────────────────────────────────────────────────────────────────────────────────── - -export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired'; - -export type Invoice = { - /** Payment hash, hex. */ - paymentHash: string; - /** BOLT11 payment request. */ - bolt11: string; - amountMsat: string | null; - amountPaidMsat: string | null; - memo: string | null; - state: InvoiceState; - createdAt: number; - expiresAt: number | null; - settledAt: number | null; - /** Only ever populated for invoices this wallet created and only when explicitly requested. */ - preimage: string | null; - isKeysend: boolean; - isAmp: boolean; -}; - -export type CreateInvoiceRequest = { - amountMsat?: string; - memo?: string; - expirySeconds?: number; - /** Requires the `customPreimages` capability. */ - preimage?: string; - isAmp?: boolean; - private?: boolean; -}; - -export type DecodedInvoice = { - bolt11: string; - paymentHash: string; - amountMsat: string | null; - description: string | null; - destination: string; - timestamp: number; - expiry: number; - cltvExpiry: number | null; - routeHints: boolean; - features: string[]; -}; - -export type PaymentStatus = 'pending' | 'succeeded' | 'failed'; - -export type Payment = { - paymentHash: string; - preimage: string | null; - amountMsat: string; - feeMsat: string; - status: PaymentStatus; - createdAt: number; - destination: string | null; - memo: string | null; - /** Upstream failure reason, verbatim, when status is 'failed'. */ - failureReason: string | null; -}; - -export type PayInvoiceRequest = { - bolt11: string; - /** Required when the invoice is zero-amount; rejected otherwise. */ - amountMsat?: string; - /** Absolute cap in msat. Mutually exclusive with feeLimitPercent. */ - feeLimitMsat?: string; - feeLimitPercent?: number; - timeoutSeconds?: number; -}; - -export type KeysendRequest = { - destination: string; - amountMsat: string; - feeLimitMsat?: string; - message?: string; -}; - -// ── channels / peers ───────────────────────────────────────────────────────────────────────────── - -export type Channel = { - channelId: string; - channelPoint: string | null; - remotePubkey: string; - remoteAlias: string | null; - capacitySats: number; - localBalanceSats: number; - remoteBalanceSats: number; - active: boolean; - private: boolean; - /** 'open' | 'pending-open' | 'pending-close' | 'force-closing' | 'closed' */ - status: string; -}; - -export type Peer = { - pubkey: string; - address: string; - alias: string | null; - inbound: boolean; -}; - -// ── signing ────────────────────────────────────────────────────────────────────────────────────── - -export type SignMessageResult = { signature: string }; -export type VerifyMessageResult = { valid: boolean; pubkey: string | null }; - -// ── the interface ──────────────────────────────────────────────────────────────────────────────── - -/** - * Every method may throw `BackendError`. Methods guarded by a capability must only be called after - * `supports()` returns true — the base class throws `notSupported()` otherwise, so a missed guard is a - * loud 501 rather than Zeus's silent `false`. - */ -export interface WalletBackend { - readonly kind: BackendKind; - - supports(cap: Capability): boolean; - - getInfo(): Promise; - getBalances(): Promise; - - // on-chain - getTransactions(opts?: { limit?: number }): Promise; - getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }>; - getUtxos(): Promise; - estimateFees(): Promise; - sendCoins(req: SendCoinsRequest): Promise; - - // lightning - getInvoices(opts?: { limit?: number }): Promise; - createInvoice(req: CreateInvoiceRequest): Promise; - lookupInvoice(paymentHash: string): Promise; - decodeInvoice(bolt11: string): Promise; - getPayments(opts?: { limit?: number }): Promise; - payInvoice(req: PayInvoiceRequest): Promise; - sendKeysend(req: KeysendRequest): Promise; - - // node operation - getChannels(): Promise; - getPeers(): Promise; - - signMessage(message: string): Promise; - verifyMessage(message: string, signature: string): Promise; - - /** - * How old the data this backend just served is. Optional because it is only meaningful for a backend - * that caches — a node backend answers from its own live state, and reporting a sync age for it would - * be inventing one. - */ - getSyncState?(): SyncState; - - /** - * Ask the chain to search for this wallet's coins from scratch. Resolves as soon as the search is - * queued, not when it finishes — progress is read back through `getSyncState().rescan`. - * - * Optional because only a wallet-level chain source can do it. Absent means the backend has nothing to - * rescan (a node backend already knows its own coins) or the source has no such endpoint. - */ - startRescan?(opts?: RescanOptions): Promise; - - /** - * Re-attach to a rescan that is running upstream but was not started by this process. - * - * A deep rescan takes minutes and lives on the indexer, not here — so restarting the sidecar mid-scan - * used to lose the wallet's only handle on it. `getSyncState().rescan` read back null, the UI showed no - * scan at all, and the refresh chained onto its completion went with it, leaving the balance stale - * until the cache TTL happened to expire. This asks the upstream what is actually in flight. - * - * Resolves null when nothing is running. Costs an upstream call, so callers reach for it only when - * there is no local state to report. - */ - adoptRescan?(): Promise; -} - -/** - * Freshness of a cached view of the chain, so the UI can say "3 minutes ago" and, more importantly, tell - * "this wallet is empty" apart from "we could not ask". Those two rendered identically before this - * existed, which is the whole reason it does. - */ -export type SyncState = { - /** Unix ms of the last successful chain read. Null when the chain has never been read successfully. */ - syncedAt: number | null; - /** The served data is past its refresh interval; a refresh is already running behind this response. */ - stale: boolean; - /** The last refresh failure, still reported while the previous good data is being served. */ - lastError: string | null; - /** - * Whether this chain source can search the chain from scratch at all. False for Esplora, which asks - * about every address on every refresh and so has nothing to catch up on. - * - * Distinct from `rescan === null`, which is ALSO what a source that simply hasn't run one yet reports - * — conflating the two put a button that 501s in front of every Esplora wallet. - */ - canRescan: boolean; - /** A deep rescan in flight, or the outcome of the last one. Null from a source that cannot rescan. */ - rescan: RescanState | null; -}; - -/** - * How far a deep rescan should look, when the default is not far enough. - * - * The default gap limit is 1000 consecutive unused addresses, which covers any wallet a person has used - * by hand. It does not cover one restored from a wallet that issued addresses in bulk — and there is no - * way to detect that case, because the whole problem is that the coins beyond the gap are invisible. - * Raising it costs the node CPU on one scan; leaving it too low costs the owner the coins. So the knob is - * exposed rather than tuned, and the caller who knows their history is the one who sets it. - */ -export type RescanOptions = { gapLimit?: number }; - -/** - * A deep chain rescan — the upstream searching the whole UTXO set for a wallet's coins, rather than - * indexing forward from the moment the wallet was registered. - * - * This exists for exactly one situation, and it is not a rare one: an imported seed on a node that only - * started watching the xpub today. Every read succeeds, nothing errors, and the balance is zero — which - * is indistinguishable from an empty wallet unless the UI can say "still looking". So the state is - * carried on SyncState beside `syncedAt`, on the same responses, for the same reason. - * - * It runs one account variant at a time because the node runs `scantxoutset` one at a time; four - * variants is minutes, not seconds. - */ -export type RescanState = { - running: boolean; - /** Account variants finished, out of how many were queued. */ - done: number; - total: number; - /** The variant the node is chewing on right now. Null when nothing is running. */ - current: AddressType | null; - /** UTXOs the finished variants pulled in. */ - found: number; - startedAt: number; - finishedAt: number | null; - error: string | null; -}; - -// ── errors ─────────────────────────────────────────────────────────────────────────────────────── - -export class BackendError extends Error { - constructor( - message: string, - readonly status: number = 502, - readonly code?: string, - ) { - super(message); - this.name = 'BackendError'; - } -} - -/** The wallet holds a seed but is currently locked — signing is impossible until /unlock. */ -export class WalletLockedError extends BackendError { - constructor() { - super('wallet is locked', 423, 'WALLET_LOCKED'); - this.name = 'WalletLockedError'; - } -} diff --git a/src/servers/sidecar/wallet/upstream.ts b/src/servers/sidecar/wallet/upstream.ts deleted file mode 100644 index 9a627b19..00000000 --- a/src/servers/sidecar/wallet/upstream.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { BitcoinNetwork } from './types'; -import { getServiceCredentials } from 'officerdb'; -import { getKey } from 'officerdb/secret-store'; - -// The ONLY reader of WALLET_* env in the tree. Everything else — node URLs, macaroons, runes, LNDHub -// credentials, NWC URIs — is per-wallet configuration the owner enters at runtime and lives encrypted in -// Postgres (databases/officer_db/src/wallet/schema.ts), not here. Env holds only what is genuinely -// deployment-wide: which chain we're on. -// -// WHERE CHAIN DATA COMES FROM IS NOT ENV. It used to be WALLET_ESPLORA_URL, which meant the one setting -// an owner actually has to change — off the public explorer, onto their own indexer — was the one -// setting they could only change with a shell and a restart. It now lives in `service_connections` -// under 'esplora' and is edited from Wallet → Settings; see getChainSource below. - -const NETWORKS: readonly BitcoinNetwork[] = ['bitcoin', 'testnet', 'signet', 'regtest']; - -// mempool.space's public API. Fine to start on; swap it for your own electrs/esplora when the node is up -// — an Esplora endpoint sees every address in the wallet, so the public one is a privacy leak, not a -// custody one. It never sees a private key and cannot authorize anything. -const DEFAULT_ESPLORA: Record = { - bitcoin: 'https://mempool.space/api', - testnet: 'https://mempool.space/testnet/api', - signet: 'https://mempool.space/signet/api', - regtest: 'http://127.0.0.1:3002', -}; - -export type WalletConfig = { - network: BitcoinNetwork; - esploraUrl: string; - unlockTtlSec: number; -}; - -let warned = false; - -export function getConfig(): WalletConfig { - const raw = process.env.WALLET_NETWORK?.trim() ?? 'bitcoin'; - const network = (NETWORKS as readonly string[]).includes(raw) ? (raw as BitcoinNetwork) : 'bitcoin'; - if (raw && network !== raw && !warned) { - console.warn(`[wallet] WALLET_NETWORK="${raw}" is not a known network, falling back to bitcoin`); - warned = true; - } - - const ttl = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900); - - return { - network, - /** The built-in default, used until the owner stores one. `getChainSource` is the real answer. */ - esploraUrl: DEFAULT_ESPLORA[network], - // Clamp: a zero TTL makes the wallet unusable, and an unbounded one defeats auto-lock entirely. - unlockTtlSec: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 30), 86_400) : 900, - }; -} - -/** - * Which protocol the endpoint speaks. Not interchangeable — see chain-source.ts for why the two are - * different shapes rather than two URLs for the same thing. - */ -export const CHAIN_KINDS = ['nbxplorer', 'esplora'] as const; - -export type ChainKind = (typeof CHAIN_KINDS)[number]; - -export type ChainSource = { - kind: ChainKind; - /** API root, no trailing slash. */ - baseUrl: string; - /** True when this is the owner's stored endpoint rather than the built-in public default. */ - custom: boolean; -}; - -/** Short TTL: a save invalidates explicitly, so this only bounds how stale a *concurrent* reader can be. */ -const CHAIN_TTL_MS = 60_000; -const chainCache = new Map(); - -export const normalizeEsploraUrl = (url: string): string => url.trim().replace(/\/+$/, ''); - -/** - * Where to read the chain, for this owner. - * - * AT MOST ONE of the two rows exists: saving either kind retires the other (config.ts::save), because a - * wallet reads from one place and storing two would leave the answer to precedence rather than to the - * owner. The order below is therefore a tiebreak that should never be needed, not a preference. - * - * Falls back to the public default rather than failing: a wallet with no stored endpoint is still a - * working watch-only wallet, just one pointed at an explorer that rate-limits and can see every address - * it asks about. The default is a starting point, not a destination. - */ -export async function getChainSource(userId: number): Promise { - const hit = chainCache.get(userId); - if (hit && Date.now() - hit.at < CHAIN_TTL_MS) return hit.source; - - const source = await readChainSource(userId); - chainCache.set(userId, { source, at: Date.now() }); - return source; -} - -async function readChainSource(userId: number): Promise { - for (const kind of CHAIN_KINDS) { - const creds = await getServiceCredentials(userId, kind); - if (creds?.url) return { kind, baseUrl: normalizeEsploraUrl(creds.url), custom: true }; - } - return { kind: 'esplora', baseUrl: getConfig().esploraUrl, custom: false }; -} - -export function invalidateChainSource(userId: number): void { - chainCache.delete(userId); -} - -/** - * Whether the wallet's at-rest key is usable. The sidecar can serve a locked, watch-only view without - * one, but every write path touching an encrypted column throws, so /_health reports it explicitly - * rather than letting the first wallet creation fail with a confusing crypto error. - * - * This tested `VAULT_STORE_KEY` in the environment until 2026-08-13. The key now lives in the secret - * store under the `wallet` purpose and is CREATED ON FIRST USE — so the honest question is no longer - * "did somebody set a variable" but "can this process open the store at all". A false here means the - * file is unreachable or unwritable, which is an install problem rather than a configuration one. - */ -export function hasStoreKey(): boolean { - try { - return getKey('wallet').length > 0; - } catch { - return false; - } -} diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 2d9f86f8..f5f1742e 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -11,7 +11,6 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; import { appRegistryMetas as giteaMetas } from '../apps/Gitea'; -import { appRegistryMetas as walletMetas } from '../apps/Wallet'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; import { appRegistryMetas as davMetas } from '../apps/Dav'; @@ -31,7 +30,6 @@ export const apps = [ ...widgetMetas, ...desktopMetas, ...giteaMetas, - ...walletMetas, ...monitorMetas, ...qrTransferMetas, ...davMetas, diff --git a/src/workspaces/officerdev/src/apps/Wallet/Amount.tsx b/src/workspaces/officerdev/src/apps/Wallet/Amount.tsx deleted file mode 100644 index 0119f4ff..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/Amount.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { formatAmount, formatMsat } from './format'; -import { useAmountUnit } from './useAmountUnit'; - -// One place amounts are rendered, so the sats/BTC toggle flips every number on screen at once. -// -// `sats` is a number; `msat` is a decimal STRING and stays one all the way through formatMsat. Passing a -// msat through the `sats` prop would parse it into a double — which is exactly the bug the string type -// exists to prevent, so the two props are deliberately not interchangeable. - -type AmountProps = { - sats?: number | null; - msat?: string | null; - className?: string; - /** Colour a negative amount as a spend and a positive one as a receipt. */ - signed?: boolean; -}; - -export const Amount = ({ sats, msat, className = '', signed = false }: AmountProps) => { - const { unit } = useAmountUnit(); - const text = msat !== undefined ? formatMsat(msat, unit) : formatAmount(sats, unit); - - const tone = !signed || sats == null ? '' : sats < 0 ? 'text-destructive' : sats > 0 ? 'text-emerald-500' : ''; - - return ( - - {signed && sats != null && sats > 0 ? '+' : ''} - {text} - - ); -}; - -/** The toggle itself — a button, because it mutates a preference rather than navigating. */ -export const UnitToggle = ({ className = '' }: { className?: string }) => { - const { unit, toggle } = useAmountUnit(); - - return ( - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx b/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx deleted file mode 100644 index 47bfd664..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import type { ServiceConnectionState } from '../../hooks/useServiceConnection'; -import { useEffect, useState } from 'react'; -import { CheckCircle2, Loader2, RotateCcw, TriangleAlert } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - serviceErrorMessage, - useServiceConnection, - useServiceConnectionActions, -} from '../../hooks/useServiceConnection'; - -// Where on-chain wallets read the chain from. One endpoint serves every on-chain wallet the owner has, -// which is why it sits here and not on a wallet. -// -// PRIVACY, NOT CUSTODY. A chain endpoint learns every address you ask it about, so pointing this at your -// own node is a real upgrade — but it never sees a key and cannot move a coin. That framing is on the -// card deliberately: the risk of getting it wrong is being watched, not being robbed. -// -// TWO PROTOCOLS, AND THEY ARE NOT INTERCHANGEABLE. Esplora is the public-explorer API (electrs, esplora, -// mempool.space); NBXplorer is what you run in front of your own bitcoind. The picker is not cosmetic — -// it selects a different client, so a URL saved under the wrong one fails the probe rather than silently -// misbehaving. bitcoin-core's own RPC port is neither. - -type ChainKind = 'esplora' | 'nbxplorer'; - -type ChainState = ServiceConnectionState & { - kind: ChainKind; - esploraUrl: string; - custom: boolean; - defaultUrl: string; - network: string; -}; - -type SaveResult = ChainState & { probe?: { blockHeight?: number | null; ms?: number } }; - -type KindMeta = { label: string; placeholder: string; hint: string }; - -const KINDS: Record = { - esplora: { - label: 'Esplora', - placeholder: 'https://mempool.space/api', - hint: 'An Esplora REST API — electrs, esplora, or a mempool.space instance.', - }, - nbxplorer: { - label: 'NBXplorer', - placeholder: 'http://127.0.0.1:32838', - hint: 'NBXplorer in front of your own bitcoind. Its API root, not bitcoind’s RPC port.', - }, -}; - -export const ChainSourceCard = () => { - const { data, isLoading } = useServiceConnection('wallet'); - const { save, forget } = useServiceConnectionActions<{ url: string; kind: ChainKind }, SaveResult>('wallet'); - - const [url, setUrl] = useState(''); - const [kind, setKind] = useState('esplora'); - const [error, setError] = useState(null); - const [height, setHeight] = useState(null); - - // Seeded from whatever is in use, so the form starts as the answer to "what is this pointed at" and the - // owner edits it rather than retyping it. Keyed on both fields: a save or reset should re-seed. - useEffect(() => { - if (!data) return; - setUrl(data.esploraUrl); - setKind(data.kind); - }, [data?.esploraUrl, data?.kind]); - - const trimmed = url.trim(); - const dirty = !!data && (trimmed !== data.esploraUrl || kind !== data.kind); - const busy = save.isPending || forget.isPending; - const meta = KINDS[kind]; - - const submit = async () => { - setError(null); - setHeight(null); - try { - const result = await save.mutateAsync({ url: trimmed, kind }); - setHeight(result.probe?.blockHeight ?? null); - } catch (err) { - setError(serviceErrorMessage(err)); - } - }; - - const reset = async () => { - setError(null); - setHeight(null); - try { - await forget.mutateAsync(); - } catch (err) { - setError(serviceErrorMessage(err)); - } - }; - - /** Switching protocol clears a URL that belongs to the other one — it would only fail the probe. */ - const pick = (next: ChainKind) => { - if (next === kind) return; - setKind(next); - setError(null); - setHeight(null); - if (data && trimmed === data.esploraUrl && next !== data.kind) setUrl(''); - }; - - return ( -
-

Chain source

-

- Where on-chain wallets read balances and history. It sees every address this wallet asks about, so your own node - is a privacy upgrade — it never sees a key and cannot spend. -

- -
- {(Object.keys(KINDS) as ChainKind[]).map((k) => ( - - ))} -
- -
- setUrl(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Enter' && trimmed && dirty && !busy) void submit(); - }} - placeholder={meta.placeholder} - disabled={isLoading || busy} - className="h-8 font-mono text-xs" - aria-label={`${meta.label} API URL`} - autoComplete="off" - spellCheck={false} - /> -
- - {data?.custom && ( - - )} -
-
- - {error && ( -

- - {error} -

- )} - - {height != null && !error && ( -

- - Connected — block {height.toLocaleString()} -

- )} - -

- {data?.custom - ? `Using your ${KINDS[data.kind].label} endpoint.` - : `Using the built-in default (${data?.defaultUrl ?? '—'}), which rate-limits and can see every address you look up.`}{' '} - {meta.hint} -

-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx deleted file mode 100644 index 194eeaea..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { Utxo } from './shared'; -import { Link } from 'react-router'; -import { Loader2, Snowflake, Sun } from 'lucide-react'; -import { formatConfirmations, truncateMiddle } from './format'; -import { walletSectionPath } from './shared'; -import { Amount } from './Amount'; -import { EmptyWallet, UnsupportedSection } from './EmptyWallet'; -import { SyncBadge } from './SyncBadge'; -import { useSelectedWallet } from './useSelectedWallet'; -import { useCoinSelection } from './useCoinSelection'; -import { usePermissions, useUtxos, useWalletOperations } from './useWalletData'; - -// Coin control. Works while locked: which coins exist and which are frozen is watch-only information, and -// freezing is Officer's own flag rather than anything signed. -// -// The selection lives in `?coins=` so the Send section reads the same set straight from the URL. - -export const CoinsView = () => { - const { walletId, isLoading } = useSelectedWallet(); - const { capabilities } = usePermissions(walletId); - const supported = capabilities.includes('coinControl'); - - const { utxos, sync, isLoading: utxosLoading } = useUtxos(walletId, supported); - const { selected, toggle, clear } = useCoinSelection(); - - if (!walletId) return ; - if (!supported) return ; - - const selectedTotal = utxos - .filter((u) => selected.includes(`${u.txid}:${u.vout}`)) - .reduce((sum, u) => sum + u.amountSats, 0); - const spendableTotal = utxos.filter((u) => !u.frozen).reduce((sum, u) => sum + u.amountSats, 0); - - return ( -
-
- - {utxos.length} coin{utxos.length === 1 ? '' : 's'} · spendable - - - - {selected.length > 0 && ( - <> - - {selected.length} selected · - - - - Spend these - - - )} -
- -
- {utxosLoading && utxos.length === 0 ? ( -
- - Listing coins… -
- ) : utxos.length === 0 ? ( -
- {/* "No coins" is only true if the chain was actually read. Never-synced is a different fact. */} -

{sync && sync.syncedAt === null ? 'Coins unavailable' : 'No coins'}

-

- {sync && sync.syncedAt === null - ? (sync.lastError ?? 'The chain source could not be reached.') - : 'Receive something and it will show up here as a UTXO.'} -

-
- ) : ( -
    - {utxos.map((utxo) => ( - - ))} -
- )} -
-
- ); -}; - -type CoinRowProps = { utxo: Utxo; walletId: number; selected: boolean; onToggle: (outpoint: string) => void }; - -const CoinRow = ({ utxo, walletId, selected, onToggle }: CoinRowProps) => { - const { freeze } = useWalletOperations(walletId); - const outpoint = `${utxo.txid}:${utxo.vout}`; - - return ( -
  • - onToggle(outpoint)} - aria-label={`Select ${outpoint}`} - className="h-3.5 w-3.5 shrink-0 accent-primary" - /> - -
    -
    {utxo.label || truncateMiddle(utxo.address, 16, 10)}
    -
    - - {truncateMiddle(utxo.txid, 10, 6)}:{utxo.vout} - - {formatConfirmations(utxo.confirmations)} - {utxo.addressType && {utxo.addressType}} -
    -
    - - - - -
  • - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/CopyField.tsx b/src/workspaces/officerdev/src/apps/Wallet/CopyField.tsx deleted file mode 100644 index fbe0d841..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/CopyField.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useState } from 'react'; -import { Check, Copy } from 'lucide-react'; -import { copyToClipboard } from 'helpers/clipboard'; - -// A read-only value with a copy button — addresses, invoices, txids, xpubs. -// -// Never used for anything secret. A mnemonic gets its own deliberately awkward treatment in -// SeedBackupDialog rather than a one-tap copy, because "copied to clipboard" is where seeds go to die. - -type CopyFieldProps = { - value: string; - label?: string; - /** Wrap rather than truncate — right for a bech32 address, wrong for a table cell. */ - wrap?: boolean; - className?: string; -}; - -export const CopyField = ({ value, label, wrap = false, className = '' }: CopyFieldProps) => { - const [copied, setCopied] = useState(false); - - const copy = async () => { - if (!(await copyToClipboard(value))) return; - setCopied(true); - setTimeout(() => setCopied(false), 1_500); - }; - - return ( -
    - {label && ( -
    {label}
    - )} -
    - {value} - -
    -
    - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/EmptyWallet.tsx b/src/workspaces/officerdev/src/apps/Wallet/EmptyWallet.tsx deleted file mode 100644 index 15880280..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/EmptyWallet.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Bitcoin, Loader2 } from 'lucide-react'; - -// The "no wallet selected" and "this backend cannot do that" placeholders. -// -// Deliberately not an error state: with no wallets registered there is nothing wrong, and a section a -// backend does not implement is a fact about the backend, not a failure. The nav hides those sections, so -// UnsupportedSection is only reached by a deep link — which should explain itself rather than 404. - -export const EmptyWallet = ({ isLoading = false }: { isLoading?: boolean }) => ( -
    - {isLoading ? ( - <> - -

    Loading wallets…

    - - ) : ( - <> - -

    No wallet yet

    -

    - Add one from the panel on the left — a self-custodial seed sealed under a passphrase, or a connection to a - node you already run. -

    - - )} -
    -); - -export const UnsupportedSection = ({ what }: { what: string }) => ( -
    -

    Not available for this wallet

    -

    - This backend does not support {what}. Pick a different wallet from the panel on the left. -

    -
    -); diff --git a/src/workspaces/officerdev/src/apps/Wallet/LightningView.tsx b/src/workspaces/officerdev/src/apps/Wallet/LightningView.tsx deleted file mode 100644 index 27214fb2..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/LightningView.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import type { Channel } from './shared'; -import { Users, Zap } from 'lucide-react'; -import { formatSats, formatTimestamp, truncateMiddle, PAYMENT_TONES } from './format'; -import { Amount } from './Amount'; -import { EmptyWallet, UnsupportedSection } from './EmptyWallet'; -import { useSelectedWallet } from './useSelectedWallet'; -import { usePermissions, useChannels, usePayments, usePeers } from './useWalletData'; - -// The node's own view of itself: payments it has made, channels it holds, peers it is connected to. -// -// lndhub and nwc have no channels or peers of their own — they are accounts on someone else's node — so -// those blocks are absent from their capability set and never rendered. Nothing here shows a control that -// would come back 501. - -export const LightningView = () => { - const { walletId, isLoading } = useSelectedWallet(); - const { capabilities } = usePermissions(walletId); - const hasChannels = capabilities.includes('channels'); - const hasPeers = capabilities.includes('peers'); - const hasPayments = capabilities.includes('lightningSend'); - const anyLightning = hasChannels || hasPeers || hasPayments || capabilities.includes('lightningReceive'); - - const { channels } = useChannels(walletId, hasChannels); - const { peers } = usePeers(walletId, hasPeers); - const { payments } = usePayments(walletId, hasPayments, 25); - - if (!walletId) return ; - if (!anyLightning) return ; - - return ( -
    - {hasChannels && ( -
    - {channels.length === 0 ? ( - No channels open. - ) : ( -
      - {channels.map((channel) => ( - - ))} -
    - )} -
    - )} - - {hasPeers && ( -
    - {peers.length === 0 ? ( - Not connected to anyone. - ) : ( -
      - {peers.map((peer) => ( -
    • - - {peer.alias || truncateMiddle(peer.pubkey, 14, 8)} - {peer.address} - - {peer.inbound ? 'inbound' : 'outbound'} - -
    • - ))} -
    - )} -
    - )} - - {hasPayments && ( -
    - {payments.length === 0 ? ( - Nothing sent yet. - ) : ( -
      - {payments.map((payment) => ( -
    • -
      - - - {payment.memo || truncateMiddle(payment.destination ?? payment.paymentHash, 14, 8)} - - - {payment.status} - - {/* msat stays a string all the way to the DOM — see format.ts. */} - -
      -
      - {formatTimestamp(payment.createdAt)} · fee - {payment.failureReason && · {payment.failureReason}} -
      -
    • - ))} -
    - )} -
    - )} -
    - ); -}; - -const ChannelRow = ({ channel }: { channel: Channel }) => { - const capacity = channel.capacitySats || 1; - const localPct = Math.max(0, Math.min(100, (channel.localBalanceSats / capacity) * 100)); - - return ( -
  • -
    - - - {channel.remoteAlias || truncateMiddle(channel.remotePubkey, 14, 8)} - - {channel.private && private} - -
    - - {/* Local/remote split — the number that decides whether you can send or only receive. */} -
    -
    -
    -
    - out {formatSats(channel.localBalanceSats)} - in {formatSats(channel.remoteBalanceSats)} -
    -
  • - ); -}; - -type SectionProps = { title: string; count: number; children: React.ReactNode }; - -const Section = ({ title, count, children }: SectionProps) => ( -
    -

    - {title} - {count > 0 && ({count})} -

    - {children} -
    -); - -const Blank = ({ children }: { children: React.ReactNode }) => ( -

    {children}

    -); diff --git a/src/workspaces/officerdev/src/apps/Wallet/LockBadge.tsx b/src/workspaces/officerdev/src/apps/Wallet/LockBadge.tsx deleted file mode 100644 index 25f69c80..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/LockBadge.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState } from 'react'; -import { Lock, LockOpen, ShieldCheck } from 'lucide-react'; -import { formatCountdown } from './format'; -import { useLockCountdown } from './useLockCountdown'; -import { useLockActions, useWalletConfig } from './useWalletData'; -import { UnlockDialog } from './dialogs/UnlockDialog'; - -// The lock indicator. Locked is the resting state and reads as reassurance, not as an error — a watch-only -// wallet with no key material in memory is the whole point of the design, so it is never styled as a -// warning and never blocks the screen behind it. -// -// The countdown ticks locally (useLockCountdown) and flips to locked at zero without waiting for the poll. - -type LockBadgeProps = { - walletId: number | null; - walletName: string; - /** Compact drops the action button — for the panel header, where there is no room. */ - compact?: boolean; -}; - -export const LockBadge = ({ walletId, walletName, compact = false }: LockBadgeProps) => { - const { hasSeed, unlocked, secondsRemaining, isLoading } = useLockCountdown(walletId); - const { config } = useWalletConfig(); - const { lock } = useLockActions(walletId); - const [unlockOpen, setUnlockOpen] = useState(false); - - // A wallet with no seed — a remote node, a custodial account — has nothing to lock. Showing it a - // padlock would imply a protection it does not have. - if (walletId == null || hasSeed === false) { - if (isLoading || hasSeed == null) return null; - return ( - - - no seed held - - ); - } - - if (hasSeed == null) return null; - - return ( - <> -
    - - {unlocked ? : } - {unlocked ? formatCountdown(secondsRemaining) : 'locked'} - - - {!compact && - (unlocked ? ( - - ) : ( - - ))} -
    - - {unlockOpen && ( - - )} - - ); -}; - -/** - * The inline "you need to unlock for this" prompt, used by the signing surfaces only. Renders its children - * disabled-in-place rather than replacing the screen, so you can still read the form you are about to - * submit while the wallet is locked. - */ -type UnlockPromptProps = { walletId: number; walletName: string; className?: string }; - -export const UnlockPrompt = ({ walletId, walletName, className = '' }: UnlockPromptProps) => { - const { config } = useWalletConfig(); - const [open, setOpen] = useState(false); - - return ( - <> -
    - - - This wallet is locked. Signing needs the passphrase — everything else on this screen already works. - - -
    - - {open && ( - - )} - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx b/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx deleted file mode 100644 index 4c753987..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import type { ReactNode } from 'react'; -import type { Balances } from './shared'; -import { ArrowDownLeft, ArrowUpRight, Bitcoin, Clock, Loader2, Zap } from 'lucide-react'; -import { Link } from 'react-router'; -import { KIND_LABELS, walletSectionPath } from './shared'; -import { formatSats, formatTimestamp, truncateMiddle } from './format'; -import { Amount, UnitToggle } from './Amount'; -import { EmptyWallet } from './EmptyWallet'; -import { SyncBadge } from './SyncBadge'; -import { useSelectedWallet } from './useSelectedWallet'; -import { useBalances, usePermissions, useTransactions, useWalletInfo } from './useWalletData'; - -// The at-a-glance section: what you hold, whether the node agrees with the chain, and the last few moves. -// -// Every number here is a read, so the whole screen works against a locked wallet — which is its resting -// state. Nothing on this page prompts for a passphrase. - -/** - * The sub-line under the on-chain figure. Unconfirmed and frozen are both reasons the headline number is - * not what a send can use, and both can be true at once, so they read as a list rather than a choice. - */ -function onchainHint(balances: Balances | null | undefined): string | undefined { - if (!balances) return undefined; - const parts: string[] = []; - if (balances.onchainUnconfirmed !== 0) { - const sign = balances.onchainUnconfirmed > 0 ? '+' : ''; - parts.push(`${sign}${formatSats(balances.onchainUnconfirmed)} sats unconfirmed`); - } - if (balances.onchainFrozen > 0) parts.push(`${formatSats(balances.onchainFrozen)} sats frozen`); - return parts.length ? parts.join(' · ') : undefined; -} - -export const OverviewView = () => { - const { wallet, walletId, isLoading } = useSelectedWallet(); - const { capabilities } = usePermissions(walletId); - const { balances, sync, isLoading: balancesLoading } = useBalances(walletId); - const { info } = useWalletInfo(walletId); - const { transactions } = useTransactions(walletId, 5); - - if (!walletId) return ; - - const hasLightning = balances?.lightningBalance != null; - const canSend = capabilities.includes('onchainSend') || capabilities.includes('lightningSend'); - const canReceive = capabilities.includes('onchainReceive') || capabilities.includes('lightningReceive'); - - return ( -
    -
    -
    -

    {wallet?.name}

    -

    - {wallet ? KIND_LABELS[wallet.kind] : '—'} - {info?.alias && ` · ${info.alias}`} - {info?.version && ` · ${info.version}`} -

    -
    -
    - - -
    -
    - - {balancesLoading && !balances ? ( -
    - - Reading balances… -
    - ) : ( -
    - } - label="On-chain" - value={} - hint={onchainHint(balances)} - /> - {hasLightning && ( - } - label="Lightning" - value={} - hint={ - balances?.lightningInbound != null ? `${formatSats(balances.lightningInbound)} sats inbound` : undefined - } - /> - )} - {info?.blockHeight?.toLocaleString() ?? '—'} - } - hint={info ? (info.synced ? 'synced' : 'syncing…') : undefined} - /> - {info?.network ?? wallet?.network ?? '—'}} - hint={info?.pubkey ? truncateMiddle(info.pubkey, 8, 6) : undefined} - /> -
    - )} - - {(canReceive || canSend) && ( -
    - {canReceive && ( - - - Receive - - )} - {canSend && ( - - - Send - - )} -
    - )} - -
    -
    -

    Recent activity

    - - all transactions - -
    - {transactions.length === 0 ? ( -

    Nothing yet.

    - ) : ( -
      - {transactions.map((tx) => ( -
    • - - {tx.amount < 0 ? ( - - ) : ( - - )} - -
      -
      {tx.label ?? truncateMiddle(tx.txid)}
      -
      - {tx.confirmations <= 0 && } - {tx.confirmations <= 0 ? 'pending' : formatTimestamp(tx.timestamp)} -
      -
      - -
    • - ))} -
    - )} -
    -
    - ); -}; - -type TileProps = { label: string; value: ReactNode; hint?: string; icon?: ReactNode }; - -const Tile = ({ label, value, hint, icon }: TileProps) => ( -
    -
    - {icon} - {label} -
    -
    {value}
    - {hint && ( -
    - {hint} -
    - )} -
    -); diff --git a/src/workspaces/officerdev/src/apps/Wallet/ReceiveView.tsx b/src/workspaces/officerdev/src/apps/Wallet/ReceiveView.tsx deleted file mode 100644 index 7edaa579..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/ReceiveView.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useState } from 'react'; -import { Loader2, Plus, RefreshCw, TriangleAlert, Zap } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { formatTimestamp, truncateMiddle } from './format'; -import { INVOICE_TONES } from './format'; -import { Amount } from './Amount'; -import { CopyField } from './CopyField'; -import { EmptyWallet, UnsupportedSection } from './EmptyWallet'; -import { useSelectedWallet } from './useSelectedWallet'; -import { errorMessage, usePermissions, useInvoices, useReceiveAddress } from './useWalletData'; -import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog'; - -// Receiving. Works while locked — deriving an address needs the account xpub, not the seed, which is why -// this whole screen is usable in the wallet's resting state. -// -// The address is fetched with `?peek=true`: it returns the current unused address without advancing the -// derivation index. Rendering must never burn an address, so advancing is an explicit button. - -export const ReceiveView = () => { - const { walletId, isLoading } = useSelectedWallet(); - const { capabilities } = usePermissions(walletId); - const canOnchain = capabilities.includes('onchainReceive'); - const canLightning = capabilities.includes('lightningReceive'); - - const { - address, - addressType, - isLoading: addressLoading, - error: addressError, - next, - } = useReceiveAddress(walletId, canOnchain); - const { invoices } = useInvoices(walletId, canLightning, 10); - const [invoiceOpen, setInvoiceOpen] = useState(false); - - if (!walletId) return ; - if (!canOnchain && !canLightning) return ; - - return ( -
    - {canOnchain && ( -
    -
    -

    - On-chain address -

    - -
    - - {addressLoading && !address ? ( -
    - - Deriving an address… -
    - ) : address ? ( - <> - -

    - {addressType ? `${addressType} · ` : ''}Reuse costs you privacy, not money — take a fresh one for each - payer. -

    - - ) : addressError ? ( - // Deriving an address needs the chain source, so this fails whenever the Esplora endpoint is - // down or rate-limiting. Showing "no address available" for that sent the last debugging - // session looking at derivation code when the answer was a 429 in the response body. -
    - -
    -

    {errorMessage(addressError, 'Could not derive an address')}

    -

    - Addresses are derived locally, but the wallet checks the chain to find an unused one. Check{' '} - Settings → Chain source. -

    -
    -
    - ) : ( -

    No address available.

    - )} -
    - )} - - {canLightning && ( -
    -
    -

    - Lightning invoices -

    - -
    - - {invoices.length === 0 ? ( -

    No invoices yet.

    - ) : ( -
      - {invoices.map((inv) => ( -
    • -
      - - - {inv.memo || truncateMiddle(inv.paymentHash, 12, 8)} - - - {inv.state} - - {/* msat stays a string all the way to the DOM — see format.ts. */} - -
      -
      - created {formatTimestamp(inv.createdAt)} - {inv.settledAt ? ` · settled ${formatTimestamp(inv.settledAt)}` : ''} -
      - {inv.state === 'open' && } -
    • - ))} -
    - )} -
    - )} - - {invoiceOpen && } -
    - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx b/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx deleted file mode 100644 index 0bfe01a0..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useState } from 'react'; -import { CheckCircle2, Loader2, Search, TriangleAlert } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { ADDRESS_TYPE_LABELS } from './shared'; -import { useBalances, useWalletOperations } from './useWalletData'; - -// Search the chain for coins this wallet already owns. -// -// THIS IS THE FIX FOR AN IMPORTED WALLET THAT READS ZERO, and that symptom is why the card exists at all. -// An indexing chain source (NBXplorer in front of your own node) only watches an account from the moment -// you register it, so a seed with a history arrives at a balance of nothing: every call succeeds, no error -// is raised, the coins are simply not in the index yet. A scan walks the node's current UTXO set directly -// and finds them regardless of when the account was registered or how far back the node is pruned. -// -// It recovers SPENDABLE COINS, not history. Transactions spent before the account was registered stay -// missing — that needs a full block rescan, which is a heavier thing this does not do. -// -// Hidden entirely when the chain source cannot rescan: Esplora asks about every address on every refresh, -// so it has nothing to catch up on, and offering a button that 501s would invent a problem. - -type RescanCardProps = { walletId: number }; - -export const RescanCard = ({ walletId }: RescanCardProps) => { - const { sync } = useBalances(walletId); - const { rescan } = useWalletOperations(walletId); - const [gapLimit, setGapLimit] = useState(''); - - // `sync` null is a node backend, which owns its own coins and has nothing to look for. `canRescan` - // false is Esplora. Note this cannot key off `sync.rescan` being null — that is equally what NBXplorer - // reports before its first scan, which is exactly when the card is most needed. - if (!sync?.canRescan) return null; - - const state = sync.rescan; - const running = state?.running === true; - const parsed = Number(gapLimit); - const depth = gapLimit.trim() && Number.isInteger(parsed) && parsed > 0 ? parsed : null; - - return ( -
    -

    - Find missing coins -

    -

    - Ask the node to search its whole UTXO set for this wallet’s coins. Run this after importing a seed that already - had funds — an indexing node only watches an account from the moment it is added, so an older balance shows as - zero until it has looked. It scans all four address types, one at a time, and takes a few minutes. -

    - -
    - - - {/* The one thing about a scan only the owner can know: how many addresses their old wallet handed - out and never had paid. 1000 covers anyone who clicked "new address" by hand; a wallet that - issued them in bulk needs more, and no amount of looking from here can tell which it was. */} - -
    - - {state && } -
    - ); -}; - -type StatusProps = { state: NonNullable['sync']>['rescan'] }; - -const Status = ({ state }: StatusProps) => { - if (!state) return null; - - if (state.running) { - const label = state.current ? ADDRESS_TYPE_LABELS[state.current] : 'queued'; - return ( -

    - Scanning {label} — {state.done} of {state.total} done - {state.found > 0 && `, ${state.found} coin${state.found === 1 ? '' : 's'} found so far`}. -

    - ); - } - - if (state.error) { - return ( -

    - - - Stopped after {state.done} of {state.total}: {state.error} - -

    - ); - } - - return ( -

    - - Last scan found {state.found} coin{state.found === 1 ? '' : 's'} across {state.total} address types. -

    - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/SendView.tsx b/src/workspaces/officerdev/src/apps/Wallet/SendView.tsx deleted file mode 100644 index 8229dcf0..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/SendView.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import type { FeeEstimates } from './shared'; -import { useState } from 'react'; -import { Link } from 'react-router'; -import { Bitcoin, Coins, Loader2, Zap } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { walletSectionPath } from './shared'; -import { formatSats } from './format'; -import { Amount } from './Amount'; -import { CopyField } from './CopyField'; -import { EmptyWallet, UnsupportedSection } from './EmptyWallet'; -import { UnlockPrompt } from './LockBadge'; -import { useSelectedWallet } from './useSelectedWallet'; -import { useCoinSelection } from './useCoinSelection'; -import { useLockCountdown } from './useLockCountdown'; -import { useBalances, usePermissions, useFees, useUtxos, useWalletOperations } from './useWalletData'; -import { PayInvoiceDialog } from './dialogs/PayInvoiceDialog'; - -// Sending — the only part of the app that genuinely needs an unlocked wallet. -// -// The form is fully usable while locked: you can compose the whole spend, see the fee, review the coins. -// Only the submit is blocked, with an inline unlock prompt above it rather than a modal wall across the -// screen. That ordering is deliberate — you should be able to read what you are about to sign before you -// are asked for the passphrase. -// -// The selected coins come from `?coins=` (useCoinSelection), so the Coins section and this one agree -// through the URL rather than a shared store. - -const FEE_PRESETS: { key: keyof FeeEstimates; label: string; hint: string }[] = [ - { key: 'fastestFee', label: 'Fastest', hint: 'next block' }, - { key: 'halfHourFee', label: 'Fast', hint: '~30 min' }, - { key: 'hourFee', label: 'Normal', hint: '~1 hour' }, - { key: 'economyFee', label: 'Economy', hint: 'hours' }, - { key: 'minimumFee', label: 'Minimum', hint: 'whenever' }, -]; - -export const SendView = () => { - const { wallet, walletId, isLoading } = useSelectedWallet(); - const { capabilities } = usePermissions(walletId); - const canOnchain = capabilities.includes('onchainSend'); - const canLightning = capabilities.includes('lightningSend'); - - if (!walletId) return ; - if (!canOnchain && !canLightning) return ; - - return ( -
    - - {canOnchain && canLightning && ( - - - - On-chain - - - - Lightning - - - )} - - {canOnchain && ( - - - - )} - {canLightning && ( - - - - )} - -
    - ); -}; - -type FormProps = { walletId: number; walletName: string }; - -const OnchainSendForm = ({ walletId, walletName }: FormProps) => { - const { balances } = useBalances(walletId); - const { fees } = useFees(walletId); - const { capabilities } = usePermissions(walletId); - const { selected, clear } = useCoinSelection(); - const { utxos } = useUtxos(walletId, capabilities.includes('coinControl')); - const { send } = useWalletOperations(walletId); - const { hasSeed, unlocked } = useLockCountdown(walletId); - - const [address, setAddress] = useState(''); - const [amount, setAmount] = useState(''); - const [sendAll, setSendAll] = useState(false); - const [satPerVbyte, setSatPerVbyte] = useState(''); - const [label, setLabel] = useState(''); - const [broadcast, setBroadcast] = useState<{ txid: string; feeSats: number } | null>(null); - - // A seeded wallet must be unlocked to sign. A wallet with no seed of its own (a remote node) signs - // upstream, so there is nothing to unlock and the form is always live. - const needsUnlock = hasSeed === true && !unlocked; - const selectedUtxos = utxos.filter((u) => selected.includes(`${u.txid}:${u.vout}`)); - const selectedTotal = selectedUtxos.reduce((sum, u) => sum + u.amountSats, 0); - - const feeRate = Number(satPerVbyte); - const amountSats = Number(amount); - const canSubmit = - !!address.trim() && - (sendAll || (Number.isFinite(amountSats) && amountSats > 0)) && - Number.isFinite(feeRate) && - feeRate >= 1 && - !needsUnlock && - !send.isPending; - - const submit = async (ev: React.FormEvent) => { - ev.preventDefault(); - if (!canSubmit) return; - const result = await send.mutateAsync({ - address: address.trim(), - amountSats: sendAll ? undefined : amountSats, - sendAll: sendAll || undefined, - satPerVbyte: feeRate, - outpoints: selected.length > 0 ? selected : undefined, - label: label.trim() || undefined, - }); - setBroadcast({ txid: result.txid, feeSats: result.feeSats }); - setAddress(''); - setAmount(''); - setLabel(''); - setSendAll(false); - clear(); - }; - - if (broadcast) { - return ( -
    -

    Broadcast

    -

    - Paid a fee of {formatSats(broadcast.feeSats)} sats. It will confirm when a miner includes it. -

    - - -
    - ); - } - - return ( -
    -
    -
    -

    Spendable

    - {/* Frozen coins are confirmed and are the wallet's, but coin selection will not touch them. This - header said "Spendable" over a figure that included them, so a max-value send was rejected as - having no spendable UTXOs while the number above it said otherwise. */} - -
    - {!!balances?.onchainFrozen && ( -

    - {formatSats(balances.onchainFrozen)} sats frozen and excluded -

    - )} -
    - -
    - - setAddress(ev.target.value)} - className="font-mono text-xs" - /> -
    - -
    - -
    - setAmount(ev.target.value)} - className="w-48" - /> - -
    -
    - -
    - -
    - {fees && - FEE_PRESETS.map(({ key, label: presetLabel, hint }) => ( - - ))} -
    - setSatPerVbyte(ev.target.value)} - className="w-32" - /> -
    - - {capabilities.includes('coinControl') && ( -
    -
    - - {selected.length === 0 ? ( - - Automatic coin selection.{' '} - - Pick coins - - - ) : ( - <> - - {selected.length} coin{selected.length === 1 ? '' : 's'} selected · - - - - )} -
    -
    - )} - -
    - - setLabel(ev.target.value)} - /> -
    - - {needsUnlock && } - - - - ); -}; - -const LightningSendPanel = ({ walletId, walletName }: FormProps) => { - const { balances } = useBalances(walletId); - const { hasSeed, unlocked } = useLockCountdown(walletId); - const [payOpen, setPayOpen] = useState(false); - const needsUnlock = hasSeed === true && !unlocked; - - return ( -
    -
    -
    -

    Lightning balance

    - -
    -
    - - {needsUnlock && } - - - - {payOpen && } -
    - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx b/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx deleted file mode 100644 index 20459efb..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { SyncState } from './shared'; -import { useEffect, useState } from 'react'; -import { AlertTriangle, Check, RefreshCw } from 'lucide-react'; - -// How old the numbers on screen are. -// -// This exists because "empty wallet" and "we could not reach the chain" used to render identically — as a -// zero balance — and the second one is alarming while the first is not. The sidecar now serves the last -// snapshot it managed to read and refreshes behind the response, so the number is almost always real; what -// the owner needs is its age, and a plain statement when the upstream is failing. -// -// Nothing here is a control. Refreshing is the data layer's job and it is already happening. - -/** Re-render on a timer so "2 minutes ago" does not sit frozen on an idle screen. */ -const TICK_MS = 15_000; - -function relativeAge(syncedAt: number, now: number): string { - const seconds = Math.max(0, Math.round((now - syncedAt) / 1000)); - if (seconds < 45) return 'just now'; - const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.round(hours / 24)}d ago`; -} - -type SyncBadgeProps = { sync: SyncState | null; className?: string }; - -export const SyncBadge = ({ sync, className }: SyncBadgeProps) => { - const [now, setNow] = useState(() => Date.now()); - - useEffect(() => { - const timer = setInterval(() => setNow(Date.now()), TICK_MS); - return () => clearInterval(timer); - }, []); - - // Null from a node backend, which reads live and has no age to report. Rendering "just now" for it - // would be inventing a fact. - if (!sync) return null; - - // A rescan outranks everything else here, including a stale age and an upstream error. While the node - // is searching, the number beside this badge is not "your balance" — it is "your balance so far", and - // that difference is the entire reason an imported wallet showing zero is alarming rather than boring. - const { rescan } = sync; - if (rescan?.running) { - return ( - - - Scanning the chain · {Math.min(rescan.done + 1, rescan.total)} of {rescan.total} - - ); - } - - const failing = sync.lastError !== null; - - // Never read successfully AND failing: the only case where the numbers beside this are not real. Say so - // loudly, because a zero here means "unknown", not "empty". - if (sync.syncedAt === null) { - if (!failing) return null; - return ( - - - Could not reach the chain - - ); - } - - const age = relativeAge(sync.syncedAt, now); - - if (failing) { - return ( - - - {age} · chain unreachable - - ); - } - - return ( - - {sync.stale ? : } - {sync.stale ? `${age} · refreshing` : age} - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx deleted file mode 100644 index 25a0358c..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import type { OnchainTx } from './shared'; -import { ArrowDownLeft, ArrowUpRight, Loader2 } from 'lucide-react'; -import { formatConfirmations, formatSats, formatTimestamp, truncateMiddle } from './format'; -import { Amount } from './Amount'; -import { EmptyWallet } from './EmptyWallet'; -import { SyncBadge } from './SyncBadge'; -import { useSelectedWallet } from './useSelectedWallet'; -import { useTransactions } from './useWalletData'; - -// On-chain history. Works while locked — the transaction list comes from the account xpub, not the seed. - -export const TransactionsView = () => { - const { walletId, isLoading } = useSelectedWallet(); - const { transactions, sync, isLoading: txLoading } = useTransactions(walletId); - - if (!walletId) return ; - - if (txLoading && transactions.length === 0) { - return ( -
    - - Reading the chain… -
    - ); - } - - if (transactions.length === 0) { - // An empty list means "no transactions" only if we actually managed to look. When the chain has never - // been read, the same screen would otherwise assert a fact nobody established. - const neverRead = sync !== null && sync.syncedAt === null; - return ( -
    -

    {neverRead ? 'History unavailable' : 'Nothing here yet'}

    -

    - {neverRead - ? (sync.lastError ?? 'The chain source could not be reached.') - : 'Transactions appear as soon as they hit the mempool.'} -

    -
    - ); - } - - return ( -
    -
    - -
    -
    -
      - {transactions.map((tx) => ( - - ))} -
    -
    -
    - ); -}; - -const TransactionRow = ({ tx }: { tx: OnchainTx }) => { - const incoming = tx.amount >= 0; - const pending = tx.confirmations <= 0; - - return ( -
  • - - {incoming ? : } - - -
    -
    - {tx.label || truncateMiddle(tx.destAddresses[0] ?? tx.txid, 14, 10)} -
    -
    - {formatTimestamp(tx.timestamp)} - {formatConfirmations(tx.confirmations)} - {tx.feeSats != null && tx.feeSats > 0 && fee {formatSats(tx.feeSats)}} -
    -
    - -
    - -
    {truncateMiddle(tx.txid, 8, 6)}
    -
    -
  • - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletNav.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletNav.tsx deleted file mode 100644 index bc66d813..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/WalletNav.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import type { LucideIcon } from 'lucide-react'; -import type { WalletSummary } from './shared'; -import { useState } from 'react'; -import { Link, NavLink } from 'react-router'; -import { ArrowDownLeft, ArrowUpRight, Bitcoin, Coins, Gauge, Plus, Receipt, Settings, Star, Zap } from 'lucide-react'; -import { - KIND_LABELS, - NETWORK_TONES, - WALLET_SECTIONS, - sectionAvailable, - walletSectionPath, - type WalletSectionId, -} from './shared'; -import { Amount, UnitToggle } from './Amount'; -import { LockBadge } from './LockBadge'; -import { useWalletSection } from './useWalletSection'; -import { useSelectedWallet } from './useSelectedWallet'; -import { useBalances, usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData'; -import { CreateWalletDialog } from './dialogs/CreateWalletDialog'; - -// Left panel of /wallet: the balance, the wallets, the sections, the lock state. -// -// Wallet rows are real links carrying `?wallet=` — cmd-click, back button and reload all work, and the -// id is in the DOM rather than an onClick closure (docs/navigation-audit.md). "Make active" is a sibling -// button, not nested inside the anchor, because it mutates rather than navigates. -// -// Sections are filtered by the backend's declared capabilities: an on-chain wallet has no Lightning tab at -// all, an lndhub account has no coin control. Better a shorter nav than a control that answers 501. - -const ICONS: Record = { - overview: Gauge, - receive: ArrowDownLeft, - send: ArrowUpRight, - transactions: Receipt, - coins: Coins, - lightning: Zap, - settings: Settings, -}; - -const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors'; - -export const WalletNav = () => { - const section = useWalletSection(); - const { config } = useWalletConfig(); - const { wallet, wallets, walletId, isPinned, isLoading } = useSelectedWallet(); - const { capabilities } = usePermissions(walletId); - const { balances } = useBalances(walletId); - const [createOpen, setCreateOpen] = useState(false); - - // Preserve an explicitly pinned wallet across section changes; leave a bare URL bare so it keeps - // meaning "whichever wallet is active". - const linkWalletId = isPinned ? walletId : null; - const total = balances ? balances.onchainConfirmed + (balances.lightningBalance ?? 0) : null; - const network = wallet?.network ?? config?.network ?? null; - - return ( -
    -
    -
    - -
    -
    -
    - Wallet - {network && network !== 'bitcoin' && ( - - {network} - - )} -
    -
    - - -
    -
    -
    - -
    - Wallets - -
    - -
    - {isLoading && wallets.length === 0 && ( -
    Loading wallets…
    - )} - {!isLoading && wallets.length === 0 && ( -
    - No wallets yet. Add one to get started — a self-custodial seed, or a connection to your node. -
    - )} - {wallets.map((w) => ( - - ))} -
    - - - -
    - - {config && {config.network}} -
    - - {createOpen && } -
    - ); -}; - -type WalletRowProps = { wallet: WalletSummary; section: WalletSectionId; selected: boolean }; - -const WalletRow = ({ wallet, section, selected }: WalletRowProps) => { - const { activate } = useWalletLifecycle(); - - return ( -
    - - - {wallet.name} - - {KIND_LABELS[wallet.kind]} - - {/* Sibling of the anchor, never nested inside it — this mutates, it does not navigate. */} - {wallet.isActive ? ( - - ) : ( - - )} -
    - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx deleted file mode 100644 index add0802d..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import type { WalletSummary } from './shared'; -import { useEffect, useRef, useState } from 'react'; -import { useNavigate } from 'react-router'; -import { Check, Eye, KeyRound, Pencil, Star, Trash2, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { KIND_LABELS, walletSectionPath } from './shared'; -import { ChainSourceCard } from './ChainSourceCard'; -import { CopyField } from './CopyField'; -import { EmptyWallet } from './EmptyWallet'; -import { LockBadge } from './LockBadge'; -import { RescanCard } from './RescanCard'; -import { useSelectedWallet } from './useSelectedWallet'; -import { useLockCountdown } from './useLockCountdown'; -import { usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData'; -import { ChangePassphraseDialog } from './dialogs/ChangePassphraseDialog'; -import { ExportSeedDialog } from './dialogs/ExportSeedDialog'; -import { DeleteWalletDialog } from './dialogs/DeleteWalletDialog'; - -// Wallet-level administration: what this wallet is, what its backend can do, and the three passphrase -// operations. Nothing here needs the wallet unlocked to *read* — each destructive action asks for the -// passphrase itself, because an open unlock window is not consent to change or destroy the key material. - -export const WalletSettingsView = () => { - const navigate = useNavigate(); - const { wallet, walletId, isLoading } = useSelectedWallet(); - const { config } = useWalletConfig(); - const { capabilities, kind } = usePermissions(walletId); - const { hasSeed } = useLockCountdown(walletId); - const { activate, rename } = useWalletLifecycle(); - - const [passphraseOpen, setPassphraseOpen] = useState(false); - const [exportOpen, setExportOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - - if (!wallet || walletId == null) return ; - - const xpubs = Object.entries(wallet.xpubs ?? {}); - - return ( -
    -
    -
    -
    - rename.mutateAsync({ walletId, name })} - isPending={rename.isPending} - /> -

    - {KIND_LABELS[wallet.kind]} · {wallet.network} · added {wallet.createdAt.slice(0, 10)} -

    -
    - -
    - -
    - {kind ?? wallet.kind} - BIP{wallet.defaultBip} - {wallet.fingerprint && ( - - {wallet.fingerprint} - - )} - {wallet.isActive ? 'yes' : 'no'} - {hasSeed === true ? 'yes' : hasSeed === false ? 'no' : '—'} -
    - - {!wallet.isActive && ( - - )} -
    - - {xpubs.length > 0 && ( -
    -

    Account keys

    -

    - Extended public keys. They can watch this wallet but never spend it — safe to hand to a block explorer or an - accounting tool. -

    -
    - {xpubs.map(([path, xpub]) => ( - - ))} -
    -
    - )} - -
    -

    - What this backend can do -

    - {capabilities.length === 0 ? ( -

    No capabilities reported.

    - ) : ( -
    - {capabilities.map((cap) => ( - - {cap} - - ))} -
    - )} -
    - - {hasSeed === true && ( -
    -

    Key material

    -

    - Both of these ask for the passphrase on their own, whether or not the wallet is currently unlocked. -

    -
    - - -
    -
    - )} - - - - - - {config && ( -
    -

    Deployment

    -
    - {config.network} - {formatMinutes(config.unlockTtlSec)} - - {config.storeKeyConfigured ? 'configured' : missing} - -
    -
    - )} - -
    -

    Danger

    -

    - {wallet.hasSeed - ? 'Deleting erases the encrypted seed. Only your written recovery phrase can bring the coins back.' - : 'Deleting removes the connection. The node itself is untouched.'} -

    - -
    - - {passphraseOpen && ( - - )} - {exportOpen && } - {deleteOpen && ( - navigate(walletSectionPath('overview'), { replace: true })} - /> - )} -
    - ); -}; - -/** The wallet's name is the one field here that is purely a label — editing it in place, not in a dialog. */ -const MAX_NAME = 64; - -type WalletNameFieldProps = { - wallet: WalletSummary; - onSave: (name: string) => Promise; - isPending: boolean; -}; - -const WalletNameField = ({ wallet, onSave, isPending }: WalletNameFieldProps) => { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(wallet.name); - const inputRef = useRef(null); - - // Switching wallets while the editor is open would otherwise rename the NEW wallet to the old one's draft. - useEffect(() => { - setEditing(false); - setDraft(wallet.name); - }, [wallet.id, wallet.name]); - - useEffect(() => { - if (editing) inputRef.current?.select(); - }, [editing]); - - const trimmed = draft.trim(); - const dirty = trimmed !== wallet.name; - - const commit = async () => { - if (!trimmed || !dirty) { - setDraft(wallet.name); - setEditing(false); - return; - } - try { - await onSave(trimmed); - setEditing(false); - } catch { - // The mutation already toasted; keep the editor open so the typed name isn't lost. - } - }; - - const cancel = () => { - setDraft(wallet.name); - setEditing(false); - }; - - if (!editing) { - return ( -
    -

    {wallet.name}

    - -
    - ); - } - - return ( -
    - setDraft(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Enter') void commit(); - if (ev.key === 'Escape') cancel(); - }} - className="h-7 max-w-56 text-sm font-semibold" - aria-label="Wallet name" - autoComplete="off" - spellCheck={false} - /> - - -
    - ); -}; - -const formatMinutes = (seconds: number) => (seconds >= 60 ? `${Math.round(seconds / 60)} min` : `${seconds}s`); - -const Row = ({ label, children }: { label: string; children: React.ReactNode }) => ( -
    -
    {label}
    -
    {children}
    -
    -); diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletView.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletView.tsx deleted file mode 100644 index acf37fcd..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/WalletView.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useWalletSection } from './useWalletSection'; -import { OverviewView } from './OverviewView'; -import { ReceiveView } from './ReceiveView'; -import { SendView } from './SendView'; -import { TransactionsView } from './TransactionsView'; -import { CoinsView } from './CoinsView'; -import { LightningView } from './LightningView'; -import { WalletSettingsView } from './WalletSettingsView'; - -// Right panel of the /wallet workspace — renders the section named by the URL. -// -// Each section decides for itself whether the backend supports it, so a deep link to a section this wallet -// cannot serve explains itself rather than 404ing. The nav simply hides those entries. - -export const WalletView = () => { - const section = useWalletSection(); - - switch (section) { - case 'receive': - return ; - case 'send': - return ; - case 'transactions': - return ; - case 'coins': - return ; - case 'lightning': - return ; - case 'settings': - return ; - default: - return ; - } -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletViewHeader.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletViewHeader.tsx deleted file mode 100644 index 982e29f8..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/WalletViewHeader.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Bitcoin } from 'lucide-react'; -import { WALLET_SECTIONS } from './shared'; -import { Amount, UnitToggle } from './Amount'; -import { LockBadge } from './LockBadge'; -import { useWalletSection } from './useWalletSection'; -import { useSelectedWallet } from './useSelectedWallet'; -import { useBalances } from './useWalletData'; - -// Panel header for the right (wallet-view) panel: which section, which wallet, the spendable balance and -// the lock state. The lock indicator is compact here — it reports, it does not act, because the actions -// live in the nav and on the signing surfaces themselves. - -export const WalletViewHeader = () => { - const section = useWalletSection(); - const { wallet, walletId } = useSelectedWallet(); - const { balances } = useBalances(walletId); - const label = WALLET_SECTIONS.find((s) => s.id === section)?.label ?? 'Wallet'; - - return ( - <> - - - {label} - {wallet && · {wallet.name}} - - {balances && } - - - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/dialogs/ChangePassphraseDialog.tsx b/src/workspaces/officerdev/src/apps/Wallet/dialogs/ChangePassphraseDialog.tsx deleted file mode 100644 index 1f0b1ddf..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/dialogs/ChangePassphraseDialog.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useState } from 'react'; -import { Loader2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useLockActions } from '../useWalletData'; - -// Re-encrypt the seed under a new passphrase. The sidecar decrypts with the old one and re-seals with the -// new one, then re-locks — there is no window where the wallet is left open as a side effect. -// -// Both passphrases live only in this component's state and are cleared as soon as the request settles. - -type ChangePassphraseDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number }; - -export const ChangePassphraseDialog = ({ open, onOpenChange, walletId }: ChangePassphraseDialogProps) => { - const { changePassphrase } = useLockActions(walletId); - // Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL. - const [oldPassphrase, setOldPassphrase] = useState(''); - const [newPassphrase, setNewPassphrase] = useState(''); - const [confirm, setConfirm] = useState(''); - - const clearSecrets = () => { - setOldPassphrase(''); - setNewPassphrase(''); - setConfirm(''); - }; - - const close = () => { - clearSecrets(); - onOpenChange(false); - }; - - const mismatch = confirm.length > 0 && confirm !== newPassphrase; - const canSubmit = - !!oldPassphrase && newPassphrase.length >= 8 && confirm === newPassphrase && !changePassphrase.isPending; - - const submit = async (ev: React.FormEvent) => { - ev.preventDefault(); - if (!canSubmit) return; - try { - await changePassphrase.mutateAsync({ oldPassphrase, newPassphrase }); - close(); - } finally { - // Cleared on failure too — a rejected passphrase is still a passphrase held in memory. - clearSecrets(); - } - }; - - return ( - (next ? onOpenChange(true) : close())}> - -
    - - Change passphrase - - The seed is re-sealed under the new passphrase and the wallet is locked again. Lose it and the coins go - with it — there is no reset. - - - -
    -
    - - {/* Controlled input → request body → cleared. Never written to any store. */} - setOldPassphrase(ev.target.value)} - /> -
    -
    - - {/* Controlled input → request body → cleared. Never written to any store. */} - setNewPassphrase(ev.target.value)} - /> -

    At least 8 characters.

    -
    -
    - - {/* Controlled input → request body → cleared. Never written to any store. */} - setConfirm(ev.target.value)} - /> - {mismatch &&

    These do not match.

    } -
    -
    - - - - - -
    -
    -
    - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateInvoiceDialog.tsx b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateInvoiceDialog.tsx deleted file mode 100644 index e3fd1ffa..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateInvoiceDialog.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useState } from 'react'; -import { Loader2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { satsToMsat } from '../format'; -import { CopyField } from '../CopyField'; -import { useWalletOperations } from '../useWalletData'; - -// Create a BOLT11 invoice. Receiving needs no key material, so this works while the wallet is locked. -// -// The amount is entered in sats and converted with satsToMsat, which appends three zeros to the digit -// string rather than multiplying — the request field is a msat STRING and must stay one. - -type CreateInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number }; - -export const CreateInvoiceDialog = ({ open, onOpenChange, walletId }: CreateInvoiceDialogProps) => { - const { createInvoice } = useWalletOperations(walletId); - const [amountSats, setAmountSats] = useState(''); - const [memo, setMemo] = useState(''); - const [expiryMinutes, setExpiryMinutes] = useState('60'); - const [created, setCreated] = useState(null); - - const close = () => { - setAmountSats(''); - setMemo(''); - setExpiryMinutes('60'); - setCreated(null); - onOpenChange(false); - }; - - const submit = async (ev: React.FormEvent) => { - ev.preventDefault(); - const sats = Number(amountSats); - const result = await createInvoice.mutateAsync({ - // Omitted entirely for a zero-amount invoice, which is a legitimate "payer decides" request. - amountMsat: amountSats && Number.isFinite(sats) && sats > 0 ? satsToMsat(sats) : undefined, - memo: memo.trim() || undefined, - expirySeconds: Number(expiryMinutes) > 0 ? Number(expiryMinutes) * 60 : undefined, - }); - setCreated(result.invoice.bolt11); - }; - - return ( - (next ? onOpenChange(true) : close())}> - - {created ? ( - <> - - Invoice created - Send this to whoever is paying you. - - - - - - - ) : ( -
    - - New invoice - Leave the amount blank to let the payer choose. - - -
    -
    - - setAmountSats(ev.target.value)} - /> -
    -
    - - setMemo(ev.target.value)} - /> -
    -
    - - setExpiryMinutes(ev.target.value)} - className="w-32" - /> -
    -
    - - - - - -
    - )} -
    -
    - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx deleted file mode 100644 index 92d851bc..00000000 --- a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx +++ /dev/null @@ -1,559 +0,0 @@ -import type { BackendKind } from '../shared'; -import { useState } from 'react'; -import { useNavigate } from 'react-router'; -import { Loader2, TriangleAlert } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { KIND_LABELS, walletSectionPath, type WalletSectionId } from '../shared'; -import { errorMessage, useWalletConfig, useWalletLifecycle } from '../useWalletData'; -import { SeedBackupDialog } from './SeedBackupDialog'; -import { Textarea } from '@/components/ui/textarea'; -import { - SeedPhraseInput, - SEED_LENGTHS, - findOrderFix, - isAezeed, - normalizePhrase, - phraseProblem, - type SeedLength, -} from './SeedPhraseInput'; - -// Add a wallet. Two genuinely different shapes behind one form: -// -// onchain — self-custodial. A seed is generated (or imported) and sealed under a passphrase that only -// ever exists in this component's state and the request body. Generating returns the mnemonic -// exactly once, which is why this dialog hands straight over to SeedBackupDialog and does not -// close until the owner confirms they wrote it down. -// everything else — a connection to somebody's node or account, so it is a config blob, no seed at all. -// -// Wallet creation is refused outright when the wallet's at-rest key is unreachable. The form says so up front -// rather than letting the submit fail with a crypto error. - -type CreateWalletDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - /** Where to land after creating — keeps the new wallet's id in the URL. */ - section: WalletSectionId; -}; - -const KINDS: BackendKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc']; - -/** The connection fields each remote backend needs, spelled once. */ -const CONFIG_FIELDS: Record, { key: string; label: string; placeholder: string }[]> = { - lnd: [ - { key: 'url', label: 'REST URL', placeholder: 'https://node.local:8080' }, - { key: 'macaroon', label: 'Admin macaroon (hex)', placeholder: '0201036c6e64…' }, - ], - 'cln-rest': [ - { key: 'url', label: 'CLNRest URL', placeholder: 'https://node.local:3010' }, - { key: 'rune', label: 'Rune', placeholder: 'ZW5jcnlwdGVkOg…' }, - ], - lndhub: [ - { key: 'url', label: 'LNDHub URL', placeholder: 'https://lndhub.io' }, - { key: 'login', label: 'Login', placeholder: 'lndhub login' }, - { key: 'password', label: 'Password', placeholder: 'lndhub password' }, - ], - nwc: [{ key: 'connectionString', label: 'Connection string', placeholder: 'nostr+walletconnect://…' }], -}; - -export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWalletDialogProps) => { - const navigate = useNavigate(); - const { config } = useWalletConfig(); - const { create } = useWalletLifecycle(); - - const [name, setName] = useState(''); - const [kind, setKind] = useState('onchain'); - const [words, setWords] = useState<12 | 24>(24); - const [importing, setImporting] = useState(false); - // One entry per word rather than one string: the boxes ARE the state, so a wrong word is visible where - // it is rather than somewhere in a wall of text. Joined back into a phrase only at submit. - const [importLength, setImportLength] = useState(24); - const [importWords, setImportWords] = useState(() => Array(24).fill('')); - // A plain-text escape hatch alongside the boxes. Both write the same `importWords`, so validation and - // submit never care which was used — but when a phrase the owner knows is right will not validate, this - // is what separates "my per-box entry mangled it" from "the phrase really is different". - const [asText, setAsText] = useState(false); - const [importText, setImportText] = useState(''); - // Both of these are secrets in flight. They exist in this component's state, go into the request body, - // and are cleared the moment the request settles — never a store, never a query key, never the URL. - const [passphrase, setPassphrase] = useState(''); - const [confirmPassphrase, setConfirmPassphrase] = useState(''); - const [bip39Passphrase, setBip39Passphrase] = useState(''); - const [makeActive, setMakeActive] = useState(true); - const [remoteConfig, setRemoteConfig] = useState>({}); - - // The one mnemonic the sidecar will ever hand back. Held here only until the backup modal is confirmed. - const [pendingSeed, setPendingSeed] = useState<{ mnemonic: string; walletName: string; walletId: number } | null>( - null, - ); - - const storeKeyMissing = config != null && !config.storeKeyConfigured; - const isOnchain = kind === 'onchain'; - - /** Anything the owner would be upset to retype — guards the incidental-dismiss paths below. */ - const hasTypedSecret = - importWords.some((w) => w.trim()) || passphrase.length > 0 || Object.values(remoteConfig).some((v) => v?.trim()); - - /** Grow or shrink the grid, keeping whatever has already been typed. */ - const resizeImport = (n: SeedLength) => { - setImportLength(n); - setImportWords((prev) => Array.from({ length: n }, (_, i) => prev[i] ?? '')); - }; - - /** Text mode is the source of truth while it is open; the boxes mirror it word for word. */ - const applyText = (text: string) => { - setImportText(text); - const parts = normalizePhrase(text); - if ((SEED_LENGTHS as readonly number[]).includes(parts.length)) setImportLength(parts.length as SeedLength); - setImportWords(parts); - }; - - /** Switching modes must not silently drop or reorder anything the owner already entered. */ - const toggleTextMode = (next: boolean) => { - if (next) setImportText(importWords.filter(Boolean).join(' ')); - else setImportWords((prev) => Array.from({ length: importLength }, (_, i) => prev[i] ?? '')); - setAsText(next); - }; - - const clearSecrets = () => { - setPassphrase(''); - setConfirmPassphrase(''); - setBip39Passphrase(''); - // Functional, so it blanks whatever length the grid currently is without reading stale state. - setImportWords((prev) => prev.map(() => '')); - setImportText(''); - setRemoteConfig({}); - }; - - const reset = () => { - setName(''); - setKind('onchain'); - setWords(24); - setImporting(false); - setMakeActive(true); - clearSecrets(); - setAsText(false); - setImportLength(24); - setImportWords(Array(24).fill('')); - }; - - const close = () => { - reset(); - onOpenChange(false); - }; - - const passphraseValid = !isOnchain || (passphrase.length >= 8 && passphrase === confirmPassphrase); - const configValid = - isOnchain || CONFIG_FIELDS[kind as Exclude].every((f) => remoteConfig[f.key]?.trim()); - // Checked locally so a bad phrase never costs a round trip. The sidecar still validates independently; - // this only means the owner finds out instantly, and precisely, instead of via a generic 400. - const seedProblem = isOnchain && importing ? phraseProblem(importWords) : null; - // Only worth computing for the one failure a human review cannot catch: right words, wrong order. - const orderFix = seedProblem?.kind === 'checksum' ? findOrderFix(importWords) : null; - // A valid phrase can be either format, and the two are visually identical, so say which one this is. - // It also changes what the passphrase field below means, which the owner has no way to guess. - const aezeed = isOnchain && importing && !seedProblem && isAezeed(importWords.map((w) => w.trim().toLowerCase())); - const canSubmit = - !!name.trim() && passphraseValid && configValid && !seedProblem && !storeKeyMissing && !create.isPending; - - // A disabled button with no stated reason is a dead end — especially here, where the blocker is often - // the passphrase fields further down while the owner is staring at the phrase. - const blockReason = !name.trim() - ? 'Give the wallet a name.' - : seedProblem - ? 'The recovery phrase above is not complete or not valid.' - : !passphraseValid - ? 'Set a passphrase of at least 8 characters, twice.' - : !configValid - ? 'Fill in the connection details.' - : null; - - const submit = async (ev: React.FormEvent) => { - ev.preventDefault(); - if (!canSubmit) return; - - let result; - try { - result = await create.mutateAsync( - isOnchain - ? { - name: name.trim(), - kind, - passphrase, - words, - mnemonic: importing ? importWords.map((w) => w.trim()).join(' ') : undefined, - bip39Passphrase: bip39Passphrase || undefined, - makeActive, - } - : { name: name.trim(), kind, config: remoteConfig, makeActive }, - ); - } catch { - // KEEP EVERYTHING TYPED. A rejected phrase is precisely the moment the owner needs their other 23 - // words back — clearing here means retyping the whole thing to fix one box, which is both miserable - // and a good way to introduce a second mistake. `create.onError` already toasts, and the error is - // rendered inline below. Nothing lingers regardless: cancel or close still runs clearSecrets(). - return; - } - - const created = result.wallet; - if (result.mnemonic) { - // Generated seed: hold the words for the backup modal and keep this dialog mounted underneath. - setPendingSeed({ mnemonic: result.mnemonic, walletName: created.name, walletId: created.id }); - clearSecrets(); - } else { - navigate(walletSectionPath(section, created.id)); - close(); - } - }; - - const seedConfirmed = () => { - const created = pendingSeed; - setPendingSeed(null); - close(); - if (created) navigate(walletSectionPath(section, created.walletId)); - }; - - return ( - <> - (next ? onOpenChange(true) : close())}> - {/* Wider only while importing — 24 word boxes at max-w-lg are two cramped columns. */} - { - if (hasTypedSecret) ev.preventDefault(); - }} - onEscapeKeyDown={(ev) => { - if (hasTypedSecret) ev.preventDefault(); - }} - > -
    - - Add a wallet - - Self-custodial keys stay in the wallet sidecar; a remote node is only a stored connection. - - - - {storeKeyMissing && ( -
    - - - The secret store is unreachable. The sidecar refuses to store - wallet secrets without it, so creation is disabled until it is set. - -
    - )} - -
    -
    - - setName(ev.target.value)} - /> -
    - -
    - - - {config && ( -

    - Network: {config.network} -

    - )} -
    - - {isOnchain ? ( - <> -
    - setImporting(false)} label="Generate a new seed" /> - setImporting(true)} label="Import a phrase" /> -
    - - {importing ? ( -
    -
    - - toggleTextMode(!asText)} - label={asText ? 'Use word boxes' : 'Paste as text'} - /> -
    - - {!asText && ( -
    - {SEED_LENGTHS.map((n) => ( - resizeImport(n)} - label={`${n}`} - /> - ))} -
    - )} - - {/* Secret in flight: state → request body → cleared. Never stored on this side, and - never echoed back by the sidecar either. */} - {asText ? ( -