aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js5
-rw-r--r--src/ark/proto.js3
-rw-r--r--src/features/gifts.js7
-rw-r--r--tools/ark/dust-gift-test.js128
4 files changed, 142 insertions, 1 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 01de06f..f9819a0 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -1270,6 +1270,11 @@ export class ArkManager {
const tip = await this.chain.tipHeight();
const totalSat = inputs.reduce((n, v) => n + v.amountSat, 0);
const feeSat = this.refreshFee(inputs, tip);
+ // The server refuses round outputs under P2TR dust (330), so a wallet
+ // this small can't renew — fail before the action marks coins pending.
+ if (totalSat - feeSat < 330) {
+ throw new Error(`balance too small to refresh: ${totalSat} sat minus ${feeSat} sat fee is under the 330 sat minimum`);
+ }
const action = {
id: `refresh-${Date.now()}`, type: 'refresh', step: 'created',
inputIds: inputs.map((v) => v.id),
diff --git a/src/ark/proto.js b/src/ark/proto.js
index 6729465..81651ee 100644
--- a/src/ark/proto.js
+++ b/src/ark/proto.js
@@ -29,6 +29,9 @@ export function pbWriter() {
const chunks = [];
const varint = (n) => {
n = BigInt(n);
+ // a negative sign-extends under >>= forever — the do/while would push
+ // bytes until the process dies of memory, so refuse it loudly instead
+ if (n < 0n) throw new Error(`varint: negative value ${n}`);
const bs = [];
do { let b = Number(n & 0x7fn); n >>= 7n; if (n) b |= 0x80; bs.push(b); } while (n);
chunks.push(Uint8Array.from(bs));
diff --git a/src/features/gifts.js b/src/features/gifts.js
index bc16619..0543030 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -402,11 +402,16 @@ export function giftsFeature(ctx) {
// An ark gift: send the amount to a fresh bearer identity, off-chain and
// instant — no coin locking, no split logic, no claim fee.
+ // Gifts go down to 1 sat: the ASP cosigns all-dust arkoor sends, so a
+ // sub-dust bearer vtxo sweeps to the claimer fine (regtest-proven —
+ // tools/ark/dust-gift-test.js). The trade a sub-330 coin carries — no
+ // unilateral exit on its own, renewable in rounds only alongside bigger
+ // siblings — is the same one dust zaps already make.
async function doCreateArkGift() {
const info = hook('arkGiftInfo');
const avail = info ? info.spendableSat : 0;
const amt = ui.giftMax ? avail : parseAmount(ui.giftAmount, getUnit());
- if (!amt || amt < 330) { ui.giftError = t('giftAmountInvalid', { n: fmtAmount(330) + ' ' + unitLabel() }); render(); return; }
+ if (!amt || amt < 1) { ui.giftError = t('giftAmountInvalid', { n: fmtAmount(1) + ' ' + unitLabel() }); render(); return; }
if (amt > avail) { ui.giftError = t('giftExceedsBalance'); render(); return; }
ui.busy = true; ui.giftError = ''; render();
try {
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);