From ba95c335c3c57dfac21062300143d1ce0b3219fc Mon Sep 17 00:00:00 2001 From: Ben Guidarelli Date: Fri, 10 Mar 2023 20:18:43 -0500 Subject: [PATCH] New Feature: Adding methods to use the simulate endpoint (#420) * Adding methods to use the simulate endpoint * fix call to sim raw, add response format of msgpack * adding simulate to atc * lint * update names * passing msgpack in both response_format and format * tmp * Remove simulate response, dont bail on would_succeed false * grab the right field out of the txn results * docstrings * provide more information since we get it back from simulate anyway * adding cucumber tests * try to make sandbox run against experimental-api branch * revert env var change * remove extra kwarg * add new test paths, remove json, add docstring * Replace direct method access with method we've already got * adding check for group number * adding tests for empty signer simulation * fix return value for atc simulation * fix return value for atc simulation * Update .test-env * typed return val --------- Co-authored-by: algochoi <86622919+algochoi@users.noreply.github.com> Co-authored-by: Bob Broderick <118225939+bbroder-algo@users.noreply.github.com> --- .test-env | 2 +- algosdk/atomic_transaction_composer.py | 162 ++++++++++++++++++++++--- algosdk/v2client/algod.py | 41 +++++++ tests/integration.tags | 1 + tests/steps/application_v2_steps.py | 11 ++ tests/steps/other_v2_steps.py | 73 +++++++++++ 6 files changed, 272 insertions(+), 18 deletions(-) diff --git a/.test-env b/.test-env index df783a4f..14b0184f 100644 --- a/.test-env +++ b/.test-env @@ -1,6 +1,6 @@ # Configs for testing repo download: SDK_TESTING_URL="https://github.com/algorand/algorand-sdk-testing" -SDK_TESTING_BRANCH="master" +SDK_TESTING_BRANCH="simulate-endpoint-tests" SDK_TESTING_HARNESS="test-harness" INSTALL_ONLY=0 diff --git a/algosdk/atomic_transaction_composer.py b/algosdk/atomic_transaction_composer.py index 2a8131a7..b54604f0 100644 --- a/algosdk/atomic_transaction_composer.py +++ b/algosdk/atomic_transaction_composer.py @@ -479,6 +479,71 @@ 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: 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": @@ -517,20 +582,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 = [ + 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,21 +647,18 @@ 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, - ) + return method_results class TransactionSigner(ABC): @@ -601,6 +676,19 @@ def sign_transactions( pass +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 AccountTransactionSigner(TransactionSigner): """ Represents a Transaction Signer for an account that can sign transactions from an @@ -740,3 +828,43 @@ def __init__( 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 diff --git a/algosdk/v2client/algod.py b/algosdk/v2client/algod.py index 64dc94ae..15cf0403 100644 --- a/algosdk/v2client/algod.py +++ b/algosdk/v2client/algod.py @@ -505,6 +505,47 @@ 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, **kwargs): + """ + 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 + + 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, round_num): """ diff --git a/tests/integration.tags b/tests/integration.tags index 2ead9e95..989a7baf 100644 --- a/tests/integration.tags +++ b/tests/integration.tags @@ -14,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 a2edb601..c81224e9 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 * @@ -1425,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