+ );
+}
diff --git a/src/hooks/useHdWallet.ts b/src/hooks/useHdWallet.ts
new file mode 100644
index 00000000..3c280541
--- /dev/null
+++ b/src/hooks/useHdWallet.ts
@@ -0,0 +1,184 @@
+import { useCallback, useMemo } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { useAppContext } from '@/hooks/useAppContext';
+import { useSecureLocalStorage } from '@/hooks/useSecureLocalStorage';
+import { useHdWalletAccess, type HdWalletAvailability } from '@/hooks/useHdWalletAccess';
+import { deriveReceiveAddress, type DerivedAddress } from '@/lib/hdwallet/derivation';
+import {
+ type AccountScanResult,
+ fetchHdTransactions,
+ type HdTransaction,
+ scanAccount,
+} from '@/lib/hdwallet/scan';
+
+// ---------------------------------------------------------------------------
+// Persisted UI cursor (per user)
+// ---------------------------------------------------------------------------
+//
+// We persist a single integer per user: the "preferred receive index" — the
+// index of the address we are *currently advertising* on the wallet page.
+// The chain-scan source of truth is the relay-derived `firstUnusedIndex`,
+// but if the user explicitly bumps to a fresh address (or rotates back),
+// we honour that until the chain catches up.
+//
+// On native, this lives in the Keychain / KeyStore via secureStorage. On web
+// it's localStorage. Either way it's not secret — losing it means we fall
+// back to firstUnusedIndex on next login.
+
+const STORAGE_KEY = (pubkey: string) => `hdwallet:cursor:${pubkey}`;
+
+interface PersistedCursor {
+ /** Currently-displayed receive index. */
+ receiveIndex: number;
+}
+
+const DEFAULT_CURSOR: PersistedCursor = { receiveIndex: 0 };
+
+// ---------------------------------------------------------------------------
+// Query refresh cadence
+// ---------------------------------------------------------------------------
+
+/** Re-scan + refresh balances every 60 s. Slower than the single-address
+ * wallet (30 s) because each scan can be 10–40 Esplora calls. */
+const REFRESH_INTERVAL_MS = 60_000;
+
+// ---------------------------------------------------------------------------
+// Return shape
+// ---------------------------------------------------------------------------
+
+export interface UseHdWalletResult {
+ /** Availability status — mirrors `useHdWalletAccess`. */
+ availability: HdWalletAvailability;
+ /** Currently-advertised receive address (the one the UI shows). */
+ currentReceiveAddress?: DerivedAddress;
+ /** Full scan result — UTXOs, used addresses, etc. */
+ scan?: AccountScanResult;
+ /** Aggregated wallet-level transaction history (newest first). */
+ transactions?: HdTransaction[];
+ /** Confirmed + pending balance in sats. */
+ totalBalance: number;
+ /** Pending (mempool) balance in sats. May be negative for outgoing. */
+ pendingBalance: number;
+ /** Initial scan in progress. */
+ isLoading: boolean;
+ /** Either scan or tx-history loading. */
+ isFetching: boolean;
+ /** Scan error, if any. */
+ error: unknown;
+ /** Trigger a manual scan + tx refresh. */
+ refetch: () => Promise;
+ /** Advance the receive cursor to the next unused address. Persisted. */
+ nextReceiveAddress: () => DerivedAddress | undefined;
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+/**
+ * Top-level HD wallet hook. Returns the cached scan, balance, transactions,
+ * and the current receive address.
+ *
+ * The hook is safe to call regardless of login state — it returns
+ * `availability.status !== 'available'` for non-nsec users without doing any
+ * derivation or network work.
+ */
+export function useHdWallet(): UseHdWalletResult {
+ const { config } = useAppContext();
+ const { esploraBaseUrl } = config;
+ const availability = useHdWalletAccess();
+ const queryClient = useQueryClient();
+
+ const pubkey = availability.status === 'available' ? availability.pubkey : '';
+ const account = availability.status === 'available' ? availability.account : undefined;
+
+ // ── Persisted receive cursor ─────────────────────────────────
+ // Key by pubkey so account-switching doesn't leak indices across users.
+ const [cursor, setCursor] = useSecureLocalStorage(
+ pubkey ? STORAGE_KEY(pubkey) : 'hdwallet:cursor:none',
+ DEFAULT_CURSOR,
+ );
+
+ // ── Scan query ───────────────────────────────────────────────
+ const scanKey = ['hdwallet-scan', esploraBaseUrl, pubkey];
+ const {
+ data: scan,
+ isLoading: scanLoading,
+ isFetching: scanFetching,
+ error: scanError,
+ refetch: refetchScan,
+ } = useQuery({
+ queryKey: scanKey,
+ queryFn: async ({ signal }) => {
+ if (!account) throw new Error('HD wallet account unavailable');
+ return scanAccount(account, esploraBaseUrl, signal);
+ },
+ enabled: !!account,
+ refetchInterval: REFRESH_INTERVAL_MS,
+ staleTime: REFRESH_INTERVAL_MS / 2,
+ });
+
+ // ── Transaction history query ────────────────────────────────
+ const {
+ data: transactions,
+ isFetching: txFetching,
+ refetch: refetchTxs,
+ } = useQuery({
+ queryKey: ['hdwallet-txs', esploraBaseUrl, pubkey, scan?.receive.used.length, scan?.change.used.length],
+ queryFn: async ({ signal }) => {
+ if (!scan) return [];
+ return fetchHdTransactions(scan, esploraBaseUrl, signal);
+ },
+ enabled: !!scan,
+ refetchInterval: REFRESH_INTERVAL_MS,
+ staleTime: REFRESH_INTERVAL_MS / 2,
+ });
+
+ // ── Current receive address ──────────────────────────────────
+ //
+ // Resolution rules, in order:
+ // 1. If the persisted cursor is *behind* the chain-derived
+ // firstUnusedIndex, the persisted index has been used by a sender →
+ // auto-advance to firstUnusedIndex. (No address reuse.)
+ // 2. If the persisted cursor is *ahead* of firstUnusedIndex (user clicked
+ // "next" multiple times without any deposits), honour it.
+ // 3. Otherwise use the chain-derived firstUnusedIndex.
+ const currentReceiveAddress = useMemo(() => {
+ if (!account) return undefined;
+ const chainNextUnused = scan?.receive.firstUnusedIndex ?? 0;
+ const resolved = Math.max(chainNextUnused, cursor.receiveIndex);
+ return deriveReceiveAddress(account, resolved);
+ }, [account, scan, cursor.receiveIndex]);
+
+ // ── Advance to next receive address ──────────────────────────
+ const nextReceiveAddress = useCallback((): DerivedAddress | undefined => {
+ if (!account) return undefined;
+ const chainNextUnused = scan?.receive.firstUnusedIndex ?? 0;
+ const current = Math.max(chainNextUnused, cursor.receiveIndex);
+ const next = current + 1;
+ setCursor({ receiveIndex: next });
+ return deriveReceiveAddress(account, next);
+ }, [account, scan, cursor.receiveIndex, setCursor]);
+
+ // ── Unified refetch ──────────────────────────────────────────
+ const refetch = useCallback(async () => {
+ // Invalidate UTXO/balance queries used by the send dialog too.
+ await queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
+ return Promise.all([refetchScan(), refetchTxs()]);
+ }, [queryClient, refetchScan, refetchTxs]);
+
+ return {
+ availability,
+ currentReceiveAddress,
+ scan,
+ transactions,
+ totalBalance: scan?.totalBalance ?? 0,
+ pendingBalance: scan?.pendingBalance ?? 0,
+ isLoading: scanLoading,
+ isFetching: scanFetching || txFetching,
+ error: scanError,
+ refetch,
+ nextReceiveAddress,
+ };
+}
diff --git a/src/hooks/useHdWalletAccess.ts b/src/hooks/useHdWalletAccess.ts
new file mode 100644
index 00000000..0586571a
--- /dev/null
+++ b/src/hooks/useHdWalletAccess.ts
@@ -0,0 +1,67 @@
+import { useMemo } from 'react';
+import { useNostrLogin } from '@nostrify/react/login';
+import { nip19 } from 'nostr-tools';
+
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { deriveAccountFromNsec, type HdAccount } from '@/lib/hdwallet/derivation';
+
+/**
+ * Aggregate availability of the HD wallet for the active login.
+ *
+ * The HD wallet derives all of its keys from the user's raw Nostr secret key.
+ * Only the `nsec` login type stores that key in a form we can read; both the
+ * NIP-07 browser extension and NIP-46 remote bunker keep the key elsewhere
+ * (the extension never exposes it; the bunker never sends it). Without the
+ * raw secret we cannot derive child keys, so the HD wallet is gated to nsec
+ * logins.
+ */
+export type HdWalletAvailability =
+ /** User logged in with nsec — full HD wallet access. */
+ | { status: 'available'; account: HdAccount; nsecBytes: Uint8Array; pubkey: string }
+ /** Not logged in at all. */
+ | { status: 'logged-out' }
+ /** Logged in, but the login type doesn't expose the secret key. */
+ | { status: 'unsupported'; loginType: 'extension' | 'bunker' | 'other' };
+
+/**
+ * Hook that returns whether the HD wallet is usable for the active login,
+ * and (when usable) the derived BIP86 account.
+ *
+ * **Security note**: the returned `account` holds private extended keys in
+ * memory for as long as the consumer holds the reference. This is unavoidable
+ * for a wallet that signs locally — the nsec is already in plaintext
+ * localStorage in the same threat model.
+ *
+ * The hook intentionally re-derives on every login change rather than caching
+ * across logouts, so a fresh login starts from a clean derivation.
+ */
+export function useHdWalletAccess(): HdWalletAvailability {
+ const { user } = useCurrentUser();
+ const { logins } = useNostrLogin();
+ const activeLogin = logins[0];
+
+ return useMemo(() => {
+ if (!user || !activeLogin) return { status: 'logged-out' };
+
+ if (activeLogin.type !== 'nsec') {
+ const loginType =
+ activeLogin.type === 'extension'
+ ? 'extension'
+ : activeLogin.type === 'bunker'
+ ? 'bunker'
+ : 'other';
+ return { status: 'unsupported', loginType };
+ }
+
+ // Decode the nsec → 32-byte secret key, then derive the BIP86 account.
+ const decoded = nip19.decode(activeLogin.data.nsec);
+ if (decoded.type !== 'nsec') {
+ // Defensive — should be impossible given the discriminated union.
+ return { status: 'unsupported', loginType: 'other' };
+ }
+ const nsecBytes = decoded.data;
+ const account = deriveAccountFromNsec(nsecBytes);
+
+ return { status: 'available', account, nsecBytes, pubkey: user.pubkey };
+ }, [user, activeLogin]);
+}
diff --git a/src/lib/hdwallet/derivation.ts b/src/lib/hdwallet/derivation.ts
new file mode 100644
index 00000000..69625160
--- /dev/null
+++ b/src/lib/hdwallet/derivation.ts
@@ -0,0 +1,234 @@
+import * as bitcoin from 'bitcoinjs-lib';
+import { toXOnly } from 'bitcoinjs-lib';
+import { HDKey } from '@scure/bip32';
+import { hkdf } from '@noble/hashes/hkdf';
+import { sha256 } from '@noble/hashes/sha2';
+import * as ecc from '@bitcoinerlab/secp256k1';
+import { ECPairFactory, type ECPairAPI } from 'ecpair';
+
+// ---------------------------------------------------------------------------
+// HD wallet derivation (BIP86 — Taproot single-key, key-path-only)
+// ---------------------------------------------------------------------------
+//
+// This wallet derives a full BIP32 hierarchy from the user's Nostr secret key
+// (nsec). There is no separate BIP39 mnemonic — the 32-byte secret key is
+// stretched through HKDF-SHA-256 with an app-specific info string to a 64-byte
+// BIP32 seed, then run through standard BIP86 derivation:
+//
+// m / 86' / 0' / 0' / change / index
+//
+// `change ∈ {0, 1}` distinguishes the receive chain (external, advertised to
+// senders) from the change chain (internal, only used as our own change
+// outputs). Industry standard: never reuse addresses. The wallet always
+// advances to the next unused index on the receive chain when an address is
+// shown, and emits change to a fresh index on the change chain.
+//
+// Output script: P2TR with the derived xonly pubkey as `internalPubkey` (no
+// tapscript tree — key-path spends only), per BIP86.
+//
+// ---------------------------------------------------------------------------
+
+/** HKDF info string. Change ⇒ all derived wallets change. Do not edit. */
+const HKDF_INFO = 'agora-hdwallet:bip86:v1';
+
+/** Standard BIP86 account base path. */
+const BIP86_ACCOUNT_PATH = "m/86'/0'/0'";
+
+/** External (receive) chain index. */
+export const RECEIVE_CHAIN = 0;
+/** Internal (change) chain index. */
+export const CHANGE_CHAIN = 1;
+
+/** Network — mainnet only. Testnet support is intentionally omitted. */
+const NETWORK = bitcoin.networks.bitcoin;
+
+// ---------------------------------------------------------------------------
+// ECC initialisation (lazy)
+// ---------------------------------------------------------------------------
+
+let _ECPair: ECPairAPI | null = null;
+let _eccInitialized = false;
+
+/** Initialize bitcoinjs-lib's ECC backend exactly once. */
+function ensureEcc(): void {
+ if (!_eccInitialized) {
+ bitcoin.initEccLib(ecc);
+ _eccInitialized = true;
+ }
+}
+
+function getECPair(): ECPairAPI {
+ ensureEcc();
+ if (!_ECPair) _ECPair = ECPairFactory(ecc);
+ return _ECPair;
+}
+
+// ---------------------------------------------------------------------------
+// Seed derivation
+// ---------------------------------------------------------------------------
+
+/**
+ * Stretch a Nostr secret key (32 bytes) into a 64-byte BIP32 seed via
+ * HKDF-SHA-256.
+ *
+ * - The Nostr secret key serves as input keying material (IKM).
+ * - A fixed app-specific `info` string domain-separates the output from any
+ * other use of the same key (e.g. NIP-44 ECDH). This means the Bitcoin
+ * wallet cannot be recovered by anyone holding only a NIP-44 conversation
+ * key, only by someone holding the raw secret key itself.
+ * - No salt: deterministic output for the same nsec across all devices.
+ *
+ * @param nsecBytes 32-byte raw Nostr secret key (the `data` field from
+ * `nip19.decode(nsec)`).
+ * @returns 64-byte seed suitable for `HDKey.fromMasterSeed`.
+ */
+export function nsecToBip32Seed(nsecBytes: Uint8Array): Uint8Array {
+ if (nsecBytes.length !== 32) {
+ throw new Error('nsec must be 32 bytes');
+ }
+ return hkdf(sha256, nsecBytes, undefined, HKDF_INFO, 64);
+}
+
+// ---------------------------------------------------------------------------
+// HD key handles
+// ---------------------------------------------------------------------------
+
+/** Result of deriving the account-level xpub. */
+export interface HdAccount {
+ /** Account-level extended public key. Used to derive receive/change chains. */
+ accountNode: HDKey;
+ /** External-chain extended public key (m/86'/0'/0'/0). */
+ receiveNode: HDKey;
+ /** Internal/change-chain extended public key (m/86'/0'/0'/1). */
+ changeNode: HDKey;
+}
+
+/**
+ * Derive the BIP86 account hierarchy from a raw Nostr secret key.
+ *
+ * Returns extended **private** keys (so signing is possible). For balance
+ * scanning, prefer `deriveWatchOnlyAccount` which only needs the xpub.
+ */
+export function deriveAccountFromNsec(nsecBytes: Uint8Array): HdAccount {
+ const seed = nsecToBip32Seed(nsecBytes);
+ const root = HDKey.fromMasterSeed(seed);
+ const accountNode = root.derive(BIP86_ACCOUNT_PATH);
+ const receiveNode = accountNode.deriveChild(RECEIVE_CHAIN);
+ const changeNode = accountNode.deriveChild(CHANGE_CHAIN);
+ return { accountNode, receiveNode, changeNode };
+}
+
+// ---------------------------------------------------------------------------
+// Address derivation
+// ---------------------------------------------------------------------------
+
+/** A single derived address with everything needed to spend from it. */
+export interface DerivedAddress {
+ /** Bech32m P2TR address (bc1p…). */
+ address: string;
+ /** 32-byte x-only internal pubkey (hex). */
+ internalPubkeyHex: string;
+ /** Chain (0 = receive, 1 = change). */
+ chain: 0 | 1;
+ /** Address index within the chain. */
+ index: number;
+ /** Full BIP32 path, e.g. `m/86'/0'/0'/0/3`. */
+ path: string;
+}
+
+/**
+ * Derive a single P2TR address from a chain extended key (either the receive
+ * or change node). The chain index is supplied so the returned `path` is
+ * accurate.
+ */
+export function deriveAddress(chainNode: HDKey, chain: 0 | 1, index: number): DerivedAddress {
+ ensureEcc();
+ if (!Number.isInteger(index) || index < 0) {
+ throw new Error(`Invalid address index: ${index}`);
+ }
+
+ const child = chainNode.deriveChild(index);
+ const pubkey = child.publicKey;
+ if (!pubkey) throw new Error('HDKey is missing a public key');
+
+ // BIP86: drop the parity byte to get the 32-byte x-only key.
+ const internalPubkey = Buffer.from(toXOnly(Buffer.from(pubkey)));
+
+ const { address } = bitcoin.payments.p2tr({
+ internalPubkey,
+ network: NETWORK,
+ });
+ if (!address) throw new Error('Failed to derive P2TR address');
+
+ return {
+ address,
+ internalPubkeyHex: internalPubkey.toString('hex'),
+ chain,
+ index,
+ path: `${BIP86_ACCOUNT_PATH}/${chain}/${index}`,
+ };
+}
+
+/** Convenience: derive a single receive address at the given index. */
+export function deriveReceiveAddress(account: HdAccount, index: number): DerivedAddress {
+ return deriveAddress(account.receiveNode, RECEIVE_CHAIN, index);
+}
+
+/** Convenience: derive a single change address at the given index. */
+export function deriveChangeAddress(account: HdAccount, index: number): DerivedAddress {
+ return deriveAddress(account.changeNode, CHANGE_CHAIN, index);
+}
+
+// ---------------------------------------------------------------------------
+// Signing keys
+// ---------------------------------------------------------------------------
+
+/**
+ * Derive the 32-byte raw private key for a specific (chain, index) leaf.
+ *
+ * The caller is responsible for zeroing the returned buffer when done (best
+ * effort — JS does not guarantee this). This function is the only place where
+ * leaf private keys are materialised.
+ */
+export function deriveLeafPrivateKey(
+ account: HdAccount,
+ chain: 0 | 1,
+ index: number,
+): Uint8Array {
+ const chainNode = chain === RECEIVE_CHAIN ? account.receiveNode : account.changeNode;
+ const child = chainNode.deriveChild(index);
+ if (!child.privateKey) {
+ throw new Error('Derived HDKey has no private key (xpub-only?)');
+ }
+ // Defensive copy — @scure/bip32 holds an internal reference.
+ return new Uint8Array(child.privateKey);
+}
+
+/**
+ * Compute the BIP-341 TapTweaked signing keypair for a given leaf. Returns an
+ * ECPair instance whose private scalar is `priv + H_tapTweak(P)` mod n.
+ *
+ * Used by the PSBT signer.
+ */
+export function deriveLeafTaprootSigner(
+ account: HdAccount,
+ chain: 0 | 1,
+ index: number,
+): ReturnType {
+ const ECPair = getECPair();
+ const privKey = deriveLeafPrivateKey(account, chain, index);
+ try {
+ const keyPair = ECPair.fromPrivateKey(Buffer.from(privKey));
+ const internalPubkey = toXOnly(keyPair.publicKey);
+ return keyPair.tweak(bitcoin.crypto.taggedHash('TapTweak', internalPubkey));
+ } finally {
+ // Best-effort wipe of the local copy.
+ privKey.fill(0);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Network constant export
+// ---------------------------------------------------------------------------
+
+export { NETWORK as HD_WALLET_NETWORK };
diff --git a/src/lib/hdwallet/scan.ts b/src/lib/hdwallet/scan.ts
new file mode 100644
index 00000000..65b62950
--- /dev/null
+++ b/src/lib/hdwallet/scan.ts
@@ -0,0 +1,292 @@
+import {
+ type AddressData,
+ fetchAddressData,
+ fetchTransactions,
+ fetchUTXOs,
+ type UTXO,
+} from '@/lib/bitcoin';
+import {
+ CHANGE_CHAIN,
+ type DerivedAddress,
+ deriveAddress,
+ type HdAccount,
+ RECEIVE_CHAIN,
+} from './derivation';
+
+// ---------------------------------------------------------------------------
+// Gap-limit chain scanning
+// ---------------------------------------------------------------------------
+//
+// BIP44 gap limit: a wallet considers a chain "fully scanned" after observing
+// `GAP_LIMIT` consecutive addresses that have never been used (zero history).
+// Industry standard is 20.
+//
+// We scan in batches of `SCAN_BATCH_SIZE` to amortise round-trip latency
+// while still bounding fan-out on the Esplora server.
+// ---------------------------------------------------------------------------
+
+/** Standard BIP44 gap limit. */
+export const GAP_LIMIT = 20;
+
+/** Number of addresses fetched per request batch. */
+const SCAN_BATCH_SIZE = 5;
+
+/** Hard ceiling on addresses scanned per chain. Protects against bugs/loops. */
+const MAX_INDEX = 10_000;
+
+/** Information about a single derived address that has been observed. */
+export interface ScannedAddress {
+ derived: DerivedAddress;
+ data: AddressData;
+ utxos: UTXO[];
+}
+
+/** Full scan result for a single chain (receive or change). */
+export interface ChainScanResult {
+ /** All addresses with any history (tx_count > 0 on either confirmed or mempool). */
+ used: ScannedAddress[];
+ /** All addresses currently holding spendable UTXOs (incl. unconfirmed). */
+ withBalance: ScannedAddress[];
+ /** Index of the first address with no history (the "next" address to advertise). */
+ firstUnusedIndex: number;
+ /** Whether the scan hit MAX_INDEX without finding a clean gap. */
+ hitMaxIndex: boolean;
+}
+
+/** Combined receive+change scan result for an entire account. */
+export interface AccountScanResult {
+ receive: ChainScanResult;
+ change: ChainScanResult;
+ /** All UTXOs across both chains. */
+ utxos: Array;
+ /** Confirmed + pending balance in satoshis, summed across both chains. */
+ totalBalance: number;
+ /** Sum of `pendingBalance` across all addresses (positive = incoming, negative = outgoing). */
+ pendingBalance: number;
+ /** Map from address → derived metadata. Used by the tx aggregator and signer. */
+ addressMap: Map;
+}
+
+/**
+ * Has this address ever been used? "Used" means it has any history at all,
+ * confirmed or in the mempool. We treat the address as advertised-and-burned
+ * the moment a sender touches it.
+ */
+function isUsed(data: AddressData): boolean {
+ return data.txCount > 0 || data.pendingTxCount > 0;
+}
+
+/**
+ * Scan a single chain (receive or change) until `GAP_LIMIT` consecutive
+ * unused addresses are observed.
+ */
+async function scanChain(
+ account: HdAccount,
+ chain: 0 | 1,
+ esploraBaseUrl: string,
+ signal?: AbortSignal,
+): Promise {
+ const chainNode = chain === RECEIVE_CHAIN ? account.receiveNode : account.changeNode;
+
+ const used: ScannedAddress[] = [];
+ const withBalance: ScannedAddress[] = [];
+ let firstUnusedIndex = 0;
+ let firstUnusedSet = false;
+ let consecutiveUnused = 0;
+ let index = 0;
+ let hitMaxIndex = false;
+
+ while (consecutiveUnused < GAP_LIMIT) {
+ if (index >= MAX_INDEX) {
+ hitMaxIndex = true;
+ break;
+ }
+
+ // Build the next batch of addresses to scan.
+ const batch: DerivedAddress[] = [];
+ for (let i = 0; i < SCAN_BATCH_SIZE && consecutiveUnused + i < GAP_LIMIT && index + i < MAX_INDEX; i++) {
+ batch.push(deriveAddress(chainNode, chain, index + i));
+ }
+ if (batch.length === 0) break;
+
+ // Fetch address data in parallel. UTXOs are only fetched for addresses
+ // that turn out to be used — we avoid speculative UTXO calls for the
+ // ~20 "tail" addresses at the end of every scan.
+ const dataResults = await Promise.all(
+ batch.map(async (d) => {
+ signal?.throwIfAborted();
+ const data = await fetchAddressData(d.address, esploraBaseUrl);
+ return { d, data };
+ }),
+ );
+
+ for (const { d, data } of dataResults) {
+ if (isUsed(data)) {
+ // Used — reset gap counter, fetch UTXOs for spending.
+ signal?.throwIfAborted();
+ const utxos = await fetchUTXOs(d.address, esploraBaseUrl);
+ const sa: ScannedAddress = { derived: d, data, utxos };
+ used.push(sa);
+ if (utxos.length > 0 || data.totalBalance > 0) withBalance.push(sa);
+ consecutiveUnused = 0;
+ // Do NOT update firstUnusedIndex here — we want the first index that
+ // has never been used, so it stays pointed at the earliest gap.
+ } else {
+ if (!firstUnusedSet) {
+ firstUnusedIndex = d.index;
+ firstUnusedSet = true;
+ }
+ consecutiveUnused++;
+ }
+ }
+
+ index += batch.length;
+ }
+
+ // Edge case: the chain has zero used addresses. firstUnusedIndex stays 0.
+ if (!firstUnusedSet) firstUnusedIndex = 0;
+
+ return { used, withBalance, firstUnusedIndex, hitMaxIndex };
+}
+
+/**
+ * Scan both chains (receive and change) for an HD account and aggregate the
+ * results.
+ *
+ * @param account The derived HD account.
+ * @param esploraBaseUrl Esplora REST root, no trailing slash.
+ * @param signal Optional abort signal.
+ */
+export async function scanAccount(
+ account: HdAccount,
+ esploraBaseUrl: string,
+ signal?: AbortSignal,
+): Promise {
+ // Both chains in parallel — they're independent of each other.
+ const [receive, change] = await Promise.all([
+ scanChain(account, RECEIVE_CHAIN, esploraBaseUrl, signal),
+ scanChain(account, CHANGE_CHAIN, esploraBaseUrl, signal),
+ ]);
+
+ const addressMap = new Map();
+ for (const sa of receive.used) addressMap.set(sa.derived.address, sa.derived);
+ for (const sa of change.used) addressMap.set(sa.derived.address, sa.derived);
+
+ const utxos: AccountScanResult['utxos'] = [];
+ let totalBalance = 0;
+ let pendingBalance = 0;
+
+ for (const chainResult of [receive, change]) {
+ for (const sa of chainResult.used) {
+ totalBalance += sa.data.totalBalance;
+ pendingBalance += sa.data.pendingBalance;
+ for (const u of sa.utxos) {
+ utxos.push({
+ ...u,
+ address: sa.derived.address,
+ chain: sa.derived.chain,
+ index: sa.derived.index,
+ });
+ }
+ }
+ }
+
+ return { receive, change, utxos, totalBalance, pendingBalance, addressMap };
+}
+
+// ---------------------------------------------------------------------------
+// Aggregated transaction history
+// ---------------------------------------------------------------------------
+
+/**
+ * Aggregated transaction record for an HD wallet. Unlike the per-address
+ * `Transaction` from `bitcoin.ts`, this one merges all on-chain activity
+ * across every owned address so a single send-with-change tx shows up as one
+ * row rather than two.
+ */
+export interface HdTransaction {
+ txid: string;
+ /** Net satoshi change across the entire wallet (positive = received, negative = sent). */
+ amount: number;
+ /** Send or receive (based on net amount sign). */
+ type: 'receive' | 'send';
+ confirmed: boolean;
+ timestamp?: number;
+}
+
+/**
+ * Fetch per-address transaction lists for every used address and combine
+ * them by txid. A single transaction that hits multiple owned addresses
+ * (e.g. send-with-change) is merged into one record whose `amount` is the
+ * net wallet-level change.
+ */
+export async function fetchHdTransactions(
+ result: AccountScanResult,
+ esploraBaseUrl: string,
+ signal?: AbortSignal,
+): Promise {
+ const allUsed = [...result.receive.used, ...result.change.used];
+ if (allUsed.length === 0) return [];
+
+ // Fetch each address's tx list in parallel. Each call returns a simplified
+ // per-address view from `fetchTransactions` (net positive/negative).
+ const perAddress = await Promise.all(
+ allUsed.map(async (sa) => {
+ signal?.throwIfAborted();
+ const txs = await fetchTransactions(sa.derived.address, esploraBaseUrl);
+ return txs.map((tx) => ({
+ ...tx,
+ // `fetchTransactions` returns Math.abs(net); recover the signed value.
+ signedAmount: tx.type === 'receive' ? tx.amount : -tx.amount,
+ }));
+ }),
+ );
+
+ // Merge by txid — sum signed amounts so that send-with-change collapses.
+ const merged = new Map();
+
+ for (const list of perAddress) {
+ for (const tx of list) {
+ const existing = merged.get(tx.txid);
+ if (existing) {
+ existing.netSats += tx.signedAmount;
+ // Once confirmed, stay confirmed.
+ existing.confirmed = existing.confirmed || tx.confirmed;
+ // Prefer the earliest known timestamp.
+ if (tx.timestamp && (!existing.timestamp || tx.timestamp < existing.timestamp)) {
+ existing.timestamp = tx.timestamp;
+ }
+ } else {
+ merged.set(tx.txid, {
+ txid: tx.txid,
+ netSats: tx.signedAmount,
+ confirmed: tx.confirmed,
+ timestamp: tx.timestamp,
+ });
+ }
+ }
+ }
+
+ const out: HdTransaction[] = Array.from(merged.values()).map((m) => ({
+ txid: m.txid,
+ amount: Math.abs(m.netSats),
+ type: m.netSats >= 0 ? 'receive' : 'send',
+ confirmed: m.confirmed,
+ timestamp: m.timestamp,
+ }));
+
+ // Sort newest first. Unconfirmed (no timestamp) go to the top.
+ out.sort((a, b) => {
+ if (!a.timestamp && !b.timestamp) return 0;
+ if (!a.timestamp) return -1;
+ if (!b.timestamp) return 1;
+ return b.timestamp - a.timestamp;
+ });
+
+ return out;
+}
diff --git a/src/lib/hdwallet/transaction.ts b/src/lib/hdwallet/transaction.ts
new file mode 100644
index 00000000..4c65b5e8
--- /dev/null
+++ b/src/lib/hdwallet/transaction.ts
@@ -0,0 +1,292 @@
+import * as bitcoin from 'bitcoinjs-lib';
+import * as ecc from '@bitcoinerlab/secp256k1';
+
+import {
+ BITCOIN_DUST_LIMIT,
+ estimateFee,
+ type UTXO,
+ validateBitcoinAddress,
+} from '@/lib/bitcoin';
+import {
+ CHANGE_CHAIN,
+ deriveAddress,
+ deriveLeafTaprootSigner,
+ type HdAccount,
+ HD_WALLET_NETWORK,
+} from './derivation';
+
+// ---------------------------------------------------------------------------
+// HD wallet transaction construction & signing
+// ---------------------------------------------------------------------------
+//
+// A "spendable" UTXO is one of our own UTXOs together with the (chain, index)
+// pair that derived its address. We need both to:
+//
+// 1. Set `tapInternalKey` on the PSBT input correctly per BIP-371.
+// 2. Reconstruct the BIP-341 tweaked private key when signing.
+//
+// Change always goes to a fresh address on the internal (change) chain —
+// never to a receive-chain address — to keep the on-chain heuristic
+// "change-from-same-owner is the smaller output on the change chain" intact.
+// ---------------------------------------------------------------------------
+
+/** A UTXO owned by the HD wallet, annotated with derivation info. */
+export interface HdSpendableUtxo extends UTXO {
+ /** The Bitcoin address the UTXO belongs to. */
+ address: string;
+ /** Chain index (0 = receive, 1 = change). */
+ chain: 0 | 1;
+ /** Address index within the chain. */
+ index: number;
+}
+
+/** Result of building an unsigned PSBT for the HD wallet. */
+export interface HdUnsignedPsbt {
+ /** Hex-encoded unsigned PSBT. */
+ psbtHex: string;
+ /** Network fee in satoshis. */
+ fee: number;
+ /** Whether a change output was added. */
+ hasChange: boolean;
+ /** Address used for change (if any). */
+ changeAddress?: string;
+ /** Per-input (chain, index) so signing knows which key to derive. */
+ inputDerivations: Array<{ chain: 0 | 1; index: number }>;
+}
+
+// ---------------------------------------------------------------------------
+// Coin selection
+// ---------------------------------------------------------------------------
+
+/**
+ * Branch-and-bound is overkill here. A "largest-first" coin selector is fine
+ * for a wallet whose UTXO set is bounded by gap-limit scanning:
+ *
+ * - Picks the smallest set of inputs that covers `target + fee`.
+ * - Prefers confirmed UTXOs over unconfirmed.
+ * - Returns the candidate set OR `null` if balance is insufficient.
+ *
+ * This deliberately avoids the privacy pitfall of the "smallest first"
+ * heuristic (which signals "I am consolidating dust" to chain analysis).
+ */
+function selectUtxos(
+ utxos: readonly HdSpendableUtxo[],
+ target: number,
+ feeRate: number,
+): { selected: HdSpendableUtxo[]; total: number } | null {
+ // Confirmed first, then largest first within each group.
+ const sorted = [...utxos].sort((a, b) => {
+ if (a.status.confirmed !== b.status.confirmed) return a.status.confirmed ? -1 : 1;
+ return b.value - a.value;
+ });
+
+ const selected: HdSpendableUtxo[] = [];
+ let total = 0;
+
+ for (const utxo of sorted) {
+ selected.push(utxo);
+ total += utxo.value;
+
+ // Fee for current set, assuming 2 outputs (recipient + change). If we
+ // can cover target+fee even without change, we'll get a chance to drop
+ // the change output below.
+ const feeWithChange = estimateFee(selected.length, 2, feeRate);
+ if (total >= target + feeWithChange) return { selected, total };
+
+ const feeNoChange = estimateFee(selected.length, 1, feeRate);
+ if (total >= target + feeNoChange) return { selected, total };
+ }
+
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// PSBT build
+// ---------------------------------------------------------------------------
+
+/**
+ * Build an unsigned P2TR PSBT for the HD wallet.
+ *
+ * Inputs are chosen by `selectUtxos`. Change (if any) goes to a fresh address
+ * on the internal chain, derived at `account.changeNode / nextChangeIndex`.
+ *
+ * @param account HD account (used to derive change address & re-derive input keys).
+ * @param ownedUtxos Candidate UTXOs to spend (must include `chain`/`index`).
+ * @param toAddress Recipient Bitcoin address.
+ * @param amountSats Amount to send in satoshis (must be >= dust limit).
+ * @param feeRate Fee rate in sat/vB.
+ * @param nextChangeIndex The next unused index on the change chain.
+ */
+export function buildHdUnsignedPsbt(
+ account: HdAccount,
+ ownedUtxos: readonly HdSpendableUtxo[],
+ toAddress: string,
+ amountSats: number,
+ feeRate: number,
+ nextChangeIndex: number,
+): HdUnsignedPsbt {
+ if (!validateBitcoinAddress(toAddress)) {
+ throw new Error(`Invalid Bitcoin address: ${toAddress}`);
+ }
+ if (!Number.isInteger(amountSats) || amountSats < BITCOIN_DUST_LIMIT) {
+ throw new Error(`Amount must be at least ${BITCOIN_DUST_LIMIT} sats.`);
+ }
+ if (!Number.isFinite(feeRate) || feeRate <= 0) {
+ throw new Error('Fee rate must be positive.');
+ }
+
+ const selection = selectUtxos(ownedUtxos, amountSats, feeRate);
+ if (!selection) {
+ const total = ownedUtxos.reduce((s, u) => s + u.value, 0);
+ throw new Error(
+ `Insufficient funds. Need at least ${amountSats.toLocaleString()} sats + fees, ` +
+ `have ${total.toLocaleString()} sats spendable.`,
+ );
+ }
+ const { selected, total: totalInput } = selection;
+
+ // Recompute fee for the final selected set.
+ const feeWithChange = estimateFee(selected.length, 2, feeRate);
+ const changeIfKept = totalInput - amountSats - feeWithChange;
+ const hasChange = changeIfKept >= BITCOIN_DUST_LIMIT;
+ const numOutputs = hasChange ? 2 : 1;
+ const fee = estimateFee(selected.length, numOutputs, feeRate);
+ const change = totalInput - amountSats - fee;
+
+ if (change < 0) {
+ throw new Error(
+ `Insufficient funds. Need ${(amountSats + fee).toLocaleString()} sats, ` +
+ `have ${totalInput.toLocaleString()} sats.`,
+ );
+ }
+
+ bitcoin.initEccLib(ecc);
+ const psbt = new bitcoin.Psbt({ network: HD_WALLET_NETWORK });
+ const inputDerivations: HdUnsignedPsbt['inputDerivations'] = [];
+
+ for (const utxo of selected) {
+ // Re-derive the input's internal key from the account hierarchy. We do
+ // not trust the address string for this — the chain/index pair is the
+ // single source of truth.
+ const derived = deriveAddress(
+ utxo.chain === CHANGE_CHAIN ? account.changeNode : account.receiveNode,
+ utxo.chain,
+ utxo.index,
+ );
+ if (derived.address !== utxo.address) {
+ throw new Error(
+ `UTXO address mismatch at ${utxo.chain}/${utxo.index}: ` +
+ `expected ${derived.address}, got ${utxo.address}`,
+ );
+ }
+ const internalPubkey = Buffer.from(derived.internalPubkeyHex, 'hex');
+
+ psbt.addInput({
+ hash: utxo.txid,
+ index: utxo.vout,
+ witnessUtxo: {
+ script: bitcoin.payments.p2tr({
+ internalPubkey,
+ network: HD_WALLET_NETWORK,
+ }).output!,
+ value: BigInt(utxo.value),
+ },
+ tapInternalKey: internalPubkey,
+ });
+ inputDerivations.push({ chain: utxo.chain, index: utxo.index });
+ }
+
+ psbt.addOutput({ address: toAddress, value: BigInt(amountSats) });
+
+ let changeAddress: string | undefined;
+ if (hasChange) {
+ const changeDerived = deriveAddress(account.changeNode, CHANGE_CHAIN, nextChangeIndex);
+ changeAddress = changeDerived.address;
+ psbt.addOutput({ address: changeAddress, value: BigInt(change) });
+ }
+
+ return {
+ psbtHex: psbt.toHex(),
+ fee,
+ hasChange,
+ changeAddress,
+ inputDerivations,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// PSBT signing
+// ---------------------------------------------------------------------------
+
+/**
+ * Sign every input in a PSBT using its corresponding HD-derived tweaked key.
+ *
+ * `inputDerivations` MUST be aligned 1:1 with the PSBT's inputs in order.
+ * (`buildHdUnsignedPsbt` returns them in the right order.)
+ *
+ * @returns Hex-encoded signed (but not finalised) PSBT.
+ */
+export function signHdPsbt(
+ psbtHex: string,
+ inputDerivations: ReadonlyArray<{ chain: 0 | 1; index: number }>,
+ account: HdAccount,
+): string {
+ bitcoin.initEccLib(ecc);
+ const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: HD_WALLET_NETWORK });
+
+ if (psbt.inputCount !== inputDerivations.length) {
+ throw new Error(
+ `PSBT input count (${psbt.inputCount}) does not match derivations ` +
+ `length (${inputDerivations.length}).`,
+ );
+ }
+
+ for (let i = 0; i < psbt.inputCount; i++) {
+ const { chain, index } = inputDerivations[i];
+ const tweakedSigner = deriveLeafTaprootSigner(account, chain, index);
+ psbt.signInput(i, tweakedSigner);
+ }
+
+ return psbt.toHex();
+}
+
+/**
+ * Finalise a signed PSBT and extract the raw transaction hex.
+ */
+export function finalizeHdPsbt(psbtHex: string): string {
+ bitcoin.initEccLib(ecc);
+ const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: HD_WALLET_NETWORK });
+ psbt.finalizeAllInputs();
+ return psbt.extractTransaction().toHex();
+}
+
+/**
+ * Convenience: build → sign → finalise in one call.
+ */
+export function createHdTransaction(
+ account: HdAccount,
+ ownedUtxos: readonly HdSpendableUtxo[],
+ toAddress: string,
+ amountSats: number,
+ feeRate: number,
+ nextChangeIndex: number,
+): { txHex: string; fee: number; hasChange: boolean; changeAddress?: string } {
+ const built = buildHdUnsignedPsbt(account, ownedUtxos, toAddress, amountSats, feeRate, nextChangeIndex);
+ const signed = signHdPsbt(built.psbtHex, built.inputDerivations, account);
+ const txHex = finalizeHdPsbt(signed);
+ return { txHex, fee: built.fee, hasChange: built.hasChange, changeAddress: built.changeAddress };
+}
+
+// ---------------------------------------------------------------------------
+// Max-sendable
+// ---------------------------------------------------------------------------
+
+/**
+ * Compute the maximum amount sendable to a single recipient if every owned
+ * UTXO is consumed and no change is produced.
+ */
+export function maxHdSendable(utxos: readonly HdSpendableUtxo[], feeRate: number): number {
+ const total = utxos.reduce((s, u) => s + u.value, 0);
+ const fee = estimateFee(utxos.length, 1, feeRate);
+ return Math.max(0, total - fee);
+}
diff --git a/src/pages/HDWalletPage.tsx b/src/pages/HDWalletPage.tsx
new file mode 100644
index 00000000..da1a4592
--- /dev/null
+++ b/src/pages/HDWalletPage.tsx
@@ -0,0 +1,313 @@
+import { useRef, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { useSeoMeta } from '@unhead/react';
+import {
+ Bitcoin,
+ Copy,
+ Check,
+ RefreshCw,
+ ChevronDown,
+ ArrowDownLeft,
+ ArrowUpRight,
+ Send,
+ ShieldOff,
+ ArrowRight,
+ KeyRound,
+} from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { LoginArea } from '@/components/auth/LoginArea';
+import { QRCodeCanvas } from '@/components/ui/qrcode';
+import { HDSendBitcoinDialog } from '@/components/HDSendBitcoinDialog';
+import { useAppContext } from '@/hooks/useAppContext';
+import { useHdWallet } from '@/hooks/useHdWallet';
+import { useBtcPrice } from '@/hooks/useBtcPrice';
+import { satsToUSD, formatBTC } from '@/lib/bitcoin';
+import type { HdTransaction } from '@/lib/hdwallet/scan';
+
+export function HDWalletPage() {
+ const { config } = useAppContext();
+ const {
+ availability,
+ currentReceiveAddress,
+ transactions,
+ totalBalance,
+ pendingBalance,
+ isLoading,
+ error,
+ refetch,
+ nextReceiveAddress,
+ } = useHdWallet();
+ const { data: btcPrice } = useBtcPrice();
+
+ const [copiedAddress, setCopiedAddress] = useState(false);
+ const [txOpen, setTxOpen] = useState(false);
+ const [sendOpen, setSendOpen] = useState(false);
+
+ useSeoMeta({
+ title: `HD Wallet | ${config.appName}`,
+ description: 'Hierarchical-deterministic Bitcoin wallet derived from your Nostr nsec.',
+ });
+
+ const address = currentReceiveAddress?.address ?? '';
+
+ const copyAddress = async () => {
+ if (!address) return;
+ try {
+ await navigator.clipboard.writeText(address);
+ setCopiedAddress(true);
+ setTimeout(() => setCopiedAddress(false), 2000);
+ } catch {
+ // clipboard unavailable
+ }
+ };
+
+ const truncatedAddress = address
+ ? `${address.slice(0, 12)}...${address.slice(-8)}`
+ : '';
+
+ // ── Logged out ────────────────────────────────────────────────
+ if (availability.status === 'logged-out') {
+ return (
+
+
+
+
+
+
+
HD Bitcoin Wallet
+
+ A hierarchical wallet derived from your Nostr identity. Fresh address per receive,
+ full transaction history, no address reuse.
+
+
+ Requires login with an nsec (your Nostr private key).
+
+
+
+
+
+ );
+ }
+
+ // ── Logged in, but signer doesn't expose the secret key ─────
+ if (availability.status === 'unsupported') {
+ return (
+
+
+
+
+
+
+
HD wallet unavailable
+
+ {availability.loginType === 'extension'
+ ? 'Your browser extension keeps your secret key isolated, so we can\'t derive child keys for an HD wallet.'
+ : availability.loginType === 'bunker'
+ ? 'Your remote signer (NIP-46 bunker) keeps your secret key on the bunker side, so we can\'t derive child keys for an HD wallet.'
+ : "Your login type doesn't expose the secret key needed to derive an HD wallet."}
+
+
+ The single-address wallet at /wallet{' '}
+ works for every login type.
+
+
+
+
+
+ );
+ }
+
+ // ── Available — full HD wallet UI ────────────────────────────
+ return (
+
+