@agentlayer.tech/wallet 0.1.100 → 0.1.102

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 (85) hide show
  1. package/.openclaw/extensions/agent-wallet/dist/index.js +46 -1
  2. package/.openclaw/extensions/agent-wallet/index.ts +46 -1
  3. package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
  4. package/.openclaw/extensions/agent-wallet/package.json +1 -1
  5. package/README.md +18 -0
  6. package/VERSION +1 -1
  7. package/agent-wallet/agent_wallet/__init__.py +1 -1
  8. package/agent-wallet/agent_wallet/connector_cli.py +198 -0
  9. package/agent-wallet/agent_wallet/connectors/__init__.py +28 -0
  10. package/agent-wallet/agent_wallet/connectors/catalog.py +68 -0
  11. package/agent-wallet/agent_wallet/connectors/client.py +308 -0
  12. package/agent-wallet/agent_wallet/connectors/intent_policy.py +243 -0
  13. package/agent-wallet/agent_wallet/connectors/manifest.py +192 -0
  14. package/agent-wallet/agent_wallet/connectors/registry.py +341 -0
  15. package/agent-wallet/agent_wallet/openclaw_adapter.py +56 -5
  16. package/agent-wallet/agent_wallet/providers/solana_rpc.py +1 -1
  17. package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +7 -4
  18. package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +7 -2
  19. package/agent-wallet/agent_wallet/wallet_layer/solana.py +1 -1
  20. package/agent-wallet/openclaw.plugin.json +1 -1
  21. package/agent-wallet/pyproject.toml +4 -2
  22. package/agent-wallet/scripts/build_release_bundle.py +1 -0
  23. package/agent-wallet/scripts/install_agent_wallet.py +1 -1
  24. package/bin/openclaw-agent-wallet.mjs +38 -0
  25. package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
  26. package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
  27. package/codex/plugins/agent-wallet/server.py +17 -5
  28. package/connectors/AGENTS.md +37 -0
  29. package/connectors/DEVELOPER_GUIDE.md +131 -0
  30. package/connectors/README.md +139 -0
  31. package/connectors/READ_ONLY_BETA.md +54 -0
  32. package/connectors/RELEASING.md +48 -0
  33. package/connectors/conformance/README.md +31 -0
  34. package/connectors/conformance/package.json +54 -0
  35. package/connectors/conformance/scripts/sync-license.mjs +3 -0
  36. package/connectors/conformance/scripts/sync-spec.mjs +16 -0
  37. package/connectors/conformance/src/cli.ts +43 -0
  38. package/connectors/conformance/src/index.ts +7 -0
  39. package/connectors/conformance/src/runner.ts +314 -0
  40. package/connectors/conformance/src/types.ts +27 -0
  41. package/connectors/conformance/test/fixtures/invalid-response-identity.json +14 -0
  42. package/connectors/conformance/test/fixtures/missing-tool-fixture.json +4 -0
  43. package/connectors/conformance/test/runner.test.mjs +151 -0
  44. package/connectors/conformance/tsconfig.json +17 -0
  45. package/connectors/examples/reference-crypto-data/Dockerfile +21 -0
  46. package/connectors/examples/reference-crypto-data/README.md +51 -0
  47. package/connectors/examples/reference-crypto-data/conformance.json +11 -0
  48. package/connectors/examples/reference-crypto-data/connector.json +95 -0
  49. package/connectors/examples/reference-crypto-data/package.json +21 -0
  50. package/connectors/examples/reference-crypto-data/railway.toml +9 -0
  51. package/connectors/examples/reference-crypto-data/src/connector.ts +137 -0
  52. package/connectors/examples/reference-crypto-data/src/server.ts +13 -0
  53. package/connectors/examples/reference-crypto-data/test/connector.test.mjs +97 -0
  54. package/connectors/examples/reference-crypto-data/tsconfig.json +17 -0
  55. package/connectors/package-lock.json +561 -0
  56. package/connectors/package.json +19 -0
  57. package/connectors/scripts/smoke-packed-packages.mjs +121 -0
  58. package/connectors/sdk-typescript/README.md +47 -0
  59. package/connectors/sdk-typescript/package.json +49 -0
  60. package/connectors/sdk-typescript/scripts/sync-license.mjs +3 -0
  61. package/connectors/sdk-typescript/src/connector.ts +204 -0
  62. package/connectors/sdk-typescript/src/errors.ts +11 -0
  63. package/connectors/sdk-typescript/src/http.ts +116 -0
  64. package/connectors/sdk-typescript/src/index.ts +26 -0
  65. package/connectors/sdk-typescript/src/types.ts +101 -0
  66. package/connectors/sdk-typescript/test/sdk.test.mjs +181 -0
  67. package/connectors/sdk-typescript/tsconfig.json +21 -0
  68. package/connectors/spec/connector-manifest.schema.json +340 -0
  69. package/connectors/spec/connector-protocol.md +159 -0
  70. package/connectors/spec/transaction-intent.schema.json +281 -0
  71. package/connectors/templates/read-only/.env.example +1 -0
  72. package/connectors/templates/read-only/Dockerfile +11 -0
  73. package/connectors/templates/read-only/README.md +33 -0
  74. package/connectors/templates/read-only/conformance.json +11 -0
  75. package/connectors/templates/read-only/connector.json +64 -0
  76. package/connectors/templates/read-only/package.json +21 -0
  77. package/connectors/templates/read-only/railway.toml +9 -0
  78. package/connectors/templates/read-only/src/connector.ts +61 -0
  79. package/connectors/templates/read-only/src/server.ts +14 -0
  80. package/connectors/templates/read-only/test/connector.test.mjs +62 -0
  81. package/connectors/templates/read-only/tsconfig.json +17 -0
  82. package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
  83. package/package.json +2 -1
  84. package/wdk-btc-wallet/package.json +1 -1
  85. package/wdk-evm-wallet/package.json +1 -1
@@ -0,0 +1,308 @@
1
+ """HTTPS client for untrusted read-only connector endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import socket
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Callable, Iterable
11
+ from urllib.parse import urlparse
12
+
13
+ import httpx
14
+ import httpcore
15
+ from jsonschema import Draft202012Validator, SchemaError, ValidationError
16
+
17
+ from agent_wallet.connectors.catalog import resolve_connector_tool
18
+ from agent_wallet.connectors.registry import ConnectorRegistry
19
+
20
+
21
+ MAX_CONNECTOR_RESPONSE_BYTES = 1024 * 1024
22
+ MAX_RESPONSE_LIFETIME_SECONDS = 300
23
+ RESERVED_WRITE_RESULT_KEYS = frozenset(
24
+ {
25
+ "approval_token",
26
+ "broadcast_request",
27
+ "evm_transaction_intent",
28
+ "payment_intent",
29
+ "raw_transaction",
30
+ "signed_transaction",
31
+ "signing_request",
32
+ "solana_transaction_intent",
33
+ "transaction_intent",
34
+ }
35
+ )
36
+ Resolver = Callable[[str], Iterable[str]]
37
+
38
+
39
+ class ConnectorInvocationError(RuntimeError):
40
+ """Raised when a connector request or response cannot be trusted."""
41
+
42
+
43
+ def _default_resolver(hostname: str) -> list[str]:
44
+ try:
45
+ records = socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM)
46
+ except OSError as exc:
47
+ raise ConnectorInvocationError(f"Connector hostname could not be resolved: {hostname}.") from exc
48
+ return sorted({str(record[4][0]) for record in records})
49
+
50
+
51
+ def _validate_endpoint_url(url: str) -> str:
52
+ parsed = urlparse(url)
53
+ hostname = str(parsed.hostname or "").lower()
54
+ if parsed.scheme != "https" or not hostname or parsed.username or parsed.password:
55
+ raise ConnectorInvocationError("Connector endpoint must be public HTTPS without credentials.")
56
+ if hostname == "localhost" or hostname.endswith(".localhost") or hostname.endswith(".local"):
57
+ raise ConnectorInvocationError("Connector endpoint cannot use a local hostname.")
58
+ return hostname
59
+
60
+
61
+ def _resolve_public_addresses(hostname: str, resolver: Resolver) -> list[str]:
62
+ addresses = list(resolver(hostname))
63
+ if not addresses:
64
+ raise ConnectorInvocationError("Connector endpoint did not resolve to an address.")
65
+ for raw_address in addresses:
66
+ try:
67
+ address = ipaddress.ip_address(raw_address)
68
+ except ValueError as exc:
69
+ raise ConnectorInvocationError("Connector endpoint resolved to an invalid address.") from exc
70
+ if not address.is_global:
71
+ raise ConnectorInvocationError(
72
+ f"Connector endpoint resolved to a non-public address: {raw_address}."
73
+ )
74
+ return addresses
75
+
76
+
77
+ def _assert_public_endpoint(url: str, resolver: Resolver) -> None:
78
+ """Validate mocked/injected clients that do not use the pinned transport."""
79
+
80
+ _resolve_public_addresses(_validate_endpoint_url(url), resolver)
81
+
82
+
83
+ class _PinnedPublicNetworkBackend(httpcore.AsyncNetworkBackend):
84
+ """Resolve and validate at connect time, then open TCP to the validated IP.
85
+
86
+ HTTP Core retains the original origin hostname and therefore still uses it
87
+ for TLS SNI and certificate verification. Only the TCP destination is
88
+ replaced, closing the DNS-validation-to-connect race.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ resolver: Resolver,
94
+ network_backend: httpcore.AsyncNetworkBackend | None = None,
95
+ ) -> None:
96
+ self._resolver = resolver
97
+ self._network_backend = network_backend or httpcore.AnyIOBackend()
98
+
99
+ async def connect_tcp(
100
+ self,
101
+ host: str,
102
+ port: int,
103
+ timeout: float | None = None,
104
+ local_address: str | None = None,
105
+ socket_options: Iterable[tuple[int, int, int | bytes]] | None = None,
106
+ ) -> httpcore.AsyncNetworkStream:
107
+ addresses = _resolve_public_addresses(host.lower(), self._resolver)
108
+ return await self._network_backend.connect_tcp(
109
+ addresses[0],
110
+ port,
111
+ timeout=timeout,
112
+ local_address=local_address,
113
+ socket_options=socket_options,
114
+ )
115
+
116
+ async def connect_unix_socket(
117
+ self,
118
+ path: str,
119
+ timeout: float | None = None,
120
+ socket_options: Iterable[tuple[int, int, int | bytes]] | None = None,
121
+ ) -> httpcore.AsyncNetworkStream:
122
+ raise ConnectorInvocationError("Connector Unix socket transport is not allowed.")
123
+
124
+ async def sleep(self, seconds: float) -> None:
125
+ await self._network_backend.sleep(seconds)
126
+
127
+
128
+ class _PinnedPublicAsyncTransport(httpx.AsyncHTTPTransport):
129
+ """HTTPX transport whose connection pool uses connect-time DNS pinning."""
130
+
131
+ def __init__(self, resolver: Resolver) -> None:
132
+ super().__init__(retries=0)
133
+ self._pool = httpcore.AsyncConnectionPool(
134
+ retries=0,
135
+ network_backend=_PinnedPublicNetworkBackend(resolver),
136
+ )
137
+
138
+
139
+ def _parse_expiry(value: Any) -> datetime:
140
+ if not isinstance(value, str) or not value:
141
+ raise ConnectorInvocationError("Connector response expires_at is required.")
142
+ try:
143
+ expiry = datetime.fromisoformat(value.replace("Z", "+00:00"))
144
+ except ValueError as exc:
145
+ raise ConnectorInvocationError("Connector response expires_at is invalid.") from exc
146
+ if expiry.tzinfo is None:
147
+ raise ConnectorInvocationError("Connector response expires_at must include a timezone.")
148
+ now = datetime.now(timezone.utc)
149
+ remaining = (expiry.astimezone(timezone.utc) - now).total_seconds()
150
+ if remaining <= 0:
151
+ raise ConnectorInvocationError("Connector response has expired.")
152
+ if remaining > MAX_RESPONSE_LIFETIME_SECONDS:
153
+ raise ConnectorInvocationError("Connector response expiry exceeds the allowed lifetime.")
154
+ return expiry
155
+
156
+
157
+ def _validate_json(instance: Any, schema: dict[str, Any], *, label: str) -> None:
158
+ try:
159
+ Draft202012Validator.check_schema(schema)
160
+ Draft202012Validator(schema).validate(instance)
161
+ except SchemaError as exc:
162
+ raise ConnectorInvocationError(f"Connector {label} schema is invalid: {exc.message}") from exc
163
+ except ValidationError as exc:
164
+ raise ConnectorInvocationError(f"Connector {label} does not match its schema: {exc.message}") from exc
165
+
166
+
167
+ def _reject_embedded_write_payload(result: Any) -> None:
168
+ pending = [result]
169
+ while pending:
170
+ value = pending.pop()
171
+ if isinstance(value, dict):
172
+ for key, item in value.items():
173
+ if str(key).lower() in RESERVED_WRITE_RESULT_KEYS:
174
+ raise ConnectorInvocationError(
175
+ f"Read-only connector result contains reserved write field: {key}."
176
+ )
177
+ pending.append(item)
178
+ elif isinstance(value, list):
179
+ pending.extend(value)
180
+
181
+
182
+ class ConnectorReadClient:
183
+ """Invoke enabled read-only connector tools with strict identity binding."""
184
+
185
+ def __init__(
186
+ self,
187
+ registry: ConnectorRegistry | None = None,
188
+ *,
189
+ http_client: httpx.AsyncClient | None = None,
190
+ resolver: Resolver | None = None,
191
+ ):
192
+ self.registry = registry or ConnectorRegistry()
193
+ self._http_client = http_client
194
+ self._resolver = resolver or _default_resolver
195
+
196
+ async def invoke(
197
+ self,
198
+ host_tool_name: str,
199
+ arguments: dict[str, Any],
200
+ *,
201
+ context: dict[str, Any] | None = None,
202
+ ) -> dict[str, Any]:
203
+ tool = resolve_connector_tool(host_tool_name, self.registry, include_write=False)
204
+ if tool["read_only"] is not True:
205
+ raise ConnectorInvocationError("ConnectorReadClient accepts only read-only tools.")
206
+ if not isinstance(arguments, dict):
207
+ raise ConnectorInvocationError("Connector tool arguments must be an object.")
208
+ _validate_json(arguments, tool["input_schema"], label="tool input")
209
+
210
+ connector_id = str(tool["connector_id"])
211
+ connector_version = str(tool["connector_version"])
212
+ manifest = self.registry.load_manifest(connector_id, connector_version)
213
+ transport = manifest["transport"]
214
+ base_url = str(transport["url"]).rstrip("/")
215
+ _validate_endpoint_url(base_url)
216
+ if self._http_client is not None:
217
+ _assert_public_endpoint(base_url, self._resolver)
218
+ request_id = str(uuid.uuid4())
219
+
220
+ safe_context: dict[str, Any] = {}
221
+ supplied_context = context if isinstance(context, dict) else {}
222
+ for field in ("chain", "network", "chain_id"):
223
+ value = supplied_context.get(field)
224
+ if value is not None:
225
+ safe_context[field] = value
226
+ if manifest["permissions"].get("wallet_address") is True:
227
+ wallet_address = supplied_context.get("wallet_address")
228
+ if isinstance(wallet_address, str) and wallet_address.strip():
229
+ safe_context["wallet_address"] = wallet_address.strip()
230
+
231
+ request_payload = {
232
+ "protocol_version": 1,
233
+ "request_id": request_id,
234
+ "connector": {
235
+ "id": connector_id,
236
+ "version": connector_version,
237
+ **(
238
+ {"artifact_digest": manifest["artifact_digest"]}
239
+ if manifest.get("artifact_digest")
240
+ else {}
241
+ ),
242
+ },
243
+ "tool": str(tool["connector_tool"]),
244
+ "arguments": arguments,
245
+ "context": safe_context,
246
+ }
247
+
248
+ owns_client = self._http_client is None
249
+ client = self._http_client or httpx.AsyncClient(
250
+ transport=_PinnedPublicAsyncTransport(self._resolver),
251
+ follow_redirects=False,
252
+ trust_env=False,
253
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
254
+ )
255
+ try:
256
+ try:
257
+ response = await client.post(
258
+ f"{base_url}/invoke",
259
+ json=request_payload,
260
+ timeout=float(transport.get("timeout_ms", 10000)) / 1000.0,
261
+ )
262
+ except httpx.HTTPError as exc:
263
+ raise ConnectorInvocationError(f"Connector request failed: {exc}") from exc
264
+ finally:
265
+ if owns_client:
266
+ await client.aclose()
267
+
268
+ if 300 <= response.status_code < 400:
269
+ raise ConnectorInvocationError("Connector redirects are not allowed.")
270
+ if response.status_code != 200:
271
+ raise ConnectorInvocationError(f"Connector returned HTTP {response.status_code}.")
272
+ if len(response.content) > MAX_CONNECTOR_RESPONSE_BYTES:
273
+ raise ConnectorInvocationError("Connector response exceeds the size limit.")
274
+ try:
275
+ payload = response.json()
276
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
277
+ raise ConnectorInvocationError("Connector response is not valid JSON.") from exc
278
+ if not isinstance(payload, dict):
279
+ raise ConnectorInvocationError("Connector response root must be an object.")
280
+ if payload.get("protocol_version") != 1 or payload.get("request_id") != request_id:
281
+ raise ConnectorInvocationError("Connector response binding is invalid.")
282
+ response_connector = payload.get("connector")
283
+ if not isinstance(response_connector, dict):
284
+ raise ConnectorInvocationError("Connector response identity is missing.")
285
+ if (
286
+ response_connector.get("id") != connector_id
287
+ or response_connector.get("version") != connector_version
288
+ ):
289
+ raise ConnectorInvocationError("Connector response identity does not match the request.")
290
+ expected_artifact = manifest.get("artifact_digest")
291
+ if expected_artifact and response_connector.get("artifact_digest") != expected_artifact:
292
+ raise ConnectorInvocationError("Connector response artifact digest does not match.")
293
+ if payload.get("tool") != tool["connector_tool"]:
294
+ raise ConnectorInvocationError("Connector response tool does not match the request.")
295
+ if payload.get("kind") != "read_result":
296
+ raise ConnectorInvocationError("Read-only connector returned a non-read result.")
297
+ _parse_expiry(payload.get("expires_at"))
298
+ result = payload.get("result")
299
+ _reject_embedded_write_payload(result)
300
+ _validate_json(result, tool["output_schema"], label="tool output")
301
+ return {
302
+ "connector_id": connector_id,
303
+ "connector_version": connector_version,
304
+ "tool": str(tool["connector_tool"]),
305
+ "untrusted_external_data": True,
306
+ "result": result,
307
+ "expires_at": payload["expires_at"],
308
+ }
@@ -0,0 +1,243 @@
1
+ """Fail-closed local policy for unsigned write-capable connector intents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from agent_wallet.connectors.manifest import DIGEST_PATTERN, validate_connector_manifest
10
+
11
+
12
+ EVM_ADDRESS_PATTERN = re.compile(r"^0x[0-9a-fA-F]{40}$")
13
+ HEX_DATA_PATTERN = re.compile(r"^0x(?:[0-9a-fA-F]{2})*$")
14
+ UINT_PATTERN = re.compile(r"^(0|[1-9][0-9]*)$")
15
+ ERC20_APPROVE_SELECTOR = "0x095ea7b3"
16
+ MAX_UINT256 = (1 << 256) - 1
17
+ MAX_INTENT_LIFETIME_SECONDS = 300
18
+
19
+
20
+ class ConnectorIntentPolicyError(ValueError):
21
+ """Raised when a connector write intent is unsafe or incorrectly bound."""
22
+
23
+
24
+ def _require_object(value: Any, field: str) -> dict[str, Any]:
25
+ if not isinstance(value, dict):
26
+ raise ConnectorIntentPolicyError(f"{field} must be an object.")
27
+ return value
28
+
29
+
30
+ def _require_uint(value: Any, field: str, *, positive: bool = False) -> int:
31
+ if not isinstance(value, str) or not UINT_PATTERN.fullmatch(value):
32
+ raise ConnectorIntentPolicyError(f"{field} must be an unsigned integer string.")
33
+ number = int(value)
34
+ if number >= 1 << 256:
35
+ raise ConnectorIntentPolicyError(f"{field} exceeds uint256.")
36
+ if positive and number <= 0:
37
+ raise ConnectorIntentPolicyError(f"{field} must be greater than zero.")
38
+ return number
39
+
40
+
41
+ def _require_address(value: Any, field: str) -> str:
42
+ if not isinstance(value, str) or not EVM_ADDRESS_PATTERN.fullmatch(value):
43
+ raise ConnectorIntentPolicyError(f"{field} must be an EVM address.")
44
+ return value.lower()
45
+
46
+
47
+ def _validate_expiry(value: Any) -> str:
48
+ if not isinstance(value, str) or not value:
49
+ raise ConnectorIntentPolicyError("expires_at is required.")
50
+ try:
51
+ expiry = datetime.fromisoformat(value.replace("Z", "+00:00"))
52
+ except ValueError as exc:
53
+ raise ConnectorIntentPolicyError("expires_at is invalid.") from exc
54
+ if expiry.tzinfo is None:
55
+ raise ConnectorIntentPolicyError("expires_at must include a timezone.")
56
+ remaining = (expiry.astimezone(timezone.utc) - datetime.now(timezone.utc)).total_seconds()
57
+ if remaining <= 0:
58
+ raise ConnectorIntentPolicyError("Connector intent has expired.")
59
+ if remaining > MAX_INTENT_LIFETIME_SECONDS:
60
+ raise ConnectorIntentPolicyError("Connector intent expiry exceeds the allowed lifetime.")
61
+ return value
62
+
63
+
64
+ def _evm_contract_policy(manifest: dict[str, Any], chain_id: int) -> dict[str, set[str]]:
65
+ result: dict[str, set[str]] = {}
66
+ for raw_policy in manifest.get("chains") or []:
67
+ if not isinstance(raw_policy, dict) or raw_policy.get("chain") != "evm":
68
+ continue
69
+ chain_ids = raw_policy.get("chain_ids")
70
+ if not isinstance(chain_ids, list) or chain_id not in chain_ids:
71
+ continue
72
+ for raw_contract in raw_policy.get("contracts") or []:
73
+ if not isinstance(raw_contract, dict) or raw_contract.get("chain_id") != chain_id:
74
+ continue
75
+ address = _require_address(raw_contract.get("address"), "manifest contract address")
76
+ selectors = raw_contract.get("selectors")
77
+ if not isinstance(selectors, list) or not selectors:
78
+ raise ConnectorIntentPolicyError("Manifest contract selectors are missing.")
79
+ result[address] = {str(selector).lower() for selector in selectors}
80
+ if not result:
81
+ raise ConnectorIntentPolicyError(f"Connector is not allowed on EVM chain {chain_id}.")
82
+ return result
83
+
84
+
85
+ def _write_tool(manifest: dict[str, Any], tool_name: Any) -> dict[str, Any]:
86
+ if not isinstance(tool_name, str):
87
+ raise ConnectorIntentPolicyError("tool is required.")
88
+ for tool in manifest["tools"]:
89
+ if isinstance(tool, dict) and tool.get("name") == tool_name:
90
+ if tool.get("read_only") is True:
91
+ raise ConnectorIntentPolicyError("A read-only connector tool cannot return an intent.")
92
+ return tool
93
+ raise ConnectorIntentPolicyError(f"Connector write tool is not declared: {tool_name}.")
94
+
95
+
96
+ def validate_evm_transaction_intent(
97
+ manifest_payload: dict[str, Any],
98
+ intent_payload: dict[str, Any],
99
+ *,
100
+ wallet_address: str,
101
+ ) -> dict[str, Any]:
102
+ """Validate an unsigned EVM intent and return an approval-safe summary.
103
+
104
+ This function does not simulate, sign, approve, or broadcast. Its output is
105
+ suitable as input to the later simulation and preview binding layers.
106
+ """
107
+
108
+ manifest = validate_connector_manifest(manifest_payload)
109
+ intent = _require_object(intent_payload, "intent")
110
+ if manifest["trust"] != "verified_write":
111
+ raise ConnectorIntentPolicyError("Only verified_write connectors may return intents.")
112
+ if manifest["permissions"].get("transaction_intents") is not True:
113
+ raise ConnectorIntentPolicyError("Connector is not permitted to return transaction intents.")
114
+ if intent.get("protocol_version") != 1 or intent.get("kind") != "evm_transaction_intent":
115
+ raise ConnectorIntentPolicyError("Unsupported EVM connector intent version or kind.")
116
+ if intent.get("connector_id") != manifest["id"]:
117
+ raise ConnectorIntentPolicyError("Connector intent id does not match the manifest.")
118
+ if intent.get("connector_version") != manifest["version"]:
119
+ raise ConnectorIntentPolicyError("Connector intent version does not match the manifest.")
120
+ if intent.get("artifact_digest") != manifest.get("artifact_digest"):
121
+ raise ConnectorIntentPolicyError("Connector intent artifact digest does not match.")
122
+ quote_fingerprint = intent.get("quote_fingerprint")
123
+ if not isinstance(quote_fingerprint, str) or not DIGEST_PATTERN.fullmatch(quote_fingerprint):
124
+ raise ConnectorIntentPolicyError("quote_fingerprint must be a lowercase sha256 digest.")
125
+ tool = _write_tool(manifest, intent.get("tool"))
126
+ expires_at = _validate_expiry(intent.get("expires_at"))
127
+
128
+ chain_id = intent.get("chain_id")
129
+ if not isinstance(chain_id, int) or isinstance(chain_id, bool) or chain_id <= 0:
130
+ raise ConnectorIntentPolicyError("chain_id must be a positive integer.")
131
+ contract_policy = _evm_contract_policy(manifest, chain_id)
132
+ expected_wallet = _require_address(wallet_address, "wallet_address")
133
+ intent_from = _require_address(intent.get("from"), "from")
134
+ if intent_from != expected_wallet:
135
+ raise ConnectorIntentPolicyError("Connector intent from address does not match the wallet.")
136
+
137
+ calls = intent.get("calls")
138
+ if not isinstance(calls, list) or not 1 <= len(calls) <= 16:
139
+ raise ConnectorIntentPolicyError("calls must contain between 1 and 16 entries.")
140
+ normalized_calls: list[dict[str, Any]] = []
141
+ for index, raw_call in enumerate(calls):
142
+ call = _require_object(raw_call, f"calls[{index}]")
143
+ target = _require_address(call.get("to"), f"calls[{index}].to")
144
+ selectors = contract_policy.get(target)
145
+ if selectors is None:
146
+ raise ConnectorIntentPolicyError(f"calls[{index}] targets an unapproved contract.")
147
+ data = call.get("data")
148
+ if not isinstance(data, str) or not HEX_DATA_PATTERN.fullmatch(data) or len(data) < 10:
149
+ raise ConnectorIntentPolicyError(f"calls[{index}].data must contain EVM calldata.")
150
+ selector = data[:10].lower()
151
+ if selector == ERC20_APPROVE_SELECTOR:
152
+ raise ConnectorIntentPolicyError(
153
+ "Connector intents must declare approvals separately from protocol calls."
154
+ )
155
+ if selector not in selectors:
156
+ raise ConnectorIntentPolicyError(f"calls[{index}] uses an unapproved selector.")
157
+ value_wei = _require_uint(call.get("value_wei"), f"calls[{index}].value_wei")
158
+ normalized_calls.append(
159
+ {
160
+ "to": target,
161
+ "selector": selector,
162
+ "value_wei": str(value_wei),
163
+ "calldata_bytes": (len(data) - 2) // 2,
164
+ }
165
+ )
166
+
167
+ approvals = intent.get("approvals")
168
+ if not isinstance(approvals, list) or len(approvals) > 16:
169
+ raise ConnectorIntentPolicyError("approvals must be an array of at most 16 entries.")
170
+ normalized_approvals: list[dict[str, str]] = []
171
+ for index, raw_approval in enumerate(approvals):
172
+ approval = _require_object(raw_approval, f"approvals[{index}]")
173
+ token = _require_address(approval.get("token"), f"approvals[{index}].token")
174
+ spender = _require_address(approval.get("spender"), f"approvals[{index}].spender")
175
+ if spender not in contract_policy:
176
+ raise ConnectorIntentPolicyError(f"approvals[{index}] uses an unapproved spender.")
177
+ amount = _require_uint(
178
+ approval.get("amount_raw"), f"approvals[{index}].amount_raw", positive=True
179
+ )
180
+ if amount == MAX_UINT256:
181
+ raise ConnectorIntentPolicyError("Unlimited connector token approvals are prohibited.")
182
+ normalized_approvals.append(
183
+ {
184
+ "token": token,
185
+ "spender": spender,
186
+ "amount_raw": str(amount),
187
+ }
188
+ )
189
+
190
+ effects = intent.get("expected_effects")
191
+ if not isinstance(effects, list) or not 1 <= len(effects) <= 64:
192
+ raise ConnectorIntentPolicyError("expected_effects must contain between 1 and 64 entries.")
193
+ normalized_effects: list[dict[str, Any]] = []
194
+ for index, raw_effect in enumerate(effects):
195
+ effect = _require_object(raw_effect, f"expected_effects[{index}]")
196
+ effect_type = effect.get("type")
197
+ if effect_type not in {"asset", "debt", "position", "protocol_fee", "network_fee"}:
198
+ raise ConnectorIntentPolicyError(f"expected_effects[{index}].type is unsupported.")
199
+ direction = effect.get("direction")
200
+ if direction not in {"debit", "credit", "increase", "decrease"}:
201
+ raise ConnectorIntentPolicyError(f"expected_effects[{index}].direction is unsupported.")
202
+ asset = effect.get("asset")
203
+ if not isinstance(asset, str) or not asset.strip() or len(asset) > 128:
204
+ raise ConnectorIntentPolicyError(f"expected_effects[{index}].asset is invalid.")
205
+ amount = _require_uint(effect.get("amount"), f"expected_effects[{index}].amount")
206
+ normalized_effects.append(
207
+ {
208
+ "type": effect_type,
209
+ "asset": asset.strip(),
210
+ "direction": direction,
211
+ "amount": str(amount),
212
+ **(
213
+ {"recipient": str(effect["recipient"])}
214
+ if isinstance(effect.get("recipient"), str) and effect["recipient"]
215
+ else {}
216
+ ),
217
+ }
218
+ )
219
+
220
+ return {
221
+ "operation": "AgentLayer connector EVM transaction",
222
+ "connector_id": str(manifest["id"]),
223
+ "connector_version": str(manifest["version"]),
224
+ "artifact_digest": str(manifest["artifact_digest"]),
225
+ "tool": str(tool["name"]),
226
+ "chain": "evm",
227
+ "chain_id": chain_id,
228
+ "from": expected_wallet,
229
+ "quote_fingerprint": quote_fingerprint,
230
+ "expires_at": expires_at,
231
+ "calls": normalized_calls,
232
+ "approvals": normalized_approvals,
233
+ "expected_effects": normalized_effects,
234
+ "validation": {
235
+ "verified_connector": True,
236
+ "wallet_bound": True,
237
+ "targets_allowlisted": True,
238
+ "selectors_allowlisted": True,
239
+ "approvals_bounded": True,
240
+ "simulated": False,
241
+ },
242
+ }
243
+