phase 3.2: cip-25 metadata via the pallas fork

unblocks named mints. wallet.mint now accepts an optional `metadata`
arg (json object); explorers + wallets render the asset with name/image
instead of <asset1xyz...>.

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<Text> 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).
This commit is contained in:
Cobb 2026-05-04 12:11:11 -07:00
parent 2f3d975c0f
commit a93a2b7cfa
7 changed files with 450 additions and 20 deletions

22
Cargo.lock generated
View file

@ -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",

View file

@ -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" }

View file

@ -38,3 +38,4 @@ cryptoxide = { workspace = true }
zeroize = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View file

@ -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,

View file

@ -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": {
//! "<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() % 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());
}
}

View file

@ -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<StagingTransaction, WalletError> {
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<Vec<u8>, 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 `<asset1xyz...>`.
#[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<Vec<u8>, 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<UnsignedPayment, WalletError> {
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:?}"),
}
}
}

View file

@ -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<u64>,
/// 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<serde_json::Value>,
}
#[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<String, String> {
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}"))?;