// 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=` — 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 | 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 = { 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 = { 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 = { 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 = { 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 = { pending: 'busy', succeeded: 'success', failed: 'danger', };