diff --git a/poetry.lock b/poetry.lock index 74b91558..98546996 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -690,6 +690,17 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "crc8" +version = "0.2.0" +description = "A module that implements the CRC8 hash algorithm for Python 2 and 3." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "crc8-0.2.0.tar.gz", hash = "sha256:3c34d0a006ae8ddecfd744ae585eac95120915bf0770011aee9250017c0c40f1"}, +] + [[package]] name = "cryptography" version = "43.0.3" @@ -2655,4 +2666,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9.1" -content-hash = "cb6da2a4b65656b454733dc7fedffde729084d3f1aeaf08135ee8446c189265e" +content-hash = "8d5af0c5b650b7b2d299adb5a79f7d83b4329196756ac953eea4b68189ca30f8" diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 00000000..ab1033bd --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/pycardano/cip/__init__.py b/pycardano/cip/__init__.py index a3d1a71c..d5ea34d8 100644 --- a/pycardano/cip/__init__.py +++ b/pycardano/cip/__init__.py @@ -2,3 +2,6 @@ from .cip8 import * from .cip14 import * +from .cip67 import * +from .cip68 import * +from .cip102 import * diff --git a/pycardano/cip/cip102.py b/pycardano/cip/cip102.py new file mode 100644 index 00000000..f32ac916 --- /dev/null +++ b/pycardano/cip/cip102.py @@ -0,0 +1,299 @@ +from dataclasses import dataclass +from typing import Any, List, Optional, Union + +from cbor2 import CBORTag + +from pycardano.cip.cip67 import CIP67TokenName, InvalidCIP67Token +from pycardano.hash import ScriptHash, VerificationKeyHash +from pycardano.plutus import PlutusData, Unit, Primitive +from pycardano.serialization import IndefiniteList +from pycardano.transaction import AssetName + + +ROYALTY_TOKEN_LABEL = 500 +ROYALTY_TOKEN_PAYLOAD = b"Royalty" + + +class InvalidCIP102Token(Exception): + pass + + +class CIP102RoyaltyTokenName(CIP67TokenName): + """Generates a CIP-102 royalty token name from an input postfix. + + The royalty token name is a CIP-67 encoded token name with label ``500`` and + payload ``"Royalty"`` followed by an optional integer postfix. + + For more information on CIP-102: + https://github.com/cardano-foundation/CIPs/tree/master/CIP-0102 + + Args: + data: The token name as bytes, str, or AssetName + """ + + def __init__(self, data: Union[bytes, str, AssetName]): + super().__init__(data) + + if self.label != ROYALTY_TOKEN_LABEL: + raise InvalidCIP102Token( + f"Royalty token must have label {ROYALTY_TOKEN_LABEL}, " + f"got {self.label}." + ) + + if not self.payload[4:].startswith(ROYALTY_TOKEN_PAYLOAD): + raise InvalidCIP102Token( + f"Royalty token payload must start with 'Royalty', " + f"got {self.payload[4:]}." + ) + + @classmethod + def from_postfix(cls, postfix: Optional[int] = None) -> "CIP102RoyaltyTokenName": + """Create a royalty token name with an optional integer postfix. + + Args: + postfix: Optional integer postfix to distinguish multiple royalty tokens + under the same policy ID (version 2). If ``None``, creates the + base ``(500)Royalty`` token for version 1. + + Returns: + CIP102RoyaltyTokenName: The constructed royalty token name. + + Example: + CIP102RoyaltyTokenName.from_postfix() # (500)Royalty + CIP102RoyaltyTokenName.from_postfix(1) # (500)Royalty1 + CIP102RoyaltyTokenName.from_postfix(2) # (500)Royalty2 + """ + from crc8 import crc8 + + label = ROYALTY_TOKEN_LABEL + # CIP-67 stores the label in the upper 12 bits of the first 3 bytes. + # data[1:5] (nibbles 1-4) are the CRC8 input, matching the validator. + label_bytes = (label << 4).to_bytes(3, "big") # 3 bytes with label in upper 12 bits + label_nibbles_for_crc = label_bytes.hex()[1:5] # e.g. "01f4" for label 500 + checksum = crc8(bytes.fromhex(label_nibbles_for_crc)).hexdigest() + prefix = "0" + label_nibbles_for_crc + checksum + "0" # 8 hex chars = 4 bytes + + payload = ROYALTY_TOKEN_PAYLOAD + if postfix is not None: + payload = payload + str(postfix).encode() + + token_hex = prefix + payload.hex() + return cls(token_hex) + + @property + def postfix(self) -> Optional[int]: + """Return the integer postfix of this royalty token, or ``None`` if absent.""" + suffix = self.payload[4 + len(ROYALTY_TOKEN_PAYLOAD) :] + if not suffix: + return None + try: + return int(suffix.decode()) + except (ValueError, UnicodeDecodeError): + return None + + +@dataclass +class RoyaltyRecipientSomeMinFee(PlutusData): + """Plutus representation of ``optional_big_int`` when a value is present (``#6.121([big_int])``).""" + + CONSTR_ID = 0 + value: int + + +@dataclass +class RoyaltyRecipientNoMinFee(PlutusData): + """Plutus representation of ``optional_big_int`` when no value is present (``#6.122([])``). + + Maps to constructor 1 in Plutus alternate constructor encoding. + """ + + CONSTR_ID = 1 + + +def _make_optional_big_int(value: Optional[int]) -> PlutusData: + """Build the ``optional_big_int`` Plutus representation. + + Args: + value: An integer value, or ``None`` for the empty case. + + Returns: + ``RoyaltyRecipientSomeMinFee(value)`` if value is set, + ``RoyaltyRecipientNoMinFee()`` if ``None``. + """ + if value is not None: + return RoyaltyRecipientSomeMinFee(value) + return RoyaltyRecipientNoMinFee() + + +@dataclass +class RoyaltyRecipient(PlutusData): + """A single royalty recipient as specified in the CIP-102 datum. + + Encodes as ``#6.121([address, fee, min_fee, max_fee])`` in CBOR/Plutus. + + The ``address`` field stores the raw Plutus address bytes as produced by + :meth:`pycardano.address.Address.to_primitive`, matching the Plutus ledger + address definition. + + Args: + address: Plutus address bytes (payment credential + optional staking credential). + fee: Variable fee as integer denominator. The royalty percentage is + ``10 / fee`` (e.g., fee=625 → 1.6%). + min_fee: Optional minimum royalty fee in lovelace. + max_fee: Optional maximum royalty fee in lovelace. + + For fee calculations see :mod:`pycardano.cip.cip102` module-level helpers. + """ + + CONSTR_ID = 0 + + address: bytes + fee: int + min_fee: Union[RoyaltyRecipientSomeMinFee, RoyaltyRecipientNoMinFee] + max_fee: Union[RoyaltyRecipientSomeMinFee, RoyaltyRecipientNoMinFee] + + @classmethod + def new( + cls, + address: bytes, + fee: int, + min_fee: Optional[int] = None, + max_fee: Optional[int] = None, + ) -> "RoyaltyRecipient": + """Construct a royalty recipient with optional min/max fee. + + Args: + address: Plutus address bytes. + fee: On-chain fee denominator (``floor(10 / pct)``). + min_fee: Minimum royalty in lovelace, or ``None``. + max_fee: Maximum royalty in lovelace, or ``None``. + + Returns: + RoyaltyRecipient: The constructed recipient. + """ + return cls( + address=address, + fee=fee, + min_fee=_make_optional_big_int(min_fee), + max_fee=_make_optional_big_int(max_fee), + ) + + +@dataclass +class RoyaltyInfo(PlutusData): + """The CIP-102 royalty datum. + + Encodes as ``#6.121([royalty_recipients, version, extra])`` in CBOR/Plutus, + suitable for use as an inline datum on the royalty token UTxO. + + For more information on CIP-102: + https://github.com/cardano-foundation/CIPs/tree/master/CIP-0102 + + Args: + recipients: List of :class:`RoyaltyRecipient` objects. + version: Datum version. Use ``1`` for a single ``(500)Royalty`` token; + use ``2`` when postfixed royalty tokens are involved. + extra: Required extra field. Pass :class:`pycardano.plutus.Unit` for empty. + + Example: + from pycardano.plutus import Unit + recipient = RoyaltyRecipient.new(address=bytes(29), fee=625) + datum = RoyaltyInfo(recipients=[recipient], version=1, extra=Unit()) + cbor_hex = datum.to_cbor_hex() + """ + + CONSTR_ID = 0 + + recipients: List[RoyaltyRecipient] + version: int + extra: Any + + def __post_init__(self): + # Deliberately does not call super().__post_init__() to allow Any-typed + # extra field (same pattern as CIP68Datum). + pass + + def to_shallow_primitive(self) -> CBORTag: + """Serialize to CBOR, wrapping the ``extra`` field appropriately.""" + primitives: Primitive = super().to_shallow_primitive() + if isinstance(primitives, CBORTag): + value = primitives.value + if value: + extra = value[2] + if isinstance(extra, Unit): + extra = CBORTag(121, IndefiniteList([])) + elif isinstance(extra, CBORTag): + extra = CBORTag(extra.tag, IndefiniteList(extra.value)) + recipients = value[0] + value = [recipients, value[1], extra] + return CBORTag(121, value) + + +def fee_to_chain(pct: float) -> int: + """Convert a royalty percentage to the on-chain integer denominator. + + The on-chain fee is stored as ``floor(10 / pct)`` (integer division with + precision 10), so that ``pct = 10 / fee``. + + Args: + pct: Royalty percentage as a decimal (e.g., ``0.016`` for 1.6%). + + Returns: + int: The on-chain fee denominator. + + Example: + >>> fee_to_chain(0.016) + 625 + """ + import math + + return math.floor(10 / pct) + + +def fee_from_chain(chain_fee: int) -> float: + """Convert an on-chain fee denominator back to a royalty percentage. + + Args: + chain_fee: The on-chain integer denominator stored in the royalty datum. + + Returns: + float: The royalty percentage (e.g., ``0.016`` for 1.6%). + + Example: + >>> fee_from_chain(625) + 0.016 + """ + return 10 / chain_fee + + +def calculate_royalty( + chain_fee: int, + sale_price: int, + min_fee: Optional[int] = None, + max_fee: Optional[int] = None, +) -> int: + """Calculate the royalty amount for a given sale price. + + Applies the CIP-102 formula:: + + max(min_fee, min(max_fee, (10 * sale_price) // chain_fee)) + + Args: + chain_fee: On-chain fee denominator from the royalty datum. + sale_price: Sale price in the same monetary unit as the royalty. + min_fee: Optional minimum fee. If ``None``, no lower bound is applied. + max_fee: Optional maximum fee. If ``None``, no upper bound is applied. + + Returns: + int: Calculated royalty amount. + + Example: + >>> calculate_royalty(625, 100_000_000) # 1.6% of 100 ADA + 1600000 + """ + amount = (10 * sale_price) // chain_fee + if max_fee is not None: + amount = min(amount, max_fee) + if min_fee is not None: + amount = max(amount, min_fee) + return amount diff --git a/pycardano/cip/cip67.py b/pycardano/cip/cip67.py new file mode 100644 index 00000000..7e45bbbf --- /dev/null +++ b/pycardano/cip/cip67.py @@ -0,0 +1,53 @@ +from typing import Union + +from crc8 import crc8 + +from pycardano.transaction import AssetName + + +class InvalidCIP67Token(Exception): + pass + + +class CIP67TokenName(AssetName): + """Implementation of CIP67 token naming scheme. + + This class enforces the CIP67 token naming format for Cardano native assets, requiring + a 4-byte token label with CRC8 checksum and brackets. + + For more information: + https://github.com/cardano-foundation/CIPs/tree/master/CIP-0067 + + Args: + data: The token name as 'bytes', 'str', or 'AssetName' + """ + def __repr__(self): + return f"{self.__class__.__name__}({self.payload})" + + def __init__(self, data: Union[bytes, str, AssetName]): + if isinstance(data, AssetName): + data = data.payload + + if isinstance(data, bytes): + data = data.hex() + + if data[0] != "0" or data[7] != "0": + raise InvalidCIP67Token( + "The first and eighth hex values must be 0. Instead found:\n" + + f"first={data[0]}\n" + + f"eigth={data[7]}" + ) + + checksum = crc8(bytes.fromhex(data[1:5])).hexdigest() + if data[5:7] != checksum: + raise InvalidCIP67Token( + f"Token label {data[1:5]} does not match token checksum.\n" + + f"expected={checksum}\n" + + f"received={data[5:7]}" + ) + + super().__init__(bytes.fromhex(data)) + + @property + def label(self) -> int: + return int.from_bytes(self.payload[:3], "big") >> 4 \ No newline at end of file diff --git a/pycardano/cip/cip68.py b/pycardano/cip/cip68.py new file mode 100644 index 00000000..fd834f6f --- /dev/null +++ b/pycardano/cip/cip68.py @@ -0,0 +1,160 @@ +from typing import Union, Dict, List, Any, TypedDict +from typing_extensions import Required +from dataclasses import dataclass +from cbor2 import CBORTag + +from pycardano.cip.cip67 import CIP67TokenName +from pycardano.plutus import PlutusData, Unit, Primitive +from pycardano.transaction import AssetName +from pycardano.serialization import IndefiniteList + + +class InvalidCIP68ReferenceNFT(Exception): + pass + + +class CIP68TokenName(CIP67TokenName): + """Generates a CIP-68 reference token name from an input asset name. + + The reference_token property generates a reference token name by slicing off the label + portion of the asset name and assigning the (100) label hex value. + + For more information on CIP-68 labels: + https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068 + + Args: + data: The token name as bytes, str, or AssetName + """ + @property + def reference_token(self) -> "CIP68ReferenceNFTName": + ref_token = self.payload.hex()[0] + "00643b" + self.payload.hex()[7:] + + return CIP68ReferenceNFTName(ref_token) + + +class CIP68ReferenceNFTName(CIP68TokenName): + """Validates that an asset name has the 100 label for reference NFTs.""" + def __init__(self, data: Union[bytes, str, AssetName]): + super().__init__(data) + + if self.label != 100: + raise InvalidCIP68ReferenceNFT("Reference NFT must have label 100.") + + +class CIP68UserNFTName(CIP68TokenName): + """Validates that an asset name has the 222 label for NFTs.""" + def __init__(self, data: Union[bytes, str, AssetName]): + super().__init__(data) + + if self.label != 222: + raise InvalidCIP68ReferenceNFT("User NFT must have label 222.") + + +class CIP68UserNFTFile(TypedDict, total=False): + """Metadata for a single file in NFT metadata.""" + name: bytes + mediaType: Required[bytes] + src: Required[bytes] + + +class CIP68UserNFTMetadata(TypedDict, total=False): + """Metadata for a user NFT. + + Multiple files can be included as a list of dictionaries or CIP68UserNFTFile objects. + """ + name: Required[bytes] + image: Required[bytes] + description: bytes + files: Union[List[CIP68UserNFTFile], None] + + +class CIP68UserFTName(CIP68TokenName): + """Validates that an asset name has the 333 label for FTs.""" + def __init__(self, data: Union[bytes, str, AssetName]): + super().__init__(data) + + if self.label != 333: + raise InvalidCIP68ReferenceNFT("User NFT must have label 333.") + + +class CIP68UserFTMetadata(TypedDict, total=False): + name: Required[bytes] + description: Required[bytes] + ticker: bytes + url: bytes + logo: bytes + decimals: int + + +class CIP68UserRFTName(CIP68TokenName): + """Validates that an asset name has the 444 label for RFTs.""" + def __init__(self, data: Union[bytes, str, AssetName]): + super().__init__(data) + + if self.label != 444: + raise InvalidCIP68ReferenceNFT("User NFT must have label 444.") + + +class CIP68UserRFTMetadata(TypedDict, total=False): + name: Required[bytes] + image: Required[bytes] + description: bytes + + +@dataclass +class CIP68Datum(PlutusData): + """Wrapper class for CIP-68 metadata to be used as inline datum. + + For detailed information on CIP-68 metadata structure and token types: + https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068 + + This class wraps metadata dictionaries in a PlutusData class for attaching to a + reference NFT transaction as an inline datum. + + Args: + metadata: A metadata dictionary. TypedDict classes are provided to define required + fields for each token type. + version: Metadata version number as 'int' + extra: Required - must be a PlutusData, or Unit() for empty PlutusData. + + Example: + metadata = { + b"name": b"My NFT", + b"image": b"ipfs://...", + b"files": [{"mediaType": b"image/png", "src": b"ipfs://..."}] + } + datum = CIP68Datum(metadata=metadata, version=1, extra=Unit()) + """ + CONSTR_ID = 0 + + metadata: Dict[bytes, Any] + version: int + extra: Any # This should be PlutusData or Unit() for empty PlutusData + + def __post_init__(self): + converted_metadata: Dict[bytes, Any] = {} + for k, v in self.metadata.items(): + key = k.encode() if isinstance(k, str) else k + if isinstance(v, dict): + v = dict((k.encode() if isinstance(k, str) else k, v) for k, v in v.items()) + elif isinstance(v, list): + v = IndefiniteList([dict((k.encode() if isinstance(k, str) else k, v) for k, v in item.items()) + if isinstance(item, dict) else item for item in v]) + converted_metadata[key] = v + self.metadata = converted_metadata + + def to_shallow_primitive(self) -> CBORTag: + """Wraps PlutusData in 'extra' field in an indefinite list when converted to a CBOR primitive.""" + primitives: Primitive = super().to_shallow_primitive() + if isinstance(primitives, CBORTag): + value = primitives.value + if value: + extra = value[2] + if isinstance(extra, Unit): + extra = CBORTag(121, IndefiniteList([])) + elif isinstance(extra, CBORTag): + extra = CBORTag(extra.tag, IndefiniteList(extra.value)) + value = [value[0], value[1], extra] + return CBORTag(121, value) + + \ No newline at end of file diff --git a/pycardano/serialization.py b/pycardano/serialization.py index 141342b9..bfe6571b 100644 --- a/pycardano/serialization.py +++ b/pycardano/serialization.py @@ -75,7 +75,10 @@ def _identity(x): class IndefiniteList(UserList): def __init__(self, li: Primitive): # type: ignore - super().__init__(li) # type: ignore + if isinstance(li, FrozenList): + super().__init__(list(li)) # type: ignore + else: + super().__init__(li) # type: ignore class IndefiniteFrozenList(FrozenList, IndefiniteList): # type: ignore diff --git a/pyproject.toml b/pyproject.toml index c8aaadfa..d6d0de02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ mnemonic = ">=0.21" ECPy = ">=1.2.5" frozendict = ">=2.3.8" frozenlist = ">=1.3.3" +crc8 = ">=0.1.0" cachetools = ">=5.3.0" docker = ">=7.1.0" ogmios = ">=1.4.2" diff --git a/test/pycardano/test_cip102.py b/test/pycardano/test_cip102.py new file mode 100644 index 00000000..5fe38d57 --- /dev/null +++ b/test/pycardano/test_cip102.py @@ -0,0 +1,179 @@ +import pytest + +from pycardano.cip.cip102 import ( + CIP102RoyaltyTokenName, + InvalidCIP102Token, + RoyaltyInfo, + RoyaltyRecipient, + RoyaltyRecipientNoMinFee, + RoyaltyRecipientSomeMinFee, + calculate_royalty, + fee_from_chain, + fee_to_chain, +) +from pycardano.cip.cip67 import InvalidCIP67Token +from pycardano.plutus import PlutusData, Unit + + +def assert_roundtrip(obj: PlutusData) -> None: + serialized = obj.to_cbor_hex() + deserialized = obj.__class__.from_cbor(serialized) + reserialized = deserialized.to_cbor_hex() + assert serialized == reserialized + + +# ── Token name ────────────────────────────────────────────────────────────── + +class TestCIP102RoyaltyTokenName: + def test_from_postfix_none(self): + token = CIP102RoyaltyTokenName.from_postfix() + assert token.label == 500 + assert token.postfix is None + # Spec hex: 001f4d70526f79616c7479 + assert token.payload.hex() == "001f4d70526f79616c7479" + + @pytest.mark.parametrize("postfix", [1, 2, 10]) + def test_from_postfix_int(self, postfix): + token = CIP102RoyaltyTokenName.from_postfix(postfix) + assert token.label == 500 + assert token.postfix == postfix + # payload ends with "Royalty{postfix}" + assert token.payload[4:] == f"Royalty{postfix}".encode() + + def test_roundtrip_from_hex(self): + token = CIP102RoyaltyTokenName.from_postfix(3) + reconstructed = CIP102RoyaltyTokenName(token.payload) + assert reconstructed.payload == token.payload + assert reconstructed.postfix == 3 + + def test_invalid_label(self): + # label 222 (user NFT) — wrong label, fails checksum first + with pytest.raises((InvalidCIP102Token, InvalidCIP67Token)): + CIP102RoyaltyTokenName("000de1404d794e4654") + + def test_invalid_payload(self): + # Build a valid CIP-67 token with label 500 but wrong payload + from crc8 import crc8 + label_bytes = (500 << 4).to_bytes(3, "big") + label_nibbles_for_crc = label_bytes.hex()[1:5] # "01f4" + checksum = crc8(bytes.fromhex(label_nibbles_for_crc)).hexdigest() + prefix = "0" + label_nibbles_for_crc + checksum + "0" + wrong_payload = b"NotRoyalty".hex() + with pytest.raises(InvalidCIP102Token): + CIP102RoyaltyTokenName(prefix + wrong_payload) + + def test_spec_hex_roundtrip(self): + """The base royalty token hex from the CIP spec must be accepted.""" + spec_hex = "001f4d70526f79616c7479" + token = CIP102RoyaltyTokenName(spec_hex) + assert token.label == 500 + assert token.postfix is None + + +# ── Fee helpers ────────────────────────────────────────────────────────────── + +class TestFeeHelpers: + @pytest.mark.parametrize("pct,expected_chain_fee", [ + (0.016, 625), # 1.6% + (0.02, 500), # 2.0% + (0.025, 400), # 2.5% + (0.05, 200), # 5% + (0.10, 100), # 10% + ]) + def test_fee_to_chain(self, pct, expected_chain_fee): + assert fee_to_chain(pct) == expected_chain_fee + + @pytest.mark.parametrize("chain_fee,expected_pct", [ + (625, 0.016), + (500, 0.02), + (200, 0.05), + ]) + def test_fee_from_chain(self, chain_fee, expected_pct): + assert abs(fee_from_chain(chain_fee) - expected_pct) < 1e-9 + + def test_fee_roundtrip(self): + for pct in [0.01, 0.016, 0.02, 0.05, 0.1]: + chain = fee_to_chain(pct) + back = fee_from_chain(chain) + # Floating-point round-trip may not be exact, but close enough + assert abs(back - pct) < 0.001 + + +class TestCalculateRoyalty: + def test_basic(self): + # 1.6% of 100 ADA (100_000_000 lovelace) + assert calculate_royalty(625, 100_000_000) == 1_600_000 + + def test_max_fee_clamps(self): + # Without max: 2% of 1000 ADA = 20 ADA; max set to 5 ADA + result = calculate_royalty(500, 1_000_000_000, max_fee=5_000_000) + assert result == 5_000_000 + + def test_min_fee_floor(self): + # 2% of 1 ADA = 0.02 ADA (20000 lovelace); min set to 1 ADA + result = calculate_royalty(500, 1_000_000, min_fee=1_000_000) + assert result == 1_000_000 + + def test_no_clamps(self): + result = calculate_royalty(500, 100_000_000, min_fee=None, max_fee=None) + assert result == 2_000_000 + + def test_both_clamps(self): + # min=1, max=3 ADA; 2% of 100 ADA = 2 ADA → within bounds + result = calculate_royalty(500, 100_000_000, min_fee=1_000_000, max_fee=3_000_000) + assert result == 2_000_000 + + +# ── RoyaltyRecipient ───────────────────────────────────────────────────────── + +# Dummy Plutus address bytes — arbitrary 58-byte enterprise address encoding +_DUMMY_ADDR = bytes(29) + + +class TestRoyaltyRecipient: + def test_new_no_fees(self): + r = RoyaltyRecipient.new(address=_DUMMY_ADDR, fee=625) + assert r.fee == 625 + assert isinstance(r.min_fee, RoyaltyRecipientNoMinFee) + assert isinstance(r.max_fee, RoyaltyRecipientNoMinFee) + + def test_new_with_fees(self): + r = RoyaltyRecipient.new(address=_DUMMY_ADDR, fee=500, min_fee=1_000_000, max_fee=5_000_000) + assert isinstance(r.min_fee, RoyaltyRecipientSomeMinFee) + assert r.min_fee.value == 1_000_000 + assert isinstance(r.max_fee, RoyaltyRecipientSomeMinFee) + assert r.max_fee.value == 5_000_000 + + def test_roundtrip_no_fees(self): + r = RoyaltyRecipient.new(address=_DUMMY_ADDR, fee=625) + assert_roundtrip(r) + + def test_roundtrip_with_fees(self): + r = RoyaltyRecipient.new(address=_DUMMY_ADDR, fee=500, min_fee=1_000_000, max_fee=10_000_000) + assert_roundtrip(r) + + +# ── RoyaltyInfo ────────────────────────────────────────────────────────────── + +class TestRoyaltyInfo: + def test_single_recipient_v1(self): + r = RoyaltyRecipient.new(address=_DUMMY_ADDR, fee=625) + info = RoyaltyInfo(recipients=[r], version=1, extra=Unit()) + assert info.version == 1 + assert len(info.recipients) == 1 + assert_roundtrip(info) + + def test_multiple_recipients_v2(self): + r1 = RoyaltyRecipient.new(address=_DUMMY_ADDR, fee=625) + r2 = RoyaltyRecipient.new(address=_DUMMY_ADDR, fee=500, min_fee=500_000) + info = RoyaltyInfo(recipients=[r1, r2], version=2, extra=Unit()) + assert info.version == 2 + assert len(info.recipients) == 2 + assert_roundtrip(info) + + def test_cbor_hex_is_string(self): + r = RoyaltyRecipient.new(address=_DUMMY_ADDR, fee=625) + info = RoyaltyInfo(recipients=[r], version=1, extra=Unit()) + cbor_hex = info.to_cbor_hex() + assert isinstance(cbor_hex, str) + assert len(cbor_hex) > 0 diff --git a/test/pycardano/test_cip67.py b/test/pycardano/test_cip67.py new file mode 100644 index 00000000..6084db59 --- /dev/null +++ b/test/pycardano/test_cip67.py @@ -0,0 +1,63 @@ +import pytest + +from pycardano.cip.cip67 import CIP67TokenName, InvalidCIP67Token +from pycardano.transaction import AssetName, Value, MultiAsset, Asset +from pycardano.hash import ScriptHash + + +@pytest.mark.parametrize("token_data", [ + # Valid tokens + ("000643b0617273656372797074", 100), # Reference NFT with label 100 + ("000de1404d794e4654", 222), # NFT with label 222 + ("0014df10546f6b656e31", 333), # FT with label 333 + ("001bc280546f6b656e31", 444), # RFT with label 444 + # Invalid tokens + pytest.param( + ("100643b0617273656372797074", None), # Invalid first hex + marks=pytest.mark.xfail(raises=InvalidCIP67Token), + id="invalid_first_hex" + ), + pytest.param( + ("000643b1617273656372797074", None), # Invalid last hex + marks=pytest.mark.xfail(raises=InvalidCIP67Token), + id="invalid_last_hex" + ), + pytest.param( + ("00064300617273656372797074", None), # Invalid checksum + marks=pytest.mark.xfail(raises=InvalidCIP67Token), + id="invalid_checksum" + ), + pytest.param( + ("000643b", None), # Too short + marks=pytest.mark.xfail(raises=(InvalidCIP67Token, IndexError)), + id="too_short" + ), +]) +def test_cip67_token_name_format(token_data): + token_str, expected_label = token_data + # Create a Value object with asset names and dummy policyID + policy = ScriptHash.from_primitive("00000000000000000000000000000000000000000000000000000000") + asset = Asset() + asset[AssetName(token_str)] = 1 + multi_asset = MultiAsset() + multi_asset[policy] = asset + value = Value(0, multi_asset) + # Extract the AssetName from the Value object and create CIP67TokenName + token_name = next(iter(next(iter(value.multi_asset.values())).keys())) + token = CIP67TokenName(token_name) + + if expected_label is not None: + assert token.label == expected_label + + +def test_cip67_input_types(): + token_str = "000643b0617273656372797074" + CIP67TokenName(token_str) # string input + CIP67TokenName(bytes.fromhex(token_str)) # bytes input + CIP67TokenName(AssetName(bytes.fromhex(token_str))) # AssetName input + + with pytest.raises(TypeError): + CIP67TokenName(123) # int input should fail + with pytest.raises(TypeError): + CIP67TokenName(None) + diff --git a/test/pycardano/test_cip68.py b/test/pycardano/test_cip68.py new file mode 100644 index 00000000..e83745aa --- /dev/null +++ b/test/pycardano/test_cip68.py @@ -0,0 +1,132 @@ +import pytest +from dataclasses import dataclass + +from pycardano.cip.cip68 import ( + CIP68TokenName, + CIP68ReferenceNFTName, + CIP68UserNFTName, + CIP68UserFTName, + CIP68UserRFTName, + CIP68Datum, + InvalidCIP68ReferenceNFT, + CIP68UserNFTFile, + CIP68UserNFTMetadata +) +from pycardano.cip.cip67 import InvalidCIP67Token +from pycardano.plutus import Unit, PlutusData + + +def assert_roundtrip(obj: PlutusData) -> None: + serialized = obj.to_cbor_hex() + deserialized = obj.__class__.from_cbor(serialized) + reserialized = deserialized.to_cbor_hex() + assert serialized == reserialized + + +@pytest.mark.parametrize("token_name,token_class,expected_label,expected_reference_token", [ + # (token_name, token_class, expected_label, expected_reference_token) + ("000643b04d794e4654", CIP68ReferenceNFTName, 100, "000643b04d794e4654"), # Reference NFT (100) + ("000de1404d794e4654", CIP68UserNFTName, 222, "000643b04d794e4654"), # User NFT (222) + ("0014df10546f6b656e", CIP68UserFTName, 333, "000643b0546f6b656e"), # User FT (333) + ("001bc280546f6b656e", CIP68UserRFTName, 444, "000643b0546f6b656e"), # User RFT (444) +]) +def test_cip68_label_and_reference(token_name, token_class, expected_label, expected_reference_token): + # Label validation + token = token_class(token_name) + assert token.label == expected_label + # Reference token generation + ref_token = token.reference_token + assert ref_token.payload == bytes.fromhex(expected_reference_token) + assert ref_token.label == 100 + + # Test Invalid label - fails checksum + invalid_token = "000000004d794e4654" + with pytest.raises(InvalidCIP68ReferenceNFT): + CIP68ReferenceNFTName(invalid_token) + + +def test_cip68_string_key_conversion(): + files = CIP68UserNFTFile( + mediaType=b"image/png", + src=b"ipfs://QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco" + ) + metadata = CIP68UserNFTMetadata( + name=b"My NFT", + image=b"https://example.com/image.jpeg", + description=b"This is a description of my NFT", + files=[files] + ) + datum = CIP68Datum(metadata=metadata, version=1, extra=Unit()) + assert b"name" in datum.metadata + assert b"image" in datum.metadata + assert b"description" in datum.metadata + assert b"files" in datum.metadata + assert b"mediaType" in datum.metadata[b"files"][0] + assert b"src" in datum.metadata[b"files"][0] + assert_roundtrip(datum) + + +def test_cip68_multiple_files(): + files1 = CIP68UserNFTFile( + mediaType=b"image/png", + src=b"ipfs://QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco" + ) + files2 = CIP68UserNFTFile( + mediaType=b"image/png", + src=b"ipfs://QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco" + ) + metadata = CIP68UserNFTMetadata( + name=b"My NFT", + image=b"https://example.com/image.jpeg", + files=[files1, files2] + ) + datum = CIP68Datum(metadata=metadata, version=1, extra=Unit()) + assert b"mediaType" in datum.metadata[b"files"][0] + assert b"src" in datum.metadata[b"files"][0] + assert b"mediaType" in datum.metadata[b"files"][1] + assert b"src" in datum.metadata[b"files"][1] + assert_roundtrip(datum) + + +def test_cip68_with_extra(): + metadata = CIP68UserNFTMetadata( + name=b"My NFT", + image=b"ipfs://Qm..." + ) + + @dataclass + class CustomData(PlutusData): + CONSTR_ID = 2 + value: bytes + count: int + + extra_data = CustomData(value=b"test value", count=42) + + datum_with_extra = CIP68Datum( + metadata=metadata, + version=1, + extra=extra_data + ) + assert datum_with_extra.extra.value == b"test value" + assert datum_with_extra.extra.count == 42 + assert datum_with_extra.extra.CONSTR_ID == 2 + assert_roundtrip(datum_with_extra) + + +@pytest.mark.parametrize("onchain_datum", [ + # ADA Handle: $handle + "d8799fab446e616d65472468616e646c6545696d6167655838697066733a2f2f7a646a3757687465384638454d666a54625541637036356f574c426f5445677934647a64386b4c61784239394a55437847496d65646961547970654a696d6167652f6a706567426f6700496f675f6e756d626572004672617269747946636f6d6d6f6e466c656e677468064a63686172616374657273476c657474657273516e756d657269635f6d6f64696669657273404b68616e646c655f747970654668616e646c654776657273696f6e0101b4527265736f6c7665645f616464726573736573a04862675f696d6167655f5840697066733a2f2f62616679626569676e376e71367971786c64786d61677274766b6779326368737561706d78686e3566616b6d766c6966637a6c6f747a736c6d426971ff497066705f696d6167654046706f7274616c5838697066733a2f2f7a6232726857666d6433416d795646784368626b766a75363241447539714a7047325545514246587341725747677276374864657369676e65725838697066733a2f2f7a623272686377626b6536326e634b326e5239686e704e4a743165564a666d424e536473594e313647455550714843614b47736f6369616c735838697066733a2f2f7a623272685a4d50315457466234366f7842766369314c666b6d3146386f5a4c6369555768736e6a417245784e4d72684c4676656e646f72404764656661756c74004e7374616e646172645f696d6167655838697066733a2f2f7a623272686d6f503932516973576468733736655559734c62483835636673346d6b4a7a596d363965413145505a595753536c6173745f7570646174655f616464726573735839018e41aa027f2351ee8e0279ab05e7d92acaa4a2735650bd51c6564413c67e12eb7cf98da0d2fa795fb7c20060c964f2ceeba0feae4d5c9b2d4c76616c6964617465645f6279581c4da965a049dfd15ed1ee19fba6e2974a0b79fc416dd1796a1f97f5e14a696d6167655f686173685820c102fe43ea1c6919bcffb570c6cc7eaf07cfcdb98fdc32a1e26398cddaf725d9537374616e646172645f696d6167655f686173685820e134411636b3a147dde4763cff01d651aacd1a5a397c11736810020cf95cf3074b7376675f76657273696f6e46332e302e31354c6167726565645f7465726d735768747470733a2f2f68616e646c652e6d652f242f746f75546d6967726174655f7369675f726571756972656400446e7366770045747269616c004a707a5f656e61626c6564014862675f6173736574582eb06e84cae01ef5871a6fe6ac556134e21b4b8eb55b833cd3dac95126001bc28048616e646c652043617264203238ff", + # ADA Handle: $steelswap + "d8799fab446e616d654a24737465656c7377617045696d6167655838697066733a2f2f7a623272686b6a4a334e595465747068736e7a624e797667763134366f34684472654861677437434a366e6b6d6474694e496d65646961547970654a696d6167652f6a706567426f6700496f675f6e756d6265720046726172697479456261736963466c656e677468094a63686172616374657273476c657474657273516e756d657269635f6d6f64696669657273404776657273696f6e014b68616e646c655f747970654668616e646c6501af4e7374616e646172645f696d6167655838697066733a2f2f7a623272686b6a4a334e595465747068736e7a624e797667763134366f34684472654861677437434a366e6b6d6474694e537374616e646172645f696d6167655f686173685820d14c7af907d68d30e64367ea3a3e67e94158817172d5a4ce45d234a75788f28b4a696d6167655f686173685820d14c7af907d68d30e64367ea3a3e67e94158817172d5a4ce45d234a75788f28b46706f7274616c404864657369676e65724047736f6369616c73404676656e646f72404764656661756c7400536c6173745f7570646174655f61646472657373583901661ae4b23b24ba9656d78b7637e6a66e889fa788c16c88017e494052c2c5baab297046f996aea6faa54eb92b6005bdb22c8288de08064e374c76616c6964617465645f6279581c4da965a049dfd15ed1ee19fba6e2974a0b79fc416dd1796a1f97f5e14b7376675f76657273696f6e45332e302e384c6167726565645f7465726d735768747470733a2f2f68616e646c652e6d652f242f746f75546d6967726174655f7369675f72657175697265640045747269616c00446e73667700ff", + # NFT: Space Bud + "d8799fa5446e616d654e5370616365427564202338313034467472616974739f4a4368657374706c6174654442656c744e436f76657265642048656c6d65744a576f6f6c20426f6f747346416e63686f72ff447479706545416c69656e45696d6167655f5840697066733a2f2f6261666b726569626e77647635646f6f706536636d796e36726d6865776e6b6672706e777361376a71353770376f78686f686f6c6664683475423571ff4673686132353658202db0ebd1b9cf2784cc37d161c966a8b17b6d207d30efdff75cee3b96519f94ec01d87980ff", + # NFT: Baby Sneklet + pytest.param( + "d8799fa6446e616d655042616279536e616b6c657420233830334566696c65739fa3437372635835697066733a2f2f516d546d533361676a43385a6833586a78625166666448686a685973336669375a375a396d666435756843684473446e616d655342616279536e616b6c6574303830332e706e67496d656469615479706549696d6167652f706e67ff45696d6167655835697066733a2f2f516d546d533361676a43385a6833586a78625166666448686a685973336669375a375a396d6664357568436844734757656273697465581968747470733a2f2f62616279736e656b6c6574732e66756e2f496d656469615479706549696d6167652f706e674a61747472696275746573a844426f64794f5065726977696e6b6c6520426c756544457965734f416c77617973205761746368696e674448656164464d7573736564445461696c4e546f756368646f776e2042616c6c445479706547526567756c6172454d6f75746848426967204269746547436c6f7468657354507572706c65205a6f6d62696520486f6f6469654a4261636b67726f756e644b4f72616e6765205065656c01ff", + marks=pytest.mark.xfail(reason="Missing empty PlutusData in extra field"), + id="babysneklet" + ), +]) +def test_cip68_onchain_datum(onchain_datum): + datum = CIP68Datum.from_cbor(bytes.fromhex(onchain_datum)) + assert_roundtrip(datum) \ No newline at end of file