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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
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);
|