Skip to content

pm: Defer ticket redemption when the sender cannot cover the face value - #4012

Closed
rickstaa wants to merge 1 commit into
masterfrom
fix/ticket-redemption-retry
Closed

pm: Defer ticket redemption when the sender cannot cover the face value#4012
rickstaa wants to merge 1 commit into
masterfrom
fix/ticket-redemption-retry

Conversation

@rickstaa

@rickstaa rickstaa commented Aug 5, 2026

Copy link
Copy Markdown
Member

Context

livepeer/protocol#657 patched the TicketBroker griefing issue disclosed via Immunefi. redeemWinningTicket now requires the sender's deposit + reserve to cover the full ticket face value; below that it reverts with sender deposit and reserve insufficient to cover ticket face value and leaves the ticket unused. Previously the ticket was consumed first and the transcoder could be paid dust.

This is the go-livepeer side of Sidestream's follow-up: "we recommend updating offchain implementation to retry failed tickets in the next rounds" and "double-check that no known offchain implementation relies on this specific error message."

On the second point: nothing does. No go-livepeer code matches the old "sender deposit and reserve are zero" string. The lookalike at cmd/livepeer_cli/wizard_ticketbroker.go:36 is a locally-generated CLI status, not a revert match.

What was actually wrong

Redemption is driven by ticketQueue.handleBlockEvent, which fires on every new L1 block (~12s) and, on a retryable failure, leaves the ticket queued for the next one. So retries already existed. Three things were wrong around them:

  1. No client-side face-value precondition. go-livepeer kept building redemptions that cannot succeed. On the default config (-gasLimit 0) these die in EstimateGas, so they cost RPC round-trips rather than gas — but they cost them on every block, for as long as the sender is underfunded.

  2. The head of the queue was re-attempted N times per block. Each loop iteration re-selects the earliest unredeemed ticket, so a retryable failure re-selects the same ticket on every remaining iteration — N attempts per block, where N is that sender's queue length.

  3. A reverted redemption dropped the ticket. CheckTx (eth/client.go) returns only "transaction failed txHash=..." with no revert reason, and isNonRetryableTicketErr matched that string to mark the ticket redeemed locally. That was correct before the patch — the ticket really was consumed. It is now a regression: a redemption that reverts leaves the ticket redeemable on-chain, and go-livepeer would forfeit it. Reachable when -gasLimit is set explicitly (skipping EstimateGas) or when the sender is drained between estimate and mining — the exact race the patch was written for.

The fix

Gate before the RPCs, don't back off. availableFunds is already served from the sender watcher's in-memory cache, which the block watcher keeps current from DepositFunded/ReserveFunded. So the check costs a big.Int compare, and the existing 12s cadence becomes the mechanism rather than the problem:

  • an underfunded sender costs one comparison per block, no RPC, no transaction
  • a top-up is picked up on the next block (≤~12s), across the full validity window

Exponential backoff would have optimized attempt count — the wrong axis once the failure is free — while risking sleeping through the top-up or the ticket's expiry.

Supporting changes: stop the loop instead of re-selecting a blocked head-of-queue ticket; log the underfunded case at V(5) rather than Errorf, since at one line per ticket per block it would otherwise flood the log; and consult IsUsedTicket before marking a ticket redeemed after a failed CheckTx, so the contract decides whether a ticket is spent rather than an opaque error string.

Net per sender per block: one comparison and one V-line, versus N × (2 RPCs + an error line).

Tests

  • errInsufficientSenderFunds is returned before any RPC — asserted by making IsUsedTicket and SuggestGasPrice fail distinguishably and observing that neither error surfaces
  • a deferred ticket redeems once the sender tops up, with no trigger beyond the next call
  • a reverted redemption that left the ticket unused is retryable; one that consumed it is not
  • one redemption attempt per block while the head of the queue is blocked, with nothing dropped
  • table-driven coverage of isNonRetryableTicketErr, including wrapped errors

All three behavioural changes were mutation-tested — reverting each one individually makes the corresponding test fail.

go test ./pm/... ./server/ ./eth/... passes; the new tests also pass under -race.

Note for reviewers

The pm package has pre-existing -race failures on master (TestWatchPoolSizeChange, TestTicketQueueLoop, TestTicketQueueLoop_IsNonRetryableTicketErr_MarkAsRedeemed) caused by non-race-safe test stubs. Untouched here; flagging so they aren't mistaken for regressions.

🤖 Generated with Claude Code

livepeer/protocol#657 changed TicketBroker.redeemWinningTicket to require the
sender's deposit and reserve to cover the full ticket face value. A redemption
below it now reverts and leaves the ticket unused, where it previously consumed
the ticket and paid out whatever remained (potentially dust).

go-livepeer had no matching precondition, so it would keep building redemption
transactions that cannot succeed. Mirror the broker's requirement before any RPC
round-trip: availableFunds is served from the sender watcher cache, which the
block watcher keeps current from DepositFunded and ReserveFunded, so a top-up is
picked up on the next L1 block without a doomed transaction in between. The
queue re-evaluates on every block, so a deferred ticket is redeemed as soon as
the sender funds it, for as long as the ticket remains valid.

Treat the underfunded case as an expected state rather than a failure: it is
retryable and logged at V(5), since at one line per ticket per block it would
otherwise flood the log for as long as a sender stays underfunded.

Stop re-attempting the head of the queue within a single block event. Every
iteration re-selects the earliest unredeemed ticket, so any outcome that leaves
that ticket in place would re-attempt it once per queued ticket. The loop now
stops and waits for the next block.

Finally, do not infer that a ticket was consumed from an opaque error. CheckTx
reports only that a transaction failed, without a revert reason, and
isNonRetryableTicketErr matched that string to mark the ticket redeemed locally.
Now that a reverted redemption leaves the ticket redeemable, ask the broker
whether it was actually used before dropping it, so a still-valid ticket is not
forfeited by a redemption that raced the sender's balance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 5, 2026 12:16
@github-actions github-actions Bot added the go Pull requests that update Go code label Aug 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the probabilistic micropayments (pm) redemption flow to align with the TicketBroker behavior change from livepeer/protocol#657, ensuring tickets are deferred (not dropped) when the sender cannot cover full face value and reducing redundant per-block retry attempts.

Changes:

  • Add an explicit, retryable errInsufficientSenderFunds gate before RPC/tx submission, and treat reverted-but-unconsumed redemptions as retryable via an error wrapper.
  • Prevent N-attempts-per-block spinning on a blocked head-of-queue ticket by stopping the block handler loop early on retryable outcomes.
  • Add/extend tests covering the new retry/defer semantics and queue behavior; update pending changelog.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pm/validator.go Introduces errInsufficientSenderFunds sentinel and unconsumedRedemptionErr wrapper type.
pm/stub.go Extends stub broker to model reverts that do not consume tickets.
pm/sendermonitor.go Adds pre-RPC face-value funding gate and consults IsUsedTicket after CheckTx failure to avoid dropping unconsumed tickets.
pm/sendermonitor_test.go Adds tests for pre-RPC gating, retryability after revert, top-up redemption, and isNonRetryableTicketErr cases.
pm/queue.go Stops per-block loop on retryable head-of-queue failures; logs insufficient-funds deferrals at V(5); updates retryability logic.
pm/queue_test.go Adds test ensuring only one redemption attempt per block when head-of-queue is retry-blocked.
CHANGELOG_PENDING.md Adds orchestrator-facing changelog entry describing the new deferral behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pm/sendermonitor.go
Comment on lines +440 to +446
// CheckTx only reports that the transaction failed, without a revert reason, so
// ask the broker whether the ticket was actually consumed rather than assuming
// it was. Since livepeer/protocol#657 a redemption that reverts leaves the
// ticket unused and still redeemable, and dropping it here would forfeit it.
if used, usedErr := sm.broker.IsUsedTicket(ticket.Ticket); usedErr == nil && !used {
return tx, unconsumedRedemptionErr{err}
}
@rickstaa

rickstaa commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Closing: the framing here was wrong. go-livepeer already retries an underfunded sender — EstimateGas catches the new revert pre-flight, so no tx is submitted and the ticket stays queued. The face-value gate in this PR was an optimisation, not a fix, and carries a real risk of over-deferring since availableFunds subtracts in-flight pendingAmount.

The one genuine defect (a reverted redemption being dropped locally) plus the efficiency items are tracked in #4013.

@rickstaa rickstaa closed this Aug 5, 2026
@rickstaa
rickstaa deleted the fix/ticket-redemption-retry branch August 5, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go Pull requests that update Go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants