Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions tools/gnode/gnodelib/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept the advertised fee-priority fields

For cross-sender batches, this help text tells operators to set maxPriorityFeePerGas, but those batch entries are still validated by _build_signed_from_spec, whose allowed field set does not include maxPriorityFeePerGas or maxFeePerGas; a batch that uses the documented ordering knob fails immediately as an unknown field before broadcasting. The cross-sender ordering mode therefore cannot be configured from gnode send-batch; either pass these EIP-1559 fields through for type 2/4 transactions or remove this documented mechanism.

AGENTS.md reference: AGENTS.md:L8-L8

Useful? React with 👍 / 👎.

' [{"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)")
Expand All @@ -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


Expand Down Expand Up @@ -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()}
Expand Down
98 changes: 98 additions & 0 deletions tools/gnode/gnodelib/difftx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""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,工件即落在 <reth>/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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid defaulting difftx to a developer-local path

When GRAVITY_RETH_DIR is unset in a fresh checkout or CI job, gnode difftx follows _dispatch -> cmd_difftx -> _reth_dir() to this hard-coded /mnt/data2/... location and returns the worktree-missing usage error before invoking cargo, so the new static oracle is unusable unless operators already know to set a private environment variable. Minimal validation is unset GRAVITY_RETH_DIR; gnode difftx; derive a repo-relative sibling/default or require an explicit path instead.

AGENTS.md reference: AGENTS.md:L8-L9

Useful? React with 👍 / 👎.


# 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
# 退出码原样透传。仅 rc==3 才是「发现 gap/背离」;其它非零(如 cargo 编译失败 101、
# 运行错误 1)不是 gap 判定,别误报——否则 agent 会把编译失败当成停链缺口。
if rc == 3:
repro = reth / "difftx-repro"
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
93 changes: 89 additions & 4 deletions tools/gnode/gnodelib/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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 十六进制,必须非负
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allocate pending nonces while preparing a batch

In gnode send-batch with two specs from the same account that omit nonce (allowed because each item is a normal gnode send spec and the default signer is the faucet), the pre-sign loop calls _build_signed_from_spec before any broadcast, so each call reads the same on-chain nonce via get_transaction_count. The batch then submits duplicate-nonce raw transactions; the second one is rejected or treated as an underpriced replacement instead of producing the advertised same-sender ordered batch. Minimal validation is a two-entry batch to different to addresses without explicit nonces; assign local per-sender nonces while preparing, or reject missing nonces for multi-tx senders.

AGENTS.md reference: AGENTS.md:L8-L8

Useful? React with 👍 / 👎.

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:
Expand Down
Loading
Loading