Skip to content

Commit

Permalink
feat: AlgorandClient (#71)
Browse files Browse the repository at this point in the history
* initial composer

* account and client manager

* build_group and execute

* WIP: algorand-client

* fix up algorand-client

* algorand-client -> algorand_client

* test_send_payment

* refactor params dataclasses

* fix sender params

* refactor txn params

* rm AlgoAmount

* beta namespace

* rm from __init__

* improve send_payment

* test_asset_opt_in

* addr -> address

* parity with JS tests

* ruff check --fix

* move account_manager to beta

* unsafe ruff fixes

* various fixes

* use match

* fix remaining ruff errors (other than line length and comments)

* assert rather than cast

* dont import from source

* use frozen dataclasses

* default get value

* instantiate dict

* using typing.Self

* fix some docstrings

* update idna due to vulnerability by pip-audit

* ruff

* ignore ruff errors in beta for now

* fix non sdk mypy stuff

* update cryptography for pip-audit

* rm comment

* ignore mypy errors

* ruff

* update setuptools

* chore: hotfixing the pkgutil.ImpImporter error when using 3.12 based pipx; adding dependabot; patching snapshot tests  (#72)

* fix: testing ci

* chore: testing ci

* chore: test

* chore: test

* chore: testing ci

* chore: testing ci

* chore: test

* chore: testing

* chore: test

* chore: test

* chore: removing tmp tweak

* chore: testing ci

* chore: lockfile maintenance (poetry update); reverting ci tweaks

* chore: testing ci

* chore: testing ci

* chore: testing ci

* tuple unpacking

---------

Co-authored-by: Al <[email protected]>
Co-authored-by: Altynbek Orumbayev <[email protected]>
  • Loading branch information
3 people authored Apr 26, 2024
1 parent d76ca8f commit 5ee3a3b
Show file tree
Hide file tree
Showing 9 changed files with 1,737 additions and 2 deletions.
4 changes: 2 additions & 2 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"autodoc2",
]
templates_path = ["_templates"]
exclude_patterns = []
exclude_patterns = [] # type: ignore
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"algosdk": ("https://py-algorand-sdk.readthedocs.io/en/latest", None),
Expand All @@ -37,7 +37,7 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output

html_theme = "sphinx_rtd_theme"
html_static_path = []
html_static_path = [] # type: ignore


# -- Options for myst ---
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ target-version = "py310"
allow-star-arg-any = true
suppress-none-returning = true

[tool.ruff.per-file-ignores]
"src/algokit_utils/beta/*" = ["ERA001", "E501", "PLR0911"]
"path/to/file.py" = ["E402"]

[tool.poe.tasks]
docs = "sphinx-build docs/source docs/html"

Expand Down
200 changes: 200 additions & 0 deletions src/algokit_utils/beta/account_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from algokit_utils.account import get_dispenser_account, get_kmd_wallet_account, get_localnet_default_account
from algosdk.account import generate_account
from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner
from typing_extensions import Self

from .client_manager import ClientManager


@dataclass
class AddressAndSigner:
address: str
signer: TransactionSigner


class AccountManager:
"""Creates and keeps track of addresses and signers"""

def __init__(self, client_manager: ClientManager):
"""
Create a new account manager.
:param client_manager: The ClientManager client to use for algod and kmd clients
"""
self._client_manager = client_manager
self._accounts = dict[str, TransactionSigner]()
self._default_signer: TransactionSigner | None = None

def set_default_signer(self, signer: TransactionSigner) -> Self:
"""
Sets the default signer to use if no other signer is specified.
:param signer: The signer to use, either a `TransactionSigner` or a `TransactionSignerAccount`
:return: The `AccountManager` so method calls can be chained
"""
self._default_signer = signer
return self

def set_signer(self, sender: str, signer: TransactionSigner) -> Self:
"""
Tracks the given account for later signing.
:param sender: The sender address to use this signer for
:param signer: The signer to sign transactions with for the given sender
:return: The AccountCreator instance for method chaining
"""
self._accounts[sender] = signer
return self

def get_signer(self, sender: str) -> TransactionSigner:
"""
Returns the `TransactionSigner` for the given sender address.
If no signer has been registered for that address then the default signer is used if registered.
:param sender: The sender address
:return: The `TransactionSigner` or throws an error if not found
"""
signer = self._accounts.get(sender, None) or self._default_signer
if not signer:
raise ValueError(f"No signer found for address {sender}")
return signer

def get_information(self, sender: str) -> dict[str, Any]:
"""
Returns the given sender account's current status, balance and spendable amounts.
Example:
address = "XBYLS2E6YI6XXL5BWCAMOA4GTWHXWENZMX5UHXMRNWWUQ7BXCY5WC5TEPA"
account_info = account.get_information(address)
`Response data schema details <https://developer.algorand.org/docs/rest-apis/algod/#get-v2accountsaddress>`_
:param sender: The address of the sender/account to look up
:return: The account information
"""
info = self._client_manager.algod.account_info(sender)
assert isinstance(info, dict)
return info

def get_asset_information(self, sender: str, asset_id: int) -> dict[str, Any]:
info = self._client_manager.algod.account_asset_info(sender, asset_id)
assert isinstance(info, dict)
return info

# TODO
# def from_mnemonic(self, mnemonic_secret: str, sender: Optional[str] = None) -> AddrAndSigner:
# """
# Tracks and returns an Algorand account with secret key loaded (i.e. that can sign transactions) by taking the mnemonic secret.

# Example:
# account = account.from_mnemonic("mnemonic secret ...")
# rekeyed_account = account.from_mnemonic("mnemonic secret ...", "SENDERADDRESS...")

# :param mnemonic_secret: The mnemonic secret representing the private key of an account; **Note: Be careful how the mnemonic is handled**,
# never commit it into source control and ideally load it from the environment (ideally via a secret storage service) rather than the file system.
# :param sender: The optional sender address to use this signer for (aka a rekeyed account)
# :return: The account
# """
# account = mnemonic_account(mnemonic_secret)
# return self.signer_account(rekeyed_account(account, sender) if sender else account)

def from_kmd(
self,
name: str,
predicate: Callable[[dict[str, Any]], bool] | None = None,
) -> AddressAndSigner:
"""
Tracks and returns an Algorand account with private key loaded from the given KMD wallet (identified by name).
Example (Get default funded account in a LocalNet):
default_dispenser_account = account.from_kmd('unencrypted-default-wallet',
lambda a: a['status'] != 'Offline' and a['amount'] > 1_000_000_000
)
:param name: The name of the wallet to retrieve an account from
:param predicate: An optional filter to use to find the account (otherwise it will return a random account from the wallet)
:return: The account
"""
account = get_kmd_wallet_account(
name=name, predicate=predicate, client=self._client_manager.algod, kmd_client=self._client_manager.kmd
)
if not account:
raise ValueError(f"Unable to find KMD account {name}{' with predicate' if predicate else ''}")

self.set_signer(account.address, account.signer)
return AddressAndSigner(address=account.address, signer=account.signer)

# TODO
# def multisig(
# self, multisig_params: algosdk.MultisigMetadata, signing_accounts: Union[algosdk.Account, SigningAccount]
# ) -> TransactionSignerAccount:
# """
# Tracks and returns an account that supports partial or full multisig signing.

# Example:
# account = account.multisig(
# {
# "version": 1,
# "threshold": 1,
# "addrs": ["ADDRESS1...", "ADDRESS2..."]
# },
# account.from_environment('ACCOUNT1')
# )

# :param multisig_params: The parameters that define the multisig account
# :param signing_accounts: The signers that are currently present
# :return: A multisig account wrapper
# """
# return self.signer_account(multisig_account(multisig_params, signing_accounts))

def random(self) -> AddressAndSigner:
"""
Tracks and returns a new, random Algorand account with secret key loaded.
Example:
account = account.random()
:return: The account
"""
(sk, addr) = generate_account() # type: ignore[no-untyped-call]
signer = AccountTransactionSigner(sk)

self.set_signer(addr, signer)

return AddressAndSigner(address=addr, signer=signer)

def dispenser(self) -> AddressAndSigner:
"""
Returns an account (with private key loaded) that can act as a dispenser.
Example:
account = account.dispenser()
If running on LocalNet then it will return the default dispenser account automatically,
otherwise it will load the account mnemonic stored in os.environ['DISPENSER_MNEMONIC'].
:return: The account
"""
acct = get_dispenser_account(self._client_manager.algod)

self.set_signer(acct.address, acct.signer)

return AddressAndSigner(address=acct.address, signer=acct.signer)

def localnet_dispenser(self) -> AddressAndSigner:
"""
Returns an Algorand account with private key loaded for the default LocalNet dispenser account (that can be used to fund other accounts).
Example:
account = account.localnet_dispenser()
:return: The account
"""
acct = get_localnet_default_account(self._client_manager.algod)
self.set_signer(acct.address, acct.signer)
return AddressAndSigner(address=acct.address, signer=acct.signer)
Loading

1 comment on commit 5ee3a3b

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/algokit_utils
   _debugging.py140795%20, 41, 76, 80, 89, 129, 157
   _ensure_funded.py69199%99
   _transfer.py62395%13, 76–77
   account.py851385%14–17, 61–65, 96, 109, 136, 139, 183
   application_client.py5337786%59–60, 166, 171, 200, 312, 317–318, 320, 322, 787, 802, 820–823, 913, 953, 965, 978, 1020, 1080–1086, 1090–1095, 1097, 1133, 1140, 1253, 1283, 1297, 1342–1344, 1346, 1356–1413, 1424–1429, 1449–1452
   application_specification.py971189%92, 94, 193–202, 206
   asset.py79594%9, 27–30
   common.py13192%13
   config.py511865%38–39, 50, 55, 60, 64–69, 100–109
   deploy.py4592495%31–34, 169, 173–174, 191, 206, 247, 403, 414–422, 439–442, 452, 460, 653–654, 678
   dispenser_api.py821285%112–113, 117–120, 155–157, 176–178
   logic_error.py38295%6, 29
   models.py227697%45, 50–52, 61–62
   network_clients.py74396%106–107, 138
src/algokit_utils/beta
   account_manager.py551475%39–40, 64, 123–130, 183–187, 198–200
   algorand_client.py1011585%111–112, 121–122, 143–145, 154–155, 224, 259, 274, 290, 303, 319
   client_manager.py371073%40, 61–63, 68–70, 75–78
   composer.py3207178%335–336, 339–340, 343–344, 347–348, 355–356, 359–360, 389, 391, 393, 396, 399, 404, 407, 411, 414, 456–489, 494–505, 510–516, 521–529, 549–562, 566, 590, 593–610, 618, 643, 659–660, 662–663, 665–666, 668–669, 671–672, 678–682
TOTAL253629388% 

Tests Skipped Failures Errors Time
203 0 💤 0 ❌ 0 🔥 2m 25s ⏱️

Please sign in to comment.