aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 15:46:22 -0700
committerAdam Soltys <[email protected]>2026-07-31 15:46:22 -0700
commit52a88f58dba97926c57aad4765c0a67be9b6c9a5 (patch)
treea60c698f41052461e1ab360860da90a083a30a26
parent142fa01322e624ac12dc24ee7af2583bda89e891 (diff)
downloadcoinosv3-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/.gitignore2
-rw-r--r--bridge/config.example.json12
-rw-r--r--bridge/ln.js113
-rw-r--r--bridge/server.js373
-rw-r--r--tools/ark/bridge-e2e-test.js190
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);