aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/custody-test.js
blob: f8106b72bed514f7474b04d6118925d2ec025939 (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
// 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');