diff --git a/.gitignore b/.gitignore index db446e6..2c181a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# Secret keys / credentials — NEVER commit these +# Secret keys / credentials — NEVER commit these (devnet static keys are whitelisted below) *.key *.pem .env @@ -9,7 +9,8 @@ **/*.secret **/*private* **/*mnemonic* -validator-keys/ +# Only ignore root-level validator-keys/ if created outside devnet +/validator-keys/ # Build artifacts **/out/ diff --git a/contracts/.gas-snapshot b/contracts/.gas-snapshot new file mode 100644 index 0000000..c685108 --- /dev/null +++ b/contracts/.gas-snapshot @@ -0,0 +1 @@ +CounterTest:test_Increment() (gas: 30105) diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 91c8f4a..d997a8f 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -1,40 +1,16 @@ -[//]: # (foundry-readme) -# Foundry - -## Setup - -```bash -curl -L https://foundry.paradigm.xyz | bash -foundryup -``` - -## Devnet-Anvil Bridge - -If you want rapid contract iteration using Anvil: - -```bash -anvil --fork-url http://localhost:8545 --chain-id 1337 -``` - -The `--fork-url` uses validator-1 RPC as the state source. - -## Foundry Configuration - -Add `foundry.toml` at `contracts/foundry.toml`: - -```toml [profile.default] src = "src" test = "test" script = "script" out = "out" -libs = ["lib"] +libs = ["../lib"] + +[profile.default.remappings] +"forge-std/"="../lib/forge-std/src/" +"@openzeppelin/"="../lib/openzeppelin-contracts/" [rpc_endpoints] devnet = "http://localhost:8545" [etherscan] devnet = { key = "", url = "" } -``` - -Do not commit Anvil private keys. diff --git a/devnet/besu-config/static-nodes.json b/devnet/besu-config/static-nodes.json new file mode 100644 index 0000000..71bc948 --- /dev/null +++ b/devnet/besu-config/static-nodes.json @@ -0,0 +1,5 @@ +[ + "enode://12691994a433e43e0608beda18257792b08122761dac26be196703ed00a88113@validator1:30303", + "enode://9f541ece7373b5e4c1507d7707575dc6e873d4af6bec0da066b95a03508ce272@validator2:30303", + "enode://2b986af838d8401a60f1c52919081f92bdbd9b4fe57870e923396b8cc9a200f5@validator3:30303" +] \ No newline at end of file diff --git a/devnet/validator-keys/validator1/address b/devnet/validator-keys/validator1/address new file mode 100644 index 0000000..7bb1e7e --- /dev/null +++ b/devnet/validator-keys/validator1/address @@ -0,0 +1 @@ +0x18257792b08122761dac26be196703ed00a88113 \ No newline at end of file diff --git a/devnet/validator-keys/validator1/key.pub b/devnet/validator-keys/validator1/key.pub new file mode 100644 index 0000000..82a4169 --- /dev/null +++ b/devnet/validator-keys/validator1/key.pub @@ -0,0 +1 @@ +045f12c6938cf4b7e94480906fed478fa0da6eeb796102cf319039fda222ad4d650ae442dcc7bb9af5bc9f060257304f69019c983a02d0b99774c31c0b1344305d \ No newline at end of file diff --git a/devnet/validator-keys/validator2/address b/devnet/validator-keys/validator2/address new file mode 100644 index 0000000..59ecff1 --- /dev/null +++ b/devnet/validator-keys/validator2/address @@ -0,0 +1 @@ +0x07575dc6e873d4af6bec0da066b95a03508ce272 \ No newline at end of file diff --git a/devnet/validator-keys/validator2/key.pub b/devnet/validator-keys/validator2/key.pub new file mode 100644 index 0000000..acc95b1 --- /dev/null +++ b/devnet/validator-keys/validator2/key.pub @@ -0,0 +1 @@ +0451b0cca2e5d8da1b600d16aee4411a269f8070afc331860b51fe3eb2dc8ea76d058f91eb2bd6898ccdcbc73ca716c22df566ab96a2dddab1f2910d4e78b8cee4 \ No newline at end of file diff --git a/devnet/validator-keys/validator3/address b/devnet/validator-keys/validator3/address new file mode 100644 index 0000000..35d1efc --- /dev/null +++ b/devnet/validator-keys/validator3/address @@ -0,0 +1 @@ +0x19081f92bdbd9b4fe57870e923396b8cc9a200f5 \ No newline at end of file diff --git a/devnet/validator-keys/validator3/key.pub b/devnet/validator-keys/validator3/key.pub new file mode 100644 index 0000000..acbf7ec --- /dev/null +++ b/devnet/validator-keys/validator3/key.pub @@ -0,0 +1 @@ +04987a02acf845ce1c57b2333febe4bd2170403cc4e30a9bf58ae3b9484b49e0be71129eb36daeb82a193df5d1265652ed6700021679d926b3c224c7ae60359768 \ No newline at end of file diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml index 85896d4..cb76576 100644 --- a/monitoring/prometheus/prometheus.yml +++ b/monitoring/prometheus/prometheus.yml @@ -9,9 +9,9 @@ scrape_configs: - job_name: 'besu-validators' static_configs: - targets: - - 'validator1:9545' - - 'validator2:9546' - - 'validator3:9547' + - 'besu-validator-1:9545' + - 'besu-validator-2:9545' + - 'besu-validator-3:9545' labels: env: devnet service: besu diff --git a/scripts/load_test.py b/scripts/load_test.py new file mode 100644 index 0000000..f9903a8 --- /dev/null +++ b/scripts/load_test.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +load_test.py — stdlib-only RPC load-test harness. +Targets any JSON-RPC endpoint (mock or real Besu). + +Usage: + python3 load_test.py --url http://localhost:8545 \ + --method eth_sendTransaction \ + --duration 30 \ + --workers 8 \ + --rps 500 +""" + +import argparse +import json +import multiprocessing +import signal +import sys +import time +import urllib.request +from collections import deque +from typing import Any, Dict, List + + +def _rpc_request(url: str, payload: Dict[str, Any], timeout: float = 10.0) -> Dict[str, Any]: + req = urllib.request.Request( + url, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read()) + + +def _worker_process( + worker_id: int, + url: str, + method: str, + duration: float, + rps: float, + result_queue, +): + """Target function for each worker process.""" + end_time = time.monotonic() + duration + interval = 1.0 / rps if rps > 0 else 0.0 + count = 0 + dropped = 0 + latencies: List[float] = [] + errors: List[str] = [] + + # Deterministic account for eth_sendTransaction + addr = f"0x{'%040x' % worker_id}" + to_addr = f"0x{'%040x' % ((worker_id + 1) % 10)}" + + while time.monotonic() < end_time: + start = time.monotonic() + if method == "eth_sendTransaction": + payload = { + "jsonrpc": "2.0", + "method": "eth_sendTransaction", + "params": [ + { + "from": addr, + "to": to_addr, + "value": "0x1", + "gas": "0x5208", + "gasPrice": "0x1", + } + ], + "id": count, + } + elif method == "eth_getBalance": + payload = { + "jsonrpc": "2.0", + "method": "eth_getBalance", + "params": [addr, "latest"], + "id": count, + } + else: + payload = { + "jsonrpc": "2.0", + "method": method, + "params": [], + "id": count, + } + + try: + _rpc_request(url, payload, timeout=10.0) + latencies.append((time.monotonic() - start) * 1000) + count += 1 + except Exception as e: + dropped += 1 + errors.append(str(e)) + + # Rate-limit sleep + elapsed = time.monotonic() - start + if interval > elapsed: + time.sleep(interval - elapsed) + + result_queue.put( + { + "worker_id": worker_id, + "requests": count, + "dropped": dropped, + "latencies": latencies, + "errors": errors[:5], # cap error detail + } + ) + + +def run_load_test(url: str, method: str, duration: float, workers: int, rps: float): + result_queue = multiprocessing.Queue() + processes: List[multiprocessing.Process] = [] + + def sigint_handler(_sig, _frame): + for p in processes: + p.terminate() + sys.exit(0) + + signal.signal(signal.SIGINT, sigint_handler) + + start = time.monotonic() + for i in range(workers): + p = multiprocessing.Process( + target=_worker_process, + args=(i, url, method, duration, rps / workers, result_queue), + ) + p.start() + processes.append(p) + + for p in processes: + p.join() + + elapsed = time.monotonic() - start + total_requests = 0 + total_dropped = 0 + all_latencies: List[float] = [] + for _ in range(workers): + result = result_queue.get() + total_requests += result["requests"] + total_dropped += result["dropped"] + all_latencies.extend(result["latencies"]) + + all_latencies.sort() + mean_latency = sum(all_latencies) / len(all_latencies) if all_latencies else 0.0 + p95 = all_latencies[int(len(all_latencies) * 0.95)] if all_latencies else 0.0 + p99 = all_latencies[int(len(all_latencies) * 0.99)] if all_latencies else 0.0 + actual_tps = total_requests / elapsed if elapsed > 0 else 0.0 + drop_rate = total_dropped / (total_requests + total_dropped) if (total_requests + total_dropped) > 0 else 0.0 + + print("\n=== Load Test Results ===") + print(f"URL : {url}") + print(f"Method : {method}") + print(f"Workers : {workers}") + print(f"Target RPS : {rps}") + print(f"Duration : {elapsed:.1f}s") + print(f"Total reqs : {total_requests}") + print(f"Dropped : {total_dropped} ({drop_rate*100:.2f}%)") + print(f"Actual TPS : {actual_tps:.1f}") + print(f"Latency avg : {mean_latency:.2f} ms") + print(f"Latency p95 : {p95:.2f} ms") + print(f"Latency p99 : {p99:.2f} ms") + + return { + "total_requests": total_requests, + "total_dropped": total_dropped, + "drop_rate": drop_rate, + "actual_tps": actual_tps, + "mean_latency_ms": mean_latency, + "p95_latency_ms": p95, + "p99_latency_ms": p99, + "duration_s": elapsed, + "pass": drop_rate <= 0.01 and actual_tps >= rps * 0.95, + } + + +def main(): + parser = argparse.ArgumentParser(description="RPC load-test harness") + parser.add_argument("--url", default="http://localhost:8545") + parser.add_argument("--method", default="eth_getBalance", choices=["eth_sendTransaction", "eth_getBalance", "eth_blockNumber"]) + parser.add_argument("--duration", type=float, default=30.0, help="Test duration in seconds") + parser.add_argument("--workers", type=int, default=8, help="Number of concurrent worker processes") + parser.add_argument("--rps", type=float, default=500.0, help="Target aggregate requests per second") + parser.add_argument("--latency-budget", type=float, default=200.0, help="Latency budget in ms for pass/fail") + args = parser.parse_args() + + result = run_load_test(args.url, args.method, args.duration, args.workers, args.rps) + + if result["pass"]: + print("\nPASS: TPS and drop-rate within acceptance thresholds.") + sys.exit(0) + else: + print("\nFAIL: Did not meet TPS/drop-rate thresholds.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 0000000..d7c78e2 --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,100 @@ +{ + "name": "jcc-devnet-scripts", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jcc-devnet-scripts", + "version": "0.0.1", + "dependencies": { + "@ethereumjs/rlp": "^5.0.0", + "ethereum-cryptography": "^2.2.0" + } + }, + "node_modules/@ethereumjs/rlp": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-5.0.2.tgz", + "integrity": "sha512-DziebCdg4JpGlEqEdGgXmjqcFoJi+JGulUXwEjsZGAscAQ7MyD/7LE/GVCP29vEQxKc7AAwjT3A2ywHp2xfoCA==", + "license": "MPL-2.0", + "bin": { + "rlp": "bin/rlp.cjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/curves": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", + "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz", + "integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.4.0", + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.3.0.tgz", + "integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethereum-cryptography": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz", + "integrity": "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "1.4.2", + "@noble/hashes": "1.4.0", + "@scure/bip32": "1.4.0", + "@scure/bip39": "1.3.0" + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..1f21dee --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,9 @@ +{ + "name": "jcc-devnet-scripts", + "version": "0.0.1", + "private": true, + "dependencies": { + "ethereum-cryptography": "^2.2.0", + "@ethereumjs/rlp": "^5.0.0" + } +} diff --git a/scripts/run_smoke_tests.sh b/scripts/run_smoke_tests.sh new file mode 100644 index 0000000..a3ccd62 --- /dev/null +++ b/scripts/run_smoke_tests.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +# run_smoke_tests.sh — quick validation suite for the JCC devnet stack. +# Requires: Python 3.x, nothing else (stdlib only). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# 1. Start mock RPC gateway in background +python3 "${SCRIPT_DIR}/rpc_gateway_mock.py" --port 8545 --latency 5 & +RPC_PID=$! +function cleanup_rpc() { + kill "$RPC_PID" 2>/dev/null || true +} +trap cleanup_rpc EXIT + +# Wait for mock to be ready +sleep 2 + +# 2. RPC gateway smoke (eth_getBalance, eth_sendTransaction) +echo "=== RPC Gateway Smoke ===" +curl -sf http://localhost:8545 -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_getBalance","params":["0x0000000000000000000000000000000000000000","latest"],"id":1}' || { echo "RPC getBalance failed"; exit 1; } + +echo "eth_getBalance OK" + +# 3. Load test against mock (short burst to prove harness) +echo "=== Load Test (mock, 10s) ===" +python3 "${SCRIPT_DIR}/load_test.py" --url http://localhost:8545 --method eth_getBalance --duration 10 --workers 4 --rps 500 + +# 4. Foundry compile + test +echo "=== Foundry Build + Test ===" +cd "${SCRIPT_DIR}/../contracts" +forge build +forge test +forge snapshot + +echo "=== All smoke tests passed ==="