diff --git a/.circleci/config.yml b/.circleci/config.yml index 0a42817e..76a9f1f1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,11 +7,11 @@ workflows: - unit-test: matrix: parameters: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11"] - integration-test: matrix: parameters: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11"] - docset jobs: @@ -24,9 +24,8 @@ jobs: steps: - checkout - run: pip install -r requirements.txt - - run: black --check . - - run: mypy algosdk - - run: pytest tests/unit_tests + - run: make lint + - run: make pytest-unit integration-test: parameters: python-version: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d1ae2e4..5d71d2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +# v2.1.0 + +## What's Changed +### Bugfixes +* bugfix: fix msig sks type + a couple other mypy complaints by @barnjamin in https://github.com/algorand/py-algorand-sdk/pull/434 +* fix: remove unused positional argument "contract_type" from OverspecifiedRoundError and UnderspecifiedRoundError by @ori-shem-tov in https://github.com/algorand/py-algorand-sdk/pull/438 +* Fix: Revert .test-env in develop by @bbroder-algo in https://github.com/algorand/py-algorand-sdk/pull/445 +### New Features +* New Feature: Adding methods to use the simulate endpoint by @barnjamin in https://github.com/algorand/py-algorand-sdk/pull/420 +### Enhancements +* Infrastructure: Add setup.py check to circle ci by @algochoi in https://github.com/algorand/py-algorand-sdk/pull/427 +* Enhancement: Type Friendly Exports by @tzaffi in https://github.com/algorand/py-algorand-sdk/pull/435 +* Algod: Add disassembly endpoint and implement cucumber test by @algochoi in https://github.com/algorand/py-algorand-sdk/pull/440 +* Enhancement: Upgrade black, mypy, and add type annotations to algod.py by @tzaffi in https://github.com/algorand/py-algorand-sdk/pull/442 + +## New Contributors +* @ori-shem-tov made their first contribution in https://github.com/algorand/py-algorand-sdk/pull/438 +* @bbroder-algo made their first contribution in https://github.com/algorand/py-algorand-sdk/pull/445 + +**Full Changelog**: https://github.com/algorand/py-algorand-sdk/compare/v2.0.0...v2.0.1 + # v2.0.0 ## What's Changed diff --git a/Makefile b/Makefile index b2be49e1..490588e4 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,27 @@ UNIT_TAGS := "$(subst :, or ,$(shell awk '{print $2}' tests/unit.tags | paste -s -d: -))" INTEGRATION_TAGS := "$(subst :, or ,$(shell awk '{print $2}' tests/integration.tags | paste -s -d: -))" +generate-init: + python -m scripts.generate_init + +check-generate-init: + python -m scripts.generate_init --check + +black: + black --check . + +mypy: + mypy algosdk + +sdist-check: + python setup.py check -s + python setup.py check -s 2>&1 | (! grep -qEi 'error|warning') + +lint: check-generate-init black mypy sdist-check + +pytest-unit: + pytest tests/unit_tests + unit: behave --tags=$(UNIT_TAGS) tests -f progress2 diff --git a/README.md b/README.md index 3fe5ab75..6231e464 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,25 @@ Set up the Algorand Sandbox based test-harness without running the tests * `make harness` -Format code: +Format code * `black .` -Lint types: +Update `algosdk/__init__.pyi` which allows downstream developers importing `algosdk` and using VSCode's PyLance to have improved type analysis -* `mypy algosdk` +* `make generate-init` + +Lint types + +* `make mypy` (or `mypy algosdk`) + +Check all lints required by the C.I. process + +* `make lint` + +Run non-test-harness related unit tests + +* `make pytest-unit` ## Quick start diff --git a/algosdk/__init__.py b/algosdk/__init__.py index 88cb78b7..1f78d6b9 100644 --- a/algosdk/__init__.py +++ b/algosdk/__init__.py @@ -1,18 +1,48 @@ -from . import abi -from . import account -from . import auction -from . import constants -from . import dryrun_results -from . import encoding -from . import error -from . import kmd -from . import logic -from . import mnemonic -from . import transaction -from . import util -from . import v2client -from . import wallet -from . import wordlist -from . import source_map +from . import ( + abi, + account, + auction, + constants, + dryrun_results, + encoding, + error, + kmd, + logic, + mnemonic, + source_map, + transaction, + util, + v2client, + wallet, + wordlist, +) + +from .abi import __all__ as abi_all +from .v2client import __all__ as v2client_all + +# begin __all__ +__all__ = ( + abi_all + + v2client_all + + [ + "abi", + "account", + "auction", + "constants", + "dryrun_results", + "encoding", + "error", + "kmd", + "logic", + "mnemonic", + "source_map", + "transaction", + "util", + "v2client", + "wallet", + "wordlist", + ] +) # type: ignore +# end __all__ name = "algosdk" diff --git a/algosdk/__init__.pyi b/algosdk/__init__.pyi new file mode 100644 index 00000000..fdf523e4 --- /dev/null +++ b/algosdk/__init__.pyi @@ -0,0 +1,68 @@ +## File generated from scripts/generate_init.py. +## DO NOT EDIT DIRECTLY + +from . import ( + abi, + account, + auction, + constants, + dryrun_results, + encoding, + error, + kmd, + logic, + mnemonic, + source_map, + transaction, + util, + v2client, + wallet, + wordlist, +) + +from .abi import __all__ as abi_all +from .v2client import __all__ as v2client_all + +__all__ = [ + "ABIReferenceType", + "ABITransactionType", + "ABIType", + "AddressType", + "Argument", + "ArrayDynamicType", + "ArrayStaticType", + "BoolType", + "ByteType", + "Contract", + "Interface", + "Method", + "NetworkInfo", + "Returns", + "StringType", + "TupleType", + "UfixedType", + "UintType", + "abi", + "account", + "algod", + "auction", + "check_abi_transaction_type", + "constants", + "dryrun_results", + "encoding", + "error", + "indexer", + "is_abi_reference_type", + "is_abi_transaction_type", + "kmd", + "logic", + "mnemonic", + "source_map", + "transaction", + "util", + "v2client", + "wallet", + "wordlist", +] + +name = "algosdk" diff --git a/algosdk/abi/__init__.py b/algosdk/abi/__init__.py index ee7b1f6a..1ebcdbd4 100644 --- a/algosdk/abi/__init__.py +++ b/algosdk/abi/__init__.py @@ -1,21 +1,45 @@ -from algosdk.abi.uint_type import UintType -from algosdk.abi.ufixed_type import UfixedType -from algosdk.abi.base_type import ABIType -from algosdk.abi.bool_type import BoolType -from algosdk.abi.byte_type import ByteType from algosdk.abi.address_type import AddressType -from algosdk.abi.string_type import StringType from algosdk.abi.array_dynamic_type import ArrayDynamicType from algosdk.abi.array_static_type import ArrayStaticType -from algosdk.abi.tuple_type import TupleType -from algosdk.abi.method import Method, Argument, Returns -from algosdk.abi.interface import Interface +from algosdk.abi.base_type import ABIType +from algosdk.abi.bool_type import BoolType +from algosdk.abi.byte_type import ByteType from algosdk.abi.contract import Contract, NetworkInfo +from algosdk.abi.interface import Interface +from algosdk.abi.method import Argument, Method, Returns +from algosdk.abi.reference import ABIReferenceType, is_abi_reference_type +from algosdk.abi.string_type import StringType from algosdk.abi.transaction import ( ABITransactionType, - is_abi_transaction_type, check_abi_transaction_type, + is_abi_transaction_type, ) -from algosdk.abi.reference import ABIReferenceType, is_abi_reference_type +from algosdk.abi.tuple_type import TupleType +from algosdk.abi.ufixed_type import UfixedType +from algosdk.abi.uint_type import UintType + +__all__ = [ + "ABIReferenceType", + "ABITransactionType", + "ABIType", + "AddressType", + "Argument", + "ArrayDynamicType", + "ArrayStaticType", + "BoolType", + "ByteType", + "check_abi_transaction_type", + "Contract", + "Interface", + "Method", + "NetworkInfo", + "Returns", + "StringType", + "TupleType", + "UfixedType", + "UintType", + "is_abi_reference_type", + "is_abi_transaction_type", +] name = "abi" diff --git a/algosdk/abi/interface.py b/algosdk/abi/interface.py index aaf07655..9b595f15 100644 --- a/algosdk/abi/interface.py +++ b/algosdk/abi/interface.py @@ -3,6 +3,7 @@ from algosdk.abi.method import Method, MethodDict, get_method_by_name + # In Python 3.11+ the following classes should be combined using `NotRequired` class InterfaceDict_Optional(TypedDict, total=False): desc: str diff --git a/algosdk/abi/method.py b/algosdk/abi/method.py index 3bd21842..cb7a6810 100644 --- a/algosdk/abi/method.py +++ b/algosdk/abi/method.py @@ -5,6 +5,7 @@ from algosdk import abi, constants, error + # In Python 3.11+ the following classes should be combined using `NotRequired` class MethodDict_Optional(TypedDict, total=False): desc: str diff --git a/algosdk/atomic_transaction_composer.py b/algosdk/atomic_transaction_composer.py index e6263c0a..1e13cccb 100644 --- a/algosdk/atomic_transaction_composer.py +++ b/algosdk/atomic_transaction_composer.py @@ -1,11 +1,11 @@ +from abc import ABC, abstractmethod import base64 import copy -from abc import ABC, abstractmethod from enum import IntEnum from typing import ( Any, - List, Dict, + List, Optional, Tuple, TypeVar, @@ -14,6 +14,7 @@ ) from algosdk import abi, error, transaction +from algosdk.transaction import GenericSignedTransaction from algosdk.abi.address_type import AddressType from algosdk.v2client import algod @@ -23,27 +24,6 @@ T = TypeVar("T") -class AtomicTransactionComposerStatus(IntEnum): - # BUILDING indicates that the atomic group is still under construction - BUILDING = 0 - - # BUILT indicates that the atomic group has been finalized, - # but not yet signed. - BUILT = 1 - - # SIGNED indicates that the atomic group has been finalized and signed, - # but not yet submitted to the network. - SIGNED = 2 - - # SUBMITTED indicates that the atomic group has been finalized, signed, - # and submitted to the network. - SUBMITTED = 3 - - # COMMITTED indicates that the atomic group has been finalized, signed, - # submitted, and successfully committed to a block. - COMMITTED = 4 - - def populate_foreign_array( value_to_add: T, foreign_array: List[T], zero_value: Optional[T] = None ) -> int: @@ -76,11 +56,234 @@ def populate_foreign_array( return offset + len(foreign_array) - 1 -GenericSignedTransaction = Union[ - transaction.SignedTransaction, - transaction.LogicSigTransaction, - transaction.MultisigTransaction, -] +class AtomicTransactionComposerStatus(IntEnum): + # BUILDING indicates that the atomic group is still under construction + BUILDING = 0 + + # BUILT indicates that the atomic group has been finalized, + # but not yet signed. + BUILT = 1 + + # SIGNED indicates that the atomic group has been finalized and signed, + # but not yet submitted to the network. + SIGNED = 2 + + # SUBMITTED indicates that the atomic group has been finalized, signed, + # and submitted to the network. + SUBMITTED = 3 + + # COMMITTED indicates that the atomic group has been finalized, signed, + # submitted, and successfully committed to a block. + COMMITTED = 4 + + +class TransactionSigner(ABC): + """ + Represents an object which can sign transactions from an atomic transaction group. + """ + + def __init__(self) -> None: + pass + + @abstractmethod + def sign_transactions( + self, txn_group: List[transaction.Transaction], indexes: List[int] + ) -> List[GenericSignedTransaction]: + pass + + +class AccountTransactionSigner(TransactionSigner): + """ + Represents a Transaction Signer for an account that can sign transactions from an + atomic transaction group. + + Args: + private_key (str): private key of signing account + """ + + def __init__(self, private_key: str) -> None: + super().__init__() + self.private_key = private_key + + def sign_transactions( + self, txn_group: List[transaction.Transaction], indexes: List[int] + ) -> List[GenericSignedTransaction]: + """ + Sign transactions in a transaction group given the indexes. + + Returns an array of encoded signed transactions. The length of the + array will be the same as the length of indexesToSign, and each index i in the array + corresponds to the signed transaction from txnGroup[indexesToSign[i]]. + + Args: + txn_group (list[Transaction]): atomic group of transactions + indexes (list[int]): array of indexes in the atomic transaction group that should be signed + """ + stxns = [] + for i in indexes: + stxn = txn_group[i].sign(self.private_key) + stxns.append(stxn) + return stxns + + +class LogicSigTransactionSigner(TransactionSigner): + """ + Represents a Transaction Signer for a LogicSig that can sign transactions from an + atomic transaction group. + + Args: + lsig (LogicSigAccount): LogicSig account + """ + + def __init__(self, lsig: transaction.LogicSigAccount) -> None: + super().__init__() + self.lsig = lsig + + def sign_transactions( + self, txn_group: List[transaction.Transaction], indexes: List[int] + ) -> List[GenericSignedTransaction]: + """ + Sign transactions in a transaction group given the indexes. + + Returns an array of encoded signed transactions. The length of the + array will be the same as the length of indexesToSign, and each index i in the array + corresponds to the signed transaction from txnGroup[indexesToSign[i]]. + + Args: + txn_group (list[Transaction]): atomic group of transactions + indexes (list[int]): array of indexes in the atomic transaction group that should be signed + """ + stxns: List[GenericSignedTransaction] = [] + for i in indexes: + stxn = transaction.LogicSigTransaction(txn_group[i], self.lsig) + stxns.append(stxn) + return stxns + + +class MultisigTransactionSigner(TransactionSigner): + """ + Represents a Transaction Signer for a Multisig that can sign transactions from an + atomic transaction group. + + Args: + msig (Multisig): Multisig account + sks (str): private keys of multisig + """ + + def __init__(self, msig: transaction.Multisig, sks: List[str]) -> None: + super().__init__() + self.msig = msig + self.sks = sks + + def sign_transactions( + self, txn_group: List[transaction.Transaction], indexes: List[int] + ) -> List[GenericSignedTransaction]: + """ + Sign transactions in a transaction group given the indexes. + + Returns an array of encoded signed transactions. The length of the + array will be the same as the length of indexesToSign, and each index i in the array + corresponds to the signed transaction from txnGroup[indexesToSign[i]]. + + Args: + txn_group (list[Transaction]): atomic group of transactions + indexes (list[int]): array of indexes in the atomic transaction group that should be signed + """ + stxns: List[GenericSignedTransaction] = [] + for i in indexes: + mtxn = transaction.MultisigTransaction(txn_group[i], self.msig) + for sk in self.sks: + mtxn.sign(sk) + stxns.append(mtxn) + return stxns + + +class EmptySigner(TransactionSigner): + def __init__(self) -> None: + super().__init__() + + def sign_transactions( + self, txn_group: List[transaction.Transaction], indexes: List[int] + ) -> List[GenericSignedTransaction]: + stxns: List[GenericSignedTransaction] = [] + for i in indexes: + stxns.append(transaction.SignedTransaction(txn_group[i], "")) + return stxns + + +class TransactionWithSigner: + def __init__( + self, txn: transaction.Transaction, signer: TransactionSigner + ) -> None: + self.txn = txn + self.signer = signer + + +class ABIResult: + def __init__( + self, + tx_id: str, + raw_value: bytes, + return_value: Any, + decode_error: Optional[Exception], + tx_info: dict, + method: abi.Method, + ) -> None: + self.tx_id = tx_id + self.raw_value = raw_value + self.return_value = return_value + self.decode_error = decode_error + self.tx_info = tx_info + self.method = method + + +class AtomicTransactionResponse: + def __init__( + self, confirmed_round: int, tx_ids: List[str], results: List[ABIResult] + ) -> None: + self.confirmed_round = confirmed_round + self.tx_ids = tx_ids + self.abi_results = results + + +class SimulateABIResult(ABIResult): + def __init__( + self, + tx_id: str, + raw_value: bytes, + return_value: Any, + decode_error: Optional[Exception], + tx_info: dict, + method: abi.Method, + missing_signature: bool, + ) -> None: + self.tx_id = tx_id + self.raw_value = raw_value + self.return_value = return_value + self.decode_error = decode_error + self.tx_info = tx_info + self.method = method + self.missing_signature = missing_signature + + +class SimulateAtomicTransactionResponse: + def __init__( + self, + version: int, + would_succeed: bool, + failure_message: str, + failed_at: Optional[List[int]], + simulate_response: Dict[str, Any], + tx_ids: List[str], + results: List[SimulateABIResult], + ) -> None: + self.version = version + self.would_succeed = would_succeed + self.failure_message = failure_message + self.failed_at = failed_at + self.simulate_response = simulate_response + self.tx_ids = tx_ids + self.abi_results = results class AtomicTransactionComposer: @@ -102,7 +305,9 @@ class AtomicTransactionComposer: MAX_APP_ARG_LIMIT = 16 def __init__(self) -> None: - self.status = AtomicTransactionComposerStatus.BUILDING + self.status: AtomicTransactionComposerStatus = ( + AtomicTransactionComposerStatus.BUILDING + ) self.method_dict: Dict[int, abi.Method] = {} self.txn_list: List[TransactionWithSigner] = [] self.signed_txns: List[GenericSignedTransaction] = [] @@ -135,7 +340,7 @@ def clone(self) -> "AtomicTransactionComposer": return cloned def add_transaction( - self, txn_and_signer: "TransactionWithSigner" + self, txn_and_signer: TransactionWithSigner ) -> "AtomicTransactionComposer": """ Adds a transaction to this atomic group. @@ -174,16 +379,14 @@ def add_method_call( method: abi.Method, sender: str, sp: transaction.SuggestedParams, - signer: "TransactionSigner", - method_args: Optional[ - List[Union[Any, "TransactionWithSigner"]] - ] = None, + signer: TransactionSigner, + method_args: Optional[List[Union[Any, TransactionWithSigner]]] = None, on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, local_schema: Optional[transaction.StateSchema] = None, global_schema: Optional[transaction.StateSchema] = None, approval_program: Optional[bytes] = None, clear_program: Optional[bytes] = None, - extra_pages: Optional[int] = None, + extra_pages: int = 0, accounts: Optional[List[str]] = None, foreign_apps: Optional[List[int]] = None, foreign_assets: Optional[List[int]] = None, @@ -381,7 +584,7 @@ def add_method_call( self.method_dict[len(self.txn_list) - 1] = method return self - def build_group(self) -> list: + def build_group(self) -> List[TransactionWithSigner]: """ Finalize the transaction group and returns the finalized transactions with signers. The composer's status will be at least BUILT after executing this method. @@ -409,7 +612,7 @@ def build_group(self) -> list: self.status = AtomicTransactionComposerStatus.BUILT return self.txn_list - def gather_signatures(self) -> list: + def gather_signatures(self) -> List[GenericSignedTransaction]: """ Obtain signatures for each transaction in this group. If signatures have already been obtained, this method will return cached versions of the signatures. @@ -479,9 +682,74 @@ def submit(self, client: algod.AlgodClient) -> List[str]: self.status = AtomicTransactionComposerStatus.SUBMITTED return self.tx_ids + def simulate( + self, client: algod.AlgodClient + ) -> SimulateAtomicTransactionResponse: + """ + Send the transaction group to the `simulate` endpoint and wait for results. + An error will be thrown if submission or execution fails. + The composer's status must be SUBMITTED or lower before calling this method, + since execution is only allowed once. + + Args: + client (AlgodClient): Algod V2 client + + Returns: + SimulateAtomicTransactionResponse: Object with simulation results for this + transaction group, a list of txIDs of the simulated transactions, + an array of results for each method call transaction in this group. + If a method has no return value (void), then the method results array + will contain None for that method's return value. + """ + + if self.status <= AtomicTransactionComposerStatus.SUBMITTED: + self.gather_signatures() + else: + raise error.AtomicTransactionComposerError( + "AtomicTransactionComposerStatus must be submitted or " + "lower to simulate a group" + ) + + simulation_result = cast( + Dict[str, Any], client.simulate_transactions(self.signed_txns) + ) + # Only take the first group in the simulate response + txn_group: Dict[str, Any] = simulation_result["txn-groups"][0] + + # Parse out abi results + txn_results = [t["txn-result"] for t in txn_group["txn-results"]] + method_results = self.parse_response(txn_results) + + # build up data structure with fields we'd want + sim_results = [] + for idx, result in enumerate(method_results): + sim_txn: Dict[str, Any] = txn_group["txn-results"][idx] + + sim_results.append( + SimulateABIResult( + tx_id=result.tx_id, + raw_value=result.raw_value, + return_value=result.return_value, + decode_error=result.decode_error, + tx_info=result.tx_info, + method=result.method, + missing_signature=sim_txn.get("missing-signature", False), + ) + ) + + return SimulateAtomicTransactionResponse( + version=simulation_result.get("version", 0), + would_succeed=simulation_result.get("would-succeed", False), + failure_message=txn_group.get("failure-message", ""), + failed_at=txn_group.get("failed-at"), + simulate_response=simulation_result, + tx_ids=self.tx_ids, + results=sim_results, + ) + def execute( self, client: algod.AlgodClient, wait_rounds: int - ) -> "AtomicTransactionResponse": + ) -> AtomicTransactionResponse: """ Send the transaction group to the network and wait until it's committed to a block. An error will be thrown if submission or execution fails. @@ -517,20 +785,33 @@ def execute( self.status = AtomicTransactionComposerStatus.COMMITTED confirmed_round = resp["confirmed-round"] - method_results = [] - for i, tx_id in enumerate(self.tx_ids): + tx_results = [ + cast(Dict[str, Any], client.pending_transaction_info(tx_id)) + for tx_id in self.tx_ids + ] + + method_results = self.parse_response(tx_results) + + return AtomicTransactionResponse( + confirmed_round=confirmed_round, + tx_ids=self.tx_ids, + results=method_results, + ) + + def parse_response(self, txns: List[Dict[str, Any]]) -> List[ABIResult]: + method_results = [] + for i, tx_info in enumerate(txns): + tx_id = self.tx_ids[i] raw_value: Optional[bytes] = None return_value = None decode_error = None - tx_info: Optional[Any] = None if i not in self.method_dict: continue # Parse log for ABI method return value try: - tx_info = client.pending_transaction_info(tx_id) if self.method_dict[i].returns.type == abi.Returns.VOID: method_results.append( ABIResult( @@ -569,174 +850,15 @@ def execute( except Exception as e: decode_error = e - abi_result = ABIResult( - tx_id=tx_id, - raw_value=cast(bytes, raw_value), - return_value=return_value, - decode_error=decode_error, - tx_info=cast(Any, tx_info), - method=self.method_dict[i], + method_results.append( + ABIResult( + tx_id=tx_id, + raw_value=cast(bytes, raw_value), + return_value=return_value, + decode_error=decode_error, + tx_info=tx_info, + method=self.method_dict[i], + ) ) - method_results.append(abi_result) - - return AtomicTransactionResponse( - confirmed_round=confirmed_round, - tx_ids=self.tx_ids, - results=method_results, - ) - - -class TransactionSigner(ABC): - """ - Represents an object which can sign transactions from an atomic transaction group. - """ - - def __init__(self) -> None: - pass - - @abstractmethod - def sign_transactions( - self, txn_group: List[transaction.Transaction], indexes: List[int] - ) -> List[GenericSignedTransaction]: - pass - - -class AccountTransactionSigner(TransactionSigner): - """ - Represents a Transaction Signer for an account that can sign transactions from an - atomic transaction group. - - Args: - private_key (str): private key of signing account - """ - - def __init__(self, private_key: str) -> None: - super().__init__() - self.private_key = private_key - - def sign_transactions( - self, txn_group: List[transaction.Transaction], indexes: List[int] - ) -> List[GenericSignedTransaction]: - """ - Sign transactions in a transaction group given the indexes. - - Returns an array of encoded signed transactions. The length of the - array will be the same as the length of indexesToSign, and each index i in the array - corresponds to the signed transaction from txnGroup[indexesToSign[i]]. - - Args: - txn_group (list[Transaction]): atomic group of transactions - indexes (list[int]): array of indexes in the atomic transaction group that should be signed - """ - stxns = [] - for i in indexes: - stxn = txn_group[i].sign(self.private_key) - stxns.append(stxn) - return stxns - - -class LogicSigTransactionSigner(TransactionSigner): - """ - Represents a Transaction Signer for a LogicSig that can sign transactions from an - atomic transaction group. - - Args: - lsig (LogicSigAccount): LogicSig account - """ - - def __init__(self, lsig: transaction.LogicSigAccount) -> None: - super().__init__() - self.lsig = lsig - - def sign_transactions( - self, txn_group: List[transaction.Transaction], indexes: List[int] - ) -> List[GenericSignedTransaction]: - """ - Sign transactions in a transaction group given the indexes. - - Returns an array of encoded signed transactions. The length of the - array will be the same as the length of indexesToSign, and each index i in the array - corresponds to the signed transaction from txnGroup[indexesToSign[i]]. - - Args: - txn_group (list[Transaction]): atomic group of transactions - indexes (list[int]): array of indexes in the atomic transaction group that should be signed - """ - stxns: List[GenericSignedTransaction] = [] - for i in indexes: - stxn = transaction.LogicSigTransaction(txn_group[i], self.lsig) - stxns.append(stxn) - return stxns - -class MultisigTransactionSigner(TransactionSigner): - """ - Represents a Transaction Signer for a Multisig that can sign transactions from an - atomic transaction group. - - Args: - msig (Multisig): Multisig account - sks (str): private keys of multisig - """ - - def __init__(self, msig: transaction.Multisig, sks: str) -> None: - super().__init__() - self.msig = msig - self.sks = sks - - def sign_transactions( - self, txn_group: List[transaction.Transaction], indexes: List[int] - ) -> List[GenericSignedTransaction]: - """ - Sign transactions in a transaction group given the indexes. - - Returns an array of encoded signed transactions. The length of the - array will be the same as the length of indexesToSign, and each index i in the array - corresponds to the signed transaction from txnGroup[indexesToSign[i]]. - - Args: - txn_group (list[Transaction]): atomic group of transactions - indexes (list[int]): array of indexes in the atomic transaction group that should be signed - """ - stxns: List[GenericSignedTransaction] = [] - for i in indexes: - mtxn = transaction.MultisigTransaction(txn_group[i], self.msig) - for sk in self.sks: - mtxn.sign(sk) - stxns.append(mtxn) - return stxns - - -class TransactionWithSigner: - def __init__( - self, txn: transaction.Transaction, signer: TransactionSigner - ) -> None: - self.txn = txn - self.signer = signer - - -class ABIResult: - def __init__( - self, - tx_id: str, - raw_value: bytes, - return_value: Any, - decode_error: Optional[Exception], - tx_info: dict, - method: abi.Method, - ) -> None: - self.tx_id = tx_id - self.raw_value = raw_value - self.return_value = return_value - self.decode_error = decode_error - self.tx_info = tx_info - self.method = method - - -class AtomicTransactionResponse: - def __init__( - self, confirmed_round: int, tx_ids: List[str], results: List[ABIResult] - ) -> None: - self.confirmed_round = confirmed_round - self.tx_ids = tx_ids - self.abi_results = results + return method_results diff --git a/algosdk/dryrun_results.py b/algosdk/dryrun_results.py index b8594fbb..383441e7 100644 --- a/algosdk/dryrun_results.py +++ b/algosdk/dryrun_results.py @@ -1,5 +1,5 @@ import base64 -from typing import List, Optional, cast +from typing import List, Optional, cast, Dict, Any class StackPrinterConfig: @@ -26,7 +26,7 @@ def __init__(self, drrjson: dict): class DryrunTransactionResult: - def __init__(self, dr): + def __init__(self, dr: Dict[str, Any]): assert ( "disassembly" in dr ), "expecting dryrun transaction result to have key 'disassembly' but its missing" @@ -34,7 +34,7 @@ def __init__(self, dr): self.disassembly = dr["disassembly"] # cost is separated into 2 fields: `budget-added` and `budget-consumed` - optionals = [ + optionals: List[str] = [ "app-call-messages", "local-deltas", "global-delta", @@ -45,7 +45,7 @@ def __init__(self, dr): "logs", ] - def attrname(field): + def attrname(field: str): return field.replace("-", "_") for field in optionals: @@ -79,11 +79,9 @@ def trace( disassembly: List[str], spc: StackPrinterConfig, ) -> str: - # 16 for length of the header up to spaces lines = [["pc#", "ln#", "source", "scratch", "stack"]] for idx in range(len(dr_trace.trace)): - trace_line = dr_trace.trace[idx] src = disassembly[trace_line.line] @@ -131,7 +129,7 @@ def app_trace(self, spc: Optional[StackPrinterConfig] = None) -> str: spc = StackPrinterConfig(top_of_stack_first=False) spc = cast(StackPrinterConfig, spc) - return self.trace(self.app_call_trace, self.disassembly, spc=spc) + return self.trace(self.app_call_trace, self.disassembly, spc=spc) # type: ignore[attr-defined] # dynamic attribute def lsig_trace(self, spc: Optional[StackPrinterConfig] = None) -> str: if not hasattr(self, "logic_sig_trace"): @@ -181,6 +179,7 @@ def __str__(self) -> str: return str(self.int) def __eq__(self, other: object): + other = cast(DryrunStackValue, other) return ( hasattr(other, "type") and self.type == other.type diff --git a/algosdk/error.py b/algosdk/error.py index 284c6524..b0931977 100644 --- a/algosdk/error.py +++ b/algosdk/error.py @@ -141,7 +141,7 @@ def __init__(self): class OverspecifiedRoundError(Exception): - def __init__(self, contract_type): + def __init__(self): Exception.__init__( self, "Two arguments were given for the round " @@ -150,7 +150,7 @@ def __init__(self, contract_type): class UnderspecifiedRoundError(Exception): - def __init__(self, contract_type): + def __init__(self): Exception.__init__(self, "Please specify a round number") @@ -172,6 +172,11 @@ class KMDHTTPError(Exception): pass +class AlgodRequestError(Exception): + def __init__(self, msg): + super().__init__(msg) + + class AlgodHTTPError(Exception): def __init__(self, msg, code=None): super().__init__(msg) diff --git a/algosdk/source_map.py b/algosdk/source_map.py index d72a2129..a5bac058 100644 --- a/algosdk/source_map.py +++ b/algosdk/source_map.py @@ -13,7 +13,6 @@ class SourceMap: """ def __init__(self, source_map: Dict[str, Any]): - self.version: int = source_map["version"] if self.version != 3: diff --git a/algosdk/testing/dryrun.py b/algosdk/testing/dryrun.py index bebc406a..db6c29d2 100644 --- a/algosdk/testing/dryrun.py +++ b/algosdk/testing/dryrun.py @@ -294,7 +294,6 @@ def assertGlobalStateContains( and txn_res["global-delta"] is not None and len(txn_res["global-delta"]) > 0 ): - found = Helper.find_delta_value( txn_res["global-delta"], delta_value ) @@ -363,7 +362,6 @@ def assertLocalStateContains( and txn_res["local-deltas"] is not None and len(txn_res["local-deltas"]) > 0 ): - for local_delta in txn_res["local-deltas"]: addr_found = False if local_delta["address"] == addr: diff --git a/algosdk/transaction.py b/algosdk/transaction.py index e25af8da..dbb350c9 100644 --- a/algosdk/transaction.py +++ b/algosdk/transaction.py @@ -3183,7 +3183,9 @@ def wait_for_confirmation( wait_rounds (int, optional): The number of rounds to block for before exiting with an Exception. If not supplied, this will be 1000. """ - last_round = algod_client.status()["last-round"] + algod.AlgodClient._assert_json_response(kwargs, "wait_for_confirmation") + + last_round = cast(int, cast(dict, algod_client.status())["last-round"]) current_round = last_round + 1 if wait_rounds == 0: @@ -3197,7 +3199,9 @@ def wait_for_confirmation( ) try: - tx_info = algod_client.pending_transaction_info(txid, **kwargs) + tx_info = cast( + dict, algod_client.pending_transaction_info(txid, **kwargs) + ) # The transaction has been rejected if "pool-error" in tx_info and len(tx_info["pool-error"]) != 0: @@ -3246,7 +3250,8 @@ def create_dryrun( """ # The list of info objects passed to the DryrunRequest object - app_infos, acct_infos = [], [] + app_infos: List[Union[dict, models.Application]] = [] + acct_infos = [] # The running list of things we need to fetch apps, assets, accts = [], [], [] @@ -3309,7 +3314,7 @@ def create_dryrun( # Dedupe and filter none, reset programs to bytecode instead of b64 apps = [i for i in set(apps) if i] for app in apps: - app_info = client.application_info(app) + app_info = cast(dict, client.application_info(app)) # Need to pass bytes, not b64 string app_info = decode_programs(app_info) app_infos.append(app_info) @@ -3323,7 +3328,7 @@ def create_dryrun( # Dedupe and filter None, add asset creator to accounts to include in dryrun assets = [i for i in set(assets) if i] for asset in assets: - asset_info = client.asset_info(asset) + asset_info = cast(dict, client.asset_info(asset)) # Make sure the asset creator address is in the accounts array accts.append(asset_info["params"]["creator"]) @@ -3331,7 +3336,7 @@ def create_dryrun( # Dedupe and filter None, fetch and add account info accts = [i for i in set(accts) if i] for acct in accts: - acct_info = client.account_info(acct) + acct_info = cast(dict, client.account_info(acct)) if "created-apps" in acct_info: acct_info["created-apps"] = [ decode_programs(ca) for ca in acct_info["created-apps"] @@ -3356,3 +3361,10 @@ def decode_programs(app): app["params"]["clear-state-program"] ) return app + + +GenericSignedTransaction = Union[ + SignedTransaction, + LogicSigTransaction, + MultisigTransaction, +] diff --git a/algosdk/v2client/__init__.py b/algosdk/v2client/__init__.py index af619e8c..67ae0640 100644 --- a/algosdk/v2client/__init__.py +++ b/algosdk/v2client/__init__.py @@ -1,4 +1,6 @@ from . import algod from . import indexer +__all__ = ["algod", "indexer"] + name = "v2client" diff --git a/algosdk/v2client/algod.py b/algosdk/v2client/algod.py index 1614f847..c0ce20ea 100644 --- a/algosdk/v2client/algod.py +++ b/algosdk/v2client/algod.py @@ -1,11 +1,29 @@ import base64 import json +from typing import ( + Any, + Dict, + Final, + Iterable, + List, + Mapping, + Optional, + Sequence, + Tuple, + Union, + cast, +) import urllib.error from urllib import parse from urllib.request import Request, urlopen from algosdk import constants, encoding, error, transaction, util +AlgodResponseType = Union[Dict[str, Any], bytes] + +# for compatibility with urllib.parse.urlencode +ParamsType = Union[Mapping[str, Any], Sequence[Tuple[str, Any]]] + api_version_path_prefix = "/v2" @@ -24,32 +42,39 @@ class AlgodClient: headers (dict) """ - def __init__(self, algod_token, algod_address, headers=None): - self.algod_token = algod_token - self.algod_address = algod_address - self.headers = headers + def __init__( + self, + algod_token: str, + algod_address: str, + headers: Optional[Dict[str, str]] = None, + ): + self.algod_token: Final[str] = algod_token + self.algod_address: Final[str] = algod_address + self.headers: Final[Optional[Dict[str, str]]] = headers def algod_request( self, - method, - requrl, - params=None, - data=None, - headers=None, - response_format="json", - ): + method: str, + requrl: str, + params: Optional[ParamsType] = None, + data: Optional[bytes] = None, + headers: Optional[Dict[str, str]] = None, + response_format: Optional[str] = "json", + ) -> AlgodResponseType: """ Execute a given request. Args: method (str): request method requrl (str): url for the request - params (dict, optional): parameters for the request - data (dict, optional): data in the body of the request + params (ParamsType, optional): parameters for the request + data (bytes, optional): data in the body of the request headers (dict, optional): additional header for request + response_format (str, optional): format of the response Returns: - dict: loaded from json response body + dict loaded from json response body when response_format == "json" + otherwise returns the response body as bytes """ header = {"User-Agent": "py-algorand-sdk"} @@ -78,9 +103,9 @@ def algod_request( resp = urlopen(req) except urllib.error.HTTPError as e: code = e.code - e = e.read().decode("utf-8") + es = e.read().decode("utf-8") try: - e = json.loads(e)["message"] + e = json.loads(es)["message"] finally: raise error.AlgodHTTPError(e, code) if response_format == "json": @@ -93,7 +118,18 @@ def algod_request( else: return resp.read() - def account_info(self, address, exclude=None, **kwargs): + @classmethod + def _assert_json_response( + cls, params: Mapping[str, Any], endpoint: str = "" + ) -> None: + if params.get("response_format", "json") != "json": + raise error.AlgodRequestError( + f"Only json response is supported{ (' for ' + endpoint) if endpoint else ''}." + ) + + def account_info( + self, address: str, exclude: Optional[bool] = None, **kwargs: Any + ) -> AlgodResponseType: """ Return account information. @@ -106,7 +142,7 @@ def account_info(self, address, exclude=None, **kwargs): req = "/accounts/" + address return self.algod_request("GET", req, query, **kwargs) - def asset_info(self, asset_id, **kwargs): + def asset_info(self, asset_id: int, **kwargs: Any) -> AlgodResponseType: """ Return information about a specific asset. @@ -116,7 +152,9 @@ def asset_info(self, asset_id, **kwargs): req = "/assets/" + str(asset_id) return self.algod_request("GET", req, **kwargs) - def application_info(self, application_id, **kwargs): + def application_info( + self, application_id: int, **kwargs: Any + ) -> AlgodResponseType: """ Return information about a specific application. @@ -127,8 +165,8 @@ def application_info(self, application_id, **kwargs): return self.algod_request("GET", req, **kwargs) def application_box_by_name( - self, application_id: int, box_name: bytes, **kwargs - ): + self, application_id: int, box_name: bytes, **kwargs: Any + ) -> AlgodResponseType: """ Return the value of an application's box. @@ -144,7 +182,9 @@ def application_box_by_name( params = {"name": box_name_encoded} return self.algod_request("GET", req, params=params, **kwargs) - def application_boxes(self, application_id: int, limit: int = 0, **kwargs): + def application_boxes( + self, application_id: int, limit: int = 0, **kwargs: Any + ) -> AlgodResponseType: """ Given an application ID, return all Box names. No particular ordering is guaranteed. Request fails when client or server-side configured limits prevent returning all Box names. @@ -159,7 +199,9 @@ def application_boxes(self, application_id: int, limit: int = 0, **kwargs): params = {"max": limit} if limit else {} return self.algod_request("GET", req, params=params, **kwargs) - def account_asset_info(self, address, asset_id, **kwargs): + def account_asset_info( + self, address: str, asset_id: int, **kwargs: Any + ) -> AlgodResponseType: """ Return asset information for a specific account. @@ -167,11 +209,13 @@ def account_asset_info(self, address, asset_id, **kwargs): address (str): account public key asset_id (int): The ID of the asset to look up. """ - query = {} + query: Mapping = {} req = "/accounts/" + address + "/assets/" + str(asset_id) return self.algod_request("GET", req, query, **kwargs) - def account_application_info(self, address, application_id, **kwargs): + def account_application_info( + self, address: str, application_id: int, **kwargs: Any + ) -> AlgodResponseType: """ Return application information for a specific account. @@ -179,13 +223,17 @@ def account_application_info(self, address, application_id, **kwargs): address (str): account public key application_id (int): The ID of the application to look up. """ - query = {} + query: Mapping = {} req = "/accounts/" + address + "/applications/" + str(application_id) return self.algod_request("GET", req, query, **kwargs) def pending_transactions_by_address( - self, address, limit=0, response_format="json", **kwargs - ): + self, + address: str, + limit: int = 0, + response_format: str = "json", + **kwargs: Any, + ) -> AlgodResponseType: """ Get the list of pending transactions by address, sorted by priority, in decreasing order, truncated at the end at MAX. If MAX = 0, returns @@ -197,7 +245,7 @@ def pending_transactions_by_address( response_format (str): the format in which the response is returned: either "json" or "msgpack" """ - query = {"format": response_format} + query: Dict[str, Union[str, int]] = {"format": response_format} if limit: query["max"] = limit req = "/accounts/" + address + "/transactions/pending" @@ -207,8 +255,12 @@ def pending_transactions_by_address( return res def block_info( - self, block=None, response_format="json", round_num=None, **kwargs - ): + self, + block: Optional[int] = None, + response_format: str = "json", + round_num: Optional[int] = None, + **kwargs: Any, + ) -> AlgodResponseType: """ Get the block for the given round. @@ -219,25 +271,28 @@ def block_info( round_num (int, optional): alias for block; specify one of these """ query = {"format": response_format} - if block is None and round_num is None: - raise error.UnderspecifiedRoundError req = "/blocks/" + _specify_round_string(block, round_num) res = self.algod_request( "GET", req, query, response_format=response_format, **kwargs ) return res - def ledger_supply(self, **kwargs): + def ledger_supply(self, **kwargs: Any) -> AlgodResponseType: """Return supply details for node's ledger.""" req = "/ledger/supply" return self.algod_request("GET", req, **kwargs) - def status(self, **kwargs): + def status(self, **kwargs: Any) -> AlgodResponseType: """Return node status.""" req = "/status" return self.algod_request("GET", req, **kwargs) - def status_after_block(self, block_num=None, round_num=None, **kwargs): + def status_after_block( + self, + block_num: Optional[int] = None, + round_num: Optional[int] = None, + **kwargs: Any, + ) -> AlgodResponseType: """ Return node status immediately after blockNum. @@ -246,14 +301,14 @@ def status_after_block(self, block_num=None, round_num=None, **kwargs): round_num (int, optional): alias for block_num; specify one of these """ - if block_num is None and round_num is None: - raise error.UnderspecifiedRoundError req = "/status/wait-for-block-after/" + _specify_round_string( block_num, round_num ) return self.algod_request("GET", req, **kwargs) - def send_transaction(self, txn, **kwargs): + def send_transaction( + self, txn: "transaction.Transaction", **kwargs: Any + ) -> str: """ Broadcast a signed transaction object to the network. @@ -266,12 +321,14 @@ def send_transaction(self, txn, **kwargs): """ assert not isinstance( txn, transaction.Transaction - ), "Attempt to send UNSIGNED transaction {}".format(txn) + ), "Attempt to send UNSUPPORTED type of transaction {}".format(txn) return self.send_raw_transaction( encoding.msgpack_encode(txn), **kwargs ) - def send_raw_transaction(self, txn, **kwargs): + def send_raw_transaction( + self, txn: Union[bytes, str], **kwargs: Any + ) -> str: """ Broadcast a signed transaction to the network. @@ -282,7 +339,9 @@ def send_raw_transaction(self, txn, **kwargs): Returns: str: transaction ID """ - txn = base64.b64decode(txn) + self._assert_json_response(kwargs, "send_raw_transaction") + + txn_bytes = base64.b64decode(txn) req = "/transactions" headers = util.build_headers_from( kwargs.get("headers", False), @@ -290,11 +349,12 @@ def send_raw_transaction(self, txn, **kwargs): ) kwargs["headers"] = headers - return self.algod_request("POST", req, data=txn, **kwargs)["txId"] + resp = self.algod_request("POST", req, data=txn_bytes, **kwargs) + return cast(str, cast(dict, resp)["txId"]) def pending_transactions( - self, max_txns=0, response_format="json", **kwargs - ): + self, max_txns: int = 0, response_format: str = "json", **kwargs: Any + ) -> AlgodResponseType: """ Return pending transactions. @@ -304,18 +364,17 @@ def pending_transactions( response_format (str): the format in which the response is returned: either "json" or "msgpack" """ - query = {"format": response_format} + query: Dict[str, Union[int, str]] = {"format": response_format} if max_txns: query["max"] = max_txns req = "/transactions/pending" - res = self.algod_request( + return self.algod_request( "GET", req, params=query, response_format=response_format, **kwargs ) - return res def pending_transaction_info( - self, transaction_id, response_format="json", **kwargs - ): + self, transaction_id: str, response_format: str = "json", **kwargs: Any + ) -> AlgodResponseType: """ Return transaction information for a pending transaction. @@ -326,22 +385,25 @@ def pending_transaction_info( """ req = "/transactions/pending/" + transaction_id query = {"format": response_format} - res = self.algod_request( + return self.algod_request( "GET", req, params=query, response_format=response_format, **kwargs ) - return res - def health(self, **kwargs): + def health(self, **kwargs: Any) -> AlgodResponseType: """Return null if the node is running.""" req = "/health" return self.algod_request("GET", req, **kwargs) - def versions(self, **kwargs): + def versions(self, **kwargs: Any) -> AlgodResponseType: """Return algod versions.""" req = "/versions" return self.algod_request("GET", req, **kwargs) - def send_transactions(self, txns, **kwargs): + def send_transactions( + self, + txns: "Iterable[transaction.GenericSignedTransaction]", + **kwargs: Any, + ) -> str: """ Broadcast list of a signed transaction objects to the network. @@ -353,21 +415,22 @@ def send_transactions(self, txns, **kwargs): Returns: str: first transaction ID """ - serialized = [] + serialized: List[bytes] = [] for txn in txns: assert not isinstance( txn, transaction.Transaction ), "Attempt to send UNSIGNED transaction {}".format(txn) serialized.append(base64.b64decode(encoding.msgpack_encode(txn))) - return self.send_raw_transaction( base64.b64encode(b"".join(serialized)), **kwargs ) - def suggested_params(self, **kwargs): + def suggested_params(self, **kwargs: Any) -> "transaction.SuggestedParams": """Return suggested transaction parameters.""" + self._assert_json_response(kwargs, "suggested_params") + req = "/transactions/params" - res = self.algod_request("GET", req, **kwargs) + res = cast(dict, self.algod_request("GET", req, **kwargs)) return transaction.SuggestedParams( res["fee"], @@ -380,7 +443,9 @@ def suggested_params(self, **kwargs): res["min-fee"], ) - def compile(self, source, source_map=False, **kwargs): + def compile( + self, source: str, source_map: bool = False, **kwargs: Any + ) -> Dict[str, Any]: """ Compile TEAL source with remote algod. @@ -390,8 +455,9 @@ def compile(self, source, source_map=False, **kwargs): Returns: dict: loaded from json response body. "result" property contains compiled bytes, "hash" - program hash (escrow address) - """ + self._assert_json_response(kwargs, "compile") + req = "/teal/compile" headers = util.build_headers_from( kwargs.get("headers", False), @@ -399,11 +465,47 @@ def compile(self, source, source_map=False, **kwargs): ) kwargs["headers"] = headers params = {"sourcemap": source_map} - return self.algod_request( - "POST", req, params=params, data=source.encode("utf-8"), **kwargs + return cast( + Dict[str, Any], + self.algod_request( + "POST", + req, + params=params, + data=source.encode("utf-8"), + **kwargs, + ), ) - def dryrun(self, drr, **kwargs): + def disassemble( + self, program_bytes: bytes, **kwargs: Any + ) -> Dict[str, str]: + """ + Disassable TEAL program bytes with remote algod. + Args: + program (bytes): bytecode to be disassembled + request_header (dict, optional): additional header for request + Returns: + dict: response dictionary containing disassembled TEAL source code + in plain text as the value of the unique "result" key. + """ + if not isinstance(program_bytes, bytes): + raise error.InvalidProgram( + message=f"disassemble endpoints only accepts bytes but request program_bytes is of type {type(program_bytes)}" + ) + self._assert_json_response(kwargs, "disassemble") + + req = "/teal/disassemble" + headers = util.build_headers_from( + kwargs.get("headers", False), + {"Content-Type": "application/x-binary"}, + ) + kwargs["headers"] = headers + return cast( + Dict[str, str], + self.algod_request("POST", req, data=program_bytes, **kwargs), + ) + + def dryrun(self, drr: Dict[str, Any], **kwargs: Any) -> Dict[str, Any]: """ Dryrun with remote algod. @@ -414,6 +516,8 @@ def dryrun(self, drr, **kwargs): Returns: dict: loaded from json response body """ + self._assert_json_response(kwargs, "dryrun") + req = "/teal/dryrun" headers = util.build_headers_from( kwargs.get("headers", False), @@ -423,16 +527,21 @@ def dryrun(self, drr, **kwargs): data = encoding.msgpack_encode(drr) data = base64.b64decode(data) - return self.algod_request("POST", req, data=data, **kwargs) + return cast(dict, self.algod_request("POST", req, data=data, **kwargs)) - def genesis(self, **kwargs): + def genesis(self, **kwargs: Any) -> AlgodResponseType: """Returns the entire genesis file.""" req = "/genesis" return self.algod_request("GET", req, **kwargs) def transaction_proof( - self, round_num, txid, hashtype="", response_format="json", **kwargs - ): + self, + round_num: int, + txid: str, + hashtype: str = "", + response_format: str = "json", + **kwargs: Any, + ) -> AlgodResponseType: """ Get a proof for a transaction in a block. @@ -450,12 +559,14 @@ def transaction_proof( req, params=params, response_format=response_format, - **kwargs + **kwargs, ) - def lightblockheader_proof(self, round_num, **kwargs): + def lightblockheader_proof( + self, round_num: int, **kwargs: Any + ) -> AlgodResponseType: """ - Gets a proof for a given light block header inside a state proof commitment. + Gets a proof for a given light block header inside a state proof commitment. Args: round_num (int): The round to which the light block header belongs. @@ -463,7 +574,7 @@ def lightblockheader_proof(self, round_num, **kwargs): req = "/blocks/{}/lightheader/proof".format(round_num) return self.algod_request("GET", req, **kwargs) - def stateproofs(self, round_num, **kwargs): + def stateproofs(self, round_num: int, **kwargs: Any) -> AlgodResponseType: """ Get a state proof that covers a given round @@ -473,7 +584,9 @@ def stateproofs(self, round_num, **kwargs): req = "/stateproofs/{}".format(round_num) return self.algod_request("GET", req, **kwargs) - def get_block_hash(self, round_num, **kwargs): + def get_block_hash( + self, round_num: int, **kwargs: Any + ) -> AlgodResponseType: """ Get the block hash for the block on the given round. @@ -483,8 +596,55 @@ def get_block_hash(self, round_num, **kwargs): req = "/blocks/{}/hash".format(round_num) return self.algod_request("GET", req, **kwargs) + def simulate_transactions( + self, + txns: "Iterable[transaction.GenericSignedTransaction]", + **kwargs: Any, + ) -> AlgodResponseType: + """ + Simulate a list of a signed transaction objects being sent to the network. + + Args: + txns (SignedTransaction[] or MultisigTransaction[]): + transactions to send + request_header (dict, optional): additional header for request + + Returns: + Dict[str, Any]: results from simulation of transaction group + """ + serialized = [] + for txn in txns: + serialized.append(base64.b64decode(encoding.msgpack_encode(txn))) + + return self.simulate_raw_transaction( + base64.b64encode(b"".join(serialized)), **kwargs + ) + + def simulate_raw_transaction(self, txn, **kwargs): + """ + Simulate a transaction group -def _specify_round_string(block, round_num): + Args: + txn (str): transaction to send, encoded in base64 + request_header (dict, optional): additional header for request + + Returns: + Dict[str, Any]: results from simulation of transaction group + """ + txn = base64.b64decode(txn) + req = "/transactions/simulate" + headers = util.build_headers_from( + kwargs.get("headers", False), + {"Content-Type": "application/x-binary"}, + ) + kwargs["headers"] = headers + + return self.algod_request("POST", req, data=txn, **kwargs) + + +def _specify_round_string( + block: Union[int, None], round_num: Union[int, None] +) -> str: """ Return the round number specified in either 'block' or 'round_num'. @@ -492,10 +652,13 @@ def _specify_round_string(block, round_num): block (int): user specified variable round_num (int): user specified variable """ + if block is None and round_num is None: + raise error.UnderspecifiedRoundError() if block is not None and round_num is not None: - raise error.OverspecifiedRoundError - elif block is not None: - return str(block) - elif round_num is not None: + raise error.OverspecifiedRoundError() + + if round_num is not None: return str(round_num) + + return str(block) diff --git a/algosdk/v2client/indexer.py b/algosdk/v2client/indexer.py index 630ceaac..9054fe4e 100644 --- a/algosdk/v2client/indexer.py +++ b/algosdk/v2client/indexer.py @@ -221,8 +221,6 @@ def block_info( round_num (int, optional): alias for block; specify one of these header_only (bool, optional): """ - if block is None and round_num is None: - raise error.UnderspecifiedRoundError req = "/blocks/" + _specify_round_string(block, round_num) query = dict() @@ -986,7 +984,7 @@ def _specify_round(query, block, round_num): """ if block is not None and round_num is not None: - raise error.OverspecifiedRoundError + raise error.OverspecifiedRoundError() elif block is not None: if block: query["round"] = block diff --git a/algosdk/v2client/models/__init__.py b/algosdk/v2client/models/__init__.py index 9995793a..025e7dea 100644 --- a/algosdk/v2client/models/__init__.py +++ b/algosdk/v2client/models/__init__.py @@ -31,3 +31,18 @@ from algosdk.v2client.models.dryrun_source import DryrunSource from algosdk.v2client.models.teal_key_value import TealKeyValue from algosdk.v2client.models.teal_value import TealValue + +__all__ = [ + "Account", + "AccountParticipation", + "ApplicationLocalState", + "ApplicationParams", + "ApplicationStateSchema", + "Asset", + "AssetHolding", + "AssetParams", + "DryrunRequest", + "DryrunSource", + "TealKeyValue", + "TealValue", +] diff --git a/mypy.ini b/mypy.ini index f8b1844b..f4790c99 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1 +1,5 @@ [mypy] + + +[mypy-msgpack.*] +ignore_missing_imports = True \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c3d13e9d..803ff6a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ . -black==22.3.0 +black==23.1.0 glom==20.11.0 pytest==6.2.5 -mypy==0.990 +mypy==1.0 msgpack-types==0.2.0 git+https://github.com/behave/behave diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/generate_init.py b/scripts/generate_init.py new file mode 100644 index 00000000..cd1b4d39 --- /dev/null +++ b/scripts/generate_init.py @@ -0,0 +1,117 @@ +import argparse +from collections import Counter +import difflib +from pathlib import Path +import sys + +from algosdk import __all__ as static_all + + +# Start of the template to be appended to +pyi_template = """## File generated from scripts/generate_init.py. +## DO NOT EDIT DIRECTLY + +""" + +# Template for __all__ export list +all_template = """__all__ = [ + {}, +]""" + +# Flags to denote the beginning/end of the __all__ exports in __init__.py +begin_flag = "# begin __all__" +end_flag = "# end __all__" + +# Make it safe to run from anywhere +curr_dir = Path.cwd() +orig_dir = curr_dir / "algosdk" + +# Path to pyi +pyi_file = "__init__.pyi" +orig_file = orig_dir / pyi_file + +# Path to py +py_file = "__init__.py" +init_file = orig_dir / py_file + + +def generate_init_pyi() -> str: + with open(init_file, "r") as f: + init_contents = f.read() + + start_idx = init_contents.index(begin_flag) + end_idx = init_contents.index(end_flag) + + counts = Counter(static_all) + dupes = [x for x, n in counts.items() if n > 1] + BR = "\n" + assert not dupes, ( + f"Aborting pyi file generation. The following duplicate imports were " + f"detected:{BR}{BR.join(dupes)}" + ) + + all_imports = ",\n ".join( + ['"{}"'.format(s) for s in sorted(static_all)] + ) + + return ( + pyi_template + + init_contents[:start_idx] + + all_template.format(all_imports) + + init_contents[end_idx + len(end_flag) :] + ) + + +def is_different(regen: str) -> bool: + if not orig_file.exists(): + return True + + with open(orig_file, "r") as f: + orig_lines = f.readlines() + + curr_lines = regen.splitlines(keepends=True) + + diff = list( + difflib.unified_diff( + orig_lines, + curr_lines, + fromfile="original", + tofile="generated", + n=3, + ) + ) + + if len(diff) != 0: + print("".join(diff), end="") + return True + + return False + + +def overwrite(regen: str): + with open(orig_file, "w") as f: + f.write(regen) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + action="store_true", + help="Only check if the generated file would change", + ) + args = parser.parse_args() + + regen = generate_init_pyi() + + if args.check: + if is_different(regen): + print( + "The __init__.pyi needs to be regenerated. " + "Please run scripts/generate_init.py" + ) + sys.exit(1) + print("No changes in __init__.pyi") + sys.exit(0) + + overwrite(regen) diff --git a/setup.py b/setup.py index 933255bc..ddba75fd 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ description="Algorand SDK in Python", author="Algorand", author_email="pypiservice@algorand.com", - version="v2.0.0", + version="2.1.0", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/algorand/py-algorand-sdk", diff --git a/tests/integration.tags b/tests/integration.tags index de2c60c5..989a7baf 100644 --- a/tests/integration.tags +++ b/tests/integration.tags @@ -6,6 +6,7 @@ @auction @c2c @compile +@compile.disassemble @compile.sourcemap @dryrun @dryrun.testing @@ -13,3 +14,4 @@ @rekey_v1 @send @send.keyregtxn +@simulate \ No newline at end of file diff --git a/tests/steps/application_v2_steps.py b/tests/steps/application_v2_steps.py index 94d1e809..a7441e3f 100644 --- a/tests/steps/application_v2_steps.py +++ b/tests/steps/application_v2_steps.py @@ -555,6 +555,17 @@ def create_transaction_with_signer(context): ) +@when( + "I create a transaction with an empty signer with the current transaction." +) +def create_transaction_no_signer(context): + context.transaction_with_signer = ( + atomic_transaction_composer.TransactionWithSigner( + context.transaction, atomic_transaction_composer.EmptySigner() + ) + ) + + @when("I add the current transaction with signer to the composer.") def add_transaction_to_composer(context): context.atomic_transaction_composer.add_transaction( diff --git a/tests/steps/other_v2_steps.py b/tests/steps/other_v2_steps.py index ef038d29..b6bd1b87 100644 --- a/tests/steps/other_v2_steps.py +++ b/tests/steps/other_v2_steps.py @@ -19,6 +19,10 @@ source_map, transaction, ) + +from algosdk.atomic_transaction_composer import ( + SimulateAtomicTransactionResponse, +) from algosdk.error import AlgodHTTPError from algosdk.testing.dryrun import DryrunTestCaseMixin from algosdk.v2client import * @@ -896,8 +900,9 @@ def expect_path(context, path): context.response["path"] ) actual_query = urllib.parse.parse_qs(actual_query) - assert exp_path == actual_path.replace("%3A", ":") - assert exp_query == actual_query + actual_path = actual_path.replace("%3A", ":") + assert exp_path == actual_path, f"{exp_path} != {actual_path}" + assert exp_query == actual_query, f"{exp_query} != {actual_query}" @then('expect error string to contain "{err:MaybeString}"') @@ -1072,6 +1077,17 @@ def b64decode_compiled_teal_step(context, binary): assert base64.b64decode(response_result.encode()) == binary +@then('disassembly of "{bytecode_filename}" matches "{source_filename}"') +def disassembly_matches_source(context, bytecode_filename, source_filename): + bytecode = load_resource(bytecode_filename) + expected_source = load_resource(source_filename).decode("utf-8") + + context.response = context.app_acl.disassemble(bytecode) + actual_source = context.response["result"] + + assert actual_source == expected_source + + @when('I dryrun a "{kind}" program "{program}"') def dryrun_step(context, kind, program): data = load_resource(program) @@ -1362,7 +1378,6 @@ def check_source_map(context, pc_to_line): @then('getting the line associated with a pc "{pc}" equals "{line}"') def check_pc_to_line(context, pc, line): - actual_line = context.source_map.get_line_for_pc(int(pc)) assert actual_line == int(line), f"expected line {line} got {actual_line}" @@ -1414,3 +1429,72 @@ def transaction_proof(context, round, txid, hashtype): @when("we make a Lookup Block Hash call against round {round}") def get_block_hash(context, round): context.response = context.acl.get_block_hash(round) + + +@when("I simulate the transaction") +def simulate_transaction(context): + context.simulate_response = context.app_acl.simulate_transactions( + [context.stx] + ) + + +@then("the simulation should succeed without any failure message") +def simulate_transaction_succeed(context): + if hasattr(context, "simulate_response"): + assert context.simulate_response["would-succeed"] is True + else: + assert context.atomic_transaction_composer_return.would_succeed is True + + +@then("I simulate the current transaction group with the composer") +def simulate_atc(context): + context.atomic_transaction_composer_return = ( + context.atomic_transaction_composer.simulate(context.app_acl) + ) + + +@then( + 'the simulation should report a failure at group "{group}", path "{path}" with message "{message}"' +) +def simulate_atc_failure(context, group, path, message): + resp: SimulateAtomicTransactionResponse = ( + context.atomic_transaction_composer_return + ) + group_idx: int = int(group) + fail_path = ",".join( + [ + str(pe) + for pe in resp.simulate_response["txn-groups"][group_idx][ + "failed-at" + ] + ] + ) + assert resp.would_succeed is False + assert fail_path == path + assert message in resp.failure_message + + +@when("I prepare the transaction without signatures for simulation") +def step_impl(context): + context.stx = transaction.SignedTransaction(context.txn, None) + + +@then( + 'the simulation should report missing signatures at group "{group}", transactions "{path}"' +) +def check_missing_signatures(context, group, path): + if hasattr(context, "simulate_response"): + resp = context.simulate_response + else: + resp = context.atomic_transaction_composer_return.simulate_response + + group_idx: int = int(group) + tx_idxs: list[int] = [int(pe) for pe in path.split(",")] + + assert resp["would-succeed"] is False + + for tx_idx in tx_idxs: + missing_sig = resp["txn-groups"][group_idx]["txn-results"][tx_idx][ + "missing-signature" + ] + assert missing_sig is True diff --git a/tests/unit_tests/test_logicsig.py b/tests/unit_tests/test_logicsig.py index ef2cfac8..4bed5123 100644 --- a/tests/unit_tests/test_logicsig.py +++ b/tests/unit_tests/test_logicsig.py @@ -761,7 +761,6 @@ def test_msig_address(self): self.assertEqual(msig2.address(), golden) def test_errors(self): - # get random private key private_key_1, account_1 = account.generate_account() _, account_2 = account.generate_account()