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 {
287 Token::new(
288 Arc::new(chains::ETHEREUM.clone()),
289 address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
290 format!("{symbol} Token"),
291 symbol.to_string(),
292 decimals,
293 )
294 }
295
296 #[rstest]
297 fn test_token_balance_as_quantity_18_decimals(#[from(arbitrum)] chain: SharedChain) {
298 let token = Token::new(
302 chain,
303 address!("0x4fE83213D56308330EC302a8BD641f1d0113A4Cc"),
304 "NuCypher".to_string(),
305 "NU".to_string(),
306 18,
307 );
308 let amount = U256::from(10342u64) * U256::from(10u64).pow(U256::from(18u64));
309 let balance = TokenBalance::new(amount, token);
310
311 let quantity = balance.as_quantity().unwrap();
312 assert_eq!(
313 quantity.as_decimal().to_string(),
314 "10342.000000000000000000"
315 );
316 }
317
318 #[rstest]
319 fn test_token_balance_as_quantity_6_decimals() {
320 let token = create_token("USDC", 6);
324 let amount = U256::from(92_220_728_254_u64);
325 let balance = TokenBalance::new(amount, token);
326
327 let quantity = balance.as_quantity().unwrap();
328 assert_eq!(quantity.as_decimal().to_string(), "92220.728254");
329 }
330
331 #[rstest]
332 fn test_token_balance_as_quantity_fractional_18_decimals(#[from(arbitrum)] chain: SharedChain) {
333 let token = Token::new(
337 chain,
338 address!("0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa"),
339 "mETH".to_string(),
340 "mETH".to_string(),
341 18,
342 );
343 let amount = U256::from(758_325_512_078_001_391_u64);
344 let balance = TokenBalance::new(amount, token);
345
346 let quantity = balance.as_quantity().unwrap();
347 assert_eq!(quantity.as_decimal().to_string(), "0.758325512078001391");
348 }
349
350 #[rstest]
351 fn test_token_balance_display_18_decimals(#[from(arbitrum)] chain: SharedChain) {
352 let token = Token::new(
354 chain,
355 address!("0x912CE59144191C1204E64559FE8253a0e49E6548"),
356 "Arbitrum".to_string(),
357 "ARB".to_string(),
358 18,
359 );
360 let amount = U256::from_str_radix("7922013795343949480329", 10).unwrap();
362 let balance = TokenBalance::new(amount, token);
363
364 let display = balance.to_string();
365 assert!(display.contains("ARB"));
366 assert!(display.contains("7922.013795343949480329"));
367 }
368
369 #[rstest]
370 fn test_token_balance_display_6_decimals() {
371 let token = create_token("USDC", 6);
373 let amount = U256::from(92_220_728_254_u64); let balance = TokenBalance::new(amount, token);
375
376 let display = balance.to_string();
377 assert!(display.contains("USDC"));
378 assert!(display.contains("92220.728254"));
379 }
380
381 #[rstest]
382 fn test_token_balance_set_amount_usd(weth: Token) {
383 let amount = U256::from(1u64) * U256::from(10u64).pow(U256::from(18u64));
384 let mut balance = TokenBalance::new(amount, weth);
385
386 assert!(balance.amount_usd.is_none());
387
388 let usd_value = Quantity::from("3500.00");
389 balance.set_amount_usd(usd_value);
390
391 assert!(balance.amount_usd.is_some());
392 assert_eq!(
393 balance.amount_usd.unwrap().as_decimal().to_string(),
394 "3500.00"
395 );
396 }
397
398 #[rstest]
399 fn test_wallet_balance_new_empty() {
400 let wallet = WalletBalance::new(HashSet::new());
401
402 assert!(wallet.native_currency.is_none());
403 assert!(wallet.token_balances.is_empty());
404 assert!(!wallet.is_token_universe_initialized());
405 }
406
407 #[rstest]
408 fn test_wallet_balance_with_token_universe() {
409 let mut tokens = HashSet::new();
410 tokens.insert(address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")); tokens.insert(address!("0x912CE59144191C1204E64559FE8253a0e49E6548")); let wallet = WalletBalance::new(tokens);
414
415 assert!(wallet.is_token_universe_initialized());
416 assert_eq!(wallet.token_universe.len(), 2);
417 }
418
419 #[rstest]
420 fn test_wallet_balance_set_native_currency() {
421 let mut wallet = WalletBalance::new(HashSet::new());
422
423 assert!(wallet.native_currency.is_none());
424
425 let eth_balance = Money::new(50.936_054, crate::types::Currency::ETH());
426 wallet.set_native_currency_balance(eth_balance);
427
428 assert!(wallet.native_currency.is_some());
429 }
430
431 #[rstest]
432 fn test_wallet_balance_add_token_balance(usdc: Token, weth: Token) {
433 let mut wallet = WalletBalance::new(HashSet::new());
434
435 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);
439 wallet.add_token_balance(weth_balance);
440
441 assert_eq!(wallet.token_balances.len(), 2);
442 assert_eq!(wallet.token_balances[0].token.symbol, "USDC");
443 assert_eq!(wallet.token_balances[1].token.symbol, "WETH");
444 }
445
446 #[rstest]
447 fn test_replace_balances_retains_snapshot_on_conversion_failure(weth: Token) {
448 let mut wallet = WalletBalance::new(HashSet::from([weth.address]));
449 let native_currency = weth.chain.native_currency();
450 let native = Money::from_wei(U256::from(1_000_000_000_000_000_000_u64), native_currency);
451 let token = TokenBalance::new(U256::from(2_000_000_000_000_000_000_u64), weth.clone());
452 wallet.replace_balances(native, vec![token]).unwrap();
453
454 let error = wallet
455 .replace_balances(
456 Money::from_wei(U256::from(3_000_000_000_000_000_000_u64), native_currency),
457 vec![TokenBalance::new(U256::MAX, weth)],
458 )
459 .unwrap_err();
460
461 assert!(
462 error.to_string().contains("exceeds QuantityRaw range"),
463 "was: {error}"
464 );
465 assert_eq!(wallet.native_currency.unwrap(), native);
466 assert_eq!(wallet.token_balances.len(), 1);
467 assert_eq!(
468 wallet.token_balances[0].amount,
469 U256::from(2_000_000_000_000_000_000_u64)
470 );
471 }
472
473 #[rstest]
474 fn test_account_balances_reports_missing_token_address(usdc: Token, weth: Token) {
475 let missing = weth.address;
476 let wallet = WalletBalance {
477 native_currency: None,
478 token_balances: vec![TokenBalance::new(U256::from(1_u64), usdc.clone())],
479 token_universe: HashSet::from([usdc.address, missing]),
480 };
481
482 let error = wallet.as_account_balances().unwrap_err();
483
484 assert_eq!(
485 error.to_string(),
486 format!("Wallet balance snapshot is missing configured token addresses: {missing}")
487 );
488 }
489
490 #[rstest]
491 fn test_account_balances_reports_unexpected_token_address(usdc: Token, weth: Token) {
492 let unexpected = weth.address;
493 let wallet = WalletBalance {
494 native_currency: None,
495 token_balances: vec![
496 TokenBalance::new(U256::from(1_u64), usdc.clone()),
497 TokenBalance::new(U256::from(2_u64), weth),
498 ],
499 token_universe: HashSet::from([usdc.address]),
500 };
501
502 let error = wallet.as_account_balances().unwrap_err();
503
504 assert_eq!(
505 error.to_string(),
506 format!("Wallet balance snapshot contains unexpected token addresses: {unexpected}")
507 );
508 }
509
510 #[rstest]
511 fn test_account_balances_reports_missing_and_unexpected_token_addresses(
512 usdc: Token,
513 weth: Token,
514 ) {
515 let missing = usdc.address;
516 let unexpected = weth.address;
517 let wallet = WalletBalance {
518 native_currency: None,
519 token_balances: vec![TokenBalance::new(U256::from(1_u64), weth)],
520 token_universe: HashSet::from([missing]),
521 };
522
523 let error = wallet.as_account_balances().unwrap_err();
524
525 assert_eq!(
526 error.to_string(),
527 format!(
528 "Wallet balance snapshot is missing configured token addresses: {missing}; contains unexpected token addresses: {unexpected}"
529 )
530 );
531 }
532
533 #[rstest]
534 fn test_account_balances_reports_duplicate_before_set_differences(usdc: Token, weth: Token) {
535 let duplicate = usdc.address;
536 let wallet = WalletBalance {
537 native_currency: None,
538 token_balances: vec![
539 TokenBalance::new(U256::from(1_u64), usdc.clone()),
540 TokenBalance::new(U256::from(2_u64), usdc),
541 ],
542 token_universe: HashSet::from([weth.address]),
543 };
544
545 let error = wallet.as_account_balances().unwrap_err();
546
547 assert_eq!(
548 error.to_string(),
549 format!("Wallet balance snapshot contains duplicate token addresses: {duplicate}")
550 );
551 }
552
553 #[rstest]
554 fn test_replace_balances_rejects_duplicate_currency_symbols(
555 usdc: Token,
556 #[from(arbitrum)] chain: SharedChain,
557 ) {
558 let duplicate = Token::new(
559 chain,
560 address!("0x0000000000000000000000000000000000000001"),
561 "Other USD Coin".to_string(),
562 "USDC".to_string(),
563 18,
564 );
565 let mut wallet = WalletBalance::new(HashSet::from([usdc.address, duplicate.address]));
566
567 let error = wallet
568 .replace_balances(
569 Money::from_wei(
570 U256::from(1_000_000_000_000_000_000_u64),
571 usdc.chain.native_currency(),
572 ),
573 vec![
574 TokenBalance::new(U256::from(1_u64), usdc),
575 TokenBalance::new(U256::from(2_u64), duplicate),
576 ],
577 )
578 .unwrap_err();
579
580 assert_eq!(
581 error.to_string(),
582 "Wallet balance snapshot contains duplicate currency USDC"
583 );
584 assert!(wallet.native_currency.is_none());
585 assert!(wallet.token_balances.is_empty());
586 }
587}