aboutsummaryrefslogtreecommitdiffstats
path: root/bridge/ln.js
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 /bridge/ln.js
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]>
Diffstat (limited to 'bridge/ln.js')
-rw-r--r--bridge/ln.js113
1 files changed, 113 insertions, 0 deletions
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');
+ },
+ };
+}