diff --git a/crates/rpc/src/simulate.rs b/crates/rpc/src/simulate.rs
index e48c8b564..fc5319df5 100644
--- a/crates/rpc/src/simulate.rs
+++ b/crates/rpc/src/simulate.rs
@@ -26,7 +26,7 @@ use revm::{
use revm_primitives::TxKind;
use serde::{Deserialize, Serialize};
use std::{
- collections::{BTreeMap, HashSet},
+ collections::{BTreeMap, BTreeSet, HashSet},
fmt::Debug,
num::NonZeroUsize,
sync::{Arc, Mutex},
@@ -162,6 +162,38 @@ pub enum SimulationStatus {
Revert,
}
+/// A safety-relevant effect detected during simulation.
+///
+/// Warning types describe objective state changes only. They do not classify
+/// the destination, asset, or operation as malicious.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
+pub enum SimulationWarningType {
+ UnlimitedErc20Approval,
+ OperatorApprovalForAll,
+ AccountOwnershipChange,
+ AccountModuleChange,
+ AccountProxyUpgrade,
+ AccountSelfDestruct,
+ AccountAuthorization,
+}
+
+/// Machine-readable warning emitted for a safety-relevant simulation effect.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SimulationWarning {
+ #[serde(rename = "type")]
+ pub warning_type: SimulationWarningType,
+ /// Smart account affected by the warning.
+ pub account: Address,
+ /// Spender or operator receiving authority, when applicable.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub counterparty: Option
,
+ /// Token contract affected by an approval, when applicable.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub asset: Option,
+}
+
/// Full response for `simulate_unsignedUserOp`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -184,9 +216,9 @@ pub struct SimulateUnsignedUserOpResult {
// BTreeMap so JSON key order is deterministic — snapshot tests, response
// signing, and debugging all benefit from stable ordering.
pub contract_management: BTreeMap>,
- /// Warning generation is not yet implemented. Always serialized as `[]`;
- /// reserved so callers can rely on the field being present.
- pub warnings: Vec,
+ /// Deterministic warnings for safety-relevant effects on the simulated
+ /// smart account. Empty when the simulation reverts.
+ pub warnings: Vec,
}
// ═══════════════════════════════════════════════════════════════════════════════
@@ -818,6 +850,12 @@ where
&logs,
&result_and_state.state,
);
+ let warnings = generate_simulation_warnings(
+ status,
+ request.sender,
+ &exposure_changes,
+ &contract_management,
+ );
Ok(SimulateUnsignedUserOpResult {
status,
@@ -828,11 +866,110 @@ where
exposure_changes,
trace,
contract_management,
- warnings: vec![],
+ warnings,
})
}
}
+// ═══════════════════════════════════════════════════════════════════════════════
+// Safety warnings
+// ═══════════════════════════════════════════════════════════════════════════════
+
+/// Returns safety-relevant effects on the simulated smart account.
+///
+/// Approval warnings reflect the final approval event for each
+/// `(asset, counterparty, approval kind)` tuple. This avoids warning about an
+/// unlimited approval that is reduced or revoked later in the same operation.
+pub fn generate_simulation_warnings(
+ status: SimulationStatus,
+ sender: Address,
+ exposure_changes: &[ExposureChange],
+ contract_management: &BTreeMap>,
+) -> Vec {
+ if status == SimulationStatus::Revert {
+ return Vec::new();
+ }
+
+ // `true` identifies ApprovalForAll events. A revocation has
+ // `is_approved_for_all == false`, so recognize its zero-value NFT form as
+ // the same approval kind to let it replace an earlier grant.
+ let mut final_approvals = BTreeMap::new();
+ for change in exposure_changes
+ .iter()
+ .filter(|change| change.owner == sender)
+ {
+ let operator_approval = change.is_approved_for_all
+ || (matches!(
+ change.asset.asset_type,
+ AssetType::Erc721 | AssetType::Erc1155
+ ) && change.raw_amount == "0");
+ final_approvals.insert(
+ (change.asset.address, change.spender, operator_approval),
+ change,
+ );
+ }
+
+ let mut warnings = BTreeSet::new();
+ let unlimited_approval = U256::MAX.to_string();
+
+ for ((asset, counterparty, operator_approval), change) in final_approvals {
+ let warning_type = if operator_approval {
+ change
+ .is_approved_for_all
+ .then_some(SimulationWarningType::OperatorApprovalForAll)
+ } else if change.asset.asset_type == AssetType::Erc20
+ && change.raw_amount == unlimited_approval
+ {
+ Some(SimulationWarningType::UnlimitedErc20Approval)
+ } else {
+ None
+ };
+
+ if let Some(warning_type) = warning_type {
+ warnings.insert(SimulationWarning {
+ warning_type,
+ account: sender,
+ counterparty: Some(counterparty),
+ asset: Some(asset),
+ });
+ }
+ }
+
+ if let Some(actions) = contract_management.get(&sender) {
+ for action in actions {
+ let warning_type = match action.action_type {
+ ContractManagementType::ContractCreation => None,
+ ContractManagementType::SelfDestruct => {
+ Some(SimulationWarningType::AccountSelfDestruct)
+ }
+ ContractManagementType::ProxyUpgrade => {
+ Some(SimulationWarningType::AccountProxyUpgrade)
+ }
+ ContractManagementType::OwnershipChange => {
+ Some(SimulationWarningType::AccountOwnershipChange)
+ }
+ ContractManagementType::ModuleChange => {
+ Some(SimulationWarningType::AccountModuleChange)
+ }
+ ContractManagementType::Authorization => {
+ Some(SimulationWarningType::AccountAuthorization)
+ }
+ };
+
+ if let Some(warning_type) = warning_type {
+ warnings.insert(SimulationWarning {
+ warning_type,
+ account: sender,
+ counterparty: None,
+ asset: None,
+ });
+ }
+ }
+ }
+
+ warnings.into_iter().collect()
+}
+
// ═══════════════════════════════════════════════════════════════════════════════
// Log parsing — asset changes
// ═══════════════════════════════════════════════════════════════════════════════
@@ -1674,6 +1811,235 @@ mod tests {
assert_eq!(asset.asset_type, AssetType::Erc1155);
}
+ fn exposure_change(
+ owner: Address,
+ spender: Address,
+ asset: Address,
+ asset_type: AssetType,
+ raw_amount: impl Into,
+ is_approved_for_all: bool,
+ ) -> ExposureChange {
+ ExposureChange {
+ owner,
+ spender,
+ raw_amount: raw_amount.into(),
+ is_approved_for_all,
+ asset: AssetInfo {
+ address: asset,
+ symbol: String::new(),
+ name: String::new(),
+ decimals: 0,
+ asset_type,
+ },
+ }
+ }
+
+ #[test]
+ fn warnings_reflect_final_erc20_approval_state() {
+ let sender = Address::repeat_byte(0x11);
+ let spender = Address::repeat_byte(0x22);
+ let token = Address::repeat_byte(0x33);
+ let unlimited = U256::MAX.to_string();
+ let mut exposures = vec![
+ exposure_change(
+ sender,
+ spender,
+ token,
+ AssetType::Erc20,
+ unlimited.clone(),
+ false,
+ ),
+ exposure_change(sender, spender, token, AssetType::Erc20, "7", false),
+ ];
+
+ let warnings = generate_simulation_warnings(
+ SimulationStatus::Success,
+ sender,
+ &exposures,
+ &BTreeMap::new(),
+ );
+ assert!(
+ warnings.is_empty(),
+ "a later finite approval must clear the warning"
+ );
+
+ exposures.reverse();
+ let warnings = generate_simulation_warnings(
+ SimulationStatus::Success,
+ sender,
+ &exposures,
+ &BTreeMap::new(),
+ );
+ assert_eq!(
+ warnings,
+ vec![SimulationWarning {
+ warning_type: SimulationWarningType::UnlimitedErc20Approval,
+ account: sender,
+ counterparty: Some(spender),
+ asset: Some(token),
+ }]
+ );
+ }
+
+ #[test]
+ fn warnings_reflect_final_operator_approval_state() {
+ let sender = Address::repeat_byte(0x11);
+ let operator = Address::repeat_byte(0x22);
+ let collection = Address::repeat_byte(0x33);
+ let grant = exposure_change(
+ sender,
+ operator,
+ collection,
+ AssetType::Erc721,
+ U256::MAX.to_string(),
+ true,
+ );
+ let revoke = exposure_change(sender, operator, collection, AssetType::Erc721, "0", false);
+
+ let warnings = generate_simulation_warnings(
+ SimulationStatus::Success,
+ sender,
+ &[grant.clone(), revoke.clone()],
+ &BTreeMap::new(),
+ );
+ assert!(
+ warnings.is_empty(),
+ "a later revocation must clear the warning"
+ );
+
+ let warnings = generate_simulation_warnings(
+ SimulationStatus::Success,
+ sender,
+ &[revoke, grant],
+ &BTreeMap::new(),
+ );
+ assert_eq!(
+ warnings,
+ vec![SimulationWarning {
+ warning_type: SimulationWarningType::OperatorApprovalForAll,
+ account: sender,
+ counterparty: Some(operator),
+ asset: Some(collection),
+ }]
+ );
+ }
+
+ #[test]
+ fn warnings_include_sender_authority_changes_once() {
+ let sender = Address::repeat_byte(0x11);
+ let actions = [
+ ContractManagementType::OwnershipChange,
+ ContractManagementType::ModuleChange,
+ ContractManagementType::ProxyUpgrade,
+ ContractManagementType::SelfDestruct,
+ ContractManagementType::Authorization,
+ ContractManagementType::ContractCreation,
+ ContractManagementType::OwnershipChange,
+ ]
+ .into_iter()
+ .map(|action_type| ContractManagementAction {
+ action_type,
+ deployer_address: None,
+ })
+ .collect();
+ let contract_management = BTreeMap::from([(sender, actions)]);
+
+ let warning_types: BTreeSet<_> = generate_simulation_warnings(
+ SimulationStatus::Success,
+ sender,
+ &[],
+ &contract_management,
+ )
+ .into_iter()
+ .map(|warning| warning.warning_type)
+ .collect();
+
+ assert_eq!(
+ warning_types,
+ BTreeSet::from([
+ SimulationWarningType::AccountOwnershipChange,
+ SimulationWarningType::AccountModuleChange,
+ SimulationWarningType::AccountProxyUpgrade,
+ SimulationWarningType::AccountSelfDestruct,
+ SimulationWarningType::AccountAuthorization,
+ ])
+ );
+ }
+
+ #[test]
+ fn warnings_ignore_other_accounts_and_reverted_simulations() {
+ let sender = Address::repeat_byte(0x11);
+ let other = Address::repeat_byte(0x22);
+ let spender = Address::repeat_byte(0x33);
+ let token = Address::repeat_byte(0x44);
+ let exposure = exposure_change(
+ other,
+ spender,
+ token,
+ AssetType::Erc20,
+ U256::MAX.to_string(),
+ false,
+ );
+ let contract_management = BTreeMap::from([(
+ other,
+ vec![ContractManagementAction {
+ action_type: ContractManagementType::OwnershipChange,
+ deployer_address: None,
+ }],
+ )]);
+
+ assert!(
+ generate_simulation_warnings(
+ SimulationStatus::Success,
+ sender,
+ std::slice::from_ref(&exposure),
+ &contract_management,
+ )
+ .is_empty()
+ );
+
+ let sender_exposure = ExposureChange {
+ owner: sender,
+ ..exposure
+ };
+ let sender_management = BTreeMap::from([(
+ sender,
+ vec![ContractManagementAction {
+ action_type: ContractManagementType::OwnershipChange,
+ deployer_address: None,
+ }],
+ )]);
+ assert!(
+ generate_simulation_warnings(
+ SimulationStatus::Revert,
+ sender,
+ &[sender_exposure],
+ &sender_management,
+ )
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn simulation_warning_serialization_is_stable() {
+ let warning = SimulationWarning {
+ warning_type: SimulationWarningType::UnlimitedErc20Approval,
+ account: Address::repeat_byte(0x11),
+ counterparty: Some(Address::repeat_byte(0x22)),
+ asset: Some(Address::repeat_byte(0x33)),
+ };
+
+ assert_eq!(
+ serde_json::to_value(warning).unwrap(),
+ serde_json::json!({
+ "type": "UNLIMITED_ERC20_APPROVAL",
+ "account": "0x1111111111111111111111111111111111111111",
+ "counterparty": "0x2222222222222222222222222222222222222222",
+ "asset": "0x3333333333333333333333333333333333333333"
+ })
+ );
+ }
+
/// A malicious `Error(string)` revert whose ABI offset field is crafted so
/// that `offset + 4 + 32` wraps `usize` under release (overflow-checks
/// off), bypassing the bounds guard and indexing far out of range.