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.
Files changed (177) hide show
  1. package/README.md +12 -5
  2. package/package.json +1 -1
  3. package/src/__init__.py +7 -0
  4. package/src/zexus/__init__.py +1 -1
  5. package/src/zexus/__pycache__/__init__.cpython-312.pyc +0 -0
  6. package/src/zexus/__pycache__/capability_system.cpython-312.pyc +0 -0
  7. package/src/zexus/__pycache__/debug_sanitizer.cpython-312.pyc +0 -0
  8. package/src/zexus/__pycache__/environment.cpython-312.pyc +0 -0
  9. package/src/zexus/__pycache__/error_reporter.cpython-312.pyc +0 -0
  10. package/src/zexus/__pycache__/input_validation.cpython-312.pyc +0 -0
  11. package/src/zexus/__pycache__/lexer.cpython-312.pyc +0 -0
  12. package/src/zexus/__pycache__/module_cache.cpython-312.pyc +0 -0
  13. package/src/zexus/__pycache__/module_manager.cpython-312.pyc +0 -0
  14. package/src/zexus/__pycache__/object.cpython-312.pyc +0 -0
  15. package/src/zexus/__pycache__/security.cpython-312.pyc +0 -0
  16. package/src/zexus/__pycache__/security_enforcement.cpython-312.pyc +0 -0
  17. package/src/zexus/__pycache__/syntax_validator.cpython-312.pyc +0 -0
  18. package/src/zexus/__pycache__/zexus_ast.cpython-312.pyc +0 -0
  19. package/src/zexus/__pycache__/zexus_token.cpython-312.pyc +0 -0
  20. package/src/zexus/access_control_system/__pycache__/__init__.cpython-312.pyc +0 -0
  21. package/src/zexus/access_control_system/__pycache__/access_control.cpython-312.pyc +0 -0
  22. package/src/zexus/advanced_types.py +17 -2
  23. package/src/zexus/blockchain/__init__.py +411 -0
  24. package/src/zexus/blockchain/accelerator.py +1160 -0
  25. package/src/zexus/blockchain/chain.py +660 -0
  26. package/src/zexus/blockchain/consensus.py +821 -0
  27. package/src/zexus/blockchain/contract_vm.py +1019 -0
  28. package/src/zexus/blockchain/crypto.py +79 -14
  29. package/src/zexus/blockchain/events.py +526 -0
  30. package/src/zexus/blockchain/loadtest.py +721 -0
  31. package/src/zexus/blockchain/monitoring.py +350 -0
  32. package/src/zexus/blockchain/mpt.py +716 -0
  33. package/src/zexus/blockchain/multichain.py +951 -0
  34. package/src/zexus/blockchain/multiprocess_executor.py +338 -0
  35. package/src/zexus/blockchain/network.py +886 -0
  36. package/src/zexus/blockchain/node.py +666 -0
  37. package/src/zexus/blockchain/rpc.py +1203 -0
  38. package/src/zexus/blockchain/rust_bridge.py +421 -0
  39. package/src/zexus/blockchain/storage.py +423 -0
  40. package/src/zexus/blockchain/tokens.py +750 -0
  41. package/src/zexus/blockchain/upgradeable.py +1004 -0
  42. package/src/zexus/blockchain/verification.py +1602 -0
  43. package/src/zexus/blockchain/wallet.py +621 -0
  44. package/src/zexus/capability_system.py +184 -9
  45. package/src/zexus/cli/__pycache__/main.cpython-312.pyc +0 -0
  46. package/src/zexus/cli/main.py +383 -34
  47. package/src/zexus/cli/zpm.py +1 -1
  48. package/src/zexus/compiler/__pycache__/bytecode.cpython-312.pyc +0 -0
  49. package/src/zexus/compiler/__pycache__/lexer.cpython-312.pyc +0 -0
  50. package/src/zexus/compiler/__pycache__/parser.cpython-312.pyc +0 -0
  51. package/src/zexus/compiler/__pycache__/semantic.cpython-312.pyc +0 -0
  52. package/src/zexus/compiler/__pycache__/zexus_ast.cpython-312.pyc +0 -0
  53. package/src/zexus/compiler/bytecode.py +124 -7
  54. package/src/zexus/compiler/compat_runtime.py +6 -2
  55. package/src/zexus/compiler/lexer.py +16 -5
  56. package/src/zexus/compiler/parser.py +108 -7
  57. package/src/zexus/compiler/semantic.py +18 -19
  58. package/src/zexus/compiler/zexus_ast.py +26 -1
  59. package/src/zexus/concurrency_system.py +79 -0
  60. package/src/zexus/config.py +54 -0
  61. package/src/zexus/crypto_bridge.py +244 -8
  62. package/src/zexus/dap/__init__.py +10 -0
  63. package/src/zexus/dap/__main__.py +4 -0
  64. package/src/zexus/dap/dap_server.py +391 -0
  65. package/src/zexus/dap/debug_engine.py +298 -0
  66. package/src/zexus/environment.py +112 -9
  67. package/src/zexus/evaluator/__pycache__/bytecode_compiler.cpython-312.pyc +0 -0
  68. package/src/zexus/evaluator/__pycache__/core.cpython-312.pyc +0 -0
  69. package/src/zexus/evaluator/__pycache__/expressions.cpython-312.pyc +0 -0
  70. package/src/zexus/evaluator/__pycache__/functions.cpython-312.pyc +0 -0
  71. package/src/zexus/evaluator/__pycache__/resource_limiter.cpython-312.pyc +0 -0
  72. package/src/zexus/evaluator/__pycache__/statements.cpython-312.pyc +0 -0
  73. package/src/zexus/evaluator/__pycache__/unified_execution.cpython-312.pyc +0 -0
  74. package/src/zexus/evaluator/__pycache__/utils.cpython-312.pyc +0 -0
  75. package/src/zexus/evaluator/bytecode_compiler.py +457 -37
  76. package/src/zexus/evaluator/core.py +644 -50
  77. package/src/zexus/evaluator/expressions.py +358 -62
  78. package/src/zexus/evaluator/functions.py +458 -20
  79. package/src/zexus/evaluator/resource_limiter.py +4 -4
  80. package/src/zexus/evaluator/statements.py +774 -122
  81. package/src/zexus/evaluator/unified_execution.py +573 -72
  82. package/src/zexus/evaluator/utils.py +14 -2
  83. package/src/zexus/evaluator_original.py +1 -1
  84. package/src/zexus/event_loop.py +186 -0
  85. package/src/zexus/lexer.py +742 -458
  86. package/src/zexus/lsp/__init__.py +1 -1
  87. package/src/zexus/lsp/definition_provider.py +163 -9
  88. package/src/zexus/lsp/server.py +22 -8
  89. package/src/zexus/lsp/symbol_provider.py +182 -9
  90. package/src/zexus/module_cache.py +239 -9
  91. package/src/zexus/module_manager.py +129 -1
  92. package/src/zexus/object.py +76 -6
  93. package/src/zexus/parser/__pycache__/parser.cpython-312.pyc +0 -0
  94. package/src/zexus/parser/__pycache__/strategy_context.cpython-312.pyc +0 -0
  95. package/src/zexus/parser/__pycache__/strategy_structural.cpython-312.pyc +0 -0
  96. package/src/zexus/parser/parser.py +1349 -408
  97. package/src/zexus/parser/strategy_context.py +755 -58
  98. package/src/zexus/parser/strategy_structural.py +121 -21
  99. package/src/zexus/persistence.py +15 -1
  100. package/src/zexus/renderer/__init__.py +61 -0
  101. package/src/zexus/renderer/__pycache__/__init__.cpython-312.pyc +0 -0
  102. package/src/zexus/renderer/__pycache__/backend.cpython-312.pyc +0 -0
  103. package/src/zexus/renderer/__pycache__/canvas.cpython-312.pyc +0 -0
  104. package/src/zexus/renderer/__pycache__/color_system.cpython-312.pyc +0 -0
  105. package/src/zexus/renderer/__pycache__/layout.cpython-312.pyc +0 -0
  106. package/src/zexus/renderer/__pycache__/main_renderer.cpython-312.pyc +0 -0
  107. package/src/zexus/renderer/__pycache__/painter.cpython-312.pyc +0 -0
  108. package/src/zexus/renderer/backend.py +261 -0
  109. package/src/zexus/renderer/canvas.py +78 -0
  110. package/src/zexus/renderer/color_system.py +201 -0
  111. package/src/zexus/renderer/graphics.py +31 -0
  112. package/src/zexus/renderer/layout.py +222 -0
  113. package/src/zexus/renderer/main_renderer.py +66 -0
  114. package/src/zexus/renderer/painter.py +30 -0
  115. package/src/zexus/renderer/tk_backend.py +208 -0
  116. package/src/zexus/renderer/web_backend.py +260 -0
  117. package/src/zexus/runtime/__init__.py +10 -2
  118. package/src/zexus/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  119. package/src/zexus/runtime/__pycache__/async_runtime.cpython-312.pyc +0 -0
  120. package/src/zexus/runtime/__pycache__/load_manager.cpython-312.pyc +0 -0
  121. package/src/zexus/runtime/file_flags.py +137 -0
  122. package/src/zexus/runtime/load_manager.py +368 -0
  123. package/src/zexus/safety/__pycache__/__init__.cpython-312.pyc +0 -0
  124. package/src/zexus/safety/__pycache__/memory_safety.cpython-312.pyc +0 -0
  125. package/src/zexus/security.py +424 -34
  126. package/src/zexus/stdlib/fs.py +23 -18
  127. package/src/zexus/stdlib/http.py +289 -186
  128. package/src/zexus/stdlib/sockets.py +207 -163
  129. package/src/zexus/stdlib/websockets.py +282 -0
  130. package/src/zexus/stdlib_integration.py +369 -2
  131. package/src/zexus/strategy_recovery.py +6 -3
  132. package/src/zexus/type_checker.py +423 -0
  133. package/src/zexus/virtual_filesystem.py +189 -2
  134. package/src/zexus/vm/__init__.py +113 -3
  135. package/src/zexus/vm/__pycache__/async_optimizer.cpython-312.pyc +0 -0
  136. package/src/zexus/vm/__pycache__/bytecode.cpython-312.pyc +0 -0
  137. package/src/zexus/vm/__pycache__/bytecode_converter.cpython-312.pyc +0 -0
  138. package/src/zexus/vm/__pycache__/cache.cpython-312.pyc +0 -0
  139. package/src/zexus/vm/__pycache__/compiler.cpython-312.pyc +0 -0
  140. package/src/zexus/vm/__pycache__/gas_metering.cpython-312.pyc +0 -0
  141. package/src/zexus/vm/__pycache__/jit.cpython-312.pyc +0 -0
  142. package/src/zexus/vm/__pycache__/parallel_vm.cpython-312.pyc +0 -0
  143. package/src/zexus/vm/__pycache__/vm.cpython-312.pyc +0 -0
  144. package/src/zexus/vm/async_optimizer.py +80 -6
  145. package/src/zexus/vm/binary_bytecode.py +659 -0
  146. package/src/zexus/vm/bytecode.py +59 -11
  147. package/src/zexus/vm/bytecode_converter.py +26 -12
  148. package/src/zexus/vm/cabi.c +1985 -0
  149. package/src/zexus/vm/cabi.cpython-312-x86_64-linux-gnu.so +0 -0
  150. package/src/zexus/vm/cabi.h +127 -0
  151. package/src/zexus/vm/cache.py +561 -17
  152. package/src/zexus/vm/compiler.py +818 -51
  153. package/src/zexus/vm/fastops.c +15743 -0
  154. package/src/zexus/vm/fastops.cpython-312-x86_64-linux-gnu.so +0 -0
  155. package/src/zexus/vm/fastops.pyx +288 -0
  156. package/src/zexus/vm/gas_metering.py +50 -9
  157. package/src/zexus/vm/jit.py +364 -20
  158. package/src/zexus/vm/native_jit_backend.py +1816 -0
  159. package/src/zexus/vm/native_runtime.cpp +1388 -0
  160. package/src/zexus/vm/native_runtime.cpython-312-x86_64-linux-gnu.so +0 -0
  161. package/src/zexus/vm/optimizer.py +161 -11
  162. package/src/zexus/vm/parallel_vm.py +140 -45
  163. package/src/zexus/vm/peephole_optimizer.py +82 -4
  164. package/src/zexus/vm/profiler.py +38 -18
  165. package/src/zexus/vm/register_allocator.py +16 -5
  166. package/src/zexus/vm/register_vm.py +8 -5
  167. package/src/zexus/vm/vm.py +3581 -531
  168. package/src/zexus/vm/wasm_compiler.py +658 -0
  169. package/src/zexus/zexus_ast.py +137 -11
  170. package/src/zexus/zexus_token.py +16 -5
  171. package/src/zexus/zpm/installer.py +55 -15
  172. package/src/zexus/zpm/package_manager.py +1 -1
  173. package/src/zexus/zpm/registry.py +257 -28
  174. package/src/zexus.egg-info/PKG-INFO +16 -6
  175. package/src/zexus.egg-info/SOURCES.txt +129 -17
  176. package/src/zexus.egg-info/entry_points.txt +1 -0
  177. package/src/zexus.egg-info/requires.txt +4 -0
@@ -0,0 +1,1203 @@
1
+ """
2
+ Zexus Blockchain — JSON-RPC Server
3
+
4
+ Production-grade JSON-RPC 2.0 server providing external access to a
5
+ running BlockchainNode. Supports both HTTP and WebSocket transports.
6
+
7
+ Namespaces:
8
+ zx_* — Core blockchain methods (query blocks, accounts, state)
9
+ txpool_* — Mempool / transaction pool
10
+ net_* — Peer-to-peer networking
11
+ miner_* — Mining control
12
+ contract_* — Smart-contract deployment and interaction
13
+ admin_* — Node administration
14
+
15
+ The server follows the JSON-RPC 2.0 specification:
16
+ https://www.jsonrpc.org/specification
17
+
18
+ Usage:
19
+ node = BlockchainNode(NodeConfig(rpc_enabled=True, rpc_port=8545))
20
+ await node.start() # RPC server starts automatically
21
+
22
+ Or standalone:
23
+ server = RPCServer(node, host="0.0.0.0", port=8545)
24
+ await server.start()
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import json
31
+ import logging
32
+ import time
33
+ import traceback
34
+ from dataclasses import dataclass, field
35
+ from enum import IntEnum
36
+ from typing import Any, Callable, Coroutine, Dict, List, Optional, Set, Tuple, Union
37
+
38
+ logger = logging.getLogger("zexus.blockchain.rpc")
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # JSON-RPC 2.0 error codes
42
+ # ---------------------------------------------------------------------------
43
+
44
+ class RPCErrorCode(IntEnum):
45
+ """Standard JSON-RPC 2.0 error codes + Zexus-specific extensions."""
46
+ PARSE_ERROR = -32700
47
+ INVALID_REQUEST = -32600
48
+ METHOD_NOT_FOUND = -32601
49
+ INVALID_PARAMS = -32602
50
+ INTERNAL_ERROR = -32603
51
+
52
+ # Zexus-specific (-32000 to -32099 reserved for server errors)
53
+ CHAIN_ERROR = -32000
54
+ TX_REJECTED = -32001
55
+ INSUFFICIENT_FUNDS = -32002
56
+ NONCE_TOO_LOW = -32003
57
+ CONTRACT_ERROR = -32004
58
+ MINING_ERROR = -32005
59
+ NOT_FOUND = -32006
60
+ UNAUTHORIZED = -32007
61
+
62
+
63
+ class RPCError(Exception):
64
+ """An error that maps directly to a JSON-RPC error response."""
65
+
66
+ def __init__(self, code: RPCErrorCode, message: str, data: Any = None):
67
+ super().__init__(message)
68
+ self.code = int(code)
69
+ self.message = message
70
+ self.data = data
71
+
72
+ def to_dict(self) -> Dict[str, Any]:
73
+ d: Dict[str, Any] = {"code": self.code, "message": self.message}
74
+ if self.data is not None:
75
+ d["data"] = self.data
76
+ return d
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Subscription manager (WebSocket push)
81
+ # ---------------------------------------------------------------------------
82
+
83
+ @dataclass
84
+ class Subscription:
85
+ """A single WebSocket subscription."""
86
+ sub_id: str
87
+ event: str # "newHeads", "newPendingTransactions", "logs"
88
+ ws: Any # aiohttp WebSocketResponse
89
+ params: Dict[str, Any] = field(default_factory=dict)
90
+
91
+
92
+ class SubscriptionManager:
93
+ """Manages WebSocket subscriptions for real-time event streaming."""
94
+
95
+ def __init__(self):
96
+ self._subs: Dict[str, Subscription] = {}
97
+ self._counter = 0
98
+
99
+ def add(self, event: str, ws: Any, params: Optional[Dict] = None) -> str:
100
+ self._counter += 1
101
+ sub_id = f"0x{self._counter:016x}"
102
+ self._subs[sub_id] = Subscription(
103
+ sub_id=sub_id, event=event, ws=ws, params=params or {},
104
+ )
105
+ return sub_id
106
+
107
+ def remove(self, sub_id: str) -> bool:
108
+ return self._subs.pop(sub_id, None) is not None
109
+
110
+ def remove_all_for_ws(self, ws: Any) -> int:
111
+ to_remove = [sid for sid, s in self._subs.items() if s.ws is ws]
112
+ for sid in to_remove:
113
+ del self._subs[sid]
114
+ return len(to_remove)
115
+
116
+ async def notify(self, event: str, data: Any):
117
+ """Push a notification to all subscribers of *event*."""
118
+ dead: List[str] = []
119
+ for sid, sub in self._subs.items():
120
+ if sub.event != event:
121
+ continue
122
+ msg = json.dumps({
123
+ "jsonrpc": "2.0",
124
+ "method": "zx_subscription",
125
+ "params": {"subscription": sid, "result": data},
126
+ })
127
+ try:
128
+ await sub.ws.send_str(msg)
129
+ except Exception:
130
+ dead.append(sid)
131
+ for sid in dead:
132
+ self._subs.pop(sid, None)
133
+
134
+ @property
135
+ def count(self) -> int:
136
+ return len(self._subs)
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Rate limiter
141
+ # ---------------------------------------------------------------------------
142
+
143
+ class RateLimiter:
144
+ """Simple token-bucket rate limiter per IP address."""
145
+
146
+ def __init__(self, max_requests: int = 100, window_seconds: float = 1.0):
147
+ self.max_requests = max_requests
148
+ self.window = window_seconds
149
+ self._buckets: Dict[str, List[float]] = {}
150
+
151
+ def allow(self, ip: str) -> bool:
152
+ now = time.monotonic()
153
+ bucket = self._buckets.setdefault(ip, [])
154
+ # Prune old entries
155
+ bucket[:] = [t for t in bucket if now - t < self.window]
156
+ if len(bucket) >= self.max_requests:
157
+ return False
158
+ bucket.append(now)
159
+ return True
160
+
161
+ def reset(self):
162
+ self._buckets.clear()
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # RPC Method registry
167
+ # ---------------------------------------------------------------------------
168
+
169
+ # Type alias for an RPC handler: takes (params) -> result
170
+ RPCHandler = Callable[..., Any]
171
+
172
+
173
+ @dataclass
174
+ class RPCMethodInfo:
175
+ """Metadata about a registered RPC method."""
176
+ name: str
177
+ handler: RPCHandler
178
+ is_async: bool = False
179
+ description: str = ""
180
+
181
+
182
+ class RPCMethodRegistry:
183
+ """Registry that maps JSON-RPC method names to Python handlers."""
184
+
185
+ def __init__(self):
186
+ self._methods: Dict[str, RPCMethodInfo] = {}
187
+
188
+ def register(self, name: str, handler: RPCHandler, description: str = ""):
189
+ is_async = asyncio.iscoroutinefunction(handler)
190
+ self._methods[name] = RPCMethodInfo(
191
+ name=name, handler=handler, is_async=is_async,
192
+ description=description,
193
+ )
194
+
195
+ def get(self, name: str) -> Optional[RPCMethodInfo]:
196
+ return self._methods.get(name)
197
+
198
+ def list_methods(self) -> List[str]:
199
+ return sorted(self._methods.keys())
200
+
201
+ def __contains__(self, name: str) -> bool:
202
+ return name in self._methods
203
+
204
+ def __len__(self) -> int:
205
+ return len(self._methods)
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Utility helpers
210
+ # ---------------------------------------------------------------------------
211
+
212
+ def _hex_int(value: int) -> str:
213
+ """Encode an integer as a 0x-prefixed hex string."""
214
+ return hex(value)
215
+
216
+
217
+ def _parse_hex_int(value: Any) -> int:
218
+ """Parse a 0x-prefixed hex string or plain int."""
219
+ if isinstance(value, int):
220
+ return value
221
+ if isinstance(value, str):
222
+ if value.startswith("0x") or value.startswith("0X"):
223
+ return int(value, 16)
224
+ return int(value)
225
+ raise RPCError(RPCErrorCode.INVALID_PARAMS, f"expected integer, got {type(value).__name__}")
226
+
227
+
228
+ def _require_param(params: Any, key: Union[str, int], name: str = "") -> Any:
229
+ """Extract a required parameter from dict or list."""
230
+ label = name or str(key)
231
+ try:
232
+ if isinstance(params, dict):
233
+ if key not in params:
234
+ raise KeyError(key)
235
+ return params[key]
236
+ if isinstance(params, (list, tuple)):
237
+ return params[key]
238
+ except (KeyError, IndexError, TypeError):
239
+ pass
240
+ raise RPCError(RPCErrorCode.INVALID_PARAMS, f"missing required parameter: {label}")
241
+
242
+
243
+ def _optional_param(params: Any, key: Union[str, int], default: Any = None) -> Any:
244
+ """Extract an optional parameter."""
245
+ try:
246
+ if isinstance(params, dict):
247
+ return params.get(key, default)
248
+ if isinstance(params, (list, tuple)) and isinstance(key, int) and key < len(params):
249
+ return params[key]
250
+ except Exception:
251
+ pass
252
+ return default
253
+
254
+
255
+ def _safe_serialize(obj: Any) -> Any:
256
+ """Make an object JSON-safe (handle non-serializable types)."""
257
+ if obj is None or isinstance(obj, (str, int, float, bool)):
258
+ return obj
259
+ if isinstance(obj, dict):
260
+ return {str(k): _safe_serialize(v) for k, v in obj.items()}
261
+ if isinstance(obj, (list, tuple)):
262
+ return [_safe_serialize(v) for v in obj]
263
+ if isinstance(obj, bytes):
264
+ return "0x" + obj.hex()
265
+ if hasattr(obj, "to_dict"):
266
+ return _safe_serialize(obj.to_dict())
267
+ return str(obj)
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # RPCServer — the main server class
272
+ # ---------------------------------------------------------------------------
273
+
274
+ class RPCServer:
275
+ """
276
+ JSON-RPC 2.0 server for a Zexus BlockchainNode.
277
+
278
+ Supports:
279
+ - HTTP POST requests (standard JSON-RPC)
280
+ - WebSocket connections (subscriptions + regular calls)
281
+ - Batch requests
282
+ - CORS for browser-based dApps
283
+ - Per-IP rate limiting
284
+ - Method namespace: zx_, txpool_, net_, miner_, contract_, admin_
285
+
286
+ Usage::
287
+
288
+ from zexus.blockchain.node import BlockchainNode, NodeConfig
289
+ from zexus.blockchain.rpc import RPCServer
290
+
291
+ node = BlockchainNode(NodeConfig(rpc_enabled=True))
292
+ rpc = RPCServer(node, host="127.0.0.1", port=8545)
293
+ await rpc.start()
294
+ # ... node is now accessible via HTTP/WS at port 8545
295
+ await rpc.stop()
296
+ """
297
+
298
+ def __init__(
299
+ self,
300
+ node: Any, # BlockchainNode — avoid circular import at module level
301
+ host: str = "0.0.0.0",
302
+ port: int = 8545,
303
+ cors_origins: str = "*",
304
+ max_request_size: int = 5 * 1024 * 1024, # 5 MB
305
+ rate_limit: int = 200, # requests per second per IP
306
+ ):
307
+ self.node = node
308
+ self.host = host
309
+ self.port = port
310
+ self.cors_origins = cors_origins
311
+ self.max_request_size = max_request_size
312
+
313
+ # Components
314
+ self.registry = RPCMethodRegistry()
315
+ self.subscriptions = SubscriptionManager()
316
+ self.rate_limiter = RateLimiter(max_requests=rate_limit)
317
+
318
+ # Server state
319
+ self._app = None
320
+ self._runner = None
321
+ self._site = None
322
+ self._running = False
323
+
324
+ # Register all RPC methods
325
+ self._register_all_methods()
326
+
327
+ # ------------------------------------------------------------------
328
+ # Server lifecycle
329
+ # ------------------------------------------------------------------
330
+
331
+ async def start(self):
332
+ """Start the HTTP+WS server."""
333
+ if self._running:
334
+ return
335
+
336
+ try:
337
+ from aiohttp import web
338
+ except ImportError:
339
+ logger.error(
340
+ "aiohttp is required for the RPC server. "
341
+ "Install with: pip install aiohttp"
342
+ )
343
+ return
344
+
345
+ self._app = web.Application(client_max_size=self.max_request_size)
346
+ self._app.router.add_post("/", self._handle_http)
347
+ self._app.router.add_get("/", self._handle_ws_or_info)
348
+ self._app.router.add_get("/ws", self._handle_ws)
349
+ self._app.router.add_options("/", self._handle_cors_preflight)
350
+ self._app.router.add_get("/health", self._handle_health)
351
+
352
+ self._runner = web.AppRunner(self._app)
353
+ await self._runner.setup()
354
+ self._site = web.TCPSite(self._runner, self.host, self.port)
355
+ await self._site.start()
356
+ self._running = True
357
+
358
+ # Hook into node events for WebSocket subscriptions
359
+ self._wire_node_events()
360
+
361
+ logger.info("RPC server listening on http://%s:%d", self.host, self.port)
362
+
363
+ async def stop(self):
364
+ """Stop the server gracefully."""
365
+ if not self._running:
366
+ return
367
+ self._running = False
368
+ if self._runner:
369
+ await self._runner.cleanup()
370
+ self._app = None
371
+ self._runner = None
372
+ self._site = None
373
+ logger.info("RPC server stopped")
374
+
375
+ @property
376
+ def is_running(self) -> bool:
377
+ return self._running
378
+
379
+ # ------------------------------------------------------------------
380
+ # HTTP handler
381
+ # ------------------------------------------------------------------
382
+
383
+ async def _handle_http(self, request):
384
+ """Handle a JSON-RPC POST request."""
385
+ from aiohttp import web
386
+
387
+ # CORS headers (Content-Type omitted — json_response sets it)
388
+ headers = {
389
+ "Access-Control-Allow-Origin": self.cors_origins,
390
+ "Access-Control-Allow-Headers": "Content-Type",
391
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
392
+ }
393
+
394
+ # Rate limiting
395
+ ip = request.remote or "unknown"
396
+ if not self.rate_limiter.allow(ip):
397
+ return web.json_response(
398
+ {"jsonrpc": "2.0", "error": {"code": -32000, "message": "rate limit exceeded"}, "id": None},
399
+ status=429, headers=headers,
400
+ )
401
+
402
+ # Parse body
403
+ try:
404
+ body = await request.json()
405
+ except Exception:
406
+ resp = self._error_response(None, RPCErrorCode.PARSE_ERROR, "Invalid JSON")
407
+ return web.json_response(resp, headers=headers)
408
+
409
+ # Batch request?
410
+ if isinstance(body, list):
411
+ if len(body) == 0:
412
+ resp = self._error_response(None, RPCErrorCode.INVALID_REQUEST, "Empty batch")
413
+ return web.json_response(resp, headers=headers)
414
+ if len(body) > 100:
415
+ resp = self._error_response(None, RPCErrorCode.INVALID_REQUEST, "Batch too large (max 100)")
416
+ return web.json_response(resp, headers=headers)
417
+ results = await asyncio.gather(
418
+ *[self._process_single_request(r) for r in body]
419
+ )
420
+ # Filter out notifications (no id)
421
+ results = [r for r in results if r is not None]
422
+ return web.json_response(results, headers=headers)
423
+
424
+ # Single request
425
+ result = await self._process_single_request(body)
426
+ if result is None:
427
+ return web.Response(status=204, headers=headers)
428
+ return web.json_response(result, headers=headers)
429
+
430
+ async def _handle_cors_preflight(self, request):
431
+ """Handle CORS preflight OPTIONS requests."""
432
+ from aiohttp import web
433
+ return web.Response(
434
+ status=204,
435
+ headers={
436
+ "Access-Control-Allow-Origin": self.cors_origins,
437
+ "Access-Control-Allow-Headers": "Content-Type",
438
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
439
+ "Access-Control-Max-Age": "86400",
440
+ },
441
+ )
442
+
443
+ async def _handle_health(self, request):
444
+ """Health check endpoint."""
445
+ from aiohttp import web
446
+ return web.json_response({
447
+ "status": "ok",
448
+ "chain_id": self.node.config.chain_id,
449
+ "height": self.node.chain.height,
450
+ "peers": self.node.network.peer_count,
451
+ "rpc_methods": len(self.registry),
452
+ "subscriptions": self.subscriptions.count,
453
+ })
454
+
455
+ async def _handle_ws_or_info(self, request):
456
+ """GET / — upgrade to WS if requested, otherwise return node info."""
457
+ from aiohttp import web
458
+ if request.headers.get("Upgrade", "").lower() == "websocket":
459
+ return await self._handle_ws(request)
460
+ # Return a friendly info page for browsers
461
+ info = {
462
+ "jsonrpc": "2.0",
463
+ "server": "Zexus Blockchain RPC",
464
+ "chain_id": self.node.config.chain_id,
465
+ "height": self.node.chain.height,
466
+ "methods": self.registry.list_methods(),
467
+ }
468
+ return web.json_response(info, headers={
469
+ "Access-Control-Allow-Origin": self.cors_origins,
470
+ })
471
+
472
+ # ------------------------------------------------------------------
473
+ # WebSocket handler
474
+ # ------------------------------------------------------------------
475
+
476
+ async def _handle_ws(self, request):
477
+ """Handle a WebSocket connection (subscriptions + regular calls)."""
478
+ from aiohttp import web, WSMsgType
479
+
480
+ ws = web.WebSocketResponse()
481
+ await ws.prepare(request)
482
+
483
+ logger.debug("WebSocket client connected: %s", request.remote)
484
+
485
+ try:
486
+ async for msg in ws:
487
+ if msg.type == WSMsgType.TEXT:
488
+ try:
489
+ body = json.loads(msg.data)
490
+ except json.JSONDecodeError:
491
+ await ws.send_json(
492
+ self._error_response(None, RPCErrorCode.PARSE_ERROR, "Invalid JSON")
493
+ )
494
+ continue
495
+
496
+ if isinstance(body, list):
497
+ results = await asyncio.gather(
498
+ *[self._process_single_request(r, ws=ws) for r in body]
499
+ )
500
+ results = [r for r in results if r is not None]
501
+ if results:
502
+ await ws.send_json(results)
503
+ else:
504
+ result = await self._process_single_request(body, ws=ws)
505
+ if result is not None:
506
+ await ws.send_json(result)
507
+
508
+ elif msg.type in (WSMsgType.ERROR, WSMsgType.CLOSE):
509
+ break
510
+ finally:
511
+ removed = self.subscriptions.remove_all_for_ws(ws)
512
+ if removed:
513
+ logger.debug("Cleaned up %d subscriptions for disconnected client", removed)
514
+
515
+ return ws
516
+
517
+ # ------------------------------------------------------------------
518
+ # Request processing
519
+ # ------------------------------------------------------------------
520
+
521
+ async def _process_single_request(
522
+ self, body: Any, ws: Any = None,
523
+ ) -> Optional[Dict[str, Any]]:
524
+ """Process a single JSON-RPC request and return the response dict."""
525
+ if not isinstance(body, dict):
526
+ return self._error_response(None, RPCErrorCode.INVALID_REQUEST, "Request must be an object")
527
+
528
+ req_id = body.get("id")
529
+ method = body.get("method")
530
+ params = body.get("params", [])
531
+
532
+ # Validate
533
+ if body.get("jsonrpc") != "2.0":
534
+ return self._error_response(req_id, RPCErrorCode.INVALID_REQUEST, "jsonrpc must be '2.0'")
535
+ if not method or not isinstance(method, str):
536
+ return self._error_response(req_id, RPCErrorCode.INVALID_REQUEST, "missing or invalid method")
537
+
538
+ # Special subscription methods that need WS
539
+ if method == "zx_subscribe" and ws is not None:
540
+ return await self._handle_subscribe(req_id, params, ws)
541
+ if method == "zx_unsubscribe" and ws is not None:
542
+ return await self._handle_unsubscribe(req_id, params)
543
+
544
+ # Dispatch to registered handler
545
+ info = self.registry.get(method)
546
+ if info is None:
547
+ return self._error_response(req_id, RPCErrorCode.METHOD_NOT_FOUND, f"method not found: {method}")
548
+
549
+ try:
550
+ if info.is_async:
551
+ result = await info.handler(params)
552
+ else:
553
+ result = info.handler(params)
554
+ return self._success_response(req_id, _safe_serialize(result))
555
+ except RPCError as e:
556
+ return self._error_response(req_id, e.code, e.message, e.data)
557
+ except Exception as e:
558
+ logger.error("RPC handler error [%s]: %s\n%s", method, e, traceback.format_exc())
559
+ return self._error_response(req_id, RPCErrorCode.INTERNAL_ERROR, str(e))
560
+
561
+ # ------------------------------------------------------------------
562
+ # Subscriptions
563
+ # ------------------------------------------------------------------
564
+
565
+ async def _handle_subscribe(self, req_id, params, ws) -> Dict:
566
+ """Handle zx_subscribe."""
567
+ event = _require_param(params, 0, "event_name")
568
+ sub_params = _optional_param(params, 1, {})
569
+ valid_events = {"newHeads", "newPendingTransactions", "logs", "syncing"}
570
+ if event not in valid_events:
571
+ raise RPCError(RPCErrorCode.INVALID_PARAMS,
572
+ f"unknown subscription event: {event}. "
573
+ f"Valid: {', '.join(sorted(valid_events))}")
574
+ sub_id = self.subscriptions.add(event, ws, sub_params if isinstance(sub_params, dict) else {})
575
+ return self._success_response(req_id, sub_id)
576
+
577
+ async def _handle_unsubscribe(self, req_id, params) -> Dict:
578
+ """Handle zx_unsubscribe."""
579
+ sub_id = _require_param(params, 0, "subscription_id")
580
+ removed = self.subscriptions.remove(sub_id)
581
+ return self._success_response(req_id, removed)
582
+
583
+ def _wire_node_events(self):
584
+ """Connect node events to subscription notifications."""
585
+ def on_new_block(block):
586
+ if not self._running:
587
+ return
588
+ data = _safe_serialize(block.to_dict()) if hasattr(block, "to_dict") else {}
589
+ asyncio.run_coroutine_threadsafe(
590
+ self.subscriptions.notify("newHeads", data),
591
+ asyncio.get_event_loop(),
592
+ )
593
+
594
+ def on_new_tx(tx):
595
+ if not self._running:
596
+ return
597
+ tx_data = _safe_serialize(tx.to_dict()) if hasattr(tx, "to_dict") else {"tx_hash": getattr(tx, "tx_hash", "")}
598
+ asyncio.run_coroutine_threadsafe(
599
+ self.subscriptions.notify("newPendingTransactions", tx_data),
600
+ asyncio.get_event_loop(),
601
+ )
602
+
603
+ self.node.on("new_block", on_new_block)
604
+ self.node.on("mined", on_new_block)
605
+ self.node.on("new_tx", on_new_tx)
606
+
607
+ # ------------------------------------------------------------------
608
+ # Response builders
609
+ # ------------------------------------------------------------------
610
+
611
+ @staticmethod
612
+ def _success_response(req_id: Any, result: Any) -> Dict[str, Any]:
613
+ return {"jsonrpc": "2.0", "result": result, "id": req_id}
614
+
615
+ @staticmethod
616
+ def _error_response(req_id: Any, code: int, message: str, data: Any = None) -> Dict[str, Any]:
617
+ err: Dict[str, Any] = {"code": int(code), "message": message}
618
+ if data is not None:
619
+ err["data"] = data
620
+ return {"jsonrpc": "2.0", "error": err, "id": req_id}
621
+
622
+ # ══════════════════════════════════════════════════════════════════
623
+ # RPC method registration
624
+ # ══════════════════════════════════════════════════════════════════
625
+
626
+ def _register_all_methods(self):
627
+ """Register every RPC method."""
628
+ r = self.registry.register
629
+
630
+ # ── zx_* namespace: core blockchain queries ───────────────
631
+ r("zx_chainId", self._zx_chain_id, "Get the chain ID")
632
+ r("zx_blockNumber", self._zx_block_number, "Get current block height")
633
+ r("zx_getBlockByNumber", self._zx_get_block_by_number, "Get block by height")
634
+ r("zx_getBlockByHash", self._zx_get_block_by_hash, "Get block by hash")
635
+ r("zx_getBlockTransactionCount", self._zx_get_block_tx_count, "Get tx count in a block")
636
+ r("zx_getBalance", self._zx_get_balance, "Get account balance")
637
+ r("zx_getTransactionCount", self._zx_get_tx_count, "Get account nonce")
638
+ r("zx_getAccount", self._zx_get_account, "Get full account state")
639
+ r("zx_getTransactionByHash", self._zx_get_tx_by_hash, "Get a transaction by hash")
640
+ r("zx_getTransactionReceipt", self._zx_get_tx_receipt, "Get a transaction receipt")
641
+ r("zx_sendTransaction", self._zx_send_transaction, "Submit a signed transaction")
642
+ r("zx_sendRawTransaction", self._zx_send_raw_transaction, "Submit a raw transaction dict")
643
+ r("zx_gasPrice", self._zx_gas_price, "Get suggested gas price")
644
+ r("zx_estimateGas", self._zx_estimate_gas, "Estimate gas for a call")
645
+ r("zx_getChainInfo", self._zx_get_chain_info, "Get chain + network summary")
646
+ r("zx_validateChain", self._zx_validate_chain, "Validate full chain integrity")
647
+ r("zx_getCode", self._zx_get_code, "Get contract code at address")
648
+ r("zx_getLogs", self._zx_get_logs, "Get event logs (filtered)")
649
+
650
+ # ── txpool_* namespace: mempool ───────────────────────────
651
+ r("txpool_status", self._txpool_status, "Mempool size and stats")
652
+ r("txpool_content", self._txpool_content, "List pending transactions")
653
+ r("txpool_replaceByFee", self._txpool_replace_by_fee, "Replace tx with higher gas price (RBF)")
654
+
655
+ # ── net_* namespace: networking ───────────────────────────
656
+ r("net_version", self._net_version, "Get network / chain ID")
657
+ r("net_peerCount", self._net_peer_count, "Get connected peer count")
658
+ r("net_listening", self._net_listening, "Is node listening for connections")
659
+ r("net_peers", self._net_peers, "Get connected peer info")
660
+
661
+ # ── miner_* namespace: mining control ─────────────────────
662
+ r("miner_start", self._miner_start, "Start mining")
663
+ r("miner_stop", self._miner_stop, "Stop mining")
664
+ r("miner_status", self._miner_status, "Get mining status")
665
+ r("miner_setMinerAddress", self._miner_set_address, "Set miner reward address")
666
+ r("miner_mineBlock", self._miner_mine_block, "Mine one block synchronously")
667
+
668
+ # ── contract_* namespace: smart contracts ─────────────────
669
+ r("contract_deploy", self._contract_deploy, "Deploy a smart contract")
670
+ r("contract_call", self._contract_call, "Execute a contract action (state-changing)")
671
+ r("contract_staticCall", self._contract_static_call, "Read-only contract call")
672
+
673
+ # ── admin_* namespace: node administration ────────────────
674
+ r("admin_nodeInfo", self._admin_node_info, "Get full node information")
675
+ r("admin_fundAccount", self._admin_fund_account, "Fund an account (devnet)")
676
+ r("admin_exportChain", self._admin_export_chain, "Export full chain JSON")
677
+ r("admin_rpcMethods", self._admin_rpc_methods, "List all available RPC methods")
678
+
679
+ # ══════════════════════════════════════════════════════════════════
680
+ # zx_* handlers
681
+ # ══════════════════════════════════════════════════════════════════
682
+
683
+ def _zx_chain_id(self, params) -> str:
684
+ return self.node.config.chain_id
685
+
686
+ def _zx_block_number(self, params) -> str:
687
+ return _hex_int(self.node.chain.height)
688
+
689
+ def _zx_get_block_by_number(self, params) -> Optional[Dict]:
690
+ height = _parse_hex_int(_require_param(params, 0, "block_number"))
691
+ full_txs = _optional_param(params, 1, False)
692
+ block = self.node.get_block(height)
693
+ if block is None:
694
+ return None
695
+ if not full_txs:
696
+ # Return only tx hashes instead of full tx objects
697
+ block["transactions"] = [
698
+ tx.get("tx_hash", "") if isinstance(tx, dict) else tx
699
+ for tx in block.get("transactions", [])
700
+ ]
701
+ return block
702
+
703
+ def _zx_get_block_by_hash(self, params) -> Optional[Dict]:
704
+ block_hash = _require_param(params, 0, "block_hash")
705
+ full_txs = _optional_param(params, 1, False)
706
+ block = self.node.get_block(block_hash)
707
+ if block is None:
708
+ return None
709
+ if not full_txs:
710
+ block["transactions"] = [
711
+ tx.get("tx_hash", "") if isinstance(tx, dict) else tx
712
+ for tx in block.get("transactions", [])
713
+ ]
714
+ return block
715
+
716
+ def _zx_get_block_tx_count(self, params) -> str:
717
+ block_id = _require_param(params, 0, "block_number_or_hash")
718
+ block_id = _parse_hex_int(block_id) if isinstance(block_id, str) and block_id.startswith("0x") else block_id
719
+ block = self.node.get_block(block_id)
720
+ if block is None:
721
+ raise RPCError(RPCErrorCode.NOT_FOUND, "block not found")
722
+ return _hex_int(len(block.get("transactions", [])))
723
+
724
+ def _zx_get_balance(self, params) -> str:
725
+ address = _require_param(params, 0, "address")
726
+ balance = self.node.get_balance(address)
727
+ return _hex_int(balance)
728
+
729
+ def _zx_get_tx_count(self, params) -> str:
730
+ address = _require_param(params, 0, "address")
731
+ nonce = self.node.get_nonce(address)
732
+ return _hex_int(nonce)
733
+
734
+ def _zx_get_account(self, params) -> Dict:
735
+ address = _require_param(params, 0, "address")
736
+ return self.node.get_account(address)
737
+
738
+ def _zx_get_tx_by_hash(self, params) -> Optional[Dict]:
739
+ tx_hash = _require_param(params, 0, "tx_hash")
740
+ # Search in blocks
741
+ for block in self.node.chain.blocks:
742
+ for tx in block.transactions:
743
+ if tx.tx_hash == tx_hash:
744
+ result = tx.to_dict()
745
+ result["block_hash"] = block.hash
746
+ result["block_height"] = block.header.height
747
+ return result
748
+ # Search in mempool
749
+ for tx in self.node.mempool.get_pending():
750
+ if tx.tx_hash == tx_hash:
751
+ result = tx.to_dict()
752
+ result["block_hash"] = None
753
+ result["block_height"] = None
754
+ result["status"] = "pending"
755
+ return result
756
+ return None
757
+
758
+ def _zx_get_tx_receipt(self, params) -> Optional[Dict]:
759
+ tx_hash = _require_param(params, 0, "tx_hash")
760
+ for block in self.node.chain.blocks:
761
+ for receipt in block.receipts:
762
+ if receipt.tx_hash == tx_hash:
763
+ result = receipt.to_dict()
764
+ result["block_hash"] = block.hash
765
+ result["block_height"] = block.header.height
766
+ return result
767
+ # Check if tx is in this block (even with no explicit receipt)
768
+ for tx in block.transactions:
769
+ if tx.tx_hash == tx_hash:
770
+ return {
771
+ "tx_hash": tx_hash,
772
+ "block_hash": block.hash,
773
+ "block_height": block.header.height,
774
+ "status": 1,
775
+ "gas_used": tx.gas_limit,
776
+ }
777
+ return None
778
+
779
+ def _zx_send_transaction(self, params) -> str:
780
+ """Create and submit a transaction from parameters."""
781
+ tx_data = _require_param(params, 0, "transaction")
782
+ if not isinstance(tx_data, dict):
783
+ raise RPCError(RPCErrorCode.INVALID_PARAMS, "transaction must be an object")
784
+
785
+ from .chain import Transaction
786
+ tx = Transaction(
787
+ sender=tx_data.get("from", tx_data.get("sender", "")),
788
+ recipient=tx_data.get("to", tx_data.get("recipient", "")),
789
+ value=_parse_hex_int(tx_data.get("value", 0)),
790
+ data=tx_data.get("data", ""),
791
+ nonce=_parse_hex_int(tx_data.get("nonce", 0)),
792
+ gas_limit=_parse_hex_int(tx_data.get("gas", tx_data.get("gasLimit", tx_data.get("gas_limit", 21000)))),
793
+ gas_price=_parse_hex_int(tx_data.get("gasPrice", tx_data.get("gas_price", 1))),
794
+ timestamp=time.time(),
795
+ )
796
+ tx.compute_hash()
797
+
798
+ # Auto-fill nonce if not explicitly provided
799
+ if "nonce" not in tx_data:
800
+ acct = self.node.chain.get_account(tx.sender)
801
+ chain_nonce = acct["nonce"]
802
+ mempool_nonce = self.node.mempool._nonces.get(tx.sender, 0)
803
+ tx.nonce = max(chain_nonce, mempool_nonce)
804
+ tx.compute_hash()
805
+
806
+ # Set dev signature for RPC-submitted transactions
807
+ if not tx.signature:
808
+ tx.signature = "rpc_dev_" + tx.tx_hash[:56]
809
+
810
+ result = self.node.submit_transaction(tx)
811
+ if not result["success"]:
812
+ error_msg = result.get("error", "transaction rejected")
813
+ if "insufficient balance" in error_msg:
814
+ raise RPCError(RPCErrorCode.INSUFFICIENT_FUNDS, error_msg)
815
+ if "nonce too low" in error_msg:
816
+ raise RPCError(RPCErrorCode.NONCE_TOO_LOW, error_msg)
817
+ raise RPCError(RPCErrorCode.TX_REJECTED, error_msg)
818
+ return result["tx_hash"]
819
+
820
+ def _zx_send_raw_transaction(self, params) -> str:
821
+ """Submit a pre-built transaction dict."""
822
+ tx_data = _require_param(params, 0, "raw_transaction")
823
+ if not isinstance(tx_data, dict):
824
+ raise RPCError(RPCErrorCode.INVALID_PARAMS, "raw_transaction must be an object")
825
+
826
+ from .chain import Transaction
827
+ tx = Transaction(**{k: v for k, v in tx_data.items() if k in Transaction.__dataclass_fields__})
828
+ if not tx.tx_hash:
829
+ tx.compute_hash()
830
+ if not tx.signature:
831
+ tx.signature = "rpc_dev_" + tx.tx_hash[:56]
832
+
833
+ result = self.node.submit_transaction(tx)
834
+ if not result["success"]:
835
+ raise RPCError(RPCErrorCode.TX_REJECTED, result.get("error", "rejected"))
836
+ return result["tx_hash"]
837
+
838
+ def _zx_gas_price(self, params) -> str:
839
+ """Return suggested gas price (simple: median of recent block txs)."""
840
+ prices = []
841
+ for block in self.node.chain.blocks[-10:]:
842
+ for tx in block.transactions:
843
+ if tx.gas_price > 0:
844
+ prices.append(tx.gas_price)
845
+ if prices:
846
+ prices.sort()
847
+ median = prices[len(prices) // 2]
848
+ return _hex_int(max(median, 1))
849
+ return _hex_int(1) # Minimum gas price
850
+
851
+ def _zx_estimate_gas(self, params) -> str:
852
+ """Estimate gas for a transaction via dry-run execution.
853
+
854
+ For simple transfers: 21,000 gas.
855
+ For contract deployments (no recipient, has data): size-based estimate.
856
+ For contract calls: attempts dry-run via ContractVM.static_call,
857
+ returning actual gas_used + 20 % safety margin.
858
+ """
859
+ tx_data = _optional_param(params, 0, {})
860
+ if not isinstance(tx_data, dict):
861
+ return _hex_int(21_000)
862
+
863
+ data = tx_data.get("data", "")
864
+ to = tx_data.get("to", tx_data.get("recipient", ""))
865
+ value = _parse_hex_int(tx_data.get("value", 0))
866
+
867
+ # Simple value transfer (no data, has recipient)
868
+ if not data and to:
869
+ return _hex_int(21_000)
870
+
871
+ # Contract deployment (has data, no recipient)
872
+ if data and not to:
873
+ # Base cost + per-byte cost (200 gas per byte of code)
874
+ base = 53_000
875
+ per_byte = len(data.encode("utf-8")) * 200
876
+ return _hex_int(base + per_byte)
877
+
878
+ # Contract call — try dry-run via static_call
879
+ if data and to:
880
+ try:
881
+ import json as _j
882
+ call_data = _j.loads(data) if isinstance(data, str) and data.startswith("{") else {}
883
+ action = call_data.get("action", "")
884
+ args = call_data.get("args", {})
885
+ caller = tx_data.get("from", tx_data.get("sender", ""))
886
+ if action and self.node.contract_vm:
887
+ receipt = self.node.contract_vm.static_call(
888
+ contract_address=to,
889
+ action=action,
890
+ args=args,
891
+ caller=caller,
892
+ )
893
+ gas_used = receipt.gas_used if receipt.gas_used > 0 else 50_000
894
+ # Add 20% safety margin
895
+ estimated = int(gas_used * 1.2)
896
+ return _hex_int(estimated)
897
+ except Exception:
898
+ pass
899
+ # Fallback for generic contract interaction
900
+ return _hex_int(500_000)
901
+
902
+ # Fallback: data but unclear intent
903
+ if data:
904
+ return _hex_int(500_000)
905
+ return _hex_int(21_000)
906
+
907
+ def _zx_get_chain_info(self, params) -> Dict:
908
+ return self.node.get_chain_info()
909
+
910
+ def _zx_validate_chain(self, params) -> Dict:
911
+ return self.node.validate_chain()
912
+
913
+ def _zx_get_code(self, params) -> str:
914
+ """Get contract code/state at an address."""
915
+ address = _require_param(params, 0, "address")
916
+ # Check contract_state first (populated by contract_deploy)
917
+ cs = self.node.chain.contract_state.get(address, {})
918
+ if cs.get("code"):
919
+ return cs["code"]
920
+ acct = self.node.chain.get_account(address)
921
+ return acct.get("code", "")
922
+
923
+ def _zx_get_logs(self, params) -> List[Dict]:
924
+ """Get event logs filtered by block range, address, topics, and event name.
925
+
926
+ Uses the EventIndex (if available) for fast indexed lookups,
927
+ otherwise falls back to scanning block receipts.
928
+ """
929
+ filter_obj = _optional_param(params, 0, {})
930
+ if not isinstance(filter_obj, dict):
931
+ filter_obj = {}
932
+
933
+ from_block = _parse_hex_int(filter_obj.get("fromBlock", 0))
934
+ to_block = _parse_hex_int(filter_obj.get("toBlock", self.node.chain.height))
935
+ target_address = filter_obj.get("address")
936
+ topics = filter_obj.get("topics")
937
+ event_name = filter_obj.get("eventName")
938
+ limit = filter_obj.get("limit", 10_000)
939
+
940
+ # ── Fast path: use EventIndex if available ──
941
+ if self.node.event_index:
942
+ try:
943
+ from .events import LogFilter
944
+ filt = LogFilter(
945
+ from_block=from_block,
946
+ to_block=to_block,
947
+ address=target_address,
948
+ topics=topics,
949
+ event_name=event_name,
950
+ limit=limit,
951
+ )
952
+ results = self.node.event_index.get_logs(filt)
953
+ return [log.to_dict() for log in results]
954
+ except Exception:
955
+ pass # Fall through to scan
956
+
957
+ # ── Fallback: scan receipts directly ──
958
+ logs = []
959
+ for h in range(from_block, min(to_block + 1, self.node.chain.height + 1)):
960
+ block = self.node.chain.get_block(h)
961
+ if block is None:
962
+ continue
963
+ for receipt in block.receipts:
964
+ for log_entry in receipt.logs:
965
+ if target_address and log_entry.get("address") != target_address:
966
+ continue
967
+ if event_name and log_entry.get("event") != event_name:
968
+ continue
969
+ enriched = dict(log_entry)
970
+ enriched["block_hash"] = block.hash
971
+ enriched["block_height"] = block.header.height
972
+ enriched["tx_hash"] = receipt.tx_hash
973
+ logs.append(enriched)
974
+ if len(logs) >= limit:
975
+ return logs
976
+ return logs
977
+
978
+ # ══════════════════════════════════════════════════════════════════
979
+ # txpool_* handlers
980
+ # ══════════════════════════════════════════════════════════════════
981
+
982
+ def _txpool_status(self, params) -> Dict:
983
+ mp = self.node.mempool
984
+ return {
985
+ "pending": mp.size,
986
+ "queued": 0,
987
+ "rbf_enabled": mp.rbf_enabled,
988
+ "rbf_increment_pct": mp.rbf_increment_pct,
989
+ }
990
+
991
+ def _txpool_content(self, params) -> Dict:
992
+ pending = self.node.mempool.get_pending()
993
+ return {
994
+ "pending": {
995
+ tx.sender: {str(tx.nonce): tx.to_dict()} for tx in pending
996
+ },
997
+ }
998
+
999
+ def _txpool_replace_by_fee(self, params) -> Dict:
1000
+ """Replace a pending tx with a higher-gas-price version (RBF)."""
1001
+ tx_data = _require_param(params, 0, "transaction")
1002
+ if not isinstance(tx_data, dict):
1003
+ raise RPCError(RPCErrorCode.INVALID_PARAMS, "transaction must be an object")
1004
+
1005
+ from .chain import Transaction
1006
+ tx = Transaction(
1007
+ sender=tx_data.get("from", tx_data.get("sender", "")),
1008
+ recipient=tx_data.get("to", tx_data.get("recipient", "")),
1009
+ value=_parse_hex_int(tx_data.get("value", 0)),
1010
+ data=tx_data.get("data", ""),
1011
+ nonce=_parse_hex_int(tx_data.get("nonce", 0)),
1012
+ gas_limit=_parse_hex_int(tx_data.get("gas", tx_data.get("gasLimit", 21000))),
1013
+ gas_price=_parse_hex_int(tx_data.get("gasPrice", tx_data.get("gas_price", 1))),
1014
+ timestamp=time.time(),
1015
+ )
1016
+ tx.compute_hash()
1017
+ if not tx.signature:
1018
+ tx.signature = "rpc_dev_" + tx.tx_hash[:56]
1019
+
1020
+ result = self.node.mempool.replace_by_fee(tx)
1021
+ if not result["replaced"]:
1022
+ raise RPCError(RPCErrorCode.TX_REJECTED, result.get("error", "RBF failed"))
1023
+ return {
1024
+ "new_tx_hash": tx.tx_hash,
1025
+ "old_tx_hash": result["old_hash"],
1026
+ "gas_price": tx.gas_price,
1027
+ }
1028
+
1029
+ # ══════════════════════════════════════════════════════════════════
1030
+ # net_* handlers
1031
+ # ══════════════════════════════════════════════════════════════════
1032
+
1033
+ def _net_version(self, params) -> str:
1034
+ return self.node.config.chain_id
1035
+
1036
+ def _net_peer_count(self, params) -> str:
1037
+ return _hex_int(self.node.network.peer_count)
1038
+
1039
+ def _net_listening(self, params) -> bool:
1040
+ return self.node.network.is_running
1041
+
1042
+ def _net_peers(self, params) -> List[Dict]:
1043
+ info = self.node.network.get_network_info()
1044
+ return info.get("peers", [])
1045
+
1046
+ # ══════════════════════════════════════════════════════════════════
1047
+ # miner_* handlers
1048
+ # ══════════════════════════════════════════════════════════════════
1049
+
1050
+ def _miner_start(self, params) -> bool:
1051
+ try:
1052
+ self.node.start_mining()
1053
+ return True
1054
+ except Exception as e:
1055
+ raise RPCError(RPCErrorCode.MINING_ERROR, str(e))
1056
+
1057
+ def _miner_stop(self, params) -> bool:
1058
+ self.node.stop_mining()
1059
+ return True
1060
+
1061
+ def _miner_status(self, params) -> Dict:
1062
+ return {
1063
+ "mining": self.node._mining,
1064
+ "miner_address": self.node.config.miner_address,
1065
+ "consensus": self.node.config.consensus,
1066
+ "height": self.node.chain.height,
1067
+ "mempool_size": self.node.mempool.size,
1068
+ }
1069
+
1070
+ def _miner_set_address(self, params) -> bool:
1071
+ address = _require_param(params, 0, "address")
1072
+ self.node.config.miner_address = address
1073
+ return True
1074
+
1075
+ def _miner_mine_block(self, params) -> Optional[Dict]:
1076
+ """Mine a single block synchronously."""
1077
+ block = self.node.mine_block_sync()
1078
+ if block is None:
1079
+ return None
1080
+ return block.to_dict()
1081
+
1082
+ # ══════════════════════════════════════════════════════════════════
1083
+ # contract_* handlers
1084
+ # ══════════════════════════════════════════════════════════════════
1085
+
1086
+ def _contract_deploy(self, params) -> Dict:
1087
+ """Deploy a contract.
1088
+
1089
+ Expects: {code, deployer, gas_limit?, initial_value?}
1090
+ """
1091
+ data = _require_param(params, 0, "deploy_params")
1092
+ if not isinstance(data, dict):
1093
+ raise RPCError(RPCErrorCode.INVALID_PARAMS, "deploy_params must be an object")
1094
+
1095
+ code = _require_param(data, "code", "code")
1096
+ deployer = _require_param(data, "deployer", "deployer")
1097
+ gas_limit = data.get("gas_limit", data.get("gasLimit", 10_000_000))
1098
+ initial_value = data.get("initial_value", data.get("initialValue", 0))
1099
+
1100
+ if self.node.contract_vm is None:
1101
+ raise RPCError(RPCErrorCode.CONTRACT_ERROR, "ContractVM not available")
1102
+
1103
+ # Create a minimal contract object for the VM
1104
+ from .contract_vm import ContractVM
1105
+ import hashlib as _hl
1106
+ contract_address = "0x" + _hl.sha256(
1107
+ f"{deployer}:{code}:{time.time()}".encode()
1108
+ ).hexdigest()[:40]
1109
+
1110
+ # Store the code in chain's contract state
1111
+ self.node.chain.contract_state[contract_address] = {
1112
+ "code": code,
1113
+ "deployer": deployer,
1114
+ "storage": {},
1115
+ }
1116
+
1117
+ return {
1118
+ "success": True,
1119
+ "address": contract_address,
1120
+ "deployer": deployer,
1121
+ "gas_used": 0,
1122
+ }
1123
+
1124
+ def _contract_call(self, params) -> Dict:
1125
+ """Execute a contract action (state-mutating)."""
1126
+ data = _require_param(params, 0, "call_params")
1127
+ if not isinstance(data, dict):
1128
+ raise RPCError(RPCErrorCode.INVALID_PARAMS, "call_params must be an object")
1129
+
1130
+ address = _require_param(data, "address", "contract address")
1131
+ action = _require_param(data, "action", "action name")
1132
+ args = data.get("args", {})
1133
+ caller = data.get("caller", data.get("from", ""))
1134
+ gas_limit = data.get("gas_limit", data.get("gasLimit", 500_000))
1135
+ value = data.get("value", 0)
1136
+
1137
+ result = self.node.call_contract(
1138
+ contract_address=address,
1139
+ action=action,
1140
+ args=args,
1141
+ caller=caller,
1142
+ gas_limit=gas_limit,
1143
+ value=value,
1144
+ )
1145
+ if not result.get("success"):
1146
+ raise RPCError(RPCErrorCode.CONTRACT_ERROR, result.get("error", "contract call failed"))
1147
+ return result
1148
+
1149
+ def _contract_static_call(self, params) -> Dict:
1150
+ """Execute a read-only contract call."""
1151
+ data = _require_param(params, 0, "call_params")
1152
+ if not isinstance(data, dict):
1153
+ raise RPCError(RPCErrorCode.INVALID_PARAMS, "call_params must be an object")
1154
+
1155
+ address = _require_param(data, "address", "contract address")
1156
+ action = _require_param(data, "action", "action name")
1157
+ args = data.get("args", {})
1158
+ caller = data.get("caller", data.get("from", ""))
1159
+
1160
+ result = self.node.static_call_contract(
1161
+ contract_address=address,
1162
+ action=action,
1163
+ args=args,
1164
+ caller=caller,
1165
+ )
1166
+ if not result.get("success"):
1167
+ raise RPCError(RPCErrorCode.CONTRACT_ERROR, result.get("error", "static call failed"))
1168
+ return result
1169
+
1170
+ # ══════════════════════════════════════════════════════════════════
1171
+ # admin_* handlers
1172
+ # ══════════════════════════════════════════════════════════════════
1173
+
1174
+ def _admin_node_info(self, params) -> Dict:
1175
+ return {
1176
+ "chain_id": self.node.config.chain_id,
1177
+ "host": self.node.config.host,
1178
+ "port": self.node.config.port,
1179
+ "rpc_port": self.node.config.rpc_port,
1180
+ "consensus": self.node.config.consensus,
1181
+ "mining": self.node._mining,
1182
+ "miner_address": self.node.config.miner_address,
1183
+ "height": self.node.chain.height,
1184
+ "peers": self.node.network.peer_count,
1185
+ "mempool_size": self.node.mempool.size,
1186
+ "contract_vm_available": self.node.contract_vm is not None,
1187
+ "rpc_methods": self.registry.list_methods(),
1188
+ "subscriptions": self.subscriptions.count,
1189
+ }
1190
+
1191
+ def _admin_fund_account(self, params) -> Dict:
1192
+ """Fund an account (for testnets/devnets only)."""
1193
+ address = _require_param(params, 0, "address")
1194
+ amount = _parse_hex_int(_require_param(params, 1, "amount"))
1195
+ self.node.fund_account(address, amount)
1196
+ new_balance = self.node.get_balance(address)
1197
+ return {"address": address, "funded": amount, "new_balance": new_balance}
1198
+
1199
+ def _admin_export_chain(self, params) -> List[Dict]:
1200
+ return self.node.export_chain()
1201
+
1202
+ def _admin_rpc_methods(self, params) -> List[str]:
1203
+ return self.registry.list_methods()