// BIP84 (native segwit, p2wpkh) HD wallet core. // // Derivation: m / 84' / coin' / 0' / chain / index // chain 0 = receive (external) addresses // chain 1 = change (internal) addresses // // All signing uses witnessUtxo only (script + amount). That is all segwit needs, // which is what makes offline signing easy: we never have to fetch full previous // transactions, only the (txid, vout, value, address) of each UTXO. import { HDKey } from '@scure/bip32'; import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { hex, base64urlnopad, base32nopad, base58check } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { scrypt } from '@noble/hashes/scrypt'; import { randomBytes } from '@noble/hashes/utils'; import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'; import { utf8ToBytes, bytesToUtf8 } from '@noble/ciphers/utils.js'; import * as btc from '@scure/btc-signer'; import { p2wpkh } from '@scure/btc-signer/payment'; import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, wsUrl, getBackend, explorerWeb, electrumCandidates } from './api.js'; import { ElectrumApi } from './electrum.js'; import { NostrSync, getSyncConfig } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder } from './silentpay.js'; // No look-ahead: stop scanning a chain at the first unused address. This wallet // only ever exposes ONE unused address at a time (freshReceive = first unused; // there is no "generate another address" button), so used addresses stay // contiguous and there is never a gap to look past. Keeps scans tiny. const GAP_LIMIT = 1; // Extra pause after finding a used address, before querying the next index — // keeps us gentle on the explorers' rate limits when a wallet has activity. const USED_HIT_DELAY_MS = 500; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const NETS = { mainnet: { net: btc.NETWORK, coin: 0 }, testnet: { net: btc.TEST_NETWORK, coin: 1 }, }; export function newMnemonic(strengthBits = 128) { return generateMnemonic(wordlist, strengthBits); } // The base localStorage cache key for a wallet identity (xpub, xprv, or // seed-id) — mirrors Wallet#_cacheKey, so app code can target a specific // account's cached state (e.g. to wipe it on log-out). export function cacheKeyFor(id) { return 'btc-wallet-cache:' + hex.encode(sha256(new TextEncoder().encode(id))).slice(0, 32); } // Account-level xpub for a seed/key without constructing a Wallet — used to // verify an entered recovery phrase matches a watch-only account before // upgrading it to spendable. export function accountXpubFor({ mnemonic, passphrase = '', xprv } = {}, netName = 'mainnet') { const { coin } = NETS[netName]; let acct; if (xprv) { const node = HDKey.fromExtendedKey(xprv); acct = node.depth === 0 ? node.derive(`m/84'/${coin}'/0'`) : node; } else { acct = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)).derive(`m/84'/${coin}'/0'`); } return acct.publicExtendedKey; } export function isValidMnemonic(m) { try { return validateMnemonic(m.trim().replace(/\s+/g, ' '), wordlist); } catch { return false; } } export class Wallet { constructor() { this.mnemonic = ''; this.passphrase = ''; this.netName = 'mainnet'; this.api = new Api('mainnet'); this.offline = false; // Scanned chains: arrays of { chain, index, address, used } this.receive = []; this.change = []; this.addrMap = new Map(); // address -> { chain, index } this.utxos = []; // { txid, vout, value, address, chain, index, confirmed } this.txs = []; // aggregated history this.feeRates = null; this.confirmed = 0; this.pending = 0; this.scanning = false; this.loaded = false; // true once a scan/snapshot has populated balances once this.historyLoading = false; // history is still being fetched in the background this._refreshing = false; // a scan/refresh is in flight this.nextReceiveIndex = 0; this.nextChangeIndex = 0; this._account = null; this._accountKey = ''; this._addrCache = new Map(); // Realtime (mempool.space WebSocket) this.live = false; this._ws = null; this._wsWant = false; this._wsRetry = null; this._refreshTimer = null; this._pollTimer = null; this._deepTimer = null; // periodic full-scan safety net this._polling = false; // a light poll is in flight this._lastPoll = 0; // last frontier poll (throttles the adaptive poll timer) this._hbTimer = null; // heartbeat / liveness watchdog this._lastMsg = 0; // time of last WS message (incl. pong) this._wakeHooked = false; // Encrypted cross-device state sync over Nostr. this.nostr = new NostrSync(); this._savedAt = 0; this._nostrPubTimer = null; this._subs = new Set(); } // --- reactive glue (tiny pub/sub; the UI re-renders on change) ---------- subscribe(fn) { this._subs.add(fn); return () => this._subs.delete(fn); } emit() { for (const fn of this._subs) fn(this); } // --- setup -------------------------------------------------------------- load({ mnemonic = '', passphrase = '', xpub = '', xprv = '', netName = 'mainnet', offline = false }) { this.stopRealtime(); this.mnemonic = (mnemonic || '').trim().replace(/\s+/g, ' '); this.passphrase = passphrase; this.xpub = xpub || ''; this.xprv = xprv || ''; // spending wallet imported from an extended private key this.watchOnly = !!this.xpub && !this.mnemonic && !this.xprv; // view/receive only this.netName = netName; this.offline = offline; this._buildApi(); this.api.offline = offline; this._account = null; this._accountKey = null; this._addrCache.clear(); this._reserved = null; // gift coins set aside from spending (lazy-loaded) this._reclaimed = null; // gift coins freed for spending but link still live this._savedAt = 0; try { if (this.mnemonic) this.nostr.load(this.mnemonic, this.passphrase); else this.nostr.unload(); } catch {} this.reset(); } reset() { this.receive = []; this.change = []; this.addrMap = new Map(); this.utxos = []; this.txs = []; this.confirmed = 0; this.pending = 0; this.loaded = false; this.nextReceiveIndex = 0; this.nextChangeIndex = 0; } setOffline(off) { this.offline = off; this.api.offline = off; if (off) this.stopRealtime(); this.emit(); } // Build the data backend for the current setting: Esplora REST (`Api`) or a // full Electrum-over-WS server (`ElectrumApi`), which routes its data calls // over this wallet's realtime socket via _rpcCall. _buildApi() { if (getBackend() === 'electrum') { this.api = new ElectrumApi({ call: (m, p) => this._rpcCall(m, p), network: this.netCfg.net, testnet: this.netName === 'testnet', explorerWeb: explorerWeb(), }); } else { this.api = new Api(this.netName); } this.api.offline = this.offline; } // Rebuild the backend against the current settings, then rescan and reconnect // realtime (called when the user switches explorer/backend in Settings). async reloadExplorer() { this.stopRealtime(); this._buildApi(); if (this.offline) return; // Electrum's data rides the socket, so connect first, then scan. if (getBackend() === 'electrum') this.startRealtime(); try { await this.scan(); } catch {} if (getBackend() !== 'electrum') this.startRealtime(); } get netCfg() { return NETS[this.netName]; } // --- derivation --------------------------------------------------------- account() { const { coin } = this.netCfg; // Cache key identifies the source so a reload rebuilds when it changes. const key = this.watchOnly ? 'pub:' + this.xpub : this.xprv ? 'prv:' + this.xprv : `${this.netName}|${this.mnemonic}|${this.passphrase}`; if (this._account && this._accountKey === key) return this._account; let acct; if (this.watchOnly) { acct = HDKey.fromExtendedKey(this.xpub); // account-level public key } else if (this.xprv) { // A master xprv (depth 0) needs the BIP84 account path derived from it; // an already account-level xprv is used as-is. const node = HDKey.fromExtendedKey(this.xprv); acct = node.depth === 0 ? node.derive(`m/84'/${coin}'/0'`) : node; } else { acct = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase)).derive(`m/84'/${coin}'/0'`); } this._account = acct; this._accountKey = key; this._addrCache.clear(); return acct; } // The account-level extended public key (xpub / zpub) for export → watch-only. accountXpub() { return this.account().publicExtendedKey; } node(chain, index) { // Relative derivation from the account node (chain/index are non-hardened). return this.account().deriveChild(chain).deriveChild(index); } // Returns { address, script, pubkey } derive(chain, index) { const cacheKey = `${chain}/${index}`; const hit = this._addrCache.get(cacheKey); if (hit) return hit; const node = this.node(chain, index); const pay = p2wpkh(node.publicKey, this.netCfg.net); const info = { address: pay.address, script: pay.script, pubkey: node.publicKey, chain, index }; this._addrCache.set(cacheKey, info); return info; } // Derive a window of addresses on both chains and (re)build addrMap. Used in // offline mode where we cannot scan for usage but still must map a UTXO's // address back to its derivation path in order to sign it. deriveWindow(count) { this.addrMap = new Map(); for (const chain of [0, 1]) { for (let i = 0; i < count; i++) { const { address } = this.derive(chain, i); this.addrMap.set(address, { chain, index: i }); } } } // --- online scan -------------------------------------------------------- // Query one address at a time; stop at the first unused one (no look-ahead). async scanChain(chain) { const found = []; let gap = 0; let i = 0; while (gap < GAP_LIMIT) { const { address } = this.derive(chain, i); const info = await this.api.addressInfo(address); const cs = info.chain_stats || {}; const ms = info.mempool_stats || {}; const used = (cs.tx_count || 0) > 0 || (ms.tx_count || 0) > 0; const hasMempool = (ms.tx_count || 0) > 0; // Balance straight from chain_stats — no need to fetch /utxo just to total. const confirmed = (cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0); const pending = (ms.funded_txo_sum || 0) - (ms.spent_txo_sum || 0); found.push({ chain, index: i, address, used, confirmed, pending, hasMempool }); this.addrMap.set(address, { chain, index: i }); if (used) { gap = 0; await sleep(USED_HIT_DELAY_MS); // slow down before fetching the next one } else { gap += 1; } i++; } return found; } // A short signature of the user-visible state, so background ("silent") // refreshes only re-render when something actually changed. _sig() { return JSON.stringify({ bc: this.confirmed, bp: this.pending, u: this.utxos.map((u) => `${u.txid}:${u.vout}:${u.value}:${u.confirmed ? 1 : 0}`), t: this.txs.map((t) => `${t.txid}:${t.confirmed ? 1 : 0}`), r: this.nextReceiveIndex, c: this.nextChangeIndex, }); } _addrInfo(chain, index) { const arr = chain === 0 ? this.receive : this.change; return arr.find((a) => a.index === index); } // Light poll: only check the fresh frontier — the next receive and change // address — for new activity. Already-scanned addresses are never re-polled; // changes to them (spends) arrive over the WebSocket while connected, and a // manual rescan covers anything missed (e.g. address reuse). If the frontier // moved, escalate to a full scan once to reconcile. async refreshLive() { if (this.offline || this._refreshing || this._polling) return; this._polling = true; this._lastPoll = Date.now(); // any refresh (poll OR socket-open) resets the // poll clock, so the two don't both fire back-to-back on load. let changed = false; try { const fresh = []; // frontier addresses found to be active this pass for (const chain of [0, 1]) { let idx = chain === 0 ? this.nextReceiveIndex : this.nextChangeIndex; // Walk forward from the frontier while addresses are used; stop at the // first unused one. Never revisits already-passed (cached) addresses. for (let guard = 0; guard < 100; guard++) { const { address } = this.derive(chain, idx); const info = await this.api.addressInfo(address); const cs = info.chain_stats || {}; const ms = info.mempool_stats || {}; const used = (cs.tx_count || 0) > 0 || (ms.tx_count || 0) > 0; const confirmed = (cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0); const pending = (ms.funded_txo_sum || 0) - (ms.spent_txo_sum || 0); const arr = chain === 0 ? this.receive : this.change; let entry = arr.find((a) => a.index === idx); if (!entry) { entry = { chain, index: idx, address }; arr.push(entry); this.addrMap.set(address, { chain, index: idx }); } if (entry.used !== used || entry.confirmed !== confirmed || entry.pending !== pending) { changed = true; if (used) fresh.push({ chain, index: idx, address }); } entry.used = used; entry.confirmed = confirmed; entry.pending = pending; if (!used) break; // frontier still fresh — done with this chain idx++; } if (chain === 0) this.nextReceiveIndex = idx; else this.nextChangeIndex = idx; } // Fetch coins + history for ONLY the newly-active frontier addresses. for (const a of fresh) { const us = await this.api.addressUtxos(a.address); this.utxos = this.utxos.filter((u) => u.address !== a.address); for (const u of us) { this.utxos.push({ txid: u.txid, vout: u.vout, value: u.value, address: a.address, chain: a.chain, index: a.index, confirmed: !!u.status.confirmed, }); } const list = await this.api.addressTxs(a.address); for (const tx of list) { const summary = this._txSummary(tx); const at = this.txs.findIndex((t) => t.txid === tx.txid); // Refresh in place so a tx that has since confirmed loses its pending // status — not just append new ones (which left confirmations stale). if (at >= 0) this.txs[at] = summary; else this.txs.push(summary); } } // Re-verify the addresses that currently hold coins, to catch spends from // an already-scanned address (the frontier walk above only looks forward, // so a coin spent here — including by a tx broadcast on another device — // would otherwise never reconcile without a full rescan). if (await this._reconcileHeld()) changed = true; if (changed) { this.utxos.sort((x, y) => y.value - x.value); this._sortTxs(); this._recomputeBalanceFromChains(); this.loaded = true; this.saveCache(); this.retrack(); // a new pending coin may have changed the watched set this.emit(); } } catch { /* transient; next poll/ws retries */ } finally { this._polling = false; } } // Enable/disable fast polling of the fresh receive address — set while the // Receive tab is open so a deposit shows near-instantly even if the WS doesn't // push it. Toggling on triggers an immediate check. setWatchReceive(on) { on = !!on; if (on === !!this._watchReceive) return; // no change — avoid re-triggering this._watchReceive = on; if (on) { this._lastReceivePoll = 0; this.pollReceiveFrontier(); } } // Cheap single-address check of the next receive address. If it has activity, // hand off to the full refreshLive (which pulls the coin + history and advances // the frontier); otherwise do nothing. One request per call. async pollReceiveFrontier() { if (this.offline || this._polling || this._refreshing) return; const idx = this.nextReceiveIndex; const { address } = this.derive(0, idx); try { const info = await this.api.addressInfo(address); const cs = info.chain_stats || {}; const ms = info.mempool_stats || {}; const used = (cs.tx_count || 0) > 0 || (ms.tx_count || 0) > 0; if (used) await this.refreshLive(); } catch { /* transient; next tick retries */ } } // Re-check every address that currently holds a UTXO: refresh its balance from // chain/mempool stats and rebuild its UTXOs. This is what reconciles spends // (a held coin that's now gone) — confirmed or still in the mempool, regardless // of which device broadcast the spending transaction. Returns true if anything // changed. Only touches addresses we already know, so it stays cheap. async _reconcileHeld() { const addrs = [...new Set(this.utxos.map((u) => u.address))]; let changed = false; for (const address of addrs) { const p = this.addrMap.get(address); if (!p) continue; let info; try { info = await this.api.addressInfo(address); } catch { continue; } const cs = info.chain_stats || {}; const ms = info.mempool_stats || {}; const confirmed = (cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0); const pending = (ms.funded_txo_sum || 0) - (ms.spent_txo_sum || 0); const arr = p.chain === 0 ? this.receive : this.change; const entry = arr.find((e) => e.index === p.index); if (entry && entry.confirmed === confirmed && entry.pending === pending) continue; let us; try { us = await this.api.addressUtxos(address); } catch { continue; } if (entry) { entry.confirmed = confirmed; entry.pending = pending; } this.utxos = this.utxos.filter((u) => u.address !== address); for (const u of us) { this.utxos.push({ txid: u.txid, vout: u.vout, value: u.value, address, chain: p.chain, index: p.index, confirmed: !!u.status.confirmed, }); } changed = true; } return changed; } // Every address the wallet currently knows about (scanned receive + change), // sorted by chain then index, for the per-address rescan list in Settings. knownAddresses() { const mk = (e, chain) => ({ chain, index: e.index, address: e.address, used: !!e.used, balance: (e.confirmed || 0) + (e.pending || 0) }); return [ ...this.receive.map((e) => mk(e, 0)), ...this.change.map((e) => mk(e, 1)), ].sort((a, b) => a.chain - b.chain || a.index - b.index); } // Re-query a single address and reconcile just its coins/history into state — // the targeted alternative to a full rescan, for recovering a deposit to a // reused old address. Returns whether the address is used. async rescanAddress(chain, index) { const { address } = this.derive(chain, index); const info = await this.api.addressInfo(address); const cs = info.chain_stats || {}; const ms = info.mempool_stats || {}; const used = (cs.tx_count || 0) > 0 || (ms.tx_count || 0) > 0; const confirmed = (cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0); const pending = (ms.funded_txo_sum || 0) - (ms.spent_txo_sum || 0); const arr = chain === 0 ? this.receive : this.change; let entry = arr.find((e) => e.index === index); if (!entry) { entry = { chain, index, address }; arr.push(entry); arr.sort((a, b) => a.index - b.index); } this.addrMap.set(address, { chain, index }); entry.used = used; entry.confirmed = confirmed; entry.pending = pending; const us = await this.api.addressUtxos(address); this.utxos = this.utxos.filter((u) => u.address !== address); for (const u of us) this.utxos.push({ txid: u.txid, vout: u.vout, value: u.value, address, chain, index, confirmed: !!u.status.confirmed }); const list = await this.api.addressTxs(address); for (const tx of list) { const summary = this._txSummary(tx); const at = this.txs.findIndex((t) => t.txid === tx.txid); if (at >= 0) this.txs[at] = summary; else this.txs.push(summary); } this.nextReceiveIndex = firstUnused(this.receive); this.nextChangeIndex = firstUnused(this.change); this.utxos.sort((x, y) => y.value - x.value); this._sortTxs(); this._recomputeBalanceFromChains(); this.loaded = true; this.saveCache(); this.retrack(); // watch this address over the socket if its coin is pending this.emit(); return used; } // silent=false: foreground load (shows loading state, always re-renders). // silent=true: background refresh (poll / WS) — no spinner, and only emits // if the visible state changed, so it never disrupts typing. async scan({ silent = false } = {}) { if (this.offline || this._refreshing) return; this._refreshing = true; const before = silent ? this._sig() : null; if (!silent) { this.scanning = true; this.emit(); } try { const prevBal = `${this.confirmed}|${this.pending}|${this.nextReceiveIndex}|${this.nextChangeIndex}`; this.addrMap = new Map(); // Probe both chains at once — they're independent, so interleaving lets // their per-hit delays overlap instead of running back-to-back. const [receive, change] = await Promise.all([this.scanChain(0), this.scanChain(1)]); this.receive = receive; this.change = change; this.nextReceiveIndex = firstUnused(this.receive); this.nextChangeIndex = firstUnused(this.change); this._recomputeBalanceFromChains(); // Only pull /utxo and /txs (the heavy part) on first load or when the // balance/addresses actually changed. Idle polls stay /address-only. const balanceChanged = `${this.confirmed}|${this.pending}|${this.nextReceiveIndex}|${this.nextChangeIndex}` !== prevBal; // Always re-pull history while any tx is still pending, so confirmations // get reconciled even when the balance itself hasn't changed (e.g. a // received coin that simply moved from mempool to a block). const hasPending = this.txs.some((t) => !t.confirmed); if (!silent || balanceChanged || !this.loaded || hasPending) { // Balance + receive address are already known from the chain_stats above. // Show them right away so the wallet looks ready, then keep loading the // heavier UTXO set and full history in the background. if (!silent) { this.loaded = true; this.historyLoading = true; this.emit(); } if (!silent || !this.feeRates) this.feeRates = await this.api.feeRates(); await this.refreshUtxos(); await this.refreshHistory(!silent); } this.loaded = true; this.saveCache(); } finally { this._refreshing = false; this.historyLoading = false; // clear even if an earlier step threw if (!silent) { this.scanning = false; this.emit(); } else if (this._sig() !== before) { this.emit(); } } } usedAddresses() { return [...this.receive, ...this.change].filter((a) => a.used); } async refreshUtxos() { // An address whose confirmed balance is 0 and which has no mempool activity // is fully spent and settled — it cannot hold any UTXOs, so skip the /utxo // round-trip for it. In a wallet with history most addresses are spent, so // this avoids the bulk of the requests (and the 429s that come with them). const used = this.usedAddresses().filter( (a) => (a.confirmed || 0) > 0 || a.hasMempool ); const lists = await pool(used, (a) => this.api.addressUtxos(a.address)); const utxos = []; used.forEach((a, idx) => { for (const u of lists[idx]) { utxos.push({ txid: u.txid, vout: u.vout, value: u.value, address: a.address, chain: a.chain, index: a.index, confirmed: !!u.status.confirmed, }); } }); utxos.sort((a, b) => b.value - a.value); this.utxos = utxos; } // Summarize a raw esplora tx into our history shape (net to us, fee, status). _txSummary(tx) { const mine = new Set(this.addrMap.keys()); let received = 0; let sent = 0; for (const vin of tx.vin || []) { const a = vin.prevout && vin.prevout.scriptpubkey_address; if (a && mine.has(a)) sent += vin.prevout.value; } for (const vout of tx.vout || []) { if (vout.scriptpubkey_address && mine.has(vout.scriptpubkey_address)) received += vout.value; } const confirmed = !!(tx.status && tx.status.confirmed); // Preserve when we first saw it pending, so we can later judge a stuck tx. const prior = this.txs.find((t) => t.txid === tx.txid); return { txid: tx.txid, net: received - sent, // >0 incoming, <0 outgoing fee: tx.fee || 0, vsize: tx.weight ? Math.ceil(tx.weight / 4) : (prior && prior.vsize) || 0, confirmed, blockTime: (tx.status && tx.status.block_time) || 0, blockHeight: (tx.status && tx.status.block_height) || 0, firstSeen: confirmed ? 0 : (prior && prior.firstSeen) || Date.now(), }; } // A pending tx is "stuck" once it's waited ~10 blocks (≈100 min) and its fee // rate is below the mempool's current floor (minimumFee) — i.e. it's been // purged and won't confirm without a bump (RBF/CPFP). We stop actively // monitoring these (no socket slot, no fast poll); the slow reconcile still // picks it up if the floor later drops and it confirms after all. isStuck(tx) { if (!tx || tx.confirmed || !tx.firstSeen || !tx.vsize) return false; if (Date.now() - tx.firstSeen < 100 * 60000) return false; // give it ~10 blocks const floor = (this.feeRates && this.feeRates.minimumFee) || 1; return tx.fee / tx.vsize < floor; } _stuckTxids() { const s = new Set(); for (const tx of this.txs) if (this.isStuck(tx)) s.add(tx.txid); return s; } _sortTxs() { this.txs.sort((a, b) => { if (a.confirmed !== b.confirmed) return a.confirmed ? 1 : -1; // pending first return (b.blockTime || 0) - (a.blockTime || 0); }); } // progressive: emit after each address resolves so the History tab fills in // as transactions arrive (foreground load) instead of all at once at the end. async refreshHistory(progressive = false) { const used = this.usedAddresses(); this.historyLoading = true; const seen = new Set(); // txids found in this pass (a tx can touch >1 address) try { await pool(used, async (a) => { const list = await this.api.addressTxs(a.address); let added = false; for (const tx of list) { if (seen.has(tx.txid)) continue; seen.add(tx.txid); const summary = this._txSummary(tx); const at = this.txs.findIndex((t) => t.txid === tx.txid); if (at >= 0) this.txs[at] = summary; // refresh confirmations/fee else { this.txs.push(summary); added = true; } } if (added && progressive) { this._sortTxs(); this.emit(); } }); // Drop anything that no longer appears in any address's history. this.txs = this.txs.filter((t) => seen.has(t.txid)); this._sortTxs(); } finally { this.historyLoading = false; } } // --- balances ----------------------------------------------------------- // confirmed/pending are plain fields, set from chain_stats during scan (and // from UTXOs when restoring an offline snapshot). total is derived. get total() { return this.confirmed + this.pending; } // Confirmed coins locked behind unclaimed gift links. The whole source coin is // committed until the gift is claimed (our change only comes back in the // claimer's tx), so it's already excluded from spending — and from the // spendable balance below. get lockedValue() { const res = this.reservedSet(); return this.utxos.reduce((s, u) => s + (res.has(utxoId(u)) ? u.value : 0), 0); } // The spendable headline: confirmed coins plus our own unconfirmed change (the // remainder of a spend we just made, so the spend debits immediately), minus // anything locked in gifts. Unconfirmed *incoming* receives are NOT counted // here — they show as pending until they confirm. get spendable() { const res = this.reservedSet(); return this.utxos.reduce((s, u) => { if (res.has(utxoId(u))) return s; if (u.confirmed || u.chain === 1) return s + u.value; return s; }, 0); } // Unconfirmed incoming receives (not our change, not gift-locked) — shown as a // separate pending line, kept out of the spendable headline. get pendingIncoming() { const res = this.reservedSet(); return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0); } _recomputeBalanceFromChains() { let c = 0; let p = 0; for (const a of [...this.receive, ...this.change]) { c += a.confirmed || 0; p += a.pending || 0; } this.confirmed = c; this.pending = p; } _recomputeBalanceFromUtxos() { this.confirmed = this.utxos.reduce((s, u) => s + (u.confirmed ? u.value : 0), 0); this.pending = this.utxos.reduce((s, u) => s + (u.confirmed ? 0 : u.value), 0); } freshReceive() { return this.derive(0, this.nextReceiveIndex); } freshChange() { return this.derive(1, this.nextChangeIndex); } // --- spending ----------------------------------------------------------- // recipients: [{ address, amount(sats) }] // coinIds: optional array of "txid:vout" to force manual coin control. // sendMax: drain all selected coins to the single recipient. buildTx({ recipients, feeRate, coinIds = null, sendMax = false }) { const pool_ = coinIds ? this.utxos.filter((u) => coinIds.includes(utxoId(u))) : this.utxos.filter((u) => !this.isReserved(utxoId(u))); // skip coins set aside for gifts if (!pool_.length) throw new Error('No spendable coins selected.'); const inputs = pool_.map((u) => { const { script, pubkey } = this.derive(u.chain, u.index); const pay = p2wpkh(pubkey, this.netCfg.net); return { ...pay, txid: u.txid, index: u.vout, sequence: 0xfffffffd, // signal opt-in RBF (BIP125) so sends are bumpable witnessUtxo: { script: pay.script, amount: BigInt(u.value) }, }; }); const changeAddress = this.freshChange().address; const feePerByte = BigInt(Math.max(1, Math.round(feeRate))); // Silent payment (sp1…) recipients: the real taproot output can only be // derived once coin selection fixes the input set (it commits to every // input), so we select against a same-size placeholder taproot output and // swap in the derived script afterward. const spOuts = []; // { placeholder, scan, spend } if (recipients.some((r) => isSilentPaymentAddress(r.address)) && this.watchOnly) throw new Error('Silent payments require a wallet with keys.'); if (sendMax) { if (recipients.length !== 1) throw new Error('Send-max requires exactly one recipient.'); // Drain everything: use the recipient as the "change" address with no // fixed outputs, so the estimator sends (total − exact fee) to them. let dest = recipients[0].address; if (isSilentPaymentAddress(dest)) { const { scan, spend } = decodeSilentPaymentAddress(dest); const placeholder = silentPaymentPlaceholder(0); spOuts.push({ placeholder, scan, spend, address: dest }); dest = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(placeholder)); } const sel = btc.selectUTXO(inputs, [], 'all', { changeAddress: dest, feePerByte, network: this.netCfg.net, createTx: true, bip69: true, }); if (!sel || !sel.tx) throw new Error('Fee exceeds balance.'); if (spOuts.length) this._applySilentPayments(sel.tx, spOuts); const sweepSummary = summarize(sel, this.netCfg.net); this._tagSilentOutputs(sweepSummary, spOuts); return sweepSummary; } const outputs = recipients.map((r) => { if (isSilentPaymentAddress(r.address)) { const { scan, spend } = decodeSilentPaymentAddress(r.address); const placeholder = silentPaymentPlaceholder(spOuts.length); spOuts.push({ placeholder, scan, spend, address: r.address }); return { script: placeholder, amount: BigInt(r.amount) }; } return { address: r.address, amount: BigInt(r.amount) }; }); const strategy = coinIds ? 'all' : 'default'; const sel = btc.selectUTXO(inputs, outputs, strategy, { changeAddress, feePerByte, network: this.netCfg.net, createTx: true, bip69: true, }); if (!sel || !sel.tx) throw new Error('Insufficient funds for amount + fee.'); if (spOuts.length) this._applySilentPayments(sel.tx, spOuts); const summary = summarize(sel, this.netCfg.net); this._tagSilentOutputs(summary, spOuts); return summary; } // Mark summary outputs that fund a silent payment with the sp1… address the // user actually typed (the on-chain output is a derived one-time taproot addr). _tagSilentOutputs(summary, spOuts) { for (const s of spOuts) { if (!s.derivedAddress) continue; const o = summary.outputs.find((o) => o.address === s.derivedAddress && !o.silent); if (o) o.silent = s.address; } } // Replace each placeholder silent-payment output in a freshly-selected (still // unsigned) tx with its real one-time taproot script, derived from the tx's // actual inputs per BIP-352. _applySilentPayments(tx, spOuts) { const byOutpoint = new Map(); for (const u of this.utxos) byOutpoint.set(utxoId(u), u); const inputs = []; for (let i = 0; i < tx.inputsLength; i++) { const inp = tx.getInput(i); const u = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`); if (!u) throw new Error('Missing key for a silent payment input.'); inputs.push({ txid: u.txid, vout: u.vout, priv: this.node(u.chain, u.index).privateKey }); } const scripts = silentPaymentScripts(inputs, spOuts.map((s) => ({ scan: s.scan, spend: s.spend }))); spOuts.forEach((s, k) => { const ph = hex.encode(s.placeholder); const idx = tx.outputs.findIndex((o) => hex.encode(o.script) === ph); if (idx < 0) throw new Error('Silent payment output not found after selection.'); tx.updateOutput(idx, { script: scripts[k], amount: tx.outputs[idx].amount }, true); s.derivedAddress = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(scripts[k])); }); } // True if a built tx spends any of our still-unconfirmed coins — meaning it // can't confirm until that parent (ancestor) transaction confirms first. spendsUnconfirmed(tx) { const pending = new Set(this.utxos.filter((u) => !u.confirmed).map((u) => utxoId(u))); if (!pending.size) return false; for (let i = 0; i < tx.inputsLength; i++) { const inp = tx.getInput(i); if (pending.has(`${hex.encode(inp.txid)}:${inp.index}`)) return true; } return false; } // Sign every input of an already-built Transaction with the matching HD key. sign(tx) { if (this.watchOnly) throw new Error('Watch-only wallet — no keys to sign with.'); const byOutpoint = new Map(); for (const u of this.utxos) byOutpoint.set(utxoId(u), u); for (let i = 0; i < tx.inputsLength; i++) { const inp = tx.getInput(i); const id = `${hex.encode(inp.txid)}:${inp.index}`; const u = byOutpoint.get(id); if (!u) throw new Error(`Cannot find key for input ${id}`); const node = this.node(u.chain, u.index); tx.signIdx(node.privateKey, i); } tx.finalize(); return tx.hex; } async broadcast(hexTx) { return this.api.broadcast(hexTx); } // Parse a raw (signed) transaction hex — used by scan-to-broadcast to show a // confirmation (txid + outputs) before relaying someone's exported tx. parseRawTx(rawHex) { const tx = btc.Transaction.fromRaw(hex.decode(rawHex.trim()), { allowUnknownOutputs: true }); const network = NETS[this.netName].net; const outputs = []; let total = 0; for (let i = 0; i < tx.outputsLength; i++) { const o = tx.getOutput(i); total += Number(o.amount); let address = ''; try { address = btc.Address(network).encode(btc.OutScript.decode(o.script)); } catch {} outputs.push({ address, value: Number(o.amount) }); } return { txid: tx.id, total, outputs }; } // RBF fee bumping, in three steps so the UI can preview a fee per rate before // building: prepareBump() fetches + reconstructs the original (async), then // planBump()/buildBump() are pure and reuse that prep. // Fetch an unconfirmed outgoing tx and reconstruct what's needed to replace it. async prepareBump(origTxid) { const orig = await this.api.getTx(origTxid); if (orig.status && orig.status.confirmed) throw new Error('Transaction already confirmed.'); const ins = []; let totalIn = 0; for (const vin of orig.vin || []) { const a = vin.prevout && vin.prevout.scriptpubkey_address; const p = a && this.addrMap.get(a); if (!p) throw new Error('Can only bump your own transactions.'); totalIn += vin.prevout.value; ins.push({ txid: vin.txid, vout: vin.vout, value: vin.prevout.value, chain: p.chain, index: p.index }); } // Our first change-chain output is the change we'll shrink; the rest are // recipients and stay fixed. const recipients = []; let outTotal = 0; let changeSeen = false; for (const o of orig.vout || []) { outTotal += o.value; const p = o.scriptpubkey_address && this.addrMap.get(o.scriptpubkey_address); if (p && p.chain === 1 && !changeSeen) { changeSeen = true; continue; } recipients.push({ address: o.scriptpubkey_address, value: o.value }); } const usedIds = new Set(ins.map((i) => `${i.txid}:${i.vout}`)); const spare = this.utxos.filter((u) => u.confirmed && !usedIds.has(utxoId(u))); return { txid: origTxid, ins, recipients, totalIn, recipTotal: recipients.reduce((s, r) => s + r.value, 0), oldFee: totalIn - outTotal, spare, }; } // Pure: pick the fee/change (and any extra inputs) for a given rate. planBump(prep, feeRate) { const rate = Math.max(1, Math.round(feeRate)); const DUST = 294; // p2wpkh dust const vsizeOf = (nIn, nOut) => 11 + 68 * nIn + 31 * nOut; const spare = prep.spare.slice(); const extra = []; const compute = () => { const nIn = prep.ins.length + extra.length; const inAmt = prep.totalIn + extra.reduce((s, u) => s + u.value, 0); let nOut = prep.recipients.length + 1; let fee = Math.ceil(vsizeOf(nIn, nOut) * rate); let change = inAmt - prep.recipTotal - fee; if (change < DUST) { nOut = prep.recipients.length; fee = inAmt - prep.recipTotal; change = 0; } const minFee = prep.oldFee + vsizeOf(nIn, nOut); // BIP125 incremental relay if (change > 0 && fee < minFee) { fee = minFee; change = inAmt - prep.recipTotal - fee; if (change < DUST) { fee = inAmt - prep.recipTotal; change = 0; } } return { fee, change, ok: inAmt - prep.recipTotal >= fee && fee > prep.oldFee }; }; let pl = compute(); while (!pl.ok && spare.length) { extra.push(spare.shift()); pl = compute(); } return { fee: pl.fee, change: pl.change, extra, ok: pl.ok, rate }; } // Build + sign the replacement at a rate. Returns { hex, txid, fee, oldFee, ... }. buildBump(prep, feeRate) { const pl = this.planBump(prep, feeRate); if (!pl.ok) throw new Error('Not enough funds to bump at this rate. Try a lower rate or CPFP.'); const allIns = [...prep.ins, ...pl.extra.map((u) => ({ txid: u.txid, vout: u.vout, value: u.value, chain: u.chain, index: u.index }))]; const t = new btc.Transaction(); for (const i of allIns) { const pay = p2wpkh(this.derive(i.chain, i.index).pubkey, this.netCfg.net); t.addInput({ ...pay, txid: i.txid, index: i.vout, sequence: 0xfffffffd, witnessUtxo: { script: pay.script, amount: BigInt(i.value) } }); } for (const r of prep.recipients) t.addOutputAddress(r.address, BigInt(r.value), this.netCfg.net); const changeAddr = this.freshChange().address; if (pl.change > 0) t.addOutputAddress(changeAddr, BigInt(pl.change), this.netCfg.net); for (let k = 0; k < allIns.length; k++) t.signIdx(this.node(allIns[k].chain, allIns[k].index).privateKey, k); t.finalize(); const outputs = prep.recipients.map((r) => ({ address: r.address, value: r.value })); if (pl.change > 0) outputs.push({ address: changeAddr, value: pl.change, change: true }); return { hex: t.hex, txid: t.id, fee: pl.fee, oldFee: prep.oldFee, feeRate: pl.rate, outputs, replaces: prep.txid }; } // --- gift links (coins backing claimable presigned transactions) --------- // A gift coin is either RESERVED (set aside, skipped by coin selection) or // RECLAIMED (freed for spending, but its link stays live until the coin is // actually spent). Both are "outstanding"; both are persisted per-wallet. _giftKey() { return this._cacheKey() + ':gift'; } _reclaimedKey() { return this._cacheKey() + ':giftfree'; } _loadSet(key) { try { return new Set(JSON.parse(localStorage.getItem(key) || '[]')); } catch { return new Set(); } } reservedSet() { if (!this._reserved) this._reserved = this._loadSet(this._giftKey()); return this._reserved; } reclaimedSet() { if (!this._reclaimed) this._reclaimed = this._loadSet(this._reclaimedKey()); return this._reclaimed; } isReserved(id) { return this.reservedSet().has(id); } _saveReserved() { try { localStorage.setItem(this._giftKey(), JSON.stringify([...this.reservedSet()])); } catch {} } _saveReclaimed() { try { localStorage.setItem(this._reclaimedKey(), JSON.stringify([...this.reclaimedSet()])); } catch {} } // Passive reclaim: free the coin for spending but keep tracking the live link // so it can still be revoked later (until the coin is spent). unreserve(id) { this.reservedSet().delete(id); this.reclaimedSet().add(id); this._saveReserved(); this._saveReclaimed(); } // Truly revoke an unclaimed gift: spend its coin back into our own wallet, // which double-spends the gift's presigned input so the link can't be claimed // once this confirms. Uses a high fee to win any race with a claimer. async revokeGift(id, feeRate) { const draft = this.buildTx({ recipients: [{ address: this.freshChange().address }], feeRate, coinIds: [id], sendMax: true }); const hexTx = this.sign(draft.tx); const txid = await this.broadcast(hexTx); this.reservedSet().delete(id); this.reclaimedSet().delete(id); this._saveReserved(); this._saveReclaimed(); return txid; } // Outstanding gifts (reserved + reclaimed) whose coin is still unspent; prunes // any whose coin has since been claimed/spent. { id, reserved, value }. outstandingGifts() { const live = new Set(this.utxos.map((u) => utxoId(u))); const res = this.reservedSet(); const rec = this.reclaimedSet(); let changed = false; for (const id of [...res]) if (!live.has(id)) { res.delete(id); changed = true; } for (const id of [...rec]) if (!live.has(id)) { rec.delete(id); changed = true; } if (changed) { this._saveReserved(); this._saveReclaimed(); } return [...new Set([...res, ...rec])].map((id) => ({ id, reserved: res.has(id), value: (this.utxos.find((u) => utxoId(u) === id) || {}).value, })); } // Best-fit coin selection for a gift of `gift` sats (needs gift + a dust // change): the smallest single confirmed coin that covers it — one input and // the least value locked until claim — else the fewest large coins. Returns // the selected UTXOs, or null if confirmed funds are insufficient. _selectGiftCoins(gift) { const DUST = 294n; const need = gift + DUST; const avail = this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)); const single = avail.filter((u) => BigInt(u.value) >= need).sort((a, b) => a.value - b.value)[0]; if (single) return [single]; const sel = []; let sum = 0n; for (const u of [...avail].sort((a, b) => b.value - a.value)) { sel.push(u); sum += BigInt(u.value); if (sum >= need) return sel; } return null; // insufficient confirmed funds } // Sats that creating this gift right now would lock (the selected coins' // total), or null if confirmed funds are insufficient. Lets the UI warn when // the lock would dwarf the gift and offer to split a coin first. giftLockPreview(amountSats) { const sel = this._selectGiftCoins(BigInt(Math.round(amountSats))); return sel ? sel.reduce((s, u) => s + u.value, 0) : null; } // Build a signed gift from already-chosen coins. If the gift leaves a viable // (>= dust) change, output0 is that change back to us, cryptographically fixed // by signing input0 SIGHASH_SINGLE (the rest SIGHASH_NONE) — so our change is // locked while the claimer is free to add their own output. If there's no // viable change (a whole-balance gift), there's no output at all and every // input is SIGHASH_NONE, so the claimer sweeps the entire amount. Reserves the // coins; returns { code, amount, reserved } where amount is what the claimer // receives before their fee (the gift, or the whole swept sum). _buildGiftPsbt(sel, gift) { const DUST = 294n; const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n); const change = sum - gift; const keepChange = change >= DUST; const sigOf = (i) => (keepChange && i === 0 ? btc.SigHash.SINGLE : btc.SigHash.NONE); const t = new btc.Transaction({ allowUnknownOutputs: true }); sel.forEach((u, i) => { const pay = p2wpkh(this.derive(u.chain, u.index).pubkey, this.netCfg.net); t.addInput({ ...pay, txid: u.txid, index: u.vout, sighashType: sigOf(i), witnessUtxo: { script: pay.script, amount: BigInt(u.value) } }); }); if (keepChange) t.addOutputAddress(this.freshChange().address, change, this.netCfg.net); sel.forEach((u, i) => t.signIdx(this.node(u.chain, u.index).privateKey, i, [sigOf(i)])); for (const u of sel) this.reservedSet().add(utxoId(u)); this._saveReserved(); return { code: base32nopad.encode(_encodeCompactGift(t)), amount: Number(keepChange ? gift : sum), reserved: sel.map(utxoId) }; } // Gift the entire spendable balance: a no-change sweep of all unreserved // spendable coins (confirmed, plus our own unconfirmed change). The claimer // receives the whole amount minus their claim fee. createGiftAll(feeRate) { const rate = Math.max(1, Math.round(feeRate)); const sel = this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)); if (!sel.length) throw new Error('No coins available to gift.'); const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n); if (sum < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); return this._buildGiftPsbt(sel, sum); // change = 0 → no-change sweep } // Build a gift link from a best-fit coin, locking the least value. No fee is // reserved here: the claimer's wallet looks up the fee rate at claim time and // subtracts it from the amount. createGift(amountSats, feeRate) { const gift = BigInt(Math.round(amountSats)); const rate = Math.max(1, Math.round(feeRate)); if (gift < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); const sel = this._selectGiftCoins(gift); if (!sel) throw new Error('Not enough confirmed funds for that gift amount.'); return this._buildGiftPsbt(sel, gift); } // Split + gift in one shot, with no confirmation wait: broadcast a self-send // that carves out a gift+dust coin, then build the gift spending that // still-unconfirmed carve-out. The funding and the claim form a chain that // confirms together; the recipient just sees a pending balance until the // split confirms. Lets the sender lock only ~the gift amount (instead of a // whole large coin) without waiting for the split to confirm first. async createGiftFromSplit(amountSats, feeRate) { const gift = Math.round(amountSats); const rate = Math.max(1, Math.round(feeRate)); if (BigInt(gift) < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); const DUST = 294; const recvIndex = this.nextReceiveIndex; const carveAddr = this.derive(0, recvIndex).address; const changeIndex = this.nextChangeIndex; // Carve out exactly gift + dust to one of our own addresses; the rest comes // back as change. buildTx already spends confirmed coins largest-first. const draft = this.buildTx({ recipients: [{ address: carveAddr, amount: gift + DUST }], feeRate: rate }); const carveIdx = draft.outputs.findIndex((o) => o.address === carveAddr); if (carveIdx < 0) throw new Error('Could not size the split output.'); const hexTx = this.sign(draft.tx); const splitTxid = draft.tx.id; await this.broadcast(hexTx); // Wait until the carve-out is actually visible in the mempool before we // build the gift on top of it and reserve it. Otherwise a background refresh // could rebuild the address from a not-yet-indexed API response, transiently // drop the coin, and prune the reservation — risking a double-spend. let seen = false; for (let i = 0; i < 8 && !seen; i++) { await sleep(i === 0 ? 400 : 900); try { const us = await this.api.addressUtxos(carveAddr); seen = us.some((u) => u.txid === splitTxid && u.vout === carveIdx); } catch {} } if (!seen) throw new Error('The split is taking a while to propagate. Once it confirms, create the gift from the new coin.'); // Reflect the split in our coin set — drop the spent inputs, add the // (now mempool-visible) carve-out and change — so balances and gift tracking // are correct immediately; a background scan reconciles the rest. const spent = new Set(); for (let i = 0; i < draft.tx.inputsLength; i++) { const inp = draft.tx.getInput(i); spent.add(`${hex.encode(inp.txid)}:${inp.index}`); } this.utxos = this.utxos.filter((u) => !spent.has(utxoId(u))); this.utxos.push({ txid: splitTxid, vout: carveIdx, value: gift + DUST, address: carveAddr, chain: 0, index: recvIndex, confirmed: false }); const changeIdx = draft.outputs.findIndex((o, i) => i !== carveIdx); if (changeIdx >= 0) { const co = draft.outputs[changeIdx]; this.utxos.push({ txid: splitTxid, vout: changeIdx, value: co.amount, address: co.address, chain: 1, index: changeIndex, confirmed: false }); } this._recomputeBalanceFromUtxos(); return this._buildGiftPsbt([{ txid: splitTxid, vout: carveIdx, value: gift + DUST, chain: 0, index: recvIndex }], BigInt(gift)); } // --- realtime (mempool.space WebSocket) --------------------------------- // Pushes us new mempool/confirmed transactions for our addresses so history // and balances update with no polling. wsUrl() { // Electrum backend: pick the current failover candidate (rotated on failure). if (getBackend() === 'electrum' && this.netName !== 'testnet') { if (!this._wsCandidates || !this._wsCandidates.length) { this._wsCandidates = electrumCandidates(); this._wsCandIdx = 0; } const list = this._wsCandidates; return list.length ? list[(this._wsCandIdx || 0) % list.length] : null; } return wsUrl(this.netName); // coinos watcher, or null } // What realtime watches: the fresh frontier (next receive + next change), plus // any address holding an unconfirmed coin — so a pending deposit's confirmation // pushes instantly instead of waiting for a poll. Returns derivation targets // ({chain,index}); Electrum/Fulcrum handles many subscriptions, but our watched // set is naturally small so we cap generously. _watchedTargets() { const seen = new Set(); const out = []; const add = (chain, index) => { const k = chain + '/' + index; if (!seen.has(k)) { seen.add(k); out.push({ chain, index }); } }; add(0, this.nextReceiveIndex); add(1, this.nextChangeIndex); const stuck = this._stuckTxids(); for (const u of this.utxos) { if (out.length >= 25) break; if (!u.confirmed && !stuck.has(u.txid)) add(u.chain, u.index); } return out; } watchedAddresses() { return this._watchedTargets().map((t) => this.derive(t.chain, t.index).address); } // Electrum scripthash for one of our addresses: sha256(scriptPubKey), reversed, // hex. This is what Fulcrum subscribes on. _scripthash(chain, index) { const h = sha256(this.derive(chain, index).script); h.reverse(); return hex.encode(h); } watchedScripthashes() { // Also (re)build the scripthash -> derivation map so a data-carrying // notification can be credited without re-deriving or hitting REST. const map = new Map(); const shes = this._watchedTargets().map((t) => { const sh = this._scripthash(t.chain, t.index); map.set(sh, { chain: t.chain, index: t.index, address: this.derive(t.chain, t.index).address }); return sh; }); this._shToTarget = map; return shes; } // Apply a watcher push directly (no REST). The push carries the matched // outputs (vout + value), the confirmed flag, and — when confirmed — the block // time. Returns false only if we can't map the scripthash (caller then scans). _applyTxNotification(sh, data) { const tgt = this._shToTarget && this._shToTarget.get(sh); if (!tgt || !data || !Array.isArray(data.outputs)) return false; let changed = false; // (1) Confirmation of a tx we already track: flip its history row and coins // to confirmed. Works for incoming AND outgoing — it only flips flags (no new // coin), so there's no overcount risk regardless of chain. if (data.confirmed) { const tx = this.txs.find((t) => t.txid === data.txid); if (tx && !tx.confirmed) { tx.confirmed = true; if (data.blockTime) tx.blockTime = data.blockTime; changed = true; } for (const o of data.outputs) { const u = this.utxos.find((x) => utxoId(x) === `${data.txid}:${o.vout}`); if (u && !u.confirmed) { u.confirmed = true; const e = (u.chain === 0 ? this.receive : this.change).find((a) => a.index === u.index); if (e) { e.pending = Math.max(0, (e.pending || 0) - o.value); e.confirmed = (e.confirmed || 0) + o.value; } changed = true; } } } // (2) New incoming receive (chain 0 only): credit the coin + a pending // history row so the balance and "payment received" fire instantly. Change // (chain 1) is our own send — its spent input isn't in the push, so crediting // it alone would transiently overcount; the backstop poll reconciles it. if (tgt.chain === 0) { let entry = this.receive.find((e) => e.index === tgt.index); if (!entry) { entry = { chain: 0, index: tgt.index, address: tgt.address, used: false, confirmed: 0, pending: 0 }; this.receive.push(entry); this.addrMap.set(tgt.address, { chain: 0, index: tgt.index }); } let credited = 0; for (const o of data.outputs) { if (this.utxos.find((u) => utxoId(u) === `${data.txid}:${o.vout}`)) continue; this.utxos.push({ txid: data.txid, vout: o.vout, value: o.value, address: tgt.address, chain: 0, index: tgt.index, confirmed: !!data.confirmed }); entry.used = true; if (data.confirmed) entry.confirmed = (entry.confirmed || 0) + o.value; else entry.pending = (entry.pending || 0) + o.value; credited += o.value; changed = true; } if (credited > 0 && !this.txs.find((t) => t.txid === data.txid)) { this.txs.push({ txid: data.txid, net: credited, fee: 0, vsize: 0, confirmed: !!data.confirmed, blockTime: data.blockTime || 0, blockHeight: 0, firstSeen: data.confirmed ? 0 : Date.now() }); } } if (!changed) return true; // nothing new (duplicate push) — handled, no scan this.nextReceiveIndex = firstUnused(this.receive); this.nextChangeIndex = firstUnused(this.change); this.utxos.sort((a, b) => b.value - a.value); this._sortTxs(); this._recomputeBalanceFromUtxos(); this.loaded = true; this.saveCache(); this.retrack(); // a new frontier address may need subscribing this.emit(); // UI updates NOW; the 30s backstop poll fills blockHeight/fee return true; } startRealtime() { if (this.offline) return; this.stopRealtime(); this._wsCandidates = null; // rebuild the failover list fresh (picks up config), prefer primary this._wsCandIdx = 0; // Frontier poll — now purely a safety net. The watcher pushes incoming // payments AND confirmations instantly with full data (credited via the WS // with no REST), so when the socket is live we poll rarely (~30s) just in // case a push was missed. When the socket is DOWN we poll faster: ~5s on the // Receive tab (user is actively waiting) or while a coin is unconfirmed, else // ~10s. This keeps mempool.space REST (and its 429s) to a trickle. this._pollTimer = setInterval(() => { if (this.offline) return; const stuck = this._stuckTxids(); const pending = this.utxos.some((u) => !u.confirmed && !stuck.has(u.txid)); let due; if (this.live) { due = 30000; // WS handles the real work; this is a backstop only } else if (this._watchReceive || pending) { due = 5000; // socket down + actively waiting → poll responsively } else { due = 10000; } if (Date.now() - (this._lastPoll || 0) < due) return; this._lastPoll = Date.now(); this.refreshLive(); }, 1000); // No periodic full re-scan: the WS (and the frontier poll above) keep the // balance current, and refreshLive's _reconcileHeld catches spends of held // coins. The only thing neither covers is a deposit to a reused old address, // which is handled on demand via per-address rescan in Settings. // Only mempool.space has a live socket; other explorers rely on the poll // above (wsUrl returns null → no socket). if (typeof WebSocket === 'undefined' || !this.wsUrl()) return; this._wsWant = true; // Heartbeat: ping every 15s; if no message (incl. pong) for 45s the socket // is half-open (died without firing onclose) — force a reconnect. this._hbTimer = setInterval(() => { if (this.offline || !this._ws || this._ws.readyState !== 1) return; if (Date.now() - this._lastMsg > 45000) { this._reconnectNow(); } else { this._rpcSend('server.ping', []); } }, 15000); // Reconnect + refresh when the tab refocuses or the network returns. if (!this._wakeHooked && typeof document !== 'undefined') { this._wakeHooked = true; const wake = () => { if (this.offline || !this._wsWant) return; if (!this._ws || this._ws.readyState !== 1) this._reconnectNow(); this.scan({ silent: true }).catch(() => {}); }; document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') wake(); }); window.addEventListener('online', wake); } this._connectWs(); } _reconnectNow() { clearTimeout(this._wsRetry); if (this._ws) { try { this._ws.onclose = null; this._ws.close(); } catch {} this._ws = null; } this.live = false; this._connectWs(); } _connectWs() { if (!this._wsWant) return; let ws; try { ws = new WebSocket(this.wsUrl()); } catch { this._scheduleReconnect(); return; } this._ws = ws; this._wsBuf = ''; // partial-line accumulator (newline-delimited JSON-RPC) this._subscribed = new Set(); // scripthashes subscribed on this connection this._wsOpenedAt = 0; // set on open; used to judge a healthy connection for failover if (this._rpcId == null) this._rpcId = 0; // monotonic across reconnects (don't alias pending ids) ws.onopen = () => { this.live = true; this._wsOpenedAt = Date.now(); this._wsBackoff = 0; // healthy again — reset the reconnect backoff this._lastMsg = Date.now(); // Electrum handshake, then subscribe our watched scripthashes. this._rpcSend('server.version', ['halwallet', '1.4']); // Flush any data calls queued while the socket was down. if (this._callQueue && this._callQueue.length) { for (const payload of this._callQueue) { try { ws.send(payload); } catch {} } this._callQueue = []; } this.retrack(); this.emit(); // The server only pushes activity AFTER we subscribe, so reconcile once on // open to catch anything that landed while we were disconnected. this.refreshLive().catch(() => {}); }; ws.onmessage = (ev) => { this._lastMsg = Date.now(); this._wsBuf += (typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data)); // Robustly extract every complete top-level JSON object from the buffer, // regardless of framing: newline-delimited (coinos watcher), one-per-frame // without a newline (electrs), several concatenated in one frame, or a // single object split across frames. Brace-depth scan, string-aware. const s = this._wsBuf; let depth = 0, inStr = false, esc = false, start = -1, consumed = 0; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; } if (ch === '"') inStr = true; else if (ch === '{') { if (depth === 0) start = i; depth++; } else if (ch === '}' && depth > 0) { if (--depth === 0 && start >= 0) { this._handleRpc(s.slice(start, i + 1)); consumed = i + 1; start = -1; } } } this._wsBuf = s.slice(consumed); if (this._wsBuf.length > 1 << 20) this._wsBuf = ''; // runaway guard }; ws.onerror = () => { try { ws.close(); } catch {} }; ws.onclose = () => { this.live = false; this._ws = null; this.emit(); this._scheduleReconnect(); }; } // Send an Electrum JSON-RPC request (fire-and-forget; we react to notifications, // not responses). Newline-terminated per the protocol. _rpcSend(method, params) { if (!this._ws || this._ws.readyState !== 1) return; const id = ++this._rpcId; try { this._ws.send(JSON.stringify({ id, method, params }) + '\n'); } catch {} return id; } // Electrum request/response (used by the ElectrumApi data backend). Resolves // when the matching id comes back; queues until the socket is open, and times // out so a wedged call can't hang a scan. ids are monotonic for the session so // they never alias a fire-and-forget _rpcSend id. _rpcCall(method, params) { return new Promise((resolve, reject) => { if (this.offline) { reject(new Error('offline')); return; } const id = ++this._rpcId; if (!this._pending) this._pending = new Map(); const timer = setTimeout(() => { if (this._pending.delete(id)) reject(new Error('electrum timeout: ' + method)); }, 20000); this._pending.set(id, { resolve, reject, timer }); const payload = JSON.stringify({ id, method, params }) + '\n'; if (this._ws && this._ws.readyState === 1) { try { this._ws.send(payload); } catch {} } else { (this._callQueue = this._callQueue || []).push(payload); } }); } _rejectPending(reason) { if (!this._pending) return; for (const { reject, timer } of this._pending.values()) { clearTimeout(timer); try { reject(new Error(reason)); } catch {} } this._pending.clear(); } // A notification means a tx touched one of our addresses (a deposit, or a // confirmation). If it carries the tx data (our watcher does), credit it // instantly with no REST on the critical path; otherwise just trigger a scan. _handleRpc(line) { let msg; try { msg = JSON.parse(line); } catch { return; } if (!msg) return; // Response to a data call (ElectrumApi backend)? if (msg.id != null && this._pending && this._pending.has(msg.id)) { const p = this._pending.get(msg.id); this._pending.delete(msg.id); clearTimeout(p.timer); if (msg.error) p.reject(new Error(typeof msg.error === 'string' ? msg.error : (msg.error.message || JSON.stringify(msg.error)))); else p.resolve(msg.result); return; } // Subscription notification: a tx touched a watched address. Our coinos // watcher carries the tx data (credit instantly); a real Electrum server // sends only [scripthash, status], so reconcile via the data backend. if (msg.method !== 'blockchain.scripthash.subscribe') return; const sh = msg.params && msg.params[0]; if (msg.data && sh && this._applyTxNotification(sh, msg.data)) return; this._scheduleRefresh(); } // Subscribe any watched scripthashes not yet subscribed on this connection. // Reset on reconnect (_subscribed is cleared in _connectWs), so a fresh socket // re-subscribes the full set. retrack() { if (!this._ws || this._ws.readyState !== 1) return; if (!this._subscribed) this._subscribed = new Set(); for (const sh of this.watchedScripthashes()) { if (this._subscribed.has(sh)) continue; this._subscribed.add(sh); this._rpcSend('blockchain.scripthash.subscribe', [sh]); } } _scheduleReconnect() { if (!this._wsWant) return; // Electrum failover: if this connection never became healthy (failed to open, // or died within a few seconds), advance to the next candidate server. if (getBackend() === 'electrum' && this._wsCandidates && this._wsCandidates.length > 1) { if (!this._wsOpenedAt || Date.now() - this._wsOpenedAt < 5000) this._wsCandIdx = (this._wsCandIdx || 0) + 1; } clearTimeout(this._wsRetry); // Exponential backoff starting small, so a first attempt that's transiently // rejected (e.g. a stale rate-limit) retries in ~0.6s instead of a flat 4s — // the wallet reaches Live quickly. Reset to 0 on a successful open. this._wsBackoff = Math.min((this._wsBackoff || 0) + 1, 6); const delay = Math.min(600 * 2 ** (this._wsBackoff - 1), 15000); this._wsRetry = setTimeout(() => this._connectWs(), delay); } // Debounced: a payment may touch several of our addresses in one go. // Reconcile incrementally (frontier only) — never re-scans old coins. _scheduleRefresh() { clearTimeout(this._refreshTimer); this._refreshTimer = setTimeout(async () => { try { await this.refreshLive(); this.retrack(); } catch {} }, 400); } stopRealtime() { this._wsWant = false; this.live = false; this._rejectPending('realtime stopped'); // fail any in-flight Electrum data calls this._callQueue = []; clearTimeout(this._wsRetry); clearTimeout(this._refreshTimer); clearInterval(this._pollTimer); clearInterval(this._deepTimer); clearInterval(this._hbTimer); clearTimeout(this._nostrPubTimer); if (this._ws) { try { this._ws.onclose = null; this._ws.close(); } catch {} this._ws = null; } } // --- local history cache ------------------------------------------------ // Cache the scanned state in localStorage, keyed by a hash of the seed (the // seed itself is never stored). Re-importing the same seed in this browser // then shows the last-known balance/history instantly while a fresh scan // runs in the background. _cacheKey() { const id = this.watchOnly ? this.xpub : this.xprv ? this.xprv : `${this.mnemonic}\n${this.passphrase}`; const bytes = new TextEncoder().encode(id); return 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32); } // The fresh receive index the "payment received" screen has been shown for. // Persisted (and advanced as soon as the celebration appears) so a payment // celebrates once and never reappears on a refresh or reopen. getReceiveAck() { try { const v = localStorage.getItem(this._cacheKey() + ':ack'); return v == null ? null : Number(v); } catch { return null; } } setReceiveAck(i) { try { localStorage.setItem(this._cacheKey() + ':ack', String(i)); } catch {} } // The serializable wallet state, shared by the localStorage cache and the // Nostr sync. savedAt lets us pick the newest copy across devices. _snapshot() { return { v: 1, savedAt: Date.now(), receive: this.receive, change: this.change, utxos: this.utxos, txs: this.txs, nextReceiveIndex: this.nextReceiveIndex, nextChangeIndex: this.nextChangeIndex, feeRates: this.feeRates, }; } _applySnapshot(d) { this.receive = d.receive || []; this.change = d.change || []; this.utxos = d.utxos || []; this.txs = d.txs || []; this.nextReceiveIndex = d.nextReceiveIndex || 0; this.nextChangeIndex = d.nextChangeIndex || 0; this.feeRates = d.feeRates || this.feeRates; this.addrMap = new Map(); for (const a of [...this.receive, ...this.change]) { this.addrMap.set(a.address, { chain: a.chain, index: a.index }); } this._recomputeBalanceFromChains(); this._savedAt = d.savedAt || 0; this.loaded = true; } // A cache key derived from the account xpub (not the seed) — the same key a // watch-only load of this wallet would use. A full wallet also writes its cache // here so that, if the session is wiped and the wallet reopens as watch-only, // its balance/history shows instantly instead of needing a rescan. _xpubCacheKey() { const bytes = new TextEncoder().encode(this.accountXpub()); return 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32); } saveCache() { const snap = this._snapshot(); this._savedAt = snap.savedAt; try { localStorage.setItem(this._cacheKey(), JSON.stringify(snap)); const xk = this._xpubCacheKey(); if (xk !== this._cacheKey()) localStorage.setItem(xk, JSON.stringify(snap)); // watch-only mirror } catch {} // Push to the configured relays too (debounced), so other devices get the // update — unless cross-device sync is turned off. const sync = getSyncConfig(); if (!this.offline && sync.enabled) { this.nostr.setRelays(sync.relays); clearTimeout(this._nostrPubTimer); this._nostrPubTimer = setTimeout(() => this.nostr.publish(snap), 2500); } } restoreCache() { try { let raw = localStorage.getItem(this._cacheKey()); // A wallet just upgraded from watch-only (seed re-entered) has no seed-keyed // cache yet — fall back to the xpub mirror so it shows instantly. if (!raw && !this.watchOnly) raw = localStorage.getItem(this._xpubCacheKey()); if (!raw) return false; this._applySnapshot(JSON.parse(raw)); this.emit(); return true; } catch { return false; } } // Pull the latest state from Nostr; apply it if it's newer than what we have. // Returns true if state was applied (so the caller can skip a full scan). async syncFromNostr() { const sync = getSyncConfig(); if (this.offline || !sync.enabled) return false; this.nostr.setRelays(sync.relays); let remote; try { remote = await this.nostr.fetch(); } catch { return false; } if (!remote) return false; if ((remote.savedAt || 0) > (this._savedAt || 0)) { this._applySnapshot(remote); this.saveCache(); // mirror into localStorage this.emit(); return true; } // Our local copy is newer (or equal) — push it up so the relay catches up. if ((this._savedAt || 0) > (remote.savedAt || 0)) this.saveCache(); return true; // remote existed, so no full scan needed } // --- offline snapshot --------------------------------------------------- // Exported on the ONLINE device. Contains no secrets — just what an offline // signer needs: coins, fee rates, and which addresses are next/fresh. exportSnapshot() { return { app: 'bitcoin-wallet', version: 1, netName: this.netName, exportedAt: new Date().toISOString(), feeRates: this.feeRates, nextReceiveIndex: this.nextReceiveIndex, nextChangeIndex: this.nextChangeIndex, utxos: this.utxos.map((u) => ({ txid: u.txid, vout: u.vout, value: u.value, address: u.address, confirmed: u.confirmed, })), }; } // Imported on the OFFLINE device (which already has the seed loaded). importSnapshot(snap) { if (!snap || !Array.isArray(snap.utxos)) throw new Error('Not a valid wallet snapshot file.'); if (snap.netName && snap.netName !== this.netName) throw new Error( `Snapshot is for ${snap.netName} but wallet is ${this.netName}.` ); this.feeRates = snap.feeRates || this.feeRates; this.nextReceiveIndex = snap.nextReceiveIndex || 0; this.nextChangeIndex = snap.nextChangeIndex || 0; // Derive a generous window so every UTXO address resolves to a path. const maxIdx = snap.utxos.reduce((m, _u) => m, 0); const window = Math.max(this.nextReceiveIndex, this.nextChangeIndex, maxIdx) + GAP_LIMIT + 5; this.deriveWindow(window); const utxos = []; const unmatched = []; for (const u of snap.utxos) { const path = this.addrMap.get(u.address); if (!path) { unmatched.push(u.address); continue; } utxos.push({ txid: u.txid, vout: u.vout, value: u.value, address: u.address, chain: path.chain, index: path.index, confirmed: !!u.confirmed, }); } utxos.sort((a, b) => b.value - a.value); this.utxos = utxos; this._recomputeBalanceFromUtxos(); this.loaded = true; this.emit(); return { imported: utxos.length, unmatched }; } } export function utxoId(u) { return `${u.txid}:${u.vout}`; } // Watch-only: accept a native-segwit account xpub or zpub and normalize it to // xpub version bytes (the key material is identical; only the prefix differs), // so HDKey can load it. Throws on anything else (private keys, wrong type). const _b58c = base58check(sha256); const _XPUB_VER = Uint8Array.from([0x04, 0x88, 0xb2, 0x1e]); const _ZPUB_VER = Uint8Array.from([0x04, 0xb2, 0x47, 0x46]); const _XPRV_VER = Uint8Array.from([0x04, 0x88, 0xad, 0xe4]); const _ZPRV_VER = Uint8Array.from([0x04, 0xb2, 0x43, 0x0c]); // Classify a pasted extended key. xpub/zpub → public (watch-only); xprv/zprv → // private (spending). Version bytes are normalized to the standard xpub/xprv set // (key material is identical; only the prefix differs) so HDKey can load it. // Returns { kind: 'xpub' | 'xprv', key } or throws. export function parseExtendedKey(s) { let data; try { data = _b58c.decode((s || '').trim()); } catch { throw new Error('Not a valid recovery phrase or key.'); } if (data.length !== 78) throw new Error('Not a valid recovery phrase or key.'); const ver = hex.encode(data.slice(0, 4)); let kind, norm; if (ver === hex.encode(_XPUB_VER) || ver === hex.encode(_ZPUB_VER)) { kind = 'xpub'; norm = _XPUB_VER; } else if (ver === hex.encode(_XPRV_VER) || ver === hex.encode(_ZPRV_VER)) { kind = 'xprv'; norm = _XPRV_VER; } else throw new Error('Unrecognized key type — use a native-segwit xpub/zpub or xprv/zprv.'); const out = new Uint8Array(data); out.set(norm, 0); const key = _b58c.encode(out); try { HDKey.fromExtendedKey(key); } catch { throw new Error('Not a valid extended key.'); } return { kind, key }; } // Encrypted vault for persisting seed-bearing accounts on this device. Key is // scrypt(password, salt); payload is XChaCha20-Poly1305 (authenticated, so a // wrong password fails to decrypt rather than returning garbage). All fields // are hex so the blob is JSON-serializable for localStorage. const _SCRYPT = { N: 2 ** 15, r: 8, p: 1, dkLen: 32 }; function _vaultKey(password, salt) { return scrypt(utf8ToBytes(password), salt, _SCRYPT); } export function encryptVault(obj, password) { const salt = randomBytes(16); const nonce = randomBytes(24); const ct = xchacha20poly1305(_vaultKey(password, salt), nonce).encrypt(utf8ToBytes(JSON.stringify(obj))); return { v: 1, salt: hex.encode(salt), nonce: hex.encode(nonce), ct: hex.encode(ct) }; } export function decryptVault(blob, password) { const pt = xchacha20poly1305(_vaultKey(password, hex.decode(blob.salt)), hex.decode(blob.nonce)).decrypt(hex.decode(blob.ct)); return JSON.parse(bytesToUtf8(pt)); } // Convert a standard account xpub to a BIP84 zpub for export/interop. export function xpubToZpub(xpub) { const data = new Uint8Array(_b58c.decode(xpub)); data.set(_ZPUB_VER, 0); return _b58c.encode(data); } // Gift-link claiming (sender side is Wallet.createGift). A gift code is a // base64url PSBT: one SIGHASH_SINGLE-signed input + the sender's change output. // previewGift reports the room available to the claimer (before their fee); // buildClaimTx adds the claimer's output and finalizes a broadcastable tx. // Smallest sensible gift at a given fee rate: dust + one claim fee of headroom // (floored at 546 sats), so the recipient clears dust even if fees climb. export function giftMinimum(feeRate) { const rate = Math.max(1, Math.round(feeRate)); const claimFee = Math.ceil((11 + 68 + 31 * 2) * rate); return Math.max(546, 294 + claimFee); } // ---- compact gift codec ---------------------------------------------------- // A gift link used to carry a full PSBT (verbose). The claimer only needs the // presigned inputs (outpoint, amount, pubkey, signature) and the sender's // committed change output, so we serialize just those — ~25% smaller, and (via // base32 + an uppercase /g/ URL) it scans in the QR's denser alphanumeric mode. // Legacy base64url-PSBT links still claim; _giftTx auto-detects the format. const GIFT_MAGIC = 0x01; const _u8 = (n) => Uint8Array.of(n & 0xff); const _le32 = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n >>> 0, true); return b; }; const _le64 = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }; function _encodeCompactGift(tx) { const hasChange = tx.outputsLength > 0; const parts = [_u8(GIFT_MAGIC), _u8(hasChange ? 1 : 0), _u8(tx.inputsLength)]; for (let i = 0; i < tx.inputsLength; i++) { const inp = tx.getInput(i); const [pubkey, sig] = inp.partialSig[0]; // pubkey(33), sig = DER + sighash byte parts.push(inp.txid, _le32(inp.index), _le64(inp.witnessUtxo.amount), pubkey, _u8(sig.length), sig); } if (hasChange) { const o = tx.getOutput(0); // p2wpkh change: script = [00 14 <20-byte hash>] parts.push(_le64(o.amount), o.script.slice(2)); } return concatBytes(...parts); } function _decodeCompactGift(bytes) { let p = 0; const rd = (n) => bytes.slice(p, (p += n)); const r32 = () => { const v = new DataView(bytes.buffer, bytes.byteOffset + p, 4).getUint32(0, true); p += 4; return v; }; const r64 = () => { const v = new DataView(bytes.buffer, bytes.byteOffset + p, 8).getBigUint64(0, true); p += 8; return v; }; p++; // magic const hasChange = !!(bytes[p++] & 1); const n = bytes[p++]; const t = new btc.Transaction({ allowUnknownOutputs: true }); for (let i = 0; i < n; i++) { const txid = rd(32), vout = r32(), amount = r64(), pubkey = rd(33), sigLen = bytes[p++], sig = rd(sigLen); const pay = p2wpkh(pubkey, btc.NETWORK); // witness-program .script is network-independent // second arg true: re-add an already-signed input without tripping the sign-status guard t.addInput({ txid, index: vout, witnessUtxo: { script: pay.script, amount }, sighashType: sig[sig.length - 1], partialSig: [[pubkey, sig]] }, true); } if (hasChange) { const amount = r64(); const hash = rd(20); t.addOutput({ script: concatBytes(_u8(0x00), _u8(0x14), hash), amount }, true); } return t; } // Parse a gift code to a Transaction. New format: base32(compact). Legacy: // base64url(PSBT). Auto-detected via the compact magic byte. function _giftTx(code) { try { const b = base32nopad.decode(code.toUpperCase()); if (b[0] === GIFT_MAGIC) return _decodeCompactGift(b); } catch {} return btc.Transaction.fromPSBT(base64urlnopad.decode(code)); } function _sumInputs(tx) { let inAmt = 0n; for (let i = 0; i < tx.inputsLength; i++) inAmt += tx.getInput(i).witnessUtxo.amount; return inAmt; } // The sender's change committed in a gift PSBT, or 0 for a no-change (whole- // balance) gift that has no outputs at all. function _giftChange(tx) { return tx.outputsLength > 0 ? tx.getOutput(0).amount : 0n; } // The gift's funding outpoints (the coins its presigned inputs spend). If any is // already spent — even by an unconfirmed claim — the gift has been taken and // re-claiming would be a doomed double-spend. Used to gate the claim screen. export function giftOutpoints(code) { try { const tx = _giftTx(code); const out = []; for (let i = 0; i < tx.inputsLength; i++) { const inp = tx.getInput(i); out.push({ txid: hex.encode(inp.txid), vout: inp.index }); } return out; } catch { return []; } } export function previewGift(code) { try { const tx = _giftTx(code); // room is the full amount the claimer receives (inputs minus our change); // the claim fee is subtracted from it at claim time, so report inputs too // so the caller can size that fee for this PSBT. return { room: Number(_sumInputs(tx) - _giftChange(tx)), inputs: tx.inputsLength }; } catch { return null; } } export function buildClaimTx(code, toAddress, feeRate, net) { const tx = _giftTx(code); const room = _sumInputs(tx) - _giftChange(tx); const fee = BigInt(Math.max(1, Math.ceil((11 + 68 * tx.inputsLength + 31 * 2) * Math.max(1, Math.round(feeRate))))); const out = room - fee; if (out < 294n) throw new Error('Gift is too small to claim at this fee rate.'); tx.addOutputAddress(toAddress, out, net); tx.finalize(); return { hex: tx.hex, txid: tx.id, amount: Number(out), fee: Number(fee) }; } function firstUnused(chain) { const u = chain.find((a) => !a.used); return u ? u.index : chain.length; } function summarize(sel, network) { const outputs = sel.tx.outputs.map((o) => ({ address: btc.Address(network).encode(btc.OutScript.decode(o.script)), amount: Number(o.amount), })); return { tx: sel.tx, fee: Number(sel.fee), hasChange: !!sel.change, inputsCount: sel.tx.inputsLength, outputs, weight: sel.weight, vsize: Math.ceil(Number(sel.weight) / 4), }; }