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.
291 lines
9.7 KiB
Rust
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());
|
|
}
|
|
}
|