From 6c4e3d1fe2bac42f8497488335e808b5b097061a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 26 Sep 2026 23:46:07 -0700 Subject: Renewal: a submitted round the server has forgotten is repaired, not asked about forever. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam's wallet carried a refresh action from August 31 in step 'submitted' whose unlock hash the server never ran: the round went through under a later submission (another hash), the inputs were spent by it and the 5,233-sat output reached the wallet through the mailbox — but the original action kept asking RoundParticipationStatus on every sync for four weeks, and since this week's coins page shows renewals in flight, it showed up as "in progress" with the raw gRPC error underneath. On "not found" the manager now checks the inputs: all spent means the round ran under another submission and the record is retired (failed, superseded — no history row, no card); none spent means the server dropped it before a round ran, so it is submitted again; a mix goes through the existing input repair. The coins page never prints a server error string; a failed check reads as "the wallet keeps trying". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D --- src/ark/manager.js | 35 ++++++++++++++++++++++++++++++++++- src/features/ark.js | 4 +++- src/i18n.js | 1 + 3 files changed, 38 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/ark/manager.js b/src/ark/manager.js index 7f24b7d..4a0a9c6 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -1920,6 +1920,33 @@ export class ArkManager { this._save(); } + // The server no longer knows the participation we submitted. Either the + // round ran under a DIFFERENT submission — a retry from this or another + // device minted a new unlock hash, the inputs are spent, and the output + // reached the wallet through the mailbox — or it was dropped before any + // round ran, and the inputs are still ours to submit again. Left alone, + // the action sat "in progress" for weeks, asking on every sync. + async _repairSubmitted(action, inputRecs) { + const states = await Promise.all(inputRecs.map((v) => + getVtxoStatus(this.arkUrl, this._decoded(v).point.raw, this._keyForVtxo(v).privkey).catch(() => null))); + if (states.some((st) => st == null)) return; // couldn't tell today; ask again next sync + const spent = inputRecs.filter((v, i) => states[i] === VTXO_STATE_SPENT); + if (spent.length === inputRecs.length) { + for (const v of inputRecs) v.state = 'spent'; + action.step = 'failed'; + action.superseded = true; // not a failure of the renewal, only of this record of it + action.lastError = 'superseded: the round ran under another submission'; + } else if (!spent.length) { + action.step = 'created'; + delete action.unlockHash; + delete action.lastError; + } else { + await this._repairRefresh(action, inputRecs); + return; + } + 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 @@ -1962,7 +1989,13 @@ export class ArkManager { this._save(); } if (action.step === 'submitted') { - const status = await roundParticipationStatus(this.arkUrl, hex.decode(action.unlockHash)); + let status; + try { status = await roundParticipationStatus(this.arkUrl, hex.decode(action.unlockHash)); } + catch (e) { + if (!/not found/i.test(e.message || '')) throw e; // transient: retry on next sync + await this._repairSubmitted(action, inputRecs); + return; + } if (status.status === 0 || !status.fundingTx) return; // round pending; retry on next sync const fundingTx = parseTx(status.fundingTx); const confirmed = await this.chain.getTxStatus(fundingTx.txid); diff --git a/src/features/ark.js b/src/features/ark.js index fa2f23d..2a47ef5 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2259,7 +2259,9 @@ export function arkFeature(ctx) { ...renewing.map((a) => h('div', { class: 'col', style: 'gap:2px' }, h('div', { class: 'small' }, t('arkRenewingLine', { amount: fmtSats(a.inAmountSat || (a.outAmountSat + (a.feeSat || 0))), fee: a.feeSat ? t('arkRenewingFee', { fee: fmtSats(a.feeSat) }) : '' })), h('div', { class: 'small muted' }, stageOf(a)), - a.lastError ? h('div', { class: 'small faint' }, a.lastError) : null))) : null; + // a check that failed is retried on the next sync; the reason is a + // server string nobody should have to read + a.lastError ? h('div', { class: 'small faint' }, t('arkRenewStageRetry')) : null))) : null; const tick = (checked, onChange, title) => h('input', { type: 'checkbox', checked, title, style: 'flex:0 0 auto;margin:0', onChange, }); diff --git a/src/i18n.js b/src/i18n.js index 4bba36f..3e20a98 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -438,6 +438,7 @@ const STR = { arkRenewStageQueued: 'Joining the next round — the wallet submits it on its next sync.', arkRenewStageRound: 'In a round. The fresh coin arrives once the round’s transaction has a confirmation, about a block from now, the next time this wallet syncs.', arkRenewStageFinishing: 'Finishing: signing the fresh coin.', + arkRenewStageRetry: 'The last check with the server didn’t go through; the wallet keeps trying.', arkRenewingHistory: 'Renewing', arkRenewingOpen: 'See the coins', arkCoinsExitTitle: 'Unilateral exit', -- cgit v1.2.3