Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions packages/solver-corridors/src/receive/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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
}

Expand Down
31 changes: 31 additions & 0 deletions test/receive/orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading