aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 00:49:21 -0700
committerAdam Soltys <[email protected]>2026-07-31 00:49:21 -0700
commit78789e0b2eaf91e2de6b4e488324fbec0fcefb9d (patch)
tree0fb96db0a8fcc62771bb86e9e0f14791c9bc1715 /tools/ark
parent6dc7f9f89a071e43de77bf6dd82cf8d4012ce8c3 (diff)
downloadcoinosv3-78789e0b2eaf91e2de6b4e488324fbec0fcefb9d.tar.gz
coinosv3-78789e0b2eaf91e2de6b4e488324fbec0fcefb9d.zip
Add Ark <-> Lightning via the ASP (captaind-native submarine swaps)
The ASP is the HTLC counterparty (second.tech's protocol), so no external swap provider is needed: paying a bolt11 converts pubkey vtxos into ServerHtlcSend vtxos the server can only claim by revealing the invoice preimage; receiving grants ServerHtlcRecv vtxos claimed by revealing ours. Failure paths cosign the HTLC back (revocation) — proven end-to-end against regtest captaind, which independently derives the HTLC taproots. - ark/lightning.js: bolt11 decode, lightning fee schedules, all pay/receive gRPC-web wrappers (cosign, initiate, status, revocation, hold-invoice receive flow, anti-DoS attestation) - ark/send.js: arkoor builder generalized to policy outputs (pubkey / serverHtlcSend / serverHtlcRecv), two-leaf taproot trees, HTLC clause scripts, reusable per-part cosign helpers for package RPCs - ark/manager.js: ln-pay and ln-recv crash-safe action state machines (deterministic receive preimages from the seed, chain 5), mailbox lightning notifications, unpayable-invoice -> instant revocation - proto.js: lightning ArkInfo fields + mailbox lightning message decode - validate/refresh: policy-aware vtxo validation and reconstruction - features: bolt11 routes via ark when the balance covers it (Boltz stays the fallback), lightning-invoice receive on the Ark pane - tools/ark/ln-test.js: harness proving HTLC cosign + revocation against captaind; manager-test.js updated for the consolidated bc-container chain Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'tools/ark')
-rw-r--r--tools/ark/ln-test.js152
-rw-r--r--tools/ark/manager-test.js5
2 files changed, 154 insertions, 3 deletions
diff --git a/tools/ark/ln-test.js b/tools/ark/ln-test.js
new file mode 100644
index 0000000..c76d914
--- /dev/null
+++ b/tools/ark/ln-test.js
@@ -0,0 +1,152 @@
+// Lightning-over-ark harness against the regtest captaind.
+//
+// Without a CLN node attached to captaind, a payment can never settle — which
+// is exactly what this proves out: the HTLC cosign (the server independently
+// derives the ServerHtlcSend taproot + sighashes, so a wrong script tree fails
+// the MuSig ceremony), the initiate/status flow, and the revocation path that
+// returns the funds. Receive-side StartLightningReceive is exercised for clean
+// error behavior (hold invoices need CLN).
+//
+// Usage: bun tools/ark/ln-test.js (ark regtest stack up, alice funded)
+
+import { execSync } from 'node:child_process';
+import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { HDKey } from '@scure/bip32';
+import { bech32, hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import { secp256k1 } from '@noble/curves/secp256k1';
+
+import { ArkManager } from '../../src/ark/manager.js';
+import { decodeBolt11 } 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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
+
+let ok = true;
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!cond) ok = false;
+};
+
+// --- minimal bolt11 encoder (regtest, signed with a throwaway key) ---------
+function toWords5(bytes) { return bech32.toWords(bytes); }
+function intToWords(n, len) {
+ const w = [];
+ for (let i = len - 1; i >= 0; i--) w.push((n >> (5 * i)) & 31);
+ return w;
+}
+function tagged(type, words) {
+ const t = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'.indexOf(type);
+ return [t, (words.length >> 5) & 31, words.length & 31, ...words];
+}
+function makeBolt11({ amountSat, paymentHash, privkey }) {
+ const hrp = `lnbcrt${amountSat * 10}n`; // sat -> 10*n (n = 100 msat... 1 sat = 10n)
+ const ts = Math.floor(Date.now() / 1000);
+ const data = [
+ ...intToWords(ts, 7),
+ ...tagged('p', toWords5(paymentHash)), // payment hash (52 words)
+ ...tagged('s', toWords5(crypto.getRandomValues(new Uint8Array(32)))), // payment secret
+ ...tagged('9', intToWords(0b100000100, 2)), // features: var_onion(8) + payment_secret(14)? keep minimal
+ ];
+ // signature: sha256(hrp || data packed 5->8 with zero pad)
+ const padded = data.slice();
+ const dataBytes = (() => {
+ let acc = 0, bits = 0; const out = [];
+ for (const w of padded) { acc = (acc << 5) | w; bits += 5; while (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff); } }
+ if (bits) out.push((acc << (8 - bits)) & 0xff);
+ return Uint8Array.from(out);
+ })();
+ const msg = sha256(new Uint8Array([...new TextEncoder().encode(hrp), ...dataBytes]));
+ const sig = secp256k1.sign(msg, privkey);
+ const sigWords = toWords5(new Uint8Array([...sig.toCompactRawBytes(), sig.recovery]));
+ return bech32.encode(hrp, [...data, ...sigWords], 4000);
+}
+
+// features field: set var_onion_optin (bit 9) + payment_secret (bit 14) required-ish;
+// 2 words = 10 bits only, so re-do with 3 words for bit 14
+function makeInvoice(amountSat) {
+ const privkey = sha256(new TextEncoder().encode('fake-ln-node'));
+ const preimage = crypto.getRandomValues(new Uint8Array(32));
+ const paymentHash = sha256(preimage);
+ const inv = makeBolt11({ amountSat, paymentHash, privkey });
+ return { inv, paymentHash: hex.encode(paymentHash) };
+}
+
+// --- wallet setup: fresh manager funded by alice ---------------------------
+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] lightning fee schedule parsed from ArkInfo');
+check('lnSendFees parsed', mgr.info.lnSendFees.baseFeeSat === 75 && mgr.info.lnSendFees.minFeeSat === 10
+ && mgr.info.lnSendFees.ppmExpiryTable.length === 4, JSON.stringify(mgr.info.lnSendFees));
+check('lnReceiveFees parsed', mgr.info.lnReceiveFees.baseFeeSat === 100 && mgr.info.lnReceiveFees.ppm === 2000,
+ JSON.stringify(mgr.info.lnReceiveFees));
+check('htlc deltas parsed', mgr.info.htlcSendExpiryDelta === 258 && mgr.info.htlcExpiryDelta === 40,
+ JSON.stringify({ send: mgr.info.htlcSendExpiryDelta, recv: mgr.info.htlcExpiryDelta }));
+
+console.log('\n[2] pay: HTLC cosign against the real server (payment must fail: no CLN)');
+const { inv } = makeInvoice(5000);
+const dec = decodeBolt11(inv);
+check('own bolt11 decodes', dec.amountSat === 5000 && dec.network === 'regtest',
+ `${dec.network} ${dec.amountSat} ${dec.paymentHash}`);
+
+let payId = null, payErr = null;
+try {
+ payId = await mgr.payLnInvoice(inv);
+} catch (e) {
+ payErr = e;
+}
+
+if (payErr) {
+ // cosign or initiate rejected — surface exactly where it died
+ console.log(' payLnInvoice error:', payErr.message);
+ const a = mgr.state.actions.find((x) => x.type === 'ln-pay');
+ console.log(' action state:', a ? JSON.stringify({ step: a.step, error: a.error, lastError: a.lastError }) : 'none');
+ check('HTLC cosign accepted by server', a && ['cosigned', 'initiated', 'revoking', 'failed'].includes(a.step),
+ a ? a.step : 'no action');
+} else {
+ const a = mgr.lnAction(payId);
+ console.log(' after first drive: step =', a.step, a.lastError ? `(lastError: ${a.lastError})` : '');
+ check('HTLC cosign accepted by server', ['cosigned', 'initiated', 'revoking', 'failed', 'done'].includes(a.step), a.step);
+ // drive until terminal (initiate will fail without CLN -> revocation)
+ for (let i = 0; i < 20 && !['done', 'failed'].includes(mgr.lnAction(payId).step); i++) {
+ await sleep(1500);
+ await mgr.driveLn(payId);
+ }
+ const fin = mgr.lnAction(payId);
+ console.log(' terminal:', JSON.stringify({ step: fin.step, error: fin.error, lastError: fin.lastError }));
+ check('payment reached a terminal step', ['done', 'failed'].includes(fin.step), fin.step);
+ check('funds recovered after failure', mgr.balance().spendableSat === 25000,
+ JSON.stringify(mgr.balance()));
+ check('failure movement recorded', mgr.movements().some((m) => m.type === 'ln-send' && m.status === 'failed'));
+}
+
+console.log('\n[3] receive: StartLightningReceive error behavior without CLN');
+try {
+ const a = await mgr.createLnInvoice(4000);
+ console.log(' got invoice (server has CLN?):', a.invoice.slice(0, 40) + '…');
+ check('invoice payment hash matches derived preimage', decodeBolt11(a.invoice).paymentHash === a.paymentHash);
+} catch (e) {
+ console.log(' createLnInvoice error (expected without CLN):', e.message);
+ check('receive fails cleanly without CLN', /grpc|lightning|invoice|node|unavailable/i.test(e.message), e.message);
+}
+
+console.log(ok ? '\n✅ SUCCESS' : '\n❌ some checks failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/ark/manager-test.js b/tools/ark/manager-test.js
index 3fab3cf..902d757 100644
--- a/tools/ark/manager-test.js
+++ b/tools/ark/manager-test.js
@@ -22,7 +22,7 @@ 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 BCLI = `bitcoin-cli -regtest -datadir=${process.env.HOME}/ark-regtest/bitcoind -rpcport=18543 -rpcwallet=miner`;
+const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
@@ -110,8 +110,7 @@ const fundingTxid = sh(`${BCLI} sendrawtransaction ${signed.hex}`);
await mgr.completeBoard(actionId, fundingTxid);
check('board waits for confirmations', mgr.pendingActions().length === 1);
mine(2);
-await sleep(2000);
-await mgr.sync();
+for (let i = 0; i < 20 && mgr.pendingActions().length; i++) { await sleep(2000); await mgr.sync(); }
check('board vtxo spendable', mgr.balance().spendableSat === 11000 + 30000 - feeSat, JSON.stringify(mgr.balance()));
// --- 5. refresh: consolidate everything into one vtxo ---