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.
- t402/__init__.py +2 -1
- t402/a2a/__init__.py +73 -0
- t402/a2a/helpers.py +158 -0
- t402/a2a/types.py +145 -0
- t402/bridge/client.py +13 -5
- t402/bridge/constants.py +4 -2
- t402/bridge/router.py +1 -1
- t402/bridge/scan.py +3 -1
- t402/chains.py +268 -1
- t402/cli.py +31 -9
- t402/common.py +2 -0
- t402/cosmos_paywall_template.py +2 -0
- t402/django/__init__.py +42 -0
- t402/django/middleware.py +596 -0
- t402/encoding.py +9 -3
- t402/erc4337/accounts.py +56 -51
- t402/erc4337/bundlers.py +105 -99
- t402/erc4337/paymasters.py +100 -109
- t402/erc4337/types.py +39 -26
- t402/errors.py +213 -0
- t402/evm_paywall_template.py +1 -1
- t402/facilitator.py +125 -0
- t402/fastapi/middleware.py +1 -3
- t402/mcp/constants.py +3 -6
- t402/mcp/server.py +501 -84
- t402/mcp/web3_utils.py +493 -0
- t402/multisig/__init__.py +120 -0
- t402/multisig/constants.py +54 -0
- t402/multisig/safe.py +441 -0
- t402/multisig/signature.py +228 -0
- t402/multisig/transaction.py +238 -0
- t402/multisig/types.py +108 -0
- t402/multisig/utils.py +77 -0
- t402/near_paywall_template.py +2 -0
- t402/networks.py +34 -1
- t402/paywall.py +1 -3
- t402/schemes/__init__.py +143 -0
- t402/schemes/aptos/__init__.py +70 -0
- t402/schemes/aptos/constants.py +349 -0
- t402/schemes/aptos/exact_direct/__init__.py +44 -0
- t402/schemes/aptos/exact_direct/client.py +202 -0
- t402/schemes/aptos/exact_direct/facilitator.py +426 -0
- t402/schemes/aptos/exact_direct/server.py +272 -0
- t402/schemes/aptos/types.py +237 -0
- t402/schemes/cosmos/__init__.py +114 -0
- t402/schemes/cosmos/constants.py +211 -0
- t402/schemes/cosmos/exact_direct/__init__.py +21 -0
- t402/schemes/cosmos/exact_direct/client.py +198 -0
- t402/schemes/cosmos/exact_direct/facilitator.py +493 -0
- t402/schemes/cosmos/exact_direct/server.py +315 -0
- t402/schemes/cosmos/types.py +501 -0
- t402/schemes/evm/__init__.py +46 -1
- t402/schemes/evm/exact/__init__.py +11 -0
- t402/schemes/evm/exact/client.py +3 -1
- t402/schemes/evm/exact/facilitator.py +894 -0
- t402/schemes/evm/exact/server.py +1 -1
- t402/schemes/evm/exact_legacy/__init__.py +38 -0
- t402/schemes/evm/exact_legacy/client.py +291 -0
- t402/schemes/evm/exact_legacy/facilitator.py +777 -0
- t402/schemes/evm/exact_legacy/server.py +231 -0
- t402/schemes/evm/upto/__init__.py +12 -0
- t402/schemes/evm/upto/client.py +6 -2
- t402/schemes/evm/upto/facilitator.py +625 -0
- t402/schemes/evm/upto/server.py +243 -0
- t402/schemes/evm/upto/types.py +3 -1
- t402/schemes/interfaces.py +6 -2
- t402/schemes/near/__init__.py +137 -0
- t402/schemes/near/constants.py +189 -0
- t402/schemes/near/exact_direct/__init__.py +21 -0
- t402/schemes/near/exact_direct/client.py +204 -0
- t402/schemes/near/exact_direct/facilitator.py +455 -0
- t402/schemes/near/exact_direct/server.py +303 -0
- t402/schemes/near/types.py +419 -0
- t402/schemes/near/upto/__init__.py +54 -0
- t402/schemes/near/upto/types.py +272 -0
- t402/schemes/polkadot/__init__.py +72 -0
- t402/schemes/polkadot/constants.py +155 -0
- t402/schemes/polkadot/exact_direct/__init__.py +43 -0
- t402/schemes/polkadot/exact_direct/client.py +235 -0
- t402/schemes/polkadot/exact_direct/facilitator.py +428 -0
- t402/schemes/polkadot/exact_direct/server.py +292 -0
- t402/schemes/polkadot/types.py +385 -0
- t402/schemes/registry.py +6 -2
- t402/schemes/stacks/__init__.py +68 -0
- t402/schemes/stacks/constants.py +122 -0
- t402/schemes/stacks/exact_direct/__init__.py +43 -0
- t402/schemes/stacks/exact_direct/client.py +222 -0
- t402/schemes/stacks/exact_direct/facilitator.py +424 -0
- t402/schemes/stacks/exact_direct/server.py +292 -0
- t402/schemes/stacks/types.py +380 -0
- t402/schemes/svm/__init__.py +44 -0
- t402/schemes/svm/exact/__init__.py +35 -0
- t402/schemes/svm/exact/client.py +23 -0
- t402/schemes/svm/exact/facilitator.py +24 -0
- t402/schemes/svm/exact/server.py +20 -0
- t402/schemes/svm/upto/__init__.py +23 -0
- t402/schemes/svm/upto/types.py +193 -0
- t402/schemes/tezos/__init__.py +84 -0
- t402/schemes/tezos/constants.py +372 -0
- t402/schemes/tezos/exact_direct/__init__.py +22 -0
- t402/schemes/tezos/exact_direct/client.py +226 -0
- t402/schemes/tezos/exact_direct/facilitator.py +491 -0
- t402/schemes/tezos/exact_direct/server.py +277 -0
- t402/schemes/tezos/types.py +220 -0
- t402/schemes/ton/__init__.py +24 -2
- t402/schemes/ton/exact/__init__.py +7 -0
- t402/schemes/ton/exact/facilitator.py +730 -0
- t402/schemes/ton/exact/server.py +1 -1
- t402/schemes/ton/upto/__init__.py +31 -0
- t402/schemes/ton/upto/types.py +215 -0
- t402/schemes/tron/__init__.py +28 -2
- t402/schemes/tron/exact/__init__.py +9 -0
- t402/schemes/tron/exact/facilitator.py +673 -0
- t402/schemes/tron/exact/server.py +1 -1
- t402/schemes/tron/upto/__init__.py +30 -0
- t402/schemes/tron/upto/types.py +213 -0
- t402/stacks_paywall_template.py +2 -0
- t402/starlette/__init__.py +38 -0
- t402/starlette/middleware.py +522 -0
- t402/svm.py +45 -11
- t402/svm_paywall_template.py +1 -1
- t402/ton.py +6 -2
- t402/ton_paywall_template.py +1 -192
- t402/tron.py +2 -0
- t402/tron_paywall_template.py +2 -0
- t402/types.py +103 -3
- t402/wdk/chains.py +1 -1
- t402/wdk/errors.py +15 -5
- t402/wdk/signer.py +11 -2
- {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/METADATA +42 -1
- t402-1.10.0.dist-info/RECORD +156 -0
- t402-1.9.0.dist-info/RECORD +0 -72
- {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/WHEEL +0 -0
- {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
|
+
)
|