diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/nwc-pouch-test.js | 51 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 69 | ||||
| -rw-r--r-- | tools/sp-indexer.js | 314 | ||||
| -rw-r--r-- | tools/sp-test.js | 143 | ||||
| -rw-r--r-- | tools/swap-e2e-reverse.test.js | 78 | ||||
| -rw-r--r-- | tools/swap-e2e-submarine.test.js | 56 | ||||
| -rw-r--r-- | tools/swap-live.test.js | 57 | ||||
| -rw-r--r-- | tools/swap-manager.test.js | 43 |
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); |
