Skip to main content

nautilus_model/defi/
pool_identifier.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{
17    fmt::{Debug, Display},
18    hash::{Hash, Hasher},
19    str::FromStr,
20};
21
22use alloy_primitives::Address;
23use nautilus_core::{correctness::FAILED, hex};
24use serde::{Deserialize, Deserializer, Serialize, Serializer};
25use ustr::Ustr;
26
27/// Protocol-aware pool identifier for DeFi liquidity pools.
28///
29/// This enum distinguishes between two types of pool identifiers:
30/// - **Address**: Used by V2/V3 protocols where pool identifier equals pool contract address (42 chars: "0x" + 40 hex)
31/// - **`PoolId`**: Used by V4 protocols where pool identifier is a bytes32 hash (66 chars: "0x" + 64 hex)
32///
33/// The type implements case-insensitive equality and hashing for address comparison,
34/// while preserving the original case for display purposes.
35///
36/// DeFi pool data carries both this `PoolIdentifier` and an `InstrumentId`, which key different
37/// layers. The chain and database layers key on the `PoolIdentifier`: the raw on-chain identity
38/// used for log filters and table lookups. The engine, message bus, and cache key on the
39/// `InstrumentId` (`Symbol(pool_identifier)` at `Venue(chain:dex)`), so pool events flow through
40/// the same instrument-keyed infrastructure as any other data. The `InstrumentId` flattens the
41/// identifier to a string and loses the `Address` versus `PoolId` variant, so it cannot
42/// reconstruct this type: both are stored rather than derived.
43#[derive(Clone, Copy, PartialOrd, Ord)]
44pub enum PoolIdentifier {
45    /// V2/V3 pool identifier (checksummed Ethereum address)
46    Address(Ustr),
47    /// V4 pool identifier (32-byte pool ID as hex string)
48    PoolId(Ustr),
49}
50
51impl PoolIdentifier {
52    /// Creates a new [`PoolIdentifier`] instance with correctness checking.
53    ///
54    /// Automatically detects variant based on string length:
55    /// - 42 characters (0x + 40 hex): Address variant
56    /// - 66 characters (0x + 64 hex): `PoolId` variant
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if:
61    /// - String doesn't start with "0x"
62    /// - Length is neither 42 nor 66 characters
63    /// - Contains invalid hex characters
64    /// - Address checksum validation fails (for Address variant)
65    pub fn new_checked<T: AsRef<str>>(value: T) -> anyhow::Result<Self> {
66        let value = value.as_ref();
67
68        if !value.starts_with("0x") {
69            anyhow::bail!("Pool identifier must start with '0x', was: {value}");
70        }
71
72        match value.len() {
73            42 => {
74                validate_hex_string(value)?;
75
76                // Parse without strict checksum validation, then normalize to checksummed format
77                let addr = value
78                    .parse::<Address>()
79                    .map_err(|e| anyhow::anyhow!("Invalid address: {e}"))?;
80
81                // Store the checksummed version
82                Ok(Self::Address(Ustr::from(addr.to_checksum(None).as_str())))
83            }
84            66 => {
85                // PoolId variant (32 bytes)
86                validate_hex_string(value)?;
87
88                // Store lowercase version for consistency
89                Ok(Self::PoolId(Ustr::from(&value.to_lowercase())))
90            }
91            len => {
92                anyhow::bail!(
93                    "Pool identifier must be 42 chars (address) or 66 chars (pool ID), was {len} chars: {value}"
94                )
95            }
96        }
97    }
98
99    /// Creates a new [`PoolIdentifier`] instance.
100    ///
101    /// # Panics
102    ///
103    /// Panics if validation fails.
104    #[must_use]
105    pub fn new<T: AsRef<str>>(value: T) -> Self {
106        Self::new_checked(value).expect(FAILED)
107    }
108
109    /// Creates an Address variant from an alloy Address.
110    ///
111    /// Returns the checksummed representation.
112    #[must_use]
113    pub fn from_address(address: Address) -> Self {
114        Self::Address(Ustr::from(address.to_checksum(None).as_str()))
115    }
116
117    /// Creates a `PoolId` variant from raw bytes (32 bytes).
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if bytes length is not 32.
122    pub fn from_pool_id_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
123        anyhow::ensure!(
124            bytes.len() == 32,
125            "Pool ID must be 32 bytes, was {}",
126            bytes.len()
127        );
128
129        Ok(Self::PoolId(Ustr::from(&hex::encode_prefixed(bytes))))
130    }
131
132    /// Creates a `PoolId` variant from a hex string (with or without 0x prefix).
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the string is not valid 64-character hex.
137    pub fn from_pool_id_hex<T: AsRef<str>>(hex: T) -> anyhow::Result<Self> {
138        let hex = hex.as_ref();
139        let hex_str = hex.strip_prefix("0x").unwrap_or(hex);
140
141        anyhow::ensure!(
142            hex_str.len() == 64,
143            "Pool ID hex must be 64 characters (32 bytes), was {}",
144            hex_str.len()
145        );
146
147        validate_hex_string(&format!("0x{hex_str}"))?;
148
149        Ok(Self::PoolId(Ustr::from(&format!(
150            "0x{}",
151            hex_str.to_lowercase()
152        ))))
153    }
154
155    /// Returns the inner identifier value as a Ustr.
156    #[must_use]
157    pub fn inner(&self) -> Ustr {
158        match self {
159            Self::Address(s) | Self::PoolId(s) => *s,
160        }
161    }
162
163    /// Returns the inner identifier value as a string slice.
164    #[must_use]
165    pub fn as_str(&self) -> &str {
166        match self {
167            Self::Address(s) | Self::PoolId(s) => s.as_str(),
168        }
169    }
170
171    /// Returns true if this is an Address variant (V2/V3 pools).
172    #[must_use]
173    pub fn is_address(&self) -> bool {
174        matches!(self, Self::Address(_))
175    }
176
177    /// Returns true if this is a `PoolId` variant (V4 pools).
178    #[must_use]
179    pub fn is_pool_id(&self) -> bool {
180        matches!(self, Self::PoolId(_))
181    }
182
183    /// Converts to native Address type (V2/V3 pools only).
184    ///
185    /// Returns the underlying Address for use with alloy/ethers operations.
186    ///
187    /// # Errors
188    ///
189    /// Returns error if this is a `PoolId` variant or if parsing fails.
190    pub fn to_address(&self) -> anyhow::Result<Address> {
191        match self {
192            Self::Address(s) => Address::parse_checksummed(s.as_str(), None)
193                .map_err(|e| anyhow::anyhow!("Failed to parse address: {e}")),
194            Self::PoolId(_) => anyhow::bail!("Cannot convert PoolId variant to Address"),
195        }
196    }
197
198    /// Converts to native bytes array (V4 pools only).
199    ///
200    /// Returns the 32-byte pool ID for use in V4-specific operations.
201    ///
202    /// # Errors
203    ///
204    /// Returns error if this is an Address variant or if hex decoding fails.
205    pub fn to_pool_id_bytes(&self) -> anyhow::Result<[u8; 32]> {
206        match self {
207            Self::PoolId(s) => {
208                let hex_str = s.as_str().strip_prefix("0x").unwrap_or(s.as_str());
209                hex::decode_array::<32>(hex_str)
210                    .map_err(|e| anyhow::anyhow!("Failed to decode pool ID hex: {e}"))
211            }
212            Self::Address(_) => anyhow::bail!("Cannot convert Address variant to PoolId bytes"),
213        }
214    }
215}
216
217/// Validates that a string contains only valid hexadecimal characters after "0x" prefix.
218fn validate_hex_string(s: &str) -> anyhow::Result<()> {
219    let hex_part = &s[2..];
220    if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
221        anyhow::bail!("Invalid hex characters in: {s}");
222    }
223    Ok(())
224}
225
226impl PartialEq for PoolIdentifier {
227    fn eq(&self, other: &Self) -> bool {
228        match (self, other) {
229            (Self::Address(a), Self::Address(b)) | (Self::PoolId(a), Self::PoolId(b)) => {
230                // Case-insensitive comparison
231                a.as_str().eq_ignore_ascii_case(b.as_str())
232            }
233            // Different variants are never equal
234            _ => false,
235        }
236    }
237}
238
239impl Eq for PoolIdentifier {}
240
241impl Hash for PoolIdentifier {
242    fn hash<H: Hasher>(&self, state: &mut H) {
243        // Hash the variant discriminant first
244        std::mem::discriminant(self).hash(state);
245
246        // Then hash the lowercase version of the string
247        match self {
248            Self::Address(s) | Self::PoolId(s) => {
249                for byte in s.as_str().bytes() {
250                    state.write_u8(byte.to_ascii_lowercase());
251                }
252            }
253        }
254    }
255}
256
257impl Display for PoolIdentifier {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        match self {
260            Self::Address(s) | Self::PoolId(s) => write!(f, "{s}"),
261        }
262    }
263}
264
265impl Debug for PoolIdentifier {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        match self {
268            Self::Address(s) => write!(f, "Address({s:?})"),
269            Self::PoolId(s) => write!(f, "PoolId({s:?})"),
270        }
271    }
272}
273
274impl Serialize for PoolIdentifier {
275    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
276    where
277        S: Serializer,
278    {
279        // Serialize as plain string (same as current String behavior)
280        match self {
281            Self::Address(s) | Self::PoolId(s) => s.serialize(serializer),
282        }
283    }
284}
285
286impl<'de> Deserialize<'de> for PoolIdentifier {
287    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
288    where
289        D: Deserializer<'de>,
290    {
291        let value_str: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
292        Self::new_checked(value_str.as_ref()).map_err(serde::de::Error::custom)
293    }
294}
295
296impl FromStr for PoolIdentifier {
297    type Err = anyhow::Error;
298
299    fn from_str(s: &str) -> Result<Self, Self::Err> {
300        Self::new_checked(s)
301    }
302}
303
304impl From<&str> for PoolIdentifier {
305    fn from(value: &str) -> Self {
306        Self::new(value)
307    }
308}
309
310impl From<String> for PoolIdentifier {
311    fn from(value: String) -> Self {
312        Self::new(value)
313    }
314}
315
316impl AsRef<str> for PoolIdentifier {
317    fn as_ref(&self) -> &str {
318        self.as_str()
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use rstest::rstest;
325
326    use super::*;
327
328    #[rstest]
329    #[case("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", true)] // Valid checksummed address
330    #[case("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", true)] // Lowercase address
331    #[case(
332        "0xc9bc8043294146424a4e4607d8ad837d6a659142822bbaaabc83bb57e7447461",
333        true
334    )] // V4 Pool ID
335    fn test_valid_pool_identifiers(#[case] input: &str, #[case] expected_valid: bool) {
336        let result = PoolIdentifier::new_checked(input);
337        assert_eq!(result.is_ok(), expected_valid, "Input: {input}");
338    }
339
340    #[rstest]
341    #[case("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")] // Missing 0x
342    #[case("0xC02aaA39")] // Too short
343    #[case("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2EXTRA")] // Too long
344    #[case("0xGGGGGGGGb223FE8D0A0e5C4F27eAD9083C756Cc2")] // Invalid hex
345    fn test_invalid_pool_identifiers(#[case] input: &str) {
346        let result = PoolIdentifier::new_checked(input);
347        assert!(result.is_err(), "Input should fail: {input}");
348    }
349
350    #[rstest]
351    fn test_case_insensitive_equality() {
352        let addr1 = PoolIdentifier::new("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
353        let addr2 = PoolIdentifier::new("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2");
354        let addr3 = PoolIdentifier::new("0xC02AAA39B223FE8D0A0E5C4F27EAD9083C756CC2");
355
356        assert_eq!(addr1, addr2);
357        assert_eq!(addr2, addr3);
358        assert_eq!(addr1, addr3);
359    }
360
361    #[rstest]
362    fn test_case_insensitive_hashing() {
363        use std::collections::HashMap;
364
365        let mut map = HashMap::new();
366        let addr1 = PoolIdentifier::new("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
367        let addr2 = PoolIdentifier::new("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2");
368
369        map.insert(addr1, "value1");
370
371        // Should be able to retrieve using different case
372        assert_eq!(map.get(&addr2), Some(&"value1"));
373    }
374
375    #[rstest]
376    fn test_display_preserves_case() {
377        let checksummed = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
378        let addr = PoolIdentifier::new_checked(checksummed).unwrap();
379
380        // Display should show checksummed version
381        assert_eq!(addr.to_string(), checksummed);
382    }
383
384    #[rstest]
385    fn test_variant_detection() {
386        let address = PoolIdentifier::new("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
387        let pool_id = PoolIdentifier::new(
388            "0xc9bc8043294146424a4e4607d8ad837d6a659142822bbaaabc83bb57e7447461",
389        );
390
391        assert!(address.is_address());
392        assert!(!address.is_pool_id());
393
394        assert!(pool_id.is_pool_id());
395        assert!(!pool_id.is_address());
396    }
397
398    #[rstest]
399    fn test_different_variants_not_equal() {
400        let address = PoolIdentifier::new("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
401        let pool_id = PoolIdentifier::new(
402            "0xc9bc8043294146424a4e4607d8ad837d6a659142822bbaaabc83bb57e7447461",
403        );
404
405        assert_ne!(address, pool_id);
406    }
407
408    #[rstest]
409    fn test_serialization_roundtrip() {
410        let original = PoolIdentifier::new("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
411
412        let json = serde_json::to_string(&original).unwrap();
413        let deserialized: PoolIdentifier = serde_json::from_str(&json).unwrap();
414
415        assert_eq!(original, deserialized);
416    }
417
418    #[rstest]
419    fn test_deserialize_from_owned_value() {
420        let value =
421            serde_json::Value::String("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2".to_string());
422
423        let deserialized: PoolIdentifier = serde_json::from_value(value).unwrap();
424        assert_eq!(
425            deserialized,
426            PoolIdentifier::new("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
427        );
428    }
429
430    #[rstest]
431    fn test_from_address() {
432        let addr = Address::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
433        let pool_id = PoolIdentifier::from_address(addr);
434
435        assert!(pool_id.is_address());
436        assert_eq!(
437            pool_id.to_string(),
438            "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
439        );
440    }
441
442    #[rstest]
443    fn test_from_pool_id_bytes() {
444        let bytes: [u8; 32] = [
445            0xc9, 0xbc, 0x80, 0x43, 0x29, 0x41, 0x46, 0x42, 0x4a, 0x4e, 0x46, 0x07, 0xd8, 0xad,
446            0x83, 0x7d, 0x6a, 0x65, 0x91, 0x42, 0x82, 0x2b, 0xba, 0xaa, 0xbc, 0x83, 0xbb, 0x57,
447            0xe7, 0x44, 0x74, 0x61,
448        ];
449
450        let pool_id = PoolIdentifier::from_pool_id_bytes(&bytes).unwrap();
451
452        assert!(pool_id.is_pool_id());
453        assert_eq!(
454            pool_id.to_string(),
455            "0xc9bc8043294146424a4e4607d8ad837d6a659142822bbaaabc83bb57e7447461"
456        );
457    }
458
459    #[rstest]
460    fn test_to_address() {
461        let id = PoolIdentifier::new("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
462        let address = id.to_address().unwrap();
463
464        assert_eq!(
465            address.to_string(),
466            "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
467        );
468    }
469
470    #[rstest]
471    fn test_to_address_fails_for_pool_id() {
472        let pool_id = PoolIdentifier::new(
473            "0xc9bc8043294146424a4e4607d8ad837d6a659142822bbaaabc83bb57e7447461",
474        );
475        let result = pool_id.to_address();
476
477        assert!(result.is_err());
478    }
479
480    #[rstest]
481    fn test_to_pool_id_bytes() {
482        let pool_id = PoolIdentifier::new(
483            "0xc9bc8043294146424a4e4607d8ad837d6a659142822bbaaabc83bb57e7447461",
484        );
485        let bytes = pool_id.to_pool_id_bytes().unwrap();
486
487        assert_eq!(bytes.len(), 32);
488        assert_eq!(bytes[0], 0xc9);
489        assert_eq!(bytes[31], 0x61);
490    }
491
492    #[rstest]
493    fn test_to_pool_id_bytes_fails_for_address() {
494        let address = PoolIdentifier::new("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
495        let result = address.to_pool_id_bytes();
496
497        assert!(result.is_err());
498    }
499
500    #[rstest]
501    fn test_conversion_roundtrip_address() {
502        let original_addr =
503            Address::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
504        let pool_id = PoolIdentifier::from_address(original_addr);
505        let converted_addr = pool_id.to_address().unwrap();
506
507        assert_eq!(original_addr, converted_addr);
508    }
509
510    #[rstest]
511    fn test_conversion_roundtrip_pool_id() {
512        let original_bytes: [u8; 32] = [
513            0xc9, 0xbc, 0x80, 0x43, 0x29, 0x41, 0x46, 0x42, 0x4a, 0x4e, 0x46, 0x07, 0xd8, 0xad,
514            0x83, 0x7d, 0x6a, 0x65, 0x91, 0x42, 0x82, 0x2b, 0xba, 0xaa, 0xbc, 0x83, 0xbb, 0x57,
515            0xe7, 0x44, 0x74, 0x61,
516        ];
517
518        let pool_id = PoolIdentifier::from_pool_id_bytes(&original_bytes).unwrap();
519        let converted_bytes = pool_id.to_pool_id_bytes().unwrap();
520
521        assert_eq!(original_bytes, converted_bytes);
522    }
523}