diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/ark/dust-gift-test.js | 128 |
1 files changed, 128 insertions, 0 deletions
diff --git a/tools/ark/dust-gift-test.js b/tools/ark/dust-gift-test.js new file mode 100644 index 0000000..061449e --- /dev/null +++ b/tools/ark/dust-gift-test.js @@ -0,0 +1,128 @@ +// Can a sub-dust ark gift be CLAIMED with the current architecture? +// +// Gift create: sender -> fresh bearer key (send has change, isolateDust pads). +// Gift claim: bearer key -> claimer, sweeping its ONLY (sub-dust) vtxo: +// a single all-dust output with no non-dust sibling. This is the +// step the 330-sat floor exists to protect. Does captaind cosign? +// +// Usage: bun /path/to/dust-gift-test.js (run from coinosv3 root) + +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 { ArkManager } from '/home/adam/coinosv3/src/ark/manager.js'; + +const ARK = 'http://127.0.0.1:3535'; +const ESPLORA = 'http://127.0.0.1:30002'; +const BARK = `${process.env.HOME}/bark/target/debug/bark`; +const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); + +let ok = true; +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); + if (!cond) ok = false; +}; + +const newWallet = async (label) => { + const store = { raw: null }; + const storage = { + load: () => (store.raw ? JSON.parse(store.raw) : null), + save: (s) => { store.raw = JSON.stringify(s); }, + }; + const account = HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist))).derive("m/86'/0'/9'"); + const mgr = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init(); + mgr._label = label; + return mgr; +}; + +const waitBalance = async (mgr, want, tries = 12) => { + for (let i = 0; i < tries && mgr.balance().spendableSat < want; i++) { await mgr.sync(); await sleep(1500); } + return mgr.balance().spendableSat; +}; + +console.log('[setup] sender, two bearer "gift" keys, claimer'); +const sender = await newWallet('sender'); +const gift1 = await newWallet('gift-1sat'); +const gift329 = await newWallet('gift-329sat'); +const claimer = await newWallet('claimer'); + +sh(`${ALICE} -q send ${sender.address()} "10000 sat"`); +check('sender funded 10000', (await waitBalance(sender, 10000)) === 10000); + +// --- gift CREATION at 1 sat and 329 sat (sender has change to pad with) --- +console.log('\n[create] sub-dust gifts (sender change pads the isolation output)'); +let createErr = null; +try { + await sender.send(gift1.address(), 1); + await sender.send(gift329.address(), 329); +} catch (e) { createErr = e.message; } +check('1-sat + 329-sat gift creation accepted', !createErr, createErr || ''); +check('1-sat bearer vtxo arrived', (await waitBalance(gift1, 1)) === 1); +check('329-sat bearer vtxo arrived', (await waitBalance(gift329, 329)) === 329); + +// --- gift CLAIM: sweep the bearer key's only, sub-dust vtxo --- +console.log('\n[claim] all-dust sweep from the bearer key (the risky step)'); +let claim1Err = null; +try { await gift1.send(claimer.address(), 1); } catch (e) { claim1Err = e.message; } +check('1-sat claim cosigned by server', !claim1Err, claim1Err || ''); + +let claim329Err = null; +try { await gift329.send(claimer.address(), 329); } catch (e) { claim329Err = e.message; } +check('329-sat claim cosigned by server', !claim329Err, claim329Err || ''); + +const got = await waitBalance(claimer, (claim1Err ? 0 : 1) + (claim329Err ? 0 : 329)); +console.log(` claimer balance: ${got} sat`); + +// --- can the claimer spend a wallet made ONLY of claimed dust? --- +// (sends are single-input, so spend the 1-sat vtxo by itself first) +if (!claim1Err) { + console.log('\n[respend] claimer re-spends the claimed 1-sat vtxo to alice'); + const before = JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat; + let respendErr = null; + try { await claimer.send(sh(`${ALICE} -q address`), 1); } catch (e) { respendErr = e.message; } + check('1-sat dust vtxo spends onward', !respendErr, respendErr || ''); + if (!respendErr) { + await sleep(2000); + const after = JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat; + check('alice +1', after === before + 1, `${before} -> ${after}`); + } +} + +// --- rounds are how vtxos renew before expiry; how does dust fare there? --- +const mine = () => sh(`docker exec bc bitcoin-cli -rpcwallet=coinos -generate 1 >/dev/null`); + +// dust-only wallet: expected to be turned away (fresh wallet so the failed +// action doesn't pollute the mixed-refresh test below) +console.log('\n[refresh: dust-only] 50-sat wallet asks to join a round'); +const gift50 = await newWallet('gift-50sat'); +await sender.send(gift50.address(), 50); +await waitBalance(gift50, 50); +let dustOnlyErr = null; +try { await gift50.refresh(); } catch (e) { dustOnlyErr = e.message; } +check('dust-only refresh refused cleanly (as expected)', /too small to refresh/.test(dustOnlyErr || ''), dustOnlyErr || 'accepted?!'); + +// mixed wallet: the case hal actually hits — a dust coin refreshed alongside +// the rest of the balance +console.log('\n[refresh: mixed] claimer holds {329 dust, 5000}; consolidate both'); +sh(`${ALICE} -q send ${claimer.address()} "5000 sat"`); +await waitBalance(claimer, 5329); +let mixedErr = null; +try { + await claimer.refresh(); + for (let i = 0; i < 40 && claimer.pendingActions().length; i++) { await sleep(2000); mine(); await claimer.sync(); } +} catch (e) { mixedErr = e.message; } +const consolidated = claimer.vtxos().filter((v) => v.state === 'spendable'); +const outAmount = claimer.state.actions.filter((a) => a.type === 'refresh').at(-1)?.outAmountSat; +check('mixed refresh completed', !mixedErr && claimer.pendingActions().length === 0, mixedErr || ''); +check('dust folded into one vtxo (5329 minus round fee)', + consolidated.length === 1 && claimer.balance().spendableSat === outAmount, + `${consolidated.length} vtxos, ${JSON.stringify(claimer.balance())}, expected ${outAmount}`); + +console.log(ok ? '\n✅ dust gifts are claimable with the CURRENT architecture' + : '\n❌ see failures above'); +process.exit(ok ? 0 : 1); |
