Tenth plugin out, and the one whose extraction turned on a permission decision rather than a mechanical move. The wallet permission was kind: 'admin' — owner only, not grantable, invisible to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts forces it and the manifest has no field for anything else, deliberately, so that "can a plugin escalate itself" is a question which cannot be asked. So extraction could not preserve admin, and that was raised as a blocker before any code moved. It was the right change anyway, and the objection inverts. Every wallet query is scoped by user_id, and that id comes from X-Officer-User which the platform injects from the authenticated session — a caller cannot name someone else's. So granting this gives an account ITS OWN wallet. Not through the API and not with root either does it hand over anyone else's, because their seed is sealed under a passphrase the server never stores. kind: 'admin' was single-user thinking; grantable is how a second person gets a wallet at all. Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically, and the appName drives the route, the API prefix, the table prefix and the install key together. Tables renamed to bitcoin_wallet_* accordingly — the prefix guard normalises the hyphen. Free only because no wallet table existed on any server, which was checked before deciding: db:push treats a rename as a drop plus a create. The three custody test files moved with it and still run — 72 tests over scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by nine, so that trap was already closed. 68 palette classes converted, five status vocabularies hoisted into shared.ts. Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all read as warning, and the bitcoin and lightning rails no longer differ by hue. Deferred on purpose: watch-only data (xpubs, balances, history) stays unencrypted so a locked wallet still shows a balance. Anyone with the database can therefore SEE any user's balances; they can never move a coin.
187 lines
7.4 KiB
TypeScript
187 lines
7.4 KiB
TypeScript
import type { ChainCacheStore } from './backends/onchain';
|
|
import type { WalletChainSource } from './chain-source';
|
|
import type { ChainSource } from './upstream';
|
|
import {
|
|
getWallet,
|
|
getWalletSecrets,
|
|
getWalletChainCache,
|
|
saveWalletChainCache,
|
|
recordWalletChainError,
|
|
type WalletSummary,
|
|
} from '../db';
|
|
import { EsploraChain } from './chain';
|
|
import { EsploraChainSource } from './chain-source-esplora';
|
|
import { NbxplorerChain } from './nbxplorer';
|
|
import { NbxplorerChainSource } from './chain-source-nbxplorer';
|
|
import { LndBackend } from './backends/lnd';
|
|
import { ClnRestBackend } from './backends/clnrest';
|
|
import { LndHubBackend } from './backends/lndhub';
|
|
import { NwcBackend } from './backends/nwc';
|
|
import { OnchainBackend } from './backends/onchain';
|
|
import { sessionFor } from './keys';
|
|
import { getChainSource } from './upstream';
|
|
import { BackendError, BIP_ADDRESS_TYPE, type AddressType, type BitcoinNetwork, type WalletBackend } from './types';
|
|
|
|
// Turns a stored wallet row into a live backend instance. This is the one place that knows the mapping
|
|
// from `kind` to a class, and the one place node credentials are decrypted — getWalletSecrets() is
|
|
// called here and the plaintext never travels further than the constructor it is handed to.
|
|
//
|
|
// Instances are cached per wallet id. Backends hold connection state worth reusing (LNDHub's access
|
|
// token, NWC's relay socket, the on-chain gap-limit scan), and rebuilding one per request would both
|
|
// re-authenticate constantly and defeat the address-scan cache. The cache is invalidated whenever the
|
|
// wallet's config changes — see `invalidate()`, called from the update/delete routes.
|
|
|
|
type Cached = { backend: WalletBackend; configVersion: string };
|
|
const cache = new Map<number, Cached>();
|
|
|
|
export function invalidate(walletId: number): void {
|
|
cache.delete(walletId);
|
|
}
|
|
|
|
export function invalidateAll(): void {
|
|
cache.clear();
|
|
}
|
|
|
|
/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */
|
|
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): string {
|
|
// The kind is in here as well as the URL: switching protocol at the same host is a different backend
|
|
// with a differently-shaped cache behind it, and a fingerprint on the URL alone would miss it.
|
|
return JSON.stringify([
|
|
wallet.kind,
|
|
wallet.network,
|
|
wallet.defaultBip,
|
|
wallet.xpubs,
|
|
config,
|
|
source.kind,
|
|
source.baseUrl,
|
|
]);
|
|
}
|
|
|
|
export type Resolved = { wallet: WalletSummary; backend: WalletBackend };
|
|
|
|
export async function resolveBackend(userId: number, walletId: number): Promise<Resolved> {
|
|
const wallet = await getWallet(userId, walletId);
|
|
if (!wallet) throw new BackendError('wallet not found', 404, 'NOT_FOUND');
|
|
|
|
const secrets = await getWalletSecrets(userId, walletId);
|
|
const config = secrets?.config ?? null;
|
|
// Read per resolve rather than per build: the source is in the fingerprint, so pointing the owner at a
|
|
// different indexer rebuilds every backend and drops the scan taken through the old one.
|
|
const source = await getChainSource(userId);
|
|
const version = versionOf(wallet, config, source);
|
|
|
|
const hit = cache.get(walletId);
|
|
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
|
|
|
|
const backend = build(wallet, config, source);
|
|
cache.set(walletId, { backend, configVersion: version });
|
|
return { wallet, backend };
|
|
}
|
|
|
|
/**
|
|
* Binds the chain cache queries to one wallet id. This is the only thing that gives the on-chain
|
|
* backend a persistent identity — the backend itself never learns which wallet it is, exactly as it
|
|
* never learns which seed it derives from.
|
|
*/
|
|
function chainCacheFor(walletId: number): ChainCacheStore {
|
|
return {
|
|
load: () => getWalletChainCache(walletId),
|
|
save: (snapshot) => saveWalletChainCache(walletId, snapshot),
|
|
recordError: (message) => recordWalletChainError(walletId, message),
|
|
};
|
|
}
|
|
|
|
function required(config: Record<string, unknown> | null, key: string, kind: string): string {
|
|
const v = config?.[key];
|
|
if (typeof v !== 'string' || !v) {
|
|
throw new BackendError(`${kind} wallet is missing required config "${key}"`, 400, 'BAD_CONFIG');
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function build(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): WalletBackend {
|
|
const network = wallet.network as BitcoinNetwork;
|
|
|
|
switch (wallet.kind) {
|
|
case 'lnd':
|
|
return new LndBackend({
|
|
url: required(config, 'url', 'lnd'),
|
|
macaroonHex: required(config, 'macaroonHex', 'lnd'),
|
|
allowSelfSigned: config?.allowSelfSigned === true,
|
|
});
|
|
|
|
case 'cln-rest':
|
|
return new ClnRestBackend({
|
|
url: required(config, 'url', 'cln-rest'),
|
|
rune: required(config, 'rune', 'cln-rest'),
|
|
allowSelfSigned: config?.allowSelfSigned === true,
|
|
});
|
|
|
|
case 'lndhub':
|
|
return new LndHubBackend({
|
|
url: required(config, 'url', 'lndhub'),
|
|
login: required(config, 'login', 'lndhub'),
|
|
password: required(config, 'password', 'lndhub'),
|
|
});
|
|
|
|
case 'nwc':
|
|
return new NwcBackend({ connectionUri: required(config, 'connectionUri', 'nwc') });
|
|
|
|
case 'onchain': {
|
|
// Every xpub the wallet holds is handed over, not just the default BIP's. A seed derives all four
|
|
// accounts (keys.ts::deriveAccountXpubs), and coins can legitimately sit on any of them — a
|
|
// recovered seed may have been used with a p2tr wallet before, or received to a legacy address.
|
|
// Scanning only the default account would silently under-report the balance and leave those UTXOs
|
|
// unspendable. `defaultBip` then means only "which script type new receive addresses use".
|
|
const accountXpub: Partial<Record<AddressType, string>> = {};
|
|
for (const [bip, type] of Object.entries(BIP_ADDRESS_TYPE)) {
|
|
const xpub = wallet.xpubs?.[bip];
|
|
if (xpub) accountXpub[type] = xpub;
|
|
}
|
|
if (Object.keys(accountXpub).length === 0) {
|
|
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
|
|
}
|
|
return new OnchainBackend({
|
|
chain: chainSourceFor(source, network),
|
|
network,
|
|
accountXpub,
|
|
// The session is the signer. While locked it holds no key material, so watch-only reads below
|
|
// still work and only sendCoins/signMessage will throw WalletLockedError.
|
|
signer: sessionFor(wallet.id),
|
|
cache: chainCacheFor(wallet.id),
|
|
});
|
|
}
|
|
|
|
default:
|
|
throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The one place the owner's chosen protocol turns into an implementation. Both satisfy the same
|
|
* interface, and OnchainBackend cannot tell which it was handed — that is the entire point of the seam
|
|
* (chain-source.ts). Adding a third kind is a case here and a file next to it, and nothing else.
|
|
*/
|
|
function chainSourceFor(source: ChainSource, network: BitcoinNetwork): WalletChainSource {
|
|
const label = `${source.kind}(${hostOf(source.baseUrl)})`;
|
|
|
|
if (source.kind === 'nbxplorer') {
|
|
return new NbxplorerChainSource({
|
|
chain: new NbxplorerChain({ baseUrl: source.baseUrl, network }),
|
|
network,
|
|
label,
|
|
});
|
|
}
|
|
|
|
return new EsploraChainSource({ chain: new EsploraChain({ baseUrl: source.baseUrl, network }), label });
|
|
}
|
|
|
|
/** Host only, for a chain-source label. A malformed URL is labelled with itself rather than throwing. */
|
|
function hostOf(url: string): string {
|
|
try {
|
|
return new URL(url).host;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|