1use std::collections::HashMap;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::ParseAmountError;
9use crate::types::NearToken;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct FtMetadata {
20 pub spec: String,
22
23 pub name: String,
25
26 pub symbol: String,
28
29 pub decimals: u8,
31
32 pub icon: Option<String>,
34
35 pub reference: Option<String>,
37
38 pub reference_hash: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct FtAmount {
84 raw: u128,
85 decimals: u8,
86 symbol: String,
87}
88
89impl FtAmount {
90 pub fn new(raw: u128, decimals: u8, symbol: impl Into<String>) -> Self {
92 Self {
93 raw,
94 decimals,
95 symbol: symbol.into(),
96 }
97 }
98
99 pub fn from_metadata(raw: u128, metadata: &FtMetadata) -> Self {
101 Self::new(raw, metadata.decimals, &metadata.symbol)
102 }
103
104 pub fn parse(
118 s: &str,
119 decimals: u8,
120 symbol: impl Into<String>,
121 ) -> Result<Self, ParseAmountError> {
122 let raw = parse_decimal_to_raw(s, decimals)?;
123 Ok(Self::new(raw, decimals, symbol))
124 }
125
126 pub fn raw(&self) -> u128 {
128 self.raw
129 }
130
131 pub fn decimals(&self) -> u8 {
133 self.decimals
134 }
135
136 pub fn symbol(&self) -> &str {
138 &self.symbol
139 }
140
141 pub fn is_zero(&self) -> bool {
143 self.raw == 0
144 }
145
146 pub fn format_amount(&self) -> String {
148 format_raw_with_decimals(self.raw, self.decimals)
149 }
150
151 pub fn checked_add(&self, other: &FtAmount) -> Option<FtAmount> {
153 if self.decimals != other.decimals || self.symbol != other.symbol {
154 return None;
155 }
156 self.raw.checked_add(other.raw).map(|raw| FtAmount {
157 raw,
158 decimals: self.decimals,
159 symbol: self.symbol.clone(),
160 })
161 }
162
163 pub fn checked_sub(&self, other: &FtAmount) -> Option<FtAmount> {
165 if self.decimals != other.decimals || self.symbol != other.symbol {
166 return None;
167 }
168 self.raw.checked_sub(other.raw).map(|raw| FtAmount {
169 raw,
170 decimals: self.decimals,
171 symbol: self.symbol.clone(),
172 })
173 }
174
175 pub fn checked_mul(&self, multiplier: u128) -> Option<FtAmount> {
177 self.raw.checked_mul(multiplier).map(|raw| FtAmount {
178 raw,
179 decimals: self.decimals,
180 symbol: self.symbol.clone(),
181 })
182 }
183
184 pub fn checked_div(&self, divisor: u128) -> Option<FtAmount> {
186 if divisor == 0 {
187 return None;
188 }
189 Some(FtAmount {
190 raw: self.raw / divisor,
191 decimals: self.decimals,
192 symbol: self.symbol.clone(),
193 })
194 }
195
196 pub fn saturating_add(&self, other: &FtAmount) -> Option<FtAmount> {
198 if self.decimals != other.decimals || self.symbol != other.symbol {
199 return None;
200 }
201 Some(FtAmount {
202 raw: self.raw.saturating_add(other.raw),
203 decimals: self.decimals,
204 symbol: self.symbol.clone(),
205 })
206 }
207
208 pub fn saturating_sub(&self, other: &FtAmount) -> Option<FtAmount> {
210 if self.decimals != other.decimals || self.symbol != other.symbol {
211 return None;
212 }
213 Some(FtAmount {
214 raw: self.raw.saturating_sub(other.raw),
215 decimals: self.decimals,
216 symbol: self.symbol.clone(),
217 })
218 }
219}
220
221impl fmt::Display for FtAmount {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 write!(f, "{} {}", self.format_amount(), self.symbol)
224 }
225}
226
227impl From<FtAmount> for u128 {
228 fn from(amount: FtAmount) -> u128 {
229 amount.raw
230 }
231}
232
233impl From<&FtAmount> for u128 {
234 fn from(amount: &FtAmount) -> u128 {
235 amount.raw
236 }
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct StorageBalanceBounds {
248 pub min: NearToken,
250
251 pub max: Option<NearToken>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct StorageBalance {
260 pub total: NearToken,
262
263 pub available: NearToken,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct NftContractMetadata {
276 pub spec: String,
278
279 pub name: String,
281
282 pub symbol: String,
284
285 pub icon: Option<String>,
287
288 pub base_uri: Option<String>,
290
291 pub reference: Option<String>,
293
294 pub reference_hash: Option<String>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct NftTokenMetadata {
303 pub title: Option<String>,
305
306 pub description: Option<String>,
308
309 pub media: Option<String>,
311
312 pub media_hash: Option<String>,
314
315 pub copies: Option<u64>,
317
318 pub issued_at: Option<String>,
320
321 pub expires_at: Option<String>,
323
324 pub starts_at: Option<String>,
326
327 pub updated_at: Option<String>,
329
330 pub extra: Option<String>,
332
333 pub reference: Option<String>,
335
336 pub reference_hash: Option<String>,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct NftToken {
345 pub token_id: String,
347
348 pub owner_id: String,
350
351 pub metadata: Option<NftTokenMetadata>,
353
354 pub approved_account_ids: Option<HashMap<String, u64>>,
356}
357
358fn format_raw_with_decimals(raw: u128, decimals: u8) -> String {
364 if decimals == 0 {
365 return raw.to_string();
366 }
367
368 let divisor = 10u128.pow(decimals as u32);
369 let whole = raw / divisor;
370 let frac = raw % divisor;
371
372 if frac == 0 {
373 whole.to_string()
374 } else {
375 let frac_str = format!("{:0>width$}", frac, width = decimals as usize);
377 let trimmed = frac_str.trim_end_matches('0');
378 format!("{}.{}", whole, trimmed)
379 }
380}
381
382fn parse_decimal_to_raw(s: &str, decimals: u8) -> Result<u128, ParseAmountError> {
384 let s = s.trim();
385
386 if s.is_empty() {
387 return Err(ParseAmountError::InvalidFormat(s.to_string()));
388 }
389
390 let parts: Vec<&str> = s.split('.').collect();
391
392 match parts.len() {
393 1 => {
394 let whole: u128 = parts[0]
396 .parse()
397 .map_err(|_| ParseAmountError::InvalidNumber(s.to_string()))?;
398 whole
399 .checked_mul(10u128.pow(decimals as u32))
400 .ok_or(ParseAmountError::Overflow)
401 }
402 2 => {
403 let whole: u128 = if parts[0].is_empty() {
405 0
406 } else {
407 parts[0]
408 .parse()
409 .map_err(|_| ParseAmountError::InvalidNumber(s.to_string()))?
410 };
411
412 let frac_str = parts[1];
413 if frac_str.len() > decimals as usize {
414 return Err(ParseAmountError::InvalidFormat(format!(
415 "Too many decimal places: {} has {} but max is {}",
416 s,
417 frac_str.len(),
418 decimals
419 )));
420 }
421
422 let padded = format!("{:0<width$}", frac_str, width = decimals as usize);
424 let frac: u128 = padded
425 .parse()
426 .map_err(|_| ParseAmountError::InvalidNumber(s.to_string()))?;
427
428 let whole_shifted = whole
429 .checked_mul(10u128.pow(decimals as u32))
430 .ok_or(ParseAmountError::Overflow)?;
431
432 whole_shifted
433 .checked_add(frac)
434 .ok_or(ParseAmountError::Overflow)
435 }
436 _ => Err(ParseAmountError::InvalidFormat(format!(
437 "Multiple decimal points in: {}",
438 s
439 ))),
440 }
441}
442
443#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
454 fn test_ft_amount_display_whole_number() {
455 let amount = FtAmount::new(1_000_000, 6, "USDC");
456 assert_eq!(format!("{}", amount), "1 USDC");
457 }
458
459 #[test]
460 fn test_ft_amount_display_with_decimals() {
461 let amount = FtAmount::new(1_500_000, 6, "USDC");
462 assert_eq!(format!("{}", amount), "1.5 USDC");
463 }
464
465 #[test]
466 fn test_ft_amount_display_small_decimals() {
467 let amount = FtAmount::new(1_000_001, 6, "USDC");
468 assert_eq!(format!("{}", amount), "1.000001 USDC");
469 }
470
471 #[test]
472 fn test_ft_amount_display_trailing_zeros_trimmed() {
473 let amount = FtAmount::new(1_100_000, 6, "USDC");
474 assert_eq!(format!("{}", amount), "1.1 USDC");
475 }
476
477 #[test]
478 fn test_ft_amount_display_zero() {
479 let amount = FtAmount::new(0, 6, "USDC");
480 assert_eq!(format!("{}", amount), "0 USDC");
481 }
482
483 #[test]
484 fn test_ft_amount_display_24_decimals() {
485 let amount = FtAmount::new(1_000_000_000_000_000_000_000_000, 24, "wNEAR");
486 assert_eq!(format!("{}", amount), "1 wNEAR");
487 }
488
489 #[test]
490 fn test_ft_amount_display_24_decimals_fractional() {
491 let amount = FtAmount::new(1_500_000_000_000_000_000_000_000, 24, "wNEAR");
492 assert_eq!(format!("{}", amount), "1.5 wNEAR");
493 }
494
495 #[test]
496 fn test_ft_amount_display_zero_decimals() {
497 let amount = FtAmount::new(42, 0, "TOKEN");
498 assert_eq!(format!("{}", amount), "42 TOKEN");
499 }
500
501 #[test]
502 fn test_ft_amount_display_fractional_only() {
503 let amount = FtAmount::new(500_000, 6, "USDC");
504 assert_eq!(format!("{}", amount), "0.5 USDC");
505 }
506
507 #[test]
510 fn test_ft_amount_parse_whole_number() {
511 let amount = FtAmount::parse("100", 6, "USDC").unwrap();
512 assert_eq!(amount.raw(), 100_000_000);
513 }
514
515 #[test]
516 fn test_ft_amount_parse_with_decimals() {
517 let amount = FtAmount::parse("1.5", 6, "USDC").unwrap();
518 assert_eq!(amount.raw(), 1_500_000);
519 }
520
521 #[test]
522 fn test_ft_amount_parse_max_decimals() {
523 let amount = FtAmount::parse("1.123456", 6, "USDC").unwrap();
524 assert_eq!(amount.raw(), 1_123_456);
525 }
526
527 #[test]
528 fn test_ft_amount_parse_fewer_decimals() {
529 let amount = FtAmount::parse("1.1", 6, "USDC").unwrap();
530 assert_eq!(amount.raw(), 1_100_000);
531 }
532
533 #[test]
534 fn test_ft_amount_parse_fractional_only() {
535 let amount = FtAmount::parse("0.5", 6, "USDC").unwrap();
536 assert_eq!(amount.raw(), 500_000);
537 }
538
539 #[test]
540 fn test_ft_amount_parse_leading_decimal() {
541 let amount = FtAmount::parse(".5", 6, "USDC").unwrap();
542 assert_eq!(amount.raw(), 500_000);
543 }
544
545 #[test]
546 fn test_ft_amount_parse_too_many_decimals() {
547 let result = FtAmount::parse("1.1234567", 6, "USDC");
548 assert!(result.is_err());
549 }
550
551 #[test]
552 fn test_ft_amount_parse_invalid() {
553 assert!(FtAmount::parse("abc", 6, "USDC").is_err());
554 assert!(FtAmount::parse("1.2.3", 6, "USDC").is_err());
555 assert!(FtAmount::parse("", 6, "USDC").is_err());
556 }
557
558 #[test]
561 fn test_ft_amount_checked_add_same_token() {
562 let a = FtAmount::new(1_000_000, 6, "USDC");
563 let b = FtAmount::new(500_000, 6, "USDC");
564 let sum = a.checked_add(&b).unwrap();
565 assert_eq!(sum.raw(), 1_500_000);
566 assert_eq!(sum.symbol(), "USDC");
567 }
568
569 #[test]
570 fn test_ft_amount_checked_add_different_symbol() {
571 let a = FtAmount::new(1_000_000, 6, "USDC");
572 let b = FtAmount::new(500_000, 6, "USDT");
573 assert!(a.checked_add(&b).is_none());
574 }
575
576 #[test]
577 fn test_ft_amount_checked_add_different_decimals() {
578 let a = FtAmount::new(1_000_000, 6, "TOKEN");
579 let b = FtAmount::new(500_000, 8, "TOKEN");
580 assert!(a.checked_add(&b).is_none());
581 }
582
583 #[test]
584 fn test_ft_amount_checked_add_overflow() {
585 let a = FtAmount::new(u128::MAX, 6, "USDC");
586 let b = FtAmount::new(1, 6, "USDC");
587 assert!(a.checked_add(&b).is_none());
588 }
589
590 #[test]
591 fn test_ft_amount_checked_sub() {
592 let a = FtAmount::new(1_000_000, 6, "USDC");
593 let b = FtAmount::new(400_000, 6, "USDC");
594 let diff = a.checked_sub(&b).unwrap();
595 assert_eq!(diff.raw(), 600_000);
596 }
597
598 #[test]
599 fn test_ft_amount_checked_sub_underflow() {
600 let a = FtAmount::new(400_000, 6, "USDC");
601 let b = FtAmount::new(1_000_000, 6, "USDC");
602 assert!(a.checked_sub(&b).is_none());
603 }
604
605 #[test]
606 fn test_ft_amount_checked_mul() {
607 let a = FtAmount::new(1_000_000, 6, "USDC");
608 let result = a.checked_mul(3).unwrap();
609 assert_eq!(result.raw(), 3_000_000);
610 }
611
612 #[test]
613 fn test_ft_amount_checked_div() {
614 let a = FtAmount::new(3_000_000, 6, "USDC");
615 let result = a.checked_div(3).unwrap();
616 assert_eq!(result.raw(), 1_000_000);
617 }
618
619 #[test]
620 fn test_ft_amount_checked_div_by_zero() {
621 let a = FtAmount::new(1_000_000, 6, "USDC");
622 assert!(a.checked_div(0).is_none());
623 }
624
625 #[test]
626 fn test_ft_amount_saturating_add() {
627 let a = FtAmount::new(u128::MAX - 1, 6, "USDC");
628 let b = FtAmount::new(10, 6, "USDC");
629 let sum = a.saturating_add(&b).unwrap();
630 assert_eq!(sum.raw(), u128::MAX);
631 }
632
633 #[test]
634 fn test_ft_amount_saturating_sub() {
635 let a = FtAmount::new(100, 6, "USDC");
636 let b = FtAmount::new(200, 6, "USDC");
637 let diff = a.saturating_sub(&b).unwrap();
638 assert_eq!(diff.raw(), 0);
639 }
640
641 #[test]
644 fn test_ft_amount_accessors() {
645 let amount = FtAmount::new(1_500_000, 6, "USDC");
646 assert_eq!(amount.raw(), 1_500_000);
647 assert_eq!(amount.decimals(), 6);
648 assert_eq!(amount.symbol(), "USDC");
649 assert!(!amount.is_zero());
650 }
651
652 #[test]
653 fn test_ft_amount_is_zero() {
654 let zero = FtAmount::new(0, 6, "USDC");
655 assert!(zero.is_zero());
656 }
657
658 #[test]
659 fn test_ft_amount_into_u128() {
660 let amount = FtAmount::new(1_500_000, 6, "USDC");
661 let raw: u128 = amount.into();
662 assert_eq!(raw, 1_500_000);
663 }
664
665 #[test]
666 fn test_ft_amount_from_metadata() {
667 let metadata = FtMetadata {
668 spec: "ft-1.0.0".to_string(),
669 name: "USD Coin".to_string(),
670 symbol: "USDC".to_string(),
671 decimals: 6,
672 icon: None,
673 reference: None,
674 reference_hash: None,
675 };
676 let amount = FtAmount::from_metadata(1_500_000, &metadata);
677 assert_eq!(format!("{}", amount), "1.5 USDC");
678 }
679
680 #[test]
683 fn test_format_amount_without_symbol() {
684 let amount = FtAmount::new(1_500_000, 6, "USDC");
685 assert_eq!(amount.format_amount(), "1.5");
686 }
687}