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.
302 lines
10 KiB
Rust
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());
|
|
}
|
|
}
|