From 4f64dfabf3213ee5665c7f2bc7b42bbf86ee17da Mon Sep 17 00:00:00 2001 From: keanji-x Date: Wed, 22 Jul 2026 14:50:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(gnode):=20difftx/difftx-exec=20?= =?UTF-8?q?=E5=B0=81=E8=A3=85=20+=20=E5=8F=82=E6=95=B0=E5=8C=96=207702=20?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=20+=20=E5=90=8C=E5=9D=97=E6=9C=89=E5=BA=8F?= =?UTF-8?q?=E6=89=B9=E9=87=8F=20(D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把「机制驱动攻击工具链」的 gnode 侧接上 D1 的 Rust 差分预言机,并把内置死 场景升级成可搜索的参数化模板: - gnode difftx / difftx-exec:shell out 到兄弟 gravity-reth worktree 的 difftx / difftx_exec 二进制(cargo run,GRAVITY_RETH_DIR 可配),透传输出 + 退出码(3=停链缺口/执行背离,0=干净),命中时指向 difftx-repro/*.json 复现 工件。纯静态、无需集群;worktree/cargo 缺失给干净错误(exit 2)。 - 7702-race 参数化场景(halt_7702_param.py,原 7702-halt 保留为稳定默认): 暴露旋钮 cross_sender_action(cross_sender|self_sponsored)/nonce_offset/ auth_count/chain_id(correct|zero|mismatched)/delegate_chain/attempts/ window,供红队 agent 搜索停链变体;不可构造的组合返回结构化 inconclusive/error 而非伪造 halt。旋钮走 PARAMS 类型校验(坏值→干净 exit 2)。 - gnode send-batch:同块有序批量发送——先全签名再按数组序背靠背 --no-wait 广播,回报 same_block/ordered_in_block/逐笔块+序。诚实标注排序边界(同发送者 连续 nonce=确定序;跨发送者=尽力而为的 fee-priority,无更强 RPC 原语)。 离线验证:gnode scenarios 列出 7702-race;difftx-exec 对 worktree 跑通(9 例 0 背离,exit 0);difftx 命中 7702 自检(exit 3);各错误路径 exit 2 无 traceback。 未跑 live 集群注入(7702-race/send-batch 的真链 halt/broadcast 未验证)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/gnode/gnodelib/cli.py | 36 +++ tools/gnode/gnodelib/difftx.py | 94 ++++++ tools/gnode/gnodelib/ops.py | 93 +++++- .../gnodelib/scenarios/halt_7702_param.py | 267 ++++++++++++++++++ tools/gnode/gnodelib/scenarios/registry.py | 6 + 5 files changed, 492 insertions(+), 4 deletions(-) create mode 100644 tools/gnode/gnodelib/difftx.py create mode 100644 tools/gnode/gnodelib/scenarios/halt_7702_param.py diff --git a/tools/gnode/gnodelib/cli.py b/tools/gnode/gnodelib/cli.py index aebd9f37..a5be9868 100644 --- a/tools/gnode/gnodelib/cli.py +++ b/tools/gnode/gnodelib/cli.py @@ -90,6 +90,21 @@ def build_parser() -> argparse.ArgumentParser: sd.add_argument("tx", help="交易规格 JSON 文件") sd.add_argument("--no-wait", action="store_true", help="不等回执,发出即返回(用于同块/连发场景)") + sb = sub.add_parser( + "send-batch", + help="按顺序连发一批交易,尽力同块同序(同块有序批量原语,用于 nonce 竞争)", + epilog=( + "batch.json = tx 规格数组,每个元素字段同 `gnode send`。\n" + "先全部签好再背靠背广播(不等回执),最后报告各笔落块/块内下标。\n" + "顺序保证:同发送者连续 nonce = 块内序确定;跨发送者 = 靠 maxPriorityFeePerGas\n" + "小费定序(best-effort,非 100%)。示例:\n" + ' [{"to":"0x..A","nonce":5},{"to":"0x..B","nonce":6}]' + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + _add_preset(sb) + sb.add_argument("batch", help="批量交易规格 JSON 文件(tx 规格数组)") + at = sub.add_parser("attack", help="运行内置攻击场景") _add_preset(at, default="prague") at.add_argument("scenario", help="场景名(见 gnode scenarios)") @@ -99,6 +114,21 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("scenarios", help="列出内置攻击场景") + # —— 差分预言机(D1,Rust bin 封装):纯静态,无需集群 —— + sub.add_parser( + "difftx", + help="跑 Rust 差分预言机 tx_filter⊇revm 矩阵(无需集群;退出码 3=发现停链缺口)", + epilog="需要兄弟 gravity-reth worktree(默认 /mnt/data2/kenji/galxe/gravity-reth-difftx," + "可用环境变量 GRAVITY_RETH_DIR 覆盖)。透传 cargo bin 的 stdout 与退出码。", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub.add_parser( + "difftx-exec", + help="跑 Rust serial⟷grevm 执行差分预言机(无需集群;退出码 3=发现执行背离)", + epilog="同 difftx,但走 --features difftx_exec --bin difftx_exec;GRAVITY_RETH_DIR 同上。", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + return p @@ -145,10 +175,16 @@ def _dispatch(args, argv: list[str]) -> int: 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 == "send-batch": + return ops.cmd_send_batch(args.preset, args.batch, instance=_inst(args)) if args.cmd == "scenarios": for name, desc in registry.list_scenarios(): print(f"{name:<16} {desc}") return 0 + if args.cmd in ("difftx", "difftx-exec"): + # 差分预言机无需集群:不解析 --preset,直接 shell out 到 Rust bin,透传退出码。 + from . import difftx + return difftx.cmd_difftx(args.cmd) if args.cmd == "attack": # 未知场景属于用法错误 → 退出码 2(与坏 --preset 一致),输出保持 scenario 字段 known = {n for n, _ in registry.list_scenarios()} diff --git a/tools/gnode/gnodelib/difftx.py b/tools/gnode/gnodelib/difftx.py new file mode 100644 index 00000000..234b0e0f --- /dev/null +++ b/tools/gnode/gnodelib/difftx.py @@ -0,0 +1,94 @@ +"""gnode difftx / difftx-exec —— 把 Rust 差分预言机(D1)串进 gnode 的统一 UX。 + +设计要点: + - 纯静态差分,无需集群 —— 不碰 resolve_cluster / RPC。 + - 直接 `cargo run` 兄弟 gravity-reth worktree 里的 difftx / difftx_exec 二进制, + 透传子进程 stdout/stderr(继承父进程 fd,实时流式),并原样传播退出码: + 3 = 发现停链缺口 / 执行背离(gap/divergence) + 0 = 干净(两侧一致) + - 命中(非 0)时补一行指向 difftx-repro/*.json 复现工件的提示。 + - worktree 缺失 / cargo 不在 PATH → 干净的一行错误 + 退出码 2(用法错误), + 绝不抛 traceback。 + +Rust bin 把 difftx-repro/ 写在**运行时 cwd** 下;为使工件位置确定, +本模块把子进程 cwd 固定为 GRAVITY_RETH_DIR,工件即落在 /difftx-repro/。 +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +# 兄弟 gravity-reth worktree 默认位置;可用环境变量 GRAVITY_RETH_DIR 覆盖。 +DEFAULT_RETH_DIR = "/mnt/data2/kenji/galxe/gravity-reth-difftx" + +# kind -> (cargo --features, --bin, 人读说明) +_VARIANTS = { + "difftx": ("difftx", "difftx", + "tx_filter ⊇ revm 交易级差分矩阵 + 7702 自检(纯静态,无需集群)"), + "difftx-exec": ("difftx_exec", "difftx_exec", + "serial(revm) ⟷ grevm 执行差分(纯静态,无需集群)"), +} + + +def _reth_dir() -> Path: + return Path(os.environ.get("GRAVITY_RETH_DIR", DEFAULT_RETH_DIR)).expanduser() + + +def cmd_difftx(kind: str) -> int: + """运行差分预言机二进制并透传其输出/退出码。kind ∈ {'difftx','difftx-exec'}。""" + if kind not in _VARIANTS: + print(f"[gnode] error: 未知 difftx 变体 '{kind}'(支持: {sorted(_VARIANTS)})", + file=sys.stderr) + return 2 + features, binname, _desc = _VARIANTS[kind] + + reth = _reth_dir() + manifest = reth / "Cargo.toml" + # —— 环境预检:worktree / manifest / cargo 缺失都归「用法错误」(2),给一行干净提示 —— + if not reth.is_dir(): + print(f"[gnode] error: gravity-reth worktree 不存在: {reth}\n" + f" 设置环境变量 GRAVITY_RETH_DIR 指向含 difftx 二进制的 worktree。", + file=sys.stderr) + return 2 + if not manifest.is_file(): + print(f"[gnode] error: 找不到 Cargo.toml: {manifest}\n" + f" GRAVITY_RETH_DIR 应指向 gravity-reth 仓库根(含 Cargo.toml)。", + file=sys.stderr) + return 2 + if shutil.which("cargo") is None: + print("[gnode] error: 找不到 cargo(Rust 工具链未安装或不在 PATH);" + "请确保 ~/.cargo/bin 在 PATH。", file=sys.stderr) + return 2 + + cmd = [ + "cargo", "run", "-q", + "--manifest-path", str(manifest), + "-p", "reth-pipe-exec-layer-ext-v2", + "--features", features, + "--bin", binname, + ] + print(f"[gnode] difftx: {_VARIANTS[kind][2]}", file=sys.stderr) + print(f"[gnode] 运行: {' '.join(cmd)} (cwd={reth})", file=sys.stderr) + + # 子进程 stdout/stderr 继承父进程 → 实时流式;cwd 固定到 reth 使 difftx-repro/ 位置确定。 + try: + proc = subprocess.run(cmd, cwd=str(reth)) + except FileNotFoundError as e: + # cargo 二进制在 which 之后又消失等极端情况 + print(f"[gnode] error: 无法执行 cargo: {e}", file=sys.stderr) + return 2 + except KeyboardInterrupt: + print("[gnode] difftx 被中断", file=sys.stderr) + return 1 + + rc = proc.returncode + # 命中(gap/背离)→ 指一手复现工件位置;退出码原样透传(3=gap,0=clean)。 + if rc != 0: + repro = reth / "difftx-repro" + print(f"\n[gnode] difftx 退出码 {rc} —— 发现停链缺口/执行背离(gap/divergence)。" + f"\n[gnode] 复现工件(gnode send 兼容)见: {repro}/*.json", + file=sys.stderr) + return rc diff --git a/tools/gnode/gnodelib/ops.py b/tools/gnode/gnodelib/ops.py index e8b4f7de..97e57ef8 100644 --- a/tools/gnode/gnodelib/ops.py +++ b/tools/gnode/gnodelib/ops.py @@ -342,7 +342,17 @@ def cmd_send(preset, tx_path: str, *, no_wait: bool = False, instance: int = 0) if rc is not None: return rc spec = json.loads(Path(tx_path).read_text()) + raw, sender, to = _build_signed_from_spec(w3, spec) + h = w3.eth.send_raw_transaction(raw) + return _report_sent(w3, h, no_wait, sender=sender, to=to) + +def _build_signed_from_spec(w3: Web3, spec: dict): + """把一条 tx.json 规格构造并签名成 (raw, sender, to),供 send / send-batch 复用。 + + raw 是可直接喂给 eth_sendRawTransaction 的原始交易;sender/to 用于回执补全 + (raw 直广播路径下未知,返回 None)。规格字段/校验与 `gnode send --help` 一致。 + """ # 拒绝未知/拼错的字段,避免像把 "value" 写成 "valeu" 这种被静默忽略 known = {"to", "value", "data", "nonce", "gas", "gasPrice", "type", "accessList", "authorizationList", "raw", "privkey"} @@ -353,8 +363,7 @@ def cmd_send(preset, tx_path: str, *, no_wait: bool = False, instance: int = 0) # 已签名原始交易:直接广播,忽略其他字段 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) + return (raw if raw.startswith("0x") else "0x" + raw), None, None acct = Account.from_key(spec["privkey"]) if spec.get("privkey") else faucet_account() # value 友好校验:接受十进制或 0x 十六进制,必须非负 @@ -410,8 +419,84 @@ def cmd_send(preset, tx_path: str, *, no_wait: bool = False, instance: int = 0) 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")) + return signed.raw_transaction, acct.address, tx.get("to") + + +def cmd_send_batch(preset, batch_path: str, *, instance: int = 0) -> int: + """按数组顺序连发一批交易,尽力打进同一个块并保持块内顺序(同块有序批量原语)。 + + batch.json = 一个 tx 规格数组,每个元素字段同 `gnode send`(to/value/data/nonce/type/ + authorizationList/raw/privkey/...)。先对全部规格逐条构造并签名,再**不等回执**地按 + 数组顺序背靠背广播(避免签名耗时打散提交),最后拉回执报告各笔落在哪个块/块内下标。 + + ⚠️ 顺序保证的诚实边界:本地 RPC 不提供比「同发送者按 nonce 排序 / 跨发送者按小费排序」 + 更强的块内定序原语。 + · 同一发送者的连续 nonce:块内顺序确定(nonce 递增),是 nonce 竞争最可靠的复现姿势; + · 跨发送者:块内先后由 fee-priority 决定,请自行在各笔 spec 里用 maxPriorityFeePerGas + 把想排前的交易设更高小费(best-effort,非 100% 保证同块/同序)。 + """ + cp = resolve_cluster(preset, int(instance)) + w3 = make_web3(cp.rpc_url()) + rc = _require_rpc(cp, w3) + if rc is not None: + return rc + specs = json.loads(Path(batch_path).read_text()) + if not isinstance(specs, list) or not specs: + raise ValueError("batch.json 需为非空的 tx 规格数组,如 '[{...},{...}]'") + + # 阶段一:先把所有交易签好(签名/estimate 可能较慢,提前做完,广播阶段才能紧凑连发)。 + prepared = [] + for i, spec in enumerate(specs): + if not isinstance(spec, dict): + raise ValueError(f"batch[{i}] 需为对象(tx 规格),得到 {type(spec).__name__}") + raw, sender, to = _build_signed_from_spec(w3, spec) + prepared.append((raw, sender, to)) + + # 阶段二:按数组顺序背靠背广播,不等回执(--no-wait 语义),尽量同块同序。 + sent = [] + for i, (raw, sender, to) in enumerate(prepared): + try: + h = w3.eth.send_raw_transaction(raw) + sent.append({"index": i, "tx_hash": h.to_0x_hex(), "from": sender, "to": to, "error": None}) + except Exception as e: # noqa: BLE001 —— 单笔失败不应中断整批,记录后继续 + sent.append({"index": i, "tx_hash": None, "from": sender, "to": to, + "error": f"{type(e).__name__}: {e}"}) + log(f"已按顺序广播 {sum(1 for s in sent if s['tx_hash'])}/{len(prepared)} 笔,拉回执确认落块 ...") + + # 阶段三:拉回执,报告各笔落在哪个块 / 块内下标,便于判断是否同块按序。 + for s in sent: + if not s["tx_hash"]: + continue + try: + r = w3.eth.wait_for_transaction_receipt(s["tx_hash"], timeout=120) + s["block"] = int(r["blockNumber"]) + s["tx_index"] = int(r["transactionIndex"]) + s["status"] = int(r["status"]) + except Exception as e: # noqa: BLE001 + s["block"] = s["tx_index"] = s["status"] = None + s["error"] = f"receipt: {type(e).__name__}: {e}" + + landed = [s for s in sent if s.get("block") is not None] + blocks = {s["block"] for s in landed} + same_block = len(blocks) == 1 and len(landed) == len(sent) + # 块内是否严格按数组顺序(index 升序 ⇔ tx_index 升序) + ordered = same_block and all( + landed[k]["tx_index"] < landed[k + 1]["tx_index"] for k in range(len(landed) - 1) + ) + out = { + "count": len(sent), + "same_block": same_block, + "ordered_in_block": ordered, + "blocks": sorted(blocks), + "txs": sent, + "note": ("全部同块且按数组顺序落块" if ordered else + "未全部同块/未严格按序 —— 跨发送者定序靠 fee-priority,best-effort;" + "同发送者连续 nonce 才有确定性块内序"), + } + print(json.dumps(out, indent=2, ensure_ascii=False)) + # 退出码:全部成功落块=0;有交易失败/未落块=2(inconclusive/部分失败),与 send 的非 1-status 归 2 一致 + all_ok = len(landed) == len(sent) and all(s.get("status") == 1 for s in landed) + return 0 if all_ok else 2 def _prepare_auth_list(w3: Web3, entries: list, tx: dict, tx_signer) -> list: diff --git a/tools/gnode/gnodelib/scenarios/halt_7702_param.py b/tools/gnode/gnodelib/scenarios/halt_7702_param.py new file mode 100644 index 00000000..8ae5b5e3 --- /dev/null +++ b/tools/gnode/gnodelib/scenarios/halt_7702_param.py @@ -0,0 +1,267 @@ +"""7702-race —— 7702 nonce 竞争停链机理的**参数化模板**(可搜索变体)。 + +在稳定默认场景 `7702-halt`(跨发送者、单委托、正确 chainId)之外,把机理拆成一组 +可调旋钮,供 Coacker 红队 agent SEARCH 变体、而非只跑一个固定用例。复用 +`halt_7702` 里的 `_deploy_target` / `_inject_race` / `probe_liveness` / `Verdict`, +故退出码契约(3=halt/panic,0=alive/revert,2=inconclusive/usage,1=error)不变。 + +旋钮(--param K=V,均经 PARAMS 类型强转;坏值→干净用法错误,非 traceback): + cross_sender_action cross_sender(默认,tx0 由 faucet→A 触发委托 CREATE) + | self_sponsored(authority==sender,A 自发同发送者连续 nonce) + nonce_offset int(默认 0) 在途 stale 交易 tx1 的 nonce 相对基准 N 的偏移 + auth_count int(默认 1) 单笔 SetCode 里装入的授权条数(>1 追加随机 EOA→T 压测授权表) + chain_id correct(默认) | zero(=0,7702 语义为任意链有效) | mismatched(=nodeid+1,应被拒) + delegate_chain single(默认,A→T) | aba(A→B→A 委托环,当前原语暂不可构造→结构化 inconclusive) + attempts int(默认 3) 竞争成形的重试次数 + window float(默认 20) 每次注入后的存活观察窗口秒数 + +未成形不下 halt 结论(inconclusive);旋钮组合暂不可构造时返回结构化说明,绝不伪造 halt。 +""" +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 +from .halt_7702 import ( + DESIGNATOR_PREFIX, + _deploy_target, + _inject_race, + _receipt, + _send_raw, + _wait_nonce, +) + +# 枚举型旋钮用 str 收,值域在 run() 内校验(坏值→usage_error)。 +PARAMS = { + "cross_sender_action": str, + "nonce_offset": int, + "auth_count": int, + "chain_id": str, + "delegate_chain": str, + "attempts": int, + "window": float, +} + +_ACTIONS = ("cross_sender", "self_sponsored") +_CHAIN_ID_MODES = ("correct", "zero", "mismatched") +_DELEGATE_CHAINS = ("single", "aba") + + +def _resolve_auth_chain_id(mode: str, node_chain_id: int) -> int: + """把 chain_id 旋钮解析成授权(authorization)里用的 chainId。""" + if mode == "correct": + return node_chain_id + if mode == "zero": + return 0 # EIP-7702:chainId=0 表示任意链有效 + return node_chain_id + 1 # mismatched:应被节点判为无效授权 + + +def _setup_delegation_param( + w3: Web3, faucet, A, T: str, *, node_chain_id: int, auth_chain_id: int, auth_count: int +) -> dict: + """参数化装委托:A→T 为首条授权,另追加 auth_count-1 条随机 EOA→T 压测授权表。 + + 授权(authorization)的 chainId 用 auth_chain_id(旋钮控制);外层 SetCode 交易本身 + 仍用节点真实 chain_id(node_chain_id),以隔离「授权链号」这一变量。 + """ + a_nonce0 = w3.eth.get_transaction_count(A.address) + auths = [sign_authorization(A, chain_id=auth_chain_id, delegate=T, nonce=a_nonce0)] + for _ in range(max(0, auth_count - 1)): + extra = Account.create() + auths.append(sign_authorization(extra, chain_id=auth_chain_id, delegate=T, nonce=0)) + inner_target = Account.create().address # to=无码地址,避免此步就触发 CREATE + fee = max(w3.eth.gas_price * 2, w3.to_wei(50, "gwei")) + raw = build_signed_set_code_tx( + faucet, chain_id=node_chain_id, nonce=w3.eth.get_transaction_count(faucet.address), + to=inner_target, authorization_list=auths, gas=200000 + auth_count * 30000, + 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(), "auth_count": auth_count, + "nonce": w3.eth.get_transaction_count(A.address)} + + +def _inject_self_sponsored(w3: Web3, faucet, A, chain_id: int, *, nonce_offset: int = 0) -> dict: + """自发起(authority==sender)同块注入:tx0(A→A 触发委托 CREATE) 先,tx1(A stale) 后。 + + 同一发送者的多笔交易在块内恒按 nonce 排序且同块,故 tx0 先于 tx1 是确定性的 + (比跨发送者按小费排序更可靠地复现竞争)。 + """ + 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") + prio = w3.to_wei(2, "gwei") + sink = Account.create().address + # tx0:调用自身(已装委托码 → 执行 CREATE);nonce=N,块内先执行 → 抬升 A.nonce 到 N+2。 + tx0 = { + "from": A.address, "to": A.address, "value": 0, "data": "0x", "nonce": N, + "gas": 500000, "chainId": chain_id, + "maxPriorityFeePerGas": prio, "maxFeePerGas": base * 2 + prio, + } + # tx1:在途 stale 交易,nonce=N+1+offset(默认紧邻 tx0);执行时 A.nonce 已 >N+1 → NonceTooLow。 + tx1 = { + "from": A.address, "to": sink, "value": 0, "nonce": N + 1 + nonce_offset, + "gas": 40000, "chainId": chain_id, + "maxPriorityFeePerGas": prio, "maxFeePerGas": base * 2 + prio, + } + tx0_hash = _send_raw(w3, A, tx0) # 同发送者:先发低 nonce + tx1_hash = _send_raw(w3, A, tx1) # 再发高 nonce,块内自然排在 tx0 之后 + return {"N": N, "head_before": head_before, "nonce_offset": nonce_offset, + "tx0_self_create": tx0_hash, "tx1_from_A_stale": tx1_hash} + + +def _evaluate(w3: Web3, race: dict, tx0_key: str, tx1_key: str, probe) -> tuple: + """把一次注入的回执 + liveness 判定成 (决定性?, verdict, detail, race_formed, receipts)。""" + r0, r1 = _receipt(w3, race[tx0_key]), _receipt(w3, race[tx1_key]) + receipts = {"tx0": r0, "tx1": r1} + if probe.verdict in (Verdict.HALT, Verdict.PANIC): + return True, probe.verdict, probe.detail, None, receipts + 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: + return True, Verdict.REVERT, "竞争已成形(tx0/tx1 同块且 tx0 在前),tx1 被优雅回滚,链不停 —— 修复生效", True, receipts + return True, Verdict.ALIVE, "竞争已成形但链继续出块 —— 该路径已被缓解/修复", True, receipts + return False, None, None, False, receipts + + +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-race", "expected": "halt", "rpc": cp.rpc_url()} + + # —— 旋钮值域校验(坏值→usage_error,退出码 2;与坏 --preset 一致)—— + action = params.get("cross_sender_action", "cross_sender") + if action not in _ACTIONS: + result.update({"verdict": Verdict.ERROR.value, "usage_error": True, + "detail": f"cross_sender_action={action!r} 非法,取值 {list(_ACTIONS)}"}) + return result + chain_id_mode = params.get("chain_id", "correct") + if chain_id_mode not in _CHAIN_ID_MODES: + result.update({"verdict": Verdict.ERROR.value, "usage_error": True, + "detail": f"chain_id={chain_id_mode!r} 非法,取值 {list(_CHAIN_ID_MODES)}"}) + return result + delegate_chain = params.get("delegate_chain", "single") + if delegate_chain not in _DELEGATE_CHAINS: + result.update({"verdict": Verdict.ERROR.value, "usage_error": True, + "detail": f"delegate_chain={delegate_chain!r} 非法,取值 {list(_DELEGATE_CHAINS)}"}) + return result + nonce_offset = int(params.get("nonce_offset", 0)) + auth_count = int(params.get("auth_count", 1)) + if auth_count < 1: + result.update({"verdict": Verdict.ERROR.value, "usage_error": True, + "detail": f"auth_count 需 ≥1,得到 {auth_count}"}) + return result + attempts = int(params.get("attempts", 3)) + if attempts < 1: + result.update({"verdict": Verdict.ERROR.value, "usage_error": True, + "detail": f"attempts 需 ≥1,得到 {attempts}"}) + return result + window = float(params.get("window", 20)) + result["params_used"] = {"cross_sender_action": action, "nonce_offset": nonce_offset, + "auth_count": auth_count, "chain_id": chain_id_mode, + "delegate_chain": delegate_chain, "attempts": attempts, "window": window} + + # —— 尚不可构造的旋钮组合:结构化 inconclusive,绝不伪造 halt —— + if delegate_chain == "aba": + result.update({ + "verdict": Verdict.INCONCLUSIVE.value, + "detail": "delegate_chain=aba(A→B→A 委托环)当前原语暂不可构造:需部署两个互指的委托目标" + "并给两个 EOA 分别装成环形 7702 designator,_deploy_target/_setup_delegation 暂只做" + "单跳 A→T。请扩展委托装配原语后再搜此变体(本次不下 halt 结论)。", + }) + return result + + # —— 健康检查必须先于任何 RPC 调用 —— + err = preflight_error(cp, w3) + if err: + result.update(err) + return result + + node_chain_id = w3.eth.chain_id + result["chain_id_node"] = node_chain_id + auth_chain_id = _resolve_auth_chain_id(chain_id_mode, node_chain_id) + result["auth_chain_id"] = auth_chain_id + fees = suggest_fees(w3) + steps: list[str] = [] + + # 委托目标 T(含 CREATE)只需部署一次 + T, derr = _deploy_target(w3, faucet, node_chain_id, fees) + if not T: + result.update({"verdict": Verdict.ERROR.value, "detail": derr["detail"]}) + return result + result["delegate_target"] = T + steps.append(f"部署委托目标 T={T}(运行时含 CREATE)") + + tries: list = [] + 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": node_chain_id, **fees, + } + _send_raw(w3, faucet, fund) + _wait_nonce(w3, faucet.address, fund["nonce"] + 1) + + setup = _setup_delegation_param( + w3, faucet, A, T, node_chain_id=node_chain_id, + auth_chain_id=auth_chain_id, auth_count=auth_count, + ) + if not setup["ok"]: + # chainId=mismatched 的预期结果就是授权被拒 → 委托没装上(负对照,非 halt)。 + if chain_id_mode == "mismatched": + result.update({ + "verdict": Verdict.INCONCLUSIVE.value, + "detail": f"chain_id=mismatched(auth_chain_id={auth_chain_id}≠node {node_chain_id})" + f"→ 授权被判无效,A 未装委托码(code={setup['code']}),委托 CREATE 不触发、" + f"竞争无法成形。这是预期的负对照,非链停。", + "setup": setup, "steps": steps, + }) + return result + result.update({"verdict": Verdict.ERROR.value, "steps": steps, + "detail": f"安装 7702 委托失败: code={setup['code']}"}) + return result + + if action == "self_sponsored": + race = _inject_self_sponsored(w3, faucet, A, node_chain_id, nonce_offset=nonce_offset) + tx0_key, tx1_key = "tx0_self_create", "tx1_from_A_stale" + else: + race = _inject_race(w3, faucet, A, node_chain_id) + tx0_key, tx1_key = "tx0_faucet_to_A", "tx1_from_A_stale" + + probe = probe_liveness(w3, window_s=window, min_delta=2, pid_alive=pid_alive) + decisive, verdict, detail, race_formed, receipts = _evaluate(w3, race, tx0_key, tx1_key, probe) + attempt_rec = {"attempt": i, "attacker_eoa": A.address, "nonce_race": race, + "liveness": probe.as_dict(), "receipts": receipts} + tries.append(attempt_rec) + + if decisive: + steps.append(f"[try {i}] 决定性判定:{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 + + r0, r1 = receipts["tx0"], receipts["tx1"] + why = "两笔未落在同一区块" if (r0 and r1 and r0["block"] != r1["block"]) else "块内顺序不满足 tx0 Date: Wed, 22 Jul 2026 17:02:06 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(gnode):=20difftx=20=E4=BB=85=20rc=3D=3D?= =?UTF-8?q?3=20=E6=8A=A5=20gap=20+=20=E6=8B=92=E7=BB=9D=20cross=5Fsender?= =?UTF-8?q?=20=E4=B8=8B=E7=9A=84=20nonce=5Foffset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review nit 修复: - difftx.py:非零退出码不再一律报「发现停链缺口/背离」——仅 rc==3 才是 gap; cargo 编译失败(101)/运行错误另给中性提示,避免 agent 把编译失败当停链。 - halt_7702_param.py:cross_sender 模式下 _inject_race 不吃 nonce_offset,过去 静默忽略却仍记进 params_used(误导)。现显式拒绝该组合(usage_error),提示改用 self_sponsored 或去掉 nonce_offset。 Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/gnode/gnodelib/difftx.py | 10 +++++++--- tools/gnode/gnodelib/scenarios/halt_7702_param.py | 8 ++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tools/gnode/gnodelib/difftx.py b/tools/gnode/gnodelib/difftx.py index 234b0e0f..80e78e59 100644 --- a/tools/gnode/gnodelib/difftx.py +++ b/tools/gnode/gnodelib/difftx.py @@ -85,10 +85,14 @@ def cmd_difftx(kind: str) -> int: return 1 rc = proc.returncode - # 命中(gap/背离)→ 指一手复现工件位置;退出码原样透传(3=gap,0=clean)。 - if rc != 0: + # 退出码原样透传。仅 rc==3 才是「发现 gap/背离」;其它非零(如 cargo 编译失败 101、 + # 运行错误 1)不是 gap 判定,别误报——否则 agent 会把编译失败当成停链缺口。 + if rc == 3: repro = reth / "difftx-repro" - print(f"\n[gnode] difftx 退出码 {rc} —— 发现停链缺口/执行背离(gap/divergence)。" + print(f"\n[gnode] difftx 退出码 3 —— 发现停链缺口/执行背离(gap/divergence)。" f"\n[gnode] 复现工件(gnode send 兼容)见: {repro}/*.json", file=sys.stderr) + elif rc not in (0, 3): + print(f"\n[gnode] difftx 退出码 {rc} —— 非 gap 判定(cargo 编译/运行错误等);" + f"请检查上面的构建/运行输出。", file=sys.stderr) return rc diff --git a/tools/gnode/gnodelib/scenarios/halt_7702_param.py b/tools/gnode/gnodelib/scenarios/halt_7702_param.py index 8ae5b5e3..b8d34bc4 100644 --- a/tools/gnode/gnodelib/scenarios/halt_7702_param.py +++ b/tools/gnode/gnodelib/scenarios/halt_7702_param.py @@ -158,6 +158,14 @@ def run(*, preset, instance: int = 0, params: dict) -> dict: "detail": f"delegate_chain={delegate_chain!r} 非法,取值 {list(_DELEGATE_CHAINS)}"}) return result nonce_offset = int(params.get("nonce_offset", 0)) + # nonce_offset 只对 self_sponsored 注入器生效(_inject_race 不吃该参数)。cross_sender 下 + # 若设了非零 nonce_offset,过去会被静默忽略、却仍记进 params_used——误导调用方以为生效了。 + # 这里显式拒绝该组合(usage_error),而不是假装接受。 + if action == "cross_sender" and nonce_offset != 0: + result.update({"verdict": Verdict.ERROR.value, "usage_error": True, + "detail": "nonce_offset 仅对 cross_sender_action=self_sponsored 生效;" + "cross_sender 模式下不支持,请改用 self_sponsored 或去掉 nonce_offset。"}) + return result auth_count = int(params.get("auth_count", 1)) if auth_count < 1: result.update({"verdict": Verdict.ERROR.value, "usage_error": True,