Skip to content

Commit b724f1e

Browse files
chrisli30will-dz
andauthored
fix: nonce cache desync recovery and Loop partial-success preservation (#512)
Co-authored-by: Will Zimmerman <will@avaprotocol.org>
1 parent f91ff27 commit b724f1e

4 files changed

Lines changed: 81 additions & 18 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ The `getWorkflow` output is the ground truth — it shows the trigger's `topics`
109109
- All feature branches and PRs must target `staging`, never `main` directly.
110110
- The `main` branch is updated only by merging `staging``main` after migration checks pass.
111111
- Before merging to `main`, run `go run scripts/compare_storage_structure.go main` to check for breaking storage changes.
112+
- **PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/) / semantic-release format** (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `ci:`, `perf:`, `BREAKING CHANGE:` footer for majors). Squash merges use the PR title as the commit subject, and `.github/workflows/release-on-pr-close.yml` runs `go-semantic-release` against it to compute the next version. A non-conforming title (e.g. `release: staging → main ...`) yields no version bump and no release. For staging→main release PRs, pick the highest-impact prefix across the bundled changes (e.g. `feat:` if any feature is included).
112113

113114
## Development Guidelines
114115

core/taskengine/manual_trigger_loop_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,13 @@ func TestLoopNode_ContractWrite_InvalidAddress_PartialFailure(t *testing.T) {
427427

428428
step, err := vm.RunNodeWithInputs(node, inputVariables)
429429

430-
// The loop should report failure because the second iteration failed
430+
// Per AvaProtocol/EigenLayer-AVS#511, per-iteration runner failures do not
431+
// fail the loop step. The loop ran to completion, so step.Success is true
432+
// and the per-iteration outcomes are reflected in the data array (nil for
433+
// failed iterations).
431434
require.NotNil(t, step, "Execution step should not be nil")
432-
assert.False(t, step.Success, "Loop should report failure when an iteration fails")
433-
assert.Contains(t, step.Error, "invalid address", "Error should mention invalid address")
435+
assert.True(t, step.Success, "Loop step should succeed when it ran to completion, even if an iteration failed")
436+
assert.Empty(t, step.Error, "Loop step error should be empty when loop ran to completion")
434437

435438
// The output should still contain results (first iteration data, second nil)
436439
loopOutput := step.GetLoop()

core/taskengine/vm.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4653,6 +4653,7 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no
46534653
results := make([]interface{}, len(inputArray))
46544654
iterationSteps := make([]*avsproto.Execution_Step, 0, len(inputArray)) // Collect iteration steps for gas aggregation
46554655
success := true
4656+
infraFailure := false
46564657
var firstError error
46574658

46584659
if concurrent {
@@ -4701,6 +4702,7 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no
47014702
if err := eq.Submit(task); err != nil {
47024703
log.WriteString(fmt.Sprintf("\nError submitting iteration %d: %s", iterationIndex, err.Error()))
47034704
success = false
4705+
infraFailure = true
47044706
if firstError == nil {
47054707
firstError = err
47064708
}
@@ -4713,11 +4715,13 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no
47134715
select {
47144716
case result := <-resultChannel:
47154717
if result.Error != nil {
4716-
success = false
47174718
if firstError == nil {
47184719
firstError = result.Error
47194720
}
47204721
log.WriteString(fmt.Sprintf("\nError in iteration %d: %s", i, result.Error.Error()))
4722+
// Per-iteration runner failure: leave results[i] = nil as a
4723+
// partial-success placeholder. Do NOT mark the loop as failed —
4724+
// the per-iteration outcome is reflected in the data array.
47214725
} else {
47224726
results[i] = result.Data
47234727
}
@@ -4726,6 +4730,7 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no
47264730
}
47274731
case <-time.After(iterationTimeout):
47284732
success = false
4733+
infraFailure = true
47294734
err := fmt.Errorf("iteration %d timed out after %s", i, iterationTimeout)
47304735
if firstError == nil {
47314736
firstError = err
@@ -4761,6 +4766,7 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no
47614766

47624767
if err := eq.Submit(task); err != nil {
47634768
success = false
4769+
infraFailure = true
47644770
if firstError == nil {
47654771
firstError = err
47664772
}
@@ -4772,11 +4778,12 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no
47724778
select {
47734779
case result := <-resultChannel:
47744780
if result.Error != nil {
4775-
success = false
47764781
if firstError == nil {
47774782
firstError = result.Error
47784783
}
47794784
log.WriteString(fmt.Sprintf("\nError in iteration %d: %s", i, result.Error.Error()))
4785+
// Per-iteration runner failure: leave results[i] = nil as a
4786+
// partial-success placeholder. Do NOT mark the loop as failed.
47804787
} else {
47814788
results[i] = result.Data
47824789
}
@@ -4785,6 +4792,7 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no
47854792
}
47864793
case <-time.After(iterationTimeout):
47874794
success = false
4795+
infraFailure = true
47884796
err := fmt.Errorf("iteration %d timed out after %s", i, iterationTimeout)
47894797
if firstError == nil {
47904798
firstError = err
@@ -4831,12 +4839,18 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no
48314839
Loop: loopOutput,
48324840
}
48334841

4834-
if !success && firstError != nil {
4842+
// Only treat infrastructure failures (queue submit errors, iteration timeouts)
4843+
// as a hard step failure. Per-iteration runner errors (e.g. a contract call
4844+
// reverting in one iteration of a Loop > ContractRead) are reflected as nil
4845+
// entries in the results array — the loop ran to completion, so we preserve
4846+
// OutputData and return success so the client can inspect partial results.
4847+
// See AvaProtocol/EigenLayer-AVS#511.
4848+
if infraFailure && firstError != nil {
48354849
finalizeStep(s, false, nil, firstError.Error(), log.String())
48364850
return s, firstError
48374851
}
48384852

4839-
finalizeStep(s, true, nil, "", log.String())
4853+
finalizeStep(s, success, nil, "", log.String())
48404854
return s, nil
48414855
}
48424856

pkg/erc4337/preset/builder.go

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,17 +1155,62 @@ func sendUserOpCore(
11551155
// On-chain or cache has advanced past our nonce — use the new value
11561156
l.Debug("Nonce advanced", "old_nonce", userOp.Nonce.String(), "new_nonce", freshNonce.String())
11571157
} else if onChainNonce.Cmp(userOp.Nonce) < 0 {
1158-
// On-chain nonce is behind our attempted nonce. Prior UserOps (at lower
1159-
// nonces) haven't mined yet, causing the bundler to reject ours with AA25.
1160-
// The nonce itself is correct — we just need to wait for prior UserOps to
1161-
// mine. Do NOT increment, as that creates an unfillable nonce gap.
1162-
freshNonce = new(big.Int).Set(userOp.Nonce)
1163-
l.Debug("On-chain nonce behind, waiting for prior UserOps to mine before retry",
1164-
"on_chain_nonce", onChainNonce.String(),
1165-
"userOp_nonce", userOp.Nonce.String())
1166-
// Wait briefly for prior UserOps to mine; without this delay
1167-
// the retry loop burns through attempts in milliseconds.
1168-
time.Sleep(2 * time.Second)
1158+
// On-chain nonce is behind our attempted nonce. Two possibilities:
1159+
// (a) Prior UserOps at lower nonces are genuinely pending in the bundler
1160+
// mempool and just haven't mined yet → wait for them.
1161+
// (b) The cache is stale: a previous UserOp was dropped from the bundler
1162+
// mempool without the cache being invalidated, so there is nothing
1163+
// at on_chain..userOp.Nonce-1 to ever mine. Waiting is futile and
1164+
// leads to the stuck loop described in issue #510.
1165+
// Distinguish the two by inspecting the bundler mempool. If no predecessor
1166+
// UserOp exists for this sender at a nonce in [on_chain, userOp.Nonce),
1167+
// then case (b) holds — rewind the cache to the on-chain nonce.
1168+
// Bound the mempool RPC so a hung bundler can't stall the retry loop.
1169+
mempoolCtx, cancelMempool := context.WithTimeout(context.Background(), 5*time.Second)
1170+
pendingOps, mempoolErr := bundlerClient.GetPendingUserOpsForSender(mempoolCtx, entrypoint, userOp.Sender)
1171+
cancelMempool()
1172+
1173+
predecessorPending := false
1174+
if mempoolErr == nil {
1175+
for _, op := range pendingOps {
1176+
// EIP-4337 bundlers return nonces as "0x..." hex strings.
1177+
opNonce := new(big.Int)
1178+
nonceStr := strings.TrimPrefix(op.Nonce, "0x")
1179+
if _, ok := opNonce.SetString(nonceStr, 16); !ok {
1180+
l.Debug("Failed to parse pending op nonce, skipping",
1181+
"nonce", op.Nonce, "sender", userOp.Sender.Hex())
1182+
continue
1183+
}
1184+
if opNonce.Cmp(onChainNonce) >= 0 && opNonce.Cmp(userOp.Nonce) < 0 {
1185+
predecessorPending = true
1186+
break
1187+
}
1188+
}
1189+
} else {
1190+
l.Warn("Failed to inspect bundler mempool while diagnosing nonce conflict; assuming predecessor pending",
1191+
"error", mempoolErr)
1192+
predecessorPending = true
1193+
}
1194+
1195+
if !predecessorPending {
1196+
// Stale cache: nothing in the mempool can ever mine to bridge the gap.
1197+
// Rewind to the on-chain nonce so the rebuild uses a value the
1198+
// EntryPoint will accept.
1199+
l.Warn("Nonce cache desync detected: no predecessor UserOp pending in bundler mempool, rewinding cache to on-chain nonce",
1200+
"sender", userOp.Sender.Hex(),
1201+
"on_chain_nonce", onChainNonce.String(),
1202+
"stale_cached_nonce", userOp.Nonce.String())
1203+
globalNonceManager.ResetNonce(userOp.Sender)
1204+
freshNonce = new(big.Int).Set(onChainNonce)
1205+
} else {
1206+
freshNonce = new(big.Int).Set(userOp.Nonce)
1207+
l.Debug("On-chain nonce behind, waiting for prior UserOps to mine before retry",
1208+
"on_chain_nonce", onChainNonce.String(),
1209+
"userOp_nonce", userOp.Nonce.String())
1210+
// Wait briefly for prior UserOps to mine; without this delay
1211+
// the retry loop burns through attempts in milliseconds.
1212+
time.Sleep(2 * time.Second)
1213+
}
11691214
} else {
11701215
// GetNextNonce returned the same nonce we already tried and on-chain has
11711216
// reached this nonce. A UserOp is pending at this nonce in the mempool.

0 commit comments

Comments
 (0)