Skip to main content

near_kit/tokens/
types.rs

1//! Types for token operations.
2
3use std::collections::HashMap;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::ParseAmountError;
9use crate::types::NearToken;
10
11// =============================================================================
12// Fungible Token Types (NEP-141)
13// =============================================================================
14
15/// NEP-141 Fungible Token metadata.
16///
17/// This is returned by the `ft_metadata` view function on NEP-141 contracts.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct FtMetadata {
20    /// Standard specification version (e.g., "ft-1.0.0")
21    pub spec: String,
22
23    /// Human-readable token name (e.g., "USD Coin")
24    pub name: String,
25
26    /// Token symbol (e.g., "USDC")
27    pub symbol: String,
28
29    /// Number of decimal places for display (e.g., 6 for USDC, 24 for wNEAR)
30    pub decimals: u8,
31
32    /// Optional icon as a data URI (base64 SVG or image)
33    pub icon: Option<String>,
34
35    /// Optional URL to off-chain JSON metadata
36    pub reference: Option<String>,
37
38    /// Optional base64-encoded SHA-256 hash of the reference content
39    pub reference_hash: Option<String>,
40}
41
42/// A fungible token amount with baked-in decimals and symbol for display.
43///
44/// `FtAmount` wraps a raw `u128` token amount along with the token's decimals
45/// and symbol, allowing for human-readable formatting.
46///
47/// # Display
48///
49/// The `Display` implementation formats the amount with the correct number
50/// of decimal places and includes the symbol:
51///
52/// ```
53/// use near_kit::FtAmount;
54///
55/// let amount = FtAmount::new(1_500_000, 6, "USDC");
56/// assert_eq!(format!("{}", amount), "1.5 USDC");
57///
58/// let amount = FtAmount::new(1_000_000_000_000_000_000_000_000, 24, "wNEAR");
59/// assert_eq!(format!("{}", amount), "1 wNEAR");
60/// ```
61///
62/// # Arithmetic
63///
64/// Arithmetic operations are supported but only between amounts of the same
65/// token (same decimals AND symbol). Operations return `Option` to indicate
66/// success or failure.
67///
68/// ```
69/// use near_kit::FtAmount;
70///
71/// let a = FtAmount::new(1_000_000, 6, "USDC");
72/// let b = FtAmount::new(500_000, 6, "USDC");
73///
74/// // Same token - works
75/// let sum = a.checked_add(&b).unwrap();
76/// assert_eq!(sum.raw(), 1_500_000);
77///
78/// // Different token - fails
79/// let c = FtAmount::new(1_000_000, 6, "USDT");
80/// assert!(a.checked_add(&c).is_none());
81/// ```
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct FtAmount {
84    raw: u128,
85    decimals: u8,
86    symbol: String,
87}
88
89impl FtAmount {
90    /// Create a new FtAmount from raw value with explicit decimals and symbol.
91    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    /// Create from raw value using token metadata.
100    pub fn from_metadata(raw: u128, metadata: &FtMetadata) -> Self {
101        Self::new(raw, metadata.decimals, &metadata.symbol)
102    }
103
104    /// Parse a human-readable decimal string like "1.5" into a raw amount.
105    ///
106    /// # Example
107    ///
108    /// ```
109    /// use near_kit::FtAmount;
110    ///
111    /// let amount = FtAmount::parse("1.5", 6, "USDC").unwrap();
112    /// assert_eq!(amount.raw(), 1_500_000);
113    ///
114    /// let amount = FtAmount::parse("100", 6, "USDC").unwrap();
115    /// assert_eq!(amount.raw(), 100_000_000);
116    /// ```
117    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    /// Get the raw token amount (in smallest units).
127    pub fn raw(&self) -> u128 {
128        self.raw
129    }
130
131    /// Get the number of decimal places.
132    pub fn decimals(&self) -> u8 {
133        self.decimals
134    }
135
136    /// Get the token symbol.
137    pub fn symbol(&self) -> &str {
138        &self.symbol
139    }
140
141    /// Check if this amount is zero.
142    pub fn is_zero(&self) -> bool {
143        self.raw == 0
144    }
145
146    /// Format as a string without the symbol.
147    pub fn format_amount(&self) -> String {
148        format_raw_with_decimals(self.raw, self.decimals)
149    }
150
151    /// Checked addition - returns None if tokens don't match or overflow.
152    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    /// Checked subtraction - returns None if tokens don't match or underflow.
164    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    /// Checked multiplication by a scalar.
176    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    /// Checked division by a scalar.
185    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    /// Saturating addition - clamps at max on overflow, returns None on token mismatch.
197    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    /// Saturating subtraction - clamps at 0 on underflow, returns None on token mismatch.
209    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// =============================================================================
240// Storage Types (NEP-145)
241// =============================================================================
242
243/// Storage balance bounds for a contract.
244///
245/// Returned by `storage_balance_bounds` view function.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct StorageBalanceBounds {
248    /// Minimum required storage deposit.
249    pub min: NearToken,
250
251    /// Maximum storage deposit (if limited).
252    pub max: Option<NearToken>,
253}
254
255/// Storage balance for an account on a contract.
256///
257/// Returned by `storage_balance_of` view function.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct StorageBalance {
260    /// Total storage deposit.
261    pub total: NearToken,
262
263    /// Available for withdrawal.
264    pub available: NearToken,
265}
266
267// =============================================================================
268// Non-Fungible Token Types (NEP-171/177)
269// =============================================================================
270
271/// NEP-177 NFT Contract metadata.
272///
273/// This is returned by the `nft_metadata` view function on NEP-171 contracts.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct NftContractMetadata {
276    /// Standard specification version (e.g., "nft-1.0.0")
277    pub spec: String,
278
279    /// Contract name (e.g., "Example NFT Collection")
280    pub name: String,
281
282    /// Contract symbol (e.g., "EXAMPLE")
283    pub symbol: String,
284
285    /// Optional icon as a data URI
286    pub icon: Option<String>,
287
288    /// Optional base URI for token metadata references
289    pub base_uri: Option<String>,
290
291    /// Optional URL to off-chain JSON metadata
292    pub reference: Option<String>,
293
294    /// Optional base64-encoded SHA-256 hash of the reference content
295    pub reference_hash: Option<String>,
296}
297
298/// NEP-177 Token metadata.
299///
300/// Metadata for an individual NFT token.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct NftTokenMetadata {
303    /// Token title
304    pub title: Option<String>,
305
306    /// Token description
307    pub description: Option<String>,
308
309    /// URL to media file
310    pub media: Option<String>,
311
312    /// Base64-encoded SHA-256 hash of the media content
313    pub media_hash: Option<String>,
314
315    /// Number of copies (for limited editions)
316    pub copies: Option<u64>,
317
318    /// ISO 8601 datetime when issued
319    pub issued_at: Option<String>,
320
321    /// ISO 8601 datetime when expires
322    pub expires_at: Option<String>,
323
324    /// ISO 8601 datetime when starts being valid
325    pub starts_at: Option<String>,
326
327    /// ISO 8601 datetime when last updated
328    pub updated_at: Option<String>,
329
330    /// Extra arbitrary data (JSON string)
331    pub extra: Option<String>,
332
333    /// URL to off-chain JSON metadata
334    pub reference: Option<String>,
335
336    /// Base64-encoded SHA-256 hash of the reference content
337    pub reference_hash: Option<String>,
338}
339
340/// NEP-171 Token.
341///
342/// A single non-fungible token.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct NftToken {
345    /// Unique token identifier within this contract
346    pub token_id: String,
347
348    /// Current owner of the token
349    pub owner_id: String,
350
351    /// Optional token metadata
352    pub metadata: Option<NftTokenMetadata>,
353
354    /// Optional approved accounts with their approval IDs (NEP-178)
355    pub approved_account_ids: Option<HashMap<String, u64>>,
356}
357
358// =============================================================================
359// Helper Functions
360// =============================================================================
361
362/// Format a raw amount with the given number of decimals.
363fn 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        // Format with leading zeros, then trim trailing zeros
376        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
382/// Parse a decimal string to a raw amount.
383fn 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            // No decimal point - multiply by 10^decimals
395            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            // Has decimal point
404            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            // Pad fractional part with zeros
423            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// =============================================================================
444// Tests
445// =============================================================================
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    // ─── FtAmount Display Tests ───
452
453    #[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    // ─── FtAmount Parsing Tests ───
508
509    #[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    // ─── FtAmount Arithmetic Tests ───
559
560    #[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    // ─── FtAmount Accessors Tests ───
642
643    #[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    // ─── Format Amount Tests ───
681
682    #[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}