aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api.js176
-rw-r--r--src/app.js605
-rw-r--r--src/i18n.js56
-rw-r--r--src/nostr.js173
-rw-r--r--src/silentpay.js254
-rw-r--r--src/sp-worker.js69
-rw-r--r--src/wallet.js570
7 files changed, 1613 insertions, 290 deletions
diff --git a/src/api.js b/src/api.js
index e7da0b2..54175f1 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 ":<net>" suffix.
function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; }
@@ -144,19 +176,53 @@ const deriveBoltzWs = (api) => api ? api.replace(/^http/, 'ws').replace(/\/+$/,
export function getBoltzApi() { return getBoltzProvider().api; }
export function getBoltzWs() { const p = getBoltzProvider(); return p.ws || deriveBoltzWs(p.api); }
+// 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: '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: '' }],
+};
+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()) {
+ 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);
- 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 +
@@ -180,7 +246,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.
@@ -204,12 +270,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 d29e7d5..41f2d5c 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4,11 +4,11 @@
// 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 { getExplorerConfig, setExplorerConfig, explorerPresets, getDataMode, setDataMode, electrumPresets, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, NETWORKS, getBoltzApi, BOLTZ_PRESETS, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom } from './api.js';
+import { getSyncConfig, setSyncConfig, parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from './nostr.js';
+import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets, getBoltzApi, BOLTZ_PRESETS, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom } from './api.js';
import { SwapManager } from './swap.js';
import { isSilentPaymentAddress } from './silentpay.js';
import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js';
@@ -56,6 +56,15 @@ 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
+ 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)
@@ -231,9 +240,40 @@ 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'
+ ui.claimLocked
+ ? lockedGiftClaimView()
+ : ui.viewGift
+ ? viewGiftView()
+ : ui.claimChoose
+ ? claimChooseView()
+ : ui.screen === 'wallet'
? walletScreen()
: ui.screen === 'accounts'
? accountsScreen()
@@ -247,6 +287,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);
@@ -255,9 +301,68 @@ 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;
+ }
+});
+
+// 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) {
@@ -303,6 +408,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);
}
@@ -489,7 +618,7 @@ function createPane() {
),
h(
'button',
- { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic) },
+ { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) },
t('skipVerification')
)
);
@@ -542,11 +671,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'))
);
}
@@ -604,11 +733,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 }); 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'
@@ -636,11 +765,11 @@ 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 });
- 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.
@@ -1315,16 +1444,45 @@ function settingsTab() {
),
networkCard(),
boltzProviderCard(),
+ 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.
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;
@@ -1340,29 +1498,49 @@ 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;
}
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 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;
+ 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';
+ 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 {
- ui.giftCode = wallet.createGift(amt, rate).code;
+ finishGift(wallet.createGift(amt, rate), amt);
ui.giftError = '';
ui.giftSplitOffer = null;
} catch (e) {
@@ -1375,7 +1553,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;
@@ -1393,7 +1571,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) {
@@ -1411,29 +1589,46 @@ 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. 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')),
- 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
? (() => {
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'))
)
);
@@ -1448,6 +1643,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'))
),
@@ -1496,6 +1702,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(() => {});
@@ -1565,63 +1772,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.id === 'custom' ? o.label : (o.url || o.base).replace(/^\w+:\/\//, '')))),
+ 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)
);
}
@@ -1745,6 +1923,95 @@ function readGiftHash() {
}
}
+// A gift locked to a nostr key: /lg/<base64url>. 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;
+ }
+}
+
+// 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.
+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')),
+ 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 })),
+ 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; } }),
+ 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 = '';
+ claimGift(payload); // claim into an existing wallet or a fresh one
+ } catch {
+ ui.busy = false; ui.claimError = t('extDecryptFailed'); render();
+ }
+}
+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 = '';
+ 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
// unconfirmed claim — means the gift is gone; show the "already claimed" screen
// rather than let the user attempt a doomed double-spend.
@@ -1775,8 +2042,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.
@@ -1794,6 +2068,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.
@@ -1887,7 +2216,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,
@@ -1987,11 +2321,11 @@ 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'))
),
- 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'))
);
}
@@ -2088,6 +2422,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')),
@@ -2095,7 +2437,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'))
);
}
@@ -2345,13 +2687,50 @@ 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: '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
+ ? 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'))
+ );
+}
+
+// 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.id === 'custom' ? o.label : o.url.replace(/^\w+:\/\//, '')))),
+ 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
);
}
@@ -2416,7 +2795,8 @@ 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'))
+ // 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'))
);
}
@@ -2596,7 +2976,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'))
@@ -2665,7 +3045,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';
};
@@ -3024,16 +3403,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')))
)
);
}
@@ -3084,7 +3467,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(); })
@@ -3093,14 +3476,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' },
@@ -3109,17 +3493,19 @@ 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 (!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' },
@@ -3130,7 +3516,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',
@@ -3149,8 +3535,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;
@@ -3203,7 +3589,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'))
);
}
@@ -3262,9 +3648,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; }
- applyBootAutoLogout(); // clear an overdue session before it's restored
+ 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
if (!restoreAccountsState()) render();
});
diff --git a/src/i18n.js b/src/i18n.js
index 2fe840d..babe3e0 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -224,6 +224,38 @@ 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',
+ 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.',
+ 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.',
giftSplitFirst: 'Split a coin first',
giftLockWhole: 'Lock the whole coin ({n})',
@@ -237,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',
@@ -253,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.',
@@ -300,7 +339,24 @@ 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.",
+ receiveAddressTab: 'Address',
+ receiveSpTab: 'Silent payment',
+ 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.',
+ 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/nostr.js b/src/nostr.js
index 74d8d25..31c9858 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -11,8 +11,72 @@
import * as nip06 from 'nostr-tools/nip06';
import * as nip44 from 'nostr-tools/nip44';
-import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
-import { Relay } from 'nostr-tools/relay';
+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 { 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) {
+ 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 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()));
+}
+
+// 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) {
+ 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; }
+}
+
+// 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) {
+ 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 [];
+}
const DTAG = 'bitcoin-wallet';
const SYNC_KEY = 'btc-wallet-sync';
@@ -46,7 +110,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 = '') {
@@ -55,40 +118,10 @@ export class NostrSync {
this.ck = nip44.getConversationKey(this.sk, this.pk);
}
- unload() {
- this.sk = this.pk = this.ck = null;
- this._closeAll();
- }
+ unload() { this.sk = this.pk = this.ck = null; }
- _closeAll() {
- for (const r of this._conns.values()) {
- try {
- r.close();
- } catch {}
- }
- this._conns.clear();
- }
-
- // 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).
@@ -97,56 +130,36 @@ 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;
+ 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));
+ }
+
+ // 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;
+ 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
}
- await Promise.allSettled(
- this.relays.map(async (url) => {
- const relay = await this._connect(url);
- await relay.publish(evt);
- })
- );
+ const res = await Promise.allSettled(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;
}
}
diff --git a/src/silentpay.js b/src/silentpay.js
index 0700999..eeabc1a 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,20 +6,21 @@
// 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';
+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;
@@ -68,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);
@@ -109,3 +117,225 @@ 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;
+}
+
+// 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) {
+ 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 <x-only>
+ if (spk.length === 34 && spk[0] === 0x51 && spk[1] === 0x20) {
+ 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 = witnessItems(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 = witnessItems(witness);
+ const pk = w[w.length - 1];
+ return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null;
+ }
+ // P2PKH: OP_DUP OP_HASH160 PUSH20 <hash> ... — the eligible pubkey is the
+ // scriptSig push whose hash160 matches <hash> (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);
+}
+
+// ---- 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/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 a92b0ce..bc70561 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -21,10 +21,11 @@ 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 } from './silentpay.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';
// 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;
@@ -38,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 },
@@ -102,6 +108,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
@@ -146,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 || '';
@@ -178,6 +193,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;
@@ -255,6 +272,349 @@ 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;
+ 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,
+ }));
+ }
+
+ // 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) })));
+ }
+
+ // 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(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;
+ }
+ }
+
node(chain, index) {
// Relative derivation from the account node (chain/index are non-hardened).
return this.account().deriveChild(chain).deriveChild(index);
@@ -616,6 +976,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
@@ -769,13 +1130,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() {
@@ -785,13 +1147,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() {
@@ -809,19 +1173,39 @@ 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.');
+ // 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.
+ // 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 = pool_.map((u) => {
- const { script, pubkey } = this.derive(u.chain, u.index);
- const pay = p2wpkh(pubkey, this.netCfg.net);
- return {
- ...pay,
+ 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)));
@@ -852,6 +1236,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);
@@ -875,7 +1260,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.');
@@ -900,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) => {
@@ -930,18 +1325,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;
@@ -1153,6 +1569,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
@@ -1181,6 +1623,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
@@ -1232,6 +1697,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
@@ -1494,7 +1967,11 @@ export class Wallet {
if (Date.now() - (this._lastPoll || 0) < due) return;
this._lastPoll = Date.now();
this.refreshLive();
+ // 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,
@@ -1757,6 +2234,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);
@@ -1816,6 +2294,8 @@ export class Wallet {
nextReceiveIndex: this.nextReceiveIndex,
nextChangeIndex: this.nextChangeIndex,
feeRates: this.feeRates,
+ spUtxos: this.spUtxos,
+ lastSpScan: this.lastSpScan,
};
}
@@ -1828,6 +2308,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 });
@@ -2157,6 +2639,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, 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.
+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;