iota_types/stardust/output/
nft.rs

1// Copyright (c) 2024 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::anyhow;
5use iota_protocol_config::ProtocolConfig;
6use iota_stardust_sdk::types::block::output::{
7    NftOutput as StardustNft, feature::Irc27Metadata as StardustIrc27,
8};
9use move_core_types::{ident_str, identifier::IdentStr, language_storage::StructTag};
10use num_rational::Ratio;
11use serde::{Deserialize, Serialize};
12use serde_with::serde_as;
13
14use super::unlock_conditions::{
15    ExpirationUnlockCondition, StorageDepositReturnUnlockCondition, TimelockUnlockCondition,
16};
17use crate::{
18    STARDUST_ADDRESS, TypeTag,
19    balance::Balance,
20    base_types::{IotaAddress, ObjectID, SequenceNumber, TxContext},
21    collection_types::{Bag, Entry, VecMap},
22    error::IotaError,
23    id::UID,
24    object::{Data, MoveObject, Object, Owner},
25    stardust::{coin_type::CoinType, stardust_to_iota_address},
26};
27
28pub const IRC27_MODULE_NAME: &IdentStr = ident_str!("irc27");
29pub const NFT_MODULE_NAME: &IdentStr = ident_str!("nft");
30pub const NFT_OUTPUT_MODULE_NAME: &IdentStr = ident_str!("nft_output");
31pub const NFT_OUTPUT_STRUCT_NAME: &IdentStr = ident_str!("NftOutput");
32pub const NFT_STRUCT_NAME: &IdentStr = ident_str!("Nft");
33pub const IRC27_STRUCT_NAME: &IdentStr = ident_str!("Irc27Metadata");
34pub const NFT_DYNAMIC_OBJECT_FIELD_KEY: &[u8] = b"nft";
35pub const NFT_DYNAMIC_OBJECT_FIELD_KEY_TYPE: &str = "vector<u8>";
36
37/// Rust version of the Move std::fixed_point32::FixedPoint32 type.
38#[derive(Debug, Default, Serialize, Deserialize, Clone, Eq, PartialEq)]
39pub struct FixedPoint32 {
40    pub value: u64,
41}
42
43impl FixedPoint32 {
44    /// Create a fixed-point value from a rational number specified by its
45    /// numerator and denominator. Imported from Move std lib.
46    /// This will panic if the denominator is zero. It will also
47    /// abort if the numerator is nonzero and the ratio is not in the range
48    /// 2^-32 .. 2^32-1. When specifying decimal fractions, be careful about
49    /// rounding errors: if you round to display N digits after the decimal
50    /// point, you can use a denominator of 10^N to avoid numbers where the
51    /// very small imprecision in the binary representation could change the
52    /// rounding, e.g., 0.0125 will round down to 0.012 instead of up to 0.013.
53    fn create_from_rational(numerator: u64, denominator: u64) -> Self {
54        // If the denominator is zero, this will abort.
55        // Scale the numerator to have 64 fractional bits and the denominator
56        // to have 32 fractional bits, so that the quotient will have 32
57        // fractional bits.
58        let scaled_numerator = (numerator as u128) << 64;
59        let scaled_denominator = (denominator as u128) << 32;
60        assert!(scaled_denominator != 0);
61        let quotient = scaled_numerator / scaled_denominator;
62        assert!(quotient != 0 || numerator == 0);
63        // Return the quotient as a fixed-point number. We first need to check whether
64        // the cast can succeed.
65        assert!(quotient <= u64::MAX as u128);
66        FixedPoint32 {
67            value: quotient as u64,
68        }
69    }
70}
71
72impl TryFrom<f64> for FixedPoint32 {
73    type Error = anyhow::Error;
74    fn try_from(value: f64) -> Result<Self, Self::Error> {
75        let value = Ratio::from_float(value).ok_or(anyhow!("Missing attribute"))?;
76        let numerator = value.numer().clone().try_into()?;
77        let denominator = value.denom().clone().try_into()?;
78        Ok(FixedPoint32::create_from_rational(numerator, denominator))
79    }
80}
81
82/// Rust version of the Move iota::url::Url type.
83#[derive(Debug, Default, Serialize, Deserialize, Clone, Eq, PartialEq)]
84pub struct Url {
85    /// The underlying URL as a string.
86    ///
87    /// # SAFETY
88    ///
89    /// Note that this String is UTF-8 encoded while the URL type in Move is
90    /// ascii-encoded. Setting this field requires ensuring that the string
91    /// consists of only ASCII characters.
92    url: String,
93}
94
95impl Url {
96    pub fn url(&self) -> &str {
97        &self.url
98    }
99}
100
101impl TryFrom<String> for Url {
102    type Error = anyhow::Error;
103
104    /// Creates a new `Url` ensuring that it only consists of ascii characters.
105    fn try_from(url: String) -> Result<Self, Self::Error> {
106        if !url.is_ascii() {
107            anyhow::bail!("url `{url}` does not consist of only ascii characters")
108        }
109        Ok(Url { url })
110    }
111}
112
113#[serde_as]
114#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
115pub struct Irc27Metadata {
116    /// Version of the metadata standard.
117    pub version: String,
118
119    /// The media type (MIME) of the asset.
120    ///
121    /// ## Examples
122    /// - Image files: `image/jpeg`, `image/png`, `image/gif`, etc.
123    /// - Video files: `video/x-msvideo` (avi), `video/mp4`, `video/mpeg`, etc.
124    /// - Audio files: `audio/mpeg`, `audio/wav`, etc.
125    /// - 3D Assets: `model/obj`, `model/u3d`, etc.
126    /// - Documents: `application/pdf`, `text/plain`, etc.
127    pub media_type: String,
128
129    /// URL pointing to the NFT file location.
130    pub uri: Url,
131
132    /// Alphanumeric text string defining the human identifiable name for the
133    /// NFT.
134    pub name: String,
135
136    /// The human-readable collection name of the NFT.
137    pub collection_name: Option<String>,
138
139    /// Royalty payment addresses mapped to the payout percentage.
140    /// Contains a hash of the 32 bytes parsed from the BECH32 encoded IOTA
141    /// address in the metadata, it is a legacy address. Royalties are not
142    /// supported by the protocol and needed to be processed by an integrator.
143    pub royalties: VecMap<IotaAddress, FixedPoint32>,
144
145    /// The human-readable name of the NFT creator.
146    pub issuer_name: Option<String>,
147
148    /// The human-readable description of the NFT.
149    pub description: Option<String>,
150
151    /// Additional attributes which follow [OpenSea Metadata standards](https://docs.opensea.io/docs/metadata-standards).
152    pub attributes: VecMap<String, String>,
153
154    /// Legacy non-standard metadata fields.
155    pub non_standard_fields: VecMap<String, String>,
156}
157
158impl TryFrom<StardustIrc27> for Irc27Metadata {
159    type Error = anyhow::Error;
160    fn try_from(irc27: StardustIrc27) -> Result<Self, Self::Error> {
161        Ok(Self {
162            version: irc27.version().to_string(),
163            media_type: irc27.media_type().to_string(),
164            // We are converting a `Url` to an ASCII string here (as the URL type in move is based
165            // on ASCII strings). The `ToString` implementation of the `Url` ensures
166            // only ascii characters are returned and this conversion is therefore safe
167            // to do.
168            uri: Url::try_from(irc27.uri().to_string())
169                .expect("url should only contain ascii characters"),
170            name: irc27.name().to_string(),
171            collection_name: irc27.collection_name().clone(),
172            royalties: VecMap {
173                contents: irc27
174                    .royalties()
175                    .iter()
176                    .map(|(addr, value)| {
177                        Ok(Entry {
178                            key: stardust_to_iota_address(addr.inner())?,
179                            value: FixedPoint32::try_from(*value)?,
180                        })
181                    })
182                    .collect::<Result<Vec<Entry<IotaAddress, FixedPoint32>>, Self::Error>>()?,
183            },
184            issuer_name: irc27.issuer_name().clone(),
185            description: irc27.description().clone(),
186            attributes: VecMap {
187                contents: irc27
188                    .attributes()
189                    .iter()
190                    .map(|attribute| Entry {
191                        key: attribute.trait_type().to_string(),
192                        value: attribute.value().to_string(),
193                    })
194                    .collect(),
195            },
196            non_standard_fields: VecMap {
197                contents: Vec::new(),
198            },
199        })
200    }
201}
202
203impl Default for Irc27Metadata {
204    fn default() -> Self {
205        // The currently supported version per <https://github.com/iotaledger/tips/blob/main/tips/TIP-0027/tip-0027.md#nft-schema>.
206        let version = "v1.0".to_owned();
207        // Matches the media type of the URI below.
208        let media_type = "image/png".to_owned();
209        // A placeholder for NFTs without metadata from which we can extract a URI.
210        let uri = Url::try_from(
211            iota_stardust_sdk::Url::parse("https://opensea.io/static/images/placeholder.png")
212                .expect("should be a valid url")
213                .to_string(),
214        )
215        .expect("url should only contain ascii characters");
216        let name = "NFT".to_owned();
217
218        Self {
219            version,
220            media_type,
221            uri,
222            name,
223            collection_name: Default::default(),
224            royalties: VecMap {
225                contents: Vec::new(),
226            },
227            issuer_name: Default::default(),
228            description: Default::default(),
229            attributes: VecMap {
230                contents: Vec::new(),
231            },
232            non_standard_fields: VecMap {
233                contents: Vec::new(),
234            },
235        }
236    }
237}
238
239#[serde_as]
240#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
241pub struct Nft {
242    /// The ID of the Nft = hash of the Output ID that created the Nft Output in
243    /// Stardust. This is the NftID from Stardust.
244    pub id: UID,
245
246    /// The sender feature holds the last sender address assigned before the
247    /// migration and is not supported by the protocol after it.
248    pub legacy_sender: Option<IotaAddress>,
249    /// The metadata feature.
250    pub metadata: Option<Vec<u8>>,
251    /// The tag feature.
252    pub tag: Option<Vec<u8>>,
253
254    /// The immutable issuer feature.
255    pub immutable_issuer: Option<IotaAddress>,
256    /// The immutable metadata feature.
257    pub immutable_metadata: Irc27Metadata,
258}
259
260impl Nft {
261    /// Returns the struct tag that represents the fully qualified path of an
262    /// [`Nft`] in its move package.
263    pub fn tag() -> StructTag {
264        StructTag {
265            address: STARDUST_ADDRESS,
266            module: NFT_MODULE_NAME.to_owned(),
267            name: NFT_STRUCT_NAME.to_owned(),
268            type_params: Vec::new(),
269        }
270    }
271
272    /// Creates the Move-based Nft model from a Stardust-based Nft Output.
273    pub fn try_from_stardust(nft_id: ObjectID, nft: &StardustNft) -> Result<Self, anyhow::Error> {
274        if nft_id.as_ref() == [0; 32] {
275            anyhow::bail!("nft_id must be non-zeroed");
276        }
277
278        let legacy_sender: Option<IotaAddress> = nft
279            .features()
280            .sender()
281            .map(|sender_feat| stardust_to_iota_address(sender_feat.address()))
282            .transpose()?;
283        let metadata: Option<Vec<u8>> = nft
284            .features()
285            .metadata()
286            .map(|metadata_feat| metadata_feat.data().to_vec());
287        let tag: Option<Vec<u8>> = nft.features().tag().map(|tag_feat| tag_feat.tag().to_vec());
288        let immutable_issuer: Option<IotaAddress> = nft
289            .immutable_features()
290            .issuer()
291            .map(|issuer_feat| stardust_to_iota_address(issuer_feat.address()))
292            .transpose()?;
293        let irc27: Irc27Metadata = Self::convert_immutable_metadata(nft)?;
294
295        Ok(Nft {
296            id: UID::new(nft_id),
297            legacy_sender,
298            metadata,
299            tag,
300            immutable_issuer,
301            immutable_metadata: irc27,
302        })
303    }
304
305    /// Converts the immutable metadata of the NFT into an [`Irc27Metadata`].
306    ///
307    /// - If the metadata does not exist returns the default `Irc27Metadata`.
308    /// - If the metadata can be parsed into [`StardustIrc27`] returns that
309    ///   converted into `Irc27Metadata`.
310    /// - If the metadata can be parsed into a JSON object returns the default
311    ///   `Irc27Metadata` with `non_standard_fields` set to the fields of the
312    ///   object.
313    /// - Otherwise, returns the default `Irc27Metadata` with
314    ///   `non_standard_fields` containing a `data` key with the hex-encoded
315    ///   metadata (without `0x` prefix).
316    ///
317    /// Note that the metadata feature of the NFT cannot be present _and_ empty
318    /// per the protocol rules: <https://github.com/iotaledger/tips/blob/main/tips/TIP-0018/tip-0018.md#additional-syntactic-transaction-validation-rules-2>.
319    pub fn convert_immutable_metadata(nft: &StardustNft) -> anyhow::Result<Irc27Metadata> {
320        let Some(metadata) = nft.immutable_features().metadata() else {
321            return Ok(Irc27Metadata::default());
322        };
323
324        if let Ok(parsed_irc27_metadata) = serde_json::from_slice::<StardustIrc27>(metadata.data())
325        {
326            return Irc27Metadata::try_from(parsed_irc27_metadata);
327        }
328
329        if let Ok(serde_json::Value::Object(json_object)) =
330            serde_json::from_slice::<serde_json::Value>(metadata.data())
331        {
332            let mut irc_metadata = Irc27Metadata::default();
333
334            for (key, value) in json_object.into_iter() {
335                irc_metadata.non_standard_fields.contents.push(Entry {
336                    key,
337                    value: value.to_string(),
338                })
339            }
340
341            return Ok(irc_metadata);
342        }
343
344        let mut irc_metadata = Irc27Metadata::default();
345        let hex_encoded_metadata = hex::encode(metadata.data());
346        irc_metadata.non_standard_fields.contents.push(Entry {
347            key: "data".to_owned(),
348            value: hex_encoded_metadata,
349        });
350        Ok(irc_metadata)
351    }
352
353    pub fn to_genesis_object(
354        &self,
355        owner: Owner,
356        protocol_config: &ProtocolConfig,
357        tx_context: &TxContext,
358        version: SequenceNumber,
359    ) -> anyhow::Result<Object> {
360        // Construct the Nft object.
361        let move_nft_object = {
362            MoveObject::new_from_execution(
363                Self::tag().into(),
364                version,
365                bcs::to_bytes(&self)?,
366                protocol_config,
367            )?
368        };
369
370        let move_nft_object = Object::new_from_genesis(
371            Data::Move(move_nft_object),
372            // We will later overwrite the owner we set here since this object will be added
373            // as a dynamic field on the nft output object.
374            owner,
375            tx_context.digest(),
376        );
377
378        Ok(move_nft_object)
379    }
380}
381
382#[serde_as]
383#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
384pub struct NftOutput {
385    /// This is a "random" UID, not the NftID from Stardust.
386    pub id: UID,
387
388    /// The amount of IOTA coins held by the output.
389    pub balance: Balance,
390    /// The `Bag` holds native tokens, key-ed by the stringified type of the
391    /// asset. Example: key: "0xabcded::soon::SOON", value:
392    /// Balance<0xabcded::soon::SOON>.
393    pub native_tokens: Bag,
394
395    /// The storage deposit return unlock condition.
396    pub storage_deposit_return: Option<StorageDepositReturnUnlockCondition>,
397    /// The timelock unlock condition.
398    pub timelock: Option<TimelockUnlockCondition>,
399    /// The expiration unlock condition.
400    pub expiration: Option<ExpirationUnlockCondition>,
401}
402
403impl NftOutput {
404    /// Returns the struct tag that represents the fully qualified path of an
405    /// [`NftOutput`] in its move package.
406    pub fn tag(type_param: TypeTag) -> StructTag {
407        StructTag {
408            address: STARDUST_ADDRESS,
409            module: NFT_OUTPUT_MODULE_NAME.to_owned(),
410            name: NFT_OUTPUT_STRUCT_NAME.to_owned(),
411            type_params: vec![type_param],
412        }
413    }
414
415    /// Creates the Move-based Nft Output model from a Stardust-based Nft
416    /// Output.
417    pub fn try_from_stardust(
418        object_id: ObjectID,
419        nft: &StardustNft,
420        native_tokens: Bag,
421    ) -> Result<Self, anyhow::Error> {
422        let unlock_conditions = nft.unlock_conditions();
423        Ok(NftOutput {
424            id: UID::new(object_id),
425            balance: Balance::new(nft.amount()),
426            native_tokens,
427            storage_deposit_return: unlock_conditions
428                .storage_deposit_return()
429                .map(|unlock| unlock.try_into())
430                .transpose()?,
431            timelock: unlock_conditions.timelock().map(|unlock| unlock.into()),
432            expiration: unlock_conditions
433                .expiration()
434                .map(|expiration| ExpirationUnlockCondition::new(nft.address(), expiration))
435                .transpose()?,
436        })
437    }
438
439    pub fn to_genesis_object(
440        &self,
441        owner: IotaAddress,
442        protocol_config: &ProtocolConfig,
443        tx_context: &TxContext,
444        version: SequenceNumber,
445        coin_type: CoinType,
446    ) -> anyhow::Result<Object> {
447        // Construct the Nft Output object.
448        let move_nft_output_object = {
449            MoveObject::new_from_execution(
450                NftOutput::tag(coin_type.to_type_tag()).into(),
451                version,
452                bcs::to_bytes(&self)?,
453                protocol_config,
454            )?
455        };
456
457        let owner = if self.expiration.is_some() {
458            Owner::Shared {
459                initial_shared_version: version,
460            }
461        } else {
462            Owner::AddressOwner(owner)
463        };
464
465        let move_nft_output_object = Object::new_from_genesis(
466            Data::Move(move_nft_output_object),
467            owner,
468            tx_context.digest(),
469        );
470
471        Ok(move_nft_output_object)
472    }
473
474    /// Create an `NftOutput` from BCS bytes.
475    pub fn from_bcs_bytes(content: &[u8]) -> Result<Self, IotaError> {
476        bcs::from_bytes(content).map_err(|err| IotaError::ObjectDeserialization {
477            error: format!("Unable to deserialize NftOutput object: {:?}", err),
478        })
479    }
480
481    pub fn is_nft_output(s: &StructTag) -> bool {
482        s.address == STARDUST_ADDRESS
483            && s.module.as_ident_str() == NFT_OUTPUT_MODULE_NAME
484            && s.name.as_ident_str() == NFT_OUTPUT_STRUCT_NAME
485    }
486}
487
488impl TryFrom<&Object> for NftOutput {
489    type Error = IotaError;
490    fn try_from(object: &Object) -> Result<Self, Self::Error> {
491        match &object.data {
492            Data::Move(o) => {
493                if o.type_().is_nft_output() {
494                    return NftOutput::from_bcs_bytes(o.contents());
495                }
496            }
497            Data::Package(_) => {}
498        }
499
500        Err(IotaError::Type {
501            error: format!("Object type is not a NftOutput: {:?}", object),
502        })
503    }
504}