diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index e92473afea..c0f2f41b13 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -140,6 +140,8 @@ tokio = { version = "1.31.0", default-features = false, features = [ default = ["client", "wallet", "tls"] events = [] +irc_27 = ["url", "serde"] +irc_30 = ["url", "serde"] ledger_nano = ["iota-ledger-nano"] mqtt = ["std", "regex", "rumqttc", "dep:once_cell"] participation = ["storage"] diff --git a/sdk/src/client/api/block_builder/input_selection/core/transition.rs b/sdk/src/client/api/block_builder/input_selection/core/transition.rs index 224274a2ed..aaa7e9a3d4 100644 --- a/sdk/src/client/api/block_builder/input_selection/core/transition.rs +++ b/sdk/src/client/api/block_builder/input_selection/core/transition.rs @@ -54,7 +54,7 @@ impl InputSelection { } // Remove potential sender feature because it will not be needed anymore as it only needs to be verified once. - let features = input.features().iter().cloned().filter(|feature| !feature.is_sender()); + let features = input.features().iter().filter(|feature| !feature.is_sender()).cloned(); let mut builder = AliasOutputBuilder::from(input) .with_alias_id(alias_id) @@ -101,7 +101,7 @@ impl InputSelection { } // Remove potential sender feature because it will not be needed anymore as it only needs to be verified once. - let features = input.features().iter().cloned().filter(|feature| !feature.is_sender()); + let features = input.features().iter().filter(|feature| !feature.is_sender()).cloned(); let output = NftOutputBuilder::from(input) .with_nft_id(nft_id) diff --git a/sdk/src/types/block/address/bech32.rs b/sdk/src/types/block/address/bech32.rs index 309e64cd7c..d23109060d 100644 --- a/sdk/src/types/block/address/bech32.rs +++ b/sdk/src/types/block/address/bech32.rs @@ -137,7 +137,7 @@ impl + Send> ConvertTo for T { } /// An address and its network type. -#[derive(Copy, Clone, Eq, PartialEq, Hash, AsRef, Deref)] +#[derive(Copy, Clone, Eq, PartialEq, Hash, AsRef, Deref, Ord, PartialOrd)] pub struct Bech32Address { pub(crate) hrp: Hrp, #[as_ref] diff --git a/sdk/src/types/block/output/feature/metadata.rs b/sdk/src/types/block/output/feature/metadata.rs index 51fba6e8f6..6a48cecc81 100644 --- a/sdk/src/types/block/output/feature/metadata.rs +++ b/sdk/src/types/block/output/feature/metadata.rs @@ -1,7 +1,7 @@ // Copyright 2021-2022 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use alloc::{boxed::Box, vec::Vec}; +use alloc::{boxed::Box, string::String, vec::Vec}; use core::{ops::RangeInclusive, str::FromStr}; use packable::{bounded::BoundedU16, prefix::BoxedSlicePrefix}; @@ -19,6 +19,29 @@ pub struct MetadataFeature( BoxedSlicePrefix, ); +macro_rules! impl_from_vec { + ($type:ty) => { + impl TryFrom<$type> for MetadataFeature { + type Error = Error; + + fn try_from(value: $type) -> Result { + Vec::::from(value).try_into() + } + } + }; +} +impl_from_vec!(&str); +impl_from_vec!(String); +impl_from_vec!(&[u8]); + +impl TryFrom<[u8; N]> for MetadataFeature { + type Error = Error; + + fn try_from(value: [u8; N]) -> Result { + value.to_vec().try_into() + } +} + impl TryFrom> for MetadataFeature { type Error = Error; @@ -51,8 +74,8 @@ impl MetadataFeature { /// Creates a new [`MetadataFeature`]. #[inline(always)] - pub fn new(data: impl Into>) -> Result { - Self::try_from(data.into()) + pub fn new>(data: T) -> Result { + data.try_into() } /// Returns the data. @@ -74,6 +97,320 @@ impl core::fmt::Debug for MetadataFeature { } } +#[cfg(feature = "irc_27")] +pub(crate) mod irc_27 { + use alloc::{ + borrow::ToOwned, + collections::{BTreeMap, BTreeSet}, + string::String, + }; + + use getset::Getters; + use serde::{Deserialize, Serialize}; + use url::Url; + + use super::*; + use crate::types::block::address::Bech32Address; + + /// The IRC27 NFT standard schema. + #[derive(Clone, Debug, Serialize, Deserialize, Getters, PartialEq)] + #[serde(rename_all = "camelCase")] + #[serde(tag = "standard", rename = "IRC27")] + #[getset(get = "pub")] + pub struct Irc27Metadata { + version: String, + /// The media type (MIME) of the asset. + /// + /// ## Examples + /// - Image files: `image/jpeg`, `image/png`, `image/gif`, etc. + /// - Video files: `video/x-msvideo` (avi), `video/mp4`, `video/mpeg`, etc. + /// - Audio files: `audio/mpeg`, `audio/wav`, etc. + /// - 3D Assets: `model/obj`, `model/u3d`, etc. + /// - Documents: `application/pdf`, `text/plain`, etc. + #[serde(rename = "type")] + media_type: String, + /// URL pointing to the NFT file location. + uri: Url, + /// The human-readable name of the native token. + name: String, + /// The human-readable collection name of the native token. + #[serde(default, skip_serializing_if = "Option::is_none")] + collection_name: Option, + /// Royalty payment addresses mapped to the payout percentage. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + royalties: BTreeMap, + /// The human-readable name of the native token creator. + #[serde(default, skip_serializing_if = "Option::is_none")] + issuer_name: Option, + /// The human-readable description of the token. + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + /// Additional attributes which follow [OpenSea Metadata standards](https://docs.opensea.io/docs/metadata-standards). + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + attributes: BTreeSet, + } + + impl Irc27Metadata { + pub fn new(media_type: impl Into, uri: Url, name: impl Into) -> Self { + Self { + version: "v1.0".to_owned(), + media_type: media_type.into(), + uri, + name: name.into(), + collection_name: Default::default(), + royalties: Default::default(), + issuer_name: Default::default(), + description: Default::default(), + attributes: Default::default(), + } + } + + pub fn with_collection_name(mut self, collection_name: impl Into) -> Self { + self.collection_name.replace(collection_name.into()); + self + } + + pub fn add_royalty(mut self, address: Bech32Address, percentage: f64) -> Self { + self.royalties.insert(address, percentage); + self + } + + pub fn with_royalties(mut self, royalties: BTreeMap) -> Self { + self.royalties = royalties; + self + } + + pub fn with_issuer_name(mut self, issuer_name: impl Into) -> Self { + self.issuer_name.replace(issuer_name.into()); + self + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description.replace(description.into()); + self + } + + pub fn add_attribute(mut self, attribute: Attribute) -> Self { + self.attributes.insert(attribute); + self + } + + pub fn with_attributes(mut self, attributes: BTreeSet) -> Self { + self.attributes = attributes; + self + } + + pub fn to_bytes(&self) -> Vec { + serde_json::to_string(self).unwrap().into_bytes() + } + } + + impl TryFrom for MetadataFeature { + type Error = Error; + fn try_from(value: Irc27Metadata) -> Result { + Self::new(value.to_bytes()) + } + } + + #[derive(Clone, Debug, Serialize, Deserialize, Getters, PartialEq, Eq)] + #[getset(get = "pub")] + pub struct Attribute { + trait_type: String, + value: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_type: Option, + } + + impl Attribute { + pub fn new(trait_type: impl Into, value: impl Into) -> Self { + Self { + trait_type: trait_type.into(), + display_type: None, + value: value.into(), + } + } + + pub fn with_display_type(mut self, display_type: impl Into) -> Self { + self.display_type.replace(display_type.into()); + self + } + } + + impl Ord for Attribute { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.trait_type.cmp(&other.trait_type) + } + } + impl PartialOrd for Attribute { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + impl core::hash::Hash for Attribute { + fn hash(&self, state: &mut H) { + self.trait_type.hash(state); + } + } + + #[cfg(test)] + mod test { + use super::*; + use crate::types::block::{address::ToBech32Ext, rand::address::rand_address}; + + #[test] + fn serialization() { + let metadata = Irc27Metadata::new( + "image/jpeg", + "https://mywebsite.com/my-nft-files-1.jpeg".parse().unwrap(), + "My NFT #0001", + ) + .with_collection_name("My Collection of Art") + .add_royalty(rand_address().to_bech32_unchecked("iota1"), 0.025) + .add_royalty(rand_address().to_bech32_unchecked("iota1"), 0.025) + .with_issuer_name("My Artist Name") + .with_description("A little information about my NFT collection") + .add_attribute(Attribute::new("Background", "Purple")) + .add_attribute(Attribute::new("Element", "Water")) + .add_attribute(Attribute::new("Attack", 150)) + .add_attribute(Attribute::new("Health", 500)); + let json = serde_json::json!( + { + "standard": "IRC27", + "version": metadata.version(), + "type": metadata.media_type(), + "uri": metadata.uri(), + "name": metadata.name(), + "collectionName": metadata.collection_name(), + "royalties": metadata.royalties(), + "issuerName": metadata.issuer_name(), + "description": metadata.description(), + "attributes": metadata.attributes() + } + ); + let metadata_deser = serde_json::from_value::(json.clone()).unwrap(); + + assert_eq!(metadata, metadata_deser); + assert_eq!(json, serde_json::to_value(metadata).unwrap()) + } + } +} + +#[cfg(feature = "irc_30")] +pub(crate) mod irc_30 { + use alloc::string::String; + + use getset::Getters; + use serde::{Deserialize, Serialize}; + use url::Url; + + use super::*; + + /// The IRC30 NFT standard schema. + #[derive(Clone, Debug, Serialize, Deserialize, Getters, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + #[serde(tag = "standard", rename = "IRC30")] + #[getset(get = "pub")] + pub struct Irc30Metadata { + /// The human-readable name of the native token. + name: String, + /// The symbol/ticker of the token. + symbol: String, + /// Number of decimals the token uses (divide the token amount by 10^decimals to get its user representation). + decimals: u32, + /// The human-readable description of the token. + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + /// URL pointing to more resources about the token. + #[serde(default, skip_serializing_if = "Option::is_none")] + url: Option, + /// URL pointing to an image resource of the token logo. + #[serde(default, skip_serializing_if = "Option::is_none")] + logo_url: Option, + /// The svg logo of the token encoded as a byte string. + #[serde(default, skip_serializing_if = "Option::is_none")] + logo: Option, + } + + impl Irc30Metadata { + pub fn new(name: impl Into, symbol: impl Into, decimals: u32) -> Self { + Self { + name: name.into(), + symbol: symbol.into(), + decimals, + description: Default::default(), + url: Default::default(), + logo_url: Default::default(), + logo: Default::default(), + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description.replace(description.into()); + self + } + + pub fn with_url(mut self, url: Url) -> Self { + self.url.replace(url); + self + } + + pub fn with_logo_url(mut self, logo_url: Url) -> Self { + self.logo_url.replace(logo_url); + self + } + + pub fn with_logo(mut self, logo: impl Into) -> Self { + self.logo.replace(logo.into()); + self + } + + pub fn to_bytes(&self) -> Vec { + serde_json::to_string(self).unwrap().into_bytes() + } + } + + impl TryFrom for MetadataFeature { + type Error = Error; + fn try_from(value: Irc30Metadata) -> Result { + Self::new(value.to_bytes()) + } + } + + #[cfg(test)] + mod test { + use super::*; + + #[test] + fn serialization() { + let description = "FooCoin is the utility and governance token of FooLand, \ + a revolutionary protocol in the play-to-earn crypto gaming field."; + let metadata = Irc30Metadata::new("FooCoin", "FOO", 3) + .with_description(description) + .with_url("https://foocoin.io/".parse().unwrap()) + .with_logo_url( + "https://ipfs.io/ipfs/QmR36VFfo1hH2RAwVs4zVJ5btkopGip5cW7ydY4jUQBrkR" + .parse() + .unwrap(), + ); + let json = serde_json::json!( + { + "standard": "IRC30", + "name": metadata.name(), + "description": metadata.description(), + "decimals": metadata.decimals(), + "symbol": metadata.symbol(), + "url": metadata.url(), + "logoUrl": metadata.logo_url() + } + ); + let metadata_deser = serde_json::from_value::(json.clone()).unwrap(); + + assert_eq!(metadata, metadata_deser); + assert_eq!(json, serde_json::to_value(metadata).unwrap()) + } + } +} + #[cfg(feature = "serde")] pub(crate) mod dto { use alloc::boxed::Box; diff --git a/sdk/src/types/block/output/feature/mod.rs b/sdk/src/types/block/output/feature/mod.rs index 716db19d7f..363fb1befc 100644 --- a/sdk/src/types/block/output/feature/mod.rs +++ b/sdk/src/types/block/output/feature/mod.rs @@ -13,6 +13,10 @@ use derive_more::{Deref, From}; use iterator_sorted::is_unique_sorted; use packable::{bounded::BoundedU8, prefix::BoxedSlicePrefix, Packable}; +#[cfg(feature = "irc_27")] +pub use self::metadata::irc_27::{Attribute, Irc27Metadata}; +#[cfg(feature = "irc_30")] +pub use self::metadata::irc_30::Irc30Metadata; pub use self::{issuer::IssuerFeature, metadata::MetadataFeature, sender::SenderFeature, tag::TagFeature}; pub(crate) use self::{metadata::MetadataFeatureLength, tag::TagFeatureLength}; use crate::types::block::{create_bitflags, Error}; diff --git a/sdk/tests/client/input_selection/nft_outputs.rs b/sdk/tests/client/input_selection/nft_outputs.rs index cfc449d908..59980c680b 100644 --- a/sdk/tests/client/input_selection/nft_outputs.rs +++ b/sdk/tests/client/input_selection/nft_outputs.rs @@ -1188,9 +1188,19 @@ fn changed_immutable_metadata() { let protocol_parameters = protocol_parameters(); let nft_id_1 = NftId::from_str(NFT_ID_1).unwrap(); + #[cfg(feature = "irc_27")] + let metadata = iota_sdk::types::block::output::feature::Irc27Metadata::new( + "image/jpeg", + "https://mywebsite.com/my-nft-files-1.jpeg".parse().unwrap(), + "file 1", + ) + .with_issuer_name("Alice"); + #[cfg(not(feature = "irc_27"))] + let metadata = [1, 2, 3]; + let nft_output = NftOutputBuilder::new_with_minimum_storage_deposit(*protocol_parameters.rent_structure(), nft_id_1) - .with_immutable_features(MetadataFeature::new([1, 2, 3])) + .with_immutable_features(MetadataFeature::try_from(metadata)) .add_unlock_condition(AddressUnlockCondition::new( Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), )) @@ -1203,14 +1213,24 @@ fn changed_immutable_metadata() { chain: None, }]; + #[cfg(feature = "irc_27")] + let metadata = iota_sdk::types::block::output::feature::Irc27Metadata::new( + "image/jpeg", + "https://mywebsite.com/my-nft-files-2.jpeg".parse().unwrap(), + "file 2", + ) + .with_issuer_name("Alice"); + #[cfg(not(feature = "irc_27"))] + let metadata = [4, 5, 6]; + // New nft output with changed immutable metadata feature - let updated_alias_output = NftOutputBuilder::from(nft_output.as_nft()) + let updated_nft_output = NftOutputBuilder::from(nft_output.as_nft()) .with_minimum_storage_deposit(*protocol_parameters.rent_structure()) - .with_immutable_features(MetadataFeature::new([4, 5, 6])) + .with_immutable_features(MetadataFeature::try_from(metadata)) .finish_output(protocol_parameters.token_supply()) .unwrap(); - let outputs = [updated_alias_output]; + let outputs = [updated_nft_output]; let selected = InputSelection::new( inputs,