diff --git a/crates/aldabra-core/src/cip68.rs b/crates/aldabra-core/src/cip68.rs new file mode 100644 index 0000000..bb2132a --- /dev/null +++ b/crates/aldabra-core/src/cip68.rs @@ -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, 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, 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, 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, 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` +/// - object → `Map` with bytes-encoded keys. +fn json_to_plutus_data(v: &Value) -> Result { + 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, 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) -> Vec { + 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()); + } +} diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index eaeea05..4c04699 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -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, diff --git a/crates/aldabra-core/src/mint.rs b/crates/aldabra-core/src/mint.rs index aeb685f..fdce560 100644 --- a/crates/aldabra-core/src/mint.rs +++ b/crates/aldabra-core/src/mint.rs @@ -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, 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 = available_utxos.to_vec(); + sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace)); + let mut acc: u64 = 0; + let mut selected: Vec = 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 = 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 { + 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 = 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; diff --git a/crates/aldabra-core/src/sign.rs b/crates/aldabra-core/src/sign.rs new file mode 100644 index 0000000..bcac7a4 --- /dev/null +++ b/crates/aldabra-core/src/sign.rs @@ -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, 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 { + 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()); + } +} diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 2a426ad..a5ada18 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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, } +#[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, + /// 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, +} + +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 { + 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 = 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 { + 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() }