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,540 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlayWebit Network — NVF-BFT Consensus Engine
|
|
3
|
+
Based on NullVoid Framework paper concepts.
|
|
4
|
+
|
|
5
|
+
Round phases mapped to NVF paper:
|
|
6
|
+
PROPOSE → Spacetime Fabric activation (leader broadcasts)
|
|
7
|
+
PREPARE → Six Directional Progressions (peers validate)
|
|
8
|
+
VOTE → Planck Threshold approach (peers sign + broadcast)
|
|
9
|
+
COMMIT → False Vacuum → True Vacuum (2/3 quorum = finalised)
|
|
10
|
+
ANCHOR → Null Void Layer 0 anchoring (write to storage)
|
|
11
|
+
NOTIFY → Cyclic re-entry notification (plugins + peers notified)
|
|
12
|
+
|
|
13
|
+
Tolerates up to f < n/3 faulty nodes (standard BFT guarantee).
|
|
14
|
+
|
|
15
|
+
Sybil Resistance — Option A (Authority Whitelist):
|
|
16
|
+
Only nodes with a node_register tx signed by AUTHORITY_WALLET
|
|
17
|
+
can participate in consensus voting.
|
|
18
|
+
Attacker nodes are silently ignored even if connected.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
import threading
|
|
23
|
+
import logging
|
|
24
|
+
from typing import Dict, List, Optional, Callable, Set
|
|
25
|
+
|
|
26
|
+
from playweb.consensus.leader import LeaderElection
|
|
27
|
+
from playweb.consensus.vote import Vote
|
|
28
|
+
from playweb.config import (
|
|
29
|
+
CONSENSUS_QUORUM,
|
|
30
|
+
BLOCK_TIME,
|
|
31
|
+
CONSENSUS_TIMEOUT,
|
|
32
|
+
BATCH_TIMEOUT,
|
|
33
|
+
MAX_TX_PER_BLOCK,
|
|
34
|
+
AUTHORITY_WALLET,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConsensusRound:
|
|
41
|
+
"""State for a single consensus round."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, block_index: int, block):
|
|
44
|
+
self.block_index = block_index
|
|
45
|
+
self.block = block
|
|
46
|
+
self.phase = "PROPOSE"
|
|
47
|
+
self.votes: Dict[str, Vote] = {} # voter_wallet → Vote
|
|
48
|
+
self.started_at = time.time()
|
|
49
|
+
self.finalised = False
|
|
50
|
+
self.failed = False
|
|
51
|
+
|
|
52
|
+
def add_vote(self, vote: Vote) -> bool:
|
|
53
|
+
"""Add a vote. Returns True if new vote added."""
|
|
54
|
+
if vote.voter_wallet in self.votes:
|
|
55
|
+
return False
|
|
56
|
+
if not vote.verify():
|
|
57
|
+
logger.warning(
|
|
58
|
+
f"Invalid vote signature from {vote.voter_wallet[:8]}"
|
|
59
|
+
)
|
|
60
|
+
return False
|
|
61
|
+
self.votes[vote.voter_wallet] = vote
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def has_quorum(
|
|
65
|
+
self,
|
|
66
|
+
active_node_count: int,
|
|
67
|
+
node_wallet: str = "",
|
|
68
|
+
) -> bool:
|
|
69
|
+
"""
|
|
70
|
+
Check if 2/3 quorum has been reached.
|
|
71
|
+
|
|
72
|
+
Single node rules:
|
|
73
|
+
- Authority node alone → can mine (bootstrapping phase)
|
|
74
|
+
- Non-authority node alone → must wait for peers (security)
|
|
75
|
+
"""
|
|
76
|
+
if active_node_count == 0:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
# Single node special case
|
|
80
|
+
if active_node_count == 1:
|
|
81
|
+
if node_wallet.lower() == AUTHORITY_WALLET.lower():
|
|
82
|
+
return len(self.votes) >= 1
|
|
83
|
+
else:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
# Multiple nodes — standard 2/3 quorum
|
|
87
|
+
return len(self.votes) / active_node_count >= CONSENSUS_QUORUM
|
|
88
|
+
|
|
89
|
+
def is_timed_out(self) -> bool:
|
|
90
|
+
return time.time() - self.started_at > CONSENSUS_TIMEOUT
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class NVFBFTConsensus:
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
blockchain,
|
|
98
|
+
peer_manager,
|
|
99
|
+
gossip,
|
|
100
|
+
node_wallet: str,
|
|
101
|
+
node_private_key: str,
|
|
102
|
+
plugin_manager = None,
|
|
103
|
+
on_block_finalised: Optional[Callable] = None,
|
|
104
|
+
):
|
|
105
|
+
self.blockchain = blockchain
|
|
106
|
+
self.peer_manager = peer_manager
|
|
107
|
+
self.gossip = gossip
|
|
108
|
+
self.node_wallet = node_wallet.lower()
|
|
109
|
+
self.node_private_key = node_private_key
|
|
110
|
+
self.plugin_manager = plugin_manager
|
|
111
|
+
self.on_block_finalised = on_block_finalised
|
|
112
|
+
|
|
113
|
+
self.leader_election = LeaderElection()
|
|
114
|
+
self.current_round: Optional[ConsensusRound] = None
|
|
115
|
+
self._lock = threading.Lock()
|
|
116
|
+
self._running = False
|
|
117
|
+
self._thread: Optional[threading.Thread] = None
|
|
118
|
+
|
|
119
|
+
# Batch mining timer
|
|
120
|
+
self._batch_timer_start: Optional[float] = None
|
|
121
|
+
|
|
122
|
+
# Sybil resistance — registered node wallet cache
|
|
123
|
+
# Loaded from chain on startup, updated on each new block
|
|
124
|
+
self._registered_nodes: Set[str] = set()
|
|
125
|
+
self._registered_nodes_loaded: bool = False
|
|
126
|
+
|
|
127
|
+
# ─────────────────────────────────────────────────────────────
|
|
128
|
+
# Lifecycle
|
|
129
|
+
# ─────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def start(self):
|
|
132
|
+
"""Start the consensus loop in a background thread."""
|
|
133
|
+
# Load registered nodes from chain before starting
|
|
134
|
+
self._load_registered_nodes()
|
|
135
|
+
|
|
136
|
+
self._running = True
|
|
137
|
+
self._thread = threading.Thread(
|
|
138
|
+
target = self._consensus_loop,
|
|
139
|
+
daemon = True,
|
|
140
|
+
name = "nvf-bft-consensus",
|
|
141
|
+
)
|
|
142
|
+
self._thread.start()
|
|
143
|
+
logger.info(
|
|
144
|
+
f"NVF-BFT consensus started — "
|
|
145
|
+
f"{len(self._registered_nodes)} registered validators"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def stop(self):
|
|
149
|
+
self._running = False
|
|
150
|
+
if self._thread:
|
|
151
|
+
self._thread.join(timeout=5)
|
|
152
|
+
logger.info("NVF-BFT consensus stopped")
|
|
153
|
+
|
|
154
|
+
# ─────────────────────────────────────────────────────────────
|
|
155
|
+
# Sybil Resistance — Registered Node Registry
|
|
156
|
+
# ─────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def _load_registered_nodes(self):
|
|
159
|
+
"""
|
|
160
|
+
Load all registered validator wallets from chain.
|
|
161
|
+
Scans for node_register transactions signed by AUTHORITY_WALLET.
|
|
162
|
+
Authority wallet is always included (bootstrapping).
|
|
163
|
+
Called on startup and when new blocks arrive.
|
|
164
|
+
"""
|
|
165
|
+
registered = {AUTHORITY_WALLET.lower()}
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
length = self.blockchain.get_chain_length()
|
|
169
|
+
scan_from = max(0, length - 100000)
|
|
170
|
+
blocks = self.blockchain.get_blocks_from(scan_from, 100000)
|
|
171
|
+
|
|
172
|
+
for block in blocks:
|
|
173
|
+
for tx in block.transactions:
|
|
174
|
+
if (
|
|
175
|
+
tx.tx_type == "node_register"
|
|
176
|
+
and tx.from_addr == AUTHORITY_WALLET.lower()
|
|
177
|
+
):
|
|
178
|
+
registered.add(tx.to_addr.lower())
|
|
179
|
+
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.error(f"Error loading registered nodes: {e}")
|
|
182
|
+
|
|
183
|
+
self._registered_nodes = registered
|
|
184
|
+
self._registered_nodes_loaded = True
|
|
185
|
+
logger.info(
|
|
186
|
+
f"Registered validators: {len(registered)} "
|
|
187
|
+
f"({', '.join(list(registered)[:3])}...)"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _is_registered_node(self, wallet: str) -> bool:
|
|
191
|
+
"""
|
|
192
|
+
Check if a wallet is a registered validator.
|
|
193
|
+
Authority wallet is always registered.
|
|
194
|
+
Other nodes must have a node_register tx on chain.
|
|
195
|
+
"""
|
|
196
|
+
if not self._registered_nodes_loaded:
|
|
197
|
+
self._load_registered_nodes()
|
|
198
|
+
return wallet.lower() in self._registered_nodes
|
|
199
|
+
|
|
200
|
+
def _update_registered_nodes(self, block):
|
|
201
|
+
"""
|
|
202
|
+
Update registered nodes cache from a newly finalised block.
|
|
203
|
+
Called after ANCHOR phase so new validators take effect
|
|
204
|
+
from the very next block.
|
|
205
|
+
"""
|
|
206
|
+
for tx in block.transactions:
|
|
207
|
+
if (
|
|
208
|
+
tx.tx_type == "node_register"
|
|
209
|
+
and tx.from_addr == AUTHORITY_WALLET.lower()
|
|
210
|
+
):
|
|
211
|
+
self._registered_nodes.add(tx.to_addr.lower())
|
|
212
|
+
logger.info(
|
|
213
|
+
f"New validator registered on chain: "
|
|
214
|
+
f"{tx.to_addr[:12]}... "
|
|
215
|
+
f"(platform: {tx.data.get('platform_id', 'unknown') if tx.data else 'unknown'})"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# ─────────────────────────────────────────────────────────────
|
|
219
|
+
# Main consensus loop — Batch Mining
|
|
220
|
+
# ─────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def _consensus_loop(self):
|
|
223
|
+
"""
|
|
224
|
+
Batch mining loop:
|
|
225
|
+
- Never mines empty blocks
|
|
226
|
+
- Starts timer when first tx hits mempool
|
|
227
|
+
- Mines immediately at MAX_TX_PER_BLOCK
|
|
228
|
+
- Mines with pending txs when BATCH_TIMEOUT expires
|
|
229
|
+
"""
|
|
230
|
+
while self._running:
|
|
231
|
+
try:
|
|
232
|
+
pending = self.blockchain.mempool.size()
|
|
233
|
+
|
|
234
|
+
if pending == 0:
|
|
235
|
+
# No transactions — reset timer, wait
|
|
236
|
+
self._batch_timer_start = None
|
|
237
|
+
time.sleep(5)
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# First tx arrived — start timer
|
|
241
|
+
if self._batch_timer_start is None:
|
|
242
|
+
self._batch_timer_start = time.time()
|
|
243
|
+
logger.info(
|
|
244
|
+
f"Batch timer started — "
|
|
245
|
+
f"{pending} tx(s) in mempool, "
|
|
246
|
+
f"mining in {BATCH_TIMEOUT}s "
|
|
247
|
+
f"or at {MAX_TX_PER_BLOCK} txs"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
elapsed = time.time() - self._batch_timer_start
|
|
251
|
+
should_mine = (
|
|
252
|
+
pending >= MAX_TX_PER_BLOCK # max txs → mine now
|
|
253
|
+
or elapsed >= BATCH_TIMEOUT # timeout → mine now
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if should_mine:
|
|
257
|
+
reason = (
|
|
258
|
+
"max_txs" if pending >= MAX_TX_PER_BLOCK
|
|
259
|
+
else "timeout"
|
|
260
|
+
)
|
|
261
|
+
logger.info(
|
|
262
|
+
f"Mining: {pending} txs | "
|
|
263
|
+
f"elapsed={elapsed:.0f}s | "
|
|
264
|
+
f"reason={reason}"
|
|
265
|
+
)
|
|
266
|
+
self._run_round()
|
|
267
|
+
self._batch_timer_start = None # reset after mining
|
|
268
|
+
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.error(f"Consensus error: {e}", exc_info=True)
|
|
271
|
+
self._batch_timer_start = None
|
|
272
|
+
|
|
273
|
+
time.sleep(5) # check every 5 seconds
|
|
274
|
+
|
|
275
|
+
def _run_round(self):
|
|
276
|
+
"""Execute one consensus round."""
|
|
277
|
+
tip = self.blockchain.get_chain_tip()
|
|
278
|
+
next_index = (tip.index + 1) if tip else 1
|
|
279
|
+
|
|
280
|
+
# Only registered nodes count as active validators
|
|
281
|
+
active_nodes = [
|
|
282
|
+
p.wallet
|
|
283
|
+
for p in self.peer_manager.get_active_peers()
|
|
284
|
+
if self._is_registered_node(p.wallet)
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
# Include self if registered
|
|
288
|
+
if self._is_registered_node(self.node_wallet):
|
|
289
|
+
if self.node_wallet not in active_nodes:
|
|
290
|
+
active_nodes.append(self.node_wallet)
|
|
291
|
+
|
|
292
|
+
if not active_nodes:
|
|
293
|
+
logger.warning("No registered validators available")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# PHASE: PROPOSE — NVF: Spacetime Fabric activation
|
|
297
|
+
am_leader = self.leader_election.is_leader(
|
|
298
|
+
self.node_wallet, next_index, active_nodes
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if am_leader:
|
|
302
|
+
logger.info(
|
|
303
|
+
f"Round {next_index}: I am leader "
|
|
304
|
+
f"({self.node_wallet[:8]}...) "
|
|
305
|
+
f"[{len(active_nodes)} validators]"
|
|
306
|
+
)
|
|
307
|
+
self._propose(next_index)
|
|
308
|
+
else:
|
|
309
|
+
leader = self.leader_election.get_leader(next_index, active_nodes)
|
|
310
|
+
logger.debug(
|
|
311
|
+
f"Round {next_index}: waiting for leader "
|
|
312
|
+
f"{leader[:8] if leader else 'unknown'}..."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# ─────────────────────────────────────────────────────────────
|
|
316
|
+
# Phase: PROPOSE — NVF: Spacetime Fabric activation
|
|
317
|
+
# ─────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
def _propose(self, block_index: int):
|
|
320
|
+
"""Leader creates and broadcasts a block proposal."""
|
|
321
|
+
block = self.blockchain.create_block(self.node_wallet)
|
|
322
|
+
if not block:
|
|
323
|
+
logger.debug(f"Round {block_index}: no transactions to propose")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
with self._lock:
|
|
327
|
+
self.current_round = ConsensusRound(block_index, block)
|
|
328
|
+
self.current_round.phase = "PREPARE"
|
|
329
|
+
|
|
330
|
+
logger.info(
|
|
331
|
+
f"PROPOSE: block {block_index}, "
|
|
332
|
+
f"{len(block.transactions)} txs, "
|
|
333
|
+
f"hash={block.hash[:12]}..."
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Broadcast to all peers
|
|
337
|
+
self.gossip.broadcast_block_proposal(
|
|
338
|
+
block = block,
|
|
339
|
+
round_number = block_index,
|
|
340
|
+
peers = self.peer_manager.get_active_peers(),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Vote for own proposal
|
|
344
|
+
self._prepare_and_vote(block, block_index)
|
|
345
|
+
|
|
346
|
+
# ─────────────────────────────────────────────────────────────
|
|
347
|
+
# Phase: PREPARE — NVF: Six Directional Progressions
|
|
348
|
+
# ─────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
def on_propose(self, block, round_number: int, from_peer: str):
|
|
351
|
+
"""
|
|
352
|
+
Called when we receive a block proposal from the leader.
|
|
353
|
+
Validates and moves to VOTE phase.
|
|
354
|
+
"""
|
|
355
|
+
# Only accept proposals from registered nodes
|
|
356
|
+
if not self._is_registered_node(from_peer):
|
|
357
|
+
logger.warning(
|
|
358
|
+
f"Proposal from unregistered node "
|
|
359
|
+
f"{from_peer[:8]}... ignored (Sybil protection)"
|
|
360
|
+
)
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
logger.info(
|
|
364
|
+
f"PREPARE: received proposal for block {round_number} "
|
|
365
|
+
f"from {from_peer[:8]}..."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
tip = self.blockchain.get_chain_tip()
|
|
369
|
+
valid, reason = block.validate(previous_block=tip)
|
|
370
|
+
|
|
371
|
+
if not valid:
|
|
372
|
+
logger.warning(f"Proposal rejected: {reason}")
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
with self._lock:
|
|
376
|
+
self.current_round = ConsensusRound(round_number, block)
|
|
377
|
+
self.current_round.phase = "VOTE"
|
|
378
|
+
|
|
379
|
+
self._prepare_and_vote(block, round_number)
|
|
380
|
+
|
|
381
|
+
def _prepare_and_vote(self, block, round_number: int):
|
|
382
|
+
"""Create and broadcast our vote for this block."""
|
|
383
|
+
vote = Vote(
|
|
384
|
+
block_hash = block.hash,
|
|
385
|
+
voter_wallet = self.node_wallet,
|
|
386
|
+
round_number = round_number,
|
|
387
|
+
phase = "VOTE",
|
|
388
|
+
)
|
|
389
|
+
vote.sign(self.node_private_key)
|
|
390
|
+
|
|
391
|
+
self.gossip.broadcast_vote(
|
|
392
|
+
vote = vote,
|
|
393
|
+
peers = self.peer_manager.get_active_peers(),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
self.on_vote(vote)
|
|
397
|
+
|
|
398
|
+
# ─────────────────────────────────────────────────────────────
|
|
399
|
+
# Phase: VOTE → COMMIT
|
|
400
|
+
# NVF: False Vacuum → True Vacuum (quorum = finality)
|
|
401
|
+
# ─────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
def on_vote(self, vote: Vote):
|
|
404
|
+
"""
|
|
405
|
+
Called when we receive a vote from any peer (or self).
|
|
406
|
+
If 2/3 quorum reached → COMMIT.
|
|
407
|
+
"""
|
|
408
|
+
with self._lock:
|
|
409
|
+
if not self.current_round:
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
# Sybil resistance — only registered nodes can vote
|
|
413
|
+
if not self._is_registered_node(vote.voter_wallet):
|
|
414
|
+
logger.warning(
|
|
415
|
+
f"Vote ignored — unregistered node: "
|
|
416
|
+
f"{vote.voter_wallet[:12]}... (Sybil protection)"
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
if vote.block_hash != self.current_round.block.hash:
|
|
421
|
+
logger.debug(
|
|
422
|
+
f"Vote for unknown block "
|
|
423
|
+
f"{vote.block_hash[:12]}... ignored"
|
|
424
|
+
)
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
added = self.current_round.add_vote(vote)
|
|
428
|
+
if not added:
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
# Count only registered active nodes
|
|
432
|
+
registered_peers = [
|
|
433
|
+
p for p in self.peer_manager.get_active_peers()
|
|
434
|
+
if self._is_registered_node(p.wallet)
|
|
435
|
+
]
|
|
436
|
+
active_count = len(registered_peers) + 1 # +1 for self
|
|
437
|
+
vote_count = len(self.current_round.votes)
|
|
438
|
+
|
|
439
|
+
logger.debug(
|
|
440
|
+
f"VOTE: {vote_count}/{active_count} registered votes "
|
|
441
|
+
f"for block {self.current_round.block_index}"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if self.current_round.has_quorum(active_count, self.node_wallet):
|
|
445
|
+
if not self.current_round.finalised:
|
|
446
|
+
self.current_round.finalised = True
|
|
447
|
+
self._commit(
|
|
448
|
+
self.current_round.block,
|
|
449
|
+
list(self.current_round.votes.values()),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# ─────────────────────────────────────────────────────────────
|
|
453
|
+
# Phase: COMMIT → ANCHOR → NOTIFY
|
|
454
|
+
# NVF: True Vacuum → Null Void anchoring → Cyclic re-entry
|
|
455
|
+
# ─────────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
def _commit(self, block, votes: List[Vote]):
|
|
458
|
+
"""
|
|
459
|
+
2/3 quorum reached. Finalise the block.
|
|
460
|
+
COMMIT → ANCHOR → NOTIFY
|
|
461
|
+
"""
|
|
462
|
+
logger.info(
|
|
463
|
+
f"COMMIT: block {block.index} finalised with "
|
|
464
|
+
f"{len(votes)} votes"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# ANCHOR — NVF Layer 0
|
|
468
|
+
vote_dicts = [v.to_dict() for v in votes]
|
|
469
|
+
success, reason = self.blockchain.add_block(
|
|
470
|
+
block = block,
|
|
471
|
+
votes = vote_dicts,
|
|
472
|
+
node_wallet = self.node_wallet,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if not success:
|
|
476
|
+
logger.error(f"Block commit failed: {reason}")
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
logger.info(
|
|
480
|
+
f"ANCHOR: block {block.index} written "
|
|
481
|
+
f"({block.hash[:16]}...)"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Update registered nodes cache from this block
|
|
485
|
+
# New node_register txs take effect immediately
|
|
486
|
+
self._update_registered_nodes(block)
|
|
487
|
+
|
|
488
|
+
# NOTIFY — Cyclic re-entry
|
|
489
|
+
# 1. Notify plugins
|
|
490
|
+
if self.plugin_manager:
|
|
491
|
+
self.plugin_manager.notify_block_finalised(block)
|
|
492
|
+
|
|
493
|
+
# 2. Notify node (broadcasts to peers)
|
|
494
|
+
if self.on_block_finalised:
|
|
495
|
+
self.on_block_finalised(block, vote_dicts)
|
|
496
|
+
|
|
497
|
+
logger.info(
|
|
498
|
+
f"NOTIFY: block {block.index} notifications sent"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# ─────────────────────────────────────────────────────────────
|
|
502
|
+
# Timeout handling
|
|
503
|
+
# ─────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
def check_timeout(self):
|
|
506
|
+
"""Check if current round timed out and reset if so."""
|
|
507
|
+
with self._lock:
|
|
508
|
+
if (
|
|
509
|
+
self.current_round
|
|
510
|
+
and self.current_round.is_timed_out()
|
|
511
|
+
):
|
|
512
|
+
logger.warning(
|
|
513
|
+
f"Consensus round {self.current_round.block_index} "
|
|
514
|
+
f"timed out — resetting"
|
|
515
|
+
)
|
|
516
|
+
self.current_round = None
|
|
517
|
+
|
|
518
|
+
# ─────────────────────────────────────────────────────────────
|
|
519
|
+
# Status
|
|
520
|
+
# ─────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
def get_status(self) -> Dict:
|
|
523
|
+
with self._lock:
|
|
524
|
+
base = {
|
|
525
|
+
"registered_validators": len(self._registered_nodes),
|
|
526
|
+
}
|
|
527
|
+
if not self.current_round:
|
|
528
|
+
return {**base, "status": "idle"}
|
|
529
|
+
return {
|
|
530
|
+
**base,
|
|
531
|
+
"status": "in_round",
|
|
532
|
+
"block_index": self.current_round.block_index,
|
|
533
|
+
"phase": self.current_round.phase,
|
|
534
|
+
"votes": len(self.current_round.votes),
|
|
535
|
+
"finalised": self.current_round.finalised,
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
def get_registered_validators(self) -> List[str]:
|
|
539
|
+
"""Get list of all registered validator wallets."""
|
|
540
|
+
return list(self._registered_nodes)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlayWebit Network — Vote
|
|
3
|
+
NVF-BFT vote message. Signed by each validator.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Dict
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Vote:
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
block_hash: str,
|
|
20
|
+
voter_wallet: str,
|
|
21
|
+
round_number: int,
|
|
22
|
+
phase: str, # PREPARE | VOTE | COMMIT
|
|
23
|
+
timestamp: float = None,
|
|
24
|
+
signature: str = None,
|
|
25
|
+
):
|
|
26
|
+
self.block_hash = block_hash
|
|
27
|
+
self.voter_wallet = voter_wallet.lower()
|
|
28
|
+
self.round_number = round_number
|
|
29
|
+
self.phase = phase
|
|
30
|
+
self.timestamp = timestamp or time.time()
|
|
31
|
+
self.signature = signature
|
|
32
|
+
self.hash = self._calculate_hash()
|
|
33
|
+
|
|
34
|
+
def _calculate_hash(self) -> str:
|
|
35
|
+
raw = json.dumps({
|
|
36
|
+
"block_hash": self.block_hash,
|
|
37
|
+
"voter_wallet": self.voter_wallet,
|
|
38
|
+
"round_number": self.round_number,
|
|
39
|
+
"phase": self.phase,
|
|
40
|
+
"timestamp": self.timestamp,
|
|
41
|
+
}, sort_keys=True)
|
|
42
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
43
|
+
|
|
44
|
+
def sign(self, private_key: str) -> bool:
|
|
45
|
+
"""Sign the vote with the node's private key."""
|
|
46
|
+
try:
|
|
47
|
+
from eth_account import Account
|
|
48
|
+
from eth_account.messages import encode_defunct
|
|
49
|
+
|
|
50
|
+
msg = encode_defunct(text=self.hash)
|
|
51
|
+
signed = Account.sign_message(msg, private_key=private_key)
|
|
52
|
+
self.signature = signed.signature.hex()
|
|
53
|
+
return True
|
|
54
|
+
except ImportError:
|
|
55
|
+
# Basic placeholder if eth_account not available
|
|
56
|
+
self.signature = f"0x{'0' * 130}"
|
|
57
|
+
return True
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.error(f"Vote signing failed: {e}")
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def verify(self) -> bool:
|
|
63
|
+
"""Verify the vote signature."""
|
|
64
|
+
if not self.signature:
|
|
65
|
+
return False
|
|
66
|
+
try:
|
|
67
|
+
from eth_account import Account
|
|
68
|
+
from eth_account.messages import encode_defunct
|
|
69
|
+
|
|
70
|
+
msg = encode_defunct(text=self.hash)
|
|
71
|
+
recovered = Account.recover_message(msg, signature=self.signature)
|
|
72
|
+
return recovered.lower() == self.voter_wallet
|
|
73
|
+
except ImportError:
|
|
74
|
+
return bool(self.signature)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(f"Vote verification failed: {e}")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> Dict:
|
|
80
|
+
return {
|
|
81
|
+
"hash": self.hash,
|
|
82
|
+
"block_hash": self.block_hash,
|
|
83
|
+
"voter_wallet": self.voter_wallet,
|
|
84
|
+
"round_number": self.round_number,
|
|
85
|
+
"phase": self.phase,
|
|
86
|
+
"timestamp": self.timestamp,
|
|
87
|
+
"signature": self.signature,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, d: Dict) -> "Vote":
|
|
92
|
+
v = cls(
|
|
93
|
+
block_hash = d["block_hash"],
|
|
94
|
+
voter_wallet = d["voter_wallet"],
|
|
95
|
+
round_number = d["round_number"],
|
|
96
|
+
phase = d["phase"],
|
|
97
|
+
timestamp = d.get("timestamp"),
|
|
98
|
+
signature = d.get("signature"),
|
|
99
|
+
)
|
|
100
|
+
v.hash = d.get("hash", v.hash)
|
|
101
|
+
return v
|
|
102
|
+
|
|
103
|
+
def __repr__(self):
|
|
104
|
+
return (
|
|
105
|
+
f"Vote(phase={self.phase}, "
|
|
106
|
+
f"voter={self.voter_wallet[:8]}..., "
|
|
107
|
+
f"block={self.block_hash[:12]}...)"
|
|
108
|
+
)
|
playweb/core/__init__.py
ADDED