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:
parent
46b6f6efa3
commit
2f3d975c0f
6 changed files with 867 additions and 3 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -87,6 +87,7 @@ dependencies = [
|
|||
"pallas-codec",
|
||||
"pallas-crypto",
|
||||
"pallas-primitives",
|
||||
"pallas-traverse",
|
||||
"pallas-txbuilder",
|
||||
"pallas-wallet",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
725
crates/aldabra-core/src/mint.rs
Normal file
725
crates/aldabra-core/src/mint.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue