uvd-x402-sdk 0.4.1__tar.gz → 0.4.2__tar.gz

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 (31) hide show
  1. {uvd_x402_sdk-0.4.1/src/uvd_x402_sdk.egg-info → uvd_x402_sdk-0.4.2}/PKG-INFO +4 -2
  2. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/pyproject.toml +5 -2
  3. uvd_x402_sdk-0.4.2/src/uvd_x402_sdk/networks/algorand.py +550 -0
  4. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2/src/uvd_x402_sdk.egg-info}/PKG-INFO +4 -2
  5. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk.egg-info/requires.txt +4 -1
  6. uvd_x402_sdk-0.4.1/src/uvd_x402_sdk/networks/algorand.py +0 -287
  7. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/LICENSE +0 -0
  8. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/README.md +0 -0
  9. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/setup.cfg +0 -0
  10. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/__init__.py +0 -0
  11. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/client.py +0 -0
  12. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/config.py +0 -0
  13. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/decorators.py +0 -0
  14. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/exceptions.py +0 -0
  15. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/integrations/__init__.py +0 -0
  16. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/integrations/django_integration.py +0 -0
  17. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/integrations/fastapi_integration.py +0 -0
  18. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/integrations/flask_integration.py +0 -0
  19. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/integrations/lambda_integration.py +0 -0
  20. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/models.py +0 -0
  21. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/networks/__init__.py +0 -0
  22. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/networks/base.py +0 -0
  23. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/networks/evm.py +0 -0
  24. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/networks/near.py +0 -0
  25. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/networks/solana.py +0 -0
  26. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/networks/stellar.py +0 -0
  27. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk/response.py +0 -0
  28. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk.egg-info/SOURCES.txt +0 -0
  29. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk.egg-info/dependency_links.txt +0 -0
  30. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/src/uvd_x402_sdk.egg-info/top_level.txt +0 -0
  31. {uvd_x402_sdk-0.4.1 → uvd_x402_sdk-0.4.2}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: uvd-x402-sdk
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Python SDK for x402 payments - gasless crypto payments across 16 blockchains with multi-stablecoin support (USDC, EURC, AUSD, PYUSD, USDT)
5
5
  Author-email: Ultravioleta DAO <dev@ultravioletadao.xyz>
6
6
  Project-URL: Homepage, https://github.com/UltravioletaDAO/uvd-x402-sdk-python
@@ -36,8 +36,10 @@ Provides-Extra: django
36
36
  Requires-Dist: django>=4.0.0; extra == "django"
37
37
  Provides-Extra: aws
38
38
  Requires-Dist: boto3>=1.26.0; extra == "aws"
39
+ Provides-Extra: algorand
40
+ Requires-Dist: py-algorand-sdk>=2.0.0; extra == "algorand"
39
41
  Provides-Extra: all
40
- Requires-Dist: uvd-x402-sdk[aws,django,fastapi,flask,web3]; extra == "all"
42
+ Requires-Dist: uvd-x402-sdk[algorand,aws,django,fastapi,flask,web3]; extra == "all"
41
43
  Provides-Extra: dev
42
44
  Requires-Dist: pytest>=7.0.0; extra == "dev"
43
45
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "uvd-x402-sdk"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Python SDK for x402 payments - gasless crypto payments across 16 blockchains with multi-stablecoin support (USDC, EURC, AUSD, PYUSD, USDT)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -65,8 +65,11 @@ django = [
65
65
  aws = [
66
66
  "boto3>=1.26.0",
67
67
  ]
68
+ algorand = [
69
+ "py-algorand-sdk>=2.0.0",
70
+ ]
68
71
  all = [
69
- "uvd-x402-sdk[web3,flask,fastapi,django,aws]",
72
+ "uvd-x402-sdk[web3,flask,fastapi,django,aws,algorand]",
70
73
  ]
71
74
  dev = [
72
75
  "pytest>=7.0.0",
@@ -0,0 +1,550 @@
1
+ """
2
+ Algorand network configurations for x402 payments.
3
+
4
+ This module supports Algorand blockchain networks:
5
+ - Algorand mainnet (network: "algorand-mainnet" or "algorand")
6
+ - Algorand testnet (network: "algorand-testnet")
7
+
8
+ Algorand uses ASA (Algorand Standard Assets) for USDC:
9
+ - Mainnet USDC ASA ID: 31566704
10
+ - Testnet USDC ASA ID: 10458941
11
+
12
+ Payment Flow (GoPlausible x402-avm Atomic Group Spec):
13
+ 1. Client creates an ATOMIC GROUP of TWO transactions:
14
+ - Transaction 0 (fee tx): Zero-amount payment FROM facilitator TO facilitator
15
+ This transaction pays fees for both txns. Client creates this UNSIGNED.
16
+ - Transaction 1 (payment tx): ASA transfer FROM client TO merchant
17
+ Client SIGNS this transaction.
18
+ 2. Both transactions share a GROUP ID computed by Algorand SDK.
19
+ 3. Fee pooling: Transaction 0's fee covers Transaction 1's fee (gasless).
20
+ 4. Facilitator completes: Signs transaction 0 and submits the atomic group.
21
+
22
+ Payload Format:
23
+ {
24
+ "x402Version": 1,
25
+ "scheme": "exact",
26
+ "network": "algorand-mainnet",
27
+ "payload": {
28
+ "paymentIndex": 1,
29
+ "paymentGroup": [
30
+ "<base64-msgpack-UNSIGNED-fee-tx>",
31
+ "<base64-msgpack-SIGNED-asa-transfer>"
32
+ ]
33
+ }
34
+ }
35
+
36
+ Address Format:
37
+ - Algorand addresses are 58 characters, base32 encoded
38
+ - Example: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ
39
+
40
+ Dependencies:
41
+ - algosdk (optional): Required for building atomic groups
42
+ Install with: pip install py-algorand-sdk
43
+ """
44
+
45
+ import base64
46
+ import re
47
+ from dataclasses import dataclass
48
+ from typing import Any, Callable, Dict, List, Optional
49
+
50
+ from uvd_x402_sdk.networks.base import (
51
+ NetworkConfig,
52
+ NetworkType,
53
+ register_network,
54
+ )
55
+
56
+
57
+ # =============================================================================
58
+ # Algorand Networks Configuration
59
+ # =============================================================================
60
+
61
+ # Algorand Mainnet
62
+ ALGORAND = NetworkConfig(
63
+ name="algorand",
64
+ display_name="Algorand",
65
+ network_type=NetworkType.ALGORAND,
66
+ chain_id=0, # Non-EVM, no chain ID
67
+ usdc_address="31566704", # USDC ASA ID on mainnet
68
+ usdc_decimals=6,
69
+ usdc_domain_name="", # Not applicable for Algorand
70
+ usdc_domain_version="",
71
+ rpc_url="https://mainnet-api.algonode.cloud",
72
+ enabled=True,
73
+ extra_config={
74
+ # ASA (Algorand Standard Asset) details
75
+ "usdc_asa_id": 31566704,
76
+ # Block explorer
77
+ "explorer_url": "https://allo.info",
78
+ # Indexer endpoint (for account queries)
79
+ "indexer_url": "https://mainnet-idx.algonode.cloud",
80
+ # Network identifier
81
+ "genesis_id": "mainnet-v1.0",
82
+ # Genesis hash (for CAIP-2)
83
+ "genesis_hash": "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=",
84
+ # x402 network name (facilitator expects this format)
85
+ "x402_network": "algorand-mainnet",
86
+ },
87
+ )
88
+
89
+ # Algorand Testnet
90
+ ALGORAND_TESTNET = NetworkConfig(
91
+ name="algorand-testnet",
92
+ display_name="Algorand Testnet",
93
+ network_type=NetworkType.ALGORAND,
94
+ chain_id=0, # Non-EVM, no chain ID
95
+ usdc_address="10458941", # USDC ASA ID on testnet
96
+ usdc_decimals=6,
97
+ usdc_domain_name="", # Not applicable for Algorand
98
+ usdc_domain_version="",
99
+ rpc_url="https://testnet-api.algonode.cloud",
100
+ enabled=True,
101
+ extra_config={
102
+ # ASA (Algorand Standard Asset) details
103
+ "usdc_asa_id": 10458941,
104
+ # Block explorer
105
+ "explorer_url": "https://testnet.allo.info",
106
+ # Indexer endpoint (for account queries)
107
+ "indexer_url": "https://testnet-idx.algonode.cloud",
108
+ # Network identifier
109
+ "genesis_id": "testnet-v1.0",
110
+ # Genesis hash
111
+ "genesis_hash": "SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=",
112
+ # x402 network name (facilitator expects this format)
113
+ "x402_network": "algorand-testnet",
114
+ },
115
+ )
116
+
117
+ # Register Algorand networks
118
+ register_network(ALGORAND)
119
+ register_network(ALGORAND_TESTNET)
120
+
121
+
122
+ # =============================================================================
123
+ # Algorand Payment Payload (x402-avm Atomic Group Spec)
124
+ # =============================================================================
125
+
126
+
127
+ @dataclass
128
+ class AlgorandPaymentPayload:
129
+ """
130
+ Algorand payment payload for x402 atomic group format.
131
+
132
+ Attributes:
133
+ payment_index: Index of the payment transaction in the group (typically 1)
134
+ payment_group: List of base64-encoded msgpack transactions
135
+ [0] = unsigned fee transaction
136
+ [1] = signed ASA transfer transaction
137
+ """
138
+
139
+ payment_index: int
140
+ payment_group: List[str]
141
+
142
+ def to_dict(self) -> Dict[str, Any]:
143
+ """Convert to dictionary for JSON serialization."""
144
+ return {
145
+ "paymentIndex": self.payment_index,
146
+ "paymentGroup": self.payment_group,
147
+ }
148
+
149
+
150
+ # =============================================================================
151
+ # Algorand-specific utilities
152
+ # =============================================================================
153
+
154
+
155
+ def is_algorand_network(network_name: str) -> bool:
156
+ """
157
+ Check if a network is Algorand.
158
+
159
+ Args:
160
+ network_name: Network name to check
161
+
162
+ Returns:
163
+ True if network is Algorand (mainnet or testnet)
164
+ """
165
+ from uvd_x402_sdk.networks.base import get_network, NetworkType
166
+
167
+ network = get_network(network_name)
168
+ if not network:
169
+ return False
170
+ return network.network_type == NetworkType.ALGORAND
171
+
172
+
173
+ def get_algorand_networks() -> list:
174
+ """
175
+ Get all registered Algorand networks.
176
+
177
+ Returns:
178
+ List of Algorand NetworkConfig instances
179
+ """
180
+ from uvd_x402_sdk.networks.base import list_networks, NetworkType
181
+
182
+ return [
183
+ n for n in list_networks(enabled_only=True)
184
+ if n.network_type == NetworkType.ALGORAND
185
+ ]
186
+
187
+
188
+ def is_valid_algorand_address(address: str) -> bool:
189
+ """
190
+ Validate an Algorand address format.
191
+
192
+ Algorand addresses are 58 characters, base32 encoded (RFC 4648).
193
+ They consist of uppercase letters A-Z and digits 2-7.
194
+
195
+ Args:
196
+ address: Address to validate
197
+
198
+ Returns:
199
+ True if valid Algorand address format
200
+ """
201
+ if not address or not isinstance(address, str):
202
+ return False
203
+
204
+ # Algorand addresses are exactly 58 characters
205
+ if len(address) != 58:
206
+ return False
207
+
208
+ # Base32 alphabet (RFC 4648): A-Z and 2-7
209
+ base32_pattern = re.compile(r'^[A-Z2-7]+$')
210
+ return bool(base32_pattern.match(address))
211
+
212
+
213
+ def validate_algorand_payload(payload: Dict[str, Any]) -> bool:
214
+ """
215
+ Validate an Algorand payment payload structure (x402-avm atomic group format).
216
+
217
+ The payload must contain:
218
+ - paymentIndex: Index of the payment transaction (typically 1)
219
+ - paymentGroup: List of base64-encoded msgpack transactions
220
+ - [0]: Unsigned fee transaction (facilitator -> facilitator)
221
+ - [1]: Signed ASA transfer (client -> merchant)
222
+
223
+ Args:
224
+ payload: Payload dictionary from x402 payment (the inner "payload" field)
225
+
226
+ Returns:
227
+ True if valid, raises ValueError if invalid
228
+
229
+ Example:
230
+ >>> payload = {
231
+ ... "paymentIndex": 1,
232
+ ... "paymentGroup": [
233
+ ... "base64-unsigned-fee-tx...",
234
+ ... "base64-signed-payment-tx..."
235
+ ... ]
236
+ ... }
237
+ >>> validate_algorand_payload(payload)
238
+ True
239
+ """
240
+ # Check required fields
241
+ if "paymentIndex" not in payload:
242
+ raise ValueError("Algorand payload missing 'paymentIndex' field")
243
+ if "paymentGroup" not in payload:
244
+ raise ValueError("Algorand payload missing 'paymentGroup' field")
245
+
246
+ # Validate paymentIndex
247
+ payment_index = payload["paymentIndex"]
248
+ if not isinstance(payment_index, int) or payment_index < 0:
249
+ raise ValueError(f"paymentIndex must be a non-negative integer: {payment_index}")
250
+
251
+ # Validate paymentGroup
252
+ payment_group = payload["paymentGroup"]
253
+ if not isinstance(payment_group, list):
254
+ raise ValueError("paymentGroup must be a list")
255
+
256
+ if len(payment_group) < 2:
257
+ raise ValueError(
258
+ f"paymentGroup must contain at least 2 transactions, got {len(payment_group)}"
259
+ )
260
+
261
+ if payment_index >= len(payment_group):
262
+ raise ValueError(
263
+ f"paymentIndex ({payment_index}) out of range for paymentGroup "
264
+ f"(length {len(payment_group)})"
265
+ )
266
+
267
+ # Validate each transaction in the group is valid base64
268
+ for i, txn_b64 in enumerate(payment_group):
269
+ if not isinstance(txn_b64, str):
270
+ raise ValueError(f"paymentGroup[{i}] must be a string")
271
+
272
+ try:
273
+ txn_bytes = base64.b64decode(txn_b64)
274
+ if len(txn_bytes) < 50:
275
+ raise ValueError(
276
+ f"paymentGroup[{i}] transaction too short: {len(txn_bytes)} bytes"
277
+ )
278
+ except Exception as e:
279
+ raise ValueError(
280
+ f"paymentGroup[{i}] is not valid base64: {e}"
281
+ ) from e
282
+
283
+ return True
284
+
285
+
286
+ def get_x402_network_name(network_name: str) -> str:
287
+ """
288
+ Get the x402 network name for an Algorand network.
289
+
290
+ The facilitator expects "algorand-mainnet" or "algorand-testnet".
291
+
292
+ Args:
293
+ network_name: SDK network name ('algorand' or 'algorand-testnet')
294
+
295
+ Returns:
296
+ x402 network name ('algorand-mainnet' or 'algorand-testnet')
297
+ """
298
+ from uvd_x402_sdk.networks.base import get_network
299
+
300
+ network = get_network(network_name)
301
+ if not network:
302
+ # Default mapping
303
+ if network_name == "algorand":
304
+ return "algorand-mainnet"
305
+ return network_name
306
+
307
+ return network.extra_config.get("x402_network", network_name)
308
+
309
+
310
+ def get_explorer_tx_url(network_name: str, tx_id: str) -> Optional[str]:
311
+ """
312
+ Get block explorer URL for a transaction.
313
+
314
+ Args:
315
+ network_name: Network name ('algorand' or 'algorand-testnet')
316
+ tx_id: Transaction ID
317
+
318
+ Returns:
319
+ Explorer URL or None if network not found
320
+ """
321
+ from uvd_x402_sdk.networks.base import get_network
322
+
323
+ network = get_network(network_name)
324
+ if not network or network.network_type != NetworkType.ALGORAND:
325
+ return None
326
+
327
+ explorer_url = network.extra_config.get("explorer_url", "https://allo.info")
328
+ return f"{explorer_url}/tx/{tx_id}"
329
+
330
+
331
+ def get_explorer_address_url(network_name: str, address: str) -> Optional[str]:
332
+ """
333
+ Get block explorer URL for an address.
334
+
335
+ Args:
336
+ network_name: Network name ('algorand' or 'algorand-testnet')
337
+ address: Algorand address
338
+
339
+ Returns:
340
+ Explorer URL or None if network not found
341
+ """
342
+ from uvd_x402_sdk.networks.base import get_network
343
+
344
+ network = get_network(network_name)
345
+ if not network or network.network_type != NetworkType.ALGORAND:
346
+ return None
347
+
348
+ explorer_url = network.extra_config.get("explorer_url", "https://allo.info")
349
+ return f"{explorer_url}/account/{address}"
350
+
351
+
352
+ def get_usdc_asa_id(network_name: str) -> Optional[int]:
353
+ """
354
+ Get the USDC ASA ID for an Algorand network.
355
+
356
+ Args:
357
+ network_name: Network name ('algorand' or 'algorand-testnet')
358
+
359
+ Returns:
360
+ USDC ASA ID or None if network not found
361
+ """
362
+ from uvd_x402_sdk.networks.base import get_network
363
+
364
+ network = get_network(network_name)
365
+ if not network or network.network_type != NetworkType.ALGORAND:
366
+ return None
367
+
368
+ # Try extra_config first, then fall back to usdc_address
369
+ asa_id = network.extra_config.get("usdc_asa_id")
370
+ if asa_id:
371
+ return int(asa_id)
372
+
373
+ # Parse from usdc_address (which stores the ASA ID as string)
374
+ try:
375
+ return int(network.usdc_address)
376
+ except (ValueError, TypeError):
377
+ return None
378
+
379
+
380
+ # =============================================================================
381
+ # Atomic Group Builder (requires algosdk)
382
+ # =============================================================================
383
+
384
+
385
+ def build_atomic_group(
386
+ sender_address: str,
387
+ recipient_address: str,
388
+ amount: int,
389
+ asset_id: int,
390
+ facilitator_address: str,
391
+ sign_transaction: Callable,
392
+ algod_client: Optional[Any] = None,
393
+ suggested_params: Optional[Any] = None,
394
+ ) -> AlgorandPaymentPayload:
395
+ """
396
+ Build an Algorand atomic group for x402 payment.
397
+
398
+ This creates the two-transaction atomic group required by the facilitator:
399
+ - Transaction 0: Unsigned fee payment (facilitator -> facilitator, 0 amount)
400
+ - Transaction 1: Signed ASA transfer (sender -> recipient)
401
+
402
+ Requires: pip install py-algorand-sdk
403
+
404
+ Args:
405
+ sender_address: Client's Algorand address
406
+ recipient_address: Merchant's Algorand address (from payTo)
407
+ amount: Amount in micro-units (1 USDC = 1,000,000)
408
+ asset_id: USDC ASA ID (31566704 mainnet, 10458941 testnet)
409
+ facilitator_address: Facilitator address (from extra.feePayer)
410
+ sign_transaction: Function that signs a transaction.
411
+ Signature: (transaction) -> SignedTransaction
412
+ Can use algosdk's transaction.sign(private_key)
413
+ algod_client: Optional AlgodClient for getting suggested params.
414
+ If not provided, suggested_params must be given.
415
+ suggested_params: Optional SuggestedParams. If not provided,
416
+ algod_client.suggested_params() is called.
417
+
418
+ Returns:
419
+ AlgorandPaymentPayload with paymentIndex and paymentGroup
420
+
421
+ Raises:
422
+ ImportError: If algosdk is not installed
423
+ ValueError: If neither algod_client nor suggested_params provided
424
+
425
+ Example:
426
+ >>> from algosdk import transaction
427
+ >>> from algosdk.v2client import algod
428
+ >>>
429
+ >>> client = algod.AlgodClient("", "https://mainnet-api.algonode.cloud")
430
+ >>> payload = build_atomic_group(
431
+ ... sender_address="SENDER...",
432
+ ... recipient_address="MERCHANT...",
433
+ ... amount=1000000, # 1 USDC
434
+ ... asset_id=31566704,
435
+ ... facilitator_address="FACILITATOR...",
436
+ ... sign_transaction=lambda txn: txn.sign(private_key),
437
+ ... algod_client=client,
438
+ ... )
439
+ """
440
+ try:
441
+ from algosdk import encoding, transaction
442
+ except ImportError as e:
443
+ raise ImportError(
444
+ "algosdk is required for building atomic groups. "
445
+ "Install with: pip install py-algorand-sdk"
446
+ ) from e
447
+
448
+ # Get suggested params
449
+ if suggested_params is None:
450
+ if algod_client is None:
451
+ raise ValueError(
452
+ "Either algod_client or suggested_params must be provided"
453
+ )
454
+ suggested_params = algod_client.suggested_params()
455
+
456
+ # Transaction 0: Fee payment (facilitator -> facilitator, 0 amount)
457
+ # This transaction pays fees for both txns in the group
458
+ fee_txn = transaction.PaymentTxn(
459
+ sender=facilitator_address,
460
+ receiver=facilitator_address, # self-transfer
461
+ amt=0,
462
+ sp=suggested_params,
463
+ )
464
+ # Cover both transactions (1000 microAlgos each = 2000 total)
465
+ fee_txn.fee = 2000
466
+
467
+ # Transaction 1: ASA transfer (client -> merchant)
468
+ payment_txn = transaction.AssetTransferTxn(
469
+ sender=sender_address,
470
+ receiver=recipient_address,
471
+ amt=amount,
472
+ index=asset_id,
473
+ sp=suggested_params,
474
+ )
475
+ # Fee paid by transaction 0
476
+ payment_txn.fee = 0
477
+
478
+ # Assign group ID to both transactions
479
+ group_id = transaction.calculate_group_id([fee_txn, payment_txn])
480
+ fee_txn.group = group_id
481
+ payment_txn.group = group_id
482
+
483
+ # Encode fee transaction (UNSIGNED - facilitator will sign)
484
+ unsigned_fee_txn_bytes = encoding.msgpack_encode(fee_txn)
485
+ unsigned_fee_txn_base64 = base64.b64encode(unsigned_fee_txn_bytes).decode("utf-8")
486
+
487
+ # Sign and encode payment transaction
488
+ signed_payment_txn = sign_transaction(payment_txn)
489
+ signed_payment_txn_bytes = encoding.msgpack_encode(signed_payment_txn)
490
+ signed_payment_txn_base64 = base64.b64encode(signed_payment_txn_bytes).decode("utf-8")
491
+
492
+ return AlgorandPaymentPayload(
493
+ payment_index=1, # Index of the payment transaction
494
+ payment_group=[
495
+ unsigned_fee_txn_base64, # Transaction 0: unsigned fee tx
496
+ signed_payment_txn_base64, # Transaction 1: signed payment tx
497
+ ],
498
+ )
499
+
500
+
501
+ def create_private_key_signer(private_key: str) -> Callable:
502
+ """
503
+ Create a transaction signer from a private key.
504
+
505
+ Args:
506
+ private_key: Algorand private key (base64 encoded)
507
+
508
+ Returns:
509
+ Function that signs transactions
510
+
511
+ Example:
512
+ >>> signer = create_private_key_signer(my_private_key)
513
+ >>> payload = build_atomic_group(..., sign_transaction=signer)
514
+ """
515
+ def sign(txn: Any) -> Any:
516
+ return txn.sign(private_key)
517
+ return sign
518
+
519
+
520
+ def build_x402_payment_request(
521
+ payload: AlgorandPaymentPayload,
522
+ network: str = "algorand-mainnet",
523
+ scheme: str = "exact",
524
+ version: int = 1,
525
+ ) -> Dict[str, Any]:
526
+ """
527
+ Build a complete x402 payment request for Algorand.
528
+
529
+ Args:
530
+ payload: AlgorandPaymentPayload from build_atomic_group()
531
+ network: Network name ("algorand-mainnet" or "algorand-testnet")
532
+ scheme: Payment scheme (default "exact")
533
+ version: x402 version (default 1)
534
+
535
+ Returns:
536
+ Complete x402 payment request dictionary
537
+
538
+ Example:
539
+ >>> payload = build_atomic_group(...)
540
+ >>> request = build_x402_payment_request(payload)
541
+ >>> # Send as X-PAYMENT header (base64 encoded JSON)
542
+ >>> import json, base64
543
+ >>> header = base64.b64encode(json.dumps(request).encode()).decode()
544
+ """
545
+ return {
546
+ "x402Version": version,
547
+ "scheme": scheme,
548
+ "network": network,
549
+ "payload": payload.to_dict(),
550
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: uvd-x402-sdk
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Python SDK for x402 payments - gasless crypto payments across 16 blockchains with multi-stablecoin support (USDC, EURC, AUSD, PYUSD, USDT)
5
5
  Author-email: Ultravioleta DAO <dev@ultravioletadao.xyz>
6
6
  Project-URL: Homepage, https://github.com/UltravioletaDAO/uvd-x402-sdk-python
@@ -36,8 +36,10 @@ Provides-Extra: django
36
36
  Requires-Dist: django>=4.0.0; extra == "django"
37
37
  Provides-Extra: aws
38
38
  Requires-Dist: boto3>=1.26.0; extra == "aws"
39
+ Provides-Extra: algorand
40
+ Requires-Dist: py-algorand-sdk>=2.0.0; extra == "algorand"
39
41
  Provides-Extra: all
40
- Requires-Dist: uvd-x402-sdk[aws,django,fastapi,flask,web3]; extra == "all"
42
+ Requires-Dist: uvd-x402-sdk[algorand,aws,django,fastapi,flask,web3]; extra == "all"
41
43
  Provides-Extra: dev
42
44
  Requires-Dist: pytest>=7.0.0; extra == "dev"
43
45
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -1,8 +1,11 @@
1
1
  httpx>=0.24.0
2
2
  pydantic>=2.0.0
3
3
 
4
+ [algorand]
5
+ py-algorand-sdk>=2.0.0
6
+
4
7
  [all]
5
- uvd-x402-sdk[aws,django,fastapi,flask,web3]
8
+ uvd-x402-sdk[algorand,aws,django,fastapi,flask,web3]
6
9
 
7
10
  [aws]
8
11
  boto3>=1.26.0
@@ -1,287 +0,0 @@
1
- """
2
- Algorand network configurations.
3
-
4
- This module supports Algorand blockchain networks:
5
- - Algorand mainnet
6
- - Algorand testnet
7
-
8
- Algorand uses ASA (Algorand Standard Assets) for USDC:
9
- - Mainnet USDC ASA ID: 31566704
10
- - Testnet USDC ASA ID: 10458941
11
-
12
- Payment Flow:
13
- 1. User creates a signed ASA transfer transaction via Pera Wallet
14
- 2. Transaction transfers USDC from user to recipient
15
- 3. Facilitator submits the pre-signed transaction on-chain
16
- 4. User pays ZERO transaction fees (facilitator covers fees)
17
-
18
- Transaction Structure:
19
- - ASA TransferAsset transaction
20
- - Signed by user wallet (Pera Wallet)
21
- - Facilitator submits the signed transaction
22
-
23
- Address Format:
24
- - Algorand addresses are 58 characters, base32 encoded
25
- - Example: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ
26
- """
27
-
28
- import base64
29
- import re
30
- from typing import Any, Dict, Optional
31
-
32
- from uvd_x402_sdk.networks.base import (
33
- NetworkConfig,
34
- NetworkType,
35
- register_network,
36
- )
37
-
38
-
39
- # =============================================================================
40
- # Algorand Networks Configuration
41
- # =============================================================================
42
-
43
- # Algorand Mainnet
44
- ALGORAND = NetworkConfig(
45
- name="algorand",
46
- display_name="Algorand",
47
- network_type=NetworkType.ALGORAND,
48
- chain_id=0, # Non-EVM, no chain ID
49
- usdc_address="31566704", # USDC ASA ID on mainnet
50
- usdc_decimals=6,
51
- usdc_domain_name="", # Not applicable for Algorand
52
- usdc_domain_version="",
53
- rpc_url="https://mainnet-api.algonode.cloud",
54
- enabled=True,
55
- extra_config={
56
- # ASA (Algorand Standard Asset) details
57
- "usdc_asa_id": 31566704,
58
- # Block explorer
59
- "explorer_url": "https://allo.info",
60
- # Indexer endpoint (for account queries)
61
- "indexer_url": "https://mainnet-idx.algonode.cloud",
62
- # Network identifier
63
- "genesis_id": "mainnet-v1.0",
64
- # Genesis hash (for CAIP-2)
65
- "genesis_hash": "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=",
66
- },
67
- )
68
-
69
- # Algorand Testnet
70
- ALGORAND_TESTNET = NetworkConfig(
71
- name="algorand-testnet",
72
- display_name="Algorand Testnet",
73
- network_type=NetworkType.ALGORAND,
74
- chain_id=0, # Non-EVM, no chain ID
75
- usdc_address="10458941", # USDC ASA ID on testnet
76
- usdc_decimals=6,
77
- usdc_domain_name="", # Not applicable for Algorand
78
- usdc_domain_version="",
79
- rpc_url="https://testnet-api.algonode.cloud",
80
- enabled=True,
81
- extra_config={
82
- # ASA (Algorand Standard Asset) details
83
- "usdc_asa_id": 10458941,
84
- # Block explorer
85
- "explorer_url": "https://testnet.allo.info",
86
- # Indexer endpoint (for account queries)
87
- "indexer_url": "https://testnet-idx.algonode.cloud",
88
- # Network identifier
89
- "genesis_id": "testnet-v1.0",
90
- # Genesis hash
91
- "genesis_hash": "SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=",
92
- },
93
- )
94
-
95
- # Register Algorand networks
96
- register_network(ALGORAND)
97
- register_network(ALGORAND_TESTNET)
98
-
99
-
100
- # =============================================================================
101
- # Algorand-specific utilities
102
- # =============================================================================
103
-
104
-
105
- def is_algorand_network(network_name: str) -> bool:
106
- """
107
- Check if a network is Algorand.
108
-
109
- Args:
110
- network_name: Network name to check
111
-
112
- Returns:
113
- True if network is Algorand (mainnet or testnet)
114
- """
115
- from uvd_x402_sdk.networks.base import get_network, NetworkType
116
-
117
- network = get_network(network_name)
118
- if not network:
119
- return False
120
- return network.network_type == NetworkType.ALGORAND
121
-
122
-
123
- def get_algorand_networks() -> list:
124
- """
125
- Get all registered Algorand networks.
126
-
127
- Returns:
128
- List of Algorand NetworkConfig instances
129
- """
130
- from uvd_x402_sdk.networks.base import list_networks, NetworkType
131
-
132
- return [
133
- n for n in list_networks(enabled_only=True)
134
- if n.network_type == NetworkType.ALGORAND
135
- ]
136
-
137
-
138
- def is_valid_algorand_address(address: str) -> bool:
139
- """
140
- Validate an Algorand address format.
141
-
142
- Algorand addresses are 58 characters, base32 encoded (RFC 4648).
143
- They consist of uppercase letters A-Z and digits 2-7.
144
-
145
- Args:
146
- address: Address to validate
147
-
148
- Returns:
149
- True if valid Algorand address format
150
- """
151
- if not address or not isinstance(address, str):
152
- return False
153
-
154
- # Algorand addresses are exactly 58 characters
155
- if len(address) != 58:
156
- return False
157
-
158
- # Base32 alphabet (RFC 4648): A-Z and 2-7
159
- base32_pattern = re.compile(r'^[A-Z2-7]+$')
160
- return bool(base32_pattern.match(address))
161
-
162
-
163
- def validate_algorand_payload(payload: Dict[str, Any]) -> bool:
164
- """
165
- Validate an Algorand payment payload structure.
166
-
167
- The payload must contain:
168
- - from: Sender's Algorand address
169
- - to: Recipient's Algorand address
170
- - amount: Amount in base units (microUSDC)
171
- - assetId: ASA ID for USDC
172
- - signedTxn: Base64-encoded signed transaction
173
-
174
- Args:
175
- payload: Payload dictionary from x402 payment
176
-
177
- Returns:
178
- True if valid, raises ValueError if invalid
179
- """
180
- required_fields = ["from", "to", "amount", "assetId", "signedTxn"]
181
-
182
- for field in required_fields:
183
- if field not in payload:
184
- raise ValueError(f"Algorand payload missing '{field}' field")
185
-
186
- # Validate addresses
187
- if not is_valid_algorand_address(payload["from"]):
188
- raise ValueError(f"Invalid 'from' address: {payload['from']}")
189
- if not is_valid_algorand_address(payload["to"]):
190
- raise ValueError(f"Invalid 'to' address: {payload['to']}")
191
-
192
- # Validate amount
193
- try:
194
- amount = int(payload["amount"])
195
- if amount <= 0:
196
- raise ValueError(f"Amount must be positive: {amount}")
197
- except (ValueError, TypeError) as e:
198
- raise ValueError(f"Invalid amount: {payload['amount']}") from e
199
-
200
- # Validate assetId
201
- try:
202
- asset_id = int(payload["assetId"])
203
- if asset_id <= 0:
204
- raise ValueError(f"Asset ID must be positive: {asset_id}")
205
- except (ValueError, TypeError) as e:
206
- raise ValueError(f"Invalid assetId: {payload['assetId']}") from e
207
-
208
- # Validate signedTxn is valid base64
209
- try:
210
- signed_txn = payload["signedTxn"]
211
- tx_bytes = base64.b64decode(signed_txn)
212
- if len(tx_bytes) < 50:
213
- raise ValueError(f"Signed transaction too short: {len(tx_bytes)} bytes")
214
- except Exception as e:
215
- raise ValueError(f"Invalid signedTxn (not valid base64): {e}") from e
216
-
217
- return True
218
-
219
-
220
- def get_explorer_tx_url(network_name: str, tx_id: str) -> Optional[str]:
221
- """
222
- Get block explorer URL for a transaction.
223
-
224
- Args:
225
- network_name: Network name ('algorand' or 'algorand-testnet')
226
- tx_id: Transaction ID
227
-
228
- Returns:
229
- Explorer URL or None if network not found
230
- """
231
- from uvd_x402_sdk.networks.base import get_network
232
-
233
- network = get_network(network_name)
234
- if not network or network.network_type != NetworkType.ALGORAND:
235
- return None
236
-
237
- explorer_url = network.extra_config.get("explorer_url", "https://allo.info")
238
- return f"{explorer_url}/tx/{tx_id}"
239
-
240
-
241
- def get_explorer_address_url(network_name: str, address: str) -> Optional[str]:
242
- """
243
- Get block explorer URL for an address.
244
-
245
- Args:
246
- network_name: Network name ('algorand' or 'algorand-testnet')
247
- address: Algorand address
248
-
249
- Returns:
250
- Explorer URL or None if network not found
251
- """
252
- from uvd_x402_sdk.networks.base import get_network
253
-
254
- network = get_network(network_name)
255
- if not network or network.network_type != NetworkType.ALGORAND:
256
- return None
257
-
258
- explorer_url = network.extra_config.get("explorer_url", "https://allo.info")
259
- return f"{explorer_url}/account/{address}"
260
-
261
-
262
- def get_usdc_asa_id(network_name: str) -> Optional[int]:
263
- """
264
- Get the USDC ASA ID for an Algorand network.
265
-
266
- Args:
267
- network_name: Network name ('algorand' or 'algorand-testnet')
268
-
269
- Returns:
270
- USDC ASA ID or None if network not found
271
- """
272
- from uvd_x402_sdk.networks.base import get_network
273
-
274
- network = get_network(network_name)
275
- if not network or network.network_type != NetworkType.ALGORAND:
276
- return None
277
-
278
- # Try extra_config first, then fall back to usdc_address
279
- asa_id = network.extra_config.get("usdc_asa_id")
280
- if asa_id:
281
- return int(asa_id)
282
-
283
- # Parse from usdc_address (which stores the ASA ID as string)
284
- try:
285
- return int(network.usdc_address)
286
- except (ValueError, TypeError):
287
- return None
File without changes
File without changes
File without changes