bitcoin-wallet, extracted into plugins/bitcoin-wallet — the platform half

Tenth extraction, and the first where the blocker was a permission rather than
a path. The wallet was kind: 'admin' — owner only, never grantable — and plugin
permissions are always kind: 'app'. mount.ts forces that and the manifest has
no field for anything else, deliberately, so a plugin cannot ask to be
restricted. Extraction therefore could not preserve admin.

Raised before moving any code, because turning "the server owner's bitcoin"
into a row in the grants list beside Photos and Music is the kind of change
nobody notices until someone ticks it.

Made anyway, and the objection inverts: every wallet query is scoped by
user_id, taken from X-Officer-User which the platform injects from the session,
so granting the permission gives an account its OWN wallet rather than access
to anyone else's — including to the owner, whose bypass covers permission
checks and not query scoping. kind: 'admin' was single-user thinking.

Named bitcoin-wallet, not wallet: appName drives route, API prefix, table
prefix and install key together, so the name should say what it is. Tables are
bitcoin_wallet_*; the prefix guard normalises the hyphen. Verified free before
committing to it — no wallet table exists on any server, and db:push would
treat a rename as a drop plus a create.

registry.test.ts re-anchored: `wallet` was its canonical kind: 'admin' fixture
and its no-selfService fixture. Now user-admin/server-admin and terminal, with
a note that a plugin's permissions are always `app` — which is exactly why this
one needed a decision rather than a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 05:18:44 +00:00
co-authored by Claude Opus 5
parent f2c6ebaa4d
commit bbb16d6ed3
81 changed files with 31 additions and 16386 deletions
+1 -2
View File
@@ -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
}
-2
View File
@@ -107,8 +107,6 @@ export function App() {
<Route path="/gitea/repo/:owner/:name" element={<Dashboard.GiteaScreen />} />
<Route path="/gitea/repo/:owner/:name/:tab" element={<Dashboard.GiteaScreen />} />
<Route path="/gitea/repo/:owner/:name/:tab/:item" element={<Dashboard.GiteaScreen />} />
<Route path="/wallet" element={<Dashboard.WalletScreen />} />
<Route path="/wallet/:section" element={<Dashboard.WalletScreen />} />
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
@@ -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<LayoutNode>('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 <Navigate to={`${walletSectionPath(DEFAULT_WALLET_SECTION)}${search}`} replace />;
}
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['wallet-nav', 'wallet-view'], fallback: 'wallet-view' }}
/>
</div>
);
};
@@ -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 },
],
};
@@ -1 +0,0 @@
export * from './WalletScreen';
@@ -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';
@@ -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' },
-1
View File
@@ -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
-1
View File
@@ -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 ───────────────────────────────────────────────────────────────────
//
@@ -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';
@@ -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<string, string> | 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<string, unknown> | 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<string, string> | 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<WalletSummary[]> {
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<WalletSummary | null> {
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<WalletSummary | null> {
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<WalletSecrets | null> {
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<string, unknown>) : 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<string | null> {
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<string, unknown> | null;
/** The already-sealed envelope as JSON; encrypted again here. */
sealedSeed?: string | null;
fingerprint?: string | null;
xpubs?: Record<string, string> | null;
defaultBip?: number;
makeActive?: boolean;
};
export async function createWallet(params: CreateWalletParams): Promise<WalletSummary> {
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<string, unknown>; defaultBip?: number },
): Promise<WalletSummary | null> {
const set: Record<string, unknown> = { 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<void> {
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<void> {
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<boolean> {
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<WalletLabel[]> {
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<void> {
// 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<string[]> {
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<void> {
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<WalletChainCache | null> {
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<void> {
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<void> {
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 } });
}
@@ -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<Record<string, string>>(),
// 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<WalletChainSnapshot>(),
/** 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 }),
});
-16
View File
@@ -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;
-11
View File
@@ -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',
-2
View File
@@ -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<typeof createRouter>
['/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],
+8 -4
View File
@@ -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);
}
-9
View File
@@ -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 ───────────────────────────────────────────────────────────────────────────────
+22
View File
@@ -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',
-380
View File
@@ -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);
}
@@ -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');
});
});
@@ -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<Buffer>;
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<string, number>(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<CipherSeed> {
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<CipherSeed> {
const bytes = mnemonicToCipherSeedBytes(words);
if (!bytes) throw new AezeedError('not a 24-word phrase from the BIP39 wordlist', 'BAD_LENGTH');
return decipherCipherSeed(bytes, passphrase);
}
-194
View File
@@ -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<Capability>;
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<NodeInfo>;
abstract getBalances(): Promise<Balances>;
getTransactions(_opts?: { limit?: number }): Promise<OnchainTx[]> {
return this.notSupported('on-chain transaction history');
}
getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
return this.notSupported('on-chain receive');
}
getUtxos(): Promise<Utxo[]> {
return this.notSupported('coin control');
}
estimateFees(): Promise<FeeEstimates> {
return this.notSupported('fee estimation');
}
sendCoins(_req: SendCoinsRequest): Promise<SendCoinsResult> {
return this.notSupported('on-chain send');
}
getInvoices(_opts?: { limit?: number }): Promise<Invoice[]> {
return this.notSupported('invoice listing');
}
createInvoice(_req: CreateInvoiceRequest): Promise<Invoice> {
return this.notSupported('lightning receive');
}
lookupInvoice(_paymentHash: string): Promise<Invoice | null> {
return this.notSupported('invoice lookup');
}
decodeInvoice(_bolt11: string): Promise<DecodedInvoice> {
return this.notSupported('invoice decoding');
}
getPayments(_opts?: { limit?: number }): Promise<Payment[]> {
return this.notSupported('payment history');
}
payInvoice(_req: PayInvoiceRequest): Promise<Payment> {
return this.notSupported('lightning send');
}
sendKeysend(_req: KeysendRequest): Promise<Payment> {
return this.notSupported('keysend');
}
getChannels(): Promise<Channel[]> {
return this.notSupported('channels');
}
getPeers(): Promise<Peer[]> {
return this.notSupported('peers');
}
signMessage(_message: string): Promise<SignMessageResult> {
return this.notSupported('message signing');
}
verifyMessage(_message: string, _signature: string): Promise<VerifyMessageResult> {
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<string, string>;
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
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<T = unknown>(opts: HttpOptions): Promise<T> {
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<string, string> = { 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');
}
File diff suppressed because it is too large Load Diff
-860
View File
@@ -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<string, LndFeature>;
};
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<string, string> = {
'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<AddressType, { fresh: string; peek: string } | null> = {
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<string, AddressType> = {
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<Capability>([
'onchainReceive',
'onchainSend',
'coinControl',
'psbt',
'bumpFee',
'sweep',
'accounts',
'lightningReceive',
'lightningSend',
'keysend',
'customPreimages',
'offers',
'channels',
'peers',
'routing',
'signMessage',
]);
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
constructor(private readonly config: LndConfig) {
super();
}
// ── transport ──────────────────────────────────────────────────────────────────────────────────
private get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<T> {
return httpJson<T>({
base: this.config.url,
path,
query,
method: 'GET',
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
allowSelfSigned: this.config.allowSelfSigned,
});
}
private post<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
return httpJson<T>({
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<string, unknown>, timeoutMs: number): Promise<LndPayment> {
const res = await httpJson<Response>({
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<NodeInfo> {
const info = await this.get<LndGetInfo>('/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<Balances> {
const [chain, channels] = await Promise.all([
this.get<LndBlockchainBalance>('/v1/balance/blockchain'),
this.get<LndChannelBalance>('/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<OnchainTx[]> {
// `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<Utxo[]> {
// 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<FeeEstimates> {
// 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<LndEstimateFee>(`/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<SendCoinsResult> {
if (req.outpoints?.length && !this.supports('coinControl')) this.notSupported('coin control');
const body: Record<string, unknown> = {
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<Invoice[]> {
// 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<Invoice> {
const res = await this.post<LndAddInvoiceResponse>('/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<Invoice | null> {
try {
// The path segment is `r_hash_str` — hex, not the base64 used by the body fields.
const inv = await this.get<LndInvoice>(`/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<DecodedInvoice> {
const res = await this.get<LndPayReq>(`/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<Payment[]> {
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<Payment> {
if (req.feeLimitMsat && req.feeLimitPercent != null) {
throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400);
}
const timeoutSeconds = req.timeoutSeconds ?? 60;
const body: Record<string, unknown> = {
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<Payment> {
// 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<string, string> = {
[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<string, unknown> = {
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<Channel[]> {
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<LndPendingChannels>('/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<Peer[]> {
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<SignMessageResult> {
// 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<VerifyMessageResult> {
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 };
}
}
@@ -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<string, { name?: string }>;
};
/** `/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<string, string | number | boolean | undefined>;
};
// ── 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<Capability> = new Set<Capability>([
'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<string> | 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<string> {
const res = await httpJson<LndHubAuth & LndHubErrorBody>({
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<string> {
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<T>(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<T>(req: LndHubCall): Promise<T> {
const send = async (token: string): Promise<T> =>
this.unwrap(
await httpJson<T>({
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<NodeInfo> {
let info: LndHubGetInfo | null = null;
try {
info = await this.call<LndHubGetInfo>({ 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<Balances> {
const res = await this.call<LndHubBalance>({ 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<OnchainTx[]> {
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<unknown>({ 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<string> {
const res = await this.call<LndHubAddress[] | LndHubAddress>({ path: '/getbtc' });
if (Array.isArray(res)) return res[0]?.address ?? '';
return res?.address ?? '';
}
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
const res = await this.call<LndHubUserInvoice[]>({
path: '/getuserinvoices',
query: { limit: opts?.limit },
});
return (Array.isArray(res) ? res : []).map((inv) => this.toInvoice(inv));
}
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
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<LndHubUserInvoice>({
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<Invoice | null> {
const wanted = paymentHash.toLowerCase();
const res = await this.call<LndHubUserInvoice[]>({ 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<DecodedInvoice> {
const res = await this.call<LndHubDecoded>({ 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<Payment[]> {
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<Payment> {
const amountSats = req.amountMsat ? msatToSats(req.amountMsat, 'amountMsat') : undefined;
const res = await this.call<LndHubPayResult>({
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<LndHubTx[]> {
const res = await this.call<LndHubTx[]>({ 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,
};
}
}
-408
View File
@@ -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://<wallet-pubkey>?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<Nip47Transaction, 'state'> & { state?: Nip47Transaction['state'] };
/** NIP-47 error codes, mapped onto HTTP so routes.ts can answer honestly. */
const ERROR_STATUS: Record<string, number> = {
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<Capability>(['lightningReceive', 'lightningSend']);
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
private client: NWCClient | null = null;
private connecting: Promise<NWCClient> | null = null;
private methods: ReadonlySet<Nip47Method> = new Set<Nip47Method>();
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<NWCClient> {
if (this.client) return this.client;
this.connecting ??= this.open().finally(() => {
this.connecting = null;
});
return this.connecting;
}
private async open(): Promise<NWCClient> {
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<T>(op: string, fn: (client: NWCClient) => Promise<T>): Promise<T> {
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<Nip47Method>();
}
// ── node / balances ────────────────────────────────────────────────────────────────────────────
override async getInfo(): Promise<NodeInfo> {
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<Balances> {
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<Invoice[]> {
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<Invoice> {
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<Invoice | null> {
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<DecodedInvoice> {
return decodeBolt11(bolt11);
}
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
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<Payment> {
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<Payment> {
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<NwcTransaction | null> {
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<NwcTransaction | null> {
if (this.methods.size && !this.methods.has('lookup_invoice')) return null;
try {
return await this.lookup(request);
} catch {
return 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<T>(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<void>;
recordError(message: string): Promise<void>;
}
// ── 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<string, { public: number; private: number }> = {
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<AddressType, number> = {
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<Record<AddressType, string>>;
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<Capability> = new Set<Capability>([
'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<AddressType, Account>;
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<string, AddressEntry>();
/** 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<string, number>();
private readonly cache: ChainCacheStore | null;
private current: Snapshot | null = null;
private inflight: Promise<Snapshot> | null = null;
/** Memoised so concurrent first reads do one cache load between them, not one each. */
private hydration: Promise<void> | 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<RescanState> {
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<RescanState | null> {
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<Snapshot> {
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<void> {
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<Snapshot> {
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<void> {
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<Snapshot> {
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<NodeInfo> {
// 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<Balances> {
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<FeeEstimates> {
return this.chain.getFeeEstimates();
}
override async getUtxos(): Promise<Utxo[]> {
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<OnchainTx[]> {
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<SendCoinsResult> {
// 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<SpendableUtxo, PsbtInputSource>(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<SignMessageResult> {
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<VerifyMessageResult> {
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<AddressEntry> {
// 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<number>();
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]));
}
-301
View File
@@ -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);
});
});
-516
View File
@@ -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<string, Bolt11Invoice>();
const MSAT_PER_BTC = 100_000_000_000n;
const MAX_MSAT = 2_100_000_000_000_000_000n;
const DIVISORS: Readonly<Record<string, bigint>> = {
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<number, string>;
/** 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<Record<number, number>> = { 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: `<digits><multiplier?>`, 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();
}
@@ -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<string, Funding>,
private readonly tip = 800_000,
) {}
async getTipHeight(): Promise<number> {
this.calls.push('tip');
return this.tip;
}
async getAddress(address: string): Promise<EsploraAddress> {
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<EsploraUtxo[]> {
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<EsploraTx[]> {
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<string> {
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<string> {
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: <T>(fn: (root: HDKey) => T): T => fn(HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC))),
};
function backendFor(world: Map<string, Funding>, 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<string, Funding>([
[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<string, Funding>([
[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<string, Funding>([[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<string, Funding>([[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<string, Funding>([[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<string, Funding>([[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,
});
});
});
@@ -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<string, ScannedAddress>;
};
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<number> {
return this.chain.getTipHeight();
}
getFeeEstimates(): Promise<FeeEstimates> {
return this.chain.getFeeEstimates();
}
getTxHex(txid: string): Promise<string> {
return this.chain.getTxHex(txid);
}
broadcast(rawHex: string): Promise<string> {
return this.chain.broadcast(rawHex);
}
async scan(ctx: ScanContext): Promise<ScanResult> {
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<AddressScan> {
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<string, ScannedAddress>();
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<boolean> {
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<ScannedAddress[]> {
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<SpendableUtxo[]> {
// 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<SpendableUtxo>((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<OnchainTx[]> {
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<string, EsploraTx>();
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,
};
}
@@ -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<void>((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<RescanState> | null = null;
constructor(opts: NbxplorerChainSourceOptions) {
this.chain = opts.chain;
this.btcNetwork = networkFor(opts.network);
this.label = opts.label;
}
async getTipHeight(): Promise<number> {
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<FeeEstimates> {
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<string> {
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<string> {
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<ScanResult> {
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<string, ScannedAddress>();
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<RescanHandle | null> {
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<RescanState> {
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<number> {
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<number> {
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<ChainIndex, number> = {
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<string, { confirmed: number; unconfirmed: number }>();
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<SpendableUtxo>((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<string, ScannedAddress>, 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;
}
-172
View File
@@ -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<RescanState> };
/** 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<number>;
getFeeEstimates(): Promise<FeeEstimates>;
/**
* 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<string>;
/** Publish a signed transaction. Resolves to the txid, throws if the network rejected it. */
broadcast(rawHex: string): Promise<string>;
/** Read the whole wallet off the chain. */
scan(ctx: ScanContext): Promise<ScanResult>;
/**
* 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<RescanHandle | null>;
/** 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<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out = new Array<R>(items.length);
let cursor = 0;
const worker = async (): Promise<void> => {
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;
}
-281
View File
@@ -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<string, number>;
// ── 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<keyof FeeEstimates, number>;
/** 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<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.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<T>(path: string): Promise<T> {
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<EsploraAddress> {
return this.json<EsploraAddress>(`/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<EsploraTx[]> {
const addr = encodeURIComponent(address);
const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`;
return this.json<EsploraTx[]>(path);
}
/** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */
getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
return this.json<EsploraUtxo[]>(`/address/${encodeURIComponent(address)}/utxo`);
}
/** GET /tx/{txid}. */
getTx(txid: string): Promise<EsploraTx> {
return this.json<EsploraTx>(`/tx/${encodeURIComponent(txid)}`);
}
/** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */
async getTxHex(txid: string): Promise<string> {
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<number> {
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<FeeEstimates> {
return mapFeeEstimates(await this.json<EsploraFeeEstimates>('/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<string> {
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 };
-200
View File
@@ -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<BitcoinNetwork, readonly string[]> = {
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<ChainProbe> {
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<ChainProbe> {
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<ChainProbe> {
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<Response> {
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<Response> {
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<Response> {
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);
}
-225
View File
@@ -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<string, unknown>).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'));
-323
View File
@@ -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,
);
});
-484
View File
@@ -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<Buffer>;
// 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<Buffer> {
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<void> {
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<SeedEnvelope> {
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<OpenedSeed> {
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<HDKey> {
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<Bip, string> }> {
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<Bip, string>;
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<number, Attempts>();
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<typeof setTimeout> | 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<void> {
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<T>(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<number, UnlockSession>();
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<OpenedSeed> {
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<boolean> {
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<string> {
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<SeedEnvelope> {
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);
}
-401
View File
@@ -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<AddressType, string> = {
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;
/** `"<txid>-<vout>"`. */
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<Record<DerivationFeature, number | null>>;
} | 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<string>();
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<string> {
const { status, body } = await this.send(path, init);
if (status < 200 || status >= 300) this.fail(path, status, body);
return body;
}
private async json<T>(path: string, init?: RequestInitLite): Promise<T> {
const body = await this.raw(path, init);
return this.parse<T>(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<T>(path: string, init?: RequestInitLite): Promise<T | null> {
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<T>(path, body);
}
private parse<T>(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<NbxStatus> {
return this.json<NbxStatus>(`${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<void> {
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<NbxBalance> {
await this.track(accountXpub, type);
return this.json<NbxBalance>(`${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<NbxUtxoChanges> {
await this.track(accountXpub, type);
return this.json<NbxUtxoChanges>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos`);
}
/** `GET …/transactions` — wallet history, already grouped by confirmation state. */
async getTransactions(accountXpub: string, type: AddressType): Promise<NbxTransactions> {
await this.track(accountXpub, type);
return this.json<NbxTransactions>(`${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<NbxAddress> {
await this.track(accountXpub, type);
const q = `feature=${feature}&reserve=${reserve ? 'true' : 'false'}`;
return this.json<NbxAddress>(`${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<string> {
await this.track(accountXpub, type);
const info = await this.json<NbxTransaction>(
`${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<void> {
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<NbxScanStatus | null> {
return this.jsonOrNull<NbxScanStatus>(`${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<number> {
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<void> {
const res = await this.json<NbxBroadcastResult>(`${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');
}
}
}
-570
View File
@@ -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<AddressType, number> = {
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<AddressType, number> = {
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<AddressType, number> = {
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 };
}
-186
View File
@@ -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<number, Cached>();
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<string, unknown> | 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<Resolved> {
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<string, unknown> | 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<string, unknown> | 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<Record<AddressType, string>> = {};
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;
}
}
-658
View File
@@ -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<string, unknown>, { status });
}
function badRequest(message: string): Response {
return json({ error: message }, 400);
}
async function body<T>(req: Request): Promise<T> {
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<Response | null> {
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<Response | null> {
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<string, unknown> }>(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<T>` 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<Parameters<WalletBackend['sendCoins']>[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<Parameters<WalletBackend['payInvoice']>[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<Parameters<WalletBackend['sendKeysend']>[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<string, unknown>;
makeActive?: boolean;
};
async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
if (!hasStoreKey()) {
throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY');
}
const b = await body<CreateBody>(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<void> {
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<Response> {
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<DeleteBody>(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<SeedEnvelope> {
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<Response> {
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<Response> {
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<Response> {
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<Balances> {
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<Response> {
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<Response> {
// /_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<Parameters<WalletBackend['createInvoice']>[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);
}
-423
View File
@@ -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<string, AddressType> = {
'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<NodeInfo>;
getBalances(): Promise<Balances>;
// on-chain
getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]>;
getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }>;
getUtxos(): Promise<Utxo[]>;
estimateFees(): Promise<FeeEstimates>;
sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult>;
// lightning
getInvoices(opts?: { limit?: number }): Promise<Invoice[]>;
createInvoice(req: CreateInvoiceRequest): Promise<Invoice>;
lookupInvoice(paymentHash: string): Promise<Invoice | null>;
decodeInvoice(bolt11: string): Promise<DecodedInvoice>;
getPayments(opts?: { limit?: number }): Promise<Payment[]>;
payInvoice(req: PayInvoiceRequest): Promise<Payment>;
sendKeysend(req: KeysendRequest): Promise<Payment>;
// node operation
getChannels(): Promise<Channel[]>;
getPeers(): Promise<Peer[]>;
signMessage(message: string): Promise<SignMessageResult>;
verifyMessage(message: string, signature: string): Promise<VerifyMessageResult>;
/**
* 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<RescanState>;
/**
* 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<RescanState | null>;
}
/**
* 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';
}
}
-124
View File
@@ -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<BitcoinNetwork, string> = {
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<number, { source: ChainSource; at: number }>();
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<ChainSource> {
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<ChainSource> {
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;
}
}
@@ -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,
@@ -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 (
<span className={`tabular-nums ${tone} ${className}`}>
{signed && sats != null && sats > 0 ? '+' : ''}
{text}
</span>
);
};
/** The toggle itself — a button, because it mutates a preference rather than navigating. */
export const UnitToggle = ({ className = '' }: { className?: string }) => {
const { unit, toggle } = useAmountUnit();
return (
<button
type="button"
onClick={toggle}
title="Switch between satoshis and BTC"
className={`rounded-md border border-border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground ${className}`}
>
{unit === 'sats' ? 'sats' : 'BTC'}
</button>
);
};
@@ -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<ChainKind, KindMeta> = {
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<ChainState>('wallet');
const { save, forget } = useServiceConnectionActions<{ url: string; kind: ChainKind }, SaveResult>('wallet');
const [url, setUrl] = useState('');
const [kind, setKind] = useState<ChainKind>('esplora');
const [error, setError] = useState<string | null>(null);
const [height, setHeight] = useState<number | null>(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 (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Chain source</h3>
<p className="mb-3 text-xs text-muted-foreground">
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.
</p>
<div
role="radiogroup"
aria-label="Chain source protocol"
className="mb-2 inline-flex rounded-lg border border-border p-0.5"
>
{(Object.keys(KINDS) as ChainKind[]).map((k) => (
<button
key={k}
type="button"
role="radio"
aria-checked={kind === k}
onClick={() => pick(k)}
disabled={isLoading || busy}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
kind === k ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{KINDS[k].label}
</button>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={url}
onChange={(ev) => 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}
/>
<div className="flex shrink-0 gap-2">
<Button size="sm" onClick={submit} disabled={!trimmed || !dirty || busy}>
{save.isPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{save.isPending ? 'Connecting…' : 'Save & connect'}
</Button>
{data?.custom && (
<Button size="sm" variant="outline" onClick={reset} disabled={busy} title="Back to the public default">
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Reset
</Button>
)}
</div>
</div>
{error && (
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</p>
)}
{height != null && !error && (
<p className="mt-2 flex items-center gap-1.5 text-xs text-emerald-500">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
Connected — block {height.toLocaleString()}
</p>
)}
<p className="mt-2 text-[11px] text-muted-foreground">
{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}
</p>
</section>
);
};
@@ -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 <EmptyWallet isLoading={isLoading} />;
if (!supported) return <UnsupportedSection what="coin control" />;
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 (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 border-b border-border px-4 py-2 text-xs">
<span className="text-muted-foreground">
{utxos.length} coin{utxos.length === 1 ? '' : 's'} · <Amount sats={spendableTotal} /> spendable
</span>
<span className="flex-1" />
<SyncBadge sync={sync} />
{selected.length > 0 && (
<>
<span>
{selected.length} selected · <Amount sats={selectedTotal} className="font-semibold" />
</span>
<button type="button" onClick={clear} className="text-primary hover:underline">
clear
</button>
<Link
to={walletSectionPath('send', walletId)}
className="rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Spend these
</Link>
</>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{utxosLoading && utxos.length === 0 ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Listing coins…
</div>
) : utxos.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
{/* "No coins" is only true if the chain was actually read. Never-synced is a different fact. */}
<p className="text-sm font-medium">{sync && sync.syncedAt === null ? 'Coins unavailable' : 'No coins'}</p>
<p className="text-xs text-muted-foreground">
{sync && sync.syncedAt === null
? (sync.lastError ?? 'The chain source could not be reached.')
: 'Receive something and it will show up here as a UTXO.'}
</p>
</div>
) : (
<ul className="divide-y divide-border">
{utxos.map((utxo) => (
<CoinRow
key={`${utxo.txid}:${utxo.vout}`}
utxo={utxo}
walletId={walletId}
selected={selected.includes(`${utxo.txid}:${utxo.vout}`)}
onToggle={toggle}
/>
))}
</ul>
)}
</div>
</div>
);
};
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 (
<li className={`flex items-center gap-3 px-4 py-2.5 ${utxo.frozen ? 'opacity-60' : ''}`}>
<input
type="checkbox"
checked={selected}
disabled={utxo.frozen}
onChange={() => onToggle(outpoint)}
aria-label={`Select ${outpoint}`}
className="h-3.5 w-3.5 shrink-0 accent-primary"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{utxo.label || truncateMiddle(utxo.address, 16, 10)}</div>
<div className="mt-0.5 flex items-center gap-2 font-mono text-[10px] text-muted-foreground">
<span>
{truncateMiddle(utxo.txid, 10, 6)}:{utxo.vout}
</span>
<span className="font-sans">{formatConfirmations(utxo.confirmations)}</span>
{utxo.addressType && <span className="font-sans">{utxo.addressType}</span>}
</div>
</div>
<Amount sats={utxo.amountSats} className="shrink-0 text-xs font-semibold" />
<button
type="button"
onClick={() => freeze.mutate({ outpoint, frozen: !utxo.frozen })}
disabled={freeze.isPending}
title={utxo.frozen ? 'Make this coin spendable again' : 'Keep this coin out of automatic selection'}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
{utxo.frozen ? <Sun className="h-3.5 w-3.5" /> : <Snowflake className="h-3.5 w-3.5" />}
</button>
</li>
);
};
@@ -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 (
<div className={className}>
{label && (
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
)}
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
<code className={`min-w-0 flex-1 font-mono text-xs ${wrap ? 'break-all' : 'truncate'}`}>{value}</code>
<button
type="button"
onClick={copy}
aria-label={`Copy ${label ?? 'value'}`}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
};
@@ -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 }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
{isLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading wallets…</p>
</>
) : (
<>
<Bitcoin className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm font-medium">No wallet yet</p>
<p className="max-w-sm text-xs text-muted-foreground">
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.
</p>
</>
)}
</div>
);
export const UnsupportedSection = ({ what }: { what: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<p className="text-sm font-medium">Not available for this wallet</p>
<p className="max-w-sm text-xs text-muted-foreground">
This backend does not support {what}. Pick a different wallet from the panel on the left.
</p>
</div>
);
@@ -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 <EmptyWallet isLoading={isLoading} />;
if (!anyLightning) return <UnsupportedSection what="lightning" />;
return (
<div className="h-full space-y-4 overflow-y-auto p-4">
{hasChannels && (
<Section title="Channels" count={channels.length}>
{channels.length === 0 ? (
<Blank>No channels open.</Blank>
) : (
<ul className="divide-y divide-border">
{channels.map((channel) => (
<ChannelRow key={channel.channelId} channel={channel} />
))}
</ul>
)}
</Section>
)}
{hasPeers && (
<Section title="Peers" count={peers.length}>
{peers.length === 0 ? (
<Blank>Not connected to anyone.</Blank>
) : (
<ul className="divide-y divide-border">
{peers.map((peer) => (
<li key={peer.pubkey} className="flex items-center gap-2 py-2 text-xs">
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{peer.alias || truncateMiddle(peer.pubkey, 14, 8)}</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{peer.address}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{peer.inbound ? 'inbound' : 'outbound'}
</span>
</li>
))}
</ul>
)}
</Section>
)}
{hasPayments && (
<Section title="Payments" count={payments.length}>
{payments.length === 0 ? (
<Blank>Nothing sent yet.</Blank>
) : (
<ul className="divide-y divide-border">
{payments.map((payment) => (
<li key={payment.paymentHash} className="py-2">
<div className="flex items-center gap-2 text-xs">
<Zap className="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span className="min-w-0 flex-1 truncate">
{payment.memo || truncateMiddle(payment.destination ?? payment.paymentHash, 14, 8)}
</span>
<span
className={`shrink-0 rounded px-1.5 py-px text-[10px] font-medium ${PAYMENT_TONES[payment.status]}`}
>
{payment.status}
</span>
{/* msat stays a string all the way to the DOM — see format.ts. */}
<Amount msat={payment.amountMsat} className="shrink-0 font-semibold" />
</div>
<div className="mt-0.5 pl-5 text-[11px] text-muted-foreground">
{formatTimestamp(payment.createdAt)} · fee <Amount msat={payment.feeMsat} />
{payment.failureReason && <span className="text-destructive"> · {payment.failureReason}</span>}
</div>
</li>
))}
</ul>
)}
</Section>
)}
</div>
);
};
const ChannelRow = ({ channel }: { channel: Channel }) => {
const capacity = channel.capacitySats || 1;
const localPct = Math.max(0, Math.min(100, (channel.localBalanceSats / capacity) * 100));
return (
<li className="py-2.5">
<div className="flex items-center gap-2 text-xs">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${channel.active ? 'bg-emerald-500' : 'bg-muted-foreground'}`}
title={channel.active ? 'active' : channel.status}
/>
<span className="min-w-0 flex-1 truncate">
{channel.remoteAlias || truncateMiddle(channel.remotePubkey, 14, 8)}
</span>
{channel.private && <span className="shrink-0 text-[10px] text-muted-foreground">private</span>}
<Amount sats={channel.capacitySats} className="shrink-0 text-[11px] text-muted-foreground" />
</div>
{/* Local/remote split — the number that decides whether you can send or only receive. */}
<div className="mt-1.5 flex h-1.5 overflow-hidden rounded-full bg-muted">
<div className="bg-primary" style={{ width: `${localPct}%` }} />
</div>
<div className="mt-1 flex justify-between text-[10px] tabular-nums text-muted-foreground">
<span>out {formatSats(channel.localBalanceSats)}</span>
<span>in {formatSats(channel.remoteBalanceSats)}</span>
</div>
</li>
);
};
type SectionProps = { title: string; count: number; children: React.ReactNode };
const Section = ({ title, count, children }: SectionProps) => (
<section className="rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{title}
{count > 0 && <span className="ml-1.5 font-normal">({count})</span>}
</h3>
{children}
</section>
);
const Blank = ({ children }: { children: React.ReactNode }) => (
<p className="text-xs text-muted-foreground">{children}</p>
);
@@ -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 (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<ShieldCheck className="h-3 w-3" />
no seed held
</span>
);
}
if (hasSeed == null) return null;
return (
<>
<div className="flex items-center gap-2">
<span
className={`flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums ${
unlocked ? 'bg-emerald-500/10 text-emerald-600' : 'bg-muted text-muted-foreground'
}`}
title={
unlocked
? 'The seed is decrypted in the sidecar until this countdown ends'
: 'Locked — balances and history still work; only signing needs the passphrase'
}
>
{unlocked ? <LockOpen className="h-3 w-3" /> : <Lock className="h-3 w-3" />}
{unlocked ? formatCountdown(secondsRemaining) : 'locked'}
</span>
{!compact &&
(unlocked ? (
<button
type="button"
onClick={() => lock.mutate()}
disabled={lock.isPending}
className="text-[10px] font-medium text-primary hover:underline"
>
lock now
</button>
) : (
<button
type="button"
onClick={() => setUnlockOpen(true)}
className="text-[10px] font-medium text-primary hover:underline"
>
unlock
</button>
))}
</div>
{unlockOpen && (
<UnlockDialog
open={unlockOpen}
onOpenChange={setUnlockOpen}
walletId={walletId}
walletName={walletName}
maxTtlSec={config?.unlockTtlSec ?? 300}
/>
)}
</>
);
};
/**
* 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 (
<>
<div
className={`flex items-center gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs ${className}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground">
This wallet is locked. Signing needs the passphrase — everything else on this screen already works.
</span>
<button
type="button"
onClick={() => setOpen(true)}
className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Unlock
</button>
</div>
{open && (
<UnlockDialog
open={open}
onOpenChange={setOpen}
walletId={walletId}
walletName={walletName}
maxTtlSec={config?.unlockTtlSec ?? 300}
/>
)}
</>
);
};
@@ -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 <EmptyWallet isLoading={isLoading} />;
const hasLightning = balances?.lightningBalance != null;
const canSend = capabilities.includes('onchainSend') || capabilities.includes('lightningSend');
const canReceive = capabilities.includes('onchainReceive') || capabilities.includes('lightningReceive');
return (
<div className="h-full overflow-y-auto p-4">
<div className="mb-3 flex items-center justify-between">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold">{wallet?.name}</h2>
<p className="truncate text-xs text-muted-foreground">
{wallet ? KIND_LABELS[wallet.kind] : '—'}
{info?.alias && ` · ${info.alias}`}
{info?.version && ` · ${info.version}`}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<SyncBadge sync={sync} />
<UnitToggle />
</div>
</div>
{balancesLoading && !balances ? (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Reading balances…
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Tile
icon={<Bitcoin className="h-4 w-4 text-orange-500" />}
label="On-chain"
value={<Amount sats={balances?.onchainConfirmed ?? null} className="text-xl font-semibold" />}
hint={onchainHint(balances)}
/>
{hasLightning && (
<Tile
icon={<Zap className="h-4 w-4 text-amber-500" />}
label="Lightning"
value={<Amount sats={balances?.lightningBalance ?? null} className="text-xl font-semibold" />}
hint={
balances?.lightningInbound != null ? `${formatSats(balances.lightningInbound)} sats inbound` : undefined
}
/>
)}
<Tile
label="Block height"
value={
<span className="text-xl font-semibold tabular-nums">{info?.blockHeight?.toLocaleString() ?? '—'}</span>
}
hint={info ? (info.synced ? 'synced' : 'syncing…') : undefined}
/>
<Tile
label="Network"
value={<span className="text-xl font-semibold">{info?.network ?? wallet?.network ?? '—'}</span>}
hint={info?.pubkey ? truncateMiddle(info.pubkey, 8, 6) : undefined}
/>
</div>
)}
{(canReceive || canSend) && (
<div className="mt-4 flex gap-2">
{canReceive && (
<Link
to={walletSectionPath('receive', walletId)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ArrowDownLeft className="h-3.5 w-3.5 text-emerald-500" />
Receive
</Link>
)}
{canSend && (
<Link
to={walletSectionPath('send', walletId)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ArrowUpRight className="h-3.5 w-3.5 text-destructive" />
Send
</Link>
)}
</div>
)}
<section className="mt-4 rounded-xl border border-border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Recent activity</h3>
<Link to={walletSectionPath('transactions', walletId)} className="text-[11px] text-primary hover:underline">
all transactions
</Link>
</div>
{transactions.length === 0 ? (
<p className="text-xs text-muted-foreground">Nothing yet.</p>
) : (
<ul className="divide-y divide-border">
{transactions.map((tx) => (
<li key={tx.txid} className="flex items-center gap-3 py-2">
<span
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${
tx.amount < 0 ? 'bg-destructive/10' : 'bg-emerald-500/10'
}`}
>
{tx.amount < 0 ? (
<ArrowUpRight className="h-3 w-3 text-destructive" />
) : (
<ArrowDownLeft className="h-3 w-3 text-emerald-500" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{tx.label ?? truncateMiddle(tx.txid)}</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
{tx.confirmations <= 0 && <Clock className="h-3 w-3" />}
{tx.confirmations <= 0 ? 'pending' : formatTimestamp(tx.timestamp)}
</div>
</div>
<Amount sats={tx.amount} signed className="shrink-0 text-xs" />
</li>
))}
</ul>
)}
</section>
</div>
);
};
type TileProps = { label: string; value: ReactNode; hint?: string; icon?: ReactNode };
const Tile = ({ label, value, hint, icon }: TileProps) => (
<div className="rounded-xl border border-border p-4">
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-1">{value}</div>
{hint && (
<div className="mt-0.5 truncate text-xs text-muted-foreground" title={hint}>
{hint}
</div>
)}
</div>
);
@@ -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 <EmptyWallet isLoading={isLoading} />;
if (!canOnchain && !canLightning) return <UnsupportedSection what="receiving" />;
return (
<div className="h-full overflow-y-auto p-4">
{canOnchain && (
<section className="max-w-2xl rounded-xl border border-border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
On-chain address
</h3>
<button
type="button"
onClick={() => next.mutate()}
disabled={next.isPending}
className="flex items-center gap-1 text-[11px] text-primary hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${next.isPending ? 'animate-spin' : ''}`} />
new address
</button>
</div>
{addressLoading && !address ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Deriving an address…
</div>
) : address ? (
<>
<CopyField value={address} wrap />
<p className="mt-2 text-xs text-muted-foreground">
{addressType ? `${addressType} · ` : ''}Reuse costs you privacy, not money — take a fresh one for each
payer.
</p>
</>
) : 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.
<div className="flex items-start gap-2 text-xs text-destructive">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="min-w-0">
<p className="break-words">{errorMessage(addressError, 'Could not derive an address')}</p>
<p className="mt-1 text-muted-foreground">
Addresses are derived locally, but the wallet checks the chain to find an unused one. Check{' '}
<span className="font-medium">Settings → Chain source</span>.
</p>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">No address available.</p>
)}
</section>
)}
{canLightning && (
<section className="mt-4 max-w-2xl rounded-xl border border-border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Lightning invoices
</h3>
<Button size="sm" variant="outline" onClick={() => setInvoiceOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New invoice
</Button>
</div>
{invoices.length === 0 ? (
<p className="text-xs text-muted-foreground">No invoices yet.</p>
) : (
<ul className="divide-y divide-border">
{invoices.map((inv) => (
<li key={inv.paymentHash} className="py-2">
<div className="flex items-center gap-2">
<Zap className="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span className="min-w-0 flex-1 truncate text-xs">
{inv.memo || truncateMiddle(inv.paymentHash, 12, 8)}
</span>
<span
className={`shrink-0 rounded px-1.5 py-px text-[10px] font-medium ${INVOICE_TONES[inv.state]}`}
>
{inv.state}
</span>
{/* msat stays a string all the way to the DOM — see format.ts. */}
<Amount msat={inv.amountMsat} className="shrink-0 text-xs" />
</div>
<div className="mt-0.5 pl-5 text-[11px] text-muted-foreground">
created {formatTimestamp(inv.createdAt)}
{inv.settledAt ? ` · settled ${formatTimestamp(inv.settledAt)}` : ''}
</div>
{inv.state === 'open' && <CopyField className="mt-1.5 pl-5" value={inv.bolt11} wrap />}
</li>
))}
</ul>
)}
</section>
)}
{invoiceOpen && <CreateInvoiceDialog open={invoiceOpen} onOpenChange={setInvoiceOpen} walletId={walletId} />}
</div>
);
};
@@ -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 (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Find missing coins
</h3>
<p className="mb-3 text-xs text-muted-foreground">
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.
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => rescan.mutate(depth ? { gapLimit: depth } : {})}
disabled={running || rescan.isPending}
>
{running || rescan.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Search className="mr-1.5 h-3.5 w-3.5" />
)}
{running ? 'Scanning…' : 'Scan the chain'}
</Button>
{/* 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. */}
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
search depth
<Input
type="number"
min={1}
max={100000}
value={gapLimit}
placeholder="1000"
disabled={running}
onChange={(ev) => setGapLimit(ev.target.value)}
className="h-7 w-24 text-xs"
/>
</label>
</div>
{state && <Status state={state} />}
</section>
);
};
type StatusProps = { state: NonNullable<ReturnType<typeof useBalances>['sync']>['rescan'] };
const Status = ({ state }: StatusProps) => {
if (!state) return null;
if (state.running) {
const label = state.current ? ADDRESS_TYPE_LABELS[state.current] : 'queued';
return (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-500">
Scanning {label} — {state.done} of {state.total} done
{state.found > 0 && `, ${state.found} coin${state.found === 1 ? '' : 's'} found so far`}.
</p>
);
}
if (state.error) {
return (
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Stopped after {state.done} of {state.total}: {state.error}
</span>
</p>
);
}
return (
<p className="mt-2 flex items-center gap-1.5 text-xs text-emerald-500">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
Last scan found {state.found} coin{state.found === 1 ? '' : 's'} across {state.total} address types.
</p>
);
};
@@ -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 <EmptyWallet isLoading={isLoading} />;
if (!canOnchain && !canLightning) return <UnsupportedSection what="sending" />;
return (
<div className="h-full overflow-y-auto p-4">
<Tabs defaultValue={canOnchain ? 'onchain' : 'lightning'} className="max-w-2xl">
{canOnchain && canLightning && (
<TabsList className="mb-4 w-fit">
<TabsTrigger value="onchain">
<Bitcoin className="mr-1.5 h-3.5 w-3.5" />
On-chain
</TabsTrigger>
<TabsTrigger value="lightning">
<Zap className="mr-1.5 h-3.5 w-3.5" />
Lightning
</TabsTrigger>
</TabsList>
)}
{canOnchain && (
<TabsContent value="onchain" className="mt-0">
<OnchainSendForm walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
</TabsContent>
)}
{canLightning && (
<TabsContent value="lightning" className="mt-0">
<LightningSendPanel walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
</TabsContent>
)}
</Tabs>
</div>
);
};
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 (
<section className="rounded-xl border border-emerald-500/40 bg-emerald-500/5 p-4">
<h3 className="text-sm font-semibold">Broadcast</h3>
<p className="mt-1 text-xs text-muted-foreground">
Paid a fee of {formatSats(broadcast.feeSats)} sats. It will confirm when a miner includes it.
</p>
<CopyField className="mt-3" value={broadcast.txid} label="txid" wrap />
<Button size="sm" variant="outline" className="mt-3" onClick={() => setBroadcast(null)}>
Send another
</Button>
</section>
);
}
return (
<form onSubmit={submit} className="space-y-4">
<div className="rounded-xl border border-border p-4">
<div className="flex items-baseline justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Spendable</h3>
{/* 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. */}
<Amount
sats={balances ? balances.onchainConfirmed - balances.onchainFrozen : null}
className="text-sm font-semibold"
/>
</div>
{!!balances?.onchainFrozen && (
<p className="mt-1 text-right text-[11px] text-muted-foreground">
{formatSats(balances.onchainFrozen)} sats frozen and excluded
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-send-address">To address</Label>
<Input
id="wallet-send-address"
value={address}
spellCheck={false}
placeholder="bc1q…"
onChange={(ev) => setAddress(ev.target.value)}
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-send-amount">Amount (sats)</Label>
<div className="flex items-center gap-2">
<Input
id="wallet-send-amount"
type="number"
min={0}
value={sendAll ? '' : amount}
disabled={sendAll}
placeholder={sendAll ? 'everything' : '0'}
onChange={(ev) => setAmount(ev.target.value)}
className="w-48"
/>
<button
type="button"
onClick={() => setSendAll((v) => !v)}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
sendAll ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
Send max
</button>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-send-fee">Fee rate (sat/vB)</Label>
<div className="flex flex-wrap gap-1.5">
{fees &&
FEE_PRESETS.map(({ key, label: presetLabel, hint }) => (
<button
key={key}
type="button"
onClick={() => setSatPerVbyte(String(fees[key]))}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
satPerVbyte === String(fees[key])
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
title={hint}
>
{presetLabel} · {fees[key]}
</button>
))}
</div>
<Input
id="wallet-send-fee"
type="number"
min={1}
value={satPerVbyte}
placeholder="1"
onChange={(ev) => setSatPerVbyte(ev.target.value)}
className="w-32"
/>
</div>
{capabilities.includes('coinControl') && (
<div className="rounded-xl border border-border p-3">
<div className="flex items-center gap-2 text-xs">
<Coins className="h-3.5 w-3.5 text-muted-foreground" />
{selected.length === 0 ? (
<span className="flex-1 text-muted-foreground">
Automatic coin selection.{' '}
<Link to={walletSectionPath('coins', walletId)} className="text-primary hover:underline">
Pick coins
</Link>
</span>
) : (
<>
<span className="flex-1">
{selected.length} coin{selected.length === 1 ? '' : 's'} selected · <Amount sats={selectedTotal} />
</span>
<button type="button" onClick={clear} className="text-primary hover:underline">
clear
</button>
</>
)}
</div>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="wallet-send-label">Label (optional)</Label>
<Input
id="wallet-send-label"
value={label}
placeholder="what this payment is for"
onChange={(ev) => setLabel(ev.target.value)}
/>
</div>
{needsUnlock && <UnlockPrompt walletId={walletId} walletName={walletName} />}
<Button type="submit" disabled={!canSubmit}>
{send.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign and broadcast
</Button>
</form>
);
};
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 (
<div className="space-y-4">
<div className="rounded-xl border border-border p-4">
<div className="flex items-baseline justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Lightning balance</h3>
<Amount sats={balances?.lightningBalance ?? null} className="text-sm font-semibold" />
</div>
</div>
{needsUnlock && <UnlockPrompt walletId={walletId} walletName={walletName} />}
<Button onClick={() => setPayOpen(true)} disabled={needsUnlock}>
<Zap className="mr-2 h-4 w-4" />
Pay an invoice
</Button>
{payOpen && <PayInvoiceDialog open={payOpen} onOpenChange={setPayOpen} walletId={walletId} />}
</div>
);
};
@@ -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 (
<span
className={`inline-flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-500 ${className ?? ''}`}
title="The node is searching its whole UTXO set for this wallet’s coins. Balances stay incomplete until it finishes."
>
<RefreshCw className="h-3 w-3 animate-spin" />
Scanning the chain · {Math.min(rescan.done + 1, rescan.total)} of {rescan.total}
</span>
);
}
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 (
<span
className={`inline-flex items-center gap-1 text-[11px] text-destructive ${className ?? ''}`}
title={sync.lastError ?? undefined}
>
<AlertTriangle className="h-3 w-3" />
Could not reach the chain
</span>
);
}
const age = relativeAge(sync.syncedAt, now);
if (failing) {
return (
<span
className={`inline-flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-500 ${className ?? ''}`}
title={sync.lastError ?? undefined}
>
<AlertTriangle className="h-3 w-3" />
{age} · chain unreachable
</span>
);
}
return (
<span className={`inline-flex items-center gap-1 text-[11px] text-muted-foreground ${className ?? ''}`}>
{sync.stale ? <RefreshCw className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{sync.stale ? `${age} · refreshing` : age}
</span>
);
};
@@ -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 <EmptyWallet isLoading={isLoading} />;
if (txLoading && transactions.length === 0) {
return (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Reading the chain…
</div>
);
}
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 (
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
<p className="text-sm font-medium">{neverRead ? 'History unavailable' : 'Nothing here yet'}</p>
<p className="text-xs text-muted-foreground">
{neverRead
? (sync.lastError ?? 'The chain source could not be reached.')
: 'Transactions appear as soon as they hit the mempool.'}
</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center justify-end border-b border-border px-4 py-1.5">
<SyncBadge sync={sync} />
</div>
<div className="flex-1 overflow-y-auto">
<ul className="divide-y divide-border">
{transactions.map((tx) => (
<TransactionRow key={tx.txid} tx={tx} />
))}
</ul>
</div>
</div>
);
};
const TransactionRow = ({ tx }: { tx: OnchainTx }) => {
const incoming = tx.amount >= 0;
const pending = tx.confirmations <= 0;
return (
<li className="flex items-center gap-3 px-4 py-2.5">
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${
incoming ? 'bg-emerald-500/10 text-emerald-500' : 'bg-muted text-muted-foreground'
}`}
>
{incoming ? <ArrowDownLeft className="h-3.5 w-3.5" /> : <ArrowUpRight className="h-3.5 w-3.5" />}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">
{tx.label || truncateMiddle(tx.destAddresses[0] ?? tx.txid, 14, 10)}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{formatTimestamp(tx.timestamp)}</span>
<span className={pending ? 'text-amber-500' : ''}>{formatConfirmations(tx.confirmations)}</span>
{tx.feeSats != null && tx.feeSats > 0 && <span>fee {formatSats(tx.feeSats)}</span>}
</div>
</div>
<div className="shrink-0 text-right">
<Amount sats={tx.amount} signed className="text-xs font-semibold" />
<div className="mt-0.5 font-mono text-[10px] text-muted-foreground">{truncateMiddle(tx.txid, 8, 6)}</div>
</div>
</li>
);
};
@@ -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=<id>` — 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<WalletSectionId, LucideIcon> = {
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 (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-orange-500/15 text-orange-500 ring-1 ring-black/5">
<Bitcoin className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-semibold leading-tight">Wallet</span>
{network && network !== 'bitcoin' && (
<span className={`rounded px-1 py-px text-[9px] font-semibold uppercase ${NETWORK_TONES[network] ?? ''}`}>
{network}
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Amount sats={total} />
<UnitToggle />
</div>
</div>
</div>
<div className="flex items-center justify-between px-5 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Wallets</span>
<button
type="button"
onClick={() => setCreateOpen(true)}
className="flex items-center gap-0.5 text-[10px] font-medium text-primary hover:underline"
>
<Plus className="h-3 w-3" />
add
</button>
</div>
<div className="flex flex-col gap-0.5 px-2 pb-3">
{isLoading && wallets.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">Loading wallets…</div>
)}
{!isLoading && wallets.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
No wallets yet. Add one to get started — a self-custodial seed, or a connection to your node.
</div>
)}
{wallets.map((w) => (
<WalletRow key={w.id} wallet={w} section={section} selected={w.id === walletId} />
))}
</div>
<nav className="flex flex-col gap-0.5 border-t border-border px-2 py-3">
{WALLET_SECTIONS.filter(({ id }) => sectionAvailable(id, capabilities)).map(({ id, label }) => {
const Icon = ICONS[id];
return (
<NavLink
key={id}
to={walletSectionPath(id, linkWalletId)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className="flex-1">{label}</span>
</>
)}
</NavLink>
);
})}
</nav>
<div className="mt-auto flex items-center justify-between gap-2 px-4 py-3">
<LockBadge walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
{config && <span className="truncate text-[10px] text-muted-foreground">{config.network}</span>}
</div>
{createOpen && <CreateWalletDialog open={createOpen} onOpenChange={setCreateOpen} section={section} />}
</div>
);
};
type WalletRowProps = { wallet: WalletSummary; section: WalletSectionId; selected: boolean };
const WalletRow = ({ wallet, section, selected }: WalletRowProps) => {
const { activate } = useWalletLifecycle();
return (
<div
className={`group flex items-center gap-1 rounded-lg pr-1.5 transition-colors ${
selected ? 'bg-muted' : 'hover:bg-muted/60'
}`}
>
<Link to={walletSectionPath(section, wallet.id)} className="flex min-w-0 flex-1 flex-col px-3 py-1.5">
<span className={`truncate text-xs ${selected ? 'font-medium text-foreground' : 'text-muted-foreground'}`}>
{wallet.name}
</span>
<span className="truncate text-[10px] text-muted-foreground">{KIND_LABELS[wallet.kind]}</span>
</Link>
{/* Sibling of the anchor, never nested inside it — this mutates, it does not navigate. */}
{wallet.isActive ? (
<Star className="h-3 w-3 shrink-0 fill-amber-400 text-amber-400" aria-label="Active wallet" />
) : (
<button
type="button"
onClick={() => activate.mutate(wallet.id)}
disabled={activate.isPending}
title="Make this the active wallet"
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-amber-400 group-hover:opacity-100 focus:opacity-100"
>
<Star className="h-3 w-3" />
</button>
)}
</div>
);
};
@@ -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 <EmptyWallet isLoading={isLoading} />;
const xpubs = Object.entries(wallet.xpubs ?? {});
return (
<div className="h-full space-y-4 overflow-y-auto p-4">
<section className="max-w-2xl rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<WalletNameField
wallet={wallet}
onSave={(name) => rename.mutateAsync({ walletId, name })}
isPending={rename.isPending}
/>
<p className="mt-0.5 text-xs text-muted-foreground">
{KIND_LABELS[wallet.kind]} · {wallet.network} · added {wallet.createdAt.slice(0, 10)}
</p>
</div>
<LockBadge walletId={walletId} walletName={wallet.name} />
</div>
<dl className="mt-4 space-y-1.5 text-xs">
<Row label="Backend">{kind ?? wallet.kind}</Row>
<Row label="Default derivation">BIP{wallet.defaultBip}</Row>
{wallet.fingerprint && (
<Row label="Fingerprint">
<code className="font-mono">{wallet.fingerprint}</code>
</Row>
)}
<Row label="Active">{wallet.isActive ? 'yes' : 'no'}</Row>
<Row label="Holds a seed">{hasSeed === true ? 'yes' : hasSeed === false ? 'no' : '—'}</Row>
</dl>
{!wallet.isActive && (
<Button
size="sm"
variant="outline"
className="mt-3"
onClick={() => activate.mutate(wallet.id)}
disabled={activate.isPending}
>
<Star className="mr-1.5 h-3.5 w-3.5" />
Make this the active wallet
</Button>
)}
</section>
{xpubs.length > 0 && (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Account keys</h3>
<p className="mb-3 text-xs text-muted-foreground">
Extended public keys. They can watch this wallet but never spend it — safe to hand to a block explorer or an
accounting tool.
</p>
<div className="space-y-2">
{xpubs.map(([path, xpub]) => (
<CopyField key={path} value={xpub} label={path} wrap />
))}
</div>
</section>
)}
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
What this backend can do
</h3>
{capabilities.length === 0 ? (
<p className="text-xs text-muted-foreground">No capabilities reported.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{capabilities.map((cap) => (
<span key={cap} className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{cap}
</span>
))}
</div>
)}
</section>
{hasSeed === true && (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Key material</h3>
<p className="mb-3 text-xs text-muted-foreground">
Both of these ask for the passphrase on their own, whether or not the wallet is currently unlocked.
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => setPassphraseOpen(true)}>
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
Change passphrase
</Button>
<Button size="sm" variant="outline" onClick={() => setExportOpen(true)}>
<Eye className="mr-1.5 h-3.5 w-3.5" />
Show recovery phrase
</Button>
</div>
</section>
)}
<ChainSourceCard />
<RescanCard walletId={walletId} />
{config && (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Deployment</h3>
<dl className="space-y-1.5 text-xs">
<Row label="Network">{config.network}</Row>
<Row label="Max unlock window">{formatMinutes(config.unlockTtlSec)}</Row>
<Row label="Store key">
{config.storeKeyConfigured ? 'configured' : <span className="text-destructive">missing</span>}
</Row>
</dl>
</section>
)}
<section className="max-w-2xl rounded-xl border border-destructive/40 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-destructive">Danger</h3>
<p className="mb-3 text-xs text-muted-foreground">
{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.'}
</p>
<Button size="sm" variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete wallet
</Button>
</section>
{passphraseOpen && (
<ChangePassphraseDialog open={passphraseOpen} onOpenChange={setPassphraseOpen} walletId={walletId} />
)}
{exportOpen && <ExportSeedDialog open={exportOpen} onOpenChange={setExportOpen} walletId={walletId} />}
{deleteOpen && (
<DeleteWalletDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
wallet={wallet}
// The deleted id must not stay in `?wallet=` — drop back to the bare section, which means
// "whatever is active now".
onDeleted={() => navigate(walletSectionPath('overview'), { replace: true })}
/>
)}
</div>
);
};
/** 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<unknown>;
isPending: boolean;
};
const WalletNameField = ({ wallet, onSave, isPending }: WalletNameFieldProps) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(wallet.name);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="truncate text-sm font-semibold">{wallet.name}</h3>
<button
type="button"
onClick={() => setEditing(true)}
aria-label="Rename wallet"
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</button>
</div>
);
}
return (
<div className="flex min-w-0 items-center gap-1.5">
<Input
ref={inputRef}
autoFocus
value={draft}
maxLength={MAX_NAME}
disabled={isPending}
onChange={(ev) => 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}
/>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={commit} disabled={isPending || !trimmed}>
<Check className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={cancel} disabled={isPending}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
};
const formatMinutes = (seconds: number) => (seconds >= 60 ? `${Math.round(seconds / 60)} min` : `${seconds}s`);
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-muted-foreground">{label}</dt>
<dd className="min-w-0 truncate text-right">{children}</dd>
</div>
);
@@ -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 <ReceiveView />;
case 'send':
return <SendView />;
case 'transactions':
return <TransactionsView />;
case 'coins':
return <CoinsView />;
case 'lightning':
return <LightningView />;
case 'settings':
return <WalletSettingsView />;
default:
return <OverviewView />;
}
};
@@ -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 (
<>
<Bitcoin className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">
{label}
{wallet && <span className="ml-1.5 font-normal text-black/50">· {wallet.name}</span>}
</span>
{balances && <Amount sats={balances.onchainConfirmed} className="shrink-0 text-[10px] text-black/60" />}
<UnitToggle className="shrink-0" />
<LockBadge walletId={walletId} walletName={wallet?.name ?? 'this wallet'} compact />
</>
);
};
@@ -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 (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>Change passphrase</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-pass-old">Current passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-pass-old"
type="password"
autoFocus
autoComplete="off"
value={oldPassphrase}
onChange={(ev) => setOldPassphrase(ev.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-pass-new">New passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-pass-new"
type="password"
autoComplete="new-password"
value={newPassphrase}
onChange={(ev) => setNewPassphrase(ev.target.value)}
/>
<p className="text-[11px] text-muted-foreground">At least 8 characters.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-pass-confirm">Confirm new passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-pass-confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(ev) => setConfirm(ev.target.value)}
/>
{mismatch && <p className="text-[11px] text-destructive">These do not match.</p>}
</div>
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{changePassphrase.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Change
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
{created ? (
<>
<DialogHeader>
<DialogTitle>Invoice created</DialogTitle>
<DialogDescription>Send this to whoever is paying you.</DialogDescription>
</DialogHeader>
<CopyField value={created} wrap label="BOLT11" />
<DialogFooter>
<Button onClick={close}>Done</Button>
</DialogFooter>
</>
) : (
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>New invoice</DialogTitle>
<DialogDescription>Leave the amount blank to let the payer choose.</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-inv-amount">Amount (sats)</Label>
<Input
id="wallet-inv-amount"
type="number"
min={0}
value={amountSats}
placeholder="any amount"
onChange={(ev) => setAmountSats(ev.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-inv-memo">Description</Label>
<Input
id="wallet-inv-memo"
value={memo}
placeholder="what this is for"
onChange={(ev) => setMemo(ev.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-inv-expiry">Expires in (minutes)</Label>
<Input
id="wallet-inv-expiry"
type="number"
min={1}
value={expiryMinutes}
onChange={(ev) => setExpiryMinutes(ev.target.value)}
className="w-32"
/>
</div>
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={createInvoice.isPending}>
{createInvoice.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
};
@@ -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<Exclude<BackendKind, 'onchain'>, { 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<BackendKind>('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<SeedLength>(24);
const [importWords, setImportWords] = useState<string[]>(() => 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<Record<string, string>>({});
// 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<BackendKind, 'onchain'>].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 (
<>
<Dialog open={open && pendingSeed == null} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
{/* Wider only while importing — 24 word boxes at max-w-lg are two cramped columns. */}
<DialogContent
className={`max-h-[85vh] overflow-y-auto ${isOnchain && importing ? 'max-w-2xl' : 'max-w-lg'}`}
// A half-typed recovery phrase must not be destroyed by a misplaced click on the overlay or a
// stray Escape (dismissing an error toast, say). Those gestures are cheap and reversible for an
// ordinary form; here they cost the owner all 24 words. Cancel and the X still close normally —
// the point is that discarding becomes deliberate, not incidental.
onInteractOutside={(ev) => {
if (hasTypedSecret) ev.preventDefault();
}}
onEscapeKeyDown={(ev) => {
if (hasTypedSecret) ev.preventDefault();
}}
>
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>Add a wallet</DialogTitle>
<DialogDescription>
Self-custodial keys stay in the wallet sidecar; a remote node is only a stored connection.
</DialogDescription>
</DialogHeader>
{storeKeyMissing && (
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<span>
<span className="font-medium">The secret store is unreachable.</span> The sidecar refuses to store
wallet secrets without it, so creation is disabled until it is set.
</span>
</div>
)}
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-new-name">Name</Label>
<Input
id="wallet-new-name"
value={name}
autoFocus
placeholder="Savings"
onChange={(ev) => setName(ev.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-new-kind">Type</Label>
<Select value={kind} onValueChange={(v) => setKind(v as BackendKind)}>
<SelectTrigger id="wallet-new-kind">
<SelectValue />
</SelectTrigger>
<SelectContent>
{KINDS.map((k) => (
<SelectItem key={k} value={k}>
{KIND_LABELS[k]}
</SelectItem>
))}
</SelectContent>
</Select>
{config && (
<p className="text-xs text-muted-foreground">
Network: <span className="font-medium">{config.network}</span>
</p>
)}
</div>
{isOnchain ? (
<>
<div className="flex gap-1.5">
<SegButton active={!importing} onClick={() => setImporting(false)} label="Generate a new seed" />
<SegButton active={importing} onClick={() => setImporting(true)} label="Import a phrase" />
</div>
{importing ? (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>Recovery phrase</Label>
<SegButton
active={asText}
onClick={() => toggleTextMode(!asText)}
label={asText ? 'Use word boxes' : 'Paste as text'}
/>
</div>
{!asText && (
<div className="flex flex-wrap gap-1.5">
{SEED_LENGTHS.map((n) => (
<SegButton
key={n}
active={importLength === n}
onClick={() => resizeImport(n)}
label={`${n}`}
/>
))}
</div>
)}
{/* Secret in flight: state → request body → cleared. Never stored on this side, and
never echoed back by the sidecar either. */}
{asText ? (
<Textarea
rows={4}
autoComplete="off"
spellCheck={false}
data-1p-ignore
data-lpignore="true"
value={importText}
placeholder="Paste or type the whole phrase. Numbering, commas and line breaks are ignored."
onChange={(ev) => applyText(ev.target.value)}
className="font-mono text-xs"
/>
) : (
<SeedPhraseInput
words={importWords}
onChange={setImportWords}
onLengthDetected={(n) => setImportLength(n as SeedLength)}
/>
)}
{aezeed ? (
<div className="space-y-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3">
<p className="text-xs">
<span className="font-medium text-emerald-600 dark:text-emerald-500">
Valid aezeed seed — checksum verified in this browser.
</span>{' '}
This is not a BIP39 phrase. It is an <em>aezeed</em> cipher seed, the format Zeus's embedded
LND node produces: the same 2048-word list, so it looks identical, but encrypted and
checksummed differently. Officer imports it.
</p>
<p className="text-xs text-muted-foreground">
This recovers the <span className="font-medium">on-chain</span> wallet. Lightning channels
are not in the seed — those need a channel backup file and a running LND.
</p>
</div>
) : orderFix ? (
<div className="space-y-1.5 rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
<p className="text-xs">
<span className="font-medium">These are your words — the order is wrong.</span> Read down
the columns, they check out. Your backup is almost certainly printed as {orderFix.rows} rows
of {orderFix.cols}, running top-to-bottom, and was read left-to-right.
</p>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setImportWords(orderFix.words);
setImportText(orderFix.words.join(' '));
}}
>
Reorder to the valid phrase
</Button>
<p className="text-xs text-muted-foreground">
Check the result against your backup before continuing.
</p>
</div>
) : seedProblem?.kind === 'checksum' ? (
// The hardest case to act on, so it gets the longest explanation: every word is
// real, so nothing is marked red, and the only remaining faults are order or a
// word that is a near-miss for the right one.
<p className="text-xs text-destructive">
All {importWords.length} words are valid BIP39 words, but the phrase does not check out. A
word is in the wrong position, or one is a near-miss for the right one (<em>fan</em> where the
backup says <em>fancy</em>). Re-read it against your backup in order.
</p>
) : seedProblem?.kind === 'unknown' ? (
<p className="text-xs text-destructive">
Not in the BIP39 word list: {seedProblem.boxes.length === 1 ? 'box' : 'boxes'}{' '}
{seedProblem.boxes.join(', ')}.
</p>
) : seedProblem?.kind === 'incomplete' ? (
<p className="text-xs text-muted-foreground">
{seedProblem.boxes.length} {seedProblem.boxes.length === 1 ? 'word' : 'words'} still empty.
</p>
) : seedProblem?.kind === 'length' ? (
<p className="text-xs text-destructive">
{seedProblem.count} words. A BIP39 phrase must be 12, 15, 18, 21 or 24.
</p>
) : (
<p className="text-xs text-emerald-600 dark:text-emerald-500">
Valid phrase — checksum verified in this browser.
</p>
)}
<p className="text-xs text-muted-foreground">
Type a few letters and pick from the list, or paste the whole phrase into the first box.
</p>
</div>
) : (
<div className="space-y-1.5">
<Label>Phrase length</Label>
<div className="flex gap-1.5">
<SegButton active={words === 12} onClick={() => setWords(12)} label="12 words" />
<SegButton active={words === 24} onClick={() => setWords(24)} label="24 words" />
</div>
<p className="text-xs text-muted-foreground">
The phrase is shown once, immediately after creation, and never again without this passphrase.
</p>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="wallet-new-pass">Passphrase</Label>
{/* Seals the seed. Straight into the request body, cleared on settle — no store. */}
<Input
id="wallet-new-pass"
type="password"
autoComplete="new-password"
value={passphrase}
onChange={(ev) => setPassphrase(ev.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-new-pass2">Confirm</Label>
<Input
id="wallet-new-pass2"
type="password"
autoComplete="new-password"
value={confirmPassphrase}
onChange={(ev) => setConfirmPassphrase(ev.target.value)}
/>
</div>
</div>
{passphrase.length > 0 && passphrase.length < 8 && (
<p className="text-xs text-destructive">Use at least 8 characters.</p>
)}
{confirmPassphrase.length > 0 && passphrase !== confirmPassphrase && (
<p className="text-xs text-destructive">The two passphrases do not match.</p>
)}
{/* One field, two meanings, because the two seed formats each have exactly one such
secret and which one applies is decided by the phrase itself. Mislabelling it for an
aezeed would be actively harmful: leaving it blank there is correct and normal, while
typing the wallet passphrase into it fails the AEZ tag and looks like a bad seed. */}
<div className="space-y-1.5">
<Label htmlFor="wallet-new-bip39">
{aezeed ? 'Seed passphrase (optional)' : 'BIP39 passphrase (optional)'}
</Label>
{/* Part of the seed itself — lose it and the coins are gone. Same handling: no store. */}
<Input
id="wallet-new-bip39"
type="password"
autoComplete="off"
value={bip39Passphrase}
onChange={(ev) => setBip39Passphrase(ev.target.value)}
/>
<p className="text-xs text-muted-foreground">
{aezeed
? 'Only if you set one when the aezeed seed was created. Almost nobody does — leave it blank.'
: 'A 25th word. It is part of the key, not a lock on it — without it the phrase alone recovers a different, empty wallet.'}
</p>
</div>
</>
) : (
<div className="space-y-3">
{CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].map((field) => (
<div key={field.key} className="space-y-1.5">
<Label htmlFor={`wallet-new-${field.key}`}>{field.label}</Label>
{/* Node credentials are stored server-side, encrypted, and never returned to the
browser — so this input is write-only and holds nothing after submit. */}
<Input
id={`wallet-new-${field.key}`}
autoComplete="off"
spellCheck={false}
value={remoteConfig[field.key] ?? ''}
placeholder={field.placeholder}
onChange={(ev) => setRemoteConfig((prev) => ({ ...prev, [field.key]: ev.target.value }))}
className="font-mono text-xs"
/>
</div>
))}
</div>
)}
<label className="flex items-center gap-2.5 text-sm">
<Checkbox checked={makeActive} onCheckedChange={(v) => setMakeActive(v === true)} />
Make this the active wallet
</label>
</div>
{/* The toast disappears; this does not. With the form no longer clearing on failure, the
owner needs the reason to stay put next to the boxes they are about to correct. */}
{/* Only while the current input still matches what was rejected. Without this the box from a
previous attempt hangs around after the phrase is corrected, which reads as "still broken"
when nothing is broken — and the mutation's error state does not clear itself. */}
{create.isError && !seedProblem && (
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<span>{errorMessage(create.error, 'Could not create the wallet')}</span>
</div>
)}
{blockReason && !storeKeyMissing && (
<p className="mt-4 text-right text-xs text-muted-foreground">{blockReason}</p>
)}
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{create.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create wallet
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{pendingSeed && (
<SeedBackupDialog
open
mnemonic={pendingSeed.mnemonic}
walletName={pendingSeed.walletName}
onConfirmed={seedConfirmed}
/>
)}
</>
);
};
const SegButton = ({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) => (
<button
type="button"
onClick={onClick}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
active ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
{label}
</button>
);
@@ -1,119 +0,0 @@
import type { WalletSummary } from '../shared';
import { useState } from 'react';
import { Loader2, TriangleAlert } 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 { useWalletLifecycle } from '../useWalletData';
// Delete a wallet. For a seeded wallet this destroys the only copy of the key material Officer holds, so
// the sidecar demands the passphrase even when the wallet is currently unlocked — an open session must not
// be enough to erase a seed. Typing the wallet's name is the second check against the wrong row, and it is
// sent rather than merely enforced here: a wallet with no seed has no passphrase gate, so without it the
// only thing standing between a node wallet and deletion was a dialog anything could route around.
type DeleteWalletDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
wallet: WalletSummary;
onDeleted?: () => void;
};
export const DeleteWalletDialog = ({ open, onOpenChange, wallet, onDeleted }: DeleteWalletDialogProps) => {
const { remove } = useWalletLifecycle();
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
const [passphrase, setPassphrase] = useState('');
const [confirmName, setConfirmName] = useState('');
const close = () => {
setPassphrase('');
setConfirmName('');
onOpenChange(false);
};
const nameMatches = confirmName.trim() === wallet.name;
const canSubmit = nameMatches && (!wallet.hasSeed || !!passphrase) && !remove.isPending;
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!canSubmit) return;
try {
await remove.mutateAsync({
walletId: wallet.id,
confirmName: confirmName.trim(),
passphrase: wallet.hasSeed ? passphrase : undefined,
});
close();
onDeleted?.();
} finally {
setPassphrase('');
}
};
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TriangleAlert className="h-5 w-5 text-destructive" />
Delete {wallet.name}
</DialogTitle>
<DialogDescription>
{wallet.hasSeed
? 'This erases the encrypted seed. Without your written-down recovery phrase the coins are gone for good.'
: 'This removes the connection to that node. Nothing on the node itself is touched.'}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-delete-name">
Type <span className="font-mono">{wallet.name}</span> to confirm
</Label>
<Input
id="wallet-delete-name"
autoFocus
autoComplete="off"
value={confirmName}
onChange={(ev) => setConfirmName(ev.target.value)}
/>
</div>
{wallet.hasSeed && (
<div className="space-y-1.5">
<Label htmlFor="wallet-delete-pass">Passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-delete-pass"
type="password"
autoComplete="off"
value={passphrase}
onChange={(ev) => setPassphrase(ev.target.value)}
/>
</div>
)}
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={!canSubmit}>
{remove.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete wallet
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -1,125 +0,0 @@
import { useState } from 'react';
import { Eye, Loader2, TriangleAlert } 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';
// Show the recovery phrase again — the one path back to it after creation, and the reason the create-time
// modal can say "not without your passphrase" rather than "never".
//
// Two things live only in this component and nowhere else: the passphrase (cleared the moment the request
// settles) and the returned mnemonic (held in local state, dropped when the dialog closes). The mutation
// deliberately has no onSuccess cache write, so the phrase never enters React Query. No copy button, for
// the same reason as SeedBackupDialog.
type ExportSeedDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
export const ExportSeedDialog = ({ open, onOpenChange, walletId }: ExportSeedDialogProps) => {
const { exportSeed } = useLockActions(walletId);
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
const [passphrase, setPassphrase] = useState('');
const [revealed, setRevealed] = useState<{ mnemonic: string; hasBip39Passphrase: boolean } | null>(null);
const close = () => {
setPassphrase('');
setRevealed(null);
onOpenChange(false);
};
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!passphrase) return;
try {
setRevealed(await exportSeed.mutateAsync({ passphrase }));
} finally {
setPassphrase('');
}
};
const words = revealed ? revealed.mnemonic.trim().split(/\s+/) : [];
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-lg">
{revealed ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TriangleAlert className="h-5 w-5 text-amber-500" />
Recovery phrase
</DialogTitle>
<DialogDescription>
Anyone who reads these {words.length} words can spend this wallet. Close this as soon as you are done.
</DialogDescription>
</DialogHeader>
<ol className="mt-2 grid grid-cols-3 gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 sm:grid-cols-4">
{words.map((word, i) => (
<li key={`${i}-${word}`} className="flex items-baseline gap-1.5 font-mono text-xs">
<span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">{i + 1}</span>
<span className="font-medium">{word}</span>
</li>
))}
</ol>
{revealed.hasBip39Passphrase && (
<p className="text-xs text-amber-600">
This seed also has a BIP39 passphrase. The words alone will not restore the wallet — you need both.
</p>
)}
<DialogFooter>
<Button onClick={close}>Done</Button>
</DialogFooter>
</>
) : (
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>Show recovery phrase</DialogTitle>
<DialogDescription>
The phrase is decrypted for display only. It is not stored by the browser and nothing is written
anywhere on this side.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-1.5">
<Label htmlFor="wallet-export-pass">Passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-export-pass"
type="password"
autoFocus
autoComplete="off"
value={passphrase}
onChange={(ev) => setPassphrase(ev.target.value)}
/>
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={!passphrase || exportSeed.isPending}>
{exportSeed.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Eye className="mr-2 h-4 w-4" />
)}
Show
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
};
@@ -1,170 +0,0 @@
import type { DecodedInvoice } from '../shared';
import { useState } from 'react';
import { Loader2, Zap } 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 { formatTimestamp, satsToMsat, truncateMiddle } from '../format';
import { Amount } from '../Amount';
import { useWalletOperations } from '../useWalletData';
// Pay a BOLT11 invoice: decode first, confirm what you are about to pay, then pay.
//
// The two steps are deliberate. A BOLT11 string is opaque, so paying one straight from the paste box is
// signing something you cannot read. Decoding is a free read — it does not need the wallet unlocked — and
// it is the only chance to see the amount and the destination before the money moves.
//
// Amounts stay msat STRINGS end to end (see format.ts): the decoded amount is rendered as a string and a
// zero-amount invoice's manual amount is converted with satsToMsat rather than a multiplication.
type PayInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
export const PayInvoiceDialog = ({ open, onOpenChange, walletId }: PayInvoiceDialogProps) => {
const { decode, pay } = useWalletOperations(walletId);
const [bolt11, setBolt11] = useState('');
const [decoded, setDecoded] = useState<DecodedInvoice | null>(null);
const [amountSats, setAmountSats] = useState('');
const [feeLimitSats, setFeeLimitSats] = useState('');
const close = () => {
setBolt11('');
setDecoded(null);
setAmountSats('');
setFeeLimitSats('');
onOpenChange(false);
};
const runDecode = async (ev: React.FormEvent) => {
ev.preventDefault();
const trimmed = bolt11.trim();
if (!trimmed) return;
const result = await decode.mutateAsync(trimmed);
setDecoded(result.decoded);
};
// A zero-amount invoice carries no amountMsat, so the payer names the figure.
const needsAmount = decoded != null && decoded.amountMsat == null;
const manualSats = Number(amountSats);
const feeLimit = Number(feeLimitSats);
const canPay = decoded != null && (!needsAmount || (Number.isFinite(manualSats) && manualSats > 0)) && !pay.isPending;
const runPay = async () => {
if (!decoded || !canPay) return;
await pay.mutateAsync({
bolt11: decoded.bolt11,
amountMsat: needsAmount ? satsToMsat(manualSats) : undefined,
feeLimitMsat: Number.isFinite(feeLimit) && feeLimit > 0 ? satsToMsat(feeLimit) : undefined,
});
close();
};
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Pay an invoice</DialogTitle>
<DialogDescription>
{decoded
? 'Check this before you pay — a lightning payment cannot be reversed.'
: 'Paste a BOLT11 invoice.'}
</DialogDescription>
</DialogHeader>
{decoded ? (
<div className="mt-4 space-y-4">
<dl className="space-y-2 rounded-lg border border-border bg-muted/40 p-3 text-xs">
<Row label="Amount">
{decoded.amountMsat == null ? (
<span className="text-muted-foreground">payer chooses</span>
) : (
<Amount msat={decoded.amountMsat} className="font-semibold" />
)}
</Row>
<Row label="Description">{decoded.description || <span className="text-muted-foreground">—</span>}</Row>
<Row label="Destination">
<code className="font-mono text-[11px]">{truncateMiddle(decoded.destination, 12, 8)}</code>
</Row>
<Row label="Expires">{formatTimestamp(decoded.timestamp + decoded.expiry)}</Row>
</dl>
{needsAmount && (
<div className="space-y-1.5">
<Label htmlFor="wallet-pay-amount">Amount to pay (sats)</Label>
<Input
id="wallet-pay-amount"
type="number"
min={1}
value={amountSats}
onChange={(ev) => setAmountSats(ev.target.value)}
className="w-40"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="wallet-pay-fee">Max routing fee (sats, optional)</Label>
<Input
id="wallet-pay-fee"
type="number"
min={0}
value={feeLimitSats}
placeholder="node default"
onChange={(ev) => setFeeLimitSats(ev.target.value)}
className="w-40"
/>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setDecoded(null)}>
Back
</Button>
<Button type="button" onClick={runPay} disabled={!canPay}>
{pay.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Zap className="mr-2 h-4 w-4" />}
Pay
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={runDecode} className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-pay-bolt11">Invoice</Label>
<Input
id="wallet-pay-bolt11"
value={bolt11}
spellCheck={false}
placeholder="lnbc…"
onChange={(ev) => setBolt11(ev.target.value)}
className="font-mono text-xs"
/>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={!bolt11.trim() || decode.isPending}>
{decode.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Decode
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
};
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-muted-foreground">{label}</dt>
<dd className="min-w-0 truncate text-right">{children}</dd>
</div>
);
@@ -1,94 +0,0 @@
import { useState } from 'react';
import { 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';
// The recovery phrase, shown exactly once.
//
// The sidecar returns `mnemonic` on the create response and only when IT generated the seed. There is no
// second chance without the passphrase (settings → export seed), so this modal cannot be dismissed by
// clicking away, pressing Escape, or anything short of ticking the box and pressing the button.
//
// The words arrive as a prop, live in this component's render only, and are never written to React Query,
// localStorage, sessionStorage, the URL, or a clipboard helper. Closing the modal is the only exit and it
// drops the string on the floor. There is deliberately no "copy" button: a seed on the clipboard is a seed
// in every clipboard manager on the machine.
type SeedBackupDialogProps = {
open: boolean;
mnemonic: string;
walletName: string;
onConfirmed: () => void;
};
export const SeedBackupDialog = ({ open, mnemonic, walletName, onConfirmed }: SeedBackupDialogProps) => {
const [acknowledged, setAcknowledged] = useState(false);
const words = mnemonic.trim().split(/\s+/);
const confirm = () => {
setAcknowledged(false);
onConfirmed();
};
return (
<Dialog open={open}>
{/* `[&>button]:hidden` drops the shared X — with no onOpenChange it would be inert anyway, and an
inert close button on the one dialog you must not dismiss is worse than no button. */}
<DialogContent
className="max-w-lg [&>button]:hidden"
onEscapeKeyDown={(ev) => ev.preventDefault()}
onPointerDownOutside={(ev) => ev.preventDefault()}
onInteractOutside={(ev) => ev.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TriangleAlert className="h-5 w-5 text-amber-500" />
Write down your recovery phrase
</DialogTitle>
<DialogDescription>
These {words.length} words are the only way to recover <span className="font-medium">{walletName}</span> if
this server is lost. Officer cannot show them again without your passphrase.
</DialogDescription>
</DialogHeader>
<ol className="mt-2 grid grid-cols-3 gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 sm:grid-cols-4">
{words.map((word, i) => (
<li key={`${i}-${word}`} className="flex items-baseline gap-1.5 font-mono text-xs">
<span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">{i + 1}</span>
<span className="font-medium">{word}</span>
</li>
))}
</ol>
<p className="text-xs text-muted-foreground">
Write them on paper, in order. Anyone who reads them can spend these coins, so do not photograph them, type
them into another device, or paste them anywhere.
</p>
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
<Checkbox className="mt-0.5" checked={acknowledged} onCheckedChange={(v) => setAcknowledged(v === true)} />
<span>
I have written this phrase down and stored it somewhere safe.
<span className="mt-0.5 block text-xs text-muted-foreground">
It will not be shown again after this dialog closes.
</span>
</span>
</label>
<DialogFooter>
<Button onClick={confirm} disabled={!acknowledged}>
I have written it down
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,387 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
// One input per BIP39 word, with dictionary autocomplete — the same shape Zeus and every other serious
// wallet uses for recovery, and for the same reason: a 24-word phrase typed into one freeform box is
// impossible to proofread, and a single mistyped word fails the checksum with no clue which one is wrong.
// Per-word boxes turn that into an immediately visible red border on word 17.
//
// SECRET IN FLIGHT. The words live in the parent's state and go straight into the request body. Nothing
// here persists, and every field carries the full set of autofill opt-outs (autoComplete="off" plus the
// 1Password/LastPass/Bitwarden ignore attributes) — a password manager silently capturing a seed phrase
// would be the single worst thing this component could do.
const WORDS_PER_ROW_HINT = 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4';
// Generous, and the list scrolls. A small cap silently hides real words — "fa" alone has 22 matches and
// "fan" is the twelfth, so a cap of 6 makes a perfectly valid word look like it is not in the dictionary.
// Truncation here must never be how the owner learns whether their seed word exists.
const MAX_SUGGESTIONS = 64;
/** BIP39 permits these lengths. 12 and 24 dominate, but a restore must not be blocked by the others. */
export const SEED_LENGTHS = [12, 15, 18, 21, 24] as const;
export type SeedLength = (typeof SEED_LENGTHS)[number];
const VALID = new Set<string>(wordlist);
/**
* Wordlist entries starting with `prefix`, shortest first (alphabetical within a length). Empty prefix
* yields nothing — suggestions need intent.
*
* Shortest-first matters more than it looks. A fully typed word is the shortest thing that can match
* itself, so it always lands at position 1 — you never hunt for confirmation that your word exists, and
* the default highlight is always the exact match rather than some longer word that merely shares a
* prefix. It also front-loads the short words that alphabetical order buries: "fan" is 12th of 22 under
* "fa" alphabetically, but 2nd by length.
*
* Collect-then-sort, never truncate-then-sort: capping during the scan would drop short matches that
* happen to sort late. Scanning all 2048 entries per keystroke is free.
*/
function suggest(prefix: string): string[] {
if (!prefix) return [];
const matches = wordlist.filter((w) => w.startsWith(prefix));
matches.sort((a, b) => a.length - b.length || a.localeCompare(b));
return matches.slice(0, MAX_SUGGESTIONS);
}
/**
* Split arbitrary pasted text into candidate words.
*
* Deliberately brutal: BIP39 English words are pure a-z, so everything else is separator. That makes the
* paste survive the shapes real backups actually arrive in — numbered lists ("1. fan 2. zoo"), commas,
* hard line breaks, non-breaking spaces, smart quotes from a notes app. Without this, a numbered paste
* silently becomes twenty-four "words" of digits and the owner is left staring at a wall of red.
*/
export function normalizePhrase(text: string): string[] {
return text
.normalize('NFKD')
.toLowerCase()
.replace(/[^a-z\s]/g, ' ')
.split(/\s+/)
.filter(Boolean);
}
export type PhraseProblem =
/** Boxes still blank (1-based, for display). */
| { kind: 'incomplete'; boxes: number[] }
/** Boxes holding something that is not a BIP39 word at all (1-based). */
| { kind: 'unknown'; boxes: number[] }
/** Not a permitted BIP39 length. Only reachable by pasting an odd count. */
| { kind: 'length'; count: number }
/** Every word is real and the count is legal, but the checksum says the phrase is not this phrase. */
| { kind: 'checksum' };
/** CRC32-Castagnoli (poly 0x82F63B78) — the polynomial LND's aezeed uses. Not the IEEE one. */
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;
}
/**
* Zeus (and anything else running an embedded LND) does NOT hand out BIP39 mnemonics. It hands out
* *aezeed* cipher seeds: 24 words drawn from the very same BIP39 English wordlist, so they are visually
* indistinguishable, but the trailing bits are a CRC32-Castagnoli checksum instead of a SHA-256 one.
* They can never validate as BIP39, no matter how carefully the owner proofreads.
*
* Officer imports these — the sidecar deciphers the AEZ ciphertext and uses the 16 bytes inside as the
* BIP32 master seed directly. But BIP39 validation still fails on them, so without this check the owner
* would be told "invalid checksum" for a phrase that is perfectly correct.
*
* This mirrors `isAezeedPhrase` in the sidecar (`wallet/aezeed/cipher-seed.ts`). It is duplicated rather
* than shared because it must run in the browser, where nothing under `src/servers` is reachable — and
* it is pure structure, no secret and no key material, so it is safe to ship client-side. The sidecar
* still decides independently; this only makes the form honest before the round trip.
*
* Layout: version(1) + ciphertext(23) + salt(5) + crc32c(4) = 33 bytes = 24 x 11 bits.
* Verified against LND's own published test vector.
*/
export function isAezeed(words: string[]): boolean {
if (words.length !== 24) return false;
let bits = '';
for (const word of words) {
const index = wordlist.indexOf(word);
if (index < 0) return false;
bits += index.toString(2).padStart(11, '0');
}
const bytes = Uint8Array.from(bits.match(/.{8}/g)!.map((b) => parseInt(b, 2)));
if (bytes[0] !== 0) return false; // aezeed CipherSeedVersion
const view = new DataView(bytes.buffer);
return crc32c(bytes.subarray(0, 29)) === view.getUint32(29, false);
}
/**
* What is wrong with the phrase, checked locally so the owner never spends a network round trip — or
* risks the form state — to learn they mistyped word 17. This mirrors what the sidecar does (BIP39
* checksum, else aezeed structure), just run earlier; the sidecar remains the authority and still
* rejects independently. Purely local: nothing is transmitted to validate.
*
* An aezeed phrase is NOT a problem — it is a supported second format, so it returns null here and the
* caller uses `isAezeed` separately to explain what it is looking at.
*/
export function phraseProblem(words: string[]): PhraseProblem | null {
const trimmed = words.map((w) => w.trim().toLowerCase());
const blank = trimmed.map((w, i) => (w ? -1 : i + 1)).filter((i) => i > 0);
if (blank.length) return { kind: 'incomplete', boxes: blank };
const unknown = trimmed.map((w, i) => (VALID.has(w) ? -1 : i + 1)).filter((i) => i > 0);
if (unknown.length) return { kind: 'unknown', boxes: unknown };
if (!(SEED_LENGTHS as readonly number[]).includes(trimmed.length)) {
return { kind: 'length', count: trimmed.length };
}
if (validateMnemonic(trimmed.join(' '), wordlist)) return null;
// Checked before the generic failure: an aezeed seed is a different supported format, not a typo.
if (isAezeed(trimmed)) return null;
return { kind: 'checksum' };
}
/**
* The words are all real and the checksum still fails — so look for the one mistake that is invisible
* when you proofread: right words, wrong order.
*
* Wallets print backups in a grid, and plenty of them run the phrase DOWN the columns (1-12 left, 13-24
* right). Read ACROSS instead and you get every word present, every word spelled correctly, every box
* green, and a phrase that is simply not yours. Nothing in a word-by-word review can catch it, because
* every word individually is fine.
*
* That misreading is exactly a matrix transpose, so it is cheap to test: for each rows x cols factoring
* of the length, un-transpose and re-check. A handful of validations, and it either finds the owner's
* real phrase or rules the theory out. Returns null when the order is not the problem.
*
* NOT a brute-force search. Trying every single-word substitution is useless here — the checksum is only
* 8 bits, so ~8 different words validate at EVERY position and it can localise nothing.
*/
export function findOrderFix(words: string[]): { rows: number; cols: number; words: string[] } | null {
const n = words.length;
for (let rows = 2; rows < n; rows++) {
if (n % rows) continue;
const cols = n / rows;
const fixed = new Array<string>(n);
for (let i = 0; i < rows; i++) for (let j = 0; j < cols; j++) fixed[j * rows + i] = words[i * cols + j]!;
if (validateMnemonic(fixed.join(' '), wordlist)) return { rows, cols, words: fixed };
}
return null;
}
type SeedPhraseInputProps = {
words: string[];
onChange: (words: string[]) => void;
/**
* Fired when a pasted phrase is itself a valid BIP39 length that differs from the current box count,
* so the parent can resize instead of making the owner notice the mismatch and fix the toggle by hand.
*/
onLengthDetected?: (count: number) => void;
disabled?: boolean;
};
export const SeedPhraseInput = ({ words, onChange, onLengthDetected, disabled }: SeedPhraseInputProps) => {
const refs = useRef<(HTMLInputElement | null)[]>([]);
const [focused, setFocused] = useState<number | null>(null);
const [highlight, setHighlight] = useState(0);
/** Whether the owner moved the highlight by hand — the difference between "chose that word" and
* "happened to have it highlighted". Reset on every keystroke and on leaving the box. */
const [arrowed, setArrowed] = useState(false);
const suggestions = useMemo(() => (focused == null ? [] : suggest(words[focused] ?? '')), [focused, words]);
// The list shrinks as you type, so a stale index can point past the end. Clamp at read time rather than
// trying to keep `highlight` in sync with every keystroke.
const safeHighlight = suggestions.length ? Math.min(highlight, suggestions.length - 1) : 0;
const highlightRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
highlightRef.current?.scrollIntoView({ block: 'nearest' });
}, [safeHighlight, focused]);
const setWord = (index: number, value: string) => {
const next = [...words];
next[index] = value;
onChange(next);
};
const focus = (index: number) => {
const el = refs.current[index];
if (!el) return;
el.focus();
el.select();
};
/** Accept a suggestion (or whatever is typed) and move on. The last box stays put. */
const commit = (index: number, value: string) => {
setWord(index, value);
setHighlight(0);
setArrowed(false);
if (index < words.length - 1) requestAnimationFrame(() => focus(index + 1));
};
// Pasting the whole phrase into any box is how people actually restore. Spread it across the boxes
// from here on rather than dumping 24 words into one field.
const handlePaste = (index: number, ev: React.ClipboardEvent<HTMLInputElement>) => {
const parts = normalizePhrase(ev.clipboardData.getData('text'));
if (parts.length < 2) return;
ev.preventDefault();
// A whole phrase pasted into box 1 of the wrong-size grid: resize rather than truncate. Only from the
// first box — pasting a fragment mid-phrase should never resize the form.
if (index === 0 && parts.length !== words.length && (SEED_LENGTHS as readonly number[]).includes(parts.length)) {
onLengthDetected?.(parts.length);
onChange(parts);
requestAnimationFrame(() => focus(parts.length - 1));
return;
}
const next = [...words];
for (let i = 0; i < parts.length && index + i < next.length; i++) next[index + i] = parts[i]!;
onChange(next);
const landed = Math.min(index + parts.length, next.length - 1);
requestAnimationFrame(() => focus(landed));
};
const handleKeyDown = (index: number, ev: React.KeyboardEvent<HTMLInputElement>) => {
const current = words[index] ?? '';
if (ev.key === 'ArrowDown' && suggestions.length) {
ev.preventDefault();
setArrowed(true);
setHighlight((safeHighlight + 1) % suggestions.length);
return;
}
if (ev.key === 'ArrowUp' && suggestions.length) {
ev.preventDefault();
setArrowed(true);
setHighlight((safeHighlight - 1 + suggestions.length) % suggestions.length);
return;
}
// Space and Enter advance. Tab does NOT accept — tab is navigation, and hijacking it to mean
// "accept" makes it impossible to skip a box you meant to come back to.
//
// A suggestion is only substituted for what you typed when the choice is UNAMBIGUOUS: you arrowed to
// it deliberately, or it is the single remaining match, or you already typed the whole word. Typing
// "fa" and hitting space must NOT silently store "fabric" — for a seed phrase, a wrong-but-valid word
// is the worst possible outcome, because it passes every check this component can do and only fails
// later as an opaque checksum error with no clue which box is at fault. Ambiguous input is left
// exactly as typed, where the missing checkmark makes it obvious.
if (ev.key === ' ' || ev.key === 'Enter') {
if (!current) return;
ev.preventDefault();
const unambiguous = arrowed || suggestions.length === 1 || VALID.has(current);
commit(index, unambiguous ? (suggestions[safeHighlight] ?? current) : current);
return;
}
// Backspace out of an empty box steps back, so correcting a run of words never needs the mouse.
if (ev.key === 'Backspace' && !current && index > 0) {
ev.preventDefault();
focus(index - 1);
}
};
return (
<div className={cn('grid gap-2', WORDS_PER_ROW_HINT)}>
{words.map((word, index) => {
const valid = VALID.has(word);
const invalid = word.length > 0 && !valid;
// Shown whenever the box has focus and anything matches — NOT gated on the word being invalid.
// "fan" is both a complete word and a prefix of "fancy"/"fantasy"; hiding the list the moment the
// text happens to be valid strands the owner mid-word with no way to see the longer options.
const showSuggestions = focused === index && suggestions.length > 0;
return (
<div key={index} className="relative">
<div
className={cn(
'flex h-10 items-center rounded-md border bg-background pl-2 focus-within:ring-2 focus-within:ring-ring',
invalid ? 'border-destructive' : 'border-input',
)}
>
<span className="w-5 shrink-0 select-none text-right font-mono text-[11px] text-muted-foreground">
{index + 1}
</span>
<input
ref={(el) => {
refs.current[index] = el;
}}
value={word}
disabled={disabled}
onChange={(ev) => {
setWord(index, ev.target.value.trim().toLowerCase());
// Typing invalidates a deliberate pick — the list underneath just changed.
setHighlight(0);
setArrowed(false);
}}
onFocus={() => {
setFocused(index);
setHighlight(0);
setArrowed(false);
}}
onBlur={() => setFocused((f) => (f === index ? null : f))}
onPaste={(ev) => handlePaste(index, ev)}
onKeyDown={(ev) => handleKeyDown(index, ev)}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
data-1p-ignore
data-lpignore="true"
data-bwignore
className="h-full w-full bg-transparent px-2 font-mono text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
/>
{/* Positive confirmation, not just the absence of red: a typo that happens to be another
real word is caught by reading the phrase back, but a typo that is NOT a word should be
obvious without having to compare against the dropdown. */}
{valid && <Check className="mr-2 h-3.5 w-3.5 shrink-0 text-emerald-500" />}
</div>
{showSuggestions && (
// onMouseDown, not onClick: blur fires first on click and would unmount the list mid-press.
<ul className="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-input bg-popover shadow-md">
{suggestions.map((s, i) => (
<li key={s}>
<button
type="button"
tabIndex={-1}
// Keeps the highlighted row visible while arrowing through a long list.
ref={i === safeHighlight ? highlightRef : undefined}
onMouseDown={(ev) => {
ev.preventDefault();
commit(index, s);
}}
onMouseEnter={() => setHighlight(i)}
className={cn(
'flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left font-mono text-sm',
i === safeHighlight ? 'bg-accent text-accent-foreground' : 'text-foreground',
)}
>
<span>{s}</span>
{/* The exact match, called out — this is the "yes, that word really exists" signal
when the typed word is also a prefix of longer ones (fan / fancy / fantasy). */}
{s === word && <Check className="h-3.5 w-3.5 shrink-0 text-emerald-500" />}
</button>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
);
};
@@ -1,132 +0,0 @@
import { useState } from 'react';
import { Loader2, LockOpen } 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';
// Unlock, and nothing else. The wallet spends most of its life locked and that is the intended state —
// this dialog exists for the moment before a signature, not as a gate on the app.
//
// The unlock window is capped by the deployment's unlockTtlSec; a caller may ask for less but never more,
// so the shorter options here are real and the longest is simply "whatever the sidecar allows".
type UnlockDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
walletId: number;
walletName: string;
maxTtlSec: number;
};
/** Offered windows, filtered to those the deployment actually permits. */
const TTL_CHOICES = [
{ sec: 60, label: '1 minute' },
{ sec: 300, label: '5 minutes' },
{ sec: 900, label: '15 minutes' },
{ sec: 3600, label: '1 hour' },
];
export const UnlockDialog = ({ open, onOpenChange, walletId, walletName, maxTtlSec }: UnlockDialogProps) => {
const { unlock } = useLockActions(walletId);
// The passphrase lives HERE and nowhere else: no localStorage, no sessionStorage, no React Query
// cache, no URL. It goes straight into the POST body and this state is cleared the moment the request
// settles, on success and on failure alike.
const [passphrase, setPassphrase] = useState('');
const [ttlSec, setTtlSec] = useState<number>(() => Math.min(300, maxTtlSec));
const choices = TTL_CHOICES.filter((c) => c.sec <= maxTtlSec);
const close = () => {
setPassphrase('');
onOpenChange(false);
};
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!passphrase) return;
try {
await unlock.mutateAsync({ passphrase, ttlSec });
close();
} finally {
// Cleared even when the unlock failed — a wrong passphrase left sitting in an input is still a
// passphrase sitting in memory attached to a mounted component.
setPassphrase('');
}
};
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>Unlock {walletName}</DialogTitle>
<DialogDescription>
The seed is decrypted in the wallet sidecar for the window you choose, then wiped. Balances and history do
not need this — only signing does.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-unlock-pass">Passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-unlock-pass"
type="password"
autoFocus
autoComplete="off"
value={passphrase}
onChange={(ev) => setPassphrase(ev.target.value)}
/>
</div>
{choices.length > 0 && (
<div className="space-y-1.5">
<Label>Keep unlocked for</Label>
<div className="flex flex-wrap gap-1.5">
{choices.map(({ sec, label }) => (
<button
key={sec}
type="button"
onClick={() => setTtlSec(sec)}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
ttlSec === sec
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={!passphrase || unlock.isPending}>
{unlock.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LockOpen className="mr-2 h-4 w-4" />
)}
Unlock
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -1,151 +0,0 @@
import type { InvoiceState, PaymentStatus } from './shared';
// Display formatting for the Wallet panels.
//
// THE MILLISATOSHI RULE. A msat value arrives as a decimal string because 2.1e18 does not fit in a
// double. Every msat formatter below works on the digit string — no Number(), no parseInt, no
// BigInt round-trip through a float. Widening one to a number would silently corrupt large amounts,
// and the corruption would only show up on the one payment that mattered.
export type AmountUnit = 'sats' | 'btc';
const SATS_PER_BTC = 100_000_000;
/** Thousands separators on a bare digit string, left to right in groups of three from the end. */
function groupDigits(digits: string): string {
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* Split a non-negative digit string by 10^places without ever building a number.
* `'1234', 3` → `{ whole: '1', frac: '234' }`.
*/
function splitByPowerOfTen(digits: string, places: number): { whole: string; frac: string } {
const clean = digits.replace(/^0+(?=\d)/, '');
if (places === 0) return { whole: clean, frac: '' };
const padded = clean.padStart(places + 1, '0');
return { whole: padded.slice(0, -places), frac: padded.slice(-places) };
}
const trimZeros = (frac: string) => frac.replace(/0+$/, '');
// ── satoshis (numbers) ────────────────────────────────────────────────────────────────────────────
/** Sats with thousands separators. Negative amounts keep their sign — a spend should read as one. */
export function formatSats(sats: number | null | undefined): string {
if (sats == null || !Number.isFinite(sats)) return '—';
const neg = sats < 0;
return `${neg ? '-' : ''}${groupDigits(String(Math.abs(Math.trunc(sats))))}`;
}
/** BTC from sats, trailing zeros trimmed but never below two decimals, so amounts stay column-aligned. */
export function formatBtc(sats: number | null | undefined): string {
if (sats == null || !Number.isFinite(sats)) return '—';
const neg = sats < 0;
const abs = Math.abs(Math.trunc(sats));
const whole = Math.floor(abs / SATS_PER_BTC);
const frac = String(abs % SATS_PER_BTC).padStart(8, '0');
const shown = trimZeros(frac).padEnd(2, '0');
return `${neg ? '-' : ''}${groupDigits(String(whole))}.${shown}`;
}
/** The one entry point the views call, so a unit toggle flips every amount on screen at once. */
export function formatAmount(sats: number | null | undefined, unit: AmountUnit): string {
if (sats == null || !Number.isFinite(sats)) return '—';
return unit === 'btc' ? `${formatBtc(sats)} BTC` : `${formatSats(sats)} sats`;
}
// ── millisatoshis (strings) ───────────────────────────────────────────────────────────────────────
/** True when the string is a decimal integer we can format; anything else is passed through verbatim. */
const isDecimal = (value: string) => /^\d+$/.test(value);
/**
* A msat string as sats, keeping the sub-sat remainder when there is one. String arithmetic throughout —
* see the rule at the top of this file.
*/
export function formatMsatAsSats(msat: string | null | undefined): string {
if (msat == null) return '—';
const neg = msat.startsWith('-');
const digits = neg ? msat.slice(1) : msat;
if (!isDecimal(digits)) return msat;
const { whole, frac } = splitByPowerOfTen(digits, 3);
const remainder = trimZeros(frac);
return `${neg ? '-' : ''}${groupDigits(whole)}${remainder ? `.${remainder}` : ''}`;
}
/** A msat string as BTC — 10^11 msat to the bitcoin. */
export function formatMsatAsBtc(msat: string | null | undefined): string {
if (msat == null) return '—';
const neg = msat.startsWith('-');
const digits = neg ? msat.slice(1) : msat;
if (!isDecimal(digits)) return msat;
const { whole, frac } = splitByPowerOfTen(digits, 11);
const shown = trimZeros(frac).padEnd(2, '0');
return `${neg ? '-' : ''}${groupDigits(whole)}.${shown}`;
}
export function formatMsat(msat: string | null | undefined, unit: AmountUnit): string {
if (msat == null) return '—';
return unit === 'btc' ? `${formatMsatAsBtc(msat)} BTC` : `${formatMsatAsSats(msat)} sats`;
}
/** Sats → msat, for request bodies. Multiplying a string by 1000 is three appended zeros. */
export function satsToMsat(sats: number): string {
return `${Math.trunc(Math.abs(sats))}000`;
}
// ── everything else ───────────────────────────────────────────────────────────────────────────────
/** Unix seconds; 0 and null both mean "never" rather than 1970. */
export function formatTimestamp(unixSeconds: number | null | undefined): string {
if (!unixSeconds || unixSeconds <= 0) return '—';
const d = new Date(unixSeconds * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** A countdown as mm:ss — the unlock window, where the seconds genuinely matter. */
export function formatCountdown(seconds: number | null | undefined): string {
if (seconds == null || seconds <= 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
/** txids, addresses and pubkeys are unreadable in full and unmistakable at both ends. */
export function truncateMiddle(value: string | null | undefined, head = 10, tail = 8): string {
if (!value) return '—';
if (value.length <= head + tail + 1) return value;
return `${value.slice(0, head)}…${value.slice(-tail)}`;
}
export function formatConfirmations(confirmations: number): string {
if (confirmations <= 0) return 'unconfirmed';
if (confirmations === 1) return '1 conf';
if (confirmations >= 6) return '6+ confs';
return `${confirmations} confs`;
}
export const INVOICE_TONES: Record<InvoiceState, string> = {
open: 'bg-blue-500/10 text-blue-500',
settled: 'bg-emerald-500/10 text-emerald-500',
accepted: 'bg-amber-500/10 text-amber-500',
canceled: 'bg-muted text-muted-foreground',
expired: 'bg-muted text-muted-foreground',
};
export const PAYMENT_TONES: Record<PaymentStatus, string> = {
pending: 'bg-amber-500/10 text-amber-500',
succeeded: 'bg-emerald-500/10 text-emerald-500',
failed: 'bg-destructive/10 text-destructive',
};
// `copyToClipboard` used to be re-exported from here, wrapping navigator.clipboard with a try/catch. The
// helper in `helpers/clipboard` now does that and more — it falls back to execCommand on an insecure
// origin, where navigator.clipboard does not exist at all — so callers import it from there directly.
//
// Removed rather than left as a pass-through: the wrapper had become `copyToClipboard` calling
// `copyToClipboard`, which is a stack overflow on any Wallet copy button. Every re-export is a chance for
// exactly that, and this file is about formatting.
@@ -1,25 +0,0 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { PanelLeft, LayoutGrid } from 'lucide-react';
import { WalletNav } from './WalletNav';
import { WalletView } from './WalletView';
import { WalletViewHeader } from './WalletViewHeader';
export { WalletNav, WalletView };
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'wallet-nav',
name: 'Wallet',
icon: PanelLeft,
component: WalletNav,
availableOnPanel: false,
},
{
key: 'wallet-view',
name: 'Wallet',
icon: LayoutGrid,
component: WalletView,
header: WalletViewHeader,
availableOnPanel: false,
},
];
@@ -1,304 +0,0 @@
// Shared types/constants for the /wallet workspace panels.
//
// The wire shapes mirror src/servers/sidecar/wallet/types.ts and the WalletSummary projection in
// src/databases/officer_db/src/wallet/queries.ts. They are restated here rather than imported because the
// officerdev workspace has no path into src/servers — pulling the sidecar's module graph into the browser
// bundle would drag bitcoinjs-lib and the key handling along with it, which is exactly what must never
// reach the client. Keep this file in step with those two by hand; the field names are identical on
// purpose so a diff is a grep.
//
// UNITS — the one rule that matters. Satoshis are `number` (2.1e15, comfortably inside 2^53).
// Millisatoshis are decimal STRINGS (2.1e18 overflows a double) and must never be parsed into a JS
// number. format.ts does msat arithmetic on the digit string; use it rather than reaching for Number().
export const WALLET_SECTIONS = [
{ id: 'overview', label: 'Overview' },
{ id: 'receive', label: 'Receive' },
{ id: 'send', label: 'Send' },
{ id: 'transactions', label: 'Transactions' },
{ id: 'coins', label: 'Coins' },
{ id: 'lightning', label: 'Lightning' },
{ id: 'settings', label: 'Settings' },
] as const;
export type WalletSectionId = (typeof WALLET_SECTIONS)[number]['id'];
/** Where /wallet lands, and where an unrecognised section redirects to. */
export const DEFAULT_WALLET_SECTION: WalletSectionId = 'overview';
export const isWalletSection = (value: string | undefined): value is WalletSectionId =>
WALLET_SECTIONS.some((s) => s.id === value);
/**
* The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart.
* Which wallet is open rides along as `?wallet=<id>` — a query param rather than a second path segment,
* because it is orthogonal to the section and every panel reads it independently.
*/
export const walletSectionPath = (id: WalletSectionId, walletId?: number | null) =>
walletId == null ? `/wallet/${id}` : `/wallet/${id}?wallet=${walletId}`;
/** The search-param name holding the open wallet. Never holds anything secret — just a row id. */
export const WALLET_PARAM = 'wallet';
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest';
/** Mirrors `Capability` in the sidecar. The UI shows a control only when its capability is present. */
export type Capability =
| 'onchainReceive'
| 'onchainSend'
| 'coinControl'
| 'psbt'
| 'bumpFee'
| 'sweep'
| 'accounts'
| 'lightningReceive'
| 'lightningSend'
| 'keysend'
| 'customPreimages'
| 'offers'
| 'channels'
| 'peers'
| 'routing'
| 'signMessage';
/** The browser-safe wallet projection — no config, no seed envelope, ever. */
export type WalletSummary = {
id: number;
name: string;
kind: BackendKind;
network: string;
fingerprint: string | null;
xpubs: Record<string, string> | null;
defaultBip: number;
isActive: boolean;
/** Whether this wallet holds a seed at all — i.e. whether unlock/lock apply to it. */
hasSeed: boolean;
createdAt: string;
};
export type WalletConfig = {
network: BitcoinNetwork;
unlockTtlSec: number;
/** Wallet creation is refused without it, so the UI blocks the form rather than failing on submit. */
storeKeyConfigured: boolean;
};
export type LockState = {
hasSeed: boolean;
unlocked: boolean;
secondsRemaining: number;
};
export type NodeInfo = {
kind: BackendKind;
pubkey: string | null;
alias: string | null;
version: string | null;
network: BitcoinNetwork;
blockHeight: number | null;
synced: boolean;
};
export type Balances = {
onchainConfirmed: number;
onchainUnconfirmed: number;
/**
* The part of `onchainConfirmed` the owner has frozen. Already counted in it, not deducted — the coins
* are still the wallet's; freezing is a spending policy. Subtract it to get what a send can actually
* use, which is what "Spendable" has to mean or a max-value send fails for no visible reason.
*/
onchainFrozen: number;
/** Null when the backend has no channels of its own. */
lightningBalance: number | null;
lightningInbound: number | null;
};
/**
* How old the numbers alongside this are, from a backend that caches its view of the chain. Null from a
* node backend, which answers from its own live state and has no age to report.
*
* `syncedAt: null` with a `lastError` is the case worth rendering carefully: the wallet has never been
* read successfully, so a zero balance means "unknown", not "empty".
*/
export type SyncState = {
/** Unix ms of the last successful chain read. */
syncedAt: number | null;
stale: boolean;
lastError: string | null;
/**
* Whether the chain source can search the chain from scratch at all — false for Esplora, which reads
* every address on every refresh and has nothing to catch up on. Gate the control on this, NOT on
* `rescan === null`: that is also what a source reports before its first scan.
*/
canRescan: boolean;
/** A deep rescan in flight, or the last one's outcome. Null from a chain source that cannot rescan. */
rescan: RescanState | null;
};
/**
* The node searching its whole UTXO set for this wallet's coins.
*
* Only ever non-null on NBXplorer, and it exists because an imported wallet on a freshly-tracked xpub
* reads as a confident zero: nothing errors, the balance is just empty until the node has looked. Four
* script variants, one at a time, minutes not seconds — which is precisely why it has to be on screen.
*/
export type RescanState = {
running: boolean;
done: number;
total: number;
current: AddressType | null;
found: number;
startedAt: number;
finishedAt: number | null;
error: string | null;
};
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
/** What each script type is actually called out loud, for anything the owner reads rather than greps. */
export const ADDRESS_TYPE_LABELS: Record<AddressType, string> = {
p2wpkh: 'native segwit',
'p2sh-p2wpkh': 'wrapped segwit',
p2pkh: 'legacy',
p2tr: 'taproot',
};
export type OnchainTx = {
txid: string;
/** Net effect on this wallet in sats — negative for a spend. */
amount: number;
feeSats: number | null;
blockHeight: number | null;
timestamp: number | null;
confirmations: number;
label: string | null;
destAddresses: string[];
rawHex: string | null;
};
/** The sidecar overlays Officer's own `frozen` flag and address label onto the backend's UTXO. */
export type Utxo = {
txid: string;
vout: number;
amountSats: number;
address: string;
addressType: AddressType | null;
confirmations: number;
derivationPath: string | null;
frozen: boolean;
label: string | null;
};
export type FeeEstimates = {
fastestFee: number;
halfHourFee: number;
hourFee: number;
economyFee: number;
minimumFee: number;
};
export type SendCoinsResult = { txid: string; feeSats: number; rawHex: string | null };
export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired';
export type Invoice = {
paymentHash: string;
bolt11: string;
amountMsat: string | null;
amountPaidMsat: string | null;
memo: string | null;
state: InvoiceState;
createdAt: number;
expiresAt: number | null;
settledAt: number | null;
preimage: string | null;
isKeysend: boolean;
isAmp: 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;
failureReason: string | null;
};
export type Channel = {
channelId: string;
channelPoint: string | null;
remotePubkey: string;
remoteAlias: string | null;
capacitySats: number;
localBalanceSats: number;
remoteBalanceSats: number;
active: boolean;
private: boolean;
status: string;
};
export type Peer = { pubkey: string; address: string; alias: string | null; inbound: boolean };
// ── Capability-driven section visibility ──────────────────────────────────────────────────────────
/**
* Which sections a backend can actually serve. Anything not listed here is hidden from the nav rather
* than rendered as a control that returns 501 — see the sidecar's `requireCap`.
*
* `overview`, `transactions` and `settings` are unconditional: getInfo/getBalances/getTransactions are
* on the base interface with no capability guard, and settings is wallet lifecycle, not a backend call.
*/
export function sectionAvailable(section: WalletSectionId, caps: readonly Capability[]): boolean {
const has = (c: Capability) => caps.includes(c);
switch (section) {
case 'receive':
return has('onchainReceive') || has('lightningReceive');
case 'send':
return has('onchainSend') || has('lightningSend');
case 'coins':
return has('coinControl');
case 'lightning':
return has('lightningReceive') || has('lightningSend') || has('channels') || has('peers');
default:
return true;
}
}
export const KIND_LABELS: Record<BackendKind, string> = {
onchain: 'On-chain (self-custodial)',
lnd: 'LND',
'cln-rest': 'Core Lightning',
lndhub: 'LNDHub',
nwc: 'Nostr Wallet Connect',
};
/** Networks other than mainnet get a visible badge — sending testnet coins by mistake is a bad day. */
export const NETWORK_TONES: Record<string, string> = {
bitcoin: 'bg-amber-500/15 text-amber-600',
testnet: 'bg-emerald-500/15 text-emerald-600',
signet: 'bg-violet-500/15 text-violet-600',
regtest: 'bg-slate-500/15 text-slate-600',
};
@@ -1,34 +0,0 @@
import type { AmountUnit } from './format';
import { useGlobal } from 'hooks/useGlobal';
// sats ⇄ BTC, shared across every wallet panel.
//
// A display preference, not a selection — it names no entity and belongs in no URL (see the audit's
// "view toggles over tiny fixed sets"). It lives in useGlobal so the nav and the view agree instantly,
// and is mirrored to localStorage so it survives a reload.
const STORAGE_KEY = 'wallet:amount-unit';
const initial = (): AmountUnit => {
try {
return localStorage.getItem(STORAGE_KEY) === 'btc' ? 'btc' : 'sats';
} catch {
return 'sats';
}
};
export function useAmountUnit() {
const [unit, setUnit] = useGlobal<AmountUnit>('WALLET_AMOUNT_UNIT', initial);
const toggle = () => {
const next: AmountUnit = unit === 'sats' ? 'btc' : 'sats';
setUnit(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
/* private mode — the preference just doesn't persist */
}
};
return { unit, toggle };
}
@@ -1,46 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router';
// Coin control selection, held in `?coins=txid:vout,txid:vout`.
//
// The Coins section picks the inputs and the Send section spends them — two panels that must agree on a
// set. The URL is how they agree (docs/navigation-audit.md): no channel, no shared store, and the
// selection survives a reload and can be handed to someone as a link.
//
// Outpoints are public transaction data, so there is nothing sensitive about putting them in an address
// bar — unlike anything else this app handles.
const PARAM = 'coins';
const OUTPOINT = /^[0-9a-f]{64}:\d+$/i;
export function useCoinSelection() {
const [params, setParams] = useSearchParams();
const raw = params.get(PARAM) ?? '';
const selected = useMemo(() => raw.split(',').filter((o) => OUTPOINT.test(o)), [raw]);
const write = useCallback(
(next: string[]) => {
setParams(
(prev) => {
const updated = new URLSearchParams(prev);
if (next.length === 0) updated.delete(PARAM);
else updated.set(PARAM, next.join(','));
return updated;
},
{ replace: true },
);
},
[setParams],
);
const toggle = useCallback(
(outpoint: string) =>
write(selected.includes(outpoint) ? selected.filter((o) => o !== outpoint) : [...selected, outpoint]),
[selected, write],
);
const clear = useCallback(() => write([]), [write]);
return { selected, toggle, clear, set: write };
}
@@ -1,47 +0,0 @@
import { useEffect, useState } from 'react';
import { useLockState } from './useWalletData';
// The unlock window, as a second-by-second countdown.
//
// The sidecar's `secondsRemaining` is a snapshot taken when the request was served, and re-polling once a
// second to animate a clock would be absurd. So the poll re-syncs the truth every ten seconds and this
// hook interpolates between syncs from React Query's `dataUpdatedAt`, which is the wall time the snapshot
// arrived. Drift is bounded by the poll interval and always errs towards showing LESS time than remains —
// a countdown that overstates the window is the one that surprises you mid-signature.
//
// When it reaches zero the UI flips to locked immediately rather than waiting for the next poll to agree.
// Nothing else is invalidated when that happens: every read in this app works fine against a locked
// wallet, and blowing the caches away would make an expiring timer look like a connection failure.
export type LockCountdown = {
/** Null while the first lock-state request is in flight, or when no wallet is selected. */
hasSeed: boolean | null;
unlocked: boolean;
secondsRemaining: number;
isLoading: boolean;
};
export function useLockCountdown(walletId: number | null): LockCountdown {
const { lock, dataUpdatedAt, isLoading } = useLockState(walletId);
const [now, setNow] = useState(() => Date.now());
const ticking = lock?.unlocked === true;
useEffect(() => {
if (!ticking) return;
const timer = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(timer);
}, [ticking]);
if (!lock) return { hasSeed: null, unlocked: false, secondsRemaining: 0, isLoading };
const elapsed = dataUpdatedAt ? Math.floor((now - dataUpdatedAt) / 1000) : 0;
const secondsRemaining = Math.max(0, lock.secondsRemaining - Math.max(0, elapsed));
return {
hasSeed: lock.hasSeed,
unlocked: lock.unlocked && secondsRemaining > 0,
secondsRemaining,
isLoading,
};
}
@@ -1,39 +0,0 @@
import type { WalletSummary } from './shared';
import { useSearchParams } from 'react-router';
import { WALLET_PARAM } from './shared';
import { useWallets } from './useWalletData';
// Which wallet is open lives in `?wallet=<id>`, read independently by every panel — never passed between
// them over a channel. See docs/navigation-audit.md.
//
// With no param, the sidecar's own `isActive` wallet wins, then the first in the list. That fallback is a
// display default, not a selection: the URL stays bare so a shared /wallet link means "whatever is active
// right now" rather than pinning whichever wallet the sender happened to have open.
export type SelectedWallet = {
walletId: number | null;
wallet: WalletSummary | null;
wallets: WalletSummary[];
/** True when the id came from the URL rather than the active-wallet fallback. */
isPinned: boolean;
isLoading: boolean;
};
export function useSelectedWallet(): SelectedWallet {
const [params] = useSearchParams();
const { wallets, isLoading } = useWallets();
const raw = params.get(WALLET_PARAM);
const pinnedId = raw && /^\d+$/.test(raw) ? Number(raw) : null;
const pinned = pinnedId == null ? null : (wallets.find((w) => w.id === pinnedId) ?? null);
const fallback = wallets.find((w) => w.isActive) ?? wallets[0] ?? null;
const wallet = pinned ?? fallback;
return {
walletId: wallet?.id ?? null,
wallet,
wallets,
isPinned: pinned != null,
isLoading,
};
}
@@ -1,558 +0,0 @@
import type {
Balances,
BackendKind,
Capability,
Channel,
DecodedInvoice,
FeeEstimates,
Invoice,
LockState,
NodeInfo,
OnchainTx,
Payment,
Peer,
RescanState,
SendCoinsResult,
SyncState,
Utxo,
WalletConfig,
WalletSummary,
} from './shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
// Data layer for the /wallet panels. Everything talks to the officer-wallet sidecar through the
// /api/wallet auth proxy, which holds no key material of its own.
//
// THE WALLET IS USUALLY LOCKED, AND THAT IS THE NORMAL STATE. Every read below — balances, history,
// receive addresses, coin control, invoices — works against a locked wallet, because a locked wallet is
// still a watch-only wallet. Only signing needs the seed. So nothing here is gated on the lock, and no
// query is invalidated when the unlock window expires: the numbers on screen do not become wrong.
//
// PASSPHRASES ARE NEVER CACHED. The unlock/export/delete/change mutations take the passphrase as a call
// argument and hand it straight to the POST body. React Query stores mutation *variables* on the
// mutation object, so these are deliberately fire-and-forget: nothing here keeps a reference after the
// request resolves, and no passphrase is ever a query key or a query result.
const ROOT_KEY = ['wallet'] as const;
/** Balances move with the chain; a 20s poll is live enough without hammering Esplora. */
const BALANCE_POLL_MS = 20_000;
/** The lock countdown is rendered locally from `secondsRemaining`; this only re-syncs the truth. */
const LOCK_POLL_MS = 10_000;
/** History and coins are cheaper to refresh on demand than to poll hard. */
const HISTORY_POLL_MS = 60_000;
/** While a rescan runs, the balance response doubles as its progress feed — poll it like one. */
const RESCAN_POLL_MS = 4_000;
const EMPTY_WALLETS: WalletSummary[] = [];
const EMPTY_CAPS: Capability[] = [];
const EMPTY_TXS: OnchainTx[] = [];
const EMPTY_UTXOS: Utxo[] = [];
const EMPTY_INVOICES: Invoice[] = [];
const EMPTY_PAYMENTS: Payment[] = [];
const EMPTY_CHANNELS: Channel[] = [];
const EMPTY_PEERS: Peer[] = [];
const base = (id: number) => `/wallet/_officer/wallets/${id}`;
// ── deployment + wallet list ──────────────────────────────────────────────────────────────────────
export function useWalletConfig() {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'config'] as const,
queryFn: () => get<WalletConfig>('/wallet/_officer/config'),
// Network and store-key config change only with a sidecar restart.
staleTime: 5 * 60_000,
});
return { config: query.data ?? null, isLoading: query.isLoading, error: query.error };
}
/** The wallet list does not poll — it only changes when the owner creates, renames or deletes one. */
export function useWallets() {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'wallets'] as const,
queryFn: () => get<{ wallets: WalletSummary[] }>('/wallet/_officer/wallets'),
staleTime: 30_000,
});
return {
wallets: query.data?.wallets ?? EMPTY_WALLETS,
isLoading: query.isLoading,
error: query.error,
};
}
// ── per-wallet reads ──────────────────────────────────────────────────────────────────────────────
/**
* The lock state, polled. The countdown a user sees is interpolated locally in useLockCountdown — this
* query is the periodic re-sync, not the clock.
*/
export function useLockState(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'lock-state', walletId] as const,
queryFn: () => get<LockState>(`${base(walletId!)}/lock-state`),
enabled: walletId != null,
refetchInterval: LOCK_POLL_MS,
staleTime: LOCK_POLL_MS - 1_000,
});
return { lock: query.data ?? null, dataUpdatedAt: query.dataUpdatedAt, isLoading: query.isLoading };
}
export function usePermissions(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'capabilities', walletId] as const,
queryFn: () => get<{ kind: BackendKind; capabilities: Capability[] }>(`${base(walletId!)}/capabilities`),
enabled: walletId != null,
// A backend's capability set is fixed for the life of the wallet.
staleTime: 10 * 60_000,
});
return {
kind: query.data?.kind ?? null,
capabilities: query.data?.capabilities ?? EMPTY_CAPS,
isLoading: query.isLoading,
error: query.error,
};
}
export function useWalletInfo(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'info', walletId] as const,
queryFn: () => get<{ info: NodeInfo }>(`${base(walletId!)}/info`),
enabled: walletId != null,
refetchInterval: BALANCE_POLL_MS,
staleTime: BALANCE_POLL_MS - 1_000,
});
return { info: query.data?.info ?? null, isLoading: query.isLoading, error: query.error };
}
// The three reads below carry a `sync` block from the sidecar. An on-chain wallet serves the last
// snapshot it managed to read and refreshes behind the response, so `isLoading` no longer means "we know
// nothing" — after the first successful read it is never true again, and the honest signal for "these
// numbers are old" or "we could not reach the chain" is `sync`, not the query state.
export function useBalances(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'balances', walletId] as const,
queryFn: () => get<{ balances: Balances; sync: SyncState | null }>(`${base(walletId!)}/balances`),
enabled: walletId != null,
// Faster while the node is rescanning: this response carries the rescan's progress, and a counter
// that moves once every twenty seconds reads as a hung one. Back to the ordinary poll when it ends.
refetchInterval: (query) => (query.state.data?.sync?.rescan?.running ? RESCAN_POLL_MS : BALANCE_POLL_MS),
staleTime: BALANCE_POLL_MS - 1_000,
});
return {
balances: query.data?.balances ?? null,
sync: query.data?.sync ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
export function useTransactions(walletId: number | null, limit = 50) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'transactions', walletId, limit] as const,
queryFn: () =>
get<{ transactions: OnchainTx[]; sync: SyncState | null }>(`${base(walletId!)}/transactions?limit=${limit}`),
enabled: walletId != null,
refetchInterval: HISTORY_POLL_MS,
staleTime: 15_000,
});
return {
transactions: query.data?.transactions ?? EMPTY_TXS,
sync: query.data?.sync ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
export function useUtxos(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'utxos', walletId] as const,
queryFn: () => get<{ utxos: Utxo[]; sync: SyncState | null }>(`${base(walletId!)}/utxos`),
enabled: walletId != null && enabled,
refetchInterval: HISTORY_POLL_MS,
staleTime: 15_000,
});
return {
utxos: query.data?.utxos ?? EMPTY_UTXOS,
sync: query.data?.sync ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
export function useFees(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'fees', walletId] as const,
queryFn: () => get<{ fees: FeeEstimates }>(`${base(walletId!)}/fees`),
enabled: walletId != null && enabled,
refetchInterval: 60_000,
staleTime: 30_000,
});
return { fees: query.data?.fees ?? null, isLoading: query.isLoading, error: query.error };
}
/**
* A receive address. `peek` returns the current unused address without advancing the derivation index —
* which is what a screen that merely *displays* an address must do, or every render burns an address.
*/
type AddressResponse = { address: string; type: string };
export function useReceiveAddress(walletId: number | null, enabled = true) {
const { get } = useClient();
const qc = useQueryClient();
const key = [...ROOT_KEY, 'address', walletId] as const;
const query = useQuery({
queryKey: key,
queryFn: () => get<AddressResponse>(`${base(walletId!)}/address?peek=true`),
enabled: walletId != null && enabled,
staleTime: 60_000,
});
// `peek=false` CONSUMES: the sidecar advances its issued mark so this address is never handed out
// again. Refetching the peeked query returns the same address forever, which is why this is a separate
// call and not `refetch()` — that was the bug behind a "new address" button that changed nothing.
const next = useMutation({
mutationFn: () => get<AddressResponse>(`${base(walletId!)}/address?peek=false`),
onSuccess: (data) => qc.setQueryData(key, data),
onError: (err) => toast.error(errorMessage(err, 'Could not derive a new address')),
});
return {
address: query.data?.address ?? null,
addressType: query.data?.type ?? null,
isLoading: query.isLoading,
error: query.error,
next,
};
}
export function useInvoices(walletId: number | null, enabled = true, limit = 50) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'invoices', walletId, limit] as const,
queryFn: () => get<{ invoices: Invoice[] }>(`${base(walletId!)}/invoices?limit=${limit}`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { invoices: query.data?.invoices ?? EMPTY_INVOICES, isLoading: query.isLoading, error: query.error };
}
export function usePayments(walletId: number | null, enabled = true, limit = 50) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'payments', walletId, limit] as const,
queryFn: () => get<{ payments: Payment[] }>(`${base(walletId!)}/payments?limit=${limit}`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { payments: query.data?.payments ?? EMPTY_PAYMENTS, isLoading: query.isLoading, error: query.error };
}
export function useChannels(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'channels', walletId] as const,
queryFn: () => get<{ channels: Channel[] }>(`${base(walletId!)}/channels`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { channels: query.data?.channels ?? EMPTY_CHANNELS, isLoading: query.isLoading, error: query.error };
}
export function usePeers(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'peers', walletId] as const,
queryFn: () => get<{ peers: Peer[] }>(`${base(walletId!)}/peers`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { peers: query.data?.peers ?? EMPTY_PEERS, isLoading: query.isLoading, error: query.error };
}
// ── wallet lifecycle mutations ────────────────────────────────────────────────────────────────────
export type CreateWalletInput = {
name: string;
kind: BackendKind;
network?: string;
/** onchain only. Omit to have the sidecar generate one — that is the only case that returns a mnemonic. */
mnemonic?: string;
words?: 12 | 24;
/** onchain only, and never stored anywhere on this side. */
passphrase?: string;
bip39Passphrase?: string;
defaultBip?: number;
config?: Record<string, unknown>;
makeActive?: boolean;
};
export type CreateWalletResult = { wallet: WalletSummary; mnemonic?: string };
export function useWalletLifecycle() {
const { post, patch, delete: del } = useClient();
const qc = useQueryClient();
const invalidateList = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'wallets'] });
const create = useMutation({
mutationFn: (input: CreateWalletInput) => post<CreateWalletResult>('/wallet/_officer/wallets', input),
// Deliberately no onSuccess toast/cache write with the response: it may carry the mnemonic, and the
// only place that is allowed to exist is the backup modal's local state. The caller invalidates.
onSuccess: invalidateList,
onError: (err) => toast.error(errorMessage(err, 'Could not create the wallet')),
});
const activate = useMutation({
mutationFn: (walletId: number) => post<{ ok: true }>(`${base(walletId)}/activate`),
onSuccess: () => {
invalidateList();
toast.success('Active wallet changed');
},
onError: (err) => toast.error(errorMessage(err, 'Could not activate the wallet')),
});
const rename = useMutation({
// Only the label. The name is the one wallet field with no bearing on the key material, which is why
// this needs neither the passphrase nor an unlocked wallet.
mutationFn: ({ walletId, name }: { walletId: number; name: string }) =>
patch<{ wallet: WalletSummary }>(`/wallet/_officer/wallets/${walletId}`, { name }),
onSuccess: () => {
invalidateList();
toast.success('Wallet renamed');
},
onError: (err) => toast.error(errorMessage(err, 'Could not rename the wallet')),
});
const remove = useMutation({
// The passphrase is required for a seeded wallet even when it is already unlocked — an open session
// must not be enough to destroy the only copy of the key material.
mutationFn: ({
walletId,
passphrase,
confirmName,
}: {
walletId: number;
passphrase?: string;
confirmName: string;
}) =>
del<{ ok: true }>(`/wallet/_officer/wallets/${walletId}`, { confirmName, ...(passphrase ? { passphrase } : {}) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ROOT_KEY });
toast.success('Wallet deleted');
},
onError: (err) => toast.error(errorMessage(err, 'Could not delete the wallet')),
});
return { create, activate, rename, remove };
}
// ── lock lifecycle ────────────────────────────────────────────────────────────────────────────────
export function useLockActions(walletId: number | null) {
const { post } = useClient();
const qc = useQueryClient();
const refreshLock = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'lock-state', walletId] });
const unlock = useMutation({
// `passphrase` goes from a controlled input straight into this body and the caller clears its state
// immediately after. It is never persisted, cached or logged on this side.
mutationFn: ({ passphrase, ttlSec }: { passphrase: string; ttlSec?: number }) =>
post<{ ok: true; unlocked: boolean; secondsRemaining: number }>(`${base(walletId!)}/unlock`, {
passphrase,
ttlSec,
}),
onSuccess: () => {
refreshLock();
toast.success('Wallet unlocked');
},
onError: (err) => toast.error(errorMessage(err, 'Could not unlock — check the passphrase')),
});
const lock = useMutation({
mutationFn: () => post<{ ok: true; unlocked: false }>(`${base(walletId!)}/lock`),
onSuccess: () => {
refreshLock();
toast.success('Wallet locked');
},
onError: (err) => toast.error(errorMessage(err, 'Could not lock the wallet')),
});
const changePassphrase = useMutation({
mutationFn: (input: { oldPassphrase: string; newPassphrase: string }) =>
post<{ ok: true }>(`${base(walletId!)}/passphrase`, input),
onSuccess: () => {
refreshLock();
toast.success('Passphrase changed — the wallet was re-locked');
},
onError: (err) => toast.error(errorMessage(err, 'Could not change the passphrase')),
});
const exportSeed = useMutation({
mutationFn: (input: { passphrase: string }) =>
post<{ mnemonic: string; hasBip39Passphrase: boolean }>(`${base(walletId!)}/export-seed`, input),
// No cache write and no toast carrying the result — the mnemonic lives only in the dialog's state.
onError: (err) => toast.error(errorMessage(err, 'Could not export the seed')),
});
return { unlock, lock, changePassphrase, exportSeed };
}
// ── operations ────────────────────────────────────────────────────────────────────────────────────
export type SendInput = {
address: string;
amountSats?: number;
sendAll?: boolean;
satPerVbyte: number;
outpoints?: string[];
label?: string;
};
export function useWalletOperations(walletId: number | null) {
const { post } = useClient();
const qc = useQueryClient();
const invalidateAll = () => qc.invalidateQueries({ queryKey: ROOT_KEY });
const send = useMutation({
mutationFn: (input: SendInput) => post<SendCoinsResult>(`${base(walletId!)}/send`, input),
onSuccess: (result) => {
invalidateAll();
toast.success(`Broadcast ${result.txid.slice(0, 12)}…`);
},
onError: (err) => toast.error(errorMessage(err, 'Could not send')),
});
const freeze = useMutation({
mutationFn: (input: { outpoint: string; frozen: boolean; reason?: string }) =>
post<{ ok: true }>(`${base(walletId!)}/utxos/freeze`, input),
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'utxos', walletId] }),
onError: (err) => toast.error(errorMessage(err, 'Could not change the freeze flag')),
});
const createInvoice = useMutation({
mutationFn: (input: { amountMsat?: string; memo?: string; expirySeconds?: number; private?: boolean }) =>
post<{ invoice: Invoice }>(`${base(walletId!)}/invoices`, input),
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'invoices', walletId] }),
onError: (err) => toast.error(errorMessage(err, 'Could not create the invoice')),
});
const decode = useMutation({
mutationFn: (bolt11: string) => post<{ decoded: DecodedInvoice }>(`${base(walletId!)}/decode`, { bolt11 }),
onError: (err) => toast.error(errorMessage(err, 'Could not decode that invoice')),
});
const pay = useMutation({
mutationFn: (input: { bolt11: string; amountMsat?: string; feeLimitMsat?: string }) =>
post<{ payment: Payment }>(`${base(walletId!)}/pay`, input),
onSuccess: (data) => {
invalidateAll();
if (data.payment.status === 'failed') toast.error(data.payment.failureReason ?? 'Payment failed');
else toast.success(data.payment.status === 'succeeded' ? 'Payment sent' : 'Payment in flight');
},
onError: (err) => toast.error(errorMessage(err, 'Could not pay')),
});
const keysend = useMutation({
mutationFn: (input: { destination: string; amountMsat: string; message?: string }) =>
post<{ payment: Payment }>(`${base(walletId!)}/keysend`, input),
onSuccess: () => {
invalidateAll();
toast.success('Keysend sent');
},
onError: (err) => toast.error(errorMessage(err, 'Keysend failed')),
});
const sign = useMutation({
mutationFn: (message: string) => post<{ signature: string }>(`${base(walletId!)}/sign`, { message }),
onError: (err) => toast.error(errorMessage(err, 'Could not sign — the wallet may be locked')),
});
const verify = useMutation({
mutationFn: (input: { message: string; signature: string }) =>
post<{ valid: boolean; pubkey: string | null }>(`${base(walletId!)}/verify`, input),
onError: (err) => toast.error(errorMessage(err, 'Could not verify')),
});
const setLabel = useMutation({
mutationFn: (input: { kind: 'address' | 'tx'; ref: string; label: string }) =>
post<{ ok: true }>(`${base(walletId!)}/labels`, input),
onSuccess: () => invalidateAll(),
onError: (err) => toast.error(errorMessage(err, 'Could not save the label')),
});
// Returns as soon as the scan is queued — it takes minutes. Progress arrives on the `sync.rescan` block
// of the balances poll, which speeds up on its own while one is running, so the invalidation here is
// only to put the first "scanning…" on screen without waiting out the current interval.
const rescan = useMutation({
mutationFn: (input: { gapLimit?: number } = {}) =>
post<{ rescan: RescanState }>(`${base(walletId!)}/rescan`, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'balances', walletId] });
toast.success('Scanning the chain for this wallet’s coins — this takes a few minutes');
},
onError: (err) => toast.error(errorMessage(err, 'Could not start the scan')),
});
return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel, rescan };
}
/**
* useClient rejects with `{status, message}` rather than an Error, and the sidecar's message is a JSON
* body — unwrap both so a toast reads "this wallet does not support coin control" rather than
* "[object Object]". Never called with anything that could contain a passphrase: the sidecar's error
* bodies are messages and codes only.
*/
export function errorMessage(err: unknown, fallback: string): string {
const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : '';
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as { error?: string };
if (parsed.error) return parsed.error;
} catch {
/* not JSON — use it as-is */
}
return raw || fallback;
}
@@ -1,10 +0,0 @@
import { useParams } from 'react-router';
import { DEFAULT_WALLET_SECTION, isWalletSection, type WalletSectionId } from './shared';
// The URL names the open section — not a panel channel. See docs/navigation-audit.md. WalletScreen
// redirects anything unrecognised, so the fallback here only covers the instant before that lands.
export function useWalletSection(): WalletSectionId {
const { section } = useParams();
return isWalletSection(section) ? section : DEFAULT_WALLET_SECTION;
}
-4
View File
@@ -66,10 +66,6 @@ export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
// Same for /wallet. WALLET_PARAM is exported too so anything linking into the screen from outside spells
// the wallet query param the same way the panels read it.
export { DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection, WALLET_PARAM } from './apps/Wallet/shared';
export type { WalletSectionId } from './apps/Wallet/shared';
export {
useFilesAPI,
useTasks,