diff options
| author | Adam Soltys <[email protected]> | 2026-07-31 00:49:21 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-31 00:49:21 -0700 |
| commit | 78789e0b2eaf91e2de6b4e488324fbec0fcefb9d (patch) | |
| tree | 0fb96db0a8fcc62771bb86e9e0f14791c9bc1715 /tools | |
| parent | 6dc7f9f89a071e43de77bf6dd82cf8d4012ce8c3 (diff) | |
| download | coinosv3-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')
| -rw-r--r-- | tools/ark/ln-test.js | 152 | ||||
| -rw-r--r-- | tools/ark/manager-test.js | 5 |
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 --- |
