Skip to content

feat: Add ERC20 state overrides for Tenderly simulations in RunNodeImmediately #413

@chrisli30

Description

@chrisli30

Problem

When testing contract write operations via RunNodeImmediately (node simulation endpoint) that involve ERC20 tokens (like Uniswap swaps), simulations fail with errors like:

  • ERC20: transfer amount exceeds allowance - token not approved for spender
  • ERC20: transfer amount exceeds balance - insufficient token balance

Currently, users must:

  1. Deploy an approval transaction first
  2. Ensure the wallet has sufficient token balance
  3. Then test the actual contract write operation

This creates friction for testing and simulation workflows.

Solution

Implement optional ERC20 state overrides in RunNodeImmediately for contract write simulations, allowing users to bypass approval/balance requirements during testing.

Scope

  • Target: run_node_immediately.go only (not deployed workflows)
  • Trigger: User-requested via optional configuration parameter
  • Use Case: Testing/simulation of contract writes involving ERC20 tokens

Implementation Plan

1. Add Configuration to RunNodeWithInputsReq

Add optional field to the gRPC request:

message RunNodeWithInputsReq {
  // ... existing fields ...
  
  // Optional: ERC20 state overrides for simulation testing
  repeated ERC20StateOverride erc20_overrides = 10;
}

message ERC20StateOverride {
  string token_address = 1;      // ERC20 token contract address
  string owner_address = 2;      // Address whose balance/allowance to override
  optional string spender_address = 3;  // Address to approve (for allowance override)
  optional string balance = 4;   // Balance override (hex or decimal string)
  optional string allowance = 5; // Allowance override (hex or decimal string)
  optional uint64 balance_slot = 6;   // Storage slot for balanceOf mapping (default: 0)
  optional uint64 allowance_slot = 7; // Storage slot for allowance mapping (default: 3)
}

2. Enhance TenderlyClient.SimulateContractWrite

Update the Tenderly client to accept and apply state overrides:

// In tenderly_client.go
func (tc *TenderlyClient) SimulateContractWrite(
    ctx context.Context,
    contractAddress string,
    callData string,
    contractABI string,
    methodName string,
    chainID int64,
    fromAddress string,
    value string,
    erc20Overrides []ERC20StateOverride, // NEW parameter
) (*ContractWriteSimulationResult, error) {
    // ... existing code ...
    
    // Build state_objects with ERC20 overrides
    stateObjects := make(map[string]interface{})
    
    // Existing balance override for gas
    stateObjects[fromAddress] = map[string]interface{}{
        "balance": balanceOverride,
    }
    
    // Apply ERC20 state overrides
    for _, override := range erc20Overrides {
        storage := make(map[string]string)
        
        // Calculate and apply balance override
        if override.Balance != "" {
            balanceSlot := calculateBalanceStorageSlot(
                common.HexToAddress(override.OwnerAddress),
                override.BalanceSlot,
            )
            storage[balanceSlot] = override.Balance
        }
        
        // Calculate and apply allowance override
        if override.Allowance != "" && override.SpenderAddress != "" {
            allowanceSlot := calculateAllowanceStorageSlot(
                common.HexToAddress(override.OwnerAddress),
                common.HexToAddress(override.SpenderAddress),
                override.AllowanceSlot,
            )
            storage[allowanceSlot] = override.Allowance
        }
        
        stateObjects[strings.ToLower(override.TokenAddress)] = map[string]interface{}{
            "storage": storage,
        }
    }
    
    payload["state_objects"] = stateObjects
    // ... rest of implementation ...
}

3. Implement Storage Slot Calculation

Add helper functions based on the example in vm_runner_contract_write_uniswap_test.go:

// calculateBalanceStorageSlot calculates the storage slot for balanceOf[owner]
// Formula: keccak256(abi.encode(owner, balanceSlot))
func calculateBalanceStorageSlot(owner common.Address, balanceSlot uint64) string {
    ownerPadded := common.LeftPadBytes(owner.Bytes(), 32)
    slotPadded := common.LeftPadBytes(big.NewInt(int64(balanceSlot)).Bytes(), 32)
    data := append(ownerPadded, slotPadded...)
    hash := crypto.Keccak256Hash(data)
    return hash.Hex()
}

// calculateAllowanceStorageSlot calculates the storage slot for allowance[owner][spender]
// Formula: keccak256(abi.encode(spender, keccak256(abi.encode(owner, allowanceSlot))))
func calculateAllowanceStorageSlot(owner, spender common.Address, allowanceSlot uint64) string {
    // Inner hash: keccak256(abi.encode(owner, allowanceSlot))
    ownerPadded := common.LeftPadBytes(owner.Bytes(), 32)
    slotPadded := common.LeftPadBytes(big.NewInt(int64(allowanceSlot)).Bytes(), 32)
    innerData := append(ownerPadded, slotPadded...)
    innerHash := crypto.Keccak256Hash(innerData)
    
    // Outer hash: keccak256(abi.encode(spender, innerHash))
    spenderPadded := common.LeftPadBytes(spender.Bytes(), 32)
    outerData := append(spenderPadded, innerHash.Bytes()...)
    outerHash := crypto.Keccak256Hash(outerData)
    
    return outerHash.Hex()
}

4. Wire Through RunNodeImmediately

Update run_node_immediately.go to pass overrides to the VM and Tenderly client:

// In RunNodeImmediatelyRPC
func (n *Engine) RunNodeImmediatelyRPC(user *model.User, req *avsproto.RunNodeWithInputsReq) (*avsproto.RunNodeWithInputsResp, error) {
    // ... existing code ...
    
    // Extract ERC20 overrides from request
    var erc20Overrides []ERC20StateOverride
    if req.Erc20Overrides != nil {
        erc20Overrides = req.Erc20Overrides
    }
    
    // Pass to VM creation or add to nodeConfig
    if len(erc20Overrides) > 0 {
        nodeConfig["erc20Overrides"] = erc20Overrides
    }
    
    // ... rest of implementation ...
}

Storage Slot Reference

Different ERC20 implementations use different storage slots:

Token Type balanceOf Slot allowance Slot
Standard ERC20 0 3
OpenZeppelin ERC20 0 1
USDC (FiatToken) 9 10

Note: Always verify storage layout by checking the token's contract source code.

Example Usage

SDK/Client Side

const result = await client.runNodeWithInputs({
  nodeType: 'contractWrite',
  nodeConfig: {
    contractAddress: '0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E', // SwapRouter02
    methodCalls: [{
      methodName: 'exactInputSingle',
      methodParams: ['["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", ...]']
    }]
  },
  inputVariables: { settings: { runner: '0x71c8f4D...' } },
  erc20Overrides: [
    {
      tokenAddress: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', // USDC
      ownerAddress: '0x71c8f4D7D5291EdCb3A081802e7efB2788Bd232e',
      spenderAddress: '0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E', // SwapRouter02
      balance: '0x38d7ea4c68000',  // 1M USDC (6 decimals)
      allowance: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', // max uint256
      balanceSlot: 0,    // Standard ERC20
      allowanceSlot: 3   // Standard ERC20
    }
  ]
});

Testing

  1. Add unit tests in run_node_immediately_test.go
  2. Use vm_runner_contract_write_uniswap_test.go as a reference for Uniswap swap testing
  3. Test with different ERC20 implementations (standard, OpenZeppelin, USDC)
  4. Verify storage slot calculations are correct

Documentation

  • Reference: core/taskengine/TENDERLY_STATE_OVERRIDES.md
  • Helper function: calculateERC20StorageSlots in vm_runner_contract_write_uniswap_test.go

Important Considerations

  1. Security: This feature should ONLY work in simulation mode (RunNodeImmediately), never in deployed workflows
  2. Optional: State overrides should be opt-in via explicit request parameters
  3. Validation: Validate addresses and slot numbers before applying overrides
  4. Error Handling: Provide clear errors if Tenderly rejects invalid storage overrides
  5. Token Detection: Consider auto-detecting common tokens and using known storage slots

Success Criteria

  • ✅ Uniswap swap simulations succeed without prior approval transactions
  • ✅ Token balance overrides work correctly
  • ✅ Token allowance overrides work correctly
  • ✅ Storage slot calculations match Solidity storage layout
  • ✅ Feature is optional and explicitly requested
  • ✅ Clear error messages when overrides are invalid
  • ✅ Documentation and examples are provided

Related Files

  • core/taskengine/run_node_immediately.go - Main entry point
  • core/taskengine/tenderly_client.go - Tenderly API client
  • core/taskengine/vm_runner_contract_write.go - Contract write execution
  • core/taskengine/TENDERLY_STATE_OVERRIDES.md - Documentation
  • core/taskengine/vm_runner_contract_write_uniswap_test.go - Reference implementation
  • protobuf/avs.proto - Protocol buffer definitions

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions