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,292 @@
1
+ """Stacks Exact-Direct Scheme - Server Implementation.
2
+
3
+ This module provides the server-side implementation of the exact-direct
4
+ payment scheme for Stacks (Bitcoin L2) networks.
5
+
6
+ The server:
7
+ 1. Parses user-friendly prices (e.g., "$0.10") into atomic amounts
8
+ 2. Enhances payment requirements with Stacks-specific metadata
9
+ (contract address, decimals, network name, CAIP-19 asset identifier)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Dict, List, Optional, Union
15
+
16
+ from t402.types import (
17
+ PaymentRequirementsV2,
18
+ Network,
19
+ )
20
+ from t402.schemes.interfaces import AssetAmount, SupportedKindDict
21
+ from t402.schemes.stacks.constants import (
22
+ SCHEME_EXACT_DIRECT,
23
+ NetworkConfig,
24
+ TokenInfo,
25
+ get_network_config,
26
+ is_stacks_network,
27
+ )
28
+ from t402.schemes.stacks.types import create_asset_identifier
29
+
30
+
31
+ class ExactDirectStacksServerScheme:
32
+ """Server scheme for Stacks exact-direct payments.
33
+
34
+ Handles parsing user-friendly prices into atomic amounts and
35
+ enhancing payment requirements with Stacks-specific metadata.
36
+
37
+ Example:
38
+ ```python
39
+ scheme = ExactDirectStacksServerScheme()
40
+
41
+ # Parse price to atomic units
42
+ asset_amount = await scheme.parse_price("$0.10", "stacks:1")
43
+ # Returns: {"amount": "100000", "asset": "stacks:1/token:SP3Y2...", "extra": {...}}
44
+
45
+ # Enhance requirements with metadata
46
+ enhanced = await scheme.enhance_requirements(
47
+ requirements,
48
+ supported_kind,
49
+ facilitator_extensions,
50
+ )
51
+ ```
52
+ """
53
+
54
+ scheme = SCHEME_EXACT_DIRECT
55
+ caip_family = "stacks:*"
56
+
57
+ def __init__(self, preferred_token: Optional[str] = None):
58
+ """Initialize the Stacks server scheme.
59
+
60
+ Args:
61
+ preferred_token: Override the default token symbol (e.g., "sUSDC")
62
+ """
63
+ self._preferred_token = preferred_token
64
+
65
+ async def parse_price(
66
+ self,
67
+ price: Union[str, int, float, Dict[str, Any]],
68
+ network: Network,
69
+ ) -> AssetAmount:
70
+ """Parse a user-friendly price to atomic amount and asset.
71
+
72
+ Supports:
73
+ - String with $ prefix: "$0.10" -> 100000 (6 decimals)
74
+ - String without prefix: "0.10" -> 100000
75
+ - Integer/float: 0.10 -> 100000
76
+ - Dict (pre-parsed): {"amount": "100000", "asset": "..."}
77
+
78
+ Args:
79
+ price: User-friendly price value
80
+ network: CAIP-2 network identifier
81
+
82
+ Returns:
83
+ AssetAmount dict with amount, asset, and extra metadata
84
+
85
+ Raises:
86
+ ValueError: If the network is unsupported or price is invalid
87
+ """
88
+ # Validate network
89
+ if not is_stacks_network(network):
90
+ raise ValueError(f"Invalid Stacks network: {network}")
91
+
92
+ network_config = get_network_config(network)
93
+
94
+ # Handle dict (already in pre-parsed format)
95
+ if isinstance(price, dict):
96
+ amount_str = str(price.get("amount", "0"))
97
+ asset = price.get("asset", "")
98
+ if not asset:
99
+ asset = create_asset_identifier(
100
+ network, network_config.default_token.contract_address
101
+ )
102
+ extra = price.get("extra", {})
103
+ return {
104
+ "amount": amount_str,
105
+ "asset": asset,
106
+ "extra": extra,
107
+ }
108
+
109
+ # Get default token for this network
110
+ token = self._get_token(network, network_config)
111
+
112
+ # Parse price string/number to decimal
113
+ decimal_amount = self._parse_money_to_decimal(price)
114
+
115
+ # Convert to atomic units
116
+ atomic_amount = self._to_atomic_units(decimal_amount, token.decimals)
117
+
118
+ # Build asset identifier
119
+ asset_identifier = create_asset_identifier(network, token.contract_address)
120
+
121
+ # Build extra metadata
122
+ extra = {
123
+ "symbol": token.symbol,
124
+ "name": token.name,
125
+ "decimals": token.decimals,
126
+ "contractAddress": token.contract_address,
127
+ }
128
+
129
+ return {
130
+ "amount": str(atomic_amount),
131
+ "asset": asset_identifier,
132
+ "extra": extra,
133
+ }
134
+
135
+ async def enhance_requirements(
136
+ self,
137
+ requirements: Union[PaymentRequirementsV2, Dict[str, Any]],
138
+ supported_kind: SupportedKindDict,
139
+ facilitator_extensions: List[str],
140
+ ) -> Union[PaymentRequirementsV2, Dict[str, Any]]:
141
+ """Enhance payment requirements with Stacks-specific metadata.
142
+
143
+ Adds asset metadata (contract address, symbol, decimals, network name)
144
+ and the CAIP-19 asset identifier to the requirements.
145
+
146
+ Args:
147
+ requirements: Base payment requirements
148
+ supported_kind: Matched SupportedKind from facilitator
149
+ facilitator_extensions: Extensions supported by facilitator
150
+
151
+ Returns:
152
+ Enhanced requirements with Stacks metadata in extra
153
+
154
+ Raises:
155
+ ValueError: If the network is not recognized
156
+ """
157
+ # Convert to dict for modification
158
+ if hasattr(requirements, "model_dump"):
159
+ req = requirements.model_dump(by_alias=True)
160
+ else:
161
+ req = dict(requirements)
162
+
163
+ network = req.get("network", "")
164
+
165
+ # Get network config
166
+ network_config = get_network_config(network)
167
+
168
+ # Get token info
169
+ token = self._get_token(network, network_config)
170
+
171
+ # Set asset identifier if not already set
172
+ if not req.get("asset"):
173
+ req["asset"] = create_asset_identifier(network, token.contract_address)
174
+
175
+ # Ensure amount is in atomic units (no decimals)
176
+ amount = req.get("amount", "")
177
+ if amount and "." in amount:
178
+ atomic = self._parse_amount_string(amount, token.decimals)
179
+ req["amount"] = str(atomic)
180
+
181
+ # Ensure extra exists
182
+ if "extra" not in req or req["extra"] is None:
183
+ req["extra"] = {}
184
+
185
+ # Add asset metadata
186
+ req["extra"]["contractAddress"] = token.contract_address
187
+ req["extra"]["assetSymbol"] = token.symbol
188
+ req["extra"]["assetDecimals"] = token.decimals
189
+ req["extra"]["networkName"] = network_config.name
190
+
191
+ # Add facilitator-provided extra fields from supportedKind
192
+ if supported_kind.get("extra"):
193
+ for key in ("contractAddress", "assetSymbol", "assetDecimals", "networkName"):
194
+ if key in supported_kind["extra"]:
195
+ req["extra"][key] = supported_kind["extra"][key]
196
+
197
+ # Copy extension keys from supportedKind
198
+ if supported_kind.get("extra"):
199
+ for key in facilitator_extensions:
200
+ if key in supported_kind["extra"]:
201
+ req["extra"][key] = supported_kind["extra"][key]
202
+
203
+ return req
204
+
205
+ def _get_token(self, network: str, network_config: NetworkConfig) -> TokenInfo:
206
+ """Get the token to use for the given network.
207
+
208
+ Uses the preferred token if configured and available,
209
+ otherwise falls back to the network's default token.
210
+
211
+ Args:
212
+ network: CAIP-2 network identifier
213
+ network_config: Network configuration
214
+
215
+ Returns:
216
+ TokenInfo for the selected token
217
+ """
218
+ if self._preferred_token:
219
+ if network_config.default_token.symbol == self._preferred_token:
220
+ return network_config.default_token
221
+ return network_config.default_token
222
+
223
+ def _parse_money_to_decimal(self, price: Union[str, int, float]) -> float:
224
+ """Parse a money value to a decimal float.
225
+
226
+ Handles:
227
+ - "$0.10" -> 0.10
228
+ - "0.10" -> 0.10
229
+ - 0.10 -> 0.10
230
+ - "1.50 sUSDC" -> 1.50
231
+
232
+ Args:
233
+ price: Price value to parse
234
+
235
+ Returns:
236
+ Decimal amount as float
237
+
238
+ Raises:
239
+ ValueError: If the price format is invalid
240
+ """
241
+ if isinstance(price, (int, float)):
242
+ return float(price)
243
+
244
+ if isinstance(price, str):
245
+ clean = price.strip()
246
+ clean = clean.lstrip("$").strip()
247
+
248
+ # Take only the numeric part (first token)
249
+ parts = clean.split()
250
+ if parts:
251
+ try:
252
+ return float(parts[0])
253
+ except ValueError:
254
+ raise ValueError(f"Failed to parse price string: '{price}'")
255
+
256
+ raise ValueError(f"Invalid price format: {price}")
257
+
258
+ def _to_atomic_units(self, amount: float, decimals: int) -> int:
259
+ """Convert a decimal amount to atomic units.
260
+
261
+ Args:
262
+ amount: Decimal amount (e.g., 1.50)
263
+ decimals: Number of decimal places (e.g., 6)
264
+
265
+ Returns:
266
+ Atomic amount as integer (e.g., 1500000)
267
+ """
268
+ multiplier = 10 ** decimals
269
+ return int(round(amount * multiplier))
270
+
271
+ def _parse_amount_string(self, amount_str: str, decimals: int) -> int:
272
+ """Parse a decimal amount string to atomic units.
273
+
274
+ Args:
275
+ amount_str: Amount as string (e.g., "1.50")
276
+ decimals: Number of decimal places
277
+
278
+ Returns:
279
+ Atomic amount as integer
280
+
281
+ Raises:
282
+ ValueError: If the amount string is invalid
283
+ """
284
+ try:
285
+ amount = float(amount_str)
286
+ except ValueError:
287
+ raise ValueError(f"Invalid amount: {amount_str}")
288
+
289
+ if amount < 0:
290
+ raise ValueError(f"Amount must be non-negative: {amount_str}")
291
+
292
+ return self._to_atomic_units(amount, decimals)
@@ -0,0 +1,380 @@
1
+ """Stacks Scheme Types.
2
+
3
+ This module defines types, payload structures, and validation utilities
4
+ for the Stacks exact-direct payment scheme.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import Any, Dict, Optional, Protocol, runtime_checkable
12
+
13
+
14
+ # Stacks address regex: SP/ST prefix followed by base58 characters
15
+ # SP for mainnet, ST for testnet, typical length 39-41 characters total
16
+ STACKS_ADDRESS_REGEX = re.compile(r"^S[PT][A-Z0-9]{38,40}$")
17
+
18
+ # Transaction ID regex: 0x-prefixed 64 hex characters (32 bytes)
19
+ TX_ID_REGEX = re.compile(r"^0x[a-fA-F0-9]{64}$")
20
+
21
+ # Contract identifier regex: address.contract-name
22
+ CONTRACT_ID_REGEX = re.compile(
23
+ r"^S[PT][A-Z0-9]{38,40}\.[a-zA-Z][a-zA-Z0-9\-]{0,127}$"
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class ExactDirectPayload:
29
+ """Payment payload for the exact-direct scheme on Stacks.
30
+
31
+ Contains the on-chain proof of a completed SIP-010 token transfer.
32
+
33
+ Attributes:
34
+ tx_id: The 0x-prefixed hex transaction ID
35
+ from_address: The sender's Stacks address
36
+ to_address: The recipient's Stacks address
37
+ amount: The atomic amount transferred (as string)
38
+ contract_address: The SIP-010 token contract identifier
39
+ """
40
+
41
+ tx_id: str
42
+ from_address: str
43
+ to_address: str
44
+ amount: str
45
+ contract_address: str
46
+
47
+ def to_dict(self) -> Dict[str, Any]:
48
+ """Convert the payload to a dictionary suitable for JSON serialization.
49
+
50
+ Returns:
51
+ Dictionary with camelCase keys matching the protocol format
52
+ """
53
+ return {
54
+ "txId": self.tx_id,
55
+ "from": self.from_address,
56
+ "to": self.to_address,
57
+ "amount": self.amount,
58
+ "contractAddress": self.contract_address,
59
+ }
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: Dict[str, Any]) -> "ExactDirectPayload":
63
+ """Create an ExactDirectPayload from a dictionary.
64
+
65
+ Args:
66
+ data: Dictionary with payload fields (camelCase or snake_case)
67
+
68
+ Returns:
69
+ ExactDirectPayload instance
70
+
71
+ Raises:
72
+ KeyError: If required fields are missing
73
+ TypeError: If field types are incorrect
74
+ """
75
+ return cls(
76
+ tx_id=data.get("txId", data.get("tx_id", "")),
77
+ from_address=data.get("from", data.get("from_address", "")),
78
+ to_address=data.get("to", data.get("to_address", "")),
79
+ amount=str(data.get("amount", "")),
80
+ contract_address=data.get(
81
+ "contractAddress", data.get("contract_address", "")
82
+ ),
83
+ )
84
+
85
+
86
+ @dataclass
87
+ class TransactionResult:
88
+ """Result of querying a transaction from the Stacks chain.
89
+
90
+ Represents the on-chain data for a submitted transaction,
91
+ including its parameters and success status.
92
+
93
+ Attributes:
94
+ tx_id: The 0x-prefixed transaction ID
95
+ tx_status: Transaction status (e.g., "success", "pending")
96
+ sender_address: The sender's Stacks address
97
+ contract_call: Details of the contract call (if applicable)
98
+ block_height: Block height where the transaction was included
99
+ block_hash: Hash of the block
100
+ """
101
+
102
+ tx_id: str
103
+ tx_status: str
104
+ sender_address: str
105
+ contract_call: Optional[Dict[str, Any]]
106
+ block_height: int
107
+ block_hash: str
108
+
109
+
110
+ @dataclass
111
+ class ParsedTokenTransfer:
112
+ """Parsed SIP-010 token transfer details extracted from a transaction.
113
+
114
+ Attributes:
115
+ contract_address: The SIP-010 contract identifier
116
+ from_address: Sender Stacks address
117
+ to_address: Recipient Stacks address
118
+ amount: Transfer amount in atomic units (as string)
119
+ success: Whether the transfer succeeded
120
+ """
121
+
122
+ contract_address: str
123
+ from_address: str
124
+ to_address: str
125
+ amount: str
126
+ success: bool
127
+
128
+
129
+ @runtime_checkable
130
+ class ClientStacksSigner(Protocol):
131
+ """Protocol for signing and submitting Stacks token transfers.
132
+
133
+ Implementations should provide the signer's address and the ability
134
+ to execute SIP-010 token transfers on the Stacks chain.
135
+
136
+ Example:
137
+ ```python
138
+ class MyStacksSigner:
139
+ @property
140
+ def address(self) -> str:
141
+ return "SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K"
142
+
143
+ async def transfer_token(
144
+ self, contract_address: str, to: str, amount: int
145
+ ) -> str:
146
+ # Build and submit SIP-010 transfer contract call
147
+ # Returns the transaction ID
148
+ return "0x..."
149
+ ```
150
+ """
151
+
152
+ @property
153
+ def address(self) -> str:
154
+ """Return the Stacks address of the signer.
155
+
156
+ Returns:
157
+ Stacks address string (SP... or ST...)
158
+ """
159
+ ...
160
+
161
+ async def transfer_token(
162
+ self, contract_address: str, to: str, amount: int
163
+ ) -> str:
164
+ """Execute a SIP-010 token transfer on-chain.
165
+
166
+ Builds a contract-call transaction for the SIP-010 `transfer`
167
+ function and submits it to the Stacks chain.
168
+
169
+ Args:
170
+ contract_address: The SIP-010 token contract identifier
171
+ (e.g., "SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.token-susdc")
172
+ to: The recipient's Stacks address
173
+ amount: The amount to transfer in atomic units
174
+
175
+ Returns:
176
+ The 0x-prefixed transaction ID
177
+
178
+ Raises:
179
+ Exception: If signing or submission fails
180
+ """
181
+ ...
182
+
183
+
184
+ @runtime_checkable
185
+ class FacilitatorStacksSigner(Protocol):
186
+ """Protocol for facilitator-side Stacks operations.
187
+
188
+ Implementations should provide the ability to query transactions
189
+ from the Stacks chain (via Hiro API or similar).
190
+
191
+ Example:
192
+ ```python
193
+ class MyStacksFacilitator:
194
+ def get_addresses(self, network: str) -> list[str]:
195
+ return ["SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K"]
196
+
197
+ async def query_transaction(self, tx_id: str) -> dict | None:
198
+ # Query Hiro API for transaction details
199
+ return {
200
+ "tx_id": "0x...",
201
+ "tx_status": "success",
202
+ "sender_address": "SP...",
203
+ "contract_call": {
204
+ "contract_id": "SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.token-susdc",
205
+ "function_name": "transfer",
206
+ "function_args": [...],
207
+ },
208
+ "block_height": 12345,
209
+ "block_hash": "0x...",
210
+ }
211
+ ```
212
+ """
213
+
214
+ def get_addresses(self, network: str) -> list:
215
+ """Get the facilitator addresses for a given network.
216
+
217
+ Args:
218
+ network: CAIP-2 network identifier
219
+
220
+ Returns:
221
+ List of Stacks addresses for the facilitator on this network
222
+ """
223
+ ...
224
+
225
+ async def query_transaction(self, tx_id: str) -> Optional[Dict[str, Any]]:
226
+ """Query a transaction by its ID from the Stacks chain.
227
+
228
+ Args:
229
+ tx_id: The 0x-prefixed transaction ID
230
+
231
+ Returns:
232
+ Dictionary with transaction details, or None if not found.
233
+ Expected fields:
234
+ - tx_id: str
235
+ - tx_status: str ("success", "pending", "abort_by_response", etc.)
236
+ - sender_address: str
237
+ - contract_call: dict with contract_id, function_name, function_args
238
+ - block_height: int
239
+ - block_hash: str
240
+
241
+ Raises:
242
+ Exception: If the query fails
243
+ """
244
+ ...
245
+
246
+
247
+ def is_valid_stacks_address(address: str) -> bool:
248
+ """Check if a string is a valid Stacks address.
249
+
250
+ Validates that the address starts with SP (mainnet) or ST (testnet)
251
+ followed by the expected base58 characters.
252
+
253
+ Args:
254
+ address: String to validate
255
+
256
+ Returns:
257
+ True if the address matches the Stacks address format
258
+ """
259
+ if not address:
260
+ return False
261
+ return bool(STACKS_ADDRESS_REGEX.match(address))
262
+
263
+
264
+ def is_valid_tx_id(tx_id: str) -> bool:
265
+ """Check if a string is a valid Stacks transaction ID.
266
+
267
+ Transaction IDs are 0x-prefixed 64 hex character strings (32 bytes).
268
+
269
+ Args:
270
+ tx_id: String to validate
271
+
272
+ Returns:
273
+ True if the transaction ID matches the expected format
274
+ """
275
+ if not tx_id:
276
+ return False
277
+ return bool(TX_ID_REGEX.match(tx_id))
278
+
279
+
280
+ def parse_contract_identifier(asset: str) -> Optional[str]:
281
+ """Parse a CAIP-19 asset identifier to extract the contract identifier.
282
+
283
+ Format: "{network}/token:{contract_id}"
284
+ Example: "stacks:1/token:SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.token-susdc"
285
+
286
+ Args:
287
+ asset: CAIP-19 asset identifier string
288
+
289
+ Returns:
290
+ The contract identifier string, or None if parsing fails
291
+ """
292
+ prefix = "/token:"
293
+ idx = asset.find(prefix)
294
+ if idx == -1:
295
+ return None
296
+ contract_id = asset[idx + len(prefix):]
297
+ if not contract_id:
298
+ return None
299
+ return contract_id
300
+
301
+
302
+ def create_asset_identifier(network: str, contract_address: str) -> str:
303
+ """Create a CAIP-19 asset identifier for a Stacks SIP-010 token.
304
+
305
+ Format: "{network}/token:{contract_id}"
306
+
307
+ Args:
308
+ network: CAIP-2 network identifier
309
+ contract_address: SIP-010 contract identifier
310
+
311
+ Returns:
312
+ CAIP-19 asset identifier string
313
+ """
314
+ return f"{network}/token:{contract_address}"
315
+
316
+
317
+ def extract_token_transfer(result: TransactionResult) -> Optional[ParsedTokenTransfer]:
318
+ """Extract SIP-010 token transfer details from a transaction result.
319
+
320
+ Validates that the transaction is a successful SIP-010 transfer
321
+ contract call and extracts the transfer parameters.
322
+
323
+ Args:
324
+ result: TransactionResult from chain query
325
+
326
+ Returns:
327
+ ParsedTokenTransfer if the transaction is a valid token transfer,
328
+ None otherwise
329
+ """
330
+ if result.tx_status != "success":
331
+ return None
332
+
333
+ if not result.contract_call:
334
+ return None
335
+
336
+ # Check for transfer function
337
+ function_name = result.contract_call.get("function_name", "")
338
+ if function_name != "transfer":
339
+ return None
340
+
341
+ contract_id = result.contract_call.get("contract_id", "")
342
+ if not contract_id:
343
+ return None
344
+
345
+ # Extract function arguments
346
+ function_args = result.contract_call.get("function_args", [])
347
+
348
+ amount: Optional[str] = None
349
+ to_address: Optional[str] = None
350
+
351
+ for arg in function_args:
352
+ if not isinstance(arg, dict):
353
+ continue
354
+
355
+ arg_name = arg.get("name", "")
356
+ arg_repr = arg.get("repr", "")
357
+
358
+ if arg_name == "amount":
359
+ # repr is like "u1000000" for uint
360
+ if arg_repr.startswith("u"):
361
+ amount = arg_repr[1:]
362
+ else:
363
+ amount = arg_repr
364
+ elif arg_name == "recipient" or arg_name == "to":
365
+ # repr is the principal address, strip leading '
366
+ if arg_repr.startswith("'"):
367
+ to_address = arg_repr[1:]
368
+ else:
369
+ to_address = arg_repr
370
+
371
+ if not amount or not to_address:
372
+ return None
373
+
374
+ return ParsedTokenTransfer(
375
+ contract_address=contract_id,
376
+ from_address=result.sender_address,
377
+ to_address=to_address,
378
+ amount=amount,
379
+ success=True,
380
+ )