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.
- playweb/__init__.py +18 -0
- playweb/api/__init__.py +4 -0
- playweb/api/erc20.py +66 -0
- playweb/api/erc721.py +88 -0
- playweb/api/metadata.py +86 -0
- playweb/api/node_api.py +224 -0
- playweb/api/public_api.py +496 -0
- playweb/api/rpc.py +247 -0
- playweb/client.py +341 -0
- playweb/config.py +168 -0
- playweb/consensus/__init__.py +5 -0
- playweb/consensus/leader.py +50 -0
- playweb/consensus/nvf_bft.py +540 -0
- playweb/consensus/vote.py +108 -0
- playweb/core/__init__.py +6 -0
- playweb/core/block.py +156 -0
- playweb/core/blockchain.py +358 -0
- playweb/core/fee_engine.py +312 -0
- playweb/core/mempool.py +112 -0
- playweb/core/royalty_engine.py +207 -0
- playweb/core/transaction.py +270 -0
- playweb/network/__init__.py +6 -0
- playweb/network/bootstrap.py +217 -0
- playweb/network/gossip.py +222 -0
- playweb/network/peer_manager.py +164 -0
- playweb/network/sync.py +244 -0
- playweb/node.py +303 -0
- playweb/plugin/__init__.py +4 -0
- playweb/plugin/base_plugin.py +225 -0
- playweb/plugin/plugin_manager.py +131 -0
- playweb/registry/__init__.py +4 -0
- playweb/registry/content_registry.py +259 -0
- playweb/registry/edition_registry.py +220 -0
- playweb/storage/__init__.py +11 -0
- playweb/storage/base.py +154 -0
- playweb/storage/ram_storage.py +123 -0
- playweb/storage/sqlite_storage.py +369 -0
- playweb/storage/supabase_storage.py +389 -0
- playweb_node-1.0.0.dist-info/METADATA +416 -0
- playweb_node-1.0.0.dist-info/RECORD +43 -0
- playweb_node-1.0.0.dist-info/WHEEL +5 -0
- playweb_node-1.0.0.dist-info/licenses/LICENSE +201 -0
- 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"]
|
playweb/core/mempool.py
ADDED
|
@@ -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"
|