Skip to content

Commit

Permalink
New Feature: Adding methods to use the simulate endpoint (#420)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: Bob Broderick <[email protected]>
  • Loading branch information
3 people authored Mar 11, 2023
1 parent d55b2c8 commit ba95c33
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 18 deletions.
2 changes: 1 addition & 1 deletion .test-env
Original file line number Diff line number Diff line change
@@ -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
Expand Down
162 changes: 145 additions & 17 deletions algosdk/atomic_transaction_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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
41 changes: 41 additions & 0 deletions algosdk/v2client/algod.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
1 change: 1 addition & 0 deletions tests/integration.tags
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
@rekey_v1
@send
@send.keyregtxn
@simulate
11 changes: 11 additions & 0 deletions tests/steps/application_v2_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
73 changes: 73 additions & 0 deletions tests/steps/other_v2_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down Expand Up @@ -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

0 comments on commit ba95c33

Please sign in to comment.