diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 173 | ||||
| -rw-r--r-- | src/ark/proto.js | 1 | ||||
| -rw-r--r-- | src/ark/refresh.js | 8 | ||||
| -rw-r--r-- | src/features/ark.js | 45 | ||||
| -rw-r--r-- | src/i18n.js | 1 |
5 files changed, 225 insertions, 3 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 15714b7..4dd39e4 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -60,6 +60,7 @@ const EMPTY_STATE = () => ({ vtxos: [], // { id, bytes, keyIndex, amountSat, expiryHeight, state } actions: [], // { id, type, step, ... } movements: [], // { id, type, amountSat, ts, status, detail } + scheduled: [], // standing-order refreshes: { unlockHash, inputIds, scheduledHeight, ... } }); // Local state must not grow forever. Two things did: a spent coin kept its @@ -135,6 +136,8 @@ export class ArkManager { this.state = null; this.info = null; this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push + this._refreshDriving = new Set(); // same, for round claims: sync loop vs mailbox breadcrumb + this._schedPollAt = new Map(); // last status poll per standing-order unlock hash // A derived key for an index never changes for this wallet, but computing // it costs a BIP32 derive + a secp256k1 getPublicKey (a full point mul). // reconcile() alone calls _keyForVtxo for every spendable coin, and decode/ @@ -417,6 +420,9 @@ export class ArkManager { } if (changed) this._save(); await this.resumePending(); + // Standing-order refreshes live beside the actions, not among them. + await this._driveScheduled().catch((e) => + console.warn('ark: scheduled refresh check failed —', e.message)); // The display is only as honest as the last reconcile: state restored // from a snapshot (or mirrored by the NWC worker) can show coins the // server already saw spent — a balance you can look at but not spend. @@ -483,7 +489,14 @@ export class ArkManager { // on the server until someone acts on it (cooney's 28,833 sats did, // for 8 days). Adopt it; rescueParticipation no-ops on inputs we don't // hold and the server side is idempotent. - if (m.unlockHash + // A standing order's round announces itself the same way, and this is + // the earliest word of it — claim on the breadcrumb instead of waiting + // for the next scheduled-height check. + const standing = (this.state.scheduled || []).find((s) => s.unlockHash === m.unlockHash); + if (standing) { + this._promoteScheduled(standing).catch((e) => + console.warn('ark: scheduled refresh not claimed —', e.message)); + } else if (m.unlockHash && !this.state.actions.some((a) => a.unlockHash === m.unlockHash && a.step !== 'failed')) { this.rescueParticipation(m.unlockHash).catch((e) => console.warn('ark: stranded participation not adopted —', e.message)); @@ -1791,7 +1804,18 @@ export class ArkManager { this._save(); } + // A refresh claim can be reached from two directions at once — the sync + // loop resuming the action, and a mailbox breadcrumb claiming the moment + // the round is announced. Both are safe server-side (cosign and forfeit are + // idempotent), but two winners would write two "Renewed" rows. async _driveRefresh(action) { + if (this._refreshDriving.has(action.id)) return; + this._refreshDriving.add(action.id); + try { await this._driveRefreshSteps(action); } + finally { this._refreshDriving.delete(action.id); } + } + + async _driveRefreshSteps(action) { const outputs = this._refreshOutputs(action); const inputRecs = action.inputIds.map((id) => this._vtxo(id)); @@ -1872,6 +1896,149 @@ export class ArkManager { } } + // ---- scheduled refresh: the renewal that happens without us ---- + // + // A participation registered now but held by the server until a future + // block height, then run in a round with nobody online (the round tree + // branch is signed by the server — that is what makes a delegated refresh + // delegated). The output waits 'unclaimed' until this wallet returns and + // forfeits the inputs, which nothing about expiry blocks: by then the money + // already sits in a fresh tree, anchored on-chain, with a new lifetime. + // + // This is the backstop under maybeAutoRefresh, whose whole premise is that + // someone opens the app inside the last few hours of a coin's life. Many + // people don't. + // + // Deliberately NOT an action: the coins stay spendable, nothing treats the + // wallet as busy, and no history row appears until there is something to + // say. Spend a coin and its standing order simply dies — the server drops a + // participation whose input is gone, and so do we. + async scheduleRefresh(vtxoIds, scheduledHeight) { + const inputs = vtxoIds.map((id) => this._vtxo(id)); + if (!inputs.length || inputs.some((v) => !v || v.state !== 'spendable')) { + throw new Error('no spendable vtxos to schedule'); + } + if (inputs.some((v) => !v.expiryHeight || v.expiryHeight <= scheduledHeight)) { + throw new Error('scheduled height is not before every input expiry'); + } + // Priced at the height it will RUN at — which is what the server checks, + // and cheaper than today: the refresh brackets fall as a coin's remaining + // lifetime does, and the last one is free. + const totalSat = inputs.reduce((n, v) => n + v.amountSat, 0); + const feeSat = this.refreshFee(inputs, scheduledHeight); + if (totalSat - feeSat < 330) { + throw new Error(`balance too small to refresh: ${totalSat} sat minus ${feeSat} sat fee is under the 330 sat minimum`); + } + const entry = { + unlockHash: null, + inputIds: inputs.map((v) => v.id), + outKeyIndex: this.state.nextKeyIndex++, + outAmountSat: totalSat - feeSat, feeSat, scheduledHeight, ts: Date.now(), + }; + this._save(); // burn the key index before it can be used, as every flow does + await registerVtxoTransactions(this.arkUrl, inputs.map((v) => vtxoBytesFromStr(v.bytes))); + const unlockHash = await submitRoundParticipation(this.arkUrl, { + inputs: inputs.map((v) => ({ vtxo: this._decoded(v), keys: this._keyForVtxo(v) })), + outputs: this._refreshOutputs(entry), + mailboxId: this._mailboxKey().pubkey, + scheduledHeight, + }); + entry.unlockHash = hex.encode(unlockHash); + (this.state.scheduled ||= []).push(entry); + this._save(); + return entry; + } + + scheduledRefreshes() { return (this.state.scheduled || []).slice(); } + + // Keep every coin covered by a standing order, `marginBlocks` before its + // 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) { + const tip = await this._tipMemo(); + if (!tip) return 0; + const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds)); + // 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) => v.state === 'spendable' && v.expiryHeight && !covered.has(v.id) + && !this._expired(v, tip) && v.expiryHeight - marginBlocks > 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++; } + catch (e) { console.warn('ark: no backstop refresh for', v.id, '—', e.message); } + } + return n; + } + + _dropScheduled(entry) { + this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry); + this._save(); + } + + // The round ran. From here it is an ordinary refresh claim — leaf cosign, + // forfeit, adopt the output — and the inputs stop being spendable, because + // the server has already issued their replacement. + async _promoteScheduled(entry) { + this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry); + const action = { + id: `refresh-${Date.now()}`, type: 'refresh', step: 'submitted', + inputIds: [...entry.inputIds], outKeyIndex: entry.outKeyIndex, + outAmountSat: entry.outAmountSat, feeSat: entry.feeSat, + unlockHash: entry.unlockHash, scheduledHeight: entry.scheduledHeight, + }; + for (const id of entry.inputIds) { + const v = this._vtxo(id); + if (v && v.state === 'spendable') v.state = 'pending'; + } + this.state.actions.push(action); + this._save(); + await this._driveRefresh(action); + return action; + } + + // Advance the standing orders: drop the dead ones, claim the ones whose + // round has run. Costs an RPC only once a scheduled height is behind us. + async _driveScheduled() { + for (const entry of [...(this.state.scheduled || [])]) { + const inputs = entry.inputIds.map((id) => this._vtxo(id)); + // A spent input ends the order — the server drops its copy for the same + // reason. 'pending' is not that: an action holding the coin may yet + // fail and hand it back, and re-registering costs a round-trip. + if (inputs.some((v) => !v || v.state === 'spent')) { this._dropScheduled(entry); continue; } + if (inputs.some((v) => v.state !== 'spendable')) continue; + const tip = this._tipH || 0; + if (tip && tip < entry.scheduledHeight) continue; + // Past the scheduled height the round can be hours out (it runs when + // the server runs one), and sync comes round every few seconds. The + // mailbox breadcrumb is what actually wakes the claim; this poll is the + // fallback for a wallet that missed it, so it can be slow. + const now = Date.now(); + if (now - (this._schedPollAt.get(entry.unlockHash) || 0) < 120_000) continue; + this._schedPollAt.set(entry.unlockHash, now); + let status; + try { + status = await roundParticipationStatus(this.arkUrl, hex.decode(entry.unlockHash)); + } catch (e) { + // The server deletes a participation it can't run (an input spent + // between rounds, a collision with another process holding the coin), + // so an unknown unlock hash ends this standing order rather than + // being retried forever. Anything else is transient. + if (/unknown|not found|no such|doesn't exist/i.test(e.message || '')) this._dropScheduled(entry); + continue; + } + if (status.status === 0 || !status.fundingTx) continue; // round hasn't run yet + await this._promoteScheduled(entry); + } + } + // Rebuild a refresh claim from its unlock hash — for a participation whose // action was lost (snapshot rollback, device wipe, crash mid-claim) after // its round ran, leaving the output stranded 'unclaimed' on the server. @@ -1884,6 +2051,10 @@ export class ArkManager { if (this.state.actions.some((a) => a.unlockHash === unlockHashHex && a.step !== 'failed')) { throw new Error('an action for this participation already exists — sync drives it'); } + // One of ours, waiting on a scheduled height: it has its inputs and key + // index already, so promote rather than rediscover them. + const standing = (this.state.scheduled || []).find((s) => s.unlockHash === unlockHashHex); + if (standing) return this._promoteScheduled(standing); const status = await roundParticipationStatus(this.arkUrl, hex.decode(unlockHashHex)); if (status.status === 0 || !status.fundingTx) throw new Error('the round for this participation has not run'); if (!inputVtxoIds?.length) inputVtxoIds = status.inputVtxoIds; diff --git a/src/ark/proto.js b/src/ark/proto.js index 829eba4..eb89d5e 100644 --- a/src/ark/proto.js +++ b/src/ark/proto.js @@ -410,6 +410,7 @@ export async function getArkInfo(ark) { if (field === 6) info.vtxoExpiryDelta = Number(value); if (field === 7) info.htlcSendExpiryDelta = Number(value); if (field === 8) info.htlcExpiryDelta = Number(value); + if (field === 9) info.maxVtxoAmountSat = Number(value); if (field === 10) info.requiredBoardConfirmations = Number(value); if (field === 11) info.maxUserInvoiceCltvDelta = Number(value); if (field === 13) info.minBoardAmountSat = Number(value); diff --git a/src/ark/refresh.js b/src/ark/refresh.js index 4bd74f2..6e4453d 100644 --- a/src/ark/refresh.js +++ b/src/ark/refresh.js @@ -311,7 +311,12 @@ export function participationAttestation(inputVtxoIdRaw, outputs, vtxoPrivkey) { // mailboxId: when given, the server posts a recovery breadcrumb (the unlock // hash) to that mailbox once the round runs — any device of this wallet can // then claim the output even if the action that submitted it is lost. -export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId }) { +// scheduledHeight: when given, the server holds the participation out of +// every round until that block height and then runs it with nobody online +// (and prices the refresh fee at that height, not today's). Omitted, the +// participation joins the next round, which is what an interactive refresh +// wants. +export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId, scheduledHeight }) { const w = pbWriter(); for (const { vtxo, keys } of inputs) { const iv = pbWriter(); @@ -326,6 +331,7 @@ export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId w.bytesField(3, vr.finish()); } if (mailboxId) w.bytesField(4, mailboxId); + if (scheduledHeight) w.varintField(5, scheduledHeight); const resp = await grpcCall(ark, 'bark_server.ArkService/SubmitRoundParticipation', w.finish()); for (const { field, value } of pbFields(resp)) if (field === 1) return value; // unlock_hash throw new Error('no unlock hash in response'); diff --git a/src/features/ark.js b/src/features/ark.js index 764affd..222b312 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2631,7 +2631,16 @@ export function arkFeature(ctx) { // rescuing) a coin even past its expiry, so aiming inside the free window // risks nothing. ~16 hours of margin on mainnet. const renewWindow = () => (getNetwork() === 'regtest' ? 24 : 96); + // How close to expiry the SERVER's own backstop renewal is aimed. The + // window above only helps a wallet someone opens inside it; this one is a + // participation registered far in advance that the ASP runs on its own, + // whether or not anybody is looking. It sits well inside renewWindow so an + // app that IS open still renews first (and the untouched backstop is + // dropped), and far enough from expiry — 36 blocks, six hourly rounds — for + // the round to actually run in time. Both are in the free fee bracket. + const backstopWindow = () => (getNetwork() === 'regtest' ? 6 : 36); let arkAutoRefreshAt = 0; + let arkBackstopAt = 0; // Set when the balance is too small to renew itself (total minus round fee // under the server's 330-sat output minimum) AND a vtxo is inside the // renewal window: { deadlineMs, sat }. The wallet-screen notice reads this. @@ -2663,8 +2672,22 @@ export function arkFeature(ctx) { async function maybeAutoRefresh(mgr) { if (wallet.watchOnly || !mgr || !mgr.state) return; if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return; - const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable' && !v.expiryRejected); + // NOT filtered by expiryRejected. A coin the server refused as expired + // can still be REFRESHED — that is the one thing an expired vtxo can + // still do — and excluding it here meant a wallet holding nothing else + // did nothing at all: no renewal attempt, and no warning either. + const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable'); if (!spendables.length) { arkRenewWarn = null; return; } + // Register the server-side backstop for anything not covered yet. Ahead + // of the in-flight guard below on purpose: a standing order takes no coin + // and starts no round, so a send in progress is no reason to skip the one + // renewal that survives the app never being opened again. Stamped on its + // own clock — the guard below can return before the refresh throttle is + // stamped, and this must not then run on every five-second sync. + if (Date.now() - arkBackstopAt > 30 * 60_000) { + arkBackstopAt = Date.now(); + await mgr.ensureScheduledRefreshes(backstopWindow()).catch(() => {}); + } // never start a round while any other action is still in flight if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; arkAutoRefreshAt = Date.now(); @@ -3404,6 +3427,26 @@ export function arkFeature(ctx) { amount: fmtAmount(arkRenewWarn.sat) + ' ' + unitLabel(), date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), }))); + // Coins past their expiry. The balance already leaves these out, and + // until now it did so in silence — money apparently gone, with the one + // page that could bring it back reachable only through an advisory + // that most wallets never see (it wants four hops of depth) or a + // renewal forecast that only appears when the renewal COSTS something, + // which is no longer the normal case. + // + // An expired vtxo can still be renewed — that is the whole of the fix, + // and it is one tap away on the Manage page. What it cannot do is be + // spent over Ark or exited unilaterally, so saying nothing is the one + // option that isn't honest. + if (!wallet.watchOnly && ark && ark.balance) { + let expiredSat = 0; + try { expiredSat = ark.balance().expiredSat || 0; } catch {} + if (expiredSat > 0) out.push(h('div', { class: 'notice err', style: 'margin:12px 0 0' }, + t('arkExpiredNotice', { amount: fmtAmount(expiredSat) + ' ' + unitLabel() }), + ' ', + h('button', { class: 'linklike small', onClick: () => { ui.arkCoinsPage = true; render(); } }, + t('arkDepthBtn')))); + } const depthNotice = exitDepthNotice(); if (depthNotice) out.push(depthNotice); // An upcoming renewal that will COST something — stated before it diff --git a/src/i18n.js b/src/i18n.js index bb787b0..274b2ed 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -268,6 +268,7 @@ const STR = { arkDepthNotice: 'Uncooperative exit costs about {fee} sats.', arkDepthFree: 'free', arkDepthBtn: 'Manage', + arkExpiredNotice: '{amount} has passed its expiry. The server can still renew it — until then it can\u2019t be spent or exited.', arkDepthRenewed: 'Renewal requested', arkDepthRenewBtn: 'Renew now', arkCoinsTitle: 'Spending coins', |
