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>
194 lines
9.2 KiB
TypeScript
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'),
|
|
],
|
|
);
|