Skip to main content

openpit/param/
account_id.rs

1// Copyright The Pit Project Owners. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16// Please see https://github.com/openpitkit and the OWNERS file for details.
17
18use std::fmt::{Display, Formatter};
19
20/// Type-safe account identifier.
21///
22/// Optimized for speed and costs.
23///
24/// Two constructors are provided; choose based on how your venue assigns IDs:
25///
26/// - [`AccountId::from_u64`]: zero cost, zero collision risk. Prefer this
27///   whenever the broker or venue assigns numeric account IDs.
28/// - [`AccountId::from_str`]: convenience constructor that hashes a string
29///   with FNV-1a 64-bit. Collisions are theoretically possible; see
30///   [`AccountId::from_str`] for the collision probability table.
31///
32/// WARNING:
33/// Use exactly one constructor family per runtime state:
34/// - either only [`AccountId::from_u64`],
35/// - or only [`AccountId::from_str`].
36///
37/// Mixing both families can collapse distinct accounts into one key when a
38/// hashed string equals a direct numeric ID.
39///
40/// # Examples
41///
42/// ```
43/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
44/// use openpit::param::AccountId;
45/// use std::collections::HashMap;
46///
47/// let numeric = AccountId::from_u64(99224416);
48/// let string  = AccountId::from_str("my-account")?;
49///
50/// let mut map: HashMap<AccountId, &str> = HashMap::new();
51/// map.insert(numeric, "numeric account");
52/// map.insert(string,  "string account");
53///
54/// assert_eq!(map[&AccountId::from_u64(99224416)], "numeric account");
55/// assert_eq!(map[&AccountId::from_str("my-account")?], "string account");
56/// # Ok(())
57/// # }
58/// ```
59#[repr(transparent)]
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub struct AccountId(u64);
63
64/// Errors returned by [`AccountId`] constructors.
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66#[non_exhaustive]
67pub enum AccountIdError {
68    /// Account identifier string is empty.
69    Empty,
70}
71
72impl Display for AccountIdError {
73    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
74        match self {
75            Self::Empty => formatter.write_str("account id string must not be empty"),
76        }
77    }
78}
79
80impl std::error::Error for AccountIdError {}
81
82impl From<AccountIdError> for crate::param::Error {
83    fn from(error: AccountIdError) -> Self {
84        match error {
85            AccountIdError::Empty => Self::AccountIdEmpty,
86        }
87    }
88}
89
90impl AccountId {
91    /// Constructs an account identifier from a 64-bit integer.
92    ///
93    /// No hashing, no allocation, no collision risk.
94    /// Prefer this constructor whenever the broker or venue assigns
95    /// numeric account IDs.
96    ///
97    /// WARNING:
98    /// Do not mix IDs created with this function and IDs created with
99    /// [`AccountId::from_str`] in the same runtime state.
100    pub fn from_u64(value: u64) -> Self {
101        Self(value)
102    }
103
104    /// Constructs an account identifier by hashing a string with FNV-1a 64-bit.
105    ///
106    /// Note: this method is intentionally *not* an implementation of
107    /// [`std::str::FromStr`] — the caller must consciously choose `from_str`
108    /// and read its collision warning. Implicit `From<&str>` / `From<String>`
109    /// conversions are not provided for the same reason.
110    ///
111    /// See <http://www.isthe.com/chongo/tech/comp/fnv/> for the algorithm
112    /// specification.
113    ///
114    /// Hash collisions are possible. By the birthday paradox, the probability
115    /// of at least one collision among `n` distinct string identifiers in a
116    /// 64-bit hash space is approximately `n² / (2 × 2^64)`:
117    ///
118    /// | Accounts  | P(at least one collision) |
119    /// | --------- | ------------------------- |
120    /// | 1 000     | < 3 × 10⁻¹⁴               |
121    /// | 10 000    | < 3 × 10⁻¹²               |
122    /// | 100 000   | < 3 × 10⁻¹⁰               |
123    /// | 1 000 000 | < 3 × 10⁻⁸                |
124    ///
125    /// If collision risk is unacceptable for your use case, maintain your own
126    /// collision-free string→u64 mapping (e.g. a registry or a database
127    /// sequence) and pass the resulting integer to [`AccountId::from_u64`].
128    ///
129    /// WARNING:
130    /// Do not mix IDs created with this function and IDs created with
131    /// [`AccountId::from_u64`] in the same runtime state.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
137    /// use openpit::param::AccountId;
138    /// use std::collections::HashMap;
139    ///
140    /// let id = AccountId::from_str("trading-account-1")?;
141    ///
142    /// let mut map: HashMap<AccountId, i32> = HashMap::new();
143    /// map.insert(id, 42);
144    ///
145    /// assert_eq!(map[&AccountId::from_str("trading-account-1")?], 42);
146    /// # Ok(())
147    /// # }
148    /// ```
149    #[allow(clippy::should_implement_trait)]
150    pub fn from_str(value: impl AsRef<str>) -> Result<Self, AccountIdError> {
151        let value = value.as_ref();
152        if value.trim().is_empty() {
153            return Err(AccountIdError::Empty);
154        }
155        Ok(Self(fnv1a_64(value)))
156    }
157
158    /// Returns the raw 64-bit integer value.
159    pub fn as_u64(self) -> u64 {
160        self.0
161    }
162}
163
164impl From<u64> for AccountId {
165    fn from(value: u64) -> Self {
166        Self::from_u64(value)
167    }
168}
169
170impl Display for AccountId {
171    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
172        Display::fmt(&self.0, formatter)
173    }
174}
175
176fn fnv1a_64(s: &str) -> u64 {
177    const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
178    const PRIME: u64 = 1_099_511_628_211;
179
180    let mut hash = OFFSET_BASIS;
181    for byte in s.bytes() {
182        hash ^= u64::from(byte);
183        hash = hash.wrapping_mul(PRIME);
184    }
185    hash
186}
187
188#[cfg(test)]
189mod tests {
190    use std::collections::HashMap;
191
192    use super::{AccountId, AccountIdError};
193
194    #[test]
195    fn from_u64_display_shows_integer() {
196        assert_eq!(AccountId::from_u64(99224416).to_string(), "99224416");
197        assert_eq!(AccountId::from_u64(42).to_string(), "42");
198        assert_eq!(
199            AccountId::from_u64(u64::MAX).to_string(),
200            u64::MAX.to_string()
201        );
202    }
203
204    #[test]
205    fn from_u64_equality() {
206        assert_eq!(AccountId::from_u64(7), AccountId::from_u64(7));
207        assert_ne!(AccountId::from_u64(7), AccountId::from_u64(8));
208    }
209
210    #[test]
211    fn from_str_same_string_equal() {
212        assert_eq!(
213            AccountId::from_str("account-a"),
214            AccountId::from_str("account-a")
215        );
216    }
217
218    #[test]
219    fn from_str_different_strings_not_equal() {
220        assert_ne!(
221            AccountId::from_str("account-a"),
222            AccountId::from_str("account-b")
223        );
224    }
225
226    // from_u64 and from_str of the same numeric string are NOT required to be
227    // equal: from_u64 stores the integer directly while from_str hashes the
228    // UTF-8 bytes of the decimal representation.
229    #[test]
230    fn from_u64_and_from_str_of_same_numeric_string_differ() {
231        assert_ne!(
232            AccountId::from_u64(42),
233            AccountId::from_str("42").expect("account id must be valid")
234        );
235    }
236
237    #[test]
238    fn hashmap_lookup_with_from_u64() {
239        let mut map: HashMap<AccountId, &str> = HashMap::new();
240        map.insert(AccountId::from_u64(100), "alpha");
241
242        assert_eq!(map[&AccountId::from_u64(100)], "alpha");
243    }
244
245    #[test]
246    fn hashmap_lookup_with_from_str() {
247        let mut map: HashMap<AccountId, &str> = HashMap::new();
248        map.insert(
249            AccountId::from_str("beta").expect("account id must be valid"),
250            "beta-value",
251        );
252
253        assert_eq!(
254            map[&AccountId::from_str("beta").expect("account id must be valid")],
255            "beta-value"
256        );
257    }
258
259    #[test]
260    fn from_str_rejects_empty_or_whitespace() {
261        assert_eq!(AccountId::from_str(""), Err(AccountIdError::Empty));
262        assert_eq!(AccountId::from_str("   "), Err(AccountIdError::Empty));
263    }
264
265    #[test]
266    fn account_id_error_display_is_stable() {
267        assert_eq!(
268            AccountIdError::Empty.to_string(),
269            "account id string must not be empty"
270        );
271    }
272
273    #[test]
274    fn as_u64_returns_inner_value() {
275        assert_eq!(AccountId::from_u64(99).as_u64(), 99);
276        assert_eq!(AccountId::from_u64(99224416).as_u64(), 99224416);
277        assert_eq!(AccountId::from_u64(u64::MAX).as_u64(), u64::MAX);
278    }
279
280    #[test]
281    fn from_u64_trait_delegates_to_constructor() {
282        let via_trait: AccountId = AccountId::from(42u64);
283        let via_constructor = AccountId::from_u64(42);
284        assert_eq!(via_trait, via_constructor);
285    }
286}