diff options
Diffstat (limited to 'bridge')
| -rw-r--r-- | bridge/.gitignore | 2 | ||||
| -rw-r--r-- | bridge/config.example.json | 12 | ||||
| -rw-r--r-- | bridge/ln.js | 116 | ||||
| -rw-r--r-- | bridge/server.js | 373 |
4 files changed, 0 insertions, 503 deletions
diff --git a/bridge/.gitignore b/bridge/.gitignore deleted file mode 100644 index 45bbf0e..0000000 --- a/bridge/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -data/ -config.json diff --git a/bridge/config.example.json b/bridge/config.example.json deleted file mode 100644 index fee8ad7..0000000 --- a/bridge/config.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index cf2eab4..0000000 --- a/bridge/ln.js +++ /dev/null @@ -1,116 +0,0 @@ -// 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 { - // Raw RPC passthrough — the names registrar drives offers/waitanyinvoice. - call: (method, params) => rpc.call(method, params), - - 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 deleted file mode 100644 index 6a5c04e..0000000 --- a/bridge/server.js +++ /dev/null @@ -1,373 +0,0 @@ -// 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); |
