@agentlayer.tech/wallet 0.1.0
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.
- package/.openclaw/AGENTS.md +98 -0
- package/.openclaw/extensions/agent-wallet/README.md +127 -0
- package/.openclaw/extensions/agent-wallet/index.ts +1520 -0
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +184 -0
- package/.openclaw/extensions/agent-wallet/package.json +11 -0
- package/.openclaw/extensions/agent-wallet/skills/wallet-operator/SKILL.md +20 -0
- package/CHANGELOG.md +42 -0
- package/LICENSE +104 -0
- package/README.md +332 -0
- package/RELEASING.md +204 -0
- package/agent-wallet/.env.example +62 -0
- package/agent-wallet/AGENTS.md +129 -0
- package/agent-wallet/README.md +527 -0
- package/agent-wallet/agent_wallet/__init__.py +11 -0
- package/agent-wallet/agent_wallet/approval.py +161 -0
- package/agent-wallet/agent_wallet/bootstrap.py +178 -0
- package/agent-wallet/agent_wallet/btc_user_wallets.py +217 -0
- package/agent-wallet/agent_wallet/config.py +382 -0
- package/agent-wallet/agent_wallet/encrypted_storage.py +161 -0
- package/agent-wallet/agent_wallet/evm_user_wallets.py +370 -0
- package/agent-wallet/agent_wallet/exceptions.py +9 -0
- package/agent-wallet/agent_wallet/file_ops.py +34 -0
- package/agent-wallet/agent_wallet/http_client.py +25 -0
- package/agent-wallet/agent_wallet/models.py +66 -0
- package/agent-wallet/agent_wallet/nonce_registry.py +59 -0
- package/agent-wallet/agent_wallet/openclaw_adapter.py +5128 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +626 -0
- package/agent-wallet/agent_wallet/openclaw_runtime.py +272 -0
- package/agent-wallet/agent_wallet/plugin_bundle.py +42 -0
- package/agent-wallet/agent_wallet/providers/__init__.py +1 -0
- package/agent-wallet/agent_wallet/providers/bags.py +259 -0
- package/agent-wallet/agent_wallet/providers/evm_portfolio.py +470 -0
- package/agent-wallet/agent_wallet/providers/jupiter.py +567 -0
- package/agent-wallet/agent_wallet/providers/kamino.py +215 -0
- package/agent-wallet/agent_wallet/providers/lifi.py +277 -0
- package/agent-wallet/agent_wallet/providers/solana_rpc.py +470 -0
- package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +114 -0
- package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +205 -0
- package/agent-wallet/agent_wallet/sealed_keys.py +61 -0
- package/agent-wallet/agent_wallet/solana_stake.py +103 -0
- package/agent-wallet/agent_wallet/solana_tx.py +93 -0
- package/agent-wallet/agent_wallet/spending_limits.py +101 -0
- package/agent-wallet/agent_wallet/transaction_policy.py +518 -0
- package/agent-wallet/agent_wallet/user_wallets.py +355 -0
- package/agent-wallet/agent_wallet/validation.py +31 -0
- package/agent-wallet/agent_wallet/wallet_layer/__init__.py +1 -0
- package/agent-wallet/agent_wallet/wallet_layer/base.py +808 -0
- package/agent-wallet/agent_wallet/wallet_layer/base58.py +44 -0
- package/agent-wallet/agent_wallet/wallet_layer/factory.py +102 -0
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +4252 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_btc.py +272 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_evm.py +1628 -0
- package/agent-wallet/examples/bootstrap_wallet.py +21 -0
- package/agent-wallet/examples/openclaw_runtime_onboarding.py +28 -0
- package/agent-wallet/examples/openclaw_user_wallet_example.py +31 -0
- package/agent-wallet/examples/openclaw_wallet_adapter_example.py +33 -0
- package/agent-wallet/openclaw.plugin.json +138 -0
- package/agent-wallet/pyproject.toml +31 -0
- package/agent-wallet/scripts/bootstrap_openclaw_btc.py +278 -0
- package/agent-wallet/scripts/build_release_bundle.py +188 -0
- package/agent-wallet/scripts/finalize_openclaw_local_wallet_config.py +121 -0
- package/agent-wallet/scripts/install_agent_wallet.py +505 -0
- package/agent-wallet/scripts/install_openclaw_local_config.py +226 -0
- package/agent-wallet/scripts/install_openclaw_sealed_keys.py +105 -0
- package/agent-wallet/scripts/manage_openclaw_btc_wallet.py +244 -0
- package/agent-wallet/scripts/reveal_btc_seed.sh +130 -0
- package/agent-wallet/scripts/security_utils.py +37 -0
- package/agent-wallet/scripts/setup_btc_wallet.sh +146 -0
- package/agent-wallet/scripts/switch_openclaw_wallet_network.py +106 -0
- package/agent-wallet/skills/wallet-operator/SKILL.md +128 -0
- package/bin/openclaw-agent-wallet.mjs +487 -0
- package/install-from-github.sh +134 -0
- package/package.json +61 -0
- package/setup.sh +40 -0
- package/wdk-btc-wallet/README.md +325 -0
- package/wdk-btc-wallet/bootstrap.sh +22 -0
- package/wdk-btc-wallet/package-lock.json +1839 -0
- package/wdk-btc-wallet/package.json +18 -0
- package/wdk-btc-wallet/run-local.sh +21 -0
- package/wdk-btc-wallet/src/config.js +160 -0
- package/wdk-btc-wallet/src/json.js +35 -0
- package/wdk-btc-wallet/src/local_vault.js +432 -0
- package/wdk-btc-wallet/src/network_state.js +84 -0
- package/wdk-btc-wallet/src/server.js +257 -0
- package/wdk-btc-wallet/src/wdk_btc_wallet.js +332 -0
- package/wdk-evm-wallet/README.md +183 -0
- package/wdk-evm-wallet/bootstrap.sh +8 -0
- package/wdk-evm-wallet/package-lock.json +2340 -0
- package/wdk-evm-wallet/package.json +23 -0
- package/wdk-evm-wallet/run-local.sh +12 -0
- package/wdk-evm-wallet/src/config.js +274 -0
- package/wdk-evm-wallet/src/json.js +35 -0
- package/wdk-evm-wallet/src/local_vault.js +430 -0
- package/wdk-evm-wallet/src/network_state.js +92 -0
- package/wdk-evm-wallet/src/server.js +575 -0
- package/wdk-evm-wallet/src/wdk_evm_wallet.js +4981 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Solana JSON-RPC provider with simple fallback logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from agent_wallet.config import settings
|
|
13
|
+
from agent_wallet.exceptions import ProviderError
|
|
14
|
+
from agent_wallet.http_client import get_client
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
SOLANA_RPC_FALLBACK = "https://api.mainnet-beta.solana.com"
|
|
19
|
+
LAMPORTS_PER_SOL = 1_000_000_000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_gateway_url(rpc_url: str) -> bool:
|
|
23
|
+
return rpc_url.startswith("gateway::")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_gateway_url(rpc_url: str) -> tuple[str, str, str]:
|
|
27
|
+
try:
|
|
28
|
+
parts = rpc_url.split("::", 3)
|
|
29
|
+
except ValueError as exc:
|
|
30
|
+
raise ProviderError("solana-rpc", f"Malformed provider gateway rpc url: {rpc_url}") from exc
|
|
31
|
+
if len(parts) == 4:
|
|
32
|
+
_, provider, network, url = parts
|
|
33
|
+
elif len(parts) == 3:
|
|
34
|
+
_, provider, url = parts
|
|
35
|
+
network = "mainnet"
|
|
36
|
+
else:
|
|
37
|
+
raise ProviderError("solana-rpc", f"Malformed provider gateway rpc url: {rpc_url}")
|
|
38
|
+
if not url.strip():
|
|
39
|
+
raise ProviderError("solana-rpc", f"Malformed provider gateway rpc url: {rpc_url}")
|
|
40
|
+
return provider.strip() or "auto", network.strip().lower() or "mainnet", url.strip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fallback_for_rpc_url(rpc_url: str) -> str:
|
|
44
|
+
"""Choose a cluster-appropriate official fallback URL."""
|
|
45
|
+
lowered = rpc_url.lower()
|
|
46
|
+
if "devnet" in lowered:
|
|
47
|
+
return "https://api.devnet.solana.com"
|
|
48
|
+
if "testnet" in lowered:
|
|
49
|
+
return "https://api.testnet.solana.com"
|
|
50
|
+
return SOLANA_RPC_FALLBACK
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize_rpc_urls(rpc_url: str | list[str]) -> list[str]:
|
|
54
|
+
if isinstance(rpc_url, list):
|
|
55
|
+
raw_values = rpc_url
|
|
56
|
+
else:
|
|
57
|
+
raw_values = [part.strip() for part in str(rpc_url).split(",")]
|
|
58
|
+
|
|
59
|
+
candidates: list[str] = []
|
|
60
|
+
for raw in raw_values:
|
|
61
|
+
if raw is None:
|
|
62
|
+
continue
|
|
63
|
+
value = str(raw).strip()
|
|
64
|
+
if value.lower() in {"", "none", "null"}:
|
|
65
|
+
continue
|
|
66
|
+
if value and value not in candidates:
|
|
67
|
+
candidates.append(value)
|
|
68
|
+
|
|
69
|
+
fallback = _fallback_for_rpc_url(candidates[0] if candidates else "")
|
|
70
|
+
if fallback and fallback not in candidates:
|
|
71
|
+
candidates.append(fallback)
|
|
72
|
+
return candidates
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _retry_after_seconds(response: httpx.Response) -> float:
|
|
76
|
+
header = response.headers.get("retry-after", "").strip()
|
|
77
|
+
if not header:
|
|
78
|
+
return 0.0
|
|
79
|
+
try:
|
|
80
|
+
return max(float(header), 0.0)
|
|
81
|
+
except ValueError:
|
|
82
|
+
return 0.0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _do_rpc_call(rpc_url: str, method: str, params: list[Any]) -> dict[str, Any]:
|
|
86
|
+
client = get_client()
|
|
87
|
+
gateway_mode = _is_gateway_url(rpc_url)
|
|
88
|
+
payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
|
89
|
+
headers: dict[str, str] | None = None
|
|
90
|
+
|
|
91
|
+
if gateway_mode:
|
|
92
|
+
provider, network, gateway_url = _parse_gateway_url(rpc_url)
|
|
93
|
+
rpc_url = gateway_url
|
|
94
|
+
payload = {
|
|
95
|
+
"provider": provider,
|
|
96
|
+
"network": network,
|
|
97
|
+
"method": method,
|
|
98
|
+
"params": params,
|
|
99
|
+
}
|
|
100
|
+
bearer = settings.provider_gateway_bearer_token.strip()
|
|
101
|
+
if bearer:
|
|
102
|
+
headers = {"Authorization": f"Bearer {bearer}"}
|
|
103
|
+
|
|
104
|
+
last_exc: Exception | None = None
|
|
105
|
+
response: httpx.Response | None = None
|
|
106
|
+
for attempt in range(3):
|
|
107
|
+
try:
|
|
108
|
+
response = await client.post(rpc_url, json=payload, headers=headers)
|
|
109
|
+
if response.status_code == 429:
|
|
110
|
+
wait_seconds = max(_retry_after_seconds(response), 0.5 * (attempt + 1))
|
|
111
|
+
last_exc = ProviderError("solana-rpc", f"Rate limited on {rpc_url}")
|
|
112
|
+
if attempt == 2:
|
|
113
|
+
raise last_exc
|
|
114
|
+
await asyncio.sleep(wait_seconds)
|
|
115
|
+
continue
|
|
116
|
+
if response.status_code in {403, 500, 502, 503, 504}:
|
|
117
|
+
last_exc = ProviderError("solana-rpc", f"HTTP {response.status_code} on {rpc_url}")
|
|
118
|
+
if attempt == 2:
|
|
119
|
+
raise last_exc
|
|
120
|
+
await asyncio.sleep(0.5 * (attempt + 1))
|
|
121
|
+
continue
|
|
122
|
+
break
|
|
123
|
+
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
|
124
|
+
last_exc = exc
|
|
125
|
+
if attempt == 2:
|
|
126
|
+
raise ProviderError("solana-rpc", f"Network error on {rpc_url}: {exc}") from exc
|
|
127
|
+
await asyncio.sleep(0.5 * (attempt + 1))
|
|
128
|
+
if response is None:
|
|
129
|
+
raise ProviderError("solana-rpc", f"RPC request failed on {rpc_url}: {last_exc}")
|
|
130
|
+
if response.status_code == 429:
|
|
131
|
+
raise ProviderError("solana-rpc", f"Rate limited on {rpc_url}")
|
|
132
|
+
if response.status_code != 200:
|
|
133
|
+
raise ProviderError("solana-rpc", f"HTTP {response.status_code} on {rpc_url}")
|
|
134
|
+
data = response.json()
|
|
135
|
+
if gateway_mode:
|
|
136
|
+
if not isinstance(data, dict):
|
|
137
|
+
raise ProviderError("solana-rpc", "Provider gateway returned unexpected response shape.")
|
|
138
|
+
if not data.get("ok", False):
|
|
139
|
+
raise ProviderError(
|
|
140
|
+
"solana-rpc",
|
|
141
|
+
f"Provider gateway error: {data.get('error') or data}",
|
|
142
|
+
)
|
|
143
|
+
data = data.get("rpc")
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
raise ProviderError("solana-rpc", "Provider gateway rpc payload is missing.")
|
|
146
|
+
if "error" in data:
|
|
147
|
+
raise ProviderError("solana-rpc", f"RPC error: {data['error']}")
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def rpc_call(
|
|
152
|
+
method: str,
|
|
153
|
+
params: list[Any],
|
|
154
|
+
rpc_url: str | list[str],
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""Execute a Solana RPC call with ordered failover across configured endpoints."""
|
|
157
|
+
candidates = _normalize_rpc_urls(rpc_url)
|
|
158
|
+
errors: list[str] = []
|
|
159
|
+
for index, candidate in enumerate(candidates):
|
|
160
|
+
try:
|
|
161
|
+
return await _do_rpc_call(candidate, method, params)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
errors.append(f"{candidate}: {exc}")
|
|
164
|
+
if index < len(candidates) - 1:
|
|
165
|
+
log.warning("Solana RPC failed on %s: %s -- trying next endpoint", candidate, exc)
|
|
166
|
+
continue
|
|
167
|
+
raise ProviderError(
|
|
168
|
+
"solana-rpc",
|
|
169
|
+
"All Solana RPC endpoints failed: " + " | ".join(errors),
|
|
170
|
+
) from exc
|
|
171
|
+
raise ProviderError("solana-rpc", "No Solana RPC endpoints are configured.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def fetch_balance(
|
|
175
|
+
address: str,
|
|
176
|
+
rpc_url: str,
|
|
177
|
+
commitment: str = "confirmed",
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""Fetch native SOL balance for a wallet."""
|
|
180
|
+
data = await rpc_call(
|
|
181
|
+
"getBalance",
|
|
182
|
+
[address, {"commitment": commitment}],
|
|
183
|
+
rpc_url=rpc_url,
|
|
184
|
+
)
|
|
185
|
+
lamports = data.get("result", {}).get("value", 0)
|
|
186
|
+
return {
|
|
187
|
+
"address": address,
|
|
188
|
+
"chain": "solana",
|
|
189
|
+
"balance_native": lamports / LAMPORTS_PER_SOL,
|
|
190
|
+
"balance_usd": None,
|
|
191
|
+
"source": "solana-rpc",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def account_exists(address: str, rpc_url: str) -> bool:
|
|
196
|
+
"""Return whether an on-chain account exists."""
|
|
197
|
+
data = await fetch_account_info(address, rpc_url=rpc_url)
|
|
198
|
+
return data is not None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def fetch_account_info(
|
|
202
|
+
address: str,
|
|
203
|
+
rpc_url: str,
|
|
204
|
+
encoding: str = "jsonParsed",
|
|
205
|
+
) -> dict[str, Any] | None:
|
|
206
|
+
"""Fetch account info for any Solana account."""
|
|
207
|
+
data = await rpc_call(
|
|
208
|
+
"getAccountInfo",
|
|
209
|
+
[address, {"encoding": encoding}],
|
|
210
|
+
rpc_url=rpc_url,
|
|
211
|
+
)
|
|
212
|
+
return data.get("result", {}).get("value")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def fetch_token_supply_info(mint: str, rpc_url: str) -> dict[str, Any]:
|
|
216
|
+
"""Fetch token supply metadata including decimals."""
|
|
217
|
+
data = await rpc_call(
|
|
218
|
+
"getTokenSupply",
|
|
219
|
+
[mint],
|
|
220
|
+
rpc_url=rpc_url,
|
|
221
|
+
)
|
|
222
|
+
value = data.get("result", {}).get("value", {})
|
|
223
|
+
return {
|
|
224
|
+
"mint": mint,
|
|
225
|
+
"amount": value.get("amount"),
|
|
226
|
+
"decimals": value.get("decimals"),
|
|
227
|
+
"ui_amount": value.get("uiAmount"),
|
|
228
|
+
"source": "solana-rpc",
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def fetch_token_account_balance(address: str, rpc_url: str) -> dict[str, Any]:
|
|
233
|
+
"""Fetch SPL token balance for a token account."""
|
|
234
|
+
data = await rpc_call(
|
|
235
|
+
"getTokenAccountBalance",
|
|
236
|
+
[address],
|
|
237
|
+
rpc_url=rpc_url,
|
|
238
|
+
)
|
|
239
|
+
value = data.get("result", {}).get("value", {})
|
|
240
|
+
return {
|
|
241
|
+
"amount": value.get("amount"),
|
|
242
|
+
"decimals": value.get("decimals"),
|
|
243
|
+
"ui_amount": value.get("uiAmount"),
|
|
244
|
+
"source": "solana-rpc",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def fetch_token_accounts_by_owner(
|
|
249
|
+
owner: str,
|
|
250
|
+
rpc_url: str,
|
|
251
|
+
token_program_id: str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
252
|
+
) -> list[dict[str, Any]]:
|
|
253
|
+
"""Fetch parsed SPL token accounts for a wallet owner."""
|
|
254
|
+
data = await rpc_call(
|
|
255
|
+
"getTokenAccountsByOwner",
|
|
256
|
+
[
|
|
257
|
+
owner,
|
|
258
|
+
{"programId": token_program_id},
|
|
259
|
+
{"encoding": "jsonParsed"},
|
|
260
|
+
],
|
|
261
|
+
rpc_url=rpc_url,
|
|
262
|
+
)
|
|
263
|
+
return data.get("result", {}).get("value", []) or []
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def fetch_latest_blockhash(rpc_url: str, commitment: str = "confirmed") -> dict[str, Any]:
|
|
267
|
+
"""Fetch latest blockhash for transaction building."""
|
|
268
|
+
data = await rpc_call(
|
|
269
|
+
"getLatestBlockhash",
|
|
270
|
+
[{"commitment": commitment}],
|
|
271
|
+
rpc_url=rpc_url,
|
|
272
|
+
)
|
|
273
|
+
value = data.get("result", {}).get("value", {})
|
|
274
|
+
return {
|
|
275
|
+
"blockhash": value.get("blockhash"),
|
|
276
|
+
"last_valid_block_height": value.get("lastValidBlockHeight"),
|
|
277
|
+
"source": "solana-rpc",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def fetch_recent_prioritization_fees(
|
|
282
|
+
rpc_url: str,
|
|
283
|
+
writable_accounts: list[str] | None = None,
|
|
284
|
+
) -> dict[str, Any]:
|
|
285
|
+
"""Fetch prioritization fee samples for future tx construction."""
|
|
286
|
+
params: list[Any] = [writable_accounts or []]
|
|
287
|
+
data = await rpc_call("getRecentPrioritizationFees", params, rpc_url=rpc_url)
|
|
288
|
+
fees = data.get("result", []) or []
|
|
289
|
+
return {
|
|
290
|
+
"samples": fees,
|
|
291
|
+
"recommended_micro_lamports": max(
|
|
292
|
+
(item.get("prioritizationFee", 0) for item in fees),
|
|
293
|
+
default=0,
|
|
294
|
+
),
|
|
295
|
+
"source": "solana-rpc",
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def fetch_minimum_balance_for_rent_exemption(
|
|
300
|
+
space: int,
|
|
301
|
+
rpc_url: str,
|
|
302
|
+
commitment: str = "confirmed",
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
"""Fetch rent-exempt minimum lamports for an account size."""
|
|
305
|
+
data = await rpc_call(
|
|
306
|
+
"getMinimumBalanceForRentExemption",
|
|
307
|
+
[space, {"commitment": commitment}],
|
|
308
|
+
rpc_url=rpc_url,
|
|
309
|
+
)
|
|
310
|
+
value = int(data.get("result") or 0)
|
|
311
|
+
return {
|
|
312
|
+
"space": space,
|
|
313
|
+
"lamports": value,
|
|
314
|
+
"source": "solana-rpc",
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def fetch_vote_accounts(
|
|
319
|
+
rpc_url: str,
|
|
320
|
+
commitment: str = "confirmed",
|
|
321
|
+
) -> dict[str, Any]:
|
|
322
|
+
"""Fetch current and delinquent vote accounts."""
|
|
323
|
+
data = await rpc_call(
|
|
324
|
+
"getVoteAccounts",
|
|
325
|
+
[{"commitment": commitment}],
|
|
326
|
+
rpc_url=rpc_url,
|
|
327
|
+
)
|
|
328
|
+
value = data.get("result", {}) or {}
|
|
329
|
+
return {
|
|
330
|
+
"current": value.get("current", []) or [],
|
|
331
|
+
"delinquent": value.get("delinquent", []) or [],
|
|
332
|
+
"source": "solana-rpc",
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def fetch_stake_activation(
|
|
337
|
+
stake_account: str,
|
|
338
|
+
rpc_url: str,
|
|
339
|
+
commitment: str = "confirmed",
|
|
340
|
+
) -> dict[str, Any]:
|
|
341
|
+
"""Fetch activation status for a stake account."""
|
|
342
|
+
try:
|
|
343
|
+
data = await rpc_call(
|
|
344
|
+
"getStakeActivation",
|
|
345
|
+
[stake_account, {"commitment": commitment}],
|
|
346
|
+
rpc_url=rpc_url,
|
|
347
|
+
)
|
|
348
|
+
except ProviderError as exc:
|
|
349
|
+
if "Method not found" in str(exc):
|
|
350
|
+
return {
|
|
351
|
+
"state": "unknown",
|
|
352
|
+
"active": None,
|
|
353
|
+
"inactive": None,
|
|
354
|
+
"source": "solana-rpc",
|
|
355
|
+
}
|
|
356
|
+
raise
|
|
357
|
+
value = data.get("result", {}) or {}
|
|
358
|
+
return {
|
|
359
|
+
"state": value.get("state"),
|
|
360
|
+
"active": value.get("active"),
|
|
361
|
+
"inactive": value.get("inactive"),
|
|
362
|
+
"source": "solana-rpc",
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def send_transaction(
|
|
367
|
+
transaction_base64: str,
|
|
368
|
+
rpc_url: str,
|
|
369
|
+
skip_preflight: bool = False,
|
|
370
|
+
max_retries: int | None = None,
|
|
371
|
+
) -> dict[str, Any]:
|
|
372
|
+
"""Send a pre-signed transaction encoded as base64."""
|
|
373
|
+
config: dict[str, Any] = {
|
|
374
|
+
"encoding": "base64",
|
|
375
|
+
"skipPreflight": skip_preflight,
|
|
376
|
+
"preflightCommitment": "confirmed",
|
|
377
|
+
}
|
|
378
|
+
if max_retries is not None:
|
|
379
|
+
config["maxRetries"] = max_retries
|
|
380
|
+
|
|
381
|
+
data = await rpc_call(
|
|
382
|
+
"sendTransaction",
|
|
383
|
+
[transaction_base64, config],
|
|
384
|
+
rpc_url=rpc_url,
|
|
385
|
+
)
|
|
386
|
+
return {"signature": data.get("result"), "source": "solana-rpc"}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def simulate_transaction(
|
|
390
|
+
transaction_base64: str,
|
|
391
|
+
rpc_url: str | list[str],
|
|
392
|
+
*,
|
|
393
|
+
commitment: str = "confirmed",
|
|
394
|
+
sig_verify: bool = True,
|
|
395
|
+
replace_recent_blockhash: bool = False,
|
|
396
|
+
) -> dict[str, Any]:
|
|
397
|
+
"""Simulate a signed transaction encoded as base64."""
|
|
398
|
+
config: dict[str, Any] = {
|
|
399
|
+
"encoding": "base64",
|
|
400
|
+
"commitment": commitment,
|
|
401
|
+
"sigVerify": sig_verify,
|
|
402
|
+
"replaceRecentBlockhash": replace_recent_blockhash,
|
|
403
|
+
}
|
|
404
|
+
if replace_recent_blockhash:
|
|
405
|
+
config["sigVerify"] = False
|
|
406
|
+
data = await rpc_call(
|
|
407
|
+
"simulateTransaction",
|
|
408
|
+
[transaction_base64, config],
|
|
409
|
+
rpc_url=rpc_url,
|
|
410
|
+
)
|
|
411
|
+
return {
|
|
412
|
+
"result": data.get("result"),
|
|
413
|
+
"value": (data.get("result") or {}).get("value"),
|
|
414
|
+
"source": "solana-rpc",
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
async def request_airdrop(
|
|
419
|
+
address: str,
|
|
420
|
+
lamports: int,
|
|
421
|
+
rpc_url: str,
|
|
422
|
+
commitment: str = "confirmed",
|
|
423
|
+
) -> dict[str, Any]:
|
|
424
|
+
"""Request a devnet or testnet SOL airdrop."""
|
|
425
|
+
data = await rpc_call(
|
|
426
|
+
"requestAirdrop",
|
|
427
|
+
[address, lamports, {"commitment": commitment}],
|
|
428
|
+
rpc_url=rpc_url,
|
|
429
|
+
)
|
|
430
|
+
return {
|
|
431
|
+
"signature": data.get("result"),
|
|
432
|
+
"source": "solana-rpc",
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def get_signature_status(
|
|
437
|
+
signature: str,
|
|
438
|
+
rpc_url: str,
|
|
439
|
+
search_transaction_history: bool = True,
|
|
440
|
+
) -> dict[str, Any] | None:
|
|
441
|
+
"""Fetch signature status for a submitted transaction."""
|
|
442
|
+
data = await rpc_call(
|
|
443
|
+
"getSignatureStatuses",
|
|
444
|
+
[[signature], {"searchTransactionHistory": search_transaction_history}],
|
|
445
|
+
rpc_url=rpc_url,
|
|
446
|
+
)
|
|
447
|
+
values = data.get("result", {}).get("value", [])
|
|
448
|
+
if not values:
|
|
449
|
+
return None
|
|
450
|
+
return values[0]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
async def wait_for_confirmation(
|
|
454
|
+
signature: str,
|
|
455
|
+
rpc_url: str,
|
|
456
|
+
timeout_seconds: float = 20.0,
|
|
457
|
+
poll_interval_seconds: float = 1.0,
|
|
458
|
+
) -> dict[str, Any] | None:
|
|
459
|
+
"""Poll signature status until confirmed/finalized or timeout."""
|
|
460
|
+
deadline = time.monotonic() + timeout_seconds
|
|
461
|
+
while time.monotonic() < deadline:
|
|
462
|
+
status = await get_signature_status(signature, rpc_url=rpc_url)
|
|
463
|
+
if status is not None:
|
|
464
|
+
if status.get("err") is not None:
|
|
465
|
+
raise ProviderError("solana-rpc", f"Transaction failed: {status['err']}")
|
|
466
|
+
confirmation_status = status.get("confirmationStatus")
|
|
467
|
+
if confirmation_status in {"confirmed", "finalized"}:
|
|
468
|
+
return status
|
|
469
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
470
|
+
return None
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Client helpers for the local wdk-btc-wallet service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from agent_wallet.config import resolve_openclaw_home
|
|
13
|
+
from agent_wallet.wallet_layer.base import WalletBackendError
|
|
14
|
+
|
|
15
|
+
LOCAL_WDK_BTC_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _normalize_base_url(value: str) -> str:
|
|
19
|
+
text = str(value or "").strip()
|
|
20
|
+
if not text:
|
|
21
|
+
raise WalletBackendError("WDK BTC service URL is not configured.")
|
|
22
|
+
parsed = urlparse(text)
|
|
23
|
+
if parsed.scheme not in {"http", "https"} or parsed.hostname not in LOCAL_WDK_BTC_HOSTS:
|
|
24
|
+
raise WalletBackendError("WDK BTC service URL must point to a localhost HTTP endpoint.")
|
|
25
|
+
return text.rstrip("/")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_local_token_path() -> Path:
|
|
29
|
+
configured = os.getenv("WDK_BTC_LOCAL_TOKEN_PATH", "").strip()
|
|
30
|
+
if configured:
|
|
31
|
+
return Path(configured).expanduser()
|
|
32
|
+
return resolve_openclaw_home() / "wdk-btc-wallet" / "local-auth-token"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_local_token() -> str:
|
|
36
|
+
direct = os.getenv("WDK_BTC_LOCAL_TOKEN", "").strip()
|
|
37
|
+
if direct:
|
|
38
|
+
return direct
|
|
39
|
+
token_path = _resolve_local_token_path()
|
|
40
|
+
if not token_path.exists():
|
|
41
|
+
raise WalletBackendError(
|
|
42
|
+
f"WDK BTC local auth token file not found: {token_path}. Start the local wdk-btc-wallet service first."
|
|
43
|
+
)
|
|
44
|
+
token = token_path.read_text(encoding="utf-8").strip()
|
|
45
|
+
if not token:
|
|
46
|
+
raise WalletBackendError(f"WDK BTC local auth token file is empty: {token_path}")
|
|
47
|
+
return token
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _unwrap_payload(response: httpx.Response) -> dict[str, Any]:
|
|
51
|
+
try:
|
|
52
|
+
payload = response.json()
|
|
53
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
54
|
+
raise WalletBackendError(
|
|
55
|
+
f"wdk-btc-wallet returned a non-JSON response ({response.status_code})."
|
|
56
|
+
) from exc
|
|
57
|
+
if response.status_code >= 400 or payload.get("ok") is False:
|
|
58
|
+
detail = payload.get("error") or f"HTTP {response.status_code}"
|
|
59
|
+
raise WalletBackendError(str(detail))
|
|
60
|
+
data = payload.get("data")
|
|
61
|
+
if not isinstance(data, dict):
|
|
62
|
+
raise WalletBackendError("wdk-btc-wallet returned an invalid response payload.")
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WdkBtcLocalClient:
|
|
67
|
+
"""Small client for the local BTC wallet service."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, base_url: str):
|
|
70
|
+
self.base_url = _normalize_base_url(base_url)
|
|
71
|
+
self._headers = {
|
|
72
|
+
"Accept": "application/json",
|
|
73
|
+
"Authorization": f"Bearer {_load_local_token()}",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
77
|
+
async with httpx.AsyncClient(
|
|
78
|
+
timeout=10.0,
|
|
79
|
+
headers=self._headers,
|
|
80
|
+
follow_redirects=False,
|
|
81
|
+
trust_env=False,
|
|
82
|
+
) as client:
|
|
83
|
+
response = await client.post(f"{self.base_url}{path}", json=payload)
|
|
84
|
+
return _unwrap_payload(response)
|
|
85
|
+
|
|
86
|
+
async def get(self, path: str) -> dict[str, Any]:
|
|
87
|
+
async with httpx.AsyncClient(
|
|
88
|
+
timeout=10.0,
|
|
89
|
+
headers=self._headers,
|
|
90
|
+
follow_redirects=False,
|
|
91
|
+
trust_env=False,
|
|
92
|
+
) as client:
|
|
93
|
+
response = await client.get(f"{self.base_url}{path}")
|
|
94
|
+
return _unwrap_payload(response)
|
|
95
|
+
|
|
96
|
+
def post_sync(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
with httpx.Client(
|
|
98
|
+
timeout=10.0,
|
|
99
|
+
headers=self._headers,
|
|
100
|
+
follow_redirects=False,
|
|
101
|
+
trust_env=False,
|
|
102
|
+
) as client:
|
|
103
|
+
response = client.post(f"{self.base_url}{path}", json=payload)
|
|
104
|
+
return _unwrap_payload(response)
|
|
105
|
+
|
|
106
|
+
def get_sync(self, path: str) -> dict[str, Any]:
|
|
107
|
+
with httpx.Client(
|
|
108
|
+
timeout=10.0,
|
|
109
|
+
headers=self._headers,
|
|
110
|
+
follow_redirects=False,
|
|
111
|
+
trust_env=False,
|
|
112
|
+
) as client:
|
|
113
|
+
response = client.get(f"{self.base_url}{path}")
|
|
114
|
+
return _unwrap_payload(response)
|