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}