diff options
| author | Adam Soltys <[email protected]> | 2026-10-22 21:06:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-22 21:06:32 -0700 |
| commit | 648a5503089d33232855925a24ff60f78cab6df4 (patch) | |
| tree | 1280073d797653d3ed86f0bb7b6c26b5d2271804 /tools/ark/custody-test.js | |
| parent | 8257a98e8d1891d0795f6b8b0623678245cc8e33 (diff) | |
| download | coinosv3-648a5503089d33232855925a24ff60f78cab6df4.tar.gz coinosv3-648a5503089d33232855925a24ff60f78cab6df4.zip | |
Custody is a choice, and coinos holding the coins is the default: nothing renews on its own and no renewal fee is paid unless the user opts in
Why: renewal fees were "money passively draining" to people who thought they had a custodial wallet (Stacker News, 2026-10-22). Measured over Sep–Oct: 84% of what users paid came from the client's own consolidation round at 20 coins (0.2% + 300 on every fresh coin), the rest from 300-sat rounds that each cohort of Lightning-received coins (30-day pool lifetime) triggered on its own schedule. The ASP itself honors a coin past its timelock — rounds and offboards never checked expiry, and captaind's allow_expired_arkoor (now on at ark.coinos.io) lets arkoor spends through too — so expiry only ever meant "the exit is gone", which most people coming from v2 never had.
Settings → Spending gets a Custody card with three tiers:
- coinos holds my coins (default): no renewals, no fees; a coin past its timelock shows as "held by coinos", stays spendable and receivable, and is left out of a unilateral exit (there is nothing on-chain for it to take).
- Keep my own exit: renew in the last hours (free bracket + base), the server backstop registered 36 blocks out and snapped to a 48-block grid so every wallet's orders share one round.
- Renew a week early: 1008 blocks out on a 144-block grid (0.1% + base).
renewPolicy() in ark/manager.js is the one table; the app, the service worker (the mirror carries the tier) and the Manage page all read it. Only shown on servers that honor expired coins (presets carry honorsExpired); elsewhere renewal stays automatic.
Manager: honorsExpired makes a past-timelock coin ordinary money (balance, selection, receive validation via acceptExpired) while a server refusal (expiryRejected) still excludes it; such coins spend LAST so an older build on the receiving side almost never meets one. A once-per-wallet rescan re-reads the mailbox and adopts coins older builds refused as expired, un-forgets expired dust, and clears stale refusals. The refresh gate that retired a renewal of expired inputs is gone (the server accepts them; the Manage page's Renew of exactly those coins was failing silently). Automatic consolidation is gone; the send cap goes 24 → 48 coins; small change (<1,000 sats) folds into a renewal that happens anyway.
Server (cs:~/ark/captaind-main.toml, restarted): allow_expired_arkoor = true; pool vtxo_lifetime 4320 → 8640 so Lightning-received coins live as long as the rest.
Tests: tools/ark/custody-test.js (policies, honoring, ordering, grid snap, validator), tools/ark/custody-ui-test.js (the card, held coins, exit count, switching), nwc-sw-test updated for the tier the worker reads.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools/ark/custody-test.js')
| -rw-r--r-- | tools/ark/custody-test.js | 98 |
1 files changed, 98 insertions, 0 deletions
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js new file mode 100644 index 0000000..da1079c --- /dev/null +++ b/tools/ark/custody-test.js @@ -0,0 +1,98 @@ +// 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', 'early']); +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('early', 'mainnet'), { window: 1008, margin: 1008, grid: 144 }); +assert.deepEqual(renewPolicy('self', 'regtest'), { window: 24, margin: 6, grid: 1 }); +// the snap never leaves the bracket: self stays under 96 blocks out, early under 2160 +for (let h = 100000; h < 100200; h++) { + const s = h - 36; assert.ok(h - (s - (s % 48)) < 96); + const e = h - 1008; assert.ok(h - (e - (e % 144)) < 2160 && h - (e - (e % 144)) >= 199); +} +console.log('✓ renewal policies: custodial renews nothing; self and early aim inside one fee 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'); |
