feat: user-facing erc20_overrides for RunNodeImmediately (+ simulation-state fix & RPC E2E proof)#624
Conversation
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.
379238d to
84c7282
Compare
| // Allowance slot value: max uint256 | ||
| wantAllowSlot := erc20AllowanceSlot(common.HexToAddress(owner), common.HexToAddress(spender), 3).Hex() | ||
| assert.Equal(t, "0x"+"ff"+"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", storage[wantAllowSlot]) | ||
| } |
| // 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{ |
| 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) | ||
| ) |
| 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.
|
Round 2 addressed in 279cabb:
|
| // 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; |
| 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.
|
Round 3 addressed in the latest commit: corrected the proto/OpenAPI/generated docstrings — |
| overrides, ok := rawOverrides.([]*avsproto.ERC20StateOverride) | ||
| if !ok || len(overrides) == 0 { | ||
| return nil | ||
| } |
| 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.
|
Round 4 addressed in the latest commit:
|
| 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.
|
Round 5 addressed in the latest commit: the override E2E test now asserts both the |
| 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.
|
Round 6 addressed in the latest commit: the "allowance only" phase now accepts either the inner |
Continues #620 (by @Antrikshgwal) and gets its E2E coverage green. Based on that branch, with two additions: the missing wiring fix that makes
erc20_overridesactually work through the RPC path, and a decisive end-to-end test that proves it.What #620 was missing
@Antrikshgwal wired
erc20_overridesfrom the request → proto →RunNodeImmediately→SimulationStateMap. Driving that path for real (not via a directApplyUserERC20Overridecall, per @will-dz's review) surfaced a bug the helper-only tests couldn't: the overrides never reached Tenderly.VM.RunNodeWithInputsbuilds a freshtempVMfor isolated single-node execution but never copied the parent VM'ssimulationState. 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 withERC20: transfer amount exceeds allowance. That's why the feature couldn't be shown green.Fix: propagate
v.simulationStateintotempVM(core/taskengine/vm.go). When simulation state is unset this isnil(unchanged); when set-but-empty the Tenderly client still takes its defaultstate_objectspath, so existing tests are unaffected.The E2E test (addresses the review checklist)
TestRunNodeImmediatelyRPC/ERC20Overrides_UniswapSwapnow drives the realRunNodeImmediatelyRPCentrypoint with a populatederc20_overrideson a UniswapexactInputSinglecontractWrite node, and asserts a true before/after: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/taskenginepackage undergo test . -short(the exact CI invocation):TestRunNodeImmediatelyRPC(incl.ERC20Overrides_UniswapSwap)TestContractWriteNode_UniswapV3QuoteTestExecuteTask_UniswapApprovalAndSwap_DifferentApprovalAmounts_SepoliaTestContractWriteTenderlySimulationThe only remaining
-shortfailure isTestBalanceNode_MissingAPIKey, which fails identically on the #620 baseline (pre-existing, unrelated to this change) and skips in CI whenMORALIS_API_KEYis configured — consistent with the currently-greenstagingruns.🤖 Generated with Claude Code