@agentlayer.tech/wallet 0.1.65 → 0.1.66
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/extensions/agent-wallet/openclaw.plugin.json +1 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/VERSION +1 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/openclaw_adapter.py +4 -0
- package/agent-wallet/agent_wallet/providers/x402.py +229 -148
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/agent-wallet/skills/wallet-operator/SKILL.md +1 -1
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/claude-code/plugins/agent-wallet/skills/wallet-operator/SKILL.md +1 -1
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/server.py +52 -3
- package/codex/plugins/agent-wallet/skills/wallet-operator/SKILL.md +1 -1
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/install-from-github.sh +6 -0
- package/package.json +1 -1
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "agent-wallet",
|
|
3
3
|
"name": "Agent Wallet",
|
|
4
4
|
"description": "Official OpenClaw plugin bridge for the agent-wallet backends, including Solana, local BTC, and local EVM.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.66",
|
|
6
6
|
"contracts": {
|
|
7
7
|
"tools": [
|
|
8
8
|
"agentlayer_autonomous_approve",
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.66
|
|
@@ -3928,6 +3928,9 @@ class OpenClawWalletAdapter:
|
|
|
3928
3928
|
raise WalletBackendError("text_body must be a string when provided.")
|
|
3929
3929
|
if not isinstance(purpose, str) or not purpose.strip():
|
|
3930
3930
|
raise WalletBackendError("purpose is required.")
|
|
3931
|
+
approved_preview = args.get("_approved_preview")
|
|
3932
|
+
if approved_preview is not None and not isinstance(approved_preview, dict):
|
|
3933
|
+
raise WalletBackendError("_approved_preview must be an object when provided.")
|
|
3931
3934
|
data = await x402.pay_and_fetch(
|
|
3932
3935
|
backend=active_backend,
|
|
3933
3936
|
url=url.strip(),
|
|
@@ -3936,6 +3939,7 @@ class OpenClawWalletAdapter:
|
|
|
3936
3939
|
query=query,
|
|
3937
3940
|
json_body=json_body,
|
|
3938
3941
|
text_body=text_body,
|
|
3942
|
+
approved_preview=approved_preview,
|
|
3939
3943
|
)
|
|
3940
3944
|
data["purpose"] = purpose.strip()
|
|
3941
3945
|
data = self._annotate_x402_payload(data, mode="execute")
|
|
@@ -6,6 +6,7 @@ import base64
|
|
|
6
6
|
import hashlib
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
|
+
import time
|
|
9
10
|
from typing import Any
|
|
10
11
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
11
12
|
|
|
@@ -17,6 +18,11 @@ from agent_wallet.wallet_layer.base import AgentWalletBackend
|
|
|
17
18
|
CDP_BAZAAR_DISCOVERY_BASE_URL = "https://api.cdp.coinbase.com/platform/v2/x402/discovery"
|
|
18
19
|
AGENTIC_MARKET_API_BASE_URL = "https://api.agentic.market/v1"
|
|
19
20
|
X402_EXECUTE_TIMEOUT_SECONDS = 45.0
|
|
21
|
+
DISCOVERY_CACHE_TTL_SECONDS = 300.0
|
|
22
|
+
_DISCOVERY_CACHE_MAX_ENTRIES = 64
|
|
23
|
+
# Discovery responses cached as JSON text so hits hand out independent copies.
|
|
24
|
+
# Long-lived only inside the resident read worker; cold CLI runs start empty.
|
|
25
|
+
_discovery_cache: dict[str, tuple[float, str]] = {}
|
|
20
26
|
SOLANA_CAIP_BY_NETWORK = {
|
|
21
27
|
"mainnet": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
22
28
|
}
|
|
@@ -24,6 +30,12 @@ EVM_CAIP_BY_NETWORK = {
|
|
|
24
30
|
"ethereum": "eip155:1",
|
|
25
31
|
"base": "eip155:8453",
|
|
26
32
|
}
|
|
33
|
+
# x402 v1 requirements carry legacy network names instead of CAIP-2 ids.
|
|
34
|
+
LEGACY_NETWORK_TO_CAIP = {
|
|
35
|
+
"ethereum": "eip155:1",
|
|
36
|
+
"base": "eip155:8453",
|
|
37
|
+
"solana": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
38
|
+
}
|
|
27
39
|
_USDC_IDENTIFIERS = {
|
|
28
40
|
"usdc",
|
|
29
41
|
"usd coin",
|
|
@@ -200,6 +212,38 @@ def _decode_payment_required(header_value: str) -> dict[str, Any]:
|
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
|
|
215
|
+
def _caip_network(value: Any) -> str:
|
|
216
|
+
"""Map a requirement network to CAIP-2 form, passing CAIP ids through."""
|
|
217
|
+
network = _trim(value)
|
|
218
|
+
if ":" in network:
|
|
219
|
+
return network
|
|
220
|
+
return LEGACY_NETWORK_TO_CAIP.get(network.lower(), network)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _decode_payment_required_body(response: Any) -> dict[str, Any] | None:
|
|
224
|
+
"""Parse a legacy x402 v1 402 response whose requirements live in the JSON body.
|
|
225
|
+
|
|
226
|
+
Returns the same shape as _decode_payment_required, with `encoded` set to a
|
|
227
|
+
base64 form the x402 SDK can decode (it detects the version from the JSON).
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
payload = response.json()
|
|
231
|
+
except Exception:
|
|
232
|
+
return None
|
|
233
|
+
if not isinstance(payload, dict) or payload.get("x402Version") != 1:
|
|
234
|
+
return None
|
|
235
|
+
accepts = payload.get("accepts")
|
|
236
|
+
if not isinstance(accepts, list) or not accepts:
|
|
237
|
+
return None
|
|
238
|
+
encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
|
|
239
|
+
return {
|
|
240
|
+
"x402_version": 1,
|
|
241
|
+
"accepts": accepts,
|
|
242
|
+
"raw": payload,
|
|
243
|
+
"encoded": encoded,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
203
247
|
def _extract_requirement_extra(requirement: dict[str, Any]) -> dict[str, Any]:
|
|
204
248
|
extra = requirement.get("extra")
|
|
205
249
|
return dict(extra) if isinstance(extra, dict) else {}
|
|
@@ -210,12 +254,24 @@ def _requirement_field(requirement: Any, field_name: str) -> Any:
|
|
|
210
254
|
aliases = {
|
|
211
255
|
"pay_to": ("pay_to", "payTo"),
|
|
212
256
|
"max_timeout_seconds": ("max_timeout_seconds", "maxTimeoutSeconds"),
|
|
257
|
+
"amount": ("amount", "maxAmountRequired"),
|
|
213
258
|
}
|
|
214
259
|
for candidate in aliases.get(field_name, (field_name,)):
|
|
215
260
|
if candidate in requirement:
|
|
216
261
|
return requirement.get(candidate)
|
|
217
262
|
return None
|
|
218
|
-
|
|
263
|
+
value = getattr(requirement, field_name, None)
|
|
264
|
+
if value is None and field_name == "amount":
|
|
265
|
+
# v1 SDK models expose the amount as max_amount_required / get_amount().
|
|
266
|
+
getter = getattr(requirement, "get_amount", None)
|
|
267
|
+
if callable(getter):
|
|
268
|
+
return getter()
|
|
269
|
+
return value
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _requirement_amount(requirement: dict[str, Any]) -> str:
|
|
273
|
+
# x402 v2 uses "amount"; v1 uses "maxAmountRequired".
|
|
274
|
+
return _trim(requirement.get("amount") or requirement.get("maxAmountRequired"))
|
|
219
275
|
|
|
220
276
|
|
|
221
277
|
def _looks_like_usdc(requirement: dict[str, Any]) -> bool:
|
|
@@ -227,7 +283,7 @@ def _looks_like_usdc(requirement: dict[str, Any]) -> bool:
|
|
|
227
283
|
|
|
228
284
|
|
|
229
285
|
def _normalize_amount_hint(requirement: dict[str, Any]) -> str | None:
|
|
230
|
-
amount =
|
|
286
|
+
amount = _requirement_amount(requirement)
|
|
231
287
|
if not amount.isdigit():
|
|
232
288
|
return None
|
|
233
289
|
if _looks_like_usdc(requirement):
|
|
@@ -247,7 +303,7 @@ def normalize_payment_requirement(
|
|
|
247
303
|
resource_url: str | None = None,
|
|
248
304
|
) -> dict[str, Any]:
|
|
249
305
|
extra = _extract_requirement_extra(requirement)
|
|
250
|
-
amount =
|
|
306
|
+
amount = _requirement_amount(requirement)
|
|
251
307
|
return {
|
|
252
308
|
"scheme": _trim(requirement.get("scheme")).lower() or None,
|
|
253
309
|
"network": _trim(requirement.get("network")) or None,
|
|
@@ -405,7 +461,7 @@ def _wallet_x402_support_summary(backend: AgentWalletBackend) -> dict[str, Any]:
|
|
|
405
461
|
|
|
406
462
|
def _requirement_compatibility(requirement: dict[str, Any], backend: AgentWalletBackend) -> dict[str, Any]:
|
|
407
463
|
wallet_summary = _wallet_x402_support_summary(backend)
|
|
408
|
-
network =
|
|
464
|
+
network = _caip_network(requirement.get("network"))
|
|
409
465
|
scheme = _trim(requirement.get("scheme")).lower()
|
|
410
466
|
wallet_network_matches = network in wallet_summary["supported_caip_networks"]
|
|
411
467
|
chain = _backend_chain(backend)
|
|
@@ -458,23 +514,24 @@ def _select_preferred_requirement(
|
|
|
458
514
|
requirements: list[dict[str, Any]],
|
|
459
515
|
backend: AgentWalletBackend,
|
|
460
516
|
) -> dict[str, Any] | None:
|
|
461
|
-
compatible = [
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
requirement
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
]
|
|
517
|
+
compatible: list[dict[str, Any]] = []
|
|
518
|
+
exact_match: list[dict[str, Any]] = []
|
|
519
|
+
for requirement in requirements:
|
|
520
|
+
compatibility = _requirement_compatibility(requirement, backend)
|
|
521
|
+
if not compatibility["planned_execution_supported"]:
|
|
522
|
+
continue
|
|
523
|
+
compatible.append(requirement)
|
|
524
|
+
if compatibility["wallet_network_matches"]:
|
|
525
|
+
exact_match.append(requirement)
|
|
471
526
|
candidates = exact_match or compatible
|
|
472
527
|
if not candidates:
|
|
473
528
|
return None
|
|
474
529
|
|
|
475
|
-
def sort_key(item: dict[str, Any]) -> tuple[int,
|
|
530
|
+
def sort_key(item: dict[str, Any]) -> tuple[int, int]:
|
|
476
531
|
amount = _trim(item.get("amount"))
|
|
477
|
-
|
|
532
|
+
if amount.isdigit():
|
|
533
|
+
return (0, int(amount))
|
|
534
|
+
return (1, 0)
|
|
478
535
|
|
|
479
536
|
return sorted(candidates, key=sort_key)[0]
|
|
480
537
|
|
|
@@ -605,14 +662,16 @@ def _parse_payment_required_response(
|
|
|
605
662
|
wallet_summary: dict[str, Any],
|
|
606
663
|
) -> dict[str, Any]:
|
|
607
664
|
payment_required = response.headers.get("PAYMENT-REQUIRED")
|
|
608
|
-
if
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
665
|
+
if payment_required:
|
|
666
|
+
decoded = _decode_payment_required(payment_required)
|
|
667
|
+
else:
|
|
668
|
+
decoded = _decode_payment_required_body(response)
|
|
669
|
+
if decoded is None:
|
|
670
|
+
raise ProviderError(
|
|
671
|
+
"x402-http",
|
|
672
|
+
"Server returned HTTP 402 without a PAYMENT-REQUIRED header or an x402 v1 JSON body.",
|
|
673
|
+
details={"status_code": response.status_code, "url": request["url"]},
|
|
674
|
+
)
|
|
616
675
|
normalized_accepts = [
|
|
617
676
|
normalize_payment_requirement(requirement, source="payment_required", resource_url=request["url"])
|
|
618
677
|
for requirement in decoded["accepts"]
|
|
@@ -651,34 +710,6 @@ def _parse_payment_required_response(
|
|
|
651
710
|
return preview
|
|
652
711
|
|
|
653
712
|
|
|
654
|
-
def _require_executable_payment(
|
|
655
|
-
*,
|
|
656
|
-
preview: dict[str, Any],
|
|
657
|
-
backend: AgentWalletBackend,
|
|
658
|
-
) -> dict[str, Any]:
|
|
659
|
-
selected = preview.get("selected_payment")
|
|
660
|
-
if not isinstance(selected, dict):
|
|
661
|
-
raise ProviderError(
|
|
662
|
-
"x402-http",
|
|
663
|
-
"No compatible x402 payment requirement was selected for this wallet.",
|
|
664
|
-
details={
|
|
665
|
-
"request_url": preview.get("request_url"),
|
|
666
|
-
"accepted_payments": preview.get("accepted_payments"),
|
|
667
|
-
},
|
|
668
|
-
)
|
|
669
|
-
compatibility = _requirement_compatibility(selected, backend)
|
|
670
|
-
if not compatibility["currently_executable"]:
|
|
671
|
-
raise ProviderError(
|
|
672
|
-
"x402-http",
|
|
673
|
-
str(compatibility["reason"]),
|
|
674
|
-
details={
|
|
675
|
-
"selected_payment": selected,
|
|
676
|
-
"compatibility": compatibility,
|
|
677
|
-
},
|
|
678
|
-
)
|
|
679
|
-
return selected
|
|
680
|
-
|
|
681
|
-
|
|
682
713
|
def _validate_payment_requirement(
|
|
683
714
|
selected: dict[str, Any] | None,
|
|
684
715
|
*,
|
|
@@ -717,7 +748,7 @@ def _validate_payment_requirement(
|
|
|
717
748
|
|
|
718
749
|
chain = _backend_chain(backend) or "unknown"
|
|
719
750
|
network = _backend_network(backend) or "unknown"
|
|
720
|
-
requirement_network =
|
|
751
|
+
requirement_network = _caip_network(selected.get("network")) or "unknown"
|
|
721
752
|
if chain == "solana" and requirement_network not in SOLANA_CAIP_BY_NETWORK.values():
|
|
722
753
|
message = (
|
|
723
754
|
f"This endpoint requires payment on {requirement_network}, but the active wallet is Solana ({network})."
|
|
@@ -1091,6 +1122,30 @@ def _log_x402_execute(
|
|
|
1091
1122
|
)
|
|
1092
1123
|
|
|
1093
1124
|
|
|
1125
|
+
def _discovery_cache_get(cache_key: str) -> dict[str, Any] | None:
|
|
1126
|
+
entry = _discovery_cache.get(cache_key)
|
|
1127
|
+
if entry is None:
|
|
1128
|
+
return None
|
|
1129
|
+
expires_at, payload_text = entry
|
|
1130
|
+
if expires_at <= time.monotonic():
|
|
1131
|
+
_discovery_cache.pop(cache_key, None)
|
|
1132
|
+
return None
|
|
1133
|
+
return json.loads(payload_text)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _discovery_cache_set(cache_key: str, payload: dict[str, Any]) -> None:
|
|
1137
|
+
try:
|
|
1138
|
+
payload_text = json.dumps(payload)
|
|
1139
|
+
except (TypeError, ValueError):
|
|
1140
|
+
return
|
|
1141
|
+
if len(_discovery_cache) >= _DISCOVERY_CACHE_MAX_ENTRIES:
|
|
1142
|
+
for stale_key in sorted(_discovery_cache, key=lambda key: _discovery_cache[key][0])[
|
|
1143
|
+
: len(_discovery_cache) - _DISCOVERY_CACHE_MAX_ENTRIES + 1
|
|
1144
|
+
]:
|
|
1145
|
+
_discovery_cache.pop(stale_key, None)
|
|
1146
|
+
_discovery_cache[cache_key] = (time.monotonic() + DISCOVERY_CACHE_TTL_SECONDS, payload_text)
|
|
1147
|
+
|
|
1148
|
+
|
|
1094
1149
|
async def search_services(
|
|
1095
1150
|
*,
|
|
1096
1151
|
query: str | None = None,
|
|
@@ -1106,20 +1161,62 @@ async def search_services(
|
|
|
1106
1161
|
provider = "cdp_bazaar"
|
|
1107
1162
|
if limit <= 0:
|
|
1108
1163
|
raise ProviderError("x402-discovery", "limit must be greater than zero.")
|
|
1164
|
+
cache_key = _canonical_json_text(
|
|
1165
|
+
{
|
|
1166
|
+
"op": "search_services",
|
|
1167
|
+
"provider": provider,
|
|
1168
|
+
"query": _trim(query),
|
|
1169
|
+
"network": _trim(network),
|
|
1170
|
+
"asset": _trim(asset),
|
|
1171
|
+
"scheme": _trim(scheme),
|
|
1172
|
+
"max_usd_price": _trim(max_usd_price),
|
|
1173
|
+
"limit": limit,
|
|
1174
|
+
}
|
|
1175
|
+
)
|
|
1176
|
+
cached = _discovery_cache_get(cache_key)
|
|
1177
|
+
if cached is not None:
|
|
1178
|
+
return cached
|
|
1179
|
+
result = await _search_services_uncached(
|
|
1180
|
+
query=query,
|
|
1181
|
+
provider=provider,
|
|
1182
|
+
network=network,
|
|
1183
|
+
asset=asset,
|
|
1184
|
+
scheme=scheme,
|
|
1185
|
+
max_usd_price=max_usd_price,
|
|
1186
|
+
limit=limit,
|
|
1187
|
+
)
|
|
1188
|
+
_discovery_cache_set(cache_key, result)
|
|
1189
|
+
return result
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
async def _search_services_uncached(
|
|
1193
|
+
*,
|
|
1194
|
+
query: str | None,
|
|
1195
|
+
provider: str,
|
|
1196
|
+
network: str | None,
|
|
1197
|
+
asset: str | None,
|
|
1198
|
+
scheme: str | None,
|
|
1199
|
+
max_usd_price: str | None,
|
|
1200
|
+
limit: int,
|
|
1201
|
+
) -> dict[str, Any]:
|
|
1109
1202
|
client = get_client()
|
|
1110
1203
|
|
|
1111
1204
|
if provider == "cdp_bazaar":
|
|
1112
1205
|
if query and _trim(query):
|
|
1206
|
+
# CDP rejects empty filter values with HTTP 400, so omit unset params
|
|
1207
|
+
# entirely instead of sending them as blank strings.
|
|
1208
|
+
search_params: dict[str, Any] = {"query": _trim(query), "limit": min(limit, 20)}
|
|
1209
|
+
for name, value in (
|
|
1210
|
+
("network", network),
|
|
1211
|
+
("asset", asset),
|
|
1212
|
+
("scheme", scheme),
|
|
1213
|
+
("maxUsdPrice", max_usd_price),
|
|
1214
|
+
):
|
|
1215
|
+
if _trim(value):
|
|
1216
|
+
search_params[name] = _trim(value)
|
|
1113
1217
|
response = await client.get(
|
|
1114
1218
|
f"{CDP_BAZAAR_DISCOVERY_BASE_URL}/search",
|
|
1115
|
-
params=
|
|
1116
|
-
"query": _trim(query),
|
|
1117
|
-
"network": _trim(network) or None,
|
|
1118
|
-
"asset": _trim(asset) or None,
|
|
1119
|
-
"scheme": _trim(scheme) or None,
|
|
1120
|
-
"maxUsdPrice": _trim(max_usd_price) or None,
|
|
1121
|
-
"limit": min(limit, 20),
|
|
1122
|
-
},
|
|
1219
|
+
params=search_params,
|
|
1123
1220
|
)
|
|
1124
1221
|
payload = _parse_json_response(
|
|
1125
1222
|
response, provider="x402-cdp-bazaar", operation="CDP Bazaar search"
|
|
@@ -1214,19 +1311,40 @@ async def get_service_details(
|
|
|
1214
1311
|
)
|
|
1215
1312
|
|
|
1216
1313
|
if provider == "cdp_bazaar":
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1314
|
+
|
|
1315
|
+
def _match(items: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
1316
|
+
exact = next((item for item in items if item.get("resource") == ref), None)
|
|
1317
|
+
if exact is not None:
|
|
1318
|
+
return exact
|
|
1220
1319
|
needle = ref.lower()
|
|
1221
|
-
|
|
1320
|
+
return next(
|
|
1222
1321
|
(
|
|
1223
1322
|
item
|
|
1224
|
-
for item in
|
|
1323
|
+
for item in items
|
|
1225
1324
|
if needle in _trim(item.get("resource")).lower()
|
|
1226
1325
|
or needle in _trim(item.get("description")).lower()
|
|
1227
1326
|
),
|
|
1228
1327
|
None,
|
|
1229
1328
|
)
|
|
1329
|
+
|
|
1330
|
+
# Server-side search covers the full Bazaar catalog; a plain resource
|
|
1331
|
+
# listing only ever returns the first page, so use it as a last resort.
|
|
1332
|
+
queries = [ref]
|
|
1333
|
+
if ref.startswith(("http://", "https://")):
|
|
1334
|
+
netloc = _trim(urlsplit(ref).netloc)
|
|
1335
|
+
if netloc and netloc != ref:
|
|
1336
|
+
queries.append(netloc)
|
|
1337
|
+
exact: dict[str, Any] | None = None
|
|
1338
|
+
for candidate_query in queries:
|
|
1339
|
+
results = await search_services(
|
|
1340
|
+
query=candidate_query, discovery_provider="cdp_bazaar", limit=20
|
|
1341
|
+
)
|
|
1342
|
+
exact = _match(results["items"])
|
|
1343
|
+
if exact is not None:
|
|
1344
|
+
break
|
|
1345
|
+
if exact is None:
|
|
1346
|
+
resources = await search_services(discovery_provider="cdp_bazaar", limit=200)
|
|
1347
|
+
exact = _match(resources["items"])
|
|
1230
1348
|
if exact is None:
|
|
1231
1349
|
raise ProviderError("x402-cdp-bazaar", f"No Bazaar resource matched: {ref}")
|
|
1232
1350
|
return {"discovery_provider": provider, "service": exact}
|
|
@@ -1330,78 +1448,30 @@ async def preview_request(
|
|
|
1330
1448
|
return payment_preview
|
|
1331
1449
|
|
|
1332
1450
|
|
|
1333
|
-
|
|
1451
|
+
def _reusable_approved_preview(
|
|
1452
|
+
approved_preview: Any,
|
|
1334
1453
|
*,
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
prepared["prepare_note"] = "The endpoint did not require x402 payment for this request."
|
|
1357
|
-
return prepared
|
|
1358
|
-
|
|
1359
|
-
selected_payment = _require_executable_payment(preview=preview, backend=backend)
|
|
1360
|
-
payment_required_header = (
|
|
1361
|
-
dict(preview.get("response_headers") or {}).get("payment-required")
|
|
1362
|
-
)
|
|
1363
|
-
if not isinstance(payment_required_header, str) or not payment_required_header.strip():
|
|
1364
|
-
raise ProviderError("x402-http", "Missing PAYMENT-REQUIRED header in preview state.")
|
|
1365
|
-
# Create the payload once during prepare to validate that the active wallet can sign it.
|
|
1366
|
-
await _create_payment_headers(
|
|
1367
|
-
backend=backend,
|
|
1368
|
-
payment_required_header=payment_required_header,
|
|
1369
|
-
selected_payment=selected_payment,
|
|
1370
|
-
)
|
|
1371
|
-
prepared = dict(preview)
|
|
1372
|
-
prepared["mode"] = "prepare"
|
|
1373
|
-
prepared["prepared"] = True
|
|
1374
|
-
prepared["signed"] = False
|
|
1375
|
-
prepared["broadcasted"] = False
|
|
1376
|
-
prepared["confirmed"] = False
|
|
1377
|
-
prepared["payment_payload_withheld"] = True
|
|
1378
|
-
prepared["prepare_note"] = (
|
|
1379
|
-
"x402 payment authorization was validated locally, but the PAYMENT-SIGNATURE header is withheld until execute."
|
|
1380
|
-
)
|
|
1381
|
-
return prepared
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
async def execute_request(
|
|
1385
|
-
*,
|
|
1386
|
-
backend: AgentWalletBackend,
|
|
1387
|
-
url: str,
|
|
1388
|
-
method: str = "GET",
|
|
1389
|
-
headers: dict[str, Any] | None = None,
|
|
1390
|
-
query: dict[str, Any] | None = None,
|
|
1391
|
-
json_body: Any | None = None,
|
|
1392
|
-
text_body: str | None = None,
|
|
1393
|
-
) -> dict[str, Any]:
|
|
1394
|
-
executed = await pay_and_fetch(
|
|
1395
|
-
backend=backend,
|
|
1396
|
-
url=url,
|
|
1397
|
-
method=method,
|
|
1398
|
-
headers=headers,
|
|
1399
|
-
query=query,
|
|
1400
|
-
json_body=json_body,
|
|
1401
|
-
text_body=text_body,
|
|
1402
|
-
)
|
|
1403
|
-
executed["mode"] = "execute"
|
|
1404
|
-
return executed
|
|
1454
|
+
request: dict[str, Any],
|
|
1455
|
+
) -> dict[str, Any] | None:
|
|
1456
|
+
"""Return an approval-time preview usable instead of a fresh unpaid probe.
|
|
1457
|
+
|
|
1458
|
+
Reuse is only safe when the preview describes exactly this request
|
|
1459
|
+
(fingerprint covers method, final URL, and body hash) and still carries the
|
|
1460
|
+
encoded PAYMENT-REQUIRED challenge plus a selected payment. Anything else
|
|
1461
|
+
falls back to a fresh probe.
|
|
1462
|
+
"""
|
|
1463
|
+
if not isinstance(approved_preview, dict):
|
|
1464
|
+
return None
|
|
1465
|
+
if not approved_preview.get("payment_required"):
|
|
1466
|
+
return None
|
|
1467
|
+
if _trim(approved_preview.get("request_fingerprint")) != request["request_fingerprint"]:
|
|
1468
|
+
return None
|
|
1469
|
+
if not isinstance(approved_preview.get("selected_payment"), dict):
|
|
1470
|
+
return None
|
|
1471
|
+
header = dict(approved_preview.get("response_headers") or {}).get("payment-required")
|
|
1472
|
+
if not isinstance(header, str) or not header.strip():
|
|
1473
|
+
return None
|
|
1474
|
+
return dict(approved_preview)
|
|
1405
1475
|
|
|
1406
1476
|
|
|
1407
1477
|
async def pay_and_fetch(
|
|
@@ -1413,9 +1483,9 @@ async def pay_and_fetch(
|
|
|
1413
1483
|
query: dict[str, Any] | None = None,
|
|
1414
1484
|
json_body: Any | None = None,
|
|
1415
1485
|
text_body: str | None = None,
|
|
1486
|
+
approved_preview: dict[str, Any] | None = None,
|
|
1416
1487
|
) -> dict[str, Any]:
|
|
1417
|
-
|
|
1418
|
-
backend=backend,
|
|
1488
|
+
request = _build_request_metadata(
|
|
1419
1489
|
url=url,
|
|
1420
1490
|
method=method,
|
|
1421
1491
|
headers=headers,
|
|
@@ -1423,6 +1493,18 @@ async def pay_and_fetch(
|
|
|
1423
1493
|
json_body=json_body,
|
|
1424
1494
|
text_body=text_body,
|
|
1425
1495
|
)
|
|
1496
|
+
preview = _reusable_approved_preview(approved_preview, request=request)
|
|
1497
|
+
reused_approved_preview = preview is not None
|
|
1498
|
+
if preview is None:
|
|
1499
|
+
preview = await preview_request(
|
|
1500
|
+
backend=backend,
|
|
1501
|
+
url=url,
|
|
1502
|
+
method=method,
|
|
1503
|
+
headers=headers,
|
|
1504
|
+
query=query,
|
|
1505
|
+
json_body=json_body,
|
|
1506
|
+
text_body=text_body,
|
|
1507
|
+
)
|
|
1426
1508
|
if not preview.get("payment_required"):
|
|
1427
1509
|
executed = dict(preview)
|
|
1428
1510
|
executed["mode"] = "execute"
|
|
@@ -1444,14 +1526,6 @@ async def pay_and_fetch(
|
|
|
1444
1526
|
if not isinstance(payment_required_header, str) or not payment_required_header.strip():
|
|
1445
1527
|
raise ProviderError("x402-http", "Missing PAYMENT-REQUIRED header in preview state.")
|
|
1446
1528
|
|
|
1447
|
-
request = _build_request_metadata(
|
|
1448
|
-
url=url,
|
|
1449
|
-
method=method,
|
|
1450
|
-
headers=headers,
|
|
1451
|
-
query=query,
|
|
1452
|
-
json_body=json_body,
|
|
1453
|
-
text_body=text_body,
|
|
1454
|
-
)
|
|
1455
1529
|
_validate_request_execution_policy(request=request, backend=backend)
|
|
1456
1530
|
payment_headers = await _create_payment_headers(
|
|
1457
1531
|
backend=backend,
|
|
@@ -1478,6 +1552,7 @@ async def pay_and_fetch(
|
|
|
1478
1552
|
{
|
|
1479
1553
|
"mode": "execute",
|
|
1480
1554
|
"paid": True,
|
|
1555
|
+
"reused_approved_preview": reused_approved_preview,
|
|
1481
1556
|
"broadcasted": bool(settlement and settlement.get("transaction")),
|
|
1482
1557
|
"confirmed": bool(settlement and settlement.get("success")),
|
|
1483
1558
|
"payment_settlement": settlement,
|
|
@@ -1491,9 +1566,15 @@ async def pay_and_fetch(
|
|
|
1491
1566
|
}
|
|
1492
1567
|
)
|
|
1493
1568
|
if response.status_code == 402:
|
|
1569
|
+
message = "The paid x402 retry still returned HTTP 402."
|
|
1570
|
+
if reused_approved_preview:
|
|
1571
|
+
message += (
|
|
1572
|
+
" The payment was signed from the approved preview quote; the server may have"
|
|
1573
|
+
" re-priced the endpoint since then. Run x402_preview_request again and retry."
|
|
1574
|
+
)
|
|
1494
1575
|
raise ProviderError(
|
|
1495
1576
|
"x402-http",
|
|
1496
|
-
|
|
1577
|
+
message,
|
|
1497
1578
|
details={
|
|
1498
1579
|
"request_url": request["url"],
|
|
1499
1580
|
"selected_payment": selected_payment,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "agent-wallet",
|
|
3
3
|
"name": "Agent Wallet",
|
|
4
4
|
"description": "Plugin-friendly wallet backend for OpenClaw agents with safe wallet tools and runtime instructions across Solana, local BTC, and local EVM.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.66",
|
|
6
6
|
"skills": ["skills/wallet-operator"],
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wallet-operator
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use whenever the user's own funds are involved, even if they never say "wallet": balances, portfolio, transfers, swaps, LI.FI cross-chain swaps, Jupiter swaps, Velora EVM swaps, BTC transfers, staking, Kamino lending, Bags launches, and wallet execution safety.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Wallet Operator
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-wallet",
|
|
3
3
|
"displayName": "Agent Wallet",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.66",
|
|
5
5
|
"description": "Claude Code bridge for the existing AgentLayer wallet runtime. Connects to Solana, Bitcoin, and EVM wallets without creating a new one.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AgentLayer"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Use
|
|
2
|
+
description: "Use whenever the user's own funds or wallet are involved — balances, portfolio, transfers, swaps, bridging, staking, lending, or x402 payments — even if the user never says \"wallet\". Trigger phrases include \"my balance\", \"how much SOL/ETH/BTC/USDC do I have\", \"send/transfer X to\", \"swap\", \"bridge\", \"stake\", \"my portfolio\", \"pay for this API\", \"x402\". Prefer agent-wallet MCP tools over shell commands, raw RPC, or crypto data servers. Preview writes first and execute only after explicit user confirmation."
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Agent Wallet Operator
|
|
@@ -273,6 +273,44 @@ def _cache_preview_for_approval(user_id: str, tool_name: str, payload: dict[str,
|
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
|
|
276
|
+
X402_PREVIEW_REUSE_TTL_SECONDS = 300
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _cache_x402_preview_payload(user_id: str, payload: dict[str, Any]) -> None:
|
|
280
|
+
"""Cache a paid-endpoint preview so x402_pay_request can skip its unpaid probe.
|
|
281
|
+
|
|
282
|
+
x402 previews run on the read-only resident-worker path, so they never reach
|
|
283
|
+
_cache_preview_for_approval (and preview-mode annotation strips
|
|
284
|
+
confirmation_summary anyway). Stored under the x402_preview_request key that
|
|
285
|
+
the pay alias resolves to. The wallet runtime revalidates the payload by
|
|
286
|
+
request fingerprint and falls back to a fresh probe on any mismatch, so a
|
|
287
|
+
stale or unrelated cached preview can never redirect a payment.
|
|
288
|
+
"""
|
|
289
|
+
if not isinstance(payload, dict) or payload.get("ok") is False:
|
|
290
|
+
return
|
|
291
|
+
data = payload.get("data")
|
|
292
|
+
if not isinstance(data, dict):
|
|
293
|
+
return
|
|
294
|
+
if not data.get("payment_required") or not str(data.get("request_fingerprint") or "").strip():
|
|
295
|
+
return
|
|
296
|
+
with _approval_cache_lock:
|
|
297
|
+
_prune_approval_preview_cache()
|
|
298
|
+
approval_preview_cache[_approval_cache_key(user_id, "x402_preview_request")] = {
|
|
299
|
+
"digest": _preview_digest(data),
|
|
300
|
+
"expires_at": time.time() + X402_PREVIEW_REUSE_TTL_SECONDS,
|
|
301
|
+
"preview": data,
|
|
302
|
+
"summary": None,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _attach_x402_approved_preview(tool_name: str, effective_params: dict[str, Any]) -> None:
|
|
307
|
+
if tool_name != "x402_pay_request" or "_approved_preview" in effective_params:
|
|
308
|
+
return
|
|
309
|
+
cached = _latest_cached_preview(_user_id(), tool_name)
|
|
310
|
+
if cached and isinstance(cached.get("preview"), dict):
|
|
311
|
+
effective_params["_approved_preview"] = cached["preview"]
|
|
312
|
+
|
|
313
|
+
|
|
276
314
|
def _latest_cached_preview(user_id: str, tool_name: str) -> dict[str, Any] | None:
|
|
277
315
|
with _approval_cache_lock:
|
|
278
316
|
_prune_approval_preview_cache()
|
|
@@ -1403,6 +1441,7 @@ def _invoke_wallet_tool_blocking(
|
|
|
1403
1441
|
cancellation stay responsive).
|
|
1404
1442
|
"""
|
|
1405
1443
|
used_cache, approval_args = _attach_approval_for_execute(tool_name, config, effective_params)
|
|
1444
|
+
_attach_x402_approved_preview(tool_name, effective_params)
|
|
1406
1445
|
try:
|
|
1407
1446
|
payload = _invoke_tool(
|
|
1408
1447
|
tool_name,
|
|
@@ -1413,6 +1452,10 @@ def _invoke_wallet_tool_blocking(
|
|
|
1413
1452
|
)
|
|
1414
1453
|
except Exception as exc:
|
|
1415
1454
|
raise _normalize_approval_context_error(exc) from exc
|
|
1455
|
+
if tool_name == "x402_pay_request":
|
|
1456
|
+
# The cached x402 preview quote is single-use: whether the payment
|
|
1457
|
+
# succeeded or the endpoint re-priced, the next pay must re-preview.
|
|
1458
|
+
_consume_cached_preview(_user_id(), tool_name)
|
|
1416
1459
|
_cache_preview_for_approval(_user_id(), tool_name, payload)
|
|
1417
1460
|
# A bridge-managed preview that was just executed successfully is single-use:
|
|
1418
1461
|
# drop it so a duplicate execute cannot silently re-run the operation.
|
|
@@ -1451,15 +1494,21 @@ async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[st
|
|
|
1451
1494
|
_invoke_wallet_tool_blocking, tool_name, config, effective_params
|
|
1452
1495
|
)
|
|
1453
1496
|
|
|
1497
|
+
if tool_name == "x402_preview_request":
|
|
1498
|
+
_cache_x402_preview_payload(_user_id(), payload)
|
|
1454
1499
|
if payload.get("ok") is False:
|
|
1455
1500
|
raise RuntimeError(str(payload.get("error") or f"{tool_name} failed"))
|
|
1456
1501
|
return payload.get("data", {})
|
|
1457
1502
|
|
|
1458
1503
|
|
|
1459
1504
|
BASE_INSTRUCTIONS = (
|
|
1460
|
-
"
|
|
1461
|
-
"
|
|
1462
|
-
"
|
|
1505
|
+
"This server is the user's own local AgentLayer wallet (Solana, EVM, Bitcoin). Use its "
|
|
1506
|
+
"tools whenever the user asks about their balances, portfolio, addresses, transfers, "
|
|
1507
|
+
"swaps, bridging, staking, lending, or x402 payments — even when they never say the "
|
|
1508
|
+
"word 'wallet'. For anything touching the user's own funds, prefer these tools over "
|
|
1509
|
+
"shell commands, raw RPC calls, or other crypto data servers (those are for arbitrary "
|
|
1510
|
+
"addresses and market data, not the user's wallet). Keep wallet secrets local. Preview "
|
|
1511
|
+
"writes first when supported, and execute only after explicit user confirmation."
|
|
1463
1512
|
)
|
|
1464
1513
|
|
|
1465
1514
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: "wallet-operator"
|
|
3
|
-
description: "Use
|
|
3
|
+
description: "Use whenever the user's own funds or wallet are involved — balances, portfolio, transfers, swaps, bridging, staking, lending, or x402 payments — even if the user never says \"wallet\". Trigger phrases include \"my balance\", \"how much SOL/ETH/BTC/USDC do I have\", \"send/transfer X to\", \"swap\", \"bridge\", \"stake\", \"my portfolio\". Prefer wallet tools over shell commands, preview writes first, and keep approval/signing semantics intact."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Agent Wallet Operator
|
package/install-from-github.sh
CHANGED
|
@@ -120,9 +120,15 @@ if [ -z "$SOURCE_ROOT" ] || [ ! -d "$SOURCE_ROOT" ]; then
|
|
|
120
120
|
exit 1
|
|
121
121
|
fi
|
|
122
122
|
|
|
123
|
+
# Pre-flight must be a superset of setup.sh's require_path list: everything is
|
|
124
|
+
# checked on the EXTRACTED temp tree, before the destructive swap below. A
|
|
125
|
+
# bundle that would fail setup.sh must never replace the user's runtime.
|
|
123
126
|
require_path "$SOURCE_ROOT/setup.sh" "local setup entrypoint"
|
|
124
127
|
require_path "$SOURCE_ROOT/agent-wallet" "agent-wallet package"
|
|
128
|
+
require_path "$SOURCE_ROOT/agent-wallet/scripts/install_agent_wallet.py" "Python installer"
|
|
125
129
|
require_path "$SOURCE_ROOT/.openclaw/extensions/agent-wallet" "OpenClaw extension"
|
|
130
|
+
require_path "$SOURCE_ROOT/codex/plugins/agent-wallet/.codex-plugin/plugin.json" "Codex plugin"
|
|
131
|
+
require_path "$SOURCE_ROOT/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json" "Claude Code plugin"
|
|
126
132
|
require_path "$SOURCE_ROOT/wdk-btc-wallet/package.json" "wdk-btc-wallet runtime"
|
|
127
133
|
require_path "$SOURCE_ROOT/wdk-evm-wallet/package.json" "wdk-evm-wallet runtime"
|
|
128
134
|
|
package/package.json
CHANGED