diff options
| author | Adam Soltys <[email protected]> | 2026-10-08 13:00:41 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-08 13:00:41 -0700 |
| commit | b0c6e8b7d0340f5ef64ddeef0f7b0c0531cb87f1 (patch) | |
| tree | ecf8c3cd8ae46175aca8cc357d015f13822baf56 /src | |
| parent | 767ab45d9158dcd995a7647d0efaf6c127b35e3a (diff) | |
| download | coinosv3-b0c6e8b7d0340f5ef64ddeef0f7b0c0531cb87f1.tar.gz coinosv3-b0c6e8b7d0340f5ef64ddeef0f7b0c0531cb87f1.zip | |
A finished renewal adopted twice no longer shows 'Renewal in progress' forever: once its done action is pruned, a re-read round breadcrumb adopted it again as a rescue at step 'issued', whose spent inputs had lost their bytes, so every sync threw. A round counts as claimed when its 'Renewed' movement or its fresh coins are already in the wallet; the breadcrumb and rescueParticipation skip it, and a stuck issued action retires without a row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 33 |
1 files changed, 32 insertions, 1 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 0031da5..ac63457 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -520,7 +520,8 @@ export class ArkManager { 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.state.actions.some((a) => a.unlockHash === m.unlockHash && a.step !== 'failed') + && !this._participationClaimed(m.unlockHash)) { this.rescueParticipation(m.unlockHash).catch((e) => console.warn('ark: stranded participation not adopted —', e.message)); } @@ -2180,6 +2181,18 @@ export class ArkManager { this._save(); } if (action.step === 'issued') { + // Already claimed — this is a second adoption of a finished round (its + // done action was pruned, then the mailbox breadcrumb was read again). + // Its inputs are long spent and may have lost their bytes to pruning, + // so driving it would throw on every sync and show "Renewal in + // progress" forever. Retire it without a row; the first claim has one. + if (this._participationClaimed(action.unlockHash, action.outputVtxos, action.id)) { + action.step = 'failed'; + action.superseded = true; + delete action.lastError; + this._save(); + return; + } const outKeys = this._key(action.outKeyIndex); const fundingTx = parseTx(hex.decode(action.fundingTxHex)); const unlockHash = hex.decode(action.unlockHash); @@ -2370,10 +2383,27 @@ export class ArkManager { // original input records, which must still exist in this wallet's state // (already marked spent is fine). inputVtxoIds may be passed explicitly // (the operator recipe) — omitted, they come from the status response. + // Has this round participation been claimed already? A done refresh's + // action is pruned, so the lasting evidence is its "Renewed" movement + // (keyed by unlock hash) or the fresh coins themselves sitting in the + // wallet. exceptId: the action asking, whose own outputs don't count. + _participationClaimed(unlockHashHex, outputVtxosHex, exceptId) { + if (!unlockHashHex) return false; + if ((this.state.movements || []).some((m) => + m.type === 'refresh' && m.unlockHash === unlockHashHex && m.status === 'complete')) return true; + if ((this.state.actions || []).some((a) => + a.id !== exceptId && a.unlockHash === unlockHashHex && a.step === 'done')) return true; + if (!outputVtxosHex?.length) return false; + try { + return outputVtxosHex.every((b) => this._vtxo(decodeVtxo(hex.decode(b)).id)); + } catch { return false; } + } + async rescueParticipation(unlockHashHex, inputVtxoIds) { 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'); } + if (this._participationClaimed(unlockHashHex)) throw new Error('this participation was already claimed'); // 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); @@ -2386,6 +2416,7 @@ export class ArkManager { if (missing.length) throw new Error('input records not in this wallet: ' + missing.join(', ')); const outs = status.outputVtxos.map((b) => decodeVtxo(b)); if (!outs.length) throw new Error('participation has no outputs'); + if (outs.every((v) => this._vtxo(v.id))) throw new Error('this participation was already claimed'); const want = new Set(outs.map((v) => v.policy.userPubkey)); let outKeyIndex = null; // The index was minted by the lost action, so scan a window past the counter. |
