zescrow_core/
asset.rs

1#[cfg(feature = "json")]
2use std::str::FromStr;
3
4use bincode::{Decode, Encode};
5use num_bigint::BigUint;
6use num_integer::Integer;
7#[cfg(feature = "json")]
8use serde::{Deserialize, Serialize};
9#[cfg(feature = "json")]
10use serde_json;
11
12use crate::error::AssetError;
13#[cfg(feature = "json")]
14use crate::EscrowError;
15use crate::{BigNumber, Result, ID};
16
17/// Represents an on-chain asset.
18#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
19#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))]
20#[derive(Debug, Clone, Encode, Decode)]
21pub struct Asset {
22    /// Kind of asset.
23    pub kind: AssetKind,
24
25    /// Unique identity of the asset on-chain.
26    pub id: Option<ID>,
27
28    /// Associated on-chain program ID or contract address of the asset.
29    pub agent_id: Option<ID>,
30
31    /// Amount in the smallest unit (e.g., wei, lamports).
32    pub amount: BigNumber,
33
34    /// Number of decimals the asset uses.
35    pub decimals: Option<u8>,
36
37    /// Total supply of the asset in circulation.
38    pub total_supply: Option<BigNumber>,
39}
40
41/// Different kinds of assets we might escrow on any chain.
42#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
43#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))]
44#[derive(Debug, Clone, Encode, Decode)]
45pub enum AssetKind {
46    /// Native chain coin (e.g., ETH, SOL).
47    Native,
48    /// Fungible token (e.g., ERC-20, SPL).
49    Token,
50    /// Non-fungible token (e.g., ERC-721, SPL NFT)
51    Nft,
52    /// Multi-token (e.g., ERC-1155) with fractional ownership via `amount`.
53    MultiToken,
54    /// Liquidity pool share (proportional ownership).
55    LpShare,
56}
57
58impl Asset {
59    /// Create a native asset.
60    pub fn native(amount: BigNumber) -> Self {
61        Self {
62            kind: AssetKind::Native,
63            id: None,
64            agent_id: None,
65            amount,
66            decimals: None,
67            total_supply: None,
68        }
69    }
70
71    /// Create a fungible token asset.
72    pub fn token(contract: ID, amount: BigNumber, total_supply: BigNumber, decimals: u8) -> Self {
73        Self {
74            kind: AssetKind::Token,
75            id: None,
76            agent_id: Some(contract),
77            amount,
78            decimals: Some(decimals),
79            total_supply: Some(total_supply),
80        }
81    }
82
83    /// Create an NFT asset.
84    pub fn nft(contract: ID, token_id: ID) -> Self {
85        Self {
86            kind: AssetKind::Nft,
87            id: Some(token_id),
88            agent_id: Some(contract),
89            amount: BigNumber::from(1u64),
90            decimals: None,
91            total_supply: None,
92        }
93    }
94
95    /// Create a multi-token asset.
96    pub fn multi_token(contract: ID, token_id: ID, amount: BigNumber) -> Self {
97        Self {
98            kind: AssetKind::MultiToken,
99            id: Some(token_id),
100            agent_id: Some(contract),
101            amount,
102            decimals: None,
103            total_supply: None,
104        }
105    }
106
107    /// Create a liquidity pool share asset.
108    pub fn pool_share(
109        pool_id: ID,
110        share: BigNumber,
111        total_supply: BigNumber,
112        decimals: u8,
113    ) -> Self {
114        Self {
115            kind: AssetKind::LpShare,
116            id: Some(pool_id),
117            agent_id: None,
118            amount: share,
119            decimals: Some(decimals),
120            total_supply: Some(total_supply),
121        }
122    }
123
124    /// Ensure asset parameters are semantically valid.
125    ///
126    /// - **Native**: `amount` must be > 0.
127    /// - **Token**: `amount` must be > 0, `contract` must be valid `ID`.
128    /// - **MultiToken**: `amount` must be > 0, `contract` must be valid `ID`, `token_id` cannot be empty.
129    /// - **Nft**: `contract` must be valid `ID`, `token_id` cannot be empty.
130    /// - **PoolShare**: `share` must be > 0, `total_supply` must be > 0, and `share` <= `total_supply`.
131    pub fn validate(&self) -> Result<()> {
132        if self.amount == BigNumber::zero() {
133            return Err(AssetError::ZeroAmount.into());
134        }
135
136        match self.kind {
137            AssetKind::Native => Ok(()),
138
139            AssetKind::Token => self
140                .agent_id
141                .as_ref()
142                .ok_or(AssetError::MissingId)?
143                .validate(),
144
145            AssetKind::Nft | AssetKind::MultiToken => {
146                self.agent_id
147                    .as_ref()
148                    .ok_or(AssetError::MissingId)?
149                    .validate()?;
150
151                self.id.as_ref().ok_or(AssetError::MissingId)?.validate()
152            }
153
154            AssetKind::LpShare => {
155                let pool = self.id.as_ref().ok_or(AssetError::MissingId)?;
156                pool.validate()?;
157
158                let total_supply = self
159                    .total_supply
160                    .as_ref()
161                    .ok_or(AssetError::MissingTotalSupply)?;
162                if *total_supply == BigNumber::zero() {
163                    return Err(AssetError::ZeroAmount.into());
164                }
165                if self.amount > *total_supply {
166                    return Err(AssetError::InvalidShare(
167                        self.amount.clone(),
168                        total_supply.clone(),
169                    )
170                    .into());
171                }
172
173                Ok(())
174            }
175        }
176    }
177
178    /// Attempt to serialize self into a Bincode‐encoded byte vector.
179    ///
180    /// Uses the standard Bincode configuration to produce a compact,
181    /// little‐endian binary representation.
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// # use num_bigint::BigUint;
187    /// # use zescrow_core::Asset;
188    ///
189    /// let asset = Asset::native(BigUint::from(1_000u64).into());
190    /// let bytes = asset.to_bytes().expect("serialize");
191    /// assert!(!bytes.is_empty());
192    /// ```
193    pub fn to_bytes(&self) -> Result<Vec<u8>> {
194        bincode::encode_to_vec(self, bincode::config::standard())
195            .map_err(|e| AssetError::Serialization(e.to_string()).into())
196    }
197
198    /// Attempt to decode Self from a Bincode‐encoded byte slice `src`.
199    ///
200    /// Expects `src` to match the format produced by [`Self::to_bytes`].
201    ///
202    /// # Examples
203    ///
204    /// ```
205    /// # use zescrow_core::{Asset, BigNumber};
206    /// # use zescrow_core::identity::ID;
207    ///
208    /// let amount = BigNumber::from(1_000u64);
209    /// let supply = BigNumber::from(2_000u64);
210    ///
211    /// let original = Asset::token(ID::from(vec![4, 5, 6]), amount, supply, 18);
212    /// let bytes = original.to_bytes().unwrap();
213    /// let decoded = Asset::from_bytes(&bytes).unwrap();
214    /// assert_eq!(format!("{:?}", decoded), format!("{:?}", original));
215    /// ```
216    pub fn from_bytes(src: &[u8]) -> Result<Self> {
217        bincode::decode_from_slice(src, bincode::config::standard())
218            .map_err(|e| AssetError::Parsing(e.to_string()).into())
219            .map(|(asset, _)| asset)
220    }
221
222    /// Format raw `BigNumber` with fixed‐point decimals.
223    pub fn format_amount(&self) -> Result<String> {
224        let decimals = self.decimals.unwrap_or(0);
225        let factor = BigUint::from(10u8).pow(decimals as u32);
226        let (whole, rem) = self.amount.0.div_rem(&factor);
227
228        let rem_str = if decimals > 0 {
229            let s = rem.to_str_radix(10);
230            format!("{:0>width$}", s, width = decimals as usize)
231        } else {
232            String::new()
233        };
234
235        Ok(if decimals > 0 {
236            format!("{}.{}", whole.to_str_radix(10), rem_str)
237        } else {
238            whole.to_str_radix(10)
239        })
240    }
241
242    /// Returns the underlying raw quantity for this asset.
243    pub fn amount(&self) -> &BigNumber {
244        &self.amount
245    }
246}
247
248#[cfg(feature = "json")]
249impl std::fmt::Display for Asset {
250    /// Compact representation for logging.
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        let json = serde_json::to_string(self).map_err(|_| std::fmt::Error)?;
253        write!(f, "{json}")
254    }
255}
256
257#[cfg(feature = "json")]
258impl FromStr for Asset {
259    type Err = EscrowError;
260
261    /// Parse an `Asset` from its JSON representation.
262    fn from_str(s: &str) -> Result<Self> {
263        serde_json::from_str::<Self>(s).map_err(|e| AssetError::Parsing(e.to_string()).into())
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::{BigNumber, ID};
271
272    fn to_bignum(num: u64) -> BigNumber {
273        BigNumber::from(num)
274    }
275
276    #[test]
277    fn native() {
278        let coin = Asset::native(to_bignum(1));
279        assert!(coin.validate().is_ok());
280
281        let zero_coin = Asset::native(to_bignum(0));
282        assert!(zero_coin.validate().is_err());
283    }
284
285    #[test]
286    fn token() {
287        // supply and amount = 1000, decimals = 9
288        let token = Asset::token(ID::from(vec![4, 5, 6]), to_bignum(1000), to_bignum(1000), 9);
289        assert!(token.validate().is_ok());
290
291        // empty program ID
292        let empty_agent_id = Asset::token(ID::from(Vec::new()), to_bignum(100), to_bignum(100), 9);
293        assert!(empty_agent_id.validate().is_err());
294
295        // zero amount
296        let zero_token = Asset::token(ID::from(vec![1, 2, 3]), to_bignum(0), to_bignum(100), 6);
297        assert!(zero_token.validate().is_err());
298    }
299
300    #[test]
301    fn nft() {
302        // valid NFT: contract and token_id both non-empty
303        let nft = Asset::nft(ID::from(vec![7, 8, 9]), ID::from("zescrowNFT".as_bytes()));
304        assert!(nft.validate().is_ok());
305
306        // empty token ID
307        let empty_token_id = Asset::nft(ID::from(vec![7, 8, 9]), ID::from(Vec::new()));
308        assert!(empty_token_id.validate().is_err());
309
310        // empty contract ID
311        let empty_contract_id = Asset::nft(ID::from(Vec::new()), ID::from("zescrowNFT".as_bytes()));
312        assert!(empty_contract_id.validate().is_err());
313    }
314
315    #[test]
316    fn multi_token() {
317        // valid multi-token
318        let asset = Asset::multi_token(
319            ID::from(vec![1]),
320            ID::from("zescrowToken".as_bytes()),
321            to_bignum(500),
322        );
323        assert!(asset.validate().is_ok());
324
325        // zero amount
326        let zero_amt = Asset::multi_token(
327            ID::from(vec![1]),
328            ID::from("zescrowToken".as_bytes()),
329            to_bignum(0),
330        );
331        assert!(zero_amt.validate().is_err());
332
333        // empty token ID
334        let bad_id = Asset::multi_token(ID::from(vec![1]), ID::from(Vec::new()), to_bignum(10));
335        assert!(bad_id.validate().is_err());
336
337        // empty contract ID
338        let bad_contract = Asset::multi_token(
339            ID::from(Vec::new()),
340            ID::from("zescrowToken".as_bytes()),
341            to_bignum(10),
342        );
343        assert!(bad_contract.validate().is_err());
344    }
345
346    #[test]
347    fn pool_share() {
348        // valid pool share
349        let share = to_bignum(50);
350        let total = to_bignum(100);
351        let valid = Asset::pool_share(ID::from(vec![1]), share.clone(), total.clone(), 0);
352        assert!(valid.validate().is_ok());
353
354        // zero share
355        let zero_share = Asset::pool_share(ID::from(vec![1]), to_bignum(0), total.clone(), 0);
356        assert!(zero_share.validate().is_err());
357
358        // zero total supply
359        let zero_total = Asset::pool_share(ID::from(vec![1]), share.clone(), to_bignum(0), 0);
360        assert!(zero_total.validate().is_err());
361
362        // empty pool ID
363        let bad_pool = Asset::pool_share(ID::from(Vec::new()), share.clone(), total.clone(), 0);
364        assert!(bad_pool.validate().is_err());
365
366        // share exceeds total supply
367        let too_many = Asset::pool_share(ID::from(vec![1]), to_bignum(150), to_bignum(100), 0);
368        assert!(too_many.validate().is_err());
369    }
370}