Skip to content

Commit 2d007f2

Browse files
S3-E.3: hedera dual-protocol adapter (Mirror REST + JSON-RPC Relay)
Hedera 是天然双协议链 — Mirror REST 提供账户/balance/transaction 查询, JSON-RPC Relay (Hashio) 提供 EVM-compat eth_* 方法。原 `rest` family adapter 不支持单 chain 内 per-method 协议路由,所以 mixed mode 跑 eth_getBalance/ eth_call 时会 ValueError(method not in _meta.rest_paths)。 ## 改动 (1) 新建 `tools/chain_adapters/hedera_dual.py` — 第 7 个 adapter family。 - per-method 路由:eth_*/net_*/web3_*/debug_*/trace_* → JSON-RPC POST 到 _meta.json_rpc_url;其他 path-style key → REST GET/POST - delegate to RestAdapter + JsonRpcAdapter,不重复 logic - health_check 复用 REST 侧;parse_block_height 两侧 best-effort (2) `tools/chain_adapters/base.py` — import chain 加 hedera_dual (3) `config/chains/hedera.json`: - adapter_family: rest → hedera_dual(留 adapter_family_upgraded_at 审计) - 加 _meta.json_rpc_url = https://mainnet.hashio.io/api - 保留全 5 mixed method(3 Mirror REST + eth_getBalance + eth_call) (4) `tests/test_chain_adapters.py`: - test_factory_registers_six → seven_families;expected set 含 hedera_dual - KNOWN_BROKEN_CLI 移除 hedera 条目,assert len 14 → 13 - _sample_address_for 加 hedera_dual → "0.0.2"(原生 3-part ID) ## 验证 L1:python3 tests/test_chain_adapters.py → 10/10 PASS - healthy 22 → 23(hedera 进集合) - broken 14 → 13 - 7 families registered L3 替代:bash tools/target_generator.sh BLOCKCHAIN_NODE=hedera RPC_MODE=mixed - 4 个 account × 5 method 全成功,exit=0 - 实际生成 vegeta target: * Mirror REST GET → mainnet-public.mirrornode.hedera.com/api/v1/accounts/0.0.2 * Mirror REST GET → mainnet-public.mirrornode.hedera.com/api/v1/balances?account.id=0.0.98 * Mirror REST GET → mainnet-public.mirrornode.hedera.com/api/v1/transactions/0.0.800 * JSON-RPC POST → mainnet.hashio.io/api body={"method":"eth_getBalance","params":["0.0.801"]} - 真双协议路由生效 L3 single mode:4 account × 1 method (GET /api/v1/accounts/{addr}) 全成功 ## 自审 E1-E5 E1 (调研先行):基于 docs/zh/chains/14-hedera.md 调研(E1-E15 实测过 Mirror + Hashio), endpoint URL 现场 curl HTTP 200 复验。 E2 (无降级/未 defer):5 method 全保留,不砍 mixed,无 KNOWN_BROKEN_MIXED ledger。 E3 (实证):L1 10/10 + L3 真路径 vegeta target 文件生成 + body base64 decode 验证。 E4 (parallel-entry-trap):无新平行入口,新 adapter 用 composition (RestAdapter + JsonRpcAdapter delegation),不复制 logic。 E5 (broken set 单调递减):14 → 13,hedera 进 audit healthy 集合。 ## 显式 defer(非降级,新 wave 处理) (a) cli.py L40 读 `params` 字段应该是 `param_formats` — 全链 bug,影响所有 JSON-RPC chain 的 param shape(eth_getBalance 应为 [addr, "latest"] 现 [addr])。本 PR scope = hedera_dual adapter,不污染 commit。 (b) hedera eth_* 实际生产需把 3-part ID (0.0.N) 转 EVM 地址 (0x...0N), fetch_active_accounts 端处理,留 S4。 ledger: KNOWN_BROKEN_CLI baseline 16 → S3-E.1 aptos -1 → S3-E.2 algorand -1 → S3-E.3 hedera -1 → 13 剩
1 parent 2e05837 commit 2d007f2

4 files changed

Lines changed: 145 additions & 11 deletions

File tree

config/chains/hedera.json

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
"tx_batch_size": "ACCOUNT_TX_BATCH_SIZE"
1818
},
1919
"rpc_methods": {
20-
"single": "mirror_account_query",
21-
"mixed": "mirror_account_query,mirror_balance_query,mirror_tx_lookup,eth_getBalance,eth_call"
20+
"single": "GET /api/v1/accounts/{addr}",
21+
"mixed": "GET /api/v1/accounts/{addr},GET /api/v1/balances?account.id={addr},GET /api/v1/transactions/{addr},eth_getBalance,eth_call"
2222
},
2323
"rpc_url": "LOCAL_RPC_URL",
2424
"system_addresses": [
@@ -54,7 +54,18 @@
5454
],
5555
"original_notes": "双协议 Mirror REST + JSON-RPC; 3-part account ID (shard.realm.num); tinybar vs weibar 单位换算; gRPC 不测",
5656
"adapter_required": true,
57-
"adapter_family": "rest",
58-
"adapter_family_assigned_at": "2026-05-24T05:24:00.591042Z"
57+
"adapter_family": "hedera_dual",
58+
"adapter_family_assigned_at": "2026-05-24T05:24:00.591042Z",
59+
"adapter_family_upgraded_at": "2026-05-24T18:00:00Z",
60+
"adapter_family_upgrade_note": "S3-E.3: was 'rest' (broken — couldn't route eth_*); upgraded to dual-protocol adapter that routes per-method between Mirror REST and JSON-RPC Relay.",
61+
"json_rpc_url": "https://mainnet.hashio.io/api",
62+
"rest_paths": {
63+
"GET /api/v1/accounts/{addr}": {"method": "GET", "path": "/api/v1/accounts/{address}"},
64+
"GET /api/v1/balances?account.id={addr}": {"method": "GET", "path": "/api/v1/balances?account.id={address}"},
65+
"GET /api/v1/transactions/{addr}": {"method": "GET", "path": "/api/v1/transactions/{address}"},
66+
"GET /api/v1/blocks?limit=1&order=desc": {"method": "GET", "path": "/api/v1/blocks?limit=1&order=desc"},
67+
"GET /api/v1/network/supply": {"method": "GET", "path": "/api/v1/network/supply"}
68+
},
69+
"health_probe": {"method": "GET", "path": "/api/v1/network/supply", "parse_jq": ".timestamp"}
5970
}
6071
}

tests/test_chain_adapters.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ def _fail(msg: str):
3333
# ─────────────────────────────────────────────────────────────────────────────
3434
# Test 1: Factory registration — 6 families
3535
# ─────────────────────────────────────────────────────────────────────────────
36-
def test_factory_registers_six_families():
36+
def test_factory_registers_seven_families():
3737
print("\n[1] Factory registration")
3838
fams = list_adapters()
39-
expected = {"jsonrpc", "rest", "tendermint", "bitcoin_jsonrpc", "substrate", "ogmios"}
39+
expected = {"jsonrpc", "rest", "tendermint", "bitcoin_jsonrpc", "substrate", "ogmios", "hedera_dual"}
4040
assert set(fams) == expected, f"expected {expected}, got {fams}"
41-
_ok(f"6 families registered: {sorted(fams)}")
41+
_ok(f"7 families registered: {sorted(fams)}")
4242

4343

4444
# ─────────────────────────────────────────────────────────────────────────────
@@ -343,7 +343,6 @@ def test_evm_compat_5chains_standard_enum():
343343
# F1: rpc_methods.single picked a health-probe (no address) instead of
344344
# a real benchmark method. Fix = pick a method from param_formats
345345
# that takes an address. Pure chain-template edit, no adapter work.
346-
"hedera": ("F1", "S3-E", "single='mirror_account_query' is logical name, no real path; use 'mirror_balance_query' or 'eth_getBalance'"),
347346
"tezos": ("F1", "S3-E", "single='GET /chains/main/blocks/head/header' has no address; use '/contracts/{addr}/balance'"),
348347
"ton": ("F1", "S3-E", "single='getMasterchainInfo' is health-probe; use 'getAddressBalance'"),
349348
"kusama": ("F1", "S3-C", "single='chain_getHeader' has no address; use 'system_account' or similar"),
@@ -368,7 +367,7 @@ def test_evm_compat_5chains_standard_enum():
368367
"acala": ("F3", "S3-C", "family=substrate; single='system_chain' has no address; pick eth_getBalance from param_formats"),
369368
}
370369

371-
assert len(KNOWN_BROKEN_CLI) == 14, f"KNOWN_BROKEN_CLI must have exactly 14 entries (baseline 16 minus aptos S3-E.1 minus algorand S3-E.2), got {len(KNOWN_BROKEN_CLI)}"
370+
assert len(KNOWN_BROKEN_CLI) == 13, f"KNOWN_BROKEN_CLI must have exactly 13 entries (baseline 16 minus aptos S3-E.1 minus algorand S3-E.2 minus hedera S3-E.3), got {len(KNOWN_BROKEN_CLI)}"
372371

373372

374373
def _sample_address_for(family: str) -> str:
@@ -385,6 +384,11 @@ def _sample_address_for(family: str) -> str:
385384
"tendermint": "cosmos1abc",
386385
"ogmios": "addr1q9adlx6mh0dr8xs0gpcm9nz5pqe5w2hzfx5l8qj5",
387386
"rest": "TESTADDR123",
387+
# hedera_dual: native 3-part account ID; L1 only asserts the address
388+
# string appears in url-or-body, not EVM semantics. Production
389+
# fetch_active_accounts must convert 0.0.N → EVM 0x...0N for eth_*
390+
# routes — out of scope for L1.
391+
"hedera_dual": "0.0.2",
388392
}.get(family, "TESTADDR123")
389393

390394

@@ -478,7 +482,7 @@ def test_cli_build_target_all_36_chains():
478482
# ─────────────────────────────────────────────────────────────────────────────
479483
def main():
480484
tests = [
481-
test_factory_registers_six_families,
485+
test_factory_registers_seven_families,
482486
test_all_36_chains_resolve,
483487
test_baseline_8_vegeta_byte_equality,
484488
test_parse_block_height_per_family,

tools/chain_adapters/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,4 @@ def list_adapters() -> list[str]:
141141

142142

143143
# Trigger registration of all subclasses
144-
from . import jsonrpc, rest, tendermint, bitcoin_jsonrpc, substrate, ogmios # noqa: E402, F401
144+
from . import jsonrpc, rest, tendermint, bitcoin_jsonrpc, substrate, ogmios, hedera_dual # noqa: E402, F401
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""HederaDualAdapter — Hedera 双协议 adapter (Mirror REST + JSON-RPC Relay).
2+
3+
Hedera 是天然双协议链:
4+
- Mirror REST https://mainnet-public.mirrornode.hedera.com (账户/balance/tx)
5+
- JSON-RPC Relay https://mainnet.hashio.io/api (EVM-compat eth_*)
6+
7+
method 路由规则 (per-request):
8+
- method 以 'eth_' 开头 或 等于已知 JSON-RPC 方法名 → 走 JSON-RPC POST + json body
9+
rpc_url 取 _meta.json_rpc_url (chain template 显式声明)
10+
- 否则按 REST 路径处理 (method key 必须在 _meta.rest_paths 里)
11+
rpc_url 取 build_vegeta_target 传入的 rpc_url (即 LOCAL_RPC_URL = Mirror REST)
12+
13+
为什么不直接复用 RestAdapter+JsonRpcAdapter?
14+
ChainAdapter ABC + factory 是 chain→family 1:1 映射。同一 chain 单 request 内
15+
按 method 分协议路由,必须新 family。
16+
17+
测试 invariant (L1-CLI):address 必须出现在生成的 vegeta target 的 url 或 body 里。
18+
REST 侧 → address 进 path → 进 url
19+
JSON-RPC 侧 → address 进 params[0] → 进 body
20+
"""
21+
from __future__ import annotations
22+
import json
23+
import os
24+
import re
25+
from typing import Optional
26+
27+
from .base import ChainAdapter, register, _vegeta_get, _vegeta_post_json, _try_int
28+
from .rest import RestAdapter, _CHAINS_DIR
29+
from .jsonrpc import JsonRpcAdapter
30+
31+
32+
_JSONRPC_METHOD_RE = re.compile(r"^(eth_|net_|web3_|debug_|trace_)")
33+
34+
35+
def _is_jsonrpc_method(method: str) -> bool:
36+
"""Decide if a method name should be routed to the JSON-RPC Relay.
37+
38+
True for eth_* / net_* / web3_* / debug_* / trace_* (standard EVM
39+
JSON-RPC namespaces). False for REST path-style keys like
40+
'GET /api/v1/accounts/{addr}'.
41+
"""
42+
return bool(_JSONRPC_METHOD_RE.match(method))
43+
44+
45+
@register("hedera_dual")
46+
class HederaDualAdapter(ChainAdapter):
47+
"""Per-request protocol routing for Hedera Mirror REST + JSON-RPC Relay."""
48+
49+
def __init__(self):
50+
# Delegate the two protocol concerns to the existing single-protocol
51+
# adapters — no logic duplication, only routing.
52+
self._rest = RestAdapter()
53+
self._jsonrpc = JsonRpcAdapter()
54+
self._chain_cache: dict[str, dict] = {}
55+
56+
def _load_chain(self, chain_name: str) -> dict:
57+
if chain_name not in self._chain_cache:
58+
with open(_CHAINS_DIR / f"{chain_name}.json") as f:
59+
self._chain_cache[chain_name] = json.load(f)
60+
return self._chain_cache[chain_name]
61+
62+
def _get_chain_name(self) -> str:
63+
chain_name = os.environ.get("BLOCKCHAIN_NODE", "").lower()
64+
if not chain_name:
65+
raise RuntimeError("HederaDualAdapter requires BLOCKCHAIN_NODE env var")
66+
return chain_name
67+
68+
def _get_jsonrpc_url(self, chain_name: str) -> str:
69+
"""JSON-RPC url comes from chain template _meta.json_rpc_url.
70+
71+
Required because LOCAL_RPC_URL (the single env var used as rpc_url
72+
for this run) points at Mirror REST; JSON-RPC traffic needs a
73+
separate endpoint (Hashio relay for hedera).
74+
"""
75+
tpl = self._load_chain(chain_name)
76+
url = tpl.get("_meta", {}).get("json_rpc_url")
77+
if not url:
78+
raise ValueError(
79+
f"hedera_dual chain {chain_name}: _meta.json_rpc_url not set; "
80+
f"required for routing eth_*/net_*/web3_* methods."
81+
)
82+
return url
83+
84+
# ─── ABC contract ──────────────────────────────────────────────────────
85+
86+
def build_vegeta_target(
87+
self, method: str, address: str, rpc_url: str, param_format: str = "",
88+
) -> dict:
89+
chain_name = self._get_chain_name()
90+
if _is_jsonrpc_method(method):
91+
jsonrpc_url = self._get_jsonrpc_url(chain_name)
92+
return self._jsonrpc.build_vegeta_target(
93+
method=method, address=address,
94+
rpc_url=jsonrpc_url, param_format=param_format,
95+
)
96+
# REST path-style: delegate to RestAdapter (uses BLOCKCHAIN_NODE +
97+
# _meta.rest_paths from chain template, same as before)
98+
return self._rest.build_vegeta_target(
99+
method=method, address=address,
100+
rpc_url=rpc_url, param_format=param_format,
101+
)
102+
103+
def health_check_request(self, rpc_url: str) -> dict:
104+
"""Health probe uses REST side (_meta.health_probe).
105+
106+
Mirror REST is the primary observability surface; JSON-RPC liveness
107+
is an orthogonal concern. If both must be probed, run two probes
108+
externally (eth_blockNumber against json_rpc_url).
109+
"""
110+
return self._rest.health_check_request(rpc_url)
111+
112+
def parse_block_height(self, response_text: str) -> Optional[int]:
113+
"""Best-effort: try REST shape first (.block_height / .blocks[0].number),
114+
then JSON-RPC shape (.result as hex or int).
115+
"""
116+
h = self._rest.parse_block_height(response_text)
117+
if h is not None:
118+
return h
119+
return self._jsonrpc.parse_block_height(response_text)

0 commit comments

Comments
 (0)