Skip to content

feat(simulator): opt-in state-override stream for pAMM-aware settlement simulation#4606

Merged
MartinquaXD merged 13 commits into
cowprotocol:mainfrom
louisponet:titan-pamm-state-override-stream
Jul 16, 2026
Merged

feat(simulator): opt-in state-override stream for pAMM-aware settlement simulation#4606
MartinquaXD merged 13 commits into
cowprotocol:mainfrom
louisponet:titan-pamm-state-override-stream

Conversation

@louisponet

@louisponet louisponet commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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_estimateGas against 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 method titan_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.toml comments 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 one StateOverride (latest frame wins per account, matching the builder's pamm_quote_stream accumulator design). It 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 on a stale/unconfigured stream. Reconnect uses exponential backoff (250 ms → 15 s cap). Prometheus metrics cover frames, parse failures, reconnects, 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 stream frame's top-level timestamp is the producer wall clock (nanoseconds), not block time, so the next-block timestamp is derived from the current head block's timestamp plus Chain::block_time_in_ms() instead. This pins the simulation to the next block — the block context the streamed venue state is intended for.

Backends

  • 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).
  • 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 — the access-list probe is a 1-wei ETH transfer that never touches pAMM state, and create_access_list has no override support.

Trade verifier

SettlementSimulator gains an optional SimulationOverrides handle. finish_simulation_builder appends streamed overrides as AccountOverrideRequest::Custom so build_final_state_overrides' existing merge + conflict handling arbitrates against the verifier's own overrides (solver code, trader balance/approval slots). Block overrides are forwarded into EthCallInputs and applied in simulate() (eth_call) and simulation_report() (debug_traceCall). The shared SettlementSimulator covers both the trade verifier and the orderbook order-creation simulator.

Config

# [simulator.state-override-stream]
# ws-url = "wss://eu.rpc.titanbuilder.xyz/ws/pamm_quote_stream"
# max-age = "3s"        # ignore the snapshot if the last frame is older than this
# max-block-lag = 1     # ignore the snapshot if its block number lags head by more than this

The same optional section is added to the price-estimation config for the trade-verifier path.

Tests

  • 9 hermetic unit tests: frame parsing (verbatim Titan sample + real frames captured from 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.
  • 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.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@louisponet

louisponet commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@louisponet
louisponet marked this pull request as ready for review July 6, 2026 19:22
@louisponet
louisponet requested a review from a team as a code owner July 6, 2026 19:22
@louisponet

Copy link
Copy Markdown
Contributor Author

recheck

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@jmg-duarte jmg-duarte left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't finish reviewing yet but this is the lowest hanging fruit

Comment thread crates/simulator/Cargo.toml Outdated
Comment thread crates/configs/src/simulator.rs Outdated

@MartinquaXD MartinquaXD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +60 to +61
current_block_number: u64,
current_block_timestamp: u64,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the only reason this is kept at all is for setting it when simulating. As long as the simulator sets timestamp and blocknumber correctly to the next one, it's ok. I have removed all of this

Comment thread crates/simulator/src/state_override_stream.rs Outdated
Comment thread crates/simulator/src/state_override_stream.rs Outdated
Comment thread crates/simulator/src/state_override_stream.rs Outdated
Comment thread crates/simulator/src/state_override_stream.rs Outdated
Comment thread crates/simulator/tests/state_override_integration.rs Outdated
github-actions Bot added a commit that referenced this pull request Jul 7, 2026
@louisponet
louisponet force-pushed the titan-pamm-state-override-stream branch from 456e28e to adcc16e Compare July 8, 2026 12:52
louisponet and others added 7 commits July 16, 2026 09:42
…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.
…g check

The overrides are published for the next block, so the snapshot block must
equal the current head (lag 0). A configurable max_block_lag is a footgun
that permits applying overrides for the wrong block. Remove the config knob
and hardcode the equality check; max_age (time lag) remains the only
freshness gate.
The simulator already pins block context natively: estimate_gas uses
.pending(), the verifier sims at the head block boundary, and Tenderly
uses block+1. The override stream only needs to contribute the live pAMM
state overrides, not block number/timestamp. Remove SimulationOverrideSet,
block_time, block_overrides construction, the estimate_gas block_overrides
param, the EthCallInputs.block_overrides field, and the block-overrides
e2e test. current() now returns Option<StateOverride> directly.
@MartinquaXD
MartinquaXD force-pushed the titan-pamm-state-override-stream branch from 5dde10a to fda56ba Compare July 16, 2026 09:43

@MartinquaXD MartinquaXD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for getting the PR this far. 🙇
Since I had to check which of my comments are actually valid I already went ahead and applied them and rebased the branch to the latest state.

Comment thread crates/simulator/src/ethereum/mod.rs Outdated
Comment on lines +137 to +139
// `None` omits the RPC param entirely (byte-identical to today).
// Requires a node supporting eth_estimateGas state overrides
// (geth >=1.13, reth, nethermind, recent erigon, anvil).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this comment does not seem to add anything. The behavior of overrides_opt is already documented and the remaining code base already relies heavily on state overrides being supported.

Comment thread crates/simulator/src/encoding.rs Outdated
Comment on lines +500 to +507
&& let Some(state_overrides) = stream.current()
{
for (account, state) in state_overrides.iter() {
builder
.account_override_requests
.push(AccountOverrideRequest::Custom {
account: *account,
state: state.clone(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.current() already returns an owned value so you don't have to clone anything here.

Suggested change
&& let Some(state_overrides) = stream.current()
{
for (account, state) in state_overrides.iter() {
builder
.account_override_requests
.push(AccountOverrideRequest::Custom {
account: *account,
state: state.clone(),
&& let Some(state_overrides) = stream.current()
{
for (account, state) in state_overrides {
builder
.account_override_requests
.push(AccountOverrideRequest::Custom {
account,
state,

Comment on lines +118 to +124
#[allow(dead_code)]
#[serde(rename_all = "camelCase")]
struct Frame {
slot: Option<u64>,
block_number: Option<u64>,
#[serde(default)]
timestamp: Option<u128>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few comments at once here:

  • if a field is not use, you can just omit it from the struct, by default it will simply not be deserialized

  • instead of using #[allow(some_lint)] it's best to use #[expect(some_lint)], that way clippy will yell at you when a lint is no longer violated which forces you to keep the code up-to-date. With the other comments addressed no lint will have to be ignored, though.

  • But most importantly: since the frame already provides a timestamp why don't we use that instead of capturing our own timestamp when it arrives? Seems like this would be the better source of truth. This would require switching the timestamps to use chrono.

Comment on lines +133 to +134
struct VenueUpdate {
#[serde(rename = "stateOverride", alias = "state_override")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's keep the name adjustments consistent

Suggested change
struct VenueUpdate {
#[serde(rename = "stateOverride", alias = "state_override")]
#[serde(rename_all = "camelCase")]
struct VenueUpdate {

Comment on lines +158 to +161
while let Some(key) = map.next_key::<String>()? {
let Ok(address) = key.parse::<Address>() else {
let _: serde_json::Value = map.next_value()?;
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally wanted to suggest dropping deserialize_venue_overrides entirely but it seems like the JSON format is actually super weird. Why would one flatten metadata and real data into the same object? 😬

With the format being what it is I think there are only few things we can optimize here. We deserialize the key in &str to avoid a heap allocations and use serde::de::IgnoredAny to avoid parsing the values we don't care about.

Suggested change
while let Some(key) = map.next_key::<String>()? {
let Ok(address) = key.parse::<Address>() else {
let _: serde_json::Value = map.next_value()?;
continue;
while let Some(key) = map.next_key::<&str>()? {
let Ok(address) = key.parse::<Address>() else {
map.next_value::<serde::de::IgnoredAny>()?;
continue;
};

#[tokio::test]
#[ignore]
async fn local_node_estimate_gas_state_override() {
run_test(estimate_gas_state_override).await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapping the test inside run_test already sets up a node, tracing log filters, and makes sure only 1 test like this runs at a time.

Comment thread crates/configs/src/simulator.rs Outdated
Comment thread crates/configs/src/simulator.rs Outdated
Comment thread crates/driver/example.toml Outdated
Comment thread crates/e2e/tests/e2e/state_override.rs Outdated
Comment thread crates/e2e/tests/e2e/state_override.rs Outdated
Comment thread crates/simulator/src/state_override_stream.rs Outdated
Comment on lines +122 to +123
// unknown non-address keys (e.g. future schema additions) are skipped by
// the address parse inside the deserializer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we warn when unknown keys are being received?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. That would make our logs noisier for no benefit. If they decide to send more metadata that we actually don't care about, why spam logs about it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but what if its important? How do we learn about the new filters?
Regardless, I'm ok with no log with the spam in mind

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now I'd trust that titan or other propAMM "maintainers" inform people about breaking changes since it's in their best interest.

Comment thread crates/simulator/src/state_override_stream.rs
Comment thread crates/simulator/src/state_override_stream.rs Outdated
Comment thread crates/simulator/src/state_override_stream.rs
@MartinquaXD
MartinquaXD added this pull request to the merge queue Jul 16, 2026
Merged via the queue into cowprotocol:main with commit c2e28d7 Jul 16, 2026
22 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 16, 2026
@MartinquaXD

Copy link
Copy Markdown
Contributor

Thanks a lot for your contribution @louisponet. I'm sure solvers will be very happy about it. 🚀

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants