diff options
Diffstat (limited to 'src/ark')
| -rw-r--r-- | src/ark/manager.js | 100 | ||||
| -rw-r--r-- | src/ark/proto.js | 4 |
2 files changed, 99 insertions, 5 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index df199af..caece44 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -119,6 +119,24 @@ export function lnFoldedIds(movements) { } if (best) out.add(best.id); } + // A Lightning payment the closed app's worker made is told twice once the + // app opens: the worker's own ln-send row (merged in from the mirror) and + // the "while closed" note the app adds from the worker's spend log. Per + // preimage (or payment hash), keep the row that names its inputs — the + // manager's — else the earliest. + const sends = (movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete' && (m.preimage || m.paymentHash)); + const keyOf = new Map(); // payment hash → the preimage key, where a row knows both + for (const s of sends) if (s.preimage && s.paymentHash) keyOf.set(s.paymentHash, 'p:' + s.preimage); + const byPay = new Map(); + for (const s of sends) { + const k = s.preimage ? 'p:' + s.preimage : (keyOf.get(s.paymentHash) || 'h:' + s.paymentHash); + (byPay.get(k) || byPay.set(k, []).get(k)).push(s); + } + for (const rows of byPay.values()) { + if (rows.length < 2) continue; + const keep = rows.find((r) => r.inputIds && r.inputIds.length) || rows.reduce((a, b) => ((a.ts || 0) <= (b.ts || 0) ? a : b)); + for (const r of rows) if (r !== keep) out.add(r.id); + } return out; } @@ -208,8 +226,12 @@ export function renewPolicy(tier, network) { } export class ArkManager { - constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false }) { + constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null }) { this.account = account; // HDKey node; ark keys derived beneath it + // The server's last published info, kept by the app: lets the wallet open + // when the server can't be reached (see init) + this.infoHint = infoHint || null; + this.offline = false; // the server cosigns spends of a coin past its timelock (coinos): such a // coin stays money here — spendable, receivable — just not exitable this.honorsExpired = !!honorsExpired; @@ -275,7 +297,19 @@ export class ArkManager { seen.add(m.vtxoId); return true; }); - this.info = await getArkInfo(this.arkUrl); + try { + this.info = await getArkInfo(this.arkUrl); + this.offline = false; + } catch (e) { + // The server is unreachable. With its last published info in hand the + // wallet still opens on its own records: coins read, a unilateral exit + // can start (that needs the chain, not the server), and anything that + // does need the server fails when it asks. Without that info there is + // no server key to check anything against — then it really is closed. + if (!this.infoHint) throw e; + this.info = this.infoHint; + this.offline = true; + } const hs = await handshake(this.arkUrl).catch(() => null); this.psa = hs?.psa; this.serverPub = hex.decode(this.info.serverPubkey); @@ -497,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(); @@ -1087,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); @@ -1106,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 @@ -2228,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 @@ -2591,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)) { |
