// 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'); } // deep coins: the server's depth cap and its base-fee waiver, mirrored { const m = mk(true); m.info.refreshFees.deepWaiverDepth = 60; m.info.maxVtxoExitDepth = 100; m.exitDepth = (v) => v.depth || 0; m._tipMemo = async () => 1000; const shallow = coin('s', 5000, 2000, { depth: 7 }), deep = coin('d', 4000, 2000, { depth: 60 }); const capped = coin('x', 3000, 2000, { depth: 100 }), near = coin('n', 2500, 2000, { depth: 92 }); const expiredDeep = coin('e', 6000, 900, { depth: 70 }); m.state.vtxos = [shallow, deep, capped, near, expiredDeep]; assert.equal(m.refreshFee([deep], 1000), 0, 'a refresh of deep coins alone pays no base fee'); assert.equal(m.refreshFee([deep, expiredDeep], 1000), 0); assert.equal(m.refreshFee([deep, shallow], 1000), 300, '…one shallow coin in the set and it pays'); assert.equal(m.refreshFee([shallow], 1000), 300); assert.deepEqual(m.deepCoins().map((v) => v.id).sort(), ['d', 'e', 'n', 'x'], 'deep = at the waiver depth or within 8 hops of the cap, expired or not'); assert.deepEqual(m._selectInputs(2900).map((v) => v.id), ['d'], 'a coin at the cap is never an input (the cosign would be refused)'); m.state.vtxos = [capped]; assert.throws(() => m._selectInputs(1000), /waiting to be renewed/, 'and the shortfall says why'); m.state.vtxos = [shallow, deep, capped, near, expiredDeep]; // the worker's orders: one per deep coin still inside its timelock, two blocks out const orders = []; m.scheduleRefresh = async (ids, h) => { orders.push([ids.join(), h]); (m.state.scheduled ||= []).push({ inputIds: ids, unlockHash: 'u' + ids }); }; assert.equal(await m.ensureDeepRefreshes(3), 3); assert.deepEqual(orders, [['x', 1002], ['n', 1002], ['d', 1002]], 'deepest first, at tip + 2; the expired one waits for the app'); assert.equal(await m.ensureDeepRefreshes(3), 0, 'covered coins are not ordered twice'); // no waiver and no cap advertised: nothing is deep const plain = mk(true); plain.exitDepth = () => 999; plain.state.vtxos = [coin('p', 5000, 2000)]; assert.deepEqual(plain.deepCoins(), []); assert.equal(plain.refreshFee(plain.state.vtxos, 1000), 300, 'no waiver advertised: the base fee stands however deep'); console.log('✓ deep coins: no base fee for a refresh of deep coins alone, a capped coin is never spent, the worker orders them flattened'); } console.log('\n✅ custody tiers behave');