algokit-utils 3.0.0b1__py3-none-any.whl → 3.0.0b2__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 -183
  2. algokit_utils/_debugging.py +123 -97
  3. algokit_utils/_legacy_v2/__init__.py +177 -0
  4. algokit_utils/{_ensure_funded.py → _legacy_v2/_ensure_funded.py} +19 -18
  5. algokit_utils/{_transfer.py → _legacy_v2/_transfer.py} +24 -23
  6. algokit_utils/_legacy_v2/account.py +203 -0
  7. algokit_utils/_legacy_v2/application_client.py +1471 -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} +19 -142
  14. algokit_utils/_legacy_v2/network_clients.py +140 -0
  15. algokit_utils/account.py +12 -183
  16. algokit_utils/accounts/__init__.py +2 -0
  17. algokit_utils/accounts/account_manager.py +909 -0
  18. algokit_utils/accounts/kmd_account_manager.py +159 -0
  19. algokit_utils/algorand.py +265 -0
  20. algokit_utils/application_client.py +9 -1453
  21. algokit_utils/application_specification.py +39 -197
  22. algokit_utils/applications/__init__.py +7 -0
  23. algokit_utils/applications/abi.py +276 -0
  24. algokit_utils/applications/app_client.py +2056 -0
  25. algokit_utils/applications/app_deployer.py +600 -0
  26. algokit_utils/applications/app_factory.py +826 -0
  27. algokit_utils/applications/app_manager.py +470 -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 +1023 -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 +320 -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 +656 -0
  42. algokit_utils/clients/dispenser_api_client.py +192 -0
  43. algokit_utils/common.py +8 -26
  44. algokit_utils/config.py +71 -18
  45. algokit_utils/deploy.py +7 -892
  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 -80
  50. algokit_utils/models/__init__.py +8 -0
  51. algokit_utils/models/account.py +193 -0
  52. algokit_utils/models/amount.py +198 -0
  53. algokit_utils/models/application.py +61 -0
  54. algokit_utils/models/network.py +25 -0
  55. algokit_utils/models/simulate.py +11 -0
  56. algokit_utils/models/state.py +59 -0
  57. algokit_utils/models/transaction.py +100 -0
  58. algokit_utils/network_clients.py +7 -152
  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 +2293 -0
  64. algokit_utils/transactions/transaction_creator.py +156 -0
  65. algokit_utils/transactions/transaction_sender.py +574 -0
  66. {algokit_utils-3.0.0b1.dist-info → algokit_utils-3.0.0b2.dist-info}/METADATA +12 -7
  67. algokit_utils-3.0.0b2.dist-info/RECORD +70 -0
  68. {algokit_utils-3.0.0b1.dist-info → algokit_utils-3.0.0b2.dist-info}/WHEEL +1 -1
  69. algokit_utils-3.0.0b1.dist-info/RECORD +0 -24
  70. {algokit_utils-3.0.0b1.dist-info → algokit_utils-3.0.0b2.dist-info}/LICENSE +0 -0
@@ -0,0 +1,909 @@
1
+ import os
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import algosdk
7
+ from algosdk import mnemonic
8
+ from algosdk.atomic_transaction_composer import TransactionSigner
9
+ from algosdk.mnemonic import to_private_key
10
+ from algosdk.transaction import LogicSigAccount as AlgosdkLogicSigAccount
11
+ from algosdk.transaction import SuggestedParams
12
+ from typing_extensions import Self
13
+
14
+ from algokit_utils.accounts.kmd_account_manager import KmdAccountManager
15
+ from algokit_utils.clients.client_manager import ClientManager
16
+ from algokit_utils.clients.dispenser_api_client import DispenserAssetName, TestNetDispenserApiClient
17
+ from algokit_utils.config import config
18
+ from algokit_utils.models.account import (
19
+ DISPENSER_ACCOUNT_NAME,
20
+ LogicSigAccount,
21
+ MultiSigAccount,
22
+ MultisigMetadata,
23
+ SigningAccount,
24
+ TransactionSignerAccount,
25
+ )
26
+ from algokit_utils.models.amount import AlgoAmount
27
+ from algokit_utils.models.transaction import SendParams
28
+ from algokit_utils.protocols.account import TransactionSignerAccountProtocol
29
+ from algokit_utils.transactions.transaction_composer import (
30
+ PaymentParams,
31
+ SendAtomicTransactionComposerResults,
32
+ TransactionComposer,
33
+ )
34
+ from algokit_utils.transactions.transaction_sender import SendSingleTransactionResult
35
+
36
+ logger = config.logger
37
+
38
+ __all__ = [
39
+ "AccountInformation",
40
+ "AccountManager",
41
+ "EnsureFundedFromTestnetDispenserApiResult",
42
+ "EnsureFundedResult",
43
+ ]
44
+
45
+
46
+ @dataclass(frozen=True, kw_only=True)
47
+ class _CommonEnsureFundedParams:
48
+ """
49
+ Common parameters for ensure funded responses.
50
+ """
51
+
52
+ transaction_id: str
53
+ amount_funded: AlgoAmount
54
+
55
+
56
+ @dataclass(frozen=True, kw_only=True)
57
+ class EnsureFundedResult(SendSingleTransactionResult, _CommonEnsureFundedParams):
58
+ """
59
+ Result from performing an ensure funded call.
60
+ """
61
+
62
+
63
+ @dataclass(frozen=True, kw_only=True)
64
+ class EnsureFundedFromTestnetDispenserApiResult(_CommonEnsureFundedParams):
65
+ """
66
+ Result from performing an ensure funded call using TestNet dispenser API.
67
+ """
68
+
69
+
70
+ @dataclass(frozen=True, kw_only=True)
71
+ class AccountInformation:
72
+ """
73
+ Information about an Algorand account's current status, balance and other properties.
74
+
75
+ See `https://developer.algorand.org/docs/rest-apis/algod/#account` for detailed field descriptions.
76
+
77
+ :ivar str address: The account's address
78
+ :ivar AlgoAmount amount: The account's current balance
79
+ :ivar AlgoAmount amount_without_pending_rewards: The account's balance without the pending rewards
80
+ :ivar AlgoAmount min_balance: The account's minimum required balance
81
+ :ivar AlgoAmount pending_rewards: The amount of pending rewards
82
+ :ivar AlgoAmount rewards: The amount of rewards earned
83
+ :ivar int round: The round for which this information is relevant
84
+ :ivar str status: The account's status (e.g., 'Offline', 'Online')
85
+ :ivar int|None total_apps_opted_in: Number of applications this account has opted into
86
+ :ivar int|None total_assets_opted_in: Number of assets this account has opted into
87
+ :ivar int|None total_box_bytes: Total number of box bytes used by this account
88
+ :ivar int|None total_boxes: Total number of boxes used by this account
89
+ :ivar int|None total_created_apps: Number of applications created by this account
90
+ :ivar int|None total_created_assets: Number of assets created by this account
91
+ :ivar list[dict]|None apps_local_state: Local state of applications this account has opted into
92
+ :ivar int|None apps_total_extra_pages: Number of extra pages allocated to applications
93
+ :ivar dict|None apps_total_schema: Total schema for all applications
94
+ :ivar list[dict]|None assets: Assets held by this account
95
+ :ivar str|None auth_addr: If rekeyed, the authorized address
96
+ :ivar int|None closed_at_round: Round when this account was closed
97
+ :ivar list[dict]|None created_apps: Applications created by this account
98
+ :ivar list[dict]|None created_assets: Assets created by this account
99
+ :ivar int|None created_at_round: Round when this account was created
100
+ :ivar bool|None deleted: Whether this account is deleted
101
+ :ivar bool|None incentive_eligible: Whether this account is eligible for incentives
102
+ :ivar int|None last_heartbeat: Last heartbeat round for this account
103
+ :ivar int|None last_proposed: Last round this account proposed a block
104
+ :ivar dict|None participation: Participation information for this account
105
+ :ivar int|None reward_base: Base reward for this account
106
+ :ivar str|None sig_type: Signature type for this account
107
+ """
108
+
109
+ address: str
110
+ amount: AlgoAmount
111
+ amount_without_pending_rewards: AlgoAmount
112
+ min_balance: AlgoAmount
113
+ pending_rewards: AlgoAmount
114
+ rewards: AlgoAmount
115
+ round: int
116
+ status: str
117
+ total_apps_opted_in: int | None = None
118
+ total_assets_opted_in: int | None = None
119
+ total_box_bytes: int | None = None
120
+ total_boxes: int | None = None
121
+ total_created_apps: int | None = None
122
+ total_created_assets: int | None = None
123
+ apps_local_state: list[dict] | None = None
124
+ apps_total_extra_pages: int | None = None
125
+ apps_total_schema: dict | None = None
126
+ assets: list[dict] | None = None
127
+ auth_addr: str | None = None
128
+ closed_at_round: int | None = None
129
+ created_apps: list[dict] | None = None
130
+ created_assets: list[dict] | None = None
131
+ created_at_round: int | None = None
132
+ deleted: bool | None = None
133
+ incentive_eligible: bool | None = None
134
+ last_heartbeat: int | None = None
135
+ last_proposed: int | None = None
136
+ participation: dict | None = None
137
+ reward_base: int | None = None
138
+ sig_type: str | None = None
139
+
140
+
141
+ class AccountManager:
142
+ """
143
+ Creates and keeps track of signing accounts that can sign transactions for a sending address.
144
+
145
+ This class provides functionality to create, track, and manage various types of accounts including
146
+ mnemonic-based, rekeyed, multisig, and logic signature accounts.
147
+
148
+ :param client_manager: The ClientManager client to use for algod and kmd clients
149
+
150
+ :example:
151
+ >>> account_manager = AccountManager(client_manager)
152
+ """
153
+
154
+ def __init__(self, client_manager: ClientManager):
155
+ self._client_manager = client_manager
156
+ self._kmd_account_manager = KmdAccountManager(client_manager)
157
+ self._accounts = dict[str, TransactionSignerAccountProtocol]()
158
+ self._default_signer: TransactionSigner | None = None
159
+
160
+ @property
161
+ def kmd(self) -> KmdAccountManager:
162
+ return self._kmd_account_manager
163
+
164
+ def set_default_signer(self, signer: TransactionSigner | TransactionSignerAccountProtocol) -> Self:
165
+ """
166
+ Sets the default signer to use if no other signer is specified.
167
+
168
+ If this isn't set and a transaction needs signing for a given sender
169
+ then an error will be thrown from `get_signer` / `get_account`.
170
+
171
+ :param signer: A `TransactionSigner` signer to use.
172
+ :returns: The `AccountManager` so method calls can be chained
173
+
174
+ :example:
175
+ >>> signer_account = account_manager.random()
176
+ >>> account_manager.set_default_signer(signer_account.signer)
177
+ >>> # When signing a transaction, if there is no signer registered for the sender
178
+ >>> # then the default signer will be used
179
+ >>> signer = account_manager.get_signer("{SENDERADDRESS}")
180
+ """
181
+ self._default_signer = signer if isinstance(signer, TransactionSigner) else signer.signer
182
+ return self
183
+
184
+ def set_signer(self, sender: str, signer: TransactionSigner) -> Self:
185
+ """
186
+ Tracks the given `TransactionSigner` against the given sender address for later signing.
187
+
188
+ :param sender: The sender address to use this signer for
189
+ :param signer: The `TransactionSigner` to sign transactions with for the given sender
190
+ :returns: The `AccountManager` instance for method chaining
191
+
192
+ :example:
193
+ >>> account_manager.set_signer("SENDERADDRESS", transaction_signer)
194
+ """
195
+ self._accounts[sender] = TransactionSignerAccount(address=sender, signer=signer)
196
+ return self
197
+
198
+ def set_signers(self, *, another_account_manager: "AccountManager", overwrite_existing: bool = True) -> Self:
199
+ """
200
+ Merges the given `AccountManager` into this one.
201
+
202
+ :param another_account_manager: The `AccountManager` to merge into this one
203
+ :param overwrite_existing: Whether to overwrite existing signers in this manager
204
+ :returns: The `AccountManager` instance for method chaining
205
+ """
206
+ self._accounts = (
207
+ {**self._accounts, **another_account_manager._accounts} # noqa: SLF001
208
+ if overwrite_existing
209
+ else {**another_account_manager._accounts, **self._accounts} # noqa: SLF001
210
+ )
211
+ return self
212
+
213
+ def set_signer_from_account(self, account: TransactionSignerAccountProtocol) -> Self:
214
+ """
215
+ Tracks the given account for later signing.
216
+
217
+ Note: If you are generating accounts via the various methods on `AccountManager`
218
+ (like `random`, `from_mnemonic`, `logic_sig`, etc.) then they automatically get tracked.
219
+
220
+ :param account: The account to register
221
+ :returns: The `AccountManager` instance for method chaining
222
+
223
+ :example:
224
+ >>> account_manager = AccountManager(client_manager)
225
+ >>> account_manager.set_signer_from_account(SigningAccount.new_account())
226
+ >>> account_manager.set_signer_from_account(LogicSigAccount(AlgosdkLogicSigAccount(program, args)))
227
+ >>> account_manager.set_signer_from_account(MultiSigAccount(multisig_params, [account1, account2]))
228
+ """
229
+ self._accounts[account.address] = account
230
+ return self
231
+
232
+ def get_signer(self, sender: str | TransactionSignerAccountProtocol) -> TransactionSigner:
233
+ """
234
+ Returns the `TransactionSigner` for the given sender address.
235
+
236
+ If no signer has been registered for that address then the default signer is used if registered.
237
+
238
+ :param sender: The sender address or account
239
+ :returns: The `TransactionSigner`
240
+ :raises ValueError: If no signer is found and no default signer is set
241
+
242
+ :example:
243
+ >>> signer = account_manager.get_signer("SENDERADDRESS")
244
+ """
245
+ signer = self._accounts.get(self._get_address(sender)) or self._default_signer
246
+ if not signer:
247
+ raise ValueError(f"No signer found for address {sender}")
248
+ return signer if isinstance(signer, TransactionSigner) else signer.signer
249
+
250
+ def get_account(self, sender: str) -> TransactionSignerAccountProtocol:
251
+ """
252
+ Returns the `TransactionSignerAccountProtocol` for the given sender address.
253
+
254
+ :param sender: The sender address
255
+ :returns: The `TransactionSignerAccountProtocol`
256
+ :raises ValueError: If no account is found or if the account is not a regular account
257
+
258
+ :example:
259
+ >>> sender = account_manager.random()
260
+ >>> # ...
261
+ >>> # Returns the `TransactionSignerAccountProtocol` for `sender` that has previously been registered
262
+ >>> account = account_manager.get_account(sender)
263
+ """
264
+ account = self._accounts.get(sender)
265
+ if not account:
266
+ raise ValueError(f"No account found for address {sender}")
267
+ if not isinstance(account, SigningAccount):
268
+ raise ValueError(f"Account {sender} is not a regular account")
269
+ return account
270
+
271
+ def get_information(self, sender: str | TransactionSignerAccountProtocol) -> AccountInformation:
272
+ """
273
+ Returns the given sender account's current status, balance and spendable amounts.
274
+
275
+ See `<https://developer.algorand.org/docs/rest-apis/algod/#get-v2accountsaddress>`_
276
+ for response data schema details.
277
+
278
+ :param sender: The address or account compliant with `TransactionSignerAccountProtocol` protocol to look up
279
+ :returns: The account information
280
+
281
+ :example:
282
+ >>> address = "XBYLS2E6YI6XXL5BWCAMOA4GTWHXWENZMX5UHXMRNWWUQ7BXCY5WC5TEPA"
283
+ >>> account_info = account_manager.get_information(address)
284
+ """
285
+ info = self._client_manager.algod.account_info(self._get_address(sender))
286
+ assert isinstance(info, dict)
287
+ info = {k.replace("-", "_"): v for k, v in info.items()}
288
+ for key, value in info.items():
289
+ if key in ("amount", "amount_without_pending_rewards", "min_balance", "pending_rewards", "rewards"):
290
+ info[key] = AlgoAmount.from_micro_algo(value)
291
+ return AccountInformation(**info)
292
+
293
+ def _register_account(self, private_key: str, address: str | None = None) -> SigningAccount:
294
+ """
295
+ Helper method to create and register an account with its signer.
296
+
297
+ :param private_key: The private key for the account
298
+ :param address: The address for the account
299
+ :returns: The registered Account instance
300
+ """
301
+ address = address or str(algosdk.account.address_from_private_key(private_key))
302
+ account = SigningAccount(private_key=private_key, address=address)
303
+ self._accounts[address or account.address] = TransactionSignerAccount(
304
+ address=account.address, signer=account.signer
305
+ )
306
+ return account
307
+
308
+ def _register_logicsig(self, program: bytes, args: list[bytes] | None = None) -> LogicSigAccount:
309
+ """
310
+ Helper method to create and register a logic signature account.
311
+
312
+ :param program: The bytes that make up the compiled logic signature
313
+ :param args: The (binary) arguments to pass into the logic signature
314
+ :returns: The registered AlgosdkLogicSigAccount instance
315
+ """
316
+ logic_sig = LogicSigAccount(AlgosdkLogicSigAccount(program, args))
317
+ self._accounts[logic_sig.address] = logic_sig
318
+ return logic_sig
319
+
320
+ def _register_multisig(self, metadata: MultisigMetadata, signing_accounts: list[SigningAccount]) -> MultiSigAccount:
321
+ """
322
+ Helper method to create and register a multisig account.
323
+
324
+ :param metadata: The metadata for the multisig account
325
+ :param signing_accounts: The list of accounts that are present to sign
326
+ :returns: The registered MultisigAccount instance
327
+ """
328
+ msig_account = MultiSigAccount(metadata, signing_accounts)
329
+ self._accounts[str(msig_account.address)] = MultiSigAccount(metadata, signing_accounts)
330
+ return msig_account
331
+
332
+ def from_mnemonic(self, *, mnemonic: str, sender: str | None = None) -> SigningAccount:
333
+ """
334
+ Tracks and returns an Algorand account with secret key loaded by taking the mnemonic secret.
335
+
336
+ :param mnemonic: The mnemonic secret representing the private key of an account
337
+ :param sender: Optional address to use as the sender
338
+ :returns: The account
339
+
340
+ .. warning::
341
+ Be careful how the mnemonic is handled. Never commit it into source control and ideally load it
342
+ from the environment (ideally via a secret storage service) rather than the file system.
343
+
344
+ :example:
345
+ >>> account = account_manager.from_mnemonic("mnemonic secret ...")
346
+ """
347
+ return self._register_account(to_private_key(mnemonic), sender)
348
+
349
+ def from_environment(self, name: str, fund_with: AlgoAmount | None = None) -> SigningAccount:
350
+ """
351
+ Tracks and returns an Algorand account with private key loaded by convention from environment variables.
352
+
353
+ This allows you to write code that will work seamlessly in production and local development (LocalNet)
354
+ without manual config locally (including when you reset the LocalNet).
355
+
356
+ :param name: The name identifier of the account
357
+ :param fund_with: Optional amount to fund the account with when it gets created
358
+ (when targeting LocalNet)
359
+ :returns: The account
360
+ :raises ValueError: If environment variable {NAME}_MNEMONIC is missing when looking for account {NAME}
361
+
362
+ .. note::
363
+ Convention:
364
+ * **Non-LocalNet:** will load `{NAME}_MNEMONIC` as a mnemonic secret.
365
+ If `{NAME}_SENDER` is defined then it will use that for the sender address
366
+ (i.e. to support rekeyed accounts)
367
+ * **LocalNet:** will load the account from a KMD wallet called {NAME} and if that wallet doesn't exist
368
+ it will create it and fund the account for you
369
+
370
+ :example:
371
+ >>> # If you have a mnemonic secret loaded into `MY_ACCOUNT_MNEMONIC` then you can call:
372
+ >>> account = account_manager.from_environment('MY_ACCOUNT')
373
+ >>> # If that code runs against LocalNet then a wallet called `MY_ACCOUNT` will automatically be created
374
+ >>> # with an account that is automatically funded with the specified amount from the default LocalNet dispenser
375
+ """
376
+ account_mnemonic = os.getenv(f"{name.upper()}_MNEMONIC")
377
+
378
+ if account_mnemonic:
379
+ private_key = mnemonic.to_private_key(account_mnemonic)
380
+ return self._register_account(private_key)
381
+
382
+ if self._client_manager.is_localnet():
383
+ kmd_account = self._kmd_account_manager.get_or_create_wallet_account(name, fund_with)
384
+ return self._register_account(kmd_account.private_key)
385
+
386
+ raise ValueError(f"Missing environment variable {name.upper()}_MNEMONIC when looking for account {name}")
387
+
388
+ def from_kmd(
389
+ self, name: str, predicate: Callable[[dict[str, Any]], bool] | None = None, sender: str | None = None
390
+ ) -> SigningAccount:
391
+ """
392
+ Tracks and returns an Algorand account with private key loaded from the given KMD wallet.
393
+
394
+ :param name: The name of the wallet to retrieve an account from
395
+ :param predicate: Optional filter to use to find the account
396
+ :param sender: Optional sender address to use this signer for (aka a rekeyed account)
397
+ :returns: The account
398
+ :raises ValueError: If unable to find KMD account with given name and predicate
399
+
400
+ :example:
401
+ >>> # Get default funded account in a LocalNet:
402
+ >>> defaultDispenserAccount = account.from_kmd('unencrypted-default-wallet',
403
+ ... lambda a: a.status != 'Offline' and a.amount > 1_000_000_000
404
+ ... )
405
+ """
406
+ kmd_account = self._kmd_account_manager.get_wallet_account(name, predicate, sender)
407
+ if not kmd_account:
408
+ raise ValueError(f"Unable to find KMD account {name}{' with predicate' if predicate else ''}")
409
+
410
+ return self._register_account(kmd_account.private_key)
411
+
412
+ def logicsig(self, program: bytes, args: list[bytes] | None = None) -> LogicSigAccount:
413
+ """
414
+ Tracks and returns an account that represents a logic signature.
415
+
416
+ :param program: The bytes that make up the compiled logic signature
417
+ :param args: Optional (binary) arguments to pass into the logic signature
418
+ :returns: A logic signature account wrapper
419
+
420
+ :example:
421
+ >>> account = account.logic_sig(program, [new Uint8Array(3, ...)])
422
+ """
423
+ return self._register_logicsig(program, args)
424
+
425
+ def multisig(self, metadata: MultisigMetadata, signing_accounts: list[SigningAccount]) -> MultiSigAccount:
426
+ """
427
+ Tracks and returns an account that supports partial or full multisig signing.
428
+
429
+ :param metadata: The metadata for the multisig account
430
+ :param signing_accounts: The signers that are currently present
431
+ :returns: A multisig account wrapper
432
+
433
+ :example:
434
+ >>> account = account_manager.multi_sig(
435
+ ... version=1,
436
+ ... threshold=1,
437
+ ... addrs=["ADDRESS1...", "ADDRESS2..."],
438
+ ... signing_accounts=[account1, account2]
439
+ ... )
440
+ """
441
+ return self._register_multisig(metadata, signing_accounts)
442
+
443
+ def random(self) -> SigningAccount:
444
+ """
445
+ Tracks and returns a new, random Algorand account.
446
+
447
+ :returns: The account
448
+
449
+ :example:
450
+ >>> account = account_manager.random()
451
+ """
452
+ account = SigningAccount.new_account()
453
+ return self._register_account(account.private_key)
454
+
455
+ def localnet_dispenser(self) -> SigningAccount:
456
+ """
457
+ Returns an Algorand account with private key loaded for the default LocalNet dispenser account.
458
+
459
+ This account can be used to fund other accounts.
460
+
461
+ :returns: The account
462
+
463
+ :example:
464
+ >>> account = account_manager.localnet_dispenser()
465
+ """
466
+ kmd_account = self._kmd_account_manager.get_localnet_dispenser_account()
467
+ return self._register_account(kmd_account.private_key)
468
+
469
+ def dispenser_from_environment(self) -> SigningAccount:
470
+ """
471
+ Returns an account (with private key loaded) that can act as a dispenser from environment variables.
472
+
473
+ If environment variables are not present, returns the default LocalNet dispenser account.
474
+
475
+ :returns: The account
476
+
477
+ :example:
478
+ >>> account = account_manager.dispenser_from_environment()
479
+ """
480
+ name = os.getenv(f"{DISPENSER_ACCOUNT_NAME}_MNEMONIC")
481
+ if name:
482
+ return self.from_environment(DISPENSER_ACCOUNT_NAME)
483
+ return self.localnet_dispenser()
484
+
485
+ def rekeyed(
486
+ self, *, sender: str, account: TransactionSignerAccountProtocol
487
+ ) -> TransactionSignerAccount | SigningAccount:
488
+ """
489
+ Tracks and returns an Algorand account that is a rekeyed version of the given account to a new sender.
490
+
491
+ :param sender: The account or address to use as the sender
492
+ :param account: The account to use as the signer for this new rekeyed account
493
+ :returns: The rekeyed account
494
+
495
+ :example:
496
+ >>> account = account.from_mnemonic("mnemonic secret ...")
497
+ >>> rekeyed_account = account_manager.rekeyed(account, "SENDERADDRESS...")
498
+ """
499
+ sender_address = sender.address if isinstance(sender, SigningAccount) else sender
500
+ self._accounts[sender_address] = TransactionSignerAccount(address=sender_address, signer=account.signer)
501
+ if isinstance(account, SigningAccount):
502
+ return SigningAccount(address=sender_address, private_key=account.private_key)
503
+ return TransactionSignerAccount(address=sender_address, signer=account.signer)
504
+
505
+ def rekey_account( # noqa: PLR0913
506
+ self,
507
+ account: str,
508
+ rekey_to: str | TransactionSignerAccountProtocol,
509
+ *, # Common transaction parameters
510
+ signer: TransactionSigner | None = None,
511
+ note: bytes | None = None,
512
+ lease: bytes | None = None,
513
+ static_fee: AlgoAmount | None = None,
514
+ extra_fee: AlgoAmount | None = None,
515
+ max_fee: AlgoAmount | None = None,
516
+ validity_window: int | None = None,
517
+ first_valid_round: int | None = None,
518
+ last_valid_round: int | None = None,
519
+ suppress_log: bool | None = None,
520
+ ) -> SendAtomicTransactionComposerResults:
521
+ """
522
+ Rekey an account to a new address.
523
+
524
+ :param account: The account to rekey
525
+ :param rekey_to: The address or account to rekey to
526
+ :param signer: Optional transaction signer
527
+ :param note: Optional transaction note
528
+ :param lease: Optional transaction lease
529
+ :param static_fee: Optional static fee
530
+ :param extra_fee: Optional extra fee
531
+ :param max_fee: Optional max fee
532
+ :param validity_window: Optional validity window
533
+ :param first_valid_round: Optional first valid round
534
+ :param last_valid_round: Optional last valid round
535
+ :param suppress_log: Optional flag to suppress logging
536
+ :returns: The result of the transaction and the transaction that was sent
537
+
538
+ .. warning::
539
+ Please be careful with this function and be sure to read the
540
+ `official rekey guidance <https://developer.algorand.org/docs/get-details/accounts/rekey/>`_.
541
+
542
+ :example:
543
+ >>> # Basic example (with string addresses):
544
+ >>> algorand.account.rekey_account({account: "ACCOUNTADDRESS", rekey_to: "NEWADDRESS"})
545
+ >>> # Basic example (with signer accounts):
546
+ >>> algorand.account.rekey_account({account: account1, rekey_to: newSignerAccount})
547
+ >>> # Advanced example:
548
+ >>> algorand.account.rekey_account({
549
+ ... account: "ACCOUNTADDRESS",
550
+ ... rekey_to: "NEWADDRESS",
551
+ ... lease: 'lease',
552
+ ... note: 'note',
553
+ ... first_valid_round: 1000,
554
+ ... validity_window: 10,
555
+ ... extra_fee: AlgoAmount.from_micro_algo(1000),
556
+ ... static_fee: AlgoAmount.from_micro_algo(1000),
557
+ ... max_fee: AlgoAmount.from_micro_algo(3000),
558
+ ... suppress_log: True,
559
+ ... })
560
+ """
561
+ sender_address = self._get_address(account)
562
+ rekey_address = self._get_address(rekey_to)
563
+
564
+ result = (
565
+ self._get_composer()
566
+ .add_payment(
567
+ PaymentParams(
568
+ sender=sender_address,
569
+ receiver=sender_address,
570
+ amount=AlgoAmount.from_micro_algo(0),
571
+ rekey_to=rekey_address,
572
+ signer=signer,
573
+ note=note,
574
+ lease=lease,
575
+ static_fee=static_fee,
576
+ extra_fee=extra_fee,
577
+ max_fee=max_fee,
578
+ validity_window=validity_window,
579
+ first_valid_round=first_valid_round,
580
+ last_valid_round=last_valid_round,
581
+ )
582
+ )
583
+ .send()
584
+ )
585
+
586
+ # If rekey_to is a signing account, set it as the signer for this account
587
+ if isinstance(rekey_to, SigningAccount):
588
+ self.rekeyed(sender=account, account=rekey_to)
589
+
590
+ if not suppress_log:
591
+ logger.info(f"Rekeyed {sender_address} to {rekey_address} via transaction {result.tx_ids[-1]}")
592
+
593
+ return result
594
+
595
+ def ensure_funded( # noqa: PLR0913
596
+ self,
597
+ account_to_fund: str | SigningAccount,
598
+ dispenser_account: str | SigningAccount,
599
+ min_spending_balance: AlgoAmount,
600
+ min_funding_increment: AlgoAmount | None = None,
601
+ # Sender params
602
+ send_params: SendParams | None = None,
603
+ # Common txn params
604
+ signer: TransactionSigner | None = None,
605
+ rekey_to: str | None = None,
606
+ note: bytes | None = None,
607
+ lease: bytes | None = None,
608
+ static_fee: AlgoAmount | None = None,
609
+ extra_fee: AlgoAmount | None = None,
610
+ max_fee: AlgoAmount | None = None,
611
+ validity_window: int | None = None,
612
+ first_valid_round: int | None = None,
613
+ last_valid_round: int | None = None,
614
+ ) -> EnsureFundedResult | None:
615
+ """
616
+ Funds a given account using a dispenser account as a funding source.
617
+
618
+ Ensures the given account has a certain amount of Algo free to spend (accounting for
619
+ Algo locked in minimum balance requirement).
620
+
621
+ See `<https://developer.algorand.org/docs/get-details/accounts/#minimum-balance>`_ for details.
622
+
623
+ :param account_to_fund: The account to fund
624
+ :param dispenser_account: The account to use as a dispenser funding source
625
+ :param min_spending_balance: The minimum balance of Algo that the account
626
+ should have available to spend
627
+ :param min_funding_increment: Optional minimum funding increment
628
+ :param send_params: Parameters for the send operation, defaults to None
629
+ :param signer: Optional transaction signer
630
+ :param rekey_to: Optional rekey address
631
+ :param note: Optional transaction note
632
+ :param lease: Optional transaction lease
633
+ :param static_fee: Optional static fee
634
+ :param extra_fee: Optional extra fee
635
+ :param max_fee: Optional maximum fee
636
+ :param validity_window: Optional validity window
637
+ :param first_valid_round: Optional first valid round
638
+ :param last_valid_round: Optional last valid round
639
+ :returns: The result of executing the dispensing transaction and the `amountFunded` if funds were needed,
640
+ or None if no funds were needed
641
+
642
+ :example:
643
+ >>> # Basic example:
644
+ >>> algorand.account.ensure_funded("ACCOUNTADDRESS", "DISPENSERADDRESS", algokit.algo(1))
645
+ >>> # With configuration:
646
+ >>> algorand.account.ensure_funded(
647
+ ... "ACCOUNTADDRESS",
648
+ ... "DISPENSERADDRESS",
649
+ ... algokit.algo(1),
650
+ ... min_funding_increment=algokit.algo(2),
651
+ ... fee=AlgoAmount.from_micro_algo(1000),
652
+ ... suppress_log=True
653
+ ... )
654
+ """
655
+ account_to_fund = self._get_address(account_to_fund)
656
+ dispenser_account = self._get_address(dispenser_account)
657
+ amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment)
658
+
659
+ if not amount_funded:
660
+ return None
661
+
662
+ result = (
663
+ self._get_composer()
664
+ .add_payment(
665
+ PaymentParams(
666
+ sender=dispenser_account,
667
+ receiver=account_to_fund,
668
+ amount=amount_funded,
669
+ signer=signer,
670
+ rekey_to=rekey_to,
671
+ note=note,
672
+ lease=lease,
673
+ static_fee=static_fee,
674
+ extra_fee=extra_fee,
675
+ max_fee=max_fee,
676
+ validity_window=validity_window,
677
+ first_valid_round=first_valid_round,
678
+ last_valid_round=last_valid_round,
679
+ )
680
+ )
681
+ .send(send_params)
682
+ )
683
+
684
+ return EnsureFundedResult(
685
+ returns=result.returns,
686
+ transactions=result.transactions,
687
+ confirmations=result.confirmations,
688
+ tx_ids=result.tx_ids,
689
+ group_id=result.group_id,
690
+ transaction_id=result.tx_ids[0],
691
+ confirmation=result.confirmations[0],
692
+ transaction=result.transactions[0],
693
+ amount_funded=amount_funded,
694
+ )
695
+
696
+ def ensure_funded_from_environment( # noqa: PLR0913
697
+ self,
698
+ account_to_fund: str | SigningAccount,
699
+ min_spending_balance: AlgoAmount,
700
+ *, # Force remaining params to be keyword-only
701
+ min_funding_increment: AlgoAmount | None = None,
702
+ # SendParams
703
+ send_params: SendParams | None = None,
704
+ # Common transaction params (omitting sender)
705
+ signer: TransactionSigner | None = None,
706
+ rekey_to: str | None = None,
707
+ note: bytes | None = None,
708
+ lease: bytes | None = None,
709
+ static_fee: AlgoAmount | None = None,
710
+ extra_fee: AlgoAmount | None = None,
711
+ max_fee: AlgoAmount | None = None,
712
+ validity_window: int | None = None,
713
+ first_valid_round: int | None = None,
714
+ last_valid_round: int | None = None,
715
+ ) -> EnsureFundedResult | None:
716
+ """
717
+ Ensure an account is funded from a dispenser account configured in environment.
718
+
719
+ Uses a dispenser account retrieved from the environment, per the `dispenser_from_environment` method,
720
+ as a funding source such that the given account has a certain amount of Algo free to spend
721
+ (accounting for Algo locked in minimum balance requirement).
722
+
723
+ See `<https://developer.algorand.org/docs/get-details/accounts/#minimum-balance>`_ for details.
724
+
725
+ :param account_to_fund: The account to fund
726
+ :param min_spending_balance: The minimum balance of Algo that the account should have available to
727
+ spend
728
+ :param min_funding_increment: Optional minimum funding increment
729
+ :param send_params: Parameters for the send operation, defaults to None
730
+ :param signer: Optional transaction signer
731
+ :param rekey_to: Optional rekey address
732
+ :param note: Optional transaction note
733
+ :param lease: Optional transaction lease
734
+ :param static_fee: Optional static fee
735
+ :param extra_fee: Optional extra fee
736
+ :param max_fee: Optional maximum fee
737
+ :param validity_window: Optional validity window
738
+ :param first_valid_round: Optional first valid round
739
+ :param last_valid_round: Optional last valid round
740
+ :returns: The result of executing the dispensing transaction and the `amountFunded` if funds were needed, or
741
+ None if no funds were needed
742
+
743
+ .. note::
744
+ The dispenser account is retrieved from the account mnemonic stored in
745
+ process.env.DISPENSER_MNEMONIC and optionally process.env.DISPENSER_SENDER
746
+ if it's a rekeyed account, or against default LocalNet if no environment variables present.
747
+
748
+ :example:
749
+ >>> # Basic example:
750
+ >>> algorand.account.ensure_funded_from_environment("ACCOUNTADDRESS", algokit.algo(1))
751
+ >>> # With configuration:
752
+ >>> algorand.account.ensure_funded_from_environment(
753
+ ... "ACCOUNTADDRESS",
754
+ ... algokit.algo(1),
755
+ ... min_funding_increment=algokit.algo(2),
756
+ ... fee=AlgoAmount.from_micro_algo(1000),
757
+ ... suppress_log=True
758
+ ... )
759
+ """
760
+ account_to_fund = self._get_address(account_to_fund)
761
+ dispenser_account = self.dispenser_from_environment()
762
+
763
+ amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment)
764
+
765
+ if not amount_funded:
766
+ return None
767
+
768
+ result = (
769
+ self._get_composer()
770
+ .add_payment(
771
+ PaymentParams(
772
+ sender=dispenser_account.address,
773
+ receiver=account_to_fund,
774
+ amount=amount_funded,
775
+ signer=signer,
776
+ rekey_to=rekey_to,
777
+ note=note,
778
+ lease=lease,
779
+ static_fee=static_fee,
780
+ extra_fee=extra_fee,
781
+ max_fee=max_fee,
782
+ validity_window=validity_window,
783
+ first_valid_round=first_valid_round,
784
+ last_valid_round=last_valid_round,
785
+ )
786
+ )
787
+ .send(send_params)
788
+ )
789
+
790
+ return EnsureFundedResult(
791
+ returns=result.returns,
792
+ transactions=result.transactions,
793
+ confirmations=result.confirmations,
794
+ tx_ids=result.tx_ids,
795
+ group_id=result.group_id,
796
+ transaction_id=result.tx_ids[0],
797
+ confirmation=result.confirmations[0],
798
+ transaction=result.transactions[0],
799
+ amount_funded=amount_funded,
800
+ )
801
+
802
+ def ensure_funded_from_testnet_dispenser_api(
803
+ self,
804
+ account_to_fund: str | SigningAccount,
805
+ dispenser_client: TestNetDispenserApiClient,
806
+ min_spending_balance: AlgoAmount,
807
+ *,
808
+ min_funding_increment: AlgoAmount | None = None,
809
+ ) -> EnsureFundedFromTestnetDispenserApiResult | None:
810
+ """
811
+ Ensure an account is funded using the TestNet Dispenser API.
812
+
813
+ Uses the TestNet Dispenser API as a funding source such that the account has a certain amount
814
+ of Algo free to spend (accounting for Algo locked in minimum balance requirement).
815
+
816
+ See `<https://developer.algorand.org/docs/get-details/accounts/#minimum-balance>`_ for details.
817
+
818
+ :param account_to_fund: The account to fund
819
+ :param dispenser_client: The TestNet dispenser funding client
820
+ :param min_spending_balance: The minimum balance of Algo that the account should have
821
+ available to spend
822
+ :param min_funding_increment: Optional minimum funding increment
823
+ :returns: The result of executing the dispensing transaction and the `amountFunded` if funds were needed, or
824
+ None if no funds were needed
825
+ :raises ValueError: If attempting to fund on non-TestNet network
826
+
827
+ :example:
828
+ >>> # Basic example:
829
+ >>> algorand.account.ensure_funded_from_testnet_dispenser_api(
830
+ ... "ACCOUNTADDRESS",
831
+ ... algorand.client.get_testnet_dispenser_from_environment(),
832
+ ... algokit.algo(1)
833
+ ... )
834
+ >>> # With configuration:
835
+ >>> algorand.account.ensure_funded_from_testnet_dispenser_api(
836
+ ... "ACCOUNTADDRESS",
837
+ ... algorand.client.get_testnet_dispenser_from_environment(),
838
+ ... algokit.algo(1),
839
+ ... min_funding_increment=algokit.algo(2)
840
+ ... )
841
+ """
842
+ account_to_fund = self._get_address(account_to_fund)
843
+
844
+ if not self._client_manager.is_testnet():
845
+ raise ValueError("Attempt to fund using TestNet dispenser API on non TestNet network.")
846
+
847
+ amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment)
848
+
849
+ if not amount_funded:
850
+ return None
851
+
852
+ result = dispenser_client.fund(
853
+ address=account_to_fund,
854
+ amount=amount_funded.micro_algo,
855
+ asset_id=DispenserAssetName.ALGO,
856
+ )
857
+
858
+ return EnsureFundedFromTestnetDispenserApiResult(
859
+ transaction_id=result.tx_id,
860
+ amount_funded=AlgoAmount.from_micro_algo(result.amount),
861
+ )
862
+
863
+ def _get_address(self, sender: str | TransactionSignerAccountProtocol) -> str:
864
+ match sender:
865
+ case TransactionSignerAccountProtocol():
866
+ return sender.address
867
+ case str():
868
+ return sender
869
+ case _:
870
+ raise ValueError(f"Unknown sender type: {type(sender)}")
871
+
872
+ def _get_composer(self, get_suggested_params: Callable[[], SuggestedParams] | None = None) -> TransactionComposer:
873
+ if get_suggested_params is None:
874
+
875
+ def _get_suggested_params() -> SuggestedParams:
876
+ return self._client_manager.algod.suggested_params()
877
+
878
+ get_suggested_params = _get_suggested_params
879
+
880
+ return TransactionComposer(
881
+ algod=self._client_manager.algod, get_signer=self.get_signer, get_suggested_params=get_suggested_params
882
+ )
883
+
884
+ def _calculate_fund_amount(
885
+ self,
886
+ min_spending_balance: int,
887
+ current_spending_balance: AlgoAmount,
888
+ min_funding_increment: int,
889
+ ) -> int | None:
890
+ if min_spending_balance > current_spending_balance:
891
+ min_fund_amount = (min_spending_balance - current_spending_balance).micro_algo
892
+ return max(min_fund_amount, min_funding_increment)
893
+ return None
894
+
895
+ def _get_ensure_funded_amount(
896
+ self,
897
+ sender: str,
898
+ min_spending_balance: AlgoAmount,
899
+ min_funding_increment: AlgoAmount | None = None,
900
+ ) -> AlgoAmount | None:
901
+ account_info = self.get_information(sender)
902
+ current_spending_balance = account_info.amount - account_info.min_balance
903
+
904
+ min_increment = min_funding_increment.micro_algo if min_funding_increment else 0
905
+ amount_funded = self._calculate_fund_amount(
906
+ min_spending_balance.micro_algo, current_spending_balance, min_increment
907
+ )
908
+
909
+ return AlgoAmount.from_micro_algo(amount_funded) if amount_funded is not None else None