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:
parent
2f3d975c0f
commit
a93a2b7cfa
7 changed files with 450 additions and 20 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
14
Cargo.toml
14
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" }
|
||||
|
|
|
|||
|
|
@ -38,3 +38,4 @@ cryptoxide = { workspace = true }
|
|||
zeroize = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
291
crates/aldabra-core/src/metadata.rs
Normal file
291
crates/aldabra-core/src/metadata.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"))?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue