fix: ERC20 approve state propagation for Uniswap workflow simulation#527
Conversation
…te propagation Tenderly HTTP /simulate returns raw_state_diff: null for approve() calls, so MergeRawStateDiff was always a no-op for the approve step. Downstream nodes (e.g. Uniswap exactInputSingle) never saw the new allowance and failed with "ERC20: transfer amount exceeds allowance". After a successful approve() simulation, explicitly compute and set the allowance[owner][spender] storage slot in SimulationStateMap across common ERC20 slot indices (1, 2, 3, 10). This ensures the swap node receives the allowance via state_objects regardless of raw_state_diff. - simulation_state.go: add erc20AllowanceSlot() helper (double-keccak for nested mapping) and commonAllowanceSlots variable - vm_runner_contract_write.go: add isMethodWithParams() predicate, extract simSuccess guard, inject allowance slots post-approve - simulate_uniswap_workflow_test.go: integration test using salt:0 wallet with zero on-chain allowance to SwapRouter02, confirming approve state propagates to the swap node via SimulateTask - docs/changes/0002: changelog documenting root cause and fix
There was a problem hiding this comment.
Pull request overview
Fixes missing ERC20 allowance state propagation between sequential Tenderly simulations (approve → Uniswap swap) by explicitly injecting the allowance storage slot into the accumulated SimulationStateMap when Tenderly returns raw_state_diff: null for approve().
Changes:
- Add ERC20 allowance storage slot computation and a set of common allowance slot indices.
- Inject allowance overrides into
SimulationStateMapafter successfulapprove()simulations. - Add a Sepolia Uniswap stop-loss workflow integration test and a changelog entry documenting the root cause/fix.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| docs/changes/0002-uniswap-simulation-state-propagation.md | Documents the Tenderly raw_state_diff: null behavior and the explicit allowance injection workaround. |
| core/taskengine/vm_runner_contract_write.go | Adds simSuccess guard + approve()-specific post-simulation allowance injection into SimulationStateMap. |
| core/taskengine/simulation_state.go | Adds erc20AllowanceSlot() and commonAllowanceSlots to support allowance storage override injection. |
| core/taskengine/simulate_uniswap_workflow_test.go | Adds an end-to-end Sepolia workflow simulation test that exercises approve → quote → swap state propagation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| amount := new(big.Int) | ||
| amount.SetString(resolvedMethodParams[1], 10) | ||
| if amount.Sign() < 0 { | ||
| amount.SetString(resolvedMethodParams[1], 0) // try hex | ||
| } | ||
| 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.
amount.SetString return value is ignored, so parse failures (e.g. when the approve amount is provided as hex like 0x...) will silently leave amount at 0 and inject a zero allowance. Also, amount.Sign() < 0 doesn’t detect parse failures, so the "try hex" fallback won’t run when base-10 parsing fails.
Consider parsing once with base 0 (auto-detect 0x), checking the ok boolean, and skipping injection (or logging a warning) when parsing fails; also ensure the parsed value is non-negative.
| amount := new(big.Int) | |
| amount.SetString(resolvedMethodParams[1], 10) | |
| if amount.Sign() < 0 { | |
| amount.SetString(resolvedMethodParams[1], 0) // try hex | |
| } | |
| 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)) | |
| amount, ok := new(big.Int).SetString(resolvedMethodParams[1], 0) | |
| if !ok { | |
| r.vm.logger.Debug("Skipping allowance override after approve simulation due to invalid amount", | |
| "token", contractAddress.Hex(), | |
| "owner", senderAddress.Hex(), | |
| "spender", spender.Hex(), | |
| "amount_raw", resolvedMethodParams[1]) | |
| } else if amount.Sign() < 0 { | |
| r.vm.logger.Debug("Skipping allowance override after approve simulation due to negative amount", | |
| "token", contractAddress.Hex(), | |
| "owner", senderAddress.Hex(), | |
| "spender", spender.Hex(), | |
| "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)) | |
| } |
|
|
||
| ### Diagnosis path | ||
|
|
||
| 1. Wrote an integration test (`TestSimulateTask_UniswapApproveSwap_Sepolia`) using a salt:0 wallet with real Sepolia USDC balance but **zero** on-chain allowance to SwapRouter02. This made the test conclusive: swap can only succeed if the approve's allowance propagates. |
There was a problem hiding this comment.
The doc references an integration test named TestSimulateTask_UniswapApproveSwap_Sepolia, but the test added in this PR is TestSimulateTask_StopLossWorkflow_Sepolia (and there’s no TestSimulateTask_UniswapApproveSwap_Sepolia in core/taskengine). Please update this reference to match the actual test name so the diagnosis steps remain reproducible.
| 1. Wrote an integration test (`TestSimulateTask_UniswapApproveSwap_Sepolia`) using a salt:0 wallet with real Sepolia USDC balance but **zero** on-chain allowance to SwapRouter02. This made the test conclusive: swap can only succeed if the approve's allowance propagates. | |
| 1. Wrote an integration test (`TestSimulateTask_StopLossWorkflow_Sepolia`) using a salt:0 wallet with real Sepolia USDC balance but **zero** on-chain allowance to SwapRouter02. This made the test conclusive: swap can only succeed if the approve's allowance propagates. |
- Use base 0 (auto-detect decimal/hex) for approve amount parsing and skip allowance injection on parse failure or negative values instead of silently injecting zero - Fix stale test name reference in changelog doc
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.
Summary
Tenderly HTTP
/simulatereturnsraw_state_diff: nullforapprove()calls, soMergeRawStateDiffwas always a no-op for the approve step. Downstream nodes (e.g. UniswapexactInputSingle) never saw the new allowance and failed with "ERC20: transfer amount exceeds allowance."After a successful
approve()simulation, we now explicitly compute and set theallowance[owner][spender]storage slot inSimulationStateMapacross common ERC20 slot indices (1, 2, 3, 10 — covering OpenZeppelin, legacy, and USDC implementations).Changes
simulation_state.go: adderc20AllowanceSlot()(double-keccak for nested mapping) andcommonAllowanceSlotsvm_runner_contract_write.go: addisMethodWithParams()predicate, extractsimSuccessguard, inject allowance slots post-approvesimulate_uniswap_workflow_test.go: comprehensive integration test replicating the full Studio stop-loss-on-uniswap template (8 nodes: trigger, balance, code, branch, approve, contractRead/quote, code/slippage, swap) on Sepolia with zero on-chain allowancedocs/changes/0002: changelog documenting root cause (Tenderly null raw_state_diff) and fixHow it was diagnosed
raw_state_diff: null(field present, value null)transaction.transaction_info.raw_state_diff) was correct — no data to extractTest plan
go vetcleanTestSimulateTask_StopLossWorkflow_Sepolia— all 8 steps pass (trigger, balance, JS code, branch, approve, quote, slippage, swap)