diff --git a/common/src/queries/assets.rs b/common/src/queries/assets.rs index ba549cee..aa47a80f 100644 --- a/common/src/queries/assets.rs +++ b/common/src/queries/assets.rs @@ -1,7 +1,7 @@ use crate::queries::errors::QueryError; use crate::{ - AssetAddressEntry, AssetInfoRecord, AssetMintRecord, AssetName, PolicyAsset, PolicyId, - TxIdentifier, + AssetAddressEntry, AssetInfoRecord, AssetMetadata, AssetMintRecord, AssetName, NativeAssets, + PolicyAsset, PolicyId, TxIdentifier, }; pub const DEFAULT_ASSETS_QUERY_TOPIC: (&str, &str) = @@ -27,6 +27,7 @@ pub enum AssetsStateQuery { GetPolicyIdAssets { policy: PolicyId }, GetAssetAddresses { policy: PolicyId, name: AssetName }, GetAssetTransactions { policy: PolicyId, name: AssetName }, + GetAssetsMetadata { assets: NativeAssets }, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -37,5 +38,6 @@ pub enum AssetsStateQueryResponse { AssetAddresses(AssetAddresses), AssetTransactions(AssetTransactions), PolicyIdAssets(PolicyAssets), + AssetsMetadata(Vec), Error(QueryError), } diff --git a/common/src/types.rs b/common/src/types.rs index d7c90545..3a63dc7f 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -2121,8 +2121,15 @@ pub struct TxCertificateWithPos { pub struct AssetInfoRecord { pub initial_mint_tx: TxIdentifier, pub mint_or_burn_count: u64, - pub onchain_metadata: Option>, - pub metadata_standard: Option, + pub metadata: AssetMetadata, +} + +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +pub struct AssetMetadata { + pub cip25_metadata: Option>, + pub cip25_version: Option, + pub cip68_metadata: Option>, + pub cip68_version: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] diff --git a/modules/assets_state/src/assets_state.rs b/modules/assets_state/src/assets_state.rs index b4b59011..d4f7a3fb 100644 --- a/modules/assets_state/src/assets_state.rs +++ b/modules/assets_state/src/assets_state.rs @@ -443,6 +443,18 @@ impl AssetsState { )), } } + AssetsStateQuery::GetAssetsMetadata { assets } => { + let reg = registry.lock().await; + match state.get_assets_metadata(assets, ®) { + Ok(Some(assets)) => AssetsStateQueryResponse::AssetsMetadata(assets), + Ok(None) => AssetsStateQueryResponse::Error(QueryError::not_found( + "One or more assets not found in registry".to_string(), + )), + Err(e) => AssetsStateQueryResponse::Error(QueryError::internal_error( + e.to_string(), + )), + } + } }; Arc::new(Message::StateQueryResponse(StateQueryResponse::Assets( response, diff --git a/modules/assets_state/src/state.rs b/modules/assets_state/src/state.rs index e8f8adc1..303fd4dd 100644 --- a/modules/assets_state/src/state.rs +++ b/modules/assets_state/src/state.rs @@ -5,9 +5,9 @@ use std::collections::HashSet; use crate::asset_registry::{AssetId, AssetRegistry}; use acropolis_common::{ queries::assets::{AssetHistory, PolicyAssets}, - Address, AddressDelta, AssetAddressEntry, AssetInfoRecord, AssetMetadataStandard, - AssetMintRecord, AssetName, Datum, Lovelace, NativeAssetsDelta, PolicyAsset, PolicyId, - ShelleyAddress, TxIdentifier, TxUTxODeltas, + Address, AddressDelta, AssetAddressEntry, AssetInfoRecord, AssetMetadata, + AssetMetadataStandard, AssetMintRecord, AssetName, Datum, Lovelace, NativeAssets, + NativeAssetsDelta, PolicyAsset, PolicyId, ShelleyAddress, TxIdentifier, TxUTxODeltas, }; use anyhow::Result; use imbl::{HashMap, Vector}; @@ -143,10 +143,8 @@ impl State { // Overwrite asset metadata if an associated CIP68 reference token is found if let Some(ref_info) = self.resolve_cip68_metadata(asset_id, registry) { if let Some(info_mut) = info.as_mut() { - info_mut.onchain_metadata = ref_info.onchain_metadata; - info_mut.metadata_standard = ref_info.metadata_standard; - } else { - info = Some(ref_info); + info_mut.metadata.cip68_metadata = ref_info.metadata.cip68_metadata; + info_mut.metadata.cip68_version = ref_info.metadata.cip68_version; } } @@ -240,6 +238,44 @@ impl State { Ok(Some(result)) } + pub fn get_assets_metadata( + &self, + assets: &NativeAssets, + registry: &AssetRegistry, + ) -> Result>> { + if !self.config.store_info || !self.config.store_assets { + return Err(anyhow::anyhow!("asset info storage disabled in config")); + } + + let mut out = Vec::new(); + + for (policy_id, policy_assets) in assets { + for asset in policy_assets { + let asset_id = match registry.lookup_id(policy_id, &asset.name) { + Some(id) => id, + None => { + return Ok(None); + } + }; + + let info = match self.info.as_ref().and_then(|map| map.get(&asset_id)) { + Some(rec) => rec, + None => { + return Err(anyhow::anyhow!( + "asset info missing in state for {}:{}", + hex::encode(policy_id), + hex::encode(asset.name.as_slice()) + )); + } + }; + + out.push(info.metadata.clone()); + } + } + + Ok(Some(out)) + } + pub fn tick(&self) -> Result<()> { if let Some(supply) = &self.supply { self.log_assets(supply.len()); @@ -312,8 +348,12 @@ impl State { .or_insert(AssetInfoRecord { initial_mint_tx: *tx_identifier, mint_or_burn_count: 1, - onchain_metadata: None, - metadata_standard: None, + metadata: AssetMetadata { + cip25_metadata: None, + cip25_version: None, + cip68_metadata: None, + cip68_version: None, + }, }); } @@ -525,8 +565,8 @@ impl State { if let Some(asset_id) = registry.lookup_id(&policy_id, &asset_name) { if let Ok(metadata_raw) = serde_cbor::to_vec(&metadata_val) { if let Some(record) = info_map.get_mut(&asset_id) { - record.onchain_metadata = Some(metadata_raw); - record.metadata_standard = Some(standard); + record.metadata.cip25_metadata = Some(metadata_raw); + record.metadata.cip25_version = Some(standard); } } } @@ -554,6 +594,21 @@ impl State { continue; }; + let mut cip68_version = Some(AssetMetadataStandard::CIP68v1); + + if let Ok(serde_cbor::Value::Map(m)) = + serde_cbor::from_slice::(blob) + { + let version_key = serde_cbor::Value::Text("version".to_string()); + + if let Some(serde_cbor::Value::Text(ver)) = m.get(&version_key) { + cip68_version = match ver.as_str() { + "2.0" => Some(AssetMetadataStandard::CIP68v2), + _ => Some(AssetMetadataStandard::CIP68v1), + }; + } + } + for (policy_id, native_assets) in &output.value.assets { for asset in native_assets { let name = &asset.name; @@ -562,13 +617,13 @@ impl State { continue; } - // NOTE: CIP68 metadata version is included in the blob and is decoded in REST handler match registry.lookup_id(policy_id, name) { Some(asset_id) => { if let Some(record) = new_info.as_mut().and_then(|m| m.get_mut(&asset_id)) { - record.onchain_metadata = Some(blob.clone()); + record.metadata.cip68_metadata = Some(blob.clone()); + record.metadata.cip68_version = cip68_version; } } None => { @@ -607,8 +662,8 @@ impl State { match label { CIP68_LABEL_100 => self.info.as_ref()?.get(asset_id).cloned().map(|mut rec| { // Hide metadata on the reference itself (Per Blockfrost spec) - rec.onchain_metadata = None; - rec.metadata_standard = None; + rec.metadata.cip68_metadata = None; + rec.metadata.cip68_version = None; rec }), @@ -631,13 +686,14 @@ mod tests { use crate::{ asset_registry::{AssetId, AssetRegistry}, - state::{AssetsStorageConfig, State, StoreTransactions}, + state::{AssetsStorageConfig, State, StoreTransactions, CIP67_LABEL_222, CIP68_LABEL_100}, }; use acropolis_common::{ - Address, AddressDelta, AssetInfoRecord, AssetMetadataStandard, AssetName, Datum, - NativeAsset, NativeAssetDelta, PolicyId, ShelleyAddress, TxIdentifier, TxOutput, + Address, AddressDelta, AssetInfoRecord, AssetMetadata, AssetMetadataStandard, AssetName, + Datum, NativeAsset, NativeAssetDelta, PolicyId, ShelleyAddress, TxIdentifier, TxOutput, TxUTxODeltas, UTxOIdentifier, Value, }; + use serde_cbor::Value as CborValue; fn dummy_policy(byte: u8) -> PolicyId { [byte; 28] @@ -998,10 +1054,10 @@ mod tests { let record = info.get(&asset_id).unwrap(); // Onchain metadata has been set - assert!(record.onchain_metadata.is_some()); + assert!(record.metadata.cip25_metadata.is_some()); // Metadata standard defaults to v1 if not present in map assert_eq!( - record.metadata_standard, + record.metadata.cip25_version, Some(AssetMetadataStandard::CIP25v1) ); } @@ -1028,10 +1084,10 @@ mod tests { let record = info.get(&asset_id).unwrap(); // Onchain metadata has been set - assert!(record.onchain_metadata.is_some()); + assert!(record.metadata.cip25_metadata.is_some()); // Metadata standard set to v2 when present in map assert_eq!( - record.metadata_standard, + record.metadata.cip25_version, Some(AssetMetadataStandard::CIP25v2) ); } @@ -1059,7 +1115,7 @@ mod tests { // Metadata for known asset unchanged by unknown asset assert!( - record.onchain_metadata.is_none(), + record.metadata.cip25_metadata.is_none(), "unknown asset should not update records" ); } @@ -1085,12 +1141,12 @@ mod tests { // Metadata not set when CBOR is invalid assert!( - record.onchain_metadata.is_none(), + record.metadata.cip25_metadata.is_none(), "invalid CBOR should be ignored" ); // Metadata standard not set when CBOR is invalid assert!( - record.metadata_standard.is_none(), + record.metadata.cip25_version.is_none(), "invalid CBOR should not set a standard" ); } @@ -1124,7 +1180,7 @@ mod tests { let record = info.get(&reference_id).expect("record should exist"); // Onchain metadata set when asset already exists and TxOutput with inline datum is processed - assert_eq!(record.onchain_metadata, Some(datum_blob)); + assert_eq!(record.metadata.cip68_metadata, Some(datum_blob)); } #[test] @@ -1156,7 +1212,7 @@ mod tests { let record = info.get(&normal_id).expect("non reference asset should exist"); // Onchain metadata not updated for non reference asset - assert_eq!(record.onchain_metadata, None); + assert_eq!(record.metadata.cip68_metadata, None); } #[test] @@ -1223,11 +1279,51 @@ mod tests { // Metadata not populated for inputs or outputs without inline datum assert!( - record.onchain_metadata.is_none(), + record.metadata.cip68_metadata.is_none(), "inputs and outputs without datums should both be ignored" ); } + #[test] + fn handle_cip68_version_detection() { + let mut registry = AssetRegistry::new(); + let policy_id: PolicyId = [7u8; 28]; + + let (state, asset_id, name) = setup_state_with_asset( + &mut registry, + policy_id, + &[0x00, 0x06, 0x43, 0xb0, 0xAA], + true, + false, + StoreTransactions::None, + ); + + let mut map = BTreeMap::new(); + map.insert( + CborValue::Text("version".to_string()), + CborValue::Text("2.0".to_string()), + ); + + let datum = serde_cbor::to_vec(&CborValue::Map(map)).unwrap(); + + let output = make_output(policy_id, name, Some(datum.clone())); + + let tx = TxUTxODeltas { + tx_identifier: TxIdentifier::new(0, 0), + inputs: vec![], + outputs: vec![output], + }; + let new_state = state.handle_cip68_metadata(&[tx], ®istry).unwrap(); + let record = new_state.info.as_ref().unwrap().get(&asset_id).unwrap(); + + // CIP68 version should be v2 + assert_eq!( + record.metadata.cip68_version, + Some(AssetMetadataStandard::CIP68v2), + "CIP68 version should be set as CIP68v2" + ); + } + #[test] fn get_asset_info_reference_nft_strips_metadata() { let mut registry = AssetRegistry::new(); @@ -1244,8 +1340,8 @@ mod tests { let mut info = state.info.take().unwrap(); let rec = info.get_mut(&ref_id).unwrap(); - rec.onchain_metadata = Some(vec![1, 2, 3]); - rec.metadata_standard = Some(AssetMetadataStandard::CIP68v1); + rec.metadata.cip68_metadata = Some(vec![1, 2, 3]); + rec.metadata.cip68_version = Some(AssetMetadataStandard::CIP68v1); state.info = Some(info); state.supply = Some(imbl::HashMap::new()); @@ -1257,59 +1353,57 @@ mod tests { // Supply unchanged assert_eq!(supply, 42); // Metadata removed for reference asset - assert!(rec.onchain_metadata.is_none()); + assert!(rec.metadata.cip68_metadata.is_none()); // Metadata standard removed for reference asset - assert!(rec.metadata_standard.is_none()); + assert!(rec.metadata.cip68_version.is_none()); } #[test] - fn resolve_cip68_metadata_overwrites_cip25_user_token_metadata() { + fn get_asset_info_resolves_user_token_metadata_from_reference_nft() { let mut registry = AssetRegistry::new(); - let policy_id: PolicyId = [10u8; 28]; - - let user_name = AssetName::new(&[0x00, 0x0d, 0xe1, 0x40, 0xaa]).unwrap(); - let user_id = registry.get_or_insert(policy_id, user_name); - - let mut ref_bytes = user_name.as_slice().to_vec(); - ref_bytes[0..4].copy_from_slice(&[0x00, 0x06, 0x43, 0xb0]); - let ref_name = AssetName::new(&ref_bytes).unwrap(); - let ref_id = registry.get_or_insert(policy_id, ref_name); - - let mut state = State::new(AssetsStorageConfig { - store_info: true, - store_assets: true, - ..Default::default() - }); - let mut info_map = imbl::HashMap::new(); - - let user_record = AssetInfoRecord { - onchain_metadata: Some(vec![1, 2, 3]), - metadata_standard: Some(AssetMetadataStandard::CIP25v1), - ..Default::default() - }; - info_map.insert(user_id, user_record); - - let ref_record = AssetInfoRecord { - onchain_metadata: Some(vec![9, 9, 9]), - metadata_standard: Some(AssetMetadataStandard::CIP68v2), - ..Default::default() - }; - info_map.insert(ref_id, ref_record); + let policy_id: PolicyId = [5u8; 28]; + let asset_name = [0x53, 0x4E, 0x45, 0x4B]; + + let mut user_name = CIP67_LABEL_222.to_vec(); + user_name.extend_from_slice(&asset_name); + let user_token_name = AssetName::new(&user_name).unwrap(); + let user_token_id = registry.get_or_insert(policy_id, user_token_name); + + let mut reference_name = CIP68_LABEL_100.to_vec(); + reference_name.extend_from_slice(&asset_name); + let reference_nft_name = AssetName::new(&reference_name).unwrap(); + let reference_id = registry.get_or_insert(policy_id, reference_nft_name); + + let mut state = State::new(full_config()); + state.info.as_mut().unwrap().insert( + reference_id, + AssetInfoRecord { + initial_mint_tx: dummy_tx_identifier(0), + mint_or_burn_count: 0, + metadata: AssetMetadata { + cip25_metadata: None, + cip25_version: None, + cip68_metadata: Some(vec![1, 2, 3]), + cip68_version: Some(AssetMetadataStandard::CIP68v1), + }, + }, + ); - state.info = Some(info_map); + let resolved = state.resolve_cip68_metadata(&user_token_id, ®istry); - state.supply = Some(imbl::HashMap::new()); - state.supply.as_mut().unwrap().insert(user_id, 100); + let record = resolved.expect("User token should resolve to reference NFT metadata"); - let result = state.get_asset_info(&user_id, ®istry).unwrap().unwrap(); - let (supply, rec) = result; + assert_eq!( + record.metadata.cip68_metadata, + Some(vec![1, 2, 3]), + "User token should inherit CIP68 metadata from reference NFT" + ); - // User asset supply unchanged - assert_eq!(supply, 100); - // User asset metadata overwritten with reference token metadata - assert_eq!(rec.onchain_metadata, Some(vec![9, 9, 9])); - // User asset metadata standard overwritten with reference token metadata standard - assert_eq!(rec.metadata_standard, Some(AssetMetadataStandard::CIP68v2)); + assert_eq!( + record.metadata.cip68_version, + Some(AssetMetadataStandard::CIP68v1), + "User token should inherit CIP68 version from reference NFT" + ); } #[test] diff --git a/modules/rest_blockfrost/src/handlers/addresses.rs b/modules/rest_blockfrost/src/handlers/addresses.rs index 3d800c64..4daf2474 100644 --- a/modules/rest_blockfrost/src/handlers/addresses.rs +++ b/modules/rest_blockfrost/src/handlers/addresses.rs @@ -1,10 +1,12 @@ use std::sync::Arc; -use crate::types::{AddressTotalsREST, TransactionInfoREST, UTxOREST}; +use crate::types::{AddressInfoExtended, AddressTotalsREST, TransactionInfoREST, UTxOREST}; use crate::{handlers_config::HandlersConfig, types::AddressInfoREST}; +use acropolis_common::queries::assets::{AssetsStateQuery, AssetsStateQueryResponse}; use acropolis_common::queries::blocks::{BlocksStateQuery, BlocksStateQueryResponse}; use acropolis_common::queries::errors::QueryError; use acropolis_common::rest_error::RESTError; +use acropolis_common::AssetMetadata; use acropolis_common::{ messages::{Message, RESTResponse, StateQuery, StateQueryResponse}, queries::{ @@ -15,6 +17,8 @@ use acropolis_common::{ Address, Value, }; use caryatid_sdk::Context; +use serde::Serialize; +use serde_cbor::Value as CborValue; /// Handle `/addresses/{address}` Blockfrost-compatible endpoint pub async fn handle_address_single_blockfrost( @@ -115,11 +119,125 @@ pub async fn handle_address_single_blockfrost( /// Handle `/addresses/{address}/extended` Blockfrost-compatible endpoint pub async fn handle_address_extended_blockfrost( - _context: Arc>, - _params: Vec, - _handlers_config: Arc, + context: Arc>, + params: Vec, + handlers_config: Arc, ) -> Result { - Err(RESTError::not_implemented("Address extended endpoint")) + let address = parse_address(¶ms)?; + let stake_address = match address { + Address::Shelley(ref addr) => addr.stake_address_string()?, + _ => None, + }; + + let address_type = address.kind().to_string(); + let is_script = address.is_script(); + + let msg = Arc::new(Message::StateQuery(StateQuery::Addresses( + AddressStateQuery::GetAddressUTxOs { + address: address.clone(), + }, + ))); + + let utxo_identifiers = query_state( + &context, + &handlers_config.addresses_query_topic, + msg, + |message| match message { + Message::StateQueryResponse(StateQueryResponse::Addresses( + AddressStateQueryResponse::AddressUTxOs(utxo_identifiers), + )) => Ok(Some(utxo_identifiers)), + Message::StateQueryResponse(StateQueryResponse::Addresses( + AddressStateQueryResponse::Error(QueryError::NotFound { .. }), + )) => Ok(None), + Message::StateQueryResponse(StateQueryResponse::Addresses( + AddressStateQueryResponse::Error(e), + )) => Err(e), + _ => Err(QueryError::internal_error( + "Unexpected message type while retrieving address UTxOs", + )), + }, + ) + .await?; + + let utxo_identifiers = match utxo_identifiers { + Some(identifiers) => identifiers, + None => { + // Empty address - return zero balance (Blockfrost behavior) + let rest_response = AddressInfoREST { + address: address.to_string()?, + amount: Value { + lovelace: 0, + assets: Vec::new(), + } + .into(), + stake_address, + address_type, + script: is_script, + }; + + let json = serde_json::to_string_pretty(&rest_response)?; + return Ok(RESTResponse::with_json(200, &json)); + } + }; + + let msg = Arc::new(Message::StateQuery(StateQuery::UTxOs( + UTxOStateQuery::GetUTxOsSum { utxo_identifiers }, + ))); + + let address_balance = query_state( + &context, + &handlers_config.utxos_query_topic, + msg, + |message| match message { + Message::StateQueryResponse(StateQueryResponse::UTxOs( + UTxOStateQueryResponse::UTxOsSum(balance), + )) => Ok(balance), + Message::StateQueryResponse(StateQueryResponse::UTxOs( + UTxOStateQueryResponse::Error(e), + )) => Err(e), + _ => Err(QueryError::internal_error( + "Unexpected message type while retrieving UTxO sum", + )), + }, + ) + .await?; + + let assets_query_msg = Arc::new(Message::StateQuery(StateQuery::Assets( + AssetsStateQuery::GetAssetsMetadata { + assets: address_balance.assets.clone(), + }, + ))); + + let assets_metadata = query_state( + &context, + &handlers_config.assets_query_topic, + assets_query_msg, + |message| match message { + Message::StateQueryResponse(StateQueryResponse::Assets( + AssetsStateQueryResponse::AssetsMetadata(balance), + )) => Ok(balance), + Message::StateQueryResponse(StateQueryResponse::Assets( + AssetsStateQueryResponse::Error(e), + )) => Err(e), + _ => Err(QueryError::internal_error( + "Unexpected message type while retrieving assets metadata", + )), + }, + ) + .await?; + + let amount = AmountListExtended::from_value_and_metadata(address_balance, &assets_metadata); + + let rest_response = AddressInfoExtended { + address: address.to_string()?, + amount, + stake_address, + type_: address_type, + script: is_script, + }; + + let json = serde_json::to_string_pretty(&rest_response)?; + Ok(RESTResponse::with_json(200, &json)) } /// Handle `/addresses/{address}/totals` Blockfrost-compatible endpoint @@ -343,3 +461,182 @@ fn parse_address(params: &[String]) -> Result { Ok(Address::from_string(address_str)?) } + +#[derive(Serialize)] +pub struct AmountEntryExtended { + pub unit: String, + pub quantity: String, + pub decimals: Option, + pub has_nft_onchain_metadata: bool, +} + +#[derive(Serialize)] +pub struct AmountListExtended(pub Vec); + +impl AmountListExtended { + pub fn from_value_and_metadata( + value: acropolis_common::Value, + metadata: &[AssetMetadata], + ) -> Self { + let mut out = Vec::new(); + + out.push(AmountEntryExtended { + unit: "lovelace".to_string(), + quantity: value.coin().to_string(), + decimals: Some(6), + has_nft_onchain_metadata: false, + }); + + let mut idx = 0; + + for (policy_id, assets) in &value.assets { + for asset in assets { + let meta = &metadata[idx]; + idx += 1; + + // Blockfrost priority + // 1. Set decimals to null if CIP25 metadata exists (This is an NFT) + // 2. Set decimals based on CIP68 metadata if exists + // 3. Set decimals based on off-chain registry + // 4. Set decimals to null if no CIP68 metadata or off-chain registry entry + + let decimals = if meta.cip25_metadata.is_some() { + None + } else if let Some(raw) = meta.cip68_metadata.as_ref() { + extract_cip68_decimals(raw) + } else { + // TODO: off-chain registry lookup once caching exists + None + }; + + out.push(AmountEntryExtended { + unit: format!( + "{}{}", + hex::encode(policy_id), + hex::encode(asset.name.as_slice()) + ), + quantity: asset.amount.to_string(), + decimals, + has_nft_onchain_metadata: meta.cip25_metadata.is_some(), + }); + } + } + + Self(out) + } +} + +pub fn extract_cip68_decimals(raw: &[u8]) -> Option { + let decoded: CborValue = serde_cbor::from_slice(raw).ok()?; + + let arr = match decoded { + CborValue::Array(a) => a, + _ => return None, + }; + + if arr.len() < 2 { + return None; + } + + let metadata = &arr[0]; + + let map = match metadata { + CborValue::Map(m) => m, + _ => return None, + }; + + for (key, value) in map { + let key_str = match key { + CborValue::Text(s) => s.as_str(), + CborValue::Bytes(b) => std::str::from_utf8(b).ok()?, + _ => continue, + }; + + if key_str == "decimals" { + if let CborValue::Integer(i) = value { + return Some(*i as u64); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use acropolis_common::{AssetName, NativeAsset, NativeAssets, Value}; + + fn make_value(policy_id: [u8; 28], name: Vec, amount: u64) -> Value { + let assets: NativeAssets = vec![( + policy_id, + vec![NativeAsset { + name: AssetName::new(&name).expect("Invalid asset name"), + amount, + }], + )]; + + Value::new(0, assets) + } + + fn make_metadata(cip25: Option>, cip68: Option>) -> AssetMetadata { + AssetMetadata { + cip25_metadata: cip25, + cip68_metadata: cip68, + ..Default::default() + } + } + + #[test] + fn cip25_existence_overrides_decimals() { + use serde_cbor::Value as CborValue; + use std::collections::BTreeMap; + + let mut map = BTreeMap::new(); + map.insert( + CborValue::Text("decimals".to_string()), + CborValue::Integer(18), + ); + + let cbor = serde_cbor::to_vec(&vec![CborValue::Map(map), CborValue::Null]).unwrap(); + + let policy_id = [1u8; 28]; + let value = make_value(policy_id, vec![0x41, 0x42], 100); + + let metadata = vec![make_metadata(Some(vec![1, 2, 3]), Some(cbor))]; + + let list = AmountListExtended::from_value_and_metadata(value, &metadata); + let asset = &list.0[1]; + + // Decimals set to none when CIP25 present + assert!(asset.decimals.is_none()); + + // Onchain metadata flag is set + assert!(asset.has_nft_onchain_metadata); + } + + #[test] + fn cip68_decimals_are_extracted_when_no_cip25() { + use serde_cbor::Value as CborValue; + use std::collections::BTreeMap; + + let mut map = BTreeMap::new(); + map.insert(CborValue::Text("decimals".into()), CborValue::Integer(18)); + + let cbor = serde_cbor::to_vec(&vec![CborValue::Map(map), CborValue::Null]).unwrap(); + + let policy_id = [3u8; 28]; + let value = make_value(policy_id, b"\x99".to_vec(), 999); + + let metadata = vec![make_metadata(None, Some(cbor))]; + + let list = AmountListExtended::from_value_and_metadata(value, &metadata); + let asset = &list.0[1]; + + // Decimals set to value in CIP68 metadata + assert_eq!(asset.decimals, Some(18)); + + // Onchain metadata is false + assert!(!asset.has_nft_onchain_metadata); + } +} diff --git a/modules/rest_blockfrost/src/handlers/assets.rs b/modules/rest_blockfrost/src/handlers/assets.rs index ceb48234..16a0d7b3 100644 --- a/modules/rest_blockfrost/src/handlers/assets.rs +++ b/modules/rest_blockfrost/src/handlers/assets.rs @@ -1,8 +1,8 @@ use crate::{ handlers_config::HandlersConfig, types::{ - AssetAddressRest, AssetInfoRest, AssetMetadata, AssetMintRecordRest, AssetTransactionRest, - PolicyAssetRest, + AssetAddressRest, AssetInfoRest, AssetMetadataREST, AssetMintRecordRest, + AssetTransactionRest, PolicyAssetRest, }, }; use acropolis_common::queries::errors::QueryError; @@ -14,7 +14,7 @@ use acropolis_common::{ utils::query_state, }, serialization::Bech32WithHrp, - AssetMetadataStandard, AssetName, PolicyId, + AssetName, PolicyId, }; use blake2::{digest::consts::U20, Blake2b, Digest}; use caryatid_sdk::Context; @@ -94,7 +94,7 @@ pub async fn handle_asset_single_blockfrost( )) => Ok((quantity, info)), Message::StateQueryResponse(StateQueryResponse::Assets( AssetsStateQueryResponse::Error(QueryError::NotFound { .. }), - )) => Err(QueryError::not_found("Asset")), + )) => Err(QueryError::not_found("Asset not found")), Message::StateQueryResponse(StateQueryResponse::Assets( AssetsStateQueryResponse::Error(e), )) => Err(e), @@ -105,13 +105,16 @@ pub async fn handle_asset_single_blockfrost( ) .await?; - let (onchain_metadata_json, onchain_metadata_extra, cip68_version) = info - .onchain_metadata - .as_ref() - .map(|raw_meta| normalize_onchain_metadata(raw_meta.as_slice())) - .unwrap_or((None, None, None)); - - let onchain_metadata_standard = cip68_version.or(info.metadata_standard); + let (onchain_metadata_json, onchain_metadata_extra, onchain_metadata_standard) = + if let Some(raw) = info.metadata.cip68_metadata.as_ref() { + let (json, extra) = normalize_onchain_metadata(raw); + (json, extra, info.metadata.cip68_version) + } else if let Some(raw) = info.metadata.cip25_metadata.as_ref() { + let (json, _) = normalize_onchain_metadata(raw); + (json, None, info.metadata.cip25_version) + } else { + (None, None, None) + }; // TODO: Query transaction_state once implemented to fetch inital_mint_tx_hash based on TxIdentifier let response = AssetInfoRest { @@ -153,7 +156,7 @@ pub async fn handle_asset_history_blockfrost( )) => Ok(history), Message::StateQueryResponse(StateQueryResponse::Assets( AssetsStateQueryResponse::Error(QueryError::NotFound { .. }), - )) => Err(QueryError::not_found("Asset history")), + )) => Err(QueryError::not_found("Asset history not found")), Message::StateQueryResponse(StateQueryResponse::Assets( AssetsStateQueryResponse::Error(e), )) => Err(e), @@ -189,8 +192,8 @@ pub async fn handle_asset_transactions_blockfrost( AssetsStateQueryResponse::AssetTransactions(txs), )) => Ok(txs), Message::StateQueryResponse(StateQueryResponse::Assets( - AssetsStateQueryResponse::Error(QueryError::NotFound { .. }), - )) => Err(QueryError::not_found("Asset")), + AssetsStateQueryResponse::Error(QueryError::NotFound { resource }), + )) => Err(QueryError::not_found(resource)), Message::StateQueryResponse(StateQueryResponse::Assets( AssetsStateQueryResponse::Error(e), )) => Err(e), @@ -320,7 +323,7 @@ fn split_policy_and_asset(hex_str: &str) -> Result<(PolicyId, AssetName), RESTEr pub async fn fetch_asset_metadata( asset: &str, offchain_registry_url: &str, -) -> Option { +) -> Option { let url = format!("{}{}.json", offchain_registry_url, asset); let client = Client::new(); @@ -348,7 +351,7 @@ pub async fn fetch_asset_metadata( .and_then(|v| v.as_u64()) .and_then(|n| u8::try_from(n).ok()); - Some(AssetMetadata { + Some(AssetMetadataREST { name, description, ticker, @@ -360,12 +363,10 @@ pub async fn fetch_asset_metadata( /// Normalize on-chain metadata for CIP-25 and CIP-68. /// Returns (metadata_json, metadata_extra, cip68_version). -pub fn normalize_onchain_metadata( - raw: &[u8], -) -> (Option, Option, Option) { +pub fn normalize_onchain_metadata(raw: &[u8]) -> (Option, Option) { let decoded: CborValue = match serde_cbor::from_slice(raw) { Ok(val) => val, - Err(_) => return (None, None, None), + Err(_) => return (None, None), }; match decoded { @@ -376,12 +377,6 @@ pub fn normalize_onchain_metadata( // CIP-68 CborValue::Array(mut arr) if arr.len() >= 2 => { let metadata = arr.remove(0); - let version = match arr.remove(0) { - CborValue::Integer(1) => Some(AssetMetadataStandard::CIP68v1), - CborValue::Integer(2) => Some(AssetMetadataStandard::CIP68v2), - CborValue::Integer(3) => Some(AssetMetadataStandard::CIP68v3), - _ => Some(AssetMetadataStandard::CIP68v1), - }; let extra = arr.pop().unwrap_or(CborValue::Array(vec![])); let json_meta = match metadata { @@ -406,7 +401,7 @@ pub fn normalize_onchain_metadata( .ok() .map(hex::encode) .filter(|val| !matches!(val.as_str(), "80" | "f6")); - (json_meta, extra_hex, version) + (json_meta, extra_hex) } // CIP-25: plain map @@ -417,10 +412,10 @@ pub fn normalize_onchain_metadata( obj.insert(key, cbor_to_json(v)); } } - (Some(Value::Object(obj)), None, None) + (Some(Value::Object(obj)), None) } - _ => (None, None, None), + _ => (None, None), } } diff --git a/modules/rest_blockfrost/src/types.rs b/modules/rest_blockfrost/src/types.rs index 405a798c..4ec9f0e9 100644 --- a/modules/rest_blockfrost/src/types.rs +++ b/modules/rest_blockfrost/src/types.rs @@ -1,4 +1,7 @@ -use crate::cost_models::{PLUTUS_V1, PLUTUS_V2, PLUTUS_V3}; +use crate::{ + cost_models::{PLUTUS_V1, PLUTUS_V2, PLUTUS_V3}, + handlers::addresses::AmountListExtended, +}; use acropolis_common::{ messages::EpochActivityMessage, protocol_params::{Nonce, NonceVariant, ProtocolParams}, @@ -729,11 +732,11 @@ pub struct AssetInfoRest { pub onchain_metadata: Option, pub onchain_metadata_standard: Option, pub onchain_metadata_extra: Option, - pub metadata: Option, + pub metadata: Option, } #[derive(Serialize, Clone)] -pub struct AssetMetadata { +pub struct AssetMetadataREST { pub name: String, pub description: String, pub ticker: Option, @@ -999,6 +1002,15 @@ pub struct AddressTotalsREST { pub tx_count: u64, } +#[derive(serde::Serialize)] +pub struct AddressInfoExtended { + pub address: String, + pub amount: AmountListExtended, + pub stake_address: Option, + pub type_: String, + pub script: bool, +} + #[derive(Serialize)] pub struct TransactionInfoREST { pub tx_hash: String,