diff options
| author | Adam Soltys <[email protected]> | 2026-10-23 07:59:40 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-23 07:59:40 -0700 |
| commit | 6c3b49a559c90954bf5ae6dbfada73ec5b3409cc (patch) | |
| tree | f0b6acf7e2ddbf22be00002b9c0b86e23c3f64a3 | |
| parent | b3cee21d7f6614c4ec0cf462ba4cfe3a3d4e308c (diff) | |
| download | coinosv3-6c3b49a559c90954bf5ae6dbfada73ec5b3409cc.tar.gz coinosv3-6c3b49a559c90954bf5ae6dbfada73ec5b3409cc.zip | |
Deep coins are renewed under every tier, free of the base fee, before the server's depth cap strands them
A coin's exit depth grows by one per off-chain hop; the server refuses to
cosign a spend of a coin at max_vtxo_exit_depth (100), so a wallet paying
hourly from one change chain has an unspendable coin within days. Survey of
the ASP's 2,279 unspent coins: median depth 7, 154 coins past 40 holding
7.7k sats, five already stuck at the cap.
The server now waives the refresh base fee when every input is at its
deep_waiver_depth (60; ArkInfo.fees.refresh carries it). The client mirrors
that in refreshFee, never selects a capped coin as an input (the shortfall
message says why), and flattens deep coins whatever the custody tier: the
open app in a round of their own, the worker with a standing order two
blocks out per coin. The default tier's copy names the one exception.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
| -rw-r--r-- | src/ark/manager.js | 62 | ||||
| -rw-r--r-- | src/ark/proto.js | 4 | ||||
| -rw-r--r-- | src/features/ark.js | 22 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/nwc-respond.js | 8 | ||||
| -rw-r--r-- | tools/ark/custody-test.js | 34 |
6 files changed, 127 insertions, 5 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 0723fc1..caece44 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -531,6 +531,32 @@ export class ArkManager { // free to spend, renew or exit from THIS device _free(v, busy = this._remoteBusy()) { return !!v && v.state === 'spendable' && !busy.has(v.id) && this._policyType(v) === 'pubkey'; } + // A coin's exit depth: one transaction per hop of its history (the round + // tree's branch, then every arkoor transfer). Each off-chain payment adds + // one to the change and one to what the recipient gets; only a round + // flattens it. The server refuses to cosign a spend of a coin at its + // `maxVtxoExitDepth` (100 on coinos: a wallet paying hourly from one change + // chain gets there in four days), so such a coin is money only a refresh + // can free — and it refreshes without the base fee at `deepWaiverDepth`. + exitDepth(v) { try { return this._decoded(v).genesis.length; } catch { return 0; } } + _capDepth() { return (this.info && this.info.maxVtxoExitDepth) || 0; } + _waiverDepth() { return (this.info && this.info.refreshFees && this.info.refreshFees.deepWaiverDepth) || 0; } + _capped(v) { const cap = this._capDepth(); return !!cap && this.exitDepth(v) >= cap; } + // The free coins a refresh should flatten now: at the server's waiver depth + // (no base fee, so always worth it), or within a few hops of the cap on a + // server without one (stuck otherwise). Expired coins too: a round is the + // one thing an expired coin can always do, and the server honors it. + deepCoins() { + const waiver = this._waiverDepth(), cap = this._capDepth(); + if (!waiver && !cap) return []; + const busy = this._remoteBusy(); + return this.state.vtxos.filter((v) => { + if (!this._free(v, busy)) return false; + const d = this.exitDepth(v); + return (waiver && d >= waiver) || (cap && d >= cap - 8); + }); + } + balance() { const tip = this._tipH || 0; const busy = this._remoteBusy(); @@ -1121,8 +1147,9 @@ export class ArkManager { _selectInputs(amountSat, tip = this._tipH || 0) { const busy = this._remoteBusy(); + // a coin at the server's depth cap can't be an input: the cosign is refused const spendable = this.state.vtxos - .filter((v) => this._free(v, busy) && !this._expired(v, tip)); + .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v)); const single = this._spendOrder(spendable.filter((v) => v.amountSat >= amountSat), tip, (a, b) => a.amountSat - b.amountSat)[0]; if (single) return [single]; const picked = this._accumulate(spendable, tip, (set, sum) => sum >= amountSat); @@ -1140,6 +1167,8 @@ export class ArkManager { .filter((v) => v.state === 'spendable' && this._expired(v, tip)) .reduce((n, v) => n + v.amountSat, 0); if (expiredSat) return `insufficient ark balance (${expiredSat} sat expired and unusable)`; + const cappedSat = this.state.vtxos.filter((v) => this._free(v) && this._capped(v)).reduce((n, v) => n + v.amountSat, 0); + if (cappedSat) return `insufficient ark balance (${cappedSat} sat have a history too long to spend and are waiting to be renewed)`; // coins a renewal took stay unspendable until its round confirms on-chain const renewing = lnShortfall(this.state.vtxos, this.state.actions, Infinity).renewingSat; return renewing @@ -2262,7 +2291,10 @@ export class ArkManager { const entry = table.filter((e) => e.thresholdBlocks <= blocks).pop(); ppmUnits += v.amountSat * (entry?.ppm ?? 0); } - return this.info.refreshFees.baseFeeSat + Math.ceil(ppmUnits / 1_000_000); + // the server waives the base fee when every input is at its waiver depth + const waiver = this._waiverDepth(); + const base = waiver && inputs.length && inputs.every((v) => this.exitDepth(v) >= waiver) ? 0 : this.info.refreshFees.baseFeeSat; + return base + Math.ceil(ppmUnits / 1_000_000); } // manual: the user asked for this one (the coins page) — it earns a @@ -2625,6 +2657,32 @@ export class ArkManager { return n; } + // The worker's version of flattening deep coins: it can't sit through a + // live round, so each deep coin gets a standing order for two blocks out, + // one per coin (an order dies whole when any input is spent — and a coin + // that is spent hourly will void it; the next wake registers again, and + // the one time the coin rests through the height, the round runs). Only + // coins still inside their timelock can be scheduled; an expired deep coin + // waits for the app, whose live refresh the server honors. + async ensureDeepRefreshes(maxCoins = 2) { + const deep = this.deepCoins(); + if (!deep.length || !maxCoins) return 0; + const tip = await this._tipMemo(); + if (!tip) return 0; + const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds)); + const cap = this.info.maxVtxoAmountSat || Infinity; + const coins = deep + .filter((v) => !covered.has(v.id) && v.expiryHeight > tip + 2 && v.amountSat <= cap) + .sort((a, b) => this.exitDepth(b) - this.exitDepth(a)) + .slice(0, maxCoins); + let n = 0; + for (const v of coins) { + try { await this.scheduleRefresh([v.id], tip + 2); n++; } + catch (e) { console.warn('ark: no deep refresh order for', v.id, '—', e.message); } + } + return n; + } + _dropScheduled(entry) { this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry); this._save(); diff --git a/src/ark/proto.js b/src/ark/proto.js index eb89d5e..849aaac 100644 --- a/src/ark/proto.js +++ b/src/ark/proto.js @@ -416,6 +416,7 @@ export async function getArkInfo(ark) { if (field === 13) info.minBoardAmountSat = Number(value); if (field === 15) info.lnReceiveAntiDosRequired = Number(value) !== 0; if (field === 16) info.mailboxPubkey = hex.encode(value); + if (field === 19) info.maxVtxoExitDepth = Number(value); // past this depth a coin can't be spent until refreshed if (field === 20) info.maxOffboardInputs = Number(value); if (field === 18) { // FeeSchedule for (const f of pbFields(value)) { @@ -445,9 +446,10 @@ export async function getArkInfo(ark) { info.offboardFees = ob; } if (f.field === 3) { // refresh - const rf = { baseFeeSat: 0, ppmExpiryTable: [] }; + const rf = { baseFeeSat: 0, ppmExpiryTable: [], deepWaiverDepth: 0 }; for (const g of pbFields(f.value)) { if (g.field === 1) rf.baseFeeSat = Number(g.value); + if (g.field === 3) rf.deepWaiverDepth = Number(g.value); // base fee waived when every input is this deep if (g.field === 2) { const e = { thresholdBlocks: 0, ppm: 0 }; for (const h of pbFields(g.value)) { diff --git a/src/features/ark.js b/src/features/ark.js index 5a8dc61..b155d17 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -3431,10 +3431,32 @@ export function arkFeature(ctx) { return true; } + // Whatever the tier, a coin whose history has run very deep is renewed: + // the server stops cosigning spends of a coin at its depth cap, so this is + // what keeps such a coin spendable, and it waives the base fee for a + // refresh of deep coins alone — so they go in a round of their own, never + // mixed with coins that would make it pay. A wallet paying hourly from one + // change chain (a subscription) is the usual way to get here. + async function flattenDeepCoins(mgr, tip) { + const deep = mgr.deepCoins ? mgr.deepCoins() : []; + if (!deep.length) return false; + if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return false; + const feeSat = mgr.refreshFee(deep, tip); + const totalSat = deep.reduce((n, v) => n + v.amountSat, 0); + if (totalSat - feeSat < 330 || feeSat * 10 > totalSat) return false; // dust, or a server charging full price: left alone + await mgr.refresh(deep.map((v) => v.id)); + render(); + return true; + } + async function maybeAutoRefresh(mgr) { if (wallet.watchOnly || !mgr || !mgr.state) return; if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return; const policy = custodyPolicy(); + if (mgr.deepCoins && mgr.deepCoins().length) { + arkAutoRefreshAt = Date.now(); + try { if (await flattenDeepCoins(mgr, await mgr.chain.tipHeight())) return; } catch {} + } // "coinos holds my coins": nothing renews on its own and nothing warns. // The server honors a coin past its timelock, and the holder chose this. if (!policy) { arkRenewWarn = null; return; } diff --git a/src/i18n.js b/src/i18n.js index 48faa49..78ba336 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -790,7 +790,7 @@ const STR = { safeBootCopied: 'Copied', arkCustodyDesc: 'Each coin expires about {days} days after it was made. Until then you can bring it on-chain yourself, without Coinos; after that, Coinos keeps honoring it. Renewing before expiry keeps your own exit, for a fee, so it is a choice:', arkCustodyCustodial: 'Allow coins to expire', - arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. A coin keeps its own exit until it expires. After that, paying still works, but bringing it on-chain needs Coinos’s cooperation.', + arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. A coin keeps its own exit until it expires. After that, paying still works, but bringing it on-chain needs Coinos’s cooperation. The one exception: a coin whose payment history has grown very long is renewed free of the base fee, so it stays spendable.', arkCustodySelf: 'Minimize renewal cost', arkCustodySelfDesc: 'Coins renew in the last hours before they expire, when renewal is cheapest, so you can always exit on your own without Coinos. Roughly once every {days} days per coin. Coins too small to be worth the fee are left alone.', arkCustodyExit: 'Minimize exit cost', diff --git a/src/nwc-respond.js b/src/nwc-respond.js index e6ee563..10692c9 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -469,7 +469,13 @@ export async function tendRenewals({ recordsFn = allBgs, saveFn = saveBg, log = while (rec.keys[String((mgr.state.nextKeyIndex || 1) + room)] && room < 4) room++; if (room && policy) { const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? policy.margin, room, policy.grid)).catch((e) => { log('renewal order: ' + e.message); return 0; }); - if (got) { n += got; log(`registered ${got} renewal order(s) while closed`); } + if (got) { n += got; room -= got; log(`registered ${got} renewal order(s) while closed`); } + } + // Under every tier: a coin near the server's depth cap must be + // flattened to stay spendable (the base fee is waived for it). + if (room > 0) { + const got = await bounded(mgr.ensureDeepRefreshes(room)).catch((e) => { log('deep refresh order: ' + e.message); return 0; }); + if (got) { n += got; log(`registered ${got} deep-coin renewal order(s) while closed`); } } await saveFn(walletKey, rec); } catch (e) { log('renewals: ' + e.message); } diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js index f8106b7..214ab45 100644 --- a/tools/ark/custody-test.js +++ b/tools/ark/custody-test.js @@ -93,4 +93,38 @@ const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expi 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'); } + +// deep coins: the server's depth cap and its base-fee waiver, mirrored +{ + const m = mk(true); + m.info.refreshFees.deepWaiverDepth = 60; + m.info.maxVtxoExitDepth = 100; + m.exitDepth = (v) => v.depth || 0; + m._tipMemo = async () => 1000; + const shallow = coin('s', 5000, 2000, { depth: 7 }), deep = coin('d', 4000, 2000, { depth: 60 }); + const capped = coin('x', 3000, 2000, { depth: 100 }), near = coin('n', 2500, 2000, { depth: 92 }); + const expiredDeep = coin('e', 6000, 900, { depth: 70 }); + m.state.vtxos = [shallow, deep, capped, near, expiredDeep]; + assert.equal(m.refreshFee([deep], 1000), 0, 'a refresh of deep coins alone pays no base fee'); + assert.equal(m.refreshFee([deep, expiredDeep], 1000), 0); + assert.equal(m.refreshFee([deep, shallow], 1000), 300, '…one shallow coin in the set and it pays'); + assert.equal(m.refreshFee([shallow], 1000), 300); + assert.deepEqual(m.deepCoins().map((v) => v.id).sort(), ['d', 'e', 'n', 'x'], 'deep = at the waiver depth or within 8 hops of the cap, expired or not'); + assert.deepEqual(m._selectInputs(2900).map((v) => v.id), ['d'], 'a coin at the cap is never an input (the cosign would be refused)'); + m.state.vtxos = [capped]; + assert.throws(() => m._selectInputs(1000), /waiting to be renewed/, 'and the shortfall says why'); + m.state.vtxos = [shallow, deep, capped, near, expiredDeep]; + // the worker's orders: one per deep coin still inside its timelock, two blocks out + const orders = []; + m.scheduleRefresh = async (ids, h) => { orders.push([ids.join(), h]); (m.state.scheduled ||= []).push({ inputIds: ids, unlockHash: 'u' + ids }); }; + assert.equal(await m.ensureDeepRefreshes(3), 3); + assert.deepEqual(orders, [['x', 1002], ['n', 1002], ['d', 1002]], 'deepest first, at tip + 2; the expired one waits for the app'); + assert.equal(await m.ensureDeepRefreshes(3), 0, 'covered coins are not ordered twice'); + // no waiver and no cap advertised: nothing is deep + const plain = mk(true); plain.exitDepth = () => 999; plain.state.vtxos = [coin('p', 5000, 2000)]; + assert.deepEqual(plain.deepCoins(), []); + assert.equal(plain.refreshFee(plain.state.vtxos, 1000), 300, 'no waiver advertised: the base fee stands however deep'); + console.log('✓ deep coins: no base fee for a refresh of deep coins alone, a capped coin is never spent, the worker orders them flattened'); +} + console.log('\n✅ custody tiers behave'); |
