diff options
Diffstat (limited to 'src/ark/manager.js')
| -rw-r--r-- | src/ark/manager.js | 355 |
1 files changed, 307 insertions, 48 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index d5ae2b0..aea6020 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -92,6 +92,16 @@ const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0; // claim recorded before it stamped them — the same amount within minutes). // Derived at read time, never deleted: a synced copy from another device // can't bring a twin back. +// Two receive rows that are one payment delivered as several coins (a gift +// held in two swept at once, a dust-padded split): the same mailbox message +// (`batch`), or for rows recorded before the stamp, the same delivery instant. +// A receive the registrar's receipts tagged ⚡ is its own payment. +export function sameDelivery(a, b) { + return !!a && !!b && a.type === 'receive' && b.type === 'receive' + && a.status === 'complete' && b.status === 'complete' && !a.viaLn && !b.viaLn + && (a.batch || b.batch ? a.batch === b.batch : !!a.ts && a.ts === b.ts); +} + export function lnFoldedIds(movements) { const out = new Set(); const recs = (movements || []).filter((m) => m.type === 'receive' && m.status === 'complete' && m.vtxoId); @@ -119,6 +129,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; } @@ -177,9 +205,46 @@ export function pruneArkState(state, now = Date.now()) { // Shared by the app (features/ark.js) and the worker tending a closed wallet. export const backstopMargin = (network) => (network === 'regtest' ? 6 : 36); +// Custody tiers — what a coin's timelock means to this wallet: +// custodial nothing renews. The coinos server keeps honoring a coin past +// its timelock (it has swept the tree; the coin is its IOU), so +// the balance works like a v2 one, and no renewal is ever paid. +// self renew in the last hours before expiry — the free ppm bracket, +// one base fee per round — so a unilateral exit is always there. +// exit the same, plus: renew a coin as soon as its history runs +// `maxDepth` hops deep. A unilateral exit publishes one on-chain +// transaction per hop, so depth — not age — is what makes an +// exit expensive; a renewal flattens it to one hop. Early, so +// the top ppm bracket + base; only for a coin worth it (the fee +// under a tenth of its value — see maybeAutoRefresh). +// A policy is { window, margin, grid } in blocks: the app renews a coin with +// under `window` blocks left; the server-side backstop is registered `margin` +// blocks before expiry, snapped DOWN to a multiple of `grid` so everyone's +// orders land in the same round (one round's mining fee shared, not one per +// coin). The snapped height stays inside the free bracket: 36–83 blocks out. +export const CUSTODY_TIERS = ['custodial', 'self', 'exit']; +export const MAX_SEND_INPUTS = 48; +export function renewPolicy(tier, network) { + const rt = network === 'regtest'; + const self = { window: rt ? 24 : 96, margin: backstopMargin(network), grid: rt ? 1 : 48 }; + // 'early' was the third tier for a day (2026-10-22): a week-ahead renewal, + // which flattens the same history a last-hours one does — mirrors may + // still carry the name + if (tier === 'exit' || tier === 'early') return { ...self, maxDepth: 4 }; + if (tier === 'self') return self; + return null; +} + export class ArkManager { - constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint }) { + 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; this.storage = storage; // { load(): obj|null, save(obj): void } this.arkUrl = arkUrl; this.esploraUrl = esploraUrl; @@ -242,7 +307,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); @@ -417,7 +494,9 @@ export class ArkManager { // the exception — they have no unilateral exit to protect and the coinos ASP // keeps honoring them off-chain (mirrors validateVtxo's carve-out). _expired(v, tip) { - return !!v.expiryRejected || (!!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip); + if (v.expiryRejected) return true; // the server said no to this one + if (this.honorsExpired) return false; + return !!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip; } // The dust exemption is not a promise that every old coin will be @@ -462,6 +541,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(); @@ -508,6 +613,10 @@ export class ArkManager { this._lnRescanned = true; try { if (await this.rescanLnReceives()) await this._save(); } catch (e) { this._lnRescanned = false; } } + if (!this._expiredRescanned) { + this._expiredRescanned = true; + try { if (await this.rescanRejectedExpired()) this._save(); } catch (e) { this._expiredRescanned = false; } + } const baselining = !!this.state.baselinePending; // keep the cached tip fresh: balance() and input selection judge coin // expiry against it synchronously, and a stale tip hides dead coins @@ -570,6 +679,59 @@ export class ArkManager { } } + // On a server that honors coins past their timelock, the ones this wallet + // wrote off before it knew that are still its money: + // - receives an older build refused at pickup ("vtxo expired at …"), + // recorded as rejected and left behind the mailbox cursor — the + // forwards stranded that way before 2026-08-24 among them; + // - coins the server once refused to spend (expiryRejected, from before + // allow_expired_arkoor), and expired dust the user gave up (Forget). + // Once per wallet: re-read the mailbox from the start and adopt what the + // server still reports spendable; clear the old verdicts the same way. The + // cursor is left where it is. Returns whether anything changed. + async rescanRejectedExpired() { + if (!this.honorsExpired || this.state.expiredRescanDone) return false; + let changed = false; + const alive = async (v) => { + try { + return (await getVtxoStatus(this.arkUrl, this._decoded(v).point.raw, this._keyForVtxo(v).privkey)) === VTXO_STATE_SPENDABLE; + } catch { return false; } + }; + for (const v of this.state.vtxos) { + if (!(v.expiryRejected || (v.forgotten && v.state === 'spent'))) continue; + if (!(await alive(v))) continue; + delete v.expiryRejected; delete v.forgotten; v.state = 'spendable'; + changed = true; + } + const rejected = (this.state.movements || []).filter((m) => m.type === 'receive' && m.status === 'rejected' && /expired at/i.test(m.detail || '')); + if (rejected.length) { + const mailbox = this._mailboxKey(); + let checkpoint = 0; + for (let page = 0; page < 50; page++) { + const { messages, haveMore } = await readMailbox(this.arkUrl, mailbox, checkpoint); + for (const m of messages) { + checkpoint = Math.max(checkpoint, m.checkpoint || 0); + if (m.kind !== 'arkoor') continue; + const fresh = []; + for (const v of m.vtxos) { + if (this._vtxo(v.id)) continue; + // a coin another device of this wallet adopted and spent since + // is not money here; only what the server still holds for us is + let status = 0; + try { status = await getVtxoStatus(this.arkUrl, v.point.raw, this._key(0).privkey); } catch { status = 0; } + if (status === VTXO_STATE_SPENDABLE) fresh.push(v); + } + if (fresh.length && await this._handleMailboxMessage({ ...m, vtxos: fresh })) changed = true; + } + if (!haveMore || !messages.length) break; + } + this.state.movements = this.state.movements.filter((m) => !rejected.includes(m)); + changed = true; + } + this.state.expiredRescanDone = true; + return true; + } + async _handleMailboxMessage(m) { let changed = false; if (m.kind === 'lnSendFinished') { @@ -640,7 +802,7 @@ export class ArkManager { continue; } try { - await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys }); + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys, acceptExpired: this.honorsExpired }); } catch (e) { if (e instanceof VtxoValidationError) { this._movement({ type: 'receive', amountSat: v.amountSat, status: 'rejected', detail: e.message }); @@ -653,9 +815,13 @@ export class ArkManager { // same message concurrently (validation awaits in between), and only the // copy that actually added the vtxo may record the receive movement. if (!this._addVtxo(v, v._raw.bytes, 0)) continue; + // `batch` names the mailbox message: one payment can arrive as several + // coins (a sweep of a gift held in two, a dust-padded split), and the + // history folds a message's coins back into one row this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(v, tip)), + ...(m.checkpoint ? { batch: String(m.checkpoint) } : {}), }); changed = true; } @@ -733,8 +899,13 @@ export class ArkManager { v.state = owner.step === 'done' || owner.step === 'failed' ? 'spent' : 'pending'; changed = true; } + // An HTLC coin whose payment no action names any more (done ln-pays are + // pruned after a while) is an orphan too: five 22-sat ones sat 'pending' + // in a wallet for days, counted as money on its way. The server's answer + // settles it — spent is spent; one still open stays pending, since an + // HTLC coin is never this wallet's to spend (see below). const candidates = this.state.vtxos.filter((v) => v.state === 'spendable' - || (v.state === 'pending' && !held.has(v.id) && v.bytes && this._policyType(v) === 'pubkey')); + || (v.state === 'pending' && !held.has(v.id) && v.bytes)); const was = candidates.map((v) => v.state); // what each was when this pass began // Each status query signs a challenge (schnorr) and may decode the vtxo — // real secp256k1 work. Building them all in one synchronous map was a @@ -759,8 +930,8 @@ export class ArkManager { // an orphan the server still counts is simply spendable again; one it // holds as spent is gone, and the pay that took it was recorded (or // will merge in) elsewhere — no row of its own - if (states[i] === VTXO_STATE_SPENDABLE) { v.state = 'spendable'; changed = true; } - else if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; } + if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; } + else if (states[i] === VTXO_STATE_SPENDABLE && this._policyType(v) === 'pubkey') { v.state = 'spendable'; changed = true; } return; } if (states[i] !== VTXO_STATE_SPENT) return; @@ -962,22 +1133,46 @@ export class ArkManager { // that covers it, else largest-first until the sum does. A balance scattered // by many small receives stays spendable — the package cosign spends all // the inputs atomically and the recipient just receives several vtxos. + // A coin past its timelock spends LAST. The server honors it, and so does + // this build at pickup — but a receiver on an older build still refuses + // one as expired and strands it. Reaching for such a coin only when + // nothing else covers the amount narrows that to almost no payment. + _pastExpiry(v, tip) { return !!tip && !!v.expiryHeight && v.expiryHeight <= tip; } + _spendOrder(list, tip, cmp) { + const live = list.filter((v) => !this._pastExpiry(v, tip)).sort(cmp); + const past = list.filter((v) => this._pastExpiry(v, tip)).sort(cmp); + return [...live, ...past]; + } + // largest-first until `need` is covered; the coins still inside their + // timelock alone if they can carry it, else everything + _accumulate(spendable, tip, need) { + const desc = (a, b) => b.amountSat - a.amountSat; + const live = spendable.filter((v) => !this._pastExpiry(v, tip)).sort(desc); + for (const pool of [live, [...spendable].sort(desc)]) { + const picked = []; + let sum = 0; + for (const v of pool) { + picked.push(v); sum += v.amountSat; + if (need(picked, sum)) return picked; + } + } + return null; + } + _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)); - const single = spendable - .filter((v) => v.amountSat >= amountSat) - .sort((a, b) => a.amountSat - b.amountSat)[0]; + .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 = []; - let sum = 0; - for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) { - picked.push(v); sum += v.amountSat; - if (sum >= amountSat) break; - } - if (sum < amountSat) throw new Error(this._insufficientMsg(tip)); - if (picked.length > 24) throw new Error('balance is spread over too many coins for one send'); + const picked = this._accumulate(spendable, tip, (set, sum) => sum >= amountSat); + if (!picked) throw new Error(this._insufficientMsg(tip)); + // The cap is this wallet's, not the server's (which caps offboard and + // board inputs only). It used to be 24, with an automatic consolidation + // round at 20 coins to stay under it — a round that cost 0.2% + base on + // every coin and was 84% of what users paid in fees. Wider, no round. + if (picked.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one send'); return picked; } @@ -986,6 +1181,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 @@ -1313,9 +1510,13 @@ export class ArkManager { if (expectNet && dec.network && dec.network !== expectNet) throw new Error(`invoice is for ${dec.network}, wallet is on ${expectNet}`); const amountSat = dec.amountSat ?? userAmountSat; if (!amountSat || amountSat <= 0) throw new Error('invoice has no amount'); - if (this.state.actions.some((a) => - a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed')) { - throw new Error('this invoice was already paid or is being paid'); + const prior = this.state.actions.find((a) => + a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed'); + if (prior) { + // two messages, so a caller can tell a settled payment from one still + // on its way (an NWC client once read "already paid or is being paid" + // as paid, hours before the money moved) + throw new Error(prior.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress'); } const tip = await this.chain.tipHeight(); const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat); @@ -1326,20 +1527,18 @@ export class ArkManager { const spendable = this.state.vtxos .filter((v) => this._free(v, busy) && !this._expired(v, tip)); let inputs = null, feeSat = 0; - for (const v of [...spendable].sort((a, b) => a.amountSat - b.amountSat)) { + for (const v of this._spendOrder(spendable, tip, (a, b) => a.amountSat - b.amountSat)) { const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing; if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; } } if (!inputs) { - const picked = []; - let sum = 0; - for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) { - picked.push(v); sum += v.amountSat; - feeSat = lnSendFee(amountSat, this.info.lnSendFees, picked, tip) + routing; - if (sum >= amountSat + feeSat) { inputs = picked; break; } - } + const picked = this._accumulate(spendable, tip, (set, sum) => { + feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing; + return sum >= amountSat + feeSat; + }); + if (picked) inputs = picked; if (!inputs) throw new Error(this._insufficientMsg(tip)); - if (inputs.length > 24) throw new Error('balance is spread over too many coins for one payment'); + if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment'); } // per-input HTLC share: first n-1 consumed whole, the last carries change let remainingLock = amountSat + feeSat; @@ -1497,9 +1696,16 @@ export class ArkManager { action.step = 'initiated'; this._save(); } catch (e) { + // the HTLC already went to this payment: another context (a second + // wake of the phone's worker, seconds apart, over its own copy of the + // state) initiated it first. That payment is this one — follow it. + // (PullCap 2026-10-24 23:21: a paid send was marked 'revoking' here.) + if (e instanceof GrpcError && /state: ln-spent/i.test(e.message)) { + action.step = 'initiated'; + this._save(); // INVALID_ARGUMENT: the server itself deems the invoice unpayable — // retrying can never succeed, revoke the HTLC instead - if (e instanceof GrpcError && e.grpcStatus === 3) { + } else if (e instanceof GrpcError && e.grpcStatus === 3) { action.error = e.message; action.step = 'revoking'; this._save(); @@ -1526,7 +1732,22 @@ export class ArkManager { this._save(); } } - if (action.step === 'revoking') await this._revokeLnPay(action); + if (action.step === 'revoking') { + // Ask first: a send can land in 'revoking' over a stale copy of the + // state while the payment went through (or is still going) — a + // settled payment is settled here too, and one in flight waits. + const res = await checkLightningPayment(this.arkUrl, hex.decode(action.paymentHash), false).catch(() => null); + if (res && res.status === 'success' && res.preimage + && hex.encode(sha256(hex.decode(res.preimage))) === action.paymentHash) { + this._settleLnPay(action, res.preimage); + return; + } + if (res && res.status === 'pending' && action.htlcExpiry) { + const tip = await this.chain.tipHeight().catch(() => 0); + if (tip && tip <= action.htlcExpiry) { action.step = 'initiated'; delete action.error; this._save(); return; } + } + await this._revokeLnPay(action); + } } _settleLnPay(action, preimageHex) { @@ -1771,7 +1992,7 @@ export class ArkManager { if (v.policy.userPubkey !== hex.encode(keys.pubkey)) throw new Error('HTLC vtxo pubkey mismatch'); if (v.policy.htlcExpiry < htlcRecvExpiry) throw new Error('HTLC vtxo expiry lower than requested'); try { - await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] }); + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'], acceptExpired: this.honorsExpired }); } catch (e) { // a granted vtxo can anchor to a round the chain hasn't confirmed // yet — park (no preimage revealed, nothing at risk) and retry on @@ -1987,7 +2208,7 @@ export class ArkManager { if (v.policy.type !== 'pubkey' || v.policy.userPubkey !== expect) { throw new Error('refund vtxo is not paid to our refund key'); } - await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect] }); + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect], acceptExpired: this.honorsExpired }); if (this._addVtxo(v, bytes, refundIndex)) total += v.amountSat; } if (total) { @@ -2110,7 +2331,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 @@ -2250,19 +2474,20 @@ export class ArkManager { if (action.step === 'created') { // A set the round would refuse anyway is retired here, not resubmitted - // every sync: an input past its expiry (only the server's backstop can - // renew it now), or an output the fee leaves under the 330-sat dust - // floor (a 21-sat coin with a 150-sat fee sat "in progress" for days). - // Nothing was spent, so the inputs go back to spendable. - const tip = this._tipH || (await this.chain.tipHeight().catch(() => 0)); + // every sync: an output the fee leaves under the 330-sat dust floor (a + // 21-sat coin with a 150-sat fee sat "in progress" for days), or an + // input no longer held. Nothing was spent, so the inputs go back to + // spendable. An input PAST ITS EXPIRY is not a reason: the server's + // round-input check has no expiry clause (a coin 3,127 blocks past its + // height was renewed on mainnet, 2026-10-14) — renewal is the one + // thing an expired coin can always do, and this gate used to retire + // the Manage page's own "Renew" of exactly those coins, silently. const inSat = inputRecs.reduce((n, v) => n + (v ? v.amountSat : 0), 0); - const expired = inputRecs.some((v) => v && v.expiryHeight && tip && v.expiryHeight <= tip); - if (expired || inSat - (action.feeSat || 0) < 330 || inputRecs.some((v) => !v)) { + if (inSat - (action.feeSat || 0) < 330 || inputRecs.some((v) => !v)) { for (const v of inputRecs) if (v && v.state === 'pending') v.state = 'spendable'; action.step = 'failed'; action.superseded = true; // no row: the renewal never began - action.lastError = expired ? 'an input has expired: only the server can renew it now' - : inputRecs.some((v) => !v) ? 'an input is no longer held' : 'the fee leaves less than the 330-sat minimum'; + action.lastError = inputRecs.some((v) => !v) ? 'an input is no longer held' : 'the fee leaves less than the 330-sat minimum'; this._save(); return; } @@ -2437,24 +2662,32 @@ export class ArkManager { // expiry. Coins arrive, get spent, get renewed — each new one needs its own // backstop while it still has the lifetime to register one. Caller owns the // policy (how much margin, how often to ask); this owns the bookkeeping. - async ensureScheduledRefreshes(marginBlocks, maxCoins = 8) { + // grid: the order's height is snapped DOWN to a multiple of it, so orders + // from every wallet fall into the same round instead of one round (one + // on-chain fee) per coin. The snap only ever moves EARLIER, by under one + // grid step — the caller picks a margin+grid that stays in one fee bracket. + async ensureScheduledRefreshes(marginBlocks, maxCoins = 8, grid = 1) { const tip = await this._tipMemo(); if (!tip) return 0; const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds)); + const runAt = (v) => { + const h = v.expiryHeight - marginBlocks; + return grid > 1 ? h - (h % grid) : h; + }; // A round output over the server's cap is refused when the round runs — // long after registration, with nobody there to hear it. Don't register // an order that is going to die that quietly. const cap = this.info.maxVtxoAmountSat || Infinity; const coins = this.state.vtxos .filter((v) => this._free(v) && v.expiryHeight && !covered.has(v.id) - && !this._expired(v, tip) && v.expiryHeight - marginBlocks > tip && v.amountSat <= cap) + && !this._expired(v, tip) && v.expiryHeight > tip && runAt(v) > tip && v.amountSat <= cap) .sort((a, b) => b.amountSat - a.amountSat) .slice(0, maxCoins); let n = 0; for (const v of coins) { // One participation per coin. A shared one dies whole the moment any // single input is spent, taking the untouched coins' cover with it. - try { await this.scheduleRefresh([v.id], v.expiryHeight - marginBlocks); n++; } + try { await this.scheduleRefresh([v.id], runAt(v)); n++; } catch (e) { // priced from a stale schedule: re-read it so the next pass registers if (/insufficient refresh fee/i.test(e.message || '')) this.info = await getArkInfo(this.arkUrl).catch(() => this.info); @@ -2464,6 +2697,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(); |
