Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
35bcafb
add crc8 to pyproject.toml
SamDelaney May 28, 2026
86852cd
Merge branch 'main' of https://github.com/python-cardano/pycardano in…
SamDelaney May 30, 2026
ecd93ae
fix IndefiniteList initializiation crash when wrapping IndefiniteFroz…
SamDelaney May 31, 2026
ded182f
import .cip67 & .cip68 in __init__
SamDelaney Jun 1, 2026
45354c2
import Required from typing_extensions to support older python version
SamDelaney Jun 2, 2026
2d29249
CIP102 implementation
SamDelaney Jun 1, 2026
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
15 changes: 13 additions & 2 deletions poetry.lock

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

2 changes: 2 additions & 0 deletions poetry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[virtualenvs]
in-project = true
3 changes: 3 additions & 0 deletions pycardano/cip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@

from .cip8 import *
from .cip14 import *
from .cip67 import *
from .cip68 import *
from .cip102 import *
299 changes: 299 additions & 0 deletions pycardano/cip/cip102.py
Original file line number Diff line number Diff line change
@@ -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
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
Loading