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 /src | |
| 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 'src')
| -rw-r--r-- | src/ark/manager.js | 21 | ||||
| -rw-r--r-- | src/features/ark.js | 43 | ||||
| -rw-r--r-- | src/i18n.js | 6 |
3 files changed, 51 insertions, 19 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.', |
