aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/gifts.js32
-rw-r--r--src/features/index.js3
-rw-r--r--src/features/sp-wallet.js467
-rw-r--r--src/features/sp.js2
-rw-r--r--src/features/sync.js119
5 files changed, 620 insertions, 3 deletions
diff --git a/src/features/gifts.js b/src/features/gifts.js
index 9562b9f..b17f5da 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -13,7 +13,7 @@ export function giftsFeature(ctx) {
const {
h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, openExternal,
fmtAmount, unitLabel, unitTag, parseAmount, getUnit, toggleUnit, download,
- brandHeader, profileChip, activeAccount, setAccounts, getAccounts, claimTargets,
+ brandHeader, activeAccount, setAccounts, getAccounts, claimTargets,
enterWallet, activateAccount, commitAccount,
} = ctx;
installGiftWallet(wallet); // gift primitives live outside the core wallet
@@ -441,7 +441,11 @@ export function giftsFeature(ctx) {
ui.giftCode = blob; ui.giftLocked = true; ui.giftClaimCode = claimCode; ui.giftDmStatus = 'sending';
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(); });
+ if (wallet.sendNostrDM) {
+ wallet.sendNostrDM(pk, dmText).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); });
+ } else {
+ ui.giftDmStatus = 'failed'; // no sync/nostr feature in this build — the claim code fallback shows
+ }
}
function giftCard() {
@@ -699,6 +703,30 @@ export function giftsFeature(ctx) {
render();
}
+ // --- 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));
+ }
+
// "Payment received!" takeover right after a gift claim (no scan wait).
function claimCelebration() {
// A gift was just claimed into this wallet — celebrate immediately (the
diff --git a/src/features/index.js b/src/features/index.js
index 590020a..e63e816 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -8,9 +8,10 @@ import { giftsFeature } from './gifts.js';
import { swapsFeature } from './swaps.js';
import { arkFeature } from './ark.js';
import { spFeature } from './sp.js';
+import { syncFeature } from './sync.js';
// NB order is meaningful where hooks stack: gifts' receive takeover and
// balance line come before ark's, matching the pre-plugin layout.
export function buildFeatures(ctx) {
- return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), spFeature(ctx)];
+ return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), spFeature(ctx), syncFeature(ctx)];
}
diff --git a/src/features/sp-wallet.js b/src/features/sp-wallet.js
new file mode 100644
index 0000000..c2d1cdb
--- /dev/null
+++ b/src/features/sp-wallet.js
@@ -0,0 +1,467 @@
+// Silent payments (BIP-352) wallet engine — key derivation, indexer scanning
+// (worker-accelerated with an inline fallback), the live indexer WebSocket,
+// SP outputs as spendable coins, sp1 send preparation, and per-output-key
+// signing. Installed onto the core wallet by the sp feature, wired through the
+// wallet's generic seams so a build without sp ships none of this.
+
+import * as btc from '@scure/btc-signer';
+import { hex } from '@scure/base';
+import { schnorr } from '@noble/curves/secp256k1';
+import { HDKey } from '@scure/bip32';
+import { mnemonicToSeedSync } from '@scure/bip39';
+import {
+ isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts,
+ silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress,
+ silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas,
+} from '../silentpay.js';
+import { spIndexerUrl } from '../api.js';
+import { utxoId, absWsUrl, NETS } from '../wallet.js';
+
+const SP_DUST_LIMIT = 0;
+
+export function installSpWallet(wallet) {
+ if (wallet.scanSilentPayments) return; // already installed
+ wallet.spUtxos = [];
+ wallet.lastSpScan = 0; // highest block height scanned for silent payments
+ wallet.spScanning = false;
+
+ Object.assign(wallet, {
+ // 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;
+ 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;
+ },
+
+ // 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();
+ 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 };
+ },
+
+ // 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,
+ }));
+ },
+
+ // 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, silent = false } = {}) {
+ const keys = this.silentPaymentKeys();
+ 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…").
+ if (!silent) { this.spScanning = true; this.emit(); }
+ try {
+ if (rescan) { this.spUtxos = []; this.lastSpScan = 0; }
+ const from = (this.lastSpScan || 0) + 1;
+ // 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 = [];
+ const hits = await this._spHitBlocks(res.blocks || []);
+ for (const block of res.blocks || []) {
+ if (!hits.has(block.height)) continue;
+ const blk = await (await fetch(`${indexer}/block/${block.height}`)).json();
+ 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;
+ // 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), firstSeen: Date.now() });
+ } catch {}
+ }
+ await this._refreshSpUtxos();
+ this._recomputeBalanceFromChains(); // fold found SP value into the displayed balance
+ this.saveCache();
+ return { found: found.length, scanned: tip };
+ } finally {
+ this._spScanBusy = false;
+ this.spScanning = false;
+ this.emit(); // always refresh (balance + clears any "Scanning…")
+ }
+ },
+
+ // 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;
+ },
+
+ // 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(absWsUrl(url)); } catch { return; }
+ this._spWs = ws;
+ 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 || !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 = () => {
+ 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 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;
+ let changed = false;
+ 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();
+ this.saveCache();
+ 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) {
+ // 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 = [];
+ 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.
+ const confNow = this.spUtxos.filter((u) => u.confirmed);
+ const confNowIds = new Set(confNow.map((u) => `${u.txid}:${u.vout}`));
+ 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 (changed) { this._recomputeBalanceFromChains(); this.saveCache(); this.emit(); }
+ }
+ } finally {
+ this._spMempoolBusy = false;
+ }
+ },
+
+ // Mark summary outputs that fund a silent payment with the sp1… address the
+ // user actually typed (the on-chain output is a derived one-time taproot addr).
+ _tagSilentOutputs(summary, spOuts) {
+ for (const s of spOuts) {
+ if (!s.derivedAddress) continue;
+ const o = summary.outputs.find((o) => o.address === s.derivedAddress && !o.silent);
+ if (o) o.silent = s.address;
+ }
+ },
+
+ // Replace each placeholder silent-payment output in a freshly-selected (still
+ // unsigned) tx with its real one-time taproot script, derived from the tx's
+ // actual inputs per BIP-352.
+ _applySilentPayments(tx, spOuts) {
+ const byOutpoint = new Map();
+ for (const u of this.utxos) byOutpoint.set(utxoId(u), { u, 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 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) => {
+ const ph = hex.encode(s.placeholder);
+ const idx = tx.outputs.findIndex((o) => hex.encode(o.script) === ph);
+ if (idx < 0) throw new Error('Silent payment output not found after selection.');
+ tx.updateOutput(idx, { script: scripts[k], amount: tx.outputs[idx].amount }, true);
+ s.derivedAddress = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(scripts[k]));
+ });
+ },
+ });
+
+ // -- seam registrations -----------------------------------------------
+ wallet.registerBalanceExtra(() => wallet.spBalance());
+ wallet.registerHistoryProvider(() => wallet.spTxRows());
+ wallet.registerScanHook(() => wallet.scanSilentPayments({ silent: true }).catch(() => {}));
+ wallet.registerRealtimeHook({
+ start: () => wallet._spConnect(), // live push of silent-payment receipts
+ stop: () => wallet._spDisconnect(),
+ // WS is primary; the heartbeat poll is the fallback when it isn't live.
+ poll: () => { if (!wallet.spLive) wallet.scanSilentPayments({ silent: true }).catch(() => {}); },
+ });
+ wallet.registerLoadHook(({ spFresh }) => {
+ // A freshly-generated wallet can't have received silent payments before it
+ // existed, so its SP watermark starts at the current tip (no history scan).
+ wallet._spFresh = !!spFresh;
+ wallet.spUtxos = [];
+ wallet.lastSpScan = 0;
+ });
+ wallet.registerCacheExtension({
+ save: () => ({ spUtxos: wallet.spUtxos, lastSpScan: wallet.lastSpScan }),
+ load: (d) => { wallet.spUtxos = d.spUtxos || []; wallet.lastSpScan = d.lastSpScan || 0; },
+ });
+ // 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).
+ wallet.registerInputProvider(({ coinIds }) => {
+ const confirmedSp = wallet.spUtxos.filter((u) => u.confirmed);
+ const pool = coinIds ? confirmedSp.filter((u) => coinIds.includes(utxoId(u))) : confirmedSp;
+ return pool.map((u) => ({
+ txid: u.txid,
+ index: u.vout,
+ 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 inputs are signed raw below, via tapKeySig).
+ tapInternalKey: hex.decode(u.xonly),
+ }));
+ });
+ // sp1… recipients: the real taproot output commits to the final input set,
+ // so buildTx selects against a placeholder and swaps the derived script in.
+ wallet.registerSendPreparer(() => {
+ const spOuts = []; // { placeholder, scan, spend, address }
+ return {
+ match: (addr) => isSilentPaymentAddress(addr),
+ prepare(addr) {
+ if (wallet.watchOnly) throw new Error('Silent payments require a wallet with keys.');
+ const { scan, spend } = decodeSilentPaymentAddress(addr);
+ const placeholder = silentPaymentPlaceholder(spOuts.length);
+ spOuts.push({ placeholder, scan, spend, address: addr });
+ return placeholder;
+ },
+ apply(tx) { if (spOuts.length) wallet._applySilentPayments(tx, spOuts); },
+ tag(summary) { wallet._tagSilentOutputs(summary, spOuts); },
+ };
+ });
+ // Received SP outputs sign with d = spend_priv + t_k (raw output key —
+ // no BIP-341 tweak, so btc-signer's auto key-path signing doesn't apply).
+ wallet.registerInputSigner((tx, i, id, { prevScripts, amounts }) => {
+ const u = wallet.spUtxos.find((x) => utxoId(x) === id);
+ if (!u) return false;
+ const d = silentPaymentOutputPrivKey(wallet.silentPaymentKeys().spendPriv, hex.decode(u.tweak));
+ const hash = tx.preimageWitnessV1(i, prevScripts, btc.SigHash.DEFAULT, amounts);
+ tx.updateInput(i, { tapKeySig: schnorr.sign(hash, d) }, true);
+ return true;
+ });
+}
diff --git a/src/features/sp.js b/src/features/sp.js
index 0960cdf..5c55457 100644
--- a/src/features/sp.js
+++ b/src/features/sp.js
@@ -3,12 +3,14 @@
// in wallet.js/silentpay.js and ships with the core wallet for now.)
import { isSilentPaymentAddress } from '../silentpay.js';
+import { installSpWallet } from './sp-wallet.js';
import { getNetwork, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from '../api.js';
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
export function spFeature(ctx) {
const { h, ui, render, wallet, copyBtn } = ctx;
+ installSpWallet(wallet); // the BIP-352 engine lives outside the core wallet
function spIndexerCard() {
if (!wallet.silentPaymentKeys || !wallet.silentPaymentKeys()) return null;
diff --git a/src/features/sync.js b/src/features/sync.js
new file mode 100644
index 0000000..8cbd9f3
--- /dev/null
+++ b/src/features/sync.js
@@ -0,0 +1,119 @@
+// Cross-device sync feature — encrypted wallet state on Nostr relays
+// (NIP-44 to self, replaceable kind 30078) plus the wallet's nostr identity
+// (used e.g. to DM locked-gift claim codes). Installed onto the core wallet;
+// a build without sync ships none of it and never talks to a relay.
+
+import { NostrSync, getSyncConfig, setSyncConfig, npubOf } from '../nostr.js';
+import { t } from '../i18n.js';
+
+export function installSyncWallet(wallet) {
+ if (wallet.syncFromNostr) return; // already installed
+ wallet.nostr = new NostrSync();
+ wallet._nostrPubTimer = null;
+
+ Object.assign(wallet, {
+ // 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;
+ },
+
+ // Pull the latest state from Nostr; apply it if it's newer than what we have.
+ // Returns true if state was applied (so the caller can skip a full scan).
+ async syncFromNostr() {
+ const sync = getSyncConfig();
+ if (this.offline || !sync.enabled) return false;
+ this.nostr.setRelays(sync.relays);
+ let remote;
+ try {
+ remote = await this.nostr.fetch();
+ } catch {
+ return false;
+ }
+ if (!remote) return false;
+ if ((remote.savedAt || 0) > (this._savedAt || 0)) {
+ this._applySnapshot(remote);
+ this.saveCache(); // mirror into localStorage
+ this.emit();
+ return true;
+ }
+ // Our local copy is newer (or equal) — push it up so the relay catches up.
+ if ((this._savedAt || 0) > (remote.savedAt || 0)) this.saveCache();
+ return true; // remote existed, so no full scan needed
+ },
+ });
+
+ // identity follows the open wallet
+ wallet.registerLoadHook(() => {
+ if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase);
+ else wallet.nostr.unload();
+ });
+ // push every saved snapshot to the relays (debounced) unless sync is off
+ wallet.registerCacheSavedHook((snap) => {
+ const sync = getSyncConfig();
+ if (wallet.offline || !sync.enabled) return;
+ wallet.nostr.setRelays(sync.relays);
+ clearTimeout(wallet._nostrPubTimer);
+ wallet._nostrPubTimer = setTimeout(() => wallet.nostr.publish(snap), 2500);
+ });
+ wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) });
+}
+
+export function syncFeature(ctx) {
+ const { h, ui, render, wallet } = ctx;
+ installSyncWallet(wallet);
+
+ // Cross-device sync settings: toggle + editable relay list (default coinos).
+ function syncCard() {
+ const cfg = getSyncConfig();
+ const setEnabled = (enabled) => {
+ if (enabled === cfg.enabled) return;
+ setSyncConfig({ enabled, relays: cfg.relays });
+ render();
+ // On enable, pull anything newer from the relays, then push our copy up.
+ if (enabled && !wallet.offline) {
+ wallet.syncFromNostr().catch(() => {}).finally(() => wallet.saveCache());
+ }
+ };
+ return h(
+ 'div',
+ { class: 'card col' },
+ h('h3', {}, t('deviceSync')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('deviceSyncDesc')),
+ h('div', { class: 'row between' },
+ h('span', { class: 'lab', style: 'margin:0' }, t('syncAcross')),
+ h('div', { class: 'seg' },
+ h('button', { type: 'button', class: cfg.enabled ? 'active' : '', onClick: () => setEnabled(true) }, t('syncOn')),
+ h('button', { type: 'button', class: !cfg.enabled ? 'active' : '', onClick: () => setEnabled(false) }, t('syncOff'))
+ )
+ ),
+ cfg.enabled
+ ? h('label', { class: 'field' },
+ h('span', { class: 'lab' }, t('relaysLabel')),
+ h('textarea', {
+ placeholder: 'wss://relay.example.com',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
+ style: 'min-height:64px',
+ value: cfg.relays.join('\n'),
+ onInput: (e) => {
+ const relays = e.target.value.split('\n').map((s) => s.trim()).filter(Boolean);
+ setSyncConfig({ enabled: true, relays });
+ },
+ }),
+ h('div', { class: 'small faint' }, t('relaysHint'))
+ )
+ : null
+ );
+ }
+
+ return {
+ id: 'sync',
+ settingsCards() {
+ return wallet.watchOnly || !wallet.mnemonic ? [] : [syncCard()];
+ },
+ };
+}