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.
173 lines
7.8 KiB
TypeScript
173 lines
7.8 KiB
TypeScript
// What a chain source owes the on-chain wallet, and the vocabulary the two halves speak.
|
|
//
|
|
// THE SPLIT IS AT THE SCAN, NOT AT THE HTTP CLIENT. This is the whole point of the file and it is not
|
|
// obvious, so: Esplora is address-level — you ask about one script at a time, and finding a wallet means
|
|
// walking the gap limit yourself, 43-160 requests a refresh. NBXplorer is wallet-level — you register the
|
|
// account xpub once and then every question is a single call. There is no common denominator at the
|
|
// request layer, because NBXplorer simply has no "tell me about this one address" endpoint to implement.
|
|
//
|
|
// So the seam is drawn one level up, at `scan()`: "read this wallet off the chain and hand back what you
|
|
// found". Esplora satisfies it with a gap-limit walk; NBXplorer satisfies it with three calls and no walk
|
|
// at all. Everything downstream — balances, coin selection, PSBT construction, signing, broadcast — works
|
|
// off the ScanResult and does not know or care which one answered.
|
|
//
|
|
// Deriving addresses is NOT a source's job. The keys belong to the backend, and `ScanContext.derive` is
|
|
// how a source asks for one. A source that could derive independently could disagree with the wallet
|
|
// about which addresses are the wallet's, which is the kind of bug that loses coins rather than failing.
|
|
|
|
import type { SpendableUtxo } from './psbt';
|
|
import type { AddressType, FeeEstimates, OnchainTx, RescanOptions, RescanState } from './types';
|
|
|
|
/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */
|
|
export type ChainIndex = 0 | 1;
|
|
|
|
/** Hard stop on a runaway scan — a misconfigured xpub against a busy chain must not loop forever. */
|
|
export const MAX_SCAN_INDEX = 1_000;
|
|
|
|
/** Parallel upstream requests. Public Esplora instances rate-limit, so this stays modest. */
|
|
export const REQUEST_CONCURRENCY = 6;
|
|
|
|
/** One address the wallet owns, fully derived. Produced by the backend, consumed by the source. */
|
|
export type AddressEntry = {
|
|
type: AddressType;
|
|
chain: ChainIndex;
|
|
index: number;
|
|
address: string;
|
|
/** Compressed 33-byte pubkey, hex. */
|
|
pubkeyHex: string;
|
|
scriptPubKeyHex: string;
|
|
/** Full path from the wallet root — what the signer derives with. */
|
|
path: string;
|
|
/** Path relative to the account xpub, which is what the public Utxo type carries. */
|
|
relPath: string;
|
|
};
|
|
|
|
/** An address the source looked up, with what the chain says about it. */
|
|
export type ScannedAddress = AddressEntry & {
|
|
confirmedSats: number;
|
|
unconfirmedSats: number;
|
|
txCount: number;
|
|
used: boolean;
|
|
};
|
|
|
|
/**
|
|
* The account keys, in the form a wallet-level source needs them.
|
|
*
|
|
* `accountXpub` is the string as stored, which for every BIP this wallet supports is plain `xpub`-version
|
|
* bytes — NBXplorer's derivation schemes are built straight from it with no SLIP-132 conversion.
|
|
*/
|
|
export type ScanAccount = { type: AddressType; accountXpub: string };
|
|
|
|
/**
|
|
* Everything a source is handed to perform one scan. Passed per call rather than held at construction so
|
|
* a source stays stateless with respect to the wallet — the same source instance is safe to share.
|
|
*/
|
|
export type ScanContext = {
|
|
accounts: readonly ScanAccount[];
|
|
/** The wallet's own script type. Address-level sources walk this one unconditionally. */
|
|
defaultType: AddressType;
|
|
derive: (type: AddressType, chain: ChainIndex, index: number) => AddressEntry;
|
|
};
|
|
|
|
/**
|
|
* A rescan in flight.
|
|
*
|
|
* `state` is the SAME object the source keeps and mutates as the scan progresses, so a caller that holds
|
|
* it sees the counters move without asking again. `done` is how the wallet knows to re-read the chain:
|
|
* a rescan that found coins has changed nothing until the snapshot behind it is rebuilt.
|
|
*/
|
|
export type RescanHandle = { state: RescanState; done: Promise<RescanState> };
|
|
|
|
/** One coherent read of the chain — balances, coins and history as of the same moment. */
|
|
export type ScanResult = {
|
|
tipHeight: number;
|
|
addresses: ScannedAddress[];
|
|
utxos: SpendableUtxo[];
|
|
txs: OnchainTx[];
|
|
};
|
|
|
|
/**
|
|
* The chain, as the on-chain wallet needs it.
|
|
*
|
|
* Five methods, and only `scan` is interesting. The other four are point lookups that every backend in
|
|
* existence offers in some form, so they map one-to-one onto whatever is upstream.
|
|
*/
|
|
export interface WalletChainSource {
|
|
/** Human-readable, for logs and the sync-state surface. e.g. `esplora(mempool.space)`. */
|
|
readonly label: string;
|
|
|
|
/**
|
|
* How many indices past the highest one `scan` reported this source will still find a payment on.
|
|
*
|
|
* Issuance must not outrun the search that finds the money again. `nextUnused` advances a mark every
|
|
* time an address is handed out, so a run of addresses issued and never paid walks the mark forward
|
|
* with nothing marking those indices used — and a later payment to one of them lands beyond the
|
|
* window the next scan covers. On a source that cannot rescan, that is an invisible, unrecoverable
|
|
* balance, from nothing worse than clicking "new address" too many times.
|
|
*
|
|
* Esplora is 0: its walk already extends a full gap limit past the last used address, so every index
|
|
* `scan` returned is covered and none beyond it is. NBXplorer reports only up to its own unused mark
|
|
* but watches a gap beyond it, so a modest overrun is still seen.
|
|
*/
|
|
readonly issueAhead: number;
|
|
|
|
/** Current best block height. The cheapest liveness probe the wallet has. */
|
|
getTipHeight(): Promise<number>;
|
|
|
|
getFeeEstimates(): Promise<FeeEstimates>;
|
|
|
|
/**
|
|
* Raw hex of one transaction. Called only for `p2pkh` inputs, which commit to the whole previous
|
|
* transaction and so need it in the PSBT; segwit inputs carry their own value and script.
|
|
*/
|
|
getTxHex(txid: string): Promise<string>;
|
|
|
|
/** Publish a signed transaction. Resolves to the txid, throws if the network rejected it. */
|
|
broadcast(rawHex: string): Promise<string>;
|
|
|
|
/** Read the whole wallet off the chain. */
|
|
scan(ctx: ScanContext): Promise<ScanResult>;
|
|
|
|
/**
|
|
* Search the chain for this wallet's coins from scratch, rather than from whenever the upstream
|
|
* started watching it. Returns immediately — a rescan takes minutes, and the caller is an HTTP route.
|
|
*
|
|
* `opts.gapLimit` widens the search past the source's own default, for a wallet restored from one that
|
|
* issued addresses in bulk. Ignored by a source with no such knob.
|
|
*
|
|
* Optional, and Esplora does not implement it — it has nothing to rescan, because a gap-limit walk
|
|
* already asks about every address every time. This exists for an upstream that *indexes*, where a
|
|
* newly registered account starts empty and stays empty until told to go and look.
|
|
*/
|
|
startRescan?(accounts: readonly ScanAccount[], opts?: RescanOptions): RescanHandle;
|
|
|
|
/**
|
|
* Find a scan already in flight upstream and return a handle to it, without starting one.
|
|
*
|
|
* Same handle contract as `startRescan`, so the caller chains its refresh onto `done` identically —
|
|
* which is the point: an adopted scan has to end in a snapshot rebuild too, or the coins it recovered
|
|
* stay invisible. Null when nothing is running.
|
|
*/
|
|
adoptRescan?(accounts: readonly ScanAccount[]): Promise<RescanHandle | null>;
|
|
|
|
/** The rescan in flight, or the last one's outcome. Null when this source has never run one. */
|
|
rescanState?(): RescanState | null;
|
|
}
|
|
|
|
/** Bounded-concurrency map that preserves input order. Address-level sources fan out wide. */
|
|
export async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
const out = new Array<R>(items.length);
|
|
let cursor = 0;
|
|
const worker = async (): Promise<void> => {
|
|
for (;;) {
|
|
const i = cursor++;
|
|
if (i >= items.length) return;
|
|
const item = items[i];
|
|
if (item === undefined) continue;
|
|
out[i] = await fn(item);
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
return out;
|
|
}
|