summaryrefslogtreecommitdiffstats
path: root/tools/ark/ln-test.js
blob: c76d914dbe01c938dc1078c8fdbb7e483dc11f7b (plain) (blame)
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
// 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);