aldabra/crates/aldabra-core/src/metadata.rs
Sulkta 6ed378a486 audit-3 (code cleanup): zero clippy warnings, zero build warnings
prep for deployment. cargo clippy --workspace --all-targets now passes
clean. cargo audit unchanged (same 2 unmaintained-warning macro-support
transitives; no cves).

cleanup applied:
- ProtocolParams construction in tools.rs uses struct-update syntax
  (clippy::field_reassign_with_default).
- main.rs collapsed two else-if branches with identical bodies
  (clippy::if_same_then_else).
- mint/plutus/stake sort_by(|a,b| b.cmp(&a)) → sort_by_key(Reverse(_))
  (clippy::manual_sort_by). 4 sites.
- metadata/mint/tx odd-length hex check uses .is_multiple_of(2)
  (clippy::manual_is_multiple_of). 3 sites.
- stake.rs witness_overhead conditional removed — both branches
  produced TWO_WITNESS_OVERHEAD_BYTES (left over from when
  registration was thought to add a third witness; it doesn't).
  WITNESS_OVERHEAD_BYTES const removed (only the two-witness one
  is used).
- Public spend/mint/stake build_signed_*_with_assets fns get
  #[allow(clippy::too_many_arguments)] — they ARE the API surface.
- ex_units_default_is_generous test gets explicit allow for the
  tautological-on-const assertion (kept the intent comment).

97 unit tests still pass. release build clean.
2026-05-04 18:40:35 -07:00

291 lines
9.7 KiB
Rust

//! CIP-25 transaction metadata for native-asset minting.
//!
//! [CIP-25 v2](https://cips.cardano.org/cip/CIP-0025) is the legacy NFT
//! metadata standard. Every Cardano wallet and explorer (Eternl,
//! Yoroi, Pool.pm, ADAStat, etc.) reads this label-721 shape, so any
//! mint we want to be human-named goes through here.
//!
//! ## Shape
//!
//! ```json
//! {
//! "721": {
//! "<policy_id_hex>": {
//! "<asset_name_utf8>": {
//! "name": "...",
//! "image": "ipfs://...",
//! "mediaType": "image/png",
//! "description": "...",
//! "files": [...]
//! }
//! },
//! "version": "2.0"
//! }
//! }
//! ```
//!
//! Cardano metadata Text values cap at 64 bytes — longer strings get
//! split into `Array<Text>`. We do that splitting here.
use pallas_codec::minicbor;
use pallas_codec::utils::KeyValuePairs;
use pallas_primitives::alonzo::{AuxiliaryData, Metadata, Metadatum, PostAlonzoAuxiliaryData};
use serde_json::Value;
use crate::WalletError;
/// CIP-25 label = 721.
pub const CIP25_LABEL: u64 = 721;
/// Cardano protocol limit on a single Metadatum::Text value.
const MAX_TEXT_BYTES: usize = 64;
/// Convert a string of arbitrary length into a Metadatum: short
/// strings fit in `Text(s)`; longer strings split into `Array<Text>`
/// chunks of ≤64 bytes (CIP-25 convention).
fn string_to_metadatum(s: &str) -> Metadatum {
if s.len() <= MAX_TEXT_BYTES {
Metadatum::Text(s.to_string())
} else {
let mut chunks: Vec<Metadatum> = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
// Find a UTF-8 boundary within MAX_TEXT_BYTES of i.
let mut end = (i + MAX_TEXT_BYTES).min(bytes.len());
while end > i && !s.is_char_boundary(end) {
end -= 1;
}
chunks.push(Metadatum::Text(s[i..end].to_string()));
i = end;
}
Metadatum::Array(chunks)
}
}
/// Convert a `serde_json::Value` into a `Metadatum`. Strings get
/// long-string-split. Numbers must fit `i128` (Cardano metadata `Int`
/// width). Booleans render as `0`/`1`. `null` returns an error.
fn json_to_metadatum(v: &Value) -> Result<Metadatum, WalletError> {
use minicbor::data::Int as CborInt;
use pallas_codec::utils::Int;
match v {
Value::Null => Err(WalletError::Derivation(
"null is not representable in Cardano metadata".into(),
)),
Value::Bool(b) => Ok(Metadatum::Int(Int(CborInt::from(if *b { 1i64 } else { 0 })))),
Value::Number(n) => {
let i = n.as_i64().ok_or_else(|| {
WalletError::Derivation(format!(
"metadata number {n} doesn't fit i64; floats unsupported"
))
})?;
Ok(Metadatum::Int(Int(CborInt::from(i))))
}
Value::String(s) => Ok(string_to_metadatum(s)),
Value::Array(arr) => {
let mut out = Vec::with_capacity(arr.len());
for item in arr {
out.push(json_to_metadatum(item)?);
}
Ok(Metadatum::Array(out))
}
Value::Object(map) => {
let mut pairs: Vec<(Metadatum, Metadatum)> = Vec::with_capacity(map.len());
for (k, vv) in map {
let key = string_to_metadatum(k);
let value = json_to_metadatum(vv)?;
pairs.push((key, value));
}
Ok(Metadatum::Map(KeyValuePairs::from(pairs)))
}
}
}
/// Build a CIP-25 v2 auxiliary-data payload, ready to attach via
/// `pallas_txbuilder::StagingTransaction::auxiliary_data`.
///
/// `metadata` is the inner per-asset attributes (everything that goes
/// underneath `<asset_name>` in the spec) — typically `{"name": ...,
/// "image": ..., "description": ..., "mediaType": ..., "files": [...]}`.
///
/// `asset_name_hex` is the hex of the raw asset-name bytes. CIP-25 v2
/// uses the *raw bytes* as the JSON key (the spec calls this
/// `asset_name`, not its UTF-8 decoding) — but historically wallets
/// expected UTF-8 decoded names. v2 added the `version: "2.0"` marker
/// to disambiguate. We emit v2 with raw-byte keys; UTF-8 names render
/// identically in v2-aware wallets.
pub fn build_cip25_aux_data(
policy_id_hex: &str,
asset_name_hex: &str,
metadata: &Value,
) -> Result<Vec<u8>, WalletError> {
if !metadata.is_object() {
return Err(WalletError::Derivation(
"CIP-25 metadata must be a JSON object (per-asset attributes)".into(),
));
}
// Decode the asset name bytes; CIP-25 v2 uses the raw bytes as the
// metadata key under the policy.
let asset_name_bytes = decode_hex(asset_name_hex)?;
let asset_attributes = json_to_metadatum(metadata)?;
// Inner: { <asset_name_bytes> -> attributes_map }
let policy_inner = Metadatum::Map(KeyValuePairs::from(vec![(
Metadatum::Bytes(asset_name_bytes.into()),
asset_attributes,
)]));
// Decode the policy id and use it as a Bytes key per CIP-25 v2.
let policy_id_bytes = decode_hex(policy_id_hex)?;
if policy_id_bytes.len() != 28 {
return Err(WalletError::Derivation(format!(
"policy_id must be 28 bytes (56 hex chars), got {}",
policy_id_bytes.len()
)));
}
// Wrapper: { <policy_id_bytes> -> policy_inner, "version" -> "2.0" }
let label_inner = Metadatum::Map(KeyValuePairs::from(vec![
(Metadatum::Bytes(policy_id_bytes.into()), policy_inner),
(
Metadatum::Text("version".into()),
Metadatum::Text("2.0".into()),
),
]));
// Top: { 721 -> label_inner }
let metadata_top: Metadata = vec![(CIP25_LABEL, label_inner)].into();
let aux = AuxiliaryData::PostAlonzo(PostAlonzoAuxiliaryData {
metadata: Some(metadata_top),
native_scripts: None,
plutus_scripts: None,
});
minicbor::to_vec(&aux)
.map_err(|e| WalletError::Derivation(format!("encode CIP-25 aux: {e}")))
}
fn decode_hex(s: &str) -> Result<Vec<u8>, WalletError> {
if !s.len().is_multiple_of(2) {
return Err(WalletError::Derivation("hex string odd length".into()));
}
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
out.push(
u8::from_str_radix(&s[i..i + 2], 16)
.map_err(|_| WalletError::Derivation(format!("invalid hex: {s}")))?,
);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn short_string_fits_in_text() {
let m = string_to_metadatum("hello");
assert!(matches!(m, Metadatum::Text(ref s) if s == "hello"));
}
#[test]
fn long_string_splits_into_array() {
let long = "a".repeat(150);
let m = string_to_metadatum(&long);
match m {
Metadatum::Array(chunks) => {
assert_eq!(chunks.len(), 3); // 64 + 64 + 22
for chunk in &chunks {
if let Metadatum::Text(s) = chunk {
assert!(s.len() <= MAX_TEXT_BYTES);
} else {
panic!("expected Text chunk");
}
}
}
other => panic!("expected Array, got {other:?}"),
}
}
#[test]
fn long_utf8_string_splits_at_char_boundary() {
// 🐢 is 4 bytes — must not split mid-codepoint.
let s = "🐢".repeat(20); // 80 bytes
let m = string_to_metadatum(&s);
match m {
Metadatum::Array(chunks) => {
for chunk in chunks {
if let Metadatum::Text(t) = chunk {
// Every chunk must round-trip through utf-8.
assert!(std::str::from_utf8(t.as_bytes()).is_ok());
}
}
}
_ => panic!("expected Array for long string"),
}
}
#[test]
fn json_object_to_metadatum() {
let v = json!({
"name": "Aldabra Tortoise",
"image": "ipfs://QmXyz",
"supply": 250,
});
let m = json_to_metadatum(&v).unwrap();
match m {
Metadatum::Map(_) => {}
other => panic!("expected Map, got {other:?}"),
}
}
#[test]
fn json_null_is_rejected() {
let v = json!(null);
assert!(json_to_metadatum(&v).is_err());
}
#[test]
fn build_cip25_round_trips_through_cbor() {
let metadata = json!({
"name": "ALDABRA_TEST",
"image": "ipfs://QmTestHashGoesHere",
"description": "Sulkta test mint",
});
let policy_id = "ee".repeat(28);
let asset_name_hex = "414c44414252415f54455354"; // "ALDABRA_TEST"
let bytes = build_cip25_aux_data(&policy_id, asset_name_hex, &metadata).unwrap();
// Decode back to verify shape.
let aux: AuxiliaryData = minicbor::decode(&bytes).expect("decode aux");
match aux {
AuxiliaryData::PostAlonzo(pa) => {
let meta = pa.metadata.expect("metadata present");
assert_eq!(meta.len(), 1);
let (label, _value) = &meta[0];
assert_eq!(*label, CIP25_LABEL);
}
other => panic!("expected PostAlonzo, got {other:?}"),
}
}
#[test]
fn build_cip25_rejects_non_object_root() {
let v = json!("not an object");
let r = build_cip25_aux_data(&"ee".repeat(28), "deadbeef", &v);
assert!(r.is_err());
}
#[test]
fn build_cip25_rejects_bad_policy_id() {
let metadata = json!({"name": "x"});
let r = build_cip25_aux_data("abcd", "deadbeef", &metadata);
assert!(r.is_err());
}
}