add the bitcoin wallet sidecar and ui

the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.

officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.

no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.

not reviewed line by line; assembled and verified to build, not audited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 06:48:06 +00:00
co-authored by Claude Opus 5
parent 5ee56e736b
commit f8826e4c24
69 changed files with 11867 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
import { getWallet, getWalletSecrets, type WalletSummary } from 'officerdb';
import { EsploraChain } from './chain';
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 { getConfig } 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): string {
return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config]);
}
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;
const version = versionOf(wallet, config);
const hit = cache.get(walletId);
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
const backend = build(wallet, config);
cache.set(walletId, { backend, configVersion: version });
return { wallet, backend };
}
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): 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');
}
const { esploraUrl } = getConfig();
return new OnchainBackend({
chain: new EsploraChain({ baseUrl: esploraUrl, 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),
});
}
default:
throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG');
}
}