1use std::{collections::HashSet, fmt::Display};
17
18use alloy_primitives::{Address, U256};
19
20use crate::{
21 defi::Token,
22 enums::CurrencyType,
23 types::{AccountBalance, Currency, Money, Quantity},
24};
25
26#[derive(Debug)]
31pub struct TokenBalance {
32 pub amount: U256,
34 pub amount_usd: Option<Quantity>,
36 pub token: Token,
38}
39
40impl TokenBalance {
41 #[must_use]
43 pub const fn new(amount: U256, token: Token) -> Self {
44 Self {
45 amount,
46 token,
47 amount_usd: None,
48 }
49 }
50
51 pub fn as_quantity(&self) -> anyhow::Result<Quantity> {
57 Quantity::from_u256(self.amount, self.token.decimals).map_err(Into::into)
58 }
59
60 fn as_money(&self) -> anyhow::Result<Money> {
61 let currency = Currency::new_checked(
62 &self.token.symbol,
63 self.token.decimals,
64 0,
65 &self.token.name,
66 CurrencyType::Crypto,
67 )?;
68 Money::from_u256(self.amount, currency).map_err(Into::into)
69 }
70
71 pub fn set_amount_usd(&mut self, amount_usd: Quantity) {
73 self.amount_usd = Some(amount_usd);
74 }
75}
76
77impl Display for TokenBalance {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 let quantity = self.as_quantity().unwrap_or_default();
80
81 match &self.amount_usd {
82 Some(usd) => write!(
83 f,
84 "TokenBalance(token={}, amount={}, usd=${:.2})",
85 self.token.symbol,
86 quantity.as_decimal(),
87 usd.as_f64()
88 ),
89 None => write!(
90 f,
91 "TokenBalance(token={}, amount={})",
92 self.token.symbol,
93 quantity.as_decimal()
94 ),
95 }
96 }
97}
98
99#[derive(Debug)]
105pub struct WalletBalance {
106 pub native_currency: Option<Money>,
108 pub token_balances: Vec<TokenBalance>,
110 pub token_universe: HashSet<Address>,
112}
113
114impl WalletBalance {
115 #[must_use]
117 pub const fn new(token_universe: HashSet<Address>) -> Self {
118 Self {
119 native_currency: None,
120 token_balances: vec![],
121 token_universe,
122 }
123 }
124
125 #[must_use]
127 pub fn is_token_universe_initialized(&self) -> bool {
128 !self.token_universe.is_empty()
129 }
130
131 pub fn set_native_currency_balance(&mut self, balance: Money) {
133 self.native_currency = Some(balance);
134 }
135
136 pub fn add_token_balance(&mut self, token_balance: TokenBalance) {
138 self.token_balances.push(token_balance);
139 }
140
141 pub fn replace_balances(
149 &mut self,
150 native_currency: Money,
151 mut token_balances: Vec<TokenBalance>,
152 ) -> anyhow::Result<Vec<AccountBalance>> {
153 token_balances.sort_unstable_by_key(|balance| balance.token.address);
154 let replacement = Self {
155 native_currency: Some(native_currency),
156 token_balances,
157 token_universe: self.token_universe.clone(),
158 };
159 let balances = replacement.account_balances(replacement.token_balances.iter())?;
160 *self = replacement;
161 Ok(balances)
162 }
163
164 pub fn as_account_balances(&self) -> anyhow::Result<Vec<AccountBalance>> {
171 let mut token_balances = self.token_balances.iter().collect::<Vec<_>>();
172 token_balances.sort_unstable_by_key(|balance| balance.token.address);
173 self.account_balances(token_balances)
174 }
175
176 fn account_balances<'a>(
177 &'a self,
178 token_balances: impl IntoIterator<Item = &'a TokenBalance>,
179 ) -> anyhow::Result<Vec<AccountBalance>> {
180 self.validate_token_addresses()?;
181
182 let native_currency = self
183 .native_currency
184 .ok_or_else(|| anyhow::anyhow!("Wallet balance snapshot has no native currency"))?;
185 let mut currencies = HashSet::new();
186 currencies.insert(native_currency.currency);
187
188 let mut balances = Vec::with_capacity(self.token_balances.len() + 1);
189 balances.push(AccountBalance::new_checked(
190 native_currency,
191 Money::zero(native_currency.currency),
192 native_currency,
193 )?);
194
195 for token_balance in token_balances {
196 let total = token_balance.as_money()?;
197 if !currencies.insert(total.currency) {
198 anyhow::bail!(
199 "Wallet balance snapshot contains duplicate currency {}",
200 total.currency
201 );
202 }
203 balances.push(AccountBalance::new_checked(
204 total,
205 Money::zero(total.currency),
206 total,
207 )?);
208 }
209
210 Ok(balances)
211 }
212
213 fn validate_token_addresses(&self) -> anyhow::Result<()> {
214 let mut token_addresses = HashSet::with_capacity(self.token_balances.len());
215 let mut duplicates = Vec::new();
216
217 for balance in &self.token_balances {
218 if !token_addresses.insert(balance.token.address) {
219 duplicates.push(balance.token.address);
220 }
221 }
222
223 if !duplicates.is_empty() {
224 duplicates.sort_unstable();
225 duplicates.dedup();
226 anyhow::bail!(
227 "Wallet balance snapshot contains duplicate token addresses: {}",
228 format_addresses(&duplicates)
229 );
230 }
231
232 let mut missing = self
233 .token_universe
234 .difference(&token_addresses)
235 .copied()
236 .collect::<Vec<_>>();
237 let mut unexpected = token_addresses
238 .difference(&self.token_universe)
239 .copied()
240 .collect::<Vec<_>>();
241 missing.sort_unstable();
242 unexpected.sort_unstable();
243
244 match (missing.is_empty(), unexpected.is_empty()) {
245 (false, false) => anyhow::bail!(
246 "Wallet balance snapshot is missing configured token addresses: {}; contains unexpected token addresses: {}",
247 format_addresses(&missing),
248 format_addresses(&unexpected)
249 ),
250 (false, true) => anyhow::bail!(
251 "Wallet balance snapshot is missing configured token addresses: {}",
252 format_addresses(&missing)
253 ),
254 (true, false) => anyhow::bail!(
255 "Wallet balance snapshot contains unexpected token addresses: {}",
256 format_addresses(&unexpected)
257 ),
258 (true, true) => Ok(()),
259 }
260 }
261}
262
263fn format_addresses(addresses: &[Address]) -> String {
264 addresses
265 .iter()
266 .map(ToString::to_string)
267 .collect::<Vec<_>>()
268 .join(", ")
269}
270
271#[cfg(test)]
272mod tests {
273 use std::sync::Arc;
274
275 use alloy_primitives::{U256, address};
276 use rstest::rstest;
277
278 use super::*;
279 use crate::defi::{
280 SharedChain, Token,
281 chain::chains,
282 stubs::{arbitrum, usdc, weth},
283 };
284
285 fn create_token(symbol: &str, decimals: u8) -> Token {
286 Token::new(
287 Arc::new(chains::ETHEREUM.clone()),
288 address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
289 format!("{symbol} Token"),
290 symbol.to_string(),
291 decimals,
292 )
293 }
294
295 #[rstest]
296 fn test_token_balance_as_quantity_18_decimals(#[from(arbitrum)] chain: SharedChain) {
297 let token = Token::new(
301 chain,
302 address!("0x4fE83213D56308330EC302a8BD641f1d0113A4Cc"),
303 "NuCypher".to_string(),
304 "NU".to_string(),
305 18,
306 );
307 let amount = U256::from(10342u64) * U256::from(10u64).pow(U256::from(18u64));
308 let balance = TokenBalance::new(amount, token);
309
310 let quantity = balance.as_quantity().unwrap();
311 assert_eq!(
312 quantity.as_decimal().to_string(),
313 "10342.000000000000000000"
314 );
315 }
316
317 #[rstest]
318 fn test_token_balance_as_quantity_6_decimals() {
319 let token = create_token("USDC", 6);
323 let amount = U256::from(92_220_728_254_u64);
324 let balance = TokenBalance::new(amount, token);
325
326 let quantity = balance.as_quantity().unwrap();
327 assert_eq!(quantity.as_decimal().to_string(), "92220.728254");
328 }
329
330 #[rstest]
331 fn test_token_balance_as_quantity_fractional_18_decimals(#[from(arbitrum)] chain: SharedChain) {
332 let token = Token::new(
336 chain,
337 address!("0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa"),
338 "mETH".to_string(),
339 "mETH".to_string(),
340 18,
341 );
342 let amount = U256::from(758_325_512_078_001_391_u64);
343 let balance = TokenBalance::new(amount, token);
344
345 let quantity = balance.as_quantity().unwrap();
346 assert_eq!(quantity.as_decimal().to_string(), "0.758325512078001391");
347 }
348
349 #[rstest]
350 fn test_token_balance_display_18_decimals(#[from(arbitrum)] chain: SharedChain) {
351 let token = Token::new(
353 chain,
354 address!("0x912CE59144191C1204E64559FE8253a0e49E6548"),
355 "Arbitrum".to_string(),
356 "ARB".to_string(),
357 18,
358 );
359 let amount = U256::from_str_radix("7922013795343949480329", 10).unwrap();
361 let balance = TokenBalance::new(amount, token);
362
363 let display = balance.to_string();
364 assert!(display.contains("ARB"));
365 assert!(display.contains("7922.013795343949480329"));
366 }
367
368 #[rstest]
369 fn test_token_balance_display_6_decimals() {
370 let token = create_token("USDC", 6);
372 let amount = U256::from(92_220_728_254_u64); let balance = TokenBalance::new(amount, token);
374
375 let display = balance.to_string();
376 assert!(display.contains("USDC"));
377 assert!(display.contains("92220.728254"));
378 }
379
380 #[rstest]
381 fn test_token_balance_set_amount_usd(weth: Token) {
382 let amount = U256::from(1u64) * U256::from(10u64).pow(U256::from(18u64));
383 let mut balance = TokenBalance::new(amount, weth);
384
385 assert!(balance.amount_usd.is_none());
386
387 let usd_value = Quantity::from("3500.00");
388 balance.set_amount_usd(usd_value);
389
390 assert!(balance.amount_usd.is_some());
391 assert_eq!(
392 balance.amount_usd.unwrap().as_decimal().to_string(),
393 "3500.00"
394 );
395 }
396
397 #[rstest]
398 fn test_wallet_balance_new_empty() {
399 let wallet = WalletBalance::new(HashSet::new());
400
401 assert!(wallet.native_currency.is_none());
402 assert!(wallet.token_balances.is_empty());
403 assert!(!wallet.is_token_universe_initialized());
404 }
405
406 #[rstest]
407 fn test_wallet_balance_with_token_universe() {
408 let mut tokens = HashSet::new();
409 tokens.insert(address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")); tokens.insert(address!("0x912CE59144191C1204E64559FE8253a0e49E6548")); let wallet = WalletBalance::new(tokens);
413
414 assert!(wallet.is_token_universe_initialized());
415 assert_eq!(wallet.token_universe.len(), 2);
416 }
417
418 #[rstest]
419 fn test_wallet_balance_set_native_currency() {
420 let mut wallet = WalletBalance::new(HashSet::new());
421
422 assert!(wallet.native_currency.is_none());
423
424 let eth_balance = Money::new(50.936_054, crate::types::Currency::ETH());
425 wallet.set_native_currency_balance(eth_balance);
426
427 assert!(wallet.native_currency.is_some());
428 }
429
430 #[rstest]
431 fn test_wallet_balance_add_token_balance(usdc: Token, weth: Token) {
432 let mut wallet = WalletBalance::new(HashSet::new());
433
434 let usdc_balance = TokenBalance::new(U256::from(100_000_000u64), usdc); let weth_balance = TokenBalance::new(U256::from(10u64).pow(U256::from(18u64)), weth); wallet.add_token_balance(usdc_balance);
438 wallet.add_token_balance(weth_balance);
439
440 assert_eq!(wallet.token_balances.len(), 2);
441 assert_eq!(wallet.token_balances[0].token.symbol, "USDC");
442 assert_eq!(wallet.token_balances[1].token.symbol, "WETH");
443 }
444
445 #[rstest]
446 fn test_replace_balances_retains_snapshot_on_conversion_failure(weth: Token) {
447 let mut wallet = WalletBalance::new(HashSet::from([weth.address]));
448 let native_currency = weth.chain.native_currency();
449 let native = Money::from_wei(U256::from(1_000_000_000_000_000_000_u64), native_currency);
450 let token = TokenBalance::new(U256::from(2_000_000_000_000_000_000_u64), weth.clone());
451 wallet.replace_balances(native, vec![token]).unwrap();
452
453 let error = wallet
454 .replace_balances(
455 Money::from_wei(U256::from(3_000_000_000_000_000_000_u64), native_currency),
456 vec![TokenBalance::new(U256::MAX, weth)],
457 )
458 .unwrap_err();
459
460 assert!(
461 error.to_string().contains("exceeds QuantityRaw range"),
462 "was: {error}"
463 );
464 assert_eq!(wallet.native_currency.unwrap(), native);
465 assert_eq!(wallet.token_balances.len(), 1);
466 assert_eq!(
467 wallet.token_balances[0].amount,
468 U256::from(2_000_000_000_000_000_000_u64)
469 );
470 }
471
472 #[rstest]
473 fn test_account_balances_reports_missing_token_address(usdc: Token, weth: Token) {
474 let missing = weth.address;
475 let wallet = WalletBalance {
476 native_currency: None,
477 token_balances: vec![TokenBalance::new(U256::from(1_u64), usdc.clone())],
478 token_universe: HashSet::from([usdc.address, missing]),
479 };
480
481 let error = wallet.as_account_balances().unwrap_err();
482
483 assert_eq!(
484 error.to_string(),
485 format!("Wallet balance snapshot is missing configured token addresses: {missing}")
486 );
487 }
488
489 #[rstest]
490 fn test_account_balances_reports_unexpected_token_address(usdc: Token, weth: Token) {
491 let unexpected = weth.address;
492 let wallet = WalletBalance {
493 native_currency: None,
494 token_balances: vec![
495 TokenBalance::new(U256::from(1_u64), usdc.clone()),
496 TokenBalance::new(U256::from(2_u64), weth),
497 ],
498 token_universe: HashSet::from([usdc.address]),
499 };
500
501 let error = wallet.as_account_balances().unwrap_err();
502
503 assert_eq!(
504 error.to_string(),
505 format!("Wallet balance snapshot contains unexpected token addresses: {unexpected}")
506 );
507 }
508
509 #[rstest]
510 fn test_account_balances_reports_missing_and_unexpected_token_addresses(
511 usdc: Token,
512 weth: Token,
513 ) {
514 let missing = usdc.address;
515 let unexpected = weth.address;
516 let wallet = WalletBalance {
517 native_currency: None,
518 token_balances: vec![TokenBalance::new(U256::from(1_u64), weth)],
519 token_universe: HashSet::from([missing]),
520 };
521
522 let error = wallet.as_account_balances().unwrap_err();
523
524 assert_eq!(
525 error.to_string(),
526 format!(
527 "Wallet balance snapshot is missing configured token addresses: {missing}; contains unexpected token addresses: {unexpected}"
528 )
529 );
530 }
531
532 #[rstest]
533 fn test_account_balances_reports_duplicate_before_set_differences(usdc: Token, weth: Token) {
534 let duplicate = usdc.address;
535 let wallet = WalletBalance {
536 native_currency: None,
537 token_balances: vec![
538 TokenBalance::new(U256::from(1_u64), usdc.clone()),
539 TokenBalance::new(U256::from(2_u64), usdc),
540 ],
541 token_universe: HashSet::from([weth.address]),
542 };
543
544 let error = wallet.as_account_balances().unwrap_err();
545
546 assert_eq!(
547 error.to_string(),
548 format!("Wallet balance snapshot contains duplicate token addresses: {duplicate}")
549 );
550 }
551
552 #[rstest]
553 fn test_replace_balances_rejects_duplicate_currency_symbols(
554 usdc: Token,
555 #[from(arbitrum)] chain: SharedChain,
556 ) {
557 let duplicate = Token::new(
558 chain,
559 address!("0x0000000000000000000000000000000000000001"),
560 "Other USD Coin".to_string(),
561 "USDC".to_string(),
562 18,
563 );
564 let mut wallet = WalletBalance::new(HashSet::from([usdc.address, duplicate.address]));
565
566 let error = wallet
567 .replace_balances(
568 Money::from_wei(
569 U256::from(1_000_000_000_000_000_000_u64),
570 usdc.chain.native_currency(),
571 ),
572 vec![
573 TokenBalance::new(U256::from(1_u64), usdc),
574 TokenBalance::new(U256::from(2_u64), duplicate),
575 ],
576 )
577 .unwrap_err();
578
579 assert_eq!(
580 error.to_string(),
581 "Wallet balance snapshot contains duplicate currency USDC"
582 );
583 assert!(wallet.native_currency.is_none());
584 assert!(wallet.token_balances.is_empty());
585 }
586}