tycho-simulation 0.435.2

Provides tools for interacting with protocol states, calculating spot prices, and quoting token swaps.
Documentation
use std::collections::HashMap;

use tracing::{debug, info};
use tycho_client::feed::synchronizer::ComponentWithState;
use tycho_common::Bytes;

pub use crate::evm::protocol::ekubo_v3::{
    filter_fn as ekubo_v3_extension_filter,
    filter_fn_with_signed_exclusive_swap as ekubo_v3_extension_filter_with_signed_exclusive_swap,
};

/// Filters out pools that DCI currently fails to find some accounts for
pub fn balancer_v2_pool_filter(component: &ComponentWithState) -> bool {
    const UNSUPPORTED_COMPONENT_IDS: [&str; 6] = [
        "0x848a5564158d84b8a8fb68ab5d004fae11619a5400000000000000000000066a",
        "0x596192bb6e41802428ac943d2f1476c1af25cc0e000000000000000000000659",
        "0x05ff47afada98a98982113758878f9a8b9fdda0a000000000000000000000645",
        "0x265b6d1a6c12873a423c177eba6dd2470f40a3b50001000000000000000003fd",
        "0x9f9d900462492d4c21e9523ca95a7cd86142f298000200000000000000000462",
        "0x42ed016f826165c2e5976fe5bc3df540c5ad0af700000000000000000000058b",
    ];

    if UNSUPPORTED_COMPONENT_IDS.contains(
        &component
            .component
            .id
            .to_lowercase()
            .as_str(),
    ) {
        debug!(
            "Filtering out Balancer pools {} that have missing Accounts after DCI update.",
            component.component.id
        );
        return false;
    }

    true
}

/// Filters out uniswap v4 pools with non-Euler hooks
pub fn uniswap_v4_euler_hook_pool_filter(component: &ComponentWithState) -> bool {
    component
        .component
        .static_attributes
        .get("hook_identifier")
        .and_then(|bytes| std::str::from_utf8(bytes).ok())
        .is_some_and(|s| s == "euler_v1")
}

/// Filters out uniswap v4 pools with non-Angstrom hooks
pub fn uniswap_v4_angstrom_hook_pool_filter(component: &ComponentWithState) -> bool {
    component
        .component
        .static_attributes
        .get("hook_identifier")
        .and_then(|bytes| std::str::from_utf8(bytes).ok())
        .is_some_and(|s| s == "angstrom_v1")
}

/// Filters out uniswap v4 pools with Angstrom hooks.
///
/// Encoding an Angstrom swap requires per-block attestations fetched from the Angstrom API,
/// authenticated with `ANGSTROM_API_KEY`. Without the key the swap fails at encoding time —
/// after route selection — so consumers without the key should exclude these pools up front.
/// [`ProtocolStreamBuilder`](crate::evm::stream::ProtocolStreamBuilder) applies this filter to
/// `uniswap_v4_hooks` whenever the key is unset, alongside any filter function the caller passes.
pub fn uniswap_v4_non_angstrom_hook_pool_filter(component: &ComponentWithState) -> bool {
    !uniswap_v4_angstrom_hook_pool_filter(component)
}

/// Filters out pools that rely on ERC4626 in Balancer V3
pub fn balancer_v3_pool_filter(component: &ComponentWithState) -> bool {
    if let Some(erc4626) = component
        .component
        .static_attributes
        .get("erc4626")
    {
        if erc4626.to_vec() == [1u8] {
            info!(
                "Filtering out Balancer V3 pool {} because it uses ERC4626",
                component.component.id
            );
            return false;
        }
    }
    true
}

pub fn fluid_v1_paused_pools_filter(component: &ComponentWithState) -> bool {
    const PAUSED_POOLS: [&str; 5] = [
        // The components below are properly paused by substreams but the way indexer
        // handles tracing atm wrongly paused all components due to tracing failure. The
        // failure is unrelated to any issues with the protocol itself.
        "0x97479d9c09c7fd333bbfd07e93d4c8a669698ebc",
        "0xd0810e5cf08dcde266ecebef40cad806c7768d72",
        "0xf507a38aaf37339cc3beac4c7a58b17401bdf6bc",
        // The substreams did not detect this component as paused. It still reports
        // a high tvl value.
        "0x2886a01a0645390872a9eb99dae1283664b0c524",
        "0x276084527b801e00db8e4410504f9baf93f72c67",
    ];

    if PAUSED_POOLS.contains(
        &component
            .component
            .id
            .to_lowercase()
            .as_str(),
    ) {
        return false;
    }
    true
}

/// Filters `vm:curve` components to those the hybrid `CurveState` can quote correctly.
///
/// Excludes pools with rate-bearing or rebasing coins, unless every such coin is an oracle coin
/// whose rate provider is on a trusted list. Such a coin prices through a per-block
/// rate (an oracle, an ERC4626 vault, or an in-place rebase exposed via `stored_rates`) whose
/// source is an external contract. The hybrid reads that rate via VM getters against the locally
/// indexed storage. If DCI does not capture every slot the rate read touches, the hybrid gets a
/// stale or default value and the quote drifts from the on-chain swap (observed up to ~30% on
/// oracle-rate NG pools). Some providers also return an inflated rate to callers that look like
/// a quote.
///
/// Detection uses the substreams' static markers:
/// - `rebase_tokens` — non-empty list of rebasing coins (e.g. stETH, ETHx).
/// - `asset_types` — per-coin Curve NG asset type encoded as a hex int. Standard coins encode as
///   `"0x"` / `"0x00"`. An oracle coin (`"0x01"`) is kept only if its entry in `oracles` and
///   `method_ids` is a trusted provider. Rebasing (`"0x02"`) and ERC4626 (`"0x03"`) coins are
///   always excluded.
pub fn curve_filter(component: &ComponentWithState) -> bool {
    let attrs = &component.component.static_attributes;
    if attr_json_list_non_empty(attrs, "rebase_tokens") || has_unsupported_asset_type(attrs) {
        debug!(
            "Filtering out curve pool {} with rate-bearing/rebasing coins (unsupported by hybrid)",
            component.component.id
        );
        return false;
    }
    true
}

/// Filters out Killed LiquidityParty pools
///
/// The Substreams module sets a `killed` dynamic attribute (`0x01`) and this
/// filter removes such components from the stream.
/// Attempting a swap on a killed pool will revert.
pub fn liquidityparty_killed_pools_filter(component: &ComponentWithState) -> bool {
    if let Some(killed) = component.state.attributes.get("killed") {
        if killed.to_vec() == [1u8] {
            debug!(
                "Filtering out LiquidityParty pool {} because it is killed",
                component.component.id
            );
            return false;
        }
    }
    true
}

/// Parse a static attribute whose value is the UTF-8 bytes of a JSON array of strings.
fn attr_json_list(attrs: &HashMap<String, Bytes>, key: &str) -> Option<Vec<String>> {
    let text = std::str::from_utf8(attrs.get(key)?.as_ref()).ok()?;
    serde_json::from_str::<Vec<String>>(text).ok()
}

/// True when `key` is a present, non-empty JSON list (the substreams only emits `rebase_tokens`
/// when at least one coin rebases).
fn attr_json_list_non_empty(attrs: &HashMap<String, Bytes>, key: &str) -> bool {
    attr_json_list(attrs, key).is_some_and(|list| !list.is_empty())
}

/// True when `asset_types` contains a coin the hybrid cannot quote: any non-standard coin other
/// than an oracle coin backed by a trusted rate provider. Each entry is a hex-encoded integer
/// (`"0x"`/`"0x00"` = standard; `"0x01"` oracle, `"0x02"` rebasing, `"0x03"` ERC4626). A pool
/// without `asset_types` has only standard coins.
fn has_unsupported_asset_type(attrs: &HashMap<String, Bytes>) -> bool {
    // Oracle rate providers as `(oracle, method id)` pairs, matched against the pool's `oracles`
    // and `method_ids` static attributes. An entry must return the same rate to a quote as to a
    // swap (no branch on the caller or the gas price, no value set from outside the issuer), and
    // DCI must capture every storage slot the rate read touches, so the pool is re-emitted when
    // the rate changes.
    const TRUSTED_RATE_ORACLES: [(&str, &str); 1] = [
        // weETH `getRate()` on Ethereum: the rate is ether.fi's own share accounting. The
        // weETH/WETH NG pool quoted wei-exact against `get_dy` over 1000 blocks, rate updates
        // included.
        ("0xcd5fe23c85820f7b72d0926fc9b05b43e359b7ee", "0x679aefce"),
    ];

    let Some(types) = attr_json_list(attrs, "asset_types") else {
        return false;
    };
    let oracles = attr_json_list(attrs, "oracles").unwrap_or_default();
    let method_ids = attr_json_list(attrs, "method_ids").unwrap_or_default();
    for (index, entry) in types.iter().enumerate() {
        let asset_type = entry
            .trim_start_matches("0x")
            .trim_start_matches('0');
        if asset_type.is_empty() {
            continue;
        }
        let (Some(oracle), Some(method_id)) = (oracles.get(index), method_ids.get(index)) else {
            return true;
        };
        let trusted = TRUSTED_RATE_ORACLES
            .iter()
            .any(|(trusted_oracle, trusted_method_id)| {
                oracle.eq_ignore_ascii_case(trusted_oracle) &&
                    method_id.eq_ignore_ascii_case(trusted_method_id)
            });
        if asset_type != "1" || !trusted {
            return true;
        }
    }
    false
}

/// Filters out ERC4626 vaults that cannot be quoted correctly.
///
/// FIXME: spETH, sUSDS and sUSDC are knowingly left in, and are correct only where `vm:curve` is
/// indexed: its components trace their rate getter at a block where the accrual branch runs,
/// which is what puts `vsr`/`ssr` into the indexed slot set. A deployment without it fails to
/// decode them exactly like the vaults listed below. They stay because consumers route through
/// them, and each one is a whole leg rather than depth — a vault converts at a fixed rate at any
/// size up to its caps — so excluding one takes USDS/sUSDS, USDC/sUSDC or WETH/spETH out of the
/// graph outright. The fix belongs in the substreams: emit an entrypoint per rate getter, so the
/// slot is captured whichever branch a conversion takes.
pub fn erc4626_filter(component: &ComponentWithState) -> bool {
    const UNSUPPORTED_POOLS: [&str; 3] = [
        // Spark Vault V2 (spUSDC, spUSDT). Their rate is `nowChi()`, which reads `vsr` (slot 4)
        // only on the `block.timestamp > rho` branch. Any deposit/withdraw leaves
        // `rho == block.timestamp`, and entrypoints are traced once, at the component's first
        // one — so the trace takes the `chi` branch and never touches `vsr`. The vault is one
        // of the component's tokens, so DCI indexes only the slots the trace saw: `vsr` reads
        // back as 0, `_rpow(0, dt)` collapses `nowChi()` to 0, and `convertToShares` reverts
        // on the division, so the component fails to decode. Confirmed against production.
        // spETH is not affected: a Curve pool holding it traces `convertToAssets` off a
        // non-drip block, so `vsr` is captured and stays indexed.
        "0x28b3a8fb53b741a8fd78c0fb9a6b2393d896a43d",
        "0xe2e7a17dff93280dec073c995595155283e3c372",
        // sDAI: `maxDeposit` returns `type(uint256).max`, which `ERC4626State` takes at face
        // value as the deposit limit, so deposits quote unbounded. The real limit is not
        // exposed on-chain in any form we can trace, and there is no generic fix.
        // This is not the branch-tracing issue that excludes the Spark V2 vaults above:
        // sDAI's rate lives on `pot` (`0x197E90f9FAD81970bA7976f33CbD77088E5D7cf7`), which
        // both branches call and which DCI full-indexes since it is not a component token.
        "0x83f20f44975d03b1b09e64809b757c47f942beea",
    ];
    if UNSUPPORTED_POOLS.contains(
        &component
            .component
            .id
            .to_lowercase()
            .as_str(),
    ) {
        return false;
    }
    true
}

#[cfg(test)]
mod tests {
    use tycho_common::models::protocol::{ProtocolComponent, ProtocolComponentState};

    use super::*;

    fn attrs(pairs: &[(&str, &str)]) -> HashMap<String, Bytes> {
        pairs
            .iter()
            .map(|(k, v)| (k.to_string(), Bytes::from(v.as_bytes().to_vec())))
            .collect()
    }

    fn hooks_component(hook_identifier: Option<&str>) -> ComponentWithState {
        let static_attributes = match hook_identifier {
            Some(id) => attrs(&[("hook_identifier", id)]),
            None => HashMap::new(),
        };
        ComponentWithState {
            state: ProtocolComponentState::new("test_pool", HashMap::new(), HashMap::new()),
            component: ProtocolComponent { static_attributes, ..Default::default() },
            component_tvl: None,
            entrypoints: Vec::new(),
        }
    }

    fn curve_component(static_attributes: &[(&str, &str)]) -> ComponentWithState {
        ComponentWithState {
            state: ProtocolComponentState::new("curve_pool", HashMap::new(), HashMap::new()),
            component: ProtocolComponent {
                static_attributes: attrs(static_attributes),
                ..Default::default()
            },
            component_tvl: None,
            entrypoints: Vec::new(),
        }
    }

    fn erc4626_component(id: &str) -> ComponentWithState {
        ComponentWithState {
            state: ProtocolComponentState::new(id, HashMap::new(), HashMap::new()),
            component: ProtocolComponent { id: id.to_string(), ..Default::default() },
            component_tvl: None,
            entrypoints: Vec::new(),
        }
    }

    #[test]
    fn non_angstrom_filter_excludes_angstrom_pools() {
        assert!(!uniswap_v4_non_angstrom_hook_pool_filter(&hooks_component(Some("angstrom_v1"))));
        assert!(uniswap_v4_non_angstrom_hook_pool_filter(&hooks_component(Some("euler_v1"))));
        assert!(uniswap_v4_non_angstrom_hook_pool_filter(&hooks_component(None)));
    }

    #[test]
    fn curve_keeps_standard_pool() {
        // 3pool-style: no rate markers.
        assert!(curve_filter(&curve_component(&[])));
        // An all-standard NG pool emits asset_types but every entry is zero.
        assert!(curve_filter(&curve_component(&[("asset_types", r#"["0x00","0x00"]"#)])));
    }

    fn oracle_pool(asset_type: &str, oracle: &str, method_id: &str) -> ComponentWithState {
        let asset_types = format!(r#"["0x00","{asset_type}"]"#);
        let oracles = format!(r#"["0x0000000000000000000000000000000000000000","{oracle}"]"#);
        let method_ids = format!(r#"["0x00000000","{method_id}"]"#);
        curve_component(&[
            ("asset_types", asset_types.as_str()),
            ("oracles", oracles.as_str()),
            ("method_ids", method_ids.as_str()),
        ])
    }

    #[test]
    fn curve_keeps_trusted_oracle_asset_type() {
        // weETH/WETH-ng: coin 1 prices through weETH `getRate()`. Checksummed input still matches.
        assert!(curve_filter(&oracle_pool(
            "0x01",
            "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee",
            "0x679aefce"
        )));
    }

    #[test]
    fn curve_excludes_untrusted_rate_coins() {
        let weeth = "0xcd5fe23c85820f7b72d0926fc9b05b43e359b7ee";
        // Oracle outside the allowlist.
        assert!(!curve_filter(&oracle_pool(
            "0x01",
            "0x1111111111111111111111111111111111111111",
            "0x679aefce"
        )));
        // Trusted oracle, other method.
        assert!(!curve_filter(&oracle_pool("0x01", weeth, "0x12345678")));
        // The allowlist covers oracle coins only: ERC4626 stays out.
        assert!(!curve_filter(&oracle_pool("0x03", weeth, "0x679aefce")));
        // Oracle coin without the oracle attributes.
        assert!(!curve_filter(&curve_component(&[("asset_types", r#"["0x00","0x01"]"#)])));
    }

    #[test]
    fn curve_excludes_rebase_tokens() {
        // ETH/ETHx and legacy stETH expose a non-empty rebase_tokens list.
        assert!(!curve_filter(&curve_component(&[(
            "rebase_tokens",
            r#"["0xa35b1b31ce002fbf2058d22f30f95d405200a15b"]"#
        )])));
    }

    #[test]
    fn erc4626_excludes_unsupported_pools_regardless_of_id_case() {
        // Component ids arrive checksummed; the entries must be lower case to ever match.
        assert!(!erc4626_filter(&erc4626_component("0x28B3a8fb53B741A8Fd78c0fb9A6B2393d896a43d")));
        assert!(!erc4626_filter(&erc4626_component("0x28b3a8fb53b741a8fd78c0fb9a6b2393d896a43d")));
        assert!(erc4626_filter(&erc4626_component("0xa3931d71877c0e7a3148cb7eb4463524fec27fbd")));
    }

    #[test]
    fn curve_zero_encoded_as_empty_hex_is_standard() {
        // BigInt 0 serializes to "0x" (empty bytes); must count as standard.
        assert!(curve_filter(&curve_component(&[("asset_types", r#"["0x","0x"]"#)])));
    }
}