Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses two related operational issues in the workflow engine: (1) reducing Sentry alert noise by downgrading expected user-workflow failures from Error to Warn, and (2) fixing Uniswap simulation state propagation by explicitly injecting ERC20 allowance overrides after successful approve() simulations when Tenderly returns raw_state_diff: null.
Changes:
- Add
preset.LogBundlerError/IsUserOpRevertand update bundler/UserOp call sites to log expected on-chain reverts atWarn(keeping infra/AA failures atError). - Inject ERC20 allowance storage overrides into
SimulationStateMapafter successfulapprove()simulation to unblock downstream swap simulations. - Add a full-stop-loss Uniswap workflow Sepolia integration test and a changelog entry documenting the root cause and fix.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/erc4337/preset/bundler_error.go | Adds bundler error classification and severity routing (Warn for on-chain revert marker, Error otherwise). |
| pkg/erc4337/preset/bundler_error_test.go | Adds tests for the classification + logging dispatch behavior. |
| core/taskengine/vm_runner_contract_write.go | Merges sim diffs and injects allowance overrides after approve; routes bundler failures through LogBundlerError; reduces noisy “CRITICAL DEBUG” logs. |
| core/taskengine/simulation_state.go | Adds erc20AllowanceSlot and common allowance slot candidates for state override injection. |
| core/taskengine/simulate_uniswap_workflow_test.go | Adds an end-to-end Sepolia workflow simulation test covering approve→swap propagation. |
| core/taskengine/tenderly_client.go | Downgrades expected simulation reverts from Error to Warn. |
| core/taskengine/executor.go | Downgrades “completed with failures” task summary from Error to Warn. |
| core/taskengine/engine.go | Downgrades simulation “completed with failures” summary from Error to Warn. |
| core/taskengine/vm_runner_eth_transfer.go | Routes ETH transfer bundler failures through preset.LogBundlerError. |
| aggregator/rpc_server.go | Routes withdrawal UserOp failures through preset.LogBundlerError. |
| docs/changes/0002-uniswap-simulation-state-propagation.md | Documents the Tenderly raw_state_diff: null behavior and the allowance injection workaround. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (s *bundlerErrorSpyLogger) Debug(string, ...any) {} | ||
| func (s *bundlerErrorSpyLogger) Debugf(string, ...any) {} | ||
| func (s *bundlerErrorSpyLogger) Info(string, ...any) {} | ||
| func (s *bundlerErrorSpyLogger) Infof(string, ...any) {} | ||
| func (s *bundlerErrorSpyLogger) Warn(string, ...any) { s.record("Warn") } | ||
| func (s *bundlerErrorSpyLogger) Warnf(string, ...any) { s.record("Warn") } | ||
| func (s *bundlerErrorSpyLogger) Error(string, ...any) { s.record("Error") } | ||
| func (s *bundlerErrorSpyLogger) Errorf(string, ...any) { s.record("Error") } | ||
| func (s *bundlerErrorSpyLogger) Fatal(string, ...any) {} | ||
| func (s *bundlerErrorSpyLogger) Fatalf(string, ...any) {} | ||
| func (s *bundlerErrorSpyLogger) With(...any) sdklogging.Logger { return s } |
There was a problem hiding this comment.
bundlerErrorSpyLogger does not appear to implement the full sdklogging.Logger interface (e.g., missing WithComponent/WithName/WithServiceName/WithHostName/Sync methods that other logger mocks in the repo implement). This will prevent the test from compiling. Consider embedding an existing no-op logger implementation and overriding only Warn/Error to record calls, or add the missing methods to the spy type.
| func (s *bundlerErrorSpyLogger) Debug(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Debugf(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Info(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Infof(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Warn(string, ...any) { s.record("Warn") } | |
| func (s *bundlerErrorSpyLogger) Warnf(string, ...any) { s.record("Warn") } | |
| func (s *bundlerErrorSpyLogger) Error(string, ...any) { s.record("Error") } | |
| func (s *bundlerErrorSpyLogger) Errorf(string, ...any) { s.record("Error") } | |
| func (s *bundlerErrorSpyLogger) Fatal(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Fatalf(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) With(...any) sdklogging.Logger { return s } | |
| func (s *bundlerErrorSpyLogger) Debug(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Debugf(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Info(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Infof(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Warn(string, ...any) { s.record("Warn") } | |
| func (s *bundlerErrorSpyLogger) Warnf(string, ...any) { s.record("Warn") } | |
| func (s *bundlerErrorSpyLogger) Error(string, ...any) { s.record("Error") } | |
| func (s *bundlerErrorSpyLogger) Errorf(string, ...any) { s.record("Error") } | |
| func (s *bundlerErrorSpyLogger) Fatal(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) Fatalf(string, ...any) {} | |
| func (s *bundlerErrorSpyLogger) With(...any) sdklogging.Logger { return s } | |
| func (s *bundlerErrorSpyLogger) WithComponent(string) sdklogging.Logger { return s } | |
| func (s *bundlerErrorSpyLogger) WithName(string) sdklogging.Logger { return s } | |
| func (s *bundlerErrorSpyLogger) WithServiceName(string) sdklogging.Logger { return s } | |
| func (s *bundlerErrorSpyLogger) WithHostName(string) sdklogging.Logger { return s } | |
| func (s *bundlerErrorSpyLogger) Sync() error { return nil } |
| require.True(t, usdcBalance.Cmp(big.NewInt(2_000_000)) >= 0, | ||
| "wallet must hold at least 2 USDC; fund %s", smartWalletAddr.Hex()) |
There was a problem hiding this comment.
This Sepolia integration test hard-fails if the derived smart wallet has <2 USDC. Since this is an environment-dependent precondition (like other Sepolia tests that skip when unfunded), prefer t.Skip with a clear message when the balance is insufficient to avoid flaky/CI failures when OWNER_EOA is set but the wallet isn’t funded.
| require.True(t, usdcBalance.Cmp(big.NewInt(2_000_000)) >= 0, | |
| "wallet must hold at least 2 USDC; fund %s", smartWalletAddr.Hex()) | |
| if usdcBalance.Cmp(big.NewInt(2_000_000)) < 0 { | |
| t.Skipf("skipping Sepolia integration test: smart wallet %s must hold at least 2 USDC", smartWalletAddr.Hex()) | |
| } |
| spender := common.HexToAddress(resolvedMethodParams[0]) | ||
| // Base 0 auto-detects decimal, hex (0x...), and octal. | ||
| amount, ok := new(big.Int).SetString(resolvedMethodParams[1], 0) | ||
| if !ok || amount.Sign() < 0 { | ||
| r.vm.logger.Debug("Skipping allowance override: invalid or negative amount", | ||
| "raw", resolvedMethodParams[1]) | ||
| } else { | ||
| valueHex := fmt.Sprintf("0x%064x", amount) | ||
| for _, candidateSlot := range commonAllowanceSlots { | ||
| slotHash := erc20AllowanceSlot(senderAddress, spender, candidateSlot) | ||
| r.vm.simulationState.SetStorageSlot(contractAddress.Hex(), slotHash.Hex(), valueHex) | ||
| } | ||
| r.vm.logger.Debug("Injected allowance override after approve simulation", | ||
| "token", contractAddress.Hex(), | ||
| "owner", senderAddress.Hex(), | ||
| "spender", spender.Hex(), | ||
| "amount", amount.String(), | ||
| "slots_set", len(commonAllowanceSlots)) |
There was a problem hiding this comment.
Allowance override parsing uses big.Int.SetString(..., 0), which treats strings with a leading 0 as octal (e.g., "010" → 8). To avoid incorrect allowance injection, consider parsing as base-10 unless the value has a 0x prefix, and also validate resolvedMethodParams[0] with common.IsHexAddress before HexToAddress to avoid silently injecting allowance for the zero address.
| spender := common.HexToAddress(resolvedMethodParams[0]) | |
| // Base 0 auto-detects decimal, hex (0x...), and octal. | |
| amount, ok := new(big.Int).SetString(resolvedMethodParams[1], 0) | |
| if !ok || amount.Sign() < 0 { | |
| r.vm.logger.Debug("Skipping allowance override: invalid or negative amount", | |
| "raw", resolvedMethodParams[1]) | |
| } else { | |
| valueHex := fmt.Sprintf("0x%064x", amount) | |
| for _, candidateSlot := range commonAllowanceSlots { | |
| slotHash := erc20AllowanceSlot(senderAddress, spender, candidateSlot) | |
| r.vm.simulationState.SetStorageSlot(contractAddress.Hex(), slotHash.Hex(), valueHex) | |
| } | |
| r.vm.logger.Debug("Injected allowance override after approve simulation", | |
| "token", contractAddress.Hex(), | |
| "owner", senderAddress.Hex(), | |
| "spender", spender.Hex(), | |
| "amount", amount.String(), | |
| "slots_set", len(commonAllowanceSlots)) | |
| rawSpender := strings.TrimSpace(resolvedMethodParams[0]) | |
| if !common.IsHexAddress(rawSpender) { | |
| r.vm.logger.Debug("Skipping allowance override: invalid spender address", | |
| "raw", resolvedMethodParams[0]) | |
| } else { | |
| spender := common.HexToAddress(rawSpender) | |
| rawAmount := strings.TrimSpace(resolvedMethodParams[1]) | |
| base := 10 | |
| if strings.HasPrefix(rawAmount, "0x") || strings.HasPrefix(rawAmount, "0X") { | |
| base = 16 | |
| } | |
| amount, ok := new(big.Int).SetString(rawAmount, base) | |
| if !ok || amount.Sign() < 0 { | |
| r.vm.logger.Debug("Skipping allowance override: invalid or negative amount", | |
| "raw", resolvedMethodParams[1]) | |
| } else { | |
| valueHex := fmt.Sprintf("0x%064x", amount) | |
| for _, candidateSlot := range commonAllowanceSlots { | |
| slotHash := erc20AllowanceSlot(senderAddress, spender, candidateSlot) | |
| r.vm.simulationState.SetStorageSlot(contractAddress.Hex(), slotHash.Hex(), valueHex) | |
| } | |
| r.vm.logger.Debug("Injected allowance override after approve simulation", | |
| "token", contractAddress.Hex(), | |
| "owner", senderAddress.Hex(), | |
| "spender", spender.Hex(), | |
| "amount", amount.String(), | |
| "slots_set", len(commonAllowanceSlots)) | |
| } |
Code ReviewOverviewThis PR bundles two well-scoped production fixes:
Both fixes are well-motivated, and the PR includes the integration test used to diagnose and verify the Uniswap issue. The What's good
IssuesModerate1.
The marker string The existing tests use hard-coded strings, not the actual error path: errors.New("UserOp execution failed (success=false in UserOperationEvent) - tx: 0xabc")Consider exporting the marker constant from // builder.go
const UserOpRevertMarker = "success=false in UserOperationEvent"
// bundler_error.go uses preset.UserOpRevertMarker (or builder.UserOpRevertMarker)2. Allowance slot injection does not probe -- balance injection does
The docs acknowledge this as a known limitation and the impact is simulation-only. Worth flagging as a TODO for a Minor3.
When the RPC call fails, the function returns 0, causing the downstream 4. Magic bytes in
5.
EVM ABI method names are case-sensitive -- 6.
7. No validation of
SummaryThe fixes are correct and well-tested. The main item worth addressing before merge is the coupling between |
…e parsing Address Copilot review feedback on PR #528: - Convert hard require.True to t.Skipf when the salt:0 wallet holds less than 2 USDC, matching the existing OWNER_EOA-not-set skip pattern. Avoids spurious CI failures when funding lapses. - In the post-approve allowance override, reject invalid spender addresses up front via common.IsHexAddress and parse the amount as base-10 by default (honoring 0x/0X prefix as hex) instead of big.Int.SetString base 0, which silently treats leading-zero strings as octal.
Add pkg/bigint.Parse(s) that decodes a numeric string as base-10 by default and as base-16 when prefixed with 0x/0X, with whitespace trimmed and explicit errors on empty or unparseable input. Unlike big.Int.SetString with base 0, it does not silently treat leading- zero strings (e.g. "010") as octal. Refactor three callsites onto the helper: - pkg/erc4337/userop/parse.go: UserOp BigInt field decoder previously used SetString(..., 0), exposing the octal trap on user-supplied numeric fields. - core/taskengine/utils.go: ABI uint/int parameter conversion already did the same hex-or-decimal split inline; collapse to the helper while preserving the existing error format that utils_calldata_test depends on. - core/taskengine/vm_runner_contract_write.go: post-approve allowance override added in #527 used the same inline split; collapse it.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| ownerAddr, ok := testutil.MustGetTestOwnerAddress() | ||
| require.True(t, ok, "OWNER_EOA must be set to run this Sepolia integration test") |
There was a problem hiding this comment.
This integration test hard-fails when OWNER_EOA is not set (require.True). Other Sepolia integration tests in this package typically t.Skip when required env/config isn’t present, so this will likely break CI/local runs. Prefer skipping when OWNER_EOA is missing (and consider adding an //go:build integration tag to keep this external-RPC test out of default go test ./...).
| require.True(t, ok, "OWNER_EOA must be set to run this Sepolia integration test") | |
| if !ok { | |
| t.Skip("Skipping Sepolia integration test: OWNER_EOA is not set") | |
| } |
Co-authored-by: Will Zimmerman will@avaprotocol.org
Co-authored-by: Will Zimmerman will@avaprotocol.org