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.
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
// The wallet sidecar's backend contract — a typed re-statement of what Zeus's utils/BackendUtils.ts
|
|
// dispatches to duck-typed across its seven backends.
|
|
//
|
|
// Two things are deliberately NOT copied from Zeus:
|
|
//
|
|
// 1. Zeus's `call(funcName, args)` returns `false` when a backend lacks a method, so an unsupported
|
|
// feature and a typo'd method name are indistinguishable at the call site. Here the surface is a
|
|
// TypeScript interface and capability negotiation is explicit via `supports()`.
|
|
// 2. Zeus threads URL path segments through the interface — `decodePaymentRequest(urlParams[0])`,
|
|
// `closeChannel(urlParams)`. Every operation below takes a named request object.
|
|
//
|
|
// UNITS. Amounts are satoshis as `number` (max 2.1e15, safely inside 2^53) and millisatoshis as
|
|
// decimal `string` (2.1e18 overflows a double). Never widen a msat to a number.
|
|
|
|
export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
|
|
|
|
export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest';
|
|
|
|
/**
|
|
* Explicit capability flags, replacing Zeus's ~60 `supportsX()` predicates (BackendUtils.ts:200-270).
|
|
* A backend declares these once; routes.ts checks before dispatching so an unsupported operation is a
|
|
* clean 501 rather than a mystery failure deep in an upstream call.
|
|
*/
|
|
export type Capability =
|
|
// on-chain
|
|
| 'onchainReceive'
|
|
| 'onchainSend'
|
|
| 'coinControl'
|
|
| 'psbt'
|
|
| 'bumpFee'
|
|
| 'sweep'
|
|
| 'accounts'
|
|
// lightning
|
|
| 'lightningReceive'
|
|
| 'lightningSend'
|
|
| 'keysend'
|
|
| 'customPreimages'
|
|
| 'offers'
|
|
// node operation
|
|
| 'channels'
|
|
| 'peers'
|
|
| 'routing'
|
|
| 'signMessage';
|
|
|
|
// ── node / balances ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type NodeInfo = {
|
|
kind: BackendKind;
|
|
/** Node pubkey, or null for backends with no node identity of their own (onchain, lndhub). */
|
|
pubkey: string | null;
|
|
alias: string | null;
|
|
/** Upstream implementation version, verbatim. Null when the backend cannot report one. */
|
|
version: string | null;
|
|
network: BitcoinNetwork;
|
|
blockHeight: number | null;
|
|
synced: boolean;
|
|
};
|
|
|
|
export type Balances = {
|
|
/** Confirmed on-chain, in sats. Includes anything frozen — see `onchainFrozen`. */
|
|
onchainConfirmed: number;
|
|
onchainUnconfirmed: number;
|
|
/**
|
|
* How much of `onchainConfirmed` sits in UTXOs the owner froze, and so cannot be spent.
|
|
*
|
|
* A component of the confirmed figure rather than a deduction from it, because the coins are still
|
|
* the wallet's — freezing is a spending policy, not a loss. Officer's overlay: freezing lives in
|
|
* Postgres and no backend knows about it, so every backend reports 0 and the route fills it in.
|
|
* Without this the UI shows a balance a max-value send then refuses to spend, with no visible reason.
|
|
*/
|
|
onchainFrozen: number;
|
|
/** Sum of local balance across active channels, in sats. Null when the backend has no channels. */
|
|
lightningBalance: number | null;
|
|
/** Sum of remote balance — i.e. inbound liquidity — in sats. */
|
|
lightningInbound: number | null;
|
|
};
|
|
|
|
// ── on-chain ─────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
|
|
|
|
/**
|
|
* BIP44 purpose → the script type it derives. Keyed by the string form, because that is how a wallet's
|
|
* `xpubs` map arrives from the database (JSON object keys are always strings).
|
|
*/
|
|
export const BIP_ADDRESS_TYPE: Record<string, AddressType> = {
|
|
'44': 'p2pkh',
|
|
'49': 'p2sh-p2wpkh',
|
|
'84': 'p2wpkh',
|
|
'86': 'p2tr',
|
|
};
|
|
|
|
/** Narrow caller-supplied text to an AddressType, or undefined if it names none. */
|
|
export function asAddressType(value: string): AddressType | undefined {
|
|
return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh' ? value : undefined;
|
|
}
|
|
|
|
export type OnchainTx = {
|
|
txid: string;
|
|
/** Net effect on this wallet in sats — negative for a spend. */
|
|
amount: number;
|
|
feeSats: number | null;
|
|
blockHeight: number | null;
|
|
/** Unix seconds. Null while unconfirmed on backends that do not timestamp the mempool. */
|
|
timestamp: number | null;
|
|
confirmations: number;
|
|
label: string | null;
|
|
destAddresses: string[];
|
|
/** Raw tx hex when the backend supplies it, for RBF/inspection. */
|
|
rawHex: string | null;
|
|
};
|
|
|
|
export type Utxo = {
|
|
txid: string;
|
|
vout: number;
|
|
amountSats: number;
|
|
address: string;
|
|
addressType: AddressType | null;
|
|
confirmations: number;
|
|
/** BIP32 path relative to the account xpub, when this UTXO belongs to a derived address. */
|
|
derivationPath: string | null;
|
|
/** Frozen UTXOs are excluded from automatic coin selection. */
|
|
frozen: boolean;
|
|
};
|
|
|
|
export type FeeEstimates = {
|
|
/** sat/vB, keyed by confirmation target in blocks. */
|
|
fastestFee: number;
|
|
halfHourFee: number;
|
|
hourFee: number;
|
|
economyFee: number;
|
|
minimumFee: number;
|
|
};
|
|
|
|
export type NewAddressRequest = {
|
|
type?: AddressType;
|
|
/** Return the current unused address instead of advancing the derivation index. */
|
|
peek?: boolean;
|
|
};
|
|
|
|
export type SendCoinsRequest = {
|
|
address: string;
|
|
/** Ignored when `sendAll` is set. */
|
|
amountSats?: number;
|
|
sendAll?: boolean;
|
|
satPerVbyte: number;
|
|
/** Coin control: restrict inputs to these outpoints (`txid:vout`). Requires the `coinControl` capability. */
|
|
outpoints?: string[];
|
|
spendUnconfirmed?: boolean;
|
|
/** Opt out of RBF signalling. Default is replaceable. */
|
|
rbf?: boolean;
|
|
label?: string;
|
|
/**
|
|
* Outpoints automatic selection must not touch. SET BY THE ROUTE FROM OFFICER'S FROZEN LIST, never by
|
|
* the caller — whatever arrives in the request body is overwritten.
|
|
*
|
|
* Freezing is Officer policy stored in Postgres, and a backend has no business reading that table; so
|
|
* the frozen set has to travel with the request. Note it only ever restricts what may be spent, so
|
|
* even a caller who did smuggle a value in could not widen the selection.
|
|
*/
|
|
frozenOutpoints?: readonly string[];
|
|
};
|
|
|
|
export type SendCoinsResult = {
|
|
txid: string;
|
|
feeSats: number;
|
|
rawHex: string | null;
|
|
};
|
|
|
|
// ── lightning ────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired';
|
|
|
|
export type Invoice = {
|
|
/** Payment hash, hex. */
|
|
paymentHash: string;
|
|
/** BOLT11 payment request. */
|
|
bolt11: string;
|
|
amountMsat: string | null;
|
|
amountPaidMsat: string | null;
|
|
memo: string | null;
|
|
state: InvoiceState;
|
|
createdAt: number;
|
|
expiresAt: number | null;
|
|
settledAt: number | null;
|
|
/** Only ever populated for invoices this wallet created and only when explicitly requested. */
|
|
preimage: string | null;
|
|
isKeysend: boolean;
|
|
isAmp: boolean;
|
|
};
|
|
|
|
export type CreateInvoiceRequest = {
|
|
amountMsat?: string;
|
|
memo?: string;
|
|
expirySeconds?: number;
|
|
/** Requires the `customPreimages` capability. */
|
|
preimage?: string;
|
|
isAmp?: boolean;
|
|
private?: boolean;
|
|
};
|
|
|
|
export type DecodedInvoice = {
|
|
bolt11: string;
|
|
paymentHash: string;
|
|
amountMsat: string | null;
|
|
description: string | null;
|
|
destination: string;
|
|
timestamp: number;
|
|
expiry: number;
|
|
cltvExpiry: number | null;
|
|
routeHints: boolean;
|
|
features: string[];
|
|
};
|
|
|
|
export type PaymentStatus = 'pending' | 'succeeded' | 'failed';
|
|
|
|
export type Payment = {
|
|
paymentHash: string;
|
|
preimage: string | null;
|
|
amountMsat: string;
|
|
feeMsat: string;
|
|
status: PaymentStatus;
|
|
createdAt: number;
|
|
destination: string | null;
|
|
memo: string | null;
|
|
/** Upstream failure reason, verbatim, when status is 'failed'. */
|
|
failureReason: string | null;
|
|
};
|
|
|
|
export type PayInvoiceRequest = {
|
|
bolt11: string;
|
|
/** Required when the invoice is zero-amount; rejected otherwise. */
|
|
amountMsat?: string;
|
|
/** Absolute cap in msat. Mutually exclusive with feeLimitPercent. */
|
|
feeLimitMsat?: string;
|
|
feeLimitPercent?: number;
|
|
timeoutSeconds?: number;
|
|
};
|
|
|
|
export type KeysendRequest = {
|
|
destination: string;
|
|
amountMsat: string;
|
|
feeLimitMsat?: string;
|
|
message?: string;
|
|
};
|
|
|
|
// ── channels / peers ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type Channel = {
|
|
channelId: string;
|
|
channelPoint: string | null;
|
|
remotePubkey: string;
|
|
remoteAlias: string | null;
|
|
capacitySats: number;
|
|
localBalanceSats: number;
|
|
remoteBalanceSats: number;
|
|
active: boolean;
|
|
private: boolean;
|
|
/** 'open' | 'pending-open' | 'pending-close' | 'force-closing' | 'closed' */
|
|
status: string;
|
|
};
|
|
|
|
export type Peer = {
|
|
pubkey: string;
|
|
address: string;
|
|
alias: string | null;
|
|
inbound: boolean;
|
|
};
|
|
|
|
// ── signing ──────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type SignMessageResult = { signature: string };
|
|
export type VerifyMessageResult = { valid: boolean; pubkey: string | null };
|
|
|
|
// ── the interface ────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Every method may throw `BackendError`. Methods guarded by a capability must only be called after
|
|
* `supports()` returns true — the base class throws `notSupported()` otherwise, so a missed guard is a
|
|
* loud 501 rather than Zeus's silent `false`.
|
|
*/
|
|
export interface WalletBackend {
|
|
readonly kind: BackendKind;
|
|
|
|
supports(cap: Capability): boolean;
|
|
|
|
getInfo(): Promise<NodeInfo>;
|
|
getBalances(): Promise<Balances>;
|
|
|
|
// on-chain
|
|
getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]>;
|
|
getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }>;
|
|
getUtxos(): Promise<Utxo[]>;
|
|
estimateFees(): Promise<FeeEstimates>;
|
|
sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult>;
|
|
|
|
// lightning
|
|
getInvoices(opts?: { limit?: number }): Promise<Invoice[]>;
|
|
createInvoice(req: CreateInvoiceRequest): Promise<Invoice>;
|
|
lookupInvoice(paymentHash: string): Promise<Invoice | null>;
|
|
decodeInvoice(bolt11: string): Promise<DecodedInvoice>;
|
|
getPayments(opts?: { limit?: number }): Promise<Payment[]>;
|
|
payInvoice(req: PayInvoiceRequest): Promise<Payment>;
|
|
sendKeysend(req: KeysendRequest): Promise<Payment>;
|
|
|
|
// node operation
|
|
getChannels(): Promise<Channel[]>;
|
|
getPeers(): Promise<Peer[]>;
|
|
|
|
signMessage(message: string): Promise<SignMessageResult>;
|
|
verifyMessage(message: string, signature: string): Promise<VerifyMessageResult>;
|
|
|
|
/**
|
|
* How old the data this backend just served is. Optional because it is only meaningful for a backend
|
|
* that caches — a node backend answers from its own live state, and reporting a sync age for it would
|
|
* be inventing one.
|
|
*/
|
|
getSyncState?(): SyncState;
|
|
|
|
/**
|
|
* Ask the chain to search for this wallet's coins from scratch. Resolves as soon as the search is
|
|
* queued, not when it finishes — progress is read back through `getSyncState().rescan`.
|
|
*
|
|
* Optional because only a wallet-level chain source can do it. Absent means the backend has nothing to
|
|
* rescan (a node backend already knows its own coins) or the source has no such endpoint.
|
|
*/
|
|
startRescan?(opts?: RescanOptions): Promise<RescanState>;
|
|
|
|
/**
|
|
* Re-attach to a rescan that is running upstream but was not started by this process.
|
|
*
|
|
* A deep rescan takes minutes and lives on the indexer, not here — so restarting the sidecar mid-scan
|
|
* used to lose the wallet's only handle on it. `getSyncState().rescan` read back null, the UI showed no
|
|
* scan at all, and the refresh chained onto its completion went with it, leaving the balance stale
|
|
* until the cache TTL happened to expire. This asks the upstream what is actually in flight.
|
|
*
|
|
* Resolves null when nothing is running. Costs an upstream call, so callers reach for it only when
|
|
* there is no local state to report.
|
|
*/
|
|
adoptRescan?(): Promise<RescanState | null>;
|
|
}
|
|
|
|
/**
|
|
* Freshness of a cached view of the chain, so the UI can say "3 minutes ago" and, more importantly, tell
|
|
* "this wallet is empty" apart from "we could not ask". Those two rendered identically before this
|
|
* existed, which is the whole reason it does.
|
|
*/
|
|
export type SyncState = {
|
|
/** Unix ms of the last successful chain read. Null when the chain has never been read successfully. */
|
|
syncedAt: number | null;
|
|
/** The served data is past its refresh interval; a refresh is already running behind this response. */
|
|
stale: boolean;
|
|
/** The last refresh failure, still reported while the previous good data is being served. */
|
|
lastError: string | null;
|
|
/**
|
|
* Whether this chain source can search the chain from scratch at all. False for Esplora, which asks
|
|
* about every address on every refresh and so has nothing to catch up on.
|
|
*
|
|
* Distinct from `rescan === null`, which is ALSO what a source that simply hasn't run one yet reports
|
|
* — conflating the two put a button that 501s in front of every Esplora wallet.
|
|
*/
|
|
canRescan: boolean;
|
|
/** A deep rescan in flight, or the outcome of the last one. Null from a source that cannot rescan. */
|
|
rescan: RescanState | null;
|
|
};
|
|
|
|
/**
|
|
* How far a deep rescan should look, when the default is not far enough.
|
|
*
|
|
* The default gap limit is 1000 consecutive unused addresses, which covers any wallet a person has used
|
|
* by hand. It does not cover one restored from a wallet that issued addresses in bulk — and there is no
|
|
* way to detect that case, because the whole problem is that the coins beyond the gap are invisible.
|
|
* Raising it costs the node CPU on one scan; leaving it too low costs the owner the coins. So the knob is
|
|
* exposed rather than tuned, and the caller who knows their history is the one who sets it.
|
|
*/
|
|
export type RescanOptions = { gapLimit?: number };
|
|
|
|
/**
|
|
* A deep chain rescan — the upstream searching the whole UTXO set for a wallet's coins, rather than
|
|
* indexing forward from the moment the wallet was registered.
|
|
*
|
|
* This exists for exactly one situation, and it is not a rare one: an imported seed on a node that only
|
|
* started watching the xpub today. Every read succeeds, nothing errors, and the balance is zero — which
|
|
* is indistinguishable from an empty wallet unless the UI can say "still looking". So the state is
|
|
* carried on SyncState beside `syncedAt`, on the same responses, for the same reason.
|
|
*
|
|
* It runs one account variant at a time because the node runs `scantxoutset` one at a time; four
|
|
* variants is minutes, not seconds.
|
|
*/
|
|
export type RescanState = {
|
|
running: boolean;
|
|
/** Account variants finished, out of how many were queued. */
|
|
done: number;
|
|
total: number;
|
|
/** The variant the node is chewing on right now. Null when nothing is running. */
|
|
current: AddressType | null;
|
|
/** UTXOs the finished variants pulled in. */
|
|
found: number;
|
|
startedAt: number;
|
|
finishedAt: number | null;
|
|
error: string | null;
|
|
};
|
|
|
|
// ── errors ───────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
export class BackendError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly status: number = 502,
|
|
readonly code?: string,
|
|
) {
|
|
super(message);
|
|
this.name = 'BackendError';
|
|
}
|
|
}
|
|
|
|
/** The wallet holds a seed but is currently locked — signing is impossible until /unlock. */
|
|
export class WalletLockedError extends BackendError {
|
|
constructor() {
|
|
super('wallet is locked', 423, 'WALLET_LOCKED');
|
|
this.name = 'WalletLockedError';
|
|
}
|
|
}
|