From 8584a1b331e1f20d2e65c1c6af771977b9e70c65 Mon Sep 17 00:00:00 2001 From: extrawurst Date: Fri, 28 Nov 2025 12:30:17 -0300 Subject: [PATCH 1/6] Fixes refunder failures on Sepolia caused by orders whose owners cannot receive ETH. When such orders are included in a batch, the entire transaction reverts with `EthTransferFailed`, blocking valid orders from being refunded. --- crates/refunder/src/refund_service.rs | 48 ++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/refunder/src/refund_service.rs b/crates/refunder/src/refund_service.rs index 7391f196ca..9848f20caf 100644 --- a/crates/refunder/src/refund_service.rs +++ b/crates/refunder/src/refund_service.rs @@ -3,6 +3,7 @@ use { alloy::{ network::TxSigner, primitives::{Address, B256, Signature, address}, + providers::Provider, }, anyhow::{Context, Result, anyhow}, contracts::alloy::CoWSwapEthFlow, @@ -107,6 +108,29 @@ impl RefundService { }) } + /// Checks if an address can receive ETH by simulating a small transfer. + /// Returns true for EOAs and contracts with working receive/fallback functions. + async fn can_receive_eth(&self, address: Address) -> bool { + use alloy::rpc::types::TransactionRequest; + + // Try to estimate gas for sending a minimal amount of ETH + let tx = TransactionRequest::default() + .to(address) + .value(alloy::primitives::U256::from(1)); + + match self.web3.alloy.estimate_gas(tx).await { + Ok(_) => true, + Err(err) => { + tracing::warn!( + ?address, + ?err, + "Address cannot receive ETH - will skip refund" + ); + false + } + } + } + async fn identify_uids_refunding_status_via_web3_calls( &self, refundable_order_uids: Vec, @@ -146,12 +170,27 @@ impl RefundService { return None; } }; - let refund_status = match order_owner { + let mut refund_status = match order_owner { Some(bytes) if bytes == INVALIDATED_OWNER => RefundStatus::Refunded, Some(bytes) if bytes == NO_OWNER => RefundStatus::Invalid, // any other owner _ => RefundStatus::NotYetRefunded, }; + + // For orders that are not yet refunded, check if the owner can receive ETH + if refund_status == RefundStatus::NotYetRefunded { + if let Some(owner) = order_owner { + if !self.can_receive_eth(owner).await { + tracing::warn!( + uid = const_hex::encode_prefixed(eth_order_placement.uid.0), + owner = ?owner, + "Order owner cannot receive ETH - marking as invalid" + ); + refund_status = RefundStatus::Invalid; + } + } + } + Some((eth_order_placement.uid, refund_status, ethflow_contract)) }); @@ -174,10 +213,11 @@ impl RefundService { } if !invalid_uids.is_empty() { // In exceptional cases, e.g. if the refunder tries to refund orders from a - // previous contract, the order_owners could be zero + // previous contract, the order_owners could be zero, or the owner cannot + // receive ETH (e.g. EOF contracts or contracts with restrictive receive logic) tracing::warn!( - "Trying to invalidate orders that weren't created in the current contract. Uids: \ - {:?}", + "Skipping invalid orders (not created in current contract or owner cannot \ + receive ETH). Uids: {:?}", invalid_uids ); } From 051d8591172a84172cb69b46bd9656f5de65fdbc Mon Sep 17 00:00:00 2001 From: extrawurst Date: Fri, 28 Nov 2025 12:37:28 -0300 Subject: [PATCH 2/6] formatting --- crates/refunder/src/refund_service.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/refunder/src/refund_service.rs b/crates/refunder/src/refund_service.rs index 9848f20caf..0d3fca74ff 100644 --- a/crates/refunder/src/refund_service.rs +++ b/crates/refunder/src/refund_service.rs @@ -109,7 +109,8 @@ impl RefundService { } /// Checks if an address can receive ETH by simulating a small transfer. - /// Returns true for EOAs and contracts with working receive/fallback functions. + /// Returns true for EOAs and contracts with working receive/fallback + /// functions. async fn can_receive_eth(&self, address: Address) -> bool { use alloy::rpc::types::TransactionRequest; @@ -216,8 +217,8 @@ impl RefundService { // previous contract, the order_owners could be zero, or the owner cannot // receive ETH (e.g. EOF contracts or contracts with restrictive receive logic) tracing::warn!( - "Skipping invalid orders (not created in current contract or owner cannot \ - receive ETH). Uids: {:?}", + "Skipping invalid orders (not created in current contract or owner cannot receive \ + ETH). Uids: {:?}", invalid_uids ); } From b2067fa5f06aa4a6e162d5f1b0b4554aa2b6e8e0 Mon Sep 17 00:00:00 2001 From: extrawurst Date: Fri, 28 Nov 2025 12:45:05 -0300 Subject: [PATCH 3/6] clippy fixes --- crates/refunder/src/refund_service.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/refunder/src/refund_service.rs b/crates/refunder/src/refund_service.rs index 0d3fca74ff..42fb8a6d0e 100644 --- a/crates/refunder/src/refund_service.rs +++ b/crates/refunder/src/refund_service.rs @@ -179,17 +179,16 @@ impl RefundService { }; // For orders that are not yet refunded, check if the owner can receive ETH - if refund_status == RefundStatus::NotYetRefunded { - if let Some(owner) = order_owner { - if !self.can_receive_eth(owner).await { - tracing::warn!( - uid = const_hex::encode_prefixed(eth_order_placement.uid.0), - owner = ?owner, - "Order owner cannot receive ETH - marking as invalid" - ); - refund_status = RefundStatus::Invalid; - } - } + if refund_status == RefundStatus::NotYetRefunded + && let Some(owner) = order_owner + && !self.can_receive_eth(owner).await + { + tracing::warn!( + uid = const_hex::encode_prefixed(eth_order_placement.uid.0), + owner = ?owner, + "Order owner cannot receive ETH - marking as invalid" + ); + refund_status = RefundStatus::Invalid; } Some((eth_order_placement.uid, refund_status, ethflow_contract)) From 46e8b4bb83658896796ee29a8b9a0c25ae2a7503 Mon Sep 17 00:00:00 2001 From: extrawurst Date: Fri, 28 Nov 2025 13:47:51 -0300 Subject: [PATCH 4/6] add test for proof --- crates/refunder/src/refund_service.rs | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/refunder/src/refund_service.rs b/crates/refunder/src/refund_service.rs index 42fb8a6d0e..4f32398109 100644 --- a/crates/refunder/src/refund_service.rs +++ b/crates/refunder/src/refund_service.rs @@ -294,3 +294,62 @@ impl RefundService { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Creates a minimal RefundService for testing purposes. + fn new_test_service(web3: Web3) -> RefundService { + RefundService { + db: PgPool::connect_lazy("postgresql://").unwrap(), + web3: web3.clone(), + ethflow_contracts: vec![], + min_validity_duration: 0, + min_price_deviation: 0.0, + max_gas_price: 0, + start_priority_fee_tip: 0, + submitter: Submitter { + web3: web3.clone(), + signer_address: Address::ZERO, + gas_estimator: Box::new(web3.legacy.clone()), + gas_parameters_of_last_tx: None, + nonce_of_last_submission: None, + max_gas_price: 0, + start_priority_fee_tip: 0, + }, + } + } + + /// Verifies that `can_receive_eth()` correctly identifies addresses that + /// cannot receive ETH transfers. Some smart contracts reject ETH transfers + /// (e.g., EOF contracts or contracts without receive/fallback functions), + /// which causes batch refunds to fail with EthTransferFailed errors. + /// + /// This test uses a real Sepolia EOF contract address that rejects ETH and + /// compares it against a normal EOA to ensure the filtering logic works. + #[tokio::test] + #[ignore] // Run with: cargo test --package refunder --lib test_problematic_sepolia_address -- --ignored + async fn test_problematic_sepolia_address() { + let web3 = Web3::new_from_url("https://ethereum-sepolia-rpc.publicnode.com"); + let service = new_test_service(web3); + + // EOF contract that cannot receive ETH (0xef01... bytecode prefix) + let problematic = address!("0x66C9152339ce05EE0C8A8eff9EeF8230AbFe8350"); + + // Normal EOA for comparison + let working = address!("0x5b485e4431853F82d89dba68220A422CC17cE024"); + + // Test that can_receive_eth correctly identifies the problematic address + assert!( + !service.can_receive_eth(problematic).await, + "EOF contract should be identified as unable to receive ETH" + ); + + // Test that can_receive_eth correctly identifies a working address + assert!( + service.can_receive_eth(working).await, + "Normal EOA should be identified as able to receive ETH" + ); + } +} From 528d161d540de019c67badb29118b5b1937b8e72 Mon Sep 17 00:00:00 2001 From: extrawurst Date: Fri, 28 Nov 2025 14:33:43 -0300 Subject: [PATCH 5/6] fix import --- crates/refunder/src/refund_service.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/refunder/src/refund_service.rs b/crates/refunder/src/refund_service.rs index 4f32398109..8be3954746 100644 --- a/crates/refunder/src/refund_service.rs +++ b/crates/refunder/src/refund_service.rs @@ -1,5 +1,6 @@ use { crate::submitter::Submitter, + alloy::rpc::types::TransactionRequest, alloy::{ network::TxSigner, primitives::{Address, B256, Signature, address}, @@ -112,8 +113,6 @@ impl RefundService { /// Returns true for EOAs and contracts with working receive/fallback /// functions. async fn can_receive_eth(&self, address: Address) -> bool { - use alloy::rpc::types::TransactionRequest; - // Try to estimate gas for sending a minimal amount of ETH let tx = TransactionRequest::default() .to(address) From bb928bbfdffaceef1f6cbde39ec59f9cc66ae3f4 Mon Sep 17 00:00:00 2001 From: extrawurst Date: Fri, 28 Nov 2025 14:40:47 -0300 Subject: [PATCH 6/6] cleanup and review feedback --- crates/refunder/src/refund_service.rs | 42 ++++++++++++--------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/crates/refunder/src/refund_service.rs b/crates/refunder/src/refund_service.rs index 8be3954746..a6a043c0bf 100644 --- a/crates/refunder/src/refund_service.rs +++ b/crates/refunder/src/refund_service.rs @@ -1,10 +1,10 @@ use { crate::submitter::Submitter, - alloy::rpc::types::TransactionRequest, alloy::{ network::TxSigner, primitives::{Address, B256, Signature, address}, providers::Provider, + rpc::types::TransactionRequest, }, anyhow::{Context, Result, anyhow}, contracts::alloy::CoWSwapEthFlow, @@ -118,17 +118,18 @@ impl RefundService { .to(address) .value(alloy::primitives::U256::from(1)); - match self.web3.alloy.estimate_gas(tx).await { - Ok(_) => true, - Err(err) => { + self.web3 + .alloy + .estimate_gas(tx) + .await + .inspect_err(|err| { tracing::warn!( ?address, ?err, "Address cannot receive ETH - will skip refund" ); - false - } - } + }) + .is_ok() } async fn identify_uids_refunding_status_via_web3_calls( @@ -160,7 +161,7 @@ impl RefundService { .expect("order_uid slice with incorrect length"); let order = ethflow_contract.orders(order_hash.into()).call().await; let order_owner = match order { - Ok(order) => Some(order.owner), + Ok(order) => order.owner, Err(err) => { tracing::error!( uid =? B256::from(order_hash), @@ -170,25 +171,20 @@ impl RefundService { return None; } }; - let mut refund_status = match order_owner { - Some(bytes) if bytes == INVALIDATED_OWNER => RefundStatus::Refunded, - Some(bytes) if bytes == NO_OWNER => RefundStatus::Invalid, - // any other owner - _ => RefundStatus::NotYetRefunded, - }; - - // For orders that are not yet refunded, check if the owner can receive ETH - if refund_status == RefundStatus::NotYetRefunded - && let Some(owner) = order_owner - && !self.can_receive_eth(owner).await - { + let refund_status = if order_owner == INVALIDATED_OWNER { + RefundStatus::Refunded + } else if order_owner == NO_OWNER { + RefundStatus::Invalid + } else if !self.can_receive_eth(order_owner).await { tracing::warn!( uid = const_hex::encode_prefixed(eth_order_placement.uid.0), - owner = ?owner, + owner = ?order_owner, "Order owner cannot receive ETH - marking as invalid" ); - refund_status = RefundStatus::Invalid; - } + RefundStatus::Invalid + } else { + RefundStatus::NotYetRefunded + }; Some((eth_order_placement.uid, refund_status, ethflow_contract)) });