Skip to main content

tycho_simulation/evm/protocol/uniswap_v4/
decoder.rs

1use std::collections::HashMap;
2
3use alloy::primitives::{Address, U256};
4use itertools::Itertools;
5use tycho_client::feed::{synchronizer::ComponentWithState, BlockHeader};
6use tycho_common::{models::token::Token, simulation::protocol_sim::ProtocolSim, Bytes};
7
8use super::state::UniswapV4State;
9use crate::{
10    evm::protocol::{
11        uniswap_v4::{
12            hooks::{
13                hook_handler_creator::{instantiate_hook_handler, HookCreationParams},
14                utils::{has_permission, HookOptions},
15            },
16            state::UniswapV4Fees,
17        },
18        utils::{
19            bytes_to_address,
20            uniswap::{i24_be_bytes_to_i32, tick_list::TickInfo},
21        },
22    },
23    protocol::{
24        errors::InvalidSnapshotError,
25        models::{DecoderContext, TryFromWithBlock},
26    },
27};
28
29/// Whether a hook can take part in a swap at all, which it can exactly when its address carries
30/// the `beforeSwap` or the `afterSwap` permission bit. Uniswap V4 encodes a hook's permissions in
31/// the low bits of its address and the pool manager calls only the callbacks those bits allow, so
32/// a hook without either bit cannot change what a swap does. The zero address carries no bits and
33/// so falls out of this check as well.
34fn hook_participates_in_swaps(address: Address) -> bool {
35    has_permission(address, HookOptions::BeforeSwap) ||
36        has_permission(address, HookOptions::AfterSwap)
37}
38
39impl TryFromWithBlock<ComponentWithState, BlockHeader> for UniswapV4State {
40    type Error = InvalidSnapshotError;
41
42    /// Decodes a `ComponentWithState` into a `UniswapV4State`. Errors with a `InvalidSnapshotError`
43    /// if the snapshot is missing any required attributes.
44    async fn try_from_with_header(
45        snapshot: ComponentWithState,
46        _block: BlockHeader,
47        account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
48        all_tokens: &HashMap<Bytes, Token>,
49        decoder_context: &DecoderContext,
50    ) -> Result<Self, Self::Error> {
51        let liq = snapshot
52            .state
53            .attributes
54            .get("liquidity")
55            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("liquidity".to_string()))?
56            .clone();
57
58        let liquidity = u128::from(liq);
59
60        let sqrt_price = U256::from_be_slice(
61            snapshot
62                .state
63                .attributes
64                .get("sqrt_price_x96")
65                .ok_or_else(|| InvalidSnapshotError::MissingAttribute("sqrt_price".to_string()))?,
66        );
67
68        let lp_fee = u32::from(
69            snapshot
70                .component
71                .static_attributes
72                .get("key_lp_fee")
73                .ok_or_else(|| InvalidSnapshotError::MissingAttribute("key_lp_fee".to_string()))?
74                .clone(),
75        );
76
77        let zero2one_protocol_fee = u32::from(
78            snapshot
79                .state
80                .attributes
81                .get("protocol_fees/zero2one")
82                .ok_or_else(|| {
83                    InvalidSnapshotError::MissingAttribute("protocol_fees/zero2one".to_string())
84                })?
85                .clone(),
86        );
87        let one2zero_protocol_fee = u32::from(
88            snapshot
89                .state
90                .attributes
91                .get("protocol_fees/one2zero")
92                .ok_or_else(|| {
93                    InvalidSnapshotError::MissingAttribute("protocol_fees/one2zero".to_string())
94                })?
95                .clone(),
96        );
97
98        let fees: UniswapV4Fees =
99            UniswapV4Fees::new(zero2one_protocol_fee, one2zero_protocol_fee, lp_fee);
100
101        let tick_spacing: i32 = i32::from(
102            snapshot
103                .component
104                .static_attributes
105                .get("tick_spacing")
106                .ok_or_else(|| InvalidSnapshotError::MissingAttribute("tick_spacing".to_string()))?
107                .clone(),
108        );
109
110        let tick = i24_be_bytes_to_i32(
111            snapshot
112                .state
113                .attributes
114                .get("tick")
115                .ok_or_else(|| InvalidSnapshotError::MissingAttribute("tick".to_string()))?,
116        );
117
118        let ticks: Result<Vec<_>, _> = snapshot
119            .state
120            .attributes
121            .iter()
122            .filter_map(|(key, value)| {
123                if key.starts_with("ticks/") {
124                    Some(
125                        key.split('/')
126                            .nth(1)?
127                            .parse::<i32>()
128                            .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
129                            .and_then(|tick_index| {
130                                TickInfo::new(tick_index, i128::from(value.clone())).map_err(
131                                    |err| InvalidSnapshotError::ValueError(err.to_string()),
132                                )
133                            }),
134                    )
135                } else {
136                    None
137                }
138            })
139            .collect();
140
141        let hook_attribute = snapshot
142            .component
143            .static_attributes
144            .get("hooks");
145
146        let mut ticks = match ticks {
147            Ok(ticks) if !ticks.is_empty() => ticks
148                .into_iter()
149                .filter(|t| t.net_liquidity != 0)
150                .collect::<Vec<_>>(),
151            _ => {
152                // there might be pools where the liquidity is managed by the hook
153                //
154                // Keyed on the attribute being present rather than on the address being non-zero:
155                // a freshly initialised hookless pool has no ticks yet and must still decode.
156                if hook_attribute.is_some() {
157                    Vec::new()
158                } else {
159                    return Err(InvalidSnapshotError::MissingAttribute(
160                        "tick_liquidities".to_string(),
161                    ));
162                }
163            }
164        };
165
166        ticks.sort_by_key(|tick| tick.index);
167
168        let mut state = UniswapV4State::new(liquidity, sqrt_price, fees, tick, tick_spacing, ticks)
169            .map_err(|err| {
170                tracing::error!(
171                    pool_id = %snapshot.component.id,
172                    error = %err,
173                    "Failed to create UniswapV4State"
174                );
175                InvalidSnapshotError::ValueError(err.to_string())
176            })?;
177
178        // Every Uniswap V4 component carries a `hooks` attribute, so its presence says nothing
179        // about whether the pool has a hook: the zero address is what "no hook" looks like on the
180        // wire, and the length check rejects a malformed value. A pool whose hook cannot take part
181        // in a swap is a plain V4 pool too, on every chain: no handler, no chain needed.
182        let hook_address = hook_attribute
183            .map(bytes_to_address)
184            .transpose()
185            .map_err(|err| {
186                InvalidSnapshotError::ValueError(format!(
187                    "hooks attribute is not a 20-byte address: {err}"
188                ))
189            })?
190            .filter(|address| hook_participates_in_swaps(*address));
191
192        if let Some(hook_address) = hook_address {
193            // Merge state attributes into static_attributes for hook creation
194            let mut merged_attributes = snapshot
195                .component
196                .static_attributes
197                .clone();
198            merged_attributes.extend(snapshot.state.attributes.clone());
199
200            let chain = decoder_context.chain.ok_or_else(|| {
201                InvalidSnapshotError::ValueError(
202                    "uniswap v4 hook pools require DecoderContext.chain; register the decoder \
203                     through TychoStreamDecoder or set DecoderContext::chain"
204                        .to_string(),
205                )
206            })?;
207
208            let hook_params = HookCreationParams::new(
209                hook_address,
210                account_balances,
211                all_tokens,
212                state.clone(),
213                &merged_attributes,
214                &snapshot.state.balances,
215                decoder_context.vm_traces,
216            );
217
218            let hook_handler = instantiate_hook_handler(chain, &hook_address, hook_params)?;
219            state.set_hook_handler(hook_handler);
220        };
221
222        for tokens in snapshot
223            .component
224            .tokens
225            .iter()
226            .permutations(2)
227        {
228            let (t0, t1) = (tokens[0], tokens[1]);
229            let token_in = all_tokens.get(t0).ok_or_else(|| {
230                InvalidSnapshotError::ValueError("Failed to get token".to_string())
231            })?;
232            let token_out = all_tokens.get(t1).ok_or_else(|| {
233                InvalidSnapshotError::ValueError("Failed to get token".to_string())
234            })?;
235            state.spot_price(token_in, token_out)?;
236        }
237
238        Ok(state)
239    }
240}
241
242/// The recorded Robinhood Pons V2 pool `0xc96847cc…` (XLG/NVDA), shared with the stream-decoder
243/// tests in [`crate::evm::decoder`].
244///
245/// Every value comes from the chain: `tests/assets/decoder/uniswap_v4_pons_snapshot_robinhood.json`
246/// is the pool at the end of the block that created it, and the sibling `_sources.json` names the
247/// log or storage read each of its fields was taken from.
248#[cfg(test)]
249pub(crate) mod pons_fixture {
250    use std::{collections::HashMap, fs, path::Path, str::FromStr};
251
252    use num_bigint::BigUint;
253    use tycho_client::feed::{dto, synchronizer::ComponentWithState, BlockHeader};
254    use tycho_common::{
255        models::{token::Token, Chain},
256        Bytes,
257    };
258
259    use super::UniswapV4State;
260    use crate::protocol::{
261        errors::InvalidSnapshotError,
262        models::{DecoderContext, TryFromWithBlock},
263    };
264
265    pub(crate) const POOL_ID: &str =
266        "0xc96847cc43f7595aafcbc1c99d335cb91be7ce1107524c87030f48a716a5f289";
267    const XLG_ADDRESS: &str = "0xab5983fe30f186055095305c862b0e097dab3b52";
268    const NVDA_ADDRESS: &str = "0xd0601ce157db5bdc3162bbac2a2c8af5320d9eec";
269
270    /// The terms `registerPool` froze for this pool, read out of `launches[poolId]`.
271    pub(crate) const HOOK_FEE_BPS: u32 = 100;
272    pub(crate) const CREATOR_TAX_BPS: u32 = 100;
273
274    /// The block the pool was created in, and its timestamp.
275    const BLOCK_NUMBER: u64 = 58_759_099;
276    const BLOCK_TIMESTAMP: u64 = 1_788_978_331;
277
278    pub(crate) fn snapshot() -> ComponentWithState {
279        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
280            .join("tests/assets/decoder/uniswap_v4_pons_snapshot_robinhood.json");
281        let raw =
282            fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading {}: {e}", path.display()));
283        serde_json::from_str::<dto::ComponentWithState>(&raw)
284            .expect("the Pons snapshot fixture should match ComponentWithState")
285            .into()
286    }
287
288    /// The same pool with everything the hook contributes stripped off the wire, which is what a
289    /// pool of this shape looks like with no hook at all. Its quotes are the core swap math the
290    /// hook's cut is measured against.
291    pub(crate) fn hookless_snapshot() -> ComponentWithState {
292        let mut snapshot = snapshot();
293        snapshot.component.protocol_system = "uniswap_v4".to_string();
294        for attribute in ["hooks", "hook_identifier", "pons_hook_fee_bps", "pons_creator_tax_bps"] {
295            snapshot
296                .component
297                .static_attributes
298                .remove(attribute);
299        }
300        snapshot
301    }
302
303    /// The same pool behind an address no native handler is registered for on any chain.
304    pub(crate) fn unknown_hook_snapshot() -> ComponentWithState {
305        let mut snapshot = snapshot();
306        snapshot
307            .component
308            .static_attributes
309            .insert(
310                "hooks".to_string(),
311                Bytes::from_str("0x14bcc18fdb0e7a427122b9c2f1a40ff7d63eaacc")
312                    .expect("the literal address parses"),
313            );
314        snapshot
315    }
316
317    fn token(address: &str, symbol: &str) -> Token {
318        Token::new(
319            &Bytes::from_str(address).expect("the fixture token addresses parse"),
320            symbol,
321            18,
322            0,
323            &[Some(10_000)],
324            Chain::Robinhood,
325            100,
326        )
327    }
328
329    /// `currency0` of the pool: the lower of the two addresses, so a swap out of it is
330    /// zero-for-one.
331    pub(crate) fn xlg() -> Token {
332        token(XLG_ADDRESS, "XLG")
333    }
334
335    pub(crate) fn nvda() -> Token {
336        token(NVDA_ADDRESS, "NVDA")
337    }
338
339    pub(crate) fn tokens() -> HashMap<Bytes, Token> {
340        [xlg(), nvda()]
341            .into_iter()
342            .map(|token| (token.address.clone(), token))
343            .collect()
344    }
345
346    pub(crate) fn header() -> BlockHeader {
347        BlockHeader {
348            number: BLOCK_NUMBER,
349            hash: Bytes::from([1u8; 32]),
350            parent_hash: Bytes::from([0u8; 32]),
351            revert: false,
352            timestamp: BLOCK_TIMESTAMP,
353            partial_block_index: None,
354        }
355    }
356
357    /// Decodes `snapshot` as a component of `chain`, the way a decoder registered for that chain
358    /// would.
359    pub(crate) async fn decode_on(
360        snapshot: ComponentWithState,
361        chain: Chain,
362    ) -> Result<UniswapV4State, InvalidSnapshotError> {
363        UniswapV4State::try_from_with_header(
364            snapshot,
365            header(),
366            &HashMap::default(),
367            &tokens(),
368            &DecoderContext::new().chain(chain),
369        )
370        .await
371    }
372
373    /// What the PoolManager holds of `token` for this pool, straight from the snapshot.
374    ///
375    /// Trade sizes are taken as fractions of this rather than of `get_limits`. A full-range
376    /// position can absorb an unbounded amount of one side while giving up a bounded amount of
377    /// the other, so even a millionth of the limit on the `currency0` side drains the pool.
378    pub(crate) fn reserve(token: &Token) -> BigUint {
379        let snapshot = snapshot();
380        let balance = snapshot
381            .state
382            .balances
383            .get(&token.address)
384            .unwrap_or_else(|| panic!("the fixture carries a balance for {}", token.symbol));
385        BigUint::from_bytes_be(balance)
386    }
387
388    /// What is left of a `core` output once the hook has taken its fee and its tax, each floored
389    /// on its own exactly as `_afterSwap` computes them.
390    pub(crate) fn net_of_hook_take(core: &BigUint) -> BigUint {
391        let denominator = BigUint::from(10_000u32);
392        let fee = core * BigUint::from(HOOK_FEE_BPS) / &denominator;
393        let tax = core * BigUint::from(CREATOR_TAX_BPS) / &denominator;
394        core - fee - tax
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use std::str::FromStr;
401
402    use chrono::DateTime;
403    use num_bigint::BigUint;
404    use rstest::rstest;
405    use tycho_common::models::{
406        protocol::{ProtocolComponent, ProtocolComponentState},
407        Chain, ChangeType,
408    };
409
410    use super::*;
411    use crate::evm::protocol::test_utils::try_decode_snapshot_with_defaults;
412
413    fn usv4_component() -> ProtocolComponent {
414        let creation_time = DateTime::from_timestamp(1622526000, 0)
415            .unwrap()
416            .naive_utc();
417
418        let static_attributes: HashMap<String, Bytes> = HashMap::from([
419            ("key_lp_fee".to_string(), Bytes::from(500_i32.to_be_bytes().to_vec())),
420            ("tick_spacing".to_string(), Bytes::from(60_i32.to_be_bytes().to_vec())),
421        ]);
422
423        ProtocolComponent {
424            id: "State1".to_string(),
425            protocol_system: "system1".to_string(),
426            protocol_type_name: "typename1".to_string(),
427            chain: Chain::Ethereum,
428            tokens: Vec::new(),
429            contract_addresses: Vec::new(),
430            static_attributes,
431            change: ChangeType::Creation,
432            creation_tx: Bytes::from_str("0x0000").unwrap(),
433            created_at: creation_time,
434        }
435    }
436
437    fn usv4_attributes() -> HashMap<String, Bytes> {
438        HashMap::from([
439            ("liquidity".to_string(), Bytes::from(100_u64.to_be_bytes().to_vec())),
440            ("tick".to_string(), Bytes::from(300_i32.to_be_bytes().to_vec())),
441            (
442                "sqrt_price_x96".to_string(),
443                Bytes::from(
444                    79228162514264337593543950336_u128
445                        .to_be_bytes()
446                        .to_vec(),
447                ),
448            ),
449            ("protocol_fees/zero2one".to_string(), Bytes::from(0_u32.to_be_bytes().to_vec())),
450            ("protocol_fees/one2zero".to_string(), Bytes::from(0_u32.to_be_bytes().to_vec())),
451            ("ticks/60/net_liquidity".to_string(), Bytes::from(400_i128.to_be_bytes().to_vec())),
452        ])
453    }
454
455    #[tokio::test]
456    async fn test_usv4_try_from() {
457        let snapshot = ComponentWithState {
458            state: ProtocolComponentState {
459                component_id: "State1".to_owned(),
460                attributes: usv4_attributes(),
461                balances: HashMap::new(),
462            },
463            component: usv4_component(),
464            component_tvl: None,
465            entrypoints: Vec::new(),
466        };
467
468        let result = try_decode_snapshot_with_defaults::<UniswapV4State>(snapshot)
469            .await
470            .unwrap();
471
472        let fees = UniswapV4Fees::new(0, 0, 500);
473        let expected = UniswapV4State::new(
474            100,
475            U256::from(79228162514264337593543950336_u128),
476            fees,
477            300,
478            60,
479            vec![TickInfo::new(60, 400).unwrap()],
480        )
481        .unwrap();
482        assert_eq!(result, expected);
483    }
484
485    /// What a pool with no hook looks like on the wire: every Uniswap V4 component carries the
486    /// `hooks` attribute, zero-valued when there is no hook.
487    const ZERO_HOOK: &str = "0x0000000000000000000000000000000000000000";
488    /// A hook address with no native handler registered for it on any chain. Its low bits carry
489    /// `beforeSwap` (bit 7) and `afterSwap` (bit 6), so it does take part in swaps.
490    const UNKNOWN_HOOK: &str = "0x00000000000000000000000000000000000000c4";
491    /// A hook that only carries the add- and remove-liquidity permissions (bits 11 and 10) and
492    /// neither swap bit, so the pool manager never calls it during a swap.
493    const LIQUIDITY_ONLY_HOOK: &str = "0x0000000000000000000000000000000000000c00";
494
495    fn hookless_token(address: &str, symbol: &str, decimals: u32) -> Token {
496        Token::new(
497            &Bytes::from_str(address).unwrap(),
498            symbol,
499            decimals,
500            0,
501            &[Some(10_000)],
502            Chain::Ethereum,
503            100,
504        )
505    }
506
507    fn token0() -> Token {
508        hookless_token("0x0000000000000000000000000000000000000001", "T0", 18)
509    }
510
511    /// Deliberately a different decimal count from [`token0`], so the two quote orderings differ
512    /// and an ordering bug cannot hide behind a symmetric result.
513    fn token1() -> Token {
514        hookless_token("0x0000000000000000000000000000000000000002", "T1", 6)
515    }
516
517    /// A symmetric, liquid pool, so quoting succeeds in both directions.
518    fn hookless_pool_attributes() -> HashMap<String, Bytes> {
519        let liquidity = 1_000_000_000_000_000_000_i128;
520        HashMap::from([
521            (
522                "liquidity".to_string(),
523                Bytes::from(
524                    (liquidity as u128)
525                        .to_be_bytes()
526                        .to_vec(),
527                ),
528            ),
529            ("tick".to_string(), Bytes::from(0_i32.to_be_bytes().to_vec())),
530            (
531                "sqrt_price_x96".to_string(),
532                Bytes::from(
533                    79228162514264337593543950336_u128
534                        .to_be_bytes()
535                        .to_vec(),
536                ),
537            ),
538            ("protocol_fees/zero2one".to_string(), Bytes::from(0_u32.to_be_bytes().to_vec())),
539            ("protocol_fees/one2zero".to_string(), Bytes::from(0_u32.to_be_bytes().to_vec())),
540            ("ticks/-60/net_liquidity".to_string(), Bytes::from(liquidity.to_be_bytes().to_vec())),
541            (
542                "ticks/60/net_liquidity".to_string(),
543                Bytes::from((-liquidity).to_be_bytes().to_vec()),
544            ),
545        ])
546    }
547
548    /// The very same pool, built directly with no hook — the reference every hookless-decoding
549    /// assertion compares against.
550    fn reference_hookless_state() -> UniswapV4State {
551        let liquidity = 1_000_000_000_000_000_000_i128;
552        UniswapV4State::new(
553            liquidity as u128,
554            U256::from(79228162514264337593543950336_u128),
555            UniswapV4Fees::new(0, 0, 500),
556            0,
557            60,
558            vec![TickInfo::new(-60, liquidity).unwrap(), TickInfo::new(60, -liquidity).unwrap()],
559        )
560        .unwrap()
561    }
562
563    fn hookless_snapshot_with_hook(hook: &str) -> ComponentWithState {
564        let mut component = usv4_component();
565        component
566            .static_attributes
567            .insert("hooks".to_string(), Bytes::from_str(hook).unwrap());
568        component.tokens = vec![token0().address, token1().address];
569
570        ComponentWithState {
571            state: ProtocolComponentState {
572                component_id: "State1".to_owned(),
573                attributes: hookless_pool_attributes(),
574                balances: HashMap::new(),
575            },
576            component,
577            component_tvl: None,
578            entrypoints: Vec::new(),
579        }
580    }
581
582    /// The snapshot the original hook tests used: minimal attributes, no tokens.
583    fn usv4_snapshot_with_hook(hook: &str) -> ComponentWithState {
584        let mut component = usv4_component();
585        component
586            .static_attributes
587            .insert("hooks".to_string(), Bytes::from_str(hook).unwrap());
588
589        ComponentWithState {
590            state: ProtocolComponentState {
591                component_id: "State1".to_owned(),
592                attributes: usv4_attributes(),
593                balances: HashMap::new(),
594            },
595            component,
596            component_tvl: None,
597            entrypoints: Vec::new(),
598        }
599    }
600
601    async fn decode_with_chain(
602        snapshot: ComponentWithState,
603        chain: Option<Chain>,
604    ) -> Result<UniswapV4State, InvalidSnapshotError> {
605        let all_tokens =
606            HashMap::from([(token0().address, token0()), (token1().address, token1())]);
607        let context = match chain {
608            Some(chain) => DecoderContext::new().chain(chain),
609            None => DecoderContext::new(),
610        };
611        UniswapV4State::try_from_with_header(
612            snapshot,
613            Default::default(),
614            &HashMap::default(),
615            &all_tokens,
616            &context,
617        )
618        .await
619    }
620
621    /// A zero `hooks` address is not a hook. The pool decodes on every chain, with or without a
622    /// chain in the context, and behaves exactly like a `UniswapV4State` built with no hook:
623    /// analytic `spot_price`, tick-walk `get_limits`, unchanged `get_amount_out`. It must never
624    /// route through `GenericVMHookHandler`.
625    #[tokio::test]
626    #[rstest]
627    #[case::no_chain(None)]
628    #[case::robinhood(Some(Chain::Robinhood))]
629    #[case::base(Some(Chain::Base))]
630    #[case::ethereum(Some(Chain::Ethereum))]
631    async fn zero_hook_address_decodes_as_hookless(#[case] chain: Option<Chain>) {
632        let decoded = decode_with_chain(hookless_snapshot_with_hook(ZERO_HOOK), chain)
633            .await
634            .expect("a pool with a zero hook address must decode on every chain");
635
636        assert!(decoded.hook.is_none(), "a zero hook address must not install a hook handler");
637        assert_quotes_like_the_hookless_reference(&decoded);
638    }
639
640    /// Every quote a decoded pool answers matches the same pool built directly with no hook.
641    fn assert_quotes_like_the_hookless_reference(decoded: &UniswapV4State) {
642        let reference = reference_hookless_state();
643        let (t0, t1) = (token0(), token1());
644
645        // spot_price, both orderings
646        assert_eq!(decoded.spot_price(&t0, &t1).unwrap(), reference.spot_price(&t0, &t1).unwrap());
647        assert_eq!(decoded.spot_price(&t1, &t0).unwrap(), reference.spot_price(&t1, &t0).unwrap());
648
649        // get_limits, both directions
650        assert_eq!(
651            decoded
652                .get_limits(t0.address.clone(), t1.address.clone())
653                .unwrap(),
654            reference
655                .get_limits(t0.address.clone(), t1.address.clone())
656                .unwrap()
657        );
658        assert_eq!(
659            decoded
660                .get_limits(t1.address.clone(), t0.address.clone())
661                .unwrap(),
662            reference
663                .get_limits(t1.address.clone(), t0.address.clone())
664                .unwrap()
665        );
666
667        // get_amount_out, one amount, both directions
668        let amount_in = BigUint::from(1_000_000_000_000_000_u64);
669        for (token_in, token_out) in [(&t0, &t1), (&t1, &t0)] {
670            let from_decoded = decoded
671                .get_amount_out(amount_in.clone(), token_in, token_out)
672                .unwrap();
673            let from_reference = reference
674                .get_amount_out(amount_in.clone(), token_in, token_out)
675                .unwrap();
676            assert_eq!(from_decoded.amount, from_reference.amount);
677            assert_eq!(from_decoded.gas, from_reference.gas);
678        }
679    }
680
681    /// A hook that carries neither swap permission cannot be called during a swap, so it is
682    /// treated exactly like the zero address: no handler, no chain needed, plain V4 pricing on
683    /// every chain, including the chains where the generic VM handler is enabled.
684    #[tokio::test]
685    #[rstest]
686    #[case::no_chain(None)]
687    #[case::robinhood(Some(Chain::Robinhood))]
688    #[case::ethereum(Some(Chain::Ethereum))]
689    async fn hook_without_swap_permissions_decodes_as_hookless(#[case] chain: Option<Chain>) {
690        let decoded = decode_with_chain(hookless_snapshot_with_hook(LIQUIDITY_ONLY_HOOK), chain)
691            .await
692            .expect("a hook that cannot take part in a swap must decode on every chain");
693
694        assert!(
695            decoded.hook.is_none(),
696            "a hook without beforeSwap or afterSwap must not install a hook handler"
697        );
698        assert_quotes_like_the_hookless_reference(&decoded);
699    }
700
701    /// A non-zero hook that does take part in swaps but has no registered handler is rejected
702    /// rather than quoted as if the hook were absent, and it still needs a chain to decide that.
703    #[tokio::test]
704    #[rstest]
705    #[case::robinhood_fails_closed(Some(Chain::Robinhood), "unsupported uniswap v4 hook")]
706    #[case::no_chain_is_rejected(None, "DecoderContext.chain")]
707    async fn nonzero_unsupported_hook_is_rejected(
708        #[case] chain: Option<Chain>,
709        #[case] expected_message: &str,
710    ) {
711        let result = decode_with_chain(hookless_snapshot_with_hook(UNKNOWN_HOOK), chain).await;
712
713        let Err(InvalidSnapshotError::ValueError(message)) = result else {
714            panic!("an unregistered non-zero hook must not decode");
715        };
716        assert!(message.contains(expected_message), "{message}");
717    }
718
719    /// The `hooks` attribute arrives from the stream, so a value that is not a 20-byte address
720    /// must be reported as a bad snapshot rather than panic the decoding task.
721    #[tokio::test]
722    async fn malformed_hooks_attribute_is_rejected_without_panicking() {
723        let mut snapshot = hookless_snapshot_with_hook(ZERO_HOOK);
724        snapshot
725            .component
726            .static_attributes
727            .insert("hooks".to_string(), Bytes::from(vec![0x11_u8; 19]));
728
729        let result = decode_with_chain(snapshot, Some(Chain::Ethereum)).await;
730
731        let Err(InvalidSnapshotError::ValueError(message)) = result else {
732            panic!("a 19-byte hooks attribute must not decode");
733        };
734        assert!(message.contains("hooks"), "{message}");
735    }
736
737    #[tokio::test]
738    async fn test_usv4_zero_hook_pool_with_no_ticks_still_decodes() {
739        // Tick leniency is keyed on the `hooks` attribute being present, not on the address being
740        // non-zero: a freshly initialised hookless pool has no ticks yet and must still decode.
741        let mut snapshot = usv4_snapshot_with_hook(ZERO_HOOK);
742        snapshot
743            .state
744            .attributes
745            .remove("ticks/60/net_liquidity");
746
747        let decoded = decode_with_chain(snapshot, None)
748            .await
749            .expect("a hookless pool with no ticks must decode");
750
751        assert!(decoded.hook.is_none());
752    }
753
754    #[tokio::test]
755    #[rstest]
756    #[case::missing_liquidity("liquidity")]
757    #[case::missing_sqrt_price("sqrt_price")]
758    #[case::missing_tick("tick")]
759    #[case::missing_tick_liquidity("tick_liquidities")]
760    #[case::missing_fee("key_lp_fee")]
761    #[case::missing_fee("protocol_fees/one2zero")]
762    #[case::missing_fee("protocol_fees/zero2one")]
763    async fn test_usv4_try_from_invalid(#[case] missing_attribute: String) {
764        // remove missing attribute
765        let mut component = usv4_component();
766        let mut attributes = usv4_attributes();
767        attributes.remove(&missing_attribute);
768
769        if missing_attribute == "tick_liquidities" {
770            attributes.remove("ticks/60/net_liquidity");
771        }
772
773        if missing_attribute == "sqrt_price" {
774            attributes.remove("sqrt_price_x96");
775        }
776
777        if missing_attribute == "key_lp_fee" {
778            component
779                .static_attributes
780                .remove("key_lp_fee");
781        }
782
783        let snapshot = ComponentWithState {
784            state: ProtocolComponentState {
785                component_id: "State1".to_owned(),
786                attributes,
787                balances: HashMap::new(),
788            },
789            component,
790            component_tvl: None,
791            entrypoints: Vec::new(),
792        };
793
794        let result = try_decode_snapshot_with_defaults::<UniswapV4State>(snapshot).await;
795
796        assert!(result.is_err());
797        assert!(matches!(
798            result.err().unwrap(),
799            InvalidSnapshotError::MissingAttribute(attr) if attr == missing_attribute
800        ));
801    }
802
803    // Pons V2 on Robinhood, decoded through this very `TryFromWithBlock`.
804
805    use std::{fs, path::Path};
806
807    use serde::Deserialize;
808
809    use super::pons_fixture;
810    use crate::evm::protocol::uniswap_v4::hooks::{
811        hook_handler::HookHandler,
812        hook_handler_creator::initialize_hook_handlers,
813        pons_v2::hook_handler::{PonsV2HookHandler, PONS_V2_HOOK_ROBINHOOD},
814    };
815
816    /// The native Pons handler a decoded state installed, or a panic naming what it installed
817    /// instead.
818    fn pons_handler(state: &UniswapV4State) -> &PonsV2HookHandler {
819        let hook = state
820            .hook
821            .as_ref()
822            .expect("a Pons pool must decode with a hook handler");
823        hook.as_any()
824            .downcast_ref::<PonsV2HookHandler>()
825            .unwrap_or_else(|| panic!("expected the native Pons handler at {}", hook.address()))
826    }
827
828    /// Whether `state` quotes through the native Pons handler.
829    fn is_pons(state: &UniswapV4State) -> bool {
830        state.hook.as_ref().is_some_and(|hook| {
831            hook.as_any()
832                .downcast_ref::<PonsV2HookHandler>()
833                .is_some()
834        })
835    }
836
837    /// A millionth, a ten-thousandth and a hundredth of what the pool holds of the input token,
838    /// so one test covers three trade sizes two orders of magnitude apart without hard-coding an
839    /// amount per direction.
840    fn probe_amounts(reserve_in: &BigUint) -> [BigUint; 3] {
841        [
842            reserve_in / BigUint::from(1_000_000u32),
843            reserve_in / BigUint::from(10_000u32),
844            reserve_in / BigUint::from(100u32),
845        ]
846    }
847
848    /// A Pons pool decoded on Robinhood charges the fee and the tax `registerPool` froze for it,
849    /// and nothing else: what it quotes is the hookless output less two independently floored
850    /// cuts, and what it prices is the hookless price marked up by the same take.
851    #[tokio::test]
852    async fn pons_pool_decodes_on_robinhood_and_charges_hook_fee() {
853        initialize_hook_handlers().expect("hook handler registration should succeed");
854
855        let hooked = pons_fixture::decode_on(pons_fixture::snapshot(), Chain::Robinhood)
856            .await
857            .expect("the recorded Pons pool must decode on Robinhood");
858        let core = pons_fixture::decode_on(pons_fixture::hookless_snapshot(), Chain::Robinhood)
859            .await
860            .expect("the same pool with no hook must decode too");
861
862        let handler = pons_handler(&hooked);
863        assert_eq!(u32::from(handler.hook_fee_bps()), pons_fixture::HOOK_FEE_BPS);
864        assert_eq!(u32::from(handler.creator_tax_bps()), pons_fixture::CREATOR_TAX_BPS);
865        assert_eq!(handler.address(), PONS_V2_HOOK_ROBINHOOD);
866        assert!(core.hook.is_none(), "the reference pool must carry no hook");
867
868        let (xlg, nvda) = (pons_fixture::xlg(), pons_fixture::nvda());
869        for (token_in, token_out) in [(&xlg, &nvda), (&nvda, &xlg)] {
870            let (limit_in, core_limit_out) = core
871                .get_limits(token_in.address.clone(), token_out.address.clone())
872                .expect("a pool holding a full range position has limits");
873
874            for amount_in in probe_amounts(&pons_fixture::reserve(token_in)) {
875                let core_out = core
876                    .get_amount_out(amount_in.clone(), token_in, token_out)
877                    .expect("the reference pool quotes every probe")
878                    .amount;
879                let hooked_out = hooked
880                    .get_amount_out(amount_in.clone(), token_in, token_out)
881                    .expect("the hooked pool quotes every probe")
882                    .amount;
883
884                let expected = pons_fixture::net_of_hook_take(&core_out);
885                assert!(expected < core_out, "the hook must take something out of {core_out}");
886                assert_eq!(
887                    hooked_out, expected,
888                    "{} -> {}: {amount_in} in",
889                    token_in.symbol, token_out.symbol
890                );
891            }
892
893            // `get_limits` reports what a swapper receives, so the hook's cut comes off the
894            // output while the input the pool can absorb is unchanged.
895            let (hooked_limit_in, hooked_limit_out) = hooked
896                .get_limits(token_in.address.clone(), token_out.address.clone())
897                .expect("the hooked pool has limits too");
898            assert_eq!(hooked_limit_in, limit_in);
899            assert_eq!(hooked_limit_out, pons_fixture::net_of_hook_take(&core_limit_out));
900        }
901
902        // Pons takes its cut out of the output, so buying one `base` costs `core / (1 - rate)`.
903        // At 100 + 100 bps that is `1 / 0.98` of the hookless price, in both orderings.
904        for (base, quote) in [(&xlg, &nvda), (&nvda, &xlg)] {
905            let hooked_price = hooked
906                .spot_price(base, quote)
907                .expect("the hook prices its own fee");
908            let core_price = core
909                .spot_price(base, quote)
910                .expect("a hookless pool always prices");
911            let ratio = hooked_price / core_price;
912            assert!((ratio * 0.98 - 1.0).abs() < 1e-9, "hooked/core is {ratio}, not 1/0.98");
913        }
914    }
915
916    /// What a tampered Pons snapshot has to do.
917    #[derive(Debug, Clone, Copy)]
918    enum Outcome {
919        /// Decodes, carrying these terms.
920        Decodes(u16, u16),
921        /// Rejected as a missing attribute of this name.
922        Missing(&'static str),
923        /// Rejected as a value error naming this attribute.
924        Value(&'static str),
925    }
926
927    /// The substreams module writes each bps with `BigInt::to_signed_bytes_be`, which is
928    /// minimal-width: one byte below 256, two above.
929    const BPS_0: &[u8] = &[0x00];
930    const BPS_1: &[u8] = &[0x01];
931    const BPS_100: &[u8] = &[0x64];
932    const BPS_1001: &[u8] = &[0x03, 0xe9];
933    const BPS_2000: &[u8] = &[0x07, 0xd0];
934    /// 1,000,001: in range for the three bytes it occupies, far past the `uint16` the contract
935    /// keeps the rate in.
936    const BPS_WIDE: &[u8] = &[0x0f, 0x42, 0x41];
937
938    /// A Pons snapshot whose terms disagree with what `_registerPool` could have written is
939    /// rejected rather than quoted at whatever the attributes happen to say. The contract caps
940    /// `hookFeeBps` at 1,000 and the sum at 2,000, with no separate cap on the creator tax.
941    #[tokio::test]
942    #[rstest]
943    #[case::missing_hook_fee(None, Some(BPS_100), Outcome::Missing("pons_hook_fee_bps"))]
944    #[case::missing_creator_tax(Some(BPS_100), None, Outcome::Missing("pons_creator_tax_bps"))]
945    #[case::hook_fee_over_cap(Some(BPS_1001), Some(BPS_0), Outcome::Value("pons_hook_fee_bps"))]
946    #[case::sum_over_cap(Some(BPS_1), Some(BPS_2000), Outcome::Value("pons_creator_tax_bps"))]
947    #[case::tax_may_take_the_whole_sum(Some(BPS_0), Some(BPS_2000), Outcome::Decodes(0, 2_000))]
948    #[case::three_bytes_past_uint16(
949        Some(BPS_WIDE),
950        Some(BPS_0),
951        Outcome::Value("pons_hook_fee_bps")
952    )]
953    async fn pons_snapshot_fails_closed_on_bad_attributes(
954        #[case] hook_fee_bps: Option<&[u8]>,
955        #[case] creator_tax_bps: Option<&[u8]>,
956        #[case] expected: Outcome,
957    ) {
958        initialize_hook_handlers().expect("hook handler registration should succeed");
959
960        let mut snapshot = pons_fixture::snapshot();
961        for (name, value) in
962            [("pons_hook_fee_bps", hook_fee_bps), ("pons_creator_tax_bps", creator_tax_bps)]
963        {
964            let attributes = &mut snapshot.component.static_attributes;
965            match value {
966                Some(value) => attributes.insert(name.to_string(), Bytes::from(value.to_vec())),
967                None => attributes.remove(name),
968            };
969        }
970
971        let result = pons_fixture::decode_on(snapshot, Chain::Robinhood).await;
972
973        match expected {
974            Outcome::Decodes(hook_fee, creator_tax) => {
975                let decoded = result.expect("terms the contract could have written must decode");
976                let handler = pons_handler(&decoded);
977                assert_eq!(handler.hook_fee_bps(), hook_fee);
978                assert_eq!(handler.creator_tax_bps(), creator_tax);
979            }
980            Outcome::Missing(name) => {
981                let Err(InvalidSnapshotError::MissingAttribute(reported)) = result else {
982                    panic!("expected a missing-attribute error, got {result:?}");
983                };
984                assert_eq!(reported, name);
985            }
986            Outcome::Value(name) => {
987                let Err(InvalidSnapshotError::ValueError(message)) = result else {
988                    panic!("expected a value error, got {result:?}");
989                };
990                assert!(message.contains(name), "{message}");
991            }
992        }
993    }
994
995    /// The same pool behind a hook this crate does not model fails to decode on Robinhood, so a
996    /// hook that could move the price is never quoted as if it were absent.
997    #[tokio::test]
998    async fn unknown_hook_on_robinhood_fails_decode() {
999        initialize_hook_handlers().expect("hook handler registration should succeed");
1000
1001        let result =
1002            pons_fixture::decode_on(pons_fixture::unknown_hook_snapshot(), Chain::Robinhood).await;
1003
1004        let Err(InvalidSnapshotError::ValueError(message)) = result else {
1005            panic!("an unregistered hook must not decode, got {result:?}");
1006        };
1007        assert!(message.contains("unsupported uniswap v4 hook"), "{message}");
1008        assert!(message.contains("robinhood"), "{message}");
1009    }
1010
1011    /// Pons is deployed on Robinhood alone, so the same address on Ethereum is a different
1012    /// deployment and the native handler must not serve it there. Whether the generic VM path
1013    /// then manages to build a handler is beside the point; it must not be the Pons one.
1014    #[tokio::test]
1015    async fn pons_same_address_on_ethereum_is_not_native() {
1016        initialize_hook_handlers().expect("hook handler registration should succeed");
1017
1018        match pons_fixture::decode_on(pons_fixture::snapshot(), Chain::Ethereum).await {
1019            Ok(decoded) => assert!(
1020                !is_pons(&decoded),
1021                "the Pons handler must not serve its address off Robinhood"
1022            ),
1023            Err(error) => {
1024                // What actually happens today: the generic VM handler is built and then fails
1025                // for want of VM prerequisites this snapshot does not carry. Either way the
1026                // failure must not come from the Pons creator, and Ethereum being a generic-VM
1027                // chain it must not be the fail-closed rejection Robinhood gives either.
1028                let message = error.to_string();
1029                assert!(
1030                    !message.contains("pons_"),
1031                    "the Pons creator ran off Robinhood: {message}"
1032                );
1033                assert!(
1034                    !message.contains("unsupported uniswap v4 hook"),
1035                    "ethereum runs the generic VM handler rather than failing closed: {message}"
1036                );
1037            }
1038        }
1039    }
1040
1041    /// A second quote taken on the state the first one returned sees the pool the first swap
1042    /// left behind. The hook moves nothing: the pool it hands back is the hookless pool's, swap
1043    /// for swap, and only the amounts differ, by the hook's cut.
1044    #[tokio::test]
1045    #[rstest]
1046    #[case::zero_for_one(true)]
1047    #[case::one_for_zero(false)]
1048    async fn pons_sequential_swaps_use_returned_state(#[case] zero_for_one: bool) {
1049        initialize_hook_handlers().expect("hook handler registration should succeed");
1050
1051        let hooked = pons_fixture::decode_on(pons_fixture::snapshot(), Chain::Robinhood)
1052            .await
1053            .expect("the recorded Pons pool must decode on Robinhood");
1054        let core = pons_fixture::decode_on(pons_fixture::hookless_snapshot(), Chain::Robinhood)
1055            .await
1056            .expect("the same pool with no hook must decode too");
1057
1058        let (xlg, nvda) = (pons_fixture::xlg(), pons_fixture::nvda());
1059        let (token_in, token_out) = if zero_for_one { (&xlg, &nvda) } else { (&nvda, &xlg) };
1060        let amount_in = pons_fixture::reserve(token_in) / BigUint::from(100u32);
1061
1062        let hooked_first = hooked
1063            .get_amount_out(amount_in.clone(), token_in, token_out)
1064            .expect("the first hooked quote fits the pool");
1065        let core_first = core
1066            .get_amount_out(amount_in.clone(), token_in, token_out)
1067            .expect("the first hookless quote fits the pool");
1068
1069        // The hook only accrues accounting balances, so the pool a hooked swap leaves behind is
1070        // the one a hookless swap of the same size leaves: same price, tick and liquidity.
1071        assert_eq!(
1072            format!("{:?}", hooked_first.new_state),
1073            format!("{:?}", core_first.new_state),
1074            "the hook must not move the pool"
1075        );
1076
1077        let hooked_second = hooked_first
1078            .new_state
1079            .get_amount_out(amount_in.clone(), token_in, token_out)
1080            .expect("the second hooked quote fits the pool");
1081        let core_second = core_first
1082            .new_state
1083            .get_amount_out(amount_in, token_in, token_out)
1084            .expect("the second hookless quote fits the pool");
1085
1086        assert!(
1087            core_second.amount < core_first.amount,
1088            "the second swap must execute at a worse price, or the state was not carried over"
1089        );
1090        assert_eq!(hooked_second.amount, pons_fixture::net_of_hook_take(&core_second.amount));
1091        assert_eq!(
1092            format!("{:?}", hooked_second.new_state),
1093            format!("{:?}", core_second.new_state)
1094        );
1095    }
1096
1097    #[derive(Deserialize)]
1098    struct RecordedSwaps {
1099        swaps: Vec<RecordedSwap>,
1100    }
1101
1102    #[derive(Deserialize)]
1103    struct RecordedSwap {
1104        label: String,
1105        currency0: String,
1106        currency1: String,
1107        tick_spacing: i32,
1108        hook_fee_bps: u16,
1109        creator_tax_bps: u16,
1110        pre_state: RecordedPoolState,
1111        position: RecordedPosition,
1112        swap: RecordedSwapLog,
1113        hook_fee: RecordedHookFee,
1114    }
1115
1116    #[derive(Deserialize)]
1117    struct RecordedPoolState {
1118        sqrt_price_x96: String,
1119        liquidity: String,
1120        tick: i32,
1121    }
1122
1123    #[derive(Deserialize)]
1124    struct RecordedPosition {
1125        tick_lower: i32,
1126        tick_upper: i32,
1127        liquidity: String,
1128    }
1129
1130    #[derive(Deserialize)]
1131    struct RecordedSwapLog {
1132        zero_for_one: bool,
1133        amount0: String,
1134        amount1: String,
1135        sqrt_price_x96: String,
1136        liquidity: String,
1137        tick: i32,
1138    }
1139
1140    #[derive(Deserialize)]
1141    struct RecordedHookFee {
1142        fee_amount: String,
1143        tax_amount: String,
1144    }
1145
1146    fn recorded_swaps() -> Vec<RecordedSwap> {
1147        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1148            .join("tests/assets/hooks/pons_v2/recorded_swaps.json");
1149        let raw =
1150            fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading {}: {e}", path.display()));
1151        serde_json::from_str::<RecordedSwaps>(&raw)
1152            .expect("the recorded swap fixture should parse")
1153            .swaps
1154    }
1155
1156    fn recorded_token(address: &str) -> Token {
1157        Token::new(
1158            &Bytes::from_str(address).expect("recorded currency addresses parse"),
1159            "RECORDED",
1160            18,
1161            0,
1162            &[Some(10_000)],
1163            Chain::Robinhood,
1164            100,
1165        )
1166    }
1167
1168    fn u256_from_decimal(value: &str, what: &str) -> U256 {
1169        U256::from_str_radix(value, 10).unwrap_or_else(|e| panic!("{what} = {value}: {e}"))
1170    }
1171
1172    /// Rebuilds the pool a recorded swap ran against: one full-range position and nothing else,
1173    /// no LP fee and no protocol fee, which is what a Pons `PoolKey` carries.
1174    fn recorded_pool(recorded: &RecordedSwap) -> UniswapV4State {
1175        let position = recorded
1176            .position
1177            .liquidity
1178            .parse::<i128>()
1179            .unwrap_or_else(|e| panic!("{}: position liquidity: {e}", recorded.label));
1180
1181        let mut pool = UniswapV4State::new(
1182            recorded
1183                .pre_state
1184                .liquidity
1185                .parse::<u128>()
1186                .unwrap_or_else(|e| panic!("{}: pre-state liquidity: {e}", recorded.label)),
1187            u256_from_decimal(&recorded.pre_state.sqrt_price_x96, "pre-state sqrt price"),
1188            UniswapV4Fees::new(0, 0, 0),
1189            recorded.pre_state.tick,
1190            recorded.tick_spacing,
1191            vec![
1192                TickInfo::new(recorded.position.tick_lower, position).expect("lower tick is valid"),
1193                TickInfo::new(recorded.position.tick_upper, -position)
1194                    .expect("upper tick is valid"),
1195            ],
1196        )
1197        .unwrap_or_else(|e| panic!("{}: rebuilding the pool: {e:?}", recorded.label));
1198
1199        pool.set_hook_handler(Box::new(PonsV2HookHandler::new(
1200            PONS_V2_HOOK_ROBINHOOD,
1201            recorded.hook_fee_bps,
1202            recorded.creator_tax_bps,
1203        )));
1204        pool
1205    }
1206
1207    /// Every recorded swap is a real Robinhood transaction. Quoting its input against the state
1208    /// the previous swap on that pool left behind has to reproduce to the wei what the swapper
1209    /// received once the hook had taken its cut, and leave the pool where the Swap log says it
1210    /// ended up.
1211    #[tokio::test]
1212    async fn pons_quotes_match_recorded_swaps() {
1213        let swaps = recorded_swaps();
1214        assert!(swaps.len() >= 6, "the fixture should cover both directions on several pools");
1215
1216        for recorded in swaps {
1217            let pool = recorded_pool(&recorded);
1218            let (currency0, currency1) =
1219                (recorded_token(&recorded.currency0), recorded_token(&recorded.currency1));
1220            let (token_in, token_out) = if recorded.swap.zero_for_one {
1221                (&currency0, &currency1)
1222            } else {
1223                (&currency1, &currency0)
1224            };
1225
1226            let parse = |value: &str, what: &str| {
1227                value
1228                    .parse::<i128>()
1229                    .unwrap_or_else(|e| panic!("{}: {what} = {value}: {e}", recorded.label))
1230            };
1231            let (amount0, amount1) = (
1232                parse(&recorded.swap.amount0, "amount0"),
1233                parse(&recorded.swap.amount1, "amount1"),
1234            );
1235            // The Swap log is the swapper's balance delta, so the leg they paid is negative.
1236            let (paid, received) =
1237                if recorded.swap.zero_for_one { (amount0, amount1) } else { (amount1, amount0) };
1238            assert!(paid < 0 && received > 0, "{}: not an exact-input swap", recorded.label);
1239
1240            let take = parse(&recorded.hook_fee.fee_amount, "fee_amount") +
1241                parse(&recorded.hook_fee.tax_amount, "tax_amount");
1242            let expected = BigUint::from(
1243                u128::try_from(received - take).expect("what the swapper kept is positive"),
1244            );
1245
1246            let quote = pool
1247                .get_amount_out(BigUint::from(paid.unsigned_abs()), token_in, token_out)
1248                .unwrap_or_else(|e| panic!("{}: {e:?}", recorded.label));
1249
1250            assert_eq!(quote.amount, expected, "{}: amount out", recorded.label);
1251
1252            // The Swap log also carries the state the swap left the pool in. A `UniswapV4State`
1253            // prints its liquidity, price, tick, fees and tick spacing and nothing else, so
1254            // building the logged state and comparing the two covers every one of them.
1255            let logged = UniswapV4State::new(
1256                recorded
1257                    .swap
1258                    .liquidity
1259                    .parse::<u128>()
1260                    .unwrap_or_else(|e| panic!("{}: post-swap liquidity: {e}", recorded.label)),
1261                u256_from_decimal(&recorded.swap.sqrt_price_x96, "post-swap sqrt price"),
1262                UniswapV4Fees::new(0, 0, 0),
1263                recorded.swap.tick,
1264                recorded.tick_spacing,
1265                Vec::new(),
1266            )
1267            .unwrap_or_else(|e| panic!("{}: building the logged state: {e:?}", recorded.label));
1268
1269            assert_eq!(
1270                format!("{:?}", quote.new_state),
1271                format!("{logged:?}"),
1272                "{}: the pool the quote leaves behind",
1273                recorded.label
1274            );
1275        }
1276    }
1277}