From 52a39d014c03cdcc733ddc91af9d7acfee2ff27a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 7 Oct 2026 08:21:35 -0700 Subject: Drop the third-party HTLC swap bridge tests: the policy they exercise is gone 68fd614 removed the VtxoPolicy::Htlc encoding from send.js/proto.js (upstream took type 0x08) and the bridge was retired from the app on 2026-08-02, so htlcLock can no longer build its output (policyTaproot throws on the missing userPubkey). bridge-e2e-test also drove bridge/server.js, which sits on the same dead primitives. Co-Authored-By: Claude Opus 5.5 --- tools/ark/bridge-e2e-test.js | 190 --------------------------------------- tools/ark/htlc-bridge-test.js | 201 ------------------------------------------ 2 files changed, 391 deletions(-) delete mode 100644 tools/ark/bridge-e2e-test.js delete mode 100644 tools/ark/htlc-bridge-test.js diff --git a/tools/ark/bridge-e2e-test.js b/tools/ark/bridge-e2e-test.js deleted file mode 100644 index b0f7146..0000000 --- a/tools/ark/bridge-e2e-test.js +++ /dev/null @@ -1,190 +0,0 @@ -// End-to-end test of the swap bridge over its real HTTP API, against the -// patched regtest ASP (VtxoPolicy::Htlc). -// -// user wallet --HTLC--> bridge --pays bolt11--> clb (invoice issuer) -// bridge --claims w/ preimage -// -// Also asserts the failure path: an unpayable invoice leaves the user's money -// untouched and refundable, and the refund really lands back in their wallet. -// -// Usage: bun tools/ark/bridge-e2e-test.js (regtest stack + cln-ark up) - -import { execSync, spawn } from 'node:child_process'; -import { writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs'; -import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39'; -import { wordlist } from '@scure/bip39/wordlists/english'; -import { HDKey } from '@scure/bip32'; -import { hex } from '@scure/base'; -import { sha256 } from '@noble/hashes/sha256'; - -import { ArkManager } from '../../src/ark/manager.js'; -import { decodeBolt11 } from '../../src/ark/lightning.js'; - -const ARK = 'http://127.0.0.1:3535'; -const ESPLORA = 'http://127.0.0.1:30002'; -const BRIDGE = 'http://127.0.0.1:8791'; -const BARK = `${process.env.HOME}/bark/target/debug/bark`; -const ALICE_CLI = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`; -const DIR = '/tmp/claude-1000/-home-adam-halwallet/f5f3f3a1-dd64-470c-80d8-1b1a87700326/scratchpad/bridge-test'; - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); -const cln = (node, cmd) => { - const out = sh(`docker exec ${node} lightning-cli --regtest ${cmd}`); - return JSON.parse(out.slice(out.indexOf('{'))); -}; - -let ok = true; -const check = (name, cond, detail = '') => { - console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); - if (!cond) ok = false; -}; - -// --- bridge wallet + config ------------------------------------------------ -rmSync(DIR, { recursive: true, force: true }); -mkdirSync(DIR, { recursive: true }); -const bridgeMnemonic = generateMnemonic(wordlist); -// the bridge pays from clc, which has ~406k sat outbound toward cln-ark — -// so cln-ark plays the role of the merchant being paid. clb has no route -// from clc (zero outbound toward the hub), which we use for the failure case. -writeFileSync(`${DIR}/config.json`, JSON.stringify({ - port: 8791, network: 'regtest', ark: ARK, esplora: ESPLORA, - mnemonic: bridgeMnemonic, - stateFile: `${DIR}/bridge.json`, - ln: { kind: 'docker', container: 'clc', network: 'regtest' }, - fee: { baseSat: 0, ppm: 1000, minSat: 1 }, - limits: { minSat: 1, maxSat: 1000000, htlcBlocks: 144 }, - tokens: ['test-token'], -}, null, 1)); - -// fund the bridge's ark wallet so it can pay out claims / hold float -const bridgeAccount = HDKey.fromMasterSeed(mnemonicToSeedSync(bridgeMnemonic)).derive("m/86'/0'/9'"); -const bridgeWallet = await new ArkManager({ - account: bridgeAccount, storage: { load: () => null, save: () => {} }, - arkUrl: ARK, esploraUrl: ESPLORA, network: 'regtest', -}).init(); - -console.log('\n[0] fund the user and the bridge'); -const user = await new ArkManager({ - account: HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist))).derive("m/86'/0'/9'"), - storage: { load: () => null, save: () => {} }, - arkUrl: ARK, esploraUrl: ESPLORA, network: 'regtest', -}).init(); -sh(`${ALICE_CLI} -q send ${user.address()} "30000 sat"`); -for (let i = 0; i < 12 && !user.balance().spendableSat; i++) { await user.sync(); await sleep(1500); } -check('user funded', user.balance().spendableSat === 30000, JSON.stringify(user.balance())); - -// --- start the bridge ------------------------------------------------------ -const proc = spawn('bun', ['bridge/server.js'], { - cwd: `${process.env.HOME}/halwallet`, - env: { ...process.env, BRIDGE_CONFIG: `${DIR}/config.json` }, - stdio: ['ignore', 'pipe', 'pipe'], -}); -let blog = ''; -proc.stdout.on('data', (d) => { blog += d; }); -proc.stderr.on('data', (d) => { blog += d; }); -let info = null; -for (let i = 0; i < 40; i++) { - try { info = await (await fetch(`${BRIDGE}/info`)).json(); break; } catch {} - await sleep(500); -} -if (!info) { console.log(blog.slice(-2000)); throw new Error('bridge did not start'); } -console.log('\n[1] bridge up'); -check('info reports our claim pubkey', /^0[23][0-9a-f]{64}$/.test(info.claimPubkey || ''), info.claimPubkey); -check('fee policy is cheap', info.fee.minSat === 1 && info.fee.ppm === 1000, JSON.stringify(info.fee)); -check('token required', info.requiresToken === true); -console.log(` outbound liquidity: ${info.outboundSat} sat`); - -// --- quote ----------------------------------------------------------------- -console.log('\n[2] quote a 25-sat zap (the case the ASP charges 20 sat for)'); -const zapInv = cln('cln-ark', 'invoice 25000 zap-' + Date.now() + ' bridge-zap').bolt11; -const quote = await (await fetch(`${BRIDGE}/quote?invoice=${zapInv}`)).json(); -check('quote returned', quote.amountSat === 25, JSON.stringify(quote).slice(0, 160)); -check('fee is 1 sat (vs the ASP\'s 20)', quote.feeSat === 1, `${quote.feeSat} sat`); -check('quote gives claim pubkey + expiry', !!quote.claimPubkey && quote.htlcExpiry > 0); - -// --- the swap -------------------------------------------------------------- -console.log('\n[3] user locks an HTLC and the bridge pays the invoice'); -const before = user.balance().spendableSat; -const lock = await user.htlcLock({ - amountSat: quote.totalSat, - claimPubkey: quote.claimPubkey, - paymentHash: quote.paymentHash, - htlcExpiry: quote.htlcExpiry, -}); -check('user locked the HTLC', lock.htlcVtxos.length === 1); - -// unauthorized attempt first -const unauth = await fetch(`${BRIDGE}/swap`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ invoice: zapInv, htlcVtxo: lock.htlcVtxos[0] }), -}); -check('bridge rejects a swap without a token', unauth.status === 401, `HTTP ${unauth.status}`); - -const res = await (await fetch(`${BRIDGE}/swap`, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: 'Bearer test-token' }, - body: JSON.stringify({ invoice: zapInv, htlcVtxo: lock.htlcVtxos[0] }), -})).json(); -console.log(' swap result:', JSON.stringify(res)); -check('swap completed', res.step === 'done', res.step + (res.error ? ' / ' + res.error : '')); -check('bridge returned the preimage', !!res.preimage - && hex.encode(sha256(hex.decode(res.preimage))) === quote.paymentHash); -check('invoice really paid at the far end', - cln('cln-ark', 'listinvoices').invoices.some((i) => i.bolt11 === zapInv && i.status === 'paid')); -check('user paid amount + 1 sat fee', user.balance().spendableSat === before - 26, - `${before} -> ${user.balance().spendableSat}`); -// read the bridge's own persisted wallet state: the claimed HTLC must have -// landed as a spendable vtxo worth the swap total -const bstate = JSON.parse(readFileSync(`${DIR}/bridge.json`, 'utf8')); -const bsat = (bstate.ark?.vtxos || []).filter((v) => v.state === 'spendable') - .reduce((n, v) => n + v.amountSat, 0); -check('bridge holds the claimed HTLC value', bsat === quote.totalSat, `${bsat} sat spendable`); - -// --- idempotency ----------------------------------------------------------- -const again = await (await fetch(`${BRIDGE}/swap`, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: 'Bearer test-token' }, - body: JSON.stringify({ invoice: zapInv, htlcVtxo: lock.htlcVtxos[0] }), -})).json(); -check('resubmitting the same swap is idempotent', again.step === 'done' && again.preimage === res.preimage); - -// --- failure + refund ------------------------------------------------------ -console.log('\n[4] unpayable invoice: user must get their money back'); -// clb is unreachable from clc (clc has no outbound toward the hub), so this -// is a genuinely unroutable payment rather than a contrived error -const badInv = cln('clb', 'invoice 5000000 unpay-' + Date.now() + ' unpayable').bolt11; -const badDec = decodeBolt11(badInv); -const beforeBad = user.balance().spendableSat; -const badQuote = await (await fetch(`${BRIDGE}/quote?invoice=${badInv}`)).json(); -if (badQuote.error) { - // the bridge refused up front on liquidity — the best possible outcome - check('bridge refuses a swap it cannot route (no funds at risk)', true, badQuote.error); - check('user balance untouched', user.balance().spendableSat === beforeBad); -} else { - const badLock = await user.htlcLock({ - amountSat: badQuote.totalSat, claimPubkey: badQuote.claimPubkey, - paymentHash: badQuote.paymentHash, htlcExpiry: badQuote.htlcExpiry, - }); - const badRes = await (await fetch(`${BRIDGE}/swap`, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: 'Bearer test-token' }, - body: JSON.stringify({ invoice: badInv, htlcVtxo: badLock.htlcVtxos[0] }), - })).json(); - check('swap reported refundable, not done', badRes.step === 'refundable', badRes.step); - const refund = await (await fetch(`${BRIDGE}/refund`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ paymentHash: badDec.paymentHash }), - })).json(); - check('bridge cosigned a refund', refund.step === 'refunded', JSON.stringify(refund).slice(0, 120)); - const got = await user.acceptHtlcRefund({ - vtxoBytesList: refund.refundVtxos, refundIndex: badLock.refundIndex, - }); - check('user got every sat back', got === badQuote.totalSat, `${got} of ${badQuote.totalSat}`); - check('user whole again', user.balance().spendableSat === beforeBad, `${beforeBad} -> ${user.balance().spendableSat}`); -} - -proc.kill(); -console.log(ok ? '\n✅ SUCCESS: bridge swaps a 25-sat zap for 1 sat, trustlessly' : '\n❌ some checks failed'); -if (!ok) console.log('\n--- bridge log tail ---\n' + blog.slice(-1500)); -process.exit(ok ? 0 : 1); diff --git a/tools/ark/htlc-bridge-test.js b/tools/ark/htlc-bridge-test.js deleted file mode 100644 index a712316..0000000 --- a/tools/ark/htlc-bridge-test.js +++ /dev/null @@ -1,201 +0,0 @@ -// Trustless third-party ark<->LN swap, end to end against a patched captaind -// (VtxoPolicy::Htlc — see docs/third-party-htlc.md). -// -// Two independent hal wallets play the roles: -// alice = user paying a Lightning invoice with ark funds -// bridge = third-party swap provider (NOT the ASP) -// -// Happy path (submarine swap, ark -> LN): -// 1. alice locks funds in an HTLC claimable by the bridge against the -// invoice's payment hash, refundable by alice after expiry -// 2. bridge validates the HTLC, pays the bolt11 (here: clc's invoice), -// learns the preimage, claims the HTLC by revealing it -// 3. alice reads the preimage back from the ASP as proof of payment -// -// Security properties the ASP must enforce (each asserted here): -// - a claim with a WRONG preimage is rejected -// - the bridge cannot spend the HTLC to itself without the preimage -// (theft attempt) — only a full refund to alice is allowed -// - the cooperative refund path works and pays alice -// -// Usage: bun tools/ark/htlc-bridge-test.js (regtest stack + cln-ark up) - -import { execSync } from 'node:child_process'; -import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39'; -import { wordlist } from '@scure/bip39/wordlists/english'; -import { HDKey } from '@scure/bip32'; -import { hex } from '@scure/base'; -import { sha256 } from '@noble/hashes/sha256'; - -import { ArkManager } from '../../src/ark/manager.js'; -import { buildArkoorSend, cosignWithServer } from '../../src/ark/send.js'; -import { decodeVtxo } from '../../src/ark/proto.js'; -import { validateVtxo } from '../../src/ark/validate.js'; -import { getHtlcPreimage, decodeBolt11 } from '../../src/ark/lightning.js'; - -const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535'; -const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002'; -const BARK = `${process.env.HOME}/bark/target/debug/bark`; -const ALICE_CLI = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`; -const CLC = 'docker exec clc lightning-cli --regtest'; - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); -// lightning-cli prints progress lines before the JSON body on some commands -const clc = (cmd) => { - const out = sh(`${CLC} ${cmd}`); - return JSON.parse(out.slice(out.indexOf('{'))); -}; - -let ok = true; -const check = (name, cond, detail = '') => { - console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); - if (!cond) ok = false; -}; - -// two independent wallets -function newWallet() { - const store = { raw: null }; - const account = HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist))) - .derive("m/86'/0'/9'"); - return new ArkManager({ - account, - storage: { load: () => (store.raw ? JSON.parse(store.raw) : null), save: (s) => { store.raw = JSON.stringify(s); } }, - arkUrl: ARK, esploraUrl: ESPLORA, - }).init(); -} - -const alice = await newWallet(); -const bridge = await newWallet(); - -console.log('\n[0] fund both wallets from the bark CLI wallet'); -sh(`${ALICE_CLI} -q send ${alice.address()} "30000 sat"`); -sh(`${ALICE_CLI} -q send ${bridge.address()} "30000 sat"`); -for (let i = 0; i < 12 && (!alice.balance().spendableSat || !bridge.balance().spendableSat); i++) { - await alice.sync(); await bridge.sync(); await sleep(1500); -} -check('alice funded', alice.balance().spendableSat === 30000, JSON.stringify(alice.balance())); -check('bridge funded', bridge.balance().spendableSat === 30000, JSON.stringify(bridge.balance())); - -const tip = await alice.chain.tipHeight(); -// bridge's claim key: index 0 of its own vtxo key chain -const bridgeClaimIndex = 0; -const bridgeClaimPub = hex.encode(bridge._key(bridgeClaimIndex).pubkey); - -// --------------------------------------------------------------------------- -console.log('\n[1] SUBMARINE SWAP: alice pays a clc invoice through the bridge'); -const label = `htlcswap-${Date.now()}`; -const inv = clc(`invoice 5000000 ${label} third-party-htlc-swap`); -const paymentHash = decodeBolt11(inv.bolt11).paymentHash; -console.log(' invoice payment hash:', paymentHash.slice(0, 16) + '…'); - -// 1. alice locks: 5000 sat + a 5 sat bridge fee (undercutting the ASP's 20) -const swapAmount = 5005; -const lock = await alice.htlcLock({ - amountSat: swapAmount, - claimPubkey: bridgeClaimPub, - paymentHash, - htlcExpiry: tip + 100, -}); -check('alice locked into an HTLC', lock.htlcVtxos.length === 1, `${lock.htlcVtxos.length} vtxos`); -const htlcBytes = lock.htlcVtxos[0]; -const htlcVtxo = decodeVtxo(hex.decode(htlcBytes)); -check('htlc policy encodes both parties', htlcVtxo.policy.type === 'htlc' - && htlcVtxo.policy.claimPubkey === bridgeClaimPub - && htlcVtxo.policy.refundPubkey === hex.encode(alice._key(lock.refundIndex).pubkey), - JSON.stringify(htlcVtxo.policy).slice(0, 120)); -check('alice debited', alice.balance().spendableSat === 30000 - swapAmount, JSON.stringify(alice.balance())); - -// 2. the bridge independently validates the HTLC before paying anything -await validateVtxo(htlcVtxo, { - serverPubkey: bridge.serverPub, chain: bridge.chain, allowPolicies: ['htlc'], -}); -check('bridge validated the htlc vtxo chain', true); -check('htlc amount + hash as agreed', htlcVtxo.amountSat === swapAmount - && htlcVtxo.policy.paymentHash === paymentHash); - -// 3. ATTACK: wrong preimage must be rejected by the ASP -let wrongRejected = false; -try { - await bridge.htlcClaim({ - htlcVtxoBytes: htlcBytes, claimKeyIndex: bridgeClaimIndex, - preimage: hex.encode(sha256(new TextEncoder().encode('not-the-preimage'))), - }); -} catch (e) { - wrongRejected = /preimage does not match/i.test(e.message); - if (!wrongRejected) console.log(' unexpected error:', e.message); -} -check('ASP rejects a claim with the wrong preimage', wrongRejected); - -// 4. ATTACK: bridge tries to take the funds without the preimage. Built -// straight against the protocol layer — a malicious bridge wouldn't be -// running our client-side guards, so the ASP has to be the one that refuses. -let theftRejected = false; -try { - const keys = bridge._key(bridgeClaimIndex); - const build = buildArkoorSend({ - input: htlcVtxo, - outputs: [{ amountSat: htlcVtxo.amountSat, userPubkey: bridge._key(1).pubkey }], - serverPubkey: bridge.serverPub, vtxoKeys: keys, - }); - await cosignWithServer(ARK, build, { - input: htlcVtxo, vtxoKeys: keys, serverPubkey: bridge.serverPub, // no preimage - }); -} catch (e) { - theftRejected = /refund pubkey|requires every/i.test(e.message); - if (!theftRejected) console.log(' unexpected error:', e.message); -} -check('ASP rejects a preimage-less spend to the bridge itself (theft)', theftRejected); - -// 5. the bridge pays the invoice for real over Lightning, learning the preimage -const payRes = clc(`pay ${inv.bolt11}`); -check('bridge paid the invoice over LN', payRes.status === 'complete', payRes.status); -const preimage = payRes.payment_preimage; -check('preimage matches the payment hash', hex.encode(sha256(hex.decode(preimage))) === paymentHash); - -// 6. bridge claims the HTLC by revealing the preimage to the ASP -const claimed = await bridge.htlcClaim({ - htlcVtxoBytes: htlcBytes, claimKeyIndex: bridgeClaimIndex, preimage, -}); -check('bridge claimed the htlc', claimed.length === 1); -check('bridge credited', bridge.balance().spendableSat === 30000 + swapAmount, - JSON.stringify(bridge.balance())); - -// 7. alice reads the preimage back from the ASP: proof her payment happened -const revealed = await getHtlcPreimage(ARK, hex.decode(paymentHash)); -check('ASP publishes the revealed preimage', revealed === preimage, `${revealed}`); -check('clc invoice is paid', clc(`listinvoices ${label}`).invoices[0].status === 'paid'); - -// --------------------------------------------------------------------------- -console.log('\n[2] REFUND PATH: a swap the bridge cannot complete'); -const preimage2 = sha256(new TextEncoder().encode('never-revealed-' + Date.now())); -const hash2 = hex.encode(sha256(preimage2)); -const beforeRefund = alice.balance().spendableSat; -const lock2 = await alice.htlcLock({ - amountSat: 3000, claimPubkey: bridgeClaimPub, paymentHash: hash2, htlcExpiry: tip + 100, -}); -check('alice locked the second htlc', lock2.htlcVtxos.length === 1); -check('alice debited again', alice.balance().spendableSat === beforeRefund - 3000); - -// the bridge gives up and cooperatively refunds — the ASP constrains the -// outputs to alice's refund key, so this is safe to expose as a public API -const refundVtxos = await bridge.htlcCosignRefund({ - htlcVtxoBytes: lock2.htlcVtxos[0], claimKeyIndex: bridgeClaimIndex, -}); -const refunded = await alice.acceptHtlcRefund({ - vtxoBytesList: refundVtxos, refundIndex: lock2.refundIndex, -}); -check('alice got her sats back', refunded === 3000, `${refunded} sat`); -check('alice whole again', alice.balance().spendableSat === beforeRefund, - JSON.stringify(alice.balance())); - -// --------------------------------------------------------------------------- -console.log('\n[3] the swapped funds are ordinary spendable vtxos'); -const aliceAddr = sh(`${ALICE_CLI} -q address | grep -o 'tark1[a-z0-9]*'`); -const cliBefore = JSON.parse(sh(`${ALICE_CLI} -q balance`)).spendable_sat; -await bridge.send(aliceAddr, 2000); -const cliAfter = JSON.parse(sh(`${ALICE_CLI} -q balance`)).spendable_sat; -check('bridge can spend claimed funds onward', cliAfter === cliBefore + 2000, `${cliBefore} -> ${cliAfter}`); - -console.log(ok ? '\n✅ SUCCESS: trustless third-party ark<->LN swap works' : '\n❌ some checks failed'); -process.exit(ok ? 0 : 1); -- cgit v1.2.3