1use 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#[derive(Debug, Default, Serialize, Deserialize, Clone, Eq, PartialEq)]
39pub struct FixedPoint32 {
40 pub value: u64,
41}
42
43impl FixedPoint32 {
44 fn create_from_rational(numerator: u64, denominator: u64) -> Self {
54 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 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#[derive(Debug, Default, Serialize, Deserialize, Clone, Eq, PartialEq)]
84pub struct Url {
85 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 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 pub version: String,
118
119 pub media_type: String,
128
129 pub uri: Url,
131
132 pub name: String,
135
136 pub collection_name: Option<String>,
138
139 pub royalties: VecMap<IotaAddress, FixedPoint32>,
144
145 pub issuer_name: Option<String>,
147
148 pub description: Option<String>,
150
151 pub attributes: VecMap<String, String>,
153
154 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 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 let version = "v1.0".to_owned();
207 let media_type = "image/png".to_owned();
209 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 pub id: UID,
245
246 pub legacy_sender: Option<IotaAddress>,
249 pub metadata: Option<Vec<u8>>,
251 pub tag: Option<Vec<u8>>,
253
254 pub immutable_issuer: Option<IotaAddress>,
256 pub immutable_metadata: Irc27Metadata,
258}
259
260impl Nft {
261 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 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 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 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 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 pub id: UID,
387
388 pub balance: Balance,
390 pub native_tokens: Bag,
394
395 pub storage_deposit_return: Option<StorageDepositReturnUnlockCondition>,
397 pub timelock: Option<TimelockUnlockCondition>,
399 pub expiration: Option<ExpirationUnlockCondition>,
401}
402
403impl NftOutput {
404 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 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 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 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}