Skip to content

Fix EIP-4626 contract revert classification#4360

Merged
jmg-duarte merged 7 commits intomainfrom
jmgd/fix/4626
Apr 23, 2026
Merged

Fix EIP-4626 contract revert classification#4360
jmg-duarte merged 7 commits intomainfrom
jmgd/fix/4626

Conversation

@jmg-duarte
Copy link
Copy Markdown
Contributor

@jmg-duarte jmg-duarte commented Apr 22, 2026

Description

Testing the Euler vault tokens I found that the revert condition is a bit too strict for the Eip4626 purposes and it would think that the underlying asset was not valid because asset() failed; even though that's the expected behavior.

This PR adds a new condition for a revert caused by a lack of selector, specifically for this case.

Tested in staging, results can be seen in — https://victorialogs.dev.cow.fi/goto/ffjuyxx1tk6psc?orgId=1
native_price requests were done for 0x53afe3343f322c4189ab69e0d048efd154259419

Changes

  • More logs, helps tracing through the layer peeling
  • Less strict variant of is_contract_error, including empty reverts as contract errors

How to test

Call the native_price endpoint with a Euler vault address (ensure that staging has them enabled)

@jmg-duarte jmg-duarte changed the title Fix 4626 revert condition Fix EIP-4626 contract revert condition Apr 22, 2026
@jmg-duarte jmg-duarte added the hotfix Labels PRs that should be applied into production right away label Apr 22, 2026
@jmg-duarte jmg-duarte marked this pull request as ready for review April 22, 2026 13:04
@jmg-duarte jmg-duarte requested a review from a team as a code owner April 22, 2026 13:04
@jmg-duarte jmg-duarte changed the title Fix EIP-4626 contract revert condition Fix EIP-4626 contract revert classification Apr 22, 2026
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 the is_contract_revert method to the ContractErrorExt trait to specifically handle contract-level reverts, including those with empty data, and updates the EIP-4626 price estimator to utilize this check. Review feedback identifies a potential regression where DecodingError is no longer classified as a non-vault condition and highlights that transport errors should be explicitly mapped to ProtocolInternal to ensure the failover mechanism functions correctly.

Comment thread crates/ethrpc/src/alloy/errors.rs Outdated
|| err.code == 3
|| err.message.to_lowercase().contains("revert")
}
ContractError::ZeroData(..) => true,
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.

high

The implementation of is_contract_revert introduces a regression compared to the previous is_contract_error check. Specifically, it returns false for DecodingError, which occurs when a contract implements the expected selector but returns data that doesn't match the ABI (e.g., a non-vault contract that happens to have an asset() function returning a string). Previously, these were correctly classified as non-vaults in the EIP-4626 estimator. To maintain robustness against interface mismatches, DecodingError should be included.

Suggested change
ContractError::ZeroData(..) => true,
ContractError::ZeroData(..) | ContractError::DecodingError(..) => true,

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.

The suggested error is not a thing. UnknownFunction and UnknownSelector are, and they fit this use case

// Contract-level revert on `asset()` + working `decimals()` =
// plain ERC-20. Transient transport failures propagate so they
// retry instead of pinning the token as non-vault.
Err(err) if err.is_contract_revert() && decimals_result.is_ok() => Ok(None),
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.

high

The error handling here does not correctly distinguish between contract-level reverts and node/transport errors. When asset() fails due to a transient transport issue (e.g., timeout or connection error), is_contract_revert() returns false, and the error falls through to be wrapped in EstimatorInternal. This prevents the FallbackNativePriceEstimator from triggering a failover to a healthy node, as it only reacts to ProtocolInternal errors. Transport errors should be explicitly caught and propagated as ProtocolInternal.

Suggested change
Err(err) if err.is_contract_revert() && decimals_result.is_ok() => Ok(None),
Err(err) if err.is_contract_revert() && decimals_result.is_ok() => Ok(None),
Err(err) if err.is_node_error() => Err(PriceEstimationError::ProtocolInternal(err.into())),

@claude
Copy link
Copy Markdown

claude Bot commented Apr 22, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@squadgazzz
Copy link
Copy Markdown
Contributor

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 22, 2026

Claude encountered an error —— View job


Code review

  • Gather context
  • Read changed files
  • Analyze code
  • Post review

Summary

The core fix is sound. The root cause was that empty-data reverts (e.g. calling a missing asset() selector on a non-vault) were flowing through TransportError(ErrorResp) with as_revert_data() == None, making is_node_error() return true and is_contract_error() return false — so the old check err.is_contract_error() failed to classify them as non-vault. The new is_contract_revert() correctly identifies these by checking error code 3 and the "revert" substring in the message.

crates/ethrpc/src/alloy/errors.rs

is_contract_revert() implementation (lines 60-73): The logic is correct for the Euler vault case. The three OR conditions cover the cases well:

  • as_revert_data().is_some() — standard revert with ABI-encoded error
  • code == 3 — Geth's execution revert (the key fix for empty-data reverts)
  • message contains "revert" — fallback for non-standard nodes

ZeroData returning true is reasonable: a call returning zero bytes when an address is expected is a contract-level response, not a transport failure.

Gemini's DecodingError concern — I agree this is a valid regression. DecodingError means the contract returned data that couldn't be decoded as the expected type (e.g. a non-vault contract happens to share the asset() 4-byte selector but returns a string). Under the old is_contract_error(), this would be caught and the token classified as non-vault. Under the new is_contract_revert(), it falls to the Err(err) branch and becomes an EstimatorInternal error. The suggested fix is correct: Fix this →

// current
ContractError::ZeroData(..) => true,

// suggested
ContractError::ZeroData(..) | ContractError::DecodingError(..) => true,

crates/price-estimation/src/native/eip4626.rs

fetch_vault_info condition (line 141): The && decimals_result.is_ok() guard is actually a meaningful improvement over the old code. It prevents incorrectly caching a token as non-vault when the RPC node is unhealthy (both calls fail). Good addition.

Gemini's ProtocolInternal concern — this is a pre-existing issue, not introduced by this PR. In the old code, transport errors for asset() also fell through to EstimatorInternal (since is_contract_error() = false for true node errors). The new code preserves the same behavior here. Fixing it would be an improvement but is out of scope for this PR.

Tests

The new tests in both files are well-structured and cover the key scenarios: empty reverts, transport failures, caller-usage errors, and the full vault-chain unwrap. No issues here.

Verdict

One blocking issue: DecodingError exclusion from is_contract_revert() is a regression. The rest of the changes look correct and well-tested.

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.

Change looks reasonable to me. 👌

Comment on lines +288 to +293
/// Regression test: USDC-like terminal tokens revert their non-existent
/// `asset()` selector with *empty* revert data. The classification must
/// treat that as "non-vault" (loop terminates cleanly), not as a transport
/// failure that aborts the unwrap chain.
#[tokio::test]
async fn terminal_token_reverting_without_data_classified_as_non_vault() {
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.

Wouldn't it be nicer to make this a forked test and test with the real USDC token?
That way, if we encounter another token that behaves weirdly we could just plug that one in and see what happens.

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.

done

Copy link
Copy Markdown
Contributor

@squadgazzz squadgazzz left a comment

Choose a reason for hiding this comment

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

LGTM

Comment thread crates/ethrpc/src/alloy/errors.rs
@jmg-duarte jmg-duarte added this pull request to the merge queue Apr 23, 2026
Merged via the queue into main with commit 11bbbb7 Apr 23, 2026
21 of 22 checks passed
@jmg-duarte jmg-duarte deleted the jmgd/fix/4626 branch April 23, 2026 17:09
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

hotfix Labels PRs that should be applied into production right away

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants