iota_genesis_builder/stardust/native_token/
package_data.rs

1// Copyright (c) 2024 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! The `package_data` module provides the [`NativeTokenPackageData`] struct,
5//! which encapsulates all the data necessary to build a Stardust native token
6//! package.
7
8use anyhow::Result;
9use iota_sdk::types::block::{
10    address::AliasAddress,
11    output::{FoundryId, FoundryOutput},
12};
13use iota_types::stardust::error::StardustError;
14use move_compiler::parser::keywords;
15use rand::distributions::{Alphanumeric, DistString};
16use rand_pcg::Pcg64;
17use rand_seeder::{Seeder, SipRng, rand_core::RngCore};
18use regex::Regex;
19use serde::{Deserialize, Serialize};
20
21use crate::stardust::types::token_scheme::SimpleTokenSchemeU64;
22
23/// The [`NativeTokenPackageData`] struct encapsulates all the data necessary to
24/// build a Stardust native token package.
25#[derive(Debug)]
26pub struct NativeTokenPackageData {
27    package_name: String,
28    module: NativeTokenModuleData,
29}
30
31impl NativeTokenPackageData {
32    /// Creates a new [`NativeTokenPackageData`] instance.
33    pub fn new(package_name: impl Into<String>, module: NativeTokenModuleData) -> Self {
34        Self {
35            package_name: package_name.into(),
36            module,
37        }
38    }
39
40    /// Returns the Move.toml manifest.
41    pub fn package_name(&self) -> &String {
42        &self.package_name
43    }
44
45    /// Returns the native token module data.
46    pub fn module(&self) -> &NativeTokenModuleData {
47        &self.module
48    }
49}
50
51/// The [`NativeTokenModuleData`] struct encapsulates all the data necessary to
52/// build a Stardust native token module.
53#[derive(Debug)]
54pub struct NativeTokenModuleData {
55    pub foundry_id: FoundryId,
56    pub module_name: String,
57    pub otw_name: String,
58    pub decimals: u8,
59    /// This must be a valid ASCII string.
60    pub symbol: String,
61    pub circulating_supply: u64,
62    pub maximum_supply: u64,
63    /// This must be a valid UTF-8 string.
64    pub coin_name: String,
65    /// This must be a valid UTF-8 string.
66    pub coin_description: String,
67    pub icon_url: Option<String>,
68    pub alias_address: AliasAddress,
69}
70
71impl NativeTokenModuleData {
72    /// Creates a new [`NativeTokenModuleData`] instance.
73    pub fn new(
74        foundry_id: FoundryId,
75        module_name: impl Into<String>,
76        otw_name: impl Into<String>,
77        decimals: u8,
78        symbol: impl Into<String>,
79        circulating_supply: u64,
80        maximum_supply: u64,
81        coin_name: impl Into<String>,
82        coin_description: impl Into<String>,
83        icon_url: Option<String>,
84        alias_address: AliasAddress,
85    ) -> Self {
86        Self {
87            foundry_id,
88            module_name: module_name.into(),
89            otw_name: otw_name.into(),
90            decimals,
91            symbol: symbol.into(),
92            circulating_supply,
93            maximum_supply,
94            coin_name: coin_name.into(),
95            coin_description: coin_description.into(),
96            icon_url,
97            alias_address,
98        }
99    }
100}
101
102impl TryFrom<&FoundryOutput> for NativeTokenPackageData {
103    type Error = StardustError;
104    fn try_from(output: &FoundryOutput) -> Result<Self, StardustError> {
105        let irc_30_metadata = extract_irc30_metadata(output);
106
107        // Derive a valid, lowercase move identifier from the symbol field in the irc30
108        // metadata
109        let identifier = derive_foundry_package_lowercase_identifier(
110            irc_30_metadata.symbol.as_str(),
111            output.id().as_slice(),
112        );
113
114        // Any decimal value that exceeds a u8 is set to zero, as we cannot infer a good
115        // alternative.
116        let decimals = u8::try_from(irc_30_metadata.decimals).unwrap_or_default();
117
118        let token_scheme_u64: SimpleTokenSchemeU64 =
119            output.token_scheme().as_simple().try_into()?;
120
121        let native_token_data = NativeTokenPackageData {
122            package_name: identifier.clone(),
123            module: NativeTokenModuleData {
124                foundry_id: output.id(),
125                module_name: identifier.clone(),
126                otw_name: identifier.clone().to_ascii_uppercase(),
127                decimals,
128                symbol: identifier,
129                circulating_supply: token_scheme_u64.circulating_supply(),
130                maximum_supply: token_scheme_u64.maximum_supply(),
131                coin_name: irc_30_metadata.name,
132                coin_description: irc_30_metadata.description.unwrap_or_default(),
133                icon_url: irc_30_metadata.logo_url,
134                alias_address: *output.alias_address(),
135            },
136        };
137
138        Ok(native_token_data)
139    }
140}
141
142#[derive(Clone, Debug, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144#[serde(tag = "standard", rename = "IRC30")]
145pub struct Irc30MetadataAlternative {
146    /// The human-readable name of the native token.
147    name: String,
148    /// The symbol/ticker of the token.
149    symbol: String,
150    /// Number of decimals the token uses (divide the token amount by
151    /// `10^decimals` to get its user representation).
152    decimals: u32,
153    /// The human-readable description of the token.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    description: Option<String>,
156    /// URL pointing to more resources about the token.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    url: Option<String>,
159    /// URL pointing to an image resource of the token logo.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    logo_url: Option<String>,
162    /// The svg logo of the token encoded as a byte string.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    logo: Option<String>,
165}
166
167impl Irc30MetadataAlternative {
168    fn new_compact(name: String) -> Self {
169        Irc30MetadataAlternative {
170            name: name.clone(),
171            symbol: name,
172            decimals: 0,
173            description: None,
174            url: None,
175            logo_url: None,
176            logo: None,
177        }
178    }
179}
180
181fn extract_irc30_metadata(output: &FoundryOutput) -> Irc30MetadataAlternative {
182    output
183        .immutable_features()
184        .metadata()
185        .and_then(|metadata| {
186            serde_json::from_slice::<Irc30MetadataAlternative>(metadata.data()).ok()
187        })
188        .and_then(|metadata| {
189            metadata
190                .logo_url
191                .as_ref()
192                .map(|url| url.is_ascii())
193                .unwrap_or(true)
194                .then_some(metadata)
195        })
196        .unwrap_or_else(|| {
197            Irc30MetadataAlternative::new_compact(derive_foundry_package_lowercase_identifier(
198                "",
199                output.id().as_slice(),
200            ))
201        })
202}
203
204fn derive_foundry_package_lowercase_identifier(input: &str, seed: &[u8]) -> String {
205    let input = input.to_ascii_lowercase();
206
207    static VALID_IDENTIFIER_PATTERN: &str = r"[a-z][a-z0-9_]*";
208
209    // Define a regex pattern to capture the valid parts of the identifier
210    let valid_parts_re =
211        Regex::new(VALID_IDENTIFIER_PATTERN).expect("should be valid regex pattern");
212    let valid_parts: Vec<&str> = valid_parts_re
213        .find_iter(&input)
214        .map(|mat| mat.as_str())
215        .collect();
216    let concatenated = valid_parts.concat();
217
218    // Ensure no trailing underscore at the end of the identifier
219    let refined_identifier = concatenated.trim_end_matches('_').to_string();
220    let is_valid = move_core_types::identifier::is_valid(&refined_identifier);
221
222    if is_valid
223        && !keywords::KEYWORDS.contains(&refined_identifier.as_str())
224        && !keywords::CONTEXTUAL_KEYWORDS.contains(&refined_identifier.as_str())
225        && !keywords::PRIMITIVE_TYPES.contains(&refined_identifier.as_str())
226        && !keywords::BUILTINS.contains(&refined_identifier.as_str())
227    {
228        refined_identifier
229    } else {
230        let mut final_identifier = String::from("foundry_");
231        let additional_part = if is_valid {
232            refined_identifier
233        } else {
234            let mut rng: SipRng = Seeder::from(seed).make_rng();
235            fn next_u128(rng: &mut SipRng) -> u128 {
236                ((rng.next_u64() as u128) << 64) | rng.next_u64() as u128
237            }
238            // Generate a new valid random identifier if the identifier is empty.
239            Alphanumeric
240                .sample_string(&mut Pcg64::new(next_u128(&mut rng), next_u128(&mut rng)), 7)
241                .to_lowercase()
242        };
243        final_identifier.push_str(&additional_part);
244        final_identifier
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use std::ops::{Add, Sub};
251
252    use iota_sdk::{
253        U256, Url,
254        types::block::{
255            address::AliasAddress,
256            output::{
257                AliasId, Feature, FoundryOutputBuilder, SimpleTokenScheme, TokenScheme,
258                feature::{Irc30Metadata, MetadataFeature},
259                unlock_condition::ImmutableAliasAddressUnlockCondition,
260            },
261        },
262    };
263
264    use super::*;
265    use crate::stardust::{
266        native_token::package_builder, types::token_scheme::MAX_ALLOWED_U64_SUPPLY,
267    };
268
269    #[test]
270    fn foundry_output_with_default_metadata() -> Result<()> {
271        // Step 1: Create a FoundryOutput with an IRC30Metadata feature
272        let token_scheme = SimpleTokenScheme::new(
273            U256::from(100_000_000),
274            U256::from(0),
275            U256::from(100_000_000),
276        )
277        .unwrap();
278
279        let irc_30_metadata = Irc30Metadata::new("Dogecoin", "DOGEā¤", 0);
280
281        let alias_id = AliasId::new([0; AliasId::LENGTH]);
282        let builder = FoundryOutputBuilder::new_with_amount(
283            100_000_000_000,
284            1,
285            TokenScheme::Simple(token_scheme),
286        )
287        .add_unlock_condition(ImmutableAliasAddressUnlockCondition::new(
288            AliasAddress::new(alias_id),
289        ))
290        .add_feature(Feature::Metadata(
291            MetadataFeature::new(irc_30_metadata).unwrap(),
292        ));
293        let output = builder.finish().unwrap();
294
295        // Step 2: Convert the FoundryOutput to NativeTokenPackageData
296        let native_token_data = NativeTokenPackageData::try_from(&output)?;
297
298        // Step 3: Verify the conversion
299        assert!(package_builder::build_and_compile(native_token_data).is_ok());
300
301        Ok(())
302    }
303
304    #[test]
305    fn foundry_output_with_additional_metadata() -> Result<()> {
306        // Step 1: Create a FoundryOutput with an IRC30Metadata feature
307        let token_scheme = SimpleTokenScheme::new(
308            U256::from(100_000_000),
309            U256::from(0),
310            U256::from(100_000_000),
311        )
312        .unwrap();
313
314        let irc_30_metadata = Irc30Metadata::new("Dogecoin", "DOGE", 0)
315            .with_description("Much wow")
316            .with_url(Url::parse("https://dogecoin.com").unwrap())
317            .with_logo_url(Url::parse("https://dogecoin.com/logo.png").unwrap())
318            .with_logo("0x54654");
319
320        let alias_id = AliasId::new([0; AliasId::LENGTH]);
321        let builder = FoundryOutputBuilder::new_with_amount(
322            100_000_000_000,
323            1,
324            TokenScheme::Simple(token_scheme),
325        )
326        .add_unlock_condition(ImmutableAliasAddressUnlockCondition::new(
327            AliasAddress::new(alias_id),
328        ))
329        .add_feature(Feature::Metadata(
330            MetadataFeature::new(irc_30_metadata).unwrap(),
331        ));
332        let output = builder.finish().unwrap();
333
334        // Step 2: Convert the FoundryOutput to NativeTokenPackageData
335        let native_token_data = NativeTokenPackageData::try_from(&output)?;
336
337        // Step 3: Verify the conversion
338        assert!(package_builder::build_and_compile(native_token_data).is_ok());
339
340        Ok(())
341    }
342
343    #[test]
344    fn foundry_output_with_exceeding_max_supply() -> Result<()> {
345        let minted_tokens = U256::from(MAX_ALLOWED_U64_SUPPLY).add(1);
346        let melted_tokens = U256::from(1);
347        let maximum_supply = U256::MAX;
348
349        // Step 1: Create a FoundryOutput with an IRC30Metadata feature
350        let token_scheme =
351            SimpleTokenScheme::new(minted_tokens, melted_tokens, maximum_supply).unwrap();
352
353        let irc_30_metadata = Irc30Metadata::new("Dogecoin", "DOGE", 0)
354            .with_description("Much wow")
355            .with_url(Url::parse("https://dogecoin.com").unwrap())
356            .with_logo_url(Url::parse("https://dogecoin.com/logo.png").unwrap())
357            .with_logo("0x54654");
358
359        let alias_id = AliasId::new([0; AliasId::LENGTH]);
360        let builder = FoundryOutputBuilder::new_with_amount(
361            100_000_000_000,
362            1,
363            TokenScheme::Simple(token_scheme),
364        )
365        .add_unlock_condition(ImmutableAliasAddressUnlockCondition::new(
366            AliasAddress::new(alias_id),
367        ))
368        .add_feature(Feature::Metadata(
369            MetadataFeature::new(irc_30_metadata).unwrap(),
370        ));
371        let output = builder.finish().unwrap();
372
373        // Step 2: Convert the FoundryOutput to NativeTokenPackageData
374        let native_token_data = NativeTokenPackageData::try_from(&output)?;
375        assert_eq!(
376            native_token_data.module().circulating_supply,
377            minted_tokens.sub(melted_tokens).as_u64()
378        );
379        assert_eq!(
380            native_token_data.module().maximum_supply,
381            MAX_ALLOWED_U64_SUPPLY
382        );
383
384        // Step 3: Verify the conversion
385        assert!(package_builder::build_and_compile(native_token_data).is_ok());
386
387        Ok(())
388    }
389
390    #[test]
391    fn foundry_output_with_exceeding_circulating_supply() -> Result<()> {
392        let minted_tokens = U256::from(u64::MAX).add(1);
393        let melted_tokens = U256::from(0);
394        let maximum_supply = U256::MAX;
395
396        // Step 1: Create a FoundryOutput with an IRC30Metadata feature
397        let token_scheme =
398            SimpleTokenScheme::new(minted_tokens, melted_tokens, maximum_supply).unwrap();
399
400        let irc_30_metadata = Irc30Metadata::new("Dogecoin", "DOGE", 0)
401            .with_description("Much wow")
402            .with_url(Url::parse("https://dogecoin.com").unwrap())
403            .with_logo_url(Url::parse("https://dogecoin.com/logo.png").unwrap())
404            .with_logo("0x54654");
405
406        let alias_id = AliasId::new([0; AliasId::LENGTH]);
407        let builder = FoundryOutputBuilder::new_with_amount(
408            100_000_000_000,
409            1,
410            TokenScheme::Simple(token_scheme),
411        )
412        .add_unlock_condition(ImmutableAliasAddressUnlockCondition::new(
413            AliasAddress::new(alias_id),
414        ))
415        .add_feature(Feature::Metadata(
416            MetadataFeature::new(irc_30_metadata).unwrap(),
417        ));
418        let output = builder.finish().unwrap();
419
420        // Step 2: Convert the FoundryOutput to NativeTokenPackageData.
421        let native_token_data = NativeTokenPackageData::try_from(&output)?;
422
423        assert_eq!(
424            native_token_data.module().circulating_supply,
425            MAX_ALLOWED_U64_SUPPLY
426        );
427        assert_eq!(
428            native_token_data.module().maximum_supply,
429            MAX_ALLOWED_U64_SUPPLY
430        );
431
432        // Step 3: Verify the conversion
433        assert!(package_builder::build_and_compile(native_token_data).is_ok());
434
435        Ok(())
436    }
437
438    #[test]
439    fn empty_identifier() {
440        let identifier = "".to_string();
441        let result = derive_foundry_package_lowercase_identifier(&identifier, &[]);
442        assert_eq!(15, result.len());
443    }
444
445    #[test]
446    fn identifier_with_only_invalid_chars() {
447        let identifier = "!@#$%^".to_string();
448        let result = derive_foundry_package_lowercase_identifier(&identifier, &[]);
449        assert_eq!(15, result.len());
450    }
451
452    #[test]
453    fn identifier_with_only_one_char() {
454        let identifier = "a".to_string();
455        assert_eq!(
456            derive_foundry_package_lowercase_identifier(&identifier, &[]),
457            "a".to_string()
458        );
459    }
460
461    #[test]
462    fn identifier_with_whitespaces_and_ending_underscore() {
463        let identifier = " a bc-d e_".to_string();
464        assert_eq!(
465            derive_foundry_package_lowercase_identifier(&identifier, &[]),
466            "abcde".to_string()
467        );
468    }
469
470    #[test]
471    fn identifier_with_minus() {
472        let identifier = "hello-world".to_string();
473        assert_eq!(
474            derive_foundry_package_lowercase_identifier(&identifier, &[]),
475            "helloworld".to_string()
476        );
477    }
478
479    #[test]
480    fn identifier_with_multiple_invalid_chars() {
481        let identifier = "#hello-move_world/token&".to_string();
482        assert_eq!(
483            derive_foundry_package_lowercase_identifier(&identifier, &[]),
484            "hellomove_worldtoken"
485        );
486    }
487    #[test]
488    fn valid_identifier() {
489        let identifier = "valid_identifier".to_string();
490        assert_eq!(
491            derive_foundry_package_lowercase_identifier(&identifier, &[]),
492            identifier
493        );
494    }
495}