t402 1.9.0__py3-none-any.whl → 1.10.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 (134) hide show
  1. t402/__init__.py +2 -1
  2. t402/a2a/__init__.py +73 -0
  3. t402/a2a/helpers.py +158 -0
  4. t402/a2a/types.py +145 -0
  5. t402/bridge/client.py +13 -5
  6. t402/bridge/constants.py +4 -2
  7. t402/bridge/router.py +1 -1
  8. t402/bridge/scan.py +3 -1
  9. t402/chains.py +268 -1
  10. t402/cli.py +31 -9
  11. t402/common.py +2 -0
  12. t402/cosmos_paywall_template.py +2 -0
  13. t402/django/__init__.py +42 -0
  14. t402/django/middleware.py +596 -0
  15. t402/encoding.py +9 -3
  16. t402/erc4337/accounts.py +56 -51
  17. t402/erc4337/bundlers.py +105 -99
  18. t402/erc4337/paymasters.py +100 -109
  19. t402/erc4337/types.py +39 -26
  20. t402/errors.py +213 -0
  21. t402/evm_paywall_template.py +1 -1
  22. t402/facilitator.py +125 -0
  23. t402/fastapi/middleware.py +1 -3
  24. t402/mcp/constants.py +3 -6
  25. t402/mcp/server.py +501 -84
  26. t402/mcp/web3_utils.py +493 -0
  27. t402/multisig/__init__.py +120 -0
  28. t402/multisig/constants.py +54 -0
  29. t402/multisig/safe.py +441 -0
  30. t402/multisig/signature.py +228 -0
  31. t402/multisig/transaction.py +238 -0
  32. t402/multisig/types.py +108 -0
  33. t402/multisig/utils.py +77 -0
  34. t402/near_paywall_template.py +2 -0
  35. t402/networks.py +34 -1
  36. t402/paywall.py +1 -3
  37. t402/schemes/__init__.py +143 -0
  38. t402/schemes/aptos/__init__.py +70 -0
  39. t402/schemes/aptos/constants.py +349 -0
  40. t402/schemes/aptos/exact_direct/__init__.py +44 -0
  41. t402/schemes/aptos/exact_direct/client.py +202 -0
  42. t402/schemes/aptos/exact_direct/facilitator.py +426 -0
  43. t402/schemes/aptos/exact_direct/server.py +272 -0
  44. t402/schemes/aptos/types.py +237 -0
  45. t402/schemes/cosmos/__init__.py +114 -0
  46. t402/schemes/cosmos/constants.py +211 -0
  47. t402/schemes/cosmos/exact_direct/__init__.py +21 -0
  48. t402/schemes/cosmos/exact_direct/client.py +198 -0
  49. t402/schemes/cosmos/exact_direct/facilitator.py +493 -0
  50. t402/schemes/cosmos/exact_direct/server.py +315 -0
  51. t402/schemes/cosmos/types.py +501 -0
  52. t402/schemes/evm/__init__.py +46 -1
  53. t402/schemes/evm/exact/__init__.py +11 -0
  54. t402/schemes/evm/exact/client.py +3 -1
  55. t402/schemes/evm/exact/facilitator.py +894 -0
  56. t402/schemes/evm/exact/server.py +1 -1
  57. t402/schemes/evm/exact_legacy/__init__.py +38 -0
  58. t402/schemes/evm/exact_legacy/client.py +291 -0
  59. t402/schemes/evm/exact_legacy/facilitator.py +777 -0
  60. t402/schemes/evm/exact_legacy/server.py +231 -0
  61. t402/schemes/evm/upto/__init__.py +12 -0
  62. t402/schemes/evm/upto/client.py +6 -2
  63. t402/schemes/evm/upto/facilitator.py +625 -0
  64. t402/schemes/evm/upto/server.py +243 -0
  65. t402/schemes/evm/upto/types.py +3 -1
  66. t402/schemes/interfaces.py +6 -2
  67. t402/schemes/near/__init__.py +137 -0
  68. t402/schemes/near/constants.py +189 -0
  69. t402/schemes/near/exact_direct/__init__.py +21 -0
  70. t402/schemes/near/exact_direct/client.py +204 -0
  71. t402/schemes/near/exact_direct/facilitator.py +455 -0
  72. t402/schemes/near/exact_direct/server.py +303 -0
  73. t402/schemes/near/types.py +419 -0
  74. t402/schemes/near/upto/__init__.py +54 -0
  75. t402/schemes/near/upto/types.py +272 -0
  76. t402/schemes/polkadot/__init__.py +72 -0
  77. t402/schemes/polkadot/constants.py +155 -0
  78. t402/schemes/polkadot/exact_direct/__init__.py +43 -0
  79. t402/schemes/polkadot/exact_direct/client.py +235 -0
  80. t402/schemes/polkadot/exact_direct/facilitator.py +428 -0
  81. t402/schemes/polkadot/exact_direct/server.py +292 -0
  82. t402/schemes/polkadot/types.py +385 -0
  83. t402/schemes/registry.py +6 -2
  84. t402/schemes/stacks/__init__.py +68 -0
  85. t402/schemes/stacks/constants.py +122 -0
  86. t402/schemes/stacks/exact_direct/__init__.py +43 -0
  87. t402/schemes/stacks/exact_direct/client.py +222 -0
  88. t402/schemes/stacks/exact_direct/facilitator.py +424 -0
  89. t402/schemes/stacks/exact_direct/server.py +292 -0
  90. t402/schemes/stacks/types.py +380 -0
  91. t402/schemes/svm/__init__.py +44 -0
  92. t402/schemes/svm/exact/__init__.py +35 -0
  93. t402/schemes/svm/exact/client.py +23 -0
  94. t402/schemes/svm/exact/facilitator.py +24 -0
  95. t402/schemes/svm/exact/server.py +20 -0
  96. t402/schemes/svm/upto/__init__.py +23 -0
  97. t402/schemes/svm/upto/types.py +193 -0
  98. t402/schemes/tezos/__init__.py +84 -0
  99. t402/schemes/tezos/constants.py +372 -0
  100. t402/schemes/tezos/exact_direct/__init__.py +22 -0
  101. t402/schemes/tezos/exact_direct/client.py +226 -0
  102. t402/schemes/tezos/exact_direct/facilitator.py +491 -0
  103. t402/schemes/tezos/exact_direct/server.py +277 -0
  104. t402/schemes/tezos/types.py +220 -0
  105. t402/schemes/ton/__init__.py +24 -2
  106. t402/schemes/ton/exact/__init__.py +7 -0
  107. t402/schemes/ton/exact/facilitator.py +730 -0
  108. t402/schemes/ton/exact/server.py +1 -1
  109. t402/schemes/ton/upto/__init__.py +31 -0
  110. t402/schemes/ton/upto/types.py +215 -0
  111. t402/schemes/tron/__init__.py +28 -2
  112. t402/schemes/tron/exact/__init__.py +9 -0
  113. t402/schemes/tron/exact/facilitator.py +673 -0
  114. t402/schemes/tron/exact/server.py +1 -1
  115. t402/schemes/tron/upto/__init__.py +30 -0
  116. t402/schemes/tron/upto/types.py +213 -0
  117. t402/stacks_paywall_template.py +2 -0
  118. t402/starlette/__init__.py +38 -0
  119. t402/starlette/middleware.py +522 -0
  120. t402/svm.py +45 -11
  121. t402/svm_paywall_template.py +1 -1
  122. t402/ton.py +6 -2
  123. t402/ton_paywall_template.py +1 -192
  124. t402/tron.py +2 -0
  125. t402/tron_paywall_template.py +2 -0
  126. t402/types.py +103 -3
  127. t402/wdk/chains.py +1 -1
  128. t402/wdk/errors.py +15 -5
  129. t402/wdk/signer.py +11 -2
  130. {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/METADATA +42 -1
  131. t402-1.10.0.dist-info/RECORD +156 -0
  132. t402-1.9.0.dist-info/RECORD +0 -72
  133. {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/WHEEL +0 -0
  134. {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,625 @@
1
+ """EVM Up-To Scheme - Facilitator Implementation.
2
+
3
+ This module provides the facilitator-side implementation of the upto payment
4
+ scheme for EVM networks using EIP-2612 Permit.
5
+
6
+ The facilitator:
7
+ 1. Verifies EIP-2612 Permit signatures by recovering the signer via EIP-712
8
+ 2. Validates permit parameters (value, spender, deadline)
9
+ 3. Settles payments by calling permit() then transferFrom() on the token contract
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ import logging
16
+ from typing import Any, Dict, List, Optional, Union
17
+
18
+ from eth_account.messages import encode_typed_data
19
+ from eth_account import Account
20
+
21
+ from t402.types import (
22
+ PaymentRequirementsV2,
23
+ PaymentPayloadV2,
24
+ VerifyResponse,
25
+ SettleResponse,
26
+ Network,
27
+ )
28
+ from t402.schemes.evm.upto.types import (
29
+ PERMIT_TYPES,
30
+ is_eip2612_payload,
31
+ create_permit_domain,
32
+ )
33
+
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Constants
38
+ SCHEME_UPTO = "upto"
39
+
40
+ # Minimal ERC-20 + EIP-2612 ABI for permit and transferFrom
41
+ ERC20_PERMIT_ABI = [
42
+ {
43
+ "inputs": [
44
+ {"name": "owner", "type": "address"},
45
+ {"name": "spender", "type": "address"},
46
+ {"name": "value", "type": "uint256"},
47
+ {"name": "deadline", "type": "uint256"},
48
+ {"name": "v", "type": "uint8"},
49
+ {"name": "r", "type": "bytes32"},
50
+ {"name": "s", "type": "bytes32"},
51
+ ],
52
+ "name": "permit",
53
+ "outputs": [],
54
+ "stateMutability": "nonpayable",
55
+ "type": "function",
56
+ },
57
+ {
58
+ "inputs": [
59
+ {"name": "from", "type": "address"},
60
+ {"name": "to", "type": "address"},
61
+ {"name": "amount", "type": "uint256"},
62
+ ],
63
+ "name": "transferFrom",
64
+ "outputs": [{"name": "", "type": "bool"}],
65
+ "stateMutability": "nonpayable",
66
+ "type": "function",
67
+ },
68
+ {
69
+ "inputs": [{"name": "owner", "type": "address"}],
70
+ "name": "nonces",
71
+ "outputs": [{"name": "", "type": "uint256"}],
72
+ "stateMutability": "view",
73
+ "type": "function",
74
+ },
75
+ ]
76
+
77
+
78
+ class UptoEvmFacilitatorScheme:
79
+ """Facilitator scheme for EVM upto payments using EIP-2612 Permit.
80
+
81
+ Verifies EIP-2612 Permit signatures off-chain and settles payments
82
+ on-chain by calling permit() followed by transferFrom() on the
83
+ token contract.
84
+
85
+ Example:
86
+ ```python
87
+ from web3 import Web3
88
+
89
+ w3 = Web3(Web3.HTTPProvider("https://mainnet.base.org"))
90
+ facilitator = UptoEvmFacilitatorScheme(
91
+ web3=w3,
92
+ private_key="0x...",
93
+ )
94
+
95
+ # Verify a permit signature
96
+ result = await facilitator.verify(payload, requirements)
97
+ if result.is_valid:
98
+ # Settle the payment
99
+ settlement = await facilitator.settle(payload, requirements)
100
+ ```
101
+ """
102
+
103
+ scheme = SCHEME_UPTO
104
+ caip_family = "eip155:*"
105
+
106
+ def __init__(
107
+ self,
108
+ web3: Optional[Any] = None,
109
+ private_key: Optional[str] = None,
110
+ address: Optional[str] = None,
111
+ ):
112
+ """Initialize the facilitator.
113
+
114
+ Args:
115
+ web3: Web3 instance for on-chain interactions.
116
+ Required for settle(), optional for verify().
117
+ private_key: Private key for signing transactions.
118
+ Required for settle().
119
+ address: Facilitator address (derived from private_key if not provided).
120
+ This is the address that acts as the spender in permits.
121
+ """
122
+ self._web3 = web3
123
+ self._private_key = private_key
124
+
125
+ if address:
126
+ self._address = address
127
+ elif private_key:
128
+ acct = Account.from_key(private_key)
129
+ self._address = acct.address
130
+ else:
131
+ self._address = None
132
+
133
+ @property
134
+ def address(self) -> Optional[str]:
135
+ """Get the facilitator's address (spender in permits)."""
136
+ return self._address
137
+
138
+ def get_extra(self, network: Network) -> Optional[Dict[str, Any]]:
139
+ """Get mechanism-specific extra data for supported kinds.
140
+
141
+ Returns the router/spender address that clients should use
142
+ in their Permit authorization.
143
+
144
+ Args:
145
+ network: The network identifier
146
+
147
+ Returns:
148
+ Dict with routerAddress if address is configured, else None
149
+ """
150
+ if self._address:
151
+ return {"routerAddress": self._address}
152
+ return None
153
+
154
+ def get_signers(self, network: Network) -> List[str]:
155
+ """Get signer addresses for this facilitator.
156
+
157
+ Args:
158
+ network: The network identifier
159
+
160
+ Returns:
161
+ List containing the facilitator address
162
+ """
163
+ if self._address:
164
+ return [self._address]
165
+ return []
166
+
167
+ async def verify(
168
+ self,
169
+ payload: Union[PaymentPayloadV2, Dict[str, Any]],
170
+ requirements: Union[PaymentRequirementsV2, Dict[str, Any]],
171
+ ) -> VerifyResponse:
172
+ """Verify an EIP-2612 Permit payment payload.
173
+
174
+ Validates:
175
+ 1. Payload has correct EIP-2612 structure
176
+ 2. Permit value >= required amount
177
+ 3. Spender matches facilitator address (if configured)
178
+ 4. Deadline is in the future
179
+ 5. Signature recovers to the claimed owner
180
+
181
+ Args:
182
+ payload: The payment payload containing permit signature
183
+ requirements: The payment requirements to verify against
184
+
185
+ Returns:
186
+ VerifyResponse indicating validity and payer address
187
+ """
188
+ try:
189
+ # Extract payload data
190
+ payload_data = self._extract_payload(payload)
191
+ req_data = self._extract_requirements(requirements)
192
+
193
+ # Validate payload structure
194
+ if not is_eip2612_payload(payload_data):
195
+ return VerifyResponse(
196
+ is_valid=False,
197
+ invalid_reason="Invalid EIP-2612 payload structure",
198
+ payer=None,
199
+ )
200
+
201
+ signature = payload_data["signature"]
202
+ authorization = payload_data["authorization"]
203
+
204
+ owner = authorization["owner"]
205
+ spender = authorization["spender"]
206
+ value = int(authorization["value"])
207
+ deadline = int(authorization["deadline"])
208
+ nonce = authorization.get("nonce", 0)
209
+
210
+ # Validate deadline is in the future
211
+ now = int(time.time())
212
+ if deadline <= now:
213
+ return VerifyResponse(
214
+ is_valid=False,
215
+ invalid_reason=f"Permit deadline has passed: {deadline} <= {now}",
216
+ payer=owner,
217
+ )
218
+
219
+ # Validate value >= required amount
220
+ required_amount = int(
221
+ req_data.get("amount")
222
+ or req_data.get("maxAmount")
223
+ or req_data.get("max_amount", "0")
224
+ )
225
+ if value < required_amount:
226
+ return VerifyResponse(
227
+ is_valid=False,
228
+ invalid_reason=(
229
+ f"Permit value {value} is less than required amount "
230
+ f"{required_amount}"
231
+ ),
232
+ payer=owner,
233
+ )
234
+
235
+ # Validate spender matches facilitator address
236
+ if self._address and spender.lower() != self._address.lower():
237
+ return VerifyResponse(
238
+ is_valid=False,
239
+ invalid_reason=(
240
+ f"Permit spender {spender} does not match facilitator "
241
+ f"address {self._address}"
242
+ ),
243
+ payer=owner,
244
+ )
245
+
246
+ # Recover signer from EIP-712 signature
247
+ network = req_data.get("network", "")
248
+ asset = req_data.get("asset", "")
249
+ extra = req_data.get("extra", {})
250
+
251
+ chain_id = self._get_chain_id(network)
252
+ token_name = extra.get("name", "TetherToken")
253
+ token_version = extra.get("version", "1")
254
+
255
+ recovered = self._recover_permit_signer(
256
+ owner=owner,
257
+ spender=spender,
258
+ value=value,
259
+ nonce=nonce,
260
+ deadline=deadline,
261
+ signature=signature,
262
+ chain_id=chain_id,
263
+ token_address=asset,
264
+ token_name=token_name,
265
+ token_version=token_version,
266
+ )
267
+
268
+ if recovered is None:
269
+ return VerifyResponse(
270
+ is_valid=False,
271
+ invalid_reason="Failed to recover signer from permit signature",
272
+ payer=owner,
273
+ )
274
+
275
+ # Validate recovered address matches owner
276
+ if recovered.lower() != owner.lower():
277
+ return VerifyResponse(
278
+ is_valid=False,
279
+ invalid_reason=(
280
+ f"Recovered signer {recovered} does not match "
281
+ f"claimed owner {owner}"
282
+ ),
283
+ payer=owner,
284
+ )
285
+
286
+ return VerifyResponse(
287
+ is_valid=True,
288
+ invalid_reason=None,
289
+ payer=owner,
290
+ )
291
+
292
+ except Exception as e:
293
+ logger.error(f"Permit verification failed: {e}")
294
+ return VerifyResponse(
295
+ is_valid=False,
296
+ invalid_reason=f"Verification error: {str(e)}",
297
+ payer=None,
298
+ )
299
+
300
+ async def settle(
301
+ self,
302
+ payload: Union[PaymentPayloadV2, Dict[str, Any]],
303
+ requirements: Union[PaymentRequirementsV2, Dict[str, Any]],
304
+ settle_amount: Optional[str] = None,
305
+ ) -> SettleResponse:
306
+ """Settle an EIP-2612 Permit payment on-chain.
307
+
308
+ Executes two transactions:
309
+ 1. token.permit(owner, spender, value, deadline, v, r, s)
310
+ 2. token.transferFrom(owner, payTo, settleAmount)
311
+
312
+ The settle_amount can be less than or equal to the permitted value,
313
+ enabling usage-based billing.
314
+
315
+ Args:
316
+ payload: The verified payment payload with permit signature
317
+ requirements: The payment requirements
318
+ settle_amount: Amount to actually settle (defaults to required amount).
319
+ Must be <= permitted value.
320
+
321
+ Returns:
322
+ SettleResponse with transaction hash and status
323
+
324
+ Raises:
325
+ RuntimeError: If web3 or private_key is not configured
326
+ """
327
+ if not self._web3:
328
+ return SettleResponse(
329
+ success=False,
330
+ error_reason="Web3 instance not configured",
331
+ transaction=None,
332
+ network=None,
333
+ payer=None,
334
+ )
335
+
336
+ if not self._private_key:
337
+ return SettleResponse(
338
+ success=False,
339
+ error_reason="Private key not configured for settlement",
340
+ transaction=None,
341
+ network=None,
342
+ payer=None,
343
+ )
344
+
345
+ try:
346
+ # Extract data
347
+ payload_data = self._extract_payload(payload)
348
+ req_data = self._extract_requirements(requirements)
349
+
350
+ signature = payload_data["signature"]
351
+ authorization = payload_data["authorization"]
352
+
353
+ owner = authorization["owner"]
354
+ spender = authorization["spender"]
355
+ value = int(authorization["value"])
356
+ deadline = int(authorization["deadline"])
357
+
358
+ # Get signature components
359
+ v = signature["v"]
360
+ r = signature["r"]
361
+ s = signature["s"]
362
+
363
+ # Convert r, s to bytes32
364
+ r_bytes = bytes.fromhex(r[2:] if r.startswith("0x") else r).rjust(32, b'\x00')
365
+ s_bytes = bytes.fromhex(s[2:] if s.startswith("0x") else s).rjust(32, b'\x00')
366
+
367
+ # Determine settle amount
368
+ network = req_data.get("network", "")
369
+ asset = req_data.get("asset", "")
370
+ pay_to = req_data.get("payTo") or req_data.get("pay_to", "")
371
+
372
+ if settle_amount is not None:
373
+ actual_amount = int(settle_amount)
374
+ else:
375
+ actual_amount = int(
376
+ req_data.get("amount")
377
+ or req_data.get("maxAmount")
378
+ or req_data.get("max_amount", "0")
379
+ )
380
+
381
+ # Validate settle amount doesn't exceed permit value
382
+ if actual_amount > value:
383
+ return SettleResponse(
384
+ success=False,
385
+ error_reason=(
386
+ f"Settle amount {actual_amount} exceeds permitted "
387
+ f"value {value}"
388
+ ),
389
+ transaction=None,
390
+ network=network,
391
+ payer=owner,
392
+ )
393
+
394
+ # Get token contract
395
+ token_contract = self._web3.eth.contract(
396
+ address=self._web3.to_checksum_address(asset),
397
+ abi=ERC20_PERMIT_ABI,
398
+ )
399
+
400
+ # Get account from private key
401
+ account = Account.from_key(self._private_key)
402
+ nonce = self._web3.eth.get_transaction_count(account.address)
403
+
404
+ # Build and send permit transaction
405
+ permit_tx = token_contract.functions.permit(
406
+ self._web3.to_checksum_address(owner),
407
+ self._web3.to_checksum_address(spender),
408
+ value,
409
+ deadline,
410
+ v,
411
+ r_bytes,
412
+ s_bytes,
413
+ ).build_transaction({
414
+ "from": account.address,
415
+ "nonce": nonce,
416
+ "gas": 100000,
417
+ "gasPrice": self._web3.eth.gas_price,
418
+ })
419
+
420
+ signed_permit = self._web3.eth.account.sign_transaction(
421
+ permit_tx, self._private_key
422
+ )
423
+ permit_tx_hash = self._web3.eth.send_raw_transaction(
424
+ signed_permit.raw_transaction
425
+ )
426
+
427
+ # Wait for permit confirmation
428
+ self._web3.eth.wait_for_transaction_receipt(permit_tx_hash)
429
+
430
+ # Build and send transferFrom transaction
431
+ nonce += 1
432
+ transfer_tx = token_contract.functions.transferFrom(
433
+ self._web3.to_checksum_address(owner),
434
+ self._web3.to_checksum_address(pay_to),
435
+ actual_amount,
436
+ ).build_transaction({
437
+ "from": account.address,
438
+ "nonce": nonce,
439
+ "gas": 100000,
440
+ "gasPrice": self._web3.eth.gas_price,
441
+ })
442
+
443
+ signed_transfer = self._web3.eth.account.sign_transaction(
444
+ transfer_tx, self._private_key
445
+ )
446
+ transfer_tx_hash = self._web3.eth.send_raw_transaction(
447
+ signed_transfer.raw_transaction
448
+ )
449
+
450
+ # Wait for transfer confirmation
451
+ receipt = self._web3.eth.wait_for_transaction_receipt(transfer_tx_hash)
452
+
453
+ tx_hash_hex = receipt.transactionHash.hex()
454
+ if not tx_hash_hex.startswith("0x"):
455
+ tx_hash_hex = f"0x{tx_hash_hex}"
456
+
457
+ return SettleResponse(
458
+ success=True,
459
+ error_reason=None,
460
+ transaction=tx_hash_hex,
461
+ network=network,
462
+ payer=owner,
463
+ )
464
+
465
+ except Exception as e:
466
+ logger.error(f"Permit settlement failed: {e}")
467
+ return SettleResponse(
468
+ success=False,
469
+ error_reason=f"Settlement error: {str(e)}",
470
+ transaction=None,
471
+ network=req_data.get("network") if 'req_data' in dir() else None,
472
+ payer=None,
473
+ )
474
+
475
+ def _recover_permit_signer(
476
+ self,
477
+ owner: str,
478
+ spender: str,
479
+ value: int,
480
+ nonce: int,
481
+ deadline: int,
482
+ signature: Dict[str, Any],
483
+ chain_id: int,
484
+ token_address: str,
485
+ token_name: str,
486
+ token_version: str,
487
+ ) -> Optional[str]:
488
+ """Recover the signer address from an EIP-2612 Permit signature.
489
+
490
+ Uses EIP-712 typed data to reconstruct the signing payload and
491
+ recover the signer's address from the v, r, s signature components.
492
+
493
+ Args:
494
+ owner: Token owner address
495
+ spender: Approved spender address
496
+ value: Permitted value
497
+ nonce: Permit nonce
498
+ deadline: Permit deadline
499
+ signature: Dict with v, r, s components
500
+ chain_id: EVM chain ID
501
+ token_address: Token contract address
502
+ token_name: Token name for EIP-712 domain
503
+ token_version: Token version for EIP-712 domain
504
+
505
+ Returns:
506
+ Recovered address as string, or None if recovery fails
507
+ """
508
+ try:
509
+ # Build EIP-712 domain
510
+ domain = create_permit_domain(
511
+ name=token_name,
512
+ version=token_version,
513
+ chain_id=chain_id,
514
+ token_address=token_address,
515
+ )
516
+
517
+ # Build EIP-712 message
518
+ message = {
519
+ "owner": owner,
520
+ "spender": spender,
521
+ "value": value,
522
+ "nonce": nonce,
523
+ "deadline": deadline,
524
+ }
525
+
526
+ # Reconstruct the signature bytes
527
+ v = signature["v"]
528
+ r = signature["r"]
529
+ s = signature["s"]
530
+
531
+ # Normalize r and s to hex without 0x prefix
532
+ r_hex = r[2:] if isinstance(r, str) and r.startswith("0x") else str(r)
533
+ s_hex = s[2:] if isinstance(s, str) and s.startswith("0x") else str(s)
534
+
535
+ # Pad to 64 hex chars (32 bytes)
536
+ r_hex = r_hex.zfill(64)
537
+ s_hex = s_hex.zfill(64)
538
+
539
+ # Build combined signature: r (32 bytes) + s (32 bytes) + v (1 byte)
540
+ v_hex = format(v, "02x")
541
+ sig_hex = f"0x{r_hex}{s_hex}{v_hex}"
542
+
543
+ # Encode EIP-712 typed data (full_types kept for reference)
544
+ _full_types = {
545
+ "EIP712Domain": [
546
+ {"name": "name", "type": "string"},
547
+ {"name": "version", "type": "string"},
548
+ {"name": "chainId", "type": "uint256"},
549
+ {"name": "verifyingContract", "type": "address"},
550
+ ],
551
+ "Permit": PERMIT_TYPES["Permit"],
552
+ }
553
+
554
+ signable = encode_typed_data(
555
+ domain_data=domain,
556
+ message_types={"Permit": PERMIT_TYPES["Permit"]},
557
+ message_data=message,
558
+ )
559
+
560
+ # Recover signer
561
+ recovered = Account.recover_message(
562
+ signable,
563
+ signature=bytes.fromhex(sig_hex[2:]),
564
+ )
565
+
566
+ return recovered
567
+
568
+ except Exception as e:
569
+ logger.debug(f"Permit signer recovery failed: {e}")
570
+ return None
571
+
572
+ def _extract_payload(self, payload: Union[PaymentPayloadV2, Dict[str, Any]]) -> Dict[str, Any]:
573
+ """Extract payload data as a dict.
574
+
575
+ Handles both PaymentPayloadV2 models (where the inner payload is
576
+ in the 'payload' field) and plain dicts.
577
+
578
+ Args:
579
+ payload: Payment payload (model or dict)
580
+
581
+ Returns:
582
+ Dict containing signature and authorization data
583
+ """
584
+ if hasattr(payload, "model_dump"):
585
+ data = payload.model_dump(by_alias=True)
586
+ return data.get("payload", data)
587
+ elif isinstance(payload, dict):
588
+ return payload.get("payload", payload)
589
+ return dict(payload)
590
+
591
+ def _extract_requirements(
592
+ self, requirements: Union[PaymentRequirementsV2, Dict[str, Any]]
593
+ ) -> Dict[str, Any]:
594
+ """Extract requirements data as a dict.
595
+
596
+ Args:
597
+ requirements: Payment requirements (model or dict)
598
+
599
+ Returns:
600
+ Dict containing requirement fields
601
+ """
602
+ if hasattr(requirements, "model_dump"):
603
+ return requirements.model_dump(by_alias=True)
604
+ return dict(requirements)
605
+
606
+ def _get_chain_id(self, network: str) -> int:
607
+ """Get chain ID from network identifier.
608
+
609
+ Args:
610
+ network: Network identifier (CAIP-2 or legacy format)
611
+
612
+ Returns:
613
+ Chain ID as integer
614
+
615
+ Raises:
616
+ ValueError: If the network format is unrecognized
617
+ """
618
+ if network.startswith("eip155:"):
619
+ return int(network.split(":")[1])
620
+
621
+ from t402.chains import get_chain_id
622
+ try:
623
+ return int(get_chain_id(network))
624
+ except (KeyError, ValueError):
625
+ raise ValueError(f"Unknown network: {network}")