Skip to content

Simulate EIP-1271 orders at creation#4366

Open
squadgazzz wants to merge 67 commits intonew-api-simulator-cratefrom
prototype-eip1271-on-new-api
Open

Simulate EIP-1271 orders at creation#4366
squadgazzz wants to merge 67 commits intonew-api-simulator-cratefrom
prototype-eip1271-on-new-api

Conversation

@squadgazzz
Copy link
Copy Markdown
Contributor

@squadgazzz squadgazzz commented Apr 29, 2026

Problem

Same as #4355: orderbook accepts an EIP-1271 order if the signer contract's isValidSignature says yes. That single check is enough to let through Aave flashloan-style orders where the signature passes but the post-hook can never settle. We catch these later, after they're in an auction, wasting solver cycles.

A symmetric class (Euler-style) is too strict on signatures but would actually settle fine. Those are rejected today.

Fix

This PR is the successor to #4355, rebuilt on top of new-api-simulator-crate (@MartinquaXD's SettlementSimulator refactor). At order-creation time, we run the signature check and a full-order simulation concurrently, then combine the results.

Per chain, a mode config picks what to do with the combined result:

  • disabled (default): same as today. Signature check only.
  • shadow: both run, disagreements land in metrics and logs, behaviour unchanged.
  • enforce: if signature passes but simulation fails, reject with HTTP 400 Eip1271SimulationFailed.

Infra errors (RPC, timeout, Tenderly) never reject in any mode. Signature-fail cases return the same InvalidEip1271Signature as before, regardless of the simulation result.

What changed vs #4355

  • The adapter drives SettlementSimulator::new_simulation_builder() instead of the legacy OrderSimulator.
  • parameters_from_app_data handles pre/post hooks, custom wrappers, and Aave flashloan configs uniformly from the user-signed app_data. The earlier prototype injected these manually.
  • The wrapper path (WrapperConfig::Flashloan(...)) routes through the deployed FlashLoanRouter, so the validation-time simulation now reflects the real Aave flashloan flow end-to-end. The borrower-to-trader transfer concern raised in the Simulate EIP-1271 orders at creation #4355 review is handled by the user-signed pre-hook (deploy helper, fund from factory), which parameters_from_app_data includes automatically.
  • orderbook::run passes the FlashLoanRouter deployment address to SettlementSimulator::new instead of Address::ZERO, which would otherwise silently no-op the wrapper call.

Rollout

Same as #4355. Land as disabled. Flip to shadow in prod to see the matrix on real traffic. Flip to enforce once we trust the sim.

Changes

  • shared::order_validation: new Eip1271Simulating trait, Eip1271Simulator bundle (simulator + mode + timeout) threaded into OrderValidator. Validation runs signature + sim concurrently via tokio::join! with a per-call timeout, emits an eip1271_simulation_total{signature, simulation} Prometheus matrix, upgrades enforce-mode disagreements to ValidationError::SimulationFailed.
  • orderbook::eip1271_simulation: OrderSimulatorAdapter driving SettlementSimulator::new_simulation_builder() + parameters_from_app_data.
  • orderbook::run: wire the deployed FlashLoanRouter address.
  • configs: Eip1271SimulationMode enum with disabled, shadow, enforce variants.

How to test

Unit tests in shared::order_validation cover the signature × simulation matrix, enforce-mode rejection, fail-open on infra errors, the eip1271_skip_creation_validation path, and the no-simulator-configured path. configs tests deserialize the three modes.

Local-node e2e in crates/e2e/tests/e2e/eip1271_creation_simulation.rs:

  • Negative: a Safe-signed order whose app_data.protocol.wrappers points at a custom always-revert wrapper. With Eip1271SimulationMode::Enforce, the orderbook returns HTTP 400 Eip1271SimulationFailed.
  • Positive: a Safe-signed order with empty app_data is accepted.

Forked-mainnet replay in crates/simulator/tests/aave_replay.rs (gated on MAINNET_RPC_URL, otherwise skipped).

A full e2e test against a forked node isn't viable: OrderValidator::partial_validate checks valid_to against SystemTime::now(), and any historical order's valid_to is in the past on a fresh test run, so the orderbook rejects with InsufficientValidTo before the simulation ever runs. These tests therefore bypass the validator and drive SettlementSimulator directly.

  • Replay of a real Aave v3 debt-swap order pinned to the block right before settlement. Asserts the simulation succeeds.
  • Same order with flashloan.amount rewritten past Aave's WETH liquidity. Asserts the simulation reverts with execution reverted. The eth_call goes to FlashLoanRouter, which forwards to AaveBorrower, which asks the Aave Pool for the loan. Aave is what reverts (insufficient liquidity), but the revert only reaches us because the router and borrower addresses are correctly wired. If the wrapper had no-op'd against Address::ZERO the simulation would have succeeded silently.

Runs the OrderSimulator concurrently with the cheap isValidSignature
check. In shadow mode (default), logs disagreements via metrics and
structured logs but returns the cheap check's result. In enforce mode,
(cheap Pass, sim Fail) is upgraded to ValidationError::SimulationFailed;
other combinations stay unchanged. Infra errors never reject.

Covers scope from plan Tasks 3, 4 and 5: shadow-mode quadrants,
enforce-mode cases, infra/skip-flag/no-sim paths.
The Shadow/Enforce distinction is a mode variant, not a property of the
capability — the same simulator infrastructure is active in both modes.
Keeping "shadow" only where it names a mode variant, and renaming the
trait, error, mode enum, metrics, constant, config fields, and module
accordingly.
Doc comments and local variable names still described the capability
as 'shadow' — rewritten to reference the mode variant only where it
genuinely applies (config docs, test names that exercise Shadow mode,
the Shadow enum variant).
The simulator, mode, and timeout are only meaningful together. Collapsing
them into a single Option<Eip1271SimConfig> field lets the call site in
run.rs return None cleanly when order_simulation isn't configured,
instead of passing placeholder mode/timeout values that aren't read.
… to Simulator

Matches the project's convention of -ing suffix for traits
(OrderValidating/OrderValidator, SignatureValidating, BalanceFetching).
The former Eip1271SimConfig bundle becomes the concrete Eip1271Simulator
struct, and the trait it depends on becomes Eip1271Simulating.
The helper now accepts the full simulator bundle instead of three
separate (simulator, mode, timeout) args. Introduces shadow_mode_sim /
enforce_mode_sim helpers to keep test call sites tight.
The constant is only used by tests; keeping it at module scope (and
public) implied an API contract with the configs crate that doesn't
exist. configs::orderbook::default_eip1271_sim_timeout is authoritative
at runtime.
The existing EIP-1271 check is an isValidSignature call; 'cheap' was
editorializing on cost rather than describing what it does. Renaming
to 'signature' for the metric axis, outcome enum, and supporting names.

Also collapsing sim_only_total into total by adding a 'skipped' value
to the signature axis — one counter with a signature × sim matrix
covers every case the two used to cover.
…configs, logs

The Eip1271Simulator struct keeps its -Simulator suffix (matching
OrderValidator/-Validating), but everywhere sim was used as a modifier
or qualifier (enum variants, error type, metric subsystem/labels,
config fields, log messages, test names) it now reads as simulation.
Operators can now opt out of the simulation at order creation on a
per-chain basis without giving up the /debug/simulation endpoint. The
shared mode enum stays binary (Shadow/Enforce); Disabled translates to
None for OrderValidator at the wiring layer. The OrderSimulator is
still constructed for the debug endpoint.

Also removed the redundant impls_trait compile-check test in
eip1271_simulation.rs — the impl block above already enforces that at
compile time.
Mock both the signature validator and the simulator with times(0) and
submit an Eip712 (EOA) order. Catches a regression where the sim is
accidentally wired to run for non-1271 orders.
The seven near-identical tests covering every (signature, simulation,
mode) combination collapsed into one table-driven test with a single
mock-driven helper. Failure messages include a label per row so any
regression still pinpoints the failing cell.

Also inlined the now-unused enforce_mode_simulator helper and replaced
shadow_mode_simulator with a general simulator_with_mode.
- Move simulation_time histogram timer inside simulation_fut so it no
  longer captures the max of sig-check + simulation latency.
- Warn-level (not info) for simulation failures in the
  eip1271_skip_creation_validation path, matching the normal path.
- Drop Default derive on the shared Eip1271SimulationMode since the
  operational default lives in configs (Disabled) and no code reaches
  for it. Updated the doc to clarify the split.
- New configs test asserting "shadow" deserializes to the Shadow
  variant.
@squadgazzz squadgazzz changed the title Prototype eip1271 on new api Simulate EIP-1271 orders at creation Apr 30, 2026
squadgazzz added 4 commits May 5, 2026 10:39
Wires the call sites that the merge from main left broken:

- `SettlementSimulator::new` now takes `native_token` and `max_gas_limit`.
- `Prices::Limit` is gone, set per-order via `Order::fill_at(_, PriceEncoding::LimitPrice)`.
- `add_order` -> `add_orders`, `with_override` -> `with_overrides`.

Also collapses the two `SettlementSimulator` instances in `orderbook/run.rs` into one shared by the EIP-1271 adapter and the orderbook. Side-effect: the orderbook's order simulator now gets the real FlashLoanRouter address instead of the zero default the second build site was passing.
# Conflicts:
#	crates/e2e/tests/e2e/malformed_requests.rs
#	crates/orderbook/src/run.rs
- Restore the single shared SettlementSimulator that 9338743
  introduced. The merge brought back the old two-block layout where
  the eip1271 path used unwrap_or_default() for the flashloan router,
  silently falling back to the zero address on chains without a
  deployment.
- Update simulator call sites to the new API in the base branch:
  add_orders -> with_orders, with_overrides([BuyTokensForBuffers]) ->
  provide_sufficient_buy_tokens(). Affects orderbook eip1271_simulation
  and the aave_replay test.
- Restore the "Invalid kind enum value" comment on the http_validation
  test, which the merge replaced with text that did not match the body.
@squadgazzz squadgazzz marked this pull request as ready for review May 5, 2026 16:38
@squadgazzz squadgazzz requested a review from a team as a code owner May 5, 2026 16:38
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces EIP-1271 order simulation at creation time, allowing the orderbook to verify that orders with smart contract signatures will successfully settle. It adds configuration for simulation modes (Shadow, Enforce, Disabled) and timeouts, implements an OrderSimulatorAdapter using the existing SettlementSimulator, and integrates this logic into the OrderValidator. New E2E and replay tests are included to verify the simulation behavior and its impact on order acceptance. No critical issues were found, and I have no feedback to provide.

Copy link
Copy Markdown
Contributor

@MartinquaXD MartinquaXD left a comment

Choose a reason for hiding this comment

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

I still have to formulate a more helpful feedback but this seems like an insane amount of code for optionally running some extra check and comparing results.

Other than that all of the wording is eip1271 specific when long term the new logic is supposed be used for all orders.


/// Mode for the EIP-1271 order simulation.
#[serde(default)]
pub eip1271_simulation_mode: Eip1271SimulationMode,
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 new simulation logic is technically not tied to EIP 1271.

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.

Yeah, sorry about that, it probably was changed at some point and I forgot to update the naming and comments.


#[test]
fn parses_simulation_mode_default() {
let toml = r#"gas-limit = "0x1000000""#;
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: Odd to use hex numbers here. All other instances of gas-limit configurations use decimal in the code base.

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.

probably makes sense to rename to order_simulation as this handles every aspect of the order.

.map_err(|err| Eip1271SimulationError::Infra(anyhow!(err).context("build")))?;

let report = inputs
.simulate_with_tenderly_report()
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 think running a full tenderly simulation for every placed order is too costly and slow.

Comment on lines +69 to +72
/// Defined here rather than in `crates/simulator` because `OrderValidator`
/// cannot depend on `orderbook`, where the concrete implementation lives.
/// To be removed once the `simulator` crate's API can be depended on
/// directly.
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 not be a doc comment.

}
}

fn build_preview_order_for_sim(
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.

This function doesn't seem to carry its weight.

Comment on lines +64 to +73
/// Mode for the EIP-1271 order simulation.
#[serde(default)]
pub eip1271_simulation_mode: Eip1271SimulationMode,

/// Per-call timeout for the EIP-1271 order simulation.
#[serde(
default = "default_eip1271_simulation_timeout",
with = "humantime_serde"
)]
pub eip1271_simulation_timeout: Duration,
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.

as is, is ok, but the prefix here hints me that it could probably be

eip1271_simulation: {mode, timeout}

Comment on lines +138 to +143
let mut orderbook_config = configs::orderbook::Configuration::test_default();
orderbook_config
.order_simulation
.as_mut()
.expect("test_default enables order_simulation")
.eip1271_simulation_mode = Eip1271SimulationMode::Enforce;
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.

If OrderSimulationConfig: TestDefault you could use destructuring here

Configuration {
    order_simulation: OrderSimulationConfig {
		eip1271_simulation_mode: Eip1271SimulationMode::Enforce,
	    ..TestDefault::test_default()
	}
    ..TestDefault::test_default()
}

no mut, no expect

"version": "1.14.0"
}"#;

/// Returns `app_data` minified with keys sorted alphabetically. The output
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'm curious how do you make sure that "with keys sorted alphabetically" is always the case

let _timer = Eip1271SimulationMetrics::get()
.simulation_time
.start_timer();
match tokio::time::timeout(timeout, sim.simulate(order, full_app_data)).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.

nit: split the timeout out

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.

wdym?

}

#[tokio::test]
async fn no_simulator_configured_preserves_existing_behaviour() {
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.

"existing behaviour" is vague and the meaning can change overtime

eip1271_skip_creation_validation,
Default::default(),
HooksTrampoline::Instance::new(
Address::from([0xcf; 20]),
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.

Suggested change
Address::from([0xcf; 20]),
Address::repeat_byte(0xcf),

Comment on lines +641 to +648
/// Two paths, depending on the `eip1271_skip_creation_validation` flag:
///
/// - **Skipped**: the cheap `isValidSignature` check is bypassed by the
/// operator, and we return a `verification_gas_limit` of `0` (no gas was
/// spent on-chain verifying the signature). If the optional
/// `Eip1271Simulator` is configured, the full simulation still runs for
/// observability only and can never reject.
/// - **Not skipped**: delegates to `run_eip1271_with_signature_check`.
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.

Suggested change
/// Two paths, depending on the `eip1271_skip_creation_validation` flag:
///
/// - **Skipped**: the cheap `isValidSignature` check is bypassed by the
/// operator, and we return a `verification_gas_limit` of `0` (no gas was
/// spent on-chain verifying the signature). If the optional
/// `Eip1271Simulator` is configured, the full simulation still runs for
/// observability only and can never reject.
/// - **Not skipped**: delegates to `run_eip1271_with_signature_check`.
/// When the `eip1271_skip_creation_validation` flag is:
///
/// - `true`: the cheap `isValidSignature` check is bypassed by the
/// operator, and we return a `verification_gas_limit` of `0` (no gas was
/// spent on-chain verifying the signature). If the optional
/// `Eip1271Simulator` is configured, the full simulation still runs for
/// observability only and can never reject.
/// - `false`: delegates to `run_eip1271_with_signature_check`.

Furthermore, turning the vars and functions into doclinks (or whatever is the name) would help doc navigation

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants