From 6c817756c5cca849864119405be9fa3f9e968057 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 14:34:59 -0700 Subject: Add BIP-352 receiving crypto (scan, tweak, keys, address) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit silentpay.js gains the receiving half of silent payments, validated against all official BIP-352 vectors (29/29 receiving sub-cases): - inputPubKey(): recover the eligible input pubkey from P2PKH/P2WPKH/P2SH-P2WPKH/ P2TR inputs (skips uncompressed keys, excludes NUMS script-path, hash160-matches malleated P2PKH scriptSigs) - silentPaymentTweak(): the oracle tweak input_hash*A (sum over eligible inputs, input hash over all outpoints, point-at-infinity guarded) — used by the indexer - silentPaymentScan(): ecdh = scan_priv*tweak, derive P_k = B_spend + t_k*G, match the tx's taproot outputs, return per-output spend tweaks - silentPaymentOutputPrivKey(), deriveSilentPaymentKeys() (m/352' paths), encodeSilentPaymentAddress() Labels not yet implemented (wallet uses a single no-label address). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/silentpay.js | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) (limited to 'src') diff --git a/src/silentpay.js b/src/silentpay.js index 0700999..1a58a41 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -17,9 +17,12 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils'; import { bech32m } from '@scure/base'; +const hash160 = (b) => ripemd160(sha256(b)); + const Point = secp256k1.ProjectivePoint; const G = Point.BASE; const N = secp256k1.CURVE.n; @@ -109,3 +112,177 @@ export function silentPaymentScripts(inputs, outputs) { export function silentPaymentPlaceholder(index) { return taprootScript(sha256(concatBytes(new TextEncoder().encode('halsp-placeholder'), be32(index)))); } + +// =========================================================================== +// Receiving (BIP-352). +// +// A browser light client can't scan the chain itself, so an indexing server (an +// SP "oracle") provides, per block, the tweaks — one 33-byte point (input_hash·A, +// the sum of a tx's eligible input pubkeys folded with its input hash) per +// eligible transaction — plus the taproot UTXOs created in that block. The +// receiver then does the private part on-device: ecdh = b_scan · tweak, then for +// k = 0,1,… derives P_k = B_spend + t_k·G and matches it against the block's +// outputs. The scan key never leaves the device. +// +// The first half here (input pubkey extraction + tweak) is what the indexer +// computes from raw blocks; the second half (scan, output key, key derivation, +// address) is what the wallet uses. They share one implementation so the math +// is validated once, against the official BIP-352 vectors. +// =========================================================================== + +const numTo32 = (x) => hexToBytes(x.toString(16).padStart(64, '0')); + +// secp256k1 NUMS point H (BIP-341): a taproot input whose script-path reveals +// this as the internal key is provably unspendable and NOT an eligible input. +const NUMS_H = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'; + +// Parse a serialized witness (hex): compact-size item count, then each item as +// compact-size length + bytes. +function parseWitness(hex) { + if (!hex) return []; + const b = hexToBytes(hex); + let o = 0; + const rd = () => { + let n = b[o++]; + if (n === 0xfd) { n = b[o] | (b[o + 1] << 8); o += 2; } + else if (n === 0xfe) { n = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; } + return n; + }; + const count = rd(); + const items = []; + for (let i = 0; i < count; i++) { const len = rd(); items.push(b.slice(o, o + len)); o += len; } + return items; +} + +// Pull the data pushes out of a scriptSig (hex) — enough to recover the pubkey +// from P2PKH or the redeemScript from P2SH (push opcodes only). +function scriptPushes(hex) { + if (!hex) return []; + const b = hexToBytes(hex); + let o = 0; + const pushes = []; + while (o < b.length) { + const op = b[o++]; + let len = -1; + if (op >= 0x01 && op <= 0x4b) len = op; + else if (op === 0x4c) len = b[o++]; + else if (op === 0x4d) { len = b[o] | (b[o + 1] << 8); o += 2; } + else if (op === 0x4e) { len = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; } + else continue; // non-push opcode + if (len < 0 || o + len > b.length) break; + pushes.push(b.slice(o, o + len)); o += len; + } + return pushes; +} + +// Only compressed pubkeys are eligible — uncompressed-key inputs are skipped. +const looksLikePubKey = (b) => !!b && b.length === 33 && (b[0] === 0x02 || b[0] === 0x03); + +// Recover the BIP-352-eligible input public key (33-byte compressed) from a tx +// input, or null if the input type isn't eligible. Handles P2PKH, P2WPKH, +// P2SH-P2WPKH and P2TR (key-path) inputs. +export function inputPubKey({ scriptSig = '', witness = '', scriptPubKey = '' }) { + try { + const spk = hexToBytes(scriptPubKey); + // P2TR: OP_1 PUSH32 + if (spk.length === 34 && spk[0] === 0x51 && spk[1] === 0x20) { + let w = parseWitness(witness); + if (w.length > 1 && w[w.length - 1][0] === 0x50) w = w.slice(0, -1); // strip annex + if (w.length > 1 && bytesToHex(w[w.length - 1].slice(1, 33)) === NUMS_H) return null; // NUMS script-path + return Point.fromHex(concatBytes(Uint8Array.of(0x02), spk.slice(2, 34))).toRawBytes(true); // even-Y lift + } + // P2WPKH: OP_0 PUSH20 + if (spk.length === 22 && spk[0] === 0x00 && spk[1] === 0x14) { + const w = parseWitness(witness); + const pk = w[w.length - 1]; + return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; + } + // P2SH-P2WPKH: scriptSig pushes redeemScript 0014<20>; witness = [sig, pubkey] + if (spk.length === 23 && spk[0] === 0xa9 && spk[1] === 0x14 && spk[22] === 0x87) { + const rs = scriptPushes(scriptSig)[0]; + if (!rs || rs.length !== 22 || rs[0] !== 0x00 || rs[1] !== 0x14) return null; + const w = parseWitness(witness); + const pk = w[w.length - 1]; + return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; + } + // P2PKH: OP_DUP OP_HASH160 PUSH20 ... — the eligible pubkey is the + // scriptSig push whose hash160 matches (handles malleated scriptSigs). + if (spk.length === 25 && spk[0] === 0x76 && spk[1] === 0xa9 && spk[2] === 0x14) { + const target = bytesToHex(spk.slice(3, 23)); + const pushes = scriptPushes(scriptSig); + for (let i = pushes.length - 1; i >= 0; i--) { + const pk = pushes[i]; + if (looksLikePubKey(pk) && bytesToHex(hash160(pk)) === target) return Point.fromHex(pk).toRawBytes(true); + } + return null; + } + } catch {} + return null; +} + +// The oracle tweak for a transaction: 33-byte point input_hash·A, where A is the +// sum of the eligible input pubkeys and input_hash = H_BIP0352/Inputs(outpoint_L +// || A) with outpoint_L the smallest outpoint among ALL the tx's inputs. +// inputs: [{ txid (display hex), vout, pubkey? (Uint8Array 33) }] — every input +// of the tx; `pubkey` present only for BIP-352-eligible inputs. +// Returns null if the tx has no eligible inputs (so it can't be a silent payment). +export function silentPaymentTweak(inputs) { + let A = null; + for (const i of inputs) if (i.pubkey) A = A ? A.add(Point.fromHex(i.pubkey)) : Point.fromHex(i.pubkey); + if (!A) return null; + let Abytes; + try { Abytes = A.toRawBytes(true); } catch { return null; } // A is the point at infinity → not a silent payment + let smallest = null; + for (const i of inputs) { + const op = concatBytes(hexToBytes(i.txid).reverse(), le32(i.vout)); + if (!smallest || bytesToHex(op) < bytesToHex(smallest)) smallest = op; + } + const inputHash = modN(toInt(tagged('BIP0352/Inputs', concatBytes(smallest, Abytes)))); + return A.multiply(inputHash).toRawBytes(true); +} + +// Scan a transaction's taproot outputs for payments to us. +// scanPriv: Uint8Array(32) our scan private key (b_scan) +// spendPub: Uint8Array(33) our spend public key (B_spend) +// tweak: Uint8Array(33) the oracle tweak for this tx (input_hash·A) +// outputs: array of 32-byte x-only output pubkeys (hex) in the tx +// Returns the matched outputs: [{ output (x-only hex), k, tweak (t_k hex 32) }]; +// `tweak` (t_k) is added to the spend private key to spend the coin. +export function silentPaymentScan({ scanPriv, spendPub, tweak, outputs, maxK = 30 }) { + const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true); + const B = Point.fromHex(spendPub); + const remaining = new Set(outputs.map((o) => o.toLowerCase())); + const found = []; + for (let k = 0; k < maxK && remaining.size; k++) { + const tk = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(k))))); + const xonly = bytesToHex(B.add(G.multiply(tk)).toRawBytes(true).slice(1)); + if (!remaining.has(xonly)) break; // no output at this k → done with this tx + found.push({ output: xonly, k, tweak: bytesToHex(numTo32(tk)) }); + remaining.delete(xonly); + } + return found; +} + +// Spend private key for a found SP output: d = (spend_priv + t_k) mod n. Used as +// the taproot key-path key (the signer negates for odd-Y per BIP-340). +export function silentPaymentOutputPrivKey(spendPriv, tweakK) { + return numTo32(modN(toInt(spendPriv) + toInt(tweakK))); +} + +// Derive the BIP-352 scan + spend keys from a BIP32 master node. +// spend: m/352'/coin'/account'/0'/0 scan: m/352'/coin'/account'/1'/0 +export function deriveSilentPaymentKeys(master, coin, account = 0) { + const spend = master.derive(`m/352'/${coin}'/${account}'/0'/0`); + const scan = master.derive(`m/352'/${coin}'/${account}'/1'/0`); + return { + scanPriv: scan.privateKey, scanPub: scan.publicKey, + spendPriv: spend.privateKey, spendPub: spend.publicKey, + }; +} + +// Encode an sp1…/tsp1… address from scan + spend public keys (33 bytes each). +export function encodeSilentPaymentAddress(scanPub, spendPub, { testnet = false } = {}) { + const hrp = testnet ? 'tsp' : 'sp'; + const words = [0, ...bech32m.toWords(concatBytes(scanPub, spendPub))]; + return bech32m.encode(hrp, words, 1023); +} -- cgit v1.2.3 From 1beae75ebd34142cc63342fbf7c8a798af45bb5a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 14:42:11 -0700 Subject: Add regtest SP tweak indexer; accept Core's array-witness in inputPubKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/sp-indexer.js: a minimal BIP-352 oracle (Bun) that walks Core and serves, per block, one entry per eligible tx — the tweak (input_hash*A) plus that tx's taproot outputs — for the wallet to scan on-device. Shares src/silentpay.js so the tweak math is the vector-validated code. Added to the regtest compose as the 'sp' service (oven/bun, pointed at bc:18443). inputPubKey now accepts a witness as either serialized hex (vectors) or an array of stack items (Core getblock RPC). Verified end-to-end on regtest: a real SP payment is indexed and the scan recovers the output, value, and spend tweak. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/silentpay.js | 28 ++++++++----- tools/sp-indexer.js | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 tools/sp-indexer.js (limited to 'src') diff --git a/src/silentpay.js b/src/silentpay.js index 1a58a41..16de66b 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -1,4 +1,4 @@ -// Silent Payments (BIP-352) — sending side only. +// Silent Payments (BIP-352) — sending and receiving. // // A silent payment address (sp1…) is a static, reusable address the receiver // publishes once. The sender derives a unique one-time taproot output for each @@ -6,14 +6,12 @@ // so there's no on-chain link between payments and no address reuse — without // any interaction with the receiver. // -// This module only does the SENDER half (deriving the output). Receiving needs -// chain scanning (a per-tx ECDH tweak), which a light client can't do without a -// dedicated index server — out of scope here. +// Sending (silentPaymentScripts) derives the output; we only spend compressed +// P2WPKH inputs, so each input contributes its private key as-is. Receiving (see +// the second half) can't scan the chain in a browser, so an indexing server +// supplies per-tx tweaks + taproot UTXOs and we match them on-device. // -// Validated against the official BIP-352 send test vectors. We only spend -// compressed P2WPKH inputs, which keeps the eligible-input rules trivial: each -// input contributes its private key as-is (no taproot even-Y negation, no -// uncompressed-key or P2SH edge cases). +// Validated against the official BIP-352 send + receive test vectors. import { secp256k1 } from '@noble/curves/secp256k1'; import { sha256 } from '@noble/hashes/sha256'; @@ -154,6 +152,14 @@ function parseWitness(hex) { return items; } +// Witness stack items as byte arrays. Accepts either a serialized witness (hex +// string, as in the BIP-352 vectors) or an array of hex stack items (as Core's +// getblock RPC returns in txinwitness). +function witnessItems(witness) { + if (Array.isArray(witness)) return witness.map(hexToBytes); + return parseWitness(witness); +} + // Pull the data pushes out of a scriptSig (hex) — enough to recover the pubkey // from P2PKH or the redeemScript from P2SH (push opcodes only). function scriptPushes(hex) { @@ -186,14 +192,14 @@ export function inputPubKey({ scriptSig = '', witness = '', scriptPubKey = '' }) const spk = hexToBytes(scriptPubKey); // P2TR: OP_1 PUSH32 if (spk.length === 34 && spk[0] === 0x51 && spk[1] === 0x20) { - let w = parseWitness(witness); + let w = witnessItems(witness); if (w.length > 1 && w[w.length - 1][0] === 0x50) w = w.slice(0, -1); // strip annex if (w.length > 1 && bytesToHex(w[w.length - 1].slice(1, 33)) === NUMS_H) return null; // NUMS script-path return Point.fromHex(concatBytes(Uint8Array.of(0x02), spk.slice(2, 34))).toRawBytes(true); // even-Y lift } // P2WPKH: OP_0 PUSH20 if (spk.length === 22 && spk[0] === 0x00 && spk[1] === 0x14) { - const w = parseWitness(witness); + const w = witnessItems(witness); const pk = w[w.length - 1]; return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; } @@ -201,7 +207,7 @@ export function inputPubKey({ scriptSig = '', witness = '', scriptPubKey = '' }) if (spk.length === 23 && spk[0] === 0xa9 && spk[1] === 0x14 && spk[22] === 0x87) { const rs = scriptPushes(scriptSig)[0]; if (!rs || rs.length !== 22 || rs[0] !== 0x00 || rs[1] !== 0x14) return null; - const w = parseWitness(witness); + const w = witnessItems(witness); const pk = w[w.length - 1]; return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; } diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js new file mode 100644 index 0000000..12f76f7 --- /dev/null +++ b/tools/sp-indexer.js @@ -0,0 +1,118 @@ +// Minimal BIP-352 silent-payment tweak indexer (an "oracle") for regtest dev. +// +// It walks the chain from a Bitcoin Core node and, per block, emits one entry per +// silent-payment-eligible transaction: the tweak (input_hash·A over the tx's +// eligible inputs) plus that transaction's taproot outputs. A wallet fetches +// these, computes ecdh = scan_priv·tweak on-device, derives P_k = B_spend + t_k·G, +// and matches it against the entry's outputs — the scan key never leaves the +// client. Discovered outputs are ordinary taproot UTXOs, so spends/confirmations +// are tracked by the normal Electrum backend; this server only does discovery. +// +// Shares the SP crypto with the wallet (../src/silentpay.js) so the tweak math is +// the same code validated against the BIP-352 vectors. In-memory, regtest-sized; +// re-indexes on restart. Run with bun. + +import { inputPubKey, silentPaymentTweak } from '../src/silentpay.js'; + +const RPC = process.env.CORE_RPC || 'http://127.0.0.1:18443'; +const RPC_USER = process.env.RPC_USER || 'admin1'; +const RPC_PASS = process.env.RPC_PASS || '123'; +const PORT = Number(process.env.SP_PORT || 8888); +const START = Number(process.env.START_HEIGHT || 0); +const POLL_MS = Number(process.env.POLL_MS || 2000); + +const auth = 'Basic ' + Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString('base64'); +let rpcId = 0; +async function rpc(method, params = []) { + const res = await fetch(RPC, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: auth }, + body: JSON.stringify({ jsonrpc: '1.0', id: ++rpcId, method, params }), + }); + if (!res.ok) throw new Error(`rpc ${method} HTTP ${res.status}`); + const j = await res.json(); + if (j.error) throw new Error(`rpc ${method}: ${j.error.message}`); + return j.result; +} + +// height -> { hash, items: [{ txid, tweak(hex), outputs: [{ vout, value(sat), xonly(hex) }] }] } +const blocks = new Map(); +let indexed = -1; // highest indexed height + +// Is a scriptPubKey (hex) a P2TR output? OP_1 PUSH32 = 5120<64 hex>. +const taprootXonly = (hex) => (hex && hex.length === 68 && hex.startsWith('5120') ? hex.slice(4) : null); + +function scanTx(tx) { + // Eligible-input set: every input with its recovered pubkey (null if ineligible + // or coinbase); the tweak needs all outpoints but only eligible pubkeys. + if (tx.vin.some((i) => i.coinbase)) return null; // coinbase is never a silent payment + const inputs = tx.vin.map((i) => ({ + txid: i.txid, + vout: i.vout, + pubkey: inputPubKey({ + scriptSig: (i.scriptSig && i.scriptSig.hex) || '', + witness: i.txinwitness || [], + scriptPubKey: (i.prevout && i.prevout.scriptPubKey && i.prevout.scriptPubKey.hex) || '', + }) || undefined, + })); + const outputs = []; + for (const o of tx.vout) { + const x = taprootXonly(o.scriptPubKey && o.scriptPubKey.hex); + if (x) outputs.push({ vout: o.n, value: Math.round(o.value * 1e8), xonly: x }); + } + if (!outputs.length) return null; // no taproot output → can't be an SP payment + let tweak; + try { tweak = silentPaymentTweak(inputs); } catch { return null; } + if (!tweak) return null; // no eligible inputs + return { txid: tx.txid, tweak: Buffer.from(tweak).toString('hex'), outputs }; +} + +async function indexBlock(height) { + const hash = await rpc('getblockhash', [height]); + const block = await rpc('getblock', [hash, 3]); // verbosity 3: full txs with prevouts + const items = []; + for (const tx of block.tx) { const it = scanTx(tx); if (it) items.push(it); } + blocks.set(height, { hash, items }); + indexed = height; +} + +async function sync() { + try { + const tip = await rpc('getblockcount', []); + for (let h = Math.max(START, indexed + 1); h <= tip; h++) { + await indexBlock(h); + if (h % 100 === 0 || h === tip) console.log(`indexed height ${h}/${tip} (${blocks.get(h).items.length} sp-tx)`); + } + } catch (e) { + console.error('sync error:', e.message); + } +} + +const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; +const json = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json', ...CORS } }); + +Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + const p = url.pathname; + if (req.method === 'OPTIONS') return new Response(null, { headers: CORS }); + if (p === '/height') return json({ height: indexed }); + let m; + if ((m = p.match(/^\/block\/(\d+)$/))) { + const h = Number(m[1]); + const b = blocks.get(h); + if (!b) return json({ error: 'not indexed', height: h, tip: indexed }, 404); + return json({ height: h, hash: b.hash, items: b.items }); + } + if ((m = p.match(/^\/tweaks\/(\d+)$/))) { + const b = blocks.get(Number(m[1])); + return json(b ? b.items.map((i) => i.tweak) : []); + } + return json({ error: 'not found' }, 404); + }, +}); + +console.log(`sp-indexer: core=${RPC} start=${START} serving :${PORT}`); +await sync(); +setInterval(sync, POLL_MS); -- cgit v1.2.3 From eeceebb09878a544b18926a3cd164e581b0c9869 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 14:46:02 -0700 Subject: Wire SP foundations into the wallet: configurable indexer + keys/address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.js: per-network SP indexer config (presets + custom, stored per network) with getSpIndexerConfig/spIndexerUrl/spReceiveEnabled — regtest defaults to the local indexer, others to custom; a hosted mainnet default slots in once we run one. wallet.js: silentPaymentKeys() derives BIP-352 scan/spend keys from the master seed (needs the mnemonic / depth-0 xprv), and silentPaymentAddress() returns the sp1…/tsp1… address. Verified it matches the vector-validated e2e address. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 34 ++++++++++++++++++++++++++++++++++ src/wallet.js | 24 +++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index 2095d51..5287e13 100644 --- a/src/api.js +++ b/src/api.js @@ -106,6 +106,40 @@ function netDefaults(net) { return NET_DEFAULTS[net] || NET_DEFAULTS.mainnet; } // get a ":" suffix. function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; } +// Silent-payment (BIP-352) tweak indexer — a separate, optional endpoint used +// only for SP receiving. It serves public tweak/UTXO data (the scan key never +// leaves the device), but a public one can observe which blocks hold your +// outputs, so self-hosting is the privacy-max option. Per network; an empty URL +// means SP receiving is unavailable on that network. +const SP_INDEXER_PRESETS_BY_NET = { + mainnet: [{ id: 'custom', label: 'Custom', url: '' }], // a hosted default goes here once we run one + testnet: [{ id: 'custom', label: 'Custom', url: '' }], + mutinynet: [{ id: 'custom', label: 'Custom', url: '' }], + regtest: [{ id: 'local', label: 'Local (localhost:8888)', url: 'http://localhost:8888' }, { id: 'custom', label: 'Custom', url: '' }], +}; +const SP_INDEXER_DEFAULT = { mainnet: 'custom', testnet: 'custom', mutinynet: 'custom', regtest: 'local' }; +const SP_INDEXER_KEY = 'btc-wallet-sp-indexer'; +export function spIndexerPresets(net = getNetwork()) { return SP_INDEXER_PRESETS_BY_NET[net] || SP_INDEXER_PRESETS_BY_NET.mainnet; } +export function getSpIndexerConfig(net = getNetwork()) { + try { + const c = JSON.parse(localStorage.getItem(nk(SP_INDEXER_KEY, net)) || 'null'); + if (c && c.server) return { server: c.server, url: c.url || '' }; + } catch {} + return { server: SP_INDEXER_DEFAULT[net] || 'custom', url: '' }; +} +export function setSpIndexerConfig({ server, url }, net = getNetwork()) { + try { localStorage.setItem(nk(SP_INDEXER_KEY, net), JSON.stringify({ server, url: url || '' })); } catch {} +} +// The resolved indexer base URL for a network, or '' if none is configured. +export function spIndexerUrl(net = getNetwork()) { + const cfg = getSpIndexerConfig(net); + if (cfg.server === 'custom') return cfg.url.trim().replace(/\/+$/, ''); + const p = spIndexerPresets(net).find((x) => x.id === cfg.server); + return p && p.url ? p.url.replace(/\/+$/, '') : ''; +} +// SP receiving is available only when an indexer is configured for this network. +export function spReceiveEnabled(net = getNetwork()) { return !!spIndexerUrl(net); } + // Resolve the configured explorer to the host list the Api tries in order. function resolveHosts(net) { const cfg = getExplorerConfig(net); diff --git a/src/wallet.js b/src/wallet.js index 46b9f5c..219b0f5 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -24,7 +24,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, 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'; +import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan } 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; @@ -255,6 +255,28 @@ export class Wallet { return this.account().publicExtendedKey; } + // BIP-352 scan/spend keys, derived from the master seed (m/352' paths) — so + // they need the mnemonic (or a depth-0 master xprv); watch-only and account- + // level imported keys can't do silent payments. Cached per account. + silentPaymentKeys() { + if (this.watchOnly) return null; + const coin = NETS[this.netName].coin; + const ck = `sp|${this.netName}|${this.mnemonic}|${this.passphrase}|${this.xprv}`; + if (this._spKeys && this._spKeysKey === ck) return this._spKeys; + let master = null; + if (this.mnemonic) master = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase)); + else if (this.xprv) { const n = HDKey.fromExtendedKey(this.xprv); if (n.depth === 0) master = n; } + this._spKeys = master ? deriveSilentPaymentKeys(master, coin) : null; + this._spKeysKey = ck; + return this._spKeys; + } + + // Our silent-payment address (sp1…/tsp1…), or null if this wallet can't do SP. + silentPaymentAddress() { + const k = this.silentPaymentKeys(); + return k ? encodeSilentPaymentAddress(k.scanPub, k.spendPub, { testnet: this.netName !== 'mainnet' }) : null; + } + node(chain, index) { // Relative derivation from the account node (chain/index are non-hardened). return this.account().deriveChild(chain).deriveChild(index); -- cgit v1.2.3 From 68edd804f1696aa412da2357a469c013f0a3e92d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 15:01:06 -0700 Subject: Wire silent-payment receiving into the wallet (scan + balance + UI) wallet.js: scanSilentPayments() pulls the indexer's /scan range, matches outputs on-device, and verifies each is unspent via the chain backend; spUtxos + balance are tracked in their own bucket (one-time taproot keys), persisted in the cache. silentPaymentsAvailable()/spBalance() expose state. app.js: the Receive tab shows the reusable sp address (QR + copy), a 'Scan for silent payments' button, and the found amount; Settings gains a per-network SP indexer picker (under Data source) with custom/self-host support. sp-indexer.js: added /scan/{from} so a client fetches a whole range in one request instead of one fetch per block. Receiving verified end-to-end on regtest (scan finds the output, 49,999,800 sat confirmed). Spending SP outputs is the remaining piece. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 72 +++++++++++++++++++++++++++++++++++++--- src/i18n.js | 9 +++++ src/wallet.js | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++- tools/sp-indexer.js | 12 +++++++ 4 files changed, 183 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index caddeb5..daf9782 100644 --- a/src/app.js +++ b/src/app.js @@ -8,7 +8,7 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; -import { getExplorerConfig, setExplorerConfig, explorerPresets, getDataMode, setDataMode, electrumPresets, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, NETWORKS } from './api.js'; +import { getExplorerConfig, setExplorerConfig, explorerPresets, getDataMode, setDataMode, electrumPresets, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from './api.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { @@ -1307,6 +1307,7 @@ function settingsTab() { ), networkCard(), explorerCard(), + spIndexerCard(), wallet.watchOnly || !wallet.mnemonic ? null : syncCard() ); } @@ -2224,12 +2225,73 @@ function receiveTab() { } const fresh = wallet.freshReceive(); + const spAddr = wallet.silentPaymentsAvailable() ? wallet.silentPaymentAddress() : null; return h( 'div', - { class: 'card col', style: 'align-items:center;gap:14px' }, - h('div', { html: qrSvg(fresh.address) }), - h('div', { class: 'addr-box', style: 'width:100%' }, fresh.address), - copyBtn(fresh.address, t('copyAddress')) + { class: 'col', style: 'gap:16px' }, + h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, + h('div', { html: qrSvg(fresh.address) }), + h('div', { class: 'addr-box', style: 'width:100%' }, fresh.address), + copyBtn(fresh.address, t('copyAddress')) + ), + spAddr ? silentPaymentReceiveCard(spAddr) : null + ); +} + +// Reusable silent-payment receive address (sp1…/tsp1…) + on-device scan. The +// address is static and reusable; senders derive a unique on-chain output each +// time, so there's no address reuse and nothing links the payments. +function silentPaymentReceiveCard(spAddr) { + const bal = wallet.spBalance(); + return h( + 'div', + { class: 'card col', style: 'gap:12px' }, + h('div', { class: 'row between' }, + h('h3', { style: 'margin:0' }, t('spReceiveTitle')), + h('span', { class: 'small muted' }, 'BIP-352') + ), + h('p', { class: 'small muted', style: 'margin:0' }, t('spReceiveDesc')), + h('div', { style: 'align-self:center' }, h('div', { html: qrSvg(spAddr) })), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, spAddr), + copyBtn(spAddr, t('copyAddress')), + bal.count + ? h('div', { class: 'small', style: 'text-align:center' }, + t('spReceived', { amount: fmtAmount(bal.confirmed + bal.pending) + ' ' + unitLabel(), n: bal.count })) + : null, + h('button', { class: 'btn-block', disabled: wallet.spScanning, onClick: doSpScan }, + wallet.spScanning ? t('spScanning') : t('spScan')) + ); +} + +function doSpScan() { + if (wallet.spScanning || wallet.offline) return; + wallet.scanSilentPayments().catch(() => {}).finally(render); + render(); +} + +// Silent-payment tweak indexer picker (per network) — the endpoint used to scan +// for received silent payments. Only shown for wallets that can derive SP keys. +function spIndexerCard() { + if (!wallet.silentPaymentKeys || !wallet.silentPaymentKeys()) return null; + const net = getNetwork(); + const cfg = getSpIndexerConfig(net); + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('spIndexerTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('spIndexerDesc')), + h('select', { onChange: (e) => { setSpIndexerConfig({ server: e.target.value, url: cfg.url }, net); render(); } }, + spIndexerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.label))), + cfg.server === 'custom' + ? h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('spIndexerUrl')), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'https://your-indexer:8888', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: cfg.url, + onChange: (e) => { setSpIndexerConfig({ server: 'custom', url: e.target.value.trim() }, net); render(); }, + }), + h('div', { class: 'small faint' }, t('spIndexerHint'))) + : null ); } diff --git a/src/i18n.js b/src/i18n.js index 2fe840d..5f029a3 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -301,6 +301,15 @@ const STR = { electrumUrl: 'Electrum server (wss://)', electrumUrlHint: 'A WebSocket Electrum server, e.g. wss://your-node:50004', silentPaymentNote: 'Silent payment', + spReceiveTitle: 'Silent payment address', + spReceiveDesc: 'A reusable address that leaks nothing on-chain — each sender derives a unique, unlinkable output. Scanning finds payments on-device using the indexer set under Data source.', + spScan: 'Scan for silent payments', + spScanning: 'Scanning…', + spReceived: 'Found {n} silent payment output(s): {amount}', + spIndexerTitle: 'Silent payment indexer', + spIndexerDesc: 'Where to scan for received silent payments. It only serves public tweak data — your scan key stays on-device — but a public one can see which blocks hold your outputs, so self-host for full privacy.', + spIndexerUrl: 'Indexer URL', + spIndexerHint: 'A BIP-352 tweak indexer (e.g. your own).', deviceSync: 'Device sync', deviceSyncDesc: 'Keep this wallet in sync across your devices by storing an encrypted copy on a Nostr relay — only a device with your seed can read it. Turn it off to keep everything on this device.', syncAcross: 'Sync across devices', diff --git a/src/wallet.js b/src/wallet.js index 219b0f5..092a005 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -21,7 +21,7 @@ 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, getBackend, explorerWeb, electrumCandidates } from './api.js'; +import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; import { NostrSync, getSyncConfig } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan } from './silentpay.js'; @@ -102,6 +102,12 @@ export class Wallet { this.feeRates = null; this.confirmed = 0; this.pending = 0; + // Silent-payment (BIP-352) receiving: outputs we found by scanning the + // indexer. Kept in their own bucket (one-time taproot keys, not BIP84 + // addresses); { txid, vout, value, confirmed, tweak, xonly, address }. + this.spUtxos = []; + this.lastSpScan = 0; // highest block height scanned for silent payments + this.spScanning = false; this.scanning = false; this.loaded = false; // true once a scan/snapshot has populated balances once @@ -178,6 +184,8 @@ export class Wallet { this.txs = []; this.confirmed = 0; this.pending = 0; + this.spUtxos = []; + this.lastSpScan = 0; this.loaded = false; this.nextReceiveIndex = 0; this.nextChangeIndex = 0; @@ -277,6 +285,88 @@ export class Wallet { return k ? encodeSilentPaymentAddress(k.scanPub, k.spendPub, { testnet: this.netName !== 'mainnet' }) : null; } + // Is SP receiving available here? (we can derive keys AND an indexer is set) + silentPaymentsAvailable() { + return !!(this.silentPaymentKeys() && spIndexerUrl(this.netName)); + } + + // Total received via silent payments: { confirmed, pending, count } over the + // unspent SP outputs we've found. + spBalance() { + let confirmed = 0, pending = 0; + for (const u of this.spUtxos) { if (u.confirmed) confirmed += u.value; else pending += u.value; } + return { confirmed, pending, count: this.spUtxos.length }; + } + + // The taproot address for an x-only output key (for backend UTXO lookups). + _spAddress(xonly) { + return btc.Address(this.netCfg.net).encode(btc.OutScript.decode(btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(xonly) }))); + } + + // Scan the configured SP indexer from the last-scanned height to the tip, find + // outputs paying our scan/spend keys, and verify each is unspent via the chain + // backend (the indexer only does discovery). Updates this.spUtxos. + async scanSilentPayments({ rescan = false } = {}) { + const keys = this.silentPaymentKeys(); + const indexer = spIndexerUrl(this.netName); + if (!keys || !indexer || this.offline) return { unavailable: true }; + if (this.spScanning) return { busy: true }; + this.spScanning = true; + this.emit(); + try { + if (rescan) { this.spUtxos = []; this.lastSpScan = 0; } + const from = (this.lastSpScan || 0) + 1; + const res = await (await fetch(`${indexer}/scan/${from}`)).json(); + const tip = res.tip || 0; + const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); + const found = []; + for (const it of res.items || []) { + const matches = silentPaymentScan({ + scanPriv: keys.scanPriv, spendPub: keys.spendPub, + tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly), + }); + for (const m of matches) { + const o = it.outputs.find((o) => o.xonly === m.output); + const id = `${it.txid}:${o.vout}`; + if (have.has(id)) continue; + have.add(id); + found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); + } + } + if (tip > this.lastSpScan) this.lastSpScan = tip; + // Verify newly-found outputs are unspent + get confirmation status. + for (const f of found) { + const address = this._spAddress(f.xonly); + try { + const us = await this.api.addressUtxos(address); + const u = us.find((x) => x.txid === f.txid && x.vout === f.vout); + if (u) this.spUtxos.push({ ...f, address, confirmed: !!(u.status && u.status.confirmed) }); + } catch {} + } + await this._refreshSpUtxos(); + this.saveCache(); + this.emit(); + return { found: found.length, scanned: tip }; + } finally { + this.spScanning = false; + this.emit(); + } + } + + // Re-check tracked SP outputs against the backend: drop spent ones, refresh + // confirmation status. + async _refreshSpUtxos() { + const next = []; + for (const u of this.spUtxos) { + try { + const us = await this.api.addressUtxos(u.address); + const live = us.find((x) => x.txid === u.txid && x.vout === u.vout); + if (live) next.push({ ...u, confirmed: !!(live.status && live.status.confirmed) }); + } catch { next.push(u); } // backend hiccup: keep as-is + } + this.spUtxos = next; + } + node(chain, index) { // Relative derivation from the account node (chain/index are non-hardened). return this.account().deriveChild(chain).deriveChild(index); @@ -1818,6 +1908,8 @@ export class Wallet { nextReceiveIndex: this.nextReceiveIndex, nextChangeIndex: this.nextChangeIndex, feeRates: this.feeRates, + spUtxos: this.spUtxos, + lastSpScan: this.lastSpScan, }; } @@ -1830,6 +1922,8 @@ export class Wallet { this.nextReceiveIndex = d.nextReceiveIndex || 0; this.nextChangeIndex = d.nextChangeIndex || 0; this.feeRates = d.feeRates || this.feeRates; + this.spUtxos = d.spUtxos || []; + this.lastSpScan = d.lastSpScan || 0; this.addrMap = new Map(); for (const a of [...this.receive, ...this.change]) { this.addrMap.set(a.address, { chain: a.chain, index: a.index }); diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js index 12f76f7..d573ab9 100644 --- a/tools/sp-indexer.js +++ b/tools/sp-indexer.js @@ -109,6 +109,18 @@ Bun.serve({ const b = blocks.get(Number(m[1])); return json(b ? b.items.map((i) => i.tweak) : []); } + // All SP-eligible tx items from height `from` to the tip, in one response — + // each tagged with its height. Lets a client scan a range without one fetch + // per block. (Regtest-scale; mainnet would page or use BIP-158 filters.) + if ((m = p.match(/^\/scan\/(\d+)$/))) { + const from = Number(m[1]); + const items = []; + for (let h = from; h <= indexed; h++) { + const b = blocks.get(h); + if (b) for (const it of b.items) items.push({ height: h, ...it }); + } + return json({ tip: indexed, items }); + } return json({ error: 'not found' }, 404); }, }); -- cgit v1.2.3 From 2edad313d270354eb2f8e3420272897c14325f42 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 15:15:04 -0700 Subject: Spend received silent-payment outputs (taproot key-path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildTx now includes received SP outputs in the coin pool, and sign() spends them as taproot key-path inputs signed manually with d = spend_priv + t_k (the output key is used raw — no BIP-341 tweak — so btc-signer's auto key-path signing doesn't apply; we set tapKeySig from preimageWitnessV1 + schnorr.sign). The fee estimator is fed a tapInternalKey (sizing only) with disableScriptCheck. SP value folds into the displayed balance. Paying an sp1… recipient still uses BIP84 coins (SP-input-funded SP sends need even-Y handling, deferred). Validated on regtest via testmempoolaccept: a wallet-built, wallet-signed spend of a received SP output is accepted by Core. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet.js | 95 ++++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 24 deletions(-) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index 092a005..107076a 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -24,7 +24,8 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; import { NostrSync, getSyncConfig } from './nostr.js'; -import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan } from './silentpay.js'; +import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey } from './silentpay.js'; +import { schnorr } from '@noble/curves/secp256k1'; // 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; @@ -897,13 +898,15 @@ export class Wallet { c += a.confirmed || 0; p += a.pending || 0; } - this.confirmed = c; - this.pending = p; + const sp = this.spBalance(); + this.confirmed = c + sp.confirmed; + this.pending = p + sp.pending; } _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); + const sp = this.spBalance(); + this.confirmed = this.utxos.reduce((s, u) => s + (u.confirmed ? u.value : 0), 0) + sp.confirmed; + this.pending = this.utxos.reduce((s, u) => s + (u.confirmed ? 0 : u.value), 0) + sp.pending; } freshReceive() { @@ -921,19 +924,40 @@ export class Wallet { 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, + // Received silent-payment outputs are spendable too — one-time taproot keys, + // signed by sign() with the per-output key. Excluded when paying an sp1… + // recipient (funding an SP send from a taproot input needs BIP-352 even-Y + // key handling we don't do yet), so those sends use BIP84 coins. + const payingSp = recipients.some((r) => isSilentPaymentAddress(r.address)); + const spPool = payingSp + ? [] + : coinIds + ? this.spUtxos.filter((u) => coinIds.includes(utxoId(u))) + : this.spUtxos.slice(); + if (!pool_.length && !spPool.length) throw new Error('No spendable coins selected.'); + + const inputs = [ + ...pool_.map((u) => { + const { 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) }, + }; + }), + ...spPool.map((u) => ({ 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) }, - }; - }); + sequence: 0xfffffffd, + witnessUtxo: { script: btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(u.xonly) }), amount: BigInt(u.value) }, + // Present so the fee estimator sizes a key-path (64-byte) witness; the + // value is unused (SP outputs are signed raw in sign(), via tapKeySig). + tapInternalKey: hex.decode(u.xonly), + })), + ]; const changeAddress = this.freshChange().address; const feePerByte = BigInt(Math.max(1, Math.round(feeRate))); @@ -964,6 +988,7 @@ export class Wallet { network: this.netCfg.net, createTx: true, bip69: true, + disableScriptCheck: true, }); if (!sel || !sel.tx) throw new Error('Fee exceeds balance.'); if (spOuts.length) this._applySilentPayments(sel.tx, spOuts); @@ -987,7 +1012,8 @@ export class Wallet { feePerByte, network: this.netCfg.net, createTx: true, - bip69: true, + bip69: true, + disableScriptCheck: true, }); if (!sel || !sel.tx) throw new Error('Insufficient funds for amount + fee.'); @@ -1042,18 +1068,39 @@ export class Wallet { return false; } - // Sign every input of an already-built Transaction with the matching HD key. + // Sign every input of an already-built Transaction. BIP84 inputs sign with the + // matching HD key; received silent-payment outputs are taproot key-path spends + // signed manually with d = spend_priv + t_k (the output key is used raw, with + // no BIP-341 tweak, so btc-signer's auto key-path signing doesn't apply). 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); + const bip84 = new Map(); + for (const u of this.utxos) bip84.set(utxoId(u), u); + const sp = new Map(); + for (const u of this.spUtxos) sp.set(utxoId(u), u); + // Prevout scripts + amounts for any taproot (SP) sighash. + const prevScripts = []; + const amounts = []; + for (let i = 0; i < tx.inputsLength; i++) { + const w = tx.getInput(i).witnessUtxo; + prevScripts.push(w.script); + amounts.push(w.amount); + } + const spKeys = sp.size ? this.silentPaymentKeys() : null; 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); + if (bip84.has(id)) { + const u = bip84.get(id); + tx.signIdx(this.node(u.chain, u.index).privateKey, i); + } else if (sp.has(id)) { + const u = sp.get(id); + const d = silentPaymentOutputPrivKey(spKeys.spendPriv, hex.decode(u.tweak)); + const hash = tx.preimageWitnessV1(i, prevScripts, btc.SigHash.DEFAULT, amounts); + tx.updateInput(i, { tapKeySig: schnorr.sign(hash, d) }, true); + } else { + throw new Error(`Cannot find key for input ${id}`); + } } tx.finalize(); return tx.hex; -- cgit v1.2.3 From e52327891130138d73e48ee8dd42781359a473c8 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 15:55:53 -0700 Subject: Auto-scan for silent payments + fold found value into balance scanSilentPayments() now recomputes the balance so received SP value shows in the wallet total (not just the Receive card), and gains a silent mode. It's triggered automatically after each full scan and on the poll loop (incremental, cheap), so payments appear without tapping anything. The Receive card now shows a status line ('Watching for payments' / found amount) with a small manual Rescan instead of a primary Scan button. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 18 +++++++++++------- src/i18n.js | 2 ++ src/wallet.js | 9 ++++++--- 3 files changed, 19 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index daf9782..727e3cc 100644 --- a/src/app.js +++ b/src/app.js @@ -2254,18 +2254,22 @@ function silentPaymentReceiveCard(spAddr) { h('div', { style: 'align-self:center' }, h('div', { html: qrSvg(spAddr) })), h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, spAddr), copyBtn(spAddr, t('copyAddress')), - bal.count - ? h('div', { class: 'small', style: 'text-align:center' }, - t('spReceived', { amount: fmtAmount(bal.confirmed + bal.pending) + ' ' + unitLabel(), n: bal.count })) - : null, - h('button', { class: 'btn-block', disabled: wallet.spScanning, onClick: doSpScan }, - wallet.spScanning ? t('spScanning') : t('spScan')) + h('div', { class: 'row between', style: 'align-items:center' }, + h('span', { class: 'small muted' }, + wallet.spScanning + ? t('spScanning') + : bal.count + ? t('spReceived', { amount: fmtAmount(bal.confirmed + bal.pending) + ' ' + unitLabel(), n: bal.count }) + : t('spWatching')), + h('button', { class: 'linklike small', disabled: wallet.spScanning, onClick: doSpScan }, t('spRescan')) + ) ); } +// Auto-scanning runs on the poll loop; this is a manual full rescan. function doSpScan() { if (wallet.spScanning || wallet.offline) return; - wallet.scanSilentPayments().catch(() => {}).finally(render); + wallet.scanSilentPayments({ rescan: true }).catch(() => {}).finally(render); render(); } diff --git a/src/i18n.js b/src/i18n.js index 5f029a3..f6a94c0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -305,6 +305,8 @@ const STR = { spReceiveDesc: 'A reusable address that leaks nothing on-chain — each sender derives a unique, unlinkable output. Scanning finds payments on-device using the indexer set under Data source.', spScan: 'Scan for silent payments', spScanning: 'Scanning…', + spWatching: 'Watching for payments', + spRescan: 'Rescan', spReceived: 'Found {n} silent payment output(s): {amount}', spIndexerTitle: 'Silent payment indexer', spIndexerDesc: 'Where to scan for received silent payments. It only serves public tweak data — your scan key stays on-device — but a public one can see which blocks hold your outputs, so self-host for full privacy.', diff --git a/src/wallet.js b/src/wallet.js index 107076a..bff8910 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -307,13 +307,13 @@ export class Wallet { // Scan the configured SP indexer from the last-scanned height to the tip, find // outputs paying our scan/spend keys, and verify each is unspent via the chain // backend (the indexer only does discovery). Updates this.spUtxos. - async scanSilentPayments({ rescan = false } = {}) { + async scanSilentPayments({ rescan = false, silent = false } = {}) { const keys = this.silentPaymentKeys(); const indexer = spIndexerUrl(this.netName); if (!keys || !indexer || this.offline) return { unavailable: true }; if (this.spScanning) return { busy: true }; this.spScanning = true; - this.emit(); + if (!silent) this.emit(); // manual scans show a "Scanning…" state; auto-scans stay quiet try { if (rescan) { this.spUtxos = []; this.lastSpScan = 0; } const from = (this.lastSpScan || 0) + 1; @@ -345,12 +345,13 @@ export class Wallet { } catch {} } await this._refreshSpUtxos(); + this._recomputeBalanceFromChains(); // fold found SP value into the displayed balance this.saveCache(); this.emit(); return { found: found.length, scanned: tip }; } finally { this.spScanning = false; - this.emit(); + if (!silent) this.emit(); } } @@ -729,6 +730,7 @@ export class Wallet { } this.loaded = true; this.saveCache(); + this.scanSilentPayments({ silent: true }).catch(() => {}); // fold in any silent-payment receipts } finally { this._refreshing = false; this.historyLoading = false; // clear even if an earlier step threw @@ -1633,6 +1635,7 @@ export class Wallet { if (Date.now() - (this._lastPoll || 0) < due) return; this._lastPoll = Date.now(); this.refreshLive(); + this.scanSilentPayments({ silent: true }).catch(() => {}); // pick up new SP receipts (incremental, cheap) }, 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 -- cgit v1.2.3 From 4c95ed2826b4dd11555c0500426747571c148dec Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 16:03:40 -0700 Subject: Push silent payments over WebSocket instead of polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indexer now serves a /ws endpoint and publishes each newly-indexed block's SP items to subscribers. The wallet subscribes (_spConnect) and scans pushed items on arrival (_scanSpItems) — folding receipts into the balance instantly — catching up via /scan on connect/reconnect. Polling for SP is now only a fallback for when the socket isn't live. Verified on regtest: connect, mine a block, wallet receives the push. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++- tools/sp-indexer.js | 19 ++++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index bff8910..212c471 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -369,6 +369,66 @@ export class Wallet { this.spUtxos = next; } + // Live silent-payment push: subscribe to the indexer's WebSocket so each new + // block's SP items are scanned on arrival instead of polling. Catches up via a + // /scan on connect (covers anything missed while disconnected) and reconnects. + _spConnect() { + if (this.offline) return; + const base = spIndexerUrl(this.netName); + if (!base || !this.silentPaymentKeys()) return; + const url = base.replace(/^http/, 'ws') + '/ws'; + if (this._spWs && this._spWsUrl === url) return; + this._spDisconnect(); + this._spWsUrl = url; + let ws; + try { ws = new WebSocket(url); } catch { return; } + this._spWs = ws; + ws.onopen = () => { this.spLive = true; this.scanSilentPayments({ silent: true }).catch(() => {}); }; + ws.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data)); } catch { return; } + if (msg && msg.type === 'block' && Array.isArray(msg.items)) this._scanSpItems(msg.items, msg.height).catch(() => {}); + }; + ws.onerror = () => { try { ws.close(); } catch {} }; + ws.onclose = () => { + if (this._spWs !== ws) return; + this.spLive = false; this._spWs = null; + if (!this.offline) this._spReconnect = setTimeout(() => this._spConnect(), 3000); + }; + } + + _spDisconnect() { + clearTimeout(this._spReconnect); + this.spLive = false; + const ws = this._spWs; + this._spWs = null; this._spWsUrl = null; + if (ws) { try { ws.onclose = null; ws.close(); } catch {} } + } + + // Scan a pushed block's items (no fetch) and fold any of ours into spUtxos. The + // block is confirmed (just mined), so matches are recorded confirmed; the + // backstop _refreshSpUtxos reconciles later spends. + async _scanSpItems(items, height) { + const keys = this.silentPaymentKeys(); + if (!keys) return; + const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); + let added = false; + for (const it of items) { + const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); + for (const m of matches) { + const o = it.outputs.find((o) => o.xonly === m.output); + if (have.has(`${it.txid}:${o.vout}`)) continue; + have.add(`${it.txid}:${o.vout}`); + this.spUtxos.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: true }); + added = true; + } + } + if (height && height > this.lastSpScan) this.lastSpScan = height; + if (added) this._recomputeBalanceFromChains(); + this.saveCache(); + if (added) this.emit(); + } + node(chain, index) { // Relative derivation from the account node (chain/index are non-hardened). return this.account().deriveChild(chain).deriveChild(index); @@ -1635,8 +1695,11 @@ export class Wallet { if (Date.now() - (this._lastPoll || 0) < due) return; this._lastPoll = Date.now(); this.refreshLive(); - this.scanSilentPayments({ silent: true }).catch(() => {}); // pick up new SP receipts (incremental, cheap) + // SP receipts arrive via the indexer WebSocket (_spConnect); only poll for + // them as a fallback when that socket isn't live. + if (!this.spLive) this.scanSilentPayments({ silent: true }).catch(() => {}); }, 1000); + this._spConnect(); // live push of silent-payment receipts // 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, @@ -1899,6 +1962,7 @@ export class Wallet { stopRealtime() { this._wsWant = false; this.live = false; + this._spDisconnect(); this._rejectPending('realtime stopped'); // fail any in-flight Electrum data calls this._callQueue = []; clearTimeout(this._wsRetry); diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js index d573ab9..bae1e15 100644 --- a/tools/sp-indexer.js +++ b/tools/sp-indexer.js @@ -76,13 +76,20 @@ async function indexBlock(height) { indexed = height; } +let live = false; // true once caught up to the tip — then new blocks are pushed over WS + async function sync() { try { const tip = await rpc('getblockcount', []); for (let h = Math.max(START, indexed + 1); h <= tip; h++) { await indexBlock(h); + // Push each newly-indexed block (once we're live) to subscribed wallets, so + // they scan on arrival instead of polling. The payload carries the items so + // there's no follow-up fetch. + if (live) server.publish('blocks', JSON.stringify({ type: 'block', height: h, items: blocks.get(h).items })); if (h % 100 === 0 || h === tip) console.log(`indexed height ${h}/${tip} (${blocks.get(h).items.length} sp-tx)`); } + if (!live) { live = true; console.log('caught up — pushing new blocks over websocket'); } } catch (e) { console.error('sync error:', e.message); } @@ -91,12 +98,17 @@ async function sync() { const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; const json = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json', ...CORS } }); -Bun.serve({ +const server = Bun.serve({ port: PORT, async fetch(req) { const url = new URL(req.url); const p = url.pathname; if (req.method === 'OPTIONS') return new Response(null, { headers: CORS }); + // WebSocket: subscribers get a {type:'block', height, items} push per new block. + if (p === '/ws') { + if (server.upgrade(req)) return undefined; + return new Response('expected websocket', { status: 400 }); + } if (p === '/height') return json({ height: indexed }); let m; if ((m = p.match(/^\/block\/(\d+)$/))) { @@ -123,6 +135,11 @@ Bun.serve({ } return json({ error: 'not found' }, 404); }, + websocket: { + open(ws) { ws.subscribe('blocks'); ws.send(JSON.stringify({ type: 'height', height: indexed })); }, + message() {}, // clients don't send anything + close(ws) { ws.unsubscribe('blocks'); }, + }, }); console.log(`sp-indexer: core=${RPC} start=${START} serving :${PORT}`); -- cgit v1.2.3 From e2aa10741a12329c1e5c28cd3887638e194ec3fc Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 16:42:04 -0700 Subject: Fix stuck 'Scanning…' — auto-scans no longer touch the visible flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The visible spScanning state is now set only for manual scans; auto-scans use a separate concurrency guard (_spScanBusy). Previously an auto-scan set spScanning and the final emit fired before it was cleared (and silent scans skipped the clearing emit), leaving the Receive card stuck on 'Scanning…'. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index 212c471..3255048 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -311,9 +311,11 @@ export class Wallet { const keys = this.silentPaymentKeys(); const indexer = spIndexerUrl(this.netName); if (!keys || !indexer || this.offline) return { unavailable: true }; - if (this.spScanning) return { busy: true }; - this.spScanning = true; - if (!silent) this.emit(); // manual scans show a "Scanning…" state; auto-scans stay quiet + if (this._spScanBusy) return { busy: true }; + this._spScanBusy = true; + // Only a manual scan shows the visible "Scanning…" state; auto-scans are + // invisible (setting spScanning for them left the card stuck on "Scanning…"). + if (!silent) { this.spScanning = true; this.emit(); } try { if (rescan) { this.spUtxos = []; this.lastSpScan = 0; } const from = (this.lastSpScan || 0) + 1; @@ -347,11 +349,11 @@ export class Wallet { await this._refreshSpUtxos(); this._recomputeBalanceFromChains(); // fold found SP value into the displayed balance this.saveCache(); - this.emit(); return { found: found.length, scanned: tip }; } finally { + this._spScanBusy = false; this.spScanning = false; - if (!silent) this.emit(); + this.emit(); // always refresh (balance + clears any "Scanning…") } } -- cgit v1.2.3 From a52cf9e500847bc1d545eee9e2816a13cccf20cf Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 17:00:17 -0700 Subject: Fix SP balance not showing — fold SP into spendable/pendingIncoming getters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header reads wallet.spendable (and pendingIncoming), which reduce over this.utxos only; SP outputs live in this.spUtxos, so received SP value showed in the Receive card but not the balance. Add confirmed SP to spendable and pending SP to pendingIncoming. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index 3255048..45300c3 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -946,13 +946,14 @@ export class Wallet { if (res.has(utxoId(u))) return s; if (u.confirmed || u.chain === 1) return s + u.value; return s; - }, 0); + }, 0) + this.spBalance().confirmed; // confirmed silent-payment receipts are spendable } // Unconfirmed incoming receives (not our change, not gift-locked) — shown as a - // separate pending line, kept out of the spendable headline. + // separate pending line, kept out of the spendable headline. Includes pending + // (mempool) silent-payment receipts. 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); + return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0) + this.spBalance().pending; } _recomputeBalanceFromChains() { -- cgit v1.2.3 From b0b9a71ef4912350ecf188bf6a75d21e97adc06b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 17:11:15 -0700 Subject: Mempool (unconfirmed) silent-payment discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Indexer: scan the mempool (getrawmempool + getrawtransaction; prevouts fetched explicitly since verbosity-2 omits them for mempool txs), track SP-eligible unconfirmed txs, serve /mempool, and push {type:'mempool'} on change. Switched Core RPC to node:http (no keep-alive) + serialized calls — Bun's fetch was dropping sockets under the added mempool load. Wallet: handle the mempool push and /mempool catch-up (_scanSpMempool) — pending SP receipts rebuild on each update (also drops RBF'd/vanished ones); block pushes promote a pending entry to confirmed. So a silent payment shows the instant it's broadcast, like a normal receive, with no mining. Verified on regtest: an unconfirmed SP is detected and served; clean logs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet.js | 54 ++++++++++++++++++++++------- tools/sp-indexer.js | 98 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 130 insertions(+), 22 deletions(-) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index 45300c3..5b5ed90 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -385,11 +385,18 @@ export class Wallet { let ws; try { ws = new WebSocket(url); } catch { return; } this._spWs = ws; - ws.onopen = () => { this.spLive = true; this.scanSilentPayments({ silent: true }).catch(() => {}); }; + ws.onopen = () => { + this.spLive = true; + this.scanSilentPayments({ silent: true }).catch(() => {}); // confirmed catch-up + // Pending (mempool) catch-up. + fetch(`${base}/mempool`).then((r) => r.json()).then((d) => this._scanSpMempool(d.items || [])).catch(() => {}); + }; ws.onmessage = (ev) => { let msg; try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data)); } catch { return; } - if (msg && msg.type === 'block' && Array.isArray(msg.items)) this._scanSpItems(msg.items, msg.height).catch(() => {}); + if (!msg || !Array.isArray(msg.items)) return; + if (msg.type === 'block') this._scanSpItems(msg.items, msg.height).catch(() => {}); + else if (msg.type === 'mempool') this._scanSpMempool(msg.items).catch(() => {}); }; ws.onerror = () => { try { ws.close(); } catch {} }; ws.onclose = () => { @@ -407,28 +414,49 @@ export class Wallet { if (ws) { try { ws.onclose = null; ws.close(); } catch {} } } - // Scan a pushed block's items (no fetch) and fold any of ours into spUtxos. The - // block is confirmed (just mined), so matches are recorded confirmed; the - // backstop _refreshSpUtxos reconciles later spends. + // Scan a confirmed block's pushed items and upsert ours into spUtxos as + // confirmed (a previously-pending mempool entry is promoted to confirmed). async _scanSpItems(items, height) { const keys = this.silentPaymentKeys(); if (!keys) return; - const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); - let added = false; + let changed = false; for (const it of items) { const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); for (const m of matches) { const o = it.outputs.find((o) => o.xonly === m.output); - if (have.has(`${it.txid}:${o.vout}`)) continue; - have.add(`${it.txid}:${o.vout}`); - this.spUtxos.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: true }); - added = true; + const existing = this.spUtxos.find((u) => u.txid === it.txid && u.vout === o.vout); + if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } } + else { this.spUtxos.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: true }); changed = true; } } } if (height && height > this.lastSpScan) this.lastSpScan = height; - if (added) this._recomputeBalanceFromChains(); + if (changed) this._recomputeBalanceFromChains(); this.saveCache(); - if (added) this.emit(); + if (changed) this.emit(); + } + + // Scan the current mempool's pushed items and rebuild our pending (unconfirmed) + // SP set — so payments show the moment they're broadcast. Confirmed entries are + // kept; the unconfirmed set is replaced, which also drops vanished/RBF'd ones. + async _scanSpMempool(items) { + const keys = this.silentPaymentKeys(); + if (!keys) return; + const confirmed = this.spUtxos.filter((u) => u.confirmed); + const confirmedIds = new Set(confirmed.map((u) => `${u.txid}:${u.vout}`)); + const pending = []; + for (const it of items) { + const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); + for (const m of matches) { + const o = it.outputs.find((o) => o.xonly === m.output); + if (confirmedIds.has(`${it.txid}:${o.vout}`)) continue; // already confirmed + pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false }); + } + } + const prevPending = this.spUtxos.filter((u) => !u.confirmed).length; + this.spUtxos = [...confirmed, ...pending]; + if (pending.length !== prevPending || pending.length) this._recomputeBalanceFromChains(); + this.saveCache(); + this.emit(); } node(chain, index) { diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js index 9bbedd6..7e34405 100644 --- a/tools/sp-indexer.js +++ b/tools/sp-indexer.js @@ -15,6 +15,7 @@ import { Database } from 'bun:sqlite'; import net from 'node:net'; +import http from 'node:http'; import { inputPubKey, silentPaymentTweak } from '../src/silentpay.js'; const RPC = process.env.CORE_RPC || 'http://127.0.0.1:18443'; @@ -27,16 +28,41 @@ const DB_PATH = process.env.SP_DB || ':memory:'; const auth = 'Basic ' + Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString('base64'); let rpcId = 0; -async function rpc(method, params = []) { - const res = await fetch(RPC, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: auth }, - body: JSON.stringify({ jsonrpc: '1.0', id: ++rpcId, method, params }), +// Serialize all RPC so the block sync and mempool poll don't hammer Core +// concurrently (which was dropping sockets). +let rpcLock = Promise.resolve(); +function rpc(method, params = [], retry = 2) { + const p = rpcLock.then(() => rpcCall(method, params, retry)); + rpcLock = p.then(() => {}, () => {}); + return p; +} +const RPC_URL = new URL(RPC); +function rpcCall(method, params = [], retry = 2) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ jsonrpc: '1.0', id: ++rpcId, method, params }); + const req = http.request({ + hostname: RPC_URL.hostname, port: RPC_URL.port || 80, path: RPC_URL.pathname || '/', method: 'POST', + agent: false, // fresh connection each call — no keep-alive sockets to go stale + headers: { 'content-type': 'application/json', authorization: auth, 'content-length': Buffer.byteLength(body) }, + }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + try { const j = JSON.parse(data); if (j.error) reject(new Error(`rpc ${method}: ${j.error.message}`)); else resolve(j.result); } + catch (e) { reject(new Error(`rpc ${method}: ${e.message}`)); } + }); + }); + req.on('error', (e) => (retry > 0 ? resolve(rpcCall(method, params, retry - 1)) : reject(e))); + req.write(body); + req.end(); }); - if (!res.ok) throw new Error(`rpc ${method} HTTP ${res.status}`); - const j = await res.json(); - if (j.error) throw new Error(`rpc ${method}: ${j.error.message}`); - return j.result; +} + +// The scriptPubKey of a previous output (for mempool txs, whose getrawtransaction +// doesn't carry prevouts). Cached within a mempool pass via the parent tx. +async function prevoutScriptPubKey(txid, vout) { + const ptx = await rpc('getrawtransaction', [txid, true]); + return (ptx.vout[vout] && ptx.vout[vout].scriptPubKey.hex) || ''; } // ---- persistent store (SQLite) -------------------------------------------- @@ -106,11 +132,61 @@ async function sync() { if (h % 100 === 0 || h === tip) console.log(`indexed height ${h}/${tip} (${items.length} sp-tx)`); } if (!live) { live = true; console.log('caught up — pushing new blocks over websocket'); } + await mempoolSync(); // blocks just changed the mempool — reconcile pending } catch (e) { console.error('sync error:', e.message); } } +// ---- mempool (unconfirmed) SP discovery ----------------------------------- +// So a payment shows the moment it's broadcast, not only once mined. We track +// which mempool txs are SP-eligible (getrawtransaction verbosity 2 carries the +// prevouts needed for the tweak) and push the set whenever it changes. +const mempoolSeen = new Map(); // txid -> item | null (null = seen, not an SP tx) +let mempoolItems = []; + +// Like scanTx but for a mempool txid: getrawtransaction has no prevouts, so we +// fetch each input's prevout scriptPubKey (only for txs with a taproot output). +async function scanMempoolTx(txid) { + const tx = await rpc('getrawtransaction', [txid, true]); + if (tx.vin.some((i) => i.coinbase)) return null; + const outputs = []; + for (const o of tx.vout) { const x = taprootXonly(o.scriptPubKey && o.scriptPubKey.hex); if (x) outputs.push({ vout: o.n, value: Math.round(o.value * 1e8), xonly: x }); } + if (!outputs.length) return null; // not SP-relevant — skip the prevout lookups + const inputs = []; + for (const i of tx.vin) { + let spk = ''; + try { spk = await prevoutScriptPubKey(i.txid, i.vout); } catch {} + inputs.push({ txid: i.txid, vout: i.vout, pubkey: inputPubKey({ scriptSig: (i.scriptSig && i.scriptSig.hex) || '', witness: i.txinwitness || [], scriptPubKey: spk }) || undefined }); + } + let tweak; + try { tweak = silentPaymentTweak(inputs); } catch { return null; } + if (!tweak) return null; + return { txid: tx.txid, tweak: Buffer.from(tweak).toString('hex'), outputs }; +} + +async function mempoolSync() { + try { + const txids = await rpc('getrawmempool', []); + const inPool = new Set(txids); + let changed = false; + for (const txid of txids) { + if (mempoolSeen.has(txid)) continue; + mempoolSeen.set(txid, null); // mark seen so we don't refetch + let it = null; + try { it = await scanMempoolTx(txid); } catch {} + if (it) { mempoolSeen.set(txid, it); changed = true; } + } + for (const txid of mempoolSeen.keys()) if (!inPool.has(txid)) { if (mempoolSeen.get(txid)) changed = true; mempoolSeen.delete(txid); } + if (changed) { + mempoolItems = [...mempoolSeen.values()].filter(Boolean); + if (live) server.publish('blocks', JSON.stringify({ type: 'mempool', items: mempoolItems })); + } + } catch (e) { + console.error('mempool sync error:', e.message); + } +} + const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; const json = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json', ...CORS } }); @@ -144,6 +220,9 @@ const server = Bun.serve({ for (const row of qScanFrom.all(Number(m[1]))) for (const it of JSON.parse(row.items)) items.push({ height: row.height, ...it }); return json({ tip: indexed, items }); } + // Current unconfirmed (mempool) SP-eligible txs — for the pending catch-up on + // connect; live updates arrive as {type:'mempool'} WS pushes. + if (p === '/mempool') return json({ items: mempoolItems }); return json({ error: 'not found' }, 404); }, websocket: { @@ -211,3 +290,4 @@ if (process.env.SP_ZMQ) { console.log(`zmq: subscribed to hashblock at ${process.env.SP_ZMQ}`); } setInterval(sync, POLL_MS); // backstop (and the only trigger when SP_ZMQ is unset) +setInterval(mempoolSync, Number(process.env.MEMPOOL_POLL_MS || 2500)); // unconfirmed SP discovery -- cgit v1.2.3 From 56a2736be51d2f35d68ca88b477a948efe3398ce Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 17:17:58 -0700 Subject: Catch-up scaling: dust filter + per-block Bloom filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit silentpay.js: silentPaymentCandidate() (our P_0 for a tweak) + buildBloom/bloomHas (double-hashed sha256, ~0.8% FP). Validated: candidate == P_0 on all no-label vectors. Indexer /scan now returns, per block, the tweaks + a Bloom over that block's taproot output keys (no outputs), with an optional dustLimit. The wallet derives a candidate per tweak, tests the filter, and only fetches /block on a hit — so a big historical scan costs candidate math + a handful of fetches instead of downloading and scanning every block. Live WS pushes still carry full items. SP_DUST_LIMIT defaults to 0 (receive everything; the indexer honors any value). Verified end-to-end on regtest: the filter flow finds the output. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/silentpay.js | 40 ++++++++++++++++++++++++++++++++++++++++ src/wallet.js | 40 +++++++++++++++++++++++++++------------- tools/sp-indexer.js | 23 ++++++++++++++++------- 3 files changed, 83 insertions(+), 20 deletions(-) (limited to 'src') diff --git a/src/silentpay.js b/src/silentpay.js index 16de66b..1732ced 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -292,3 +292,43 @@ export function encodeSilentPaymentAddress(scanPub, spendPub, { testnet = false const words = [0, ...bech32m.toWords(concatBytes(scanPub, spendPub))]; return bech32m.encode(hrp, words, 1023); } + +// ---- catch-up scaling: candidate keys + per-block Bloom filters ----------- +// For a large historical scan, downloading every block's outputs is wasteful. +// The indexer gives, per block, the tweaks + a Bloom filter over that block's +// taproot output keys. The client derives its candidate key P_0 from each tweak +// and tests the filter; only on a hit does it pull the block's outputs and run +// the full scan. (P_0 always exists if we're paid, so testing it alone suffices.) + +// Our candidate output key (P_0 = B_spend + t_0·G, x-only hex) for one tweak. +export function silentPaymentCandidate({ scanPriv, spendPub, tweak }) { + const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true); + const t0 = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(0))))); + return bytesToHex(Point.fromHex(spendPub).add(G.multiply(t0)).toRawBytes(true).slice(1)); +} + +// k bit indices for a 32-byte x-only key (hex) in an m-bit filter, via +// double-hashing one sha256(key): idx_i = (h1 + i·h2) mod m. +function* bloomIndices(keyHex, m, k) { + const h = sha256(hexToBytes(keyHex)); + const u32 = (o) => (h[o] | (h[o + 1] << 8) | (h[o + 2] << 16) | (h[o + 3] * 0x1000000)) >>> 0; + const h1 = u32(0); + const h2 = u32(4) | 1; // odd, so it's coprime-ish with m + for (let i = 0; i < k; i++) yield ((h1 + i * h2) >>> 0) % m; +} + +// Build a Bloom filter over x-only keys (hex). { m (bits), k (hashes), bits (hex) }. +export function buildBloom(keysHex, bitsPerKey = 10, k = 7) { + const m = Math.max(8, keysHex.length * bitsPerKey); + const bytes = new Uint8Array(Math.ceil(m / 8)); + for (const key of keysHex) for (const idx of bloomIndices(key, m, k)) bytes[idx >> 3] |= 1 << (idx & 7); + return { m, k, bits: bytesToHex(bytes) }; +} + +// Is an x-only key (hex) possibly in the filter? (false = definitely not.) +export function bloomHas(filter, keyHex) { + if (!filter || !filter.m) return true; // no filter ⇒ can't rule out → scan it + const bytes = hexToBytes(filter.bits); + for (const idx of bloomIndices(keyHex, filter.m, filter.k)) if (!(bytes[idx >> 3] & (1 << (idx & 7)))) return false; + return true; +} diff --git a/src/wallet.js b/src/wallet.js index 5b5ed90..7b4c8ae 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -24,7 +24,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; import { NostrSync, getSyncConfig } from './nostr.js'; -import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey } from './silentpay.js'; +import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; // No look-ahead: stop scanning a chain at the first unused address. This wallet @@ -39,6 +39,11 @@ const USED_HIT_DELAY_MS = 500; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +// Silent-payment catch-up dust limit (sats): the indexer drops tweaks for txs +// whose taproot outputs are all below this, cutting the candidate math on a big +// scan. 0 = receive everything (default, so no small payment is ever missed). +const SP_DUST_LIMIT = 0; + const NETS = { mainnet: { net: btc.NETWORK, coin: 0 }, testnet: { net: btc.TEST_NETWORK, coin: 1 }, @@ -319,21 +324,30 @@ export class Wallet { try { if (rescan) { this.spUtxos = []; this.lastSpScan = 0; } const from = (this.lastSpScan || 0) + 1; - const res = await (await fetch(`${indexer}/scan/${from}`)).json(); + // Catch-up: per block we get tweaks + a Bloom filter (no outputs). Derive a + // candidate key per tweak, test the filter, and only fetch the full block on + // a hit — so most blocks cost just the candidate math, no download/scan. + const res = await (await fetch(`${indexer}/scan/${from}?dustLimit=${SP_DUST_LIMIT}`)).json(); const tip = res.tip || 0; const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); const found = []; - for (const it of res.items || []) { - const matches = silentPaymentScan({ - scanPriv: keys.scanPriv, spendPub: keys.spendPub, - tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly), - }); - for (const m of matches) { - const o = it.outputs.find((o) => o.xonly === m.output); - const id = `${it.txid}:${o.vout}`; - if (have.has(id)) continue; - have.add(id); - found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); + for (const block of res.blocks || []) { + let hit = false; + for (const tw of block.tweaks) { + const cand = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tw) }); + if (bloomHas(block.filter, cand)) { hit = true; break; } + } + if (!hit) continue; + const blk = await (await fetch(`${indexer}/block/${block.height}`)).json(); + for (const it of blk.items || []) { + const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); + for (const m of matches) { + const o = it.outputs.find((o) => o.xonly === m.output); + const id = `${it.txid}:${o.vout}`; + if (have.has(id)) continue; + have.add(id); + found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); + } } } if (tip > this.lastSpScan) this.lastSpScan = tip; diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js index 09f9a19..934c55c 100644 --- a/tools/sp-indexer.js +++ b/tools/sp-indexer.js @@ -16,7 +16,7 @@ import { Database } from 'bun:sqlite'; import net from 'node:net'; import http from 'node:http'; -import { inputPubKey, silentPaymentTweak } from '../src/silentpay.js'; +import { inputPubKey, silentPaymentTweak, buildBloom } from '../src/silentpay.js'; const RPC = process.env.CORE_RPC || 'http://127.0.0.1:18443'; const RPC_USER = process.env.RPC_USER || 'admin1'; @@ -216,13 +216,22 @@ const server = Bun.serve({ const b = qGetBlock.get(Number(m[1])); return json(b ? JSON.parse(b.items).map((i) => i.tweak) : []); } - // All SP-eligible tx items from height `from` to the tip, in one response — - // each tagged with its height. Lets a client scan a range without one fetch - // per block. (A future BIP-158/Bloom filter will cut this for large ranges.) + // Catch-up over a height range: per block, the tweaks (dust-filtered) + a + // Bloom filter over that block's taproot output keys — NOT the outputs. The + // client derives its candidate key per tweak, tests the filter, and only + // fetches /block for the rare hits. dustLimit (sats) drops txs whose taproot + // outputs are all below it (fewer tweaks to check). if ((m = p.match(/^\/scan\/(\d+)$/))) { - const items = []; - for (const row of qScanFrom.all(Number(m[1]))) for (const it of JSON.parse(row.items)) items.push({ height: row.height, ...it }); - return json({ tip: indexed, items }); + const dust = Number(url.searchParams.get('dustLimit') || 0); + const blocks = []; + for (const row of qScanFrom.all(Number(m[1]))) { + let items = JSON.parse(row.items); + if (dust > 0) items = items.filter((it) => it.outputs.some((o) => o.value >= dust)); + if (!items.length) continue; + const outKeys = items.flatMap((it) => it.outputs.map((o) => o.xonly)); + blocks.push({ height: row.height, tweaks: items.map((it) => it.tweak), filter: buildBloom(outKeys) }); + } + return json({ tip: indexed, blocks }); } // Current unconfirmed (mempool) SP-eligible txs — for the pending catch-up on // connect; live updates arrive as {type:'mempool'} WS pushes. -- cgit v1.2.3 From 700efba34e34a8282a7853cbbdaa0f7de21e218c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 17:35:03 -0700 Subject: Make silent-payment receives first-class in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - One Receive card with an Address ⇄ Silent payment toggle (no separate card, no BIP-352 badge, no scan-status line or Rescan button — discovery is automatic). - SP receipts appear in History like normal payments: wallet.history merges BIP84 txs with per-tx SP receive rows (spTxRows), newest first; the detail view and pagination use it. SP outputs carry a firstSeen timestamp. - Receive description simplified to 'A reusable address that can't be tracked.' - Removed the '· Silent payment' tag from the Send recipient line. Verified on regtest: a received SP shows in history and balance with no manual scan. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 75 +++++++++++++++++++---------------------------------------- src/i18n.js | 4 +++- src/wallet.js | 42 +++++++++++++++++++++++++++++---- 3 files changed, 65 insertions(+), 56 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 727e3cc..3a8f5fd 100644 --- a/src/app.js +++ b/src/app.js @@ -2224,55 +2224,28 @@ function receiveTab() { ); } + // One card with a toggle between the standard address and the silent-payment + // address (shown only when SP is available). const fresh = wallet.freshReceive(); const spAddr = wallet.silentPaymentsAvailable() ? wallet.silentPaymentAddress() : null; + const sp = spAddr && ui.receiveType === 'sp'; + const addr = sp ? spAddr : fresh.address; return h( 'div', - { class: 'col', style: 'gap:16px' }, - h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, - h('div', { html: qrSvg(fresh.address) }), - h('div', { class: 'addr-box', style: 'width:100%' }, fresh.address), - copyBtn(fresh.address, t('copyAddress')) - ), - spAddr ? silentPaymentReceiveCard(spAddr) : null - ); -} - -// Reusable silent-payment receive address (sp1…/tsp1…) + on-device scan. The -// address is static and reusable; senders derive a unique on-chain output each -// time, so there's no address reuse and nothing links the payments. -function silentPaymentReceiveCard(spAddr) { - const bal = wallet.spBalance(); - return h( - 'div', - { class: 'card col', style: 'gap:12px' }, - h('div', { class: 'row between' }, - h('h3', { style: 'margin:0' }, t('spReceiveTitle')), - h('span', { class: 'small muted' }, 'BIP-352') - ), - h('p', { class: 'small muted', style: 'margin:0' }, t('spReceiveDesc')), - h('div', { style: 'align-self:center' }, h('div', { html: qrSvg(spAddr) })), - h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, spAddr), - copyBtn(spAddr, t('copyAddress')), - h('div', { class: 'row between', style: 'align-items:center' }, - h('span', { class: 'small muted' }, - wallet.spScanning - ? t('spScanning') - : bal.count - ? t('spReceived', { amount: fmtAmount(bal.confirmed + bal.pending) + ' ' + unitLabel(), n: bal.count }) - : t('spWatching')), - h('button', { class: 'linklike small', disabled: wallet.spScanning, onClick: doSpScan }, t('spRescan')) - ) + { class: 'card col', style: 'align-items:center;gap:14px' }, + spAddr + ? h('div', { class: 'seg', style: 'display:flex;width:100%' }, + h('button', { type: 'button', class: (!sp ? 'active ' : '') + 'grow', onClick: () => { ui.receiveType = 'address'; render(); } }, t('receiveAddressTab')), + h('button', { type: 'button', class: (sp ? 'active ' : '') + 'grow', onClick: () => { ui.receiveType = 'sp'; render(); } }, t('receiveSpTab')) + ) + : null, + h('div', { html: qrSvg(addr) }), + h('div', { class: 'addr-box' + (sp ? ' break' : ''), style: 'width:100%' + (sp ? ';font-size:12px' : '') }, addr), + copyBtn(addr, t('copyAddress')), + sp ? h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('spReceiveDesc')) : null ); } -// Auto-scanning runs on the poll loop; this is a manual full rescan. -function doSpScan() { - if (wallet.spScanning || wallet.offline) return; - wallet.scanSilentPayments({ rescan: true }).catch(() => {}).finally(render); - render(); -} - // Silent-payment tweak indexer picker (per network) — the endpoint used to scan // for received silent payments. Only shown for wallets that can derive SP keys. function spIndexerCard() { @@ -2609,7 +2582,6 @@ function recipientRow(s, r, i) { const syncCheck = () => { const a = r.address.trim(); const nodes = addrVerifyNodes(a); - if (a && isSilentPaymentAddress(a)) nodes.push(h('span', { class: 'small faint', style: 'margin-left:6px' }, '· ' + t('silentPaymentNote'))); check.replaceChildren(...nodes); check.style.display = a ? '' : 'none'; }; @@ -3037,14 +3009,15 @@ function giftsAllView(gifts) { function historyTab() { if (ui.bump) return bumpView(); + const txs = wallet.history; // BIP84 txs + silent-payment receipts, newest first if (ui.txDetail) { - const tx = wallet.txs.find((x) => x.txid === ui.txDetail); + const tx = txs.find((x) => x.txid === ui.txDetail); if (tx) return txDetailView(tx); ui.txDetail = null; } if (wallet.offline) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('historyOffline'))); - if ((wallet.scanning && !wallet.loaded) || (wallet.historyLoading && !wallet.txs.length)) + if ((wallet.scanning && !wallet.loaded) || (wallet.historyLoading && !txs.length)) return h( 'div', { class: 'card center col', style: 'align-items:center;gap:10px' }, @@ -3055,15 +3028,15 @@ function historyTab() { // on-chain history; they aren't transactions until claimed or revoked. const gifts = wallet.loaded ? wallet.outstandingGifts() : []; if (ui.giftsAll && gifts.length) return giftsAllView(gifts); - if (!wallet.txs.length && !gifts.length) + if (!txs.length && !gifts.length) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet'))); // Show at most 3 gifts inline; the rest live behind "View all". Transactions // paginate 10 at a time. const giftsHead = gifts.slice(0, 3); - const txPages = Math.ceil(wallet.txs.length / PAGE_SIZE); + const txPages = Math.ceil(txs.length / PAGE_SIZE); const txPage = Math.min(ui.txPage, Math.max(0, txPages - 1)); - const txSlice = wallet.txs.slice(txPage * PAGE_SIZE, txPage * PAGE_SIZE + PAGE_SIZE); + const txSlice = txs.slice(txPage * PAGE_SIZE, txPage * PAGE_SIZE + PAGE_SIZE); return h( 'div', { class: 'card' }, @@ -3074,7 +3047,7 @@ function historyTab() { gifts.length > 3 ? h('button', { class: 'btn-sm btn-block', style: 'margin-top:8px', onClick: () => { ui.giftsAll = true; ui.giftsPage = 0; ui.revokeId = null; render(); } }, t('viewAllGifts', { n: gifts.length })) : null, - pager(txPage, wallet.txs.length, (p) => { ui.txPage = p; render(); }), + pager(txPage, txs.length, (p) => { ui.txPage = p; render(); }), wallet.historyLoading ? h( 'div', @@ -3093,8 +3066,8 @@ function historyTab() { async function openTx(txid) { ui.txDetail = txid; render(); - const tx = wallet.txs.find((t) => t.txid === txid); - if (!tx || tx.fee || wallet.offline) return; + const tx = wallet.history.find((t) => t.txid === txid); + if (!tx || tx.sp || tx.fee || wallet.offline) return; try { const full = await wallet.api.getTx(txid); if (!full || ui.txDetail !== txid) return; diff --git a/src/i18n.js b/src/i18n.js index f6a94c0..adfa4e8 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -302,7 +302,9 @@ const STR = { electrumUrlHint: 'A WebSocket Electrum server, e.g. wss://your-node:50004', silentPaymentNote: 'Silent payment', spReceiveTitle: 'Silent payment address', - spReceiveDesc: 'A reusable address that leaks nothing on-chain — each sender derives a unique, unlinkable output. Scanning finds payments on-device using the indexer set under Data source.', + spReceiveDesc: "A reusable address that can't be tracked.", + receiveAddressTab: 'Address', + receiveSpTab: 'Silent payment', spScan: 'Scan for silent payments', spScanning: 'Scanning…', spWatching: 'Watching for payments', diff --git a/src/wallet.js b/src/wallet.js index 7b4c8ae..2ea9dad 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -304,6 +304,38 @@ export class Wallet { return { confirmed, pending, count: this.spUtxos.length }; } + // Received silent payments as history rows (one per tx; multiple outputs to us + // sum into one received entry) so they appear in history like normal payments. + spTxRows() { + const byTx = new Map(); + for (const u of this.spUtxos) { + const e = byTx.get(u.txid) || { txid: u.txid, net: 0, confirmed: true, firstSeen: u.firstSeen || Date.now() }; + e.net += u.value; + if (!u.confirmed) e.confirmed = false; + e.firstSeen = Math.min(e.firstSeen, u.firstSeen || e.firstSeen); + byTx.set(u.txid, e); + } + return [...byTx.values()].map((e) => ({ + txid: e.txid, net: e.net, fee: 0, vsize: 0, sp: true, + confirmed: e.confirmed, + blockTime: e.confirmed ? Math.floor(e.firstSeen / 1000) : 0, + blockHeight: 0, + firstSeen: e.firstSeen, + })); + } + + // History for display: BIP84 txs + silent-payment receipts, newest first. (SP + // receipts pay one-time taproot keys, never our BIP84 addresses, so no overlap.) + get history() { + const rows = [...this.txs, ...this.spTxRows()]; + rows.sort((a, b) => { + if (a.confirmed !== b.confirmed) return a.confirmed ? 1 : -1; // pending first + if (a.confirmed) return (b.blockTime || 0) - (a.blockTime || 0); + return (b.firstSeen || 0) - (a.firstSeen || 0); + }); + return rows; + } + // The taproot address for an x-only output key (for backend UTXO lookups). _spAddress(xonly) { return btc.Address(this.netCfg.net).encode(btc.OutScript.decode(btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(xonly) }))); @@ -357,7 +389,7 @@ export class Wallet { try { const us = await this.api.addressUtxos(address); const u = us.find((x) => x.txid === f.txid && x.vout === f.vout); - if (u) this.spUtxos.push({ ...f, address, confirmed: !!(u.status && u.status.confirmed) }); + if (u) this.spUtxos.push({ ...f, address, confirmed: !!(u.status && u.status.confirmed), firstSeen: Date.now() }); } catch {} } await this._refreshSpUtxos(); @@ -440,7 +472,7 @@ export class Wallet { const o = it.outputs.find((o) => o.xonly === m.output); const existing = this.spUtxos.find((u) => u.txid === it.txid && u.vout === o.vout); if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } } - else { this.spUtxos.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: true }); changed = true; } + else { this.spUtxos.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: true, firstSeen: Date.now() }); changed = true; } } } if (height && height > this.lastSpScan) this.lastSpScan = height; @@ -457,13 +489,15 @@ export class Wallet { if (!keys) return; const confirmed = this.spUtxos.filter((u) => u.confirmed); const confirmedIds = new Set(confirmed.map((u) => `${u.txid}:${u.vout}`)); + const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen])); const pending = []; for (const it of items) { const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); for (const m of matches) { const o = it.outputs.find((o) => o.xonly === m.output); - if (confirmedIds.has(`${it.txid}:${o.vout}`)) continue; // already confirmed - pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false }); + const id = `${it.txid}:${o.vout}`; + if (confirmedIds.has(id)) continue; // already confirmed + pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() }); } } const prevPending = this.spUtxos.filter((u) => !u.confirmed).length; -- cgit v1.2.3 From 803ccd12cded3f09086c715fffbc918a01575e09 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 17:45:01 -0700 Subject: Default mainnet to the hosted coinos silent-payment indexer Mainnet wallets now use https://halwallet.app/sp (our indexer on cs, behind nginx /sp), enabling silent-payment receiving on mainnet. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index 5287e13..56978f8 100644 --- a/src/api.js +++ b/src/api.js @@ -112,12 +112,12 @@ function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; } // outputs, so self-hosting is the privacy-max option. Per network; an empty URL // means SP receiving is unavailable on that network. const SP_INDEXER_PRESETS_BY_NET = { - mainnet: [{ id: 'custom', label: 'Custom', url: '' }], // a hosted default goes here once we run one + mainnet: [{ id: 'coinos', label: 'coinos', url: 'https://halwallet.app/sp' }, { id: 'custom', label: 'Custom', url: '' }], testnet: [{ id: 'custom', label: 'Custom', url: '' }], mutinynet: [{ id: 'custom', label: 'Custom', url: '' }], regtest: [{ id: 'local', label: 'Local (localhost:8888)', url: 'http://localhost:8888' }, { id: 'custom', label: 'Custom', url: '' }], }; -const SP_INDEXER_DEFAULT = { mainnet: 'custom', testnet: 'custom', mutinynet: 'custom', regtest: 'local' }; +const SP_INDEXER_DEFAULT = { mainnet: 'coinos', testnet: 'custom', mutinynet: 'custom', regtest: 'local' }; const SP_INDEXER_KEY = 'btc-wallet-sp-indexer'; export function spIndexerPresets(net = getNetwork()) { return SP_INDEXER_PRESETS_BY_NET[net] || SP_INDEXER_PRESETS_BY_NET.mainnet; } export function getSpIndexerConfig(net = getNetwork()) { -- cgit v1.2.3 From 0a17635c93b1e7ec1b2937ece25000c518881f64 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 19:12:29 -0700 Subject: Browser history / back-button navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the app's screen position (screen, tab, txDetail, bump, giftsAll, giftMode, claimStep) into the browser history, so Back/Forward (and Android system back) move between screens actually viewed. render() snapshots just these nav fields and pushState()s on change — incidental re-renders (typing, polling) don't add entries. popstate restores the snapshot and re-renders. History-API calls are guarded so they can never break a render. Verified via CDP: tab nav and tx-detail push entries; Back/Forward (incl. A→B→A) restore the right screen and the DOM follows. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) (limited to 'src') diff --git a/src/app.js b/src/app.js index 3a8f5fd..00bdbba 100644 --- a/src/app.js +++ b/src/app.js @@ -249,9 +249,59 @@ function render() { // Remember where we are so a refresh restores it (only meaningful on the wallet). try { sessionStorage.setItem(NAV_KEY, JSON.stringify({ tab: ui.tab, txDetail: ui.txDetail })); } catch {} } + syncHistory(); // mirror the current screen into browser history (Back/Forward) } wallet.subscribe(render); +// ---- browser-history navigation ------------------------------------------ +// Mirror the app's screen position into the browser history so the Back/Forward +// buttons (and Android/system back) move between screens we've actually viewed. +// We snapshot only the navigation-relevant `ui` fields, so incidental re-renders +// (typing, polling, balance updates) don't create history entries. +const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'bump', 'giftsAll', 'giftMode', 'claimStep']; +function navSnapshot() { + const s = {}; + for (const f of NAV_FIELDS) s[f] = ui[f] ?? null; + return s; +} +const navSig = (s) => JSON.stringify(s); +let navStack = []; // in-memory mirror of the history entries (to detect an in-app Back) +let navIndex = -1; +let restoringHistory = false; // true while applying a popstate (suppresses pushing) + +function syncHistory() { + if (restoringHistory) return; + try { + const snap = navSnapshot(); + const sig = navSig(snap); + if (navIndex >= 0 && sig === navSig(navStack[navIndex])) return; // no navigation change + // Every screen change is a new history entry. (We deliberately don't try to + // detect in-app "back" navigations — an A→B→A pattern is indistinguishable + // from a genuine back, so guessing corrupts the stack. An in-app back just + // adds an entry; Back/Forward still walk the screens correctly.) + navStack = navStack.slice(0, navIndex + 1); // drop any forward entries + navStack.push(snap); + navIndex++; + const entry = { nav: snap, i: navIndex }; + if (navIndex === 0) history.replaceState(entry, ''); + else history.pushState(entry, ''); + } catch {} // history API failures must never break a render +} + +window.addEventListener('popstate', (e) => { + const st = e.state; + const snap = (st && st.nav) || navSnapshot(); + restoringHistory = true; + try { + for (const f of NAV_FIELDS) ui[f] = f in snap ? snap[f] : null; + if (st && typeof st.i === 'number') navIndex = st.i; + else { const i = navStack.findIndex((s) => navSig(s) === navSig(snap)); if (i >= 0) navIndex = i; } + render(); + } finally { + restoringHistory = false; + } +}); + // ---------------------------------------------------------------- utilities let toastTimer; function toast(msg) { -- cgit v1.2.3 From 77a14981edc9b65bfce52a2e97ec99e6069e9c77 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 19:36:54 -0700 Subject: Wire in-app back buttons to pop browser history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen-changing back buttons (tx detail, gift sub-view, all-gifts, bump, account settings, accounts) now call goBack(), which does history.back() when there's an entry to pop — so they no longer push a duplicate entry. Falls back to an explicit parent-screen navigation when there's no in-app history (e.g. reloaded straight into a sub-screen). Modal-dismiss buttons (revoke, rename, password, draft) are unchanged — they don't touch nav state. Verified via CDP: detail (i=2) → in-app back → i=1 (popped) → Forward restores the detail. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 00bdbba..ce8ee13 100644 --- a/src/app.js +++ b/src/app.js @@ -302,6 +302,15 @@ window.addEventListener('popstate', (e) => { } }); +// In-app "back" buttons call this instead of mutating ui + render() directly, so +// they POP the history entry they're leaving rather than pushing a duplicate. +// When there's no in-app entry to pop (e.g. the page was reloaded straight into a +// sub-screen), fall back to navigating to the explicit parent. +function goBack(toParent) { + if (navIndex > 0) history.back(); + else { toParent(); render(); } +} + // ---------------------------------------------------------------- utilities let toastTimer; function toast(msg) { @@ -1999,7 +2008,7 @@ function accountsScreen() { hasVault() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.confirmClear = true; render(); } }, t('clearAll')) ), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.screen = 'wallet'; render(); } }, t('back')) + h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.screen = 'wallet'; }) }, t('back')) ); } @@ -2103,7 +2112,7 @@ function accountSettingsScreen() { h('select', { onChange: (e) => { a.autoLock = Number(e.target.value) || 0; persistAccounts(); if (a.persisted) writeVault(); render(); } }, AUTOLOCK_OPTIONS.map((o) => h('option', { value: String(o.ms), selected: o.ms === accAutoLock(a) }, t(o.label)))) ), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; ui.screen = 'accounts'; render(); } }, t('back')) + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; goBack(() => { ui.screen = 'accounts'; }); } }, t('back')) ); } @@ -2383,7 +2392,7 @@ function giftView() { 'div', { class: 'col', style: 'gap:12px' }, giftCard(), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftMode = false; ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; render(); } }, t('back')) + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; goBack(() => { ui.giftMode = false; }); } }, t('back')) ); } @@ -2563,7 +2572,7 @@ function bumpView() { ), (ui.sendError || planErr) && h('div', { class: 'notice err' }, ui.sendError || planErr), h('div', { class: 'row gap6' }, - h('button', { class: 'btn-ghost', onClick: () => { ui.bump = null; ui.sendError = ''; render(); } }, t('back')), + h('button', { class: 'btn-ghost', onClick: () => { ui.sendError = ''; goBack(() => { ui.bump = null; }); } }, t('back')), ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary grow', disabled: !newFee, onClick: doBump }, t('replaceTx')) @@ -3050,7 +3059,7 @@ function giftsAllView(gifts) { { class: 'card col', style: 'gap:12px' }, h('div', { class: 'row between' }, h('h3', { style: 'margin:0' }, t('giftReserved', { n: gifts.length })), - h('button', { class: 'btn-sm', onClick: () => { ui.giftsAll = false; ui.revokeId = null; render(); } }, t('back')) + h('button', { class: 'btn-sm', onClick: () => { ui.revokeId = null; goBack(() => { ui.giftsAll = false; }); } }, t('back')) ), h('div', { class: 'list' }, ...slice.map(giftHistoryItem)), pager(page, gifts.length, (p) => { ui.giftsPage = p; render(); }) @@ -3170,7 +3179,7 @@ function txDetailView(tx) { ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary btn-block', onClick: () => bumpFee(tx.txid) }, t('bumpFee'))) : null, - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.txDetail = null; render(); } }, t('backToHistory')) + h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.txDetail = null; }) }, t('backToHistory')) ); } -- cgit v1.2.3 From df58295848c7675e6501148da2a988b918811a2c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 23:17:56 -0700 Subject: Fix SP scanning jank: memoize by tweak + yield + coalesce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling (CDP) showed the toggle lag/high CPU was _scanSpMempool re-scanning the ENTIRE mempool item set on every WS push (the mempool churns constantly), redoing an EC point-multiplication per tweak on the main thread — ~1s blocks. - Memoize silentPaymentScan/silentPaymentCandidate by tweak (outcome is fixed), cleared when the SP keys change, capped to bound memory. Steady-state CPU from re-scanning dropped from ~1s/push to ~0 (measured 18ms over 9s idle). - Yield to the event loop during the catch-up block loop and the mempool scan, so a large first/cold scan (mainnet) doesn't freeze the UI. - Coalesce mempool scans (latest-wins, never two at once) with a race guard so a block confirming mid-scan can't duplicate an outpoint. Measured after: receive toggle 8/8 switches, avg 5ms / max 9ms (was 1-2s). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index 2ea9dad..2f9cca5 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -282,9 +282,37 @@ export class Wallet { else if (this.xprv) { const n = HDKey.fromExtendedKey(this.xprv); if (n.depth === 0) master = n; } this._spKeys = master ? deriveSilentPaymentKeys(master, coin) : null; this._spKeysKey = ck; + this._spScanCache = new Map(); // per-tweak scan results are keyed to these keys + this._spCandCache = new Map(); return this._spKeys; } + // Scan one tweak's outputs for our payments, memoized by tweak — the EC + // point-multiplication is the cost, and a tweak's outcome never changes, so + // repeated mempool pushes (which re-send the same tweaks) stay cheap. + _spScan(tweakHex, outputs) { + const keys = this.silentPaymentKeys(); // resets the caches if keys changed + if (!keys) return []; + let m = this._spScanCache.get(tweakHex); + if (m) return m; + if (this._spScanCache.size > 100000) this._spScanCache.clear(); // bound memory over long sessions + m = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex), outputs }); + this._spScanCache.set(tweakHex, m); + return m; + } + + // Our candidate key for a tweak (for the Bloom pre-filter), memoized likewise. + _spCandidate(tweakHex) { + const keys = this.silentPaymentKeys(); + if (!keys) return null; + let c = this._spCandCache.get(tweakHex); + if (c) return c; + if (this._spCandCache.size > 100000) this._spCandCache.clear(); // bound memory over long sessions + c = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex) }); + this._spCandCache.set(tweakHex, c); + return c; + } + // Our silent-payment address (sp1…/tsp1…), or null if this wallet can't do SP. silentPaymentAddress() { const k = this.silentPaymentKeys(); @@ -363,16 +391,20 @@ export class Wallet { const tip = res.tip || 0; const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); const found = []; + let scanned = 0; for (const block of res.blocks || []) { + // Yield to the event loop periodically — the candidate math is an EC + // multiply per tweak and a big catch-up would otherwise freeze the UI. + if (++scanned % 8 === 0) await new Promise((r) => setTimeout(r, 0)); let hit = false; for (const tw of block.tweaks) { - const cand = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tw) }); + const cand = this._spCandidate(tw); if (bloomHas(block.filter, cand)) { hit = true; break; } } if (!hit) continue; const blk = await (await fetch(`${indexer}/block/${block.height}`)).json(); for (const it of blk.items || []) { - const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); + const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly)); for (const m of matches) { const o = it.outputs.find((o) => o.xonly === m.output); const id = `${it.txid}:${o.vout}`; @@ -467,7 +499,7 @@ export class Wallet { if (!keys) return; let changed = false; for (const it of items) { - const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); + const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly)); for (const m of matches) { const o = it.outputs.find((o) => o.xonly === m.output); const existing = this.spUtxos.find((u) => u.txid === it.txid && u.vout === o.vout); @@ -485,26 +517,46 @@ export class Wallet { // SP set — so payments show the moment they're broadcast. Confirmed entries are // kept; the unconfirmed set is replaced, which also drops vanished/RBF'd ones. async _scanSpMempool(items) { - const keys = this.silentPaymentKeys(); - if (!keys) return; - const confirmed = this.spUtxos.filter((u) => u.confirmed); - const confirmedIds = new Set(confirmed.map((u) => `${u.txid}:${u.vout}`)); - const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen])); - const pending = []; - for (const it of items) { - const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); - for (const m of matches) { - const o = it.outputs.find((o) => o.xonly === m.output); - const id = `${it.txid}:${o.vout}`; - if (confirmedIds.has(id)) continue; // already confirmed - pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() }); + // Coalesce: pushes arrive faster than a cold (mainnet) scan completes, so + // keep only the latest set and never run two scans at once. + this._spMempoolLatest = items; + if (this._spMempoolBusy) return; + this._spMempoolBusy = true; + try { + if (!this.silentPaymentKeys()) return; + while (this._spMempoolLatest) { + const cur = this._spMempoolLatest; + this._spMempoolLatest = null; + const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen])); + const confirmedIds = new Set(this.spUtxos.filter((u) => u.confirmed).map((u) => `${u.txid}:${u.vout}`)); + const pending = []; + let i = 0; + for (const it of cur) { + // Yield periodically so a large first scan doesn't block the UI (cached + // tweaks are cheap, so steady-state pushes barely pause here). + if (++i % 50 === 0) await new Promise((r) => setTimeout(r, 0)); + const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly)); + for (const m of matches) { + const o = it.outputs.find((o) => o.xonly === m.output); + const id = `${it.txid}:${o.vout}`; + if (confirmedIds.has(id)) continue; + pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() }); + } + } + // Re-read confirmed (a block push may have landed during a yield) and drop + // any pending that just confirmed, so an outpoint never appears twice. + const confNow = this.spUtxos.filter((u) => u.confirmed); + const confNowIds = new Set(confNow.map((u) => `${u.txid}:${u.vout}`)); + const prevPending = this.spUtxos.length - confNow.length; + const freshPending = pending.filter((p) => !confNowIds.has(`${p.txid}:${p.vout}`)); + this.spUtxos = [...confNow, ...freshPending]; + if (freshPending.length !== prevPending || freshPending.length) this._recomputeBalanceFromChains(); + this.saveCache(); + this.emit(); } + } finally { + this._spMempoolBusy = false; } - const prevPending = this.spUtxos.filter((u) => !u.confirmed).length; - this.spUtxos = [...confirmed, ...pending]; - if (pending.length !== prevPending || pending.length) this._recomputeBalanceFromChains(); - this.saveCache(); - this.emit(); } node(chain, index) { -- cgit v1.2.3 From e8edfd88b14ad07f5f97f5cb1d5d603d825a413d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 03:15:00 -0700 Subject: Scan silent payments from each wallet's birth, not the indexer's start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshly-generated wallet can't have received SP before it existed, so set its scan watermark to the current tip on first connect (skipping the whole historical catch-up) instead of scanning from 0. Threaded a 'generated' flag through the create flow (openWallet → enterWallet → activateAccount → load spFresh); imported wallets still scan from the indexer start since they may have prior receipts. Verified on regtest: a fresh wallet skips the block-5780 receipt (watermark=tip, balance 0); the same seed imported finds it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 12 ++++++------ src/wallet.js | 13 ++++++++++++- 2 files changed, 18 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index ce8ee13..9c79ac4 100644 --- a/src/app.js +++ b/src/app.js @@ -542,7 +542,7 @@ function createPane() { ), h( 'button', - { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic) }, + { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification') ) ); @@ -595,11 +595,11 @@ function createPane() { if (hasPass && ui.confirmPass !== ui.passphrase) { ui.unlockError = t('passphraseMismatch'); render(); return; } - openWallet(ui.draftMnemonic); + openWallet(ui.draftMnemonic, { generated: true }); }, }, t('openWallet')) ), - h('button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic) }, t('skipVerification')) + h('button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification')) ); } @@ -657,11 +657,11 @@ function optionsPanel() { // Import accepts a recovery phrase, an xpub/zpub (watch-only), or an xprv/zprv // (full spending). Classify the pasted text and open the right kind of wallet. -async function openWallet(input) { +async function openWallet(input, opts = {}) { ui.unlockError = ''; const raw = (input || '').trim(); const m = raw.replace(/\s+/g, ' '); - if (isValidMnemonic(m)) { await enterWallet(m, ui.passphrase); return; } + if (isValidMnemonic(m)) { await enterWallet(m, ui.passphrase, { generated: opts.generated }); return; } let pk; try { pk = parseExtendedKey(raw); } catch { ui.unlockError = t('invalidImport'); render(); return; } const acc = pk.kind === 'xpub' @@ -693,7 +693,7 @@ async function activateAccount(acc, opts = {}) { const netName = getNetwork(); if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName, offline: false }); else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName, offline: false }); - else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName, offline: false }); + else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName, offline: false, spFresh: !!opts.generated }); // Record the account-level xpub so this wallet survives a session wipe as a // watch-only entry (see the durable account directory) — you keep seeing your // balance/history and re-enter the seed to spend again. diff --git a/src/wallet.js b/src/wallet.js index 2f9cca5..497b60b 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -158,8 +158,11 @@ export class Wallet { } // --- setup -------------------------------------------------------------- - load({ mnemonic = '', passphrase = '', xpub = '', xprv = '', netName = 'mainnet', offline = false }) { + load({ mnemonic = '', passphrase = '', xpub = '', xprv = '', netName = 'mainnet', offline = false, spFresh = false }) { this.stopRealtime(); + // A freshly-generated wallet can't have received silent payments before it + // existed, so we start its SP watermark at the current tip (no history scan). + this._spFresh = spFresh; this.mnemonic = (mnemonic || '').trim().replace(/\s+/g, ' '); this.passphrase = passphrase; this.xpub = xpub || ''; @@ -377,6 +380,14 @@ export class Wallet { const indexer = spIndexerUrl(this.netName); if (!keys || !indexer || this.offline) return { unavailable: true }; if (this._spScanBusy) return { busy: true }; + // Fresh wallet, never scanned: set the watermark to the current tip instead of + // scanning all history (nothing predates the wallet). New blocks + mempool + // arrive via the live push from here on. + if (this._spFresh && !this.lastSpScan && !rescan) { + this._spFresh = false; + try { this.lastSpScan = (await (await fetch(`${indexer}/height`)).json()).height || 0; this.saveCache(); } catch {} + return { fresh: true }; + } this._spScanBusy = true; // Only a manual scan shows the visible "Scanning…" state; auto-scans are // invisible (setting spScanning for them left the card stuck on "Scanning…"). -- cgit v1.2.3 From 935adfe4a28cedd17f97ea3a6be7ccbafe6c7cec Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 03:21:07 -0700 Subject: Run silent-payment scanning in a Web Worker The EC crypto (candidate derivation + full scan) now runs in a worker, off the main thread, so even a large mainnet scan can't jank the UI at all. The worker is bundled separately and embedded as base64 (single-file app), spawned from a Blob URL; it holds the keys + per-tweak cache. If workers are unavailable or the bundle is missing, scanning falls back to the main thread (still cached + yielding). scanSilentPayments / _scanSpItems / _scanSpMempool now go through _spHitBlocks and _spScanItems, which dispatch to the worker (or the fallback). +27KB to index.html for the worker's crypto bundle. Verified via CDP: worker runs keys/candidates/scan correctly in-browser; a real reload + mainnet scan threw 0 exceptions and the SP address renders. Co-Authored-By: Claude Opus 4.8 (1M context) --- build.js | 17 ++++++++ src/sp-worker.js | 69 +++++++++++++++++++++++++++++++ src/wallet.js | 122 +++++++++++++++++++++++++++++++++++++------------------ 3 files changed, 169 insertions(+), 39 deletions(-) create mode 100644 src/sp-worker.js (limited to 'src') diff --git a/build.js b/build.js index 59c399b..97744b1 100644 --- a/build.js +++ b/build.js @@ -121,6 +121,21 @@ export async function buildJsQr({ minify = true } = {}) { return result.outputs[0].text(); } +// The silent-payment scanning worker, bundled separately (its own global scope) +// and embedded as base64 so the single-file app can spawn it from a Blob URL. +export async function buildSpWorker({ minify = true } = {}) { + const result = await Bun.build({ + entrypoints: ['./src/sp-worker.js'], + target: 'browser', + minify, + }); + if (!result.success) { + for (const log of result.logs) console.error(log); + throw new Error('sp-worker bundle failed'); + } + return result.outputs[0].text(); +} + export async function buildHtml({ minify = true, pwa = minify } = {}) { const result = await Bun.build({ entrypoints: ['./src/app.js'], @@ -134,6 +149,7 @@ export async function buildHtml({ minify = true, pwa = minify } = {}) { let js = await result.outputs[0].text(); // Guard against a literal inside the bundle closing our tag early. js = js.replaceAll(' @@ -149,6 +165,7 @@ ${pwa ? PWA_HEAD : ''}
+ ${pwa ? SW_REGISTER + '\n' : ''} `; diff --git a/src/sp-worker.js b/src/sp-worker.js new file mode 100644 index 0000000..59a1769 --- /dev/null +++ b/src/sp-worker.js @@ -0,0 +1,69 @@ +// Web Worker: the EC-heavy silent-payment crypto, kept off the main thread so a +// large scan never janks the UI. Memoizes per tweak (a tweak's outcome is fixed), +// so repeated mempool pushes — which re-send the same tweaks — stay cheap. +// +// Protocol (request/response by id): +// { id, op:'keys', scanPriv, spendPub } → { id, ok:true } +// { id, op:'candidates', blocks:[{height,tweaks,filter}] } → { id, hits:[height] } +// { id, op:'scan', items:[{txid,tweak,outputs}] } → { id, found:[utxo] } + +import { hex } from '@scure/base'; +import { silentPaymentScan, silentPaymentCandidate, bloomHas } from './silentpay.js'; + +let scanPriv = null; +let spendPub = null; +const scanCache = new Map(); // tweakHex -> matches +const candCache = new Map(); // tweakHex -> candidate x-only hex + +self.onmessage = (e) => { + const { id, op } = e.data; + try { + if (op === 'keys') { + scanPriv = e.data.scanPriv; + spendPub = e.data.spendPub; + scanCache.clear(); + candCache.clear(); + self.postMessage({ id, ok: true }); + return; + } + if (op === 'candidates') { + // Return the heights of blocks whose Bloom filter could contain one of our + // outputs (derive a candidate per tweak, test the filter, stop on first hit). + const hits = []; + for (const b of e.data.blocks) { + for (const tw of b.tweaks) { + let c = candCache.get(tw); + if (!c) { + if (candCache.size > 100000) candCache.clear(); + c = silentPaymentCandidate({ scanPriv, spendPub, tweak: hex.decode(tw) }); + candCache.set(tw, c); + } + if (bloomHas(b.filter, c)) { hits.push(b.height); break; } + } + } + self.postMessage({ id, hits }); + return; + } + if (op === 'scan') { + // Full scan: return resolved utxos {txid,vout,value,xonly,tweak} for ours. + const found = []; + for (const it of e.data.items) { + let m = scanCache.get(it.tweak); + if (!m) { + if (scanCache.size > 100000) scanCache.clear(); + m = silentPaymentScan({ scanPriv, spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); + scanCache.set(it.tweak, m); + } + for (const mm of m) { + const o = it.outputs.find((x) => x.xonly === mm.output); + if (o) found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: mm.output, tweak: mm.tweak }); + } + } + self.postMessage({ id, found }); + return; + } + self.postMessage({ id, error: 'unknown op ' + op }); + } catch (err) { + self.postMessage({ id, error: String((err && err.message) || err) }); + } +}; diff --git a/src/wallet.js b/src/wallet.js index 497b60b..006441c 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -316,6 +316,74 @@ export class Wallet { return c; } + // Spawn (once) the scanning worker and keep its keys current. Returns null if + // workers are unavailable or the embedded bundle is missing — callers then fall + // back to scanning on the main thread (still cached + yielding). + _ensureSpWorker() { + if (this._spWorkerFailed || typeof Worker === 'undefined') return null; + const keys = this.silentPaymentKeys(); + if (!keys) return null; + if (!this._spWorker) { + try { + const b64 = globalThis.__SP_WORKER__; + if (!b64) { this._spWorkerFailed = true; return null; } + const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); + const w = new Worker(URL.createObjectURL(new Blob([bytes], { type: 'text/javascript' }))); + this._spPending = new Map(); + this._spReqId = 0; + w.onmessage = (e) => { const p = this._spPending.get(e.data.id); if (p) { this._spPending.delete(e.data.id); e.data.error ? p.rej(new Error(e.data.error)) : p.res(e.data); } }; + w.onerror = () => { this._spWorkerFailed = true; try { w.terminate(); } catch {} this._spWorker = null; for (const p of this._spPending.values()) p.rej(new Error('sp worker error')); this._spPending.clear(); }; + this._spWorker = w; + this._spWorkerKeysSig = null; + } catch { this._spWorkerFailed = true; return null; } + } + if (this._spWorkerKeysSig !== this._spKeysKey) { + this._spWorkerKeysSig = this._spKeysKey; + this._spWorker.postMessage({ id: ++this._spReqId, op: 'keys', scanPriv: keys.scanPriv, spendPub: keys.spendPub }); + } + return this._spWorker; + } + + _spCall(op, data) { + return new Promise((res, rej) => { + const id = ++this._spReqId; + this._spPending.set(id, { res, rej }); + this._spWorker.postMessage({ id, op, ...data }); + setTimeout(() => { if (this._spPending.has(id)) { this._spPending.delete(id); rej(new Error('sp worker timeout')); } }, 30000); + }); + } + + // Block heights whose Bloom filter might hold one of our outputs (worker if + // available, else a cached + yielding main-thread loop). + async _spHitBlocks(blocks) { + if (!blocks.length) return new Set(); + const w = this._ensureSpWorker(); + if (w) { try { return new Set((await this._spCall('candidates', { blocks })).hits || []); } catch {} } + const hits = new Set(); + let i = 0; + for (const b of blocks) { + if (++i % 8 === 0) await new Promise((r) => setTimeout(r, 0)); + for (const tw of b.tweaks) { if (bloomHas(b.filter, this._spCandidate(tw))) { hits.add(b.height); break; } } + } + return hits; + } + + // Full-scan items → our resolved utxos {txid,vout,value,xonly,tweak} (worker if + // available, else a cached + yielding main-thread loop). + async _spScanItems(items) { + if (!items.length) return []; + const w = this._ensureSpWorker(); + if (w) { try { return (await this._spCall('scan', { items })).found || []; } catch {} } + const found = []; + let i = 0; + for (const it of items) { + if (++i % 50 === 0) await new Promise((r) => setTimeout(r, 0)); + const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly)); + for (const m of matches) { const o = it.outputs.find((x) => x.xonly === m.output); if (o) found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); } + } + return found; + } + // Our silent-payment address (sp1…/tsp1…), or null if this wallet can't do SP. silentPaymentAddress() { const k = this.silentPaymentKeys(); @@ -402,27 +470,15 @@ export class Wallet { const tip = res.tip || 0; const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); const found = []; - let scanned = 0; + const hits = await this._spHitBlocks(res.blocks || []); for (const block of res.blocks || []) { - // Yield to the event loop periodically — the candidate math is an EC - // multiply per tweak and a big catch-up would otherwise freeze the UI. - if (++scanned % 8 === 0) await new Promise((r) => setTimeout(r, 0)); - let hit = false; - for (const tw of block.tweaks) { - const cand = this._spCandidate(tw); - if (bloomHas(block.filter, cand)) { hit = true; break; } - } - if (!hit) continue; + if (!hits.has(block.height)) continue; const blk = await (await fetch(`${indexer}/block/${block.height}`)).json(); - for (const it of blk.items || []) { - const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly)); - for (const m of matches) { - const o = it.outputs.find((o) => o.xonly === m.output); - const id = `${it.txid}:${o.vout}`; - if (have.has(id)) continue; - have.add(id); - found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); - } + for (const f of await this._spScanItems(blk.items || [])) { + const id = `${f.txid}:${f.vout}`; + if (have.has(id)) continue; + have.add(id); + found.push(f); } } if (tip > this.lastSpScan) this.lastSpScan = tip; @@ -509,14 +565,10 @@ export class Wallet { const keys = this.silentPaymentKeys(); if (!keys) return; let changed = false; - for (const it of items) { - const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly)); - for (const m of matches) { - const o = it.outputs.find((o) => o.xonly === m.output); - const existing = this.spUtxos.find((u) => u.txid === it.txid && u.vout === o.vout); - if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } } - else { this.spUtxos.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: true, firstSeen: Date.now() }); changed = true; } - } + for (const f of await this._spScanItems(items)) { + const existing = this.spUtxos.find((u) => u.txid === f.txid && u.vout === f.vout); + if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } } + else { this.spUtxos.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), confirmed: true, firstSeen: Date.now() }); changed = true; } } if (height && height > this.lastSpScan) this.lastSpScan = height; if (changed) this._recomputeBalanceFromChains(); @@ -541,18 +593,10 @@ export class Wallet { const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen])); const confirmedIds = new Set(this.spUtxos.filter((u) => u.confirmed).map((u) => `${u.txid}:${u.vout}`)); const pending = []; - let i = 0; - for (const it of cur) { - // Yield periodically so a large first scan doesn't block the UI (cached - // tweaks are cheap, so steady-state pushes barely pause here). - if (++i % 50 === 0) await new Promise((r) => setTimeout(r, 0)); - const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly)); - for (const m of matches) { - const o = it.outputs.find((o) => o.xonly === m.output); - const id = `${it.txid}:${o.vout}`; - if (confirmedIds.has(id)) continue; - pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() }); - } + for (const f of await this._spScanItems(cur)) { + const id = `${f.txid}:${f.vout}`; + if (confirmedIds.has(id)) continue; + pending.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() }); } // Re-read confirmed (a block push may have landed during a yield) and drop // any pending that just confirmed, so an outpoint never appears twice. -- cgit v1.2.3 From a6e6083e4c758a9c07dc7a4ea38f880c561cb365 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 03:50:35 -0700 Subject: Stop input/select fields losing focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two causes, both fixed: - _scanSpMempool emitted on every WS push even when our pending set was unchanged. The mainnet mempool churns constantly, so this re-rendered the whole app every few seconds, stealing focus mid-type. Now it only emits when our pending SP set actually changes. - render() rebuilds the DOM (replaceChildren), which inherently drops focus. Added focus + caret preservation: capture the focused field's index-path and selection before the rebuild, restore them after. So any render source (poll, payment push, scan) is now invisible to someone typing. Verified via CDP: typing 'bc1q…' in the Send address, caret at 6, then forcing a re-render — focus, value, and caret all retained. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 31 +++++++++++++++++++++++++++++++ src/wallet.js | 9 +++++---- 2 files changed, 36 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 9c79ac4..2470ac8 100644 --- a/src/app.js +++ b/src/app.js @@ -225,7 +225,32 @@ function openHowItWorks() { // Per-tab session nav (tab + open tx), so a refresh keeps the user's place. const NAV_KEY = 'btc-wallet-nav'; +// Index path from #app down to a node, and back — used to re-find the focused +// field after a full rebuild (a background re-render produces the same structure). +function focusPath(el) { + const path = []; + for (let n = el; n && n !== root; n = n.parentNode) { + const p = n.parentNode; + if (!p) return null; + path.unshift(Array.prototype.indexOf.call(p.children, n)); + } + return path; +} +function nodeAtPath(path) { + let n = root; + for (const i of path) n = n && n.children[i]; + return n || null; +} + function render() { + // Preserve focus + caret across the rebuild, so a background update (poll, a + // payment push, an SP scan) can't kick the user out of a field they're editing. + const a = document.activeElement; + let fpath = null, selStart = null, selEnd = null; + if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA)$/.test(a.tagName)) { + fpath = focusPath(a); + try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {} + } const screen = ui.screen === 'wallet' ? walletScreen() @@ -241,6 +266,12 @@ function render() { ? howItWorksScreen() : unlockScreen(); root.replaceChildren(screen, footer()); + if (fpath) { + const el = nodeAtPath(fpath); + if (el && el !== a && /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) { + try { el.focus({ preventScroll: true }); if (selStart != null && el.setSelectionRange) el.setSelectionRange(selStart, selEnd); } catch {} + } + } // Fast-poll the receive address only while the user is actually watching for a // payment (wallet screen, Receive tab, online). Idempotent — safe each render. wallet.setWatchReceive(ui.screen === 'wallet' && ui.tab === 'receive' && !wallet.offline); diff --git a/src/wallet.js b/src/wallet.js index 006441c..5943d86 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -602,12 +602,13 @@ export class Wallet { // any pending that just confirmed, so an outpoint never appears twice. const confNow = this.spUtxos.filter((u) => u.confirmed); const confNowIds = new Set(confNow.map((u) => `${u.txid}:${u.vout}`)); - const prevPending = this.spUtxos.length - confNow.length; + const oldPendingIds = new Set(this.spUtxos.filter((u) => !u.confirmed).map((u) => `${u.txid}:${u.vout}`)); const freshPending = pending.filter((p) => !confNowIds.has(`${p.txid}:${p.vout}`)); + // Only emit when our pending set actually changed — the mempool churns + // constantly, and a no-op emit would re-render and steal input focus. + const changed = freshPending.length !== oldPendingIds.size || freshPending.some((p) => !oldPendingIds.has(`${p.txid}:${p.vout}`)); this.spUtxos = [...confNow, ...freshPending]; - if (freshPending.length !== prevPending || freshPending.length) this._recomputeBalanceFromChains(); - this.saveCache(); - this.emit(); + if (changed) { this._recomputeBalanceFromChains(); this.saveCache(); this.emit(); } } } finally { this._spMempoolBusy = false; -- cgit v1.2.3 From 76daca707a7b9cb9aafab1ceb8999ec3c708a63d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 04:14:07 -0700 Subject: Spend SP coins like any coin — confirmed-only, and able to fund SP payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things: - Only confirmed SP coins enter the spend pool. Unconfirmed ones are excluded (the sender's tx could be replaced, invalidating our spend) — matching the spendable headline, which already counted confirmed-only. - SP→SP sending: our own silent-payment coins can now fund a payment TO an sp1 address (previously excluded). _applySilentPayments includes SP inputs with their key d = spend_priv + t_k, and silentPaymentScripts negates a taproot input's key when it gives odd Y, per BIP-352 (so the sender's A = a·G matches what the indexer reconstructs). signing was already SP-aware. Validated: BIP-352 sending vectors pass for all clean key-path-taproot-input cases (the remaining vector failures are NUMS script-path / labels / uncompressed keys — input types the wallet never spends). End-to-end on regtest: an SP coin funds a payment to another sp address, testmempoolaccept passes, and the recipient scans and finds it. Note: silentPaymentScripts derives input_hash from the eligible inputs it's given, which is correct because the wallet only ever spends all-eligible coins (P2WPKH + key-path SP); it isn't a general-purpose sender for txs with ineligible inputs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/silentpay.js | 15 +++++++++++---- src/wallet.js | 28 ++++++++++++++++++---------- 2 files changed, 29 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/silentpay.js b/src/silentpay.js index 1732ced..eeabc1a 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -69,17 +69,24 @@ export function decodeSilentPaymentAddress(addr) { // Derive the one-time taproot output scripts for a set of silent-payment outputs // that this transaction's inputs are funding. -// inputs: [{ txid (display hex), vout, priv (Uint8Array 32) }] — ALL eligible -// (P2WPKH) inputs of the tx; the derivation commits to every one. +// inputs: [{ txid (display hex), vout, priv (Uint8Array 32), taproot? }] — ALL +// eligible inputs of the tx; the derivation commits to every one. Set +// taproot:true for P2TR inputs (e.g. our own silent-payment coins) so +// their key is taken with even Y, per BIP-352. // outputs: [{ scan, spend }] — one entry per desired output (repeat the same // recipient to pay it multiple times; the k counter is handled here). // Returns a taproot scriptPubKey (Uint8Array) for each entry, in order. export function silentPaymentScripts(inputs, outputs) { if (!inputs.length) throw new Error('Silent payment needs at least one input.'); - // a = Σ input private keys (mod n); A = a·G + // a = Σ input private keys (mod n); A = a·G. A taproot input contributes the + // key for its even-Y x-only pubkey, so negate when the key gives odd Y. let a = 0n; - for (const i of inputs) a = modN(a + toInt(i.priv)); + for (const i of inputs) { + let d = toInt(i.priv); + if (i.taproot && Number(G.multiply(d).toRawBytes(true)[0]) === 0x03) d = modN(-d); + a = modN(a + d); + } if (a === 0n) throw new Error('Silent payment input key sum is zero.'); const A = G.multiply(a); diff --git a/src/wallet.js b/src/wallet.js index 5943d86..dbb9b73 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1177,12 +1177,11 @@ export class Wallet { // signed by sign() with the per-output key. Excluded when paying an sp1… // recipient (funding an SP send from a taproot input needs BIP-352 even-Y // key handling we don't do yet), so those sends use BIP84 coins. - const payingSp = recipients.some((r) => isSilentPaymentAddress(r.address)); - const spPool = payingSp - ? [] - : coinIds - ? this.spUtxos.filter((u) => coinIds.includes(utxoId(u))) - : this.spUtxos.slice(); + // Confirmed SP coins are spendable like any other coin — including to fund a + // silent payment. Unconfirmed ones are excluded (the sender's tx could be + // replaced, invalidating our spend). + const confirmedSp = this.spUtxos.filter((u) => u.confirmed); + const spPool = coinIds ? confirmedSp.filter((u) => coinIds.includes(utxoId(u))) : confirmedSp.slice(); if (!pool_.length && !spPool.length) throw new Error('No spendable coins selected.'); const inputs = [ @@ -1287,13 +1286,22 @@ export class Wallet { // actual inputs per BIP-352. _applySilentPayments(tx, spOuts) { const byOutpoint = new Map(); - for (const u of this.utxos) byOutpoint.set(utxoId(u), u); + for (const u of this.utxos) byOutpoint.set(utxoId(u), { u, sp: false }); + for (const u of this.spUtxos) byOutpoint.set(utxoId(u), { u, sp: true }); + const spKeys = this.silentPaymentKeys(); 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 e = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`); + if (!e) throw new Error('Missing key for a silent payment input.'); + // Our own SP coins can fund a silent payment too — they're taproot inputs + // whose key is d = spend_priv + t_k (committed to with even Y by the script). + if (e.sp) { + if (!spKeys) throw new Error('Missing keys for a silent payment input.'); + inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: silentPaymentOutputPrivKey(spKeys.spendPriv, hex.decode(e.u.tweak)), taproot: true }); + } else { + inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: this.node(e.u.chain, e.u.index).privateKey }); + } } const scripts = silentPaymentScripts(inputs, spOuts.map((s) => ({ scan: s.scan, spend: s.spend }))); spOuts.forEach((s, k) => { -- cgit v1.2.3 From 98b216605604c3fea85936a54b07344ddc7a08a6 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 04:38:18 -0700 Subject: Unify data sources into one list; coinos → electrum.coinos.io MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Combined the separate Electrum and Explorer settings into a single data-source list. Each preset knows its type (electrum vs esplora); a custom source's type is detected from its URL scheme (ws/wss → Electrum, http/https → Esplora). The settings UI is now one dropdown + a URL field for Custom, with a one-line note showing the detected/selected type. getBackend/electrumCandidates/resolveHosts derive from the selected source. Old split settings migrate automatically. - coinos now points at wss://electrum.coinos.io (was wss://halwallet.app/electrum). Set up as a cloudflared route on the bitcoin tunnel → the Fulcrum WS (:50003), with a DNS CNAME. Verified the endpoint serves Electrum from a browser (Fulcrum 2.1.1 replies; cloudflare Bot Fight Mode only challenges non-browser clients). Verified: combined list + auto-detection (unit + browser), old-settings migration, regtest data flow unchanged, and the app loads over electrum.coinos.io with no errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 142 ++++++++++++++++++++++++++++++++++++------------------------ src/app.js | 71 +++++++++--------------------- src/i18n.js | 4 ++ 3 files changed, 111 insertions(+), 106 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index 56978f8..8621af8 100644 --- a/src/api.js +++ b/src/api.js @@ -50,58 +50,90 @@ export function setNetwork(net) { // live (so there are no duplicate URL fields elsewhere). Each network has its own // Electrum servers and block explorers; the selection is stored per network, so // switching networks remembers each one's choice. 'custom' points at your node. -const ELECTRUM_PRESETS_BY_NET = { +// One unified list of data sources per network. Each entry knows its type — +// electrum (an Electrum-over-WS server: data + instant push) or esplora (a block +// explorer's REST API: data only, polled). The UI shows a single list; the wallet +// derives the backend from the selected source's type. A custom source's type is +// detected from its URL scheme (ws/wss → electrum, http/https → esplora). +const DATA_SOURCES_BY_NET = { mainnet: [ - { id: 'coinos', label: 'coinos', url: 'wss://halwallet.app/electrum' }, - { id: 'blockstream', label: 'blockstream.info', url: 'wss://blockstream.info/electrum-websocket/' }, - { id: 'electroncash', label: 'btc.electroncash.dk', url: 'wss://btc.electroncash.dk:60004' }, - { id: 'blackie', label: 'blackie.c3-soft.com', url: 'wss://blackie.c3-soft.com:57004' }, - { id: 'jochen', label: 'electrum.jochen-hoenicke.de', url: 'wss://electrum.jochen-hoenicke.de:50010' }, - { id: 'mempoolguide', label: 'mempool.guide', url: 'wss://mempool.guide/electrum-websocket/' }, - { id: 'custom', label: 'Custom (your node)', url: '' }, + { id: 'coinos', label: 'coinos', type: 'electrum', url: 'wss://electrum.coinos.io' }, + { id: 'mempool', label: 'mempool.space', type: 'esplora', base: 'https://mempool.space/api', web: 'https://mempool.space', kind: 'mempool' }, + { id: 'blockstream', label: 'blockstream.info', type: 'esplora', base: 'https://blockstream.info/api', web: 'https://blockstream.info', kind: 'esplora' }, + { id: 'electroncash', label: 'btc.electroncash.dk', type: 'electrum', url: 'wss://btc.electroncash.dk:60004' }, + { id: 'jochen', label: 'electrum.jochen-hoenicke.de', type: 'electrum', url: 'wss://electrum.jochen-hoenicke.de:50010' }, + { id: 'mempoolguide', label: 'mempool.guide', type: 'electrum', url: 'wss://mempool.guide/electrum-websocket/' }, + { id: 'custom', label: 'Custom', type: 'auto', url: '' }, ], testnet: [ - { id: 'blockstream', label: 'blockstream.info', url: 'wss://blockstream.info/testnet/electrum-websocket/' }, - { id: 'custom', label: 'Custom (your node)', url: '' }, + { id: 'mempool', label: 'mempool.space', type: 'esplora', base: 'https://mempool.space/testnet/api', web: 'https://mempool.space/testnet', kind: 'mempool' }, + { id: 'blockstream', label: 'blockstream.info', type: 'esplora', base: 'https://blockstream.info/testnet/api', web: 'https://blockstream.info/testnet', kind: 'esplora' }, + { id: 'blockstreamel', label: 'blockstream.info (Electrum)', type: 'electrum', url: 'wss://blockstream.info/testnet/electrum-websocket/' }, + { id: 'custom', label: 'Custom', type: 'auto', url: '' }, ], mutinynet: [ - { id: 'custom', label: 'Custom (your node)', url: '' }, + { id: 'mutinynet', label: 'mutinynet.com', type: 'esplora', base: 'https://mutinynet.com/api', web: 'https://mutinynet.com', kind: 'esplora' }, + { id: 'custom', label: 'Custom', type: 'auto', url: '' }, ], regtest: [ - { id: 'local', label: 'Local Fulcrum', url: 'ws://localhost:50003' }, - { id: 'custom', label: 'Custom', url: '' }, + { id: 'local', label: 'Local Fulcrum', type: 'electrum', url: 'ws://localhost:50003' }, + { id: 'localesplora', label: 'Local Esplora', type: 'esplora', base: 'http://localhost:3000', web: 'http://localhost:3000', kind: 'esplora' }, + { id: 'custom', label: 'Custom', type: 'auto', url: '' }, ], }; -const EXPLORER_PRESETS_BY_NET = { - mainnet: [ - { id: 'mempool', label: 'mempool.space', base: 'https://mempool.space/api', web: 'https://mempool.space', kind: 'mempool' }, - { id: 'blockstream', label: 'blockstream.info', base: 'https://blockstream.info/api', web: 'https://blockstream.info', kind: 'esplora' }, - { id: 'custom', label: 'Custom', base: '', web: '', kind: 'esplora' }, - ], - testnet: [ - { id: 'mempool', label: 'mempool.space', base: 'https://mempool.space/testnet/api', web: 'https://mempool.space/testnet', kind: 'mempool' }, - { id: 'blockstream', label: 'blockstream.info', base: 'https://blockstream.info/testnet/api', web: 'https://blockstream.info/testnet', kind: 'esplora' }, - { id: 'custom', label: 'Custom', base: '', web: '', kind: 'esplora' }, - ], - mutinynet: [ - { id: 'mutinynet', label: 'mutinynet.com', base: 'https://mutinynet.com/api', web: 'https://mutinynet.com', kind: 'esplora' }, - { id: 'custom', label: 'Custom', base: '', web: '', kind: 'esplora' }, - ], - regtest: [ - { id: 'local', label: 'Local (localhost:3000)', base: 'http://localhost:3000', web: 'http://localhost:3000', kind: 'esplora' }, - { id: 'custom', label: 'Custom', base: '', web: '', kind: 'esplora' }, - ], -}; -// Per-network defaults: the mode + server a network starts on before any choice. +const DATA_SOURCE_DEFAULT = { mainnet: 'coinos', testnet: 'mempool', mutinynet: 'mutinynet', regtest: 'local' }; +export function dataSources(net = getNetwork()) { return DATA_SOURCES_BY_NET[net] || DATA_SOURCES_BY_NET.mainnet; } +const detectType = (url) => (/^wss?:\/\//i.test((url || '').trim()) ? 'electrum' : 'esplora'); + +// Old per-network defaults (mode + server id) — kept only so the one-time +// migration from the old split electrum/explorer settings can read them. const NET_DEFAULTS = { mainnet: { mode: 'electrum', electrum: 'coinos', explorer: 'mempool' }, - testnet: { mode: 'explorer', electrum: 'blockstream', explorer: 'mempool' }, - mutinynet: { mode: 'explorer', electrum: 'custom', explorer: 'mutinynet' }, - regtest: { mode: 'electrum', electrum: 'local', explorer: 'local' }, + testnet: { mode: 'esplora', electrum: 'blockstreamel', explorer: 'mempool' }, + mutinynet: { mode: 'esplora', electrum: 'custom', explorer: 'mutinynet' }, + regtest: { mode: 'electrum', electrum: 'local', explorer: 'localesplora' }, }; -export function electrumPresets(net = getNetwork()) { return ELECTRUM_PRESETS_BY_NET[net] || ELECTRUM_PRESETS_BY_NET.mainnet; } -export function explorerPresets(net = getNetwork()) { return EXPLORER_PRESETS_BY_NET[net] || EXPLORER_PRESETS_BY_NET.mainnet; } function netDefaults(net) { return NET_DEFAULTS[net] || NET_DEFAULTS.mainnet; } +// Internal filtered views (electrum fallover list + esplora host resolution). +export function electrumPresets(net = getNetwork()) { return dataSources(net).filter((x) => x.type === 'electrum'); } +export function explorerPresets(net = getNetwork()) { return dataSources(net).filter((x) => x.type === 'esplora'); } + +// The selected data source, resolved to a full entry. Migrates from the old split +// settings the first time (until the user picks a source, which writes SOURCE_KEY). +const SOURCE_KEY = 'btc-wallet-source'; +export function getSource(net = getNetwork()) { + let sel = null; + try { sel = JSON.parse(localStorage.getItem(nk(SOURCE_KEY, net)) || 'null'); } catch {} + if (!sel || !sel.id) sel = migrateSource(net); + const list = dataSources(net); + if (sel.id === 'custom') { + const url = (sel.url || '').trim(); + const type = detectType(url); + if (type === 'electrum') return { id: 'custom', label: 'Custom', type, url }; + const base = url.replace(/\/+$/, ''); + return { id: 'custom', label: 'Custom', type, url, base, web: base.replace(/\/api$/, ''), kind: 'esplora' }; + } + return list.find((x) => x.id === sel.id) || list.find((x) => x.id === DATA_SOURCE_DEFAULT[net]) || list[0]; +} +export function setSource({ id, url }, net = getNetwork()) { + try { localStorage.setItem(nk(SOURCE_KEY, net), JSON.stringify({ id, url: url || '' })); } catch {} +} +function migrateSource(net) { + const list = dataSources(net); + const has = (id) => list.some((x) => x.id === id); + try { + if (getDataMode(net) === 'electrum') { + const ec = getElectrumServerConfig(net); + if (ec.server === 'custom' && ec.url) return { id: 'custom', url: ec.url }; + if (has(ec.server)) return { id: ec.server }; + } else { + const xc = getExplorerConfig(net); + if (xc.server === 'custom' && xc.url) return { id: 'custom', url: xc.url }; + if (has(xc.server)) return { id: xc.server }; + } + } catch {} + return { id: DATA_SOURCE_DEFAULT[net] || list[0].id }; +} // Per-network storage keys: mainnet keeps the bare key (backward compat), others // get a ":" suffix. function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; } @@ -142,17 +174,17 @@ export function spReceiveEnabled(net = getNetwork()) { return !!spIndexerUrl(net // Resolve the configured explorer to the host list the Api tries in order. function resolveHosts(net) { - const cfg = getExplorerConfig(net); - const presets = explorerPresets(net); - const mk = (p) => ({ base: p.base, kind: p.kind, web: p.web, cooldownUntil: 0 }); - if (cfg.server === 'custom' && cfg.url) { - const base = cfg.url.trim().replace(/\/+$/, ''); - return [{ base, kind: 'esplora', web: base.replace(/\/api$/, ''), cooldownUntil: 0 }]; + const src = getSource(net); + const mk = (p) => ({ base: p.base, kind: p.kind || 'esplora', web: p.web || (p.base || '').replace(/\/api$/, ''), cooldownUntil: 0 }); + if (src.type === 'esplora' && src.base) { + // Mainnet silently fails mempool.space over to blockstream.info. + if (net === 'mainnet' && src.id === 'mempool') { const bs = dataSources(net).find((x) => x.id === 'blockstream'); return bs ? [mk(src), mk(bs)] : [mk(src)]; } + return [mk(src)]; } - const p = presets.find((x) => x.id === cfg.server && x.base) || presets.find((x) => x.base) || presets[0]; - // Mainnet silently fails mempool.space over to blockstream.info. - if (net === 'mainnet' && p.id === 'mempool') return [mk(p), mk(presets.find((x) => x.id === 'blockstream'))]; - return [mk(p)]; + // Electrum (or an empty custom) selected: no esplora data host, but tx links + // still need a web base, so use the network's default explorer host. + const def = dataSources(net).find((x) => x.type === 'esplora'); + return [def ? mk(def) : { base: '', kind: 'esplora', web: 'https://mempool.space', cooldownUntil: 0 }]; } // The "data source" setting (stored per network) picks where chain data + @@ -176,7 +208,7 @@ export function setDataMode(m, net = getNetwork()) { } export function getBackend() { - return getDataMode() === 'electrum' ? 'electrum' : 'esplora'; + return getSource().type === 'electrum' ? 'electrum' : 'esplora'; } // The legacy coinos watcher shim is retired (coinos is a full Electrum server // now); 'explorer' mode polls for payments instead of subscribing. @@ -200,12 +232,10 @@ export function setElectrumServerConfig({ server, url }, net = getNetwork()) { // didn't pick. const PUBLIC_ELECTRUM = (net) => electrumPresets(net).filter((x) => x.url).map((x) => x.url); export function electrumCandidates(net = getNetwork()) { - const cfg = getElectrumServerConfig(net); - const presets = electrumPresets(net); - if (cfg.server === 'custom') return cfg.url.trim() ? [cfg.url.trim()] : []; - const primary = (presets.find((x) => x.id === cfg.server) || presets[0]).url; - if (!primary) return []; - return [primary, ...PUBLIC_ELECTRUM(net).filter((u) => u !== primary)]; + const src = getSource(net); + if (src.type !== 'electrum' || !src.url) return []; + if (src.id === 'custom') return [src.url]; // your node — don't fall over to public servers + return [src.url, ...PUBLIC_ELECTRUM(net).filter((u) => u !== src.url)]; } export function resolveElectrumUrl() { return electrumCandidates()[0] || null; diff --git a/src/app.js b/src/app.js index 2470ac8..c49e279 100644 --- a/src/app.js +++ b/src/app.js @@ -8,7 +8,7 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; -import { getExplorerConfig, setExplorerConfig, explorerPresets, getDataMode, setDataMode, electrumPresets, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from './api.js'; +import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from './api.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { @@ -1613,63 +1613,34 @@ function networkCard() { function explorerCard() { const net = getNetwork(); - const mode = getDataMode(); - const cfg = getExplorerConfig(); - const ecfg = getElectrumServerConfig(); - const setMode = (m) => { - if (m === getDataMode()) return; - setDataMode(m); + const src = getSource(); + const sources = dataSources(net); + const pick = (id) => { + setSource({ id, url: '' }); render(); - // electrum⇄explorer swaps the whole backend, so rebuild + rescan. - if (!wallet.offline) wallet.reloadExplorer(); + // Switching source can swap the whole backend (electrum⇄esplora), so rebuild + // + rescan — except an empty custom (wait for the URL). + if (id !== 'custom' && !wallet.offline) wallet.reloadExplorer(); }; + const typeDesc = t(src.type === 'electrum' ? 'detectedElectrum' : 'detectedEsplora'); return h( 'div', { class: 'card col' }, h('h3', {}, t('dataSource')), h('p', { class: 'small muted', style: 'margin:0' }, t('dataSourceDesc')), - h('select', { onChange: (e) => setMode(e.target.value) }, - h('option', { value: 'electrum', selected: mode === 'electrum' }, t('modeElectrum')), - h('option', { value: 'explorer', selected: mode === 'explorer' }, t('modeExplorer')) - ), - mode === 'electrum' - ? h('div', { class: 'col', style: 'gap:8px' }, - h('div', { class: 'small faint' }, t('backendElectrumDesc')), - h('select', { - onChange: (e) => { setElectrumServerConfig({ server: e.target.value, url: ecfg.url }); render(); if (e.target.value !== 'custom' || ecfg.url) wallet.reloadExplorer(); }, - }, electrumPresets(net).map((o) => h('option', { value: o.id, selected: o.id === ecfg.server }, o.label))), - ecfg.server === 'custom' - ? h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('electrumUrl')), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'wss://your-node:50004', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ecfg.url, - onChange: (e) => { setElectrumServerConfig({ server: 'custom', url: e.target.value.trim() }); wallet.reloadExplorer(); }, - }), - h('div', { class: 'small faint' }, t('electrumUrlHint')) - ) - : null - ) - : null, - mode === 'explorer' - ? h('div', { class: 'col', style: 'gap:8px' }, - h('div', { class: 'small faint' }, t('modeExplorerDesc')), - h('select', { - onChange: (e) => { setExplorerConfig({ server: e.target.value, url: cfg.url }); render(); if (e.target.value !== 'custom' || cfg.url) wallet.reloadExplorer(); }, - }, explorerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.label))), - cfg.server === 'custom' - ? h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('explorerUrl')), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'https://mempool.space/api', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: cfg.url, - onChange: (e) => { setExplorerConfig({ server: 'custom', url: e.target.value.trim() }); wallet.reloadExplorer(); }, - }), - h('div', { class: 'small faint' }, t('explorerUrlHint')) - ) - : null + h('select', { onChange: (e) => pick(e.target.value) }, + sources.map((o) => h('option', { value: o.id, selected: o.id === src.id }, o.label))), + src.id === 'custom' + ? h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('sourceUrl')), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'wss://your-node:50004 · https://your-esplora/api', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: src.url || '', + onChange: (e) => { const url = e.target.value.trim(); setSource({ id: 'custom', url }); render(); if (!wallet.offline) wallet.reloadExplorer(); }, + }), + h('div', { class: 'small faint' }, src.url ? typeDesc : t('sourceUrlHint')) ) - : null + : h('div', { class: 'small faint' }, typeDesc) ); } diff --git a/src/i18n.js b/src/i18n.js index adfa4e8..5b9f1e8 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -300,6 +300,10 @@ const STR = { backendElectrumDesc: 'Data and instant payments over one Electrum WebSocket. Point it at your own server for a fully self-hosted wallet.', electrumUrl: 'Electrum server (wss://)', electrumUrlHint: 'A WebSocket Electrum server, e.g. wss://your-node:50004', + sourceUrl: 'Server URL', + sourceUrlHint: 'wss:// for an Electrum server, or an Esplora REST base (https://…/api). The type is detected from the URL.', + detectedElectrum: 'Electrum — data + instant payment notifications.', + detectedEsplora: 'Block explorer (Esplora) — data only; polls for payments.', silentPaymentNote: 'Silent payment', spReceiveTitle: 'Silent payment address', spReceiveDesc: "A reusable address that can't be tracked.", -- cgit v1.2.3 From ad71e8e879b51d4af12e74d108515d9d4d64598b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 04:47:12 -0700 Subject: Show full URLs in the source lists; SP indexer → tweaks.coinos.io MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Data-source and SP-indexer dropdowns now show the endpoint URL alongside the name (e.g. 'coinos — wss://electrum.coinos.io'), so it's clear where data comes from; 'Custom' stays bare. - The tweak indexer is now served at https://tweaks.coinos.io (was halwallet.app/ sp): a cloudflared route on the bitcoin tunnel → the indexer at :8888, with a DNS CNAME. Verified from a browser: /height, /mempool (1064 items), and /ws all work (the coinos.io Bot Fight Mode challenges non-browser clients only). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 2 +- src/app.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index 8621af8..cb582f3 100644 --- a/src/api.js +++ b/src/api.js @@ -144,7 +144,7 @@ function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; } // outputs, so self-hosting is the privacy-max option. Per network; an empty URL // means SP receiving is unavailable on that network. const SP_INDEXER_PRESETS_BY_NET = { - mainnet: [{ id: 'coinos', label: 'coinos', url: 'https://halwallet.app/sp' }, { id: 'custom', label: 'Custom', url: '' }], + mainnet: [{ id: 'coinos', label: 'coinos', url: 'https://tweaks.coinos.io' }, { id: 'custom', label: 'Custom', url: '' }], testnet: [{ id: 'custom', label: 'Custom', url: '' }], mutinynet: [{ id: 'custom', label: 'Custom', url: '' }], regtest: [{ id: 'local', label: 'Local (localhost:8888)', url: 'http://localhost:8888' }, { id: 'custom', label: 'Custom', url: '' }], diff --git a/src/app.js b/src/app.js index c49e279..0a74589 100644 --- a/src/app.js +++ b/src/app.js @@ -1629,7 +1629,7 @@ function explorerCard() { h('h3', {}, t('dataSource')), h('p', { class: 'small muted', style: 'margin:0' }, t('dataSourceDesc')), h('select', { onChange: (e) => pick(e.target.value) }, - sources.map((o) => h('option', { value: o.id, selected: o.id === src.id }, o.label))), + sources.map((o) => h('option', { value: o.id, selected: o.id === src.id }, o.id === 'custom' ? o.label : `${o.label} — ${o.url || o.base}`))), src.id === 'custom' ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('sourceUrl')), @@ -2319,7 +2319,7 @@ function spIndexerCard() { h('h3', {}, t('spIndexerTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('spIndexerDesc')), h('select', { onChange: (e) => { setSpIndexerConfig({ server: e.target.value, url: cfg.url }, net); render(); } }, - spIndexerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.label))), + spIndexerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.id === 'custom' ? o.label : `${o.label} — ${o.url}`))), cfg.server === 'custom' ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('spIndexerUrl')), -- cgit v1.2.3 From 5e19371642b0d2edc7540f96f53a17c47cf51a15 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 07:48:21 -0700 Subject: Source lists: show only the URL, protocol stripped Drop the human-readable name and the scheme prefix in the data-source and SP-indexer dropdowns, e.g. 'electrum.coinos.io', 'mempool.space/api', 'tweaks.coinos.io'. 'Custom' stays bare. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 0a74589..892cb69 100644 --- a/src/app.js +++ b/src/app.js @@ -1629,7 +1629,7 @@ function explorerCard() { h('h3', {}, t('dataSource')), h('p', { class: 'small muted', style: 'margin:0' }, t('dataSourceDesc')), h('select', { onChange: (e) => pick(e.target.value) }, - sources.map((o) => h('option', { value: o.id, selected: o.id === src.id }, o.id === 'custom' ? o.label : `${o.label} — ${o.url || o.base}`))), + sources.map((o) => h('option', { value: o.id, selected: o.id === src.id }, o.id === 'custom' ? o.label : (o.url || o.base).replace(/^\w+:\/\//, '')))), src.id === 'custom' ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('sourceUrl')), @@ -2319,7 +2319,7 @@ function spIndexerCard() { h('h3', {}, t('spIndexerTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('spIndexerDesc')), h('select', { onChange: (e) => { setSpIndexerConfig({ server: e.target.value, url: cfg.url }, net); render(); } }, - spIndexerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.id === 'custom' ? o.label : `${o.label} — ${o.url}`))), + spIndexerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.id === 'custom' ? o.label : o.url.replace(/^\w+:\/\//, '')))), cfg.server === 'custom' ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('spIndexerUrl')), -- cgit v1.2.3 From d459e4aa196b356cfc24f2118cc1ef54eda7600e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 09:22:45 -0700 Subject: Lock a gift to a nostr key: public link, claim code DM'd to the recipient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gift can now be locked to a recipient's nostr npub. The shareable link (/lg/) carries the amount + recipient in the clear (anyone can see what it is and who it's for), but the claim payload is encrypted under a one-time code. That code is delivered as an encrypted nostr DM (NIP-04) to the recipient — so only they can read it. They paste the code to decrypt, then claim into a fresh wallet exactly like a normal bearer gift. - nostr.js: parseNostrPubkey/npubOf, encryptWithCode/decryptWithCode (random 32-byte code), fetchNostrProfile, NostrSync.sendDM (broad relays). - wallet.js: lockGift → { blob, claimCode }, previewLockedGift, nostr identity + sendNostrDM. - app.js: gift form gains an optional 'Lock to npub' field that shows the recipient's avatar + name (nostr kind-0 profile); create encrypts + DMs the code (with sent/failed status + a copyable fallback); /lg/ links open a claim view showing the gift + recipient where you paste the code; account settings shows your own npub to share. Verified: code lock/unlock round-trip, a real nostr DM published and read back from relay.damus.io, and the UI loads + renders with no errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 144 +++++++++++++++++++++++++++++++++++++++++++++++++++++----- src/i18n.js | 18 ++++++++ src/nostr.js | 70 ++++++++++++++++++++++++++++ src/wallet.js | 32 ++++++++++++- 4 files changed, 251 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 892cb69..84cec51 100644 --- a/src/app.js +++ b/src/app.js @@ -4,10 +4,10 @@ // which rebuilds the active screen. Text inputs write back into `ui` on `input` // (without re-rendering) so their values survive structural re-renders. -import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, previewGift, giftOutpoints, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; +import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, previewGift, giftOutpoints, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault, lockGift, previewLockedGift } from './wallet.js'; import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; -import { getSyncConfig, setSyncConfig } from './nostr.js'; +import { getSyncConfig, setSyncConfig, parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from './nostr.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from './api.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; @@ -50,6 +50,12 @@ const ui = { pubkeyShown: false, // account public key revealed in Settings giftMode: false, // gift sub-view active on the Send page giftAmount: '', // gift-create amount input + giftLockNpub: '', // optional: lock the gift to this recipient nostr npub + giftLocked: false, // the just-created gift is locked to a nostr key + giftClaimCode: null, // the one-time claim code DM'd to the recipient (fallback) + giftDmStatus: null, // 'sending' | 'sent' | 'failed' — nostr DM of the claim code + claimLocked: null, // a locked gift being opened: { v, amount, to, ct } + claimCodeInput: '', // the claim code the recipient pastes from their nostr DM giftCode: null, // last-created gift PSBT code giftError: '', giftMax: false, // gift the whole spendable balance (no-change sweep) @@ -252,7 +258,9 @@ function render() { try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {} } const screen = - ui.screen === 'wallet' + ui.claimLocked + ? lockedGiftClaimView() + : ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' ? accountsScreen() @@ -387,6 +395,30 @@ function download(filename, text, mime = 'application/json') { setTimeout(() => URL.revokeObjectURL(url), 1000); } +// --- nostr profiles (avatar + name) — cached, lazily fetched, re-renders ---- +const _profileCache = new Map(); // pubkeyHex -> profile | null | 'loading' +function nostrProfile(pkHex) { + if (_profileCache.has(pkHex)) return _profileCache.get(pkHex); + _profileCache.set(pkHex, 'loading'); + fetchNostrProfile(pkHex).then((p) => { _profileCache.set(pkHex, p || null); render(); }).catch(() => { _profileCache.set(pkHex, null); render(); }); + return 'loading'; +} +// A row showing the recipient's avatar + name (or a shortened npub fallback). +function profileChip(pkHex, { size = 30 } = {}) { + const p = nostrProfile(pkHex); + const npub = npubOf(pkHex) || pkHex; + const short = npub.slice(0, 12) + '…' + npub.slice(-4); + const avatar = (pic) => + pic + ? h('img', { src: pic, style: `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;flex:0 0 auto`, onError: (e) => { e.target.style.visibility = 'hidden'; } }) + : h('div', { style: `width:${size}px;height:${size}px;border-radius:50%;background:#9993;flex:0 0 auto` }); + if (p === 'loading') return h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, short)); + const name = (p && p.name) || short; + return h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(p && p.picture), + h('span', { class: p && p.name ? '' : 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, name)); +} + function copyBtn(text, label = t('copy')) { return h('button', { class: 'btn-sm', onClick: () => copy(text) }, label); } @@ -1406,7 +1438,7 @@ function settingsTab() { // Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims // into a fresh wallet only they control. The coin is reserved until claimed. function giftUrl() { - return `${location.origin}/g/${ui.giftCode}`; + return `${location.origin}/${ui.giftLocked ? 'lg' : 'g'}/${ui.giftCode}`; } function giftRate() { return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; @@ -1442,9 +1474,22 @@ function createGiftLink() { } doCreateGift(amt, rate); } +// Finalize a freshly-built gift. With a recipient npub it's a locked gift: the +// claim payload is encrypted under a one-time code, the link carries only the +// ciphertext, and the code is DM'd to the recipient over nostr. Without, it's a +// plain bearer gift. +function finishGift(g, fallbackAmt) { + const pk = ui.giftLockNpub.trim() ? parseNostrPubkey(ui.giftLockNpub) : null; + if (!pk) { ui.giftCode = g.code; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; return; } + const { blob, claimCode } = lockGift(g.code, g.amount || fallbackAmt || 0, pk); + ui.giftCode = blob; ui.giftLocked = true; ui.giftClaimCode = claimCode; ui.giftDmStatus = 'sending'; + const amount = fmtAmount(g.amount || fallbackAmt || 0) + ' ' + unitLabel(); + const dm = t('giftDmText', { amount, link: giftUrl(), code: claimCode }); + wallet.sendNostrDM(pk, dm).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); }); +} function doCreateGift(amt, rate) { try { - ui.giftCode = wallet.createGift(amt, rate).code; + finishGift(wallet.createGift(amt, rate), amt); ui.giftError = ''; ui.giftSplitOffer = null; } catch (e) { @@ -1457,7 +1502,7 @@ function doCreateGift(amt, rate) { // everything minus their claim fee). function doCreateGiftAll(rate) { try { - ui.giftCode = wallet.createGiftAll(rate).code; + finishGift(wallet.createGiftAll(rate)); ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; @@ -1475,7 +1520,7 @@ async function doSplitForGift(amt) { ui.busy = true; ui.giftError = ''; render(); try { const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - ui.giftCode = (await wallet.createGiftFromSplit(amt, rate)).code; + finishGift(await wallet.createGiftFromSplit(amt, rate), amt); ui.giftSplitOffer = null; ui.giftError = ''; } catch (e) { @@ -1493,14 +1538,23 @@ function giftCard() { h('h3', {}, t('giftLink')), ui.giftCode ? h('div', { class: 'col', style: 'align-items:center;gap:10px' }, - // Uppercase the whole URL + alphanumeric mode → much smaller QR. The - // domain is case-insensitive and the base32 code is already uppercase; - // the opener parses /g/ and the code case-insensitively. - h('div', { html: qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), + ui.giftLocked && (() => { + const lk = previewLockedGift(ui.giftCode); + if (!lk) return null; + return h('div', { class: 'col gap6', style: 'width:100%;align-items:center' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockedTo')), profileChip(lk.to)), + ui.giftDmStatus === 'sent' ? h('div', { class: 'small', style: 'color:var(--ok,#2a8)' }, t('giftDmSent')) + : ui.giftDmStatus === 'failed' ? h('div', { class: 'col gap6', style: 'align-items:center' }, h('div', { class: 'small err' }, t('giftDmFailed')), ui.giftClaimCode ? copyBtn(ui.giftClaimCode, t('giftCopyCode')) : null) + : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending')))); + })(), + // A bearer gift uppercases the URL for a smaller alphanumeric QR (the + // base32 code is case-insensitive). A locked gift's payload is + // case-sensitive base64url, so it uses a plain byte-mode QR. + h('div', { html: ui.giftLocked ? qrSvg(giftUrl()) : qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()), h('div', { class: 'row gap6 wrap' }, copyBtn(giftUrl(), t('copyLink')), - h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; render(); } }, t('giftAnother')) + h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftLockNpub = ''; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; render(); } }, t('giftAnother')) ) ) : ui.giftSplitOffer @@ -1530,6 +1584,17 @@ function giftCard() { h('div', { style: 'display:flex;align-items:center' }, unitTag()) ), h('div', { class: 'small faint' }, ui.giftMax ? t('giftAllNote') : t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })), + (() => { + const raw = ui.giftLockNpub.trim(); + const pk = raw ? parseNostrPubkey(raw) : null; + return h('div', { class: 'col gap6' }, + h('input', { type: 'text', class: 'mono-input', placeholder: t('giftLockPlaceholder'), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.giftLockNpub, + onInput: (e) => { ui.giftLockNpub = e.target.value; render(); } }), + !raw ? h('div', { class: 'small faint' }, t('giftLockHint')) + : pk ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockTo')), profileChip(pk)) + : h('div', { class: 'small err' }, t('giftLockInvalid'))); + })(), ui.giftError && h('div', { class: 'notice err' }, ui.giftError), h('button', { class: 'btn-block', onClick: createGiftLink }, t('giftLinkReveal')) ), @@ -1764,6 +1829,52 @@ function readGiftHash() { } } +// A gift locked to a nostr key: /lg/. Returns the public preview +// ({ amount, to, eph, ct }) — claimed by the recipient's own wallet, not a fresh +// one, so we don't auto-create a wallet here. +function readLockedGiftHash() { + try { + const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/); + if (!m) return null; + history.replaceState(null, '', '/'); + return previewLockedGift(m[1]); + } catch { + return null; + } +} + +// A locked gift opened from a link: the amount + recipient are public. The +// recipient pastes the claim code from their nostr DM; that decrypts the payload, +// then it's claimed into a fresh wallet exactly like a normal gift. +function lockedGiftClaimView() { + const lk = ui.claimLocked; + return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, + h('div', { class: 'card col', style: 'gap:14px;align-items:center' }, + h('h3', { style: 'margin:0' }, t('giftForYou')), + h('div', { class: 'amt' }, fmtAmount(lk.amount), ' ', unitTag()), + h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small muted' }, t('giftLockedTo')), + profileChip(lk.to, { size: 36 })), + h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), + h('input', { type: 'text', class: 'mono-input', style: 'width:100%', placeholder: t('claimCodePlaceholder'), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.claimCodeInput, + onInput: (e) => { ui.claimCodeInput = e.target.value; } }), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + h('button', { class: 'btn-primary btn-block', onClick: submitLockedCode }, t('claimGift')), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; render(); } }, t('dismiss')) + ) + ); +} +function submitLockedCode() { + const code = (ui.claimCodeInput || '').trim(); + if (!code) return; + let payload = null; + try { payload = decryptWithCode(code, ui.claimLocked.ct); } catch {} + if (!payload || !previewGift(payload)) { ui.claimError = t('claimCodeWrong'); render(); return; } + ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; + enterWallet(newMnemonic(), '', { gift: payload }); // fresh-wallet claim, like a normal gift +} + // Has this gift already been claimed? Its funding coin being spent — even by an // unconfirmed claim — means the gift is gone; show the "already claimed" screen // rather than let the user attempt a doomed double-spend. @@ -2107,6 +2218,14 @@ function accountSettingsScreen() { h('button', { class: 'btn-sm grow', onClick: () => { ui.pubkeyShown = false; render(); } }, t('hide')))) : h('button', { class: 'btn-block', onClick: () => { ui.pubkeyShown = true; render(); } }, t('showPublicKey')) ), + // Nostr address — share it so others can send you locked gifts. + (a.id === activeId && wallet.nostrNpub()) + ? h('div', { class: 'card col' }, + h('h3', {}, t('nostrKeyTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('nostrKeyDesc')), + h('div', { class: 'addr-box break', style: 'font-size:12px' }, wallet.nostrNpub()), + copyBtn(wallet.nostrNpub(), t('copyKey'))) + : null, // Auto-logout (bottom) h('div', { class: 'card col' }, h('h3', {}, t('autolockTitle')), @@ -3242,6 +3361,7 @@ document.addEventListener('visibilitychange', () => { loadLocale(getLang()).finally(() => { const code = readGiftHash(); if (code) { enterWallet(newMnemonic(), '', { gift: code }); return; } + ui.claimLocked = readLockedGiftHash(); // locked gift → claim with your own wallet applyBootAutoLogout(); // clear an overdue session before it's restored if (!restoreAccountsState()) render(); }); diff --git a/src/i18n.js b/src/i18n.js index 5b9f1e8..48dcef0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -224,6 +224,24 @@ const STR = { giftExceedsBalance: 'That’s more than your balance.', giftNeedsHeadroom: 'Gift up to {n}, or tap Max to gift your whole balance.', giftAnother: 'Create another', + giftLockPlaceholder: 'Lock to npub (optional)', + giftLockHint: 'Optionally lock this gift to a nostr account: the link stays public, but only they get the claim code — sent to them as a nostr DM.', + giftLockTo: 'For', + giftLockInvalid: 'Not a valid npub or hex pubkey.', + giftLockedTo: 'For', + giftForYou: 'A gift for you', + claimGift: 'Claim', + dismiss: 'Dismiss', + giftCodeHint: 'Check your nostr direct messages for the claim code, then paste it below.', + claimCodePlaceholder: 'Paste claim code', + claimCodeWrong: 'That code didn’t work — check your nostr DM and try again.', + giftDmText: "🎁 You've received a Bitcoin gift of {amount}! Claim it here: {link}\n\nClaim code: {code}", + giftDmSent: 'Claim code sent to them via nostr DM.', + giftDmSending: 'Sending the claim code…', + giftDmFailed: 'Couldn’t send the nostr DM. Share this code with them privately:', + giftCopyCode: 'Copy claim code', + nostrKeyTitle: 'Nostr address', + nostrKeyDesc: 'Share this so others can lock a gift to you — the claim code is sent here as a nostr DM.', giftSplitExplain: 'This gift would lock {lock} until it’s claimed ({change} of it comes back to you then). You can split a right-sized coin first for about {fee} so only the gift amount stays locked.', giftSplitFirst: 'Split a coin first', giftLockWhole: 'Lock the whole coin ({n})', diff --git a/src/nostr.js b/src/nostr.js index 74d8d25..02e37a4 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -11,8 +11,64 @@ import * as nip06 from 'nostr-tools/nip06'; import * as nip44 from 'nostr-tools/nip44'; +import * as nip04 from 'nostr-tools/nip04'; import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19'; import { Relay } from 'nostr-tools/relay'; +import { randomBytes } from '@noble/hashes/utils'; +import { base64urlnopad } from '@scure/base'; + +// --- locking a gift to a recipient's Nostr key --------------------------- +// An npub or 64-hex nostr pubkey → hex, or null if it isn't one. +export function parseNostrPubkey(input) { + const s = (input || '').trim(); + if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase(); + try { const d = nip19decode(s); if (d.type === 'npub') return d.data; } catch {} + return null; +} +export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } } + +// Encrypt a payload under a fresh one-time code (32 random bytes → base64url). +// The code travels to the recipient out-of-band (a nostr DM), so the public link +// holding the ciphertext can't be claimed without it. Returns { code, ct }. +export function encryptWithCode(plaintext) { + const key = randomBytes(32); + return { code: base64urlnopad.encode(key), ct: nip44.encrypt(plaintext, key) }; +} +export function decryptWithCode(code, ct) { + return nip44.decrypt(ct, base64urlnopad.decode((code || '').trim())); +} + +// Profiles live across the network, so look them up on popular relays (broader +// than the sync relays). +export const PROFILE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.coinos.io']; +// Fetch a recipient's profile (name + picture) for a pubkey, newest across relays. +export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { + const results = await Promise.allSettled(relays.map((url) => _fetchProfileFrom(url, pubkeyHex))); + let best = null; + for (const r of results) { + const v = r.status === 'fulfilled' ? r.value : null; + if (v && (!best || (v.created_at || 0) > (best.created_at || 0))) best = v; + } + if (!best) return null; + try { + const m = JSON.parse(best.content); + return { name: m.display_name || m.name || null, picture: m.picture || null, nip05: m.nip05 || null }; + } catch { return null; } +} +async function _fetchProfileFrom(url, pk) { + let relay; + try { relay = await Relay.connect(url); } catch { return null; } + return new Promise((resolve) => { + let ev = null; + const done = () => { try { sub.close(); } catch {} try { relay.close(); } catch {} resolve(ev); }; + const sub = relay.subscribe([{ kinds: [0], authors: [pk], limit: 1 }], { + onevent: (e) => { if (!ev || e.created_at > ev.created_at) ev = e; }, + oneose: done, + }); + setTimeout(done, 5000); + }); +} const DTAG = 'bitcoin-wallet'; const SYNC_KEY = 'btc-wallet-sync'; @@ -112,6 +168,20 @@ export class NostrSync { ); } + // Send an encrypted DM (NIP-04 kind 4) — used to deliver a locked gift's claim + // code. Best-effort across the broad relay set so the recipient's client sees + // it. Returns true if at least one relay accepted it. + async sendDM(recipientPkHex, text, relays = PROFILE_RELAYS) { + if (!this.sk) return false; + let evt; + try { + const content = await nip04.encrypt(this.sk, recipientPkHex, text); + evt = finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPkHex]], content }, this.sk); + } catch { return false; } + const res = await Promise.allSettled((relays || PROFILE_RELAYS).map(async (url) => { const r = await this._connect(url); await r.publish(evt); })); + return res.some((x) => x.status === 'fulfilled'); + } + // Fetch from every relay; return the newest decrypted state, or null. async fetch() { if (!this.sk) return null; diff --git a/src/wallet.js b/src/wallet.js index dbb9b73..b2ff3e3 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -23,7 +23,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; -import { NostrSync, getSyncConfig } from './nostr.js'; +import { NostrSync, getSyncConfig, encryptWithCode, npubOf } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; @@ -1628,6 +1628,14 @@ export class Wallet { return this._buildGiftPsbt(sel, gift); } + // Our nostr identity (used to DM a locked gift's claim code to the recipient). + nostrPubkey() { return (this.nostr && this.nostr.pk) || null; } + nostrNpub() { const pk = this.nostrPubkey(); return pk ? npubOf(pk) : null; } + // Send a nostr DM (e.g. a locked gift's claim code) to a recipient pubkey. + async sendNostrDM(recipientPkHex, text) { + return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false; + } + // 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 @@ -2562,6 +2570,28 @@ export function buildClaimTx(code, toAddress, feeRate, net) { return { hex: tx.hex, txid: tx.id, amount: Number(out), fee: Number(fee) }; } +// A gift locked to a recipient's nostr key. The amount + recipient pubkey are +// public (anyone can see what it is and who it's for), but the claim payload (the +// gift code) is NIP-44-encrypted to the recipient via an ephemeral key — so the +// link can be shared openly yet only the recipient's nostr key can claim it. +const LOCKED_GIFT_VERSION = 1; +// Encrypt the gift's claim payload under a one-time code. The code is delivered +// to the recipient via a nostr DM; the public link carries only the ciphertext. +// Returns { blob (for the /lg/ link), claimCode (to DM) }. +export function lockGift(code, amount, recipientPkHex) { + const { code: claimCode, ct } = encryptWithCode(code); + const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct }))); + return { blob, claimCode }; +} +// The public fields of a locked-gift blob (or null). Decryption needs the code. +export function previewLockedGift(blob) { + try { + const o = JSON.parse(new TextDecoder().decode(base64urlnopad.decode(blob))); + if (o.v === LOCKED_GIFT_VERSION && typeof o.amount === 'number' && o.to && o.ct) return o; + } catch {} + return null; +} + function firstUnused(chain) { const u = chain.find((a) => !a.used); return u ? u.index : chain.length; -- cgit v1.2.3 From 96d9b7ee6914b157a17e078cfe597cb40f35c9e2 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 09:28:18 -0700 Subject: Route the gift DM to the recipient's published inbox relays Discover where a recipient reads DMs before sending the claim code: prefer their NIP-17 DM relay list (kind 10050), else their NIP-65 read relays (kind 10002), unioned with the broad fallback set (so users without a list still get it). This makes the locked-gift DM far more likely to land in the recipient's client. Verified: resolves fiatjaf's real inbox relays; a key with no list returns none and falls back to defaults. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/nostr.js | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/nostr.js b/src/nostr.js index 02e37a4..378cec9 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -70,6 +70,28 @@ async function _fetchProfileFrom(url, pk) { }); } +// Where to deliver a DM to a pubkey: prefer their NIP-17 DM relay list (kind +// 10050), else their NIP-65 read relays (kind 10002), else [] (caller falls back). +export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) { + const lists = (await Promise.allSettled(relays.map((url) => _fetchRelayListFrom(url, pubkeyHex)))).flatMap((r) => (r.status === 'fulfilled' && r.value) || []); + const newest = (kind) => lists.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0]; + const dm = newest(10050); + if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r; } + const nip65 = newest(10002); + if (nip65) { const r = nip65.tags.filter((t) => t[0] === 'r' && t[1] && (!t[2] || t[2] === 'read')).map((t) => t[1]); if (r.length) return r; } + return []; +} +async function _fetchRelayListFrom(url, pk) { + let relay; + try { relay = await Relay.connect(url); } catch { return []; } + return new Promise((resolve) => { + const evs = []; + const done = () => { try { sub.close(); } catch {} try { relay.close(); } catch {} resolve(evs); }; + const sub = relay.subscribe([{ kinds: [10002, 10050], authors: [pk] }], { onevent: (e) => evs.push(e), oneose: done }); + setTimeout(done, 5000); + }); +} + const DTAG = 'bitcoin-wallet'; const SYNC_KEY = 'btc-wallet-sync'; export const DEFAULT_SYNC_RELAYS = ['wss://relay.coinos.io']; @@ -169,16 +191,22 @@ export class NostrSync { } // Send an encrypted DM (NIP-04 kind 4) — used to deliver a locked gift's claim - // code. Best-effort across the broad relay set so the recipient's client sees - // it. Returns true if at least one relay accepted it. - async sendDM(recipientPkHex, text, relays = PROFILE_RELAYS) { + // code. Delivers to the recipient's published inbox relays (NIP-17 / NIP-65) so + // their client actually sees it, plus a broad fallback. Returns true if ≥1 relay + // accepted it. + async sendDM(recipientPkHex, text, relays = null) { if (!this.sk) return false; let evt; try { const content = await nip04.encrypt(this.sk, recipientPkHex, text); evt = finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPkHex]], content }, this.sk); } catch { return false; } - const res = await Promise.allSettled((relays || PROFILE_RELAYS).map(async (url) => { const r = await this._connect(url); await r.publish(evt); })); + let targets = relays; + if (!targets) { + const inbox = await fetchInboxRelays(recipientPkHex); + targets = [...new Set([...inbox, ...PROFILE_RELAYS])].slice(0, 8); // recipient's inbox + safety net + } + const res = await Promise.allSettled(targets.map(async (url) => { const r = await this._connect(url); await r.publish(evt); })); return res.some((x) => x.status === 'fulfilled'); } -- cgit v1.2.3 From 0fbeb3c55413fb221d890b3bf3464ebd2aed9fa1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 09:31:51 -0700 Subject: Locked gifts: let NIP-07 extension users decrypt in-place, skip the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gift payload is now encrypted two ways in the link: under the one-time code (the DM / manual path) AND to the recipient's nostr pubkey via an ephemeral key. When a NIP-07 browser extension (window.nostr) is present, the claim view offers 'Claim with nostr extension' — it checks the extension's pubkey matches the gift, decrypts ctKey via window.nostr.nip44.decrypt, and claims into a fresh wallet with no code. The pasted-code path remains as the fallback for everyone else. Verified: both decrypt paths recover the payload from the blob; a different extension account is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 26 +++++++++++++++++++++++--- src/i18n.js | 5 +++++ src/nostr.js | 21 ++++++++++++++------- src/wallet.js | 6 +++--- 4 files changed, 45 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 84cec51..803a82b 100644 --- a/src/app.js +++ b/src/app.js @@ -1848,6 +1848,9 @@ function readLockedGiftHash() { // then it's claimed into a fresh wallet exactly like a normal gift. function lockedGiftClaimView() { const lk = ui.claimLocked; + // NIP-07: if a nostr browser extension is present, the recipient can decrypt + // in-place — no code needed. + const hasExt = !!(typeof globalThis !== 'undefined' && globalThis.nostr && lk.eph && lk.ctKey); return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, h('div', { class: 'card col', style: 'gap:14px;align-items:center' }, h('h3', { style: 'margin:0' }, t('giftForYou')), @@ -1855,16 +1858,33 @@ function lockedGiftClaimView() { h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockedTo')), profileChip(lk.to, { size: 36 })), - h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + hasExt ? h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: claimViaExtension }, ui.busy ? h('span', { class: 'spinner' }) : t('claimWithExtension')) : null, + hasExt ? h('div', { class: 'small faint', style: 'text-align:center' }, t('orEnterCode')) : h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), h('input', { type: 'text', class: 'mono-input', style: 'width:100%', placeholder: t('claimCodePlaceholder'), autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.claimCodeInput, onInput: (e) => { ui.claimCodeInput = e.target.value; } }), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - h('button', { class: 'btn-primary btn-block', onClick: submitLockedCode }, t('claimGift')), + h('button', { class: (hasExt ? '' : 'btn-primary ') + 'btn-block', onClick: submitLockedCode }, t('claimGift')), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; render(); } }, t('dismiss')) ) ); } +async function claimViaExtension() { + const ext = typeof globalThis !== 'undefined' && globalThis.nostr; + if (!ext) return; + ui.busy = true; ui.claimError = ''; render(); + try { + const pk = await ext.getPublicKey(); + if (pk !== ui.claimLocked.to) { ui.claimError = t('extWrongAccount'); ui.busy = false; render(); return; } + if (!ext.nip44 || !ext.nip44.decrypt) { ui.claimError = t('extNoNip44'); ui.busy = false; render(); return; } + const payload = await ext.nip44.decrypt(ui.claimLocked.eph, ui.claimLocked.ctKey); + if (!payload || !previewGift(payload)) { ui.claimError = t('extDecryptFailed'); ui.busy = false; render(); return; } + ui.busy = false; ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; + enterWallet(newMnemonic(), '', { gift: payload }); // fresh-wallet claim + } catch { + ui.busy = false; ui.claimError = t('extDecryptFailed'); render(); + } +} function submitLockedCode() { const code = (ui.claimCodeInput || '').trim(); if (!code) return; diff --git a/src/i18n.js b/src/i18n.js index 48dcef0..31cc3c9 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -240,6 +240,11 @@ const STR = { giftDmSending: 'Sending the claim code…', giftDmFailed: 'Couldn’t send the nostr DM. Share this code with them privately:', giftCopyCode: 'Copy claim code', + claimWithExtension: 'Claim with nostr extension', + orEnterCode: 'or paste the claim code', + extWrongAccount: 'Your extension is signed in as a different nostr account than this gift is for.', + extNoNip44: 'Your nostr extension doesn’t support NIP-44 — paste the code instead.', + extDecryptFailed: 'Couldn’t decrypt with the extension — try the code.', nostrKeyTitle: 'Nostr address', nostrKeyDesc: 'Share this so others can lock a gift to you — the claim code is sent here as a nostr DM.', giftSplitExplain: 'This gift would lock {lock} until it’s claimed ({change} of it comes back to you then). You can split a right-sized coin first for about {fee} so only the gift amount stays locked.', diff --git a/src/nostr.js b/src/nostr.js index 378cec9..0ebfe92 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -12,7 +12,7 @@ import * as nip06 from 'nostr-tools/nip06'; import * as nip44 from 'nostr-tools/nip44'; import * as nip04 from 'nostr-tools/nip04'; -import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19'; import { Relay } from 'nostr-tools/relay'; import { randomBytes } from '@noble/hashes/utils'; @@ -28,12 +28,19 @@ export function parseNostrPubkey(input) { } export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } } -// Encrypt a payload under a fresh one-time code (32 random bytes → base64url). -// The code travels to the recipient out-of-band (a nostr DM), so the public link -// holding the ciphertext can't be claimed without it. Returns { code, ct }. -export function encryptWithCode(plaintext) { - const key = randomBytes(32); - return { code: base64urlnopad.encode(key), ct: nip44.encrypt(plaintext, key) }; +// Encrypt a gift payload two ways: (1) under a fresh one-time code, delivered to +// the recipient out-of-band via a nostr DM — the manual path; and (2) to the +// recipient's nostr pubkey via an ephemeral key, so a NIP-07 browser extension +// can decrypt it in-place with no code. Both decrypt to the same payload. +export function encryptGiftPayload(plaintext, recipientPkHex) { + const codeKey = randomBytes(32); + const ephSk = generateSecretKey(); + return { + code: base64urlnopad.encode(codeKey), + ctCode: nip44.encrypt(plaintext, codeKey), + eph: getPublicKey(ephSk), + ctKey: nip44.encrypt(plaintext, nip44.getConversationKey(ephSk, recipientPkHex)), + }; } export function decryptWithCode(code, ct) { return nip44.decrypt(ct, base64urlnopad.decode((code || '').trim())); diff --git a/src/wallet.js b/src/wallet.js index b2ff3e3..e2b5b35 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -23,7 +23,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; -import { NostrSync, getSyncConfig, encryptWithCode, npubOf } from './nostr.js'; +import { NostrSync, getSyncConfig, encryptGiftPayload, npubOf } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; @@ -2579,8 +2579,8 @@ const LOCKED_GIFT_VERSION = 1; // to the recipient via a nostr DM; the public link carries only the ciphertext. // Returns { blob (for the /lg/ link), claimCode (to DM) }. export function lockGift(code, amount, recipientPkHex) { - const { code: claimCode, ct } = encryptWithCode(code); - const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct }))); + const { code: claimCode, ctCode, eph, ctKey } = encryptGiftPayload(code, recipientPkHex); + const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct: ctCode, eph, ctKey }))); return { blob, claimCode }; } // The public fields of a locked-gift blob (or null). Decryption needs the code. -- cgit v1.2.3 From 1e43db6e50e5e1dc7ccb8fb53e7ce68cc580967d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 09:42:25 -0700 Subject: Fix gift creation silently failing on large (many-input) gifts; one Back button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A many-input gift produces a URL too long for a QR, and qrSvg threw — uncaught, inside render() — so the click did nothing and showed no error. Wrap the gift QR in try/catch: render the QR when it fits, otherwise fall back to a 'too large for a QR — use the link' note. The link itself still works. - The split-offer card has its own Back; hide the gift sub-view's outer Back while the split offer is showing, so there aren't two stacked Back buttons. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 12 +++++++++--- src/i18n.js | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 803a82b..f069830 100644 --- a/src/app.js +++ b/src/app.js @@ -1549,8 +1549,13 @@ function giftCard() { })(), // A bearer gift uppercases the URL for a smaller alphanumeric QR (the // base32 code is case-insensitive). A locked gift's payload is - // case-sensitive base64url, so it uses a plain byte-mode QR. - h('div', { html: ui.giftLocked ? qrSvg(giftUrl()) : qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), + // case-sensitive base64url, so it uses a plain byte-mode QR. A large + // (many-input) gift can exceed QR capacity — then fall back to the link. + (() => { + let svg = null; + try { svg = ui.giftLocked ? qrSvg(giftUrl()) : qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} + return svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')); + })(), h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()), h('div', { class: 'row gap6 wrap' }, copyBtn(giftUrl(), t('copyLink')), @@ -2533,7 +2538,8 @@ function giftView() { 'div', { class: 'col', style: 'gap:12px' }, giftCard(), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; goBack(() => { ui.giftMode = false; }); } }, t('back')) + // The split-offer card has its own Back; don't show a second one under it. + ui.giftSplitOffer ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; goBack(() => { ui.giftMode = false; }); } }, t('back')) ); } diff --git a/src/i18n.js b/src/i18n.js index 31cc3c9..66f866d 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -240,6 +240,7 @@ const STR = { giftDmSending: 'Sending the claim code…', giftDmFailed: 'Couldn’t send the nostr DM. Share this code with them privately:', giftCopyCode: 'Copy claim code', + giftQrTooLong: 'Too large for a QR code — share the link below instead.', claimWithExtension: 'Claim with nostr extension', orEnterCode: 'or paste the claim code', extWrongAccount: 'Your extension is signed in as a different nostr account than this gift is for.', -- cgit v1.2.3 From 7dd7f1678c9f294b7a42bfbca263c53c08a83ac4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 09:57:18 -0700 Subject: Consolidate coins: a Settings action + a gift-time recommendation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings gains a 'Consolidate coins' card (shown when you hold >1 spendable coin): wallet.consolidate() does a max self-send merging them into one, so future sends/gifts use a single input — cheaper and a smaller gift QR. - At gift creation, detect when the gift would bundle several coins (giftCoinSummary returns the input count) and trigger the split/consolidate offer when count ≥ 3 (not just when it frees change). The offer now explains it plainly ('would bundle N coins, costly to claim') and recommends splitting into one coin first, with 'Use N coins anyway' as the explicit opt-out. Verified on regtest: 6 small coins → a 1000-sat gift needs 3 inputs and triggers the offer; consolidate merges them to 1 coin, after which the same gift is a clean single input. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 56 +++++++++++++++++++++++++++++++++++++++++++++----------- src/i18n.js | 8 ++++++++ src/wallet.js | 23 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index f069830..d9066a3 100644 --- a/src/app.js +++ b/src/app.js @@ -56,6 +56,7 @@ const ui = { giftDmStatus: null, // 'sending' | 'sent' | 'failed' — nostr DM of the claim code claimLocked: null, // a locked gift being opened: { v, amount, to, ct } claimCodeInput: '', // the claim code the recipient pastes from their nostr DM + consolidateError: null, // error from a coin-consolidation attempt giftCode: null, // last-created gift PSBT code giftError: '', giftMax: false, // gift the whole spendable balance (no-change sweep) @@ -1428,12 +1429,40 @@ function settingsTab() { : h('button', { onClick: () => { ui.addrScan = true; ui.addrScanPage = 0; render(); } }, t('rescanAddresses')) ), networkCard(), + consolidateCard(), explorerCard(), spIndexerCard(), wallet.watchOnly || !wallet.mnemonic ? null : syncCard() ); } +// Merge many small coins into one. Useful after lots of small receives/gifts — +// keeps future sends/gifts to a single input (cheaper, smaller gift QR). +function consolidateCard() { + if (wallet.watchOnly) return null; + const n = wallet.spendableCoinCount ? wallet.spendableCoinCount() : 0; + if (n < 2) return null; + return h('div', { class: 'card col' }, + h('h3', {}, t('consolidateTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('consolidateDesc', { n })), + ui.consolidateError && h('div', { class: 'notice err' }, ui.consolidateError), + h('button', { class: 'btn-block', disabled: ui.busy || wallet.offline, onClick: doConsolidate }, ui.busy ? h('span', { class: 'spinner' }) : t('consolidateAction', { n }))); +} +async function doConsolidate() { + if (wallet.offline) { ui.consolidateError = t('scanOffline'); render(); return; } + ui.busy = true; ui.consolidateError = ''; render(); + try { + const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; + await wallet.consolidate(rate); + toast(t('consolidateDone')); + wallet.scan().catch(() => {}); + } catch (e) { + ui.consolidateError = e.message || t('consolidateFailed'); + } + ui.busy = false; + render(); +} + // Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims // into a fresh wallet only they control. The coin is reserved until claimed. @@ -1454,20 +1483,22 @@ function createGiftLink() { // A specific-amount gift must leave us a dust change output (the gift PSBT // commits one). If the amount is so close to the balance that no dust change // is possible, point the user at Max to gift the whole balance instead. - const lock = wallet.giftLockPreview(amt); - if (lock == null) { + const summary = wallet.giftCoinSummary(amt); + if (summary == null) { ui.giftError = t('giftNeedsHeadroom', { n: fmtAmount(Math.max(0, spendable - 294)) + ' ' + unitLabel() }); render(); return; } - // Creating the gift now locks the whole source coin until it's claimed. If - // best-fit can't find a near-exact coin, a pre-split would shrink the locked - // change down to a dust pad (294) — worth offering only when the liquidity it - // frees exceeds the split's own fee (otherwise you'd pay more than you regain). + const lock = summary.lock; + // Offer to split/consolidate into one coin first when it would free meaningful + // change OR when the gift would otherwise bundle several coins — a many-input + // gift is costly to claim (each input adds to the claimer's fee) and its link + // can outgrow a QR. Splitting absorbs the fragmentation into the funding tx and + // leaves the claimer a clean single-input gift. const splitFee = Math.ceil((11 + 68 + 31 * 2) * Math.max(1, Math.round(rate))); const freed = lock - amt - 294; - if (freed > splitFee && !wallet.offline) { - ui.giftSplitOffer = { amt, lock, freed, fee: splitFee }; + if ((freed > splitFee || summary.count >= 3) && !wallet.offline) { + ui.giftSplitOffer = { amt, lock, freed: Math.max(0, freed), fee: splitFee, count: summary.count }; ui.giftError = ''; render(); return; @@ -1566,15 +1597,18 @@ function giftCard() { ? (() => { const o = ui.giftSplitOffer; const u = ' ' + unitLabel(); + const manyCoins = (o.count || 0) >= 3; return h('div', { class: 'col gap6' }, h('div', { class: 'small muted' }, - t('giftSplitExplain', { lock: fmtAmount(o.lock) + u, change: fmtAmount(o.lock - o.amt) + u, fee: fmtAmount(o.fee) + u })), + manyCoins + ? t('giftConsolidateExplain', { count: o.count, fee: fmtAmount(o.fee) + u }) + : t('giftSplitExplain', { lock: fmtAmount(o.lock) + u, change: fmtAmount(o.lock - o.amt) + u, fee: fmtAmount(o.fee) + u })), ui.giftError && h('div', { class: 'notice err' }, ui.giftError), ui.busy ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) : h('div', { class: 'col gap6' }, - h('button', { class: 'btn-primary btn-block', onClick: () => doSplitForGift(o.amt) }, t('giftSplitFirst')), - h('button', { class: 'btn-block', onClick: () => doCreateGift(o.amt, giftRate()) }, t('giftLockWhole', { n: fmtAmount(o.lock) + u })), + h('button', { class: 'btn-primary btn-block', onClick: () => doSplitForGift(o.amt) }, manyCoins ? t('giftConsolidateFirst') : t('giftSplitFirst')), + h('button', { class: 'btn-block', onClick: () => doCreateGift(o.amt, giftRate()) }, manyCoins ? t('giftUseManyCoins', { count: o.count }) : t('giftLockWhole', { n: fmtAmount(o.lock) + u })), h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.giftSplitOffer = null; render(); } }, t('back')) ) ); diff --git a/src/i18n.js b/src/i18n.js index 66f866d..3562337 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -241,6 +241,14 @@ const STR = { giftDmFailed: 'Couldn’t send the nostr DM. Share this code with them privately:', giftCopyCode: 'Copy claim code', giftQrTooLong: 'Too large for a QR code — share the link below instead.', + giftConsolidateExplain: 'This gift would bundle {count} coins, making it costly for the recipient to claim. Splitting one coin first (~{fee}) gives them a clean single-input gift.', + giftConsolidateFirst: 'Split into one coin first', + giftUseManyCoins: 'Use {count} coins anyway', + consolidateTitle: 'Consolidate coins', + consolidateDesc: 'Your balance is spread across {n} coins. Merging them into one makes future sends and gifts cheaper and smaller.', + consolidateAction: 'Merge {n} coins into one', + consolidateDone: 'Consolidating — your coins are merging into one.', + consolidateFailed: 'Consolidation failed.', claimWithExtension: 'Claim with nostr extension', orEnterCode: 'or paste the claim code', extWrongAccount: 'Your extension is signed in as a different nostr account than this gift is for.', diff --git a/src/wallet.js b/src/wallet.js index e2b5b35..6e3ea81 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1577,6 +1577,29 @@ export class Wallet { return sel ? sel.reduce((s, u) => s + u.value, 0) : null; } + // The coins a gift of this amount would lock: total value + how many inputs. + // A high input count means a costly-to-claim gift (each input adds to the + // claimer's fee) — the UI uses it to recommend splitting/consolidating first. + giftCoinSummary(amountSats) { + const sel = this._selectGiftCoins(BigInt(Math.round(amountSats))); + return sel ? { lock: sel.reduce((s, u) => s + u.value, 0), count: sel.length } : null; + } + + // Number of unreserved spendable coins (for the consolidate prompt/action). + spendableCoinCount() { + return this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)).length; + } + + // Merge all unreserved spendable coins into one, via a max self-send. Leaves + // the wallet with a single coin so future sends/gifts use one input. + async consolidate(feeRate) { + const rate = Math.max(1, Math.round(feeRate)); + const draft = this.buildTx({ recipients: [{ address: this.freshReceive().address }], feeRate: rate, sendMax: true }); + const hex = this.sign(draft.tx); + await this.broadcast(hex); + return { txid: draft.tx.id, inputs: draft.inputsCount }; + } + // 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 -- cgit v1.2.3 From 5dd9412fded8f712932dc4b3036c3e96cef816c5 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 13:30:27 -0700 Subject: Remove the 'reusable address that can't be tracked' note from the SP receive card Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index d9066a3..3f7b0b4 100644 --- a/src/app.js +++ b/src/app.js @@ -2480,8 +2480,7 @@ function receiveTab() { : null, h('div', { html: qrSvg(addr) }), h('div', { class: 'addr-box' + (sp ? ' break' : ''), style: 'width:100%' + (sp ? ';font-size:12px' : '') }, addr), - copyBtn(addr, t('copyAddress')), - sp ? h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('spReceiveDesc')) : null + copyBtn(addr, t('copyAddress')) ); } -- cgit v1.2.3 From 210eeb8073cd3cc46dae3411ce84ec99c928d2b1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 14:05:19 -0700 Subject: Nostr: use SimplePool (fixes WS console errors) + send DMs as NIP-04 AND NIP-17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrated all relay I/O (profile/inbox fetch, DM publish, state sync) to a shared SimplePool. The old code opened a relay per request and closed it manually, which raced nostr-tools' CLOSE-after-EOSE and spammed 'WebSocket is already in CLOSING or CLOSED state'. SimplePool owns the connection lifecycle, so that's gone. Also reuses connections instead of churning one per fetch. - Gift DMs now go out as BOTH NIP-04 (kind 4) and NIP-17 (kind 1059 gift wrap). Older clients (Amethyst) read NIP-04; newer ones (Jumble) only support NIP-17 and ignore NIP-04 — sending both means the claim code shows up either way. Verified: profile/inbox fetch + DM still work via the pool; the NIP-17 wrap is published and unwraps to the original message; the NIP-04 kind-4 still lands too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/nostr.js | 154 +++++++++++++---------------------------------------------- 1 file changed, 33 insertions(+), 121 deletions(-) (limited to 'src') diff --git a/src/nostr.js b/src/nostr.js index 0ebfe92..f1c1802 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -12,12 +12,17 @@ import * as nip06 from 'nostr-tools/nip06'; import * as nip44 from 'nostr-tools/nip44'; import * as nip04 from 'nostr-tools/nip04'; +import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17'; import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19'; -import { Relay } from 'nostr-tools/relay'; +import { SimplePool } from 'nostr-tools/pool'; import { randomBytes } from '@noble/hashes/utils'; import { base64urlnopad } from '@scure/base'; +// One shared pool for all relay I/O — it manages connection lifecycles (and the +// CLOSE-after-EOSE handshake) cleanly, so we never send on a half-closed socket. +const pool = new SimplePool(); + // --- locking a gift to a recipient's Nostr key --------------------------- // An npub or 64-hex nostr pubkey → hex, or null if it isn't one. export function parseNostrPubkey(input) { @@ -51,53 +56,28 @@ export function decryptWithCode(code, ct) { export const PROFILE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.coinos.io']; // Fetch a recipient's profile (name + picture) for a pubkey, newest across relays. export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { - const results = await Promise.allSettled(relays.map((url) => _fetchProfileFrom(url, pubkeyHex))); - let best = null; - for (const r of results) { - const v = r.status === 'fulfilled' ? r.value : null; - if (v && (!best || (v.created_at || 0) > (best.created_at || 0))) best = v; - } + let events; + try { events = await pool.querySync(relays, { kinds: [0], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return null; } + const best = events.sort((a, b) => b.created_at - a.created_at)[0]; if (!best) return null; try { const m = JSON.parse(best.content); return { name: m.display_name || m.name || null, picture: m.picture || null, nip05: m.nip05 || null }; } catch { return null; } } -async function _fetchProfileFrom(url, pk) { - let relay; - try { relay = await Relay.connect(url); } catch { return null; } - return new Promise((resolve) => { - let ev = null; - const done = () => { try { sub.close(); } catch {} try { relay.close(); } catch {} resolve(ev); }; - const sub = relay.subscribe([{ kinds: [0], authors: [pk], limit: 1 }], { - onevent: (e) => { if (!ev || e.created_at > ev.created_at) ev = e; }, - oneose: done, - }); - setTimeout(done, 5000); - }); -} // Where to deliver a DM to a pubkey: prefer their NIP-17 DM relay list (kind // 10050), else their NIP-65 read relays (kind 10002), else [] (caller falls back). export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) { - const lists = (await Promise.allSettled(relays.map((url) => _fetchRelayListFrom(url, pubkeyHex)))).flatMap((r) => (r.status === 'fulfilled' && r.value) || []); - const newest = (kind) => lists.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0]; + let events; + try { events = await pool.querySync(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return []; } + const newest = (kind) => events.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0]; const dm = newest(10050); if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r; } const nip65 = newest(10002); if (nip65) { const r = nip65.tags.filter((t) => t[0] === 'r' && t[1] && (!t[2] || t[2] === 'read')).map((t) => t[1]); if (r.length) return r; } return []; } -async function _fetchRelayListFrom(url, pk) { - let relay; - try { relay = await Relay.connect(url); } catch { return []; } - return new Promise((resolve) => { - const evs = []; - const done = () => { try { sub.close(); } catch {} try { relay.close(); } catch {} resolve(evs); }; - const sub = relay.subscribe([{ kinds: [10002, 10050], authors: [pk] }], { onevent: (e) => evs.push(e), oneose: done }); - setTimeout(done, 5000); - }); -} const DTAG = 'bitcoin-wallet'; const SYNC_KEY = 'btc-wallet-sync'; @@ -131,7 +111,6 @@ export class NostrSync { this.pk = null; this.ck = null; // self conversation key for NIP-44 this.relays = DEFAULT_SYNC_RELAYS; - this._conns = new Map(); // url -> Relay } load(mnemonic, passphrase = '') { @@ -140,40 +119,10 @@ export class NostrSync { this.ck = nip44.getConversationKey(this.sk, this.pk); } - unload() { - this.sk = this.pk = this.ck = null; - this._closeAll(); - } - - _closeAll() { - for (const r of this._conns.values()) { - try { - r.close(); - } catch {} - } - this._conns.clear(); - } + unload() { this.sk = this.pk = this.ck = null; } - // Point at a new relay list, closing connections that are no longer used. setRelays(relays) { - const next = Array.isArray(relays) && relays.length ? relays : DEFAULT_SYNC_RELAYS; - for (const url of [...this._conns.keys()]) { - if (!next.includes(url)) { - try { - this._conns.get(url).close(); - } catch {} - this._conns.delete(url); - } - } - this.relays = next; - } - - async _connect(url) { - const existing = this._conns.get(url); - if (existing && existing.connected) return existing; - const r = await Relay.connect(url); - this._conns.set(url, r); - return r; + this.relays = Array.isArray(relays) && relays.length ? relays : DEFAULT_SYNC_RELAYS; } // Encrypt + publish the latest state to every relay (best-effort). @@ -182,76 +131,39 @@ export class NostrSync { let evt; try { const content = nip44.encrypt(JSON.stringify(stateObj), this.ck); - evt = finalizeEvent( - { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', DTAG]], content }, - this.sk - ); - } catch { - return; - } - await Promise.allSettled( - this.relays.map(async (url) => { - const relay = await this._connect(url); - await relay.publish(evt); - }) - ); + evt = finalizeEvent({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', DTAG]], content }, this.sk); + } catch { return; } + await Promise.allSettled(pool.publish(this.relays, evt)); } - // Send an encrypted DM (NIP-04 kind 4) — used to deliver a locked gift's claim - // code. Delivers to the recipient's published inbox relays (NIP-17 / NIP-65) so - // their client actually sees it, plus a broad fallback. Returns true if ≥1 relay - // accepted it. + // Deliver an encrypted DM (a locked gift's claim code) to a recipient. Sends it + // BOTH ways so any client sees it: NIP-04 (kind 4 — older clients like Amethyst) + // and NIP-17 (kind 1059 gift wrap — newer clients like Jumble, which ignore + // NIP-04). Goes to the recipient's published inbox relays (NIP-17/65) plus a + // broad fallback. Returns true if ≥1 relay accepted ≥1 of them. async sendDM(recipientPkHex, text, relays = null) { if (!this.sk) return false; - let evt; - try { - const content = await nip04.encrypt(this.sk, recipientPkHex, text); - evt = finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPkHex]], content }, this.sk); - } catch { return false; } + const events = []; + try { events.push(finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPkHex]], content: await nip04.encrypt(this.sk, recipientPkHex, text) }, this.sk)); } catch {} + try { events.push(nip17WrapEvent(this.sk, { publicKey: recipientPkHex }, text)); } catch {} + if (!events.length) return false; let targets = relays; if (!targets) { const inbox = await fetchInboxRelays(recipientPkHex); targets = [...new Set([...inbox, ...PROFILE_RELAYS])].slice(0, 8); // recipient's inbox + safety net } - const res = await Promise.allSettled(targets.map(async (url) => { const r = await this._connect(url); await r.publish(evt); })); + const res = await Promise.allSettled(events.flatMap((evt) => pool.publish(targets, evt))); return res.some((x) => x.status === 'fulfilled'); } - // Fetch from every relay; return the newest decrypted state, or null. + // Fetch the newest decrypted state across relays, or null. async fetch() { if (!this.sk) return null; - const results = await Promise.allSettled(this.relays.map((url) => this._fetchOne(url))); - let best = null; - for (const res of results) { - const v = res.status === 'fulfilled' ? res.value : null; - if (v && (!best || (v.savedAt || 0) > (best.savedAt || 0))) best = v; + let events; + try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk], '#d': [DTAG] }, { maxWait: 6000 }); } catch { return null; } + for (const e of events.sort((a, b) => b.created_at - a.created_at)) { + try { const v = JSON.parse(nip44.decrypt(e.content, this.ck)); if (v) return v; } catch {} } - return best; - } - - async _fetchOne(url) { - const relay = await this._connect(url); - return new Promise((resolve) => { - let result = null; - const sub = relay.subscribe([{ kinds: [30078], authors: [this.pk], '#d': [DTAG], limit: 1 }], { - onevent: (e) => { - try { - result = JSON.parse(nip44.decrypt(e.content, this.ck)); - } catch {} - }, - oneose: () => { - try { - sub.close(); - } catch {} - resolve(result); - }, - }); - setTimeout(() => { - try { - sub.close(); - } catch {} - resolve(result); - }, 6000); - }); + return null; } } -- cgit v1.2.3 From ca2dec6ebca48daef99427c1fcd39439b7d75d4b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 14:07:11 -0700 Subject: Send gift DMs as NIP-17 only (drop NIP-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop also sending the legacy NIP-04 kind-4 DM — clients that support both (e.g. Amethyst) were showing the claim code twice. NIP-17 alone covers current clients. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/nostr.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/nostr.js b/src/nostr.js index f1c1802..31c9858 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -11,7 +11,6 @@ import * as nip06 from 'nostr-tools/nip06'; import * as nip44 from 'nostr-tools/nip44'; -import * as nip04 from 'nostr-tools/nip04'; import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17'; import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19'; @@ -136,23 +135,20 @@ export class NostrSync { await Promise.allSettled(pool.publish(this.relays, evt)); } - // Deliver an encrypted DM (a locked gift's claim code) to a recipient. Sends it - // BOTH ways so any client sees it: NIP-04 (kind 4 — older clients like Amethyst) - // and NIP-17 (kind 1059 gift wrap — newer clients like Jumble, which ignore - // NIP-04). Goes to the recipient's published inbox relays (NIP-17/65) plus a - // broad fallback. Returns true if ≥1 relay accepted ≥1 of them. + // Deliver an encrypted DM (a locked gift's claim code) as a NIP-17 gift wrap + // (kind 1059) — the modern standard every current client supports. Goes to the + // recipient's published inbox relays (NIP-17/65) plus a broad fallback. Returns + // true if ≥1 relay accepted it. async sendDM(recipientPkHex, text, relays = null) { if (!this.sk) return false; - const events = []; - try { events.push(finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPkHex]], content: await nip04.encrypt(this.sk, recipientPkHex, text) }, this.sk)); } catch {} - try { events.push(nip17WrapEvent(this.sk, { publicKey: recipientPkHex }, text)); } catch {} - if (!events.length) return false; + let evt; + try { evt = nip17WrapEvent(this.sk, { publicKey: recipientPkHex }, text); } catch { return false; } let targets = relays; if (!targets) { const inbox = await fetchInboxRelays(recipientPkHex); targets = [...new Set([...inbox, ...PROFILE_RELAYS])].slice(0, 8); // recipient's inbox + safety net } - const res = await Promise.allSettled(events.flatMap((evt) => pool.publish(targets, evt))); + const res = await Promise.allSettled(pool.publish(targets, evt)); return res.some((x) => x.status === 'fulfilled'); } -- cgit v1.2.3 From 06376a71d4b9a4f579095933d91fe0a369b5e0fe Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 14:12:47 -0700 Subject: SP indexer → sp.coinos.io (clearer than tweaks); tunnel on http2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed the default mainnet SP-indexer endpoint to sp.coinos.io. Also switched the cloudflared bitcoin tunnel to http2 (QUIC was intermittently dropping edge connections → transient 502s). tweaks.coinos.io still resolves to the same indexer. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index cb582f3..b7b34d9 100644 --- a/src/api.js +++ b/src/api.js @@ -144,7 +144,7 @@ function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; } // outputs, so self-hosting is the privacy-max option. Per network; an empty URL // means SP receiving is unavailable on that network. const SP_INDEXER_PRESETS_BY_NET = { - mainnet: [{ id: 'coinos', label: 'coinos', url: 'https://tweaks.coinos.io' }, { id: 'custom', label: 'Custom', url: '' }], + mainnet: [{ id: 'coinos', label: 'coinos', url: 'https://sp.coinos.io' }, { id: 'custom', label: 'Custom', url: '' }], testnet: [{ id: 'custom', label: 'Custom', url: '' }], mutinynet: [{ id: 'custom', label: 'Custom', url: '' }], regtest: [{ id: 'local', label: 'Local (localhost:8888)', url: 'http://localhost:8888' }, { id: 'custom', label: 'Custom', url: '' }], -- cgit v1.2.3 From a267f7f37fd178ebb58871188067b571b18a67f1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 14:25:09 -0700 Subject: Gifts: re-view link/QR for unclaimed gifts + mark claimed ones in history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist a per-account gift record (shareable link/code + amount, plus a locked gift's claim code) when a gift is created, so: - Unclaimed gifts in History get a 'Gift link' button that re-opens the QR + link (and the claim code for locked gifts), to re-share without recreating the gift. - Once a gift's coin is spent and we didn't revoke it, it shows in History as 'claimed' instead of vanishing. Revoking marks the record so a reclaim isn't mislabelled as claimed. Verified on regtest: create → outstanding=1, link stored; after the recipient claims → outstanding=0, claimed=1 with the right amount. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++++---------- src/i18n.js | 3 +++ src/wallet.js | 26 ++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 3f7b0b4..eaf9b86 100644 --- a/src/app.js +++ b/src/app.js @@ -57,6 +57,7 @@ const ui = { claimLocked: null, // a locked gift being opened: { v, amount, to, ct } claimCodeInput: '', // the claim code the recipient pastes from their nostr DM consolidateError: null, // error from a coin-consolidation attempt + viewGift: null, // re-viewing a previously created gift's link/QR { code, locked, amount, claimCode } giftCode: null, // last-created gift PSBT code giftError: '', giftMax: false, // gift the whole spendable balance (no-change sweep) @@ -261,6 +262,8 @@ function render() { const screen = ui.claimLocked ? lockedGiftClaimView() + : ui.viewGift + ? viewGiftView() : ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' @@ -1510,13 +1513,18 @@ function createGiftLink() { // ciphertext, and the code is DM'd to the recipient over nostr. Without, it's a // plain bearer gift. function finishGift(g, fallbackAmt) { + const amount = g.amount || fallbackAmt || 0; const pk = ui.giftLockNpub.trim() ? parseNostrPubkey(ui.giftLockNpub) : null; - if (!pk) { ui.giftCode = g.code; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; return; } - const { blob, claimCode } = lockGift(g.code, g.amount || fallbackAmt || 0, pk); + if (!pk) { + ui.giftCode = g.code; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; + wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved }); + return; + } + const { blob, claimCode } = lockGift(g.code, amount, pk); ui.giftCode = blob; ui.giftLocked = true; ui.giftClaimCode = claimCode; ui.giftDmStatus = 'sending'; - const amount = fmtAmount(g.amount || fallbackAmt || 0) + ' ' + unitLabel(); - const dm = t('giftDmText', { amount, link: giftUrl(), code: claimCode }); - wallet.sendNostrDM(pk, dm).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); }); + wallet.recordGift({ code: blob, locked: true, amount, claimCode, outpoints: g.reserved }); + const dmText = t('giftDmText', { amount: fmtAmount(amount) + ' ' + unitLabel(), link: giftUrl(), code: claimCode }); + wallet.sendNostrDM(pk, dmText).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); }); } function doCreateGift(amt, rate) { try { @@ -1682,6 +1690,7 @@ async function doRevoke(id) { try { const rate = (wallet.feeRates && wallet.feeRates.fastestFee) || 10; await wallet.revokeGift(id, rate); + wallet.markGiftRevoked(id); // we took it back — don't later mislabel it as claimed ui.revokeId = null; toast(t('giftRevoked')); wallet.scan().catch(() => {}); @@ -1882,6 +1891,29 @@ function readLockedGiftHash() { } } +// Re-view a previously created gift's link + QR (from its stored record), so the +// sender can re-share an unclaimed gift. For a locked gift, also surfaces the +// claim code that was DM'd, in case it needs re-sending. +function viewGiftView() { + const g = ui.viewGift; // { code, locked, amount, claimCode } + const url = `${location.origin}/${g.locked ? 'lg' : 'g'}/${g.code}`; + let svg = null; + try { svg = g.locked ? qrSvg(url) : qrSvg(url.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} + return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, + h('div', { class: 'card col', style: 'gap:12px;align-items:center' }, + h('h3', { style: 'margin:0' }, t('giftView')), + g.amount != null ? h('div', { class: 'amt' }, fmtAmount(g.amount), ' ', unitTag()) : null, + svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, url), + copyBtn(url, t('copyLink')), + g.locked && g.claimCode + ? h('div', { class: 'col gap6', style: 'width:100%;align-items:center' }, h('div', { class: 'small muted' }, t('giftCodeLabel')), copyBtn(g.claimCode, t('giftCopyCode'))) + : null, + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.viewGift = null; render(); } }, t('back')) + ) + ); +} + // A locked gift opened from a link: the amount + recipient are public. The // recipient pastes the claim code from their nostr DM; that decrypts the payload, // then it's claimed into a fresh wallet exactly like a normal gift. @@ -3179,16 +3211,20 @@ function giftHistoryItem(g) { ui.busy ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.revokeId = null; render(); } }, t('back')) ); } + const amt = g.value != null ? g.value : g.amount; + const rec = g.claimed ? null : wallet.giftLink(g.id); return h('div', { class: 'item' }, h('div', { class: 'ico out' }, '🎁'), h('div', { class: 'grow' }, h('div', { class: 'row gap6' }, t('giftHistoryTitle'), - h('span', { class: 'tag pending' }, g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), - g.reserved ? h('div', { class: 'small faint' }, t('lockedInGifts')) : null + h('span', { class: 'tag ' + (g.claimed ? 'conf' : 'pending') }, g.claimed ? t('giftClaimedTag') : g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), + !g.claimed && g.reserved ? h('div', { class: 'small faint' }, t('lockedInGifts')) : null ), h('div', { style: 'text-align:right' }, - g.value != null ? h('div', { class: 'amount' }, fmtAmount(g.value)) : null, - h('button', { class: 'btn-sm', style: 'margin-top:4px', onClick: () => { ui.revokeId = g.id; render(); } }, t('giftCancel')) + amt != null ? h('div', { class: 'amount' }, fmtAmount(amt)) : null, + g.claimed ? null : h('div', { class: 'row gap6', style: 'justify-content:flex-end;margin-top:4px' }, + rec ? h('button', { class: 'btn-sm', onClick: () => { ui.viewGift = rec; render(); } }, t('giftView')) : null, + h('button', { class: 'btn-sm', onClick: () => { ui.revokeId = g.id; render(); } }, t('giftCancel'))) ) ); } @@ -3265,7 +3301,9 @@ function historyTab() { ); // Outstanding sent gifts (reserved/reclaimed but unclaimed) sit above the // on-chain history; they aren't transactions until claimed or revoked. - const gifts = wallet.loaded ? wallet.outstandingGifts() : []; + const gifts = wallet.loaded + ? [...wallet.outstandingGifts(), ...wallet.claimedGifts().map((c) => ({ ...c, claimed: true }))] + : []; if (ui.giftsAll && gifts.length) return giftsAllView(gifts); if (!txs.length && !gifts.length) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet'))); diff --git a/src/i18n.js b/src/i18n.js index 3562337..047509b 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -269,6 +269,9 @@ const STR = { newWalletBody: 'Back up your recovery phrase to keep it safe.', giftHistoryTitle: 'Gift link', giftUnclaimedTag: 'unclaimed', + giftClaimedTag: 'claimed', + giftView: 'Gift link', + giftCodeLabel: 'Claim code (sent via DM)', giftCancel: 'Cancel', viewAllGifts: 'View all gifts ({n})', prevPage: '‹ Prev', diff --git a/src/wallet.js b/src/wallet.js index 6e3ea81..4a8ddc4 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1549,6 +1549,32 @@ export class Wallet { })); } + // Persistent gift records (per account): the shareable link/code + amount, so a + // gift's link/QR can be re-viewed later and a claimed one identified in history. + _giftRecordsKey() { return this._cacheKey() + ':giftrec'; } + giftRecords() { + if (!this._giftRecords) { try { this._giftRecords = JSON.parse(localStorage.getItem(this._giftRecordsKey()) || '{}'); } catch { this._giftRecords = {}; } } + return this._giftRecords; + } + _saveGiftRecords() { try { localStorage.setItem(this._giftRecordsKey(), JSON.stringify(this.giftRecords())); } catch {} } + recordGift({ code, locked, amount, claimCode, outpoints }) { + if (!outpoints || !outpoints.length) return; + this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false }; + this._saveGiftRecords(); + } + giftLink(id) { return this.giftRecords()[id] || null; } + markGiftRevoked(id) { const r = this.giftRecords()[id]; if (r) { r.revoked = true; this._saveGiftRecords(); } } + // Gifts whose coin has been spent and weren't revoked by us → claimed. (Only + // once scanned, so an un-synced wallet doesn't mislabel everything as claimed.) + claimedGifts() { + if (!this.loaded) return []; + const live = new Set(this.utxos.map((u) => utxoId(u))); + return Object.values(this.giftRecords()) + .filter((r) => !r.revoked && r.outpoints && !r.outpoints.some((o) => live.has(o))) + .map((r) => ({ id: r.id, amount: r.amount, locked: r.locked, created: r.created })) + .sort((a, b) => (b.created || 0) - (a.created || 0)); + } + // 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 -- cgit v1.2.3 From 6fd4469cd09226a3a8672251bc825a26945fcad5 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 17:32:55 -0700 Subject: Gifts: let recipients claim into an existing wallet, not just a new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a gift link when you already have wallet(s) now shows a 'Choose where to receive it' screen — each known wallet (full accounts this session plus the durable directory, deduped by xpub) plus 'Receive into a new wallet'. A first-timer with no wallets goes straight to a fresh wallet as before. Claiming into an existing wallet activates it non-provisionally and sweeps the gift to its receive address (watch-only is fine — re-enter the seed to spend), skipping the new-seed backup step. Both bearer and locked (NIP-17) gifts route through the same claimGift() entry. Verified in-browser: selection lists the wallet + new option + amount; claim-into-address mechanic proven on regtest. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------ src/i18n.js | 4 +++ 2 files changed, 82 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index eaf9b86..a11691c 100644 --- a/src/app.js +++ b/src/app.js @@ -58,6 +58,7 @@ const ui = { claimCodeInput: '', // the claim code the recipient pastes from their nostr DM consolidateError: null, // error from a coin-consolidation attempt viewGift: null, // re-viewing a previously created gift's link/QR { code, locked, amount, claimCode } + claimChoose: null, // opening a gift with existing wallets present: { code } — pick a target giftCode: null, // last-created gift PSBT code giftError: '', giftMax: false, // gift the whole spendable balance (no-change sweep) @@ -264,6 +265,8 @@ function render() { ? lockedGiftClaimView() : ui.viewGift ? viewGiftView() + : ui.claimChoose + ? claimChooseView() : ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' @@ -756,7 +759,7 @@ async function activateAccount(acc, opts = {}) { // A gift link generates this wallet only to claim into. Keep it provisional // until the user commits (claims, or chooses to keep it / enters the wallet), // so bailing from an already-claimed gift doesn't leave an empty account. - if (opts.gift) acc.provisional = true; + if (opts.gift && !opts.existingClaim) acc.provisional = true; const netName = getNetwork(); if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName, offline: false }); else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName, offline: false }); @@ -1951,7 +1954,7 @@ async function claimViaExtension() { const payload = await ext.nip44.decrypt(ui.claimLocked.eph, ui.claimLocked.ctKey); if (!payload || !previewGift(payload)) { ui.claimError = t('extDecryptFailed'); ui.busy = false; render(); return; } ui.busy = false; ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; - enterWallet(newMnemonic(), '', { gift: payload }); // fresh-wallet claim + claimGift(payload); // claim into an existing wallet or a fresh one } catch { ui.busy = false; ui.claimError = t('extDecryptFailed'); render(); } @@ -1963,7 +1966,7 @@ function submitLockedCode() { try { payload = decryptWithCode(code, ui.claimLocked.ct); } catch {} if (!payload || !previewGift(payload)) { ui.claimError = t('claimCodeWrong'); render(); return; } ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; - enterWallet(newMnemonic(), '', { gift: payload }); // fresh-wallet claim, like a normal gift + claimGift(payload); // claim into an existing wallet or a fresh one } // Has this gift already been claimed? Its funding coin being spent — even by an @@ -1996,8 +1999,15 @@ async function doClaim() { const claim = buildClaimTx(ui.claimCode, to, rate, wallet.netCfg.net); await wallet.broadcast(claim.hex); ui.claimedAmount = claim.amount; - ui.claimStep = 'backup'; - commitAccount(); // claimed — this wallet now holds funds, keep it + const wasProvisional = !!(activeAccount() && activeAccount().provisional); + commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one) + if (wasProvisional) { + ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up + } else { + ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up + ui.claimStep = null; + toast(t('giftClaimedToast')); + } wallet.scan().catch(() => {}); } catch (e) { // Broadcast failed — most likely someone claimed it in the race window. @@ -2015,6 +2025,61 @@ async function doClaim() { render(); } +// Wallets a gift could be claimed into: full accounts open this session plus the +// durable directory (every wallet's xpub, watch-only), deduped by xpub. Claiming +// sends the gift to the wallet's address; a watch-only one then needs the seed +// re-entered to spend. Empty for a first-time recipient. +function claimTargets() { + let sess = null; + try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {} + const open = ((sess && Array.isArray(sess.accounts)) ? sess.accounts : []).filter((a) => !a.provisional); + const seen = new Set(open.map((a) => a.xpub).filter(Boolean)); + return [...open, ...loadWatchAccounts().filter((w) => !seen.has(w.xpub))]; +} + +// Entry point for claiming a gift code. If the recipient already has wallet(s), +// offer to claim into one or make a new one; otherwise go straight to a fresh +// wallet (the original first-timer flow). +function claimGift(code) { + ui.claimLocked = null; + const targets = claimTargets(); + if (targets.length) { accounts = targets; ui.claimChoose = { code }; render(); } + else enterWallet(newMnemonic(), '', { gift: code }); +} + +// Claim a gift into an existing wallet (no new seed). activateAccount loads it and +// opens the claim screen; existingClaim keeps it committed and, in doClaim, skips +// the seed-backup step. +function claimIntoAccount(acc, code) { + ui.claimChoose = null; + activateAccount(acc, { gift: code, existingClaim: true, fresh: true }); +} + +// Pick where to receive a gift: an existing wallet, or a brand-new one. +function claimChooseView() { + const code = ui.claimChoose.code; + const pv = previewGift(code); + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, + h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), + h('h2', { style: 'margin:0' }, t('giftWelcome')), + pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null, + h('p', { class: 'muted', style: 'margin:0' }, t('claimIntoPrompt')) + ), + h('div', { class: 'card col gap6' }, + ...accounts.map((a) => + h('button', { class: 'btn-block', style: 'text-align:left', onClick: () => claimIntoAccount(a, code) }, + a.label, + a.type === 'watch' ? h('span', { class: 'small faint', style: 'margin-left:6px' }, '(' + t('watchOnly') + ')') : null) + ), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimChoose = null; enterWallet(newMnemonic(), '', { gift: code }); } }, t('claimNewWallet')) + ) + ); +} + // Gift-claim flow: a fresh wallet only the claimer controls. Step 'welcome' // shows the amount + a Claim button; 'backup' (after a successful claim) shows // the fresh recovery phrase to write down. @@ -2108,7 +2173,12 @@ function claimScreen() { ), // A fresh wallet was already generated for the claim — offer to keep it, // view the claim on a block explorer, or head back to the home screen. - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimTaken = null; ui.claimedAmount = 0; ui.claimStep = 'backup'; commitAccount(); render(); } }, t('createWalletAnyway')), + h('button', { class: 'btn-primary btn-block', onClick: () => { + ui.claimTaken = null; ui.claimedAmount = 0; + if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); } + else { ui.screen = 'wallet'; ui.claimStep = null; } + render(); + } }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')), ui.claimTaken.txid ? h('a', { class: 'btn btn-block', href: wallet.api.explorerTx(ui.claimTaken.txid), target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(wallet.api.explorerTx(ui.claimTaken.txid)); } }, t('viewOnMempool')) : null, @@ -3456,10 +3526,10 @@ document.addEventListener('visibilitychange', () => { else onAppVisible(); }); loadLocale(getLang()).finally(() => { + applyBootAutoLogout(); // clear an overdue session before we read it for claim targets const code = readGiftHash(); - if (code) { enterWallet(newMnemonic(), '', { gift: code }); return; } + if (code) { claimGift(code); return; } // bearer gift → claim into an existing wallet or a new one ui.claimLocked = readLockedGiftHash(); // locked gift → claim with your own wallet - applyBootAutoLogout(); // clear an overdue session before it's restored if (!restoreAccountsState()) render(); }); diff --git a/src/i18n.js b/src/i18n.js index 047509b..babe3e0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -288,6 +288,10 @@ const STR = { giftRevoked: 'Gift revoked', copyLink: 'Copy link', claimBody: 'Claim it into a brand-new wallet that only you control.', + claimIntoPrompt: 'Choose where to receive it.', + claimNewWallet: 'Receive into a new wallet', + goToWallet: 'Go to wallet', + giftClaimedToast: 'Gift claimed', claimBtn: 'Claim it', claimFeeNote: '~{n} fee', claimFailed: 'This gift has already been claimed or is no longer available.', -- cgit v1.2.3 From 374f08e28a1bb42fb8911d34126043b69f286410 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 18:10:56 -0700 Subject: Fix Add wallet reusing the current wallet's seed instead of a new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create pane shows the existing draftMnemonic's backup if one is set, only falling back to the Generate-seed step when it's empty. Nothing cleared the draft after a wallet was created, and Add wallet jumped to the create tab without resetting it — so Add wallet presented the already-active wallet's seed, and 'creating' it just re-opened the same account (no new wallet). Clear draftMnemonic (+ createStep, confirm) when Add wallet starts a fresh creation, and clear it in openWallet once a seed has been used to create/enter a wallet, so each new creation generates a fresh seed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index a11691c..cd63fe9 100644 --- a/src/app.js +++ b/src/app.js @@ -731,7 +731,7 @@ async function openWallet(input, opts = {}) { ui.unlockError = ''; const raw = (input || '').trim(); const m = raw.replace(/\s+/g, ' '); - if (isValidMnemonic(m)) { await enterWallet(m, ui.passphrase, { generated: opts.generated }); return; } + if (isValidMnemonic(m)) { await enterWallet(m, ui.passphrase, { generated: opts.generated }); ui.draftMnemonic = ''; return; } // discard the used draft so the next "Add wallet" generates a fresh seed let pk; try { pk = parseExtendedKey(raw); } catch { ui.unlockError = t('invalidImport'); render(); return; } const acc = pk.kind === 'xpub' @@ -2278,7 +2278,7 @@ function accountsScreen() { ); }) ), - h('button', { class: 'btn-block', onClick: () => { ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; render(); } }, t('addWallet')), + h('button', { class: 'btn-block', onClick: () => { ui.draftMnemonic = ''; ui.createStep = 'gen'; ui.confirm = []; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; render(); } }, t('addWallet')), hasVault() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.confirmClear = true; render(); } }, t('clearAll')) ), -- cgit v1.2.3