bitcoin-wallet, extracted into plugins/bitcoin-wallet — the platform half
Tenth extraction, and the first where the blocker was a permission rather than a path. The wallet was kind: 'admin' — owner only, never grantable — and plugin permissions are always kind: 'app'. mount.ts forces that and the manifest has no field for anything else, deliberately, so a plugin cannot ask to be restricted. Extraction therefore could not preserve admin. Raised before moving any code, because turning "the server owner's bitcoin" into a row in the grants list beside Photos and Music is the kind of change nobody notices until someone ticks it. Made anyway, and the objection inverts: every wallet query is scoped by user_id, taken from X-Officer-User which the platform injects from the session, so granting the permission gives an account its OWN wallet rather than access to anyone else's — including to the owner, whose bypass covers permission checks and not query scoping. kind: 'admin' was single-user thinking. Named bitcoin-wallet, not wallet: appName drives route, API prefix, table prefix and install key together, so the name should say what it is. Tables are bitcoin_wallet_*; the prefix guard normalises the hyphen. Verified free before committing to it — no wallet table exists on any server, and db:push would treat a rename as a drop plus a create. registry.test.ts re-anchored: `wallet` was its canonical kind: 'admin' fixture and its no-selfService fixture. Now user-admin/server-admin and terminal, with a note that a plugin's permissions are always `app` — which is exactly why this one needed a decision rather than a default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,6 @@ export * from './user-data';
|
||||
|
||||
export * from './dav';
|
||||
export * from './notify';
|
||||
export * from './wallet';
|
||||
|
||||
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
|
||||
// deleted end to end on 2026-08-13 after `task-logger.ts` turned out to have no callers — a full read
|
||||
|
||||
@@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections
|
||||
|
||||
// export * from './notify/schema'; // push_devices officer-notify
|
||||
// export * from './dav/schema'; // dav_app_passwords officer-caldav
|
||||
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
|
||||
|
||||
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
export {
|
||||
listWallets,
|
||||
getWallet,
|
||||
getActiveWallet,
|
||||
getWalletSecrets,
|
||||
getSealedSeed,
|
||||
createWallet,
|
||||
updateWallet,
|
||||
replaceSealedSeed,
|
||||
setActiveWallet,
|
||||
deleteWallet,
|
||||
getWalletLabels,
|
||||
setWalletLabel,
|
||||
getFrozenOutpoints,
|
||||
setUtxoFrozen,
|
||||
getWalletChainCache,
|
||||
saveWalletChainCache,
|
||||
recordWalletChainError,
|
||||
} from './queries';
|
||||
|
||||
export type {
|
||||
WalletKind,
|
||||
WalletSummary,
|
||||
WalletSecrets,
|
||||
WalletLabel,
|
||||
CreateWalletParams,
|
||||
WalletChainCache,
|
||||
} from './queries';
|
||||
|
||||
export type { WalletChainSnapshot } from './schema';
|
||||
@@ -1,349 +0,0 @@
|
||||
import type { WalletChainSnapshot } from './schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { walletWallets, walletLabels, walletFrozenUtxos, walletChainCache } from './schema';
|
||||
import { encryptSecret, decryptSecret } from '../crypto';
|
||||
|
||||
// Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the at-rest layer ('wallet'
|
||||
// purpose in the secret store) is
|
||||
// applied and stripped here, so route handlers never touch crypto. See ../crypto.ts, ../schema/wallet.ts.
|
||||
//
|
||||
// Note what "plaintext" means for `seedEnvelope`: it is the passphrase-sealed envelope, which is itself
|
||||
// still ciphertext. This layer only removes the SECOND wrapping. Nothing in this file can read a mnemonic,
|
||||
// and that is intentional — only servers/sidecar/wallet/keys.ts can, and only with the owner passphrase.
|
||||
//
|
||||
// THREE return types, and the projection is what enforces the separation:
|
||||
// WalletSummary — safe to serialize to the browser. No config, no seed envelope, at all.
|
||||
// WalletSecrets — decrypted config for the sidecar's own upstream calls. Never returned by a handler.
|
||||
// SealedSeed — the sealed envelope, for keys.ts to open with the owner passphrase.
|
||||
// A bare `select()` would leak both ciphertext columns into every list response the moment someone
|
||||
// forgot to strip them, so no function here uses one.
|
||||
|
||||
export type WalletKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
|
||||
|
||||
export type WalletSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: WalletKind;
|
||||
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: Date;
|
||||
};
|
||||
|
||||
export type WalletSecrets = { id: number; kind: WalletKind; network: string; config: Record<string, unknown> | null };
|
||||
|
||||
const walletCols = {
|
||||
id: walletWallets.id,
|
||||
name: walletWallets.name,
|
||||
kind: walletWallets.kind,
|
||||
network: walletWallets.network,
|
||||
fingerprint: walletWallets.fingerprint,
|
||||
xpubs: walletWallets.xpubs,
|
||||
defaultBip: walletWallets.defaultBip,
|
||||
isActive: walletWallets.isActive,
|
||||
createdAt: walletWallets.createdAt,
|
||||
seedEnvelope: walletWallets.seedEnvelope,
|
||||
};
|
||||
|
||||
function toSummary(row: {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: string;
|
||||
network: string;
|
||||
fingerprint: string | null;
|
||||
xpubs: Record<string, string> | null;
|
||||
defaultBip: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
seedEnvelope: string | null;
|
||||
}): WalletSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as WalletKind,
|
||||
network: row.network,
|
||||
fingerprint: row.fingerprint,
|
||||
xpubs: row.xpubs,
|
||||
defaultBip: row.defaultBip,
|
||||
isActive: row.isActive,
|
||||
hasSeed: row.seedEnvelope !== null,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every wallet the owner has registered, active first then newest. Never includes secrets. */
|
||||
export async function listWallets(userId: number): Promise<WalletSummary[]> {
|
||||
const rows = await db
|
||||
.select(walletCols)
|
||||
.from(walletWallets)
|
||||
.where(eq(walletWallets.userId, userId))
|
||||
.orderBy(desc(walletWallets.isActive), desc(walletWallets.createdAt));
|
||||
return rows.map(toSummary);
|
||||
}
|
||||
|
||||
export async function getWallet(userId: number, id: number): Promise<WalletSummary | null> {
|
||||
const [row] = await db
|
||||
.select(walletCols)
|
||||
.from(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
return row ? toSummary(row) : null;
|
||||
}
|
||||
|
||||
export async function getActiveWallet(userId: number): Promise<WalletSummary | null> {
|
||||
const [row] = await db
|
||||
.select(walletCols)
|
||||
.from(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
|
||||
return row ? toSummary(row) : null;
|
||||
}
|
||||
|
||||
/** Decrypted backend connection config, for the sidecar's upstream calls. Never leaves the sidecar. */
|
||||
export async function getWalletSecrets(userId: number, id: number): Promise<WalletSecrets | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: walletWallets.id,
|
||||
kind: walletWallets.kind,
|
||||
network: walletWallets.network,
|
||||
config: walletWallets.config,
|
||||
})
|
||||
.from(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as WalletKind,
|
||||
network: row.network,
|
||||
config: row.config ? (JSON.parse(decryptSecret('wallet', row.config)) as Record<string, unknown>) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The passphrase-sealed seed envelope, still sealed. Returns null for wallets that hold no seed.
|
||||
* The ONLY caller should be the sidecar's unlock path.
|
||||
*/
|
||||
export async function getSealedSeed(userId: number, id: number): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ seedEnvelope: walletWallets.seedEnvelope })
|
||||
.from(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
if (!row?.seedEnvelope) return null;
|
||||
return decryptSecret('wallet', row.seedEnvelope);
|
||||
}
|
||||
|
||||
export type CreateWalletParams = {
|
||||
userId: number;
|
||||
name: string;
|
||||
kind: WalletKind;
|
||||
network: string;
|
||||
/** Plaintext; encrypted here. */
|
||||
config?: Record<string, unknown> | null;
|
||||
/** The already-sealed envelope as JSON; encrypted again here. */
|
||||
sealedSeed?: string | null;
|
||||
fingerprint?: string | null;
|
||||
xpubs?: Record<string, string> | null;
|
||||
defaultBip?: number;
|
||||
makeActive?: boolean;
|
||||
};
|
||||
|
||||
export async function createWallet(params: CreateWalletParams): Promise<WalletSummary> {
|
||||
return db.transaction(async (tx) => {
|
||||
if (params.makeActive) {
|
||||
await tx
|
||||
.update(walletWallets)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(walletWallets.userId, params.userId), eq(walletWallets.isActive, true)));
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(walletWallets)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
kind: params.kind,
|
||||
network: params.network,
|
||||
config: params.config ? encryptSecret('wallet', JSON.stringify(params.config)) : null,
|
||||
seedEnvelope: params.sealedSeed ? encryptSecret('wallet', params.sealedSeed) : null,
|
||||
fingerprint: params.fingerprint ?? null,
|
||||
xpubs: params.xpubs ?? null,
|
||||
defaultBip: params.defaultBip ?? 84,
|
||||
isActive: params.makeActive ?? false,
|
||||
})
|
||||
.returning(walletCols);
|
||||
return toSummary(row!);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable wallet metadata. CANNOT TOUCH THE SEED — see replaceSealedSeed below for why that is a
|
||||
* separate function rather than one more optional field here.
|
||||
*/
|
||||
export async function updateWallet(
|
||||
userId: number,
|
||||
id: number,
|
||||
patch: { name?: string; config?: Record<string, unknown>; defaultBip?: number },
|
||||
): Promise<WalletSummary | null> {
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (patch.name !== undefined) set.name = patch.name;
|
||||
if (patch.defaultBip !== undefined) set.defaultBip = patch.defaultBip;
|
||||
if (patch.config !== undefined) set.config = encryptSecret('wallet', JSON.stringify(patch.config));
|
||||
|
||||
const [row] = await db
|
||||
.update(walletWallets)
|
||||
.set(set)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
|
||||
.returning(walletCols);
|
||||
return row ? toSummary(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the encrypted seed. The ONLY caller is the passphrase change, which reseals the same words
|
||||
* under a new passphrase and has already proved knowledge of the old one.
|
||||
*
|
||||
* It is a function of its own, rather than a fourth field on updateWallet, because it was one: the PATCH
|
||||
* route typed its request body as `{name, defaultBip, config}` and passed the parsed object straight
|
||||
* through, and a TypeScript annotation strips nothing at runtime. Any authenticated caller could send a
|
||||
* `sealedSeed` key and replace the seed — no passphrase, no unlock, no confirmation — and since
|
||||
* encryptSecret happily encrypts nonsense, the damage only surfaced at the next unlock attempt. Keeping
|
||||
* the two apart means the next field added to that body cannot re-open it.
|
||||
*/
|
||||
export async function replaceSealedSeed(userId: number, id: number, sealedSeed: string): Promise<void> {
|
||||
await db
|
||||
.update(walletWallets)
|
||||
.set({ seedEnvelope: encryptSecret('wallet', sealedSeed), updatedAt: new Date() })
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
}
|
||||
|
||||
/** Exactly one active wallet per owner. Cleared and set in one transaction; the partial unique index is the backstop. */
|
||||
export async function setActiveWallet(userId: number, id: number): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(walletWallets)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
|
||||
await tx
|
||||
.update(walletWallets)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWallet(userId: number, id: number): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
|
||||
.returning({ id: walletWallets.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// ── labels ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WalletLabel = { kind: 'address' | 'tx'; ref: string; label: string };
|
||||
|
||||
export async function getWalletLabels(walletId: number): Promise<WalletLabel[]> {
|
||||
const rows = await db
|
||||
.select({ kind: walletLabels.kind, ref: walletLabels.ref, label: walletLabels.label })
|
||||
.from(walletLabels)
|
||||
.where(eq(walletLabels.walletId, walletId));
|
||||
return rows.map((r) => ({ kind: r.kind as 'address' | 'tx', ref: r.ref, label: r.label }));
|
||||
}
|
||||
|
||||
export async function setWalletLabel(
|
||||
walletId: number,
|
||||
kind: 'address' | 'tx',
|
||||
ref: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
// An empty label is a delete — the UI clears a field rather than pressing a separate button.
|
||||
if (!label.trim()) {
|
||||
await db
|
||||
.delete(walletLabels)
|
||||
.where(and(eq(walletLabels.walletId, walletId), eq(walletLabels.kind, kind), eq(walletLabels.ref, ref)));
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.insert(walletLabels)
|
||||
.values({ walletId, kind, ref, label })
|
||||
.onConflictDoUpdate({ target: [walletLabels.walletId, walletLabels.kind, walletLabels.ref], set: { label } });
|
||||
}
|
||||
|
||||
// ── frozen UTXOs ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFrozenOutpoints(walletId: number): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ outpoint: walletFrozenUtxos.outpoint })
|
||||
.from(walletFrozenUtxos)
|
||||
.where(eq(walletFrozenUtxos.walletId, walletId));
|
||||
return rows.map((r) => r.outpoint);
|
||||
}
|
||||
|
||||
export async function setUtxoFrozen(
|
||||
walletId: number,
|
||||
outpoint: string,
|
||||
frozen: boolean,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
if (!frozen) {
|
||||
await db
|
||||
.delete(walletFrozenUtxos)
|
||||
.where(and(eq(walletFrozenUtxos.walletId, walletId), eq(walletFrozenUtxos.outpoint, outpoint)));
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.insert(walletFrozenUtxos)
|
||||
.values({ walletId, outpoint, reason: reason ?? null })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// ── chain cache ──────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unlike everything above, none of this is authoritative — see the comment on the table. These three
|
||||
// functions are deliberately total: a cache read or write must never be able to fail a wallet request,
|
||||
// so the sidecar treats every one of them as best-effort.
|
||||
|
||||
export type WalletChainCache = {
|
||||
snapshot: WalletChainSnapshot | null;
|
||||
syncedAt: Date | null;
|
||||
lastError: string | null;
|
||||
lastErrorAt: Date | null;
|
||||
};
|
||||
|
||||
export async function getWalletChainCache(walletId: number): Promise<WalletChainCache | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
snapshot: walletChainCache.snapshot,
|
||||
syncedAt: walletChainCache.syncedAt,
|
||||
lastError: walletChainCache.lastError,
|
||||
lastErrorAt: walletChainCache.lastErrorAt,
|
||||
})
|
||||
.from(walletChainCache)
|
||||
.where(eq(walletChainCache.walletId, walletId));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** A successful refresh. Clears the error columns — the upstream just answered, so nothing is wrong now. */
|
||||
export async function saveWalletChainCache(walletId: number, snapshot: WalletChainSnapshot): Promise<void> {
|
||||
const syncedAt = new Date();
|
||||
await db
|
||||
.insert(walletChainCache)
|
||||
.values({ walletId, snapshot, syncedAt, lastError: null, lastErrorAt: null })
|
||||
.onConflictDoUpdate({
|
||||
target: walletChainCache.walletId,
|
||||
set: { snapshot, syncedAt, lastError: null, lastErrorAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
/** A failed refresh. Touches only the error columns, so the last good snapshot and its age survive. */
|
||||
export async function recordWalletChainError(walletId: number, message: string): Promise<void> {
|
||||
const lastErrorAt = new Date();
|
||||
// Bounded because this is an upstream message verbatim, and an HTML error page from a misconfigured
|
||||
// proxy would otherwise land whole in the column.
|
||||
const lastError = message.slice(0, 500);
|
||||
await db
|
||||
.insert(walletChainCache)
|
||||
.values({ walletId, snapshot: null, syncedAt: null, lastError, lastErrorAt })
|
||||
.onConflictDoUpdate({ target: walletChainCache.walletId, set: { lastError, lastErrorAt } });
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from '../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(
|
||||
'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_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_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(
|
||||
'wallet_labels',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
walletId: integer('wallet_id')
|
||||
.notNull()
|
||||
.references(() => walletWallets.id, { onDelete: 'cascade' }),
|
||||
// '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_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(
|
||||
'wallet_frozen_utxos',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
walletId: integer('wallet_id')
|
||||
.notNull()
|
||||
.references(() => walletWallets.id, { onDelete: 'cascade' }),
|
||||
// `txid:vout`
|
||||
outpoint: text('outpoint').notNull(),
|
||||
reason: text('reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [uniqueIndex('uq_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('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()
|
||||
.references(() => walletWallets.id, { onDelete: 'cascade' }),
|
||||
// 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 }),
|
||||
});
|
||||
Reference in New Issue
Block a user