Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add IRC27 and IRC30 metadata utilities #1160

Merged
merged 8 commits into from
Sep 7, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion sdk/src/types/block/address/bech32.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ impl<T: AsRef<str> + Send> ConvertTo<Hrp> 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]
Expand Down
314 changes: 314 additions & 0 deletions sdk/src/types/block/output/feature/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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<String>,
/// Royalty payment addresses mapped to the payout percentage.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
royalties: BTreeMap<Bech32Address, f64>,
/// The human-readable name of the native token creator.
#[serde(default, skip_serializing_if = "Option::is_none")]
issuer_name: Option<String>,
/// The human-readable description of the token.
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
/// Additional attributes which follow [OpenSea Metadata standards](https://docs.opensea.io/docs/metadata-standards).
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
attributes: BTreeSet<Attribute>,
}

impl Irc27Metadata {
pub fn new(media_type: impl Into<String>, uri: Url, name: impl Into<String>) -> Self {
Self {
version: "v1.0".to_owned(),
Thoralf-M marked this conversation as resolved.
Show resolved Hide resolved
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<String>) -> 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<Bech32Address, f64>) -> Self {
self.royalties = royalties;
self
}

pub fn with_issuer_name(mut self, issuer_name: impl Into<String>) -> Self {
self.issuer_name.replace(issuer_name.into());
self
}

pub fn with_description(mut self, description: impl Into<String>) -> 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<Attribute>) -> Self {
self.attributes = attributes;
self
}

pub fn to_bytes(&self) -> Vec<u8> {
serde_json::to_string(self).unwrap().into_bytes()
}
}

impl TryFrom<Irc27Metadata> for MetadataFeature {
type Error = Error;
fn try_from(value: Irc27Metadata) -> Result<Self, Error> {
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<String>,
}

impl Attribute {
pub fn new(trait_type: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
Self {
trait_type: trait_type.into(),
display_type: None,
value: value.into(),
}
}

pub fn with_display_type(mut self, display_type: impl Into<String>) -> 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<core::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl core::hash::Hash for Attribute {
fn hash<H: core::hash::Hasher>(&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::<Irc27Metadata>(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<String>,
/// URL pointing to more resources about the token.
#[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<Url>,
/// URL pointing to an image resource of the token logo.
#[serde(default, skip_serializing_if = "Option::is_none")]
logo_url: Option<Url>,
/// The svg logo of the token encoded as a byte string.
#[serde(default, skip_serializing_if = "Option::is_none")]
logo: Option<String>,
}

impl Irc30Metadata {
pub fn new(name: impl Into<String>, symbol: impl Into<String>, 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<String>) -> 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<String>) -> Self {
self.logo.replace(logo.into());
self
}

pub fn to_bytes(&self) -> Vec<u8> {
serde_json::to_string(self).unwrap().into_bytes()
}
}

impl TryFrom<Irc30Metadata> for MetadataFeature {
type Error = Error;
fn try_from(value: Irc30Metadata) -> Result<Self, Error> {
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::<Irc30Metadata>(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;
Expand Down
4 changes: 4 additions & 0 deletions sdk/src/types/block/output/feature/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
22 changes: 18 additions & 4 deletions sdk/tests/client/input_selection/nft_outputs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1188,9 +1188,16 @@
let protocol_parameters = protocol_parameters();
let nft_id_1 = NftId::from_str(NFT_ID_1).unwrap();

let metadata = iota_sdk::types::block::output::feature::Irc27Metadata::new(

Check failure on line 1191 in sdk/tests/client/input_selection/nft_outputs.rs

View workflow job for this annotation

GitHub Actions / Clippy Results for the Rust Core

failed to resolve: could not find `Irc27Metadata` in `feature`

error[E0433]: failed to resolve: could not find `Irc27Metadata` in `feature` --> sdk/tests/client/input_selection/nft_outputs.rs:1191:61 | 1191 | let metadata = iota_sdk::types::block::output::feature::Irc27Metadata::new( | ^^^^^^^^^^^^^ could not find `Irc27Metadata` in `feature`
Thoralf-M marked this conversation as resolved.
Show resolved Hide resolved
"image/jpeg",
"https://mywebsite.com/my-nft-files-1.jpeg".parse().unwrap(),
"file 1",
)
.with_issuer_name("Alice");

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(),
))
Expand All @@ -1203,14 +1210,21 @@
chain: None,
}];

let metadata = iota_sdk::types::block::output::feature::Irc27Metadata::new(

Check failure on line 1213 in sdk/tests/client/input_selection/nft_outputs.rs

View workflow job for this annotation

GitHub Actions / Clippy Results for the Rust Core

failed to resolve: could not find `Irc27Metadata` in `feature`

error[E0433]: failed to resolve: could not find `Irc27Metadata` in `feature` --> sdk/tests/client/input_selection/nft_outputs.rs:1213:61 | 1213 | let metadata = iota_sdk::types::block::output::feature::Irc27Metadata::new( | ^^^^^^^^^^^^^ could not find `Irc27Metadata` in `feature`
"image/jpeg",
"https://mywebsite.com/my-nft-files-2.jpeg".parse().unwrap(),
"file 2",
)
.with_issuer_name("Alice");

// 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,
Expand Down
Loading