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,270 @@
1
+ """
2
+ PlayWebit Network — Transaction
3
+ Clean L1 transaction. No CipherVault logic. No Supabase.
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+ import time
9
+ import logging
10
+ from typing import Optional, Dict, Any
11
+
12
+ from playweb.config import CHAIN_ID, L1_TX_TYPES, AUTHORITY_TX_TYPES
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class Transaction:
18
+ """
19
+ L1 Transaction.
20
+
21
+ MANDATORY fields — L1 validates all of these:
22
+ from_addr, to_addr, amount, tx_type,
23
+ timestamp, signature, nonce, chain_id
24
+
25
+ CONDITIONAL fields — required for specific tx_types:
26
+ cid → required for content_register, cv_link,
27
+ ownership_transfer, edition_transfer
28
+ editions → required for content_register
29
+ royalty_pct → required for content_register
30
+ edition_number → required for edition_transfer
31
+ spider_hash → required for spider_hash_anchor
32
+ chain_name → required for spider_hash_anchor
33
+
34
+ OPTIONAL free field — L1 carries it, never reads it:
35
+ data: {} → platform stores anything here
36
+ license_type, song_title, platform_id,
37
+ spider_hash metadata, custom fields etc
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ from_addr: str,
43
+ to_addr: str,
44
+ amount: float,
45
+ tx_type: str,
46
+ signature: str = None,
47
+ nonce: int = None,
48
+ timestamp: float = None,
49
+
50
+ # Conditional fields
51
+ cid: Optional[str] = None,
52
+ editions: Optional[int] = None,
53
+ royalty_pct: Optional[float] = None,
54
+ edition_number: Optional[int] = None,
55
+ spider_hash: Optional[str] = None,
56
+ chain_name: Optional[str] = None,
57
+
58
+ # Free optional data field — L1 never reads this
59
+ data: Optional[Dict[str, Any]] = None,
60
+ ):
61
+ # ── Mandatory fields ──────────────────────────────────
62
+ self.from_addr = from_addr.lower() if from_addr else from_addr
63
+ self.to_addr = to_addr.lower() if to_addr else to_addr
64
+ self.amount = float(amount)
65
+ self.tx_type = tx_type
66
+ self.timestamp = timestamp or time.time()
67
+ self.nonce = nonce if nonce is not None else int(self.timestamp * 1000)
68
+ self.chain_id = CHAIN_ID
69
+ self.signature = signature
70
+
71
+ # ── Conditional fields ────────────────────────────────
72
+ self.cid = cid
73
+ self.editions = editions
74
+ self.royalty_pct = royalty_pct
75
+ self.edition_number = edition_number
76
+ self.spider_hash = spider_hash
77
+ self.chain_name = chain_name
78
+
79
+ # ── Optional free data field ──────────────────────────
80
+ self.data = data or {}
81
+
82
+ # ── Computed ──────────────────────────────────────────
83
+ self.hash = self.calculate_hash()
84
+
85
+ # ─────────────────────────────────────────────────────────────
86
+ # Hashing
87
+ # ─────────────────────────────────────────────────────────────
88
+
89
+ def get_signable_data(self) -> Dict:
90
+ """
91
+ Returns the canonical dict that gets hashed and signed.
92
+ Only mandatory + conditional fields — not data{} field
93
+ because platforms may add data after signing.
94
+ """
95
+ d = {
96
+ "from_addr": self.from_addr,
97
+ "to_addr": self.to_addr,
98
+ "amount": self.amount,
99
+ "tx_type": self.tx_type,
100
+ "timestamp": self.timestamp,
101
+ "nonce": self.nonce,
102
+ "chain_id": self.chain_id,
103
+ }
104
+
105
+ # Include conditional fields only if set
106
+ if self.cid is not None: d["cid"] = self.cid
107
+ if self.editions is not None: d["editions"] = self.editions
108
+ if self.royalty_pct is not None: d["royalty_pct"] = self.royalty_pct
109
+ if self.edition_number is not None: d["edition_number"] = self.edition_number
110
+ if self.spider_hash is not None: d["spider_hash"] = self.spider_hash
111
+ if self.chain_name is not None: d["chain_name"] = self.chain_name
112
+
113
+ return d
114
+
115
+ def calculate_hash(self) -> str:
116
+ """SHA256 of canonical signable data."""
117
+ raw = json.dumps(self.get_signable_data(), sort_keys=True)
118
+ return hashlib.sha256(raw.encode()).hexdigest()
119
+
120
+ # ─────────────────────────────────────────────────────────────
121
+ # Validation
122
+ # ─────────────────────────────────────────────────────────────
123
+
124
+ def validate_fields(self) -> tuple[bool, str]:
125
+ """
126
+ Validate mandatory and conditional fields.
127
+ Returns (is_valid, reason).
128
+ """
129
+
130
+ # Mandatory checks
131
+ if not self.from_addr:
132
+ return False, "Missing from_addr"
133
+ if not self.to_addr:
134
+ return False, "Missing to_addr"
135
+ if self.amount < 0:
136
+ return False, "Amount cannot be negative"
137
+ if self.tx_type not in L1_TX_TYPES:
138
+ return False, f"Unknown tx_type: {self.tx_type}"
139
+ if self.chain_id != CHAIN_ID:
140
+ return False, f"Wrong chain_id: {self.chain_id}, expected {CHAIN_ID}"
141
+ if self.nonce is None:
142
+ return False, "Missing nonce"
143
+
144
+ # Conditional checks
145
+ if self.tx_type == "content_register":
146
+ if not self.cid:
147
+ return False, "content_register requires cid"
148
+ if self.editions is None:
149
+ return False, "content_register requires editions"
150
+ if self.royalty_pct is None:
151
+ return False, "content_register requires royalty_pct"
152
+ if not (0 <= self.royalty_pct <= 100):
153
+ return False, "royalty_pct must be 0-100"
154
+
155
+ if self.tx_type in ("cv_link", "ownership_transfer"):
156
+ if not self.cid:
157
+ return False, f"{self.tx_type} requires cid"
158
+
159
+ if self.tx_type == "edition_transfer":
160
+ if not self.cid:
161
+ return False, "edition_transfer requires cid"
162
+ if self.edition_number is None:
163
+ return False, "edition_transfer requires edition_number"
164
+
165
+ if self.tx_type == "spider_hash_anchor":
166
+ if not self.spider_hash:
167
+ return False, "spider_hash_anchor requires spider_hash"
168
+ if not self.chain_name:
169
+ return False, "spider_hash_anchor requires chain_name"
170
+
171
+ return True, "Valid"
172
+
173
+ def verify_signature(self, public_key: str = None) -> bool:
174
+ """
175
+ Verify the transaction signature.
176
+ Authority transactions skip verification.
177
+ MetaMask personal_sign compatible.
178
+ """
179
+ # Authority txs don't need signature verification
180
+ if self.tx_type in AUTHORITY_TX_TYPES:
181
+ return True
182
+
183
+ if not self.signature:
184
+ logger.warning(f"Transaction {self.hash} missing signature")
185
+ return False
186
+
187
+ try:
188
+ from eth_account import Account
189
+ from eth_account.messages import encode_defunct
190
+
191
+ signable = json.dumps(self.get_signable_data(), sort_keys=True)
192
+ message = encode_defunct(text=signable)
193
+ recovered = Account.recover_message(message, signature=self.signature)
194
+
195
+ return recovered.lower() == self.from_addr.lower()
196
+
197
+ except ImportError:
198
+ # eth_account not installed — basic format check only
199
+ logger.warning(
200
+ "eth_account not installed. "
201
+ "Skipping full signature verification. "
202
+ "Install: pip install eth-account"
203
+ )
204
+ return bool(self.signature and self.signature.startswith("0x"))
205
+
206
+ except Exception as e:
207
+ logger.error(f"Signature verification failed: {e}")
208
+ return False
209
+
210
+ # ─────────────────────────────────────────────────────────────
211
+ # Serialisation
212
+ # ─────────────────────────────────────────────────────────────
213
+
214
+ def to_dict(self) -> Dict:
215
+ d = {
216
+ # Mandatory
217
+ "hash": self.hash,
218
+ "from_addr": self.from_addr,
219
+ "to_addr": self.to_addr,
220
+ "amount": self.amount,
221
+ "tx_type": self.tx_type,
222
+ "timestamp": self.timestamp,
223
+ "nonce": self.nonce,
224
+ "chain_id": self.chain_id,
225
+ "signature": self.signature,
226
+ # Optional free field
227
+ "data": self.data,
228
+ }
229
+
230
+ # Conditional — only include if set
231
+ if self.cid is not None: d["cid"] = self.cid
232
+ if self.editions is not None: d["editions"] = self.editions
233
+ if self.royalty_pct is not None: d["royalty_pct"] = self.royalty_pct
234
+ if self.edition_number is not None: d["edition_number"] = self.edition_number
235
+ if self.spider_hash is not None: d["spider_hash"] = self.spider_hash
236
+ if self.chain_name is not None: d["chain_name"] = self.chain_name
237
+
238
+ return d
239
+
240
+ @classmethod
241
+ def from_dict(cls, d: Dict) -> "Transaction":
242
+ tx = cls(
243
+ from_addr = d["from_addr"],
244
+ to_addr = d["to_addr"],
245
+ amount = d["amount"],
246
+ tx_type = d["tx_type"],
247
+ signature = d.get("signature"),
248
+ nonce = d.get("nonce"),
249
+ timestamp = d.get("timestamp"),
250
+ cid = d.get("cid"),
251
+ editions = d.get("editions"),
252
+ royalty_pct = d.get("royalty_pct"),
253
+ edition_number = d.get("edition_number"),
254
+ spider_hash = d.get("spider_hash"),
255
+ chain_name = d.get("chain_name"),
256
+ data = d.get("data", {}),
257
+ )
258
+ # Restore original hash (don't recalculate — it was stored)
259
+ if "hash" in d:
260
+ tx.hash = d["hash"]
261
+ return tx
262
+
263
+ def __repr__(self):
264
+ return (
265
+ f"Transaction(type={self.tx_type}, "
266
+ f"from={self.from_addr[:8]}..., "
267
+ f"to={self.to_addr[:8]}..., "
268
+ f"amount={self.amount}, "
269
+ f"hash={self.hash[:12]}...)"
270
+ )
@@ -0,0 +1,6 @@
1
+ from playweb.network.peer_manager import PeerManager, Peer
2
+ from playweb.network.bootstrap import Bootstrap
3
+ from playweb.network.gossip import GossipProtocol
4
+ from playweb.network.sync import ChainSync
5
+
6
+ __all__ = ["PeerManager", "Peer", "Bootstrap", "GossipProtocol", "ChainSync"]
@@ -0,0 +1,217 @@
1
+ """
2
+ PlayWebit Network — Bootstrap
3
+ First peer discovery via Cloudflare Worker.
4
+ After first connection, pure P2P gossip takes over.
5
+ Cloudflare is never needed again until node restarts.
6
+ """
7
+
8
+ import time
9
+ import json
10
+ import hashlib
11
+ import logging
12
+ import threading
13
+ import requests
14
+ from typing import List, Dict, Optional
15
+
16
+ from playweb.config import (
17
+ BOOTSTRAP_URL,
18
+ BOOTSTRAP_ENDPOINTS,
19
+ BOOTSTRAP_FALLBACK_NODES,
20
+ BOOTSTRAP_HEARTBEAT_INTERVAL,
21
+ PEER_TIMEOUT,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class Bootstrap:
28
+
29
+ def __init__(
30
+ self,
31
+ node_url: str,
32
+ node_wallet: str,
33
+ node_private_key: str,
34
+ platform: str = "unknown",
35
+ role: str = "validator",
36
+ ):
37
+ self.node_url = node_url
38
+ self.node_wallet = node_wallet.lower()
39
+ self.node_private_key = node_private_key
40
+ self.platform = platform
41
+ self.role = role
42
+ self._heartbeat_thread: Optional[threading.Thread] = None
43
+ self._running = False
44
+
45
+ # ─────────────────────────────────────────────────────────────
46
+ # Signature for Cloudflare verification
47
+ # ─────────────────────────────────────────────────────────────
48
+
49
+ def _sign(self, timestamp: int) -> str:
50
+ """
51
+ Sign the bootstrap message with node's wallet.
52
+ message = "playwebit-bootstrap:{url}:{timestamp}"
53
+ """
54
+ try:
55
+ from eth_account import Account
56
+ from eth_account.messages import encode_defunct
57
+
58
+ message = f"playwebit-bootstrap:{self.node_url}:{timestamp}"
59
+ msg = encode_defunct(text=message)
60
+ signed = Account.sign_message(msg, private_key=self.node_private_key)
61
+ sig = signed.signature.hex()
62
+ # ensure 0x prefix — Worker requires it
63
+ return sig if sig.startswith("0x") else "0x" + sig
64
+ except ImportError:
65
+ # Fallback placeholder signature (format-valid)
66
+ raw = f"{self.node_url}:{timestamp}:{self.node_wallet}"
67
+ return "0x" + hashlib.sha256(raw.encode()).hexdigest() + "0" * 66
68
+ except Exception as e:
69
+ logger.error(f"Bootstrap signing failed: {e}")
70
+ return "0x" + "0" * 130
71
+
72
+ # ─────────────────────────────────────────────────────────────
73
+ # Discover peers
74
+ # ─────────────────────────────────────────────────────────────
75
+
76
+ def discover_peers(self) -> List[Dict]:
77
+ """
78
+ Get list of active nodes from Cloudflare Worker.
79
+ Falls back to hardcoded nodes if Cloudflare unreachable.
80
+ Returns list of { url, wallet, platform } dicts.
81
+ """
82
+ url = BOOTSTRAP_URL + BOOTSTRAP_ENDPOINTS["nodes"]
83
+ try:
84
+ res = requests.get(url, timeout=PEER_TIMEOUT)
85
+ if res.status_code == 200:
86
+ data = res.json()
87
+ nodes = data.get("nodes", [])
88
+ # Filter out self
89
+ nodes = [
90
+ n for n in nodes
91
+ if n.get("url") != self.node_url
92
+ and n.get("wallet", "").lower() != self.node_wallet
93
+ ]
94
+ logger.info(
95
+ f"Bootstrap: discovered {len(nodes)} peers "
96
+ f"from Cloudflare"
97
+ )
98
+ return nodes
99
+ else:
100
+ logger.warning(
101
+ f"Bootstrap Cloudflare returned {res.status_code}"
102
+ )
103
+ except Exception as e:
104
+ logger.warning(f"Bootstrap Cloudflare unreachable: {e}")
105
+
106
+ # Fallback to hardcoded nodes
107
+ logger.info("Bootstrap: using fallback nodes")
108
+ return [
109
+ {"url": url, "wallet": "unknown", "platform": "playwebit"}
110
+ for url in BOOTSTRAP_FALLBACK_NODES
111
+ if url != self.node_url
112
+ ]
113
+
114
+ # ─────────────────────────────────────────────────────────────
115
+ # Register node
116
+ # ─────────────────────────────────────────────────────────────
117
+
118
+ def register_node(self) -> bool:
119
+ """
120
+ Register this node with the Cloudflare bootstrap directory.
121
+ Cloudflare will notify all existing nodes about us.
122
+ """
123
+ timestamp = int(time.time())
124
+ signature = self._sign(timestamp)
125
+ url = BOOTSTRAP_URL + BOOTSTRAP_ENDPOINTS["register"]
126
+
127
+ try:
128
+ res = requests.post(url, json={
129
+ "url": self.node_url,
130
+ "wallet": self.node_wallet,
131
+ "signature": signature,
132
+ "timestamp": timestamp,
133
+ "platform": self.platform,
134
+ "role": self.role,
135
+ }, timeout=PEER_TIMEOUT)
136
+
137
+ if res.status_code == 200:
138
+ data = res.json()
139
+ logger.info(
140
+ f"Bootstrap: node registered. "
141
+ f"Network has {data.get('node_count', '?')} nodes"
142
+ )
143
+ # Cloudflare returns peer list in registration response
144
+ # — use it to seed peer manager immediately
145
+ return True
146
+ else:
147
+ logger.warning(
148
+ f"Bootstrap registration failed: "
149
+ f"{res.status_code} {res.text}"
150
+ )
151
+ return False
152
+
153
+ except Exception as e:
154
+ logger.warning(f"Bootstrap registration error: {e}")
155
+ return False
156
+
157
+ # ─────────────────────────────────────────────────────────────
158
+ # Heartbeat — keeps node listed in Cloudflare KV
159
+ # ─────────────────────────────────────────────────────────────
160
+
161
+ def start_heartbeat(self):
162
+ """Start background heartbeat thread (every 12 hours)."""
163
+ self._running = True
164
+ self._heartbeat_thread = threading.Thread(
165
+ target = self._heartbeat_loop,
166
+ daemon = True,
167
+ name = "bootstrap-heartbeat",
168
+ )
169
+ self._heartbeat_thread.start()
170
+ logger.info("Bootstrap heartbeat started (every 12h)")
171
+
172
+ def stop_heartbeat(self):
173
+ self._running = False
174
+
175
+ def _heartbeat_loop(self):
176
+ while self._running:
177
+ time.sleep(BOOTSTRAP_HEARTBEAT_INTERVAL)
178
+ self._send_heartbeat()
179
+
180
+ def _send_heartbeat(self):
181
+ timestamp = int(time.time())
182
+ signature = self._sign(timestamp)
183
+ url = BOOTSTRAP_URL + BOOTSTRAP_ENDPOINTS["heartbeat"]
184
+ try:
185
+ res = requests.post(url, json={
186
+ "url": self.node_url,
187
+ "wallet": self.node_wallet,
188
+ "signature": signature,
189
+ "timestamp": timestamp,
190
+ }, timeout=PEER_TIMEOUT)
191
+ if res.status_code == 200:
192
+ logger.debug("Bootstrap heartbeat sent")
193
+ else:
194
+ logger.warning(f"Bootstrap heartbeat failed: {res.status_code}")
195
+ except Exception as e:
196
+ logger.warning(f"Bootstrap heartbeat error: {e}")
197
+
198
+ # ─────────────────────────────────────────────────────────────
199
+ # Deregister
200
+ # ─────────────────────────────────────────────────────────────
201
+
202
+ def deregister(self):
203
+ """Clean removal when node shuts down gracefully."""
204
+ self.stop_heartbeat()
205
+ timestamp = int(time.time())
206
+ signature = self._sign(timestamp)
207
+ url = BOOTSTRAP_URL + BOOTSTRAP_ENDPOINTS["deregister"]
208
+ try:
209
+ requests.post(url, json={
210
+ "url": self.node_url,
211
+ "wallet": self.node_wallet,
212
+ "signature": signature,
213
+ "timestamp": timestamp,
214
+ }, timeout=PEER_TIMEOUT)
215
+ logger.info("Bootstrap: node deregistered")
216
+ except Exception as e:
217
+ logger.warning(f"Bootstrap deregistration error: {e}")