aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 01:10:13 -0700
committerAdam Soltys <[email protected]>2026-07-31 01:10:13 -0700
commit72e407bf6d7d09923d15461be82685017a0887be (patch)
treed9f9dbbf6955384ea10e1b1ada046258158e4fcc /tools
parent78789e0b2eaf91e2de6b4e488324fbec0fcefb9d (diff)
downloadcoinosv3-72e407bf6d7d09923d15461be82685017a0887be.tar.gz
coinosv3-72e407bf6d7d09923d15461be82685017a0887be.zip
Dust-isolate arkoor outputs; prove lightning E2E on regtest
The server rejects an arkoor mixing dust and non-dust outputs, which broke small Lightning payments (a 45-sat HTLC next to change) and tiny plain sends. Mirror bark's isolation: dust outputs route through a combined isolation output spent by a fanout tx, splitting a non-dust output to pad the dust below-threshold. Outputs can therefore split across two vtxos, so the manager now classifies results by policy instead of index (legacy single-field actions still resume). Regtest now runs the full stack: dedicated cln-ark node (secondark/cln-hold, hold plugin + xpay) wired into captaind with a channel to the coinos dev clc node. tools/ark/ln-e2e-test.js proves pay (5000 sat and a 25-sat dust zap), hold-invoice receive with claim, and spending the claimed vtxos. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'tools')
-rw-r--r--tools/ark/ln-e2e-test.js137
1 files changed, 137 insertions, 0 deletions
diff --git a/tools/ark/ln-e2e-test.js b/tools/ark/ln-e2e-test.js
new file mode 100644
index 0000000..b11f542
--- /dev/null
+++ b/tools/ark/ln-e2e-test.js
@@ -0,0 +1,137 @@
+// 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';
+
+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 BARK = `${process.env.HOME}/bark/target/debug/bark`;
+const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`;
+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');
+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)');
+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);
+ 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);