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,372 @@
1
+ """Tezos Blockchain Constants for T402 Protocol.
2
+
3
+ This module defines constants, token information, and network configurations
4
+ for the Tezos blockchain mechanism in the T402 protocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Optional
10
+
11
+
12
+ # Scheme identifier
13
+ SCHEME_EXACT_DIRECT = "exact-direct"
14
+
15
+ # CAIP-2 network identifiers (derived from genesis block hash prefix)
16
+ TEZOS_MAINNET = "tezos:NetXdQprcVkpaWU"
17
+ TEZOS_GHOSTNET = "tezos:NetXnHfVqm9iesp"
18
+
19
+ # RPC endpoints
20
+ TEZOS_MAINNET_RPC = "https://mainnet.api.tez.ie"
21
+ TEZOS_GHOSTNET_RPC = "https://ghostnet.tezos.marigold.dev"
22
+
23
+ # Indexer API endpoints (TzKT)
24
+ TEZOS_MAINNET_INDEXER = "https://api.tzkt.io"
25
+ TEZOS_GHOSTNET_INDEXER = "https://api.ghostnet.tzkt.io"
26
+
27
+ # FA2 token standard (TZIP-12)
28
+ FA2_TRANSFER_ENTRYPOINT = "transfer"
29
+
30
+ # USDt on Tezos Mainnet
31
+ USDT_MAINNET_CONTRACT = "KT1XnTn74bUtxHfDtBmm2bGZAQfhPbvKWR8o"
32
+ USDT_MAINNET_TOKEN_ID = 0
33
+ USDT_DECIMALS = 6
34
+
35
+ # Address length for Tezos addresses (tz1/tz2/tz3/KT1)
36
+ TEZOS_ADDRESS_LENGTH = 36
37
+
38
+ # Operation hash length (starts with 'o')
39
+ TEZOS_OP_HASH_LENGTH = 51
40
+
41
+ # Valid address prefixes
42
+ VALID_ADDRESS_PREFIXES = ("tz1", "tz2", "tz3", "KT1")
43
+
44
+ # Base58 character set (no 0, O, I, l)
45
+ BASE58_CHARS = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
46
+
47
+
48
+ class TokenInfo:
49
+ """Information about a Tezos FA2 token.
50
+
51
+ Attributes:
52
+ contract_address: The FA2 contract address (KT1...)
53
+ token_id: The token ID within the FA2 contract
54
+ symbol: Token symbol (e.g., "USDt")
55
+ name: Full token name (e.g., "Tether USD")
56
+ decimals: Number of decimal places
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ contract_address: str,
62
+ token_id: int,
63
+ symbol: str,
64
+ name: str,
65
+ decimals: int,
66
+ ):
67
+ self.contract_address = contract_address
68
+ self.token_id = token_id
69
+ self.symbol = symbol
70
+ self.name = name
71
+ self.decimals = decimals
72
+
73
+
74
+ class NetworkConfig:
75
+ """Configuration for a Tezos network.
76
+
77
+ Attributes:
78
+ name: Human-readable network name
79
+ rpc_url: Tezos RPC endpoint URL
80
+ indexer_url: TzKT indexer API URL
81
+ default_token: Default token for price conversion (may be None)
82
+ is_testnet: Whether this is a testnet
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ name: str,
88
+ rpc_url: str,
89
+ indexer_url: str,
90
+ default_token: Optional[TokenInfo] = None,
91
+ is_testnet: bool = False,
92
+ ):
93
+ self.name = name
94
+ self.rpc_url = rpc_url
95
+ self.indexer_url = indexer_url
96
+ self.default_token = default_token
97
+ self.is_testnet = is_testnet
98
+
99
+
100
+ # Token definitions
101
+ USDT_MAINNET = TokenInfo(
102
+ contract_address=USDT_MAINNET_CONTRACT,
103
+ token_id=USDT_MAINNET_TOKEN_ID,
104
+ symbol="USDt",
105
+ name="Tether USD",
106
+ decimals=USDT_DECIMALS,
107
+ )
108
+
109
+ # Network configurations
110
+ NETWORK_CONFIGS: Dict[str, NetworkConfig] = {
111
+ TEZOS_MAINNET: NetworkConfig(
112
+ name="Tezos Mainnet",
113
+ rpc_url=TEZOS_MAINNET_RPC,
114
+ indexer_url=TEZOS_MAINNET_INDEXER,
115
+ default_token=USDT_MAINNET,
116
+ is_testnet=False,
117
+ ),
118
+ TEZOS_GHOSTNET: NetworkConfig(
119
+ name="Tezos Ghostnet",
120
+ rpc_url=TEZOS_GHOSTNET_RPC,
121
+ indexer_url=TEZOS_GHOSTNET_INDEXER,
122
+ default_token=None, # No USDT on testnet
123
+ is_testnet=True,
124
+ ),
125
+ }
126
+
127
+ # Token registry indexed by network and symbol
128
+ TOKEN_REGISTRY: Dict[str, Dict[str, TokenInfo]] = {
129
+ TEZOS_MAINNET: {
130
+ "USDt": USDT_MAINNET,
131
+ },
132
+ TEZOS_GHOSTNET: {},
133
+ }
134
+
135
+
136
+ def get_network_config(network: str) -> Optional[NetworkConfig]:
137
+ """Get the configuration for a Tezos network.
138
+
139
+ Args:
140
+ network: CAIP-2 network identifier (e.g., "tezos:NetXdQprcVkpaWU")
141
+
142
+ Returns:
143
+ NetworkConfig if found, None otherwise
144
+ """
145
+ return NETWORK_CONFIGS.get(network)
146
+
147
+
148
+ def get_token_info(network: str, symbol: str) -> Optional[TokenInfo]:
149
+ """Get token information by network and symbol.
150
+
151
+ Args:
152
+ network: CAIP-2 network identifier
153
+ symbol: Token symbol (e.g., "USDt")
154
+
155
+ Returns:
156
+ TokenInfo if found, None otherwise
157
+ """
158
+ tokens = TOKEN_REGISTRY.get(network, {})
159
+ return tokens.get(symbol)
160
+
161
+
162
+ def get_token_by_contract(
163
+ network: str, contract_address: str, token_id: int
164
+ ) -> Optional[TokenInfo]:
165
+ """Get token information by contract address and token ID.
166
+
167
+ Args:
168
+ network: CAIP-2 network identifier
169
+ contract_address: FA2 contract address
170
+ token_id: Token ID within the contract
171
+
172
+ Returns:
173
+ TokenInfo if found, None otherwise
174
+ """
175
+ tokens = TOKEN_REGISTRY.get(network, {})
176
+ for token in tokens.values():
177
+ if token.contract_address == contract_address and token.token_id == token_id:
178
+ return token
179
+ return None
180
+
181
+
182
+ def is_tezos_network(network: str) -> bool:
183
+ """Check if a network identifier belongs to the Tezos namespace.
184
+
185
+ Args:
186
+ network: Network identifier string
187
+
188
+ Returns:
189
+ True if the network starts with "tezos:"
190
+ """
191
+ return network.startswith("tezos:")
192
+
193
+
194
+ def is_valid_address(address: str) -> bool:
195
+ """Validate a Tezos address format.
196
+
197
+ Valid addresses:
198
+ - Implicit accounts: tz1, tz2, tz3 (36 characters)
199
+ - Contract accounts: KT1 (36 characters)
200
+
201
+ All characters must be valid Base58 characters.
202
+
203
+ Args:
204
+ address: The address to validate
205
+
206
+ Returns:
207
+ True if the address format is valid
208
+ """
209
+ if not address:
210
+ return False
211
+ if not address.startswith(VALID_ADDRESS_PREFIXES):
212
+ return False
213
+ if len(address) != TEZOS_ADDRESS_LENGTH:
214
+ return False
215
+ # Check all characters are valid base58
216
+ for char in address:
217
+ if char not in BASE58_CHARS:
218
+ return False
219
+ return True
220
+
221
+
222
+ def is_valid_operation_hash(op_hash: str) -> bool:
223
+ """Validate a Tezos operation hash format.
224
+
225
+ Operation hashes start with 'o' and are 51 characters of Base58.
226
+
227
+ Args:
228
+ op_hash: The operation hash to validate
229
+
230
+ Returns:
231
+ True if the operation hash format is valid
232
+ """
233
+ if not op_hash:
234
+ return False
235
+ if not op_hash.startswith("o"):
236
+ return False
237
+ if len(op_hash) != TEZOS_OP_HASH_LENGTH:
238
+ return False
239
+ # Check all characters are valid base58
240
+ for char in op_hash:
241
+ if char not in BASE58_CHARS:
242
+ return False
243
+ return True
244
+
245
+
246
+ def create_asset_identifier(network: str, contract_address: str, token_id: int) -> str:
247
+ """Create a CAIP-19 asset identifier for a Tezos FA2 token.
248
+
249
+ Format: tezos:{chainRef}/fa2:{contractAddress}/{tokenId}
250
+
251
+ Args:
252
+ network: CAIP-2 network identifier (e.g., "tezos:NetXdQprcVkpaWU")
253
+ contract_address: FA2 contract address (e.g., "KT1XnTn74bUtxHfDtBmm2bGZAQfhPbvKWR8o")
254
+ token_id: Token ID within the FA2 contract
255
+
256
+ Returns:
257
+ CAIP-19 asset identifier string
258
+ """
259
+ return f"{network}/fa2:{contract_address}/{token_id}"
260
+
261
+
262
+ def parse_asset_identifier(asset: str) -> Dict[str, Any]:
263
+ """Parse a CAIP-19 asset identifier for Tezos FA2 tokens.
264
+
265
+ Supports two formats:
266
+ - CAIP-19: tezos:{chainRef}/fa2:{contractAddress}/{tokenId}
267
+ - Simple: {contractAddress}/{tokenId} or {contractAddress} (tokenId defaults to 0)
268
+
269
+ Args:
270
+ asset: The asset identifier string
271
+
272
+ Returns:
273
+ Dict with "contract_address" (str) and "token_id" (int)
274
+
275
+ Raises:
276
+ ValueError: If the asset format is unrecognized
277
+ """
278
+ if not asset:
279
+ raise ValueError("Asset identifier is empty")
280
+
281
+ # Try CAIP-19 format: tezos:{chainRef}/fa2:{contract}/{tokenId}
282
+ if asset.startswith("tezos:"):
283
+ parts = asset.split("/")
284
+ if len(parts) == 3 and parts[1].startswith("fa2:"):
285
+ contract_address = parts[1][4:] # Remove "fa2:" prefix
286
+ try:
287
+ token_id = int(parts[2])
288
+ except ValueError:
289
+ raise ValueError(f"Invalid token ID in asset: {asset}")
290
+ if not contract_address.startswith("KT1") or len(contract_address) != 36:
291
+ raise ValueError(f"Invalid contract address in asset: {asset}")
292
+ return {"contract_address": contract_address, "token_id": token_id}
293
+
294
+ # Try simple format: KT1.../tokenId or KT1...
295
+ if asset.startswith("KT1"):
296
+ parts = asset.split("/")
297
+ contract_address = parts[0]
298
+ if len(contract_address) != 36:
299
+ raise ValueError(f"Invalid contract address in asset: {asset}")
300
+ token_id = 0
301
+ if len(parts) == 2:
302
+ try:
303
+ token_id = int(parts[1])
304
+ except ValueError:
305
+ raise ValueError(f"Invalid token ID in asset: {asset}")
306
+ elif len(parts) > 2:
307
+ raise ValueError(f"Unrecognized asset format: {asset}")
308
+ return {"contract_address": contract_address, "token_id": token_id}
309
+
310
+ raise ValueError(
311
+ f"Unrecognized asset format: {asset} "
312
+ f"(expected tezos:{{chainRef}}/fa2:{{contract}}/{{tokenId}} or KT1...)"
313
+ )
314
+
315
+
316
+ def decimal_to_atomic(amount: float, decimals: int) -> str:
317
+ """Convert a decimal amount to atomic units string.
318
+
319
+ Args:
320
+ amount: Decimal amount (e.g., 1.50)
321
+ decimals: Number of decimal places for the token
322
+
323
+ Returns:
324
+ Atomic amount as string (e.g., "1500000" for 6 decimals)
325
+ """
326
+
327
+ multiplier = 10**decimals
328
+ atomic = int(round(amount * multiplier))
329
+ return str(atomic)
330
+
331
+
332
+ def parse_decimal_to_atomic(amount: str, decimals: int) -> str:
333
+ """Convert a decimal string amount to atomic units.
334
+
335
+ Args:
336
+ amount: Decimal string (e.g., "1.50")
337
+ decimals: Number of decimal places for the token
338
+
339
+ Returns:
340
+ Atomic amount string (e.g., "1500000" for 6 decimals)
341
+
342
+ Raises:
343
+ ValueError: If the amount format is invalid
344
+ """
345
+ parts = amount.split(".")
346
+
347
+ integer_part = parts[0]
348
+ fractional_part = ""
349
+
350
+ if len(parts) == 2:
351
+ fractional_part = parts[1]
352
+ elif len(parts) > 2:
353
+ raise ValueError(f"Invalid amount format: {amount}")
354
+
355
+ # Pad or truncate fractional part to match decimals
356
+ if len(fractional_part) > decimals:
357
+ fractional_part = fractional_part[:decimals]
358
+ else:
359
+ fractional_part = fractional_part + "0" * (decimals - len(fractional_part))
360
+
361
+ # Combine and parse as integer
362
+ combined = integer_part + fractional_part
363
+
364
+ # Remove leading zeros but keep at least one digit
365
+ combined = combined.lstrip("0") or "0"
366
+
367
+ try:
368
+ result = int(combined)
369
+ except ValueError:
370
+ raise ValueError(f"Failed to parse amount: {amount}")
371
+
372
+ return str(result)
@@ -0,0 +1,22 @@
1
+ """Tezos Exact-Direct Payment Scheme.
2
+
3
+ This package provides the exact-direct payment scheme implementation for Tezos.
4
+ In this scheme, the client executes the FA2 transfer directly on-chain and
5
+ provides the operation hash as proof of payment. The facilitator then verifies
6
+ the operation on-chain.
7
+
8
+ Components:
9
+ - Client: Executes FA2 transfer and returns opHash
10
+ - Server: Parses prices and enhances requirements with CAIP-19 asset info
11
+ - Facilitator: Verifies operation status and transfer details on-chain
12
+ """
13
+
14
+ from t402.schemes.tezos.exact_direct.client import ExactDirectTezosClient
15
+ from t402.schemes.tezos.exact_direct.server import ExactDirectTezosServer
16
+ from t402.schemes.tezos.exact_direct.facilitator import ExactDirectTezosFacilitator
17
+
18
+ __all__ = [
19
+ "ExactDirectTezosClient",
20
+ "ExactDirectTezosServer",
21
+ "ExactDirectTezosFacilitator",
22
+ ]
@@ -0,0 +1,226 @@
1
+ """Tezos Exact-Direct Scheme - Client Implementation.
2
+
3
+ This module provides the client-side implementation of the exact-direct payment
4
+ scheme for Tezos using FA2 token transfers.
5
+
6
+ In the exact-direct scheme, the client directly executes the FA2 transfer on-chain
7
+ and provides the operation hash as proof of payment. This differs from off-chain
8
+ authorization schemes where the facilitator executes the transfer.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any, Dict, Union
15
+
16
+ from t402.types import (
17
+ PaymentRequirementsV2,
18
+ T402_VERSION_V1,
19
+ T402_VERSION_V2,
20
+ )
21
+ from t402.schemes.tezos.constants import (
22
+ SCHEME_EXACT_DIRECT,
23
+ is_tezos_network,
24
+ is_valid_address,
25
+ parse_asset_identifier,
26
+ )
27
+ from t402.schemes.tezos.types import (
28
+ ClientTezosSigner,
29
+ ExactDirectPayload,
30
+ )
31
+
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class ExactDirectTezosClient:
37
+ """Client scheme for Tezos exact-direct payments using FA2 transfers.
38
+
39
+ This scheme executes FA2 token transfers directly on-chain and provides
40
+ the operation hash as proof of payment. The facilitator verifies the
41
+ operation status and transfer details.
42
+
43
+ Example:
44
+ ```python
45
+ from t402.schemes.tezos import ExactDirectTezosClient
46
+
47
+ class MyTezosSigner:
48
+ def address(self) -> str:
49
+ return "tz1..."
50
+
51
+ async def transfer_fa2(
52
+ self, contract, token_id, to, amount, network
53
+ ) -> str:
54
+ # Execute FA2 transfer
55
+ return "oo7bHf..." # operation hash
56
+
57
+ signer = MyTezosSigner()
58
+ client = ExactDirectTezosClient(signer=signer)
59
+
60
+ payload = await client.create_payment_payload(
61
+ t402_version=2,
62
+ requirements={
63
+ "scheme": "exact-direct",
64
+ "network": "tezos:NetXdQprcVkpaWU",
65
+ "asset": "tezos:NetXdQprcVkpaWU/fa2:KT1XnTn74bUtxHfDtBmm2bGZAQfhPbvKWR8o/0",
66
+ "amount": "1000000",
67
+ "payTo": "tz1...",
68
+ "maxTimeoutSeconds": 300,
69
+ },
70
+ )
71
+ ```
72
+ """
73
+
74
+ scheme = SCHEME_EXACT_DIRECT
75
+ caip_family = "tezos:*"
76
+
77
+ def __init__(self, signer: ClientTezosSigner):
78
+ """Initialize the Tezos exact-direct client.
79
+
80
+ Args:
81
+ signer: A Tezos signer implementing the ClientTezosSigner protocol.
82
+ Must provide address() and transfer_fa2() methods.
83
+ """
84
+ self._signer = signer
85
+
86
+ @property
87
+ def address(self) -> str:
88
+ """Get the signer's Tezos address."""
89
+ return self._signer.address()
90
+
91
+ async def create_payment_payload(
92
+ self,
93
+ t402_version: int,
94
+ requirements: Union[PaymentRequirementsV2, Dict[str, Any]],
95
+ ) -> Dict[str, Any]:
96
+ """Execute FA2 transfer and create payment payload with operation hash.
97
+
98
+ This method:
99
+ 1. Validates the payment requirements
100
+ 2. Parses the CAIP-19 asset identifier
101
+ 3. Executes the FA2 transfer on-chain via the signer
102
+ 4. Returns a payload containing the operation hash as proof
103
+
104
+ Args:
105
+ t402_version: Protocol version (1 or 2)
106
+ requirements: Payment requirements specifying amount, network, asset, payTo
107
+
108
+ Returns:
109
+ Payment payload dict containing the operation hash and transfer metadata
110
+
111
+ Raises:
112
+ ValueError: If requirements are invalid (wrong scheme, network, address, etc.)
113
+ Exception: If the FA2 transfer fails
114
+ """
115
+ # Convert to dict for easier access
116
+ if hasattr(requirements, "model_dump"):
117
+ req = requirements.model_dump(by_alias=True)
118
+ else:
119
+ req = dict(requirements)
120
+
121
+ # Validate requirements
122
+ self._validate_requirements(req)
123
+
124
+ # Extract fields
125
+ network = req.get("network", "")
126
+ asset = req.get("asset", "")
127
+ amount = req.get("amount", "0")
128
+ pay_to = req.get("payTo") or req.get("pay_to", "")
129
+
130
+ # Parse asset to get contract address and token ID
131
+ asset_info = parse_asset_identifier(asset)
132
+ contract_address = asset_info["contract_address"]
133
+ token_id = asset_info["token_id"]
134
+
135
+ # Parse amount as integer
136
+ amount_int = int(amount)
137
+
138
+ # Execute FA2 transfer on-chain
139
+ logger.debug(
140
+ "Executing FA2 transfer: contract=%s, token_id=%d, to=%s, amount=%d",
141
+ contract_address,
142
+ token_id,
143
+ pay_to,
144
+ amount_int,
145
+ )
146
+ op_hash = await self._signer.transfer_fa2(
147
+ contract=contract_address,
148
+ token_id=token_id,
149
+ to=pay_to,
150
+ amount=amount_int,
151
+ network=network,
152
+ )
153
+
154
+ # Build the payload
155
+ payload_data = ExactDirectPayload(
156
+ op_hash=op_hash,
157
+ from_=self._signer.address(),
158
+ to=pay_to,
159
+ amount=amount,
160
+ contract_address=contract_address,
161
+ token_id=token_id,
162
+ )
163
+
164
+ if t402_version == T402_VERSION_V1:
165
+ return {
166
+ "t402Version": T402_VERSION_V1,
167
+ "scheme": self.scheme,
168
+ "network": network,
169
+ "payload": payload_data.to_map(),
170
+ }
171
+
172
+ # V2 format
173
+ return {
174
+ "t402Version": T402_VERSION_V2,
175
+ "payload": payload_data.to_map(),
176
+ }
177
+
178
+ def _validate_requirements(self, req: Dict[str, Any]) -> None:
179
+ """Validate payment requirements for the exact-direct scheme.
180
+
181
+ Args:
182
+ req: Requirements dict
183
+
184
+ Raises:
185
+ ValueError: If any validation check fails
186
+ """
187
+ # Check scheme
188
+ scheme = req.get("scheme", "")
189
+ if scheme and scheme != SCHEME_EXACT_DIRECT:
190
+ raise ValueError(
191
+ f"Invalid scheme: expected {SCHEME_EXACT_DIRECT}, got {scheme}"
192
+ )
193
+
194
+ # Check network is Tezos
195
+ network = req.get("network", "")
196
+ if not is_tezos_network(network):
197
+ raise ValueError(f"Invalid network: {network} (expected tezos:*)")
198
+
199
+ # Check payTo address
200
+ pay_to = req.get("payTo") or req.get("pay_to", "")
201
+ if not pay_to:
202
+ raise ValueError("PayTo address is required")
203
+ if not is_valid_address(pay_to):
204
+ raise ValueError(f"Invalid payTo address: {pay_to}")
205
+
206
+ # Check amount
207
+ amount = req.get("amount", "")
208
+ if not amount:
209
+ raise ValueError("Amount is required")
210
+ try:
211
+ amount_int = int(amount)
212
+ if amount_int <= 0:
213
+ raise ValueError(
214
+ f"Invalid amount: {amount} (must be a positive integer)"
215
+ )
216
+ except ValueError:
217
+ raise ValueError(
218
+ f"Invalid amount: {amount} (must be a positive integer string)"
219
+ )
220
+
221
+ # Check asset
222
+ asset = req.get("asset", "")
223
+ if not asset:
224
+ raise ValueError("Asset is required")
225
+ # This will raise ValueError if invalid
226
+ parse_asset_identifier(asset)