playweb-node 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. playweb/__init__.py +18 -0
  2. playweb/api/__init__.py +4 -0
  3. playweb/api/erc20.py +66 -0
  4. playweb/api/erc721.py +88 -0
  5. playweb/api/metadata.py +86 -0
  6. playweb/api/node_api.py +224 -0
  7. playweb/api/public_api.py +496 -0
  8. playweb/api/rpc.py +247 -0
  9. playweb/client.py +341 -0
  10. playweb/config.py +168 -0
  11. playweb/consensus/__init__.py +5 -0
  12. playweb/consensus/leader.py +50 -0
  13. playweb/consensus/nvf_bft.py +540 -0
  14. playweb/consensus/vote.py +108 -0
  15. playweb/core/__init__.py +6 -0
  16. playweb/core/block.py +156 -0
  17. playweb/core/blockchain.py +358 -0
  18. playweb/core/fee_engine.py +312 -0
  19. playweb/core/mempool.py +112 -0
  20. playweb/core/royalty_engine.py +207 -0
  21. playweb/core/transaction.py +270 -0
  22. playweb/network/__init__.py +6 -0
  23. playweb/network/bootstrap.py +217 -0
  24. playweb/network/gossip.py +222 -0
  25. playweb/network/peer_manager.py +164 -0
  26. playweb/network/sync.py +244 -0
  27. playweb/node.py +303 -0
  28. playweb/plugin/__init__.py +4 -0
  29. playweb/plugin/base_plugin.py +225 -0
  30. playweb/plugin/plugin_manager.py +131 -0
  31. playweb/registry/__init__.py +4 -0
  32. playweb/registry/content_registry.py +259 -0
  33. playweb/registry/edition_registry.py +220 -0
  34. playweb/storage/__init__.py +11 -0
  35. playweb/storage/base.py +154 -0
  36. playweb/storage/ram_storage.py +123 -0
  37. playweb/storage/sqlite_storage.py +369 -0
  38. playweb/storage/supabase_storage.py +389 -0
  39. playweb_node-1.0.0.dist-info/METADATA +416 -0
  40. playweb_node-1.0.0.dist-info/RECORD +43 -0
  41. playweb_node-1.0.0.dist-info/WHEEL +5 -0
  42. playweb_node-1.0.0.dist-info/licenses/LICENSE +201 -0
  43. playweb_node-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,312 @@
1
+ """
2
+ PlayWebit Network — Fee Engine
3
+ Handles all L1 fee logic.
4
+ 50/50 split enforced here — no node operator can override.
5
+ Every block is validated against these rules.
6
+ """
7
+
8
+ import logging
9
+ from typing import List, Tuple, Dict
10
+
11
+ from playweb.config import (
12
+ AUTHORITY_WALLET,
13
+ TRANSACTION_FEE,
14
+ CV_LINK_FEE,
15
+ PLWB_REDEMPTION_FEE,
16
+ FEE_SPLIT_AUTHORITY,
17
+ FEE_SPLIT_NODE,
18
+ SPLITTABLE_FEE_TYPES,
19
+ AUTHORITY_ONLY_FEE_TYPES,
20
+ AUTHORITY_TX_TYPES,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class FeeEngine:
27
+
28
+ def __init__(self):
29
+ pass
30
+
31
+ # ─────────────────────────────────────────────────────────────
32
+ # Calculate
33
+ # ─────────────────────────────────────────────────────────────
34
+
35
+ def calculate_fee(self, tx_type: str, amount: float = 0) -> Dict:
36
+ """
37
+ Calculate the fee for a given transaction type.
38
+ Returns:
39
+ {
40
+ total: float,
41
+ authority_cut: float,
42
+ node_cut: float,
43
+ fee_type: str, "split" | "authority_only" | "none"
44
+ }
45
+ """
46
+ # ── Authority tx types never have fees ───────────────────
47
+ # genesis, reward, plwb_purchase, plwb_redeem,
48
+ # spider_hash_anchor — all free
49
+ if tx_type in AUTHORITY_TX_TYPES:
50
+ return {
51
+ "total": 0.0,
52
+ "authority_cut": 0.0,
53
+ "node_cut": 0.0,
54
+ "fee_type": "none",
55
+ }
56
+
57
+ # ── Base network fee (50/50 split) ────────────────────────
58
+ if tx_type == "fee":
59
+ return self._split(TRANSACTION_FEE)
60
+
61
+ # ── CID link fee (50/50 split) ────────────────────────────
62
+ if tx_type == "cv_link":
63
+ return self._split(CV_LINK_FEE)
64
+
65
+ # ── All standard user-facing tx types → 1 PLWB network fee
66
+ if tx_type in (
67
+ "transfer", # PLWB transfer between any wallets
68
+ "content_register", # register CID ownership
69
+ "ownership_transfer", # transfer content (1:1 model)
70
+ "edition_transfer", # transfer edition (creator model)
71
+ "node_register", # platform registers on network
72
+ ):
73
+ return self._split(TRANSACTION_FEE)
74
+
75
+ # ── Redemption fee → 100% authority ──────────────────────
76
+ if tx_type == "plwb_redeem":
77
+ total = amount * PLWB_REDEMPTION_FEE
78
+ return {
79
+ "total": total,
80
+ "authority_cut": total,
81
+ "node_cut": 0.0,
82
+ "fee_type": "authority_only",
83
+ }
84
+
85
+ # ── No fee for unknown/other tx types ────────────────────
86
+ return {
87
+ "total": 0.0,
88
+ "authority_cut": 0.0,
89
+ "node_cut": 0.0,
90
+ "fee_type": "none",
91
+ }
92
+
93
+ def _split(self, total: float) -> Dict:
94
+ """50/50 split between authority and node operator."""
95
+ authority_cut = round(total * FEE_SPLIT_AUTHORITY, 8)
96
+ node_cut = round(total * FEE_SPLIT_NODE, 8)
97
+ return {
98
+ "total": total,
99
+ "authority_cut": authority_cut,
100
+ "node_cut": node_cut,
101
+ "fee_type": "split",
102
+ }
103
+
104
+ # ─────────────────────────────────────────────────────────────
105
+ # Create fee transactions
106
+ # ─────────────────────────────────────────────────────────────
107
+
108
+ def create_fee_transactions(
109
+ self,
110
+ original_tx,
111
+ node_wallet: str,
112
+ ) -> List:
113
+ """
114
+ Create the fee transactions for a given original transaction.
115
+ Returns list of Transaction objects to add to the block.
116
+
117
+ Split fees → 2 transactions (authority + node)
118
+ Authority-only fees → 1 transaction (authority only)
119
+ No fee → []
120
+ """
121
+ from playweb.core.transaction import Transaction
122
+
123
+ fee_info = self.calculate_fee(original_tx.tx_type, original_tx.amount)
124
+
125
+ if fee_info["fee_type"] == "none":
126
+ return []
127
+
128
+ fee_txs = []
129
+
130
+ if fee_info["fee_type"] == "split":
131
+ # Authority cut
132
+ if fee_info["authority_cut"] > 0:
133
+ fee_txs.append(Transaction(
134
+ from_addr = original_tx.from_addr,
135
+ to_addr = AUTHORITY_WALLET.lower(),
136
+ amount = fee_info["authority_cut"],
137
+ tx_type = "fee",
138
+ nonce = original_tx.nonce + 1,
139
+ data = {
140
+ "fee_for": original_tx.hash,
141
+ "fee_split": "authority",
142
+ "split_pct": FEE_SPLIT_AUTHORITY,
143
+ }
144
+ ))
145
+
146
+ # Node operator cut
147
+ if fee_info["node_cut"] > 0 and node_wallet:
148
+ fee_txs.append(Transaction(
149
+ from_addr = original_tx.from_addr,
150
+ to_addr = node_wallet.lower(),
151
+ amount = fee_info["node_cut"],
152
+ tx_type = "fee",
153
+ nonce = original_tx.nonce + 2,
154
+ data = {
155
+ "fee_for": original_tx.hash,
156
+ "fee_split": "node",
157
+ "split_pct": FEE_SPLIT_NODE,
158
+ }
159
+ ))
160
+
161
+ elif fee_info["fee_type"] == "authority_only":
162
+ if fee_info["authority_cut"] > 0:
163
+ fee_txs.append(Transaction(
164
+ from_addr = original_tx.from_addr,
165
+ to_addr = AUTHORITY_WALLET.lower(),
166
+ amount = fee_info["authority_cut"],
167
+ tx_type = "fee",
168
+ nonce = original_tx.nonce + 1,
169
+ data = {
170
+ "fee_for": original_tx.hash,
171
+ "fee_split": "authority_only",
172
+ }
173
+ ))
174
+
175
+ return fee_txs
176
+
177
+ # ─────────────────────────────────────────────────────────────
178
+ # Validate fee transactions in a block
179
+ # Called by every node during consensus
180
+ # This prevents fee manipulation
181
+ # ─────────────────────────────────────────────────────────────
182
+
183
+ def validate_fee_transactions(
184
+ self,
185
+ block,
186
+ node_wallet: str,
187
+ ) -> Tuple[bool, str]:
188
+ """
189
+ Validate that all fee transactions in a block are correct.
190
+ Every honest node runs this during consensus.
191
+ Returns (is_valid, reason).
192
+ """
193
+ transactions = block.transactions
194
+
195
+ # Build map of fee txs keyed by the tx they're paying for
196
+ fee_map: Dict[str, List] = {}
197
+ for tx in transactions:
198
+ if tx.tx_type == "fee":
199
+ fee_for = tx.data.get("fee_for") if tx.data else None
200
+ if fee_for:
201
+ fee_map.setdefault(fee_for, []).append(tx)
202
+
203
+ # Validate each non-fee transaction
204
+ for tx in transactions:
205
+ if tx.tx_type == "fee":
206
+ continue
207
+
208
+ # Authority tx types never have fee transactions
209
+ if tx.tx_type in AUTHORITY_TX_TYPES:
210
+ continue
211
+
212
+ fee_info = self.calculate_fee(tx.tx_type, tx.amount)
213
+
214
+ if fee_info["fee_type"] == "none":
215
+ # Should have no fee transactions
216
+ if tx.hash in fee_map:
217
+ return False, f"Unexpected fee for tx {tx.hash[:12]}"
218
+ continue
219
+
220
+ fees = fee_map.get(tx.hash, [])
221
+
222
+ if fee_info["fee_type"] == "split":
223
+ if len(fees) != 2:
224
+ return (
225
+ False,
226
+ f"Expected 2 fee txs for {tx.hash[:12]}, "
227
+ f"got {len(fees)}"
228
+ )
229
+
230
+ # Validate total fee amount is correct
231
+ total_fee_paid = sum(f.amount for f in fees)
232
+ expected_total = fee_info["total"]
233
+ if round(total_fee_paid, 8) != round(expected_total, 8):
234
+ return (
235
+ False,
236
+ f"Wrong total fee for {tx.hash[:12]}: "
237
+ f"expected {expected_total}, got {total_fee_paid}"
238
+ )
239
+
240
+ # When authority wallet == node wallet (same address)
241
+ # both fee txs go to same wallet — that's correct
242
+ if AUTHORITY_WALLET.lower() == node_wallet.lower():
243
+ # Both fees go to same wallet — just check amounts
244
+ assert len(fees) == 2
245
+ for f in fees:
246
+ if f.to_addr != AUTHORITY_WALLET.lower():
247
+ return (
248
+ False,
249
+ f"Fee going to wrong wallet: {f.to_addr}"
250
+ )
251
+ else:
252
+ # Different wallets — check 50/50 split properly
253
+ auth_fees = [
254
+ f for f in fees
255
+ if f.to_addr == AUTHORITY_WALLET.lower()
256
+ ]
257
+ node_fees = [
258
+ f for f in fees
259
+ if f.to_addr == node_wallet.lower()
260
+ ]
261
+
262
+ if len(auth_fees) != 1:
263
+ return (
264
+ False,
265
+ f"Missing authority fee for tx {tx.hash[:12]}"
266
+ )
267
+ if len(node_fees) != 1:
268
+ return (
269
+ False,
270
+ f"Missing node fee for tx {tx.hash[:12]}"
271
+ )
272
+
273
+ expected_auth = round(fee_info["total"] * FEE_SPLIT_AUTHORITY, 8)
274
+ expected_node = round(fee_info["total"] * FEE_SPLIT_NODE, 8)
275
+
276
+ if round(auth_fees[0].amount, 8) != expected_auth:
277
+ return (
278
+ False,
279
+ f"Wrong authority fee: expected {expected_auth}, "
280
+ f"got {auth_fees[0].amount}"
281
+ )
282
+ if round(node_fees[0].amount, 8) != expected_node:
283
+ return (
284
+ False,
285
+ f"Wrong node fee: expected {expected_node}, "
286
+ f"got {node_fees[0].amount}"
287
+ )
288
+
289
+ elif fee_info["fee_type"] == "authority_only":
290
+ if len(fees) != 1:
291
+ return (
292
+ False,
293
+ f"Expected 1 fee tx for {tx.hash[:12]}, "
294
+ f"got {len(fees)}"
295
+ )
296
+ if fees[0].to_addr != AUTHORITY_WALLET.lower():
297
+ return (
298
+ False,
299
+ f"Authority-only fee going to wrong wallet: "
300
+ f"{fees[0].to_addr}"
301
+ )
302
+
303
+ return True, "Valid"
304
+
305
+ def get_required_balance_for_tx(
306
+ self,
307
+ tx_type: str,
308
+ amount: float,
309
+ ) -> float:
310
+ """How much PLWB does sender need? amount + all fees."""
311
+ fee_info = self.calculate_fee(tx_type, amount)
312
+ return amount + fee_info["total"]
@@ -0,0 +1,112 @@
1
+ """
2
+ PlayWebit Network — Mempool
3
+ Pending transactions — lives in RAM only.
4
+ Lost on restart — peers will resend. That's fine.
5
+ """
6
+
7
+ import time
8
+ import logging
9
+ from typing import Dict, List, Optional
10
+
11
+ from playweb.core.transaction import Transaction
12
+ from playweb.config import MAX_TX_PER_BLOCK
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Max age of a pending transaction before it's dropped (10 minutes)
17
+ TX_MAX_AGE = 600
18
+
19
+
20
+ class Mempool:
21
+
22
+ def __init__(self):
23
+ # tx_hash → Transaction
24
+ self._pool: Dict[str, Transaction] = {}
25
+
26
+ # ─────────────────────────────────────────────────────────────
27
+ # Add
28
+ # ─────────────────────────────────────────────────────────────
29
+
30
+ def add(self, tx: Transaction) -> tuple[bool, str]:
31
+ """
32
+ Add a transaction to the mempool.
33
+ Returns (success, reason).
34
+ """
35
+ if tx.hash in self._pool:
36
+ return False, "Transaction already in mempool"
37
+
38
+ valid, reason = tx.validate_fields()
39
+ if not valid:
40
+ return False, reason
41
+
42
+ self._pool[tx.hash] = tx
43
+ logger.debug(f"Mempool: added {tx.hash[:12]} ({tx.tx_type})")
44
+ return True, "Added"
45
+
46
+ # ─────────────────────────────────────────────────────────────
47
+ # Get
48
+ # ─────────────────────────────────────────────────────────────
49
+
50
+ def get_pending(self, limit: int = MAX_TX_PER_BLOCK) -> List[Transaction]:
51
+ """
52
+ Return pending transactions sorted by timestamp (oldest first).
53
+ Automatically drops expired transactions.
54
+ """
55
+ self._drop_expired()
56
+ txs = sorted(self._pool.values(), key=lambda tx: tx.timestamp)
57
+ return txs[:limit]
58
+
59
+ def get(self, tx_hash: str) -> Optional[Transaction]:
60
+ return self._pool.get(tx_hash)
61
+
62
+ def contains(self, tx_hash: str) -> bool:
63
+ return tx_hash in self._pool
64
+
65
+ def size(self) -> int:
66
+ return len(self._pool)
67
+
68
+ # ─────────────────────────────────────────────────────────────
69
+ # Remove
70
+ # ─────────────────────────────────────────────────────────────
71
+
72
+ def remove(self, tx_hash: str):
73
+ """Remove a transaction after it's been included in a block."""
74
+ if tx_hash in self._pool:
75
+ del self._pool[tx_hash]
76
+ logger.debug(f"Mempool: removed {tx_hash[:12]}")
77
+
78
+ def remove_batch(self, tx_hashes: List[str]):
79
+ """Remove multiple transactions at once after block is finalised."""
80
+ for h in tx_hashes:
81
+ self.remove(h)
82
+
83
+ def clear(self):
84
+ self._pool.clear()
85
+ logger.debug("Mempool: cleared")
86
+
87
+ # ─────────────────────────────────────────────────────────────
88
+ # Maintenance
89
+ # ─────────────────────────────────────────────────────────────
90
+
91
+ def _drop_expired(self):
92
+ """Drop transactions older than TX_MAX_AGE seconds."""
93
+ now = time.time()
94
+ expired = [
95
+ h for h, tx in self._pool.items()
96
+ if now - tx.timestamp > TX_MAX_AGE
97
+ ]
98
+ for h in expired:
99
+ del self._pool[h]
100
+ logger.debug(f"Mempool: expired {h[:12]}")
101
+
102
+ def get_stats(self) -> Dict:
103
+ return {
104
+ "pending_count": self.size(),
105
+ "oldest_tx": min(
106
+ (tx.timestamp for tx in self._pool.values()),
107
+ default=None
108
+ ),
109
+ }
110
+
111
+ def __repr__(self):
112
+ return f"Mempool(pending={self.size()})"
@@ -0,0 +1,207 @@
1
+ """
2
+ PlayWebit Network — Royalty Engine
3
+ Enforces creator royalties at protocol level.
4
+ Set at mint time, enforced on every resale — forever.
5
+ Platforms cannot bypass this. Consensus rejects blocks that do.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, List, Tuple, Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class RoyaltyEngine:
15
+
16
+ def __init__(self, storage):
17
+ """
18
+ storage — any ChainStorage implementation.
19
+ Reads content_registry to get creator + royalty_pct per CID.
20
+ """
21
+ self.storage = storage
22
+
23
+ # ─────────────────────────────────────────────────────────────
24
+ # Get royalty info
25
+ # ─────────────────────────────────────────────────────────────
26
+
27
+ def get_royalty(self, cid: str) -> Optional[Dict]:
28
+ """
29
+ Get royalty info for a CID from the content registry.
30
+ Returns:
31
+ {
32
+ creator_wallet: str,
33
+ royalty_pct: float,
34
+ cid: str,
35
+ }
36
+ or None if CID not registered.
37
+ """
38
+ record = self.storage.get_content_record(cid)
39
+ if not record:
40
+ return None
41
+
42
+ return {
43
+ "creator_wallet": record.get("creator_wallet"),
44
+ "royalty_pct": record.get("royalty_pct", 0),
45
+ "cid": cid,
46
+ }
47
+
48
+ # ─────────────────────────────────────────────────────────────
49
+ # Calculate split
50
+ # ─────────────────────────────────────────────────────────────
51
+
52
+ def calculate_split(
53
+ self,
54
+ cid: str,
55
+ sale_price: float,
56
+ ) -> Dict:
57
+ """
58
+ Calculate how a sale price splits between creator and seller.
59
+ Returns:
60
+ {
61
+ creator_wallet: str,
62
+ royalty_pct: float,
63
+ creator_amount: float, # royalty to original creator
64
+ seller_amount: float, # remainder to seller
65
+ sale_price: float,
66
+ }
67
+ """
68
+ royalty = self.get_royalty(cid)
69
+
70
+ if not royalty or royalty["royalty_pct"] == 0:
71
+ return {
72
+ "creator_wallet": royalty["creator_wallet"] if royalty else None,
73
+ "royalty_pct": 0,
74
+ "creator_amount": 0.0,
75
+ "seller_amount": sale_price,
76
+ "sale_price": sale_price,
77
+ }
78
+
79
+ royalty_pct = royalty["royalty_pct"]
80
+ creator_amount = round(sale_price * (royalty_pct / 100), 8)
81
+ seller_amount = round(sale_price - creator_amount, 8)
82
+
83
+ return {
84
+ "creator_wallet": royalty["creator_wallet"],
85
+ "royalty_pct": royalty_pct,
86
+ "creator_amount": creator_amount,
87
+ "seller_amount": seller_amount,
88
+ "sale_price": sale_price,
89
+ }
90
+
91
+ # ─────────────────────────────────────────────────────────────
92
+ # Create royalty transactions
93
+ # ─────────────────────────────────────────────────────────────
94
+
95
+ def create_royalty_transactions(
96
+ self,
97
+ cid: str,
98
+ sale_price: float,
99
+ buyer_wallet: str,
100
+ seller_wallet: str,
101
+ base_nonce: int = 0,
102
+ ) -> List:
103
+ """
104
+ Create the transactions for a resale.
105
+ Returns [creator_royalty_tx, seller_payment_tx]
106
+
107
+ Both go on chain. Consensus validates they're correct.
108
+ """
109
+ from playweb.core.transaction import Transaction
110
+
111
+ split = self.calculate_split(cid, sale_price)
112
+ txs = []
113
+
114
+ # Royalty to original creator
115
+ if split["creator_amount"] > 0 and split["creator_wallet"]:
116
+ txs.append(Transaction(
117
+ from_addr = buyer_wallet.lower(),
118
+ to_addr = split["creator_wallet"].lower(),
119
+ amount = split["creator_amount"],
120
+ tx_type = "ownership_transfer",
121
+ nonce = base_nonce + 1,
122
+ cid = cid,
123
+ data = {
124
+ "royalty": True,
125
+ "royalty_pct": split["royalty_pct"],
126
+ "sale_price": sale_price,
127
+ "seller": seller_wallet,
128
+ }
129
+ ))
130
+
131
+ # Payment to seller
132
+ if split["seller_amount"] > 0:
133
+ txs.append(Transaction(
134
+ from_addr = buyer_wallet.lower(),
135
+ to_addr = seller_wallet.lower(),
136
+ amount = split["seller_amount"],
137
+ tx_type = "ownership_transfer",
138
+ nonce = base_nonce + 2,
139
+ cid = cid,
140
+ data = {
141
+ "royalty": False,
142
+ "royalty_pct": split["royalty_pct"],
143
+ "sale_price": sale_price,
144
+ "creator_cut": split["creator_amount"],
145
+ }
146
+ ))
147
+
148
+ return txs
149
+
150
+ # ─────────────────────────────────────────────────────────────
151
+ # Validate royalty transactions in a block
152
+ # Called by every node during consensus
153
+ # ─────────────────────────────────────────────────────────────
154
+
155
+ def validate_royalty_transactions(
156
+ self,
157
+ block,
158
+ ) -> Tuple[bool, str]:
159
+ """
160
+ Validate that royalty payments in a block are correct.
161
+ Every honest node runs this during consensus.
162
+
163
+ Checks:
164
+ 1. Royalty goes to the correct original creator
165
+ 2. Royalty amount matches the registered royalty_pct
166
+ 3. Creator cannot be changed after minting
167
+
168
+ Returns (is_valid, reason).
169
+ """
170
+ for tx in block.transactions:
171
+ if tx.tx_type != "ownership_transfer":
172
+ continue
173
+ if not tx.data:
174
+ continue
175
+ if not tx.data.get("royalty"):
176
+ continue
177
+
178
+ # This is a royalty payment — validate it
179
+ cid = tx.cid
180
+ if not cid:
181
+ return False, f"Royalty tx {tx.hash[:12]} missing cid"
182
+
183
+ royalty = self.get_royalty(cid)
184
+ if not royalty:
185
+ return False, f"CID {cid} not in content registry"
186
+
187
+ # Creator wallet must match
188
+ if tx.to_addr != royalty["creator_wallet"].lower():
189
+ return (
190
+ False,
191
+ f"Royalty going to wrong wallet for {cid}: "
192
+ f"expected {royalty['creator_wallet']}, "
193
+ f"got {tx.to_addr}"
194
+ )
195
+
196
+ # Royalty amount must match
197
+ sale_price = tx.data.get("sale_price", 0)
198
+ if sale_price > 0:
199
+ expected = round(sale_price * (royalty["royalty_pct"] / 100), 8)
200
+ if round(tx.amount, 8) != expected:
201
+ return (
202
+ False,
203
+ f"Wrong royalty amount for {cid}: "
204
+ f"expected {expected}, got {tx.amount}"
205
+ )
206
+
207
+ return True, "Valid"