aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/custody-test.js
blob: 214ab45ece3a660660b43563c074465e1f3dafea (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// 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');