Skip to content

Commit 60827ad

Browse files
[S3-A2] feat: TronAdapter 新族 + dual-protocol mock (HTTP /wallet/* + JSON-RPC subset)
Tron 节点同时暴露两套 API: - HTTP REST: POST /wallet/<verb> body 直接 JSON 不带 envelope - JSON-RPC 子集: POST /jsonrpc EVM-compat namespace 新族 (adapter_family=tron) 覆盖两套,dispatch by method name shape。 变更: - tools/chain_adapters/tron.py 新增 TronAdapter (注册到 family=tron) · build_vegeta_target: method 形态 dispatch /wallet/* vs eth_* · 5 body 模板: no_params / body_address_visible / body_value_txid_nopfx / body_owner_contract_selector_parameter / body_num · parse_block_height 双形态: Tron envelope 优先, JSON-RPC fallback · health_check: /wallet/getnowblock + parse_jq - tools/chain_adapters/base.py: import 列表加 tron - tools/mock_rpc_server.py: · do_POST 加 path 分支 (/wallet/* 路由到 process_tron_http) · 新增 6 个 Tron HTTP verb handler · CHAIN_HANDLERS 加 tron: handle_evm (JSON-RPC 子集复用) - config/chains/tron.json: _meta.adapter_family jsonrpc → tron - tests/test_chain_adapters.py: · test_1 期望集 6→7 (加 tron) · test_10 test_tron_adapter_shapes 新增 (9 子断言) - tools/e2e_smoke_tron_matrix.sh 新增 (e2e harness + 5 个 /wallet/ HTTP probe) - analysis-notes/p2-exec/wave-S3-A2.md 实施报告 验证: - L1: 10/10 PASS (含新增 test_10) - L3 baseline 8 链: 8/8 PASS (零回归) - L3 S3-A 5 EVM-compat 链: 5/5 PASS (零回归) - L3 S3-A2 Tron: e2e PASS + 5/5 HTTP probe PASS 端口分配: 28557 (e2e), 28568 (HTTP probe sibling)
1 parent faffecf commit 60827ad

7 files changed

Lines changed: 604 additions & 5 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# S3-A2 — TronAdapter 新族实施报告
2+
3+
**Wave**: S3-A2 (第 2 个 wave,9 族中第 7 族新增)
4+
**Baseline**: `faffecf` (S3-A 完成态)
5+
**Status**: ✅ 全绿
6+
**File 变更**: 5 个 (1 新 adapter + 1 chain template 修 meta + 1 mock_rpc_server 扩 + 1 测试 + 1 e2e sibling)
7+
8+
---
9+
10+
## 1. 范围
11+
12+
Tron 新族 (`adapter_family=tron`)。Tron 节点暴露两个 API:
13+
- HTTP REST: `POST /wallet/<verb>` + JSON body (4 个核心 method)
14+
- JSON-RPC 子集: `POST /jsonrpc` (EVM-compat,1 个 method `eth_blockNumber`)
15+
16+
不能套现有 jsonrpc 族,因 `/wallet/*` 完全不是 JSON-RPC envelope。
17+
18+
## 2. 设计决策
19+
20+
**dispatch by method name shape (TronAdapter.build_vegeta_target)**:
21+
- method 以 `/wallet/``/walletsolidity/` 开头 → 拼 base + method, body 走 HTTP body template
22+
- method 以 `eth_`/`net_`/`web3_` 开头 → 拼 base + `/jsonrpc`, body 走 JSON-RPC envelope
23+
- 其他 → ValueError(显式拒,不静默)
24+
25+
**body 模板枚举 (5 个)**:
26+
- `no_params``{}`
27+
- `body_address_visible``{address, visible:true}` (getaccount)
28+
- `body_value_txid_nopfx``{value: <txid_no_0x>}` (gettransactionbyid; 自动去 0x 前缀)
29+
- `body_owner_contract_selector_parameter` → 5 字段 TRC20 balanceOf (triggerconstantcontract)
30+
- `body_num``{num: <int>}` (getblockbynum, 预留)
31+
32+
**parse_block_height 双形态**:
33+
- 主路径: Tron envelope `block_header.raw_data.number` (int)
34+
- fallback: JSON-RPC `.result` 0x-hex (`_try_int` 处理)
35+
36+
**health_check**: `POST /wallet/getnowblock` body `{}`, parse_jq `.block_header.raw_data.number`
37+
38+
## 3. mock_rpc_server 扩展
39+
40+
- `do_POST` 加 path 分支: `/wallet/*``process_tron_http`, 否则走 `process_jsonrpc`
41+
- `_TRON_VERB_HANDLERS` 6 个 verb: getnowblock / getaccount / gettransactionbyid / triggerconstantcontract / getblockbynum / getaccountresource
42+
- `CHAIN_HANDLERS``tron: handle_evm` (供 JSON-RPC 子集复用 EVM handler)
43+
- 13 → 14 链
44+
45+
## 4. 测试矩阵
46+
47+
**L1** (`python3 tests/test_chain_adapters.py`):
48+
- 9 → **10/10 PASS**
49+
- 新 test_10 `test_tron_adapter_shapes` 含 9 子断言: 4 HTTP body 形态 + JSON-RPC 路由 + 2 parse_block_height + health_check + unknown method raise
50+
- test_1 `test_factory_registers_six_families` 期望集 6→7 (加 `tron`)
51+
52+
**L3** (`bash tools/e2e_smoke_tron_matrix.sh`):
53+
- e2e harness PASS (端口 28557)
54+
- 5 个 /wallet/ HTTP probe PASS (端口 28568): getnowblock / getaccount / gettransactionbyid / triggerconstantcontract + / 上 eth_blockNumber
55+
56+
**回归** (baseline 不变):
57+
- `bash tools/e2e_smoke_8chain_matrix.sh`**8/8 PASS**
58+
- `bash tools/e2e_smoke_5evm_compat_matrix.sh`**5/5 PASS**
59+
60+
## 5. chain template 改动
61+
62+
`config/chains/tron.json`:
63+
- `_meta.adapter_family`: `jsonrpc``tron`
64+
- `_meta.s3a2_normalized_at`, `_meta.s3a2_note`
65+
- `param_formats`: 5 个 method 全部已在 adapter 支持枚举内 (无需修改)
66+
67+
## 6. 反向压测自检
68+
69+
|| 状态 |
70+
|----|------|
71+
| 是否真改入口 (`/wallet/*` POST)? | ✅ curl probe 5/5 真返 Tron-shaped JSON |
72+
| 是否伪 e2e (mock-only / SKIP_HTML)? | ⚠️ 是 mock 上的 e2e,真节点未跑 (此 wave 不涉及真节点) |
73+
| 是否破坏 baseline? | ✅ 8/8 + 5/5 PASS, 零回归 |
74+
| adapter ABC 抽象先于具体? | ✅ TronAdapter 继承 ChainAdapter, 走 register 装饰器 |
75+
| feature-coverage defer 风险? | ✅ Tron 5/5 method 全实现, 无 defer |
76+
77+
## 7. 端口分配累计
78+
79+
- baseline: 28545-28551 + 28899 (8 链)
80+
- S3-A: 28552-28556 (5 EVM-compat)
81+
- **S3-A2: 28557 (e2e), 28568 (HTTP probe sibling)**
82+
- 后续 wave 用 28557 之后段 (从 28558 起 — 注意避开 28568)
83+
84+
## 8. 文件清单
85+
86+
新增:
87+
- `tools/chain_adapters/tron.py` (5804 字节)
88+
- `tools/e2e_smoke_tron_matrix.sh` (6900 字节)
89+
90+
修改:
91+
- `tools/chain_adapters/base.py` (加 `tron` 到 import 列表)
92+
- `tools/mock_rpc_server.py` (do_POST 加路径分支 + 6 verb handler + tron 链注册)
93+
- `tests/test_chain_adapters.py` (test_1 期望集扩 + test_10 新增 + main 列表加)
94+
- `config/chains/tron.json` (`_meta.adapter_family` 改 + s3a2 备注)
95+
96+
## 9. 后续 wave
97+
98+
S3-A3 (AvaX 新族) → S3-A4 (Near) → S3-D (Bitcoin 4) → ... 共 7 wave。

config/chains/tron.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@
4848
],
4949
"original_notes": "双协议(HTTP REST POST + JSON-RPC);base58check ↔ hex41 双格式;USDT-TRC20 合约为基准 target",
5050
"adapter_required": true,
51-
"adapter_family": "jsonrpc",
52-
"adapter_family_assigned_at": "2026-05-24T05:24:00.591042Z"
51+
"adapter_family": "tron",
52+
"adapter_family_assigned_at": "2026-05-24T05:24:00.591042Z",
53+
"s3a2_normalized_at": "2026-05-24T10:00:00Z",
54+
"s3a2_note": "Switched from jsonrpc to tron family; standardized param_formats to TronAdapter enum"
5355
}
5456
}

tests/test_chain_adapters.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ def _fail(msg: str):
3636
def test_factory_registers_six_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", "tron"}
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
# ─────────────────────────────────────────────────────────────────────────────
@@ -310,6 +310,89 @@ def test_evm_compat_5chains_standard_enum():
310310
_ok(f"{chain}: {len(pf)} formats all standard")
311311

312312

313+
# ─────────────────────────────────────────────────────────────────────────────
314+
# Test 10: TronAdapter — dual-protocol shapes
315+
# ─────────────────────────────────────────────────────────────────────────────
316+
def test_tron_adapter_shapes():
317+
print("\n[10] TronAdapter: HTTP /wallet/* + JSON-RPC /jsonrpc subset")
318+
import base64
319+
a = get_adapter("tron")
320+
assert a.protocol_family == "tron", f"expected 'tron', got {a.protocol_family!r}"
321+
322+
BASE = "http://localhost:8545"
323+
ADDR = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" # base58 tron address
324+
325+
# 10a: /wallet/getnowblock → empty body
326+
t = a.build_vegeta_target("/wallet/getnowblock", ADDR, BASE, "no_params")
327+
assert t["method"] == "POST"
328+
assert t["url"] == "http://localhost:8545/wallet/getnowblock", f"bad url: {t['url']}"
329+
body = json.loads(base64.b64decode(t["body"]))
330+
assert body == {}, f"expected empty body, got {body}"
331+
_ok(f"/wallet/getnowblock → POST {t['url']} body={{}}")
332+
333+
# 10b: /wallet/getaccount → {address, visible}
334+
t = a.build_vegeta_target("/wallet/getaccount", ADDR, BASE, "body_address_visible")
335+
body = json.loads(base64.b64decode(t["body"]))
336+
assert body == {"address": ADDR, "visible": True}, f"bad body: {body}"
337+
_ok(f"/wallet/getaccount → body={{address: {ADDR[:10]}..., visible: True}}")
338+
339+
# 10c: /wallet/gettransactionbyid → {value: txid_no_0x}
340+
txid = "0xabc123" + "0" * 58
341+
t = a.build_vegeta_target("/wallet/gettransactionbyid", txid, BASE, "body_value_txid_nopfx")
342+
body = json.loads(base64.b64decode(t["body"]))
343+
assert body["value"] == "abc123" + "0" * 58, f"expected stripped 0x: {body}"
344+
_ok(f"/wallet/gettransactionbyid → body.value 0x-stripped")
345+
346+
# 10d: /wallet/triggerconstantcontract → 5-field body
347+
t = a.build_vegeta_target(
348+
"/wallet/triggerconstantcontract", ADDR, BASE,
349+
"body_owner_contract_selector_parameter",
350+
)
351+
body = json.loads(base64.b64decode(t["body"]))
352+
assert body["function_selector"] == "balanceOf(address)", f"bad selector: {body.get('function_selector')}"
353+
assert body["owner_address"] == ADDR
354+
assert len(body["parameter"]) == 64, f"parameter must be 32-byte hex, got len={len(body['parameter'])}"
355+
_ok(f"/wallet/triggerconstantcontract → 5-field body with balanceOf selector")
356+
357+
# 10e: JSON-RPC subset routes to /jsonrpc path
358+
t = a.build_vegeta_target("eth_blockNumber", ADDR, BASE, "no_params")
359+
assert t["url"] == "http://localhost:8545/jsonrpc", f"jsonrpc path mismatch: {t['url']}"
360+
body = json.loads(base64.b64decode(t["body"]))
361+
assert body["method"] == "eth_blockNumber"
362+
assert body["params"] == []
363+
_ok(f"eth_blockNumber → POST /jsonrpc with JSON-RPC envelope")
364+
365+
# 10f: parse_block_height — Tron getnowblock response
366+
sample = json.dumps({
367+
"blockID": "0" * 64,
368+
"block_header": {"raw_data": {"number": 60100000, "timestamp": 1735200000000}},
369+
})
370+
h = a.parse_block_height(sample)
371+
assert h == 60100000, f"expected 60100000, got {h}"
372+
_ok(f"parse_block_height Tron envelope → 60100000")
373+
374+
# 10g: parse_block_height — JSON-RPC fallback
375+
rpc_sample = json.dumps({"jsonrpc": "2.0", "id": 1, "result": "0x3947ea0"})
376+
h = a.parse_block_height(rpc_sample)
377+
assert h == 0x3947ea0, f"expected {0x3947ea0}, got {h}"
378+
_ok(f"parse_block_height JSON-RPC fallback → {0x3947ea0}")
379+
380+
# 10h: health_check_request shape
381+
hc = a.health_check_request(BASE)
382+
assert hc["method"] == "POST"
383+
assert hc["url"] == BASE + "/wallet/getnowblock"
384+
assert hc["body"] == "{}"
385+
assert hc["parse_jq"] == ".block_header.raw_data.number"
386+
_ok(f"health_check → POST /wallet/getnowblock + parse_jq")
387+
388+
# 10i: unknown method shape → raises
389+
try:
390+
a.build_vegeta_target("foo_bar", ADDR, BASE, "")
391+
_fail("expected ValueError for unknown method")
392+
except ValueError as e:
393+
_ok(f"unknown method raises ValueError: {str(e)[:60]}")
394+
395+
313396
# ─────────────────────────────────────────────────────────────────────────────
314397
# Main
315398
# ─────────────────────────────────────────────────────────────────────────────
@@ -324,6 +407,7 @@ def main():
324407
test_rest_requires_env_and_path_map,
325408
test_jsonrpc_s3a_new_formats,
326409
test_evm_compat_5chains_standard_enum,
410+
test_tron_adapter_shapes,
327411
]
328412
print(f"Running {len(tests)} test groups for chain_adapters")
329413
for t in tests:

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, tron # noqa: E402, F401

tools/chain_adapters/tron.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""TronAdapter — dual-protocol (HTTP /wallet/* + JSON-RPC /jsonrpc subset).
2+
3+
Tron node exposes two APIs on the same host:
4+
1. HTTP API: POST /wallet/<verb> + JSON body (each method → distinct path)
5+
2. JSON-RPC: POST /jsonrpc + body.method (EVM-compat subset)
6+
7+
DSL dispatch by method name shape:
8+
- method starts with "/wallet/" or "/walletsolidity/" → HTTP API path
9+
- method starts with "eth_" or "net_" or "web3_" → JSON-RPC at /jsonrpc
10+
11+
Param formats (extracted from research doc docs/zh/chains/09-tron.md):
12+
no_params → empty body {}
13+
body_address_visible → {"address": <addr>, "visible": true}
14+
body_value_txid_nopfx → {"value": <txid>} # no 0x
15+
body_owner_contract_selector_parameter → triggerconstantcontract balanceOf
16+
17+
For JSON-RPC subset, delegates to JsonRpcAdapter param logic (address_latest etc.).
18+
19+
Endpoint convention:
20+
rpc_url passed in is the BASE host (e.g. https://api.trongrid.io)
21+
HTTP methods append /wallet/<verb>; JSON-RPC methods append /jsonrpc
22+
"""
23+
from __future__ import annotations
24+
import json
25+
from typing import Optional
26+
27+
from .base import ChainAdapter, register, _vegeta_post_json, _try_int
28+
29+
30+
@register("tron")
31+
class TronAdapter(ChainAdapter):
32+
33+
def build_vegeta_target(
34+
self, method: str, address: str, rpc_url: str, param_format: str = "",
35+
) -> dict:
36+
# Strip trailing slash from base URL to normalize
37+
base = rpc_url.rstrip("/")
38+
39+
# HTTP API path: method itself is the path like "/wallet/getaccount"
40+
if method.startswith("/wallet/") or method.startswith("/walletsolidity/"):
41+
url = base + method
42+
body_obj = self._build_http_body(param_format, address)
43+
return _vegeta_post_json(url, body_obj)
44+
45+
# JSON-RPC subset: eth_*/net_*/web3_*
46+
if method.startswith(("eth_", "net_", "web3_")):
47+
url = base + "/jsonrpc"
48+
params = self._build_jsonrpc_params(param_format, address)
49+
body = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
50+
return _vegeta_post_json(url, body)
51+
52+
# Unknown method shape → raise to surface in CI
53+
raise ValueError(
54+
f"TronAdapter: cannot dispatch method {method!r}. "
55+
f"Expected '/wallet/...' or 'eth_*'/'net_*'/'web3_*'."
56+
)
57+
58+
@staticmethod
59+
def _build_http_body(param_format: str, address: str) -> dict:
60+
"""Body templates for Tron HTTP API. address is repurposed by format."""
61+
if param_format == "no_params":
62+
return {}
63+
if param_format == "body_address_visible":
64+
# /wallet/getaccount, /wallet/getaccountresource → {address, visible}
65+
return {"address": address, "visible": True}
66+
if param_format == "body_value_txid_nopfx":
67+
# /wallet/gettransactionbyid → {value: <txid_no_0x>}
68+
# If address starts with 0x, strip it
69+
txid = address[2:] if address.startswith("0x") else address
70+
return {"value": txid}
71+
if param_format == "body_num":
72+
# /wallet/getblockbynum → {num: <int>}
73+
try:
74+
n = int(address)
75+
except (TypeError, ValueError):
76+
n = 1
77+
return {"num": n}
78+
if param_format == "body_owner_contract_selector_parameter":
79+
# /wallet/triggerconstantcontract → balanceOf(address)
80+
# owner_address and contract_address both base58 ("T..." form);
81+
# parameter is 32-byte hex-padded address (last 20B of hex41 form, padded)
82+
# For placeholder: use empty padded parameter.
83+
return {
84+
"owner_address": address,
85+
"contract_address": address, # placeholder; production injects real TRC20 contract
86+
"function_selector": "balanceOf(address)",
87+
"parameter": "0" * 64, # 32-byte padded zero
88+
"visible": True,
89+
}
90+
# default fallback
91+
return {}
92+
93+
@staticmethod
94+
def _build_jsonrpc_params(param_format: str, address: str) -> list:
95+
"""Minimal JSON-RPC param builder for Tron's /jsonrpc subset."""
96+
if param_format == "no_params" or param_format == "":
97+
return []
98+
if param_format == "single_address":
99+
return [address]
100+
if param_format == "address_latest":
101+
return [address, "latest"]
102+
if param_format == "transaction_hash":
103+
tx_hash = address if address.startswith("0x") and len(address) == 66 else "0x" + "0" * 64
104+
return [tx_hash]
105+
return [address]
106+
107+
def health_check_request(self, rpc_url: str) -> dict:
108+
"""Health probe = HTTP /wallet/getnowblock with empty body."""
109+
base = rpc_url.rstrip("/")
110+
return {
111+
"method": "POST",
112+
"url": base + "/wallet/getnowblock",
113+
"headers": {"Content-Type": "application/json"},
114+
"body": "{}",
115+
# parse: $.block_header.raw_data.number
116+
"parse_jq": ".block_header.raw_data.number",
117+
}
118+
119+
def parse_block_height(self, response_text: str) -> Optional[int]:
120+
"""Parse Tron getnowblock response → block_header.raw_data.number (int).
121+
122+
Also handles the JSON-RPC eth_blockNumber response (.result as 0x-hex).
123+
"""
124+
if not response_text:
125+
return None
126+
try:
127+
obj = json.loads(response_text)
128+
except json.JSONDecodeError:
129+
return None
130+
# HTTP API path
131+
try:
132+
n = obj["block_header"]["raw_data"]["number"]
133+
return _try_int(n)
134+
except (KeyError, TypeError):
135+
pass
136+
# JSON-RPC fallback
137+
return _try_int(obj.get("result"))

0 commit comments

Comments
 (0)