1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
|
// 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');
},
};
}
|