diff --git a/scripts/theme-baseline.json b/scripts/theme-baseline.json
index 6f7651b3..39c476f2 100644
--- a/scripts/theme-baseline.json
+++ b/scripts/theme-baseline.json
@@ -12,6 +12,5 @@
"src/workspaces/officerdev/src/apps/Preview": 4,
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
"src/workspaces/officerdev/src/apps/SystemMonitor": 1,
- "src/workspaces/officerdev/src/apps/Terminal": 10,
- "src/workspaces/officerdev/src/apps/Wallet": 68
+ "src/workspaces/officerdev/src/apps/Terminal": 10
}
diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index e09bd5db..bdf714ae 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -107,8 +107,6 @@ export function App() {
} />
} />
} />
- } />
- } />
} />
} />
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx
deleted file mode 100644
index 5f612ace..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Navigate, useLocation, useParams } from 'react-router';
-import type { LayoutNode } from 'officerdev';
-import { WorkspaceView, DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection } from 'officerdev';
-import { useDashboardState } from 'state/useDashboardState';
-import { defaultLayout } from './defaultLayout';
-
-// /wallet uses the Workspace/Panel system (like /command-hq): the wallet list and section nav on the left,
-// the section itself on the right. Both talk to the officer-wallet sidecar through the /api/wallet auth
-// proxy, which holds no key material of its own — seeds live encrypted in the sidecar and are decrypted
-// there only for the length of an unlock window.
-//
-// The open section is :section in the URL, the open wallet is ?wallet= and any coin-control selection is
-// ?coins=, so both panels read the URL with useParams/useSearchParams rather than passing state between
-// themselves over a channel. This screen backs both /wallet and /wallet/:section and is the single place
-// that decides what an absent or bogus section means.
-
-export const WalletScreen = () => {
- const { section } = useParams();
- const { search } = useLocation();
- const workspace = useDashboardState('screens/wallet', defaultLayout);
-
- // Bare /wallet, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
- // default while the address bar says something else — the nav highlight is derived from the URL. The
- // ?wallet= param is deliberately carried through rather than dropped: /wallet?wallet=3 is a legitimate
- // deep link and canonicalising the section must not silently change which wallet is open.
- if (!isWalletSection(section)) {
- return ;
- }
-
- return (
-
-
-
- );
-};
diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts
deleted file mode 100644
index ebdad736..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { LayoutNode } from 'officerdev';
-
-export const defaultLayout: LayoutNode = {
- type: 'group',
- id: 'wallet-root',
- direction: 'horizontal',
- children: [
- { node: { type: 'panel', id: 'wallet-nav', appType: 'wallet-nav' }, size: 24 },
- { node: { type: 'panel', id: 'wallet-view', appType: 'wallet-view' }, size: 76 },
- ],
-};
diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx b/src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx
deleted file mode 100644
index b39d800e..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export * from './WalletScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index 902d20c6..37410a2a 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -15,7 +15,6 @@ export * from './Calendar';
export * from './Contacts';
export * from './CommandHQ';
export * from './Gitea';
-export * from './Wallet';
export * from './SystemMonitor';
export * from './Activity';
export * from './CodeEditor';
diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts
index 94c72a89..117eec9c 100644
--- a/src/apps/officer-web/state/usePageTitle.ts
+++ b/src/apps/officer-web/state/usePageTitle.ts
@@ -25,7 +25,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
- { match: (p) => p.startsWith('/wallet'), title: 'Wallet' },
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
{ match: (p) => p.startsWith('/activity'), title: 'Activity' },
diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts
index b14ea104..fe9850cc 100644
--- a/src/databases/officer_db/src/index.ts
+++ b/src/databases/officer_db/src/index.ts
@@ -47,7 +47,6 @@ export * from './user-data';
export * from './dav';
export * from './notify';
-export * from './wallet';
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
// deleted end to end on 2026-08-13 after `task-logger.ts` turned out to have no callers — a full read
diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts
index 57e4d6f4..efa142d2 100644
--- a/src/databases/officer_db/src/schema.ts
+++ b/src/databases/officer_db/src/schema.ts
@@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections
// export * from './notify/schema'; // push_devices officer-notify
// export * from './dav/schema'; // dav_app_passwords officer-caldav
-// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
//
diff --git a/src/databases/officer_db/src/wallet/index.ts b/src/databases/officer_db/src/wallet/index.ts
deleted file mode 100644
index 53d8d137..00000000
--- a/src/databases/officer_db/src/wallet/index.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-export {
- listWallets,
- getWallet,
- getActiveWallet,
- getWalletSecrets,
- getSealedSeed,
- createWallet,
- updateWallet,
- replaceSealedSeed,
- setActiveWallet,
- deleteWallet,
- getWalletLabels,
- setWalletLabel,
- getFrozenOutpoints,
- setUtxoFrozen,
- getWalletChainCache,
- saveWalletChainCache,
- recordWalletChainError,
-} from './queries';
-
-export type {
- WalletKind,
- WalletSummary,
- WalletSecrets,
- WalletLabel,
- CreateWalletParams,
- WalletChainCache,
-} from './queries';
-
-export type { WalletChainSnapshot } from './schema';
diff --git a/src/databases/officer_db/src/wallet/queries.ts b/src/databases/officer_db/src/wallet/queries.ts
deleted file mode 100644
index 76a5fbfa..00000000
--- a/src/databases/officer_db/src/wallet/queries.ts
+++ /dev/null
@@ -1,349 +0,0 @@
-import type { WalletChainSnapshot } from './schema';
-import { eq, and, desc } from 'drizzle-orm';
-import { db } from '../db';
-import { walletWallets, walletLabels, walletFrozenUtxos, walletChainCache } from './schema';
-import { encryptSecret, decryptSecret } from '../crypto';
-
-// Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the at-rest layer ('wallet'
-// purpose in the secret store) is
-// applied and stripped here, so route handlers never touch crypto. See ../crypto.ts, ../schema/wallet.ts.
-//
-// Note what "plaintext" means for `seedEnvelope`: it is the passphrase-sealed envelope, which is itself
-// still ciphertext. This layer only removes the SECOND wrapping. Nothing in this file can read a mnemonic,
-// and that is intentional — only servers/sidecar/wallet/keys.ts can, and only with the owner passphrase.
-//
-// THREE return types, and the projection is what enforces the separation:
-// WalletSummary — safe to serialize to the browser. No config, no seed envelope, at all.
-// WalletSecrets — decrypted config for the sidecar's own upstream calls. Never returned by a handler.
-// SealedSeed — the sealed envelope, for keys.ts to open with the owner passphrase.
-// A bare `select()` would leak both ciphertext columns into every list response the moment someone
-// forgot to strip them, so no function here uses one.
-
-export type WalletKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
-
-export type WalletSummary = {
- id: number;
- name: string;
- kind: WalletKind;
- network: string;
- fingerprint: string | null;
- xpubs: Record | null;
- defaultBip: number;
- isActive: boolean;
- /** Whether this wallet holds a seed at all — i.e. whether unlock/lock apply to it. */
- hasSeed: boolean;
- createdAt: Date;
-};
-
-export type WalletSecrets = { id: number; kind: WalletKind; network: string; config: Record | null };
-
-const walletCols = {
- id: walletWallets.id,
- name: walletWallets.name,
- kind: walletWallets.kind,
- network: walletWallets.network,
- fingerprint: walletWallets.fingerprint,
- xpubs: walletWallets.xpubs,
- defaultBip: walletWallets.defaultBip,
- isActive: walletWallets.isActive,
- createdAt: walletWallets.createdAt,
- seedEnvelope: walletWallets.seedEnvelope,
-};
-
-function toSummary(row: {
- id: number;
- name: string;
- kind: string;
- network: string;
- fingerprint: string | null;
- xpubs: Record | null;
- defaultBip: number;
- isActive: boolean;
- createdAt: Date;
- seedEnvelope: string | null;
-}): WalletSummary {
- return {
- id: row.id,
- name: row.name,
- kind: row.kind as WalletKind,
- network: row.network,
- fingerprint: row.fingerprint,
- xpubs: row.xpubs,
- defaultBip: row.defaultBip,
- isActive: row.isActive,
- hasSeed: row.seedEnvelope !== null,
- createdAt: row.createdAt,
- };
-}
-
-/** Every wallet the owner has registered, active first then newest. Never includes secrets. */
-export async function listWallets(userId: number): Promise {
- const rows = await db
- .select(walletCols)
- .from(walletWallets)
- .where(eq(walletWallets.userId, userId))
- .orderBy(desc(walletWallets.isActive), desc(walletWallets.createdAt));
- return rows.map(toSummary);
-}
-
-export async function getWallet(userId: number, id: number): Promise {
- const [row] = await db
- .select(walletCols)
- .from(walletWallets)
- .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
- return row ? toSummary(row) : null;
-}
-
-export async function getActiveWallet(userId: number): Promise {
- const [row] = await db
- .select(walletCols)
- .from(walletWallets)
- .where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
- return row ? toSummary(row) : null;
-}
-
-/** Decrypted backend connection config, for the sidecar's upstream calls. Never leaves the sidecar. */
-export async function getWalletSecrets(userId: number, id: number): Promise {
- const [row] = await db
- .select({
- id: walletWallets.id,
- kind: walletWallets.kind,
- network: walletWallets.network,
- config: walletWallets.config,
- })
- .from(walletWallets)
- .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
- if (!row) return null;
- return {
- id: row.id,
- kind: row.kind as WalletKind,
- network: row.network,
- config: row.config ? (JSON.parse(decryptSecret('wallet', row.config)) as Record) : null,
- };
-}
-
-/**
- * The passphrase-sealed seed envelope, still sealed. Returns null for wallets that hold no seed.
- * The ONLY caller should be the sidecar's unlock path.
- */
-export async function getSealedSeed(userId: number, id: number): Promise {
- const [row] = await db
- .select({ seedEnvelope: walletWallets.seedEnvelope })
- .from(walletWallets)
- .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
- if (!row?.seedEnvelope) return null;
- return decryptSecret('wallet', row.seedEnvelope);
-}
-
-export type CreateWalletParams = {
- userId: number;
- name: string;
- kind: WalletKind;
- network: string;
- /** Plaintext; encrypted here. */
- config?: Record | null;
- /** The already-sealed envelope as JSON; encrypted again here. */
- sealedSeed?: string | null;
- fingerprint?: string | null;
- xpubs?: Record | null;
- defaultBip?: number;
- makeActive?: boolean;
-};
-
-export async function createWallet(params: CreateWalletParams): Promise {
- return db.transaction(async (tx) => {
- if (params.makeActive) {
- await tx
- .update(walletWallets)
- .set({ isActive: false })
- .where(and(eq(walletWallets.userId, params.userId), eq(walletWallets.isActive, true)));
- }
- const [row] = await tx
- .insert(walletWallets)
- .values({
- userId: params.userId,
- name: params.name,
- kind: params.kind,
- network: params.network,
- config: params.config ? encryptSecret('wallet', JSON.stringify(params.config)) : null,
- seedEnvelope: params.sealedSeed ? encryptSecret('wallet', params.sealedSeed) : null,
- fingerprint: params.fingerprint ?? null,
- xpubs: params.xpubs ?? null,
- defaultBip: params.defaultBip ?? 84,
- isActive: params.makeActive ?? false,
- })
- .returning(walletCols);
- return toSummary(row!);
- });
-}
-
-/**
- * Mutable wallet metadata. CANNOT TOUCH THE SEED — see replaceSealedSeed below for why that is a
- * separate function rather than one more optional field here.
- */
-export async function updateWallet(
- userId: number,
- id: number,
- patch: { name?: string; config?: Record; defaultBip?: number },
-): Promise {
- const set: Record = { updatedAt: new Date() };
- if (patch.name !== undefined) set.name = patch.name;
- if (patch.defaultBip !== undefined) set.defaultBip = patch.defaultBip;
- if (patch.config !== undefined) set.config = encryptSecret('wallet', JSON.stringify(patch.config));
-
- const [row] = await db
- .update(walletWallets)
- .set(set)
- .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
- .returning(walletCols);
- return row ? toSummary(row) : null;
-}
-
-/**
- * Overwrite the encrypted seed. The ONLY caller is the passphrase change, which reseals the same words
- * under a new passphrase and has already proved knowledge of the old one.
- *
- * It is a function of its own, rather than a fourth field on updateWallet, because it was one: the PATCH
- * route typed its request body as `{name, defaultBip, config}` and passed the parsed object straight
- * through, and a TypeScript annotation strips nothing at runtime. Any authenticated caller could send a
- * `sealedSeed` key and replace the seed — no passphrase, no unlock, no confirmation — and since
- * encryptSecret happily encrypts nonsense, the damage only surfaced at the next unlock attempt. Keeping
- * the two apart means the next field added to that body cannot re-open it.
- */
-export async function replaceSealedSeed(userId: number, id: number, sealedSeed: string): Promise {
- await db
- .update(walletWallets)
- .set({ seedEnvelope: encryptSecret('wallet', sealedSeed), updatedAt: new Date() })
- .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
-}
-
-/** Exactly one active wallet per owner. Cleared and set in one transaction; the partial unique index is the backstop. */
-export async function setActiveWallet(userId: number, id: number): Promise {
- await db.transaction(async (tx) => {
- await tx
- .update(walletWallets)
- .set({ isActive: false })
- .where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
- await tx
- .update(walletWallets)
- .set({ isActive: true, updatedAt: new Date() })
- .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
- });
-}
-
-export async function deleteWallet(userId: number, id: number): Promise {
- const rows = await db
- .delete(walletWallets)
- .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
- .returning({ id: walletWallets.id });
- return rows.length > 0;
-}
-
-// ── labels ───────────────────────────────────────────────────────────────────────────────────────
-
-export type WalletLabel = { kind: 'address' | 'tx'; ref: string; label: string };
-
-export async function getWalletLabels(walletId: number): Promise {
- const rows = await db
- .select({ kind: walletLabels.kind, ref: walletLabels.ref, label: walletLabels.label })
- .from(walletLabels)
- .where(eq(walletLabels.walletId, walletId));
- return rows.map((r) => ({ kind: r.kind as 'address' | 'tx', ref: r.ref, label: r.label }));
-}
-
-export async function setWalletLabel(
- walletId: number,
- kind: 'address' | 'tx',
- ref: string,
- label: string,
-): Promise {
- // An empty label is a delete — the UI clears a field rather than pressing a separate button.
- if (!label.trim()) {
- await db
- .delete(walletLabels)
- .where(and(eq(walletLabels.walletId, walletId), eq(walletLabels.kind, kind), eq(walletLabels.ref, ref)));
- return;
- }
- await db
- .insert(walletLabels)
- .values({ walletId, kind, ref, label })
- .onConflictDoUpdate({ target: [walletLabels.walletId, walletLabels.kind, walletLabels.ref], set: { label } });
-}
-
-// ── frozen UTXOs ─────────────────────────────────────────────────────────────────────────────────
-
-export async function getFrozenOutpoints(walletId: number): Promise {
- const rows = await db
- .select({ outpoint: walletFrozenUtxos.outpoint })
- .from(walletFrozenUtxos)
- .where(eq(walletFrozenUtxos.walletId, walletId));
- return rows.map((r) => r.outpoint);
-}
-
-export async function setUtxoFrozen(
- walletId: number,
- outpoint: string,
- frozen: boolean,
- reason?: string,
-): Promise {
- if (!frozen) {
- await db
- .delete(walletFrozenUtxos)
- .where(and(eq(walletFrozenUtxos.walletId, walletId), eq(walletFrozenUtxos.outpoint, outpoint)));
- return;
- }
- await db
- .insert(walletFrozenUtxos)
- .values({ walletId, outpoint, reason: reason ?? null })
- .onConflictDoNothing();
-}
-
-// ── chain cache ──────────────────────────────────────────────────────────────────────────────────
-//
-// Unlike everything above, none of this is authoritative — see the comment on the table. These three
-// functions are deliberately total: a cache read or write must never be able to fail a wallet request,
-// so the sidecar treats every one of them as best-effort.
-
-export type WalletChainCache = {
- snapshot: WalletChainSnapshot | null;
- syncedAt: Date | null;
- lastError: string | null;
- lastErrorAt: Date | null;
-};
-
-export async function getWalletChainCache(walletId: number): Promise {
- const [row] = await db
- .select({
- snapshot: walletChainCache.snapshot,
- syncedAt: walletChainCache.syncedAt,
- lastError: walletChainCache.lastError,
- lastErrorAt: walletChainCache.lastErrorAt,
- })
- .from(walletChainCache)
- .where(eq(walletChainCache.walletId, walletId));
- return row ?? null;
-}
-
-/** A successful refresh. Clears the error columns — the upstream just answered, so nothing is wrong now. */
-export async function saveWalletChainCache(walletId: number, snapshot: WalletChainSnapshot): Promise {
- const syncedAt = new Date();
- await db
- .insert(walletChainCache)
- .values({ walletId, snapshot, syncedAt, lastError: null, lastErrorAt: null })
- .onConflictDoUpdate({
- target: walletChainCache.walletId,
- set: { snapshot, syncedAt, lastError: null, lastErrorAt: null },
- });
-}
-
-/** A failed refresh. Touches only the error columns, so the last good snapshot and its age survive. */
-export async function recordWalletChainError(walletId: number, message: string): Promise {
- const lastErrorAt = new Date();
- // Bounded because this is an upstream message verbatim, and an HTML error page from a misconfigured
- // proxy would otherwise land whole in the column.
- const lastError = message.slice(0, 500);
- await db
- .insert(walletChainCache)
- .values({ walletId, snapshot: null, syncedAt: null, lastError, lastErrorAt })
- .onConflictDoUpdate({ target: walletChainCache.walletId, set: { lastError, lastErrorAt } });
-}
diff --git a/src/databases/officer_db/src/wallet/schema.ts b/src/databases/officer_db/src/wallet/schema.ts
deleted file mode 100644
index 40ebf6f6..00000000
--- a/src/databases/officer_db/src/wallet/schema.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
-import { sql } from 'drizzle-orm';
-import { users } from '../auth/schema';
-
-// Bitcoin wallets for the officer-wallet sidecar. The owner registers one or more wallets — either a
-// self-custodial on-chain wallet whose seed lives here, or a connection to a node (LND / Core Lightning /
-// LNDHub / NWC) exactly as Zeus models them — and switches between them.
-//
-// TWO COLUMNS HOLD SPENDING AUTHORITY AND THEY ARE PROTECTED DIFFERENTLY. This asymmetry is deliberate:
-//
-// `config` — node credentials (macaroon, rune, LNDHub password, NWC URI). Encrypted at rest with
-// the 'wallet' store key via ../crypto.ts, the way offscale_servers.api_key uses its
-// own. It CANNOT be
-// passphrase-protected: background balance polling needs it without the owner present.
-//
-// `seed_envelope` — a BIP39 mnemonic that is ALREADY sealed under an owner passphrase by the sidecar
-// (see servers/sidecar/wallet/keys.ts) before it ever arrives here, and is then
-// encrypted AGAIN with the 'wallet' store key on the way into this column. Two independent
-// secrets, neither sufficient alone. A database dump does not spend; a leaked .env
-// does not spend.
-//
-// `xpubs` and `fingerprint` are stored in the CLEAR, on purpose. They are what lets the wallet show
-// balances, history and fresh receive addresses while locked — the watch-only-when-locked property. An
-// xpub leak costs privacy (an observer can enumerate the wallet's addresses), never funds.
-//
-// Every table is `wallet_`-prefixed and this file holds nothing else, so it can move into
-// src/servers/sidecar/wallet/ wholesale when sidecars own their schema. Only officer-wallet reads these.
-
-export const walletWallets = pgTable(
- 'wallet_wallets',
- {
- id: serial('id').primaryKey(),
- userId: integer('user_id')
- .notNull()
- .references(() => users.id, { onDelete: 'cascade' }),
- name: text('name').notNull(),
- // BackendKind: 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc'. TS-only enum, no DB CHECK —
- // matches how pipeline_jobs.mode is handled elsewhere in this schema.
- kind: text('kind').notNull(),
- // BitcoinNetwork: 'bitcoin' | 'testnet' | 'signet' | 'regtest'.
- network: text('network').notNull().default('bitcoin'),
- // Backend connection config as JSON, encrypted. Null for a pure on-chain wallet, which connects to
- // nothing but the Esplora endpoint the sidecar is configured with.
- config: text('config'),
- // The passphrase-sealed SeedEnvelope, encrypted again. Null for every remote-node wallet — those
- // hold no seed, the node does.
- seedEnvelope: text('seed_envelope'),
- // BIP32 master fingerprint (8 hex chars), for PSBT construction and hardware-wallet pairing.
- fingerprint: text('fingerprint'),
- // { "44": "xpub…", "49": "ypub…", "84": "zpub…", "86": "xpub…" } — plaintext, see header.
- xpubs: jsonb('xpubs').$type>(),
- // Which derivation standard new receive addresses use. 84 (native segwit) is the default.
- defaultBip: integer('default_bip').notNull().default(84),
- isActive: boolean('is_active').notNull().default(false),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
- },
- (t) => [
- uniqueIndex('uq_wallet_wallets_user_name').on(t.userId, t.name),
- // At most one active wallet per owner, enforced by the DB rather than convention — a partial unique
- // index over active rows only, mirroring uq_offscale_servers_one_active.
- uniqueIndex('uq_wallet_wallets_one_active')
- .on(t.userId)
- .where(sql`${t.isActive}`),
- ],
-);
-
-// Owner-assigned labels for addresses and transactions. Zeus keeps these client-side; here they belong to
-// the server so every client sees the same annotations.
-export const walletLabels = pgTable(
- 'wallet_labels',
- {
- id: serial('id').primaryKey(),
- walletId: integer('wallet_id')
- .notNull()
- .references(() => walletWallets.id, { onDelete: 'cascade' }),
- // 'address' | 'tx'
- kind: text('kind').notNull(),
- // The address string or txid being labelled.
- ref: text('ref').notNull(),
- label: text('label').notNull(),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- },
- (t) => [uniqueIndex('uq_wallet_labels_wallet_kind_ref').on(t.walletId, t.kind, t.ref)],
-);
-
-// Frozen UTXOs, excluded from automatic coin selection. Separate from labels because this one affects
-// what the wallet will actually spend — losing a label is cosmetic, losing a freeze spends a coin the
-// owner meant to keep back.
-//
-// NOTE the contrast with wallet_chain_cache below: this table is POLICY (the owner's decision, only
-// they can restate it) and that one is OBSERVATION (the chain's, refetchable at any time). That is why
-// only one of them can be safely truncated.
-export const walletFrozenUtxos = pgTable(
- 'wallet_frozen_utxos',
- {
- id: serial('id').primaryKey(),
- walletId: integer('wallet_id')
- .notNull()
- .references(() => walletWallets.id, { onDelete: 'cascade' }),
- // `txid:vout`
- outpoint: text('outpoint').notNull(),
- reason: text('reason'),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- },
- (t) => [uniqueIndex('uq_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)],
-);
-
-/**
- * The last thing the chain told us about an on-chain wallet, so a wallet screen paints real numbers
- * immediately instead of nothing while a gap-limit scan runs.
- *
- * Every field is DERIVED and rebuildable — it is recomputed from the account xpubs above on each
- * successful refresh, so this table holds no secret and nothing authoritative. Truncating it costs one
- * slow load and nothing else.
- *
- * Only the chain's own facts are stored. Addresses, scripts and pubkeys are NOT: they are pure EC
- * derivation from the xpub, cheaper to recompute on load than to persist, and re-deriving them means a
- * restored snapshot can never disagree with the wallet's actual keys.
- */
-export type WalletChainSnapshot = {
- /** Bumped when the shape below changes; a snapshot at any other version is discarded, not migrated. */
- v: 1;
- tipHeight: number;
- /** One entry per address the scan visited, keyed by its derivation coordinates. */
- addresses: {
- /** AddressType: 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh'. */
- type: string;
- /** BIP44 chain index: 0 receive, 1 change. */
- chain: number;
- index: number;
- confirmed: number;
- unconfirmed: number;
- txCount: number;
- }[];
- utxos: {
- txid: string;
- vout: number;
- value: number;
- type: string;
- chain: number;
- index: number;
- /** Null while unconfirmed. Confirmations are recomputed against tipHeight, never stored. */
- blockHeight: number | null;
- }[];
- /** The OnchainTx shape from servers/sidecar/wallet/types.ts, restated here so this package imports nothing. */
- transactions: {
- txid: string;
- amount: number;
- feeSats: number | null;
- blockHeight: number | null;
- timestamp: number | null;
- confirmations: number;
- label: string | null;
- destAddresses: string[];
- rawHex: string | null;
- }[];
-};
-
-export const walletChainCache = pgTable('wallet_chain_cache', {
- // One row per wallet, so a refresh is a single atomic upsert with no partially-written window.
- walletId: integer('wallet_id')
- .primaryKey()
- .references(() => walletWallets.id, { onDelete: 'cascade' }),
- // Nullable: a wallet whose very first refresh failed has an error to report and no data to report it
- // against, and that row still has to exist for the UI to say why the screen is empty.
- snapshot: jsonb('snapshot').$type(),
- /** When the chain was last read successfully — the age the UI shows. Not when the row was written. */
- syncedAt: timestamp('synced_at', { withTimezone: true }),
- // Kept ALONGSIDE the last good snapshot rather than replacing it. A failure that wiped the data would
- // reproduce the exact bug this table exists to fix: an unreachable upstream rendering as a zero balance
- // instead of as a stale one.
- lastError: text('last_error'),
- lastErrorAt: timestamp('last_error_at', { withTimezone: true }),
-});
diff --git a/src/servers/api/wallet/router.ts b/src/servers/api/wallet/router.ts
deleted file mode 100644
index 17f6486d..00000000
--- a/src/servers/api/wallet/router.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { createSidecarProxy } from '../../sidecar/create-proxy';
-
-// /api/wallet/* — auth, then forward to officer-wallet. No routes of its own, and no wallet knowledge
-// whatsoever: not a seed, not a node credential, not an xpub. The platform cannot spend and cannot read a
-// balance except by asking the sidecar.
-//
-// The unlock passphrase DOES transit this proxy — the browser has to send it somewhere — but it is
-// forwarded as an opaque body and never parsed, logged or retained. See sidecar/create-proxy.ts, which
-// deliberately performs no body inspection; that restraint matters more for this sidecar than any other.
-
-const proxy = createSidecarProxy({ name: 'wallet', prefix: '/api/wallet' });
-
-export const walletRouter = proxy.router;
-
-/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
-export const getWalletServerUrl = proxy.getHttpUrl;
diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts
index 9d2609b2..9f2454c7 100644
--- a/src/servers/app-store/catalogue.ts
+++ b/src/servers/app-store/catalogue.ts
@@ -257,17 +257,6 @@ export const CATALOGUE: CatalogueEntry[] = [
// `[open]` This is the "two dock sources" seam. The store keeps its own catalogue while the plugin
// system builds tiles from manifests, and the self endpoint concatenates both. One when the store is
// rebuilt on the plugin system.
- {
- id: 'wallet',
- ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] },
- process: 'officer-wallet',
- label: 'Wallet',
- summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
- members: 'none',
- modes: ['config'],
- permission: 'wallet',
- configFields: [],
- },
{
id: 'notify',
process: 'officer-notify',
diff --git a/src/servers/hono.ts b/src/servers/hono.ts
index fcbddf18..e9cb5e1f 100644
--- a/src/servers/hono.ts
+++ b/src/servers/hono.ts
@@ -24,7 +24,6 @@ import { devProxyRouter } from './api/dev-proxy/router';
import { pluginsRouter } from './api/plugins/router';
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
-// import { walletRouter } from './api/wallet/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
@@ -147,7 +146,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType
['/dav', davRouter], // app-password management (the sync door is /dav, top-level)
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
- // ['/wallet', walletRouter], // plugin — switched off 2026-08-13
['/system-monitor', systemMonitorRouter],
['/activity', activityRouter],
['/dock', dockRouter],
diff --git a/src/servers/permissions/registry.test.ts b/src/servers/permissions/registry.test.ts
index 1d9a44c1..cc78e969 100644
--- a/src/servers/permissions/registry.test.ts
+++ b/src/servers/permissions/registry.test.ts
@@ -202,7 +202,9 @@ describe('self-service routes', () => {
});
test('permissions without the field are unaffected', () => {
- expect(isSelfServiceRoute(PERMISSION_BY_KEY.get('wallet')!, 'PUT', '/api/wallet')).toBe(false);
+ // Was `wallet` until 2026-08-19, when it became `plugins/wallet/`. Any permission with no
+ // selfService field will do — this pins the absence, not the key.
+ expect(isSelfServiceRoute(PERMISSION_BY_KEY.get('terminal')!, 'PUT', '/api/terminal')).toBe(false);
});
});
@@ -242,9 +244,11 @@ describe('kinds', () => {
test('admin permissions are never grantable', () => {
const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key));
- // `headscale` was here until 2026-08-15, when it left with offscale. A plugin's permissions are
- // registered at install from its manifest, so they are not in this compile-time list by design.
- for (const key of ['user-admin', 'server-admin', 'wallet']) {
+ // `headscale` was here until 2026-08-15 when it left with offscale, and `wallet` until 2026-08-19.
+ // A plugin's permissions are registered at install from its manifest, so they are not in this
+ // compile-time list by design — and a plugin's are ALWAYS `app`, which is exactly why the wallet
+ // becoming one had to be a deliberate decision rather than a side effect.
+ for (const key of ['user-admin', 'server-admin']) {
expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('admin');
expect(grantable.has(key)).toBe(false);
}
diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts
index 5f62d114..5aa0319f 100644
--- a/src/servers/permissions/registry.ts
+++ b/src/servers/permissions/registry.ts
@@ -381,15 +381,6 @@ const CORE_REGISTRY: Permission[] = [
// `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares
// its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The
// platform no longer knows this permission exists, which is the entire point.
- {
- key: 'wallet',
- label: 'Wallet',
- description: "The server owner's bitcoin",
- kind: 'admin',
- // api: ['/wallet'], // plugin — switched off 2026-08-13
- api: [],
- routes: ['/wallet'],
- },
];
// ── Derived lookups ───────────────────────────────────────────────────────────────────────────────
diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts
index 322c9f8f..07fb2cad 100644
--- a/src/servers/plugins/marketplace.ts
+++ b/src/servers/plugins/marketplace.ts
@@ -212,6 +212,28 @@ const BUNDLED: CatalogueEntry[] = [
// shows rather than the daemon behind it. See its PLUGIN.md for what that cost.
has: { api: true, schema: true, sidecar: true, web: true },
},
+ {
+ appName: 'bitcoin-wallet',
+ publisher: 'officerdev',
+ version: '1.0.0',
+ platform: '>=1.0.0',
+ label: 'Bitcoin Wallet',
+ summary: 'A self-custodial bitcoin wallet — keys sealed on the server, never on a device',
+ color: '#f7931a',
+ icon: 'Bitcoin',
+ source: 'https://gitea.officer.dev/plugins/bitcoin-wallet.git',
+ permissions: [
+ {
+ key: 'bitcoin-wallet',
+ label: 'Bitcoin Wallet',
+ description: "Bitcoin wallet — each account gets its own, sealed under that account's own passphrase",
+ },
+ ],
+ // Grantable, unlike the `kind: 'admin'` it had in the core registry — every query is scoped by
+ // user_id, so granting it gives an account its OWN wallet rather than access to anyone else's.
+ // See its PLUGIN.md; this was a deliberate decision, not a side effect of extraction.
+ has: { api: true, schema: true, sidecar: true, web: true },
+ },
{
appName: 'vault',
publisher: 'officerdev',
diff --git a/src/servers/sidecar/wallet/aezeed/aez.ts b/src/servers/sidecar/wallet/aezeed/aez.ts
deleted file mode 100644
index 85105fda..00000000
--- a/src/servers/sidecar/wallet/aezeed/aez.ts
+++ /dev/null
@@ -1,380 +0,0 @@
-import { blake2b } from '@noble/hashes/blake2.js';
-
-// AEZ v5 — the wide-block cipher LND's aezeed uses to encrypt a cipher seed.
-//
-// SCOPE, deliberately narrow. This is a DECRYPT-ONLY implementation of the SHORT-INPUT path:
-//
-// - No encrypt. Officer never mints aezeed seeds — it only imports ones LND/Zeus already created.
-// A new Officer wallet gets a BIP39 mnemonic, which is the portable format.
-// - No aezCore. An aezeed ciphertext is a FIXED 23 bytes (33 total, minus the 1-byte version, the
-// 5-byte salt and the 4-byte CRC), and AEZ dispatches on length: anything under 32 bytes takes the
-// `aezTiny` Feistel path. `aezCore` is unreachable here, so shipping it would mean carrying ~120
-// lines of untested cipher in a seed-recovery path. `decrypt` throws rather than guess.
-// - No aezPRF. Only reached when the ciphertext is exactly `tau` bytes (empty plaintext).
-//
-// Every line below is exercised by the tests. That is the point of the narrowness — a subtle bug in
-// unreachable crypto is a bug nobody finds until it eats someone's wallet.
-//
-// Ported from the reference implementation (Yawning/aez, as used by lnd/aezeed). Faithful to the point
-// of preserving its integer-truncation quirks; where the original relied on a JavaScript accident, the
-// comment says so.
-
-const BLOCK_SIZE = 16;
-const EXTRACTED_KEY_SIZE = 48;
-const ZERO = new Uint8Array(BLOCK_SIZE);
-
-// ── AES round tables ─────────────────────────────────────────────────────────────────────────────
-//
-// AEZ is built on reduced-round AES (4 and 10 rounds, always with MixColumns and no key schedule —
-// the "keys" are just the extracted key material). The four 256-entry T-tables are pure functions of
-// the AES S-box: TE0[i] = [2·S[i], S[i], S[i], 3·S[i]] big-endian, and TE1..TE3 are TE0 rotated right
-// by 8, 16 and 24 bits. Deriving them costs microseconds once and removes 1024 hand-copied constants
-// from a file where a single wrong digit silently produces the wrong wallet.
-
-// prettier-ignore
-const SBOX_HEX =
- '637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0' +
- 'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275' +
- '09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf' +
- 'd0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2' +
- 'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb' +
- 'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08' +
- 'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e' +
- 'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16';
-
-const SBOX = Uint8Array.from(SBOX_HEX.match(/../g)!, (h) => parseInt(h, 16));
-
-/** GF(2^8) doubling — the `xtime` of the AES spec. */
-const xtime = (b: number): number => ((b << 1) ^ (b & 0x80 ? 0x1b : 0)) & 0xff;
-
-function buildTables(): [Uint32Array, Uint32Array, Uint32Array, Uint32Array] {
- const te0 = new Uint32Array(256);
- for (let i = 0; i < 256; i++) {
- const s = SBOX[i]!;
- te0[i] = ((xtime(s) << 24) | (s << 16) | (s << 8) | (xtime(s) ^ s)) >>> 0;
- }
- const ror8 = (x: number): number => ((x >>> 8) | (x << 24)) >>> 0;
- const te1 = te0.map(ror8);
- const te2 = te1.map(ror8);
- const te3 = te2.map(ror8);
- return [te0, te1, te2, te3];
-}
-
-const [TE0, TE1, TE2, TE3] = buildTables();
-
-// ── block helpers ────────────────────────────────────────────────────────────────────────────────
-
-const block = (size = BLOCK_SIZE): Uint8Array => new Uint8Array(size);
-
-function xor16(a: Uint8Array, b: Uint8Array, dst: Uint8Array): void {
- for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]!;
-}
-
-function xor4x16(a: Uint8Array, b: Uint8Array, c: Uint8Array, d: Uint8Array, dst: Uint8Array): void {
- for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]! ^ c[i]! ^ d[i]!;
-}
-
-/** Multiply a block by x in GF(2^128), in place. */
-function doubleBlock(p: Uint8Array): void {
- const carry = p[0]!;
- for (let i = 0; i < 15; i++) p[i] = ((p[i]! << 1) | (p[i + 1]! >> 7)) & 0xff;
- p[15] = ((p[15]! << 1) ^ (carry >> 7 ? 135 : 0)) & 0xff;
-}
-
-/** Multiply a block by the integer `x` in GF(2^128) — repeated doubling, accumulating odd bits. */
-function multBlock(x: number, src: Uint8Array, dst: Uint8Array): void {
- const t = block();
- const r = block();
- t.set(src);
- let n = x;
- while (n !== 0) {
- if (n & 1) xor16(r, t, r);
- doubleBlock(t);
- n >>= 1;
- }
- dst.set(r);
-}
-
-const readU32BE = (b: Uint8Array, off: number): number =>
- ((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0;
-
-function writeU32BE(b: Uint8Array, off: number, v: number): void {
- b[off] = (v >>> 24) & 0xff;
- b[off + 1] = (v >>> 16) & 0xff;
- b[off + 2] = (v >>> 8) & 0xff;
- b[off + 3] = v & 0xff;
-}
-
-// ── reduced-round AES ────────────────────────────────────────────────────────────────────────────
-
-type AesKeys = { aes4: Uint32Array; aes10: Uint32Array };
-
-/** Lay the 48-byte extracted key out into the fixed round-key schedules AEZ prescribes. */
-function aesKeys(extracted: Uint8Array): AesKeys {
- const k = new Uint32Array(12);
- for (let i = 0; i < 12; i++) k[i] = readU32BE(extracted, 4 * i);
-
- const aes10 = new Uint32Array(40);
- aes10.set(k, 0);
- aes10.set(k, 12);
- aes10.set(k, 24);
- aes10.set(k.subarray(0, 4), 36);
-
- const aes4 = new Uint32Array(16);
- aes4.set(k.subarray(4, 8), 0);
- aes4.set(k.subarray(0, 4), 4);
- aes4.set(k.subarray(8, 12), 8);
-
- return { aes4, aes10 };
-}
-
-/** AES rounds with MixColumns applied on every round, including the last (as AEZ specifies). */
-function rounds(keys: Uint32Array, count: number, b: Uint8Array): void {
- let s0 = readU32BE(b, 0);
- let s1 = readU32BE(b, 4);
- let s2 = readU32BE(b, 8);
- let s3 = readU32BE(b, 12);
-
- for (let r = 0; r < count; r++) {
- const o = r * 4;
- const t0 =
- TE0[(s0 >>> 24) & 0xff]! ^ TE1[(s1 >>> 16) & 0xff]! ^ TE2[(s2 >>> 8) & 0xff]! ^ TE3[s3 & 0xff]! ^ keys[o]!;
- const t1 =
- TE0[(s1 >>> 24) & 0xff]! ^ TE1[(s2 >>> 16) & 0xff]! ^ TE2[(s3 >>> 8) & 0xff]! ^ TE3[s0 & 0xff]! ^ keys[o + 1]!;
- const t2 =
- TE0[(s2 >>> 24) & 0xff]! ^ TE1[(s3 >>> 16) & 0xff]! ^ TE2[(s0 >>> 8) & 0xff]! ^ TE3[s1 & 0xff]! ^ keys[o + 2]!;
- const t3 =
- TE0[(s3 >>> 24) & 0xff]! ^ TE1[(s0 >>> 16) & 0xff]! ^ TE2[(s1 >>> 8) & 0xff]! ^ TE3[s2 & 0xff]! ^ keys[o + 3]!;
- s0 = t0 >>> 0;
- s1 = t1 >>> 0;
- s2 = t2 >>> 0;
- s3 = t3 >>> 0;
- }
-
- writeU32BE(b, 0, s0);
- writeU32BE(b, 4, s1);
- writeU32BE(b, 8, s2);
- writeU32BE(b, 12, s3);
-}
-
-function aes4(k: AesKeys, j: Uint8Array, i: Uint8Array, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void {
- xor4x16(j, i, l, src, dst);
- rounds(k.aes4, 4, dst);
-}
-
-function aes10(k: AesKeys, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void {
- xor16(src, l, dst);
- rounds(k.aes10, 10, dst);
-}
-
-// ── AEZ state ────────────────────────────────────────────────────────────────────────────────────
-
-type AezState = { I: Uint8Array[]; J: Uint8Array[]; L: Uint8Array[]; aes: AesKeys };
-
-/** AEZ tolerates any key length by hashing it to 48 bytes; a 48-byte key is used verbatim. */
-function extractKey(key: Uint8Array): Uint8Array {
- return key.length === EXTRACTED_KEY_SIZE ? key : blake2b(key, { dkLen: EXTRACTED_KEY_SIZE });
-}
-
-function init(key: Uint8Array): AezState {
- const ext = extractKey(key);
- const I = [block(), block()];
- const J = [block(), block(), block()];
- const L = Array.from({ length: 8 }, () => block());
-
- I[0]!.set(ext.subarray(0, 16));
- multBlock(2, I[0]!, I[1]!);
-
- J[0]!.set(ext.subarray(16, 32));
- multBlock(2, J[0]!, J[1]!);
- multBlock(2, J[1]!, J[2]!);
-
- // L[0] is deliberately left as the zero block — the reference never assigns it.
- L[1]!.set(ext.subarray(32, 48));
- multBlock(2, L[1]!, L[2]!);
- xor16(L[2]!, L[1]!, L[3]!);
- multBlock(2, L[2]!, L[4]!);
- xor16(L[4]!, L[1]!, L[5]!);
- multBlock(2, L[3]!, L[6]!);
- xor16(L[6]!, L[1]!, L[7]!);
-
- return { I, J, L, aes: aesKeys(ext) };
-}
-
-/** The AXU hash binding the tag length, the nonce and each associated-data string into `delta`. */
-function aezHash(st: AezState, nonce: Uint8Array | null, ad: Uint8Array[], tauBits: number): Uint8Array {
- const buf = block();
- const sum = block();
- const I = block();
- const J = block();
-
- writeU32BE(buf, 12, tauBits >>> 0);
- xor16(st.J[0]!, st.J[1]!, J);
- aes4(st.aes, J, st.I[1]!, st.L[1]!, buf, sum);
-
- const nonceEmpty = !nonce || nonce.length === 0;
- let n = nonce ?? new Uint8Array(0);
- let nBytes = n.length;
- I.set(st.I[1]!);
- for (let i = 1; nBytes >= BLOCK_SIZE; i++, nBytes -= BLOCK_SIZE) {
- aes4(st.aes, st.J[2]!, I, st.L[i % 8]!, n.subarray(0, BLOCK_SIZE), buf);
- xor16(sum, buf, sum);
- n = n.subarray(BLOCK_SIZE);
- if (i % 8 === 0) doubleBlock(I);
- }
- if (nBytes > 0 || nonceEmpty) {
- buf.fill(0);
- if (!nonceEmpty) buf.set(n.subarray(0, nBytes));
- buf[nBytes] = 0x80;
- aes4(st.aes, st.J[2]!, st.I[0]!, st.L[0]!, buf, buf);
- xor16(sum, buf, sum);
- }
-
- ad.forEach((entry, k) => {
- let p = entry ?? new Uint8Array(0);
- const adEmpty = p.length === 0;
- let bytes = p.length;
- I.set(st.I[1]!);
- multBlock(5 + k, st.J[0]!, J);
- for (let i = 1; bytes >= BLOCK_SIZE; i++, bytes -= BLOCK_SIZE) {
- aes4(st.aes, J, I, st.L[i % 8]!, p.subarray(0, BLOCK_SIZE), buf);
- xor16(sum, buf, sum);
- p = p.subarray(BLOCK_SIZE);
- if (i % 8 === 0) doubleBlock(I);
- }
- if (bytes > 0 || adEmpty) {
- buf.fill(0);
- if (!adEmpty) buf.set(p.subarray(0, bytes));
- buf[bytes] = 0x80;
- aes4(st.aes, J, st.I[0]!, st.L[0]!, buf, buf);
- xor16(sum, buf, sum);
- }
- });
-
- return sum;
-}
-
-/**
- * The short-input (< 32 byte) balanced Feistel network. `d` is 0 to encipher, 1 to decipher — the only
- * difference is which end of the round counter it starts from.
- */
-function aezTiny(st: AezState, delta: Uint8Array, input: Uint8Array, d: 0 | 1, dst: Uint8Array): void {
- const inBytes = input.length;
- const buf = block(2 * BLOCK_SIZE);
- const L = block();
- const R = block();
- const tmp = block();
- let mask = 0x00;
- let pad = 0x80;
-
- // Integer halves. The reference indexes with `inBytes/2` and leans on truncation; spelling the two
- // halves out makes the odd-length case readable instead of accidental.
- const half = inBytes >> 1;
- const halfUp = (inBytes + 1) >> 1;
-
- let i = 7;
- let roundCount: number;
- if (inBytes === 1) roundCount = 24;
- else if (inBytes === 2) roundCount = 16;
- else if (inBytes < 16) roundCount = 10;
- else {
- i = 6;
- roundCount = 8;
- }
-
- L.set(input.subarray(0, halfUp));
- R.set(input.subarray(half, half + halfUp));
-
- if (inBytes & 1) {
- // Odd length: the two halves overlap by a nibble, so shift R left by 4 bits and switch to
- // nibble-granular padding.
- for (let k = 0; k < half; k++) R[k] = ((R[k]! << 4) | (R[k + 1]! >> 4)) & 0xff;
- R[half] = (R[half]! << 4) & 0xff;
- pad = 0x08;
- mask = 0xf0;
- }
-
- let j: number;
- let step: number;
- if (d !== 0) {
- if (inBytes < 16) {
- buf.set(input.subarray(0, BLOCK_SIZE));
- buf[0] = buf[0]! | 0x80;
- xor16(delta, buf, buf);
- aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp);
- L[0] = L[0]! ^ (tmp[0]! & 0x80);
- }
- j = roundCount - 1;
- step = -1;
- } else {
- j = 0;
- step = 1;
- }
-
- for (let k = 0; k < roundCount / 2; k++, j += step * 2) {
- buf.fill(0, 0, BLOCK_SIZE);
- buf.set(R.subarray(0, halfUp));
- buf[half] = (buf[half]! & mask) | pad;
- xor16(buf, delta, buf);
- buf[15] = buf[15]! ^ (j & 0xff);
- aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp);
- xor16(L, tmp, L);
-
- buf.fill(0, 0, BLOCK_SIZE);
- buf.set(L.subarray(0, halfUp));
- buf[half] = (buf[half]! & mask) | pad;
- xor16(buf, delta, buf);
- buf[15] = buf[15]! ^ ((j + step) & 0xff);
- aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp);
- xor16(R, tmp, R);
- }
-
- buf.set(R.subarray(0, half), 0);
- buf.set(L.subarray(0, halfUp), half);
- if (inBytes & 1) {
- for (let k = inBytes - 1; k > half; k--) buf[k] = ((buf[k]! >> 4) | (buf[k - 1]! << 4)) & 0xff;
- buf[half] = (L[0]! >> 4) | (R[half]! & 0xf0);
- }
-
- dst.set(buf.subarray(0, inBytes));
-
- if (inBytes < 16 && d === 0) {
- buf.fill(0, inBytes, BLOCK_SIZE);
- buf[0] = buf[0]! | 0x80;
- xor16(delta, buf, buf);
- aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp);
- dst[0] = dst[0]! ^ (tmp[0]! & 0x80);
- }
-}
-
-/**
- * AEZ decrypt-and-verify. Returns the plaintext, or `null` when the `tau`-byte authenticator does not
- * check out — which is the signal that the key (and therefore the seed passphrase) is wrong.
- *
- * Throws for inputs outside the aezeed envelope rather than silently taking an untested path; see the
- * scope note at the top of this file.
- */
-export function aezDecrypt(
- key: Uint8Array,
- nonce: Uint8Array | null,
- ad: Uint8Array[],
- tau: number,
- ciphertext: Uint8Array,
-): Uint8Array | null {
- if (ciphertext.length >= 32 || ciphertext.length <= tau) {
- throw new Error(`aez: unsupported ciphertext length ${ciphertext.length} (decrypt is short-input only)`);
- }
-
- const st = init(key);
- const delta = aezHash(st, nonce, ad, tau * 8);
- const x = new Uint8Array(ciphertext.length);
- aezTiny(st, delta, ciphertext, 1, x);
-
- // Constant-time-ish: fold every authenticator byte before deciding, so the comparison does not
- // short-circuit on the first mismatch.
- let sum = 0;
- for (let i = 0; i < tau; i++) sum |= x[ciphertext.length - tau + i]!;
- if (sum !== 0) return null;
-
- return x.slice(0, ciphertext.length - tau);
-}
diff --git a/src/servers/sidecar/wallet/aezeed/aezeed.test.ts b/src/servers/sidecar/wallet/aezeed/aezeed.test.ts
deleted file mode 100644
index e27976a7..00000000
--- a/src/servers/sidecar/wallet/aezeed/aezeed.test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-import { generateMnemonic } from '@scure/bip39';
-import { wordlist } from '@scure/bip39/wordlists/english';
-import {
- AezeedError,
- birthdayToDate,
- decipherMnemonic,
- isAezeedPhrase,
- mnemonicToCipherSeedBytes,
-} from './cipher-seed';
-
-// Fixtures were produced by the `aezeed` npm package (an INDEPENDENT implementation, different author
-// and codebase, itself matching lnd/aezeed). Deterministic entropy and salt make them reproducible, so
-// agreement here is genuine cross-implementation evidence rather than this port agreeing with itself.
-//
-// entropy 000102...0f, salt 0102030405, internalVersion 0, birthday 5593
-const ENTROPY_HEX = '000102030405060708090a0b0c0d0e0f';
-const DEFAULT_PW =
- 'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen';
-const CUSTOM_PW =
- 'above shop hair museum laugh diamond win skin habit inspire hero box category crime boss black nest useless dog alcohol doctor radar urban split';
-const CUSTOM_PASSPHRASE = 'correct horse battery staple';
-// entropy ffee...00, salt a1b2c3d4e5, internalVersion 1, birthday 0
-const BIRTHDAY_ZERO =
- 'abstract three say vacant about cable tent neck other jeans certain accident arrest market couch front inflict laptop hole marble exact okay owner number';
-
-const split = (phrase: string): string[] => phrase.split(' ');
-const hex = (b: Uint8Array): string => Buffer.from(b).toString('hex');
-
-describe('isAezeedPhrase', () => {
- test('accepts aezeed cipher seeds', () => {
- expect(isAezeedPhrase(split(DEFAULT_PW))).toBe(true);
- expect(isAezeedPhrase(split(CUSTOM_PW))).toBe(true);
- expect(isAezeedPhrase(split(BIRTHDAY_ZERO))).toBe(true);
- });
-
- test('is passphrase-independent — structure only, no scrypt', () => {
- // Both fixtures hold the SAME entropy under different passphrases. Detection must not care.
- expect(isAezeedPhrase(split(DEFAULT_PW))).toBe(isAezeedPhrase(split(CUSTOM_PW)));
- });
-
- test('rejects genuine BIP39 mnemonics', () => {
- // The whole point: a 24-word BIP39 phrase is also 33 bytes, so only the CRC separates them.
- for (let i = 0; i < 25; i++) {
- expect(isAezeedPhrase(split(generateMnemonic(wordlist, 256)))).toBe(false);
- }
- });
-
- test('rejects wrong lengths and non-wordlist words', () => {
- expect(isAezeedPhrase(split(DEFAULT_PW).slice(0, 12))).toBe(false);
- expect(isAezeedPhrase([...split(DEFAULT_PW).slice(0, 23), 'notaword'])).toBe(false);
- expect(isAezeedPhrase([])).toBe(false);
- });
-
- test('rejects a single transposed word', () => {
- const words = split(DEFAULT_PW);
- const swapped = [...words];
- swapped[3] = words[4]!;
- swapped[4] = words[3]!;
- expect(isAezeedPhrase(swapped)).toBe(false);
- });
-});
-
-describe('decipherMnemonic', () => {
- test('recovers entropy under the default passphrase', async () => {
- const seed = await decipherMnemonic(split(DEFAULT_PW));
- expect(hex(seed.entropy)).toBe(ENTROPY_HEX);
- expect(seed.entropy).toHaveLength(16);
- expect(seed.internalVersion).toBe(0);
- expect(seed.birthday).toBe(5593);
- });
-
- test('recovers the same entropy under a custom passphrase', async () => {
- const seed = await decipherMnemonic(split(CUSTOM_PW), CUSTOM_PASSPHRASE);
- expect(hex(seed.entropy)).toBe(ENTROPY_HEX);
- });
-
- test('decodes internalVersion 1 and a zero birthday', async () => {
- const seed = await decipherMnemonic(split(BIRTHDAY_ZERO));
- expect(hex(seed.entropy)).toBe('ffeeddccbbaa99887766554433221100');
- expect(seed.internalVersion).toBe(1);
- expect(seed.birthday).toBe(0);
- });
-
- test('a wrong passphrase fails the AEZ tag rather than returning junk', async () => {
- // This is the property that makes the whole port safe: a bad key CANNOT yield plausible-looking
- // entropy, so we can never silently derive the wrong wallet.
- const err = await decipherMnemonic(split(CUSTOM_PW), 'not the passphrase').catch((e) => e);
- expect(err).toBeInstanceOf(AezeedError);
- expect(err.code).toBe('BAD_PASSPHRASE');
- });
-
- test('the default passphrase is not silently accepted for a custom-passphrase seed', async () => {
- const err = await decipherMnemonic(split(CUSTOM_PW)).catch((e) => e);
- expect(err.code).toBe('BAD_PASSPHRASE');
- });
-
- test('a corrupted checksum is caught before scrypt', async () => {
- const bytes = mnemonicToCipherSeedBytes(split(DEFAULT_PW))!;
- bytes[32] = bytes[32]! ^ 0xff;
- const { decipherCipherSeed } = await import('./cipher-seed');
- const err = await decipherCipherSeed(bytes, 'aezeed').catch((e) => e);
- expect(err.code).toBe('BAD_CHECKSUM');
- });
-
- test('rejects a non-aezeed phrase', async () => {
- const err = await decipherMnemonic(split(generateMnemonic(wordlist, 256))).catch((e) => e);
- expect(err).toBeInstanceOf(AezeedError);
- });
-});
-
-describe('framing', () => {
- test('24 words pack into exactly 33 bytes', () => {
- const bytes = mnemonicToCipherSeedBytes(split(DEFAULT_PW));
- expect(bytes).toHaveLength(33);
- expect(bytes![0]).toBe(0); // aezeed version
- });
-
- test('birthday maps to a real date', () => {
- // 5593 days after the genesis block.
- expect(birthdayToDate(5593).toISOString().slice(0, 10)).toBe('2024-04-27');
- expect(birthdayToDate(0).toISOString().slice(0, 10)).toBe('2009-01-03');
- });
-});
diff --git a/src/servers/sidecar/wallet/aezeed/cipher-seed.ts b/src/servers/sidecar/wallet/aezeed/cipher-seed.ts
deleted file mode 100644
index ccca58a3..00000000
--- a/src/servers/sidecar/wallet/aezeed/cipher-seed.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-import { scrypt as scryptCb } from 'node:crypto';
-import { promisify } from 'node:util';
-import { wordlist } from '@scure/bip39/wordlists/english';
-import { aezDecrypt } from './aez';
-
-// LND's aezeed cipher seed — the format Zeus's embedded LND node hands out as a "backup phrase".
-//
-// It is NOT BIP39, and that is the entire problem this module exists to solve. An aezeed phrase is 24
-// words drawn from the very same BIP39 English wordlist, so it is visually indistinguishable from a
-// BIP39 mnemonic, but the bits underneath are a different structure entirely:
-//
-// byte 0 version (0)
-// bytes 1..24 ciphertext (19-byte plaintext + 4-byte AEZ authenticator)
-// bytes 24..29 salt (scrypt salt for the passphrase)
-// bytes 29..33 CRC32-Castagnoli over bytes 0..29
-// ─────
-// 33 bytes = 24 words x 11 bits
-//
-// A BIP39 24-word phrase is also 33 bytes, but its last 8 bits are a SHA-256 checksum. So an aezeed
-// phrase fails BIP39 validation every single time, with an error that reads exactly like "you mistyped
-// a word" — which is unfixable advice, because nothing is mistyped.
-//
-// The deciphered plaintext is:
-//
-// byte 0 internal version
-// bytes 1..3 birthday (days since the Bitcoin genesis block, big-endian uint16)
-// bytes 3..19 entropy (16 bytes)
-//
-// That 16-byte entropy is used DIRECTLY as the BIP32 master seed — LND does not run it through BIP39's
-// PBKDF2. This is why an aezeed cannot be converted into an equivalent BIP39 phrase: doing so would
-// change the derived keys and produce an empty wallet at different addresses.
-
-const scrypt = promisify(scryptCb) as (
- password: string | Buffer,
- salt: Buffer,
- keylen: number,
- options: { N: number; r: number; p: number; maxmem: number },
-) => Promise;
-
-export const CIPHER_SEED_VERSION = 0;
-export const ENCIPHERED_SIZE = 33;
-export const ENTROPY_SIZE = 16;
-const PLAINTEXT_SIZE = 19;
-const SALT_SIZE = 5;
-const CHECKSUM_SIZE = 4;
-const CIPHERTEXT_EXPANSION = 4;
-const SALT_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE - SALT_SIZE; // 24
-const CHECKSUM_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE; // 29
-
-/**
- * LND's default when the user declines to set a seed passphrase. Not a secret — it is a literal
- * constant in lnd/aezeed, and every Zeus wallet created without an explicit passphrase uses it.
- */
-export const DEFAULT_AEZEED_PASSPHRASE = 'aezeed';
-
-// scrypt parameters for version 0, straight from lnd/aezeed/params.go.
-const SCRYPT_N = 32768;
-const SCRYPT_R = 8;
-const SCRYPT_P = 1;
-const KEY_LEN = 32;
-// 128 * N * r is ~33.5 MB here, over Node's 32 MB default, which fails with a bare MEMORY_LIMIT_EXCEEDED.
-const SCRYPT_MAXMEM = 64 * 1024 * 1024;
-
-/** LND counts a seed's birthday in days from this instant. */
-const BITCOIN_GENESIS = Date.UTC(2009, 0, 3, 18, 15, 5);
-const ONE_DAY_MS = 86_400_000;
-
-// ── CRC32-Castagnoli ─────────────────────────────────────────────────────────────────────────────
-//
-// Castagnoli (0x82F63B78), NOT the far more common IEEE polynomial. Getting this wrong makes every
-// valid seed look corrupt.
-
-const CRC32C_TABLE = Uint32Array.from({ length: 256 }, (_, n) => {
- let c = n;
- for (let k = 0; k < 8; k++) c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1;
- return c >>> 0;
-});
-
-function crc32c(bytes: Uint8Array): number {
- let c = 0xffffffff;
- for (const b of bytes) c = CRC32C_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
- return (c ^ 0xffffffff) >>> 0;
-}
-
-// ── mnemonic <-> bytes ───────────────────────────────────────────────────────────────────────────
-
-const WORD_INDEX = new Map(wordlist.map((w, i) => [w, i]));
-
-/**
- * Pack 24 words into the 33 raw bytes, 11 bits each. Returns null when any word is outside the
- * wordlist or the count is wrong — i.e. when this cannot be an aezeed phrase at all.
- */
-export function mnemonicToCipherSeedBytes(words: string[]): Uint8Array | null {
- if (words.length !== 24) return null;
-
- const bits: number[] = [];
- for (const word of words) {
- const index = WORD_INDEX.get(word);
- if (index === undefined) return null;
- for (let b = 10; b >= 0; b--) bits.push((index >> b) & 1);
- }
-
- const bytes = new Uint8Array(ENCIPHERED_SIZE);
- for (let i = 0; i < ENCIPHERED_SIZE; i++) {
- let v = 0;
- for (let b = 0; b < 8; b++) v = (v << 1) | bits[i * 8 + b]!;
- bytes[i] = v;
- }
- return bytes;
-}
-
-/**
- * Whether a phrase is an aezeed cipher seed. Cheap, offline, and decisive: it checks the version byte
- * and the CRC32C, so it never touches scrypt or the passphrase. Safe to run on every phrase the owner
- * types, and safe to ship to a client — it reveals nothing and needs no secret.
- */
-export function isAezeedPhrase(words: string[]): boolean {
- const bytes = mnemonicToCipherSeedBytes(words);
- if (!bytes) return false;
- if (bytes[0] !== CIPHER_SEED_VERSION) return false;
- return crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) === readU32BE(bytes, CHECKSUM_OFFSET);
-}
-
-const readU32BE = (b: Uint8Array, off: number): number =>
- ((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0;
-
-// ── decipher ─────────────────────────────────────────────────────────────────────────────────────
-
-export type CipherSeed = {
- internalVersion: number;
- /** Days since the Bitcoin genesis block. */
- birthday: number;
- /** The BIP32 master seed. 16 bytes. Caller is responsible for zeroing it. */
- entropy: Uint8Array;
-};
-
-export class AezeedError extends Error {
- constructor(
- message: string,
- readonly code: 'BAD_VERSION' | 'BAD_CHECKSUM' | 'BAD_PASSPHRASE' | 'BAD_LENGTH',
- ) {
- super(message);
- this.name = 'AezeedError';
- }
-}
-
-/** Convert a birthday (days since genesis) to a date — useful as a rescan floor. */
-export function birthdayToDate(birthday: number): Date {
- return new Date(BITCOIN_GENESIS + birthday * ONE_DAY_MS);
-}
-
-/**
- * Decipher a 33-byte aezeed. Throws AezeedError with a code the caller can turn into a useful message —
- * in particular BAD_PASSPHRASE, which is the difference between "your words are wrong" (they are not)
- * and "this seed has a passphrase set and we need it".
- */
-export async function decipherCipherSeed(bytes: Uint8Array, passphrase: string): Promise {
- if (bytes.length !== ENCIPHERED_SIZE) {
- throw new AezeedError(`aezeed must be ${ENCIPHERED_SIZE} bytes, got ${bytes.length}`, 'BAD_LENGTH');
- }
- if (bytes[0] !== CIPHER_SEED_VERSION) {
- throw new AezeedError(`unsupported aezeed version ${bytes[0]}`, 'BAD_VERSION');
- }
- if (crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) !== readU32BE(bytes, CHECKSUM_OFFSET)) {
- throw new AezeedError('aezeed checksum mismatch', 'BAD_CHECKSUM');
- }
-
- const salt = Buffer.from(bytes.subarray(SALT_OFFSET, SALT_OFFSET + SALT_SIZE));
- const key = await scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, {
- N: SCRYPT_N,
- r: SCRYPT_R,
- p: SCRYPT_P,
- maxmem: SCRYPT_MAXMEM,
- });
-
- // Associated data is the version byte followed by the salt, so a seed cannot be replayed under a
- // different version or salt.
- const ad = new Uint8Array(1 + SALT_SIZE);
- ad[0] = CIPHER_SEED_VERSION;
- ad.set(salt, 1);
-
- try {
- const plaintext = aezDecrypt(key, null, [ad], CIPHERTEXT_EXPANSION, bytes.subarray(1, SALT_OFFSET));
- if (!plaintext || plaintext.length !== PLAINTEXT_SIZE) {
- throw new AezeedError('incorrect seed passphrase', 'BAD_PASSPHRASE');
- }
- return {
- internalVersion: plaintext[0]!,
- birthday: (plaintext[1]! << 8) | plaintext[2]!,
- entropy: plaintext.slice(3, 3 + ENTROPY_SIZE),
- };
- } finally {
- key.fill(0);
- }
-}
-
-/** Convenience: words straight to a deciphered seed. */
-export async function decipherMnemonic(words: string[], passphrase = DEFAULT_AEZEED_PASSPHRASE): Promise {
- const bytes = mnemonicToCipherSeedBytes(words);
- if (!bytes) throw new AezeedError('not a 24-word phrase from the BIP39 wordlist', 'BAD_LENGTH');
- return decipherCipherSeed(bytes, passphrase);
-}
diff --git a/src/servers/sidecar/wallet/backends/base.ts b/src/servers/sidecar/wallet/backends/base.ts
deleted file mode 100644
index 1f5bd79f..00000000
--- a/src/servers/sidecar/wallet/backends/base.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-import {
- BackendError,
- type Balances,
- type BackendKind,
- type Capability,
- type Channel,
- type CreateInvoiceRequest,
- type DecodedInvoice,
- type FeeEstimates,
- type Invoice,
- type KeysendRequest,
- type NewAddressRequest,
- type NodeInfo,
- type OnchainTx,
- type PayInvoiceRequest,
- type Payment,
- type Peer,
- type SendCoinsRequest,
- type SendCoinsResult,
- type SignMessageResult,
- type Utxo,
- type VerifyMessageResult,
- type WalletBackend,
- type AddressType,
-} from '../types';
-
-/**
- * Shared backend base. Declares its capability set once and turns every unimplemented method into a
- * clean 501 — the counterpart to Zeus's `BackendUtils.call()` returning `false` for a missing method
- * (utils/BackendUtils.ts:56-58), but loud instead of silent.
- */
-export abstract class BaseBackend implements WalletBackend {
- abstract readonly kind: BackendKind;
- protected abstract readonly capabilities: ReadonlySet;
-
- supports(cap: Capability): boolean {
- return this.capabilities.has(cap);
- }
-
- protected notSupported(op: string): never {
- throw new BackendError(`${this.kind} does not support ${op}`, 501, 'NOT_SUPPORTED');
- }
-
- abstract getInfo(): Promise;
- abstract getBalances(): Promise;
-
- getTransactions(_opts?: { limit?: number }): Promise {
- return this.notSupported('on-chain transaction history');
- }
- getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
- return this.notSupported('on-chain receive');
- }
- getUtxos(): Promise {
- return this.notSupported('coin control');
- }
- estimateFees(): Promise {
- return this.notSupported('fee estimation');
- }
- sendCoins(_req: SendCoinsRequest): Promise {
- return this.notSupported('on-chain send');
- }
-
- getInvoices(_opts?: { limit?: number }): Promise {
- return this.notSupported('invoice listing');
- }
- createInvoice(_req: CreateInvoiceRequest): Promise {
- return this.notSupported('lightning receive');
- }
- lookupInvoice(_paymentHash: string): Promise {
- return this.notSupported('invoice lookup');
- }
- decodeInvoice(_bolt11: string): Promise {
- return this.notSupported('invoice decoding');
- }
- getPayments(_opts?: { limit?: number }): Promise {
- return this.notSupported('payment history');
- }
- payInvoice(_req: PayInvoiceRequest): Promise {
- return this.notSupported('lightning send');
- }
- sendKeysend(_req: KeysendRequest): Promise {
- return this.notSupported('keysend');
- }
-
- getChannels(): Promise {
- return this.notSupported('channels');
- }
- getPeers(): Promise {
- return this.notSupported('peers');
- }
-
- signMessage(_message: string): Promise {
- return this.notSupported('message signing');
- }
- verifyMessage(_message: string, _signature: string): Promise {
- return this.notSupported('message verification');
- }
-}
-
-// ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────
-
-// Zeus reaches its REST backends through `react-native-blob-util` with a `trusty: true` flag, which
-// disables TLS verification wholesale so a node with a self-signed cert is reachable (backends/LND.ts).
-// Bun's fetch takes an equivalent per-request `tls` option, so no extra HTTP client is needed.
-//
-// Verification is only relaxed when the owner explicitly opts in for a specific node, and it is a real
-// trade: it buys reachability for a node with a self-signed cert at the cost of MITM protection on that
-// connection. Acceptable over loopback or a tailnet, not over the open internet — which is why it is a
-// per-wallet flag the owner sets deliberately, never a default.
-type BunFetchInit = RequestInit & { tls?: { rejectUnauthorized?: boolean } };
-
-export type HttpOptions = {
- method?: string;
- path: string;
- base: string;
- headers?: Record;
- body?: unknown;
- query?: Record;
- allowSelfSigned?: boolean;
- timeoutMs?: number;
- /** Return the raw Response instead of parsed JSON. */
- raw?: boolean;
-};
-
-const DEFAULT_TIMEOUT_MS = 30_000;
-
-/** The single door every REST backend goes through. Normalizes errors into BackendError. */
-export async function httpJson(opts: HttpOptions): Promise {
- const base = opts.base.replace(/\/+$/, '');
- const url = new URL(`${base}${opts.path.startsWith('/') ? opts.path : `/${opts.path}`}`);
- for (const [k, v] of Object.entries(opts.query ?? {})) {
- if (v !== undefined) url.searchParams.set(k, String(v));
- }
-
- const headers: Record = { Accept: 'application/json', ...opts.headers };
- let body: string | undefined;
- if (opts.body !== undefined) {
- body = JSON.stringify(opts.body);
- headers['Content-Type'] ??= 'application/json';
- }
-
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
-
- const init: BunFetchInit = {
- method: opts.method ?? 'GET',
- headers,
- body,
- signal: controller.signal,
- };
- if (opts.allowSelfSigned) init.tls = { rejectUnauthorized: false };
-
- let res: Response;
- try {
- res = await fetch(url, init);
- } catch (err) {
- const msg = controller.signal.aborted ? `timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` : String(err);
- throw new BackendError(`request to ${url.host} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
- } finally {
- clearTimeout(timer);
- }
-
- if (opts.raw) return res as unknown as T;
-
- const text = await res.text();
- let parsed: unknown = null;
- if (text) {
- try {
- parsed = JSON.parse(text);
- } catch {
- if (!res.ok) throw new BackendError(`upstream ${res.status}: ${text.slice(0, 200)}`, res.status);
- throw new BackendError(`upstream returned non-JSON: ${text.slice(0, 200)}`, 502);
- }
- }
-
- if (!res.ok) {
- // LND puts {error, code}; CLN puts {message}; LNDHub puts {error, message}. Try all three.
- const p = parsed as { error?: string; message?: string; code?: number } | null;
- const msg = p?.error ?? p?.message ?? `upstream returned ${res.status}`;
- throw new BackendError(msg, res.status, p?.code != null ? String(p.code) : undefined);
- }
-
- return parsed as T;
-}
-
-/** Hex → base64. LND's REST API takes bytes fields base64-encoded; callers hold hex. */
-export function hexToBase64(hex: string): string {
- return Buffer.from(hex, 'hex').toString('base64');
-}
-
-/** Base64 → hex, for reading LND's bytes fields back out. */
-export function base64ToHex(b64: string): string {
- return Buffer.from(b64, 'base64').toString('hex');
-}
diff --git a/src/servers/sidecar/wallet/backends/clnrest.ts b/src/servers/sidecar/wallet/backends/clnrest.ts
deleted file mode 100644
index 8eabd636..00000000
--- a/src/servers/sidecar/wallet/backends/clnrest.ts
+++ /dev/null
@@ -1,1012 +0,0 @@
-// Core Lightning REST backend — ported from Zeus's `backends/CLNRest.ts` together with
-// `backends/CoreLightningRequestHandler.ts`, which holds all of the actual shaping logic (balances,
-// channels, peers, chain transactions) that CLNRest.ts delegates to.
-//
-// CLNRest is POST-only: every command is `POST /v1/` with a JSON body (`{}` when the command
-// takes no arguments) and a `Rune: ` header. There are no GET endpoints and no path parameters.
-//
-// UNITS. CLN reports millisatoshis two ways depending on the endpoint and on the node's deprecated-API
-// setting: a bare JSON number (`1234`) or a suffixed string (`"1234msat"`). Zeus assumes the number form
-// everywhere — `opArray[i].amount_msat / 1000` in CoreLightningRequestHandler — which yields NaN against a
-// node still emitting the string form. `parseMsat` below accepts both.
-//
-// INTENTIONAL BEHAVIOURAL DIFFERENCES FROM ZEUS
-//
-// 1. No Tor, no in-flight call de-duplication, no `trusty:`-style blanket TLS bypass — see the same three
-// notes in `lnd.ts`. Self-signed certs are accepted only when the owner opts in per node.
-// 2. `payLightningInvoice` no longer sends `amount_msat: Number(data.amt && data.amt * 1000)`. When `amt`
-// is undefined that expression is `0`, and CLN rejects an explicit zero amount on an invoice that
-// already carries one. We omit the field unless an amount was actually requested.
-// 3. `feeLimitMsat` is honoured via `pay`'s `maxfee`. Zeus declares `supportsCustomFeeLimit = () => false`
-// for CLN and only ever sends `maxfeepercent`; both forms are supported here.
-// 4. `signMessage` returns the **zbase** signature. CLN's signmessage returns `signature` (hex),
-// `recid` and `zbase`, but its own `checkmessage` only accepts zbase — returning the hex form would
-// produce a signature this backend could not verify.
-// 5. Channel states are reported rather than dropped. Zeus's `listPeerChannels` filters ONCHAIN, CLOSED
-// and CHANNELD_AWAITING_LOCKIN out of the list entirely because its flat model has nowhere to put
-// them; `Channel.status` does, so awaiting-lockin surfaces as 'pending-open'.
-// 6. Chain-transaction confirmations are `tip - height + 1`. Zeus computes `tip - height`, which reports
-// a transaction in the tip block as having zero confirmations.
-// 7. `getInvoices` recovers each invoice's creation time by reading the 35-bit timestamp straight out of
-// the BOLT11 data part. Zeus does the same thing via `Bolt11Utils.decode`; listinvoices genuinely has
-// no creation-time column, only `paid_at` and `expires_at`.
-// 8. `customPreimages` is NOT declared, matching Zeus's `supportsCustomPreimages = () => false`, even
-// though CLN's `invoice` command does accept a `preimage` argument.
-// 9. `accounts` is NOT declared — `supportsAccounts = () => false` in Zeus. CLN has no account concept.
-// 10. `NewAddressRequest.peek` is ignored: `newaddr` always derives a fresh address.
-// 11. `SendCoinsRequest.rbf` and `.label` are ignored — `withdraw` exposes neither.
-// 12. BOLT12 offer management (`listoffers`, `offer`, `disableoffer`, `fetchinvoice`, `invoicerequest`,
-// `sendinvoice`) and the askrene/getroutes/sendpay routing surface are not ported: `WalletBackend`
-// has no methods for them. The `offers` capability is still declared, since the node supports them.
-//
-// DEPENDENCIES ON CLN PLUGINS. `getInvoices`, `getPayments` and `getTransactions` go through the **sql**
-// plugin, exactly as Zeus does; `getTransactions` additionally reads the **bookkeeper**'s
-// `bkpr_accountevents` table. Both plugins ship with CLN and are enabled by default, but a node that has
-// disabled either will fail those three calls and no others.
-
-import * as bitcoin from 'bitcoinjs-lib';
-import {
- BackendError,
- type AddressType,
- type Balances,
- type BackendKind,
- type BitcoinNetwork,
- type Capability,
- type Channel,
- type CreateInvoiceRequest,
- type DecodedInvoice,
- type FeeEstimates,
- type Invoice,
- type InvoiceState,
- type KeysendRequest,
- type NewAddressRequest,
- type NodeInfo,
- type OnchainTx,
- type PayInvoiceRequest,
- type Payment,
- type PaymentStatus,
- type Peer,
- type SendCoinsRequest,
- type SendCoinsResult,
- type SignMessageResult,
- type Utxo,
- type VerifyMessageResult,
-} from '../types';
-import { BaseBackend, httpJson } from './base';
-
-// ── upstream wire types ──────────────────────────────────────────────────────────────────────────
-//
-// Field names are lightningd's own, verbatim. `Msat` marks every field that may arrive as either a number
-// or a `"…msat"` string — see the UNITS note above.
-
-type Msat = number | string;
-
-type ClnGetInfo = {
- id?: string;
- alias?: string;
- version?: string;
- blockheight?: number;
- network?: string;
- fees_collected_msat?: Msat;
- warning_bitcoind_sync?: string;
- warning_lightningd_sync?: string;
-};
-
-type ClnFundsOutput = {
- txid?: string;
- output?: number;
- amount_msat?: Msat;
- scriptpubkey?: string;
- address?: string;
- status?: string;
- blockheight?: number;
- reserved?: boolean;
-};
-
-type ClnFundsChannel = {
- peer_id?: string;
- our_amount_msat?: Msat;
- amount_msat?: Msat;
- connected?: boolean;
- state?: string;
-};
-
-type ClnListFunds = { outputs?: ClnFundsOutput[]; channels?: ClnFundsChannel[] };
-
-type ClnPeerChannel = {
- peer_id?: string;
- peer_connected?: boolean;
- state?: string;
- channel_id?: string;
- short_channel_id?: string;
- funding_txid?: string;
- funding_outnum?: number;
- total_msat?: Msat;
- to_us_msat?: Msat;
- private?: boolean;
-};
-
-type ClnPeer = { id?: string; connected?: boolean; num_channels?: number; netaddr?: string[] };
-
-type ClnNode = { nodeid?: string; alias?: string };
-
-type ClnTxOutput = { index?: number; amount_msat?: Msat; scriptPubKey?: string };
-
-type ClnTransaction = {
- hash?: string;
- rawtx?: string;
- blockheight?: number;
- txindex?: number;
- outputs?: ClnTxOutput[];
-};
-
-/** The sql plugin answers with positional rows in the order the SELECT names its columns. */
-type ClnSqlResult = { rows?: SqlCell[][] };
-
-type ClnFeerateEstimate = { blockcount?: number; feerate?: number; smoothed_feerate?: number };
-
-type ClnFeerates = {
- perkb?: {
- min_acceptable?: number;
- max_acceptable?: number;
- floor?: number;
- opening?: number;
- estimates?: ClnFeerateEstimate[];
- };
-};
-
-type ClnNewAddr = { bech32?: string; p2tr?: string };
-
-type ClnWithdraw = { tx?: string; txid?: string; psbt?: string };
-
-type ClnInvoiceResponse = {
- bolt11?: string;
- payment_hash?: string;
- payment_secret?: string;
- expires_at?: number;
- created_index?: number;
-};
-
-type ClnListInvoice = {
- label?: string;
- bolt11?: string;
- bolt12?: string;
- payment_hash?: string;
- amount_msat?: Msat;
- status?: string;
- amount_received_msat?: Msat;
- paid_at?: number;
- payment_preimage?: string;
- description?: string;
- expires_at?: number;
-};
-
-type ClnDecoded = {
- type?: string;
- valid?: boolean;
- created_at?: number;
- expiry?: number;
- payee?: string;
- amount_msat?: Msat;
- payment_hash?: string;
- description?: string;
- min_final_cltv_expiry?: number;
- features?: string;
- routes?: unknown[];
-};
-
-type ClnPayResult = {
- payment_preimage?: string;
- payment_hash?: string;
- created_at?: number;
- parts?: number;
- amount_msat?: Msat;
- amount_sent_msat?: Msat;
- status?: string;
- destination?: string;
-};
-
-type ClnSignMessage = { signature?: string; recid?: number; zbase?: string };
-
-type ClnCheckMessage = { verified?: boolean; pubkey?: string };
-
-// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
-
-/** Accepts `1234`, `"1234"` and `"1234msat"`; returns a decimal msat string, never a number. */
-function parseMsat(value: Msat | null | undefined): string | null {
- if (value == null) return null;
- if (typeof value === 'number') return Number.isFinite(value) ? Math.round(value).toString() : null;
- const match = /^(\d+)\s*(msat)?$/i.exec(value.trim());
- return match?.[1] ?? null;
-}
-
-/** msat (either form) → whole satoshis, truncated the way CLN's own sat-denominated views truncate. */
-function msatToSats(value: Msat | null | undefined): number {
- const msat = parseMsat(value);
- return msat == null ? 0 : Math.floor(Number(msat) / 1000);
-}
-
-function toNumber(value: unknown): number {
- const n = Number(value);
- return Number.isFinite(n) ? n : 0;
-}
-
-/**
- * Caller-supplied msat decimal string → the JSON number CLN's API expects.
- *
- * CLN takes amounts as JSON numbers, so a msat value above 2^53 would silently lose precision on the
- * way out — a request to pay 1 msat more than MAX_SAFE_INTEGER would be rounded rather than rejected.
- * The amounts involved are absurd (2^53 msat ≈ 90,000 BTC) but the failure is silent and the input is
- * caller-controlled, so it is checked rather than assumed. Mirrors toMsatNumber() in nwc.ts.
- */
-function msatToApiNumber(msat: string, field: string): number {
- let value: bigint;
- try {
- value = BigInt(msat);
- } catch {
- throw new BackendError(`${field} must be a decimal msat string, got "${msat}"`, 400, 'BAD_AMOUNT');
- }
- if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
- throw new BackendError(`${field}=${msat}msat is out of range`, 400, 'BAD_AMOUNT');
- }
- return Number(value);
-}
-
-function toNetwork(network: string | undefined): BitcoinNetwork {
- switch (network) {
- case 'testnet':
- case 'testnet4':
- return 'testnet';
- case 'signet':
- return 'signet';
- case 'regtest':
- return 'regtest';
- default:
- return 'bitcoin';
- }
-}
-
-const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
-
-/**
- * BOLT11 puts a 35-bit creation timestamp in the first seven characters of the data part, immediately
- * after the separator (the LAST '1' in the string, per the spec — the human-readable part may contain
- * one). Reading it directly avoids pulling in a full bech32 decoder just for `listinvoices`, which has no
- * creation-time column at all.
- */
-function bolt11Timestamp(bolt11: string | null | undefined): number | null {
- if (!bolt11) return null;
- const invoice = bolt11.toLowerCase();
- const separator = invoice.lastIndexOf('1');
- if (separator < 0) return null;
- const data = invoice.slice(separator + 1);
- if (data.length < 7) return null;
-
- let timestamp = 0;
- for (let i = 0; i < 7; i++) {
- const char = data[i];
- // indexOf('') would answer 0, so the empty case has to be rejected before the lookup.
- if (!char) return null;
- const value = BECH32_CHARSET.indexOf(char);
- if (value < 0) return null;
- timestamp = timestamp * 32 + value;
- }
- return timestamp;
-}
-
-// ── sql plugin cells ─────────────────────────────────────────────────────────────────────────────
-//
-// The sql plugin returns positional rows, so every read is an index into an array of loosely-typed cells.
-// These three readers keep that in one place instead of scattering casts through the mappers.
-
-type SqlCell = string | number | boolean | null | undefined;
-
-function cellStr(cell: SqlCell): string | null {
- return typeof cell === 'string' && cell.length > 0 ? cell : null;
-}
-
-function cellNum(cell: SqlCell): number | null {
- if (cell == null || typeof cell === 'boolean') return null;
- const n = Number(cell);
- return Number.isFinite(n) ? n : null;
-}
-
-function cellMsat(cell: SqlCell): Msat | undefined {
- return typeof cell === 'number' || typeof cell === 'string' ? cell : undefined;
-}
-
-/**
- * The sql plugin takes a whole SQL string, not bound parameters, so `limit` is the one caller-supplied
- * value that reaches it. Force it to a bounded integer before it is ever interpolated.
- */
-function clampLimit(limit: number | undefined, fallback: number): number {
- const n = Math.floor(Number(limit));
- if (!Number.isFinite(n) || n <= 0) return fallback;
- return Math.min(n, 5000);
-}
-
-/**
- * Address type by prefix. `listfunds` reports the address but not its kind, and the prefix is an exact
- * discriminator across every network CLN runs on, so no lookup is needed.
- */
-function addressType(address: string | undefined): AddressType | null {
- if (!address) return null;
- const addr = address.toLowerCase();
- const bech32Prefix = /^(bc1|tb1|bcrt1)/.exec(addr);
- if (bech32Prefix) return addr[bech32Prefix[0].length] === 'p' ? 'p2tr' : 'p2wpkh';
- if (addr.startsWith('3') || addr.startsWith('2')) return 'p2sh-p2wpkh';
- if (addr.startsWith('1') || addr.startsWith('m') || addr.startsWith('n')) return 'p2pkh';
- return null;
-}
-
-/** bitcoinjs has no signet parameter set; signet reuses testnet's version bytes and 'tb' hrp. */
-function bitcoinjsNetwork(network: BitcoinNetwork): bitcoin.Network {
- switch (network) {
- case 'regtest':
- return bitcoin.networks.regtest;
- case 'testnet':
- case 'signet':
- return bitcoin.networks.testnet;
- default:
- return bitcoin.networks.bitcoin;
- }
-}
-
-function toPaymentStatus(status: string | undefined): PaymentStatus {
- switch (status) {
- case 'complete':
- return 'succeeded';
- case 'failed':
- return 'failed';
- default:
- return 'pending';
- }
-}
-
-/**
- * CLN's channel state machine → the coarse status in `Channel`. Zeus never models this; it filters the
- * terminal states out and treats everything else as open.
- */
-function toChannelStatus(state: string | undefined): string {
- switch (state) {
- case 'CHANNELD_NORMAL':
- return 'open';
- case 'OPENINGD':
- case 'CHANNELD_AWAITING_LOCKIN':
- case 'DUALOPEND_OPEN_INIT':
- case 'DUALOPEND_AWAITING_LOCKIN':
- case 'DUALOPEND_OPEN_COMMITTED':
- case 'DUALOPEND_OPEN_COMMIT_READY':
- return 'pending-open';
- case 'CHANNELD_SHUTTING_DOWN':
- case 'CLOSINGD_SIGEXCHANGE':
- case 'CLOSINGD_COMPLETE':
- return 'pending-close';
- case 'AWAITING_UNILATERAL':
- case 'FUNDING_SPEND_SEEN':
- case 'ONCHAIN':
- return 'force-closing';
- case 'CLOSED':
- return 'closed';
- default:
- return state ?? 'open';
- }
-}
-
-/** Column indices for the two SQL projections below — positional rows are otherwise unreadable. */
-const INVOICE_COL = {
- label: 0,
- bolt11: 1,
- bolt12: 2,
- paymentHash: 3,
- amountMsat: 4,
- status: 5,
- amountReceivedMsat: 6,
- paidAt: 7,
- preimage: 8,
- description: 9,
- expiresAt: 10,
-} as const;
-
-const PAYMENT_COL = {
- paymentHash: 0,
- groupId: 1,
- status: 2,
- destination: 3,
- createdAt: 4,
- description: 5,
- bolt11: 6,
- bolt12: 7,
- amountSentMsat: 8,
- amountMsat: 9,
- preimage: 10,
-} as const;
-
-const ACCOUNT_EVENT_COL = {
- account: 0,
- tag: 1,
- outpoint: 2,
- creditMsat: 3,
- debitMsat: 4,
- timestamp: 5,
- blockheight: 6,
-} as const;
-
-/** TLV record carrying a free-text note alongside a keysend payment. */
-const KEYSEND_MESSAGE_RECORD = '34349334';
-
-export type ClnRestConfig = {
- /** Full base URL including scheme and port, e.g. `https://192.168.1.5:3010`. */
- url: string;
- /** The rune authorising this connection. Sent as the `Rune` header. */
- rune: string;
- allowSelfSigned?: boolean;
-};
-
-// ── the backend ──────────────────────────────────────────────────────────────────────────────────
-
-export class ClnRestBackend extends BaseBackend {
- readonly kind: BackendKind = 'cln-rest';
-
- protected readonly capabilities: ReadonlySet = new Set([
- 'onchainReceive',
- 'onchainSend',
- 'coinControl', // Zeus: supportsCoinControl / supportsChannelCoinControl
- 'sweep', // `withdraw … satoshi: "all"`
- 'lightningReceive',
- 'lightningSend',
- 'keysend',
- 'offers', // Zeus: supportsOffers / supportsListingOffers / supportsBolt12Address
- 'channels',
- 'peers',
- 'routing',
- 'signMessage',
- // Deliberately absent: psbt, bumpFee (supportsBumpFee = false), accounts (supportsAccounts = false),
- // customPreimages (supportsCustomPreimages = false).
- ]);
-
- constructor(private readonly config: ClnRestConfig) {
- super();
- }
-
- // ── transport ──────────────────────────────────────────────────────────────────────────────────
-
- /** Every CLNRest command is a POST with a JSON body; commands without arguments still need `{}`. */
- private call(command: string, body: Record = {}, timeoutMs?: number): Promise {
- return httpJson({
- base: this.config.url,
- path: `/v1/${command}`,
- method: 'POST',
- body,
- headers: { Rune: this.config.rune },
- allowSelfSigned: this.config.allowSelfSigned,
- timeoutMs,
- });
- }
-
- private sql(query: string): Promise {
- return this.call('sql', { query });
- }
-
- /**
- * Resolve node aliases in one pass. Zeus issues a `listnodes` per channel and per peer inside
- * `Promise.all` and lets a single failure reject the whole list; we de-duplicate by pubkey and swallow
- * individual lookups, because a missing alias must not cost you the channel list.
- */
- private async resolveAliases(pubkeys: string[]): Promise