zexus 1.6.8 → 1.7.2
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.
- package/README.md +12 -5
- package/package.json +1 -1
- package/src/__init__.py +7 -0
- package/src/zexus/__init__.py +1 -1
- package/src/zexus/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/capability_system.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/debug_sanitizer.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/environment.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/error_reporter.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/input_validation.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/lexer.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/module_cache.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/module_manager.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/object.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/security.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/security_enforcement.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/syntax_validator.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/zexus_ast.cpython-312.pyc +0 -0
- package/src/zexus/__pycache__/zexus_token.cpython-312.pyc +0 -0
- package/src/zexus/access_control_system/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/zexus/access_control_system/__pycache__/access_control.cpython-312.pyc +0 -0
- package/src/zexus/advanced_types.py +17 -2
- package/src/zexus/blockchain/__init__.py +411 -0
- package/src/zexus/blockchain/accelerator.py +1160 -0
- package/src/zexus/blockchain/chain.py +660 -0
- package/src/zexus/blockchain/consensus.py +821 -0
- package/src/zexus/blockchain/contract_vm.py +1019 -0
- package/src/zexus/blockchain/crypto.py +79 -14
- package/src/zexus/blockchain/events.py +526 -0
- package/src/zexus/blockchain/loadtest.py +721 -0
- package/src/zexus/blockchain/monitoring.py +350 -0
- package/src/zexus/blockchain/mpt.py +716 -0
- package/src/zexus/blockchain/multichain.py +951 -0
- package/src/zexus/blockchain/multiprocess_executor.py +338 -0
- package/src/zexus/blockchain/network.py +886 -0
- package/src/zexus/blockchain/node.py +666 -0
- package/src/zexus/blockchain/rpc.py +1203 -0
- package/src/zexus/blockchain/rust_bridge.py +421 -0
- package/src/zexus/blockchain/storage.py +423 -0
- package/src/zexus/blockchain/tokens.py +750 -0
- package/src/zexus/blockchain/upgradeable.py +1004 -0
- package/src/zexus/blockchain/verification.py +1602 -0
- package/src/zexus/blockchain/wallet.py +621 -0
- package/src/zexus/capability_system.py +184 -9
- package/src/zexus/cli/__pycache__/main.cpython-312.pyc +0 -0
- package/src/zexus/cli/main.py +383 -34
- package/src/zexus/cli/zpm.py +1 -1
- package/src/zexus/compiler/__pycache__/bytecode.cpython-312.pyc +0 -0
- package/src/zexus/compiler/__pycache__/lexer.cpython-312.pyc +0 -0
- package/src/zexus/compiler/__pycache__/parser.cpython-312.pyc +0 -0
- package/src/zexus/compiler/__pycache__/semantic.cpython-312.pyc +0 -0
- package/src/zexus/compiler/__pycache__/zexus_ast.cpython-312.pyc +0 -0
- package/src/zexus/compiler/bytecode.py +124 -7
- package/src/zexus/compiler/compat_runtime.py +6 -2
- package/src/zexus/compiler/lexer.py +16 -5
- package/src/zexus/compiler/parser.py +108 -7
- package/src/zexus/compiler/semantic.py +18 -19
- package/src/zexus/compiler/zexus_ast.py +26 -1
- package/src/zexus/concurrency_system.py +79 -0
- package/src/zexus/config.py +54 -0
- package/src/zexus/crypto_bridge.py +244 -8
- package/src/zexus/dap/__init__.py +10 -0
- package/src/zexus/dap/__main__.py +4 -0
- package/src/zexus/dap/dap_server.py +391 -0
- package/src/zexus/dap/debug_engine.py +298 -0
- package/src/zexus/environment.py +112 -9
- package/src/zexus/evaluator/__pycache__/bytecode_compiler.cpython-312.pyc +0 -0
- package/src/zexus/evaluator/__pycache__/core.cpython-312.pyc +0 -0
- package/src/zexus/evaluator/__pycache__/expressions.cpython-312.pyc +0 -0
- package/src/zexus/evaluator/__pycache__/functions.cpython-312.pyc +0 -0
- package/src/zexus/evaluator/__pycache__/resource_limiter.cpython-312.pyc +0 -0
- package/src/zexus/evaluator/__pycache__/statements.cpython-312.pyc +0 -0
- package/src/zexus/evaluator/__pycache__/unified_execution.cpython-312.pyc +0 -0
- package/src/zexus/evaluator/__pycache__/utils.cpython-312.pyc +0 -0
- package/src/zexus/evaluator/bytecode_compiler.py +457 -37
- package/src/zexus/evaluator/core.py +644 -50
- package/src/zexus/evaluator/expressions.py +358 -62
- package/src/zexus/evaluator/functions.py +458 -20
- package/src/zexus/evaluator/resource_limiter.py +4 -4
- package/src/zexus/evaluator/statements.py +774 -122
- package/src/zexus/evaluator/unified_execution.py +573 -72
- package/src/zexus/evaluator/utils.py +14 -2
- package/src/zexus/evaluator_original.py +1 -1
- package/src/zexus/event_loop.py +186 -0
- package/src/zexus/lexer.py +742 -458
- package/src/zexus/lsp/__init__.py +1 -1
- package/src/zexus/lsp/definition_provider.py +163 -9
- package/src/zexus/lsp/server.py +22 -8
- package/src/zexus/lsp/symbol_provider.py +182 -9
- package/src/zexus/module_cache.py +239 -9
- package/src/zexus/module_manager.py +129 -1
- package/src/zexus/object.py +76 -6
- package/src/zexus/parser/__pycache__/parser.cpython-312.pyc +0 -0
- package/src/zexus/parser/__pycache__/strategy_context.cpython-312.pyc +0 -0
- package/src/zexus/parser/__pycache__/strategy_structural.cpython-312.pyc +0 -0
- package/src/zexus/parser/parser.py +1349 -408
- package/src/zexus/parser/strategy_context.py +755 -58
- package/src/zexus/parser/strategy_structural.py +121 -21
- package/src/zexus/persistence.py +15 -1
- package/src/zexus/renderer/__init__.py +61 -0
- package/src/zexus/renderer/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/zexus/renderer/__pycache__/backend.cpython-312.pyc +0 -0
- package/src/zexus/renderer/__pycache__/canvas.cpython-312.pyc +0 -0
- package/src/zexus/renderer/__pycache__/color_system.cpython-312.pyc +0 -0
- package/src/zexus/renderer/__pycache__/layout.cpython-312.pyc +0 -0
- package/src/zexus/renderer/__pycache__/main_renderer.cpython-312.pyc +0 -0
- package/src/zexus/renderer/__pycache__/painter.cpython-312.pyc +0 -0
- package/src/zexus/renderer/backend.py +261 -0
- package/src/zexus/renderer/canvas.py +78 -0
- package/src/zexus/renderer/color_system.py +201 -0
- package/src/zexus/renderer/graphics.py +31 -0
- package/src/zexus/renderer/layout.py +222 -0
- package/src/zexus/renderer/main_renderer.py +66 -0
- package/src/zexus/renderer/painter.py +30 -0
- package/src/zexus/renderer/tk_backend.py +208 -0
- package/src/zexus/renderer/web_backend.py +260 -0
- package/src/zexus/runtime/__init__.py +10 -2
- package/src/zexus/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/zexus/runtime/__pycache__/async_runtime.cpython-312.pyc +0 -0
- package/src/zexus/runtime/__pycache__/load_manager.cpython-312.pyc +0 -0
- package/src/zexus/runtime/file_flags.py +137 -0
- package/src/zexus/runtime/load_manager.py +368 -0
- package/src/zexus/safety/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/zexus/safety/__pycache__/memory_safety.cpython-312.pyc +0 -0
- package/src/zexus/security.py +424 -34
- package/src/zexus/stdlib/fs.py +23 -18
- package/src/zexus/stdlib/http.py +289 -186
- package/src/zexus/stdlib/sockets.py +207 -163
- package/src/zexus/stdlib/websockets.py +282 -0
- package/src/zexus/stdlib_integration.py +369 -2
- package/src/zexus/strategy_recovery.py +6 -3
- package/src/zexus/type_checker.py +423 -0
- package/src/zexus/virtual_filesystem.py +189 -2
- package/src/zexus/vm/__init__.py +113 -3
- package/src/zexus/vm/__pycache__/async_optimizer.cpython-312.pyc +0 -0
- package/src/zexus/vm/__pycache__/bytecode.cpython-312.pyc +0 -0
- package/src/zexus/vm/__pycache__/bytecode_converter.cpython-312.pyc +0 -0
- package/src/zexus/vm/__pycache__/cache.cpython-312.pyc +0 -0
- package/src/zexus/vm/__pycache__/compiler.cpython-312.pyc +0 -0
- package/src/zexus/vm/__pycache__/gas_metering.cpython-312.pyc +0 -0
- package/src/zexus/vm/__pycache__/jit.cpython-312.pyc +0 -0
- package/src/zexus/vm/__pycache__/parallel_vm.cpython-312.pyc +0 -0
- package/src/zexus/vm/__pycache__/vm.cpython-312.pyc +0 -0
- package/src/zexus/vm/async_optimizer.py +80 -6
- package/src/zexus/vm/binary_bytecode.py +659 -0
- package/src/zexus/vm/bytecode.py +59 -11
- package/src/zexus/vm/bytecode_converter.py +26 -12
- package/src/zexus/vm/cabi.c +1985 -0
- package/src/zexus/vm/cabi.cpython-312-x86_64-linux-gnu.so +0 -0
- package/src/zexus/vm/cabi.h +127 -0
- package/src/zexus/vm/cache.py +561 -17
- package/src/zexus/vm/compiler.py +818 -51
- package/src/zexus/vm/fastops.c +15743 -0
- package/src/zexus/vm/fastops.cpython-312-x86_64-linux-gnu.so +0 -0
- package/src/zexus/vm/fastops.pyx +288 -0
- package/src/zexus/vm/gas_metering.py +50 -9
- package/src/zexus/vm/jit.py +364 -20
- package/src/zexus/vm/native_jit_backend.py +1816 -0
- package/src/zexus/vm/native_runtime.cpp +1388 -0
- package/src/zexus/vm/native_runtime.cpython-312-x86_64-linux-gnu.so +0 -0
- package/src/zexus/vm/optimizer.py +161 -11
- package/src/zexus/vm/parallel_vm.py +140 -45
- package/src/zexus/vm/peephole_optimizer.py +82 -4
- package/src/zexus/vm/profiler.py +38 -18
- package/src/zexus/vm/register_allocator.py +16 -5
- package/src/zexus/vm/register_vm.py +8 -5
- package/src/zexus/vm/vm.py +3581 -531
- package/src/zexus/vm/wasm_compiler.py +658 -0
- package/src/zexus/zexus_ast.py +137 -11
- package/src/zexus/zexus_token.py +16 -5
- package/src/zexus/zpm/installer.py +55 -15
- package/src/zexus/zpm/package_manager.py +1 -1
- package/src/zexus/zpm/registry.py +257 -28
- package/src/zexus.egg-info/PKG-INFO +16 -6
- package/src/zexus.egg-info/SOURCES.txt +129 -17
- package/src/zexus.egg-info/entry_points.txt +1 -0
- package/src/zexus.egg-info/requires.txt +4 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Zexus Blockchain — Peer-to-Peer Networking Layer
|
|
3
|
+
|
|
4
|
+
Provides peer discovery, connection management, and message propagation
|
|
5
|
+
for the Zexus blockchain network. Built on top of the existing asyncio
|
|
6
|
+
TCP/WebSocket infrastructure in ``stdlib.sockets``.
|
|
7
|
+
|
|
8
|
+
Protocol messages are JSON-encoded with a type field and optional payload.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import random
|
|
15
|
+
import ssl
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass, field, asdict
|
|
21
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("zexus.blockchain.network")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── TLS helpers ────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def _generate_self_signed_cert(cert_path: str, key_path: str):
|
|
29
|
+
"""Generate a self-signed TLS certificate for node identity.
|
|
30
|
+
|
|
31
|
+
Uses the ``cryptography`` library (already a dependency) to produce
|
|
32
|
+
an ECDSA P-256 keypair and an X.509 certificate valid for 10 years.
|
|
33
|
+
The certificate's Subject is set to the key's SHA-256 fingerprint so
|
|
34
|
+
it doubles as a verifiable node identity.
|
|
35
|
+
"""
|
|
36
|
+
from cryptography import x509
|
|
37
|
+
from cryptography.x509.oid import NameOID
|
|
38
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
39
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
40
|
+
from cryptography.hazmat.backends import default_backend
|
|
41
|
+
import datetime
|
|
42
|
+
|
|
43
|
+
key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
|
44
|
+
|
|
45
|
+
# Derive a human-readable CN from the public key fingerprint
|
|
46
|
+
pub_bytes = key.public_key().public_bytes(
|
|
47
|
+
serialization.Encoding.DER,
|
|
48
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
49
|
+
)
|
|
50
|
+
fingerprint = hashlib.sha256(pub_bytes).hexdigest()[:16]
|
|
51
|
+
subject = issuer = x509.Name([
|
|
52
|
+
x509.NameAttribute(NameOID.COMMON_NAME, f"zexus-node-{fingerprint}"),
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
cert = (
|
|
56
|
+
x509.CertificateBuilder()
|
|
57
|
+
.subject_name(subject)
|
|
58
|
+
.issuer_name(issuer)
|
|
59
|
+
.public_key(key.public_key())
|
|
60
|
+
.serial_number(x509.random_serial_number())
|
|
61
|
+
.not_valid_before(datetime.datetime.utcnow())
|
|
62
|
+
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650))
|
|
63
|
+
.sign(key, hashes.SHA256(), default_backend())
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
os.makedirs(os.path.dirname(cert_path) or ".", exist_ok=True)
|
|
67
|
+
|
|
68
|
+
with open(key_path, "wb") as f:
|
|
69
|
+
f.write(key.private_bytes(
|
|
70
|
+
serialization.Encoding.PEM,
|
|
71
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
72
|
+
serialization.NoEncryption(),
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
with open(cert_path, "wb") as f:
|
|
76
|
+
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
|
77
|
+
|
|
78
|
+
return cert_path, key_path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _make_server_ssl_context(
|
|
82
|
+
cert_path: str,
|
|
83
|
+
key_path: str,
|
|
84
|
+
ca_cert_path: Optional[str] = None,
|
|
85
|
+
require_client_cert: bool = False,
|
|
86
|
+
) -> ssl.SSLContext:
|
|
87
|
+
"""Create a TLS server context with optional mutual-TLS (mTLS).
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
cert_path : str
|
|
92
|
+
Path to the server certificate (PEM).
|
|
93
|
+
key_path : str
|
|
94
|
+
Path to the server private key (PEM).
|
|
95
|
+
ca_cert_path : str, optional
|
|
96
|
+
Path to a CA bundle. When provided, the server verifies peer
|
|
97
|
+
certificates against this CA — enabling proper CA-signed
|
|
98
|
+
certificate chains instead of self-signed only.
|
|
99
|
+
require_client_cert : bool
|
|
100
|
+
When *True*, the server demands a valid client certificate
|
|
101
|
+
(mutual TLS). Only effective when ``ca_cert_path`` is set.
|
|
102
|
+
"""
|
|
103
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
104
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
105
|
+
ctx.load_cert_chain(cert_path, key_path)
|
|
106
|
+
|
|
107
|
+
if ca_cert_path and os.path.isfile(ca_cert_path):
|
|
108
|
+
ctx.load_verify_locations(ca_cert_path)
|
|
109
|
+
if require_client_cert:
|
|
110
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
111
|
+
else:
|
|
112
|
+
ctx.verify_mode = ssl.CERT_OPTIONAL
|
|
113
|
+
logger.info("Server TLS: CA-signed verification enabled (mTLS=%s)", require_client_cert)
|
|
114
|
+
else:
|
|
115
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
116
|
+
|
|
117
|
+
ctx.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!MD5:!DSS")
|
|
118
|
+
return ctx
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _make_client_ssl_context(
|
|
122
|
+
cert_path: Optional[str] = None,
|
|
123
|
+
key_path: Optional[str] = None,
|
|
124
|
+
ca_cert_path: Optional[str] = None,
|
|
125
|
+
) -> ssl.SSLContext:
|
|
126
|
+
"""Create a TLS client context with optional CA verification.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
cert_path : str, optional
|
|
131
|
+
Client certificate for mutual TLS.
|
|
132
|
+
key_path : str, optional
|
|
133
|
+
Client private key for mutual TLS.
|
|
134
|
+
ca_cert_path : str, optional
|
|
135
|
+
CA bundle. When provided the client verifies the server's
|
|
136
|
+
certificate against known CA roots — enabling production-grade
|
|
137
|
+
certificate validation instead of trusting all self-signed certs.
|
|
138
|
+
"""
|
|
139
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
140
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
141
|
+
|
|
142
|
+
if ca_cert_path and os.path.isfile(ca_cert_path):
|
|
143
|
+
ctx.load_verify_locations(ca_cert_path)
|
|
144
|
+
ctx.check_hostname = False # P2P nodes don't use DNS hostnames
|
|
145
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
146
|
+
logger.info("Client TLS: CA-signed server verification enabled")
|
|
147
|
+
else:
|
|
148
|
+
ctx.check_hostname = False
|
|
149
|
+
ctx.verify_mode = ssl.CERT_NONE # fallback: accept self-signed
|
|
150
|
+
|
|
151
|
+
if cert_path and key_path:
|
|
152
|
+
ctx.load_cert_chain(cert_path, key_path)
|
|
153
|
+
|
|
154
|
+
ctx.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!MD5:!DSS")
|
|
155
|
+
return ctx
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── Message types ──────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
class MessageType:
|
|
161
|
+
"""Protocol message types."""
|
|
162
|
+
# Discovery
|
|
163
|
+
PING = "ping"
|
|
164
|
+
PONG = "pong"
|
|
165
|
+
FIND_PEERS = "find_peers"
|
|
166
|
+
PEERS = "peers"
|
|
167
|
+
# Chain sync
|
|
168
|
+
GET_BLOCKS = "get_blocks"
|
|
169
|
+
BLOCKS = "blocks"
|
|
170
|
+
GET_HEADERS = "get_headers"
|
|
171
|
+
HEADERS = "headers"
|
|
172
|
+
NEW_BLOCK = "new_block"
|
|
173
|
+
# Transactions
|
|
174
|
+
NEW_TX = "new_tx"
|
|
175
|
+
GET_TX = "get_tx"
|
|
176
|
+
TX = "tx"
|
|
177
|
+
# Consensus
|
|
178
|
+
VOTE = "vote"
|
|
179
|
+
PROPOSE = "propose"
|
|
180
|
+
# General
|
|
181
|
+
HANDSHAKE = "handshake"
|
|
182
|
+
DISCONNECT = "disconnect"
|
|
183
|
+
ERROR = "error"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class Message:
|
|
188
|
+
"""Network protocol message."""
|
|
189
|
+
type: str
|
|
190
|
+
payload: Dict[str, Any] = field(default_factory=dict)
|
|
191
|
+
sender: str = ""
|
|
192
|
+
nonce: str = ""
|
|
193
|
+
timestamp: float = 0.0
|
|
194
|
+
|
|
195
|
+
def __post_init__(self):
|
|
196
|
+
self.timestamp = self.timestamp or time.time()
|
|
197
|
+
self.nonce = self.nonce or hashlib.sha256(
|
|
198
|
+
f"{self.type}{self.timestamp}{random.random()}".encode()
|
|
199
|
+
).hexdigest()[:16]
|
|
200
|
+
|
|
201
|
+
def encode(self) -> bytes:
|
|
202
|
+
"""Serialize to JSON bytes."""
|
|
203
|
+
return json.dumps(asdict(self)).encode("utf-8")
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def decode(data: bytes) -> 'Message':
|
|
207
|
+
"""Deserialize from JSON bytes."""
|
|
208
|
+
d = json.loads(data.decode("utf-8"))
|
|
209
|
+
return Message(**d)
|
|
210
|
+
|
|
211
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
212
|
+
return asdict(self)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class PeerInfo:
|
|
217
|
+
"""Information about a network peer."""
|
|
218
|
+
peer_id: str = ""
|
|
219
|
+
host: str = ""
|
|
220
|
+
port: int = 0
|
|
221
|
+
chain_id: str = ""
|
|
222
|
+
height: int = 0
|
|
223
|
+
version: str = "1.0.0"
|
|
224
|
+
last_seen: float = 0.0
|
|
225
|
+
latency_ms: float = 0.0
|
|
226
|
+
reputation: int = 100 # 0-100 score
|
|
227
|
+
connected: bool = False
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def address(self) -> str:
|
|
231
|
+
return f"{self.host}:{self.port}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class PeerReputationManager:
|
|
235
|
+
"""Track and enforce peer reputation to resist Sybil and DoS attacks.
|
|
236
|
+
|
|
237
|
+
Every peer starts at score 100 (maximum). Good behaviour (valid blocks,
|
|
238
|
+
valid transactions) earns points; bad behaviour (invalid messages, spam,
|
|
239
|
+
protocol violations) costs points. Peers whose score drops to 0 are
|
|
240
|
+
banned for ``ban_duration`` seconds.
|
|
241
|
+
|
|
242
|
+
The manager also enforces per-peer message rate limiting.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
# ── Reputation deltas ──────────────────────────────────────────
|
|
246
|
+
VALID_BLOCK = 5
|
|
247
|
+
VALID_TX = 1
|
|
248
|
+
INVALID_BLOCK = -20
|
|
249
|
+
INVALID_TX = -10
|
|
250
|
+
PROTOCOL_VIOLATION = -25
|
|
251
|
+
TIMEOUT = -5
|
|
252
|
+
SPAM = -15
|
|
253
|
+
SUCCESSFUL_SYNC = 10
|
|
254
|
+
|
|
255
|
+
def __init__(self, ban_duration: float = 3600.0,
|
|
256
|
+
rate_limit: int = 100,
|
|
257
|
+
rate_window: float = 60.0):
|
|
258
|
+
self.ban_duration = ban_duration # seconds
|
|
259
|
+
self.rate_limit = rate_limit # msgs per window
|
|
260
|
+
self.rate_window = rate_window # seconds
|
|
261
|
+
self._bans: Dict[str, float] = {} # peer_id -> unban timestamp
|
|
262
|
+
self._msg_counts: Dict[str, List[float]] = {} # peer_id -> [timestamps]
|
|
263
|
+
|
|
264
|
+
def update(self, peer: PeerInfo, delta: int, reason: str = "") -> int:
|
|
265
|
+
"""Adjust a peer's reputation score.
|
|
266
|
+
|
|
267
|
+
Returns the new score. If it drops to 0, the peer is banned.
|
|
268
|
+
"""
|
|
269
|
+
old = peer.reputation
|
|
270
|
+
peer.reputation = max(0, min(100, peer.reputation + delta))
|
|
271
|
+
if reason:
|
|
272
|
+
logger.debug("Reputation %s: %d -> %d (%s)", peer.peer_id[:8], old, peer.reputation, reason)
|
|
273
|
+
if peer.reputation == 0:
|
|
274
|
+
self.ban(peer.peer_id)
|
|
275
|
+
return peer.reputation
|
|
276
|
+
|
|
277
|
+
def ban(self, peer_id: str):
|
|
278
|
+
"""Ban a peer for ``ban_duration`` seconds."""
|
|
279
|
+
self._bans[peer_id] = time.time() + self.ban_duration
|
|
280
|
+
logger.warning("Peer %s BANNED for %ds", peer_id[:8], int(self.ban_duration))
|
|
281
|
+
|
|
282
|
+
def unban(self, peer_id: str):
|
|
283
|
+
"""Manually unban a peer."""
|
|
284
|
+
self._bans.pop(peer_id, None)
|
|
285
|
+
|
|
286
|
+
def is_banned(self, peer_id: str) -> bool:
|
|
287
|
+
"""Check if a peer is currently banned."""
|
|
288
|
+
if peer_id not in self._bans:
|
|
289
|
+
return False
|
|
290
|
+
if time.time() >= self._bans[peer_id]:
|
|
291
|
+
del self._bans[peer_id]
|
|
292
|
+
return False
|
|
293
|
+
return True
|
|
294
|
+
|
|
295
|
+
def check_rate_limit(self, peer_id: str) -> bool:
|
|
296
|
+
"""Check if a peer has exceeded the message rate limit.
|
|
297
|
+
|
|
298
|
+
Returns True if the message should be allowed, False if rate-limited.
|
|
299
|
+
"""
|
|
300
|
+
now = time.time()
|
|
301
|
+
timestamps = self._msg_counts.setdefault(peer_id, [])
|
|
302
|
+
# Prune old entries
|
|
303
|
+
cutoff = now - self.rate_window
|
|
304
|
+
self._msg_counts[peer_id] = [t for t in timestamps if t > cutoff]
|
|
305
|
+
timestamps = self._msg_counts[peer_id]
|
|
306
|
+
|
|
307
|
+
if len(timestamps) >= self.rate_limit:
|
|
308
|
+
return False # Rate limited
|
|
309
|
+
timestamps.append(now)
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
def get_banned_peers(self) -> List[str]:
|
|
313
|
+
"""Return list of currently banned peer IDs."""
|
|
314
|
+
now = time.time()
|
|
315
|
+
# Prune expired bans
|
|
316
|
+
self._bans = {pid: ts for pid, ts in self._bans.items() if ts > now}
|
|
317
|
+
return list(self._bans.keys())
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class PeerConnection:
|
|
321
|
+
"""Manages a single peer connection with send/receive capabilities."""
|
|
322
|
+
|
|
323
|
+
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
|
|
324
|
+
peer_info: PeerInfo, is_inbound: bool = False):
|
|
325
|
+
self.reader = reader
|
|
326
|
+
self.writer = writer
|
|
327
|
+
self.peer_info = peer_info
|
|
328
|
+
self.is_inbound = is_inbound
|
|
329
|
+
self._closed = False
|
|
330
|
+
self._recv_task: Optional[asyncio.Task] = None
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def is_connected(self) -> bool:
|
|
334
|
+
return not self._closed and self.writer is not None
|
|
335
|
+
|
|
336
|
+
async def send(self, msg: Message) -> bool:
|
|
337
|
+
"""Send a message to this peer."""
|
|
338
|
+
if self._closed:
|
|
339
|
+
return False
|
|
340
|
+
try:
|
|
341
|
+
data = msg.encode()
|
|
342
|
+
length = len(data)
|
|
343
|
+
self.writer.write(length.to_bytes(4, "big") + data)
|
|
344
|
+
await self.writer.drain()
|
|
345
|
+
return True
|
|
346
|
+
except (ConnectionError, OSError, asyncio.CancelledError):
|
|
347
|
+
self._closed = True
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
async def receive(self) -> Optional[Message]:
|
|
351
|
+
"""Receive a single message from this peer."""
|
|
352
|
+
if self._closed:
|
|
353
|
+
return None
|
|
354
|
+
try:
|
|
355
|
+
length_bytes = await self.reader.readexactly(4)
|
|
356
|
+
length = int.from_bytes(length_bytes, "big")
|
|
357
|
+
if length > 10 * 1024 * 1024: # 10 MB max message size
|
|
358
|
+
logger.warning("Message too large from %s: %d bytes", self.peer_info.address, length)
|
|
359
|
+
self._closed = True
|
|
360
|
+
return None
|
|
361
|
+
data = await self.reader.readexactly(length)
|
|
362
|
+
return Message.decode(data)
|
|
363
|
+
except (ConnectionError, asyncio.IncompleteReadError, asyncio.CancelledError):
|
|
364
|
+
self._closed = True
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
async def close(self):
|
|
368
|
+
"""Close the connection."""
|
|
369
|
+
self._closed = True
|
|
370
|
+
if self.writer:
|
|
371
|
+
try:
|
|
372
|
+
self.writer.close()
|
|
373
|
+
await self.writer.wait_closed()
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class P2PNetwork:
|
|
379
|
+
"""Peer-to-peer network manager for the Zexus blockchain.
|
|
380
|
+
|
|
381
|
+
Handles:
|
|
382
|
+
- Listening for inbound connections
|
|
383
|
+
- Connecting to peers
|
|
384
|
+
- Peer discovery and management
|
|
385
|
+
- Message routing and broadcasting
|
|
386
|
+
- Seen-message deduplication
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
def __init__(self, host: str = "0.0.0.0", port: int = 30303,
|
|
390
|
+
chain_id: str = "zexus-mainnet", node_id: str = "",
|
|
391
|
+
max_peers: int = 25, min_peers: int = 3,
|
|
392
|
+
tls_cert: Optional[str] = None, tls_key: Optional[str] = None,
|
|
393
|
+
tls_ca: Optional[str] = None,
|
|
394
|
+
tls_enabled: bool = True,
|
|
395
|
+
tls_mutual: bool = False,
|
|
396
|
+
tls_pinned_certs: Optional[List[str]] = None,
|
|
397
|
+
data_dir: Optional[str] = None):
|
|
398
|
+
self.host = host
|
|
399
|
+
self.port = port
|
|
400
|
+
self.chain_id = chain_id
|
|
401
|
+
self.node_id = node_id or hashlib.sha256(
|
|
402
|
+
f"{host}:{port}:{time.time()}:{random.random()}".encode()
|
|
403
|
+
).hexdigest()[:40]
|
|
404
|
+
self.max_peers = max_peers
|
|
405
|
+
self.min_peers = min_peers
|
|
406
|
+
|
|
407
|
+
# ── TLS configuration ────────────────────────────────────────
|
|
408
|
+
self.tls_enabled = tls_enabled
|
|
409
|
+
self.tls_mutual = tls_mutual
|
|
410
|
+
self._server_ssl: Optional[ssl.SSLContext] = None
|
|
411
|
+
self._client_ssl: Optional[ssl.SSLContext] = None
|
|
412
|
+
|
|
413
|
+
# Certificate pinning: set of SHA-256 fingerprints of trusted
|
|
414
|
+
# peer certificates. If non-empty, only peers whose TLS cert
|
|
415
|
+
# fingerprint is in this set are accepted.
|
|
416
|
+
self._pinned_certs: Set[str] = set(tls_pinned_certs or [])
|
|
417
|
+
|
|
418
|
+
if tls_enabled:
|
|
419
|
+
# Auto-generate certs if none provided
|
|
420
|
+
_data = data_dir or os.path.join(os.path.expanduser("~"), ".zexus", "tls")
|
|
421
|
+
self._cert_path = tls_cert or os.path.join(_data, "node.crt")
|
|
422
|
+
self._key_path = tls_key or os.path.join(_data, "node.key")
|
|
423
|
+
self._ca_path = tls_ca # None = self-signed mode
|
|
424
|
+
|
|
425
|
+
if not (os.path.exists(self._cert_path) and os.path.exists(self._key_path)):
|
|
426
|
+
logger.info("Generating TLS certificate for node identity...")
|
|
427
|
+
_generate_self_signed_cert(self._cert_path, self._key_path)
|
|
428
|
+
|
|
429
|
+
self._server_ssl = _make_server_ssl_context(
|
|
430
|
+
self._cert_path, self._key_path,
|
|
431
|
+
ca_cert_path=self._ca_path,
|
|
432
|
+
require_client_cert=self.tls_mutual,
|
|
433
|
+
)
|
|
434
|
+
self._client_ssl = _make_client_ssl_context(
|
|
435
|
+
self._cert_path, self._key_path,
|
|
436
|
+
ca_cert_path=self._ca_path,
|
|
437
|
+
)
|
|
438
|
+
mode = "mTLS" if tls_mutual else ("CA-verified" if tls_ca else "self-signed")
|
|
439
|
+
logger.info("TLS enabled (%s) — all P2P traffic is encrypted", mode)
|
|
440
|
+
|
|
441
|
+
# Connection state
|
|
442
|
+
self.peers: Dict[str, PeerConnection] = {} # peer_id -> connection
|
|
443
|
+
self.known_peers: Dict[str, PeerInfo] = {} # peer_id -> info (includes disconnected)
|
|
444
|
+
self.bootstrap_nodes: List[Tuple[str, int]] = []
|
|
445
|
+
|
|
446
|
+
# Message handling
|
|
447
|
+
self._handlers: Dict[str, List[Callable]] = {}
|
|
448
|
+
self._seen_messages: Set[str] = set()
|
|
449
|
+
self._seen_max = 10_000
|
|
450
|
+
|
|
451
|
+
# Sybil / DoS resistance
|
|
452
|
+
self.reputation = PeerReputationManager()
|
|
453
|
+
|
|
454
|
+
# Server state
|
|
455
|
+
self._server: Optional[asyncio.AbstractServer] = None
|
|
456
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
457
|
+
self._running = False
|
|
458
|
+
self._recv_tasks: Dict[str, asyncio.Task] = {}
|
|
459
|
+
|
|
460
|
+
# ── Event handler registration ─────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
def on(self, msg_type: str, handler: Callable):
|
|
463
|
+
"""Register a handler for a message type."""
|
|
464
|
+
self._handlers.setdefault(msg_type, []).append(handler)
|
|
465
|
+
|
|
466
|
+
def off(self, msg_type: str, handler: Optional[Callable] = None):
|
|
467
|
+
"""Remove handler(s) for a message type."""
|
|
468
|
+
if handler is None:
|
|
469
|
+
self._handlers.pop(msg_type, None)
|
|
470
|
+
elif msg_type in self._handlers:
|
|
471
|
+
self._handlers[msg_type] = [h for h in self._handlers[msg_type] if h != handler]
|
|
472
|
+
|
|
473
|
+
async def _dispatch(self, msg: Message, conn: PeerConnection):
|
|
474
|
+
"""Dispatch a received message to registered handlers.
|
|
475
|
+
|
|
476
|
+
Enforces rate-limiting and reputation checks before dispatch.
|
|
477
|
+
"""
|
|
478
|
+
peer_id = conn.peer_info.peer_id
|
|
479
|
+
|
|
480
|
+
# Block banned peers
|
|
481
|
+
if self.reputation.is_banned(peer_id):
|
|
482
|
+
logger.debug("Dropping message from banned peer %s", peer_id[:8])
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
# Rate-limit check
|
|
486
|
+
if not self.reputation.check_rate_limit(peer_id):
|
|
487
|
+
self.reputation.update(conn.peer_info, PeerReputationManager.SPAM,
|
|
488
|
+
reason="rate limit exceeded")
|
|
489
|
+
logger.warning("Rate-limited peer %s", peer_id[:8])
|
|
490
|
+
if self.reputation.is_banned(peer_id):
|
|
491
|
+
await self.disconnect_peer(peer_id)
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
handlers = self._handlers.get(msg.type, [])
|
|
495
|
+
for handler in handlers:
|
|
496
|
+
try:
|
|
497
|
+
result = handler(msg, conn)
|
|
498
|
+
if asyncio.iscoroutine(result):
|
|
499
|
+
await result
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.error("Handler error for %s: %s", msg.type, e)
|
|
502
|
+
|
|
503
|
+
# ── Server lifecycle ───────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
async def start(self):
|
|
506
|
+
"""Start listening for inbound connections."""
|
|
507
|
+
if self._running:
|
|
508
|
+
return
|
|
509
|
+
self._loop = asyncio.get_event_loop()
|
|
510
|
+
self._server = await asyncio.start_server(
|
|
511
|
+
self._handle_inbound, self.host, self.port,
|
|
512
|
+
ssl=self._server_ssl, # None when TLS disabled → plain TCP
|
|
513
|
+
)
|
|
514
|
+
self._running = True
|
|
515
|
+
tls_status = "TLS" if self.tls_enabled else "plaintext"
|
|
516
|
+
logger.info("P2P listening on %s:%d (%s, node_id=%s)",
|
|
517
|
+
self.host, self.port, tls_status, self.node_id[:8])
|
|
518
|
+
|
|
519
|
+
# Register built-in handlers
|
|
520
|
+
self.on(MessageType.PING, self._handle_ping)
|
|
521
|
+
self.on(MessageType.FIND_PEERS, self._handle_find_peers)
|
|
522
|
+
self.on(MessageType.HANDSHAKE, self._handle_handshake_msg)
|
|
523
|
+
|
|
524
|
+
# Bootstrap connections
|
|
525
|
+
asyncio.ensure_future(self._bootstrap())
|
|
526
|
+
|
|
527
|
+
async def stop(self):
|
|
528
|
+
"""Stop the P2P network."""
|
|
529
|
+
self._running = False
|
|
530
|
+
# Close all peer connections
|
|
531
|
+
for peer_id, conn in list(self.peers.items()):
|
|
532
|
+
await conn.send(Message(type=MessageType.DISCONNECT, sender=self.node_id))
|
|
533
|
+
await conn.close()
|
|
534
|
+
self.peers.clear()
|
|
535
|
+
|
|
536
|
+
# Cancel receive tasks
|
|
537
|
+
for task in self._recv_tasks.values():
|
|
538
|
+
task.cancel()
|
|
539
|
+
self._recv_tasks.clear()
|
|
540
|
+
|
|
541
|
+
# Stop server
|
|
542
|
+
if self._server:
|
|
543
|
+
self._server.close()
|
|
544
|
+
await self._server.wait_closed()
|
|
545
|
+
self._server = None
|
|
546
|
+
logger.info("P2P network stopped")
|
|
547
|
+
|
|
548
|
+
@property
|
|
549
|
+
def peer_count(self) -> int:
|
|
550
|
+
return len(self.peers)
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def is_running(self) -> bool:
|
|
554
|
+
return self._running
|
|
555
|
+
|
|
556
|
+
# ── Certificate pinning ──────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
def _check_cert_pin(self, writer: asyncio.StreamWriter) -> bool:
|
|
559
|
+
"""Verify the peer's TLS certificate fingerprint against the
|
|
560
|
+
pinned set. Returns *True* if pinning is disabled or if the
|
|
561
|
+
cert is in the pinned set; *False* to reject the connection."""
|
|
562
|
+
if not self._pinned_certs:
|
|
563
|
+
return True # pinning not configured — accept all
|
|
564
|
+
ssl_obj = writer.get_extra_info("ssl_object")
|
|
565
|
+
if ssl_obj is None:
|
|
566
|
+
return True # non-TLS — nothing to pin
|
|
567
|
+
try:
|
|
568
|
+
der = ssl_obj.getpeercert(binary_form=True)
|
|
569
|
+
if der is None:
|
|
570
|
+
return False # no cert presented
|
|
571
|
+
fp = hashlib.sha256(der).hexdigest()
|
|
572
|
+
if fp in self._pinned_certs:
|
|
573
|
+
return True
|
|
574
|
+
logger.warning("Certificate pin mismatch: %s", fp)
|
|
575
|
+
return False
|
|
576
|
+
except Exception:
|
|
577
|
+
return False
|
|
578
|
+
|
|
579
|
+
# ── Connections ────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
async def _handle_inbound(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
582
|
+
"""Handle a new inbound TCP connection."""
|
|
583
|
+
addr = writer.get_extra_info("peername")
|
|
584
|
+
logger.debug("Inbound connection from %s", addr)
|
|
585
|
+
|
|
586
|
+
if len(self.peers) >= self.max_peers:
|
|
587
|
+
writer.close()
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
# Certificate pinning check
|
|
591
|
+
if not self._check_cert_pin(writer):
|
|
592
|
+
logger.warning("Rejected inbound connection (cert pin mismatch) from %s", addr)
|
|
593
|
+
writer.close()
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
# Create temporary peer info, will be updated on handshake
|
|
597
|
+
temp_info = PeerInfo(host=addr[0] if addr else "unknown", port=addr[1] if addr else 0)
|
|
598
|
+
conn = PeerConnection(reader, writer, temp_info, is_inbound=True)
|
|
599
|
+
|
|
600
|
+
# Send our handshake
|
|
601
|
+
await conn.send(Message(
|
|
602
|
+
type=MessageType.HANDSHAKE,
|
|
603
|
+
sender=self.node_id,
|
|
604
|
+
payload={
|
|
605
|
+
"chain_id": self.chain_id,
|
|
606
|
+
"port": self.port,
|
|
607
|
+
"version": "1.0.0",
|
|
608
|
+
}
|
|
609
|
+
))
|
|
610
|
+
|
|
611
|
+
# Wait for handshake response with timeout
|
|
612
|
+
try:
|
|
613
|
+
msg = await asyncio.wait_for(conn.receive(), timeout=10.0)
|
|
614
|
+
except asyncio.TimeoutError:
|
|
615
|
+
await conn.close()
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
if not msg or msg.type != MessageType.HANDSHAKE:
|
|
619
|
+
await conn.close()
|
|
620
|
+
return
|
|
621
|
+
|
|
622
|
+
peer_id = msg.sender
|
|
623
|
+
if peer_id == self.node_id or peer_id in self.peers:
|
|
624
|
+
await conn.close()
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
# Reject banned peers
|
|
628
|
+
if self.reputation.is_banned(peer_id):
|
|
629
|
+
logger.info("Rejected banned peer %s", peer_id[:8])
|
|
630
|
+
await conn.close()
|
|
631
|
+
return
|
|
632
|
+
await conn.close()
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
if not msg or msg.type != MessageType.HANDSHAKE:
|
|
636
|
+
await conn.close()
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
peer_id = msg.sender
|
|
640
|
+
if peer_id == self.node_id or peer_id in self.peers:
|
|
641
|
+
await conn.close()
|
|
642
|
+
return
|
|
643
|
+
|
|
644
|
+
# Accept the peer
|
|
645
|
+
conn.peer_info.peer_id = peer_id
|
|
646
|
+
conn.peer_info.chain_id = msg.payload.get("chain_id", "")
|
|
647
|
+
conn.peer_info.connected = True
|
|
648
|
+
conn.peer_info.last_seen = time.time()
|
|
649
|
+
self.peers[peer_id] = conn
|
|
650
|
+
self.known_peers[peer_id] = conn.peer_info
|
|
651
|
+
logger.info("Peer connected (inbound): %s", peer_id[:8])
|
|
652
|
+
|
|
653
|
+
# Start receive loop
|
|
654
|
+
self._recv_tasks[peer_id] = asyncio.ensure_future(self._peer_recv_loop(peer_id))
|
|
655
|
+
|
|
656
|
+
async def connect_to(self, host: str, port: int) -> Optional[PeerConnection]:
|
|
657
|
+
"""Connect to a remote peer."""
|
|
658
|
+
if len(self.peers) >= self.max_peers:
|
|
659
|
+
return None
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
reader, writer = await asyncio.wait_for(
|
|
663
|
+
asyncio.open_connection(host, port, ssl=self._client_ssl),
|
|
664
|
+
timeout=10.0,
|
|
665
|
+
)
|
|
666
|
+
except (ConnectionError, asyncio.TimeoutError, OSError) as e:
|
|
667
|
+
logger.debug("Failed to connect to %s:%d: %s", host, port, e)
|
|
668
|
+
return None
|
|
669
|
+
|
|
670
|
+
# Certificate pinning check
|
|
671
|
+
if not self._check_cert_pin(writer):
|
|
672
|
+
logger.warning("Rejected outbound connection (cert pin mismatch) to %s:%d", host, port)
|
|
673
|
+
writer.close()
|
|
674
|
+
return None
|
|
675
|
+
|
|
676
|
+
temp_info = PeerInfo(host=host, port=port)
|
|
677
|
+
conn = PeerConnection(reader, writer, temp_info, is_inbound=False)
|
|
678
|
+
|
|
679
|
+
# Send handshake
|
|
680
|
+
await conn.send(Message(
|
|
681
|
+
type=MessageType.HANDSHAKE,
|
|
682
|
+
sender=self.node_id,
|
|
683
|
+
payload={
|
|
684
|
+
"chain_id": self.chain_id,
|
|
685
|
+
"port": self.port,
|
|
686
|
+
"version": "1.0.0",
|
|
687
|
+
}
|
|
688
|
+
))
|
|
689
|
+
|
|
690
|
+
# Wait for remote handshake
|
|
691
|
+
try:
|
|
692
|
+
msg = await asyncio.wait_for(conn.receive(), timeout=10.0)
|
|
693
|
+
except asyncio.TimeoutError:
|
|
694
|
+
await conn.close()
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
if not msg or msg.type != MessageType.HANDSHAKE:
|
|
698
|
+
await conn.close()
|
|
699
|
+
return None
|
|
700
|
+
|
|
701
|
+
peer_id = msg.sender
|
|
702
|
+
if peer_id == self.node_id or peer_id in self.peers:
|
|
703
|
+
await conn.close()
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
# Reject banned peers
|
|
707
|
+
if self.reputation.is_banned(peer_id):
|
|
708
|
+
logger.info("Refused connection to banned peer %s", peer_id[:8])
|
|
709
|
+
await conn.close()
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
conn.peer_info.peer_id = peer_id
|
|
713
|
+
conn.peer_info.chain_id = msg.payload.get("chain_id", "")
|
|
714
|
+
conn.peer_info.connected = True
|
|
715
|
+
conn.peer_info.last_seen = time.time()
|
|
716
|
+
self.peers[peer_id] = conn
|
|
717
|
+
self.known_peers[peer_id] = conn.peer_info
|
|
718
|
+
logger.info("Peer connected (outbound): %s @ %s:%d", peer_id[:8], host, port)
|
|
719
|
+
|
|
720
|
+
self._recv_tasks[peer_id] = asyncio.ensure_future(self._peer_recv_loop(peer_id))
|
|
721
|
+
return conn
|
|
722
|
+
|
|
723
|
+
async def disconnect_peer(self, peer_id: str):
|
|
724
|
+
"""Disconnect a specific peer."""
|
|
725
|
+
conn = self.peers.pop(peer_id, None)
|
|
726
|
+
if conn:
|
|
727
|
+
await conn.send(Message(type=MessageType.DISCONNECT, sender=self.node_id))
|
|
728
|
+
await conn.close()
|
|
729
|
+
if peer_id in self.known_peers:
|
|
730
|
+
self.known_peers[peer_id].connected = False
|
|
731
|
+
task = self._recv_tasks.pop(peer_id, None)
|
|
732
|
+
if task:
|
|
733
|
+
task.cancel()
|
|
734
|
+
|
|
735
|
+
# ── Message sending ────────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
async def send(self, peer_id: str, msg: Message) -> bool:
|
|
738
|
+
"""Send a message to a specific peer."""
|
|
739
|
+
conn = self.peers.get(peer_id)
|
|
740
|
+
if not conn:
|
|
741
|
+
return False
|
|
742
|
+
msg.sender = self.node_id
|
|
743
|
+
return await conn.send(msg)
|
|
744
|
+
|
|
745
|
+
async def broadcast(self, msg: Message, exclude: Optional[Set[str]] = None):
|
|
746
|
+
"""Broadcast a message to all connected peers."""
|
|
747
|
+
msg.sender = self.node_id
|
|
748
|
+
exclude = exclude or set()
|
|
749
|
+
for peer_id, conn in list(self.peers.items()):
|
|
750
|
+
if peer_id not in exclude:
|
|
751
|
+
success = await conn.send(msg)
|
|
752
|
+
if not success:
|
|
753
|
+
await self.disconnect_peer(peer_id)
|
|
754
|
+
|
|
755
|
+
async def gossip(self, msg: Message, fanout: int = 0, exclude: Optional[Set[str]] = None):
|
|
756
|
+
"""Gossip a message to a random subset of peers.
|
|
757
|
+
|
|
758
|
+
If fanout is 0, broadcasts to all peers. Otherwise, selects
|
|
759
|
+
``fanout`` random peers from the connected set.
|
|
760
|
+
"""
|
|
761
|
+
# Deduplication
|
|
762
|
+
if msg.nonce in self._seen_messages:
|
|
763
|
+
return
|
|
764
|
+
self._seen_messages.add(msg.nonce)
|
|
765
|
+
if len(self._seen_messages) > self._seen_max:
|
|
766
|
+
# Trim oldest (convert to list, drop first half)
|
|
767
|
+
self._seen_messages = set(list(self._seen_messages)[self._seen_max // 2:])
|
|
768
|
+
|
|
769
|
+
msg.sender = self.node_id
|
|
770
|
+
exclude = exclude or set()
|
|
771
|
+
candidates = [pid for pid in self.peers if pid not in exclude]
|
|
772
|
+
|
|
773
|
+
if fanout > 0 and len(candidates) > fanout:
|
|
774
|
+
candidates = random.sample(candidates, fanout)
|
|
775
|
+
|
|
776
|
+
for peer_id in candidates:
|
|
777
|
+
conn = self.peers.get(peer_id)
|
|
778
|
+
if conn:
|
|
779
|
+
await conn.send(msg)
|
|
780
|
+
|
|
781
|
+
# ── Receive loop ───────────────────────────────────────────────────
|
|
782
|
+
|
|
783
|
+
async def _peer_recv_loop(self, peer_id: str):
|
|
784
|
+
"""Continuous receive loop for a peer."""
|
|
785
|
+
conn = self.peers.get(peer_id)
|
|
786
|
+
if not conn:
|
|
787
|
+
return
|
|
788
|
+
while self._running and conn.is_connected:
|
|
789
|
+
msg = await conn.receive()
|
|
790
|
+
if msg is None:
|
|
791
|
+
break
|
|
792
|
+
conn.peer_info.last_seen = time.time()
|
|
793
|
+
|
|
794
|
+
if msg.type == MessageType.DISCONNECT:
|
|
795
|
+
break
|
|
796
|
+
|
|
797
|
+
await self._dispatch(msg, conn)
|
|
798
|
+
|
|
799
|
+
# Cleanup
|
|
800
|
+
self.peers.pop(peer_id, None)
|
|
801
|
+
if peer_id in self.known_peers:
|
|
802
|
+
self.known_peers[peer_id].connected = False
|
|
803
|
+
logger.debug("Peer disconnected: %s", peer_id[:8])
|
|
804
|
+
|
|
805
|
+
# ── Built-in handlers ──────────────────────────────────────────────
|
|
806
|
+
|
|
807
|
+
async def _handle_ping(self, msg: Message, conn: PeerConnection):
|
|
808
|
+
await conn.send(Message(
|
|
809
|
+
type=MessageType.PONG,
|
|
810
|
+
sender=self.node_id,
|
|
811
|
+
payload={"echo": msg.nonce}
|
|
812
|
+
))
|
|
813
|
+
|
|
814
|
+
async def _handle_find_peers(self, msg: Message, conn: PeerConnection):
|
|
815
|
+
"""Return known peers to the requester."""
|
|
816
|
+
peers_list = []
|
|
817
|
+
for pid, info in self.known_peers.items():
|
|
818
|
+
if pid != msg.sender and info.connected:
|
|
819
|
+
peers_list.append({
|
|
820
|
+
"peer_id": info.peer_id,
|
|
821
|
+
"host": info.host,
|
|
822
|
+
"port": info.port,
|
|
823
|
+
})
|
|
824
|
+
await conn.send(Message(
|
|
825
|
+
type=MessageType.PEERS,
|
|
826
|
+
sender=self.node_id,
|
|
827
|
+
payload={"peers": peers_list[:20]}
|
|
828
|
+
))
|
|
829
|
+
|
|
830
|
+
async def _handle_handshake_msg(self, msg: Message, conn: PeerConnection):
|
|
831
|
+
"""Handle late handshake messages (re-announcements)."""
|
|
832
|
+
pass # Already handled during connection setup
|
|
833
|
+
|
|
834
|
+
# ── Peer discovery ─────────────────────────────────────────────────
|
|
835
|
+
|
|
836
|
+
def add_bootstrap_node(self, host: str, port: int):
|
|
837
|
+
"""Add a bootstrap node for initial peer discovery."""
|
|
838
|
+
self.bootstrap_nodes.append((host, port))
|
|
839
|
+
|
|
840
|
+
async def _bootstrap(self):
|
|
841
|
+
"""Connect to bootstrap nodes and discover more peers."""
|
|
842
|
+
for host, port in self.bootstrap_nodes:
|
|
843
|
+
if len(self.peers) >= self.max_peers:
|
|
844
|
+
break
|
|
845
|
+
await self.connect_to(host, port)
|
|
846
|
+
|
|
847
|
+
# Discover more peers from connected ones
|
|
848
|
+
if self.peers:
|
|
849
|
+
await self.broadcast(Message(
|
|
850
|
+
type=MessageType.FIND_PEERS,
|
|
851
|
+
sender=self.node_id,
|
|
852
|
+
))
|
|
853
|
+
|
|
854
|
+
async def discover_peers(self):
|
|
855
|
+
"""Actively discover new peers by querying existing connections."""
|
|
856
|
+
if not self.peers:
|
|
857
|
+
await self._bootstrap()
|
|
858
|
+
return
|
|
859
|
+
await self.broadcast(Message(
|
|
860
|
+
type=MessageType.FIND_PEERS,
|
|
861
|
+
sender=self.node_id,
|
|
862
|
+
))
|
|
863
|
+
|
|
864
|
+
# ── Utility ────────────────────────────────────────────────────────
|
|
865
|
+
|
|
866
|
+
def get_network_info(self) -> Dict[str, Any]:
|
|
867
|
+
"""Get network status information."""
|
|
868
|
+
return {
|
|
869
|
+
"node_id": self.node_id,
|
|
870
|
+
"host": self.host,
|
|
871
|
+
"port": self.port,
|
|
872
|
+
"chain_id": self.chain_id,
|
|
873
|
+
"tls_enabled": self.tls_enabled,
|
|
874
|
+
"connected_peers": len(self.peers),
|
|
875
|
+
"known_peers": len(self.known_peers),
|
|
876
|
+
"running": self._running,
|
|
877
|
+
"peers": [
|
|
878
|
+
{
|
|
879
|
+
"peer_id": pid[:8],
|
|
880
|
+
"address": conn.peer_info.address,
|
|
881
|
+
"inbound": conn.is_inbound,
|
|
882
|
+
"last_seen": conn.peer_info.last_seen,
|
|
883
|
+
}
|
|
884
|
+
for pid, conn in self.peers.items()
|
|
885
|
+
]
|
|
886
|
+
}
|