-
Notifications
You must be signed in to change notification settings - Fork 75
Description
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 spenderERC20: transfer amount exceeds balance- insufficient token balance
Currently, users must:
- Deploy an approval transaction first
- Ensure the wallet has sufficient token balance
- 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.goonly (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
- Add unit tests in
run_node_immediately_test.go - Use
vm_runner_contract_write_uniswap_test.goas a reference for Uniswap swap testing - Test with different ERC20 implementations (standard, OpenZeppelin, USDC)
- Verify storage slot calculations are correct
Documentation
- Reference:
core/taskengine/TENDERLY_STATE_OVERRIDES.md - Helper function:
calculateERC20StorageSlotsinvm_runner_contract_write_uniswap_test.go
Important Considerations
- Security: This feature should ONLY work in simulation mode (
RunNodeImmediately), never in deployed workflows - Optional: State overrides should be opt-in via explicit request parameters
- Validation: Validate addresses and slot numbers before applying overrides
- Error Handling: Provide clear errors if Tenderly rejects invalid storage overrides
- 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 pointcore/taskengine/tenderly_client.go- Tenderly API clientcore/taskengine/vm_runner_contract_write.go- Contract write executioncore/taskengine/TENDERLY_STATE_OVERRIDES.md- Documentationcore/taskengine/vm_runner_contract_write_uniswap_test.go- Reference implementationprotobuf/avs.proto- Protocol buffer definitions