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
|
// FULL end-to-end lightning-over-ark test against the regtest stack:
// captaind wired to a dedicated CLN node (cln-ark, hold plugin + xpay) with a
// direct channel to the coinos dev node `clc` as counterparty.
//
// pay: ark vtxo -> HTLC cosign -> captaind pays clc's invoice via xpay
// -> preimage settles the action, clc sees the invoice paid
// receive: hal mints a hold invoice -> clc pays it -> captaind grants
// HTLC-recv vtxos -> hal claims with the preimage -> spendable
//
// Usage: bun tools/ark/ln-e2e-test.js (stack up, alice funded)
import { execSync, spawn } from 'node:child_process';
import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { HDKey } from '@scure/bip32';
import { hex } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import { ArkManager } from '../../src/ark/manager.js';
import { lnReceiveFee } from '../../src/ark/lightning.js';
import { ALICE, aliceReady } from './alice.js';
const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002';
const CLC = 'docker exec clc lightning-cli --regtest';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
const clc = (cmd) => JSON.parse(sh(`${CLC} ${cmd}`));
let ok = true;
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
if (!cond) ok = false;
};
const store = { raw: null };
const storage = {
load: () => (store.raw ? JSON.parse(store.raw) : null),
save: (s) => { store.raw = JSON.stringify(s); },
};
const mnemonic = generateMnemonic(wordlist);
const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'");
const mgr = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init();
console.log('\n[0] fund manager from alice');
await aliceReady();
sh(`${ALICE} -q send ${mgr.address()} "25000 sat"`);
for (let i = 0; i < 10 && !mgr.balance().spendableSat; i++) { await mgr.sync(); await sleep(1500); }
check('funded 25000 sat', mgr.balance().spendableSat === 25000, JSON.stringify(mgr.balance()));
// ---------------------------------------------------------------------------
console.log('\n[1] PAY: hal (ark) pays a 5000-sat invoice minted by clc');
const label = `arkpay-${Date.now()}`;
const invRes = clc(`invoice 5000000 ${label} ark-e2e-pay`);
console.log(' clc invoice:', invRes.bolt11.slice(0, 50) + '…');
const payId = await mgr.payLnInvoice(invRes.bolt11);
let a = mgr.lnAction(payId);
console.log(' after first drive: step =', a.step);
for (let i = 0; i < 40 && !['done', 'failed'].includes(mgr.lnAction(payId).step); i++) {
await sleep(1500);
await mgr.driveLn(payId);
}
a = mgr.lnAction(payId);
console.log(' terminal:', JSON.stringify({ step: a.step, error: a.error, lastError: a.lastError, feeSat: a.feeSat }));
check('payment settled', a.step === 'done', a.step);
check('preimage matches payment hash', a.preimage && hex.encode(sha256(hex.decode(a.preimage))) === a.paymentHash);
const clcInv = clc(`listinvoices ${label}`).invoices[0];
check('clc invoice paid', clcInv.status === 'paid', clcInv.status);
check('balance decremented by amount+fee', mgr.balance().spendableSat === 25000 - 5000 - a.feeSat,
JSON.stringify(mgr.balance()));
check('ln-send movement complete', mgr.movements().some((m) => m.type === 'ln-send' && m.status === 'complete'));
// ---------------------------------------------------------------------------
console.log('\n[1b] PAY dust: 25-sat zap-size invoice (HTLC output < dust -> isolation)');
const label2 = `arkdust-${Date.now()}`;
const invDust = clc(`invoice 25000 ${label2} ark-e2e-dust`);
const balBeforeDust = mgr.balance().spendableSat;
const dustId = await mgr.payLnInvoice(invDust.bolt11);
for (let i = 0; i < 40 && !['done', 'failed'].includes(mgr.lnAction(dustId).step); i++) {
await sleep(1500);
await mgr.driveLn(dustId);
}
const d = mgr.lnAction(dustId);
console.log(' terminal:', JSON.stringify({ step: d.step, error: d.error, lastError: d.lastError, feeSat: d.feeSat, htlcs: (d.htlcVtxoIds || []).length }));
check('dust payment settled', d.step === 'done', d.step + (d.lastError ? ' / ' + d.lastError : ''));
check('clc dust invoice paid', clc(`listinvoices ${label2}`).invoices[0].status === 'paid');
check('dust balance decremented correctly', mgr.balance().spendableSat === balBeforeDust - 25 - d.feeSat,
`${balBeforeDust} -> ${mgr.balance().spendableSat}, fee ${d.feeSat}`);
// ---------------------------------------------------------------------------
console.log('\n[1c] tiny plain ark send (100 sat with change -> isolation)');
const aliceBefore = JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat;
const tinyAddr = sh(`${ALICE} -q address | grep -o 'tark1[a-z0-9]*'`);
await mgr.send(tinyAddr, 100);
const aliceAfter = JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat;
check('alice +100 from tiny send', aliceAfter === aliceBefore + 100, `${aliceBefore} -> ${aliceAfter}`);
// ---------------------------------------------------------------------------
console.log('\n[2] RECEIVE: clc pays a 4000-sat invoice minted by hal (hold flow)');
// both CLN nodes must have caught up with blocks earlier tests mined, or the
// HTLC's cltv disagrees with the hold invoice (final_incorrect_cltv_expiry)
const tip = Number(sh('docker exec bc bitcoin-cli getblockcount'));
const synced = (node) => JSON.parse(sh(`docker exec ${node} lightning-cli --regtest getinfo`)).blockheight >= tip;
for (let i = 0; i < 40 && !(synced('clc') && synced('cln-ark')); i++) await sleep(500);
const balBefore = mgr.balance().spendableSat;
const recv = await mgr.createLnInvoice(4000, 'ark-e2e-receive');
console.log(' hal invoice:', recv.invoice.slice(0, 50) + '…');
// clc's pay blocks until we claim (hold invoice) — run it in the background
const payer = spawn('docker', ['exec', 'clc', 'lightning-cli', '--regtest', 'pay', recv.invoice]);
let payerOut = '';
payer.stdout.on('data', (d) => { payerOut += d; });
payer.stderr.on('data', (d) => { payerOut += d; });
const payerDone = new Promise((r) => payer.on('close', (code) => r(code)));
for (let i = 0; i < 40 && !['done', 'failed'].includes(mgr.lnAction(recv.id).step); i++) {
await sleep(1500);
// fresh pool vtxos anchor to a possibly-unconfirmed issuance round — keep
// blocks coming so the HTLC vtxo validation can pass
if (i % 3 === 2) sh('docker exec bc bitcoin-cli -rpcwallet=coinos -generate 1 >/dev/null');
await mgr.driveLn(recv.id);
}
const r = mgr.lnAction(recv.id);
console.log(' terminal:', JSON.stringify({ step: r.step, error: r.error, lastError: r.lastError }));
check('receive claimed', r.step === 'done', r.step);
const payerCode = await Promise.race([payerDone, sleep(20000).then(() => 'timeout')]);
check('clc pay completed', payerCode === 0, `exit ${payerCode}: ${payerOut.slice(0, 120)}`);
const fee = lnReceiveFee(4000, mgr.info.lnReceiveFees);
check(`balance grew by 4000 - ${fee} fee`, mgr.balance().spendableSat === balBefore + 4000 - fee,
`${balBefore} -> ${mgr.balance().spendableSat}`);
check('ln-receive movement complete', mgr.movements().some((m) => m.type === 'ln-receive' && m.status === 'complete'));
check('receive counts as unseen (celebration)', mgr.unseenReceives().length >= 1);
// ---------------------------------------------------------------------------
console.log('\n[3] spend the claimed vtxos back to alice (they must be real)');
const aliceAddr = sh(`${ALICE} -q address | grep -o 'tark1[a-z0-9]*'`);
await mgr.send(aliceAddr, 2000);
check('claimed funds spendable', mgr.movements().some((m) => m.type === 'send' && m.status === 'complete' && m.amountSat === 2000));
console.log(ok ? '\n✅ SUCCESS: full ark<->lightning round trip' : '\n❌ some checks failed');
process.exit(ok ? 0 : 1);
|