open-agentpay 0.2.1__tar.gz → 0.2.2__tar.gz

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 (50) hide show
  1. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/PKG-INFO +1 -1
  2. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/pyproject.toml +1 -1
  3. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/__init__.py +1 -1
  4. open_agentpay-0.2.2/src/agentpay/client/client.py +170 -0
  5. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/client/transport.py +89 -7
  6. {open_agentpay-0.2.1/src/agentpay/protocol → open_agentpay-0.2.2/src/agentpay/core}/codecs.py +10 -1
  7. {open_agentpay-0.2.1/src/agentpay/protocol → open_agentpay-0.2.2/src/agentpay/core}/models.py +74 -12
  8. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/policy/models.py +11 -2
  9. open_agentpay-0.2.1/src/agentpay/client/client.py +0 -109
  10. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/.gitignore +0 -0
  11. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/README.md +0 -0
  12. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/budget/__init__.py +0 -0
  13. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/client/__init__.py +0 -0
  14. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/client/errors.py +0 -0
  15. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/client/interfaces.py +0 -0
  16. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/client/signer.py +0 -0
  17. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/core/__init__.py +0 -0
  18. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/core/errors.py +0 -0
  19. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/core/interop.py +0 -0
  20. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/core/state.py +0 -0
  21. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/evm/__init__.py +0 -0
  22. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/evm/constants.py +0 -0
  23. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/evm/eip3009.py +0 -0
  24. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/evm/rpc.py +0 -0
  25. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/integrations/__init__.py +0 -0
  26. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/integrations/langchain/__init__.py +0 -0
  27. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/integrations/langchain/http.py +0 -0
  28. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/integrations/langchain/tool.py +0 -0
  29. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/policy/__init__.py +0 -0
  30. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/policy/config.py +0 -0
  31. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/policy/db.py +0 -0
  32. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/policy/errors.py +0 -0
  33. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/policy/evaluator.py +0 -0
  34. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/policy/interfaces.py +0 -0
  35. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/policy/metered.py +0 -0
  36. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/policy/sqlite_engine.py +0 -0
  37. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/protocol/__init__.py +0 -0
  38. {open_agentpay-0.2.1/src/agentpay/core → open_agentpay-0.2.2/src/agentpay/protocol}/codecs.py +0 -0
  39. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/protocol/errors.py +0 -0
  40. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/protocol/interop.py +0 -0
  41. {open_agentpay-0.2.1/src/agentpay/core → open_agentpay-0.2.2/src/agentpay/protocol}/models.py +0 -0
  42. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/protocol/state.py +0 -0
  43. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/signer/__init__.py +0 -0
  44. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/state/__init__.py +0 -0
  45. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/state/buyer_coordinator.py +0 -0
  46. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/state/errors.py +0 -0
  47. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/state/guards.py +0 -0
  48. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/state/reconciliation.py +0 -0
  49. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/testing/__init__.py +0 -0
  50. {open_agentpay-0.2.1 → open_agentpay-0.2.2}/src/agentpay/testing/mock_seller.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: open-agentpay
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: The Open-Source Payment Infrastructure & Financial Firewall for Autonomous AI Agents
5
5
  Project-URL: Homepage, https://github.com/open-agentpay/AgentPay
6
6
  Project-URL: Documentation, https://open-agentpay.github.io/AgentPay/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "open-agentpay"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "The Open-Source Payment Infrastructure & Financial Firewall for Autonomous AI Agents"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -3,7 +3,7 @@ AgentPay Python SDK
3
3
  The Open-Source Payment Infrastructure & Financial Firewall for Autonomous AI Agents.
4
4
  """
5
5
 
6
- __version__ = "0.2.1"
6
+ __version__ = "0.2.2"
7
7
 
8
8
  from agentpay.client import (
9
9
  AgentPayClient,
@@ -0,0 +1,170 @@
1
+ """
2
+ AgentPayClient: Drop-in httpx.Client with automatic 402 payment negotiation.
3
+
4
+ Wraps the financial firewall and EIP-712 cryptographic signer to automatically
5
+ satisfy HTTP 402 Payment Required challenges.
6
+
7
+ Note on ``transport=``: unlike a plain httpx.Client, the transport you pass here is the
8
+ INNER transport. It is always wrapped by AgentPayTransport so every request passes the firewall.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from agentpay.client.interfaces import ISigner
19
+ from agentpay.client.transport import (
20
+ DEFAULT_CREDENTIAL_HEADERS,
21
+ DEFAULT_CREDENTIAL_VALUE_PREFIXES,
22
+ AgentPayTransport,
23
+ AsyncAgentPayTransport,
24
+ CredentialMode,
25
+ )
26
+ from agentpay.policy.interfaces import IPolicyEngine
27
+
28
+ # Client kwargs that configure the default network transport. httpx only applies them when it builds
29
+ # the transport itself; since we always inject ours, they must be forwarded to the inner transport.
30
+ _INNER_PASSTHROUGH = ("verify", "cert", "http1", "http2", "limits", "trust_env")
31
+
32
+
33
+ def _inner_transport_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
34
+ """Collect inner-transport settings from client kwargs (mutates ``kwargs``: removes ``proxy``)."""
35
+ if "mounts" in kwargs or "proxies" in kwargs:
36
+ # httpx routes matching URLs to mounted transports BEFORE the default transport,
37
+ # which would skip the payment firewall entirely.
38
+ raise TypeError(
39
+ "AgentPayClient does not support 'mounts'/'proxies': they bypass the payment firewall. "
40
+ "Pass a pre-configured inner transport via transport=... or use proxy=..."
41
+ )
42
+ out = {k: kwargs[k] for k in _INNER_PASSTHROUGH if k in kwargs}
43
+ if "proxy" in kwargs:
44
+ # Left in kwargs, httpx would create a proxy mount that bypasses AgentPayTransport.
45
+ out["proxy"] = kwargs.pop("proxy")
46
+ return out
47
+
48
+
49
+ class AgentPayClient(httpx.Client):
50
+ """
51
+ HTTP client for autonomous AI agents with integrated financial firewall.
52
+
53
+ Inherits from httpx.Client: fully compatible with LangChain, LlamaIndex,
54
+ and standard Python HTTP toolchains. Set ``wallet_challenge_mode=True`` when
55
+ sending AgentPay Gateway API-key requests so the first request can receive
56
+ a price challenge before any wallet hold is placed.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ signer: ISigner,
62
+ policy: IPolicyEngine,
63
+ *,
64
+ transport: httpx.BaseTransport | None = None,
65
+ auto_retry_on_402: bool = True,
66
+ raise_on_policy_rejection: bool = False,
67
+ clock: Callable[[], int] | None = None,
68
+ unmetered_credential_policy: CredentialMode = "block",
69
+ credential_headers: tuple[str, ...] = DEFAULT_CREDENTIAL_HEADERS,
70
+ credential_value_prefixes: tuple[str, ...] = DEFAULT_CREDENTIAL_VALUE_PREFIXES,
71
+ estimate_cost: Callable[[httpx.Request], int] | None = None,
72
+ actual_cost: Callable[[httpx.Response], int | None] | None = None,
73
+ credential_ttl_seconds: int = 60,
74
+ wallet_challenge_mode: bool = False,
75
+ **kwargs: Any,
76
+ ) -> None:
77
+ inner_kwargs = _inner_transport_kwargs(kwargs)
78
+
79
+ self.signer = signer
80
+ self.policy = policy
81
+ self.auto_retry_on_402 = auto_retry_on_402
82
+ self.raise_on_policy_rejection = raise_on_policy_rejection
83
+ self._clock = clock
84
+ self.unmetered_credential_policy = unmetered_credential_policy
85
+ # Single source of truth, reused by async_client() so both clients behave identically.
86
+ self._agentpay_options: dict[str, Any] = {
87
+ "auto_retry_on_402": auto_retry_on_402,
88
+ "raise_on_policy_rejection": raise_on_policy_rejection,
89
+ "clock": clock,
90
+ "unmetered_credential_policy": unmetered_credential_policy,
91
+ "credential_headers": credential_headers,
92
+ "credential_value_prefixes": credential_value_prefixes,
93
+ "estimate_cost": estimate_cost,
94
+ "actual_cost": actual_cost,
95
+ "credential_ttl_seconds": credential_ttl_seconds,
96
+ "wallet_challenge_mode": wallet_challenge_mode,
97
+ }
98
+
99
+ inner = transport if transport is not None else httpx.HTTPTransport(**inner_kwargs)
100
+ agent_transport = AgentPayTransport(inner, signer, policy, **self._agentpay_options)
101
+ super().__init__(transport=agent_transport, **kwargs)
102
+
103
+ def async_client(
104
+ self,
105
+ *,
106
+ transport: httpx.AsyncBaseTransport | None = None,
107
+ **kwargs: Any,
108
+ ) -> AsyncAgentPayClient:
109
+ """Create an AsyncAgentPayClient sharing the same signer, policy and firewall options."""
110
+ options = {**self._agentpay_options, **{k: v for k, v in kwargs.items() if k in self._agentpay_options}}
111
+ client_kwargs = {k: v for k, v in kwargs.items() if k not in self._agentpay_options}
112
+ return AsyncAgentPayClient(
113
+ signer=self.signer,
114
+ policy=self.policy,
115
+ transport=transport,
116
+ **options,
117
+ **client_kwargs,
118
+ )
119
+
120
+
121
+ class AsyncAgentPayClient(httpx.AsyncClient):
122
+ """
123
+ Asynchronous HTTP client for autonomous AI agents with integrated financial firewall.
124
+
125
+ Inherits from httpx.AsyncClient. Use ``wallet_challenge_mode=True`` for
126
+ AgentPay Gateway API-key requests that must be priced before wallet payment.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ signer: ISigner,
132
+ policy: IPolicyEngine,
133
+ *,
134
+ transport: httpx.AsyncBaseTransport | None = None,
135
+ auto_retry_on_402: bool = True,
136
+ raise_on_policy_rejection: bool = False,
137
+ clock: Callable[[], int] | None = None,
138
+ unmetered_credential_policy: CredentialMode = "block",
139
+ credential_headers: tuple[str, ...] = DEFAULT_CREDENTIAL_HEADERS,
140
+ credential_value_prefixes: tuple[str, ...] = DEFAULT_CREDENTIAL_VALUE_PREFIXES,
141
+ estimate_cost: Callable[[httpx.Request], int] | None = None,
142
+ actual_cost: Callable[[httpx.Response], int | None] | None = None,
143
+ credential_ttl_seconds: int = 60,
144
+ wallet_challenge_mode: bool = False,
145
+ **kwargs: Any,
146
+ ) -> None:
147
+ inner_kwargs = _inner_transport_kwargs(kwargs)
148
+
149
+ self.signer = signer
150
+ self.policy = policy
151
+ self.auto_retry_on_402 = auto_retry_on_402
152
+ self.raise_on_policy_rejection = raise_on_policy_rejection
153
+ self._clock = clock
154
+ self.unmetered_credential_policy = unmetered_credential_policy
155
+ self._agentpay_options: dict[str, Any] = {
156
+ "auto_retry_on_402": auto_retry_on_402,
157
+ "raise_on_policy_rejection": raise_on_policy_rejection,
158
+ "clock": clock,
159
+ "unmetered_credential_policy": unmetered_credential_policy,
160
+ "credential_headers": credential_headers,
161
+ "credential_value_prefixes": credential_value_prefixes,
162
+ "estimate_cost": estimate_cost,
163
+ "actual_cost": actual_cost,
164
+ "credential_ttl_seconds": credential_ttl_seconds,
165
+ "wallet_challenge_mode": wallet_challenge_mode,
166
+ }
167
+
168
+ inner = transport if transport is not None else httpx.AsyncHTTPTransport(**inner_kwargs)
169
+ agent_transport = AsyncAgentPayTransport(inner, signer, policy, **self._agentpay_options)
170
+ super().__init__(transport=agent_transport, **kwargs)
@@ -2,7 +2,7 @@
2
2
  httpx transparent 402 interceptor transports for AgentPay.
3
3
 
4
4
  Implements the autonomous financial lifecycle:
5
- Intercept 402 -> Policy Evaluate & Reserve -> Sign Authorization -> Replay with PAYMENT-SIGNATURE -> Commit or Hold
5
+ Intercept 402 -> Policy Evaluate & Reserve -> Sign or authorize -> Replay with PAYMENT-SIGNATURE -> Commit or Hold
6
6
 
7
7
  Adheres strictly to CLAUDE.md safety rules:
8
8
  - Fail-closed evaluation
@@ -21,11 +21,17 @@ answered 200 without any 402-round trip, which would bypass the local firewall.
21
21
  "allow" legacy behaviour, firewall is bypassed (explicit opt-in only)
22
22
 
23
23
  A single request can opt out with ``extensions={"agentpay_allow_unmetered": True}``.
24
+
25
+ For AgentPay Gateway virtual wallets, ``wallet_challenge_mode=True`` bypasses the
26
+ pre-request credential meter so the gateway can return a price challenge first;
27
+ the challenge is then evaluated locally before the authorized retry is sent.
24
28
  """
25
29
 
26
30
  from __future__ import annotations
27
31
 
32
+ import hashlib
28
33
  import logging
34
+ import secrets
29
35
  import time
30
36
  import uuid
31
37
  from collections.abc import Callable
@@ -35,10 +41,11 @@ import httpx
35
41
 
36
42
  from agentpay.client.interfaces import ISigner
37
43
  from agentpay.core.codecs import (
44
+ encode_wallet_authorization,
38
45
  extract_challenge_from_headers,
39
46
  inject_authorization_to_headers,
40
47
  )
41
- from agentpay.core.models import PaymentAuthorization
48
+ from agentpay.core.models import PaymentAuthorization, WalletAuthorization
42
49
  from agentpay.policy.errors import PolicyErrorCode, PolicyViolationError
43
50
  from agentpay.policy.interfaces import IPolicyEngine
44
51
  from agentpay.policy.metered import IMeteredPolicyEngine
@@ -117,6 +124,7 @@ class _AgentPayCore:
117
124
  estimate_cost: Callable[[httpx.Request], int] | None,
118
125
  actual_cost: Callable[[httpx.Response], int | None] | None,
119
126
  credential_ttl_seconds: int,
127
+ wallet_challenge_mode: bool,
120
128
  ) -> None:
121
129
  if unmetered_credential_policy not in ("block", "meter", "allow"):
122
130
  raise ValueError(
@@ -137,6 +145,7 @@ class _AgentPayCore:
137
145
  self._estimate_cost = estimate_cost
138
146
  self._actual_cost = actual_cost
139
147
  self._cred_ttl = credential_ttl_seconds
148
+ self._wallet_challenge_mode = wallet_challenge_mode
140
149
 
141
150
  # -- credential-mode ---------------------------------------------------
142
151
 
@@ -154,6 +163,10 @@ class _AgentPayCore:
154
163
 
155
164
  def _meter_begin(self, request: httpx.Request) -> str | None:
156
165
  """Runs BEFORE the request is sent. Returns an intent id if a metered reservation was taken."""
166
+ # This opt-in is only for AgentPay Gateway wallet routes: the first call must reach
167
+ # the gateway to obtain its price challenge, which is then policy-checked below.
168
+ if self._wallet_challenge_mode and self._has_credential(request):
169
+ return None
157
170
  if request.extensions.get(ALLOW_UNMETERED_EXTENSION) or not self._has_credential(request):
158
171
  return None
159
172
  if self._cred_mode == "allow":
@@ -211,8 +224,20 @@ class _AgentPayCore:
211
224
  return None # not a compliant x402 challenge
212
225
  if challenge.is_expired(self._clock()):
213
226
  return None
227
+ if challenge.scheme not in ("EIP-3009", "agentpay-wallet"):
228
+ raise PolicyViolationError(PolicyErrorCode.POLICY_SYSTEM_ERROR, f"unsupported payment scheme: {challenge.scheme}")
229
+ if challenge.scheme == "agentpay-wallet":
230
+ expected_binding = self._request_binding(request)
231
+ if (challenge.extensions or {}).get("requestBinding") != expected_binding:
232
+ raise PolicyViolationError(PolicyErrorCode.POLICY_SYSTEM_ERROR, "wallet challenge requestBinding mismatch")
233
+ if not (challenge.extensions or {}).get("routeId"):
234
+ raise PolicyViolationError(PolicyErrorCode.POLICY_SYSTEM_ERROR, "wallet challenge missing routeId")
235
+ raw_path, _, _ = request.url.raw_path.partition(b"?")
236
+ if challenge.resource_uri != (raw_path.decode("ascii") or "/"):
237
+ raise PolicyViolationError(PolicyErrorCode.POLICY_SYSTEM_ERROR, "wallet challenge resourceUri mismatch")
214
238
 
215
239
  payment_request = challenge.to_payment_request(domain=request.url.host)
240
+ # SECURITY-REVIEW: reserve the exact seller challenge amount before generating wallet authorization.
216
241
  decision = self._policy.reserve(
217
242
  intent_id=payment_request.intent_id,
218
243
  amount=payment_request.amount,
@@ -231,19 +256,58 @@ class _AgentPayCore:
231
256
  raise PolicyViolationError(PolicyErrorCode.POLICY_SYSTEM_ERROR, "approved decision without reservation")
232
257
  return challenge, decision.reservation
233
258
 
259
+ @staticmethod
260
+ def _request_binding(request: httpx.Request) -> str:
261
+ """Match the Go Gateway's method/path/query/body SHA-256 canonicalization."""
262
+ raw_path, marker, raw_query = request.url.raw_path.partition(b"?")
263
+ path = raw_path.decode("ascii") or "/"
264
+ query = raw_query.decode("ascii") if marker else ""
265
+ body_digest = hashlib.sha256(request.read()).hexdigest()
266
+ canonical = f"{request.method.upper()}\n{path}\n{query}\n{body_digest}".encode("utf-8")
267
+ return "sha256:" + hashlib.sha256(canonical).hexdigest()
268
+
234
269
  def _sign_and_clone(self, request: httpx.Request, challenge: Any, reservation: Any) -> httpx.Request:
235
270
  # Everything here happens BEFORE transmission, so releasing on failure is safe.
236
271
  try:
272
+ if challenge.scheme == "agentpay-wallet":
273
+ # SECURITY-REVIEW: the challenge-bound reservation is the buyer-side approval gate.
274
+ if reservation.intent_id != challenge.challenge_id or reservation.amount != challenge.amount_base_units:
275
+ raise PolicyViolationError(PolicyErrorCode.POLICY_SYSTEM_ERROR, "wallet reservation does not match challenge")
276
+ if reservation.expires_at < challenge.valid_before:
277
+ raise PolicyViolationError(PolicyErrorCode.POLICY_SYSTEM_ERROR, "wallet reservation expires before challenge")
278
+ request_binding = (challenge.extensions or {}).get("requestBinding")
279
+ if not request_binding:
280
+ raise PolicyViolationError(PolicyErrorCode.POLICY_SYSTEM_ERROR, "wallet challenge missing requestBinding")
281
+ wallet_auth = WalletAuthorization(
282
+ challengeId=challenge.challenge_id,
283
+ nonce="0x" + secrets.token_bytes(32).hex(),
284
+ validAfter=challenge.valid_after,
285
+ validBefore=challenge.valid_before,
286
+ amount=challenge.amount,
287
+ resourceUri=challenge.resource_uri,
288
+ requestBinding=request_binding,
289
+ )
290
+ headers = httpx.Headers(request.headers)
291
+ headers["PAYMENT-SIGNATURE"] = encode_wallet_authorization(wallet_auth)
292
+ content = request.read()
293
+ return httpx.Request(
294
+ method=request.method, url=request.url, headers=headers,
295
+ content=content, extensions=dict(request.extensions),
296
+ )
237
297
  authorization = self._signer.sign_payment_authorization(challenge, reservation)
238
298
  return _clone_request_with_authorization(request, authorization)
239
299
  except Exception:
240
300
  self._policy.release(reservation.intent_id)
241
301
  raise
242
302
 
243
- def _settle_x402(self, intent_id: str, retry_response: httpx.Response) -> None:
303
+ def _settle_x402(self, intent_id: str, retry_response: httpx.Response, scheme: str) -> None:
244
304
  code = retry_response.status_code
305
+ # SECURITY-REVIEW: only a definitive wallet 4xx releases; ambiguous and 5xx outcomes stay reserved.
245
306
  if 200 <= code < 300:
246
307
  self._policy.commit(intent_id)
308
+ elif scheme == "agentpay-wallet" and 400 <= code < 500:
309
+ # Wallet authorization has no chain signature; a definitive gateway rejection means no hold was dispatched.
310
+ self._policy.release(intent_id)
247
311
  elif code >= 500:
248
312
  # Signature is on the wire: keep RESERVED (no auto release) and feed the breaker.
249
313
  _safe_record_failure(self._policy, f"upstream {code} after payment")
@@ -259,8 +323,8 @@ class AgentPayTransport(_AgentPayCore, httpx.BaseTransport):
259
323
  Synchronous httpx transport interceptor for AgentPay.
260
324
 
261
325
  Transparently catches HTTP 402 Payment Required responses, evaluates the
262
- challenge against the local policy firewall, signs with EIP-712/3009,
263
- and replays the request with payment credentials.
326
+ challenge against the local policy firewall, then either signs EIP-3009 or
327
+ creates an AgentPay wallet authorization before replaying the request.
264
328
  """
265
329
 
266
330
  def __init__(
@@ -278,6 +342,7 @@ class AgentPayTransport(_AgentPayCore, httpx.BaseTransport):
278
342
  estimate_cost: Callable[[httpx.Request], int] | None = None,
279
343
  actual_cost: Callable[[httpx.Response], int | None] | None = None,
280
344
  credential_ttl_seconds: int = 60,
345
+ wallet_challenge_mode: bool = False,
281
346
  ) -> None:
282
347
  httpx.BaseTransport.__init__(self)
283
348
  _AgentPayCore.__init__(
@@ -293,6 +358,7 @@ class AgentPayTransport(_AgentPayCore, httpx.BaseTransport):
293
358
  estimate_cost=estimate_cost,
294
359
  actual_cost=actual_cost,
295
360
  credential_ttl_seconds=credential_ttl_seconds,
361
+ wallet_challenge_mode=wallet_challenge_mode,
296
362
  )
297
363
  self._inner_transport = inner_transport
298
364
 
@@ -311,6 +377,13 @@ class AgentPayTransport(_AgentPayCore, httpx.BaseTransport):
311
377
  if meter_id is not None:
312
378
  self._meter_end(meter_id, response)
313
379
 
380
+ if self._wallet_challenge_mode and self._has_credential(request) and 200 <= response.status_code < 300:
381
+ response.close()
382
+ raise PolicyViolationError(
383
+ PolicyErrorCode.UNMETERED_CREDENTIAL,
384
+ "Wallet challenge mode expected a 402 price challenge; the gateway served the credential request directly.",
385
+ )
386
+
314
387
  if response.status_code != 402 or not self._auto_retry:
315
388
  return response
316
389
 
@@ -329,7 +402,7 @@ class AgentPayTransport(_AgentPayCore, httpx.BaseTransport):
329
402
  _safe_record_failure(self._policy, "transport error after payment signature sent")
330
403
  raise
331
404
 
332
- self._settle_x402(reservation.intent_id, retry_response)
405
+ self._settle_x402(reservation.intent_id, retry_response, challenge.scheme)
333
406
  return retry_response
334
407
 
335
408
 
@@ -356,6 +429,7 @@ class AsyncAgentPayTransport(_AgentPayCore, httpx.AsyncBaseTransport):
356
429
  estimate_cost: Callable[[httpx.Request], int] | None = None,
357
430
  actual_cost: Callable[[httpx.Response], int | None] | None = None,
358
431
  credential_ttl_seconds: int = 60,
432
+ wallet_challenge_mode: bool = False,
359
433
  ) -> None:
360
434
  httpx.AsyncBaseTransport.__init__(self)
361
435
  _AgentPayCore.__init__(
@@ -371,6 +445,7 @@ class AsyncAgentPayTransport(_AgentPayCore, httpx.AsyncBaseTransport):
371
445
  estimate_cost=estimate_cost,
372
446
  actual_cost=actual_cost,
373
447
  credential_ttl_seconds=credential_ttl_seconds,
448
+ wallet_challenge_mode=wallet_challenge_mode,
374
449
  )
375
450
  self._inner_transport = inner_transport
376
451
 
@@ -388,6 +463,13 @@ class AsyncAgentPayTransport(_AgentPayCore, httpx.AsyncBaseTransport):
388
463
  if meter_id is not None:
389
464
  self._meter_end(meter_id, response)
390
465
 
466
+ if self._wallet_challenge_mode and self._has_credential(request) and 200 <= response.status_code < 300:
467
+ await response.aclose()
468
+ raise PolicyViolationError(
469
+ PolicyErrorCode.UNMETERED_CREDENTIAL,
470
+ "Wallet challenge mode expected a 402 price challenge; the gateway served the credential request directly.",
471
+ )
472
+
391
473
  if response.status_code != 402 or not self._auto_retry:
392
474
  return response
393
475
 
@@ -405,5 +487,5 @@ class AsyncAgentPayTransport(_AgentPayCore, httpx.AsyncBaseTransport):
405
487
  _safe_record_failure(self._policy, "transport error after payment signature sent")
406
488
  raise
407
489
 
408
- self._settle_x402(reservation.intent_id, retry_response)
490
+ self._settle_x402(reservation.intent_id, retry_response, challenge.scheme)
409
491
  return retry_response
@@ -23,7 +23,7 @@ from agentpay.core.errors import (
23
23
  X402ErrorCode,
24
24
  X402ValidationError,
25
25
  )
26
- from agentpay.core.models import PaymentAuthorization, PaymentChallenge
26
+ from agentpay.core.models import PaymentAuthorization, PaymentChallenge, WalletAuthorization
27
27
 
28
28
  # Standard HTTP Header Constants (RFC-compliant uppercase default)
29
29
  HEADER_PAYMENT_REQUIRED = "PAYMENT-REQUIRED"
@@ -176,6 +176,15 @@ def encode_authorization(authorization: PaymentAuthorization, *, base64_url: boo
176
176
  ) from exc
177
177
 
178
178
 
179
+ def encode_wallet_authorization(authorization: WalletAuthorization, *, base64_url: bool = True) -> str:
180
+ """Encode AgentPay's non-EIP-3009 wallet authorization in PAYMENT-SIGNATURE."""
181
+ try:
182
+ payload = authorization.model_dump_json(by_alias=True, exclude_none=True)
183
+ return base64url_encode(payload.encode("utf-8")) if base64_url else payload
184
+ except Exception as exc: # noqa: BLE001
185
+ raise X402EncodeError("Failed to encode wallet authorization", code=X402ErrorCode.MALFORMED_HEADER) from exc
186
+
187
+
179
188
  def decode_authorization(header_value: str) -> PaymentAuthorization:
180
189
  """
181
190
  Decode an HTTP PAYMENT-SIGNATURE header value (Base64URL or raw JSON) into a PaymentAuthorization model.
@@ -146,21 +146,22 @@ class PaymentChallenge(BaseModel):
146
146
  Corresponds to the PAYMENT-REQUIRED header.
147
147
  """
148
148
 
149
- model_config = ConfigDict(frozen=True, populate_by_name=True)
149
+ model_config = ConfigDict(frozen=True, populate_by_name=True, extra="allow")
150
150
 
151
151
  version: str = Field(default="2.0", description="Protocol version, fixed to 2.0")
152
152
  challenge_id: str = Field(..., alias="challengeId", description="Globally unique challenge ID")
153
- network: str = Field(..., description="CAIP-2 formatted network identifier")
154
- asset: str = Field(..., description="Payment token contract address (EVM 0x format)")
153
+ scheme: str = Field(default="EIP-3009", description="Payment authorization scheme")
154
+ network: str | None = Field(default=None, description="CAIP-2 network or AgentPay internal ledger")
155
+ asset: str | None = Field(default=None, description="Payment asset identifier")
155
156
  asset_symbol: str | None = Field(default=None, alias="assetSymbol", description="Token symbol")
156
157
  decimals: int = Field(default=6, description="Token decimals")
157
158
  amount: str = Field(..., description="Payment amount in integer base units as string")
158
- payee: str = Field(..., description="Recipient address on-chain (EVM 0x format)")
159
- scheme: str = Field(default="EIP-3009", description="Payment authorization scheme")
159
+ payee: str | None = Field(default=None, description="Chain recipient or AgentPay route resource")
160
160
  valid_after: int = Field(..., alias="validAfter", description="Unix timestamp valid after (seconds)")
161
161
  valid_before: int = Field(..., alias="validBefore", description="Unix timestamp valid before (seconds)")
162
- facilitator_url: str = Field(..., alias="facilitatorUrl", description="Facilitator clearing endpoint URL")
162
+ facilitator_url: str | None = Field(default=None, alias="facilitatorUrl", description="Facilitator clearing endpoint URL")
163
163
  resource_uri: str | None = Field(default=None, alias="resourceUri", description="Protected resource path")
164
+ extensions: dict[str, Any] | None = None
164
165
 
165
166
  def __init__(self, /, **data: Any) -> None:
166
167
  try:
@@ -217,17 +218,29 @@ class PaymentChallenge(BaseModel):
217
218
 
218
219
  @field_validator("network")
219
220
  @classmethod
220
- def _validate_network(cls, v: str) -> str:
221
+ def _validate_network(cls, v: str | None, info: Any) -> str | None:
222
+ if info.data.get("scheme") == "agentpay-wallet" and v == "agentpay:internal-ledger":
223
+ return v
224
+ if v is None:
225
+ raise X402ValidationError("network is required", code=X402ErrorCode.MISSING_REQUIRED_FIELD)
221
226
  return _validate_caip2(v)
222
227
 
223
228
  @field_validator("asset")
224
229
  @classmethod
225
- def _validate_asset(cls, v: str) -> str:
230
+ def _validate_asset(cls, v: str | None, info: Any) -> str | None:
231
+ if info.data.get("scheme") == "agentpay-wallet" and v == "USD":
232
+ return v
233
+ if v is None:
234
+ raise X402ValidationError("asset is required", code=X402ErrorCode.MISSING_REQUIRED_FIELD)
226
235
  return _validate_evm_address(v, "asset")
227
236
 
228
237
  @field_validator("payee")
229
238
  @classmethod
230
- def _validate_payee(cls, v: str) -> str:
239
+ def _validate_payee(cls, v: str | None, info: Any) -> str | None:
240
+ if info.data.get("scheme") == "agentpay-wallet" and v and v.startswith("agentpay:route:"):
241
+ return v
242
+ if v is None:
243
+ raise X402ValidationError("payee is required", code=X402ErrorCode.MISSING_REQUIRED_FIELD)
231
244
  return _validate_evm_address(v, "payee")
232
245
 
233
246
  @field_validator("decimals")
@@ -315,6 +328,17 @@ class PaymentChallenge(BaseModel):
315
328
  )
316
329
  return self
317
330
 
331
+ @model_validator(mode="after")
332
+ def _validate_scheme_fields(self) -> PaymentChallenge:
333
+ if self.scheme == "agentpay-wallet":
334
+ if self.network != "agentpay:internal-ledger" or self.asset != "USD" or not self.resource_uri:
335
+ raise X402ValidationError("invalid agentpay-wallet challenge fields", code=X402ErrorCode.INVALID_SCHEME)
336
+ if self.facilitator_url:
337
+ raise X402ValidationError("agentpay-wallet challenge must not name an EIP-3009 facilitator", code=X402ErrorCode.INVALID_SCHEME)
338
+ elif not self.network or not self.asset or not self.payee or not self.facilitator_url:
339
+ raise X402ValidationError("EIP-3009 challenge requires network, asset, payee, and facilitatorUrl", code=X402ErrorCode.MISSING_REQUIRED_FIELD)
340
+ return self
341
+
318
342
  @property
319
343
  def amount_base_units(self) -> int:
320
344
  """Payment amount parsed into integer base units."""
@@ -341,9 +365,9 @@ class PaymentChallenge(BaseModel):
341
365
  return PaymentRequest(
342
366
  intent_id=intent_id or self.challenge_id,
343
367
  amount=self.amount_base_units,
344
- payee=self.payee,
345
- asset=self.asset,
346
- network=self.network,
368
+ payee=self.payee or "agentpay:route:unknown",
369
+ asset=self.asset or "USD",
370
+ network=self.network or "agentpay:internal-ledger",
347
371
  domain=domain,
348
372
  ttl_seconds=ttl_seconds,
349
373
  )
@@ -460,3 +484,41 @@ class PaymentAuthorization(BaseModel):
460
484
  code=X402ErrorCode.INVALID_TIME_WINDOW,
461
485
  )
462
486
  return self
487
+
488
+
489
+ class WalletAuthorization(BaseModel):
490
+ """AgentPay's API-key-authenticated wallet approval payload (not a cryptographic signature)."""
491
+
492
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
493
+
494
+ version: str = "2.0"
495
+ challenge_id: str = Field(..., alias="challengeId")
496
+ scheme: str = "agentpay-wallet"
497
+ nonce: str
498
+ valid_after: int = Field(..., alias="validAfter")
499
+ valid_before: int = Field(..., alias="validBefore")
500
+ amount: str
501
+ resource_uri: str = Field(..., alias="resourceUri")
502
+ request_binding: str = Field(..., alias="requestBinding")
503
+
504
+ @field_validator("nonce")
505
+ @classmethod
506
+ def _validate_wallet_nonce(cls, value: str) -> str:
507
+ if not re.fullmatch(r"0x[0-9a-f]{64}", value):
508
+ raise X402ValidationError("wallet nonce must be 32 random bytes encoded as lowercase hex", code=X402ErrorCode.INVALID_NONCE)
509
+ return value
510
+
511
+ @field_validator("amount")
512
+ @classmethod
513
+ def _validate_wallet_amount(cls, value: str) -> str:
514
+ if not value.isdecimal() or int(value) <= 0 or int(value) > SQLITE_MAX_INT:
515
+ raise X402ValidationError("wallet amount must be a positive int64 decimal string", code=X402ErrorCode.INVALID_AMOUNT_FORMAT)
516
+ return value
517
+
518
+ @model_validator(mode="after")
519
+ def _validate_wallet_fields(self) -> WalletAuthorization:
520
+ if self.version != "2.0" or self.scheme != "agentpay-wallet" or self.valid_before <= self.valid_after:
521
+ raise X402ValidationError("invalid agentpay-wallet authorization fields", code=X402ErrorCode.INVALID_SCHEME)
522
+ if not self.challenge_id or not self.resource_uri or not re.fullmatch(r"sha256:[0-9a-f]{64}", self.request_binding):
523
+ raise X402ValidationError("wallet authorization is missing challenge binding fields", code=X402ErrorCode.MISSING_REQUIRED_FIELD)
524
+ return self
@@ -199,7 +199,13 @@ class PolicyConfig(BaseModel):
199
199
  for addr in v:
200
200
  if not isinstance(addr, str):
201
201
  raise TypeError(f"whitelist_payees elements must be strings, got {type(addr).__name__}")
202
- return frozenset(_validate_evm_address(addr, "whitelist_payees") for addr in v)
202
+ normalized: list[str] = []
203
+ for addr in v:
204
+ if addr.startswith("agentpay:route:") and len(addr) > len("agentpay:route:"):
205
+ normalized.append(addr)
206
+ else:
207
+ normalized.append(_validate_evm_address(addr, "whitelist_payees"))
208
+ return frozenset(normalized)
203
209
 
204
210
  @field_validator("whitelist_assets", mode="before")
205
211
  @classmethod
@@ -211,7 +217,10 @@ class PolicyConfig(BaseModel):
211
217
  for addr in v:
212
218
  if not isinstance(addr, str):
213
219
  raise TypeError(f"whitelist_assets elements must be strings, got {type(addr).__name__}")
214
- return frozenset(_validate_evm_address(addr, "whitelist_assets") for addr in v)
220
+ normalized: list[str] = []
221
+ for addr in v:
222
+ normalized.append("USD" if addr == "USD" else _validate_evm_address(addr, "whitelist_assets"))
223
+ return frozenset(normalized)
215
224
 
216
225
  @field_validator("whitelist_networks", mode="before")
217
226
  @classmethod
@@ -1,109 +0,0 @@
1
- """
2
- AgentPayClient: Drop-in httpx.Client with automatic 402 payment negotiation.
3
-
4
- Wraps the financial firewall and EIP-712 cryptographic signer to automatically
5
- satisfy HTTP 402 Payment Required challenges.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from collections.abc import Callable
11
- from typing import Any
12
-
13
- import httpx
14
-
15
- from agentpay.client.interfaces import ISigner
16
- from agentpay.client.transport import AgentPayTransport, AsyncAgentPayTransport
17
- from agentpay.policy.interfaces import IPolicyEngine
18
-
19
-
20
- class AgentPayClient(httpx.Client):
21
- """
22
- HTTP client for autonomous AI agents with integrated financial firewall.
23
-
24
- Inherits from httpx.Client: fully compatible with LangChain, LlamaIndex,
25
- and standard Python HTTP toolchains.
26
- """
27
-
28
- def __init__(
29
- self,
30
- signer: ISigner,
31
- policy: IPolicyEngine,
32
- *,
33
- transport: httpx.BaseTransport | None = None,
34
- auto_retry_on_402: bool = True,
35
- raise_on_policy_rejection: bool = False,
36
- clock: Callable[[], int] | None = None,
37
- **kwargs: Any,
38
- ) -> None:
39
- self.signer = signer
40
- self.policy = policy
41
- self.auto_retry_on_402 = auto_retry_on_402
42
- self.raise_on_policy_rejection = raise_on_policy_rejection
43
- self._clock = clock
44
-
45
- inner = transport or httpx.HTTPTransport()
46
- agent_transport = AgentPayTransport(
47
- inner_transport=inner,
48
- signer=signer,
49
- policy=policy,
50
- auto_retry_on_402=auto_retry_on_402,
51
- raise_on_policy_rejection=raise_on_policy_rejection,
52
- clock=clock,
53
- )
54
-
55
- super().__init__(transport=agent_transport, **kwargs)
56
-
57
- def async_client(
58
- self,
59
- *,
60
- transport: httpx.AsyncBaseTransport | None = None,
61
- **kwargs: Any,
62
- ) -> AsyncAgentPayClient:
63
- """Create an asynchronous AsyncAgentPayClient sharing the same signer and policy."""
64
- return AsyncAgentPayClient(
65
- signer=self.signer,
66
- policy=self.policy,
67
- transport=transport,
68
- auto_retry_on_402=self.auto_retry_on_402,
69
- raise_on_policy_rejection=self.raise_on_policy_rejection,
70
- clock=self._clock,
71
- **kwargs,
72
- )
73
-
74
-
75
- class AsyncAgentPayClient(httpx.AsyncClient):
76
- """
77
- Asynchronous HTTP client for autonomous AI agents with integrated financial firewall.
78
-
79
- Inherits from httpx.AsyncClient.
80
- """
81
-
82
- def __init__(
83
- self,
84
- signer: ISigner,
85
- policy: IPolicyEngine,
86
- *,
87
- transport: httpx.AsyncBaseTransport | None = None,
88
- auto_retry_on_402: bool = True,
89
- raise_on_policy_rejection: bool = False,
90
- clock: Callable[[], int] | None = None,
91
- **kwargs: Any,
92
- ) -> None:
93
- self.signer = signer
94
- self.policy = policy
95
- self.auto_retry_on_402 = auto_retry_on_402
96
- self.raise_on_policy_rejection = raise_on_policy_rejection
97
- self._clock = clock
98
-
99
- inner = transport or httpx.AsyncHTTPTransport()
100
- agent_transport = AsyncAgentPayTransport(
101
- inner_transport=inner,
102
- signer=signer,
103
- policy=policy,
104
- auto_retry_on_402=auto_retry_on_402,
105
- raise_on_policy_rejection=raise_on_policy_rejection,
106
- clock=clock,
107
- )
108
-
109
- super().__init__(transport=agent_transport, **kwargs)
File without changes
File without changes