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
29fn 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 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 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 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 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#[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 pub(crate) const HOOK_FEE_BPS: u32 = 100;
272 pub(crate) const CREATOR_TAX_BPS: u32 = 100;
273
274 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 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 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 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 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 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 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 const ZERO_HOOK: &str = "0x0000000000000000000000000000000000000000";
488 const UNKNOWN_HOOK: &str = "0x00000000000000000000000000000000000000c4";
491 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 fn token1() -> Token {
514 hookless_token("0x0000000000000000000000000000000000000002", "T1", 6)
515 }
516
517 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 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 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 #[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 fn assert_quotes_like_the_hookless_reference(decoded: &UniswapV4State) {
642 let reference = reference_hookless_state();
643 let (t0, t1) = (token0(), token1());
644
645 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 #[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 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 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 #[derive(Debug, Clone, Copy)]
918 enum Outcome {
919 Decodes(u16, u16),
921 Missing(&'static str),
923 Value(&'static str),
925 }
926
927 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 const BPS_WIDE: &[u8] = &[0x0f, 0x42, 0x41];
937
938 #[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 #[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 #[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 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 #[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 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 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 #[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 (¤cy0, ¤cy1)
1222 } else {
1223 (¤cy1, ¤cy0)
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 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 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}