From 648a5503089d33232855925a24ff60f78cab6df4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 22 Oct 2026 21:06:32 -0700 Subject: 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/api.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) (limited to 'src/api.js') diff --git a/src/api.js b/src/api.js index c0ecb4b..7f22e78 100644 --- a/src/api.js +++ b/src/api.js @@ -232,7 +232,7 @@ export function getBoltzWs() { const p = getBoltzProvider(); return p.ws || deri const ARK_PRESETS_BY_NET = { mainnet: [ { id: 'off', label: 'Off', ark: '', esplora: '' }, - { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api' }, + { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api', honorsExpired: true }, { id: 'second', label: 'Second (ark.second.tech)', ark: 'https://ark.second.tech', esplora: 'https://mempool.second.tech/api' }, { id: 'custom', label: 'Custom…', ark: '', esplora: '' }, ], @@ -249,12 +249,12 @@ const ARK_PRESETS_BY_NET = { ], mutinynet: [ { id: 'off', label: 'Off', ark: '', esplora: '' }, - { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://staging.coinos.io/esplora' }, + { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://staging.coinos.io/esplora', honorsExpired: true }, { id: 'custom', label: 'Custom…', ark: '', esplora: '' }, ], regtest: [ { id: 'off', label: 'Off', ark: '', esplora: '' }, - { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002' }, + { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002', honorsExpired: true }, { id: 'custom', label: 'Custom…', ark: '', esplora: '' }, ], }; @@ -294,7 +294,10 @@ export function getArkConfig(net = getNetwork()) { const id = getArkProviderId(net); if (id === 'off') return null; const p = arkPresets(net).find((x) => x.id === id); - const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora }; + // honorsExpired: the coinos servers keep cosigning spends of a coin past + // its timelock (captaind allow_expired_arkoor) — the basis of the default + // "coinos holds my coins" custody tier. Other servers refuse such a coin. + const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora, honorsExpired: !!p.honorsExpired }; return cfg.ark && cfg.esplora ? cfg : null; } -- cgit v1.2.3