Skip to content

feat: user-facing erc20_overrides for RunNodeImmediately (+ simulation-state fix & RPC E2E proof)#624

Merged
chrisli30 merged 12 commits into
stagingfrom
feat/erc20-overrides-runnode-e2e
Jun 23, 2026
Merged

feat: user-facing erc20_overrides for RunNodeImmediately (+ simulation-state fix & RPC E2E proof)#624
chrisli30 merged 12 commits into
stagingfrom
feat/erc20-overrides-runnode-e2e

Conversation

@chrisli30

Copy link
Copy Markdown
Member

Continues #620 (by @Antrikshgwal) and gets its E2E coverage green. Based on that branch, with two additions: the missing wiring fix that makes erc20_overrides actually work through the RPC path, and a decisive end-to-end test that proves it.

What #620 was missing

@Antrikshgwal wired erc20_overrides from the request → proto → RunNodeImmediatelySimulationStateMap. Driving that path for real (not via a direct ApplyUserERC20Override call, per @will-dz's review) surfaced a bug the helper-only tests couldn't: the overrides never reached Tenderly.

VM.RunNodeWithInputs builds a fresh tempVM for isolated single-node execution but never copied the parent VM's simulationState. The seeded balances/allowances were applied to the parent VM and then dropped — so the contractWrite node simulated against empty state and the Uniswap swap still reverted with ERC20: transfer amount exceeds allowance. That's why the feature couldn't be shown green.

Fix: propagate v.simulationState into tempVM (core/taskengine/vm.go). When simulation state is unset this is nil (unchanged); when set-but-empty the Tenderly client still takes its default state_objects path, so existing tests are unaffected.

The E2E test (addresses the review checklist)

TestRunNodeImmediatelyRPC/ERC20Overrides_UniswapSwap now drives the real RunNodeImmediatelyRPC entrypoint with a populated erc20_overrides on a Uniswap exactInputSingle contractWrite node, and asserts a true before/after:

  • control (no overrides) reverts on the allowance/balance precondition, and
  • the same swap succeeds once overrides seed USDC balance (slot 9) + SwapRouter02 allowance (slot 10).

Only balance/allowance flowing from the request all the way into the Tenderly simulation can flip that outcome. Slots 9/10 were confirmed empirically against the deployed Sepolia USDC (Circle FiatToken).

Local verification (real Tenderly + funded OWNER_EOA)

Full core/taskengine package under go test . -short (the exact CI invocation):

Test baseline (#620 head) this branch
TestRunNodeImmediatelyRPC (incl. ERC20Overrides_UniswapSwap) ❌ FAIL ✅ PASS
TestContractWriteNode_UniswapV3Quote
TestExecuteTask_UniswapApprovalAndSwap_DifferentApprovalAmounts_Sepolia
TestContractWriteTenderlySimulation

The only remaining -short failure is TestBalanceNode_MissingAPIKey, which fails identically on the #620 baseline (pre-existing, unrelated to this change) and skips in CI when MORALIS_API_KEY is configured — consistent with the currently-green staging runs.

🤖 Generated with Claude Code

Antrikshgwal and others added 3 commits June 23, 2026 15:17
Addresses review feedback on #620.

- parseUint256: reject negative values and anything exceeding uint256 max,
  so an override can never format into an invalid EVM storage word.
- erc20SlotOrDefault: bounds-check the caller-supplied *uint64 slot before
  the int64 conversion, preventing a >MaxInt64 index from overflowing into
  a bogus storage slot.
- Add a real end-to-end test as a subtest of TestRunNodeImmediatelyRPC
  (ERC20Overrides_UniswapSwap): builds a RunNodeWithInputsReq with populated
  erc20_overrides and calls the real RunNodeImmediatelyRPC on a Uniswap
  exactInputSingle node, asserting the sim no longer reverts on the
  allowance/balance precondition — exercising the full request → proto →
  RunNodeImmediately → SimulationStateMap → Tenderly path (not the helper
  directly). Skips cleanly without OWNER_EOA.
- Seed balance and allowance at their OWN slots (balance 0/9, allowance
  3/10) instead of reusing balance slots for allowance; keep the documented
  0/3 defaults and add unit cases for the new validation.
- Docs: note the value/slot bounds and the explicit-slot escape hatch for
  OpenZeppelin (allowance slot 1) and USDC (9/10) layouts.
…rrides reach Tenderly

RunNodeWithInputs builds a fresh tempVM for isolated single-node execution
but never propagated the parent VM's simulationState. Caller-seeded
erc20_overrides (and any accumulated state diffs) were applied to the parent
VM and then dropped, so the contractWrite node simulated against an empty
state and Uniswap swaps still reverted with "transfer amount exceeds
allowance/balance" despite the overrides.

Propagate v.simulationState to tempVM so the seeded balances/allowances
actually flow into the node's Tenderly simulation. When no simulation state
is set (non-simulation paths) this is nil, unchanged from before; when set
but empty the Tenderly client still takes the default state_objects path, so
existing tests are unaffected.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 12 changed files in this pull request and generated 5 comments.

Files not reviewed (1)
  • protobuf/avs.pb.go: Generated file

Comment on lines +79 to +82
// Allowance slot value: max uint256
wantAllowSlot := erc20AllowanceSlot(common.HexToAddress(owner), common.HexToAddress(spender), 3).Hex()
assert.Equal(t, "0x"+"ff"+"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", storage[wantAllowSlot])
}
Comment thread core/taskengine/vm_runner_contract_write_uniswap_test.go Outdated
Comment on lines +476 to +480
// USDC on Sepolia is Circle's FiatToken: balanceOf at storage slot 9 and
// allowance at slot 10 (confirmed empirically against the deployed token).
maxAllowance := "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
bigBalance := "0x38d7ea4c68000" // 1,000,000 USDC (6 decimals)
balanceOverride := &avsproto.ERC20StateOverride{
Comment on lines +85 to +91
err := vm.simulationState.ApplyUserERC20Override(
tokenAddress, ownerAddress, spenderAddress,
"0x38d7ea4c68000", // balance: 1,000,000 USDC
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", // allowance: max uint256
balanceSlotPtr, // *uint64, required when balance is set (e.g. USDC: 9)
allowanceSlotPtr, // *uint64, required when allowance is set (e.g. USDC: 10)
)
Comment on lines +172 to +178
tokenAddress: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', // USDC
ownerAddress: '0x71c8f4D7D5291EdCb3A081802e7efB2788Bd232e',
spenderAddress: '0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E', // SwapRouter02
balance: '0x38d7ea4c68000', // 1,000,000 USDC (6 decimals)
allowance: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', // max uint256
balanceSlot: 9, // USDC FiatToken layout (required — see table below)
allowanceSlot: 10, // USDC FiatToken layout (required — see table below)
…ew nits

Address Copilot review round 2 on PR #624:

- 0x38d7ea4c68000 is 1e15 base units = 1,000,000,000 USDC at 6 decimals, not
  1,000,000. Fixed the misleading comment in all five spots (engine docs, SDK
  example, and the two Uniswap tests).
- Replaced the string-concatenated max-uint256 literal in the override unit test
  with the full hex literal for clarity.
- Dropped the misleading "Standard ERC20 | 0 | 3" row from the storage-slot
  table; OpenZeppelin v4/v5 (0/1) is the de-facto standard.
@chrisli30

Copy link
Copy Markdown
Member Author

Round 2 addressed in 279cabb:

  • Magnitude comment: correct — 0x38d7ea4c68000 is 1e15 base units = 1,000,000,000 USDC at 6 decimals, not 1,000,000. Fixed the comment in all five spots (it was an oversized-on-purpose balance; only the comment was wrong).
  • Max-uint256 string concat: replaced with the full hex literal in the unit test.
  • Also dropped the misleading Standard ERC20 | 0 | 3 row from the slot table (OpenZeppelin v4/v5 0/1 is the real standard), consistent with the require-explicit-slot change.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 12 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • protobuf/avs.pb.go: Generated file

Comment thread protobuf/avs.proto
Comment on lines +1245 to +1250
// Optional ERC20 balance/allowance state overrides applied only during this
// isolated node simulation (RunNodeImmediately). Lets callers seed token
// balances and approvals so contract-write simulations (e.g. Uniswap swaps)
// don't revert with "transfer amount exceeds allowance/balance" before the
// approval/funding transactions have been run. Ignored for real execution.
repeated ERC20StateOverride erc20_overrides = 5;
Comment thread api/openapi.yaml Outdated
Comment on lines +1432 to +1437
description: >
Optional ERC20 balance/allowance state overrides applied only during
this isolated node simulation. Lets callers seed token balances and
approvals so contract-write simulations (e.g. Uniswap swaps) don't
revert with "transfer amount exceeds allowance/balance" before the
approval/funding transactions have been run. Ignored for real execution.
Address Copilot review round 3 on PR #624: the proto and OpenAPI docs said
erc20_overrides are "Ignored for real execution", but RunNodeImmediately returns
an error ("erc20_overrides are only supported in simulation mode") when
isSimulated=false. Corrected the proto/OpenAPI/generated docstrings to state the
reject-with-error behavior so SDK/REST clients don't assume the field is
silently dropped.
@chrisli30

Copy link
Copy Markdown
Member Author

Round 3 addressed in the latest commit: corrected the proto/OpenAPI/generated docstrings — erc20_overrides are rejected with an error when isSimulated=false (erc20_overrides are only supported in simulation mode), not silently ignored. Thanks!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 12 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • protobuf/avs.pb.go: Generated file

Comment on lines +2467 to +2470
overrides, ok := rawOverrides.([]*avsproto.ERC20StateOverride)
if !ok || len(overrides) == 0 {
return nil
}
Comment on lines +148 to +167
usdc := "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"
owner := "0x71c8f4D7D5291EdCb3A081802e7efB2788Bd232e"
swapRouter02 := "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E"
require.NotNil(t, vm.simulationState, "simulation state must exist in simulation mode")
bigBalance := "0x38d7ea4c68000" // 1,000,000,000 USDC (6 decimals)
maxAllowance := "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" // max uint256
// Cover both the standard OpenZeppelin ERC20 layout (balance slot 0 /
// allowance slot 1) and USDC's FiatToken layout (9 / 10) so the override lands
// regardless of which the deployed test token uses. Balance and allowance get
// their OWN slots.
for _, balSlot := range []uint64{0, 9} {
s := balSlot
require.NoError(t, vm.simulationState.ApplyUserERC20Override(
usdc, owner, "", bigBalance, "", &s, nil))
}
for _, allowSlot := range []uint64{1, 10} {
s := allowSlot
require.NoError(t, vm.simulationState.ApplyUserERC20Override(
usdc, owner, swapRouter02, "", maxAllowance, nil, &s))
}
…verrides from quote test

Address Copilot review round 4 on PR #624:

- applyERC20Overrides returned nil when the stashed value wasn't a
  []*ERC20StateOverride, silently dropping a caller's overrides and masking any
  wiring/serialization bug. Return an explicit internal error instead; the
  empty-slice case still no-ops.
- TestContractWriteNode_UniswapV3Quote seeded a SwapRouter02 balance/allowance,
  but QuoterV2.quoteExactInputSingle is a view function that reverts by design to
  return the quote and never does a transferFrom — so those overrides validated
  nothing and only muddied the test. Removed them; the test now honestly covers
  tuple-parameter calldata generation. The erc20_overrides request path is proven
  end-to-end by TestRunNodeImmediatelyRPC/ERC20Overrides_UniswapSwap.
@chrisli30

Copy link
Copy Markdown
Member Author

Round 4 addressed in the latest commit:

  • Silent type-assertion drop: applyERC20Overrides now returns an explicit internal error if the stashed value isn't a []*ERC20StateOverride (it could only happen via a wiring bug), instead of silently dropping the overrides. Empty slice still no-ops.
  • QuoterV2 override mismatch: you're right — quoteExactInputSingle reverts by design to return the quote and never does a transferFrom, so seeding a SwapRouter02 allowance proved nothing there. Removed the override seeding; that test now honestly validates tuple-parameter calldata generation, and the override request path is proven E2E by TestRunNodeImmediatelyRPC/ERC20Overrides_UniswapSwap.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 12 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • protobuf/avs.pb.go: Generated file

Comment thread core/taskengine/run_node_immediately_rpc_test.go Outdated
Comment on lines +552 to +573
abi, _ := structpb.NewValue(map[string]interface{}{
"inputs": []interface{}{
map[string]interface{}{
"name": "params",
"type": "tuple",
"components": []interface{}{
map[string]interface{}{"name": "tokenIn", "type": "address"},
map[string]interface{}{"name": "tokenOut", "type": "address"},
map[string]interface{}{"name": "fee", "type": "uint24"},
map[string]interface{}{"name": "recipient", "type": "address"},
map[string]interface{}{"name": "amountIn", "type": "uint256"},
map[string]interface{}{"name": "amountOutMinimum", "type": "uint256"},
map[string]interface{}{"name": "sqrtPriceLimitX96", "type": "uint160"},
},
},
},
"name": "exactInputSingle",
"outputs": []interface{}{map[string]interface{}{"name": "amountOut", "type": "uint256"}},
"stateMutability": "payable",
"type": "function",
})

…ride E2E test

Address Copilot review round 5 on PR #624: the erc20_overrides E2E test ignored
the StoreWallet error and the structpb.NewValue error in exactInputSingleSwapNode.
Assert both (require.NoError) so a seed/ABI failure fails the test deterministically
instead of surfacing as a confusing downstream error.
@chrisli30

Copy link
Copy Markdown
Member Author

Round 5 addressed in the latest commit: the override E2E test now asserts both the StoreWallet error and the structpb.NewValue ABI-build error (require.NoError) so a seed/ABI failure fails the test deterministically rather than surfacing later as a confusing error.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

Files not reviewed (1)
  • protobuf/avs.pb.go: Generated file

Comment on lines +504 to +510
allowOnly := runSwap([]*avsproto.ERC20StateOverride{allowanceOverride})
t.Logf("allowance only : success=%v error=%q", allowOnly.Success, allowOnly.Error)
require.False(t, allowOnly.Success, "swap must still fail with balance unseeded")
assert.NotContains(t, allowOnly.Error, "transfer amount exceeds allowance",
"allowance override should have seeded the approval through the RPC path")
assert.Contains(t, allowOnly.Error, "transfer amount exceeds balance",
"with approval seeded but balance unseeded, the swap must revert on balance")
…phase

Address Copilot review round 6 on PR #624: the "allowance only" phase required
the error to contain exactly "transfer amount exceeds balance", but SwapRouter02's
transferFrom failure can surface as TransferHelper "STF" or the inner token revert
depending on how Tenderly extracts it. Accept either so the balance-precondition
assertion isn't brittle to revert-extraction ordering.
@chrisli30

Copy link
Copy Markdown
Member Author

Round 6 addressed in the latest commit: the "allowance only" phase now accepts either the inner transfer amount exceeds balance revert or TransferHelper STF, since Tenderly may surface SwapRouter02's transferFrom failure as either — removing the brittleness without weakening the proof (it still asserts the allowance was seeded and the remaining failure is the balance precondition).

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.

4 participants