Skip to content

Commit 2d29249

Browse files
committed
CIP102 implementation
1 parent 45354c2 commit 2d29249

3 files changed

Lines changed: 479 additions & 0 deletions

File tree

pycardano/cip/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
from .cip14 import *
55
from .cip67 import *
66
from .cip68 import *
7+
from .cip102 import *

pycardano/cip/cip102.py

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
from dataclasses import dataclass
2+
from typing import Any, List, Optional, Union
3+
4+
from cbor2 import CBORTag
5+
6+
from pycardano.cip.cip67 import CIP67TokenName, InvalidCIP67Token
7+
from pycardano.hash import ScriptHash, VerificationKeyHash
8+
from pycardano.plutus import PlutusData, Unit, Primitive
9+
from pycardano.serialization import IndefiniteList
10+
from pycardano.transaction import AssetName
11+
12+
13+
ROYALTY_TOKEN_LABEL = 500
14+
ROYALTY_TOKEN_PAYLOAD = b"Royalty"
15+
16+
17+
class InvalidCIP102Token(Exception):
18+
pass
19+
20+
21+
class CIP102RoyaltyTokenName(CIP67TokenName):
22+
"""Generates a CIP-102 royalty token name from an input postfix.
23+
24+
The royalty token name is a CIP-67 encoded token name with label ``500`` and
25+
payload ``"Royalty"`` followed by an optional integer postfix.
26+
27+
For more information on CIP-102:
28+
https://github.com/cardano-foundation/CIPs/tree/master/CIP-0102
29+
30+
Args:
31+
data: The token name as bytes, str, or AssetName
32+
"""
33+
34+
def __init__(self, data: Union[bytes, str, AssetName]):
35+
super().__init__(data)
36+
37+
if self.label != ROYALTY_TOKEN_LABEL:
38+
raise InvalidCIP102Token(
39+
f"Royalty token must have label {ROYALTY_TOKEN_LABEL}, "
40+
f"got {self.label}."
41+
)
42+
43+
if not self.payload[4:].startswith(ROYALTY_TOKEN_PAYLOAD):
44+
raise InvalidCIP102Token(
45+
f"Royalty token payload must start with 'Royalty', "
46+
f"got {self.payload[4:]}."
47+
)
48+
49+
@classmethod
50+
def from_postfix(cls, postfix: Optional[int] = None) -> "CIP102RoyaltyTokenName":
51+
"""Create a royalty token name with an optional integer postfix.
52+
53+
Args:
54+
postfix: Optional integer postfix to distinguish multiple royalty tokens
55+
under the same policy ID (version 2). If ``None``, creates the
56+
base ``(500)Royalty`` token for version 1.
57+
58+
Returns:
59+
CIP102RoyaltyTokenName: The constructed royalty token name.
60+
61+
Example:
62+
CIP102RoyaltyTokenName.from_postfix() # (500)Royalty
63+
CIP102RoyaltyTokenName.from_postfix(1) # (500)Royalty1
64+
CIP102RoyaltyTokenName.from_postfix(2) # (500)Royalty2
65+
"""
66+
from crc8 import crc8
67+
68+
label = ROYALTY_TOKEN_LABEL
69+
# CIP-67 stores the label in the upper 12 bits of the first 3 bytes.
70+
# data[1:5] (nibbles 1-4) are the CRC8 input, matching the validator.
71+
label_bytes = (label << 4).to_bytes(3, "big") # 3 bytes with label in upper 12 bits
72+
label_nibbles_for_crc = label_bytes.hex()[1:5] # e.g. "01f4" for label 500
73+
checksum = crc8(bytes.fromhex(label_nibbles_for_crc)).hexdigest()
74+
prefix = "0" + label_nibbles_for_crc + checksum + "0" # 8 hex chars = 4 bytes
75+
76+
payload = ROYALTY_TOKEN_PAYLOAD
77+
if postfix is not None:
78+
payload = payload + str(postfix).encode()
79+
80+
token_hex = prefix + payload.hex()
81+
return cls(token_hex)
82+
83+
@property
84+
def postfix(self) -> Optional[int]:
85+
"""Return the integer postfix of this royalty token, or ``None`` if absent."""
86+
suffix = self.payload[4 + len(ROYALTY_TOKEN_PAYLOAD) :]
87+
if not suffix:
88+
return None
89+
try:
90+
return int(suffix.decode())
91+
except (ValueError, UnicodeDecodeError):
92+
return None
93+
94+
95+
@dataclass
96+
class RoyaltyRecipientSomeMinFee(PlutusData):
97+
"""Plutus representation of ``optional_big_int`` when a value is present (``#6.121([big_int])``)."""
98+
99+
CONSTR_ID = 0
100+
value: int
101+
102+
103+
@dataclass
104+
class RoyaltyRecipientNoMinFee(PlutusData):
105+
"""Plutus representation of ``optional_big_int`` when no value is present (``#6.122([])``).
106+
107+
Maps to constructor 1 in Plutus alternate constructor encoding.
108+
"""
109+
110+
CONSTR_ID = 1
111+
112+
113+
def _make_optional_big_int(value: Optional[int]) -> PlutusData:
114+
"""Build the ``optional_big_int`` Plutus representation.
115+
116+
Args:
117+
value: An integer value, or ``None`` for the empty case.
118+
119+
Returns:
120+
``RoyaltyRecipientSomeMinFee(value)`` if value is set,
121+
``RoyaltyRecipientNoMinFee()`` if ``None``.
122+
"""
123+
if value is not None:
124+
return RoyaltyRecipientSomeMinFee(value)
125+
return RoyaltyRecipientNoMinFee()
126+
127+
128+
@dataclass
129+
class RoyaltyRecipient(PlutusData):
130+
"""A single royalty recipient as specified in the CIP-102 datum.
131+
132+
Encodes as ``#6.121([address, fee, min_fee, max_fee])`` in CBOR/Plutus.
133+
134+
The ``address`` field stores the raw Plutus address bytes as produced by
135+
:meth:`pycardano.address.Address.to_primitive`, matching the Plutus ledger
136+
address definition.
137+
138+
Args:
139+
address: Plutus address bytes (payment credential + optional staking credential).
140+
fee: Variable fee as integer denominator. The royalty percentage is
141+
``10 / fee`` (e.g., fee=625 → 1.6%).
142+
min_fee: Optional minimum royalty fee in lovelace.
143+
max_fee: Optional maximum royalty fee in lovelace.
144+
145+
For fee calculations see :mod:`pycardano.cip.cip102` module-level helpers.
146+
"""
147+
148+
CONSTR_ID = 0
149+
150+
address: bytes
151+
fee: int
152+
min_fee: Union[RoyaltyRecipientSomeMinFee, RoyaltyRecipientNoMinFee]
153+
max_fee: Union[RoyaltyRecipientSomeMinFee, RoyaltyRecipientNoMinFee]
154+
155+
@classmethod
156+
def new(
157+
cls,
158+
address: bytes,
159+
fee: int,
160+
min_fee: Optional[int] = None,
161+
max_fee: Optional[int] = None,
162+
) -> "RoyaltyRecipient":
163+
"""Construct a royalty recipient with optional min/max fee.
164+
165+
Args:
166+
address: Plutus address bytes.
167+
fee: On-chain fee denominator (``floor(10 / pct)``).
168+
min_fee: Minimum royalty in lovelace, or ``None``.
169+
max_fee: Maximum royalty in lovelace, or ``None``.
170+
171+
Returns:
172+
RoyaltyRecipient: The constructed recipient.
173+
"""
174+
return cls(
175+
address=address,
176+
fee=fee,
177+
min_fee=_make_optional_big_int(min_fee),
178+
max_fee=_make_optional_big_int(max_fee),
179+
)
180+
181+
182+
@dataclass
183+
class RoyaltyInfo(PlutusData):
184+
"""The CIP-102 royalty datum.
185+
186+
Encodes as ``#6.121([royalty_recipients, version, extra])`` in CBOR/Plutus,
187+
suitable for use as an inline datum on the royalty token UTxO.
188+
189+
For more information on CIP-102:
190+
https://github.com/cardano-foundation/CIPs/tree/master/CIP-0102
191+
192+
Args:
193+
recipients: List of :class:`RoyaltyRecipient` objects.
194+
version: Datum version. Use ``1`` for a single ``(500)Royalty`` token;
195+
use ``2`` when postfixed royalty tokens are involved.
196+
extra: Required extra field. Pass :class:`pycardano.plutus.Unit` for empty.
197+
198+
Example:
199+
from pycardano.plutus import Unit
200+
recipient = RoyaltyRecipient.new(address=bytes(29), fee=625)
201+
datum = RoyaltyInfo(recipients=[recipient], version=1, extra=Unit())
202+
cbor_hex = datum.to_cbor_hex()
203+
"""
204+
205+
CONSTR_ID = 0
206+
207+
recipients: List[RoyaltyRecipient]
208+
version: int
209+
extra: Any
210+
211+
def __post_init__(self):
212+
# Deliberately does not call super().__post_init__() to allow Any-typed
213+
# extra field (same pattern as CIP68Datum).
214+
pass
215+
216+
def to_shallow_primitive(self) -> CBORTag:
217+
"""Serialize to CBOR, wrapping the ``extra`` field appropriately."""
218+
primitives: Primitive = super().to_shallow_primitive()
219+
if isinstance(primitives, CBORTag):
220+
value = primitives.value
221+
if value:
222+
extra = value[2]
223+
if isinstance(extra, Unit):
224+
extra = CBORTag(121, IndefiniteList([]))
225+
elif isinstance(extra, CBORTag):
226+
extra = CBORTag(extra.tag, IndefiniteList(extra.value))
227+
recipients = value[0]
228+
value = [recipients, value[1], extra]
229+
return CBORTag(121, value)
230+
231+
232+
def fee_to_chain(pct: float) -> int:
233+
"""Convert a royalty percentage to the on-chain integer denominator.
234+
235+
The on-chain fee is stored as ``floor(10 / pct)`` (integer division with
236+
precision 10), so that ``pct = 10 / fee``.
237+
238+
Args:
239+
pct: Royalty percentage as a decimal (e.g., ``0.016`` for 1.6%).
240+
241+
Returns:
242+
int: The on-chain fee denominator.
243+
244+
Example:
245+
>>> fee_to_chain(0.016)
246+
625
247+
"""
248+
import math
249+
250+
return math.floor(10 / pct)
251+
252+
253+
def fee_from_chain(chain_fee: int) -> float:
254+
"""Convert an on-chain fee denominator back to a royalty percentage.
255+
256+
Args:
257+
chain_fee: The on-chain integer denominator stored in the royalty datum.
258+
259+
Returns:
260+
float: The royalty percentage (e.g., ``0.016`` for 1.6%).
261+
262+
Example:
263+
>>> fee_from_chain(625)
264+
0.016
265+
"""
266+
return 10 / chain_fee
267+
268+
269+
def calculate_royalty(
270+
chain_fee: int,
271+
sale_price: int,
272+
min_fee: Optional[int] = None,
273+
max_fee: Optional[int] = None,
274+
) -> int:
275+
"""Calculate the royalty amount for a given sale price.
276+
277+
Applies the CIP-102 formula::
278+
279+
max(min_fee, min(max_fee, (10 * sale_price) // chain_fee))
280+
281+
Args:
282+
chain_fee: On-chain fee denominator from the royalty datum.
283+
sale_price: Sale price in the same monetary unit as the royalty.
284+
min_fee: Optional minimum fee. If ``None``, no lower bound is applied.
285+
max_fee: Optional maximum fee. If ``None``, no upper bound is applied.
286+
287+
Returns:
288+
int: Calculated royalty amount.
289+
290+
Example:
291+
>>> calculate_royalty(625, 100_000_000) # 1.6% of 100 ADA
292+
1600000
293+
"""
294+
amount = (10 * sale_price) // chain_fee
295+
if max_fee is not None:
296+
amount = min(amount, max_fee)
297+
if min_fee is not None:
298+
amount = max(amount, min_fee)
299+
return amount

0 commit comments

Comments
 (0)