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