Repository: algorand/py-algorand-sdk

Issue:
ABI Interaction - off-chain programs invoking on-chain contracts
As described in [this design document](https://gist.github.com/jasonpaulos/a810abe7e86d43840d14445718565a9a), we build support in the SDK for off-chain programs invoking on-chain contracts. 

Discussion:
tzaffi: Looks like you shouldn't follow my `f-string` or `@dataclass` suggestions since we are stuck on Python 3.5. I'll bring this up for discussion during standup.

Patch:
diff --git a/Makefile b/Makefile
index b3fdb78..b2c4123 100644
--- a/Makefile
+++ b/Makefile
@@ -1,8 +1,8 @@
 unit:
-	behave --tags="@unit.offline or @unit.algod or @unit.indexer or @unit.rekey or @unit.tealsign or @unit.dryrun or @unit.applications or @unit.responses or @unit.transactions or @unit.responses.231 or @unit.feetest or @unit.indexer.logs" test -f progress2
+	behave --tags="@unit.offline or @unit.algod or @unit.indexer or @unit.rekey or @unit.tealsign or @unit.dryrun or @unit.applications or @unit.responses or @unit.transactions or @unit.transactions.payment or @unit.responses.231 or @unit.feetest or @unit.indexer.logs or @unit.abijson or @unit.atomic_transaction_composer" test -f progress2
 
 integration:
-	behave --tags="@algod or @assets or @auction or @kmd or @send or @template or @indexer or @indexer.applications or @rekey or @compile or @dryrun or @dryrun.testing or @applications or @applications.verified or @indexer.231" test -f progress2
+	behave --tags="@algod or @assets or @auction or @kmd or @send or @template or @indexer or @indexer.applications or @rekey or @compile or @dryrun or @dryrun.testing or @applications or @applications.verified or @indexer.231 or @abi" test -f progress2
 
 docker-test:
 	./run_integration.sh
diff --git a/algosdk/abi/__init__.py b/algosdk/abi/__init__.py
index 161c0b9..02e78ad 100644
--- a/algosdk/abi/__init__.py
+++ b/algosdk/abi/__init__.py
@@ -1,6 +1,7 @@
 from .util import type_from_string
 from .uint_type import UintType
 from .ufixed_type import UfixedType
+from .base_type import ABIType
 from .bool_type import BoolType
 from .byte_type import ByteType
 from .address_type import AddressType
@@ -8,5 +9,8 @@ from .string_type import StringType
 from .array_dynamic_type import ArrayDynamicType
 from .array_static_type import ArrayStaticType
 from .tuple_type import TupleType
+from .method import Method, Argument, Returns
+from .interface import Interface
+from .contract import Contract
 
 name = "abi"
diff --git a/algosdk/abi/address_type.py b/algosdk/abi/address_type.py
index f51be89..a7c3d61 100644
--- a/algosdk/abi/address_type.py
+++ b/algosdk/abi/address_type.py
@@ -1,4 +1,4 @@
-from .base_type import Type
+from .base_type import ABIType
 from .byte_type import ByteType
 from .tuple_type import TupleType
 from .. import error
@@ -6,7 +6,7 @@ from .. import error
 from algosdk import encoding
 
 
-class AddressType(Type):
+class AddressType(ABIType):
     """
     Represents an Address ABI Type for encoding.
     """
diff --git a/algosdk/abi/array_dynamic_type.py b/algosdk/abi/array_dynamic_type.py
index 7482135..b92a837 100644
--- a/algosdk/abi/array_dynamic_type.py
+++ b/algosdk/abi/array_dynamic_type.py
@@ -1,10 +1,10 @@
-from .base_type import ABI_LENGTH_SIZE, Type
+from .base_type import ABI_LENGTH_SIZE, ABIType
 from .byte_type import ByteType
 from .tuple_type import TupleType
 from .. import error
 
 
-class ArrayDynamicType(Type):
+class ArrayDynamicType(ABIType):
     """
     Represents a ArrayDynamic ABI Type for encoding.
 
diff --git a/algosdk/abi/array_static_type.py b/algosdk/abi/array_static_type.py
index d9ebca9..4d935d2 100644
--- a/algosdk/abi/array_static_type.py
+++ b/algosdk/abi/array_static_type.py
@@ -1,13 +1,13 @@
 import math
 
-from .base_type import Type
+from .base_type import ABIType
 from .bool_type import BoolType
 from .byte_type import ByteType
 from .tuple_type import TupleType
 from .. import error
 
 
-class ArrayStaticType(Type):
+class ArrayStaticType(ABIType):
     """
     Represents a ArrayStatic ABI Type for encoding.
 
diff --git a/algosdk/abi/base_type.py b/algosdk/abi/base_type.py
index 54a5eec..0fdcede 100644
--- a/algosdk/abi/base_type.py
+++ b/algosdk/abi/base_type.py
@@ -1,11 +1,10 @@
 from abc import ABC, abstractmethod
-from enum import IntEnum
 
 # Globals
 ABI_LENGTH_SIZE = 2  # We use 2 bytes to encode the length of a dynamic element
 
 
-class Type(ABC):
+class ABIType(ABC):
     """
     Represents an ABI Type for encoding.
     """
@@ -45,7 +44,7 @@ class Type(ABC):
         pass
 
     @abstractmethod
-    def decode(self, value_string):
+    def decode(self, bytestring):
         """
         Deserialize the ABI type and value from a byte string using ABI encoding rules.
         """
diff --git a/algosdk/abi/bool_type.py b/algosdk/abi/bool_type.py
index 1f11ba6..a7b0a91 100644
--- a/algosdk/abi/bool_type.py
+++ b/algosdk/abi/bool_type.py
@@ -1,8 +1,8 @@
-from .base_type import Type
+from .base_type import ABIType
 from .. import error
 
 
-class BoolType(Type):
+class BoolType(ABIType):
     """
     Represents a Bool ABI Type for encoding.
     """
diff --git a/algosdk/abi/byte_type.py b/algosdk/abi/byte_type.py
index d980b07..ccca808 100644
--- a/algosdk/abi/byte_type.py
+++ b/algosdk/abi/byte_type.py
@@ -1,8 +1,8 @@
-from .base_type import Type
+from .base_type import ABIType
 from .. import error
 
 
-class ByteType(Type):
+class ByteType(ABIType):
     """
     Represents a Byte ABI Type for encoding.
     """
diff --git a/algosdk/abi/contract.py b/algosdk/abi/contract.py
new file mode 100644
index 0000000..054e3ee
--- /dev/null
+++ b/algosdk/abi/contract.py
@@ -0,0 +1,47 @@
+import json
+
+from algosdk.abi.method import Method
+
+
+class Contract:
+    """
+    Represents a ABI contract description.
+
+    Args:
+        name (string): name of the contract
+        app_id (int): application id associated with the contract
+        methods (list): list of Method objects
+    """
+
+    def __init__(self, name, app_id, methods) -> None:
+        self.name = name
+        self.app_id = int(app_id)
+        self.methods = methods
+
+    def __eq__(self, o) -> 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
+        )
+
+    @staticmethod
+    def from_json(resp):
+        d = json.loads(resp)
+        return Contract.undictify(d)
+
+    def dictify(self):
+        d = {}
+        d["name"] = self.name
+        d["appId"] = self.app_id
+        d["methods"] = [m.dictify() for m in self.methods]
+        return d
+
+    @staticmethod
+    def undictify(d):
+        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)
diff --git a/algosdk/abi/interface.py b/algosdk/abi/interface.py
new file mode 100644
index 0000000..64bccdf
--- /dev/null
+++ b/algosdk/abi/interface.py
@@ -0,0 +1,39 @@
+import json
+
+from algosdk.abi.method import Method
+
+
+class Interface:
+    """
+    Represents a ABI interface description.
+
+    Args:
+        name (string): name of the interface
+        methods (list): list of Method objects
+    """
+
+    def __init__(self, name, methods):
+        self.name = name
+        self.methods = methods
+
+    def __eq__(self, o):
+        if not isinstance(o, Interface):
+            return False
+        return self.name == o.name and self.methods == o.methods
+
+    @staticmethod
+    def from_json(resp):
+        d = json.loads(resp)
+        return Interface.undictify(d)
+
+    def dictify(self):
+        d = {}
+        d["name"] = self.name
+        d["methods"] = [m.dictify() for m in self.methods]
+        return d
+
+    @staticmethod
+    def undictify(d):
+        name = d["name"]
+        method_list = [Method.undictify(method) for method in d["methods"]]
+        return Interface(name=name, methods=method_list)
diff --git a/algosdk/abi/method.py b/algosdk/abi/method.py
new file mode 100644
index 0000000..1254a60
--- /dev/null
+++ b/algosdk/abi/method.py
@@ -0,0 +1,222 @@
+import json
+
+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.
+
+    Args:
+        name (string): name of the method
+        args (list): list of Argument objects with type, name, and optional
+        description
+        returns (Returns): a Returns object with a type and optional description
+        desc (string, optional): optional description of the method
+    """
+
+    def __init__(self, name, args, returns, desc=None) -> None:
+        self.name = name
+        self.args = args
+        self.desc = desc
+        self.returns = returns
+        # Calculate number of method calls passed in as arguments and
+        # add one for this method call itself.
+        txn_count = 1
+        for arg in self.args:
+            if arg.type in TRANSACTION_ARGS:
+                txn_count += 1
+        self.txn_calls = txn_count
+
+    def __eq__(self, o) -> bool:
+        if not isinstance(o, Method):
+            return False
+        return (
+            self.name == o.name
+            and self.args == o.args
+            and self.returns == o.returns
+            and self.desc == o.desc
+            and self.txn_calls == o.txn_calls
+        )
+
+    def get_signature(self):
+        arg_string = ",".join(str(arg.type) for arg in self.args)
+        ret_string = self.returns.type if self.returns else "void"
+        return "{}({}){}".format(self.name, arg_string, ret_string)
+
+    def get_selector(self):
+        """
+        Returns the ABI method signature, which is the first four bytes of the
+        SHA-512/256 hash of the method signature.
+
+        Returns:
+            bytes: first four bytes of the method signature hash
+        """
+        hash = SHA512.new(truncate="256")
+        hash.update(self.get_signature().encode("utf-8"))
+        return hash.digest()[:4]
+
+    def get_txn_calls(self):
+        """
+        Returns the number of transactions needed to invoke this ABI method.
+        """
+        return self.txn_calls
+
+    @staticmethod
+    def _parse_string(s):
+        # Parses a method signature into three tokens, returned as a list:
+        # e.g. 'a(b,c)d' -> ['a', 'b,c', 'd']
+        stack = []
+        for i, char in enumerate(s):
+            if char == "(":
+                stack.append(i)
+            elif char == ")":
+                if not stack:
+                    break
+                left_index = stack.pop()
+                if not stack:
+                    return (s[:left_index], s[left_index + 1 : i], s[i + 1 :])
+
+        raise error.ABIEncodingError(
+            "ABI method string has mismatched parentheses: {}".format(s)
+        )
+
+    @staticmethod
+    def from_json(resp):
+        method_dict = json.loads(resp)
+        return Method.undictify(method_dict)
+
+    @staticmethod
+    def from_signature(s):
+        # Split string into tokens around outer parentheses.
+        # The first token should always be the name of the method,
+        # the second token should be the arguments as a tuple,
+        # and the last token should be the return type (or void).
+        tokens = Method._parse_string(s)
+        argument_list = [
+            Argument(t) for t in abi.TupleType._parse_tuple(tokens[1])
+        ]
+        return_type = Returns(tokens[-1])
+        return Method(name=tokens[0], args=argument_list, returns=return_type)
+
+    def dictify(self):
+        d = {}
+        d["name"] = self.name
+        d["args"] = [arg.dictify() for arg in self.args]
+        if self.returns:
+            d["returns"] = self.returns.dictify()
+        if self.desc:
+            d["desc"] = self.desc
+        return d
+
+    @staticmethod
+    def undictify(d):
+        name = d["name"]
+        arg_list = [Argument.undictify(arg) for arg in d["args"]]
+        return_obj = (
+            Returns.undictify(d["returns"]) if "returns" in d else None
+        )
+        desc = d["desc"] if "desc" in d else None
+        return Method(name=name, args=arg_list, returns=return_obj, desc=desc)
+
+
+class Argument:
+    """
+    Represents an argument for a ABI method
+
+    Args:
+        arg_type (string): ABI type or transaction string of the method argument
+        name (string, optional): name of this method argument
+        desc (string, optional): description of this method argument
+    """
+
+    def __init__(self, arg_type, name=None, desc=None) -> None:
+        if arg_type in TRANSACTION_ARGS:
+            self.type = arg_type
+        else:
+            # If the type cannot be parsed into an ABI type, it will error
+            self.type = abi.util.type_from_string(arg_type)
+        self.name = name
+        self.desc = desc
+
+    def __eq__(self, o) -> bool:
+        if not isinstance(o, Argument):
+            return False
+        return (
+            self.name == o.name and self.type == o.type and self.desc == o.desc
+        )
+
+    def __str__(self):
+        return str(self.type)
+
+    def dictify(self):
+        d = {}
+        d["type"] = str(self.type)
+        if self.name:
+            d["name"] = self.name
+        if self.desc:
+            d["desc"] = self.desc
+        return d
+
+    @staticmethod
+    def undictify(d):
+        return Argument(
+            arg_type=d["type"],
+            name=d["name"] if "name" in d else None,
+            desc=d["desc"] if "desc" in d else None,
+        )
+
+
+class Returns:
+    """
+    Represents a return type for a ABI method
+
+    Args:
+        arg_type (string): ABI type of this return argument
+        desc (string, optional): description of this return argument
+    """
+
+    # Represents a void return.
+    VOID = "void"
+
+    def __init__(self, arg_type, desc=None) -> None:
+        if arg_type == "void":
+            self.type = self.VOID
+        else:
+            # If the type cannot be parsed into an ABI type, it will error.
+            self.type = abi.util.type_from_string(arg_type)
+        self.desc = desc
+
+    def __eq__(self, o) -> bool:
+        if not isinstance(o, Returns):
+            return False
+        return self.type == o.type and self.desc == o.desc
+
+    def __str__(self):
+        return str(self.type)
+
+    def dictify(self):
+        d = {}
+        d["type"] = str(self.type)
+        if self.desc:
+            d["desc"] = self.desc
+        return d
+
+    @staticmethod
+    def undictify(d):
+        return Returns(
+            arg_type=d["type"], desc=d["desc"] if "desc" in d else None
+        )
diff --git a/algosdk/abi/string_type.py b/algosdk/abi/string_type.py
index 64b0efa..b2c207d 100644
--- a/algosdk/abi/string_type.py
+++ b/algosdk/abi/string_type.py
@@ -1,8 +1,8 @@
-from .base_type import ABI_LENGTH_SIZE, Type
+from .base_type import ABI_LENGTH_SIZE, ABIType
 from .. import error
 
 
-class StringType(Type):
+class StringType(ABIType):
     """
     Represents a String ABI Type for encoding.
     """
diff --git a/algosdk/abi/tuple_type.py b/algosdk/abi/tuple_type.py
index 54fcec1..7ec5095 100644
--- a/algosdk/abi/tuple_type.py
+++ b/algosdk/abi/tuple_type.py
@@ -1,9 +1,9 @@
-from .base_type import ABI_LENGTH_SIZE, Type
+from .base_type import ABI_LENGTH_SIZE, ABIType
 from .bool_type import BoolType
 from .. import error
 
 
-class TupleType(Type):
+class TupleType(ABIType):
     """
     Represents a Tuple ABI Type for encoding.
 
diff --git a/algosdk/abi/ufixed_type.py b/algosdk/abi/ufixed_type.py
index 7bb531b..d86cfe3 100644
--- a/algosdk/abi/ufixed_type.py
+++ b/algosdk/abi/ufixed_type.py
@@ -1,8 +1,8 @@
-from .base_type import Type
+from .base_type import ABIType
 from .. import error
 
 
-class UfixedType(Type):
+class UfixedType(ABIType):
     """
     Represents an Ufixed ABI Type for encoding.
 
diff --git a/algosdk/abi/uint_type.py b/algosdk/abi/uint_type.py
index 42ce09a..b22c26c 100644
--- a/algosdk/abi/uint_type.py
+++ b/algosdk/abi/uint_type.py
@@ -1,8 +1,8 @@
-from .base_type import Type
+from .base_type import ABIType
 from .. import error
 
 
-class UintType(Type):
+class UintType(ABIType):
     """
     Represents an Uint ABI Type for encoding.
 
diff --git a/algosdk/atomic_transaction_composer.py b/algosdk/atomic_transaction_composer.py
new file mode 100644
index 0000000..156dee2
--- /dev/null
+++ b/algosdk/atomic_transaction_composer.py
@@ -0,0 +1,544 @@
+from abc import ABC, abstractmethod
+import base64
+import copy
+from enum import IntEnum
+
+from algosdk import abi, error
+from algosdk.future import transaction
+
+# The first four bytes of an ABI method call return must have this hash
+ABI_RETURN_HASH = b"\x15\x1f\x7c\x75"
+
+
+class AtomicTransactionComposerStatus(IntEnum):
+    # BUILDING indicates that the atomic group is still under construction
+    BUILDING = 0
+
+    # BUILT indicates that the atomic group has been finalized,
+    # but not yet signed.
+    BUILT = 1
+
+    # SIGNED indicates that the atomic group has been finalized and signed,
+    # but not yet submitted to the network.
+    SIGNED = 2
+
+    # SUBMITTED indicates that the atomic group has been finalized, signed,
+    # and submitted to the network.
+    SUBMITTED = 3
+
+    # COMMITTED indicates that the atomic group has been finalized, signed,
+    # submitted, and successfully committed to a block.
+    COMMITTED = 4
+
+
+class AtomicTransactionComposer:
+    """
+    Constructs an atomic transaction group which may contain a combination of
+    Transactions and ABI Method calls.
+
+    Args:
+        status (AtomicTransactionComposerStatus): IntEnum representing the current state of the composer
+        method_dict (dict): dictionary of an index in the transaction list to a Method object
+        txn_list (list[TransactionWithSigner]): list of transactions with signers
+        signed_txns (list[SignedTransaction]): list of signed transactions
+        tx_ids (list[str]): list of individual transaction IDs in this atomic group
+    """
+
+    # 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
+
+    def __init__(self) -> None:
+        self.status = AtomicTransactionComposerStatus.BUILDING
+        self.method_dict = {}
+        self.txn_list = []
+        self.signed_txns = []
+        self.tx_ids = []
+
+    def get_status(self):
+        """
+        Returns the status of this composer's transaction group.
+        """
+        return self.status
+
+    def get_tx_count(self):
+        """
+        Returns the number of transactions currently in this atomic group.
+        """
+        return len(self.txn_list)
+
+    def clone(self):
+        """
+        Creates a new composer with the same underlying transactions.
+        The new composer's status will be BUILDING, so additional transactions
+        may be added to it.
+        """
+        cloned = AtomicTransactionComposer()
+        cloned.method_dict = copy.deepcopy(self.method_dict)
+        cloned.txn_list = copy.deepcopy(self.txn_list)
+        for t in cloned.txn_list:
+            t.txn.group = None
+        cloned.status = AtomicTransactionComposerStatus.BUILDING
+        return cloned
+
+    def add_transaction(self, txn_and_signer):
+        """
+        Adds a transaction to this atomic group.
+
+        An error will be thrown if the composer's status is not BUILDING,
+        or if adding this transaction causes the current group to exceed
+        MAX_GROUP_SIZE.
+
+        Args:
+            txn_and_signer (TransactionWithSigner)
+        """
+        if self.status != AtomicTransactionComposerStatus.BUILDING:
+            raise error.AtomicTransactionComposerError(
+                "AtomicTransactionComposer must be in BUILDING state for a transaction to be added"
+            )
+        if len(self.txn_list) == self.MAX_GROUP_SIZE:
+            raise error.AtomicTransactionComposerError(
+                "AtomicTransactionComposer cannot exceed MAX_GROUP_SIZE {} transactions".format(
+                    self.MAX_GROUP_SIZE
+                )
+            )
+        if not isinstance(txn_and_signer, TransactionWithSigner):
+            raise error.AtomicTransactionComposerError(
+                "expected TransactionWithSigner object to the AtomicTransactionComposer"
+            )
+        if txn_and_signer.txn.group and txn_and_signer.txn.group != 0:
+            raise error.AtomicTransactionComposerError(
+                "cannot add a transaction with nonzero group ID"
+            )
+        self.txn_list.append(txn_and_signer)
+        return self
+
+    def add_method_call(
+        self,
+        app_id,
+        method,
+        sender,
+        sp,
+        signer,
+        method_args=None,
+        on_complete=transaction.OnComplete.NoOpOC,
+        note=None,
+        lease=None,
+        rekey_to=None,
+    ):
+        """
+        Add a smart contract method call to this atomic group.
+
+        An error will be thrown if the composer's status is not BUILDING,
+        if adding this transaction causes the current group to exceed
+        MAX_GROUP_SIZE, or if the provided arguments are invalid for
+        the given method.
+
+        Args:
+            app_id (int): application id of app that the method is being invoked on
+            method (Method): ABI method object with initialized arguments and return types
+            sender (str): address of the sender
+            sp (SuggestedParams): suggested params from algod
+            signer (TransactionSigner): signer that will sign the transactions
+            method_args (list[ABIValue | TransactionWithSigner], optional): list of arguments to be encoded
+                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
+            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
+                transaction's valid rounds
+            rekey_to (str, optional): additionally rekey the sender to this address
+
+        """
+        if self.status != AtomicTransactionComposerStatus.BUILDING:
+            raise error.AtomicTransactionComposerError(
+                "AtomicTransactionComposer must be in BUILDING state for a transaction to be added"
+            )
+        if len(self.txn_list) + method.get_txn_calls() > self.MAX_GROUP_SIZE:
+            raise error.AtomicTransactionComposerError(
+                "AtomicTransactionComposer cannot exceed MAX_GROUP_SIZE transactions"
+            )
+        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):
+            raise error.AtomicTransactionComposerError(
+                "invalid Method object was passed into AtomicTransactionComposer"
+            )
+        if app_id == 0:
+            raise error.AtomicTransactionComposerError(
+                "application create call not supported"
+            )
+
+        app_args = []
+        # For more than 14 args, including the selector, compact them into a tuple
+        additional_args = []
+        additional_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):
+                    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)
+
+        # Create a method call transaction
+        method_txn = transaction.ApplicationCallTxn(
+            sender=sender,
+            sp=sp,
+            index=app_id,
+            on_complete=on_complete,
+            app_args=app_args,
+            note=note,
+            lease=lease,
+            rekey_to=rekey_to,
+        )
+        txn_with_signer = TransactionWithSigner(method_txn, signer)
+        txn_list.append(txn_with_signer)
+
+        self.txn_list += txn_list
+        self.method_dict[len(self.txn_list) - 1] = method
+        return self
+
+    def build_group(self):
+        """
+        Finalize the transaction group and returns the finalized transactions with signers.
+        The composer's status will be at least BUILT after executing this method.
+
+        Returns:
+            list[TransactionWithSigner]: list of transactions with signers
+        """
+        if self.status >= AtomicTransactionComposerStatus.BUILT:
+            return self.txn_list
+        if not self.txn_list:
+            raise error.AtomicTransactionComposerError(
+                "no transactions to build for 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())
+
+        self.status = AtomicTransactionComposerStatus.BUILT
+        return self.txn_list
+
+    def gather_signatures(self):
+        """
+        Obtain signatures for each transaction in this group. If signatures have already been obtained,
+        this method will return cached versions of the signatures.
+        The composer's status will be at least SIGNED after executing this method.
+        An error will be thrown if signing any of the transactions fails.
+
+        Returns:
+            list[SignedTransactions]: list of signed transactions
+        """
+        if self.status >= AtomicTransactionComposerStatus.SIGNED:
+            # Return cached versions of the signatures
+            return self.signed_txns
+
+        stxn_list = [None] * len(self.txn_list)
+        signer_indexes = {}  # Map a signer to a list of indices to sign
+        txn_list = self.build_group()
+        for i, txn_with_signer in enumerate(txn_list):
+            if txn_with_signer.signer not in signer_indexes:
+                signer_indexes[txn_with_signer.signer] = []
+            signer_indexes[txn_with_signer.signer].append(i)
+
+        # Sign then merge the signed transactions in order
+        txns = [t.txn for t in self.txn_list]
+        for signer, indexes in signer_indexes.items():
+            stxns = signer.sign_transactions(txns, indexes)
+            for i, stxn in enumerate(stxns):
+                index = indexes[i]
+                stxn_list[index] = stxn
+
+        if None in stxn_list:
+            raise error.AtomicTransactionComposerError(
+                "missing signatures, got {}".format(stxn_list)
+            )
+
+        self.status = AtomicTransactionComposerStatus.SIGNED
+        self.signed_txns = stxn_list
+        return self.signed_txns
+
+    def submit(self, client):
+        """
+        Send the transaction group to the network, but don't wait for it to be
+        committed to a block. An error will be thrown if submission fails.
+        The composer's status must be SUBMITTED or lower before calling this method.
+        If submission is successful, this composer's status will update to SUBMITTED.
+
+        Note: a group can only be submitted again if it fails.
+
+        Args:
+            client (AlgodClient): Algod V2 client
+
+        Returns:
+            list[Transaction]: list of submitted transactions
+        """
+        if self.status <= AtomicTransactionComposerStatus.SUBMITTED:
+            self.gather_signatures()
+        else:
+            raise error.AtomicTransactionComposerError(
+                "AtomicTransactionComposerStatus must be submitted or lower to submit a group"
+            )
+
+        client.send_transactions(self.signed_txns)
+        self.status = AtomicTransactionComposerStatus.SUBMITTED
+        return self.tx_ids
+
+    def execute(self, client, wait_rounds):
+        """
+        Send the transaction group to the network and wait until it's committed
+        to a block. 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. If submission is successful,
+        this composer's status will update to SUBMITTED.
+        If the execution is also successful, this composer's status will update to COMMITTED.
+
+        Note: a group can only be submitted again if it fails.
+
+        Args:
+            client (AlgodClient): Algod V2 client
+            wait_rounds (int): maximum number of rounds to wait for transaction confirmation
+
+        Returns:
+            AtomicTransactionResponse: Object with confirmed round for this transaction,
+                a list of txIDs of the submitted transactions, and 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:
+            raise error.AtomicTransactionComposerError(
+                "AtomicTransactionComposerStatus must be submitted or lower to execute a group"
+            )
+
+        self.submit(client)
+        resp = transaction.wait_for_confirmation(
+            client, self.tx_ids[0], wait_rounds
+        )
+        self.status = AtomicTransactionComposerStatus.COMMITTED
+
+        confirmed_round = resp["confirmed-round"]
+        method_results = []
+
+        for i, tx_id in enumerate(self.tx_ids):
+            raw_value = None
+            return_value = None
+            decode_error = None
+
+            if i not in self.method_dict:
+                continue
+            # Return is void
+            if self.method_dict[i].returns.type == abi.Returns.VOID:
+                method_results.append(
+                    ABIResult(
+                        tx_id=tx_id,
+                        raw_value=raw_value,
+                        return_value=return_value,
+                        decode_error=decode_error,
+                    )
+                )
+                continue
+
+            # Parse log for ABI method return value
+            try:
+                resp = client.pending_transaction_info(tx_id)
+                confirmed_round = resp["confirmed-round"]
+                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
+                    )
+                    decode_error = None
+                    break
+            except Exception as e:
+                decode_error = e
+
+            abi_result = ABIResult(
+                tx_id=tx_id,
+                raw_value=raw_value,
+                return_value=return_value,
+                decode_error=decode_error,
+            )
+            method_results.append(abi_result)
+
+        return AtomicTransactionResponse(
+            confirmed_round=confirmed_round,
+            tx_ids=self.tx_ids,
+            results=method_results,
+        )
+
+
+class TransactionSigner(ABC):
+    """
+    Represents an object which can sign transactions from an atomic transaction group.
+    """
+
+    def __init__(self) -> None:
+        pass
+
+    @abstractmethod
+    def sign_transactions(self, txn_group, indexes):
+        pass
+
+
+class AccountTransactionSigner(TransactionSigner):
+    """
+    Represents a Transaction Signer for an account that can sign transactions from an
+    atomic transaction group.
+
+    Args:
+        private_key (str): private key of signing account
+    """
+
+    def __init__(self, private_key) -> None:
+        super().__init__()
+        self.private_key = private_key
+
+    def sign_transactions(self, txn_group, indexes):
+        """
+        Sign transactions in a transaction group given the indexes.
+
+        Returns an array of encoded signed transactions. The length of the
+        array will be the same as the length of indexesToSign, and each index i in the array
+        corresponds to the signed transaction from txnGroup[indexesToSign[i]].
+
+        Args:
+            txn_group (list[Transaction]): atomic group of transactions
+            indexes (list[int]): array of indexes in the atomic transaction group that should be signed
+        """
+        stxns = []
+        for i in indexes:
+            stxn = txn_group[i].sign(self.private_key)
+            stxns.append(stxn)
+        return stxns
+
+
+class LogicSigTransactionSigner(TransactionSigner):
+    """
+    Represents a Transaction Signer for a LogicSig that can sign transactions from an
+    atomic transaction group.
+
+    Args:
+        lsig (LogicSigAccount): LogicSig account
+    """
+
+    def __init__(self, lsig) -> None:
+        super().__init__()
+        self.lsig = lsig
+
+    def sign_transactions(self, txn_group, indexes):
+        """
+        Sign transactions in a transaction group given the indexes.
+
+        Returns an array of encoded signed transactions. The length of the
+        array will be the same as the length of indexesToSign, and each index i in the array
+        corresponds to the signed transaction from txnGroup[indexesToSign[i]].
+
+        Args:
+            txn_group (list[Transaction]): atomic group of transactions
+            indexes (list[int]): array of indexes in the atomic transaction group that should be signed
+        """
+        stxns = []
+        for i in indexes:
+            stxn = transaction.LogicSigTransaction(txn_group[i], self.lsig)
+            stxns.append(stxn)
+        return stxns
+
+
+class MultisigTransactionSigner(TransactionSigner):
+    """
+    Represents a Transaction Signer for a Multisig that can sign transactions from an
+    atomic transaction group.
+
+    Args:
+        msig (Multisig): Multisig account
+        sks (str): private keys of multisig
+    """
+
+    def __init__(self, msig, sks) -> None:
+        super().__init__()
+        self.msig = msig
+        self.sks = sks
+
+    def sign_transactions(self, txn_group, indexes):
+        """
+        Sign transactions in a transaction group given the indexes.
+
+        Returns an array of encoded signed transactions. The length of the
+        array will be the same as the length of indexesToSign, and each index i in the array
+        corresponds to the signed transaction from txnGroup[indexesToSign[i]].
+
+        Args:
+            txn_group (list[Transaction]): atomic group of transactions
+            indexes (list[int]): array of indexes in the atomic transaction group that should be signed
+        """
+        stxns = []
+        for i in indexes:
+            mtxn = transaction.MultisigTransaction(txn_group[i], self.msig)
+            for sk in self.sks:
+                mtxn.sign(sk)
+            stxns.append(mtxn)
+        return stxns
+
+
+class TransactionWithSigner:
+    def __init__(self, txn, signer) -> None:
+        self.txn = txn
+        self.signer = signer
+
+
+class ABIResult:
+    def __init__(self, tx_id, raw_value, return_value, decode_error) -> None:
+        self.tx_id = tx_id
+        self.raw_value = raw_value
+        self.return_value = return_value
+        self.decode_error = decode_error
+
+
+class AtomicTransactionResponse:
+    def __init__(self, confirmed_round, tx_ids, results) -> None:
+        self.confirmed_round = confirmed_round
+        self.tx_ids = tx_ids
+        self.abi_results = results
diff --git a/algosdk/error.py b/algosdk/error.py
index 0ca52c7..3158316 100644
--- a/algosdk/error.py
+++ b/algosdk/error.py
@@ -217,3 +217,8 @@ class ABITypeError(Exception):
 class ABIEncodingError(Exception):
     def __init__(self, msg):
         super().__init__(msg)
+
+
+class AtomicTransactionComposerError(Exception):
+    def __init__(self, msg):
+        super().__init__(msg)


Test patch:
diff --git a/test/steps/v2_steps.py b/test/steps/v2_steps.py
index 2e0306a..bb3c8f4 100644
--- a/test/steps/v2_steps.py
+++ b/test/steps/v2_steps.py
@@ -16,7 +16,14 @@ from behave import (
 )  # pylint: disable=no-name-in-module
 
 from algosdk.future import transaction
-from algosdk import account, encoding, error, mnemonic
+from algosdk import (
+    abi,
+    account,
+    atomic_transaction_composer,
+    encoding,
+    error,
+    mnemonic,
+)
 from algosdk.v2client import *
 from algosdk.v2client.models import (
     DryrunRequest,
@@ -1653,11 +1660,34 @@ def signing_account(context, address, mnemonic):
     context.signing_mnemonic = mnemonic
 
 
+@given(
+    'suggested transaction parameters fee {fee}, flat-fee "{flat_fee:MaybeBool}", first-valid {first_valid}, last-valid {last_valid}, genesis-hash "{genesis_hash}", genesis-id "{genesis_id}"'
+)
+def suggested_transaction_parameters(
+    context, fee, flat_fee, first_valid, last_valid, genesis_hash, genesis_id
+):
+    context.suggested_params = transaction.SuggestedParams(
+        fee=int(fee),
+        flat_fee=flat_fee,
+        first=int(first_valid),
+        last=int(last_valid),
+        gh=genesis_hash,
+        gen=genesis_id,
+    )
+
+
+@given("suggested transaction parameters from the algod v2 client")
+def get_sp_from_algod(context):
+    context.suggested_params = context.app_acl.suggested_params()
+
+
 def operation_string_to_enum(operation):
     if operation == "call":
         return transaction.OnComplete.NoOpOC
     elif operation == "create":
         return transaction.OnComplete.NoOpOC
+    elif operation == "noop":
+        return transaction.OnComplete.NoOpOC
     elif operation == "update":
         return transaction.OnComplete.UpdateApplicationOC
     elif operation == "optin":
@@ -1688,6 +1718,27 @@ def split_and_process_app_args(in_args):
     return app_args
 
 
+@when(
+    '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(
+    context, sender, receiver, amount, close_remainder_to
+):
+    if sender == "transient":
+        sender = context.transient_pk
+    if receiver == "transient":
+        receiver = context.transient_pk
+    if not close_remainder_to:
+        close_remainder_to = None
+    context.transaction = transaction.PaymentTxn(
+        sender=sender,
+        sp=context.suggested_params,
+        receiver=receiver,
+        amt=int(amount),
+        close_remainder_to=close_remainder_to,
+    )
+
+
 @when(
     'I build an application transaction with operation "{operation:MaybeString}", application-id {application_id}, sender "{sender:MaybeString}", approval-program "{approval_program:MaybeString}", clear-program "{clear_program:MaybeString}", global-bytes {global_bytes}, global-ints {global_ints}, local-bytes {local_bytes}, local-ints {local_ints}, app-args "{app_args:MaybeString}", foreign-apps "{foreign_apps:MaybeString}", foreign-assets "{foreign_assets:MaybeString}", app-accounts "{app_accounts:MaybeString}", fee {fee}, first-valid {first_valid}, last-valid {last_valid}, genesis-hash "{genesis_hash:MaybeString}", extra-pages {extra_pages}'
 )
@@ -1799,10 +1850,26 @@ def sign_transaction_with_signing_account(context):
     context.signed_transaction = context.transaction.sign(private_key)
 
 
+@then('the base64 encoded signed transactions should equal "{goldens}"')
+def compare_stxns_array_to_base64_golden(context, goldens):
+    golden_strings = goldens.split(",")
+    assert len(golden_strings) == len(context.signed_transactions)
+    for i, golden in enumerate(golden_strings):
+        actual_base64 = encoding.msgpack_encode(context.signed_transactions[i])
+        assert golden == actual_base64, "actual is {}".format(actual_base64)
+
+
 @then('the base64 encoded signed transaction should equal "{golden}"')
 def compare_to_base64_golden(context, golden):
     actual_base64 = encoding.msgpack_encode(context.signed_transaction)
-    assert golden == actual_base64
+    assert golden == actual_base64, "actual is {}".format(actual_base64)
+
+
+@then("the decoded transaction should equal the original")
+def compare_to_original(context):
+    encoded = encoding.msgpack_encode(context.signed_transaction)
+    decoded = encoding.future_msgpack_decode(encoded)
+    assert decoded.transaction == context.transaction
 
 
 @given(
@@ -1943,7 +2010,7 @@ def sign_submit_save_txid_with_error(context, error_string):
             signed_app_transaction
         )
     except Exception as e:
-        if error_string not in str(e):
+        if not error_string or error_string not in str(e):
             raise RuntimeError(
                 "error string "
                 + error_string
@@ -1957,17 +2024,34 @@ def wait_for_app_txn_confirm(context):
     sp = context.app_acl.suggested_params()
     last_round = sp.first
     context.app_acl.status_after_block(last_round + 2)
-    assert "type" in context.acl.transaction_info(
-        context.transient_pk, context.app_txid
-    )
-    assert "type" in context.acl.transaction_by_id(context.app_txid)
+    if hasattr(context, "acl"):
+        assert "type" in context.acl.transaction_info(
+            context.transient_pk, context.app_txid
+        )
+        assert "type" in context.acl.transaction_by_id(context.app_txid)
+    else:
+        transaction.wait_for_confirmation(
+            context.app_acl, context.app_txid, 10
+        )
 
 
 @given("I remember the new application ID.")
 def remember_app_id(context):
-    context.current_application_id = context.acl.pending_transaction_info(
-        context.app_txid
-    )["txresults"]["createdapp"]
+    if hasattr(context, "acl"):
+        context.current_application_id = context.acl.pending_transaction_info(
+            context.app_txid
+        )["txresults"]["createdapp"]
+    else:
+        context.current_application_id = (
+            context.app_acl.pending_transaction_info(context.app_txid)[
+                "application-index"
+            ]
+        )
+
+
+@given("an application id {app_id}")
+def set_app_id(context, app_id):
+    context.current_application_id = app_id
 
 
 @step(
@@ -2264,3 +2348,331 @@ def dryrun_test_case_local_state_assert_fail_step(
 
     ts.assertNoError(drr)
     ts.assertLocalStateContains(drr, account, dict(key=key, value=val))
+
+
+@given("a new AtomicTransactionComposer")
+def create_atomic_transaction_composer(context):
+    context.atomic_transaction_composer = (
+        atomic_transaction_composer.AtomicTransactionComposer()
+    )
+
+
+@when("I make a transaction signer for the {account_type} account.")
+def create_transaction_signer(context, account_type):
+    if account_type == "transient":
+        private_key = context.transient_sk
+    elif account_type == "signing":
+        private_key = mnemonic.to_private_key(context.signing_mnemonic)
+    else:
+        raise NotImplementedError(
+            "cannot make transaction signer for " + account_type
+        )
+    context.transaction_signer = (
+        atomic_transaction_composer.AccountTransactionSigner(private_key)
+    )
+
+
+@when('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)
+
+
+@when("I create a transaction with signer with the current transaction.")
+def create_transaction_with_signer(context):
+    context.transaction_with_signer = (
+        atomic_transaction_composer.TransactionWithSigner(
+            context.transaction, context.transaction_signer
+        )
+    )
+
+
+@when("I add the current transaction with signer to the composer.")
+def add_transaction_to_composer(context):
+    context.atomic_transaction_composer.add_transaction(
+        context.transaction_with_signer
+    )
+
+
+def split_and_process_abi_args(method, arg_string):
+    method_args = []
+    arg_tokens = arg_string.split(",")
+    arg_index = 0
+    for arg in 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
+    return method_args
+
+
+@when("I create a new method arguments array.")
+def create_abi_method_args(context):
+    context.method_args = []
+
+
+@when(
+    "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(
+    '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)
+    context.method_args += app_args
+
+
+@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.'
+)
+def add_abi_method_call(context, account_type, operation):
+    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
+        )
+    context.atomic_transaction_composer.add_method_call(
+        app_id=context.current_application_id,
+        method=context.abi_method,
+        sender=sender,
+        sp=context.suggested_params,
+        signer=context.transaction_signer,
+        method_args=context.method_args,
+        on_complete=operation_string_to_enum(operation),
+    )
+
+
+@when(
+    '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()
+
+
+def composer_status_string_to_enum(status):
+    if status == "BUILDING":
+        return (
+            atomic_transaction_composer.AtomicTransactionComposerStatus.BUILDING
+        )
+    elif status == "BUILT":
+        return (
+            atomic_transaction_composer.AtomicTransactionComposerStatus.BUILT
+        )
+    elif status == "SIGNED":
+        return (
+            atomic_transaction_composer.AtomicTransactionComposerStatus.SIGNED
+        )
+    elif status == "SUBMITTED":
+        return (
+            atomic_transaction_composer.AtomicTransactionComposerStatus.SUBMITTED
+        )
+    elif status == "COMMITTED":
+        return (
+            atomic_transaction_composer.AtomicTransactionComposerStatus.COMMITTED
+        )
+    else:
+        raise NotImplementedError(
+            "no AtomicTransactionComposerStatus enum for " + status
+        )
+
+
+@then('The composer should have a status of "{status}".')
+def check_atomic_transaction_composer_status(context, status):
+    assert (
+        context.atomic_transaction_composer.get_status()
+        == composer_status_string_to_enum(status)
+    )
+
+
+@then("I gather signatures with the composer.")
+def gather_signatures_composer(context):
+    context.signed_transactions = (
+        context.atomic_transaction_composer.gather_signatures()
+    )
+
+
+@then("I clone the composer.")
+def clone_atomic_transaction_composer(context):
+    context.atomic_transaction_composer = (
+        context.atomic_transaction_composer.clone()
+    )
+
+
+@then("I execute the current transaction group with the composer.")
+def execute_atomic_transaction_composer(context):
+    context.atomic_transaction_composer_return = (
+        context.atomic_transaction_composer.execute(context.app_acl, 10)
+    )
+    assert context.atomic_transaction_composer_return.confirmed_round > 0
+
+
+@then('The app should have returned "{returns:MaybeString}".')
+def check_atomic_transaction_composer_response(context, returns):
+    if not returns:
+        expected_tokens = []
+        assert len(context.atomic_transaction_composer_return.abi_results) == 1
+        result = context.atomic_transaction_composer_return.abi_results[0]
+        assert result.return_value is None
+        assert result.decode_error is None
+    else:
+        expected_tokens = returns.split(",")
+        for i, expected in enumerate(expected_tokens):
+            result = context.atomic_transaction_composer_return.abi_results[i]
+            if not returns or not expected_tokens[i]:
+                assert result.return_value is None
+                assert result.decode_error is None
+                continue
+            expected_bytes = base64.b64decode(expected)
+            expected_value = context.abi_method.returns.type.decode(
+                expected_bytes
+            )
+
+            assert expected_bytes == result.raw_value, "actual is {}".format(
+                result.raw_value
+            )
+            assert (
+                expected_value == result.return_value
+            ), "actual is {}".format(result.return_value)
+            assert result.decode_error is None
+
+
+@when("I serialize the Method object into json")
+def serialize_method_to_json(context):
+    context.json_output = context.abi_method.dictify()
+
+
+@then(
+    'the produced json should equal "{json_path}" loaded from "{json_directory}"'
+)
+def check_json_output_equals(context, json_path, json_directory):
+    with open(
+        "test/features/unit/" + json_directory + "/" + json_path, "rb"
+    ) as f:
+        loaded_response = json.load(f)
+    assert context.json_output == loaded_response
+
+
+@when(
+    'I create the Method object with name "{method_name}" method description "{method_desc}" first argument type "{first_arg_type}" first argument description "{first_arg_desc}" second argument type "{second_arg_type}" second argument description "{second_arg_desc}" and return type "{return_arg_type}"'
+)
+def create_method_from_test_with_arg_name(
+    context,
+    method_name,
+    method_desc,
+    first_arg_type,
+    first_arg_desc,
+    second_arg_type,
+    second_arg_desc,
+    return_arg_type,
+):
+    context.abi_method = abi.Method(
+        name=method_name,
+        args=[
+            abi.Argument(arg_type=first_arg_type, desc=first_arg_desc),
+            abi.Argument(arg_type=second_arg_type, desc=second_arg_desc),
+        ],
+        returns=abi.Returns(return_arg_type),
+        desc=method_desc,
+    )
+
+
+@when(
+    'I create the Method object with name "{method_name}" first argument name "{first_arg_name}" first argument type "{first_arg_type}" second argument name "{second_arg_name}" second argument type "{second_arg_type}" and return type "{return_arg_type}"'
+)
+def create_method_from_test_with_arg_name(
+    context,
+    method_name,
+    first_arg_name,
+    first_arg_type,
+    second_arg_name,
+    second_arg_type,
+    return_arg_type,
+):
+    context.abi_method = abi.Method(
+        name=method_name,
+        args=[
+            abi.Argument(arg_type=first_arg_type, name=first_arg_name),
+            abi.Argument(arg_type=second_arg_type, name=second_arg_name),
+        ],
+        returns=abi.Returns(return_arg_type),
+    )
+
+
+@when(
+    'I create the Method object with name "{method_name}" first argument type "{first_arg_type}" second argument type "{second_arg_type}" and return type "{return_arg_type}"'
+)
+def create_method_from_test(
+    context, method_name, first_arg_type, second_arg_type, return_arg_type
+):
+    context.abi_method = abi.Method(
+        name=method_name,
+        args=[abi.Argument(first_arg_type), abi.Argument(second_arg_type)],
+        returns=abi.Returns(return_arg_type),
+    )
+
+
+@then("the deserialized json should equal the original Method object")
+def deserialize_method_to_object(context):
+    json_string = json.dumps(context.json_output)
+    actual = abi.Method.from_json(json_string)
+    assert actual == context.abi_method
+
+
+@then("the txn count should be {txn_count}")
+def check_method_txn_count(context, txn_count):
+    assert context.abi_method.get_txn_calls() == int(txn_count)
+
+
+@then('the method selector should be "{method_selector}"')
+def check_method_selector(context, method_selector):
+    assert context.abi_method.get_selector() == bytes.fromhex(method_selector)
+
+
+@when(
+    'I create an Interface object from the Method object with name "{interface_name}"'
+)
+def create_interface_object(context, interface_name):
+    context.abi_interface = abi.Interface(
+        name=interface_name, methods=[context.abi_method]
+    )
+
+
+@when("I serialize the Interface object into json")
+def serialize_interface_to_json(context):
+    context.json_output = context.abi_interface.dictify()
+
+
+@then("the deserialized json should equal the original Interface object")
+def deserialize_json_to_interface(context):
+    actual = abi.Interface.undictify(context.json_output)
+    assert actual == context.abi_interface
+
+
+@when(
+    'I create a Contract object from the Method object with name "{contract_name}" and appId {app_id}'
+)
+def create_contract_object(context, contract_name, app_id):
+    context.abi_contract = abi.Contract(
+        name=contract_name, app_id=app_id, methods=[context.abi_method]
+    )
+
+
+@when("I serialize the Contract object into json")
+def serialize_contract_to_json(context):
+    context.json_output = context.abi_contract.dictify()
+
+
+@then("the deserialized json should equal the original Contract object")
+def deserialize_json_to_contract(context):
+    actual = abi.Contract.undictify(context.json_output)
+    assert actual == context.abi_contract
diff --git a/test_unit.py b/test_unit.py
index 3712bfd..24596f4 100644
--- a/test_unit.py
+++ b/test_unit.py
@@ -30,6 +30,9 @@ from algosdk.abi import (
     ArrayDynamicType,
     ArrayStaticType,
     TupleType,
+    Method,
+    Interface,
+    Contract,
 )
 from algosdk.future import template, transaction
 from algosdk.testing import dryrun
@@ -4178,6 +4181,88 @@ class TestABIEncoding(unittest.TestCase):
             self.assertEqual(actual, expected)
 
 
+class TestABIInteraction(unittest.TestCase):
+    def test_method(self):
+        # Parse method object from JSON
+        test_json = '{"name": "add", "desc": "Calculate the sum of two 64-bit integers", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ], "returns": { "type": "uint128", "desc": "..." } }'
+        m = Method.from_json(test_json)
+        self.assertEqual(m.get_signature(), "add(uint64,uint64)uint128")
+        self.assertEqual(m.get_selector(), b"\x8a\xa3\xb6\x1f")
+        self.assertEqual(
+            [(a.type) for a in m.args],
+            [type_from_string("uint64"), type_from_string("uint64")],
+        )
+        self.assertEqual(m.get_txn_calls(), 1)
+
+        # Parse method object from string
+        test_cases = [
+            (
+                "add(uint64,uint64)uint128",
+                b"\x8a\xa3\xb6\x1f",
+                [type_from_string("uint64"), type_from_string("uint64")],
+                type_from_string("uint128"),
+                1,
+            ),
+            (
+                "tupler((string,uint16),bool)void",
+                b"\x3d\x98\xe4\x5d",
+                [
+                    type_from_string("(string,uint16)"),
+                    type_from_string("bool"),
+                ],
+                "void",
+                1,
+            ),
+            (
+                "txcalls(pay,pay,axfer,byte)bool",
+                b"\x05\x6d\x2e\xc0",
+                ["pay", "pay", "axfer", type_from_string("byte")],
+                type_from_string("bool"),
+                4,
+            ),
+            (
+                "getter()string",
+                b"\xa2\x59\x11\x1d",
+                [],
+                type_from_string("string"),
+                1,
+            ),
+        ]
+
+        for test_case in test_cases:
+            m = Method.from_signature(test_case[0])
+
+            # Check method signature
+            self.assertEqual(m.get_signature(), test_case[0])
+            # Check selector
+            self.assertEqual(m.get_selector(), test_case[1])
+            # Check args
+            self.assertEqual([(a.type) for a in m.args], test_case[2])
+            # Check return
+            self.assertEqual(m.returns.type, test_case[3])
+            # Check txn calls
+            self.assertEqual(m.get_txn_calls(), test_case[4])
+
+    def test_interface(self):
+        test_json = '{"name": "Calculator","methods": [{ "name": "add", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}'
+        i = Interface.from_json(test_json)
+        self.assertEqual(i.name, "Calculator")
+        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", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "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(
+            [m.get_signature() for m in c.methods],
+            ["add(uint64,uint64)void", "multiply(uint64,uint64)void"],
+        )
+
+
 if __name__ == "__main__":
     to_run = [
         TestPaymentTransaction,
@@ -4197,6 +4282,7 @@ if __name__ == "__main__":
         TestDryrun,
         TestABIType,
         TestABIEncoding,
+        TestABIInteraction,
     ]
     loader = unittest.TestLoader()
     suites = [