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:
Sulkta 2026-05-04 11:44:16 -07:00
parent a472695558
commit 532b3d3558
6 changed files with 867 additions and 3 deletions

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()
}