From a93a2b7cfaa7b369b59481acfbaff938abf328ca Mon Sep 17 00:00:00 2001 From: Cobb Date: Mon, 4 May 2026 12:11:11 -0700 Subject: [PATCH] phase 3.2: cip-25 metadata via the pallas fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unblocks named mints. wallet.mint now accepts an optional `metadata` arg (json object); explorers + wallets render the asset with name/image instead of . new aldabra-core::metadata module: - json_to_metadatum: serde_json::Value → Metadatum (recursive). numbers must fit i64 (cardano metadata Int width). strings >64 bytes split into Array chunks at utf-8 char boundaries (CIP-25 v2 long-string convention). null is rejected. - build_cip25_aux_data(policy_id_hex, asset_name_hex, json_value): builds the label-721 wrapper (Map { 721: Map { policy_bytes: Map { name_bytes: attrs }, "version": "2.0" } }), wraps in AuxiliaryData::PostAlonzo, returns cbor bytes. mint module: - new build_signed_mint_with_metadata + build_unsigned_mint now take optional cip25_metadata. backward-compat: build_signed_mint is a thin no-metadata wrapper. - prepare_mint + build_mint_staging plumb aux_data_cbor through. staging.auxiliary_data(bytes) is the new fork API surface — when set, conway::build_conway_raw decodes + computes auxiliary_data_hash automatically. - regression test build_signed_mint_with_metadata_produces_aux_hash: decodes the resulting signed cbor, asserts both body.auxiliary_data_hash is Some and tx.auxiliary_data is present. catches the failure mode where metadata is silently dropped. mcp wallet.mint gains a `metadata` arg field surfaced via schemars JsonSchema. tools/list shape correctly carries the optional json object. depends on Sulkta-Coop/pallas@feat-aux-data — vendored via [patch.crates-io] in the workspace Cargo.toml. PR upstream pending. 56 → 65 unit tests. 8 → 8 mcp tools (count unchanged, wallet.mint gained an arg). --- Cargo.lock | 22 +-- Cargo.toml | 14 ++ crates/aldabra-core/Cargo.toml | 1 + crates/aldabra-core/src/lib.rs | 4 +- crates/aldabra-core/src/metadata.rs | 291 ++++++++++++++++++++++++++++ crates/aldabra-core/src/mint.rs | 121 +++++++++++- crates/aldabra-mcp/src/tools.rs | 17 +- 7 files changed, 450 insertions(+), 20 deletions(-) create mode 100644 crates/aldabra-core/src/metadata.rs diff --git a/Cargo.lock b/Cargo.lock index 4d22bfe..5655395 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,7 @@ dependencies = [ "pallas-txbuilder", "pallas-wallet", "serde", + "serde_json", "thiserror 1.0.69", "zeroize", ] @@ -1250,8 +1251,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "pallas-addresses" version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a7e0425ec22afe8e80c9f9dfb086cbad569fd2ba3e51d6ab8caa20423b7488" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" dependencies = [ "base58", "bech32", @@ -1266,8 +1266,7 @@ dependencies = [ [[package]] name = "pallas-codec" version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e344b3e39ca3bd79bb7547b65b980869c3c377a00c48ece70430f4611c32a18b" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" dependencies = [ "hex", "minicbor", @@ -1278,8 +1277,7 @@ dependencies = [ [[package]] name = "pallas-crypto" version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59c89ea16190a87a1d8bd36923093740a2b659ed6129f4636329319a70cc4db3" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" dependencies = [ "cryptoxide", "hex", @@ -1293,8 +1291,7 @@ dependencies = [ [[package]] name = "pallas-primitives" version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1912f4f4a0719e36ac061f7f3557b687e8ef7285b573608fb5c71eba64c1b04c" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" dependencies = [ "base58", "bech32", @@ -1309,8 +1306,7 @@ dependencies = [ [[package]] name = "pallas-traverse" version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be7fbb1db75a0b6b32d1808b2cc5c7ba6dd261f289491bb86998b987b4716883" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" dependencies = [ "hex", "itertools", @@ -1326,8 +1322,7 @@ dependencies = [ [[package]] name = "pallas-txbuilder" version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fff83ae515a88b1ecf5354468d9fd3562d915e5eceb5c9467f6b1cdce60a3e9a" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" dependencies = [ "hex", "pallas-addresses", @@ -1344,8 +1339,7 @@ dependencies = [ [[package]] name = "pallas-wallet" version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "086f428e68ab513a0445c23a345cd462dc925e37626f72f1dbb7276919f68bfa" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" dependencies = [ "bech32", "bip39", diff --git a/Cargo.toml b/Cargo.toml index 3deccd1..cb4d3b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,3 +91,17 @@ toml = "0.9" # Hidden-input passphrase prompts for the mnemonic bootstrap CLI. # rpassword is the standard "tty echo off" prompt crate. rpassword = "7" + +# Vendored fork of txpipe/pallas with auxiliary_data support added to +# pallas-txbuilder (upstream had TODO markers we filled in). Patches +# all pallas-* crates so the version graph resolves consistently +# against the same commit. PR upstream pending; switch back to +# crates.io once merged. +[patch.crates-io] +pallas-codec = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-crypto = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-primitives = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-traverse = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-addresses = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-wallet = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-txbuilder = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" } diff --git a/crates/aldabra-core/Cargo.toml b/crates/aldabra-core/Cargo.toml index 00538c8..422025a 100644 --- a/crates/aldabra-core/Cargo.toml +++ b/crates/aldabra-core/Cargo.toml @@ -38,3 +38,4 @@ cryptoxide = { workspace = true } zeroize = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index dd534bd..eaeea05 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -36,10 +36,12 @@ use thiserror::Error; use zeroize::ZeroizeOnDrop; pub mod derive; +pub mod metadata; pub mod mint; pub mod tx; pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey}; -pub use mint::{build_signed_mint, build_unsigned_mint, PolicySpec}; +pub use metadata::{build_cip25_aux_data, CIP25_LABEL}; +pub use mint::{build_signed_mint, build_signed_mint_with_metadata, build_unsigned_mint, PolicySpec}; pub use tx::{ build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment, build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary, diff --git a/crates/aldabra-core/src/metadata.rs b/crates/aldabra-core/src/metadata.rs new file mode 100644 index 0000000..98eab5c --- /dev/null +++ b/crates/aldabra-core/src/metadata.rs @@ -0,0 +1,291 @@ +//! 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": { +//! "": { +//! "": { +//! "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`. 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` +/// 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 = 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 { + 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 `` 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, 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: { -> 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_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, WalletError> { + if s.len() % 2 != 0 { + 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()); + } +} diff --git a/crates/aldabra-core/src/mint.rs b/crates/aldabra-core/src/mint.rs index 5056f3e..aeb685f 100644 --- a/crates/aldabra-core/src/mint.rs +++ b/crates/aldabra-core/src/mint.rs @@ -221,6 +221,7 @@ const WITNESS_OVERHEAD_BYTES: u64 = 128; /// final BuiltTransaction + summary. Mint version of /// `tx::prepare_payment`. Adds: mint-asset entry, native-script /// witness, disclosed signer. +#[allow(clippy::too_many_arguments)] fn prepare_mint( network: Network, available_utxos: &[InputUtxo], @@ -231,6 +232,7 @@ fn prepare_mint( asset_name_hex: &str, mint_quantity: i64, payment_pkh: Hash<28>, + aux_data_cbor: Option<&[u8]>, params: &ProtocolParams, ) -> Result<(BuiltTransaction, PaymentSummary), WalletError> { let dest_addr = parse_address(dest_address_bech32)?; @@ -327,6 +329,7 @@ fn prepare_mint( mint_quantity, &script_cbor, payment_pkh, + aux_data_cbor, )?; let unsigned = staging1 .build_conway_raw() @@ -371,6 +374,7 @@ fn prepare_mint( mint_quantity, &script_cbor, payment_pkh, + aux_data_cbor, )?; let built = staging2 .build_conway_raw() @@ -427,6 +431,7 @@ fn build_mint_staging( mint_quantity: i64, script_cbor: &[u8], payment_pkh: Hash<28>, + aux_data_cbor: Option<&[u8]>, ) -> Result { let mut staging = StagingTransaction::new(); for u in inputs { @@ -489,10 +494,14 @@ fn build_mint_staging( .fee(fee) .network_id(network_id); + if let Some(aux) = aux_data_cbor { + staging = staging.auxiliary_data(aux.to_vec()); + } + Ok(staging) } -/// Build + sign a mint TX. +/// Build + sign a mint TX (no metadata). /// /// `mint_quantity > 0` mints, `mint_quantity < 0` burns. Burning /// requires the wallet's UTXOs hold ≥ |quantity| of the asset. @@ -512,9 +521,57 @@ pub fn build_signed_mint( asset_name_hex: &str, mint_quantity: i64, params: &ProtocolParams, +) -> Result, WalletError> { + build_signed_mint_with_metadata( + payment_key, + network, + available_utxos, + change_address_bech32, + dest_address_bech32, + dest_lovelace, + policy, + asset_name_hex, + mint_quantity, + None, + params, + ) +} + +/// Build + sign a mint TX with optional CIP-25 metadata. +/// +/// If `cip25_metadata` is `Some(json_value)`, the JSON object's +/// fields become the asset's CIP-25 v2 attributes (`name`, `image`, +/// `description`, etc.) and ride along in the tx's auxiliary data. +/// Wallets and explorers will then show the asset with its name + +/// image instead of as ``. +#[allow(clippy::too_many_arguments)] +pub fn build_signed_mint_with_metadata( + payment_key: &PaymentKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + dest_address_bech32: &str, + dest_lovelace: u64, + policy: &PolicySpec, + asset_name_hex: &str, + mint_quantity: i64, + cip25_metadata: Option<&serde_json::Value>, + params: &ProtocolParams, ) -> Result, WalletError> { let private = payment_key_to_private(payment_key)?; let payment_pkh = payment_key.public_key_hash(); + let aux_bytes = match cip25_metadata { + Some(meta) => { + let policy_id = policy.policy_id()?; + let pol_hex = hash_to_hex(&policy_id); + Some(crate::metadata::build_cip25_aux_data( + &pol_hex, + asset_name_hex, + meta, + )?) + } + None => None, + }; let (built, _summary) = prepare_mint( network, available_utxos, @@ -525,6 +582,7 @@ pub fn build_signed_mint( asset_name_hex, mint_quantity, payment_pkh, + aux_bytes.as_deref(), params, )?; let signed = built @@ -546,9 +604,22 @@ pub fn build_unsigned_mint( policy: &PolicySpec, asset_name_hex: &str, mint_quantity: i64, + cip25_metadata: Option<&serde_json::Value>, params: &ProtocolParams, ) -> Result { let payment_pkh = parse_pkh(payment_pkh_hex)?; + let aux_bytes = match cip25_metadata { + Some(meta) => { + let policy_id = policy.policy_id()?; + let pol_hex = hash_to_hex(&policy_id); + Some(crate::metadata::build_cip25_aux_data( + &pol_hex, + asset_name_hex, + meta, + )?) + } + None => None, + }; let (built, summary) = prepare_mint( network, available_utxos, @@ -559,6 +630,7 @@ pub fn build_unsigned_mint( asset_name_hex, mint_quantity, payment_pkh, + aux_bytes.as_deref(), params, )?; let mut cbor_hex = String::with_capacity(built.tx_bytes.0.len() * 2); @@ -722,4 +794,51 @@ mod tests { assert!(parse_pkh("ab").is_err()); assert!(parse_pkh(&"ee".repeat(28)).is_ok()); } + + #[test] + fn build_signed_mint_with_metadata_produces_aux_hash() { + use pallas_primitives::Fragment; + let payment = payment_from_canonical(); + let change = change_address(Network::Preprod); + let policy = PolicySpec::single_sig(&payment); + let utxos = vec![InputUtxo { + tx_hash_hex: "deadbeef".repeat(8), + output_index: 0, + lovelace: 100_000_000, + assets: Default::default(), + }]; + let metadata = serde_json::json!({ + "name": "ALDABRA_TEST", + "image": "ipfs://QmTestHashGoesHere", + "description": "Sulkta test mint", + }); + let cbor = build_signed_mint_with_metadata( + &payment, + Network::Preprod, + &utxos, + &change, + &to_address_preprod(), + 2_000_000, + &policy, + "414c44414252415f54455354", // "ALDABRA_TEST" + 1, + Some(&metadata), + &ProtocolParams::default(), + ) + .expect("metadata mint builds + signs"); + + // Decode the resulting tx and confirm: + // 1. aux_data is present + // 2. body.auxiliary_data_hash is populated + let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor) + .expect("decode signed mint cbor"); + assert!( + tx.transaction_body.auxiliary_data_hash.is_some(), + "aux_data_hash must be set when metadata is attached" + ); + match tx.auxiliary_data { + pallas_codec::utils::Nullable::Some(_) => {} + other => panic!("expected aux data, got {other:?}"), + } + } } diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 3dd56a0..2a426ad 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -26,8 +26,9 @@ use std::sync::Arc; use aldabra_chain::{ChainBackend, KoiosClient}; use aldabra_core::{ - build_signed_mint, build_signed_payment_with_assets, build_unsigned_payment_with_assets, - hex_decode, AssetSpec, InputUtxo, Network, PaymentKey, PolicySpec, ProtocolParams, + build_signed_mint_with_metadata, build_signed_payment_with_assets, + build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, Network, PaymentKey, + PolicySpec, ProtocolParams, }; use rmcp::{model::ServerInfo, schemars, tool, ServerHandler}; use serde::Deserialize; @@ -156,6 +157,12 @@ pub struct MintArgs { /// to the wallet's payment key. #[serde(default)] pub invalid_after_slot: Option, + /// Optional CIP-25 v2 metadata: a JSON object with the asset's + /// per-attributes (`name`, `image`, `description`, `mediaType`, + /// `files`, etc.). Wallets and explorers display this when + /// rendering the asset. + #[serde(default)] + pub metadata: Option, } #[tool(tool_box)] @@ -394,7 +401,7 @@ impl WalletService { #[tool( name = "wallet.mint", - description = "Mint or burn a native asset under a wallet-generated single-sig policy. Args: dest_address, dest_lovelace (ADA to attach to the mint output, ≥ ~1.5 ADA for an asset-bearing utxo), asset_name_hex, quantity (positive=mint, negative=burn), invalid_after_slot (optional). Returns the tx hash on success. NB: this version does not attach CIP-25 metadata — pallas-txbuilder 0.32 doesn't surface auxiliary_data yet." + description = "Mint or burn a native asset under a wallet-generated single-sig policy, optionally with CIP-25 v2 metadata. Args: dest_address, dest_lovelace (≥ 1.5 ADA for asset-bearing UTXO), asset_name_hex, quantity (positive=mint, negative=burn), invalid_after_slot (optional), metadata (optional CIP-25 JSON object: {name, image, description, mediaType, files, ...}). Returns the tx hash on success." )] async fn wallet_mint( &self, @@ -404,6 +411,7 @@ impl WalletService { asset_name_hex, quantity, invalid_after_slot, + metadata, }: MintArgs, ) -> Result { if quantity == 0 { @@ -443,7 +451,7 @@ impl WalletService { None => PolicySpec::single_sig(&self.inner.payment_key), }; - let cbor = build_signed_mint( + let cbor = build_signed_mint_with_metadata( &self.inner.payment_key, self.inner.network, &inputs, @@ -453,6 +461,7 @@ impl WalletService { &policy, &asset_name_hex, quantity, + metadata.as_ref(), &ProtocolParams::default(), ) .map_err(|e| format!("build/sign mint: {e}"))?;