@agentlayer.tech/wallet 0.1.16 → 0.1.18
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/dist/index.js +108 -10
- package/.openclaw/extensions/agent-wallet/index.ts +108 -10
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +5 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/.openclaw/extensions/pay-bridge/package.json +1 -1
- package/CHANGELOG.md +38 -0
- package/agent-wallet/README.md +5 -1
- package/agent-wallet/agent_wallet/openclaw_adapter.py +306 -4
- package/agent-wallet/agent_wallet/providers/flash_sdk_bridge.py +50 -4
- package/agent-wallet/agent_wallet/providers/x402.py +1323 -0
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +9 -9
- package/agent-wallet/agent_wallet/wallet_layer/wdk_evm.py +30 -0
- package/agent-wallet/pyproject.toml +2 -1
- package/agent-wallet/scripts/flash-sdk-bridge/README.md +2 -2
- package/agent-wallet/scripts/flash-sdk-bridge/bridge.mjs +17 -17
- package/agent-wallet/scripts/install_openclaw_local_config.py +8 -1
- package/package.json +1 -1
- package/wdk-evm-wallet/src/server.js +6 -0
- package/wdk-evm-wallet/src/wdk_evm_wallet.js +108 -0
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
"""x402 discovery, preview, and buyer execution helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
from agent_wallet.config import resolve_solana_rpc_url
|
|
12
|
+
from agent_wallet.exceptions import ProviderError
|
|
13
|
+
from agent_wallet.http_client import get_client
|
|
14
|
+
from agent_wallet.wallet_layer.base import AgentWalletBackend
|
|
15
|
+
|
|
16
|
+
CDP_BAZAAR_DISCOVERY_BASE_URL = "https://api.cdp.coinbase.com/platform/v2/x402/discovery"
|
|
17
|
+
AGENTIC_MARKET_API_BASE_URL = "https://api.agentic.market/v1"
|
|
18
|
+
SOLANA_CAIP_BY_NETWORK = {
|
|
19
|
+
"mainnet": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
20
|
+
"devnet": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
|
|
21
|
+
}
|
|
22
|
+
EVM_CAIP_BY_NETWORK = {
|
|
23
|
+
"ethereum": "eip155:1",
|
|
24
|
+
"base": "eip155:8453",
|
|
25
|
+
"sepolia": "eip155:11155111",
|
|
26
|
+
"base-sepolia": "eip155:84532",
|
|
27
|
+
}
|
|
28
|
+
_USDC_IDENTIFIERS = {
|
|
29
|
+
"usdc",
|
|
30
|
+
"usd coin",
|
|
31
|
+
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
32
|
+
"0x036cbd53842c5426634e7929541ec2318f3dcf7e",
|
|
33
|
+
"epjfwdd5aufqssqem2qn1xzybapc8g4wegkgkzwytdt1v",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _backend_chain(backend: AgentWalletBackend) -> str:
|
|
38
|
+
chain = _trim(getattr(backend, "chain", "")).lower()
|
|
39
|
+
if chain:
|
|
40
|
+
return chain
|
|
41
|
+
try:
|
|
42
|
+
capabilities = backend.get_capabilities()
|
|
43
|
+
except Exception:
|
|
44
|
+
return ""
|
|
45
|
+
return _trim(getattr(capabilities, "chain", "")).lower()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _backend_network(backend: AgentWalletBackend) -> str:
|
|
49
|
+
return _trim(getattr(backend, "network", "")).lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _backend_solana_sdk_rpc_url(backend: AgentWalletBackend) -> str | None:
|
|
53
|
+
candidates = getattr(backend, "rpc_urls", None)
|
|
54
|
+
if isinstance(candidates, list):
|
|
55
|
+
for candidate in candidates:
|
|
56
|
+
text = _trim(candidate)
|
|
57
|
+
if text.startswith(("http://", "https://")):
|
|
58
|
+
return text
|
|
59
|
+
primary = _trim(getattr(backend, "rpc_url", None))
|
|
60
|
+
if primary.startswith(("http://", "https://")):
|
|
61
|
+
return primary
|
|
62
|
+
network = _backend_network(backend)
|
|
63
|
+
fallback = resolve_solana_rpc_url(network or "mainnet", "")
|
|
64
|
+
return _trim(fallback) or None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _trim(value: Any) -> str:
|
|
68
|
+
return str(value or "").strip()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _canonical_json_text(payload: Any) -> str:
|
|
72
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _hash_text(text: str) -> str:
|
|
76
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _normalize_discovery_provider(value: Any) -> str:
|
|
80
|
+
provider = _trim(value).lower() or "auto"
|
|
81
|
+
aliases = {
|
|
82
|
+
"bazaar": "cdp_bazaar",
|
|
83
|
+
"cdp": "cdp_bazaar",
|
|
84
|
+
"agentic": "agentic_market",
|
|
85
|
+
"agenticmarket": "agentic_market",
|
|
86
|
+
"market": "agentic_market",
|
|
87
|
+
}
|
|
88
|
+
provider = aliases.get(provider, provider)
|
|
89
|
+
if provider not in {"auto", "cdp_bazaar", "agentic_market"}:
|
|
90
|
+
raise ProviderError("x402-discovery", f"Unsupported discovery provider: {provider}")
|
|
91
|
+
return provider
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _normalize_http_method(value: Any) -> str:
|
|
95
|
+
method = _trim(value).upper() or "GET"
|
|
96
|
+
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
|
|
97
|
+
raise ProviderError("x402-http", f"Unsupported HTTP method: {method}")
|
|
98
|
+
return method
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _normalize_headers(value: Any) -> dict[str, str]:
|
|
102
|
+
if value is None:
|
|
103
|
+
return {}
|
|
104
|
+
if not isinstance(value, dict):
|
|
105
|
+
raise ProviderError("x402-http", "headers must be an object when provided.")
|
|
106
|
+
headers: dict[str, str] = {}
|
|
107
|
+
for key, raw_value in value.items():
|
|
108
|
+
name = _trim(key)
|
|
109
|
+
if not name:
|
|
110
|
+
raise ProviderError("x402-http", "headers must not contain empty names.")
|
|
111
|
+
headers[name] = str(raw_value)
|
|
112
|
+
return headers
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _normalize_query_params(value: Any) -> dict[str, str]:
|
|
116
|
+
if value is None:
|
|
117
|
+
return {}
|
|
118
|
+
if not isinstance(value, dict):
|
|
119
|
+
raise ProviderError("x402-http", "query must be an object when provided.")
|
|
120
|
+
params: dict[str, str] = {}
|
|
121
|
+
for key, raw_value in value.items():
|
|
122
|
+
name = _trim(key)
|
|
123
|
+
if not name:
|
|
124
|
+
raise ProviderError("x402-http", "query must not contain empty names.")
|
|
125
|
+
params[name] = str(raw_value)
|
|
126
|
+
return params
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _append_query(url: str, query: dict[str, str]) -> str:
|
|
130
|
+
if not query:
|
|
131
|
+
return url
|
|
132
|
+
parts = urlsplit(url)
|
|
133
|
+
merged = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
134
|
+
merged.update(query)
|
|
135
|
+
return urlunsplit(
|
|
136
|
+
(parts.scheme, parts.netloc, parts.path, urlencode(merged, doseq=True), parts.fragment)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _response_text(response: Any) -> str:
|
|
141
|
+
try:
|
|
142
|
+
text = response.text
|
|
143
|
+
except Exception:
|
|
144
|
+
return ""
|
|
145
|
+
return str(text or "")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_json_response(response: Any, *, provider: str, operation: str) -> Any:
|
|
149
|
+
body = _response_text(response)
|
|
150
|
+
if not body.strip():
|
|
151
|
+
raise ProviderError(provider, f"{operation} returned an empty response body.")
|
|
152
|
+
try:
|
|
153
|
+
return response.json()
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
snippet = body.strip().replace("\n", " ")[:200]
|
|
156
|
+
detail = f": {snippet}" if snippet else ""
|
|
157
|
+
raise ProviderError(provider, f"{operation} returned invalid JSON{detail}") from exc
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _decode_payment_required(header_value: str) -> dict[str, Any]:
|
|
161
|
+
raw = _trim(header_value)
|
|
162
|
+
if not raw:
|
|
163
|
+
raise ProviderError("x402-http", "PAYMENT-REQUIRED header is empty.")
|
|
164
|
+
decoded_bytes: bytes | None = None
|
|
165
|
+
for decoder in (base64.b64decode, base64.urlsafe_b64decode):
|
|
166
|
+
try:
|
|
167
|
+
padding = "=" * (-len(raw) % 4)
|
|
168
|
+
decoded_bytes = decoder(raw + padding)
|
|
169
|
+
break
|
|
170
|
+
except Exception:
|
|
171
|
+
continue
|
|
172
|
+
if decoded_bytes is None:
|
|
173
|
+
raise ProviderError("x402-http", "PAYMENT-REQUIRED header is not valid base64.")
|
|
174
|
+
try:
|
|
175
|
+
payload = json.loads(decoded_bytes.decode("utf-8"))
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
raise ProviderError("x402-http", "PAYMENT-REQUIRED header is not valid JSON.") from exc
|
|
178
|
+
if isinstance(payload, list):
|
|
179
|
+
accepts = payload
|
|
180
|
+
x402_version = None
|
|
181
|
+
elif isinstance(payload, dict):
|
|
182
|
+
accepts = payload.get("accepts")
|
|
183
|
+
x402_version = payload.get("x402Version")
|
|
184
|
+
else:
|
|
185
|
+
raise ProviderError("x402-http", "PAYMENT-REQUIRED payload must decode to JSON.")
|
|
186
|
+
if not isinstance(accepts, list) or not accepts:
|
|
187
|
+
raise ProviderError("x402-http", "PAYMENT-REQUIRED payload does not contain accepts[].")
|
|
188
|
+
return {
|
|
189
|
+
"x402_version": x402_version,
|
|
190
|
+
"accepts": accepts,
|
|
191
|
+
"raw": payload,
|
|
192
|
+
"encoded": raw,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _extract_requirement_extra(requirement: dict[str, Any]) -> dict[str, Any]:
|
|
197
|
+
extra = requirement.get("extra")
|
|
198
|
+
return dict(extra) if isinstance(extra, dict) else {}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _requirement_field(requirement: Any, field_name: str) -> Any:
|
|
202
|
+
if isinstance(requirement, dict):
|
|
203
|
+
aliases = {
|
|
204
|
+
"pay_to": ("pay_to", "payTo"),
|
|
205
|
+
"max_timeout_seconds": ("max_timeout_seconds", "maxTimeoutSeconds"),
|
|
206
|
+
}
|
|
207
|
+
for candidate in aliases.get(field_name, (field_name,)):
|
|
208
|
+
if candidate in requirement:
|
|
209
|
+
return requirement.get(candidate)
|
|
210
|
+
return None
|
|
211
|
+
return getattr(requirement, field_name, None)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _looks_like_usdc(requirement: dict[str, Any]) -> bool:
|
|
215
|
+
asset = _trim(requirement.get("asset")).lower()
|
|
216
|
+
if asset in _USDC_IDENTIFIERS:
|
|
217
|
+
return True
|
|
218
|
+
extra = _extract_requirement_extra(requirement)
|
|
219
|
+
return _trim(extra.get("name")).lower() in _USDC_IDENTIFIERS
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _normalize_amount_hint(requirement: dict[str, Any]) -> str | None:
|
|
223
|
+
amount = _trim(requirement.get("amount"))
|
|
224
|
+
if not amount.isdigit():
|
|
225
|
+
return None
|
|
226
|
+
if _looks_like_usdc(requirement):
|
|
227
|
+
raw = int(amount)
|
|
228
|
+
whole = raw // 1_000_000
|
|
229
|
+
fraction = raw % 1_000_000
|
|
230
|
+
if fraction:
|
|
231
|
+
return f"{whole}.{fraction:06d}".rstrip("0").rstrip(".")
|
|
232
|
+
return f"{whole}"
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def normalize_payment_requirement(
|
|
237
|
+
requirement: dict[str, Any],
|
|
238
|
+
*,
|
|
239
|
+
source: str,
|
|
240
|
+
resource_url: str | None = None,
|
|
241
|
+
) -> dict[str, Any]:
|
|
242
|
+
extra = _extract_requirement_extra(requirement)
|
|
243
|
+
amount = _trim(requirement.get("amount"))
|
|
244
|
+
return {
|
|
245
|
+
"scheme": _trim(requirement.get("scheme")).lower() or None,
|
|
246
|
+
"network": _trim(requirement.get("network")) or None,
|
|
247
|
+
"asset": _trim(requirement.get("asset")) or None,
|
|
248
|
+
"amount": amount or None,
|
|
249
|
+
"amount_display": _normalize_amount_hint(requirement),
|
|
250
|
+
"pay_to": _trim(requirement.get("payTo")) or None,
|
|
251
|
+
"max_timeout_seconds": requirement.get("maxTimeoutSeconds"),
|
|
252
|
+
"resource_url": resource_url,
|
|
253
|
+
"extra": extra,
|
|
254
|
+
"source": source,
|
|
255
|
+
"raw": requirement,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _normalize_cdp_resource(item: dict[str, Any]) -> dict[str, Any]:
|
|
260
|
+
metadata = item.get("metadata")
|
|
261
|
+
metadata_dict = dict(metadata) if isinstance(metadata, dict) else {}
|
|
262
|
+
accepts = item.get("accepts") if isinstance(item.get("accepts"), list) else []
|
|
263
|
+
resource = _trim(item.get("resource"))
|
|
264
|
+
return {
|
|
265
|
+
"discovery_provider": "cdp_bazaar",
|
|
266
|
+
"resource": resource,
|
|
267
|
+
"type": _trim(item.get("type")) or "http",
|
|
268
|
+
"x402_version": item.get("x402Version"),
|
|
269
|
+
"description": _trim(item.get("description") or metadata_dict.get("description")) or None,
|
|
270
|
+
"last_updated": item.get("lastUpdated"),
|
|
271
|
+
"accepts": [
|
|
272
|
+
normalize_payment_requirement(requirement, source="cdp_bazaar", resource_url=resource)
|
|
273
|
+
for requirement in accepts
|
|
274
|
+
if isinstance(requirement, dict)
|
|
275
|
+
],
|
|
276
|
+
"metadata": metadata_dict,
|
|
277
|
+
"raw": item,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _normalize_agentic_service(service: dict[str, Any]) -> dict[str, Any]:
|
|
282
|
+
endpoints = service.get("endpoints") if isinstance(service.get("endpoints"), list) else []
|
|
283
|
+
normalized_endpoints: list[dict[str, Any]] = []
|
|
284
|
+
accepts: list[dict[str, Any]] = []
|
|
285
|
+
for endpoint in endpoints:
|
|
286
|
+
if not isinstance(endpoint, dict):
|
|
287
|
+
continue
|
|
288
|
+
pricing = endpoint.get("pricing") if isinstance(endpoint.get("pricing"), dict) else {}
|
|
289
|
+
normalized = {
|
|
290
|
+
"url": _trim(endpoint.get("url")) or None,
|
|
291
|
+
"description": _trim(endpoint.get("description")) or None,
|
|
292
|
+
"method": _normalize_http_method(endpoint.get("method")),
|
|
293
|
+
"pricing": {
|
|
294
|
+
"amount": _trim(pricing.get("amount")) or None,
|
|
295
|
+
"currency": _trim(pricing.get("currency")) or None,
|
|
296
|
+
"network": _trim(pricing.get("network")) or None,
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
normalized_endpoints.append(normalized)
|
|
300
|
+
amount_text = _trim(pricing.get("amount"))
|
|
301
|
+
currency = _trim(pricing.get("currency")).lower()
|
|
302
|
+
network = _trim(pricing.get("network")).lower()
|
|
303
|
+
if amount_text and currency == "usdc":
|
|
304
|
+
accepts.append(
|
|
305
|
+
{
|
|
306
|
+
"scheme": "exact",
|
|
307
|
+
"network": EVM_CAIP_BY_NETWORK.get(network, SOLANA_CAIP_BY_NETWORK.get(network, network)),
|
|
308
|
+
"asset": "USDC",
|
|
309
|
+
"amount": amount_text,
|
|
310
|
+
"amount_display": amount_text,
|
|
311
|
+
"pay_to": None,
|
|
312
|
+
"max_timeout_seconds": None,
|
|
313
|
+
"resource_url": normalized["url"],
|
|
314
|
+
"extra": {
|
|
315
|
+
"marketplace": "agentic_market",
|
|
316
|
+
"pricingCurrency": pricing.get("currency"),
|
|
317
|
+
"pricingNetwork": pricing.get("network"),
|
|
318
|
+
},
|
|
319
|
+
"source": "agentic_market",
|
|
320
|
+
"raw": endpoint,
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
return {
|
|
324
|
+
"discovery_provider": "agentic_market",
|
|
325
|
+
"service_id": _trim(service.get("id")) or None,
|
|
326
|
+
"service_name": _trim(service.get("name")) or None,
|
|
327
|
+
"description": _trim(service.get("description")) or None,
|
|
328
|
+
"domain": _trim(service.get("domain")) or None,
|
|
329
|
+
"category": _trim(service.get("category")) or None,
|
|
330
|
+
"networks": [str(item) for item in service.get("networks") or []],
|
|
331
|
+
"integration_type": _trim(service.get("integrationType")) or None,
|
|
332
|
+
"is_new": bool(service.get("isNew")),
|
|
333
|
+
"endpoints": normalized_endpoints,
|
|
334
|
+
"accepts": accepts,
|
|
335
|
+
"raw": service,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _wallet_caip_networks(backend: AgentWalletBackend) -> list[str]:
|
|
340
|
+
chain = _backend_chain(backend)
|
|
341
|
+
network = _backend_network(backend)
|
|
342
|
+
if chain == "evm":
|
|
343
|
+
caip = EVM_CAIP_BY_NETWORK.get(network)
|
|
344
|
+
return [caip] if caip else []
|
|
345
|
+
if chain == "solana":
|
|
346
|
+
caip = SOLANA_CAIP_BY_NETWORK.get(network)
|
|
347
|
+
return [caip] if caip else []
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _solana_exact_execution_supported(backend: AgentWalletBackend) -> bool:
|
|
352
|
+
return (
|
|
353
|
+
_backend_chain(backend) == "solana"
|
|
354
|
+
and _backend_network(backend) in {"mainnet", "devnet"}
|
|
355
|
+
and getattr(backend, "signer", None) is not None
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _evm_exact_execution_supported(backend: AgentWalletBackend) -> bool:
|
|
360
|
+
return (
|
|
361
|
+
_backend_chain(backend) == "evm"
|
|
362
|
+
and _backend_network(backend) in {"base", "base-sepolia"}
|
|
363
|
+
and callable(getattr(backend, "sign_x402_evm_exact_typed_data", None))
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _evm_payment_requirement_supported(requirement: dict[str, Any]) -> bool:
|
|
368
|
+
if _trim(requirement.get("scheme")).lower() != "exact":
|
|
369
|
+
return False
|
|
370
|
+
extra = _extract_requirement_extra(requirement)
|
|
371
|
+
transfer_method = _trim(extra.get("assetTransferMethod")).lower()
|
|
372
|
+
return transfer_method in {"", "eip3009", "transferwithauthorization"}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _wallet_x402_support_summary(backend: AgentWalletBackend) -> dict[str, Any]:
|
|
376
|
+
chain = _backend_chain(backend)
|
|
377
|
+
network = _backend_network(backend)
|
|
378
|
+
supported_networks = _wallet_caip_networks(backend)
|
|
379
|
+
planned_execution_networks = {
|
|
380
|
+
"eip155:8453",
|
|
381
|
+
"eip155:84532",
|
|
382
|
+
"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
383
|
+
"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
|
|
384
|
+
}
|
|
385
|
+
execution_modes: list[str] = []
|
|
386
|
+
if _solana_exact_execution_supported(backend):
|
|
387
|
+
execution_modes.append("solana_exact")
|
|
388
|
+
if _evm_exact_execution_supported(backend):
|
|
389
|
+
execution_modes.append("evm_exact")
|
|
390
|
+
return {
|
|
391
|
+
"chain": chain,
|
|
392
|
+
"network": network,
|
|
393
|
+
"supported_caip_networks": supported_networks,
|
|
394
|
+
"wallet_type_supported": chain in {"evm", "solana"},
|
|
395
|
+
"execution_available": bool(execution_modes),
|
|
396
|
+
"execution_modes": execution_modes,
|
|
397
|
+
"planned_execution_networks": sorted(planned_execution_networks),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _requirement_compatibility(requirement: dict[str, Any], backend: AgentWalletBackend) -> dict[str, Any]:
|
|
402
|
+
wallet_summary = _wallet_x402_support_summary(backend)
|
|
403
|
+
network = _trim(requirement.get("network"))
|
|
404
|
+
scheme = _trim(requirement.get("scheme")).lower()
|
|
405
|
+
wallet_network_matches = network in wallet_summary["supported_caip_networks"]
|
|
406
|
+
chain = _backend_chain(backend)
|
|
407
|
+
planned_execution_supported = False
|
|
408
|
+
currently_executable = False
|
|
409
|
+
if chain == "solana":
|
|
410
|
+
planned_execution_supported = scheme == "exact" and network in set(
|
|
411
|
+
wallet_summary["planned_execution_networks"]
|
|
412
|
+
)
|
|
413
|
+
currently_executable = (
|
|
414
|
+
planned_execution_supported
|
|
415
|
+
and wallet_network_matches
|
|
416
|
+
and _solana_exact_execution_supported(backend)
|
|
417
|
+
)
|
|
418
|
+
elif chain == "evm":
|
|
419
|
+
planned_execution_supported = (
|
|
420
|
+
scheme == "exact"
|
|
421
|
+
and network in set(wallet_summary["planned_execution_networks"])
|
|
422
|
+
and _evm_payment_requirement_supported(requirement)
|
|
423
|
+
)
|
|
424
|
+
currently_executable = (
|
|
425
|
+
planned_execution_supported
|
|
426
|
+
and wallet_network_matches
|
|
427
|
+
and _evm_exact_execution_supported(backend)
|
|
428
|
+
)
|
|
429
|
+
if currently_executable:
|
|
430
|
+
reason = (
|
|
431
|
+
"Executable now through the local Solana exact buyer flow."
|
|
432
|
+
if chain == "solana"
|
|
433
|
+
else "Executable now through the local EVM exact buyer flow."
|
|
434
|
+
)
|
|
435
|
+
elif chain == "evm" and scheme == "exact" and not _evm_payment_requirement_supported(requirement):
|
|
436
|
+
reason = "This EVM exact payment requires a transfer method that is not enabled in the current wallet runtime."
|
|
437
|
+
elif planned_execution_supported and wallet_network_matches:
|
|
438
|
+
reason = "Wallet network matches, but this backend does not yet expose a supported x402 signer path."
|
|
439
|
+
elif planned_execution_supported:
|
|
440
|
+
reason = "Planned execution path exists, but the requirement targets a different network than the active wallet."
|
|
441
|
+
else:
|
|
442
|
+
reason = "Unsupported scheme or network for the planned execution path."
|
|
443
|
+
return {
|
|
444
|
+
"wallet_type_supported": wallet_summary["wallet_type_supported"],
|
|
445
|
+
"wallet_network_matches": wallet_network_matches,
|
|
446
|
+
"planned_execution_supported": planned_execution_supported,
|
|
447
|
+
"currently_executable": currently_executable,
|
|
448
|
+
"reason": reason,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _select_preferred_requirement(
|
|
453
|
+
requirements: list[dict[str, Any]],
|
|
454
|
+
backend: AgentWalletBackend,
|
|
455
|
+
) -> dict[str, Any] | None:
|
|
456
|
+
compatible = [
|
|
457
|
+
requirement
|
|
458
|
+
for requirement in requirements
|
|
459
|
+
if _requirement_compatibility(requirement, backend)["planned_execution_supported"]
|
|
460
|
+
]
|
|
461
|
+
exact_match = [
|
|
462
|
+
requirement
|
|
463
|
+
for requirement in compatible
|
|
464
|
+
if _requirement_compatibility(requirement, backend)["wallet_network_matches"]
|
|
465
|
+
]
|
|
466
|
+
candidates = exact_match or compatible
|
|
467
|
+
if not candidates:
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
def sort_key(item: dict[str, Any]) -> tuple[int, str]:
|
|
471
|
+
amount = _trim(item.get("amount"))
|
|
472
|
+
return (0 if amount.isdigit() else 1, amount)
|
|
473
|
+
|
|
474
|
+
return sorted(candidates, key=sort_key)[0]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _build_request_metadata(
|
|
478
|
+
*,
|
|
479
|
+
url: str,
|
|
480
|
+
method: str = "GET",
|
|
481
|
+
headers: dict[str, Any] | None = None,
|
|
482
|
+
query: dict[str, Any] | None = None,
|
|
483
|
+
json_body: Any | None = None,
|
|
484
|
+
text_body: str | None = None,
|
|
485
|
+
) -> dict[str, Any]:
|
|
486
|
+
request_url = _trim(url)
|
|
487
|
+
if not request_url:
|
|
488
|
+
raise ProviderError("x402-http", "url is required.")
|
|
489
|
+
http_method = _normalize_http_method(method)
|
|
490
|
+
normalized_headers = _normalize_headers(headers)
|
|
491
|
+
normalized_query = _normalize_query_params(query)
|
|
492
|
+
if json_body is not None and text_body is not None:
|
|
493
|
+
raise ProviderError("x402-http", "Provide either json_body or text_body, not both.")
|
|
494
|
+
final_url = _append_query(request_url, normalized_query)
|
|
495
|
+
body_hash = None
|
|
496
|
+
content_type = None
|
|
497
|
+
if json_body is not None:
|
|
498
|
+
body_hash = _hash_text(_canonical_json_text(json_body))
|
|
499
|
+
content_type = normalized_headers.get("Content-Type") or normalized_headers.get("content-type")
|
|
500
|
+
if not content_type:
|
|
501
|
+
normalized_headers["Content-Type"] = "application/json"
|
|
502
|
+
elif text_body is not None:
|
|
503
|
+
body_hash = _hash_text(text_body)
|
|
504
|
+
content_type = normalized_headers.get("Content-Type") or normalized_headers.get("content-type")
|
|
505
|
+
request_fingerprint = _hash_text(
|
|
506
|
+
_canonical_json_text(
|
|
507
|
+
{
|
|
508
|
+
"method": http_method,
|
|
509
|
+
"url": final_url,
|
|
510
|
+
"body_hash": body_hash,
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
return {
|
|
515
|
+
"url": final_url,
|
|
516
|
+
"method": http_method,
|
|
517
|
+
"headers": normalized_headers,
|
|
518
|
+
"query": normalized_query,
|
|
519
|
+
"json_body": json_body,
|
|
520
|
+
"text_body": text_body,
|
|
521
|
+
"body_hash": body_hash,
|
|
522
|
+
"content_type": content_type,
|
|
523
|
+
"request_fingerprint": request_fingerprint,
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
async def _send_request(
|
|
528
|
+
*,
|
|
529
|
+
client: Any,
|
|
530
|
+
request: dict[str, Any],
|
|
531
|
+
extra_headers: dict[str, str] | None = None,
|
|
532
|
+
) -> Any:
|
|
533
|
+
headers = dict(request["headers"])
|
|
534
|
+
if extra_headers:
|
|
535
|
+
headers.update(extra_headers)
|
|
536
|
+
return await client.request(
|
|
537
|
+
request["method"],
|
|
538
|
+
request["url"],
|
|
539
|
+
headers=headers,
|
|
540
|
+
json=request["json_body"] if request["json_body"] is not None else None,
|
|
541
|
+
content=request["text_body"] if request["text_body"] is not None else None,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _build_x402_action_payload(
|
|
546
|
+
*,
|
|
547
|
+
backend: AgentWalletBackend,
|
|
548
|
+
request: dict[str, Any],
|
|
549
|
+
wallet_summary: dict[str, Any],
|
|
550
|
+
address: str | None,
|
|
551
|
+
status_code: int,
|
|
552
|
+
selected_payment: dict[str, Any] | None,
|
|
553
|
+
accepted_payments: list[dict[str, Any]] | None = None,
|
|
554
|
+
) -> dict[str, Any]:
|
|
555
|
+
return {
|
|
556
|
+
"asset_type": "x402-request",
|
|
557
|
+
"source": "x402-http",
|
|
558
|
+
"chain": _backend_chain(backend),
|
|
559
|
+
"network": _backend_network(backend),
|
|
560
|
+
"x402_network": selected_payment.get("network") if isinstance(selected_payment, dict) else None,
|
|
561
|
+
"x402_scheme": selected_payment.get("scheme") if isinstance(selected_payment, dict) else None,
|
|
562
|
+
"x402_asset": selected_payment.get("asset") if isinstance(selected_payment, dict) else None,
|
|
563
|
+
"x402_amount": selected_payment.get("amount") if isinstance(selected_payment, dict) else None,
|
|
564
|
+
"x402_amount_display": selected_payment.get("amount_display")
|
|
565
|
+
if isinstance(selected_payment, dict)
|
|
566
|
+
else None,
|
|
567
|
+
"x402_pay_to": selected_payment.get("pay_to") if isinstance(selected_payment, dict) else None,
|
|
568
|
+
"request_url": request["url"],
|
|
569
|
+
"method": request["method"],
|
|
570
|
+
"request_fingerprint": request["request_fingerprint"],
|
|
571
|
+
"body_hash": request["body_hash"],
|
|
572
|
+
"content_type": request["content_type"],
|
|
573
|
+
"wallet": {
|
|
574
|
+
**wallet_summary,
|
|
575
|
+
"address": address,
|
|
576
|
+
},
|
|
577
|
+
"status_code": status_code,
|
|
578
|
+
"selected_payment": selected_payment,
|
|
579
|
+
"accepted_payments": accepted_payments,
|
|
580
|
+
"payment_required": selected_payment is not None,
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _response_preview(response: Any) -> Any:
|
|
585
|
+
try:
|
|
586
|
+
return response.json()
|
|
587
|
+
except Exception:
|
|
588
|
+
return _response_text(response)[:2000]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _parse_payment_required_response(
|
|
592
|
+
response: Any,
|
|
593
|
+
*,
|
|
594
|
+
backend: AgentWalletBackend,
|
|
595
|
+
request: dict[str, Any],
|
|
596
|
+
address: str | None,
|
|
597
|
+
wallet_summary: dict[str, Any],
|
|
598
|
+
) -> dict[str, Any]:
|
|
599
|
+
payment_required = response.headers.get("PAYMENT-REQUIRED")
|
|
600
|
+
if not payment_required:
|
|
601
|
+
raise ProviderError(
|
|
602
|
+
"x402-http",
|
|
603
|
+
"Server returned HTTP 402 without a PAYMENT-REQUIRED header.",
|
|
604
|
+
details={"status_code": response.status_code, "url": request["url"]},
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
decoded = _decode_payment_required(payment_required)
|
|
608
|
+
normalized_accepts = [
|
|
609
|
+
normalize_payment_requirement(requirement, source="payment_required", resource_url=request["url"])
|
|
610
|
+
for requirement in decoded["accepts"]
|
|
611
|
+
if isinstance(requirement, dict)
|
|
612
|
+
]
|
|
613
|
+
compatibility = [
|
|
614
|
+
{
|
|
615
|
+
**requirement,
|
|
616
|
+
"compatibility": _requirement_compatibility(requirement, backend),
|
|
617
|
+
}
|
|
618
|
+
for requirement in normalized_accepts
|
|
619
|
+
]
|
|
620
|
+
selected = _select_preferred_requirement(normalized_accepts, backend)
|
|
621
|
+
preview = _build_x402_action_payload(
|
|
622
|
+
backend=backend,
|
|
623
|
+
request=request,
|
|
624
|
+
wallet_summary=wallet_summary,
|
|
625
|
+
address=address,
|
|
626
|
+
status_code=response.status_code,
|
|
627
|
+
selected_payment=selected,
|
|
628
|
+
accepted_payments=compatibility,
|
|
629
|
+
)
|
|
630
|
+
preview.update(
|
|
631
|
+
{
|
|
632
|
+
"execute_available": bool(
|
|
633
|
+
isinstance(selected, dict)
|
|
634
|
+
and _requirement_compatibility(selected, backend)["currently_executable"]
|
|
635
|
+
),
|
|
636
|
+
"x402_version": decoded["x402_version"],
|
|
637
|
+
"response_headers": {
|
|
638
|
+
"payment-required": decoded["encoded"],
|
|
639
|
+
"content-type": response.headers.get("content-type"),
|
|
640
|
+
},
|
|
641
|
+
}
|
|
642
|
+
)
|
|
643
|
+
return preview
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _require_executable_payment(
|
|
647
|
+
*,
|
|
648
|
+
preview: dict[str, Any],
|
|
649
|
+
backend: AgentWalletBackend,
|
|
650
|
+
) -> dict[str, Any]:
|
|
651
|
+
selected = preview.get("selected_payment")
|
|
652
|
+
if not isinstance(selected, dict):
|
|
653
|
+
raise ProviderError(
|
|
654
|
+
"x402-http",
|
|
655
|
+
"No compatible x402 payment requirement was selected for this wallet.",
|
|
656
|
+
details={
|
|
657
|
+
"request_url": preview.get("request_url"),
|
|
658
|
+
"accepted_payments": preview.get("accepted_payments"),
|
|
659
|
+
},
|
|
660
|
+
)
|
|
661
|
+
compatibility = _requirement_compatibility(selected, backend)
|
|
662
|
+
if not compatibility["currently_executable"]:
|
|
663
|
+
raise ProviderError(
|
|
664
|
+
"x402-http",
|
|
665
|
+
str(compatibility["reason"]),
|
|
666
|
+
details={
|
|
667
|
+
"selected_payment": selected,
|
|
668
|
+
"compatibility": compatibility,
|
|
669
|
+
},
|
|
670
|
+
)
|
|
671
|
+
return selected
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _select_sdk_payment_requirement(
|
|
675
|
+
payment_required: Any,
|
|
676
|
+
*,
|
|
677
|
+
selected_payment: dict[str, Any],
|
|
678
|
+
) -> Any:
|
|
679
|
+
accepts = getattr(payment_required, "accepts", None)
|
|
680
|
+
if not isinstance(accepts, list) or not accepts:
|
|
681
|
+
raise ProviderError("x402-http", "Decoded x402 payment payload does not contain accepts[].")
|
|
682
|
+
|
|
683
|
+
selected_raw = selected_payment.get("raw") if isinstance(selected_payment, dict) else None
|
|
684
|
+
if isinstance(selected_raw, dict):
|
|
685
|
+
for requirement in accepts:
|
|
686
|
+
model_dump = getattr(requirement, "model_dump", None)
|
|
687
|
+
if callable(model_dump):
|
|
688
|
+
dumped = model_dump(by_alias=True, exclude_none=True)
|
|
689
|
+
if dumped == selected_raw:
|
|
690
|
+
return requirement
|
|
691
|
+
|
|
692
|
+
for requirement in accepts:
|
|
693
|
+
if (
|
|
694
|
+
_trim(_requirement_field(requirement, "scheme")).lower()
|
|
695
|
+
== _trim(selected_payment.get("scheme")).lower()
|
|
696
|
+
and _trim(_requirement_field(requirement, "network"))
|
|
697
|
+
== _trim(selected_payment.get("network"))
|
|
698
|
+
and _trim(_requirement_field(requirement, "asset"))
|
|
699
|
+
== _trim(selected_payment.get("asset"))
|
|
700
|
+
and _trim(_requirement_field(requirement, "amount"))
|
|
701
|
+
== _trim(selected_payment.get("amount"))
|
|
702
|
+
and _trim(_requirement_field(requirement, "pay_to"))
|
|
703
|
+
== _trim(selected_payment.get("pay_to"))
|
|
704
|
+
):
|
|
705
|
+
return requirement
|
|
706
|
+
|
|
707
|
+
if len(accepts) == 1:
|
|
708
|
+
return accepts[0]
|
|
709
|
+
|
|
710
|
+
raise ProviderError(
|
|
711
|
+
"x402-http",
|
|
712
|
+
"Could not match the selected x402 payment back to the decoded PAYMENT-REQUIRED payload.",
|
|
713
|
+
details={
|
|
714
|
+
"selected_payment": selected_payment,
|
|
715
|
+
"accepts_count": len(accepts),
|
|
716
|
+
},
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _build_selected_payment_required_payload(
|
|
721
|
+
payment_required: Any,
|
|
722
|
+
*,
|
|
723
|
+
selected_payment: dict[str, Any],
|
|
724
|
+
) -> Any:
|
|
725
|
+
selected_requirement = _select_sdk_payment_requirement(
|
|
726
|
+
payment_required,
|
|
727
|
+
selected_payment=selected_payment,
|
|
728
|
+
)
|
|
729
|
+
return payment_required.model_copy(update={"accepts": [selected_requirement]})
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _load_x402_common_sdk() -> dict[str, Any]:
|
|
733
|
+
try:
|
|
734
|
+
from x402 import x402Client
|
|
735
|
+
from x402.http.x402_http_client_base import x402HTTPClientBase
|
|
736
|
+
from x402.http.utils import decode_payment_required_header
|
|
737
|
+
except ImportError as exc:
|
|
738
|
+
raise ProviderError(
|
|
739
|
+
"x402-sdk",
|
|
740
|
+
"x402 execution requires the x402 Python package with HTTP client support.",
|
|
741
|
+
details={"hint": 'Install dependencies so `x402[httpx]` is available in the wallet runtime.'},
|
|
742
|
+
) from exc
|
|
743
|
+
return {
|
|
744
|
+
"x402Client": x402Client,
|
|
745
|
+
"x402HTTPClientBase": x402HTTPClientBase,
|
|
746
|
+
"decode_payment_required_header": decode_payment_required_header,
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _load_x402_solana_sdk() -> dict[str, Any]:
|
|
751
|
+
sdk = _load_x402_common_sdk()
|
|
752
|
+
try:
|
|
753
|
+
from x402.mechanisms.svm.exact import register_exact_svm_client
|
|
754
|
+
except ImportError as exc:
|
|
755
|
+
raise ProviderError(
|
|
756
|
+
"x402-sdk",
|
|
757
|
+
"x402 Solana execution requires SVM support.",
|
|
758
|
+
details={"hint": 'Install dependencies so `x402[httpx,svm]` is available in the wallet runtime.'},
|
|
759
|
+
) from exc
|
|
760
|
+
sdk.update(
|
|
761
|
+
{
|
|
762
|
+
"register_exact_svm_client": register_exact_svm_client,
|
|
763
|
+
}
|
|
764
|
+
)
|
|
765
|
+
return sdk
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _load_x402_evm_sdk() -> dict[str, Any]:
|
|
769
|
+
sdk = _load_x402_common_sdk()
|
|
770
|
+
try:
|
|
771
|
+
from x402.mechanisms.evm.exact import register_exact_evm_client
|
|
772
|
+
except ImportError as exc:
|
|
773
|
+
raise ProviderError(
|
|
774
|
+
"x402-sdk",
|
|
775
|
+
"x402 EVM execution requires EVM support.",
|
|
776
|
+
details={"hint": 'Install dependencies so `x402[httpx,evm]` is available in the wallet runtime.'},
|
|
777
|
+
) from exc
|
|
778
|
+
sdk.update(
|
|
779
|
+
{
|
|
780
|
+
"register_exact_evm_client": register_exact_evm_client,
|
|
781
|
+
}
|
|
782
|
+
)
|
|
783
|
+
return sdk
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _load_x402_sdk() -> dict[str, Any]:
|
|
787
|
+
return _load_x402_common_sdk()
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _build_solana_sdk_signer(backend: AgentWalletBackend) -> Any:
|
|
791
|
+
signer = getattr(backend, "signer", None)
|
|
792
|
+
if signer is None or not hasattr(signer, "export_keypair_bytes"):
|
|
793
|
+
raise ProviderError(
|
|
794
|
+
"x402-solana",
|
|
795
|
+
"The active Solana backend does not expose a local signer for x402 payments.",
|
|
796
|
+
)
|
|
797
|
+
try:
|
|
798
|
+
from solders.keypair import Keypair
|
|
799
|
+
except ImportError as exc:
|
|
800
|
+
raise ProviderError(
|
|
801
|
+
"x402-solana",
|
|
802
|
+
"Solders is required for Solana x402 signing.",
|
|
803
|
+
) from exc
|
|
804
|
+
|
|
805
|
+
class _OpenClawSolanaX402Signer:
|
|
806
|
+
def __init__(self, wallet_signer: Any):
|
|
807
|
+
self._wallet_signer = wallet_signer
|
|
808
|
+
self._keypair = Keypair.from_bytes(wallet_signer.export_keypair_bytes())
|
|
809
|
+
|
|
810
|
+
@property
|
|
811
|
+
def address(self) -> str:
|
|
812
|
+
return str(self._wallet_signer.address)
|
|
813
|
+
|
|
814
|
+
@property
|
|
815
|
+
def keypair(self) -> Any:
|
|
816
|
+
return self._keypair
|
|
817
|
+
|
|
818
|
+
def sign_transaction(self, tx: Any) -> Any:
|
|
819
|
+
tx.sign([self._keypair])
|
|
820
|
+
return tx
|
|
821
|
+
|
|
822
|
+
return _OpenClawSolanaX402Signer(signer)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _build_evm_sdk_signer(backend: AgentWalletBackend, address: str) -> Any:
|
|
826
|
+
sign_typed_data = getattr(backend, "sign_x402_evm_exact_typed_data", None)
|
|
827
|
+
if not callable(sign_typed_data):
|
|
828
|
+
raise ProviderError(
|
|
829
|
+
"x402-evm",
|
|
830
|
+
"The active EVM backend does not expose an x402 exact typed-data signer.",
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
class _OpenClawEvmX402Signer:
|
|
834
|
+
def __init__(self, wallet_backend: AgentWalletBackend, wallet_address: str):
|
|
835
|
+
self._wallet_backend = wallet_backend
|
|
836
|
+
self._address = wallet_address
|
|
837
|
+
|
|
838
|
+
@property
|
|
839
|
+
def address(self) -> str:
|
|
840
|
+
return self._address
|
|
841
|
+
|
|
842
|
+
def sign_typed_data(
|
|
843
|
+
self,
|
|
844
|
+
domain: Any,
|
|
845
|
+
types: dict[str, list[Any]],
|
|
846
|
+
primary_type: str,
|
|
847
|
+
message: dict[str, Any],
|
|
848
|
+
) -> bytes:
|
|
849
|
+
normalized_types: dict[str, list[dict[str, str]]] = {}
|
|
850
|
+
for type_name, fields in types.items():
|
|
851
|
+
normalized_types[type_name] = [
|
|
852
|
+
{
|
|
853
|
+
"name": _trim(getattr(field, "name", "")),
|
|
854
|
+
"type": _trim(getattr(field, "type", "")),
|
|
855
|
+
}
|
|
856
|
+
for field in fields
|
|
857
|
+
]
|
|
858
|
+
domain_payload = {
|
|
859
|
+
"name": getattr(domain, "name", None),
|
|
860
|
+
"version": getattr(domain, "version", None),
|
|
861
|
+
"chainId": getattr(domain, "chain_id", None),
|
|
862
|
+
"verifyingContract": getattr(domain, "verifying_contract", None),
|
|
863
|
+
}
|
|
864
|
+
return self._wallet_backend.sign_x402_evm_exact_typed_data(
|
|
865
|
+
domain=domain_payload,
|
|
866
|
+
types=normalized_types,
|
|
867
|
+
primary_type=primary_type,
|
|
868
|
+
message=message,
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
return _OpenClawEvmX402Signer(backend, address)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
async def _create_payment_headers(
|
|
875
|
+
*,
|
|
876
|
+
backend: AgentWalletBackend,
|
|
877
|
+
payment_required_header: str,
|
|
878
|
+
selected_payment: dict[str, Any],
|
|
879
|
+
) -> dict[str, str]:
|
|
880
|
+
chain = _backend_chain(backend)
|
|
881
|
+
if chain == "solana":
|
|
882
|
+
sdk = _load_x402_solana_sdk()
|
|
883
|
+
payment_required = sdk["decode_payment_required_header"](payment_required_header)
|
|
884
|
+
selected_payload = _build_selected_payment_required_payload(
|
|
885
|
+
payment_required,
|
|
886
|
+
selected_payment=selected_payment,
|
|
887
|
+
)
|
|
888
|
+
client = sdk["x402Client"]()
|
|
889
|
+
sdk_rpc_url = _backend_solana_sdk_rpc_url(backend)
|
|
890
|
+
if not sdk_rpc_url:
|
|
891
|
+
raise ProviderError(
|
|
892
|
+
"x402-solana",
|
|
893
|
+
"No direct Solana RPC URL is available for the x402 SDK signer path.",
|
|
894
|
+
details={"network": _backend_network(backend)},
|
|
895
|
+
)
|
|
896
|
+
sdk["register_exact_svm_client"](
|
|
897
|
+
client,
|
|
898
|
+
_build_solana_sdk_signer(backend),
|
|
899
|
+
networks=str(selected_payment["network"]),
|
|
900
|
+
rpc_url=sdk_rpc_url,
|
|
901
|
+
)
|
|
902
|
+
try:
|
|
903
|
+
payment_payload = await client.create_payment_payload(selected_payload)
|
|
904
|
+
except Exception as exc:
|
|
905
|
+
raise ProviderError(
|
|
906
|
+
"x402-solana",
|
|
907
|
+
"Failed to build the Solana x402 payment payload.",
|
|
908
|
+
details={
|
|
909
|
+
"network": _backend_network(backend),
|
|
910
|
+
"sdk_rpc_url": sdk_rpc_url,
|
|
911
|
+
"error_type": type(exc).__name__,
|
|
912
|
+
"error": str(exc) or None,
|
|
913
|
+
},
|
|
914
|
+
) from exc
|
|
915
|
+
return sdk["x402HTTPClientBase"]().encode_payment_signature_header(payment_payload)
|
|
916
|
+
|
|
917
|
+
if chain == "evm":
|
|
918
|
+
sdk = _load_x402_evm_sdk()
|
|
919
|
+
payment_required = sdk["decode_payment_required_header"](payment_required_header)
|
|
920
|
+
selected_payload = _build_selected_payment_required_payload(
|
|
921
|
+
payment_required,
|
|
922
|
+
selected_payment=selected_payment,
|
|
923
|
+
)
|
|
924
|
+
client = sdk["x402Client"]()
|
|
925
|
+
address = await backend.get_address()
|
|
926
|
+
if not isinstance(address, str) or not address.strip():
|
|
927
|
+
raise ProviderError("x402-evm", "The active EVM backend did not resolve a payer address.")
|
|
928
|
+
sdk["register_exact_evm_client"](
|
|
929
|
+
client,
|
|
930
|
+
_build_evm_sdk_signer(backend, address.strip()),
|
|
931
|
+
networks=str(selected_payment["network"]),
|
|
932
|
+
)
|
|
933
|
+
payment_payload = await client.create_payment_payload(selected_payload)
|
|
934
|
+
return sdk["x402HTTPClientBase"]().encode_payment_signature_header(payment_payload)
|
|
935
|
+
|
|
936
|
+
raise ProviderError(
|
|
937
|
+
"x402-http",
|
|
938
|
+
"Only Solana and EVM buyer flows are executable in this milestone.",
|
|
939
|
+
details={"chain": chain},
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _extract_settlement_header(response: Any) -> dict[str, Any] | None:
|
|
944
|
+
sdk = _load_x402_sdk()
|
|
945
|
+
try:
|
|
946
|
+
settle = sdk["x402HTTPClientBase"]().get_payment_settle_response(
|
|
947
|
+
lambda name: response.headers.get(name)
|
|
948
|
+
)
|
|
949
|
+
except Exception:
|
|
950
|
+
return None
|
|
951
|
+
return settle.model_dump(by_alias=True, exclude_none=True)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
async def search_services(
|
|
955
|
+
*,
|
|
956
|
+
query: str | None = None,
|
|
957
|
+
discovery_provider: str = "auto",
|
|
958
|
+
network: str | None = None,
|
|
959
|
+
asset: str | None = None,
|
|
960
|
+
scheme: str | None = None,
|
|
961
|
+
max_usd_price: str | None = None,
|
|
962
|
+
limit: int = 10,
|
|
963
|
+
) -> dict[str, Any]:
|
|
964
|
+
provider = _normalize_discovery_provider(discovery_provider)
|
|
965
|
+
if provider == "auto":
|
|
966
|
+
provider = "cdp_bazaar"
|
|
967
|
+
if limit <= 0:
|
|
968
|
+
raise ProviderError("x402-discovery", "limit must be greater than zero.")
|
|
969
|
+
client = get_client()
|
|
970
|
+
|
|
971
|
+
if provider == "cdp_bazaar":
|
|
972
|
+
if query and _trim(query):
|
|
973
|
+
response = await client.get(
|
|
974
|
+
f"{CDP_BAZAAR_DISCOVERY_BASE_URL}/search",
|
|
975
|
+
params={
|
|
976
|
+
"query": _trim(query),
|
|
977
|
+
"network": _trim(network) or None,
|
|
978
|
+
"asset": _trim(asset) or None,
|
|
979
|
+
"scheme": _trim(scheme) or None,
|
|
980
|
+
"maxUsdPrice": _trim(max_usd_price) or None,
|
|
981
|
+
"limit": min(limit, 20),
|
|
982
|
+
},
|
|
983
|
+
)
|
|
984
|
+
payload = _parse_json_response(
|
|
985
|
+
response, provider="x402-cdp-bazaar", operation="CDP Bazaar search"
|
|
986
|
+
)
|
|
987
|
+
resources = payload.get("resources") if isinstance(payload, dict) else None
|
|
988
|
+
if not isinstance(resources, list):
|
|
989
|
+
raise ProviderError("x402-cdp-bazaar", "Unexpected CDP Bazaar search response.")
|
|
990
|
+
items = [_normalize_cdp_resource(item) for item in resources if isinstance(item, dict)]
|
|
991
|
+
return {
|
|
992
|
+
"discovery_provider": provider,
|
|
993
|
+
"query": _trim(query),
|
|
994
|
+
"count": len(items),
|
|
995
|
+
"partial_results": bool(payload.get("partialResults")),
|
|
996
|
+
"search_method": payload.get("searchMethod"),
|
|
997
|
+
"items": items,
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
response = await client.get(
|
|
1001
|
+
f"{CDP_BAZAAR_DISCOVERY_BASE_URL}/resources",
|
|
1002
|
+
params={"type": "http", "limit": min(limit, 1000), "offset": 0},
|
|
1003
|
+
)
|
|
1004
|
+
payload = _parse_json_response(
|
|
1005
|
+
response, provider="x402-cdp-bazaar", operation="CDP Bazaar resources"
|
|
1006
|
+
)
|
|
1007
|
+
resources = payload.get("items") if isinstance(payload, dict) else None
|
|
1008
|
+
if not isinstance(resources, list):
|
|
1009
|
+
raise ProviderError("x402-cdp-bazaar", "Unexpected CDP Bazaar resources response.")
|
|
1010
|
+
items = [_normalize_cdp_resource(item) for item in resources if isinstance(item, dict)]
|
|
1011
|
+
return {
|
|
1012
|
+
"discovery_provider": provider,
|
|
1013
|
+
"query": "",
|
|
1014
|
+
"count": len(items),
|
|
1015
|
+
"pagination": payload.get("pagination") if isinstance(payload, dict) else None,
|
|
1016
|
+
"items": items,
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
endpoint = "/services/search" if query and _trim(query) else "/services/"
|
|
1020
|
+
params = {"q": _trim(query)} if query and _trim(query) else None
|
|
1021
|
+
response = await client.get(f"{AGENTIC_MARKET_API_BASE_URL}{endpoint}", params=params)
|
|
1022
|
+
payload = _parse_json_response(
|
|
1023
|
+
response, provider="x402-agentic-market", operation="Agentic Market search"
|
|
1024
|
+
)
|
|
1025
|
+
services = payload.get("services") if isinstance(payload, dict) else None
|
|
1026
|
+
if not isinstance(services, list):
|
|
1027
|
+
raise ProviderError("x402-agentic-market", "Unexpected Agentic Market response.")
|
|
1028
|
+
normalized = [
|
|
1029
|
+
_normalize_agentic_service(service)
|
|
1030
|
+
for service in services
|
|
1031
|
+
if isinstance(service, dict)
|
|
1032
|
+
]
|
|
1033
|
+
if network:
|
|
1034
|
+
needle = _trim(network).lower()
|
|
1035
|
+
normalized = [
|
|
1036
|
+
item
|
|
1037
|
+
for item in normalized
|
|
1038
|
+
if needle in {entry.lower() for entry in item.get("networks") or []}
|
|
1039
|
+
]
|
|
1040
|
+
if scheme:
|
|
1041
|
+
needle = _trim(scheme).lower()
|
|
1042
|
+
normalized = [
|
|
1043
|
+
item
|
|
1044
|
+
for item in normalized
|
|
1045
|
+
if any(_trim(req.get("scheme")).lower() == needle for req in item.get("accepts") or [])
|
|
1046
|
+
]
|
|
1047
|
+
if asset:
|
|
1048
|
+
needle = _trim(asset).lower()
|
|
1049
|
+
normalized = [
|
|
1050
|
+
item
|
|
1051
|
+
for item in normalized
|
|
1052
|
+
if any(_trim(req.get("asset")).lower() == needle for req in item.get("accepts") or [])
|
|
1053
|
+
]
|
|
1054
|
+
return {
|
|
1055
|
+
"discovery_provider": provider,
|
|
1056
|
+
"query": _trim(query),
|
|
1057
|
+
"count": len(normalized[:limit]),
|
|
1058
|
+
"items": normalized[:limit],
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
async def get_service_details(
|
|
1063
|
+
*,
|
|
1064
|
+
reference: str,
|
|
1065
|
+
discovery_provider: str = "auto",
|
|
1066
|
+
) -> dict[str, Any]:
|
|
1067
|
+
ref = _trim(reference)
|
|
1068
|
+
if not ref:
|
|
1069
|
+
raise ProviderError("x402-discovery", "reference is required.")
|
|
1070
|
+
provider = _normalize_discovery_provider(discovery_provider)
|
|
1071
|
+
if provider == "auto":
|
|
1072
|
+
provider = (
|
|
1073
|
+
"cdp_bazaar" if ref.startswith("http://") or ref.startswith("https://") else "agentic_market"
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
if provider == "cdp_bazaar":
|
|
1077
|
+
resources = await search_services(discovery_provider="cdp_bazaar", limit=200)
|
|
1078
|
+
exact = next((item for item in resources["items"] if item.get("resource") == ref), None)
|
|
1079
|
+
if exact is None:
|
|
1080
|
+
needle = ref.lower()
|
|
1081
|
+
exact = next(
|
|
1082
|
+
(
|
|
1083
|
+
item
|
|
1084
|
+
for item in resources["items"]
|
|
1085
|
+
if needle in _trim(item.get("resource")).lower()
|
|
1086
|
+
or needle in _trim(item.get("description")).lower()
|
|
1087
|
+
),
|
|
1088
|
+
None,
|
|
1089
|
+
)
|
|
1090
|
+
if exact is None:
|
|
1091
|
+
raise ProviderError("x402-cdp-bazaar", f"No Bazaar resource matched: {ref}")
|
|
1092
|
+
return {"discovery_provider": provider, "service": exact}
|
|
1093
|
+
|
|
1094
|
+
query = ref
|
|
1095
|
+
if ref.startswith("http://") or ref.startswith("https://"):
|
|
1096
|
+
query = urlsplit(ref).netloc or ref
|
|
1097
|
+
services = await search_services(query=query, discovery_provider="agentic_market", limit=20)
|
|
1098
|
+
needle = ref.lower()
|
|
1099
|
+
exact = next(
|
|
1100
|
+
(
|
|
1101
|
+
item
|
|
1102
|
+
for item in services["items"]
|
|
1103
|
+
if needle
|
|
1104
|
+
in {
|
|
1105
|
+
_trim(item.get("service_id")).lower(),
|
|
1106
|
+
_trim(item.get("domain")).lower(),
|
|
1107
|
+
_trim(item.get("service_name")).lower(),
|
|
1108
|
+
}
|
|
1109
|
+
),
|
|
1110
|
+
None,
|
|
1111
|
+
)
|
|
1112
|
+
if exact is None and services["items"]:
|
|
1113
|
+
exact = services["items"][0]
|
|
1114
|
+
if exact is None:
|
|
1115
|
+
raise ProviderError("x402-agentic-market", f"No Agentic Market service matched: {ref}")
|
|
1116
|
+
return {"discovery_provider": provider, "service": exact}
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
async def preview_request(
|
|
1120
|
+
*,
|
|
1121
|
+
backend: AgentWalletBackend,
|
|
1122
|
+
url: str,
|
|
1123
|
+
method: str = "GET",
|
|
1124
|
+
headers: dict[str, Any] | None = None,
|
|
1125
|
+
query: dict[str, Any] | None = None,
|
|
1126
|
+
json_body: Any | None = None,
|
|
1127
|
+
text_body: str | None = None,
|
|
1128
|
+
) -> dict[str, Any]:
|
|
1129
|
+
request = _build_request_metadata(
|
|
1130
|
+
url=url,
|
|
1131
|
+
method=method,
|
|
1132
|
+
headers=headers,
|
|
1133
|
+
query=query,
|
|
1134
|
+
json_body=json_body,
|
|
1135
|
+
text_body=text_body,
|
|
1136
|
+
)
|
|
1137
|
+
client = get_client()
|
|
1138
|
+
response = await _send_request(client=client, request=request)
|
|
1139
|
+
wallet_summary = _wallet_x402_support_summary(backend)
|
|
1140
|
+
address = await backend.get_address()
|
|
1141
|
+
preview: dict[str, Any] = {
|
|
1142
|
+
"mode": "preview",
|
|
1143
|
+
**_build_x402_action_payload(
|
|
1144
|
+
backend=backend,
|
|
1145
|
+
request=request,
|
|
1146
|
+
wallet_summary=wallet_summary,
|
|
1147
|
+
address=address,
|
|
1148
|
+
status_code=response.status_code,
|
|
1149
|
+
selected_payment=None,
|
|
1150
|
+
),
|
|
1151
|
+
"execute_available": False,
|
|
1152
|
+
"request": {
|
|
1153
|
+
"url": request["url"],
|
|
1154
|
+
"method": request["method"],
|
|
1155
|
+
"request_fingerprint": request["request_fingerprint"],
|
|
1156
|
+
"query": request["query"],
|
|
1157
|
+
"body_hash": request["body_hash"],
|
|
1158
|
+
"content_type": request["content_type"],
|
|
1159
|
+
},
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if response.status_code != 402:
|
|
1163
|
+
preview.update(
|
|
1164
|
+
{
|
|
1165
|
+
"payment_required": False,
|
|
1166
|
+
"response_preview": _response_preview(response),
|
|
1167
|
+
"response_headers": {
|
|
1168
|
+
"content-type": response.headers.get("content-type"),
|
|
1169
|
+
},
|
|
1170
|
+
}
|
|
1171
|
+
)
|
|
1172
|
+
return preview
|
|
1173
|
+
|
|
1174
|
+
payment_preview = _parse_payment_required_response(
|
|
1175
|
+
response,
|
|
1176
|
+
backend=backend,
|
|
1177
|
+
request=request,
|
|
1178
|
+
address=address,
|
|
1179
|
+
wallet_summary=wallet_summary,
|
|
1180
|
+
)
|
|
1181
|
+
payment_preview["mode"] = "preview"
|
|
1182
|
+
payment_preview["request"] = {
|
|
1183
|
+
"url": request["url"],
|
|
1184
|
+
"method": request["method"],
|
|
1185
|
+
"request_fingerprint": request["request_fingerprint"],
|
|
1186
|
+
"query": request["query"],
|
|
1187
|
+
"body_hash": request["body_hash"],
|
|
1188
|
+
"content_type": request["content_type"],
|
|
1189
|
+
}
|
|
1190
|
+
return payment_preview
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
async def prepare_request(
|
|
1194
|
+
*,
|
|
1195
|
+
backend: AgentWalletBackend,
|
|
1196
|
+
url: str,
|
|
1197
|
+
method: str = "GET",
|
|
1198
|
+
headers: dict[str, Any] | None = None,
|
|
1199
|
+
query: dict[str, Any] | None = None,
|
|
1200
|
+
json_body: Any | None = None,
|
|
1201
|
+
text_body: str | None = None,
|
|
1202
|
+
) -> dict[str, Any]:
|
|
1203
|
+
preview = await preview_request(
|
|
1204
|
+
backend=backend,
|
|
1205
|
+
url=url,
|
|
1206
|
+
method=method,
|
|
1207
|
+
headers=headers,
|
|
1208
|
+
query=query,
|
|
1209
|
+
json_body=json_body,
|
|
1210
|
+
text_body=text_body,
|
|
1211
|
+
)
|
|
1212
|
+
if not preview.get("payment_required"):
|
|
1213
|
+
prepared = dict(preview)
|
|
1214
|
+
prepared["mode"] = "prepare"
|
|
1215
|
+
prepared["prepared"] = False
|
|
1216
|
+
prepared["prepare_note"] = "The endpoint did not require x402 payment for this request."
|
|
1217
|
+
return prepared
|
|
1218
|
+
|
|
1219
|
+
selected_payment = _require_executable_payment(preview=preview, backend=backend)
|
|
1220
|
+
payment_required_header = (
|
|
1221
|
+
dict(preview.get("response_headers") or {}).get("payment-required")
|
|
1222
|
+
)
|
|
1223
|
+
if not isinstance(payment_required_header, str) or not payment_required_header.strip():
|
|
1224
|
+
raise ProviderError("x402-http", "Missing PAYMENT-REQUIRED header in preview state.")
|
|
1225
|
+
# Create the payload once during prepare to validate that the active wallet can sign it.
|
|
1226
|
+
await _create_payment_headers(
|
|
1227
|
+
backend=backend,
|
|
1228
|
+
payment_required_header=payment_required_header,
|
|
1229
|
+
selected_payment=selected_payment,
|
|
1230
|
+
)
|
|
1231
|
+
prepared = dict(preview)
|
|
1232
|
+
prepared["mode"] = "prepare"
|
|
1233
|
+
prepared["prepared"] = True
|
|
1234
|
+
prepared["signed"] = False
|
|
1235
|
+
prepared["broadcasted"] = False
|
|
1236
|
+
prepared["confirmed"] = False
|
|
1237
|
+
prepared["payment_payload_withheld"] = True
|
|
1238
|
+
prepared["prepare_note"] = (
|
|
1239
|
+
"x402 payment authorization was validated locally, but the PAYMENT-SIGNATURE header is withheld until execute."
|
|
1240
|
+
)
|
|
1241
|
+
return prepared
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
async def execute_request(
|
|
1245
|
+
*,
|
|
1246
|
+
backend: AgentWalletBackend,
|
|
1247
|
+
url: str,
|
|
1248
|
+
method: str = "GET",
|
|
1249
|
+
headers: dict[str, Any] | None = None,
|
|
1250
|
+
query: dict[str, Any] | None = None,
|
|
1251
|
+
json_body: Any | None = None,
|
|
1252
|
+
text_body: str | None = None,
|
|
1253
|
+
) -> dict[str, Any]:
|
|
1254
|
+
preview = await preview_request(
|
|
1255
|
+
backend=backend,
|
|
1256
|
+
url=url,
|
|
1257
|
+
method=method,
|
|
1258
|
+
headers=headers,
|
|
1259
|
+
query=query,
|
|
1260
|
+
json_body=json_body,
|
|
1261
|
+
text_body=text_body,
|
|
1262
|
+
)
|
|
1263
|
+
if not preview.get("payment_required"):
|
|
1264
|
+
executed = dict(preview)
|
|
1265
|
+
executed["mode"] = "execute"
|
|
1266
|
+
executed["paid"] = False
|
|
1267
|
+
executed["broadcasted"] = False
|
|
1268
|
+
executed["confirmed"] = False
|
|
1269
|
+
return executed
|
|
1270
|
+
|
|
1271
|
+
selected_payment = _require_executable_payment(preview=preview, backend=backend)
|
|
1272
|
+
payment_required_header = (
|
|
1273
|
+
dict(preview.get("response_headers") or {}).get("payment-required")
|
|
1274
|
+
)
|
|
1275
|
+
if not isinstance(payment_required_header, str) or not payment_required_header.strip():
|
|
1276
|
+
raise ProviderError("x402-http", "Missing PAYMENT-REQUIRED header in preview state.")
|
|
1277
|
+
|
|
1278
|
+
request = _build_request_metadata(
|
|
1279
|
+
url=url,
|
|
1280
|
+
method=method,
|
|
1281
|
+
headers=headers,
|
|
1282
|
+
query=query,
|
|
1283
|
+
json_body=json_body,
|
|
1284
|
+
text_body=text_body,
|
|
1285
|
+
)
|
|
1286
|
+
payment_headers = await _create_payment_headers(
|
|
1287
|
+
backend=backend,
|
|
1288
|
+
payment_required_header=payment_required_header,
|
|
1289
|
+
selected_payment=selected_payment,
|
|
1290
|
+
)
|
|
1291
|
+
payment_headers["Access-Control-Expose-Headers"] = "PAYMENT-RESPONSE,X-PAYMENT-RESPONSE"
|
|
1292
|
+
client = get_client()
|
|
1293
|
+
response = await _send_request(client=client, request=request, extra_headers=payment_headers)
|
|
1294
|
+
settlement = _extract_settlement_header(response)
|
|
1295
|
+
|
|
1296
|
+
executed = dict(preview)
|
|
1297
|
+
executed.update(
|
|
1298
|
+
{
|
|
1299
|
+
"mode": "execute",
|
|
1300
|
+
"paid": True,
|
|
1301
|
+
"broadcasted": bool(settlement and settlement.get("transaction")),
|
|
1302
|
+
"confirmed": bool(settlement and settlement.get("success")),
|
|
1303
|
+
"payment_settlement": settlement,
|
|
1304
|
+
"status_code": response.status_code,
|
|
1305
|
+
"response_preview": _response_preview(response),
|
|
1306
|
+
"response_headers": {
|
|
1307
|
+
"content-type": response.headers.get("content-type"),
|
|
1308
|
+
"payment-response": response.headers.get("PAYMENT-RESPONSE")
|
|
1309
|
+
or response.headers.get("X-PAYMENT-RESPONSE"),
|
|
1310
|
+
},
|
|
1311
|
+
}
|
|
1312
|
+
)
|
|
1313
|
+
if response.status_code == 402:
|
|
1314
|
+
raise ProviderError(
|
|
1315
|
+
"x402-http",
|
|
1316
|
+
"The paid x402 retry still returned HTTP 402.",
|
|
1317
|
+
details={
|
|
1318
|
+
"request_url": request["url"],
|
|
1319
|
+
"selected_payment": selected_payment,
|
|
1320
|
+
"response_preview": executed["response_preview"],
|
|
1321
|
+
},
|
|
1322
|
+
)
|
|
1323
|
+
return executed
|