aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/custody-test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/ark/custody-test.js')
-rw-r--r--tools/ark/custody-test.js96
1 files changed, 96 insertions, 0 deletions
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js
new file mode 100644
index 0000000..f8106b7
--- /dev/null
+++ b/tools/ark/custody-test.js
@@ -0,0 +1,96 @@
+// The custody tiers, without a network: what each policy aims for, how the
+// manager treats a coin past its timelock on a server that honors it, how
+// backstop orders snap to a grid so wallets share rounds, and that the
+// receive-side validator can be told to accept such a coin.
+// Run: bun tools/ark/custody-test.js
+import assert from 'node:assert/strict';
+import { hex } from '@scure/base';
+import { ArkManager, renewPolicy, CUSTODY_TIERS, MAX_SEND_INPUTS } from '../../src/ark/manager.js';
+import { validateVtxo, VtxoValidationError } from '../../src/ark/validate.js';
+
+// policies
+assert.deepEqual(CUSTODY_TIERS, ['custodial', 'self', 'exit']);
+assert.equal(renewPolicy('custodial', 'mainnet'), null, 'the default tier renews nothing');
+assert.equal(renewPolicy('nonsense', 'mainnet'), null);
+assert.deepEqual(renewPolicy('self', 'mainnet'), { window: 96, margin: 36, grid: 48 });
+assert.deepEqual(renewPolicy('exit', 'mainnet'), { window: 96, margin: 36, grid: 48, maxDepth: 4 }, 'exit = self + a depth trigger');
+assert.deepEqual(renewPolicy('early', 'mainnet'), renewPolicy('exit', 'mainnet'), 'the old name still means the exit tier');
+assert.deepEqual(renewPolicy('self', 'regtest'), { window: 24, margin: 6, grid: 1 });
+// the snap never leaves the free bracket: under 96 blocks out
+for (let h = 100000; h < 100200; h++) { const s = h - 36; assert.ok(h - (s - (s % 48)) < 96); }
+console.log('✓ renewal policies: custodial renews nothing; self and exit aim inside the free bracket');
+
+const mk = (honorsExpired) => {
+ const mgr = new ArkManager({ account: null, storage: { load: () => null, save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', honorsExpired });
+ mgr.state = { v: 1, nextKeyIndex: 1, vtxos: [], actions: [], movements: [], scheduled: [] };
+ mgr.info = { maxVtxoAmountSat: 5_000_000, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] } };
+ mgr._tipH = 1000;
+ return mgr;
+};
+const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expiryHeight, state: 'spendable', policy: 'pubkey', keyIndex: 1, ...extra });
+
+// a coin past its timelock: unusable on a server that refuses it, money on one that honors it
+{
+ const strict = mk(false), lenient = mk(true);
+ for (const m of [strict, lenient]) m.state.vtxos = [coin('a', 5000, 900), coin('b', 7000, 1200), coin('c', 100, 900)];
+ assert.equal(strict._expired(strict.state.vtxos[0], 1000), true);
+ assert.equal(strict._expired(strict.state.vtxos[2], 1000), false, 'sub-dust was always honored');
+ assert.deepEqual(strict.balance(), { spendableSat: 7100, pendingSat: 0, boardingSat: 0, expiredSat: 5000 });
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), false);
+ assert.deepEqual(lenient.balance(), { spendableSat: 12100, pendingSat: 0, boardingSat: 0, expiredSat: 0 });
+ assert.deepEqual(lenient._selectInputs(5000).map((v) => v.id), ['b'], 'a coin still inside its timelock is preferred…');
+ assert.deepEqual(lenient._selectInputs(7001).map((v) => v.id), ['b', 'a'], '…and the expired one joins only when needed');
+ lenient.state.vtxos.push(coin('d', 9000, 900));
+ assert.deepEqual(lenient._selectInputs(8000).map((v) => v.id), ['d'], 'a single expired coin beats a many-coin package');
+ lenient.state.vtxos.pop();
+ assert.deepEqual(strict._selectInputs(5000).map((v) => v.id), ['b']);
+ // the server's word still wins: a refusal naming the coin excludes it
+ lenient.state.vtxos[0].expiryRejected = true;
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), true);
+ assert.equal(lenient.balance().spendableSat, 7100);
+ console.log('✓ honorsExpired: a coin past its timelock stays spendable; a server refusal still excludes it');
+}
+
+// the send cap is wider now that nothing consolidates on its own
+{
+ const m = mk(true);
+ m.state.vtxos = Array.from({ length: 40 }, (_, i) => coin('c' + i, 100, 2000));
+ assert.equal(m._selectInputs(3500).length, 35, 'a 35-coin send is fine');
+ m.state.vtxos = Array.from({ length: MAX_SEND_INPUTS + 5 }, (_, i) => coin('d' + i, 100, 2000));
+ assert.throws(() => m._selectInputs(100 * (MAX_SEND_INPUTS + 1)), /too many coins/);
+ console.log(`✓ a send takes up to ${MAX_SEND_INPUTS} coins`);
+}
+
+// backstop orders snap down to the grid, and skip what the snap would put in the past
+{
+ const m = mk(true);
+ const orders = [];
+ m._tipMemo = async () => 1000;
+ m.scheduleRefresh = async (ids, height) => { orders.push([ids[0], height]); };
+ m.state.vtxos = [
+ coin('x', 10000, 1100), // 1100-36 = 1064 → 1056
+ coin('y', 20000, 1041), // 1005 → 960 ≤ tip: skipped (the app itself renews it)
+ coin('z', 30000, 1200), // 1164 → 1152
+ coin('w', 40000, 1200, { state: 'pending' }),
+ ];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 48), 2);
+ assert.deepEqual(orders, [['z', 1152], ['x', 1056]], 'largest first, snapped to multiples of 48');
+ assert.ok(orders.every(([, h]) => h % 48 === 0));
+ orders.length = 0;
+ m.state.scheduled = [];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 1), 3, 'grid 1 = the old exact heights');
+ assert.deepEqual(orders, [['z', 1164], ['y', 1005], ['x', 1064]]);
+ console.log('✓ backstop orders snap to the grid (and never into the past)');
+}
+
+// the validator: an expired coin is refused by default, accepted when told the server honors it
+{
+ const serverPub = new Uint8Array(33).fill(2);
+ const v = { policy: { type: 'pubkey', userPubkey: 'aa' }, serverPubkey: hex.encode(serverPub), genesis: [{ transition: {} }],
+ expiryHeight: 500, amountSat: 1000, anchorPoint: { txid: 'ab'.repeat(32), vout: 0 }, point: { txid: 'cd'.repeat(32), vout: 0 }, id: 'x' };
+ const chain = { tipHeight: async () => 600, getTxStatus: async () => ({ confirmed: false }), getTxHex: async () => null };
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain }), (e) => e instanceof VtxoValidationError && /expired at 500/.test(e.message));
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain, acceptExpired: true }), (e) => e instanceof VtxoValidationError && /not confirmed/.test(e.message), 'past the expiry check, on to the anchor');
+ console.log('✓ validateVtxo: acceptExpired lets a coin past its timelock through the expiry check');
+}
+console.log('\n✅ custody tiers behave');