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 /bridge/server.js | |
| 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]>
Diffstat (limited to 'bridge/server.js')
| -rw-r--r-- | bridge/server.js | 373 |
1 files changed, 373 insertions, 0 deletions
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); |
