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');
|