algokit-utils 2.4.0b1__py3-none-any.whl → 3.0.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.

Potentially problematic release.


This version of algokit-utils might be problematic. Click here for more details.

Files changed (70) hide show
  1. algokit_utils/__init__.py +23 -181
  2. algokit_utils/_debugging.py +89 -45
  3. algokit_utils/_legacy_v2/__init__.py +177 -0
  4. algokit_utils/{_ensure_funded.py → _legacy_v2/_ensure_funded.py} +21 -24
  5. algokit_utils/{_transfer.py → _legacy_v2/_transfer.py} +26 -23
  6. algokit_utils/_legacy_v2/account.py +203 -0
  7. algokit_utils/_legacy_v2/application_client.py +1472 -0
  8. algokit_utils/_legacy_v2/application_specification.py +21 -0
  9. algokit_utils/_legacy_v2/asset.py +168 -0
  10. algokit_utils/_legacy_v2/common.py +28 -0
  11. algokit_utils/_legacy_v2/deploy.py +822 -0
  12. algokit_utils/_legacy_v2/logic_error.py +14 -0
  13. algokit_utils/{models.py → _legacy_v2/models.py} +16 -45
  14. algokit_utils/_legacy_v2/network_clients.py +144 -0
  15. algokit_utils/account.py +12 -183
  16. algokit_utils/accounts/__init__.py +2 -0
  17. algokit_utils/accounts/account_manager.py +912 -0
  18. algokit_utils/accounts/kmd_account_manager.py +161 -0
  19. algokit_utils/algorand.py +359 -0
  20. algokit_utils/application_client.py +9 -1447
  21. algokit_utils/application_specification.py +39 -197
  22. algokit_utils/applications/__init__.py +7 -0
  23. algokit_utils/applications/abi.py +275 -0
  24. algokit_utils/applications/app_client.py +2108 -0
  25. algokit_utils/applications/app_deployer.py +725 -0
  26. algokit_utils/applications/app_factory.py +1134 -0
  27. algokit_utils/applications/app_manager.py +578 -0
  28. algokit_utils/applications/app_spec/__init__.py +2 -0
  29. algokit_utils/applications/app_spec/arc32.py +207 -0
  30. algokit_utils/applications/app_spec/arc56.py +989 -0
  31. algokit_utils/applications/enums.py +40 -0
  32. algokit_utils/asset.py +32 -168
  33. algokit_utils/assets/__init__.py +1 -0
  34. algokit_utils/assets/asset_manager.py +336 -0
  35. algokit_utils/beta/_utils.py +36 -0
  36. algokit_utils/beta/account_manager.py +4 -195
  37. algokit_utils/beta/algorand_client.py +4 -314
  38. algokit_utils/beta/client_manager.py +5 -74
  39. algokit_utils/beta/composer.py +5 -712
  40. algokit_utils/clients/__init__.py +2 -0
  41. algokit_utils/clients/client_manager.py +738 -0
  42. algokit_utils/clients/dispenser_api_client.py +224 -0
  43. algokit_utils/common.py +8 -26
  44. algokit_utils/config.py +76 -29
  45. algokit_utils/deploy.py +7 -894
  46. algokit_utils/dispenser_api.py +8 -176
  47. algokit_utils/errors/__init__.py +1 -0
  48. algokit_utils/errors/logic_error.py +121 -0
  49. algokit_utils/logic_error.py +7 -82
  50. algokit_utils/models/__init__.py +8 -0
  51. algokit_utils/models/account.py +217 -0
  52. algokit_utils/models/amount.py +200 -0
  53. algokit_utils/models/application.py +91 -0
  54. algokit_utils/models/network.py +29 -0
  55. algokit_utils/models/simulate.py +11 -0
  56. algokit_utils/models/state.py +68 -0
  57. algokit_utils/models/transaction.py +100 -0
  58. algokit_utils/network_clients.py +7 -128
  59. algokit_utils/protocols/__init__.py +2 -0
  60. algokit_utils/protocols/account.py +22 -0
  61. algokit_utils/protocols/typed_clients.py +108 -0
  62. algokit_utils/transactions/__init__.py +3 -0
  63. algokit_utils/transactions/transaction_composer.py +2499 -0
  64. algokit_utils/transactions/transaction_creator.py +688 -0
  65. algokit_utils/transactions/transaction_sender.py +1219 -0
  66. {algokit_utils-2.4.0b1.dist-info → algokit_utils-3.0.0.dist-info}/METADATA +11 -7
  67. algokit_utils-3.0.0.dist-info/RECORD +70 -0
  68. {algokit_utils-2.4.0b1.dist-info → algokit_utils-3.0.0.dist-info}/WHEEL +1 -1
  69. algokit_utils-2.4.0b1.dist-info/RECORD +0 -24
  70. {algokit_utils-2.4.0b1.dist-info → algokit_utils-3.0.0.dist-info}/LICENSE +0 -0
@@ -0,0 +1,2499 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Any, TypedDict, Union, cast
9
+
10
+ import algosdk
11
+ import algosdk.atomic_transaction_composer
12
+ import algosdk.v2client.models
13
+ from algosdk import logic, transaction
14
+ from algosdk.atomic_transaction_composer import (
15
+ AtomicTransactionComposer,
16
+ SimulateAtomicTransactionResponse,
17
+ TransactionSigner,
18
+ TransactionWithSigner,
19
+ )
20
+ from algosdk.transaction import ApplicationCallTxn, OnComplete, SuggestedParams
21
+ from algosdk.v2client.algod import AlgodClient
22
+ from algosdk.v2client.models.simulate_request import SimulateRequest
23
+ from typing_extensions import deprecated
24
+
25
+ from algokit_utils.applications.abi import ABIReturn, ABIValue
26
+ from algokit_utils.applications.app_manager import AppManager
27
+ from algokit_utils.applications.app_spec.arc56 import Method as Arc56Method
28
+ from algokit_utils.config import config
29
+ from algokit_utils.models.state import BoxIdentifier, BoxReference
30
+ from algokit_utils.models.transaction import SendParams, TransactionWrapper
31
+ from algokit_utils.protocols.account import TransactionSignerAccountProtocol
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Callable
35
+
36
+ from algosdk.abi import Method
37
+ from algosdk.v2client.algod import AlgodClient
38
+ from algosdk.v2client.models import SimulateTraceConfig
39
+
40
+ from algokit_utils.models.amount import AlgoAmount
41
+ from algokit_utils.models.transaction import Arc2TransactionNote
42
+
43
+
44
+ __all__ = [
45
+ "AppCallMethodCallParams",
46
+ "AppCallParams",
47
+ "AppCreateMethodCallParams",
48
+ "AppCreateParams",
49
+ "AppCreateSchema",
50
+ "AppDeleteMethodCallParams",
51
+ "AppDeleteParams",
52
+ "AppMethodCallTransactionArgument",
53
+ "AppUpdateMethodCallParams",
54
+ "AppUpdateParams",
55
+ "AssetConfigParams",
56
+ "AssetCreateParams",
57
+ "AssetDestroyParams",
58
+ "AssetFreezeParams",
59
+ "AssetOptInParams",
60
+ "AssetOptOutParams",
61
+ "AssetTransferParams",
62
+ "BuiltTransactions",
63
+ "MethodCallParams",
64
+ "OfflineKeyRegistrationParams",
65
+ "OnlineKeyRegistrationParams",
66
+ "PaymentParams",
67
+ "SendAtomicTransactionComposerResults",
68
+ "TransactionComposer",
69
+ "TransactionComposerBuildResult",
70
+ "TxnParams",
71
+ "calculate_extra_program_pages",
72
+ "populate_app_call_resources",
73
+ "prepare_group_for_sending",
74
+ "send_atomic_transaction_composer",
75
+ ]
76
+
77
+
78
+ logger = config.logger
79
+
80
+ MAX_TRANSACTION_GROUP_SIZE = 16
81
+ MAX_APP_CALL_FOREIGN_REFERENCES = 8
82
+ MAX_APP_CALL_ACCOUNT_REFERENCES = 4
83
+
84
+
85
+ @dataclass(kw_only=True, frozen=True)
86
+ class _CommonTxnParams:
87
+ sender: str
88
+ """The account that will send the transaction"""
89
+ signer: TransactionSigner | TransactionSignerAccountProtocol | None = None
90
+ """The signer for the transaction, defaults to None"""
91
+ rekey_to: str | None = None
92
+ """The account to rekey to, defaults to None"""
93
+ note: bytes | None = None
94
+ """The note for the transaction, defaults to None"""
95
+ lease: bytes | None = None
96
+ """The lease for the transaction, defaults to None"""
97
+ static_fee: AlgoAmount | None = None
98
+ """The static fee for the transaction, defaults to None"""
99
+ extra_fee: AlgoAmount | None = None
100
+ """The extra fee for the transaction, defaults to None"""
101
+ max_fee: AlgoAmount | None = None
102
+ """The maximum fee for the transaction, defaults to None"""
103
+ validity_window: int | None = None
104
+ """The validity window for the transaction, defaults to None"""
105
+ first_valid_round: int | None = None
106
+ """The first valid round for the transaction, defaults to None"""
107
+ last_valid_round: int | None = None
108
+ """The last valid round for the transaction, defaults to None"""
109
+
110
+
111
+ @dataclass(kw_only=True, frozen=True)
112
+ class AdditionalAtcContext:
113
+ max_fees: dict[int, AlgoAmount] | None = None
114
+ """The maximum fees for each transaction, defaults to None"""
115
+ suggested_params: SuggestedParams | None = None
116
+ """The suggested parameters for the transaction, defaults to None"""
117
+
118
+
119
+ @dataclass(kw_only=True, frozen=True)
120
+ class PaymentParams(_CommonTxnParams):
121
+ """Parameters for a payment transaction."""
122
+
123
+ receiver: str
124
+ """The account that will receive the ALGO"""
125
+ amount: AlgoAmount
126
+ """Amount to send"""
127
+ close_remainder_to: str | None = None
128
+ """If given, close the sender account and send the remaining balance to this address, defaults to None"""
129
+
130
+
131
+ @dataclass(kw_only=True, frozen=True)
132
+ class AssetCreateParams(_CommonTxnParams):
133
+ """Parameters for creating a new asset."""
134
+
135
+ total: int
136
+ """The total amount of the smallest divisible unit to create"""
137
+ asset_name: str | None = None
138
+ """The full name of the asset"""
139
+ unit_name: str | None = None
140
+ """The short ticker name for the asset"""
141
+ url: str | None = None
142
+ """The metadata URL for the asset"""
143
+ decimals: int | None = None
144
+ """The amount of decimal places the asset should have"""
145
+ default_frozen: bool | None = None
146
+ """Whether the asset is frozen by default in the creator address"""
147
+ manager: str | None = None
148
+ """The address that can change the manager, reserve, clawback, and freeze addresses"""
149
+ reserve: str | None = None
150
+ """The address that holds the uncirculated supply"""
151
+ freeze: str | None = None
152
+ """The address that can freeze the asset in any account"""
153
+ clawback: str | None = None
154
+ """The address that can clawback the asset from any account"""
155
+ metadata_hash: bytes | None = None
156
+ """Hash of the metadata contained in the metadata URL"""
157
+
158
+
159
+ @dataclass(kw_only=True, frozen=True)
160
+ class AssetConfigParams(_CommonTxnParams):
161
+ """Parameters for configuring an existing asset."""
162
+
163
+ asset_id: int
164
+ """The ID of the asset"""
165
+ manager: str | None = None
166
+ """The address that can change the manager, reserve, clawback, and freeze addresses, defaults to None"""
167
+ reserve: str | None = None
168
+ """The address that holds the uncirculated supply, defaults to None"""
169
+ freeze: str | None = None
170
+ """The address that can freeze the asset in any account, defaults to None"""
171
+ clawback: str | None = None
172
+ """The address that can clawback the asset from any account, defaults to None"""
173
+
174
+
175
+ @dataclass(kw_only=True, frozen=True)
176
+ class AssetFreezeParams(_CommonTxnParams):
177
+ """Parameters for freezing an asset."""
178
+
179
+ asset_id: int
180
+ """The ID of the asset"""
181
+ account: str
182
+ """The account to freeze or unfreeze"""
183
+ frozen: bool
184
+ """Whether the assets in the account should be frozen"""
185
+
186
+
187
+ @dataclass(kw_only=True, frozen=True)
188
+ class AssetDestroyParams(_CommonTxnParams):
189
+ """Parameters for destroying an asset."""
190
+
191
+ asset_id: int
192
+ """The ID of the asset"""
193
+
194
+
195
+ @dataclass(kw_only=True, frozen=True)
196
+ class OnlineKeyRegistrationParams(_CommonTxnParams):
197
+ """Parameters for online key registration."""
198
+
199
+ vote_key: str
200
+ """The root participation public key"""
201
+ selection_key: str
202
+ """The VRF public key"""
203
+ vote_first: int
204
+ """The first round that the participation key is valid"""
205
+ vote_last: int
206
+ """The last round that the participation key is valid"""
207
+ vote_key_dilution: int
208
+ """The dilution for the 2-level participation key"""
209
+ state_proof_key: bytes | None = None
210
+ """The 64 byte state proof public key commitment, defaults to None"""
211
+
212
+
213
+ @dataclass(kw_only=True, frozen=True)
214
+ class OfflineKeyRegistrationParams(_CommonTxnParams):
215
+ """Parameters for offline key registration."""
216
+
217
+ prevent_account_from_ever_participating_again: bool
218
+ """Whether to prevent the account from ever participating again"""
219
+
220
+
221
+ @dataclass(kw_only=True, frozen=True)
222
+ class AssetTransferParams(_CommonTxnParams):
223
+ """Parameters for transferring an asset."""
224
+
225
+ asset_id: int
226
+ """The ID of the asset"""
227
+ amount: int
228
+ """The amount of the asset to transfer (smallest divisible unit)"""
229
+ receiver: str
230
+ """The account to send the asset to"""
231
+ clawback_target: str | None = None
232
+ """The account to take the asset from, defaults to None"""
233
+ close_asset_to: str | None = None
234
+ """The account to close the asset to, defaults to None"""
235
+
236
+
237
+ @dataclass(kw_only=True, frozen=True)
238
+ class AssetOptInParams(_CommonTxnParams):
239
+ """Parameters for opting into an asset."""
240
+
241
+ asset_id: int
242
+ """The ID of the asset"""
243
+
244
+
245
+ @dataclass(kw_only=True, frozen=True)
246
+ class AssetOptOutParams(_CommonTxnParams):
247
+ """Parameters for opting out of an asset."""
248
+
249
+ asset_id: int
250
+ """The ID of the asset"""
251
+ creator: str
252
+ """The creator address of the asset"""
253
+
254
+
255
+ @dataclass(kw_only=True, frozen=True)
256
+ class AppCallParams(_CommonTxnParams):
257
+ """Parameters for calling an application."""
258
+
259
+ on_complete: OnComplete
260
+ """The OnComplete action, defaults to None"""
261
+ app_id: int | None = None
262
+ """The ID of the application, defaults to None"""
263
+ approval_program: str | bytes | None = None
264
+ """The program to execute for all OnCompletes other than ClearState, defaults to None"""
265
+ clear_state_program: str | bytes | None = None
266
+ """The program to execute for ClearState OnComplete, defaults to None"""
267
+ schema: dict[str, int] | None = None
268
+ """The state schema for the app, defaults to None"""
269
+ args: list[bytes] | None = None
270
+ """Application arguments, defaults to None"""
271
+ account_references: list[str] | None = None
272
+ """Account references, defaults to None"""
273
+ app_references: list[int] | None = None
274
+ """App references, defaults to None"""
275
+ asset_references: list[int] | None = None
276
+ """Asset references, defaults to None"""
277
+ extra_pages: int | None = None
278
+ """Number of extra pages required for the programs, defaults to None"""
279
+ box_references: list[BoxReference | BoxIdentifier] | None = None
280
+ """Box references, defaults to None"""
281
+
282
+
283
+ class AppCreateSchema(TypedDict):
284
+ global_ints: int
285
+ """The number of global ints in the schema"""
286
+ global_byte_slices: int
287
+ """The number of global byte slices in the schema"""
288
+ local_ints: int
289
+ """The number of local ints in the schema"""
290
+ local_byte_slices: int
291
+ """The number of local byte slices in the schema"""
292
+
293
+
294
+ @dataclass(kw_only=True, frozen=True)
295
+ class AppCreateParams(_CommonTxnParams):
296
+ """Parameters for creating an application."""
297
+
298
+ approval_program: str | bytes
299
+ """The program to execute for all OnCompletes other than ClearState"""
300
+ clear_state_program: str | bytes
301
+ """The program to execute for ClearState OnComplete"""
302
+ schema: AppCreateSchema | None = None
303
+ """The state schema for the app, defaults to None"""
304
+ on_complete: OnComplete | None = None
305
+ """The OnComplete action, defaults to None"""
306
+ args: list[bytes] | None = None
307
+ """Application arguments, defaults to None"""
308
+ account_references: list[str] | None = None
309
+ """Account references, defaults to None"""
310
+ app_references: list[int] | None = None
311
+ """App references, defaults to None"""
312
+ asset_references: list[int] | None = None
313
+ """Asset references, defaults to None"""
314
+ box_references: list[BoxReference | BoxIdentifier] | None = None
315
+ """Box references, defaults to None"""
316
+ extra_program_pages: int | None = None
317
+ """Number of extra pages required for the programs, defaults to None"""
318
+
319
+
320
+ @dataclass(kw_only=True, frozen=True)
321
+ class AppUpdateParams(_CommonTxnParams):
322
+ """Parameters for updating an application."""
323
+
324
+ app_id: int
325
+ """The ID of the application"""
326
+ approval_program: str | bytes
327
+ """The program to execute for all OnCompletes other than ClearState"""
328
+ clear_state_program: str | bytes
329
+ """The program to execute for ClearState OnComplete"""
330
+ args: list[bytes] | None = None
331
+ """Application arguments, defaults to None"""
332
+ account_references: list[str] | None = None
333
+ """Account references, defaults to None"""
334
+ app_references: list[int] | None = None
335
+ """App references, defaults to None"""
336
+ asset_references: list[int] | None = None
337
+ """Asset references, defaults to None"""
338
+ box_references: list[BoxReference | BoxIdentifier] | None = None
339
+ """Box references, defaults to None"""
340
+ on_complete: OnComplete | None = None
341
+ """The OnComplete action, defaults to None"""
342
+
343
+
344
+ @dataclass(kw_only=True, frozen=True)
345
+ class AppDeleteParams(_CommonTxnParams):
346
+ """Parameters for deleting an application."""
347
+
348
+ app_id: int
349
+ """The ID of the application"""
350
+ args: list[bytes] | None = None
351
+ """Application arguments, defaults to None"""
352
+ account_references: list[str] | None = None
353
+ """Account references, defaults to None"""
354
+ app_references: list[int] | None = None
355
+ """App references, defaults to None"""
356
+ asset_references: list[int] | None = None
357
+ """Asset references, defaults to None"""
358
+ box_references: list[BoxReference | BoxIdentifier] | None = None
359
+ """Box references, defaults to None"""
360
+ on_complete: OnComplete = OnComplete.DeleteApplicationOC
361
+ """The OnComplete action, defaults to DeleteApplicationOC"""
362
+
363
+
364
+ @dataclass(kw_only=True, frozen=True)
365
+ class _BaseAppMethodCall(_CommonTxnParams):
366
+ app_id: int
367
+ """The ID of the application"""
368
+ method: Method
369
+ """The ABI method to call"""
370
+ args: list | None = None
371
+ """Arguments to the ABI method, defaults to None"""
372
+ account_references: list[str] | None = None
373
+ """Account references, defaults to None"""
374
+ app_references: list[int] | None = None
375
+ """App references, defaults to None"""
376
+ asset_references: list[int] | None = None
377
+ """Asset references, defaults to None"""
378
+ box_references: list[BoxReference | BoxIdentifier] | None = None
379
+ """Box references, defaults to None"""
380
+ schema: AppCreateSchema | None = None
381
+ """The state schema for the app, defaults to None"""
382
+ on_complete: OnComplete | None = None
383
+ """The OnComplete action, defaults to None"""
384
+
385
+
386
+ @dataclass(kw_only=True, frozen=True)
387
+ class AppMethodCallParams(_CommonTxnParams):
388
+ """Parameters for calling an application method."""
389
+
390
+ app_id: int
391
+ """The ID of the application"""
392
+ method: Method
393
+ """The ABI method to call"""
394
+ args: list[bytes] | None = None
395
+ """Arguments to the ABI method, defaults to None"""
396
+ on_complete: OnComplete | None = None
397
+ """The OnComplete action, defaults to None"""
398
+ account_references: list[str] | None = None
399
+ """Account references, defaults to None"""
400
+ app_references: list[int] | None = None
401
+ """App references, defaults to None"""
402
+ asset_references: list[int] | None = None
403
+ """Asset references, defaults to None"""
404
+ box_references: list[BoxReference | BoxIdentifier] | None = None
405
+ """Box references, defaults to None"""
406
+
407
+
408
+ @dataclass(kw_only=True, frozen=True)
409
+ class AppCallMethodCallParams(_BaseAppMethodCall):
410
+ """Parameters for a regular ABI method call."""
411
+
412
+ app_id: int
413
+ """The ID of the application"""
414
+ on_complete: OnComplete | None = None
415
+ """The OnComplete action, defaults to None"""
416
+
417
+
418
+ @dataclass(kw_only=True, frozen=True)
419
+ class AppCreateMethodCallParams(_BaseAppMethodCall):
420
+ """Parameters for an ABI method call that creates an application."""
421
+
422
+ approval_program: str | bytes
423
+ """The program to execute for all OnCompletes other than ClearState"""
424
+ clear_state_program: str | bytes
425
+ """The program to execute for ClearState OnComplete"""
426
+ schema: AppCreateSchema | None = None
427
+ """The state schema for the app, defaults to None"""
428
+ on_complete: OnComplete | None = None
429
+ """The OnComplete action (cannot be ClearState), defaults to None"""
430
+ extra_program_pages: int | None = None
431
+ """Number of extra pages required for the programs, defaults to None"""
432
+
433
+
434
+ @dataclass(kw_only=True, frozen=True)
435
+ class AppUpdateMethodCallParams(_BaseAppMethodCall):
436
+ """Parameters for an ABI method call that updates an application."""
437
+
438
+ app_id: int
439
+ """The ID of the application"""
440
+ approval_program: str | bytes
441
+ """The program to execute for all OnCompletes other than ClearState"""
442
+ clear_state_program: str | bytes
443
+ """The program to execute for ClearState OnComplete"""
444
+ on_complete: OnComplete = OnComplete.UpdateApplicationOC
445
+ """The OnComplete action"""
446
+
447
+
448
+ @dataclass(kw_only=True, frozen=True)
449
+ class AppDeleteMethodCallParams(_BaseAppMethodCall):
450
+ """Parameters for an ABI method call that deletes an application."""
451
+
452
+ app_id: int
453
+ """The ID of the application"""
454
+ on_complete: OnComplete = OnComplete.DeleteApplicationOC
455
+ """The OnComplete action"""
456
+
457
+
458
+ MethodCallParams = (
459
+ AppCallMethodCallParams | AppCreateMethodCallParams | AppUpdateMethodCallParams | AppDeleteMethodCallParams
460
+ )
461
+
462
+
463
+ AppMethodCallTransactionArgument = (
464
+ TransactionWithSigner
465
+ | algosdk.transaction.Transaction
466
+ | AppCreateMethodCallParams
467
+ | AppUpdateMethodCallParams
468
+ | AppCallMethodCallParams
469
+ )
470
+
471
+
472
+ TxnParams = Union[ # noqa: UP007
473
+ PaymentParams,
474
+ AssetCreateParams,
475
+ AssetConfigParams,
476
+ AssetFreezeParams,
477
+ AssetDestroyParams,
478
+ OnlineKeyRegistrationParams,
479
+ AssetTransferParams,
480
+ AssetOptInParams,
481
+ AssetOptOutParams,
482
+ AppCallParams,
483
+ AppCreateParams,
484
+ AppUpdateParams,
485
+ AppDeleteParams,
486
+ MethodCallParams,
487
+ OfflineKeyRegistrationParams,
488
+ ]
489
+
490
+
491
+ @dataclass(frozen=True, kw_only=True)
492
+ class TransactionContext:
493
+ """Contextual information for a transaction."""
494
+
495
+ max_fee: AlgoAmount | None = None
496
+ abi_method: Method | None = None
497
+
498
+ @staticmethod
499
+ def empty() -> TransactionContext:
500
+ return TransactionContext(max_fee=None, abi_method=None)
501
+
502
+
503
+ class TransactionWithContext:
504
+ """Combines Transaction with additional context."""
505
+
506
+ def __init__(self, txn: algosdk.transaction.Transaction, context: TransactionContext):
507
+ self.txn = txn
508
+ self.context = context
509
+
510
+
511
+ class TransactionWithSignerAndContext(TransactionWithSigner):
512
+ """Combines TransactionWithSigner with additional context."""
513
+
514
+ def __init__(self, txn: algosdk.transaction.Transaction, signer: TransactionSigner, context: TransactionContext):
515
+ super().__init__(txn, signer)
516
+ self.context = context
517
+
518
+ @staticmethod
519
+ def from_txn_with_context(
520
+ txn_with_context: TransactionWithContext, signer: TransactionSigner
521
+ ) -> TransactionWithSignerAndContext:
522
+ return TransactionWithSignerAndContext(
523
+ txn=txn_with_context.txn, signer=signer, context=txn_with_context.context
524
+ )
525
+
526
+
527
+ @dataclass(frozen=True)
528
+ class BuiltTransactions:
529
+ """Set of transactions built by TransactionComposer."""
530
+
531
+ transactions: list[algosdk.transaction.Transaction]
532
+ """The built transactions"""
533
+ method_calls: dict[int, Method]
534
+ """Map of transaction index to ABI method"""
535
+ signers: dict[int, TransactionSigner]
536
+ """Map of transaction index to TransactionSigner"""
537
+
538
+
539
+ @dataclass
540
+ class TransactionComposerBuildResult:
541
+ """Result of building transactions with TransactionComposer."""
542
+
543
+ atc: AtomicTransactionComposer
544
+ """The AtomicTransactionComposer instance"""
545
+ transactions: list[TransactionWithSigner]
546
+ """The list of transactions with signers"""
547
+ method_calls: dict[int, Method]
548
+ """Map of transaction index to ABI method"""
549
+
550
+
551
+ @dataclass
552
+ class SendAtomicTransactionComposerResults:
553
+ """Results from sending an AtomicTransactionComposer transaction group."""
554
+
555
+ group_id: str
556
+ """The group ID if this was a transaction group"""
557
+ confirmations: list[algosdk.v2client.algod.AlgodResponseType]
558
+ """The confirmation info for each transaction"""
559
+ tx_ids: list[str]
560
+ """The transaction IDs that were sent"""
561
+ transactions: list[TransactionWrapper]
562
+ """The transactions that were sent"""
563
+ returns: list[ABIReturn]
564
+ """The ABI return values from any ABI method calls"""
565
+ simulate_response: dict[str, Any] | None = None
566
+ """The simulation response if simulation was performed, defaults to None"""
567
+
568
+
569
+ class UnnamedResourcesAccessed:
570
+ """Information about unnamed resource access."""
571
+
572
+ def __init__(self, resources_accessed: dict[str, Any] | None = None):
573
+ resources = resources_accessed or {}
574
+
575
+ if not isinstance(resources, dict):
576
+ raise TypeError(f"Expected dictionary object, got {type(resources_accessed)}")
577
+
578
+ self.accounts: list[str] | None = resources.get("accounts", None)
579
+ self.app_locals: list[dict[str, Any]] | None = resources.get("app-locals", None)
580
+ self.apps: list[int] | None = resources.get("apps", None)
581
+ self.asset_holdings: list[dict[str, Any]] | None = resources.get("asset-holdings", None)
582
+ self.assets: list[int] | None = resources.get("assets", None)
583
+ self.boxes: list[dict[str, Any]] | None = resources.get("boxes", None)
584
+ self.extra_box_refs: int | None = resources.get("extra-box-refs", None)
585
+
586
+
587
+ @dataclass
588
+ class ExecutionInfoTxn:
589
+ """Execution info for a transaction."""
590
+
591
+ unnamed_resources_accessed: UnnamedResourcesAccessed | None = None
592
+ """The unnamed resources accessed in the transaction"""
593
+ required_fee_delta: int = 0
594
+ """The required fee delta for the transaction"""
595
+
596
+
597
+ @dataclass
598
+ class ExecutionInfo:
599
+ """Information about transaction execution from simulation."""
600
+
601
+ group_unnamed_resources_accessed: UnnamedResourcesAccessed | None = None
602
+ """The unnamed resources accessed in the group"""
603
+ txns: list[ExecutionInfoTxn] | None = None
604
+ """The execution info for each transaction"""
605
+
606
+
607
+ @dataclass
608
+ class _TransactionWithPriority:
609
+ txn: algosdk.transaction.Transaction
610
+ priority: int
611
+ fee_delta: int
612
+ index: int
613
+
614
+
615
+ MAX_LEASE_LENGTH = 32
616
+ NULL_SIGNER: TransactionSigner = algosdk.atomic_transaction_composer.EmptySigner()
617
+
618
+
619
+ def _encode_lease(lease: str | bytes | None) -> bytes | None:
620
+ if lease is None:
621
+ return None
622
+ elif isinstance(lease, bytes):
623
+ if not (1 <= len(lease) <= MAX_LEASE_LENGTH):
624
+ raise ValueError(
625
+ f"Received invalid lease; expected something with length between 1 and {MAX_LEASE_LENGTH}, "
626
+ f"but received bytes with length {len(lease)}"
627
+ )
628
+ if len(lease) == MAX_LEASE_LENGTH:
629
+ return lease
630
+ lease32 = bytearray(32)
631
+ lease32[: len(lease)] = lease
632
+ return bytes(lease32)
633
+ elif isinstance(lease, str):
634
+ encoded = lease.encode("utf-8")
635
+ if not (1 <= len(encoded) <= MAX_LEASE_LENGTH):
636
+ raise ValueError(
637
+ f"Received invalid lease; expected something with length between 1 and {MAX_LEASE_LENGTH}, "
638
+ f"but received '{lease}' with length {len(lease)}"
639
+ )
640
+ lease32 = bytearray(MAX_LEASE_LENGTH)
641
+ lease32[: len(encoded)] = encoded
642
+ return bytes(lease32)
643
+ else:
644
+ raise TypeError(f"Unknown lease type received of {type(lease)}")
645
+
646
+
647
+ def _get_group_execution_info( # noqa: C901, PLR0912
648
+ atc: AtomicTransactionComposer,
649
+ algod: AlgodClient,
650
+ populate_app_call_resources: bool | None = None,
651
+ cover_app_call_inner_transaction_fees: bool | None = None,
652
+ additional_atc_context: AdditionalAtcContext | None = None,
653
+ ) -> ExecutionInfo:
654
+ # Create simulation request
655
+ suggested_params = additional_atc_context.suggested_params if additional_atc_context else None
656
+ max_fees = additional_atc_context.max_fees if additional_atc_context else None
657
+
658
+ simulate_request = SimulateRequest(
659
+ txn_groups=[],
660
+ allow_unnamed_resources=True,
661
+ allow_empty_signatures=True,
662
+ )
663
+
664
+ # Clone ATC with null signers
665
+ empty_signer_atc = atc.clone()
666
+
667
+ # Track app call indexes without max fees
668
+ app_call_indexes_without_max_fees = []
669
+
670
+ # Copy transactions with null signers
671
+ for i, txn in enumerate(empty_signer_atc.txn_list):
672
+ txn_with_signer = TransactionWithSigner(txn=txn.txn, signer=NULL_SIGNER)
673
+
674
+ if cover_app_call_inner_transaction_fees and isinstance(txn.txn, algosdk.transaction.ApplicationCallTxn):
675
+ if not suggested_params:
676
+ raise ValueError("suggested_params required when cover_app_call_inner_transaction_fees enabled")
677
+
678
+ max_fee = max_fees.get(i).micro_algo if max_fees and i in max_fees else None # type: ignore[union-attr]
679
+ if max_fee is None:
680
+ app_call_indexes_without_max_fees.append(i)
681
+ else:
682
+ txn_with_signer.txn.fee = max_fee
683
+
684
+ if cover_app_call_inner_transaction_fees and app_call_indexes_without_max_fees:
685
+ raise ValueError(
686
+ f"Please provide a `max_fee` for each app call transaction when `cover_app_call_inner_transaction_fees` is enabled. " # noqa: E501
687
+ f"Required for transactions: {', '.join(str(i) for i in app_call_indexes_without_max_fees)}"
688
+ )
689
+
690
+ # Get fee parameters
691
+ per_byte_txn_fee = suggested_params.fee if suggested_params else 0
692
+ min_txn_fee = int(suggested_params.min_fee) if suggested_params else 1000 # type: ignore[unused-ignore]
693
+
694
+ # Simulate transactions
695
+ result = empty_signer_atc.simulate(algod, simulate_request)
696
+
697
+ group_response = result.simulate_response["txn-groups"][0]
698
+
699
+ if group_response.get("failure-message"):
700
+ msg = group_response["failure-message"]
701
+ if cover_app_call_inner_transaction_fees and "fee too small" in msg:
702
+ raise ValueError(
703
+ "Fees were too small to resolve execution info via simulate. "
704
+ "You may need to increase an app call transaction maxFee."
705
+ )
706
+ failed_at = group_response.get("failed-at", [0])[0]
707
+ raise ValueError(
708
+ f"Error resolving execution info via simulate in transaction {failed_at}: "
709
+ f"{group_response['failure-message']}"
710
+ )
711
+
712
+ # Build execution info
713
+ txn_results = []
714
+ for i, txn_result_raw in enumerate(group_response["txn-results"]):
715
+ txn_result = txn_result_raw.get("txn-result")
716
+ if not txn_result:
717
+ continue
718
+
719
+ original_txn = atc.build_group()[i].txn
720
+
721
+ required_fee_delta = 0
722
+ if cover_app_call_inner_transaction_fees:
723
+ # Calculate parent transaction fee
724
+ parent_per_byte_fee = per_byte_txn_fee * (original_txn.estimate_size() + 75)
725
+ parent_min_fee = max(parent_per_byte_fee, min_txn_fee)
726
+ parent_fee_delta = parent_min_fee - original_txn.fee
727
+
728
+ if isinstance(original_txn, algosdk.transaction.ApplicationCallTxn):
729
+ # Calculate inner transaction fees recursively
730
+ def calculate_inner_fee_delta(inner_txns: list[dict], acc: int = 0) -> int:
731
+ for inner_txn in reversed(inner_txns):
732
+ current_fee_delta = (
733
+ calculate_inner_fee_delta(inner_txn["inner-txns"], acc)
734
+ if inner_txn.get("inner-txns")
735
+ else acc
736
+ ) + (min_txn_fee - inner_txn["txn"]["txn"].get("fee", 0))
737
+ acc = max(0, current_fee_delta)
738
+ return acc
739
+
740
+ inner_fee_delta = calculate_inner_fee_delta(txn_result.get("inner-txns", []))
741
+ required_fee_delta = inner_fee_delta + parent_fee_delta
742
+ else:
743
+ required_fee_delta = parent_fee_delta
744
+
745
+ txn_results.append(
746
+ ExecutionInfoTxn(
747
+ unnamed_resources_accessed=UnnamedResourcesAccessed(txn_result_raw.get("unnamed-resources-accessed"))
748
+ if populate_app_call_resources
749
+ else None,
750
+ required_fee_delta=required_fee_delta,
751
+ )
752
+ )
753
+
754
+ return ExecutionInfo(
755
+ group_unnamed_resources_accessed=UnnamedResourcesAccessed(group_response.get("unnamed-resources-accessed"))
756
+ if populate_app_call_resources
757
+ else None,
758
+ txns=txn_results,
759
+ )
760
+
761
+
762
+ def _find_available_transaction_index(
763
+ txns: list[TransactionWithSigner], reference_type: str, reference: str | dict[str, Any] | int
764
+ ) -> int:
765
+ """Find index of first transaction that can accommodate the new reference."""
766
+
767
+ def check_transaction(txn: TransactionWithSigner) -> bool:
768
+ # Skip if not an application call transaction
769
+ if txn.txn.type != "appl":
770
+ return False
771
+
772
+ # Get current counts (using get() with default 0 for Pythonic null handling)
773
+ accounts = len(getattr(txn.txn, "accounts", []) or [])
774
+ assets = len(getattr(txn.txn, "foreign_assets", []) or [])
775
+ apps = len(getattr(txn.txn, "foreign_apps", []) or [])
776
+ boxes = len(getattr(txn.txn, "boxes", []) or [])
777
+
778
+ # For account references, only check account limit
779
+ if reference_type == "account":
780
+ return accounts < MAX_APP_CALL_ACCOUNT_REFERENCES
781
+
782
+ # For asset holdings or local state, need space for both account and other reference
783
+ if reference_type in ("asset_holding", "app_local"):
784
+ return (
785
+ accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES - 1
786
+ and accounts < MAX_APP_CALL_ACCOUNT_REFERENCES
787
+ )
788
+
789
+ # For boxes with non-zero app ID, need space for box and app reference
790
+ if reference_type == "box" and reference and int(getattr(reference, "app", 0)) != 0:
791
+ return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES - 1
792
+
793
+ # Default case - just check total references
794
+ return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES
795
+
796
+ # Return first matching index or -1 if none found
797
+ return next((i for i, txn in enumerate(txns) if check_transaction(txn)), -1)
798
+
799
+
800
+ def calculate_extra_program_pages(approval: bytes | None, clear: bytes | None) -> int:
801
+ """Calculate minimum number of extra_pages required for provided approval and clear programs"""
802
+ total = len(approval or b"") + len(clear or b"")
803
+ return max(0, (total - 1) // algosdk.constants.APP_PAGE_MAX_SIZE)
804
+
805
+
806
+ def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClient) -> AtomicTransactionComposer:
807
+ """Populate application call resources based on simulation results.
808
+
809
+ :param atc: The AtomicTransactionComposer containing transactions
810
+ :param algod: Algod client for simulation
811
+ :return: Modified AtomicTransactionComposer with populated resources
812
+ """
813
+ return prepare_group_for_sending(atc, algod, populate_app_call_resources=True)
814
+
815
+
816
+ def prepare_group_for_sending( # noqa: C901, PLR0912, PLR0915
817
+ atc: AtomicTransactionComposer,
818
+ algod: AlgodClient,
819
+ populate_app_call_resources: bool | None = None,
820
+ cover_app_call_inner_transaction_fees: bool | None = None,
821
+ additional_atc_context: AdditionalAtcContext | None = None,
822
+ ) -> AtomicTransactionComposer:
823
+ """Prepare a transaction group for sending by handling execution info and resources.
824
+
825
+ :param atc: The AtomicTransactionComposer containing transactions
826
+ :param algod: Algod client for simulation
827
+ :param populate_app_call_resources: Whether to populate app call resources
828
+ :param cover_app_call_inner_transaction_fees: Whether to cover inner txn fees
829
+ :param additional_atc_context: Additional context for the AtomicTransactionComposer
830
+ :return: Modified AtomicTransactionComposer ready for sending
831
+ """
832
+ # Get execution info via simulation
833
+ execution_info = _get_group_execution_info(
834
+ atc, algod, populate_app_call_resources, cover_app_call_inner_transaction_fees, additional_atc_context
835
+ )
836
+ max_fees = additional_atc_context.max_fees if additional_atc_context else None
837
+
838
+ group = atc.build_group()
839
+
840
+ # Handle transaction fees if needed
841
+ if cover_app_call_inner_transaction_fees:
842
+ # Sort transactions by fee priority
843
+ txns_with_priority: list[_TransactionWithPriority] = []
844
+ for i, txn_info in enumerate(execution_info.txns or []):
845
+ if not txn_info:
846
+ continue
847
+ txn = group[i].txn
848
+ max_fee = max_fees.get(i).micro_algo if max_fees and i in max_fees else None # type: ignore[union-attr]
849
+ immutable_fee = max_fee is not None and max_fee == txn.fee
850
+ priority_multiplier = (
851
+ 1000
852
+ if (
853
+ txn_info.required_fee_delta > 0
854
+ and (immutable_fee or not isinstance(txn, algosdk.transaction.ApplicationCallTxn))
855
+ )
856
+ else 1
857
+ )
858
+
859
+ txns_with_priority.append(
860
+ _TransactionWithPriority(
861
+ txn=txn,
862
+ index=i,
863
+ fee_delta=txn_info.required_fee_delta,
864
+ priority=txn_info.required_fee_delta * priority_multiplier
865
+ if txn_info.required_fee_delta > 0
866
+ else -1,
867
+ )
868
+ )
869
+
870
+ # Sort by priority descending
871
+ txns_with_priority.sort(key=lambda x: x.priority, reverse=True)
872
+
873
+ # Calculate surplus fees and additional fees needed
874
+ surplus_fees = sum(
875
+ txn_info.required_fee_delta * -1
876
+ for txn_info in execution_info.txns or []
877
+ if txn_info is not None and txn_info.required_fee_delta < 0
878
+ )
879
+
880
+ additional_fees = {}
881
+
882
+ # Distribute surplus fees to cover deficits
883
+ for txn_obj in txns_with_priority:
884
+ if txn_obj.fee_delta > 0:
885
+ if surplus_fees >= txn_obj.fee_delta:
886
+ surplus_fees -= txn_obj.fee_delta
887
+ else:
888
+ additional_fees[txn_obj.index] = txn_obj.fee_delta - surplus_fees
889
+ surplus_fees = 0
890
+
891
+ def populate_group_resource( # noqa: PLR0915, PLR0912, C901
892
+ txns: list[TransactionWithSigner], reference: str | dict[str, Any] | int, ref_type: str
893
+ ) -> None:
894
+ """Helper function to populate group-level resources."""
895
+
896
+ def is_appl_below_limit(t: TransactionWithSigner) -> bool:
897
+ if not isinstance(t.txn, transaction.ApplicationCallTxn):
898
+ return False
899
+
900
+ accounts = len(getattr(t.txn, "accounts", []) or [])
901
+ assets = len(getattr(t.txn, "foreign_assets", []) or [])
902
+ apps = len(getattr(t.txn, "foreign_apps", []) or [])
903
+ boxes = len(getattr(t.txn, "boxes", []) or [])
904
+
905
+ return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES
906
+
907
+ # Handle asset holding and app local references first
908
+ if ref_type in ("assetHolding", "appLocal"):
909
+ ref_dict = cast(dict[str, Any], reference)
910
+ account = ref_dict["account"]
911
+
912
+ # First try to find transaction with account already available
913
+ txn_idx = next(
914
+ (
915
+ i
916
+ for i, t in enumerate(txns)
917
+ if is_appl_below_limit(t)
918
+ and isinstance(t.txn, transaction.ApplicationCallTxn)
919
+ and (
920
+ account in (getattr(t.txn, "accounts", []) or [])
921
+ or account
922
+ in (
923
+ logic.get_application_address(app_id)
924
+ for app_id in (getattr(t.txn, "foreign_apps", []) or [])
925
+ )
926
+ or any(str(account) in str(v) for v in t.txn.__dict__.values())
927
+ )
928
+ ),
929
+ -1,
930
+ )
931
+
932
+ if txn_idx >= 0:
933
+ app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn)
934
+ if ref_type == "assetHolding":
935
+ asset_id = ref_dict["asset"]
936
+ app_txn.foreign_assets = [*list(getattr(app_txn, "foreign_assets", []) or []), asset_id]
937
+ else:
938
+ app_id = ref_dict["app"]
939
+ app_txn.foreign_apps = [*list(getattr(app_txn, "foreign_apps", []) or []), app_id]
940
+ return
941
+
942
+ # Try to find transaction that already has the app/asset available
943
+ txn_idx = next(
944
+ (
945
+ i
946
+ for i, t in enumerate(txns)
947
+ if is_appl_below_limit(t)
948
+ and isinstance(t.txn, transaction.ApplicationCallTxn)
949
+ and len(getattr(t.txn, "accounts", []) or []) < MAX_APP_CALL_ACCOUNT_REFERENCES
950
+ and (
951
+ (
952
+ ref_type == "assetHolding"
953
+ and ref_dict["asset"] in (getattr(t.txn, "foreign_assets", []) or [])
954
+ )
955
+ or (
956
+ ref_type == "appLocal"
957
+ and (
958
+ ref_dict["app"] in (getattr(t.txn, "foreign_apps", []) or [])
959
+ or t.txn.index == ref_dict["app"]
960
+ )
961
+ )
962
+ )
963
+ ),
964
+ -1,
965
+ )
966
+
967
+ if txn_idx >= 0:
968
+ app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn)
969
+ accounts = list(getattr(app_txn, "accounts", []) or [])
970
+ accounts.append(account)
971
+ app_txn.accounts = accounts
972
+ return
973
+
974
+ # Handle box references
975
+ if ref_type == "box":
976
+ box_ref = (reference["app"], base64.b64decode(reference["name"])) # type: ignore[index]
977
+
978
+ # Try to find transaction that already has the app available
979
+ txn_idx = next(
980
+ (
981
+ i
982
+ for i, t in enumerate(txns)
983
+ if is_appl_below_limit(t)
984
+ and isinstance(t.txn, transaction.ApplicationCallTxn)
985
+ and (box_ref[0] in (getattr(t.txn, "foreign_apps", []) or []) or t.txn.index == box_ref[0])
986
+ ),
987
+ -1,
988
+ )
989
+
990
+ if txn_idx >= 0:
991
+ app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn)
992
+ boxes = list(getattr(app_txn, "boxes", []) or [])
993
+ boxes.append(BoxReference.translate_box_reference(box_ref, app_txn.foreign_apps or [], app_txn.index)) # type: ignore[arg-type]
994
+ app_txn.boxes = boxes
995
+ return
996
+
997
+ # Find available transaction for the resource
998
+ txn_idx = _find_available_transaction_index(txns, ref_type, reference)
999
+
1000
+ if txn_idx == -1:
1001
+ raise ValueError("No more transactions below reference limit. Add another app call to the group.")
1002
+
1003
+ app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn)
1004
+
1005
+ if ref_type == "account":
1006
+ accounts = list(getattr(app_txn, "accounts", []) or [])
1007
+ accounts.append(cast(str, reference))
1008
+ app_txn.accounts = accounts
1009
+ elif ref_type == "app":
1010
+ app_id = int(cast(str | int, reference))
1011
+ foreign_apps = list(getattr(app_txn, "foreign_apps", []) or [])
1012
+ foreign_apps.append(app_id)
1013
+ app_txn.foreign_apps = foreign_apps
1014
+ elif ref_type == "box":
1015
+ boxes = list(getattr(app_txn, "boxes", []) or [])
1016
+ boxes.append(BoxReference.translate_box_reference(box_ref, app_txn.foreign_apps or [], app_txn.index)) # type: ignore[arg-type]
1017
+ app_txn.boxes = boxes
1018
+ if box_ref[0] != 0:
1019
+ foreign_apps = list(getattr(app_txn, "foreign_apps", []) or [])
1020
+ foreign_apps.append(box_ref[0])
1021
+ app_txn.foreign_apps = foreign_apps
1022
+ elif ref_type == "asset":
1023
+ asset_id = int(cast(str | int, reference))
1024
+ foreign_assets = list(getattr(app_txn, "foreign_assets", []) or [])
1025
+ foreign_assets.append(asset_id)
1026
+ app_txn.foreign_assets = foreign_assets
1027
+ elif ref_type == "assetHolding":
1028
+ ref_dict = cast(dict[str, Any], reference)
1029
+ foreign_assets = list(getattr(app_txn, "foreign_assets", []) or [])
1030
+ foreign_assets.append(ref_dict["asset"])
1031
+ app_txn.foreign_assets = foreign_assets
1032
+ accounts = list(getattr(app_txn, "accounts", []) or [])
1033
+ accounts.append(ref_dict["account"])
1034
+ app_txn.accounts = accounts
1035
+ elif ref_type == "appLocal":
1036
+ ref_dict = cast(dict[str, Any], reference)
1037
+ foreign_apps = list(getattr(app_txn, "foreign_apps", []) or [])
1038
+ foreign_apps.append(ref_dict["app"])
1039
+ app_txn.foreign_apps = foreign_apps
1040
+ accounts = list(getattr(app_txn, "accounts", []) or [])
1041
+ accounts.append(ref_dict["account"])
1042
+ app_txn.accounts = accounts
1043
+
1044
+ # Process transaction-level resources
1045
+ for i, txn_info in enumerate(execution_info.txns or []):
1046
+ if not txn_info:
1047
+ continue
1048
+
1049
+ # Validate no unexpected resources
1050
+ is_app_txn = isinstance(group[i].txn, algosdk.transaction.ApplicationCallTxn)
1051
+ resources = txn_info.unnamed_resources_accessed
1052
+ if resources and is_app_txn:
1053
+ app_txn = group[i].txn
1054
+ if resources.boxes or resources.extra_box_refs:
1055
+ raise ValueError("Unexpected boxes at transaction level")
1056
+ if resources.app_locals:
1057
+ raise ValueError("Unexpected app local at transaction level")
1058
+ if resources.asset_holdings:
1059
+ raise ValueError("Unexpected asset holding at transaction level")
1060
+
1061
+ # Update application call fields
1062
+ accounts = list(getattr(app_txn, "accounts", []) or [])
1063
+ foreign_apps = list(getattr(app_txn, "foreign_apps", []) or [])
1064
+ foreign_assets = list(getattr(app_txn, "foreign_assets", []) or [])
1065
+ boxes = list(getattr(app_txn, "boxes", []) or [])
1066
+
1067
+ # Add new resources
1068
+ accounts.extend(resources.accounts or [])
1069
+ foreign_apps.extend(resources.apps or [])
1070
+ foreign_assets.extend(resources.assets or [])
1071
+ boxes.extend(resources.boxes or [])
1072
+
1073
+ # Validate limits
1074
+ if len(accounts) > MAX_APP_CALL_ACCOUNT_REFERENCES:
1075
+ raise ValueError(
1076
+ f"Account reference limit of {MAX_APP_CALL_ACCOUNT_REFERENCES} exceeded in transaction {i}"
1077
+ )
1078
+
1079
+ total_refs = len(accounts) + len(foreign_assets) + len(foreign_apps) + len(boxes)
1080
+ if total_refs > MAX_APP_CALL_FOREIGN_REFERENCES:
1081
+ raise ValueError(
1082
+ f"Resource reference limit of {MAX_APP_CALL_FOREIGN_REFERENCES} exceeded in transaction {i}"
1083
+ )
1084
+
1085
+ # Update transaction
1086
+ app_txn.accounts = accounts # type: ignore[attr-defined]
1087
+ app_txn.foreign_apps = foreign_apps # type: ignore[attr-defined]
1088
+ app_txn.foreign_assets = foreign_assets # type: ignore[attr-defined]
1089
+ app_txn.boxes = boxes # type: ignore[attr-defined]
1090
+
1091
+ # Update fees if needed
1092
+ if cover_app_call_inner_transaction_fees and i in additional_fees:
1093
+ cur_txn = group[i].txn
1094
+ additional_fee = additional_fees[i]
1095
+ if not isinstance(cur_txn, algosdk.transaction.ApplicationCallTxn):
1096
+ raise ValueError(
1097
+ f"An additional fee of {additional_fee} µALGO is required for non app call transaction {i}"
1098
+ )
1099
+
1100
+ transaction_fee = cur_txn.fee + additional_fee
1101
+ max_fee = max_fees.get(i).micro_algo if max_fees and i in max_fees else None # type: ignore[union-attr]
1102
+
1103
+ if max_fee is None or transaction_fee > max_fee:
1104
+ raise ValueError(
1105
+ f"Calculated transaction fee {transaction_fee} µALGO is greater "
1106
+ f"than max of {max_fee or 'undefined'} "
1107
+ f"for transaction {i}"
1108
+ )
1109
+ cur_txn.fee = transaction_fee
1110
+
1111
+ # Process group-level resources
1112
+ group_resources = execution_info.group_unnamed_resources_accessed
1113
+ if group_resources:
1114
+ # Handle cross-reference resources first
1115
+ for app_local in group_resources.app_locals or []:
1116
+ populate_group_resource(group, app_local, "appLocal")
1117
+ # Remove processed resources
1118
+ if group_resources.accounts:
1119
+ group_resources.accounts = [acc for acc in group_resources.accounts if acc != app_local["account"]]
1120
+ if group_resources.apps:
1121
+ group_resources.apps = [app for app in group_resources.apps if int(app) != int(app_local["app"])]
1122
+
1123
+ for asset_holding in group_resources.asset_holdings or []:
1124
+ populate_group_resource(group, asset_holding, "assetHolding")
1125
+ # Remove processed resources
1126
+ if group_resources.accounts:
1127
+ group_resources.accounts = [acc for acc in group_resources.accounts if acc != asset_holding["account"]]
1128
+ if group_resources.assets:
1129
+ group_resources.assets = [
1130
+ asset for asset in group_resources.assets if int(asset) != int(asset_holding["asset"])
1131
+ ]
1132
+
1133
+ # Handle remaining resources
1134
+ for account in group_resources.accounts or []:
1135
+ populate_group_resource(group, account, "account")
1136
+
1137
+ for box in group_resources.boxes or []:
1138
+ populate_group_resource(group, box, "box")
1139
+ if group_resources.apps:
1140
+ group_resources.apps = [app for app in group_resources.apps if int(app) != int(box["app"])]
1141
+
1142
+ for asset in group_resources.assets or []:
1143
+ populate_group_resource(group, asset, "asset")
1144
+
1145
+ for app in group_resources.apps or []:
1146
+ populate_group_resource(group, app, "app")
1147
+
1148
+ # Handle extra box references
1149
+ extra_box_refs = group_resources.extra_box_refs or 0
1150
+ for _ in range(extra_box_refs):
1151
+ populate_group_resource(group, {"app": 0, "name": ""}, "box")
1152
+
1153
+ # Create new ATC with updated transactions
1154
+ new_atc = AtomicTransactionComposer()
1155
+ for txn_with_signer in group:
1156
+ txn_with_signer.txn.group = None
1157
+ new_atc.add_transaction(txn_with_signer)
1158
+ new_atc.method_dict = deepcopy(atc.method_dict)
1159
+
1160
+ return new_atc
1161
+
1162
+
1163
+ def send_atomic_transaction_composer( # noqa: C901, PLR0912
1164
+ atc: AtomicTransactionComposer,
1165
+ algod: AlgodClient,
1166
+ *,
1167
+ max_rounds_to_wait: int | None = 5,
1168
+ skip_waiting: bool = False,
1169
+ suppress_log: bool | None = None,
1170
+ populate_app_call_resources: bool | None = None,
1171
+ cover_app_call_inner_transaction_fees: bool | None = None,
1172
+ additional_atc_context: AdditionalAtcContext | None = None,
1173
+ ) -> SendAtomicTransactionComposerResults:
1174
+ """Send an AtomicTransactionComposer transaction group.
1175
+
1176
+ Executes a group of transactions atomically using the AtomicTransactionComposer.
1177
+
1178
+ :param atc: The AtomicTransactionComposer instance containing the transaction group to send
1179
+ :param algod: The Algod client to use for sending the transactions
1180
+ :param max_rounds_to_wait: Maximum number of rounds to wait for confirmation, defaults to 5
1181
+ :param skip_waiting: If True, don't wait for transaction confirmation, defaults to False
1182
+ :param suppress_log: If True, suppress logging, defaults to None
1183
+ :param populate_app_call_resources: If True, populate app call resources, defaults to None
1184
+ :param cover_app_call_inner_transaction_fees: If True, cover app call inner transaction fees, defaults to None
1185
+ :param additional_atc_context: Additional context for the AtomicTransactionComposer
1186
+ :return: Results from sending the transaction group
1187
+ :raises Exception: If there is an error sending the transactions
1188
+ :raises error: If there is an error from the Algorand node
1189
+ """
1190
+ from algokit_utils._debugging import simulate_and_persist_response, simulate_response
1191
+
1192
+ try:
1193
+ # Build transactions
1194
+ transactions_with_signer = atc.build_group()
1195
+
1196
+ populate_app_call_resources = (
1197
+ populate_app_call_resources
1198
+ if populate_app_call_resources is not None
1199
+ else config.populate_app_call_resource
1200
+ )
1201
+
1202
+ if (populate_app_call_resources or cover_app_call_inner_transaction_fees) and any(
1203
+ isinstance(t.txn, algosdk.transaction.ApplicationCallTxn) for t in transactions_with_signer
1204
+ ):
1205
+ atc = prepare_group_for_sending(
1206
+ atc,
1207
+ algod,
1208
+ populate_app_call_resources,
1209
+ cover_app_call_inner_transaction_fees,
1210
+ additional_atc_context,
1211
+ )
1212
+
1213
+ transactions_to_send = [t.txn for t in transactions_with_signer]
1214
+
1215
+ # Get group ID if multiple transactions
1216
+ group_id = None
1217
+ if len(transactions_to_send) > 1:
1218
+ group_id = (
1219
+ base64.b64encode(transactions_to_send[0].group).decode("utf-8") if transactions_to_send[0].group else ""
1220
+ )
1221
+
1222
+ if not suppress_log:
1223
+ logger.info(
1224
+ f"Sending group of {len(transactions_to_send)} transactions ({group_id})",
1225
+ extra={"suppress_log": suppress_log or False},
1226
+ )
1227
+ logger.debug(
1228
+ f"Transaction IDs ({group_id}): {[t.get_txid() for t in transactions_to_send]}",
1229
+ extra={"suppress_log": suppress_log or False},
1230
+ )
1231
+
1232
+ # Simulate if debug enabled
1233
+ if config.debug and config.trace_all and config.project_root:
1234
+ simulate_and_persist_response(
1235
+ atc,
1236
+ config.project_root,
1237
+ algod,
1238
+ config.trace_buffer_size_mb,
1239
+ )
1240
+
1241
+ # Execute transactions
1242
+ result = atc.execute(algod, wait_rounds=max_rounds_to_wait or 5)
1243
+
1244
+ # Log results
1245
+ if not suppress_log:
1246
+ if len(transactions_to_send) > 1:
1247
+ logger.info(
1248
+ f"Group transaction ({group_id}) sent with {len(transactions_to_send)} transactions",
1249
+ extra={"suppress_log": suppress_log or False},
1250
+ )
1251
+ else:
1252
+ logger.info(
1253
+ f"Sent transaction ID {transactions_to_send[0].get_txid()}",
1254
+ extra={"suppress_log": suppress_log or False},
1255
+ )
1256
+
1257
+ # Get confirmations if not skipping
1258
+ confirmations = None
1259
+ if not skip_waiting:
1260
+ confirmations = [algod.pending_transaction_info(t.get_txid()) for t in transactions_to_send]
1261
+
1262
+ # Return results
1263
+ return SendAtomicTransactionComposerResults(
1264
+ group_id=group_id or "",
1265
+ confirmations=confirmations or [],
1266
+ tx_ids=[t.get_txid() for t in transactions_to_send],
1267
+ transactions=[TransactionWrapper(t) for t in transactions_to_send],
1268
+ returns=[ABIReturn(r) for r in result.abi_results],
1269
+ )
1270
+
1271
+ except Exception as e:
1272
+ # Handle error with debug info if enabled
1273
+ if config.debug:
1274
+ logger.error(
1275
+ "Received error executing Atomic Transaction Composer and debug flag enabled; "
1276
+ "attempting simulation to get more information ",
1277
+ extra={"suppress_log": suppress_log or False},
1278
+ exc_info=e,
1279
+ )
1280
+
1281
+ simulate = None
1282
+ if config.project_root and not config.trace_all:
1283
+ # Only simulate if trace_all is disabled and project_root is set
1284
+ simulate = simulate_and_persist_response(atc, config.project_root, algod, config.trace_buffer_size_mb)
1285
+ else:
1286
+ simulate = simulate_response(atc, algod)
1287
+
1288
+ traces = []
1289
+ if simulate and simulate.failed_at:
1290
+ for txn_group in simulate.simulate_response["txn-groups"]:
1291
+ app_budget = txn_group.get("app-budget-added")
1292
+ app_budget_consumed = txn_group.get("app-budget-consumed")
1293
+ failure_message = txn_group.get("failure-message")
1294
+ txn_result = txn_group.get("txn-results", [{}])[0]
1295
+ exec_trace = txn_result.get("exec-trace", {})
1296
+
1297
+ traces.append(
1298
+ {
1299
+ "trace": exec_trace,
1300
+ "app_budget": app_budget,
1301
+ "app_budget_consumed": app_budget_consumed,
1302
+ "failure_message": failure_message,
1303
+ }
1304
+ )
1305
+
1306
+ error = Exception(f"Transaction failed: {e}")
1307
+ error.traces = traces # type: ignore[attr-defined]
1308
+ raise error from e
1309
+
1310
+ logger.error(
1311
+ "Received error executing Atomic Transaction Composer, for more information enable the debug flag",
1312
+ extra={"suppress_log": suppress_log or False},
1313
+ exc_info=e,
1314
+ )
1315
+ raise e
1316
+
1317
+
1318
+ class TransactionComposer:
1319
+ """A class for composing and managing Algorand transactions.
1320
+
1321
+ Provides a high-level interface for building and executing transaction groups using the Algosdk library.
1322
+ Supports various transaction types including payments, asset operations, application calls, and key registrations.
1323
+
1324
+ :param algod: An instance of AlgodClient used to get suggested params and send transactions
1325
+ :param get_signer: A function that takes an address and returns a TransactionSigner for that address
1326
+ :param get_suggested_params: Optional function to get suggested transaction parameters,
1327
+ defaults to using algod.suggested_params()
1328
+ :param default_validity_window: Optional default validity window for transactions in rounds, defaults to 10
1329
+ :param app_manager: Optional AppManager instance for compiling TEAL programs, defaults to None
1330
+ """
1331
+
1332
+ def __init__(
1333
+ self,
1334
+ algod: AlgodClient,
1335
+ get_signer: Callable[[str], TransactionSigner],
1336
+ get_suggested_params: Callable[[], algosdk.transaction.SuggestedParams] | None = None,
1337
+ default_validity_window: int | None = None,
1338
+ app_manager: AppManager | None = None,
1339
+ ):
1340
+ # Map of transaction index in the atc to a max logical fee.
1341
+ # This is set using the value of either maxFee or staticFee.
1342
+ self._txn_max_fees: dict[int, AlgoAmount] = {}
1343
+ self._txns: list[TransactionWithSigner | TxnParams | AtomicTransactionComposer] = []
1344
+ self._atc: AtomicTransactionComposer = AtomicTransactionComposer()
1345
+ self._algod: AlgodClient = algod
1346
+ self._default_get_send_params = lambda: self._algod.suggested_params()
1347
+ self._get_suggested_params = get_suggested_params or self._default_get_send_params
1348
+ self._get_signer: Callable[[str], TransactionSigner] = get_signer
1349
+ self._default_validity_window: int = default_validity_window or 10
1350
+ self._default_validity_window_is_explicit: bool = default_validity_window is not None
1351
+ self._app_manager = app_manager or AppManager(algod)
1352
+
1353
+ def add_transaction(
1354
+ self, transaction: algosdk.transaction.Transaction, signer: TransactionSigner | None = None
1355
+ ) -> TransactionComposer:
1356
+ """Add a raw transaction to the composer.
1357
+
1358
+ :param transaction: The transaction to add
1359
+ :param signer: Optional transaction signer, defaults to getting signer from transaction sender
1360
+ :return: The transaction composer instance for chaining
1361
+
1362
+ :example:
1363
+ >>> composer.add_transaction(transaction)
1364
+ """
1365
+ self._txns.append(TransactionWithSigner(txn=transaction, signer=signer or self._get_signer(transaction.sender)))
1366
+ return self
1367
+
1368
+ def add_payment(self, params: PaymentParams) -> TransactionComposer:
1369
+ """Add a payment transaction.
1370
+
1371
+ :example:
1372
+ >>> params = PaymentParams(
1373
+ ... sender="SENDER_ADDRESS",
1374
+ ... receiver="RECEIVER_ADDRESS",
1375
+ ... amount=AlgoAmount.from_algo(1),
1376
+ ... close_remainder_to="CLOSE_ADDRESS"
1377
+ ... ... (see PaymentParams for more options)
1378
+ ... )
1379
+ >>> composer.add_payment(params)
1380
+
1381
+ :param params: The payment transaction parameters
1382
+ :return: The transaction composer instance for chaining
1383
+ """
1384
+ self._txns.append(params)
1385
+ return self
1386
+
1387
+ def add_asset_create(self, params: AssetCreateParams) -> TransactionComposer:
1388
+ """Add an asset creation transaction.
1389
+
1390
+ :example:
1391
+ >>> params = AssetCreateParams(
1392
+ ... sender="SENDER_ADDRESS",
1393
+ ... total=1000,
1394
+ ... asset_name="MyAsset",
1395
+ ... unit_name="MA",
1396
+ ... url="https://example.com",
1397
+ ... decimals=0,
1398
+ ... default_frozen=False,
1399
+ ... manager="MANAGER_ADDRESS",
1400
+ ... reserve="RESERVE_ADDRESS",
1401
+ ... freeze="FREEZE_ADDRESS",
1402
+ ... clawback="CLAWBACK_ADDRESS"
1403
+ ... ... (see AssetCreateParams for more options)
1404
+ >>> composer.add_asset_create(params)
1405
+
1406
+ :param params: The asset creation parameters
1407
+ :return: The transaction composer instance for chaining
1408
+ """
1409
+ self._txns.append(params)
1410
+ return self
1411
+
1412
+ def add_asset_config(self, params: AssetConfigParams) -> TransactionComposer:
1413
+ """Add an asset configuration transaction.
1414
+
1415
+ :example:
1416
+ >>> params = AssetConfigParams(
1417
+ ... sender="SENDER_ADDRESS",
1418
+ ... asset_id=123456,
1419
+ ... manager="NEW_MANAGER_ADDRESS",
1420
+ ... reserve="NEW_RESERVE_ADDRESS",
1421
+ ... freeze="NEW_FREEZE_ADDRESS",
1422
+ ... clawback="NEW_CLAWBACK_ADDRESS"
1423
+ ... ... (see AssetConfigParams for more options)
1424
+ ... )
1425
+ >>> composer.add_asset_config(params)
1426
+
1427
+ :param params: The asset configuration parameters
1428
+ :return: The transaction composer instance for chaining
1429
+ """
1430
+ self._txns.append(params)
1431
+ return self
1432
+
1433
+ def add_asset_freeze(self, params: AssetFreezeParams) -> TransactionComposer:
1434
+ """Add an asset freeze transaction.
1435
+
1436
+ :example:
1437
+ >>> params = AssetFreezeParams(
1438
+ ... sender="SENDER_ADDRESS",
1439
+ ... asset_id=123456,
1440
+ ... account="ACCOUNT_TO_FREEZE",
1441
+ ... frozen=True
1442
+ ... ... (see AssetFreezeParams for more options)
1443
+ ... )
1444
+ >>> composer.add_asset_freeze(params)
1445
+
1446
+ :param params: The asset freeze parameters
1447
+ :return: The transaction composer instance for chaining
1448
+ """
1449
+ self._txns.append(params)
1450
+ return self
1451
+
1452
+ def add_asset_destroy(self, params: AssetDestroyParams) -> TransactionComposer:
1453
+ """Add an asset destruction transaction.
1454
+
1455
+ :example:
1456
+ >>> params = AssetDestroyParams(
1457
+ ... sender="SENDER_ADDRESS",
1458
+ ... asset_id=123456
1459
+ ... ... (see AssetDestroyParams for more options)
1460
+ >>> composer.add_asset_destroy(params)
1461
+
1462
+ :param params: The asset destruction parameters
1463
+ :return: The transaction composer instance for chaining
1464
+ """
1465
+ self._txns.append(params)
1466
+ return self
1467
+
1468
+ def add_asset_transfer(self, params: AssetTransferParams) -> TransactionComposer:
1469
+ """Add an asset transfer transaction.
1470
+
1471
+ :example:
1472
+ >>> params = AssetTransferParams(
1473
+ ... sender="SENDER_ADDRESS",
1474
+ ... asset_id=123456,
1475
+ ... amount=10,
1476
+ ... receiver="RECEIVER_ADDRESS",
1477
+ ... clawback_target="CLAWBACK_TARGET_ADDRESS",
1478
+ ... close_asset_to="CLOSE_ADDRESS"
1479
+ ... ... (see AssetTransferParams for more options)
1480
+ >>> composer.add_asset_transfer(params)
1481
+
1482
+ :param params: The asset transfer parameters
1483
+ :return: The transaction composer instance for chaining
1484
+ """
1485
+ self._txns.append(params)
1486
+ return self
1487
+
1488
+ def add_asset_opt_in(self, params: AssetOptInParams) -> TransactionComposer:
1489
+ """Add an asset opt-in transaction.
1490
+
1491
+ :example:
1492
+ >>> params = AssetOptInParams(
1493
+ ... sender="SENDER_ADDRESS",
1494
+ ... asset_id=123456
1495
+ ... ... (see AssetOptInParams for more options)
1496
+ ... )
1497
+ >>> composer.add_asset_opt_in(params)
1498
+
1499
+ :param params: The asset opt-in parameters
1500
+ :return: The transaction composer instance for chaining
1501
+ """
1502
+ self._txns.append(params)
1503
+ return self
1504
+
1505
+ def add_asset_opt_out(self, params: AssetOptOutParams) -> TransactionComposer:
1506
+ """Add an asset opt-out transaction.
1507
+
1508
+ :example:
1509
+ >>> params = AssetOptOutParams(
1510
+ ... sender="SENDER_ADDRESS",
1511
+ ... asset_id=123456,
1512
+ ... creator="CREATOR_ADDRESS"
1513
+ ... ... (see AssetOptOutParams for more options)
1514
+ >>> composer.add_asset_opt_out(params)
1515
+
1516
+ :param params: The asset opt-out parameters
1517
+ :return: The transaction composer instance for chaining
1518
+ """
1519
+ self._txns.append(params)
1520
+ return self
1521
+
1522
+ def add_app_create(self, params: AppCreateParams) -> TransactionComposer:
1523
+ """Add an application creation transaction.
1524
+
1525
+ :example:
1526
+ >>> params = AppCreateParams(
1527
+ ... sender="SENDER_ADDRESS",
1528
+ ... approval_program="TEAL_APPROVAL_CODE",
1529
+ ... clear_state_program="TEAL_CLEAR_CODE",
1530
+ ... schema={'global_ints': 1, 'global_byte_slices': 1, 'local_ints': 1, 'local_byte_slices': 1},
1531
+ ... on_complete=OnComplete.NoOpOC,
1532
+ ... args=[b'arg1'],
1533
+ ... account_references=["ACCOUNT1"],
1534
+ ... app_references=[789],
1535
+ ... asset_references=[123],
1536
+ ... box_references=[],
1537
+ ... extra_program_pages=0
1538
+ ... ... (see AppCreateParams for more options)
1539
+ ... )
1540
+ >>> composer.add_app_create(params)
1541
+
1542
+ :param params: The application creation parameters
1543
+ :return: The transaction composer instance for chaining
1544
+ """
1545
+ self._txns.append(params)
1546
+ return self
1547
+
1548
+ def add_app_update(self, params: AppUpdateParams) -> TransactionComposer:
1549
+ """Add an application update transaction.
1550
+
1551
+ :example:
1552
+ >>> params = AppUpdateParams(
1553
+ ... sender="SENDER_ADDRESS",
1554
+ ... app_id=789,
1555
+ ... approval_program="TEAL_NEW_APPROVAL_CODE",
1556
+ ... clear_state_program="TEAL_NEW_CLEAR_CODE",
1557
+ ... args=[b'new_arg1'],
1558
+ ... account_references=["ACCOUNT1"],
1559
+ ... app_references=[789],
1560
+ ... asset_references=[123],
1561
+ ... box_references=[],
1562
+ ... on_complete=OnComplete.UpdateApplicationOC
1563
+ ... ... (see AppUpdateParams for more options)
1564
+ >>> composer.add_app_update(params)
1565
+
1566
+ :param params: The application update parameters
1567
+ :return: The transaction composer instance for chaining
1568
+ """
1569
+ self._txns.append(params)
1570
+ return self
1571
+
1572
+ def add_app_delete(self, params: AppDeleteParams) -> TransactionComposer:
1573
+ """Add an application deletion transaction.
1574
+
1575
+ :example:
1576
+ >>> params = AppDeleteParams(
1577
+ ... sender="SENDER_ADDRESS",
1578
+ ... app_id=789,
1579
+ ... args=[b'delete_arg'],
1580
+ ... account_references=["ACCOUNT1"],
1581
+ ... app_references=[789],
1582
+ ... asset_references=[123],
1583
+ ... box_references=[],
1584
+ ... on_complete=OnComplete.DeleteApplicationOC
1585
+ ... ... (see AppDeleteParams for more options)
1586
+ >>> composer.add_app_delete(params)
1587
+
1588
+ :param params: The application deletion parameters
1589
+ :return: The transaction composer instance for chaining
1590
+ """
1591
+ self._txns.append(params)
1592
+ return self
1593
+
1594
+ def add_app_call(self, params: AppCallParams) -> TransactionComposer:
1595
+ """Add an application call transaction.
1596
+
1597
+ :example:
1598
+ >>> params = AppCallParams(
1599
+ ... sender="SENDER_ADDRESS",
1600
+ ... on_complete=OnComplete.NoOpOC,
1601
+ ... app_id=789,
1602
+ ... approval_program="TEAL_APPROVAL_CODE",
1603
+ ... clear_state_program="TEAL_CLEAR_CODE",
1604
+ ... schema={'global_ints': 1, 'global_byte_slices': 1, 'local_ints': 1, 'local_byte_slices': 1},
1605
+ ... ... (see AppCallParams for more options)
1606
+ ... )
1607
+ >>> composer.add_app_call(params)
1608
+
1609
+ :param params: The application call parameters
1610
+ :return: The transaction composer instance for chaining
1611
+ """
1612
+ self._txns.append(params)
1613
+ return self
1614
+
1615
+ def add_app_create_method_call(self, params: AppCreateMethodCallParams) -> TransactionComposer:
1616
+ """Add an application creation method call transaction.
1617
+
1618
+ :param params: The application creation method call parameters
1619
+ :return: The transaction composer instance for chaining
1620
+
1621
+ :example:
1622
+ >>> # Basic example
1623
+ >>> method = algosdk.abi.Method(
1624
+ ... name="method",
1625
+ ... args=[...],
1626
+ ... returns="string"
1627
+ ... )
1628
+ >>> composer.add_app_create_method_call(
1629
+ ... AppCreateMethodCallParams(
1630
+ ... sender="CREATORADDRESS",
1631
+ ... approval_program="TEALCODE",
1632
+ ... clear_state_program="TEALCODE",
1633
+ ... method=method,
1634
+ ... args=["arg1_value"]
1635
+ ... )
1636
+ ... )
1637
+ >>>
1638
+ >>> # Advanced example
1639
+ >>> method = ABIMethod(
1640
+ ... name="method",
1641
+ ... args=[{"name": "arg1", "type": "string"}],
1642
+ ... returns={"type": "string"}
1643
+ ... )
1644
+ >>> composer.add_app_create_method_call(
1645
+ ... AppCreateMethodCallParams(
1646
+ ... sender="CREATORADDRESS",
1647
+ ... method=method,
1648
+ ... args=["arg1_value"],
1649
+ ... approval_program="TEALCODE",
1650
+ ... clear_state_program="TEALCODE",
1651
+ ... schema={
1652
+ ... "global_ints": 1,
1653
+ ... "global_byte_slices": 2,
1654
+ ... "local_ints": 3,
1655
+ ... "local_byte_slices": 4
1656
+ ... },
1657
+ ... extra_pages=1,
1658
+ ... on_complete=OnComplete.OptInOC,
1659
+ ... args=[bytes([1, 2, 3, 4])],
1660
+ ... account_references=["ACCOUNT_1"],
1661
+ ... app_references=[123, 1234],
1662
+ ... asset_references=[12345],
1663
+ ... box_references=["box1", {"app_id": 1234, "name": "box2"}],
1664
+ ... lease="lease",
1665
+ ... note="note",
1666
+ ... first_valid_round=1000,
1667
+ ... validity_window=10,
1668
+ ... extra_fee=AlgoAmount.from_micro_algos(1000),
1669
+ ... static_fee=AlgoAmount.from_micro_algos(1000),
1670
+ ... max_fee=AlgoAmount.from_micro_algos(3000)
1671
+ ... )
1672
+ ... )
1673
+ """
1674
+ self._txns.append(params)
1675
+ return self
1676
+
1677
+ def add_app_update_method_call(self, params: AppUpdateMethodCallParams) -> TransactionComposer:
1678
+ """Add an application update method call transaction.
1679
+
1680
+ :param params: The application update method call parameters
1681
+ :return: The transaction composer instance for chaining
1682
+ """
1683
+ self._txns.append(params)
1684
+ return self
1685
+
1686
+ def add_app_delete_method_call(self, params: AppDeleteMethodCallParams) -> TransactionComposer:
1687
+ """Add an application deletion method call transaction.
1688
+
1689
+ :param params: The application deletion method call parameters
1690
+ :return: The transaction composer instance for chaining
1691
+ """
1692
+ self._txns.append(params)
1693
+ return self
1694
+
1695
+ def add_app_call_method_call(self, params: AppCallMethodCallParams) -> TransactionComposer:
1696
+ """Add an application call method call transaction.
1697
+
1698
+ :param params: The application call method call parameters
1699
+ :return: The transaction composer instance for chaining
1700
+ """
1701
+ self._txns.append(params)
1702
+ return self
1703
+
1704
+ def add_online_key_registration(self, params: OnlineKeyRegistrationParams) -> TransactionComposer:
1705
+ """Add an online key registration transaction.
1706
+
1707
+ :param params: The online key registration parameters
1708
+ :return: The transaction composer instance for chaining
1709
+ """
1710
+ self._txns.append(params)
1711
+ return self
1712
+
1713
+ def add_offline_key_registration(self, params: OfflineKeyRegistrationParams) -> TransactionComposer:
1714
+ """Add an offline key registration transaction.
1715
+
1716
+ :param params: The offline key registration parameters
1717
+ :return: The transaction composer instance for chaining
1718
+ """
1719
+ self._txns.append(params)
1720
+ return self
1721
+
1722
+ def add_atc(self, atc: AtomicTransactionComposer) -> TransactionComposer:
1723
+ """Add an existing AtomicTransactionComposer's transactions.
1724
+
1725
+ :param atc: The AtomicTransactionComposer to add
1726
+ :return: The transaction composer instance for chaining
1727
+
1728
+ :example:
1729
+ >>> atc = AtomicTransactionComposer()
1730
+ >>> atc.add_transaction(TransactionWithSigner(transaction, signer))
1731
+ >>> composer.add_atc(atc)
1732
+ """
1733
+ self._txns.append(atc)
1734
+ return self
1735
+
1736
+ def count(self) -> int:
1737
+ """Get the total number of transactions.
1738
+
1739
+ :return: The number of transactions
1740
+ """
1741
+ return len(self.build_transactions().transactions)
1742
+
1743
+ def build(self) -> TransactionComposerBuildResult:
1744
+ """Build the transaction group.
1745
+
1746
+ :return: The built transaction group result
1747
+ """
1748
+ if self._atc.get_status() == algosdk.atomic_transaction_composer.AtomicTransactionComposerStatus.BUILDING:
1749
+ suggested_params = self._get_suggested_params()
1750
+ txn_with_signers: list[TransactionWithSignerAndContext] = []
1751
+
1752
+ for txn in self._txns:
1753
+ txn_with_signers.extend(self._build_txn(txn, suggested_params))
1754
+
1755
+ for ts in txn_with_signers:
1756
+ self._atc.add_transaction(ts)
1757
+ if ts.context.abi_method:
1758
+ self._atc.method_dict[len(self._atc.txn_list) - 1] = ts.context.abi_method
1759
+ if ts.context.max_fee:
1760
+ self._txn_max_fees[len(self._atc.txn_list) - 1] = ts.context.max_fee
1761
+
1762
+ return TransactionComposerBuildResult(
1763
+ atc=self._atc,
1764
+ transactions=self._atc.build_group(),
1765
+ method_calls=self._atc.method_dict,
1766
+ )
1767
+
1768
+ def rebuild(self) -> TransactionComposerBuildResult:
1769
+ """Rebuild the transaction group from scratch.
1770
+
1771
+ :return: The rebuilt transaction group result
1772
+ """
1773
+ self._atc = AtomicTransactionComposer()
1774
+ return self.build()
1775
+
1776
+ def build_transactions(self) -> BuiltTransactions:
1777
+ """Build and return the transactions without executing them.
1778
+
1779
+ :return: The built transactions result
1780
+ """
1781
+ suggested_params = self._get_suggested_params()
1782
+
1783
+ transactions: list[algosdk.transaction.Transaction] = []
1784
+ method_calls: dict[int, Method] = {}
1785
+ signers: dict[int, TransactionSigner] = {}
1786
+
1787
+ idx = 0
1788
+
1789
+ for txn in self._txns:
1790
+ txn_with_signers: list[TransactionWithSigner] = []
1791
+
1792
+ if isinstance(txn, MethodCallParams):
1793
+ txn_with_signers.extend(self._build_method_call(txn, suggested_params))
1794
+ else:
1795
+ txn_with_signers.extend(self._build_txn(txn, suggested_params))
1796
+
1797
+ for ts in txn_with_signers:
1798
+ transactions.append(ts.txn)
1799
+ if ts.signer and ts.signer != NULL_SIGNER:
1800
+ signers[idx] = ts.signer
1801
+ if isinstance(ts, TransactionWithSignerAndContext) and ts.context.abi_method:
1802
+ method_calls[idx] = ts.context.abi_method
1803
+ if ts.context.max_fee:
1804
+ self._txn_max_fees[idx] = ts.context.max_fee
1805
+ idx += 1
1806
+
1807
+ return BuiltTransactions(transactions=transactions, method_calls=method_calls, signers=signers)
1808
+
1809
+ @deprecated("Use send() instead")
1810
+ def execute(
1811
+ self,
1812
+ *,
1813
+ max_rounds_to_wait: int | None = None,
1814
+ ) -> SendAtomicTransactionComposerResults:
1815
+ return self.send(SendParams(max_rounds_to_wait=max_rounds_to_wait))
1816
+
1817
+ def send(
1818
+ self,
1819
+ params: SendParams | None = None,
1820
+ ) -> SendAtomicTransactionComposerResults:
1821
+ """Send the transaction group to the network.
1822
+
1823
+ :param params: Parameters for the send operation
1824
+ :return: The transaction send results
1825
+ :raises Exception: If the transaction fails
1826
+ """
1827
+ group = self.build().transactions
1828
+
1829
+ if not params:
1830
+ has_app_call = any(isinstance(txn.txn, ApplicationCallTxn) for txn in group)
1831
+ params = SendParams() if has_app_call else SendParams()
1832
+
1833
+ cover_app_call_inner_transaction_fees = params.get("cover_app_call_inner_transaction_fees")
1834
+ populate_app_call_resources = params.get("populate_app_call_resources")
1835
+ wait_rounds = params.get("max_rounds_to_wait")
1836
+ sp = self._get_suggested_params() if not wait_rounds or cover_app_call_inner_transaction_fees else None
1837
+
1838
+ if wait_rounds is None:
1839
+ last_round = max(txn.txn.last_valid_round for txn in group)
1840
+ assert sp is not None
1841
+ first_round = sp.first
1842
+ wait_rounds = last_round - first_round + 1
1843
+
1844
+ try:
1845
+ return send_atomic_transaction_composer(
1846
+ self._atc,
1847
+ self._algod,
1848
+ max_rounds_to_wait=wait_rounds,
1849
+ suppress_log=params.get("suppress_log"),
1850
+ populate_app_call_resources=populate_app_call_resources,
1851
+ cover_app_call_inner_transaction_fees=cover_app_call_inner_transaction_fees,
1852
+ additional_atc_context=AdditionalAtcContext(
1853
+ suggested_params=sp,
1854
+ max_fees=self._txn_max_fees,
1855
+ ),
1856
+ )
1857
+ except algosdk.error.AlgodHTTPError as e:
1858
+ raise Exception(f"Transaction failed: {e}") from e
1859
+
1860
+ def _handle_simulate_error(self, simulate_response: SimulateAtomicTransactionResponse) -> None:
1861
+ # const failedGroup = simulateResponse?.txnGroups[0]
1862
+ failed_group = simulate_response.simulate_response.get("txn-groups", [{}])[0]
1863
+ failure_message = failed_group.get("failure-message")
1864
+ failed_at = [str(x) for x in failed_group.get("failed-at", [])]
1865
+ if failure_message:
1866
+ error_message = (
1867
+ f"Transaction failed at transaction(s) {', '.join(failed_at) if failed_at else 'N/A'} in the group. "
1868
+ f"{failure_message}"
1869
+ )
1870
+ raise Exception(error_message)
1871
+
1872
+ def simulate(
1873
+ self,
1874
+ allow_more_logs: bool | None = None,
1875
+ allow_empty_signatures: bool | None = None,
1876
+ allow_unnamed_resources: bool | None = None,
1877
+ extra_opcode_budget: int | None = None,
1878
+ exec_trace_config: SimulateTraceConfig | None = None,
1879
+ simulation_round: int | None = None,
1880
+ skip_signatures: bool | None = None,
1881
+ ) -> SendAtomicTransactionComposerResults:
1882
+ """Simulate transaction group execution with configurable validation rules.
1883
+
1884
+ :param allow_more_logs: Whether to allow more logs than the standard limit
1885
+ :param allow_empty_signatures: Whether to allow transactions with empty signatures
1886
+ :param allow_unnamed_resources: Whether to allow unnamed resources.
1887
+ :param extra_opcode_budget: Additional opcode budget to allocate
1888
+ :param exec_trace_config: Configuration for execution tracing
1889
+ :param simulation_round: Round number to simulate at
1890
+ :param skip_signatures: Whether to skip signature validation
1891
+ :return: The simulation results
1892
+
1893
+ :example:
1894
+ >>> result = composer.simulate(extra_opcode_budget=1000, skip_signatures=True, ...)
1895
+ """
1896
+ from algokit_utils._debugging import simulate_and_persist_response, simulate_response
1897
+
1898
+ atc = AtomicTransactionComposer() if skip_signatures else self._atc
1899
+
1900
+ if skip_signatures:
1901
+ allow_empty_signatures = True
1902
+ transactions = self.build_transactions()
1903
+ for txn in transactions.transactions:
1904
+ atc.add_transaction(TransactionWithSigner(txn=txn, signer=NULL_SIGNER))
1905
+ atc.method_dict = transactions.method_calls
1906
+ else:
1907
+ self.build()
1908
+
1909
+ if config.debug and config.project_root and config.trace_all:
1910
+ response = simulate_and_persist_response(
1911
+ atc,
1912
+ config.project_root,
1913
+ self._algod,
1914
+ config.trace_buffer_size_mb,
1915
+ allow_more_logs,
1916
+ allow_empty_signatures,
1917
+ allow_unnamed_resources,
1918
+ extra_opcode_budget,
1919
+ exec_trace_config,
1920
+ simulation_round,
1921
+ )
1922
+ self._handle_simulate_error(response)
1923
+ return SendAtomicTransactionComposerResults(
1924
+ confirmations=response.simulate_response.get("txn-groups", [{"txn-results": [{"txn-result": {}}]}])[0][
1925
+ "txn-results"
1926
+ ],
1927
+ transactions=[TransactionWrapper(txn.txn) for txn in atc.txn_list],
1928
+ tx_ids=response.tx_ids,
1929
+ group_id=atc.txn_list[-1].txn.group or "",
1930
+ simulate_response=response.simulate_response,
1931
+ returns=[ABIReturn(r) for r in response.abi_results],
1932
+ )
1933
+
1934
+ response = simulate_response(
1935
+ atc,
1936
+ self._algod,
1937
+ allow_more_logs,
1938
+ allow_empty_signatures,
1939
+ allow_unnamed_resources,
1940
+ extra_opcode_budget,
1941
+ exec_trace_config,
1942
+ simulation_round,
1943
+ )
1944
+ self._handle_simulate_error(response)
1945
+ confirmation_results = response.simulate_response.get("txn-groups", [{"txn-results": [{"txn-result": {}}]}])[0][
1946
+ "txn-results"
1947
+ ]
1948
+
1949
+ return SendAtomicTransactionComposerResults(
1950
+ confirmations=[txn["txn-result"] for txn in confirmation_results],
1951
+ transactions=[TransactionWrapper(txn.txn) for txn in atc.txn_list],
1952
+ tx_ids=response.tx_ids,
1953
+ group_id=atc.txn_list[-1].txn.group or "",
1954
+ simulate_response=response.simulate_response,
1955
+ returns=[ABIReturn(r) for r in response.abi_results],
1956
+ )
1957
+
1958
+ @staticmethod
1959
+ def arc2_note(note: Arc2TransactionNote) -> bytes:
1960
+ """Create an encoded transaction note that follows the ARC-2 spec.
1961
+
1962
+ https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md
1963
+
1964
+ :param note: The ARC-2 note to encode
1965
+ :return: The encoded note bytes
1966
+ :raises ValueError: If the dapp_name is invalid
1967
+ """
1968
+
1969
+ pattern = r"^[a-zA-Z0-9][a-zA-Z0-9_/@.-]{4,31}$"
1970
+ if not re.match(pattern, note["dapp_name"]):
1971
+ raise ValueError(
1972
+ "dapp_name must be 5-32 chars, start with alphanumeric, "
1973
+ "and contain only alphanumeric, _, /, @, ., or -"
1974
+ )
1975
+
1976
+ data = note["data"]
1977
+ if note["format"] == "j" and isinstance(data, (dict | list)):
1978
+ # Ensure JSON data uses double quotes
1979
+ data = json.dumps(data)
1980
+
1981
+ arc2_payload = f"{note['dapp_name']}:{note['format']}{data}"
1982
+ return arc2_payload.encode("utf-8")
1983
+
1984
+ def _build_atc(self, atc: AtomicTransactionComposer) -> list[TransactionWithSignerAndContext]:
1985
+ group = atc.build_group()
1986
+
1987
+ txn_with_signers = []
1988
+ for idx, ts in enumerate(group):
1989
+ ts.txn.group = None
1990
+ if atc.method_dict.get(idx):
1991
+ txn_with_signers.append(
1992
+ TransactionWithSignerAndContext(
1993
+ txn=ts.txn,
1994
+ signer=ts.signer,
1995
+ context=TransactionContext(abi_method=atc.method_dict.get(idx)),
1996
+ )
1997
+ )
1998
+ else:
1999
+ txn_with_signers.append(
2000
+ TransactionWithSignerAndContext(
2001
+ txn=ts.txn,
2002
+ signer=ts.signer,
2003
+ context=TransactionContext(abi_method=None),
2004
+ )
2005
+ )
2006
+
2007
+ return txn_with_signers
2008
+
2009
+ def _common_txn_build_step( # noqa: C901
2010
+ self,
2011
+ build_txn: Callable[[dict], algosdk.transaction.Transaction],
2012
+ params: _CommonTxnParams,
2013
+ txn_params: dict,
2014
+ ) -> TransactionWithContext:
2015
+ # Clone suggested params
2016
+ txn_params["sp"] = (
2017
+ algosdk.transaction.SuggestedParams(**txn_params["sp"].__dict__) if "sp" in txn_params else None
2018
+ )
2019
+
2020
+ if params.lease:
2021
+ txn_params["lease"] = _encode_lease(params.lease)
2022
+ if params.rekey_to:
2023
+ txn_params["rekey_to"] = params.rekey_to
2024
+ if params.note:
2025
+ txn_params["note"] = params.note
2026
+
2027
+ if txn_params["sp"]:
2028
+ if params.first_valid_round:
2029
+ txn_params["sp"].first = params.first_valid_round
2030
+
2031
+ if params.last_valid_round:
2032
+ txn_params["sp"].last = params.last_valid_round
2033
+ else:
2034
+ # If the validity window isn't set in this transaction or by default and we are pointing at
2035
+ # LocalNet set a bigger window to avoid dead transactions
2036
+ from algokit_utils.clients import ClientManager
2037
+
2038
+ is_localnet = ClientManager.genesis_id_is_localnet(txn_params["sp"].gen)
2039
+ window = params.validity_window or (
2040
+ 1000
2041
+ if is_localnet and not self._default_validity_window_is_explicit
2042
+ else self._default_validity_window
2043
+ )
2044
+ txn_params["sp"].last = txn_params["sp"].first + window
2045
+
2046
+ if params.static_fee is not None and txn_params["sp"]:
2047
+ txn_params["sp"].fee = params.static_fee.micro_algo
2048
+ txn_params["sp"].flat_fee = True
2049
+
2050
+ if isinstance(txn_params.get("method"), Arc56Method):
2051
+ txn_params["method"] = txn_params["method"].to_abi_method()
2052
+
2053
+ txn = build_txn(txn_params)
2054
+
2055
+ if params.extra_fee:
2056
+ txn.fee += params.extra_fee.micro_algo
2057
+
2058
+ if params.max_fee and txn.fee > params.max_fee.micro_algo:
2059
+ raise ValueError(f"Transaction fee {txn.fee} is greater than max_fee {params.max_fee}")
2060
+ use_max_fee = params.max_fee and params.max_fee.micro_algo > (
2061
+ params.static_fee.micro_algo if params.static_fee else 0
2062
+ )
2063
+ logical_max_fee = params.max_fee if use_max_fee else params.static_fee
2064
+
2065
+ return TransactionWithContext(
2066
+ txn=txn,
2067
+ context=TransactionContext(max_fee=logical_max_fee),
2068
+ )
2069
+
2070
+ def _build_method_call( # noqa: C901, PLR0912, PLR0915
2071
+ self, params: MethodCallParams, suggested_params: algosdk.transaction.SuggestedParams
2072
+ ) -> list[TransactionWithSignerAndContext]:
2073
+ method_args: list[ABIValue | TransactionWithSigner] = []
2074
+ txns_for_group: list[TransactionWithSignerAndContext] = []
2075
+
2076
+ if params.args:
2077
+ for arg in reversed(params.args):
2078
+ if arg is None and len(txns_for_group) > 0:
2079
+ # Pull last transaction from group as placeholder
2080
+ placeholder_transaction = txns_for_group.pop()
2081
+ method_args.append(placeholder_transaction)
2082
+ continue
2083
+ if self._is_abi_value(arg):
2084
+ method_args.append(arg)
2085
+ continue
2086
+
2087
+ if isinstance(arg, TransactionWithSigner):
2088
+ method_args.append(arg)
2089
+ continue
2090
+
2091
+ if isinstance(arg, algosdk.transaction.Transaction):
2092
+ # Wrap in TransactionWithSigner
2093
+ signer = (
2094
+ params.signer.signer
2095
+ if isinstance(params.signer, TransactionSignerAccountProtocol)
2096
+ else params.signer
2097
+ )
2098
+ method_args.append(
2099
+ TransactionWithSignerAndContext(
2100
+ txn=arg,
2101
+ signer=signer if signer is not None else self._get_signer(params.sender),
2102
+ context=TransactionContext(abi_method=None),
2103
+ )
2104
+ )
2105
+ continue
2106
+ match arg:
2107
+ case (
2108
+ AppCreateMethodCallParams()
2109
+ | AppCallMethodCallParams()
2110
+ | AppUpdateMethodCallParams()
2111
+ | AppDeleteMethodCallParams()
2112
+ ):
2113
+ temp_txn_with_signers = self._build_method_call(arg, suggested_params)
2114
+ # Add all transactions except the last one in reverse order
2115
+ txns_for_group.extend(temp_txn_with_signers[:-1])
2116
+ # Add the last transaction to method_args
2117
+ method_args.append(temp_txn_with_signers[-1])
2118
+ continue
2119
+ case AppCallParams():
2120
+ txn = self._build_app_call(arg, suggested_params)
2121
+ case PaymentParams():
2122
+ txn = self._build_payment(arg, suggested_params)
2123
+ case AssetOptInParams():
2124
+ txn = self._build_asset_transfer(
2125
+ AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params
2126
+ )
2127
+ case AssetCreateParams():
2128
+ txn = self._build_asset_create(arg, suggested_params)
2129
+ case AssetConfigParams():
2130
+ txn = self._build_asset_config(arg, suggested_params)
2131
+ case AssetDestroyParams():
2132
+ txn = self._build_asset_destroy(arg, suggested_params)
2133
+ case AssetFreezeParams():
2134
+ txn = self._build_asset_freeze(arg, suggested_params)
2135
+ case AssetTransferParams():
2136
+ txn = self._build_asset_transfer(arg, suggested_params)
2137
+ case OnlineKeyRegistrationParams() | OfflineKeyRegistrationParams():
2138
+ txn = self._build_key_reg(arg, suggested_params)
2139
+ case _:
2140
+ raise ValueError(f"Unsupported method arg transaction type: {arg!s}")
2141
+
2142
+ signer = (
2143
+ params.signer.signer
2144
+ if isinstance(params.signer, TransactionSignerAccountProtocol)
2145
+ else params.signer
2146
+ )
2147
+ method_args.append(
2148
+ TransactionWithSignerAndContext(
2149
+ txn=txn.txn,
2150
+ signer=signer or self._get_signer(params.sender),
2151
+ context=TransactionContext(abi_method=params.method),
2152
+ )
2153
+ )
2154
+
2155
+ continue
2156
+
2157
+ method_atc = AtomicTransactionComposer()
2158
+ max_fees: dict[int, AlgoAmount] = {}
2159
+
2160
+ # Process in reverse order
2161
+ for arg in reversed(txns_for_group):
2162
+ atc_index = method_atc.get_tx_count() - 1
2163
+
2164
+ if isinstance(arg, TransactionWithSignerAndContext) and arg.context:
2165
+ if arg.context.abi_method:
2166
+ method_atc.method_dict[atc_index] = arg.context.abi_method
2167
+
2168
+ if arg.context.max_fee is not None:
2169
+ max_fees[atc_index] = arg.context.max_fee
2170
+
2171
+ # Process method args that are transactions with ABI method info
2172
+ for i, arg in enumerate(reversed([a for a in method_args if isinstance(a, TransactionWithSignerAndContext)])):
2173
+ atc_index = method_atc.get_tx_count() + i
2174
+ if arg.context:
2175
+ if arg.context.abi_method:
2176
+ method_atc.method_dict[atc_index] = arg.context.abi_method
2177
+ if arg.context.max_fee is not None:
2178
+ max_fees[atc_index] = arg.context.max_fee
2179
+
2180
+ app_id = params.app_id or 0
2181
+ approval_program = getattr(params, "approval_program", None)
2182
+ clear_program = getattr(params, "clear_state_program", None)
2183
+ extra_pages = None
2184
+
2185
+ if app_id == 0:
2186
+ extra_pages = getattr(params, "extra_program_pages", None)
2187
+ if extra_pages is None and approval_program is not None:
2188
+ extra_pages = calculate_extra_program_pages(approval_program, clear_program)
2189
+
2190
+ txn_params = {
2191
+ "app_id": app_id,
2192
+ "method": params.method,
2193
+ "sender": params.sender,
2194
+ "sp": suggested_params,
2195
+ "signer": params.signer
2196
+ if params.signer is not None
2197
+ else self._get_signer(params.sender) or algosdk.atomic_transaction_composer.EmptySigner(),
2198
+ "method_args": list(reversed(method_args)),
2199
+ "on_complete": params.on_complete or algosdk.transaction.OnComplete.NoOpOC,
2200
+ "boxes": [AppManager.get_box_reference(ref) for ref in params.box_references]
2201
+ if params.box_references
2202
+ else None,
2203
+ "foreign_apps": params.app_references,
2204
+ "foreign_assets": params.asset_references,
2205
+ "accounts": params.account_references,
2206
+ "global_schema": algosdk.transaction.StateSchema(
2207
+ num_uints=params.schema.get("global_ints", 0),
2208
+ num_byte_slices=params.schema.get("global_byte_slices", 0),
2209
+ )
2210
+ if params.schema
2211
+ else None,
2212
+ "local_schema": algosdk.transaction.StateSchema(
2213
+ num_uints=params.schema.get("local_ints", 0),
2214
+ num_byte_slices=params.schema.get("local_byte_slices", 0),
2215
+ )
2216
+ if params.schema
2217
+ else None,
2218
+ "approval_program": approval_program,
2219
+ "clear_program": clear_program,
2220
+ "extra_pages": extra_pages,
2221
+ }
2222
+
2223
+ def _add_method_call_and_return_txn(x: dict) -> algosdk.transaction.Transaction:
2224
+ method_atc.add_method_call(**x)
2225
+ return method_atc.build_group()[-1].txn
2226
+
2227
+ result = self._common_txn_build_step(lambda x: _add_method_call_and_return_txn(x), params, txn_params)
2228
+
2229
+ build_atc_resp = self._build_atc(method_atc)
2230
+ response = []
2231
+ for i, v in enumerate(build_atc_resp):
2232
+ max_fee = result.context.max_fee if i == method_atc.get_tx_count() - 1 else max_fees.get(i)
2233
+ context = TransactionContext(abi_method=v.context.abi_method, max_fee=max_fee)
2234
+ response.append(TransactionWithSignerAndContext(txn=v.txn, signer=v.signer, context=context))
2235
+
2236
+ return response
2237
+
2238
+ def _build_payment(
2239
+ self, params: PaymentParams, suggested_params: algosdk.transaction.SuggestedParams
2240
+ ) -> TransactionWithContext:
2241
+ txn_params = {
2242
+ "sender": params.sender,
2243
+ "sp": suggested_params,
2244
+ "receiver": params.receiver,
2245
+ "amt": params.amount.micro_algo,
2246
+ "close_remainder_to": params.close_remainder_to,
2247
+ }
2248
+
2249
+ return self._common_txn_build_step(lambda x: algosdk.transaction.PaymentTxn(**x), params, txn_params)
2250
+
2251
+ def _build_asset_create(
2252
+ self, params: AssetCreateParams, suggested_params: algosdk.transaction.SuggestedParams
2253
+ ) -> TransactionWithContext:
2254
+ txn_params = {
2255
+ "sender": params.sender,
2256
+ "sp": suggested_params,
2257
+ "total": params.total,
2258
+ "default_frozen": params.default_frozen or False,
2259
+ "unit_name": params.unit_name or "",
2260
+ "asset_name": params.asset_name or "",
2261
+ "manager": params.manager,
2262
+ "reserve": params.reserve,
2263
+ "freeze": params.freeze,
2264
+ "clawback": params.clawback,
2265
+ "url": params.url or "",
2266
+ "metadata_hash": params.metadata_hash,
2267
+ "decimals": params.decimals or 0,
2268
+ }
2269
+
2270
+ return self._common_txn_build_step(lambda x: algosdk.transaction.AssetCreateTxn(**x), params, txn_params)
2271
+
2272
+ def _build_app_call(
2273
+ self,
2274
+ params: AppCallParams | AppUpdateParams | AppCreateParams | AppDeleteParams,
2275
+ suggested_params: algosdk.transaction.SuggestedParams,
2276
+ ) -> TransactionWithContext:
2277
+ app_id = getattr(params, "app_id", 0)
2278
+
2279
+ approval_program = None
2280
+ clear_program = None
2281
+
2282
+ if isinstance(params, AppUpdateParams | AppCreateParams):
2283
+ if isinstance(params.approval_program, str):
2284
+ approval_program = self._app_manager.compile_teal(params.approval_program).compiled_base64_to_bytes
2285
+ elif isinstance(params.approval_program, bytes):
2286
+ approval_program = params.approval_program
2287
+
2288
+ if isinstance(params.clear_state_program, str):
2289
+ clear_program = self._app_manager.compile_teal(params.clear_state_program).compiled_base64_to_bytes
2290
+ elif isinstance(params.clear_state_program, bytes):
2291
+ clear_program = params.clear_state_program
2292
+
2293
+ sdk_params = {
2294
+ "sender": params.sender,
2295
+ "sp": suggested_params,
2296
+ "app_args": params.args,
2297
+ "on_complete": params.on_complete or algosdk.transaction.OnComplete.NoOpOC,
2298
+ "accounts": params.account_references,
2299
+ "foreign_apps": params.app_references,
2300
+ "foreign_assets": params.asset_references,
2301
+ "boxes": params.box_references,
2302
+ "approval_program": approval_program,
2303
+ "clear_program": clear_program,
2304
+ }
2305
+
2306
+ txn_params = {**sdk_params, "index": app_id}
2307
+
2308
+ if not app_id and isinstance(params, AppCreateParams):
2309
+ if not sdk_params["approval_program"] or not sdk_params["clear_program"]:
2310
+ raise ValueError("approval_program and clear_program are required for application creation")
2311
+
2312
+ schema = params.schema
2313
+ if not schema:
2314
+ schema = AppCreateSchema(
2315
+ global_ints=0,
2316
+ global_byte_slices=0,
2317
+ local_ints=0,
2318
+ local_byte_slices=0,
2319
+ )
2320
+
2321
+ txn_params = {
2322
+ **txn_params,
2323
+ "global_schema": algosdk.transaction.StateSchema(
2324
+ num_uints=schema["global_ints"],
2325
+ num_byte_slices=schema["global_byte_slices"],
2326
+ ),
2327
+ "local_schema": algosdk.transaction.StateSchema(
2328
+ num_uints=schema["local_ints"],
2329
+ num_byte_slices=schema["local_byte_slices"],
2330
+ ),
2331
+ "extra_pages": params.extra_program_pages
2332
+ or calculate_extra_program_pages(approval_program, clear_program),
2333
+ }
2334
+
2335
+ return self._common_txn_build_step(lambda x: algosdk.transaction.ApplicationCallTxn(**x), params, txn_params)
2336
+
2337
+ def _build_asset_config(
2338
+ self, params: AssetConfigParams, suggested_params: algosdk.transaction.SuggestedParams
2339
+ ) -> TransactionWithContext:
2340
+ txn_params = {
2341
+ "sender": params.sender,
2342
+ "sp": suggested_params,
2343
+ "index": params.asset_id,
2344
+ "manager": params.manager,
2345
+ "reserve": params.reserve,
2346
+ "freeze": params.freeze,
2347
+ "clawback": params.clawback,
2348
+ "strict_empty_address_check": False,
2349
+ }
2350
+
2351
+ return self._common_txn_build_step(lambda x: algosdk.transaction.AssetConfigTxn(**x), params, txn_params)
2352
+
2353
+ def _build_asset_destroy(
2354
+ self, params: AssetDestroyParams, suggested_params: algosdk.transaction.SuggestedParams
2355
+ ) -> TransactionWithContext:
2356
+ txn_params = {
2357
+ "sender": params.sender,
2358
+ "sp": suggested_params,
2359
+ "index": params.asset_id,
2360
+ }
2361
+
2362
+ return self._common_txn_build_step(lambda x: algosdk.transaction.AssetDestroyTxn(**x), params, txn_params)
2363
+
2364
+ def _build_asset_freeze(
2365
+ self, params: AssetFreezeParams, suggested_params: algosdk.transaction.SuggestedParams
2366
+ ) -> TransactionWithContext:
2367
+ txn_params = {
2368
+ "sender": params.sender,
2369
+ "sp": suggested_params,
2370
+ "index": params.asset_id,
2371
+ "target": params.account,
2372
+ "new_freeze_state": params.frozen,
2373
+ }
2374
+
2375
+ return self._common_txn_build_step(lambda x: algosdk.transaction.AssetFreezeTxn(**x), params, txn_params)
2376
+
2377
+ def _build_asset_transfer(
2378
+ self, params: AssetTransferParams, suggested_params: algosdk.transaction.SuggestedParams
2379
+ ) -> TransactionWithContext:
2380
+ txn_params = {
2381
+ "sender": params.sender,
2382
+ "sp": suggested_params,
2383
+ "receiver": params.receiver,
2384
+ "amt": params.amount,
2385
+ "index": params.asset_id,
2386
+ "close_assets_to": params.close_asset_to,
2387
+ "revocation_target": params.clawback_target,
2388
+ }
2389
+
2390
+ return self._common_txn_build_step(lambda x: algosdk.transaction.AssetTransferTxn(**x), params, txn_params)
2391
+
2392
+ def _build_key_reg(
2393
+ self,
2394
+ params: OnlineKeyRegistrationParams | OfflineKeyRegistrationParams,
2395
+ suggested_params: algosdk.transaction.SuggestedParams,
2396
+ ) -> TransactionWithContext:
2397
+ if isinstance(params, OnlineKeyRegistrationParams):
2398
+ txn_params = {
2399
+ "sender": params.sender,
2400
+ "sp": suggested_params,
2401
+ "votekey": params.vote_key,
2402
+ "selkey": params.selection_key,
2403
+ "votefst": params.vote_first,
2404
+ "votelst": params.vote_last,
2405
+ "votekd": params.vote_key_dilution,
2406
+ "rekey_to": params.rekey_to,
2407
+ "nonpart": False,
2408
+ "sprfkey": params.state_proof_key,
2409
+ }
2410
+
2411
+ return self._common_txn_build_step(lambda x: algosdk.transaction.KeyregTxn(**x), params, txn_params)
2412
+
2413
+ return self._common_txn_build_step(
2414
+ lambda x: algosdk.transaction.KeyregTxn(**x),
2415
+ params,
2416
+ {
2417
+ "sender": params.sender,
2418
+ "sp": suggested_params,
2419
+ "nonpart": params.prevent_account_from_ever_participating_again,
2420
+ "votekey": None,
2421
+ "selkey": None,
2422
+ "votefst": None,
2423
+ "votelst": None,
2424
+ "votekd": None,
2425
+ },
2426
+ )
2427
+
2428
+ def _is_abi_value(self, x: bool | float | str | bytes | list | TxnParams) -> bool:
2429
+ if isinstance(x, list | tuple):
2430
+ return len(x) == 0 or all(self._is_abi_value(item) for item in x)
2431
+
2432
+ return isinstance(x, bool | int | float | str | bytes)
2433
+
2434
+ def _build_txn( # noqa: C901, PLR0912, PLR0911
2435
+ self,
2436
+ txn: TransactionWithSigner | TxnParams | AtomicTransactionComposer,
2437
+ suggested_params: algosdk.transaction.SuggestedParams,
2438
+ ) -> list[TransactionWithSignerAndContext]:
2439
+ match txn:
2440
+ case TransactionWithSigner():
2441
+ return [
2442
+ TransactionWithSignerAndContext(txn=txn.txn, signer=txn.signer, context=TransactionContext.empty())
2443
+ ]
2444
+ case AtomicTransactionComposer():
2445
+ return self._build_atc(txn)
2446
+ case algosdk.transaction.Transaction():
2447
+ signer = self._get_signer(txn.sender)
2448
+ return [TransactionWithSignerAndContext(txn=txn, signer=signer, context=TransactionContext.empty())]
2449
+ case (
2450
+ AppCreateMethodCallParams()
2451
+ | AppCallMethodCallParams()
2452
+ | AppUpdateMethodCallParams()
2453
+ | AppDeleteMethodCallParams()
2454
+ ):
2455
+ return self._build_method_call(txn, suggested_params)
2456
+
2457
+ signer = txn.signer.signer if isinstance(txn.signer, TransactionSignerAccountProtocol) else txn.signer # type: ignore[assignment]
2458
+ signer = signer or self._get_signer(txn.sender)
2459
+
2460
+ match txn:
2461
+ case PaymentParams():
2462
+ payment = self._build_payment(txn, suggested_params)
2463
+ return [TransactionWithSignerAndContext.from_txn_with_context(payment, signer)]
2464
+ case AssetCreateParams():
2465
+ asset_create = self._build_asset_create(txn, suggested_params)
2466
+ return [TransactionWithSignerAndContext.from_txn_with_context(asset_create, signer)]
2467
+ case AppCallParams() | AppUpdateParams() | AppCreateParams() | AppDeleteParams():
2468
+ app_call = self._build_app_call(txn, suggested_params)
2469
+ return [TransactionWithSignerAndContext.from_txn_with_context(app_call, signer)]
2470
+ case AssetConfigParams():
2471
+ asset_config = self._build_asset_config(txn, suggested_params)
2472
+ return [TransactionWithSignerAndContext.from_txn_with_context(asset_config, signer)]
2473
+ case AssetDestroyParams():
2474
+ asset_destroy = self._build_asset_destroy(txn, suggested_params)
2475
+ return [TransactionWithSignerAndContext.from_txn_with_context(asset_destroy, signer)]
2476
+ case AssetFreezeParams():
2477
+ asset_freeze = self._build_asset_freeze(txn, suggested_params)
2478
+ return [TransactionWithSignerAndContext.from_txn_with_context(asset_freeze, signer)]
2479
+ case AssetTransferParams():
2480
+ asset_transfer = self._build_asset_transfer(txn, suggested_params)
2481
+ return [TransactionWithSignerAndContext.from_txn_with_context(asset_transfer, signer)]
2482
+ case AssetOptInParams():
2483
+ asset_transfer = self._build_asset_transfer(
2484
+ AssetTransferParams(**txn.__dict__, receiver=txn.sender, amount=0), suggested_params
2485
+ )
2486
+ return [TransactionWithSignerAndContext.from_txn_with_context(asset_transfer, signer)]
2487
+ case AssetOptOutParams():
2488
+ txn_dict = txn.__dict__
2489
+ creator = txn_dict.pop("creator")
2490
+ asset_transfer = self._build_asset_transfer(
2491
+ AssetTransferParams(**txn_dict, receiver=txn.sender, amount=0, close_asset_to=creator),
2492
+ suggested_params,
2493
+ )
2494
+ return [TransactionWithSignerAndContext.from_txn_with_context(asset_transfer, signer)]
2495
+ case OnlineKeyRegistrationParams() | OfflineKeyRegistrationParams():
2496
+ key_reg = self._build_key_reg(txn, suggested_params)
2497
+ return [TransactionWithSignerAndContext.from_txn_with_context(key_reg, signer)]
2498
+ case _:
2499
+ raise ValueError(f"Unsupported txn: {txn}")