Skip to content

feat: ERC20 allowance overrides + user-facing erc20_overrides API for RunNodeImmediately#620

Closed
Antrikshgwal wants to merge 7 commits into
AvaProtocol:stagingfrom
Antrikshgwal:feat/erc20-overrides-runnode
Closed

feat: ERC20 allowance overrides + user-facing erc20_overrides API for RunNodeImmediately#620
Antrikshgwal wants to merge 7 commits into
AvaProtocol:stagingfrom
Antrikshgwal:feat/erc20-overrides-runnode

Conversation

@Antrikshgwal

Copy link
Copy Markdown
Contributor

Fixes #413

Summary

Completes the remaining work on #413. The SimulationStateMap infra and the
balance-override path landed in #517; this PR adds the allowance path and the
user-facing erc20_overrides API, so a caller testing an isolated
RunNodeImmediately contract write (e.g. a Uniswap swap) can seed token balance
and approval up front instead of hitting ERC20: transfer amount exceeds allowance/balance.

Changes

  • proto (protobuf/avs.proto): new ERC20StateOverride message and
    repeated ERC20StateOverride erc20_overrides = 5 on RunNodeWithInputsReq.
    Regenerated avs.pb.go with the pinned toolchain (protoc v29.2 /
    protoc-gen-go v1.36.6) so the diff is limited to the new message + field.
  • REST/OpenAPI (api/openapi.yaml, aggregator/rest/...): ERC20StateOverride
    schema + erc20Overrides on RunNodeRequest; regenerated types; mapping from
    the generated type to proto in the RunNode handler.
  • engine (core/taskengine/simulation_state.go, run_node_immediately.go):
    • ApplyUserERC20Override — pure, absolute-value seeding of balanceOf /
      allowance storage slots (defaults: balance slot 0, allowance slot 3),
      plus a parseUint256 hex/decimal helper.
    • Overrides are threaded from the request into the VM's SimulationStateMap
      and are rejected in real-execution mode — they only ever affect
      simulations, never deployed workflows or live wallets.
  • tests: deterministic unit tests for the slot math, hex/decimal parsing,
    and validation (simulation_state_override_test.go); the Uniswap test now
    seeds overrides through the new path and skips cleanly without Tenderly config.
  • docs: TENDERLY_STATE_OVERRIDES.md refreshed to document the shipped
    SimulationStateMap design and the new erc20_overrides API.

Notes for reviewers

  • Field number: used the next free tag = 5 rather than the = 10 in the
    original issue sketch (1/3/4 are used, 2 is reserved).
  • Uniswap test: the issue suggested flipping it to "expect success", but its
    method (QuoterV2.quoteExactInputSingle) reverts by design to return the
    quote. The test now asserts the allowance/balance revert is gone (the actual
    fix) and tolerates QuoterV2's intentional revert, rather than asserting a
    misleading success.
  • Security: erc20_overrides are simulation-only and explicit opt-in;
    passing them with isSimulated:false returns a clear error.

chrisli30 and others added 2 commits June 21, 2026 20:23
…#619)

Co-authored-by: Will Zimmerman <will@avaprotocol.org>
…ing (AvaProtocol#618)

Co-authored-by: Will Zimmerman <will@avaprotocol.org>
@chrisli30

Copy link
Copy Markdown
Member

@Antrikshgwal, thanks for the PR, let us review this.

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

Adds a user-facing ERC20 state override mechanism to RunNodeImmediately simulations so callers can pre-seed ERC20 balanceOf and allowance storage values (via proto + REST), enabling isolated contract-write simulations (e.g., Uniswap) to bypass funding/approval preconditions without affecting real execution paths.

Changes:

  • Extend gRPC + REST/OpenAPI request models with erc20_overrides / erc20Overrides and wire them into RunNodeImmediately.
  • Implement SimulationStateMap.ApplyUserERC20Override plus parsing/slot math to seed ERC20 storage overrides in simulation state.
  • Update/introduce unit + integration-style tests and refresh Tenderly override documentation.

Reviewed changes

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

Show a summary per file
File Description
protobuf/avs.proto Adds ERC20StateOverride message + erc20_overrides field on RunNodeWithInputsReq.
protobuf/avs.pb.go Regenerated Go bindings to include the new message/field.
api/openapi.yaml Adds ERC20StateOverride schema and erc20Overrides to RunNodeRequest.
aggregator/rest/handlers_nodes.go Maps REST erc20Overrides into proto request for RunNodeImmediately.
aggregator/rest/generated/types.gen.go Regenerated REST types for the new schema/field.
core/taskengine/run_node_immediately.go Threads overrides into VM simulation state and rejects them in non-simulated execution.
core/taskengine/simulation_state.go Adds parsing + state seeding for ERC20 balance/allowance slot overrides.
core/taskengine/simulation_state_override_test.go Adds unit tests for parsing, slot math, and validation.
core/taskengine/vm_runner_contract_write_uniswap_test.go Updates Uniswap simulation test to seed overrides and assert allowance/balance preconditions are removed.
core/taskengine/TENDERLY_STATE_OVERRIDES.md Updates documentation to reflect shipped SimulationStateMap + user-facing overrides API.
Files not reviewed (1)
  • protobuf/avs.pb.go: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +156 to +166
// Seed at every common balance slot so the test USDC's actual layout is covered.
for _, slot := range commonBalanceSlots {
s := uint64(slot)
err := vm.simulationState.ApplyUserERC20Override(
usdc, owner, swapRouter02,
"0x38d7ea4c68000", // 1,000,000 USDC (6 decimals)
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", // max allowance
&s, &s,
)
require.NoError(t, err)
}
Comment on lines +452 to +469
func parseUint256(value string) (*big.Int, error) {
v := strings.TrimSpace(value)
if v == "" {
return nil, fmt.Errorf("empty value")
}
if strings.HasPrefix(v, "0x") || strings.HasPrefix(v, "0X") {
n, ok := new(big.Int).SetString(v[2:], 16)
if !ok {
return nil, fmt.Errorf("invalid hex value %q", value)
}
return n, nil
}
n, ok := new(big.Int).SetString(v, 10)
if !ok {
return nil, fmt.Errorf("invalid decimal value %q", value)
}
return n, nil
}
Comment thread core/taskengine/simulation_state.go Outdated
Comment on lines +502 to +505
slot := defaultERC20BalanceSlot
if balanceSlot != nil {
slot = int64(*balanceSlot)
}
Comment thread core/taskengine/simulation_state.go Outdated
Comment on lines +527 to +530
slot := defaultERC20AllowanceSlot
if allowanceSlot != nil {
slot = int64(*allowanceSlot)
}
Comment on lines +441 to +445
// Default storage-slot indices used when a user-supplied ERC20 override does
// not specify them. These match the most common standard-ERC20 layout; tokens
// with non-standard layouts (e.g. USDC FiatToken at 9/10) must set the slots
// explicitly. See TENDERLY_STATE_OVERRIDES.md for a reference table.
const (
@will-dz

will-dz commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Hi @Antrikshgwal,

Because this PR touches the protobuf files — it adds a new ERC20StateOverride message and the erc20_overrides field to RunNodeWithInputsReq in avs.proto, the below E2E tests that must pass for this PR.

  1. Existing simulation E2E tests must actually run and pass (with a populated gateway-dev.yaml / Tenderly config — i.e. green, not skipped):
  1. A new E2E test must be added that drives the actual erc20_overrides request field. Right now the modified Uniswap test calls vm.simulationState.ApplyUserERC20Override(...) directly, which bypasses the whole new path — the proto field, the REST/handler mapping, and the RunNodeImmediately threading get zero coverage. Please add a test that:
  • builds a RunNodeWithInputsReq with a populated erc20_overrides (token/owner/spender/balance/allowance), and
  • calls RunNodeImmediatelyRPC (the real entrypoint, e.g. alongside TestRunNodeImmediatelyRPC in run_node_immediately_rpc_test.go) on a Uniswap contractWrite node, and
  • asserts the simulation no longer reverts with transfer amount exceeds allowance/balance — proving the overrides flowed from the request all the way into the Tenderly simulation.

That's the test that actually proves the feature works. The pure-helper tests you already have are good, but they don't exercise the protocol surface this PR adds.

will-dz and others added 3 commits June 22, 2026 19:30
…-index backfill

aa-wallet-toolkit.sh exec'd scripts/aa-wallet-toolkit.go, which was deleted
in AvaProtocol#538 — the wrapper has been broken (and unused) ever since. Remove it and
its config/README mention.

The (owner, factory, salt) → address backfill migration has run on all chains,
so remove the standalone script, the /ava backfill-wallet-salt-index
subcommand, and the BackfillWalletSaltIndex/RedactRPCURL core. Live
StoreWallet/MarkWalletStale/ListWallets behavior and its tests are kept;
only the migration-only tests are dropped. yaml.v3 drops to indirect.
The Option C Phase 1-4 narrative described a finished one-time cleanup and
no longer reflects current state. Keep the still-accurate per-chain config
exception (userops_withdraw_test.go), promoted to its own heading.
…way/worker, add operator template (AvaProtocol#621)

Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Member

Hi @Antrikshgwal — one heads-up before you tackle Will's checklist: the config/ naming changed on staging (#621 just merged), so gateway-dev.yaml no longer exists. Please rebase your branch on staging first. The setup below uses the new names.

Where the test config lives now

Those simulation E2E tests load their config via testutil.DefaultConfigPath, which is now config/test.yaml (a dedicated test fixture, not a server config). Full guide: docs/Development.md#testing. Short version:

# 1. Create the fixture from the template
cp config/test.example.yaml config/test.yaml

# 2. Fill in (in config/test.yaml):
#    eth_rpc_url        — a Sepolia RPC endpoint (your own Infura/Alchemy/etc.)
#    tenderly_account / tenderly_project / tenderly_access_key — your Tenderly creds

# 3. Set the funded owner EOA (env, or a .env file in the repo root)
export OWNER_EOA=0x...   # a Sepolia EOA with testnet funds

⚠️ Without OWNER_EOA and the Tenderly creds, these tests skip rather than run — that's the "green, not skipped" point Will is making (t.Skip("Owner EOA address not set...")).

1. Confirm the existing simulation tests run green (not skipped)

go test -v ./core/taskengine/ \
  -run 'TestContractWriteNode_UniswapV3Quote|TestExecuteTask_UniswapApprovalAndSwap_DifferentApprovalAmounts_Sepolia|TestContractWriteTenderlySimulation'

Look for --- PASS on each — if you see --- SKIP, the config / OWNER_EOA above isn't being picked up yet.

2. The new E2E test that drives erc20_overrides through the real path

Use TestRunNodeImmediatelyRPC (core/taskengine/run_node_immediately_rpc_test.go) as your template — it calls the real RunNodeImmediatelyRPC entrypoint. Your new test should:

  • build a RunNodeWithInputsReq with a populated erc20_overrides (token / owner / spender / balance / allowance),
  • call RunNodeImmediatelyRPC on a Uniswap contractWrite node, and
  • assert the simulation no longer reverts with transfer amount exceeds allowance/balance

proving the overrides flow from the request → proto field → REST/handler mapping → Tenderly simulation, rather than calling ApplyUserERC20Override(...) directly.


Generated by Claude Code

@Antrikshgwal

Copy link
Copy Markdown
Contributor Author

Hi @Antrikshgwal — one heads-up before you tackle Will's checklist: the config/ naming changed on staging (#621 just merged), so gateway-dev.yaml no longer exists. Please rebase your branch on staging first. The setup below uses the new names.

Where the test config lives now

Those simulation E2E tests load their config via testutil.DefaultConfigPath, which is now config/test.yaml (a dedicated test fixture, not a server config). Full guide: docs/Development.md#testing. Short version:

# 1. Create the fixture from the template
cp config/test.example.yaml config/test.yaml

# 2. Fill in (in config/test.yaml):
#    eth_rpc_url        — a Sepolia RPC endpoint (your own Infura/Alchemy/etc.)
#    tenderly_account / tenderly_project / tenderly_access_key — your Tenderly creds

# 3. Set the funded owner EOA (env, or a .env file in the repo root)
export OWNER_EOA=0x...   # a Sepolia EOA with testnet funds

⚠️ Without OWNER_EOA and the Tenderly creds, these tests skip rather than run — that's the "green, not skipped" point Will is making (t.Skip("Owner EOA address not set...")).

1. Confirm the existing simulation tests run green (not skipped)

go test -v ./core/taskengine/ \
  -run 'TestContractWriteNode_UniswapV3Quote|TestExecuteTask_UniswapApprovalAndSwap_DifferentApprovalAmounts_Sepolia|TestContractWriteTenderlySimulation'

Look for --- PASS on each — if you see --- SKIP, the config / OWNER_EOA above isn't being picked up yet.

2. The new E2E test that drives erc20_overrides through the real path

Use TestRunNodeImmediatelyRPC (core/taskengine/run_node_immediately_rpc_test.go) as your template — it calls the real RunNodeImmediatelyRPC entrypoint. Your new test should:

  • build a RunNodeWithInputsReq with a populated erc20_overrides (token / owner / spender / balance / allowance),
  • call RunNodeImmediatelyRPC on a Uniswap contractWrite node, and
  • assert the simulation no longer reverts with transfer amount exceeds allowance/balance

proving the overrides flow from the request → proto field → REST/handler mapping → Tenderly simulation, rather than calling ApplyUserERC20Override(...) directly.

Generated by Claude Code

Hi @chrisli30, Im done with the changes, but can't find the Tenderly access token, on their website, Could you please tell me where can i find it ?

Addresses review feedback on AvaProtocol#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.
@Antrikshgwal Antrikshgwal force-pushed the feat/erc20-overrides-runnode branch from 1abe216 to 5e0c377 Compare June 23, 2026 17:45
@Antrikshgwal

Copy link
Copy Markdown
Contributor Author

Hi @Antrikshgwal,

Because this PR touches the protobuf files — it adds a new ERC20StateOverride message and the erc20_overrides field to RunNodeWithInputsReq in avs.proto, the below E2E tests that must pass for this PR.

  1. Existing simulation E2E tests must actually run and pass (with a populated gateway-dev.yaml / Tenderly config — i.e. green, not skipped):
  1. A new E2E test must be added that drives the actual erc20_overrides request field. Right now the modified Uniswap test calls vm.simulationState.ApplyUserERC20Override(...) directly, which bypasses the whole new path — the proto field, the REST/handler mapping, and the RunNodeImmediately threading get zero coverage. Please add a test that:
  • builds a RunNodeWithInputsReq with a populated erc20_overrides (token/owner/spender/balance/allowance), and
  • calls RunNodeImmediatelyRPC (the real entrypoint, e.g. alongside TestRunNodeImmediatelyRPC in run_node_immediately_rpc_test.go) on a Uniswap contractWrite node, and
  • asserts the simulation no longer reverts with transfer amount exceeds allowance/balance — proving the overrides flowed from the request all the way into the Tenderly simulation.

That's the test that actually proves the feature works. The pure-helper tests you already have are good, but they don't exercise the protocol surface this PR adds.

Implementation + the new RPC-level E2E test (TestRunNodeImmediatelyRPC/ERC20Overrides_UniswapSwap) are in. I set up config/test.yaml locally, but the config validator requires controller_private_key to match the testnet paymaster's verifyingSigner (0x82F2Dd9a…), which is an Ava Protocol secret I don't have — so I can't run the Tenderly E2E suite green locally. Could you run TestContractWriteNode_UniswapV3Quote, TestExecuteTask_UniswapApprovalAndSwap_DifferentApprovalAmounts_Sepolia, TestContractWriteTenderlySimulation, and the new override test on your configured environment? Happy to iterate on anything they surface.

chrisli30 pushed a commit that referenced this pull request Jun 23, 2026
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.
@will-dz

will-dz commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Hi @Antrikshgwal — thanks again for this work. We picked it up and got it over the line in #624, which folds in your two commits (authorship preserved) on top of the latest staging.

The gap that was blocking green CI: your wiring was correct all the way from the request → proto → RunNodeImmediatelySimulationStateMap. But when we drove it through the real RunNodeImmediatelyRPC path (per @will-dz's request, instead of calling ApplyUserERC20Override directly), the overrides never reached Tenderly. The cause: VM.RunNodeWithInputs spins up a fresh tempVM for isolated single-node execution and didn't copy the parent VM's simulationState. So the balances/allowances you seeded were applied to the parent VM and then dropped before the contractWrite node simulated — which is why the Uniswap swap kept reverting with transfer amount exceeds allowance no matter what. This isn't something you could have caught with the Tenderly creds alone; the helper-only tests passed precisely because they bypassed that boundary.

What we added on top of your commits:

  • the one-line fix to propagate simulationState into the per-node VM (core/taskengine/vm.go);
  • an RPC-level E2E test that proves the full path with a real before/after (no overrides → reverts on allowance; allowance-only → reverts on balance; balance+allowance → swap succeeds);
  • a couple of review-driven tweaks (require an explicit storage slot instead of guessing a default, since ERC20 layout varies per token).

CI is green — the full core/taskengine suite passes in the unit-test workflow. We'll land it via #624 → staging. Appreciate the contribution! 🙏

@will-dz

will-dz commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Closing this PR since @Antrikshgwal's commits are included in the #624 now.

@will-dz will-dz closed this Jun 24, 2026
@Antrikshgwal

Copy link
Copy Markdown
Contributor Author

Hi @Antrikshgwal — thanks again for this work. We picked it up and got it over the line in #624, which folds in your two commits (authorship preserved) on top of the latest staging.

The gap that was blocking green CI: your wiring was correct all the way from the request → proto → RunNodeImmediatelySimulationStateMap. But when we drove it through the real RunNodeImmediatelyRPC path (per @will-dz's request, instead of calling ApplyUserERC20Override directly), the overrides never reached Tenderly. The cause: VM.RunNodeWithInputs spins up a fresh tempVM for isolated single-node execution and didn't copy the parent VM's simulationState. So the balances/allowances you seeded were applied to the parent VM and then dropped before the contractWrite node simulated — which is why the Uniswap swap kept reverting with transfer amount exceeds allowance no matter what. This isn't something you could have caught with the Tenderly creds alone; the helper-only tests passed precisely because they bypassed that boundary.

What we added on top of your commits:

  • the one-line fix to propagate simulationState into the per-node VM (core/taskengine/vm.go);
  • an RPC-level E2E test that proves the full path with a real before/after (no overrides → reverts on allowance; allowance-only → reverts on balance; balance+allowance → swap succeeds);
  • a couple of review-driven tweaks (require an explicit storage slot instead of guessing a default, since ERC20 layout varies per token).

CI is green — the full core/taskengine suite passes in the unit-test workflow. We'll land it via #624 → staging. Appreciate the contribution! 🙏

Thanks for picking this up and the clear writeup, that tempVM boundary in RunNodeWithInputs is a sneaky one. Seeding simulationState on the parent VM is moot if the per-node tempVM starts empty, and my helper-only tests sailed right past it. Exactly the blind spot that you flagged, lesson taken on testing at the real protocol surface.

One small clarification on the slots: I wasn't defaulting/guessing — the overrides seeded across the common ERC20 layouts (balance 0/9, allowance 3/10) to cover both standard and USDC FiatToken. But I agree requiring an explicit slot is the cleaner contract — fail loudly beats silently seeding the wrong slot for an unknown token.

Appreciate you preserving authorship and landing it in #624 🙏 Happy to take on more — point me at 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.

4 participants