trc-8004-sdk 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdk/__init__.py +123 -0
- sdk/agent_protocol_client.py +180 -0
- sdk/agent_sdk.py +1549 -0
- sdk/chain_utils.py +278 -0
- sdk/cli.py +549 -0
- sdk/client.py +202 -0
- sdk/contract_adapter.py +489 -0
- sdk/exceptions.py +652 -0
- sdk/retry.py +509 -0
- sdk/signer.py +284 -0
- sdk/utils.py +163 -0
- trc_8004_sdk-0.1.0b1.dist-info/METADATA +411 -0
- trc_8004_sdk-0.1.0b1.dist-info/RECORD +16 -0
- trc_8004_sdk-0.1.0b1.dist-info/WHEEL +5 -0
- trc_8004_sdk-0.1.0b1.dist-info/entry_points.txt +2 -0
- trc_8004_sdk-0.1.0b1.dist-info/top_level.txt +1 -0
sdk/client.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TRC-8004 SDK Agent 客户端模块
|
|
3
|
+
|
|
4
|
+
提供智能 HTTP 客户端,自动解析 Agent 元数据中的端点。
|
|
5
|
+
|
|
6
|
+
Classes:
|
|
7
|
+
AgentClient: 智能 Agent HTTP 客户端
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
>>> from sdk.client import AgentClient
|
|
11
|
+
>>> client = AgentClient(
|
|
12
|
+
... metadata=agent_metadata,
|
|
13
|
+
... base_url="https://agent.example.com",
|
|
14
|
+
... )
|
|
15
|
+
>>> response = client.post("quote", {"asset": "TRX/USDT", "amount": 100})
|
|
16
|
+
|
|
17
|
+
Note:
|
|
18
|
+
- 支持从 Agent 元数据自动解析端点
|
|
19
|
+
- 遵循 A2A 协议的 URL 约定
|
|
20
|
+
- 支持 mock 模式用于测试
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from typing import Dict, Any, Optional
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AgentClient:
|
|
29
|
+
"""
|
|
30
|
+
智能 Agent HTTP 客户端。
|
|
31
|
+
|
|
32
|
+
根据 Agent 元数据自动解析端点 URL,支持:
|
|
33
|
+
- 从 metadata.url 获取基础 URL
|
|
34
|
+
- 从 metadata.endpoints 获取 A2A 端点
|
|
35
|
+
- 从 metadata.skills 获取特定能力的端点
|
|
36
|
+
- 使用 A2A 协议约定构造 URL
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
metadata: Agent 元数据字典
|
|
40
|
+
base_url: 基础 URL(优先级低于 metadata)
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
metadata: Agent 元数据,通常从 Central Service 获取
|
|
44
|
+
base_url: 基础 URL,作为 metadata 的回退
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> # 使用元数据
|
|
48
|
+
>>> client = AgentClient(metadata={
|
|
49
|
+
... "url": "https://agent.example.com",
|
|
50
|
+
... "skills": [{"id": "quote", "endpoint": "/custom/quote"}],
|
|
51
|
+
... })
|
|
52
|
+
>>> client.resolve_url("quote")
|
|
53
|
+
'https://agent.example.com/custom/quote'
|
|
54
|
+
>>>
|
|
55
|
+
>>> # 使用基础 URL
|
|
56
|
+
>>> client = AgentClient(base_url="https://agent.example.com")
|
|
57
|
+
>>> client.resolve_url("execute")
|
|
58
|
+
'https://agent.example.com/a2a/execute'
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
64
|
+
base_url: Optional[str] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
初始化 Agent 客户端。
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
metadata: Agent 元数据字典,包含 url、endpoints、skills 等
|
|
71
|
+
base_url: 基础 URL,当 metadata 中没有 URL 时使用
|
|
72
|
+
"""
|
|
73
|
+
self.metadata = metadata or {}
|
|
74
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
75
|
+
|
|
76
|
+
def resolve_url(self, capability: str) -> str:
|
|
77
|
+
"""
|
|
78
|
+
解析能力/技能对应的完整 URL。
|
|
79
|
+
|
|
80
|
+
解析优先级:
|
|
81
|
+
1. 检查 mock 模式
|
|
82
|
+
2. 从 metadata.url 或 base_url 获取基础 URL
|
|
83
|
+
3. 从 metadata.endpoints 查找 A2A 端点
|
|
84
|
+
4. 从 metadata.skills 查找特定能力的端点
|
|
85
|
+
5. 使用 A2A 协议约定:{base_url}/a2a/{capability}
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
capability: 能力/技能名称(如 'quote', 'execute')
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
完整的端点 URL
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: 无法找到基础 URL
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> client = AgentClient(base_url="https://agent.example.com")
|
|
98
|
+
>>> client.resolve_url("quote")
|
|
99
|
+
'https://agent.example.com/a2a/quote'
|
|
100
|
+
"""
|
|
101
|
+
# 0. 检查 Mock 模式
|
|
102
|
+
if self.base_url == "mock":
|
|
103
|
+
return "mock"
|
|
104
|
+
|
|
105
|
+
# 1. 获取基础 URL
|
|
106
|
+
base_url = self.metadata.get("url") or self.base_url
|
|
107
|
+
if not base_url:
|
|
108
|
+
# 尝试从 endpoints 获取 A2A 端点
|
|
109
|
+
for endpoint in self.metadata.get("endpoints", []):
|
|
110
|
+
if endpoint.get("name", "").lower() == "a2a":
|
|
111
|
+
base_url = endpoint.get("endpoint", "")
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
if not base_url:
|
|
115
|
+
raise ValueError(f"No Base URL found for agent to capability '{capability}'")
|
|
116
|
+
|
|
117
|
+
# 2. 检查 skills 中是否有特定端点
|
|
118
|
+
skills = self.metadata.get("skills", [])
|
|
119
|
+
if skills:
|
|
120
|
+
skill = next((s for s in skills if s.get("id") == capability), None)
|
|
121
|
+
if skill:
|
|
122
|
+
endpoint = skill.get("endpoint") or skill.get("path")
|
|
123
|
+
if endpoint:
|
|
124
|
+
# 绝对 URL
|
|
125
|
+
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
|
126
|
+
return endpoint
|
|
127
|
+
# 相对路径
|
|
128
|
+
return f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
129
|
+
|
|
130
|
+
# 3. 使用 A2A 协议约定
|
|
131
|
+
return f"{base_url.rstrip('/')}/a2a/{capability}"
|
|
132
|
+
|
|
133
|
+
def post(
|
|
134
|
+
self,
|
|
135
|
+
capability: str,
|
|
136
|
+
json_data: Dict[str, Any],
|
|
137
|
+
timeout: float = 10.0,
|
|
138
|
+
) -> Dict[str, Any]:
|
|
139
|
+
"""
|
|
140
|
+
向指定能力端点发送 POST 请求。
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
capability: 能力/技能名称
|
|
144
|
+
json_data: 请求体 JSON 数据
|
|
145
|
+
timeout: 请求超时时间(秒)
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
响应 JSON 数据
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: 无法解析 URL
|
|
152
|
+
httpx.HTTPStatusError: HTTP 请求失败
|
|
153
|
+
httpx.TimeoutException: 请求超时
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
>>> response = client.post("quote", {
|
|
157
|
+
... "asset": "TRX/USDT",
|
|
158
|
+
... "amount": 100,
|
|
159
|
+
... })
|
|
160
|
+
"""
|
|
161
|
+
url = self.resolve_url(capability)
|
|
162
|
+
if url == "mock":
|
|
163
|
+
return {"mock": True}
|
|
164
|
+
|
|
165
|
+
with httpx.Client(timeout=timeout) as client:
|
|
166
|
+
response = client.post(url, json=json_data)
|
|
167
|
+
response.raise_for_status()
|
|
168
|
+
return response.json()
|
|
169
|
+
|
|
170
|
+
def get(
|
|
171
|
+
self,
|
|
172
|
+
capability: str,
|
|
173
|
+
params: Optional[Dict[str, Any]] = None,
|
|
174
|
+
timeout: float = 10.0,
|
|
175
|
+
) -> Dict[str, Any]:
|
|
176
|
+
"""
|
|
177
|
+
向指定能力端点发送 GET 请求。
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
capability: 能力/技能名称
|
|
181
|
+
params: URL 查询参数
|
|
182
|
+
timeout: 请求超时时间(秒)
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
响应 JSON 数据
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
ValueError: 无法解析 URL
|
|
189
|
+
httpx.HTTPStatusError: HTTP 请求失败
|
|
190
|
+
httpx.TimeoutException: 请求超时
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
>>> response = client.get("status", params={"order_id": "123"})
|
|
194
|
+
"""
|
|
195
|
+
url = self.resolve_url(capability)
|
|
196
|
+
if url == "mock":
|
|
197
|
+
return {"mock": True}
|
|
198
|
+
|
|
199
|
+
with httpx.Client(timeout=timeout) as client:
|
|
200
|
+
response = client.get(url, params=params)
|
|
201
|
+
response.raise_for_status()
|
|
202
|
+
return response.json()
|
sdk/contract_adapter.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TRC-8004 合约适配器
|
|
3
|
+
|
|
4
|
+
提供与不同区块链交互的抽象层,支持:
|
|
5
|
+
- DummyContractAdapter: 本地开发/测试
|
|
6
|
+
- TronContractAdapter: TRON 区块链
|
|
7
|
+
- (未来) EVMContractAdapter: EVM 兼容链
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, List, Optional
|
|
13
|
+
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
ContractCallError,
|
|
16
|
+
ContractFunctionNotFoundError,
|
|
17
|
+
InsufficientEnergyError,
|
|
18
|
+
MissingContractAddressError,
|
|
19
|
+
NetworkError,
|
|
20
|
+
TransactionFailedError,
|
|
21
|
+
)
|
|
22
|
+
from .retry import RetryConfig, DEFAULT_RETRY_CONFIG, retry
|
|
23
|
+
from .signer import Signer
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("trc8004.adapter")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ContractAdapter:
|
|
29
|
+
"""
|
|
30
|
+
合约适配器抽象基类
|
|
31
|
+
|
|
32
|
+
定义与区块链合约交互的标准接口。
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def call(self, contract: str, method: str, params: List[Any]) -> Any:
|
|
36
|
+
"""
|
|
37
|
+
调用合约只读方法
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
contract: 合约名称 ("identity", "validation", "reputation")
|
|
41
|
+
method: 方法名
|
|
42
|
+
params: 参数列表
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
调用结果
|
|
46
|
+
"""
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
def send(self, contract: str, method: str, params: List[Any], signer: Signer) -> str:
|
|
50
|
+
"""
|
|
51
|
+
发送合约交易
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
contract: 合约名称
|
|
55
|
+
method: 方法名
|
|
56
|
+
params: 参数列表
|
|
57
|
+
signer: 签名器
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
交易 ID
|
|
61
|
+
"""
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DummyContractAdapter(ContractAdapter):
|
|
66
|
+
"""
|
|
67
|
+
本地测试用适配器
|
|
68
|
+
|
|
69
|
+
返回确定性的交易 ID,不进行实际的区块链交互。
|
|
70
|
+
适用于单元测试和本地开发。
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def call(self, contract: str, method: str, params: List[Any]) -> Any:
|
|
74
|
+
return {"contract": contract, "method": method, "params": params}
|
|
75
|
+
|
|
76
|
+
def send(self, contract: str, method: str, params: List[Any], signer: Signer) -> str:
|
|
77
|
+
stamp = int(time.time() * 1000)
|
|
78
|
+
return f"0x{contract}-{method}-{stamp}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TronContractAdapter(ContractAdapter):
|
|
82
|
+
"""
|
|
83
|
+
TRON 区块链合约适配器
|
|
84
|
+
|
|
85
|
+
使用 tronpy 库与 TRON 区块链交互。
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
rpc_url: TRON RPC 节点地址
|
|
89
|
+
identity_registry: IdentityRegistry 合约地址
|
|
90
|
+
validation_registry: ValidationRegistry 合约地址
|
|
91
|
+
reputation_registry: ReputationRegistry 合约地址
|
|
92
|
+
fee_limit: 交易费用上限(单位:sun)
|
|
93
|
+
retry_config: 重试配置
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
>>> adapter = TronContractAdapter(
|
|
97
|
+
... rpc_url="https://nile.trongrid.io",
|
|
98
|
+
... identity_registry="TIdentity...",
|
|
99
|
+
... fee_limit=10_000_000,
|
|
100
|
+
... )
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
rpc_url: str,
|
|
106
|
+
identity_registry: Optional[str],
|
|
107
|
+
validation_registry: Optional[str],
|
|
108
|
+
reputation_registry: Optional[str],
|
|
109
|
+
identity_registry_abi_path: Optional[str] = None,
|
|
110
|
+
validation_registry_abi_path: Optional[str] = None,
|
|
111
|
+
reputation_registry_abi_path: Optional[str] = None,
|
|
112
|
+
fee_limit: Optional[int] = None,
|
|
113
|
+
retry_config: Optional[RetryConfig] = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.rpc_url = rpc_url
|
|
116
|
+
self.identity_registry = identity_registry
|
|
117
|
+
self.validation_registry = validation_registry
|
|
118
|
+
self.reputation_registry = reputation_registry
|
|
119
|
+
self.identity_registry_abi_path = identity_registry_abi_path
|
|
120
|
+
self.validation_registry_abi_path = validation_registry_abi_path
|
|
121
|
+
self.reputation_registry_abi_path = reputation_registry_abi_path
|
|
122
|
+
self.fee_limit = fee_limit or 10_000_000
|
|
123
|
+
self.retry_config = retry_config or DEFAULT_RETRY_CONFIG
|
|
124
|
+
self._client = None
|
|
125
|
+
|
|
126
|
+
def _get_client(self):
|
|
127
|
+
"""获取或创建 TRON 客户端"""
|
|
128
|
+
if self._client is None:
|
|
129
|
+
try:
|
|
130
|
+
from tronpy import Tron
|
|
131
|
+
from tronpy.providers import HTTPProvider
|
|
132
|
+
except ImportError as exc:
|
|
133
|
+
raise RuntimeError("tronpy is required for TronContractAdapter") from exc
|
|
134
|
+
self._client = Tron(provider=HTTPProvider(self.rpc_url))
|
|
135
|
+
if self.fee_limit:
|
|
136
|
+
self._client.conf["fee_limit"] = self.fee_limit
|
|
137
|
+
return self._client
|
|
138
|
+
|
|
139
|
+
def _resolve_contract(self, contract: str):
|
|
140
|
+
"""解析合约地址并获取合约引用"""
|
|
141
|
+
address = None
|
|
142
|
+
abi_path = None
|
|
143
|
+
if contract == "identity":
|
|
144
|
+
address = self.identity_registry
|
|
145
|
+
abi_path = self.identity_registry_abi_path
|
|
146
|
+
elif contract == "validation":
|
|
147
|
+
address = self.validation_registry
|
|
148
|
+
abi_path = self.validation_registry_abi_path
|
|
149
|
+
elif contract == "reputation":
|
|
150
|
+
address = self.reputation_registry
|
|
151
|
+
abi_path = self.reputation_registry_abi_path
|
|
152
|
+
|
|
153
|
+
if not address:
|
|
154
|
+
raise MissingContractAddressError(contract)
|
|
155
|
+
|
|
156
|
+
client = self._get_client()
|
|
157
|
+
try:
|
|
158
|
+
contract_ref = client.get_contract(address)
|
|
159
|
+
|
|
160
|
+
# 如果提供了 ABI 文件路径,使用文件中的 ABI(支持 ABIEncoderV2)
|
|
161
|
+
if abi_path:
|
|
162
|
+
import json
|
|
163
|
+
with open(abi_path) as f:
|
|
164
|
+
abi_data = json.load(f)
|
|
165
|
+
if isinstance(abi_data, dict) and "abi" in abi_data:
|
|
166
|
+
contract_ref.abi = abi_data["abi"]
|
|
167
|
+
elif isinstance(abi_data, list):
|
|
168
|
+
contract_ref.abi = abi_data
|
|
169
|
+
logger.debug("Loaded ABI from %s for %s", abi_path, contract)
|
|
170
|
+
else:
|
|
171
|
+
# 没有提供 ABI 文件,尝试修复 ABIEncoderV2 的 tuple 类型
|
|
172
|
+
# tronpy 不支持 components 字段,需要手动展开
|
|
173
|
+
fixed_abi = self._fix_abi_encoder_v2(contract_ref.abi)
|
|
174
|
+
if fixed_abi:
|
|
175
|
+
contract_ref.abi = fixed_abi
|
|
176
|
+
logger.debug("Fixed ABIEncoderV2 for %s", contract)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
raise ContractCallError(contract, "get_contract", str(e)) from e
|
|
179
|
+
|
|
180
|
+
return contract_ref
|
|
181
|
+
|
|
182
|
+
def _fix_abi_encoder_v2(self, abi: list) -> list:
|
|
183
|
+
"""
|
|
184
|
+
修复 ABIEncoderV2 的 tuple 类型
|
|
185
|
+
|
|
186
|
+
tronpy 不支持 components 字段,需要将 tuple 类型展开为基本类型
|
|
187
|
+
注意:链上返回的 type 可能是 "Function" 而不是 "function"
|
|
188
|
+
"""
|
|
189
|
+
if not abi:
|
|
190
|
+
return abi
|
|
191
|
+
|
|
192
|
+
def expand_type(item: dict) -> str:
|
|
193
|
+
"""展开 tuple 类型为 (type1,type2,...) 格式"""
|
|
194
|
+
t = item.get("type", "")
|
|
195
|
+
if t == "tuple" or t.startswith("tuple["):
|
|
196
|
+
components = item.get("components", [])
|
|
197
|
+
if components:
|
|
198
|
+
inner = ",".join(expand_type(c) for c in components)
|
|
199
|
+
if t == "tuple":
|
|
200
|
+
return f"({inner})"
|
|
201
|
+
else:
|
|
202
|
+
# tuple[] -> (...)[]
|
|
203
|
+
suffix = t[5:] # 获取 [] 部分
|
|
204
|
+
return f"({inner}){suffix}"
|
|
205
|
+
return t
|
|
206
|
+
|
|
207
|
+
fixed = []
|
|
208
|
+
for entry in abi:
|
|
209
|
+
# 使用 .lower() 进行大小写不敏感比较
|
|
210
|
+
if entry.get("type", "").lower() != "function":
|
|
211
|
+
fixed.append(entry)
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
new_entry = dict(entry)
|
|
215
|
+
|
|
216
|
+
# 修复 inputs
|
|
217
|
+
if "inputs" in entry:
|
|
218
|
+
new_inputs = []
|
|
219
|
+
for inp in entry["inputs"]:
|
|
220
|
+
new_inp = dict(inp)
|
|
221
|
+
new_inp["type"] = expand_type(inp)
|
|
222
|
+
# 移除 components 字段,tronpy 不需要
|
|
223
|
+
new_inp.pop("components", None)
|
|
224
|
+
new_inputs.append(new_inp)
|
|
225
|
+
new_entry["inputs"] = new_inputs
|
|
226
|
+
|
|
227
|
+
# 修复 outputs
|
|
228
|
+
if "outputs" in entry:
|
|
229
|
+
new_outputs = []
|
|
230
|
+
for out in entry["outputs"]:
|
|
231
|
+
new_out = dict(out)
|
|
232
|
+
new_out["type"] = expand_type(out)
|
|
233
|
+
new_out.pop("components", None)
|
|
234
|
+
new_outputs.append(new_out)
|
|
235
|
+
new_entry["outputs"] = new_outputs
|
|
236
|
+
|
|
237
|
+
fixed.append(new_entry)
|
|
238
|
+
|
|
239
|
+
return fixed
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _pick_function(contract_ref, method: str, params: List[Any]):
|
|
243
|
+
"""选择合约方法(处理重载)"""
|
|
244
|
+
|
|
245
|
+
def _get_overload(name: str, arity: int):
|
|
246
|
+
try:
|
|
247
|
+
from tronpy.contract import ContractMethod
|
|
248
|
+
except ImportError as exc:
|
|
249
|
+
raise RuntimeError("tronpy is required") from exc
|
|
250
|
+
for item in contract_ref.abi:
|
|
251
|
+
if item.get("type", "").lower() != "function":
|
|
252
|
+
continue
|
|
253
|
+
if item.get("name") != name:
|
|
254
|
+
continue
|
|
255
|
+
inputs = item.get("inputs", [])
|
|
256
|
+
if len(inputs) == arity:
|
|
257
|
+
return ContractMethod(item, contract_ref)
|
|
258
|
+
raise ContractFunctionNotFoundError(
|
|
259
|
+
contract_ref.contract_address, name, arity
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _get(name: str):
|
|
263
|
+
return getattr(contract_ref.functions, name)
|
|
264
|
+
|
|
265
|
+
# 处理 register 方法的重载
|
|
266
|
+
if method == "register" and "(" not in method:
|
|
267
|
+
if len(params) == 0:
|
|
268
|
+
try:
|
|
269
|
+
return _get_overload("register", 0)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
elif len(params) == 1:
|
|
273
|
+
try:
|
|
274
|
+
return _get_overload("register", 1)
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
elif len(params) == 2:
|
|
278
|
+
try:
|
|
279
|
+
return _get_overload("register", 2)
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
try:
|
|
283
|
+
logger.debug("register params=%s try_function=%s", params, method)
|
|
284
|
+
return _get(method)
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
raise ContractFunctionNotFoundError(
|
|
288
|
+
contract_ref.contract_address, "register"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
return _get(method)
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
raise ContractFunctionNotFoundError(contract_ref.contract_address, method)
|
|
296
|
+
|
|
297
|
+
def call(self, contract: str, method: str, params: List[Any]) -> Any:
|
|
298
|
+
"""调用合约只读方法"""
|
|
299
|
+
contract_ref = self._resolve_contract(contract)
|
|
300
|
+
function = self._pick_function(contract_ref, method, params)
|
|
301
|
+
try:
|
|
302
|
+
result = function(*params)
|
|
303
|
+
# tronpy 的 ContractMethod 在某些情况下直接返回结果
|
|
304
|
+
# 而不是返回一个需要 .call() 的对象
|
|
305
|
+
if hasattr(result, 'call'):
|
|
306
|
+
return result.call()
|
|
307
|
+
return result
|
|
308
|
+
except Exception as e:
|
|
309
|
+
raise ContractCallError(contract, method, str(e)) from e
|
|
310
|
+
|
|
311
|
+
def send(self, contract: str, method: str, params: List[Any], signer: Signer) -> str:
|
|
312
|
+
"""
|
|
313
|
+
发送合约交易(带重试)
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
contract: 合约名称
|
|
317
|
+
method: 方法名
|
|
318
|
+
params: 参数列表
|
|
319
|
+
signer: 签名器
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
交易 ID
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
ContractCallError: 合约调用失败
|
|
326
|
+
TransactionFailedError: 交易执行失败
|
|
327
|
+
InsufficientEnergyError: 能量不足
|
|
328
|
+
"""
|
|
329
|
+
return self._send_with_retry(contract, method, params, signer)
|
|
330
|
+
|
|
331
|
+
@retry(operation_name="contract_send")
|
|
332
|
+
def _send_with_retry(
|
|
333
|
+
self, contract: str, method: str, params: List[Any], signer: Signer
|
|
334
|
+
) -> str:
|
|
335
|
+
"""带重试的交易发送"""
|
|
336
|
+
contract_ref = self._resolve_contract(contract)
|
|
337
|
+
|
|
338
|
+
# 检查能量(仅 register 方法)
|
|
339
|
+
if method == "register":
|
|
340
|
+
self._check_energy(signer)
|
|
341
|
+
|
|
342
|
+
function = self._pick_function(contract_ref, method, params)
|
|
343
|
+
logger.debug(
|
|
344
|
+
"Sending tx: contract=%s, method=%s, params_count=%d",
|
|
345
|
+
contract,
|
|
346
|
+
method,
|
|
347
|
+
len(params),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
# 尝试使用标准方式构建交易
|
|
352
|
+
try:
|
|
353
|
+
txn = function(*params).with_owner(signer.get_address()).build()
|
|
354
|
+
except ValueError as ve:
|
|
355
|
+
if "ABIEncoderV2" in str(ve):
|
|
356
|
+
# ABIEncoderV2 需要手动编码参数
|
|
357
|
+
txn = self._build_tx_with_abi_encoder_v2(
|
|
358
|
+
contract_ref, method, params, signer
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
raise
|
|
362
|
+
|
|
363
|
+
signed = signer.sign_tx(txn)
|
|
364
|
+
result = signed.broadcast().wait()
|
|
365
|
+
|
|
366
|
+
tx_id = result.get("id")
|
|
367
|
+
if not tx_id:
|
|
368
|
+
raise TransactionFailedError(reason="No transaction ID in result")
|
|
369
|
+
|
|
370
|
+
logger.info("Transaction sent: %s", tx_id)
|
|
371
|
+
return tx_id
|
|
372
|
+
|
|
373
|
+
except Exception as e:
|
|
374
|
+
error_msg = str(e).lower()
|
|
375
|
+
if "energy" in error_msg or "bandwidth" in error_msg:
|
|
376
|
+
raise InsufficientEnergyError() from e
|
|
377
|
+
if "revert" in error_msg:
|
|
378
|
+
raise TransactionFailedError(reason=str(e)) from e
|
|
379
|
+
# 网络错误可重试
|
|
380
|
+
if any(
|
|
381
|
+
kw in error_msg
|
|
382
|
+
for kw in ["timeout", "connection", "network", "unavailable"]
|
|
383
|
+
):
|
|
384
|
+
raise NetworkError(str(e)) from e
|
|
385
|
+
raise ContractCallError(contract, method, str(e)) from e
|
|
386
|
+
|
|
387
|
+
def _build_tx_with_abi_encoder_v2(
|
|
388
|
+
self, contract_ref, method: str, params: List[Any], signer: Signer
|
|
389
|
+
):
|
|
390
|
+
"""
|
|
391
|
+
使用 eth_abi 手动编码 ABIEncoderV2 参数
|
|
392
|
+
|
|
393
|
+
tronpy 不支持 ABIEncoderV2 的 tuple 类型,需要手动编码参数并构建交易
|
|
394
|
+
"""
|
|
395
|
+
try:
|
|
396
|
+
from eth_abi import encode
|
|
397
|
+
from eth_utils import keccak
|
|
398
|
+
except ImportError:
|
|
399
|
+
raise RuntimeError("eth_abi and eth_utils are required for ABIEncoderV2 encoding")
|
|
400
|
+
|
|
401
|
+
# 找到方法的 ABI(支持重载方法)
|
|
402
|
+
# 注意:链上返回的 type 可能是 "Function" 而不是 "function"
|
|
403
|
+
method_abi = None
|
|
404
|
+
for item in contract_ref.abi:
|
|
405
|
+
if item.get("type", "").lower() == "function" and item.get("name") == method:
|
|
406
|
+
inputs = item.get("inputs", [])
|
|
407
|
+
if len(inputs) == len(params):
|
|
408
|
+
method_abi = item
|
|
409
|
+
break
|
|
410
|
+
|
|
411
|
+
if not method_abi:
|
|
412
|
+
# 打印调试信息
|
|
413
|
+
logger.debug(
|
|
414
|
+
"Looking for method %s with %d params in ABI with %d entries",
|
|
415
|
+
method, len(params), len(contract_ref.abi)
|
|
416
|
+
)
|
|
417
|
+
for item in contract_ref.abi:
|
|
418
|
+
if item.get("type", "").lower() == "function":
|
|
419
|
+
logger.debug(
|
|
420
|
+
" Found function: %s with %d inputs",
|
|
421
|
+
item.get("name"), len(item.get("inputs", []))
|
|
422
|
+
)
|
|
423
|
+
raise ContractFunctionNotFoundError(
|
|
424
|
+
contract_ref.contract_address, method, len(params)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# 构建类型签名
|
|
428
|
+
def get_type_str(inp: dict) -> str:
|
|
429
|
+
t = inp.get("type", "")
|
|
430
|
+
if t == "tuple" or t.startswith("tuple"):
|
|
431
|
+
components = inp.get("components", [])
|
|
432
|
+
inner = ",".join(get_type_str(c) for c in components)
|
|
433
|
+
if t == "tuple":
|
|
434
|
+
return f"({inner})"
|
|
435
|
+
else:
|
|
436
|
+
suffix = t[5:]
|
|
437
|
+
return f"({inner}){suffix}"
|
|
438
|
+
return t
|
|
439
|
+
|
|
440
|
+
types = [get_type_str(inp) for inp in method_abi.get("inputs", [])]
|
|
441
|
+
logger.debug("ABIEncoderV2 types: %s", types)
|
|
442
|
+
|
|
443
|
+
# 编码参数
|
|
444
|
+
encoded_params = encode(types, params)
|
|
445
|
+
|
|
446
|
+
# 计算函数选择器 (keccak256 of function signature)
|
|
447
|
+
sig = f"{method}({','.join(types)})"
|
|
448
|
+
selector = keccak(text=sig)[:4]
|
|
449
|
+
logger.debug("Function signature: %s, selector: %s", sig, selector.hex())
|
|
450
|
+
|
|
451
|
+
# 构建完整的 calldata
|
|
452
|
+
data = selector + encoded_params
|
|
453
|
+
|
|
454
|
+
# 使用 tronpy 的底层 API 构建交易
|
|
455
|
+
client = self._get_client()
|
|
456
|
+
owner_address = signer.get_address()
|
|
457
|
+
|
|
458
|
+
# 构建 TriggerSmartContract 交易
|
|
459
|
+
txn = client.trx._build_transaction(
|
|
460
|
+
"TriggerSmartContract",
|
|
461
|
+
{
|
|
462
|
+
"owner_address": owner_address,
|
|
463
|
+
"contract_address": contract_ref.contract_address,
|
|
464
|
+
"data": data.hex(),
|
|
465
|
+
},
|
|
466
|
+
method=method,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
return txn
|
|
470
|
+
|
|
471
|
+
def _check_energy(self, signer: Signer) -> None:
|
|
472
|
+
"""检查账户能量"""
|
|
473
|
+
try:
|
|
474
|
+
client = self._get_client()
|
|
475
|
+
address = signer.get_address()
|
|
476
|
+
resource = client.get_account_resource(address)
|
|
477
|
+
energy_limit = resource.get("EnergyLimit", 0)
|
|
478
|
+
energy_used = resource.get("EnergyUsed", 0)
|
|
479
|
+
energy_left = max(energy_limit - energy_used, 0)
|
|
480
|
+
logger.debug(
|
|
481
|
+
"Energy check: left=%d, limit=%d, used=%d",
|
|
482
|
+
energy_left,
|
|
483
|
+
energy_limit,
|
|
484
|
+
energy_used,
|
|
485
|
+
)
|
|
486
|
+
if energy_left < 100_000:
|
|
487
|
+
logger.warning("Low energy: %d", energy_left)
|
|
488
|
+
except Exception as e:
|
|
489
|
+
logger.warning("Energy check failed: %s", e)
|