feat(simulator): opt-in state-override stream for pAMM-aware settlement simulation#4606
feat(simulator): opt-in state-override stream for pAMM-aware settlement simulation#4606louisponet wants to merge 1 commit into
Conversation
…nt simulation
Add a generic, fully opt-in "simulation state-override stream" to the CoW
driver so pAMM and other live-quote venues are simulated against their current
in-memory state rather than stale previous-block state. The wire format is the
standard Ethereum State Override Set (nothing Titan-branded in code); the Titan
endpoint appears only in example.toml comments. When the config section is
absent, no task is spawned and all RPC override parameters are omitted —
byte-identical to today's behavior.
Stream module (crates/simulator/src/state_override_stream.rs):
- Background tokio task connects to a websocket, parses per-venue frames
(camelCase, forward-compatible flatten of unknown metadata keys), and
flattens them directly into one StateOverride (latest frame wins per
account, matching the builder's pamm_quote_stream accumulator design).
- Publishes via tokio::sync::watch; SimulationOverrides::current() gates on
staleness (local receive time, not the producer wall-clock frame timestamp)
and block lag, returning None so callers omit the RPC params entirely.
- Reconnect with exponential backoff (250ms -> 15s cap). Prometheus metrics
for frames, parse failures, reconnects, venue/account count, and
simulations_with_overrides{applied,stale}.
Next-block pinning:
- current() also returns BlockOverrides { number: head+1, time:
head_timestamp + chain block_time }. The frame timestamp is the producer
wall clock, not block time, so the next-block timestamp is derived from the
head block's timestamp plus Chain::block_time_in_ms().
Backends:
- simulator/ethereum: estimate_gas gains Option<StateOverride> +
Option<BlockOverrides> via overrides_opt/with_block_overrides_opt (None
omits the RPC params). Requires a node supporting eth_estimateGas
state/block overrides (geth >=1.13, reth, nethermind, recent erigon, anvil).
- simulator/tenderly: simulate gains Option<StateOverride>; nonce is stripped
inline (Tenderly's StateObject::try_from rejects it, pAMM frames always
carry it, and contract nonces only matter for CREATE). Full-state overrides
are left for try_from to reject.
- access_list() unchanged (1-wei ETH probe never touches pAMM state;
create_access_list has no override support).
Simulator integration (crates/simulator/src/lib.rs):
- Simulator gains a simulation_overrides field + set_simulation_overrides
setter; gas() fetches current(head_number, head_timestamp) and forwards
state overrides to Tenderly (which already simulates the next block) and
both state + block overrides to the Ethereum backend.
Trade verifier (crates/price-estimation):
- configs/price_estimation.rs: optional state_override_stream field.
- simulator/simulation_builder.rs: SettlementSimulator gains an optional
SimulationOverrides handle.
- simulator/encoding.rs: finish_simulation_builder appends streamed overrides
as AccountOverrideRequest::Custom so build_final_state_overrides' merge
arbitrates against the verifier's own; block overrides forwarded into
EthCallInputs and applied in simulate() (eth_call) and simulation_report()
(debug_traceCall). Block overrides apply only for Block::Latest.
- factory.rs: spawn one stream per service at factory time; the shared
SettlementSimulator covers both the trade verifier and the orderbook
order-creation simulator.
Wiring + config:
- driver/run.rs: spawn the stream when [simulator.state-override-stream] is
present, passing the chain block time.
- configs/simulator.rs: StateOverrideStream struct (ws-url, max-age 3s,
max-block-lag 1) with deserialize tests.
- driver/example.toml: commented example config.
Tests:
- 9 hermetic unit tests: frame parsing (verbatim Titan sample + real captured
frames from wss://eu.rpc.titanbuilder.xyz), per-account flattening across
venues, latest-wins conflict handling, all three staleness gates, and an
in-process WebSocket server reconnect/accumulation test.
- 2 #[ignore]'d anvil integration tests: deploy a contract reverting unless
storage slot 0 is nonzero, assert estimate_gas reverts without the override
and succeeds with it, and that block overrides are accepted.
|
All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
|
recheck |
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
jmg-duarte
left a comment
There was a problem hiding this comment.
Didn't finish reviewing yet but this is the lowest hanging fruit
| tracing = { workspace = true } | ||
| url = { workspace = true } | ||
| tokio = { workspace = true, features = ["sync", "time", "rt", "macros"] } | ||
| tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } |
There was a problem hiding this comment.
| tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } | |
| tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } |
and add
tokio-tungstenite = { version = "0.28", default-features = false }
in the top level Cargo.toml
| fn default_override_max_age() -> Duration { | ||
| Duration::from_secs(3) | ||
| } | ||
|
|
||
| fn default_override_max_block_lag() -> u64 { | ||
| 1 |
There was a problem hiding this comment.
nit
| fn default_override_max_age() -> Duration { | |
| Duration::from_secs(3) | |
| } | |
| fn default_override_max_block_lag() -> u64 { | |
| 1 | |
| const fn default_override_max_age() -> Duration { | |
| Duration::from_secs(3) | |
| } | |
| const fn default_override_max_block_lag() -> u64 { | |
| 1 |
MartinquaXD
left a comment
There was a problem hiding this comment.
The overall idea of the code (manage updates in background task and expose latest state via watcher) makes a lot of sense to me.
Just seems like the current PR could be simplified further.
| current_block_number: u64, | ||
| current_block_timestamp: u64, |
There was a problem hiding this comment.
Instead of forcing the caller to tell us the current block the SimulationOverrides could also store its own CurrentBlockWatcher.
And more importantly since updates get streamed on a sub-block interval does it even make sense to think in terms of blocks here?
| impl SimulationsWithOverridesExt for IntCounterVec { | ||
| fn inc_by_result(&self, result: &str) { | ||
| self.with_label_values(&[result]).inc(); | ||
| } | ||
| } |
There was a problem hiding this comment.
This implementation is a footgun because it allows you to call this on metrics with an unexpected set of labels.
| venues: frame_venues, | ||
| } = frame; | ||
|
|
||
| for (key, value) in frame_venues { |
There was a problem hiding this comment.
Instead of parsing the data here why not configure serde to already deserialize the JSON string into those types?
| } | ||
|
|
||
| Metrics::get().frames_received.inc(); | ||
| match serde_json::from_str::<Frame>(message.to_text().unwrap_or("")) { |
There was a problem hiding this comment.
| match serde_json::from_str::<Frame>(message.to_text().unwrap_or("")) { | |
| match serde_json::from_slice::<Frame>(&message.into_data()) { |
| } | ||
|
|
||
| #[derive(Clone)] | ||
| pub struct SimulationOverrides { |
There was a problem hiding this comment.
Let's have SimulationOverrides(Arc<Inner>) where Inner contains the data the struct holds now.
| #[ignore = "requires anvil on PATH (or ANVIL_COMMAND set)"] | ||
| async fn estimate_gas_applies_streamed_state_override() { |
There was a problem hiding this comment.
If you move this into the e2e crate there are already a bunch of helper function for setting stuff up.
Also if you call the test local_node_<TESTNAME> and #[ignore] it, CI will automatically run the test with a docker image that has anvil installed.
Problem
Titan's propAMMs (pAMMs) hold their live prices in maker quote streams. The CoW driver simulates every solver solution before scoring (gas estimation via
eth_estimateGasagainst a plain node), and the trade verifier simulates proposed quotes the same way — both without any state overrides.Titan publishes a public pAMM state stream: eth_call-style state-override sets over websocket (
wss://{eu,ap,us}.rpc.titanbuilder.xyz/ws/pamm_quote_stream, no auth, ~100–400 ms cadence), plus an HTTP snapshot methodtitan_getPammStateOverrides. Without applying the overrides, a node sim only sees the pAMM's last-committed-block storage.Ref: https://docs.titanbuilder.xyz/propamms/takers#pamm-state-stream
Approach
A generic, fully opt-in "simulation state-override stream" feature. The wire format is exactly Ethereum's standard State Override Set, so nothing Titan-branded lives in code — the Titan endpoint appears only in
example.tomlcomments and this PR description. When the config section is absent: no task is spawned, and the RPC override parameters are omitted entirely — byte-identical behavior to today.The stream module (
crates/simulator/src/state_override_stream.rs) spawns a background tokio task that connects to the websocket, parses per-venue frames, and flattens them into oneStateOverride(latest frame wins per account, matching the builder'spamm_quote_streamaccumulator design). It publishes viatokio::sync::watch;SimulationOverrides::current()gates on staleness (local receive time, not the producer wall-clock frame timestamp) and block lag, returningNoneso callers omit the RPC params entirely on a stale/unconfigured stream. Reconnect uses exponential backoff (250 ms → 15 s cap). Prometheus metrics cover frames, parse failures, reconnects, account count, andsimulations_with_overrides{applied,stale}.Next-block pinning
current()also returnsBlockOverrides { number: head+1, time: head_timestamp + chain_block_time }. The stream frame's top-leveltimestampis the producer wall clock (nanoseconds), not block time, so the next-block timestamp is derived from the current head block's timestamp plusChain::block_time_in_ms()instead. This pins the simulation to the next block — the block context the streamed venue state is intended for.Backends
estimate_gasgainsOption<StateOverride>+Option<BlockOverrides>viaoverrides_opt/with_block_overrides_opt(Noneomits the RPC params). Requires a node supportingeth_estimateGasstate/block overrides (geth ≥1.13, reth, nethermind, recent erigon, anvil).simulategainsOption<StateOverride>; nonce is stripped inline (Tenderly'sStateObject::try_fromrejects it, pAMM frames always carry it, and contract nonces only matter for CREATE). Full-stateoverrides are left fortry_fromto reject.access_list(): unchanged — the access-list probe is a 1-wei ETH transfer that never touches pAMM state, andcreate_access_listhas no override support.Trade verifier
SettlementSimulatorgains an optionalSimulationOverrideshandle.finish_simulation_builderappends streamed overrides asAccountOverrideRequest::Customsobuild_final_state_overrides' existing merge + conflict handling arbitrates against the verifier's own overrides (solver code, trader balance/approval slots). Block overrides are forwarded intoEthCallInputsand applied insimulate()(eth_call) andsimulation_report()(debug_traceCall). The sharedSettlementSimulatorcovers both the trade verifier and the orderbook order-creation simulator.Config
The same optional section is added to the price-estimation config for the trade-verifier path.
Tests
wss://eu.rpc.titanbuilder.xyz/ws/pamm_quote_stream), per-account flattening across venues, latest-wins conflict handling, all three staleness gates, and an in-process WebSocket server reconnect/accumulation test.#[ignore]'d anvil integration tests: deploy a contract reverting unless storage slot 0 is nonzero, assertestimate_gasreverts without the override and succeeds with it, and that block overrides are accepted.