diff --git a/crates/tycho-ethereum/src/services/token_analyzer/bytecode.rs b/crates/tycho-ethereum/src/services/token_analyzer/bytecode.rs index 9ad73712b1..c5aacd75c1 100644 --- a/crates/tycho-ethereum/src/services/token_analyzer/bytecode.rs +++ b/crates/tycho-ethereum/src/services/token_analyzer/bytecode.rs @@ -25,7 +25,7 @@ use alloy::sol; /// Runtime bytecode for `contracts/Analyzer.sol`. pub const ANALYZER_BYTECODE: &[u8] = &alloy::primitives::hex!( - "6080604052600436101561001257600080fd5b6000803560e01c63521c65391461002857600080fd5b346100bf5760803660031901126100bf576001600160a01b039060043582811681036100c2576044359183831683036100bf5760643593841684036100bf5761014061007885856024358661013e565b976040979197969296959395519915158a52151560208a0152151560408901526060880152608087015260a086015260c085015260e0840152610100830152610120820152f35b80fd5b5080fd5b90601f8019910116810190811067ffffffffffffffff8211176100e857604052565b634e487b7160e01b600052604160045260246000fd5b90816020910312610116575180151581036101165790565b600080fd5b9190820391821161012857565b634e487b7160e01b600052601160045260246000fd5b92939160018060a01b03948585169460408051946370a0823160e01b9384875289600496169889878901526020602491818a8481845afa998a156105cd5760009a61059e575b50898287519e8f968b88521695868c8201528581855afa9081156104dc578d9e60009261056c575b5081985a9e895198868a019163a9059cbb60e01b8352888b015260448a015260448952608089019867ffffffffffffffff998181108b821117610557578b525160009283929083905af1963d1561054f573d90811161053b5788519061021b601f8201601f19168701836100c6565b81523d60008683013e5b87610508575b50610238879e5a9061011b565b96156104e7575050855198888a528d818b0152828a8581855afa998a156104dc5760009a6104ad575b50898b811061049157506102bf9c9d61027a8c8c61011b565b955a96895190631339c64960e01b825285858301528288830152604482015285816064816000875af160009181610472575b5061046a575060009e8f975b5a9061011b565b96156103ee5788518b8152828482015285818881885afa9081156103b6576000916103c1575b509a8951908152818482015285818881885afa9081156103b65790869594939291600091610380575b5091600091606494939b5198899687956361be88e160e11b8752860152840152811960448401525af1918291600093610351575b505061034e5750600097565b97565b610371929350803d10610379575b61036981836100c6565b8101906100fe565b903880610342565b503d61035f565b86819395949792503d83116103af575b61039a81836100c6565b8101031261011657518594919290600061030e565b503d610390565b8a513d6000823e3d90fd5b90508581813d83116103e7575b6103d881836100c6565b810103126101165751386102e5565b503d6103ce565b509683969e50809a9b9c989992919493959d5051968794859384528301525afa9485156104605750600094610430575b50600197600097889796959493889350565b9080929450813d8311610459575b61044881836100c6565b81010312610116575191388061041e565b503d61043e565b513d6000823e3d90fd5b9e8f976102b8565b61048a919250873d89116103795761036981836100c6565b90386102ac565b60009e508e9d508d9c909a508c99508997508795509350505050565b90998382813d83116104d5575b6104c481836100c6565b810103126100bf5750519838610261565b503d6104ba565b87513d6000823e3d90fd5b60009e508e9d508d9c909b508c9a508a995090975088965086945092505050565b809197505190848215928315610523575b505050953861022b565b61053393508201810191016100fe565b388481610519565b8560418d634e487b7160e01b600052526000fd5b506060610225565b508760418f634e487b7160e01b600052526000fd5b9d509050828d813d8111610597575b61058581836100c6565b81010312610116578d9c5190386101ac565b503d61057b565b9099508181813d83116105c6575b6105b681836100c6565b8101031261011657519838610184565b503d6105ac565b86513d6000823e3d90fd" + "610100604052600436101561001357600080fd5b6000803560e01c63521c65391461002957600080fd5b346100fc5760803660031901126100fc57600435906001600160a01b039081831683036100fc576044359082821682036100fc5760643592831683036100fc5750602435610076936101b7565b9b909b9a919a9992999893989794979695969b9a999897969594939291906080519d906040519e8f92151583521515602083015215159060400152151560608d015260808c015260a08b015260c08a015260e08901526101008801526101208701526101408601526101608501526101808401526101a08301526101c08201526101e090f35b80fd5b90601f8019910116810190811067ffffffffffffffff82111761012157604052565b634e487b7160e01b600052604160045260246000fd5b3d15610172573d9067ffffffffffffffff82116101215760405191610166601f8201601f1916602001846100ff565b82523d6000602084013e565b606090565b9081602091031261018f5751801515810361018f5790565b600080fd5b919082039182116101a157565b634e487b7160e01b600052601160045260246000fd5b60e05260c05260a052600060805260018060a01b0391604051926370a0823160e01b84528060a0511660048501526020846024818460e051165afa9384156105ff576000946108c0575b508390604051936370a0823160e01b85523060048601526020856024818560e051165afa9485156105ff5760009561088c575b50846040516370a0823160e01b815283831660048201526020816024818760e051165afa9081156105ff5760009161085a575b5080965a60405163a9059cbb60e01b6020820190815260c05160a0516001600160a01b031660248401526044830152919a916000918291906102b681606481015b03601f1981018352826100ff565b51908260e0515af1926102c7610137565b84610828575b506102da849b5a90610194565b931561080057505050604051936370a0823160e01b85528060a0511660048601526020856024818460e051165afa9485156105ff576000956107cc575b50848681106107a9575061039461032e8787610194565b61036d60205a604051631339c64960e01b815260e0516001600160a01b0316600482015230602482015260448101949094529291829081906064820190565b038160008860a051165af160009181610788575b50610780575060006080525b5a90610194565b97604051946370a0823160e01b86523060048701526020866024818660e051165afa9586156105ff5760009661074c575b5085936040516370a0823160e01b81528460a0511660048201526020816024818860e051165afa9081156105ff5760009161071a575b508097608051156106fb57505060c051850185116101a1576104218660c0518701610194565b60405163a9059cbb60e01b6020820190815260a0516001600160a01b0316602483015260448201839052919c9160009182919061046181606481016102a8565b51908260e0515af150610472610137565b506104b560205a604051631339c64960e01b815260e0516001600160a01b03908116600483015287166024820152604481019f909f529d91829081906064820190565b038160008a60a051165af1600091816106da575b506106d057506104dc60009c5a90610194565b8c1561063d576040516370a0823160e01b81528660a0511660048201526020816024818a60e051165afa9081156105ff5760009161060b575b50956040516370a0823160e01b815281861660048201526020816024818560e051165afa9081156105ff5782906000926105ca575b50606460209297600060405195869485936361be88e160e11b85528160e0511660048601521660248401528119604484015260a051165af160009181610599575b50610596575060009b565b9b565b6105bc91925060203d6020116105c3575b6105b481836100ff565b810190610177565b903861058b565b503d6105aa565b9150506020813d6020116105f7575b816105e6602093836100ff565b8101031261018f575181606461054a565b3d91506105d9565b6040513d6000823e3d90fd5b90506020813d602011610635575b81610626602093836100ff565b8101031261018f575138610515565b3d9150610619565b9b509b509050602060249495969798999293604051958680926370a0823160e01b82528060a05116600483015260e051165afa9384156105ff5760009461069c575b5060019a8b60805260009a60009a99989796959493600093929190565b9093506020813d6020116106c8575b816106b8602093836100ff565b8101031261018f5751923861067f565b3d91506106ab565b6104dc909c61038d565b6106f491925060203d6020116105c3576105b481836100ff565b90386104c9565b6000608081905260019e509c8d9c9299509096508b9550859350908390565b90506020813d602011610744575b81610735602093836100ff565b8101031261018f5751386103fb565b3d9150610728565b9095506020813d602011610778575b81610768602093836100ff565b8101031261018f575194386103c5565b3d915061075b565b60805261038d565b6107a291925060203d6020116105c3576105b481836100ff565b9038610381565b600060808190529950899889989196508895508593849350909183919082908190565b90946020823d6020116107f8575b816107e7602093836100ff565b810103126100fc5750519338610317565b3d91506107da565b600060808190529a508a99508998919750889650869550919350849283929183919082908190565b80519194508115918215610840575b505092386102cd565b6108539250602080918301019101610177565b3880610837565b90506020813d602011610884575b81610875602093836100ff565b8101031261018f575138610267565b3d9150610868565b9094506020813d6020116108b8575b816108a8602093836100ff565b8101031261018f57519338610234565b3d91506108b8565b9093506020813d6020116108ec575b816108dc602093836100ff565b8101031261018f57519238610201565b3d91506108cf56" ); /// Runtime bytecode for `contracts/Forwarder.sol`. @@ -42,14 +42,19 @@ sol! { address recipient ) external returns ( bool transferInOk, + bool roundtripOutOk, bool transferOutOk, bool approvalOk, uint256 balanceBeforeIn, uint256 balanceAfterIn, + uint256 balanceAfterRoundtrip, + uint256 holderBefore, + uint256 holderAfter, uint256 balanceAfterOut, uint256 recipientBefore, uint256 recipientAfter, uint256 gasIn, + uint256 roundtripGasOut, uint256 gasOut ); } @@ -62,20 +67,16 @@ mod tests { #[test] fn bytecodes_are_valid_evm() { - // EVM contracts compiled via --via-ir start with PUSH1 0x80 (0x60 0x80). + // Contracts compiled via --via-ir start with a PUSH opcode (0x60 = PUSH1, 0x61 = PUSH2) + // for the free memory pointer initialisation. Larger contracts use PUSH2. + fn is_valid_evm_header(code: &[u8]) -> bool { + matches!(code.first(), Some(0x60 | 0x61)) + } assert!(ANALYZER_BYTECODE.len() > 10, "analyzer bytecode is suspiciously short"); - assert_eq!( - &ANALYZER_BYTECODE[..2], - &[0x60, 0x80], - "analyzer bytecode has wrong EVM header" - ); + assert!(is_valid_evm_header(ANALYZER_BYTECODE), "analyzer bytecode has wrong EVM header"); assert!(FORWARDER_BYTECODE.len() > 10, "forwarder bytecode is suspiciously short"); - assert_eq!( - &FORWARDER_BYTECODE[..2], - &[0x60, 0x80], - "forwarder bytecode has wrong EVM header" - ); + assert!(is_valid_evm_header(FORWARDER_BYTECODE), "forwarder bytecode has wrong EVM header"); } #[test] diff --git a/crates/tycho-ethereum/src/services/token_analyzer/contracts/Analyzer.sol b/crates/tycho-ethereum/src/services/token_analyzer/contracts/Analyzer.sol index 7413a8e7cb..166cdf0147 100644 --- a/crates/tycho-ethereum/src/services/token_analyzer/contracts/Analyzer.sol +++ b/crates/tycho-ethereum/src/services/token_analyzer/contracts/Analyzer.sol @@ -12,29 +12,41 @@ interface IForwarder { } /// @title Token Analyzer -/// @notice Injected at a token holder's address via eth_call state override. Simulates a full -/// round-trip ERC20 transfer (holder -> settlement -> recipient) in a single call and reports -/// balances, gas costs, and success flags. No on-chain deployment required. -/// @dev The inbound transfer uses a low-level call rather than a typed interface so that tokens with -/// non-standard transfer() implementations (e.g. USDT, which omits the bool return value) are -/// handled correctly. balanceOf and approve are called via the typed interface since they are -/// consistently implemented across tokens. +/// @notice Injected at a token holder's address via eth_call state override. Performs a full +/// sell-fee analysis in a single call using four transfer legs: +/// +/// Leg 1 (buy): holder → settlement — detects buy fees +/// Leg 2 (roundtrip): settlement → holder — detects fees even to whitelisted LP +/// Leg 3 (retransfer): holder → settlement — returns tokens for the sell test +/// Leg 4 (sell): settlement → recipient — detects sell-only fees +/// +/// If the roundtrip (legs 1-2) passes but the sell test (legs 3-4) fails, the token has a +/// sell-only fee: it charges fees only when transferring to non-whitelisted addresses. +/// +/// @dev Inbound transfers (legs 1 and 3) use low-level calls so that tokens with non-standard +/// transfer() implementations (e.g. USDT, which omits the bool return value) are handled +/// correctly. balanceOf and approve are called via the typed interface. contract Analyzer { - /// @notice Simulate ERC20 transfer in and out, measuring balances and gas at each step. - /// @param token The ERC20 token to analyze. - /// @param amount The amount to transfer in from this address (the holder). - /// @param settlement Intermediary address (injected with Forwarder bytecode). - /// @param recipient Final recipient of the outbound transfer. - /// @return transferInOk Whether transfer(settlement, amount) succeeded. - /// @return transferOutOk Whether forwardTransfer(token, recipient, received) succeeded. - /// @return approvalOk Whether forwardApprove(token, recipient, MAX_UINT256) succeeded. - /// @return balanceBeforeIn Settlement balance before transfer in. - /// @return balanceAfterIn Settlement balance after transfer in. - /// @return balanceAfterOut Settlement balance after transfer out. - /// @return recipientBefore Recipient balance before any transfer. - /// @return recipientAfter Recipient balance after transfer out. - /// @return gasIn Gas consumed by the inbound transfer (gasleft() delta). - /// @return gasOut Gas consumed by the outbound transfer (gasleft() delta). + /// @notice Simulate a full ERC20 transfer analysis in four legs. + /// @param token The ERC20 token to analyze. + /// @param amount The amount to transfer in each buy leg. + /// @param settlement Intermediary address (injected with Forwarder bytecode). + /// @param recipient Final arbitrary recipient for the sell-only fee test. + /// @return transferInOk Whether leg 1 (holder → settlement) succeeded. + /// @return roundtripOutOk Whether leg 2 (settlement → holder) succeeded. + /// @return transferOutOk Whether leg 4 (settlement → recipient) succeeded. + /// @return approvalOk Whether forwardApprove(MAX_UINT256) succeeded. + /// @return balanceBeforeIn Settlement balance before leg 1. + /// @return balanceAfterIn Settlement balance after leg 1. + /// @return balanceAfterRoundtrip Settlement balance after leg 2. + /// @return holderBefore Holder balance before leg 1. + /// @return holderAfter Holder balance after leg 2. + /// @return balanceAfterOut Settlement balance after leg 4. + /// @return recipientBefore Recipient balance before leg 4. + /// @return recipientAfter Recipient balance after leg 4. + /// @return gasIn Gas consumed by leg 1. + /// @return roundtripGasOut Gas consumed by leg 2. + /// @return gasOut Gas consumed by leg 4. function analyze( address token, uint256 amount, @@ -42,25 +54,28 @@ contract Analyzer { address recipient ) external returns ( bool transferInOk, + bool roundtripOutOk, bool transferOutOk, bool approvalOk, uint256 balanceBeforeIn, uint256 balanceAfterIn, + uint256 balanceAfterRoundtrip, + uint256 holderBefore, + uint256 holderAfter, uint256 balanceAfterOut, uint256 recipientBefore, uint256 recipientAfter, uint256 gasIn, + uint256 roundtripGasOut, uint256 gasOut ) { IERC20 erc20 = IERC20(token); - // Read pre-transfer balances for both the settlement and the final recipient. balanceBeforeIn = erc20.balanceOf(settlement); + holderBefore = erc20.balanceOf(address(this)); recipientBefore = erc20.balanceOf(recipient); - // Transfer from holder (this address) to settlement using a low-level call so that tokens - // which omit the bool return value (e.g. USDT) do not cause a revert during ABI decoding. - // Success condition: call did not revert AND, if return data is present, it decodes true. + // === Leg 1: Buy — holder → settlement === uint256 g1 = gasleft(); { (bool ok, bytes memory data) = token.call( @@ -71,41 +86,69 @@ contract Analyzer { gasIn = g1 - gasleft(); if (!transferInOk) { - return (false, false, false, balanceBeforeIn, 0, 0, recipientBefore, 0, gasIn, 0); + return (false, false, false, false, balanceBeforeIn, 0, 0, holderBefore, 0, 0, recipientBefore, 0, gasIn, 0, 0); } balanceAfterIn = erc20.balanceOf(settlement); - // Guard: a token that returns true but reduces the settlement balance is pathological. - // Without this check Solidity 0.8 checked arithmetic would revert the entire eth_call, - // making the result undecodable. Instead we surface it as a transfer failure. + // Guard: a token that returns true but reduces settlement balance is pathological. if (balanceAfterIn < balanceBeforeIn) { - return (false, false, false, balanceBeforeIn, balanceAfterIn, 0, recipientBefore, 0, gasIn, 0); + return (false, false, false, false, balanceBeforeIn, balanceAfterIn, 0, holderBefore, 0, 0, recipientBefore, 0, gasIn, 0, 0); } - // received may be less than amount for fee-on-transfer tokens. uint256 received = balanceAfterIn - balanceBeforeIn; - // Transfer out from settlement to recipient via the injected Forwarder. + // === Leg 2: Roundtrip — settlement → holder === uint256 g2 = gasleft(); - try IForwarder(settlement).forwardTransfer(token, recipient, received) returns (bool success) { - transferOutOk = success; + try IForwarder(settlement).forwardTransfer(token, address(this), received) returns (bool s) { + roundtripOutOk = s; + } catch { + roundtripOutOk = false; + } + roundtripGasOut = g2 - gasleft(); + + holderAfter = erc20.balanceOf(address(this)); + balanceAfterRoundtrip = erc20.balanceOf(settlement); + + if (!roundtripOutOk) { + return (true, false, false, false, balanceBeforeIn, balanceAfterIn, balanceAfterRoundtrip, holderBefore, holderAfter, 0, recipientBefore, 0, gasIn, roundtripGasOut, 0); + } + + // === Leg 3: Retransfer — holder → settlement (returns the received tokens for the sell test) === + // holderAfter = holderBefore - amount + roundtripReceived + // ⟹ roundtripReceived = holderAfter + amount - holderBefore + // Safe: holderAfter >= holderBefore - amount (holder only gains from roundtrip), + // so holderAfter + amount >= holderBefore (no underflow). + uint256 roundtripReceived = holderAfter + amount - holderBefore; + { + (bool ok, bytes memory data) = token.call( + abi.encodeWithSelector(0xa9059cbb, settlement, roundtripReceived) + ); + // Intentionally not checked: if this fails, leg 4 will also fail (settlement + // has no tokens), and transferOutOk will correctly capture the outcome. + (ok, data); + } + + // === Leg 4: Sell — settlement → recipient (arbitrary) === + uint256 g3 = gasleft(); + try IForwarder(settlement).forwardTransfer(token, recipient, roundtripReceived) returns (bool s) { + transferOutOk = s; } catch { transferOutOk = false; } - gasOut = g2 - gasleft(); + gasOut = g3 - gasleft(); if (!transferOutOk) { balanceAfterOut = erc20.balanceOf(settlement); - return (true, false, false, balanceBeforeIn, balanceAfterIn, balanceAfterOut, recipientBefore, 0, gasIn, gasOut); + return (true, true, false, false, balanceBeforeIn, balanceAfterIn, balanceAfterRoundtrip, holderBefore, holderAfter, balanceAfterOut, recipientBefore, 0, gasIn, roundtripGasOut, gasOut); } balanceAfterOut = erc20.balanceOf(settlement); recipientAfter = erc20.balanceOf(recipient); // Test that settlement can approve (some tokens block approvals from contracts). - try IForwarder(settlement).forwardApprove(token, recipient, type(uint256).max) returns (bool success) { - approvalOk = success; + try IForwarder(settlement).forwardApprove(token, recipient, type(uint256).max) returns (bool s) { + approvalOk = s; } catch { approvalOk = false; } diff --git a/crates/tycho-ethereum/src/services/token_analyzer/ethcall_detector.rs b/crates/tycho-ethereum/src/services/token_analyzer/ethcall_detector.rs index d7867d6daa..cdbee15ea2 100644 --- a/crates/tycho-ethereum/src/services/token_analyzer/ethcall_detector.rs +++ b/crates/tycho-ethereum/src/services/token_analyzer/ethcall_detector.rs @@ -20,7 +20,7 @@ use tycho_common::{ use super::{ arbitrary_recipient, bytecode::{analyzeCall, ANALYZER_BYTECODE, FORWARDER_BYTECODE}, - calculate_fee, map_block_tag, + map_block_tag, }; use crate::{rpc::EthereumRpcClient, BytesCodec}; @@ -31,14 +31,78 @@ const GAS_LIMIT: u64 = 30_000_000; /// `TokenAnalyzer` implementation using `eth_call` with bytecode state overrides. /// /// Injects the Analyzer contract at the token holder's address and the Forwarder contract at the -/// settlement address, then executes the full round-trip transfer simulation in a single -/// `eth_call`. Compatible with any EVM chain that supports `eth_call` state overrides. +/// settlement address, then executes the full analysis in a single `eth_call`. The Analyzer +/// performs four transfer legs: +/// +/// Leg 1 (buy): holder → settlement — detects buy fees +/// Leg 2 (roundtrip): settlement → holder — detects fees even to the LP pool address +/// Leg 3 (retransfer): holder → settlement — returns tokens for the sell test +/// Leg 4 (sell): settlement → recipient — detects sell-only fees +/// +/// If legs 1–2 pass but leg 4 fails, the token has a sell-only fee. +/// +/// Compatible with any EVM chain that supports `eth_call` state overrides. pub struct EthCallDetector { rpc: EthereumRpcClient, finder: Arc, settlement_contract: Address, } +/// Returns the worst-case transfer tax in basis points across all three legs of the analysis: +/// the buy leg (holder → settlement), the roundtrip sell leg (settlement → holder), and the +/// sell-to-arbitrary leg (settlement → recipient). Using the maximum ensures the reported tax +/// is always conservative. +fn worst_fee( + amount: U256, + middle_amount: U256, + roundtrip_received: U256, + r: &::Return, +) -> Result { + let bps = U256::from(10_000_u32); + + // Buy fee: fraction of `amount` not received by settlement. + let buy_fee = if middle_amount < amount && amount > U256::ZERO { + (amount - middle_amount) + .saturating_mul(bps) + .checked_div(amount) + .ok_or("division by zero in buy fee")? + } else { + U256::ZERO + }; + + // Roundtrip sell fee: fraction of `middle_amount` not returned to holder. + let expected_holder = r + .holderBefore + .checked_sub(amount) + .ok_or("holder balance underflow")? + .checked_add(middle_amount) + .ok_or("holder balance overflow")?; + let roundtrip_fee = if r.holderAfter < expected_holder && middle_amount > U256::ZERO { + (expected_holder - r.holderAfter) + .saturating_mul(bps) + .checked_div(middle_amount) + .ok_or("division by zero in roundtrip fee")? + } else { + U256::ZERO + }; + + // Sell-to-arbitrary fee: fraction of `roundtrip_received` not received by the recipient. + let expected_recipient = r + .recipientBefore + .checked_add(roundtrip_received) + .ok_or("recipient balance overflow")?; + let sell_fee = if r.recipientAfter < expected_recipient && roundtrip_received > U256::ZERO { + (expected_recipient - r.recipientAfter) + .saturating_mul(bps) + .checked_div(roundtrip_received) + .ok_or("division by zero in sell fee")? + } else { + U256::ZERO + }; + + Ok(buy_fee.max(roundtrip_fee).max(sell_fee)) +} + impl EthCallDetector { pub fn new( rpc: &EthereumRpcClient, @@ -79,8 +143,7 @@ impl EthCallDetector { ) -> Result<(TokenQuality, Option, Option), String> { let block_tag = map_block_tag(block); - // Arbitrary amount that is large enough that small relative fees should be - // visible. + // Arbitrary amount that is large enough that small relative fees should be visible. const MIN_AMOUNT: u64 = 100_000; let (holder, amount) = match self .finder @@ -160,6 +223,8 @@ impl EthCallDetector { amount: U256, holder: Address, ) -> Result<(TokenQuality, Option, Option), String> { + // --- Leg 1: buy (holder → settlement) --- + // Without a successful buy there is nothing meaningful to report for the sell legs. if !r.transferInOk { return Ok(( TokenQuality::bad(format!( @@ -171,13 +236,14 @@ impl EthCallDetector { )); } - let recipient = arbitrary_recipient(); - - if !r.transferOutOk { + // --- Leg 2: roundtrip (settlement → holder) revert --- + // When the roundtrip transfer reverts the Solidity contract returns early, so legs 3–4 + // did not run and their results are meaningless. Report the revert directly. + if !r.roundtripOutOk { return Ok(( TokenQuality::bad(format!( - "Transfer of token out of settlement contract to arbitrary recipient \ - {recipient:#x} failed", + "Transfer of token out of settlement contract back to holder {holder:#x} \ + failed", )), None, None, @@ -186,69 +252,158 @@ impl EthCallDetector { let gas_per_transfer = (r.gasIn + r.gasOut) / U256::from(2); - // The Solidity guard ensures balanceAfterIn >= balanceBeforeIn when transferInOk = true, - // so this subtraction is always safe. + // Safe: Solidity guards balanceAfterIn >= balanceBeforeIn when transferInOk = true. let middle_amount = r .balanceAfterIn .checked_sub(r.balanceBeforeIn) .ok_or("settlement balance underflow after successful transfer in")?; - let fees = calculate_fee( - amount, - middle_amount, - r.balanceBeforeIn, - r.balanceAfterIn, - r.recipientBefore, - r.recipientAfter, - ) - .map_err(|e| format!("Failed to calculate transfer fee: {e}"))?; - - let computed_balance_after_in = r + // roundtripReceived: what holder actually got back in leg 2. + // holderAfter = holderBefore - amount + roundtripReceived + // ⟹ roundtripReceived = holderAfter + amount - holderBefore + // Safe: holderBefore >= amount (leg 1 succeeded). + let roundtrip_received = r + .holderAfter + .checked_add(amount) + .ok_or("overflow computing roundtrip received")? + .checked_sub(r.holderBefore) + .ok_or("underflow computing roundtrip received")?; + + // Compute the worst-case fee across all legs so that we always report the most + // conservative (largest) tax, regardless of which specific check fires. + let fees = worst_fee(amount, middle_amount, roundtrip_received, &r)?; + + // Collect issues from every remaining leg without returning early. A revert is + // always worse than a fee; among fees, the higher basis-point rate wins. + // Each entry: (is_revert: bool, fee_bps: U256, reason: String) + let recipient = arbitrary_recipient(); + let mut issues: Vec<(bool, U256, String)> = Vec::new(); + + // Buy fee + let expected_after_in = r .balanceBeforeIn .checked_add(amount) - .ok_or("settlement balance overflow when checking transfer in")?; - if r.balanceAfterIn != computed_balance_after_in { - return Ok(( - TokenQuality::bad(format!( - "Transferring {amount} into settlement was expected to result in a balance \ - of {computed_balance_after_in} but got {}. The token likely takes a fee on \ - transfer.", - r.balanceAfterIn, - )), - Some(gas_per_transfer), - Some(fees), + .ok_or("settlement balance overflow")?; + if r.balanceAfterIn != expected_after_in { + let fee_bps = (amount - middle_amount) + .saturating_mul(U256::from(10_000)) + .checked_div(amount) + .unwrap_or(U256::ZERO); + issues.push(( + false, + fee_bps, + format!( + "Transferring {amount} into settlement was expected to result in a balance of \ + {expected_after_in} but got {}. The token likely takes a fee on transfer.", + r.balanceAfterIn + ), )); } - if r.balanceAfterOut != r.balanceBeforeIn { - return Ok(( - TokenQuality::bad(format!( - "Transferring {amount} out of settlement was expected to restore the \ - original balance of {} but got {}.", - r.balanceBeforeIn, r.balanceAfterOut, - )), - Some(gas_per_transfer), - Some(fees), + // Roundtrip: settlement kept some of the tokens (fee in settlement's favour) + if r.balanceAfterRoundtrip > r.balanceBeforeIn { + let kept = r.balanceAfterRoundtrip - r.balanceBeforeIn; + let fee_bps = kept + .saturating_mul(U256::from(10_000)) + .checked_div(middle_amount.max(U256::from(1))) + .unwrap_or(U256::ZERO); + issues.push(( + false, + fee_bps, + format!( + "After roundtrip, settlement balance was expected to be {} but got {}.", + r.balanceBeforeIn, r.balanceAfterRoundtrip + ), )); } - let computed_recipient_after = r - .recipientBefore + // Roundtrip: holder received less than expected (fee left settlement but not to holder) + let expected_holder_after = r + .holderBefore + .checked_sub(amount) + .ok_or("holder balance underflow")? .checked_add(middle_amount) - .ok_or("recipient balance overflow when checking transfer out")?; - if r.recipientAfter != computed_recipient_after { - return Ok(( - TokenQuality::bad(format!( - "Transferring {amount} to arbitrary recipient {recipient:#x} was expected \ - to result in a balance of {computed_recipient_after} but got {}. The token \ - likely takes a fee on transfer.", - r.recipientAfter, - )), - Some(gas_per_transfer), - Some(fees), + .ok_or("holder balance overflow")?; + if r.holderAfter != expected_holder_after { + let fee_raw = expected_holder_after.saturating_sub(r.holderAfter); + let fee_bps = fee_raw + .saturating_mul(U256::from(10_000)) + .checked_div(middle_amount.max(U256::from(1))) + .unwrap_or(U256::ZERO); + issues.push(( + false, + fee_bps, + format!( + "Roundtrip transfer back to holder {holder:#x} was expected to result in a \ + balance of {expected_holder_after} but got {}. The token takes a fee even \ + on roundtrip transfers.", + r.holderAfter + ), )); } + // Sell: transfer to arbitrary recipient reverted + if !r.transferOutOk { + issues.push(( + true, + U256::ZERO, + format!( + "Transfer of token out of settlement contract to arbitrary recipient \ + {recipient:#x} failed" + ), + )); + } else { + // Sell: recipient received less than expected (sell-only fee) + let computed_recipient_after = r + .recipientBefore + .checked_add(roundtrip_received) + .ok_or("recipient balance overflow")?; + if r.recipientAfter != computed_recipient_after { + let fee_raw = computed_recipient_after.saturating_sub(r.recipientAfter); + let fee_bps = fee_raw + .saturating_mul(U256::from(10_000)) + .checked_div(roundtrip_received.max(U256::from(1))) + .unwrap_or(U256::ZERO); + issues.push(( + false, + fee_bps, + format!( + "Sell-only fee: transferring {roundtrip_received} to arbitrary recipient \ + {recipient:#x} was expected to result in a balance of \ + {computed_recipient_after} but got {}.", + r.recipientAfter + ), + )); + } + + // Sell: settlement kept tokens + if r.balanceAfterOut > r.balanceBeforeIn { + let kept = r.balanceAfterOut - r.balanceBeforeIn; + let fee_bps = kept + .saturating_mul(U256::from(10_000)) + .checked_div(roundtrip_received.max(U256::from(1))) + .unwrap_or(U256::ZERO); + issues.push(( + false, + fee_bps, + format!( + "Transferring {roundtrip_received} out of settlement was expected to \ + restore the original balance of {} but got {}.", + r.balanceBeforeIn, r.balanceAfterOut + ), + )); + } + } + + // Take the worst issue: revert beats any fee; higher fee_bps beats lower. + let worst = issues + .into_iter() + .max_by(|(rev_a, bps_a, _), (rev_b, bps_b, _)| rev_a.cmp(rev_b).then(bps_a.cmp(bps_b))); + + if let Some((_, _, reason)) = worst { + return Ok((TokenQuality::bad(reason), Some(gas_per_transfer), Some(fees))); + } + if !r.approvalOk { return Ok(( TokenQuality::bad("Approval of U256::MAX failed".to_string()), @@ -273,19 +428,26 @@ mod tests { const COWSWAP_SETTLEMENT: Address = address!("c9f2e6ea1637E499406986ac50ddC92401ce1f58"); - // Return value builder for unit tests — all fields default to zero / true. + // Builds a fully-fee-free return value. The holder starts with 2x `amount` so + // the roundtrip accounting (holderBefore - amount + received = holderBefore) holds. fn good_return(amount: U256) -> ::Return { type R = ::Return; + let holder_balance = amount * U256::from(2); R { transferInOk: true, + roundtripOutOk: true, transferOutOk: true, approvalOk: true, balanceBeforeIn: U256::ZERO, balanceAfterIn: amount, + balanceAfterRoundtrip: U256::ZERO, + holderBefore: holder_balance, + holderAfter: holder_balance, // no roundtrip fee: holder balance unchanged balanceAfterOut: U256::ZERO, recipientBefore: U256::ZERO, recipientAfter: amount, gasIn: U256::from(30_000_u64), + roundtripGasOut: U256::from(25_000_u64), gasOut: U256::from(25_000_u64), } } @@ -313,10 +475,10 @@ mod tests { } #[test] - fn handle_response_transfer_out_failed() { + fn handle_response_roundtrip_failed() { let amount = U256::from(1_000_000_u64); let mut r = good_return(amount); - r.transferOutOk = false; + r.roundtripOutOk = false; let (quality, gas, tax) = EthCallDetector::handle_response(r, amount, Address::ZERO).unwrap(); assert!(matches!(quality, TokenQuality::Bad { .. })); @@ -324,6 +486,18 @@ mod tests { assert!(tax.is_none()); } + #[test] + fn handle_response_transfer_out_failed() { + let amount = U256::from(1_000_000_u64); + let mut r = good_return(amount); + r.transferOutOk = false; + let (quality, gas, tax) = + EthCallDetector::handle_response(r, amount, Address::ZERO).unwrap(); + assert!(matches!(quality, TokenQuality::Bad { .. })); + assert!(gas.is_some()); + assert!(tax.is_some()); + } + #[test] fn handle_response_approval_failed() { let amount = U256::from(1_000_000_u64); @@ -337,19 +511,52 @@ mod tests { } #[test] - fn handle_response_fee_on_transfer_inbound() { - // Token takes 1% fee: 1_000_000 sent, only 990_000 received. + fn handle_response_buy_fee() { + // Token takes 1% fee on inbound: 1_000_000 sent, only 990_000 reaches settlement. + // The sell leg should pass cleanly — do NOT touch recipientAfter so that it stays + // equal to roundtrip_received (= amount, since holderBefore/holderAfter are unchanged). let amount = U256::from(1_000_000_u64); - let received = U256::from(990_000_u64); let mut r = good_return(amount); - r.balanceAfterIn = received; - r.recipientAfter = received; // recipient gets what settlement received + r.balanceAfterIn = U256::from(990_000_u64); let (quality, gas, tax) = EthCallDetector::handle_response(r, amount, Address::ZERO).unwrap(); - assert!(matches!(quality, TokenQuality::Bad { .. })); + assert!( + matches!(quality, TokenQuality::Bad { reason } if !reason.starts_with("Sell-only fee")) + ); + assert!(gas.is_some()); + assert_eq!(tax, Some(U256::from(100_u64))); // ~100 bps (1%) + } + + #[test] + fn handle_response_roundtrip_sell_fee() { + // Token takes 1% fee even when selling back to the holder (LP pool). + let amount = U256::from(1_000_000_u64); + let sell_fee = U256::from(10_000_u64); + let mut r = good_return(amount); + // holderBefore = 2_000_000, after roundtrip holder should have 2_000_000 but got less. + r.holderAfter = r.holderBefore - sell_fee; + let (quality, gas, _tax) = + EthCallDetector::handle_response(r, amount, Address::ZERO).unwrap(); + assert!( + matches!(quality, TokenQuality::Bad { reason } if !reason.starts_with("Sell-only fee")) + ); assert!(gas.is_some()); - // Fee should be ~100 bps (1%) - assert_eq!(tax, Some(U256::from(100_u64))); + } + + #[test] + fn handle_response_sell_only_fee() { + // Token has no fee on roundtrip but charges 1% when selling to an arbitrary address. + let amount = U256::from(1_000_000_u64); + let mut r = good_return(amount); + // Arbitrary recipient gets only 990_000 instead of 1_000_000. + r.recipientAfter = U256::from(990_000_u64); + let (quality, gas, tax) = + EthCallDetector::handle_response(r, amount, Address::ZERO).unwrap(); + assert!( + matches!(quality, TokenQuality::Bad { reason } if reason.starts_with("Sell-only fee")) + ); + assert!(gas.is_some()); + assert!(tax.is_some()); } impl TestFixture { @@ -394,6 +601,50 @@ mod tests { assert_eq!(tax, Some(U256::ZERO)); } + mod bsc { + use alloy::primitives::address; + + use super::*; + use crate::test_fixtures::{BSC_SELL_FEE_TOKEN_STR, BSC_TOKEN_HOLDERS}; + + const BSC_COWSWAP_SETTLEMENT: Address = + address!("9008D19f58AAbD9eD0D60971565AA8510560ab41"); + + impl TestFixture { + pub(crate) fn create_bsc_ethcall_detector(&self) -> EthCallDetector { + let rpc = self.create_rpc_client(false); + let finder = TokenOwnerStore::new(BSC_TOKEN_HOLDERS.clone()); + EthCallDetector::new(&rpc, Arc::new(finder), BSC_COWSWAP_SETTLEMENT) + } + } + + #[tokio::test] + #[ignore = "require BSC_RPC_URL"] + async fn bsc_sell_only_fee_token() { + let fixture = TestFixture::new_bsc(); + let detector = fixture.create_bsc_ethcall_detector(); + let token = Address::from_str(BSC_SELL_FEE_TOKEN_STR).unwrap(); + + let (quality, gas, _tax) = detector + .detect_impl(token, BlockTag::Latest) + .await + .expect("detect_impl failed"); + + // This token charges a sell fee in two possible ways: + // • "Sell-only fee" prefix — fee on transfers to non-whitelisted addresses + // (to=arbitrary) • "roundtrip" in reason — fee on transfers TO the LP + // pair (to=LP = the DEX sell direction) Both are sell-fee patterns with no + // buy fee on leg 1. + assert!( + matches!(&quality, TokenQuality::Bad { reason } + if reason.starts_with("Sell-only fee") || reason.contains("roundtrip")), + "expected sell-fee detection, got: {:?}", + quality, + ); + assert!(gas.is_some()); + } + } + mod arbitrum { use super::*; use crate::test_fixtures::{ARB_ARB_STR, ARB_TOKEN_HOLDERS, ARB_USDC_STR, ARB_WETH_STR}; diff --git a/crates/tycho-ethereum/src/test_fixtures.rs b/crates/tycho-ethereum/src/test_fixtures.rs index 9d6d0a74ca..07a4235a00 100644 --- a/crates/tycho-ethereum/src/test_fixtures.rs +++ b/crates/tycho-ethereum/src/test_fixtures.rs @@ -40,6 +40,13 @@ pub const ARB_USDC_HOLDER_ADDR: &str = "0xC6962004f452bE9203591991D15f6b388e09E8 pub const ARB_WETH_HOLDER_ADDR: &str = "0xC6962004f452bE9203591991D15f6b388e09E8D0"; // USDC/WETH V3 pub const ARB_ARB_HOLDER_ADDR: &str = "0x92c63d0e701CAAe670C9415d91C474F686298f00"; // ARB/ETH V3 +// BSC mainnet token addresses +// Sell-only fee token: charges a fee when transferring to non-whitelisted addresses but not +// when transferring to/from the LP pool itself. +pub const BSC_SELL_FEE_TOKEN_STR: &str = "0x701add4311e85c1f9c1549319fe2c476bc8a1b8b"; +// Liquidity pair that holds the token. +pub const BSC_SELL_FEE_TOKEN_HOLDER_STR: &str = "0x1831Bb2723CED46e1b6c08d2f3ae50b2Ab9427B9"; + // Common token addresses array pub const TOKEN_ADDRESSES: [&str; 5] = [BALANCER_VAULT_STR, STETH_STR, DAI_STR, WBTC_STR, USDC_STR]; @@ -76,6 +83,18 @@ pub static TOKEN_HOLDERS: LazyLock> = LazyLock: ]) }); +/// Lazily-initialized map of BSC mainnet token addresses to their known holders and balances. +pub static BSC_TOKEN_HOLDERS: LazyLock> = LazyLock::new(|| { + HashMap::from([( + Address::from_str(BSC_SELL_FEE_TOKEN_STR).unwrap(), + ( + Bytes::from_str(BSC_SELL_FEE_TOKEN_HOLDER_STR).unwrap(), + // 200_000 raw units → amount = 100_000 = MIN_AMOUNT, safe for any decimal count. + Bytes::from_str("0x030d40").unwrap(), + ), + )]) +}); + /// Lazily-initialized map of Arbitrum mainnet token addresses to their known holders and balances. pub static ARB_TOKEN_HOLDERS: LazyLock> = LazyLock::new(|| { HashMap::from([ @@ -178,6 +197,18 @@ impl TestFixture { ); Self { block, inner_rpc, url } } + + /// Creates a test fixture for BSC using `BSC_RPC_URL`. + pub fn new_bsc() -> Self { + let url = std::env::var("BSC_RPC_URL").expect("BSC_RPC_URL must be set for BSC tests"); + let inner_rpc = ClientBuilder::default().http( + url.parse() + .expect("Invalid BSC_RPC_URL"), + ); + let block = + Block::new(0, Chain::Bsc, Default::default(), Default::default(), Default::default()); + Self { block, inner_rpc, url } + } } impl Default for TestFixture { diff --git a/crates/tycho-simulation/examples/price_printer/main.rs b/crates/tycho-simulation/examples/price_printer/main.rs index 754f8e5f3f..c11e0a736c 100644 --- a/crates/tycho-simulation/examples/price_printer/main.rs +++ b/crates/tycho-simulation/examples/price_printer/main.rs @@ -89,6 +89,20 @@ fn register_exchanges( .exchange::("uniswap_v3", tvl_filter.clone(), None) .exchange::("uniswap_v4", tvl_filter.clone(), None) } + Chain::Arbitrum => { + builder = builder + .exchange::("uniswap_v2", tvl_filter.clone(), None) + .exchange::("uniswap_v3", tvl_filter.clone(), None) + .exchange::("pancakeswap_v3", tvl_filter.clone(), None) + .exchange::("uniswap_v4", tvl_filter.clone(), None) + } + Chain::Polygon => { + builder = builder + .exchange::("uniswap_v2", tvl_filter.clone(), None) + .exchange::("uniswap_v3", tvl_filter.clone(), None) + .exchange::("uniswap_v4", tvl_filter.clone(), None) + .exchange::("quickswap_v2", tvl_filter.clone(), None) + } _ => {} } builder