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