From 348a57004f94dd0f58c4e70769e65ca7ea3f7619 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 1 Aug 2026 19:38:30 -0700 Subject: Simplify: one Lightning path, one balance, fewer features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- HANDOFF.md | 252 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 HANDOFF.md (limited to 'HANDOFF.md') 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. + +
Original investigation notes (superseded) + +**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":[""],"since":}]]` +- 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. + +
+ +--- + +## 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). -- cgit v1.2.3