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:
parent
a93a2b7cfa
commit
f376481a8f
5 changed files with 903 additions and 5 deletions
302
crates/aldabra-core/src/cip68.rs
Normal file
302
crates/aldabra-core/src/cip68.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
189
crates/aldabra-core/src/sign.rs
Normal file
189
crates/aldabra-core/src/sign.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue