evm-amm-state 0.2.0

EVM-backed AMM state loading, cache synchronization, and pool simulation models
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
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
use super::bytecode::{AdapterCodeSeed, BytecodeTemplateError, v3_code_seed_from_metadata};
use super::cold_start::{
    AdapterColdStartPlanner, ColdStartPlan, ColdStartResults, ColdStartRunReport, ColdStartStep,
    SlotFetch,
};
use super::factory::{ConcentratedLiquidityFactory, FactoryConfig, PoolFactory};
use super::sim::{
    QuoteExactInputSingleParams, SimConfig, SimError, SwapQuote,
    quote_via_call_with_code_overrides_from, quoteExactInputSingleCall,
};
use super::{
    AdapterCache, AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult,
    AmmAdapter, ColdStartOutcome, ColdStartPolicy, ColdStartReport, DeferredWork, EventSource,
    PoolRegistration, PoolStateDependencies, PoolStatus, ProtocolId, ProtocolMetadata,
    RepairAction, SlotChange, StateDiff, StateSlot, StateUpdate, StateView, UnsupportedReason,
    UpdateQuality, V3Metadata,
};
use crate::adapters::storage::{
    V3StorageLayout, layout_for, v3_tick_bitmap_storage_key_with_base,
    v3_tick_info_storage_keys_with_base, v3_word_position,
};
use alloy_primitives::{Address, B256, Bytes, Log, U256, aliases::U24};
use alloy_sol_types::{SolCall, SolEvent};

/// `sol!`-generated pool event bindings (crate-internal, not public API).
mod abi {
    alloy_sol_types::sol! {
        event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick);
        event Mint(address sender, address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1);
        event Burn(address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1);
    }
}
use abi::{Burn, Mint, Swap};

/// PancakeSwap V3 `Swap` appends `protocolFeesToken0`/`protocolFeesToken1`
/// (`uint128`) to the Uniswap V3 event, so its `topic0` differs (`0x19b47279…`
/// vs Uniswap's `0xc42079f9…`). The extra fields append after `tick`, so
/// `sqrtPriceX96`/`liquidity`/`tick` stay at data words 2/3/4 and the body decode
/// is shared with the Uniswap [`Swap`]. `Mint`/`Burn` are unchanged from Uniswap
/// V3, so their hashes are shared. Wrapped in a module so the 9-field event's
/// `sol!`-generated constructor can be exempted from `clippy::too_many_arguments`
/// without relaxing the lint for the rest of the file.
mod pancake_v3 {
    #![allow(clippy::too_many_arguments)]
    alloy_sol_types::sol! {
        event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick, uint128 protocolFeesToken0, uint128 protocolFeesToken1);
    }
}
use pancake_v3::Swap as PancakeV3Swap;

const SLOT0_PRICE_TICK_BITS: usize = 184;
const SLOT0_TICK_SHIFT: usize = 160;

/// Minimal runtime that ABI-returns `true` for any call.
///
/// QuoterV2 obtains its answer by executing `pool.swap`; the pool transfers the
/// output token, then the quoter callback deliberately reverts with quote data.
/// An immutable AMM snapshot intentionally does not carry arbitrary ERC20
/// balance mappings, so snapshot-backed quote execution temporarily substitutes
/// this runtime for both path tokens. Pool math and tick traversal remain real;
/// only the transfer side effect that precedes the intentional revert is
/// neutralized. Live-backed caches ignore the override and execute real token
/// code/state through their lazy backend.
const ERC20_TRANSFER_SUCCESS_RUNTIME: &[u8] = &[
    0x60, 0x01, // PUSH1 1
    0x60, 0x00, // PUSH1 0
    0x52, // MSTORE
    0x60, 0x20, // PUSH1 32
    0x60, 0x00, // PUSH1 0
    0xf3, // RETURN
];

/// The minimum/maximum tick a Uniswap V3 pool can reach (`±887272`). Ticks (and
/// the tick-bitmap words derived from them) outside this range never exist, so
/// the cold-start window is clamped to it to avoid warming non-existent slots.
const V3_MIN_TICK: i32 = -887272;
const V3_MAX_TICK: i32 = 887272;

/// Radius (in tick-bitmap words) of the cold-start tick warm-up window.
///
/// The warmed window is `[W0 - R, W0 + R]` — `2R + 1` words centred on the
/// current-tick word `W0`. One word covers `256 * tick_spacing` of tick range,
/// so `R = 2` pre-warms ±2 words: generous headroom for a moderate
/// tick-crossing swap while keeping the warm-up strictly bounded (never more
/// than `2R + 1` bitmap words plus their initialized ticks). A true
/// outward-adaptive scan (extend until N consecutive empty words) is a future
/// refinement; this single named constant is the tuning knob until then.
pub(crate) const V3_TICK_WORD_RADIUS: i16 = 2;

/// Adapter for the Uniswap V3 storage-layout family.
///
/// A single instance serves Uniswap V3, Pancake V3, and Slipstream: those
/// protocols differ only in storage-slot offsets, which `layout_for` resolves
/// per-pool from the registration metadata. The struct is registered once and
/// claims all three ids via [`AmmAdapter::protocols`].
#[derive(Clone, Debug, Default)]
pub struct ConcentratedLiquidityAdapter {
    _private: (),
}

impl AmmAdapter for ConcentratedLiquidityAdapter {
    fn protocol(&self) -> ProtocolId {
        ProtocolId::UniswapV3
    }

    fn protocols(&self) -> Vec<ProtocolId> {
        vec![
            ProtocolId::UniswapV3,
            ProtocolId::PancakeV3,
            ProtocolId::Slipstream,
        ]
    }

    fn event_sources(&self, pool: &PoolRegistration) -> Vec<EventSource> {
        pool.key
            .address()
            .map(|address| {
                EventSource::direct(
                    address,
                    vec![
                        swap_topic_for(pool.protocol()),
                        Mint::SIGNATURE_HASH,
                        Burn::SIGNATURE_HASH,
                    ],
                )
            })
            .into_iter()
            .collect()
    }

    fn state_dependencies(&self, pool: &PoolRegistration) -> PoolStateDependencies {
        let Some(address) = pool.key.address() else {
            return PoolStateDependencies::default();
        };
        let mut slots = v3_metadata(pool)
            .map(|metadata| metadata.warmed_slots.clone())
            .unwrap_or_default();
        if slots.is_empty()
            && let Some(layout) = layout_for(pool)
        {
            slots.extend([layout.slot0_slot, layout.liquidity_slot]);
        }
        PoolStateDependencies::default()
            .with_associated_addresses([address])
            .with_slots(slots.into_iter().map(|slot| StateSlot::new(address, slot)))
    }

    fn pool_factories(&self, config: &FactoryConfig) -> Vec<Box<dyn PoolFactory>> {
        config
            .concentrated_liquidity
            .iter()
            .map(|spec| {
                // The config-level `verify_derivations` is a global off-switch: a
                // spec's CREATE2 cross-check runs only when both it and the global
                // flag opt in.
                let mut spec = spec.clone();
                spec.verify_derivations &= config.verify_derivations;
                Box::new(ConcentratedLiquidityFactory::new(spec)) as Box<dyn PoolFactory>
            })
            .collect()
    }

    fn cold_start_planner(
        &self,
        pool: &PoolRegistration,
        policy: ColdStartPolicy,
    ) -> Result<Box<dyn AdapterColdStartPlanner>, UnsupportedReason> {
        let Some(address) = pool.key.address() else {
            return Err(UnsupportedReason::Custom(
                "Uniswap V3 pool key is not address-keyed".into(),
            ));
        };

        // Resolve the storage layout before any fetch: the missing-layout case
        // must surface as `Unsupported` even when no batch fetcher is configured,
        // so the factory short-circuits here rather than running any round.
        let Some(layout) = layout_for(pool) else {
            return Err(UnsupportedReason::MissingMetadata("V3 storage layout"));
        };

        // Per-pool tick-warm radius from V3 metadata, defaulting to the crate
        // constant when the field (or the metadata) is absent.
        let radius = v3_warm_word_radius(pool).unwrap_or(V3_TICK_WORD_RADIUS);

        Ok(Box::new(UniswapV3ColdStartPlanner::new(
            address, layout, policy, radius,
        )))
    }

    fn code_seeds(
        &self,
        pool: &PoolRegistration,
    ) -> Result<Vec<AdapterCodeSeed>, BytecodeTemplateError> {
        if matches!(
            pool.protocol(),
            ProtocolId::PancakeV3 | ProtocolId::Slipstream
        ) {
            return Ok(Vec::new());
        }
        let Some(address) = pool.key.address() else {
            return Ok(Vec::new());
        };
        let Some(metadata) = v3_metadata(pool) else {
            return Ok(Vec::new());
        };
        v3_code_seed_from_metadata(address, metadata).map(|opt| opt.into_iter().collect())
    }

    fn verified_code_targets(&self, pool: &PoolRegistration) -> Vec<Address> {
        if matches!(
            pool.protocol(),
            ProtocolId::PancakeV3 | ProtocolId::Slipstream
        ) {
            pool.key.address().into_iter().collect()
        } else {
            Vec::new()
        }
    }

    fn decode_event(
        &self,
        pool: &PoolRegistration,
        log: &Log,
        view: &dyn StateView,
    ) -> AdapterEventResult {
        let Some(topic0) = log.topics().first().copied() else {
            return AdapterEventResult::ignored();
        };

        if topic0 == Swap::SIGNATURE_HASH {
            self.decode_swap(pool, log, topic0, SwapAbi::Uniswap)
        } else if topic0 == PancakeV3Swap::SIGNATURE_HASH {
            self.decode_swap(pool, log, topic0, SwapAbi::Pancake)
        } else if topic0 == Mint::SIGNATURE_HASH {
            self.decode_liquidity_event(pool, log, view, true)
        } else if topic0 == Burn::SIGNATURE_HASH {
            self.decode_liquidity_event(pool, log, view, false)
        } else {
            AdapterEventResult::ignored()
        }
    }

    fn after_apply(
        &self,
        _pool: &PoolRegistration,
        event: &AdapterEvent,
        diff: &StateDiff,
    ) -> RepairAction {
        if event.kind != AdapterEventKind::Swap || !diff.has_skipped() {
            return RepairAction::None;
        }

        let mut slots = Vec::new();
        for skipped in &diff.skipped_masks {
            slots.push((skipped.address, skipped.slot));
        }
        for skipped in &diff.skipped {
            slots.push((skipped.address, skipped.slot));
        }

        if slots.is_empty() {
            RepairAction::None
        } else {
            RepairAction::VerifySlots(slots)
        }
    }

    /// Quote via `QuoterV2.quoteExactInputSingle((tokenIn, tokenOut, amountIn,
    /// fee, sqrtPriceLimitX96 = 0))`.
    ///
    /// The Quoter executes a real V3 swap against the warmed pool slots and
    /// returns the encoded `amountOut` (chain code, not reimplemented math). The
    /// pool `fee` is taken from the V3-family metadata; tick-crossing swaps stay
    /// correct because the cache lazily fetches any cold tick/bitmap slot from
    /// the backend.
    ///
    /// The quote target is the pool's own [`V3Metadata::quoter`] when set (a
    /// fork's QuoterV2, e.g. PancakeSwap's, filled in by factory discovery),
    /// falling back to the caller's [`SimConfig::v3_quoter`] otherwise. The quote
    /// ABI is unchanged (the `fee`-param struct variant) — forks whose quoter
    /// takes a different struct (e.g. Slipstream's `tickSpacing`-keyed quoter)
    /// leave `quoter` unset and ride the caller's Uniswap-compatible quoter.
    fn simulate_swap(
        &self,
        pool: &PoolRegistration,
        cache: &mut dyn AdapterCache,
        token_in: Address,
        token_out: Address,
        amount_in: U256,
        config: &SimConfig,
    ) -> Result<SwapQuote, SimError> {
        let fee = v3_fee(pool).ok_or(SimError::MissingMetadata("V3 fee"))?;
        // Same resolver `ProtocolMetadata::quote_code_targets` uses, so the quoter
        // we call is always the one an eager cold-start pre-warmed.
        let quoter = v3_metadata(pool).map_or(config.v3_quoter, |m| m.quote_target(config));

        let params = QuoteExactInputSingleParams {
            tokenIn: token_in,
            tokenOut: token_out,
            amountIn: amount_in,
            fee: U24::from(fee),
            sqrtPriceLimitX96: U256::ZERO.to(),
        };
        let calldata = Bytes::from(quoteExactInputSingleCall { params }.abi_encode());

        let transfer_success = Bytes::from_static(ERC20_TRANSFER_SUCCESS_RUNTIME);
        let code_overrides = [
            (token_in, transfer_success.clone()),
            (token_out, transfer_success),
        ];
        let output = quote_via_call_with_code_overrides_from(
            cache,
            config.from,
            quoter,
            calldata,
            &code_overrides,
        )?;
        let decoded = quoteExactInputSingleCall::abi_decode_returns_validate(&output)
            .map_err(|_| SimError::MalformedOutput("quoteExactInputSingle return"))?;

        Ok(SwapQuote::new(decoded.amountOut))
    }
}

/// Read the pool `fee` (in hundredths of a bip, e.g. `500` for 0.05%) from the
/// V3-family metadata, regardless of which family variant the pool registered.
fn v3_fee(pool: &PoolRegistration) -> Option<u32> {
    v3_metadata(pool).and_then(|m| m.fee)
}

/// Read the per-pool cold-start tick-warm radius (in tick-bitmap words) from the
/// V3-family metadata, regardless of which family variant the pool registered.
///
/// Returns `None` when the metadata is absent or `warm_word_radius` is unset, in
/// which case callers fall back to [`V3_TICK_WORD_RADIUS`].
fn v3_warm_word_radius(pool: &PoolRegistration) -> Option<i16> {
    v3_metadata(pool).and_then(|m| m.warm_word_radius)
}

/// Which `Swap` ABI a routed log matched — the Uniswap V3 shape or the
/// PancakeSwap V3 shape (two extra `uint128` fields, so a distinct `topic0`).
#[derive(Clone, Copy)]
enum SwapAbi {
    Uniswap,
    Pancake,
}

/// The `Swap` event `topic0` to subscribe/route for `protocol`.
///
/// PancakeSwap V3 emits an extended `Swap` (extra `protocolFeesToken0/1`), so its
/// `topic0` differs from Uniswap's; every other V3-family fork (Uniswap V3,
/// Slipstream) uses the canonical Uniswap `Swap` hash.
fn swap_topic_for(protocol: ProtocolId) -> B256 {
    match protocol {
        ProtocolId::PancakeV3 => PancakeV3Swap::SIGNATURE_HASH,
        _ => Swap::SIGNATURE_HASH,
    }
}

/// Borrow the [`V3Metadata`] for a pool if it registered as any V3-family
/// variant (Uniswap V3 / Pancake V3 / Slipstream), else `None`.
fn v3_metadata(pool: &PoolRegistration) -> Option<&V3Metadata> {
    match &pool.metadata {
        ProtocolMetadata::UniswapV3(m)
        | ProtocolMetadata::PancakeV3(m)
        | ProtocolMetadata::Slipstream(m) => Some(m),
        _ => None,
    }
}

impl ConcentratedLiquidityAdapter {
    fn decode_swap(
        &self,
        pool: &PoolRegistration,
        log: &Log,
        topic0: B256,
        abi: SwapAbi,
    ) -> AdapterEventResult {
        // Validate against the ABI whose topic0 matched. Cross-protocol safety
        // comes from topic0 routing — a pool only subscribes its own Swap hash
        // (`swap_topic_for`), so `decode_event` always pairs the matched topic0
        // with its `SwapAbi` — NOT from payload length: alloy's log decoder reads
        // only the leading static words, so the Uniswap validator tolerates the
        // Pancake body's two trailing `uint128`s (the Pancake validator, which
        // needs more words, does reject the shorter Uniswap body). Either way
        // `sqrtPriceX96`/`liquidity`/`tick` share data words 2/3/4, so the body
        // decode below is ABI-agnostic once the matched validator passes.
        let valid = match abi {
            SwapAbi::Uniswap => Swap::decode_log_data_validate(&log.data).is_ok(),
            SwapAbi::Pancake => PancakeV3Swap::decode_log_data_validate(&log.data).is_ok(),
        };
        if !valid {
            return AdapterEventResult::error(AdapterEventError::MalformedLog(
                "malformed V3 Swap log",
            ));
        }

        let Some(address) = pool.key.address() else {
            return AdapterEventResult::error(AdapterEventError::MalformedLog(
                "V3 pool key is not address-keyed",
            ));
        };
        let Some(layout) = layout_for(pool) else {
            return AdapterEventResult::error(AdapterEventError::Unsupported(
                super::UnsupportedReason::MissingMetadata("V3 storage layout"),
            ));
        };

        let Some(sqrt_price) = data_word(log, 2) else {
            return AdapterEventResult::error(AdapterEventError::MalformedLog(
                "missing V3 sqrtPriceX96",
            ));
        };
        let Some(liquidity) = data_word(log, 3) else {
            return AdapterEventResult::error(AdapterEventError::MalformedLog(
                "missing V3 liquidity",
            ));
        };
        let Some(tick_word) = data_word(log, 4) else {
            return AdapterEventResult::error(AdapterEventError::MalformedLog("missing V3 tick"));
        };

        let tick = int24_from_word(tick_word);
        let tick24 = U256::from((tick as u32) & 0x00FF_FFFF);
        let mask = low_mask(SLOT0_PRICE_TICK_BITS);
        let value = sqrt_price | (tick24 << SLOT0_TICK_SHIFT);

        AdapterEventResult::event(AdapterEvent {
            pool: pool.key.clone(),
            emitter: log.address,
            topic0,
            kind: AdapterEventKind::Swap,
            updates: vec![
                StateUpdate::slot_masked(address, layout.slot0_slot, mask, value),
                StateUpdate::slot(address, layout.liquidity_slot, liquidity),
            ],
            quality: UpdateQuality::ExactIfApplied,
            repair: RepairAction::None,
        })
    }

    /// Decode a Uniswap V3 `Mint`/`Burn` and **event-source** the affected state
    /// directly wherever it is already warm — no RPC — falling back to a targeted
    /// resync only for boundary ticks whose base value is cold (outside the warmed
    /// window).
    ///
    /// The event carries the exact liquidity delta (`amount`) and the boundary
    /// ticks; the current tick comes from cached `slot0`. For each **warm**
    /// boundary tick this read-modify-writes the packed `Tick.Info` word 0
    /// (`liquidityGross` in the low 128 bits, `liquidityNet` in the high 128 —
    /// moving in *opposite* directions for the lower vs. upper tick) and toggles
    /// the `tickBitmap` bit when the tick initializes or clears (the contract's
    /// `flipTick` is exactly an XOR of that bit). The global `liquidity` slot is
    /// adjusted by `±amount` when the position straddles the current tick. Those
    /// are precisely the slots a `QuoterV2` swap reads; `feeGrowthOutside` and the
    /// `positions` mapping are accounting-only (they do not affect `amountOut`) and
    /// are intentionally not maintained here.
    ///
    /// A **cold** boundary tick (its word 0 not cached) cannot be
    /// read-modify-written, so its info + bitmap slots are emitted as a
    /// [`RepairAction::VerifySlots`] resync instead — the hybrid write-where-warm /
    /// resync-cold policy. A pool with no resolvable layout falls back to a
    /// conservative whole-storage invalidation.
    fn decode_liquidity_event(
        &self,
        pool: &PoolRegistration,
        log: &Log,
        view: &dyn StateView,
        is_mint: bool,
    ) -> AdapterEventResult {
        let decode_ok = if is_mint {
            Mint::decode_log_data_validate(&log.data).is_ok()
        } else {
            Burn::decode_log_data_validate(&log.data).is_ok()
        };
        if !decode_ok {
            return AdapterEventResult::error(AdapterEventError::MalformedLog(
                "malformed V3 liquidity log",
            ));
        }

        let Some(tick_lower_topic) = log.topics().get(2) else {
            return AdapterEventResult::error(AdapterEventError::MalformedLog(
                "missing V3 tickLower topic",
            ));
        };
        let Some(tick_upper_topic) = log.topics().get(3) else {
            return AdapterEventResult::error(AdapterEventError::MalformedLog(
                "missing V3 tickUpper topic",
            ));
        };
        let tick_lower = topic_to_i32(tick_lower_topic);
        let tick_upper = topic_to_i32(tick_upper_topic);

        let topic0 = if is_mint {
            Mint::SIGNATURE_HASH
        } else {
            Burn::SIGNATURE_HASH
        };
        let kind = if is_mint {
            AdapterEventKind::LiquidityAdded
        } else {
            AdapterEventKind::LiquidityRemoved
        };

        let Some(address) = pool.key.address() else {
            return AdapterEventResult::error(AdapterEventError::MalformedLog(
                "V3 pool key is not address-keyed",
            ));
        };

        // The `amount` (uint128 liquidity L) is the first NON-indexed data word:
        // index 1 for Mint (a non-indexed `sender` precedes it) and index 0 for
        // Burn (no leading non-indexed field).
        let amount_word_index = if is_mint { 1 } else { 0 };
        let Some(amount_word) = data_word(log, amount_word_index) else {
            return AdapterEventResult::error(AdapterEventError::MalformedLog(
                "missing V3 liquidity amount",
            ));
        };
        let amount = u128_low(amount_word);

        // Without a resolvable layout the protocol slots cannot be named safely, so
        // conservatively invalidate all of the pool's storage (prior behavior).
        let Some(layout) = layout_for(pool) else {
            return AdapterEventResult::event(
                AdapterEvent::new(
                    pool.key.clone(),
                    log.address,
                    topic0,
                    kind,
                    UpdateQuality::RequiresRepair,
                )
                .with_repair(RepairAction::PurgeStorage(address)),
            );
        };

        let mut updates: Vec<StateUpdate> = Vec::new();
        let mut resync: Vec<(Address, U256)> = Vec::new();

        // Current tick from cached slot0 drives the in-range check for the global
        // liquidity. slot0 is a mandatory cold-start slot; the boundary-tick writes
        // below are independent of it.
        let current_tick = view
            .storage(address, layout.slot0_slot)
            .map(|slot0| int24_from_word(slot0 >> SLOT0_TICK_SHIFT));

        match current_tick {
            // In range: apply ±amount to the warm global liquidity, or resync it.
            Some(tick) if tick_lower <= tick && tick < tick_upper => {
                match view.storage(address, layout.liquidity_slot) {
                    // Checked, not saturating: if the delta cannot apply (a
                    // duplicated / out-of-order log, a reorg, or a bad cache
                    // base), resync the slot to on-chain truth rather than write a
                    // plausible-but-wrong value — the same self-healing choice the
                    // boundary-tick path (`apply_liquidity_delta`) makes.
                    Some(old) => {
                        let delta = U256::from(amount);
                        let updated = if is_mint {
                            old.checked_add(delta)
                        } else {
                            old.checked_sub(delta)
                        };
                        match updated {
                            Some(new) => {
                                updates.push(StateUpdate::slot(address, layout.liquidity_slot, new))
                            }
                            None => resync.push((address, layout.liquidity_slot)),
                        }
                    }
                    None => resync.push((address, layout.liquidity_slot)),
                }
            }
            // Out of range: the position does not straddle the current tick, so the
            // global liquidity is unaffected.
            Some(_) => {}
            // slot0 cold (a degraded pool): the in-range decision cannot be made, so
            // conservatively resync the global liquidity slot to its on-chain truth
            // rather than silently dropping a possible delta (self-healing, and it
            // correctly forces RequiresRepair).
            None => resync.push((address, layout.liquidity_slot)),
        }

        // Bitmap-bit flips are accumulated per bitmap word as an XOR mask, then
        // emitted as ONE combined write per word below. Both boundary ticks can
        // land in the same word; two separate full-slot writes — each computed
        // from the same pre-event `view` — would not compose (the second would
        // clobber the first), so they must be merged before writing.
        let mut bitmap_toggles: Vec<(U256, U256)> = Vec::new();

        // Each boundary tick: read-modify-write the packed `Tick.Info` word 0 (and
        // record a bitmap-bit flip on an init/clear) when warm; resync when cold.
        for (tick, is_lower) in [(tick_lower, true), (tick_upper, false)] {
            let keys = v3_tick_info_storage_keys_with_base(tick, layout.ticks_base_slot);
            let word_pos = v3_word_position(tick, layout.tick_spacing);
            let bitmap_key =
                v3_tick_bitmap_storage_key_with_base(word_pos, layout.tick_bitmap_base_slot);

            let cold_fallback = |resync: &mut Vec<(Address, U256)>| {
                resync.extend(keys.iter().map(|slot| (address, *slot)));
                resync.push((address, bitmap_key));
            };

            let Some(old_word0) = view.storage(address, keys[0]) else {
                // Cold tick: cannot read-modify-write; resync its info + bitmap slots.
                cold_fallback(&mut resync);
                continue;
            };

            let Some((new_word0, was_init, now_init)) =
                apply_liquidity_delta(old_word0, amount, is_mint, is_lower)
            else {
                // Arithmetic out of range (should not happen for valid chain data):
                // resync this tick rather than write a wrong value.
                cold_fallback(&mut resync);
                continue;
            };

            updates.push(StateUpdate::slot(address, keys[0], new_word0));

            // The bitmap bit flips exactly when the tick's initialized state
            // changes (Uniswap `flipTick` XORs the bit).
            if was_init != now_init {
                if view.storage(address, bitmap_key).is_some() {
                    let mask = U256::from(1u8) << v3_bit_position(tick, layout.tick_spacing);
                    match bitmap_toggles
                        .iter_mut()
                        .find(|(key, _)| *key == bitmap_key)
                    {
                        Some((_, acc)) => *acc ^= mask,
                        None => bitmap_toggles.push((bitmap_key, mask)),
                    }
                } else {
                    // Cold bitmap word: cannot toggle without the base; resync it.
                    resync.push((address, bitmap_key));
                }
            }
        }

        // Emit one combined write per touched bitmap word (base XOR accumulated
        // mask), so both ticks' flips in a shared word compose correctly.
        for (bitmap_key, mask) in bitmap_toggles {
            match view.storage(address, bitmap_key) {
                Some(base) => updates.push(StateUpdate::slot(address, bitmap_key, base ^ mask)),
                None => resync.push((address, bitmap_key)),
            }
        }

        // Dedup the resync set (both boundary ticks can share a bitmap word).
        resync.sort_unstable();
        resync.dedup();

        let (quality, repair) = if resync.is_empty() {
            (UpdateQuality::Exact, RepairAction::None)
        } else {
            (
                UpdateQuality::RequiresRepair,
                RepairAction::VerifySlots(resync),
            )
        };

        AdapterEventResult::event(
            AdapterEvent::new(pool.key.clone(), log.address, topic0, kind, quality)
                .with_updates(updates)
                .with_repair(repair),
        )
    }
}

/// Cold-start planner for the Uniswap V3 storage-layout family.
///
/// Warms a bounded **window** of tick-bitmap words around the current tick as
/// planner rounds:
///
/// - Round 1 verifies `slot0` + global `liquidity`. `slot0` is mandatory; its
///   [`SlotFetch`] verdict decides ready vs. repair. From the warmed `slot0` the
///   current tick — and so the current `tickBitmap` word `W0` — is decoded, then
///   the window `[W0 - R, W0 + R]` (`R = ` the pool's
///   [`V3Metadata::warm_word_radius`], defaulting to [`V3_TICK_WORD_RADIUS`]) is
///   computed, clamped to the valid V3 word range, and each word's bitmap key
///   resolved.
/// - Round 2 (`Strict`/`Eager` only) verifies **all** window bitmap words in one
///   round.
/// - Round 3 (`Strict`/`Eager` only) verifies all four `Tick.Info` words of
///   every tick initialized across the whole window in one round.
///
/// `HotSlotsOnly` stops after round 1 (slot0 + liquidity — no bitmap/tick
/// warming). `Lazy` stops after round 1 and defers the **window** of bitmap
/// words. Config-supplied V3 metadata is preserved unchanged.
struct UniswapV3ColdStartPlanner {
    address: Address,
    layout: V3StorageLayout,
    policy: ColdStartPolicy,
    /// ± radius, in tick-bitmap words, of the cold-start tick-warm window (from
    /// the pool's [`V3Metadata::warm_word_radius`], or [`V3_TICK_WORD_RADIUS`]
    /// when unset). Clamped to `>= 0` in [`Self::resolve_window`].
    radius: i16,
    phase: V3Phase,
    /// The cold-start window: each `(word, bitmap_key)` pair in
    /// `[W0 - R, W0 + R]` clamped to the valid V3 word range, resolved from the
    /// warmed slot0. Empty until round 1 decodes the current tick.
    window: Vec<(i16, U256)>,
    verified_slots: Vec<(Address, U256)>,
    changed_slots: Vec<SlotChange>,
    deferred: Vec<DeferredWork>,
    /// `true` once round 1 found `slot0` cold (unfetchable / genuine zero).
    slot0_cold: bool,
}

/// Which cold-start round the V3 planner just completed.
#[derive(Clone, Copy, PartialEq, Eq)]
enum V3Phase {
    /// Round 1: slot0 + liquidity (the next `on_results` classifies slot0).
    Slot0Liquidity,
    /// Round 2: the window of bitmap words (the next `on_results` extracts the
    /// initialized ticks across the whole window).
    BitmapWord,
    /// Round 3: the tick-info slots (the next `on_results` finishes).
    TickInfo,
}

impl UniswapV3ColdStartPlanner {
    fn new(
        address: Address,
        layout: V3StorageLayout,
        policy: ColdStartPolicy,
        radius: i16,
    ) -> Self {
        Self {
            address,
            layout,
            policy,
            radius,
            phase: V3Phase::Slot0Liquidity,
            window: Vec::new(),
            verified_slots: Vec::new(),
            changed_slots: Vec::new(),
            deferred: Vec::new(),
            slot0_cold: false,
        }
    }

    /// Resolve the bounded window of bitmap words `[W0 - R, W0 + R]` around the
    /// current-tick word, clamped to the valid V3 word range, returning each
    /// `(word, bitmap_key)` pair.
    ///
    /// `R` is `self.radius` (the pool's [`V3Metadata::warm_word_radius`], or
    /// [`V3_TICK_WORD_RADIUS`] when unset), clamped to `>= 0` so a negative
    /// radius is treated as `0` (current word only) rather than underflowing the
    /// window math.
    ///
    /// The word clamp derives from `MIN_TICK`/`MAX_TICK = ±887272`: words outside
    /// the pool's reachable word range are skipped. All arithmetic is done in
    /// `i32` before the final `i16` cast so the radius offset can never overflow.
    fn resolve_window(&self, current_word: i16) -> Vec<(i16, U256)> {
        let radius = self.radius.max(0) as i32;
        let min_word = v3_word_position(V3_MIN_TICK, self.layout.tick_spacing) as i32;
        let max_word = v3_word_position(V3_MAX_TICK, self.layout.tick_spacing) as i32;

        let lo = (current_word as i32 - radius).max(min_word);
        let hi = (current_word as i32 + radius).min(max_word);

        let mut window = Vec::new();
        let mut word = lo;
        while word <= hi {
            let word_i16 = word as i16;
            let key =
                v3_tick_bitmap_storage_key_with_base(word_i16, self.layout.tick_bitmap_base_slot);
            window.push((word_i16, key));
            word += 1;
        }
        window
    }
}

impl AdapterColdStartPlanner for UniswapV3ColdStartPlanner {
    fn initial_plan(&mut self, _state: &dyn StateView) -> ColdStartPlan {
        // Round 1: slot0 + global liquidity. slot0 is mandatory (it carries the
        // current tick that drives the bounded tick warm-up); liquidity is an
        // absolute write that the reactive Swap always reapplies.
        let verify = vec![
            (self.address, self.layout.slot0_slot),
            (self.address, self.layout.liquidity_slot),
        ];
        self.verified_slots.extend_from_slice(&verify);
        ColdStartPlan {
            verify,
            ..Default::default()
        }
    }

    fn on_results(&mut self, results: &ColdStartResults, state: &dyn StateView) -> ColdStartStep {
        self.changed_slots.extend(results.verified.iter().cloned());

        match self.phase {
            V3Phase::Slot0Liquidity => {
                // slot0 is mandatory: classify it from its per-slot `SlotFetch`
                // rather than a `cached_storage(..).is_none()` proxy.
                let slot0_outcome = results
                    .fetched
                    .iter()
                    .find(|o| o.address == self.address && o.slot == self.layout.slot0_slot);
                let slot0_value = match slot0_outcome.map(|o| &o.fetch) {
                    Some(SlotFetch::Value(value)) => Some(*value),
                    // A genuine zero or a fetch failure leaves slot0 cold/unusable.
                    _ => None,
                };

                let Some(slot0) = slot0_value else {
                    self.slot0_cold = true;
                    return ColdStartStep::Done;
                };

                // Decode the current tick from the warm slot0 word (bits
                // [160, 184), 24-bit signed), reusing the reactive Swap decode,
                // then resolve the bounded window of bitmap words around it.
                let tick = int24_from_word(slot0 >> SLOT0_TICK_SHIFT);
                let current_word = v3_word_position(tick, self.layout.tick_spacing);
                self.window = self.resolve_window(current_word);

                match self.policy {
                    ColdStartPolicy::Strict | ColdStartPolicy::Eager => {
                        // Round 2: warm every bitmap word in the window in one round.
                        self.phase = V3Phase::BitmapWord;
                        let verify: Vec<(Address, U256)> = self
                            .window
                            .iter()
                            .map(|(_, key)| (self.address, *key))
                            .collect();
                        self.verified_slots.extend_from_slice(&verify);
                        ColdStartStep::Continue(ColdStartPlan {
                            verify,
                            ..Default::default()
                        })
                    }
                    ColdStartPolicy::HotSlotsOnly => ColdStartStep::Done,
                    ColdStartPolicy::Lazy => {
                        // Warm the hot slots now; defer the whole window of bitmap words.
                        let window_keys: Vec<(Address, U256)> = self
                            .window
                            .iter()
                            .map(|(_, key)| (self.address, *key))
                            .collect();
                        self.deferred.push(DeferredWork::VerifySlots(window_keys));
                        ColdStartStep::Done
                    }
                }
            }
            V3Phase::BitmapWord => {
                // Round 3: warm ALL FOUR `Tick.Info` words of every tick
                // initialized across the whole window. A tick-crossing swap quote
                // reads the full struct — `liquidityGross`/`liquidityNet` (word 0),
                // both `feeGrowthOutside{0,1}X128` (words 1/2), and the packed
                // `tickCumulative`/`secondsPerLiquidity`/`secondsOutside`/
                // `initialized` (word 3) — so warming only {0, 3} left a hard
                // tick-crossing quote lazily fetching words 1/2 (correct online,
                // but not fully offline). Warming all four matches the one-shot
                // full-sync program. Each window word's bitmap is extracted
                // adapter-locally: bit `i` set => tick `(word * 256 + i) *
                // tick_spacing`, skipping any tick outside [MIN_TICK, MAX_TICK].
                let mut tick_slots: Vec<(Address, U256)> = Vec::new();
                for (word, bitmap_key) in &self.window {
                    let bitmap = state
                        .storage(self.address, *bitmap_key)
                        .unwrap_or(U256::ZERO);
                    for bit in 0..256u32 {
                        if (bitmap >> bit) & U256::from(1) == U256::from(1) {
                            // Compute the tick index in i32; word/bit/spacing are
                            // all bounded so this cannot overflow.
                            let tick_i =
                                (*word as i32 * 256 + bit as i32) * self.layout.tick_spacing;
                            if !(V3_MIN_TICK..=V3_MAX_TICK).contains(&tick_i) {
                                continue;
                            }
                            let keys = v3_tick_info_storage_keys_with_base(
                                tick_i,
                                self.layout.ticks_base_slot,
                            );
                            tick_slots.extend(keys.iter().map(|key| (self.address, *key)));
                        }
                    }
                }

                if tick_slots.is_empty() {
                    ColdStartStep::Done
                } else {
                    self.phase = V3Phase::TickInfo;
                    self.verified_slots.extend_from_slice(&tick_slots);
                    ColdStartStep::Continue(ColdStartPlan {
                        verify: tick_slots,
                        ..Default::default()
                    })
                }
            }
            V3Phase::TickInfo => ColdStartStep::Done,
        }
    }

    fn finish(
        &mut self,
        pool: &mut PoolRegistration,
        _report: &ColdStartRunReport,
    ) -> ColdStartOutcome {
        let mut report = ColdStartReport::new(pool.key.clone(), self.policy);
        report.verified_slots = self.verified_slots.clone();
        report.changed_slots = self.changed_slots.clone();

        if self.slot0_cold {
            report.status = PoolStatus::Degraded;
            return ColdStartOutcome::NeedsRepair(
                report,
                RepairAction::VerifySlots(vec![(self.address, self.layout.slot0_slot)]),
            );
        }

        // Preserve the config-supplied V3 metadata (token0/token1/fee/
        // tick_spacing/layout are not at predictable storage slots and are not
        // re-fetched here).
        record_v3_warmed_slots(pool, self.address, &self.verified_slots);
        pool.status = PoolStatus::Ready;
        report.status = PoolStatus::Ready;

        if self.deferred.is_empty() {
            ColdStartOutcome::Ready(report)
        } else {
            report.deferred = self.deferred.clone();
            ColdStartOutcome::ReadyWithDeferred(report, self.deferred.clone())
        }
    }
}

fn record_v3_warmed_slots(
    pool: &mut PoolRegistration,
    address: Address,
    verified_slots: &[(Address, U256)],
) {
    let mut slots = verified_slots
        .iter()
        .filter_map(|(slot_address, slot)| (*slot_address == address).then_some(*slot))
        .collect::<Vec<_>>();
    slots.sort_unstable();
    slots.dedup();
    match &mut pool.metadata {
        ProtocolMetadata::UniswapV3(metadata)
        | ProtocolMetadata::PancakeV3(metadata)
        | ProtocolMetadata::Slipstream(metadata) => {
            metadata.warmed_slots = slots;
        }
        _ => {}
    }
}

fn data_word(log: &Log, index: usize) -> Option<U256> {
    let start = index.checked_mul(32)?;
    log.data
        .data
        .get(start..start + 32)
        .map(U256::from_be_slice)
}

fn int24_from_word(word: U256) -> i32 {
    let raw = (word & U256::from(0x00FF_FFFFu32)).to::<u32>();
    if raw & 0x0080_0000 != 0 {
        (raw | 0xFF00_0000) as i32
    } else {
        raw as i32
    }
}

fn topic_to_i32(topic: &B256) -> i32 {
    let mut bytes = [0u8; 4];
    bytes.copy_from_slice(&topic.as_slice()[28..32]);
    i32::from_be_bytes(bytes)
}

fn low_mask(bits: usize) -> U256 {
    (U256::from(1) << bits) - U256::from(1)
}

/// The low 128 bits of a 256-bit word (a `Tick.Info` word 0's `liquidityGross`).
fn u128_low(word: U256) -> u128 {
    let limbs = word.as_limbs();
    (limbs[0] as u128) | ((limbs[1] as u128) << 64)
}

/// The high 128 bits of a 256-bit word, as raw bits (word 0's `liquidityNet`,
/// two's-complement `int128`).
fn u128_high(word: U256) -> u128 {
    let limbs = word.as_limbs();
    (limbs[2] as u128) | ((limbs[3] as u128) << 64)
}

/// Pack `liquidityGross` (low 128) and `liquidityNet` (high 128, two's complement)
/// back into a `Tick.Info` word-0 value.
fn pack_gross_net(gross: u128, net: i128) -> U256 {
    U256::from(gross) | (U256::from(net as u128) << 128)
}

/// The `tickBitmap` bit index (0..256) for `tick`, matching the V3
/// `TickBitmap.position` low byte (`compressed % 256`, floor-toward-negative).
/// `tick_spacing` must be positive (guaranteed by [`layout_for`]).
fn v3_bit_position(tick: i32, tick_spacing: i32) -> usize {
    tick.div_euclid(tick_spacing).rem_euclid(256) as usize
}

/// Apply a liquidity `amount` delta to a `Tick.Info` word 0, returning the new
/// packed word plus the tick's initialized state before/after.
///
/// `liquidityGross` always moves by `+amount` (mint) / `-amount` (burn);
/// `liquidityNet` moves `+amount` for the lower tick and `-amount` for the upper
/// on a mint (negated on a burn) — captured by `add_to_net = is_mint == is_lower`.
/// Returns `None` on arithmetic overflow/underflow (invalid chain data) so the
/// caller can resync the tick instead of writing a wrong value.
fn apply_liquidity_delta(
    word0: U256,
    amount: u128,
    is_mint: bool,
    is_lower: bool,
) -> Option<(U256, bool, bool)> {
    let old_gross = u128_low(word0);
    let old_net = u128_high(word0) as i128;
    let signed = i128::try_from(amount).ok()?;

    let new_gross = if is_mint {
        old_gross.checked_add(amount)?
    } else {
        old_gross.checked_sub(amount)?
    };
    let add_to_net = is_mint == is_lower;
    let new_net = if add_to_net {
        old_net.checked_add(signed)?
    } else {
        old_net.checked_sub(signed)?
    };

    let was_init = old_gross != 0;
    let now_init = new_gross != 0;
    Some((pack_gross_net(new_gross, new_net), was_init, now_init))
}

#[cfg(test)]
mod tests {
    use super::super::PoolKey;
    use super::*;

    fn gross(word0: U256) -> u128 {
        u128_low(word0)
    }
    fn net(word0: U256) -> i128 {
        u128_high(word0) as i128
    }

    #[test]
    fn pack_unpack_round_trips_including_negative_net() {
        for (g, n) in [
            (0u128, 0i128),
            (5, 7),
            (u128::MAX, -1),
            (123, i128::MIN),
            (1, i128::MAX),
        ] {
            let w = pack_gross_net(g, n);
            assert_eq!(gross(w), g);
            assert_eq!(net(w), n);
        }
    }

    #[test]
    fn record_warmed_slots_keeps_pool_slots_only() {
        let address = Address::repeat_byte(0x42);
        let other = Address::repeat_byte(0x43);
        let mut pool = PoolRegistration::new(PoolKey::UniswapV3(address)).with_metadata(
            ProtocolMetadata::UniswapV3(V3Metadata::default().with_tick_spacing(60)),
        );

        record_v3_warmed_slots(
            &mut pool,
            address,
            &[
                (address, U256::from(4)),
                (other, U256::from(9)),
                (address, U256::ZERO),
                (address, U256::from(4)),
            ],
        );

        let ProtocolMetadata::UniswapV3(metadata) = &pool.metadata else {
            panic!("metadata changed protocol");
        };
        assert_eq!(metadata.warmed_slots, vec![U256::ZERO, U256::from(4)]);
    }

    #[test]
    fn v3_dependencies_use_warmed_slots_not_whole_account() {
        let address = Address::repeat_byte(0x44);
        let pool = PoolRegistration::new(PoolKey::UniswapV3(address)).with_metadata(
            ProtocolMetadata::UniswapV3(
                V3Metadata::default()
                    .with_tick_spacing(60)
                    .with_warmed_slots([U256::from(4), U256::ZERO, U256::from(4)]),
            ),
        );

        let dependencies = ConcentratedLiquidityAdapter::default().state_dependencies(&pool);

        assert_eq!(dependencies.associated_addresses(), &[address]);
        assert!(dependencies.whole_accounts().is_empty());
        assert_eq!(
            dependencies.slots(),
            &[
                StateSlot::new(address, U256::ZERO),
                StateSlot::new(address, U256::from(4)),
            ]
        );
    }

    #[test]
    fn mint_lower_adds_gross_and_net() {
        // gross += amount (low), net += amount (high, lower tick).
        let (w, was, now) = apply_liquidity_delta(pack_gross_net(10, 3), 4, true, true).unwrap();
        assert_eq!(gross(w), 14);
        assert_eq!(net(w), 7);
        assert!(was && now);
    }

    #[test]
    fn mint_upper_adds_gross_subtracts_net() {
        let (w, _, _) = apply_liquidity_delta(pack_gross_net(10, 3), 4, true, false).unwrap();
        assert_eq!(gross(w), 14);
        assert_eq!(net(w), -1);
    }

    #[test]
    fn burn_lower_subtracts_both() {
        let (w, _, _) = apply_liquidity_delta(pack_gross_net(10, 3), 4, false, true).unwrap();
        assert_eq!(gross(w), 6);
        assert_eq!(net(w), -1);
    }

    #[test]
    fn burn_upper_subtracts_gross_adds_net() {
        let (w, _, _) = apply_liquidity_delta(pack_gross_net(10, 3), 4, false, false).unwrap();
        assert_eq!(gross(w), 6);
        assert_eq!(net(w), 7);
    }

    #[test]
    fn mint_onto_empty_tick_reports_initialization() {
        // A tick with zero gross that gains liquidity flips uninitialized→initialized.
        let (w, was, now) = apply_liquidity_delta(U256::ZERO, 5, true, true).unwrap();
        assert_eq!(gross(w), 5);
        assert_eq!(net(w), 5);
        assert!(!was && now);
    }

    #[test]
    fn burn_to_zero_reports_clear_and_zeroes_word() {
        // Burning all of a tick's gross flips initialized→uninitialized; the lower
        // tick's net returns to zero, so word 0 is fully zero.
        let (w, was, now) = apply_liquidity_delta(pack_gross_net(5, 5), 5, false, true).unwrap();
        assert_eq!(w, U256::ZERO);
        assert!(was && !now);
    }

    #[test]
    fn burn_more_than_gross_is_rejected() {
        assert!(apply_liquidity_delta(pack_gross_net(3, 3), 4, false, true).is_none());
    }

    // Pin the contract at the exact 128-bit boundaries: checked arithmetic
    // (`None` -> the caller resyncs the tick) — never a wrap or saturation
    // silently packed into a wrong word.
    #[test]
    fn liquidity_delta_boundary_values_reject_not_wrap() {
        // Filling gross to exactly u128::MAX is representable...
        let (w, was, now) =
            apply_liquidity_delta(pack_gross_net(u128::MAX - 4, 0), 4, true, true).unwrap();
        assert_eq!(gross(w), u128::MAX);
        assert!(was && now);
        // ...one more unit is None, not a wrap to zero.
        assert!(apply_liquidity_delta(pack_gross_net(u128::MAX, 0), 1, true, true).is_none());
        // Net overflow at i128::MAX (mint at the lower tick adds to net).
        assert!(apply_liquidity_delta(pack_gross_net(0, i128::MAX), 1, true, true).is_none());
        // Net underflow at i128::MIN (mint at the upper tick subtracts).
        assert!(apply_liquidity_delta(pack_gross_net(0, i128::MIN), 1, true, false).is_none());
        // An amount above i128::MAX cannot be a valid net move: rejected up front.
        assert!(apply_liquidity_delta(pack_gross_net(0, 0), 1u128 << 127, true, true).is_none());
        // The largest representable amount round-trips exactly.
        let amount = i128::MAX as u128;
        let (w, _, _) = apply_liquidity_delta(pack_gross_net(0, 0), amount, true, true).unwrap();
        assert_eq!(gross(w), amount);
        assert_eq!(net(w), i128::MAX);
    }

    #[test]
    fn bit_position_matches_uniswap_position_low_byte() {
        // spacing 1: compressed == tick; bit = tick mod 256 (floor for negatives).
        assert_eq!(v3_bit_position(0, 1), 0);
        assert_eq!(v3_bit_position(255, 1), 255);
        assert_eq!(v3_bit_position(256, 1), 0);
        assert_eq!(v3_bit_position(-1, 1), 255); // word -1, top bit
        assert_eq!(v3_bit_position(-256, 1), 0);
        // spacing 60: compressed = tick/60.
        assert_eq!(v3_bit_position(60, 60), 1);
        assert_eq!(v3_bit_position(120, 60), 2);
    }
}