Files
pastilhasandClaude Opus 5 b2722df5aa name the over-long foreign keys so db:push can be a no-op
Drizzle generates names past Postgres's 63-character identifier limit. Postgres
stores the truncated form, drizzle compares against its own longer one, and every
push on every host drops and re-adds constraints that are already identical.

Deferred as harmless noise since 2026-08-19. What ended that was a deploy plan
saying "expect no changes, stop if it wants to alter something" — which this made
impossible to satisfy, so the deploy agent had to overrule its own instructions.

Verified by pushing twice against a scratch database: the fleet-wide second-push
churn went from ten statements to two, and the two that remain are a composite
primary key that cannot be fixed either way.

Naming a foreign key means removing the inline .references() or drizzle creates
both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:16:31 +00:00

194 lines
9.2 KiB
TypeScript

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<Record<string, string>>(),
// 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<WalletChainSnapshot>(),
/** 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'),
],
);