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:
@@ -107,8 +107,6 @@ export function App() {
|
||||
<Route path="/gitea/repo/:owner/:name" element={<Dashboard.GiteaScreen />} />
|
||||
<Route path="/gitea/repo/:owner/:name/:tab" element={<Dashboard.GiteaScreen />} />
|
||||
<Route path="/gitea/repo/:owner/:name/:tab/:item" element={<Dashboard.GiteaScreen />} />
|
||||
<Route path="/wallet" element={<Dashboard.WalletScreen />} />
|
||||
<Route path="/wallet/:section" element={<Dashboard.WalletScreen />} />
|
||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
||||
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Navigate, useLocation, useParams } from 'react-router';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView, DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /wallet uses the Workspace/Panel system (like /command-hq): the wallet list and section nav on the left,
|
||||
// the section itself on the right. Both talk to the officer-wallet sidecar through the /api/wallet auth
|
||||
// proxy, which holds no key material of its own — seeds live encrypted in the sidecar and are decrypted
|
||||
// there only for the length of an unlock window.
|
||||
//
|
||||
// The open section is :section in the URL, the open wallet is ?wallet= and any coin-control selection is
|
||||
// ?coins=, so both panels read the URL with useParams/useSearchParams rather than passing state between
|
||||
// themselves over a channel. This screen backs both /wallet and /wallet/:section and is the single place
|
||||
// that decides what an absent or bogus section means.
|
||||
|
||||
export const WalletScreen = () => {
|
||||
const { section } = useParams();
|
||||
const { search } = useLocation();
|
||||
const workspace = useDashboardState<LayoutNode>('screens/wallet', defaultLayout);
|
||||
|
||||
// Bare /wallet, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||
// default while the address bar says something else — the nav highlight is derived from the URL. The
|
||||
// ?wallet= param is deliberately carried through rather than dropped: /wallet?wallet=3 is a legitimate
|
||||
// deep link and canonicalising the section must not silently change which wallet is open.
|
||||
if (!isWalletSection(section)) {
|
||||
return <Navigate to={`${walletSectionPath(DEFAULT_WALLET_SECTION)}${search}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['wallet-nav', 'wallet-view'], fallback: 'wallet-view' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'wallet-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'wallet-nav', appType: 'wallet-nav' }, size: 24 },
|
||||
{ node: { type: 'panel', id: 'wallet-view', appType: 'wallet-view' }, size: 76 },
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './WalletScreen';
|
||||
@@ -15,7 +15,6 @@ export * from './Calendar';
|
||||
export * from './Contacts';
|
||||
export * from './CommandHQ';
|
||||
export * from './Gitea';
|
||||
export * from './Wallet';
|
||||
export * from './SystemMonitor';
|
||||
export * from './Activity';
|
||||
export * from './CodeEditor';
|
||||
|
||||
@@ -25,7 +25,6 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
|
||||
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
|
||||
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
||||
{ match: (p) => p.startsWith('/wallet'), title: 'Wallet' },
|
||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
|
||||
{ match: (p) => p.startsWith('/activity'), title: 'Activity' },
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
// /api/wallet/* — auth, then forward to officer-wallet. No routes of its own, and no wallet knowledge
|
||||
// whatsoever: not a seed, not a node credential, not an xpub. The platform cannot spend and cannot read a
|
||||
// balance except by asking the sidecar.
|
||||
//
|
||||
// The unlock passphrase DOES transit this proxy — the browser has to send it somewhere — but it is
|
||||
// forwarded as an opaque body and never parsed, logged or retained. See sidecar/create-proxy.ts, which
|
||||
// deliberately performs no body inspection; that restraint matters more for this sidecar than any other.
|
||||
|
||||
const proxy = createSidecarProxy({ name: 'wallet', prefix: '/api/wallet' });
|
||||
|
||||
export const walletRouter = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getWalletServerUrl = proxy.getHttpUrl;
|
||||
@@ -257,17 +257,6 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
// `[open]` This is the "two dock sources" seam. The store keeps its own catalogue while the plugin
|
||||
// system builds tiles from manifests, and the self endpoint concatenates both. One when the store is
|
||||
// rebuilt on the plugin system.
|
||||
{
|
||||
id: 'wallet',
|
||||
ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] },
|
||||
process: 'officer-wallet',
|
||||
label: 'Wallet',
|
||||
summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
|
||||
members: 'none',
|
||||
modes: ['config'],
|
||||
permission: 'wallet',
|
||||
configFields: [],
|
||||
},
|
||||
{
|
||||
id: 'notify',
|
||||
process: 'officer-notify',
|
||||
|
||||
@@ -24,7 +24,6 @@ import { devProxyRouter } from './api/dev-proxy/router';
|
||||
import { pluginsRouter } from './api/plugins/router';
|
||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
// import { walletRouter } from './api/wallet/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
// import { memosRouter } from './api/memos/router';
|
||||
@@ -147,7 +146,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/dav', davRouter], // app-password management (the sync door is /dav, top-level)
|
||||
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
||||
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
||||
// ['/wallet', walletRouter], // plugin — switched off 2026-08-13
|
||||
['/system-monitor', systemMonitorRouter],
|
||||
['/activity', activityRouter],
|
||||
['/dock', dockRouter],
|
||||
|
||||
@@ -202,7 +202,9 @@ describe('self-service routes', () => {
|
||||
});
|
||||
|
||||
test('permissions without the field are unaffected', () => {
|
||||
expect(isSelfServiceRoute(PERMISSION_BY_KEY.get('wallet')!, 'PUT', '/api/wallet')).toBe(false);
|
||||
// Was `wallet` until 2026-08-19, when it became `plugins/wallet/`. Any permission with no
|
||||
// selfService field will do — this pins the absence, not the key.
|
||||
expect(isSelfServiceRoute(PERMISSION_BY_KEY.get('terminal')!, 'PUT', '/api/terminal')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,9 +244,11 @@ describe('kinds', () => {
|
||||
|
||||
test('admin permissions are never grantable', () => {
|
||||
const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key));
|
||||
// `headscale` was here until 2026-08-15, when it left with offscale. A plugin's permissions are
|
||||
// registered at install from its manifest, so they are not in this compile-time list by design.
|
||||
for (const key of ['user-admin', 'server-admin', 'wallet']) {
|
||||
// `headscale` was here until 2026-08-15 when it left with offscale, and `wallet` until 2026-08-19.
|
||||
// A plugin's permissions are registered at install from its manifest, so they are not in this
|
||||
// compile-time list by design — and a plugin's are ALWAYS `app`, which is exactly why the wallet
|
||||
// becoming one had to be a deliberate decision rather than a side effect.
|
||||
for (const key of ['user-admin', 'server-admin']) {
|
||||
expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('admin');
|
||||
expect(grantable.has(key)).toBe(false);
|
||||
}
|
||||
|
||||
@@ -381,15 +381,6 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares
|
||||
// its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The
|
||||
// platform no longer knows this permission exists, which is the entire point.
|
||||
{
|
||||
key: 'wallet',
|
||||
label: 'Wallet',
|
||||
description: "The server owner's bitcoin",
|
||||
kind: 'admin',
|
||||
// api: ['/wallet'], // plugin — switched off 2026-08-13
|
||||
api: [],
|
||||
routes: ['/wallet'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Derived lookups ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -212,6 +212,28 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
// shows rather than the daemon behind it. See its PLUGIN.md for what that cost.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'bitcoin-wallet',
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'Bitcoin Wallet',
|
||||
summary: 'A self-custodial bitcoin wallet — keys sealed on the server, never on a device',
|
||||
color: '#f7931a',
|
||||
icon: 'Bitcoin',
|
||||
source: 'https://gitea.officer.dev/plugins/bitcoin-wallet.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'bitcoin-wallet',
|
||||
label: 'Bitcoin Wallet',
|
||||
description: "Bitcoin wallet — each account gets its own, sealed under that account's own passphrase",
|
||||
},
|
||||
],
|
||||
// Grantable, unlike the `kind: 'admin'` it had in the core registry — every query is scoped by
|
||||
// user_id, so granting it gives an account its OWN wallet rather than access to anyone else's.
|
||||
// See its PLUGIN.md; this was a deliberate decision, not a side effect of extraction.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'vault',
|
||||
publisher: 'officerdev',
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import { blake2b } from '@noble/hashes/blake2.js';
|
||||
|
||||
// AEZ v5 — the wide-block cipher LND's aezeed uses to encrypt a cipher seed.
|
||||
//
|
||||
// SCOPE, deliberately narrow. This is a DECRYPT-ONLY implementation of the SHORT-INPUT path:
|
||||
//
|
||||
// - No encrypt. Officer never mints aezeed seeds — it only imports ones LND/Zeus already created.
|
||||
// A new Officer wallet gets a BIP39 mnemonic, which is the portable format.
|
||||
// - No aezCore. An aezeed ciphertext is a FIXED 23 bytes (33 total, minus the 1-byte version, the
|
||||
// 5-byte salt and the 4-byte CRC), and AEZ dispatches on length: anything under 32 bytes takes the
|
||||
// `aezTiny` Feistel path. `aezCore` is unreachable here, so shipping it would mean carrying ~120
|
||||
// lines of untested cipher in a seed-recovery path. `decrypt` throws rather than guess.
|
||||
// - No aezPRF. Only reached when the ciphertext is exactly `tau` bytes (empty plaintext).
|
||||
//
|
||||
// Every line below is exercised by the tests. That is the point of the narrowness — a subtle bug in
|
||||
// unreachable crypto is a bug nobody finds until it eats someone's wallet.
|
||||
//
|
||||
// Ported from the reference implementation (Yawning/aez, as used by lnd/aezeed). Faithful to the point
|
||||
// of preserving its integer-truncation quirks; where the original relied on a JavaScript accident, the
|
||||
// comment says so.
|
||||
|
||||
const BLOCK_SIZE = 16;
|
||||
const EXTRACTED_KEY_SIZE = 48;
|
||||
const ZERO = new Uint8Array(BLOCK_SIZE);
|
||||
|
||||
// ── AES round tables ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// AEZ is built on reduced-round AES (4 and 10 rounds, always with MixColumns and no key schedule —
|
||||
// the "keys" are just the extracted key material). The four 256-entry T-tables are pure functions of
|
||||
// the AES S-box: TE0[i] = [2·S[i], S[i], S[i], 3·S[i]] big-endian, and TE1..TE3 are TE0 rotated right
|
||||
// by 8, 16 and 24 bits. Deriving them costs microseconds once and removes 1024 hand-copied constants
|
||||
// from a file where a single wrong digit silently produces the wrong wallet.
|
||||
|
||||
// prettier-ignore
|
||||
const SBOX_HEX =
|
||||
'637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0' +
|
||||
'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275' +
|
||||
'09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf' +
|
||||
'd0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2' +
|
||||
'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb' +
|
||||
'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08' +
|
||||
'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e' +
|
||||
'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16';
|
||||
|
||||
const SBOX = Uint8Array.from(SBOX_HEX.match(/../g)!, (h) => parseInt(h, 16));
|
||||
|
||||
/** GF(2^8) doubling — the `xtime` of the AES spec. */
|
||||
const xtime = (b: number): number => ((b << 1) ^ (b & 0x80 ? 0x1b : 0)) & 0xff;
|
||||
|
||||
function buildTables(): [Uint32Array, Uint32Array, Uint32Array, Uint32Array] {
|
||||
const te0 = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const s = SBOX[i]!;
|
||||
te0[i] = ((xtime(s) << 24) | (s << 16) | (s << 8) | (xtime(s) ^ s)) >>> 0;
|
||||
}
|
||||
const ror8 = (x: number): number => ((x >>> 8) | (x << 24)) >>> 0;
|
||||
const te1 = te0.map(ror8);
|
||||
const te2 = te1.map(ror8);
|
||||
const te3 = te2.map(ror8);
|
||||
return [te0, te1, te2, te3];
|
||||
}
|
||||
|
||||
const [TE0, TE1, TE2, TE3] = buildTables();
|
||||
|
||||
// ── block helpers ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const block = (size = BLOCK_SIZE): Uint8Array => new Uint8Array(size);
|
||||
|
||||
function xor16(a: Uint8Array, b: Uint8Array, dst: Uint8Array): void {
|
||||
for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]!;
|
||||
}
|
||||
|
||||
function xor4x16(a: Uint8Array, b: Uint8Array, c: Uint8Array, d: Uint8Array, dst: Uint8Array): void {
|
||||
for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]! ^ c[i]! ^ d[i]!;
|
||||
}
|
||||
|
||||
/** Multiply a block by x in GF(2^128), in place. */
|
||||
function doubleBlock(p: Uint8Array): void {
|
||||
const carry = p[0]!;
|
||||
for (let i = 0; i < 15; i++) p[i] = ((p[i]! << 1) | (p[i + 1]! >> 7)) & 0xff;
|
||||
p[15] = ((p[15]! << 1) ^ (carry >> 7 ? 135 : 0)) & 0xff;
|
||||
}
|
||||
|
||||
/** Multiply a block by the integer `x` in GF(2^128) — repeated doubling, accumulating odd bits. */
|
||||
function multBlock(x: number, src: Uint8Array, dst: Uint8Array): void {
|
||||
const t = block();
|
||||
const r = block();
|
||||
t.set(src);
|
||||
let n = x;
|
||||
while (n !== 0) {
|
||||
if (n & 1) xor16(r, t, r);
|
||||
doubleBlock(t);
|
||||
n >>= 1;
|
||||
}
|
||||
dst.set(r);
|
||||
}
|
||||
|
||||
const readU32BE = (b: Uint8Array, off: number): number =>
|
||||
((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0;
|
||||
|
||||
function writeU32BE(b: Uint8Array, off: number, v: number): void {
|
||||
b[off] = (v >>> 24) & 0xff;
|
||||
b[off + 1] = (v >>> 16) & 0xff;
|
||||
b[off + 2] = (v >>> 8) & 0xff;
|
||||
b[off + 3] = v & 0xff;
|
||||
}
|
||||
|
||||
// ── reduced-round AES ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AesKeys = { aes4: Uint32Array; aes10: Uint32Array };
|
||||
|
||||
/** Lay the 48-byte extracted key out into the fixed round-key schedules AEZ prescribes. */
|
||||
function aesKeys(extracted: Uint8Array): AesKeys {
|
||||
const k = new Uint32Array(12);
|
||||
for (let i = 0; i < 12; i++) k[i] = readU32BE(extracted, 4 * i);
|
||||
|
||||
const aes10 = new Uint32Array(40);
|
||||
aes10.set(k, 0);
|
||||
aes10.set(k, 12);
|
||||
aes10.set(k, 24);
|
||||
aes10.set(k.subarray(0, 4), 36);
|
||||
|
||||
const aes4 = new Uint32Array(16);
|
||||
aes4.set(k.subarray(4, 8), 0);
|
||||
aes4.set(k.subarray(0, 4), 4);
|
||||
aes4.set(k.subarray(8, 12), 8);
|
||||
|
||||
return { aes4, aes10 };
|
||||
}
|
||||
|
||||
/** AES rounds with MixColumns applied on every round, including the last (as AEZ specifies). */
|
||||
function rounds(keys: Uint32Array, count: number, b: Uint8Array): void {
|
||||
let s0 = readU32BE(b, 0);
|
||||
let s1 = readU32BE(b, 4);
|
||||
let s2 = readU32BE(b, 8);
|
||||
let s3 = readU32BE(b, 12);
|
||||
|
||||
for (let r = 0; r < count; r++) {
|
||||
const o = r * 4;
|
||||
const t0 =
|
||||
TE0[(s0 >>> 24) & 0xff]! ^ TE1[(s1 >>> 16) & 0xff]! ^ TE2[(s2 >>> 8) & 0xff]! ^ TE3[s3 & 0xff]! ^ keys[o]!;
|
||||
const t1 =
|
||||
TE0[(s1 >>> 24) & 0xff]! ^ TE1[(s2 >>> 16) & 0xff]! ^ TE2[(s3 >>> 8) & 0xff]! ^ TE3[s0 & 0xff]! ^ keys[o + 1]!;
|
||||
const t2 =
|
||||
TE0[(s2 >>> 24) & 0xff]! ^ TE1[(s3 >>> 16) & 0xff]! ^ TE2[(s0 >>> 8) & 0xff]! ^ TE3[s1 & 0xff]! ^ keys[o + 2]!;
|
||||
const t3 =
|
||||
TE0[(s3 >>> 24) & 0xff]! ^ TE1[(s0 >>> 16) & 0xff]! ^ TE2[(s1 >>> 8) & 0xff]! ^ TE3[s2 & 0xff]! ^ keys[o + 3]!;
|
||||
s0 = t0 >>> 0;
|
||||
s1 = t1 >>> 0;
|
||||
s2 = t2 >>> 0;
|
||||
s3 = t3 >>> 0;
|
||||
}
|
||||
|
||||
writeU32BE(b, 0, s0);
|
||||
writeU32BE(b, 4, s1);
|
||||
writeU32BE(b, 8, s2);
|
||||
writeU32BE(b, 12, s3);
|
||||
}
|
||||
|
||||
function aes4(k: AesKeys, j: Uint8Array, i: Uint8Array, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void {
|
||||
xor4x16(j, i, l, src, dst);
|
||||
rounds(k.aes4, 4, dst);
|
||||
}
|
||||
|
||||
function aes10(k: AesKeys, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void {
|
||||
xor16(src, l, dst);
|
||||
rounds(k.aes10, 10, dst);
|
||||
}
|
||||
|
||||
// ── AEZ state ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AezState = { I: Uint8Array[]; J: Uint8Array[]; L: Uint8Array[]; aes: AesKeys };
|
||||
|
||||
/** AEZ tolerates any key length by hashing it to 48 bytes; a 48-byte key is used verbatim. */
|
||||
function extractKey(key: Uint8Array): Uint8Array {
|
||||
return key.length === EXTRACTED_KEY_SIZE ? key : blake2b(key, { dkLen: EXTRACTED_KEY_SIZE });
|
||||
}
|
||||
|
||||
function init(key: Uint8Array): AezState {
|
||||
const ext = extractKey(key);
|
||||
const I = [block(), block()];
|
||||
const J = [block(), block(), block()];
|
||||
const L = Array.from({ length: 8 }, () => block());
|
||||
|
||||
I[0]!.set(ext.subarray(0, 16));
|
||||
multBlock(2, I[0]!, I[1]!);
|
||||
|
||||
J[0]!.set(ext.subarray(16, 32));
|
||||
multBlock(2, J[0]!, J[1]!);
|
||||
multBlock(2, J[1]!, J[2]!);
|
||||
|
||||
// L[0] is deliberately left as the zero block — the reference never assigns it.
|
||||
L[1]!.set(ext.subarray(32, 48));
|
||||
multBlock(2, L[1]!, L[2]!);
|
||||
xor16(L[2]!, L[1]!, L[3]!);
|
||||
multBlock(2, L[2]!, L[4]!);
|
||||
xor16(L[4]!, L[1]!, L[5]!);
|
||||
multBlock(2, L[3]!, L[6]!);
|
||||
xor16(L[6]!, L[1]!, L[7]!);
|
||||
|
||||
return { I, J, L, aes: aesKeys(ext) };
|
||||
}
|
||||
|
||||
/** The AXU hash binding the tag length, the nonce and each associated-data string into `delta`. */
|
||||
function aezHash(st: AezState, nonce: Uint8Array | null, ad: Uint8Array[], tauBits: number): Uint8Array {
|
||||
const buf = block();
|
||||
const sum = block();
|
||||
const I = block();
|
||||
const J = block();
|
||||
|
||||
writeU32BE(buf, 12, tauBits >>> 0);
|
||||
xor16(st.J[0]!, st.J[1]!, J);
|
||||
aes4(st.aes, J, st.I[1]!, st.L[1]!, buf, sum);
|
||||
|
||||
const nonceEmpty = !nonce || nonce.length === 0;
|
||||
let n = nonce ?? new Uint8Array(0);
|
||||
let nBytes = n.length;
|
||||
I.set(st.I[1]!);
|
||||
for (let i = 1; nBytes >= BLOCK_SIZE; i++, nBytes -= BLOCK_SIZE) {
|
||||
aes4(st.aes, st.J[2]!, I, st.L[i % 8]!, n.subarray(0, BLOCK_SIZE), buf);
|
||||
xor16(sum, buf, sum);
|
||||
n = n.subarray(BLOCK_SIZE);
|
||||
if (i % 8 === 0) doubleBlock(I);
|
||||
}
|
||||
if (nBytes > 0 || nonceEmpty) {
|
||||
buf.fill(0);
|
||||
if (!nonceEmpty) buf.set(n.subarray(0, nBytes));
|
||||
buf[nBytes] = 0x80;
|
||||
aes4(st.aes, st.J[2]!, st.I[0]!, st.L[0]!, buf, buf);
|
||||
xor16(sum, buf, sum);
|
||||
}
|
||||
|
||||
ad.forEach((entry, k) => {
|
||||
let p = entry ?? new Uint8Array(0);
|
||||
const adEmpty = p.length === 0;
|
||||
let bytes = p.length;
|
||||
I.set(st.I[1]!);
|
||||
multBlock(5 + k, st.J[0]!, J);
|
||||
for (let i = 1; bytes >= BLOCK_SIZE; i++, bytes -= BLOCK_SIZE) {
|
||||
aes4(st.aes, J, I, st.L[i % 8]!, p.subarray(0, BLOCK_SIZE), buf);
|
||||
xor16(sum, buf, sum);
|
||||
p = p.subarray(BLOCK_SIZE);
|
||||
if (i % 8 === 0) doubleBlock(I);
|
||||
}
|
||||
if (bytes > 0 || adEmpty) {
|
||||
buf.fill(0);
|
||||
if (!adEmpty) buf.set(p.subarray(0, bytes));
|
||||
buf[bytes] = 0x80;
|
||||
aes4(st.aes, J, st.I[0]!, st.L[0]!, buf, buf);
|
||||
xor16(sum, buf, sum);
|
||||
}
|
||||
});
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* The short-input (< 32 byte) balanced Feistel network. `d` is 0 to encipher, 1 to decipher — the only
|
||||
* difference is which end of the round counter it starts from.
|
||||
*/
|
||||
function aezTiny(st: AezState, delta: Uint8Array, input: Uint8Array, d: 0 | 1, dst: Uint8Array): void {
|
||||
const inBytes = input.length;
|
||||
const buf = block(2 * BLOCK_SIZE);
|
||||
const L = block();
|
||||
const R = block();
|
||||
const tmp = block();
|
||||
let mask = 0x00;
|
||||
let pad = 0x80;
|
||||
|
||||
// Integer halves. The reference indexes with `inBytes/2` and leans on truncation; spelling the two
|
||||
// halves out makes the odd-length case readable instead of accidental.
|
||||
const half = inBytes >> 1;
|
||||
const halfUp = (inBytes + 1) >> 1;
|
||||
|
||||
let i = 7;
|
||||
let roundCount: number;
|
||||
if (inBytes === 1) roundCount = 24;
|
||||
else if (inBytes === 2) roundCount = 16;
|
||||
else if (inBytes < 16) roundCount = 10;
|
||||
else {
|
||||
i = 6;
|
||||
roundCount = 8;
|
||||
}
|
||||
|
||||
L.set(input.subarray(0, halfUp));
|
||||
R.set(input.subarray(half, half + halfUp));
|
||||
|
||||
if (inBytes & 1) {
|
||||
// Odd length: the two halves overlap by a nibble, so shift R left by 4 bits and switch to
|
||||
// nibble-granular padding.
|
||||
for (let k = 0; k < half; k++) R[k] = ((R[k]! << 4) | (R[k + 1]! >> 4)) & 0xff;
|
||||
R[half] = (R[half]! << 4) & 0xff;
|
||||
pad = 0x08;
|
||||
mask = 0xf0;
|
||||
}
|
||||
|
||||
let j: number;
|
||||
let step: number;
|
||||
if (d !== 0) {
|
||||
if (inBytes < 16) {
|
||||
buf.set(input.subarray(0, BLOCK_SIZE));
|
||||
buf[0] = buf[0]! | 0x80;
|
||||
xor16(delta, buf, buf);
|
||||
aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp);
|
||||
L[0] = L[0]! ^ (tmp[0]! & 0x80);
|
||||
}
|
||||
j = roundCount - 1;
|
||||
step = -1;
|
||||
} else {
|
||||
j = 0;
|
||||
step = 1;
|
||||
}
|
||||
|
||||
for (let k = 0; k < roundCount / 2; k++, j += step * 2) {
|
||||
buf.fill(0, 0, BLOCK_SIZE);
|
||||
buf.set(R.subarray(0, halfUp));
|
||||
buf[half] = (buf[half]! & mask) | pad;
|
||||
xor16(buf, delta, buf);
|
||||
buf[15] = buf[15]! ^ (j & 0xff);
|
||||
aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp);
|
||||
xor16(L, tmp, L);
|
||||
|
||||
buf.fill(0, 0, BLOCK_SIZE);
|
||||
buf.set(L.subarray(0, halfUp));
|
||||
buf[half] = (buf[half]! & mask) | pad;
|
||||
xor16(buf, delta, buf);
|
||||
buf[15] = buf[15]! ^ ((j + step) & 0xff);
|
||||
aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp);
|
||||
xor16(R, tmp, R);
|
||||
}
|
||||
|
||||
buf.set(R.subarray(0, half), 0);
|
||||
buf.set(L.subarray(0, halfUp), half);
|
||||
if (inBytes & 1) {
|
||||
for (let k = inBytes - 1; k > half; k--) buf[k] = ((buf[k]! >> 4) | (buf[k - 1]! << 4)) & 0xff;
|
||||
buf[half] = (L[0]! >> 4) | (R[half]! & 0xf0);
|
||||
}
|
||||
|
||||
dst.set(buf.subarray(0, inBytes));
|
||||
|
||||
if (inBytes < 16 && d === 0) {
|
||||
buf.fill(0, inBytes, BLOCK_SIZE);
|
||||
buf[0] = buf[0]! | 0x80;
|
||||
xor16(delta, buf, buf);
|
||||
aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp);
|
||||
dst[0] = dst[0]! ^ (tmp[0]! & 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AEZ decrypt-and-verify. Returns the plaintext, or `null` when the `tau`-byte authenticator does not
|
||||
* check out — which is the signal that the key (and therefore the seed passphrase) is wrong.
|
||||
*
|
||||
* Throws for inputs outside the aezeed envelope rather than silently taking an untested path; see the
|
||||
* scope note at the top of this file.
|
||||
*/
|
||||
export function aezDecrypt(
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array | null,
|
||||
ad: Uint8Array[],
|
||||
tau: number,
|
||||
ciphertext: Uint8Array,
|
||||
): Uint8Array | null {
|
||||
if (ciphertext.length >= 32 || ciphertext.length <= tau) {
|
||||
throw new Error(`aez: unsupported ciphertext length ${ciphertext.length} (decrypt is short-input only)`);
|
||||
}
|
||||
|
||||
const st = init(key);
|
||||
const delta = aezHash(st, nonce, ad, tau * 8);
|
||||
const x = new Uint8Array(ciphertext.length);
|
||||
aezTiny(st, delta, ciphertext, 1, x);
|
||||
|
||||
// Constant-time-ish: fold every authenticator byte before deciding, so the comparison does not
|
||||
// short-circuit on the first mismatch.
|
||||
let sum = 0;
|
||||
for (let i = 0; i < tau; i++) sum |= x[ciphertext.length - tau + i]!;
|
||||
if (sum !== 0) return null;
|
||||
|
||||
return x.slice(0, ciphertext.length - tau);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { generateMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import {
|
||||
AezeedError,
|
||||
birthdayToDate,
|
||||
decipherMnemonic,
|
||||
isAezeedPhrase,
|
||||
mnemonicToCipherSeedBytes,
|
||||
} from './cipher-seed';
|
||||
|
||||
// Fixtures were produced by the `aezeed` npm package (an INDEPENDENT implementation, different author
|
||||
// and codebase, itself matching lnd/aezeed). Deterministic entropy and salt make them reproducible, so
|
||||
// agreement here is genuine cross-implementation evidence rather than this port agreeing with itself.
|
||||
//
|
||||
// entropy 000102...0f, salt 0102030405, internalVersion 0, birthday 5593
|
||||
const ENTROPY_HEX = '000102030405060708090a0b0c0d0e0f';
|
||||
const DEFAULT_PW =
|
||||
'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen';
|
||||
const CUSTOM_PW =
|
||||
'above shop hair museum laugh diamond win skin habit inspire hero box category crime boss black nest useless dog alcohol doctor radar urban split';
|
||||
const CUSTOM_PASSPHRASE = 'correct horse battery staple';
|
||||
// entropy ffee...00, salt a1b2c3d4e5, internalVersion 1, birthday 0
|
||||
const BIRTHDAY_ZERO =
|
||||
'abstract three say vacant about cable tent neck other jeans certain accident arrest market couch front inflict laptop hole marble exact okay owner number';
|
||||
|
||||
const split = (phrase: string): string[] => phrase.split(' ');
|
||||
const hex = (b: Uint8Array): string => Buffer.from(b).toString('hex');
|
||||
|
||||
describe('isAezeedPhrase', () => {
|
||||
test('accepts aezeed cipher seeds', () => {
|
||||
expect(isAezeedPhrase(split(DEFAULT_PW))).toBe(true);
|
||||
expect(isAezeedPhrase(split(CUSTOM_PW))).toBe(true);
|
||||
expect(isAezeedPhrase(split(BIRTHDAY_ZERO))).toBe(true);
|
||||
});
|
||||
|
||||
test('is passphrase-independent — structure only, no scrypt', () => {
|
||||
// Both fixtures hold the SAME entropy under different passphrases. Detection must not care.
|
||||
expect(isAezeedPhrase(split(DEFAULT_PW))).toBe(isAezeedPhrase(split(CUSTOM_PW)));
|
||||
});
|
||||
|
||||
test('rejects genuine BIP39 mnemonics', () => {
|
||||
// The whole point: a 24-word BIP39 phrase is also 33 bytes, so only the CRC separates them.
|
||||
for (let i = 0; i < 25; i++) {
|
||||
expect(isAezeedPhrase(split(generateMnemonic(wordlist, 256)))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects wrong lengths and non-wordlist words', () => {
|
||||
expect(isAezeedPhrase(split(DEFAULT_PW).slice(0, 12))).toBe(false);
|
||||
expect(isAezeedPhrase([...split(DEFAULT_PW).slice(0, 23), 'notaword'])).toBe(false);
|
||||
expect(isAezeedPhrase([])).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a single transposed word', () => {
|
||||
const words = split(DEFAULT_PW);
|
||||
const swapped = [...words];
|
||||
swapped[3] = words[4]!;
|
||||
swapped[4] = words[3]!;
|
||||
expect(isAezeedPhrase(swapped)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decipherMnemonic', () => {
|
||||
test('recovers entropy under the default passphrase', async () => {
|
||||
const seed = await decipherMnemonic(split(DEFAULT_PW));
|
||||
expect(hex(seed.entropy)).toBe(ENTROPY_HEX);
|
||||
expect(seed.entropy).toHaveLength(16);
|
||||
expect(seed.internalVersion).toBe(0);
|
||||
expect(seed.birthday).toBe(5593);
|
||||
});
|
||||
|
||||
test('recovers the same entropy under a custom passphrase', async () => {
|
||||
const seed = await decipherMnemonic(split(CUSTOM_PW), CUSTOM_PASSPHRASE);
|
||||
expect(hex(seed.entropy)).toBe(ENTROPY_HEX);
|
||||
});
|
||||
|
||||
test('decodes internalVersion 1 and a zero birthday', async () => {
|
||||
const seed = await decipherMnemonic(split(BIRTHDAY_ZERO));
|
||||
expect(hex(seed.entropy)).toBe('ffeeddccbbaa99887766554433221100');
|
||||
expect(seed.internalVersion).toBe(1);
|
||||
expect(seed.birthday).toBe(0);
|
||||
});
|
||||
|
||||
test('a wrong passphrase fails the AEZ tag rather than returning junk', async () => {
|
||||
// This is the property that makes the whole port safe: a bad key CANNOT yield plausible-looking
|
||||
// entropy, so we can never silently derive the wrong wallet.
|
||||
const err = await decipherMnemonic(split(CUSTOM_PW), 'not the passphrase').catch((e) => e);
|
||||
expect(err).toBeInstanceOf(AezeedError);
|
||||
expect(err.code).toBe('BAD_PASSPHRASE');
|
||||
});
|
||||
|
||||
test('the default passphrase is not silently accepted for a custom-passphrase seed', async () => {
|
||||
const err = await decipherMnemonic(split(CUSTOM_PW)).catch((e) => e);
|
||||
expect(err.code).toBe('BAD_PASSPHRASE');
|
||||
});
|
||||
|
||||
test('a corrupted checksum is caught before scrypt', async () => {
|
||||
const bytes = mnemonicToCipherSeedBytes(split(DEFAULT_PW))!;
|
||||
bytes[32] = bytes[32]! ^ 0xff;
|
||||
const { decipherCipherSeed } = await import('./cipher-seed');
|
||||
const err = await decipherCipherSeed(bytes, 'aezeed').catch((e) => e);
|
||||
expect(err.code).toBe('BAD_CHECKSUM');
|
||||
});
|
||||
|
||||
test('rejects a non-aezeed phrase', async () => {
|
||||
const err = await decipherMnemonic(split(generateMnemonic(wordlist, 256))).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(AezeedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('framing', () => {
|
||||
test('24 words pack into exactly 33 bytes', () => {
|
||||
const bytes = mnemonicToCipherSeedBytes(split(DEFAULT_PW));
|
||||
expect(bytes).toHaveLength(33);
|
||||
expect(bytes![0]).toBe(0); // aezeed version
|
||||
});
|
||||
|
||||
test('birthday maps to a real date', () => {
|
||||
// 5593 days after the genesis block.
|
||||
expect(birthdayToDate(5593).toISOString().slice(0, 10)).toBe('2024-04-27');
|
||||
expect(birthdayToDate(0).toISOString().slice(0, 10)).toBe('2009-01-03');
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import { scrypt as scryptCb } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import { aezDecrypt } from './aez';
|
||||
|
||||
// LND's aezeed cipher seed — the format Zeus's embedded LND node hands out as a "backup phrase".
|
||||
//
|
||||
// It is NOT BIP39, and that is the entire problem this module exists to solve. An aezeed phrase is 24
|
||||
// words drawn from the very same BIP39 English wordlist, so it is visually indistinguishable from a
|
||||
// BIP39 mnemonic, but the bits underneath are a different structure entirely:
|
||||
//
|
||||
// byte 0 version (0)
|
||||
// bytes 1..24 ciphertext (19-byte plaintext + 4-byte AEZ authenticator)
|
||||
// bytes 24..29 salt (scrypt salt for the passphrase)
|
||||
// bytes 29..33 CRC32-Castagnoli over bytes 0..29
|
||||
// ─────
|
||||
// 33 bytes = 24 words x 11 bits
|
||||
//
|
||||
// A BIP39 24-word phrase is also 33 bytes, but its last 8 bits are a SHA-256 checksum. So an aezeed
|
||||
// phrase fails BIP39 validation every single time, with an error that reads exactly like "you mistyped
|
||||
// a word" — which is unfixable advice, because nothing is mistyped.
|
||||
//
|
||||
// The deciphered plaintext is:
|
||||
//
|
||||
// byte 0 internal version
|
||||
// bytes 1..3 birthday (days since the Bitcoin genesis block, big-endian uint16)
|
||||
// bytes 3..19 entropy (16 bytes)
|
||||
//
|
||||
// That 16-byte entropy is used DIRECTLY as the BIP32 master seed — LND does not run it through BIP39's
|
||||
// PBKDF2. This is why an aezeed cannot be converted into an equivalent BIP39 phrase: doing so would
|
||||
// change the derived keys and produce an empty wallet at different addresses.
|
||||
|
||||
const scrypt = promisify(scryptCb) as (
|
||||
password: string | Buffer,
|
||||
salt: Buffer,
|
||||
keylen: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
|
||||
export const CIPHER_SEED_VERSION = 0;
|
||||
export const ENCIPHERED_SIZE = 33;
|
||||
export const ENTROPY_SIZE = 16;
|
||||
const PLAINTEXT_SIZE = 19;
|
||||
const SALT_SIZE = 5;
|
||||
const CHECKSUM_SIZE = 4;
|
||||
const CIPHERTEXT_EXPANSION = 4;
|
||||
const SALT_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE - SALT_SIZE; // 24
|
||||
const CHECKSUM_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE; // 29
|
||||
|
||||
/**
|
||||
* LND's default when the user declines to set a seed passphrase. Not a secret — it is a literal
|
||||
* constant in lnd/aezeed, and every Zeus wallet created without an explicit passphrase uses it.
|
||||
*/
|
||||
export const DEFAULT_AEZEED_PASSPHRASE = 'aezeed';
|
||||
|
||||
// scrypt parameters for version 0, straight from lnd/aezeed/params.go.
|
||||
const SCRYPT_N = 32768;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
const KEY_LEN = 32;
|
||||
// 128 * N * r is ~33.5 MB here, over Node's 32 MB default, which fails with a bare MEMORY_LIMIT_EXCEEDED.
|
||||
const SCRYPT_MAXMEM = 64 * 1024 * 1024;
|
||||
|
||||
/** LND counts a seed's birthday in days from this instant. */
|
||||
const BITCOIN_GENESIS = Date.UTC(2009, 0, 3, 18, 15, 5);
|
||||
const ONE_DAY_MS = 86_400_000;
|
||||
|
||||
// ── CRC32-Castagnoli ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Castagnoli (0x82F63B78), NOT the far more common IEEE polynomial. Getting this wrong makes every
|
||||
// valid seed look corrupt.
|
||||
|
||||
const CRC32C_TABLE = Uint32Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
|
||||
function crc32c(bytes: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (const b of bytes) c = CRC32C_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
// ── mnemonic <-> bytes ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const WORD_INDEX = new Map<string, number>(wordlist.map((w, i) => [w, i]));
|
||||
|
||||
/**
|
||||
* Pack 24 words into the 33 raw bytes, 11 bits each. Returns null when any word is outside the
|
||||
* wordlist or the count is wrong — i.e. when this cannot be an aezeed phrase at all.
|
||||
*/
|
||||
export function mnemonicToCipherSeedBytes(words: string[]): Uint8Array | null {
|
||||
if (words.length !== 24) return null;
|
||||
|
||||
const bits: number[] = [];
|
||||
for (const word of words) {
|
||||
const index = WORD_INDEX.get(word);
|
||||
if (index === undefined) return null;
|
||||
for (let b = 10; b >= 0; b--) bits.push((index >> b) & 1);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(ENCIPHERED_SIZE);
|
||||
for (let i = 0; i < ENCIPHERED_SIZE; i++) {
|
||||
let v = 0;
|
||||
for (let b = 0; b < 8; b++) v = (v << 1) | bits[i * 8 + b]!;
|
||||
bytes[i] = v;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a phrase is an aezeed cipher seed. Cheap, offline, and decisive: it checks the version byte
|
||||
* and the CRC32C, so it never touches scrypt or the passphrase. Safe to run on every phrase the owner
|
||||
* types, and safe to ship to a client — it reveals nothing and needs no secret.
|
||||
*/
|
||||
export function isAezeedPhrase(words: string[]): boolean {
|
||||
const bytes = mnemonicToCipherSeedBytes(words);
|
||||
if (!bytes) return false;
|
||||
if (bytes[0] !== CIPHER_SEED_VERSION) return false;
|
||||
return crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) === readU32BE(bytes, CHECKSUM_OFFSET);
|
||||
}
|
||||
|
||||
const readU32BE = (b: Uint8Array, off: number): number =>
|
||||
((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0;
|
||||
|
||||
// ── decipher ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CipherSeed = {
|
||||
internalVersion: number;
|
||||
/** Days since the Bitcoin genesis block. */
|
||||
birthday: number;
|
||||
/** The BIP32 master seed. 16 bytes. Caller is responsible for zeroing it. */
|
||||
entropy: Uint8Array;
|
||||
};
|
||||
|
||||
export class AezeedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: 'BAD_VERSION' | 'BAD_CHECKSUM' | 'BAD_PASSPHRASE' | 'BAD_LENGTH',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AezeedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a birthday (days since genesis) to a date — useful as a rescan floor. */
|
||||
export function birthdayToDate(birthday: number): Date {
|
||||
return new Date(BITCOIN_GENESIS + birthday * ONE_DAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decipher a 33-byte aezeed. Throws AezeedError with a code the caller can turn into a useful message —
|
||||
* in particular BAD_PASSPHRASE, which is the difference between "your words are wrong" (they are not)
|
||||
* and "this seed has a passphrase set and we need it".
|
||||
*/
|
||||
export async function decipherCipherSeed(bytes: Uint8Array, passphrase: string): Promise<CipherSeed> {
|
||||
if (bytes.length !== ENCIPHERED_SIZE) {
|
||||
throw new AezeedError(`aezeed must be ${ENCIPHERED_SIZE} bytes, got ${bytes.length}`, 'BAD_LENGTH');
|
||||
}
|
||||
if (bytes[0] !== CIPHER_SEED_VERSION) {
|
||||
throw new AezeedError(`unsupported aezeed version ${bytes[0]}`, 'BAD_VERSION');
|
||||
}
|
||||
if (crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) !== readU32BE(bytes, CHECKSUM_OFFSET)) {
|
||||
throw new AezeedError('aezeed checksum mismatch', 'BAD_CHECKSUM');
|
||||
}
|
||||
|
||||
const salt = Buffer.from(bytes.subarray(SALT_OFFSET, SALT_OFFSET + SALT_SIZE));
|
||||
const key = await scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, {
|
||||
N: SCRYPT_N,
|
||||
r: SCRYPT_R,
|
||||
p: SCRYPT_P,
|
||||
maxmem: SCRYPT_MAXMEM,
|
||||
});
|
||||
|
||||
// Associated data is the version byte followed by the salt, so a seed cannot be replayed under a
|
||||
// different version or salt.
|
||||
const ad = new Uint8Array(1 + SALT_SIZE);
|
||||
ad[0] = CIPHER_SEED_VERSION;
|
||||
ad.set(salt, 1);
|
||||
|
||||
try {
|
||||
const plaintext = aezDecrypt(key, null, [ad], CIPHERTEXT_EXPANSION, bytes.subarray(1, SALT_OFFSET));
|
||||
if (!plaintext || plaintext.length !== PLAINTEXT_SIZE) {
|
||||
throw new AezeedError('incorrect seed passphrase', 'BAD_PASSPHRASE');
|
||||
}
|
||||
return {
|
||||
internalVersion: plaintext[0]!,
|
||||
birthday: (plaintext[1]! << 8) | plaintext[2]!,
|
||||
entropy: plaintext.slice(3, 3 + ENTROPY_SIZE),
|
||||
};
|
||||
} finally {
|
||||
key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience: words straight to a deciphered seed. */
|
||||
export async function decipherMnemonic(words: string[], passphrase = DEFAULT_AEZEED_PASSPHRASE): Promise<CipherSeed> {
|
||||
const bytes = mnemonicToCipherSeedBytes(words);
|
||||
if (!bytes) throw new AezeedError('not a 24-word phrase from the BIP39 wordlist', 'BAD_LENGTH');
|
||||
return decipherCipherSeed(bytes, passphrase);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import {
|
||||
BackendError,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type Capability,
|
||||
type Channel,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type FeeEstimates,
|
||||
type Invoice,
|
||||
type KeysendRequest,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type Peer,
|
||||
type SendCoinsRequest,
|
||||
type SendCoinsResult,
|
||||
type SignMessageResult,
|
||||
type Utxo,
|
||||
type VerifyMessageResult,
|
||||
type WalletBackend,
|
||||
type AddressType,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Shared backend base. Declares its capability set once and turns every unimplemented method into a
|
||||
* clean 501 — the counterpart to Zeus's `BackendUtils.call()` returning `false` for a missing method
|
||||
* (utils/BackendUtils.ts:56-58), but loud instead of silent.
|
||||
*/
|
||||
export abstract class BaseBackend implements WalletBackend {
|
||||
abstract readonly kind: BackendKind;
|
||||
protected abstract readonly capabilities: ReadonlySet<Capability>;
|
||||
|
||||
supports(cap: Capability): boolean {
|
||||
return this.capabilities.has(cap);
|
||||
}
|
||||
|
||||
protected notSupported(op: string): never {
|
||||
throw new BackendError(`${this.kind} does not support ${op}`, 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
|
||||
abstract getInfo(): Promise<NodeInfo>;
|
||||
abstract getBalances(): Promise<Balances>;
|
||||
|
||||
getTransactions(_opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
return this.notSupported('on-chain transaction history');
|
||||
}
|
||||
getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
return this.notSupported('on-chain receive');
|
||||
}
|
||||
getUtxos(): Promise<Utxo[]> {
|
||||
return this.notSupported('coin control');
|
||||
}
|
||||
estimateFees(): Promise<FeeEstimates> {
|
||||
return this.notSupported('fee estimation');
|
||||
}
|
||||
sendCoins(_req: SendCoinsRequest): Promise<SendCoinsResult> {
|
||||
return this.notSupported('on-chain send');
|
||||
}
|
||||
|
||||
getInvoices(_opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
return this.notSupported('invoice listing');
|
||||
}
|
||||
createInvoice(_req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
return this.notSupported('lightning receive');
|
||||
}
|
||||
lookupInvoice(_paymentHash: string): Promise<Invoice | null> {
|
||||
return this.notSupported('invoice lookup');
|
||||
}
|
||||
decodeInvoice(_bolt11: string): Promise<DecodedInvoice> {
|
||||
return this.notSupported('invoice decoding');
|
||||
}
|
||||
getPayments(_opts?: { limit?: number }): Promise<Payment[]> {
|
||||
return this.notSupported('payment history');
|
||||
}
|
||||
payInvoice(_req: PayInvoiceRequest): Promise<Payment> {
|
||||
return this.notSupported('lightning send');
|
||||
}
|
||||
sendKeysend(_req: KeysendRequest): Promise<Payment> {
|
||||
return this.notSupported('keysend');
|
||||
}
|
||||
|
||||
getChannels(): Promise<Channel[]> {
|
||||
return this.notSupported('channels');
|
||||
}
|
||||
getPeers(): Promise<Peer[]> {
|
||||
return this.notSupported('peers');
|
||||
}
|
||||
|
||||
signMessage(_message: string): Promise<SignMessageResult> {
|
||||
return this.notSupported('message signing');
|
||||
}
|
||||
verifyMessage(_message: string, _signature: string): Promise<VerifyMessageResult> {
|
||||
return this.notSupported('message verification');
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Zeus reaches its REST backends through `react-native-blob-util` with a `trusty: true` flag, which
|
||||
// disables TLS verification wholesale so a node with a self-signed cert is reachable (backends/LND.ts).
|
||||
// Bun's fetch takes an equivalent per-request `tls` option, so no extra HTTP client is needed.
|
||||
//
|
||||
// Verification is only relaxed when the owner explicitly opts in for a specific node, and it is a real
|
||||
// trade: it buys reachability for a node with a self-signed cert at the cost of MITM protection on that
|
||||
// connection. Acceptable over loopback or a tailnet, not over the open internet — which is why it is a
|
||||
// per-wallet flag the owner sets deliberately, never a default.
|
||||
type BunFetchInit = RequestInit & { tls?: { rejectUnauthorized?: boolean } };
|
||||
|
||||
export type HttpOptions = {
|
||||
method?: string;
|
||||
path: string;
|
||||
base: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
allowSelfSigned?: boolean;
|
||||
timeoutMs?: number;
|
||||
/** Return the raw Response instead of parsed JSON. */
|
||||
raw?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** The single door every REST backend goes through. Normalizes errors into BackendError. */
|
||||
export async function httpJson<T = unknown>(opts: HttpOptions): Promise<T> {
|
||||
const base = opts.base.replace(/\/+$/, '');
|
||||
const url = new URL(`${base}${opts.path.startsWith('/') ? opts.path : `/${opts.path}`}`);
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
||||
if (v !== undefined) url.searchParams.set(k, String(v));
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json', ...opts.headers };
|
||||
let body: string | undefined;
|
||||
if (opts.body !== undefined) {
|
||||
body = JSON.stringify(opts.body);
|
||||
headers['Content-Type'] ??= 'application/json';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
|
||||
const init: BunFetchInit = {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (opts.allowSelfSigned) init.tls = { rejectUnauthorized: false };
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, init);
|
||||
} catch (err) {
|
||||
const msg = controller.signal.aborted ? `timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` : String(err);
|
||||
throw new BackendError(`request to ${url.host} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (opts.raw) return res as unknown as T;
|
||||
|
||||
const text = await res.text();
|
||||
let parsed: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
if (!res.ok) throw new BackendError(`upstream ${res.status}: ${text.slice(0, 200)}`, res.status);
|
||||
throw new BackendError(`upstream returned non-JSON: ${text.slice(0, 200)}`, 502);
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// LND puts {error, code}; CLN puts {message}; LNDHub puts {error, message}. Try all three.
|
||||
const p = parsed as { error?: string; message?: string; code?: number } | null;
|
||||
const msg = p?.error ?? p?.message ?? `upstream returned ${res.status}`;
|
||||
throw new BackendError(msg, res.status, p?.code != null ? String(p.code) : undefined);
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
/** Hex → base64. LND's REST API takes bytes fields base64-encoded; callers hold hex. */
|
||||
export function hexToBase64(hex: string): string {
|
||||
return Buffer.from(hex, 'hex').toString('base64');
|
||||
}
|
||||
|
||||
/** Base64 → hex, for reading LND's bytes fields back out. */
|
||||
export function base64ToHex(b64: string): string {
|
||||
return Buffer.from(b64, 'base64').toString('hex');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,860 +0,0 @@
|
||||
// LND REST backend — ported from Zeus's `backends/LND.ts` (+ `utils/LndUtils.ts` for the address-type
|
||||
// mapping). Zeus's class is a thin, untyped RPC shim: every method returns the upstream JSON verbatim and
|
||||
// the view layer does the unit conversion. Here the conversion happens once, at the edge, against the
|
||||
// `WalletBackend` contract in `../types.ts`.
|
||||
//
|
||||
// INTENTIONAL BEHAVIOURAL DIFFERENCES FROM ZEUS
|
||||
//
|
||||
// 1. No Tor. Zeus routes through `doTorRequest` when `enableTor` is set. A sidecar on the owner's own box
|
||||
// reaches its node directly; if Tor is ever needed it belongs in the dispatcher, not per backend.
|
||||
// 2. No in-flight call de-duplication. Zeus keys a module-level `calls` Map by url+body so a second
|
||||
// identical request joins the first, and needs `clearCachedCalls()` to escape a poisoned entry. That
|
||||
// cache is a mobile-battery optimisation and a footgun (a failed call can be re-awaited); dropped.
|
||||
// 3. No `forcedTimeout` race. Zeus races `payLightningInvoice` against a promise that RESOLVES with a fake
|
||||
// `{payment_error: 'timed out'}` after timeout+1s — so a slow payment silently reports failure while
|
||||
// still in flight. We pass a real deadline to `httpJson` and let it abort, surfacing a 502.
|
||||
// 4. TLS verification is opt-in per node (`allowSelfSigned`), not the blanket `trusty: !certVerification`.
|
||||
// 5. Version gating is OPTIMISTIC until `getInfo()` runs. Zeus reads `nodeInfoStore.version`, which is
|
||||
// empty before the first getinfo, so every version-gated `supportsX()` answers false. Here the gated
|
||||
// capabilities (coinControl, accounts, offers) start ON and are switched OFF once getInfo reports an
|
||||
// older node — a pre-getInfo call to an old node fails upstream instead of 501-ing on a modern one.
|
||||
// 6. `offers` is declared for v0.18+. Zeus hardcodes `supportsOffers = () => false` for LND.
|
||||
// 7. Websocket-only endpoints are not ported: `openChannelStream`, `initChanAcceptor`,
|
||||
// `subscribeCustomMessages`, `subscribeInvoice`, `subscribeTransactions`. `WalletBackend` has no
|
||||
// streaming surface.
|
||||
// 8. `sendCoins` reports `feeSats: 0`. LND's SendCoinsResponse carries only `txid`; the fee is only
|
||||
// observable afterwards through GetTransactions, and we do not block the send on a second round trip.
|
||||
// 9. `SendCoinsRequest.rbf` is ignored — lnrpc.SendCoins has no replaceability flag to forward.
|
||||
// 10. `Utxo.derivationPath` is always null. Zeus reads paths from a separate ListAddresses call (v0.18+);
|
||||
// joining that per-UTXO here would double the request count for a field nothing currently consumes.
|
||||
// 11. `Peer.alias` is always null, matching Zeus's `listPeers` — /v1/peers has no alias, and resolving one
|
||||
// costs a graph lookup per peer. Channels DO get an alias, via ListChannels' own `peer_alias_lookup`.
|
||||
// 12. `InvoiceState.expired` is derived (unsettled + past `creation_date + expiry`); lnrpc's Invoice.state
|
||||
// enum has no EXPIRED member.
|
||||
// 13. `estimateFees` uses walletrpc EstimateFee. Zeus does not ask LND for on-chain fees at all — it reads
|
||||
// mempool.space in the app layer.
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
BackendError,
|
||||
type AddressType,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type Channel,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type FeeEstimates,
|
||||
type Invoice,
|
||||
type InvoiceState,
|
||||
type KeysendRequest,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type PaymentStatus,
|
||||
type Peer,
|
||||
type SendCoinsRequest,
|
||||
type SendCoinsResult,
|
||||
type SignMessageResult,
|
||||
type Utxo,
|
||||
type VerifyMessageResult,
|
||||
} from '../types';
|
||||
import { BaseBackend, base64ToHex, hexToBase64, httpJson } from './base';
|
||||
|
||||
// ── upstream wire types ──────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Field names are lnrpc's own, verbatim. grpc-gateway renders every int64 as a decimal STRING and every
|
||||
// `bytes` field as base64 — both quirks are load-bearing below, so the types record them exactly.
|
||||
|
||||
type LndChain = { chain?: string; network?: string };
|
||||
|
||||
type LndGetInfo = {
|
||||
version?: string;
|
||||
identity_pubkey?: string;
|
||||
alias?: string;
|
||||
block_height?: number;
|
||||
synced_to_chain?: boolean;
|
||||
testnet?: boolean;
|
||||
chains?: LndChain[];
|
||||
};
|
||||
|
||||
type LndBlockchainBalance = {
|
||||
total_balance?: string;
|
||||
confirmed_balance?: string;
|
||||
unconfirmed_balance?: string;
|
||||
};
|
||||
|
||||
/** lnrpc.Amount — the same sat/msat pair LND uses everywhere it reports a channel-side balance. */
|
||||
type LndAmount = { sat?: string; msat?: string };
|
||||
|
||||
type LndChannelBalance = {
|
||||
balance?: string;
|
||||
local_balance?: LndAmount;
|
||||
remote_balance?: LndAmount;
|
||||
};
|
||||
|
||||
type LndTransaction = {
|
||||
tx_hash?: string;
|
||||
amount?: string;
|
||||
num_confirmations?: number;
|
||||
block_height?: number;
|
||||
time_stamp?: string;
|
||||
total_fees?: string;
|
||||
dest_addresses?: string[];
|
||||
raw_tx_hex?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type LndUtxo = {
|
||||
address_type?: string;
|
||||
address?: string;
|
||||
amount_sat?: string;
|
||||
confirmations?: string;
|
||||
outpoint?: { txid_str?: string; output_index?: number };
|
||||
};
|
||||
|
||||
type LndEstimateFee = { sat_per_kw?: string; min_relay_fee_sat_per_kw?: string };
|
||||
|
||||
type LndInvoice = {
|
||||
memo?: string;
|
||||
r_preimage?: string;
|
||||
r_hash?: string;
|
||||
value?: string;
|
||||
value_msat?: string;
|
||||
creation_date?: string;
|
||||
settle_date?: string;
|
||||
payment_request?: string;
|
||||
expiry?: string;
|
||||
amt_paid_msat?: string;
|
||||
state?: string;
|
||||
is_keysend?: boolean;
|
||||
is_amp?: boolean;
|
||||
};
|
||||
|
||||
type LndAddInvoiceResponse = { r_hash?: string; payment_request?: string; payment_addr?: string };
|
||||
|
||||
type LndFeature = { name?: string; is_required?: boolean; is_known?: boolean };
|
||||
|
||||
type LndPayReq = {
|
||||
destination?: string;
|
||||
payment_hash?: string;
|
||||
num_msat?: string;
|
||||
num_satoshis?: string;
|
||||
timestamp?: string;
|
||||
expiry?: string;
|
||||
description?: string;
|
||||
cltv_expiry?: string;
|
||||
route_hints?: unknown[];
|
||||
features?: Record<string, LndFeature>;
|
||||
};
|
||||
|
||||
type LndHop = { pub_key?: string };
|
||||
type LndHtlcAttempt = { route?: { hops?: LndHop[] } };
|
||||
|
||||
type LndPayment = {
|
||||
payment_hash?: string;
|
||||
payment_preimage?: string;
|
||||
value_msat?: string;
|
||||
fee_msat?: string;
|
||||
status?: string;
|
||||
creation_date?: string;
|
||||
creation_time_ns?: string;
|
||||
failure_reason?: string;
|
||||
htlcs?: LndHtlcAttempt[];
|
||||
};
|
||||
|
||||
/** One newline-delimited frame of the SendPaymentV2 stream. */
|
||||
type LndRouterFrame = { result?: LndPayment; error?: { message?: string; code?: number } };
|
||||
|
||||
type LndChannel = {
|
||||
chan_id?: string;
|
||||
channel_point?: string;
|
||||
remote_pubkey?: string;
|
||||
peer_alias?: string;
|
||||
capacity?: string;
|
||||
local_balance?: string;
|
||||
remote_balance?: string;
|
||||
active?: boolean;
|
||||
private?: boolean;
|
||||
};
|
||||
|
||||
type LndPendingChannel = {
|
||||
remote_node_pub?: string;
|
||||
channel_point?: string;
|
||||
capacity?: string;
|
||||
local_balance?: string;
|
||||
remote_balance?: string;
|
||||
private?: boolean;
|
||||
};
|
||||
|
||||
type LndPendingChannels = {
|
||||
pending_open_channels?: { channel?: LndPendingChannel }[];
|
||||
pending_force_closing_channels?: { channel?: LndPendingChannel }[];
|
||||
waiting_close_channels?: { channel?: LndPendingChannel }[];
|
||||
};
|
||||
|
||||
type LndPeer = { pub_key?: string; address?: string; inbound?: boolean };
|
||||
|
||||
// ── constants ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Zeus's LndUtils.LNRPC_NEW_ADDRESS_TYPE_NAMES, verbatim. See that file for why each row exists. */
|
||||
const LNRPC_NEW_ADDRESS_TYPE_NAMES: Record<string, string> = {
|
||||
'0': 'WITNESS_PUBKEY_HASH',
|
||||
'1': 'NESTED_PUBKEY_HASH',
|
||||
'2': 'UNUSED_WITNESS_PUBKEY_HASH',
|
||||
'3': 'UNUSED_NESTED_PUBKEY_HASH',
|
||||
'4': 'TAPROOT_PUBKEY',
|
||||
'5': 'UNUSED_TAPROOT_PUBKEY',
|
||||
NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH',
|
||||
HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH',
|
||||
};
|
||||
|
||||
/**
|
||||
* Zeus's `toLnrpcAddressType`. The numeric strings matter: LND REST's grpc-gateway silently treats an
|
||||
* unrecognised `type` as WITNESS_PUBKEY_HASH, so sending '1' quietly yields a native segwit address.
|
||||
*/
|
||||
function toLnrpcAddressType(value: string | number | undefined | null): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const key = String(value);
|
||||
return LNRPC_NEW_ADDRESS_TYPE_NAMES[key] ?? key;
|
||||
}
|
||||
|
||||
/** Our AddressType → the lnrpc enum index, in normal and `peek` (UNUSED_*, non-advancing) form. */
|
||||
const ADDRESS_TYPE_INDEX: Record<AddressType, { fresh: string; peek: string } | null> = {
|
||||
p2wpkh: { fresh: '0', peek: '2' },
|
||||
'p2sh-p2wpkh': { fresh: '1', peek: '3' },
|
||||
p2tr: { fresh: '4', peek: '5' },
|
||||
// lnrpc.NewAddress has no legacy p2pkh member — LND has never handed out a base58 receive address.
|
||||
p2pkh: null,
|
||||
};
|
||||
|
||||
/** walletrpc's AddressType enum (ListUnspent) → ours. The HYBRID_ variant is still a p2sh-p2wpkh. */
|
||||
const WALLETRPC_ADDRESS_TYPE: Record<string, AddressType> = {
|
||||
WITNESS_PUBKEY_HASH: 'p2wpkh',
|
||||
NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh',
|
||||
HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh',
|
||||
TAPROOT_PUBKEY: 'p2tr',
|
||||
};
|
||||
|
||||
/** BOLT spec TLV records for keysend: the preimage the receiver settles with, and the free-text message. */
|
||||
const KEYSEND_PREIMAGE_RECORD = '5482373484';
|
||||
const KEYSEND_MESSAGE_RECORD = '34349334';
|
||||
|
||||
/** A 32-byte all-zero preimage is LND's "not settled yet" placeholder, not a real preimage. */
|
||||
const ZERO_PREIMAGE_HEX = '0'.repeat(64);
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** int64-as-string → number. Used only for sat-denominated fields, which cannot overflow a double. */
|
||||
function toSats(value: string | number | undefined | null): number {
|
||||
if (value == null || value === '') return 0;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** int64-as-string → msat decimal string. Never widened to a number: 2.1e18 does not fit a double. */
|
||||
function toMsat(value: string | number | undefined | null): string {
|
||||
if (value == null || value === '') return '0';
|
||||
return typeof value === 'number' ? Math.round(value).toString() : value;
|
||||
}
|
||||
|
||||
/** `0.18.3-beta commit=v0.18.3-beta` → [0, 18, 3]. Zeus does the same in VersionUtils. */
|
||||
function parseVersion(version: string | null): [number, number, number] | null {
|
||||
if (!version) return null;
|
||||
const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
/** Unknown version counts as "new enough" — see difference 5 in the header. */
|
||||
function atLeast(version: string | null, min: string): boolean {
|
||||
const have = parseVersion(version);
|
||||
const want = parseVersion(min);
|
||||
if (!have || !want) return true;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const h = have[i] ?? 0;
|
||||
const w = want[i] ?? 0;
|
||||
if (h !== w) return h > w;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toNetwork(info: LndGetInfo): BitcoinNetwork {
|
||||
const raw = info.chains?.[0]?.network ?? (info.testnet ? 'testnet' : 'mainnet');
|
||||
switch (raw) {
|
||||
case 'testnet':
|
||||
case 'testnet3':
|
||||
case 'testnet4':
|
||||
return 'testnet';
|
||||
case 'signet':
|
||||
return 'signet';
|
||||
// simnet is btcd's private-chain mode; it behaves like regtest for everything we surface.
|
||||
case 'regtest':
|
||||
case 'simnet':
|
||||
return 'regtest';
|
||||
default:
|
||||
return 'bitcoin';
|
||||
}
|
||||
}
|
||||
|
||||
function toPaymentStatus(status: string | undefined): PaymentStatus {
|
||||
switch (status) {
|
||||
case 'SUCCEEDED':
|
||||
return 'succeeded';
|
||||
case 'FAILED':
|
||||
return 'failed';
|
||||
// UNKNOWN / IN_FLIGHT / INITIATED all mean "not resolved yet".
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
export type LndConfig = {
|
||||
/** Full base URL including scheme and port, e.g. `https://192.168.1.5:8080`. */
|
||||
url: string;
|
||||
/** Admin (or narrower) macaroon, hex. Sent as `Grpc-Metadata-macaroon`. */
|
||||
macaroonHex: string;
|
||||
allowSelfSigned?: boolean;
|
||||
};
|
||||
|
||||
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LndBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'lnd';
|
||||
|
||||
// Mutated in place by `applyVersionGates`; `capabilities` is the same object seen read-only.
|
||||
private readonly caps = new Set<Capability>([
|
||||
'onchainReceive',
|
||||
'onchainSend',
|
||||
'coinControl',
|
||||
'psbt',
|
||||
'bumpFee',
|
||||
'sweep',
|
||||
'accounts',
|
||||
'lightningReceive',
|
||||
'lightningSend',
|
||||
'keysend',
|
||||
'customPreimages',
|
||||
'offers',
|
||||
'channels',
|
||||
'peers',
|
||||
'routing',
|
||||
'signMessage',
|
||||
]);
|
||||
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
|
||||
|
||||
constructor(private readonly config: LndConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── transport ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
return httpJson<T>({
|
||||
base: this.config.url,
|
||||
path,
|
||||
query,
|
||||
method: 'GET',
|
||||
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
});
|
||||
}
|
||||
|
||||
private post<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
|
||||
return httpJson<T>({
|
||||
base: this.config.url,
|
||||
path,
|
||||
method: 'POST',
|
||||
body: body ?? {},
|
||||
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SendPaymentV2 (`/v2/router/send`) is a SERVER-STREAMING rpc. grpc-gateway renders it as
|
||||
* newline-delimited JSON — one `{"result": …}` frame per payment state change — which `JSON.parse` on
|
||||
* the whole body cannot read. Zeus handles this in `restReq` by splitting on '\n' and taking
|
||||
* `split[length - 2]` (the body has a trailing newline); we take the last non-empty line, which is the
|
||||
* same frame without depending on the trailing newline being there.
|
||||
*
|
||||
* We also set `no_inflight_updates`, so in practice there is exactly one frame: the terminal one.
|
||||
*/
|
||||
private async routerSend(body: Record<string, unknown>, timeoutMs: number): Promise<LndPayment> {
|
||||
const res = await httpJson<Response>({
|
||||
base: this.config.url,
|
||||
path: '/v2/router/send',
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
timeoutMs,
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const lines = text.split('\n').filter((line) => line.trim().length > 0);
|
||||
const last = lines[lines.length - 1];
|
||||
|
||||
if (!last) {
|
||||
throw new BackendError(`router/send returned an empty body (HTTP ${res.status})`, res.ok ? 502 : res.status);
|
||||
}
|
||||
|
||||
let frame: LndRouterFrame;
|
||||
try {
|
||||
frame = JSON.parse(last) as LndRouterFrame;
|
||||
} catch {
|
||||
throw new BackendError(`router/send returned non-JSON: ${last.slice(0, 200)}`, res.ok ? 502 : res.status);
|
||||
}
|
||||
|
||||
// A stream error arrives as a frame, not as a non-2xx status, so check it before `res.ok`.
|
||||
if (frame.error) {
|
||||
throw new BackendError(
|
||||
frame.error.message ?? 'payment failed',
|
||||
res.ok ? 502 : res.status,
|
||||
frame.error.code != null ? String(frame.error.code) : undefined,
|
||||
);
|
||||
}
|
||||
if (!res.ok || !frame.result) {
|
||||
throw new BackendError(`router/send failed: ${last.slice(0, 200)}`, res.ok ? 502 : res.status);
|
||||
}
|
||||
return frame.result;
|
||||
}
|
||||
|
||||
/** Zeus's version predicates, evaluated once per getInfo instead of once per `supportsX()` call. */
|
||||
private applyVersionGates(version: string | null): void {
|
||||
const gate = (cap: Capability, min: string) => {
|
||||
if (atLeast(version, min)) this.caps.add(cap);
|
||||
else this.caps.delete(cap);
|
||||
};
|
||||
gate('coinControl', '0.12.0'); // Zeus: supportsCoinControl
|
||||
gate('accounts', '0.13.0'); // Zeus: supportsAccounts
|
||||
gate('offers', '0.18.0'); // Zeus hardcodes false; see difference 6
|
||||
}
|
||||
|
||||
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInfo(): Promise<NodeInfo> {
|
||||
const info = await this.get<LndGetInfo>('/v1/getinfo');
|
||||
const version = info.version ?? null;
|
||||
this.applyVersionGates(version);
|
||||
return {
|
||||
kind: this.kind,
|
||||
pubkey: info.identity_pubkey ?? null,
|
||||
alias: info.alias ?? null,
|
||||
version,
|
||||
network: toNetwork(info),
|
||||
blockHeight: info.block_height ?? null,
|
||||
synced: info.synced_to_chain === true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const [chain, channels] = await Promise.all([
|
||||
this.get<LndBlockchainBalance>('/v1/balance/blockchain'),
|
||||
this.get<LndChannelBalance>('/v1/balance/channels'),
|
||||
]);
|
||||
return {
|
||||
onchainConfirmed: toSats(chain.confirmed_balance),
|
||||
onchainFrozen: 0,
|
||||
onchainUnconfirmed: toSats(chain.unconfirmed_balance),
|
||||
// `balance` is the deprecated flat field; the nested Amount is authoritative on v0.11+.
|
||||
lightningBalance: toSats(channels.local_balance?.sat ?? channels.balance),
|
||||
lightningInbound: toSats(channels.remote_balance?.sat),
|
||||
};
|
||||
}
|
||||
|
||||
// ── on-chain ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
// `end_height=-1` is LND's "up to and including unconfirmed" sentinel — without it the mempool is
|
||||
// excluded and a just-broadcast send is invisible.
|
||||
const res = await this.get<{ transactions?: LndTransaction[] }>('/v1/transactions', {
|
||||
end_height: -1,
|
||||
max_transactions: opts?.limit ?? 500,
|
||||
});
|
||||
return (res.transactions ?? []).map((tx) => ({
|
||||
txid: tx.tx_hash ?? '',
|
||||
amount: toSats(tx.amount),
|
||||
feeSats: tx.total_fees != null ? toSats(tx.total_fees) : null,
|
||||
blockHeight: tx.block_height ? tx.block_height : null,
|
||||
timestamp: tx.time_stamp ? toSats(tx.time_stamp) : null,
|
||||
confirmations: tx.num_confirmations ?? 0,
|
||||
label: tx.label ? tx.label : null,
|
||||
destAddresses: tx.dest_addresses ?? [],
|
||||
rawHex: tx.raw_tx_hex ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
const type: AddressType = req?.type ?? 'p2wpkh';
|
||||
const index = ADDRESS_TYPE_INDEX[type];
|
||||
if (!index) throw new BackendError(`lnd cannot derive a ${type} address`, 400, 'UNSUPPORTED_ADDRESS_TYPE');
|
||||
|
||||
// `peek` maps onto the UNUSED_* enum members, which return the current address without advancing the
|
||||
// derivation index — LND's only form of "show me the same address again".
|
||||
const lnrpcType = toLnrpcAddressType(req?.peek ? index.peek : index.fresh);
|
||||
const res = await this.get<{ address?: string }>('/v1/newaddress', { type: lnrpcType });
|
||||
if (!res.address) throw new BackendError('lnd returned no address', 502);
|
||||
return { address: res.address, type };
|
||||
}
|
||||
|
||||
override async getUtxos(): Promise<Utxo[]> {
|
||||
// max_confs must be set explicitly: walletrpc defaults it to 0, which matches nothing.
|
||||
const res = await this.post<{ utxos?: LndUtxo[] }>('/v2/wallet/utxos', {
|
||||
min_confs: 0,
|
||||
max_confs: 0x7fffffff,
|
||||
});
|
||||
return (res.utxos ?? []).map((utxo) => ({
|
||||
txid: utxo.outpoint?.txid_str ?? '',
|
||||
vout: utxo.outpoint?.output_index ?? 0,
|
||||
amountSats: toSats(utxo.amount_sat),
|
||||
address: utxo.address ?? '',
|
||||
addressType: utxo.address_type ? (WALLETRPC_ADDRESS_TYPE[utxo.address_type] ?? null) : null,
|
||||
confirmations: toSats(utxo.confirmations),
|
||||
derivationPath: null,
|
||||
// ListUnspent already omits leased outputs, so anything we can see here is selectable.
|
||||
frozen: false,
|
||||
}));
|
||||
}
|
||||
|
||||
override async estimateFees(): Promise<FeeEstimates> {
|
||||
// walletrpc quotes sat/kw (weight units). 1 vB = 4 wu, so sat/vB = sat_per_kw / 250.
|
||||
const perVbyte = (satPerKw: string | undefined): number => Math.max(1, Math.ceil(toSats(satPerKw) / 250));
|
||||
|
||||
const targets = [2, 3, 6, 144] as const;
|
||||
const [fastest, halfHour, hour, economy] = await Promise.all(
|
||||
targets.map((target) => this.get<LndEstimateFee>(`/v2/wallet/estimatefee/${target}`)),
|
||||
);
|
||||
|
||||
return {
|
||||
fastestFee: perVbyte(fastest?.sat_per_kw),
|
||||
halfHourFee: perVbyte(halfHour?.sat_per_kw),
|
||||
hourFee: perVbyte(hour?.sat_per_kw),
|
||||
economyFee: perVbyte(economy?.sat_per_kw),
|
||||
// min_relay_fee_sat_per_kw only exists on v0.18+; 253 sat/kw is bitcoind's floor (~1 sat/vB).
|
||||
minimumFee: perVbyte(fastest?.min_relay_fee_sat_per_kw ?? '253'),
|
||||
};
|
||||
}
|
||||
|
||||
override async sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult> {
|
||||
if (req.outpoints?.length && !this.supports('coinControl')) this.notSupported('coin control');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
addr: req.address,
|
||||
sat_per_vbyte: String(req.satPerVbyte),
|
||||
spend_unconfirmed: req.spendUnconfirmed === true,
|
||||
label: req.label,
|
||||
};
|
||||
if (req.sendAll) body.send_all = true;
|
||||
else body.amount = String(req.amountSats ?? 0);
|
||||
|
||||
if (req.outpoints?.length) {
|
||||
body.outpoints = req.outpoints.map((outpoint) => {
|
||||
const [txid, vout] = outpoint.split(':');
|
||||
return { txid_str: txid, output_index: Number(vout ?? 0) };
|
||||
});
|
||||
}
|
||||
|
||||
const res = await this.post<{ txid?: string }>('/v1/transactions', body);
|
||||
if (!res.txid) throw new BackendError('lnd returned no txid', 502);
|
||||
return { txid: res.txid, feeSats: 0, rawHex: null };
|
||||
}
|
||||
|
||||
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private toInvoice(inv: LndInvoice): Invoice {
|
||||
const createdAt = toSats(inv.creation_date);
|
||||
const expiry = toSats(inv.expiry);
|
||||
const expiresAt = expiry > 0 ? createdAt + expiry : null;
|
||||
const settledAt = toSats(inv.settle_date);
|
||||
const preimageHex = inv.r_preimage ? base64ToHex(inv.r_preimage) : '';
|
||||
|
||||
let state: InvoiceState;
|
||||
switch (inv.state) {
|
||||
case 'SETTLED':
|
||||
state = 'settled';
|
||||
break;
|
||||
case 'CANCELED':
|
||||
state = 'canceled';
|
||||
break;
|
||||
case 'ACCEPTED':
|
||||
state = 'accepted';
|
||||
break;
|
||||
default:
|
||||
// lnrpc has no EXPIRED member; an unsettled invoice past its deadline is reported as OPEN.
|
||||
state = expiresAt != null && expiresAt * 1000 < Date.now() ? 'expired' : 'open';
|
||||
}
|
||||
|
||||
return {
|
||||
paymentHash: inv.r_hash ? base64ToHex(inv.r_hash) : '',
|
||||
bolt11: inv.payment_request ?? '',
|
||||
amountMsat: inv.value_msat && inv.value_msat !== '0' ? toMsat(inv.value_msat) : null,
|
||||
amountPaidMsat: inv.amt_paid_msat && inv.amt_paid_msat !== '0' ? toMsat(inv.amt_paid_msat) : null,
|
||||
memo: inv.memo ? inv.memo : null,
|
||||
state,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
settledAt: settledAt > 0 ? settledAt : null,
|
||||
// LND ships an all-zero r_preimage for anything it has not settled — that is a placeholder, not a
|
||||
// secret, and passing it on would let a caller believe it can claim the HTLC.
|
||||
preimage: preimageHex && preimageHex !== ZERO_PREIMAGE_HEX ? preimageHex : null,
|
||||
isKeysend: inv.is_keysend === true,
|
||||
isAmp: inv.is_amp === true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
// reversed=true walks the add_index backwards, i.e. newest first.
|
||||
const res = await this.get<{ invoices?: LndInvoice[] }>('/v1/invoices', {
|
||||
reversed: true,
|
||||
num_max_invoices: opts?.limit ?? 500,
|
||||
});
|
||||
return (res.invoices ?? []).map((inv) => this.toInvoice(inv));
|
||||
}
|
||||
|
||||
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
const res = await this.post<LndAddInvoiceResponse>('/v1/invoices', {
|
||||
memo: req.memo,
|
||||
value_msat: req.amountMsat,
|
||||
expiry: req.expirySeconds != null ? String(req.expirySeconds) : undefined,
|
||||
is_amp: req.isAmp,
|
||||
private: req.private,
|
||||
r_preimage: req.preimage ? hexToBase64(req.preimage) : undefined,
|
||||
});
|
||||
|
||||
const paymentHash = res.r_hash ? base64ToHex(res.r_hash) : '';
|
||||
|
||||
// AddInvoiceResponse carries only the hash, the request string and the payment address — no
|
||||
// timestamps and no state — so read the invoice back to answer with a complete record.
|
||||
const stored = paymentHash ? await this.lookupInvoice(paymentHash).catch(() => null) : null;
|
||||
if (stored) return stored;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
paymentHash,
|
||||
bolt11: res.payment_request ?? '',
|
||||
amountMsat: req.amountMsat ?? null,
|
||||
amountPaidMsat: null,
|
||||
memo: req.memo ?? null,
|
||||
state: 'open',
|
||||
createdAt: now,
|
||||
expiresAt: req.expirySeconds != null ? now + req.expirySeconds : null,
|
||||
settledAt: null,
|
||||
preimage: null,
|
||||
isKeysend: false,
|
||||
isAmp: req.isAmp === true,
|
||||
};
|
||||
}
|
||||
|
||||
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
try {
|
||||
// The path segment is `r_hash_str` — hex, not the base64 used by the body fields.
|
||||
const inv = await this.get<LndInvoice>(`/v1/invoice/${paymentHash}`);
|
||||
return this.toInvoice(inv);
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
|
||||
const res = await this.get<LndPayReq>(`/v1/payreq/${encodeURIComponent(bolt11)}`);
|
||||
return {
|
||||
bolt11,
|
||||
// PayReq.payment_hash is a proto `string` (already hex), unlike Invoice.r_hash which is `bytes`.
|
||||
paymentHash: res.payment_hash ?? '',
|
||||
amountMsat: res.num_msat && res.num_msat !== '0' ? toMsat(res.num_msat) : null,
|
||||
description: res.description ? res.description : null,
|
||||
destination: res.destination ?? '',
|
||||
timestamp: toSats(res.timestamp),
|
||||
expiry: toSats(res.expiry),
|
||||
cltvExpiry: res.cltv_expiry != null ? toSats(res.cltv_expiry) : null,
|
||||
routeHints: (res.route_hints ?? []).length > 0,
|
||||
features: Object.values(res.features ?? {})
|
||||
.map((feature) => feature.name)
|
||||
.filter((name): name is string => !!name),
|
||||
};
|
||||
}
|
||||
|
||||
private toPayment(payment: LndPayment): Payment {
|
||||
const preimage = payment.payment_preimage ?? '';
|
||||
// creation_date (seconds) was deprecated in favour of creation_time_ns; accept either.
|
||||
const createdAt = payment.creation_date
|
||||
? toSats(payment.creation_date)
|
||||
: Math.floor(toSats(payment.creation_time_ns) / 1e9);
|
||||
const hops = payment.htlcs?.[0]?.route?.hops ?? [];
|
||||
|
||||
return {
|
||||
// ListPayments renders payment_hash/payment_preimage as proto `string`s — already hex.
|
||||
paymentHash: payment.payment_hash ?? '',
|
||||
preimage: preimage && preimage !== ZERO_PREIMAGE_HEX ? preimage : null,
|
||||
amountMsat: toMsat(payment.value_msat),
|
||||
feeMsat: toMsat(payment.fee_msat),
|
||||
status: toPaymentStatus(payment.status),
|
||||
createdAt,
|
||||
// lnrpc.Payment has no destination field; the last hop of the first attempt's route is the payee.
|
||||
destination: hops[hops.length - 1]?.pub_key ?? null,
|
||||
// Recovering the memo would mean decoding payment_request on every row.
|
||||
memo: null,
|
||||
failureReason:
|
||||
payment.failure_reason && payment.failure_reason !== 'FAILURE_REASON_NONE' ? payment.failure_reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
||||
const res = await this.get<{ payments?: LndPayment[] }>('/v1/payments', {
|
||||
include_incomplete: true,
|
||||
max_payments: opts?.limit ?? 500,
|
||||
reversed: true,
|
||||
});
|
||||
return (res.payments ?? []).map((payment) => this.toPayment(payment));
|
||||
}
|
||||
|
||||
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
|
||||
if (req.feeLimitMsat && req.feeLimitPercent != null) {
|
||||
throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400);
|
||||
}
|
||||
|
||||
const timeoutSeconds = req.timeoutSeconds ?? 60;
|
||||
const body: Record<string, unknown> = {
|
||||
payment_request: req.bolt11,
|
||||
timeout_seconds: timeoutSeconds,
|
||||
// Zeus sets this so a payment to one's own node is not rejected as a self-payment.
|
||||
allow_self_payment: true,
|
||||
// Collapses the stream to a single terminal frame — see `routerSend`.
|
||||
no_inflight_updates: true,
|
||||
};
|
||||
if (req.amountMsat) body.amt_msat = req.amountMsat;
|
||||
|
||||
if (req.feeLimitMsat) {
|
||||
body.fee_limit_msat = req.feeLimitMsat;
|
||||
} else if (req.feeLimitPercent != null) {
|
||||
// SendPaymentRequest has no percentage form (that is a CLN concept), so resolve the invoice amount
|
||||
// and turn the percentage into the absolute cap LND wants.
|
||||
const amountMsat = req.amountMsat ?? (await this.decodeInvoice(req.bolt11)).amountMsat;
|
||||
if (!amountMsat) {
|
||||
throw new BackendError('feeLimitPercent needs an amount: the invoice is zero-amount', 400);
|
||||
}
|
||||
const limit = (BigInt(amountMsat) * BigInt(Math.round(req.feeLimitPercent * 100))) / 10_000n;
|
||||
body.fee_limit_msat = limit.toString();
|
||||
}
|
||||
|
||||
// Give the HTTP call a little more room than LND's own deadline so the node, not the socket, decides.
|
||||
const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000);
|
||||
return this.toPayment(result);
|
||||
}
|
||||
|
||||
override async sendKeysend(req: KeysendRequest): Promise<Payment> {
|
||||
// Keysend is a spontaneous payment: WE pick the preimage, hash it ourselves, and ship the preimage to
|
||||
// the receiver in TLV 5482373484 so it can settle an HTLC it never issued an invoice for.
|
||||
const preimage = randomBytes(32);
|
||||
const paymentHash = createHash('sha256').update(preimage).digest();
|
||||
|
||||
const customRecords: Record<string, string> = {
|
||||
[KEYSEND_PREIMAGE_RECORD]: preimage.toString('base64'),
|
||||
};
|
||||
if (req.message) customRecords[KEYSEND_MESSAGE_RECORD] = Buffer.from(req.message, 'utf8').toString('base64');
|
||||
|
||||
const timeoutSeconds = 60;
|
||||
const body: Record<string, unknown> = {
|
||||
dest: hexToBase64(req.destination),
|
||||
amt_msat: req.amountMsat,
|
||||
payment_hash: paymentHash.toString('base64'),
|
||||
dest_custom_records: customRecords,
|
||||
// Without a route hint there is no invoice to read a final CLTV delta from; 40 is LND's own default.
|
||||
final_cltv_delta: 40,
|
||||
timeout_seconds: timeoutSeconds,
|
||||
allow_self_payment: true,
|
||||
no_inflight_updates: true,
|
||||
};
|
||||
if (req.feeLimitMsat) body.fee_limit_msat = req.feeLimitMsat;
|
||||
|
||||
const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000);
|
||||
return this.toPayment(result);
|
||||
}
|
||||
|
||||
// ── channels / peers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getChannels(): Promise<Channel[]> {
|
||||
const [open, pending] = await Promise.all([
|
||||
// peer_alias_lookup makes LND resolve each peer's graph alias for us (v0.15.1+); on older nodes the
|
||||
// parameter is ignored and `peer_alias` simply comes back absent.
|
||||
this.get<{ channels?: LndChannel[] }>('/v1/channels', { peer_alias_lookup: true }),
|
||||
this.get<LndPendingChannels>('/v1/channels/pending'),
|
||||
]);
|
||||
|
||||
const channels: Channel[] = (open.channels ?? []).map((chan) => ({
|
||||
channelId: chan.chan_id ?? chan.channel_point ?? '',
|
||||
channelPoint: chan.channel_point ?? null,
|
||||
remotePubkey: chan.remote_pubkey ?? '',
|
||||
remoteAlias: chan.peer_alias ? chan.peer_alias : null,
|
||||
capacitySats: toSats(chan.capacity),
|
||||
localBalanceSats: toSats(chan.local_balance),
|
||||
remoteBalanceSats: toSats(chan.remote_balance),
|
||||
active: chan.active === true,
|
||||
private: chan.private === true,
|
||||
status: 'open',
|
||||
}));
|
||||
|
||||
const fromPending = (entries: { channel?: LndPendingChannel }[] | undefined, status: string): Channel[] =>
|
||||
(entries ?? [])
|
||||
.map((entry) => entry.channel)
|
||||
.filter((chan): chan is LndPendingChannel => !!chan)
|
||||
.map((chan) => ({
|
||||
// A pending channel has no short channel id yet — the funding outpoint is its only identity.
|
||||
channelId: chan.channel_point ?? '',
|
||||
channelPoint: chan.channel_point ?? null,
|
||||
remotePubkey: chan.remote_node_pub ?? '',
|
||||
remoteAlias: null,
|
||||
capacitySats: toSats(chan.capacity),
|
||||
localBalanceSats: toSats(chan.local_balance),
|
||||
remoteBalanceSats: toSats(chan.remote_balance),
|
||||
active: false,
|
||||
private: chan.private === true,
|
||||
status,
|
||||
}));
|
||||
|
||||
return [
|
||||
...channels,
|
||||
...fromPending(pending.pending_open_channels, 'pending-open'),
|
||||
// "waiting close" is a cooperative close whose closing tx has not confirmed; force-closing is the
|
||||
// unilateral path with a timelock still to run.
|
||||
...fromPending(pending.waiting_close_channels, 'pending-close'),
|
||||
...fromPending(pending.pending_force_closing_channels, 'force-closing'),
|
||||
];
|
||||
}
|
||||
|
||||
override async getPeers(): Promise<Peer[]> {
|
||||
const res = await this.get<{ peers?: LndPeer[] }>('/v1/peers');
|
||||
return (res.peers ?? []).map((peer) => ({
|
||||
pubkey: peer.pub_key ?? '',
|
||||
address: peer.address ?? '',
|
||||
alias: null,
|
||||
inbound: peer.inbound === true,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── signing ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async signMessage(message: string): Promise<SignMessageResult> {
|
||||
// lnrpc.SignMessageRequest.msg is `bytes`, so the payload is base64 even though it is plain text.
|
||||
const res = await this.post<{ signature?: string }>('/v1/signmessage', {
|
||||
msg: Buffer.from(message, 'utf8').toString('base64'),
|
||||
});
|
||||
if (!res.signature) throw new BackendError('lnd returned no signature', 502);
|
||||
return { signature: res.signature };
|
||||
}
|
||||
|
||||
override async verifyMessage(message: string, signature: string): Promise<VerifyMessageResult> {
|
||||
const res = await this.post<{ valid?: boolean; pubkey?: string }>('/v1/verifymessage', {
|
||||
msg: Buffer.from(message, 'utf8').toString('base64'),
|
||||
signature,
|
||||
});
|
||||
return { valid: res.valid === true, pubkey: res.pubkey ?? null };
|
||||
}
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
// LNDHub — port of Zeus's backends/LndHub.ts (which `extends LND` and overrides ~10 methods).
|
||||
//
|
||||
// LNDHub is a CUSTODIAL account API, not a node. The server holds the keys; this backend is a thin
|
||||
// client over a REST facade (BlueWallet/LndHub, LNbits' lndhub extension, Alby, lntxbot, …). There is
|
||||
// no node identity, no channel view, no UTXO set and no on-chain spend — only a balance, invoices,
|
||||
// payments and (on some deployments) a single deposit address.
|
||||
//
|
||||
// Differences from Zeus, all deliberate:
|
||||
//
|
||||
// 1. AUTH. Zeus logs in from SettingsStore (SettingsStore.ts:2090-2130), parks `access_token` in an
|
||||
// observable and then never refreshes it — an expired token surfaces as a "bad auth" error string
|
||||
// in the UI. Here the token is cached in memory, acquired lazily, and re-acquired transparently on
|
||||
// the first auth failure of any request, through a single-flight promise so N concurrent calls
|
||||
// produce one login rather than N.
|
||||
// 2. ERRORS. LNDHub reports failure as **HTTP 200 with `{error, code, message}`** (Zeus checks for
|
||||
// this ad hoc in three different stores: InvoicesStore.ts:410, TransactionsStore.ts:795). httpJson
|
||||
// only maps non-2xx, so every response goes through `unwrap()` first. Error code 1 ("bad auth") is
|
||||
// re-raised as a 401 so it feeds the same re-auth path as a real 401.
|
||||
// 3. lookupInvoice / getTransactions. Zeus *inherits* LND's `/v1/invoice/:r_hash` and
|
||||
// `/v1/transactions`, neither of which exists on an LNDHub server — those calls simply fail
|
||||
// upstream. lookupInvoice here scans `/getuserinvoices`; on-chain history is read out of the
|
||||
// `bitcoind_tx` entries that `/gettxs` interleaves with lightning payments.
|
||||
// 4. decodeInvoice. Zeus decodes BOLT11 locally (Bolt11Utils). The sidecar has no bolt11 decoder, so
|
||||
// this uses the server's own `/decodeinvoice`, which returns LND's payreq shape.
|
||||
// 5. The `lnurlAuth` signing modes (Alby vs BlueWallet key derivation) are not ported — LNURL-auth is
|
||||
// not part of the WalletBackend contract.
|
||||
//
|
||||
// UNITS. LNDHub is sloppy here and each field has to be taken on its own terms:
|
||||
// • /balance → BTC.AvailableBalance, satoshis
|
||||
// • /getuserinvoices → `amt`, satoshis
|
||||
// • /gettxs (payment) → `value` and `fee`, satoshis, where `value` already includes `fee`
|
||||
// • /gettxs (onchain) → `amount`, **BTC** as a float (verbatim from bitcoind's listtransactions)
|
||||
// • /decodeinvoice → `num_satoshis` and `num_msat` side by side
|
||||
// Everything is normalised to the contract's sats-as-number / msats-as-decimal-string rule on the way out.
|
||||
|
||||
import {
|
||||
BackendError,
|
||||
type AddressType,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type Capability,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type Invoice,
|
||||
type InvoiceState,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type BitcoinNetwork,
|
||||
} from '../types';
|
||||
import { BaseBackend, base64ToHex, httpJson } from './base';
|
||||
import { decodeBolt11 } from '../bolt11';
|
||||
|
||||
export type LndHubConfig = {
|
||||
/** Base URL of the LNDHub server, e.g. https://lndhub.example.com or https://ln.getalby.com/lndhub. */
|
||||
url: string;
|
||||
login: string;
|
||||
password: string;
|
||||
/** Opt-in TLS relaxation for a self-hosted server with a self-signed cert. Never default. */
|
||||
allowSelfSigned?: boolean;
|
||||
};
|
||||
|
||||
// ── upstream wire shapes ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every LNDHub response can be this instead of the documented shape, with HTTP 200. */
|
||||
type LndHubErrorBody = { error?: string | boolean | number; code?: number; message?: string };
|
||||
|
||||
/** node's `JSON.stringify(Buffer)` — LNDHub leaks raw Buffers for hash/preimage fields. */
|
||||
type LndHubBuffer = { type?: string; data?: number[] };
|
||||
type LndHubBytes = string | LndHubBuffer;
|
||||
|
||||
type LndHubAuth = { access_token?: string; refresh_token?: string };
|
||||
|
||||
type LndHubBalance = { BTC?: { AvailableBalance?: number; TotalBalance?: number } };
|
||||
|
||||
type LndHubGetInfo = {
|
||||
identity_pubkey?: string;
|
||||
alias?: string;
|
||||
version?: string;
|
||||
block_height?: number;
|
||||
testnet?: boolean;
|
||||
synced_to_chain?: boolean;
|
||||
chains?: { chain?: string; network?: string }[];
|
||||
};
|
||||
|
||||
type LndHubUserInvoice = {
|
||||
payment_request?: string;
|
||||
pay_req?: string;
|
||||
r_hash?: LndHubBytes;
|
||||
payment_hash?: LndHubBytes;
|
||||
description?: string;
|
||||
memo?: string;
|
||||
ispaid?: boolean;
|
||||
/** satoshis */
|
||||
amt?: number | string;
|
||||
amt_paid_sat?: number | string;
|
||||
amt_paid_msat?: number | string;
|
||||
/** seconds of validity, relative to `timestamp` */
|
||||
expire_time?: number;
|
||||
/** unix seconds */
|
||||
timestamp?: number | string;
|
||||
settled_at?: number;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
/** `/gettxs` interleaves outgoing lightning payments and imported on-chain deposits. */
|
||||
type LndHubTx = {
|
||||
type?: string;
|
||||
// paid_invoice
|
||||
payment_preimage?: LndHubBytes;
|
||||
payment_hash?: LndHubBytes;
|
||||
payment_request?: string;
|
||||
/** satoshis, and LNDHub folds `fee` into it (User.getTxs) */
|
||||
value?: number | string;
|
||||
/** satoshis */
|
||||
fee?: number | string;
|
||||
memo?: string;
|
||||
description?: string;
|
||||
timestamp?: number | string;
|
||||
// bitcoind_tx
|
||||
txid?: string;
|
||||
/** BTC, float, straight from bitcoind */
|
||||
amount?: number;
|
||||
confirmations?: number;
|
||||
address?: string;
|
||||
category?: string;
|
||||
time?: number;
|
||||
blockheight?: number;
|
||||
};
|
||||
|
||||
type LndHubAddress = { address?: string };
|
||||
|
||||
type LndHubDecoded = {
|
||||
destination?: string;
|
||||
payment_hash?: string;
|
||||
num_satoshis?: string | number;
|
||||
num_msat?: string | number;
|
||||
timestamp?: string | number;
|
||||
expiry?: string | number;
|
||||
description?: string;
|
||||
cltv_expiry?: string | number;
|
||||
route_hints?: unknown[];
|
||||
features?: Record<string, { name?: string }>;
|
||||
};
|
||||
|
||||
/** `/payinvoice` passes LND's sendPaymentSync response through, mostly. */
|
||||
type LndHubPayResult = {
|
||||
payment_error?: string;
|
||||
payment_preimage?: LndHubBytes;
|
||||
payment_hash?: LndHubBytes;
|
||||
payment_route?: {
|
||||
total_amt?: number | string;
|
||||
total_fees?: number | string;
|
||||
total_amt_msat?: number | string;
|
||||
total_fees_msat?: number | string;
|
||||
};
|
||||
decoded?: LndHubDecoded;
|
||||
/** some forks answer a zero-amount pay with the amount they chose */
|
||||
num_satoshis?: number | string;
|
||||
};
|
||||
|
||||
type LndHubCall = {
|
||||
path: string;
|
||||
method?: 'GET' | 'POST';
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
};
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HEX32 = /^[0-9a-f]{64}$/i;
|
||||
|
||||
/** LNDHub returns a 32-byte field as hex, base64, url-safe base64 or a stringified Buffer. */
|
||||
function bytesToHex(value: LndHubBytes | undefined | null): string {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') {
|
||||
if (HEX32.test(value)) return value.toLowerCase();
|
||||
return base64ToHex(value.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
}
|
||||
if (Array.isArray(value.data)) return Buffer.from(value.data).toString('hex');
|
||||
return '';
|
||||
}
|
||||
|
||||
const num = (value: number | string | undefined | null): number => {
|
||||
const n = typeof value === 'string' ? Number(value) : value;
|
||||
return typeof n === 'number' && Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
const satsToMsat = (sats: number): string => (BigInt(Math.round(sats)) * 1000n).toString();
|
||||
|
||||
/** msat string → whole satoshis. LNDHub cannot express sub-satoshi amounts anywhere. */
|
||||
function msatToSats(msat: string, field: string): number {
|
||||
const value = BigInt(msat);
|
||||
if (value % 1000n !== 0n) {
|
||||
throw new BackendError(`lndhub cannot express sub-satoshi amounts (${field}=${msat}msat)`, 400, 'BAD_AMOUNT');
|
||||
}
|
||||
return Number(value / 1000n);
|
||||
}
|
||||
|
||||
/** LNDHub tells us nothing about the address it hands out, so classify it by prefix. */
|
||||
function addressType(address: string): AddressType {
|
||||
const a = address.toLowerCase();
|
||||
if (a.startsWith('bc1p') || a.startsWith('tb1p') || a.startsWith('bcrt1p')) return 'p2tr';
|
||||
if (a.startsWith('bc1') || a.startsWith('tb1') || a.startsWith('bcrt1')) return 'p2wpkh';
|
||||
if (a.startsWith('3') || a.startsWith('2')) return 'p2sh-p2wpkh';
|
||||
return 'p2pkh';
|
||||
}
|
||||
|
||||
// ── backend ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LndHubBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'lndhub';
|
||||
|
||||
// Custodial: receive and send over lightning, plus a deposit address on deployments that expose
|
||||
// /getbtc. No on-chain send, no coin control, no PSBT, no channels, no peers, no message signing —
|
||||
// the wallet does not hold the keys those would need.
|
||||
protected readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
|
||||
'lightningReceive',
|
||||
'lightningSend',
|
||||
'onchainReceive',
|
||||
]);
|
||||
|
||||
private accessToken: string | null = null;
|
||||
/** In-flight login, shared by every caller that needs a token — the single-flight refresh. */
|
||||
private pendingLogin: Promise<string> | null = null;
|
||||
|
||||
constructor(private readonly config: LndHubConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── auth ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `POST /auth?type=auth {login, password}` → `{access_token, refresh_token}`. The refresh_token
|
||||
* grant (`type=refresh_token`) is deliberately not used: we hold the password, so a fresh login is
|
||||
* one round-trip either way and has one failure mode instead of two.
|
||||
*/
|
||||
private async login(): Promise<string> {
|
||||
const res = await httpJson<LndHubAuth & LndHubErrorBody>({
|
||||
base: this.config.url,
|
||||
path: '/auth',
|
||||
method: 'POST',
|
||||
query: { type: 'auth' },
|
||||
body: { login: this.config.login, password: this.config.password },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
});
|
||||
if (res?.error || !res?.access_token) {
|
||||
const message = res?.message ?? (typeof res?.error === 'string' ? res.error : 'lndhub rejected the login');
|
||||
throw new BackendError(message, 401, 'LNDHUB_AUTH_FAILED');
|
||||
}
|
||||
this.accessToken = res.access_token;
|
||||
return res.access_token;
|
||||
}
|
||||
|
||||
private async token(force = false): Promise<string> {
|
||||
if (!force && this.accessToken) return this.accessToken;
|
||||
// Concurrent 401s all await the same login; whoever loses the race gets the winner's token.
|
||||
this.pendingLogin ??= this.login().finally(() => {
|
||||
this.pendingLogin = null;
|
||||
});
|
||||
return this.pendingLogin;
|
||||
}
|
||||
|
||||
/** LNDHub's 200-with-`{error}` channel, folded back into the exception path. */
|
||||
private unwrap<T>(payload: T): T {
|
||||
const body = payload as LndHubErrorBody | null;
|
||||
if (body && typeof body === 'object' && !Array.isArray(body) && body.error) {
|
||||
const message = body.message || (typeof body.error === 'string' ? body.error : 'lndhub returned an error');
|
||||
// code 1 is LNDHub's "bad auth"; some forks only put the phrase in the message.
|
||||
if (body.code === 1 || /bad auth/i.test(message)) {
|
||||
throw new BackendError(message, 401, 'LNDHUB_BAD_AUTH');
|
||||
}
|
||||
throw new BackendError(message, 502, body.code != null ? `LNDHUB_${body.code}` : undefined);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async call<T>(req: LndHubCall): Promise<T> {
|
||||
const send = async (token: string): Promise<T> =>
|
||||
this.unwrap(
|
||||
await httpJson<T>({
|
||||
base: this.config.url,
|
||||
path: req.path,
|
||||
method: req.method ?? 'GET',
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
return await send(await this.token());
|
||||
} catch (err) {
|
||||
// One retry, and only for auth — a second 401 with a token minted seconds ago is a real failure.
|
||||
if (!(err instanceof BackendError) || err.status !== 401) throw err;
|
||||
this.accessToken = null;
|
||||
return send(await this.token(true));
|
||||
}
|
||||
}
|
||||
|
||||
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Best-effort. `/getinfo` exists on BlueWallet-derived servers but not on every fork (Alby, LNbits),
|
||||
* so a failure degrades to an anonymous descriptor rather than breaking connect. Zeus sidesteps this
|
||||
* by reporting `supportsNodeInfo() = false` and never calling it.
|
||||
*/
|
||||
override async getInfo(): Promise<NodeInfo> {
|
||||
let info: LndHubGetInfo | null = null;
|
||||
try {
|
||||
info = await this.call<LndHubGetInfo>({ path: '/getinfo' });
|
||||
} catch {
|
||||
info = null;
|
||||
}
|
||||
|
||||
const chain = info?.chains?.[0]?.network;
|
||||
const network: BitcoinNetwork =
|
||||
chain === 'testnet' || chain === 'signet' || chain === 'regtest'
|
||||
? chain
|
||||
: info?.testnet === true
|
||||
? 'testnet'
|
||||
: 'bitcoin';
|
||||
|
||||
return {
|
||||
kind: this.kind,
|
||||
// The account is custodial — the pubkey, if any, belongs to the operator's node, not to us.
|
||||
pubkey: info?.identity_pubkey ?? null,
|
||||
alias: info?.alias ?? null,
|
||||
version: info?.version ?? null,
|
||||
network,
|
||||
blockHeight: info?.block_height ?? null,
|
||||
synced: info?.synced_to_chain ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const res = await this.call<LndHubBalance>({ path: '/balance' });
|
||||
return {
|
||||
// A custodial account has no on-chain balance of its own; deposits land in the lightning balance.
|
||||
onchainConfirmed: 0,
|
||||
onchainFrozen: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
lightningBalance: num(res?.BTC?.AvailableBalance),
|
||||
// Inbound liquidity is the operator's problem and is never reported.
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── on-chain ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `/gettxs` mixes `paid_invoice` (lightning, outgoing) with `bitcoind_tx` (on-chain deposits copied
|
||||
* out of bitcoind's listtransactions). Only the latter belong here.
|
||||
*/
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
const txs = await this.fetchTxs(opts?.limit);
|
||||
return txs
|
||||
.filter((tx) => tx.type === 'bitcoind_tx' || (!!tx.txid && tx.payment_preimage === undefined))
|
||||
.map((tx) => ({
|
||||
txid: tx.txid ?? '',
|
||||
// `amount` here is BTC as a float, not sats. Round after scaling — 0.1 BTC is not exact.
|
||||
amount: Math.round(num(tx.amount) * 1e8),
|
||||
feeSats: null,
|
||||
blockHeight: tx.blockheight ?? null,
|
||||
timestamp: tx.time ?? (tx.timestamp != null ? num(tx.timestamp) : null),
|
||||
confirmations: tx.confirmations ?? 0,
|
||||
label: null,
|
||||
destAddresses: tx.address ? [tx.address] : [],
|
||||
rawHex: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `/getbtc` returns the account's single deposit address as a one-element array (Zeus reads
|
||||
* `data[0].address`, InvoicesStore.ts:620). It is static per account, so `peek` is meaningless and
|
||||
* the requested address type is ignored — the operator chose it. On the deployments that hand back an
|
||||
* empty array until an address has been allocated, `/newbtc` allocates one.
|
||||
*/
|
||||
override async getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
let address = await this.fetchBtcAddress();
|
||||
if (!address) {
|
||||
await this.call<unknown>({ path: '/newbtc', method: 'POST', body: {} });
|
||||
address = await this.fetchBtcAddress();
|
||||
}
|
||||
if (!address) {
|
||||
throw new BackendError('lndhub server does not offer on-chain deposits', 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
return { address, type: addressType(address) };
|
||||
}
|
||||
|
||||
private async fetchBtcAddress(): Promise<string> {
|
||||
const res = await this.call<LndHubAddress[] | LndHubAddress>({ path: '/getbtc' });
|
||||
if (Array.isArray(res)) return res[0]?.address ?? '';
|
||||
return res?.address ?? '';
|
||||
}
|
||||
|
||||
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
const res = await this.call<LndHubUserInvoice[]>({
|
||||
path: '/getuserinvoices',
|
||||
query: { limit: opts?.limit },
|
||||
});
|
||||
return (Array.isArray(res) ? res : []).map((inv) => this.toInvoice(inv));
|
||||
}
|
||||
|
||||
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
if (req.preimage) return this.notSupported('custom preimages');
|
||||
if (req.isAmp) return this.notSupported('AMP invoices');
|
||||
// LNDHub answers a zero-amount invoice request with "Bad arguments" (Zeus special-cases the string
|
||||
// in InvoicesStore.ts:425); reject it here with something legible instead.
|
||||
if (!req.amountMsat || req.amountMsat === '0') {
|
||||
throw new BackendError('lndhub requires an invoice amount', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
const sats = msatToSats(req.amountMsat, 'amountMsat');
|
||||
// `expirySeconds` and `private` have no equivalent — the server picks both (Zeus:
|
||||
// supportsSettingInvoiceExpiration() = false).
|
||||
const res = await this.call<LndHubUserInvoice>({
|
||||
path: '/addinvoice',
|
||||
method: 'POST',
|
||||
body: { amt: String(sats), memo: req.memo ?? '' },
|
||||
});
|
||||
|
||||
const bolt11 = res.payment_request ?? res.pay_req ?? '';
|
||||
let paymentHash = bytesToHex(res.r_hash ?? res.payment_hash);
|
||||
// A few forks omit r_hash on /addinvoice. The hash is already in the invoice we were just handed, so
|
||||
// read it locally rather than spending a second authenticated round-trip — and a second failure mode
|
||||
// — on `/decodeinvoice`, which those same forks are the least likely to implement.
|
||||
if (!paymentHash && bolt11) {
|
||||
paymentHash = decodeBolt11(bolt11).paymentHash;
|
||||
}
|
||||
|
||||
const createdAt = res.timestamp != null ? num(res.timestamp) : Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
paymentHash,
|
||||
bolt11,
|
||||
amountMsat: satsToMsat(sats),
|
||||
amountPaidMsat: null,
|
||||
memo: req.memo ?? res.description ?? null,
|
||||
state: 'open',
|
||||
createdAt,
|
||||
expiresAt: res.expire_time ? createdAt + res.expire_time : null,
|
||||
settledAt: null,
|
||||
preimage: null,
|
||||
isKeysend: false,
|
||||
isAmp: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LNDHub has no lookup endpoint — Zeus inherits LND's `/v1/invoice/:r_hash`, which 404s here. Scan
|
||||
* the invoice list instead. Bounded at 200 entries: an older invoice reports as not found rather
|
||||
* than paging the whole history on every poll.
|
||||
*/
|
||||
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
const wanted = paymentHash.toLowerCase();
|
||||
const res = await this.call<LndHubUserInvoice[]>({ path: '/getuserinvoices', query: { limit: 200 } });
|
||||
const found = (Array.isArray(res) ? res : []).find((inv) => bytesToHex(inv.r_hash ?? inv.payment_hash) === wanted);
|
||||
return found ? this.toInvoice(found) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeus decodes BOLT11 client-side (Bolt11Utils) and never calls this endpoint. The sidecar has no
|
||||
* decoder, and the server's `/decodeinvoice?invoice=` answers with LND's payreq shape, which maps
|
||||
* onto DecodedInvoice one-for-one.
|
||||
*/
|
||||
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
|
||||
const res = await this.call<LndHubDecoded>({ path: '/decodeinvoice', query: { invoice: bolt11 } });
|
||||
const msat = res.num_msat != null ? String(num(res.num_msat)) : null;
|
||||
return {
|
||||
bolt11,
|
||||
paymentHash: res.payment_hash ?? '',
|
||||
amountMsat: msat ?? (res.num_satoshis != null ? satsToMsat(num(res.num_satoshis)) : null),
|
||||
description: res.description ?? null,
|
||||
destination: res.destination ?? '',
|
||||
timestamp: num(res.timestamp),
|
||||
expiry: res.expiry != null ? num(res.expiry) : 3600,
|
||||
cltvExpiry: res.cltv_expiry != null ? num(res.cltv_expiry) : null,
|
||||
routeHints: Array.isArray(res.route_hints) && res.route_hints.length > 0,
|
||||
features: Object.values(res.features ?? {}).map((f) => f?.name ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
||||
const txs = await this.fetchTxs(opts?.limit);
|
||||
return txs
|
||||
.filter((tx) => tx.type === 'paid_invoice' || tx.payment_preimage !== undefined)
|
||||
.map((tx) => {
|
||||
// LNDHub's User.getTxs() sets `value = payment_route.total_amt + payment_route.total_fees`,
|
||||
// so the destination amount is `value - fee`. Both are satoshis.
|
||||
const feeSats = num(tx.fee);
|
||||
const amountSats = Math.max(num(tx.value) - feeSats, 0);
|
||||
return {
|
||||
paymentHash: bytesToHex(tx.payment_hash),
|
||||
preimage: bytesToHex(tx.payment_preimage) || null,
|
||||
amountMsat: satsToMsat(amountSats),
|
||||
feeMsat: satsToMsat(feeSats),
|
||||
// /gettxs only records payments that went through; failures are never persisted.
|
||||
status: 'succeeded' as const,
|
||||
createdAt: num(tx.timestamp ?? tx.time),
|
||||
destination: null,
|
||||
memo: tx.memo ?? tx.description ?? null,
|
||||
failureReason: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /payinvoice {invoice, amount}` — `amount` is satoshis and only consulted for a zero-amount
|
||||
* invoice. Fee limits and timeouts are the operator's policy (Zeus: supportsCustomFeeLimit() =
|
||||
* false), so feeLimitMsat / feeLimitPercent / timeoutSeconds are accepted and ignored.
|
||||
*/
|
||||
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
|
||||
const amountSats = req.amountMsat ? msatToSats(req.amountMsat, 'amountMsat') : undefined;
|
||||
const res = await this.call<LndHubPayResult>({
|
||||
path: '/payinvoice',
|
||||
method: 'POST',
|
||||
body: { invoice: req.bolt11, amount: amountSats },
|
||||
});
|
||||
|
||||
const route = res.payment_route ?? {};
|
||||
const feeMsat =
|
||||
route.total_fees_msat != null ? String(num(route.total_fees_msat)) : satsToMsat(num(route.total_fees));
|
||||
// Prefer the invoice's own amount: `total_amt` is ambiguous across forks about whether fees are
|
||||
// included, whereas the decoded payreq is not.
|
||||
const decodedMsat =
|
||||
res.decoded?.num_msat != null
|
||||
? String(num(res.decoded.num_msat))
|
||||
: res.decoded?.num_satoshis != null
|
||||
? satsToMsat(num(res.decoded.num_satoshis))
|
||||
: null;
|
||||
const routedMsat =
|
||||
route.total_amt_msat != null ? String(num(route.total_amt_msat)) : satsToMsat(num(route.total_amt));
|
||||
|
||||
const preimage = bytesToHex(res.payment_preimage) || null;
|
||||
// Hard failures arrive as `{error, message}` and have already thrown in unwrap(). `payment_error`
|
||||
// is LND's soft channel — a routing failure, reported as a failed Payment rather than an exception.
|
||||
const failed = !!res.payment_error;
|
||||
|
||||
return {
|
||||
paymentHash: bytesToHex(res.payment_hash) || res.decoded?.payment_hash || '',
|
||||
preimage,
|
||||
amountMsat: decodedMsat ?? req.amountMsat ?? routedMsat,
|
||||
feeMsat,
|
||||
status: failed ? 'failed' : 'succeeded',
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
destination: res.decoded?.destination ?? null,
|
||||
memo: res.decoded?.description ?? null,
|
||||
failureReason: failed ? (res.payment_error ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── shared ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async fetchTxs(limit?: number): Promise<LndHubTx[]> {
|
||||
const res = await this.call<LndHubTx[]>({ path: '/gettxs', query: { limit } });
|
||||
return Array.isArray(res) ? res : [];
|
||||
}
|
||||
|
||||
private toInvoice(inv: LndHubUserInvoice): Invoice {
|
||||
const createdAt = num(inv.timestamp);
|
||||
const expiresAt = inv.expire_time ? createdAt + inv.expire_time : null;
|
||||
const paid = inv.ispaid === true;
|
||||
// LNDHub has no cancel and no hold invoices, so 'canceled' and 'accepted' are unreachable; an
|
||||
// unpaid invoice past its expiry is reported as expired rather than left open.
|
||||
const state: InvoiceState = paid
|
||||
? 'settled'
|
||||
: expiresAt != null && expiresAt < Math.floor(Date.now() / 1000)
|
||||
? 'expired'
|
||||
: 'open';
|
||||
|
||||
const amountMsat = inv.amt != null ? satsToMsat(num(inv.amt)) : null;
|
||||
const paidMsat =
|
||||
inv.amt_paid_msat != null
|
||||
? String(num(inv.amt_paid_msat))
|
||||
: inv.amt_paid_sat != null
|
||||
? satsToMsat(num(inv.amt_paid_sat))
|
||||
: paid
|
||||
? amountMsat
|
||||
: null;
|
||||
|
||||
return {
|
||||
paymentHash: bytesToHex(inv.r_hash ?? inv.payment_hash),
|
||||
bolt11: inv.payment_request ?? inv.pay_req ?? '',
|
||||
amountMsat,
|
||||
amountPaidMsat: paidMsat,
|
||||
memo: inv.description ?? inv.memo ?? null,
|
||||
state,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
// The settle time is not recorded upstream; only the ispaid flag is.
|
||||
settledAt: inv.settled_at ?? null,
|
||||
// Preimages of received payments stay with the custodian.
|
||||
preimage: null,
|
||||
isKeysend: false,
|
||||
isAmp: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
// Nostr Wallet Connect (NIP-47) — port of Zeus's backends/NostrWalletConnect.ts.
|
||||
//
|
||||
// A NWC connection is a capability grant, not a node: a `nostr+walletconnect://` URI carries a wallet
|
||||
// service pubkey, one or more relays and a shared secret. Every operation is an encrypted nostr event
|
||||
// round-tripped through a relay, so the whole surface is lightning-only and the wallet decides which
|
||||
// commands it will honour.
|
||||
//
|
||||
// CAPABILITIES ARE DYNAMIC. This is the one backend whose `capabilities` set is not a constant: the
|
||||
// wallet advertises its methods in the `get_info` response (falling back to the kind-13194 info event),
|
||||
// and the set is rebuilt from that list on connect. Zeus hardcodes `supportsKeysend() = false` even
|
||||
// though plenty of NWC wallets do keysend; here `keysend` appears iff the wallet lists `pay_keysend`.
|
||||
// Before the first connect the set holds the NIP-47 baseline (make_invoice + pay_invoice), because
|
||||
// `supports()` is synchronous and cannot await the handshake.
|
||||
//
|
||||
// Differences from Zeus, all deliberate:
|
||||
//
|
||||
// 1. TRANSPORT. Zeus drives `NostrWebLNProvider` from `@getalby/sdk`, a WebLN shim that silently
|
||||
// converts sats↔msats. This uses `NWCClient` from the same package (v8), which speaks raw NIP-47
|
||||
// — every amount in and out is millisatoshis, matching this contract's msat-as-string rule with
|
||||
// no lossy hop through sats. Bun/Node 22 both provide the global WebSocket that nostr-tools needs.
|
||||
// 2. lookupInvoice. Zeus passes `Base64Utils.hexToBase64(r_hash)` (NostrWalletConnect.ts:78) — NIP-47
|
||||
// specifies payment_hash as **hex**, so that call cannot match. Hex is sent here.
|
||||
// 3. INVOICE/PAYMENT SPLIT. Zeus calls `list_transactions` and filters `type` client-side, twice.
|
||||
// NIP-47 takes `type` as a request parameter; the filter is pushed to the wallet, with the
|
||||
// client-side filter kept as a guard for wallets that ignore it.
|
||||
// 4. payInvoice. NIP-47 answers a payment with `{preimage, fees_paid}` and nothing else, so Zeus
|
||||
// returns a Payment with no hash. The payment hash is sha256(preimage) by definition, so it is
|
||||
// computed locally; the amount is filled from a best-effort `lookup_invoice` when the wallet
|
||||
// supports it, otherwise from the request.
|
||||
// 5. decodeInvoice is LOCAL. NIP-47 has no decode command, so this is the one operation that never
|
||||
// touches the relay: `../bolt11` (ported from Zeus's Bolt11Utils) decodes in-process. On-chain,
|
||||
// channels, peers and message signing do stay at BaseBackend's 501 — NIP-47 `sign_message` signs
|
||||
// with the node key, which is not what the contract's signMessage means.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Nip47Error, Nip47TimeoutError, NWCClient, type Nip47Method, type Nip47Transaction } from '@getalby/sdk/nwc';
|
||||
import { decodeBolt11 } from '../bolt11';
|
||||
import {
|
||||
BackendError,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type Invoice,
|
||||
type InvoiceState,
|
||||
type KeysendRequest,
|
||||
type NodeInfo,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type PaymentStatus,
|
||||
} from '../types';
|
||||
import { BaseBackend } from './base';
|
||||
|
||||
export type NwcConfig = {
|
||||
/** A `nostr+walletconnect://<wallet-pubkey>?relay=…&secret=…` URI. */
|
||||
connectionUri: string;
|
||||
};
|
||||
|
||||
// ── upstream wire shapes ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The SDK types `state` as required, but it was added late to NIP-47 and pre-1.0 wallets omit it —
|
||||
* their only settlement signal is a non-zero `settled_at`. Amounts are millisatoshis throughout.
|
||||
*/
|
||||
type NwcTransaction = Omit<Nip47Transaction, 'state'> & { state?: Nip47Transaction['state'] };
|
||||
|
||||
/** NIP-47 error codes, mapped onto HTTP so routes.ts can answer honestly. */
|
||||
const ERROR_STATUS: Record<string, number> = {
|
||||
NOT_IMPLEMENTED: 501,
|
||||
UNSUPPORTED_ENCRYPTION: 501,
|
||||
UNAUTHORIZED: 403,
|
||||
RESTRICTED: 403,
|
||||
INSUFFICIENT_BALANCE: 402,
|
||||
QUOTA_EXCEEDED: 402,
|
||||
PAYMENT_FAILED: 502,
|
||||
NOT_FOUND: 404,
|
||||
RATE_LIMITED: 429,
|
||||
INTERNAL: 502,
|
||||
OTHER: 502,
|
||||
};
|
||||
|
||||
/** The keysend TLV that carries a human-readable message (the de-facto standard record). */
|
||||
const KEYSEND_MESSAGE_TLV = 34349334;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const now = (): number => Math.floor(Date.now() / 1000);
|
||||
|
||||
/** payment_hash = sha256(preimage) — how NIP-47 lets us recover a hash it never sends back. */
|
||||
function hashFromPreimage(preimage: string | undefined): string {
|
||||
if (!preimage) return '';
|
||||
return createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex');
|
||||
}
|
||||
|
||||
/** msat decimal string → the integer msats NIP-47 wants, with the JS-safe range enforced. */
|
||||
function toMsatNumber(msat: string, field: string): number {
|
||||
const value = BigInt(msat);
|
||||
if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new BackendError(`${field}=${msat}msat is out of range for NIP-47`, 400, 'BAD_AMOUNT');
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function toBackendError(err: unknown, op: string): BackendError {
|
||||
if (err instanceof BackendError) return err;
|
||||
if (err instanceof Nip47TimeoutError) {
|
||||
return new BackendError(`nwc ${op} timed out: ${err.message}`, 504, err.code);
|
||||
}
|
||||
if (err instanceof Nip47Error) {
|
||||
return new BackendError(`nwc ${op} failed: ${err.message}`, ERROR_STATUS[err.code] ?? 502, err.code);
|
||||
}
|
||||
return new BackendError(`nwc ${op} failed: ${String(err)}`, 502, 'NWC_ERROR');
|
||||
}
|
||||
|
||||
function toInvoiceState(tx: NwcTransaction): InvoiceState {
|
||||
if (tx.state === 'settled') return 'settled';
|
||||
if (tx.state === 'accepted') return 'accepted';
|
||||
// NIP-47 has no explicit cancel; a hold invoice that was released comes back as failed.
|
||||
if (tx.state === 'failed') return 'canceled';
|
||||
if (tx.settled_at) return 'settled';
|
||||
if (tx.expires_at && tx.expires_at < now()) return 'expired';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function toPaymentStatus(tx: NwcTransaction): PaymentStatus {
|
||||
if (tx.state === 'settled') return 'succeeded';
|
||||
if (tx.state === 'failed') return 'failed';
|
||||
if (tx.state) return 'pending';
|
||||
return tx.settled_at ? 'succeeded' : 'pending';
|
||||
}
|
||||
|
||||
function toInvoice(tx: NwcTransaction): Invoice {
|
||||
const amountMsat = tx.amount != null ? String(tx.amount) : null;
|
||||
const state = toInvoiceState(tx);
|
||||
return {
|
||||
paymentHash: tx.payment_hash ?? '',
|
||||
bolt11: tx.invoice ?? '',
|
||||
amountMsat,
|
||||
amountPaidMsat: state === 'settled' ? amountMsat : null,
|
||||
memo: tx.description || null,
|
||||
state,
|
||||
createdAt: tx.created_at ?? now(),
|
||||
expiresAt: tx.expires_at || null,
|
||||
settledAt: tx.settled_at || null,
|
||||
preimage: tx.preimage || null,
|
||||
// An incoming payment with no BOLT11 attached to it can only have been a keysend.
|
||||
isKeysend: !tx.invoice && !!tx.payment_hash,
|
||||
// NIP-47 has no AMP concept.
|
||||
isAmp: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toPayment(tx: NwcTransaction): Payment {
|
||||
return {
|
||||
paymentHash: tx.payment_hash ?? '',
|
||||
preimage: tx.preimage || null,
|
||||
amountMsat: String(tx.amount ?? 0),
|
||||
feeMsat: String(tx.fees_paid ?? 0),
|
||||
status: toPaymentStatus(tx),
|
||||
createdAt: tx.created_at ?? now(),
|
||||
// The wallet never names the payee; only the invoice it paid.
|
||||
destination: null,
|
||||
memo: tx.description || null,
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── backend ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class NwcBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'nwc';
|
||||
|
||||
/**
|
||||
* Mutable behind a ReadonlySet view — `capabilities` and `caps` are the same object, so rebuilding
|
||||
* the set after the get_info handshake is visible through `supports()` without reassigning a
|
||||
* readonly field. Seeded with the NIP-47 baseline until the wallet says otherwise.
|
||||
*/
|
||||
private readonly caps = new Set<Capability>(['lightningReceive', 'lightningSend']);
|
||||
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
|
||||
|
||||
private client: NWCClient | null = null;
|
||||
private connecting: Promise<NWCClient> | null = null;
|
||||
private methods: ReadonlySet<Nip47Method> = new Set<Nip47Method>();
|
||||
|
||||
constructor(private readonly config: NwcConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── connection ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Single-flight connect: the relay subscription and the get_info handshake happen exactly once. */
|
||||
private async connect(): Promise<NWCClient> {
|
||||
if (this.client) return this.client;
|
||||
this.connecting ??= this.open().finally(() => {
|
||||
this.connecting = null;
|
||||
});
|
||||
return this.connecting;
|
||||
}
|
||||
|
||||
private async open(): Promise<NWCClient> {
|
||||
let client: NWCClient;
|
||||
try {
|
||||
client = new NWCClient({ nostrWalletConnectUrl: this.config.connectionUri });
|
||||
} catch (err) {
|
||||
throw new BackendError(`invalid NWC connection URI: ${String(err)}`, 400, 'BAD_CONFIG');
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await client.getInfo();
|
||||
this.applyMethods(info.methods);
|
||||
} catch (err) {
|
||||
// Wallets that do not implement get_info still publish a kind-13194 info event listing their
|
||||
// capabilities. If neither is reachable the connection itself is broken — surface that.
|
||||
try {
|
||||
const service = await client.getWalletServiceInfo();
|
||||
this.applyMethods(service.capabilities.filter((cap): cap is Nip47Method => cap !== 'notifications'));
|
||||
} catch {
|
||||
client.close();
|
||||
throw toBackendError(err, 'connect');
|
||||
}
|
||||
}
|
||||
|
||||
this.client = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Capability negotiation: the whole point of NWC's get_info. */
|
||||
private applyMethods(methods: Nip47Method[] | undefined): void {
|
||||
if (!methods?.length) return;
|
||||
this.methods = new Set(methods);
|
||||
this.caps.clear();
|
||||
if (this.methods.has('make_invoice')) this.caps.add('lightningReceive');
|
||||
if (this.methods.has('pay_invoice')) this.caps.add('lightningSend');
|
||||
if (this.methods.has('pay_keysend')) this.caps.add('keysend');
|
||||
}
|
||||
|
||||
private async run<T>(op: string, fn: (client: NWCClient) => Promise<T>): Promise<T> {
|
||||
const client = await this.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} catch (err) {
|
||||
throw toBackendError(err, op);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the relay subscription. Not part of WalletBackend; the factory calls it on teardown. */
|
||||
close(): void {
|
||||
this.client?.close();
|
||||
this.client = null;
|
||||
this.methods = new Set<Nip47Method>();
|
||||
}
|
||||
|
||||
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInfo(): Promise<NodeInfo> {
|
||||
const info = await this.run('get_info', (client) => client.getInfo());
|
||||
const network = info.network === 'mainnet' || !info.network ? 'bitcoin' : info.network;
|
||||
return {
|
||||
kind: this.kind,
|
||||
// The pubkey belongs to the wallet service's node, not to this connection.
|
||||
pubkey: info.pubkey || null,
|
||||
alias: info.alias || null,
|
||||
// NIP-47 carries no implementation version.
|
||||
version: null,
|
||||
network: (['bitcoin', 'testnet', 'signet', 'regtest'] as const).includes(network as BitcoinNetwork)
|
||||
? (network as BitcoinNetwork)
|
||||
: 'bitcoin',
|
||||
blockHeight: info.block_height ?? null,
|
||||
// A wallet that answers at all is by definition usable; there is no sync flag in NIP-47.
|
||||
synced: true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const res = await this.run('get_balance', (client) => client.getBalance());
|
||||
return {
|
||||
// NWC is lightning-only; there is no on-chain side to report.
|
||||
onchainConfirmed: 0,
|
||||
onchainFrozen: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
// `balance` is msats (the WebLN shim Zeus uses divides by 1000 before the UI ever sees it).
|
||||
lightningBalance: Math.floor((res.balance ?? 0) / 1000),
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
const res = await this.run('list_transactions', (client) =>
|
||||
client.listTransactions({ type: 'incoming', limit: opts?.limit, unpaid: true }),
|
||||
);
|
||||
// The `type` filter is a request parameter, but older wallets ignore it — filter again.
|
||||
return (res.transactions ?? []).filter((tx) => tx.type !== 'outgoing').map(toInvoice);
|
||||
}
|
||||
|
||||
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
if (!this.caps.has('lightningReceive')) return this.notSupported('lightning receive');
|
||||
// No NIP-47 wallet accepts a caller-supplied preimage or an AMP invoice, and `private` has no
|
||||
// equivalent — the wallet picks its own route hints.
|
||||
if (req.preimage) return this.notSupported('custom preimages');
|
||||
if (req.isAmp) return this.notSupported('AMP invoices');
|
||||
if (!req.amountMsat || req.amountMsat === '0') {
|
||||
throw new BackendError('nwc requires an invoice amount', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
const amount = toMsatNumber(req.amountMsat, 'amountMsat');
|
||||
|
||||
const tx = await this.run('make_invoice', (client) =>
|
||||
client.makeInvoice({ amount, description: req.memo, expiry: req.expirySeconds }),
|
||||
);
|
||||
return toInvoice(tx);
|
||||
}
|
||||
|
||||
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
if (!this.methods.size || this.methods.has('lookup_invoice')) {
|
||||
const tx = await this.lookup({ payment_hash: paymentHash });
|
||||
return tx ? toInvoice(tx) : null;
|
||||
}
|
||||
return this.notSupported('invoice lookup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely local — no relay round-trip, and it works while the wallet is unreachable. The invoice is
|
||||
* self-describing, so there is nothing the wallet service could add beyond what the bytes already say.
|
||||
*/
|
||||
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
|
||||
return decodeBolt11(bolt11);
|
||||
}
|
||||
|
||||
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
||||
const res = await this.run('list_transactions', (client) =>
|
||||
client.listTransactions({ type: 'outgoing', limit: opts?.limit, unpaid_outgoing: true }),
|
||||
);
|
||||
return (res.transactions ?? []).filter((tx) => tx.type !== 'incoming').map(toPayment);
|
||||
}
|
||||
|
||||
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
|
||||
if (!this.caps.has('lightningSend')) return this.notSupported('lightning send');
|
||||
// Fee limits and timeouts are the wallet's own budget policy; NIP-47 has no field for either.
|
||||
const amount = req.amountMsat ? toMsatNumber(req.amountMsat, 'amountMsat') : undefined;
|
||||
const res = await this.run('pay_invoice', (client) => client.payInvoice({ invoice: req.bolt11, amount }));
|
||||
|
||||
const paymentHash = hashFromPreimage(res.preimage);
|
||||
// pay_invoice returns only {preimage, fees_paid}. Recover the rest from the wallet's own record
|
||||
// when it keeps one — best effort, never fatal, since the payment has already settled.
|
||||
const record = paymentHash ? await this.lookupQuietly({ payment_hash: paymentHash }) : null;
|
||||
|
||||
return {
|
||||
paymentHash,
|
||||
preimage: res.preimage || null,
|
||||
amountMsat: record?.amount != null ? String(record.amount) : (req.amountMsat ?? '0'),
|
||||
feeMsat: String(res.fees_paid ?? record?.fees_paid ?? 0),
|
||||
// A NIP-47 pay_invoice that resolves has succeeded; a failure arrives as a Nip47WalletError.
|
||||
status: 'succeeded',
|
||||
createdAt: record?.created_at ?? now(),
|
||||
destination: null,
|
||||
memo: record?.description || null,
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
override async sendKeysend(req: KeysendRequest): Promise<Payment> {
|
||||
if (!this.caps.has('keysend')) return this.notSupported('keysend');
|
||||
const amount = toMsatNumber(req.amountMsat, 'amountMsat');
|
||||
// NIP-47 carries TLV values as hex.
|
||||
const tlvRecords = req.message
|
||||
? [{ type: KEYSEND_MESSAGE_TLV, value: Buffer.from(req.message, 'utf8').toString('hex') }]
|
||||
: undefined;
|
||||
|
||||
const res = await this.run('pay_keysend', (client) =>
|
||||
client.payKeysend({ pubkey: req.destination, amount, tlv_records: tlvRecords }),
|
||||
);
|
||||
return {
|
||||
paymentHash: hashFromPreimage(res.preimage),
|
||||
preimage: res.preimage || null,
|
||||
amountMsat: req.amountMsat,
|
||||
feeMsat: String(res.fees_paid ?? 0),
|
||||
status: 'succeeded',
|
||||
createdAt: now(),
|
||||
destination: req.destination,
|
||||
memo: req.message ?? null,
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── shared ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async lookup(request: { payment_hash?: string; invoice?: string }): Promise<NwcTransaction | null> {
|
||||
try {
|
||||
return await this.run('lookup_invoice', (client) => client.lookupInvoice(request));
|
||||
} catch (err) {
|
||||
// A wallet that has never seen the hash answers NOT_FOUND; that is an absence, not a failure.
|
||||
if (err instanceof BackendError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async lookupQuietly(request: { payment_hash?: string; invoice?: string }): Promise<NwcTransaction | null> {
|
||||
if (this.methods.size && !this.methods.has('lookup_invoice')) return null;
|
||||
try {
|
||||
return await this.lookup(request);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,822 +0,0 @@
|
||||
// The native on-chain wallet backend.
|
||||
//
|
||||
// This is the server-side replacement for Zeus's two embedded backends. EmbeddedLND
|
||||
// (backends/EmbeddedLND.ts) and LdkNode (backends/LdkNode.ts) are thin JS shims over React Native
|
||||
// native modules — `lndmobile`, `ldk-node-rn` — that bundle an actual node into the app process.
|
||||
// Neither exists off a phone, so neither can be ported into a Bun sidecar. Instead this backend is a
|
||||
// real wallet in its own right: it derives addresses from a BIP32 account xpub, reads the chain through
|
||||
// an injected WalletChainSource (chain-source.ts), and builds/signs its own transactions with
|
||||
// bitcoinjs-lib (psbt.ts).
|
||||
//
|
||||
// It owns the KEYS and the MONEY: derivation, the snapshot cache, coin selection, PSBT construction,
|
||||
// signing. It owns no HTTP at all. Which indexer answers — a public Esplora, the owner's own node behind
|
||||
// NBXplorer — is a constructor argument, and nothing below this line knows the difference.
|
||||
//
|
||||
// WATCH-ONLY WHILE LOCKED is the load-bearing design property. Everything a UI polls —
|
||||
// getInfo / getBalances / getTransactions / getNewAddress / getUtxos / estimateFees — is derived from
|
||||
// the account XPUB and public chain data, so it works with the wallet locked and no key material in
|
||||
// memory. `signer.withRoot` is reached from exactly two methods, sendCoins and signMessage, and both
|
||||
// fail fast with WalletLockedError before doing any work. Nothing else in this file touches the signer.
|
||||
//
|
||||
// There is no lightning here at all: the capability set omits every lightning flag, so invoices,
|
||||
// payments, channels and peers all fall through to BaseBackend's 501.
|
||||
|
||||
import type { WalletChainSnapshot } from 'officerdb';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
import {
|
||||
mapLimit,
|
||||
MAX_SCAN_INDEX,
|
||||
REQUEST_CONCURRENCY,
|
||||
type AddressEntry,
|
||||
type ChainIndex,
|
||||
type ScanContext,
|
||||
type ScannedAddress,
|
||||
type WalletChainSource,
|
||||
} from '../chain-source';
|
||||
import {
|
||||
buildPsbt,
|
||||
coinTypeFor,
|
||||
initEcc,
|
||||
networkFor,
|
||||
outputScriptFor,
|
||||
scriptType,
|
||||
selectCoins,
|
||||
signAndFinalize,
|
||||
type PsbtInputSource,
|
||||
type PsbtOutputSpec,
|
||||
type SpendableUtxo,
|
||||
} from '../psbt';
|
||||
import {
|
||||
BackendError,
|
||||
WalletLockedError,
|
||||
type AddressType,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type FeeEstimates,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type RescanOptions,
|
||||
type RescanState,
|
||||
type SendCoinsRequest,
|
||||
type SendCoinsResult,
|
||||
type SignMessageResult,
|
||||
type SyncState,
|
||||
type Utxo,
|
||||
type VerifyMessageResult,
|
||||
} from '../types';
|
||||
import { BaseBackend } from './base';
|
||||
|
||||
// ── the signer boundary ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The wallet's key custodian, implemented by keys.ts and injected here.
|
||||
*
|
||||
* This backend never sees a seed, a mnemonic, a passphrase or a file path. It receives a root HDKey for
|
||||
* the duration of one synchronous callback and nothing more, which is what keeps the watch-only path
|
||||
* genuinely key-free rather than key-free by convention.
|
||||
*
|
||||
* `withRoot` MUST throw `WalletLockedError` when the wallet is locked, and MUST NOT be async — an async
|
||||
* borrow would pin the root in memory across arbitrary awaits.
|
||||
*/
|
||||
export interface WalletSigner {
|
||||
isUnlocked(): boolean;
|
||||
withRoot<T>(fn: (root: HDKey) => T): T;
|
||||
}
|
||||
|
||||
// ── the persistence boundary ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Durable storage for the last snapshot this backend read off the chain. Injected the same way the
|
||||
* signer is, and for the same reason: this file stays a pure wallet with no idea which database it is
|
||||
* attached to, or that it has a wallet id at all. resolve.ts binds the real implementation.
|
||||
*
|
||||
* Every method is best-effort by contract. A cache that is unreachable, corrupt or empty must degrade
|
||||
* to today's behaviour — a slow first load — and must never fail a wallet request.
|
||||
*/
|
||||
export interface ChainCacheStore {
|
||||
load(): Promise<{ snapshot: WalletChainSnapshot | null; syncedAt: Date | null; lastError: string | null } | null>;
|
||||
save(snapshot: WalletChainSnapshot): Promise<void>;
|
||||
recordError(message: string): Promise<void>;
|
||||
}
|
||||
|
||||
// ── extended key parsing ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* SLIP-132 version bytes. @scure/bip32 refuses an extended key whose version does not match the
|
||||
* `versions` it was handed, so the prefix has to be recognised before the key can be imported. Wallets
|
||||
* export the purpose-tagged forms (zpub for BIP84, ypub for BIP49, vpub/upub on testnet) as often as
|
||||
* they export a plain xpub, and all of them are the same key with different four leading bytes.
|
||||
*/
|
||||
const XPUB_VERSIONS: Record<string, { public: number; private: number }> = {
|
||||
xpub: { public: 0x0488b21e, private: 0x0488ade4 },
|
||||
ypub: { public: 0x049d7cb2, private: 0x049d7878 },
|
||||
zpub: { public: 0x04b24746, private: 0x04b2430c },
|
||||
tpub: { public: 0x043587cf, private: 0x04358394 },
|
||||
upub: { public: 0x044a5262, private: 0x044a4e28 },
|
||||
vpub: { public: 0x045f1cf6, private: 0x045f18bc },
|
||||
};
|
||||
|
||||
function parseAccountXpub(key: string): HDKey {
|
||||
const prefix = key.slice(0, 4).toLowerCase();
|
||||
const versions = XPUB_VERSIONS[prefix];
|
||||
if (!versions) throw new BackendError(`unrecognised extended key prefix '${prefix}'`, 400, 'BAD_XPUB');
|
||||
try {
|
||||
return HDKey.fromExtendedKey(key, versions);
|
||||
} catch (err) {
|
||||
throw new BackendError(`could not parse the account extended key: ${String(err)}`, 400, 'BAD_XPUB');
|
||||
}
|
||||
}
|
||||
|
||||
// ── derivation ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** BIP44 purpose per script type: 44' legacy, 49' wrapped segwit, 84' native segwit, 86' taproot. */
|
||||
const PURPOSE: Record<AddressType, number> = {
|
||||
p2pkh: 44,
|
||||
'p2sh-p2wpkh': 49,
|
||||
p2wpkh: 84,
|
||||
p2tr: 86,
|
||||
};
|
||||
|
||||
/** Preference order when the caller does not name a script type. Native segwit first. */
|
||||
const TYPE_PREFERENCE: readonly AddressType[] = ['p2wpkh', 'p2tr', 'p2sh-p2wpkh', 'p2pkh'];
|
||||
|
||||
type Account = {
|
||||
type: AddressType;
|
||||
node: HDKey;
|
||||
/** The extended key exactly as it was given. Wallet-level chain sources index by this string. */
|
||||
accountXpub: string;
|
||||
/** Account-level path from the wallet root, e.g. `m/84'/0'/0'`. */
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One coherent read of the chain: the address scan and everything derived from it, taken together.
|
||||
*
|
||||
* Balances, UTXOs and history used to be fetched by three separate endpoints on top of a shared scan,
|
||||
* which meant the three queries a wallet screen fires on mount could each observe a different moment.
|
||||
* Building them as one snapshot costs the same requests — collectUtxos only asks about funded addresses
|
||||
* and collectTxs only about touched ones — and makes the whole thing a single unit to cache, serve stale
|
||||
* and persist.
|
||||
*/
|
||||
type Snapshot = {
|
||||
/** Unix ms the chain was read. Survives a restart: a restored snapshot carries its original time. */
|
||||
at: number;
|
||||
tipHeight: number;
|
||||
addresses: ScannedAddress[];
|
||||
utxos: SpendableUtxo[];
|
||||
txs: OnchainTx[];
|
||||
};
|
||||
|
||||
// ── tuning ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** How long a snapshot is served without kicking off a refresh behind it. */
|
||||
const SCAN_TTL_MS = 30_000;
|
||||
|
||||
const DEFAULT_TX_LIMIT = 100;
|
||||
|
||||
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OnchainBackendOptions = {
|
||||
chain: WalletChainSource;
|
||||
network: BitcoinNetwork;
|
||||
/**
|
||||
* The account-level extended public key. A bare string is taken as the BIP84 (p2wpkh) account; pass a
|
||||
* map to enable more than one script type, e.g. `{ p2wpkh: 'zpub…', p2tr: 'xpub…' }`.
|
||||
*/
|
||||
accountXpub: string | Partial<Record<AddressType, string>>;
|
||||
signer: WalletSigner;
|
||||
/** Omit for a backend that should hold its chain view in memory only — tests do. */
|
||||
cache?: ChainCacheStore;
|
||||
};
|
||||
|
||||
export class OnchainBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'onchain';
|
||||
|
||||
protected readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
|
||||
'onchainReceive',
|
||||
'onchainSend',
|
||||
'coinControl',
|
||||
'psbt',
|
||||
'sweep',
|
||||
'signMessage',
|
||||
]);
|
||||
|
||||
private readonly chain: WalletChainSource;
|
||||
private readonly network: BitcoinNetwork;
|
||||
private readonly btcNetwork: bitcoin.Network;
|
||||
private readonly signer: WalletSigner;
|
||||
private readonly accounts: Map<AddressType, Account>;
|
||||
private readonly defaultType: AddressType;
|
||||
|
||||
/** Derivation is pure EC math over a fixed xpub, so every address is derived at most once. */
|
||||
private readonly derived = new Map<string, AddressEntry>();
|
||||
|
||||
/** In-memory issuance high-water mark per `type:chain`, so consecutive getNewAddress calls advance.
|
||||
* Deliberately not persisted: after a restart the first unused address is recomputed from the chain,
|
||||
* which is correct as soon as an issued address has actually been paid. */
|
||||
private readonly issued = new Map<string, number>();
|
||||
|
||||
private readonly cache: ChainCacheStore | null;
|
||||
|
||||
private current: Snapshot | null = null;
|
||||
private inflight: Promise<Snapshot> | null = null;
|
||||
/** Memoised so concurrent first reads do one cache load between them, not one each. */
|
||||
private hydration: Promise<void> | null = null;
|
||||
private lastError: string | null = null;
|
||||
|
||||
constructor(opts: OnchainBackendOptions) {
|
||||
super();
|
||||
initEcc();
|
||||
this.chain = opts.chain;
|
||||
this.network = opts.network;
|
||||
this.btcNetwork = networkFor(opts.network);
|
||||
this.signer = opts.signer;
|
||||
this.cache = opts.cache ?? null;
|
||||
|
||||
const coin = coinTypeFor(opts.network);
|
||||
const raw = typeof opts.accountXpub === 'string' ? { p2wpkh: opts.accountXpub } : opts.accountXpub;
|
||||
|
||||
this.accounts = new Map();
|
||||
for (const type of TYPE_PREFERENCE) {
|
||||
const xpub = raw[type];
|
||||
if (!xpub) continue;
|
||||
this.accounts.set(type, {
|
||||
type,
|
||||
node: parseAccountXpub(xpub),
|
||||
accountXpub: xpub,
|
||||
basePath: `m/${PURPOSE[type]}'/${coin}'/0'`,
|
||||
});
|
||||
}
|
||||
|
||||
const first = TYPE_PREFERENCE.find((t) => this.accounts.has(t));
|
||||
if (!first) throw new BackendError('the on-chain backend needs at least one account xpub', 400, 'BAD_XPUB');
|
||||
this.defaultType = first;
|
||||
}
|
||||
|
||||
// ── derivation helpers ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private accountFor(type: AddressType): Account {
|
||||
const account = this.accounts.get(type);
|
||||
if (!account) {
|
||||
const have = [...this.accounts.keys()].join(', ') || 'none';
|
||||
throw new BackendError(`no ${type} account is configured (have: ${have})`, 400, 'NO_ACCOUNT');
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private derive(type: AddressType, chain: ChainIndex, index: number): AddressEntry {
|
||||
const cacheKey = `${type}:${chain}:${index}`;
|
||||
const hit = this.derived.get(cacheKey);
|
||||
if (hit) return hit;
|
||||
|
||||
const account = this.accountFor(type);
|
||||
const node = account.node.derive(`m/${chain}/${index}`);
|
||||
const pub = node.publicKey;
|
||||
if (!pub) throw new BackendError(`failed to derive ${type} ${chain}/${index}`, 500);
|
||||
const pubkey = Buffer.from(pub);
|
||||
const network = this.btcNetwork;
|
||||
|
||||
const payment =
|
||||
type === 'p2wpkh'
|
||||
? bitcoin.payments.p2wpkh({ pubkey, network })
|
||||
: type === 'p2tr'
|
||||
? bitcoin.payments.p2tr({ internalPubkey: toXOnly(pubkey), network })
|
||||
: type === 'p2sh-p2wpkh'
|
||||
? bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey, network }), network })
|
||||
: bitcoin.payments.p2pkh({ pubkey, network });
|
||||
|
||||
if (!payment.address || !payment.output) throw new BackendError(`failed to build a ${type} address`, 500);
|
||||
|
||||
const entry: AddressEntry = {
|
||||
type,
|
||||
chain,
|
||||
index,
|
||||
address: payment.address,
|
||||
pubkeyHex: pubkey.toString('hex'),
|
||||
scriptPubKeyHex: payment.output.toString('hex'),
|
||||
path: `${account.basePath}/${chain}/${index}`,
|
||||
relPath: `${chain}/${index}`,
|
||||
};
|
||||
this.derived.set(cacheKey, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ── the snapshot: cache, refresh, persist ──────────────────────────────────────────────────────
|
||||
|
||||
getSyncState(): SyncState {
|
||||
return {
|
||||
syncedAt: this.current?.at ?? null,
|
||||
stale: this.current !== null && Date.now() - this.current.at >= SCAN_TTL_MS,
|
||||
lastError: this.lastError,
|
||||
canRescan: this.chain.startRescan !== undefined,
|
||||
rescan: this.chain.rescanState?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the chain source to go looking for this wallet's coins from scratch.
|
||||
*
|
||||
* Returns as soon as the search is queued — it takes minutes, and the caller is an HTTP route. The
|
||||
* refresh chained onto its completion is the part that matters: a rescan that found coins has changed
|
||||
* nothing visible until the snapshot in front of it is rebuilt, and without this the owner would be
|
||||
* staring at the same zero until the TTL happened to expire.
|
||||
*/
|
||||
async startRescan(opts: RescanOptions = {}): Promise<RescanState> {
|
||||
const start = this.chain.startRescan?.bind(this.chain);
|
||||
if (!start) this.notSupported('rescanning the chain');
|
||||
const { state, done } = start(this.scanContext().accounts, opts);
|
||||
void done
|
||||
.then(() => this.refresh())
|
||||
// The rescan's own failure is already on its state and reported through getSyncState; a failed
|
||||
// refresh behind it lands on lastError the same way. Neither should surface as an unhandled
|
||||
// rejection in the sidecar's log.
|
||||
.catch(() => {});
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach to a rescan that outlived this process, and put the refresh back on the end of it.
|
||||
*
|
||||
* Read-only with respect to the upstream: it never starts a scan, so calling it when nothing is running
|
||||
* costs one request per account and answers null.
|
||||
*/
|
||||
async adoptRescan(): Promise<RescanState | null> {
|
||||
const adopt = this.chain.adoptRescan?.bind(this.chain);
|
||||
if (!adopt) return null;
|
||||
const handle = await adopt(this.scanContext().accounts);
|
||||
if (!handle) return null;
|
||||
// The same chaining startRescan does, and for the same reason: coins recovered by a scan are invisible
|
||||
// until the snapshot in front of them is rebuilt.
|
||||
void handle.done.then(() => this.refresh()).catch(() => {});
|
||||
return handle.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wallet's view of the chain, stale-while-revalidate.
|
||||
*
|
||||
* A snapshot within SCAN_TTL_MS is served as-is. An older one is served IMMEDIATELY with a refresh
|
||||
* started behind it — that is the property the persistence exists for: after a restart, or while the
|
||||
* Esplora upstream is slow or unreachable, the screen shows the last real numbers with their age
|
||||
* rather than a blank wallet. Only a wallet that has genuinely never been read blocks on the network,
|
||||
* and only that case can surface an error to the caller.
|
||||
*
|
||||
* `fresh` opts out for the two paths where stale data would be wrong rather than merely old.
|
||||
*/
|
||||
private async snapshot(opts?: { fresh?: boolean }): Promise<Snapshot> {
|
||||
if (opts?.fresh) return this.refresh();
|
||||
|
||||
await this.hydrate();
|
||||
|
||||
const have = this.current;
|
||||
if (have && Date.now() - have.at < SCAN_TTL_MS) return have;
|
||||
|
||||
const running = this.refresh();
|
||||
if (!have) return running;
|
||||
|
||||
// The refresh outlives this response. Its failure is recorded on `lastError` and reported through
|
||||
// getSyncState, so it is handled here only to keep it from surfacing as an unhandled rejection.
|
||||
void running.catch(() => {});
|
||||
return have;
|
||||
}
|
||||
|
||||
/** Load the persisted snapshot into memory. Runs at most once, and never throws. */
|
||||
private hydrate(): Promise<void> {
|
||||
this.hydration ??= (async () => {
|
||||
const store = this.cache;
|
||||
if (!store || this.current) return;
|
||||
try {
|
||||
const row = await store.load();
|
||||
if (!row) return;
|
||||
this.lastError = row.lastError;
|
||||
// A refresh may have completed while this load was in flight; live data always wins. A snapshot
|
||||
// written by an older shape is ignored, not migrated — it is a cache, and the next refresh
|
||||
// replaces it for free.
|
||||
if (row.snapshot?.v === 1 && row.syncedAt && !this.current) {
|
||||
this.current = this.fromPersisted(row.snapshot, row.syncedAt.getTime());
|
||||
}
|
||||
} catch (err) {
|
||||
// A cache failure costs a slow load, nothing more. It must not become a wallet failure.
|
||||
console.error('[wallet] chain cache load failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
})();
|
||||
return this.hydration;
|
||||
}
|
||||
|
||||
/** Read the chain. Concurrent callers share one walk rather than each starting their own. */
|
||||
private refresh(): Promise<Snapshot> {
|
||||
this.inflight ??= this.buildSnapshot().then(
|
||||
(snap) => {
|
||||
this.inflight = null;
|
||||
this.current = snap;
|
||||
this.lastError = null;
|
||||
void this.persist(snap);
|
||||
return snap;
|
||||
},
|
||||
(err: unknown) => {
|
||||
this.inflight = null;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.lastError = message;
|
||||
void this.cache?.recordError(message).catch((e: unknown) => {
|
||||
console.error('[wallet] chain cache error write failed:', e instanceof Error ? e.message : e);
|
||||
});
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
private async persist(snap: Snapshot): Promise<void> {
|
||||
if (!this.cache) return;
|
||||
try {
|
||||
await this.cache.save(toPersisted(snap));
|
||||
} catch (err) {
|
||||
console.error('[wallet] chain cache write failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a snapshot from what was stored. Only the chain's own facts were persisted; every address,
|
||||
* script and pubkey is re-derived here from the account xpub, so a restored snapshot cannot disagree
|
||||
* with the wallet's actual keys. An entry naming a script type this wallet no longer has an xpub for
|
||||
* is dropped rather than faked.
|
||||
*/
|
||||
private fromPersisted(stored: WalletChainSnapshot, at: number): Snapshot {
|
||||
const addresses: ScannedAddress[] = [];
|
||||
for (const a of stored.addresses) {
|
||||
const entry = this.tryDerive(a.type, a.chain, a.index);
|
||||
if (!entry) continue;
|
||||
addresses.push({
|
||||
...entry,
|
||||
confirmedSats: a.confirmed,
|
||||
unconfirmedSats: a.unconfirmed,
|
||||
txCount: a.txCount,
|
||||
used: a.txCount > 0,
|
||||
});
|
||||
}
|
||||
|
||||
const utxos: SpendableUtxo[] = [];
|
||||
for (const u of stored.utxos) {
|
||||
const entry = this.tryDerive(u.type, u.chain, u.index);
|
||||
if (!entry) continue;
|
||||
utxos.push({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.value,
|
||||
address: entry.address,
|
||||
addressType: entry.type,
|
||||
confirmations: u.blockHeight === null ? 0 : Math.max(0, stored.tipHeight - u.blockHeight + 1),
|
||||
derivationPath: entry.path,
|
||||
frozen: false,
|
||||
scriptPubKeyHex: entry.scriptPubKeyHex,
|
||||
pubkeyHex: entry.pubkeyHex,
|
||||
});
|
||||
}
|
||||
|
||||
return { at, tipHeight: stored.tipHeight, addresses, utxos, txs: stored.transactions };
|
||||
}
|
||||
|
||||
/** Derive from untrusted stored coordinates — returns null rather than throwing on anything unusable. */
|
||||
private tryDerive(type: string, chain: number, index: number): AddressEntry | null {
|
||||
if (!isAddressType(type) || (chain !== 0 && chain !== 1)) return null;
|
||||
if (!Number.isInteger(index) || index < 0 || index >= MAX_SCAN_INDEX) return null;
|
||||
if (!this.accounts.has(type)) return null;
|
||||
try {
|
||||
return this.derive(type, chain, index);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the source is allowed to know: the account keys, which type is ours, and how to derive.
|
||||
*
|
||||
* `derive` is bound to this backend on purpose — a source that derived addresses itself could disagree
|
||||
* with the wallet about which addresses are the wallet's, and the resulting bug would lose coins rather
|
||||
* than fail loudly.
|
||||
*/
|
||||
private scanContext(): ScanContext {
|
||||
return {
|
||||
accounts: [...this.accounts.values()].map((a) => ({ type: a.type, accountXpub: a.accountXpub })),
|
||||
defaultType: this.defaultType,
|
||||
derive: (type, chain, index) => this.derive(type, chain, index),
|
||||
};
|
||||
}
|
||||
|
||||
/** One full read of the chain, stamped with the moment it was taken. */
|
||||
private async buildSnapshot(): Promise<Snapshot> {
|
||||
const scan = await this.chain.scan(this.scanContext());
|
||||
return { at: Date.now(), ...scan };
|
||||
}
|
||||
|
||||
/** Drop everything after a send, so the spent coins cannot reappear from cache on the next read. */
|
||||
private invalidateScan(): void {
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
// ── watch-only reads (no key material required) ────────────────────────────────────────────────
|
||||
|
||||
async getInfo(): Promise<NodeInfo> {
|
||||
// Deliberately does not force a scan: this is the cheapest liveness probe the wallet has.
|
||||
const blockHeight = await this.chain.getTipHeight();
|
||||
return {
|
||||
kind: 'onchain',
|
||||
// An Esplora-backed wallet has no node identity, no alias and no upstream version to report.
|
||||
pubkey: null,
|
||||
alias: null,
|
||||
version: null,
|
||||
network: this.network,
|
||||
blockHeight,
|
||||
synced: true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const snap = await this.snapshot();
|
||||
let confirmed = 0;
|
||||
let unconfirmed = 0;
|
||||
for (const entry of snap.addresses) {
|
||||
confirmed += entry.confirmedSats;
|
||||
unconfirmed += entry.unconfirmedSats;
|
||||
}
|
||||
return {
|
||||
onchainConfirmed: confirmed,
|
||||
// Filled in by the route, which is the only layer that knows what the owner froze.
|
||||
onchainFrozen: 0,
|
||||
onchainUnconfirmed: unconfirmed,
|
||||
// No channels exist, and null is the contract's "this backend has no lightning" value.
|
||||
lightningBalance: null,
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
override async estimateFees(): Promise<FeeEstimates> {
|
||||
return this.chain.getFeeEstimates();
|
||||
}
|
||||
|
||||
override async getUtxos(): Promise<Utxo[]> {
|
||||
const snap = await this.snapshot();
|
||||
return snap.utxos.map((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.amountSats,
|
||||
address: u.address,
|
||||
addressType: u.addressType,
|
||||
confirmations: u.confirmations,
|
||||
// The public contract wants the path relative to the account xpub, not the absolute one the
|
||||
// signer uses, so it is recomputed from the tail of the full path.
|
||||
derivationPath: u.derivationPath.split('/').slice(-2).join('/'),
|
||||
// Freezing is a policy layer above this backend; nothing on-chain marks a coin frozen.
|
||||
frozen: false,
|
||||
}));
|
||||
}
|
||||
|
||||
override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
const type = req?.type ?? this.defaultType;
|
||||
this.accountFor(type);
|
||||
const entry = await this.nextUnused(type, 0, req?.peek === true);
|
||||
return { address: entry.address, type };
|
||||
}
|
||||
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
const snap = await this.snapshot();
|
||||
return snap.txs.slice(0, opts?.limit ?? DEFAULT_TX_LIMIT);
|
||||
}
|
||||
|
||||
/** History for every address the scan found had been touched. Only those can appear in it. */
|
||||
// ── spending (requires the root key) ───────────────────────────────────────────────────────────
|
||||
|
||||
override async sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult> {
|
||||
// Fail before any network work rather than after building a PSBT we cannot sign.
|
||||
if (!this.signer.isUnlocked()) throw new WalletLockedError();
|
||||
if (!Number.isFinite(req.satPerVbyte) || req.satPerVbyte <= 0) {
|
||||
throw new BackendError('satPerVbyte must be a positive number', 400, 'INVALID_FEE_RATE');
|
||||
}
|
||||
|
||||
const recipientScript = outputScriptFor(req.address, this.btcNetwork);
|
||||
const recipientType = scriptType(recipientScript);
|
||||
|
||||
// Never stale. Selecting coins from a cached UTXO set would build a transaction spending outputs
|
||||
// that may already be gone, and the failure would arrive as a broadcast rejection after signing.
|
||||
const snap = await this.snapshot({ fresh: true });
|
||||
|
||||
// The snapshot comes off the chain, which knows nothing about freezing, so every UTXO in it claims
|
||||
// `frozen: false`. selectCoins has always honoured the flag — nothing ever set it, which is what made
|
||||
// a plain send happily spend a coin the owner had frozen, and made "send everything" sweep it.
|
||||
const frozen = new Set(req.frozenOutpoints ?? []);
|
||||
const utxos = frozen.size
|
||||
? snap.utxos.map((u) => (frozen.has(`${u.txid}:${u.vout}`) ? { ...u, frozen: true } : u))
|
||||
: snap.utxos;
|
||||
|
||||
const selection = selectCoins({
|
||||
utxos,
|
||||
targetSats: req.amountSats ?? 0,
|
||||
sendAll: req.sendAll === true,
|
||||
satPerVbyte: req.satPerVbyte,
|
||||
recipientType,
|
||||
changeType: this.defaultType,
|
||||
outpoints: req.outpoints,
|
||||
spendUnconfirmed: req.spendUnconfirmed,
|
||||
});
|
||||
|
||||
// Legacy inputs commit to the whole previous transaction, so it has to be fetched; segwit inputs
|
||||
// carry their own value and script in the PSBT and need nothing extra.
|
||||
const inputs = await mapLimit<SpendableUtxo, PsbtInputSource>(selection.inputs, REQUEST_CONCURRENCY, async (u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.amountSats,
|
||||
addressType: u.addressType,
|
||||
scriptPubKeyHex: u.scriptPubKeyHex,
|
||||
pubkeyHex: u.pubkeyHex,
|
||||
derivationPath: u.derivationPath,
|
||||
prevTxHex: u.addressType === 'p2pkh' ? await this.chain.getTxHex(u.txid) : undefined,
|
||||
}));
|
||||
|
||||
const outputs: PsbtOutputSpec[] = [{ address: req.address, amountSats: selection.outputSats }];
|
||||
if (selection.changeSats !== null) {
|
||||
const change = await this.nextUnused(this.defaultType, 1, false);
|
||||
outputs.push({ address: change.address, amountSats: selection.changeSats });
|
||||
// Change-is-always-last is a well-known chain-analysis heuristic. One shuffle removes it.
|
||||
if (Math.random() < 0.5) outputs.reverse();
|
||||
}
|
||||
|
||||
const { psbt, inputPaths } = buildPsbt({
|
||||
network: this.btcNetwork,
|
||||
inputs,
|
||||
outputs,
|
||||
rbf: req.rbf,
|
||||
});
|
||||
|
||||
// The only place a root key enters this file. Synchronous, so the key is not held across an await.
|
||||
const signed = this.signer.withRoot((root) => signAndFinalize(psbt, root, inputPaths));
|
||||
|
||||
const txid = await this.chain.broadcast(signed.rawHex);
|
||||
this.invalidateScan();
|
||||
return { txid, feeSats: signed.feeSats, rawHex: signed.rawHex };
|
||||
}
|
||||
|
||||
override async signMessage(message: string): Promise<SignMessageResult> {
|
||||
if (!this.signer.isUnlocked()) throw new WalletLockedError();
|
||||
// BIP137-style: the identity key is the first receive key of the default account, which is what
|
||||
// every other wallet that signs with a derived key uses.
|
||||
const entry = this.derive(this.defaultType, 0, 0);
|
||||
const hash = bitcoinMessageHash(message, this.btcNetwork);
|
||||
|
||||
const signature = this.signer.withRoot((root) => {
|
||||
const node = root.derive(entry.path.replace(/[hH]/g, "'"));
|
||||
const priv = node.privateKey;
|
||||
if (!priv) throw new BackendError('derived node has no private key', 500);
|
||||
const { signature: sig, recoveryId } = ecc.signRecoverable(hash, priv);
|
||||
// Header byte: 27 + recovery id, +4 because the key is compressed.
|
||||
const header = Buffer.from([27 + recoveryId + 4]);
|
||||
return Buffer.concat([header, Buffer.from(sig)]).toString('base64');
|
||||
});
|
||||
|
||||
return { signature };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification needs no key material — only the xpub — so it stays available while locked.
|
||||
*
|
||||
* The contract takes no claimed address, and a recoverable signature *always* recovers some pubkey,
|
||||
* so "did this recover" is not a meaningful answer: a tampered message would still report valid.
|
||||
* `valid` therefore means "this wallet signed this message" — the recovered key is compared against
|
||||
* the identity key `signMessage` uses. The recovered pubkey is returned either way, so a caller
|
||||
* verifying a third party's signature can do its own comparison.
|
||||
*/
|
||||
override async verifyMessage(message: string, signature: string): Promise<VerifyMessageResult> {
|
||||
const sig = Buffer.from(signature, 'base64');
|
||||
if (sig.length !== 65) return { valid: false, pubkey: null };
|
||||
|
||||
const header = sig[0];
|
||||
if (header === undefined || header < 27 || header > 42) return { valid: false, pubkey: null };
|
||||
const recoveryId = ((header - 27) & 3) as 0 | 1 | 2 | 3;
|
||||
const compressed = ((header - 27) & 4) !== 0;
|
||||
const compact = sig.subarray(1);
|
||||
const hash = bitcoinMessageHash(message, this.btcNetwork);
|
||||
|
||||
try {
|
||||
const recovered = ecc.recover(hash, compact, recoveryId, compressed);
|
||||
if (!recovered || !ecc.verify(hash, recovered, compact)) return { valid: false, pubkey: null };
|
||||
const pubkey = Buffer.from(recovered).toString('hex');
|
||||
return { valid: pubkey === this.derive(this.defaultType, 0, 0).pubkeyHex, pubkey };
|
||||
} catch {
|
||||
return { valid: false, pubkey: null };
|
||||
}
|
||||
}
|
||||
|
||||
// ── shared internals ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch UTXOs for every scanned address that still holds a balance. */
|
||||
/**
|
||||
* First address on a chain that the blockchain has never seen, at or beyond the in-memory issuance
|
||||
* mark. `peek` reads without consuming; otherwise the mark advances so the next call hands out a
|
||||
* different address even before the current one is paid.
|
||||
*/
|
||||
private async nextUnused(type: AddressType, chain: ChainIndex, peek: boolean): Promise<AddressEntry> {
|
||||
// Deliberately never served from cache. Handing out an address whose stale record says "unused" but
|
||||
// which has been paid since is silent address reuse — a privacy leak the owner cannot see and cannot
|
||||
// undo. Failing loudly while the upstream is down is the better of the two, so this is the one read
|
||||
// that stops working offline, and on purpose.
|
||||
const snap = await this.snapshot({ fresh: true });
|
||||
const key = `${type}:${chain}`;
|
||||
const hint = this.issued.get(key) ?? 0;
|
||||
|
||||
const used = new Set<number>();
|
||||
let highest = -1;
|
||||
for (const entry of snap.addresses) {
|
||||
if (entry.type !== type || entry.chain !== chain) continue;
|
||||
highest = Math.max(highest, entry.index);
|
||||
if (entry.used) used.add(entry.index);
|
||||
}
|
||||
|
||||
let index = hint;
|
||||
// Anything past the scanned window is unused by definition — the gap limit is what ended the scan.
|
||||
while (index <= highest && used.has(index)) index++;
|
||||
|
||||
// ...but "unused" is not the same as "findable". The mark advances on every issuance, paid or not,
|
||||
// so twenty unpaid addresses walk it past the end of the window the next scan covers, and a payment
|
||||
// to the twenty-first would never be seen again. Rather than hand out an address the wallet could
|
||||
// lose money on, stop and re-offer the last safe one: every index in that run is still virgin, so
|
||||
// repeating one is not address reuse in the sense that matters — nothing was ever received on it.
|
||||
// The floor of 0 covers a wallet the chain has never seen: Esplora short-circuits an account with no
|
||||
// history and returns no addresses at all, leaving `highest` at -1, and index 0 is then the only one
|
||||
// it will look at. Handing that out until it is paid is exactly right.
|
||||
const ceiling = Math.max(0, highest + this.chain.issueAhead);
|
||||
if (index > ceiling) index = ceiling;
|
||||
|
||||
if (!peek) this.issued.set(key, index + 1);
|
||||
return this.derive(type, chain, index);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function isAddressType(value: string): value is AddressType {
|
||||
return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a snapshot to the chain facts worth storing. Addresses, scripts and pubkeys are dropped: they
|
||||
* are pure derivation from the account xpub, so re-deriving them on load is both cheaper than storing
|
||||
* them and the only way a restored snapshot is guaranteed to match the wallet's real keys.
|
||||
*/
|
||||
function toPersisted(snap: Snapshot): WalletChainSnapshot {
|
||||
return {
|
||||
v: 1,
|
||||
tipHeight: snap.tipHeight,
|
||||
addresses: snap.addresses.map((a) => ({
|
||||
type: a.type,
|
||||
chain: a.chain,
|
||||
index: a.index,
|
||||
confirmed: a.confirmedSats,
|
||||
unconfirmed: a.unconfirmedSats,
|
||||
txCount: a.txCount,
|
||||
})),
|
||||
utxos: snap.utxos.map((u) => {
|
||||
// The last two path elements are the BIP44 chain and address index — the same tail getUtxos
|
||||
// reports as the relative path.
|
||||
const tail = u.derivationPath.split('/');
|
||||
return {
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
value: u.amountSats,
|
||||
type: u.addressType,
|
||||
chain: Number(tail.at(-2)),
|
||||
index: Number(tail.at(-1)),
|
||||
// Stored as a height, not a count, so confirmations stay correct as the tip moves on.
|
||||
blockHeight: u.confirmations > 0 ? snap.tipHeight - u.confirmations + 1 : null,
|
||||
};
|
||||
}),
|
||||
transactions: snap.txs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Varint, for the length prefix in the Bitcoin signed-message preimage. */
|
||||
function varint(n: number): Buffer {
|
||||
if (n < 0xfd) return Buffer.from([n]);
|
||||
if (n <= 0xffff) {
|
||||
const b = Buffer.alloc(3);
|
||||
b[0] = 0xfd;
|
||||
b.writeUInt16LE(n, 1);
|
||||
return b;
|
||||
}
|
||||
const b = Buffer.alloc(5);
|
||||
b[0] = 0xfe;
|
||||
b.writeUInt32LE(n, 1);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** sha256d(messagePrefix || varint(len) || message) — the standard signed-message preimage. */
|
||||
function bitcoinMessageHash(message: string, network: bitcoin.Network): Buffer {
|
||||
const prefix = Buffer.isBuffer(network.messagePrefix)
|
||||
? network.messagePrefix
|
||||
: Buffer.from(network.messagePrefix, 'utf8');
|
||||
const body = Buffer.from(message, 'utf8');
|
||||
return bitcoin.crypto.hash256(Buffer.concat([prefix, varint(body.length), body]));
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
// Vectors: the BOLT 11 spec examples (lightning/bolts, 11-payment-encoding.md § Examples), plus the
|
||||
// regtest and signet invoices Zeus uses in its own utils/Bolt11Utils.test.ts. Every spec invoice below is
|
||||
// signed with priv_key e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734.
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { clearBolt11Cache, decodeBolt11, decodeBolt11Invoice } from './bolt11';
|
||||
import { BackendError } from './types';
|
||||
|
||||
// ── spec vectors ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** no amount, description "Please consider supporting this project" */
|
||||
const NO_AMOUNT =
|
||||
'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql';
|
||||
|
||||
/** 2500u, description "1 cup coffee", expiry 60 */
|
||||
const COFFEE_250U =
|
||||
'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh';
|
||||
|
||||
/** 2500u, UTF-8 description, expiry 60 */
|
||||
const NONSENSE_250U =
|
||||
'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr';
|
||||
|
||||
/** 20m, description_hash instead of a description */
|
||||
const HASHED_20M =
|
||||
'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44';
|
||||
|
||||
/** the same on testnet, with a P2PKH fallback address */
|
||||
const TESTNET_20M_FALLBACK =
|
||||
'lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8';
|
||||
|
||||
/** 20m with a fallback address and a two-hop route hint */
|
||||
const MAINNET_20M_ROUTES =
|
||||
'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0';
|
||||
|
||||
/** 9678785340p — a msat amount that is not a whole number of sats */
|
||||
const PICO_9678785340P =
|
||||
'lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz';
|
||||
|
||||
/** 25m advertising features 8, 14 and 99 */
|
||||
const FEATURES_25M =
|
||||
'lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa';
|
||||
|
||||
/** the same invoice in the all-uppercase QR form */
|
||||
const FEATURES_25M_UPPER =
|
||||
'LNBC25M1PVJLUEZPP5QQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQYPQDQ5VDHKVEN9V5SXYETPDEESSP5ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYGS9Q5SQQQQQQQQQQQQQQQQSGQ2A25DXL5HRNTDTN6ZVYDT7D66HYZSYHQS4WDYNAVYS42XGL6SGX9C4G7ME86A27T07MDTFRY458RTJR0V92CNMSWPSJSCGT2VCSE3SGPZ3UAPA';
|
||||
|
||||
/** 10m carrying payment metadata 0x01fafaf0 */
|
||||
const METADATA_10M =
|
||||
'lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc';
|
||||
|
||||
/** a valid invoice whose signature is high-S */
|
||||
const HIGH_S_SIGNATURE =
|
||||
'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap2r09nt4ndd0unm3z9u5t48y6ucv4r5sg7lk98c77ctvjczkspk5qprc90gx';
|
||||
|
||||
/** a high-S signature that does not match the invoice's own `n` payee field */
|
||||
const PAYEE_MISMATCH =
|
||||
'lnbc25m1p70xwfzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaqnp4q0n326hr8v9zprg8gsvezcch06gfaqqhde2aj730yg0durunfhv66sp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsp5cfzp9ugllvk03rltd6hvndxj26ux6gcxc5azyxk060rj9tzghct5zvjlps76gx8wpq5yuu79688k8gnm2c0al6v608s96l0xzrrlqqwnzxmu';
|
||||
|
||||
/** bech32 checksum is invalid */
|
||||
const BAD_CHECKSUM =
|
||||
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt';
|
||||
|
||||
/** signature is not recoverable */
|
||||
const UNRECOVERABLE_SIGNATURE =
|
||||
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqwgt7mcn5yqw3yx0w94pswkpq6j9uh6xfqqqtsk4tnarugeektd4hg5975x9am52rz4qskukxdmjemg92vvqz8nvmsye63r5ykel43pgz7zq0g2';
|
||||
|
||||
/** string is too short */
|
||||
const TOO_SHORT =
|
||||
'lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh';
|
||||
|
||||
/** invalid amount multiplier */
|
||||
const BAD_MULTIPLIER =
|
||||
'lnbc2500x1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqrrzc4cvfue4zp3hggxp47ag7xnrlr8vgcmkjxk3j5jqethnumgkpqp23z9jclu3v0a7e0aruz366e9wqdykw6dxhdzcjjhldxq0w6wgqcnu43j';
|
||||
|
||||
/** invalid sub-millisatoshi precision */
|
||||
const SUB_MSAT_PRECISION =
|
||||
'lnbc2500000001p1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uv0m3umg28gclm5lqxtqqwk32uuk4k6673k6n5kfvx3d2h8s295fad45fdhmusm8sjudfhlf6dcsxmfvkeywmjdkxcp99202x';
|
||||
|
||||
// ── Zeus fixtures (utils/Bolt11Utils.test.ts) ────────────────────────────────────────────────────
|
||||
|
||||
/** regtest, 1230n = 123 sat, no expiry field */
|
||||
const REGTEST_1230N =
|
||||
'lnbcrt1230n1pj429x7pp57t97q4awqj3f529snr0pa6senk83sq5pp760qf5a4jzvd7xgwcksdqqcqzzsxqrrsssp57eqtv7vxr46arupna3w4ct0lkf2mqmz9wt044cwkks0rwlnhfr5s9qyyssqragwpwav7nfwv2xyuuamxxj4pnnpzv2hlw7j473repd3sq7st698ta9kmzmygt0w7tmncl56a6mnma0w7e5dlpqd0wy6x3v35rssldspjhh8p0';
|
||||
|
||||
/** signet, 567780n = 56778 sat */
|
||||
const SIGNET_567780N =
|
||||
'lntbs567780n1pnqr26ypp5c0wcrpzwxwqnwu2nld5q36dfc9yjrfdp87nn9d5y093jjncvqresdq0w3jhxar8v3n8xeccqzpuxqrrsssp5r94e3nwnw63gjaxc8wex38ufv2m6442vnrw49m7dad9jdum3tdsq9qyyssqk4dvvuk7zhhju8ztf7nfc2hzqq9gqtzuyc0ljz8nl93laxwv4869lt9fsxkxacje6eh4ur5ymg83hvakn4tfpzdu6fq49705sar7fxspga8qjp';
|
||||
|
||||
/** Every spec vector shares this payee, payment hash and timestamp. */
|
||||
const SPEC_PAYEE = '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad';
|
||||
const SPEC_PAYMENT_HASH = '0001020304050607080900010203040506070809000102030405060708090102';
|
||||
const SPEC_TIMESTAMP = 1496314658;
|
||||
// ── amounts ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 amounts', () => {
|
||||
it('returns null for a zero-amount invoice', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).amountMsat).toBeNull();
|
||||
});
|
||||
|
||||
it('parses every multiplier as an exact msat string', () => {
|
||||
// 20m = 0.02 BTC, 2500u = 0.0025 BTC, 1230n = 123 sat, 9678785340p = 967878534 msat.
|
||||
expect(decodeBolt11(HASHED_20M).amountMsat).toBe('2000000000');
|
||||
expect(decodeBolt11(COFFEE_250U).amountMsat).toBe('250000000');
|
||||
expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000');
|
||||
expect(decodeBolt11(PICO_9678785340P).amountMsat).toBe('967878534');
|
||||
});
|
||||
|
||||
it('keeps amountMsat a string and never widens it to a number', () => {
|
||||
const amount = decodeBolt11(PICO_9678785340P).amountMsat;
|
||||
expect(typeof amount).toBe('string');
|
||||
// A pico amount need not be a whole sat: the msat string is exact where `satoshis` cannot be.
|
||||
expect(decodeBolt11Invoice(PICO_9678785340P).satoshis).toBeNull();
|
||||
expect(decodeBolt11Invoice(REGTEST_1230N).satoshis).toBe(123);
|
||||
});
|
||||
|
||||
it('rejects an unknown multiplier', () => {
|
||||
expect(() => decodeBolt11(BAD_MULTIPLIER)).toThrow(BackendError);
|
||||
});
|
||||
|
||||
it('rejects sub-millisatoshi precision instead of rounding it', () => {
|
||||
expect(() => decodeBolt11(SUB_MSAT_PRECISION)).toThrow(BackendError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fields ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 fields', () => {
|
||||
it('extracts the payment hash, destination and timestamp', () => {
|
||||
const decoded = decodeBolt11(NO_AMOUNT);
|
||||
expect(decoded.paymentHash).toBe(SPEC_PAYMENT_HASH);
|
||||
expect(decoded.destination).toBe(SPEC_PAYEE);
|
||||
expect(decoded.timestamp).toBe(SPEC_TIMESTAMP);
|
||||
});
|
||||
|
||||
it('recovers a key from a high-S signature instead of rejecting it', () => {
|
||||
// The spec's high-S vector is the donation invoice re-signed with s negated while keeping recovery
|
||||
// flag 1, so it recovers a *different* (valid) key. What matters is that recovery runs at all —
|
||||
// implementations that enforce low-S during recovery fail here.
|
||||
expect(decodeBolt11(HIGH_S_SIGNATURE).destination).toMatch(/^0[23][0-9a-f]{64}$/);
|
||||
expect(decodeBolt11(HIGH_S_SIGNATURE).destination).not.toBe(SPEC_PAYEE);
|
||||
expect(decodeBolt11(HIGH_S_SIGNATURE).paymentHash).toBe(SPEC_PAYMENT_HASH);
|
||||
});
|
||||
|
||||
it('returns the description for a `d` invoice and null for an `h` one', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).description).toBe('Please consider supporting this project');
|
||||
expect(decodeBolt11(COFFEE_250U).description).toBe('1 cup coffee');
|
||||
expect(decodeBolt11(NONSENSE_250U).description).toBe('ナンセンス 1杯');
|
||||
|
||||
const hashed = decodeBolt11Invoice(HASHED_20M);
|
||||
expect(decodeBolt11(HASHED_20M).description).toBeNull();
|
||||
expect(hashed.descriptionHash).toBe('3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1');
|
||||
});
|
||||
|
||||
it('defaults expiry to 3600 when there is no `x` field', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).expiry).toBe(3600);
|
||||
expect(decodeBolt11Invoice(NO_AMOUNT).expiry).toBeNull();
|
||||
expect(decodeBolt11(REGTEST_1230N).expiry).toBe(3600);
|
||||
});
|
||||
|
||||
it('reads an explicit expiry', () => {
|
||||
expect(decodeBolt11(COFFEE_250U).expiry).toBe(60);
|
||||
expect(decodeBolt11(PICO_9678785340P).expiry).toBe(604800);
|
||||
expect(decodeBolt11Invoice(COFFEE_250U).expiresAt).toBe(SPEC_TIMESTAMP + 60);
|
||||
});
|
||||
|
||||
it('expands the feature bitmap to the indices of the set bits', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).features).toEqual(['8', '14']);
|
||||
expect(decodeBolt11(FEATURES_25M).features).toEqual(['8', '14', '99']);
|
||||
});
|
||||
|
||||
it('reports route hints and parses their hops', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).routeHints).toBe(false);
|
||||
|
||||
const decoded = decodeBolt11Invoice(MAINNET_20M_ROUTES);
|
||||
expect(decodeBolt11(MAINNET_20M_ROUTES).routeHints).toBe(true);
|
||||
expect(decoded.routes).toHaveLength(1);
|
||||
expect(decoded.routes[0]).toEqual([
|
||||
{
|
||||
pubkey: '029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255',
|
||||
shortChannelId: '66051x263430x1800',
|
||||
feeBaseMsat: 1,
|
||||
feeProportionalMillionths: 20,
|
||||
cltvExpiryDelta: 3,
|
||||
},
|
||||
{
|
||||
pubkey: '039e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255',
|
||||
shortChannelId: '197637x395016x2314',
|
||||
feeBaseMsat: 2,
|
||||
feeProportionalMillionths: 30,
|
||||
cltvExpiryDelta: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses payment secret, metadata and fallback addresses', () => {
|
||||
expect(decodeBolt11Invoice(NO_AMOUNT).paymentSecret).toBe(
|
||||
'1111111111111111111111111111111111111111111111111111111111111111',
|
||||
);
|
||||
expect(decodeBolt11Invoice(METADATA_10M).metadata).toBe('01fafaf0');
|
||||
|
||||
const fallback = decodeBolt11Invoice(TESTNET_20M_FALLBACK).fallbacks[0];
|
||||
// 17 is the P2PKH marker; the program is the 20-byte hash160 of mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP.
|
||||
expect(fallback?.version).toBe(17);
|
||||
expect(fallback?.programHex).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('leaves cltvExpiry null when the invoice omits `c`', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).cltvExpiry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── networks ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 networks', () => {
|
||||
it('resolves every bech32 network prefix', () => {
|
||||
expect(decodeBolt11Invoice(NO_AMOUNT).network).toBe('bitcoin');
|
||||
expect(decodeBolt11Invoice(TESTNET_20M_FALLBACK).network).toBe('testnet');
|
||||
expect(decodeBolt11Invoice(SIGNET_567780N).network).toBe('signet');
|
||||
expect(decodeBolt11Invoice(REGTEST_1230N).network).toBe('regtest');
|
||||
});
|
||||
|
||||
it('parses the amount out of a multi-letter network prefix', () => {
|
||||
// `lnbcrt1230n` and `lntbs567780n` are the cases where a greedy hrp match eats the amount.
|
||||
expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000');
|
||||
expect(decodeBolt11(SIGNET_567780N).amountMsat).toBe('56778000');
|
||||
});
|
||||
|
||||
it('rejects an unknown network prefix', () => {
|
||||
expect(() => decodeBolt11(NO_AMOUNT.replace(/^lnbc/, 'lnxyz'))).toThrow(BackendError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── malformed input ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 rejections', () => {
|
||||
const expectBadInvoice = (invoice: string) => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
decodeBolt11(invoice);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(BackendError);
|
||||
expect((caught as BackendError).status).toBe(400);
|
||||
expect((caught as BackendError).code).toBe('BAD_INVOICE');
|
||||
};
|
||||
|
||||
it('throws BackendError(400, BAD_INVOICE) on a corrupted checksum', () => {
|
||||
expectBadInvoice(BAD_CHECKSUM);
|
||||
// The same invoice with one data character flipped.
|
||||
expectBadInvoice(`${COFFEE_250U.slice(0, -1)}${COFFEE_250U.endsWith('h') ? 'w' : 'h'}`);
|
||||
});
|
||||
|
||||
it('throws on a signature that cannot be recovered', () => {
|
||||
expectBadInvoice(UNRECOVERABLE_SIGNATURE);
|
||||
});
|
||||
|
||||
it('throws when the recovered key contradicts an explicit `n` payee field', () => {
|
||||
expectBadInvoice(PAYEE_MISMATCH);
|
||||
});
|
||||
|
||||
it('throws on a truncated payment request', () => {
|
||||
expectBadInvoice(TOO_SHORT);
|
||||
});
|
||||
|
||||
it('throws on input that is not a payment request at all', () => {
|
||||
expectBadInvoice('');
|
||||
expectBadInvoice('not an invoice');
|
||||
expectBadInvoice(NO_AMOUNT.replace(/^ln/, ''));
|
||||
});
|
||||
|
||||
it('throws on a mixed-case payment request but accepts the uppercase QR form', () => {
|
||||
expectBadInvoice(`LNBC${NO_AMOUNT.slice(4)}`);
|
||||
expect(decodeBolt11(FEATURES_25M_UPPER)).toEqual(decodeBolt11(FEATURES_25M));
|
||||
// The contract's `bolt11` is always the canonical lowercase form.
|
||||
expect(decodeBolt11(FEATURES_25M_UPPER).bolt11).toBe(FEATURES_25M);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cache ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('the LRU cache', () => {
|
||||
it('returns the same object for a repeat decode and survives a clear', () => {
|
||||
clearBolt11Cache();
|
||||
const first = decodeBolt11Invoice(COFFEE_250U);
|
||||
expect(decodeBolt11Invoice(COFFEE_250U)).toBe(first);
|
||||
expect(decodeBolt11Invoice(COFFEE_250U.toUpperCase())).toBe(first);
|
||||
|
||||
clearBolt11Cache();
|
||||
const afterClear = decodeBolt11Invoice(COFFEE_250U);
|
||||
expect(afterClear).not.toBe(first);
|
||||
expect(afterClear).toEqual(first);
|
||||
});
|
||||
|
||||
it('hands every decodeBolt11 caller its own object', () => {
|
||||
const first = decodeBolt11(COFFEE_250U);
|
||||
const second = decodeBolt11(COFFEE_250U);
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
@@ -1,516 +0,0 @@
|
||||
// BOLT11 invoice decoding, in-process — no node round-trip.
|
||||
//
|
||||
// Ported from Zeus's `utils/Bolt11Utils.ts`, which is itself derived from **light-bolt11-decoder**:
|
||||
// Copyright (c) 2021 bitcoinjs contributors, fiatjaf. MIT licence.
|
||||
// The bech32 walk, the human-readable-part amount grammar and the signature preimage construction are
|
||||
// that upstream's work and are preserved verbatim in behaviour.
|
||||
//
|
||||
// INTENTIONAL DIFFERENCES from Zeus's port:
|
||||
//
|
||||
// 1. ERRORS. Zeus mixes `throw new Error(...)` with silent `undefined` returns (a bad multiplier sets
|
||||
// `satoshis = null` and carries on; a `fromWordsUnsafe` failure yields `undefined`). Every failure
|
||||
// here is a `BackendError(msg, 400, 'BAD_INVOICE')`, so routes.ts answers 400 rather than leaking a
|
||||
// half-decoded invoice with null amounts into a payment flow.
|
||||
// 2. NO bignumber.js. All amount arithmetic is BigInt: msat tops out at 2.1e18, which a double cannot
|
||||
// hold, and BigInt does the same job exactly with one less dependency.
|
||||
// 3. NO @noble/hashes. The single sha256 comes from `node:crypto`, which Bun provides natively.
|
||||
// 4. EAGER signature recovery. Zeus installs lazy getters for `destination`/`signature` because its
|
||||
// Activity list decodes hundreds of invoices to read only the timestamp. Here `DecodedInvoice`
|
||||
// requires a non-null `destination` on every call, so laziness would never pay off; the LRU cache
|
||||
// (which is what actually collapses repeat recoveries) is kept and sized for a server.
|
||||
// 5. NO `sections` array. Zeus keeps light-bolt11-decoder's positional `{name, letters, value?: any}`
|
||||
// list for back-compat. Nothing here consumes it, and it was the source of the `any`. Every tag is
|
||||
// exposed as a named, typed field instead, with anything unrecognised in `unknownTags`.
|
||||
// 6. MORE TAGS PARSED. Zeus leaves `r` (route hints), `9` (features) and `f` (fallback address) as
|
||||
// unknown tags. All three are parsed, because the contract's `routeHints` and `features` fields need
|
||||
// them. Feature bits are expanded to the indices of the set bits, matching how backends/clnrest.ts
|
||||
// renders CLN's bitmap.
|
||||
// 7. SPEC-CONFORMANT LENIENCY/STRICTNESS. Known tags whose `data_length` is wrong are skipped and
|
||||
// unknown tags ignored (BOLT 11 requires both). An all-uppercase invoice — the QR form — is accepted
|
||||
// and lowercased; a MIXED-case one is rejected, as the spec requires and Zeus's blanket
|
||||
// `.toLowerCase()` did not. An explicit `n` payee is checked against the recovered key rather than
|
||||
// trusted blindly, which is the check Zeus skips.
|
||||
// 8. NO SIMNET. Zeus carries the btcd-only `sb` prefix; `BitcoinNetwork` has no member for it.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { bech32 } from 'bech32';
|
||||
import { recoverPublicKey } from '@noble/secp256k1';
|
||||
import { BackendError, type BitcoinNetwork, type DecodedInvoice } from './types';
|
||||
|
||||
// ── shape ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Bolt11NetworkParams = {
|
||||
/** The bech32 human-readable part that follows `ln`. */
|
||||
readonly bech32: string;
|
||||
readonly network: BitcoinNetwork;
|
||||
readonly pubKeyHash: number;
|
||||
readonly scriptHash: number;
|
||||
readonly validWitnessVersions: readonly number[];
|
||||
};
|
||||
|
||||
/** One hop of an `r` field. Multiple `r` fields each describe a separate route. */
|
||||
export type Bolt11RouteHop = {
|
||||
readonly pubkey: string;
|
||||
/** `block x tx x output`, the standard short_channel_id rendering. */
|
||||
readonly shortChannelId: string;
|
||||
readonly feeBaseMsat: number;
|
||||
readonly feeProportionalMillionths: number;
|
||||
readonly cltvExpiryDelta: number;
|
||||
};
|
||||
|
||||
/** An `f` field. Left as witness-version + program rather than re-encoded to an address string. */
|
||||
export type Bolt11Fallback = {
|
||||
/** 0-16 are segwit witness versions; 17 is P2PKH and 18 is P2SH. */
|
||||
readonly version: number;
|
||||
readonly programHex: string;
|
||||
};
|
||||
|
||||
export type Bolt11UnknownTag = { readonly tagCode: number; readonly words: readonly number[] };
|
||||
|
||||
/** The full decode. `decodeBolt11` projects this onto the narrower `DecodedInvoice` contract. */
|
||||
export type Bolt11Invoice = {
|
||||
/** The invoice, lowercased. */
|
||||
readonly paymentRequest: string;
|
||||
/** The whole human-readable part, e.g. `lnbc2500u`. */
|
||||
readonly prefix: string;
|
||||
readonly network: BitcoinNetwork;
|
||||
readonly networkParams: Bolt11NetworkParams;
|
||||
readonly timestamp: number;
|
||||
/** Decimal msat string, or null for a zero-amount ("any amount") invoice. Never a number. */
|
||||
readonly millisatoshis: string | null;
|
||||
/** Convenience only, and null whenever the amount is not a whole number of sats. */
|
||||
readonly satoshis: number | null;
|
||||
readonly paymentHash: string;
|
||||
readonly paymentSecret: string | null;
|
||||
readonly description: string | null;
|
||||
readonly descriptionHash: string | null;
|
||||
/** The explicit `n` payee field, when the invoice carries one. */
|
||||
readonly payee: string | null;
|
||||
/** `payee` when present, else recovered from the signature. 33-byte compressed pubkey, hex. */
|
||||
readonly destination: string;
|
||||
/** The raw `x` field; null when absent. `expirySeconds` applies the spec's 3600 default. */
|
||||
readonly expiry: number | null;
|
||||
readonly expirySeconds: number;
|
||||
readonly expiresAt: number;
|
||||
readonly cltvExpiry: number | null;
|
||||
readonly metadata: string | null;
|
||||
/** Indices of the set feature bits, as decimal strings. */
|
||||
readonly featureBits: readonly string[];
|
||||
readonly routes: readonly (readonly Bolt11RouteHop[])[];
|
||||
readonly fallbacks: readonly Bolt11Fallback[];
|
||||
/** 64-byte compact signature, hex. */
|
||||
readonly signature: string;
|
||||
readonly recoveryFlag: number;
|
||||
readonly unknownTags: readonly Bolt11UnknownTag[];
|
||||
};
|
||||
|
||||
// ── constants ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Zeus caches 256 entries for a phone's Activity list. A server decodes the same invoice from several
|
||||
* call sites (list → detail → pay), so the cache earns its keep the same way; 1024 entries of a few
|
||||
* hundred bytes each is a rounding error against a Bun heap.
|
||||
*/
|
||||
const CACHE_LIMIT = 1024;
|
||||
const cache = new Map<string, Bolt11Invoice>();
|
||||
|
||||
const MSAT_PER_BTC = 100_000_000_000n;
|
||||
const MAX_MSAT = 2_100_000_000_000_000_000n;
|
||||
const DIVISORS: Readonly<Record<string, bigint>> = {
|
||||
m: 1_000n,
|
||||
u: 1_000_000n,
|
||||
n: 1_000_000_000n,
|
||||
p: 1_000_000_000_000n,
|
||||
};
|
||||
|
||||
const NETWORKS: readonly Bolt11NetworkParams[] = [
|
||||
{ bech32: 'bc', network: 'bitcoin', pubKeyHash: 0x00, scriptHash: 0x05, validWitnessVersions: [0, 1] },
|
||||
{ bech32: 'tb', network: 'testnet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
|
||||
{ bech32: 'tbs', network: 'signet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
|
||||
{ bech32: 'bcrt', network: 'regtest', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
|
||||
];
|
||||
|
||||
const TAG_NAMES = {
|
||||
1: 'payment_hash',
|
||||
3: 'route_hint',
|
||||
5: 'feature_bits',
|
||||
6: 'expiry',
|
||||
9: 'fallback_address',
|
||||
13: 'description',
|
||||
16: 'payment_secret',
|
||||
19: 'payee',
|
||||
23: 'description_hash',
|
||||
24: 'min_final_cltv_expiry',
|
||||
27: 'metadata',
|
||||
} as const satisfies Record<number, string>;
|
||||
|
||||
/** BOLT 11: a reader MUST skip a `p`, `s`, `h` or `n` field whose data_length is not the fixed size. */
|
||||
const FIXED_TAG_WORDS: Readonly<Record<number, number>> = { 1: 52, 16: 52, 23: 52, 19: 53 };
|
||||
|
||||
/** The signature occupies the last 104 words — 65 bytes: 64-byte compact sig + 1 recovery byte. */
|
||||
const SIGNATURE_WORDS = 104;
|
||||
const TIMESTAMP_WORDS = 7;
|
||||
|
||||
/** BOLT 11 default when no `x` field is present. */
|
||||
const DEFAULT_EXPIRY_SECONDS = 3600;
|
||||
|
||||
const ROUTE_HOP_BYTES = 51;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function badInvoice(message: string): never {
|
||||
throw new BackendError(`invalid bolt11 invoice: ${message}`, 400, 'BAD_INVOICE');
|
||||
}
|
||||
|
||||
/** Big-endian base-32. BigInt because a hostile tag can claim far more than 53 bits. */
|
||||
function wordsToInt(words: readonly number[]): number {
|
||||
let value = 0n;
|
||||
for (const word of words) value = value * 32n + BigInt(word);
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER)) badInvoice('a numeric field is out of range');
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function wordsToBytes(words: readonly number[]): Uint8Array {
|
||||
const bytes = bech32.fromWordsUnsafe(words);
|
||||
if (bytes == null) badInvoice('a field is not a whole number of bytes');
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
const wordsToHex = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('hex');
|
||||
|
||||
const wordsToUtf8 = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('utf8');
|
||||
|
||||
/**
|
||||
* The `9` field is a big-endian bit vector whose LAST bit is feature 0, so a word's position from the
|
||||
* end fixes the base index. Rendered as decimal strings to match backends/clnrest.ts's `featureBits`.
|
||||
*/
|
||||
function wordsToFeatureBits(words: readonly number[]): string[] {
|
||||
const bits: string[] = [];
|
||||
for (let i = words.length - 1; i >= 0; i--) {
|
||||
const word = words[i] ?? 0;
|
||||
const base = (words.length - 1 - i) * 5;
|
||||
for (let bit = 0; bit < 5; bit++) {
|
||||
if (word & (1 << bit)) bits.push(String(base + bit));
|
||||
}
|
||||
}
|
||||
return bits.sort((a, b) => Number(a) - Number(b));
|
||||
}
|
||||
|
||||
function wordsToRoute(words: readonly number[]): Bolt11RouteHop[] {
|
||||
const bytes = Buffer.from(wordsToBytes(words));
|
||||
if (bytes.length === 0 || bytes.length % ROUTE_HOP_BYTES !== 0) badInvoice('a route hint is malformed');
|
||||
|
||||
const hops: Bolt11RouteHop[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += ROUTE_HOP_BYTES) {
|
||||
const hop = bytes.subarray(offset, offset + ROUTE_HOP_BYTES);
|
||||
const scid = hop.subarray(33, 41);
|
||||
const block = scid.readUIntBE(0, 3);
|
||||
const tx = scid.readUIntBE(3, 3);
|
||||
const output = scid.readUInt16BE(6);
|
||||
hops.push({
|
||||
pubkey: hop.subarray(0, 33).toString('hex'),
|
||||
shortChannelId: `${block}x${tx}x${output}`,
|
||||
feeBaseMsat: hop.readUInt32BE(41),
|
||||
feeProportionalMillionths: hop.readUInt32BE(45),
|
||||
cltvExpiryDelta: hop.readUInt16BE(49),
|
||||
});
|
||||
}
|
||||
return hops;
|
||||
}
|
||||
|
||||
function wordsToFallback(words: readonly number[]): Bolt11Fallback | null {
|
||||
const version = words[0];
|
||||
// A fallback with no program is meaningless; the spec says to ignore an unparseable one.
|
||||
if (version === undefined || words.length < 2) return null;
|
||||
return { version, programHex: wordsToHex(words.slice(1)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount grammar from the human-readable part: `<digits><multiplier?>`, where the multiplier
|
||||
* divides one bitcoin. Integer arithmetic throughout — a `p` amount that is not a multiple of 10 would
|
||||
* be sub-millisatoshi and is invalid rather than rounded.
|
||||
*/
|
||||
function hrpToMsat(amount: string, multiplier: string): string {
|
||||
if (!/^\d+$/.test(amount)) badInvoice(`"${amount}" is not a valid amount`);
|
||||
if (multiplier && !(multiplier in DIVISORS)) badInvoice(`"${multiplier}" is not a valid amount multiplier`);
|
||||
|
||||
const value = BigInt(amount);
|
||||
const divisor = multiplier ? DIVISORS[multiplier] : undefined;
|
||||
const msat = divisor === undefined ? value * MSAT_PER_BTC : (value * MSAT_PER_BTC) / divisor;
|
||||
|
||||
if (multiplier === 'p' && value % 10n !== 0n) badInvoice('amount has sub-millisatoshi precision');
|
||||
if (msat > MAX_MSAT) badInvoice('amount is outside of valid range');
|
||||
return msat.toString();
|
||||
}
|
||||
|
||||
/** 5-bit → 8-bit, right-padded with zeros: the preimage the signature covers. */
|
||||
function convertBits(words: readonly number[]): Uint8Array {
|
||||
let value = 0;
|
||||
let bits = 0;
|
||||
const result: number[] = [];
|
||||
for (const word of words) {
|
||||
value = (value << 5) | word;
|
||||
bits += 5;
|
||||
while (bits >= 8) {
|
||||
bits -= 8;
|
||||
result.push((value >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
if (bits > 0) result.push((value << (8 - bits)) & 0xff);
|
||||
return Uint8Array.from(result);
|
||||
}
|
||||
|
||||
type Recovered = { destination: string; signature: string; recoveryFlag: number };
|
||||
|
||||
/** The signed message is sha256( utf8(prefix) || convertBits(dataWords) ). SEC1 recovery from there. */
|
||||
function recoverPayee(prefix: string, sigWords: readonly number[], signedWords: readonly number[]): Recovered {
|
||||
const sigBytes = wordsToBytes(sigWords);
|
||||
const recoveryFlag = sigBytes[64];
|
||||
if (sigBytes.length !== 65 || recoveryFlag === undefined || recoveryFlag > 3) {
|
||||
badInvoice('signature is malformed');
|
||||
}
|
||||
|
||||
const preimage = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convertBits(signedWords))]);
|
||||
const hash = createHash('sha256').update(preimage).digest();
|
||||
|
||||
// @noble/secp256k1 v3 wants the recovery byte FIRST; bech32 carries it last.
|
||||
const recoverable = Buffer.concat([Buffer.from([recoveryFlag]), Buffer.from(sigBytes.subarray(0, 64))]);
|
||||
|
||||
let pubkey: Uint8Array;
|
||||
try {
|
||||
pubkey = recoverPublicKey(recoverable, hash, { prehash: false });
|
||||
} catch {
|
||||
badInvoice('signature is not recoverable');
|
||||
}
|
||||
|
||||
return {
|
||||
destination: Buffer.from(pubkey).toString('hex'),
|
||||
signature: Buffer.from(sigBytes.subarray(0, 64)).toString('hex'),
|
||||
recoveryFlag,
|
||||
};
|
||||
}
|
||||
|
||||
/** `lnbc2500u` → the network params and the amount. */
|
||||
function parsePrefix(prefix: string): { params: Bolt11NetworkParams; millisatoshis: string | null } {
|
||||
// Non-greedy on the hrp so the trailing digits+multiplier win: `lnbcrt500u` → bcrt / 500 / u. A prefix
|
||||
// with no amount ends in a letter the first pattern would eat, hence the amount-less second attempt.
|
||||
let matches = /^ln(\S+?)(\d+)([a-z]?)$/.exec(prefix);
|
||||
let amount = matches?.[2] ?? '';
|
||||
let multiplier = matches?.[3] ?? '';
|
||||
if (!matches) {
|
||||
matches = /^ln(\S+)$/.exec(prefix);
|
||||
amount = '';
|
||||
multiplier = '';
|
||||
}
|
||||
|
||||
const hrp = matches?.[1];
|
||||
if (!hrp) badInvoice('not a lightning payment request');
|
||||
|
||||
const params = NETWORKS.find((candidate) => candidate.bech32 === hrp);
|
||||
if (!params) badInvoice(`unknown network prefix "ln${hrp}"`);
|
||||
|
||||
return { params, millisatoshis: amount ? hrpToMsat(amount, multiplier) : null };
|
||||
}
|
||||
|
||||
type Tags = {
|
||||
paymentHash: string | null;
|
||||
paymentSecret: string | null;
|
||||
description: string | null;
|
||||
descriptionHash: string | null;
|
||||
payee: string | null;
|
||||
expiry: number | null;
|
||||
cltvExpiry: number | null;
|
||||
metadata: string | null;
|
||||
featureBits: string[];
|
||||
routes: Bolt11RouteHop[][];
|
||||
fallbacks: Bolt11Fallback[];
|
||||
unknownTags: Bolt11UnknownTag[];
|
||||
};
|
||||
|
||||
/** Walks the tagged fields. First occurrence of a field wins; unknown and wrong-length fields are skipped. */
|
||||
function parseTags(dataWords: readonly number[]): Tags {
|
||||
const tags: Tags = {
|
||||
paymentHash: null,
|
||||
paymentSecret: null,
|
||||
description: null,
|
||||
descriptionHash: null,
|
||||
payee: null,
|
||||
expiry: null,
|
||||
cltvExpiry: null,
|
||||
metadata: null,
|
||||
featureBits: [],
|
||||
routes: [],
|
||||
fallbacks: [],
|
||||
unknownTags: [],
|
||||
};
|
||||
|
||||
let words = dataWords;
|
||||
while (words.length > 0) {
|
||||
const tagCode = words[0];
|
||||
if (tagCode === undefined) break;
|
||||
if (words.length < 3) badInvoice('a tagged field is truncated');
|
||||
|
||||
const length = wordsToInt(words.slice(1, 3));
|
||||
words = words.slice(3);
|
||||
if (length > words.length) badInvoice('a tagged field overruns the invoice');
|
||||
const tagWords = words.slice(0, length);
|
||||
words = words.slice(length);
|
||||
|
||||
const expected = FIXED_TAG_WORDS[tagCode];
|
||||
if (expected !== undefined && length !== expected) continue;
|
||||
|
||||
const name: string | undefined = TAG_NAMES[tagCode as keyof typeof TAG_NAMES];
|
||||
switch (name) {
|
||||
case 'payment_hash':
|
||||
tags.paymentHash ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'payment_secret':
|
||||
tags.paymentSecret ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'description':
|
||||
tags.description ??= wordsToUtf8(tagWords);
|
||||
break;
|
||||
case 'description_hash':
|
||||
tags.descriptionHash ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'payee':
|
||||
tags.payee ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'expiry':
|
||||
tags.expiry ??= wordsToInt(tagWords);
|
||||
break;
|
||||
case 'min_final_cltv_expiry':
|
||||
tags.cltvExpiry ??= wordsToInt(tagWords);
|
||||
break;
|
||||
case 'metadata':
|
||||
tags.metadata ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'feature_bits':
|
||||
if (tags.featureBits.length === 0) tags.featureBits = wordsToFeatureBits(tagWords);
|
||||
break;
|
||||
case 'route_hint':
|
||||
tags.routes.push(wordsToRoute(tagWords));
|
||||
break;
|
||||
case 'fallback_address': {
|
||||
const fallback = wordsToFallback(tagWords);
|
||||
if (fallback) tags.fallbacks.push(fallback);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
tags.unknownTags.push({ tagCode, words: tagWords });
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
// ── the decoder ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full decode, cached. The returned object is shared with every other caller for the same invoice and is
|
||||
* typed readonly throughout — treat it as frozen.
|
||||
*/
|
||||
export function decodeBolt11Invoice(paymentRequest: string): Bolt11Invoice {
|
||||
if (typeof paymentRequest !== 'string') badInvoice('expected a string');
|
||||
|
||||
const trimmed = paymentRequest.trim();
|
||||
// The QR form is all-uppercase and legal; a mixed-case string is not (BOLT 11 / BIP-173).
|
||||
if (/[a-z]/.test(trimmed) && /[A-Z]/.test(trimmed)) badInvoice('mixed-case payment request');
|
||||
const normalized = trimmed.toLowerCase();
|
||||
|
||||
const cached = cache.get(normalized);
|
||||
if (cached) {
|
||||
// Re-insert to move to the MRU end of the Map's insertion order.
|
||||
cache.delete(normalized);
|
||||
cache.set(normalized, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!normalized.startsWith('ln')) badInvoice('not a lightning payment request');
|
||||
|
||||
let decoded: { prefix: string; words: number[] };
|
||||
try {
|
||||
decoded = bech32.decode(normalized, Number.MAX_SAFE_INTEGER);
|
||||
} catch (err) {
|
||||
badInvoice(err instanceof Error ? err.message.toLowerCase() : 'bech32 decode failed');
|
||||
}
|
||||
|
||||
if (decoded.words.length < SIGNATURE_WORDS + TIMESTAMP_WORDS) badInvoice('payment request is too short');
|
||||
|
||||
const { params, millisatoshis } = parsePrefix(decoded.prefix);
|
||||
|
||||
const signedWords = decoded.words.slice(0, -SIGNATURE_WORDS);
|
||||
const sigWords = decoded.words.slice(-SIGNATURE_WORDS);
|
||||
const timestamp = wordsToInt(signedWords.slice(0, TIMESTAMP_WORDS));
|
||||
const tags = parseTags(signedWords.slice(TIMESTAMP_WORDS));
|
||||
|
||||
if (!tags.paymentHash) badInvoice('no payment hash');
|
||||
|
||||
const recovered = recoverPayee(decoded.prefix, sigWords, signedWords);
|
||||
// BOLT 11: when an `n` field is present a reader MUST still check the signature against it. A correct
|
||||
// signer always recovers to its own key, so a mismatch means the invoice was tampered with.
|
||||
if (tags.payee && tags.payee !== recovered.destination) badInvoice('signature does not match the payee');
|
||||
|
||||
const msat = millisatoshis === null ? null : BigInt(millisatoshis);
|
||||
const expirySeconds = tags.expiry ?? DEFAULT_EXPIRY_SECONDS;
|
||||
|
||||
const invoice: Bolt11Invoice = {
|
||||
paymentRequest: normalized,
|
||||
prefix: decoded.prefix,
|
||||
network: params.network,
|
||||
networkParams: params,
|
||||
timestamp,
|
||||
millisatoshis,
|
||||
satoshis: msat !== null && msat % 1000n === 0n ? Number(msat / 1000n) : null,
|
||||
paymentHash: tags.paymentHash,
|
||||
paymentSecret: tags.paymentSecret,
|
||||
description: tags.description,
|
||||
descriptionHash: tags.descriptionHash,
|
||||
payee: tags.payee,
|
||||
destination: tags.payee ?? recovered.destination,
|
||||
expiry: tags.expiry,
|
||||
expirySeconds,
|
||||
expiresAt: timestamp + expirySeconds,
|
||||
cltvExpiry: tags.cltvExpiry,
|
||||
metadata: tags.metadata,
|
||||
featureBits: tags.featureBits,
|
||||
routes: tags.routes,
|
||||
fallbacks: tags.fallbacks,
|
||||
signature: recovered.signature,
|
||||
recoveryFlag: recovered.recoveryFlag,
|
||||
unknownTags: tags.unknownTags,
|
||||
};
|
||||
|
||||
if (cache.size >= CACHE_LIMIT) {
|
||||
const oldest = cache.keys().next();
|
||||
if (!oldest.done) cache.delete(oldest.value);
|
||||
}
|
||||
cache.set(normalized, invoice);
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract surface: a BOLT11 payment request as the backends report it. Throws
|
||||
* `BackendError(…, 400, 'BAD_INVOICE')` for anything that does not decode.
|
||||
*/
|
||||
export function decodeBolt11(bolt11: string): DecodedInvoice {
|
||||
const invoice = decodeBolt11Invoice(bolt11);
|
||||
return {
|
||||
bolt11: invoice.paymentRequest,
|
||||
paymentHash: invoice.paymentHash,
|
||||
amountMsat: invoice.millisatoshis,
|
||||
description: invoice.description,
|
||||
destination: invoice.destination,
|
||||
timestamp: invoice.timestamp,
|
||||
expiry: invoice.expirySeconds,
|
||||
cltvExpiry: invoice.cltvExpiry,
|
||||
routeHints: invoice.routes.length > 0,
|
||||
features: [...invoice.featureBits],
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: the LRU is process-global, so a test that measures recovery cost needs to reset it. */
|
||||
export function clearBolt11Cache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
// Behavioural pins for the Esplora chain source, driven through a real OnchainBackend.
|
||||
//
|
||||
// These exist because the scan used to live inside the backend and was moved out wholesale; nothing in
|
||||
// the suite covered it before, so a silent behaviour change during the move would have shipped. The stub
|
||||
// below is a full stand-in for the HTTP client, which means the whole path — gap-limit walk, balance
|
||||
// summation, UTXO mapping, transaction scoring, address issuance — runs with no network at all.
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { mnemonicToSeedSync } from '@scure/bip39';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import type { EsploraAddress, EsploraChain, EsploraTx, EsploraUtxo } from './chain';
|
||||
import { EsploraChainSource } from './chain-source-esplora';
|
||||
import { OnchainBackend, type WalletSigner } from './backends/onchain';
|
||||
import { initEcc } from './psbt';
|
||||
|
||||
initEcc();
|
||||
|
||||
// NEVER PUT A REAL ACCOUNT KEY IN A TEST. An xpub cannot spend, but it reveals every address a wallet
|
||||
// will ever use and its entire history, permanently and irrevocably — and a test file is the easiest
|
||||
// place in a repo for one to hide, because it looks like a fixture. This one is derived here, in the
|
||||
// open, from the BIP84 specification's own published mnemonic, so its provenance is checkable rather
|
||||
// than asserted. If you need a different fixture, derive it the same way; do not paste one in.
|
||||
const PUBLIC_TEST_MNEMONIC =
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
const ACCOUNT_XPUB = HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC)).derive(
|
||||
"m/84'/0'/0'",
|
||||
).publicExtendedKey;
|
||||
|
||||
/** Derive the same p2wpkh address the backend will, independently of the backend. */
|
||||
function addressAt(chain: 0 | 1, index: number): string {
|
||||
const node = HDKey.fromExtendedKey(ACCOUNT_XPUB).deriveChild(chain).deriveChild(index);
|
||||
const pubkey = Buffer.from(node.publicKey!);
|
||||
return bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin }).address!;
|
||||
}
|
||||
|
||||
function scriptAt(chain: 0 | 1, index: number): string {
|
||||
const node = HDKey.fromExtendedKey(ACCOUNT_XPUB).deriveChild(chain).deriveChild(index);
|
||||
const pubkey = Buffer.from(node.publicKey!);
|
||||
return bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin }).output!.toString('hex');
|
||||
}
|
||||
|
||||
const EMPTY_STATS = { funded_txo_count: 0, funded_txo_sum: 0, spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0 };
|
||||
|
||||
type Funding = { confirmed?: number; spent?: number; mempool?: number; txCount?: number };
|
||||
|
||||
/**
|
||||
* A stand-in for EsploraChain that serves a fixed world: a map of address → what the chain says about it,
|
||||
* and everything else derived from that. Also counts requests, because the request count IS the behaviour
|
||||
* under test for the gap-limit walk.
|
||||
*/
|
||||
class StubEsplora {
|
||||
readonly calls: string[] = [];
|
||||
constructor(
|
||||
private readonly world: Map<string, Funding>,
|
||||
private readonly tip = 800_000,
|
||||
) {}
|
||||
|
||||
async getTipHeight(): Promise<number> {
|
||||
this.calls.push('tip');
|
||||
return this.tip;
|
||||
}
|
||||
|
||||
async getAddress(address: string): Promise<EsploraAddress> {
|
||||
this.calls.push(`addr:${address}`);
|
||||
const f = this.world.get(address);
|
||||
if (!f) return { address, chain_stats: { ...EMPTY_STATS }, mempool_stats: { ...EMPTY_STATS } };
|
||||
return {
|
||||
address,
|
||||
chain_stats: {
|
||||
...EMPTY_STATS,
|
||||
funded_txo_sum: f.confirmed ?? 0,
|
||||
spent_txo_sum: f.spent ?? 0,
|
||||
tx_count: f.txCount ?? 1,
|
||||
},
|
||||
mempool_stats: { ...EMPTY_STATS, funded_txo_sum: f.mempool ?? 0, tx_count: f.mempool ? 1 : 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
|
||||
this.calls.push(`utxo:${address}`);
|
||||
const f = this.world.get(address);
|
||||
const value = (f?.confirmed ?? 0) - (f?.spent ?? 0);
|
||||
if (!f || value <= 0) return [];
|
||||
return [{ txid: 'a'.repeat(64), vout: 0, value, status: { confirmed: true, block_height: this.tip - 5 } }];
|
||||
}
|
||||
|
||||
async getAddressTxs(address: string): Promise<EsploraTx[]> {
|
||||
this.calls.push(`txs:${address}`);
|
||||
const f = this.world.get(address);
|
||||
if (!f) return [];
|
||||
const idx = [...this.world.keys()].indexOf(address);
|
||||
return [
|
||||
{
|
||||
txid: String(idx).padStart(64, 'b'),
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
fee: 200,
|
||||
vin: [{ txid: 'c'.repeat(64), vout: 0, prevout: null, scriptsig: '', sequence: 0xffffffff, witness: [] }],
|
||||
vout: [
|
||||
{ scriptpubkey: scriptFor(address), scriptpubkey_asm: '', scriptpubkey_type: 'v0_p2wpkh', value: 50_000 },
|
||||
],
|
||||
status: { confirmed: true, block_height: this.tip - 5, block_time: 1_700_000_000 },
|
||||
} as unknown as EsploraTx,
|
||||
];
|
||||
}
|
||||
|
||||
async getFeeEstimates() {
|
||||
return { fastestFee: 10, halfHourFee: 8, hourFee: 5, economyFee: 2, minimumFee: 1 };
|
||||
}
|
||||
|
||||
async getTxHex(): Promise<string> {
|
||||
throw new Error('not needed');
|
||||
}
|
||||
|
||||
/** Accepts anything and reports a fixed txid — the spend tests care what got selected, not what got sent. */
|
||||
async broadcast(): Promise<string> {
|
||||
this.calls.push('broadcast');
|
||||
return 'd'.repeat(64);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reverse-lookup the script for an address the stub was configured with. */
|
||||
function scriptFor(address: string): string {
|
||||
for (const chain of [0, 1] as const) {
|
||||
for (let i = 0; i < 40; i++) if (addressAt(chain, i) === address) return scriptAt(chain, i);
|
||||
}
|
||||
return 'ff';
|
||||
}
|
||||
|
||||
const LOCKED_SIGNER: WalletSigner = {
|
||||
isUnlocked: () => false,
|
||||
withRoot: () => {
|
||||
throw new Error('locked');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A signer holding the fixture's own root key. Only the spend tests need one — everything else runs
|
||||
* locked, which is the state the wallet is in almost all of the time.
|
||||
*/
|
||||
const UNLOCKED_SIGNER: WalletSigner = {
|
||||
isUnlocked: () => true,
|
||||
withRoot: <T>(fn: (root: HDKey) => T): T => fn(HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC))),
|
||||
};
|
||||
|
||||
function backendFor(world: Map<string, Funding>, signer: WalletSigner = LOCKED_SIGNER) {
|
||||
const stub = new StubEsplora(world);
|
||||
const source = new EsploraChainSource({
|
||||
chain: stub as unknown as EsploraChain,
|
||||
label: 'esplora(stub)',
|
||||
});
|
||||
const backend = new OnchainBackend({
|
||||
chain: source,
|
||||
network: 'bitcoin',
|
||||
accountXpub: { p2wpkh: ACCOUNT_XPUB },
|
||||
signer,
|
||||
});
|
||||
return { backend, stub };
|
||||
}
|
||||
|
||||
describe('EsploraChainSource through OnchainBackend', () => {
|
||||
test('the fixture key really is the published BIP84 vector, not somebody’s wallet', () => {
|
||||
// The spec's own first receive address. If this fails, the mnemonic above is not what it claims and
|
||||
// whatever replaced it must be treated as live key material until proven otherwise.
|
||||
expect(addressAt(0, 0)).toBe('bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu');
|
||||
});
|
||||
|
||||
test('an empty wallet costs one gap-limit window per chain and reports zero', async () => {
|
||||
const { backend, stub } = backendFor(new Map());
|
||||
|
||||
expect(await backend.getBalances()).toEqual({
|
||||
onchainConfirmed: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
onchainFrozen: 0,
|
||||
lightningBalance: null,
|
||||
lightningInbound: null,
|
||||
});
|
||||
expect(await backend.getUtxos()).toEqual([]);
|
||||
expect(await backend.getTransactions()).toEqual([]);
|
||||
|
||||
// One script type, two chains, one gap-limit window each: exactly 40 address lookups and no more.
|
||||
expect(stub.calls.filter((c) => c.startsWith('addr:'))).toHaveLength(40);
|
||||
// Nothing held a balance, so not a single UTXO call was worth making.
|
||||
expect(stub.calls.filter((c) => c.startsWith('utxo:'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('unused script types cost one probe each, not a full walk', async () => {
|
||||
// The same xpub under four script types: every one derives valid addresses, and only the default
|
||||
// (p2wpkh) should be walked. This is the optimisation that keeps a scan at 43 requests instead of 160.
|
||||
const stub = new StubEsplora(new Map());
|
||||
const backend = new OnchainBackend({
|
||||
chain: new EsploraChainSource({ chain: stub as unknown as EsploraChain, label: 'esplora(stub)' }),
|
||||
network: 'bitcoin',
|
||||
accountXpub: {
|
||||
p2wpkh: ACCOUNT_XPUB,
|
||||
p2tr: ACCOUNT_XPUB,
|
||||
'p2sh-p2wpkh': ACCOUNT_XPUB,
|
||||
p2pkh: ACCOUNT_XPUB,
|
||||
},
|
||||
signer: LOCKED_SIGNER,
|
||||
});
|
||||
|
||||
await backend.getBalances();
|
||||
// 40 for the walked default + 1 probe each for the three that have never been used.
|
||||
expect(stub.calls.filter((c) => c.startsWith('addr:'))).toHaveLength(43);
|
||||
});
|
||||
|
||||
test('balances sum the confirmed and mempool deltas across the scan', async () => {
|
||||
const world = new Map<string, Funding>([
|
||||
[addressAt(0, 0), { confirmed: 100_000, spent: 40_000 }],
|
||||
[addressAt(0, 3), { confirmed: 25_000 }],
|
||||
[addressAt(1, 0), { confirmed: 0, mempool: 7_000 }],
|
||||
]);
|
||||
const { backend } = backendFor(world);
|
||||
|
||||
const balances = await backend.getBalances();
|
||||
expect(balances.onchainConfirmed).toBe(85_000);
|
||||
expect(balances.onchainUnconfirmed).toBe(7_000);
|
||||
|
||||
const utxos = await backend.getUtxos();
|
||||
expect(utxos.map((u) => u.amountSats).sort((a, b) => a - b)).toEqual([25_000, 60_000]);
|
||||
// The public contract reports the path relative to the account xpub, not the absolute one.
|
||||
expect(utxos.every((u) => /^[01]\/\d+$/.test(u.derivationPath ?? ''))).toBe(true);
|
||||
expect(utxos.every((u) => u.confirmations === 6)).toBe(true);
|
||||
});
|
||||
|
||||
test('a used address resets the gap, so funds past the first window are still found', async () => {
|
||||
// Index 25 is beyond the initial 20-address window: it is only reachable if a hit at 19 reset the gap.
|
||||
const world = new Map<string, Funding>([
|
||||
[addressAt(0, 19), { confirmed: 1_000 }],
|
||||
[addressAt(0, 25), { confirmed: 2_000 }],
|
||||
]);
|
||||
const { backend } = backendFor(world);
|
||||
|
||||
expect((await backend.getBalances()).onchainConfirmed).toBe(3_000);
|
||||
});
|
||||
|
||||
test('getNewAddress peeks without consuming and advances only when asked', async () => {
|
||||
const world = new Map<string, Funding>([[addressAt(0, 0), { confirmed: 5_000 }]]);
|
||||
const { backend } = backendFor(world);
|
||||
|
||||
// 0/0 is used, so the first unused is 0/1 — and peeking twice must not move past it.
|
||||
expect((await backend.getNewAddress({ peek: true })).address).toBe(addressAt(0, 1));
|
||||
expect((await backend.getNewAddress({ peek: true })).address).toBe(addressAt(0, 1));
|
||||
|
||||
expect((await backend.getNewAddress()).address).toBe(addressAt(0, 1));
|
||||
expect((await backend.getNewAddress()).address).toBe(addressAt(0, 2));
|
||||
});
|
||||
|
||||
// Issuance used to be unbounded: the mark moved on every call, paid or not, so a run of unpaid
|
||||
// addresses walked it past the end of the window the next scan covers. A payment there would have been
|
||||
// invisible forever — Esplora has no rescan to go and look.
|
||||
test('issuance stops at the far edge of the scanned window instead of walking past it', async () => {
|
||||
const world = new Map<string, Funding>([[addressAt(0, 0), { confirmed: 5_000 }]]);
|
||||
const { backend } = backendFor(world);
|
||||
|
||||
const issued: string[] = [];
|
||||
for (let i = 0; i < 30; i++) issued.push((await backend.getNewAddress()).address);
|
||||
|
||||
// 0/0 is used, so the walk covers 0..20 — one full gap limit of misses past it — and stops.
|
||||
expect(issued[0]).toBe(addressAt(0, 1));
|
||||
expect(issued[19]).toBe(addressAt(0, 20));
|
||||
// Everything after that re-offers the last address the scan can still see. All of them are virgin,
|
||||
// so repeating one is not address reuse in the sense that loses privacy.
|
||||
expect(issued.at(-1)).toBe(addressAt(0, 20));
|
||||
const scanned = new Set(Array.from({ length: 21 }, (_, i) => addressAt(0, i)));
|
||||
expect(issued.filter((a) => !scanned.has(a))).toEqual([]);
|
||||
});
|
||||
|
||||
test('transactions are scored against the wallet and credit is positive', async () => {
|
||||
const world = new Map<string, Funding>([[addressAt(0, 0), { confirmed: 50_000 }]]);
|
||||
const { backend } = backendFor(world);
|
||||
|
||||
const txs = await backend.getTransactions();
|
||||
expect(txs).toHaveLength(1);
|
||||
const tx = txs[0]!;
|
||||
expect(tx.amount).toBe(50_000);
|
||||
expect(tx.confirmations).toBe(6);
|
||||
expect(tx.destAddresses).toEqual([addressAt(0, 0)]);
|
||||
// We funded no input, so the fee was the sender's and must not be attributed to this wallet.
|
||||
expect(tx.feeSats).toBeNull();
|
||||
});
|
||||
|
||||
// Freezing a coin is a promise that it will not be spent. It was not being kept: the route checked the
|
||||
// frozen list only against outpoints the caller named explicitly, so coin control was safe while a
|
||||
// plain send — the ordinary case — picked inputs from a snapshot on which every UTXO claimed
|
||||
// `frozen: false`, because the chain has no idea what Officer froze. Sweeping was the worst of it: a
|
||||
// "send everything" emptied the frozen coin too.
|
||||
//
|
||||
// These run unlocked and reach the signer, which is why the fixture key had to be the published test
|
||||
// vector and nothing else.
|
||||
describe('frozen coins', () => {
|
||||
const fundedWorld = () => new Map<string, Funding>([[addressAt(0, 0), { confirmed: 50_000 }]]);
|
||||
/** The single outpoint the stub serves for a funded address. */
|
||||
const OUTPOINT = `${'a'.repeat(64)}:0`;
|
||||
const TO = 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu';
|
||||
|
||||
test('automatic selection will not spend one', async () => {
|
||||
const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER);
|
||||
await expect(
|
||||
backend.sendCoins({ address: TO, amountSats: 10_000, satPerVbyte: 5, frozenOutpoints: [OUTPOINT] }),
|
||||
).rejects.toThrow(/no spendable UTXOs/);
|
||||
// Nothing was signed and nothing left the machine.
|
||||
expect(stub.calls).not.toContain('broadcast');
|
||||
});
|
||||
|
||||
test('sendAll will not sweep one', async () => {
|
||||
const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER);
|
||||
await expect(
|
||||
backend.sendCoins({ address: TO, sendAll: true, satPerVbyte: 5, frozenOutpoints: [OUTPOINT] }),
|
||||
).rejects.toThrow(/no spendable UTXOs/);
|
||||
expect(stub.calls).not.toContain('broadcast');
|
||||
});
|
||||
|
||||
test('the same send succeeds once the coin is not frozen', async () => {
|
||||
// The control. Without it the two tests above would still pass if sending were broken outright.
|
||||
const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER);
|
||||
const result = await backend.sendCoins({ address: TO, amountSats: 10_000, satPerVbyte: 5 });
|
||||
expect(result.txid).toBe('d'.repeat(64));
|
||||
expect(stub.calls).toContain('broadcast');
|
||||
});
|
||||
});
|
||||
|
||||
test('fee estimates pass through untouched', async () => {
|
||||
const { backend } = backendFor(new Map());
|
||||
expect(await backend.estimateFees()).toEqual({
|
||||
fastestFee: 10,
|
||||
halfHourFee: 8,
|
||||
hourFee: 5,
|
||||
economyFee: 2,
|
||||
minimumFee: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
// The Esplora chain source: everything that used to be the on-chain backend's scanning half.
|
||||
//
|
||||
// Esplora answers about ONE address at a time and has no concept of a wallet, so finding the wallet is
|
||||
// this file's job: derive addresses, ask about each, and stop after enough consecutive misses. That walk
|
||||
// is why the file exists, and why it is the expensive source of the two.
|
||||
|
||||
import type { EsploraAddress, EsploraChain, EsploraTx } from './chain';
|
||||
import type { SpendableUtxo } from './psbt';
|
||||
import {
|
||||
mapLimit,
|
||||
MAX_SCAN_INDEX,
|
||||
REQUEST_CONCURRENCY,
|
||||
type AddressEntry,
|
||||
type ChainIndex,
|
||||
type ScanContext,
|
||||
type ScannedAddress,
|
||||
type ScanResult,
|
||||
type WalletChainSource,
|
||||
} from './chain-source';
|
||||
import type { AddressType, FeeEstimates, OnchainTx } from './types';
|
||||
|
||||
/** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */
|
||||
const GAP_LIMIT = 20;
|
||||
|
||||
/** Cap on the history returned. A wallet with years of activity should not grow an unbounded blob. */
|
||||
const MAX_CACHED_TXS = 500;
|
||||
|
||||
/** The scan proper, before the coins and history that hang off it. */
|
||||
type AddressScan = {
|
||||
tipHeight: number;
|
||||
addresses: ScannedAddress[];
|
||||
/** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. */
|
||||
byScript: Map<string, ScannedAddress>;
|
||||
};
|
||||
|
||||
export type EsploraChainSourceOptions = { chain: EsploraChain; label: string };
|
||||
|
||||
export class EsploraChainSource implements WalletChainSource {
|
||||
readonly label: string;
|
||||
|
||||
/** The walk already runs a full GAP_LIMIT past the last used address, and stops dead there. */
|
||||
readonly issueAhead = 0;
|
||||
|
||||
private readonly chain: EsploraChain;
|
||||
|
||||
constructor(opts: EsploraChainSourceOptions) {
|
||||
this.chain = opts.chain;
|
||||
this.label = opts.label;
|
||||
}
|
||||
|
||||
getTipHeight(): Promise<number> {
|
||||
return this.chain.getTipHeight();
|
||||
}
|
||||
|
||||
getFeeEstimates(): Promise<FeeEstimates> {
|
||||
return this.chain.getFeeEstimates();
|
||||
}
|
||||
|
||||
getTxHex(txid: string): Promise<string> {
|
||||
return this.chain.getTxHex(txid);
|
||||
}
|
||||
|
||||
broadcast(rawHex: string): Promise<string> {
|
||||
return this.chain.broadcast(rawHex);
|
||||
}
|
||||
|
||||
async scan(ctx: ScanContext): Promise<ScanResult> {
|
||||
const scan = await this.runScan(ctx);
|
||||
// Both fan out over the scanned address set and neither depends on the other, so they overlap.
|
||||
const [utxos, txs] = await Promise.all([this.collectUtxos(scan), this.collectTxs(scan)]);
|
||||
return { tipHeight: scan.tipHeight, addresses: scan.addresses, utxos, txs };
|
||||
}
|
||||
|
||||
private async runScan(ctx: ScanContext): Promise<AddressScan> {
|
||||
const tipHeight = await this.chain.getTipHeight();
|
||||
const addresses: ScannedAddress[] = [];
|
||||
|
||||
// A wallet holds an account xpub for all four BIP purposes, but a full gap-limit walk of every one
|
||||
// costs 4 types x 2 chains x 20 addresses = 160 requests, which trips the rate limit on every public
|
||||
// Esplora instance. Only the wallet's own script type is walked unconditionally; the others are
|
||||
// probed at receive index 0 first (one request each) and walked only if that address has ever been
|
||||
// used. A freshly generated seed therefore costs 43 requests instead of 160, while a seed recovered
|
||||
// from a wallet that used a different script type is still found rather than silently reported empty.
|
||||
for (const account of ctx.accounts) {
|
||||
if (account.type !== ctx.defaultType && !(await this.hasHistory(ctx, account.type))) continue;
|
||||
for (const chain of [0, 1] as ChainIndex[]) {
|
||||
addresses.push(...(await this.scanChain(ctx, account.type, chain)));
|
||||
}
|
||||
}
|
||||
|
||||
const byScript = new Map<string, ScannedAddress>();
|
||||
for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry);
|
||||
|
||||
return { tipHeight, addresses, byScript };
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this script type ever been used at all? One request against receive index 0, which is the
|
||||
* address any wallet hands out first — so a used account is essentially never missed, and an unused
|
||||
* one costs a single call instead of a forty-address walk.
|
||||
*/
|
||||
private async hasHistory(ctx: ScanContext, type: AddressType): Promise<boolean> {
|
||||
const entry = ctx.derive(type, 0, 0);
|
||||
return toScannedAddress(entry, await this.chain.getAddress(entry.address)).used;
|
||||
}
|
||||
|
||||
/** Walk one (type, chain) pair a gap-limit window at a time until GAP_LIMIT consecutive misses. */
|
||||
private async scanChain(ctx: ScanContext, type: AddressType, chain: ChainIndex): Promise<ScannedAddress[]> {
|
||||
const found: ScannedAddress[] = [];
|
||||
let index = 0;
|
||||
let gap = 0;
|
||||
|
||||
while (gap < GAP_LIMIT && index < MAX_SCAN_INDEX) {
|
||||
const window = Array.from({ length: GAP_LIMIT }, (_, i) => ctx.derive(type, chain, index + i));
|
||||
const stats = await mapLimit(window, REQUEST_CONCURRENCY, (entry) => this.chain.getAddress(entry.address));
|
||||
|
||||
for (let i = 0; i < window.length; i++) {
|
||||
const entry = window[i];
|
||||
const stat = stats[i];
|
||||
if (!entry || !stat) continue;
|
||||
const scanned = toScannedAddress(entry, stat);
|
||||
found.push(scanned);
|
||||
gap = scanned.used ? 0 : gap + 1;
|
||||
if (gap >= GAP_LIMIT) break;
|
||||
}
|
||||
index += window.length;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Fetch UTXOs for every scanned address that still holds a balance. */
|
||||
private async collectUtxos(scan: AddressScan): Promise<SpendableUtxo[]> {
|
||||
// An address whose funded and spent counts match holds nothing; asking Esplora about it is a
|
||||
// wasted round trip, and on a wallet with long history that is most of the address set.
|
||||
const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0);
|
||||
const sets = await mapLimit(funded, REQUEST_CONCURRENCY, async (entry) => {
|
||||
const utxos = await this.chain.getAddressUtxos(entry.address);
|
||||
return utxos.map<SpendableUtxo>((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.value,
|
||||
address: entry.address,
|
||||
addressType: entry.type,
|
||||
confirmations:
|
||||
u.status.confirmed && u.status.block_height !== undefined
|
||||
? Math.max(0, scan.tipHeight - u.status.block_height + 1)
|
||||
: 0,
|
||||
derivationPath: entry.path,
|
||||
frozen: false,
|
||||
scriptPubKeyHex: entry.scriptPubKeyHex,
|
||||
pubkeyHex: entry.pubkeyHex,
|
||||
}));
|
||||
});
|
||||
return sets.flat();
|
||||
}
|
||||
|
||||
/** History for every address the scan found had been touched. Only those can appear in it. */
|
||||
private async collectTxs(scan: AddressScan): Promise<OnchainTx[]> {
|
||||
const touched = scan.addresses.filter((a) => a.used);
|
||||
const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address));
|
||||
|
||||
const seen = new Map<string, EsploraTx>();
|
||||
for (const page of pages) {
|
||||
for (const tx of page) if (!seen.has(tx.txid)) seen.set(tx.txid, tx);
|
||||
}
|
||||
|
||||
const txs = [...seen.values()].map((tx) => toOnchainTx(tx, scan));
|
||||
txs.sort((a, b) => {
|
||||
// Unconfirmed first (they have no height), then newest block, then newest timestamp.
|
||||
const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
if (ah !== bh) return bh - ah;
|
||||
return (b.timestamp ?? 0) - (a.timestamp ?? 0);
|
||||
});
|
||||
return txs.slice(0, MAX_CACHED_TXS);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress {
|
||||
const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum;
|
||||
// The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is
|
||||
// exactly what the Balances contract wants in onchainUnconfirmed.
|
||||
const mempoolBalance = stat.mempool_stats.funded_txo_sum - stat.mempool_stats.spent_txo_sum;
|
||||
const txCount = stat.chain_stats.tx_count + stat.mempool_stats.tx_count;
|
||||
return {
|
||||
...entry,
|
||||
confirmedSats: chainBalance,
|
||||
unconfirmedSats: mempoolBalance,
|
||||
txCount,
|
||||
used: txCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Score one Esplora transaction against the wallet's own scripts. */
|
||||
function toOnchainTx(tx: EsploraTx, scan: AddressScan): OnchainTx {
|
||||
let credit = 0;
|
||||
let debit = 0;
|
||||
const ours: string[] = [];
|
||||
const theirs: string[] = [];
|
||||
|
||||
for (const out of tx.vout) {
|
||||
const mine = scan.byScript.get(out.scriptpubkey);
|
||||
if (mine) {
|
||||
credit += out.value;
|
||||
ours.push(mine.address);
|
||||
} else if (out.scriptpubkey_address) {
|
||||
theirs.push(out.scriptpubkey_address);
|
||||
}
|
||||
}
|
||||
for (const input of tx.vin) {
|
||||
const prevout = input.prevout;
|
||||
if (prevout && scan.byScript.has(prevout.scriptpubkey)) debit += prevout.value;
|
||||
}
|
||||
|
||||
const height = tx.status.confirmed ? (tx.status.block_height ?? null) : null;
|
||||
const confirmations = height === null ? 0 : Math.max(0, scan.tipHeight - height + 1);
|
||||
const amount = credit - debit;
|
||||
|
||||
return {
|
||||
txid: tx.txid,
|
||||
amount,
|
||||
// The fee is only ours to report when we funded an input; for an incoming payment the sender
|
||||
// paid it and attributing it to this wallet would be a lie.
|
||||
feeSats: debit > 0 ? tx.fee : null,
|
||||
blockHeight: height,
|
||||
timestamp: tx.status.block_time ?? null,
|
||||
confirmations,
|
||||
// No label store in this backend; SendCoinsRequest.label is accepted and dropped.
|
||||
label: null,
|
||||
destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours,
|
||||
// Fetching /tx/{txid}/hex per transaction would double the request count for a list view. The
|
||||
// contract permits null, and the raw hex is fetched on demand where it is actually needed.
|
||||
rawHex: null,
|
||||
};
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
// The NBXplorer chain source: the owner's own bitcoind, read through NBXplorer's wallet-level index.
|
||||
//
|
||||
// The contrast with chain-source-esplora.ts is the whole reason the WalletChainSource seam exists. There
|
||||
// is no gap-limit walk here and no address fan-out, because NBXplorer already knows what the wallet owns:
|
||||
// the account xpub is registered once, and after that a scan is three calls per account regardless of how
|
||||
// many addresses have been used. What that costs instead is reassembly — NBXplorer answers wallet-wide,
|
||||
// so the per-address view the rest of the wallet expects has to be rebuilt from UTXO key paths.
|
||||
//
|
||||
// REGISTRATION IS NOT RECOVERY, and this is the trap: a newly tracked xpub only picks up activity from
|
||||
// the moment it is registered. An imported seed therefore reads as a real, quiet, empty wallet — every
|
||||
// call succeeds, nothing errors, and the coins are simply not in the index. `startRescan` below is the
|
||||
// answer for balance and spending: `scantxoutset` walks the node's current UTXO set directly, so it finds
|
||||
// coins regardless of when the account was registered or how far back the node is pruned.
|
||||
//
|
||||
// What a rescan does NOT recover is spent-transaction history predating registration; that is a block
|
||||
// rescan, a much heavier thing, and this source will keep reporting it as absent rather than as an error.
|
||||
// Esplora stays available for the owner who wants the full history back.
|
||||
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import {
|
||||
type AddressEntry,
|
||||
type ChainIndex,
|
||||
type RescanHandle,
|
||||
type ScanAccount,
|
||||
type ScanContext,
|
||||
type ScannedAddress,
|
||||
type ScanResult,
|
||||
type WalletChainSource,
|
||||
} from './chain-source';
|
||||
import { derivationScheme, type NbxplorerChain, type NbxTransaction, type NbxUtxo } from './nbxplorer';
|
||||
import type { SpendableUtxo } from './psbt';
|
||||
import { networkFor } from './psbt';
|
||||
import {
|
||||
BackendError,
|
||||
type AddressType,
|
||||
type BitcoinNetwork,
|
||||
type FeeEstimates,
|
||||
type OnchainTx,
|
||||
type RescanOptions,
|
||||
type RescanState,
|
||||
} from './types';
|
||||
|
||||
/** Confirmation targets, in blocks, behind the five fee tiers the wallet reports. */
|
||||
const FEE_TARGETS = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 25, minimumFee: 144 } as const;
|
||||
|
||||
/** How often to ask how a `scantxoutset` is going. Frequent enough that a finished result is never missed. */
|
||||
const SCAN_POLL_MS = 2_000;
|
||||
|
||||
/** Per-variant ceiling. A scan of a large UTXO set is minutes; anything past this is a wedged node. */
|
||||
const SCAN_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** Consecutive 404s tolerated before a variant has ever reported a status — see `scanVariant`. */
|
||||
const SCAN_NULL_GRACE = 5;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export type NbxplorerChainSourceOptions = { chain: NbxplorerChain; network: BitcoinNetwork; label: string };
|
||||
|
||||
export class NbxplorerChainSource implements WalletChainSource {
|
||||
readonly label: string;
|
||||
|
||||
/**
|
||||
* A scan reports up to NBXplorer's unused mark, but NBXplorer itself watches a gap beyond it
|
||||
* (`MinGapSize`, 20 by default) and extends as addresses are used, so an address a little past the
|
||||
* mark is still seen. Deliberately below that default rather than equal to it, because the setting
|
||||
* belongs to the node operator and is not readable from here — and unlike Esplora, a miss on this
|
||||
* source is recoverable: `utxos/scan` walks the node's whole UTXO set to a gap limit of 1000.
|
||||
*/
|
||||
readonly issueAhead = 10;
|
||||
|
||||
private readonly chain: NbxplorerChain;
|
||||
private readonly btcNetwork: bitcoin.Network;
|
||||
|
||||
/**
|
||||
* Accounts seen by the most recent scan.
|
||||
*
|
||||
* `getTxHex` is the reason this exists: NBXplorer has no global transaction lookup, only a per-scheme
|
||||
* one, but the WalletChainSource signature carries a txid and nothing else. Every caller of getTxHex
|
||||
* runs inside sendCoins, which forces a fresh scan before it selects a single coin, so the map is
|
||||
* always populated by the time it is read. It is a memo of the last scan, never a source of truth.
|
||||
*/
|
||||
private accounts: readonly ScanAccount[] = [];
|
||||
|
||||
/** The rescan in flight, or the last one's outcome. Mutated in place as the scan progresses. */
|
||||
private rescan: RescanState | null = null;
|
||||
private rescanRun: Promise<RescanState> | null = null;
|
||||
|
||||
constructor(opts: NbxplorerChainSourceOptions) {
|
||||
this.chain = opts.chain;
|
||||
this.btcNetwork = networkFor(opts.network);
|
||||
this.label = opts.label;
|
||||
}
|
||||
|
||||
async getTipHeight(): Promise<number> {
|
||||
const status = await this.chain.getStatus();
|
||||
return status.chainHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* NBXplorer answers one confirmation target per call, so the five tiers are five requests.
|
||||
*
|
||||
* In a quiet mempool every target returns the same number and the tiers collapse to one value. That is
|
||||
* the node telling the truth, not a bug — there is genuinely no fee premium worth paying when the next
|
||||
* block will clear the queue. The clamp only enforces that a slower target is never quoted higher than
|
||||
* a faster one, which estimatesmartfee can otherwise produce at the boundaries.
|
||||
*/
|
||||
async getFeeEstimates(): Promise<FeeEstimates> {
|
||||
const entries = Object.entries(FEE_TARGETS) as [keyof FeeEstimates, number][];
|
||||
const rates = await Promise.all(entries.map(([, blocks]) => this.chain.getFeeRate(blocks)));
|
||||
|
||||
const out = {} as FeeEstimates;
|
||||
let ceiling = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const rate = rates[i];
|
||||
if (!entry || rate === undefined) continue;
|
||||
ceiling = Math.min(ceiling, rate);
|
||||
out[entry[0]] = ceiling;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async getTxHex(txid: string): Promise<string> {
|
||||
if (this.accounts.length === 0) {
|
||||
throw new BackendError('nbxplorer source was asked for a transaction before any scan', 500, 'NO_SCAN');
|
||||
}
|
||||
// Which account funded the input is not known here, and asking the wrong one 404s rather than
|
||||
// returning something wrong, so trying each in turn is safe. In practice the first one answers.
|
||||
let last: unknown = null;
|
||||
for (const account of this.accounts) {
|
||||
try {
|
||||
return await this.chain.getTxHex(account.accountXpub, account.type, txid);
|
||||
} catch (err) {
|
||||
last = err;
|
||||
}
|
||||
}
|
||||
throw last instanceof Error ? last : new BackendError(`no account knows transaction ${txid}`, 502);
|
||||
}
|
||||
|
||||
async broadcast(rawHex: string): Promise<string> {
|
||||
await this.chain.broadcast(rawHex);
|
||||
// NBXplorer's broadcast returns only success/failure, so the txid is computed locally. It is a hash
|
||||
// of the bytes we just sent, so it cannot disagree with what was accepted.
|
||||
return bitcoin.Transaction.fromHex(rawHex).getId();
|
||||
}
|
||||
|
||||
async scan(ctx: ScanContext): Promise<ScanResult> {
|
||||
this.accounts = ctx.accounts;
|
||||
|
||||
const perAccount = await Promise.all(ctx.accounts.map((account) => this.scanAccount(ctx, account)));
|
||||
|
||||
const tipHeight = Math.max(0, ...perAccount.map((r) => r.tipHeight));
|
||||
const addresses = perAccount.flatMap((r) => r.addresses);
|
||||
const utxos = perAccount.flatMap((r) => r.utxos);
|
||||
|
||||
const byScript = new Map<string, ScannedAddress>();
|
||||
for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry);
|
||||
|
||||
const txs = perAccount
|
||||
.flatMap((r) => r.txs)
|
||||
.map((tx) => this.toOnchainTx(tx, byScript, tipHeight))
|
||||
.sort((a, b) => {
|
||||
// Unconfirmed first (they have no height), then newest block, then newest timestamp.
|
||||
const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
if (ah !== bh) return bh - ah;
|
||||
return (b.timestamp ?? 0) - (a.timestamp ?? 0);
|
||||
});
|
||||
|
||||
return { tipHeight, addresses, utxos, txs };
|
||||
}
|
||||
|
||||
rescanState(): RescanState | null {
|
||||
return this.rescan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `scantxoutset` over every account variant, one at a time.
|
||||
*
|
||||
* ALL FOUR VARIANTS, ALWAYS. A recovered seed's coins can sit on any script type, and nothing here can
|
||||
* tell which until the node has looked — the p2sh account of a wallet that only ever used native segwit
|
||||
* costs one wasted scan, whereas skipping it costs the owner their money. The node serialises
|
||||
* `scantxoutset` anyway, so scanning them sequentially is what happens regardless of what we ask for;
|
||||
* doing it explicitly is what makes `done`/`total` mean something on screen.
|
||||
*
|
||||
* A second call while one is running returns the same handle rather than queueing a duplicate.
|
||||
*/
|
||||
startRescan(accounts: readonly ScanAccount[], opts: RescanOptions = {}): RescanHandle {
|
||||
const existing = this.rescan;
|
||||
if (existing?.running && this.rescanRun) return { state: existing, done: this.rescanRun };
|
||||
|
||||
const state: RescanState = {
|
||||
running: true,
|
||||
done: 0,
|
||||
total: accounts.length,
|
||||
current: accounts[0]?.type ?? null,
|
||||
found: 0,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
error: null,
|
||||
};
|
||||
this.rescan = state;
|
||||
this.rescanRun = this.runRescan(accounts, state, opts);
|
||||
return { state, done: this.rescanRun };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick up a scan that survived a restart of this sidecar.
|
||||
*
|
||||
* `scantxoutset` runs on the node and outlives us; only the handle to it was ever in memory. So the
|
||||
* upstream is asked directly, and if anything is Queued or Pending the run is rebuilt around the same
|
||||
* polling loop with starting suppressed — a duplicate POST would sit behind the real scan for its whole
|
||||
* duration, since the node runs one at a time.
|
||||
*
|
||||
* `found` restarts from zero and a variant that finished before the restart contributes nothing to it:
|
||||
* its result has expired upstream and there is no way to recover the count. The number under-reports
|
||||
* for that one run, which is the honest answer — and the refresh chained on the end is what actually
|
||||
* puts the coins on screen.
|
||||
*/
|
||||
async adoptRescan(accounts: readonly ScanAccount[]): Promise<RescanHandle | null> {
|
||||
if (this.rescan?.running && this.rescanRun) return { state: this.rescan, done: this.rescanRun };
|
||||
|
||||
const live = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
// A probe failing is not an answer either way, and must not be reported as "a scan is running".
|
||||
const status = await this.chain.getUtxoScanStatus(account.accountXpub, account.type).catch(() => null);
|
||||
return status?.status === 'Queued' || status?.status === 'Pending';
|
||||
}),
|
||||
);
|
||||
if (!live.some(Boolean)) return null;
|
||||
|
||||
const state: RescanState = {
|
||||
running: true,
|
||||
done: 0,
|
||||
total: accounts.length,
|
||||
current: accounts[0]?.type ?? null,
|
||||
found: 0,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
error: null,
|
||||
};
|
||||
this.rescan = state;
|
||||
this.rescanRun = this.runRescan(accounts, state, null);
|
||||
return { state, done: this.rescanRun };
|
||||
}
|
||||
|
||||
/** `opts` null means adopt: poll the variants without ever queueing one. */
|
||||
private async runRescan(
|
||||
accounts: readonly ScanAccount[],
|
||||
state: RescanState,
|
||||
opts: RescanOptions | null,
|
||||
): Promise<RescanState> {
|
||||
try {
|
||||
for (const account of accounts) {
|
||||
state.current = account.type;
|
||||
state.found += opts ? await this.scanVariant(account, opts) : await this.awaitVariant(account);
|
||||
state.done += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
// One variant failing stops the run, and the partial `found` stays on the state: coins already
|
||||
// pulled in by an earlier variant are really there, and reporting zero would understate the wallet.
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
state.running = false;
|
||||
state.current = null;
|
||||
state.finishedAt = Date.now();
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Queue one variant's scan and wait it out. Resolves with how many UTXOs it pulled in. */
|
||||
private async scanVariant(account: ScanAccount, opts: RescanOptions): Promise<number> {
|
||||
await this.chain.startUtxoScan(account.accountXpub, account.type, { gapLimit: opts.gapLimit });
|
||||
return this.awaitVariant(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait out a scan that is already queued upstream, without starting one.
|
||||
*
|
||||
* Split out of scanVariant for adoption: a scan this process did not start must never be re-queued,
|
||||
* because scantxoutset is single-threaded node-wide and a duplicate would sit behind the real one for
|
||||
* its whole duration. An account that already finished answers 404 or Complete and returns at once.
|
||||
*/
|
||||
private async awaitVariant(account: ScanAccount): Promise<number> {
|
||||
const { accountXpub, type } = account;
|
||||
|
||||
// The timeout bounds how long a scan may RUN, not how long it may wait its turn. scantxoutset is
|
||||
// single-threaded node-wide, so four variants queue behind each other and the last one could spend
|
||||
// most of its ten minutes not started — timing out while the node was never asked to do anything
|
||||
// wrong. The deadline is therefore pushed forward for as long as the status reads Queued.
|
||||
let deadline = Date.now() + SCAN_TIMEOUT_MS;
|
||||
let found = 0;
|
||||
let seen = false;
|
||||
let nulls = 0;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(SCAN_POLL_MS);
|
||||
const status = await this.chain.getUtxoScanStatus(accountXpub, type);
|
||||
|
||||
// 404 means "no scan is running" — which before the first sighting is a race with the queue, and
|
||||
// after it means the result has expired. Only the second reading is a finish, hence the flag; the
|
||||
// grace bounds the first so a scan the node silently dropped does not hold the run for ten minutes.
|
||||
if (!status) {
|
||||
if (seen) return found;
|
||||
if (++nulls >= SCAN_NULL_GRACE) return found;
|
||||
continue;
|
||||
}
|
||||
|
||||
seen = true;
|
||||
nulls = 0;
|
||||
found = status.progress?.found ?? found;
|
||||
|
||||
if (status.status === 'Queued') deadline = Date.now() + SCAN_TIMEOUT_MS;
|
||||
if (status.status === 'Complete') return found;
|
||||
if (status.status === 'Error') {
|
||||
throw new BackendError(`utxo scan of the ${type} account failed: ${status.error ?? 'unknown'}`, 502, 'SCAN');
|
||||
}
|
||||
}
|
||||
|
||||
throw new BackendError(`utxo scan of the ${type} account did not finish in time`, 504, 'SCAN_TIMEOUT');
|
||||
}
|
||||
|
||||
private async scanAccount(ctx: ScanContext, account: ScanAccount) {
|
||||
const { accountXpub, type } = account;
|
||||
// track() is idempotent and every one of these calls makes it first, so registration cannot be
|
||||
// skipped — which matters because an unregistered scheme answers 200-with-zeros, not 404.
|
||||
const [utxoChanges, history, unusedDeposit, unusedChange] = await Promise.all([
|
||||
this.chain.getUtxos(accountXpub, type),
|
||||
this.chain.getTransactions(accountXpub, type),
|
||||
this.chain.getUnusedAddress(accountXpub, type, 'Deposit'),
|
||||
this.chain.getUnusedAddress(accountXpub, type, 'Change'),
|
||||
]);
|
||||
|
||||
const tipHeight = utxoChanges.currentHeight;
|
||||
|
||||
// A coin already being spent by something in the mempool is not spendable again. NBXplorer still
|
||||
// lists it as a confirmed UTXO, so it has to be excluded explicitly or coin selection would build a
|
||||
// transaction that double-spends the owner's own unconfirmed one.
|
||||
const spentUnconfirmed = new Set(utxoChanges.spentUnconfirmed.map((u) => u.outpoint));
|
||||
|
||||
const confirmed = utxoChanges.confirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint));
|
||||
const unconfirmed = utxoChanges.unconfirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint));
|
||||
|
||||
// The per-address view the rest of the wallet works in. NBXplorer's own answer to "what is unused" is
|
||||
// taken as authoritative for where each chain ends — everything below that index has been seen, which
|
||||
// is what makes it unsafe to hand out again.
|
||||
const highest: Record<ChainIndex, number> = {
|
||||
0: indexOf(unusedDeposit.keyPath),
|
||||
1: indexOf(unusedChange.keyPath),
|
||||
};
|
||||
for (const u of [...confirmed, ...unconfirmed, ...utxoChanges.spentUnconfirmed]) {
|
||||
const at = parseKeyPath(u.keyPath);
|
||||
if (at) highest[at.chain] = Math.max(highest[at.chain], at.index);
|
||||
}
|
||||
|
||||
const sats = new Map<string, { confirmed: number; unconfirmed: number }>();
|
||||
const bump = (u: NbxUtxo, field: 'confirmed' | 'unconfirmed', sign: 1 | -1) => {
|
||||
const cur = sats.get(u.keyPath) ?? { confirmed: 0, unconfirmed: 0 };
|
||||
cur[field] += sign * u.value;
|
||||
sats.set(u.keyPath, cur);
|
||||
};
|
||||
for (const u of confirmed) bump(u, 'confirmed', 1);
|
||||
for (const u of unconfirmed) bump(u, 'unconfirmed', 1);
|
||||
// Matches Esplora's signed mempool delta: an unconfirmed spend of a confirmed coin reads negative,
|
||||
// which is exactly what the Balances contract wants in onchainUnconfirmed.
|
||||
for (const u of utxoChanges.spentUnconfirmed) bump(u, 'unconfirmed', -1);
|
||||
|
||||
const addresses: ScannedAddress[] = [];
|
||||
for (const chain of [0, 1] as ChainIndex[]) {
|
||||
for (let index = 0; index <= highest[chain]; index++) {
|
||||
const entry = ctx.derive(type, chain, index);
|
||||
const held = sats.get(`${chain}/${index}`);
|
||||
addresses.push({
|
||||
...entry,
|
||||
confirmedSats: held?.confirmed ?? 0,
|
||||
unconfirmedSats: held?.unconfirmed ?? 0,
|
||||
// Below the unused mark means NBXplorer has seen this address; the mark itself has not.
|
||||
txCount: index < highest[chain] ? 1 : 0,
|
||||
used: index < highest[chain],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const utxos = [...confirmed, ...unconfirmed].flatMap<SpendableUtxo>((u) => {
|
||||
const at = parseKeyPath(u.keyPath);
|
||||
if (!at) return [];
|
||||
const entry = ctx.derive(type, at.chain, at.index);
|
||||
return [
|
||||
{
|
||||
txid: u.transactionHash,
|
||||
vout: u.index,
|
||||
amountSats: u.value,
|
||||
address: entry.address,
|
||||
addressType: entry.type,
|
||||
confirmations: Math.max(0, u.confirmations),
|
||||
derivationPath: entry.path,
|
||||
frozen: false,
|
||||
scriptPubKeyHex: entry.scriptPubKeyHex,
|
||||
pubkeyHex: entry.pubkeyHex,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const txs = [...history.confirmedTransactions.transactions, ...history.unconfirmedTransactions.transactions];
|
||||
return { tipHeight, addresses, utxos, txs };
|
||||
}
|
||||
|
||||
/**
|
||||
* NBXplorer already scores the transaction for us — `balanceChange` is signed and wallet-wide, which is
|
||||
* precisely what the Esplora source computes by hand from credits and debits.
|
||||
*
|
||||
* The fee is NOT recoverable here, and reporting a wrong one would be worse than reporting none: the
|
||||
* response carries no input values, and fetching every prevout to derive it would reintroduce the
|
||||
* per-transaction fan-out this source exists to avoid. The contract permits null.
|
||||
*/
|
||||
private toOnchainTx(tx: NbxTransaction, byScript: Map<string, ScannedAddress>, tipHeight: number): OnchainTx {
|
||||
const amount = tx.balanceChange;
|
||||
const ours: string[] = [];
|
||||
const theirs: string[] = [];
|
||||
|
||||
const hex = tx.transaction?.trim();
|
||||
if (hex) {
|
||||
try {
|
||||
for (const out of bitcoin.Transaction.fromHex(hex).outs) {
|
||||
const mine = byScript.get(out.script.toString('hex'));
|
||||
if (mine) {
|
||||
ours.push(mine.address);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
theirs.push(bitcoin.address.fromOutputScript(out.script, this.btcNetwork));
|
||||
} catch {
|
||||
// OP_RETURN and other unspendable scripts have no address. Not an error, just not a payee.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A transaction we cannot parse still has a correct amount and height; only the payee list is
|
||||
// lost, and an empty one renders as "unknown" rather than as something false.
|
||||
}
|
||||
}
|
||||
|
||||
const height = tx.confirmations > 0 ? (tx.height ?? null) : null;
|
||||
return {
|
||||
txid: tx.transactionId,
|
||||
amount,
|
||||
feeSats: null,
|
||||
blockHeight: height,
|
||||
timestamp: tx.timestamp,
|
||||
confirmations: Math.max(0, tx.confirmations),
|
||||
label: null,
|
||||
destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours,
|
||||
rawHex: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `"0/7"` → `{ chain: 0, index: 7 }`. Anything else is not an address this wallet derives. */
|
||||
function parseKeyPath(keyPath: string): { chain: ChainIndex; index: number } | null {
|
||||
const parts = keyPath.split('/');
|
||||
const chain = Number(parts.at(-2));
|
||||
const index = Number(parts.at(-1));
|
||||
if ((chain !== 0 && chain !== 1) || !Number.isInteger(index) || index < 0) return null;
|
||||
return { chain, index };
|
||||
}
|
||||
|
||||
/** The address index out of a key path, or 0 when it is unreadable. */
|
||||
function indexOf(keyPath: string): number {
|
||||
return parseKeyPath(keyPath)?.index ?? 0;
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// What a chain source owes the on-chain wallet, and the vocabulary the two halves speak.
|
||||
//
|
||||
// THE SPLIT IS AT THE SCAN, NOT AT THE HTTP CLIENT. This is the whole point of the file and it is not
|
||||
// obvious, so: Esplora is address-level — you ask about one script at a time, and finding a wallet means
|
||||
// walking the gap limit yourself, 43-160 requests a refresh. NBXplorer is wallet-level — you register the
|
||||
// account xpub once and then every question is a single call. There is no common denominator at the
|
||||
// request layer, because NBXplorer simply has no "tell me about this one address" endpoint to implement.
|
||||
//
|
||||
// So the seam is drawn one level up, at `scan()`: "read this wallet off the chain and hand back what you
|
||||
// found". Esplora satisfies it with a gap-limit walk; NBXplorer satisfies it with three calls and no walk
|
||||
// at all. Everything downstream — balances, coin selection, PSBT construction, signing, broadcast — works
|
||||
// off the ScanResult and does not know or care which one answered.
|
||||
//
|
||||
// Deriving addresses is NOT a source's job. The keys belong to the backend, and `ScanContext.derive` is
|
||||
// how a source asks for one. A source that could derive independently could disagree with the wallet
|
||||
// about which addresses are the wallet's, which is the kind of bug that loses coins rather than failing.
|
||||
|
||||
import type { SpendableUtxo } from './psbt';
|
||||
import type { AddressType, FeeEstimates, OnchainTx, RescanOptions, RescanState } from './types';
|
||||
|
||||
/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */
|
||||
export type ChainIndex = 0 | 1;
|
||||
|
||||
/** Hard stop on a runaway scan — a misconfigured xpub against a busy chain must not loop forever. */
|
||||
export const MAX_SCAN_INDEX = 1_000;
|
||||
|
||||
/** Parallel upstream requests. Public Esplora instances rate-limit, so this stays modest. */
|
||||
export const REQUEST_CONCURRENCY = 6;
|
||||
|
||||
/** One address the wallet owns, fully derived. Produced by the backend, consumed by the source. */
|
||||
export type AddressEntry = {
|
||||
type: AddressType;
|
||||
chain: ChainIndex;
|
||||
index: number;
|
||||
address: string;
|
||||
/** Compressed 33-byte pubkey, hex. */
|
||||
pubkeyHex: string;
|
||||
scriptPubKeyHex: string;
|
||||
/** Full path from the wallet root — what the signer derives with. */
|
||||
path: string;
|
||||
/** Path relative to the account xpub, which is what the public Utxo type carries. */
|
||||
relPath: string;
|
||||
};
|
||||
|
||||
/** An address the source looked up, with what the chain says about it. */
|
||||
export type ScannedAddress = AddressEntry & {
|
||||
confirmedSats: number;
|
||||
unconfirmedSats: number;
|
||||
txCount: number;
|
||||
used: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The account keys, in the form a wallet-level source needs them.
|
||||
*
|
||||
* `accountXpub` is the string as stored, which for every BIP this wallet supports is plain `xpub`-version
|
||||
* bytes — NBXplorer's derivation schemes are built straight from it with no SLIP-132 conversion.
|
||||
*/
|
||||
export type ScanAccount = { type: AddressType; accountXpub: string };
|
||||
|
||||
/**
|
||||
* Everything a source is handed to perform one scan. Passed per call rather than held at construction so
|
||||
* a source stays stateless with respect to the wallet — the same source instance is safe to share.
|
||||
*/
|
||||
export type ScanContext = {
|
||||
accounts: readonly ScanAccount[];
|
||||
/** The wallet's own script type. Address-level sources walk this one unconditionally. */
|
||||
defaultType: AddressType;
|
||||
derive: (type: AddressType, chain: ChainIndex, index: number) => AddressEntry;
|
||||
};
|
||||
|
||||
/**
|
||||
* A rescan in flight.
|
||||
*
|
||||
* `state` is the SAME object the source keeps and mutates as the scan progresses, so a caller that holds
|
||||
* it sees the counters move without asking again. `done` is how the wallet knows to re-read the chain:
|
||||
* a rescan that found coins has changed nothing until the snapshot behind it is rebuilt.
|
||||
*/
|
||||
export type RescanHandle = { state: RescanState; done: Promise<RescanState> };
|
||||
|
||||
/** One coherent read of the chain — balances, coins and history as of the same moment. */
|
||||
export type ScanResult = {
|
||||
tipHeight: number;
|
||||
addresses: ScannedAddress[];
|
||||
utxos: SpendableUtxo[];
|
||||
txs: OnchainTx[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The chain, as the on-chain wallet needs it.
|
||||
*
|
||||
* Five methods, and only `scan` is interesting. The other four are point lookups that every backend in
|
||||
* existence offers in some form, so they map one-to-one onto whatever is upstream.
|
||||
*/
|
||||
export interface WalletChainSource {
|
||||
/** Human-readable, for logs and the sync-state surface. e.g. `esplora(mempool.space)`. */
|
||||
readonly label: string;
|
||||
|
||||
/**
|
||||
* How many indices past the highest one `scan` reported this source will still find a payment on.
|
||||
*
|
||||
* Issuance must not outrun the search that finds the money again. `nextUnused` advances a mark every
|
||||
* time an address is handed out, so a run of addresses issued and never paid walks the mark forward
|
||||
* with nothing marking those indices used — and a later payment to one of them lands beyond the
|
||||
* window the next scan covers. On a source that cannot rescan, that is an invisible, unrecoverable
|
||||
* balance, from nothing worse than clicking "new address" too many times.
|
||||
*
|
||||
* Esplora is 0: its walk already extends a full gap limit past the last used address, so every index
|
||||
* `scan` returned is covered and none beyond it is. NBXplorer reports only up to its own unused mark
|
||||
* but watches a gap beyond it, so a modest overrun is still seen.
|
||||
*/
|
||||
readonly issueAhead: number;
|
||||
|
||||
/** Current best block height. The cheapest liveness probe the wallet has. */
|
||||
getTipHeight(): Promise<number>;
|
||||
|
||||
getFeeEstimates(): Promise<FeeEstimates>;
|
||||
|
||||
/**
|
||||
* Raw hex of one transaction. Called only for `p2pkh` inputs, which commit to the whole previous
|
||||
* transaction and so need it in the PSBT; segwit inputs carry their own value and script.
|
||||
*/
|
||||
getTxHex(txid: string): Promise<string>;
|
||||
|
||||
/** Publish a signed transaction. Resolves to the txid, throws if the network rejected it. */
|
||||
broadcast(rawHex: string): Promise<string>;
|
||||
|
||||
/** Read the whole wallet off the chain. */
|
||||
scan(ctx: ScanContext): Promise<ScanResult>;
|
||||
|
||||
/**
|
||||
* Search the chain for this wallet's coins from scratch, rather than from whenever the upstream
|
||||
* started watching it. Returns immediately — a rescan takes minutes, and the caller is an HTTP route.
|
||||
*
|
||||
* `opts.gapLimit` widens the search past the source's own default, for a wallet restored from one that
|
||||
* issued addresses in bulk. Ignored by a source with no such knob.
|
||||
*
|
||||
* Optional, and Esplora does not implement it — it has nothing to rescan, because a gap-limit walk
|
||||
* already asks about every address every time. This exists for an upstream that *indexes*, where a
|
||||
* newly registered account starts empty and stays empty until told to go and look.
|
||||
*/
|
||||
startRescan?(accounts: readonly ScanAccount[], opts?: RescanOptions): RescanHandle;
|
||||
|
||||
/**
|
||||
* Find a scan already in flight upstream and return a handle to it, without starting one.
|
||||
*
|
||||
* Same handle contract as `startRescan`, so the caller chains its refresh onto `done` identically —
|
||||
* which is the point: an adopted scan has to end in a snapshot rebuild too, or the coins it recovered
|
||||
* stay invisible. Null when nothing is running.
|
||||
*/
|
||||
adoptRescan?(accounts: readonly ScanAccount[]): Promise<RescanHandle | null>;
|
||||
|
||||
/** The rescan in flight, or the last one's outcome. Null when this source has never run one. */
|
||||
rescanState?(): RescanState | null;
|
||||
}
|
||||
|
||||
/** Bounded-concurrency map that preserves input order. Address-level sources fan out wide. */
|
||||
export async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let cursor = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const i = cursor++;
|
||||
if (i >= items.length) return;
|
||||
const item = items[i];
|
||||
if (item === undefined) continue;
|
||||
out[i] = await fn(item);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// Esplora REST client — the chain data source for the native on-chain wallet backend.
|
||||
//
|
||||
// Zeus's embedded backends (EmbeddedLND, LdkNode) get chain data from a node bound to a React Native
|
||||
// native module. Nothing about that ports to a Bun server process, so the server-side wallet reads the
|
||||
// chain over Esplora's HTTP API instead: mempool.space, blockstream.info, or a self-hosted esplora.
|
||||
// Every call is a plain unauthenticated GET, which is why the backend can serve watch-only requests
|
||||
// without ever touching key material.
|
||||
//
|
||||
// Two Esplora endpoints are NOT JSON and are handled explicitly below:
|
||||
// GET /tx/{txid}/hex → the raw transaction as a bare hex string
|
||||
// POST /tx → request body is bare hex, response body is the bare txid
|
||||
// Everything else is JSON, and every wire shape it returns is typed in this file.
|
||||
|
||||
import { BackendError, type BitcoinNetwork, type FeeEstimates } from './types';
|
||||
|
||||
// ── configuration ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EsploraConfig = {
|
||||
/** Base URL of the Esplora API root, e.g. `https://mempool.space/api`. Trailing slashes are trimmed. */
|
||||
baseUrl: string;
|
||||
network: BitcoinNetwork;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
// ── wire shapes ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Confirmation status shared by /tx, /address/{a}/txs and /address/{a}/utxo. */
|
||||
export type EsploraTxStatus = {
|
||||
confirmed: boolean;
|
||||
block_height?: number;
|
||||
block_hash?: string;
|
||||
/** Unix seconds of the containing block. Absent while unconfirmed. */
|
||||
block_time?: number;
|
||||
};
|
||||
|
||||
/** Aggregate funded/spent counters. Esplora reports one set for the chain and one for the mempool. */
|
||||
export type EsploraAddressStats = {
|
||||
funded_txo_count: number;
|
||||
funded_txo_sum: number;
|
||||
spent_txo_count: number;
|
||||
spent_txo_sum: number;
|
||||
tx_count: number;
|
||||
};
|
||||
|
||||
export type EsploraAddress = {
|
||||
address: string;
|
||||
chain_stats: EsploraAddressStats;
|
||||
mempool_stats: EsploraAddressStats;
|
||||
};
|
||||
|
||||
export type EsploraVout = {
|
||||
/** scriptPubKey, hex. */
|
||||
scriptpubkey: string;
|
||||
scriptpubkey_asm: string;
|
||||
/** Esplora's own classification: 'v0_p2wpkh' | 'v1_p2tr' | 'p2pkh' | 'p2sh' | 'op_return' | … */
|
||||
scriptpubkey_type: string;
|
||||
scriptpubkey_address?: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type EsploraVin = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
/** The output being spent. Null for coinbase inputs. */
|
||||
prevout: EsploraVout | null;
|
||||
scriptsig: string;
|
||||
scriptsig_asm: string;
|
||||
witness?: string[];
|
||||
is_coinbase: boolean;
|
||||
sequence: number;
|
||||
inner_redeemscript_asm?: string;
|
||||
inner_witnessscript_asm?: string;
|
||||
};
|
||||
|
||||
export type EsploraTx = {
|
||||
txid: string;
|
||||
version: number;
|
||||
locktime: number;
|
||||
vin: EsploraVin[];
|
||||
vout: EsploraVout[];
|
||||
size: number;
|
||||
weight: number;
|
||||
/** Absolute fee in sats. Zero for coinbase. */
|
||||
fee: number;
|
||||
status: EsploraTxStatus;
|
||||
};
|
||||
|
||||
export type EsploraUtxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
value: number;
|
||||
status: EsploraTxStatus;
|
||||
};
|
||||
|
||||
/** /fee-estimates: confirmation target (in blocks, as a string key) → sat/vB, as a float. */
|
||||
export type EsploraFeeEstimates = Record<string, number>;
|
||||
|
||||
// ── fee mapping ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Esplora publishes an estimate per confirmation target; FeeEstimates in types.ts is mempool.space's
|
||||
// five named tiers. These are the targets each tier maps onto.
|
||||
const FEE_TARGETS = {
|
||||
fastestFee: 1,
|
||||
halfHourFee: 3,
|
||||
hourFee: 6,
|
||||
economyFee: 144,
|
||||
minimumFee: 1008,
|
||||
} as const satisfies Record<keyof FeeEstimates, number>;
|
||||
|
||||
/** Nothing below the default minimum relay fee will propagate, so 1 sat/vB is the hard floor. */
|
||||
const MIN_SAT_VB = 1;
|
||||
|
||||
/**
|
||||
* Pick the estimate for a confirmation target. Esplora only publishes some targets (1..25, then 144,
|
||||
* 504, 1008), so when the exact key is missing we take the largest published target *at or below* the
|
||||
* one asked for — a shorter target always quotes a higher rate, so this errs towards confirming.
|
||||
*/
|
||||
function pickForTarget(raw: EsploraFeeEstimates, target: number): number | null {
|
||||
const exact = raw[String(target)];
|
||||
if (typeof exact === 'number' && Number.isFinite(exact)) return exact;
|
||||
|
||||
let best: number | null = null;
|
||||
let bestKey = -1;
|
||||
for (const [key, rate] of Object.entries(raw)) {
|
||||
const k = Number(key);
|
||||
if (!Number.isFinite(k) || !Number.isFinite(rate)) continue;
|
||||
if (k <= target && k > bestKey) {
|
||||
bestKey = k;
|
||||
best = rate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Esplora's target→rate map onto the five named tiers. Exported separately from the client so the
|
||||
* mapping can be unit-tested against a captured /fee-estimates payload with no network involved.
|
||||
*/
|
||||
export function mapFeeEstimates(raw: EsploraFeeEstimates): FeeEstimates {
|
||||
const at = (target: number): number => Math.max(MIN_SAT_VB, Math.ceil(pickForTarget(raw, target) ?? MIN_SAT_VB));
|
||||
|
||||
const fastestFee = at(FEE_TARGETS.fastestFee);
|
||||
// A tier must never quote more than a shorter-target tier: some esplora deployments serve a stale or
|
||||
// partial map where a longer target reads higher, and a UI that shows "1 hour" above "fastest" is a
|
||||
// bug report. Clamp each tier down to its faster neighbour.
|
||||
const halfHourFee = Math.min(fastestFee, at(FEE_TARGETS.halfHourFee));
|
||||
const hourFee = Math.min(halfHourFee, at(FEE_TARGETS.hourFee));
|
||||
const economyFee = Math.min(hourFee, at(FEE_TARGETS.economyFee));
|
||||
const minimumFee = Math.min(economyFee, at(FEE_TARGETS.minimumFee));
|
||||
|
||||
return { fastestFee, halfHourFee, hourFee, economyFee, minimumFee };
|
||||
}
|
||||
|
||||
// ── the client ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RequestInitLite = {
|
||||
method?: 'GET' | 'POST';
|
||||
/** Sent verbatim as the body — Esplora's POST /tx takes bare hex, not JSON. */
|
||||
body?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export class EsploraChain {
|
||||
readonly network: BitcoinNetwork;
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(cfg: EsploraConfig) {
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
|
||||
this.network = cfg.network;
|
||||
this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** Every request goes through here so timeouts and upstream errors become one BackendError shape. */
|
||||
private async text(path: string, init: RequestInitLite = {}): Promise<string> {
|
||||
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: init.method ?? 'GET',
|
||||
body: init.body,
|
||||
headers: init.contentType ? { 'Content-Type': init.contentType } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err);
|
||||
throw new BackendError(`esplora request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const body = await res.text();
|
||||
if (!res.ok) {
|
||||
// Esplora reports every failure as plain text: 'Transaction not found', 'sendrawtransaction RPC
|
||||
// error: {"code":-26,...}'. There is no JSON envelope to unwrap, so the text is the message.
|
||||
throw new BackendError(`esplora ${res.status} on ${path}: ${body.slice(0, 300)}`, res.status, 'ESPLORA_ERROR');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private async json<T>(path: string): Promise<T> {
|
||||
const body = await this.text(path);
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch {
|
||||
throw new BackendError(`esplora returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// ── endpoints ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /address/{addr} — funded/spent counters for the chain and the mempool. */
|
||||
getAddress(address: string): Promise<EsploraAddress> {
|
||||
return this.json<EsploraAddress>(`/address/${encodeURIComponent(address)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /address/{addr}/txs — the newest ~50 transactions (all mempool ones, then 25 confirmed).
|
||||
* Pass `afterTxid` to page further back through confirmed history via /txs/chain/{last_seen_txid}.
|
||||
*/
|
||||
getAddressTxs(address: string, afterTxid?: string): Promise<EsploraTx[]> {
|
||||
const addr = encodeURIComponent(address);
|
||||
const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`;
|
||||
return this.json<EsploraTx[]>(path);
|
||||
}
|
||||
|
||||
/** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */
|
||||
getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
|
||||
return this.json<EsploraUtxo[]>(`/address/${encodeURIComponent(address)}/utxo`);
|
||||
}
|
||||
|
||||
/** GET /tx/{txid}. */
|
||||
getTx(txid: string): Promise<EsploraTx> {
|
||||
return this.json<EsploraTx>(`/tx/${encodeURIComponent(txid)}`);
|
||||
}
|
||||
|
||||
/** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */
|
||||
async getTxHex(txid: string): Promise<string> {
|
||||
const hex = (await this.text(`/tx/${encodeURIComponent(txid)}/hex`)).trim();
|
||||
if (!/^[0-9a-fA-F]+$/.test(hex)) {
|
||||
throw new BackendError(`esplora returned a non-hex body for tx ${txid}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/** GET /blocks/tip/height — plain integer in the body. */
|
||||
async getTipHeight(): Promise<number> {
|
||||
const body = (await this.text('/blocks/tip/height')).trim();
|
||||
const height = Number(body);
|
||||
if (!Number.isInteger(height) || height < 0) {
|
||||
throw new BackendError(`esplora returned a bad tip height: ${body.slice(0, 60)}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
/** GET /fee-estimates, folded onto the five named tiers. */
|
||||
async getFeeEstimates(): Promise<FeeEstimates> {
|
||||
return mapFeeEstimates(await this.json<EsploraFeeEstimates>('/fee-estimates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tx — the body is the raw transaction hex with no JSON wrapper, and the 200 response body is
|
||||
* the bare txid. A rejection comes back as a 400 with bitcoind's `sendrawtransaction RPC error` text,
|
||||
* which `text()` above surfaces verbatim; that message is the only diagnosis a caller gets.
|
||||
*/
|
||||
async broadcast(rawHex: string): Promise<string> {
|
||||
const txid = (await this.text('/tx', { method: 'POST', body: rawHex, contentType: 'text/plain' })).trim();
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(txid)) {
|
||||
throw new BackendError(`broadcast did not return a txid: ${txid.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
return txid;
|
||||
}
|
||||
}
|
||||
|
||||
/** Alias kept for call sites that read better as a client than as a chain source. */
|
||||
export { EsploraChain as EsploraClient };
|
||||
@@ -1,200 +0,0 @@
|
||||
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
|
||||
import { invalidateAll } from './resolve';
|
||||
import type { ChainKind } from './upstream';
|
||||
import { CHAIN_KINDS, getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream';
|
||||
import type { BitcoinNetwork } from './types';
|
||||
|
||||
// `/_config` — which endpoint this owner's on-chain wallets read the chain from, and which protocol it
|
||||
// speaks.
|
||||
//
|
||||
// This is the one wallet setting that is NOT per-wallet and NOT key material: an endpoint serves every
|
||||
// on-chain wallet the owner has, and it holds no secret, because both supported kinds are unauthenticated
|
||||
// read APIs. It went from an env var to a row for one reason — it is the setting most likely to need
|
||||
// changing, and the reason to change it (a public explorer rate-limiting you, or seeing every address you
|
||||
// own) shows up long after the deployment that set it.
|
||||
//
|
||||
// TWO KINDS, ONE ROW. Esplora and NBXplorer are stored as separate services but are mutually exclusive:
|
||||
// saving one deletes the other, so "which endpoint is in use" never depends on precedence. See
|
||||
// upstream.ts::getChainSource.
|
||||
//
|
||||
// PRIVACY, NOT CUSTODY. A chain endpoint learns which addresses you ask about, so pointing this at your
|
||||
// own indexer is a real privacy upgrade. It is never a custody risk: the endpoint sees no key, and a
|
||||
// hostile one can lie about balances but cannot move a coin.
|
||||
|
||||
export type ChainProbe = { ok: boolean; blockHeight?: number | null; error?: string; ms?: number };
|
||||
|
||||
/** What each kind reports itself as, versus what this deployment is configured for. */
|
||||
const NETWORK_ALIASES: Record<BitcoinNetwork, readonly string[]> = {
|
||||
bitcoin: ['mainnet', 'main', 'bitcoin'],
|
||||
testnet: ['testnet', 'test'],
|
||||
signet: ['signet'],
|
||||
regtest: ['regtest'],
|
||||
};
|
||||
|
||||
/** Does this endpoint answer, does it speak the protocol we think, and is it on OUR chain? */
|
||||
export async function probe(baseUrl: string, kind: ChainKind): Promise<ChainProbe> {
|
||||
return kind === 'nbxplorer' ? probeNbxplorer(baseUrl) : probeEsplora(baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/blocks/tip/height` is the cheapest endpoint Esplora has and the one every implementation has — a
|
||||
* bare number in the body. Anything else answering (a bitcoind RPC port, a mempool.space *web* root, an
|
||||
* nginx default page) fails here rather than at the first address scan, which is the whole point: the
|
||||
* owner finds out while looking at the form.
|
||||
*
|
||||
* Esplora does not say which chain it serves, so unlike NBXplorer there is nothing to cross-check here.
|
||||
*/
|
||||
async function probeEsplora(baseUrl: string): Promise<ChainProbe> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/blocks/tip/height`, {
|
||||
headers: { Accept: 'text/plain' },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
redirect: 'manual',
|
||||
});
|
||||
const body = (await res.text()).trim();
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started };
|
||||
}
|
||||
const height = Number(body);
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `that URL answered, but not like an Esplora API (expected a block height, got "${body.slice(0, 60)}")`,
|
||||
ms: Date.now() - started,
|
||||
};
|
||||
}
|
||||
return { ok: true, blockHeight: height, ms: Date.now() - started };
|
||||
} catch (err) {
|
||||
return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/v1/cryptos/BTC/status` — NBXplorer's own liveness endpoint, and richer than Esplora's.
|
||||
*
|
||||
* It names the chain it is indexing, so a testnet NBXplorer accepted for a mainnet wallet is caught HERE
|
||||
* rather than by the owner wondering why their balance is zero. That mistake is easy to make (one docker
|
||||
* flag) and expensive to discover late, so the mismatch is a hard failure, not a warning.
|
||||
*
|
||||
* A node still catching up is allowed through with its height reported: it will be correct shortly, and
|
||||
* refusing to save a config because of a temporary sync state would be its own kind of wrong.
|
||||
*/
|
||||
async function probeNbxplorer(baseUrl: string): Promise<ChainProbe> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/v1/cryptos/BTC/status`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
redirect: 'manual',
|
||||
});
|
||||
const body = (await res.text()).trim();
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started };
|
||||
}
|
||||
|
||||
let status: { chainHeight?: unknown; networkType?: unknown; isFullySynched?: unknown };
|
||||
try {
|
||||
status = JSON.parse(body) as typeof status;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: `that URL answered, but not like NBXplorer (expected JSON, got "${body.slice(0, 60)}")`,
|
||||
ms: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const height = Number(status.chainHeight);
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return { ok: false, error: 'that endpoint answered but reported no chain height', ms: Date.now() - started };
|
||||
}
|
||||
|
||||
const want = getConfig().network;
|
||||
const got = String(status.networkType ?? '').toLowerCase();
|
||||
if (got && !NETWORK_ALIASES[want].includes(got)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `that NBXplorer is indexing ${got}, but this wallet is on ${want}. Refusing to point a ${want} wallet at a ${got} node.`,
|
||||
ms: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, blockHeight: height, ms: Date.now() - started };
|
||||
} catch (err) {
|
||||
return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started };
|
||||
}
|
||||
}
|
||||
|
||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
|
||||
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
||||
|
||||
/**
|
||||
* The endpoint in use, which protocol it speaks, and whether it is the owner's or the built-in default.
|
||||
* No secrets exist here at all.
|
||||
*
|
||||
* `esploraUrl` is kept as a field name despite now possibly holding an NBXplorer URL: it is the URL in
|
||||
* use, and renaming it would break the stored dashboard state of anything already reading it. `kind`
|
||||
* says what it actually is.
|
||||
*/
|
||||
async function state(userId: number): Promise<Response> {
|
||||
const source = await getChainSource(userId);
|
||||
const connection = source.custom ? await getServiceConnection(userId, source.kind) : null;
|
||||
return Response.json({
|
||||
configured: !!connection,
|
||||
connection,
|
||||
kind: source.kind,
|
||||
esploraUrl: source.baseUrl,
|
||||
custom: source.custom,
|
||||
defaultUrl: getConfig().esploraUrl,
|
||||
network: getConfig().network,
|
||||
});
|
||||
}
|
||||
|
||||
type ConfigBody = { url?: unknown; kind?: unknown };
|
||||
|
||||
const isChainKind = (v: unknown): v is ChainKind =>
|
||||
typeof v === 'string' && (CHAIN_KINDS as readonly string[]).includes(v);
|
||||
|
||||
async function save(req: Request, userId: number): Promise<Response> {
|
||||
const body = ((await req.json().catch(() => null)) as ConfigBody | null) ?? {};
|
||||
const url = typeof body.url === 'string' ? normalizeEsploraUrl(body.url) : '';
|
||||
// Absent kind means a client written before the picker existed; those only ever spoke Esplora.
|
||||
const kind: ChainKind = isChainKind(body.kind) ? body.kind : 'esplora';
|
||||
|
||||
if (!url) return bad('url is required');
|
||||
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||
|
||||
const result = await probe(url, kind);
|
||||
if (!result.ok) return bad(result.error ?? 'that endpoint did not answer');
|
||||
|
||||
await saveServiceConnection({ userId, service: kind, url });
|
||||
// Exactly one chain source exists at a time. Retiring the other kind here is what lets getChainSource
|
||||
// answer without a precedence rule, and what stops a stale row from resurfacing if this one is deleted.
|
||||
for (const other of CHAIN_KINDS) if (other !== kind) await deleteServiceConnection(userId, other);
|
||||
|
||||
invalidateChainSource(userId);
|
||||
// Every on-chain backend holds a chain source built from the old URL, and a cached gap-limit scan taken
|
||||
// through it. Both are now wrong, so drop the instances rather than trying to patch them.
|
||||
invalidateAll();
|
||||
|
||||
return Response.json({ ...(await state(userId).then((r) => r.json())), probe: result });
|
||||
}
|
||||
|
||||
/** `/_config` — GET what is in use, PUT to change it, DELETE to fall back to the built-in default. */
|
||||
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
||||
if (subpath && subpath !== '/') return bad('not found', 404);
|
||||
|
||||
if (req.method === 'GET') return state(userId);
|
||||
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
// Both, not just the active one: "reset to default" has to mean it regardless of which row exists.
|
||||
for (const kind of CHAIN_KINDS) await deleteServiceConnection(userId, kind);
|
||||
invalidateChainSource(userId);
|
||||
invalidateAll();
|
||||
return state(userId);
|
||||
}
|
||||
|
||||
return bad('method not allowed', 405);
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import type { ChainSource } from './upstream';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleConfigRoute, probe } from './config';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { getChainSource, getConfig, hasStoreKey } from './upstream';
|
||||
import { lockAll } from './keys';
|
||||
import { invalidateAll } from './resolve';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
|
||||
// The officer-wallet sidecar. A bitcoin wallet in the shape Zeus models one — several interchangeable
|
||||
// backends behind one interface — but server-side, with the key material held here and nowhere else.
|
||||
//
|
||||
// WHY THIS PROCESS EXISTS SEPARATELY. Seeds and node credentials never enter the main Officer process.
|
||||
// The platform is a thin auth proxy (src/servers/api/wallet/router.ts) that forwards to this port and
|
||||
// holds nothing: no seed, no macaroon, no xpub. Compromising `officer` gets an attacker the ability to
|
||||
// *call* this sidecar as the authenticated owner — it does not get them a key, and it cannot spend from a
|
||||
// locked wallet, because a locked wallet has no key material in memory at all.
|
||||
//
|
||||
// BACKENDS (mirroring _references/zeus/backends/):
|
||||
// onchain — self-custodial. BIP39 seed sealed under an owner passphrase (keys.ts), BIP84/86/49
|
||||
// derivation, Esplora for chain data, bitcoinjs-lib for PSBT construction. This is the
|
||||
// server analogue of Zeus's EmbeddedLND/LdkNode, which are native-module-bound and cannot
|
||||
// be ported. Watch-only while locked; unlock only to sign.
|
||||
// lnd — LND REST + macaroon (ported from backends/LND.ts)
|
||||
// cln-rest — Core Lightning CLNRest + rune (ported from backends/CLNRest.ts)
|
||||
// lndhub — custodial LNDHub/BlueWallet REST (ported from backends/LndHub.ts)
|
||||
// nwc — Nostr Wallet Connect, NIP-47 (ported from backends/NostrWalletConnect.ts)
|
||||
//
|
||||
// When the owner runs their own node, it registers as an `lnd` or `cln-rest` wallet — no code change.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding.
|
||||
//
|
||||
// GET /_health ours. Reports network, chain reachability, store key.
|
||||
// GET /_config the owner's chain endpoint (or the built-in default)
|
||||
// PUT /_config {url, kind} — probed as that kind before it is stored
|
||||
// DELETE /_config fall back to the built-in default
|
||||
// GET /_officer/config network + unlock TTL + storeKeyConfigured
|
||||
//
|
||||
// GET /_officer/wallets list. Never includes secrets.
|
||||
// POST /_officer/wallets create. Seeded wallets return the mnemonic ONCE,
|
||||
// and only when the sidecar generated it.
|
||||
// GET /_officer/wallets/active the currently selected wallet
|
||||
// GET /_officer/wallets/:id one wallet
|
||||
// PATCH /_officer/wallets/:id rename / defaultBip / config
|
||||
// DELETE /_officer/wallets/:id {passphrase} required when the wallet holds a seed
|
||||
// POST /_officer/wallets/:id/activate
|
||||
//
|
||||
// GET /_officer/wallets/:id/lock-state {hasSeed, unlocked, secondsRemaining}
|
||||
// POST /_officer/wallets/:id/unlock {passphrase, ttlSec?}
|
||||
// POST /_officer/wallets/:id/lock
|
||||
// POST /_officer/wallets/:id/passphrase {oldPassphrase, newPassphrase}
|
||||
// POST /_officer/wallets/:id/export-seed {passphrase} → the mnemonic. Logged as a warning.
|
||||
//
|
||||
// GET /_officer/wallets/:id/capabilities what this backend can actually do
|
||||
// GET /_officer/wallets/:id/info node/chain identity + sync state
|
||||
// GET /_officer/wallets/:id/balances on-chain confirmed/unconfirmed + lightning local/inbound
|
||||
// GET /_officer/wallets/:id/transactions?limit on-chain history, owner labels overlaid
|
||||
// GET /_officer/wallets/:id/address?peek fresh receive address
|
||||
// GET /_officer/wallets/:id/utxos coin control view, freeze flags + labels overlaid
|
||||
// POST /_officer/wallets/:id/utxos/freeze {outpoint, frozen, reason?}
|
||||
// GET /_officer/wallets/:id/fees sat/vB estimates
|
||||
// POST /_officer/wallets/:id/send on-chain spend. Requires an unlocked wallet.
|
||||
// GET /_officer/wallets/:id/invoices?limit
|
||||
// POST /_officer/wallets/:id/invoices create
|
||||
// GET /_officer/wallets/:id/invoices/:hash lookup
|
||||
// POST /_officer/wallets/:id/decode {bolt11}
|
||||
// GET /_officer/wallets/:id/payments?limit
|
||||
// POST /_officer/wallets/:id/pay {bolt11, amountMsat?, feeLimit…}
|
||||
// POST /_officer/wallets/:id/keysend {destination, amountMsat}
|
||||
// GET /_officer/wallets/:id/channels
|
||||
// GET /_officer/wallets/:id/peers
|
||||
// POST /_officer/wallets/:id/sign {message}
|
||||
// POST /_officer/wallets/:id/verify {message, signature}
|
||||
// GET|POST /_officer/wallets/:id/labels owner annotations for addresses and txids
|
||||
//
|
||||
// anything else 404
|
||||
//
|
||||
// Operations a backend cannot perform return 501 with code NOT_SUPPORTED, checked against its declared
|
||||
// capability set before dispatch — never a confusing upstream error. A locked wallet returns 423
|
||||
// WALLET_LOCKED from signing paths only; every read above keeps working.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const p = probeServer.port;
|
||||
probeServer.stop(true);
|
||||
if (p == null) throw new Error('failed to acquire a free port');
|
||||
return p;
|
||||
}
|
||||
|
||||
const port = getFreePort();
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
// Wallet payloads are small — PSBTs and invoices, never file uploads. A tight cap is free hardening.
|
||||
maxRequestBodySize: 1 * 1024 * 1024,
|
||||
// Bun's default is 10s, which is SHORTER than the Esplora client's own 20s per-request timeout. That
|
||||
// inversion is why a slow or unreachable upstream showed up as a wallet with no data at all rather than
|
||||
// as an error: Bun killed the response before the scan could either finish or report why it hadn't.
|
||||
// A gap-limit scan is many sequential rounds, so the ceiling has to sit above the whole walk, not one
|
||||
// request. Bun caps this at 255s; every other sidecar already uses that.
|
||||
idleTimeout: 255,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// `/_config` is the chain endpoint, which is per-owner — so unlike /_health it needs the header.
|
||||
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
const userId = Number(officerUser);
|
||||
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
||||
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
|
||||
} catch (err) {
|
||||
console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
const cfg = getConfig();
|
||||
// Health has to probe the endpoint the wallets ACTUALLY use, not the built-in default — reporting
|
||||
// the default green while the owner's own indexer is down would be worse than no health at all.
|
||||
// The header is optional here (health predates it), so fall back rather than 401.
|
||||
const healthUser = Number(req.headers.get('X-Officer-User'));
|
||||
const chain: ChainSource =
|
||||
Number.isInteger(healthUser) && healthUser > 0
|
||||
? await getChainSource(healthUser)
|
||||
: { kind: 'esplora', baseUrl: cfg.esploraUrl, custom: false };
|
||||
|
||||
// Same probe the config form runs, so "saved successfully" and "healthy" can never disagree about
|
||||
// what a working endpoint is — including the network cross-check on NBXplorer.
|
||||
const result = await probe(chain.baseUrl, chain.kind);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
ok: result.ok,
|
||||
network: cfg.network,
|
||||
kind: chain.kind,
|
||||
esplora: chain.baseUrl,
|
||||
custom: chain.custom,
|
||||
blockHeight: result.blockHeight ?? null,
|
||||
...(result.error ? { error: result.error } : {}),
|
||||
// Surfaced because wallet creation is refused without it, and that failure would otherwise
|
||||
// look like a bug rather than a missing config line.
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
ms: result.ms,
|
||||
},
|
||||
{ status: result.ok ? 200 : 502 },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
const res = await handleOfficerRoute(req, url);
|
||||
if (res) return res;
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
} catch (err) {
|
||||
// Method and path only. Bodies on this sidecar carry passphrases and mnemonics.
|
||||
console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// No esplora URL in the banner: it is per-owner state read from the database now, not a boot constant.
|
||||
console.log(`[wallet] listening on 127.0.0.1:${port} — network=${getConfig().network}`);
|
||||
if (!hasStoreKey()) {
|
||||
console.warn('[wallet] the secret store is unreachable — wallet creation will be refused until it is');
|
||||
}
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'wallet',
|
||||
handles: ['wallet'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
connection.send({ type: 'wallet:server', port });
|
||||
console.log(`[wallet] reported server port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[wallet] ${signal} received, locking all wallets and shutting down...`);
|
||||
// Wipe key material before anything else. This is best-effort — see the caveat in keys.ts — but it
|
||||
// costs nothing and closes the obvious window on a graceful restart.
|
||||
lockAll();
|
||||
invalidateAll();
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -1,323 +0,0 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
sealSeed,
|
||||
generateSeed,
|
||||
deriveAccountXpubs,
|
||||
UnlockSession,
|
||||
verifyPassphrase,
|
||||
exportMnemonic,
|
||||
changePassphrase,
|
||||
} from './keys';
|
||||
|
||||
/**
|
||||
* The seed custody core. This is the one file in the wallet where a bug is unrecoverable rather than
|
||||
* merely wrong — a mistake here either loses the owner's coins or leaks the key that spends them, so the
|
||||
* properties below are asserted rather than assumed.
|
||||
*
|
||||
* scrypt at N=2^17 makes each seal/open deliberately expensive (~1s), which is the entire point of the
|
||||
* parameter and also why this file carries a raised timeout instead of a smaller N.
|
||||
*/
|
||||
|
||||
// BIP39's canonical all-`abandon` vector, and the BIP32 root fingerprint it must produce.
|
||||
const VECTOR = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const ROOT_FINGERPRINT = '73c5da0a';
|
||||
const PASS = 'correct horse battery staple';
|
||||
|
||||
const SLOW = 60_000;
|
||||
|
||||
describe('sealSeed / deriveAccountXpubs', () => {
|
||||
test(
|
||||
'seals the vector and derives all four accounts',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
||||
|
||||
expect(env.v).toBe(1);
|
||||
expect(fingerprint).toBe(ROOT_FINGERPRINT);
|
||||
for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'the envelope never contains the plaintext mnemonic',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
expect(JSON.stringify(env)).not.toContain('abandon');
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'sealing is non-deterministic but derivation is stable',
|
||||
async () => {
|
||||
// Fresh salt and nonce per seal, so two seals of one seed must not be byte-identical — otherwise a
|
||||
// DB dump would reveal which wallets share a seed.
|
||||
const [a, b] = [await sealSeed(VECTOR, PASS), await sealSeed(VECTOR, PASS)];
|
||||
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b));
|
||||
|
||||
const [xa, xb] = [await deriveAccountXpubs(a, PASS, 'bitcoin'), await deriveAccountXpubs(b, PASS, 'bitcoin')];
|
||||
expect(xb.xpubs[84]).toBe(xa.xpubs[84]);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rejects a mnemonic that fails its checksum',
|
||||
async () => {
|
||||
const bad = VECTOR.replace(/about$/, 'abandon');
|
||||
await expect(sealSeed(bad, PASS)).rejects.toThrow(/BIP39/i);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rejects a passphrase under the minimum length',
|
||||
async () => {
|
||||
await expect(sealSeed(VECTOR, 'short')).rejects.toThrow(/8 characters/);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'testnet derives a different account under the same root',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const [main, test_] = [
|
||||
await deriveAccountXpubs(env, PASS, 'bitcoin'),
|
||||
await deriveAccountXpubs(env, PASS, 'testnet'),
|
||||
];
|
||||
// Coin type 1' vs 0' — a different account, but the same seed, so the same root fingerprint.
|
||||
expect(test_.xpubs[84]).not.toBe(main.xpubs[84]);
|
||||
expect(test_.fingerprint).toBe(main.fingerprint);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A throwaway wallet id per call. Every passphrase check is now rate-limited per wallet id in module
|
||||
* state, so tests that share one would start locking each other out after five wrong guesses.
|
||||
*/
|
||||
let idSeq = 0;
|
||||
const wid = () => ++idSeq;
|
||||
|
||||
describe('passphrase handling', () => {
|
||||
test(
|
||||
'accepts the right passphrase and rejects a wrong one',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
expect(await verifyPassphrase(wid(), env, PASS)).toBe(true);
|
||||
expect(await verifyPassphrase(wid(), env, 'not the passphrase')).toBe(false);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'locks out after five wrong guesses, on export as well as unlock',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const id = wid();
|
||||
|
||||
// The backoff used to live inside UnlockSession.unlock only, so export-seed — which returns the
|
||||
// words in the clear — took unlimited guesses. Exercised through exportMnemonic for that reason.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await expect(exportMnemonic(id, env, `wrong ${i}`)).rejects.toThrow(/incorrect passphrase/);
|
||||
}
|
||||
await expect(exportMnemonic(id, env, `wrong 5`)).rejects.toThrow(/too many failed attempts/);
|
||||
// The right passphrase is refused too — otherwise the lockout would only slow a guesser down.
|
||||
await expect(exportMnemonic(id, env, PASS)).rejects.toThrow(/too many failed attempts/);
|
||||
// And it is not folded into a bland `false` by the boolean-returning sibling.
|
||||
await expect(verifyPassphrase(id, env, PASS)).rejects.toThrow(/too many failed attempts/);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'round-trips the mnemonic exactly',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
expect(await exportMnemonic(wid(), env, PASS)).toBe(VECTOR);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rotation swaps the passphrase without disturbing the seed',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const next = 'an entirely different passphrase';
|
||||
const rotated = await changePassphrase(wid(), env, PASS, next);
|
||||
|
||||
expect(await verifyPassphrase(wid(), rotated, next)).toBe(true);
|
||||
expect(await verifyPassphrase(wid(), rotated, PASS)).toBe(false);
|
||||
expect(await exportMnemonic(wid(), rotated, next)).toBe(VECTOR);
|
||||
|
||||
// The xpubs are stored alongside the envelope; if rotation changed them the wallet would silently
|
||||
// start watching a different account and report a zero balance.
|
||||
const [before, after] = [
|
||||
await deriveAccountXpubs(env, PASS, 'bitcoin'),
|
||||
await deriveAccountXpubs(rotated, next, 'bitcoin'),
|
||||
];
|
||||
expect(after.xpubs[84]).toBe(before.xpubs[84]);
|
||||
expect(after.fingerprint).toBe(before.fingerprint);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe('UnlockSession', () => {
|
||||
test('holds no key material until unlocked', async () => {
|
||||
const s = new UnlockSession(1);
|
||||
expect(s.isUnlocked()).toBe(false);
|
||||
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
|
||||
});
|
||||
|
||||
test(
|
||||
'unlocks, derives the stored xpub, then relocks',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
||||
|
||||
const s = new UnlockSession(1);
|
||||
await s.unlock(env, PASS, 60);
|
||||
expect(s.isUnlocked()).toBe(true);
|
||||
expect(s.secondsRemaining()).toBeGreaterThan(0);
|
||||
expect(s.secondsRemaining()).toBeLessThanOrEqual(60);
|
||||
|
||||
// Proves the unlocked root is the same key the watch-only xpub came from.
|
||||
expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);
|
||||
|
||||
s.lock();
|
||||
expect(s.isUnlocked()).toBe(false);
|
||||
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'a failed unlock leaves the session locked',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const s = new UnlockSession(1);
|
||||
await expect(s.unlock(env, 'the wrong passphrase', 60)).rejects.toThrow();
|
||||
expect(s.isUnlocked()).toBe(false);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe('aezeed seeds', () => {
|
||||
// The same fixture the aezeed suite uses: entropy 000102…0f under the default passphrase. That entropy
|
||||
// is BIP32 test vector 1's master seed, so the fingerprint below is a published constant from a third
|
||||
// specification — this asserts the whole chain (words → AEZ → BIP32) against something outside Officer.
|
||||
const AEZEED =
|
||||
'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen';
|
||||
const BIP32_VECTOR_1_FINGERPRINT = '3442193e';
|
||||
|
||||
test(
|
||||
'seals as v2 and derives the BIP32 vector root',
|
||||
async () => {
|
||||
const env = await sealSeed(AEZEED, PASS);
|
||||
expect(env.v).toBe(2);
|
||||
|
||||
const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
||||
expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
|
||||
for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'does NOT derive what the BIP39 path would have',
|
||||
async () => {
|
||||
// The failure this whole `kind` mechanism exists to prevent. An aezeed phrase is 24 valid wordlist
|
||||
// words, so `mnemonicToSeedSync` accepts it happily and returns a perfectly real — and completely
|
||||
// wrong — master key, pointing at an empty wallet the owner cannot tell apart from a bad rescan.
|
||||
const { mnemonicToSeedSync } = await import('@scure/bip39');
|
||||
const { HDKey } = await import('@scure/bip32');
|
||||
const wrong = HDKey.fromMasterSeed(mnemonicToSeedSync(AEZEED));
|
||||
const wrongFingerprint = Buffer.from(new Uint8Array(new Uint32Array([wrong.fingerprint]).buffer))
|
||||
.reverse()
|
||||
.toString('hex');
|
||||
expect(wrongFingerprint).not.toBe(BIP32_VECTOR_1_FINGERPRINT);
|
||||
|
||||
const env = await sealSeed(AEZEED, PASS);
|
||||
const { fingerprint } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
||||
expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'the words survive an export round trip',
|
||||
async () => {
|
||||
const env = await sealSeed(AEZEED, PASS);
|
||||
expect(await exportMnemonic(wid(), env, PASS)).toBe(AEZEED);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'unlocking derives the same root as the stored xpub',
|
||||
async () => {
|
||||
const env = await sealSeed(AEZEED, PASS);
|
||||
const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
||||
|
||||
const s = new UnlockSession(2);
|
||||
await s.unlock(env, PASS, 60);
|
||||
expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);
|
||||
s.lock();
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'a wrong seed passphrase is refused at seal time, not silently accepted',
|
||||
async () => {
|
||||
// AEZ's 4-byte tag makes this loud. Without the check the wallet would seal fine and only reveal
|
||||
// itself later as an empty balance — the single most expensive way to find out.
|
||||
await expect(sealSeed(AEZEED, PASS, 'not the seed passphrase')).rejects.toThrow(/passphrase/i);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'a phrase that is neither BIP39 nor aezeed is still rejected',
|
||||
async () => {
|
||||
const bad = AEZEED.split(' ');
|
||||
[bad[3], bad[4]] = [bad[4]!, bad[3]!];
|
||||
await expect(sealSeed(bad.join(' '), PASS)).rejects.toThrow(/BIP39/i);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rotation preserves the aezeed kind and its derivation',
|
||||
async () => {
|
||||
const env = await sealSeed(AEZEED, PASS);
|
||||
const next = 'an entirely different passphrase';
|
||||
const rotated = await changePassphrase(wid(), env, PASS, next);
|
||||
|
||||
expect(rotated.v).toBe(2);
|
||||
const after = await deriveAccountXpubs(rotated, next, 'bitcoin');
|
||||
expect(after.fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe('generateSeed', () => {
|
||||
test(
|
||||
'produces a distinct, valid mnemonic that survives a round trip',
|
||||
async () => {
|
||||
const mnemonic = generateSeed();
|
||||
expect([12, 24]).toContain(mnemonic.split(' ').length);
|
||||
expect(generateSeed()).not.toBe(mnemonic);
|
||||
|
||||
const env = await sealSeed(mnemonic, 'a sufficiently long passphrase');
|
||||
expect(await exportMnemonic(wid(), env, 'a sufficiently long passphrase')).toBe(mnemonic);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
@@ -1,484 +0,0 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import type { BitcoinNetwork } from './types';
|
||||
import { AezeedError, DEFAULT_AEZEED_PASSPHRASE, decipherMnemonic, isAezeedPhrase } from './aezeed/cipher-seed';
|
||||
import { BackendError, WalletLockedError } from './types';
|
||||
|
||||
// Seed custody for the wallet sidecar.
|
||||
//
|
||||
// THE THREAT MODEL, stated plainly, because it is the whole point of this file:
|
||||
//
|
||||
// Zeus stores its seed phrases as plaintext inside a JSON settings blob and leans entirely on the OS
|
||||
// keychain (storage/index.ts + stores/SettingsStore.ts:32-67 — `seedPhrase?: string[]`). A phone has a
|
||||
// secure enclave and a screen lock; a server has neither. So none of Zeus's key handling is reusable
|
||||
// here and this is written from scratch.
|
||||
//
|
||||
// The seed is protected by TWO independent secrets, and an attacker needs BOTH:
|
||||
//
|
||||
// 1. An owner passphrase, which is never persisted anywhere. It derives a KEK via scrypt and that
|
||||
// KEK wraps the random per-wallet DEK that actually encrypts the mnemonic.
|
||||
// 2. the 'wallet' key from the secret store, applied by queries/wallet.ts (../../databases/officer_db)
|
||||
// over the already-encrypted envelope before it touches Postgres.
|
||||
//
|
||||
// Consequence: a stolen database dump is useless without .env, a stolen .env is useless without the
|
||||
// passphrase, and a full server compromise still cannot spend while the wallet is locked, because a
|
||||
// locked wallet holds no key material in memory at all.
|
||||
//
|
||||
// WATCH-ONLY WHILE LOCKED. The account xpubs are stored in the clear on purpose. Balances, history and
|
||||
// receive addresses therefore work with the wallet locked and the passphrase nowhere on the machine —
|
||||
// unlocking is required only to SIGN. This is the single most important property here: the wallet spends
|
||||
// almost all of its life locked and still fully readable.
|
||||
//
|
||||
// WHAT THIS CANNOT DO. Once unlocked, the root key is in the Bun process's heap and Node gives no way to
|
||||
// pin or reliably wipe it — GC may have copied it. `zeroize()` scrubs the buffers we own, which shrinks
|
||||
// the window but does not close it. That is why the unlock TTL is short and defaults tight.
|
||||
|
||||
const scrypt = promisify(scryptCb) as (
|
||||
password: string | Buffer,
|
||||
salt: Buffer,
|
||||
keylen: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
|
||||
// N=2^17 / r=8 / p=1 → ~128 MiB and ~1s per attempt on this class of hardware. Deliberately painful:
|
||||
// this is the only thing standing between a leaked database + .env and the coins. Node's default maxmem
|
||||
// is 32 MiB, which these parameters blow through, so it must be raised explicitly or scrypt throws.
|
||||
const SCRYPT_N = 1 << 17;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
/** Bump when the KDF parameters or envelope layout change, so old envelopes can be migrated on unlock. */
|
||||
// Two seed formats, two envelope versions.
|
||||
//
|
||||
// v1 — BIP39 mnemonic. The master seed is PBKDF2(mnemonic, "mnemonic" + passphrase).
|
||||
// v2 — LND aezeed cipher seed (what Zeus's embedded node produces). Its 16-byte entropy IS the BIP32
|
||||
// master seed, with no PBKDF2 step at all.
|
||||
//
|
||||
// The bump is not cosmetic. Both formats are 24 words from the same wordlist, so a build that predates
|
||||
// aezeed support would happily read a v2 envelope, run the words through BIP39 derivation, and produce a
|
||||
// completely different — empty — wallet without erroring. Refusing an unknown version is what makes a
|
||||
// platform rollback safe.
|
||||
const ENVELOPE_VERSION_BIP39 = 1;
|
||||
const ENVELOPE_VERSION_AEZEED = 2;
|
||||
|
||||
export type SeedKind = 'bip39' | 'aezeed';
|
||||
|
||||
export type SeedEnvelope = {
|
||||
v: number;
|
||||
/** base64, 16 bytes — scrypt salt for the KEK. */
|
||||
salt: string;
|
||||
/** base64(iv[12] | tag[16] | ciphertext) — the DEK, wrapped under the passphrase-derived KEK. */
|
||||
wrappedDek: string;
|
||||
/** base64(iv[12] | tag[16] | ciphertext) — the seed phrase, encrypted under the DEK. */
|
||||
seed: string;
|
||||
/**
|
||||
* Whether a seed passphrase is part of this seed — the BIP39 "25th word", or the aezeed passphrase.
|
||||
* Affects derivation, not secrecy.
|
||||
*/
|
||||
hasBip39Passphrase: boolean;
|
||||
};
|
||||
|
||||
// ── AES-256-GCM primitives ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function gcmEncrypt(key: Buffer, plaintext: Buffer): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
|
||||
}
|
||||
|
||||
function gcmDecrypt(key: Buffer, blob: string): Buffer {
|
||||
const buf = Buffer.from(blob, 'base64');
|
||||
if (buf.length < 29) throw new BackendError('malformed ciphertext', 500, 'BAD_ENVELOPE');
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
|
||||
decipher.setAuthTag(buf.subarray(12, 28));
|
||||
return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]);
|
||||
}
|
||||
|
||||
/** Best-effort scrub of a buffer we own. See the caveat in the header comment. */
|
||||
function zeroize(buf: Buffer | null): void {
|
||||
if (buf) buf.fill(0);
|
||||
}
|
||||
|
||||
// ── envelope construction ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function deriveKek(passphrase: string, salt: Buffer): Promise<Buffer> {
|
||||
return scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, {
|
||||
N: SCRYPT_N,
|
||||
r: SCRYPT_R,
|
||||
p: SCRYPT_P,
|
||||
maxmem: SCRYPT_MAXMEM,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove an aezeed actually opens before we seal it. Sealing an undecipherable seed would store something
|
||||
* permanently unusable behind the owner's passphrase and only surface at unlock — possibly months later,
|
||||
* with the original backup long since put away.
|
||||
*/
|
||||
async function assertAezeedOpens(words: string[], seedPassphrase: string | undefined): Promise<void> {
|
||||
try {
|
||||
const seed = await decipherMnemonic(words, seedPassphrase || DEFAULT_AEZEED_PASSPHRASE);
|
||||
seed.entropy.fill(0);
|
||||
} catch (err) {
|
||||
if (err instanceof AezeedError && err.code === 'BAD_PASSPHRASE') {
|
||||
throw new BackendError(
|
||||
'this is an LND (aezeed) seed and the passphrase is wrong — leave it blank if you never set one',
|
||||
400,
|
||||
'BAD_SEED_PASSPHRASE',
|
||||
);
|
||||
}
|
||||
throw new BackendError('not a valid aezeed cipher seed', 400, 'BAD_MNEMONIC');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a seed phrase into a sealed envelope. The phrase is validated first — importing a typo'd phrase
|
||||
* silently produces a valid-but-wrong wallet that shows a zero balance, which is a genuinely awful
|
||||
* failure mode to debug.
|
||||
*
|
||||
* The format is detected from the phrase itself rather than asked of the caller. A BIP39 mnemonic and an
|
||||
* LND aezeed are both 24 words drawn from the same wordlist and are indistinguishable by eye, so the
|
||||
* owner cannot reasonably be expected to know which one their wallet gave them — but the bits can tell
|
||||
* us for certain (SHA-256 checksum vs CRC32-Castagnoli).
|
||||
*/
|
||||
export async function sealSeed(
|
||||
mnemonic: string,
|
||||
ownerPassphrase: string,
|
||||
bip39Passphrase?: string,
|
||||
): Promise<SeedEnvelope> {
|
||||
const normalized = mnemonic.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
const words = normalized.split(' ');
|
||||
|
||||
let kind: SeedKind;
|
||||
if (validateMnemonic(normalized, wordlist)) {
|
||||
kind = 'bip39';
|
||||
} else if (isAezeedPhrase(words)) {
|
||||
kind = 'aezeed';
|
||||
await assertAezeedOpens(words, bip39Passphrase);
|
||||
} else {
|
||||
throw new BackendError('not a valid BIP39 mnemonic (checksum or wordlist mismatch)', 400, 'BAD_MNEMONIC');
|
||||
}
|
||||
|
||||
if (ownerPassphrase.length < 8) {
|
||||
throw new BackendError('unlock passphrase must be at least 8 characters', 400, 'WEAK_PASSPHRASE');
|
||||
}
|
||||
|
||||
const salt = randomBytes(16);
|
||||
const kek = await deriveKek(ownerPassphrase, salt);
|
||||
const dek = randomBytes(KEY_LEN);
|
||||
// The BIP39 passphrase lives inside the encrypted payload, not beside it: it is as sensitive as the
|
||||
// words themselves, since together they are the wallet.
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ mnemonic: normalized, bip39Passphrase: bip39Passphrase ?? '', kind }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
return {
|
||||
v: kind === 'aezeed' ? ENVELOPE_VERSION_AEZEED : ENVELOPE_VERSION_BIP39,
|
||||
salt: salt.toString('base64'),
|
||||
wrappedDek: gcmEncrypt(kek, dek),
|
||||
seed: gcmEncrypt(dek, payload),
|
||||
hasBip39Passphrase: Boolean(bip39Passphrase),
|
||||
};
|
||||
} finally {
|
||||
zeroize(kek);
|
||||
zeroize(dek);
|
||||
zeroize(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh 12- or 24-word mnemonic. 24 words (256 bits) is the default. */
|
||||
export function generateSeed(words: 12 | 24 = 24): string {
|
||||
return generateMnemonic(wordlist, words === 12 ? 128 : 256);
|
||||
}
|
||||
|
||||
type OpenedSeed = { mnemonic: string; bip39Passphrase: string; kind?: SeedKind };
|
||||
|
||||
async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
|
||||
if (env.v !== ENVELOPE_VERSION_BIP39 && env.v !== ENVELOPE_VERSION_AEZEED) {
|
||||
throw new BackendError(`unsupported seed envelope version ${env.v}`, 500, 'BAD_ENVELOPE');
|
||||
}
|
||||
const kek = await deriveKek(ownerPassphrase, Buffer.from(env.salt, 'base64'));
|
||||
let dek: Buffer | null = null;
|
||||
try {
|
||||
// A wrong passphrase fails here, as a GCM tag mismatch. That is the ONLY signal — we never store a
|
||||
// verifier hash of the passphrase, because a verifier is an offline-crackable oracle.
|
||||
dek = gcmDecrypt(kek, env.wrappedDek);
|
||||
const payload = gcmDecrypt(dek, env.seed);
|
||||
try {
|
||||
const opened = JSON.parse(payload.toString('utf8')) as OpenedSeed;
|
||||
// Envelopes sealed before aezeed support carry no `kind`; the version is the fallback authority,
|
||||
// and v1 has only ever meant BIP39.
|
||||
return { ...opened, kind: opened.kind ?? (env.v === ENVELOPE_VERSION_AEZEED ? 'aezeed' : 'bip39') };
|
||||
} finally {
|
||||
zeroize(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError) throw err;
|
||||
throw new BackendError('incorrect passphrase', 401, 'BAD_PASSPHRASE');
|
||||
} finally {
|
||||
zeroize(kek);
|
||||
zeroize(dek);
|
||||
}
|
||||
}
|
||||
|
||||
// ── derivation ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Bip = 44 | 49 | 84 | 86;
|
||||
|
||||
/** Mainnet is coin type 0; every test network shares coin type 1 (BIP44). */
|
||||
export function coinType(network: BitcoinNetwork): 0 | 1 {
|
||||
return network === 'bitcoin' ? 0 : 1;
|
||||
}
|
||||
|
||||
export function accountPath(bip: Bip, network: BitcoinNetwork, account = 0): string {
|
||||
return `m/${bip}'/${coinType(network)}'/${account}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place the two seed formats diverge, and the reason `kind` has to be carried at all.
|
||||
*
|
||||
* BIP39 stretches the words into a 64-byte master seed via PBKDF2. aezeed does not stretch anything —
|
||||
* the 16 bytes recovered from the cipher seed ARE the BIP32 master seed, exactly as lnd/btcwallet uses
|
||||
* them. Feeding an aezeed phrase through the BIP39 path would succeed, derive real-looking keys, and
|
||||
* point at an entirely different (empty) wallet.
|
||||
*/
|
||||
async function rootFromSeed(opened: OpenedSeed): Promise<HDKey> {
|
||||
if (opened.kind === 'aezeed') {
|
||||
const seed = await decipherMnemonic(
|
||||
opened.mnemonic.split(' '),
|
||||
opened.bip39Passphrase || DEFAULT_AEZEED_PASSPHRASE,
|
||||
);
|
||||
try {
|
||||
return HDKey.fromMasterSeed(seed.entropy);
|
||||
} finally {
|
||||
seed.entropy.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
const seed = Buffer.from(mnemonicToSeedSync(opened.mnemonic, opened.bip39Passphrase || undefined));
|
||||
try {
|
||||
return HDKey.fromMasterSeed(seed);
|
||||
} finally {
|
||||
zeroize(seed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the public account descriptors WITHOUT retaining any private material. Called once at import
|
||||
* time; the returned xpubs are stored in the clear and are what makes watch-only-while-locked work.
|
||||
*/
|
||||
export async function deriveAccountXpubs(
|
||||
env: SeedEnvelope,
|
||||
ownerPassphrase: string,
|
||||
network: BitcoinNetwork,
|
||||
): Promise<{ fingerprint: string; xpubs: Record<Bip, string> }> {
|
||||
const opened = await openEnvelope(env, ownerPassphrase);
|
||||
const root = await rootFromSeed(opened);
|
||||
try {
|
||||
const fingerprint = Buffer.from(new Uint8Array(new Uint32Array([root.fingerprint]).buffer))
|
||||
.reverse()
|
||||
.toString('hex');
|
||||
const xpubs = {} as Record<Bip, string>;
|
||||
for (const bip of [44, 49, 84, 86] as const) {
|
||||
const node = root.derive(accountPath(bip, network));
|
||||
xpubs[bip] = node.publicExtendedKey;
|
||||
}
|
||||
return { fingerprint, xpubs };
|
||||
} finally {
|
||||
root.wipePrivateData();
|
||||
}
|
||||
}
|
||||
|
||||
// ── the unlock session ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TTL_SEC = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);
|
||||
|
||||
// Brute-force resistance. scrypt already makes each guess cost ~1s and ~128 MiB, but an attacker with
|
||||
// the DB and .env can grind offline anyway — this only protects the live endpoint. Backoff is per
|
||||
// wallet id and resets on success.
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_MS = 60_000;
|
||||
|
||||
type Attempts = { count: number; lockedUntil: number };
|
||||
const attempts = new Map<number, Attempts>();
|
||||
|
||||
function checkLockout(walletId: number): void {
|
||||
const a = attempts.get(walletId);
|
||||
if (a && a.lockedUntil > Date.now()) {
|
||||
const secs = Math.ceil((a.lockedUntil - Date.now()) / 1000);
|
||||
throw new BackendError(`too many failed attempts, retry in ${secs}s`, 429, 'LOCKED_OUT');
|
||||
}
|
||||
}
|
||||
|
||||
function recordFailure(walletId: number): void {
|
||||
const a = attempts.get(walletId) ?? { count: 0, lockedUntil: 0 };
|
||||
a.count += 1;
|
||||
if (a.count >= MAX_ATTEMPTS) {
|
||||
a.lockedUntil = Date.now() + LOCKOUT_MS;
|
||||
a.count = 0;
|
||||
}
|
||||
attempts.set(walletId, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* A live, unlocked wallet. Holds the derived root key in memory and nothing else — the mnemonic itself
|
||||
* is decrypted, converted to a root key, and dropped inside `unlock()`; it is never retained.
|
||||
*
|
||||
* Structurally satisfies the `Signer` interface that backends/onchain.ts consumes.
|
||||
*/
|
||||
export class UnlockSession {
|
||||
private root: HDKey | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private expiresAt = 0;
|
||||
|
||||
constructor(readonly walletId: number) {}
|
||||
|
||||
isUnlocked(): boolean {
|
||||
return this.root !== null && Date.now() < this.expiresAt;
|
||||
}
|
||||
|
||||
/** Seconds until auto-lock, or 0 when locked. For the UI's countdown. */
|
||||
secondsRemaining(): number {
|
||||
if (!this.isUnlocked()) return 0;
|
||||
return Math.max(0, Math.ceil((this.expiresAt - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
async unlock(env: SeedEnvelope, ownerPassphrase: string, ttlSec = DEFAULT_TTL_SEC): Promise<void> {
|
||||
const opened = await openGuarded(this.walletId, env, ownerPassphrase);
|
||||
|
||||
this.lock(); // replace any existing session rather than leaking the old root
|
||||
this.root = await rootFromSeed(opened);
|
||||
// Drop the words immediately — the root key is all any signing operation needs.
|
||||
opened.mnemonic = '';
|
||||
opened.bip39Passphrase = '';
|
||||
this.arm(ttlSec);
|
||||
}
|
||||
|
||||
private arm(ttlSec: number): void {
|
||||
this.expiresAt = Date.now() + ttlSec * 1000;
|
||||
this.timer = setTimeout(() => this.lock(), ttlSec * 1000);
|
||||
// Don't hold the event loop open just to auto-lock; shutdown wipes memory anyway.
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
lock(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.root?.wipePrivateData();
|
||||
this.root = null;
|
||||
this.expiresAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with the root key. The ONLY way key material leaves this class, and it never escapes as a
|
||||
* return value by construction — callers get a derived signature, not the key.
|
||||
*
|
||||
* Deliberately does NOT slide the TTL. An unlock is a bounded window the owner opened on purpose;
|
||||
* refreshing it on use would let a compromised session stay open indefinitely by signing.
|
||||
*/
|
||||
withRoot<T>(fn: (root: HDKey) => T): T {
|
||||
if (!this.isUnlocked() || !this.root) {
|
||||
this.lock();
|
||||
throw new WalletLockedError();
|
||||
}
|
||||
return fn(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
// One session per wallet id, process-wide. The sidecar is a single process, so this map IS the unlock
|
||||
// state — there is no cross-process sharing and deliberately no persistence: a sidecar restart relocks
|
||||
// every wallet, which is the correct default.
|
||||
const sessions = new Map<number, UnlockSession>();
|
||||
|
||||
export function sessionFor(walletId: number): UnlockSession {
|
||||
let s = sessions.get(walletId);
|
||||
if (!s) {
|
||||
s = new UnlockSession(walletId);
|
||||
sessions.set(walletId, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function lockAll(): void {
|
||||
for (const s of sessions.values()) s.lock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an envelope under the same backoff `unlock()` uses.
|
||||
*
|
||||
* EVERY passphrase check goes through here, not just the session one. The lockout used to live inside
|
||||
* UnlockSession.unlock alone, which left export-seed — the one endpoint that returns the words in the
|
||||
* clear — accepting unlimited guesses, while /unlock capped at five a minute. An attacker with a session
|
||||
* cookie would simply never have used /unlock.
|
||||
*/
|
||||
async function openGuarded(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
|
||||
checkLockout(walletId);
|
||||
let opened: OpenedSeed;
|
||||
try {
|
||||
opened = await openEnvelope(env, ownerPassphrase);
|
||||
} catch (err) {
|
||||
recordFailure(walletId);
|
||||
throw err;
|
||||
}
|
||||
attempts.delete(walletId);
|
||||
return opened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a passphrase without opening a session — used before destructive operations (seed export,
|
||||
* wallet deletion) so they need a fresh confirmation even when the wallet is already unlocked.
|
||||
*
|
||||
* Rethrows a LOCKED_OUT BackendError rather than folding it into `false`: "wrong passphrase" and "stop
|
||||
* guessing" are different answers, and a caller that showed the first for both would loop forever.
|
||||
*/
|
||||
export async function verifyPassphrase(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise<boolean> {
|
||||
try {
|
||||
await openGuarded(walletId, env, ownerPassphrase);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError && err.code === 'LOCKED_OUT') throw err;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the mnemonic. The only function that returns raw seed words, and it exists solely so the owner
|
||||
* can back up or migrate. Always requires the passphrase even if a session is open, and callers must
|
||||
* gate it behind a fresh confirmation.
|
||||
*/
|
||||
export async function exportMnemonic(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise<string> {
|
||||
const opened = await openGuarded(walletId, env, ownerPassphrase);
|
||||
return opened.mnemonic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seal an existing seed under a new passphrase. Requires the old one. Note this mints a FRESH salt,
|
||||
* DEK and IVs rather than merely re-wrapping the existing DEK — so a copy of the old envelope, plus the
|
||||
* old passphrase, cannot decrypt anything written after a rotation.
|
||||
*/
|
||||
export async function changePassphrase(
|
||||
walletId: number,
|
||||
env: SeedEnvelope,
|
||||
oldPassphrase: string,
|
||||
newPassphrase: string,
|
||||
): Promise<SeedEnvelope> {
|
||||
const opened = await openGuarded(walletId, env, oldPassphrase);
|
||||
return sealSeed(opened.mnemonic, newPassphrase, opened.bip39Passphrase || undefined);
|
||||
}
|
||||
|
||||
/** Constant-time compare for any confirmation token we hand out and take back. */
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
import type { AddressType, BitcoinNetwork } from './types';
|
||||
import { BackendError } from './types';
|
||||
|
||||
// NBXplorer client — the second chain source, alongside EsploraChain.
|
||||
//
|
||||
// THE MODEL IS DIFFERENT, AND THAT IS THE POINT. Esplora is address-level: you ask about one address at a
|
||||
// time, so finding a wallet's coins means walking the gap limit yourself (43–160 requests per refresh, see
|
||||
// backends/onchain.ts). NBXplorer is scheme-level: you register the account xpub once, it indexes the
|
||||
// wallet server-side, and every question after that — balance, utxos, history, next unused address — is a
|
||||
// single call. That is why this is not an implementation of EsploraChain's interface: there is no sensible
|
||||
// per-address query to implement, and emulating one would throw away the entire advantage.
|
||||
//
|
||||
// It talks to a bitcoind that the owner runs. Everything below was verified against NBXplorer 2.6.9.
|
||||
|
||||
/**
|
||||
* NBXplorer names an account by its xpub plus a suffix for the script type. Native segwit is the bare
|
||||
* xpub; the rest carry a bracketed tag.
|
||||
*
|
||||
* Taproot is `-[taproot]`, which is NOT in NBXplorer's own README table — verified against a live 2.6.9
|
||||
* instance, which accepts it and derives the same addresses we do.
|
||||
*/
|
||||
const SCHEME_SUFFIX: Record<AddressType, string> = {
|
||||
p2wpkh: '',
|
||||
'p2sh-p2wpkh': '-[p2sh]',
|
||||
p2pkh: '-[legacy]',
|
||||
p2tr: '-[taproot]',
|
||||
};
|
||||
|
||||
/**
|
||||
* The account identifier NBXplorer indexes under.
|
||||
*
|
||||
* The xpub must carry `xpub` version bytes for every script type — NOT ypub/zpub. That is already what
|
||||
* keys.ts::deriveAccountXpubs stores, and changing it would silently point NBXplorer at a different
|
||||
* account than the one we derive from.
|
||||
*/
|
||||
export const derivationScheme = (accountXpub: string, type: AddressType): string =>
|
||||
`${accountXpub}${SCHEME_SUFFIX[type]}`;
|
||||
|
||||
/** NBXplorer's two address roles. `Deposit` is the receive chain, `Change` is internal. */
|
||||
export type DerivationFeature = 'Deposit' | 'Change';
|
||||
|
||||
export type NbxStatus = {
|
||||
chainHeight: number;
|
||||
syncHeight: number;
|
||||
isFullySynched: boolean;
|
||||
networkType: string;
|
||||
cryptoCode: string;
|
||||
version: string;
|
||||
bitcoinStatus?: { blocks: number; headers: number; verificationProgress: number; isSynched: boolean } | null;
|
||||
};
|
||||
|
||||
export type NbxBalance = {
|
||||
/** Satoshis. NBXplorer emits plain numbers here, not strings. */
|
||||
unconfirmed: number;
|
||||
confirmed: number;
|
||||
total: number;
|
||||
immature: number;
|
||||
available: number;
|
||||
};
|
||||
|
||||
export type NbxUtxo = {
|
||||
feature: DerivationFeature;
|
||||
/** `"<txid>-<vout>"`. */
|
||||
outpoint: string;
|
||||
index: number;
|
||||
transactionHash: string;
|
||||
scriptPubKey: string;
|
||||
address: string;
|
||||
/** Relative to the account, e.g. `"0/7"` — chain then index. */
|
||||
keyPath: string;
|
||||
keyIndex: number;
|
||||
value: number;
|
||||
confirmations: number;
|
||||
timestamp: number;
|
||||
redeem?: string | null;
|
||||
};
|
||||
|
||||
type NbxUtxoGroup = { utxOs: NbxUtxo[]; spentOutpoints: string[] };
|
||||
|
||||
export type NbxUtxoChanges = {
|
||||
currentHeight: number;
|
||||
unconfirmed: NbxUtxoGroup;
|
||||
confirmed: NbxUtxoGroup;
|
||||
spentUnconfirmed: NbxUtxo[];
|
||||
};
|
||||
|
||||
export type NbxTransaction = {
|
||||
transactionId: string;
|
||||
/** Raw hex. Present on wallet transactions, which is the only kind NBXplorer stores. */
|
||||
transaction?: string | null;
|
||||
blockHash?: string | null;
|
||||
confirmations: number;
|
||||
height?: number | null;
|
||||
timestamp: number;
|
||||
/** Signed satoshis: negative when the wallet spent. */
|
||||
balanceChange: number;
|
||||
replaceable?: boolean;
|
||||
};
|
||||
|
||||
export type NbxTransactions = {
|
||||
height: number;
|
||||
confirmedTransactions: { transactions: NbxTransaction[] };
|
||||
unconfirmedTransactions: { transactions: NbxTransaction[] };
|
||||
replacedTransactions: { transactions: NbxTransaction[] };
|
||||
};
|
||||
|
||||
export type NbxAddress = {
|
||||
feature: DerivationFeature;
|
||||
keyPath: string;
|
||||
scriptPubKey: string;
|
||||
address: string;
|
||||
index: number;
|
||||
redeem?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `GET …/utxos/scan` — how a `scantxoutset` sweep is going.
|
||||
*
|
||||
* Every field below `status` is optional on purpose: NBXplorer fills `progress` only once the node has
|
||||
* actually started, and a queued scan reports nothing but its place in the queue.
|
||||
*/
|
||||
export type NbxScanStatus = {
|
||||
status: 'Queued' | 'Pending' | 'Complete' | 'Error';
|
||||
error?: string | null;
|
||||
queuedAt?: string;
|
||||
progress?: {
|
||||
startedAt?: string;
|
||||
completedAt?: string | null;
|
||||
/** UTXOs pulled in. This is the number that answers "did the scan find my coins". */
|
||||
found?: number;
|
||||
batchNumber?: number;
|
||||
remainingBatches?: number;
|
||||
currentBatchProgress?: number;
|
||||
overallProgress?: number;
|
||||
remainingSeconds?: number;
|
||||
highestKeyIndexFound?: Partial<Record<DerivationFeature, number | null>>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `POST …/utxos/scan` parameters.
|
||||
*
|
||||
* The defaults are the values the node operator verified live. `gapLimit` 1000 is generous for any
|
||||
* wallet that has not used addresses beyond index 1000; raising it costs node CPU, not correctness.
|
||||
*/
|
||||
export type UtxoScanOptions = { batchSize?: number; gapLimit?: number; from?: number };
|
||||
|
||||
const SCAN_DEFAULTS = { batchSize: 1000, gapLimit: 1000, from: 0 } as const;
|
||||
|
||||
/** A failed broadcast comes back as HTTP 200 with `success: false` — never as an HTTP error. */
|
||||
export type NbxBroadcastResult = {
|
||||
success: boolean;
|
||||
rpcCode?: number | null;
|
||||
rpcCodeMessage?: string | null;
|
||||
rpcMessage?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
export type NbxplorerConfig = {
|
||||
baseUrl: string;
|
||||
network: BitcoinNetwork;
|
||||
/** Only ever 'BTC' here, but NBXplorer is multi-chain and the route carries it. */
|
||||
cryptoCode?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type RequestInitLite = { method?: 'GET' | 'POST'; body?: string };
|
||||
|
||||
export class NbxplorerChain {
|
||||
readonly network: BitcoinNetwork;
|
||||
private readonly baseUrl: string;
|
||||
private readonly cryptoCode: string;
|
||||
private readonly timeoutMs: number;
|
||||
/** Schemes already registered this process. Tracking is idempotent but not free — see `track`. */
|
||||
private readonly tracked = new Set<string>();
|
||||
|
||||
constructor(cfg: NbxplorerConfig) {
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
|
||||
this.network = cfg.network;
|
||||
this.cryptoCode = cfg.cryptoCode ?? 'BTC';
|
||||
this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
private get root(): string {
|
||||
return `/v1/cryptos/${this.cryptoCode}`;
|
||||
}
|
||||
|
||||
/** Every request funnels through here, so a timeout and an upstream failure share one error shape. */
|
||||
private async send(path: string, init: RequestInitLite): Promise<{ status: number; body: string }> {
|
||||
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: init.method ?? 'GET',
|
||||
body: init.body,
|
||||
headers: init.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err);
|
||||
throw new BackendError(`nbxplorer request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
return { status: res.status, body: await res.text() };
|
||||
}
|
||||
|
||||
private fail(path: string, status: number, body: string): never {
|
||||
// NBXplorer answers with {"code","message"} where it can, and bare text where it cannot.
|
||||
let message = body.slice(0, 300);
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { message?: unknown };
|
||||
if (typeof parsed.message === 'string' && parsed.message) message = parsed.message;
|
||||
} catch {
|
||||
/* not its JSON envelope */
|
||||
}
|
||||
throw new BackendError(`nbxplorer ${status} on ${path}: ${message}`, status, 'NBXPLORER_ERROR');
|
||||
}
|
||||
|
||||
private async raw(path: string, init: RequestInitLite = {}): Promise<string> {
|
||||
const { status, body } = await this.send(path, init);
|
||||
if (status < 200 || status >= 300) this.fail(path, status, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
private async json<T>(path: string, init?: RequestInitLite): Promise<T> {
|
||||
const body = await this.raw(path, init);
|
||||
return this.parse<T>(path, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `json`, but a 404 is an answer rather than a failure.
|
||||
*
|
||||
* Only the scan-status endpoint needs this, and it needs it badly: NBXplorer 404s both when no scan is
|
||||
* running and once a finished scan's result has expired. Treating that as an error would turn the two
|
||||
* most ordinary moments of a rescan — before it starts, and a while after it ends — into upstream
|
||||
* failures on the wallet screen.
|
||||
*/
|
||||
private async jsonOrNull<T>(path: string, init?: RequestInitLite): Promise<T | null> {
|
||||
const { status, body } = await this.send(path, init ?? {});
|
||||
if (status === 404) return null;
|
||||
if (status < 200 || status >= 300) this.fail(path, status, body);
|
||||
return this.parse<T>(path, body);
|
||||
}
|
||||
|
||||
private parse<T>(path: string, body: string): T {
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch {
|
||||
throw new BackendError(`nbxplorer returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'NBXPLORER_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
private scheme(accountXpub: string, type: AddressType): string {
|
||||
return encodeURIComponent(derivationScheme(accountXpub, type));
|
||||
}
|
||||
|
||||
// ── endpoints ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `GET /v1/cryptos/BTC/status` — the liveness probe, and the only call that needs no scheme. */
|
||||
getStatus(): Promise<NbxStatus> {
|
||||
return this.json<NbxStatus>(`${this.root}/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST …/derivations/{scheme}` — register an account so NBXplorer starts indexing it.
|
||||
*
|
||||
* THIS IS NOT OPTIONAL AND ITS ABSENCE IS SILENT. Querying an untracked scheme returns HTTP 200 with
|
||||
* every figure zeroed, which is indistinguishable from a real empty wallet — a registration that never
|
||||
* happened would read as "you have no coins". So every read path calls this first, and the in-process
|
||||
* set only skips the repeat calls, never the first one after a restart.
|
||||
*/
|
||||
async track(accountXpub: string, type: AddressType): Promise<void> {
|
||||
const key = derivationScheme(accountXpub, type);
|
||||
if (this.tracked.has(key)) return;
|
||||
await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}`, { method: 'POST' });
|
||||
this.tracked.add(key);
|
||||
}
|
||||
|
||||
/** `GET …/balance` — confirmed/unconfirmed/total for the whole account, in one call. */
|
||||
async getBalance(accountXpub: string, type: AddressType): Promise<NbxBalance> {
|
||||
await this.track(accountXpub, type);
|
||||
return this.json<NbxBalance>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/balance`);
|
||||
}
|
||||
|
||||
/** `GET …/utxos` — every unspent output with its keyPath, which is what the PSBT builder needs. */
|
||||
async getUtxos(accountXpub: string, type: AddressType): Promise<NbxUtxoChanges> {
|
||||
await this.track(accountXpub, type);
|
||||
return this.json<NbxUtxoChanges>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos`);
|
||||
}
|
||||
|
||||
/** `GET …/transactions` — wallet history, already grouped by confirmation state. */
|
||||
async getTransactions(accountXpub: string, type: AddressType): Promise<NbxTransactions> {
|
||||
await this.track(accountXpub, type);
|
||||
return this.json<NbxTransactions>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/transactions`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET …/addresses/unused` — the next address NBXplorer believes is unused.
|
||||
*
|
||||
* `reserve=true` makes it hand out the next one on the following call, which is the server-side
|
||||
* equivalent of the issued-index mark onchain.ts keeps. Leave it false to peek.
|
||||
*/
|
||||
async getUnusedAddress(
|
||||
accountXpub: string,
|
||||
type: AddressType,
|
||||
feature: DerivationFeature = 'Deposit',
|
||||
reserve = false,
|
||||
): Promise<NbxAddress> {
|
||||
await this.track(accountXpub, type);
|
||||
const q = `feature=${feature}&reserve=${reserve ? 'true' : 'false'}`;
|
||||
return this.json<NbxAddress>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/addresses/unused?${q}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET …/transactions/{txid}` — the raw hex of ONE transaction.
|
||||
*
|
||||
* Only wallet-relevant transactions exist here; an arbitrary mainnet txid 404s, verified. That is
|
||||
* enough for our only caller, which wants `nonWitnessUtxo` for a legacy input — the transaction that
|
||||
* funded our own address is by definition a wallet transaction.
|
||||
*/
|
||||
async getTxHex(accountXpub: string, type: AddressType, txid: string): Promise<string> {
|
||||
await this.track(accountXpub, type);
|
||||
const info = await this.json<NbxTransaction>(
|
||||
`${this.root}/derivations/${this.scheme(accountXpub, type)}/transactions/${encodeURIComponent(txid)}`,
|
||||
);
|
||||
const hex = info.transaction?.trim();
|
||||
if (!hex || !/^[0-9a-f]+$/i.test(hex)) {
|
||||
throw new BackendError(`nbxplorer has no raw hex for tx ${txid}`, 502, 'NBXPLORER_ERROR');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST …/utxos/scan` — sweep the node's whole UTXO set for this account's coins.
|
||||
*
|
||||
* WHY THIS EXISTS AT ALL. Registering a scheme only makes NBXplorer index it *from now on*. An
|
||||
* imported xpub with a history therefore reads as a real, quiet, zero-balance wallet: every call
|
||||
* succeeds, nothing errors, and the coins are simply not there. This endpoint is the fix — it runs
|
||||
* bitcoind's `scantxoutset`, which walks the current UTXO set directly and finds coins regardless of
|
||||
* when the account was registered or how far back the node is pruned.
|
||||
*
|
||||
* It finds spendable coins, NOT history. Spent-transaction history predating registration stays
|
||||
* missing; recovering that is a block rescan, which is a different and much heavier thing.
|
||||
*
|
||||
* Returns as soon as the scan is queued. `scantxoutset` is single-threaded and IO-heavy on the node,
|
||||
* so concurrent scans queue and run one after another — poll `getUtxoScanStatus` for the outcome.
|
||||
*/
|
||||
async startUtxoScan(accountXpub: string, type: AddressType, opts: UtxoScanOptions = {}): Promise<void> {
|
||||
await this.track(accountXpub, type);
|
||||
const q = new URLSearchParams({
|
||||
batchSize: String(opts.batchSize ?? SCAN_DEFAULTS.batchSize),
|
||||
gapLimit: String(opts.gapLimit ?? SCAN_DEFAULTS.gapLimit),
|
||||
from: String(opts.from ?? SCAN_DEFAULTS.from),
|
||||
});
|
||||
await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan?${q}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET …/utxos/scan` — the running scan, or null.
|
||||
*
|
||||
* Null means "nothing to report": no scan is running, or one finished long enough ago that NBXplorer
|
||||
* has dropped the result. Both are 404s and neither is a failure, so poll promptly and read a null
|
||||
* after a `Complete` as "it is over", not as "it vanished".
|
||||
*/
|
||||
getUtxoScanStatus(accountXpub: string, type: AddressType): Promise<NbxScanStatus | null> {
|
||||
return this.jsonOrNull<NbxScanStatus>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan`);
|
||||
}
|
||||
|
||||
/** `GET /v1/cryptos/BTC/fees/{blockCount}` — one target per call, sat/vB as a float. */
|
||||
async getFeeRate(blockCount: number): Promise<number> {
|
||||
const res = await this.json<{ feeRate: number; blockCount: number }>(`${this.root}/fees/${blockCount}`);
|
||||
if (!Number.isFinite(res.feeRate) || res.feeRate <= 0) {
|
||||
throw new BackendError(`nbxplorer returned a bad fee rate for ${blockCount} blocks`, 502, 'NBXPLORER_ERROR');
|
||||
}
|
||||
return res.feeRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /v1/cryptos/BTC/transactions` — broadcast.
|
||||
*
|
||||
* The body is `{hex}`: the handler accepts raw bytes, a bare JSON string or this object, and the object
|
||||
* is the only one of the three with no parsing ambiguity. A REJECTED broadcast still returns HTTP 200
|
||||
* with `success: false`, so the status code alone would report a bounced transaction as sent.
|
||||
*/
|
||||
async broadcast(rawHex: string): Promise<void> {
|
||||
const res = await this.json<NbxBroadcastResult>(`${this.root}/transactions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ hex: rawHex }),
|
||||
});
|
||||
if (!res.success) {
|
||||
const detail = res.rpcCodeMessage || res.rpcMessage || 'the node rejected the transaction';
|
||||
throw new BackendError(`broadcast failed: ${detail}`, 502, 'BROADCAST_REJECTED');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,570 +0,0 @@
|
||||
// Coin selection and PSBT construction/signing for the native on-chain wallet backend.
|
||||
//
|
||||
// This is the raw-transaction half of the wallet: given a set of UTXOs discovered from the chain (see
|
||||
// chain.ts) and a BIP32 root supplied by the caller, it picks inputs, builds a bitcoinjs-lib Psbt,
|
||||
// signs it, and hands back a finalised transaction ready for `EsploraClient.broadcast`.
|
||||
//
|
||||
// It is modelled on Zeus's SweepStore (stores/SweepStore.ts) — the same p2pkh / p2sh-p2wpkh / p2wpkh /
|
||||
// p2tr input construction, the same `toXOnly` from bitcoinjs-lib/src/psbt/bip371 — with three
|
||||
// differences that matter:
|
||||
//
|
||||
// 1. Zeus sweeps a single WIF key and estimates the fee by signing a throwaway PSBT. Here the fee is
|
||||
// estimated analytically from per-script-type weights (see WEIGHTS below), because selection has
|
||||
// to know the fee before it knows the input set, and signing to find out costs a key.
|
||||
// 2. Zeus refuses p2tr sweeps (ZEUS-3276). Taproot key-path spends work here: the private key is
|
||||
// BIP341-tweaked before signing and the signer exposes `signSchnorr`.
|
||||
// 3. SECURITY: no function in this file reads a seed from disk, env or module state. `signAndFinalize`
|
||||
// takes the root HDKey as a parameter and the caller — keys.ts, via the backend's signer interface
|
||||
// — decides whether it is willing to hand one over. Broadcast deliberately lives in chain.ts so
|
||||
// the root is never held across an `await`.
|
||||
|
||||
import type { HDKey } from '@scure/bip32';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
import { BackendError, type AddressType, type BitcoinNetwork } from './types';
|
||||
|
||||
// bitcoinjs-lib needs an ECC backend for anything taproot (x-only point tweaking). Module-level and
|
||||
// idempotent — `initEcc()` is exported so a consumer that only touches address derivation can force it
|
||||
// without depending on import order.
|
||||
let eccReady = false;
|
||||
|
||||
export function initEcc(): void {
|
||||
if (eccReady) return;
|
||||
bitcoin.initEccLib(ecc);
|
||||
eccReady = true;
|
||||
}
|
||||
|
||||
initEcc();
|
||||
|
||||
// ── networks ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* bitcoinjs-lib ships bitcoin/testnet/regtest only. Signet shares testnet's address parameters exactly
|
||||
* (`tb` bech32 HRP, 0x6f p2pkh version, 0xc4 p2sh version) — only the genesis block and message magic
|
||||
* differ, and neither is used for address encoding or signing — so signet maps onto testnet.
|
||||
*/
|
||||
export function networkFor(network: BitcoinNetwork): bitcoin.Network {
|
||||
switch (network) {
|
||||
case 'bitcoin':
|
||||
return bitcoin.networks.bitcoin;
|
||||
case 'regtest':
|
||||
return bitcoin.networks.regtest;
|
||||
case 'testnet':
|
||||
case 'signet':
|
||||
return bitcoin.networks.testnet;
|
||||
}
|
||||
}
|
||||
|
||||
/** BIP44 coin type: 0 for mainnet, 1 for every test chain (SLIP-44 "Testnet (all coins)"). */
|
||||
export function coinTypeFor(network: BitcoinNetwork): 0 | 1 {
|
||||
return network === 'bitcoin' ? 0 : 1;
|
||||
}
|
||||
|
||||
// ── script classification ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Classify a scriptPubKey. Returns null for anything the wallet cannot own or size precisely (p2wsh,
|
||||
* bare multisig, op_return, future witness versions).
|
||||
*
|
||||
* Note that a bare p2sh script is reported as 'p2sh-p2wpkh'. On the *output* side that is exact — every
|
||||
* p2sh output is 23 bytes regardless of what redeems it — and on the input side the wallet only ever
|
||||
* owns the wrapped-segwit form, so the conflation is safe in both directions.
|
||||
*/
|
||||
export function scriptType(script: Uint8Array): AddressType | null {
|
||||
const b = script;
|
||||
if (b.length === 25 && b[0] === 0x76 && b[1] === 0xa9 && b[2] === 0x14 && b[23] === 0x88 && b[24] === 0xac) {
|
||||
return 'p2pkh';
|
||||
}
|
||||
if (b.length === 23 && b[0] === 0xa9 && b[1] === 0x14 && b[22] === 0x87) return 'p2sh-p2wpkh';
|
||||
if (b.length === 22 && b[0] === 0x00 && b[1] === 0x14) return 'p2wpkh';
|
||||
if (b.length === 34 && b[0] === 0x51 && b[1] === 0x20) return 'p2tr';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Same, from hex. */
|
||||
export function scriptTypeFromHex(hex: string): AddressType | null {
|
||||
return scriptType(Buffer.from(hex, 'hex'));
|
||||
}
|
||||
|
||||
/** Classify a destination address by the script it encodes. Throws when the address is not valid here. */
|
||||
export function addressScriptType(address: string, network: bitcoin.Network): AddressType | null {
|
||||
return scriptType(outputScriptFor(address, network));
|
||||
}
|
||||
|
||||
/** `bitcoin.address.toOutputScript` with the failure turned into a 400 instead of a bare Error. */
|
||||
export function outputScriptFor(address: string, network: bitcoin.Network): Buffer {
|
||||
try {
|
||||
return bitcoin.address.toOutputScript(address, network);
|
||||
} catch {
|
||||
throw new BackendError(`invalid address for this network: ${address}`, 400, 'INVALID_ADDRESS');
|
||||
}
|
||||
}
|
||||
|
||||
// ── size and dust constants ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Everything here is in *weight units* (4 wu = 1 vbyte) so segwit's quarter-vbyte discount survives the
|
||||
// arithmetic instead of being rounded away per input. A flat 148-in/34-out estimate — the usual
|
||||
// shortcut — overpays a p2wpkh spend by ~2.2x and a p2tr spend by ~2.6x, which at any real fee rate is
|
||||
// money handed to a miner for nothing.
|
||||
//
|
||||
// Input weights, assuming a 72-byte low-R DER signature (71 is common, 72 is the worst case) and a
|
||||
// 33-byte compressed pubkey:
|
||||
//
|
||||
// p2pkh 32 txid + 4 vout + 1 len + 107 scriptSig + 4 seq = 148 vB → 592 wu
|
||||
// p2sh-p2wpkh 64 base vB (scriptSig = 23-byte redeemScript push) + 108 wu witness → 364 wu
|
||||
// p2wpkh 41 base vB + 108 wu witness (2 items: 72-byte sig, 33-byte key) → 272 wu
|
||||
// p2tr key-path 41 base vB + 66 wu witness (1 item: 64-byte schnorr sig) → 230 wu
|
||||
//
|
||||
// which come out at 148 / 91 / 68 / 57.5 vbytes — the same numbers Bitcoin Core assumes.
|
||||
const INPUT_WEIGHT: Record<AddressType, number> = {
|
||||
p2pkh: 592,
|
||||
'p2sh-p2wpkh': 364,
|
||||
p2wpkh: 272,
|
||||
p2tr: 230,
|
||||
};
|
||||
|
||||
// Output weights: 8-byte value + 1-byte script length + the script itself, all ×4.
|
||||
// p2pkh 25B script → 34 vB, p2sh 23B → 32 vB, p2wpkh 22B → 31 vB, p2tr 34B → 43 vB.
|
||||
const OUTPUT_WEIGHT: Record<AddressType, number> = {
|
||||
p2pkh: 136,
|
||||
'p2sh-p2wpkh': 128,
|
||||
p2wpkh: 124,
|
||||
p2tr: 172,
|
||||
};
|
||||
|
||||
/** Unclassifiable output (p2wsh, future witness versions): charge the 43-vbyte p2tr/p2wsh size. */
|
||||
const OUTPUT_WEIGHT_UNKNOWN = 172;
|
||||
|
||||
/** version(4) + locktime(4), ×4. The input/output count varints are added separately. */
|
||||
const TX_OVERHEAD_WEIGHT = 32;
|
||||
|
||||
/** Segwit marker + flag: 1 byte each, but they live in the witness so they weigh 1 wu each. */
|
||||
const SEGWIT_MARKER_WEIGHT = 2;
|
||||
|
||||
/**
|
||||
* Bitcoin Core's dust threshold: 3 sat/vB (the default dustRelayFee) times the size of the output plus
|
||||
* the size of the input that would eventually spend it — witness inputs are counted as a flat 67 vB.
|
||||
* p2pkh (34+148)*3 = 546 · p2sh (32+148)*3 = 540 · p2wpkh (31+67)*3 = 294 · p2tr (43+67)*3 = 330
|
||||
* A change output below its threshold is not created; the remainder is donated to the fee instead.
|
||||
*/
|
||||
export const DUST_THRESHOLD: Record<AddressType, number> = {
|
||||
p2pkh: 546,
|
||||
'p2sh-p2wpkh': 540,
|
||||
p2wpkh: 294,
|
||||
p2tr: 330,
|
||||
};
|
||||
|
||||
/** Dust floor for an output we could not classify — use the most demanding value we know. */
|
||||
const DUST_UNKNOWN = 546;
|
||||
|
||||
/** The network will not relay below this, so a caller asking for less gets bumped rather than stuck. */
|
||||
const MIN_SAT_VB = 1;
|
||||
|
||||
function varintWeight(n: number): number {
|
||||
if (n < 0xfd) return 4;
|
||||
if (n <= 0xffff) return 12;
|
||||
return 20;
|
||||
}
|
||||
|
||||
function isSegwit(type: AddressType): boolean {
|
||||
return type !== 'p2pkh';
|
||||
}
|
||||
|
||||
export type VsizeParams = {
|
||||
inputs: AddressType[];
|
||||
/** Output script types, in order. `null` means "unclassifiable" and is charged 43 vbytes. */
|
||||
outputs: (AddressType | null)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Virtual size of the transaction these inputs and outputs would produce, rounded up. Accurate to
|
||||
* within one vbyte per input (the DER signature is occasionally 71 bytes rather than 72), always in the
|
||||
* conservative direction, so the realised fee rate lands at or just above what was asked for.
|
||||
*/
|
||||
export function estimateVsize({ inputs, outputs }: VsizeParams): number {
|
||||
let weight = TX_OVERHEAD_WEIGHT + varintWeight(inputs.length) + varintWeight(outputs.length);
|
||||
if (inputs.some(isSegwit)) weight += SEGWIT_MARKER_WEIGHT;
|
||||
for (const type of inputs) weight += INPUT_WEIGHT[type];
|
||||
for (const type of outputs) weight += type === null ? OUTPUT_WEIGHT_UNKNOWN : OUTPUT_WEIGHT[type];
|
||||
return Math.ceil(weight / 4);
|
||||
}
|
||||
|
||||
// ── coin selection ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A UTXO the selector may spend. `derivationPath` is the FULL path from the wallet root
|
||||
* (`m/84'/0'/0'/0/7`), not the account-relative form the public Utxo type carries, because it is used
|
||||
* verbatim to derive the signing key.
|
||||
*/
|
||||
export type SpendableUtxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
address: string;
|
||||
addressType: AddressType;
|
||||
confirmations: number;
|
||||
derivationPath: string;
|
||||
frozen: boolean;
|
||||
/** scriptPubKey of the output being spent, hex. */
|
||||
scriptPubKeyHex: string;
|
||||
/** Compressed 33-byte pubkey of the owning address, hex. */
|
||||
pubkeyHex: string;
|
||||
};
|
||||
|
||||
export type CoinSelectionParams = {
|
||||
utxos: SpendableUtxo[];
|
||||
/** Sats to pay the recipient. Ignored when `sendAll` is set. */
|
||||
targetSats: number;
|
||||
sendAll?: boolean;
|
||||
satPerVbyte: number;
|
||||
/** Script type of the recipient output; null when it is a script we cannot size exactly. */
|
||||
recipientType: AddressType | null;
|
||||
/** Script type the change output would use. */
|
||||
changeType: AddressType;
|
||||
/** Coin control: restrict the input set to these `txid:vout` outpoints. */
|
||||
outpoints?: string[];
|
||||
spendUnconfirmed?: boolean;
|
||||
};
|
||||
|
||||
export type CoinSelection = {
|
||||
inputs: SpendableUtxo[];
|
||||
/** Sats actually paid to the recipient. Equals `targetSats` unless `sendAll`. */
|
||||
outputSats: number;
|
||||
/** Sats returned to the wallet, or null when no change output is created. */
|
||||
changeSats: number | null;
|
||||
feeSats: number;
|
||||
/** The vsize the fee was computed from. */
|
||||
vsize: number;
|
||||
};
|
||||
|
||||
const sumSats = (utxos: SpendableUtxo[]): number => utxos.reduce((acc, u) => acc + u.amountSats, 0);
|
||||
|
||||
const outpointOf = (u: SpendableUtxo): string => `${u.txid}:${u.vout}`;
|
||||
|
||||
function dustFor(type: AddressType | null): number {
|
||||
return type === null ? DUST_UNKNOWN : DUST_THRESHOLD[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Coin selection: a two-phase accumulative selector. Deliberately not branch-and-bound — BnB's payoff
|
||||
* is finding changeless solutions in a large UTXO set, and it needs a waste metric plus a fallback
|
||||
* anyway. Instead:
|
||||
*
|
||||
* Phase 1 — smallest sufficient single input. Walk the eligible UTXOs smallest-first and take the
|
||||
* first one that can cover the payment on its own. One input is the cheapest possible
|
||||
* transaction, and going smallest-first quietly consolidates the wallet's dust over time.
|
||||
* Phase 2 — largest-first accumulation. Nothing single-handedly covers it, so add UTXOs
|
||||
* largest-first (confirmed before unconfirmed) until the total covers payment + fee. Going
|
||||
* largest-first minimises the input count, and each input costs real money.
|
||||
*
|
||||
* Both phases decide change the same way: prefer a change output, but if what is left after the
|
||||
* with-change fee falls under the dust threshold, drop the change output and donate the remainder to
|
||||
* the fee. The donation is bounded by roughly (one output's fee + one dust threshold), so it can never
|
||||
* quietly become a large overpayment.
|
||||
*/
|
||||
export function selectCoins(params: CoinSelectionParams): CoinSelection {
|
||||
const rate = Math.max(params.satPerVbyte, MIN_SAT_VB);
|
||||
const allow = params.outpoints && params.outpoints.length > 0 ? new Set(params.outpoints) : null;
|
||||
|
||||
const eligible = params.utxos.filter((u) => {
|
||||
// Frozen is never overridable, not even by naming the outpoint. Freezing is the owner saying "this
|
||||
// coin does not exist for spending purposes" — a reservation against an open channel, a coin held
|
||||
// for tax lots, dust being kept out of the way — and a rule that a sufficiently explicit request
|
||||
// can bypass is not a rule. The route refuses a named frozen outpoint before ever reaching here;
|
||||
// this is what makes that true for any future caller too.
|
||||
if (u.frozen) return false;
|
||||
// An explicit coin-control pick is otherwise authoritative: it overrides the confirmed-only
|
||||
// default, because the user named this exact outpoint and can see what it is.
|
||||
if (allow) return allow.has(outpointOf(u));
|
||||
if (!params.spendUnconfirmed && u.confirmations < 1) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (eligible.length === 0) {
|
||||
const why = allow ? 'none of the selected outpoints are spendable' : 'no spendable UTXOs';
|
||||
throw new BackendError(why, 400, 'INSUFFICIENT_FUNDS');
|
||||
}
|
||||
|
||||
if (params.sendAll) return sweepAll(eligible, rate, params.recipientType);
|
||||
|
||||
if (!Number.isInteger(params.targetSats) || params.targetSats <= 0) {
|
||||
throw new BackendError('amountSats must be a positive integer', 400, 'INVALID_AMOUNT');
|
||||
}
|
||||
|
||||
const attempt = (inputs: SpendableUtxo[]): CoinSelection | null => {
|
||||
const total = sumSats(inputs);
|
||||
const types = inputs.map((u) => u.addressType);
|
||||
const withChange = estimateVsize({ inputs: types, outputs: [params.recipientType, params.changeType] });
|
||||
const noChange = estimateVsize({ inputs: types, outputs: [params.recipientType] });
|
||||
const feeWithChange = Math.ceil(withChange * rate);
|
||||
const feeNoChange = Math.ceil(noChange * rate);
|
||||
|
||||
const change = total - params.targetSats - feeWithChange;
|
||||
if (change >= DUST_THRESHOLD[params.changeType]) {
|
||||
return {
|
||||
inputs: [...inputs],
|
||||
outputSats: params.targetSats,
|
||||
changeSats: change,
|
||||
feeSats: feeWithChange,
|
||||
vsize: withChange,
|
||||
};
|
||||
}
|
||||
if (total >= params.targetSats + feeNoChange) {
|
||||
// Changeless: everything above the payment is fee. Smaller transaction, no dust output created.
|
||||
return {
|
||||
inputs: [...inputs],
|
||||
outputSats: params.targetSats,
|
||||
changeSats: null,
|
||||
feeSats: total - params.targetSats,
|
||||
vsize: noChange,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Phase 1 — smallest sufficient single input.
|
||||
const ascending = [...eligible].sort((a, b) => a.amountSats - b.amountSats);
|
||||
for (const utxo of ascending) {
|
||||
const single = attempt([utxo]);
|
||||
if (single) return single;
|
||||
}
|
||||
|
||||
// Phase 2 — largest-first accumulation, confirmed coins ahead of unconfirmed ones.
|
||||
const ordered = [...eligible].sort((a, b) => {
|
||||
const aPending = a.confirmations > 0 ? 0 : 1;
|
||||
const bPending = b.confirmations > 0 ? 0 : 1;
|
||||
if (aPending !== bPending) return aPending - bPending;
|
||||
return b.amountSats - a.amountSats;
|
||||
});
|
||||
|
||||
const chosen: SpendableUtxo[] = [];
|
||||
for (const utxo of ordered) {
|
||||
chosen.push(utxo);
|
||||
const selection = attempt(chosen);
|
||||
if (selection) return selection;
|
||||
}
|
||||
|
||||
const available = sumSats(eligible);
|
||||
throw new BackendError(
|
||||
`insufficient funds: ${available} sat available, ${params.targetSats} sat requested plus fees`,
|
||||
400,
|
||||
'INSUFFICIENT_FUNDS',
|
||||
);
|
||||
}
|
||||
|
||||
/** sendAll: every eligible coin in, one output out, the fee taken off that output. */
|
||||
function sweepAll(inputs: SpendableUtxo[], rate: number, recipientType: AddressType | null): CoinSelection {
|
||||
const total = sumSats(inputs);
|
||||
const vsize = estimateVsize({ inputs: inputs.map((u) => u.addressType), outputs: [recipientType] });
|
||||
const feeSats = Math.ceil(vsize * rate);
|
||||
const outputSats = total - feeSats;
|
||||
if (outputSats < dustFor(recipientType)) {
|
||||
throw new BackendError(
|
||||
`sweep leaves ${outputSats} sat after a ${feeSats} sat fee, below the dust threshold`,
|
||||
400,
|
||||
'INSUFFICIENT_FUNDS',
|
||||
);
|
||||
}
|
||||
return { inputs: [...inputs], outputSats, changeSats: null, feeSats, vsize };
|
||||
}
|
||||
|
||||
// ── PSBT construction ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** RBF-signalling sequence (BIP125): any input below 0xfffffffe marks the whole transaction replaceable. */
|
||||
const SEQUENCE_RBF = 0xfffffffd;
|
||||
const SEQUENCE_FINAL = 0xffffffff;
|
||||
|
||||
export type PsbtInputSource = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
addressType: AddressType;
|
||||
/** scriptPubKey being spent, hex. */
|
||||
scriptPubKeyHex: string;
|
||||
/** Compressed 33-byte pubkey of the owning address, hex. */
|
||||
pubkeyHex: string;
|
||||
/** Full BIP32 path from the wallet root — returned in `inputPaths` for `signAndFinalize`. */
|
||||
derivationPath: string;
|
||||
/** Whole previous transaction, hex. Required for p2pkh inputs, unused otherwise. */
|
||||
prevTxHex?: string;
|
||||
};
|
||||
|
||||
export type PsbtOutputSpec = { address: string; amountSats: number };
|
||||
|
||||
export type BuildPsbtParams = {
|
||||
network: bitcoin.Network;
|
||||
inputs: PsbtInputSource[];
|
||||
outputs: PsbtOutputSpec[];
|
||||
/** Signal RBF. Default true. */
|
||||
rbf?: boolean;
|
||||
locktime?: number;
|
||||
};
|
||||
|
||||
export type BuiltPsbt = {
|
||||
psbt: bitcoin.Psbt;
|
||||
/** Derivation paths in input order — pass straight to `signAndFinalize`. */
|
||||
inputPaths: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an unsigned PSBT. Input construction is per script type, exactly as Zeus's SweepStore does it:
|
||||
* legacy p2pkh needs the whole previous transaction (`nonWitnessUtxo`), segwit needs only the output
|
||||
* being spent (`witnessUtxo`), wrapped segwit additionally needs the p2wpkh `redeemScript`, and taproot
|
||||
* needs the x-only internal key so the signer can be matched against the output key.
|
||||
*/
|
||||
export function buildPsbt(params: BuildPsbtParams): BuiltPsbt {
|
||||
initEcc();
|
||||
const { network } = params;
|
||||
if (params.inputs.length === 0) throw new BackendError('cannot build a PSBT with no inputs', 400);
|
||||
if (params.outputs.length === 0) throw new BackendError('cannot build a PSBT with no outputs', 400);
|
||||
|
||||
const psbt = new bitcoin.Psbt({ network });
|
||||
psbt.setVersion(2);
|
||||
psbt.setLocktime(params.locktime ?? 0);
|
||||
|
||||
const sequence = params.rbf === false ? SEQUENCE_FINAL : SEQUENCE_RBF;
|
||||
const inputPaths: string[] = [];
|
||||
|
||||
for (const source of params.inputs) {
|
||||
const pubkey = Buffer.from(source.pubkeyHex, 'hex');
|
||||
const script = Buffer.from(source.scriptPubKeyHex, 'hex');
|
||||
const base = { hash: source.txid, index: source.vout, sequence };
|
||||
|
||||
switch (source.addressType) {
|
||||
case 'p2pkh': {
|
||||
if (!source.prevTxHex) {
|
||||
throw new BackendError(`p2pkh input ${source.txid}:${source.vout} needs the previous tx hex`, 500);
|
||||
}
|
||||
psbt.addInput({ ...base, nonWitnessUtxo: Buffer.from(source.prevTxHex, 'hex') });
|
||||
break;
|
||||
}
|
||||
case 'p2wpkh': {
|
||||
psbt.addInput({ ...base, witnessUtxo: { script, value: source.amountSats } });
|
||||
break;
|
||||
}
|
||||
case 'p2sh-p2wpkh': {
|
||||
const redeem = bitcoin.payments.p2wpkh({ pubkey, network });
|
||||
if (!redeem.output) throw new BackendError('failed to build the p2wpkh redeemScript', 500);
|
||||
psbt.addInput({
|
||||
...base,
|
||||
witnessUtxo: { script, value: source.amountSats },
|
||||
redeemScript: redeem.output,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'p2tr': {
|
||||
psbt.addInput({
|
||||
...base,
|
||||
witnessUtxo: { script, value: source.amountSats },
|
||||
tapInternalKey: toXOnly(pubkey),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
inputPaths.push(source.derivationPath);
|
||||
}
|
||||
|
||||
for (const output of params.outputs) {
|
||||
// Validate against the configured network before adding — bitcoinjs would accept a foreign-network
|
||||
// address encoded for a chain with the same prefixes and silently burn the funds.
|
||||
outputScriptFor(output.address, network);
|
||||
psbt.addOutput({ address: output.address, value: output.amountSats });
|
||||
}
|
||||
|
||||
return { psbt, inputPaths };
|
||||
}
|
||||
|
||||
// ── signing ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SignedTx = {
|
||||
txid: string;
|
||||
rawHex: string;
|
||||
/** Realised vsize of the signed transaction — compare against the estimate to audit fee accuracy. */
|
||||
vsize: number;
|
||||
feeSats: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* BIP341 key-path tweak. The output key is `P + H_TapTweak(P) * G` where P is the x-only internal key,
|
||||
* so the private key has to be tweaked the same way before it can produce a matching schnorr signature.
|
||||
* If the internal point has odd Y the scalar is negated first, since x-only keys are always even-Y.
|
||||
*/
|
||||
function tweakPrivateKey(priv: Uint8Array, pubkey: Buffer): Uint8Array {
|
||||
const even = pubkey[0] === 0x02 ? priv : ecc.privateNegate(priv);
|
||||
const tweak = bitcoin.crypto.taggedHash('TapTweak', toXOnly(pubkey));
|
||||
const tweaked = ecc.privateAdd(even, tweak);
|
||||
if (!tweaked) throw new BackendError('taproot tweak produced an invalid key', 500);
|
||||
return tweaked;
|
||||
}
|
||||
|
||||
function keyMaterial(node: HDKey): { priv: Uint8Array; pubkey: Buffer } {
|
||||
const priv = node.privateKey;
|
||||
const pub = node.publicKey;
|
||||
if (!priv || !pub) throw new BackendError('derived node has no private key — cannot sign', 500);
|
||||
return { priv, pubkey: Buffer.from(pub) };
|
||||
}
|
||||
|
||||
/** ECDSA signer for p2pkh / p2sh-p2wpkh / p2wpkh inputs. */
|
||||
function ecdsaSigner(node: HDKey): bitcoin.Signer {
|
||||
const { priv, pubkey } = keyMaterial(node);
|
||||
return {
|
||||
publicKey: pubkey,
|
||||
sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, priv)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnorr signer for a taproot key-path spend. `publicKey` must be the TWEAKED key: bitcoinjs matches
|
||||
* `toXOnly(signer.publicKey)` against the output key in the prevout (psbt.js getTaprootHashesForSig),
|
||||
* and the untweaked internal key would simply not match, failing with "Can not sign for input".
|
||||
*/
|
||||
function taprootSigner(node: HDKey): bitcoin.Signer {
|
||||
const { priv, pubkey } = keyMaterial(node);
|
||||
const tweakedPriv = tweakPrivateKey(priv, pubkey);
|
||||
const tweakedPub = ecc.pointFromScalar(tweakedPriv, true);
|
||||
if (!tweakedPub) throw new BackendError('taproot tweak produced an invalid point', 500);
|
||||
return {
|
||||
publicKey: Buffer.from(tweakedPub),
|
||||
sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, tweakedPriv)),
|
||||
signSchnorr: (hash: Buffer) => Buffer.from(ecc.signSchnorr(hash, tweakedPriv)),
|
||||
};
|
||||
}
|
||||
|
||||
/** @scure/bip32 only accepts the apostrophe form of a hardened index. */
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/[hH]/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign every input from keys derived off `root`, finalise, and extract the transaction.
|
||||
*
|
||||
* `root` is a PARAMETER and is never cached, stored or closed over past this call: the caller holds the
|
||||
* key material and this function borrows it for the duration of a synchronous signing pass. Nothing
|
||||
* here awaits, so the root cannot be pinned alive by a pending network call.
|
||||
*/
|
||||
export function signAndFinalize(psbt: bitcoin.Psbt, root: HDKey, inputPaths: string[]): SignedTx {
|
||||
initEcc();
|
||||
const count = psbt.data.inputs.length;
|
||||
if (inputPaths.length !== count) {
|
||||
throw new BackendError(`expected ${count} derivation paths, got ${inputPaths.length}`, 500);
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const path = inputPaths[i];
|
||||
const input = psbt.data.inputs[i];
|
||||
if (path === undefined || input === undefined) {
|
||||
throw new BackendError(`missing derivation path for input #${i}`, 500);
|
||||
}
|
||||
const node = root.derive(normalizePath(path));
|
||||
// `tapInternalKey` is set only by the p2tr branch of buildPsbt, so it is the authoritative marker
|
||||
// for "this input needs a schnorr signature over a tweaked key".
|
||||
psbt.signInput(i, input.tapInternalKey ? taprootSigner(node) : ecdsaSigner(node));
|
||||
}
|
||||
|
||||
psbt.finalizeAllInputs();
|
||||
const feeSats = psbt.getFee();
|
||||
const tx = psbt.extractTransaction();
|
||||
return { txid: tx.getId(), rawHex: tx.toHex(), vsize: tx.virtualSize(), feeSats };
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { ChainCacheStore } from './backends/onchain';
|
||||
import type { WalletChainSource } from './chain-source';
|
||||
import type { ChainSource } from './upstream';
|
||||
import {
|
||||
getWallet,
|
||||
getWalletSecrets,
|
||||
getWalletChainCache,
|
||||
saveWalletChainCache,
|
||||
recordWalletChainError,
|
||||
type WalletSummary,
|
||||
} from 'officerdb';
|
||||
import { EsploraChain } from './chain';
|
||||
import { EsploraChainSource } from './chain-source-esplora';
|
||||
import { NbxplorerChain } from './nbxplorer';
|
||||
import { NbxplorerChainSource } from './chain-source-nbxplorer';
|
||||
import { LndBackend } from './backends/lnd';
|
||||
import { ClnRestBackend } from './backends/clnrest';
|
||||
import { LndHubBackend } from './backends/lndhub';
|
||||
import { NwcBackend } from './backends/nwc';
|
||||
import { OnchainBackend } from './backends/onchain';
|
||||
import { sessionFor } from './keys';
|
||||
import { getChainSource } from './upstream';
|
||||
import { BackendError, BIP_ADDRESS_TYPE, type AddressType, type BitcoinNetwork, type WalletBackend } from './types';
|
||||
|
||||
// Turns a stored wallet row into a live backend instance. This is the one place that knows the mapping
|
||||
// from `kind` to a class, and the one place node credentials are decrypted — getWalletSecrets() is
|
||||
// called here and the plaintext never travels further than the constructor it is handed to.
|
||||
//
|
||||
// Instances are cached per wallet id. Backends hold connection state worth reusing (LNDHub's access
|
||||
// token, NWC's relay socket, the on-chain gap-limit scan), and rebuilding one per request would both
|
||||
// re-authenticate constantly and defeat the address-scan cache. The cache is invalidated whenever the
|
||||
// wallet's config changes — see `invalidate()`, called from the update/delete routes.
|
||||
|
||||
type Cached = { backend: WalletBackend; configVersion: string };
|
||||
const cache = new Map<number, Cached>();
|
||||
|
||||
export function invalidate(walletId: number): void {
|
||||
cache.delete(walletId);
|
||||
}
|
||||
|
||||
export function invalidateAll(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */
|
||||
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): string {
|
||||
// The kind is in here as well as the URL: switching protocol at the same host is a different backend
|
||||
// with a differently-shaped cache behind it, and a fingerprint on the URL alone would miss it.
|
||||
return JSON.stringify([
|
||||
wallet.kind,
|
||||
wallet.network,
|
||||
wallet.defaultBip,
|
||||
wallet.xpubs,
|
||||
config,
|
||||
source.kind,
|
||||
source.baseUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
export type Resolved = { wallet: WalletSummary; backend: WalletBackend };
|
||||
|
||||
export async function resolveBackend(userId: number, walletId: number): Promise<Resolved> {
|
||||
const wallet = await getWallet(userId, walletId);
|
||||
if (!wallet) throw new BackendError('wallet not found', 404, 'NOT_FOUND');
|
||||
|
||||
const secrets = await getWalletSecrets(userId, walletId);
|
||||
const config = secrets?.config ?? null;
|
||||
// Read per resolve rather than per build: the source is in the fingerprint, so pointing the owner at a
|
||||
// different indexer rebuilds every backend and drops the scan taken through the old one.
|
||||
const source = await getChainSource(userId);
|
||||
const version = versionOf(wallet, config, source);
|
||||
|
||||
const hit = cache.get(walletId);
|
||||
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
|
||||
|
||||
const backend = build(wallet, config, source);
|
||||
cache.set(walletId, { backend, configVersion: version });
|
||||
return { wallet, backend };
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the chain cache queries to one wallet id. This is the only thing that gives the on-chain
|
||||
* backend a persistent identity — the backend itself never learns which wallet it is, exactly as it
|
||||
* never learns which seed it derives from.
|
||||
*/
|
||||
function chainCacheFor(walletId: number): ChainCacheStore {
|
||||
return {
|
||||
load: () => getWalletChainCache(walletId),
|
||||
save: (snapshot) => saveWalletChainCache(walletId, snapshot),
|
||||
recordError: (message) => recordWalletChainError(walletId, message),
|
||||
};
|
||||
}
|
||||
|
||||
function required(config: Record<string, unknown> | null, key: string, kind: string): string {
|
||||
const v = config?.[key];
|
||||
if (typeof v !== 'string' || !v) {
|
||||
throw new BackendError(`${kind} wallet is missing required config "${key}"`, 400, 'BAD_CONFIG');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function build(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): WalletBackend {
|
||||
const network = wallet.network as BitcoinNetwork;
|
||||
|
||||
switch (wallet.kind) {
|
||||
case 'lnd':
|
||||
return new LndBackend({
|
||||
url: required(config, 'url', 'lnd'),
|
||||
macaroonHex: required(config, 'macaroonHex', 'lnd'),
|
||||
allowSelfSigned: config?.allowSelfSigned === true,
|
||||
});
|
||||
|
||||
case 'cln-rest':
|
||||
return new ClnRestBackend({
|
||||
url: required(config, 'url', 'cln-rest'),
|
||||
rune: required(config, 'rune', 'cln-rest'),
|
||||
allowSelfSigned: config?.allowSelfSigned === true,
|
||||
});
|
||||
|
||||
case 'lndhub':
|
||||
return new LndHubBackend({
|
||||
url: required(config, 'url', 'lndhub'),
|
||||
login: required(config, 'login', 'lndhub'),
|
||||
password: required(config, 'password', 'lndhub'),
|
||||
});
|
||||
|
||||
case 'nwc':
|
||||
return new NwcBackend({ connectionUri: required(config, 'connectionUri', 'nwc') });
|
||||
|
||||
case 'onchain': {
|
||||
// Every xpub the wallet holds is handed over, not just the default BIP's. A seed derives all four
|
||||
// accounts (keys.ts::deriveAccountXpubs), and coins can legitimately sit on any of them — a
|
||||
// recovered seed may have been used with a p2tr wallet before, or received to a legacy address.
|
||||
// Scanning only the default account would silently under-report the balance and leave those UTXOs
|
||||
// unspendable. `defaultBip` then means only "which script type new receive addresses use".
|
||||
const accountXpub: Partial<Record<AddressType, string>> = {};
|
||||
for (const [bip, type] of Object.entries(BIP_ADDRESS_TYPE)) {
|
||||
const xpub = wallet.xpubs?.[bip];
|
||||
if (xpub) accountXpub[type] = xpub;
|
||||
}
|
||||
if (Object.keys(accountXpub).length === 0) {
|
||||
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
|
||||
}
|
||||
return new OnchainBackend({
|
||||
chain: chainSourceFor(source, network),
|
||||
network,
|
||||
accountXpub,
|
||||
// The session is the signer. While locked it holds no key material, so watch-only reads below
|
||||
// still work and only sendCoins/signMessage will throw WalletLockedError.
|
||||
signer: sessionFor(wallet.id),
|
||||
cache: chainCacheFor(wallet.id),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place the owner's chosen protocol turns into an implementation. Both satisfy the same
|
||||
* interface, and OnchainBackend cannot tell which it was handed — that is the entire point of the seam
|
||||
* (chain-source.ts). Adding a third kind is a case here and a file next to it, and nothing else.
|
||||
*/
|
||||
function chainSourceFor(source: ChainSource, network: BitcoinNetwork): WalletChainSource {
|
||||
const label = `${source.kind}(${hostOf(source.baseUrl)})`;
|
||||
|
||||
if (source.kind === 'nbxplorer') {
|
||||
return new NbxplorerChainSource({
|
||||
chain: new NbxplorerChain({ baseUrl: source.baseUrl, network }),
|
||||
network,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
return new EsploraChainSource({ chain: new EsploraChain({ baseUrl: source.baseUrl, network }), label });
|
||||
}
|
||||
|
||||
/** Host only, for a chain-source label. A malformed URL is labelled with itself rather than throwing. */
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -1,658 +0,0 @@
|
||||
import {
|
||||
listWallets,
|
||||
getWallet,
|
||||
getSealedSeed,
|
||||
createWallet,
|
||||
updateWallet,
|
||||
replaceSealedSeed,
|
||||
setActiveWallet,
|
||||
deleteWallet,
|
||||
getActiveWallet,
|
||||
getWalletLabels,
|
||||
setWalletLabel,
|
||||
getFrozenOutpoints,
|
||||
setUtxoFrozen,
|
||||
type WalletKind,
|
||||
} from 'officerdb';
|
||||
import { resolveBackend, invalidate } from './resolve';
|
||||
import {
|
||||
changePassphrase,
|
||||
deriveAccountXpubs,
|
||||
exportMnemonic,
|
||||
generateSeed,
|
||||
sealSeed,
|
||||
sessionFor,
|
||||
verifyPassphrase,
|
||||
type SeedEnvelope,
|
||||
} from './keys';
|
||||
import { getConfig, hasStoreKey } from './upstream';
|
||||
import {
|
||||
asAddressType,
|
||||
BackendError,
|
||||
WalletLockedError,
|
||||
BIP_ADDRESS_TYPE,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type Balances,
|
||||
type SyncState,
|
||||
type Utxo,
|
||||
type WalletBackend,
|
||||
} from './types';
|
||||
|
||||
// The wallet sidecar's route surface. Every route is scoped to the authenticated owner via X-Officer-User,
|
||||
// which the platform proxy injects (src/servers/api/wallet/router.ts) and which is trustworthy because the
|
||||
// sidecar binds loopback only.
|
||||
//
|
||||
// Route ordering matters here: the seed/lock routes are matched BEFORE the generic wallet operations, so a
|
||||
// wallet named "unlock" can never shadow the unlock endpoint.
|
||||
//
|
||||
// SECURITY NOTES that apply to this whole file:
|
||||
// - No route ever returns a mnemonic, a sealed envelope, a node macaroon, or an unlock passphrase,
|
||||
// except /export-seed, which exists for backup and demands the passphrase every single time.
|
||||
// - Passphrases arrive in request bodies and are never logged. The catch-all handler at the bottom logs
|
||||
// the method and path only, deliberately not the body.
|
||||
// - Capability checks happen before dispatch so an unsupported operation is a clean 501.
|
||||
|
||||
export type OfficerContext = { req: Request; url: URL; userId: number };
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return Response.json(data as Record<string, unknown>, { status });
|
||||
}
|
||||
|
||||
function badRequest(message: string): Response {
|
||||
return json({ error: message }, 400);
|
||||
}
|
||||
|
||||
async function body<T>(req: Request): Promise<T> {
|
||||
try {
|
||||
return (await req.json()) as T;
|
||||
} catch {
|
||||
throw new BackendError('expected a JSON body', 400, 'BAD_BODY');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Freshness of the data a read just returned, or null from a backend that has no cache to be stale.
|
||||
* Attached to balances, transactions and utxos so the UI can distinguish an empty wallet from one it
|
||||
* could not reach — those rendered identically before, as a zero balance.
|
||||
*
|
||||
* Must be read AFTER the response it describes: it reports the snapshot that was actually served.
|
||||
*/
|
||||
function syncOf(backend: WalletBackend): SyncState | null {
|
||||
return backend.getSyncState?.() ?? null;
|
||||
}
|
||||
|
||||
/** Guard a capability before dispatching, so callers get 501 rather than a confusing upstream error. */
|
||||
function requireCap(backend: WalletBackend, cap: Capability, op: string): void {
|
||||
if (!backend.supports(cap)) {
|
||||
throw new BackendError(`this wallet does not support ${op}`, 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
}
|
||||
|
||||
// ── entry point ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
if (!officerUser) return json({ error: 'missing X-Officer-User' }, 401);
|
||||
const userId = Number(officerUser);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
|
||||
|
||||
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
const ctx: OfficerContext = { req, url, userId };
|
||||
|
||||
try {
|
||||
switch (segments[0]) {
|
||||
case 'config':
|
||||
return handleConfig();
|
||||
case 'wallets':
|
||||
return await handleWallets(ctx, segments.slice(1));
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError) {
|
||||
return json({ error: err.message, code: err.code }, err.status);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-secret deployment facts the UI needs before any wallet exists.
|
||||
*
|
||||
* No esplora URL: that is per-owner now and lives at `/_config`, which is also the only place that can
|
||||
* say whether it is the owner's endpoint or the fallback. Reporting the default here would have shown
|
||||
* the wrong URL to anyone who had set their own.
|
||||
*/
|
||||
function handleConfig(): Response {
|
||||
const cfg = getConfig();
|
||||
return json({
|
||||
network: cfg.network,
|
||||
unlockTtlSec: cfg.unlockTtlSec,
|
||||
// The UI blocks wallet creation on this rather than letting the first write fail on a crypto error.
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── /wallets ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const KINDS: readonly WalletKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
|
||||
|
||||
/** Highest gap limit a rescan may be asked for. Four variants at this width is already tens of minutes. */
|
||||
const MAX_GAP_LIMIT = 100_000;
|
||||
|
||||
async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Response | null> {
|
||||
const { req, userId } = ctx;
|
||||
|
||||
// /_officer/wallets
|
||||
if (seg.length === 0) {
|
||||
if (req.method === 'GET') return json({ wallets: await listWallets(userId) });
|
||||
if (req.method === 'POST') return await createWalletRoute(ctx);
|
||||
return json({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
|
||||
// /_officer/wallets/active — resolves to whichever wallet is currently selected
|
||||
if (seg[0] === 'active' && seg.length === 1 && req.method === 'GET') {
|
||||
const active = await getActiveWallet(userId);
|
||||
return json({ wallet: active });
|
||||
}
|
||||
|
||||
const walletId = Number(seg[0]);
|
||||
if (!Number.isInteger(walletId) || walletId <= 0) return badRequest('invalid wallet id');
|
||||
const rest = seg.slice(1);
|
||||
|
||||
// /_officer/wallets/:id
|
||||
if (rest.length === 0) {
|
||||
if (req.method === 'GET') {
|
||||
const wallet = await getWallet(userId, walletId);
|
||||
return wallet ? json({ wallet }) : json({ error: 'wallet not found' }, 404);
|
||||
}
|
||||
if (req.method === 'PATCH') {
|
||||
const patch = await body<{ name?: string; defaultBip?: number; config?: Record<string, unknown> }>(req);
|
||||
// A rename goes through the same rule as a create: trimmed, and never blank. Without this a stray
|
||||
// empty string would leave a wallet with no name anywhere in the UI and no way to type one back.
|
||||
if (patch.name !== undefined) {
|
||||
const name = patch.name.trim();
|
||||
if (!name) return badRequest('name cannot be empty');
|
||||
if (name.length > 64) return badRequest('name is too long (64 characters max)');
|
||||
patch.name = name;
|
||||
}
|
||||
// Rebuilt field by field rather than forwarded: `body<T>` is a cast, so the parsed object holds
|
||||
// whatever the caller sent, not what the type says. updateWallet can no longer write the seed
|
||||
// either — that is the belt to this brace.
|
||||
const updated = await updateWallet(userId, walletId, {
|
||||
name: patch.name,
|
||||
defaultBip: patch.defaultBip,
|
||||
config: patch.config,
|
||||
});
|
||||
invalidate(walletId);
|
||||
return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404);
|
||||
}
|
||||
if (req.method === 'DELETE') return await deleteWalletRoute(ctx, walletId);
|
||||
return json({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
|
||||
// Seed / lock lifecycle — matched before the generic operations below.
|
||||
switch (rest[0]) {
|
||||
case 'activate':
|
||||
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
await setActiveWallet(userId, walletId);
|
||||
return json({ ok: true });
|
||||
|
||||
case 'lock-state': {
|
||||
const wallet = await getWallet(userId, walletId);
|
||||
if (!wallet) return json({ error: 'wallet not found' }, 404);
|
||||
const session = sessionFor(walletId);
|
||||
return json({
|
||||
hasSeed: wallet.hasSeed,
|
||||
unlocked: session.isUnlocked(),
|
||||
secondsRemaining: session.secondsRemaining(),
|
||||
});
|
||||
}
|
||||
|
||||
case 'unlock':
|
||||
return await unlockRoute(ctx, walletId);
|
||||
|
||||
case 'lock':
|
||||
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
sessionFor(walletId).lock();
|
||||
return json({ ok: true, unlocked: false });
|
||||
|
||||
case 'passphrase':
|
||||
return await changePassphraseRoute(ctx, walletId);
|
||||
|
||||
case 'export-seed':
|
||||
return await exportSeedRoute(ctx, walletId);
|
||||
}
|
||||
|
||||
// Everything else needs a live backend.
|
||||
const { wallet, backend } = await resolveBackend(userId, walletId);
|
||||
|
||||
switch (rest[0]) {
|
||||
case 'capabilities': {
|
||||
const all: Capability[] = [
|
||||
'onchainReceive',
|
||||
'onchainSend',
|
||||
'coinControl',
|
||||
'psbt',
|
||||
'bumpFee',
|
||||
'sweep',
|
||||
'accounts',
|
||||
'lightningReceive',
|
||||
'lightningSend',
|
||||
'keysend',
|
||||
'customPreimages',
|
||||
'offers',
|
||||
'channels',
|
||||
'peers',
|
||||
'routing',
|
||||
'signMessage',
|
||||
];
|
||||
return json({ kind: wallet.kind, capabilities: all.filter((c) => backend.supports(c)) });
|
||||
}
|
||||
|
||||
case 'info':
|
||||
return json({ info: await backend.getInfo() });
|
||||
|
||||
case 'balances':
|
||||
return json({ balances: await balancesWithFrozen(walletId, backend), sync: syncOf(backend) });
|
||||
|
||||
case 'transactions': {
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
const txs = await backend.getTransactions({ limit });
|
||||
// Overlay owner labels, which live in Officer's DB rather than any backend.
|
||||
const labels = await getWalletLabels(walletId);
|
||||
const byRef = new Map(labels.filter((l) => l.kind === 'tx').map((l) => [l.ref, l.label]));
|
||||
return json({
|
||||
transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })),
|
||||
sync: syncOf(backend),
|
||||
});
|
||||
}
|
||||
|
||||
case 'address': {
|
||||
requireCap(backend, 'onchainReceive', 'receiving on-chain');
|
||||
const peek = ctx.url.searchParams.get('peek') === 'true';
|
||||
// An explicit ?type= wins; otherwise the wallet's defaultBip decides. Without this the on-chain
|
||||
// backend would always fall back to its own preference order (native segwit), silently ignoring an
|
||||
// owner who set the wallet to taproot or legacy.
|
||||
const asked = ctx.url.searchParams.get('type');
|
||||
if (asked && !asAddressType(asked)) return badRequest(`unknown address type "${asked}"`);
|
||||
const type = asked ? asAddressType(asked) : BIP_ADDRESS_TYPE[String(wallet.defaultBip)];
|
||||
return json(await backend.getNewAddress({ peek, type }));
|
||||
}
|
||||
|
||||
case 'utxos':
|
||||
return await utxosRoute(ctx, walletId, backend, rest.slice(1));
|
||||
|
||||
// POST starts a deep rescan, GET reads how it is going. Both answer with the same `rescan` block that
|
||||
// rides on balances/transactions/utxos, so the UI has one shape to render and can poll whichever it
|
||||
// was already polling. A backend with nothing to rescan is a clean 501 rather than a silent no-op.
|
||||
case 'rescan': {
|
||||
// No local state means either "never rescanned" or "this sidecar restarted while one was running",
|
||||
// and only the upstream can tell those apart. Probing costs a request per account, so it happens
|
||||
// only in that gap — once a scan is adopted or finished, the local state answers.
|
||||
if (ctx.req.method === 'GET') {
|
||||
const local = syncOf(backend)?.rescan ?? null;
|
||||
return json({ rescan: local ?? (await backend.adoptRescan?.()) ?? null });
|
||||
}
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
if (!backend.startRescan) {
|
||||
throw new BackendError('this wallet has nothing to rescan', 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
// The gap limit is the one thing about a rescan only the owner can know — how many addresses their
|
||||
// old wallet issued without ever being paid to. The ceiling is a node-CPU guard, not a correctness
|
||||
// one: past it the scan takes longer than anyone will wait for it.
|
||||
const { gapLimit } = await body<{ gapLimit?: number }>(ctx.req);
|
||||
if (gapLimit !== undefined && (!Number.isInteger(gapLimit) || gapLimit < 1 || gapLimit > MAX_GAP_LIMIT)) {
|
||||
return badRequest(`gapLimit must be a whole number between 1 and ${MAX_GAP_LIMIT}`);
|
||||
}
|
||||
return json({ rescan: await backend.startRescan({ gapLimit }) });
|
||||
}
|
||||
|
||||
case 'fees':
|
||||
return json({ fees: await backend.estimateFees() });
|
||||
|
||||
case 'send': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'onchainSend', 'sending on-chain');
|
||||
// The lock gate comes before body validation, not after. The backend checks it too, but only once
|
||||
// the request has already passed every field check here — so a locked wallet was answering "your
|
||||
// fee rate is wrong" instead of "unlock me first", which sends the UI down the wrong path.
|
||||
if (wallet.hasSeed && !sessionFor(walletId).isUnlocked()) throw new WalletLockedError();
|
||||
const req2 = await body<Parameters<WalletBackend['sendCoins']>[0]>(ctx.req);
|
||||
if (!req2.address) return badRequest('address is required');
|
||||
if (!req2.sendAll && !req2.amountSats) return badRequest('amountSats or sendAll is required');
|
||||
if (!req2.satPerVbyte || req2.satPerVbyte < 1) return badRequest('satPerVbyte must be at least 1');
|
||||
// Never spend a frozen coin, even if the caller passed no explicit outpoint list.
|
||||
const frozenList = await getFrozenOutpoints(walletId);
|
||||
const frozen = new Set(frozenList);
|
||||
if (req2.outpoints?.some((o) => frozen.has(o))) return badRequest('refusing to spend a frozen UTXO');
|
||||
// Spread last so a caller cannot supply its own frozen list: the check above only ever covered
|
||||
// outpoints the caller named explicitly, which left automatic selection free to pick a frozen coin.
|
||||
const result = await backend.sendCoins({ ...req2, frozenOutpoints: frozenList });
|
||||
if (req2.label) await setWalletLabel(walletId, 'tx', result.txid, req2.label);
|
||||
return json(result);
|
||||
}
|
||||
|
||||
case 'invoices':
|
||||
return await invoicesRoute(ctx, backend, rest.slice(1));
|
||||
|
||||
case 'decode': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { bolt11 } = await body<{ bolt11?: string }>(ctx.req);
|
||||
if (!bolt11) return badRequest('bolt11 is required');
|
||||
return json({ decoded: await backend.decodeInvoice(bolt11) });
|
||||
}
|
||||
|
||||
case 'payments': {
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
return json({ payments: await backend.getPayments({ limit }) });
|
||||
}
|
||||
|
||||
case 'pay': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'lightningSend', 'paying lightning invoices');
|
||||
const payReq = await body<Parameters<WalletBackend['payInvoice']>[0]>(ctx.req);
|
||||
if (!payReq.bolt11) return badRequest('bolt11 is required');
|
||||
if (payReq.feeLimitMsat && payReq.feeLimitPercent) {
|
||||
return badRequest('feeLimitMsat and feeLimitPercent are mutually exclusive');
|
||||
}
|
||||
return json({ payment: await backend.payInvoice(payReq) });
|
||||
}
|
||||
|
||||
case 'keysend': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'keysend', 'keysend');
|
||||
const ks = await body<Parameters<WalletBackend['sendKeysend']>[0]>(ctx.req);
|
||||
if (!ks.destination || !ks.amountMsat) return badRequest('destination and amountMsat are required');
|
||||
return json({ payment: await backend.sendKeysend(ks) });
|
||||
}
|
||||
|
||||
case 'channels':
|
||||
requireCap(backend, 'channels', 'channels');
|
||||
return json({ channels: await backend.getChannels() });
|
||||
|
||||
case 'peers':
|
||||
requireCap(backend, 'peers', 'peers');
|
||||
return json({ peers: await backend.getPeers() });
|
||||
|
||||
case 'sign': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'signMessage', 'message signing');
|
||||
const { message } = await body<{ message?: string }>(ctx.req);
|
||||
if (!message) return badRequest('message is required');
|
||||
return json(await backend.signMessage(message));
|
||||
}
|
||||
|
||||
case 'verify': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'signMessage', 'message verification');
|
||||
const { message, signature } = await body<{ message?: string; signature?: string }>(ctx.req);
|
||||
if (!message || !signature) return badRequest('message and signature are required');
|
||||
return json(await backend.verifyMessage(message, signature));
|
||||
}
|
||||
|
||||
case 'labels': {
|
||||
if (ctx.req.method === 'GET') return json({ labels: await getWalletLabels(walletId) });
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { kind, ref, label } = await body<{ kind?: string; ref?: string; label?: string }>(ctx.req);
|
||||
if (kind !== 'address' && kind !== 'tx') return badRequest('kind must be "address" or "tx"');
|
||||
if (!ref) return badRequest('ref is required');
|
||||
await setWalletLabel(walletId, kind, ref, label ?? '');
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── wallet lifecycle ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CreateBody = {
|
||||
name?: string;
|
||||
kind?: string;
|
||||
network?: string;
|
||||
/** onchain only: omit `mnemonic` to generate a fresh seed. */
|
||||
mnemonic?: string;
|
||||
words?: 12 | 24;
|
||||
passphrase?: string;
|
||||
bip39Passphrase?: string;
|
||||
defaultBip?: number;
|
||||
config?: Record<string, unknown>;
|
||||
makeActive?: boolean;
|
||||
};
|
||||
|
||||
async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
|
||||
if (!hasStoreKey()) {
|
||||
throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY');
|
||||
}
|
||||
const b = await body<CreateBody>(ctx.req);
|
||||
if (!b.name?.trim()) return badRequest('name is required');
|
||||
// Same cap as the rename route, so a name you can create is always a name you can type back.
|
||||
if (b.name.trim().length > 64) return badRequest('name is too long (64 characters max)');
|
||||
if (!b.kind || !(KINDS as readonly string[]).includes(b.kind)) {
|
||||
return badRequest(`kind must be one of ${KINDS.join(', ')}`);
|
||||
}
|
||||
const kind = b.kind as WalletKind;
|
||||
const network = (b.network ?? getConfig().network) as BitcoinNetwork;
|
||||
|
||||
// Remote-node wallets: store the connection config, no seed involved.
|
||||
if (kind !== 'onchain') {
|
||||
if (!b.config) return badRequest(`${kind} wallets require a config object`);
|
||||
const wallet = await createWallet({
|
||||
userId: ctx.userId,
|
||||
name: b.name.trim(),
|
||||
kind,
|
||||
network,
|
||||
config: b.config,
|
||||
makeActive: b.makeActive ?? true,
|
||||
});
|
||||
return json({ wallet }, 201);
|
||||
}
|
||||
|
||||
// Self-custodial on-chain wallet: seal a seed under the owner passphrase.
|
||||
if (!b.passphrase) return badRequest('passphrase is required for a seeded wallet');
|
||||
const mnemonic = b.mnemonic?.trim() || generateSeed(b.words ?? 24);
|
||||
const envelope = await sealSeed(mnemonic, b.passphrase, b.bip39Passphrase);
|
||||
const { fingerprint, xpubs } = await deriveAccountXpubs(envelope, b.passphrase, network);
|
||||
|
||||
const wallet = await createWallet({
|
||||
userId: ctx.userId,
|
||||
name: b.name.trim(),
|
||||
kind,
|
||||
network,
|
||||
sealedSeed: JSON.stringify(envelope),
|
||||
fingerprint,
|
||||
xpubs: Object.fromEntries(Object.entries(xpubs).map(([k, v]) => [k, v])),
|
||||
defaultBip: b.defaultBip ?? 84,
|
||||
makeActive: b.makeActive ?? true,
|
||||
});
|
||||
|
||||
// An IMPORTED seed is the one case that needs the chain searched from scratch: an indexing upstream
|
||||
// only watches an account from the moment it is registered, so a wallet with a history would otherwise
|
||||
// show a confident, wrong zero. A freshly generated seed has no history to find, and a rescan for it
|
||||
// would burn several minutes of the node's CPU to confirm nothing.
|
||||
if (b.mnemonic) void kickOffRescan(ctx.userId, wallet.id);
|
||||
|
||||
// Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and
|
||||
// has no other chance to see it without re-entering the passphrase. An imported mnemonic is never
|
||||
// echoed back: the caller already has it, and echoing would put it in a response log for no reason.
|
||||
return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a rescan behind the response that created the wallet.
|
||||
*
|
||||
* Never throws: a chain source that cannot rescan, or an upstream that is down, must not turn a
|
||||
* successful wallet import into a failed one. The owner can trigger it by hand from Settings either way.
|
||||
*/
|
||||
async function kickOffRescan(userId: number, walletId: number): Promise<void> {
|
||||
try {
|
||||
const { backend } = await resolveBackend(userId, walletId);
|
||||
await backend.startRescan?.();
|
||||
} catch (err) {
|
||||
console.error('[wallet] rescan after import failed to start:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
const wallet = await getWallet(ctx.userId, walletId);
|
||||
if (!wallet) return json({ error: 'wallet not found' }, 404);
|
||||
|
||||
// A bodyless DELETE is a caller that skipped the confirmation, not a malformed request — it should be
|
||||
// told which field is missing rather than that its JSON did not parse.
|
||||
type DeleteBody = { passphrase?: string; confirmName?: string };
|
||||
const { passphrase, confirmName } = await body<DeleteBody>(ctx.req).catch((): DeleteBody => ({}));
|
||||
|
||||
// Deleting a seeded wallet destroys the only copy of the key material Officer holds. Require the
|
||||
// passphrase, even when the wallet is already unlocked — an open session must not be enough.
|
||||
if (wallet.hasSeed) {
|
||||
if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet');
|
||||
const sealed = await getSealedSeed(ctx.userId, walletId);
|
||||
if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED');
|
||||
if (!(await verifyPassphrase(walletId, JSON.parse(sealed) as SeedEnvelope, passphrase))) {
|
||||
return json({ error: 'incorrect passphrase' }, 401);
|
||||
}
|
||||
} else if (confirmName?.trim() !== wallet.name) {
|
||||
// A wallet with no seed of its own still holds the node credential, the labels and the freezes, and
|
||||
// it deleted on one unconfirmed request — the name check lived only in the dialog, so anything that
|
||||
// was not the dialog skipped it. Naming the wallet is the same bar the UI already asks for, now
|
||||
// enforced where it cannot be bypassed.
|
||||
return badRequest('confirmName must match the wallet name');
|
||||
}
|
||||
|
||||
sessionFor(walletId).lock();
|
||||
invalidate(walletId);
|
||||
const deleted = await deleteWallet(ctx.userId, walletId);
|
||||
return deleted ? json({ ok: true }) : json({ error: 'wallet not found' }, 404);
|
||||
}
|
||||
|
||||
// ── lock lifecycle ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadEnvelope(userId: number, walletId: number): Promise<SeedEnvelope> {
|
||||
const sealed = await getSealedSeed(userId, walletId);
|
||||
if (!sealed) throw new BackendError('this wallet holds no seed', 400, 'NO_SEED');
|
||||
return JSON.parse(sealed) as SeedEnvelope;
|
||||
}
|
||||
|
||||
async function unlockRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { passphrase, ttlSec } = await body<{ passphrase?: string; ttlSec?: number }>(ctx.req);
|
||||
if (!passphrase) return badRequest('passphrase is required');
|
||||
|
||||
const env = await loadEnvelope(ctx.userId, walletId);
|
||||
const session = sessionFor(walletId);
|
||||
const max = getConfig().unlockTtlSec;
|
||||
// A caller may shorten the window but never extend it past the deployment's configured maximum.
|
||||
await session.unlock(env, passphrase, Math.min(ttlSec ?? max, max));
|
||||
|
||||
return json({ ok: true, unlocked: true, secondsRemaining: session.secondsRemaining() });
|
||||
}
|
||||
|
||||
async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { oldPassphrase, newPassphrase } = await body<{ oldPassphrase?: string; newPassphrase?: string }>(ctx.req);
|
||||
if (!oldPassphrase || !newPassphrase) return badRequest('oldPassphrase and newPassphrase are required');
|
||||
|
||||
const env = await loadEnvelope(ctx.userId, walletId);
|
||||
const resealed = await changePassphrase(walletId, env, oldPassphrase, newPassphrase);
|
||||
await replaceSealedSeed(ctx.userId, walletId, JSON.stringify(resealed));
|
||||
// Force a re-unlock under the new passphrase rather than leaving a session opened by the old one.
|
||||
sessionFor(walletId).lock();
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
|
||||
if (!passphrase) return badRequest('passphrase is required');
|
||||
|
||||
const env = await loadEnvelope(ctx.userId, walletId);
|
||||
const mnemonic = await exportMnemonic(walletId, env, passphrase);
|
||||
console.warn(`[wallet] seed exported for wallet ${walletId} by user ${ctx.userId}`);
|
||||
return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase });
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances, plus how much of the confirmed figure the owner has frozen.
|
||||
*
|
||||
* Only asks the backend for its UTXOs when something is actually frozen, so a wallet that never uses the
|
||||
* feature pays nothing for it — on LND that is a real extra RPC, and this runs on every balance poll.
|
||||
*/
|
||||
async function balancesWithFrozen(walletId: number, backend: WalletBackend): Promise<Balances> {
|
||||
const balances = await backend.getBalances();
|
||||
const frozenList = await getFrozenOutpoints(walletId);
|
||||
if (frozenList.length === 0) return balances;
|
||||
|
||||
const frozen = new Set(frozenList);
|
||||
const utxos = await backend.getUtxos();
|
||||
// Confirmed only, to match what it is a component of. An unconfirmed frozen coin is not spendable for
|
||||
// a reason that has nothing to do with freezing.
|
||||
const sats = utxos
|
||||
.filter((u) => frozen.has(`${u.txid}:${u.vout}`) && u.confirmations > 0)
|
||||
.reduce((sum, u) => sum + u.amountSats, 0);
|
||||
return { ...balances, onchainFrozen: sats };
|
||||
}
|
||||
|
||||
// ── utxos ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function utxosRoute(
|
||||
ctx: OfficerContext,
|
||||
walletId: number,
|
||||
backend: WalletBackend,
|
||||
seg: string[],
|
||||
): Promise<Response> {
|
||||
requireCap(backend, 'coinControl', 'coin control');
|
||||
|
||||
if (seg[0] === 'freeze') {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { outpoint, frozen, reason } = await body<{ outpoint?: string; frozen?: boolean; reason?: string }>(ctx.req);
|
||||
if (!outpoint || !/^[0-9a-f]{64}:\d+$/i.test(outpoint)) return badRequest('outpoint must be "txid:vout"');
|
||||
await setUtxoFrozen(walletId, outpoint, frozen !== false, reason);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
const [utxos, frozenList, labels] = await Promise.all([
|
||||
backend.getUtxos(),
|
||||
getFrozenOutpoints(walletId),
|
||||
getWalletLabels(walletId),
|
||||
]);
|
||||
const frozen = new Set(frozenList);
|
||||
const byAddr = new Map(labels.filter((l) => l.kind === 'address').map((l) => [l.ref, l.label]));
|
||||
|
||||
// The freeze flag is Officer's, not the backend's — overlay it here so coin control is consistent
|
||||
// across every backend, including ones with no freeze concept of their own.
|
||||
const merged: (Utxo & { label: string | null })[] = utxos.map((u) => ({
|
||||
...u,
|
||||
frozen: frozen.has(`${u.txid}:${u.vout}`),
|
||||
label: byAddr.get(u.address) ?? null,
|
||||
}));
|
||||
return json({ utxos: merged, sync: syncOf(backend) });
|
||||
}
|
||||
|
||||
// ── invoices ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function invoicesRoute(ctx: OfficerContext, backend: WalletBackend, seg: string[]): Promise<Response> {
|
||||
// /_officer/wallets/:id/invoices/:paymentHash
|
||||
if (seg.length === 1) {
|
||||
const invoice = await backend.lookupInvoice(seg[0]!);
|
||||
return invoice ? json({ invoice }) : json({ error: 'invoice not found' }, 404);
|
||||
}
|
||||
|
||||
if (ctx.req.method === 'GET') {
|
||||
requireCap(backend, 'lightningReceive', 'lightning');
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
return json({ invoices: await backend.getInvoices({ limit }) });
|
||||
}
|
||||
|
||||
if (ctx.req.method === 'POST') {
|
||||
requireCap(backend, 'lightningReceive', 'creating invoices');
|
||||
const req = await body<Parameters<WalletBackend['createInvoice']>[0]>(ctx.req);
|
||||
if (req.preimage) requireCap(backend, 'customPreimages', 'custom preimages');
|
||||
return json({ invoice: await backend.createInvoice(req) }, 201);
|
||||
}
|
||||
|
||||
return json({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
// The wallet sidecar's backend contract — a typed re-statement of what Zeus's utils/BackendUtils.ts
|
||||
// dispatches to duck-typed across its seven backends.
|
||||
//
|
||||
// Two things are deliberately NOT copied from Zeus:
|
||||
//
|
||||
// 1. Zeus's `call(funcName, args)` returns `false` when a backend lacks a method, so an unsupported
|
||||
// feature and a typo'd method name are indistinguishable at the call site. Here the surface is a
|
||||
// TypeScript interface and capability negotiation is explicit via `supports()`.
|
||||
// 2. Zeus threads URL path segments through the interface — `decodePaymentRequest(urlParams[0])`,
|
||||
// `closeChannel(urlParams)`. Every operation below takes a named request object.
|
||||
//
|
||||
// UNITS. Amounts are satoshis as `number` (max 2.1e15, safely inside 2^53) and millisatoshis as
|
||||
// decimal `string` (2.1e18 overflows a double). Never widen a msat to a number.
|
||||
|
||||
export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
|
||||
|
||||
export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest';
|
||||
|
||||
/**
|
||||
* Explicit capability flags, replacing Zeus's ~60 `supportsX()` predicates (BackendUtils.ts:200-270).
|
||||
* A backend declares these once; routes.ts checks before dispatching so an unsupported operation is a
|
||||
* clean 501 rather than a mystery failure deep in an upstream call.
|
||||
*/
|
||||
export type Capability =
|
||||
// on-chain
|
||||
| 'onchainReceive'
|
||||
| 'onchainSend'
|
||||
| 'coinControl'
|
||||
| 'psbt'
|
||||
| 'bumpFee'
|
||||
| 'sweep'
|
||||
| 'accounts'
|
||||
// lightning
|
||||
| 'lightningReceive'
|
||||
| 'lightningSend'
|
||||
| 'keysend'
|
||||
| 'customPreimages'
|
||||
| 'offers'
|
||||
// node operation
|
||||
| 'channels'
|
||||
| 'peers'
|
||||
| 'routing'
|
||||
| 'signMessage';
|
||||
|
||||
// ── node / balances ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NodeInfo = {
|
||||
kind: BackendKind;
|
||||
/** Node pubkey, or null for backends with no node identity of their own (onchain, lndhub). */
|
||||
pubkey: string | null;
|
||||
alias: string | null;
|
||||
/** Upstream implementation version, verbatim. Null when the backend cannot report one. */
|
||||
version: string | null;
|
||||
network: BitcoinNetwork;
|
||||
blockHeight: number | null;
|
||||
synced: boolean;
|
||||
};
|
||||
|
||||
export type Balances = {
|
||||
/** Confirmed on-chain, in sats. Includes anything frozen — see `onchainFrozen`. */
|
||||
onchainConfirmed: number;
|
||||
onchainUnconfirmed: number;
|
||||
/**
|
||||
* How much of `onchainConfirmed` sits in UTXOs the owner froze, and so cannot be spent.
|
||||
*
|
||||
* A component of the confirmed figure rather than a deduction from it, because the coins are still
|
||||
* the wallet's — freezing is a spending policy, not a loss. Officer's overlay: freezing lives in
|
||||
* Postgres and no backend knows about it, so every backend reports 0 and the route fills it in.
|
||||
* Without this the UI shows a balance a max-value send then refuses to spend, with no visible reason.
|
||||
*/
|
||||
onchainFrozen: number;
|
||||
/** Sum of local balance across active channels, in sats. Null when the backend has no channels. */
|
||||
lightningBalance: number | null;
|
||||
/** Sum of remote balance — i.e. inbound liquidity — in sats. */
|
||||
lightningInbound: number | null;
|
||||
};
|
||||
|
||||
// ── on-chain ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
|
||||
|
||||
/**
|
||||
* BIP44 purpose → the script type it derives. Keyed by the string form, because that is how a wallet's
|
||||
* `xpubs` map arrives from the database (JSON object keys are always strings).
|
||||
*/
|
||||
export const BIP_ADDRESS_TYPE: Record<string, AddressType> = {
|
||||
'44': 'p2pkh',
|
||||
'49': 'p2sh-p2wpkh',
|
||||
'84': 'p2wpkh',
|
||||
'86': 'p2tr',
|
||||
};
|
||||
|
||||
/** Narrow caller-supplied text to an AddressType, or undefined if it names none. */
|
||||
export function asAddressType(value: string): AddressType | undefined {
|
||||
return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh' ? value : undefined;
|
||||
}
|
||||
|
||||
export type OnchainTx = {
|
||||
txid: string;
|
||||
/** Net effect on this wallet in sats — negative for a spend. */
|
||||
amount: number;
|
||||
feeSats: number | null;
|
||||
blockHeight: number | null;
|
||||
/** Unix seconds. Null while unconfirmed on backends that do not timestamp the mempool. */
|
||||
timestamp: number | null;
|
||||
confirmations: number;
|
||||
label: string | null;
|
||||
destAddresses: string[];
|
||||
/** Raw tx hex when the backend supplies it, for RBF/inspection. */
|
||||
rawHex: string | null;
|
||||
};
|
||||
|
||||
export type Utxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
address: string;
|
||||
addressType: AddressType | null;
|
||||
confirmations: number;
|
||||
/** BIP32 path relative to the account xpub, when this UTXO belongs to a derived address. */
|
||||
derivationPath: string | null;
|
||||
/** Frozen UTXOs are excluded from automatic coin selection. */
|
||||
frozen: boolean;
|
||||
};
|
||||
|
||||
export type FeeEstimates = {
|
||||
/** sat/vB, keyed by confirmation target in blocks. */
|
||||
fastestFee: number;
|
||||
halfHourFee: number;
|
||||
hourFee: number;
|
||||
economyFee: number;
|
||||
minimumFee: number;
|
||||
};
|
||||
|
||||
export type NewAddressRequest = {
|
||||
type?: AddressType;
|
||||
/** Return the current unused address instead of advancing the derivation index. */
|
||||
peek?: boolean;
|
||||
};
|
||||
|
||||
export type SendCoinsRequest = {
|
||||
address: string;
|
||||
/** Ignored when `sendAll` is set. */
|
||||
amountSats?: number;
|
||||
sendAll?: boolean;
|
||||
satPerVbyte: number;
|
||||
/** Coin control: restrict inputs to these outpoints (`txid:vout`). Requires the `coinControl` capability. */
|
||||
outpoints?: string[];
|
||||
spendUnconfirmed?: boolean;
|
||||
/** Opt out of RBF signalling. Default is replaceable. */
|
||||
rbf?: boolean;
|
||||
label?: string;
|
||||
/**
|
||||
* Outpoints automatic selection must not touch. SET BY THE ROUTE FROM OFFICER'S FROZEN LIST, never by
|
||||
* the caller — whatever arrives in the request body is overwritten.
|
||||
*
|
||||
* Freezing is Officer policy stored in Postgres, and a backend has no business reading that table; so
|
||||
* the frozen set has to travel with the request. Note it only ever restricts what may be spent, so
|
||||
* even a caller who did smuggle a value in could not widen the selection.
|
||||
*/
|
||||
frozenOutpoints?: readonly string[];
|
||||
};
|
||||
|
||||
export type SendCoinsResult = {
|
||||
txid: string;
|
||||
feeSats: number;
|
||||
rawHex: string | null;
|
||||
};
|
||||
|
||||
// ── lightning ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired';
|
||||
|
||||
export type Invoice = {
|
||||
/** Payment hash, hex. */
|
||||
paymentHash: string;
|
||||
/** BOLT11 payment request. */
|
||||
bolt11: string;
|
||||
amountMsat: string | null;
|
||||
amountPaidMsat: string | null;
|
||||
memo: string | null;
|
||||
state: InvoiceState;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
settledAt: number | null;
|
||||
/** Only ever populated for invoices this wallet created and only when explicitly requested. */
|
||||
preimage: string | null;
|
||||
isKeysend: boolean;
|
||||
isAmp: boolean;
|
||||
};
|
||||
|
||||
export type CreateInvoiceRequest = {
|
||||
amountMsat?: string;
|
||||
memo?: string;
|
||||
expirySeconds?: number;
|
||||
/** Requires the `customPreimages` capability. */
|
||||
preimage?: string;
|
||||
isAmp?: boolean;
|
||||
private?: 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;
|
||||
/** Upstream failure reason, verbatim, when status is 'failed'. */
|
||||
failureReason: string | null;
|
||||
};
|
||||
|
||||
export type PayInvoiceRequest = {
|
||||
bolt11: string;
|
||||
/** Required when the invoice is zero-amount; rejected otherwise. */
|
||||
amountMsat?: string;
|
||||
/** Absolute cap in msat. Mutually exclusive with feeLimitPercent. */
|
||||
feeLimitMsat?: string;
|
||||
feeLimitPercent?: number;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
export type KeysendRequest = {
|
||||
destination: string;
|
||||
amountMsat: string;
|
||||
feeLimitMsat?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
// ── channels / peers ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Channel = {
|
||||
channelId: string;
|
||||
channelPoint: string | null;
|
||||
remotePubkey: string;
|
||||
remoteAlias: string | null;
|
||||
capacitySats: number;
|
||||
localBalanceSats: number;
|
||||
remoteBalanceSats: number;
|
||||
active: boolean;
|
||||
private: boolean;
|
||||
/** 'open' | 'pending-open' | 'pending-close' | 'force-closing' | 'closed' */
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Peer = {
|
||||
pubkey: string;
|
||||
address: string;
|
||||
alias: string | null;
|
||||
inbound: boolean;
|
||||
};
|
||||
|
||||
// ── signing ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SignMessageResult = { signature: string };
|
||||
export type VerifyMessageResult = { valid: boolean; pubkey: string | null };
|
||||
|
||||
// ── the interface ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every method may throw `BackendError`. Methods guarded by a capability must only be called after
|
||||
* `supports()` returns true — the base class throws `notSupported()` otherwise, so a missed guard is a
|
||||
* loud 501 rather than Zeus's silent `false`.
|
||||
*/
|
||||
export interface WalletBackend {
|
||||
readonly kind: BackendKind;
|
||||
|
||||
supports(cap: Capability): boolean;
|
||||
|
||||
getInfo(): Promise<NodeInfo>;
|
||||
getBalances(): Promise<Balances>;
|
||||
|
||||
// on-chain
|
||||
getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]>;
|
||||
getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }>;
|
||||
getUtxos(): Promise<Utxo[]>;
|
||||
estimateFees(): Promise<FeeEstimates>;
|
||||
sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult>;
|
||||
|
||||
// lightning
|
||||
getInvoices(opts?: { limit?: number }): Promise<Invoice[]>;
|
||||
createInvoice(req: CreateInvoiceRequest): Promise<Invoice>;
|
||||
lookupInvoice(paymentHash: string): Promise<Invoice | null>;
|
||||
decodeInvoice(bolt11: string): Promise<DecodedInvoice>;
|
||||
getPayments(opts?: { limit?: number }): Promise<Payment[]>;
|
||||
payInvoice(req: PayInvoiceRequest): Promise<Payment>;
|
||||
sendKeysend(req: KeysendRequest): Promise<Payment>;
|
||||
|
||||
// node operation
|
||||
getChannels(): Promise<Channel[]>;
|
||||
getPeers(): Promise<Peer[]>;
|
||||
|
||||
signMessage(message: string): Promise<SignMessageResult>;
|
||||
verifyMessage(message: string, signature: string): Promise<VerifyMessageResult>;
|
||||
|
||||
/**
|
||||
* How old the data this backend just served is. Optional because it is only meaningful for a backend
|
||||
* that caches — a node backend answers from its own live state, and reporting a sync age for it would
|
||||
* be inventing one.
|
||||
*/
|
||||
getSyncState?(): SyncState;
|
||||
|
||||
/**
|
||||
* Ask the chain to search for this wallet's coins from scratch. Resolves as soon as the search is
|
||||
* queued, not when it finishes — progress is read back through `getSyncState().rescan`.
|
||||
*
|
||||
* Optional because only a wallet-level chain source can do it. Absent means the backend has nothing to
|
||||
* rescan (a node backend already knows its own coins) or the source has no such endpoint.
|
||||
*/
|
||||
startRescan?(opts?: RescanOptions): Promise<RescanState>;
|
||||
|
||||
/**
|
||||
* Re-attach to a rescan that is running upstream but was not started by this process.
|
||||
*
|
||||
* A deep rescan takes minutes and lives on the indexer, not here — so restarting the sidecar mid-scan
|
||||
* used to lose the wallet's only handle on it. `getSyncState().rescan` read back null, the UI showed no
|
||||
* scan at all, and the refresh chained onto its completion went with it, leaving the balance stale
|
||||
* until the cache TTL happened to expire. This asks the upstream what is actually in flight.
|
||||
*
|
||||
* Resolves null when nothing is running. Costs an upstream call, so callers reach for it only when
|
||||
* there is no local state to report.
|
||||
*/
|
||||
adoptRescan?(): Promise<RescanState | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freshness of a cached view of the chain, so the UI can say "3 minutes ago" and, more importantly, tell
|
||||
* "this wallet is empty" apart from "we could not ask". Those two rendered identically before this
|
||||
* existed, which is the whole reason it does.
|
||||
*/
|
||||
export type SyncState = {
|
||||
/** Unix ms of the last successful chain read. Null when the chain has never been read successfully. */
|
||||
syncedAt: number | null;
|
||||
/** The served data is past its refresh interval; a refresh is already running behind this response. */
|
||||
stale: boolean;
|
||||
/** The last refresh failure, still reported while the previous good data is being served. */
|
||||
lastError: string | null;
|
||||
/**
|
||||
* Whether this chain source can search the chain from scratch at all. False for Esplora, which asks
|
||||
* about every address on every refresh and so has nothing to catch up on.
|
||||
*
|
||||
* Distinct from `rescan === null`, which is ALSO what a source that simply hasn't run one yet reports
|
||||
* — conflating the two put a button that 501s in front of every Esplora wallet.
|
||||
*/
|
||||
canRescan: boolean;
|
||||
/** A deep rescan in flight, or the outcome of the last one. Null from a source that cannot rescan. */
|
||||
rescan: RescanState | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How far a deep rescan should look, when the default is not far enough.
|
||||
*
|
||||
* The default gap limit is 1000 consecutive unused addresses, which covers any wallet a person has used
|
||||
* by hand. It does not cover one restored from a wallet that issued addresses in bulk — and there is no
|
||||
* way to detect that case, because the whole problem is that the coins beyond the gap are invisible.
|
||||
* Raising it costs the node CPU on one scan; leaving it too low costs the owner the coins. So the knob is
|
||||
* exposed rather than tuned, and the caller who knows their history is the one who sets it.
|
||||
*/
|
||||
export type RescanOptions = { gapLimit?: number };
|
||||
|
||||
/**
|
||||
* A deep chain rescan — the upstream searching the whole UTXO set for a wallet's coins, rather than
|
||||
* indexing forward from the moment the wallet was registered.
|
||||
*
|
||||
* This exists for exactly one situation, and it is not a rare one: an imported seed on a node that only
|
||||
* started watching the xpub today. Every read succeeds, nothing errors, and the balance is zero — which
|
||||
* is indistinguishable from an empty wallet unless the UI can say "still looking". So the state is
|
||||
* carried on SyncState beside `syncedAt`, on the same responses, for the same reason.
|
||||
*
|
||||
* It runs one account variant at a time because the node runs `scantxoutset` one at a time; four
|
||||
* variants is minutes, not seconds.
|
||||
*/
|
||||
export type RescanState = {
|
||||
running: boolean;
|
||||
/** Account variants finished, out of how many were queued. */
|
||||
done: number;
|
||||
total: number;
|
||||
/** The variant the node is chewing on right now. Null when nothing is running. */
|
||||
current: AddressType | null;
|
||||
/** UTXOs the finished variants pulled in. */
|
||||
found: number;
|
||||
startedAt: number;
|
||||
finishedAt: number | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
// ── errors ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class BackendError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number = 502,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BackendError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The wallet holds a seed but is currently locked — signing is impossible until /unlock. */
|
||||
export class WalletLockedError extends BackendError {
|
||||
constructor() {
|
||||
super('wallet is locked', 423, 'WALLET_LOCKED');
|
||||
this.name = 'WalletLockedError';
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { BitcoinNetwork } from './types';
|
||||
import { getServiceCredentials } from 'officerdb';
|
||||
import { getKey } from 'officerdb/secret-store';
|
||||
|
||||
// The ONLY reader of WALLET_* env in the tree. Everything else — node URLs, macaroons, runes, LNDHub
|
||||
// credentials, NWC URIs — is per-wallet configuration the owner enters at runtime and lives encrypted in
|
||||
// Postgres (databases/officer_db/src/wallet/schema.ts), not here. Env holds only what is genuinely
|
||||
// deployment-wide: which chain we're on.
|
||||
//
|
||||
// WHERE CHAIN DATA COMES FROM IS NOT ENV. It used to be WALLET_ESPLORA_URL, which meant the one setting
|
||||
// an owner actually has to change — off the public explorer, onto their own indexer — was the one
|
||||
// setting they could only change with a shell and a restart. It now lives in `service_connections`
|
||||
// under 'esplora' and is edited from Wallet → Settings; see getChainSource below.
|
||||
|
||||
const NETWORKS: readonly BitcoinNetwork[] = ['bitcoin', 'testnet', 'signet', 'regtest'];
|
||||
|
||||
// mempool.space's public API. Fine to start on; swap it for your own electrs/esplora when the node is up
|
||||
// — an Esplora endpoint sees every address in the wallet, so the public one is a privacy leak, not a
|
||||
// custody one. It never sees a private key and cannot authorize anything.
|
||||
const DEFAULT_ESPLORA: Record<BitcoinNetwork, string> = {
|
||||
bitcoin: 'https://mempool.space/api',
|
||||
testnet: 'https://mempool.space/testnet/api',
|
||||
signet: 'https://mempool.space/signet/api',
|
||||
regtest: 'http://127.0.0.1:3002',
|
||||
};
|
||||
|
||||
export type WalletConfig = {
|
||||
network: BitcoinNetwork;
|
||||
esploraUrl: string;
|
||||
unlockTtlSec: number;
|
||||
};
|
||||
|
||||
let warned = false;
|
||||
|
||||
export function getConfig(): WalletConfig {
|
||||
const raw = process.env.WALLET_NETWORK?.trim() ?? 'bitcoin';
|
||||
const network = (NETWORKS as readonly string[]).includes(raw) ? (raw as BitcoinNetwork) : 'bitcoin';
|
||||
if (raw && network !== raw && !warned) {
|
||||
console.warn(`[wallet] WALLET_NETWORK="${raw}" is not a known network, falling back to bitcoin`);
|
||||
warned = true;
|
||||
}
|
||||
|
||||
const ttl = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);
|
||||
|
||||
return {
|
||||
network,
|
||||
/** The built-in default, used until the owner stores one. `getChainSource` is the real answer. */
|
||||
esploraUrl: DEFAULT_ESPLORA[network],
|
||||
// Clamp: a zero TTL makes the wallet unusable, and an unbounded one defeats auto-lock entirely.
|
||||
unlockTtlSec: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 30), 86_400) : 900,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which protocol the endpoint speaks. Not interchangeable — see chain-source.ts for why the two are
|
||||
* different shapes rather than two URLs for the same thing.
|
||||
*/
|
||||
export const CHAIN_KINDS = ['nbxplorer', 'esplora'] as const;
|
||||
|
||||
export type ChainKind = (typeof CHAIN_KINDS)[number];
|
||||
|
||||
export type ChainSource = {
|
||||
kind: ChainKind;
|
||||
/** API root, no trailing slash. */
|
||||
baseUrl: string;
|
||||
/** True when this is the owner's stored endpoint rather than the built-in public default. */
|
||||
custom: boolean;
|
||||
};
|
||||
|
||||
/** Short TTL: a save invalidates explicitly, so this only bounds how stale a *concurrent* reader can be. */
|
||||
const CHAIN_TTL_MS = 60_000;
|
||||
const chainCache = new Map<number, { source: ChainSource; at: number }>();
|
||||
|
||||
export const normalizeEsploraUrl = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* Where to read the chain, for this owner.
|
||||
*
|
||||
* AT MOST ONE of the two rows exists: saving either kind retires the other (config.ts::save), because a
|
||||
* wallet reads from one place and storing two would leave the answer to precedence rather than to the
|
||||
* owner. The order below is therefore a tiebreak that should never be needed, not a preference.
|
||||
*
|
||||
* Falls back to the public default rather than failing: a wallet with no stored endpoint is still a
|
||||
* working watch-only wallet, just one pointed at an explorer that rate-limits and can see every address
|
||||
* it asks about. The default is a starting point, not a destination.
|
||||
*/
|
||||
export async function getChainSource(userId: number): Promise<ChainSource> {
|
||||
const hit = chainCache.get(userId);
|
||||
if (hit && Date.now() - hit.at < CHAIN_TTL_MS) return hit.source;
|
||||
|
||||
const source = await readChainSource(userId);
|
||||
chainCache.set(userId, { source, at: Date.now() });
|
||||
return source;
|
||||
}
|
||||
|
||||
async function readChainSource(userId: number): Promise<ChainSource> {
|
||||
for (const kind of CHAIN_KINDS) {
|
||||
const creds = await getServiceCredentials(userId, kind);
|
||||
if (creds?.url) return { kind, baseUrl: normalizeEsploraUrl(creds.url), custom: true };
|
||||
}
|
||||
return { kind: 'esplora', baseUrl: getConfig().esploraUrl, custom: false };
|
||||
}
|
||||
|
||||
export function invalidateChainSource(userId: number): void {
|
||||
chainCache.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the wallet's at-rest key is usable. The sidecar can serve a locked, watch-only view without
|
||||
* one, but every write path touching an encrypted column throws, so /_health reports it explicitly
|
||||
* rather than letting the first wallet creation fail with a confusing crypto error.
|
||||
*
|
||||
* This tested `VAULT_STORE_KEY` in the environment until 2026-08-13. The key now lives in the secret
|
||||
* store under the `wallet` purpose and is CREATED ON FIRST USE — so the honest question is no longer
|
||||
* "did somebody set a variable" but "can this process open the store at all". A false here means the
|
||||
* file is unreachable or unwritable, which is an install problem rather than a configuration one.
|
||||
*/
|
||||
export function hasStoreKey(): boolean {
|
||||
try {
|
||||
return getKey('wallet').length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
|
||||
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
import { appRegistryMetas as davMetas } from '../apps/Dav';
|
||||
@@ -31,7 +30,6 @@ export const apps = [
|
||||
...widgetMetas,
|
||||
...desktopMetas,
|
||||
...giteaMetas,
|
||||
...walletMetas,
|
||||
...monitorMetas,
|
||||
...qrTransferMetas,
|
||||
...davMetas,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { formatAmount, formatMsat } from './format';
|
||||
import { useAmountUnit } from './useAmountUnit';
|
||||
|
||||
// One place amounts are rendered, so the sats/BTC toggle flips every number on screen at once.
|
||||
//
|
||||
// `sats` is a number; `msat` is a decimal STRING and stays one all the way through formatMsat. Passing a
|
||||
// msat through the `sats` prop would parse it into a double — which is exactly the bug the string type
|
||||
// exists to prevent, so the two props are deliberately not interchangeable.
|
||||
|
||||
type AmountProps = {
|
||||
sats?: number | null;
|
||||
msat?: string | null;
|
||||
className?: string;
|
||||
/** Colour a negative amount as a spend and a positive one as a receipt. */
|
||||
signed?: boolean;
|
||||
};
|
||||
|
||||
export const Amount = ({ sats, msat, className = '', signed = false }: AmountProps) => {
|
||||
const { unit } = useAmountUnit();
|
||||
const text = msat !== undefined ? formatMsat(msat, unit) : formatAmount(sats, unit);
|
||||
|
||||
const tone = !signed || sats == null ? '' : sats < 0 ? 'text-destructive' : sats > 0 ? 'text-emerald-500' : '';
|
||||
|
||||
return (
|
||||
<span className={`tabular-nums ${tone} ${className}`}>
|
||||
{signed && sats != null && sats > 0 ? '+' : ''}
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** The toggle itself — a button, because it mutates a preference rather than navigating. */
|
||||
export const UnitToggle = ({ className = '' }: { className?: string }) => {
|
||||
const { unit, toggle } = useAmountUnit();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title="Switch between satoshis and BTC"
|
||||
className={`rounded-md border border-border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground ${className}`}
|
||||
>
|
||||
{unit === 'sats' ? 'sats' : 'BTC'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { ServiceConnectionState } from '../../hooks/useServiceConnection';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle2, Loader2, RotateCcw, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
serviceErrorMessage,
|
||||
useServiceConnection,
|
||||
useServiceConnectionActions,
|
||||
} from '../../hooks/useServiceConnection';
|
||||
|
||||
// Where on-chain wallets read the chain from. One endpoint serves every on-chain wallet the owner has,
|
||||
// which is why it sits here and not on a wallet.
|
||||
//
|
||||
// PRIVACY, NOT CUSTODY. A chain endpoint learns every address you ask it about, so pointing this at your
|
||||
// own node is a real upgrade — but it never sees a key and cannot move a coin. That framing is on the
|
||||
// card deliberately: the risk of getting it wrong is being watched, not being robbed.
|
||||
//
|
||||
// TWO PROTOCOLS, AND THEY ARE NOT INTERCHANGEABLE. Esplora is the public-explorer API (electrs, esplora,
|
||||
// mempool.space); NBXplorer is what you run in front of your own bitcoind. The picker is not cosmetic —
|
||||
// it selects a different client, so a URL saved under the wrong one fails the probe rather than silently
|
||||
// misbehaving. bitcoin-core's own RPC port is neither.
|
||||
|
||||
type ChainKind = 'esplora' | 'nbxplorer';
|
||||
|
||||
type ChainState = ServiceConnectionState & {
|
||||
kind: ChainKind;
|
||||
esploraUrl: string;
|
||||
custom: boolean;
|
||||
defaultUrl: string;
|
||||
network: string;
|
||||
};
|
||||
|
||||
type SaveResult = ChainState & { probe?: { blockHeight?: number | null; ms?: number } };
|
||||
|
||||
type KindMeta = { label: string; placeholder: string; hint: string };
|
||||
|
||||
const KINDS: Record<ChainKind, KindMeta> = {
|
||||
esplora: {
|
||||
label: 'Esplora',
|
||||
placeholder: 'https://mempool.space/api',
|
||||
hint: 'An Esplora REST API — electrs, esplora, or a mempool.space instance.',
|
||||
},
|
||||
nbxplorer: {
|
||||
label: 'NBXplorer',
|
||||
placeholder: 'http://127.0.0.1:32838',
|
||||
hint: 'NBXplorer in front of your own bitcoind. Its API root, not bitcoind’s RPC port.',
|
||||
},
|
||||
};
|
||||
|
||||
export const ChainSourceCard = () => {
|
||||
const { data, isLoading } = useServiceConnection<ChainState>('wallet');
|
||||
const { save, forget } = useServiceConnectionActions<{ url: string; kind: ChainKind }, SaveResult>('wallet');
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [kind, setKind] = useState<ChainKind>('esplora');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
|
||||
// Seeded from whatever is in use, so the form starts as the answer to "what is this pointed at" and the
|
||||
// owner edits it rather than retyping it. Keyed on both fields: a save or reset should re-seed.
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setUrl(data.esploraUrl);
|
||||
setKind(data.kind);
|
||||
}, [data?.esploraUrl, data?.kind]);
|
||||
|
||||
const trimmed = url.trim();
|
||||
const dirty = !!data && (trimmed !== data.esploraUrl || kind !== data.kind);
|
||||
const busy = save.isPending || forget.isPending;
|
||||
const meta = KINDS[kind];
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
setHeight(null);
|
||||
try {
|
||||
const result = await save.mutateAsync({ url: trimmed, kind });
|
||||
setHeight(result.probe?.blockHeight ?? null);
|
||||
} catch (err) {
|
||||
setError(serviceErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
setError(null);
|
||||
setHeight(null);
|
||||
try {
|
||||
await forget.mutateAsync();
|
||||
} catch (err) {
|
||||
setError(serviceErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
/** Switching protocol clears a URL that belongs to the other one — it would only fail the probe. */
|
||||
const pick = (next: ChainKind) => {
|
||||
if (next === kind) return;
|
||||
setKind(next);
|
||||
setError(null);
|
||||
setHeight(null);
|
||||
if (data && trimmed === data.esploraUrl && next !== data.kind) setUrl('');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Chain source</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Where on-chain wallets read balances and history. It sees every address this wallet asks about, so your own node
|
||||
is a privacy upgrade — it never sees a key and cannot spend.
|
||||
</p>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Chain source protocol"
|
||||
className="mb-2 inline-flex rounded-lg border border-border p-0.5"
|
||||
>
|
||||
{(Object.keys(KINDS) as ChainKind[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={kind === k}
|
||||
onClick={() => pick(k)}
|
||||
disabled={isLoading || busy}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
kind === k ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{KINDS[k].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(ev) => setUrl(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && trimmed && dirty && !busy) void submit();
|
||||
}}
|
||||
placeholder={meta.placeholder}
|
||||
disabled={isLoading || busy}
|
||||
className="h-8 font-mono text-xs"
|
||||
aria-label={`${meta.label} API URL`}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="sm" onClick={submit} disabled={!trimmed || !dirty || busy}>
|
||||
{save.isPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{save.isPending ? 'Connecting…' : 'Save & connect'}
|
||||
</Button>
|
||||
{data?.custom && (
|
||||
<Button size="sm" variant="outline" onClick={reset} disabled={busy} title="Back to the public default">
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
|
||||
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{height != null && !error && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs text-emerald-500">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
Connected — block {height.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{data?.custom
|
||||
? `Using your ${KINDS[data.kind].label} endpoint.`
|
||||
: `Using the built-in default (${data?.defaultUrl ?? '—'}), which rate-limits and can see every address you look up.`}{' '}
|
||||
{meta.hint}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { Utxo } from './shared';
|
||||
import { Link } from 'react-router';
|
||||
import { Loader2, Snowflake, Sun } from 'lucide-react';
|
||||
import { formatConfirmations, truncateMiddle } from './format';
|
||||
import { walletSectionPath } from './shared';
|
||||
import { Amount } from './Amount';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { SyncBadge } from './SyncBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCoinSelection } from './useCoinSelection';
|
||||
import { usePermissions, useUtxos, useWalletOperations } from './useWalletData';
|
||||
|
||||
// Coin control. Works while locked: which coins exist and which are frozen is watch-only information, and
|
||||
// freezing is Officer's own flag rather than anything signed.
|
||||
//
|
||||
// The selection lives in `?coins=` so the Send section reads the same set straight from the URL.
|
||||
|
||||
export const CoinsView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const supported = capabilities.includes('coinControl');
|
||||
|
||||
const { utxos, sync, isLoading: utxosLoading } = useUtxos(walletId, supported);
|
||||
const { selected, toggle, clear } = useCoinSelection();
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
if (!supported) return <UnsupportedSection what="coin control" />;
|
||||
|
||||
const selectedTotal = utxos
|
||||
.filter((u) => selected.includes(`${u.txid}:${u.vout}`))
|
||||
.reduce((sum, u) => sum + u.amountSats, 0);
|
||||
const spendableTotal = utxos.filter((u) => !u.frozen).reduce((sum, u) => sum + u.amountSats, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{utxos.length} coin{utxos.length === 1 ? '' : 's'} · <Amount sats={spendableTotal} /> spendable
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<SyncBadge sync={sync} />
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<span>
|
||||
{selected.length} selected · <Amount sats={selectedTotal} className="font-semibold" />
|
||||
</span>
|
||||
<button type="button" onClick={clear} className="text-primary hover:underline">
|
||||
clear
|
||||
</button>
|
||||
<Link
|
||||
to={walletSectionPath('send', walletId)}
|
||||
className="rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Spend these
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{utxosLoading && utxos.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Listing coins…
|
||||
</div>
|
||||
) : utxos.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
|
||||
{/* "No coins" is only true if the chain was actually read. Never-synced is a different fact. */}
|
||||
<p className="text-sm font-medium">{sync && sync.syncedAt === null ? 'Coins unavailable' : 'No coins'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sync && sync.syncedAt === null
|
||||
? (sync.lastError ?? 'The chain source could not be reached.')
|
||||
: 'Receive something and it will show up here as a UTXO.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{utxos.map((utxo) => (
|
||||
<CoinRow
|
||||
key={`${utxo.txid}:${utxo.vout}`}
|
||||
utxo={utxo}
|
||||
walletId={walletId}
|
||||
selected={selected.includes(`${utxo.txid}:${utxo.vout}`)}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CoinRowProps = { utxo: Utxo; walletId: number; selected: boolean; onToggle: (outpoint: string) => void };
|
||||
|
||||
const CoinRow = ({ utxo, walletId, selected, onToggle }: CoinRowProps) => {
|
||||
const { freeze } = useWalletOperations(walletId);
|
||||
const outpoint = `${utxo.txid}:${utxo.vout}`;
|
||||
|
||||
return (
|
||||
<li className={`flex items-center gap-3 px-4 py-2.5 ${utxo.frozen ? 'opacity-60' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
disabled={utxo.frozen}
|
||||
onChange={() => onToggle(outpoint)}
|
||||
aria-label={`Select ${outpoint}`}
|
||||
className="h-3.5 w-3.5 shrink-0 accent-primary"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{utxo.label || truncateMiddle(utxo.address, 16, 10)}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 font-mono text-[10px] text-muted-foreground">
|
||||
<span>
|
||||
{truncateMiddle(utxo.txid, 10, 6)}:{utxo.vout}
|
||||
</span>
|
||||
<span className="font-sans">{formatConfirmations(utxo.confirmations)}</span>
|
||||
{utxo.addressType && <span className="font-sans">{utxo.addressType}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Amount sats={utxo.amountSats} className="shrink-0 text-xs font-semibold" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => freeze.mutate({ outpoint, frozen: !utxo.frozen })}
|
||||
disabled={freeze.isPending}
|
||||
title={utxo.frozen ? 'Make this coin spendable again' : 'Keep this coin out of automatic selection'}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{utxo.frozen ? <Sun className="h-3.5 w-3.5" /> : <Snowflake className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// A read-only value with a copy button — addresses, invoices, txids, xpubs.
|
||||
//
|
||||
// Never used for anything secret. A mnemonic gets its own deliberately awkward treatment in
|
||||
// SeedBackupDialog rather than a one-tap copy, because "copied to clipboard" is where seeds go to die.
|
||||
|
||||
type CopyFieldProps = {
|
||||
value: string;
|
||||
label?: string;
|
||||
/** Wrap rather than truncate — right for a bech32 address, wrong for a table cell. */
|
||||
wrap?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CopyField = ({ value, label, wrap = false, className = '' }: CopyFieldProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
if (!(await copyToClipboard(value))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1_500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||||
<code className={`min-w-0 flex-1 font-mono text-xs ${wrap ? 'break-all' : 'truncate'}`}>{value}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
aria-label={`Copy ${label ?? 'value'}`}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Bitcoin, Loader2 } from 'lucide-react';
|
||||
|
||||
// The "no wallet selected" and "this backend cannot do that" placeholders.
|
||||
//
|
||||
// Deliberately not an error state: with no wallets registered there is nothing wrong, and a section a
|
||||
// backend does not implement is a fact about the backend, not a failure. The nav hides those sections, so
|
||||
// UnsupportedSection is only reached by a deep link — which should explain itself rather than 404.
|
||||
|
||||
export const EmptyWallet = ({ isLoading = false }: { isLoading?: boolean }) => (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading wallets…</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bitcoin className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium">No wallet yet</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
Add one from the panel on the left — a self-custodial seed sealed under a passphrase, or a connection to a
|
||||
node you already run.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const UnsupportedSection = ({ what }: { what: string }) => (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
<p className="text-sm font-medium">Not available for this wallet</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
This backend does not support {what}. Pick a different wallet from the panel on the left.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1,146 +0,0 @@
|
||||
import type { Channel } from './shared';
|
||||
import { Users, Zap } from 'lucide-react';
|
||||
import { formatSats, formatTimestamp, truncateMiddle, PAYMENT_TONES } from './format';
|
||||
import { Amount } from './Amount';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { usePermissions, useChannels, usePayments, usePeers } from './useWalletData';
|
||||
|
||||
// The node's own view of itself: payments it has made, channels it holds, peers it is connected to.
|
||||
//
|
||||
// lndhub and nwc have no channels or peers of their own — they are accounts on someone else's node — so
|
||||
// those blocks are absent from their capability set and never rendered. Nothing here shows a control that
|
||||
// would come back 501.
|
||||
|
||||
export const LightningView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const hasChannels = capabilities.includes('channels');
|
||||
const hasPeers = capabilities.includes('peers');
|
||||
const hasPayments = capabilities.includes('lightningSend');
|
||||
const anyLightning = hasChannels || hasPeers || hasPayments || capabilities.includes('lightningReceive');
|
||||
|
||||
const { channels } = useChannels(walletId, hasChannels);
|
||||
const { peers } = usePeers(walletId, hasPeers);
|
||||
const { payments } = usePayments(walletId, hasPayments, 25);
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
if (!anyLightning) return <UnsupportedSection what="lightning" />;
|
||||
|
||||
return (
|
||||
<div className="h-full space-y-4 overflow-y-auto p-4">
|
||||
{hasChannels && (
|
||||
<Section title="Channels" count={channels.length}>
|
||||
{channels.length === 0 ? (
|
||||
<Blank>No channels open.</Blank>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{channels.map((channel) => (
|
||||
<ChannelRow key={channel.channelId} channel={channel} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasPeers && (
|
||||
<Section title="Peers" count={peers.length}>
|
||||
{peers.length === 0 ? (
|
||||
<Blank>Not connected to anyone.</Blank>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{peers.map((peer) => (
|
||||
<li key={peer.pubkey} className="flex items-center gap-2 py-2 text-xs">
|
||||
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{peer.alias || truncateMiddle(peer.pubkey, 14, 8)}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{peer.address}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{peer.inbound ? 'inbound' : 'outbound'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasPayments && (
|
||||
<Section title="Payments" count={payments.length}>
|
||||
{payments.length === 0 ? (
|
||||
<Blank>Nothing sent yet.</Blank>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{payments.map((payment) => (
|
||||
<li key={payment.paymentHash} className="py-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Zap className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{payment.memo || truncateMiddle(payment.destination ?? payment.paymentHash, 14, 8)}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-px text-[10px] font-medium ${PAYMENT_TONES[payment.status]}`}
|
||||
>
|
||||
{payment.status}
|
||||
</span>
|
||||
{/* msat stays a string all the way to the DOM — see format.ts. */}
|
||||
<Amount msat={payment.amountMsat} className="shrink-0 font-semibold" />
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[11px] text-muted-foreground">
|
||||
{formatTimestamp(payment.createdAt)} · fee <Amount msat={payment.feeMsat} />
|
||||
{payment.failureReason && <span className="text-destructive"> · {payment.failureReason}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChannelRow = ({ channel }: { channel: Channel }) => {
|
||||
const capacity = channel.capacitySats || 1;
|
||||
const localPct = Math.max(0, Math.min(100, (channel.localBalanceSats / capacity) * 100));
|
||||
|
||||
return (
|
||||
<li className="py-2.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${channel.active ? 'bg-emerald-500' : 'bg-muted-foreground'}`}
|
||||
title={channel.active ? 'active' : channel.status}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{channel.remoteAlias || truncateMiddle(channel.remotePubkey, 14, 8)}
|
||||
</span>
|
||||
{channel.private && <span className="shrink-0 text-[10px] text-muted-foreground">private</span>}
|
||||
<Amount sats={channel.capacitySats} className="shrink-0 text-[11px] text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Local/remote split — the number that decides whether you can send or only receive. */}
|
||||
<div className="mt-1.5 flex h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="bg-primary" style={{ width: `${localPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] tabular-nums text-muted-foreground">
|
||||
<span>out {formatSats(channel.localBalanceSats)}</span>
|
||||
<span>in {formatSats(channel.remoteBalanceSats)}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps = { title: string; count: number; children: React.ReactNode };
|
||||
|
||||
const Section = ({ title, count, children }: SectionProps) => (
|
||||
<section className="rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
{count > 0 && <span className="ml-1.5 font-normal">({count})</span>}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
const Blank = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="text-xs text-muted-foreground">{children}</p>
|
||||
);
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Lock, LockOpen, ShieldCheck } from 'lucide-react';
|
||||
import { formatCountdown } from './format';
|
||||
import { useLockCountdown } from './useLockCountdown';
|
||||
import { useLockActions, useWalletConfig } from './useWalletData';
|
||||
import { UnlockDialog } from './dialogs/UnlockDialog';
|
||||
|
||||
// The lock indicator. Locked is the resting state and reads as reassurance, not as an error — a watch-only
|
||||
// wallet with no key material in memory is the whole point of the design, so it is never styled as a
|
||||
// warning and never blocks the screen behind it.
|
||||
//
|
||||
// The countdown ticks locally (useLockCountdown) and flips to locked at zero without waiting for the poll.
|
||||
|
||||
type LockBadgeProps = {
|
||||
walletId: number | null;
|
||||
walletName: string;
|
||||
/** Compact drops the action button — for the panel header, where there is no room. */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export const LockBadge = ({ walletId, walletName, compact = false }: LockBadgeProps) => {
|
||||
const { hasSeed, unlocked, secondsRemaining, isLoading } = useLockCountdown(walletId);
|
||||
const { config } = useWalletConfig();
|
||||
const { lock } = useLockActions(walletId);
|
||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||
|
||||
// A wallet with no seed — a remote node, a custodial account — has nothing to lock. Showing it a
|
||||
// padlock would imply a protection it does not have.
|
||||
if (walletId == null || hasSeed === false) {
|
||||
if (isLoading || hasSeed == null) return null;
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
no seed held
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasSeed == null) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums ${
|
||||
unlocked ? 'bg-emerald-500/10 text-emerald-600' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
title={
|
||||
unlocked
|
||||
? 'The seed is decrypted in the sidecar until this countdown ends'
|
||||
: 'Locked — balances and history still work; only signing needs the passphrase'
|
||||
}
|
||||
>
|
||||
{unlocked ? <LockOpen className="h-3 w-3" /> : <Lock className="h-3 w-3" />}
|
||||
{unlocked ? formatCountdown(secondsRemaining) : 'locked'}
|
||||
</span>
|
||||
|
||||
{!compact &&
|
||||
(unlocked ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lock.mutate()}
|
||||
disabled={lock.isPending}
|
||||
className="text-[10px] font-medium text-primary hover:underline"
|
||||
>
|
||||
lock now
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUnlockOpen(true)}
|
||||
className="text-[10px] font-medium text-primary hover:underline"
|
||||
>
|
||||
unlock
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{unlockOpen && (
|
||||
<UnlockDialog
|
||||
open={unlockOpen}
|
||||
onOpenChange={setUnlockOpen}
|
||||
walletId={walletId}
|
||||
walletName={walletName}
|
||||
maxTtlSec={config?.unlockTtlSec ?? 300}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The inline "you need to unlock for this" prompt, used by the signing surfaces only. Renders its children
|
||||
* disabled-in-place rather than replacing the screen, so you can still read the form you are about to
|
||||
* submit while the wallet is locked.
|
||||
*/
|
||||
type UnlockPromptProps = { walletId: number; walletName: string; className?: string };
|
||||
|
||||
export const UnlockPrompt = ({ walletId, walletName, className = '' }: UnlockPromptProps) => {
|
||||
const { config } = useWalletConfig();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs ${className}`}
|
||||
>
|
||||
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground">
|
||||
This wallet is locked. Signing needs the passphrase — everything else on this screen already works.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<UnlockDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
walletId={walletId}
|
||||
walletName={walletName}
|
||||
maxTtlSec={config?.unlockTtlSec ?? 300}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,180 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Balances } from './shared';
|
||||
import { ArrowDownLeft, ArrowUpRight, Bitcoin, Clock, Loader2, Zap } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { KIND_LABELS, walletSectionPath } from './shared';
|
||||
import { formatSats, formatTimestamp, truncateMiddle } from './format';
|
||||
import { Amount, UnitToggle } from './Amount';
|
||||
import { EmptyWallet } from './EmptyWallet';
|
||||
import { SyncBadge } from './SyncBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useBalances, usePermissions, useTransactions, useWalletInfo } from './useWalletData';
|
||||
|
||||
// The at-a-glance section: what you hold, whether the node agrees with the chain, and the last few moves.
|
||||
//
|
||||
// Every number here is a read, so the whole screen works against a locked wallet — which is its resting
|
||||
// state. Nothing on this page prompts for a passphrase.
|
||||
|
||||
/**
|
||||
* The sub-line under the on-chain figure. Unconfirmed and frozen are both reasons the headline number is
|
||||
* not what a send can use, and both can be true at once, so they read as a list rather than a choice.
|
||||
*/
|
||||
function onchainHint(balances: Balances | null | undefined): string | undefined {
|
||||
if (!balances) return undefined;
|
||||
const parts: string[] = [];
|
||||
if (balances.onchainUnconfirmed !== 0) {
|
||||
const sign = balances.onchainUnconfirmed > 0 ? '+' : '';
|
||||
parts.push(`${sign}${formatSats(balances.onchainUnconfirmed)} sats unconfirmed`);
|
||||
}
|
||||
if (balances.onchainFrozen > 0) parts.push(`${formatSats(balances.onchainFrozen)} sats frozen`);
|
||||
return parts.length ? parts.join(' · ') : undefined;
|
||||
}
|
||||
|
||||
export const OverviewView = () => {
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const { balances, sync, isLoading: balancesLoading } = useBalances(walletId);
|
||||
const { info } = useWalletInfo(walletId);
|
||||
const { transactions } = useTransactions(walletId, 5);
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
|
||||
const hasLightning = balances?.lightningBalance != null;
|
||||
const canSend = capabilities.includes('onchainSend') || capabilities.includes('lightningSend');
|
||||
const canReceive = capabilities.includes('onchainReceive') || capabilities.includes('lightningReceive');
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold">{wallet?.name}</h2>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{wallet ? KIND_LABELS[wallet.kind] : '—'}
|
||||
{info?.alias && ` · ${info.alias}`}
|
||||
{info?.version && ` · ${info.version}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<SyncBadge sync={sync} />
|
||||
<UnitToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{balancesLoading && !balances ? (
|
||||
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Reading balances…
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Tile
|
||||
icon={<Bitcoin className="h-4 w-4 text-orange-500" />}
|
||||
label="On-chain"
|
||||
value={<Amount sats={balances?.onchainConfirmed ?? null} className="text-xl font-semibold" />}
|
||||
hint={onchainHint(balances)}
|
||||
/>
|
||||
{hasLightning && (
|
||||
<Tile
|
||||
icon={<Zap className="h-4 w-4 text-amber-500" />}
|
||||
label="Lightning"
|
||||
value={<Amount sats={balances?.lightningBalance ?? null} className="text-xl font-semibold" />}
|
||||
hint={
|
||||
balances?.lightningInbound != null ? `${formatSats(balances.lightningInbound)} sats inbound` : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Tile
|
||||
label="Block height"
|
||||
value={
|
||||
<span className="text-xl font-semibold tabular-nums">{info?.blockHeight?.toLocaleString() ?? '—'}</span>
|
||||
}
|
||||
hint={info ? (info.synced ? 'synced' : 'syncing…') : undefined}
|
||||
/>
|
||||
<Tile
|
||||
label="Network"
|
||||
value={<span className="text-xl font-semibold">{info?.network ?? wallet?.network ?? '—'}</span>}
|
||||
hint={info?.pubkey ? truncateMiddle(info.pubkey, 8, 6) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(canReceive || canSend) && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{canReceive && (
|
||||
<Link
|
||||
to={walletSectionPath('receive', walletId)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
<ArrowDownLeft className="h-3.5 w-3.5 text-emerald-500" />
|
||||
Receive
|
||||
</Link>
|
||||
)}
|
||||
{canSend && (
|
||||
<Link
|
||||
to={walletSectionPath('send', walletId)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-destructive" />
|
||||
Send
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mt-4 rounded-xl border border-border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Recent activity</h3>
|
||||
<Link to={walletSectionPath('transactions', walletId)} className="text-[11px] text-primary hover:underline">
|
||||
all transactions
|
||||
</Link>
|
||||
</div>
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Nothing yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{transactions.map((tx) => (
|
||||
<li key={tx.txid} className="flex items-center gap-3 py-2">
|
||||
<span
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${
|
||||
tx.amount < 0 ? 'bg-destructive/10' : 'bg-emerald-500/10'
|
||||
}`}
|
||||
>
|
||||
{tx.amount < 0 ? (
|
||||
<ArrowUpRight className="h-3 w-3 text-destructive" />
|
||||
) : (
|
||||
<ArrowDownLeft className="h-3 w-3 text-emerald-500" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{tx.label ?? truncateMiddle(tx.txid)}</div>
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
{tx.confirmations <= 0 && <Clock className="h-3 w-3" />}
|
||||
{tx.confirmations <= 0 ? 'pending' : formatTimestamp(tx.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<Amount sats={tx.amount} signed className="shrink-0 text-xs" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TileProps = { label: string; value: ReactNode; hint?: string; icon?: ReactNode };
|
||||
|
||||
const Tile = ({ label, value, hint, icon }: TileProps) => (
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1">{value}</div>
|
||||
{hint && (
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground" title={hint}>
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Plus, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatTimestamp, truncateMiddle } from './format';
|
||||
import { INVOICE_TONES } from './format';
|
||||
import { Amount } from './Amount';
|
||||
import { CopyField } from './CopyField';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { errorMessage, usePermissions, useInvoices, useReceiveAddress } from './useWalletData';
|
||||
import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog';
|
||||
|
||||
// Receiving. Works while locked — deriving an address needs the account xpub, not the seed, which is why
|
||||
// this whole screen is usable in the wallet's resting state.
|
||||
//
|
||||
// The address is fetched with `?peek=true`: it returns the current unused address without advancing the
|
||||
// derivation index. Rendering must never burn an address, so advancing is an explicit button.
|
||||
|
||||
export const ReceiveView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const canOnchain = capabilities.includes('onchainReceive');
|
||||
const canLightning = capabilities.includes('lightningReceive');
|
||||
|
||||
const {
|
||||
address,
|
||||
addressType,
|
||||
isLoading: addressLoading,
|
||||
error: addressError,
|
||||
next,
|
||||
} = useReceiveAddress(walletId, canOnchain);
|
||||
const { invoices } = useInvoices(walletId, canLightning, 10);
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
if (!canOnchain && !canLightning) return <UnsupportedSection what="receiving" />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
{canOnchain && (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
On-chain address
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => next.mutate()}
|
||||
disabled={next.isPending}
|
||||
className="flex items-center gap-1 text-[11px] text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${next.isPending ? 'animate-spin' : ''}`} />
|
||||
new address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addressLoading && !address ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Deriving an address…
|
||||
</div>
|
||||
) : address ? (
|
||||
<>
|
||||
<CopyField value={address} wrap />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{addressType ? `${addressType} · ` : ''}Reuse costs you privacy, not money — take a fresh one for each
|
||||
payer.
|
||||
</p>
|
||||
</>
|
||||
) : addressError ? (
|
||||
// Deriving an address needs the chain source, so this fails whenever the Esplora endpoint is
|
||||
// down or rate-limiting. Showing "no address available" for that sent the last debugging
|
||||
// session looking at derivation code when the answer was a 429 in the response body.
|
||||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="break-words">{errorMessage(addressError, 'Could not derive an address')}</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Addresses are derived locally, but the wallet checks the chain to find an unused one. Check{' '}
|
||||
<span className="font-medium">Settings → Chain source</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No address available.</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canLightning && (
|
||||
<section className="mt-4 max-w-2xl rounded-xl border border-border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Lightning invoices
|
||||
</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => setInvoiceOpen(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New invoice
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No invoices yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{invoices.map((inv) => (
|
||||
<li key={inv.paymentHash} className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
{inv.memo || truncateMiddle(inv.paymentHash, 12, 8)}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-px text-[10px] font-medium ${INVOICE_TONES[inv.state]}`}
|
||||
>
|
||||
{inv.state}
|
||||
</span>
|
||||
{/* msat stays a string all the way to the DOM — see format.ts. */}
|
||||
<Amount msat={inv.amountMsat} className="shrink-0 text-xs" />
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[11px] text-muted-foreground">
|
||||
created {formatTimestamp(inv.createdAt)}
|
||||
{inv.settledAt ? ` · settled ${formatTimestamp(inv.settledAt)}` : ''}
|
||||
</div>
|
||||
{inv.state === 'open' && <CopyField className="mt-1.5 pl-5" value={inv.bolt11} wrap />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{invoiceOpen && <CreateInvoiceDialog open={invoiceOpen} onOpenChange={setInvoiceOpen} walletId={walletId} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, Loader2, Search, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ADDRESS_TYPE_LABELS } from './shared';
|
||||
import { useBalances, useWalletOperations } from './useWalletData';
|
||||
|
||||
// Search the chain for coins this wallet already owns.
|
||||
//
|
||||
// THIS IS THE FIX FOR AN IMPORTED WALLET THAT READS ZERO, and that symptom is why the card exists at all.
|
||||
// An indexing chain source (NBXplorer in front of your own node) only watches an account from the moment
|
||||
// you register it, so a seed with a history arrives at a balance of nothing: every call succeeds, no error
|
||||
// is raised, the coins are simply not in the index yet. A scan walks the node's current UTXO set directly
|
||||
// and finds them regardless of when the account was registered or how far back the node is pruned.
|
||||
//
|
||||
// It recovers SPENDABLE COINS, not history. Transactions spent before the account was registered stay
|
||||
// missing — that needs a full block rescan, which is a heavier thing this does not do.
|
||||
//
|
||||
// Hidden entirely when the chain source cannot rescan: Esplora asks about every address on every refresh,
|
||||
// so it has nothing to catch up on, and offering a button that 501s would invent a problem.
|
||||
|
||||
type RescanCardProps = { walletId: number };
|
||||
|
||||
export const RescanCard = ({ walletId }: RescanCardProps) => {
|
||||
const { sync } = useBalances(walletId);
|
||||
const { rescan } = useWalletOperations(walletId);
|
||||
const [gapLimit, setGapLimit] = useState('');
|
||||
|
||||
// `sync` null is a node backend, which owns its own coins and has nothing to look for. `canRescan`
|
||||
// false is Esplora. Note this cannot key off `sync.rescan` being null — that is equally what NBXplorer
|
||||
// reports before its first scan, which is exactly when the card is most needed.
|
||||
if (!sync?.canRescan) return null;
|
||||
|
||||
const state = sync.rescan;
|
||||
const running = state?.running === true;
|
||||
const parsed = Number(gapLimit);
|
||||
const depth = gapLimit.trim() && Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
|
||||
return (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Find missing coins
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Ask the node to search its whole UTXO set for this wallet’s coins. Run this after importing a seed that already
|
||||
had funds — an indexing node only watches an account from the moment it is added, so an older balance shows as
|
||||
zero until it has looked. It scans all four address types, one at a time, and takes a few minutes.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => rescan.mutate(depth ? { gapLimit: depth } : {})}
|
||||
disabled={running || rescan.isPending}
|
||||
>
|
||||
{running || rescan.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{running ? 'Scanning…' : 'Scan the chain'}
|
||||
</Button>
|
||||
|
||||
{/* The one thing about a scan only the owner can know: how many addresses their old wallet handed
|
||||
out and never had paid. 1000 covers anyone who clicked "new address" by hand; a wallet that
|
||||
issued them in bulk needs more, and no amount of looking from here can tell which it was. */}
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
search depth
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
value={gapLimit}
|
||||
placeholder="1000"
|
||||
disabled={running}
|
||||
onChange={(ev) => setGapLimit(ev.target.value)}
|
||||
className="h-7 w-24 text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{state && <Status state={state} />}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type StatusProps = { state: NonNullable<ReturnType<typeof useBalances>['sync']>['rescan'] };
|
||||
|
||||
const Status = ({ state }: StatusProps) => {
|
||||
if (!state) return null;
|
||||
|
||||
if (state.running) {
|
||||
const label = state.current ? ADDRESS_TYPE_LABELS[state.current] : 'queued';
|
||||
return (
|
||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-500">
|
||||
Scanning {label} — {state.done} of {state.total} done
|
||||
{state.found > 0 && `, ${state.found} coin${state.found === 1 ? '' : 's'} found so far`}.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
|
||||
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Stopped after {state.done} of {state.total}: {state.error}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs text-emerald-500">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
Last scan found {state.found} coin{state.found === 1 ? '' : 's'} across {state.total} address types.
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -1,305 +0,0 @@
|
||||
import type { FeeEstimates } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Bitcoin, Coins, Loader2, Zap } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { walletSectionPath } from './shared';
|
||||
import { formatSats } from './format';
|
||||
import { Amount } from './Amount';
|
||||
import { CopyField } from './CopyField';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { UnlockPrompt } from './LockBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCoinSelection } from './useCoinSelection';
|
||||
import { useLockCountdown } from './useLockCountdown';
|
||||
import { useBalances, usePermissions, useFees, useUtxos, useWalletOperations } from './useWalletData';
|
||||
import { PayInvoiceDialog } from './dialogs/PayInvoiceDialog';
|
||||
|
||||
// Sending — the only part of the app that genuinely needs an unlocked wallet.
|
||||
//
|
||||
// The form is fully usable while locked: you can compose the whole spend, see the fee, review the coins.
|
||||
// Only the submit is blocked, with an inline unlock prompt above it rather than a modal wall across the
|
||||
// screen. That ordering is deliberate — you should be able to read what you are about to sign before you
|
||||
// are asked for the passphrase.
|
||||
//
|
||||
// The selected coins come from `?coins=` (useCoinSelection), so the Coins section and this one agree
|
||||
// through the URL rather than a shared store.
|
||||
|
||||
const FEE_PRESETS: { key: keyof FeeEstimates; label: string; hint: string }[] = [
|
||||
{ key: 'fastestFee', label: 'Fastest', hint: 'next block' },
|
||||
{ key: 'halfHourFee', label: 'Fast', hint: '~30 min' },
|
||||
{ key: 'hourFee', label: 'Normal', hint: '~1 hour' },
|
||||
{ key: 'economyFee', label: 'Economy', hint: 'hours' },
|
||||
{ key: 'minimumFee', label: 'Minimum', hint: 'whenever' },
|
||||
];
|
||||
|
||||
export const SendView = () => {
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const canOnchain = capabilities.includes('onchainSend');
|
||||
const canLightning = capabilities.includes('lightningSend');
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
if (!canOnchain && !canLightning) return <UnsupportedSection what="sending" />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<Tabs defaultValue={canOnchain ? 'onchain' : 'lightning'} className="max-w-2xl">
|
||||
{canOnchain && canLightning && (
|
||||
<TabsList className="mb-4 w-fit">
|
||||
<TabsTrigger value="onchain">
|
||||
<Bitcoin className="mr-1.5 h-3.5 w-3.5" />
|
||||
On-chain
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="lightning">
|
||||
<Zap className="mr-1.5 h-3.5 w-3.5" />
|
||||
Lightning
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
{canOnchain && (
|
||||
<TabsContent value="onchain" className="mt-0">
|
||||
<OnchainSendForm walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{canLightning && (
|
||||
<TabsContent value="lightning" className="mt-0">
|
||||
<LightningSendPanel walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type FormProps = { walletId: number; walletName: string };
|
||||
|
||||
const OnchainSendForm = ({ walletId, walletName }: FormProps) => {
|
||||
const { balances } = useBalances(walletId);
|
||||
const { fees } = useFees(walletId);
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const { selected, clear } = useCoinSelection();
|
||||
const { utxos } = useUtxos(walletId, capabilities.includes('coinControl'));
|
||||
const { send } = useWalletOperations(walletId);
|
||||
const { hasSeed, unlocked } = useLockCountdown(walletId);
|
||||
|
||||
const [address, setAddress] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [sendAll, setSendAll] = useState(false);
|
||||
const [satPerVbyte, setSatPerVbyte] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [broadcast, setBroadcast] = useState<{ txid: string; feeSats: number } | null>(null);
|
||||
|
||||
// A seeded wallet must be unlocked to sign. A wallet with no seed of its own (a remote node) signs
|
||||
// upstream, so there is nothing to unlock and the form is always live.
|
||||
const needsUnlock = hasSeed === true && !unlocked;
|
||||
const selectedUtxos = utxos.filter((u) => selected.includes(`${u.txid}:${u.vout}`));
|
||||
const selectedTotal = selectedUtxos.reduce((sum, u) => sum + u.amountSats, 0);
|
||||
|
||||
const feeRate = Number(satPerVbyte);
|
||||
const amountSats = Number(amount);
|
||||
const canSubmit =
|
||||
!!address.trim() &&
|
||||
(sendAll || (Number.isFinite(amountSats) && amountSats > 0)) &&
|
||||
Number.isFinite(feeRate) &&
|
||||
feeRate >= 1 &&
|
||||
!needsUnlock &&
|
||||
!send.isPending;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
const result = await send.mutateAsync({
|
||||
address: address.trim(),
|
||||
amountSats: sendAll ? undefined : amountSats,
|
||||
sendAll: sendAll || undefined,
|
||||
satPerVbyte: feeRate,
|
||||
outpoints: selected.length > 0 ? selected : undefined,
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
setBroadcast({ txid: result.txid, feeSats: result.feeSats });
|
||||
setAddress('');
|
||||
setAmount('');
|
||||
setLabel('');
|
||||
setSendAll(false);
|
||||
clear();
|
||||
};
|
||||
|
||||
if (broadcast) {
|
||||
return (
|
||||
<section className="rounded-xl border border-emerald-500/40 bg-emerald-500/5 p-4">
|
||||
<h3 className="text-sm font-semibold">Broadcast</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Paid a fee of {formatSats(broadcast.feeSats)} sats. It will confirm when a miner includes it.
|
||||
</p>
|
||||
<CopyField className="mt-3" value={broadcast.txid} label="txid" wrap />
|
||||
<Button size="sm" variant="outline" className="mt-3" onClick={() => setBroadcast(null)}>
|
||||
Send another
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Spendable</h3>
|
||||
{/* Frozen coins are confirmed and are the wallet's, but coin selection will not touch them. This
|
||||
header said "Spendable" over a figure that included them, so a max-value send was rejected as
|
||||
having no spendable UTXOs while the number above it said otherwise. */}
|
||||
<Amount
|
||||
sats={balances ? balances.onchainConfirmed - balances.onchainFrozen : null}
|
||||
className="text-sm font-semibold"
|
||||
/>
|
||||
</div>
|
||||
{!!balances?.onchainFrozen && (
|
||||
<p className="mt-1 text-right text-[11px] text-muted-foreground">
|
||||
{formatSats(balances.onchainFrozen)} sats frozen and excluded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-send-address">To address</Label>
|
||||
<Input
|
||||
id="wallet-send-address"
|
||||
value={address}
|
||||
spellCheck={false}
|
||||
placeholder="bc1q…"
|
||||
onChange={(ev) => setAddress(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-send-amount">Amount (sats)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="wallet-send-amount"
|
||||
type="number"
|
||||
min={0}
|
||||
value={sendAll ? '' : amount}
|
||||
disabled={sendAll}
|
||||
placeholder={sendAll ? 'everything' : '0'}
|
||||
onChange={(ev) => setAmount(ev.target.value)}
|
||||
className="w-48"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSendAll((v) => !v)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
sendAll ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
Send max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-send-fee">Fee rate (sat/vB)</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{fees &&
|
||||
FEE_PRESETS.map(({ key, label: presetLabel, hint }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setSatPerVbyte(String(fees[key]))}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
satPerVbyte === String(fees[key])
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
title={hint}
|
||||
>
|
||||
{presetLabel} · {fees[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
id="wallet-send-fee"
|
||||
type="number"
|
||||
min={1}
|
||||
value={satPerVbyte}
|
||||
placeholder="1"
|
||||
onChange={(ev) => setSatPerVbyte(ev.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{capabilities.includes('coinControl') && (
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Coins className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{selected.length === 0 ? (
|
||||
<span className="flex-1 text-muted-foreground">
|
||||
Automatic coin selection.{' '}
|
||||
<Link to={walletSectionPath('coins', walletId)} className="text-primary hover:underline">
|
||||
Pick coins
|
||||
</Link>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1">
|
||||
{selected.length} coin{selected.length === 1 ? '' : 's'} selected · <Amount sats={selectedTotal} />
|
||||
</span>
|
||||
<button type="button" onClick={clear} className="text-primary hover:underline">
|
||||
clear
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-send-label">Label (optional)</Label>
|
||||
<Input
|
||||
id="wallet-send-label"
|
||||
value={label}
|
||||
placeholder="what this payment is for"
|
||||
onChange={(ev) => setLabel(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{needsUnlock && <UnlockPrompt walletId={walletId} walletName={walletName} />}
|
||||
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{send.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Sign and broadcast
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const LightningSendPanel = ({ walletId, walletName }: FormProps) => {
|
||||
const { balances } = useBalances(walletId);
|
||||
const { hasSeed, unlocked } = useLockCountdown(walletId);
|
||||
const [payOpen, setPayOpen] = useState(false);
|
||||
const needsUnlock = hasSeed === true && !unlocked;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Lightning balance</h3>
|
||||
<Amount sats={balances?.lightningBalance ?? null} className="text-sm font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsUnlock && <UnlockPrompt walletId={walletId} walletName={walletName} />}
|
||||
|
||||
<Button onClick={() => setPayOpen(true)} disabled={needsUnlock}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Pay an invoice
|
||||
</Button>
|
||||
|
||||
{payOpen && <PayInvoiceDialog open={payOpen} onOpenChange={setPayOpen} walletId={walletId} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { SyncState } from './shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle, Check, RefreshCw } from 'lucide-react';
|
||||
|
||||
// How old the numbers on screen are.
|
||||
//
|
||||
// This exists because "empty wallet" and "we could not reach the chain" used to render identically — as a
|
||||
// zero balance — and the second one is alarming while the first is not. The sidecar now serves the last
|
||||
// snapshot it managed to read and refreshes behind the response, so the number is almost always real; what
|
||||
// the owner needs is its age, and a plain statement when the upstream is failing.
|
||||
//
|
||||
// Nothing here is a control. Refreshing is the data layer's job and it is already happening.
|
||||
|
||||
/** Re-render on a timer so "2 minutes ago" does not sit frozen on an idle screen. */
|
||||
const TICK_MS = 15_000;
|
||||
|
||||
function relativeAge(syncedAt: number, now: number): string {
|
||||
const seconds = Math.max(0, Math.round((now - syncedAt) / 1000));
|
||||
if (seconds < 45) return 'just now';
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
type SyncBadgeProps = { sync: SyncState | null; className?: string };
|
||||
|
||||
export const SyncBadge = ({ sync, className }: SyncBadgeProps) => {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), TICK_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Null from a node backend, which reads live and has no age to report. Rendering "just now" for it
|
||||
// would be inventing a fact.
|
||||
if (!sync) return null;
|
||||
|
||||
// A rescan outranks everything else here, including a stale age and an upstream error. While the node
|
||||
// is searching, the number beside this badge is not "your balance" — it is "your balance so far", and
|
||||
// that difference is the entire reason an imported wallet showing zero is alarming rather than boring.
|
||||
const { rescan } = sync;
|
||||
if (rescan?.running) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-500 ${className ?? ''}`}
|
||||
title="The node is searching its whole UTXO set for this wallet’s coins. Balances stay incomplete until it finishes."
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
Scanning the chain · {Math.min(rescan.done + 1, rescan.total)} of {rescan.total}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const failing = sync.lastError !== null;
|
||||
|
||||
// Never read successfully AND failing: the only case where the numbers beside this are not real. Say so
|
||||
// loudly, because a zero here means "unknown", not "empty".
|
||||
if (sync.syncedAt === null) {
|
||||
if (!failing) return null;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[11px] text-destructive ${className ?? ''}`}
|
||||
title={sync.lastError ?? undefined}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Could not reach the chain
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const age = relativeAge(sync.syncedAt, now);
|
||||
|
||||
if (failing) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-500 ${className ?? ''}`}
|
||||
title={sync.lastError ?? undefined}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{age} · chain unreachable
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] text-muted-foreground ${className ?? ''}`}>
|
||||
{sync.stale ? <RefreshCw className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
{sync.stale ? `${age} · refreshing` : age}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { OnchainTx } from './shared';
|
||||
import { ArrowDownLeft, ArrowUpRight, Loader2 } from 'lucide-react';
|
||||
import { formatConfirmations, formatSats, formatTimestamp, truncateMiddle } from './format';
|
||||
import { Amount } from './Amount';
|
||||
import { EmptyWallet } from './EmptyWallet';
|
||||
import { SyncBadge } from './SyncBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useTransactions } from './useWalletData';
|
||||
|
||||
// On-chain history. Works while locked — the transaction list comes from the account xpub, not the seed.
|
||||
|
||||
export const TransactionsView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { transactions, sync, isLoading: txLoading } = useTransactions(walletId);
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
|
||||
if (txLoading && transactions.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Reading the chain…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
// An empty list means "no transactions" only if we actually managed to look. When the chain has never
|
||||
// been read, the same screen would otherwise assert a fact nobody established.
|
||||
const neverRead = sync !== null && sync.syncedAt === null;
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
|
||||
<p className="text-sm font-medium">{neverRead ? 'History unavailable' : 'Nothing here yet'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{neverRead
|
||||
? (sync.lastError ?? 'The chain source could not be reached.')
|
||||
: 'Transactions appear as soon as they hit the mempool.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center justify-end border-b border-border px-4 py-1.5">
|
||||
<SyncBadge sync={sync} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ul className="divide-y divide-border">
|
||||
{transactions.map((tx) => (
|
||||
<TransactionRow key={tx.txid} tx={tx} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TransactionRow = ({ tx }: { tx: OnchainTx }) => {
|
||||
const incoming = tx.amount >= 0;
|
||||
const pending = tx.confirmations <= 0;
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${
|
||||
incoming ? 'bg-emerald-500/10 text-emerald-500' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{incoming ? <ArrowDownLeft className="h-3.5 w-3.5" /> : <ArrowUpRight className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{tx.label || truncateMiddle(tx.destAddresses[0] ?? tx.txid, 14, 10)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{formatTimestamp(tx.timestamp)}</span>
|
||||
<span className={pending ? 'text-amber-500' : ''}>{formatConfirmations(tx.confirmations)}</span>
|
||||
{tx.feeSats != null && tx.feeSats > 0 && <span>fee {formatSats(tx.feeSats)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<Amount sats={tx.amount} signed className="text-xs font-semibold" />
|
||||
<div className="mt-0.5 font-mono text-[10px] text-muted-foreground">{truncateMiddle(tx.txid, 8, 6)}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { WalletSummary } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Link, NavLink } from 'react-router';
|
||||
import { ArrowDownLeft, ArrowUpRight, Bitcoin, Coins, Gauge, Plus, Receipt, Settings, Star, Zap } from 'lucide-react';
|
||||
import {
|
||||
KIND_LABELS,
|
||||
NETWORK_TONES,
|
||||
WALLET_SECTIONS,
|
||||
sectionAvailable,
|
||||
walletSectionPath,
|
||||
type WalletSectionId,
|
||||
} from './shared';
|
||||
import { Amount, UnitToggle } from './Amount';
|
||||
import { LockBadge } from './LockBadge';
|
||||
import { useWalletSection } from './useWalletSection';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useBalances, usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
|
||||
import { CreateWalletDialog } from './dialogs/CreateWalletDialog';
|
||||
|
||||
// Left panel of /wallet: the balance, the wallets, the sections, the lock state.
|
||||
//
|
||||
// Wallet rows are real links carrying `?wallet=<id>` — cmd-click, back button and reload all work, and the
|
||||
// id is in the DOM rather than an onClick closure (docs/navigation-audit.md). "Make active" is a sibling
|
||||
// button, not nested inside the anchor, because it mutates rather than navigates.
|
||||
//
|
||||
// Sections are filtered by the backend's declared capabilities: an on-chain wallet has no Lightning tab at
|
||||
// all, an lndhub account has no coin control. Better a shorter nav than a control that answers 501.
|
||||
|
||||
const ICONS: Record<WalletSectionId, LucideIcon> = {
|
||||
overview: Gauge,
|
||||
receive: ArrowDownLeft,
|
||||
send: ArrowUpRight,
|
||||
transactions: Receipt,
|
||||
coins: Coins,
|
||||
lightning: Zap,
|
||||
settings: Settings,
|
||||
};
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
export const WalletNav = () => {
|
||||
const section = useWalletSection();
|
||||
const { config } = useWalletConfig();
|
||||
const { wallet, wallets, walletId, isPinned, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const { balances } = useBalances(walletId);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
// Preserve an explicitly pinned wallet across section changes; leave a bare URL bare so it keeps
|
||||
// meaning "whichever wallet is active".
|
||||
const linkWalletId = isPinned ? walletId : null;
|
||||
const total = balances ? balances.onchainConfirmed + (balances.lightningBalance ?? 0) : null;
|
||||
const network = wallet?.network ?? config?.network ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-orange-500/15 text-orange-500 ring-1 ring-black/5">
|
||||
<Bitcoin className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold leading-tight">Wallet</span>
|
||||
{network && network !== 'bitcoin' && (
|
||||
<span className={`rounded px-1 py-px text-[9px] font-semibold uppercase ${NETWORK_TONES[network] ?? ''}`}>
|
||||
{network}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Amount sats={total} />
|
||||
<UnitToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-5 pb-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Wallets</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex items-center gap-0.5 text-[10px] font-medium text-primary hover:underline"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{isLoading && wallets.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">Loading wallets…</div>
|
||||
)}
|
||||
{!isLoading && wallets.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
No wallets yet. Add one to get started — a self-custodial seed, or a connection to your node.
|
||||
</div>
|
||||
)}
|
||||
{wallets.map((w) => (
|
||||
<WalletRow key={w.id} wallet={w} section={section} selected={w.id === walletId} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 border-t border-border px-2 py-3">
|
||||
{WALLET_SECTIONS.filter(({ id }) => sectionAvailable(id, capabilities)).map(({ id, label }) => {
|
||||
const Icon = ICONS[id];
|
||||
return (
|
||||
<NavLink
|
||||
key={id}
|
||||
to={walletSectionPath(id, linkWalletId)}
|
||||
className={({ isActive }) =>
|
||||
`${ROW} ${
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
<span className="flex-1">{label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2 px-4 py-3">
|
||||
<LockBadge walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
|
||||
{config && <span className="truncate text-[10px] text-muted-foreground">{config.network}</span>}
|
||||
</div>
|
||||
|
||||
{createOpen && <CreateWalletDialog open={createOpen} onOpenChange={setCreateOpen} section={section} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type WalletRowProps = { wallet: WalletSummary; section: WalletSectionId; selected: boolean };
|
||||
|
||||
const WalletRow = ({ wallet, section, selected }: WalletRowProps) => {
|
||||
const { activate } = useWalletLifecycle();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-1 rounded-lg pr-1.5 transition-colors ${
|
||||
selected ? 'bg-muted' : 'hover:bg-muted/60'
|
||||
}`}
|
||||
>
|
||||
<Link to={walletSectionPath(section, wallet.id)} className="flex min-w-0 flex-1 flex-col px-3 py-1.5">
|
||||
<span className={`truncate text-xs ${selected ? 'font-medium text-foreground' : 'text-muted-foreground'}`}>
|
||||
{wallet.name}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">{KIND_LABELS[wallet.kind]}</span>
|
||||
</Link>
|
||||
{/* Sibling of the anchor, never nested inside it — this mutates, it does not navigate. */}
|
||||
{wallet.isActive ? (
|
||||
<Star className="h-3 w-3 shrink-0 fill-amber-400 text-amber-400" aria-label="Active wallet" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => activate.mutate(wallet.id)}
|
||||
disabled={activate.isPending}
|
||||
title="Make this the active wallet"
|
||||
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-amber-400 group-hover:opacity-100 focus:opacity-100"
|
||||
>
|
||||
<Star className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,279 +0,0 @@
|
||||
import type { WalletSummary } from './shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Check, Eye, KeyRound, Pencil, Star, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { KIND_LABELS, walletSectionPath } from './shared';
|
||||
import { ChainSourceCard } from './ChainSourceCard';
|
||||
import { CopyField } from './CopyField';
|
||||
import { EmptyWallet } from './EmptyWallet';
|
||||
import { LockBadge } from './LockBadge';
|
||||
import { RescanCard } from './RescanCard';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useLockCountdown } from './useLockCountdown';
|
||||
import { usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
|
||||
import { ChangePassphraseDialog } from './dialogs/ChangePassphraseDialog';
|
||||
import { ExportSeedDialog } from './dialogs/ExportSeedDialog';
|
||||
import { DeleteWalletDialog } from './dialogs/DeleteWalletDialog';
|
||||
|
||||
// Wallet-level administration: what this wallet is, what its backend can do, and the three passphrase
|
||||
// operations. Nothing here needs the wallet unlocked to *read* — each destructive action asks for the
|
||||
// passphrase itself, because an open unlock window is not consent to change or destroy the key material.
|
||||
|
||||
export const WalletSettingsView = () => {
|
||||
const navigate = useNavigate();
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { config } = useWalletConfig();
|
||||
const { capabilities, kind } = usePermissions(walletId);
|
||||
const { hasSeed } = useLockCountdown(walletId);
|
||||
const { activate, rename } = useWalletLifecycle();
|
||||
|
||||
const [passphraseOpen, setPassphraseOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
if (!wallet || walletId == null) return <EmptyWallet isLoading={isLoading} />;
|
||||
|
||||
const xpubs = Object.entries(wallet.xpubs ?? {});
|
||||
|
||||
return (
|
||||
<div className="h-full space-y-4 overflow-y-auto p-4">
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<WalletNameField
|
||||
wallet={wallet}
|
||||
onSave={(name) => rename.mutateAsync({ walletId, name })}
|
||||
isPending={rename.isPending}
|
||||
/>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{KIND_LABELS[wallet.kind]} · {wallet.network} · added {wallet.createdAt.slice(0, 10)}
|
||||
</p>
|
||||
</div>
|
||||
<LockBadge walletId={walletId} walletName={wallet.name} />
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 space-y-1.5 text-xs">
|
||||
<Row label="Backend">{kind ?? wallet.kind}</Row>
|
||||
<Row label="Default derivation">BIP{wallet.defaultBip}</Row>
|
||||
{wallet.fingerprint && (
|
||||
<Row label="Fingerprint">
|
||||
<code className="font-mono">{wallet.fingerprint}</code>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Active">{wallet.isActive ? 'yes' : 'no'}</Row>
|
||||
<Row label="Holds a seed">{hasSeed === true ? 'yes' : hasSeed === false ? 'no' : '—'}</Row>
|
||||
</dl>
|
||||
|
||||
{!wallet.isActive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-3"
|
||||
onClick={() => activate.mutate(wallet.id)}
|
||||
disabled={activate.isPending}
|
||||
>
|
||||
<Star className="mr-1.5 h-3.5 w-3.5" />
|
||||
Make this the active wallet
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{xpubs.length > 0 && (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Account keys</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Extended public keys. They can watch this wallet but never spend it — safe to hand to a block explorer or an
|
||||
accounting tool.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{xpubs.map(([path, xpub]) => (
|
||||
<CopyField key={path} value={xpub} label={path} wrap />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
What this backend can do
|
||||
</h3>
|
||||
{capabilities.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No capabilities reported.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{capabilities.map((cap) => (
|
||||
<span key={cap} className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{hasSeed === true && (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Key material</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Both of these ask for the passphrase on their own, whether or not the wallet is currently unlocked.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setPassphraseOpen(true)}>
|
||||
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
|
||||
Change passphrase
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setExportOpen(true)}>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" />
|
||||
Show recovery phrase
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ChainSourceCard />
|
||||
|
||||
<RescanCard walletId={walletId} />
|
||||
|
||||
{config && (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Deployment</h3>
|
||||
<dl className="space-y-1.5 text-xs">
|
||||
<Row label="Network">{config.network}</Row>
|
||||
<Row label="Max unlock window">{formatMinutes(config.unlockTtlSec)}</Row>
|
||||
<Row label="Store key">
|
||||
{config.storeKeyConfigured ? 'configured' : <span className="text-destructive">missing</span>}
|
||||
</Row>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="max-w-2xl rounded-xl border border-destructive/40 p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-destructive">Danger</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{wallet.hasSeed
|
||||
? 'Deleting erases the encrypted seed. Only your written recovery phrase can bring the coins back.'
|
||||
: 'Deleting removes the connection. The node itself is untouched.'}
|
||||
</p>
|
||||
<Button size="sm" variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete wallet
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{passphraseOpen && (
|
||||
<ChangePassphraseDialog open={passphraseOpen} onOpenChange={setPassphraseOpen} walletId={walletId} />
|
||||
)}
|
||||
{exportOpen && <ExportSeedDialog open={exportOpen} onOpenChange={setExportOpen} walletId={walletId} />}
|
||||
{deleteOpen && (
|
||||
<DeleteWalletDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
wallet={wallet}
|
||||
// The deleted id must not stay in `?wallet=` — drop back to the bare section, which means
|
||||
// "whatever is active now".
|
||||
onDeleted={() => navigate(walletSectionPath('overview'), { replace: true })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The wallet's name is the one field here that is purely a label — editing it in place, not in a dialog. */
|
||||
const MAX_NAME = 64;
|
||||
|
||||
type WalletNameFieldProps = {
|
||||
wallet: WalletSummary;
|
||||
onSave: (name: string) => Promise<unknown>;
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
const WalletNameField = ({ wallet, onSave, isPending }: WalletNameFieldProps) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(wallet.name);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Switching wallets while the editor is open would otherwise rename the NEW wallet to the old one's draft.
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraft(wallet.name);
|
||||
}, [wallet.id, wallet.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.select();
|
||||
}, [editing]);
|
||||
|
||||
const trimmed = draft.trim();
|
||||
const dirty = trimmed !== wallet.name;
|
||||
|
||||
const commit = async () => {
|
||||
if (!trimmed || !dirty) {
|
||||
setDraft(wallet.name);
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSave(trimmed);
|
||||
setEditing(false);
|
||||
} catch {
|
||||
// The mutation already toasted; keep the editor open so the typed name isn't lost.
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setDraft(wallet.name);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-semibold">{wallet.name}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label="Rename wallet"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={draft}
|
||||
maxLength={MAX_NAME}
|
||||
disabled={isPending}
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') void commit();
|
||||
if (ev.key === 'Escape') cancel();
|
||||
}}
|
||||
className="h-7 max-w-56 text-sm font-semibold"
|
||||
aria-label="Wallet name"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={commit} disabled={isPending || !trimmed}>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={cancel} disabled={isPending}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatMinutes = (seconds: number) => (seconds >= 60 ? `${Math.round(seconds / 60)} min` : `${seconds}s`);
|
||||
|
||||
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 truncate text-right">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useWalletSection } from './useWalletSection';
|
||||
import { OverviewView } from './OverviewView';
|
||||
import { ReceiveView } from './ReceiveView';
|
||||
import { SendView } from './SendView';
|
||||
import { TransactionsView } from './TransactionsView';
|
||||
import { CoinsView } from './CoinsView';
|
||||
import { LightningView } from './LightningView';
|
||||
import { WalletSettingsView } from './WalletSettingsView';
|
||||
|
||||
// Right panel of the /wallet workspace — renders the section named by the URL.
|
||||
//
|
||||
// Each section decides for itself whether the backend supports it, so a deep link to a section this wallet
|
||||
// cannot serve explains itself rather than 404ing. The nav simply hides those entries.
|
||||
|
||||
export const WalletView = () => {
|
||||
const section = useWalletSection();
|
||||
|
||||
switch (section) {
|
||||
case 'receive':
|
||||
return <ReceiveView />;
|
||||
case 'send':
|
||||
return <SendView />;
|
||||
case 'transactions':
|
||||
return <TransactionsView />;
|
||||
case 'coins':
|
||||
return <CoinsView />;
|
||||
case 'lightning':
|
||||
return <LightningView />;
|
||||
case 'settings':
|
||||
return <WalletSettingsView />;
|
||||
default:
|
||||
return <OverviewView />;
|
||||
}
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Bitcoin } from 'lucide-react';
|
||||
import { WALLET_SECTIONS } from './shared';
|
||||
import { Amount, UnitToggle } from './Amount';
|
||||
import { LockBadge } from './LockBadge';
|
||||
import { useWalletSection } from './useWalletSection';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useBalances } from './useWalletData';
|
||||
|
||||
// Panel header for the right (wallet-view) panel: which section, which wallet, the spendable balance and
|
||||
// the lock state. The lock indicator is compact here — it reports, it does not act, because the actions
|
||||
// live in the nav and on the signing surfaces themselves.
|
||||
|
||||
export const WalletViewHeader = () => {
|
||||
const section = useWalletSection();
|
||||
const { wallet, walletId } = useSelectedWallet();
|
||||
const { balances } = useBalances(walletId);
|
||||
const label = WALLET_SECTIONS.find((s) => s.id === section)?.label ?? 'Wallet';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Bitcoin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">
|
||||
{label}
|
||||
{wallet && <span className="ml-1.5 font-normal text-black/50">· {wallet.name}</span>}
|
||||
</span>
|
||||
{balances && <Amount sats={balances.onchainConfirmed} className="shrink-0 text-[10px] text-black/60" />}
|
||||
<UnitToggle className="shrink-0" />
|
||||
<LockBadge walletId={walletId} walletName={wallet?.name ?? 'this wallet'} compact />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLockActions } from '../useWalletData';
|
||||
|
||||
// Re-encrypt the seed under a new passphrase. The sidecar decrypts with the old one and re-seals with the
|
||||
// new one, then re-locks — there is no window where the wallet is left open as a side effect.
|
||||
//
|
||||
// Both passphrases live only in this component's state and are cleared as soon as the request settles.
|
||||
|
||||
type ChangePassphraseDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const ChangePassphraseDialog = ({ open, onOpenChange, walletId }: ChangePassphraseDialogProps) => {
|
||||
const { changePassphrase } = useLockActions(walletId);
|
||||
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
|
||||
const [oldPassphrase, setOldPassphrase] = useState('');
|
||||
const [newPassphrase, setNewPassphrase] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
|
||||
const clearSecrets = () => {
|
||||
setOldPassphrase('');
|
||||
setNewPassphrase('');
|
||||
setConfirm('');
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
clearSecrets();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const mismatch = confirm.length > 0 && confirm !== newPassphrase;
|
||||
const canSubmit =
|
||||
!!oldPassphrase && newPassphrase.length >= 8 && confirm === newPassphrase && !changePassphrase.isPending;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
await changePassphrase.mutateAsync({ oldPassphrase, newPassphrase });
|
||||
close();
|
||||
} finally {
|
||||
// Cleared on failure too — a rejected passphrase is still a passphrase held in memory.
|
||||
clearSecrets();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change passphrase</DialogTitle>
|
||||
<DialogDescription>
|
||||
The seed is re-sealed under the new passphrase and the wallet is locked again. Lose it and the coins go
|
||||
with it — there is no reset.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pass-old">Current passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-pass-old"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={oldPassphrase}
|
||||
onChange={(ev) => setOldPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pass-new">New passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-pass-new"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassphrase}
|
||||
onChange={(ev) => setNewPassphrase(ev.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">At least 8 characters.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pass-confirm">Confirm new passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-pass-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(ev) => setConfirm(ev.target.value)}
|
||||
/>
|
||||
{mismatch && <p className="text-[11px] text-destructive">These do not match.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{changePassphrase.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Change
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { satsToMsat } from '../format';
|
||||
import { CopyField } from '../CopyField';
|
||||
import { useWalletOperations } from '../useWalletData';
|
||||
|
||||
// Create a BOLT11 invoice. Receiving needs no key material, so this works while the wallet is locked.
|
||||
//
|
||||
// The amount is entered in sats and converted with satsToMsat, which appends three zeros to the digit
|
||||
// string rather than multiplying — the request field is a msat STRING and must stay one.
|
||||
|
||||
type CreateInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const CreateInvoiceDialog = ({ open, onOpenChange, walletId }: CreateInvoiceDialogProps) => {
|
||||
const { createInvoice } = useWalletOperations(walletId);
|
||||
const [amountSats, setAmountSats] = useState('');
|
||||
const [memo, setMemo] = useState('');
|
||||
const [expiryMinutes, setExpiryMinutes] = useState('60');
|
||||
const [created, setCreated] = useState<string | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setAmountSats('');
|
||||
setMemo('');
|
||||
setExpiryMinutes('60');
|
||||
setCreated(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const sats = Number(amountSats);
|
||||
const result = await createInvoice.mutateAsync({
|
||||
// Omitted entirely for a zero-amount invoice, which is a legitimate "payer decides" request.
|
||||
amountMsat: amountSats && Number.isFinite(sats) && sats > 0 ? satsToMsat(sats) : undefined,
|
||||
memo: memo.trim() || undefined,
|
||||
expirySeconds: Number(expiryMinutes) > 0 ? Number(expiryMinutes) * 60 : undefined,
|
||||
});
|
||||
setCreated(result.invoice.bolt11);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
{created ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invoice created</DialogTitle>
|
||||
<DialogDescription>Send this to whoever is paying you.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CopyField value={created} wrap label="BOLT11" />
|
||||
<DialogFooter>
|
||||
<Button onClick={close}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New invoice</DialogTitle>
|
||||
<DialogDescription>Leave the amount blank to let the payer choose.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-inv-amount">Amount (sats)</Label>
|
||||
<Input
|
||||
id="wallet-inv-amount"
|
||||
type="number"
|
||||
min={0}
|
||||
value={amountSats}
|
||||
placeholder="any amount"
|
||||
onChange={(ev) => setAmountSats(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-inv-memo">Description</Label>
|
||||
<Input
|
||||
id="wallet-inv-memo"
|
||||
value={memo}
|
||||
placeholder="what this is for"
|
||||
onChange={(ev) => setMemo(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-inv-expiry">Expires in (minutes)</Label>
|
||||
<Input
|
||||
id="wallet-inv-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
value={expiryMinutes}
|
||||
onChange={(ev) => setExpiryMinutes(ev.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createInvoice.isPending}>
|
||||
{createInvoice.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,559 +0,0 @@
|
||||
import type { BackendKind } from '../shared';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { KIND_LABELS, walletSectionPath, type WalletSectionId } from '../shared';
|
||||
import { errorMessage, useWalletConfig, useWalletLifecycle } from '../useWalletData';
|
||||
import { SeedBackupDialog } from './SeedBackupDialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
SeedPhraseInput,
|
||||
SEED_LENGTHS,
|
||||
findOrderFix,
|
||||
isAezeed,
|
||||
normalizePhrase,
|
||||
phraseProblem,
|
||||
type SeedLength,
|
||||
} from './SeedPhraseInput';
|
||||
|
||||
// Add a wallet. Two genuinely different shapes behind one form:
|
||||
//
|
||||
// onchain — self-custodial. A seed is generated (or imported) and sealed under a passphrase that only
|
||||
// ever exists in this component's state and the request body. Generating returns the mnemonic
|
||||
// exactly once, which is why this dialog hands straight over to SeedBackupDialog and does not
|
||||
// close until the owner confirms they wrote it down.
|
||||
// everything else — a connection to somebody's node or account, so it is a config blob, no seed at all.
|
||||
//
|
||||
// Wallet creation is refused outright when the wallet's at-rest key is unreachable. The form says so up front
|
||||
// rather than letting the submit fail with a crypto error.
|
||||
|
||||
type CreateWalletDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Where to land after creating — keeps the new wallet's id in the URL. */
|
||||
section: WalletSectionId;
|
||||
};
|
||||
|
||||
const KINDS: BackendKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
|
||||
|
||||
/** The connection fields each remote backend needs, spelled once. */
|
||||
const CONFIG_FIELDS: Record<Exclude<BackendKind, 'onchain'>, { key: string; label: string; placeholder: string }[]> = {
|
||||
lnd: [
|
||||
{ key: 'url', label: 'REST URL', placeholder: 'https://node.local:8080' },
|
||||
{ key: 'macaroon', label: 'Admin macaroon (hex)', placeholder: '0201036c6e64…' },
|
||||
],
|
||||
'cln-rest': [
|
||||
{ key: 'url', label: 'CLNRest URL', placeholder: 'https://node.local:3010' },
|
||||
{ key: 'rune', label: 'Rune', placeholder: 'ZW5jcnlwdGVkOg…' },
|
||||
],
|
||||
lndhub: [
|
||||
{ key: 'url', label: 'LNDHub URL', placeholder: 'https://lndhub.io' },
|
||||
{ key: 'login', label: 'Login', placeholder: 'lndhub login' },
|
||||
{ key: 'password', label: 'Password', placeholder: 'lndhub password' },
|
||||
],
|
||||
nwc: [{ key: 'connectionString', label: 'Connection string', placeholder: 'nostr+walletconnect://…' }],
|
||||
};
|
||||
|
||||
export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWalletDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { config } = useWalletConfig();
|
||||
const { create } = useWalletLifecycle();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [kind, setKind] = useState<BackendKind>('onchain');
|
||||
const [words, setWords] = useState<12 | 24>(24);
|
||||
const [importing, setImporting] = useState(false);
|
||||
// One entry per word rather than one string: the boxes ARE the state, so a wrong word is visible where
|
||||
// it is rather than somewhere in a wall of text. Joined back into a phrase only at submit.
|
||||
const [importLength, setImportLength] = useState<SeedLength>(24);
|
||||
const [importWords, setImportWords] = useState<string[]>(() => Array(24).fill(''));
|
||||
// A plain-text escape hatch alongside the boxes. Both write the same `importWords`, so validation and
|
||||
// submit never care which was used — but when a phrase the owner knows is right will not validate, this
|
||||
// is what separates "my per-box entry mangled it" from "the phrase really is different".
|
||||
const [asText, setAsText] = useState(false);
|
||||
const [importText, setImportText] = useState('');
|
||||
// Both of these are secrets in flight. They exist in this component's state, go into the request body,
|
||||
// and are cleared the moment the request settles — never a store, never a query key, never the URL.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [confirmPassphrase, setConfirmPassphrase] = useState('');
|
||||
const [bip39Passphrase, setBip39Passphrase] = useState('');
|
||||
const [makeActive, setMakeActive] = useState(true);
|
||||
const [remoteConfig, setRemoteConfig] = useState<Record<string, string>>({});
|
||||
|
||||
// The one mnemonic the sidecar will ever hand back. Held here only until the backup modal is confirmed.
|
||||
const [pendingSeed, setPendingSeed] = useState<{ mnemonic: string; walletName: string; walletId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const storeKeyMissing = config != null && !config.storeKeyConfigured;
|
||||
const isOnchain = kind === 'onchain';
|
||||
|
||||
/** Anything the owner would be upset to retype — guards the incidental-dismiss paths below. */
|
||||
const hasTypedSecret =
|
||||
importWords.some((w) => w.trim()) || passphrase.length > 0 || Object.values(remoteConfig).some((v) => v?.trim());
|
||||
|
||||
/** Grow or shrink the grid, keeping whatever has already been typed. */
|
||||
const resizeImport = (n: SeedLength) => {
|
||||
setImportLength(n);
|
||||
setImportWords((prev) => Array.from({ length: n }, (_, i) => prev[i] ?? ''));
|
||||
};
|
||||
|
||||
/** Text mode is the source of truth while it is open; the boxes mirror it word for word. */
|
||||
const applyText = (text: string) => {
|
||||
setImportText(text);
|
||||
const parts = normalizePhrase(text);
|
||||
if ((SEED_LENGTHS as readonly number[]).includes(parts.length)) setImportLength(parts.length as SeedLength);
|
||||
setImportWords(parts);
|
||||
};
|
||||
|
||||
/** Switching modes must not silently drop or reorder anything the owner already entered. */
|
||||
const toggleTextMode = (next: boolean) => {
|
||||
if (next) setImportText(importWords.filter(Boolean).join(' '));
|
||||
else setImportWords((prev) => Array.from({ length: importLength }, (_, i) => prev[i] ?? ''));
|
||||
setAsText(next);
|
||||
};
|
||||
|
||||
const clearSecrets = () => {
|
||||
setPassphrase('');
|
||||
setConfirmPassphrase('');
|
||||
setBip39Passphrase('');
|
||||
// Functional, so it blanks whatever length the grid currently is without reading stale state.
|
||||
setImportWords((prev) => prev.map(() => ''));
|
||||
setImportText('');
|
||||
setRemoteConfig({});
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setName('');
|
||||
setKind('onchain');
|
||||
setWords(24);
|
||||
setImporting(false);
|
||||
setMakeActive(true);
|
||||
clearSecrets();
|
||||
setAsText(false);
|
||||
setImportLength(24);
|
||||
setImportWords(Array(24).fill(''));
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const passphraseValid = !isOnchain || (passphrase.length >= 8 && passphrase === confirmPassphrase);
|
||||
const configValid =
|
||||
isOnchain || CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].every((f) => remoteConfig[f.key]?.trim());
|
||||
// Checked locally so a bad phrase never costs a round trip. The sidecar still validates independently;
|
||||
// this only means the owner finds out instantly, and precisely, instead of via a generic 400.
|
||||
const seedProblem = isOnchain && importing ? phraseProblem(importWords) : null;
|
||||
// Only worth computing for the one failure a human review cannot catch: right words, wrong order.
|
||||
const orderFix = seedProblem?.kind === 'checksum' ? findOrderFix(importWords) : null;
|
||||
// A valid phrase can be either format, and the two are visually identical, so say which one this is.
|
||||
// It also changes what the passphrase field below means, which the owner has no way to guess.
|
||||
const aezeed = isOnchain && importing && !seedProblem && isAezeed(importWords.map((w) => w.trim().toLowerCase()));
|
||||
const canSubmit =
|
||||
!!name.trim() && passphraseValid && configValid && !seedProblem && !storeKeyMissing && !create.isPending;
|
||||
|
||||
// A disabled button with no stated reason is a dead end — especially here, where the blocker is often
|
||||
// the passphrase fields further down while the owner is staring at the phrase.
|
||||
const blockReason = !name.trim()
|
||||
? 'Give the wallet a name.'
|
||||
: seedProblem
|
||||
? 'The recovery phrase above is not complete or not valid.'
|
||||
: !passphraseValid
|
||||
? 'Set a passphrase of at least 8 characters, twice.'
|
||||
: !configValid
|
||||
? 'Fill in the connection details.'
|
||||
: null;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await create.mutateAsync(
|
||||
isOnchain
|
||||
? {
|
||||
name: name.trim(),
|
||||
kind,
|
||||
passphrase,
|
||||
words,
|
||||
mnemonic: importing ? importWords.map((w) => w.trim()).join(' ') : undefined,
|
||||
bip39Passphrase: bip39Passphrase || undefined,
|
||||
makeActive,
|
||||
}
|
||||
: { name: name.trim(), kind, config: remoteConfig, makeActive },
|
||||
);
|
||||
} catch {
|
||||
// KEEP EVERYTHING TYPED. A rejected phrase is precisely the moment the owner needs their other 23
|
||||
// words back — clearing here means retyping the whole thing to fix one box, which is both miserable
|
||||
// and a good way to introduce a second mistake. `create.onError` already toasts, and the error is
|
||||
// rendered inline below. Nothing lingers regardless: cancel or close still runs clearSecrets().
|
||||
return;
|
||||
}
|
||||
|
||||
const created = result.wallet;
|
||||
if (result.mnemonic) {
|
||||
// Generated seed: hold the words for the backup modal and keep this dialog mounted underneath.
|
||||
setPendingSeed({ mnemonic: result.mnemonic, walletName: created.name, walletId: created.id });
|
||||
clearSecrets();
|
||||
} else {
|
||||
navigate(walletSectionPath(section, created.id));
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const seedConfirmed = () => {
|
||||
const created = pendingSeed;
|
||||
setPendingSeed(null);
|
||||
close();
|
||||
if (created) navigate(walletSectionPath(section, created.walletId));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open && pendingSeed == null} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
{/* Wider only while importing — 24 word boxes at max-w-lg are two cramped columns. */}
|
||||
<DialogContent
|
||||
className={`max-h-[85vh] overflow-y-auto ${isOnchain && importing ? 'max-w-2xl' : 'max-w-lg'}`}
|
||||
// A half-typed recovery phrase must not be destroyed by a misplaced click on the overlay or a
|
||||
// stray Escape (dismissing an error toast, say). Those gestures are cheap and reversible for an
|
||||
// ordinary form; here they cost the owner all 24 words. Cancel and the X still close normally —
|
||||
// the point is that discarding becomes deliberate, not incidental.
|
||||
onInteractOutside={(ev) => {
|
||||
if (hasTypedSecret) ev.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(ev) => {
|
||||
if (hasTypedSecret) ev.preventDefault();
|
||||
}}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Self-custodial keys stay in the wallet sidecar; a remote node is only a stored connection.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{storeKeyMissing && (
|
||||
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<span>
|
||||
<span className="font-medium">The secret store is unreachable.</span> The sidecar refuses to store
|
||||
wallet secrets without it, so creation is disabled until it is set.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-name">Name</Label>
|
||||
<Input
|
||||
id="wallet-new-name"
|
||||
value={name}
|
||||
autoFocus
|
||||
placeholder="Savings"
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-kind">Type</Label>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as BackendKind)}>
|
||||
<SelectTrigger id="wallet-new-kind">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{KIND_LABELS[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Network: <span className="font-medium">{config.network}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOnchain ? (
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<SegButton active={!importing} onClick={() => setImporting(false)} label="Generate a new seed" />
|
||||
<SegButton active={importing} onClick={() => setImporting(true)} label="Import a phrase" />
|
||||
</div>
|
||||
|
||||
{importing ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label>Recovery phrase</Label>
|
||||
<SegButton
|
||||
active={asText}
|
||||
onClick={() => toggleTextMode(!asText)}
|
||||
label={asText ? 'Use word boxes' : 'Paste as text'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!asText && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SEED_LENGTHS.map((n) => (
|
||||
<SegButton
|
||||
key={n}
|
||||
active={importLength === n}
|
||||
onClick={() => resizeImport(n)}
|
||||
label={`${n}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secret in flight: state → request body → cleared. Never stored on this side, and
|
||||
never echoed back by the sidecar either. */}
|
||||
{asText ? (
|
||||
<Textarea
|
||||
rows={4}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
value={importText}
|
||||
placeholder="Paste or type the whole phrase. Numbering, commas and line breaks are ignored."
|
||||
onChange={(ev) => applyText(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<SeedPhraseInput
|
||||
words={importWords}
|
||||
onChange={setImportWords}
|
||||
onLengthDetected={(n) => setImportLength(n as SeedLength)}
|
||||
/>
|
||||
)}
|
||||
{aezeed ? (
|
||||
<div className="space-y-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3">
|
||||
<p className="text-xs">
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-500">
|
||||
Valid aezeed seed — checksum verified in this browser.
|
||||
</span>{' '}
|
||||
This is not a BIP39 phrase. It is an <em>aezeed</em> cipher seed, the format Zeus's embedded
|
||||
LND node produces: the same 2048-word list, so it looks identical, but encrypted and
|
||||
checksummed differently. Officer imports it.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This recovers the <span className="font-medium">on-chain</span> wallet. Lightning channels
|
||||
are not in the seed — those need a channel backup file and a running LND.
|
||||
</p>
|
||||
</div>
|
||||
) : orderFix ? (
|
||||
<div className="space-y-1.5 rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
|
||||
<p className="text-xs">
|
||||
<span className="font-medium">These are your words — the order is wrong.</span> Read down
|
||||
the columns, they check out. Your backup is almost certainly printed as {orderFix.rows} rows
|
||||
of {orderFix.cols}, running top-to-bottom, and was read left-to-right.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setImportWords(orderFix.words);
|
||||
setImportText(orderFix.words.join(' '));
|
||||
}}
|
||||
>
|
||||
Reorder to the valid phrase
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Check the result against your backup before continuing.
|
||||
</p>
|
||||
</div>
|
||||
) : seedProblem?.kind === 'checksum' ? (
|
||||
// The hardest case to act on, so it gets the longest explanation: every word is
|
||||
// real, so nothing is marked red, and the only remaining faults are order or a
|
||||
// word that is a near-miss for the right one.
|
||||
<p className="text-xs text-destructive">
|
||||
All {importWords.length} words are valid BIP39 words, but the phrase does not check out. A
|
||||
word is in the wrong position, or one is a near-miss for the right one (<em>fan</em> where the
|
||||
backup says <em>fancy</em>). Re-read it against your backup in order.
|
||||
</p>
|
||||
) : seedProblem?.kind === 'unknown' ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Not in the BIP39 word list: {seedProblem.boxes.length === 1 ? 'box' : 'boxes'}{' '}
|
||||
{seedProblem.boxes.join(', ')}.
|
||||
</p>
|
||||
) : seedProblem?.kind === 'incomplete' ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{seedProblem.boxes.length} {seedProblem.boxes.length === 1 ? 'word' : 'words'} still empty.
|
||||
</p>
|
||||
) : seedProblem?.kind === 'length' ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{seedProblem.count} words. A BIP39 phrase must be 12, 15, 18, 21 or 24.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-500">
|
||||
Valid phrase — checksum verified in this browser.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type a few letters and pick from the list, or paste the whole phrase into the first box.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Phrase length</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<SegButton active={words === 12} onClick={() => setWords(12)} label="12 words" />
|
||||
<SegButton active={words === 24} onClick={() => setWords(24)} label="24 words" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The phrase is shown once, immediately after creation, and never again without this passphrase.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-pass">Passphrase</Label>
|
||||
{/* Seals the seed. Straight into the request body, cleared on settle — no store. */}
|
||||
<Input
|
||||
id="wallet-new-pass"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-pass2">Confirm</Label>
|
||||
<Input
|
||||
id="wallet-new-pass2"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassphrase}
|
||||
onChange={(ev) => setConfirmPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{passphrase.length > 0 && passphrase.length < 8 && (
|
||||
<p className="text-xs text-destructive">Use at least 8 characters.</p>
|
||||
)}
|
||||
{confirmPassphrase.length > 0 && passphrase !== confirmPassphrase && (
|
||||
<p className="text-xs text-destructive">The two passphrases do not match.</p>
|
||||
)}
|
||||
|
||||
{/* One field, two meanings, because the two seed formats each have exactly one such
|
||||
secret and which one applies is decided by the phrase itself. Mislabelling it for an
|
||||
aezeed would be actively harmful: leaving it blank there is correct and normal, while
|
||||
typing the wallet passphrase into it fails the AEZ tag and looks like a bad seed. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-bip39">
|
||||
{aezeed ? 'Seed passphrase (optional)' : 'BIP39 passphrase (optional)'}
|
||||
</Label>
|
||||
{/* Part of the seed itself — lose it and the coins are gone. Same handling: no store. */}
|
||||
<Input
|
||||
id="wallet-new-bip39"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={bip39Passphrase}
|
||||
onChange={(ev) => setBip39Passphrase(ev.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{aezeed
|
||||
? 'Only if you set one when the aezeed seed was created. Almost nobody does — leave it blank.'
|
||||
: 'A 25th word. It is part of the key, not a lock on it — without it the phrase alone recovers a different, empty wallet.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label htmlFor={`wallet-new-${field.key}`}>{field.label}</Label>
|
||||
{/* Node credentials are stored server-side, encrypted, and never returned to the
|
||||
browser — so this input is write-only and holds nothing after submit. */}
|
||||
<Input
|
||||
id={`wallet-new-${field.key}`}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={remoteConfig[field.key] ?? ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(ev) => setRemoteConfig((prev) => ({ ...prev, [field.key]: ev.target.value }))}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2.5 text-sm">
|
||||
<Checkbox checked={makeActive} onCheckedChange={(v) => setMakeActive(v === true)} />
|
||||
Make this the active wallet
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* The toast disappears; this does not. With the form no longer clearing on failure, the
|
||||
owner needs the reason to stay put next to the boxes they are about to correct. */}
|
||||
{/* Only while the current input still matches what was rejected. Without this the box from a
|
||||
previous attempt hangs around after the phrase is corrected, which reads as "still broken"
|
||||
when nothing is broken — and the mutation's error state does not clear itself. */}
|
||||
{create.isError && !seedProblem && (
|
||||
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<span>{errorMessage(create.error, 'Could not create the wallet')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blockReason && !storeKeyMissing && (
|
||||
<p className="mt-4 text-right text-xs text-muted-foreground">{blockReason}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{create.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create wallet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{pendingSeed && (
|
||||
<SeedBackupDialog
|
||||
open
|
||||
mnemonic={pendingSeed.mnemonic}
|
||||
walletName={pendingSeed.walletName}
|
||||
onConfirmed={seedConfirmed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SegButton = ({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
active ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { WalletSummary } from '../shared';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useWalletLifecycle } from '../useWalletData';
|
||||
|
||||
// Delete a wallet. For a seeded wallet this destroys the only copy of the key material Officer holds, so
|
||||
// the sidecar demands the passphrase even when the wallet is currently unlocked — an open session must not
|
||||
// be enough to erase a seed. Typing the wallet's name is the second check against the wrong row, and it is
|
||||
// sent rather than merely enforced here: a wallet with no seed has no passphrase gate, so without it the
|
||||
// only thing standing between a node wallet and deletion was a dialog anything could route around.
|
||||
|
||||
type DeleteWalletDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
wallet: WalletSummary;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export const DeleteWalletDialog = ({ open, onOpenChange, wallet, onDeleted }: DeleteWalletDialogProps) => {
|
||||
const { remove } = useWalletLifecycle();
|
||||
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [confirmName, setConfirmName] = useState('');
|
||||
|
||||
const close = () => {
|
||||
setPassphrase('');
|
||||
setConfirmName('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const nameMatches = confirmName.trim() === wallet.name;
|
||||
const canSubmit = nameMatches && (!wallet.hasSeed || !!passphrase) && !remove.isPending;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
await remove.mutateAsync({
|
||||
walletId: wallet.id,
|
||||
confirmName: confirmName.trim(),
|
||||
passphrase: wallet.hasSeed ? passphrase : undefined,
|
||||
});
|
||||
close();
|
||||
onDeleted?.();
|
||||
} finally {
|
||||
setPassphrase('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-destructive" />
|
||||
Delete {wallet.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{wallet.hasSeed
|
||||
? 'This erases the encrypted seed. Without your written-down recovery phrase the coins are gone for good.'
|
||||
: 'This removes the connection to that node. Nothing on the node itself is touched.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-delete-name">
|
||||
Type <span className="font-mono">{wallet.name}</span> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="wallet-delete-name"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={confirmName}
|
||||
onChange={(ev) => setConfirmName(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{wallet.hasSeed && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-delete-pass">Passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-delete-pass"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={!canSubmit}>
|
||||
{remove.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete wallet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Eye, Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLockActions } from '../useWalletData';
|
||||
|
||||
// Show the recovery phrase again — the one path back to it after creation, and the reason the create-time
|
||||
// modal can say "not without your passphrase" rather than "never".
|
||||
//
|
||||
// Two things live only in this component and nowhere else: the passphrase (cleared the moment the request
|
||||
// settles) and the returned mnemonic (held in local state, dropped when the dialog closes). The mutation
|
||||
// deliberately has no onSuccess cache write, so the phrase never enters React Query. No copy button, for
|
||||
// the same reason as SeedBackupDialog.
|
||||
|
||||
type ExportSeedDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const ExportSeedDialog = ({ open, onOpenChange, walletId }: ExportSeedDialogProps) => {
|
||||
const { exportSeed } = useLockActions(walletId);
|
||||
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [revealed, setRevealed] = useState<{ mnemonic: string; hasBip39Passphrase: boolean } | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setPassphrase('');
|
||||
setRevealed(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!passphrase) return;
|
||||
try {
|
||||
setRevealed(await exportSeed.mutateAsync({ passphrase }));
|
||||
} finally {
|
||||
setPassphrase('');
|
||||
}
|
||||
};
|
||||
|
||||
const words = revealed ? revealed.mnemonic.trim().split(/\s+/) : [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-lg">
|
||||
{revealed ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-amber-500" />
|
||||
Recovery phrase
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Anyone who reads these {words.length} words can spend this wallet. Close this as soon as you are done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ol className="mt-2 grid grid-cols-3 gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 sm:grid-cols-4">
|
||||
{words.map((word, i) => (
|
||||
<li key={`${i}-${word}`} className="flex items-baseline gap-1.5 font-mono text-xs">
|
||||
<span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{word}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{revealed.hasBip39Passphrase && (
|
||||
<p className="text-xs text-amber-600">
|
||||
This seed also has a BIP39 passphrase. The words alone will not restore the wallet — you need both.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={close}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Show recovery phrase</DialogTitle>
|
||||
<DialogDescription>
|
||||
The phrase is decrypted for display only. It is not stored by the browser and nothing is written
|
||||
anywhere on this side.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<Label htmlFor="wallet-export-pass">Passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-export-pass"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!passphrase || exportSeed.isPending}>
|
||||
{exportSeed.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Show
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { DecodedInvoice } from '../shared';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Zap } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { formatTimestamp, satsToMsat, truncateMiddle } from '../format';
|
||||
import { Amount } from '../Amount';
|
||||
import { useWalletOperations } from '../useWalletData';
|
||||
|
||||
// Pay a BOLT11 invoice: decode first, confirm what you are about to pay, then pay.
|
||||
//
|
||||
// The two steps are deliberate. A BOLT11 string is opaque, so paying one straight from the paste box is
|
||||
// signing something you cannot read. Decoding is a free read — it does not need the wallet unlocked — and
|
||||
// it is the only chance to see the amount and the destination before the money moves.
|
||||
//
|
||||
// Amounts stay msat STRINGS end to end (see format.ts): the decoded amount is rendered as a string and a
|
||||
// zero-amount invoice's manual amount is converted with satsToMsat rather than a multiplication.
|
||||
|
||||
type PayInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const PayInvoiceDialog = ({ open, onOpenChange, walletId }: PayInvoiceDialogProps) => {
|
||||
const { decode, pay } = useWalletOperations(walletId);
|
||||
const [bolt11, setBolt11] = useState('');
|
||||
const [decoded, setDecoded] = useState<DecodedInvoice | null>(null);
|
||||
const [amountSats, setAmountSats] = useState('');
|
||||
const [feeLimitSats, setFeeLimitSats] = useState('');
|
||||
|
||||
const close = () => {
|
||||
setBolt11('');
|
||||
setDecoded(null);
|
||||
setAmountSats('');
|
||||
setFeeLimitSats('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const runDecode = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const trimmed = bolt11.trim();
|
||||
if (!trimmed) return;
|
||||
const result = await decode.mutateAsync(trimmed);
|
||||
setDecoded(result.decoded);
|
||||
};
|
||||
|
||||
// A zero-amount invoice carries no amountMsat, so the payer names the figure.
|
||||
const needsAmount = decoded != null && decoded.amountMsat == null;
|
||||
const manualSats = Number(amountSats);
|
||||
const feeLimit = Number(feeLimitSats);
|
||||
const canPay = decoded != null && (!needsAmount || (Number.isFinite(manualSats) && manualSats > 0)) && !pay.isPending;
|
||||
|
||||
const runPay = async () => {
|
||||
if (!decoded || !canPay) return;
|
||||
await pay.mutateAsync({
|
||||
bolt11: decoded.bolt11,
|
||||
amountMsat: needsAmount ? satsToMsat(manualSats) : undefined,
|
||||
feeLimitMsat: Number.isFinite(feeLimit) && feeLimit > 0 ? satsToMsat(feeLimit) : undefined,
|
||||
});
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay an invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
{decoded
|
||||
? 'Check this before you pay — a lightning payment cannot be reversed.'
|
||||
: 'Paste a BOLT11 invoice.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{decoded ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<dl className="space-y-2 rounded-lg border border-border bg-muted/40 p-3 text-xs">
|
||||
<Row label="Amount">
|
||||
{decoded.amountMsat == null ? (
|
||||
<span className="text-muted-foreground">payer chooses</span>
|
||||
) : (
|
||||
<Amount msat={decoded.amountMsat} className="font-semibold" />
|
||||
)}
|
||||
</Row>
|
||||
<Row label="Description">{decoded.description || <span className="text-muted-foreground">—</span>}</Row>
|
||||
<Row label="Destination">
|
||||
<code className="font-mono text-[11px]">{truncateMiddle(decoded.destination, 12, 8)}</code>
|
||||
</Row>
|
||||
<Row label="Expires">{formatTimestamp(decoded.timestamp + decoded.expiry)}</Row>
|
||||
</dl>
|
||||
|
||||
{needsAmount && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pay-amount">Amount to pay (sats)</Label>
|
||||
<Input
|
||||
id="wallet-pay-amount"
|
||||
type="number"
|
||||
min={1}
|
||||
value={amountSats}
|
||||
onChange={(ev) => setAmountSats(ev.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pay-fee">Max routing fee (sats, optional)</Label>
|
||||
<Input
|
||||
id="wallet-pay-fee"
|
||||
type="number"
|
||||
min={0}
|
||||
value={feeLimitSats}
|
||||
placeholder="node default"
|
||||
onChange={(ev) => setFeeLimitSats(ev.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setDecoded(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={runPay} disabled={!canPay}>
|
||||
{pay.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Zap className="mr-2 h-4 w-4" />}
|
||||
Pay
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={runDecode} className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pay-bolt11">Invoice</Label>
|
||||
<Input
|
||||
id="wallet-pay-bolt11"
|
||||
value={bolt11}
|
||||
spellCheck={false}
|
||||
placeholder="lnbc…"
|
||||
onChange={(ev) => setBolt11(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!bolt11.trim() || decode.isPending}>
|
||||
{decode.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Decode
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 truncate text-right">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
// The recovery phrase, shown exactly once.
|
||||
//
|
||||
// The sidecar returns `mnemonic` on the create response and only when IT generated the seed. There is no
|
||||
// second chance without the passphrase (settings → export seed), so this modal cannot be dismissed by
|
||||
// clicking away, pressing Escape, or anything short of ticking the box and pressing the button.
|
||||
//
|
||||
// The words arrive as a prop, live in this component's render only, and are never written to React Query,
|
||||
// localStorage, sessionStorage, the URL, or a clipboard helper. Closing the modal is the only exit and it
|
||||
// drops the string on the floor. There is deliberately no "copy" button: a seed on the clipboard is a seed
|
||||
// in every clipboard manager on the machine.
|
||||
|
||||
type SeedBackupDialogProps = {
|
||||
open: boolean;
|
||||
mnemonic: string;
|
||||
walletName: string;
|
||||
onConfirmed: () => void;
|
||||
};
|
||||
|
||||
export const SeedBackupDialog = ({ open, mnemonic, walletName, onConfirmed }: SeedBackupDialogProps) => {
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const words = mnemonic.trim().split(/\s+/);
|
||||
|
||||
const confirm = () => {
|
||||
setAcknowledged(false);
|
||||
onConfirmed();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
{/* `[&>button]:hidden` drops the shared X — with no onOpenChange it would be inert anyway, and an
|
||||
inert close button on the one dialog you must not dismiss is worse than no button. */}
|
||||
<DialogContent
|
||||
className="max-w-lg [&>button]:hidden"
|
||||
onEscapeKeyDown={(ev) => ev.preventDefault()}
|
||||
onPointerDownOutside={(ev) => ev.preventDefault()}
|
||||
onInteractOutside={(ev) => ev.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-amber-500" />
|
||||
Write down your recovery phrase
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
These {words.length} words are the only way to recover <span className="font-medium">{walletName}</span> if
|
||||
this server is lost. Officer cannot show them again without your passphrase.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ol className="mt-2 grid grid-cols-3 gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 sm:grid-cols-4">
|
||||
{words.map((word, i) => (
|
||||
<li key={`${i}-${word}`} className="flex items-baseline gap-1.5 font-mono text-xs">
|
||||
<span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{word}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Write them on paper, in order. Anyone who reads them can spend these coins, so do not photograph them, type
|
||||
them into another device, or paste them anywhere.
|
||||
</p>
|
||||
|
||||
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||
<Checkbox className="mt-0.5" checked={acknowledged} onCheckedChange={(v) => setAcknowledged(v === true)} />
|
||||
<span>
|
||||
I have written this phrase down and stored it somewhere safe.
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
It will not be shown again after this dialog closes.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={confirm} disabled={!acknowledged}>
|
||||
I have written it down
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,387 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// One input per BIP39 word, with dictionary autocomplete — the same shape Zeus and every other serious
|
||||
// wallet uses for recovery, and for the same reason: a 24-word phrase typed into one freeform box is
|
||||
// impossible to proofread, and a single mistyped word fails the checksum with no clue which one is wrong.
|
||||
// Per-word boxes turn that into an immediately visible red border on word 17.
|
||||
//
|
||||
// SECRET IN FLIGHT. The words live in the parent's state and go straight into the request body. Nothing
|
||||
// here persists, and every field carries the full set of autofill opt-outs (autoComplete="off" plus the
|
||||
// 1Password/LastPass/Bitwarden ignore attributes) — a password manager silently capturing a seed phrase
|
||||
// would be the single worst thing this component could do.
|
||||
|
||||
const WORDS_PER_ROW_HINT = 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4';
|
||||
|
||||
// Generous, and the list scrolls. A small cap silently hides real words — "fa" alone has 22 matches and
|
||||
// "fan" is the twelfth, so a cap of 6 makes a perfectly valid word look like it is not in the dictionary.
|
||||
// Truncation here must never be how the owner learns whether their seed word exists.
|
||||
const MAX_SUGGESTIONS = 64;
|
||||
|
||||
/** BIP39 permits these lengths. 12 and 24 dominate, but a restore must not be blocked by the others. */
|
||||
export const SEED_LENGTHS = [12, 15, 18, 21, 24] as const;
|
||||
export type SeedLength = (typeof SEED_LENGTHS)[number];
|
||||
|
||||
const VALID = new Set<string>(wordlist);
|
||||
|
||||
/**
|
||||
* Wordlist entries starting with `prefix`, shortest first (alphabetical within a length). Empty prefix
|
||||
* yields nothing — suggestions need intent.
|
||||
*
|
||||
* Shortest-first matters more than it looks. A fully typed word is the shortest thing that can match
|
||||
* itself, so it always lands at position 1 — you never hunt for confirmation that your word exists, and
|
||||
* the default highlight is always the exact match rather than some longer word that merely shares a
|
||||
* prefix. It also front-loads the short words that alphabetical order buries: "fan" is 12th of 22 under
|
||||
* "fa" alphabetically, but 2nd by length.
|
||||
*
|
||||
* Collect-then-sort, never truncate-then-sort: capping during the scan would drop short matches that
|
||||
* happen to sort late. Scanning all 2048 entries per keystroke is free.
|
||||
*/
|
||||
function suggest(prefix: string): string[] {
|
||||
if (!prefix) return [];
|
||||
const matches = wordlist.filter((w) => w.startsWith(prefix));
|
||||
matches.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
||||
return matches.slice(0, MAX_SUGGESTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split arbitrary pasted text into candidate words.
|
||||
*
|
||||
* Deliberately brutal: BIP39 English words are pure a-z, so everything else is separator. That makes the
|
||||
* paste survive the shapes real backups actually arrive in — numbered lists ("1. fan 2. zoo"), commas,
|
||||
* hard line breaks, non-breaking spaces, smart quotes from a notes app. Without this, a numbered paste
|
||||
* silently becomes twenty-four "words" of digits and the owner is left staring at a wall of red.
|
||||
*/
|
||||
export function normalizePhrase(text: string): string[] {
|
||||
return text
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export type PhraseProblem =
|
||||
/** Boxes still blank (1-based, for display). */
|
||||
| { kind: 'incomplete'; boxes: number[] }
|
||||
/** Boxes holding something that is not a BIP39 word at all (1-based). */
|
||||
| { kind: 'unknown'; boxes: number[] }
|
||||
/** Not a permitted BIP39 length. Only reachable by pasting an odd count. */
|
||||
| { kind: 'length'; count: number }
|
||||
/** Every word is real and the count is legal, but the checksum says the phrase is not this phrase. */
|
||||
| { kind: 'checksum' };
|
||||
|
||||
/** CRC32-Castagnoli (poly 0x82F63B78) — the polynomial LND's aezeed uses. Not the IEEE one. */
|
||||
const CRC32C_TABLE = Uint32Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
|
||||
function crc32c(bytes: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (const b of bytes) c = CRC32C_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeus (and anything else running an embedded LND) does NOT hand out BIP39 mnemonics. It hands out
|
||||
* *aezeed* cipher seeds: 24 words drawn from the very same BIP39 English wordlist, so they are visually
|
||||
* indistinguishable, but the trailing bits are a CRC32-Castagnoli checksum instead of a SHA-256 one.
|
||||
* They can never validate as BIP39, no matter how carefully the owner proofreads.
|
||||
*
|
||||
* Officer imports these — the sidecar deciphers the AEZ ciphertext and uses the 16 bytes inside as the
|
||||
* BIP32 master seed directly. But BIP39 validation still fails on them, so without this check the owner
|
||||
* would be told "invalid checksum" for a phrase that is perfectly correct.
|
||||
*
|
||||
* This mirrors `isAezeedPhrase` in the sidecar (`wallet/aezeed/cipher-seed.ts`). It is duplicated rather
|
||||
* than shared because it must run in the browser, where nothing under `src/servers` is reachable — and
|
||||
* it is pure structure, no secret and no key material, so it is safe to ship client-side. The sidecar
|
||||
* still decides independently; this only makes the form honest before the round trip.
|
||||
*
|
||||
* Layout: version(1) + ciphertext(23) + salt(5) + crc32c(4) = 33 bytes = 24 x 11 bits.
|
||||
* Verified against LND's own published test vector.
|
||||
*/
|
||||
export function isAezeed(words: string[]): boolean {
|
||||
if (words.length !== 24) return false;
|
||||
|
||||
let bits = '';
|
||||
for (const word of words) {
|
||||
const index = wordlist.indexOf(word);
|
||||
if (index < 0) return false;
|
||||
bits += index.toString(2).padStart(11, '0');
|
||||
}
|
||||
|
||||
const bytes = Uint8Array.from(bits.match(/.{8}/g)!.map((b) => parseInt(b, 2)));
|
||||
if (bytes[0] !== 0) return false; // aezeed CipherSeedVersion
|
||||
|
||||
const view = new DataView(bytes.buffer);
|
||||
return crc32c(bytes.subarray(0, 29)) === view.getUint32(29, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is wrong with the phrase, checked locally so the owner never spends a network round trip — or
|
||||
* risks the form state — to learn they mistyped word 17. This mirrors what the sidecar does (BIP39
|
||||
* checksum, else aezeed structure), just run earlier; the sidecar remains the authority and still
|
||||
* rejects independently. Purely local: nothing is transmitted to validate.
|
||||
*
|
||||
* An aezeed phrase is NOT a problem — it is a supported second format, so it returns null here and the
|
||||
* caller uses `isAezeed` separately to explain what it is looking at.
|
||||
*/
|
||||
export function phraseProblem(words: string[]): PhraseProblem | null {
|
||||
const trimmed = words.map((w) => w.trim().toLowerCase());
|
||||
|
||||
const blank = trimmed.map((w, i) => (w ? -1 : i + 1)).filter((i) => i > 0);
|
||||
if (blank.length) return { kind: 'incomplete', boxes: blank };
|
||||
|
||||
const unknown = trimmed.map((w, i) => (VALID.has(w) ? -1 : i + 1)).filter((i) => i > 0);
|
||||
if (unknown.length) return { kind: 'unknown', boxes: unknown };
|
||||
|
||||
if (!(SEED_LENGTHS as readonly number[]).includes(trimmed.length)) {
|
||||
return { kind: 'length', count: trimmed.length };
|
||||
}
|
||||
|
||||
if (validateMnemonic(trimmed.join(' '), wordlist)) return null;
|
||||
|
||||
// Checked before the generic failure: an aezeed seed is a different supported format, not a typo.
|
||||
if (isAezeed(trimmed)) return null;
|
||||
return { kind: 'checksum' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The words are all real and the checksum still fails — so look for the one mistake that is invisible
|
||||
* when you proofread: right words, wrong order.
|
||||
*
|
||||
* Wallets print backups in a grid, and plenty of them run the phrase DOWN the columns (1-12 left, 13-24
|
||||
* right). Read ACROSS instead and you get every word present, every word spelled correctly, every box
|
||||
* green, and a phrase that is simply not yours. Nothing in a word-by-word review can catch it, because
|
||||
* every word individually is fine.
|
||||
*
|
||||
* That misreading is exactly a matrix transpose, so it is cheap to test: for each rows x cols factoring
|
||||
* of the length, un-transpose and re-check. A handful of validations, and it either finds the owner's
|
||||
* real phrase or rules the theory out. Returns null when the order is not the problem.
|
||||
*
|
||||
* NOT a brute-force search. Trying every single-word substitution is useless here — the checksum is only
|
||||
* 8 bits, so ~8 different words validate at EVERY position and it can localise nothing.
|
||||
*/
|
||||
export function findOrderFix(words: string[]): { rows: number; cols: number; words: string[] } | null {
|
||||
const n = words.length;
|
||||
for (let rows = 2; rows < n; rows++) {
|
||||
if (n % rows) continue;
|
||||
const cols = n / rows;
|
||||
const fixed = new Array<string>(n);
|
||||
for (let i = 0; i < rows; i++) for (let j = 0; j < cols; j++) fixed[j * rows + i] = words[i * cols + j]!;
|
||||
if (validateMnemonic(fixed.join(' '), wordlist)) return { rows, cols, words: fixed };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type SeedPhraseInputProps = {
|
||||
words: string[];
|
||||
onChange: (words: string[]) => void;
|
||||
/**
|
||||
* Fired when a pasted phrase is itself a valid BIP39 length that differs from the current box count,
|
||||
* so the parent can resize instead of making the owner notice the mismatch and fix the toggle by hand.
|
||||
*/
|
||||
onLengthDetected?: (count: number) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SeedPhraseInput = ({ words, onChange, onLengthDetected, disabled }: SeedPhraseInputProps) => {
|
||||
const refs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const [focused, setFocused] = useState<number | null>(null);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
/** Whether the owner moved the highlight by hand — the difference between "chose that word" and
|
||||
* "happened to have it highlighted". Reset on every keystroke and on leaving the box. */
|
||||
const [arrowed, setArrowed] = useState(false);
|
||||
|
||||
const suggestions = useMemo(() => (focused == null ? [] : suggest(words[focused] ?? '')), [focused, words]);
|
||||
|
||||
// The list shrinks as you type, so a stale index can point past the end. Clamp at read time rather than
|
||||
// trying to keep `highlight` in sync with every keystroke.
|
||||
const safeHighlight = suggestions.length ? Math.min(highlight, suggestions.length - 1) : 0;
|
||||
|
||||
const highlightRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
highlightRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [safeHighlight, focused]);
|
||||
|
||||
const setWord = (index: number, value: string) => {
|
||||
const next = [...words];
|
||||
next[index] = value;
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const focus = (index: number) => {
|
||||
const el = refs.current[index];
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.select();
|
||||
};
|
||||
|
||||
/** Accept a suggestion (or whatever is typed) and move on. The last box stays put. */
|
||||
const commit = (index: number, value: string) => {
|
||||
setWord(index, value);
|
||||
setHighlight(0);
|
||||
setArrowed(false);
|
||||
if (index < words.length - 1) requestAnimationFrame(() => focus(index + 1));
|
||||
};
|
||||
|
||||
// Pasting the whole phrase into any box is how people actually restore. Spread it across the boxes
|
||||
// from here on rather than dumping 24 words into one field.
|
||||
const handlePaste = (index: number, ev: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const parts = normalizePhrase(ev.clipboardData.getData('text'));
|
||||
if (parts.length < 2) return;
|
||||
ev.preventDefault();
|
||||
|
||||
// A whole phrase pasted into box 1 of the wrong-size grid: resize rather than truncate. Only from the
|
||||
// first box — pasting a fragment mid-phrase should never resize the form.
|
||||
if (index === 0 && parts.length !== words.length && (SEED_LENGTHS as readonly number[]).includes(parts.length)) {
|
||||
onLengthDetected?.(parts.length);
|
||||
onChange(parts);
|
||||
requestAnimationFrame(() => focus(parts.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const next = [...words];
|
||||
for (let i = 0; i < parts.length && index + i < next.length; i++) next[index + i] = parts[i]!;
|
||||
onChange(next);
|
||||
|
||||
const landed = Math.min(index + parts.length, next.length - 1);
|
||||
requestAnimationFrame(() => focus(landed));
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const current = words[index] ?? '';
|
||||
|
||||
if (ev.key === 'ArrowDown' && suggestions.length) {
|
||||
ev.preventDefault();
|
||||
setArrowed(true);
|
||||
setHighlight((safeHighlight + 1) % suggestions.length);
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'ArrowUp' && suggestions.length) {
|
||||
ev.preventDefault();
|
||||
setArrowed(true);
|
||||
setHighlight((safeHighlight - 1 + suggestions.length) % suggestions.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Space and Enter advance. Tab does NOT accept — tab is navigation, and hijacking it to mean
|
||||
// "accept" makes it impossible to skip a box you meant to come back to.
|
||||
//
|
||||
// A suggestion is only substituted for what you typed when the choice is UNAMBIGUOUS: you arrowed to
|
||||
// it deliberately, or it is the single remaining match, or you already typed the whole word. Typing
|
||||
// "fa" and hitting space must NOT silently store "fabric" — for a seed phrase, a wrong-but-valid word
|
||||
// is the worst possible outcome, because it passes every check this component can do and only fails
|
||||
// later as an opaque checksum error with no clue which box is at fault. Ambiguous input is left
|
||||
// exactly as typed, where the missing checkmark makes it obvious.
|
||||
if (ev.key === ' ' || ev.key === 'Enter') {
|
||||
if (!current) return;
|
||||
ev.preventDefault();
|
||||
const unambiguous = arrowed || suggestions.length === 1 || VALID.has(current);
|
||||
commit(index, unambiguous ? (suggestions[safeHighlight] ?? current) : current);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace out of an empty box steps back, so correcting a run of words never needs the mouse.
|
||||
if (ev.key === 'Backspace' && !current && index > 0) {
|
||||
ev.preventDefault();
|
||||
focus(index - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-2', WORDS_PER_ROW_HINT)}>
|
||||
{words.map((word, index) => {
|
||||
const valid = VALID.has(word);
|
||||
const invalid = word.length > 0 && !valid;
|
||||
// Shown whenever the box has focus and anything matches — NOT gated on the word being invalid.
|
||||
// "fan" is both a complete word and a prefix of "fancy"/"fantasy"; hiding the list the moment the
|
||||
// text happens to be valid strands the owner mid-word with no way to see the longer options.
|
||||
const showSuggestions = focused === index && suggestions.length > 0;
|
||||
|
||||
return (
|
||||
<div key={index} className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 items-center rounded-md border bg-background pl-2 focus-within:ring-2 focus-within:ring-ring',
|
||||
invalid ? 'border-destructive' : 'border-input',
|
||||
)}
|
||||
>
|
||||
<span className="w-5 shrink-0 select-none text-right font-mono text-[11px] text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<input
|
||||
ref={(el) => {
|
||||
refs.current[index] = el;
|
||||
}}
|
||||
value={word}
|
||||
disabled={disabled}
|
||||
onChange={(ev) => {
|
||||
setWord(index, ev.target.value.trim().toLowerCase());
|
||||
// Typing invalidates a deliberate pick — the list underneath just changed.
|
||||
setHighlight(0);
|
||||
setArrowed(false);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setFocused(index);
|
||||
setHighlight(0);
|
||||
setArrowed(false);
|
||||
}}
|
||||
onBlur={() => setFocused((f) => (f === index ? null : f))}
|
||||
onPaste={(ev) => handlePaste(index, ev)}
|
||||
onKeyDown={(ev) => handleKeyDown(index, ev)}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
className="h-full w-full bg-transparent px-2 font-mono text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
{/* Positive confirmation, not just the absence of red: a typo that happens to be another
|
||||
real word is caught by reading the phrase back, but a typo that is NOT a word should be
|
||||
obvious without having to compare against the dropdown. */}
|
||||
{valid && <Check className="mr-2 h-3.5 w-3.5 shrink-0 text-emerald-500" />}
|
||||
</div>
|
||||
|
||||
{showSuggestions && (
|
||||
// onMouseDown, not onClick: blur fires first on click and would unmount the list mid-press.
|
||||
<ul className="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-input bg-popover shadow-md">
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
// Keeps the highlighted row visible while arrowing through a long list.
|
||||
ref={i === safeHighlight ? highlightRef : undefined}
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault();
|
||||
commit(index, s);
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left font-mono text-sm',
|
||||
i === safeHighlight ? 'bg-accent text-accent-foreground' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
<span>{s}</span>
|
||||
{/* The exact match, called out — this is the "yes, that word really exists" signal
|
||||
when the typed word is also a prefix of longer ones (fan / fancy / fantasy). */}
|
||||
{s === word && <Check className="h-3.5 w-3.5 shrink-0 text-emerald-500" />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, LockOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLockActions } from '../useWalletData';
|
||||
|
||||
// Unlock, and nothing else. The wallet spends most of its life locked and that is the intended state —
|
||||
// this dialog exists for the moment before a signature, not as a gate on the app.
|
||||
//
|
||||
// The unlock window is capped by the deployment's unlockTtlSec; a caller may ask for less but never more,
|
||||
// so the shorter options here are real and the longest is simply "whatever the sidecar allows".
|
||||
|
||||
type UnlockDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
walletId: number;
|
||||
walletName: string;
|
||||
maxTtlSec: number;
|
||||
};
|
||||
|
||||
/** Offered windows, filtered to those the deployment actually permits. */
|
||||
const TTL_CHOICES = [
|
||||
{ sec: 60, label: '1 minute' },
|
||||
{ sec: 300, label: '5 minutes' },
|
||||
{ sec: 900, label: '15 minutes' },
|
||||
{ sec: 3600, label: '1 hour' },
|
||||
];
|
||||
|
||||
export const UnlockDialog = ({ open, onOpenChange, walletId, walletName, maxTtlSec }: UnlockDialogProps) => {
|
||||
const { unlock } = useLockActions(walletId);
|
||||
// The passphrase lives HERE and nowhere else: no localStorage, no sessionStorage, no React Query
|
||||
// cache, no URL. It goes straight into the POST body and this state is cleared the moment the request
|
||||
// settles, on success and on failure alike.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [ttlSec, setTtlSec] = useState<number>(() => Math.min(300, maxTtlSec));
|
||||
|
||||
const choices = TTL_CHOICES.filter((c) => c.sec <= maxTtlSec);
|
||||
|
||||
const close = () => {
|
||||
setPassphrase('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!passphrase) return;
|
||||
try {
|
||||
await unlock.mutateAsync({ passphrase, ttlSec });
|
||||
close();
|
||||
} finally {
|
||||
// Cleared even when the unlock failed — a wrong passphrase left sitting in an input is still a
|
||||
// passphrase sitting in memory attached to a mounted component.
|
||||
setPassphrase('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unlock {walletName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The seed is decrypted in the wallet sidecar for the window you choose, then wiped. Balances and history do
|
||||
not need this — only signing does.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-unlock-pass">Passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-unlock-pass"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{choices.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Keep unlocked for</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{choices.map(({ sec, label }) => (
|
||||
<button
|
||||
key={sec}
|
||||
type="button"
|
||||
onClick={() => setTtlSec(sec)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
ttlSec === sec
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!passphrase || unlock.isPending}>
|
||||
{unlock.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LockOpen className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Unlock
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { InvoiceState, PaymentStatus } from './shared';
|
||||
|
||||
|
||||
// Display formatting for the Wallet panels.
|
||||
//
|
||||
// THE MILLISATOSHI RULE. A msat value arrives as a decimal string because 2.1e18 does not fit in a
|
||||
// double. Every msat formatter below works on the digit string — no Number(), no parseInt, no
|
||||
// BigInt round-trip through a float. Widening one to a number would silently corrupt large amounts,
|
||||
// and the corruption would only show up on the one payment that mattered.
|
||||
|
||||
export type AmountUnit = 'sats' | 'btc';
|
||||
|
||||
const SATS_PER_BTC = 100_000_000;
|
||||
|
||||
/** Thousands separators on a bare digit string, left to right in groups of three from the end. */
|
||||
function groupDigits(digits: string): string {
|
||||
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a non-negative digit string by 10^places without ever building a number.
|
||||
* `'1234', 3` → `{ whole: '1', frac: '234' }`.
|
||||
*/
|
||||
function splitByPowerOfTen(digits: string, places: number): { whole: string; frac: string } {
|
||||
const clean = digits.replace(/^0+(?=\d)/, '');
|
||||
if (places === 0) return { whole: clean, frac: '' };
|
||||
const padded = clean.padStart(places + 1, '0');
|
||||
return { whole: padded.slice(0, -places), frac: padded.slice(-places) };
|
||||
}
|
||||
|
||||
const trimZeros = (frac: string) => frac.replace(/0+$/, '');
|
||||
|
||||
// ── satoshis (numbers) ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sats with thousands separators. Negative amounts keep their sign — a spend should read as one. */
|
||||
export function formatSats(sats: number | null | undefined): string {
|
||||
if (sats == null || !Number.isFinite(sats)) return '—';
|
||||
const neg = sats < 0;
|
||||
return `${neg ? '-' : ''}${groupDigits(String(Math.abs(Math.trunc(sats))))}`;
|
||||
}
|
||||
|
||||
/** BTC from sats, trailing zeros trimmed but never below two decimals, so amounts stay column-aligned. */
|
||||
export function formatBtc(sats: number | null | undefined): string {
|
||||
if (sats == null || !Number.isFinite(sats)) return '—';
|
||||
const neg = sats < 0;
|
||||
const abs = Math.abs(Math.trunc(sats));
|
||||
const whole = Math.floor(abs / SATS_PER_BTC);
|
||||
const frac = String(abs % SATS_PER_BTC).padStart(8, '0');
|
||||
const shown = trimZeros(frac).padEnd(2, '0');
|
||||
return `${neg ? '-' : ''}${groupDigits(String(whole))}.${shown}`;
|
||||
}
|
||||
|
||||
/** The one entry point the views call, so a unit toggle flips every amount on screen at once. */
|
||||
export function formatAmount(sats: number | null | undefined, unit: AmountUnit): string {
|
||||
if (sats == null || !Number.isFinite(sats)) return '—';
|
||||
return unit === 'btc' ? `${formatBtc(sats)} BTC` : `${formatSats(sats)} sats`;
|
||||
}
|
||||
|
||||
// ── millisatoshis (strings) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** True when the string is a decimal integer we can format; anything else is passed through verbatim. */
|
||||
const isDecimal = (value: string) => /^\d+$/.test(value);
|
||||
|
||||
/**
|
||||
* A msat string as sats, keeping the sub-sat remainder when there is one. String arithmetic throughout —
|
||||
* see the rule at the top of this file.
|
||||
*/
|
||||
export function formatMsatAsSats(msat: string | null | undefined): string {
|
||||
if (msat == null) return '—';
|
||||
const neg = msat.startsWith('-');
|
||||
const digits = neg ? msat.slice(1) : msat;
|
||||
if (!isDecimal(digits)) return msat;
|
||||
const { whole, frac } = splitByPowerOfTen(digits, 3);
|
||||
const remainder = trimZeros(frac);
|
||||
return `${neg ? '-' : ''}${groupDigits(whole)}${remainder ? `.${remainder}` : ''}`;
|
||||
}
|
||||
|
||||
/** A msat string as BTC — 10^11 msat to the bitcoin. */
|
||||
export function formatMsatAsBtc(msat: string | null | undefined): string {
|
||||
if (msat == null) return '—';
|
||||
const neg = msat.startsWith('-');
|
||||
const digits = neg ? msat.slice(1) : msat;
|
||||
if (!isDecimal(digits)) return msat;
|
||||
const { whole, frac } = splitByPowerOfTen(digits, 11);
|
||||
const shown = trimZeros(frac).padEnd(2, '0');
|
||||
return `${neg ? '-' : ''}${groupDigits(whole)}.${shown}`;
|
||||
}
|
||||
|
||||
export function formatMsat(msat: string | null | undefined, unit: AmountUnit): string {
|
||||
if (msat == null) return '—';
|
||||
return unit === 'btc' ? `${formatMsatAsBtc(msat)} BTC` : `${formatMsatAsSats(msat)} sats`;
|
||||
}
|
||||
|
||||
/** Sats → msat, for request bodies. Multiplying a string by 1000 is three appended zeros. */
|
||||
export function satsToMsat(sats: number): string {
|
||||
return `${Math.trunc(Math.abs(sats))}000`;
|
||||
}
|
||||
|
||||
// ── everything else ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Unix seconds; 0 and null both mean "never" rather than 1970. */
|
||||
export function formatTimestamp(unixSeconds: number | null | undefined): string {
|
||||
if (!unixSeconds || unixSeconds <= 0) return '—';
|
||||
const d = new Date(unixSeconds * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** A countdown as mm:ss — the unlock window, where the seconds genuinely matter. */
|
||||
export function formatCountdown(seconds: number | null | undefined): string {
|
||||
if (seconds == null || seconds <= 0) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** txids, addresses and pubkeys are unreadable in full and unmistakable at both ends. */
|
||||
export function truncateMiddle(value: string | null | undefined, head = 10, tail = 8): string {
|
||||
if (!value) return '—';
|
||||
if (value.length <= head + tail + 1) return value;
|
||||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||||
}
|
||||
|
||||
export function formatConfirmations(confirmations: number): string {
|
||||
if (confirmations <= 0) return 'unconfirmed';
|
||||
if (confirmations === 1) return '1 conf';
|
||||
if (confirmations >= 6) return '6+ confs';
|
||||
return `${confirmations} confs`;
|
||||
}
|
||||
|
||||
export const INVOICE_TONES: Record<InvoiceState, string> = {
|
||||
open: 'bg-blue-500/10 text-blue-500',
|
||||
settled: 'bg-emerald-500/10 text-emerald-500',
|
||||
accepted: 'bg-amber-500/10 text-amber-500',
|
||||
canceled: 'bg-muted text-muted-foreground',
|
||||
expired: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
export const PAYMENT_TONES: Record<PaymentStatus, string> = {
|
||||
pending: 'bg-amber-500/10 text-amber-500',
|
||||
succeeded: 'bg-emerald-500/10 text-emerald-500',
|
||||
failed: 'bg-destructive/10 text-destructive',
|
||||
};
|
||||
|
||||
// `copyToClipboard` used to be re-exported from here, wrapping navigator.clipboard with a try/catch. The
|
||||
// helper in `helpers/clipboard` now does that and more — it falls back to execCommand on an insecure
|
||||
// origin, where navigator.clipboard does not exist at all — so callers import it from there directly.
|
||||
//
|
||||
// Removed rather than left as a pass-through: the wrapper had become `copyToClipboard` calling
|
||||
// `copyToClipboard`, which is a stack overflow on any Wallet copy button. Every re-export is a chance for
|
||||
// exactly that, and this file is about formatting.
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||
import { WalletNav } from './WalletNav';
|
||||
import { WalletView } from './WalletView';
|
||||
import { WalletViewHeader } from './WalletViewHeader';
|
||||
|
||||
export { WalletNav, WalletView };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'wallet-nav',
|
||||
name: 'Wallet',
|
||||
icon: PanelLeft,
|
||||
component: WalletNav,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'wallet-view',
|
||||
name: 'Wallet',
|
||||
icon: LayoutGrid,
|
||||
component: WalletView,
|
||||
header: WalletViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -1,304 +0,0 @@
|
||||
// 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().
|
||||
|
||||
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=<id>` — 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 ? `/wallet/${id}` : `/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<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: 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<AddressType, string> = {
|
||||
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<BackendKind, string> = {
|
||||
onchain: 'On-chain (self-custodial)',
|
||||
lnd: 'LND',
|
||||
'cln-rest': 'Core Lightning',
|
||||
lndhub: 'LNDHub',
|
||||
nwc: 'Nostr Wallet Connect',
|
||||
};
|
||||
|
||||
/** Networks other than mainnet get a visible badge — sending testnet coins by mistake is a bad day. */
|
||||
export const NETWORK_TONES: Record<string, string> = {
|
||||
bitcoin: 'bg-amber-500/15 text-amber-600',
|
||||
testnet: 'bg-emerald-500/15 text-emerald-600',
|
||||
signet: 'bg-violet-500/15 text-violet-600',
|
||||
regtest: 'bg-slate-500/15 text-slate-600',
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { AmountUnit } from './format';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
// sats ⇄ BTC, shared across every wallet panel.
|
||||
//
|
||||
// A display preference, not a selection — it names no entity and belongs in no URL (see the audit's
|
||||
// "view toggles over tiny fixed sets"). It lives in useGlobal so the nav and the view agree instantly,
|
||||
// and is mirrored to localStorage so it survives a reload.
|
||||
|
||||
const STORAGE_KEY = 'wallet:amount-unit';
|
||||
|
||||
const initial = (): AmountUnit => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'btc' ? 'btc' : 'sats';
|
||||
} catch {
|
||||
return 'sats';
|
||||
}
|
||||
};
|
||||
|
||||
export function useAmountUnit() {
|
||||
const [unit, setUnit] = useGlobal<AmountUnit>('WALLET_AMOUNT_UNIT', initial);
|
||||
|
||||
const toggle = () => {
|
||||
const next: AmountUnit = unit === 'sats' ? 'btc' : 'sats';
|
||||
setUnit(next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* private mode — the preference just doesn't persist */
|
||||
}
|
||||
};
|
||||
|
||||
return { unit, toggle };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
// Coin control selection, held in `?coins=txid:vout,txid:vout`.
|
||||
//
|
||||
// The Coins section picks the inputs and the Send section spends them — two panels that must agree on a
|
||||
// set. The URL is how they agree (docs/navigation-audit.md): no channel, no shared store, and the
|
||||
// selection survives a reload and can be handed to someone as a link.
|
||||
//
|
||||
// Outpoints are public transaction data, so there is nothing sensitive about putting them in an address
|
||||
// bar — unlike anything else this app handles.
|
||||
|
||||
const PARAM = 'coins';
|
||||
const OUTPOINT = /^[0-9a-f]{64}:\d+$/i;
|
||||
|
||||
export function useCoinSelection() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const raw = params.get(PARAM) ?? '';
|
||||
const selected = useMemo(() => raw.split(',').filter((o) => OUTPOINT.test(o)), [raw]);
|
||||
|
||||
const write = useCallback(
|
||||
(next: string[]) => {
|
||||
setParams(
|
||||
(prev) => {
|
||||
const updated = new URLSearchParams(prev);
|
||||
if (next.length === 0) updated.delete(PARAM);
|
||||
else updated.set(PARAM, next.join(','));
|
||||
return updated;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(outpoint: string) =>
|
||||
write(selected.includes(outpoint) ? selected.filter((o) => o !== outpoint) : [...selected, outpoint]),
|
||||
[selected, write],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => write([]), [write]);
|
||||
|
||||
return { selected, toggle, clear, set: write };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLockState } from './useWalletData';
|
||||
|
||||
// The unlock window, as a second-by-second countdown.
|
||||
//
|
||||
// The sidecar's `secondsRemaining` is a snapshot taken when the request was served, and re-polling once a
|
||||
// second to animate a clock would be absurd. So the poll re-syncs the truth every ten seconds and this
|
||||
// hook interpolates between syncs from React Query's `dataUpdatedAt`, which is the wall time the snapshot
|
||||
// arrived. Drift is bounded by the poll interval and always errs towards showing LESS time than remains —
|
||||
// a countdown that overstates the window is the one that surprises you mid-signature.
|
||||
//
|
||||
// When it reaches zero the UI flips to locked immediately rather than waiting for the next poll to agree.
|
||||
// Nothing else is invalidated when that happens: every read in this app works fine against a locked
|
||||
// wallet, and blowing the caches away would make an expiring timer look like a connection failure.
|
||||
|
||||
export type LockCountdown = {
|
||||
/** Null while the first lock-state request is in flight, or when no wallet is selected. */
|
||||
hasSeed: boolean | null;
|
||||
unlocked: boolean;
|
||||
secondsRemaining: number;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function useLockCountdown(walletId: number | null): LockCountdown {
|
||||
const { lock, dataUpdatedAt, isLoading } = useLockState(walletId);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const ticking = lock?.unlocked === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticking) return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [ticking]);
|
||||
|
||||
if (!lock) return { hasSeed: null, unlocked: false, secondsRemaining: 0, isLoading };
|
||||
|
||||
const elapsed = dataUpdatedAt ? Math.floor((now - dataUpdatedAt) / 1000) : 0;
|
||||
const secondsRemaining = Math.max(0, lock.secondsRemaining - Math.max(0, elapsed));
|
||||
|
||||
return {
|
||||
hasSeed: lock.hasSeed,
|
||||
unlocked: lock.unlocked && secondsRemaining > 0,
|
||||
secondsRemaining,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { WalletSummary } from './shared';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { WALLET_PARAM } from './shared';
|
||||
import { useWallets } from './useWalletData';
|
||||
|
||||
// Which wallet is open lives in `?wallet=<id>`, read independently by every panel — never passed between
|
||||
// them over a channel. See docs/navigation-audit.md.
|
||||
//
|
||||
// With no param, the sidecar's own `isActive` wallet wins, then the first in the list. That fallback is a
|
||||
// display default, not a selection: the URL stays bare so a shared /wallet link means "whatever is active
|
||||
// right now" rather than pinning whichever wallet the sender happened to have open.
|
||||
|
||||
export type SelectedWallet = {
|
||||
walletId: number | null;
|
||||
wallet: WalletSummary | null;
|
||||
wallets: WalletSummary[];
|
||||
/** True when the id came from the URL rather than the active-wallet fallback. */
|
||||
isPinned: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function useSelectedWallet(): SelectedWallet {
|
||||
const [params] = useSearchParams();
|
||||
const { wallets, isLoading } = useWallets();
|
||||
|
||||
const raw = params.get(WALLET_PARAM);
|
||||
const pinnedId = raw && /^\d+$/.test(raw) ? Number(raw) : null;
|
||||
const pinned = pinnedId == null ? null : (wallets.find((w) => w.id === pinnedId) ?? null);
|
||||
const fallback = wallets.find((w) => w.isActive) ?? wallets[0] ?? null;
|
||||
const wallet = pinned ?? fallback;
|
||||
|
||||
return {
|
||||
walletId: wallet?.id ?? null,
|
||||
wallet,
|
||||
wallets,
|
||||
isPinned: pinned != null,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
import type {
|
||||
Balances,
|
||||
BackendKind,
|
||||
Capability,
|
||||
Channel,
|
||||
DecodedInvoice,
|
||||
FeeEstimates,
|
||||
Invoice,
|
||||
LockState,
|
||||
NodeInfo,
|
||||
OnchainTx,
|
||||
Payment,
|
||||
Peer,
|
||||
RescanState,
|
||||
SendCoinsResult,
|
||||
SyncState,
|
||||
Utxo,
|
||||
WalletConfig,
|
||||
WalletSummary,
|
||||
} from './shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
// Data layer for the /wallet panels. Everything talks to the officer-wallet sidecar through the
|
||||
// /api/wallet auth proxy, which holds no key material of its own.
|
||||
//
|
||||
// THE WALLET IS USUALLY LOCKED, AND THAT IS THE NORMAL STATE. Every read below — balances, history,
|
||||
// receive addresses, coin control, invoices — works against a locked wallet, because a locked wallet is
|
||||
// still a watch-only wallet. Only signing needs the seed. So nothing here is gated on the lock, and no
|
||||
// query is invalidated when the unlock window expires: the numbers on screen do not become wrong.
|
||||
//
|
||||
// PASSPHRASES ARE NEVER CACHED. The unlock/export/delete/change mutations take the passphrase as a call
|
||||
// argument and hand it straight to the POST body. React Query stores mutation *variables* on the
|
||||
// mutation object, so these are deliberately fire-and-forget: nothing here keeps a reference after the
|
||||
// request resolves, and no passphrase is ever a query key or a query result.
|
||||
|
||||
const ROOT_KEY = ['wallet'] as const;
|
||||
|
||||
/** Balances move with the chain; a 20s poll is live enough without hammering Esplora. */
|
||||
const BALANCE_POLL_MS = 20_000;
|
||||
/** The lock countdown is rendered locally from `secondsRemaining`; this only re-syncs the truth. */
|
||||
const LOCK_POLL_MS = 10_000;
|
||||
/** History and coins are cheaper to refresh on demand than to poll hard. */
|
||||
const HISTORY_POLL_MS = 60_000;
|
||||
/** While a rescan runs, the balance response doubles as its progress feed — poll it like one. */
|
||||
const RESCAN_POLL_MS = 4_000;
|
||||
|
||||
const EMPTY_WALLETS: WalletSummary[] = [];
|
||||
const EMPTY_CAPS: Capability[] = [];
|
||||
const EMPTY_TXS: OnchainTx[] = [];
|
||||
const EMPTY_UTXOS: Utxo[] = [];
|
||||
const EMPTY_INVOICES: Invoice[] = [];
|
||||
const EMPTY_PAYMENTS: Payment[] = [];
|
||||
const EMPTY_CHANNELS: Channel[] = [];
|
||||
const EMPTY_PEERS: Peer[] = [];
|
||||
|
||||
const base = (id: number) => `/wallet/_officer/wallets/${id}`;
|
||||
|
||||
// ── deployment + wallet list ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWalletConfig() {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'config'] as const,
|
||||
queryFn: () => get<WalletConfig>('/wallet/_officer/config'),
|
||||
// Network and store-key config change only with a sidecar restart.
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
return { config: query.data ?? null, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
/** The wallet list does not poll — it only changes when the owner creates, renames or deletes one. */
|
||||
export function useWallets() {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'wallets'] as const,
|
||||
queryFn: () => get<{ wallets: WalletSummary[] }>('/wallet/_officer/wallets'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return {
|
||||
wallets: query.data?.wallets ?? EMPTY_WALLETS,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ── per-wallet reads ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The lock state, polled. The countdown a user sees is interpolated locally in useLockCountdown — this
|
||||
* query is the periodic re-sync, not the clock.
|
||||
*/
|
||||
export function useLockState(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'lock-state', walletId] as const,
|
||||
queryFn: () => get<LockState>(`${base(walletId!)}/lock-state`),
|
||||
enabled: walletId != null,
|
||||
refetchInterval: LOCK_POLL_MS,
|
||||
staleTime: LOCK_POLL_MS - 1_000,
|
||||
});
|
||||
|
||||
return { lock: query.data ?? null, dataUpdatedAt: query.dataUpdatedAt, isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePermissions(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'capabilities', walletId] as const,
|
||||
queryFn: () => get<{ kind: BackendKind; capabilities: Capability[] }>(`${base(walletId!)}/capabilities`),
|
||||
enabled: walletId != null,
|
||||
// A backend's capability set is fixed for the life of the wallet.
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: query.data?.kind ?? null,
|
||||
capabilities: query.data?.capabilities ?? EMPTY_CAPS,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWalletInfo(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'info', walletId] as const,
|
||||
queryFn: () => get<{ info: NodeInfo }>(`${base(walletId!)}/info`),
|
||||
enabled: walletId != null,
|
||||
refetchInterval: BALANCE_POLL_MS,
|
||||
staleTime: BALANCE_POLL_MS - 1_000,
|
||||
});
|
||||
|
||||
return { info: query.data?.info ?? null, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
// The three reads below carry a `sync` block from the sidecar. An on-chain wallet serves the last
|
||||
// snapshot it managed to read and refreshes behind the response, so `isLoading` no longer means "we know
|
||||
// nothing" — after the first successful read it is never true again, and the honest signal for "these
|
||||
// numbers are old" or "we could not reach the chain" is `sync`, not the query state.
|
||||
|
||||
export function useBalances(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'balances', walletId] as const,
|
||||
queryFn: () => get<{ balances: Balances; sync: SyncState | null }>(`${base(walletId!)}/balances`),
|
||||
enabled: walletId != null,
|
||||
// Faster while the node is rescanning: this response carries the rescan's progress, and a counter
|
||||
// that moves once every twenty seconds reads as a hung one. Back to the ordinary poll when it ends.
|
||||
refetchInterval: (query) => (query.state.data?.sync?.rescan?.running ? RESCAN_POLL_MS : BALANCE_POLL_MS),
|
||||
staleTime: BALANCE_POLL_MS - 1_000,
|
||||
});
|
||||
|
||||
return {
|
||||
balances: query.data?.balances ?? null,
|
||||
sync: query.data?.sync ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTransactions(walletId: number | null, limit = 50) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'transactions', walletId, limit] as const,
|
||||
queryFn: () =>
|
||||
get<{ transactions: OnchainTx[]; sync: SyncState | null }>(`${base(walletId!)}/transactions?limit=${limit}`),
|
||||
enabled: walletId != null,
|
||||
refetchInterval: HISTORY_POLL_MS,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return {
|
||||
transactions: query.data?.transactions ?? EMPTY_TXS,
|
||||
sync: query.data?.sync ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUtxos(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'utxos', walletId] as const,
|
||||
queryFn: () => get<{ utxos: Utxo[]; sync: SyncState | null }>(`${base(walletId!)}/utxos`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: HISTORY_POLL_MS,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return {
|
||||
utxos: query.data?.utxos ?? EMPTY_UTXOS,
|
||||
sync: query.data?.sync ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFees(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'fees', walletId] as const,
|
||||
queryFn: () => get<{ fees: FeeEstimates }>(`${base(walletId!)}/fees`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return { fees: query.data?.fees ?? null, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* A receive address. `peek` returns the current unused address without advancing the derivation index —
|
||||
* which is what a screen that merely *displays* an address must do, or every render burns an address.
|
||||
*/
|
||||
type AddressResponse = { address: string; type: string };
|
||||
|
||||
export function useReceiveAddress(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const key = [...ROOT_KEY, 'address', walletId] as const;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => get<AddressResponse>(`${base(walletId!)}/address?peek=true`),
|
||||
enabled: walletId != null && enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// `peek=false` CONSUMES: the sidecar advances its issued mark so this address is never handed out
|
||||
// again. Refetching the peeked query returns the same address forever, which is why this is a separate
|
||||
// call and not `refetch()` — that was the bug behind a "new address" button that changed nothing.
|
||||
const next = useMutation({
|
||||
mutationFn: () => get<AddressResponse>(`${base(walletId!)}/address?peek=false`),
|
||||
onSuccess: (data) => qc.setQueryData(key, data),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not derive a new address')),
|
||||
});
|
||||
|
||||
return {
|
||||
address: query.data?.address ?? null,
|
||||
addressType: query.data?.type ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function useInvoices(walletId: number | null, enabled = true, limit = 50) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'invoices', walletId, limit] as const,
|
||||
queryFn: () => get<{ invoices: Invoice[] }>(`${base(walletId!)}/invoices?limit=${limit}`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { invoices: query.data?.invoices ?? EMPTY_INVOICES, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function usePayments(walletId: number | null, enabled = true, limit = 50) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'payments', walletId, limit] as const,
|
||||
queryFn: () => get<{ payments: Payment[] }>(`${base(walletId!)}/payments?limit=${limit}`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { payments: query.data?.payments ?? EMPTY_PAYMENTS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useChannels(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'channels', walletId] as const,
|
||||
queryFn: () => get<{ channels: Channel[] }>(`${base(walletId!)}/channels`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { channels: query.data?.channels ?? EMPTY_CHANNELS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function usePeers(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'peers', walletId] as const,
|
||||
queryFn: () => get<{ peers: Peer[] }>(`${base(walletId!)}/peers`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { peers: query.data?.peers ?? EMPTY_PEERS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
// ── wallet lifecycle mutations ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CreateWalletInput = {
|
||||
name: string;
|
||||
kind: BackendKind;
|
||||
network?: string;
|
||||
/** onchain only. Omit to have the sidecar generate one — that is the only case that returns a mnemonic. */
|
||||
mnemonic?: string;
|
||||
words?: 12 | 24;
|
||||
/** onchain only, and never stored anywhere on this side. */
|
||||
passphrase?: string;
|
||||
bip39Passphrase?: string;
|
||||
defaultBip?: number;
|
||||
config?: Record<string, unknown>;
|
||||
makeActive?: boolean;
|
||||
};
|
||||
|
||||
export type CreateWalletResult = { wallet: WalletSummary; mnemonic?: string };
|
||||
|
||||
export function useWalletLifecycle() {
|
||||
const { post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidateList = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'wallets'] });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (input: CreateWalletInput) => post<CreateWalletResult>('/wallet/_officer/wallets', input),
|
||||
// Deliberately no onSuccess toast/cache write with the response: it may carry the mnemonic, and the
|
||||
// only place that is allowed to exist is the backup modal's local state. The caller invalidates.
|
||||
onSuccess: invalidateList,
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not create the wallet')),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (walletId: number) => post<{ ok: true }>(`${base(walletId)}/activate`),
|
||||
onSuccess: () => {
|
||||
invalidateList();
|
||||
toast.success('Active wallet changed');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not activate the wallet')),
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
// Only the label. The name is the one wallet field with no bearing on the key material, which is why
|
||||
// this needs neither the passphrase nor an unlocked wallet.
|
||||
mutationFn: ({ walletId, name }: { walletId: number; name: string }) =>
|
||||
patch<{ wallet: WalletSummary }>(`/wallet/_officer/wallets/${walletId}`, { name }),
|
||||
onSuccess: () => {
|
||||
invalidateList();
|
||||
toast.success('Wallet renamed');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not rename the wallet')),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
// The passphrase is required for a seeded wallet even when it is already unlocked — an open session
|
||||
// must not be enough to destroy the only copy of the key material.
|
||||
mutationFn: ({
|
||||
walletId,
|
||||
passphrase,
|
||||
confirmName,
|
||||
}: {
|
||||
walletId: number;
|
||||
passphrase?: string;
|
||||
confirmName: string;
|
||||
}) =>
|
||||
del<{ ok: true }>(`/wallet/_officer/wallets/${walletId}`, { confirmName, ...(passphrase ? { passphrase } : {}) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ROOT_KEY });
|
||||
toast.success('Wallet deleted');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not delete the wallet')),
|
||||
});
|
||||
|
||||
return { create, activate, rename, remove };
|
||||
}
|
||||
|
||||
// ── lock lifecycle ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useLockActions(walletId: number | null) {
|
||||
const { post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const refreshLock = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'lock-state', walletId] });
|
||||
|
||||
const unlock = useMutation({
|
||||
// `passphrase` goes from a controlled input straight into this body and the caller clears its state
|
||||
// immediately after. It is never persisted, cached or logged on this side.
|
||||
mutationFn: ({ passphrase, ttlSec }: { passphrase: string; ttlSec?: number }) =>
|
||||
post<{ ok: true; unlocked: boolean; secondsRemaining: number }>(`${base(walletId!)}/unlock`, {
|
||||
passphrase,
|
||||
ttlSec,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
refreshLock();
|
||||
toast.success('Wallet unlocked');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not unlock — check the passphrase')),
|
||||
});
|
||||
|
||||
const lock = useMutation({
|
||||
mutationFn: () => post<{ ok: true; unlocked: false }>(`${base(walletId!)}/lock`),
|
||||
onSuccess: () => {
|
||||
refreshLock();
|
||||
toast.success('Wallet locked');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not lock the wallet')),
|
||||
});
|
||||
|
||||
const changePassphrase = useMutation({
|
||||
mutationFn: (input: { oldPassphrase: string; newPassphrase: string }) =>
|
||||
post<{ ok: true }>(`${base(walletId!)}/passphrase`, input),
|
||||
onSuccess: () => {
|
||||
refreshLock();
|
||||
toast.success('Passphrase changed — the wallet was re-locked');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not change the passphrase')),
|
||||
});
|
||||
|
||||
const exportSeed = useMutation({
|
||||
mutationFn: (input: { passphrase: string }) =>
|
||||
post<{ mnemonic: string; hasBip39Passphrase: boolean }>(`${base(walletId!)}/export-seed`, input),
|
||||
// No cache write and no toast carrying the result — the mnemonic lives only in the dialog's state.
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not export the seed')),
|
||||
});
|
||||
|
||||
return { unlock, lock, changePassphrase, exportSeed };
|
||||
}
|
||||
|
||||
// ── operations ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SendInput = {
|
||||
address: string;
|
||||
amountSats?: number;
|
||||
sendAll?: boolean;
|
||||
satPerVbyte: number;
|
||||
outpoints?: string[];
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function useWalletOperations(walletId: number | null) {
|
||||
const { post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidateAll = () => qc.invalidateQueries({ queryKey: ROOT_KEY });
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: (input: SendInput) => post<SendCoinsResult>(`${base(walletId!)}/send`, input),
|
||||
onSuccess: (result) => {
|
||||
invalidateAll();
|
||||
toast.success(`Broadcast ${result.txid.slice(0, 12)}…`);
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not send')),
|
||||
});
|
||||
|
||||
const freeze = useMutation({
|
||||
mutationFn: (input: { outpoint: string; frozen: boolean; reason?: string }) =>
|
||||
post<{ ok: true }>(`${base(walletId!)}/utxos/freeze`, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'utxos', walletId] }),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not change the freeze flag')),
|
||||
});
|
||||
|
||||
const createInvoice = useMutation({
|
||||
mutationFn: (input: { amountMsat?: string; memo?: string; expirySeconds?: number; private?: boolean }) =>
|
||||
post<{ invoice: Invoice }>(`${base(walletId!)}/invoices`, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'invoices', walletId] }),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not create the invoice')),
|
||||
});
|
||||
|
||||
const decode = useMutation({
|
||||
mutationFn: (bolt11: string) => post<{ decoded: DecodedInvoice }>(`${base(walletId!)}/decode`, { bolt11 }),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not decode that invoice')),
|
||||
});
|
||||
|
||||
const pay = useMutation({
|
||||
mutationFn: (input: { bolt11: string; amountMsat?: string; feeLimitMsat?: string }) =>
|
||||
post<{ payment: Payment }>(`${base(walletId!)}/pay`, input),
|
||||
onSuccess: (data) => {
|
||||
invalidateAll();
|
||||
if (data.payment.status === 'failed') toast.error(data.payment.failureReason ?? 'Payment failed');
|
||||
else toast.success(data.payment.status === 'succeeded' ? 'Payment sent' : 'Payment in flight');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not pay')),
|
||||
});
|
||||
|
||||
const keysend = useMutation({
|
||||
mutationFn: (input: { destination: string; amountMsat: string; message?: string }) =>
|
||||
post<{ payment: Payment }>(`${base(walletId!)}/keysend`, input),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success('Keysend sent');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Keysend failed')),
|
||||
});
|
||||
|
||||
const sign = useMutation({
|
||||
mutationFn: (message: string) => post<{ signature: string }>(`${base(walletId!)}/sign`, { message }),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not sign — the wallet may be locked')),
|
||||
});
|
||||
|
||||
const verify = useMutation({
|
||||
mutationFn: (input: { message: string; signature: string }) =>
|
||||
post<{ valid: boolean; pubkey: string | null }>(`${base(walletId!)}/verify`, input),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not verify')),
|
||||
});
|
||||
|
||||
const setLabel = useMutation({
|
||||
mutationFn: (input: { kind: 'address' | 'tx'; ref: string; label: string }) =>
|
||||
post<{ ok: true }>(`${base(walletId!)}/labels`, input),
|
||||
onSuccess: () => invalidateAll(),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not save the label')),
|
||||
});
|
||||
|
||||
// Returns as soon as the scan is queued — it takes minutes. Progress arrives on the `sync.rescan` block
|
||||
// of the balances poll, which speeds up on its own while one is running, so the invalidation here is
|
||||
// only to put the first "scanning…" on screen without waiting out the current interval.
|
||||
const rescan = useMutation({
|
||||
mutationFn: (input: { gapLimit?: number } = {}) =>
|
||||
post<{ rescan: RescanState }>(`${base(walletId!)}/rescan`, input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'balances', walletId] });
|
||||
toast.success('Scanning the chain for this wallet’s coins — this takes a few minutes');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not start the scan')),
|
||||
});
|
||||
|
||||
return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel, rescan };
|
||||
}
|
||||
|
||||
/**
|
||||
* useClient rejects with `{status, message}` rather than an Error, and the sidecar's message is a JSON
|
||||
* body — unwrap both so a toast reads "this wallet does not support coin control" rather than
|
||||
* "[object Object]". Never called with anything that could contain a passphrase: the sidecar's error
|
||||
* bodies are messages and codes only.
|
||||
*/
|
||||
export function errorMessage(err: unknown, fallback: string): string {
|
||||
const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : '';
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: string };
|
||||
if (parsed.error) return parsed.error;
|
||||
} catch {
|
||||
/* not JSON — use it as-is */
|
||||
}
|
||||
return raw || fallback;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { DEFAULT_WALLET_SECTION, isWalletSection, type WalletSectionId } from './shared';
|
||||
|
||||
// The URL names the open section — not a panel channel. See docs/navigation-audit.md. WalletScreen
|
||||
// redirects anything unrecognised, so the fallback here only covers the instant before that lands.
|
||||
|
||||
export function useWalletSection(): WalletSectionId {
|
||||
const { section } = useParams();
|
||||
return isWalletSection(section) ? section : DEFAULT_WALLET_SECTION;
|
||||
}
|
||||
@@ -66,10 +66,6 @@ export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
|
||||
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
|
||||
|
||||
|
||||
// Same for /wallet. WALLET_PARAM is exported too so anything linking into the screen from outside spells
|
||||
// the wallet query param the same way the panels read it.
|
||||
export { DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection, WALLET_PARAM } from './apps/Wallet/shared';
|
||||
export type { WalletSectionId } from './apps/Wallet/shared';
|
||||
export {
|
||||
useFilesAPI,
|
||||
useTasks,
|
||||
|
||||
Reference in New Issue
Block a user