@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.
Files changed (96) hide show
  1. package/.openclaw/AGENTS.md +98 -0
  2. package/.openclaw/extensions/agent-wallet/README.md +127 -0
  3. package/.openclaw/extensions/agent-wallet/index.ts +1520 -0
  4. package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +184 -0
  5. package/.openclaw/extensions/agent-wallet/package.json +11 -0
  6. package/.openclaw/extensions/agent-wallet/skills/wallet-operator/SKILL.md +20 -0
  7. package/CHANGELOG.md +42 -0
  8. package/LICENSE +104 -0
  9. package/README.md +332 -0
  10. package/RELEASING.md +204 -0
  11. package/agent-wallet/.env.example +62 -0
  12. package/agent-wallet/AGENTS.md +129 -0
  13. package/agent-wallet/README.md +527 -0
  14. package/agent-wallet/agent_wallet/__init__.py +11 -0
  15. package/agent-wallet/agent_wallet/approval.py +161 -0
  16. package/agent-wallet/agent_wallet/bootstrap.py +178 -0
  17. package/agent-wallet/agent_wallet/btc_user_wallets.py +217 -0
  18. package/agent-wallet/agent_wallet/config.py +382 -0
  19. package/agent-wallet/agent_wallet/encrypted_storage.py +161 -0
  20. package/agent-wallet/agent_wallet/evm_user_wallets.py +370 -0
  21. package/agent-wallet/agent_wallet/exceptions.py +9 -0
  22. package/agent-wallet/agent_wallet/file_ops.py +34 -0
  23. package/agent-wallet/agent_wallet/http_client.py +25 -0
  24. package/agent-wallet/agent_wallet/models.py +66 -0
  25. package/agent-wallet/agent_wallet/nonce_registry.py +59 -0
  26. package/agent-wallet/agent_wallet/openclaw_adapter.py +5128 -0
  27. package/agent-wallet/agent_wallet/openclaw_cli.py +626 -0
  28. package/agent-wallet/agent_wallet/openclaw_runtime.py +272 -0
  29. package/agent-wallet/agent_wallet/plugin_bundle.py +42 -0
  30. package/agent-wallet/agent_wallet/providers/__init__.py +1 -0
  31. package/agent-wallet/agent_wallet/providers/bags.py +259 -0
  32. package/agent-wallet/agent_wallet/providers/evm_portfolio.py +470 -0
  33. package/agent-wallet/agent_wallet/providers/jupiter.py +567 -0
  34. package/agent-wallet/agent_wallet/providers/kamino.py +215 -0
  35. package/agent-wallet/agent_wallet/providers/lifi.py +277 -0
  36. package/agent-wallet/agent_wallet/providers/solana_rpc.py +470 -0
  37. package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +114 -0
  38. package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +205 -0
  39. package/agent-wallet/agent_wallet/sealed_keys.py +61 -0
  40. package/agent-wallet/agent_wallet/solana_stake.py +103 -0
  41. package/agent-wallet/agent_wallet/solana_tx.py +93 -0
  42. package/agent-wallet/agent_wallet/spending_limits.py +101 -0
  43. package/agent-wallet/agent_wallet/transaction_policy.py +518 -0
  44. package/agent-wallet/agent_wallet/user_wallets.py +355 -0
  45. package/agent-wallet/agent_wallet/validation.py +31 -0
  46. package/agent-wallet/agent_wallet/wallet_layer/__init__.py +1 -0
  47. package/agent-wallet/agent_wallet/wallet_layer/base.py +808 -0
  48. package/agent-wallet/agent_wallet/wallet_layer/base58.py +44 -0
  49. package/agent-wallet/agent_wallet/wallet_layer/factory.py +102 -0
  50. package/agent-wallet/agent_wallet/wallet_layer/solana.py +4252 -0
  51. package/agent-wallet/agent_wallet/wallet_layer/wdk_btc.py +272 -0
  52. package/agent-wallet/agent_wallet/wallet_layer/wdk_evm.py +1628 -0
  53. package/agent-wallet/examples/bootstrap_wallet.py +21 -0
  54. package/agent-wallet/examples/openclaw_runtime_onboarding.py +28 -0
  55. package/agent-wallet/examples/openclaw_user_wallet_example.py +31 -0
  56. package/agent-wallet/examples/openclaw_wallet_adapter_example.py +33 -0
  57. package/agent-wallet/openclaw.plugin.json +138 -0
  58. package/agent-wallet/pyproject.toml +31 -0
  59. package/agent-wallet/scripts/bootstrap_openclaw_btc.py +278 -0
  60. package/agent-wallet/scripts/build_release_bundle.py +188 -0
  61. package/agent-wallet/scripts/finalize_openclaw_local_wallet_config.py +121 -0
  62. package/agent-wallet/scripts/install_agent_wallet.py +505 -0
  63. package/agent-wallet/scripts/install_openclaw_local_config.py +226 -0
  64. package/agent-wallet/scripts/install_openclaw_sealed_keys.py +105 -0
  65. package/agent-wallet/scripts/manage_openclaw_btc_wallet.py +244 -0
  66. package/agent-wallet/scripts/reveal_btc_seed.sh +130 -0
  67. package/agent-wallet/scripts/security_utils.py +37 -0
  68. package/agent-wallet/scripts/setup_btc_wallet.sh +146 -0
  69. package/agent-wallet/scripts/switch_openclaw_wallet_network.py +106 -0
  70. package/agent-wallet/skills/wallet-operator/SKILL.md +128 -0
  71. package/bin/openclaw-agent-wallet.mjs +487 -0
  72. package/install-from-github.sh +134 -0
  73. package/package.json +61 -0
  74. package/setup.sh +40 -0
  75. package/wdk-btc-wallet/README.md +325 -0
  76. package/wdk-btc-wallet/bootstrap.sh +22 -0
  77. package/wdk-btc-wallet/package-lock.json +1839 -0
  78. package/wdk-btc-wallet/package.json +18 -0
  79. package/wdk-btc-wallet/run-local.sh +21 -0
  80. package/wdk-btc-wallet/src/config.js +160 -0
  81. package/wdk-btc-wallet/src/json.js +35 -0
  82. package/wdk-btc-wallet/src/local_vault.js +432 -0
  83. package/wdk-btc-wallet/src/network_state.js +84 -0
  84. package/wdk-btc-wallet/src/server.js +257 -0
  85. package/wdk-btc-wallet/src/wdk_btc_wallet.js +332 -0
  86. package/wdk-evm-wallet/README.md +183 -0
  87. package/wdk-evm-wallet/bootstrap.sh +8 -0
  88. package/wdk-evm-wallet/package-lock.json +2340 -0
  89. package/wdk-evm-wallet/package.json +23 -0
  90. package/wdk-evm-wallet/run-local.sh +12 -0
  91. package/wdk-evm-wallet/src/config.js +274 -0
  92. package/wdk-evm-wallet/src/json.js +35 -0
  93. package/wdk-evm-wallet/src/local_vault.js +430 -0
  94. package/wdk-evm-wallet/src/network_state.js +92 -0
  95. package/wdk-evm-wallet/src/server.js +575 -0
  96. 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)