diff --git a/.circleci/config.yml b/.circleci/config.yml index 1cc46bde..e8b39724 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,11 +9,11 @@ workflows: - unit-test: matrix: parameters: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - integration-test: matrix: parameters: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - docset jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ec9cfa..26e29c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +# v2.6.0 + + + +## What's Changed +### Bugfixes +* fix: no timeout for urlopen issue #526 by @grzracz in https://github.com/algorand/py-algorand-sdk/pull/527 +* txns: Uses sp.min_fee if available by @jannotti in https://github.com/algorand/py-algorand-sdk/pull/530 +* fix: Fix initialization for `WrongAmountType` error by @algolog in https://github.com/algorand/py-algorand-sdk/pull/532 +* Fix: Fix indexer sync issue in cucumber tests by @jasonpaulos in https://github.com/algorand/py-algorand-sdk/pull/533 +### Enhancements +* Docs: Add missing pages for source map and dryrun results by @jasonpaulos in https://github.com/algorand/py-algorand-sdk/pull/520 +* DX: Keyreg bytes by @jannotti in https://github.com/algorand/py-algorand-sdk/pull/522 +* Testing: Add Python 3.12 to test matrix by @jasonpaulos in https://github.com/algorand/py-algorand-sdk/pull/534 +* Simulate: Support newer simulate options by @jasonpaulos in https://github.com/algorand/py-algorand-sdk/pull/537 +* Tests: Enable min-balance Cucumber tests. by @gmalouf in https://github.com/algorand/py-algorand-sdk/pull/539 +### Other +* Fix typographic error when printing offline participation transaction by @hsoerensen in https://github.com/algorand/py-algorand-sdk/pull/524 + +## New Contributors +* @hsoerensen made their first contribution in https://github.com/algorand/py-algorand-sdk/pull/524 +* @grzracz made their first contribution in https://github.com/algorand/py-algorand-sdk/pull/527 +* @algolog made their first contribution in https://github.com/algorand/py-algorand-sdk/pull/532 +* @gmalouf made their first contribution in https://github.com/algorand/py-algorand-sdk/pull/539 + +**Full Changelog**: https://github.com/algorand/py-algorand-sdk/compare/v2.5.0...v2.6.0 + # v2.5.0 diff --git a/algosdk/atomic_transaction_composer.py b/algosdk/atomic_transaction_composer.py index 4df0985b..c48a1e53 100644 --- a/algosdk/atomic_transaction_composer.py +++ b/algosdk/atomic_transaction_composer.py @@ -272,11 +272,13 @@ def __init__( max_log_calls: Optional[int] = None, max_log_size: Optional[int] = None, allow_empty_signatures: Optional[bool] = None, + allow_unnamed_resources: Optional[bool] = None, extra_opcode_budget: Optional[int] = None, ) -> None: self.max_log_calls = max_log_calls self.max_log_size = max_log_size self.allow_empty_signatures = allow_empty_signatures + self.allow_unnamed_resources = allow_unnamed_resources self.extra_opcode_budget = extra_opcode_budget @staticmethod @@ -297,6 +299,10 @@ def from_simulation_result( eval_override.allow_empty_signatures = eval_override_dict[ "allow-empty-signatures" ] + if "allow-unnamed-resources" in eval_override_dict: + eval_override.allow_unnamed_resources = eval_override_dict[ + "allow-unnamed-resources" + ] if "extra-opcode-budget" in eval_override_dict: eval_override.extra_opcode_budget = eval_override_dict[ "extra-opcode-budget" diff --git a/algosdk/error.py b/algosdk/error.py index b0931977..6de6afee 100644 --- a/algosdk/error.py +++ b/algosdk/error.py @@ -48,7 +48,7 @@ def __init__(self): class WrongAmountType(Exception): - def __init(self): + def __init__(self): Exception.__init__(self, "amount (amt) must be a non-negative integer") @@ -70,7 +70,7 @@ def __init__(self): class WrongHashLengthError(Exception): """General error that is normally changed to be more specific""" - def __init(self): + def __init__(self): Exception.__init__(self, "length must be 32 bytes") @@ -85,22 +85,22 @@ def __init__(self): class WrongMetadataLengthError(Exception): - def __init(self): + def __init__(self): Exception.__init__(self, "metadata length must be 32 bytes") class WrongLeaseLengthError(Exception): - def __init(self): + def __init__(self): Exception.__init__(self, "lease length must be 32 bytes") class WrongNoteType(Exception): - def __init(self): + def __init__(self): Exception.__init__(self, 'note must be of type "bytes"') class WrongNoteLength(Exception): - def __init(self): + def __init__(self): Exception.__init__(self, "note length must be at most 1024") diff --git a/algosdk/kmd.py b/algosdk/kmd.py index a6edfa03..70dc0edd 100644 --- a/algosdk/kmd.py +++ b/algosdk/kmd.py @@ -26,7 +26,7 @@ def __init__(self, kmd_token, kmd_address): self.kmd_token = kmd_token self.kmd_address = kmd_address - def kmd_request(self, method, requrl, params=None, data=None): + def kmd_request(self, method, requrl, params=None, data=None, timeout=30): """ Execute a given request. @@ -35,6 +35,7 @@ def kmd_request(self, method, requrl, params=None, data=None): requrl (str): url for the request params (dict, optional): parameters for the request data (dict, optional): data in the body of the request + timeout (int, optional): request timeout in seconds Returns: dict: loaded from json response body @@ -56,7 +57,7 @@ def kmd_request(self, method, requrl, params=None, data=None): ) resp = None try: - resp = urlopen(req) + resp = urlopen(req, timeout=timeout) except urllib.error.HTTPError as e: e = e.read().decode("utf-8") try: diff --git a/algosdk/transaction.py b/algosdk/transaction.py index 09e2a923..23db5592 100644 --- a/algosdk/transaction.py +++ b/algosdk/transaction.py @@ -368,9 +368,8 @@ def __init__( raise error.WrongAmountType self.close_remainder_to = close_remainder_to if not sp.flat_fee: - self.fee = max( - self.estimate_size() * self.fee, constants.min_txn_fee - ) + mf = constants.min_txn_fee if sp.min_fee is None else sp.min_fee + self.fee = max(self.estimate_size() * self.fee, mf) def dictify(self): d = dict() @@ -419,8 +418,8 @@ class KeyregTxn(Transaction): Args: sender (str): address of sender sp (SuggestedParams): suggested params from algod - votekey (str): participation public key in base64 - selkey (str): VRF public key in base64 + votekey (str|bytes): participation public key bytes, optionally encoded in base64 + selkey (str|bytes): VRF public key bytes, optionally encoded in base64 votefst (int): first round to vote votelst (int): last round to vote votekd (int): vote key dilution @@ -430,7 +429,7 @@ class KeyregTxn(Transaction): transaction's valid rounds rekey_to (str, optional): additionally rekey the sender to this address nonpart (bool, optional): mark the account non-participating if true - StateProofPK: state proof + sprfkey (str|bytes, optional): state proof ID bytes, optionally encoded in base64 Attributes: sender (str) @@ -440,7 +439,7 @@ class KeyregTxn(Transaction): note (bytes) genesis_id (str) genesis_hash (str) - group(bytes) + group (bytes) votepk (str) selkey (str) votefst (int) @@ -471,18 +470,17 @@ def __init__( Transaction.__init__( self, sender, sp, note, lease, constants.keyreg_txn, rekey_to ) - self.votepk = votekey - self.selkey = selkey + self.votepk = self._fixed_bytes64(votekey, 32) + self.selkey = self._fixed_bytes64(selkey, 32) self.votefst = votefst self.votelst = votelst self.votekd = votekd self.nonpart = nonpart - self.sprfkey = sprfkey + self.sprfkey = self._fixed_bytes64(sprfkey, 64) if not sp.flat_fee: - self.fee = max( - self.estimate_size() * self.fee, constants.min_txn_fee - ) + mf = constants.min_txn_fee if sp.min_fee is None else sp.min_fee + self.fee = max(self.estimate_size() * self.fee, mf) def dictify(self): d = {} @@ -520,6 +518,18 @@ def __eq__(self, other): and self.sprfkey == other.sprfkey ) + @staticmethod + def _fixed_bytes64(key, size): + if key is None: + return None + if isinstance(key, (bytes, bytearray)) and len(key) == size: + return base64.b64encode(key) + if len(base64.b64decode(key)) == size: + return key + assert False, "{} is not {} bytes or b64 decodable as such".format( + key, size + ) + class KeyregOnlineTxn(KeyregTxn): """ @@ -529,8 +539,8 @@ class KeyregOnlineTxn(KeyregTxn): Args: sender (str): address of sender sp (SuggestedParams): suggested params from algod - votekey (str): participation public key in base64 - selkey (str): VRF public key in base64 + votekey (str|bytes): participation public key bytes, optionally encoded in base64 + selkey (str|bytes): VRF public key bytes, optionally encoded in base64 votefst (int): first round to vote votelst (int): last round to vote votekd (int): vote key dilution @@ -539,7 +549,7 @@ class KeyregOnlineTxn(KeyregTxn): with the same sender and lease can be confirmed in this transaction's valid rounds rekey_to (str, optional): additionally rekey the sender to this address - sprfkey (str, optional): state proof ID + sprfkey (str|bytes, optional): state proof ID bytes, optionally encoded in base64 Attributes: sender (str) @@ -549,7 +559,7 @@ class KeyregOnlineTxn(KeyregTxn): note (bytes) genesis_id (str) genesis_hash (str) - group(bytes) + group (bytes) votepk (str) selkey (str) votefst (int) @@ -590,12 +600,6 @@ def __init__( nonpart=False, sprfkey=sprfkey, ) - self.votepk = votekey - self.selkey = selkey - self.votefst = votefst - self.votelst = votelst - self.votekd = votekd - self.sprfkey = sprfkey if votekey is None: raise error.KeyregOnlineTxnInitError("votekey") if selkey is None: @@ -606,37 +610,19 @@ def __init__( raise error.KeyregOnlineTxnInitError("votelst") if votekd is None: raise error.KeyregOnlineTxnInitError("votekd") - if not sp.flat_fee: - self.fee = max( - self.estimate_size() * self.fee, constants.min_txn_fee - ) @staticmethod def _undictify(d): - votekey = base64.b64encode(d["votekey"]).decode() - selkey = base64.b64encode(d["selkey"]).decode() - votefst = d["votefst"] - votelst = d["votelst"] - votekd = d["votekd"] + args = { + "votekey": base64.b64encode(d["votekey"]).decode(), + "selkey": base64.b64encode(d["selkey"]).decode(), + "votefst": d["votefst"], + "votelst": d["votelst"], + "votekd": d["votekd"], + } + if "sprfkey" in d: - sprfID = base64.b64encode(d["sprfkey"]).decode() - - args = { - "votekey": votekey, - "selkey": selkey, - "votefst": votefst, - "votelst": votelst, - "votekd": votekd, - "sprfkey": sprfID, - } - else: - args = { - "votekey": votekey, - "selkey": selkey, - "votefst": votefst, - "votelst": votelst, - "votekd": votekd, - } + args["sprfkey"] = base64.b64encode(d["sprfkey"]).decode() return args @@ -668,7 +654,7 @@ class KeyregOfflineTxn(KeyregTxn): note (bytes) genesis_id (str) genesis_hash (str) - group(bytes) + group (bytes) type (str) lease (byte[32]) rekey_to (str) @@ -690,10 +676,6 @@ def __init__(self, sender, sp, note=None, lease=None, rekey_to=None): nonpart=False, sprfkey=None, ) - if not sp.flat_fee: - self.fee = max( - self.estimate_size() * self.fee, constants.min_txn_fee - ) @staticmethod def _undictify(d): @@ -728,7 +710,7 @@ class KeyregNonparticipatingTxn(KeyregTxn): note (bytes) genesis_id (str) genesis_hash (str) - group(bytes) + group (bytes) type (str) lease (byte[32]) rekey_to (str) @@ -750,10 +732,6 @@ def __init__(self, sender, sp, note=None, lease=None, rekey_to=None): nonpart=True, sprfkey=None, ) - if not sp.flat_fee: - self.fee = max( - self.estimate_size() * self.fee, constants.min_txn_fee - ) @staticmethod def _undictify(d): @@ -886,9 +864,8 @@ def __init__( if self.decimals < 0 or self.decimals > constants.max_asset_decimals: raise error.OutOfRangeDecimalsError if not sp.flat_fee: - self.fee = max( - self.estimate_size() * self.fee, constants.min_txn_fee - ) + mf = constants.min_txn_fee if sp.min_fee is None else sp.min_fee + self.fee = max(self.estimate_size() * self.fee, mf) def dictify(self): d = dict() @@ -1241,9 +1218,8 @@ def __init__( self.target = target self.new_freeze_state = new_freeze_state if not sp.flat_fee: - self.fee = max( - self.estimate_size() * self.fee, constants.min_txn_fee - ) + mf = constants.min_txn_fee if sp.min_fee is None else sp.min_fee + self.fee = max(self.estimate_size() * self.fee, mf) def dictify(self): d = dict() @@ -1359,9 +1335,8 @@ def __init__( self.close_assets_to = close_assets_to self.revocation_target = revocation_target if not sp.flat_fee: - self.fee = max( - self.estimate_size() * self.fee, constants.min_txn_fee - ) + mf = constants.min_txn_fee if sp.min_fee is None else sp.min_fee + self.fee = max(self.estimate_size() * self.fee, mf) def dictify(self): d = dict() @@ -1632,9 +1607,8 @@ def __init__( boxes, self.foreign_apps, self.index ) if not sp.flat_fee: - self.fee = max( - self.estimate_size() * self.fee, constants.min_txn_fee - ) + mf = constants.min_txn_fee if sp.min_fee is None else sp.min_fee + self.fee = max(self.estimate_size() * self.fee, mf) @staticmethod def state_schema(schema): diff --git a/algosdk/v2client/algod.py b/algosdk/v2client/algod.py index bd2f4189..431f7715 100644 --- a/algosdk/v2client/algod.py +++ b/algosdk/v2client/algod.py @@ -61,6 +61,7 @@ def algod_request( data: Optional[bytes] = None, headers: Optional[Dict[str, str]] = None, response_format: Optional[str] = "json", + timeout: Optional[int] = 30, ) -> AlgodResponseType: """ Execute a given request. @@ -72,6 +73,7 @@ def algod_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 + timeout (int, optional): request timeout in seconds Returns: dict loaded from json response body when response_format == "json" @@ -101,7 +103,7 @@ def algod_request( ) try: - resp = urlopen(req) + resp = urlopen(req, timeout=timeout) except urllib.error.HTTPError as e: code = e.code es = e.read().decode("utf-8") diff --git a/algosdk/v2client/indexer.py b/algosdk/v2client/indexer.py index c666b902..e2cccb45 100644 --- a/algosdk/v2client/indexer.py +++ b/algosdk/v2client/indexer.py @@ -31,7 +31,7 @@ def __init__(self, indexer_token, indexer_address, headers=None): self.headers = headers def indexer_request( - self, method, requrl, params=None, data=None, headers=None + self, method, requrl, params=None, data=None, headers=None, timeout=30 ): """ Execute a given request. @@ -42,6 +42,7 @@ def indexer_request( params (dict, optional): parameters for the request data (dict, optional): data in the body of the request headers (dict, optional): additional header for request + timeout (int, optional): request timeout in seconds Returns: dict: loaded from json response body @@ -70,7 +71,7 @@ def indexer_request( ) try: - resp = urlopen(req) + resp = urlopen(req, timeout=timeout) except urllib.error.HTTPError as e: e = e.read().decode("utf-8") try: diff --git a/algosdk/v2client/models/simulate_request.py b/algosdk/v2client/models/simulate_request.py index 5d008cba..3b6b220b 100644 --- a/algosdk/v2client/models/simulate_request.py +++ b/algosdk/v2client/models/simulate_request.py @@ -20,6 +20,7 @@ class SimulateTraceConfig: enable: bool stack_change: bool scratch_change: bool + state_change: bool def __init__( self, @@ -27,16 +28,19 @@ def __init__( enable: bool = False, stack_change: bool = False, scratch_change: bool = False, + state_change: bool = False, ) -> None: self.enable = enable self.stack_change = stack_change self.scratch_change = scratch_change + self.state_change = state_change def dictify(self) -> Dict[str, Any]: return { "enable": self.enable, "stack-change": self.stack_change, "scratch-change": self.scratch_change, + "state-change": self.state_change, } @staticmethod @@ -45,6 +49,7 @@ def undictify(d: Dict[str, Any]) -> "SimulateTraceConfig": enable="enable" in d and d["enable"], stack_change="stack-change" in d and d["stack-change"], scratch_change="scratch-change" in d and d["scratch-change"], + state_change="state-change" in d and d["state-change"], ) @@ -52,21 +57,27 @@ class SimulateRequest: txn_groups: List[SimulateRequestTransactionGroup] allow_more_logs: bool allow_empty_signatures: bool + allow_unnamed_resources: bool extra_opcode_budget: int exec_trace_config: SimulateTraceConfig + round: Optional[int] def __init__( self, *, txn_groups: List[SimulateRequestTransactionGroup], + round: Optional[int] = None, allow_more_logs: bool = False, allow_empty_signatures: bool = False, + allow_unnamed_resources: bool = False, extra_opcode_budget: int = 0, exec_trace_config: Optional[SimulateTraceConfig] = None, ) -> None: self.txn_groups = txn_groups + self.round = round self.allow_more_logs = allow_more_logs self.allow_empty_signatures = allow_empty_signatures + self.allow_unnamed_resources = allow_unnamed_resources self.extra_opcode_budget = extra_opcode_budget self.exec_trace_config = ( exec_trace_config if exec_trace_config else SimulateTraceConfig() @@ -77,7 +88,9 @@ def dictify(self) -> Dict[str, Any]: "txn-groups": [ txn_group.dictify() for txn_group in self.txn_groups ], + "round": self.round, "allow-more-logging": self.allow_more_logs, + "allow-unnamed-resources": self.allow_unnamed_resources, "allow-empty-signatures": self.allow_empty_signatures, "extra-opcode-budget": self.extra_opcode_budget, "exec-trace-config": self.exec_trace_config.dictify(), diff --git a/docs/algosdk/dryrun_results.rst b/docs/algosdk/dryrun_results.rst new file mode 100644 index 00000000..311e0c3b --- /dev/null +++ b/docs/algosdk/dryrun_results.rst @@ -0,0 +1,7 @@ +dryrun_results +============== + +.. automodule:: algosdk.dryrun_results + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/algosdk/index.rst b/docs/algosdk/index.rst index 07adab58..c68a1faa 100644 --- a/docs/algosdk/index.rst +++ b/docs/algosdk/index.rst @@ -9,11 +9,13 @@ algosdk auction box_reference constants + dryrun_results encoding error kmd logic mnemonic + source_map transaction util v2client/index diff --git a/docs/algosdk/source_map.rst b/docs/algosdk/source_map.rst new file mode 100644 index 00000000..9592e0de --- /dev/null +++ b/docs/algosdk/source_map.rst @@ -0,0 +1,7 @@ +source_map +========== + +.. automodule:: algosdk.source_map + :members: + :undoc-members: + :show-inheritance: diff --git a/examples/indexer.py b/examples/indexer.py index d786607a..45e30e8c 100644 --- a/examples/indexer.py +++ b/examples/indexer.py @@ -1,7 +1,12 @@ import json from algosdk import transaction from algosdk.v2client import indexer -from utils import get_accounts, get_algod_client, get_indexer_client +from utils import ( + get_accounts, + get_algod_client, + get_indexer_client, + indexer_wait_for_round, +) # example: INDEXER_CREATE_CLIENT @@ -41,11 +46,8 @@ algod_client, algod_client.send_transaction(ptxn.sign(acct.private_key)), 4 ) -# sleep for a couple seconds to allow indexer to catch up -import time - -time.sleep(2) - +# allow indexer to catch up +indexer_wait_for_round(indexer_client, res["confirmed-round"], 30) # example: INDEXER_LOOKUP_ASSET # lookup a single asset diff --git a/examples/participation.py b/examples/participation.py index 224a67b3..bc3ed57f 100644 --- a/examples/participation.py +++ b/examples/participation.py @@ -40,5 +40,5 @@ votelst=None, votekd=None, ) -print(online_keyreg.dictify()) +print(offline_keyreg.dictify()) # example: TRANSACTION_KEYREG_OFFLINE_CREATE diff --git a/examples/utils.py b/examples/utils.py index 64c3958d..d5c3a8bc 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -1,5 +1,6 @@ import os import base64 +import time from dataclasses import dataclass from typing import List @@ -55,6 +56,31 @@ def get_sandbox_default_wallet() -> Wallet: ) +def indexer_wait_for_round( + client: indexer.IndexerClient, round: int, max_attempts: int +) -> None: + """waits for the indexer to catch up to the given round""" + indexer_round = 0 + attempts = 0 + + while True: + indexer_status = client.health() + indexer_round = indexer_status["round"] + if indexer_round >= round: + # Success + break + + # Sleep for 1 second and try again + time.sleep(1) + attempts += 1 + + if attempts >= max_attempts: + # Failsafe to prevent infinite loop + raise RuntimeError( + f"Timeout waiting for indexer to catch up to round {round}. It is currently on {indexer_round}" + ) + + @dataclass class SandboxAccount: """SandboxAccount is a simple dataclass to hold a sandbox account details""" diff --git a/setup.py b/setup.py index 70d6c854..f7fb11b3 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ description="Algorand SDK in Python", author="Algorand", author_email="pypiservice@algorand.com", - version="2.5.0", + version="2.6.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 660f858b..54a97517 100644 --- a/tests/integration.tags +++ b/tests/integration.tags @@ -17,4 +17,5 @@ @simulate @simulate.lift_log_limits @simulate.extra_opcode_budget -@simulate.exec_trace_with_stack_scratch \ No newline at end of file +@simulate.exec_trace_with_stack_scratch +@simulate.exec_trace_with_state_change_and_hash \ No newline at end of file diff --git a/tests/steps/application_v2_steps.py b/tests/steps/application_v2_steps.py index b18a958e..77d08647 100644 --- a/tests/steps/application_v2_steps.py +++ b/tests/steps/application_v2_steps.py @@ -24,6 +24,8 @@ def operation_string_to_enum(operation): return transaction.OnComplete.NoOpOC elif operation == "create": return transaction.OnComplete.NoOpOC + elif operation == "create-and-optin": + return transaction.OnComplete.OptInOC elif operation == "noop": return transaction.OnComplete.NoOpOC elif operation == "update": @@ -116,27 +118,32 @@ def s512_256_uint64(witness): return int.from_bytes(encoding.checksum(witness)[:8], "big") -# Dev mode helper functions -def wait_for_transaction_processing_to_complete_in_dev_mode( - millisecond_num=500, -): - """ - wait_for_transaction_processing_to_complete_in_dev_mode is a Dev mode helper method that waits for a transaction to be processed and serves as a rough analog to `context.app_acl.status_after_block(last_round + 2)`. - *

- * Since Dev mode produces blocks on a per transaction basis, it's possible algod generates a block _before_ the corresponding SDK call to wait for a block. - * Without _any_ wait, it's possible the SDK looks for the transaction before algod completes processing. The analogous problem may also exist in indexer. So, the method performs a local sleep to simulate waiting for a block. - """ - time.sleep(millisecond_num / 1000) - - -# Dev mode helper step @then( - "I sleep for {millisecond_num} milliseconds for indexer to digest things down." + "I wait for indexer to catch up to the round where my most recent transaction was confirmed." ) -def wait_for_indexer_in_dev_mode(context, millisecond_num): - wait_for_transaction_processing_to_complete_in_dev_mode( - int(millisecond_num) - ) +def wait_for_indexer_to_catch_up(context): + max_attempts = 30 + + round_to_wait_for = context.last_tx_confirmed_round + indexer_round = 0 + attempts = 0 + + while True: + indexer_status = context.app_icl.health() + indexer_round = indexer_status["round"] + if indexer_round >= round_to_wait_for: + # Success + break + + # Sleep for 1 second and try again + time.sleep(1) + attempts += 1 + + if attempts >= max_attempts: + # Failsafe to prevent infinite loop + raise RuntimeError( + f"Timeout waiting for indexer to catch up to round {round_to_wait_for}. It is currently on {indexer_round}" + ) @step( @@ -354,7 +361,7 @@ def build_app_txn_with_transient( if ( hasattr(context, "current_application_id") and context.current_application_id - and operation != "create" + and operation not in ("create", "create-and-optin") ): application_id = context.current_application_id operation = operation_string_to_enum(operation) @@ -439,8 +446,10 @@ def remember_app_id(context): # TODO: this needs to be modified to use v2 only @step("I wait for the transaction to be confirmed.") def wait_for_app_txn_confirm(context): - wait_for_transaction_processing_to_complete_in_dev_mode() - transaction.wait_for_confirmation(context.app_acl, context.app_txid, 1) + tx_info = transaction.wait_for_confirmation( + context.app_acl, context.app_txid, 1 + ) + context.last_tx_confirmed_round = tx_info["confirmed-round"] @given("an application id {app_id}") @@ -633,6 +642,7 @@ def add_nonce(context, nonce): def abi_method_adder( context, + *, account_type, operation, create_when_calling=False, @@ -645,6 +655,7 @@ def abi_method_adder( extra_pages=None, force_unique_transactions=False, exception_key="none", + comma_separated_boxes_string=None, ): if account_type == "transient": sender = context.transient_pk @@ -691,6 +702,10 @@ def int_if_given(given): + context.nonce.encode() ) + boxes = None + if comma_separated_boxes_string is not None: + boxes = split_and_process_boxes(comma_separated_boxes_string) + try: context.atomic_transaction_composer.add_method_call( app_id=app_id, @@ -706,6 +721,7 @@ def int_if_given(given): clear_program=clear_program, extra_pages=extra_pages, note=note, + boxes=boxes, ) except AtomicTransactionComposerError as atce: assert ( @@ -732,6 +748,18 @@ def int_if_given(given): ), f"should have encountered an AtomicTransactionComposerError keyed by '{exception_key}', but no such exception has been detected" +@when( + 'I add a method call with the transient account, the current application, suggested params, on complete "{operation}", current transaction signer, current method arguments, boxes "{boxes}".' +) +def add_abi_method_call_with_boxes(context, operation, boxes): + abi_method_adder( + context, + account_type="transient", + operation=operation, + comma_separated_boxes_string=boxes, + ) + + @step( 'I add a method call with the {account_type} account, the current application, suggested params, on complete "{operation}", current transaction signer, current method arguments; any resulting exception has key "{exception_key}".' ) @@ -740,8 +768,8 @@ def add_abi_method_call_with_exception( ): abi_method_adder( context, - account_type, - operation, + account_type=account_type, + operation=operation, exception_key=exception_key, ) @@ -752,8 +780,8 @@ def add_abi_method_call_with_exception( def add_abi_method_call(context, account_type, operation): abi_method_adder( context, - account_type, - operation, + account_type=account_type, + operation=operation, ) @@ -774,16 +802,16 @@ def add_abi_method_call_creation_with_allocs( ): abi_method_adder( context, - account_type, - operation, - True, - approval_program_path, - clear_program_path, - global_bytes, - global_ints, - local_bytes, - local_ints, - extra_pages, + account_type=account_type, + operation=operation, + create_when_calling=True, + approval_program_path=approval_program_path, + clear_program_path=clear_program_path, + global_bytes=global_bytes, + global_ints=global_ints, + local_bytes=local_bytes, + local_ints=local_ints, + extra_pages=extra_pages, ) @@ -799,11 +827,11 @@ def add_abi_method_call_creation( ): abi_method_adder( context, - account_type, - operation, - True, - approval_program_path, - clear_program_path, + account_type=account_type, + operation=operation, + create_when_calling=True, + approval_program_path=approval_program_path, + clear_program_path=clear_program_path, ) @@ -813,8 +841,8 @@ def add_abi_method_call_creation( def add_abi_method_call_nonced(context, account_type, operation): abi_method_adder( context, - account_type, - operation, + account_type=account_type, + operation=operation, force_unique_transactions=True, ) diff --git a/tests/steps/other_v2_steps.py b/tests/steps/other_v2_steps.py index 9728a6ca..7fcc36eb 100644 --- a/tests/steps/other_v2_steps.py +++ b/tests/steps/other_v2_steps.py @@ -1540,9 +1540,32 @@ def exec_trace_config_in_simulation(context, options: str): enable=True, stack_change="stack" in option_list, scratch_change="scratch" in option_list, + state_change="state" in option_list, ) +def compare_avm_value_with_string_literal( + expected_string_literal: str, actual_avm_value: dict +): + [expected_avm_type, expected_value] = expected_string_literal.split(":") + + if expected_avm_type == "uint64": + assert actual_avm_value["type"] == 2 + if expected_value == "0": + assert "uint" not in actual_avm_value + else: + assert actual_avm_value["uint"] == int(expected_value) + elif expected_avm_type == "bytes": + assert actual_avm_value["type"] == 1 + if len(expected_value) == 0: + assert "bytes" not in actual_avm_value + else: + # expected_value and actual bytes should both be b64 encoded + assert actual_avm_value["bytes"] == expected_value + else: + raise Exception(f"Unknown AVM type: {expected_avm_type}") + + @then( '{unit_index}th unit in the "{trace_type}" trace at txn-groups path "{group_path}" should add value "{stack_addition:MaybeString}" to stack, pop {pop_count} values from stack, write value "{scratch_var:MaybeString}" to scratch slot "{scratch_index:MaybeString}".' ) @@ -1572,8 +1595,8 @@ def exec_trace_unit_in_simulation_check_stack_scratch( ]["exec-trace"] assert traces - for i in range(1, len(group_path)): - traces = traces["inner-trace"][group_path[i]] + for p in group_path[1:]: + traces = traces["inner-trace"][p] assert traces trace = [] @@ -1589,23 +1612,6 @@ def exec_trace_unit_in_simulation_check_stack_scratch( unit_index = int(unit_index) unit = trace[unit_index] - def compare_avm_value_with_string_literal(string_literal, avm_value): - [avm_type, value] = string_literal.split(":") - assert avm_type in ["uint64", "bytes"] - assert "type" in avm_value - if avm_type == "uint64": - assert avm_value["type"] == 2 - if int(value) > 0: - assert avm_value["uint"] == int(value) - else: - assert "uint" not in avm_value - elif avm_value == "bytes": - assert avm_value["type"] == 1 - if len(value) > 0: - assert avm_value["bytes"] == base64.b64decode(bytearray(value)) - else: - assert "bytes" not in avm_value - pop_count = int(pop_count) if pop_count > 0: assert unit["stack-pop-count"] @@ -1636,6 +1642,220 @@ def compare_avm_value_with_string_literal(string_literal, avm_value): assert len(scratch_var) == 0 +@then('the current application initial "{state_type}" state should be empty.') +def current_app_initial_state_should_be_empty(context, state_type): + assert context.atomic_transaction_composer_return + assert context.atomic_transaction_composer_return.simulate_response + simulation_response = ( + context.atomic_transaction_composer_return.simulate_response + ) + + assert simulation_response["initial-states"] + app_initial_states = simulation_response["initial-states"][ + "app-initial-states" + ] + assert app_initial_states + + initial_app_state = None + found = False + for app_state in app_initial_states: + if app_state["id"] == context.current_application_id: + initial_app_state = app_state + found = True + break + assert found + if initial_app_state: + if state_type == "local": + assert "app-locals" not in initial_app_state + elif state_type == "global": + assert "app-globals" not in initial_app_state + elif state_type == "box": + assert "app-boxes" not in initial_app_state + else: + raise Exception(f"Unknown state type: {state_type}") + + +@then( + 'the current application initial "{state_type}" state should contain "{key_str}" with value "{value_str}".' +) +def current_app_initial_state_should_contain_key_value( + context, state_type, key_str, value_str +): + assert context.atomic_transaction_composer_return + assert context.atomic_transaction_composer_return.simulate_response + simulation_response = ( + context.atomic_transaction_composer_return.simulate_response + ) + + assert simulation_response["initial-states"] + app_initial_states = simulation_response["initial-states"][ + "app-initial-states" + ] + assert app_initial_states + + initial_app_state = None + for app_state in app_initial_states: + if app_state["id"] == context.current_application_id: + initial_app_state = app_state + break + assert initial_app_state is not None + kvs = None + if state_type == "local": + assert "app-locals" in initial_app_state + assert isinstance(initial_app_state["app-locals"], list) + assert len(initial_app_state["app-locals"]) == 1 + assert "account" in initial_app_state["app-locals"][0] + # TODO: verify account is an algorand address + assert "kvs" in initial_app_state["app-locals"][0] + assert isinstance(initial_app_state["app-locals"][0]["kvs"], list) + kvs = initial_app_state["app-locals"][0]["kvs"] + elif state_type == "global": + assert "app-globals" in initial_app_state + assert "account" not in initial_app_state["app-globals"] + assert "kvs" in initial_app_state["app-globals"] + assert isinstance(initial_app_state["app-globals"]["kvs"], list) + kvs = initial_app_state["app-globals"]["kvs"] + elif state_type == "box": + assert "app-boxes" in initial_app_state + assert "account" not in initial_app_state["app-boxes"] + assert "kvs" in initial_app_state["app-boxes"] + assert isinstance(initial_app_state["app-boxes"]["kvs"], list) + kvs = initial_app_state["app-boxes"]["kvs"] + else: + raise Exception(f"Unknown state type: {state_type}") + assert isinstance(kvs, list) + assert len(kvs) > 0 + + actual_value = None + b64_key = base64.b64encode(key_str.encode()).decode() + for kv in kvs: + assert "key" in kv + assert "value" in kv + if kv["key"] == b64_key: + actual_value = kv["value"] + break + assert actual_value is not None + compare_avm_value_with_string_literal(value_str, actual_value) + + +@then( + '{unit_index}th unit in the "{trace_type}" trace at txn-groups path "{txn_group_path}" should write to "{state_type}" state "{state_key}" with new value "{state_new_value}".' +) +def trace_unit_should_write_to_state_with_value( + context, + unit_index, + trace_type, + txn_group_path, + state_type, + state_key, + state_new_value, +): + def unit_finder( + simulation_response: dict, + txn_group_path: str, + trace_type: str, + unit_index: int, + ) -> dict: + txn_group_path_split = [ + int(p) for p in txn_group_path.split(",") if p != "" + ] + assert len(txn_group_path_split) > 0 + + traces = simulation_response["txn-groups"][0]["txn-results"][ + txn_group_path_split[0] + ]["exec-trace"] + assert traces + + for p in txn_group_path_split[1:]: + traces = traces["inner-trace"][p] + assert traces + + trace = None + if trace_type == "approval": + trace = traces["approval-program-trace"] + elif trace_type == "clearState": + trace = traces["clear-state-program-trace"] + elif trace_type == "logic": + trace = traces["logic-sig-trace"] + else: + raise Exception(f"Unknown trace type: {trace_type}") + + assert unit_index < len(trace) + return trace[unit_index] + + assert context.atomic_transaction_composer_return + assert context.atomic_transaction_composer_return.simulate_response + simulation_response = ( + context.atomic_transaction_composer_return.simulate_response + ) + + change_unit = unit_finder( + simulation_response, txn_group_path, trace_type, int(unit_index) + ) + assert change_unit["state-changes"] + assert len(change_unit["state-changes"]) == 1 + state_change = change_unit["state-changes"][0] + + if state_type == "global": + assert state_change["app-state-type"] == "g" + assert "account" not in state_change + elif state_type == "local": + assert state_change["app-state-type"] == "l" + assert "account" in state_change + # TODO: verify account is an algorand address + elif state_type == "box": + assert state_change["app-state-type"] == "b" + assert "account" not in state_change + else: + raise Exception(f"Unknown state type: {state_type}") + + assert state_change["operation"] == "w" + assert state_change["key"] == base64.b64encode(state_key.encode()).decode() + assert "new-value" in state_change + compare_avm_value_with_string_literal( + state_new_value, state_change["new-value"] + ) + + +@then( + '"{trace_type}" hash at txn-groups path "{txn_group_path}" should be "{b64_hash}".' +) +def program_hash_at_path_should_be( + context, trace_type, txn_group_path, b64_hash +): + assert context.atomic_transaction_composer_return + assert context.atomic_transaction_composer_return.simulate_response + simulation_response = ( + context.atomic_transaction_composer_return.simulate_response + ) + + txn_group_path_split = [ + int(p) for p in txn_group_path.split(",") if p != "" + ] + assert len(txn_group_path_split) > 0 + + traces = simulation_response["txn-groups"][0]["txn-results"][ + txn_group_path_split[0] + ]["exec-trace"] + assert traces + + for p in txn_group_path_split[1:]: + traces = traces["inner-trace"][p] + assert traces + + hash = None + if trace_type == "approval": + hash = traces["approval-program-hash"] + elif trace_type == "clearState": + hash = traces["clear-state-program-hash"] + elif trace_type == "logic": + hash = traces["logic-sig-hash"] + else: + raise Exception(f"Unknown trace type: {trace_type}") + + assert hash == b64_hash + + @when("we make a SetSyncRound call against round {round}") def set_sync_round_call(context, round): context.response = context.acl.set_sync_round(round) diff --git a/tests/unit.tags b/tests/unit.tags index a1e28d90..6cd21fe6 100644 --- a/tests/unit.tags +++ b/tests/unit.tags @@ -21,6 +21,7 @@ @unit.responses @unit.responses.231 @unit.responses.blocksummary +@unit.responses.minbalance @unit.responses.participationupdates @unit.responses.sync @unit.responses.timestamp diff --git a/tests/unit_tests/test_transaction.py b/tests/unit_tests/test_transaction.py index d1e7b12e..395b0a4c 100644 --- a/tests/unit_tests/test_transaction.py +++ b/tests/unit_tests/test_transaction.py @@ -391,6 +391,22 @@ def test_serialize_keyreg_online(self): print(encoding.msgpack_encode(signed_txn)) self.assertEqual(golden, encoding.msgpack_encode(signed_txn)) + # Test that raw bytes are also acceptable inputs for keys + + txn = transaction.KeyregTxn( + pk, + sp, + base64.b64decode(votepk), + base64.b64decode(selpk), + votefirst, + votelast, + votedilution, + sprfkey=base64.b64decode(sprfKey), + ) + signed_txn = txn.sign(sk) + print(encoding.msgpack_encode(signed_txn)) + self.assertEqual(golden, encoding.msgpack_encode(signed_txn)) + def test_serialize_keyreg_offline(self): mn = ( "awful drop leaf tennis indoor begin mandate discover uncle seven "