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.
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
// Shared types/constants for the /wallet workspace panels.
|
|
//
|
|
// The wire shapes mirror src/servers/sidecar/wallet/types.ts and the WalletSummary projection in
|
|
// src/databases/officer_db/src/wallet/queries.ts. They are restated here rather than imported because the
|
|
// officerdev workspace has no path into src/servers — pulling the sidecar's module graph into the browser
|
|
// bundle would drag bitcoinjs-lib and the key handling along with it, which is exactly what must never
|
|
// reach the client. Keep this file in step with those two by hand; the field names are identical on
|
|
// purpose so a diff is a grep.
|
|
//
|
|
// UNITS — the one rule that matters. Satoshis are `number` (2.1e15, comfortably inside 2^53).
|
|
// Millisatoshis are decimal STRINGS (2.1e18 overflows a double) and must never be parsed into a JS
|
|
// number. format.ts does msat arithmetic on the digit string; use it rather than reaching for Number().
|
|
|
|
import type { Tone } from '@/components/Data/tone';
|
|
|
|
export const WALLET_SECTIONS = [
|
|
{ id: 'overview', label: 'Overview' },
|
|
{ id: 'receive', label: 'Receive' },
|
|
{ id: 'send', label: 'Send' },
|
|
{ id: 'transactions', label: 'Transactions' },
|
|
{ id: 'coins', label: 'Coins' },
|
|
{ id: 'lightning', label: 'Lightning' },
|
|
{ id: 'settings', label: 'Settings' },
|
|
] as const;
|
|
|
|
export type WalletSectionId = (typeof WALLET_SECTIONS)[number]['id'];
|
|
|
|
/** Where /wallet lands, and where an unrecognised section redirects to. */
|
|
export const DEFAULT_WALLET_SECTION: WalletSectionId = 'overview';
|
|
|
|
export const isWalletSection = (value: string | undefined): value is WalletSectionId =>
|
|
WALLET_SECTIONS.some((s) => s.id === value);
|
|
|
|
/**
|
|
* The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart.
|
|
* Which wallet is open rides along as `?wallet=<id>` — a query param rather than a second path segment,
|
|
* because it is orthogonal to the section and every panel reads it independently.
|
|
*/
|
|
export const walletSectionPath = (id: WalletSectionId, walletId?: number | null) =>
|
|
walletId == null ? `/bitcoin-wallet/${id}` : `/bitcoin-wallet/${id}?wallet=${walletId}`;
|
|
|
|
/** The search-param name holding the open wallet. Never holds anything secret — just a row id. */
|
|
export const WALLET_PARAM = 'wallet';
|
|
|
|
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
|
|
|
|
export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest';
|
|
|
|
/** Mirrors `Capability` in the sidecar. The UI shows a control only when its capability is present. */
|
|
export type Capability =
|
|
| 'onchainReceive'
|
|
| 'onchainSend'
|
|
| 'coinControl'
|
|
| 'psbt'
|
|
| 'bumpFee'
|
|
| 'sweep'
|
|
| 'accounts'
|
|
| 'lightningReceive'
|
|
| 'lightningSend'
|
|
| 'keysend'
|
|
| 'customPreimages'
|
|
| 'offers'
|
|
| 'channels'
|
|
| 'peers'
|
|
| 'routing'
|
|
| 'signMessage';
|
|
|
|
/** The browser-safe wallet projection — no config, no seed envelope, ever. */
|
|
export type WalletSummary = {
|
|
id: number;
|
|
name: string;
|
|
kind: BackendKind;
|
|
network: string;
|
|
fingerprint: string | null;
|
|
xpubs: Record<string, string> | null;
|
|
defaultBip: number;
|
|
isActive: boolean;
|
|
/** Whether this wallet holds a seed at all — i.e. whether unlock/lock apply to it. */
|
|
hasSeed: boolean;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type WalletConfig = {
|
|
network: BitcoinNetwork;
|
|
unlockTtlSec: number;
|
|
/** Wallet creation is refused without it, so the UI blocks the form rather than failing on submit. */
|
|
storeKeyConfigured: boolean;
|
|
};
|
|
|
|
export type LockState = {
|
|
hasSeed: boolean;
|
|
unlocked: boolean;
|
|
secondsRemaining: number;
|
|
};
|
|
|
|
export type NodeInfo = {
|
|
kind: BackendKind;
|
|
pubkey: string | null;
|
|
alias: string | null;
|
|
version: string | null;
|
|
network: BitcoinNetwork;
|
|
blockHeight: number | null;
|
|
synced: boolean;
|
|
};
|
|
|
|
export type Balances = {
|
|
onchainConfirmed: number;
|
|
onchainUnconfirmed: number;
|
|
/**
|
|
* The part of `onchainConfirmed` the owner has frozen. Already counted in it, not deducted — the coins
|
|
* are still the wallet's; freezing is a spending policy. Subtract it to get what a send can actually
|
|
* use, which is what "Spendable" has to mean or a max-value send fails for no visible reason.
|
|
*/
|
|
onchainFrozen: number;
|
|
/** Null when the backend has no channels of its own. */
|
|
lightningBalance: number | null;
|
|
lightningInbound: number | null;
|
|
};
|
|
|
|
/**
|
|
* How old the numbers alongside this are, from a backend that caches its view of the chain. Null from a
|
|
* node backend, which answers from its own live state and has no age to report.
|
|
*
|
|
* `syncedAt: null` with a `lastError` is the case worth rendering carefully: the wallet has never been
|
|
* read successfully, so a zero balance means "unknown", not "empty".
|
|
*/
|
|
export type SyncState = {
|
|
/** Unix ms of the last successful chain read. */
|
|
syncedAt: number | null;
|
|
stale: boolean;
|
|
lastError: string | null;
|
|
/**
|
|
* Whether the chain source can search the chain from scratch at all — false for Esplora, which reads
|
|
* every address on every refresh and has nothing to catch up on. Gate the control on this, NOT on
|
|
* `rescan === null`: that is also what a source reports before its first scan.
|
|
*/
|
|
canRescan: boolean;
|
|
/** A deep rescan in flight, or the last one's outcome. Null from a chain source that cannot rescan. */
|
|
rescan: RescanState | null;
|
|
};
|
|
|
|
/**
|
|
* The node searching its whole UTXO set for this wallet's coins.
|
|
*
|
|
* Only ever non-null on NBXplorer, and it exists because an imported wallet on a freshly-tracked xpub
|
|
* reads as a confident zero: nothing errors, the balance is just empty until the node has looked. Four
|
|
* script variants, one at a time, minutes not seconds — which is precisely why it has to be on screen.
|
|
*/
|
|
export type RescanState = {
|
|
running: boolean;
|
|
done: number;
|
|
total: number;
|
|
current: AddressType | null;
|
|
found: number;
|
|
startedAt: number;
|
|
finishedAt: number | null;
|
|
error: string | null;
|
|
};
|
|
|
|
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
|
|
|
|
/** What each script type is actually called out loud, for anything the owner reads rather than greps. */
|
|
export const ADDRESS_TYPE_LABELS: Record<AddressType, string> = {
|
|
p2wpkh: 'native segwit',
|
|
'p2sh-p2wpkh': 'wrapped segwit',
|
|
p2pkh: 'legacy',
|
|
p2tr: 'taproot',
|
|
};
|
|
|
|
export type OnchainTx = {
|
|
txid: string;
|
|
/** Net effect on this wallet in sats — negative for a spend. */
|
|
amount: number;
|
|
feeSats: number | null;
|
|
blockHeight: number | null;
|
|
timestamp: number | null;
|
|
confirmations: number;
|
|
label: string | null;
|
|
destAddresses: string[];
|
|
rawHex: string | null;
|
|
};
|
|
|
|
/** The sidecar overlays Officer's own `frozen` flag and address label onto the backend's UTXO. */
|
|
export type Utxo = {
|
|
txid: string;
|
|
vout: number;
|
|
amountSats: number;
|
|
address: string;
|
|
addressType: AddressType | null;
|
|
confirmations: number;
|
|
derivationPath: string | null;
|
|
frozen: boolean;
|
|
label: string | null;
|
|
};
|
|
|
|
export type FeeEstimates = {
|
|
fastestFee: number;
|
|
halfHourFee: number;
|
|
hourFee: number;
|
|
economyFee: number;
|
|
minimumFee: number;
|
|
};
|
|
|
|
export type SendCoinsResult = { txid: string; feeSats: number; rawHex: string | null };
|
|
|
|
export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired';
|
|
|
|
export type Invoice = {
|
|
paymentHash: string;
|
|
bolt11: string;
|
|
amountMsat: string | null;
|
|
amountPaidMsat: string | null;
|
|
memo: string | null;
|
|
state: InvoiceState;
|
|
createdAt: number;
|
|
expiresAt: number | null;
|
|
settledAt: number | null;
|
|
preimage: string | null;
|
|
isKeysend: boolean;
|
|
isAmp: 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;
|
|
failureReason: string | null;
|
|
};
|
|
|
|
export type Channel = {
|
|
channelId: string;
|
|
channelPoint: string | null;
|
|
remotePubkey: string;
|
|
remoteAlias: string | null;
|
|
capacitySats: number;
|
|
localBalanceSats: number;
|
|
remoteBalanceSats: number;
|
|
active: boolean;
|
|
private: boolean;
|
|
status: string;
|
|
};
|
|
|
|
export type Peer = { pubkey: string; address: string; alias: string | null; inbound: boolean };
|
|
|
|
// ── Capability-driven section visibility ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Which sections a backend can actually serve. Anything not listed here is hidden from the nav rather
|
|
* than rendered as a control that returns 501 — see the sidecar's `requireCap`.
|
|
*
|
|
* `overview`, `transactions` and `settings` are unconditional: getInfo/getBalances/getTransactions are
|
|
* on the base interface with no capability guard, and settings is wallet lifecycle, not a backend call.
|
|
*/
|
|
export function sectionAvailable(section: WalletSectionId, caps: readonly Capability[]): boolean {
|
|
const has = (c: Capability) => caps.includes(c);
|
|
switch (section) {
|
|
case 'receive':
|
|
return has('onchainReceive') || has('lightningReceive');
|
|
case 'send':
|
|
return has('onchainSend') || has('lightningSend');
|
|
case 'coins':
|
|
return has('coinControl');
|
|
case 'lightning':
|
|
return has('lightningReceive') || has('lightningSend') || has('channels') || has('peers');
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export const KIND_LABELS: Record<BackendKind, string> = {
|
|
onchain: 'On-chain (self-custodial)',
|
|
lnd: 'LND',
|
|
'cln-rest': 'Core Lightning',
|
|
lndhub: 'LNDHub',
|
|
nwc: 'Nostr Wallet Connect',
|
|
};
|
|
|
|
// ── state → tone ──────────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Every colour a wallet state can take is decided here, once, and mapped onto the platform's closed
|
|
// six-tone set (`components/Data/tone.ts`) by MEANING rather than by the hue the code happened to use.
|
|
// The views read these maps through `toneText`/`toneFill`; none of them picks a colour of its own, so a
|
|
// transaction cannot be a success in the overview and a neutral row in the list — which is exactly what
|
|
// it was before this file held the decision.
|
|
|
|
/**
|
|
* Networks other than mainnet get a visible badge — sending testnet coins by mistake is a bad day.
|
|
*
|
|
* All three test chains are `warning` and mainnet is `neutral`, which loses the three-way colour
|
|
* distinction the palette version had (amber/emerald/violet/slate). That distinction was decorative: the
|
|
* badge spells the network out in words, and the one fact a colour can usefully carry here is "these are
|
|
* not real coins". Six tones cannot say "signet" — they can say "not mainnet", and that is the warning.
|
|
*/
|
|
export const NETWORK_TONE: Record<string, Tone> = {
|
|
bitcoin: 'neutral',
|
|
testnet: 'warning',
|
|
signet: 'warning',
|
|
regtest: 'warning',
|
|
};
|
|
|
|
/**
|
|
* On-chain direction. Read by the amount (which colours a signed figure), the overview's recent-activity
|
|
* bubbles and the transactions list.
|
|
*
|
|
* A spend is `danger` — red for money leaving is the convention the signed `Amount` and the overview
|
|
* already used. The transactions list drew its outgoing bubble neutral instead; that disagreement is
|
|
* resolved here in favour of the other two rather than preserved.
|
|
*/
|
|
export const TX_DIRECTION_TONE: Record<'received' | 'sent', Tone> = {
|
|
received: 'success',
|
|
sent: 'danger',
|
|
};
|
|
|
|
/**
|
|
* Chain sync and rescan. The header badge and the rescan card in settings both render these states, so
|
|
* the tone is decided once — they used the same amber for two different conditions before.
|
|
*
|
|
* `scanning` is `busy` (work in flight, matching a torrent verifying or a transfer routing) while
|
|
* `degraded` is `warning` (nothing is running; the numbers on screen are simply old). Splitting them is
|
|
* the point: a rescan in progress resolves itself, an unreachable chain does not.
|
|
*/
|
|
export const CHAIN_SYNC_TONE: Record<'scanning' | 'degraded' | 'failed' | 'ok', Tone> = {
|
|
scanning: 'busy',
|
|
degraded: 'warning',
|
|
failed: 'danger',
|
|
ok: 'success',
|
|
};
|
|
|
|
/**
|
|
* Lightning invoice state, read by the receive list.
|
|
*
|
|
* `accepted` is `busy` rather than the amber it used to be: the HTLC is held and the payment is in
|
|
* flight, which is the same thing `PAYMENT_STATUS_TONE.pending` describes. `canceled` and `expired`
|
|
* share `neutral` — both are simply over, and neither is a failure worth colouring.
|
|
*/
|
|
export const INVOICE_STATE_TONE: Record<InvoiceState, Tone> = {
|
|
open: 'info',
|
|
settled: 'success',
|
|
accepted: 'busy',
|
|
canceled: 'neutral',
|
|
expired: 'neutral',
|
|
};
|
|
|
|
/**
|
|
* Outbound payment status, read by the lightning list. `pending` is `busy` (routing, seconds away from
|
|
* an answer) and NOT the `warning` an unconfirmed on-chain transaction gets — those are different waits,
|
|
* and the tone set has room to say so.
|
|
*/
|
|
export const PAYMENT_STATUS_TONE: Record<PaymentStatus, Tone> = {
|
|
pending: 'busy',
|
|
succeeded: 'success',
|
|
failed: 'danger',
|
|
};
|