diff --git a/packages/solver-corridors/src/receive/orchestrator.ts b/packages/solver-corridors/src/receive/orchestrator.ts index 806f8360..2c34a997 100644 --- a/packages/solver-corridors/src/receive/orchestrator.ts +++ b/packages/solver-corridors/src/receive/orchestrator.ts @@ -1073,8 +1073,15 @@ export class ReceiveSwapService { const { store, arkade } = this.deps const observeStarted = performance.now() const outputs = await arkade.findLockups(row.pkScript) + if (row.arkadeLockupTxid === null || row.arkadeLockupVout === null) { + await store.fail(row.id, 'funded', 'funded state with no funded outpoint recorded') + return false + } + // Gate on the RECORDED funded output, not an empty script: dust on the public + // lockup address must not defer claim detection to the refund deadline. + const fundedSpendable = outputs.some((o) => o.txid === row.arkadeLockupTxid && o.vout === row.arkadeLockupVout) - if (outputs.length > 0) { + if (fundedSpendable) { // The refund-deadline check comes BEFORE the reveal attempt, not after: // revealToCovclaimd can throw (covclaimd unreachable), and if that check // ran second it would never be reached while covclaimd stays down — @@ -1094,14 +1101,6 @@ export class ReceiveSwapService { return false } - // Nothing spendable left — most likely covclaimd's claim landed. - if (row.arkadeLockupTxid === null || row.arkadeLockupVout === null) { - // Unreachable by construction: `funded` is only ever entered with these - // set (whenArmed polls until it finds them). Guarded so a bad edit - // fails loudly rather than reading null silently. - await store.fail(row.id, 'funded', 'funded state with no funded outpoint recorded') - return false - } // ALL historical outpoints, not just the recorded one. The recorded // outpoint is this row's refund target, but it is not guaranteed to be the // one that got claimed — a row adopted out of a crashed funding attempt @@ -1283,7 +1282,10 @@ export class ReceiveSwapService { } const outputs = await arkade.findLockups(row.pkScript) - if (outputs.length === 0) { + // Keyed on the RECORDED funded output, not an empty script: dust must not be + // refunded into terminal `refunded` while a claim trails the `late` recheck. + const fundedOutput = outputs.find((o) => o.txid === row.arkadeLockupTxid && o.vout === row.arkadeLockupVout) + if (!fundedOutput) { // Our own refund, finalized (one-shot) or accepted before its response reached us. A throw keeps // the row refunding, so the sweep retries a finalize arkd refused. const own = await arkade.finalizePendingRefund?.(receiveCovenantRowFor(row)) @@ -1306,7 +1308,11 @@ export class ReceiveSwapService { if (row.htlcExpiresAt !== null && this.now() < row.htlcExpiresAt) return false // Still nothing, and nothing left to collect. A human needs to look, same // posture the onchain leg takes for its own ambiguous-witness case. - await store.fail(row.id, 'refunding', 'lockup empty during refunding with no matching claim found; needs review') + await store.fail( + row.id, + 'refunding', + 'funded output gone during refunding with no matching claim found; needs review', + ) return false } diff --git a/test/receive/orchestrator.test.ts b/test/receive/orchestrator.test.ts index e9111599..e6dbe584 100644 --- a/test/receive/orchestrator.test.ts +++ b/test/receive/orchestrator.test.ts @@ -1768,6 +1768,37 @@ describe('ReceiveSwapService.tick — claim detection and settlement', () => { expect(row.state).toBe('funded') }) + it('settles a claim even when a stray dust output still sits on the lockup script', async () => { + const funded = await armAndFund() + const row0 = await store.get(funded.id) + const recorded = { txid: row0.arkadeLockupTxid!, vout: row0.arkadeLockupVout! } + arkade.state.outputs = [{ txid: 'attacker-dust', vout: 7, value: 1 }] + arkade.state.spentOutputs = [{ ...recorded, value: row0.payoutSats }] + arkade.state.claimOn = recorded + arkade.state.claimPreimage = P + + const row = await service.tick(funded.id) + expect(row.state).toBe('settled') + expect(row.preimage).toBe(hex.encode(P)) + }) + + it('escalates rather than booking refunded when dust hides a funded output a claim already took', async () => { + const funded = await armAndFund() + const row0 = await store.get(funded.id) + const recorded = { txid: row0.arkadeLockupTxid!, vout: row0.arkadeLockupVout! } + arkade.state.outputs = [{ txid: 'attacker-dust', vout: 7, value: 1 }] + arkade.state.spentOutputs = [{ ...recorded, value: row0.payoutSats }] + arkade.state.claimPreimage = null // the claim's witness is not fetchable yet + now = funded.refundLocktime + + expect((await service.tick(funded.id)).state).toBe('refunding') + expect(arkade.state.refundCalls).toBe(0) + + arkade.state.claimOn = recorded + arkade.state.claimPreimage = P + expect((await service.tick(funded.id)).state).toBe('settled') + }) + /** * Places a row directly into `claimed` via the store, bypassing tick()'s * own chaining (which — correctly — would drive a `claimed` row with a