t402 1.7.1__py3-none-any.whl → 1.9.1__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 (102) hide show
  1. t402/__init__.py +2 -1
  2. t402/bridge/client.py +13 -5
  3. t402/bridge/constants.py +3 -1
  4. t402/bridge/router.py +1 -1
  5. t402/bridge/scan.py +3 -1
  6. t402/chains.py +268 -1
  7. t402/cli.py +31 -9
  8. t402/common.py +2 -0
  9. t402/cosmos_paywall_template.py +2 -0
  10. t402/encoding.py +9 -3
  11. t402/erc4337/accounts.py +56 -51
  12. t402/erc4337/bundlers.py +105 -99
  13. t402/erc4337/paymasters.py +100 -109
  14. t402/erc4337/types.py +39 -26
  15. t402/evm_paywall_template.py +1 -1
  16. t402/fastapi/middleware.py +1 -3
  17. t402/mcp/server.py +79 -46
  18. t402/near_paywall_template.py +2 -0
  19. t402/networks.py +34 -1
  20. t402/paywall.py +1 -3
  21. t402/schemes/__init__.py +164 -1
  22. t402/schemes/aptos/__init__.py +70 -0
  23. t402/schemes/aptos/constants.py +349 -0
  24. t402/schemes/aptos/exact_direct/__init__.py +44 -0
  25. t402/schemes/aptos/exact_direct/client.py +202 -0
  26. t402/schemes/aptos/exact_direct/facilitator.py +426 -0
  27. t402/schemes/aptos/exact_direct/server.py +272 -0
  28. t402/schemes/aptos/types.py +237 -0
  29. t402/schemes/evm/__init__.py +67 -1
  30. t402/schemes/evm/exact/__init__.py +11 -0
  31. t402/schemes/evm/exact/client.py +3 -1
  32. t402/schemes/evm/exact/facilitator.py +894 -0
  33. t402/schemes/evm/exact/server.py +1 -1
  34. t402/schemes/evm/exact_legacy/__init__.py +38 -0
  35. t402/schemes/evm/exact_legacy/client.py +291 -0
  36. t402/schemes/evm/exact_legacy/facilitator.py +777 -0
  37. t402/schemes/evm/exact_legacy/server.py +231 -0
  38. t402/schemes/evm/upto/__init__.py +70 -0
  39. t402/schemes/evm/upto/client.py +244 -0
  40. t402/schemes/evm/upto/facilitator.py +625 -0
  41. t402/schemes/evm/upto/server.py +243 -0
  42. t402/schemes/evm/upto/types.py +307 -0
  43. t402/schemes/interfaces.py +6 -2
  44. t402/schemes/near/__init__.py +112 -0
  45. t402/schemes/near/constants.py +189 -0
  46. t402/schemes/near/exact_direct/__init__.py +21 -0
  47. t402/schemes/near/exact_direct/client.py +204 -0
  48. t402/schemes/near/exact_direct/facilitator.py +455 -0
  49. t402/schemes/near/exact_direct/server.py +303 -0
  50. t402/schemes/near/types.py +419 -0
  51. t402/schemes/polkadot/__init__.py +72 -0
  52. t402/schemes/polkadot/constants.py +155 -0
  53. t402/schemes/polkadot/exact_direct/__init__.py +43 -0
  54. t402/schemes/polkadot/exact_direct/client.py +235 -0
  55. t402/schemes/polkadot/exact_direct/facilitator.py +428 -0
  56. t402/schemes/polkadot/exact_direct/server.py +292 -0
  57. t402/schemes/polkadot/types.py +385 -0
  58. t402/schemes/registry.py +6 -2
  59. t402/schemes/stacks/__init__.py +68 -0
  60. t402/schemes/stacks/constants.py +122 -0
  61. t402/schemes/stacks/exact_direct/__init__.py +43 -0
  62. t402/schemes/stacks/exact_direct/client.py +222 -0
  63. t402/schemes/stacks/exact_direct/facilitator.py +424 -0
  64. t402/schemes/stacks/exact_direct/server.py +292 -0
  65. t402/schemes/stacks/types.py +380 -0
  66. t402/schemes/svm/__init__.py +29 -0
  67. t402/schemes/svm/exact/__init__.py +35 -0
  68. t402/schemes/svm/exact/client.py +23 -0
  69. t402/schemes/svm/exact/facilitator.py +24 -0
  70. t402/schemes/svm/exact/server.py +20 -0
  71. t402/schemes/tezos/__init__.py +84 -0
  72. t402/schemes/tezos/constants.py +372 -0
  73. t402/schemes/tezos/exact_direct/__init__.py +22 -0
  74. t402/schemes/tezos/exact_direct/client.py +226 -0
  75. t402/schemes/tezos/exact_direct/facilitator.py +491 -0
  76. t402/schemes/tezos/exact_direct/server.py +277 -0
  77. t402/schemes/tezos/types.py +220 -0
  78. t402/schemes/ton/__init__.py +9 -2
  79. t402/schemes/ton/exact/__init__.py +7 -0
  80. t402/schemes/ton/exact/facilitator.py +730 -0
  81. t402/schemes/ton/exact/server.py +1 -1
  82. t402/schemes/tron/__init__.py +11 -2
  83. t402/schemes/tron/exact/__init__.py +9 -0
  84. t402/schemes/tron/exact/facilitator.py +673 -0
  85. t402/schemes/tron/exact/server.py +1 -1
  86. t402/schemes/upto/__init__.py +80 -0
  87. t402/schemes/upto/types.py +376 -0
  88. t402/stacks_paywall_template.py +2 -0
  89. t402/svm.py +45 -11
  90. t402/svm_paywall_template.py +1 -1
  91. t402/ton.py +5 -1
  92. t402/ton_paywall_template.py +1 -192
  93. t402/tron.py +2 -0
  94. t402/tron_paywall_template.py +2 -0
  95. t402/types.py +4 -2
  96. t402/wdk/errors.py +15 -5
  97. t402/wdk/signer.py +11 -2
  98. {t402-1.7.1.dist-info → t402-1.9.1.dist-info}/METADATA +42 -1
  99. t402-1.9.1.dist-info/RECORD +125 -0
  100. t402-1.7.1.dist-info/RECORD +0 -67
  101. {t402-1.7.1.dist-info → t402-1.9.1.dist-info}/WHEEL +0 -0
  102. {t402-1.7.1.dist-info → t402-1.9.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,894 @@
1
+ """EVM Exact Scheme - Facilitator Implementation.
2
+
3
+ This module provides the facilitator-side implementation of the exact payment
4
+ scheme for EVM networks using EIP-3009 TransferWithAuthorization.
5
+
6
+ The facilitator:
7
+ 1. Verifies EIP-3009 signatures off-chain by checking authorization metadata,
8
+ EIP-712 typed data signature recovery, balance, and nonce usage
9
+ 2. Settles payments by calling transferWithAuthorization on the token contract
10
+ 3. Waits for transaction confirmation via receipt polling
11
+
12
+ EIP-3009 TransferWithAuthorization allows gasless token transfers where:
13
+ - The token holder signs an off-chain authorization (EIP-712 typed data)
14
+ - A facilitator submits the authorization on-chain
15
+ - The token contract verifies the signature and executes the transfer
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import time
22
+ from typing import Any, Dict, List, Optional, Protocol, Union, runtime_checkable
23
+
24
+ from t402.types import (
25
+ PaymentRequirementsV2,
26
+ PaymentPayloadV2,
27
+ VerifyResponse,
28
+ SettleResponse,
29
+ Network,
30
+ )
31
+ from t402.chains import KNOWN_TOKENS
32
+
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Constants
37
+ SCHEME_EXACT = "exact"
38
+ CAIP_FAMILY = "eip155:*"
39
+
40
+ # Minimum time buffer (seconds) before validBefore deadline
41
+ MIN_VALIDITY_BUFFER = 30
42
+
43
+ # Default timeout for transaction confirmation (milliseconds)
44
+ DEFAULT_CONFIRMATION_TIMEOUT = 60000
45
+
46
+
47
+ @runtime_checkable
48
+ class FacilitatorEvmSigner(Protocol):
49
+ """Protocol for EVM facilitator signer operations.
50
+
51
+ Implementations should provide address retrieval, EIP-3009 signature
52
+ verification, token transfer execution, transaction confirmation,
53
+ and balance checking capabilities.
54
+
55
+ The signer abstracts all blockchain interactions so the facilitator
56
+ scheme logic remains chain-agnostic within EVM.
57
+
58
+ Example implementation:
59
+ ```python
60
+ from web3 import Web3
61
+
62
+ class MyEvmFacilitatorSigner:
63
+ def __init__(self, web3: Web3, private_key: str, addresses: dict):
64
+ self._web3 = web3
65
+ self._account = web3.eth.account.from_key(private_key)
66
+ self._addresses = addresses
67
+
68
+ def get_addresses(self, network: str) -> List[str]:
69
+ return self._addresses.get(network, [self._account.address])
70
+
71
+ async def verify_eip3009_signature(
72
+ self,
73
+ from_address: str,
74
+ to_address: str,
75
+ value: str,
76
+ valid_after: str,
77
+ valid_before: str,
78
+ nonce: str,
79
+ signature: str,
80
+ token_address: str,
81
+ chain_id: int,
82
+ token_name: str,
83
+ token_version: str,
84
+ ) -> EvmVerifyResult:
85
+ # Recover signer from EIP-712 typed data signature
86
+ # and compare with from_address
87
+ ...
88
+
89
+ async def execute_transfer(
90
+ self,
91
+ from_address: str,
92
+ to_address: str,
93
+ value: str,
94
+ valid_after: str,
95
+ valid_before: str,
96
+ nonce: str,
97
+ signature: str,
98
+ token_address: str,
99
+ network: str,
100
+ ) -> str:
101
+ # Call transferWithAuthorization on token contract
102
+ ...
103
+
104
+ async def wait_for_confirmation(
105
+ self,
106
+ tx_hash: str,
107
+ network: str,
108
+ timeout_ms: int = 60000,
109
+ ) -> EvmTransactionConfirmation:
110
+ # Wait for transaction receipt
111
+ ...
112
+
113
+ async def get_balance(
114
+ self,
115
+ owner_address: str,
116
+ token_address: str,
117
+ network: str,
118
+ ) -> str:
119
+ # Get ERC-20 token balance
120
+ ...
121
+ ```
122
+ """
123
+
124
+ def get_addresses(self, network: str) -> List[str]:
125
+ """Return all facilitator addresses for the given network.
126
+
127
+ Enables multi-address support for load balancing and key rotation.
128
+
129
+ Args:
130
+ network: Network identifier (CAIP-2 format, e.g., "eip155:8453")
131
+
132
+ Returns:
133
+ List of Ethereum addresses (checksummed or lowercase hex)
134
+ """
135
+ ...
136
+
137
+ async def verify_eip3009_signature(
138
+ self,
139
+ from_address: str,
140
+ to_address: str,
141
+ value: str,
142
+ valid_after: str,
143
+ valid_before: str,
144
+ nonce: str,
145
+ signature: str,
146
+ token_address: str,
147
+ chain_id: int,
148
+ token_name: str,
149
+ token_version: str,
150
+ ) -> "EvmVerifyResult":
151
+ """Verify an EIP-3009 TransferWithAuthorization signature.
152
+
153
+ Reconstructs the EIP-712 typed data hash and recovers the signer
154
+ address from the signature, comparing it with the expected from_address.
155
+
156
+ Supports both EOA (ecrecover) and smart wallet (EIP-1271) signatures.
157
+
158
+ Args:
159
+ from_address: Expected signer/payer address
160
+ to_address: Recipient address
161
+ value: Transfer amount in token's smallest unit
162
+ valid_after: Unix timestamp after which authorization is valid
163
+ valid_before: Unix timestamp before which authorization is valid
164
+ nonce: 32-byte nonce as hex string (0x-prefixed)
165
+ signature: ECDSA signature as hex string (0x-prefixed, 65 bytes)
166
+ token_address: ERC-20 token contract address
167
+ chain_id: EVM chain ID
168
+ token_name: Token name for EIP-712 domain (e.g., "TetherToken")
169
+ token_version: Token version for EIP-712 domain (e.g., "1")
170
+
171
+ Returns:
172
+ EvmVerifyResult indicating validity and recovered address
173
+ """
174
+ ...
175
+
176
+ async def execute_transfer(
177
+ self,
178
+ from_address: str,
179
+ to_address: str,
180
+ value: str,
181
+ valid_after: str,
182
+ valid_before: str,
183
+ nonce: str,
184
+ signature: str,
185
+ token_address: str,
186
+ network: str,
187
+ ) -> str:
188
+ """Execute transferWithAuthorization on the token contract.
189
+
190
+ Calls the EIP-3009 transferWithAuthorization function with the
191
+ provided authorization parameters and signature.
192
+
193
+ Args:
194
+ from_address: Payer address (token holder)
195
+ to_address: Recipient address
196
+ value: Transfer amount in token's smallest unit
197
+ valid_after: Unix timestamp (as string)
198
+ valid_before: Unix timestamp (as string)
199
+ nonce: 32-byte nonce as hex string (0x-prefixed)
200
+ signature: ECDSA signature as hex string (0x-prefixed)
201
+ token_address: ERC-20 token contract address
202
+ network: Network identifier (CAIP-2 format)
203
+
204
+ Returns:
205
+ Transaction hash as hex string (0x-prefixed)
206
+
207
+ Raises:
208
+ Exception: If transaction submission fails
209
+ """
210
+ ...
211
+
212
+ async def wait_for_confirmation(
213
+ self,
214
+ tx_hash: str,
215
+ network: str,
216
+ timeout_ms: int = 60000,
217
+ ) -> "EvmTransactionConfirmation":
218
+ """Wait for a transaction to be confirmed (mined and successful).
219
+
220
+ Polls for the transaction receipt until confirmed or timeout.
221
+
222
+ Args:
223
+ tx_hash: Transaction hash to monitor
224
+ network: Network identifier
225
+ timeout_ms: Maximum wait time in milliseconds
226
+
227
+ Returns:
228
+ EvmTransactionConfirmation with status, block number, and hash
229
+ """
230
+ ...
231
+
232
+ async def get_balance(
233
+ self,
234
+ owner_address: str,
235
+ token_address: str,
236
+ network: str,
237
+ ) -> str:
238
+ """Get the ERC-20 token balance for an address.
239
+
240
+ Calls balanceOf on the token contract.
241
+
242
+ Args:
243
+ owner_address: Address to check balance for
244
+ token_address: ERC-20 token contract address
245
+ network: Network identifier
246
+
247
+ Returns:
248
+ Balance in token's smallest unit as string
249
+ """
250
+ ...
251
+
252
+
253
+ class EvmVerifyResult:
254
+ """Result of EIP-3009 signature verification.
255
+
256
+ Attributes:
257
+ valid: Whether the signature is valid
258
+ recovered_address: Address recovered from the signature (if successful)
259
+ reason: Reason for failure (if invalid)
260
+ """
261
+
262
+ def __init__(
263
+ self,
264
+ valid: bool,
265
+ recovered_address: Optional[str] = None,
266
+ reason: Optional[str] = None,
267
+ ):
268
+ self.valid = valid
269
+ self.recovered_address = recovered_address
270
+ self.reason = reason
271
+
272
+
273
+ class EvmTransactionConfirmation:
274
+ """Result of waiting for transaction confirmation.
275
+
276
+ Attributes:
277
+ success: Whether the transaction was successfully confirmed
278
+ tx_hash: The confirmed transaction hash
279
+ block_number: Block number where the transaction was mined
280
+ error: Error message if confirmation failed
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ success: bool,
286
+ tx_hash: Optional[str] = None,
287
+ block_number: Optional[int] = None,
288
+ error: Optional[str] = None,
289
+ ):
290
+ self.success = success
291
+ self.tx_hash = tx_hash
292
+ self.block_number = block_number
293
+ self.error = error
294
+
295
+
296
+ class ExactEvmFacilitatorScheme:
297
+ """Facilitator scheme for EVM exact payments using EIP-3009.
298
+
299
+ Verifies EIP-3009 TransferWithAuthorization signatures and settles
300
+ payments by calling transferWithAuthorization on the token contract.
301
+
302
+ The verification process checks:
303
+ 1. Scheme and network validity
304
+ 2. Payload structure (signature + authorization fields)
305
+ 3. EIP-3009 signature recovery against from_address
306
+ 4. Deadline validity (validBefore with 30-second buffer)
307
+ 5. Valid-after constraint (validAfter <= current time)
308
+ 6. Token balance sufficiency
309
+ 7. Amount >= required amount
310
+ 8. Recipient matches payTo
311
+ 9. Token address matches required asset
312
+
313
+ The settlement process:
314
+ 1. Re-verifies the payment
315
+ 2. Calls transferWithAuthorization on the token contract
316
+ 3. Waits for transaction confirmation
317
+
318
+ Example:
319
+ ```python
320
+ facilitator = ExactEvmFacilitatorScheme(signer=my_evm_signer)
321
+
322
+ # Verify a payment
323
+ result = await facilitator.verify(payload, requirements)
324
+ if result.is_valid:
325
+ # Settle the payment on-chain
326
+ settlement = await facilitator.settle(payload, requirements)
327
+ if settlement.success:
328
+ print(f"Settled: {settlement.transaction}")
329
+ ```
330
+ """
331
+
332
+ scheme = SCHEME_EXACT
333
+ caip_family = CAIP_FAMILY
334
+
335
+ def __init__(self, signer: FacilitatorEvmSigner):
336
+ """Initialize the EVM facilitator scheme.
337
+
338
+ Args:
339
+ signer: EVM facilitator signer for signature verification,
340
+ balance checking, and transaction execution.
341
+ """
342
+ self._signer = signer
343
+
344
+ def get_extra(self, network: Network) -> Optional[Dict[str, Any]]:
345
+ """Get mechanism-specific extra data for supported kinds.
346
+
347
+ Returns asset metadata (default asset address, name, version, decimals)
348
+ for the specified EVM network.
349
+
350
+ Args:
351
+ network: The network identifier (e.g., "eip155:8453")
352
+
353
+ Returns:
354
+ Dict with asset metadata if network is supported, else None
355
+ """
356
+ chain_id_str = self._get_chain_id_str(network)
357
+ if chain_id_str is None:
358
+ return None
359
+
360
+ tokens = KNOWN_TOKENS.get(chain_id_str)
361
+ if not tokens or len(tokens) == 0:
362
+ return None
363
+
364
+ token = tokens[0]
365
+ return {
366
+ "defaultAsset": token["address"],
367
+ "name": token["name"],
368
+ "version": token["version"],
369
+ "decimals": token["decimals"],
370
+ }
371
+
372
+ def get_signers(self, network: Network) -> List[str]:
373
+ """Get signer addresses for this facilitator on the given network.
374
+
375
+ Args:
376
+ network: The network identifier
377
+
378
+ Returns:
379
+ List of facilitator Ethereum addresses
380
+ """
381
+ return self._signer.get_addresses(network)
382
+
383
+ async def verify(
384
+ self,
385
+ payload: Union[PaymentPayloadV2, Dict[str, Any]],
386
+ requirements: Union[PaymentRequirementsV2, Dict[str, Any]],
387
+ ) -> VerifyResponse:
388
+ """Verify an EVM EIP-3009 payment payload.
389
+
390
+ Performs comprehensive validation of the EIP-3009 authorization
391
+ including signature recovery, balance checks, and constraint validation.
392
+
393
+ Args:
394
+ payload: The payment payload containing signature and authorization
395
+ requirements: The payment requirements to verify against
396
+
397
+ Returns:
398
+ VerifyResponse indicating validity and payer address
399
+ """
400
+ try:
401
+ # Extract data from payload and requirements
402
+ payload_data = self._extract_payload(payload)
403
+ req_data = self._extract_requirements(requirements)
404
+
405
+ network = req_data.get("network", "")
406
+ scheme = req_data.get("scheme", "")
407
+
408
+ # Step 1: Validate scheme
409
+ if scheme != SCHEME_EXACT:
410
+ return VerifyResponse(
411
+ is_valid=False,
412
+ invalid_reason="unsupported_scheme",
413
+ payer=None,
414
+ )
415
+
416
+ # Step 2: Validate network (must be eip155:*)
417
+ if not self._is_valid_network(network):
418
+ return VerifyResponse(
419
+ is_valid=False,
420
+ invalid_reason="unsupported_network",
421
+ payer=None,
422
+ )
423
+
424
+ # Step 3: Parse EIP-3009 payload
425
+ eip3009_payload = self._parse_eip3009_payload(payload_data)
426
+ if eip3009_payload is None:
427
+ return VerifyResponse(
428
+ is_valid=False,
429
+ invalid_reason="invalid_payload",
430
+ payer=None,
431
+ )
432
+
433
+ authorization = eip3009_payload["authorization"]
434
+ signature = eip3009_payload["signature"]
435
+ payer = authorization["from"]
436
+
437
+ # Step 4: Get chain ID and token info
438
+ chain_id = self._get_chain_id(network)
439
+ if chain_id is None:
440
+ return VerifyResponse(
441
+ is_valid=False,
442
+ invalid_reason="unsupported_network",
443
+ payer=payer,
444
+ )
445
+
446
+ asset = req_data.get("asset", "")
447
+ token_name, token_version = self._get_token_info(network, asset)
448
+
449
+ # Step 5: Verify EIP-3009 signature
450
+ try:
451
+ verify_result = await self._signer.verify_eip3009_signature(
452
+ from_address=payer,
453
+ to_address=authorization["to"],
454
+ value=authorization["value"],
455
+ valid_after=authorization["validAfter"],
456
+ valid_before=authorization["validBefore"],
457
+ nonce=authorization["nonce"],
458
+ signature=signature,
459
+ token_address=asset,
460
+ chain_id=chain_id,
461
+ token_name=token_name,
462
+ token_version=token_version,
463
+ )
464
+ except Exception as e:
465
+ logger.error(f"Signature verification error: {e}")
466
+ return VerifyResponse(
467
+ is_valid=False,
468
+ invalid_reason=f"signature_verification_error: {str(e)}",
469
+ payer=payer,
470
+ )
471
+
472
+ if not verify_result.valid:
473
+ reason = verify_result.reason or "invalid_signature"
474
+ return VerifyResponse(
475
+ is_valid=False,
476
+ invalid_reason=f"invalid_signature: {reason}",
477
+ payer=payer,
478
+ )
479
+
480
+ # Step 6: Check validBefore deadline (with buffer)
481
+ now = int(time.time())
482
+ try:
483
+ valid_before = int(authorization["validBefore"])
484
+ except (ValueError, TypeError):
485
+ return VerifyResponse(
486
+ is_valid=False,
487
+ invalid_reason="invalid_valid_before",
488
+ payer=payer,
489
+ )
490
+
491
+ if valid_before < now + MIN_VALIDITY_BUFFER:
492
+ return VerifyResponse(
493
+ is_valid=False,
494
+ invalid_reason="authorization_expired",
495
+ payer=payer,
496
+ )
497
+
498
+ # Step 7: Check validAfter constraint
499
+ try:
500
+ valid_after = int(authorization["validAfter"])
501
+ except (ValueError, TypeError):
502
+ return VerifyResponse(
503
+ is_valid=False,
504
+ invalid_reason="invalid_valid_after",
505
+ payer=payer,
506
+ )
507
+
508
+ if valid_after > now:
509
+ return VerifyResponse(
510
+ is_valid=False,
511
+ invalid_reason="authorization_not_yet_valid",
512
+ payer=payer,
513
+ )
514
+
515
+ # Step 8: Verify token balance
516
+ try:
517
+ balance_str = await self._signer.get_balance(
518
+ owner_address=payer,
519
+ token_address=asset,
520
+ network=network,
521
+ )
522
+ balance = int(balance_str)
523
+ except (ValueError, TypeError) as e:
524
+ logger.error(f"Balance check failed: {e}")
525
+ return VerifyResponse(
526
+ is_valid=False,
527
+ invalid_reason="balance_check_failed",
528
+ payer=payer,
529
+ )
530
+
531
+ required_amount_str = req_data.get("amount", "0")
532
+ try:
533
+ required_amount = int(required_amount_str)
534
+ except (ValueError, TypeError):
535
+ return VerifyResponse(
536
+ is_valid=False,
537
+ invalid_reason="invalid_required_amount",
538
+ payer=payer,
539
+ )
540
+
541
+ if balance < required_amount:
542
+ return VerifyResponse(
543
+ is_valid=False,
544
+ invalid_reason="insufficient_balance",
545
+ payer=payer,
546
+ )
547
+
548
+ # Step 9: Verify amount sufficiency
549
+ try:
550
+ payload_value = int(authorization["value"])
551
+ except (ValueError, TypeError):
552
+ return VerifyResponse(
553
+ is_valid=False,
554
+ invalid_reason="invalid_payload_amount",
555
+ payer=payer,
556
+ )
557
+
558
+ if payload_value < required_amount:
559
+ return VerifyResponse(
560
+ is_valid=False,
561
+ invalid_reason="insufficient_amount",
562
+ payer=payer,
563
+ )
564
+
565
+ # Step 10: Verify recipient matches payTo
566
+ pay_to = req_data.get("payTo", "")
567
+ auth_to = authorization.get("to", "")
568
+ if not self._addresses_equal(auth_to, pay_to):
569
+ return VerifyResponse(
570
+ is_valid=False,
571
+ invalid_reason="recipient_mismatch",
572
+ payer=payer,
573
+ )
574
+
575
+ # All checks passed
576
+ return VerifyResponse(
577
+ is_valid=True,
578
+ invalid_reason=None,
579
+ payer=payer,
580
+ )
581
+
582
+ except Exception as e:
583
+ logger.error(f"EVM verification failed: {e}")
584
+ return VerifyResponse(
585
+ is_valid=False,
586
+ invalid_reason=f"verification_error: {str(e)}",
587
+ payer=None,
588
+ )
589
+
590
+ async def settle(
591
+ self,
592
+ payload: Union[PaymentPayloadV2, Dict[str, Any]],
593
+ requirements: Union[PaymentRequirementsV2, Dict[str, Any]],
594
+ ) -> SettleResponse:
595
+ """Settle an EVM EIP-3009 payment on-chain.
596
+
597
+ Verifies the payment first, then calls transferWithAuthorization
598
+ on the token contract and waits for transaction confirmation.
599
+
600
+ Args:
601
+ payload: The verified payment payload with signature and authorization
602
+ requirements: The payment requirements
603
+
604
+ Returns:
605
+ SettleResponse with transaction hash and status
606
+ """
607
+ req_data = self._extract_requirements(requirements)
608
+ network = req_data.get("network", "")
609
+
610
+ # Step 1: Verify the payment first
611
+ verify_result = await self.verify(payload, requirements)
612
+
613
+ if not verify_result.is_valid:
614
+ return SettleResponse(
615
+ success=False,
616
+ error_reason=verify_result.invalid_reason,
617
+ transaction=None,
618
+ network=network,
619
+ payer=verify_result.payer,
620
+ )
621
+
622
+ # Step 2: Extract payload data for on-chain execution
623
+ try:
624
+ payload_data = self._extract_payload(payload)
625
+ eip3009_payload = self._parse_eip3009_payload(payload_data)
626
+
627
+ if eip3009_payload is None:
628
+ return SettleResponse(
629
+ success=False,
630
+ error_reason="invalid_payload",
631
+ transaction=None,
632
+ network=network,
633
+ payer=verify_result.payer,
634
+ )
635
+
636
+ authorization = eip3009_payload["authorization"]
637
+ signature = eip3009_payload["signature"]
638
+ payer = authorization["from"]
639
+
640
+ except Exception as e:
641
+ logger.error(f"Payload extraction failed: {e}")
642
+ return SettleResponse(
643
+ success=False,
644
+ error_reason=f"invalid_payload: {str(e)}",
645
+ transaction=None,
646
+ network=network,
647
+ payer=verify_result.payer,
648
+ )
649
+
650
+ # Step 3: Execute transferWithAuthorization on-chain
651
+ asset = req_data.get("asset", "")
652
+ try:
653
+ tx_hash = await self._signer.execute_transfer(
654
+ from_address=payer,
655
+ to_address=authorization["to"],
656
+ value=authorization["value"],
657
+ valid_after=authorization["validAfter"],
658
+ valid_before=authorization["validBefore"],
659
+ nonce=authorization["nonce"],
660
+ signature=signature,
661
+ token_address=asset,
662
+ network=network,
663
+ )
664
+ except Exception as e:
665
+ logger.error(f"Transaction execution failed: {e}")
666
+ return SettleResponse(
667
+ success=False,
668
+ error_reason=f"transaction_failed: {str(e)}",
669
+ transaction=None,
670
+ network=network,
671
+ payer=payer,
672
+ )
673
+
674
+ # Step 4: Wait for transaction confirmation
675
+ try:
676
+ confirmation = await self._signer.wait_for_confirmation(
677
+ tx_hash=tx_hash,
678
+ network=network,
679
+ timeout_ms=DEFAULT_CONFIRMATION_TIMEOUT,
680
+ )
681
+ except Exception as e:
682
+ logger.error(f"Transaction confirmation failed: {e}")
683
+ return SettleResponse(
684
+ success=False,
685
+ error_reason=f"confirmation_failed: {str(e)}",
686
+ transaction=tx_hash,
687
+ network=network,
688
+ payer=payer,
689
+ )
690
+
691
+ if not confirmation.success:
692
+ return SettleResponse(
693
+ success=False,
694
+ error_reason=confirmation.error or "transaction_reverted",
695
+ transaction=tx_hash,
696
+ network=network,
697
+ payer=payer,
698
+ )
699
+
700
+ # Use confirmed tx hash if available (should match)
701
+ final_tx_hash = confirmation.tx_hash if confirmation.tx_hash else tx_hash
702
+
703
+ return SettleResponse(
704
+ success=True,
705
+ error_reason=None,
706
+ transaction=final_tx_hash,
707
+ network=network,
708
+ payer=payer,
709
+ )
710
+
711
+ def _extract_payload(
712
+ self, payload: Union[PaymentPayloadV2, Dict[str, Any]]
713
+ ) -> Dict[str, Any]:
714
+ """Extract payload data as a dict.
715
+
716
+ Handles both PaymentPayloadV2 models (where the inner payload is
717
+ in the 'payload' field) and plain dicts.
718
+
719
+ Args:
720
+ payload: Payment payload (model or dict)
721
+
722
+ Returns:
723
+ Dict containing signature and authorization data
724
+ """
725
+ if hasattr(payload, "model_dump"):
726
+ data = payload.model_dump(by_alias=True)
727
+ return data.get("payload", data)
728
+ elif isinstance(payload, dict):
729
+ return payload.get("payload", payload)
730
+ return dict(payload)
731
+
732
+ def _extract_requirements(
733
+ self, requirements: Union[PaymentRequirementsV2, Dict[str, Any]]
734
+ ) -> Dict[str, Any]:
735
+ """Extract requirements data as a dict.
736
+
737
+ Args:
738
+ requirements: Payment requirements (model or dict)
739
+
740
+ Returns:
741
+ Dict containing requirement fields
742
+ """
743
+ if hasattr(requirements, "model_dump"):
744
+ return requirements.model_dump(by_alias=True)
745
+ return dict(requirements)
746
+
747
+ def _parse_eip3009_payload(
748
+ self, payload_data: Dict[str, Any]
749
+ ) -> Optional[Dict[str, Any]]:
750
+ """Parse and validate EIP-3009 payload fields.
751
+
752
+ Extracts signature and authorization from the payload data,
753
+ normalizing field names for internal use.
754
+
755
+ The EIP-3009 authorization contains:
756
+ - from: payer address
757
+ - to: recipient address
758
+ - value: amount in token units
759
+ - validAfter: earliest validity timestamp
760
+ - validBefore: latest validity timestamp
761
+ - nonce: random 32-byte nonce (hex)
762
+
763
+ Args:
764
+ payload_data: Raw payload dict
765
+
766
+ Returns:
767
+ Normalized dict with signature and authorization fields,
768
+ or None if required fields are missing.
769
+ """
770
+ signature = payload_data.get("signature", "")
771
+ if not signature:
772
+ return None
773
+
774
+ auth_data = payload_data.get("authorization")
775
+ if not auth_data:
776
+ return None
777
+
778
+ # Extract and normalize authorization fields
779
+ from_addr = auth_data.get("from", "")
780
+ to_addr = auth_data.get("to", "")
781
+ value = auth_data.get("value", "0")
782
+ valid_after = auth_data.get("validAfter", auth_data.get("valid_after", "0"))
783
+ valid_before = auth_data.get("validBefore", auth_data.get("valid_before", "0"))
784
+ nonce = auth_data.get("nonce", "")
785
+
786
+ if not from_addr:
787
+ return None
788
+
789
+ if not signature:
790
+ return None
791
+
792
+ return {
793
+ "signature": signature,
794
+ "authorization": {
795
+ "from": from_addr,
796
+ "to": to_addr,
797
+ "value": str(value),
798
+ "validAfter": str(valid_after),
799
+ "validBefore": str(valid_before),
800
+ "nonce": str(nonce),
801
+ },
802
+ }
803
+
804
+ def _is_valid_network(self, network: str) -> bool:
805
+ """Check if the network is a valid EVM network.
806
+
807
+ Validates that the network follows the eip155:* CAIP-2 format
808
+ and has a valid numeric chain ID.
809
+
810
+ Args:
811
+ network: Network identifier
812
+
813
+ Returns:
814
+ True if the network is a valid EVM network
815
+ """
816
+ if not network.startswith("eip155:"):
817
+ return False
818
+
819
+ try:
820
+ chain_id_str = network.split(":")[1]
821
+ chain_id = int(chain_id_str)
822
+ return chain_id > 0
823
+ except (IndexError, ValueError):
824
+ return False
825
+
826
+ def _get_chain_id(self, network: str) -> Optional[int]:
827
+ """Get the chain ID from a network identifier.
828
+
829
+ Args:
830
+ network: Network identifier (CAIP-2 format, e.g., "eip155:8453")
831
+
832
+ Returns:
833
+ Chain ID as integer, or None if invalid
834
+ """
835
+ if not network.startswith("eip155:"):
836
+ return None
837
+
838
+ try:
839
+ return int(network.split(":")[1])
840
+ except (IndexError, ValueError):
841
+ return None
842
+
843
+ def _get_chain_id_str(self, network: str) -> Optional[str]:
844
+ """Get the chain ID as string for KNOWN_TOKENS lookup.
845
+
846
+ Args:
847
+ network: Network identifier (CAIP-2 format)
848
+
849
+ Returns:
850
+ Chain ID as string, or None if invalid
851
+ """
852
+ chain_id = self._get_chain_id(network)
853
+ if chain_id is None:
854
+ return None
855
+ return str(chain_id)
856
+
857
+ def _get_token_info(self, network: str, asset: str) -> tuple:
858
+ """Get token name and version for EIP-712 domain.
859
+
860
+ Looks up the token in KNOWN_TOKENS by chain ID and address.
861
+ Falls back to defaults if not found.
862
+
863
+ Args:
864
+ network: Network identifier
865
+ asset: Token contract address
866
+
867
+ Returns:
868
+ Tuple of (token_name, token_version)
869
+ """
870
+ chain_id_str = self._get_chain_id_str(network)
871
+ if chain_id_str and chain_id_str in KNOWN_TOKENS:
872
+ for token in KNOWN_TOKENS[chain_id_str]:
873
+ if self._addresses_equal(token["address"], asset):
874
+ return token["name"], token["version"]
875
+
876
+ # Default fallback
877
+ return "TetherToken", "1"
878
+
879
+ def _addresses_equal(self, addr1: str, addr2: str) -> bool:
880
+ """Compare two Ethereum addresses case-insensitively.
881
+
882
+ Ethereum addresses are hex-encoded and should be compared
883
+ case-insensitively (checksummed vs. lowercase).
884
+
885
+ Args:
886
+ addr1: First address
887
+ addr2: Second address
888
+
889
+ Returns:
890
+ True if addresses are equal (case-insensitive)
891
+ """
892
+ if not addr1 or not addr2:
893
+ return False
894
+ return addr1.lower() == addr2.lower()