Skip to content

Commit 14ddfd7

Browse files
chrisli30Antrikshgwalwill-dz
authored
feat: Path B /api/notify summaries + erc20_overrides for run-node (#627)
Co-authored-by: Antriksh Gwal <antrikshgwal@gmail.com> Co-authored-by: Will Zimmerman <will@avaprotocol.org>
1 parent 679e5cc commit 14ddfd7

17 files changed

Lines changed: 1454 additions & 804 deletions

aggregator/rest/generated/types.gen.go

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

aggregator/rest/handlers_nodes.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ func (s *Server) RunNode(ctx echo.Context) error {
5454
}
5555
req.InputVariables = converted
5656
}
57+
if body.Erc20Overrides != nil {
58+
req.Erc20Overrides = openAPIERC20OverridesToProto(*body.Erc20Overrides)
59+
}
5760

5861
resp, err := s.engine.RunNodeImmediatelyRPCWithContext(ctx.Request().Context(), user, req)
5962
if err != nil {
@@ -62,6 +65,50 @@ func (s *Server) RunNode(ctx echo.Context) error {
6265
return ctx.JSON(http.StatusOK, runNodeRespToOpenAPI(resp))
6366
}
6467

68+
// openAPIERC20OverridesToProto maps the REST ERC20StateOverride list onto the
69+
// proto representation consumed by RunNodeImmediately. Slot indices are widened
70+
// from the spec's int64 to the proto's uint64. A negative slot is invalid (a
71+
// storage slot is a non-negative index), so it is treated as unset rather than
72+
// silently coerced to slot 0 — and because the engine requires an explicit slot
73+
// whenever the corresponding balance/allowance is set, a missing or negative
74+
// slot surfaces as a clear validation error instead of a wrong-slot seed. The
75+
// engine validates addresses/values.
76+
func openAPIERC20OverridesToProto(in []generated.ERC20StateOverride) []*avsproto.ERC20StateOverride {
77+
if len(in) == 0 {
78+
return nil
79+
}
80+
out := make([]*avsproto.ERC20StateOverride, 0, len(in))
81+
for _, o := range in {
82+
po := &avsproto.ERC20StateOverride{
83+
TokenAddress: string(o.TokenAddress),
84+
OwnerAddress: string(o.OwnerAddress),
85+
Balance: o.Balance,
86+
Allowance: o.Allowance,
87+
}
88+
if o.SpenderAddress != nil {
89+
s := string(*o.SpenderAddress)
90+
po.SpenderAddress = &s
91+
}
92+
po.BalanceSlot = nonNegativeSlotPtr(o.BalanceSlot)
93+
po.AllowanceSlot = nonNegativeSlotPtr(o.AllowanceSlot)
94+
out = append(out, po)
95+
}
96+
return out
97+
}
98+
99+
// nonNegativeSlotPtr widens a spec int64 storage slot to the proto's uint64,
100+
// returning nil when the slot is absent or negative. A nil slot is then
101+
// rejected by the engine (an explicit slot is required when balance/allowance
102+
// is set), so a negative value surfaces as a validation error rather than a
103+
// silent slot-0 seed.
104+
func nonNegativeSlotPtr(v *int64) *uint64 {
105+
if v == nil || *v < 0 {
106+
return nil
107+
}
108+
u := uint64(*v)
109+
return &u
110+
}
111+
65112
// runNodeRespToOpenAPI maps the engine RunNodeWithInputsResp to the
66113
// OpenAPI RunNodeResponse. The per-type OutputData oneof is flattened
67114
// to a generic map[string]interface{} via protojson — the SDK consumes

api/openapi.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1427,6 +1427,46 @@ components:
14271427
node: { $ref: '#/components/schemas/Node' }
14281428
inputVariables: { $ref: '#/components/schemas/InputVariables' }
14291429
chainId: { $ref: '#/components/schemas/ChainId' }
1430+
erc20Overrides:
1431+
type: array
1432+
description: >
1433+
Optional ERC20 balance/allowance state overrides applied only during
1434+
this isolated node simulation. Lets callers seed token balances and
1435+
approvals so contract-write simulations (e.g. Uniswap swaps) don't
1436+
revert with "transfer amount exceeds allowance/balance" before the
1437+
approval/funding transactions have been run. Simulation-only: a
1438+
real-execution request (isSimulated=false) that sets these is rejected
1439+
with an error, never silently ignored.
1440+
items: { $ref: '#/components/schemas/ERC20StateOverride' }
1441+
1442+
ERC20StateOverride:
1443+
type: object
1444+
required: [tokenAddress, ownerAddress]
1445+
description: >
1446+
Seeds a token's balanceOf / allowance storage slots for a single
1447+
simulation. balanceOf[owner] lives at keccak256(abi.encode(owner,
1448+
balanceSlot)); allowance[owner][spender] at keccak256(abi.encode(spender,
1449+
keccak256(abi.encode(owner, allowanceSlot)))).
1450+
properties:
1451+
tokenAddress: { $ref: '#/components/schemas/EthereumAddress' }
1452+
ownerAddress: { $ref: '#/components/schemas/EthereumAddress' }
1453+
spenderAddress: { $ref: '#/components/schemas/EthereumAddress' }
1454+
balance:
1455+
type: string
1456+
description: Balance override (hex 0x… or decimal string).
1457+
allowance:
1458+
type: string
1459+
description: Allowance override (hex 0x… or decimal string).
1460+
balanceSlot:
1461+
type: integer
1462+
format: int64
1463+
minimum: 0
1464+
description: 'Storage slot for the balanceOf mapping. Required when balance is set; ERC20 storage layout varies per token (OpenZeppelin 0, USDC FiatToken 9).'
1465+
allowanceSlot:
1466+
type: integer
1467+
format: int64
1468+
minimum: 0
1469+
description: 'Storage slot for the allowance mapping. Required when allowance is set; ERC20 storage layout varies per token (OpenZeppelin 1, USDC FiatToken 10).'
14301470

14311471
RunNodeResponse:
14321472
type: object

core/taskengine/TENDERLY_STATE_OVERRIDES.md

Lines changed: 103 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,30 @@ When simulating contract writes involving ERC20 tokens (like Uniswap swaps), you
88

99
This 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

1330
ERC20 tokens use Solidity mappings to store balances and allowances:
1431

1532
```solidity
1633
mapping(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))
4360
Where:
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

100121
Where:
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

Comments
 (0)