aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-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
8 files changed, 39 insertions, 772 deletions
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);