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:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user