diff options
| author | Adam Soltys <[email protected]> | 2026-10-22 22:06:01 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-22 22:06:01 -0700 |
| commit | f4997d29a0c97eac61338448854c7ab9724cdc92 (patch) | |
| tree | 14f2d56a06bd4bfb093c6bb5f6a21a0321de1dfa /tools | |
| parent | 648a5503089d33232855925a24ff60f78cab6df4 (diff) | |
| download | coinosv3-f4997d29a0c97eac61338448854c7ab9724cdc92.tar.gz coinosv3-f4997d29a0c97eac61338448854c7ab9724cdc92.zip | |
The third custody tier keeps exits cheap by depth, not by date: "Keep my exit cheap" renews a coin once its history is four hops deep
Why: a unilateral exit publishes one on-chain transaction per off-chain hop, so depth — not age — is what makes an exit expensive, and a renewal a week before expiry flattens exactly the same history a renewal in the last hours does. The week-ahead "early" tier only guarded against the server being unreachable at the very end, which is not the case worth selling. (Adam, 2026-10-23.)
exit = self (last-hours renewal, free bracket + base, the same grid-snapped backstop) plus: any coin whose genesis chain is 4+ hops deep is renewed in the next round the app runs, paying the top bracket + base — but only when that fee is under a tenth of the coin (flattening a 21-sat coin for 300 sats helps nobody; its exit is uneconomic anyway). Deep coins ride along with an expiry renewal when one is due, or get a round of their own. The server-side backstop stays near-expiry only: depth changes only while the wallet transacts, i.e. while the app is open. A stored 'early' reads as 'exit'. Card copy says what the hop count means and what the two prices are for.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/ark/custody-test.js | 14 | ||||
| -rw-r--r-- | tools/ark/custody-ui-test.js | 25 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 4 |
3 files changed, 24 insertions, 19 deletions
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js index da1079c..f8106b7 100644 --- a/tools/ark/custody-test.js +++ b/tools/ark/custody-test.js @@ -9,18 +9,16 @@ import { ArkManager, renewPolicy, CUSTODY_TIERS, MAX_SEND_INPUTS } from '../../s import { validateVtxo, VtxoValidationError } from '../../src/ark/validate.js'; // policies -assert.deepEqual(CUSTODY_TIERS, ['custodial', 'self', 'early']); +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('early', 'mainnet'), { window: 1008, margin: 1008, grid: 144 }); +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 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'); +// 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 }); diff --git a/tools/ark/custody-ui-test.js b/tools/ark/custody-ui-test.js index d85cd36..20035ac 100644 --- a/tools/ark/custody-ui-test.js +++ b/tools/ark/custody-ui-test.js @@ -48,14 +48,15 @@ try { window.coopManager = { honorsExpired: true, state: { vtxos: [ - { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921 }, // 29 days left - { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000 }, // past its timelock + { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 5 }, // 29 days left, five hops deep + { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000, depth: 1 }, // past its timelock + { id: 'coin-2', amountSat: 800, state: 'spendable', expiryHeight: 973921, depth: 6 }, // deep but not worth 300 sats ], actions: [], movements: [], scheduled: [] }, info: { network: 'regtest', vtxoExpiryDelta: 8640, roundIntervalSecs: 120, refreshFees: { baseFeeSat: 300, ppmExpiryTable: table } }, _tipH: 969751, chain: { tipHeight: async () => 969751 }, vtxos() { return this.state.vtxos; }, - _decoded() { throw new Error('no exit transaction fixture'); }, - balance() { return { spendableSat: 25000, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; }, + _decoded(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; }, + balance() { return { spendableSat: 25800, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; }, refreshFee(inputs, tip) { let u = 0; for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0); @@ -78,7 +79,8 @@ try { assert.match(tx, /Custody/); assert.match(tx, /coinos holds my coins[\s\S]*no fees/); assert.match(tx, /Keep my own exit[\s\S]*300 sats per renewal/); - assert.match(tx, /Renew a week early[\s\S]*0\.1% \+ 300 sats per renewal/); + assert.match(tx, /Keep my exit cheap[\s\S]*300 sats near expiry, 0\.2% \+ 300 sats when early/); + assert.match(tx, /4 hops deep/); const checked = await page.$$eval('.ark-custody input[type=radio]', (els) => els.filter((e) => e.checked).map((e) => e.value)); assert.deepEqual(checked, ['custodial'], 'coinos holds the coins by default'); assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial'); @@ -94,7 +96,7 @@ try { assert.deepEqual(exitBtn, [false]); await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Exit now').click()); await sleep(200); tx = await text(); - assert.match(tx, /Coins\s*1/); assert.match(tx, /Amount\s*20,000 sats/); + assert.match(tx, /Coins\s*2/); assert.match(tx, /Amount\s*20,800 sats/); await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); console.log('✓ a unilateral exit counts only the coin still inside its timelock'); @@ -108,11 +110,16 @@ try { assert.match(tx, /renews coins automatically once they are close to expiring/); const calls = await page.evaluate(() => coopFeature.autoRefreshCalls); assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered at once (regtest margin 6)'); - assert.deepEqual(calls[1], ['refresh', ['coin-1']], 'the coin past its timelock is renewed now; the healthy one is left alone'); + assert.deepEqual(calls[1], ['refresh', ['coin-1', 'coin-2']], 'the coin past its timelock is renewed now with the small change; the deep healthy one is left alone'); console.log('✓ switching to "Keep my own exit" persists, reprices the page and starts renewing'); - await page.click('.ark-custody-opt[data-tier=early] input'); await sleep(300); - assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'early'); + // the exit tier also flattens the deep coin — but not the deep 800-sat one on its own merits + await page.evaluate(() => { coopFeature.autoRefreshCalls.length = 0; }); + await page.click('.ark-custody-opt[data-tier=exit] input'); await sleep(400); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit'); + const calls2 = await page.evaluate(() => coopFeature.autoRefreshCalls); + assert.deepEqual(calls2[1], ['refresh', ['coin-1', 'coin-0', 'coin-2']], 'expired + deep (worth it) + small change ride one round'); + console.log('✓ "Keep my exit cheap" renews the deep coin too, when the fee is under a tenth of it'); await page.click('.ark-custody-opt[data-tier=custodial] input'); await sleep(300); assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial'); assert.match(await text(), /nothing renews on its own/); diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 2d07963..f037275 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -360,9 +360,9 @@ console.log('\n[tending renewals while closed]'); await tendRenewals({ recordsFn: async () => [{ walletKey: 'w6', rec: rec6 }], saveFn: async () => {} }); check('custodial (default, or a mirror without the field) → claims only, no orders', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0]]), JSON.stringify(calls)); calls.length = 0; - const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'early'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' }; + const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'exit'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' }; await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} }); - check('early tier → a week ahead on the daily grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 1008, 1, 144]]), JSON.stringify(calls)); + check('exit tier → the same backstop as self (depth is the app\'s business)', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 36, 1, 48]]), JSON.stringify(calls)); calls.length = 0; const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.custody = 'self'; rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key |
