aldabra/crates/aldabra-core/src/cip68.rs
Sulkta 640af23598 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.
2026-05-04 12:27:43 -07:00

302 lines
10 KiB
Rust

//! 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());
}
}