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,222 @@
1
+ """
2
+ PlayWebit Network — Gossip Protocol
3
+ P2P block and transaction propagation via HTTP webhooks.
4
+ Works on ALL cloud platforms — HuggingFace, AWS, VPS, anywhere.
5
+ No persistent connections needed. Fire and forget to peers.
6
+ """
7
+
8
+ import logging
9
+ import threading
10
+ import requests
11
+ from typing import List, TYPE_CHECKING
12
+
13
+ from playweb.config import GOSSIP_FANOUT, PEER_TIMEOUT
14
+
15
+ if TYPE_CHECKING:
16
+ from playweb.network.peer_manager import Peer
17
+ from playweb.consensus.vote import Vote
18
+ from playweb.core.block import Block
19
+ from playweb.core.transaction import Transaction
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class GossipProtocol:
25
+
26
+ def __init__(self, node_wallet: str):
27
+ self.node_wallet = node_wallet
28
+
29
+ # ─────────────────────────────────────────────────────────────
30
+ # Internal helpers
31
+ # ─────────────────────────────────────────────────────────────
32
+
33
+ def _post(self, url: str, data: dict, timeout: int = PEER_TIMEOUT):
34
+ """Fire-and-forget HTTP POST to a peer endpoint."""
35
+ try:
36
+ requests.post(url, json=data, timeout=timeout)
37
+ except Exception:
38
+ pass # peer may be down — that's fine
39
+
40
+ def _broadcast(
41
+ self,
42
+ peers: List["Peer"],
43
+ endpoint: str,
44
+ payload: dict,
45
+ fanout: int = GOSSIP_FANOUT,
46
+ ):
47
+ """
48
+ Broadcast payload to up to GOSSIP_FANOUT peers in parallel.
49
+ Each peer then forwards to their peers.
50
+ This is how messages propagate through the whole network.
51
+ """
52
+ # Limit fanout but prefer low-latency peers
53
+ targets = sorted(peers, key=lambda p: p.latency_ms)[:fanout]
54
+
55
+ threads = []
56
+ for peer in targets:
57
+ t = threading.Thread(
58
+ target=self._post,
59
+ args=(f"{peer.url}{endpoint}", payload),
60
+ daemon=True,
61
+ )
62
+ t.start()
63
+ threads.append(t)
64
+
65
+ # Don't wait — fire and forget
66
+ # Peers will propagate further themselves
67
+
68
+ # ─────────────────────────────────────────────────────────────
69
+ # Broadcast: new transaction
70
+ # ─────────────────────────────────────────────────────────────
71
+
72
+ def broadcast_transaction(
73
+ self,
74
+ tx: "Transaction",
75
+ peers: List["Peer"],
76
+ ):
77
+ """
78
+ Broadcast a new transaction to peers.
79
+ Called when a node receives a tx from an API client.
80
+ """
81
+ if not peers:
82
+ return
83
+
84
+ payload = {
85
+ "transaction": tx.to_dict(),
86
+ "from_node": self.node_wallet,
87
+ }
88
+ self._broadcast(peers, "/peer/new_transaction", payload)
89
+ logger.debug(f"Gossip: broadcast tx {tx.hash[:12]}... to {len(peers)} peers")
90
+
91
+ # ─────────────────────────────────────────────────────────────
92
+ # Broadcast: block proposal (from consensus leader)
93
+ # ─────────────────────────────────────────────────────────────
94
+
95
+ def broadcast_block_proposal(
96
+ self,
97
+ block: "Block",
98
+ round_number: int,
99
+ peers: List["Peer"],
100
+ ):
101
+ """
102
+ Broadcast a block proposal during PROPOSE phase.
103
+ Only the consensus leader calls this.
104
+ """
105
+ if not peers:
106
+ return
107
+
108
+ payload = {
109
+ "block": block.to_dict(),
110
+ "round_number": round_number,
111
+ "from_node": self.node_wallet,
112
+ "message_type": "proposal",
113
+ }
114
+ # Send to ALL peers for proposals (not just fanout)
115
+ # Every peer needs to receive the proposal to vote
116
+ threads = []
117
+ for peer in peers:
118
+ t = threading.Thread(
119
+ target=self._post,
120
+ args=(f"{peer.url}/peer/propose", payload),
121
+ daemon=True,
122
+ )
123
+ t.start()
124
+ threads.append(t)
125
+
126
+ logger.info(
127
+ f"Gossip: broadcast proposal block {block.index} "
128
+ f"to {len(peers)} peers"
129
+ )
130
+
131
+ # ─────────────────────────────────────────────────────────────
132
+ # Broadcast: vote
133
+ # ─────────────────────────────────────────────────────────────
134
+
135
+ def broadcast_vote(
136
+ self,
137
+ vote: "Vote",
138
+ peers: List["Peer"],
139
+ ):
140
+ """
141
+ Broadcast a consensus vote to all peers.
142
+ Every validator calls this after validating a proposal.
143
+ """
144
+ if not peers:
145
+ return
146
+
147
+ payload = {
148
+ "vote": vote.to_dict(),
149
+ "from_node": self.node_wallet,
150
+ }
151
+ # Send votes to ALL peers — every node needs to collect them
152
+ threads = []
153
+ for peer in peers:
154
+ t = threading.Thread(
155
+ target=self._post,
156
+ args=(f"{peer.url}/peer/vote", payload),
157
+ daemon=True,
158
+ )
159
+ t.start()
160
+ threads.append(t)
161
+
162
+ logger.debug(
163
+ f"Gossip: broadcast vote for block "
164
+ f"{vote.block_hash[:12]}... to {len(peers)} peers"
165
+ )
166
+
167
+ # ─────────────────────────────────────────────────────────────
168
+ # Broadcast: finalised block
169
+ # ─────────────────────────────────────────────────────────────
170
+
171
+ def broadcast_finalised_block(
172
+ self,
173
+ block: "Block",
174
+ votes: list,
175
+ peers: List["Peer"],
176
+ ):
177
+ """
178
+ Broadcast a finalised block after consensus.
179
+ Nodes that missed the consensus round catch up via this.
180
+ """
181
+ if not peers:
182
+ return
183
+
184
+ payload = {
185
+ "block": block.to_dict(),
186
+ "votes": votes,
187
+ "from_node": self.node_wallet,
188
+ "message_type": "finalised",
189
+ }
190
+ self._broadcast(peers, "/peer/new_block", payload)
191
+ logger.info(
192
+ f"Gossip: broadcast finalised block "
193
+ f"{block.index} to {len(peers)} peers"
194
+ )
195
+
196
+ # ─────────────────────────────────────────────────────────────
197
+ # Broadcast: new node joined
198
+ # ─────────────────────────────────────────────────────────────
199
+
200
+ def broadcast_new_node(
201
+ self,
202
+ new_node_url: str,
203
+ new_node_wallet: str,
204
+ peers: List["Peer"],
205
+ ):
206
+ """
207
+ Tell all existing peers about a new node.
208
+ Called when Cloudflare notifies us of a new registration.
209
+ """
210
+ if not peers:
211
+ return
212
+
213
+ payload = {
214
+ "url": new_node_url,
215
+ "wallet": new_node_wallet,
216
+ "from_node": self.node_wallet,
217
+ }
218
+ self._broadcast(peers, "/peer/new_node", payload)
219
+ logger.info(
220
+ f"Gossip: broadcast new node "
221
+ f"{new_node_url} to {len(peers)} peers"
222
+ )
@@ -0,0 +1,164 @@
1
+ """
2
+ PlayWebit Network — Peer Manager
3
+ Manages known peers in RAM. Lost on restart — rediscovered via bootstrap.
4
+ """
5
+
6
+ import time
7
+ import threading
8
+ import logging
9
+ import requests
10
+ from typing import List, Optional, Dict
11
+ from dataclasses import dataclass, field
12
+
13
+ from playweb.config import MAX_PEERS, PEER_TIMEOUT
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class Peer:
20
+ url: str
21
+ wallet: str
22
+ platform: str = "unknown"
23
+ role: str = "validator"
24
+ last_seen: float = field(default_factory=time.time)
25
+ is_active: bool = True
26
+ latency_ms: float = 0.0
27
+
28
+ def __hash__(self):
29
+ return hash(self.url)
30
+
31
+ def __eq__(self, other):
32
+ return self.url == other.url
33
+
34
+
35
+ class PeerManager:
36
+
37
+ def __init__(self, my_url: str, my_wallet: str):
38
+ self.my_url = my_url
39
+ self.my_wallet = my_wallet.lower()
40
+ self._peers: Dict[str, Peer] = {} # url → Peer
41
+ self._lock = threading.Lock()
42
+
43
+ # ─────────────────────────────────────────────────────────────
44
+ # Add / Remove
45
+ # ─────────────────────────────────────────────────────────────
46
+
47
+ def add_peer(
48
+ self,
49
+ url: str,
50
+ wallet: str,
51
+ platform: str = "unknown",
52
+ role: str = "validator",
53
+ ) -> bool:
54
+ """Add a new peer. Returns True if actually added (not duplicate)."""
55
+ # Don't add self
56
+ if url == self.my_url or wallet.lower() == self.my_wallet:
57
+ return False
58
+
59
+ with self._lock:
60
+ if url in self._peers:
61
+ # Update last_seen
62
+ self._peers[url].last_seen = time.time()
63
+ self._peers[url].is_active = True
64
+ return False
65
+
66
+ if len(self._peers) >= MAX_PEERS:
67
+ # Remove oldest inactive peer first
68
+ self._evict_one()
69
+
70
+ self._peers[url] = Peer(
71
+ url = url,
72
+ wallet = wallet.lower(),
73
+ platform = platform,
74
+ role = role,
75
+ )
76
+ logger.info(f"Peer added: {url} ({wallet[:8]}...)")
77
+ return True
78
+
79
+ def remove_peer(self, url: str):
80
+ with self._lock:
81
+ if url in self._peers:
82
+ del self._peers[url]
83
+ logger.info(f"Peer removed: {url}")
84
+
85
+ def _evict_one(self):
86
+ """Remove the least recently seen inactive peer."""
87
+ inactive = [
88
+ p for p in self._peers.values()
89
+ if not p.is_active
90
+ ]
91
+ if inactive:
92
+ oldest = min(inactive, key=lambda p: p.last_seen)
93
+ del self._peers[oldest.url]
94
+
95
+ # ─────────────────────────────────────────────────────────────
96
+ # Query
97
+ # ─────────────────────────────────────────────────────────────
98
+
99
+ def get_active_peers(self) -> List[Peer]:
100
+ with self._lock:
101
+ return [p for p in self._peers.values() if p.is_active]
102
+
103
+ def get_all_peers(self) -> List[Peer]:
104
+ with self._lock:
105
+ return list(self._peers.values())
106
+
107
+ def get_peer(self, url: str) -> Optional[Peer]:
108
+ return self._peers.get(url)
109
+
110
+ def peer_count(self) -> int:
111
+ return len([p for p in self._peers.values() if p.is_active])
112
+
113
+ def get_peer_list_for_sharing(self) -> List[Dict]:
114
+ """Return peer list to share with new nodes."""
115
+ return [
116
+ {"url": p.url, "wallet": p.wallet, "platform": p.platform}
117
+ for p in self.get_active_peers()
118
+ ]
119
+
120
+ # ─────────────────────────────────────────────────────────────
121
+ # Health check
122
+ # ─────────────────────────────────────────────────────────────
123
+
124
+ def ping_all(self):
125
+ """Ping all peers and mark inactive ones."""
126
+ peers = self.get_all_peers()
127
+ for peer in peers:
128
+ self._ping_peer(peer)
129
+
130
+ def _ping_peer(self, peer: Peer):
131
+ try:
132
+ start = time.time()
133
+ res = requests.get(
134
+ f"{peer.url}/peer/health",
135
+ timeout=PEER_TIMEOUT
136
+ )
137
+ latency = (time.time() - start) * 1000
138
+
139
+ if res.status_code == 200:
140
+ with self._lock:
141
+ if peer.url in self._peers:
142
+ self._peers[peer.url].is_active = True
143
+ self._peers[peer.url].last_seen = time.time()
144
+ self._peers[peer.url].latency_ms = latency
145
+ else:
146
+ self._mark_inactive(peer.url)
147
+
148
+ except Exception:
149
+ self._mark_inactive(peer.url)
150
+
151
+ def _mark_inactive(self, url: str):
152
+ with self._lock:
153
+ if url in self._peers:
154
+ self._peers[url].is_active = False
155
+ logger.debug(f"Peer marked inactive: {url}")
156
+
157
+ def load_peers_from_list(self, peers: List[Dict]):
158
+ """Load peers from a list (from bootstrap or another peer)."""
159
+ for p in peers:
160
+ self.add_peer(
161
+ url = p.get("url", ""),
162
+ wallet = p.get("wallet", ""),
163
+ platform = p.get("platform", "unknown"),
164
+ )
@@ -0,0 +1,244 @@
1
+ """
2
+ PlayWebit Network — Chain Sync
3
+ Syncs chain from peers when a node starts or restarts.
4
+ Downloads missing blocks in chunks, verifies each one.
5
+ After sync completes, node joins consensus normally.
6
+ """
7
+
8
+ import logging
9
+ import requests
10
+ from typing import List, Optional, Tuple, TYPE_CHECKING
11
+
12
+ from playweb.config import SYNC_CHUNK_SIZE, PEER_TIMEOUT
13
+
14
+ if TYPE_CHECKING:
15
+ from playweb.network.peer_manager import Peer
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ChainSync:
21
+
22
+ def __init__(self, blockchain, peer_manager):
23
+ self.blockchain = blockchain
24
+ self.peer_manager = peer_manager
25
+ self._syncing = False
26
+ self._progress = {"current": 0, "target": 0, "synced": False}
27
+
28
+ # ─────────────────────────────────────────────────────────────
29
+ # Main sync entry point
30
+ # ─────────────────────────────────────────────────────────────
31
+
32
+ def sync(self) -> bool:
33
+ """
34
+ Sync chain from peers.
35
+ 1. Find canonical chain tip from peers
36
+ 2. Download missing blocks in chunks
37
+ 3. Verify and apply each block
38
+ Returns True if fully synced.
39
+ """
40
+ peers = self.peer_manager.get_active_peers()
41
+ if not peers:
42
+ logger.info("Sync: no peers available — running as solo node")
43
+ return True
44
+
45
+ self._syncing = True
46
+
47
+ # Step 1: Find the canonical chain tip
48
+ target_index, best_peer = self._find_canonical_tip(peers)
49
+ if target_index is None:
50
+ logger.info("Sync: could not determine canonical tip from peers")
51
+ self._syncing = False
52
+ return False
53
+
54
+ my_index = self.blockchain.get_chain_length() - 1
55
+ self._progress["target"] = target_index
56
+ self._progress["current"] = my_index
57
+
58
+ if my_index >= target_index:
59
+ logger.info(
60
+ f"Sync: already up to date (block {my_index})"
61
+ )
62
+ self._syncing = False
63
+ self._progress["synced"] = True
64
+ return True
65
+
66
+ logger.info(
67
+ f"Sync: need blocks {my_index + 1} → {target_index} "
68
+ f"({target_index - my_index} blocks)"
69
+ )
70
+
71
+ # Step 2: Download blocks in chunks
72
+ success = self._download_blocks(
73
+ from_index = my_index + 1,
74
+ to_index = target_index,
75
+ peers = peers,
76
+ )
77
+
78
+ self._syncing = False
79
+ self._progress["synced"] = success
80
+ return success
81
+
82
+ # ─────────────────────────────────────────────────────────────
83
+ # Find canonical chain tip
84
+ # ─────────────────────────────────────────────────────────────
85
+
86
+ def _find_canonical_tip(
87
+ self,
88
+ peers: List["Peer"],
89
+ ) -> Tuple[Optional[int], Optional["Peer"]]:
90
+ """
91
+ Ask each peer for their chain tip.
92
+ The canonical chain is the one with the highest valid index.
93
+ Returns (target_block_index, best_peer).
94
+ """
95
+ tips = []
96
+
97
+ for peer in peers:
98
+ try:
99
+ res = requests.get(
100
+ f"{peer.url}/peer/chain_tip",
101
+ timeout=PEER_TIMEOUT,
102
+ )
103
+ if res.status_code == 200:
104
+ data = res.json()
105
+ index = data.get("block_index", -1)
106
+ hash_ = data.get("block_hash", "")
107
+ if index >= 0:
108
+ tips.append((index, hash_, peer))
109
+ except Exception as e:
110
+ logger.debug(f"Sync: could not get tip from {peer.url}: {e}")
111
+
112
+ if not tips:
113
+ return None, None
114
+
115
+ # Pick highest index — canonical chain
116
+ tips.sort(key=lambda t: t[0], reverse=True)
117
+ best_index, best_hash, best_peer = tips[0]
118
+
119
+ logger.info(
120
+ f"Sync: canonical tip is block {best_index} "
121
+ f"({best_hash[:12]}...) from {best_peer.url}"
122
+ )
123
+ return best_index, best_peer
124
+
125
+ # ─────────────────────────────────────────────────────────────
126
+ # Download blocks
127
+ # ─────────────────────────────────────────────────────────────
128
+
129
+ def _download_blocks(
130
+ self,
131
+ from_index: int,
132
+ to_index: int,
133
+ peers: List["Peer"],
134
+ ) -> bool:
135
+ """
136
+ Download blocks in chunks from peers.
137
+ Tries each peer in sequence — falls back if one fails.
138
+ Verifies each block before adding to chain.
139
+ """
140
+ current = from_index
141
+
142
+ while current <= to_index:
143
+ chunk_end = min(current + SYNC_CHUNK_SIZE - 1, to_index)
144
+ success = False
145
+
146
+ # Try each peer until one works
147
+ for peer in peers:
148
+ blocks = self._fetch_chunk(peer, current, chunk_end)
149
+ if blocks:
150
+ applied = self._apply_chunk(blocks)
151
+ if applied:
152
+ current += len(blocks)
153
+ self._progress["current"] = current
154
+ logger.info(
155
+ f"Sync: applied blocks "
156
+ f"{current - len(blocks)} → {current - 1}"
157
+ )
158
+ success = True
159
+ break
160
+
161
+ if not success:
162
+ logger.error(
163
+ f"Sync: failed to download blocks "
164
+ f"{current} → {chunk_end} from any peer"
165
+ )
166
+ return False
167
+
168
+ logger.info(f"Sync: complete. Chain at block {to_index}")
169
+ return True
170
+
171
+ def _fetch_chunk(
172
+ self,
173
+ peer: "Peer",
174
+ from_index: int,
175
+ to_index: int,
176
+ ) -> List:
177
+ """Fetch a chunk of blocks from a peer."""
178
+ try:
179
+ res = requests.get(
180
+ f"{peer.url}/peer/blocks/{from_index}/{to_index}",
181
+ timeout=PEER_TIMEOUT * 3, # longer timeout for bulk fetch
182
+ )
183
+ if res.status_code != 200:
184
+ return []
185
+
186
+ data = res.json()
187
+ blocks_data = data.get("blocks", [])
188
+ blocks = []
189
+
190
+ from playweb.core.block import Block
191
+ for bd in blocks_data:
192
+ try:
193
+ block = Block.from_dict(bd)
194
+ blocks.append(block)
195
+ except Exception as e:
196
+ logger.warning(f"Sync: failed to parse block: {e}")
197
+ return []
198
+
199
+ return blocks
200
+
201
+ except Exception as e:
202
+ logger.debug(f"Sync: fetch failed from {peer.url}: {e}")
203
+ return []
204
+
205
+ def _apply_chunk(self, blocks: List) -> bool:
206
+ """Verify and apply a chunk of blocks to local chain."""
207
+ for block in blocks:
208
+ # Verify block integrity
209
+ tip = self.blockchain.get_chain_tip()
210
+ valid, reason = block.validate(previous_block=tip)
211
+
212
+ if not valid:
213
+ logger.warning(f"Sync: invalid block {block.index}: {reason}")
214
+ return False
215
+
216
+ # Add to chain (skip fee validation during sync —
217
+ # we trust the chain has been validated by consensus already)
218
+ success, reason = self.blockchain.add_block(block)
219
+ if not success:
220
+ logger.warning(
221
+ f"Sync: failed to add block {block.index}: {reason}"
222
+ )
223
+ return False
224
+
225
+ return True
226
+
227
+ # ─────────────────────────────────────────────────────────────
228
+ # Status
229
+ # ─────────────────────────────────────────────────────────────
230
+
231
+ def get_sync_status(self) -> dict:
232
+ return {
233
+ "syncing": self._syncing,
234
+ "current": self._progress["current"],
235
+ "target": self._progress["target"],
236
+ "synced": self._progress["synced"],
237
+ "percent": (
238
+ round(
239
+ self._progress["current"] /
240
+ max(self._progress["target"], 1) * 100, 1
241
+ )
242
+ if self._progress["target"] > 0 else 100
243
+ ),
244
+ }