aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 09:13:31 -0700
committerAdam Soltys <[email protected]>2026-10-07 09:13:31 -0700
commit95b72a73545cd2a3700b0e0866eee54e59d3b50e (patch)
tree81cc50c71d5e6cb78beaa1a7f755a23691f8983f
parent3aff6e4cc9eb97fc0fa488f1f95001f69f24330a (diff)
parentdf87bd84560fec4b085b6f0f4785b61502ffd480 (diff)
downloadcoinosv3-95b72a73545cd2a3700b0e0866eee54e59d3b50e.tar.gz
coinosv3-95b72a73545cd2a3700b0e0866eee54e59d3b50e.zip
Merge branch 'worktree-agent-a9ba2f8bea9a7dafc'
-rw-r--r--tools/ark/alice.js49
-rw-r--r--tools/ark/board-test.js10
-rw-r--r--tools/ark/bridge-e2e-test.js190
-rw-r--r--tools/ark/expired-dust-test.js11
-rw-r--r--tools/ark/gift-shot.js2
-rw-r--r--tools/ark/htlc-bridge-test.js201
-rw-r--r--tools/ark/ln-e2e-test.js9
-rw-r--r--tools/ark/ln-test.js4
-rw-r--r--tools/ark/manager-test.js4
-rw-r--r--tools/ark/receive-test.js5
-rw-r--r--tools/ark/refresh-test.js8
-rw-r--r--tools/ark/scheduled-refresh-test.js17
-rw-r--r--tools/ark/ui-test.js266
-rw-r--r--tools/bolt12-test.js11
-rw-r--r--tools/electrum-ws.test.js66
-rw-r--r--tools/fixtures/bolt12-invoice.txt1
-rw-r--r--tools/fixtures/bolt12-offer.txt1
-rw-r--r--tools/swap-vectors.test.js42
18 files changed, 262 insertions, 635 deletions
diff --git a/tools/ark/alice.js b/tools/ark/alice.js
new file mode 100644
index 0000000..b270edd
--- /dev/null
+++ b/tools/ark/alice.js
@@ -0,0 +1,49 @@
+// alice = the bark CLI wallet the regtest ark tests fund from.
+//
+// scheduled-refresh-test mines a coin's whole lifetime, which expires alice's
+// coins too — an expired vtxo can be refreshed but not sent, so any test that
+// runs after it would fund nothing ("spendableSat 0") or hand out an expired
+// coin. Every funding test also drains her a little. aliceReady() refreshes
+// expired coins back to life and boards more when she runs low; a no-op when
+// she's healthy.
+
+import { execSync } from 'node:child_process';
+
+export const ALICE = `${process.env.HOME}/bark/target/debug/bark --datadir ${process.env.HOME}/ark-regtest/alice`;
+const BCLI = 'docker exec bc bitcoin-cli -rpcwallet=coinos';
+const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
+const mine = (n) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`);
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const LOW_SAT = 200_000;
+const TOP_UP_BTC = '0.01';
+
+export const aliceBalance = () => JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat;
+const expiredCoins = () => {
+ const tip = Number(sh(`${BCLI} getblockcount`));
+ return JSON.parse(sh(`${ALICE} -q vtxos`)).filter((v) => v.expiry_height <= tip);
+};
+
+export async function aliceReady() {
+ if (expiredCoins().length) {
+ console.log(' … refreshing alice\'s expired coins first');
+ // maintain = refresh what's expiring + resume any action a killed run left
+ // half-done (those hold their coins locked); errors surface below as a
+ // balance that never comes back
+ try { sh(`${ALICE} -q maintain >/dev/null 2>&1`); } catch {}
+ // the round's output turns spendable once its funding tx confirms and the
+ // indexer has seen it — keep mining until bark agrees
+ for (let i = 0; i < 20 && (expiredCoins().length || !aliceBalance()); i++) { mine(1); await sleep(1500); }
+ }
+ if (aliceBalance() < LOW_SAT) {
+ console.log(` … topping alice up (board ${TOP_UP_BTC} BTC)`);
+ const { address } = JSON.parse(sh(`${ALICE} -q onchain address`));
+ sh(`${BCLI} sendtoaddress ${address} ${TOP_UP_BTC} >/dev/null`);
+ mine(1);
+ for (let i = 0; i < 20; i++) {
+ try { sh(`${ALICE} -q board --all >/dev/null 2>&1`); break; } catch { await sleep(1500); } // wallet sync lag
+ }
+ const before = aliceBalance();
+ for (let i = 0; i < 20 && aliceBalance() <= before; i++) { mine(1); await sleep(1500); }
+ }
+}
diff --git a/tools/ark/board-test.js b/tools/ark/board-test.js
index 40cf0aa..b117fd0 100644
--- a/tools/ark/board-test.js
+++ b/tools/ark/board-test.js
@@ -28,7 +28,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 BOARD_SAT = 30000;
const SPEND_SAT = 5000;
@@ -76,6 +76,7 @@ const nonces = musig2.nonceGen(keys.vtxo.pubkey, keys.vtxo.privkey, undefined, b
const cosign = await requestBoardCosign(ARK, {
amountSat: BOARD_SAT, fundingOutpointRaw, expiryHeight,
userPub: keys.vtxo.pubkey, pubNonce: nonces.public,
+ fundingTxBytes: hex.decode(signed.hex), // require_board_funding_tx
});
const finalSig = combineBoardSignature({ board, serverCosign: cosign, userNonces: nonces, vtxoKeys: keys.vtxo, serverPub });
console.log('✓ board exit tx MuSig2 signature combined and verified');
@@ -90,6 +91,13 @@ console.log(`board vtxo: ${vtxo.id} (${vtxo.amountSat} sat)`);
// --- 4. confirm, register, prove spendability by paying alice ---
execSync(`${BCLI} generatetoaddress 2 $(${BCLI} getnewaddress) >/dev/null`, { shell: '/bin/bash' });
+// bark validates received vtxos against esplora and silently DROPS ones whose
+// board tx it can't see yet, so wait for the indexer before paying alice
+for (let i = 0; i < 60; i++) {
+ const st = await fetch(`${ESPLORA}/tx/${fundingTxid}/status`).then((r) => r.ok ? r.json() : {}).catch(() => ({}));
+ if (st.confirmed) break;
+ await new Promise((r) => setTimeout(r, 500));
+}
await new Promise((r) => setTimeout(r, 2000)); // let the server index the blocks
console.log('mined 2 blocks (board confirmed)');
diff --git a/tools/ark/bridge-e2e-test.js b/tools/ark/bridge-e2e-test.js
deleted file mode 100644
index b0f7146..0000000
--- a/tools/ark/bridge-e2e-test.js
+++ /dev/null
@@ -1,190 +0,0 @@
-// End-to-end test of the swap bridge over its real HTTP API, against the
-// patched regtest ASP (VtxoPolicy::Htlc).
-//
-// user wallet --HTLC--> bridge --pays bolt11--> clb (invoice issuer)
-// bridge --claims w/ preimage
-//
-// Also asserts the failure path: an unpayable invoice leaves the user's money
-// untouched and refundable, and the refund really lands back in their wallet.
-//
-// Usage: bun tools/ark/bridge-e2e-test.js (regtest stack + cln-ark up)
-
-import { execSync, spawn } from 'node:child_process';
-import { writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs';
-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 { decodeBolt11 } from '../../src/ark/lightning.js';
-
-const ARK = 'http://127.0.0.1:3535';
-const ESPLORA = 'http://127.0.0.1:30002';
-const BRIDGE = 'http://127.0.0.1:8791';
-const BARK = `${process.env.HOME}/bark/target/debug/bark`;
-const ALICE_CLI = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`;
-const DIR = '/tmp/claude-1000/-home-adam-halwallet/f5f3f3a1-dd64-470c-80d8-1b1a87700326/scratchpad/bridge-test';
-
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
-const cln = (node, cmd) => {
- const out = sh(`docker exec ${node} lightning-cli --regtest ${cmd}`);
- return JSON.parse(out.slice(out.indexOf('{')));
-};
-
-let ok = true;
-const check = (name, cond, detail = '') => {
- console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
- if (!cond) ok = false;
-};
-
-// --- bridge wallet + config ------------------------------------------------
-rmSync(DIR, { recursive: true, force: true });
-mkdirSync(DIR, { recursive: true });
-const bridgeMnemonic = generateMnemonic(wordlist);
-// the bridge pays from clc, which has ~406k sat outbound toward cln-ark —
-// so cln-ark plays the role of the merchant being paid. clb has no route
-// from clc (zero outbound toward the hub), which we use for the failure case.
-writeFileSync(`${DIR}/config.json`, JSON.stringify({
- port: 8791, network: 'regtest', ark: ARK, esplora: ESPLORA,
- mnemonic: bridgeMnemonic,
- stateFile: `${DIR}/bridge.json`,
- ln: { kind: 'docker', container: 'clc', network: 'regtest' },
- fee: { baseSat: 0, ppm: 1000, minSat: 1 },
- limits: { minSat: 1, maxSat: 1000000, htlcBlocks: 144 },
- tokens: ['test-token'],
-}, null, 1));
-
-// fund the bridge's ark wallet so it can pay out claims / hold float
-const bridgeAccount = HDKey.fromMasterSeed(mnemonicToSeedSync(bridgeMnemonic)).derive("m/86'/0'/9'");
-const bridgeWallet = await new ArkManager({
- account: bridgeAccount, storage: { load: () => null, save: () => {} },
- arkUrl: ARK, esploraUrl: ESPLORA, network: 'regtest',
-}).init();
-
-console.log('\n[0] fund the user and the bridge');
-const user = await new ArkManager({
- account: HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist))).derive("m/86'/0'/9'"),
- storage: { load: () => null, save: () => {} },
- arkUrl: ARK, esploraUrl: ESPLORA, network: 'regtest',
-}).init();
-sh(`${ALICE_CLI} -q send ${user.address()} "30000 sat"`);
-for (let i = 0; i < 12 && !user.balance().spendableSat; i++) { await user.sync(); await sleep(1500); }
-check('user funded', user.balance().spendableSat === 30000, JSON.stringify(user.balance()));
-
-// --- start the bridge ------------------------------------------------------
-const proc = spawn('bun', ['bridge/server.js'], {
- cwd: `${process.env.HOME}/halwallet`,
- env: { ...process.env, BRIDGE_CONFIG: `${DIR}/config.json` },
- stdio: ['ignore', 'pipe', 'pipe'],
-});
-let blog = '';
-proc.stdout.on('data', (d) => { blog += d; });
-proc.stderr.on('data', (d) => { blog += d; });
-let info = null;
-for (let i = 0; i < 40; i++) {
- try { info = await (await fetch(`${BRIDGE}/info`)).json(); break; } catch {}
- await sleep(500);
-}
-if (!info) { console.log(blog.slice(-2000)); throw new Error('bridge did not start'); }
-console.log('\n[1] bridge up');
-check('info reports our claim pubkey', /^0[23][0-9a-f]{64}$/.test(info.claimPubkey || ''), info.claimPubkey);
-check('fee policy is cheap', info.fee.minSat === 1 && info.fee.ppm === 1000, JSON.stringify(info.fee));
-check('token required', info.requiresToken === true);
-console.log(` outbound liquidity: ${info.outboundSat} sat`);
-
-// --- quote -----------------------------------------------------------------
-console.log('\n[2] quote a 25-sat zap (the case the ASP charges 20 sat for)');
-const zapInv = cln('cln-ark', 'invoice 25000 zap-' + Date.now() + ' bridge-zap').bolt11;
-const quote = await (await fetch(`${BRIDGE}/quote?invoice=${zapInv}`)).json();
-check('quote returned', quote.amountSat === 25, JSON.stringify(quote).slice(0, 160));
-check('fee is 1 sat (vs the ASP\'s 20)', quote.feeSat === 1, `${quote.feeSat} sat`);
-check('quote gives claim pubkey + expiry', !!quote.claimPubkey && quote.htlcExpiry > 0);
-
-// --- the swap --------------------------------------------------------------
-console.log('\n[3] user locks an HTLC and the bridge pays the invoice');
-const before = user.balance().spendableSat;
-const lock = await user.htlcLock({
- amountSat: quote.totalSat,
- claimPubkey: quote.claimPubkey,
- paymentHash: quote.paymentHash,
- htlcExpiry: quote.htlcExpiry,
-});
-check('user locked the HTLC', lock.htlcVtxos.length === 1);
-
-// unauthorized attempt first
-const unauth = await fetch(`${BRIDGE}/swap`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ invoice: zapInv, htlcVtxo: lock.htlcVtxos[0] }),
-});
-check('bridge rejects a swap without a token', unauth.status === 401, `HTTP ${unauth.status}`);
-
-const res = await (await fetch(`${BRIDGE}/swap`, {
- method: 'POST',
- headers: { 'content-type': 'application/json', authorization: 'Bearer test-token' },
- body: JSON.stringify({ invoice: zapInv, htlcVtxo: lock.htlcVtxos[0] }),
-})).json();
-console.log(' swap result:', JSON.stringify(res));
-check('swap completed', res.step === 'done', res.step + (res.error ? ' / ' + res.error : ''));
-check('bridge returned the preimage', !!res.preimage
- && hex.encode(sha256(hex.decode(res.preimage))) === quote.paymentHash);
-check('invoice really paid at the far end',
- cln('cln-ark', 'listinvoices').invoices.some((i) => i.bolt11 === zapInv && i.status === 'paid'));
-check('user paid amount + 1 sat fee', user.balance().spendableSat === before - 26,
- `${before} -> ${user.balance().spendableSat}`);
-// read the bridge's own persisted wallet state: the claimed HTLC must have
-// landed as a spendable vtxo worth the swap total
-const bstate = JSON.parse(readFileSync(`${DIR}/bridge.json`, 'utf8'));
-const bsat = (bstate.ark?.vtxos || []).filter((v) => v.state === 'spendable')
- .reduce((n, v) => n + v.amountSat, 0);
-check('bridge holds the claimed HTLC value', bsat === quote.totalSat, `${bsat} sat spendable`);
-
-// --- idempotency -----------------------------------------------------------
-const again = await (await fetch(`${BRIDGE}/swap`, {
- method: 'POST',
- headers: { 'content-type': 'application/json', authorization: 'Bearer test-token' },
- body: JSON.stringify({ invoice: zapInv, htlcVtxo: lock.htlcVtxos[0] }),
-})).json();
-check('resubmitting the same swap is idempotent', again.step === 'done' && again.preimage === res.preimage);
-
-// --- failure + refund ------------------------------------------------------
-console.log('\n[4] unpayable invoice: user must get their money back');
-// clb is unreachable from clc (clc has no outbound toward the hub), so this
-// is a genuinely unroutable payment rather than a contrived error
-const badInv = cln('clb', 'invoice 5000000 unpay-' + Date.now() + ' unpayable').bolt11;
-const badDec = decodeBolt11(badInv);
-const beforeBad = user.balance().spendableSat;
-const badQuote = await (await fetch(`${BRIDGE}/quote?invoice=${badInv}`)).json();
-if (badQuote.error) {
- // the bridge refused up front on liquidity — the best possible outcome
- check('bridge refuses a swap it cannot route (no funds at risk)', true, badQuote.error);
- check('user balance untouched', user.balance().spendableSat === beforeBad);
-} else {
- const badLock = await user.htlcLock({
- amountSat: badQuote.totalSat, claimPubkey: badQuote.claimPubkey,
- paymentHash: badQuote.paymentHash, htlcExpiry: badQuote.htlcExpiry,
- });
- const badRes = await (await fetch(`${BRIDGE}/swap`, {
- method: 'POST',
- headers: { 'content-type': 'application/json', authorization: 'Bearer test-token' },
- body: JSON.stringify({ invoice: badInv, htlcVtxo: badLock.htlcVtxos[0] }),
- })).json();
- check('swap reported refundable, not done', badRes.step === 'refundable', badRes.step);
- const refund = await (await fetch(`${BRIDGE}/refund`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ paymentHash: badDec.paymentHash }),
- })).json();
- check('bridge cosigned a refund', refund.step === 'refunded', JSON.stringify(refund).slice(0, 120));
- const got = await user.acceptHtlcRefund({
- vtxoBytesList: refund.refundVtxos, refundIndex: badLock.refundIndex,
- });
- check('user got every sat back', got === badQuote.totalSat, `${got} of ${badQuote.totalSat}`);
- check('user whole again', user.balance().spendableSat === beforeBad, `${beforeBad} -> ${user.balance().spendableSat}`);
-}
-
-proc.kill();
-console.log(ok ? '\n✅ SUCCESS: bridge swaps a 25-sat zap for 1 sat, trustlessly' : '\n❌ some checks failed');
-if (!ok) console.log('\n--- bridge log tail ---\n' + blog.slice(-1500));
-process.exit(ok ? 0 : 1);
diff --git a/tools/ark/expired-dust-test.js b/tools/ark/expired-dust-test.js
index 4741fb8..c23bd52 100644
--- a/tools/ark/expired-dust-test.js
+++ b/tools/ark/expired-dust-test.js
@@ -15,7 +15,8 @@ import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { HDKey } from '@scure/bip32';
-import { ArkManager } from '/home/adam/coinosv3/src/ark/manager.js';
+import { ArkManager } from '../../src/ark/manager.js';
+import { getArkInfo } from '../../src/ark/proto.js';
const ARK = 'http://127.0.0.1:3535';
const ESPLORA = 'http://127.0.0.1:30002';
@@ -42,6 +43,14 @@ const waitBalance = async (mgr, want, tries = 15) => {
return mgr.balance().spendableSat;
};
+// Mining past a normal lifetime (4320 blocks) would expire every other coin on
+// the shared regtest chain, so without the short-lifetime captaind this skips.
+const { vtxoExpiryDelta } = await getArkInfo(ARK);
+if (vtxoExpiryDelta > 60) {
+ console.log(`SKIP: needs captaind with a short vtxo_lifetime (<= 60 blocks); this one has ${vtxoExpiryDelta}`);
+ process.exit(0);
+}
+
console.log('[setup] board fresh coins under the short lifetime');
const S = await newWallet();
const G1 = await newWallet(); // 1-sat bearer "gift" key
diff --git a/tools/ark/gift-shot.js b/tools/ark/gift-shot.js
index 3ebb21f..aac6809 100644
--- a/tools/ark/gift-shot.js
+++ b/tools/ark/gift-shot.js
@@ -8,7 +8,7 @@ import { wordlist } from '@scure/bip39/wordlists/english';
const PORT = 5199;
const APP = `http://localhost:${PORT}`;
-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();
diff --git a/tools/ark/htlc-bridge-test.js b/tools/ark/htlc-bridge-test.js
deleted file mode 100644
index a712316..0000000
--- a/tools/ark/htlc-bridge-test.js
+++ /dev/null
@@ -1,201 +0,0 @@
-// Trustless third-party ark<->LN swap, end to end against a patched captaind
-// (VtxoPolicy::Htlc — see docs/third-party-htlc.md).
-//
-// Two independent hal wallets play the roles:
-// alice = user paying a Lightning invoice with ark funds
-// bridge = third-party swap provider (NOT the ASP)
-//
-// Happy path (submarine swap, ark -> LN):
-// 1. alice locks funds in an HTLC claimable by the bridge against the
-// invoice's payment hash, refundable by alice after expiry
-// 2. bridge validates the HTLC, pays the bolt11 (here: clc's invoice),
-// learns the preimage, claims the HTLC by revealing it
-// 3. alice reads the preimage back from the ASP as proof of payment
-//
-// Security properties the ASP must enforce (each asserted here):
-// - a claim with a WRONG preimage is rejected
-// - the bridge cannot spend the HTLC to itself without the preimage
-// (theft attempt) — only a full refund to alice is allowed
-// - the cooperative refund path works and pays alice
-//
-// Usage: bun tools/ark/htlc-bridge-test.js (regtest stack + cln-ark up)
-
-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 { hex } from '@scure/base';
-import { sha256 } from '@noble/hashes/sha256';
-
-import { ArkManager } from '../../src/ark/manager.js';
-import { buildArkoorSend, cosignWithServer } from '../../src/ark/send.js';
-import { decodeVtxo } from '../../src/ark/proto.js';
-import { validateVtxo } from '../../src/ark/validate.js';
-import { getHtlcPreimage, 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_CLI = `${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();
-// lightning-cli prints progress lines before the JSON body on some commands
-const clc = (cmd) => {
- const out = sh(`${CLC} ${cmd}`);
- return JSON.parse(out.slice(out.indexOf('{')));
-};
-
-let ok = true;
-const check = (name, cond, detail = '') => {
- console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
- if (!cond) ok = false;
-};
-
-// two independent wallets
-function newWallet() {
- const store = { raw: null };
- const account = HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist)))
- .derive("m/86'/0'/9'");
- return new ArkManager({
- account,
- storage: { load: () => (store.raw ? JSON.parse(store.raw) : null), save: (s) => { store.raw = JSON.stringify(s); } },
- arkUrl: ARK, esploraUrl: ESPLORA,
- }).init();
-}
-
-const alice = await newWallet();
-const bridge = await newWallet();
-
-console.log('\n[0] fund both wallets from the bark CLI wallet');
-sh(`${ALICE_CLI} -q send ${alice.address()} "30000 sat"`);
-sh(`${ALICE_CLI} -q send ${bridge.address()} "30000 sat"`);
-for (let i = 0; i < 12 && (!alice.balance().spendableSat || !bridge.balance().spendableSat); i++) {
- await alice.sync(); await bridge.sync(); await sleep(1500);
-}
-check('alice funded', alice.balance().spendableSat === 30000, JSON.stringify(alice.balance()));
-check('bridge funded', bridge.balance().spendableSat === 30000, JSON.stringify(bridge.balance()));
-
-const tip = await alice.chain.tipHeight();
-// bridge's claim key: index 0 of its own vtxo key chain
-const bridgeClaimIndex = 0;
-const bridgeClaimPub = hex.encode(bridge._key(bridgeClaimIndex).pubkey);
-
-// ---------------------------------------------------------------------------
-console.log('\n[1] SUBMARINE SWAP: alice pays a clc invoice through the bridge');
-const label = `htlcswap-${Date.now()}`;
-const inv = clc(`invoice 5000000 ${label} third-party-htlc-swap`);
-const paymentHash = decodeBolt11(inv.bolt11).paymentHash;
-console.log(' invoice payment hash:', paymentHash.slice(0, 16) + '…');
-
-// 1. alice locks: 5000 sat + a 5 sat bridge fee (undercutting the ASP's 20)
-const swapAmount = 5005;
-const lock = await alice.htlcLock({
- amountSat: swapAmount,
- claimPubkey: bridgeClaimPub,
- paymentHash,
- htlcExpiry: tip + 100,
-});
-check('alice locked into an HTLC', lock.htlcVtxos.length === 1, `${lock.htlcVtxos.length} vtxos`);
-const htlcBytes = lock.htlcVtxos[0];
-const htlcVtxo = decodeVtxo(hex.decode(htlcBytes));
-check('htlc policy encodes both parties', htlcVtxo.policy.type === 'htlc'
- && htlcVtxo.policy.claimPubkey === bridgeClaimPub
- && htlcVtxo.policy.refundPubkey === hex.encode(alice._key(lock.refundIndex).pubkey),
- JSON.stringify(htlcVtxo.policy).slice(0, 120));
-check('alice debited', alice.balance().spendableSat === 30000 - swapAmount, JSON.stringify(alice.balance()));
-
-// 2. the bridge independently validates the HTLC before paying anything
-await validateVtxo(htlcVtxo, {
- serverPubkey: bridge.serverPub, chain: bridge.chain, allowPolicies: ['htlc'],
-});
-check('bridge validated the htlc vtxo chain', true);
-check('htlc amount + hash as agreed', htlcVtxo.amountSat === swapAmount
- && htlcVtxo.policy.paymentHash === paymentHash);
-
-// 3. ATTACK: wrong preimage must be rejected by the ASP
-let wrongRejected = false;
-try {
- await bridge.htlcClaim({
- htlcVtxoBytes: htlcBytes, claimKeyIndex: bridgeClaimIndex,
- preimage: hex.encode(sha256(new TextEncoder().encode('not-the-preimage'))),
- });
-} catch (e) {
- wrongRejected = /preimage does not match/i.test(e.message);
- if (!wrongRejected) console.log(' unexpected error:', e.message);
-}
-check('ASP rejects a claim with the wrong preimage', wrongRejected);
-
-// 4. ATTACK: bridge tries to take the funds without the preimage. Built
-// straight against the protocol layer — a malicious bridge wouldn't be
-// running our client-side guards, so the ASP has to be the one that refuses.
-let theftRejected = false;
-try {
- const keys = bridge._key(bridgeClaimIndex);
- const build = buildArkoorSend({
- input: htlcVtxo,
- outputs: [{ amountSat: htlcVtxo.amountSat, userPubkey: bridge._key(1).pubkey }],
- serverPubkey: bridge.serverPub, vtxoKeys: keys,
- });
- await cosignWithServer(ARK, build, {
- input: htlcVtxo, vtxoKeys: keys, serverPubkey: bridge.serverPub, // no preimage
- });
-} catch (e) {
- theftRejected = /refund pubkey|requires every/i.test(e.message);
- if (!theftRejected) console.log(' unexpected error:', e.message);
-}
-check('ASP rejects a preimage-less spend to the bridge itself (theft)', theftRejected);
-
-// 5. the bridge pays the invoice for real over Lightning, learning the preimage
-const payRes = clc(`pay ${inv.bolt11}`);
-check('bridge paid the invoice over LN', payRes.status === 'complete', payRes.status);
-const preimage = payRes.payment_preimage;
-check('preimage matches the payment hash', hex.encode(sha256(hex.decode(preimage))) === paymentHash);
-
-// 6. bridge claims the HTLC by revealing the preimage to the ASP
-const claimed = await bridge.htlcClaim({
- htlcVtxoBytes: htlcBytes, claimKeyIndex: bridgeClaimIndex, preimage,
-});
-check('bridge claimed the htlc', claimed.length === 1);
-check('bridge credited', bridge.balance().spendableSat === 30000 + swapAmount,
- JSON.stringify(bridge.balance()));
-
-// 7. alice reads the preimage back from the ASP: proof her payment happened
-const revealed = await getHtlcPreimage(ARK, hex.decode(paymentHash));
-check('ASP publishes the revealed preimage', revealed === preimage, `${revealed}`);
-check('clc invoice is paid', clc(`listinvoices ${label}`).invoices[0].status === 'paid');
-
-// ---------------------------------------------------------------------------
-console.log('\n[2] REFUND PATH: a swap the bridge cannot complete');
-const preimage2 = sha256(new TextEncoder().encode('never-revealed-' + Date.now()));
-const hash2 = hex.encode(sha256(preimage2));
-const beforeRefund = alice.balance().spendableSat;
-const lock2 = await alice.htlcLock({
- amountSat: 3000, claimPubkey: bridgeClaimPub, paymentHash: hash2, htlcExpiry: tip + 100,
-});
-check('alice locked the second htlc', lock2.htlcVtxos.length === 1);
-check('alice debited again', alice.balance().spendableSat === beforeRefund - 3000);
-
-// the bridge gives up and cooperatively refunds — the ASP constrains the
-// outputs to alice's refund key, so this is safe to expose as a public API
-const refundVtxos = await bridge.htlcCosignRefund({
- htlcVtxoBytes: lock2.htlcVtxos[0], claimKeyIndex: bridgeClaimIndex,
-});
-const refunded = await alice.acceptHtlcRefund({
- vtxoBytesList: refundVtxos, refundIndex: lock2.refundIndex,
-});
-check('alice got her sats back', refunded === 3000, `${refunded} sat`);
-check('alice whole again', alice.balance().spendableSat === beforeRefund,
- JSON.stringify(alice.balance()));
-
-// ---------------------------------------------------------------------------
-console.log('\n[3] the swapped funds are ordinary spendable vtxos');
-const aliceAddr = sh(`${ALICE_CLI} -q address | grep -o 'tark1[a-z0-9]*'`);
-const cliBefore = JSON.parse(sh(`${ALICE_CLI} -q balance`)).spendable_sat;
-await bridge.send(aliceAddr, 2000);
-const cliAfter = JSON.parse(sh(`${ALICE_CLI} -q balance`)).spendable_sat;
-check('bridge can spend claimed funds onward', cliAfter === cliBefore + 2000, `${cliBefore} -> ${cliAfter}`);
-
-console.log(ok ? '\n✅ SUCCESS: trustless third-party ark<->LN swap works' : '\n❌ some checks failed');
-process.exit(ok ? 0 : 1);
diff --git a/tools/ark/ln-e2e-test.js b/tools/ark/ln-e2e-test.js
index 4d31b56..f4b5856 100644
--- a/tools/ark/ln-e2e-test.js
+++ b/tools/ark/ln-e2e-test.js
@@ -18,11 +18,10 @@ 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 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));
@@ -45,6 +44,7 @@ const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'
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()));
@@ -99,6 +99,11 @@ check('alice +100 from tiny send', aliceAfter === aliceBefore + 100, `${aliceBef
// ---------------------------------------------------------------------------
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) + '…');
diff --git a/tools/ark/ln-test.js b/tools/ark/ln-test.js
index c76d914..87e29d4 100644
--- a/tools/ark/ln-test.js
+++ b/tools/ark/ln-test.js
@@ -19,11 +19,10 @@ import { secp256k1 } from '@noble/curves/secp256k1';
import { ArkManager } from '../../src/ark/manager.js';
import { decodeBolt11 } 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 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();
@@ -89,6 +88,7 @@ const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'
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()));
diff --git a/tools/ark/manager-test.js b/tools/ark/manager-test.js
index 74a1c5c..6cbc506 100644
--- a/tools/ark/manager-test.js
+++ b/tools/ark/manager-test.js
@@ -17,11 +17,10 @@ import { hex } from '@scure/base';
import { ArkManager } from '../../src/ark/manager.js';
import { decodeVtxo, vtxoBytesFromStr } from '../../src/ark/proto.js';
import { validateVtxo } from '../../src/ark/validate.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 BARK = `${process.env.HOME}/bark/target/debug/bark`;
-const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`;
const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -52,6 +51,7 @@ let mgr = await newManager();
// --- 1. receive with full validation ---
console.log('\n[1] receive');
const addr = mgr.address();
+await aliceReady();
sh(`${ALICE} -q send ${addr} "20000 sat"`);
for (let i = 0; i < 10 && !mgr.balance().spendableSat; i++) { await mgr.sync(); await sleep(1500); }
check('received 20000 sat', mgr.balance().spendableSat === 20000, JSON.stringify(mgr.balance()));
diff --git a/tools/ark/receive-test.js b/tools/ark/receive-test.js
index 1e6adbe..ac916ad 100644
--- a/tools/ark/receive-test.js
+++ b/tools/ark/receive-test.js
@@ -17,11 +17,11 @@ import {
getArkInfo, handshake, encodeAddress, decodeAddress,
blindMailboxId, readMailbox,
} from '../../src/ark/proto.js';
+import { ALICE as ALICE_CLI, 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 BARK = `${process.env.HOME}/bark/target/debug/bark`;
-const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice -q`;
+const ALICE = `${ALICE_CLI} -q`;
const AMOUNT_SAT = 12345;
const MNEMONIC = 'rug rebuild abstract kiwi rifle vast food robust rifle spirit dilemma stumble';
@@ -57,6 +57,7 @@ console.log('round-trip decode:', decodeAddress(address));
// --- 3. pay it from the bark CLI ---
console.log(`\npaying ${AMOUNT_SAT} sat from alice (bark CLI)...`);
+await aliceReady();
execSync(`${ALICE} send ${address} "${AMOUNT_SAT} sat"`, { stdio: 'inherit' });
// --- 4. read mailbox, decode, validate ---
diff --git a/tools/ark/refresh-test.js b/tools/ark/refresh-test.js
index f23ee05..0557659 100644
--- a/tools/ark/refresh-test.js
+++ b/tools/ark/refresh-test.js
@@ -26,11 +26,10 @@ import {
cosignHarkLeaf, requestForfeitNonces, forfeitBundle, forfeitVtxos,
encodeVtxoFromDecoded,
} from '../../src/ark/refresh.js';
+import { ALICE, aliceReady } from './alice.js';
const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
-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 FUND_SAT = 12345;
const SPEND_SAT = 3000;
@@ -51,6 +50,7 @@ const blinded = blindMailboxId(keys.mailbox.pubkey, hex.decode(info.mailboxPubke
const addr = encodeAddress({
testnet: true, serverPubkey: serverPub, userPubkey: keys.vtxo.pubkey, blindedMailboxId: blinded,
});
+await aliceReady();
execSync(`${ALICE} -q send ${addr} "${FUND_SAT} sat"`, { stdio: 'ignore' });
let input = null;
for (let i = 0; i < 10 && !input; i++) {
@@ -67,7 +67,7 @@ console.log(`input vtxo: ${input.id} (${input.amountSat} sat, expiry ${input.exp
const tipNow = Number(execSync(`${BCLI} getblockcount`).toString().trim());
const expiryBlocks = input.expiryHeight - tipNow;
const entry = info.refreshFees.ppmExpiryTable.filter((e) => e.thresholdBlocks <= expiryBlocks).pop();
-const fee = info.refreshFees.baseFeeSat + Math.floor((input.amountSat * (entry?.ppm ?? 0)) / 1_000_000);
+const fee = info.refreshFees.baseFeeSat + Math.ceil((input.amountSat * (entry?.ppm ?? 0)) / 1_000_000); // server rounds the ppm part up
const outputs = [{ amountSat: input.amountSat - fee, userPubkey: keys.vtxo.pubkey }];
console.log(`refresh fee: ${fee} sat (ppm ${entry?.ppm}, ${expiryBlocks} blocks to expiry) -> requesting ${outputs[0].amountSat} sat`);
diff --git a/tools/ark/scheduled-refresh-test.js b/tools/ark/scheduled-refresh-test.js
index bb030c4..c406514 100644
--- a/tools/ark/scheduled-refresh-test.js
+++ b/tools/ark/scheduled-refresh-test.js
@@ -15,11 +15,10 @@ import { wordlist } from '@scure/bip39/wordlists/english';
import { HDKey } from '@scure/bip32';
import { ArkManager } from '../../src/ark/manager.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 BARK = `${process.env.HOME}/bark/target/debug/bark`;
-const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`;
const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -44,18 +43,8 @@ const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'
const mgr = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init();
// This test mines a coin's whole lifetime in step 5, which also expires the
-// funding wallet's coins — an expired vtxo can be refreshed but not sent, so
-// re-running against a chain that already ran it would fund nothing.
-const fundingReady = () => {
- const alice = JSON.parse(sh(`${ALICE} -q vtxos`));
- const tip = tipHeight();
- if (!alice.some((v) => v.expiry_height <= tip)) return;
- console.log(' … refreshing the funding wallet\'s expired coins first');
- sh(`${ALICE} refresh --all >/dev/null`);
- mine(3);
- sh(`${ALICE} -q balance >/dev/null`);
-};
-fundingReady();
+// funding wallet's coins — aliceReady() refreshes them on the next run.
+await aliceReady();
const receive = async (sat) => {
const before = mgr.balance().spendableSat;
diff --git a/tools/ark/ui-test.js b/tools/ark/ui-test.js
index 232e742..4460cd3 100644
--- a/tools/ark/ui-test.js
+++ b/tools/ark/ui-test.js
@@ -1,27 +1,25 @@
-// Browser-level e2e of the Ark UI wiring, driven with puppeteer against the
-// real dev server + the ark regtest stack:
+// Browser-level e2e of the Ark wallet, driven with puppeteer: the real app
+// bundle against the real ark regtest stack (captaind :3535, esplora :30002).
//
-// import wallet -> ark connects -> receive over Ark (alice pays the address
-// shown in the Receive tab) -> send over Ark from the Send form -> board
-// from the Settings card -> refresh/consolidate -> balances line up.
+// fresh wallet -> set up Spending -> receive over Ark (alice pays the tark1
+// address on the Receive tab) -> send over Ark from the Send form -> fund
+// Savings on-chain and move it to Spending (board) -> reload: the balance
+// comes back on its own.
//
-// Usage: bun tools/ark/ui-test.js (regtest stack up + nothing on :5199)
+// Usage: bun tools/ark/ui-test.js (ark regtest stack up)
-import { spawn, execSync } from 'node:child_process';
+import { execSync } from 'node:child_process';
import puppeteer from 'puppeteer-core';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
+import { ALICE, aliceReady, aliceBalance } from './alice.js';
-const PORT = 5199;
-const APP = `http://localhost:${PORT}`;
-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 SHOTS = process.env.SHOTS; // dir: save a screenshot per step
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`);
-const aliceBalance = () => JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat;
let ok = true;
const check = (name, cond, detail = '') => {
@@ -29,18 +27,38 @@ const check = (name, cond, detail = '') => {
if (!cond) ok = false;
};
-// --- dev server ---
-const dev = spawn('bun', ['run', 'dev'], { cwd: process.env.HOME + '/halwallet', env: { ...process.env, PORT: String(PORT) }, stdio: 'ignore' });
-for (let i = 0; i < 30; i++) {
- try { if ((await fetch(APP)).ok) break; } catch {}
- await sleep(500);
-}
+// the real app, with a handle on its state for setup and assertions
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'ark-ui-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.uiApp = { ui, wallet, ctx, render, enterWallet, get ready() { return !_bootDeciding; } };
+` }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+if (!bundle.success) throw new Error(bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
-const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage();
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
+page.on('console', (m) => process.env.DEBUG && console.log(' [console]', m.type(), m.text().slice(0, 200)));
+await page.setViewport({ width: 390, height: 844 });
+// only the page and the local regtest backends — no relays, no registrar
+await page.setRequestInterception(true);
+const local = (u) => u.startsWith(server.url.origin) || u.startsWith('data:')
+ || /^https?:\/\/(localhost|127\.0\.0\.1):(3535|30002)\//.test(u);
+page.on('request', (r) => (local(r.url()) ? r.continue() : r.abort()));
+await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' }));
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local');
+});
// helpers — NB innerText respects CSS text-transform, so match case-insensitively
+let shot = 0;
+const snap = async (name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${String(++shot).padStart(2, '0')}-${name}.png` }); };
const bodyText = () => page.evaluate(() => document.body.innerText);
const clickText = async (sel, text) => {
const found = await page.evaluate((sel, text) => {
@@ -57,160 +75,124 @@ const waitText = async (text, ms = 20000) => {
}
return false;
};
-// The number shown next to a label like "ark balance" (sats mode, comma groups)
-const labeledNumber = async (label) => {
- const txt = await bodyText();
- const esc = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const m = txt.match(new RegExp(esc + String.raw`[\s\n]*([\d,]+)`, 'i'));
+// the amount on a balance card face ("Spending" / "Savings"), in sats
+const faceSat = (label) => page.evaluate((label) => {
+ const f = [...document.querySelectorAll('.balance-face')]
+ .find((x) => x.innerText.split('\n').some((l) => l.trim().toLowerCase() === label.toLowerCase()));
+ const m = f && f.innerText.match(/([\d,]+)\s*sats/i);
return m ? Number(m[1].replaceAll(',', '')) : null;
-};
-const waitLabeledNumber = async (label, want, ms = 30000) => {
+}, label);
+const waitFace = async (label, want, ms = 30000) => {
for (let i = 0; i < ms / 500; i++) {
- if (await labeledNumber(label) === want) return true;
+ if (await faceSat(label) === want) return true;
await sleep(500);
}
return false;
};
+const tabPane = () => page.evaluate(() => document.querySelector('.tab-pane')?.innerText || '');
try {
- // --- configure: regtest, esplora on the ark chain, ark local ---
- await page.goto(APP, { waitUntil: 'domcontentloaded' });
- await page.evaluate(() => {
- localStorage.setItem('btc-wallet-network', 'regtest');
- localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' }));
- localStorage.setItem('btc-wallet-ark-provider:regtest', 'local');
- });
- await page.reload({ waitUntil: 'domcontentloaded' });
- await sleep(500);
-
- // --- import a fresh wallet ---
- console.log('\n[1] import wallet + ark connects');
- const mnemonic = generateMnemonic(wordlist);
- await clickText('button', 'Import existing');
- await sleep(200);
- await page.type('textarea', mnemonic);
- await clickText('button', 'Open wallet');
- check('wallet opened', await waitText('receive', 15000));
+ await aliceReady();
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.uiApp?.ready);
- check('ark connected (receive option present)', await (async () => {
- for (let i = 0; i < 40; i++) {
- const has = await page.evaluate(() => [...document.querySelectorAll('option')].some((o) => o.value === 'ark'));
- if (has) return true;
- await sleep(500);
- }
- return false;
- })());
+ console.log('\n[1] fresh wallet + spending account');
+ await page.evaluate(async (mn) => {
+ await uiApp.enterWallet(mn, '', { fresh: true, generated: true });
+ uiApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ uiApp.render();
+ }, generateMnemonic(wordlist));
+ check('wallet opened', await waitText('Set up a spending account', 15000));
+ await clickText('button', 'Set up a spending account');
+ await sleep(300);
+ await clickText('button', 'Not now'); // skip claiming a username
+ check('spending card shown', await waitFace('Spending', 0, 15000));
+ await snap('spending');
- // --- receive over ark ---
console.log('\n[2] receive over Ark');
- await page.select('select', 'ark');
- check('lazy connect on first use', await waitText('tark1', 15000));
- const arkAddr = await page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim() || '');
- check('ark address shown', /^tark1[a-z0-9]+$/.test(arkAddr), arkAddr.slice(0, 24) + '…');
+ await page.evaluate(() => uiApp.ctx.setAccount('spending'));
+ let arkAddr = '';
+ for (let i = 0; i < 120 && !arkAddr; i++) {
+ arkAddr = ((await tabPane()).match(/tark1[a-z0-9]+/) || [''])[0];
+ if (!arkAddr) await sleep(250);
+ }
+ check('ark address on the Receive tab', !!arkAddr, arkAddr.slice(0, 24) + '…');
+ if (!arkAddr) { await snap('no-ark-address'); throw new Error('ark never connected — is captaind up?'); }
sh(`${ALICE} -q send ${arkAddr} "25000 sat"`);
- check('ark balance shows 25,000', await waitLabeledNumber('ark balance', 25000, 30000), String(await labeledNumber('ark balance')));
+ check('spending shows 25,000', await waitFace('Spending', 25000), String(await faceSat('Spending')));
check('payment celebration shown', await waitText('Payment received', 10000));
await clickText('.card', 'Payment received'); // tap to acknowledge
await sleep(400);
- check('back to receive card after tap', await waitText('tark1', 5000));
+ await snap('received');
- // --- send over ark ---
console.log('\n[3] send over Ark');
+ await clickText('button', 'Send');
+ await sleep(500);
const aliceAddr = sh(`${ALICE} -q address`);
const before = aliceBalance();
- await clickText('.tabs button', 'Send');
- await sleep(300);
- await page.type('.mono-input', aliceAddr);
- await sleep(400); // destReady re-render reveals the amount input
- await page.type('input[type="number"]', '7000');
- const noFeeUi = await page.evaluate(() => {
- const txt = document.body.innerText.toLowerCase();
- return !txt.includes('fee rate') && !txt.includes('coin control') && txt.includes('no mining fee');
- });
- check('no fee/coin controls for ark dest', noFeeUi);
+ await page.type('textarea.send-dest', aliceAddr);
+ await sleep(800);
+ check('no fee controls for an ark dest', /no mining fee/i.test(await tabPane()) && !/fee rate|coin control/i.test(await tabPane()));
+ await page.type('.tab-pane input[placeholder="0"]', '7000');
+ await snap('send-form');
await clickText('button.btn-primary', 'Review transaction');
- check('ark review shown', await waitText('Send over Ark', 5000));
- await clickText('button.btn-primary', 'Send');
+ await sleep(800);
+ check('review shows the amount, no network fee', /7,000 sats/.test(await tabPane()) && /network fee\s*none/i.test(await tabPane()));
+ await snap('review');
+ await clickText('.tab-pane button.btn-primary', 'Send');
check('sent view', await waitText('Sent!', 15000));
+ await snap('sent');
check('alice +7000', aliceBalance() === before + 7000, String(aliceBalance() - before));
+ check('spending shows 18,000', await waitFace('Spending', 18000), String(await faceSat('Spending')));
await clickText('.card', 'Sent!'); // tap anywhere to proceed
- await sleep(300);
-
- // history shows both ark movements
- await clickText('.tabs button', 'History');
await sleep(400);
const hist = await bodyText();
- check('history shows ark receive', /received\s*\n?\s*ark/i.test(hist));
- check('history shows ark send', /sent\s*\n?\s*ark/i.test(hist));
-
- // rows open a detail view
- await clickText('.item', 'sent');
- await sleep(300);
- check('ark detail view', await waitText('Coin (VTXO)', 5000));
- check('detail shows destination', (await bodyText()).includes('tark1'));
- await clickText('button', 'Back');
- await sleep(300);
+ check('history shows the receive', /received[\s\S]*\+25,000/i.test(hist));
+ check('history shows the send', /-7,000/.test(hist));
- // --- board from settings ---
- console.log('\n[4] board');
- await clickText('.tabs button', 'Receive');
- await sleep(300);
- await page.select('select', 'address');
- await sleep(300);
- const onchainAddr = await page.evaluate(() => document.querySelector('.addr-box').textContent.trim());
- sh(`${BCLI} sendtoaddress ${onchainAddr} 0.001`);
- mine(1);
- check('onchain funds arrive', await waitText('100,000', 30000));
- // the on-chain receive celebration takes over the tab — tap through it
- if ((await bodyText()).toLowerCase().includes('payment received')) {
- await clickText('.card', 'Payment received');
- await sleep(400);
+ console.log('\n[4] Savings -> Spending (board)');
+ await page.evaluate(() => uiApp.ctx.setAccount('savings'));
+ let btcAddr = '';
+ for (let i = 0; i < 40 && !btcAddr; i++) {
+ btcAddr = ((await tabPane()).match(/bcrt1[a-z0-9]+/) || [''])[0];
+ if (!btcAddr) await sleep(250);
}
- await page.select('select', 'ark');
- await sleep(400);
- const preBoard = await labeledNumber('ark balance');
- const boardInput = await page.evaluateHandle(() =>
- [...document.querySelectorAll('input')].find((i) => (i.placeholder || '').toLowerCase().startsWith('board amount')));
- check('board form on receive/ark', !!(await boardInput.asElement()));
- await boardInput.asElement().type('30000');
- await clickText('button', 'Board');
- check('boarding success screen', await waitText('Boarding!', 20000));
- check('boarding txid link shown', await page.evaluate(() =>
- !!document.querySelector('.addr-box') && /^[0-9a-f]{64}$/.test(document.querySelector('.addr-box').textContent.trim())));
- await clickText('button.btn-primary', 'Done');
+ check('on-chain address on the Savings Receive tab', !!btcAddr, btcAddr);
+ sh(`${BCLI} sendtoaddress ${btcAddr} 0.001`);
+ mine(1);
+ check('savings shows 100,000', await waitFace('Savings', 100000, 40000), String(await faceSat('Savings')));
+ if (/payment received/i.test(await bodyText())) { await clickText('.card', 'Payment received'); await sleep(400); }
+ // sending Savings to your own Spending address opens the move panel instead
+ await clickText('button', 'Send');
+ await sleep(500);
+ await page.type('textarea.send-dest', arkAddr);
+ check('own Spending address opens the move panel', await waitText('Moving from Savings instead', 10000));
+ await page.type('.balance-move input', '30000');
await sleep(300);
+ check('board fee previewed', /330/.test(await page.evaluate(() => document.querySelector('.balance-move')?.innerText || '')));
+ await snap('move-panel');
+ await clickText('.balance-move button.btn-primary', 'Move to Spending');
+ const broadcast = await page.waitForFunction(() => uiApp.ui.arkBoarded || uiApp.ui.arkError, { timeout: 20000 })
+ .then(() => page.evaluate(() => uiApp.ui.arkError || 'ok')).catch(() => 'timed out');
+ check('board funded and cosigned', broadcast === 'ok', broadcast);
+ check('board shows as moving into Spending', /moving…/i.test(await bodyText()));
+ await snap('boarding');
+ // the success screen lives on the Receive tab (we came in through Send)
+ await clickText('button', 'Receive');
+ check('boarding success screen', await waitText('On its way!', 5000));
+ await snap('boarded-screen');
+ await clickText('button.btn-primary', 'Done');
mine(2);
- check('board vtxo lands (+29,670)', await waitLabeledNumber('ark balance', preBoard + 29670, 40000),
- String(await labeledNumber('ark balance')));
+ check('board lands in Spending (+29,670)', await waitFace('Spending', 18000 + 29670, 60000), String(await faceSat('Spending')));
+ await snap('boarded');
- // --- refresh / consolidate ---
- console.log('\n[5] refresh');
- await clickText('.tabs button', 'Settings');
- await sleep(300);
- await clickText('button', 'Refresh / consolidate');
- check('refresh submitted', await waitText('Refresh submitted', 10000));
- const done = await (async () => {
- for (let i = 0; i < 45; i++) {
- mine(1);
- await sleep(2000);
- if (await labeledNumber('coins (vtxos)') === 1 && !(await bodyText()).toLowerCase().includes('in progress')) return true;
- }
- return false;
- })();
- check('refresh consolidated to 1 coin', done, String(await labeledNumber('coins (vtxos)')));
- const finalBal = await labeledNumber('ark balance');
- console.log(` final ark balance: ${finalBal} sats`);
-
- // --- reload: a wallet holding vtxos reconnects on its own ---
- console.log('\n[6] reload auto-connects (has vtxos)');
+ console.log('\n[5] reload');
await page.reload({ waitUntil: 'domcontentloaded' });
- check('ark balance back without interaction', await waitLabeledNumber('ark balance', finalBal, 20000),
- String(await labeledNumber('ark balance')));
-
- console.log(ok ? '\n✅ SUCCESS: Ark UI — receive, send, board, refresh all work in the browser'
- : '\n❌ some checks failed');
+ check('spending back without interaction', await waitFace('Spending', 47670, 30000), String(await faceSat('Spending')));
+ await snap('reloaded');
} finally {
await browser.close();
- dev.kill();
+ server.stop(true);
}
+console.log(ok ? '\n✅ SUCCESS: Ark UI — receive, send, board all work in the browser' : '\n❌ some checks failed');
process.exit(ok ? 0 : 1);
diff --git a/tools/bolt12-test.js b/tools/bolt12-test.js
index 28909d3..ab5109a 100644
--- a/tools/bolt12-test.js
+++ b/tools/bolt12-test.js
@@ -1,6 +1,7 @@
// BOLT 12 decode + signature verification against a REAL offer/invoice pair
// (fetched from a live CLN via fetchinvoice) plus tamper checks.
// Run: bun tools/bolt12-test.js [offer-file invoice-file]
+// Default fixtures: regtest clc offer, invoice fetched by cln-ark for 12345 msat.
import { readFileSync } from 'node:fs';
import {
decodeOffer, decodeBolt12Invoice, verifyOfferInvoice, encodeBech32Raw, decodeBech32Raw,
@@ -14,8 +15,9 @@ const check = (name, ok, detail = '') => {
const throws = (fn) => { try { fn(); return null; } catch (e) { return e.message; } };
const [offerFile, invoiceFile] = process.argv.slice(2);
-const offerStr = readFileSync(offerFile || '/tmp/claude-1000/-home-adam-coinosv3/556b3f05-c728-4e11-a426-50382ad8bd8d/scratchpad/fixture-offer.txt', 'utf8').trim();
-const invStr = readFileSync(invoiceFile || '/tmp/claude-1000/-home-adam-coinosv3/556b3f05-c728-4e11-a426-50382ad8bd8d/scratchpad/fixture-invoice.txt', 'utf8').trim();
+const fixture = (name) => new URL(`fixtures/${name}`, import.meta.url).pathname;
+const offerStr = readFileSync(offerFile || fixture('bolt12-offer.txt'), 'utf8').trim();
+const invStr = readFileSync(invoiceFile || fixture('bolt12-invoice.txt'), 'utf8').trim();
console.log('[codec]');
const rt = decodeBech32Raw(invStr);
@@ -25,7 +27,7 @@ check('bech32 roundtrips', encodeBech32Raw('lni', rt.bytes) === invStr.toLowerCa
console.log('\n[offer]');
const offer = decodeOffer(offerStr);
check('decodes', !!offer.records.length, `${offer.records.length} records`);
-check('mainnet', offer.network === 'mainnet');
+check('network known', ['mainnet', 'testnet', 'signet', 'regtest'].includes(offer.network), offer.network);
check('describes itself', typeof offer.description === 'string', offer.description);
console.log('\n[invoice]');
@@ -34,7 +36,8 @@ check('payment hash present', /^[0-9a-f]{64}$/.test(inv.paymentHash || ''), inv.
check('amount = 12345 msat', inv.amountMsat === 12345n, String(inv.amountMsat));
check('amount rounds up to 13 sat', inv.amountSat === 13);
check('signer named', /^[0-9a-f]{66}$/.test(inv.nodeId || ''), inv.nodeId);
-check('expiry sane', inv.expiresAt > Date.now() - 86400_000, new Date(inv.expiresAt).toISOString());
+check('same network as the offer', inv.network === offer.network, inv.network);
+check('expiry sane', inv.expiresAt > inv.createdAt * 1000, new Date(inv.expiresAt).toISOString());
console.log('\n[verification]');
check('real invoice verifies', throws(() => verifyOfferInvoice(offer, inv)) === null,
diff --git a/tools/electrum-ws.test.js b/tools/electrum-ws.test.js
index 1d5b5d0..f743819 100644
--- a/tools/electrum-ws.test.js
+++ b/tools/electrum-ws.test.js
@@ -1,6 +1,9 @@
// Verify the regtest electrum-ws bridge gives realtime push: subscribe to an
// address scripthash over ws://localhost:50003, fund it, expect a notification.
-// Run (stack + ews up): bun tools/electrum-ws.test.js
+// Run (stack + ews up): bun test tools/electrum-ws.test.js
+// Integration test: skips (with a note) when no bridge listens on :50003 — the
+// WS<->TCP bridge (coinos-server/electrum-ws) in front of a regtest electrs.
+import { test, expect } from 'bun:test';
import { $ } from 'bun';
import { p2wpkh } from '@scure/btc-signer/payment';
import * as btc from '@scure/btc-signer';
@@ -8,37 +11,46 @@ import { secp256k1 } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha2';
import { bytesToHex } from '@noble/hashes/utils';
+const URL = 'ws://localhost:50003';
const REGTEST = { ...btc.TEST_NETWORK, bech32: 'bcrt' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const bcli = (...a) => $`docker exec bc bitcoin-cli -regtest -rpcuser=admin1 -rpcpassword=123 ${a}`.text();
-const pub = secp256k1.getPublicKey(secp256k1.utils.randomPrivateKey(), true);
-const pay = p2wpkh(pub, REGTEST);
-const sh = sha256(pay.script).reverse(); // electrum scripthash (reversed)
-const shHex = bytesToHex(sh);
-console.log('address', pay.address, '\nscripthash', shHex);
+// open the socket up front so an absent bridge becomes a skip, not a failure
+const ws = await new Promise((res) => {
+ const s = new WebSocket(URL);
+ const t = setTimeout(() => { s.close(); res(null); }, 3000);
+ s.onopen = () => { clearTimeout(t); res(s); };
+ s.onerror = () => { clearTimeout(t); res(null); };
+});
+if (!ws) console.log(`SKIP: no Electrum-over-WebSocket bridge at ${URL} (start the regtest ews bridge to run this)`);
-const ws = new WebSocket('ws://localhost:50003');
-let gotInitial = false, gotPush = false;
-ws.onmessage = (ev) => {
- for (const line of String(ev.data).split('\n')) {
- if (!line.trim()) continue;
- let m; try { m = JSON.parse(line); } catch { continue; }
- if (m.id === 2) { gotInitial = true; console.log('subscribe ok (initial status:', m.result, ')'); }
- if (m.method === 'blockchain.scripthash.subscribe') { gotPush = true; console.log('PUSH notification received:', JSON.stringify(m.params)); }
- }
-};
-await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
-ws.send(JSON.stringify({ id: 1, method: 'server.version', params: ['test', '1.4'] }) + '\n');
-ws.send(JSON.stringify({ id: 2, method: 'blockchain.scripthash.subscribe', params: [shHex] }) + '\n');
+test.skipIf(!ws)('realtime scripthash push over the WS bridge', async () => {
+ const pub = secp256k1.getPublicKey(secp256k1.utils.randomPrivateKey(), true);
+ const pay = p2wpkh(pub, REGTEST);
+ const shHex = bytesToHex(sha256(pay.script).reverse()); // electrum scripthash (reversed)
+ console.log('address', pay.address, '\nscripthash', shHex);
-await sleep(1500);
-if (!gotInitial) { console.log('FAIL — no subscribe response'); process.exit(1); }
+ let gotInitial = false, gotPush = false;
+ ws.onmessage = (ev) => {
+ for (const line of String(ev.data).split('\n')) {
+ if (!line.trim()) continue;
+ let m; try { m = JSON.parse(line); } catch { continue; }
+ if (m.id === 2) { gotInitial = true; console.log('subscribe ok (initial status:', m.result, ')'); }
+ if (m.method === 'blockchain.scripthash.subscribe') { gotPush = true; console.log('PUSH notification received:', JSON.stringify(m.params)); }
+ }
+ };
+ ws.send(JSON.stringify({ id: 1, method: 'server.version', params: ['test', '1.4'] }) + '\n');
+ ws.send(JSON.stringify({ id: 2, method: 'blockchain.scripthash.subscribe', params: [shHex] }) + '\n');
-console.log('funding the address…');
-await bcli('-rpcwallet=coinos', 'sendtoaddress', pay.address, '0.005');
-await bcli('generatetoaddress', '1', (await bcli('-rpcwallet=coinos', 'getnewaddress')).trim());
+ await sleep(1500);
+ expect(gotInitial).toBe(true);
-for (let i = 0; i < 20 && !gotPush; i++) await sleep(500);
-console.log(gotPush ? '\nPASS — realtime push delivered over the WS bridge' : '\nFAIL — no push within timeout');
-process.exit(gotPush ? 0 : 1);
+ console.log('funding the address…');
+ await bcli('-rpcwallet=coinos', 'sendtoaddress', pay.address, '0.005');
+ await bcli('generatetoaddress', '1', (await bcli('-rpcwallet=coinos', 'getnewaddress')).trim());
+
+ for (let i = 0; i < 20 && !gotPush; i++) await sleep(500);
+ ws.close();
+ expect(gotPush).toBe(true);
+}, 30000);
diff --git a/tools/fixtures/bolt12-invoice.txt b/tools/fixtures/bolt12-invoice.txt
new file mode 100644
index 0000000..13a6def
--- /dev/null
+++ b/tools/fixtures/bolt12-invoice.txt
@@ -0,0 +1 @@
+lni1qqgy8t5dte5h2gyavux9d35y4ut57q3qqc3xu3s3rg94nj40zfsy866mhu5vxne6tcej5878k2mneuvgjy8s5xnrda5kummnyp3x7mr5xyezqar9wd6zqenf0p682un9zcss9v3q6z4840hdg5rr2uzyyljj9ss7m0pdhh067zljlqmapdnjxp6d2qsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzr6jqgcrj4qqtqss8d7zt6g2jadypdjye7eaynn7djwthrsk7sp0ayuancfxx23q9czx5zvq9v3q6z4840hdg5rr2uzyyljj9ss7m0pdhh067zljlqmapdnjxp6dqd6cyeyntuje3gnfs0vvk92gajtvzpgpvghsqrxplevw8h20w8a57qgrx8ull89l52lfm2kch90lzmk9jxzqeawjwf3yegx7tnazhpj3vlqsqvn87mukg4nac4x3c6tpaa486r7948h4plagkxzmyxcm7gmjd4tk4a4m449x3a4ejhp9wqyh5vm590nfc84zrsqqqqqqqqqqqqqqpgqqqqqqqqqqqqqayjedltzjqqqqqq9yq34vvcfc4qstz7ayvch6wum4523uplu9t96njk52fdv3caw39ls0mwd57jf9j0a2qgcrntsrqgqqpvppq2ezp59202lw63gxx4cygfl9ytppak7zm0wl4u9l97ph6zm8yvr5muzqgtz93zggsprllc77u9vuvle8c93svnr6wvrsxptrnq39u2shhuy56rkssg6w35cp8rsd3g2atzqcgf3kn2ehwaw08ftjc6e8gqjucmg
diff --git a/tools/fixtures/bolt12-offer.txt b/tools/fixtures/bolt12-offer.txt
new file mode 100644
index 0000000..3faf966
--- /dev/null
+++ b/tools/fixtures/bolt12-offer.txt
@@ -0,0 +1 @@
+lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrc2rf3k76twdaejqcn0d36rzv3qw3jhxapqve5hsar4wfj3vggzkgsdp2n6hmk52p34wpzz0efzcg0dhskmmha0p0e0sd7skeerqaxs
diff --git a/tools/swap-vectors.test.js b/tools/swap-vectors.test.js
deleted file mode 100644
index 147b114..0000000
--- a/tools/swap-vectors.test.js
+++ /dev/null
@@ -1,42 +0,0 @@
-// Verify Hal's swap tapscript/tree construction byte-for-byte against
-// boltz-core's published test vectors (boltz-core/src/bitcoin/scripts/*.rs).
-// Run: bun tools/swap-vectors.test.js
-import {
- submarineClaimLeaf, reverseClaimLeaf, refundLeaf, swapMerkleRoot,
- hexToBytes, bytesToHex,
-} from '../src/swap.js';
-import { p2tr, taprootListToTree } from '@scure/btc-signer/payment';
-
-let pass = 0, fail = 0;
-const eq = (name, got, want) => {
- if (got === want) { pass++; console.log(` ok ${name}`); }
- else { fail++; console.log(`FAIL ${name}\n got: ${got}\n want: ${want}`); }
-};
-
-// --- swap_tree.rs vector ---
-const claimPk = hexToBytes('814fe2b5ce0e3fe5787fade9a4357a743c4ce6de473e5f29daf42112e47db25a');
-const refundPk = hexToBytes('0095b23ac89f523cdbcd791d3325ada7ddb30fd6134bb7fff22ba1e00414cb7d');
-const ph = hexToBytes('34e64e5b1373a872019ee2c7791cf4264d1079de'); // hash160(preimage)
-
-eq('submarine claim leaf',
- bytesToHex(submarineClaimLeaf(ph, claimPk)),
- 'a91434e64e5b1373a872019ee2c7791cf4264d1079de8820814fe2b5ce0e3fe5787fade9a4357a743c4ce6de473e5f29daf42112e47db25aac');
-
-// Cross-check our two-leaf merkle root against @scure's own taproot tree
-// builder (BIP341) for the same leaves — proves the TapBranch/sort logic.
-const claim = submarineClaimLeaf(ph, claimPk);
-const refund = refundLeaf(refundPk, 123);
-const scureRoot = bytesToHex(
- p2tr(claimPk, taprootListToTree([{ script: claim }, { script: refund }]), undefined, true).tapMerkleRoot,
-);
-eq('merkle root matches @scure tree builder', bytesToHex(swapMerkleRoot(claim, refund)), scureRoot);
-
-// --- reverse_tree.rs vector ---
-const rClaimPk = hexToBytes('97f2ea5402afd2e90130e84d97e455f67493a956c20faedce32086516ef9f12e');
-const rPh = hexToBytes('761de3d1f1f54cc8b3beec0a1ad03820a2e12b90');
-eq('reverse claim leaf',
- bytesToHex(reverseClaimLeaf(rPh, rClaimPk)),
- '82012088a914761de3d1f1f54cc8b3beec0a1ad03820a2e12b90882097f2ea5402afd2e90130e84d97e455f67493a956c20faedce32086516ef9f12eac');
-
-console.log(`\n${pass} passed, ${fail} failed`);
-process.exit(fail ? 1 : 0);