Skip to content

Commit ef3bc85

Browse files
Merge pull request #3 from Cat-Treat/feat/cip67-cip68
Feat/cip67 cip68
2 parents 092b5f6 + 7e8ddea commit ef3bc85

File tree

4 files changed

+310
-38
lines changed

4 files changed

+310
-38
lines changed

pycardano/cip/cip67.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ class InvalidCIP67Token(Exception):
1010

1111

1212
class CIP67TokenName(AssetName):
13+
"""Implementation of CIP67 token naming scheme.
14+
15+
This class enforces the CIP67 token naming format for Cardano native assets, requiring
16+
a 4-byte token label with CRC8 checksum and brackets.
17+
18+
For more information:
19+
https://github.com/cardano-foundation/CIPs/tree/master/CIP-0067
20+
21+
Args:
22+
data: The token name as 'bytes', 'str', or 'AssetName'
23+
"""
1324
def __repr__(self):
1425
return f"{self.__class__.__name__}({self.payload})"
1526

@@ -39,4 +50,4 @@ def __init__(self, data: Union[bytes, str, AssetName]):
3950

4051
@property
4152
def label(self) -> int:
42-
return int.from_bytes(self.payload[:3], "big") >> 4
53+
return int.from_bytes(self.payload[:3], "big") >> 4

pycardano/cip/cip68.py

Lines changed: 103 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
1-
from typing import Union
1+
from typing import Union, Dict, List, Any, TypedDict, Required
2+
from dataclasses import dataclass
3+
from cbor2 import CBORTag
24

35
from pycardano.cip.cip67 import CIP67TokenName
4-
from pycardano.plutus import PlutusData
5-
from pycardano.serialization import ArrayCBORSerializable
6-
from pycardano.serialization import MapCBORSerializable
6+
from pycardano.plutus import PlutusData, Unit, Primitive
77
from pycardano.transaction import AssetName
8+
from pycardano.serialization import IndefiniteList
89

910

1011
class InvalidCIP68ReferenceNFT(Exception):
1112
pass
1213

1314

1415
class CIP68TokenName(CIP67TokenName):
16+
"""Generates a CIP-68 reference token name from an input asset name.
17+
18+
The reference_token property generates a reference token name by slicing off the label
19+
portion of the asset name and assigning the (100) label hex value.
20+
21+
For more information on CIP-68 labels:
22+
https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068
23+
24+
Args:
25+
data: The token name as bytes, str, or AssetName
26+
"""
1527
@property
1628
def reference_token(self) -> "CIP68ReferenceNFTName":
1729
ref_token = self.payload.hex()[0] + "00643b" + self.payload.hex()[7:]
@@ -20,6 +32,7 @@ def reference_token(self) -> "CIP68ReferenceNFTName":
2032

2133

2234
class CIP68ReferenceNFTName(CIP68TokenName):
35+
"""Validates that an asset name has the 100 label for reference NFTs."""
2336
def __init__(self, data: Union[bytes, str, AssetName]):
2437
super().__init__(data)
2538

@@ -28,66 +41,119 @@ def __init__(self, data: Union[bytes, str, AssetName]):
2841

2942

3043
class CIP68UserNFTName(CIP68TokenName):
44+
"""Validates that an asset name has the 222 label for NFTs."""
3145
def __init__(self, data: Union[bytes, str, AssetName]):
3246
super().__init__(data)
3347

3448
if self.label != 222:
3549
raise InvalidCIP68ReferenceNFT("User NFT must have label 222.")
3650

3751

38-
class CIP68UserNFTFiles(MapCBORSerializable):
39-
name: Union[bytes, None] = None
40-
mediaType: bytes
41-
src: bytes
52+
class CIP68UserNFTFile(TypedDict, total=False):
53+
"""Metadata for a single file in NFT metadata."""
54+
name: bytes
55+
mediaType: Required[bytes]
56+
src: Required[bytes]
4257

4358

44-
class CIP68UserNFTMetadata(MapCBORSerializable):
45-
name: bytes
46-
image: bytes
47-
description: Union[bytes, None] = None
48-
files: Union[CIP68UserNFTFiles, None] = None
59+
class CIP68UserNFTMetadata(TypedDict, total=False):
60+
"""Metadata for a user NFT.
61+
62+
Multiple files can be included as a list of dictionaries or CIP68UserNFTFile objects.
63+
"""
64+
name: Required[bytes]
65+
image: Required[bytes]
66+
description: bytes
67+
files: Union[List[CIP68UserNFTFile], None]
4968

5069

5170
class CIP68UserFTName(CIP68TokenName):
71+
"""Validates that an asset name has the 333 label for FTs."""
5272
def __init__(self, data: Union[bytes, str, AssetName]):
5373
super().__init__(data)
5474

5575
if self.label != 333:
5676
raise InvalidCIP68ReferenceNFT("User NFT must have label 333.")
5777

5878

59-
class CIP68UserFTMetadata(MapCBORSerializable):
60-
name: bytes
61-
description: bytes
62-
ticker: Union[bytes, None] = None
63-
url: Union[bytes, None] = None
64-
decimals: Union[int, None] = None
65-
logo: Union[bytes, None] = None
79+
class CIP68UserFTMetadata(TypedDict, total=False):
80+
name: Required[bytes]
81+
description: Required[bytes]
82+
ticker: bytes
83+
url: bytes
84+
logo: bytes
85+
decimals: int
6686

6787

6888
class CIP68UserRFTName(CIP68TokenName):
89+
"""Validates that an asset name has the 444 label for RFTs."""
6990
def __init__(self, data: Union[bytes, str, AssetName]):
7091
super().__init__(data)
7192

7293
if self.label != 444:
7394
raise InvalidCIP68ReferenceNFT("User NFT must have label 444.")
7495

7596

76-
class CIP68UserRFTMetadata(MapCBORSerializable):
77-
name: bytes
78-
image: bytes
79-
description: Union[bytes, None] = None
80-
decimals: Union[int, None] = None
81-
files: Union[CIP68UserNFTFiles, None] = None
82-
83-
84-
class CIP68Metadata(ArrayCBORSerializable):
85-
metadata: Union[
86-
CIP68UserNFTMetadata,
87-
CIP68UserFTMetadata,
88-
CIP68UserRFTMetadata,
89-
MapCBORSerializable,
90-
ArrayCBORSerializable,
91-
]
97+
class CIP68UserRFTMetadata(TypedDict, total=False):
98+
name: Required[bytes]
99+
image: Required[bytes]
100+
description: bytes
101+
102+
103+
@dataclass
104+
class CIP68Datum(PlutusData):
105+
"""Wrapper class for CIP-68 metadata to be used as inline datum.
106+
107+
For detailed information on CIP-68 metadata structure and token types:
108+
https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068
109+
110+
This class wraps metadata dictionaries in a PlutusData class for attaching to a
111+
reference NFT transaction as an inline datum.
112+
113+
Args:
114+
metadata: A metadata dictionary. TypedDict classes are provided to define required
115+
fields for each token type.
116+
version: Metadata version number as 'int'
117+
extra: Required - must be a PlutusData, or Unit() for empty PlutusData.
118+
119+
Example:
120+
metadata = {
121+
b"name": b"My NFT",
122+
b"image": b"ipfs://...",
123+
b"files": [{"mediaType": b"image/png", "src": b"ipfs://..."}]
124+
}
125+
datum = CIP68Datum(metadata=metadata, version=1, extra=Unit())
126+
"""
127+
CONSTR_ID = 0
128+
129+
metadata: Dict[bytes, Any]
92130
version: int
93-
extra: Union[PlutusData, None] = None
131+
extra: Any # This should be PlutusData or Unit() for empty PlutusData
132+
133+
def __post_init__(self):
134+
converted_metadata: Dict[bytes, Any] = {}
135+
for k, v in self.metadata.items():
136+
key = k.encode() if isinstance(k, str) else k
137+
if isinstance(v, dict):
138+
v = dict((k.encode() if isinstance(k, str) else k, v) for k, v in v.items())
139+
elif isinstance(v, list):
140+
v = IndefiniteList([dict((k.encode() if isinstance(k, str) else k, v) for k, v in item.items())
141+
if isinstance(item, dict) else item for item in v])
142+
converted_metadata[key] = v
143+
self.metadata = converted_metadata
144+
145+
def to_shallow_primitive(self) -> CBORTag:
146+
"""Wraps PlutusData in 'extra' field in an indefinite list when converted to a CBOR primitive."""
147+
primitives: Primitive = super().to_shallow_primitive()
148+
if isinstance(primitives, CBORTag):
149+
value = primitives.value
150+
if value:
151+
extra = value[2]
152+
if isinstance(extra, Unit):
153+
extra = CBORTag(121, IndefiniteList([]))
154+
elif isinstance(extra, CBORTag):
155+
extra = CBORTag(extra.tag, IndefiniteList(extra.value))
156+
value = [value[0], value[1], extra]
157+
return CBORTag(121, value)
158+
159+

test/pycardano/test_cip67.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import pytest
2+
3+
from pycardano.cip.cip67 import CIP67TokenName, InvalidCIP67Token
4+
from pycardano.transaction import AssetName, Value, MultiAsset, Asset
5+
from pycardano.hash import ScriptHash
6+
7+
8+
@pytest.mark.parametrize("token_data", [
9+
# Valid tokens
10+
("000643b0617273656372797074", 100), # Reference NFT with label 100
11+
("000de1404d794e4654", 222), # NFT with label 222
12+
("0014df10546f6b656e31", 333), # FT with label 333
13+
("001bc280546f6b656e31", 444), # RFT with label 444
14+
# Invalid tokens
15+
pytest.param(
16+
("100643b0617273656372797074", None), # Invalid first hex
17+
marks=pytest.mark.xfail(raises=InvalidCIP67Token),
18+
id="invalid_first_hex"
19+
),
20+
pytest.param(
21+
("000643b1617273656372797074", None), # Invalid last hex
22+
marks=pytest.mark.xfail(raises=InvalidCIP67Token),
23+
id="invalid_last_hex"
24+
),
25+
pytest.param(
26+
("00064300617273656372797074", None), # Invalid checksum
27+
marks=pytest.mark.xfail(raises=InvalidCIP67Token),
28+
id="invalid_checksum"
29+
),
30+
pytest.param(
31+
("000643b", None), # Too short
32+
marks=pytest.mark.xfail(raises=(InvalidCIP67Token, IndexError)),
33+
id="too_short"
34+
),
35+
])
36+
def test_cip67_token_name_format(token_data):
37+
token_str, expected_label = token_data
38+
# Create a Value object with asset names and dummy policyID
39+
policy = ScriptHash.from_primitive("00000000000000000000000000000000000000000000000000000000")
40+
asset = Asset()
41+
asset[AssetName(token_str)] = 1
42+
multi_asset = MultiAsset()
43+
multi_asset[policy] = asset
44+
value = Value(0, multi_asset)
45+
# Extract the AssetName from the Value object and create CIP67TokenName
46+
token_name = next(iter(next(iter(value.multi_asset.values())).keys()))
47+
token = CIP67TokenName(token_name)
48+
49+
if expected_label is not None:
50+
assert token.label == expected_label
51+
52+
53+
def test_cip67_input_types():
54+
token_str = "000643b0617273656372797074"
55+
CIP67TokenName(token_str) # string input
56+
CIP67TokenName(bytes.fromhex(token_str)) # bytes input
57+
CIP67TokenName(AssetName(bytes.fromhex(token_str))) # AssetName input
58+
59+
with pytest.raises(TypeError):
60+
CIP67TokenName(123) # int input should fail
61+
with pytest.raises(TypeError):
62+
CIP67TokenName(None)
63+

0 commit comments

Comments
 (0)