aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-22 22:06:01 -0700
committerAdam Soltys <[email protected]>2026-10-22 22:06:01 -0700
commitf4997d29a0c97eac61338448854c7ab9724cdc92 (patch)
tree14f2d56a06bd4bfb093c6bb5f6a21a0321de1dfa
parent648a5503089d33232855925a24ff60f78cab6df4 (diff)
downloadcoinosv3-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]>
-rw-r--r--src/ark/manager.js21
-rw-r--r--src/features/ark.js43
-rw-r--r--src/i18n.js6
-rw-r--r--tools/ark/custody-test.js14
-rw-r--r--tools/ark/custody-ui-test.js25
-rw-r--r--tools/nwc-sw-test.js4
6 files changed, 75 insertions, 38 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 2b7a5a7..79fdcd0 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -183,20 +183,27 @@ export const backstopMargin = (network) => (network === 'regtest' ? 6 : 36);
// the balance works like a v2 one, and no renewal is ever paid.
// self renew in the last hours before expiry — the free ppm bracket,
// one base fee per round — so a unilateral exit is always there.
-// early renew a week ahead (the 0.1% bracket + base) for whoever wants
-// a margin against the server being unreachable near the end.
+// exit the same, plus: renew a coin as soon as its history runs
+// `maxDepth` hops deep. A unilateral exit publishes one on-chain
+// transaction per hop, so depth — not age — is what makes an
+// exit expensive; a renewal flattens it to one hop. Early, so
+// the top ppm bracket + base; only for a coin worth it (the fee
+// under a tenth of its value — see maybeAutoRefresh).
// A policy is { window, margin, grid } in blocks: the app renews a coin with
// under `window` blocks left; the server-side backstop is registered `margin`
// blocks before expiry, snapped DOWN to a multiple of `grid` so everyone's
// orders land in the same round (one round's mining fee shared, not one per
-// coin). The snapped height stays inside the same fee bracket: self runs
-// 36–83 blocks out (free under 96), early 1008–1151 (0.1% under 2160).
-export const CUSTODY_TIERS = ['custodial', 'self', 'early'];
+// coin). The snapped height stays inside the free bracket: 36–83 blocks out.
+export const CUSTODY_TIERS = ['custodial', 'self', 'exit'];
export const MAX_SEND_INPUTS = 48;
export function renewPolicy(tier, network) {
const rt = network === 'regtest';
- if (tier === 'early') return { window: rt ? 60 : 1008, margin: rt ? 60 : 1008, grid: rt ? 1 : 144 };
- if (tier === 'self') return { window: rt ? 24 : 96, margin: backstopMargin(network), grid: rt ? 1 : 48 };
+ const self = { window: rt ? 24 : 96, margin: backstopMargin(network), grid: rt ? 1 : 48 };
+ // 'early' was the third tier for a day (2026-10-22): a week-ahead renewal,
+ // which flattens the same history a last-hours one does — mirrors may
+ // still carry the name
+ if (tier === 'exit' || tier === 'early') return { ...self, maxDepth: 4 };
+ if (tier === 'self') return self;
return null;
}
diff --git a/src/features/ark.js b/src/features/ark.js
index 7a77d2f..984c6a2 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -2602,8 +2602,9 @@ export function arkFeature(ctx) {
// default is coinos: most people come from v2, where coinos held
// everything, and the server honors a coin past its timelock anyway. The
// two renewing tiers are the opt-ins, for those who want the exit kept
- // (self) or kept with a week's margin (early). Only shown on a server that
- // honors expired coins; elsewhere renewal is the only way to keep money.
+ // (self) or kept cheap by flattening deep histories early (exit). Only
+ // shown on a server that honors expired coins; elsewhere renewal is the
+ // only way to keep money.
function custodyCard() {
if (!arkAvailable() || wallet.watchOnly) return null;
const cfg = getArkConfig();
@@ -2615,11 +2616,13 @@ export function arkFeature(ctx) {
const ppmAt = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0;
const lifeDays = Math.max(1, Math.round(((info && info.vtxoExpiryDelta) || 8640) / 144));
const baseCost = base ? fmtSats(base) + ' sats' : t('arkDepthFree');
- const earlyPct = pct(ppmAt(1008));
+ // an early renewal pays the bracket a fresh coin sits in (the top one)
+ const earlyPct = pct(ppmAt(((info && info.vtxoExpiryDelta) || 8640) - 1));
+ const hops = (renewPolicy('exit', getNetwork()) || {}).maxDepth || 4;
const tiers = [
{ id: 'custodial', label: t('arkCustodyCustodial'), desc: t('arkCustodyCustodialDesc'), cost: t('arkCustodyCostNone') },
{ id: 'self', label: t('arkCustodySelf'), desc: t('arkCustodySelfDesc', { days: lifeDays }), cost: t('arkCustodyCostSelf', { base: baseCost }) },
- { id: 'early', label: t('arkCustodyEarly'), desc: t('arkCustodyEarlyDesc'), cost: t('arkCustodyCostEarly', { pct: earlyPct, base: baseCost }) },
+ { id: 'exit', label: t('arkCustodyExit'), desc: t('arkCustodyExitDesc', { hops }), cost: t('arkCustodyCostExit', { pct: earlyPct, base: baseCost }) },
].filter((x) => honors || x.id !== 'custodial');
return h('div', { class: 'card col ark-custody', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('arkCustodyTitle')),
@@ -3260,8 +3263,10 @@ export function arkFeature(ctx) {
// one did, and no renewal fee is ever paid. The holder trusts coinos.
// self — renew in the last hours before expiry, the free ppm bracket, one
// base fee per round (~300 sats): a unilateral exit is always possible.
- // early — renew a week ahead at 0.1% + base, for whoever wants a margin
- // against coinos being unreachable right at the end.
+ // exit — the same, plus a coin is renewed as soon as its history is four
+ // hops deep: an exit pays one on-chain transaction per hop, so this
+ // keeps the exit price of every coin near a fresh coin's. Costs the
+ // top bracket + base each time, so only for coins worth it.
// The policy's window is when THIS app renews a coin (blocks of life left);
// margin/grid aim the SERVER's own backstop order — a participation the ASP
// runs on its own, registered far in advance, snapped to a grid so every
@@ -3275,7 +3280,8 @@ export function arkFeature(ctx) {
// request.
const CUSTODY_DEFAULT = 'custodial';
function getCustody() {
- const v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null;
+ let v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null;
+ if (v === 'early') v = 'exit'; // the third tier's first name (one day in October 2026)
return CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT;
}
function setCustody(tier) { if (wallet.saveFeatureState) wallet.saveFeatureState('arkCustody', tier); }
@@ -3354,7 +3360,26 @@ export function arkFeature(ctx) {
try {
const tip = await mgr.chain.tipHeight();
const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < policy.window);
- if (!expiringCoins.length) { arkRenewWarn = null; return; }
+ // The exit tier also renews a coin whose history has grown deep — each
+ // hop is one more on-chain transaction in a unilateral exit — but only
+ // a coin worth it: never more than a tenth of the coin on its renewal
+ // (300 sats to flatten a 21-sat coin's history would be absurd, and
+ // such a coin's exit is uneconomic whatever its depth).
+ const depthOf = (v) => { try { return mgr._decoded(v).genesis.length; } catch { return 0; } };
+ const deepCoins = !policy.maxDepth ? [] : spendables.filter((v) =>
+ !expiringCoins.includes(v) && depthOf(v) >= policy.maxDepth && mgr.refreshFee([v], tip) * 10 <= v.amountSat);
+ if (!expiringCoins.length && !deepCoins.length) { arkRenewWarn = null; return; }
+ if (!expiringCoins.length) {
+ // nothing is expiring: this round is for the deep coins alone, and
+ // they pay for it — the floor logic below still applies
+ arkRenewWarn = null;
+ let renewSet = [...deepCoins, ...spendables.filter((v) => !deepCoins.includes(v) && v.amountSat < 1000)];
+ const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
+ if (netSat(renewSet) < 330) return;
+ await mgr.refresh(renewSet.map((v) => v.id));
+ render();
+ return;
+ }
const totalSat = spendables.reduce((n, v) => n + v.amountSat, 0);
const renewable = totalSat - mgr.refreshFee(spendables, tip) >= 330;
if (!renewable) {
@@ -3373,7 +3398,7 @@ export function arkFeature(ctx) {
// Small change rides along, though: a coin under 1,000 sats costs at
// most 2 sats of ppm in this round, and folding it in now spares it a
// whole base fee's worth of round of its own when its turn comes.
- let renewSet = [...expiringCoins, ...spendables.filter((v) => !expiringCoins.includes(v) && v.amountSat < 1000)];
+ let renewSet = [...expiringCoins, ...deepCoins, ...spendables.filter((v) => !expiringCoins.includes(v) && !deepCoins.includes(v) && v.amountSat < 1000)];
// The set must clear the server's 330-sat output floor BY ITSELF — the
// renewable check above prices the whole balance. Two dust coins at
// expiry (133 sats between them) failed their renewal round silently
diff --git a/src/i18n.js b/src/i18n.js
index 6d36f49..d3817b6 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -784,11 +784,11 @@ const STR = {
arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. Past its timelock a coin is held by coinos, like a balance on the old coinos: paying, receiving and withdrawing keep working, but bringing it on-chain needs coinos’s cooperation.',
arkCustodySelf: 'Keep my own exit',
arkCustodySelfDesc: 'Coins renew in the last hours before their timelock ends — the cheapest moment — so you can always exit on your own. Roughly once every {days} days per coin.',
- arkCustodyEarly: 'Renew a week early',
- arkCustodyEarlyDesc: 'Coins renew about a week before their timelock ends, a margin in case coinos is unreachable right at the end.',
+ arkCustodyExit: 'Keep my exit cheap',
+ arkCustodyExitDesc: 'Every transfer adds a hop to a coin’s history, and a unilateral exit pays one on-chain transaction per hop. This renews near expiry too, and also as soon as a coin’s history is {hops} hops deep, so bringing any coin on-chain stays close to a fresh coin’s price. Only for coins worth it: never more than a tenth of a coin on its renewal.',
arkCustodyCostNone: 'no fees',
arkCustodyCostSelf: '{base} per renewal',
- arkCustodyCostEarly: '{pct} + {base} per renewal',
+ arkCustodyCostExit: '{base} near expiry, {pct} + {base} when early',
arkCustodySaved: 'Custody choice saved',
arkCustodyNotHere: 'This server does not honor coins past their timelock, so they renew automatically here.',
arkExitNoneExitable: 'Every coin has passed its timelock, so there is nothing a unilateral exit can take on-chain: coinos holds them now. Renewing restores your own exit; withdrawing with coinos’s cooperation works as before.',
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