KeeperHub / KeeperHub/keeperhub
fix: simulate-sequence's state-overrides fallback traces calls against raw state, dropping earlier calls' writes
- Dominant language
- TypeScript
- Stars
- 24
- Forks
- 93
- Avg merge
- 1d 8h
- Merged PRs (30d)
- 266
Description
## Where
`lib/execute/simulate-sequence.ts`, `runWithStateOverrides` — the fallback path for chains whose RPC does not implement `eth_simulateV1`, introduced by #2452 (#2427).
For every call in the sequence, the loop runs:
1. `eth_call` and `eth_estimateGas` **with** `stateAtThisCall` (the accumulated state overrides from all earlier calls) — correct.
2. `debug_traceCall` with `prestateTracer` + `diffMode: true` to produce the state diff that seeds the next call — **without** any overrides:
```ts
provider.send("debug_traceCall", [
tx,
"latest",
{ tracer: "prestateTracer", tracerConfig: { diffMode: true } },
])
```
So the diff is computed against the raw `latest` chain state, not against the state the call was actually simulated against.
## Reason
`debug_traceCall` accepts `stateOverrides` in its options object (same per-account shape as `eth_call`'s third argument; geth `StateOverride`), so tracing against the accumulated state is supported. Without it:
- Any call that only succeeds because an earlier call set up state (approve before transferFrom, set-config before use, ...) **reverts inside the trace** even though the `eth_call` directly above it (run with the overrides) succeeded.
- Its `post` diff is then empty or wrong, `mergeDiffIntoOverrides` merges nothing, and every later call silently answers against state that lacks the earlier calls' writes.
The answer is internally inconsistent: the sequence reports call N as success (from the overridden `eth_call`) while not carrying call N's state into call N+1. That is exactly the failure mode this fallback exists to avoid — the feature's commit message says a node offering neither mechanism should report the later calls as unavailable "rather than quietly answering against latest state".
## Scope
- Fallback path only (chains without `eth_simulateV1`); the primary path is unaffected.
- Only from the 2nd call onward: the first call has nothing to carry, and a 2-call sequence never consumes the last call's diff — so the canonical approve→deposit pair answers correctly today.
- 3+ call sequences where an intermediate call depends on earlier state are wrong (e.g. approve → transferFrom → read balance: the read returns the pre-transfer balance). `MAX_SEQUENCE_CALLS` is 10, so this is reachable through `POST /api/execute/contract-call` with `simulate: true` and `calls[]`.
## Proposed fix
Pass the same snapshot to the trace:
```ts
{
tracer: "prestateTracer",
tracerConfig: { diffMode: true },
...(Object.keys(stateAtThisCall).length > 0 && { stateOverrides: stateAtThisCall }),
}
```
If a node that supports `debug_traceCall` rejects `stateOverrides`, the existing catch already degrades the remaining calls to `unavailable` — better than state-blind answers.
## Test
Assert in `tests/unit/execute-simulate-sequence.test.ts` that the second `debug_traceCall` request carries the first call's accumulated `stateOverrides`, while the first request sends none.
Contributor guide
Research direction
Start in lib/execute/simulate-sequence.ts at runWithStateOverrides and run tests/unit/execute-simulate-sequence.test.ts. Read the fallback loop and existing trace request handling, then run the focused test. Done means the first debug_traceCall has no overrides, the second carries the first call's accumulated overrides, and the test passes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- blockchain
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100