phase 3.1, 3.4, 3.5: native policy + mint path (no metadata yet)

new aldabra-core::mint module:
- PolicySpec enum: SingleSig, SingleSigTimelock, NofK
  - SingleSig{pkh}: ScriptPubkey native script
  - SingleSigTimelock{pkh, slot}: ScriptAll[ScriptPubkey, InvalidHereafter(slot)]
  - NofK{n, [pkhs]}: ScriptNOfK
- PolicySpec::single_sig(payment) + single_sig_timelock(payment, slot)
  convenience constructors that derive the pkh from a PaymentKey.
- policy_id() = pallas_traverse::ComputeHash<28>::compute_hash, which
  is blake2b-224 of (0x00 || cbor) — the canonical native-script hash.
- to_cbor() for callers that want the script bytes raw.

build_signed_mint / build_unsigned_mint:
- two-pass fee like the send path, plus a few extras specific to mint:
  staging.mint_asset(policy, name, qty), .script(Native, cbor),
  .disclosed_signer(payment_pkh) — the disclosed_signer surfaces the
  required signature in the tx body so the chain knows which witness
  to verify against the script.
- positive qty mints (asset goes into dest output), negative qty burns
  (asset comes out of input holdings, change preserves leftover).
- token-bearing change must hold ≥ min_utxo lovelace — same guard as
  the send path.

mcp tools:
- wallet.policy.create — args: invalid_after_slot? — returns
  {policy_id_hex, script_cbor_hex, type}.
- wallet.mint — args: dest_address, dest_lovelace (≥ 1 ADA),
  asset_name_hex, quantity (i64), invalid_after_slot? — auto-generates
  a single-sig policy bound to the wallet's payment key, builds, signs,
  submits.

8 → 10 mcp tools. 48 → 56 unit tests.

3.2 (CIP-25 metadata) is BLOCKED on pallas-txbuilder 0.32/0.35 — both
hardcode `auxiliary_data: None` in the conway builder. options for next
session: (a) post-build CBOR injection, (b) assemble tx via
pallas-primitives directly, (c) wait for upstream. flagged in the
spec doc.

3.3 (CIP-68) depends on 3.2. 3.6 (MAP 2-of-2) needs the multi-key
signing flow on the build side; PolicySpec::NofK variant is ready but
build_signed_mint only sign with one key today.
This commit is contained in:
Cobb 2026-05-04 11:44:16 -07:00
parent 46b6f6efa3
commit 2f3d975c0f
6 changed files with 867 additions and 3 deletions

1
Cargo.lock generated
View file

@ -87,6 +87,7 @@ dependencies = [
"pallas-codec",
"pallas-crypto",
"pallas-primitives",
"pallas-traverse",
"pallas-txbuilder",
"pallas-wallet",
"serde",

View file

@ -40,6 +40,7 @@ pallas-crypto = "0.32"
pallas-addresses = "0.32"
pallas-txbuilder = "0.32"
pallas-wallet = "0.32"
pallas-traverse = "0.32"
pallas-network = "0.32"
# Mnemonic + key derivation.

View file

@ -31,6 +31,7 @@ pallas-crypto = { workspace = true }
pallas-addresses = { workspace = true }
pallas-txbuilder = { workspace = true }
pallas-wallet = { workspace = true }
pallas-traverse = { workspace = true }
bip39 = { workspace = true }
ed25519-bip32 = { workspace = true }
cryptoxide = { workspace = true }

View file

@ -36,8 +36,10 @@ use thiserror::Error;
use zeroize::ZeroizeOnDrop;
pub mod derive;
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 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,725 @@
//! Native-asset minting (Phase 3).
//!
//! ## What
//!
//! - Construct a Cardano native script (CIP-1854): single-sig,
//! single-sig-with-timelock, n-of-k multisig.
//! - Compute the script's policy ID (`blake2b-224(0x00 || cbor)`).
//! - Build a signed Conway-era mint transaction that issues N of an
//! asset under that policy and sends them to a destination
//! address.
//!
//! ## Phase 3 scope today
//!
//! - **Single-sig** + **single-sig with `invalid_after`** policy
//! shapes — the bread-and-butter wallet-controlled mint.
//! - **n-of-k multisig** policy shape (untested at the build-tx
//! layer; the script construction is solid but the mint flow
//! below assumes the wallet's payment key alone is sufficient
//! to satisfy the script).
//! - **No metadata.** pallas-txbuilder 0.32/0.35 doesn't surface
//! `auxiliary_data` through its public API yet (it's a hardcoded
//! `None` in the conway builder), so CIP-25 / CIP-68 metadata
//! needs either a post-build CBOR injection pass or a direct
//! pallas-primitives tx assembly. Both are follow-up work.
//!
//! ## Burn vs mint
//!
//! `quantity > 0` mints, `quantity < 0` burns. Burns require the
//! caller to actually hold the assets at one of the input UTXOs.
use pallas_codec::minicbor;
use pallas_crypto::hash::Hash;
use pallas_primitives::alonzo::NativeScript;
use pallas_traverse::ComputeHash;
use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction};
use pallas_wallet::PrivateKey;
use serde::{Deserialize, Serialize};
use crate::tx::{InputUtxo, PaymentSummary, ProtocolParams, UnsignedPayment};
use crate::{Network, PaymentKey, WalletError};
/// High-level policy descriptor — abstracts the
/// `pallas_primitives::alonzo::NativeScript` enum so callers don't
/// need to touch pallas types directly.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PolicySpec {
/// Pure single-signature policy — anyone with the payment key
/// can mint or burn.
SingleSig {
/// 28-byte hex of the required signer's blake2b-224
/// payment-key hash.
signer_pkh_hex: String,
},
/// Single-sig that becomes invalid after a specific slot.
/// Stops minting / locks supply. Cardano's idiomatic "minted
/// supply is permanent" pattern.
SingleSigTimelock {
signer_pkh_hex: String,
invalid_after_slot: u64,
},
/// n-of-k multisig — k signers, any n satisfy the script.
/// Smaller `n` = looser script. ADAMaps treasury minting is
/// the canonical use case (2-of-2).
NofK {
/// Required signature count.
n: u32,
/// Hex-encoded payment-key hashes of the k signers.
signer_pkhs_hex: Vec<String>,
},
}
fn parse_pkh(hex_str: &str) -> Result<Hash<28>, WalletError> {
if hex_str.len() != 56 {
return Err(WalletError::Derivation(format!(
"expected 56-char hex pubkey hash, got {}",
hex_str.len()
)));
}
let mut out = [0u8; 28];
for i in 0..28 {
out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).map_err(|_| {
WalletError::Derivation(format!("invalid hex in pubkey hash: {hex_str}"))
})?;
}
Ok(Hash::<28>::new(out))
}
impl PolicySpec {
/// Convenience constructor — derive a single-sig policy from a
/// `PaymentKey` directly.
pub fn single_sig(payment: &PaymentKey) -> Self {
let h = payment.public_key_hash();
Self::SingleSig {
signer_pkh_hex: hash_to_hex(&h),
}
}
/// Convenience constructor — derive a single-sig-with-timelock
/// policy from a `PaymentKey` directly.
pub fn single_sig_timelock(payment: &PaymentKey, invalid_after_slot: u64) -> Self {
let h = payment.public_key_hash();
Self::SingleSigTimelock {
signer_pkh_hex: hash_to_hex(&h),
invalid_after_slot,
}
}
/// Lower the spec into a `pallas_primitives::alonzo::NativeScript`.
pub fn to_native_script(&self) -> Result<NativeScript, WalletError> {
match self {
Self::SingleSig { signer_pkh_hex } => {
let h = parse_pkh(signer_pkh_hex)?;
Ok(NativeScript::ScriptPubkey(h))
}
Self::SingleSigTimelock {
signer_pkh_hex,
invalid_after_slot,
} => {
let h = parse_pkh(signer_pkh_hex)?;
// ScriptAll = require BOTH the signature AND the
// not-yet-expired condition.
Ok(NativeScript::ScriptAll(vec![
NativeScript::ScriptPubkey(h),
NativeScript::InvalidHereafter(*invalid_after_slot),
]))
}
Self::NofK { n, signer_pkhs_hex } => {
let mut subs: Vec<NativeScript> = Vec::with_capacity(signer_pkhs_hex.len());
for s in signer_pkhs_hex {
let h = parse_pkh(s)?;
subs.push(NativeScript::ScriptPubkey(h));
}
Ok(NativeScript::ScriptNOfK(*n, subs))
}
}
}
/// CBOR encoding of the underlying native script.
pub fn to_cbor(&self) -> Result<Vec<u8>, WalletError> {
let script = self.to_native_script()?;
minicbor::to_vec(&script)
.map_err(|e| WalletError::Derivation(format!("encode native script: {e}")))
}
/// blake2b-224 of `0x00 || cbor` — the asset's policy ID.
pub fn policy_id(&self) -> Result<Hash<28>, WalletError> {
let script = self.to_native_script()?;
Ok(script.compute_hash())
}
}
fn hash_to_hex(h: &Hash<28>) -> String {
let bytes: &[u8] = h.as_ref();
let mut s = String::with_capacity(56);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
pallas_addresses::Address::from_bech32(bech32)
.map_err(|e| WalletError::Address(e.to_string()))
}
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
if hex_str.len() != 64 {
return Err(WalletError::Derivation(format!(
"expected 64-char hex tx_hash, got {}",
hex_str.len()
)));
}
let mut out = [0u8; 32];
for i in 0..32 {
out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
.map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {hex_str}")))?;
}
Ok(Hash::<32>::new(out))
}
fn parse_asset_name(hex_str: &str) -> Result<Vec<u8>, WalletError> {
if hex_str.len() % 2 != 0 {
return Err(WalletError::Derivation(
"asset_name hex must have even length".into(),
));
}
if hex_str.len() > 64 {
return Err(WalletError::Derivation(format!(
"asset_name too long: {} hex chars (>64)",
hex_str.len()
)));
}
let mut out = Vec::with_capacity(hex_str.len() / 2);
for i in (0..hex_str.len()).step_by(2) {
out.push(u8::from_str_radix(&hex_str[i..i + 2], 16).map_err(|_| {
WalletError::Derivation(format!("invalid hex in asset_name: {hex_str}"))
})?);
}
Ok(out)
}
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
use pallas_crypto::key::ed25519::SecretKeyExtended;
let extended: [u8; 64] = payment.xprv().extended_secret_key();
let secret = SecretKeyExtended::from_bytes(extended)
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
Ok(PrivateKey::Extended(secret))
}
fn network_id_for(network: Network) -> u8 {
match network {
Network::Mainnet => 1,
Network::Preview | Network::Preprod => 0,
}
}
const WITNESS_OVERHEAD_BYTES: u64 = 128;
/// Internal — runs the two-pass fee refinement and returns the
/// final BuiltTransaction + summary. Mint version of
/// `tx::prepare_payment`. Adds: mint-asset entry, native-script
/// witness, disclosed signer.
fn prepare_mint(
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,
payment_pkh: Hash<28>,
params: &ProtocolParams,
) -> Result<(BuiltTransaction, PaymentSummary), WalletError> {
let dest_addr = parse_address(dest_address_bech32)?;
let change_addr = parse_address(change_address_bech32)?;
let network_id = network_id_for(network);
let policy_id = policy.policy_id()?;
let asset_name_bytes = parse_asset_name(asset_name_hex)?;
let script_cbor = policy.to_cbor()?;
// The destination output holds dest_lovelace + the freshly-minted
// asset (positive quantity). Burns subtract assets from change
// instead, so for a burn there's no asset on the dest output.
let mint_is_positive = mint_quantity > 0;
// Pre-flight UTXO selection — we need enough lovelace for
// dest_lovelace + fee + min_change. No required input assets
// here (mint creates the supply).
let fee_pass1: u64 = 800_000;
let need = dest_lovelace
.checked_add(fee_pass1)
.and_then(|x| x.checked_add(params.min_utxo_lovelace))
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
let mut sorted: Vec<InputUtxo> = available_utxos.to_vec();
sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace));
let mut acc: u64 = 0;
let mut selected: Vec<InputUtxo> = Vec::new();
for u in sorted {
acc = acc.saturating_add(u.lovelace);
selected.push(u);
if acc >= need {
break;
}
}
if acc < need {
return Err(WalletError::Derivation(format!(
"insufficient funds: need {need} lovelace, have {acc}"
)));
}
let total_in: u64 = selected.iter().map(|u| u.lovelace).sum();
// Aggregate input assets (other than the one being minted) so
// we can preserve them on the change output.
let mut input_assets: std::collections::BTreeMap<String, u64> = Default::default();
for u in &selected {
for (k, v) in &u.assets {
let entry = input_assets.entry(k.clone()).or_insert(0);
*entry = entry.saturating_add(*v);
}
}
let policy_id_hex = hash_to_hex(&policy_id);
let mint_asset_key = format!("{policy_id_hex}{asset_name_hex}");
// Adjust input_assets for burns (negative mint).
if mint_quantity < 0 {
let burn_qty = (-mint_quantity) as u64;
let entry = input_assets.entry(mint_asset_key.clone()).or_insert(0);
if *entry < burn_qty {
return Err(WalletError::Derivation(format!(
"insufficient {mint_asset_key} to burn: have {entry}, need {burn_qty}"
)));
}
*entry -= burn_qty;
}
// Build pass 1 with placeholder fee.
let dest_assets: std::collections::BTreeMap<String, u64> = if mint_is_positive {
let mut m = std::collections::BTreeMap::new();
m.insert(mint_asset_key.clone(), mint_quantity as u64);
m
} else {
Default::default()
};
let change_pass1 = total_in
.checked_sub(dest_lovelace)
.and_then(|x| x.checked_sub(fee_pass1))
.ok_or_else(|| WalletError::Derivation("pass1: insufficient lovelace".into()))?;
let staging1 = build_mint_staging(
&selected,
&dest_addr,
dest_lovelace,
&dest_assets,
&change_addr,
change_pass1,
&input_assets,
fee_pass1,
network_id,
policy_id,
&asset_name_bytes,
mint_quantity,
&script_cbor,
payment_pkh,
)?;
let unsigned = staging1
.build_conway_raw()
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?
.tx_bytes
.0;
let est_signed = (unsigned.len() as u64) + WITNESS_OVERHEAD_BYTES;
let real_fee = params.min_fee_for_size(est_signed);
// Token-bearing change output must hold ≥ min_utxo lovelace.
let token_change = !input_assets.is_empty();
let (final_fee, final_change) = match total_in.checked_sub(dest_lovelace + real_fee) {
Some(c) if c >= params.min_utxo_lovelace || token_change => {
if token_change && c < params.min_utxo_lovelace {
return Err(WalletError::Derivation(format!(
"insufficient ADA for token-bearing change: change={c}, min={}",
params.min_utxo_lovelace
)));
}
(real_fee, c)
}
Some(c) => (real_fee + c, 0),
None => {
return Err(WalletError::Derivation(format!(
"insufficient funds for fee: total_in={total_in} dest={dest_lovelace} fee={real_fee}"
)))
}
};
let staging2 = build_mint_staging(
&selected,
&dest_addr,
dest_lovelace,
&dest_assets,
&change_addr,
final_change,
&input_assets,
final_fee,
network_id,
policy_id,
&asset_name_bytes,
mint_quantity,
&script_cbor,
payment_pkh,
)?;
let built = staging2
.build_conway_raw()
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
let summary = PaymentSummary {
tx_hash: hash_to_hex_32(&built.tx_hash.0),
network,
from_address: change_address_bech32.to_string(),
to_address: dest_address_bech32.to_string(),
send_lovelace: dest_lovelace,
fee_lovelace: final_fee,
change_lovelace: final_change,
num_inputs: selected.len(),
send_assets: vec![crate::tx::AssetSpec {
policy_id_hex: policy_id_hex.clone(),
asset_name_hex: asset_name_hex.to_string(),
quantity: mint_quantity.unsigned_abs(),
}],
change_assets: input_assets
.iter()
.map(|(k, v)| crate::tx::AssetSpec {
policy_id_hex: k[..56].to_string(),
asset_name_hex: k[56..].to_string(),
quantity: *v,
})
.collect(),
};
Ok((built, summary))
}
fn hash_to_hex_32(h: &[u8; 32]) -> String {
let mut s = String::with_capacity(64);
for b in h {
s.push_str(&format!("{:02x}", b));
}
s
}
#[allow(clippy::too_many_arguments)]
fn build_mint_staging(
inputs: &[InputUtxo],
dest_addr: &pallas_addresses::Address,
dest_lovelace: u64,
dest_assets: &std::collections::BTreeMap<String, u64>,
change_addr: &pallas_addresses::Address,
change_lovelace: u64,
change_assets: &std::collections::BTreeMap<String, u64>,
fee: u64,
network_id: u8,
policy_id: Hash<28>,
asset_name_bytes: &[u8],
mint_quantity: i64,
script_cbor: &[u8],
payment_pkh: Hash<28>,
) -> Result<StagingTransaction, WalletError> {
let mut staging = StagingTransaction::new();
for u in inputs {
let h = parse_tx_hash(&u.tx_hash_hex)?;
staging = staging.input(Input::new(h, u.output_index as u64));
}
// Destination output (with mint asset if positive).
let mut dest_out = Output::new(dest_addr.clone(), dest_lovelace);
for (k, q) in dest_assets {
if *q == 0 {
continue;
}
// dest_assets keys are policy||name hex; decode parts.
if k.len() < 56 {
return Err(WalletError::Derivation(
"dest asset key shorter than 56 chars".into(),
));
}
let pol_hex = &k[..56];
let name_hex = &k[56..];
let p = parse_pkh(pol_hex)?; // 28-byte hash, same parser works
let n = parse_asset_name(name_hex)?;
dest_out = dest_out
.add_asset(p, n, *q)
.map_err(|e| WalletError::Derivation(format!("dest add_asset: {e}")))?;
}
staging = staging.output(dest_out);
// Change output (with leftover input assets, if any).
let nonzero_change: std::collections::BTreeMap<String, u64> = change_assets
.iter()
.filter(|(_, q)| **q > 0)
.map(|(k, v)| (k.clone(), *v))
.collect();
if change_lovelace > 0 || !nonzero_change.is_empty() {
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
for (k, q) in &nonzero_change {
if k.len() < 56 {
return Err(WalletError::Derivation(
"change asset key shorter than 56 chars".into(),
));
}
let pol_hex = &k[..56];
let name_hex = &k[56..];
let p = parse_pkh(pol_hex)?;
let n = parse_asset_name(name_hex)?;
change_out = change_out
.add_asset(p, n, *q)
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
}
staging = staging.output(change_out);
}
staging = staging
.mint_asset(policy_id, asset_name_bytes.to_vec(), mint_quantity)
.map_err(|e| WalletError::Derivation(format!("mint_asset: {e}")))?
.script(ScriptKind::Native, script_cbor.to_vec())
.disclosed_signer(payment_pkh)
.fee(fee)
.network_id(network_id);
Ok(staging)
}
/// Build + sign a mint TX.
///
/// `mint_quantity > 0` mints, `mint_quantity < 0` burns. Burning
/// requires the wallet's UTXOs hold ≥ |quantity| of the asset.
///
/// The destination output gets `dest_lovelace` + the freshly-minted
/// supply (or just `dest_lovelace` for a burn). Change output picks
/// up any leftover ADA + non-burn assets from the wallet.
#[allow(clippy::too_many_arguments)]
pub fn build_signed_mint(
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,
params: &ProtocolParams,
) -> Result<Vec<u8>, WalletError> {
let private = payment_key_to_private(payment_key)?;
let payment_pkh = payment_key.public_key_hash();
let (built, _summary) = prepare_mint(
network,
available_utxos,
change_address_bech32,
dest_address_bech32,
dest_lovelace,
policy,
asset_name_hex,
mint_quantity,
payment_pkh,
params,
)?;
let signed = built
.sign(private)
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
Ok(signed.tx_bytes.0)
}
/// Build a mint TX without signing — for cold-sign flows. Returns
/// the unsigned CBOR + summary.
#[allow(clippy::too_many_arguments)]
pub fn build_unsigned_mint(
network: Network,
payment_pkh_hex: &str,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
dest_address_bech32: &str,
dest_lovelace: u64,
policy: &PolicySpec,
asset_name_hex: &str,
mint_quantity: i64,
params: &ProtocolParams,
) -> Result<UnsignedPayment, WalletError> {
let payment_pkh = parse_pkh(payment_pkh_hex)?;
let (built, summary) = prepare_mint(
network,
available_utxos,
change_address_bech32,
dest_address_bech32,
dest_lovelace,
policy,
asset_name_hex,
mint_quantity,
payment_pkh,
params,
)?;
let mut cbor_hex = String::with_capacity(built.tx_bytes.0.len() * 2);
for b in &built.tx_bytes.0 {
cbor_hex.push_str(&format!("{:02x}", b));
}
Ok(UnsignedPayment { cbor_hex, summary })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Mnemonic;
const ABANDON_ART: &str = concat!(
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon art",
);
fn payment_from_canonical() -> PaymentKey {
let root = Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap();
crate::derive::derive_payment_key(&root, 0, 0)
}
fn change_address(network: Network) -> String {
let root = Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap();
crate::derive_base_address(&root, network, 0, 0).unwrap()
}
fn to_address_preprod() -> String {
let root = Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap();
crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap()
}
#[test]
fn single_sig_policy_round_trips() {
let payment = payment_from_canonical();
let policy = PolicySpec::single_sig(&payment);
let script = policy.to_native_script().unwrap();
match script {
NativeScript::ScriptPubkey(_) => {}
other => panic!("expected ScriptPubkey, got {other:?}"),
}
let id = policy.policy_id().unwrap();
assert_eq!(id.as_ref().len(), 28);
}
#[test]
fn timelock_policy_uses_script_all() {
let payment = payment_from_canonical();
let policy = PolicySpec::single_sig_timelock(&payment, 100_000_000);
let script = policy.to_native_script().unwrap();
match script {
NativeScript::ScriptAll(subs) => {
assert_eq!(subs.len(), 2);
assert!(matches!(subs[0], NativeScript::ScriptPubkey(_)));
assert!(matches!(subs[1], NativeScript::InvalidHereafter(_)));
}
other => panic!("expected ScriptAll, got {other:?}"),
}
}
#[test]
fn nofk_policy_serializes() {
let payment = payment_from_canonical();
let pkh1 = hash_to_hex(&payment.public_key_hash());
let policy = PolicySpec::NofK {
n: 2,
signer_pkhs_hex: vec![pkh1.clone(), pkh1.clone(), pkh1],
};
let id = policy.policy_id().unwrap();
assert_eq!(id.as_ref().len(), 28);
}
#[test]
fn policy_id_is_deterministic() {
let payment = payment_from_canonical();
let p1 = PolicySpec::single_sig(&payment);
let p2 = PolicySpec::single_sig(&payment);
assert_eq!(p1.policy_id().unwrap(), p2.policy_id().unwrap());
}
#[test]
fn timelock_policy_id_changes_with_slot() {
let payment = payment_from_canonical();
let p1 = PolicySpec::single_sig_timelock(&payment, 100);
let p2 = PolicySpec::single_sig_timelock(&payment, 200);
assert_ne!(p1.policy_id().unwrap(), p2.policy_id().unwrap());
}
#[test]
fn build_signed_mint_produces_cbor() {
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 cbor = build_signed_mint(
&payment,
Network::Preprod,
&utxos,
&change,
&to_address_preprod(),
2_000_000,
&policy,
"414c44414252415f54455354", // "ALDABRA_TEST" hex
1,
&ProtocolParams::default(),
)
.expect("mint builds + signs");
assert!(cbor.len() > 200, "mint cbor too short: {} bytes", cbor.len());
}
#[test]
fn build_signed_mint_rejects_burn_without_holdings() {
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(), // no holdings of the burn asset
}];
let err = build_signed_mint(
&payment,
Network::Preprod,
&utxos,
&change,
&to_address_preprod(),
2_000_000,
&policy,
"414c44414252415f54455354",
-1,
&ProtocolParams::default(),
)
.expect_err("burn without holdings should fail");
match err {
WalletError::Derivation(m) => assert!(m.contains("insufficient")),
other => panic!("expected Derivation, got {other:?}"),
}
}
#[test]
fn parse_pkh_validates_length() {
assert!(parse_pkh("ab").is_err());
assert!(parse_pkh(&"ee".repeat(28)).is_ok());
}
}

View file

@ -26,8 +26,8 @@ use std::sync::Arc;
use aldabra_chain::{ChainBackend, KoiosClient};
use aldabra_core::{
build_signed_payment_with_assets, build_unsigned_payment_with_assets, hex_decode, AssetSpec,
InputUtxo, Network, PaymentKey, ProtocolParams,
build_signed_mint, 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;
@ -129,6 +129,35 @@ pub struct SubmitSignedArgs {
pub signed_cbor_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct PolicyCreateArgs {
/// Optional slot after which the policy becomes invalid. Use
/// to lock supply (Cardano idiom: mint then expire). Omit for
/// an open-ended policy that allows mint/burn forever.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct MintArgs {
/// Recipient bech32 address. Receives `dest_lovelace` ADA + the
/// freshly-minted asset. Often the wallet's own address.
pub dest_address: String,
/// ADA to attach to the mint output (must be ≥ min_utxo —
/// typically 1_500_000 lovelace for an asset-bearing output).
pub dest_lovelace: u64,
/// Hex-encoded asset name (raw bytes, 0-32 bytes).
pub asset_name_hex: String,
/// Mint quantity. Positive = mint, negative = burn (caller must
/// hold the assets to burn).
pub quantity: i64,
/// Optional invalid-after slot for the auto-generated policy.
/// If omitted, generates an open-ended single-sig policy bound
/// to the wallet's payment key.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
}
#[tool(tool_box)]
impl WalletService {
#[tool(
@ -331,6 +360,111 @@ impl WalletService {
.await
.map_err(|e| format!("submit: {e}"))
}
#[tool(
name = "wallet.policy.create",
description = "Generate a single-sig native policy bound to this wallet's payment key. Args: invalid_after_slot (optional u64 — omit for open-ended, supply for time-locked supply). Returns JSON {policy_id_hex, script_cbor_hex, type}."
)]
async fn wallet_policy_create(
&self,
#[tool(aggr)] PolicyCreateArgs { invalid_after_slot }: PolicyCreateArgs,
) -> Result<String, String> {
let policy = match invalid_after_slot {
Some(slot) => PolicySpec::single_sig_timelock(&self.inner.payment_key, slot),
None => PolicySpec::single_sig(&self.inner.payment_key),
};
let policy_id = policy.policy_id().map_err(|e| e.to_string())?;
let cbor = policy.to_cbor().map_err(|e| e.to_string())?;
let mut policy_id_hex = String::with_capacity(56);
for b in policy_id.as_ref() {
policy_id_hex.push_str(&format!("{:02x}", b));
}
let mut cbor_hex = String::with_capacity(cbor.len() * 2);
for b in &cbor {
cbor_hex.push_str(&format!("{:02x}", b));
}
let kind = match invalid_after_slot {
Some(_) => "single_sig_timelock",
None => "single_sig",
};
Ok(format!(
"{{\"policy_id_hex\":\"{policy_id_hex}\",\"script_cbor_hex\":\"{cbor_hex}\",\"type\":\"{kind}\"}}"
))
}
#[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."
)]
async fn wallet_mint(
&self,
#[tool(aggr)] MintArgs {
dest_address,
dest_lovelace,
asset_name_hex,
quantity,
invalid_after_slot,
}: MintArgs,
) -> Result<String, String> {
if quantity == 0 {
return Err("quantity must be nonzero (positive=mint, negative=burn)".into());
}
if dest_lovelace < 1_000_000 {
return Err(format!(
"dest_lovelace {dest_lovelace} below 1 ADA min — token-bearing UTXO will be rejected"
));
}
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let policy = match invalid_after_slot {
Some(slot) => PolicySpec::single_sig_timelock(&self.inner.payment_key, slot),
None => PolicySpec::single_sig(&self.inner.payment_key),
};
let cbor = build_signed_mint(
&self.inner.payment_key,
self.inner.network,
&inputs,
&self.inner.address,
&dest_address,
dest_lovelace,
&policy,
&asset_name_hex,
quantity,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign mint: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
}
#[tool(tool_box)]
@ -338,7 +472,7 @@ impl ServerHandler for WalletService {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"aldabra — Cardano lite wallet over MCP. Phase 1 (read): wallet.address, wallet.network, wallet.balance, wallet.utxos. Phase 2 (send): wallet.send (auto-sign), wallet.send.unsigned + wallet.submit_signed_tx (cold-sign flow), wallet.tx_status. Native-asset send + Plutus land in phase 3+.".into(),
"aldabra — Cardano lite wallet over MCP. Phase 1 (read): wallet.address, wallet.network, wallet.balance, wallet.utxos. Phase 2 (send): wallet.send (with native-asset bundle support), wallet.send.unsigned + wallet.submit_signed_tx (cold-sign), wallet.tx_status. Phase 3 (mint): wallet.policy.create, wallet.mint. CIP-25 metadata + CIP-68 + Plutus land in follow-up.".into(),
),
..Default::default()
}