aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 19:38:30 -0700
committerAdam Soltys <[email protected]>2026-08-01 19:38:30 -0700
commit348a57004f94dd0f58c4e70769e65ca7ea3f7619 (patch)
tree5f8cd2930abd9c1e05bb7c18e0bec239780281ef
parent9196f4804851a87c8356418b1c4cade6e87c54ba (diff)
downloadcoinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.tar.gz
coinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.zip
Simplify: one Lightning path, one balance, fewer features
Dropped: silent payments (feature, wallet chains, scan worker, indexer config), Boltz submarine/reverse swaps (feature + swap.js + tools), the third-party ark swap bridge (config, client flows, settings card — the ASP's own Lightning send is the one path now), and the Consolidate coins settings action. The NWC pouch is gone with them: background answering now spends the WHOLE ark balance. The IndexedDB record mirrors the real manager state plus the chain-3 keys for its live coins and a change-index window; the worker pays through the ASP exactly like the open app, and the app folds the worker's spends, change, and key allocations back in on connect (additive merge + reconcile against the server). A leftover chain-6 pouch from the short-lived pouch era is swept home automatically using the keys stored in its own record. The stated tradeoff: same-origin script access now reaches the ark balance, not a carve-out — the ark balance is walking-around money, the on-chain wallet's keys are never mirrored, and per-connection NWC budgets remain the enforced ceiling. Bundle: 726KB -> 602KB. Co-Authored-By: Claude Fable 5 <[email protected]>
-rw-r--r--HANDOFF.md252
-rw-r--r--build.js22
-rw-r--r--src/api.js102
-rw-r--r--src/app.js29
-rw-r--r--src/ark/manager.js26
-rw-r--r--src/features/ark.js362
-rw-r--r--src/features/index.js7
-rw-r--r--src/features/nwc.js110
-rw-r--r--src/features/sp-wallet.js544
-rw-r--r--src/features/sp.js66
-rw-r--r--src/features/swaps.js239
-rw-r--r--src/i18n.js32
-rw-r--r--src/nwc-bg.js117
-rw-r--r--src/nwc-pouch.js148
-rw-r--r--src/nwc-respond.js167
-rw-r--r--src/sp-worker.js69
-rw-r--r--src/sw-nwc.js9
-rw-r--r--src/swap.js459
-rw-r--r--src/wallet.js15
-rw-r--r--tools/nwc-pouch-test.js51
-rw-r--r--tools/nwc-sw-test.js69
-rw-r--r--tools/sp-indexer.js314
-rw-r--r--tools/sp-test.js143
-rw-r--r--tools/swap-e2e-reverse.test.js78
-rw-r--r--tools/swap-e2e-submarine.test.js56
-rw-r--r--tools/swap-live.test.js57
-rw-r--r--tools/swap-manager.test.js43
27 files changed, 599 insertions, 2987 deletions
diff --git a/HANDOFF.md b/HANDOFF.md
new file mode 100644
index 0000000..2635aeb
--- /dev/null
+++ b/HANDOFF.md
@@ -0,0 +1,252 @@
+# Handoff — Ark / bridge / NWC work
+
+Working note, not project docs. Delete when it's stale.
+Persistent memory for all of this lives in
+`~/.claude/projects/-home-adam-halwallet/memory/` — start by reading
+`MEMORY.md`, then `hal-nwc`, `coinos-ark-mainnet`, `ark-swap-bridge`,
+`coinos-ark-asp`, `ark-third-party-htlc`.
+
+---
+
+## 1. ~~THE ONE UNRESOLVED BUG~~ — **RESOLVED 2026-08-01**
+
+**Root cause:** nostr-tools ≥2.23 changed `SimplePool.subscribeMany` to take a
+SINGLE filter object instead of an array. Our `[filter]` wrapping serialized
+as `["REQ",id,[{...}]]` — invalid NIP-01 — and every relay answered
+`["CLOSED","sub:N","ERROR: bad req: provided filter is not an object"]`, then
+silently dropped the sub. Neither guard nor decrypt was ever reached; there
+was simply no live subscription. The CDP probe's own harness had the same bug,
+which is why it "received frames" but could never observe a 23195.
+
+Fixed in `src/nostr.js` (`subscribeOn` + `NostrSync.subscribeEvents` — device
+sync's live subscription was broken too) and `nwcpush/server.js` (deployed to
+cs, restarted, resubscribed OK). Verified end-to-end: a real nip04
+`get_balance` published from the stored connection's client key got back
+`{"result_type":"get_balance","result":{"balance":20000000}}` over public
+relays. Instrumentation (guard logging + sub lifecycle gens) is still in
+`nwc.js` — strip once Amethyst is confirmed.
+
+**Remaining human step:** paste the existing connection string into Amethyst
+again — it should now show a balance.
+
+**LAYER 3 (2026-08-01 evening — the zap):** NWC now round-trips; the zap
+`pay_invoice` failed for stacked reasons, all found: (a) the swap bridge
+defaults OFF in hal (`BRIDGE_DEFAULT.mainnet='off'`) so pays went to the ASP,
+whose CLN routing budget = 90% of the ASP's 1-sat min fee = 0 sats → unroutable;
+(b) once enabled, the bridge needed its bearer token (cs
+`~/arkswap/config-main.json` `tokens[0]`, now set in the laptop wallet) and
+(c) its state dir `cs:~/arkswap/data-main` had been deleted → dangling mount,
+500s (recreated + container restarted); (d) with all that fixed the 21-sat zap
+STILL fails: the recipient is a private node behind Megalithic.me and is
+offline — even manual `xpay` with 10-sat maxfee dies on
+temporary_channel_failure everywhere. Not our infra. Refund machinery worked.
+ALSO fixed: pool ping+reconnect (silent socket death used to kill sync + NWC
+subs until reload — that's why a phone-created connection stayed invisible to
+the open laptop tab). RESOLVED SAME EVENING (Adam approved): ASP
+min_fee_sat 1→3 (captaind-main restarted, healthy); bridge default ON for
+mainnet (commit 0344733) and bridge opened — tokens cleared in
+`cs:~/arkswap/config-main.json`. Still open: phone PWA needs two loads to
+get new builds.
+
+**LAYER 2 (found after the filter fix — Amethyst still spun):**
+relay.coinos.io's `write-policy.pl` (strfry `sf` on desk,
+`~/coinos-server/data/strfry/`, gitignored) restricted kinds 13194/23195 to
+coinos server pubkeys, so every reply hal published there was rejected — and
+Amethyst listens for replies ONLY on the first URI relay, which is
+relay.coinos.io. The rejections also strike-counted the laptop IP into a
+temp-ban. Fixed 2026-08-01: NWC kinds opened to all pubkeys on the relay
+(rate limits intact), `publishOn` now logs refused publishes, 13194 goes out
+once per session per connection. Amethyst-shaped probe (coinos-only, #e+#p
+filter) round-trips get_balance green on the deployed build.
+
+<details><summary>Original investigation notes (superseded)</summary>
+
+**Symptom:** pasting a hal NWC connection string into Amethyst shows a
+spinner forever. No balance, no error.
+
+**This is narrowed, not mysterious. Do not re-derive the following — all of
+it was verified against the live laptop tab over Chrome DevTools Protocol:**
+
+- hal DOES subscribe correctly, on all three NWC relays. Captured from its own
+ socket:
+ `["REQ","sub:1",[{"kinds":[23194],"#p":["<servicePk>"],"since":<now-120>}]]`
+- The stored connection's keypairs are self-consistent:
+ `getPublicKey(secret) === clientPk` and `getPublicKey(serviceSk) === servicePk`.
+ So the string Amethyst holds is valid.
+- hal's sockets **did receive 23194 frames** (6 — one per relay) in one run.
+- No 23195 reply is ever produced.
+- The decrypt path is now instrumented (logs + Settings card + replies with an
+ INTERNAL error in both schemes). A fresh probe produced **no log, no UI
+ error, no reply** — so execution isn't reaching decrypt.
+
+**Therefore the drop is in one of the silent guards before decrypt**, in
+`onRequest` in `src/features/nwc.js`:
+
+```js
+if (handled.has(ev.id)) return; // replay set
+if (nowSec() - ev.created_at > MAX_AGE_SEC) return; // 120s age check
+if (ev.pubkey !== c.clientPk) return; // wrong client
+```
+
+…or `onRequest` isn't being invoked at all.
+
+**Second suspect, untested:** `subscribeOn` reuses the shared `pool` in
+`src/nostr.js`, and `listen()` is called from three places — feature `init()`,
+`createConn()`, and the sync-snapshot `load()`. Each call does `stop()` then
+re-subscribes. If a later `listen()` closes the subscription whose callback
+would have fired, frames keep arriving on the socket with nothing handling
+them. That fits every observation.
+
+**Next step (one cycle should name it):** log at the TOP of `onRequest` —
+every event id seen and which guard returns — plus subscription lifecycle
+(each `listen()`/`stop()` and how many subs are live). Then re-run the probe
+below.
+
+### The probe (copy-paste, needs Chrome with `--remote-debugging-port=9222` and a hal tab open)
+
+```js
+// bun run this from ~/halwallet
+import puppeteer from 'puppeteer-core';
+import { SimplePool } from 'nostr-tools/pool';
+import { nip04, finalizeEvent } from './src/nostr.js';
+import { hex } from '@scure/base';
+
+const b = await puppeteer.connect({ browserURL: 'http://127.0.0.1:9222', defaultViewport: null });
+const p = (await b.pages()).find(x => x.url().includes('halwallet.app'));
+p.on('console', (m) => { const t = m.text(); if (/nwc/i.test(t)) console.log('PAGE:', t.slice(0, 240)); });
+
+// watch hal's own relay traffic
+const cdp = await p.createCDPSession();
+await cdp.send('Network.enable');
+let got23194 = 0;
+cdp.on('Network.webSocketFrameSent', (e) => {
+ if (e.response.payloadData.startsWith('["REQ"')) console.log('SUB:', e.response.payloadData.slice(0, 200));
+});
+cdp.on('Network.webSocketFrameReceived', (e) => { if (e.response.payloadData.includes('23194')) got23194++; });
+
+await p.reload({ waitUntil: 'networkidle2' });
+await new Promise(r => setTimeout(r, 8000));
+
+const c = await p.evaluate(() => {
+ const k = Object.keys(localStorage).find(x => x.endsWith(':nwc'));
+ return (JSON.parse(localStorage.getItem(k)).conns || []).filter(y => !y.revoked)[0];
+});
+
+const RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol'];
+const pool = new SimplePool();
+const cSk = hex.decode(c.secret);
+const now = Math.floor(Date.now() / 1000);
+let reply = null;
+const sub = pool.subscribeMany(RELAYS, [{ kinds: [23195], '#p': [c.clientPk], since: now - 10 }], {
+ onevent: async (ev) => { if (!reply) reply = await nip04.decrypt(cSk, c.servicePk, ev.content).catch(e => '(undecryptable: ' + e.message + ')'); },
+});
+await new Promise(r => setTimeout(r, 2500));
+const req = finalizeEvent({ kind: 23194, created_at: now, tags: [['p', c.servicePk]],
+ content: await nip04.encrypt(cSk, c.servicePk, JSON.stringify({ method: 'get_balance', params: {} })) }, cSk);
+await Promise.allSettled(pool.publish(RELAYS, req));
+for (let i = 0; i < 40 && !reply; i++) await new Promise(r => setTimeout(r, 500));
+console.log('hal received 23194 frames:', got23194);
+console.log('reply:', reply || 'NONE');
+sub.close(); pool.close(RELAYS); await b.disconnect();
+```
+
+Also worth trying, as a cheap independent signal: is it Amethyst-specific?
+Test the same connection string with Alby Go or a NIP-47 CLI. If those work,
+the bug is in how Amethyst reads the URI, not in hal's handler.
+
+</details>
+
+---
+
+## 2. WHAT IS LIVE
+
+| Thing | Where | Notes |
+|---|---|---|
+| Mainnet ASP | `https://ark.coinos.io` | patched captaind, 5M sat funded, 500k vtxo cap, 1h rounds, CLN attached |
+| Mutinynet ASP | `https://ark-staging.coinos.io` | 30s rounds, free boards |
+| Mainnet bridge | `https://arkswap.coinos.io` | max(2 sat, 2000ppm), shares boltz's CLN |
+| Mutinynet bridge | `https://arkswap-staging.coinos.io` | max(1 sat, 1000ppm) |
+| NWC push notifier | `https://nwcpush.coinos.io` | `/health`, `/vapid`, `POST|DELETE /register` |
+
+Containers on **cs**: `captaind-main`, `ark-pg-main`, `captaind-mut`,
+`ark-pg-mut`, `arkswap-main`, `arkswap-mut`, `nwcpush`.
+Configs in `~/ark/` and `~/arkswap/`. Backups: `~/backup-ark.sh`, hourly at
+:17, mirrored to `desk:/home/adam/backups/ark-cs/`, restore-tested.
+
+**Machines:** `lap` = this laptop (its `cl`/`clb`/`clc`/`bc` containers are a
+**regtest** dev stack, despite the "coinos" alias). `desk` = prod box
+(mainnet bitcoind :8332, mainnet CLN `cl`, `~/scripts/solvency-monitor.ts`).
+`cs` = coinos server (everything above + cloudflared tunnels).
+
+---
+
+## 3. LANDMINES THAT COST TIME
+
+1. **build.js has its own feature registry.** It swaps
+ `src/features/index.js` for a generated module from `ALL_FEATURES`. Adding
+ a feature to index.js alone ships its i18n strings but NOT its code, so it
+ looks deployed and silently does nothing. Guard:
+ `bun tools/feature-registry-check.js`. **Grep the built bundle for feature
+ code, not just a green build.**
+2. **Cloudflare challenges CORS preflights.** Any browser POST with
+ `content-type: application/json` preflights, and the managed WAF 403s the
+ OPTIONS → the browser reports a bare "Failed to fetch". Bit us three times.
+ One skip rule covers it: **`d3ee4d654c1b4e3eb646e14bfe8d76d1`** in ruleset
+ `48c017dca49b40ea897cd2ed5185fa11`, zone `cc9b01de29250639d6b0b733e19cefda`.
+ **Any new coinos hostname a browser POSTs to must be added.** curl will not
+ reproduce it — curl doesn't preflight. Creds: `~/.cf_global_key`
+ (line 1 email, line 2 global key; use `X-Auth-Email` + `X-Auth-Key`).
+3. **Android Chrome freezes backgrounded PWAs** and closes their sockets. A
+ phone with hal "open" in the background answers nothing. Desktop keeps
+ background sockets alive; mobile does not.
+4. **captaind + CLN TLS:** address it as `https://cln:9736` / `https://hold:9737`,
+ never `bcln` — the certs' SANs are `cln`/`hold`/`localhost`, and a mismatch
+ shows only as an endless "Trying to connect to offline node".
+5. **Board outputs must be vout 0.** `bitcoin-cli sendtoaddress` randomizes
+ output order; use `~/ark/fund-board.sh` (pins change to position 1).
+6. **Service-worker staleness:** the PWA often needs two loads to pick up a
+ deploy. When verifying in a live tab, check `caches.keys()` matches the
+ deployed `cold-*` hash before concluding anything.
+
+---
+
+## 4. OTHER OPEN WORK
+
+- **NWC step 3, remaining half:** the SW currently only *wakes* the user
+ (notification → tap → hal answers). Auto-answering needs the Ark spend stack
+ (musig2, arkoor build, gRPC-web) bundled into the service worker — a second
+ build entry point — plus the pouch funding UI. `src/nwc-pouch.js` (chain-6
+ keys in IndexedDB, seed stays in localStorage) is built and tested; nothing
+ funds it yet.
+- **Upstream:** issue filed at
+ `https://gitlab.com/ark-bitcoin/bark/-/work_items/1388`, no reply yet. Draft
+ MR sits in the fork at `asoltys/bark!1`, branch `third-party-htlc`, rebased
+ and green. Once the live demo works, adding it to the issue would strengthen
+ the case. Personal GitLab token: `~/.gitlab_pat`.
+- **Monitoring:** nothing watches the mainnet ASP's rounds wallet or bcln's
+ outbound. Was going to the agent on desk (`~/scripts/solvency-monitor.ts`).
+ Suggested alerts: rounds wallet under ~1M of 5M; bcln outbound low.
+- **bcln is shared** by boltz, the ark bridge, and now the ASP. A dedicated
+ CLN is the clean fix if volume grows.
+- **Liquidity model** (verified in bark's source): ASP working capital ≈ total
+ user balance under management, locked ~30 days per cycle. Boards and arkoor
+ sends cost the ASP nothing; rounds and offboards front the full value.
+ 5M sat comfortably supports ~3–4M of user balances.
+
+---
+
+## 5. TEST COMMANDS
+
+```
+bun tools/feature-registry-check.js # features ship (membership + order)
+bun tools/nwc-test.js # NWC protocol, budgets, sync merge
+bun tools/nwc-pouch-test.js # pouch key isolation
+bun tools/ark/manager-test.js # ark regression (needs regtest stack)
+bun tools/ark/ln-e2e-test.js # ark<->LN over the ASP
+bun tools/ark/bridge-e2e-test.js # trustless bridge, regtest
+bun tools/ark/htlc-bridge-test.js # third-party HTLC + attack rejections
+bun tools/ark/live-bridge-demo.js # live mutinynet demo (public infra)
+```
+
+Regtest stack: `docker start ark-postgres ark-electrs captaind cln-ark`
+(plain `docker start`, not compose — those services aren't in the compose file).
diff --git a/build.js b/build.js
index f542533..710aa35 100644
--- a/build.js
+++ b/build.js
@@ -178,21 +178,6 @@ export async function buildSwNwc({ minify = true } = {}) {
return result.outputs[0].text();
}
-// The silent-payment scanning worker, bundled separately (its own global scope)
-// and embedded as base64 so the single-file app can spawn it from a Blob URL.
-export async function buildSpWorker({ minify = true } = {}) {
- const result = await Bun.build({
- entrypoints: ['./src/sp-worker.js'],
- target: 'browser',
- minify,
- });
- if (!result.success) {
- for (const log of result.logs) console.error(log);
- throw new Error('sp-worker bundle failed');
- }
- return result.outputs[0].text();
-}
-
// Optional-feature selection: HAL_FEATURES is a comma list of enabled
// features (gifts,swaps,ark,zaps,sp,nwc). Unset means all; "none"/"" means a minimal
// on-chain-only wallet. A build plugin swaps src/features/index.js for a
@@ -200,7 +185,7 @@ export async function buildSpWorker({ minify = true } = {}) {
// feature's code (and its network endpoints) never enters the bundle.
// NB order matters and must match src/features/index.js — nwc sits after ark
// because it drives ark's headless pay/balance seam.
-const ALL_FEATURES = { gifts: 'giftsFeature', swaps: 'swapsFeature', ark: 'arkFeature', zaps: 'zapsFeature', sp: 'spFeature', nwc: 'nwcFeature', sync: 'syncFeature' };
+const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', sync: 'syncFeature' };
export function enabledFeatures(spec = process.env.HAL_FEATURES) {
if (spec == null) return Object.keys(ALL_FEATURES);
@@ -236,10 +221,6 @@ export async function buildHtml({ minify = true, pwa = minify, features = proces
let js = await result.outputs[0].text();
// Guard against a literal </script> inside the bundle closing our tag early.
js = js.replaceAll('</script', '<\\/script');
- // The silent-payment scan worker only ships when the sp feature does.
- const workerB64 = enabledFeatures(features).includes('sp')
- ? Buffer.from(await buildSpWorker({ minify })).toString('base64')
- : '';
const css = await Bun.file('./src/style.css').text();
return `<!doctype html>
@@ -255,7 +236,6 @@ ${pwa ? PWA_HEAD : ''}<style>${css}</style>
</head>
<body>
<div id="app"></div>
-${workerB64 ? `<script>globalThis.__SP_WORKER__=${JSON.stringify(workerB64)}</script>` : ''}
<script>${js}</script>
${pwa ? SW_REGISTER + '\n' : ''}</body>
</html>`;
diff --git a/src/api.js b/src/api.js
index 8fefa5f..5824120 100644
--- a/src/api.js
+++ b/src/api.js
@@ -273,108 +273,6 @@ export function getArkConfig(net = getNetwork()) {
return cfg.ark && cfg.esplora ? cfg : null;
}
-// Ark->Lightning swap bridge — an optional third-party provider that pays
-// bolt11 invoices out of your Ark balance for less than the ASP charges. The
-// swap is trustless (funds are locked in an HTLC only the bridge can claim,
-// and only by revealing the invoice preimage), but it requires an ASP that
-// supports third-party HTLC vtxos. Off means bolt11 payments use the ASP's
-// own — pricier — lightning-send path.
-const BRIDGE_PRESETS_BY_NET = {
- mainnet: [
- { id: 'off', label: 'Off (use the ASP)', url: '' },
- { id: 'coinos', label: 'coinos (arkswap.coinos.io)', url: 'https://arkswap.coinos.io' },
- { id: 'custom', label: 'Custom…', url: '' },
- ],
- testnet: [{ id: 'off', label: 'Off (use the ASP)', url: '' }, { id: 'custom', label: 'Custom…', url: '' }],
- signet: [{ id: 'off', label: 'Off (use the ASP)', url: '' }, { id: 'custom', label: 'Custom…', url: '' }],
- mutinynet: [
- { id: 'off', label: 'Off (use the ASP)', url: '' },
- { id: 'coinos', label: 'coinos (arkswap-staging.coinos.io)', url: 'https://arkswap-staging.coinos.io' },
- { id: 'custom', label: 'Custom…', url: '' },
- ],
- regtest: [
- { id: 'off', label: 'Off (use the ASP)', url: '' },
- { id: 'local', label: 'Local (regtest)', url: 'http://localhost:8790' },
- { id: 'custom', label: 'Custom…', url: '' },
- ],
-};
-// Mainnet defaults to the coinos bridge: small bolt11 sends (zaps) are
-// unroutable through the ASP path, whose CLN fee budget is a fraction of the
-// ASP's own 3-sat minimum fee. The bridge runs open (no token) for this.
-const BRIDGE_DEFAULT = { mainnet: 'coinos', testnet: 'off', signet: 'off', mutinynet: 'off', regtest: 'off' };
-const BRIDGE_KEY = 'btc-wallet-ark-bridge'; // selected preset id, per network
-const BRIDGE_CUSTOM_KEY = 'btc-wallet-ark-bridge-custom'; // custom url, per network
-const BRIDGE_TOKEN_KEY = 'btc-wallet-ark-bridge-token'; // access token, per network
-
-export function bridgePresets(net = getNetwork()) {
- return BRIDGE_PRESETS_BY_NET[net] || BRIDGE_PRESETS_BY_NET.mainnet;
-}
-export function getBridgeProviderId(net = getNetwork()) {
- try {
- const id = localStorage.getItem(nk(BRIDGE_KEY, net));
- if (id && bridgePresets(net).some((p) => p.id === id)) return id;
- } catch {}
- return BRIDGE_DEFAULT[net] || 'off';
-}
-export function setBridgeProviderId(id, net = getNetwork()) {
- try { localStorage.setItem(nk(BRIDGE_KEY, net), id); } catch {}
-}
-export function getBridgeCustom(net = getNetwork()) {
- try { return localStorage.getItem(nk(BRIDGE_CUSTOM_KEY, net)) || ''; } catch { return ''; }
-}
-export function setBridgeCustom(url, net = getNetwork()) {
- try { localStorage.setItem(nk(BRIDGE_CUSTOM_KEY, net), (url || '').trim()); } catch {}
-}
-export function getBridgeToken(net = getNetwork()) {
- try { return localStorage.getItem(nk(BRIDGE_TOKEN_KEY, net)) || ''; } catch { return ''; }
-}
-export function setBridgeToken(tok, net = getNetwork()) {
- try { localStorage.setItem(nk(BRIDGE_TOKEN_KEY, net), (tok || '').trim()); } catch {}
-}
-// { url, token } when a bridge is configured, else null.
-export function getBridgeConfig(net = getNetwork()) {
- const id = getBridgeProviderId(net);
- if (id === 'off') return null;
- const p = bridgePresets(net).find((x) => x.id === id);
- const url = id === 'custom' ? getBridgeCustom(net) : p.url;
- return url ? { url: url.replace(/\/$/, ''), token: getBridgeToken(net) } : null;
-}
-
-// 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: '' }],
- signet: [{ id: 'custom', label: 'Custom', url: '' }],
- mutinynet: [{ id: 'custom', label: 'Custom', url: '' }],
- regtest: [{ id: 'local', label: 'Local (proxied)', url: '/sp' }, { id: 'custom', label: 'Custom', url: '' }],
-};
-const SP_INDEXER_DEFAULT = { mainnet: 'coinos', testnet: 'custom', signet: '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 src = getSource(net);
diff --git a/src/app.js b/src/app.js
index 8eda874..d5f277b 100644
--- a/src/app.js
+++ b/src/app.js
@@ -82,7 +82,6 @@ const ui = {
giftDmStatus: null, // 'sending' | 'sent' | 'failed' — nostr DM of the claim code
claimLocked: null, // a locked gift being opened: { v, amount, to, ct }
claimCodeInput: '', // the claim code the recipient pastes from their nostr DM
- consolidateError: null, // error from a coin-consolidation attempt
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
@@ -1476,38 +1475,12 @@ function settingsTab() {
),
networkCard(),
...featureAll('settingsCards'),
- consolidateCard(),
explorerCard(),
null
);
}
-// 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();
-}
+
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 2edcd8a..5a797e7 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -500,32 +500,6 @@ export class ArkManager {
return action.id;
}
- // A send whose recipient is one of the USER's own foreign-branch keys (the
- // NWC pouch): no address, no mailbox delivery — the caller receives the
- // signed recipient vtxo bytes directly and stores them wherever the branch
- // lives. Same cosign machinery as send(), so all its failure handling holds.
- async sendToPubkey(destPubkeyHex, amountSat) {
- const input = this._selectInput(amountSat);
- const changeSat = input.amountSat - amountSat;
- const action = {
- id: `send-${Date.now()}`, type: 'send', step: 'created',
- inputId: input.id, amountSat, destAddress: null,
- destPubkey: destPubkeyHex, destBlindedId: null,
- changeIndex: changeSat > 0 ? this.state.nextKeyIndex++ : null, changeSat,
- };
- input.state = 'pending';
- this.state.actions.push(action);
- this._save();
- await this._driveSend(action);
- if (action.step !== 'done') throw new Error(action.error || 'send did not complete');
- return {
- vtxos: (action.destBytesList || []).map((b) => {
- const v = decodeVtxo(hex.decode(b));
- return { id: v.id, bytes: b, amountSat: v.amountSat, expiryHeight: v.expiryHeight };
- }),
- };
- }
-
_sendOutputs(action) {
const outputs = [{ amountSat: action.amountSat, userPubkey: hex.decode(action.destPubkey) }];
if (action.changeSat > 0) {
diff --git a/src/features/ark.js b/src/features/ark.js
index c163583..f84857d 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -7,7 +7,7 @@ import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import { ArkManager } from '../ark/manager.js';
-import { pouchKey, loadPouch, savePouch, POUCH_CHANGE_BASE } from '../nwc-pouch.js';
+import { loadBg, saveBg, buildBg } from '../nwc-bg.js';
import { boardFee } from '../ark/board.js';
import { maybeBolt11, lnSendFee } from '../ark/lightning.js';
import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js';
@@ -16,8 +16,6 @@ import { utxoId } from '../wallet.js';
import {
getNetwork, setNetwork, arkPresets, getArkProviderId, setArkProviderId,
getArkCustom, setArkCustom, getArkConfig,
- bridgePresets, getBridgeProviderId, setBridgeProviderId, getBridgeCustom,
- setBridgeCustom, getBridgeToken, setBridgeToken, getBridgeConfig,
} from '../api.js';
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
@@ -333,7 +331,7 @@ export function arkFeature(ctx) {
// against the server, so a stale spendable is caught here rather than
// only at send time.
mgr.reconcile().catch(() => {});
- resumeBridgeSwaps(mgr).catch(() => {}); // refund any HTLC a bridge left hanging
+ mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed
// Receives arrive in real time over the mailbox stream; the poll is the
// fallback and what drives in-flight boards/refreshes forward.
mgr.startMailboxStream();
@@ -452,128 +450,77 @@ export function arkFeature(ctx) {
}, 2500));
}
- // ---- third-party swap bridge (cheaper bolt11 sends) ---------------------
- // When a bridge is configured we lock the amount in an HTLC only it can
- // claim — and only by revealing the invoice preimage. Trustless: if it
- // never pays we get a cosigned refund, or exit through the refund leaf.
- // Requires an ASP supporting HTLC vtxos, so any failure falls back to the
- // ASP's own (pricier) lightning-send.
- async function bridgeQuote(invoice) {
- const cfg = getBridgeConfig();
- if (!cfg) return null;
- const r = await fetch(`${cfg.url}/quote?invoice=${encodeURIComponent(invoice)}`,
- { headers: cfg.token ? { authorization: `Bearer ${cfg.token}` } : {} });
- const q = await r.json();
- if (!r.ok || q.error) throw new Error(q.error || `bridge quote failed (${r.status})`);
- return { ...q, bridgeUrl: cfg.url, token: cfg.token };
- }
-
- // Lock the HTLC, hand it to the bridge, and record the swap so a crash or
- // a failed payment can still be refunded later.
- async function bridgePay(quote, invoice) {
- const mgr = await connectArk();
- const lock = await mgr.htlcLock({
- amountSat: quote.totalSat,
- claimPubkey: quote.claimPubkey,
- paymentHash: quote.paymentHash,
- htlcExpiry: quote.htlcExpiry,
+ // ---- background NWC mirror ---------------------------------------------
+ // The service worker answers wallet-connect requests while the app is
+ // closed, spending the SAME ark balance the app does. The mirror is the
+ // full manager state plus the chain-3 keys for its live coins and a window
+ // of upcoming change indices (the worker cannot derive). nwc.js decides
+ // WHEN to mirror (background answering on) and supplies the connections.
+ async function writeBgMirror(connections) {
+ const cfg = getArkConfig();
+ if (!cfg || !ark || !ark.info || !ark.state) return;
+ const prev = await loadBg(wallet._cacheKey());
+ const rec = buildBg({
+ ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey },
+ mgrState: ark.state,
+ keyFor: (i) => hex.encode(ark._key(i).privkey),
+ connections,
});
- // persist before handing it over: the HTLC exists on the server now
- const swaps = (mgr.state.bridgeSwaps = mgr.state.bridgeSwaps || []);
- const rec = {
- paymentHash: quote.paymentHash, invoice, bridgeUrl: quote.bridgeUrl,
- amountSat: quote.amountSat, feeSat: quote.feeSat,
- htlcVtxo: lock.htlcVtxos[0], refundIndex: lock.refundIndex,
- htlcExpiry: quote.htlcExpiry, step: 'submitted', ts: Date.now(),
- };
- swaps.push(rec);
- mgr._save();
+ if (prev) rec.spends = prev.spends; // the worker's log survives rewrites
+ await saveBg(wallet._cacheKey(), rec);
+ }
- let res;
- try {
- const r = await fetch(`${quote.bridgeUrl}/swap`, {
- method: 'POST',
- headers: {
- 'content-type': 'application/json',
- ...(quote.token ? { authorization: `Bearer ${quote.token}` } : {}),
- },
- body: JSON.stringify({ invoice, htlcVtxo: lock.htlcVtxos[0] }),
- });
- res = await r.json();
- if (!r.ok || res.error) throw new Error(res.error || `bridge error (${r.status})`);
- } catch (e) {
- rec.step = 'failed';
- rec.error = e.message;
- mgr._save();
- await tryBridgeRefund(rec).catch(() => {});
- throw e;
- }
- if (res.step !== 'done') {
- rec.step = 'unpaid';
- mgr._save();
- await tryBridgeRefund(rec).catch(() => {});
- throw new Error(res.error || t('arkLnRefunded'));
+ // On connect, fold whatever the worker did while we were closed back into
+ // the live state: its spent flips and change vtxos merge in (the additive
+ // merge never resurrects anything reconcile() disproves), its key
+ // allocations advance ours, and its in-flight ln actions become resumable.
+ async function mergeBgWorkerState(mgr) {
+ const rec = await loadBg(wallet._cacheKey());
+ if (!rec) return;
+ if (rec.v < 3) {
+ await sweepLegacyPouch(rec).catch((e) => console.warn('ark: pouch sweep failed', e.message));
+ return;
}
- // proof of payment: the preimage must hash to the invoice's payment hash
- if (!res.preimage || hex.encode(sha256(hex.decode(res.preimage))) !== quote.paymentHash) {
- rec.step = 'suspect';
- mgr._save();
- throw new Error('bridge returned an invalid preimage');
+ if (!rec.mgr) return;
+ const merged = mergeArkStates(mgr.state, rec.mgr);
+ // the worker's view of OUR vtxos is newer for anything it spent
+ const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state]));
+ for (const v of merged.vtxos) {
+ if (workerState.get(v.id) === 'spent') v.state = 'spent';
}
- rec.step = 'done';
- rec.preimage = res.preimage;
+ merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1);
+ mgr.state = merged;
mgr._save();
- mgr._movement({
- type: 'ln-send', amountSat: quote.amountSat, status: 'complete',
- detail: `via bridge · fee ${quote.feeSat} sat`, invoice, preimage: res.preimage,
- });
- mgr._save();
- return res;
+ await mgr.reconcile().catch(() => {});
+ await mgr.resumePending().catch(() => {});
}
- // An ephemeral manager over the POUCH's chain-6 keys — used to empty the
- // pouch and to refund worker-initiated swaps (their refund pubkeys are
- // pouch keys, which the main manager cannot sign for). State persists back
- // into the pouch record so the service worker sees the same coin set.
- async function pouchManager(pouch) {
+ // A leftover chain-6 pouch from before whole-balance background spending:
+ // sweep its coins home using the keys stored in the record itself, then
+ // let the next mirror write replace it.
+ async function sweepLegacyPouch(rec) {
+ if (!(rec.vtxos || []).some((v) => (v.state || 'spendable') === 'spendable')) return;
const cfg = getArkConfig();
- const account6 = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(pouch.keys[String(i)] || pouch.keys['0']) }) }) };
- const storage = {
- load: () => pouch.mgr || {
- v: 1, serverPubkey: pouch.ark.serverPubkey || null, mailboxCheckpoint: 0,
- nextKeyIndex: pouch.nextKeyIndex || POUCH_CHANGE_BASE,
- vtxos: (pouch.vtxos || []).map((v) => ({ ...v, state: v.state || 'spendable' })),
- actions: [], movements: [],
- },
- save: (s) => { pouch.mgr = s; savePouch(wallet._cacheKey(), pouch).catch(() => {}); },
+ const mgr = await connectArk();
+ const shim = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(rec.keys[String(i)] || rec.keys['0']) }) }) };
+ let state = {
+ v: 1, serverPubkey: rec.ark?.serverPubkey || null, mailboxCheckpoint: 0,
+ nextKeyIndex: rec.nextKeyIndex || 20,
+ vtxos: (rec.mgr?.vtxos || rec.vtxos || []).map((v) => ({ ...v, state: v.state || 'spendable' })),
+ actions: [], movements: [],
};
- return new ArkManager({
- account: account6, storage,
+ const pm = await new ArkManager({
+ account: shim,
+ storage: { load: () => state, save: (s2) => { state = s2; } },
arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(),
}).init();
- }
-
- // Ask the bridge to hand an unpaid HTLC back, and take ownership of it.
- async function tryBridgeRefund(rec) {
- const mgr = await connectArk();
- const r = await fetch(`${rec.bridgeUrl}/refund`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ paymentHash: rec.paymentHash }),
- });
- const out = await r.json();
- if (!r.ok || out.error) throw new Error(out.error || 'refund failed');
- const got = await mgr.acceptHtlcRefund({
- vtxoBytesList: out.refundVtxos, refundIndex: rec.refundIndex,
- });
- rec.step = 'refunded';
- mgr._save();
- return got;
- }
-
- // Retry refunds for any bridge swap left hanging (called on connect).
- async function resumeBridgeSwaps(mgr) {
- const open = (mgr.state.bridgeSwaps || []).filter((r) => ['unpaid', 'failed'].includes(r.step));
- for (const rec of open) await tryBridgeRefund(rec).catch(() => {});
+ const dest = mgr.address();
+ for (let guard = 0; guard < 30; guard++) {
+ const v = (pm.state.vtxos || []).find((x) => x.state === 'spendable');
+ if (!v) break;
+ await pm.send(dest, v.amountSat);
+ }
+ await saveBg(wallet._cacheKey(), { ...rec, vtxos: [], mgr: state, sweptAt: Date.now() });
}
// Pay a bolt11 with no UI at all — used by the NWC wallet service. Same
@@ -593,18 +540,6 @@ export function arkFeature(ctx) {
// payment picks the stale coin and the ASP rejects it ("state: spent").
await mgr.reconcile().catch(() => {});
- // 1. the bridge, when one is configured and can take it
- try {
- const q = await bridgeQuote(invoice);
- if (q && mgr.vtxos().some((v) => v.state === 'spendable' && v.amountSat >= q.totalSat)) {
- const res = await bridgePay(q, invoice);
- return { preimage: res.preimage, feeSat: q.feeSat, amountSat: q.amountSat };
- }
- } catch (e) {
- console.warn('nwc: bridge pay failed, falling back to the ASP:', e.message);
- }
-
- // 2. the ASP's native lightning-send
const id = await mgr.payLnInvoice(invoice);
for (let i = 0; i < 60; i++) {
const a = mgr.lnAction(id);
@@ -648,23 +583,6 @@ export function arkFeature(ctx) {
const p = ui.arkLnPay;
if (!p || p.invoice !== invoice) return;
if (!p.amountSat) { p.status = 'ready'; render(); return; }
- // A configured bridge is cheaper than the ASP; quote it first and fall
- // back silently if it's unreachable or won't take the swap.
- try {
- const q = await bridgeQuote(invoice);
- if (q && ui.arkLnPay === p) {
- const covers = mgr.vtxos().some((v) => v.state === 'spendable' && v.amountSat >= q.totalSat);
- if (covers) {
- p.bridge = q;
- p.feeSat = q.feeSat;
- p.status = 'ready';
- render();
- return;
- }
- }
- } catch (e) {
- console.warn('bridge quote failed, using the ASP:', e.message);
- }
const tip = await mgr.chain.tipHeight();
const candidates = mgr.vtxos().filter((v) => v.state === 'spendable')
.sort((a, b) => a.amountSat - b.amountSat);
@@ -684,9 +602,9 @@ export function arkFeature(ctx) {
}, 4000);
return;
}
- // can't cover once fees are in — hand off to the Boltz path
+ // can't cover once fees are in
ui.arkLnPay = null;
- if (!ctx.hook('startLnPayBoltz', invoice, meta)) { ui.sendError = t('arkNotConnected'); }
+ ui.sendError = t('arkLnPayFailed');
render();
return;
}
@@ -702,21 +620,6 @@ export function arkFeature(ctx) {
ui.busy = true; ui.sendError = ''; render();
try {
const mgr = await connectArk();
- if (p.bridge) {
- p.status = 'paying'; render();
- try {
- await bridgePay(p.bridge, p.invoice);
- ui.arkLnPaid = { amountSat: p.amountSat, meta: p.meta };
- ui.arkLnPay = null;
- ui.busy = false; render();
- return;
- } catch (e) {
- // the bridge couldn't do it (and has refunded us) — fall back to
- // the ASP rather than dead-ending the payment
- console.warn('bridge swap failed, falling back to the ASP:', e.message);
- p.bridge = null; p.status = 'ready';
- }
- }
const id = await mgr.payLnInvoice(p.invoice, { amountSat: sats });
const settle = (a) => {
if (a.step === 'done') {
@@ -777,7 +680,7 @@ export function arkFeature(ctx) {
h('div', { style: 'display:flex;align-items:center' }, unitTag()))
: null,
p.amountSat != null ? row(t('lnPayAmount'), p.amountSat) : null,
- p.feeSat != null && p.feeSat > 0 ? row(p.bridge ? t('arkLnBridgeFee') : t('arkLnFee'), p.feeSat) : null,
+ p.feeSat != null && p.feeSat > 0 ? row(t('arkLnFee'), p.feeSat) : null,
p.amountSat != null && p.feeSat != null
? [h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), row(t('lnPayTotal'), total, true)]
: null),
@@ -985,41 +888,6 @@ export function arkFeature(ctx) {
);
}
- // Swap-bridge settings: an optional cheaper route for bolt11 sends.
- function bridgeCard() {
- const net = getNetwork();
- const id = getBridgeProviderId(net);
- const presets = bridgePresets(net);
- if (presets.length <= 1 || !arkAvailable()) return null;
- return h('div', { class: 'card col' },
- h('h3', { class: 'row gap6', style: 'align-items:center' }, '⚡', t('arkBridgeTitle')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('arkBridgeDesc')),
- h('select', {
- onChange: (e) => { setBridgeProviderId(e.target.value, net); render(); },
- }, presets.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))),
- id === 'custom'
- ? h('label', { class: 'field' },
- h('span', { class: 'lab' }, t('arkBridgeUrl')),
- h('input', {
- type: 'text', class: 'mono-input', placeholder: 'https://swap.example.com',
- autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
- value: getBridgeCustom(net),
- onChange: (e) => { setBridgeCustom(e.target.value, net); render(); },
- }))
- : null,
- id !== 'off'
- ? h('label', { class: 'field' },
- h('span', { class: 'lab' }, t('arkBridgeToken')),
- h('input', {
- type: 'password', class: 'mono-input', placeholder: t('arkBridgeTokenPh'),
- autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
- value: getBridgeToken(net),
- onChange: (e) => { setBridgeToken(e.target.value, net); render(); },
- }))
- : null,
- id !== 'off' ? h('div', { class: 'small faint' }, t('arkBridgeTrust')) : null);
- }
-
async function doArkBoard() {
const sats = parseInt((ui.arkBoardAmt || '').trim(), 10);
if (!sats) return;
@@ -1699,93 +1567,23 @@ export function arkFeature(ctx) {
return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat };
},
arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; },
- // endpoints + server identity + bridge for the NWC pouch record
- arkPouchContext() {
- const cfg = getArkConfig();
- if (!cfg || !ark || !ark.info) return null;
- return {
- arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(),
- serverPubkey: ark.info.serverPubkey, bridge: getBridgeConfig(),
- };
- },
- // Move sats INTO the pouch: an arkoor self-send to a chain-6 key whose
- // signed recipient bytes we keep directly — no address, no mailbox.
- async arkPouchFund(amountSat) {
- const pouch = await loadPouch(wallet._cacheKey());
- if (!pouch) throw new Error('enable background answering first');
- const mgr = await connectArk();
- const idx = (pouch.fundSeq || 0) % POUCH_CHANGE_BASE;
- const dest = pouchKey(wallet.account(), idx);
- const out = await mgr.sendToPubkey(hex.encode(dest.pubkey), amountSat);
- pouch.fundSeq = (pouch.fundSeq || 0) + 1;
- const add = out.vtxos.map((v) => ({ ...v, keyIndex: idx, state: 'spendable' }));
- pouch.vtxos = [...(pouch.vtxos || []), ...add];
- if (pouch.mgr) pouch.mgr.vtxos = [...(pouch.mgr.vtxos || []), ...add.map((v) => ({ ...v }))];
- pouch.updated = Date.now();
- await savePouch(wallet._cacheKey(), pouch);
- return add.reduce((n, v) => n + v.amountSat, 0);
- },
- // Everything back to the main wallet (ordinary arkoor sends to our own
- // ark address, so they arrive like any receive).
- async arkPouchEmpty() {
- const pouch = await loadPouch(wallet._cacheKey());
- if (!pouch) return 0;
- const pm = await pouchManager(pouch);
- const mgr = await connectArk();
- const dest = mgr.address();
- let moved = 0;
- for (let guard = 0; guard < 30; guard++) {
- const v = (pm.state.vtxos || []).find((x) => x.state === 'spendable');
- if (!v) break;
- await pm.send(dest, v.amountSat); // full amount: no change stays behind
- moved += v.amountSat;
- }
- pouch.vtxos = (pm.state.vtxos || []).filter((x) => x.state === 'spendable');
- pouch.mgr = pm.state;
- pouch.updated = Date.now();
- await savePouch(wallet._cacheKey(), pouch);
- return moved;
- },
- async arkPouchBalance() {
- const pouch = await loadPouch(wallet._cacheKey());
- if (!pouch) return 0;
- const vs = (pouch.mgr && pouch.mgr.vtxos) || pouch.vtxos || [];
- return vs.filter((v) => (v.state || 'spendable') === 'spendable').reduce((n, v) => n + (v.amountSat || 0), 0);
- },
- // Recover worker-initiated swaps: finished ones become history, dead ones
- // are refunded with the POUCH's own keys (the refund pubkey is chain 6 —
- // the main manager cannot sign for it).
- async arkPouchRecover() {
- const pouch = await loadPouch(wallet._cacheKey());
- if (!pouch || !(pouch.swaps || []).some((r) => ['submitted', 'refundable'].includes(r.step))) return;
- const pm = await pouchManager(pouch);
- for (const rec of pouch.swaps) {
- if (!['submitted', 'refundable'].includes(rec.step)) continue;
- try {
- const st = await fetch(`${rec.bridgeUrl}/swap?paymentHash=${rec.paymentHash}`).then((r) => r.json());
- if (st.step === 'done' && st.preimage) { rec.step = 'done'; rec.preimage = st.preimage; continue; }
- } catch {}
- try {
- const r = await fetch(`${rec.bridgeUrl}/refund`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ paymentHash: rec.paymentHash }),
- });
- const out = await r.json();
- if (!r.ok || out.error) throw new Error(out.error || 'refund failed');
- await pm.acceptHtlcRefund({ vtxoBytesList: out.refundVtxos, refundIndex: rec.refundIndex });
- rec.step = 'refunded';
- } catch {}
- }
- pouch.mgr = pm.state;
- await savePouch(wallet._cacheKey(), pouch);
+ // Whether background answering can mirror right now.
+ arkBgReady() { return !!(ark && ark.info && ark.state); },
+ // nwc.js calls this (with its connections) whenever the mirror should
+ // refresh: on enable, on connection changes, after payments, on a timer.
+ async arkBgWrite(connections) { return writeBgMirror(connections); },
+ async arkBgSpendableSat() {
+ const rec = await loadBg(wallet._cacheKey());
+ return ((rec && rec.mgr && rec.mgr.vtxos) || [])
+ .filter((v) => v.state === 'spendable').reduce((n, v) => n + (v.amountSat || 0), 0);
},
- // Pouch payments become ordinary history lines once the app opens.
- async arkPouchNoteSpends(spends) {
+ // Worker payments become ordinary history lines once the app opens.
+ async arkBgNoteSpends(spends) {
const mgr = await connectArk();
- for (const s of spends) {
+ for (const sp of spends) {
mgr._movement({
- type: 'ln-send', amountSat: s.amountSat, status: 'complete',
- detail: `via pouch · fee ${s.feeSat || 0} sat`, invoice: s.invoice || '', preimage: s.preimage || '',
+ type: 'ln-send', amountSat: sp.amountSat, status: 'complete',
+ detail: `while closed · fee ${sp.feeSat || 0} sat`, invoice: sp.invoice || '', preimage: sp.preimage || '',
});
}
mgr._save();
@@ -1872,7 +1670,7 @@ export function arkFeature(ctx) {
: null,
done ? null : h('div', { class: 'small muted', style: 'margin-top:2px' }, t('arkBoardedNote')));
},
- settingsCards() { return [arkCard(), bridgeCard()]; },
+ settingsCards() { return [arkCard()]; },
// ---- ark-gift hooks (called by the gifts feature via ctx.hook) ----
arkGiftInfo() {
diff --git a/src/features/index.js b/src/features/index.js
index 06d8d4d..583334f 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -5,17 +5,16 @@
// bundle. See featureIndexSource() in build.js.
import { giftsFeature } from './gifts.js';
-import { swapsFeature } from './swaps.js';
import { arkFeature } from './ark.js';
import { zapsFeature } from './zaps.js';
-import { spFeature } from './sp.js';
import { syncFeature } from './sync.js';
import { nwcFeature } from './nwc.js';
// NB order is meaningful where hooks stack: gifts' receive takeover and
// balance line come before ark's, matching the pre-plugin layout. zaps sits
// after ark so an npub prefers an instant Ark zap and only falls through to a
-// Lightning zap when Ark can't serve it.
+// Lightning zap when Ark can't serve it. nwc sits after ark because it
+// drives ark's headless pay/balance seam.
export function buildFeatures(ctx) {
- return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), zapsFeature(ctx), spFeature(ctx), nwcFeature(ctx), syncFeature(ctx)];
+ return [giftsFeature(ctx), arkFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)];
}
diff --git a/src/features/nwc.js b/src/features/nwc.js
index a91768d..f7e42c8 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -7,10 +7,8 @@
// at any time.
//
// hal holds no native Lightning balance, so every Lightning operation here
-// runs over Ark: paying goes through the swap bridge when one is configured
-// and falls back to the ASP's own lightning-send, and receiving mints an
-// invoice on the ASP. That means NWC availability tracks Ark availability —
-// no Ark server, no wallet service.
+// runs over Ark: the ASP pays and mints invoices. That means NWC
+// availability tracks Ark availability — no Ark server, no wallet service.
//
// Shape of the protocol (mirrors coinos-server/lib/nwc.ts):
// kind 13194 info event, content = space-separated supported methods
@@ -18,8 +16,9 @@
// kind 23195 response (wallet -> client), same encryption scheme back
// Encryption is nip04 unless the request carries `["encryption","nip44_v2"]`.
//
-// IMPORTANT limitation, surfaced in the UI: hal is a web wallet, so it only
-// answers while a tab is open. It is not an always-on wallet service.
+// While a tab is open it answers directly; with background answering on,
+// the service worker answers from a mirrored state (see nwc-bg.js) so the
+// wallet keeps working closed.
import { hex } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
@@ -30,7 +29,7 @@ import {
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
import { getNetwork } from '../api.js';
-import { buildPouch, loadPouch, savePouch, clearPouch, pouchKey } from '../nwc-pouch.js';
+import { loadBg, saveBg, clearBg } from '../nwc-bg.js';
import { maybeBolt11 } from '../ark/lightning.js';
const REQ_KIND = 23194;
@@ -82,6 +81,7 @@ export function nwcFeature(ctx) {
// travels — and the payment just changed the ark state (vtxos, movement)
// that the user's other devices display.
try { wallet.saveCache(); } catch {}
+ writeBg(); // keep the worker's mirror as fresh as the state it spends
}
// The relays advertised in the URI MUST be the ones we listen on, or a
@@ -449,7 +449,7 @@ export function nwcFeature(ctx) {
});
if (!r.ok) throw new Error(`notifier refused: ${r.status}`);
const st = load(); st.background = true; save(st);
- await writePouch();
+ writeBg();
return true;
}
@@ -493,56 +493,45 @@ export function nwcFeature(ctx) {
const st = load(); st.background = false; save(st);
// no background answering means no reason to keep spendable keys where a
// worker can read them
- try { await clearPouch(wallet._cacheKey()); } catch {}
+ try { await clearBg(wallet._cacheKey()); } catch {}
}
- // (Re)write the pouch record the worker answers from: connections' service
- // keys, ASP + bridge endpoints, the chain-6 key window — while PRESERVING
- // anything the pouch already holds (its coins, the worker's spend log and
- // manager state), which a rebuild must never wipe.
- async function writePouch() {
+ // (Re)write the background record the worker answers from: the full ark
+ // state mirror plus this feature's connections. The ark feature owns the
+ // heavy lifting; this decides WHEN (background answering on).
+ let bgTimer = null;
+ function writeBg() {
if (!load().background) return;
- const cfg = hook('arkPouchContext');
- if (!cfg) return;
- try {
- const prev = await loadPouch(wallet._cacheKey());
- const fresh = buildPouch({
- ...cfg, vtxos: prev?.vtxos || [], account: wallet.account(), connections: conns(),
- });
- if (prev) {
- fresh.mgr = prev.mgr;
- fresh.spends = prev.spends;
- fresh.swaps = prev.swaps;
- fresh.fundSeq = prev.fundSeq;
- if (prev.mgr?.nextKeyIndex) fresh.nextKeyIndex = prev.mgr.nextKeyIndex;
- }
- await savePouch(wallet._cacheKey(), fresh);
- pouchSat = await hook('arkPouchBalance');
- } catch (e) { console.warn('nwc: could not write pouch', e.message); }
+ clearTimeout(bgTimer);
+ bgTimer = setTimeout(async () => {
+ try {
+ if (!hook('arkBgReady')) return;
+ await hook('arkBgWrite', conns());
+ } catch (e) { console.warn('nwc: could not write background state', e.message); }
+ }, 1500);
}
// When the app opens after the worker has been answering: absorb its spends
- // into the budgets and history, and rescue any swap it left hanging.
- let pouchSat = null; // cached for the (synchronous) settings card
- async function reconcilePouch() {
+ // into the budgets and history. (The ark feature separately merges the
+ // worker's coin state on connect.)
+ async function reconcileBg() {
try {
if (!load().background) return;
- await hook('arkPouchRecover');
- const pouch = await loadPouch(wallet._cacheKey());
- if (!pouch) return;
- const fresh = (pouch.spends || []).filter((s) => !s.absorbed);
+ const rec = await loadBg(wallet._cacheKey());
+ if (!rec) return;
+ const fresh = (rec.spends || []).filter((s) => !s.absorbed);
if (fresh.length) {
for (const s of fresh) {
const c = conns().find((x) => x.id === s.connId);
if (c) recordSpend(c, (s.amountSat || 0) + (s.feeSat || 0));
s.absorbed = true;
}
- await hook('arkPouchNoteSpends', fresh);
- await savePouch(wallet._cacheKey(), pouch);
+ await hook('arkBgNoteSpends', fresh);
+ await saveBg(wallet._cacheKey(), rec);
}
- pouchSat = await hook('arkPouchBalance');
+ writeBg(); // refresh the mirror after the merge settled
render();
- } catch (e) { console.warn('nwc: pouch reconcile failed', e.message); }
+ } catch (e) { console.warn('nwc: background reconcile failed', e.message); }
}
// An open window handles the request itself; the worker just nudges it.
@@ -561,7 +550,7 @@ export function nwcFeature(ctx) {
let watchdog = null;
function startWatchdog() {
if (watchdog) return;
- watchdog = setInterval(() => { if (conns().length) listen(); }, 90 * 1000);
+ watchdog = setInterval(() => { if (conns().length) { listen(); writeBg(); } }, 90 * 1000);
if (typeof window !== 'undefined') {
window.addEventListener('online', () => listen());
document.addEventListener('visibilitychange', () => {
@@ -615,42 +604,11 @@ export function nwcFeature(ctx) {
h('button', { class: 'btn-sm', onClick: async () => {
try {
if (load().background) { await disableBackground(); toast(t('nwcBackgroundOff')); }
- else { await enableBackground(); await reconcilePouch(); toast(t('nwcBackgroundOn')); }
+ else { await enableBackground(); await reconcileBg(); toast(t('nwcBackgroundOn')); }
} catch (e) { toast(e.message); }
render();
} }, load().background ? t('nwcOn') : t('nwcOff')))
: null,
- list.length && load().background
- ? h('div', { class: 'col', style: 'gap:6px' },
- h('div', { class: 'row between' },
- h('span', { class: 'small' }, t('nwcPouch')),
- h('span', { class: 'small' }, pouchSat == null ? '…' : fmtAmount(pouchSat))),
- h('p', { class: 'small faint', style: 'margin:0' }, t('nwcPouchDesc')),
- h('div', { class: 'row gap6' },
- h('input', { type: 'number', min: '1', placeholder: t('nwcPouchAmount'), value: ui.nwcPouchAmt || '',
- style: 'flex:1', onInput: (e) => { ui.nwcPouchAmt = e.target.value; } }),
- h('button', { class: 'btn-sm', onClick: async () => {
- const sat = parseInt(ui.nwcPouchAmt, 10);
- if (!sat) return;
- try {
- const moved = await hook('arkPouchFund', sat);
- ui.nwcPouchAmt = '';
- pouchSat = await hook('arkPouchBalance');
- toast(t('nwcPouchFunded', { sats: fmtAmount(moved) }));
- } catch (e) { toast(e.message); }
- render();
- } }, t('nwcPouchFund')),
- pouchSat > 0
- ? h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
- try {
- const moved = await hook('arkPouchEmpty');
- pouchSat = await hook('arkPouchBalance');
- toast(t('nwcPouchEmptied', { sats: fmtAmount(moved) }));
- } catch (e) { toast(e.message); }
- render();
- } }, t('nwcPouchEmpty'))
- : null))
- : null,
st
? h('div', { class: 'col', style: 'gap:6px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' },
h('input', { type: 'text', placeholder: t('nwcAppName'), value: st.name,
@@ -677,7 +635,7 @@ export function nwcFeature(ctx) {
return {
id: 'nwc',
- init() { listen(); startWatchdog(); refreshRegistration(); reconcilePouch(); },
+ init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); },
stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } },
settingsCards() { return [nwcCard()]; },
};
diff --git a/src/features/sp-wallet.js b/src/features/sp-wallet.js
deleted file mode 100644
index f461d27..0000000
--- a/src/features/sp-wallet.js
+++ /dev/null
@@ -1,544 +0,0 @@
-// Silent payments (BIP-352) wallet engine — key derivation, indexer scanning
-// (worker-accelerated with an inline fallback), the live indexer WebSocket,
-// SP outputs as spendable coins, sp1 send preparation, and per-output-key
-// signing. Installed onto the core wallet by the sp feature, wired through the
-// wallet's generic seams so a build without sp ships none of this.
-
-import * as btc from '@scure/btc-signer';
-import { hex } from '@scure/base';
-import { schnorr } from '@noble/curves/secp256k1';
-import { HDKey } from '@scure/bip32';
-import { mnemonicToSeedSync } from '@scure/bip39';
-import {
- isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts,
- silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress,
- silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas,
-} from '../silentpay.js';
-import { spIndexerUrl } from '../api.js';
-import { utxoId, absWsUrl, NETS } from '../wallet.js';
-
-const SP_DUST_LIMIT = 0;
-
-export function installSpWallet(wallet) {
- if (wallet.scanSilentPayments) return; // already installed
- wallet.spUtxos = [];
- wallet.lastSpScan = 0; // highest block height scanned for silent payments
- wallet.spScanning = false;
-
- Object.assign(wallet, {
- // BIP-352 scan/spend keys, derived from the master seed (m/352' paths) — so
- // they need the mnemonic (or a depth-0 master xprv); watch-only and account-
- // level imported keys can't do silent payments. Cached per account.
- silentPaymentKeys() {
- if (this.watchOnly) return null;
- const coin = NETS[this.netName].coin;
- const ck = `sp|${this.netName}|${this.mnemonic}|${this.passphrase}|${this.xprv}`;
- if (this._spKeys && this._spKeysKey === ck) return this._spKeys;
- let master = null;
- if (this.mnemonic) master = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase));
- else if (this.xprv) { const n = HDKey.fromExtendedKey(this.xprv); if (n.depth === 0) master = n; }
- this._spKeys = master ? deriveSilentPaymentKeys(master, coin) : null;
- this._spKeysKey = ck;
- this._spScanCache = new Map(); // per-tweak scan results are keyed to these keys
- this._spCandCache = new Map();
- return this._spKeys;
- },
-
- // Scan one tweak's outputs for our payments, memoized by tweak — the EC
- // point-multiplication is the cost, and a tweak's outcome never changes, so
- // repeated mempool pushes (which re-send the same tweaks) stay cheap.
- _spScan(tweakHex, outputs) {
- const keys = this.silentPaymentKeys(); // resets the caches if keys changed
- if (!keys) return [];
- let m = this._spScanCache.get(tweakHex);
- if (m) return m;
- if (this._spScanCache.size > 100000) this._spScanCache.clear(); // bound memory over long sessions
- m = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex), outputs });
- this._spScanCache.set(tweakHex, m);
- return m;
- },
-
- // Our candidate key for a tweak (for the Bloom pre-filter), memoized likewise.
- _spCandidate(tweakHex) {
- const keys = this.silentPaymentKeys();
- if (!keys) return null;
- let c = this._spCandCache.get(tweakHex);
- if (c) return c;
- if (this._spCandCache.size > 100000) this._spCandCache.clear(); // bound memory over long sessions
- c = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex) });
- this._spCandCache.set(tweakHex, c);
- return c;
- },
-
- // Spawn (once) the scanning worker and keep its keys current. Returns null if
- // workers are unavailable or the embedded bundle is missing — callers then fall
- // back to scanning on the main thread (still cached + yielding).
- _ensureSpWorker() {
- if (this._spWorkerFailed || typeof Worker === 'undefined') return null;
- const keys = this.silentPaymentKeys();
- if (!keys) return null;
- if (!this._spWorker) {
- try {
- const b64 = globalThis.__SP_WORKER__;
- if (!b64) { this._spWorkerFailed = true; return null; }
- const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
- const w = new Worker(URL.createObjectURL(new Blob([bytes], { type: 'text/javascript' })));
- this._spPending = new Map();
- this._spReqId = 0;
- w.onmessage = (e) => { const p = this._spPending.get(e.data.id); if (p) { this._spPending.delete(e.data.id); e.data.error ? p.rej(new Error(e.data.error)) : p.res(e.data); } };
- w.onerror = () => { this._spWorkerFailed = true; try { w.terminate(); } catch {} this._spWorker = null; for (const p of this._spPending.values()) p.rej(new Error('sp worker error')); this._spPending.clear(); };
- this._spWorker = w;
- this._spWorkerKeysSig = null;
- } catch { this._spWorkerFailed = true; return null; }
- }
- if (this._spWorkerKeysSig !== this._spKeysKey) {
- this._spWorkerKeysSig = this._spKeysKey;
- this._spWorker.postMessage({ id: ++this._spReqId, op: 'keys', scanPriv: keys.scanPriv, spendPub: keys.spendPub });
- }
- return this._spWorker;
- },
-
- _spCall(op, data) {
- return new Promise((res, rej) => {
- const id = ++this._spReqId;
- this._spPending.set(id, { res, rej });
- this._spWorker.postMessage({ id, op, ...data });
- setTimeout(() => {
- if (!this._spPending.has(id)) return;
- // Timed out. TERMINATE the worker rather than let it keep grinding the
- // orphaned request in the background — otherwise the caller's inline
- // fallback runs the SAME work on the main thread and both threads peg.
- // _ensureSpWorker() respawns a fresh worker (and re-sends keys) on the
- // next call, so this only costs us the current chunk. With the tweak
- // budget above, calls stay sub-second and this should never fire.
- try { this._spWorker && this._spWorker.terminate(); } catch {}
- this._spWorker = null;
- for (const p of this._spPending.values()) p.rej(new Error('sp worker timeout'));
- this._spPending.clear();
- }, 30000);
- });
- },
-
- // Block heights whose Bloom filter might hold one of our outputs (worker if
- // available, else a cached + yielding main-thread loop).
- async _spHitBlocks(blocks) {
- if (!blocks.length) return new Set();
- const w = this._ensureSpWorker();
- if (w) { try { return new Set((await this._spCall('candidates', { blocks })).hits || []); } catch {} }
- const hits = new Set();
- let i = 0;
- for (const b of blocks) {
- if (++i % 8 === 0) await new Promise((r) => setTimeout(r, 0));
- for (const tw of b.tweaks) { if (bloomHas(b.filter, this._spCandidate(tw))) { hits.add(b.height); break; } }
- }
- return hits;
- },
-
- // Full-scan items → our resolved utxos {txid,vout,value,xonly,tweak} (worker if
- // available, else a cached + yielding main-thread loop).
- async _spScanItems(items) {
- if (!items.length) return [];
- const w = this._ensureSpWorker();
- if (w) { try { return (await this._spCall('scan', { items })).found || []; } catch {} }
- const found = [];
- let i = 0;
- for (const it of items) {
- if (++i % 50 === 0) await new Promise((r) => setTimeout(r, 0));
- const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly));
- for (const m of matches) { const o = it.outputs.find((x) => x.xonly === m.output); if (o) found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); }
- }
- return found;
- },
-
- // Our silent-payment address (sp1…/tsp1…), or null if this wallet can't do SP.
- silentPaymentAddress() {
- const k = this.silentPaymentKeys();
- return k ? encodeSilentPaymentAddress(k.scanPub, k.spendPub, { testnet: this.netName !== 'mainnet' }) : null;
- },
-
- // Is SP receiving available here? (we can derive keys AND an indexer is set)
- silentPaymentsAvailable() {
- return !!(this.silentPaymentKeys() && spIndexerUrl(this.netName));
- },
-
- // Total received via silent payments: { confirmed, pending, count } over the
- // unspent SP outputs we've found.
- spBalance() {
- let confirmed = 0, pending = 0;
- for (const u of this.spUtxos) { if (u.confirmed) confirmed += u.value; else pending += u.value; }
- return { confirmed, pending, count: this.spUtxos.length };
- },
-
- // Received silent payments as history rows (one per tx; multiple outputs to us
- // sum into one received entry) so they appear in history like normal payments.
- spTxRows() {
- const byTx = new Map();
- for (const u of this.spUtxos) {
- const e = byTx.get(u.txid) || { txid: u.txid, net: 0, confirmed: true, firstSeen: u.firstSeen || Date.now() };
- e.net += u.value;
- if (!u.confirmed) e.confirmed = false;
- e.firstSeen = Math.min(e.firstSeen, u.firstSeen || e.firstSeen);
- byTx.set(u.txid, e);
- }
- return [...byTx.values()].map((e) => ({
- txid: e.txid, net: e.net, fee: 0, vsize: 0, sp: true,
- confirmed: e.confirmed,
- blockTime: e.confirmed ? Math.floor(e.firstSeen / 1000) : 0,
- blockHeight: 0,
- firstSeen: e.firstSeen,
- }));
- },
-
- // The taproot address for an x-only output key (for backend UTXO lookups).
- _spAddress(xonly) {
- return btc.Address(this.netCfg.net).encode(btc.OutScript.decode(btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(xonly) })));
- },
-
- // Scan the configured SP indexer from the last-scanned height to the tip, find
- // outputs paying our scan/spend keys, and verify each is unspent via the chain
- // backend (the indexer only does discovery). Updates this.spUtxos.
- async scanSilentPayments({ rescan = false, silent = false } = {}) {
- const keys = this.silentPaymentKeys();
- const indexer = spIndexerUrl(this.netName);
- if (!keys || !indexer || this.offline) return { unavailable: true };
- if (this._spScanBusy) return { busy: true };
- // Fresh wallet, never scanned: set the watermark to the current tip instead of
- // scanning all history (nothing predates the wallet). New blocks + mempool
- // arrive via the live push from here on.
- if (this._spFresh && !this.lastSpScan && !rescan) {
- this._spFresh = false;
- try { this.lastSpScan = (await (await fetch(`${indexer}/height`)).json()).height || 0; this.saveCache(); } catch {}
- return { fresh: true };
- }
- // Two tabs on the same wallet must not both grind the catch-up: a
- // localStorage lease (refreshed per chunk) lets the second tab yield.
- const leaseKey = this._cacheKey() + ':spscan-lease';
- try {
- if (!rescan && Date.now() - (parseInt(localStorage.getItem(leaseKey), 10) || 0) < 30000) {
- return { busy: true };
- }
- localStorage.setItem(leaseKey, String(Date.now()));
- } catch {}
- 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 blocks = res.blocks || [];
- // Process in chunks bounded by TWEAK count, not block count. The cost is
- // one EC point-multiply per tweak, and mainnet blocks are wildly uneven
- // (a few to 2500+ tweaks each). A fixed 100-block chunk could be ~25k
- // tweaks — seconds of work that blew the 30s worker timeout, which then
- // dropped the same chunk onto the main thread while the orphaned worker
- // kept grinding it (both threads pegged). Budgeting by tweaks keeps every
- // worker call sub-second regardless of block density; the watermark still
- // advances per chunk so interruptions resume instead of restarting.
- const TWEAK_BUDGET = 2000;
- for (let c = 0; c < blocks.length; ) {
- // grow the slice until it holds ~TWEAK_BUDGET tweaks (always ≥1 block,
- // so a single dense block still goes as its own chunk).
- let end = c, tw = 0;
- do { tw += (blocks[end].tweaks || []).length; end++; } while (end < blocks.length && tw < TWEAK_BUDGET);
- const slice = blocks.slice(c, end);
- c = end;
- const hits = await this._spHitBlocks(slice);
- for (const block of slice) {
- 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);
- }
- }
- const maxH = slice[slice.length - 1].height;
- if (maxH > (this.lastSpScan || 0)) { this.lastSpScan = maxH; this.saveCache(); } // checkpoint
- try { localStorage.setItem(leaseKey, String(Date.now())); } catch {} // keep the lease
- await new Promise((r) => setTimeout(r, 0)); // let the UI breathe
- }
- if (tip > (this.lastSpScan || 0)) this.lastSpScan = tip;
- // Verify newly-found outputs are unspent + get confirmation status.
- for (const f of found) {
- const address = this._spAddress(f.xonly);
- try {
- const us = await this.api.addressUtxos(address);
- const u = us.find((x) => x.txid === f.txid && x.vout === f.vout);
- if (u) this.spUtxos.push({ ...f, address, confirmed: !!(u.status && u.status.confirmed), firstSeen: Date.now() });
- } catch {}
- }
- await this._refreshSpUtxos();
- this._recomputeBalanceFromChains(); // fold found SP value into the displayed balance
- this.saveCache();
- return { found: found.length, scanned: tip };
- } finally {
- this._spScanBusy = false;
- this.spScanning = false;
- this.emit(); // always refresh (balance + clears any "Scanning…")
- }
- },
-
- // Re-check tracked SP outputs against the backend: drop spent ones, refresh
- // confirmation status.
- async _refreshSpUtxos() {
- const next = [];
- for (const u of this.spUtxos) {
- try {
- const us = await this.api.addressUtxos(u.address);
- const live = us.find((x) => x.txid === u.txid && x.vout === u.vout);
- if (live) next.push({ ...u, confirmed: !!(live.status && live.status.confirmed) });
- } catch { next.push(u); } // backend hiccup: keep as-is
- }
- this.spUtxos = next;
- },
-
- // Live silent-payment push: subscribe to the indexer's WebSocket so each new
- // block's SP items are scanned on arrival instead of polling. Catches up via a
- // /scan on connect (covers anything missed while disconnected) and reconnects.
- _spConnect() {
- if (this.offline) return;
- const base = spIndexerUrl(this.netName);
- if (!base || !this.silentPaymentKeys()) return;
- const url = base.replace(/^http/, 'ws') + '/ws';
- if (this._spWs && this._spWsUrl === url) return;
- this._spDisconnect();
- this._spWsUrl = url;
- let ws;
- try { ws = new WebSocket(absWsUrl(url)); } catch { return; }
- this._spWs = ws;
- ws.onopen = () => {
- this.spLive = true;
- this.scanSilentPayments({ silent: true }).catch(() => {}); // confirmed catch-up
- // Pending (mempool) catch-up.
- fetch(`${base}/mempool`).then((r) => r.json()).then((d) => this._scanSpMempool(d.items || [])).catch(() => {});
- };
- ws.onmessage = (ev) => {
- let msg;
- try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data)); } catch { return; }
- if (!msg || !Array.isArray(msg.items)) return;
- if (msg.type === 'block') this._scanSpItems(msg.items, msg.height).catch(() => {});
- else if (msg.type === 'mempool') this._scanSpMempool(msg.items).catch(() => {});
- };
- ws.onerror = () => { try { ws.close(); } catch {} };
- ws.onclose = () => {
- if (this._spWs !== ws) return;
- this.spLive = false; this._spWs = null;
- if (!this.offline) this._spReconnect = setTimeout(() => this._spConnect(), 3000);
- };
- },
-
- _spDisconnect() {
- clearTimeout(this._spReconnect);
- this.spLive = false;
- const ws = this._spWs;
- this._spWs = null; this._spWsUrl = null;
- if (ws) { try { ws.onclose = null; ws.close(); } catch {} }
- },
-
- // Scan a confirmed block's pushed items and upsert ours into spUtxos as
- // confirmed (a previously-pending mempool entry is promoted to confirmed).
- async _scanSpItems(items, height) {
- const keys = this.silentPaymentKeys();
- if (!keys) return;
- let changed = false;
- for (const f of await this._spScanItems(items)) {
- const existing = this.spUtxos.find((u) => u.txid === f.txid && u.vout === f.vout);
- if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } }
- else { this.spUtxos.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), confirmed: true, firstSeen: Date.now() }); changed = true; }
- }
- if (height && height > this.lastSpScan) this.lastSpScan = height;
- if (changed) this._recomputeBalanceFromChains();
- this.saveCache();
- if (changed) this.emit();
- },
-
- // Scan the current mempool's pushed items and rebuild our pending (unconfirmed)
- // SP set — so payments show the moment they're broadcast. Confirmed entries are
- // kept; the unconfirmed set is replaced, which also drops vanished/RBF'd ones.
- async _scanSpMempool(items) {
- // Coalesce: pushes arrive faster than a cold (mainnet) scan completes, so
- // keep only the latest set and never run two scans at once.
- this._spMempoolLatest = items;
- if (this._spMempoolBusy) return;
- this._spMempoolBusy = true;
- try {
- if (!this.silentPaymentKeys()) return;
- while (this._spMempoolLatest) {
- const cur = this._spMempoolLatest;
- this._spMempoolLatest = null;
- const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen]));
- const confirmedIds = new Set(this.spUtxos.filter((u) => u.confirmed).map((u) => `${u.txid}:${u.vout}`));
- const pending = [];
- for (const f of await this._spScanItems(cur)) {
- const id = `${f.txid}:${f.vout}`;
- if (confirmedIds.has(id)) continue;
- pending.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() });
- }
- // Re-read confirmed (a block push may have landed during a yield) and drop
- // any pending that just confirmed, so an outpoint never appears twice.
- const confNow = this.spUtxos.filter((u) => u.confirmed);
- const confNowIds = new Set(confNow.map((u) => `${u.txid}:${u.vout}`));
- const oldPendingIds = new Set(this.spUtxos.filter((u) => !u.confirmed).map((u) => `${u.txid}:${u.vout}`));
- const freshPending = pending.filter((p) => !confNowIds.has(`${p.txid}:${p.vout}`));
- // Only emit when our pending set actually changed — the mempool churns
- // constantly, and a no-op emit would re-render and steal input focus.
- const changed = freshPending.length !== oldPendingIds.size || freshPending.some((p) => !oldPendingIds.has(`${p.txid}:${p.vout}`));
- this.spUtxos = [...confNow, ...freshPending];
- if (changed) { this._recomputeBalanceFromChains(); this.saveCache(); this.emit(); }
- }
- } finally {
- this._spMempoolBusy = false;
- }
- },
-
- // Mark summary outputs that fund a silent payment with the sp1… address the
- // user actually typed (the on-chain output is a derived one-time taproot addr).
- _tagSilentOutputs(summary, spOuts) {
- for (const s of spOuts) {
- if (!s.derivedAddress) continue;
- const o = summary.outputs.find((o) => o.address === s.derivedAddress && !o.silent);
- if (o) o.silent = s.address;
- }
- },
-
- // Replace each placeholder silent-payment output in a freshly-selected (still
- // unsigned) tx with its real one-time taproot script, derived from the tx's
- // actual inputs per BIP-352.
- _applySilentPayments(tx, spOuts) {
- const byOutpoint = new Map();
- for (const u of this.utxos) byOutpoint.set(utxoId(u), { u, sp: false });
- for (const u of this.spUtxos) byOutpoint.set(utxoId(u), { u, sp: true });
- const spKeys = this.silentPaymentKeys();
- const inputs = [];
- for (let i = 0; i < tx.inputsLength; i++) {
- const inp = tx.getInput(i);
- const e = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`);
- if (!e) throw new Error('Missing key for a silent payment input.');
- // Our own SP coins can fund a silent payment too — they're taproot inputs
- // whose key is d = spend_priv + t_k (committed to with even Y by the script).
- if (e.sp) {
- if (!spKeys) throw new Error('Missing keys for a silent payment input.');
- inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: silentPaymentOutputPrivKey(spKeys.spendPriv, hex.decode(e.u.tweak)), taproot: true });
- } else {
- inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: this.node(e.u.chain, e.u.index).privateKey });
- }
- }
- const scripts = silentPaymentScripts(inputs, spOuts.map((s) => ({ scan: s.scan, spend: s.spend })));
- spOuts.forEach((s, k) => {
- const ph = hex.encode(s.placeholder);
- const idx = tx.outputs.findIndex((o) => hex.encode(o.script) === ph);
- if (idx < 0) throw new Error('Silent payment output not found after selection.');
- tx.updateOutput(idx, { script: scripts[k], amount: tx.outputs[idx].amount }, true);
- s.derivedAddress = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(scripts[k]));
- });
- },
- });
-
- // -- seam registrations -----------------------------------------------
- wallet.registerBalanceExtra(() => wallet.spBalance());
- wallet.registerHistoryProvider(() => wallet.spTxRows());
- wallet.registerScanHook(() => wallet.scanSilentPayments({ silent: true }).catch(() => {}));
- wallet.registerRealtimeHook({
- start: () => wallet._spConnect(), // live push of silent-payment receipts
- stop: () => wallet._spDisconnect(),
- // WS is primary; the heartbeat poll is the fallback when it isn't live.
- poll: () => { if (!wallet.spLive) wallet.scanSilentPayments({ silent: true }).catch(() => {}); },
- });
- wallet.registerLoadHook(({ spFresh }) => {
- // A freshly-generated wallet can't have received silent payments before it
- // existed, so its SP watermark starts at the current tip (no history scan).
- wallet._spFresh = !!spFresh;
- wallet.spUtxos = [];
- wallet.lastSpScan = 0;
- });
- // Ride the encrypted sync snapshot so the (expensive) SP block catch-up runs
- // ONCE across all devices instead of on each. Like Ark, this is a commutative
- // merge (mergeAlways) — applied even for snapshots older than local state — so
- // whichever device scans furthest carries the rest.
- wallet.registerCacheExtension({
- mergeAlways: true,
- save: () => ({ spUtxos: wallet.spUtxos, lastSpScan: wallet.lastSpScan }),
- load: (d) => {
- let grew = false;
- // Watermark is a HIGH-water mark: adopt the furthest height any device
- // reached, never regress. This is what stops every device re-grinding the
- // ~1M-tweak backlog. (A manual rescan forces from=1 synchronously, so it
- // still re-scans everything regardless of this.)
- const remoteScan = d.lastSpScan || 0;
- if (remoteScan > (wallet.lastSpScan || 0)) wallet.lastSpScan = remoteScan;
- else if ((wallet.lastSpScan || 0) > remoteScan) grew = true; // our watermark is ahead
- // Additive union of found coins by outpoint — bring in ones we lack, but
- // NEVER mutate a coin we already track. Spend detection is each device's
- // own job (_refreshSpUtxos drops coins the backend reports spent), so a
- // stale coin merged in from another device self-heals on the next refresh
- // rather than needing spent-state to propagate.
- if (Array.isArray(d.spUtxos)) {
- const have = new Set(wallet.spUtxos.map((u) => `${u.txid}:${u.vout}`));
- let added = false;
- for (const ru of d.spUtxos) {
- if (!ru || ru.txid == null || ru.vout == null) continue;
- const id = `${ru.txid}:${ru.vout}`;
- if (!have.has(id)) { wallet.spUtxos.push(ru); have.add(id); added = true; }
- }
- if (wallet.spUtxos.length > d.spUtxos.length) grew = true; // we hold coins it lacked
- if (added) wallet._recomputeBalanceFromChains();
- }
- // Anti-entropy: if we ended up knowing more than this snapshot did, push
- // our superset back so the shared slot converges. Guarded, so once every
- // device agrees it stops (matches the Ark merge).
- if (grew) { try { wallet.saveCache(); } catch {} }
- },
- });
- // Confirmed SP coins are spendable like any other coin — including to fund a
- // silent payment. Unconfirmed ones are excluded (the sender's tx could be
- // replaced, invalidating our spend).
- wallet.registerInputProvider(({ coinIds }) => {
- const confirmedSp = wallet.spUtxos.filter((u) => u.confirmed);
- const pool = coinIds ? confirmedSp.filter((u) => coinIds.includes(utxoId(u))) : confirmedSp;
- return pool.map((u) => ({
- txid: u.txid,
- index: u.vout,
- sequence: 0xfffffffd,
- witnessUtxo: { script: btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(u.xonly) }), amount: BigInt(u.value) },
- // Present so the fee estimator sizes a key-path (64-byte) witness; the
- // value is unused (SP inputs are signed raw below, via tapKeySig).
- tapInternalKey: hex.decode(u.xonly),
- }));
- });
- // sp1… recipients: the real taproot output commits to the final input set,
- // so buildTx selects against a placeholder and swaps the derived script in.
- wallet.registerSendPreparer(() => {
- const spOuts = []; // { placeholder, scan, spend, address }
- return {
- match: (addr) => isSilentPaymentAddress(addr),
- prepare(addr) {
- if (wallet.watchOnly) throw new Error('Silent payments require a wallet with keys.');
- const { scan, spend } = decodeSilentPaymentAddress(addr);
- const placeholder = silentPaymentPlaceholder(spOuts.length);
- spOuts.push({ placeholder, scan, spend, address: addr });
- return placeholder;
- },
- apply(tx) { if (spOuts.length) wallet._applySilentPayments(tx, spOuts); },
- tag(summary) { wallet._tagSilentOutputs(summary, spOuts); },
- };
- });
- // Received SP outputs sign with d = spend_priv + t_k (raw output key —
- // no BIP-341 tweak, so btc-signer's auto key-path signing doesn't apply).
- wallet.registerInputSigner((tx, i, id, { prevScripts, amounts }) => {
- const u = wallet.spUtxos.find((x) => utxoId(x) === id);
- if (!u) return false;
- const d = silentPaymentOutputPrivKey(wallet.silentPaymentKeys().spendPriv, hex.decode(u.tweak));
- const hash = tx.preimageWitnessV1(i, prevScripts, btc.SigHash.DEFAULT, amounts);
- tx.updateInput(i, { tapKeySig: schnorr.sign(hash, d) }, true);
- return true;
- });
-}
diff --git a/src/features/sp.js b/src/features/sp.js
deleted file mode 100644
index 92f091f..0000000
--- a/src/features/sp.js
+++ /dev/null
@@ -1,66 +0,0 @@
-// Silent payments (BIP-352) feature — the reusable sp1… receive address and
-// the tweak-indexer settings. (The SP scanning/spending machinery itself lives
-// in wallet.js/silentpay.js and ships with the core wallet for now.)
-
-import { isSilentPaymentAddress } from '../silentpay.js';
-import { installSpWallet } from './sp-wallet.js';
-import { getNetwork, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from '../api.js';
-import { t } from '../i18n.js';
-import { qrSvg } from '../qr.js';
-
-export function spFeature(ctx) {
- const { h, ui, render, wallet, copyBtn } = ctx;
- installSpWallet(wallet); // the BIP-352 engine lives outside the core wallet
-
- function spIndexerCard() {
- if (!wallet.silentPaymentKeys || !wallet.silentPaymentKeys()) return null;
- 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
- );
- }
-
- return {
- id: 'sp',
- receiveModes() {
- const spAddr = wallet.silentPaymentsAvailable && wallet.silentPaymentsAvailable() ? wallet.silentPaymentAddress() : null;
- if (!spAddr) return [];
- return [{
- id: 'sp', label: t('receiveSpTab'), icon: '🕶️',
- render: (seg) => h(
- 'div',
- { class: 'card col', style: 'align-items:center;gap:14px' },
- seg,
- h('div', { html: qrSvg(spAddr) }),
- h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, spAddr),
- copyBtn(spAddr, t('copyAddress'))
- ),
- }];
- },
- isSendDest(a) { return isSilentPaymentAddress(a); },
- // Silent-payment receipts merge into the main history as on-chain-looking
- // rows (tx.sp) — give them a rail glyph + tag so they're recognizable.
- decorateTxRow(tx) {
- return tx && tx.sp
- ? { icon: '🕶️', label: h('span', {}, t('received'), ' ', h('span', { class: 'tag' }, t('receiveSpTab'))) }
- : null;
- },
- settingsCards() { return [spIndexerCard()]; },
- };
-}
diff --git a/src/features/swaps.js b/src/features/swaps.js
deleted file mode 100644
index bad823c..0000000
--- a/src/features/swaps.js
+++ /dev/null
@@ -1,239 +0,0 @@
-// Lightning swaps feature (Boltz reverse/submarine) — receive over LN,
-// pay bolt11 invoices, provider settings. Extracted from app.js; the
-// protocol layer lives in ../swap.js.
-
-import { SwapManager, installSwapWallet } from '../swap.js';
-import { getNetwork, getBoltzApi, boltzPresetsFor, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom } from '../api.js';
-import { t } from '../i18n.js';
-import { qrSvg } from '../qr.js';
-import { fmtBtc } from '../format.js';
-
-// A bolt11 Lightning invoice (any network prefix); long ln-prefixed bech32 string.
-function isLnInvoice(v) { v = (v || '').trim(); return /^ln[a-z0-9]+$/i.test(v) && v.length > 50; }
-
-export function swapsFeature(ctx) {
- const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, copyBtn } = ctx;
- installSwapWallet(wallet); // swap storage/keys live outside the core wallet
-
- // Boltz swap orchestrator (reverse = receive over LN, submarine = spend over LN).
- const swaps = new SwapManager({
- wallet, network: getNetwork(), getApi: getBoltzApi,
- feeRate: 2, onUpdate: () => render(),
- });
-
- function lnReceiveContent() {
- if (!ui.swapLimits && !ui._swapLimitsLoading) {
- ui._swapLimitsLoading = true;
- swaps.reverseLimits().then((l) => { ui.swapLimits = l; ui._swapLimitsLoading = false; render(); }).catch(() => { ui._swapLimitsLoading = false; });
- }
- const lim = ui.swapLimits;
- let rec = ui.lnReceiveId ? swaps.list().find((s) => s.id === ui.lnReceiveId) : null;
- if (rec && ['claimed', 'success'].includes(rec.status)) {
- // Claimed — the coin is credited on-chain (swap.js → creditReceive), so the
- // generic "Payment received!" celebration shows it like any receive. Just
- // reset the Lightning flow back to its form.
- ui.lnReceiveId = null;
- rec = null;
- }
- if (rec) {
- let svg = null;
- try { svg = qrSvg(rec.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {}
- return [
- h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('lnReceiveAwaiting')),
- svg ? h('div', { html: svg }) : null,
- h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, rec.invoice),
- copyBtn(rec.invoice, t('copyInvoice')),
- h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('lnReceiveWatching'))),
- h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnReceiveId = null; ui.swapReverseAmt = ''; ui.swapError = ''; render(); } }, t('back')),
- ];
- }
- return [
- h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('lnReceiveIntro')),
- h('label', { class: 'field', style: 'width:100%' },
- h('span', { class: 'lab' }, t('amountSatsLabel')),
- h('input', { type: 'number', inputmode: 'numeric', placeholder: '', value: ui.swapReverseAmt || '', onInput: (e) => { ui.swapReverseAmt = e.target.value; } }),
- lim ? h('span', { class: 'small muted' }, `Min ${lim.min.toLocaleString()} · max ${lim.max.toLocaleString()} sats`) : null),
- ui.swapError ? h('div', { class: 'notice err' }, ui.swapError) : null,
- h('button', { class: 'btn-primary btn-block', disabled: !!ui.swapBusy, onClick: doReverse }, ui.swapBusy ? h('span', { class: 'spinner' }) : t('lnCreateInvoice')),
- ];
- }
-
- async function doReverse() {
- const amt = parseInt((ui.swapReverseAmt || '').trim(), 10);
- const lim = ui.swapLimits || (ui.swapLimits = await swaps.reverseLimits());
- if (!amt || amt < lim.min || amt > lim.max) { ui.swapError = `Enter ${lim.min.toLocaleString()}–${lim.max.toLocaleString()} sats`; return render(); }
- ui.swapError = ''; ui.swapBusy = true; render();
- try { const rec = await swaps.startReverse(amt); ui.lnReceiveId = rec.id; ui.swapReverseAmt = ''; }
- catch (e) { ui.swapError = e.message; }
- ui.swapBusy = false; render();
- }
-
- async function doRefund(id) {
- ui.swapError = ''; ui.swapBusy = true; render();
- try { await swaps.refundSubmarine(id); }
- catch (e) { ui.swapError = e.message; }
- ui.swapBusy = false; render();
- }
-
- function boltzProviderCard() {
- const id = getBoltzProviderId();
- const custom = getBoltzCustom();
- return h(
- 'div',
- { class: 'card col' },
- h('h3', {}, '⚡ Swap provider'),
- h('p', { class: 'small muted', style: 'margin:0' }, 'Boltz-compatible provider for Lightning swaps. Non-custodial: a provider can fail a swap but never take your funds.'),
- h('select', { onChange: (e) => { setBoltzProviderId(e.target.value); ui.swapLimits = null; render(); } },
- boltzPresetsFor(getNetwork()).map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))),
- id === 'custom'
- ? h('div', { class: 'col', style: 'gap:8px' },
- h('label', { class: 'field' },
- h('span', { class: 'lab' }, 'API URL'),
- h('input', {
- type: 'text', class: 'mono-input', placeholder: 'https://api.example.com',
- autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.api,
- onChange: (e) => { setBoltzCustom({ api: e.target.value.trim(), ws: custom.ws }); ui.swapLimits = null; render(); },
- })),
- h('label', { class: 'field' },
- h('span', { class: 'lab' }, 'WebSocket URL (optional)'),
- h('input', {
- type: 'text', class: 'mono-input', placeholder: 'wss://api.example.com/v2/ws',
- autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.ws,
- onChange: (e) => { setBoltzCustom({ api: custom.api, ws: e.target.value.trim() }); render(); },
- }),
- h('div', { class: 'small faint' }, 'Left blank, the WS is derived from the API URL.')))
- : h('div', { class: 'small faint', style: 'word-break:break-all' }, getBoltzApi())
- );
- }
-
- function swapTxDetailSection(swap, tx) {
- const u = ' ' + unitLabel();
- const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v));
- const head = (txt) => h('div', { style: 'font-weight:600;margin:12px 0 2px' }, txt);
- if (swap.refundTxid === tx.txid)
- return h('div', { class: 'summary col', style: 'gap:0' }, head('↩ Lightning swap refund'), line(t('status'), 'Refunded ✓'));
- if (swap.kind === 'submarine') {
- const fee = (swap.expectedAmount || 0) - (swap.invoiceAmount || 0);
- const st = swap.status === 'success' ? 'Invoice paid ✓'
- : swap.status === 'refundable' ? 'Payment failed — refundable'
- : swap.status === 'refunded' ? 'Refunded' : 'Paying…';
- return h('div', { class: 'summary col', style: 'gap:0' },
- head('⚡ Paid over Lightning'),
- swap.invoiceAmount != null ? line('Invoice', fmtAmount(swap.invoiceAmount) + u) : null,
- fee > 0 ? line('Swap fee', fmtAmount(fee) + u) : null,
- line(t('status'), st),
- swap.status === 'refundable'
- ? h('button', { class: 'btn-sm btn-block', style: 'margin-top:8px', disabled: !!ui.swapBusy, onClick: () => doRefund(swap.id) }, 'Refund on-chain')
- : null);
- }
- // reverse swap claim
- return h('div', { class: 'summary col', style: 'gap:0' },
- head('⚡ Received over Lightning'),
- line(t('status'), swap.status === 'claimed' ? 'Settled ✓' : swap.status));
- }
-
- async function startLnSend(invoice, meta = null, skipArk = false) {
- // The ark feature pays bolt11 straight from the ark balance when it can
- // cover it (the ASP is the swap counterparty) — cheaper and instant, so
- // it gets first refusal before the on-chain Boltz submarine path.
- if (!skipArk && ctx.hook('startArkLnPay', invoice, meta)) return;
- ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnPayMeta = meta; ui.lnSendBusy = true; render();
- try { ui.lnSend = await swaps.quoteSubmarine(invoice); }
- catch (e) { ui.sendError = e.message; }
- ui.lnSendBusy = false; render();
- }
-
- async function doLnPay() {
- if (!ui.lnSend) return;
- ui.sendError = ''; ui.lnSendBusy = true; render();
- try { const rec = await swaps.fundQuotedSubmarine(ui.lnSend); ui.lnSent = { amount: ui.lnSend.invoiceAmount, id: rec.id }; }
- catch (e) { ui.sendError = e.message; }
- ui.lnSendBusy = false; render();
- }
-
- function lnSendReview() {
- const u = ' ' + unitLabel();
- const zap = ui.lnPayMeta; // set when a NIP-57 zap routed through here
- if (ui.lnSent) {
- return h('div', { class: 'col', style: 'gap:16px' },
- h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:8px' },
- h('div', { class: 'check-badge' }, '⚡'),
- h('h2', { style: 'margin:0' }, zap ? t('arkZapSentTitle') : t('lnPaySentTitle')),
- zap && zap.name ? h('div', { class: 'small muted' }, zap.name) : null,
- ui.lnSent.amount ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, fmtAmount(ui.lnSent.amount) + u) : null,
- h('p', { class: 'muted', style: 'margin:0' }, zap ? t('lnZapSentBody') : t('lnPaySentBody'))),
- h('button', { class: 'btn-primary btn-block', onClick: () => { ui.lnSent = null; ui.lnSend = null; ui.lnPayMeta = null; ui.send = blankSend(); ui.tab = 'history'; render(); } }, t('done')));
- }
- const q = ui.lnSend;
- const row = (label, sats, bold) => h('div', { class: 'row', style: 'justify-content:space-between' + (bold ? ';font-weight:600' : '') },
- h('span', { class: bold ? '' : 'small muted' }, label), h('span', {}, fmtAmount(sats) + u));
- return h('div', { class: 'col', style: 'gap:12px' },
- h('div', { class: 'card col', style: 'gap:10px' },
- h('h3', { style: 'margin:0' }, zap ? '⚡ ' + t('lnZapReviewTitle') : t('lnPayTitle')),
- zap && (zap.name || zap.address) ? h('div', { class: 'small muted', style: 'word-break:break-all' }, zap.name || zap.address) : null,
- zap && zap.comment ? h('div', { class: 'small faint', style: 'font-style:italic;word-break:break-word' }, '“' + zap.comment + '”') : null,
- q.invoiceAmount != null ? row(t('lnPayAmount'), q.invoiceAmount) : null,
- q.boltzFee != null ? row(t('lnPayBoltzFee'), q.boltzFee) : null,
- row(t('lnPayNetworkFee'), q.networkFee),
- h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }),
- row(t('lnPayTotal'), q.total, true)),
- ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null,
- h('button', { class: 'btn-primary btn-block', disabled: !!ui.lnSendBusy, onClick: doLnPay }, ui.lnSendBusy ? h('span', { class: 'spinner' }) : t('lnPayConfirm')),
- h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnSend = null; ui.lnPayMeta = null; ui.sendError = ''; render(); } }, t('back')));
- }
-
- return {
- id: 'swaps',
- // wallet activated: re-attach in-flight swap watchers
- init() {
- swaps.network = getNetwork();
- if (!wallet.watchOnly) { try { swaps.resumeAll(); } catch {} }
- },
- networkChanged(net) { swaps.network = net; ui.swapLimits = null; },
- receiveModes() {
- // a reverse swap needs our keys to claim on-chain
- if (wallet.watchOnly) return [];
- return [{
- id: 'ln', label: t('receiveLnTab'), icon: '⚡',
- render: (seg) => h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, seg, ...lnReceiveContent()),
- }];
- },
- // a pasted/typed/scanned bolt11 auto-advances to its own confirmation
- matchSendText(text) {
- const inv = (text || '').trim().replace(/^lightning:/i, '');
- if (ui.send.recipients.length === 1 && !ui.lnSendBusy && !ui.lnSend && isLnInvoice(inv)) {
- startLnSend(inv);
- return true;
- }
- return false;
- },
- interceptReview(s) {
- const target = s.recipients.length === 1 ? (s.recipients[0].address || '').trim().replace(/^lightning:/i, '') : '';
- if (isLnInvoice(target)) { startLnSend(target); return true; }
- return false;
- },
- sendView() {
- if (ui.lnSent || ui.lnSend) return lnSendReview();
- if (ui.lnSendBusy) return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' }, h('span', { class: 'spinner' }), h('p', { class: 'muted', style: 'margin:0' }, t('lnQuoting')));
- return null;
- },
- txDetailSection(tx) {
- const swap = swaps.findByTxid(tx.txid);
- return swap ? swapTxDetailSection(swap, tx) : null;
- },
- findByTxid(txid) { return swaps.findByTxid(txid); },
- settingsCards() { return [boltzProviderCard()]; },
- // ---- cross-feature seam (the zaps feature pays a resolved bolt11) ----
- // True when Lightning payment is possible from this wallet/build — the
- // zaps feature checks this before offering a Lightning zap.
- canLnPay() { return !wallet.watchOnly; },
- // Pay a bolt11 that another feature resolved (e.g. a NIP-57 zap), reusing
- // this feature's quote → review → confirm → success flow. `meta` decorates
- // the review as a zap (recipient name, comment). Returns true so the hook
- // dispatcher stops here.
- startLnPay(invoice, meta) { startLnSend(invoice, meta || null); return true; },
- // The ark feature falls back here when the ark balance can't cover an
- // invoice after fees — skipArk avoids bouncing back and forth.
- startLnPayBoltz(invoice, meta) { startLnSend(invoice, meta || null, true); return true; },
- };
-}
diff --git a/src/i18n.js b/src/i18n.js
index 327bf01..4a05936 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -130,7 +130,7 @@ const STR = {
hiwBasics4: 'You can even save this page and open it straight from your computer with no internet at all. It keeps working offline.',
hiwBasics5: 'It follows the usual Bitcoin standards (a 12-word BIP39 phrase and BIP84 native SegWit keys), so if this site ever disappears you can restore the same phrase in Sparrow, Electrum, or any other standard wallet and your coins will be right there.',
hiwLnTitle: 'Lightning',
- hiwLn1: 'You can pay Lightning invoices and receive Lightning payments. Behind the scenes a swap provider exchanges between your on-chain coins and Lightning without ever holding your money — if a swap fails, the coins come back to you. A small swap fee applies, shown before you confirm.',
+ hiwLn1: 'You can pay Lightning invoices and receive Lightning payments. They run over your Ark balance: the Ark server exchanges with the Lightning network without ever holding your money — if a payment fails, the coins come back to you. A small fee applies, shown before you confirm.',
hiwArkTitle: 'Ark',
hiwArk1: 'Ark is a newer way to pay off-chain: sends arrive instantly, cost no mining fees, and stay self-custodial. You "board" coins from your on-chain balance into an Ark server, pay and receive freely, and move funds back on-chain whenever you like.',
hiwArk2: 'The server can never take your money: every coin you hold in the Ark comes with a pre-signed escape route, and the wallet can put it back on-chain even if the server disappears (“Exit on-chain without the server” in Settings — it takes a while and needs a little on-chain balance for fees, but it needs nobody’s permission).',
@@ -254,11 +254,6 @@ const STR = {
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.',
@@ -359,8 +354,6 @@ const STR = {
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: 'Bitcoin address',
receiveLnTab: 'Lightning',
receiveSpTab: 'Silent payment',
@@ -444,13 +437,6 @@ const STR = {
nwcOff: 'Off',
nwcBackgroundOn: 'Requests will be answered in the background',
nwcBackgroundOff: 'Background answering turned off',
- nwcPouch: 'Pouch (spendable while closed)',
- nwcPouchDesc: 'Coins in the pouch can be spent by the background responder without opening Hal. Keep it small — top it up as it drains.',
- nwcPouchFund: 'Add to pouch',
- nwcPouchEmpty: 'Empty pouch',
- nwcPouchFunded: 'Moved {sats} sats into the pouch',
- nwcPouchEmptied: 'Moved {sats} sats back to the wallet',
- nwcPouchAmount: 'Amount (sats)',
nwcAppName: 'App name (e.g. Damus)',
nwcMaxPayment: 'Max per payment (sats)',
nwcDailyBudget: 'Daily budget (sats)',
@@ -460,13 +446,6 @@ const STR = {
nwcRevoke: 'Revoke',
nwcRevoked: 'Revoked {name}',
nwcLimits: 'Max {max}/payment · {day}/day · {spent} spent today',
- arkBridgeTitle: 'Lightning swap bridge',
- arkBridgeDesc: 'Optional third-party service that pays Lightning invoices from your Ark balance, usually cheaper than the Ark server itself. Trustless: your sats are locked in a hash contract the bridge can only take by actually paying your invoice.',
- arkBridgeUrl: 'Bridge URL',
- arkBridgeToken: 'Access token',
- arkBridgeTokenPh: 'Leave blank if the bridge is open',
- arkBridgeTrust: 'If the bridge fails to pay, your sats come straight back — it can never take them without settling your invoice.',
- arkLnBridgeFee: 'Bridge fee',
arkLnFundsPending: 'Your Ark funds are settling from a previous payment — retrying…',
lnZapOffer: 'Zap {addr}',
arkLnInvoiceIntro: 'Or receive over Lightning, straight into your Ark balance.',
@@ -514,15 +493,6 @@ const STR = {
lnZapHintPlain: 'Sent over Lightning (this address doesn’t support zap receipts).',
lnZapSentBody: 'Your zap is on its way — the swap provider pays it once the on-chain lockup confirms.',
lnZapFailed: 'Couldn’t complete the zap.',
- 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/nwc-bg.js b/src/nwc-bg.js
new file mode 100644
index 0000000..f539e45
--- /dev/null
+++ b/src/nwc-bg.js
@@ -0,0 +1,117 @@
+// Background NWC spending state — what the service worker answers (and pays)
+// from while the app is closed.
+//
+// The record mirrors the wallet's REAL ark state: its vtxos (with the signed
+// bytes), the chain-3 private keys for exactly those coins plus a window of
+// upcoming indices for change, the NWC connections' service keys, and the
+// worker's spend log. The seed itself never leaves localStorage.
+//
+// THREAT MODEL, stated plainly. Anything with same-origin script access, or
+// a stolen unlocked device, can read IndexedDB and therefore spend the ark
+// balance. That is the accepted cost of a wallet that answers while closed —
+// the ark balance is treated as walking-around money, and the on-chain wallet
+// (whose keys are never mirrored) stays out of reach. Per-connection NWC
+// budgets are enforced by the worker before it pays. A user who does not
+// enable background answering has no record here and loses nothing.
+
+const DB = 'hal-nwc-pouch'; // name kept from the pouch era so upgrades are seamless
+const STORE = 'pouch';
+const VERSION = 1;
+
+function openDb() {
+ return new Promise((resolve, reject) => {
+ if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable'));
+ const req = indexedDB.open(DB, VERSION);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+async function tx(mode, fn) {
+ const db = await openDb();
+ try {
+ return await new Promise((resolve, reject) => {
+ const t = db.transaction(STORE, mode);
+ const s = t.objectStore(STORE);
+ let out;
+ Promise.resolve(fn(s)).then((v) => { out = v; }).catch(reject);
+ t.oncomplete = () => resolve(out);
+ t.onerror = () => reject(t.error);
+ t.onabort = () => reject(t.error);
+ });
+ } finally { db.close(); }
+}
+const get = (s, k) => new Promise((res, rej) => { const r = s.get(k); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
+
+// One record per wallet, keyed by the wallet's cache key.
+export async function loadBg(walletKey) {
+ try { return (await tx('readonly', (s) => get(s, walletKey))) || null; } catch { return null; }
+}
+export async function saveBg(walletKey, rec) {
+ return tx('readwrite', (s) => { s.put(rec, walletKey); });
+}
+export async function clearBg(walletKey) {
+ return tx('readwrite', (s) => { s.delete(walletKey); });
+}
+// The service worker doesn't know wallet cache keys — it finds the right
+// record by the connection's service pubkey across all of them.
+export async function allBgs() {
+ try {
+ return await tx('readonly', (s) => new Promise((res, rej) => {
+ const out = [];
+ const cur = s.openCursor();
+ cur.onsuccess = () => {
+ const c = cur.result;
+ if (!c) return res(out);
+ out.push({ walletKey: c.key, rec: c.value });
+ c.continue();
+ };
+ cur.onerror = () => rej(cur.error);
+ }));
+ } catch { return []; }
+}
+
+// How many upcoming change/refund indices travel with the record: the worker
+// cannot derive keys, so its allocations must land inside this window.
+export const BG_KEY_LOOKAHEAD = 25;
+
+// { ark } endpoints, the manager state to mirror, and a keyFor(index) derive
+// function (the caller owns the account node — it never enters this module).
+export function buildBg({ ark, mgrState, keyFor, connections }) {
+ const keys = {};
+ const idx = new Set((mgrState.vtxos || []).filter((v) => v.state !== 'spent').map((v) => v.keyIndex));
+ for (let i = 0; i < BG_KEY_LOOKAHEAD; i++) idx.add((mgrState.nextKeyIndex || 1) + i);
+ idx.add(0); // the receive-address key: mailbox/refund defaults land on it
+ for (const i of idx) keys[String(i)] = keyFor(i);
+ return {
+ v: 3,
+ updated: Date.now(),
+ ark,
+ mgr: mgrState,
+ keys,
+ connections: (connections || []).map((c) => ({
+ id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
+ maxSat: c.maxSat, dailySat: c.dailySat,
+ })),
+ };
+}
+
+export const bgSpendableSat = (rec) =>
+ ((rec && rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable')
+ .reduce((n, v) => n + (v.amountSat || 0), 0);
+
+// The worker records what it spent so the app can absorb it into budgets and
+// history on next open.
+export async function noteBgSpend(walletKey, spend) {
+ const rec = await loadBg(walletKey);
+ if (!rec) return null;
+ rec.spends = rec.spends || [];
+ rec.spends.push({ ...spend, ts: Date.now() });
+ if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200);
+ await saveBg(walletKey, rec);
+ return rec;
+}
diff --git a/src/nwc-pouch.js b/src/nwc-pouch.js
deleted file mode 100644
index 187ab7a..0000000
--- a/src/nwc-pouch.js
+++ /dev/null
@@ -1,148 +0,0 @@
-// The NWC "pouch": a small, bounded spending capability that a service
-// worker can use while the wallet itself stays out of reach.
-//
-// WHY THIS EXISTS
-// hal keeps the seed in localStorage, which service workers cannot read — by
-// design, and we are not changing that. But answering a wallet-connect
-// request while the app is closed means signing in a service worker. So
-// instead of exposing the wallet, we carve off a deliberately small pouch:
-//
-// * its own key branch (account chain 6), never used for ordinary coins
-// * only the pouch's PRIVATE KEYS and vtxo bytes go into IndexedDB, which
-// the service worker can read — never the seed, never chain 3
-// * it holds only what the user puts in it
-//
-// THREAT MODEL, stated plainly. Anything with same-origin script access, or
-// a stolen unlocked device, can read IndexedDB and therefore spend the pouch.
-// That is the accepted cost of answering while closed. It is bounded: the
-// pouch is the blast radius, the rest of the wallet is not reachable from it.
-// A user who does not enable background NWC has no pouch and loses nothing.
-//
-// This module owns the storage and key derivation only. Funding it is an
-// ordinary arkoor send from the main wallet (see the ark feature); spending
-// from it lives in the service worker (step 3).
-
-import { hex } from '@scure/base';
-import { secp256k1 } from '@noble/curves/secp256k1';
-
-const DB = 'hal-nwc-pouch';
-const STORE = 'pouch';
-const VERSION = 1;
-
-// Chain 6 is the pouch. Chains 0-2 are hal's on-chain use, 3 ark vtxo keys,
-// 4 the ark mailbox, 5 lightning-receive preimages — 6 must stay distinct so
-// a leaked pouch key can never sign for an ordinary coin.
-export const POUCH_CHAIN = 6;
-
-export function pouchKey(account, index) {
- const node = account.deriveChild(POUCH_CHAIN).deriveChild(index);
- return { privkey: node.privateKey, pubkey: secp256k1.getPublicKey(node.privateKey, true) };
-}
-
-function openDb() {
- return new Promise((resolve, reject) => {
- if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable'));
- const req = indexedDB.open(DB, VERSION);
- req.onupgradeneeded = () => {
- const db = req.result;
- if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
- };
- req.onsuccess = () => resolve(req.result);
- req.onerror = () => reject(req.error);
- });
-}
-
-async function tx(mode, fn) {
- const db = await openDb();
- try {
- return await new Promise((resolve, reject) => {
- const t = db.transaction(STORE, mode);
- const s = t.objectStore(STORE);
- let out;
- Promise.resolve(fn(s)).then((v) => { out = v; }).catch(reject);
- t.oncomplete = () => resolve(out);
- t.onerror = () => reject(t.error);
- t.onabort = () => reject(t.error);
- });
- } finally { db.close(); }
-}
-const get = (s, k) => new Promise((res, rej) => { const r = s.get(k); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
-
-// One record per wallet, keyed by the wallet's cache key so several wallets
-// on one device keep separate pouches.
-export async function loadPouch(walletKey) {
- try { return (await tx('readonly', (s) => get(s, walletKey))) || null; } catch { return null; }
-}
-// The service worker doesn't know wallet cache keys — it finds the right
-// pouch by the connection's service pubkey across all of them.
-export async function allPouches() {
- try {
- return await tx('readonly', (s) => new Promise((res, rej) => {
- const out = [];
- const cur = s.openCursor();
- cur.onsuccess = () => {
- const c = cur.result;
- if (!c) return res(out);
- out.push({ walletKey: c.key, pouch: c.value });
- c.continue();
- };
- cur.onerror = () => rej(cur.error);
- }));
- } catch { return []; }
-}
-export async function savePouch(walletKey, pouch) {
- return tx('readwrite', (s) => { s.put(pouch, walletKey); });
-}
-export async function clearPouch(walletKey) {
- return tx('readwrite', (s) => { s.delete(walletKey); });
-}
-
-// The worker can't derive keys (no seed), so the pouch carries a fixed window
-// of pre-derived chain-6 keys: funding uses low indices, the worker's change
-// and HTLC-refund outputs allocate upward from POUCH_CHANGE_BASE.
-export const POUCH_KEY_WINDOW = 50;
-export const POUCH_CHANGE_BASE = 20;
-
-// Everything the service worker needs to spend, and nothing more: the ASP
-// endpoints, the bridge, the pouch vtxos with their bytes, and the private
-// keys for exactly the pouch's key window.
-export function buildPouch({ arkUrl, esploraUrl, network, serverPubkey, bridge, vtxos, account, connections }) {
- const keys = {};
- for (let i = 0; i < POUCH_KEY_WINDOW; i++) {
- keys[String(i)] = hex.encode(pouchKey(account, i).privkey);
- }
- return {
- v: 2,
- updated: Date.now(),
- ark: { arkUrl, esploraUrl, network, serverPubkey },
- bridge: bridge || null, // { url, token } — the worker pays through this
- // [{ id, bytes, keyIndex, amountSat, expiryHeight }]
- vtxos: vtxos.map((v) => ({
- id: v.id, bytes: v.bytes, keyIndex: v.keyIndex,
- amountSat: v.amountSat, expiryHeight: v.expiryHeight,
- })),
- keys,
- nextKeyIndex: POUCH_CHANGE_BASE,
- // service keys for the connections the SW may answer for, so it can
- // decrypt requests and sign replies without the wallet
- connections: (connections || []).map((c) => ({
- id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
- maxSat: c.maxSat, dailySat: c.dailySat,
- })),
- };
-}
-
-export const pouchBalance = (pouch) =>
- (pouch?.vtxos || []).reduce((n, v) => n + (v.amountSat || 0), 0);
-
-// The service worker records what it spent so the app can reconcile and the
-// daily budget survives a restart.
-export async function notePouchSpend(walletKey, { connId, amountSat, paymentHash, preimage }) {
- const p = await loadPouch(walletKey);
- if (!p) return null;
- p.spends = p.spends || [];
- p.spends.push({ connId, amountSat, paymentHash, preimage, ts: Date.now() });
- if (p.spends.length > 200) p.spends = p.spends.slice(-200);
- await savePouch(walletKey, p);
- return p;
-}
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index e7bb716..c0897d1 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -1,28 +1,28 @@
-// NWC auto-answer from the pouch — the half of background wallet-connect
-// that pays without a tap. Runs inside the service worker's push handler:
-// the push payload carries the encrypted request event, the pouch (IndexedDB)
-// carries the connection service keys and a bounded set of coins, and the
-// reply goes back through the notifier's /publish endpoint — no WebSocket,
-// no seed, nothing outside the pouch's blast radius.
+// NWC auto-answer while the app is closed. Runs inside the service worker's
+// push handler: the push payload carries the encrypted request event, the
+// background record (IndexedDB) carries the connection service keys and a
+// mirror of the wallet's ark state, and the reply goes back through the
+// notifier's /publish endpoint — no WebSocket, no seed.
//
// Trust: the notifier transports ciphertext both ways. Requests arrive
// nip04/nip44-encrypted and signed by the client; replies leave encrypted and
// signed by the per-connection service key. The notifier can neither read nor
-// forge either. Spending happens directly against the ASP/bridge over fetch.
+// forge either. Payments run the ASP's own Lightning send directly over
+// fetch — the same path the open app uses.
//
// Anything this module can't handle returns false, and the caller falls back
-// to today's behavior: a notification that opens the wallet.
+// to a notification that opens the wallet.
import { hex } from '@scure/base';
-import { sha256 } from '@noble/hashes/sha256';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent } from 'nostr-tools/pure';
import { ArkManager } from './ark/manager.js';
import { maybeBolt11 } from './ark/lightning.js';
-import { allPouches, savePouch, POUCH_KEY_WINDOW, POUCH_CHANGE_BASE } from './nwc-pouch.js';
+import { allBgs, saveBg } from './nwc-bg.js';
const MAX_AGE_SEC = 120;
+const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin
const nowSec = () => Math.floor(Date.now() / 1000);
const errRes = (t, code, message) => ({ result_type: t, error: { code, message } });
@@ -42,24 +42,15 @@ async function buildReply(conn, ev, scheme, payload) {
}, sk);
}
-const seedState = (pouch) => ({
- v: 1,
- serverPubkey: pouch.ark.serverPubkey || null,
- mailboxCheckpoint: 0,
- nextKeyIndex: pouch.nextKeyIndex || POUCH_CHANGE_BASE,
- vtxos: (pouch.vtxos || []).map((v) => ({ ...v, state: v.state || 'spendable' })),
- actions: [],
- movements: [],
-});
-
-const spendableVtxos = (pouch) =>
- ((pouch.mgr && pouch.mgr.vtxos) || seedState(pouch).vtxos).filter((v) => v.state === 'spendable');
+const spendableSat = (rec) =>
+ ((rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable')
+ .reduce((n, v) => n + (v.amountSat || 0), 0);
// data: the push payload { type:'nwc', servicePubkey, event }. Returns true
// when the request was fully dealt with (reply published or deliberately
// left to another device); false means "wake the user instead".
-export async function respondFromPouch(data, {
- notifier, fetchFn = fetch, pouchesFn = allPouches, saveFn = savePouch, log = () => {},
+export async function respondFromBg(data, {
+ notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {},
} = {}) {
const ev = data && data.event;
if (!ev || ev.kind !== 23194 || !ev.id || !ev.pubkey) return false;
@@ -67,10 +58,10 @@ export async function respondFromPouch(data, {
const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1];
if (!target) return false;
- let walletKey = null, pouch = null, conn = null;
- for (const p of await pouchesFn()) {
- const c = (p.pouch.connections || []).find((x) => x.servicePk === target);
- if (c) { walletKey = p.walletKey; pouch = p.pouch; conn = c; break; }
+ let walletKey = null, rec = null, conn = null;
+ for (const r of await recordsFn()) {
+ const c = (r.rec.connections || []).find((x) => x.servicePk === target);
+ if (c && r.rec.v >= 3) { walletKey = r.walletKey; rec = r.rec; conn = c; break; }
}
if (!conn) return false;
if (ev.pubkey !== conn.clientPk) return false;
@@ -92,7 +83,7 @@ export async function respondFromPouch(data, {
};
// Another device (an open tab elsewhere) may have answered already — a
// paid zap must never be shouted over with a late error, and paying twice
- // wastes a lock cycle. The notifier tracks recent reply e-tags.
+ // wastes funds-in-flight. The notifier tracks recent reply e-tags.
const answered = async () => {
try {
const r = await fetchFn(`${notifier}/answered?event=${ev.id}`);
@@ -105,7 +96,7 @@ export async function respondFromPouch(data, {
result_type: 'get_info',
result: {
alias: 'Hal', color: '#f7931a',
- network: pouch.ark.network === 'mainnet' ? 'mainnet' : pouch.ark.network,
+ network: rec.ark.network === 'mainnet' ? 'mainnet' : rec.ark.network,
block_height: 0, block_hash: '',
methods: ['get_info', 'get_balance', 'pay_invoice', 'list_transactions'],
},
@@ -113,12 +104,11 @@ export async function respondFromPouch(data, {
return true;
}
if (method === 'get_balance') {
- const sat = spendableVtxos(pouch).reduce((n, v) => n + (v.amountSat || 0), 0);
- await publish({ result_type: 'get_balance', result: { balance: sat * 1000 } });
+ await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } });
return true;
}
if (method === 'list_transactions') {
- const txs = (pouch.spends || []).slice(-(params.limit || 20)).map((s) => ({
+ const txs = (rec.spends || []).slice(-(params.limit || 20)).map((s) => ({
type: 'outgoing', invoice: s.invoice || '', preimage: s.preimage || '',
amount: (s.amountSat || 0) * 1000, fees_paid: (s.feeSat || 0) * 1000,
created_at: Math.floor((s.ts || Date.now()) / 1000),
@@ -145,101 +135,76 @@ export async function respondFromPouch(data, {
return true;
}
const today = new Date().toISOString().slice(0, 10);
- const spentToday = (pouch.spends || [])
+ const spentToday = (rec.spends || [])
.filter((s) => s.connId === conn.id && new Date(s.ts).toISOString().slice(0, 10) === today)
.reduce((n, s) => n + (s.amountSat || 0) + (s.feeSat || 0), 0);
if (spentToday + dec.amountSat > conn.dailySat) {
await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining daily budget (${Math.max(0, conn.dailySat - spentToday)} sat)`));
return true;
}
+ if (spendableSat(rec) < dec.amountSat) return false; // stale/empty mirror: wake the user
- // The worker only pays through the bridge (one HTLC lock, one HTTP swap).
- // No bridge, no coins, or an exhausted key window → wake the user instead.
- if (!pouch.bridge || !pouch.bridge.url) return false;
- const nextIdx = (pouch.mgr && pouch.mgr.nextKeyIndex) || pouch.nextKeyIndex || POUCH_CHANGE_BASE;
- if (nextIdx >= POUCH_KEY_WINDOW - 1) { log('pouch key window exhausted'); return false; }
-
- const auth = pouch.bridge.token ? { authorization: `Bearer ${pouch.bridge.token}` } : {};
- let quote;
- try {
- const r = await fetchFn(`${pouch.bridge.url}/quote?invoice=${encodeURIComponent(invoice)}`, { headers: auth });
- quote = await r.json();
- if (!r.ok || quote.error) throw new Error(quote.error || `quote failed (${r.status})`);
- } catch (e) { log('quote failed: ' + e.message); return false; }
- if (!spendableVtxos(pouch).some((v) => v.amountSat >= quote.totalSat)) {
- log('pouch cannot cover ' + quote.totalSat);
- return false; // underfunded pouch: the user should top up — notify
- }
-
+ // The ASP's own Lightning send, over the mirrored state. Change and HTLC
+ // outputs allocate from nextKeyIndex, which must stay inside the key
+ // window the app pre-derived; if it ran out, wake the user instead.
+ if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; }
const storage = {
- load: () => pouch.mgr || seedState(pouch),
- save: (s) => { pouch.mgr = s; saveFn(walletKey, pouch).catch(() => {}); },
+ load: () => rec.mgr,
+ save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); },
};
- const keyShim = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(pouch.keys[String(i)] || pouch.keys['0']) }) }) };
+ const keyShim = { deriveChild: () => ({ deriveChild: (i) => {
+ const k = rec.keys[String(i)];
+ if (!k) throw new Error(`key index ${i} outside the mirrored window`);
+ return { privateKey: hex.decode(k) };
+ } }) };
let mgr;
try {
mgr = await new ArkManager({
account: keyShim, storage,
- arkUrl: pouch.ark.arkUrl, esploraUrl: pouch.ark.esploraUrl, network: pouch.ark.network,
+ arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network,
}).init();
} catch (e) { log('ark init failed: ' + e.message); return false; }
- let lock;
- try {
- lock = await mgr.htlcLock({
- amountSat: quote.totalSat, claimPubkey: quote.claimPubkey,
- paymentHash: quote.paymentHash, htlcExpiry: quote.htlcExpiry,
- });
- } catch (e) { log('htlc lock failed: ' + e.message); return false; }
-
- // Persist the swap BEFORE handing it to the bridge: if we die mid-flight,
- // the app finds the record on next open and runs its refund machinery.
- pouch.swaps = pouch.swaps || [];
- const rec = {
- paymentHash: quote.paymentHash, invoice, bridgeUrl: pouch.bridge.url,
- amountSat: quote.amountSat, feeSat: quote.feeSat,
- htlcVtxo: lock.htlcVtxos[0], refundIndex: lock.refundIndex,
- htlcExpiry: quote.htlcExpiry, step: 'submitted', ts: Date.now(),
- };
- pouch.swaps.push(rec);
- await saveFn(walletKey, pouch);
-
- let res;
+ let id;
try {
- const r = await fetchFn(`${pouch.bridge.url}/swap`, {
- method: 'POST', headers: { 'content-type': 'application/json', ...auth },
- body: JSON.stringify({ invoice, htlcVtxo: lock.htlcVtxos[0] }),
- });
- res = await r.json();
- if (!r.ok || res.error) throw new Error(res.error || `bridge error (${r.status})`);
- if (res.step !== 'done') throw new Error(res.error || 'bridge did not pay');
- if (!res.preimage || hex.encode(sha256(hex.decode(res.preimage))) !== quote.paymentHash) {
- throw new Error('bridge returned an invalid preimage');
- }
+ id = await mgr.payLnInvoice(invoice);
} catch (e) {
- rec.step = 'refundable';
- rec.error = e.message;
- await saveFn(walletKey, pouch);
- // the winner may have paid while we were locking — never shout over it
if (!(await answered())) {
await publish(errRes('pay_invoice', 'INTERNAL', e.message)).catch(() => {});
}
return true;
}
+ const deadline = Date.now() + PAY_WAIT_MS;
+ let a = mgr.lnAction(id);
+ while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 1500));
+ await mgr.driveLn(id).catch(() => {});
+ a = mgr.lnAction(id);
+ }
+ if (!a || a.step === 'failed') {
+ if (!(await answered())) {
+ await publish(errRes('pay_invoice', 'INTERNAL', (a && a.error) || 'payment failed')).catch(() => {});
+ }
+ return true;
+ }
+ if (a.step !== 'done') {
+ // still in flight as our budget runs out: the app resumes it from the
+ // mirrored state on next open — wake the user rather than guess.
+ log('payment still pending at SW deadline');
+ return false;
+ }
- rec.step = 'done';
- rec.preimage = res.preimage;
- pouch.spends = pouch.spends || [];
- pouch.spends.push({
- connId: conn.id, amountSat: quote.amountSat, feeSat: quote.feeSat,
- paymentHash: quote.paymentHash, preimage: res.preimage, invoice, ts: Date.now(),
+ rec.spends = rec.spends || [];
+ rec.spends.push({
+ connId: conn.id, amountSat: a.amountSat || dec.amountSat, feeSat: a.feeSat || 0,
+ paymentHash: dec.paymentHash, preimage: a.preimage, invoice, ts: Date.now(),
});
- if (pouch.spends.length > 200) pouch.spends = pouch.spends.slice(-200);
- await saveFn(walletKey, pouch);
+ if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200);
+ await saveFn(walletKey, rec);
await publish({
result_type: 'pay_invoice',
- result: { preimage: res.preimage, fees_paid: (quote.feeSat || 0) * 1000 },
+ result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 },
});
- log(`paid ${quote.amountSat} sat via bridge from the pouch`);
+ log(`paid ${a.amountSat || dec.amountSat} sat via the ASP from the background mirror`);
return true;
}
diff --git a/src/sp-worker.js b/src/sp-worker.js
deleted file mode 100644
index 59a1769..0000000
--- a/src/sp-worker.js
+++ /dev/null
@@ -1,69 +0,0 @@
-// 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/sw-nwc.js b/src/sw-nwc.js
index db57b99..8267b6b 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -1,9 +1,10 @@
// Service-worker side of background NWC. Bundled by build.js and appended to
// the generated sw.js, replacing the notify-only push handler: a push now
-// first tries to ANSWER the request from the pouch (see nwc-respond.js), and
-// only falls back to the wake-the-user notification when it can't.
+// first tries to ANSWER the request from the background state mirror (see
+// nwc-respond.js), and only falls back to the wake-the-user notification
+// when it can't.
-import { respondFromPouch } from './nwc-respond.js';
+import { respondFromBg } from './nwc-respond.js';
const NOTIFIER = 'https://nwcpush.coinos.io';
@@ -21,7 +22,7 @@ self.addEventListener('push', (e) => {
}
let handled = false;
try {
- handled = await respondFromPouch(data, {
+ handled = await respondFromBg(data, {
notifier: NOTIFIER,
log: (m) => console.log('[sw-nwc]', m),
});
diff --git a/src/swap.js b/src/swap.js
deleted file mode 100644
index 7aec8cd..0000000
--- a/src/swap.js
+++ /dev/null
@@ -1,459 +0,0 @@
-// Boltz submarine + reverse swap support for Hal — fully client-side and
-// non-custodial. Hal holds the swap key + preimage and can always claim
-// (reverse) or refund (submarine) on-chain via the Taproot SCRIPT PATH, so an
-// uncooperative/offline Boltz can never strand funds. The cooperative MuSig2
-// key-path spend (smaller witness) is a future optimization; correctness here
-// relies only on the script path.
-//
-// Tapscript byte layouts and the two-leaf tree are replicated exactly from
-// boltz-core (src/bitcoin/scripts/{swap_tree,reverse_tree,tree}.rs) and unit-
-// tested against its published test vectors (see tools/swap-vectors.test.js).
-
-import * as btc from '@scure/btc-signer';
-import { p2tr, taprootListToTree, tapLeafHash as scureTapLeafHash } from '@scure/btc-signer/payment';
-import { keyAggregate, keyAggExport, sortKeys } from '@scure/btc-signer/musig2';
-import { schnorr } from '@noble/curves/secp256k1';
-import { sha256 } from '@noble/hashes/sha2';
-import { ripemd160 } from '@noble/hashes/legacy';
-import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils';
-import { bech32 } from '@scure/base';
-
-export { hexToBytes, bytesToHex };
-
-// Regtest network object (same version bytes as testnet, `bcrt` HRP).
-export const REGTEST = { ...btc.TEST_NETWORK, bech32: 'bcrt' };
-// mutinynet is a signet — same address params as testnet (tb1…), matching Boltz's
-// bitcoinSignet. Without this entry netFor() falls back to mainnet and the swap
-// P2TR address is computed wrong → "lockup address mismatch".
-const NETS = { mainnet: btc.NETWORK, testnet: btc.TEST_NETWORK, signet: btc.TEST_NETWORK, mutinynet: btc.TEST_NETWORK, regtest: REGTEST };
-export const netFor = (name) => NETS[name] || btc.NETWORK;
-
-const TAP_LEAF_VERSION = 0xc0;
-
-export const hash160 = (b) => ripemd160(sha256(b));
-const sha256d = (b) => sha256(b);
-
-// BIP340/341 tagged hash: sha256(sha256(tag) || sha256(tag) || msg).
-function taggedHash(tag, ...msgs) {
- const tagHash = sha256(new TextEncoder().encode(tag));
- return sha256(concatBytes(tagHash, tagHash, ...msgs));
-}
-
-// Minimal CScriptNum push of a non-negative locktime, as bitcoin's
-// script::Builder::push_lock_time / push_int produces (used in the refund leaf).
-function pushScriptNum(n) {
- if (n === 0) return Uint8Array.of(0x00); // OP_0
- if (n >= 1 && n <= 16) return Uint8Array.of(0x50 + n); // OP_1..OP_16
- const out = [];
- let v = n;
- while (v > 0) { out.push(v & 0xff); v >>= 8; }
- if (out[out.length - 1] & 0x80) out.push(0x00); // sign byte for positives
- return concatBytes(Uint8Array.of(out.length), Uint8Array.from(out));
-}
-
-// data push of a byte slice with its OP_PUSHBYTES_N length prefix (slices here
-// are always < 76 bytes: a 20-byte hash160 or a 32-byte x-only key).
-const pushData = (b) => concatBytes(Uint8Array.of(b.length), b);
-
-const OP = { HASH160: 0xa9, EQUALVERIFY: 0x88, CHECKSIG: 0xac, CHECKSIGVERIFY: 0xad, CLTV: 0xb1, SIZE: 0x82 };
-
-// Submarine claim leaf: OP_HASH160 <h160> OP_EQUALVERIFY <claimX> OP_CHECKSIG
-export function submarineClaimLeaf(preimageHash160, claimXonly) {
- return concatBytes(
- Uint8Array.of(OP.HASH160), pushData(preimageHash160), Uint8Array.of(OP.EQUALVERIFY),
- pushData(claimXonly), Uint8Array.of(OP.CHECKSIG),
- );
-}
-
-// Reverse claim leaf: OP_SIZE <32> OP_EQUALVERIFY OP_HASH160 <h160> OP_EQUALVERIFY <claimX> OP_CHECKSIG
-export function reverseClaimLeaf(preimageHash160, claimXonly) {
- return concatBytes(
- Uint8Array.of(OP.SIZE), pushData(Uint8Array.of(32)), Uint8Array.of(OP.EQUALVERIFY),
- Uint8Array.of(OP.HASH160), pushData(preimageHash160), Uint8Array.of(OP.EQUALVERIFY),
- pushData(claimXonly), Uint8Array.of(OP.CHECKSIG),
- );
-}
-
-// Refund leaf (both kinds): <refundX> OP_CHECKSIGVERIFY <locktime> OP_CLTV
-export function refundLeaf(refundXonly, lockTime) {
- return concatBytes(
- pushData(refundXonly), Uint8Array.of(OP.CHECKSIGVERIFY),
- pushScriptNum(lockTime), Uint8Array.of(OP.CLTV),
- );
-}
-
-// Use @scure's BIP341 leaf hash (handles the compact-size encoding + tag).
-export function tapLeafHash(script) {
- return scureTapLeafHash(script, TAP_LEAF_VERSION);
-}
-
-// Two-leaf Taproot merkle root: TapBranch of the lexicographically-sorted
-// (claim, refund) leaf hashes (BIP341).
-export function swapMerkleRoot(claimLeafScript, refundLeafScript) {
- const a = tapLeafHash(claimLeafScript);
- const b = tapLeafHash(refundLeafScript);
- const [lo, hi] = compareBytes(a, b) <= 0 ? [a, b] : [b, a];
- return taggedHash('TapBranch', lo, hi);
-}
-
-function compareBytes(a, b) {
- for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return a[i] - b[i]; }
- return 0;
-}
-
-// Build the full swap tree for a given kind from Hal's own inputs. Returns the
-// leaf scripts + merkle root so the caller can verify Boltz's lockup address
-// and later construct a script-path spend.
-export function buildSwapTree(kind, preimageHash160, claimXonly, refundXonly, lockTime) {
- const claim = kind === 'reverse'
- ? reverseClaimLeaf(preimageHash160, claimXonly)
- : submarineClaimLeaf(preimageHash160, claimXonly);
- const refund = refundLeaf(refundXonly, lockTime);
- return { claimLeaf: claim, refundLeaf: refund, merkleRoot: swapMerkleRoot(claim, refund) };
-}
-
-// Taproot internal key = MuSig2 aggregate of the two 33-byte pubkeys, x-only,
-// in the FIXED order [boltzKey, halKey] (no sort). Boltz builds the musig as
-// Musig.create(ourPriv, [ourPub, theirPub]) without sorting, and "ours" is
-// always Boltz, so the address is agg([boltzPub, halPub]). Verified live
-// against Boltz's lockupAddress (6/6; sorted fails when key order differs).
-export function swapInternalKey(boltzPub33, halPub33) {
- return keyAggExport(keyAggregate([boltzPub33, halPub33]));
-}
-
-// Full swap P2TR for either flow. Roles by kind:
-// reverse — hal CLAIMS (preimage), Boltz REFUNDS → claim leaf = halKey, refund leaf = boltzKey
-// submarine — Boltz CLAIMS (preimage), hal REFUNDS → claim leaf = boltzKey, refund leaf = halKey
-// Internal key is always agg([boltzKey, halKey]). Pubkeys are 33-byte compressed.
-// Returns the address + @scure p2tr payment (for control blocks/spending) + leaves.
-export function swapP2TR({ kind, preimageHash160, boltzPub33, halPub33, lockTime, network = 'regtest' }) {
- const claimPub = kind === 'reverse' ? halPub33 : boltzPub33; // who can claim
- const refundPub = kind === 'reverse' ? boltzPub33 : halPub33; // who can refund
- const claim = kind === 'reverse'
- ? reverseClaimLeaf(preimageHash160, claimPub.slice(1))
- : submarineClaimLeaf(preimageHash160, claimPub.slice(1));
- const refund = refundLeaf(refundPub.slice(1), lockTime);
- const internalKey = swapInternalKey(boltzPub33, halPub33);
- const tree = taprootListToTree([{ script: claim }, { script: refund }]);
- const pay = p2tr(internalKey, tree, netFor(network), true);
- return { address: pay.address, script: pay.script, pay, internalKey, claimLeaf: claim, refundLeaf: refund };
-}
-
-const eqBytes = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
-
-// Build a signed taproot SCRIPT-PATH spend of a swap lockup output. This is the
-// non-custodial path that needs no Boltz cooperation:
-// reverse claim — witness [sig, preimage, claimLeaf, controlBlock]
-// submarine refund — witness [sig, refundLeaf, controlBlock], nLockTime=lockTime
-// `leaf` is the leaf script being spent; `preimage` is required for a claim and
-// omitted for a refund. Returns the raw signed tx hex ready to broadcast.
-export function buildSwapSpend({
- pay, leaf, halPriv, preimage = null,
- lockupTxid, lockupVout, lockupValue, destAddress,
- fee, lockTime = 0, network = 'regtest',
-}) {
- const net = netFor(network);
- const isRefund = preimage === null;
- // Find the @scure leaf entry (control block + script||version) for this leaf.
- const idx = pay.leaves.findIndex((l) => eqBytes(l.script, leaf));
- if (idx < 0) throw new Error('leaf not found in swap tree');
- const controlBlock = pay.leaves[idx].controlBlock;
-
- const tx = new btc.Transaction({ allowUnknownOutputs: true, version: 2 });
- tx.addInput({
- txid: lockupTxid, index: lockupVout,
- witnessUtxo: { script: pay.script, amount: BigInt(lockupValue) },
- tapLeafScript: [pay.tapLeafScript[idx]],
- tapInternalKey: pay.tapInternalKey,
- sequence: isRefund ? 0xfffffffd : 0xffffffff, // refund: non-final for CLTV
- });
- if (isRefund && lockTime) tx.lockTime = lockTime;
- tx.addOutputAddress(destAddress, BigInt(lockupValue - fee), net);
-
- // Sighash for the leaf (BIP341 script path, SIGHASH_DEFAULT) + schnorr sig.
- const sighash = tx.preimageWitnessV1(
- 0, [pay.script], btc.SigHash.DEFAULT, [BigInt(lockupValue)],
- undefined, leaf, TAP_LEAF_VERSION,
- );
- const sig = schnorr.sign(sighash, halPriv);
-
- // Assemble the witness manually (preimage inserted for a claim).
- const witness = isRefund
- ? [sig, leaf, controlBlock]
- : [sig, preimage, leaf, controlBlock];
- tx.updateInput(0, { finalScriptWitness: witness });
- return tx.hex;
-}
-
-// ---------------------------------------------------------------------------
-// Boltz api-v2 client + bolt11 decode + SwapManager (polling-based).
-// ---------------------------------------------------------------------------
-
-async function boltzReq(apiBase, path, body) {
- const opt = body
- ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
- : {};
- const r = await fetch(apiBase.replace(/\/+$/, '') + path, opt);
- const j = await r.json().catch(() => ({}));
- if (!r.ok) throw new Error(j?.error || `boltz ${path}: ${r.status}`);
- return j;
-}
-
-function convertBits(data, from, to) {
- let acc = 0, bits = 0; const out = []; const maxv = (1 << to) - 1;
- for (const v of data) { acc = (acc << from) | v; bits += from; while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); } }
- return Uint8Array.from(out);
-}
-
-// Decode the payment hash (+ amount in msat, best-effort) from a bolt11 invoice.
-export function decodeBolt11(invoice) {
- const dec = bech32.decode(invoice.toLowerCase(), 4000);
- const words = dec.words;
- const m = dec.prefix.match(/^ln[a-z]+?(\d+)([munp])?$/);
- let amountMsat = null;
- if (m && m[1]) {
- const n = BigInt(m[1]);
- if (m[2] === 'p') amountMsat = n / 10n;
- else amountMsat = n * ({ m: 100000000n, u: 100000n, n: 100n }[m[2]] ?? 100000000000n);
- }
- let payHash = null;
- const end = words.length - 104; // last 104 words = signature
- for (let i = 7; i + 3 <= end;) {
- const type = words[i];
- const len = (words[i + 1] << 5) | words[i + 2];
- if (type === 1 && len === 52) payHash = convertBits(words.slice(i + 3, i + 3 + len), 5, 8).slice(0, 32);
- i += 3 + len;
- }
- if (!payHash) throw new Error('invoice missing payment hash');
- return { paymentHash: payHash, amountMsat };
-}
-
-// Orchestrates non-custodial swaps: creates them via Boltz, verifies the lockup
-// address locally before any funds move, then polls the chain (via the wallet's
-// esplora api) to claim (reverse) or detect success/refund (submarine). DOM-free.
-// Wallet storage/key helpers for this feature, installed onto the core
-// wallet instance so a build without the feature ships none of it.
-export function installSwapWallet(wallet) {
- if (wallet.loadSwaps) return; // already installed
- Object.assign(wallet, {
- // ---- Boltz swap support ----------------------------------------------
- // Persisted swap records (public metadata only — keys/preimage are re-derived
- // deterministically from the seed via swapNode, so nothing secret is stored).
- _swapKey() { return this._cacheKey() + ':swaps'; },
- loadSwaps() {
- try { return JSON.parse(localStorage.getItem(this._swapKey()) || '[]'); } catch { return []; }
- },
- saveSwaps(list) {
- try { localStorage.setItem(this._swapKey(), JSON.stringify(list)); } catch {}
- },
- _swapIdxKey() { return this._cacheKey() + ':swapidx'; },
- // Reserve the next swap index: monotonic + persisted on every call. A reverse/
- // submarine attempt that fails AFTER Boltz created the swap (a later validation
- // throw, a dropped response) still burns its index, so a retry can't reuse the
- // same deterministic preimage (Boltz rejects "swap with this preimage exists").
- // Seeded from saved swaps so existing wallets don't regress.
- nextSwapIndex() {
- const fromSaved = this.loadSwaps().reduce((m, s) => Math.max(m, (s.swapIndex ?? -1) + 1), 0);
- let stored = 0;
- try { stored = parseInt(localStorage.getItem(this._swapIdxKey()) || '0', 10) || 0; } catch {}
- const idx = Math.max(fromSaved, stored);
- try { localStorage.setItem(this._swapIdxKey(), String(idx + 1)); } catch {}
- return idx;
- },
- // Deterministic swap keypair on a dedicated chain (2) so swap keys never
- // collide with receive (0) / change (1). Returns the HDKey node (has
- // privateKey + publicKey, 33-byte compressed).
- swapNode(index) {
- return this.account().deriveChild(2).deriveChild(index);
- },
- });
-}
-
-export class SwapManager {
- constructor({ wallet, network = 'regtest', getApi, feeRate = 2, onUpdate }) {
- this.wallet = wallet;
- this.network = network;
- this.getApi = getApi; // () => boltz REST base url
- this.feeRate = feeRate; // sat/vB for funding + claim/refund
- this.onUpdate = onUpdate || (() => {});
- this.timers = new Map();
- }
-
- list() { return this.wallet.loadSwaps(); }
- // The swap (if any) an on-chain tx belongs to — its lockup funding (submarine),
- // claim (reverse), or refund. Lets the tx detail screen show the swap context.
- findByTxid(txid) { return this.list().find((s) => s.fundTxid === txid || s.claimTxid === txid || s.refundTxid === txid) || null; }
- _get(id) { return this.wallet.loadSwaps().find((s) => s.id === id); }
- _save(rec) {
- const list = this.wallet.loadSwaps();
- const i = list.findIndex((s) => s.id === rec.id);
- if (i >= 0) list[i] = rec; else list.push(rec);
- this.wallet.saveSwaps(list);
- this.onUpdate(rec);
- }
- _stop(id) { const t = this.timers.get(id); if (t) clearInterval(t); this.timers.delete(id); }
- _spendFee() { return Math.max(250, Math.ceil(170 * this.feeRate)); } // ~1-input taproot spend
-
- // The selected provider's reverse-swap limits (sat), fetched + cached per API.
- // Lets the UI bound the amount to what the provider actually accepts instead of
- // a hardcoded guess (providers + networks differ). Falls back conservatively.
- async reverseLimits() {
- const api = this.getApi();
- if (this._revLim && this._revLimApi === api) return this._revLim;
- let lim = { min: 25000, max: 25000000 };
- try {
- const res = await boltzReq(api, '/v2/swap/reverse');
- const b = res && res.BTC && res.BTC.BTC && res.BTC.BTC.limits;
- if (b && b.minimal) lim = { min: b.minimal, max: b.maximal };
- } catch {}
- this._revLim = lim; this._revLimApi = api;
- return lim;
- }
-
- // REVERSE: hal receives over Lightning. Returns a record whose `.invoice`
- // (bolt11) the user has someone pay; hal then claims the lockup on-chain.
- async startReverse(amountSat) {
- const w = this.wallet, net = this.network;
- let idx, node, preimage, res;
- // Our preimage is deterministic per swap index. If a prior attempt burned this
- // index at Boltz ("swap with this preimage exists"), nextSwapIndex has advanced,
- // so just retry with the next one.
- for (let attempt = 0; ; attempt++) {
- idx = w.nextSwapIndex();
- node = w.swapNode(idx);
- preimage = sha256(node.privateKey);
- try {
- res = await boltzReq(this.getApi(), '/v2/swap/reverse', {
- invoiceAmount: amountSat, from: 'BTC', to: 'BTC',
- claimPublicKey: bytesToHex(node.publicKey), preimageHash: bytesToHex(sha256(preimage)),
- });
- break;
- } catch (e) {
- if (attempt < 5 && /preimage|already exist/i.test(String(e?.message || e))) continue;
- throw e;
- }
- }
- const d = swapP2TR({ kind: 'reverse', preimageHash160: hash160(preimage), boltzPub33: hexToBytes(res.refundPublicKey), halPub33: node.publicKey, lockTime: res.timeoutBlockHeight, network: net });
- if (d.address !== res.lockupAddress) throw new Error('lockup address mismatch — not paying');
- if (bytesToHex(decodeBolt11(res.invoice).paymentHash) !== bytesToHex(sha256(preimage))) throw new Error('invoice hash mismatch');
- const rec = { id: res.id, kind: 'reverse', swapIndex: idx, status: 'awaiting-payment', network: net, invoice: res.invoice, lockupAddress: res.lockupAddress, onchainAmount: res.onchainAmount, refundPublicKey: res.refundPublicKey, timeoutBlockHeight: res.timeoutBlockHeight, createdAt: Date.now() };
- this._save(rec);
- this._watchReverse(rec.id);
- return rec;
- }
-
- _watchReverse(id) {
- if (this.timers.has(id)) return;
- const w = this.wallet, net = this.network;
- const tick = async () => {
- const rec = this._get(id);
- if (!rec || ['claimed', 'failed'].includes(rec.status)) return this._stop(id);
- try {
- const utxos = await w.api.addressUtxos(rec.lockupAddress);
- if (!utxos || !utxos.length) return;
- const u = utxos[0];
- const node = w.swapNode(rec.swapIndex);
- const preimage = sha256(node.privateKey);
- const d = swapP2TR({ kind: 'reverse', preimageHash160: hash160(preimage), boltzPub33: hexToBytes(rec.refundPublicKey), halPub33: node.publicKey, lockTime: rec.timeoutBlockHeight, network: net });
- const fee = this._spendFee();
- const destAddress = w.freshReceive().address;
- const hex = buildSwapSpend({ pay: d.pay, leaf: d.claimLeaf, halPriv: node.privateKey, preimage, lockupTxid: u.txid, lockupVout: u.vout, lockupValue: u.value, destAddress, fee, network: net });
- const txid = await w.api.broadcast(hex);
- rec.status = 'claimed'; rec.claimTxid = txid; rec.received = u.value - fee;
- this._save(rec); this._stop(id);
- // Credit the claimed coin (single output → destAddress) now, so the balance
- // + generic "payment received" fire instantly instead of waiting for a scan.
- if (w.creditReceive) w.creditReceive({ txid, vout: 0, value: rec.received, address: destAddress });
- } catch (e) { rec.lastError = String(e?.message || e); this.onUpdate(rec); }
- };
- this.timers.set(id, setInterval(tick, 3000)); tick();
- }
-
- // SUBMARINE: hal spends over Lightning. Funds the lockup from the wallet;
- // Boltz pays the invoice + claims. On failure, refund after the timeout.
- async startSubmarine(invoice) {
- return this.fundQuotedSubmarine(await this.quoteSubmarine(invoice));
- }
-
- // Quote a submarine swap WITHOUT committing funds: ask Boltz for the lockup
- // amount + build (but don't broadcast) the funding tx, so the UI can show an
- // itemized cost (amount + Boltz fee + on-chain network fee) before the user
- // pays. Returns everything fundQuotedSubmarine() needs.
- async quoteSubmarine(invoice) {
- const w = this.wallet, net = this.network;
- const dec = decodeBolt11(invoice);
- const idx = w.nextSwapIndex();
- const node = w.swapNode(idx);
- const res = await boltzReq(this.getApi(), '/v2/swap/submarine', { invoice, from: 'BTC', to: 'BTC', refundPublicKey: bytesToHex(node.publicKey) });
- const d = swapP2TR({ kind: 'submarine', preimageHash160: ripemd160(dec.paymentHash), boltzPub33: hexToBytes(res.claimPublicKey), halPub33: node.publicKey, lockTime: res.timeoutBlockHeight, network: net });
- if (d.address !== res.address) throw new Error('lockup address mismatch — not funding');
- const draft = w.buildTx({ recipients: [{ address: res.address, amount: res.expectedAmount }], feeRate: this.feeRate });
- const invoiceAmount = dec.amountMsat ? Number(dec.amountMsat / 1000n) : null;
- return {
- invoice, id: res.id, swapIndex: idx, lockupAddress: res.address, expectedAmount: res.expectedAmount,
- claimPublicKey: res.claimPublicKey, timeoutBlockHeight: res.timeoutBlockHeight, paymentHash: bytesToHex(dec.paymentHash),
- invoiceAmount, networkFee: draft.fee, boltzFee: invoiceAmount != null ? res.expectedAmount - invoiceAmount : null,
- total: res.expectedAmount + draft.fee, _draft: draft,
- };
- }
-
- // Broadcast the funding tx for a previously-quoted submarine swap + watch it.
- async fundQuotedSubmarine(q) {
- const w = this.wallet, net = this.network;
- const rec = { id: q.id, kind: 'submarine', swapIndex: q.swapIndex, status: 'funding', network: net, paymentHash: q.paymentHash, lockupAddress: q.lockupAddress, expectedAmount: q.expectedAmount, claimPublicKey: q.claimPublicKey, timeoutBlockHeight: q.timeoutBlockHeight, invoiceAmount: q.invoiceAmount, createdAt: Date.now() };
- this._save(rec);
- const fundTxid = await w.broadcast(w.sign(q._draft.tx));
- if (w.applySentTx) w.applySentTx(q._draft.tx);
- rec.fundTxid = fundTxid; rec.status = 'paying'; this._save(rec);
- this._watchSubmarine(rec.id);
- return rec;
- }
-
- _watchSubmarine(id) {
- if (this.timers.has(id)) return;
- // For a submarine swap the success moment is invoice.paid — Boltz has paid the
- // Lightning invoice (the whole point). It then claims its on-chain lockup
- // (transaction.claim.pending → claimed), often deferred to a later batch, but
- // our funds are already gone to the LN destination, so we're done either way.
- const done = new Set(['invoice.paid', 'transaction.claim.pending', 'transaction.claimed', 'invoice.settled']);
- const bad = new Set(['invoice.failedToPay', 'swap.expired', 'transaction.lockupFailed', 'transaction.refunded']);
- const tick = async () => {
- const rec = this._get(id);
- if (!rec || ['success', 'refunded', 'failed'].includes(rec.status)) return this._stop(id);
- try {
- const s = await boltzReq(this.getApi(), `/v2/swap/${rec.id}`);
- rec.boltzStatus = s.status;
- if (done.has(s.status)) { rec.status = 'success'; this._save(rec); this._stop(id); return; }
- if (bad.has(s.status)) { rec.status = 'refundable'; this._save(rec); this._stop(id); return; }
- this._save(rec);
- } catch (e) { rec.lastError = String(e?.message || e); this.onUpdate(rec); }
- };
- this.timers.set(id, setInterval(tick, 3000)); tick();
- }
-
- // Refund a failed submarine swap on-chain via the timeout script path. Only
- // succeeds once chain height >= timeoutBlockHeight (CLTV).
- async refundSubmarine(id) {
- const w = this.wallet, net = this.network;
- const rec = this._get(id);
- if (!rec || rec.kind !== 'submarine') throw new Error('not a submarine swap');
- const utxos = await w.api.addressUtxos(rec.lockupAddress);
- if (!utxos || !utxos.length) throw new Error('no lockup utxo to refund');
- const u = utxos[0];
- const node = w.swapNode(rec.swapIndex);
- const d = swapP2TR({ kind: 'submarine', preimageHash160: ripemd160(hexToBytes(rec.paymentHash)), boltzPub33: hexToBytes(rec.claimPublicKey), halPub33: node.publicKey, lockTime: rec.timeoutBlockHeight, network: net });
- const fee = this._spendFee();
- const hex = buildSwapSpend({ pay: d.pay, leaf: d.refundLeaf, halPriv: node.privateKey, preimage: null, lockupTxid: u.txid, lockupVout: u.vout, lockupValue: u.value, destAddress: w.freshReceive().address, fee, lockTime: rec.timeoutBlockHeight, network: net });
- const txid = await w.api.broadcast(hex);
- rec.status = 'refunded'; rec.refundTxid = txid; this._save(rec);
- return txid;
- }
-
- // Re-attach watchers to non-final swaps after a page reload.
- resumeAll() {
- for (const rec of this.list()) {
- if (rec.kind === 'reverse' && !['claimed', 'failed'].includes(rec.status)) this._watchReverse(rec.id);
- else if (rec.kind === 'submarine' && ['funding', 'paying'].includes(rec.status)) this._watchSubmarine(rec.id);
- }
- }
-}
diff --git a/src/wallet.js b/src/wallet.js
index 2417383..015a396 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -1229,21 +1229,6 @@ export class Wallet {
- // Number of unreserved spendable coins (for the consolidate prompt/action).
- spendableCoinCount() {
- const locked = this.lockedCoinIds();
- return this.utxos.filter((u) => !locked.has(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 };
- }
diff --git a/tools/nwc-pouch-test.js b/tools/nwc-pouch-test.js
deleted file mode 100644
index 7846585..0000000
--- a/tools/nwc-pouch-test.js
+++ /dev/null
@@ -1,51 +0,0 @@
-// Pouch: key isolation + IndexedDB round trip (fake-indexeddb not available,
-// so exercise the pure logic and assert the key branch is separate).
-import { HDKey } from '@scure/bip32';
-import { mnemonicToSeedSync, generateMnemonic } from '@scure/bip39';
-import { wordlist } from '@scure/bip39/wordlists/english';
-import { hex } from '@scure/base';
-import { pouchKey, buildPouch, pouchBalance, POUCH_CHAIN } from '/home/adam/halwallet/src/nwc-pouch.js';
-import { secp256k1 } from '@noble/curves/secp256k1';
-
-let ok = true; const check=(n,c,d='')=>{console.log(` ${c?'✓':'✗'} ${n}${d?' — '+d:''}`); if(!c) ok=false;};
-const account = HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist))).derive("m/86'/0'/9'");
-
-// the pouch branch must never collide with the branches that hold real coins
-const arkKey = (i) => account.deriveChild(3).deriveChild(i).privateKey;
-const mailbox = account.deriveChild(4).deriveChild(0).privateKey;
-const preimg = account.deriveChild(5).deriveChild(0).privateKey;
-check('pouch chain is 6', POUCH_CHAIN === 6);
-const p0 = pouchKey(account, 0).privkey;
-check('pouch key != ark vtxo key', hex.encode(p0) !== hex.encode(arkKey(0)));
-check('pouch key != mailbox key', hex.encode(p0) !== hex.encode(mailbox));
-check('pouch key != ln preimage key', hex.encode(p0) !== hex.encode(preimg));
-check('pouch indices differ', hex.encode(pouchKey(account,0).privkey) !== hex.encode(pouchKey(account,1).privkey));
-
-// buildPouch must export exactly the keys for the vtxos it carries — and no seed
-const vtxos = [
- { id: 'a:0', bytes: 'deadbeef', keyIndex: 0, amountSat: 5000, expiryHeight: 100 },
- { id: 'b:0', bytes: 'cafebabe', keyIndex: 1, amountSat: 2000, expiryHeight: 100 },
-];
-const pouch = buildPouch({
- arkUrl: 'https://ark.coinos.io', esploraUrl: 'https://mempool.space/api',
- network: 'mainnet', serverPubkey: '03ff', vtxos, account,
- connections: [{ id:'c1', servicePk:'aa', serviceSk:'bb', clientPk:'cc', maxSat:1000, dailySat:5000 }],
-});
-check('pouch carries both vtxos', pouch.vtxos.length === 2);
-check('pouch balance sums', pouchBalance(pouch) === 7000, String(pouchBalance(pouch)));
-// the worker can't derive, so the pouch ships a fixed key window covering
-// funding indices and the worker's change/refund allocations
-check('full key window shipped', Object.keys(pouch.keys).length === 50 && pouch.keys['0'] && pouch.keys['49']);
-check('change base inside the window', pouch.nextKeyIndex === 20);
-const blob = JSON.stringify(pouch);
-check('NO seed/mnemonic in the pouch', !/mnemonic|seed|xprv/i.test(blob));
-// the exported keys must actually correspond to the pouch pubkeys
-const derived = secp256k1.getPublicKey(hex.decode(pouch.keys['0']), true);
-check('exported key matches its pouch pubkey', hex.encode(derived) === hex.encode(pouchKey(account,0).pubkey));
-// and must NOT be able to sign for an ordinary ark vtxo
-check('pouch key cannot control chain-3 coins',
- hex.encode(secp256k1.getPublicKey(hex.decode(pouch.keys['0']), true))
- !== hex.encode(secp256k1.getPublicKey(arkKey(0), true)));
-check('connection service keys travel with it', pouch.connections[0].serviceSk === 'bb');
-
-console.log(ok ? '\n✅ pouch isolates correctly' : '\n❌ failed'); process.exit(ok?0:1);
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index b9826dd..69ed150 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -1,11 +1,11 @@
-// The service worker's pouch responder, headless: request decrypt, balance,
+// The service worker's background responder, headless: request decrypt, balance,
// budgets, answered-elsewhere suppression, fallbacks. The bridge/ASP pay path
// itself is covered by the manager and bridge tests. Run: bun tools/nwc-sw-test.js
import { hex } from '@scure/base';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
-import { respondFromPouch } from '../src/nwc-respond.js';
+import { respondFromBg } from '../src/nwc-respond.js';
let fails = 0;
const check = (name, ok, detail = '') => {
@@ -19,13 +19,15 @@ const conn = {
id: 'c1', servicePk: getPublicKey(serviceSk), serviceSk: hex.encode(serviceSk),
clientPk: getPublicKey(clientSk), maxSat: 1000, dailySat: 2000,
};
-const basePouch = () => ({
- v: 2,
+const baseRec = () => ({
+ v: 3,
ark: { arkUrl: 'http://ark.test', esploraUrl: 'http://esp.test', network: 'mainnet', serverPubkey: 'ab'.repeat(32) },
- bridge: { url: 'http://bridge.test', token: '' },
- vtxos: [{ id: 'v1', bytes: '00', keyIndex: 0, amountSat: 5000, expiryHeight: 1 }],
- keys: { 0: hex.encode(generateSecretKey()) },
- nextKeyIndex: 20,
+ mgr: {
+ v: 1, serverPubkey: 'ab'.repeat(32), nextKeyIndex: 5,
+ vtxos: [{ id: 'v1', bytes: '00', keyIndex: 1, amountSat: 5000, expiryHeight: 1, state: 'spendable' }],
+ actions: [], movements: [],
+ },
+ keys: { 1: hex.encode(generateSecretKey()), 5: hex.encode(generateSecretKey()) },
connections: [conn],
spends: [],
});
@@ -42,7 +44,7 @@ function makeReq(method, params = {}, scheme = 'nip44_v2', ageSec = 0) {
}, clientSk);
}
-function harness({ pouch = basePouch(), answered = false } = {}) {
+function harness({ rec = baseRec(), answered = false } = {}) {
const published = [];
const fetchFn = async (url, opts) => {
if (url.includes('/publish')) {
@@ -55,7 +57,7 @@ function harness({ pouch = basePouch(), answered = false } = {}) {
const deps = {
notifier: 'http://notif.test',
fetchFn,
- pouchesFn: async () => [{ walletKey: 'w1', pouch }],
+ recordsFn: async () => [{ walletKey: 'w1', rec }],
saveFn: async () => {},
};
const decryptReply = (ev) => {
@@ -72,14 +74,14 @@ const ev = (e) => JSON.parse(JSON.stringify(e)); // strip verified symbol like a
console.log('[balance + info]');
{
const { deps, published, decryptReply } = harness();
- const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('get_balance')) }, deps);
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('get_balance')) }, deps);
check('get_balance handled', handled === true);
const r = decryptReply(published[0]);
check('pouch balance in msat', r.result?.balance === 5000000, JSON.stringify(r));
}
{
const { deps, published, decryptReply } = harness();
- await respondFromPouch({ type: 'nwc', event: ev(makeReq('get_info', {}, 'nip04')) }, deps);
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('get_info', {}, 'nip04')) }, deps);
const r = decryptReply(published[0]);
check('nip04 get_info answered in nip04', r.result?.alias === 'Hal' && published[0].tags.find((t) => t[0] === 'encryption')[1] === 'nip04');
}
@@ -88,7 +90,7 @@ console.log('\n[guards]');
{
const { deps } = harness();
const stale = ev(makeReq('get_balance', {}, 'nip44_v2', 300));
- check('stale request refused', (await respondFromPouch({ type: 'nwc', event: stale }, deps)) === false);
+ check('stale request refused', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === false);
}
{
const { deps } = harness();
@@ -96,42 +98,49 @@ console.log('\n[guards]');
kind: 23194, created_at: Math.floor(Date.now() / 1000),
tags: [['p', conn.servicePk]], content: 'junk',
}, generateSecretKey()));
- check('foreign signer refused', (await respondFromPouch({ type: 'nwc', event: foreign }, deps)) === false);
+ check('foreign signer refused', (await respondFromBg({ type: 'nwc', event: foreign }, deps)) === false);
}
{
const { deps } = harness();
- check('no payload event → wake the user', (await respondFromPouch({ type: 'nwc', servicePubkey: conn.servicePk }, deps)) === false);
+ check('no payload event → wake the user', (await respondFromBg({ type: 'nwc', servicePubkey: conn.servicePk }, deps)) === false);
}
console.log('\n[pay budgets]');
// a real (long-expired) 21-sat invoice — decode only, nothing is paid
const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g';
{
- const pouch = basePouch();
- pouch.connections = [{ ...conn, maxSat: 10 }];
- const { deps, published, decryptReply } = harness({ pouch });
- const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ const rec = baseRec();
+ rec.connections = [{ ...conn, maxSat: 10 }];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
check('over per-payment limit refused', handled && decryptReply(published[0]).error?.code === 'QUOTA_EXCEEDED');
}
{
- const pouch = basePouch();
- pouch.spends = [{ connId: 'c1', amountSat: 1990, feeSat: 0, ts: Date.now() }];
- const { deps, published, decryptReply } = harness({ pouch });
- const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ const rec = baseRec();
+ rec.spends = [{ connId: 'c1', amountSat: 1990, feeSat: 0, ts: Date.now() }];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
check('over daily budget refused', handled && decryptReply(published[0]).error?.code === 'QUOTA_EXCEEDED');
}
{
const { deps, published } = harness({ answered: true });
- const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
check('answered elsewhere → silent', handled === true && published.length === 0);
}
{
- const pouch = basePouch();
- pouch.bridge = null;
- const { deps } = harness({ pouch });
- const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
- check('no bridge → wake the user', handled === false);
+ const rec = baseRec();
+ rec.mgr.vtxos = []; // nothing spendable in the mirror
+ const { deps } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('empty mirror → wake the user', handled === false);
+}
+{
+ const rec = baseRec();
+ delete rec.keys['5']; // change index outside the mirrored window
+ const { deps } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('exhausted key window → wake the user', handled === false);
}
-console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ pouch responder behaves');
+console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ background responder behaves');
process.exit(fails ? 1 : 0);
diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js
deleted file mode 100644
index 5611bb5..0000000
--- a/tools/sp-indexer.js
+++ /dev/null
@@ -1,314 +0,0 @@
-// BIP-352 silent-payment tweak indexer (an "oracle").
-//
-// It walks the chain from a Bitcoin Core node and, per block, emits one entry per
-// silent-payment-eligible transaction: the tweak (input_hash·A over the tx's
-// eligible inputs) plus that transaction's taproot outputs. A wallet fetches
-// these, computes ecdh = scan_priv·tweak on-device, derives P_k = B_spend + t_k·G,
-// and matches it against the entry's outputs — the scan key never leaves the
-// client. Discovered outputs are ordinary taproot UTXOs, so spends/confirmations
-// are tracked by the normal Electrum backend; this server only does discovery.
-//
-// Shares the SP crypto with the wallet (../src/silentpay.js) so the tweak math is
-// the same code validated against the BIP-352 vectors. Persists to SQLite (set
-// SP_DB to a file to survive restarts; defaults to in-memory). New blocks are
-// pushed to subscribed wallets over WebSocket (/ws). Run with bun.
-
-import { Database } from 'bun:sqlite';
-import net from 'node:net';
-import http from 'node:http';
-import { inputPubKey, silentPaymentTweak, buildBloom } from '../src/silentpay.js';
-
-const RPC = process.env.CORE_RPC || 'http://127.0.0.1:18443';
-const RPC_USER = process.env.RPC_USER || 'admin1';
-const RPC_PASS = process.env.RPC_PASS || '123';
-const PORT = Number(process.env.SP_PORT || 8888);
-const START = Number(process.env.START_HEIGHT || 0);
-const POLL_MS = Number(process.env.POLL_MS || 2000);
-const DB_PATH = process.env.SP_DB || ':memory:';
-// Mempool (unconfirmed) discovery is great for fast feedback but adds RPC load on
-// Core (a getrawtransaction + prevout lookups per new taproot-output tx). On by
-// default; set SP_MEMPOOL=0 to disable (e.g. to spare a busy production node).
-const MEMPOOL = process.env.SP_MEMPOOL !== '0';
-
-const auth = 'Basic ' + Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString('base64');
-let rpcId = 0;
-// Serialize all RPC so the block sync and mempool poll don't hammer Core
-// concurrently (which was dropping sockets).
-let rpcLock = Promise.resolve();
-function rpc(method, params = [], retry = 2) {
- const p = rpcLock.then(() => rpcCall(method, params, retry));
- rpcLock = p.then(() => {}, () => {});
- return p;
-}
-const RPC_URL = new URL(RPC);
-function rpcCall(method, params = [], retry = 2) {
- return new Promise((resolve, reject) => {
- const body = JSON.stringify({ jsonrpc: '1.0', id: ++rpcId, method, params });
- const req = http.request({
- hostname: RPC_URL.hostname, port: RPC_URL.port || 80, path: RPC_URL.pathname || '/', method: 'POST',
- agent: false, // fresh connection each call — no keep-alive sockets to go stale
- headers: { 'content-type': 'application/json', authorization: auth, 'content-length': Buffer.byteLength(body) },
- }, (res) => {
- let data = '';
- res.on('data', (c) => (data += c));
- res.on('end', () => {
- try { const j = JSON.parse(data); if (j.error) reject(new Error(`rpc ${method}: ${j.error.message}`)); else resolve(j.result); }
- catch (e) { reject(new Error(`rpc ${method}: ${e.message}`)); }
- });
- });
- req.on('error', (e) => (retry > 0 ? resolve(rpcCall(method, params, retry - 1)) : reject(e)));
- req.write(body);
- req.end();
- });
-}
-
-// The scriptPubKey of a previous output (for mempool txs, whose getrawtransaction
-// doesn't carry prevouts). Cached within a mempool pass via the parent tx.
-async function prevoutScriptPubKey(txid, vout) {
- const ptx = await rpc('getrawtransaction', [txid, true]);
- return (ptx.vout[vout] && ptx.vout[vout].scriptPubKey.hex) || '';
-}
-
-// ---- persistent store (SQLite) --------------------------------------------
-// blocks(height, hash, items-json); meta(indexed). Survives restarts so we don't
-// re-walk the chain — important on mainnet where the initial index is slow.
-const db = new Database(DB_PATH);
-db.exec('PRAGMA journal_mode = WAL');
-db.run('CREATE TABLE IF NOT EXISTS blocks (height INTEGER PRIMARY KEY, hash TEXT, items TEXT)');
-db.run('CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v INTEGER)');
-const qGetMeta = db.query('SELECT v FROM meta WHERE k = ?');
-const qSetMeta = db.query('INSERT OR REPLACE INTO meta (k, v) VALUES (?, ?)');
-const qPutBlock = db.query('INSERT OR REPLACE INTO blocks (height, hash, items) VALUES (?, ?, ?)');
-const qGetBlock = db.query('SELECT hash, items FROM blocks WHERE height = ?');
-const qScanFrom = db.query('SELECT height, items FROM blocks WHERE height >= ? ORDER BY height');
-let indexed = qGetMeta.get('indexed')?.v ?? (START - 1);
-
-// Is a scriptPubKey (hex) a P2TR output? OP_1 PUSH32 <x-only> = 5120<64 hex>.
-const taprootXonly = (hex) => (hex && hex.length === 68 && hex.startsWith('5120') ? hex.slice(4) : null);
-
-function scanTx(tx) {
- // Eligible-input set: every input with its recovered pubkey (null if ineligible
- // or coinbase); the tweak needs all outpoints but only eligible pubkeys.
- if (tx.vin.some((i) => i.coinbase)) return null; // coinbase is never a silent payment
- const inputs = tx.vin.map((i) => ({
- txid: i.txid,
- vout: i.vout,
- pubkey: inputPubKey({
- scriptSig: (i.scriptSig && i.scriptSig.hex) || '',
- witness: i.txinwitness || [],
- scriptPubKey: (i.prevout && i.prevout.scriptPubKey && i.prevout.scriptPubKey.hex) || '',
- }) || undefined,
- }));
- const outputs = [];
- for (const o of tx.vout) {
- const x = taprootXonly(o.scriptPubKey && o.scriptPubKey.hex);
- if (x) outputs.push({ vout: o.n, value: Math.round(o.value * 1e8), xonly: x });
- }
- if (!outputs.length) return null; // no taproot output → can't be an SP payment
- let tweak;
- try { tweak = silentPaymentTweak(inputs); } catch { return null; }
- if (!tweak) return null; // no eligible inputs
- return { txid: tx.txid, tweak: Buffer.from(tweak).toString('hex'), outputs };
-}
-
-async function indexBlock(height) {
- const hash = await rpc('getblockhash', [height]);
- const block = await rpc('getblock', [hash, 3]); // verbosity 3: full txs with prevouts
- const items = [];
- for (const tx of block.tx) { const it = scanTx(tx); if (it) items.push(it); }
- qPutBlock.run(height, hash, JSON.stringify(items));
- qSetMeta.run('indexed', height);
- indexed = height;
- return items;
-}
-
-let live = false; // true once caught up to the tip — then new blocks are pushed over WS
-
-async function sync() {
- try {
- const tip = await rpc('getblockcount', []);
- for (let h = Math.max(START, indexed + 1); h <= tip; h++) {
- const items = await indexBlock(h);
- // Push each newly-indexed block (once live) to subscribed wallets, so they
- // scan on arrival instead of polling. The payload carries the items so
- // there's no follow-up fetch.
- if (live) server.publish('blocks', JSON.stringify({ type: 'block', height: h, items }));
- if (h % 100 === 0 || h === tip) console.log(`indexed height ${h}/${tip} (${items.length} sp-tx)`);
- }
- if (!live) { live = true; console.log('caught up — pushing new blocks over websocket'); }
- if (MEMPOOL) await mempoolSync(); // blocks just changed the mempool — reconcile pending
- } catch (e) {
- console.error('sync error:', e.message);
- }
-}
-
-// ---- mempool (unconfirmed) SP discovery -----------------------------------
-// So a payment shows the moment it's broadcast, not only once mined. We track
-// which mempool txs are SP-eligible (getrawtransaction verbosity 2 carries the
-// prevouts needed for the tweak) and push the set whenever it changes.
-const mempoolSeen = new Map(); // txid -> item | null (null = seen, not an SP tx)
-let mempoolItems = [];
-
-// Like scanTx but for a mempool txid: getrawtransaction has no prevouts, so we
-// fetch each input's prevout scriptPubKey (only for txs with a taproot output).
-async function scanMempoolTx(txid) {
- const tx = await rpc('getrawtransaction', [txid, true]);
- if (tx.vin.some((i) => i.coinbase)) return null;
- const outputs = [];
- for (const o of tx.vout) { const x = taprootXonly(o.scriptPubKey && o.scriptPubKey.hex); if (x) outputs.push({ vout: o.n, value: Math.round(o.value * 1e8), xonly: x }); }
- if (!outputs.length) return null; // not SP-relevant — skip the prevout lookups
- const inputs = [];
- for (const i of tx.vin) {
- let spk = '';
- try { spk = await prevoutScriptPubKey(i.txid, i.vout); } catch {}
- inputs.push({ txid: i.txid, vout: i.vout, pubkey: inputPubKey({ scriptSig: (i.scriptSig && i.scriptSig.hex) || '', witness: i.txinwitness || [], scriptPubKey: spk }) || undefined });
- }
- let tweak;
- try { tweak = silentPaymentTweak(inputs); } catch { return null; }
- if (!tweak) return null;
- return { txid: tx.txid, tweak: Buffer.from(tweak).toString('hex'), outputs };
-}
-
-let mempoolInit = false;
-async function mempoolSync() {
- try {
- const txids = await rpc('getrawmempool', []);
- const inPool = new Set(txids);
- if (!mempoolInit) {
- mempoolInit = true;
- // Don't scan a large (mainnet) backlog on startup — that's thousands of
- // getrawtransaction calls in a burst on the node. Mark it seen and watch
- // only new arrivals. A small (regtest) mempool is cheap, so scan it.
- if (txids.length > 200) { for (const txid of txids) mempoolSeen.set(txid, null); return; }
- }
- let changed = false;
- for (const txid of txids) {
- if (mempoolSeen.has(txid)) continue;
- mempoolSeen.set(txid, null); // mark seen so we don't refetch
- let it = null;
- try { it = await scanMempoolTx(txid); } catch {}
- if (it) { mempoolSeen.set(txid, it); changed = true; }
- }
- for (const txid of mempoolSeen.keys()) if (!inPool.has(txid)) { if (mempoolSeen.get(txid)) changed = true; mempoolSeen.delete(txid); }
- if (changed) {
- mempoolItems = [...mempoolSeen.values()].filter(Boolean);
- if (live) server.publish('blocks', JSON.stringify({ type: 'mempool', items: mempoolItems }));
- }
- } catch (e) {
- console.error('mempool sync error:', e.message);
- }
-}
-
-const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
-const json = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json', ...CORS } });
-
-const server = Bun.serve({
- port: PORT,
- async fetch(req) {
- const url = new URL(req.url);
- const p = url.pathname;
- if (req.method === 'OPTIONS') return new Response(null, { headers: CORS });
- // WebSocket: subscribers get a {type:'block', height, items} push per new block.
- if (p === '/ws') {
- if (server.upgrade(req)) return undefined;
- return new Response('expected websocket', { status: 400 });
- }
- if (p === '/height') return json({ height: indexed });
- let m;
- if ((m = p.match(/^\/block\/(\d+)$/))) {
- const b = qGetBlock.get(Number(m[1]));
- if (!b) return json({ error: 'not indexed', height: Number(m[1]), tip: indexed }, 404);
- return json({ height: Number(m[1]), hash: b.hash, items: JSON.parse(b.items) });
- }
- if ((m = p.match(/^\/tweaks\/(\d+)$/))) {
- const b = qGetBlock.get(Number(m[1]));
- return json(b ? JSON.parse(b.items).map((i) => i.tweak) : []);
- }
- // Catch-up over a height range: per block, the tweaks (dust-filtered) + a
- // Bloom filter over that block's taproot output keys — NOT the outputs. The
- // client derives its candidate key per tweak, tests the filter, and only
- // fetches /block for the rare hits. dustLimit (sats) drops txs whose taproot
- // outputs are all below it (fewer tweaks to check).
- if ((m = p.match(/^\/scan\/(\d+)$/))) {
- const dust = Number(url.searchParams.get('dustLimit') || 0);
- const blocks = [];
- for (const row of qScanFrom.all(Number(m[1]))) {
- let items = JSON.parse(row.items);
- if (dust > 0) items = items.filter((it) => it.outputs.some((o) => o.value >= dust));
- if (!items.length) continue;
- const outKeys = items.flatMap((it) => it.outputs.map((o) => o.xonly));
- blocks.push({ height: row.height, tweaks: items.map((it) => it.tweak), filter: buildBloom(outKeys) });
- }
- return json({ tip: indexed, blocks });
- }
- // Current unconfirmed (mempool) SP-eligible txs — for the pending catch-up on
- // connect; live updates arrive as {type:'mempool'} WS pushes.
- if (p === '/mempool') return json({ items: mempoolItems });
- return json({ error: 'not found' }, 404);
- },
- websocket: {
- open(ws) { ws.subscribe('blocks'); ws.send(JSON.stringify({ type: 'height', height: indexed })); },
- message() {}, // clients don't send anything
- close(ws) { ws.unsubscribe('blocks'); },
- },
-});
-
-// Instant block detection via Core's `hashblock` ZMQ — trigger a sync the moment
-// a block arrives, instead of waiting for the poll. A minimal ZMTP/3.0 SUB client
-// (no native dep, so the bundle stays self-contained). The poll below remains a
-// backstop if the socket drops.
-function subscribeHashblock(endpoint, onBlock) {
- const [host, port] = endpoint.replace(/^tcp:\/\//, '').split(':');
- const sock = net.connect(Number(port), host);
- let buf = Buffer.alloc(0);
- let greeted = false;
- const sendFrame = (flags, body) => {
- const head = body.length < 256
- ? Buffer.from([flags, body.length])
- : Buffer.concat([Buffer.from([flags | 0x02]), (() => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(body.length)); return b; })()]);
- sock.write(Buffer.concat([head, body]));
- };
- sock.on('connect', () => {
- const g = Buffer.alloc(64);
- g[0] = 0xff; g[9] = 0x7f; g[10] = 3; g[11] = 0; // signature + ZMTP version 3.0
- g.write('NULL', 12); // security mechanism, null-padded to 20 bytes
- sock.write(g);
- });
- sock.on('data', (d) => {
- buf = Buffer.concat([buf, d]);
- if (!greeted) {
- if (buf.length < 64) return;
- buf = buf.subarray(64);
- greeted = true;
- // READY (command) advertising Socket-Type=SUB, then subscribe to "hashblock".
- sendFrame(0x04, Buffer.concat([
- Buffer.from([5]), Buffer.from('READY'),
- Buffer.from([11]), Buffer.from('Socket-Type'), Buffer.from([0, 0, 0, 3]), Buffer.from('SUB'),
- ]));
- sendFrame(0x00, Buffer.concat([Buffer.from([0x01]), Buffer.from('hashblock')]));
- }
- while (buf.length >= 2) {
- const flags = buf[0];
- const long = flags & 0x02;
- const headerLen = long ? 9 : 2;
- if (buf.length < headerLen) break;
- const size = long ? Number(buf.readBigUInt64BE(1)) : buf[1];
- if (buf.length < headerLen + size) break;
- const body = buf.subarray(headerLen, headerLen + size);
- buf = buf.subarray(headerLen + size);
- if (!(flags & 0x04) && body.length >= 9 && body.subarray(0, 9).toString() === 'hashblock') onBlock();
- }
- });
- sock.on('error', (e) => console.error('zmq error:', e.message));
- sock.on('close', () => setTimeout(() => subscribeHashblock(endpoint, onBlock), 3000)); // reconnect
-}
-
-console.log(`sp-indexer: core=${RPC} db=${DB_PATH} start=${START} resume-from=${indexed + 1} serving :${PORT}`);
-await sync();
-if (process.env.SP_ZMQ) {
- let t;
- subscribeHashblock(process.env.SP_ZMQ, () => { clearTimeout(t); t = setTimeout(sync, 50); }); // debounce a burst
- console.log(`zmq: subscribed to hashblock at ${process.env.SP_ZMQ}`);
-}
-setInterval(sync, POLL_MS); // backstop (and the only trigger when SP_ZMQ is unset)
-if (MEMPOOL) setInterval(mempoolSync, Number(process.env.MEMPOOL_POLL_MS || 2500)); // unconfirmed SP discovery
diff --git a/tools/sp-test.js b/tools/sp-test.js
deleted file mode 100644
index b55ec29..0000000
--- a/tools/sp-test.js
+++ /dev/null
@@ -1,143 +0,0 @@
-// Silent-payments e2e on regtest, through the real UI (two isolated browser
-// contexts = two devices):
-//
-// wallet B shows its sp1 address -> wallet A (funded on-chain) pays it ->
-// B's indexer scan finds the payment -> B SPENDS the SP coin (exercises the
-// BIP-352 per-output key signing path) -> miner address receives it.
-//
-// Needs the ark-regtest stack + the SP indexer:
-// COOKIE=$(cat ~/ark-regtest/bitcoind/regtest/.cookie); \
-// CORE_RPC=http://127.0.0.1:18543 RPC_USER=${COOKIE%%:*} RPC_PASS=${COOKIE#*:} \
-// SP_PORT=8899 START_HEIGHT=<tip> bun tools/sp-indexer.js
-//
-// Usage: bun tools/sp-test.js
-
-import { spawn, execSync } from 'node:child_process';
-import puppeteer from 'puppeteer-core';
-import { generateMnemonic } from '@scure/bip39';
-import { wordlist } from '@scure/bip39/wordlists/english';
-
-const PORT = 5199;
-const APP = `http://localhost:${PORT}`;
-const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
-const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`);
-
-let ok = true;
-const check = (name, cond, detail = '') => {
- console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
- if (!cond) ok = false;
-};
-
-const dev = spawn('bun', ['run', 'dev'], { cwd: process.env.HOME + '/halwallet', env: { ...process.env, PORT: String(PORT) }, stdio: 'ignore' });
-for (let i = 0; i < 30; i++) { try { if ((await fetch(APP)).ok) break; } catch {} await sleep(500); }
-
-const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
-
-async function newWalletPage() {
- const ctx = await browser.createBrowserContext(); // isolated storage = its own device
- const page = await ctx.newPage();
- page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
- await page.goto(APP, { waitUntil: 'domcontentloaded' });
- await page.evaluate(() => {
- localStorage.setItem('btc-wallet-network', 'regtest');
- localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' }));
- localStorage.setItem('btc-wallet-sp-indexer:regtest', JSON.stringify({ server: 'custom', url: 'http://localhost:8899' }));
- });
- await page.reload({ waitUntil: 'domcontentloaded' });
- await sleep(400);
- const clickText = (sel, text) => page.evaluate((s, x) => {
- const el = [...document.querySelectorAll(s)].find((e) => e.textContent.trim().toLowerCase().includes(x.toLowerCase()));
- if (el) { el.click(); return true; }
- return false;
- }, sel, text);
- const bodyText = () => page.evaluate(() => document.body.innerText);
- const waitText = async (text, ms = 20000) => {
- for (let i = 0; i < ms / 300; i++) {
- if ((await bodyText()).toLowerCase().includes(text.toLowerCase())) return true;
- await sleep(300);
- }
- return false;
- };
- await clickText('button', 'Import existing');
- await sleep(200);
- await page.type('textarea', generateMnemonic(wordlist));
- await clickText('button', 'Open wallet');
- await waitText('receive');
- return { page, clickText, bodyText, waitText };
-}
-
-try {
- console.log('\n[1] two wallets');
- const A = await newWalletPage();
- const B = await newWalletPage();
-
- // B: silent-payment address
- await B.page.select('select', 'sp');
- await sleep(400);
- const spAddr = await B.page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim());
- check('B shows sp1 address', /^t?sp1[a-z0-9]+$/.test(spAddr || ''), (spAddr || '').slice(0, 20) + '…');
-
- // A: fund on-chain
- const aAddr = await A.page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim());
- sh(`${BCLI} sendtoaddress ${aAddr} 0.001`);
- mine(1);
- check('A funded', await A.waitText('100,000', 30000));
- if (await A.page.evaluate(() => document.body.innerText.includes('Payment received'))) {
- await A.clickText('.card', 'Payment received');
- await sleep(300);
- }
-
- console.log('\n[2] A pays B\'s silent-payment address');
- await A.clickText('.tabs button', 'Send');
- await sleep(300);
- await A.page.type('.mono-input', spAddr);
- await sleep(500); // destReady render
- await A.page.type('input[type="number"]', '30000');
- await A.clickText('button.btn-primary', 'Review transaction');
- check('review shows SP note', await A.waitText('Silent payment', 8000));
- await A.clickText('button.btn-primary', 'Sign & broadcast');
- const bok = await A.waitText('Transaction ID', 15000);
- if (!bok) console.log(' [A after send]', (await A.bodyText()).slice(0, 320).replace(/\n+/g, ' | '));
- check('broadcast ok', bok);
- mine(1);
-
- console.log('\n[3] B discovers the payment');
- const found = await (async () => {
- for (let i = 0; i < 25; i++) {
- if ((await B.bodyText()).includes('30,000')) return true;
- await sleep(2000);
- }
- return false;
- })();
- check('B sees 30,000 sats via SP scan', found, (await B.bodyText()).match(/([\d,]+)\s*\n?\s*sats/)?.[1]);
-
- console.log('\n[4] B spends the SP coin (BIP-352 per-output key signing)');
- if (await B.page.evaluate(() => document.body.innerText.includes('Payment received'))) {
- await B.clickText('.card', 'Payment received');
- await sleep(300);
- }
- mine(1); // confirm the SP receive; unconfirmed SP coins aren't spendable
- await B.waitText('30,000', 15000);
- await sleep(2000);
- const minerAddr = sh(`${BCLI} getnewaddress`);
- await B.clickText('.tabs button', 'Send');
- await sleep(300);
- await B.page.type('.mono-input', minerAddr);
- await sleep(500);
- await B.page.type('input[type="number"]', '20000');
- await B.clickText('button.btn-primary', 'Review transaction');
- await sleep(800);
- await B.clickText('button.btn-primary', 'Sign & broadcast');
- const spent = await B.waitText('Transaction ID', 15000);
- check('SP coin spend broadcast', spent, spent ? '' : (await B.bodyText()).slice(0, 200));
- mine(1);
-
- console.log(ok ? '\n✅ SUCCESS: SP send, indexer discovery, and SP-input spend all work'
- : '\n❌ some checks failed');
-} finally {
- await browser.close();
- dev.kill();
-}
-process.exit(ok ? 0 : 1);
diff --git a/tools/swap-e2e-reverse.test.js b/tools/swap-e2e-reverse.test.js
deleted file mode 100644
index e3cd082..0000000
--- a/tools/swap-e2e-reverse.test.js
+++ /dev/null
@@ -1,78 +0,0 @@
-// End-to-end REVERSE swap (hal receives over Lightning) on regtest:
-// 1. hal requests a reverse swap (preimage it controls)
-// 2. coinos `cl` pays the hold invoice (HTLC locked)
-// 3. Boltz locks funds on-chain to the swap address
-// 4. hal claims them on-chain via the taproot SCRIPT PATH (preimage + sig)
-// 5. Boltz extracts the preimage, settles the LN payment
-// Proves buildSwapSpend (reverse claim) against the live stack.
-// Run (stack up): bun tools/swap-e2e-reverse.test.js
-import { $ } from 'bun';
-import { secp256k1 } from '@noble/curves/secp256k1';
-import * as btc from '@scure/btc-signer';
-import { sha256 } from '@noble/hashes/sha2';
-import { randomBytes, bytesToHex, hexToBytes } from '@noble/hashes/utils';
-import { hash160, swapP2TR, buildSwapSpend, REGTEST } from '../src/swap.js';
-
-const BOLTZ = 'http://localhost:9001';
-const ESPLORA = 'http://localhost:3000';
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-const jget = async (u) => { const r = await fetch(u); return r.ok ? r.json() : null; };
-const post = async (p, b) => {
- const r = await fetch(BOLTZ + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
- const j = await r.json(); if (!r.ok) throw new Error(`${p}: ${JSON.stringify(j)}`); return j;
-};
-
-// hal's claim key, preimage, and a destination address it controls.
-const claimPriv = secp256k1.utils.randomPrivateKey();
-const claimPub = secp256k1.getPublicKey(claimPriv, true);
-const preimage = randomBytes(32);
-const destPriv = secp256k1.utils.randomPrivateKey();
-const destAddr = btc.p2wpkh(secp256k1.getPublicKey(destPriv, true), REGTEST).address;
-
-const amount = 120000;
-const swap = await post('/v2/swap/reverse', {
- invoiceAmount: amount, from: 'BTC', to: 'BTC',
- claimPublicKey: bytesToHex(claimPub), preimageHash: bytesToHex(sha256(preimage)),
-});
-console.log('swap', swap.id, 'lockup', swap.lockupAddress, 'onchain', swap.onchainAmount);
-
-const boltzPub = hexToBytes(swap.refundPublicKey);
-const d = swapP2TR({ kind: 'reverse', preimageHash160: hash160(preimage), boltzPub33: boltzPub, halPub33: claimPub, lockTime: swap.timeoutBlockHeight, network: 'regtest' });
-if (d.address !== swap.lockupAddress) throw new Error('address mismatch — abort');
-
-// Pay the hold invoice from cl (backgrounded — it stays pending until we claim).
-console.log('paying hold invoice from cl…');
-Bun.spawn(['docker', 'exec', 'cl', 'lightning-cli', '--regtest', 'pay', swap.invoice], { stdout: 'ignore', stderr: 'ignore' });
-
-// Wait for Boltz to lock funds on-chain to the swap address.
-let utxo = null;
-for (let i = 0; i < 40; i++) {
- await sleep(1000);
- const us = await jget(`${ESPLORA}/address/${swap.lockupAddress}/utxo`);
- if (us && us.length) { utxo = us[0]; break; }
-}
-if (!utxo) throw new Error('lockup not seen on-chain (esplora)');
-console.log('lockup utxo', utxo.txid, 'vout', utxo.vout, 'value', utxo.value);
-
-// Claim it on-chain via the script path.
-const fee = 300;
-const claimHex = buildSwapSpend({
- pay: d.pay, leaf: d.claimLeaf, halPriv: claimPriv, preimage,
- lockupTxid: utxo.txid, lockupVout: utxo.vout, lockupValue: utxo.value,
- destAddress: destAddr, fee, network: 'regtest',
-});
-const br = await fetch(`${ESPLORA}/tx`, { method: 'POST', body: claimHex });
-const claimTxid = await br.text();
-if (!br.ok) throw new Error('broadcast failed: ' + claimTxid);
-console.log('claim broadcast', claimTxid);
-
-// Verify hal's destination received the funds.
-let got = null;
-for (let i = 0; i < 15; i++) {
- await sleep(1000);
- const us = await jget(`${ESPLORA}/address/${destAddr}/utxo`);
- if (us && us.length) { got = us[0]; break; }
-}
-if (!got) throw new Error('destination did not receive claim');
-console.log(`\nPASS — hal received ${got.value} sat on-chain at ${destAddr} (reverse swap claimed via script path)`);
-process.exit(0);
diff --git a/tools/swap-e2e-submarine.test.js b/tools/swap-e2e-submarine.test.js
deleted file mode 100644
index 265721d..0000000
--- a/tools/swap-e2e-submarine.test.js
+++ /dev/null
@@ -1,56 +0,0 @@
-// End-to-end SUBMARINE swap (hal spends over Lightning) on regtest:
-// 1. `cl` issues an invoice (the payee/merchant)
-// 2. hal creates a submarine swap for it (refund key it controls)
-// 3. hal funds the on-chain lockup with expectedAmount (here: from bc)
-// 4. Boltz detects the lockup, pays the LN invoice, and claims the lockup
-// 5. the invoice is paid → hal's on-chain spend reached a LN payee
-// Proves swap creation + address + the funded-lockup → Boltz-pays path.
-// (Refund-path signing is the same buildSwapSpend validated by the reverse E2E.)
-// Run (stack up): bun tools/swap-e2e-submarine.test.js
-import { $ } from 'bun';
-import { secp256k1 } from '@noble/curves/secp256k1';
-import { ripemd160 } from '@noble/hashes/legacy';
-import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
-import { swapP2TR } from '../src/swap.js';
-
-const BOLTZ = 'http://localhost:9001';
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-const post = async (p, b) => {
- const r = await fetch(BOLTZ + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
- const j = await r.json(); if (!r.ok) throw new Error(`${p}: ${JSON.stringify(j)}`); return j;
-};
-const bcli = (...a) => $`docker exec bc bitcoin-cli -regtest -rpcuser=admin1 -rpcpassword=123 ${a}`.text();
-const clcli = (...a) => $`docker exec cl lightning-cli --regtest ${a}`.text();
-
-// cl (payee) issues an invoice hal wants to pay.
-const amount = 120000;
-const inv = JSON.parse(await clcli('invoice', String(amount * 1000), `hal-${Date.now()}`, 'submarine test'));
-
-// hal's refund key + submarine swap.
-const refundPriv = secp256k1.utils.randomPrivateKey();
-const refundPub = secp256k1.getPublicKey(refundPriv, true);
-const swap = await post('/v2/swap/submarine', { invoice: inv.bolt11, from: 'BTC', to: 'BTC', refundPublicKey: bytesToHex(refundPub) });
-console.log('swap', swap.id, 'lockup', swap.address, 'expected', swap.expectedAmount);
-
-// Verify the lockup address before funding (don't-trust-verify).
-const d = swapP2TR({ kind: 'submarine', preimageHash160: ripemd160(hexToBytes(inv.payment_hash)), boltzPub33: hexToBytes(swap.claimPublicKey), halPub33: refundPub, lockTime: swap.timeoutBlockHeight, network: 'regtest' });
-if (d.address !== swap.address) throw new Error('address mismatch — abort');
-console.log('lockup address verified ✓');
-
-// Fund the lockup (in real hal this is wallet coin-selection; here: from bc).
-await bcli('-rpcwallet=coinos', 'sendtoaddress', swap.address, (swap.expectedAmount / 1e8).toFixed(8));
-const mineAddr = (await bcli('-rpcwallet=coinos', 'getnewaddress')).trim();
-await bcli('generatetoaddress', '1', mineAddr); // confirm (maxZeroConfAmount=0)
-console.log('lockup funded + confirmed; waiting for Boltz to pay the invoice…');
-
-// Boltz should detect the lockup, pay the LN invoice, and claim.
-let paid = false;
-for (let i = 0; i < 45; i++) {
- await sleep(1000);
- const ls = JSON.parse(await clcli('listinvoices'));
- const mine = ls.invoices?.find((x) => x.payment_hash === inv.payment_hash);
- if (mine?.status === 'paid') { paid = true; break; }
-}
-if (!paid) throw new Error('invoice not paid by Boltz within timeout');
-console.log(`\nPASS — Boltz paid hal's submarine invoice (${amount} sat) after the on-chain lockup. hal spent on-chain → LN payee.`);
-process.exit(0);
diff --git a/tools/swap-live.test.js b/tools/swap-live.test.js
deleted file mode 100644
index f56105a..0000000
--- a/tools/swap-live.test.js
+++ /dev/null
@@ -1,57 +0,0 @@
-// Live integration check against the local Boltz (http://localhost:9001) +
-// regtest CLN. Confirms Hal's locally-derived swap address + tapleaves match
-// Boltz's lockupAddress + swapTree for BOTH flows — proving the MuSig2
-// internal-key order ([boltzKey, halKey]), the taptweak, and leaf construction.
-// Run (regtest Boltz stack up): bun tools/swap-live.test.js
-import { $ } from 'bun';
-import { secp256k1 } from '@noble/curves/secp256k1';
-import { sha256 } from '@noble/hashes/sha2';
-import { ripemd160 } from '@noble/hashes/legacy';
-import { randomBytes, bytesToHex, hexToBytes } from '@noble/hashes/utils';
-import { hash160, swapP2TR, buildSwapTree } from '../src/swap.js';
-
-const BOLTZ = process.env.BOLTZ_API || 'http://localhost:9001';
-const post = async (p, b) => {
- const r = await fetch(BOLTZ + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
- const j = await r.json();
- if (!r.ok) throw new Error(`${p} -> ${r.status} ${JSON.stringify(j)}`);
- return j;
-};
-const kp = () => { const sk = secp256k1.utils.randomPrivateKey(); return { sk, pk: secp256k1.getPublicKey(sk, true) }; };
-let fail = 0;
-const eq = (n, g, w) => { if (g === w) console.log(` ok ${n}`); else { fail++; console.log(`FAIL ${n}\n got: ${g}\n want: ${w}`); } };
-
-// ---------- REVERSE (hal receives over LN; hal claims) ----------
-{
- const claim = kp();
- const preimage = randomBytes(32);
- const res = await post('/v2/swap/reverse', {
- invoiceAmount: 100000, from: 'BTC', to: 'BTC',
- claimPublicKey: bytesToHex(claim.pk), preimageHash: bytesToHex(sha256(preimage)),
- });
- const boltzPub = hexToBytes(res.refundPublicKey); // Boltz refunds on reverse
- const d = swapP2TR({ kind: 'reverse', preimageHash160: hash160(preimage), boltzPub33: boltzPub, halPub33: claim.pk, lockTime: res.timeoutBlockHeight, network: 'regtest' });
- eq('reverse lockup address', d.address, res.lockupAddress);
- eq('reverse claim leaf', bytesToHex(d.claimLeaf), res.swapTree.claimLeaf.output);
- eq('reverse refund leaf', bytesToHex(d.refundLeaf), res.swapTree.refundLeaf.output);
-}
-
-// ---------- SUBMARINE (hal spends over LN; hal refunds) ----------
-{
- const inv = JSON.parse(await $`docker exec clb lightning-cli --regtest invoice 100000000 hal-sub-${Date.now()} test`.text());
- const payHash = inv.payment_hash;
- const refund = kp(); // hal's refund key
- const res = await post('/v2/swap/submarine', {
- invoice: inv.bolt11, from: 'BTC', to: 'BTC', refundPublicKey: bytesToHex(refund.pk),
- });
- const boltzPub = hexToBytes(res.claimPublicKey); // Boltz claims on submarine
- // tapscript embeds hash160(preimage) = ripemd160(payment_hash)
- const ph160 = ripemd160(hexToBytes(payHash));
- const d = swapP2TR({ kind: 'submarine', preimageHash160: ph160, boltzPub33: boltzPub, halPub33: refund.pk, lockTime: res.timeoutBlockHeight, network: 'regtest' });
- eq('submarine lockup address', d.address, res.address);
- eq('submarine claim leaf', bytesToHex(d.claimLeaf), res.swapTree.claimLeaf.output);
- eq('submarine refund leaf', bytesToHex(d.refundLeaf), res.swapTree.refundLeaf.output);
-}
-
-console.log(fail ? `\n${fail} FAILED` : '\nall good');
-process.exit(fail ? 1 : 0);
diff --git a/tools/swap-manager.test.js b/tools/swap-manager.test.js
deleted file mode 100644
index 7a83be1..0000000
--- a/tools/swap-manager.test.js
+++ /dev/null
@@ -1,43 +0,0 @@
-// Drives the SwapManager reverse flow end-to-end with a minimal wallet shim:
-// startReverse -> pay the hold invoice from cl -> manager auto-claims on-chain.
-// Run (stack up): bun tools/swap-manager.test.js
-import { HDKey } from '@scure/bip32';
-import * as btc from '@scure/btc-signer';
-import { secp256k1 } from '@noble/curves/secp256k1';
-import { randomBytes } from '@noble/hashes/utils';
-import { SwapManager, REGTEST } from '../src/swap.js';
-
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-const ESPLORA = 'http://localhost:3000';
-// Fresh random seed per run (real wallet persists swaps so swapNode indices
-// increment; this shim's store is in-memory, so randomize to avoid preimage reuse).
-const acct = HDKey.fromMasterSeed(randomBytes(64)).derive("m/84'/1'/0'");
-
-let store = [];
-const wallet = {
- loadSwaps: () => store,
- saveSwaps: (l) => { store = l; },
- nextSwapIndex: () => store.reduce((m, s) => Math.max(m, (s.swapIndex ?? -1) + 1), 0),
- swapNode: (i) => acct.deriveChild(2).deriveChild(i),
- freshReceive: () => ({ address: btc.p2wpkh(secp256k1.getPublicKey(acct.deriveChild(0).deriveChild(7).privateKey, true), REGTEST).address }),
- api: {
- addressUtxos: async (a) => { const r = await fetch(`${ESPLORA}/address/${a}/utxo`); return r.ok ? r.json() : []; },
- broadcast: async (hex) => { const r = await fetch(`${ESPLORA}/tx`, { method: 'POST', body: hex }); const t = await r.text(); if (!r.ok) throw new Error(t); return t; },
- },
-};
-
-const sm = new SwapManager({ wallet, network: 'regtest', getApi: () => 'http://localhost:9001', feeRate: 2, onUpdate: () => {} });
-const rec = await sm.startReverse(120000);
-console.log('startReverse ->', rec.id, rec.status, 'lockup', rec.lockupAddress);
-
-console.log('paying hold invoice from cl…');
-Bun.spawn(['docker', 'exec', 'cl', 'lightning-cli', '--regtest', 'pay', rec.invoice], { stdout: 'ignore', stderr: 'ignore' });
-
-for (let i = 0; i < 60; i++) {
- await sleep(1000);
- const r = sm.list().find((s) => s.id === rec.id);
- if (r.status === 'claimed') { console.log(`\nPASS — SwapManager auto-claimed: ${r.received} sats, txid ${r.claimTxid}`); process.exit(0); }
- if (r.lastError && i % 5 === 0) console.log(' …', r.lastError);
-}
-console.log('FAIL — not claimed:', JSON.stringify(sm.list()));
-process.exit(1);