aboutsummaryrefslogtreecommitdiffstats
path: root/bridge/server.js
diff options
context:
space:
mode:
Diffstat (limited to 'bridge/server.js')
-rw-r--r--bridge/server.js373
1 files changed, 0 insertions, 373 deletions
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);