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:
Cobb 2026-05-04 12:27:43 -07:00
parent a93a2b7cfa
commit f376481a8f
5 changed files with 903 additions and 5 deletions

View file

@ -0,0 +1,302 @@
//! CIP-68 reference NFT pattern (label 100 / 222 / 333).
//!
//! ## Why
//!
//! CIP-25 metadata rides in a tx's auxiliary data — chain-attached but
//! not on-chain queryable. Wallets / explorers index it post-mint and
//! cache the result; if the tx is missed, the metadata is invisible
//! to that wallet forever. CIP-68 fixes this by putting the metadata
//! in the inline datum of a UTXO carrying a special "reference NFT"
//! token. Smart contracts and dApps can read the datum directly.
//!
//! ## Asset-name prefixes
//!
//! CIP-68 splits a logical NFT into two on-chain assets that share the
//! same policy + name body but differ in a 4-byte prefix:
//!
//! - `100` → `0x000643b0` — **reference NFT**, 1 supply, holds the
//! metadata in its UTXO's inline datum.
//! - `222` → `0x000de140` — **user NFT**, 1 supply, the actual token
//! the user holds.
//! - `333` → `0x0014df10` — **fungible token**, any supply, paired
//! with a single 100 ref NFT for shared metadata.
//!
//! ## Datum shape (v2)
//!
//! ```text
//! Constr 0 [
//! Map { name → value, ... }, -- metadata fields (bytes-keyed)
//! 1, -- version int
//! Constr 0 [] -- "extra" placeholder (unit)
//! ]
//! ```
//!
//! ## Mint flow
//!
//! 1. Mint quantity 1 of the ref-NFT asset.
//! 2. Mint quantity 1 (NFT) or N (FT) of the user-asset.
//! 3. Output A: ref NFT → script address with the metadata datum.
//! For mutable NFTs we use the wallet's own address (so the
//! wallet's payment key can later spend the ref NFT to update
//! metadata). For immutable NFTs use an "always-fails" script
//! address.
//! 4. Output B: user NFT → end-user's address.
//!
//! Phase 1 of CIP-68 here is the mutable-NFT case (ref NFT lives at
//! the wallet's own address).
use pallas_codec::minicbor;
use pallas_codec::utils::{KeyValuePairs, MaybeIndefArray};
use pallas_primitives::{BigInt, BoundedBytes, Constr, PlutusData};
use serde_json::Value;
use crate::WalletError;
/// `100` — ref NFT prefix bytes.
pub const PREFIX_REF_NFT: [u8; 4] = [0x00, 0x06, 0x43, 0xb0];
/// `222` — user NFT prefix bytes.
pub const PREFIX_USER_NFT: [u8; 4] = [0x00, 0x0d, 0xe1, 0x40];
/// `333` — fungible token prefix bytes.
pub const PREFIX_FT: [u8; 4] = [0x00, 0x14, 0xdf, 0x10];
/// CIP-68 v2 datum version constant.
pub const CIP68_VERSION_2: i64 = 2;
/// Tag for Plutus constructor `0` (the metadata wrapper constructor).
const PLUTUS_TAG_CONSTR_0: u64 = 121;
fn prefixed(prefix: [u8; 4], name_body: &[u8]) -> Result<Vec<u8>, WalletError> {
if name_body.len() + 4 > 32 {
return Err(WalletError::Derivation(format!(
"CIP-68 asset name (prefix + body) exceeds 32 bytes: body is {} bytes",
name_body.len()
)));
}
let mut out = Vec::with_capacity(prefix.len() + name_body.len());
out.extend_from_slice(&prefix);
out.extend_from_slice(name_body);
Ok(out)
}
/// Build the on-chain asset name for the **reference** NFT given the
/// raw name body bytes (without prefix).
pub fn ref_nft_asset_name(name_body: &[u8]) -> Result<Vec<u8>, WalletError> {
prefixed(PREFIX_REF_NFT, name_body)
}
/// Build the on-chain asset name for the **user** NFT.
pub fn user_nft_asset_name(name_body: &[u8]) -> Result<Vec<u8>, WalletError> {
prefixed(PREFIX_USER_NFT, name_body)
}
/// Build the on-chain asset name for a CIP-68 fungible token.
pub fn ft_asset_name(name_body: &[u8]) -> Result<Vec<u8>, WalletError> {
prefixed(PREFIX_FT, name_body)
}
/// Convert a `serde_json::Value` to a `PlutusData`.
///
/// Mapping:
/// - `null` → error (no Plutus equivalent)
/// - `bool` → `BigInt(0|1)`
/// - integer number → `BigInt`
/// - float number → error (Plutus has no floats)
/// - string → `BoundedBytes(utf8)`. CIP-68 historically uses bytes
/// for both keys and string-valued attributes.
/// - array → `Array<PlutusData>`
/// - object → `Map<PlutusData, PlutusData>` with bytes-encoded keys.
fn json_to_plutus_data(v: &Value) -> Result<PlutusData, WalletError> {
match v {
Value::Null => Err(WalletError::Derivation(
"null is not representable in Plutus Data".into(),
)),
Value::Bool(b) => Ok(PlutusData::BigInt(BigInt::Int(
pallas_codec::utils::Int::from(if *b { 1i64 } else { 0 }),
))),
Value::Number(n) => {
let i = n.as_i64().ok_or_else(|| {
WalletError::Derivation(format!(
"Plutus Data number {n} doesn't fit i64; floats unsupported"
))
})?;
Ok(PlutusData::BigInt(BigInt::Int(
pallas_codec::utils::Int::from(i),
)))
}
Value::String(s) => Ok(PlutusData::BoundedBytes(BoundedBytes::from(
s.as_bytes().to_vec(),
))),
Value::Array(arr) => {
let mut out = Vec::with_capacity(arr.len());
for item in arr {
out.push(json_to_plutus_data(item)?);
}
Ok(PlutusData::Array(MaybeIndefArray::Def(out)))
}
Value::Object(map) => {
let mut pairs: Vec<(PlutusData, PlutusData)> = Vec::with_capacity(map.len());
for (k, vv) in map {
let key =
PlutusData::BoundedBytes(BoundedBytes::from(k.as_bytes().to_vec()));
let value = json_to_plutus_data(vv)?;
pairs.push((key, value));
}
Ok(PlutusData::Map(KeyValuePairs::from(pairs)))
}
}
}
/// Build a CIP-68 v2 datum wrapper for the given metadata. Returns
/// CBOR-encoded `PlutusData` ready for
/// `pallas_txbuilder::Output::set_inline_datum`.
///
/// The datum is `Constr 0 [metadata_map, version_int, Constr 0 []]`,
/// where:
/// - `metadata_map` is the JSON object converted to a Plutus Map
/// (bytes-keyed)
/// - `version_int` is `2` (CIP-68 v2)
/// - the final `Constr 0 []` is the "extra" placeholder, conventionally
/// unit, reserved for future extensions.
pub fn build_cip68_datum_cbor(metadata: &Value) -> Result<Vec<u8>, WalletError> {
if !metadata.is_object() {
return Err(WalletError::Derivation(
"CIP-68 metadata must be a JSON object".into(),
));
}
let metadata_pd = json_to_plutus_data(metadata)?;
let version_pd = PlutusData::BigInt(BigInt::Int(
pallas_codec::utils::Int::from(CIP68_VERSION_2),
));
// "extra" — Constr 0 with no fields (Plutus unit).
let extra_pd = PlutusData::Constr(Constr {
tag: PLUTUS_TAG_CONSTR_0,
any_constructor: None,
fields: MaybeIndefArray::Def(vec![]),
});
let datum = PlutusData::Constr(Constr {
tag: PLUTUS_TAG_CONSTR_0,
any_constructor: None,
fields: MaybeIndefArray::Def(vec![metadata_pd, version_pd, extra_pd]),
});
minicbor::to_vec(&datum)
.map_err(|e| WalletError::Derivation(format!("encode CIP-68 datum: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ref_nft_asset_name_has_correct_prefix() {
let body = b"ALDABRA";
let name = ref_nft_asset_name(body).unwrap();
assert_eq!(&name[..4], &PREFIX_REF_NFT);
assert_eq!(&name[4..], body);
}
#[test]
fn user_nft_asset_name_has_correct_prefix() {
let body = b"ALDABRA";
let name = user_nft_asset_name(body).unwrap();
assert_eq!(&name[..4], &PREFIX_USER_NFT);
assert_eq!(&name[4..], body);
}
#[test]
fn ref_and_user_names_share_body_differ_in_prefix() {
let body = b"TURTLE_001";
let r = ref_nft_asset_name(body).unwrap();
let u = user_nft_asset_name(body).unwrap();
assert_eq!(&r[4..], &u[4..]);
assert_ne!(&r[..4], &u[..4]);
}
#[test]
fn body_too_long_is_rejected() {
// 4 prefix + 30 body = 34 > 32-byte cap
let body = vec![0u8; 30];
assert!(ref_nft_asset_name(&body).is_err());
}
#[test]
fn json_object_to_plutus_data() {
let v = json!({
"name": "Aldabra Tortoise",
"image": "ipfs://Qm...",
"supply": 250,
});
let pd = json_to_plutus_data(&v).unwrap();
match pd {
PlutusData::Map(_) => {}
other => panic!("expected Map, got {other:?}"),
}
}
#[test]
fn json_null_rejected() {
let v = json!(null);
assert!(json_to_plutus_data(&v).is_err());
}
#[test]
fn json_array_to_plutus_array() {
let v = json!(["a", "b", "c"]);
let pd = json_to_plutus_data(&v).unwrap();
match pd {
PlutusData::Array(arr) => {
assert_eq!(arr.clone().to_vec().len(), 3);
}
_ => panic!("expected Array"),
}
}
fn fields_vec(c: &Constr<PlutusData>) -> Vec<PlutusData> {
c.fields.clone().to_vec()
}
#[test]
fn build_datum_round_trips_cbor() {
let metadata = json!({
"name": "ALDABRA_TEST",
"image": "ipfs://QmTest",
"description": "Sulkta CIP-68 test",
});
let cbor = build_cip68_datum_cbor(&metadata).unwrap();
let pd: PlutusData = minicbor::decode(&cbor).expect("decode datum");
// Outermost should be Constr 0 with 3 fields.
match pd {
PlutusData::Constr(c) => {
assert_eq!(c.tag, PLUTUS_TAG_CONSTR_0);
let fields = fields_vec(&c);
assert_eq!(fields.len(), 3);
// Field 1: metadata Map.
assert!(matches!(fields[0], PlutusData::Map(_)));
// Field 2: version BigInt(2).
match &fields[1] {
PlutusData::BigInt(_) => {}
other => panic!("expected version BigInt, got {other:?}"),
}
// Field 3: extra Constr 0 [].
match &fields[2] {
PlutusData::Constr(extra) => {
assert_eq!(extra.tag, PLUTUS_TAG_CONSTR_0);
assert!(fields_vec(extra).is_empty());
}
other => panic!("expected extra Constr, got {other:?}"),
}
}
other => panic!("expected outer Constr, got {other:?}"),
}
}
#[test]
fn build_datum_rejects_non_object_root() {
let v = json!("not an object");
assert!(build_cip68_datum_cbor(&v).is_err());
}
}

View file

@ -35,13 +35,22 @@ use pallas_addresses::{
use thiserror::Error;
use zeroize::ZeroizeOnDrop;
pub mod cip68;
pub mod derive;
pub mod metadata;
pub mod mint;
pub mod sign;
pub mod tx;
pub use cip68::{
build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name,
};
pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey};
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 mint::{
build_signed_cip68_nft_mint, build_signed_mint, build_signed_mint_with_metadata,
build_unsigned_mint, PolicySpec,
};
pub use sign::add_witness;
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

@ -36,6 +36,7 @@ use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, ScriptKind,
use pallas_wallet::PrivateKey;
use serde::{Deserialize, Serialize};
use crate::cip68::{build_cip68_datum_cbor, ref_nft_asset_name, user_nft_asset_name};
use crate::tx::{InputUtxo, PaymentSummary, ProtocolParams, UnsignedPayment};
use crate::{Network, PaymentKey, WalletError};
@ -640,6 +641,198 @@ pub fn build_unsigned_mint(
Ok(UnsignedPayment { cbor_hex, summary })
}
/// Build + sign a CIP-68 NFT mint.
///
/// Issues two assets simultaneously under a single policy:
/// - **Reference NFT** (label `100`, prefix `0x000643b0`) — quantity 1,
/// lands at `ref_address` with the metadata in its inline datum.
/// - **User NFT** (label `222`, prefix `0x000de140`) — quantity 1, lands
/// at `user_address` for the end recipient.
///
/// `name_body` is the raw asset-name body (NOT hex). The function
/// prefixes both 100 and 222 onto it. Combined name length must be
/// ≤32 bytes (so `name_body.len() ≤ 28`).
///
/// `metadata` is the JSON object of CIP-68 attributes (`name`,
/// `image`, `description`, etc.). It's wrapped in the canonical
/// `Constr 0 [meta_map, version=2, Constr 0 []]` Plutus Data shape.
///
/// For mutable NFTs, set `ref_address == change_address` (the
/// wallet's own address); the wallet's payment key can later spend
/// the ref NFT UTXO and re-create it with updated metadata.
///
/// For immutable NFTs, lock `ref_address` at an always-fails script
/// — that's a Phase 4 concern; today this function trusts whatever
/// address you pass.
#[allow(clippy::too_many_arguments)]
pub fn build_signed_cip68_nft_mint(
payment_key: &PaymentKey,
network: Network,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
user_address_bech32: &str,
user_lovelace: u64,
ref_address_bech32: &str,
ref_lovelace: u64,
name_body: &[u8],
metadata: &serde_json::Value,
policy: &PolicySpec,
params: &ProtocolParams,
) -> Result<Vec<u8>, WalletError> {
let private = payment_key_to_private(payment_key)?;
let payment_pkh = payment_key.public_key_hash();
let user_addr = parse_address(user_address_bech32)?;
let ref_addr = parse_address(ref_address_bech32)?;
let change_addr = parse_address(change_address_bech32)?;
let network_id = network_id_for(network);
let policy_id = policy.policy_id()?;
let script_cbor = policy.to_cbor()?;
let ref_name = ref_nft_asset_name(name_body)?;
let user_name = user_nft_asset_name(name_body)?;
let datum_cbor = build_cip68_datum_cbor(metadata)?;
// Lovelace need: user output + ref output + fee + min_change.
let fee_pass1: u64 = 1_000_000;
let need = user_lovelace
.checked_add(ref_lovelace)
.and_then(|x| x.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 for cip68 mint: need {need} lovelace, have {acc}"
)));
}
let total_in: u64 = selected.iter().map(|u| u.lovelace).sum();
// Aggregate input assets to preserve them on change.
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 build_with_fee = |fee: u64,
change_lovelace: u64|
-> Result<StagingTransaction, WalletError> {
let mut staging = StagingTransaction::new();
for u in &selected {
let h = parse_tx_hash(&u.tx_hash_hex)?;
staging = staging.input(Input::new(h, u.output_index as u64));
}
// Output 1: ref NFT @ ref_address, with inline datum.
let ref_out = Output::new(ref_addr.clone(), ref_lovelace)
.add_asset(policy_id, ref_name.clone(), 1)
.map_err(|e| WalletError::Derivation(format!("ref add_asset: {e}")))?
.set_inline_datum(datum_cbor.clone());
staging = staging.output(ref_out);
// Output 2: user NFT @ user_address.
let user_out = Output::new(user_addr.clone(), user_lovelace)
.add_asset(policy_id, user_name.clone(), 1)
.map_err(|e| WalletError::Derivation(format!("user add_asset: {e}")))?;
staging = staging.output(user_out);
// Output 3 (optional): change @ wallet, with leftover input assets.
let nonzero_change_assets: std::collections::BTreeMap<String, u64> = input_assets
.iter()
.filter(|(_, q)| **q > 0)
.map(|(k, v)| (k.clone(), *v))
.collect();
if change_lovelace > 0 || !nonzero_change_assets.is_empty() {
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
for (k, q) in &nonzero_change_assets {
if k.len() < 56 {
return Err(WalletError::Derivation(
"change asset key shorter than 56 chars".into(),
));
}
let p = parse_pkh(&k[..56])?;
let n = parse_asset_name(&k[56..])?;
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, ref_name.clone(), 1)
.map_err(|e| WalletError::Derivation(format!("mint ref: {e}")))?
.mint_asset(policy_id, user_name.clone(), 1)
.map_err(|e| WalletError::Derivation(format!("mint user: {e}")))?
.script(ScriptKind::Native, script_cbor.clone())
.disclosed_signer(payment_pkh)
.fee(fee)
.network_id(network_id);
Ok(staging)
};
// Pass 1 — placeholder fee, measure unsigned size, recompute.
let change_pass1 = total_in
.checked_sub(user_lovelace + ref_lovelace + fee_pass1)
.ok_or_else(|| {
WalletError::Derivation("pass1: insufficient lovelace for cip68 outputs".into())
})?;
let staging1 = build_with_fee(fee_pass1, change_pass1)?;
let unsigned = staging1
.build_conway_raw()
.map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))?
.tx_bytes
.0;
let est_signed = (unsigned.len() as u64) + WITNESS_OVERHEAD_BYTES;
let real_fee = params.min_fee_for_size(est_signed);
let token_change = !input_assets.is_empty();
let (final_fee, final_change) = match total_in
.checked_sub(user_lovelace + ref_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} fee={real_fee}"
)))
}
};
let staging2 = build_with_fee(final_fee, final_change)?;
let built = staging2
.build_conway_raw()
.map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?;
let signed = built
.sign(private)
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
Ok(signed.tx_bytes.0)
}
#[cfg(test)]
mod tests {
use super::*;
@ -795,6 +988,68 @@ mod tests {
assert!(parse_pkh(&"ee".repeat(28)).is_ok());
}
#[test]
fn build_signed_cip68_nft_mint_produces_two_assets_and_inline_datum() {
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: 200_000_000,
assets: Default::default(),
}];
let metadata = serde_json::json!({
"name": "ALDABRA",
"image": "ipfs://QmAldabraTortoise",
"description": "First Sulkta CIP-68 NFT",
});
let cbor = build_signed_cip68_nft_mint(
&payment,
Network::Preprod,
&utxos,
&change,
&to_address_preprod(),
2_000_000,
&change, // ref NFT lives at wallet's own addr (mutable)
2_000_000,
b"ALDABRA",
&metadata,
&policy,
&ProtocolParams::default(),
)
.expect("cip68 mint builds + signs");
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
.expect("decode signed cip68 mint cbor");
// Mint field should hold ONE policy with TWO asset entries (100 + 222).
let mint = tx.transaction_body.mint.expect("mint set");
let mint_pairs: Vec<_> = mint.to_vec();
assert_eq!(mint_pairs.len(), 1, "one policy");
let (_pid, assets) = &mint_pairs[0];
let asset_pairs: Vec<_> = assets.clone().to_vec();
assert_eq!(asset_pairs.len(), 2, "ref + user assets minted");
// Outputs must include the ref NFT output WITH inline_datum.
let outputs: Vec<_> = tx.transaction_body.outputs.to_vec();
// 3 outputs: ref, user, change.
assert_eq!(outputs.len(), 3, "expected ref + user + change outputs");
let mut found_inline_datum = false;
for o in outputs {
let pallas_primitives::conway::PseudoTransactionOutput::PostAlonzo(po) = o else {
continue;
};
if let Some(pallas_primitives::conway::PseudoDatumOption::Data(_)) = po.datum_option {
found_inline_datum = true;
break;
}
}
assert!(found_inline_datum, "ref NFT output must carry an inline datum");
}
#[test]
fn build_signed_mint_with_metadata_produces_aux_hash() {
use pallas_primitives::Fragment;

View file

@ -0,0 +1,189 @@
//! Partial-witness signing for multi-party flows.
//!
//! Cardano transactions can carry multiple `VKeyWitness` entries. For
//! single-sig wallets only one is needed; for multi-sig policies (e.g.
//! ADAMaps treasury 2-of-2) each party signs the same tx body and
//! hands the partially-signed CBOR to the next signer.
//!
//! [`add_witness`] decodes a Conway-era tx CBOR, signs the body hash
//! with this wallet's payment key, appends a `VKeyWitness`, and
//! re-encodes. Idempotent in spirit — calling it twice with the same
//! key produces a tx with two identical witnesses, which is wasteful
//! but not invalid; the chain dedupes on submission. For real flows
//! each party calls it once.
//!
//! ## Workflow (e.g. MAP 2-of-2 treasury)
//!
//! 1. Cobb builds an unsigned mint via `wallet.send.unsigned` (or a
//! future `wallet.mint.unsigned` for treasury-policy mints).
//! 2. Cobb passes the unsigned hex to his offline aldabra instance,
//! which calls [`add_witness`] with Cobb's treasury key.
//! 3. Cobb hands the once-signed hex to Kayos's instance.
//! 4. Kayos calls [`add_witness`] with the second treasury key.
//! 5. Either party calls `wallet.submit_signed_tx`.
use pallas_codec::minicbor;
use pallas_codec::utils::NonEmptySet;
use pallas_crypto::key::ed25519::SecretKeyExtended;
use pallas_primitives::conway::{Tx, VKeyWitness};
use pallas_primitives::Fragment;
use pallas_traverse::ComputeHash;
use crate::{PaymentKey, WalletError};
/// Append a vkeywitness from the given payment key to an existing
/// (unsigned or partially-signed) Conway tx. Returns the new CBOR.
pub fn add_witness(
payment_key: &PaymentKey,
cbor_bytes: &[u8],
) -> Result<Vec<u8>, WalletError> {
let mut tx = Tx::decode_fragment(cbor_bytes)
.map_err(|e| WalletError::Derivation(format!("decode tx: {e}")))?;
// The body hash is what the witness signs. minicbor::to_vec to be
// sure we hash the canonical encoding rather than the raw input
// slice (which may not be canonical if it came from another
// encoder).
let body_hash = tx.transaction_body.compute_hash();
let extended_bytes: [u8; 64] = payment_key.xprv().extended_secret_key();
let secret = SecretKeyExtended::from_bytes(extended_bytes)
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
let signature = secret.sign(body_hash.as_ref());
let pubkey = secret.public_key();
let pubkey_bytes: [u8; 32] = pubkey
.as_ref()
.try_into()
.map_err(|_| WalletError::Derivation("pubkey length mismatch".into()))?;
let signature_bytes: [u8; 64] = signature
.as_ref()
.try_into()
.map_err(|_| WalletError::Derivation("signature length mismatch".into()))?;
let new_witness = VKeyWitness {
vkey: pubkey_bytes.to_vec().into(),
signature: signature_bytes.to_vec().into(),
};
let mut witnesses = tx
.transaction_witness_set
.vkeywitness
.map(|w| w.to_vec())
.unwrap_or_default();
witnesses.push(new_witness);
tx.transaction_witness_set.vkeywitness = NonEmptySet::from_vec(witnesses);
let encoded = minicbor::to_vec(&tx)
.map_err(|e| WalletError::Derivation(format!("encode tx: {e}")))?;
Ok(encoded)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tx::{build_unsigned_payment, InputUtxo, ProtocolParams};
use crate::{Mnemonic, Network};
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 second_address() -> String {
let root = Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap();
crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap()
}
fn fixture_unsigned_payment() -> Vec<u8> {
let change = change_address(Network::Preprod);
let utxos = vec![InputUtxo {
tx_hash_hex: "deadbeef".repeat(8),
output_index: 0,
lovelace: 100_000_000,
assets: Default::default(),
}];
let result = build_unsigned_payment(
Network::Preprod,
&utxos,
&change,
&second_address(),
10_000_000,
&ProtocolParams::default(),
)
.unwrap();
crate::tx::hex_decode(&result.cbor_hex).unwrap()
}
#[test]
fn add_witness_increases_witness_count_by_one() {
let unsigned = fixture_unsigned_payment();
let signed = add_witness(&payment_from_canonical(), &unsigned).unwrap();
let tx = Tx::decode_fragment(&signed).unwrap();
let count = tx
.transaction_witness_set
.vkeywitness
.map(|w| w.to_vec().len())
.unwrap_or(0);
assert_eq!(count, 1, "should have exactly one witness after first sign");
}
#[test]
fn add_witness_twice_yields_two_witnesses() {
let unsigned = fixture_unsigned_payment();
let once = add_witness(&payment_from_canonical(), &unsigned).unwrap();
let twice = add_witness(&payment_from_canonical(), &once).unwrap();
let tx = Tx::decode_fragment(&twice).unwrap();
let count = tx
.transaction_witness_set
.vkeywitness
.map(|w| w.to_vec().len())
.unwrap_or(0);
assert_eq!(count, 2, "duplicate-sign produces two witnesses");
}
#[test]
fn add_witness_preserves_body() {
let unsigned = fixture_unsigned_payment();
let body_hash_before = Tx::decode_fragment(&unsigned)
.unwrap()
.transaction_body
.compute_hash();
let signed = add_witness(&payment_from_canonical(), &unsigned).unwrap();
let body_hash_after = Tx::decode_fragment(&signed)
.unwrap()
.transaction_body
.compute_hash();
assert_eq!(
body_hash_before, body_hash_after,
"signing must not mutate the body — only the witness set"
);
}
#[test]
fn add_witness_rejects_garbage() {
let r = add_witness(&payment_from_canonical(), b"not cbor");
assert!(r.is_err());
}
}

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. ADAMaps treasury 2-of-2): 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()
}