Skip to content

WIP: CIP67 and CIP68 Support #297

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fdfd3e2
Added extended signing key support for cip8
theeldermillenial Oct 6, 2023
01230ac
Fixed unused imports, flake8 checks pass.
theeldermillenial Oct 6, 2023
143e843
Fixed mypy error for overloaded variable
theeldermillenial Oct 11, 2023
049c6bf
Remove extraneous parameter for verify
theeldermillenial Oct 18, 2023
434a163
Merge branch 'main' of https://github.com/Python-Cardano/pycardano in…
theeldermillenial Nov 3, 2023
1bf3f81
Added ByteString to _restored_typed_primitive
theeldermillenial Nov 3, 2023
67add7a
Added type checking
theeldermillenial Nov 3, 2023
f0644e7
Merge pull request #1 from theeldermillenial/bugfix/bytestring
theeldermillenial Nov 3, 2023
1edb549
Merge branch 'main' of https://github.com/Python-Cardano/pycardano
theeldermillenial Jan 10, 2024
f9c8754
Added support for CIP 14
theeldermillenial Jan 10, 2024
8f80fbc
Added support for ScriptHash and AssetName
theeldermillenial Jan 10, 2024
eec89fd
Merge branch 'main' of https://github.com/Python-Cardano/pycardano in…
theeldermillenial Jan 11, 2024
d77e045
WIP: support for cip67/68
theeldermillenial Jan 11, 2024
2df945e
Updated poetry lock
theeldermillenial Jan 12, 2024
ddf05d2
Merge branch 'feat/cip67-cip68' of https://github.com/theeldermilleni…
Cat-Treat Apr 10, 2025
fed24ba
Merge remote-tracking branch 'pycardano/main' into feat/cip67-cip68
Cat-Treat Apr 11, 2025
1e24168
Merge remote-tracking branch 'pycardano/main' into feat/cip67-cip68
Cat-Treat Apr 11, 2025
092b5f6
Merge pull request #2 from Cat-Treat/feat/cip67-cip68
theeldermillenial Apr 15, 2025
d9fa5e8
Add unit tests for CIP67
Cat-Treat Apr 22, 2025
b71893f
feat: implement complete cip67/68 with tests
Cat-Treat Jun 4, 2025
ad0b0f4
Merge branch 'feat/cip67-cip68' of https://github.com/Cat-Treat/pycar…
Cat-Treat Jun 4, 2025
b820eb5
Cleaned up unused code comments
Cat-Treat Jun 4, 2025
4695493
docs: added documentation for CIP-67 and CIP-68
Cat-Treat Jun 5, 2025
7e8ddea
Renamed CIP68UserNFTFile for clarification and improved tests
Cat-Treat Jun 17, 2025
ef3bc85
Merge pull request #3 from Cat-Treat/feat/cip67-cip68
theeldermillenial Aug 4, 2025
6abe56b
Merge branch 'Python-Cardano:main' into feat/cip67-cip68
theeldermillenial Aug 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions pycardano/cip/cip67.py
Original file line number Diff line number Diff line change
@@ -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
159 changes: 159 additions & 0 deletions pycardano/cip/cip68.py
Original file line number Diff line number Diff line change
@@ -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)


63 changes: 63 additions & 0 deletions test/pycardano/test_cip67.py
Original file line number Diff line number Diff line change
@@ -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)

Loading
Loading