diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 09:13:31 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 09:13:31 -0700 |
| commit | 95b72a73545cd2a3700b0e0866eee54e59d3b50e (patch) | |
| tree | 81cc50c71d5e6cb78beaa1a7f755a23691f8983f /tools | |
| parent | 3aff6e4cc9eb97fc0fa488f1f95001f69f24330a (diff) | |
| parent | df87bd84560fec4b085b6f0f4785b61502ffd480 (diff) | |
| download | coinosv3-95b72a73545cd2a3700b0e0866eee54e59d3b50e.tar.gz coinosv3-95b72a73545cd2a3700b0e0866eee54e59d3b50e.zip | |
Merge branch 'worktree-agent-a9ba2f8bea9a7dafc'
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/ark/alice.js | 49 | ||||
| -rw-r--r-- | tools/ark/board-test.js | 10 | ||||
| -rw-r--r-- | tools/ark/bridge-e2e-test.js | 190 | ||||
| -rw-r--r-- | tools/ark/expired-dust-test.js | 11 | ||||
| -rw-r--r-- | tools/ark/gift-shot.js | 2 | ||||
| -rw-r--r-- | tools/ark/htlc-bridge-test.js | 201 | ||||
| -rw-r--r-- | tools/ark/ln-e2e-test.js | 9 | ||||
| -rw-r--r-- | tools/ark/ln-test.js | 4 | ||||
| -rw-r--r-- | tools/ark/manager-test.js | 4 | ||||
| -rw-r--r-- | tools/ark/receive-test.js | 5 | ||||
| -rw-r--r-- | tools/ark/refresh-test.js | 8 | ||||
| -rw-r--r-- | tools/ark/scheduled-refresh-test.js | 17 | ||||
| -rw-r--r-- | tools/ark/ui-test.js | 266 | ||||
| -rw-r--r-- | tools/bolt12-test.js | 11 | ||||
| -rw-r--r-- | tools/electrum-ws.test.js | 66 | ||||
| -rw-r--r-- | tools/fixtures/bolt12-invoice.txt | 1 | ||||
| -rw-r--r-- | tools/fixtures/bolt12-offer.txt | 1 | ||||
| -rw-r--r-- | tools/swap-vectors.test.js | 42 |
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); |
