aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ark/manager.js62
-rw-r--r--src/ark/proto.js4
-rw-r--r--src/features/ark.js22
-rw-r--r--src/i18n.js2
-rw-r--r--src/nwc-respond.js8
5 files changed, 93 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); }