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,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
|
+
)
|
playweb/network/sync.py
ADDED
|
@@ -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
|
+
}
|