diff options
Diffstat (limited to 'src/ark')
| -rw-r--r-- | src/ark/manager.js | 62 | ||||
| -rw-r--r-- | src/ark/proto.js | 4 |
2 files changed, 63 insertions, 3 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)) { |
