From 238731202083bef203399ac751a1bd39c87c45a1 Mon Sep 17 00:00:00 2001 From: Felix Leupold <1200333+fleupold@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:00:02 +0200 Subject: [PATCH 1/2] Fix 4626 classification --- crates/price-estimation/src/native/eip4626.rs | 85 +++++++++++++++---- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/crates/price-estimation/src/native/eip4626.rs b/crates/price-estimation/src/native/eip4626.rs index 1c733bc1be..225cb273a8 100644 --- a/crates/price-estimation/src/native/eip4626.rs +++ b/crates/price-estimation/src/native/eip4626.rs @@ -53,13 +53,15 @@ use { /// - per-atom factor: `1_100_000 / 10^18 == 1.1e-12` /// - if USDC's native price is `x`, then ynUSDx's is `x * 1.1e-12` /// -/// Tokens whose `asset()` call reverts are remembered in a negative cache so +/// Tokens that don't classify as usable vaults — `asset()` reverts, or it +/// succeeds but a method the pricing relies on (`decimals()`, +/// `convertToAssets()`) reverts — are remembered in a negative cache so /// subsequent requests skip the RPC and go straight to the inner estimator. pub struct Eip4626 { inner: Box, provider: AlloyProvider, - /// Addresses that are known *not* to be EIP-4626 vaults (i.e. `asset()` - /// reverted). Checked before making any RPC calls. + /// Addresses that are known *not* to be (usable) EIP-4626 vaults. Checked + /// before making any RPC calls. non_vault_tokens: DashSet
, } @@ -126,12 +128,19 @@ impl Eip4626 { } let Some((asset, vault_decimals)) = self.fetch_vault_info(token).await? else { - self.non_vault_tokens.insert(token); - metrics::non_vault_cache_size(self.non_vault_tokens.len()); + self.mark_non_vault(token); tracing::debug!(%token, "eip4626: classified as non-vault"); return Ok(None); }; - let assets = self.fetch_conversion_data(token, vault_decimals).await?; + + // Some tokens expose `asset()` yet revert here (e.g. a partial EIP-4626 + // implementation). Treat those as plain ERC-20s. + let Some(assets) = self.fetch_conversion_data(token, vault_decimals).await? else { + self.mark_non_vault(token); + tracing::debug!(%token, "eip4626: convertToAssets() reverts, classified as non-vault"); + return Ok(None); + }; + let rate = conversion_rate(assets, vault_decimals) .context("conversion rate is not representable as f64") .map_err(PriceEstimationError::EstimatorInternal)?; @@ -139,6 +148,13 @@ impl Eip4626 { Ok(Some((asset, rate))) } + /// Records `token` in the negative cache so subsequent requests skip the + /// RPC probing and delegate straight to the inner estimator. + fn mark_non_vault(&self, token: Address) { + self.non_vault_tokens.insert(token); + metrics::non_vault_cache_size(self.non_vault_tokens.len()); + } + /// Fetches the vault's underlying asset address and vault token decimals. /// /// Returns: @@ -178,11 +194,18 @@ impl Eip4626 { /// Fetches `convertToAssets(10^vault_decimals)` — how many atomic units of /// the underlying asset correspond to one full vault token. + /// + /// Returns: + /// - `Ok(Some(assets))` on success. + /// - `Ok(None)` when `convertToAssets()` reverts. The caller must not treat + /// it as a vault. + /// - `Err` on transient transport failures, so they retry instead of + /// pinning the token as non-vault. async fn fetch_conversion_data( &self, token: Address, vault_decimals: u8, - ) -> Result { + ) -> Result, PriceEstimationError> { let one_token = U256::from(10u64) .checked_pow(U256::from(vault_decimals)) .ok_or_else(|| { @@ -192,15 +215,13 @@ impl Eip4626 { })?; let vault = IERC4626::IERC4626::new(token, &self.provider); - vault - .convertToAssets(one_token) - .call() - .await - .map_err(|err| { - PriceEstimationError::EstimatorInternal(anyhow::anyhow!( - "failed to call convertToAssets() on {token}: {err}" - )) - }) + match vault.convertToAssets(one_token).call().await { + Ok(assets) => Ok(Some(assets)), + Err(err) if err.is_contract_revert() => Ok(None), + Err(err) => Err(PriceEstimationError::EstimatorInternal(anyhow::anyhow!( + "failed to call convertToAssets() on {token}: {err}" + ))), + } } } @@ -256,7 +277,7 @@ mod tests { use { super::*, crate::{HEALTHY_PRICE_ESTIMATION_TIME, native::MockNativePriceEstimating}, - alloy::providers::mock::Asserter, + alloy::{providers::mock::Asserter, sol_types::SolCall}, std::borrow::Cow, }; @@ -374,4 +395,34 @@ mod tests { .await; assert_eq!(result.unwrap(), expected_price); } + + #[tokio::test] + async fn reverting_convert_to_assets_is_treated_as_non_vault() { + let token = Address::repeat_byte(0x11); + let underlying = Address::repeat_byte(0x22); + let expected_price = 1.5; + + let mut inner = MockNativePriceEstimating::new(); + inner + .expect_estimate_native_price() + .withf(move |t, _| *t == token) + .returning(move |_, _| Box::pin(async move { Ok(expected_price) })); + + let asserter = Asserter::new(); + asserter.push_success(&IERC4626::IERC4626::assetCall::abi_encode_returns( + &underlying, + )); + asserter.push_success(&ERC20::ERC20::decimalsCall::abi_encode_returns(&6u8)); + asserter.push_failure_msg("execution reverted"); + let web3 = ethrpc::Web3::with_asserter(asserter); + + let estimator = Eip4626::new(Box::new(inner), web3.provider); + + let result = estimator + .estimate(token, HEALTHY_PRICE_ESTIMATION_TIME) + .await; + assert_eq!(result.unwrap(), expected_price); + // The failed classification is cached so we don't re-probe on-chain. + assert!(estimator.non_vault_tokens.contains(&token)); + } } From 42e5bd782060487bc23ff2f8116f6835f4851354 Mon Sep 17 00:00:00 2001 From: Felix Leupold <1200333+fleupold@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:08:41 +0200 Subject: [PATCH 2/2] minor doc comment fixes --- crates/price-estimation/src/native/eip4626.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/price-estimation/src/native/eip4626.rs b/crates/price-estimation/src/native/eip4626.rs index 225cb273a8..5a613996f3 100644 --- a/crates/price-estimation/src/native/eip4626.rs +++ b/crates/price-estimation/src/native/eip4626.rs @@ -53,10 +53,9 @@ use { /// - per-atom factor: `1_100_000 / 10^18 == 1.1e-12` /// - if USDC's native price is `x`, then ynUSDx's is `x * 1.1e-12` /// -/// Tokens that don't classify as usable vaults — `asset()` reverts, or it -/// succeeds but a method the pricing relies on (`decimals()`, -/// `convertToAssets()`) reverts — are remembered in a negative cache so -/// subsequent requests skip the RPC and go straight to the inner estimator. +/// Tokens that don't classify as usable vaults are remembered in a negative +/// cache so subsequent requests skip the RPC and go straight to the inner +/// estimator. pub struct Eip4626 { inner: Box, provider: AlloyProvider, @@ -133,7 +132,7 @@ impl Eip4626 { return Ok(None); }; - // Some tokens expose `asset()` yet revert here (e.g. a partial EIP-4626 + // Some tokens expose `asset()` yet revert here (e.g. a partial EIP-4626 // implementation). Treat those as plain ERC-20s. let Some(assets) = self.fetch_conversion_data(token, vault_decimals).await? else { self.mark_non_vault(token);