From 740c6629272f0446435c5f2fa146d39ff8a4f84f Mon Sep 17 00:00:00 2001 From: BruceJqs <616353876@qq.com> Date: Sat, 13 Jun 2026 19:28:32 +0800 Subject: [PATCH 1/3] Finished D10 example test --- .../D10_eth_explorer/ethereum_pos.py | 263 +++++------ .../blockchain/D10_eth_explorer/example.yaml | 167 +++++++ .../D10_eth_explorer/test_runtime.py | 442 ++++++++++++++++++ 3 files changed, 723 insertions(+), 149 deletions(-) create mode 100644 examples/blockchain/D10_eth_explorer/example.yaml create mode 100644 examples/blockchain/D10_eth_explorer/test_runtime.py diff --git a/examples/blockchain/D10_eth_explorer/ethereum_pos.py b/examples/blockchain/D10_eth_explorer/ethereum_pos.py index 1d5a6976d..8d373f7ea 100644 --- a/examples/blockchain/D10_eth_explorer/ethereum_pos.py +++ b/examples/blockchain/D10_eth_explorer/ethereum_pos.py @@ -1,149 +1,114 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu import * -import sys, os -import math, json -from eth_account import Account - -############################################################################### -# Set the platform information -script_name = os.path.basename(__file__) - -if len(sys.argv) < 2: - print(f"Usage: {script_name} [amd|arm]") - sys.exit(1) - -# Read total number of Ethereum beaconnodes from the command line argument -try: - total_number_of_beaconnodes = int(sys.argv[1]) -except ValueError: - print(f"Invalid number of Ethereum beaconnodes: {sys.argv[1]}") - sys.exit(1) - -# Optional platform argument -if len(sys.argv) == 3: - if sys.argv[2].lower() == 'amd': - platform = Platform.AMD64 - elif sys.argv[2].lower() == 'arm': - platform = Platform.ARM64 - else: - print(f"Usage: {script_name} amd|arm") - sys.exit(1) -else: - platform = Platform.AMD64 # Default platform is AMD64 - -geth_node_number = total_number_of_beaconnodes -beacon_node_number = total_number_of_beaconnodes -vc_node_number = 3 * total_number_of_beaconnodes -beaconsetup_node_number = 1 - -total_number_of_nodes = geth_node_number + beacon_node_number + vc_node_number + beaconsetup_node_number - -# Calculate how many hosts per stub AS are needed -# We know we have 10 stub AS (150-154, 160-164), and at least one node per AS is required -# to host a node (Beacon or Ethereum node). -total_stub_as = 10 # We have 10 ASNs available -hosts_per_stub_as = math.ceil(total_number_of_nodes / total_stub_as) - -# Create Emulator Base with the calculated number of hosts per stub AS -emu = Makers.makeEmulatorBaseWith10StubASAndHosts(hosts_per_stub_as=hosts_per_stub_as) - -print(f"Number of eth nodes per stub AS: {hosts_per_stub_as}") - -# Create the Ethereum layer -eth = EthereumService() -### ethserevice -> blockchain -> ethserver (gethserver/beaconserver/beaconsetup) -# Create the Blockchain layer which is a sub-layer of Ethereum layer. 说明是pos子类 -blockchain = eth.createBlockchain(chainName="pos", consensus=ConsensusMechanism.POS) - -# Generate a list of accounts and prefund them -accounts_total = 1000 -pre_funded_amount = 1000000 -mnemonic = "gentle always fun glass foster produce north tail security list example gain" -Account.enable_unaudited_hdwallet_features() -for i in range(accounts_total): - account = Account.from_mnemonic(mnemonic, account_path=f"m/44'/60'/0'/0/{i}") - blockchain.addLocalAccount(address=account.address, balance=pre_funded_amount) - -asns = [150, 151, 152, 153, 154, 160, 161, 162, 163, 164] - -################################################### - - -geth_nodes: List[PoSGethServer] = [] -beacon_nodes: List[PoSBeaconServer] = [] - -vc_nodes: List[PoSVcServer] = [] - -### create beaconsetupnode -beaconsetupServer: PoSBeaconSetupServer = blockchain.createBeaconSetupNode(f"BeaconSetupNode") -emu.getVirtualNode(f'BeaconSetupNode').setDisplayName('Ethereum-BeaconSetup') -### create gethnode -for i in range(geth_node_number): - gethServer: PoSGethServer = blockchain.createGethNode(f"gethnode{i}") - gethServer.enableGethHttp() - gethServer.appendClassName(f'Ethereum-POS-Geth-{i + 1}') - geth_nodes.append(gethServer) - emu.getVirtualNode(f'gethnode{i}').setDisplayName(f'Ethereum-POS-Geth-{i + 1}') -## create beaconnode -for i in range(beacon_node_number): - beaconServer: PoSBeaconServer = blockchain.createBeaconNode(f"beaconnode{i}") - beaconServer.appendClassName(f'Ethereum-POS-Beacon-{i + 1}') - beaconServer.connectToGethNode(f"gethnode{(i + 1) % len(geth_nodes)}") - beacon_nodes.append(beaconServer) - emu.getVirtualNode(f'beaconnode{i}').setDisplayName(f'Ethereum-POS-Beacon-{i + 1}') - # beaconServer.enablePOSValidatorAtGenesis() -# set bootnode -geth_nodes[0].setBootNode(True) -beacon_nodes[0].setBootNode(True) - -for i in range(vc_node_number): - VcServer: PoSVcServer = blockchain.createVcNode(f"vcnode{i}") - VcServer.appendClassName(f'Ethereum-POS-Validator-{i + 1}') - - VcServer.connectToBeaconNode(f"beaconnode{(i + 1) % len(beacon_nodes)}") - VcServer.enablePOSValidatorAtGenesis() - vc_nodes.append(VcServer) - emu.getVirtualNode(f'vcnode{i}').setDisplayName(f'Ethereum-POS-Validator-{i + 1}') - -assign_index = 0 -total_nodes = len(geth_nodes) + len(beacon_nodes) + len(vc_nodes) -for asn in asns: - for id in range(hosts_per_stub_as): - if asn == 152 and id == 0: - emu.addBinding(Binding('BeaconSetupNode', - filter=Filter(asn=asn, nodeName=f'^host_{id}$'), - action=Action.FIRST)) - else: - if assign_index >= total_nodes: - continue - if assign_index < len(geth_nodes): - name = f'gethnode{assign_index}' - elif assign_index < len(geth_nodes) + len(beacon_nodes): - name = f'beaconnode{assign_index - len(geth_nodes)}' - else: - name = f'vcnode{assign_index - len(geth_nodes) - len(beacon_nodes)}' - emu.addBinding(Binding(name, - filter=Filter(asn=asn, nodeName=f'^host_{id}$'), - action=Action.FIRST)) - assign_index += 1 - -# Add Ethereum layer to the emulator -emu.addLayer(eth) -base_layer = emu.getLayer('Base') -for asn in asns: - as_obj = base_layer.getAutonomousSystem(asn) - net = as_obj.getNetwork('net0') - # Extend host IP range from 71-99 to 71-199 (supports 129 hosts, enough for 50 per AS) - # Router range is 200-254, so host must end at 199 to avoid conflict - # Move DHCP range to 51-70 to avoid conflict with extended host range (71-199) - net.setHostIpRange(hostStart=71, hostEnd=199, hostStep=1) - -emu.render() - -# Enable internetMap and etherView for visualization -docker = Docker(internetMapEnabled=True, etherViewEnabled=True, platform=platform) - -# Compile the emulator to output -emu.compile(docker, './output', override=True) +#!/usr/bin/env python3 +# encoding: utf-8 + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from seedemu import Platform +from examples.blockchain.D01_ethereum_pos import ethereum_pos as d01_ethereum_pos + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the D10 Ethereum PoS EthExplorer example.") + parser.add_argument( + "legacy_args", + nargs="*", + help="legacy form: [amd|arm]", + ) + parser.add_argument("--platform", choices=["amd", "arm"]) + parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) + parser.add_argument("--dumpfile") + parser.add_argument( + "--beacon-nodes", + type=int, + default=3, + help="number of geth/beacon node pairs", + ) + parser.add_argument( + "--validators-per-beacon", + type=int, + default=3, + help="validator clients per beacon node", + ) + parser.add_argument("--override", dest="override", action="store_true", default=True) + parser.add_argument("--no-override", dest="override", action="store_false") + parser.add_argument("--skip-render", dest="render", action="store_false", default=True) + args = parser.parse_args() + + if len(args.legacy_args) > 2: + parser.error("legacy arguments must be: [amd|arm]") + + legacy_platform = None + if args.legacy_args: + first = args.legacy_args[0].lower() + if first in ("amd", "arm"): + legacy_platform = first + else: + try: + args.beacon_nodes = int(first) + except ValueError: + parser.error("legacy beacon node count must be an integer") + + if len(args.legacy_args) == 2: + legacy_platform = args.legacy_args[1].lower() + if legacy_platform not in ("amd", "arm"): + parser.error("legacy platform must be amd or arm") + + args.platform = args.platform or legacy_platform or "amd" + if args.beacon_nodes < 1: + parser.error("--beacon-nodes must be >= 1") + if args.validators_per_beacon < 1: + parser.error("--validators-per-beacon must be >= 1") + return args + + +def resolve_platform(name: str) -> Platform: + return Platform.AMD64 if name == "amd" else Platform.ARM64 + + +def run( + dumpfile=None, + total_beacon_nodes: int = 3, + vc_per_beacon: int = 3, + output=None, + platform=Platform.AMD64, + override: bool = True, + render: bool = True, +): + d01_ethereum_pos.run( + dumpfile=dumpfile, + total_beacon_nodes=total_beacon_nodes, + vc_per_beacon=vc_per_beacon, + output=output, + platform=platform, + override=override, + render=render, + ether_view_enabled=True, + ) + + +def main() -> int: + args = parse_args() + run( + dumpfile=args.dumpfile, + total_beacon_nodes=args.beacon_nodes, + vc_per_beacon=args.validators_per_beacon, + output=str(Path(args.output).resolve()), + platform=resolve_platform(args.platform), + override=args.override, + render=args.render, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/blockchain/D10_eth_explorer/example.yaml b/examples/blockchain/D10_eth_explorer/example.yaml new file mode 100644 index 000000000..d981c249d --- /dev/null +++ b/examples/blockchain/D10_eth_explorer/example.yaml @@ -0,0 +1,167 @@ +id: blockchain-d10-eth-explorer +name: Ethereum PoS Private Chain with EthExplorer +description: A D01-style Ethereum proof-of-stake private chain with the EthExplorer visualization enabled and tested. +runner: blockchain +script: ethereum_pos.py +platform: amd +features: + - ethereum-pos + - geth + - lighthouse + - validators-at-genesis + - ethereum-transaction + - eth-explorer + +compile: + enabled: true + output: output + clean: + - output + expected: + - output/docker-compose.yml + timeout: 1800 + +build: + enabled: true + timeout: 3600 + +runtime: + compose: output/docker-compose.yml + readiness: + - name: D10 Geth execution nodes are running + type: ethereum-compose-ps + class_contains: Ethereum-POS-Geth + consensus: POS + expected_count: 3 + retries: 60 + interval: 3 + + - name: D10 Lighthouse beacon nodes are running + type: ethereum-compose-ps + class_contains: Ethereum-POS-Beacon + consensus: POS + expected_count: 3 + retries: 60 + interval: 3 + + - name: D10 genesis validator clients are running + type: ethereum-compose-ps + class_contains: Ethereum-POS-Validator + role: validator_at_genesis + consensus: POS + expected_count: 9 + retries: 60 + interval: 3 + + - name: D10 beacon setup service is running + type: ethereum-compose-ps + display_contains: Ethereum-POS-BeaconSetup + expected_count: 1 + retries: 60 + interval: 3 + + - name: D10 Faucet service is running + type: ethereum-compose-ps + class_contains: FaucetService + expected_count: 1 + retries: 60 + interval: 3 + + - name: D10 Utility service is running + type: ethereum-compose-ps + class_contains: EthUtilityServer + expected_count: 1 + retries: 60 + interval: 3 + + - name: D10 EthExplorer services are running + type: compose-ps + services: + - clickhouse + - postgres + - alloy + - redis + - littlebigtable + - rawbigtable + - seedemu-ethexplorer-backend + - seedemu-ethexplorer-web + retries: 90 + interval: 5 + + + - name: D10 EthExplorer homepage is ready + type: http + url: http://127.0.0.1:5000/ + retries: 90 + interval: 5 + timeout: 10 + +probes: + - name: D10 Geth process is active + type: ethereum-exec + class_contains: Ethereum-POS-Geth + consensus: POS + command: pgrep geth >/dev/null + retries: 30 + interval: 3 + + - name: D10 Lighthouse beacon process is active + type: ethereum-exec + class_contains: Ethereum-POS-Beacon + consensus: POS + command: "pgrep -af 'lighthouse.* bn ' >/dev/null" + retries: 30 + interval: 3 + + - name: D10 Lighthouse validator process is active + type: ethereum-exec + class_contains: Ethereum-POS-Validator + role: validator_at_genesis + consensus: POS + command: "pgrep -af 'lighthouse.* vc ' >/dev/null" + retries: 30 + interval: 3 + + - name: D10 beacon setup produced genesis state + type: ethereum-exec + display_contains: Ethereum-POS-BeaconSetup + command: test -s /local-testnet/testnet/genesis.ssz + retries: 30 + interval: 3 + + - name: D10 Geth IPC exposes a funded local account + type: ethereum-exec + class_contains: Ethereum-POS-Geth + consensus: POS + command: "geth attach --exec 'eth.accounts.length > 0' | grep -q true" + retries: 30 + interval: 3 + + - name: D10 PoS chain produces execution blocks + type: ethereum-block-progress + class_contains: Ethereum-POS-Geth + consensus: POS + min_delta: 1 + block_retries: 60 + block_interval: 5 + timeout: 60 + + - name: D10 EthExplorer API docs are reachable + type: http + url: http://127.0.0.1:5000/api/v1/docs + retries: 30 + interval: 5 + timeout: 10 + + - name: D10 EthExplorer latest-state API is reachable + type: http + url: http://127.0.0.1:5000/api/v1/latestState + expect_body_regex: "(?i)(slot|epoch|head|finalized)" + retries: 90 + interval: 5 + timeout: 10 + +test_programs: + - name: D10 Ethereum transaction and EthExplorer validation + script: test_runtime.py + timeout: 1500 diff --git a/examples/blockchain/D10_eth_explorer/test_runtime.py b/examples/blockchain/D10_eth_explorer/test_runtime.py new file mode 100644 index 000000000..9e33a9f92 --- /dev/null +++ b/examples/blockchain/D10_eth_explorer/test_runtime.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Callable, Dict, Iterable, Optional + +from seedemu.testing import EthereumRuntimeTest + + +EXPLORER_URL = os.environ.get("D10_ETH_EXPLORER_URL", "http://127.0.0.1:5000").rstrip("/") +EXPLORER_SERVICE = "seedemu-ethexplorer-web" + + +def record(test: EthereumRuntimeTest, name: str, command: str, ok: bool, stdout: str = "", stderr: str = "") -> None: + test.results.append( + { + "name": name, + "service": EXPLORER_SERVICE, + "command": command, + "exit": 0 if ok else 1, + "stdout": stdout[-1000:], + "stderr": stderr[-1000:], + "status": "passed" if ok else "failed", + } + ) + + +def fetch(path: str, *, method: str = "GET", data: Optional[bytes] = None, timeout: int = 10) -> tuple[int, str]: + url = EXPLORER_URL + path + request = urllib.request.Request(url, data=data, method=method) + if data is not None: + request.add_header("Content-Type", "application/x-www-form-urlencoded") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = response.read().decode("utf-8", errors="replace") + return response.status, body + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + return exc.code, body + + +def wait_for_http( + test: EthereumRuntimeTest, + name: str, + path: str, + *, + predicate: Optional[Callable[[str], bool]] = None, + method: str = "GET", + data: Optional[bytes] = None, + retries: int = 90, + interval: int = 5, + timeout: int = 10, +) -> Optional[str]: + last = "" + command = f"HTTP {method} {EXPLORER_URL}{path}" + for attempt in range(1, retries + 1): + try: + status, body = fetch(path, method=method, data=data, timeout=timeout) + except Exception as exc: + last = str(exc) + else: + last = f"status={status} body={body[:500]}" + if 200 <= status < 300 and (predicate is None or predicate(body)): + record(test, name, command, True, f"attempts={attempt} {last}") + return body + if attempt < retries: + time.sleep(interval) + record(test, name, command, False, last) + return None + + +def wait_for_any_http( + test: EthereumRuntimeTest, + name: str, + paths: Iterable[str], + *, + predicate: Optional[Callable[[str], bool]] = None, + retries: int = 60, + interval: int = 5, + timeout: int = 10, +) -> Optional[tuple[str, str]]: + candidates = list(paths) + last = "" + command = "HTTP GET " + ", ".join(EXPLORER_URL + path for path in candidates) + for attempt in range(1, retries + 1): + for path in candidates: + try: + status, body = fetch(path, timeout=timeout) + except Exception as exc: + last = f"{path}: {exc}" + continue + last = f"{path}: status={status} body={body[:500]}" + if 200 <= status < 300 and (predicate is None or predicate(body)): + record(test, name, command, True, f"attempts={attempt} path={path} body={body[:500]}") + return path, body + if attempt < retries: + time.sleep(interval) + record(test, name, command, False, last) + return None + + +def parse_json_body(body: Optional[str]) -> Optional[Any]: + if body is None: + return None + try: + return json.loads(body) + except json.JSONDecodeError: + return None + + +def int_from_value(value: object) -> Optional[int]: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str): + text = value.strip() + if text.startswith(("0x", "0X")): + try: + return int(text, 16) + except ValueError: + return None + if text.isdigit(): + return int(text) + return None + + +def find_int_by_key(data: Any, key_names: tuple[str, ...], contains: str) -> Optional[int]: + if isinstance(data, dict): + for key, value in data.items(): + key_text = str(key).lower() + if key_text in key_names: + parsed = int_from_value(value) + if parsed is not None: + return parsed + for key, value in data.items(): + key_text = str(key).lower() + if contains in key_text: + parsed = int_from_value(value) + if parsed is not None: + return parsed + found = find_int_by_key(value, key_names, contains) + if found is not None: + return found + elif isinstance(data, list): + for item in data: + found = find_int_by_key(item, key_names, contains) + if found is not None: + return found + return None + + +def body_has_any(*needles: str) -> Callable[[str], bool]: + lowered = [needle.lower() for needle in needles] + + def predicate(body: str) -> bool: + text = body.lower() + return any(needle in text for needle in lowered) + + return predicate + + +def body_has_text(text: str) -> Callable[[str], bool]: + text_lower = text.lower() + + def predicate(body: str) -> bool: + return text_lower in body.lower() + + return predicate + + +def datatable_query() -> str: + return urllib.parse.urlencode({"draw": 1, "start": 0, "length": 10}) + + +def main() -> int: + test = EthereumRuntimeTest(__file__) + + geth_nodes = test.require_ethereum_services( + "D10 has Geth nodes for the transaction and explorer tests", + expected_count=3, + class_contains="Ethereum-POS-Geth", + consensus="POS", + ) + test.require_ethereum_services( + "D10 has Lighthouse beacon nodes for EthExplorer", + expected_count=3, + class_contains="Ethereum-POS-Beacon", + consensus="POS", + ) + test.require_ethereum_services( + "D10 has genesis validator clients for EthExplorer", + expected_count=9, + class_contains="Ethereum-POS-Validator", + role="validator_at_genesis", + consensus="POS", + ) + + tx_summary: Dict[str, Any] = {} + if geth_nodes: + tx_result = test.send_ether_and_verify( + "A signed ETH transfer is included and changes balances", + geth_nodes[0], + value_wei=10**18, + ) + if tx_result["status"] == "passed": + try: + tx_summary = json.loads(str(tx_result["stdout"])) + record( + test, + "D10 signed transaction fixture is available for EthExplorer checks", + "send Ethereum transaction and parse transaction summary", + True, + json.dumps(tx_summary, sort_keys=True), + ) + except json.JSONDecodeError as exc: + record(test, "D10 transaction summary is parseable", "parse transaction summary", False, str(exc)) + + latest_body = wait_for_http( + test, + "EthExplorer latest-state API exposes chain status", + "/api/v1/latestState", + predicate=body_has_any("slot", "epoch", "head", "finalized"), + retries=120, + interval=5, + ) + latest_data = parse_json_body(latest_body) + latest_slot = find_int_by_key( + latest_data, + ("slot", "currentslot", "headslot", "latestslot", "finalizedslot"), + "slot", + ) + latest_epoch = find_int_by_key( + latest_data, + ("epoch", "currentepoch", "latestepoch", "finalizedepoch"), + "epoch", + ) + + if latest_slot is None: + latest_slot = 1 + record(test, "EthExplorer latest-state slot is parseable", "parse /api/v1/latestState slot", False, latest_body or "") + else: + record(test, "EthExplorer latest-state slot is parseable", "parse /api/v1/latestState slot", True, str(latest_slot)) + if latest_epoch is None: + latest_epoch = max(latest_slot // 32, 0) + record(test, "EthExplorer latest-state epoch fallback is available", "derive epoch from slot", True, str(latest_epoch)) + else: + record(test, "EthExplorer latest-state epoch is parseable", "parse /api/v1/latestState epoch", True, str(latest_epoch)) + + slot_candidates = [] + for candidate in (latest_slot, latest_slot - 1, 1, 0): + if candidate >= 0 and candidate not in slot_candidates: + slot_candidates.append(candidate) + slot_result = wait_for_any_http( + test, + "EthExplorer consensus slot API returns indexed slot data", + [f"/api/v1/slot/{slot}" for slot in slot_candidates], + predicate=body_has_any("slot", "block", "proposer", "epoch"), + retries=120, + interval=5, + ) + indexed_slot = slot_candidates[0] + if slot_result is not None: + indexed_slot = int(slot_result[0].rsplit("/", 1)[-1]) + + wait_for_http( + test, + "EthExplorer consensus slot UI renders indexed slot", + f"/slot/{indexed_slot}", + predicate=body_has_any("slot", "block", str(indexed_slot)), + retries=60, + interval=5, + ) + wait_for_http( + test, + "EthExplorer epoch API returns epoch data", + f"/api/v1/epoch/{latest_epoch}", + predicate=body_has_any("epoch", "validators", "blocks", str(latest_epoch)), + retries=90, + interval=5, + ) + wait_for_http( + test, + "EthExplorer epoch UI renders epoch page", + f"/epoch/{latest_epoch}", + predicate=body_has_any("epoch", str(latest_epoch)), + retries=60, + interval=5, + ) + + wait_for_http( + test, + "EthExplorer validator UI renders validator 0", + "/validator/0", + predicate=body_has_any("validator", "pubkey", "balance", "status"), + retries=90, + interval=5, + ) + wait_for_http( + test, + "EthExplorer validators list UI renders", + "/validators", + predicate=body_has_any("validator", "validators"), + retries=60, + interval=5, + ) + wait_for_http( + test, + "EthExplorer validators data endpoint returns table data", + "/validators/data?" + datatable_query(), + predicate=body_has_any("data", "validator", "pubkey"), + retries=90, + interval=5, + ) + + wait_for_http( + test, + "EthExplorer slots list data endpoint returns table data", + "/slots/data?" + datatable_query(), + predicate=body_has_any("data", "slot", "epoch"), + retries=90, + interval=5, + ) + wait_for_http( + test, + "EthExplorer index data endpoint returns chain activity", + "/index/data", + predicate=body_has_any("slot", "epoch", "block", "validator"), + retries=90, + interval=5, + ) + + tx_hash = str(tx_summary.get("tx_hash", "")) + block_number = tx_summary.get("block_number") + if block_number is None: + record(test, "D10 transaction block number is available for explorer lookup", "read transaction summary", False, json.dumps(tx_summary)) + else: + block_text = str(block_number) + wait_for_http( + test, + "EthExplorer execution block API returns transaction block", + f"/api/v1/execution/block/{block_text}", + predicate=body_has_any("block", block_text), + retries=120, + interval=5, + ) + wait_for_http( + test, + "EthExplorer execution block UI renders transaction block", + f"/block/{block_text}", + predicate=body_has_any("block", block_text), + retries=90, + interval=5, + ) + if tx_hash: + wait_for_http( + test, + "EthExplorer execution block transactions endpoint indexes the signed transfer", + f"/block/{block_text}/transactions?" + datatable_query(), + predicate=body_has_any(tx_hash, tx_hash.removeprefix("0x")), + retries=120, + interval=5, + ) + wait_for_http( + test, + "EthExplorer execution blocks list UI renders", + "/blocks", + predicate=body_has_any("block", "blocks"), + retries=60, + interval=5, + ) + wait_for_http( + test, + "EthExplorer execution blocks data endpoint returns table data", + "/blocks/data?" + datatable_query(), + predicate=body_has_any("data", "block"), + retries=90, + interval=5, + ) + + if tx_hash: + wait_for_http( + test, + "EthExplorer transaction UI renders the signed transfer", + f"/tx/{tx_hash}", + predicate=body_has_text(tx_hash), + retries=120, + interval=5, + ) + wait_for_http( + test, + "EthExplorer transactions data endpoint indexes the signed transfer", + "/transactions/data?" + datatable_query(), + predicate=body_has_any(tx_hash, tx_hash.removeprefix("0x")), + retries=120, + interval=5, + ) + search_data = urllib.parse.urlencode({"search": tx_hash}).encode("utf-8") + wait_for_http( + test, + "EthExplorer search form can find the signed transfer", + "/search", + method="POST", + data=search_data, + predicate=body_has_any(tx_hash, tx_hash.removeprefix("0x"), "transaction"), + retries=30, + interval=5, + ) + else: + record(test, "D10 transaction hash is available for explorer lookup", "read transaction summary", False, json.dumps(tx_summary)) + + wait_for_http( + test, + "EthExplorer transactions list UI renders", + "/transactions", + predicate=body_has_any("transaction", "transactions"), + retries=60, + interval=5, + ) + wait_for_http( + test, + "EthExplorer transactions data endpoint returns table data", + "/transactions/data?" + datatable_query(), + predicate=body_has_any("data", "transaction", "hash"), + retries=90, + interval=5, + ) + + test.write_summary("d10-eth-explorer-runtime-test.json") + return test.exit_code() + + +if __name__ == "__main__": + raise SystemExit(main()) From 250b7806599163019b5e76cbbdef092abb75d6c4 Mon Sep 17 00:00:00 2001 From: BruceJqs <616353876@qq.com> Date: Sat, 13 Jun 2026 22:40:35 +0800 Subject: [PATCH 2/3] Finished D20 example test & Fix bug in Faucetservice to fit ubuntu24.04 --- examples/blockchain/D20_faucet/example.yaml | 142 +++++++ examples/blockchain/D20_faucet/faucet.py | 229 +++++++---- .../blockchain/D20_faucet/test_runtime.py | 356 ++++++++++++++++++ .../files_faucet/faucet_server.py | 3 + .../EthTemplates/files_faucet/fundme.py | 3 + .../files_utility/deploy_contract.py | 3 + .../files_utility/fund_account.py | 3 + .../files_utility/utility_server.py | 3 + .../EthereumService/FaucetUserService.py | 2 +- 9 files changed, 666 insertions(+), 78 deletions(-) create mode 100644 examples/blockchain/D20_faucet/example.yaml create mode 100644 examples/blockchain/D20_faucet/test_runtime.py diff --git a/examples/blockchain/D20_faucet/example.yaml b/examples/blockchain/D20_faucet/example.yaml new file mode 100644 index 000000000..8a06dd703 --- /dev/null +++ b/examples/blockchain/D20_faucet/example.yaml @@ -0,0 +1,142 @@ +id: blockchain-d20-faucet-pos +name: Ethereum PoS Faucet +description: A D01-style Ethereum proof-of-stake chain with Faucet build-time and runtime funding tests. +runner: blockchain +script: faucet.py +platform: amd +features: + - ethereum-pos + - geth + - lighthouse + - validators-at-genesis + - faucet + - faucet-user + +compile: + enabled: true + output: output + args: + - --consensus + - pos + - --no-ether-view + clean: + - output + expected: + - output/docker-compose.yml + timeout: 1800 + +build: + enabled: true + timeout: 3600 + +runtime: + compose: output/docker-compose.yml + readiness: + - name: D20 POS Geth execution nodes are running + type: ethereum-compose-ps + class_contains: Ethereum-POS-Geth + consensus: POS + expected_count: 3 + retries: 60 + interval: 3 + + - name: D20 POS Lighthouse beacon nodes are running + type: ethereum-compose-ps + class_contains: Ethereum-POS-Beacon + consensus: POS + expected_count: 3 + retries: 60 + interval: 3 + + - name: D20 POS genesis validator clients are running + type: ethereum-compose-ps + class_contains: Ethereum-POS-Validator + role: validator_at_genesis + consensus: POS + expected_count: 9 + retries: 60 + interval: 3 + + - name: D20 POS beacon setup service is running + type: ethereum-compose-ps + display_contains: Ethereum-POS-BeaconSetup + expected_count: 1 + retries: 60 + interval: 3 + + - name: D20 Faucet service is running + type: ethereum-compose-ps + class_contains: FaucetService + expected_count: 1 + retries: 60 + interval: 3 + + - name: D20 Faucet user service is running + type: ethereum-compose-ps + class_contains: FaucetUserService + expected_count: 1 + retries: 60 + interval: 3 + + - name: D20 Utility service is running + type: ethereum-compose-ps + class_contains: EthUtilityServer + expected_count: 1 + retries: 60 + interval: 3 + +probes: + - name: D20 POS Geth process is active + type: ethereum-exec + class_contains: Ethereum-POS-Geth + consensus: POS + command: pgrep geth >/dev/null + retries: 30 + interval: 3 + + - name: D20 POS Lighthouse beacon process is active + type: ethereum-exec + class_contains: Ethereum-POS-Beacon + consensus: POS + command: "pgrep -af 'lighthouse.* bn ' >/dev/null" + retries: 30 + interval: 3 + + - name: D20 POS Lighthouse validator process is active + type: ethereum-exec + class_contains: Ethereum-POS-Validator + role: validator_at_genesis + consensus: POS + command: "pgrep -af 'lighthouse.* vc ' >/dev/null" + retries: 30 + interval: 3 + + - name: D20 POS beacon setup produced genesis state + type: ethereum-exec + display_contains: Ethereum-POS-BeaconSetup + command: test -s /local-testnet/testnet/genesis.ssz + retries: 30 + interval: 3 + + - name: D20 Faucet HTTP health endpoint returns OK + type: ethereum-exec + class_contains: FaucetService + command: >- + python3 -c 'import urllib.request; print(urllib.request.urlopen("http://127.0.0.1:80/", timeout=5).read().decode())' | grep -q '^OK$' + retries: 60 + interval: 5 + timeout: 15 + + - name: D20 POS chain produces execution blocks + type: ethereum-block-progress + class_contains: Ethereum-POS-Geth + consensus: POS + min_delta: 1 + block_retries: 60 + block_interval: 5 + timeout: 60 + +test_programs: + - name: D20 POS Faucet funding validation + script: test_runtime.py + timeout: 1800 diff --git a/examples/blockchain/D20_faucet/faucet.py b/examples/blockchain/D20_faucet/faucet.py index 56ce8f75a..6877baea9 100755 --- a/examples/blockchain/D20_faucet/faucet.py +++ b/examples/blockchain/D20_faucet/faucet.py @@ -1,85 +1,160 @@ #!/usr/bin/env python3 # encoding: utf-8 -import sys, os +from __future__ import annotations + +import argparse +import os +from contextlib import contextmanager +from pathlib import Path +import sys +import tempfile + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + from seedemu import * from examples.blockchain.D00_ethereum_poa import ethereum_poa from examples.blockchain.D01_ethereum_pos import ethereum_pos -############################################################################### -# Set the platform information -script_name = os.path.basename(__file__) - -consensus = 'poa' -platform = Platform.AMD64 - -def print_help(): - print(f"Usage: {script_name} [poa|pos] [amd|arm]") - print(f"Options:") - print(f" poa|pos Set the consensus mechanism (default: poa)") - print(f" amd|arm Set the platform architecture (default: amd)") - print(f" -h, --help Show this help message") - -if len(sys.argv) >= 2: - arg1 = sys.argv[1].lower() - if arg1 in ['-h', '--help']: - print_help() - sys.exit(0) - if arg1 in ['poa', 'pos']: - consensus = arg1 - if len(sys.argv) == 3: - arg2 = sys.argv[2].lower() - if arg2 == 'amd': - platform = Platform.AMD64 - elif arg2 == 'arm': - platform = Platform.ARM64 - else: - print_help() - sys.exit(1) - elif len(sys.argv) > 3: - print_help() - sys.exit(1) - else: - if arg1 == 'amd': - platform = Platform.AMD64 - elif arg1 == 'arm': - platform = Platform.ARM64 + +@contextmanager +def pushd(directory: Path): + previous = Path.cwd() + os.chdir(directory) + try: + yield + finally: + os.chdir(previous) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the D20 Faucet example.") + parser.add_argument( + "legacy_args", + nargs="*", + help="legacy form: [poa|pos] [amd|arm]", + ) + parser.add_argument("--consensus", choices=["poa", "pos"]) + parser.add_argument("--platform", choices=["amd", "arm"]) + parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) + parser.add_argument("--dumpfile") + parser.add_argument( + "--ether-view", + dest="ether_view_enabled", + action="store_true", + default=True, + help="enable Eth Explorer output in generated Docker files (default)", + ) + parser.add_argument( + "--no-ether-view", + dest="ether_view_enabled", + action="store_false", + help="disable Eth Explorer output; useful for CI builds", + ) + parser.add_argument("--override", dest="override", action="store_true", default=True) + parser.add_argument("--no-override", dest="override", action="store_false") + parser.add_argument("--skip-render", dest="render", action="store_false", default=True) + args = parser.parse_args() + + legacy_consensus = None + legacy_platform = None + for item in args.legacy_args: + value = item.lower() + if value in ("poa", "pos") and legacy_consensus is None: + legacy_consensus = value + elif value in ("amd", "arm") and legacy_platform is None: + legacy_platform = value else: - print_help() - sys.exit(1) - -emu = Emulator() - -if consensus == 'pos': - local_dump_path = './blockchain_pos.bin' - ethereum_pos.run(dumpfile=local_dump_path) -else: - local_dump_path = './blockchain_poa.bin' - ethereum_poa.run(dumpfile=local_dump_path, total_accounts_per_node=1) -emu.load(local_dump_path) - -# Get the faucet server instance -eth = emu.getLayer('EthereumService') -blockchain = eth.getBlockchainByName(eth.getBlockchainNames()[0]) -faucet_name = blockchain.getFaucetServerNames()[0] -faucet = blockchain.getFaucetServerByName(faucet_name) - -# Funding accounts during the build time. The actual funding -# is carried out after the emulator starts -faucet.fund('0x72943017a1fa5f255fc0f06625aec22319fcd5b3', 2) -faucet.fund('0x5449ba5c5f185e9694146d60cfe72681e2158499', 5) - -# Funding accounts during the run time, i.e., after the emulation starts -faucetUserService = FaucetUserService() -faucetUserService.install('faucetUser').setDisplayName('FaucetUser') -faucet_info = blockchain.getFaucetServerInfo() -faucetUserService.setFaucetServerInfo(faucet_info[0]['name'], - faucet_info[0]['port']) -emu.addBinding(Binding('faucetUser')) -emu.addLayer(faucetUserService) - -emu.render() - -docker = Docker(etherViewEnabled=True, platform=platform) -emu.compile(docker, './output', override=True) -# user_node.print(indent=4) \ No newline at end of file + parser.error("legacy arguments must be: [poa|pos] [amd|arm]") + + args.consensus = args.consensus or legacy_consensus or "poa" + args.platform = args.platform or legacy_platform or "amd" + return args + + +def resolve_platform(name: str) -> Platform: + return Platform.AMD64 if name == "amd" else Platform.ARM64 + + +def load_base_blockchain(consensus: str) -> Emulator: + emu = Emulator() + with tempfile.TemporaryDirectory(prefix="seedemu-d20-") as tempdir: + temp_path = Path(tempdir) + dump_path = temp_path / f"blockchain_{consensus}.bin" + with pushd(temp_path): + if consensus == "pos": + ethereum_pos.run(dumpfile=str(dump_path)) + else: + ethereum_poa.run(dumpfile=str(dump_path), total_accounts_per_node=1) + emu.load(str(dump_path)) + return emu + + +def build_emulator(consensus: str = "poa") -> Emulator: + emu = load_base_blockchain(consensus) + + eth = emu.getLayer("EthereumService") + blockchain = eth.getBlockchainByName(eth.getBlockchainNames()[0]) + faucet_name = blockchain.getFaucetServerNames()[0] + faucet = blockchain.getFaucetServerByName(faucet_name) + + # Funding accounts during build time. The actual funding is carried out + # after the emulation starts. + faucet.fund("0x72943017a1fa5f255fc0f06625aec22319fcd5b3", 2) + faucet.fund("0x5449ba5c5f185e9694146d60cfe72681e2158499", 5) + + # Funding accounts during run time, i.e., after the emulation starts. + faucet_user_service = FaucetUserService() + faucet_user_service.install("faucetUser").setDisplayName("FaucetUser") + faucet_info = blockchain.getFaucetServerInfo() + faucet_user_service.setFaucetServerInfo(faucet_info[0]["name"], faucet_info[0]["port"]) + emu.addBinding(Binding("faucetUser")) + emu.addLayer(faucet_user_service) + + return emu + + +def run( + *, + consensus: str = "poa", + dumpfile=None, + output=None, + platform=Platform.AMD64, + override: bool = True, + render: bool = True, + ether_view_enabled: bool = True, +): + emu = build_emulator(consensus=consensus) + if dumpfile is not None: + emu.dump(dumpfile) + return + + if render: + emu.render() + + output_dir = Path(output or SCRIPT_DIR / "output").resolve() + output_dir.parent.mkdir(parents=True, exist_ok=True) + docker = Docker(etherViewEnabled=ether_view_enabled, platform=platform) + emu.compile(docker, str(output_dir), override=override) + + +def main() -> int: + args = parse_args() + run( + consensus=args.consensus, + dumpfile=args.dumpfile, + output=str(Path(args.output).resolve()), + platform=resolve_platform(args.platform), + override=args.override, + render=args.render, + ether_view_enabled=args.ether_view_enabled, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/blockchain/D20_faucet/test_runtime.py b/examples/blockchain/D20_faucet/test_runtime.py new file mode 100644 index 000000000..2f810b841 --- /dev/null +++ b/examples/blockchain/D20_faucet/test_runtime.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import re +import shlex +import time +from typing import Dict, Optional + +from seedemu.testing import EthereumRuntimeTest + + +WEI_PER_ETH = 10**18 +BUILD_TIME_FUNDS = ( + ("0x72943017a1fa5f255fc0f06625aec22319fcd5b3", 2), + ("0x5449ba5c5f185e9694146d60cfe72681e2158499", 5), +) +JSON_REQUEST_RECIPIENT = "0x1000000000000000000000000000000000000020" +FORM_REQUEST_RECIPIENT = "0x1000000000000000000000000000000000000021" +FAUCET_USER_RECIPIENT = "0x1000000000000000000000000000000000000022" +INVALID_RECIPIENT = "0xnot-an-ethereum-address" + + +def service_name(service: object) -> str: + return getattr(service, "name", str(service)) + + +def record( + test: EthereumRuntimeTest, + name: str, + service: object, + command: str, + ok: bool, + stdout: str = "", + stderr: str = "", +) -> Dict[str, object]: + result = { + "name": name, + "service": service_name(service), + "command": command, + "exit": 0 if ok else 1, + "stdout": stdout[-1000:], + "stderr": stderr[-1000:], + "status": "passed" if ok else "failed", + } + test.results.append(result) + return result + + +def int_from_geth_output(output: object) -> int: + matches = re.findall(r"\b\d+\b", str(output)) + if not matches: + raise RuntimeError("could not parse integer from geth output: {}".format(output)) + return int(matches[-1]) + + +def get_balance_wei(test: EthereumRuntimeTest, geth_service: object, address: str) -> int: + result = test.geth_eval(geth_service, 'eth.getBalance("{}").toString(10)'.format(address), timeout=60) + if result["exit"] != 0: + raise RuntimeError(result["stderr"] or result["stdout"] or "geth balance query failed") + return int_from_geth_output(result["stdout"]) + + +def wait_for_balance_at_least( + test: EthereumRuntimeTest, + name: str, + geth_service: object, + address: str, + minimum_wei: int, + *, + retries: int = 60, + interval: int = 5, +) -> Optional[int]: + last_balance: Optional[int] = None + last_error = "" + for attempt in range(1, retries + 1): + try: + last_balance = get_balance_wei(test, geth_service, address) + except Exception as exc: + last_error = str(exc) + else: + if last_balance >= minimum_wei: + record( + test, + name, + geth_service, + "eth.getBalance({}) >= {}".format(address, minimum_wei), + True, + "balance={} attempts={}".format(last_balance, attempt), + ) + return last_balance + if attempt < retries: + time.sleep(interval) + + record( + test, + name, + geth_service, + "eth.getBalance({}) >= {}".format(address, minimum_wei), + False, + "last_balance={}".format(last_balance), + last_error, + ) + return last_balance + + +def wait_for_balance_increase( + test: EthereumRuntimeTest, + name: str, + geth_service: object, + address: str, + before_wei: int, + delta_wei: int, +) -> None: + wait_for_balance_at_least( + test, + name, + geth_service, + address, + before_wei + delta_wei, + retries=60, + interval=5, + ) + + +def faucet_request_command(url: str, address: str, amount: int, mode: str, expected_status: int, expect_text: str) -> str: + script = ''' +import json +import sys +import urllib.error +import urllib.parse +import urllib.request + +url = __URL__ +address = __ADDRESS__ +amount = __AMOUNT__ +mode = __MODE__ +expected_status = __EXPECTED_STATUS__ +expect_text = __EXPECT_TEXT__.lower() + +headers = {} +if mode == "json": + body = json.dumps({"address": address, "amount": amount}).encode("utf-8") + headers["Content-Type"] = "application/json" +elif mode == "form": + body = urllib.parse.urlencode({"address": address, "amount": amount}).encode("utf-8") + headers["Content-Type"] = "application/x-www-form-urlencoded" +else: + raise SystemExit("unknown request mode: {}".format(mode)) + +request = urllib.request.Request(url, data=body, headers=headers, method="POST") +try: + with urllib.request.urlopen(request, timeout=360) as response: + status = response.status + text = response.read().decode("utf-8", errors="replace") +except urllib.error.HTTPError as exc: + status = exc.code + text = exc.read().decode("utf-8", errors="replace") + +print("status={}".format(status)) +print(text) +if status != expected_status: + raise SystemExit(1) +if expect_text and expect_text not in text.lower(): + raise SystemExit(1) +'''.strip() + replacements = { + "__URL__": json.dumps(url), + "__ADDRESS__": json.dumps(address), + "__AMOUNT__": str(int(amount)), + "__MODE__": json.dumps(mode), + "__EXPECTED_STATUS__": str(int(expected_status)), + "__EXPECT_TEXT__": json.dumps(expect_text), + } + for old, new in replacements.items(): + script = script.replace(old, new) + return "python3 -c {}".format(shlex.quote(script)) + + +def send_faucet_request( + test: EthereumRuntimeTest, + name: str, + requester_service: object, + faucet_url: str, + address: str, + amount: int, + *, + mode: str, + expected_status: int = 200, + expect_text: str = "success", +) -> Dict[str, object]: + command = faucet_request_command(faucet_url, address, amount, mode, expected_status, expect_text) + result = test.exec(requester_service, command, timeout=420) + ok = result["exit"] == 0 + return record(test, name, requester_service, command, ok, str(result["stdout"]), str(result["stderr"])) + + +def run_faucet_user_script( + test: EthereumRuntimeTest, + faucet_user_service: object, + address: str, +) -> Dict[str, object]: + command = "python3 faucet_user/fundme.py {}".format(shlex.quote(address)) + result = test.exec(faucet_user_service, command, timeout=420) + ok = result["exit"] == 0 + return record( + test, + "D20 FaucetUserService script can request runtime funds", + faucet_user_service, + command, + ok, + str(result["stdout"]), + str(result["stderr"]), + ) + + +def main() -> int: + test = EthereumRuntimeTest(__file__) + + geth_nodes = test.require_ethereum_services( + "D20 has POS Geth nodes for balance checks", + expected_count=3, + class_contains="Ethereum-POS-Geth", + consensus="POS", + ) + test.require_ethereum_services( + "D20 has POS Lighthouse beacon nodes", + expected_count=3, + class_contains="Ethereum-POS-Beacon", + consensus="POS", + ) + test.require_ethereum_services( + "D20 has POS genesis validator clients", + expected_count=9, + class_contains="Ethereum-POS-Validator", + role="validator_at_genesis", + consensus="POS", + ) + faucet_services = test.require_ethereum_services( + "D20 has one Faucet service", + expected_count=1, + class_contains="FaucetService", + ) + faucet_user_services = test.require_ethereum_services( + "D20 has one FaucetUser service", + expected_count=1, + class_contains="FaucetUserService", + ) + test.require_ethereum_services( + "D20 has one Utility service", + expected_count=1, + class_contains="EthUtilityServer", + ) + + if not geth_nodes or not faucet_services or not faucet_user_services: + test.write_summary("d20-faucet-runtime-test.json") + return test.exit_code() + + geth = geth_nodes[0] + faucet = faucet_services[0] + faucet_user = faucet_user_services[0] + faucet_fund_url = "http://{}:80/fundme".format(faucet.address) + + for address, amount_eth in BUILD_TIME_FUNDS: + wait_for_balance_at_least( + test, + "D20 build-time Faucet funding reaches {} ETH for {}".format(amount_eth, address), + geth, + address, + amount_eth * WEI_PER_ETH, + retries=90, + interval=5, + ) + + before_json = get_balance_wei(test, geth, JSON_REQUEST_RECIPIENT) + json_result = send_faucet_request( + test, + "D20 Faucet JSON /fundme request succeeds", + faucet_user, + faucet_fund_url, + JSON_REQUEST_RECIPIENT, + 1, + mode="json", + ) + if json_result["status"] == "passed": + wait_for_balance_increase( + test, + "D20 Faucet JSON /fundme transfers 1 ETH", + geth, + JSON_REQUEST_RECIPIENT, + before_json, + WEI_PER_ETH, + ) + + before_form = get_balance_wei(test, geth, FORM_REQUEST_RECIPIENT) + form_result = send_faucet_request( + test, + "D20 Faucet form /fundme request succeeds", + faucet_user, + faucet_fund_url, + FORM_REQUEST_RECIPIENT, + 1, + mode="form", + ) + if form_result["status"] == "passed": + wait_for_balance_increase( + test, + "D20 Faucet form /fundme transfers 1 ETH", + geth, + FORM_REQUEST_RECIPIENT, + before_form, + WEI_PER_ETH, + ) + + before_user = get_balance_wei(test, geth, FAUCET_USER_RECIPIENT) + user_result = run_faucet_user_script(test, faucet_user, FAUCET_USER_RECIPIENT) + if user_result["status"] == "passed": + wait_for_balance_increase( + test, + "D20 FaucetUserService runtime script transfers 10 ETH", + geth, + FAUCET_USER_RECIPIENT, + before_user, + 10 * WEI_PER_ETH, + ) + + send_faucet_request( + test, + "D20 Faucet rejects invalid Ethereum address", + faucet_user, + faucet_fund_url, + INVALID_RECIPIENT, + 1, + mode="json", + expected_status=500, + expect_text="invalid ethereum address", + ) + send_faucet_request( + test, + "D20 Faucet rejects requests above max_fund_amount", + faucet_user, + faucet_fund_url, + JSON_REQUEST_RECIPIENT, + 11, + mode="json", + expected_status=500, + expect_text="max_fund_amount", + ) + + test.write_summary("d20-faucet-runtime-test.json") + return test.exit_code() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/seedemu/services/EthereumService/EthTemplates/files_faucet/faucet_server.py b/seedemu/services/EthereumService/EthTemplates/files_faucet/faucet_server.py index c808033c6..1c3ec2300 100644 --- a/seedemu/services/EthereumService/EthTemplates/files_faucet/faucet_server.py +++ b/seedemu/services/EthereumService/EthTemplates/files_faucet/faucet_server.py @@ -1,4 +1,7 @@ from flask import Flask, request, jsonify +import inspect +if not hasattr(inspect, "getargspec"): + inspect.getargspec = inspect.getfullargspec from web3 import Web3 from web3.middleware import geth_poa_middleware import sys, time diff --git a/seedemu/services/EthereumService/EthTemplates/files_faucet/fundme.py b/seedemu/services/EthereumService/EthTemplates/files_faucet/fundme.py index ddcbb2f67..d63a0eb23 100644 --- a/seedemu/services/EthereumService/EthTemplates/files_faucet/fundme.py +++ b/seedemu/services/EthereumService/EthTemplates/files_faucet/fundme.py @@ -2,6 +2,9 @@ import time, sys, json import requests +import inspect +if not hasattr(inspect, "getargspec"): + inspect.getargspec = inspect.getfullargspec from eth_account import Account import logging diff --git a/seedemu/services/EthereumService/EthTemplates/files_utility/deploy_contract.py b/seedemu/services/EthereumService/EthTemplates/files_utility/deploy_contract.py index f4d664473..25ecdf0f7 100644 --- a/seedemu/services/EthereumService/EthTemplates/files_utility/deploy_contract.py +++ b/seedemu/services/EthereumService/EthTemplates/files_utility/deploy_contract.py @@ -1,6 +1,9 @@ #!/bin/env python3 import time +import inspect +if not hasattr(inspect, "getargspec"): + inspect.getargspec = inspect.getfullargspec from web3 import Web3, HTTPProvider import os import logging diff --git a/seedemu/services/EthereumService/EthTemplates/files_utility/fund_account.py b/seedemu/services/EthereumService/EthTemplates/files_utility/fund_account.py index 73fc975b7..8d034b9b2 100644 --- a/seedemu/services/EthereumService/EthTemplates/files_utility/fund_account.py +++ b/seedemu/services/EthereumService/EthTemplates/files_utility/fund_account.py @@ -1,6 +1,9 @@ #!/bin/env python3 import time +import inspect +if not hasattr(inspect, "getargspec"): + inspect.getargspec = inspect.getfullargspec from web3 import Web3, HTTPProvider import requests import logging diff --git a/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server.py b/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server.py index d35485b99..c01f94356 100644 --- a/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server.py +++ b/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server.py @@ -2,6 +2,9 @@ from flask import Flask, request, jsonify import os +import inspect +if not hasattr(inspect, "getargspec"): + inspect.getargspec = inspect.getfullargspec from web3 import Web3, HTTPProvider import json diff --git a/seedemu/services/EthereumService/FaucetUserService.py b/seedemu/services/EthereumService/FaucetUserService.py index e8aaf7953..358ee918c 100644 --- a/seedemu/services/EthereumService/FaucetUserService.py +++ b/seedemu/services/EthereumService/FaucetUserService.py @@ -52,7 +52,7 @@ def install(self, node: Node): """ node.appendClassName("FaucetUserService") node.addSoftware('python3 python3-pip') - node.addBuildCommand('pip3 install eth_account==0.5.9 requests') + node.addBuildCommand('pip3 install --break-system-packages eth_account==0.5.9 requests || pip3 install eth_account==0.5.9 requests') node.setFile(self.DIR_PREFIX + '/fundme.py', FaucetServerFileTemplates['fundme'].format( faucet_url=self.__faucet_util.getFacuetUrl(), From 31d3aef93cf7b39fa6d0efd752acdd3cf6c3a767 Mon Sep 17 00:00:00 2001 From: BruceJqs <616353876@qq.com> Date: Sat, 13 Jun 2026 23:02:45 +0800 Subject: [PATCH 3/3] Add account cache to optimize test process --- seedemu/testing/blockchain.py | 90 +++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/seedemu/testing/blockchain.py b/seedemu/testing/blockchain.py index 983b7076e..93dfe991d 100644 --- a/seedemu/testing/blockchain.py +++ b/seedemu/testing/blockchain.py @@ -7,6 +7,7 @@ import shlex import time import urllib.request +from pathlib import Path from typing import Any, Dict, List, Optional try: @@ -26,6 +27,7 @@ ETH_CHAIN_ID_LABEL = META_PREFIX + "ethereum.chain_id" DEFAULT_TRANSFER_RECIPIENT = "0x1000000000000000000000000000000000000001" +LOCAL_ACCOUNT_CACHE_VERSION = 1 def _label_list(value: object) -> List[str]: @@ -571,13 +573,14 @@ def _signed_raw_transfer_script( state = self._local_account_transaction_state(service) sender = str(state["sender"]) - private_key = self._private_key_for_local_account(service, sender, password) + chain_id = self._chain_id_for_service(service) + private_key = self._private_key_for_local_account(service, sender, password, chain_id=chain_id) account = Account.from_key(private_key) if account.address.lower() != sender.lower(): raise RuntimeError("decrypted key {} does not match local geth account {}".format(account.address, sender)) transaction = { - "chainId": self._chain_id_for_service(service), + "chainId": chain_id, "nonce": self._rpc_int(state["nonce"]), "gas": 21000, "gasPrice": self._rpc_int(state["gasPrice"]), @@ -601,12 +604,28 @@ def _local_account_transaction_state(self, service: ComposeService | str) -> Dic raise RuntimeError(result["stderr"] or result["stdout"] or "failed to inspect local geth account") return self._extract_json(str(result["stdout"])) - def _private_key_for_local_account(self, service: ComposeService | str, sender: str, password: str) -> str: + def _private_key_for_local_account( + self, + service: ComposeService | str, + sender: str, + password: str, + *, + chain_id: Optional[int] = None, + ) -> str: try: from eth_account import Account except ImportError as exc: raise RuntimeError("eth_account is required to decrypt geth keystore files") from exc + cached_private_key = self._load_cached_local_private_key(sender, chain_id) + if cached_private_key: + try: + cached_account = Account.from_key(cached_private_key) + except Exception: + cached_account = None + if cached_account is not None and cached_account.address.lower() == sender.lower(): + return cached_private_key + passwords = [password] password_result = self.exec(service, "cat /tmp/eth-password 2>/dev/null || true", timeout=30) for item in str(password_result["stdout"]).splitlines(): @@ -637,11 +656,76 @@ def _private_key_for_local_account(self, service: ComposeService | str, sender: errors.append("{}: decrypt failed: {}".format(path_item, exc)) continue if account.address.lower() == sender.lower(): + self._save_cached_local_private_key(sender, chain_id, private_key) return private_key detail = "; ".join(errors[-3:]) if errors else "no readable keystore files" raise RuntimeError("could not decrypt local geth account {}: {}".format(sender, detail)) + def _local_account_cache_path(self) -> Optional[Path]: + if self.artifact_dir is None: + return None + return self.artifact_dir / "ethereum-local-account-cache.json" + + @staticmethod + def _local_account_cache_key(sender: str, chain_id: Optional[int]) -> str: + return "{}:{}".format(chain_id if chain_id is not None else "unknown", sender.lower()) + + def _load_cached_local_private_key(self, sender: str, chain_id: Optional[int]) -> Optional[str]: + cache_path = self._local_account_cache_path() + if cache_path is None or not cache_path.is_file(): + return None + try: + data = json.loads(cache_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict) or data.get("version") != LOCAL_ACCOUNT_CACHE_VERSION: + return None + accounts = data.get("accounts") + if not isinstance(accounts, dict): + return None + entry = accounts.get(self._local_account_cache_key(sender, chain_id)) + if not isinstance(entry, dict): + return None + private_key = entry.get("private_key") + if not isinstance(private_key, str) or not private_key: + return None + if not private_key.startswith("0x"): + private_key = "0x" + private_key + return private_key + + def _save_cached_local_private_key(self, sender: str, chain_id: Optional[int], private_key: str) -> None: + cache_path = self._local_account_cache_path() + if cache_path is None: + return + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + if cache_path.is_file(): + data = json.loads(cache_path.read_text(encoding="utf-8")) + else: + data = {} + except (OSError, json.JSONDecodeError): + data = {} + if not isinstance(data, dict) or data.get("version") != LOCAL_ACCOUNT_CACHE_VERSION: + data = {"version": LOCAL_ACCOUNT_CACHE_VERSION, "accounts": {}} + accounts = data.setdefault("accounts", {}) + if not isinstance(accounts, dict): + accounts = {} + data["accounts"] = accounts + accounts[self._local_account_cache_key(sender, chain_id)] = { + "sender": sender, + "chain_id": chain_id, + "private_key": private_key, + } + tmp_path = cache_path.with_name(cache_path.name + ".tmp") + try: + tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp_path.chmod(0o600) + tmp_path.replace(cache_path) + cache_path.chmod(0o600) + except OSError: + return + def _local_keystore_paths(self, service: ComposeService | str) -> List[str]: command = ( 'for dir in /root/.ethereum/keystore /tmp/keystore; do '