open-agentpay 0.1.0__py3-none-any.whl

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 (47) hide show
  1. agentpay/__init__.py +45 -0
  2. agentpay/budget/__init__.py +37 -0
  3. agentpay/client/__init__.py +41 -0
  4. agentpay/client/client.py +109 -0
  5. agentpay/client/errors.py +28 -0
  6. agentpay/client/interfaces.py +53 -0
  7. agentpay/client/signer.py +340 -0
  8. agentpay/client/transport.py +223 -0
  9. agentpay/core/__init__.py +73 -0
  10. agentpay/core/codecs.py +237 -0
  11. agentpay/core/errors.py +70 -0
  12. agentpay/core/interop.py +69 -0
  13. agentpay/core/models.py +462 -0
  14. agentpay/core/state.py +161 -0
  15. agentpay/evm/__init__.py +79 -0
  16. agentpay/evm/constants.py +118 -0
  17. agentpay/evm/eip3009.py +194 -0
  18. agentpay/evm/rpc.py +291 -0
  19. agentpay/integrations/__init__.py +10 -0
  20. agentpay/integrations/langchain/__init__.py +20 -0
  21. agentpay/integrations/langchain/http.py +174 -0
  22. agentpay/integrations/langchain/tool.py +210 -0
  23. agentpay/policy/__init__.py +39 -0
  24. agentpay/policy/config.py +118 -0
  25. agentpay/policy/db.py +128 -0
  26. agentpay/policy/errors.py +65 -0
  27. agentpay/policy/evaluator.py +114 -0
  28. agentpay/policy/interfaces.py +109 -0
  29. agentpay/policy/models.py +343 -0
  30. agentpay/policy/sqlite_engine.py +622 -0
  31. agentpay/protocol/__init__.py +73 -0
  32. agentpay/protocol/codecs.py +237 -0
  33. agentpay/protocol/errors.py +70 -0
  34. agentpay/protocol/interop.py +69 -0
  35. agentpay/protocol/models.py +462 -0
  36. agentpay/protocol/state.py +161 -0
  37. agentpay/signer/__init__.py +39 -0
  38. agentpay/state/__init__.py +65 -0
  39. agentpay/state/buyer_coordinator.py +537 -0
  40. agentpay/state/errors.py +104 -0
  41. agentpay/state/guards.py +344 -0
  42. agentpay/state/reconciliation.py +422 -0
  43. agentpay/testing/__init__.py +17 -0
  44. agentpay/testing/mock_seller.py +296 -0
  45. open_agentpay-0.1.0.dist-info/METADATA +101 -0
  46. open_agentpay-0.1.0.dist-info/RECORD +47 -0
  47. open_agentpay-0.1.0.dist-info/WHEEL +4 -0
agentpay/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ AgentPay Python SDK
3
+ The Open-Source Payment Infrastructure & Financial Firewall for Autonomous AI Agents.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ from agentpay.client import (
9
+ AgentPayClient,
10
+ AgentPayTransport,
11
+ AsyncAgentPayClient,
12
+ AsyncAgentPayTransport,
13
+ ISigner,
14
+ LocalSigner,
15
+ )
16
+ from agentpay.core import (
17
+ HEADER_PAYMENT_REQUIRED,
18
+ HEADER_PAYMENT_SIGNATURE,
19
+ PaymentAuthorization,
20
+ PaymentChallenge,
21
+ SignatureData,
22
+ decode_authorization,
23
+ decode_challenge,
24
+ encode_authorization,
25
+ encode_challenge,
26
+ )
27
+
28
+ __all__ = [
29
+ "HEADER_PAYMENT_REQUIRED",
30
+ "HEADER_PAYMENT_SIGNATURE",
31
+ "AgentPayClient",
32
+ "AgentPayTransport",
33
+ "AsyncAgentPayClient",
34
+ "AsyncAgentPayTransport",
35
+ "ISigner",
36
+ "LocalSigner",
37
+ "PaymentAuthorization",
38
+ "PaymentChallenge",
39
+ "SignatureData",
40
+ "__version__",
41
+ "decode_authorization",
42
+ "decode_challenge",
43
+ "encode_authorization",
44
+ "encode_challenge",
45
+ ]
@@ -0,0 +1,37 @@
1
+ """
2
+ AgentPay Budget sub-package.
3
+
4
+ Exposes budget models, ledger records, and reservation tokens for buyer spending limits.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from agentpay.core.state import BudgetLedgerState
10
+ from agentpay.policy.models import (
11
+ BreakerState,
12
+ LedgerEntryState,
13
+ PaymentRequest,
14
+ PolicyConfig,
15
+ PolicyDecision,
16
+ ReservationToken,
17
+ )
18
+
19
+ # Backward compatibility / convenient domain aliases
20
+ BudgetReservation = ReservationToken
21
+ LedgerEntry = LedgerEntryState
22
+ PolicyEvaluationResult = PolicyDecision
23
+ PolicyStatus = BreakerState
24
+
25
+ __all__ = [
26
+ "BreakerState",
27
+ "BudgetLedgerState",
28
+ "BudgetReservation",
29
+ "LedgerEntry",
30
+ "LedgerEntryState",
31
+ "PaymentRequest",
32
+ "PolicyConfig",
33
+ "PolicyDecision",
34
+ "PolicyEvaluationResult",
35
+ "PolicyStatus",
36
+ "ReservationToken",
37
+ ]
@@ -0,0 +1,41 @@
1
+ """
2
+ AgentPay Client SDK and Signers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from agentpay.client.client import AgentPayClient, AsyncAgentPayClient
8
+ from agentpay.client.errors import (
9
+ ClientError,
10
+ InvalidPrivateKeyError,
11
+ ReservationMismatchError,
12
+ SignerError,
13
+ UnsupportedNetworkError,
14
+ )
15
+ from agentpay.client.interfaces import ISigner
16
+ from agentpay.client.signer import (
17
+ DEFAULT_TOKEN_NAMES,
18
+ EIP3009_MESSAGE_TYPES,
19
+ LocalSigner,
20
+ generate_nonce,
21
+ parse_caip2_chain_id,
22
+ )
23
+ from agentpay.client.transport import AgentPayTransport, AsyncAgentPayTransport
24
+
25
+ __all__ = [
26
+ "DEFAULT_TOKEN_NAMES",
27
+ "EIP3009_MESSAGE_TYPES",
28
+ "AgentPayClient",
29
+ "AgentPayTransport",
30
+ "AsyncAgentPayClient",
31
+ "AsyncAgentPayTransport",
32
+ "ClientError",
33
+ "ISigner",
34
+ "InvalidPrivateKeyError",
35
+ "LocalSigner",
36
+ "ReservationMismatchError",
37
+ "SignerError",
38
+ "UnsupportedNetworkError",
39
+ "generate_nonce",
40
+ "parse_caip2_chain_id",
41
+ ]
@@ -0,0 +1,109 @@
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)
@@ -0,0 +1,28 @@
1
+ """
2
+ Signer and Client Exceptions for AgentPay.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ class ClientError(Exception):
9
+ """Base exception for all AgentPay client and signer operations."""
10
+
11
+
12
+ class SignerError(ClientError):
13
+ """Base exception for cryptographic signing errors."""
14
+
15
+
16
+ class InvalidPrivateKeyError(SignerError):
17
+ """Raised when an invalid EVM private key is provided."""
18
+
19
+
20
+ class UnsupportedNetworkError(SignerError):
21
+ """Raised when a challenge specifies a network unsupported by the signer (e.g. non-EIP155)."""
22
+
23
+
24
+ class ReservationMismatchError(SignerError):
25
+ """
26
+ Raised when an approved ReservationToken does not match the PaymentChallenge.
27
+ Critical financial firewall guardrail violation.
28
+ """
@@ -0,0 +1,53 @@
1
+ """
2
+ Signer interface protocols for AgentPay.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Protocol, runtime_checkable
8
+
9
+ from agentpay.core.models import PaymentAuthorization, PaymentChallenge
10
+ from agentpay.policy.models import ReservationToken
11
+
12
+
13
+ @runtime_checkable
14
+ class ISigner(Protocol):
15
+ """
16
+ Protocol for signing agents and cryptographic keystores.
17
+ Enforces firewall reservation binding before signing.
18
+ """
19
+
20
+ @property
21
+ def address(self) -> str:
22
+ """The canonical lowercase EVM address of the signing wallet."""
23
+ ...
24
+
25
+ def sign_payment_authorization(
26
+ self,
27
+ challenge: PaymentChallenge,
28
+ reservation: ReservationToken,
29
+ *,
30
+ nonce: str | None = None,
31
+ token_name: str | None = None,
32
+ token_version: str = "2",
33
+ ) -> PaymentAuthorization:
34
+ """
35
+ Sign an EIP-712/3009 transfer authorization for the given challenge.
36
+
37
+ MUST verify that the reservation matches the challenge before signing.
38
+
39
+ Args:
40
+ challenge: The PaymentChallenge from the 402 PAYMENT-REQUIRED header.
41
+ reservation: The Approved ReservationToken from the policy engine.
42
+ nonce: Optional 32-byte hex nonce. If omitted, a random nonce is generated.
43
+ token_name: Optional EIP-712 domain token name (e.g. 'USD Coin').
44
+ token_version: EIP-712 domain version (default '2').
45
+
46
+ Returns:
47
+ PaymentAuthorization populated with cryptographic signature.
48
+
49
+ Raises:
50
+ ReservationMismatchError: If the reservation does not match the challenge.
51
+ SignerError: If cryptographic signing fails.
52
+ """
53
+ ...
@@ -0,0 +1,340 @@
1
+ """
2
+ EIP-712 & EIP-3009 LocalSigner implementation for AgentPay.
3
+
4
+ Uses eth-account for all cryptographic operations.
5
+ Enforces firewall reservation binding and secret redaction.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import secrets
12
+ from collections.abc import Mapping
13
+ from typing import Any
14
+
15
+ from eth_account import Account
16
+ from eth_account.messages import encode_typed_data
17
+ from eth_account.signers.local import LocalAccount
18
+
19
+ from agentpay.client.errors import (
20
+ InvalidPrivateKeyError,
21
+ ReservationMismatchError,
22
+ SignerError,
23
+ UnsupportedNetworkError,
24
+ )
25
+ from agentpay.client.interfaces import ISigner
26
+ from agentpay.core.models import (
27
+ PaymentAuthorization,
28
+ PaymentChallenge,
29
+ SignatureData,
30
+ )
31
+ from agentpay.policy.models import ReservationToken
32
+
33
+ # CAIP-2 parsing regex for eip155 namespace: eip155:<chain_id>
34
+ _CAIP2_EIP155_RE = re.compile(r"^eip155:([0-9]+)$")
35
+
36
+ # Standard EIP-3009 message type definition for TransferWithAuthorization
37
+ EIP3009_MESSAGE_TYPES = {
38
+ "TransferWithAuthorization": [
39
+ {"name": "from", "type": "address"},
40
+ {"name": "to", "type": "address"},
41
+ {"name": "value", "type": "uint256"},
42
+ {"name": "validAfter", "type": "uint256"},
43
+ {"name": "validBefore", "type": "uint256"},
44
+ {"name": "nonce", "type": "bytes32"},
45
+ ]
46
+ }
47
+
48
+ # Standard token names for major stablecoins
49
+ DEFAULT_TOKEN_NAMES = {
50
+ "usdc": "USD Coin",
51
+ "eurc": "Euro Coin",
52
+ }
53
+
54
+
55
+ def generate_nonce() -> str:
56
+ """Generate a cryptographically secure 32-byte hex nonce with 0x prefix."""
57
+ return "0x" + secrets.token_bytes(32).hex()
58
+
59
+
60
+ def parse_caip2_chain_id(network: str) -> int:
61
+ """
62
+ Extract the integer chain ID from a CAIP-2 network string.
63
+
64
+ Args:
65
+ network: CAIP-2 network identifier (e.g. 'eip155:84532').
66
+
67
+ Returns:
68
+ Integer chain ID.
69
+
70
+ Raises:
71
+ UnsupportedNetworkError: If the network is not an eip155 EVM chain.
72
+ """
73
+ match = _CAIP2_EIP155_RE.match(network.strip())
74
+ if not match:
75
+ raise UnsupportedNetworkError(
76
+ f"Unsupported network identifier: {network!r}. "
77
+ "LocalSigner only supports EVM chains conforming to 'eip155:<chainId>'."
78
+ )
79
+ return int(match.group(1))
80
+
81
+
82
+ class LocalSigner(ISigner):
83
+ """
84
+ Local private key signer for AgentPay clients.
85
+
86
+ - Adheres to EIP-712 and EIP-3009 specifications.
87
+ - Uses eth-account strictly; implements zero custom cryptography.
88
+ - Enforces mandatory policy reservation verification before signing.
89
+ - Redacts private key in __repr__ and __str__ for financial safety.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ private_key: str | bytes,
95
+ *,
96
+ token_name_map: Mapping[str, str] | None = None,
97
+ ) -> None:
98
+ """
99
+ Initialize LocalSigner with an EVM private key.
100
+
101
+ Args:
102
+ private_key: Hex string (with or without 0x) or 32 raw bytes.
103
+ token_name_map: Optional mapping of contract address or symbol to EIP-712 domain name.
104
+ """
105
+ try:
106
+ if isinstance(private_key, str):
107
+ clean_key = private_key.strip()
108
+ if not clean_key.startswith("0x"):
109
+ clean_key = "0x" + clean_key
110
+ self._account: LocalAccount = Account.from_key(clean_key)
111
+ elif isinstance(private_key, (bytes, bytearray)):
112
+ self._account = Account.from_key(private_key)
113
+ else:
114
+ raise TypeError(f"private_key must be str or bytes, got {type(private_key).__name__}")
115
+ except Exception as exc:
116
+ raise InvalidPrivateKeyError(f"Failed to load EVM private key: {exc}") from exc
117
+
118
+ self._address = self._account.address.lower()
119
+ self._token_name_map: dict[str, str] = dict(token_name_map) if token_name_map else {}
120
+
121
+ @property
122
+ def address(self) -> str:
123
+ """Canonical lowercase EVM address of the signing wallet."""
124
+ return self._address
125
+
126
+ def __repr__(self) -> str:
127
+ # Redact private key strictly
128
+ return f"<LocalSigner address={self._address}>"
129
+
130
+ def __str__(self) -> str:
131
+ # Redact private key strictly
132
+ return f"LocalSigner(address={self._address})"
133
+
134
+ @classmethod
135
+ def generate(cls) -> LocalSigner:
136
+ """Generate a new random LocalSigner using cryptographically secure randomness."""
137
+ random_key = "0x" + secrets.token_bytes(32).hex()
138
+ return cls(random_key)
139
+
140
+ def resolve_token_name(self, challenge: PaymentChallenge, explicit_name: str | None = None) -> str:
141
+ """Resolve the EIP-712 domain token name for a payment challenge."""
142
+ if explicit_name:
143
+ return explicit_name
144
+ # Check custom token_name_map by contract address
145
+ if challenge.asset in self._token_name_map:
146
+ return self._token_name_map[challenge.asset]
147
+ # Check by symbol
148
+ if challenge.asset_symbol and challenge.asset_symbol.lower() in self._token_name_map:
149
+ return self._token_name_map[challenge.asset_symbol.lower()]
150
+ # Check default stablecoin mappings
151
+ if challenge.asset_symbol and challenge.asset_symbol.lower() in DEFAULT_TOKEN_NAMES:
152
+ return DEFAULT_TOKEN_NAMES[challenge.asset_symbol.lower()]
153
+ # Fallback to symbol or USD Coin
154
+ return challenge.asset_symbol or "USD Coin"
155
+
156
+ def build_eip712_data(
157
+ self,
158
+ challenge: PaymentChallenge,
159
+ nonce: str,
160
+ *,
161
+ token_name: str | None = None,
162
+ token_version: str = "2",
163
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
164
+ """
165
+ Build EIP-712 domain, types, and message dictionaries conforming to EIP-3009.
166
+ """
167
+ chain_id = parse_caip2_chain_id(challenge.network)
168
+ domain_name = self.resolve_token_name(challenge, token_name)
169
+
170
+ domain_data = {
171
+ "name": domain_name,
172
+ "version": token_version,
173
+ "chainId": chain_id,
174
+ "verifyingContract": challenge.asset,
175
+ }
176
+
177
+ types_data = EIP3009_MESSAGE_TYPES
178
+
179
+ # Nonce format: ensure 0x prefix and lowercase
180
+ norm_nonce = nonce if nonce.startswith("0x") else ("0x" + nonce)
181
+
182
+ message_data = {
183
+ "from": self.address,
184
+ "to": challenge.payee,
185
+ "value": challenge.amount_base_units,
186
+ "validAfter": challenge.valid_after,
187
+ "validBefore": challenge.valid_before,
188
+ "nonce": norm_nonce,
189
+ }
190
+
191
+ return domain_data, types_data, message_data
192
+
193
+ def sign_payment_authorization(
194
+ self,
195
+ challenge: PaymentChallenge,
196
+ reservation: ReservationToken,
197
+ *,
198
+ nonce: str | None = None,
199
+ token_name: str | None = None,
200
+ token_version: str = "2",
201
+ ) -> PaymentAuthorization:
202
+ """
203
+ Sign an EIP-712/3009 transfer authorization after verifying the policy reservation.
204
+
205
+ Safety Guardrails:
206
+ 1. Verifies reservation.intent_id == challenge.challenge_id
207
+ 2. Verifies reservation.amount == challenge.amount_base_units
208
+ 3. Verifies reservation.expires_at >= challenge.valid_before
209
+
210
+ Returns:
211
+ PaymentAuthorization with cryptographic signature.
212
+ """
213
+ # Guardrail 1: Policy intent binding
214
+ if reservation.intent_id != challenge.challenge_id:
215
+ raise ReservationMismatchError(
216
+ f"Reservation intent_id ({reservation.intent_id!r}) does not match "
217
+ f"challenge_id ({challenge.challenge_id!r}). Refusing to sign."
218
+ )
219
+
220
+ # Guardrail 2: Amount exact match
221
+ if reservation.amount != challenge.amount_base_units:
222
+ raise ReservationMismatchError(
223
+ f"Reservation amount ({reservation.amount}) does not match "
224
+ f"challenge amount ({challenge.amount_base_units}). Refusing to sign."
225
+ )
226
+
227
+ # Guardrail 3: Expiration safety window
228
+ if reservation.expires_at < challenge.valid_before:
229
+ raise ReservationMismatchError(
230
+ f"Reservation expires_at ({reservation.expires_at}) does not cover "
231
+ f"challenge valid_before ({challenge.valid_before}). Refusing to sign."
232
+ )
233
+
234
+ # Determine nonce
235
+ sign_nonce = nonce if nonce is not None else generate_nonce()
236
+
237
+ # Build EIP-712 data
238
+ domain_data, types_data, message_data = self.build_eip712_data(
239
+ challenge,
240
+ sign_nonce,
241
+ token_name=token_name,
242
+ token_version=token_version,
243
+ )
244
+
245
+ try:
246
+ encoded = encode_typed_data(
247
+ domain_data=domain_data,
248
+ message_types=types_data,
249
+ message_data=message_data,
250
+ )
251
+ signed = self._account.sign_message(encoded)
252
+ except Exception as exc:
253
+ raise SignerError(f"Cryptographic signing failed: {exc}") from exc
254
+
255
+ r_hex = f"0x{signed.r:064x}"
256
+ s_hex = f"0x{signed.s:064x}"
257
+ v_val = signed.v
258
+
259
+ # Serialized hex: 65 bytes (r 32B + s 32B + v 1B)
260
+ serialized_hex = f"{signed.r:064x}{signed.s:064x}{v_val:02x}"
261
+
262
+ sig_data = SignatureData(
263
+ v=v_val,
264
+ r=r_hex,
265
+ s=s_hex,
266
+ serialized=serialized_hex,
267
+ compact="0x" + serialized_hex,
268
+ )
269
+
270
+ return PaymentAuthorization(
271
+ version="2.0",
272
+ challenge_id=challenge.challenge_id,
273
+ payer=self.address,
274
+ scheme=challenge.scheme,
275
+ nonce=sign_nonce,
276
+ valid_after=challenge.valid_after,
277
+ valid_before=challenge.valid_before,
278
+ signature=sig_data,
279
+ )
280
+
281
+ @classmethod
282
+ def verify_authorization(
283
+ cls,
284
+ authorization: PaymentAuthorization,
285
+ challenge: PaymentChallenge,
286
+ *,
287
+ expected_payer: str | None = None,
288
+ token_name: str | None = None,
289
+ token_version: str = "2",
290
+ ) -> bool:
291
+ """
292
+ Verify that a PaymentAuthorization cryptographically matches the PaymentChallenge
293
+ and was signed by the expected payer.
294
+
295
+ Returns:
296
+ True if signature is valid and signer matches expected payer; False otherwise.
297
+ """
298
+ target_payer = (expected_payer or authorization.payer).lower()
299
+ chain_id = parse_caip2_chain_id(challenge.network)
300
+ d_name = token_name or (
301
+ DEFAULT_TOKEN_NAMES.get((challenge.asset_symbol or "").lower()) or challenge.asset_symbol or "USD Coin"
302
+ )
303
+
304
+ domain_data = {
305
+ "name": d_name,
306
+ "version": token_version,
307
+ "chainId": chain_id,
308
+ "verifyingContract": challenge.asset,
309
+ }
310
+
311
+ types_data = EIP3009_MESSAGE_TYPES
312
+
313
+ message_data = {
314
+ "from": authorization.payer,
315
+ "to": challenge.payee,
316
+ "value": challenge.amount_base_units,
317
+ "validAfter": authorization.valid_after,
318
+ "validBefore": authorization.valid_before,
319
+ "nonce": authorization.nonce,
320
+ }
321
+
322
+ try:
323
+ encoded = encode_typed_data(
324
+ domain_data=domain_data,
325
+ message_types=types_data,
326
+ message_data=message_data,
327
+ )
328
+ # Obtain serialized signature bytes
329
+ if isinstance(authorization.signature, SignatureData):
330
+ sig_bytes = bytes.fromhex(authorization.signature.serialized or "")
331
+ elif isinstance(authorization.signature, str):
332
+ sig_clean = authorization.signature.removeprefix("0x")
333
+ sig_bytes = bytes.fromhex(sig_clean)
334
+ else:
335
+ return False
336
+
337
+ recovered = Account.recover_message(encoded, signature=sig_bytes).lower()
338
+ return recovered == target_payer
339
+ except (ValueError, TypeError, KeyError):
340
+ return False