diff --git a/poetry.lock b/poetry.lock index 2f781027..ae82768c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -590,6 +590,16 @@ 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 = "*" +files = [ + {file = "crc8-0.2.0.tar.gz", hash = "sha256:3c34d0a006ae8ddecfd744ae585eac95120915bf0770011aee9250017c0c40f1"}, +] + [[package]] name = "cryptography" version = "43.0.3" 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..b47c4a12 --- /dev/null +++ b/pycardano/cip/cip68.py @@ -0,0 +1,159 @@ +from typing import Union, Dict, List, Any, TypedDict, 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/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