Repository: algorand/py-algorand-sdk

Issue:
Support foreign objects as ABI arguments
## Summary
This is a follow up to #247, which added support for invoking ABI methods.

In addition to standard ABI type arguments, the SDK should support the special foreign object argument types `account`, `asset`, and `application`, [as described in ARC-4](https://github.com/algorandfoundation/ARCs/blob/6a9f91aed8068bbace872483a64cbf22d0e1c975/ARCs/arc-0004.md#foreign-apps-asas-and-accounts).

## Scope
Extend the `AtomicTransactionComposer.add_method_call` function to support these new method argument types. At a high level it should:

1. Recognize when an argument is a special type or a standard ABI type. The special argument types are only valid as top-level arguments, so direct string comparison should be sufficient.
2. If a special type is present, insert the argument value into the appropriate foreign array, and use the array index of the value as the app call argument, treated as an ABI `uint8`.

Discussion:

Patch:
diff --git a/Dockerfile b/Dockerfile
index f445d57..60c0721 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,7 +7,7 @@ WORKDIR $HOME/py-algorand-sdk
 
 # SDK dependencies, and source version of behave with tag expression support
 RUN pip install . -q \
-    && pip install git+https://github.com/behave/behave -q
+    && pip install -r requirements.txt -q
 
 # Run integration tests
 CMD ["/bin/bash", "-c", "make unit && make integration"]
diff --git a/README.md b/README.md
index cca9687..44f67cd 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,14 @@ Alternatively, choose a [distribution file](https://pypi.org/project/py-algorand
 
 ## SDK Development
 
-Run tests with `make docker-test`
+Install dependencies
+* `pip install -r requirements.txt`
+
+Run tests
+* `make docker-test`
+
+Format code:
+* `black .`
 
 ## Quick start
 
diff --git a/algosdk/abi/__init__.py b/algosdk/abi/__init__.py
index c0cdd48..ee7b1f6 100644
--- a/algosdk/abi/__init__.py
+++ b/algosdk/abi/__init__.py
@@ -8,8 +8,14 @@ from algosdk.abi.string_type import StringType
 from algosdk.abi.array_dynamic_type import ArrayDynamicType
 from algosdk.abi.array_static_type import ArrayStaticType
 from algosdk.abi.tuple_type import TupleType
-from .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 d607a8d..b64fb65 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 @@ class 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 ff42d80..775bc8d 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 @@ class Interface:
         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 011310b..67d7bf3 100644
--- a/algosdk/abi/method.py
+++ b/algosdk/abi/method.py
@@ -6,17 +6,6 @@ from Cryptodome.Hash import SHA512
 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 @@ class Method:
         # 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 0000000..112fa1d
--- /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 0000000..02c9192
--- /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 9496231..df6095c 100644
--- a/algosdk/atomic_transaction_composer.py
+++ b/algosdk/atomic_transaction_composer.py
@@ -2,15 +2,17 @@ from abc import ABC, abstractmethod
 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 @@ class AtomicTransactionComposer:
     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 @@ class AtomicTransactionComposer:
                 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 @@ class AtomicTransactionComposer:
             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 @@ class AtomicTransactionComposer:
             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 @@ class AtomicTransactionComposer:
             )
 
         # 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 @@ class AtomicTransactionComposer:
             )
 
         self.submit(client)
+        self.status = AtomicTransactionComposerStatus.SUBMITTED
+
         resp = transaction.wait_for_confirmation(
             client, self.tx_ids[0], wait_rounds
         )
@@ -388,17 +517,24 @@ class AtomicTransactionComposer:
                 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/algosdk/future/transaction.py b/algosdk/future/transaction.py
index 3574a30..7a641fb 100644
--- a/algosdk/future/transaction.py
+++ b/algosdk/future/transaction.py
@@ -9,6 +9,7 @@ from .. import encoding
 from .. import error
 from .. import logic
 from .. import transaction
+from ..v2client import algod
 from nacl.signing import SigningKey, VerifyKey
 from nacl.exceptions import BadSignatureError
 
@@ -3010,7 +3011,9 @@ def assign_group_id(txns, address=None):
     return result
 
 
-def wait_for_confirmation(algod_client, txid, wait_rounds=0, **kwargs):
+def wait_for_confirmation(
+    algod_client: algod.AlgodClient, txid: str, wait_rounds: int = 0, **kwargs
+):
     """
     Block until a pending transaction is confirmed by the network.
 
@@ -3018,23 +3021,41 @@ def wait_for_confirmation(algod_client, txid, wait_rounds=0, **kwargs):
         algod_client (algod.AlgodClient): Instance of the `algod` client
         txid (str): transaction ID
         wait_rounds (int, optional): The number of rounds to block for before
-            exiting with an Exception. If not supplied, there is no timeout.
+            exiting with an Exception. If not supplied, this will be 1000.
     """
     last_round = algod_client.status()["last-round"]
     current_round = last_round + 1
 
+    if wait_rounds == 0:
+        wait_rounds = 1000
+
     while True:
         # Check that the `wait_rounds` has not passed
-        if wait_rounds > 0 and current_round > last_round + wait_rounds:
+        if current_round > last_round + wait_rounds:
             raise error.ConfirmationTimeoutError(
-                f"Wait for transaction id {txid} timed out"
+                "Wait for transaction id {} timed out".format(txid)
             )
 
-        tx_info = algod_client.pending_transaction_info(txid, **kwargs)
-
-        # The transaction has been confirmed
-        if "confirmed-round" in tx_info:
-            return tx_info
+        try:
+            tx_info = algod_client.pending_transaction_info(txid, **kwargs)
+
+            # The transaction has been rejected
+            if "pool-error" in tx_info and len(tx_info["pool-error"]) != 0:
+                raise error.TransactionRejectedError(
+                    "Transaction rejected: " + tx_info["pool-error"]
+                )
+
+            # The transaction has been confirmed
+            if (
+                "confirmed-round" in tx_info
+                and tx_info["confirmed-round"] != 0
+            ):
+                return tx_info
+        except error.AlgodHTTPError as e:
+            # Ignore HTTP errors from pending_transaction_info, since it may return 404 if the algod
+            # instance is behind a load balancer and the request goes to a different algod than the
+            # one we submitted the transaction to
+            pass
 
         # Wait until the block for the `current_round` is confirmed
         algod_client.status_after_block(current_round)
diff --git a/algosdk/v2client/algod.py b/algosdk/v2client/algod.py
index 17e28a5..44f1e03 100644
--- a/algosdk/v2client/algod.py
+++ b/algosdk/v2client/algod.py
@@ -55,7 +55,7 @@ class AlgodClient:
         Returns:
             dict: loaded from json response body
         """
-        header = {}
+        header = {"User-Agent": "py-algorand-sdk"}
 
         if self.headers:
             header.update(self.headers)
diff --git a/algosdk/v2client/indexer.py b/algosdk/v2client/indexer.py
index a5cc272..1e54ae2 100644
--- a/algosdk/v2client/indexer.py
+++ b/algosdk/v2client/indexer.py
@@ -47,7 +47,7 @@ class IndexerClient:
         Returns:
             dict: loaded from json response body
         """
-        header = {}
+        header = {"User-Agent": "py-algorand-sdk"}
 
         if self.headers:
             header.update(self.headers)
diff --git a/requirements.txt b/requirements.txt
index ff455fe..4b7551f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
 .
-black==21.9b0
\ No newline at end of file
+black==21.9b0
+git+https://github.com/behave/behave


Test patch:
diff --git a/test/steps/v2_steps.py b/test/steps/v2_steps.py
index bb3c8f4..f7b6fd9 100644
--- a/test/steps/v2_steps.py
+++ b/test/steps/v2_steps.py
@@ -5,6 +5,7 @@ import urllib
 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 f0fe5ed..183f46e 100644
--- a/test_unit.py
+++ b/test_unit.py
@@ -33,6 +33,7 @@ from algosdk.abi import (
     Method,
     Interface,
     Contract,
+    NetworkInfo,
 )
 from algosdk.future import template, transaction
 from algosdk.testing import dryrun
@@ -4227,6 +4228,19 @@ class TestABIInteraction(unittest.TestCase):
                 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 @@ class TestABIInteraction(unittest.TestCase):
             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"],