From 7c975a70b6b137e7c11561c1e1d127961eab66b5 Mon Sep 17 00:00:00 2001 From: algochoi <86622919+algochoi@users.noreply.github.com> Date: Tue, 28 Dec 2021 15:21:14 -0500 Subject: [PATCH] Support Foreign objects as ABI arguments and address ARC-4 changes (#251) * Start ABI JSON interaction * Add static annoation * Fix Method argument parsing * Add ABI Typing to Method arguments * [WIP] Add AtomicTransactionComposer build functions * [WIP] Sign and send atomic transaction groups * Add unit tests for object parsing * Clean up method calls * Address PR comments on JSON objects * Refactor ABI Type to ABIType so it can be exposed to outside world * Add cucumber steps for ABI tests and update existing implementation so it can pass these tests * Refactor TransactionSigner to Abstract class and merge signatures when signing * Update testing to reflect json unit tests and composer tests * Formatting and docstring fixes * Add foreign types for method arguments * Clean up imports * Fix unit test for appId * Add unit test for foreign array * Refactor some names and add txn as an arg type * Partially address PR comments * Fix encoding args for foreign types * Add some additional checks for safety * Fix a step so we check for empty string instead of None * Correct foreign app and account indices accounting for the implicit argument * Resolve formatting * Fix unit tests * Fix foreign objects to compact duplicates and special values * Refactor foreign objects, transactions, and address some new ABI changes * ABI composer modifications and test updates * Change Interface and Contract to newest ABI changes * Fix some integration tests for composer * Fix remaining composer tests * Formatting changes * Fix method json tests * Address PR Comments, clean up and refactor composer and contract * Create helper function for populating foreign objects * Change type hints on reference and transaction checks * Add generics and fix dictifying network info * Fix step for cucumber test contract parsing --- algosdk/abi/__init__.py | 12 +- algosdk/abi/contract.py | 56 +++++- algosdk/abi/interface.py | 17 +- algosdk/abi/method.py | 17 +- algosdk/abi/reference.py | 20 ++ algosdk/abi/transaction.py | 45 +++++ algosdk/atomic_transaction_composer.py | 228 +++++++++++++++++----- test/steps/v2_steps.py | 260 +++++++++++++++++++------ test_unit.py | 32 ++- 9 files changed, 554 insertions(+), 133 deletions(-) create mode 100644 algosdk/abi/reference.py create mode 100644 algosdk/abi/transaction.py diff --git a/algosdk/abi/__init__.py b/algosdk/abi/__init__.py index c0cdd4840..ee7b1f6a9 100644 --- a/algosdk/abi/__init__.py +++ b/algosdk/abi/__init__.py @@ -8,8 +8,14 @@ from algosdk.abi.array_dynamic_type import ArrayDynamicType from algosdk.abi.array_static_type import ArrayStaticType from algosdk.abi.tuple_type import TupleType -from .method import Method, Argument, Returns -from .interface import Interface -from .contract import Contract +from algosdk.abi.method import Method, Argument, Returns +from algosdk.abi.interface import Interface +from algosdk.abi.contract import Contract, NetworkInfo +from algosdk.abi.transaction import ( + ABITransactionType, + is_abi_transaction_type, + check_abi_transaction_type, +) +from algosdk.abi.reference import ABIReferenceType, is_abi_reference_type name = "abi" diff --git a/algosdk/abi/contract.py b/algosdk/abi/contract.py index d607a8d8e..b64fb652c 100644 --- a/algosdk/abi/contract.py +++ b/algosdk/abi/contract.py @@ -1,5 +1,5 @@ import json -from typing import List, Union +from typing import Dict, List, Union from algosdk.abi.method import Method @@ -10,22 +10,32 @@ class Contract: Args: name (string): name of the contract - app_id (int): application id associated with the contract methods (list): list of Method objects + desc (string, optional): description of the contract + networks (dict, optional): information about the contract in a + particular network, such as an app-id. """ - def __init__(self, name: str, app_id: int, methods: List[Method]) -> None: + def __init__( + self, + name: str, + methods: List[Method], + desc: str = None, + networks: Dict[str, "NetworkInfo"] = None, + ) -> None: self.name = name - self.app_id = int(app_id) self.methods = methods + self.desc = desc + self.networks = networks if networks else {} def __eq__(self, o: object) -> bool: if not isinstance(o, Contract): return False return ( self.name == o.name - and self.app_id == o.app_id and self.methods == o.methods + and self.desc == o.desc + and self.networks == o.networks ) @staticmethod @@ -36,13 +46,43 @@ def from_json(resp: Union[str, bytes, bytearray]) -> "Contract": def dictify(self) -> dict: d = {} d["name"] = self.name - d["appId"] = self.app_id d["methods"] = [m.dictify() for m in self.methods] + d["desc"] = self.desc + d["networks"] = {k: v.dictify() for k, v in self.networks.items()} return d @staticmethod def undictify(d: dict) -> "Contract": name = d["name"] - app_id = d["appId"] method_list = [Method.undictify(method) for method in d["methods"]] - return Contract(name=name, app_id=app_id, methods=method_list) + desc = d["desc"] if "desc" in d else None + networks = d["networks"] if "networks" in d else {} + for k, v in networks.items(): + networks[k] = NetworkInfo.undictify(v) + return Contract( + name=name, desc=desc, networks=networks, methods=method_list + ) + + +class NetworkInfo: + """ + Represents network information. + + Args: + app_id (int): application ID on a particular network + """ + + def __init__(self, app_id: int) -> None: + self.app_id = app_id + + def __eq__(self, o: object) -> bool: + if not isinstance(o, NetworkInfo): + return False + return self.app_id == o.app_id + + def dictify(self) -> dict: + return {"appID": self.app_id} + + @staticmethod + def undictify(d: dict) -> "NetworkInfo": + return NetworkInfo(app_id=d["appID"]) diff --git a/algosdk/abi/interface.py b/algosdk/abi/interface.py index ff42d8093..775bc8df5 100644 --- a/algosdk/abi/interface.py +++ b/algosdk/abi/interface.py @@ -11,16 +11,24 @@ class Interface: Args: name (string): name of the interface methods (list): list of Method objects + desc (string, optional): description of the interface """ - def __init__(self, name: str, methods: List[Method]) -> None: + def __init__( + self, name: str, methods: List[Method], desc: str = None + ) -> None: self.name = name self.methods = methods + self.desc = desc def __eq__(self, o: object) -> bool: if not isinstance(o, Interface): return False - return self.name == o.name and self.methods == o.methods + return ( + self.name == o.name + and self.methods == o.methods + and self.desc == o.desc + ) @staticmethod def from_json(resp: Union[str, bytes, bytearray]) -> "Interface": @@ -31,10 +39,13 @@ def dictify(self) -> dict: d = {} d["name"] = self.name d["methods"] = [m.dictify() for m in self.methods] + if self.desc: + d["desc"] = self.desc return d @staticmethod def undictify(d: dict) -> "Interface": name = d["name"] method_list = [Method.undictify(method) for method in d["methods"]] - return Interface(name=name, methods=method_list) + desc = d["desc"] if "desc" in d else None + return Interface(name=name, desc=desc, methods=method_list) diff --git a/algosdk/abi/method.py b/algosdk/abi/method.py index 011310ba9..67d7bf359 100644 --- a/algosdk/abi/method.py +++ b/algosdk/abi/method.py @@ -6,17 +6,6 @@ from algosdk import abi, constants, error -TRANSACTION_ARGS = ( - "txn", # Denotes a placeholder for any of the six transaction types below - constants.PAYMENT_TXN, - constants.KEYREG_TXN, - constants.ASSETCONFIG_TXN, - constants.ASSETTRANSFER_TXN, - constants.ASSETFREEZE_TXN, - constants.APPCALL_TXN, -) - - class Method: """ Represents a ABI method description. @@ -44,7 +33,7 @@ def __init__( # add one for this method call itself. txn_count = 1 for arg in self.args: - if arg.type in TRANSACTION_ARGS: + if abi.is_abi_transaction_type(arg.type): txn_count += 1 self.txn_calls = txn_count @@ -150,7 +139,9 @@ class Argument: def __init__( self, arg_type: str, name: str = None, desc: str = None ) -> None: - if arg_type in TRANSACTION_ARGS: + if abi.is_abi_transaction_type(arg_type) or abi.is_abi_reference_type( + arg_type + ): self.type = arg_type else: # If the type cannot be parsed into an ABI type, it will error diff --git a/algosdk/abi/reference.py b/algosdk/abi/reference.py new file mode 100644 index 000000000..112fa1db8 --- /dev/null +++ b/algosdk/abi/reference.py @@ -0,0 +1,20 @@ +from typing import Any + + +class ABIReferenceType: + # Account reference type + ACCOUNT = "account" + + # Application reference type + APPLICATION = "application" + + # Asset reference type + ASSET = "asset" + + +def is_abi_reference_type(t: Any) -> bool: + return t in ( + ABIReferenceType.ACCOUNT, + ABIReferenceType.APPLICATION, + ABIReferenceType.ASSET, + ) diff --git a/algosdk/abi/transaction.py b/algosdk/abi/transaction.py new file mode 100644 index 000000000..02c919215 --- /dev/null +++ b/algosdk/abi/transaction.py @@ -0,0 +1,45 @@ +from typing import Any + +from algosdk import constants +from algosdk.future.transaction import Transaction + + +class ABITransactionType: + # Any transaction type + ANY = "txn" + + # Payment transaction type + PAY = constants.PAYMENT_TXN + + # Key registration transaction type + KEYREG = constants.KEYREG_TXN + + # Asset configuration transaction type + ACFG = constants.ASSETCONFIG_TXN + + # Asset transfer transaction type + AXFER = constants.ASSETTRANSFER_TXN + + # Asset freeze transaction type + AFRZ = constants.ASSETFREEZE_TXN + + # Application transaction type + APPL = constants.APPCALL_TXN + + +def is_abi_transaction_type(t: Any) -> bool: + return t in ( + ABITransactionType.ANY, + ABITransactionType.PAY, + ABITransactionType.KEYREG, + ABITransactionType.ACFG, + ABITransactionType.AXFER, + ABITransactionType.AFRZ, + ABITransactionType.APPL, + ) + + +def check_abi_transaction_type(t: Any, txn: Transaction) -> bool: + if t == ABITransactionType.ANY: + return True + return txn.type and txn.type == t diff --git a/algosdk/atomic_transaction_composer.py b/algosdk/atomic_transaction_composer.py index 9496231a4..df6095cf3 100644 --- a/algosdk/atomic_transaction_composer.py +++ b/algosdk/atomic_transaction_composer.py @@ -2,15 +2,17 @@ import base64 import copy from enum import IntEnum -from typing import Any, List, Union +from typing import Any, List, TypeVar, Union from algosdk import abi, error -from algosdk.abi.base_type import ABIType +from algosdk.abi.address_type import AddressType from algosdk.future import transaction from algosdk.v2client import algod # The first four bytes of an ABI method call return must have this hash ABI_RETURN_HASH = b"\x15\x1f\x7c\x75" +# Support for generic typing +T = TypeVar("T") class AtomicTransactionComposerStatus(IntEnum): @@ -34,6 +36,38 @@ class AtomicTransactionComposerStatus(IntEnum): COMMITTED = 4 +def populate_foreign_array( + value_to_add: T, foreign_array: List[T], zero_value: T = None +) -> int: + """ + Add a value to an application call's foreign array. The addition will be as + compact as possible, and this function will return an index used to + reference `value_to_add` in the `foreign_array`. + + Args: + value_to_add: value to add to the array. If the value is already + present, it will not be added again. Instead, the existing index + will be returned. + foreign_array: the existing foreign array. This input may be modified + to append `value_to_add`. + zero_value: If provided, this value indicates two things: the 0 value is + reserved for this array so `foreign_array` must start at index 1; + additionally, if `value_to_add` equals `zero_value`, then + `value_to_add` will not be added to the array and the 0 index will + be returned. + """ + if zero_value and value_to_add == zero_value: + return 0 + + offset = 0 if not zero_value else 1 + + if value_to_add in foreign_array: + return foreign_array.index(value_to_add) + offset + + foreign_array.append(value_to_add) + return offset + len(foreign_array) - 1 + + class AtomicTransactionComposer: """ Constructs an atomic transaction group which may contain a combination of @@ -50,7 +84,7 @@ class AtomicTransactionComposer: # The maximum size of an atomic transaction group. MAX_GROUP_SIZE = 16 # The maximum number of app-args that can be individually packed for ABIs - MAX_ABI_APP_ARG_LIMIT = 14 + MAX_APP_ARG_LIMIT = 16 def __init__(self) -> None: self.status = AtomicTransactionComposerStatus.BUILDING @@ -122,12 +156,20 @@ def add_transaction( def add_method_call( self, app_id: int, - method: abi.method.Method, + method: abi.Method, sender: str, sp: transaction.SuggestedParams, signer: "TransactionSigner", - method_args: List[Union[Any]] = None, + method_args: List[Union[Any, "TransactionWithSigner"]] = None, on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, + local_schema: transaction.StateSchema = None, + global_schema: transaction.StateSchema = None, + approval_program: bytes = None, + clear_program: bytes = None, + extra_pages: int = None, + accounts: List[str] = None, + foreign_apps: List[int] = None, + foreign_assets: List[int] = None, note: bytes = None, lease: bytes = None, rekey_to: str = None, @@ -150,6 +192,19 @@ def add_method_call( or transactions that immediate precede this method call on_complete (OnComplete, optional): intEnum representing what app should do on completion and if blank, it will default to a NoOp call + local_schema (StateSchema, optional): restricts what can be stored by created application; + must be omitted if not creating an application + global_schema (StateSchema, optional): restricts what can be stored by created application; + must be omitted if not creating an application + approval_program (bytes, optional): the program to run on transaction approval; + must be omitted if not creating or updating an application + clear_program (bytes, optional): the program to run when state is being cleared; + must be omitted if not creating or updating an application + extra_pages (int, optional): additional program space for supporting larger programs. + A page is 1024 bytes. + accounts (list[string], optional): list of additional accounts involved in call + foreign_apps (list[int], optional): list of other applications (identified by index) involved in call + foreign_assets (list[int], optional): list of assets involved in call note (bytes, optional): arbitrary optional bytes lease (byte[32], optional): specifies a lease, and no other transaction with the same sender and lease can be confirmed in this @@ -165,56 +220,117 @@ def add_method_call( raise error.AtomicTransactionComposerError( "AtomicTransactionComposer cannot exceed MAX_GROUP_SIZE transactions" ) + if app_id == 0: + if not approval_program or not clear_program: + raise error.AtomicTransactionComposerError( + "One of the following required parameters for application creation is missing: approvalProgram, clearProgram" + ) + elif on_complete == transaction.OnComplete.UpdateApplicationOC: + if not approval_program or not clear_program: + raise error.AtomicTransactionComposerError( + "One of the following required parameters for OnApplicationComplete.UpdateApplicationOC is missing: approvalProgram, clearProgram" + ) + if local_schema or global_schema or extra_pages: + raise error.AtomicTransactionComposerError( + "One of the following application creation parameters were set on a non-creation call: numGlobalInts, numGlobalByteSlices, numLocalInts, numLocalByteSlices, extraPages" + ) + elif ( + approval_program + or clear_program + or local_schema + or global_schema + or extra_pages + ): + raise error.AtomicTransactionComposerError( + "One of the following application creation parameters were set on a non-creation call: approvalProgram, clearProgram, numGlobalInts, numGlobalByteSlices, numLocalInts, numLocalByteSlices, extraPages" + ) if not method_args: method_args = [] if len(method.args) != len(method_args): raise error.AtomicTransactionComposerError( "number of method arguments do not match the method signature" ) - if not isinstance(method, abi.method.Method): + if not isinstance(method, abi.Method): raise error.AtomicTransactionComposerError( "invalid Method object was passed into AtomicTransactionComposer" ) - if app_id == 0: - raise error.AtomicTransactionComposerError( - "application create call not supported" - ) + + # Initialize foreign object maps + accounts = accounts[:] if accounts else [] + foreign_apps = foreign_apps[:] if foreign_apps else [] + foreign_assets = foreign_assets[:] if foreign_assets else [] app_args = [] - # For more than 14 args, including the selector, compact them into a tuple - additional_args = [] - additional_types = [] + raw_values = [] + raw_types = [] txn_list = [] + # First app arg must be the selector of the method app_args.append(method.get_selector()) + # Iterate through the method arguments and either pack a transaction # or encode a ABI value. for i, arg in enumerate(method.args): - if arg.type in abi.method.TRANSACTION_ARGS: - if not isinstance(method_args[i], TransactionWithSigner): + if abi.is_abi_transaction_type(arg.type): + if not isinstance( + method_args[i], TransactionWithSigner + ) or not abi.check_abi_transaction_type( + arg.type, method_args[i].txn + ): raise error.AtomicTransactionComposerError( "expected TransactionWithSigner as method argument, but received: {}".format( method_args[i] ) ) - if method_args[i].txn.group and method_args[i].txn.group != 0: - raise error.AtomicTransactionComposerError( - "cannot add a transaction with nonzero group ID" - ) txn_list.append(method_args[i]) - elif len(app_args) > self.MAX_ABI_APP_ARG_LIMIT: - # Pack the remaining values as a tuple - additional_types.append(arg.type) - additional_args.append(method_args[i]) else: - encoded_arg = arg.type.encode(method_args[i]) - app_args.append(encoded_arg) - - if additional_args: - remainder_args = abi.TupleType(additional_types).encode( - additional_args - ) - app_args.append(remainder_args) + if abi.is_abi_reference_type(arg.type): + current_type = abi.UintType(8) + if arg.type == abi.ABIReferenceType.ACCOUNT: + address_type = AddressType() + account_arg = address_type.decode( + address_type.encode(method_args[i]) + ) + current_arg = populate_foreign_array( + account_arg, accounts, sender + ) + elif arg.type == abi.ABIReferenceType.ASSET: + asset_arg = int(method_args[i]) + current_arg = populate_foreign_array( + asset_arg, foreign_assets + ) + elif arg.type == abi.ABIReferenceType.APPLICATION: + app_arg = int(method_args[i]) + current_arg = populate_foreign_array( + app_arg, foreign_apps, app_id + ) + else: + # Shouldn't reach this line unless someone accidentally + # adds another foreign array arg + raise error.AtomicTransactionComposerError( + "cannot recognize {} as a foreign array arg".format( + arg.type + ) + ) + else: + current_type = arg.type + current_arg = method_args[i] + + raw_types.append(current_type) + raw_values.append(current_arg) + + # Compact the arguments into a single tuple, if there are more than + # 15 arguments excluding the selector, into the last app arg slot. + if len(raw_types) > self.MAX_APP_ARG_LIMIT - 1: + additional_types = raw_types[self.MAX_APP_ARG_LIMIT - 2 :] + additional_values = raw_values[self.MAX_APP_ARG_LIMIT - 2 :] + raw_types = raw_types[: self.MAX_APP_ARG_LIMIT - 2] + raw_values = raw_values[: self.MAX_APP_ARG_LIMIT - 2] + raw_types.append(abi.TupleType(additional_types)) + raw_values.append(additional_values) + + for i, arg_type in enumerate(raw_types): + app_args.append(arg_type.encode(raw_values[i])) # Create a method call transaction method_txn = transaction.ApplicationCallTxn( @@ -222,10 +338,18 @@ def add_method_call( sp=sp, index=app_id, on_complete=on_complete, + local_schema=local_schema, + global_schema=global_schema, + approval_program=approval_program, + clear_program=clear_program, app_args=app_args, + accounts=accounts, + foreign_apps=foreign_apps, + foreign_assets=foreign_assets, note=note, lease=lease, rekey_to=rekey_to, + extra_pages=extra_pages, ) txn_with_signer = TransactionWithSigner(method_txn, signer) txn_list.append(txn_with_signer) @@ -250,11 +374,14 @@ def build_group(self) -> list: ) # Get group transaction id - group_txns = [t.txn for t in self.txn_list] - group_id = transaction.calculate_group_id(group_txns) - for t in self.txn_list: - t.txn.group = group_id - self.tx_ids.append(t.txn.get_txid()) + if len(self.txn_list) > 1: + group_txns = [t.txn for t in self.txn_list] + group_id = transaction.calculate_group_id(group_txns) + for t in self.txn_list: + t.txn.group = group_id + self.tx_ids.append(t.txn.get_txid()) + else: + self.tx_ids.append(self.txn_list[0].txn.get_txid()) self.status = AtomicTransactionComposerStatus.BUILT return self.txn_list @@ -354,6 +481,8 @@ def execute( ) self.submit(client) + self.status = AtomicTransactionComposerStatus.SUBMITTED + resp = transaction.wait_for_confirmation( client, self.tx_ids[0], wait_rounds ) @@ -388,17 +517,24 @@ def execute( logs = resp["logs"] if "logs" in resp else [] # Look for the last returned value in the log - for result in reversed(logs): - # Check that the first four bytes is the hash of "return" - result_bytes = base64.b64decode(result) - if result_bytes[:4] != ABI_RETURN_HASH: - continue - raw_value = result_bytes[4:] - return_value = self.method_dict[i].returns.type.decode( - raw_value + if not logs: + raise error.AtomicTransactionComposerError( + "app call transaction did not log a return value" + ) + result = logs[-1] + # Check that the first four bytes is the hash of "return" + result_bytes = base64.b64decode(result) + if ( + len(result_bytes) < 4 + or result_bytes[:4] != ABI_RETURN_HASH + ): + raise error.AtomicTransactionComposerError( + "app call transaction did not log a return value" ) - decode_error = None - break + raw_value = result_bytes[4:] + return_value = self.method_dict[i].returns.type.decode( + raw_value + ) except Exception as e: decode_error = e diff --git a/test/steps/v2_steps.py b/test/steps/v2_steps.py index bb3c8f445..f7b6fd967 100644 --- a/test/steps/v2_steps.py +++ b/test/steps/v2_steps.py @@ -5,6 +5,7 @@ import unittest from datetime import datetime from urllib.request import Request, urlopen +from algosdk.abi.contract import NetworkInfo import parse from behave import ( @@ -1718,7 +1719,7 @@ def split_and_process_app_args(in_args): return app_args -@when( +@step( 'I build a payment transaction with sender "{sender:MaybeString}", receiver "{receiver:MaybeString}", amount {amount}, close remainder to "{close_remainder_to:MaybeString}"' ) def build_payment_transaction( @@ -1805,18 +1806,12 @@ def build_app_transaction( ] if genesis_hash == "none": genesis_hash = None - if int(local_ints) == 0 and int(local_bytes) == 0: - local_schema = None - else: - local_schema = transaction.StateSchema( - num_uints=int(local_ints), num_byte_slices=int(local_bytes) - ) - if int(global_ints) == 0 and int(global_bytes) == 0: - global_schema = None - else: - global_schema = transaction.StateSchema( - num_uints=int(global_ints), num_byte_slices=int(global_bytes) - ) + local_schema = transaction.StateSchema( + num_uints=int(local_ints), num_byte_slices=int(local_bytes) + ) + global_schema = transaction.StateSchema( + num_uints=int(global_ints), num_byte_slices=int(global_bytes) + ) sp = transaction.SuggestedParams( int(fee), int(first_valid), @@ -1921,9 +1916,16 @@ def build_app_txn_with_transient( app_accounts, extra_pages, ): + application_id = 0 if operation == "none": operation = None else: + if ( + hasattr(context, "current_application_id") + and context.current_application_id + and operation != "create" + ): + application_id = context.current_application_id operation = operation_string_to_enum(operation) dir_path = os.path.dirname(os.path.realpath(__file__)) dir_path = os.path.dirname(os.path.dirname(dir_path)) @@ -1941,18 +1943,12 @@ def build_app_txn_with_transient( dir_path + "/test/features/resources/" + clear_program, "rb" ) as f: clear_program = bytearray(f.read()) - if int(local_ints) == 0 and int(local_bytes) == 0: - local_schema = None - else: - local_schema = transaction.StateSchema( - num_uints=int(local_ints), num_byte_slices=int(local_bytes) - ) - if int(global_ints) == 0 and int(global_bytes) == 0: - global_schema = None - else: - global_schema = transaction.StateSchema( - num_uints=int(global_ints), num_byte_slices=int(global_bytes) - ) + local_schema = transaction.StateSchema( + num_uints=int(local_ints), num_byte_slices=int(local_bytes) + ) + global_schema = transaction.StateSchema( + num_uints=int(global_ints), num_byte_slices=int(global_bytes) + ) if app_args == "none": app_args = None elif app_args: @@ -1971,12 +1967,7 @@ def build_app_txn_with_transient( app_accounts = [ account_pubkey for account_pubkey in app_accounts.split(",") ] - application_id = 0 - if ( - hasattr(context, "current_application_id") - and context.current_application_id - ): - application_id = context.current_application_id + sp = context.app_acl.suggested_params() context.app_transaction = transaction.ApplicationCallTxn( sender=context.transient_pk, @@ -2355,6 +2346,15 @@ def create_atomic_transaction_composer(context): context.atomic_transaction_composer = ( atomic_transaction_composer.AtomicTransactionComposer() ) + context.method_list = [] + + +@given("I make a transaction signer for the transient account.") +def create_transient_transaction_signer(context): + private_key = context.transient_sk + context.transaction_signer = ( + atomic_transaction_composer.AccountTransactionSigner(private_key) + ) @when("I make a transaction signer for the {account_type} account.") @@ -2372,12 +2372,15 @@ def create_transaction_signer(context, account_type): ) -@when('I create the Method object from method signature "{method_signature}"') +@step('I create the Method object from method signature "{method_signature}"') def build_abi_method(context, method_signature): context.abi_method = abi.Method.from_signature(method_signature) + if not hasattr(context, "method_list"): + context.method_list = [] + context.method_list.append(context.abi_method) -@when("I create a transaction with signer with the current transaction.") +@step("I create a transaction with signer with the current transaction.") def create_transaction_with_signer(context): context.transaction_with_signer = ( atomic_transaction_composer.TransactionWithSigner( @@ -2393,41 +2396,56 @@ def add_transaction_to_composer(context): ) -def split_and_process_abi_args(method, arg_string): +def process_abi_args(method, arg_tokens): method_args = [] - arg_tokens = arg_string.split(",") - arg_index = 0 - for arg in method.args: + for arg_index, arg in enumerate(method.args): # Skip arg if it does not have a type if isinstance(arg.type, abi.ABIType): - arg = arg.type.decode(base64.b64decode(arg_tokens[arg_index])) - method_args.append(arg) - arg_index += 1 + method_arg = arg.type.decode( + base64.b64decode(arg_tokens[arg_index]) + ) + method_args.append(method_arg) + elif arg.type == abi.ABIReferenceType.ACCOUNT: + method_arg = abi.AddressType().decode( + base64.b64decode(arg_tokens[arg_index]) + ) + method_args.append(method_arg) + elif ( + arg.type == abi.ABIReferenceType.APPLICATION + or arg.type == abi.ABIReferenceType.ASSET + ): + method_arg = abi.UintType(64).decode( + base64.b64decode(arg_tokens[arg_index]) + ) + method_args.append(method_arg) + else: + # Append the transaction signer as is + method_args.append(arg_tokens[arg_index]) return method_args -@when("I create a new method arguments array.") +@step("I create a new method arguments array.") def create_abi_method_args(context): context.method_args = [] -@when( +@step( "I append the current transaction with signer to the method arguments array." ) def append_txn_to_method_args(context): context.method_args.append(context.transaction_with_signer) -@when( +@step( 'I append the encoded arguments "{method_args:MaybeString}" to the method arguments array.' ) def append_app_args_to_method_args(context, method_args): # Returns a list of ABI method arguments - app_args = split_and_process_abi_args(context.abi_method, method_args) + app_args = method_args.split(",") context.method_args += app_args -@when( +@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.' ) def add_abi_method_call(context, account_type, operation): @@ -2439,24 +2457,145 @@ def add_abi_method_call(context, account_type, operation): raise NotImplementedError( "cannot make transaction signer for " + account_type ) + app_args = process_abi_args(context.abi_method, context.method_args) + context.atomic_transaction_composer.add_method_call( + app_id=int(context.current_application_id), + method=context.abi_method, + sender=sender, + sp=context.suggested_params, + signer=context.transaction_signer, + method_args=app_args, + on_complete=operation_string_to_enum(operation), + ) + + +@when( + 'I add a method call with the {account_type} account, the current application, suggested params, on complete "{operation}", current transaction signer, current method arguments, approval-program "{approval_program_path:MaybeString}", clear-program "{clear_program_path:MaybeString}", global-bytes {global_bytes}, global-ints {global_ints}, local-bytes {local_bytes}, local-ints {local_ints}, extra-pages {extra_pages}.' +) +def add_abi_method_call_creation( + context, + account_type, + operation, + approval_program_path, + clear_program_path, + global_bytes, + global_ints, + local_bytes, + local_ints, + extra_pages, +): + if account_type == "transient": + sender = context.transient_pk + elif account_type == "signing": + sender = mnemonic.to_public_key(context.signing_mnemonic) + else: + raise NotImplementedError( + "cannot make transaction signer for " + account_type + ) + dir_path = os.path.dirname(os.path.realpath(__file__)) + dir_path = os.path.dirname(os.path.dirname(dir_path)) + if approval_program_path: + with open( + dir_path + "/test/features/resources/" + approval_program_path, + "rb", + ) as f: + approval_program = bytearray(f.read()) + else: + approval_program = None + if clear_program_path: + with open( + dir_path + "/test/features/resources/" + clear_program_path, "rb" + ) as f: + clear_program = bytearray(f.read()) + else: + clear_program = None + local_schema = transaction.StateSchema( + num_uints=int(local_ints), num_byte_slices=int(local_bytes) + ) + global_schema = transaction.StateSchema( + num_uints=int(global_ints), num_byte_slices=int(global_bytes) + ) + extra_pages = int(extra_pages) + app_args = process_abi_args(context.abi_method, context.method_args) context.atomic_transaction_composer.add_method_call( - app_id=context.current_application_id, + app_id=int(context.current_application_id), method=context.abi_method, sender=sender, sp=context.suggested_params, signer=context.transaction_signer, - method_args=context.method_args, + method_args=app_args, on_complete=operation_string_to_enum(operation), + local_schema=local_schema, + global_schema=global_schema, + approval_program=approval_program, + clear_program=clear_program, + extra_pages=extra_pages, ) @when( + 'I add a method call with the {account_type} account, the current application, suggested params, on complete "{operation}", current transaction signer, current method arguments, approval-program "{approval_program_path:MaybeString}", clear-program "{clear_program_path:MaybeString}".' +) +def add_abi_method_call_creation( + context, account_type, operation, approval_program_path, clear_program_path +): + if account_type == "transient": + sender = context.transient_pk + elif account_type == "signing": + sender = mnemonic.to_public_key(context.signing_mnemonic) + else: + raise NotImplementedError( + "cannot make transaction signer for " + account_type + ) + dir_path = os.path.dirname(os.path.realpath(__file__)) + dir_path = os.path.dirname(os.path.dirname(dir_path)) + if approval_program_path: + with open( + dir_path + "/test/features/resources/" + approval_program_path, + "rb", + ) as f: + approval_program = bytearray(f.read()) + else: + approval_program = None + if clear_program_path: + with open( + dir_path + "/test/features/resources/" + clear_program_path, "rb" + ) as f: + clear_program = bytearray(f.read()) + else: + clear_program = None + app_args = process_abi_args(context.abi_method, context.method_args) + context.atomic_transaction_composer.add_method_call( + app_id=int(context.current_application_id), + method=context.abi_method, + sender=sender, + sp=context.suggested_params, + signer=context.transaction_signer, + method_args=app_args, + on_complete=operation_string_to_enum(operation), + approval_program=approval_program, + clear_program=clear_program, + ) + + +@step( 'I build the transaction group with the composer. If there is an error it is "{error_string:MaybeString}".' ) def build_atomic_transaction_group(context, error_string): - # Error checking not yet implemented - assert not error_string - context.atomic_transaction_composer.build_group() + try: + context.atomic_transaction_composer.build_group() + except Exception as e: + if not error_string: + raise RuntimeError(f"Unexpected error for building composer {e}") + elif error_string == "zero group size error": + error_message = ( + "no transactions to build for AtomicTransactionComposer" + ) + assert error_message in str(e) + else: + raise NotImplemented( + f"Unknown error string for building composer: {error_string}" + ) def composer_status_string_to_enum(status): @@ -2533,7 +2672,7 @@ def check_atomic_transaction_composer_response(context, returns): assert result.decode_error is None continue expected_bytes = base64.b64decode(expected) - expected_value = context.abi_method.returns.type.decode( + expected_value = context.method_list[i].returns.type.decode( expected_bytes ) @@ -2639,11 +2778,11 @@ def check_method_selector(context, method_selector): @when( - 'I create an Interface object from the Method object with name "{interface_name}"' + 'I create an Interface object from the Method object with name "{interface_name}" and description "{description}"' ) -def create_interface_object(context, interface_name): +def create_interface_object(context, interface_name, description): context.abi_interface = abi.Interface( - name=interface_name, methods=[context.abi_method] + name=interface_name, desc=description, methods=[context.abi_method] ) @@ -2659,14 +2798,21 @@ def deserialize_json_to_interface(context): @when( - 'I create a Contract object from the Method object with name "{contract_name}" and appId {app_id}' + 'I create a Contract object from the Method object with name "{contract_name}" and description "{description}"' ) -def create_contract_object(context, contract_name, app_id): +def create_contract_object(context, contract_name, description): context.abi_contract = abi.Contract( - name=contract_name, app_id=app_id, methods=[context.abi_method] + name=contract_name, desc=description, methods=[context.abi_method] ) +@when('I set the Contract\'s appID to {app_id} for the network "{network_id}"') +def set_contract_networks(context, app_id, network_id): + if not context.abi_contract.networks: + context.abi_contract.networks = {} + context.abi_contract.networks[network_id] = NetworkInfo(int(app_id)) + + @when("I serialize the Contract object into json") def serialize_contract_to_json(context): context.json_output = context.abi_contract.dictify() diff --git a/test_unit.py b/test_unit.py index f0fe5ed50..183f46ee4 100644 --- a/test_unit.py +++ b/test_unit.py @@ -33,6 +33,7 @@ Method, Interface, Contract, + NetworkInfo, ) from algosdk.future import template, transaction from algosdk.testing import dryrun @@ -4227,6 +4228,19 @@ def test_method(self): ABIType.from_string("string"), 1, ), + ( + "foreigns(account,pay,asset,application,bool)void", + b"\xbf\xed\xf2\xc1", + [ + "account", + "pay", + "asset", + "application", + ABIType.from_string("bool"), + ], + "void", + 2, + ), ] for test_case in test_cases: @@ -4244,19 +4258,31 @@ def test_method(self): self.assertEqual(m.get_txn_calls(), test_case[4]) def test_interface(self): - test_json = '{"name": "Calculator","methods": [{ "name": "add", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' + test_json = '{"name": "Calculator","desc":"This is an example interface","methods": [{ "name": "add", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' i = Interface.from_json(test_json) self.assertEqual(i.name, "Calculator") + self.assertEqual(i.desc, "This is an example interface") self.assertEqual( [m.get_signature() for m in i.methods], ["add(uint64,uint64)void", "multiply(uint64,uint64)void"], ) def test_contract(self): - test_json = '{"name": "Calculator","appId": 3, "methods": [{ "name": "add", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' + test_json = '{"name": "Calculator","desc":"This is an example contract","networks":{"wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=":{"appID":1234},"SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=":{"appID":5678}}, "methods": [{ "name": "add", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' c = Contract.from_json(test_json) self.assertEqual(c.name, "Calculator") - self.assertEqual(c.app_id, 3) + self.assertEqual(c.desc, "This is an example contract") + self.assertEqual( + c.networks, + { + "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=": NetworkInfo( + app_id=1234 + ), + "SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=": NetworkInfo( + app_id=5678 + ), + }, + ) self.assertEqual( [m.get_signature() for m in c.methods], ["add(uint64,uint64)void", "multiply(uint64,uint64)void"],