import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex, foreignKey} from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; import { users } from 'officerdb/auth/schema'; // Bitcoin wallets for the officer-wallet sidecar. The owner registers one or more wallets — either a // self-custodial on-chain wallet whose seed lives here, or a connection to a node (LND / Core Lightning / // LNDHub / NWC) exactly as Zeus models them — and switches between them. // // TWO COLUMNS HOLD SPENDING AUTHORITY AND THEY ARE PROTECTED DIFFERENTLY. This asymmetry is deliberate: // // `config` — node credentials (macaroon, rune, LNDHub password, NWC URI). Encrypted at rest with // the 'wallet' store key via ../crypto.ts, the way offscale_servers.api_key uses its // own. It CANNOT be // passphrase-protected: background balance polling needs it without the owner present. // // `seed_envelope` — a BIP39 mnemonic that is ALREADY sealed under an owner passphrase by the sidecar // (see servers/sidecar/wallet/keys.ts) before it ever arrives here, and is then // encrypted AGAIN with the 'wallet' store key on the way into this column. Two independent // secrets, neither sufficient alone. A database dump does not spend; a leaked .env // does not spend. // // `xpubs` and `fingerprint` are stored in the CLEAR, on purpose. They are what lets the wallet show // balances, history and fresh receive addresses while locked — the watch-only-when-locked property. An // xpub leak costs privacy (an observer can enumerate the wallet's addresses), never funds. // // Every table is `wallet_`-prefixed and this file holds nothing else, so it can move into // src/servers/sidecar/wallet/ wholesale when sidecars own their schema. Only officer-wallet reads these. export const walletWallets = pgTable( 'bitcoin_wallet_wallets', { id: serial('id').primaryKey(), userId: integer('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), name: text('name').notNull(), // BackendKind: 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc'. TS-only enum, no DB CHECK — // matches how pipeline_jobs.mode is handled elsewhere in this schema. kind: text('kind').notNull(), // BitcoinNetwork: 'bitcoin' | 'testnet' | 'signet' | 'regtest'. network: text('network').notNull().default('bitcoin'), // Backend connection config as JSON, encrypted. Null for a pure on-chain wallet, which connects to // nothing but the Esplora endpoint the sidecar is configured with. config: text('config'), // The passphrase-sealed SeedEnvelope, encrypted again. Null for every remote-node wallet — those // hold no seed, the node does. seedEnvelope: text('seed_envelope'), // BIP32 master fingerprint (8 hex chars), for PSBT construction and hardware-wallet pairing. fingerprint: text('fingerprint'), // { "44": "xpub…", "49": "ypub…", "84": "zpub…", "86": "xpub…" } — plaintext, see header. xpubs: jsonb('xpubs').$type>(), // Which derivation standard new receive addresses use. 84 (native segwit) is the default. defaultBip: integer('default_bip').notNull().default(84), isActive: boolean('is_active').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => [ uniqueIndex('uq_bitcoin_wallet_wallets_user_name').on(t.userId, t.name), // At most one active wallet per owner, enforced by the DB rather than convention — a partial unique // index over active rows only, mirroring uq_offscale_servers_one_active. uniqueIndex('uq_bitcoin_wallet_wallets_one_active') .on(t.userId) .where(sql`${t.isActive}`), ], ); // Owner-assigned labels for addresses and transactions. Zeus keeps these client-side; here they belong to // the server so every client sees the same annotations. export const walletLabels = pgTable( 'bitcoin_wallet_labels', { id: serial('id').primaryKey(), walletId: integer('wallet_id') .notNull(), // 'address' | 'tx' kind: text('kind').notNull(), // The address string or txid being labelled. ref: text('ref').notNull(), label: text('label').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => [uniqueIndex('uq_bitcoin_wallet_labels_wallet_kind_ref').on(t.walletId, t.kind, t.ref)], ); // Frozen UTXOs, excluded from automatic coin selection. Separate from labels because this one affects // what the wallet will actually spend — losing a label is cosmetic, losing a freeze spends a coin the // owner meant to keep back. // // NOTE the contrast with wallet_chain_cache below: this table is POLICY (the owner's decision, only // they can restate it) and that one is OBSERVATION (the chain's, refetchable at any time). That is why // only one of them can be safely truncated. export const walletFrozenUtxos = pgTable( 'bitcoin_wallet_frozen_utxos', { id: serial('id').primaryKey(), walletId: integer('wallet_id') .notNull(), // `txid:vout` outpoint: text('outpoint').notNull(), reason: text('reason'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => [ // Named, because drizzle's generated name exceeds Postgres's 63-character identifier limit. // Postgres stores the truncated form, drizzle keeps comparing against its own longer one, and // every `db:push` drops and re-adds an identical constraint — on every host, for ever. foreignKey({ name: 'fk_bitcoin_wallet_frozen_utxos_wallet', columns: [t.walletId], foreignColumns: [walletWallets.id], }).onDelete('cascade'), uniqueIndex('uq_bitcoin_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)], ); /** * The last thing the chain told us about an on-chain wallet, so a wallet screen paints real numbers * immediately instead of nothing while a gap-limit scan runs. * * Every field is DERIVED and rebuildable — it is recomputed from the account xpubs above on each * successful refresh, so this table holds no secret and nothing authoritative. Truncating it costs one * slow load and nothing else. * * Only the chain's own facts are stored. Addresses, scripts and pubkeys are NOT: they are pure EC * derivation from the xpub, cheaper to recompute on load than to persist, and re-deriving them means a * restored snapshot can never disagree with the wallet's actual keys. */ export type WalletChainSnapshot = { /** Bumped when the shape below changes; a snapshot at any other version is discarded, not migrated. */ v: 1; tipHeight: number; /** One entry per address the scan visited, keyed by its derivation coordinates. */ addresses: { /** AddressType: 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh'. */ type: string; /** BIP44 chain index: 0 receive, 1 change. */ chain: number; index: number; confirmed: number; unconfirmed: number; txCount: number; }[]; utxos: { txid: string; vout: number; value: number; type: string; chain: number; index: number; /** Null while unconfirmed. Confirmations are recomputed against tipHeight, never stored. */ blockHeight: number | null; }[]; /** The OnchainTx shape from servers/sidecar/wallet/types.ts, restated here so this package imports nothing. */ transactions: { txid: string; amount: number; feeSats: number | null; blockHeight: number | null; timestamp: number | null; confirmations: number; label: string | null; destAddresses: string[]; rawHex: string | null; }[]; }; export const walletChainCache = pgTable( 'bitcoin_wallet_chain_cache', { // One row per wallet, so a refresh is a single atomic upsert with no partially-written window. walletId: integer('wallet_id').primaryKey(), // Nullable: a wallet whose very first refresh failed has an error to report and no data to report it // against, and that row still has to exist for the UI to say why the screen is empty. snapshot: jsonb('snapshot').$type(), /** When the chain was last read successfully — the age the UI shows. Not when the row was written. */ syncedAt: timestamp('synced_at', { withTimezone: true }), // Kept ALONGSIDE the last good snapshot rather than replacing it. A failure that wiped the data would // reproduce the exact bug this table exists to fix: an unreachable upstream rendering as a zero balance // instead of as a stale one. lastError: text('last_error'), lastErrorAt: timestamp('last_error_at', { withTimezone: true }), }, (t) => [ // Named, because drizzle's generated name is 65 characters and Postgres truncates at 63 — it stores // the short form, drizzle compares against its own long one, and every push drops and re-adds an // identical constraint. Same fix as `fk_bitcoin_wallet_frozen_utxos_wallet` above. foreignKey({ name: 'fk_bitcoin_wallet_chain_cache_wallet', columns: [t.walletId], foreignColumns: [walletWallets.id], }).onDelete('cascade'), ], );