// 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 = { '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; getBalances(): Promise; // on-chain getTransactions(opts?: { limit?: number }): Promise; getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }>; getUtxos(): Promise; estimateFees(): Promise; sendCoins(req: SendCoinsRequest): Promise; // lightning getInvoices(opts?: { limit?: number }): Promise; createInvoice(req: CreateInvoiceRequest): Promise; lookupInvoice(paymentHash: string): Promise; decodeInvoice(bolt11: string): Promise; getPayments(opts?: { limit?: number }): Promise; payInvoice(req: PayInvoiceRequest): Promise; sendKeysend(req: KeysendRequest): Promise; // node operation getChannels(): Promise; getPeers(): Promise; signMessage(message: string): Promise; verifyMessage(message: string, signature: string): Promise; /** * 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; /** * 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; } /** * 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'; } }