diff options
| -rw-r--r-- | src/ark/manager.js | 33 | ||||
| -rw-r--r-- | tools/ark/rescue-claimed-test.js | 57 |
2 files changed, 89 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. diff --git a/tools/ark/rescue-claimed-test.js b/tools/ark/rescue-claimed-test.js new file mode 100644 index 0000000..7d3c68b --- /dev/null +++ b/tools/ark/rescue-claimed-test.js @@ -0,0 +1,57 @@ +// A finished renewal adopted a second time — its done action pruned, then the +// round's mailbox breadcrumb read again — must not sit "Renewal in progress" +// forever. The issued step retires it without a row (its first claim has one), +// and neither the breadcrumb nor rescueParticipation adopts a claimed round. +// Run: bun tools/ark/rescue-claimed-test.js +import assert from 'node:assert/strict'; +import { mock } from 'bun:test'; + +const refresh = await import('../../src/ark/refresh.js'); +let claims = 0, statusAsks = 0; +mock.module('../../src/ark/refresh.js', () => ({ + ...refresh, + requestForfeitNonces: async () => { claims++; throw new Error('network'); }, + roundParticipationStatus: async () => { statusAsks++; throw new Error('network'); }, +})); +const { ArkManager } = await import('../../src/ark/manager.js'); +const mgr = new ArkManager({ storage: { save() {} }, arkUrl: 'http://test.invalid' }); +mgr._refreshOutputs = () => []; +mgr._key = () => ({}); +mgr._decoded = () => ({ point: { raw: new Uint8Array(36) } }); +// version 2, one input, one 1000-sat OP_TRUE output +const rawTx = '02000000' + '01' + '11'.repeat(32) + '00000000' + '00' + 'ffffffff' + '01' + 'e803000000000000' + '0151' + '00000000'; +const U = 'a6'.repeat(32); +const fresh = () => ({ + id: 'refresh-rescue-1', type: 'refresh', step: 'issued', inputIds: ['a:0', 'b:0'], outKeyIndex: 3, + outAmountSat: 900, feeSat: 2, unlockHash: U, fundingTxHex: rawTx, outputVtxos: [], +}); +// input b:0 has lost its bytes to pruning, as on Adam's wallet +const coins = () => [{ id: 'a:0', amountSat: 500, state: 'spent', bytes: 'AA==' }, { id: 'b:0', amountSat: 402, state: 'spent' }]; + +{ + const action = fresh(); + mgr.state = { nextKeyIndex: 4, actions: [action], vtxos: coins(), + movements: [{ id: 'm1', type: 'refresh', status: 'complete', amountSat: 900, unlockHash: U, inputIds: ['a:0', 'b:0'] }] }; + await mgr._driveRefresh(action); + assert.equal(action.step, 'failed'); + assert.equal(action.superseded, true, 'retired without a history row'); + assert.equal(claims, 0, 'never asks the server to claim again'); + assert.equal(mgr.state.movements.length, 1, 'no second "Renewed" row'); + console.log('✓ A second adoption of a finished renewal retires on the next sync'); +} + +{ + await assert.rejects(mgr.rescueParticipation(U), /already claimed/); + assert.equal(statusAsks, 0, 'refused before asking the server'); + console.log('✓ rescueParticipation refuses a round the history already shows as renewed'); +} + +{ + const action = fresh(); + mgr.state = { nextKeyIndex: 4, actions: [action], vtxos: coins(), movements: [] }; + await assert.rejects(mgr._driveRefresh(action), /network/); + assert.equal(claims, 1, 'an unclaimed round still goes on to claim'); + assert.equal(action.step, 'issued'); + console.log('✓ A round with no claim on record still drives its claim'); +} +console.log('\n✅ claimed-round rescue passes'); |
