diff options
| author | Adam Soltys <[email protected]> | 2026-07-31 15:46:22 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-31 15:46:22 -0700 |
| commit | 52a88f58dba97926c57aad4765c0a67be9b6c9a5 (patch) | |
| tree | a60c698f41052461e1ab360860da90a083a30a26 | |
| parent | 142fa01322e624ac12dc24ee7af2583bda89e891 (diff) | |
| download | coinosv3-52a88f58dba97926c57aad4765c0a67be9b6c9a5.tar.gz coinosv3-52a88f58dba97926c57aad4765c0a67be9b6c9a5.zip | |
Add the trustless Ark->Lightning swap bridge
A third-party swap provider built on the proposed VtxoPolicy::Htlc: users pay
bolt11 invoices from their Ark balance without the ASP being the counterparty
and without trusting the bridge with custody.
Scope is deliberately one-directional. An ASP's lightning *receive* is already
free (0 base / 0 ppm on mainnet); the expensive leg is *send*, with a 20 sat
floor that eats 80% of a 25 sat zap. Only that leg is worth undercutting.
- bridge/server.js: /info, /quote, /swap, /refund over Bun.serve. Validates
the user's HTLC fully (policy, hash, amount, expiry margin, full vtxo chain)
before spending anything on Lightning, pays, then claims with the preimage.
Crash-safe: state is written atomically and every step re-checks reality
(listpays before paying, so a resumed bridge never double-pays).
- bridge/ln.js: minimal CLN backend (unix socket or docker exec), named
params, maxfee-capped pay, pending/paid introspection.
- /refund is safe to expose publicly: the ASP only cosigns a preimage-less
HTLC spend when every output pays the refunder, so it cannot be redirected.
tools/ark/bridge-e2e-test.js drives the real HTTP API against the patched
regtest ASP: a 25 sat zap costs 1 sat, the far-end invoice really settles, the
preimage checks out, resubmission is idempotent, an unroutable invoice reports
refundable rather than done, and the refund lands every sat back.
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | bridge/.gitignore | 2 | ||||
| -rw-r--r-- | bridge/config.example.json | 12 | ||||
| -rw-r--r-- | bridge/ln.js | 113 | ||||
| -rw-r--r-- | bridge/server.js | 373 | ||||
| -rw-r--r-- | tools/ark/bridge-e2e-test.js | 190 |
5 files changed, 690 insertions, 0 deletions
diff --git a/bridge/.gitignore b/bridge/.gitignore new file mode 100644 index 0000000..45bbf0e --- /dev/null +++ b/bridge/.gitignore @@ -0,0 +1,2 @@ +data/ +config.json diff --git a/bridge/config.example.json b/bridge/config.example.json new file mode 100644 index 0000000..fee8ad7 --- /dev/null +++ b/bridge/config.example.json @@ -0,0 +1,12 @@ +{ + "port": 8790, + "network": "regtest", + "ark": "http://127.0.0.1:3535", + "esplora": "http://127.0.0.1:30002", + "mnemonic": "REPLACE with a 12/24-word mnemonic — this wallet holds bridge float", + "stateFile": "./data/bridge.json", + "ln": { "kind": "docker", "container": "clc", "network": "regtest" }, + "fee": { "baseSat": 0, "ppm": 1000, "minSat": 1 }, + "limits": { "minSat": 1, "maxSat": 1000000, "htlcBlocks": 144 }, + "tokens": [] +} diff --git a/bridge/ln.js b/bridge/ln.js new file mode 100644 index 0000000..ac85d6c --- /dev/null +++ b/bridge/ln.js @@ -0,0 +1,113 @@ +// Lightning backend for the swap bridge: pay a bolt11 and report the +// preimage. Deliberately tiny — the bridge only ever needs to *send* over +// Lightning (an ark->LN swap), because an ASP's LN *receive* is already free. +// +// Two adapters, both CLN, both using named parameters: +// 'socket' — lightning-rpc unix socket (production: mount the socket) +// 'docker' — `docker exec <container> lightning-cli` (regtest convenience) + +import { execFile } from 'node:child_process'; +import { createConnection } from 'node:net'; + +const run = (cmd, args) => new Promise((resolve, reject) => { + execFile(cmd, args, { maxBuffer: 8 << 20 }, (err, stdout, stderr) => { + if (err) return reject(new Error((stderr || err.message).trim().slice(0, 400))); + resolve(stdout); + }); +}); + +// CLN prints progress lines before the JSON body on some commands. +const parseJson = (s) => { + const i = s.indexOf('{'); + if (i < 0) throw new Error('no JSON in CLN response: ' + s.slice(0, 200)); + return JSON.parse(s.slice(i)); +}; + +function dockerAdapter({ container, network }) { + return { + async call(method, params = {}) { + const args = ['exec', container, 'lightning-cli', `--${network}`, method, + ...Object.entries(params).map(([k, v]) => `${k}=${v}`)]; + return parseJson(await run('docker', args)); + }, + }; +} + +function socketAdapter({ path }) { + let id = 0; + return { + call(method, params = {}) { + return new Promise((resolve, reject) => { + const sock = createConnection(path); + let buf = ''; + sock.on('error', reject); + sock.on('connect', () => sock.write(JSON.stringify({ + jsonrpc: '2.0', id: ++id, method, params, + }) + '\n')); + sock.on('data', (d) => { + buf += d; + let nl; + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + sock.end(); + if (msg.error) return reject(new Error(JSON.stringify(msg.error).slice(0, 400))); + return resolve(msg.result); + } + }); + }); + }, + }; +} + +export function lnBackend(cfg) { + const rpc = cfg.kind === 'docker' ? dockerAdapter(cfg) : socketAdapter(cfg); + + return { + async info() { + const r = await rpc.call('getinfo'); + return { id: r.id, alias: r.alias, blockheight: r.blockheight, network: r.network }; + }, + + // Spendable outbound capacity, so the bridge can refuse a swap it can't + // route rather than taking an HTLC it will only have to refund. + async outboundSat() { + const r = await rpc.call('listpeerchannels'); + return (r.channels || []) + .filter((c) => c.state === 'CHANNELD_NORMAL' && c.peer_connected) + .reduce((n, c) => n + Math.floor((c.spendable_msat ?? 0) / 1000), 0); + }, + + // Pay a bolt11. Resolves { preimage, feeSat }; throws otherwise. + // maxfee caps routing spend so a swap can't cost more than we charged. + async pay(bolt11, { maxfeeSat, retryFor = 60 } = {}) { + const params = { bolt11, retry_for: retryFor }; + if (maxfeeSat != null) params.maxfee = `${Math.max(0, Math.floor(maxfeeSat))}sat`; + const r = await rpc.call('pay', params); + if (r.status && r.status !== 'complete') throw new Error(`payment not complete: ${r.status}`); + if (!r.payment_preimage) throw new Error('payment returned no preimage'); + return { + preimage: r.payment_preimage, + feeSat: Math.ceil((((r.amount_sent_msat ?? 0) - (r.amount_msat ?? 0)) || 0) / 1000), + }; + }, + + // Has this payment hash already been paid by us? Checked before paying so + // a crashed-and-resumed bridge never double-pays. + async paidPreimage(paymentHash) { + const r = await rpc.call('listpays', { payment_hash: paymentHash }).catch(() => null); + const done = (r?.pays || []).find((p) => p.status === 'complete'); + return done?.preimage || null; + }, + + // Is a payment still in flight? A pending pay must not be retried and + // must not be refunded — its preimage may still arrive. + async isPending(paymentHash) { + const r = await rpc.call('listpays', { payment_hash: paymentHash }).catch(() => null); + return (r?.pays || []).some((p) => p.status === 'pending'); + }, + }; +} diff --git a/bridge/server.js b/bridge/server.js new file mode 100644 index 0000000..6a5c04e --- /dev/null +++ b/bridge/server.js @@ -0,0 +1,373 @@ +// Trustless Ark -> Lightning swap bridge. +// +// A third-party swap provider: users pay bolt11 invoices out of their Ark +// balance without the ASP being the counterparty and without trusting us with +// custody. Requires an ASP supporting VtxoPolicy::Htlc (see the upstream +// proposal in bark's docs/third-party-htlc.md). +// +// Why only ark -> LN: an ASP's lightning *receive* is already free (0 base, +// 0 ppm on mainnet today). The expensive direction is *send*, which has a +// 20 sat floor — 80% of a 25 sat zap. That's the leg worth undercutting; the +// receive leg stays on the ASP's native path. +// +// The swap: +// 1. GET /quote?invoice=... -> amount, fee, our claim pubkey, expiry +// 2. POST /swap {invoice, htlcVtxo} +// the user has locked funds in an HTLC that only we can claim, and +// only by revealing the invoice preimage. We validate it, pay the +// invoice, then claim with the preimage. +// 3. POST /refund {paymentHash} +// we couldn't pay: we cosign the HTLC back to the user's refund key. +// The ASP constrains such a spend to pay only the refunder, so this +// endpoint cannot be turned into a redirect. +// +// What the user risks: nothing. If we vanish between lock and claim, the HTLC +// expires and they exit through the refund leaf. We cannot take the funds +// without the preimage, which we only have if we paid their invoice. +// +// What WE risk: paying an invoice and then failing to claim. Mitigated by +// validating before paying, claiming immediately after, and retrying on +// resume — we hold the preimage, so the claim stays available (worst case +// through an on-chain exit of the HTLC leaf). + +import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; + +import { ArkManager } from '../src/ark/manager.js'; +import { decodeVtxo } from '../src/ark/proto.js'; +import { validateVtxo } from '../src/ark/validate.js'; +import { decodeBolt11 } from '../src/ark/lightning.js'; +import { lnBackend } from './ln.js'; + +// --------------------------------------------------------------------------- +// config +// --------------------------------------------------------------------------- + +const CONFIG_PATH = process.env.BRIDGE_CONFIG || join(import.meta.dir, 'config.json'); +const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); +// { +// "port": 8790, "network": "regtest", +// "ark": "http://127.0.0.1:3535", "esplora": "http://127.0.0.1:30002", +// "mnemonic": "...", "stateFile": "./data/bridge.json", +// "ln": { "kind": "docker", "container": "clc", "network": "regtest" }, +// "fee": { "baseSat": 0, "ppm": 1000, "minSat": 1 }, +// "limits": { "minSat": 1, "maxSat": 1000000, "htlcBlocks": 144 }, +// "tokens": ["..."] // omit or empty to run open +// } + +const FEE = { baseSat: 0, ppm: 1000, minSat: 1, ...(cfg.fee || {}) }; +const LIMITS = { minSat: 1, maxSat: 1_000_000, htlcBlocks: 144, ...(cfg.limits || {}) }; +const TOKENS = new Set(cfg.tokens || []); +const STATE_PATH = cfg.stateFile || join(import.meta.dir, 'data', 'bridge.json'); + +const log = (...a) => console.log(new Date().toISOString(), ...a); + +// The fee we charge for a swap. Deliberately simple and cheap: our real cost +// is the routing fee plus an amortized slice of rebalancing. +const swapFee = (amountSat) => + Math.max(FEE.minSat, FEE.baseSat + Math.ceil((amountSat * FEE.ppm) / 1_000_000)); + +// --------------------------------------------------------------------------- +// state (swap records, crash-safe via atomic replace) +// --------------------------------------------------------------------------- + +function loadState() { + try { return JSON.parse(readFileSync(STATE_PATH, 'utf8')); } catch { return { swaps: {} }; } +} +let state = loadState(); +function saveState() { + mkdirSync(dirname(STATE_PATH), { recursive: true }); + const tmp = STATE_PATH + '.tmp'; + writeFileSync(tmp, JSON.stringify(state, null, 1)); + renameSync(tmp, STATE_PATH); // atomic +} + +// --------------------------------------------------------------------------- +// wallets +// --------------------------------------------------------------------------- + +const account = HDKey.fromMasterSeed(mnemonicToSeedSync(cfg.mnemonic)).derive("m/86'/0'/9'"); +const arkStore = { + load: () => state.ark || null, + save: (s) => { state.ark = s; saveState(); }, +}; +const ark = await new ArkManager({ + account, storage: arkStore, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: cfg.network, +}).init(); +const ln = lnBackend(cfg.ln); + +// Our HTLC claim key. Fixed index so it survives restarts and users can pin it. +const CLAIM_INDEX = 0; +const claimPubkey = hex.encode(ark._key(CLAIM_INDEX).pubkey); + +// --------------------------------------------------------------------------- +// swap engine +// --------------------------------------------------------------------------- + +const swap = (hash) => state.swaps[hash]; +function putSwap(hash, patch) { + state.swaps[hash] = { ...(state.swaps[hash] || {}), ...patch, updated: Date.now() }; + saveState(); + return state.swaps[hash]; +} + +// Validate a user's HTLC lock. Everything here must pass BEFORE we spend a +// satoshi on Lightning — this is the only thing standing between us and +// paying an invoice against a worthless VTXO. +async function validateLock({ htlcVtxoBytes, invoice, dec }) { + const bytes = hex.decode(htlcVtxoBytes); + const v = decodeVtxo(bytes); + + if (v.policy.type !== 'htlc') throw new Error('vtxo is not an HTLC policy'); + if (v.policy.claimPubkey !== claimPubkey) throw new Error('HTLC is not claimable by this bridge'); + if (v.policy.paymentHash !== dec.paymentHash) throw new Error('HTLC payment hash does not match the invoice'); + + const amountSat = dec.amountSat; + const fee = swapFee(amountSat); + if (v.amountSat < amountSat + fee) { + throw new Error(`HTLC underfunded: ${v.amountSat} < ${amountSat} + ${fee} fee`); + } + + // Expiry must leave us enough room to pay and claim. Too short and we could + // pay an invoice and then watch the user refund it out from under us. + const tip = await ark.chain.tipHeight(); + const left = v.policy.htlcExpiry - tip; + if (left < Math.floor(LIMITS.htlcBlocks / 2)) { + throw new Error(`HTLC expires too soon (${left} blocks left)`); + } + + // Full chain validation against the ASP + the chain: this proves the VTXO + // exists, is properly cosigned and is anchored on-chain. + await validateVtxo(v, { + serverPubkey: ark.serverPub, chain: ark.chain, allowPolicies: ['htlc'], + }); + + return { vtxo: v, amountSat, fee }; +} + +// Drive one swap to a terminal state. Idempotent and safe to re-enter: each +// step checks reality (has the invoice been paid? is the HTLC already spent?) +// rather than trusting the stored step alone. +async function driveSwap(hash) { + const s = swap(hash); + if (!s || ['done', 'refunded', 'failed'].includes(s.step)) return s; + + // Never pay twice: ask the node what it actually did. + if (s.step === 'validated' || s.step === 'paying') { + const already = await ln.paidPreimage(hash); + if (already) putSwap(hash, { step: 'paid', preimage: already }); + else if (await ln.isPending(hash)) return swap(hash); // in flight; come back later + } + + if (swap(hash).step === 'validated') { + putSwap(hash, { step: 'paying' }); + try { + const { preimage, feeSat } = await ln.pay(s.invoice, { maxfeeSat: s.fee }); + if (hex.encode(sha256(hex.decode(preimage))) !== hash) { + throw new Error('node returned a preimage that does not match the payment hash'); + } + putSwap(hash, { step: 'paid', preimage, routingFeeSat: feeSat }); + log(`paid ${hash.slice(0, 12)} amount=${s.amountSat} routing=${feeSat} charged=${s.fee}`); + } catch (e) { + // The payment failed; the user's money is untouched. Offer the refund. + log(`pay failed ${hash.slice(0, 12)}: ${e.message}`); + putSwap(hash, { step: 'refundable', error: e.message }); + return swap(hash); + } + } + + if (swap(hash).step === 'paid') { + // We hold the preimage, so this claim can always be made — retry until it + // lands (and in the worst case it is claimable on-chain via the leaf). + try { + await ark.htlcClaim({ + htlcVtxoBytes: swap(hash).htlcVtxo, + claimKeyIndex: CLAIM_INDEX, + preimage: swap(hash).preimage, + }); + putSwap(hash, { step: 'done' }); + log(`claimed ${hash.slice(0, 12)} — swap complete`); + } catch (e) { + if (/already spent|not spendable/i.test(e.message)) { + putSwap(hash, { step: 'done', note: 'htlc already spent' }); + } else { + putSwap(hash, { claimError: e.message }); + log(`claim failed ${hash.slice(0, 12)} (will retry): ${e.message}`); + } + } + } + + return swap(hash); +} + +// Resume anything in flight, then keep nudging. +async function resumeAll() { + for (const hash of Object.keys(state.swaps)) { + await driveSwap(hash).catch((e) => log('resume error', hash.slice(0, 12), e.message)); + } +} + +// --------------------------------------------------------------------------- +// HTTP API +// --------------------------------------------------------------------------- + +const json = (body, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json', + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'content-type,authorization', + 'access-control-allow-methods': 'GET,POST,OPTIONS', + }, +}); +const fail = (msg, status = 400) => json({ error: msg }, status); + +function authed(req) { + if (!TOKENS.size) return true; // open mode + const h = req.headers.get('authorization') || ''; + return TOKENS.has(h.replace(/^Bearer\s+/i, '').trim()); +} + +const server = Bun.serve({ + port: cfg.port || 8790, + async fetch(req) { + const url = new URL(req.url); + if (req.method === 'OPTIONS') return json({}); + + try { + // Public: what this bridge is and what it charges. + if (url.pathname === '/info') { + return json({ + network: cfg.network, + ark: cfg.ark, + claimPubkey, + fee: FEE, + limits: LIMITS, + requiresToken: TOKENS.size > 0, + outboundSat: await ln.outboundSat().catch(() => null), + }); + } + + if (url.pathname === '/quote') { + const invoice = url.searchParams.get('invoice'); + if (!invoice) return fail('missing invoice'); + let dec; + try { dec = decodeBolt11(invoice); } catch (e) { return fail('bad invoice: ' + e.message); } + if (!dec.amountSat) return fail('invoice has no amount'); + if (dec.amountSat < LIMITS.minSat || dec.amountSat > LIMITS.maxSat) { + return fail(`amount out of range (${LIMITS.minSat}–${LIMITS.maxSat} sat)`); + } + if (Date.now() > dec.expiresAt) return fail('invoice expired'); + const outbound = await ln.outboundSat().catch(() => 0); + if (outbound < dec.amountSat) return fail('insufficient outbound liquidity', 503); + const tip = await ark.chain.tipHeight(); + return json({ + amountSat: dec.amountSat, + feeSat: swapFee(dec.amountSat), + totalSat: dec.amountSat + swapFee(dec.amountSat), + paymentHash: dec.paymentHash, + claimPubkey, + htlcExpiry: tip + LIMITS.htlcBlocks, + }); + } + + if (url.pathname === '/swap' && req.method === 'POST') { + if (!authed(req)) return fail('unauthorized', 401); + const body = await req.json(); + const { invoice, htlcVtxo } = body || {}; + if (!invoice || !htlcVtxo) return fail('missing invoice or htlcVtxo'); + + let dec; + try { dec = decodeBolt11(invoice); } catch (e) { return fail('bad invoice: ' + e.message); } + const hash = dec.paymentHash; + + const existing = swap(hash); + if (existing) { + // idempotent: same swap resubmitted (client retry / crash recovery) + return json(publicSwap(hash, await driveSwap(hash))); + } + if (!dec.amountSat) return fail('invoice has no amount'); + if (Date.now() > dec.expiresAt) return fail('invoice expired'); + + let checked; + try { + checked = await validateLock({ htlcVtxoBytes: htlcVtxo, invoice, dec }); + } catch (e) { + return fail('invalid HTLC: ' + e.message); + } + + putSwap(hash, { + step: 'validated', invoice, htlcVtxo, + amountSat: checked.amountSat, fee: checked.fee, + htlcAmountSat: checked.vtxo.amountSat, + created: Date.now(), + }); + log(`accepted ${hash.slice(0, 12)} amount=${checked.amountSat} fee=${checked.fee}`); + + return json(publicSwap(hash, await driveSwap(hash))); + } + + // Poll a swap (or nudge a stuck one forward). + if (url.pathname === '/swap' && req.method === 'GET') { + const hash = url.searchParams.get('paymentHash'); + if (!hash || !swap(hash)) return fail('unknown swap', 404); + return json(publicSwap(hash, await driveSwap(hash))); + } + + // Give up on a swap we couldn't pay: cosign the HTLC back to the user. + // Safe to expose — the ASP only cosigns a preimage-less HTLC spend when + // every output pays the refund pubkey, so we cannot redirect it. + if (url.pathname === '/refund' && req.method === 'POST') { + const body = await req.json(); + const hash = body?.paymentHash; + const s = hash && swap(hash); + if (!s) return fail('unknown swap', 404); + if (s.step === 'done') return fail('swap already completed'); + if (s.step === 'paid') return fail('invoice was paid; the swap will complete'); + if (await ln.isPending(hash)) return fail('payment still in flight, try again shortly', 409); + if (s.step === 'refunded') return json({ paymentHash: hash, step: 'refunded', refundVtxos: s.refundVtxos }); + + const refundVtxos = await ark.htlcCosignRefund({ + htlcVtxoBytes: s.htlcVtxo, claimKeyIndex: CLAIM_INDEX, + }); + putSwap(hash, { step: 'refunded', refundVtxos }); + log(`refunded ${hash.slice(0, 12)}`); + return json({ paymentHash: hash, step: 'refunded', refundVtxos }); + } + + return fail('not found', 404); + } catch (e) { + log('handler error:', e.stack || e.message); + return fail('internal error: ' + e.message, 500); + } + }, +}); + +// What we tell the client — never the raw stored record. +function publicSwap(hash, s = swap(hash)) { + return { + paymentHash: hash, + step: s.step, + amountSat: s.amountSat, + feeSat: s.fee, + preimage: s.preimage || null, // the user's proof of payment + error: s.error || s.claimError || null, + refundable: s.step === 'refundable', + }; +} + +log(`bridge listening on :${server.port}`); +log(` network=${cfg.network} ark=${cfg.ark}`); +log(` claim pubkey ${claimPubkey}`); +log(` ark balance ${ark.balance().spendableSat} sat`); +log(` fee: max(${FEE.minSat}, ${FEE.baseSat} + ${FEE.ppm}ppm)`); +try { log(` ln node ${(await ln.info()).id.slice(0, 16)}… outbound=${await ln.outboundSat()} sat`); } +catch (e) { log(' WARNING: lightning backend unreachable:', e.message); } + +await resumeAll(); +setInterval(() => { resumeAll().catch(() => {}); }, 15_000); +setInterval(() => { ark.sync().catch(() => {}); }, 30_000); diff --git a/tools/ark/bridge-e2e-test.js b/tools/ark/bridge-e2e-test.js new file mode 100644 index 0000000..b0f7146 --- /dev/null +++ b/tools/ark/bridge-e2e-test.js @@ -0,0 +1,190 @@ +// 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); |
