@@ -8,13 +8,30 @@ When simulating contract writes involving ERC20 tokens (like Uniswap swaps), you
88
99This document explains how to calculate and use Tenderly state overrides for ERC20 tokens.
1010
11+ ## Status
12+
13+ State overrides are implemented and accumulated by ` SimulationStateMap `
14+ (` simulation_state.go ` ), applied to every Tenderly call via
15+ ` BuildStateObjects() ` (` tenderly_client.go ` ). There are two ways state overrides
16+ get populated:
17+
18+ 1 . ** Automatic** — event-trigger / multi-step workflow simulation. A Transfer
19+ event replayed during simulation injects a balance delta
20+ (` InjectERC20BalanceChange ` ), and each step's ` raw_state_diff ` is carried
21+ forward (` MergeRawStateDiff ` ) so later steps see a consistent view.
22+ 2 . ** User-supplied** — the ` erc20_overrides ` field on ` RunNodeWithInputsReq `
23+ (REST: ` erc20Overrides ` on ` POST /api/v1/nodes:run ` ). This lets a caller
24+ testing an isolated ` RunNodeImmediately ` swap seed an arbitrary balance and
25+ allowance up front, with no preceding trigger. See
26+ [ User-facing API] ( #user-facing-api-erc20_overrides ) below.
27+
1128## How ERC20 Storage Works
1229
1330ERC20 tokens use Solidity mappings to store balances and allowances:
1431
1532``` solidity
1633mapping(address => uint256) public balanceOf; // Usually at storage slot 0
17- mapping(address => mapping(address => uint256)) public allowance; // Usually at slot 3 or 4
34+ mapping(address => mapping(address => uint256)) public allowance; // OpenZeppelin: slot 1 (varies by token)
1835```
1936
2037## Calculating Storage Slots
@@ -43,31 +60,35 @@ storage_slot = keccak256(abi.encode(spender_address, inner_hash))
4360Where:
4461- ` owner_address ` is the token owner (32 bytes, left-padded)
4562- ` spender_address ` is the approved spender (32 bytes, left-padded)
46- - ` allowance_mapping_slot ` is usually 3 or 4 ( check the token contract)
63+ - ` allowance_mapping_slot ` is 1 for standard OpenZeppelin ERC20 (varies by token — e.g. USDC FiatToken uses 10; check the token contract)
4764
4865## Go Implementation
4966
50- Example implementation for calculating ERC20 storage slots:
67+ The slot math lives in ` simulation_state.go ` as ` erc20BalanceSlot ` and
68+ ` erc20AllowanceSlot ` :
5169
5270``` go
53- func calculateERC20StorageSlots (owner , spender , tokenAddress common .Address , balanceSlot , allowanceSlot uint64 ) (balanceStorageSlot , allowanceStorageSlot string ) {
54- // Balance slot: keccak256(abi.encode(owner, balanceSlot))
55- ownerPadded := common.LeftPadBytes (owner.Bytes (), 32 )
56- balanceSlotPadded := common.LeftPadBytes (big.NewInt (int64 (balanceSlot)).Bytes (), 32 )
57- balanceData := append (ownerPadded, balanceSlotPadded...)
58- balanceHash := crypto.Keccak256Hash (balanceData)
59-
60- // Allowance slot: keccak256(abi.encode(spender, keccak256(abi.encode(owner, allowanceSlot))))
61- allowanceSlotPadded := common.LeftPadBytes (big.NewInt (int64 (allowanceSlot)).Bytes (), 32 )
62- innerData := append (ownerPadded, allowanceSlotPadded...)
63- innerHash := crypto.Keccak256Hash (innerData)
64-
65- spenderPadded := common.LeftPadBytes (spender.Bytes (), 32 )
66- outerData := append (spenderPadded, innerHash.Bytes ()...)
67- allowanceHash := crypto.Keccak256Hash (outerData)
68-
69- return balanceHash.Hex (), allowanceHash.Hex ()
70- }
71+ // keccak256(abi.encode(holder, mappingSlot))
72+ func erc20BalanceSlot (holder common .Address , mappingSlot int64 ) common .Hash
73+
74+ // keccak256(abi.encode(spender, keccak256(abi.encode(owner, mappingSlot))))
75+ func erc20AllowanceSlot(owner, spender common.Address, mappingSlot int64) common.Hash
76+ ```
77+
78+ To seed a balance and/or allowance directly, use the higher-level helper
79+ `SimulationStateMap.ApplyUserERC20Override`, which parses hex/decimal values,
80+ computes the mapping slots from the caller-supplied slot indices, and records
81+ the storage override. The slot is required whenever the corresponding value is
82+ set — ERC20 storage layout is not standardized, so there is no safe default:
83+
84+ ```go
85+ err := vm.simulationState.ApplyUserERC20Override(
86+ tokenAddress, ownerAddress, spenderAddress,
87+ "0x38d7ea4c68000", // balance: 1,000,000,000 USDC
88+ "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", // allowance: max uint256
89+ balanceSlotPtr, // *uint64, required when balance is set (e.g. USDC: 9)
90+ allowanceSlotPtr, // *uint64, required when allowance is set (e.g. USDC: 10)
91+ )
7192```
7293
7394## Tenderly API Format
@@ -98,31 +119,81 @@ When calling the Tenderly simulation API, include state overrides in the `state_
98119```
99120
100121Where:
101- - Token balance: ` 0x38d7ea4c68000 ` = 1,000,000 USDC (6 decimals)
122+ - Token balance: ` 0x38d7ea4c68000 ` = 1,000,000,000 USDC (6 decimals)
102123- Token allowance: ` 0xffff...ffff ` = max uint256 (unlimited approval)
103124
104125## Common Token Storage Slots
105126
106127| Token Type | balanceOf Slot | allowance Slot |
107128| ------------| ----------------| ----------------|
108- | Standard ERC20 | 0 | 3 |
109- | OpenZeppelin ERC20 | 0 | 1 |
129+ | OpenZeppelin ERC20 (v4/v5) | 0 | 1 |
110130| USDC (FiatToken) | 9 | 10 |
111131
112132** Note:** Always verify the actual storage layout by checking the token's contract source code or using tools like ` cast storage ` from Foundry.
113133
114- ## Implementation TODO
134+ ## User-facing API: ` erc20_overrides `
115135
116- To enable state overrides in production code, the ` TenderlyClient.SimulateContractWrite() ` method needs to be enhanced to:
136+ ` RunNodeImmediately ` accepts optional ERC20 overrides so an isolated
137+ contract-write simulation can seed balances/approvals up front. The overrides
138+ are ** simulation-only** — ` RunNodeImmediately ` rejects them in real-execution
139+ mode, and they never apply to deployed workflows.
117140
118- 1 . Accept optional parameters for ERC20 overrides:
119- - Token addresses to override
120- - Balance and allowance values
121- - Storage slot numbers
141+ ### Request shape
122142
123- 2 . Calculate storage slots using the helper function
143+ Protobuf (` RunNodeWithInputsReq ` ):
144+
145+ ``` protobuf
146+ repeated ERC20StateOverride erc20_overrides = 5;
147+
148+ message ERC20StateOverride {
149+ string token_address = 1; // ERC20 token contract address
150+ string owner_address = 2; // Address whose balance/allowance to override
151+ optional string spender_address = 3; // Spender to approve (required for allowance override)
152+ optional string balance = 4; // Balance override (hex 0x… or decimal string)
153+ optional string allowance = 5; // Allowance override (hex 0x… or decimal string)
154+ optional uint64 balance_slot = 6; // Storage slot for the balanceOf mapping (required when balance is set; layout varies per token)
155+ optional uint64 allowance_slot = 7; // Storage slot for the allowance mapping (required when allowance is set; layout varies per token)
156+ }
157+ ```
158+
159+ REST (` POST /api/v1/nodes:run ` , camelCase): ` erc20Overrides ` is an array of the
160+ same fields (` tokenAddress ` , ` ownerAddress ` , ` spenderAddress ` , ` balance ` ,
161+ ` allowance ` , ` balanceSlot ` , ` allowanceSlot ` ).
162+
163+ ### SDK example
164+
165+ ``` javascript
166+ const result = await client .runNodeWithInputs ({
167+ node: { /* contractWrite node */ },
168+ inputVariables: { settings: { runner: ' 0x71c8f4D…' } },
169+ erc20Overrides: [
170+ {
171+ tokenAddress: ' 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' , // USDC
172+ ownerAddress: ' 0x71c8f4D7D5291EdCb3A081802e7efB2788Bd232e' ,
173+ spenderAddress: ' 0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E' , // SwapRouter02
174+ balance: ' 0x38d7ea4c68000' , // 1,000,000,000 USDC (6 decimals)
175+ allowance: ' 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' , // max uint256
176+ balanceSlot: 9 , // USDC FiatToken layout (required — see table below)
177+ allowanceSlot: 10 , // USDC FiatToken layout (required — see table below)
178+ },
179+ ],
180+ });
181+ ```
124182
125- 3 . Include the ` state_objects ` in the Tenderly API payload
183+ ### Validation
184+
185+ - ` token_address ` / ` owner_address ` must be valid hex addresses.
186+ - An allowance override requires a valid ` spender_address ` .
187+ - At least one of ` balance ` / ` allowance ` must be set.
188+ - ` balance ` / ` allowance ` must be non-negative and fit in a ` uint256 ` .
189+ - ` balance_slot ` is required when ` balance ` is set, and ` allowance_slot ` is
190+ required when ` allowance ` is set. ERC20 storage layout is not standardized —
191+ OpenZeppelin uses ` _balances ` at slot 0 / ` _allowances ` at slot 1, USDC
192+ (FiatToken) uses 9/10, others differ — so there is no safe default and a
193+ missing slot is a validation error (a guessed slot would silently seed the
194+ wrong storage word). Look up your token's layout (see the
195+ [ table below] ( #common-token-storage-slots ) ); if unsure, send several overrides
196+ for the same token, one per candidate slot.
126197
127198## Example Use Case: Uniswap Swap
128199
0 commit comments