tycho-simulation 0.451.0

Provides tools for interacting with protocol states, calculating spot prices, and quoting token swaps.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
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). A pool carrying it is
///   kept only if its id is on a trusted list.
/// - `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 has_untrusted_rebase_tokens(component) || 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 the pool lists `rebase_tokens` and is not on the trusted list below.
fn has_untrusted_rebase_tokens(component: &ComponentWithState) -> bool {
    // Pools whose rebasing coins the hybrid quotes correctly. An entry must read each rebasing
    // coin's balance live through `balanceOf(pool)` rather than caching it in pool storage, so
    // the substreams' `balanceOf` DCI entrypoint captures every slot a rebase changes and the pool
    // is re-emitted when it does.
    //
    // Both stETH pools hold native ETH, which the pool reads as `self.balance`. That balance is
    // only correct from tycho-substreams 0.8.2 (#1544): earlier packages missed ETH sent out of
    // the pool, and the legacy pool's indexed native balance drifted ~299 ETH above the chain.
    //
    // Checked on Ethereum on 2026-10-09: the hybrid quote equalled the pool's `get_dy` to the wei
    // in both directions over 5 ETH swaps (blocks 26152141-26152169), and the 17 slots DCI traces
    // for `balanceOf(pool)` matched the chain at block 26152079. Executed swaps land 1-2 wei
    // under the quote because a stETH transfer rounds down to whole shares; `get_dy` does not
    // model that either.
    const TRUSTED_REBASE_POOLS: [&str; 2] = [
        // Lido ETH/stETH, the legacy StableSwap pool.
        "0xdc24316b9ae028f1497c275eb9192a3ea0f67022",
        // ETH/stETH-ng, a plain pool from the meta pool factory.
        "0x21e27a5e5513d6e65c4f830167390997aa84843a",
    ];

    if !attr_json_list_non_empty(&component.component.static_attributes, "rebase_tokens") {
        return false;
    }
    let id = &component.component.id;
    !TRUSTED_REBASE_POOLS
        .iter()
        .any(|trusted| id.eq_ignore_ascii_case(trusted))
}

/// 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"]"#)])));
    }

    fn steth_pool(id: &str) -> ComponentWithState {
        let mut component = curve_component(&[(
            "rebase_tokens",
            r#"["0xae7ab96520de3a18e5e111b5eaab095312d7fe84"]"#,
        )]);
        component.component.id = id.to_string();
        component
    }

    #[test]
    fn curve_excludes_rebase_tokens() {
        // ETH/ETHx exposes a non-empty rebase_tokens list.
        assert!(!curve_filter(&curve_component(&[(
            "rebase_tokens",
            r#"["0xa35b1b31ce002fbf2058d22f30f95d405200a15b"]"#
        )])));
        // A stETH pool outside the trusted list stays out.
        assert!(!curve_filter(&steth_pool("0x1111111111111111111111111111111111111111")));
    }

    #[test]
    fn curve_keeps_trusted_rebase_pools_regardless_of_id_case() {
        assert!(curve_filter(&steth_pool("0xDC24316b9AE028F1497c275EB9192a3Ea0f67022")));
        assert!(curve_filter(&steth_pool("0x21e27a5e5513d6e65c4f830167390997aa84843a")));
    }

    #[test]
    fn curve_trusted_rebase_pool_still_checks_asset_types() {
        let mut component = steth_pool("0xdc24316b9ae028f1497c275eb9192a3ea0f67022");
        component
            .component
            .static_attributes
            .extend(attrs(&[("asset_types", r#"["0x00","0x03"]"#)]));
        assert!(!curve_filter(&component));
    }

    #[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"]"#)])));
    }
}