phase 3.3, 3.6: cip-68 ref-nft pair + sign_partial primitive

new aldabra-core::cip68 module:
- asset name prefixes 100 (0x000643b0 ref) / 222 (0x000de140 user) /
  333 (0x0014df10 ft). prefixed() guards 32-byte total cap so caller
  can't blow past the cardano protocol limit by accident.
- json_to_plutus_data: serde_json::Value → PlutusData (recursive).
  numbers must fit i64. strings → BoundedBytes (cip-68 convention is
  bytes-keyed datum maps, not text). null is rejected, floats rejected.
- build_cip68_datum_cbor wraps the metadata in the canonical
  Constr 0 [meta_map, version_int=2, Constr 0 []] shape.

new aldabra-core::mint::build_signed_cip68_nft_mint:
- mints two assets simultaneously under one policy (ref + user, qty 1
  each), three outputs (ref @ ref_addr w/ inline datum, user @ user_addr,
  change). same two-pass fee refinement as the rest of the path.
- mutable nfts: pass ref_addr == change_addr. wallet's payment key can
  later spend the ref UTXO and re-create with new datum.
- immutable: caller passes an always-fails script address (phase 4
  concern; today this fn trusts whatever's passed).

new aldabra-core::sign module + add_witness:
- decodes a conway tx (any state — unsigned or partially signed),
  signs the body hash with the wallet's payment key, appends a
  VKeyWitness to the witness_set, re-encodes. body is invariant
  (regression test asserts the body hash before and after the witness
  append are identical).
- this is the missing primitive for n-of-k multisig flows: each party
  calls add_witness on the previous party's output cbor; any party
  submits via wallet.submit_signed_tx.

mcp tools: 10 → 12.
- wallet.mint.cip68_nft — args: user_address, name_body_hex (≤28b),
  metadata (json object), user_lovelace? ref_address? ref_lovelace?
  invalid_after_slot? — defaults provided for the ergonomic case
  (ref_addr=wallet, lovelace=1.5 ADA each).
- wallet.sign_partial — args: cbor_hex — appends our witness, returns
  updated hex. usable for MAP treasury 2-of-2 once a
  wallet.mint.unsigned-with-policy-arg lands (TODO, deferred).

65 → 79 unit tests. cip68 module: 9 tests covering prefix+datum
shape. sign module: 4 tests covering one-witness, two-witness,
body-hash invariant, garbage rejection. integration test in mint
verifies cip68 build produces 3 outputs with inline datum on the
ref output.
This commit is contained in:
Sulkta 2026-05-04 12:27:43 -07:00
parent 218d1ac94b
commit 640af23598
5 changed files with 903 additions and 5 deletions

View file

@ -26,9 +26,9 @@ use std::sync::Arc;
use aldabra_chain::{ChainBackend, KoiosClient};
use aldabra_core::{
build_signed_mint_with_metadata, build_signed_payment_with_assets,
build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, Network, PaymentKey,
PolicySpec, ProtocolParams,
add_witness, build_signed_cip68_nft_mint, 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;
@ -139,6 +139,51 @@ pub struct PolicyCreateArgs {
pub invalid_after_slot: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SignPartialArgs {
/// Hex-encoded Conway-era tx CBOR — unsigned, or already
/// partially signed by another party. The wallet's payment key
/// gets appended as an additional VKeyWitness.
pub cbor_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct Cip68NftArgs {
/// Recipient address — receives the user NFT (label 222).
pub user_address: String,
/// ADA to attach to the user NFT output (≥ 1.5 ADA min).
/// Defaults to 1_500_000 lovelace if omitted.
#[serde(default = "default_token_lovelace")]
pub user_lovelace: u64,
/// Hex-encoded asset name body (0-28 bytes; the 4-byte CIP-68
/// prefix gets prepended automatically). Same body is shared
/// between the ref NFT (100) and the user NFT (222).
pub name_body_hex: String,
/// CIP-68 metadata JSON object (`name`, `image`, `description`,
/// `mediaType`, `files`, etc.). Encoded as Plutus Data and
/// attached as the inline datum on the ref-NFT output.
pub metadata: serde_json::Value,
/// Optional address where the reference NFT lives. Defaults to
/// the wallet's own address — keeps the NFT *mutable* (the
/// wallet's payment key can later spend the ref UTXO and update
/// metadata). Pass an "always-fails" script address for
/// immutable NFTs.
#[serde(default)]
pub ref_address: Option<String>,
/// ADA to attach to the ref NFT output. Defaults to 1_500_000.
#[serde(default = "default_token_lovelace")]
pub ref_lovelace: u64,
/// Optional invalid-after slot for the auto-generated policy.
/// Omit for an open-ended single-sig policy bound to this
/// wallet's payment key.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
}
fn default_token_lovelace() -> u64 {
1_500_000
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct MintArgs {
/// Recipient bech32 address. Receives `dest_lovelace` ADA + the
@ -474,6 +519,104 @@ impl WalletService {
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet.mint.cip68_nft",
description = "Mint a CIP-68 NFT pair (label 100 ref + label 222 user) under a wallet-generated single-sig policy. Args: user_address, name_body_hex (raw asset-name body, ≤28 bytes), metadata (JSON object: name, image, description, mediaType, files, ...), user_lovelace (defaults 1.5 ADA), ref_address (defaults wallet — mutable NFT), ref_lovelace (defaults 1.5 ADA), invalid_after_slot? Returns the tx hash."
)]
async fn wallet_mint_cip68_nft(
&self,
#[tool(aggr)] Cip68NftArgs {
user_address,
user_lovelace,
name_body_hex,
metadata,
ref_address,
ref_lovelace,
invalid_after_slot,
}: Cip68NftArgs,
) -> Result<String, String> {
if user_lovelace < 1_000_000 || ref_lovelace < 1_000_000 {
return Err("user_lovelace and ref_lovelace must each be ≥ 1 ADA".into());
}
let name_body = hex_decode(&name_body_hex).map_err(|e| format!("name_body_hex: {e}"))?;
if name_body.len() > 28 {
return Err(format!(
"name_body too long: {} bytes (max 28; CIP-68 prefix needs 4)",
name_body.len()
));
}
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 ref_addr = ref_address.unwrap_or_else(|| self.inner.address.clone());
let cbor = build_signed_cip68_nft_mint(
&self.inner.payment_key,
self.inner.network,
&inputs,
&self.inner.address,
&user_address,
user_lovelace,
&ref_addr,
ref_lovelace,
&name_body,
&metadata,
&policy,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign cip68 mint: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet.sign_partial",
description = "Append this wallet's VKeyWitness to a Conway-era tx (unsigned or partially-signed). Args: cbor_hex (hex-encoded tx CBOR). Returns the updated CBOR hex with our signature added. For multi-sig flows (e.g. a 2-of-2 treasury): each party calls this in turn, then any party submits via wallet.submit_signed_tx."
)]
async fn wallet_sign_partial(
&self,
#[tool(aggr)] SignPartialArgs { cbor_hex }: SignPartialArgs,
) -> Result<String, String> {
let bytes = hex_decode(&cbor_hex).map_err(|e| format!("decode: {e}"))?;
let updated = add_witness(&self.inner.payment_key, &bytes)
.map_err(|e| format!("sign: {e}"))?;
let mut hex = String::with_capacity(updated.len() * 2);
for b in &updated {
hex.push_str(&format!("{:02x}", b));
}
Ok(hex)
}
}
#[tool(tool_box)]
@ -481,7 +624,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 (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(),
"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), wallet.send.unsigned + wallet.submit_signed_tx + wallet.sign_partial (cold-sign + multi-sig), wallet.tx_status. Phase 3 (mint): wallet.policy.create, wallet.mint (with CIP-25 metadata), wallet.mint.cip68_nft (ref + user NFT pair w/ inline datum). Plutus + stake delegation land in Phase 4.".into(),
),
..Default::default()
}