Skip to content

fix: ERC20 approve state propagation for Uniswap workflow simulation#527

Merged
chrisli30 merged 2 commits into
stagingfrom
test/uniswap-simulate-propagation
Apr 17, 2026
Merged

fix: ERC20 approve state propagation for Uniswap workflow simulation#527
chrisli30 merged 2 commits into
stagingfrom
test/uniswap-simulate-propagation

Conversation

@chrisli30
Copy link
Copy Markdown
Member

  • fix: inject ERC20 allowance override after approve simulation for state propagation

Summary

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, we now explicitly compute and set the allowance[owner][spender] storage slot in SimulationStateMap across common ERC20 slot indices (1, 2, 3, 10 — covering OpenZeppelin, legacy, and USDC implementations).

Changes

  • simulation_state.go: add erc20AllowanceSlot() (double-keccak for nested mapping) and commonAllowanceSlots
  • vm_runner_contract_write.go: add isMethodWithParams() predicate, extract simSuccess guard, inject allowance slots post-approve
  • simulate_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 allowance
  • docs/changes/0002: changelog documenting root cause (Tenderly null raw_state_diff) and fix

How it was diagnosed

  1. Wrote integration test using salt:0 wallet (6.78 USDC, zero allowance to SwapRouter02)
  2. Confirmed approve succeeded but swap failed with allowance error
  3. Raw HTTP diagnostic confirmed Tenderly returns raw_state_diff: null (field present, value null)
  4. Extraction path (transaction.transaction_info.raw_state_diff) was correct — no data to extract
  5. Fix: explicit allowance injection after approve, independent of raw_state_diff

Test plan

  • go vet clean
  • TestSimulateTask_StopLossWorkflow_Sepolia — all 8 steps pass (trigger, balance, JS code, branch, approve, quote, slippage, swap)
  • Wallet has zero on-chain allowance — swap success proves propagation works
  • Post-deploy: verify stop-loss template simulation in Studio UI

…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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 SimulationStateMap after successful approve() 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.

Comment on lines +509 to +525
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))
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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))
}

Copilot uses AI. Check for mistakes.

### 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.
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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.

Copilot uses AI. Check for mistakes.
- 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
@chrisli30 chrisli30 merged commit 2f71d77 into staging Apr 17, 2026
18 checks passed
@chrisli30 chrisli30 deleted the test/uniswap-simulate-propagation branch April 17, 2026 18:08
chrisli30 pushed a commit that referenced this pull request Apr 22, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants