Repository: algorand/js-algorand-sdk
Pull request #473 (2021-11-23 21:47:34)

DO NOT MERGE - js-algorand-sdk v1.13.0 beta.1
# 1.13.0-beta.1

## Added

- ABI Support for JS library (#454)
- ABI interaction support (#466)
- Wait for confirmation function (#469)
- Add freezeAccount encoding in display method (#460)
- Regenerate code from specification file (#456)

## Fixed

- Fix type for foreignAssets (#472)



Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index 526720b..2833307 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -23,6 +23,8 @@ module.exports = {
         ts: 'never',
       },
     ],
+    'import/prefer-default-export': 'off',
+    'no-continue': 'off',
     'lines-between-class-members': [
       'error',
       'always',
@@ -32,6 +34,8 @@ module.exports = {
     '@typescript-eslint/no-unused-vars': ['error'],
     'no-redeclare': 'off',
     '@typescript-eslint/no-redeclare': ['error'],
+    'no-shadow': 'off',
+    '@typescript-eslint/no-shadow': ['error'],
   },
   overrides: [
     {
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dddbc00..c6969ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+# 1.13.0-beta.1
+
+## Added
+
+- ABI Support for JS library (#454)
+- ABI interaction support (#466)
+- Wait for confirmation function (#469)
+- Add freezeAccount encoding in display method (#460)
+- Regenerate code from specification file (#456)
+
+## Fixed
+
+- Fix type for foreignAssets (#472)
+
 # 1.12.0
 
 ## Added
diff --git a/Makefile b/Makefile
index 35cba15..df7fe68 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
 unit:
-	node_modules/.bin/cucumber-js --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" tests/cucumber/features --require-module ts-node/register --require tests/cucumber/steps/index.js
+	node_modules/.bin/cucumber-js --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" tests/cucumber/features --require-module ts-node/register --require tests/cucumber/steps/index.js
 integration:
-	node_modules/.bin/cucumber-js --tags "@algod or @assets or @auction or @kmd or @send or @template or @indexer or @rekey or @dryrun or @compile or @applications or @indexer.applications or @applications.verified or @indexer.231" tests/cucumber/features --require-module ts-node/register --require tests/cucumber/steps/index.js
+	node_modules/.bin/cucumber-js --tags "@algod or @assets or @auction or @kmd or @send or @template or @indexer or @rekey or @dryrun or @compile or @applications or @indexer.applications or @applications.verified or @indexer.231 or @abi" tests/cucumber/features --require-module ts-node/register --require tests/cucumber/steps/index.js
 
 docker-test:
 	./tests/cucumber/docker/run_docker.sh
diff --git a/README.md b/README.md
index faa5869..3e35a87 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,8 @@ Include a minified browser bundle directly in your HTML like so:
 
 ```html
 <script
-  src="https://unpkg.com/algosdk@1.12.0/dist/browser/algosdk.min.js"
-  integrity="sha384-N6rRZtVTe4Rvktk9jRWB/jo+NHuNpd2Uh87V0GndIMZbWkKKZZfn1FuzORyMypsZ"
+  src="https://unpkg.com/algosdk@1.13.0-beta.1/dist/browser/algosdk.min.js"
+  integrity="sha384-w9fodN00YjvWxdNoiyT+W6CqaATkXlfid9tnzBlToDqZWepVWLjI5wO7jNL4+aNs"
   crossorigin="anonymous"
 ></script>
 ```
@@ -30,8 +30,8 @@ or
 
 ```html
 <script
-  src="https://cdn.jsdelivr.net/npm/algosdk@1.12.0/dist/browser/algosdk.min.js"
-  integrity="sha384-N6rRZtVTe4Rvktk9jRWB/jo+NHuNpd2Uh87V0GndIMZbWkKKZZfn1FuzORyMypsZ"
+  src="https://cdn.jsdelivr.net/npm/algosdk@1.13.0-beta.1/dist/browser/algosdk.min.js"
+  integrity="sha384-w9fodN00YjvWxdNoiyT+W6CqaATkXlfid9tnzBlToDqZWepVWLjI5wO7jNL4+aNs"
   crossorigin="anonymous"
 ></script>
 ```
diff --git a/package-lock.json b/package-lock.json
index 577d2a9..bd28b9b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "algosdk",
-  "version": "1.12.0",
+  "version": "1.13.0-beta.1",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "algosdk",
-      "version": "1.12.0",
+      "version": "1.13.0-beta.1",
       "license": "MIT",
       "dependencies": {
         "algo-msgpack-with-bigint": "^2.1.1",
@@ -28,7 +28,7 @@
         "@typescript-eslint/eslint-plugin": "^4.26.1",
         "@typescript-eslint/parser": "^4.26.1",
         "assert": "^2.0.0",
-        "chromedriver": "^93.0.1",
+        "chromedriver": "^95.0.0",
         "concurrently": "^6.2.0",
         "coveralls": "^3.1.0",
         "cucumber": "^5.1.0",
@@ -1521,9 +1521,9 @@
       }
     },
     "node_modules/chromedriver": {
-      "version": "93.0.1",
-      "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-93.0.1.tgz",
-      "integrity": "sha512-KDzbW34CvQLF5aTkm3b5VdlTrvdIt4wEpCzT2p4XJIQWQZEPco5pNce7Lu9UqZQGkhQ4mpZt4Ky6NKVyIS2N8A==",
+      "version": "95.0.0",
+      "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-95.0.0.tgz",
+      "integrity": "sha512-HwSg7S0ZZYsHTjULwxFHrrUqEpz1+ljDudJM3eOquvqD5QKnR5pSe/GlBTY9UU2tVFRYz8bEHYC4Y8qxciQiLQ==",
       "dev": true,
       "hasInstallScript": true,
       "dependencies": {
@@ -9602,9 +9602,9 @@
       "dev": true
     },
     "chromedriver": {
-      "version": "93.0.1",
-      "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-93.0.1.tgz",
-      "integrity": "sha512-KDzbW34CvQLF5aTkm3b5VdlTrvdIt4wEpCzT2p4XJIQWQZEPco5pNce7Lu9UqZQGkhQ4mpZt4Ky6NKVyIS2N8A==",
+      "version": "95.0.0",
+      "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-95.0.0.tgz",
+      "integrity": "sha512-HwSg7S0ZZYsHTjULwxFHrrUqEpz1+ljDudJM3eOquvqD5QKnR5pSe/GlBTY9UU2tVFRYz8bEHYC4Y8qxciQiLQ==",
       "dev": true,
       "requires": {
         "@testim/chrome-version": "^1.0.7",
diff --git a/package.json b/package.json
index b0a25fa..80bfc79 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "algosdk",
-  "version": "1.12.0",
+  "version": "1.13.0-beta.1",
   "description": "The official JavaScript SDK for Algorand",
   "main": "dist/cjs/index.js",
   "module": "dist/esm/index.js",
@@ -36,7 +36,7 @@
     "@typescript-eslint/eslint-plugin": "^4.26.1",
     "@typescript-eslint/parser": "^4.26.1",
     "assert": "^2.0.0",
-    "chromedriver": "^93.0.1",
+    "chromedriver": "^95.0.0",
     "concurrently": "^6.2.0",
     "coveralls": "^3.1.0",
     "cucumber": "^5.1.0",
diff --git a/src/abi/abi_type.ts b/src/abi/abi_type.ts
new file mode 100644
index 0000000..08c9021
--- /dev/null
+++ b/src/abi/abi_type.ts
@@ -0,0 +1,813 @@
+/* eslint-disable no-bitwise */
+/* eslint-disable no-use-before-define */
+/* eslint-disable class-methods-use-this */
+
+/**
+    //ABI-Types: uint<N>: An N-bit unsigned integer (8 <= N <= 512 and N % 8 = 0).
+    // | byte (alias for uint8)
+    // | ufixed <N> x <M> (8 <= N <= 512, N % 8 = 0, and 0 < M <= 160)
+    // | bool
+    // | address (alias for byte[32])
+    // | <type> [<N>]
+    // | <type> []
+    // | string
+    // | (T1, ..., Tn)
+*/
+import { encodeAddress, decodeAddress } from '../encoding/address';
+import { bigIntToBytes, bytesToBigInt } from '../encoding/bigint';
+import { concatArrays } from '../utils/utils';
+
+export const MAX_LEN = 2 ** 16 - 1;
+export const ADDR_BYTE_SIZE = 32;
+export const SINGLE_BYTE_SIZE = 1;
+export const SINGLE_BOOL_SIZE = 1;
+export const LENGTH_ENCODE_BYTE_SIZE = 2;
+
+interface Segment {
+  left: number;
+  right: number;
+}
+
+const staticArrayRegexp = /^([a-z\d[\](),]+)\[([1-9][\d]*)]$/;
+const ufixedRegexp = /^ufixed([1-9][\d]*)x([1-9][\d]*)$/;
+
+export type ABIValue =
+  | boolean
+  | number
+  | bigint
+  | string
+  | Uint8Array
+  | ABIValue[];
+
+export abstract class ABIType {
+  // Converts a ABIType object to a string
+  abstract toString(): string;
+  // Checks if two ABIType objects are equal in value
+  abstract equal(other: ABIType): boolean;
+  // Checks if the ABIType object (or any of its child types) have dynamic length
+  abstract isDynamic(): boolean;
+  // Returns the size of the ABIType object in bytes
+  abstract byteLen(): number;
+  // Encodes a value for the ABIType object using the ABI specs
+  abstract encode(value: ABIValue): Uint8Array;
+  // Decodes a value for the ABIType object using the ABI specs
+  abstract decode(byteString: Uint8Array): ABIValue;
+  // De-serializes the ABI type from a string using the ABI specs
+  static from(str: string): ABIType {
+    if (str.endsWith('[]')) {
+      const arrayArgType = ABIType.from(str.slice(0, str.length - 2));
+      return new ABIArrayDynamicType(arrayArgType);
+    }
+    if (str.endsWith(']')) {
+      const stringMatches = str.match(staticArrayRegexp);
+      // Match the string itself, array element type, then array length
+      if (stringMatches.length !== 3) {
+        throw new Error(`malformed static array string: ${str}`);
+      }
+      // Parse static array using regex
+      const arrayLengthStr = stringMatches[2];
+      const arrayLength = parseInt(arrayLengthStr, 10);
+      if (arrayLength > MAX_LEN) {
+        throw new Error(`array length exceeds limit ${MAX_LEN}`);
+      }
+      // Parse the array element type
+      const arrayType = ABIType.from(stringMatches[1]);
+      return new ABIArrayStaticType(arrayType, arrayLength);
+    }
+    if (str.startsWith('uint')) {
+      // Checks if the parsed number contains only digits, no whitespaces
+      const digitsOnly = (string) =>
+        [...string].every((c) => '0123456789'.includes(c));
+      const typeSizeStr = str.slice(4, str.length);
+      if (!digitsOnly(typeSizeStr)) {
+        throw new Error(`malformed uint string: ${typeSizeStr}`);
+      }
+      const typeSize = parseInt(typeSizeStr, 10);
+      if (typeSize > MAX_LEN) {
+        throw new Error(`malformed uint string: ${typeSize}`);
+      }
+      return new ABIUintType(typeSize);
+    }
+    if (str === 'byte') {
+      return new ABIByteType();
+    }
+    if (str.startsWith('ufixed')) {
+      const stringMatches = str.match(ufixedRegexp);
+      if (stringMatches.length !== 3) {
+        throw new Error(`malformed ufixed type: ${str}`);
+      }
+      const ufixedSize = parseInt(stringMatches[1], 10);
+      const ufixedPrecision = parseInt(stringMatches[2], 10);
+      return new ABIUfixedType(ufixedSize, ufixedPrecision);
+    }
+    if (str === 'bool') {
+      return new ABIBoolType();
+    }
+    if (str === 'address') {
+      return new ABIAddressType();
+    }
+    if (str === 'string') {
+      return new ABIStringType();
+    }
+    if (str.length >= 2 && str[0] === '(' && str[str.length - 1] === ')') {
+      const tupleContent = ABITupleType.parseTupleContent(
+        str.slice(1, str.length - 1)
+      );
+      const tupleTypes: ABIType[] = [];
+      for (let i = 0; i < tupleContent.length; i++) {
+        const ti = ABIType.from(tupleContent[i]);
+        tupleTypes.push(ti);
+      }
+      return new ABITupleType(tupleTypes);
+    }
+    throw new Error(`cannot convert a string ${str} to an ABI type`);
+  }
+}
+
+export class ABIUintType extends ABIType {
+  bitSize: number;
+
+  constructor(size: number) {
+    super();
+    if (size % 8 !== 0 || size < 8 || size > 512) {
+      throw new Error(`unsupported uint type bitSize: ${size}`);
+    }
+    this.bitSize = size;
+  }
+
+  toString() {
+    return `uint${this.bitSize}`;
+  }
+
+  equal(other: ABIUintType) {
+    return (
+      this.constructor === other.constructor && this.bitSize === other.bitSize
+    );
+  }
+
+  isDynamic() {
+    return false;
+  }
+
+  byteLen() {
+    return this.bitSize / 8;
+  }
+
+  encode(value: bigint | number) {
+    if (
+      (typeof value !== 'bigint' && typeof value !== 'number') ||
+      value >= BigInt(2 ** this.bitSize) ||
+      value < BigInt(0)
+    ) {
+      throw new Error(
+        `${value} is not a non-negative int or too big to fit in size uint${this.bitSize}`
+      );
+    }
+    if (typeof value === 'number' && !Number.isSafeInteger(value)) {
+      throw new Error(
+        `${value} should be converted into a BigInt before it is encoded`
+      );
+    }
+    return bigIntToBytes(value, this.bitSize / 8);
+  }
+
+  decode(byteString: Uint8Array) {
+    if (byteString.length !== this.bitSize / 8) {
+      throw new Error(`byte string must correspond to a uint${this.bitSize}`);
+    }
+    return bytesToBigInt(byteString);
+  }
+}
+
+export class ABIUfixedType extends ABIType {
+  bitSize: number;
+  precision: number;
+
+  constructor(size: number, denominator: number) {
+    super();
+    if (size % 8 !== 0 || size < 8 || size > 512) {
+      throw new Error(`unsupported ufixed type bitSize: ${size}`);
+    }
+    if (denominator > 160 || denominator < 1) {
+      throw new Error(`unsupported ufixed type precision: ${denominator}`);
+    }
+    this.bitSize = size;
+    this.precision = denominator;
+  }
+
+  toString() {
+    return `ufixed${this.bitSize}x${this.precision}`;
+  }
+
+  equal(other: ABIUfixedType) {
+    return (
+      this.constructor === other.constructor &&
+      this.bitSize === other.bitSize &&
+      this.precision === other.precision
+    );
+  }
+
+  isDynamic() {
+    return false;
+  }
+
+  byteLen() {
+    return this.bitSize / 8;
+  }
+
+  encode(value: bigint | number) {
+    if (
+      (typeof value !== 'bigint' && typeof value !== 'number') ||
+      value >= BigInt(2 ** this.bitSize) ||
+      value < BigInt(0)
+    ) {
+      throw new Error(
+        `${value} is not a non-negative int or too big to fit in size ufixed${this.bitSize}`
+      );
+    }
+    if (typeof value === 'number' && !Number.isSafeInteger(value)) {
+      throw new Error(
+        `${value} should be converted into a BigInt before it is encoded`
+      );
+    }
+    return bigIntToBytes(value, this.bitSize / 8);
+  }
+
+  decode(byteString: Uint8Array) {
+    if (byteString.length !== this.bitSize / 8) {
+      throw new Error(`byte string must correspond to a ufixed${this.bitSize}`);
+    }
+    return bytesToBigInt(byteString);
+  }
+}
+
+export class ABIAddressType extends ABIType {
+  toString() {
+    return 'address';
+  }
+
+  equal(other: ABIAddressType) {
+    return this.constructor === other.constructor;
+  }
+
+  isDynamic() {
+    return false;
+  }
+
+  byteLen() {
+    return ADDR_BYTE_SIZE;
+  }
+
+  encode(value: string | Uint8Array) {
+    if (typeof value === 'string') {
+      const decodedAddress = decodeAddress(value);
+      return decodedAddress.publicKey;
+    }
+    // Return the address if it is already in bytes
+    if (value.length !== 32) {
+      throw new Error(`byte string must be 32 bytes long for an address`);
+    }
+    return value;
+  }
+
+  decode(byteString: Uint8Array) {
+    if (byteString.length !== 32) {
+      throw new Error(`byte string must be 32 bytes long for an address`);
+    }
+    return encodeAddress(byteString);
+  }
+}
+
+export class ABIBoolType extends ABIType {
+  toString() {
+    return 'bool';
+  }
+
+  equal(other: ABIBoolType) {
+    return this.constructor === other.constructor;
+  }
+
+  isDynamic() {
+    return false;
+  }
+
+  byteLen() {
+    return SINGLE_BOOL_SIZE;
+  }
+
+  encode(value: boolean) {
+    if (value) {
+      return new Uint8Array([128]);
+    }
+    return new Uint8Array([0]);
+  }
+
+  decode(byteString: Uint8Array) {
+    if (byteString.length !== 1) {
+      throw new Error(`bool string must be 1 byte long`);
+    }
+    const value = byteString[0];
+    if (value === 128) {
+      return true;
+    }
+    if (value === 0) {
+      return false;
+    }
+    throw new Error(`boolean could not be decoded from the byte string`);
+  }
+}
+
+export class ABIByteType extends ABIType {
+  toString() {
+    return 'byte';
+  }
+
+  equal(other: ABIByteType) {
+    return this.constructor === other.constructor;
+  }
+
+  isDynamic() {
+    return false;
+  }
+
+  byteLen() {
+    return SINGLE_BYTE_SIZE;
+  }
+
+  encode(value: number) {
+    if (value < 0 || value > 255) {
+      throw new Error(`${value} cannot be encoded into a byte`);
+    }
+    return new Uint8Array([value]);
+  }
+
+  decode(byteString: Uint8Array) {
+    if (byteString.length !== 1) {
+      throw new Error(`byte string must be 1 byte long`);
+    }
+    return byteString[0];
+  }
+}
+
+export class ABIStringType extends ABIType {
+  toString() {
+    return 'string';
+  }
+
+  equal(other: ABIStringType) {
+    return this.constructor === other.constructor;
+  }
+
+  isDynamic() {
+    return true;
+  }
+
+  byteLen(): never {
+    throw new Error(`${this.toString()} is a dynamic type`);
+  }
+
+  encode(value: string) {
+    const encodedBytes = Buffer.from(value);
+    const encodedLength = bigIntToBytes(value.length, LENGTH_ENCODE_BYTE_SIZE);
+    const mergedBytes = new Uint8Array(value.length + LENGTH_ENCODE_BYTE_SIZE);
+    mergedBytes.set(encodedLength);
+    mergedBytes.set(encodedBytes, LENGTH_ENCODE_BYTE_SIZE);
+    return mergedBytes;
+  }
+
+  decode(byteString: Uint8Array) {
+    if (byteString.length < LENGTH_ENCODE_BYTE_SIZE) {
+      throw new Error(`byte string is too short to be decoded: ${byteString}`);
+    }
+    const buf = Buffer.from(byteString);
+    const byteLength = buf.readUIntBE(0, LENGTH_ENCODE_BYTE_SIZE);
+    const byteValue = byteString.slice(
+      LENGTH_ENCODE_BYTE_SIZE,
+      byteString.length
+    );
+    if (byteLength !== byteValue.length) {
+      throw new Error(
+        `string length bytes do not match the actual length of string: ${byteString}`
+      );
+    }
+    const stringValue = Buffer.from(byteValue).toString('utf-8');
+    return stringValue;
+  }
+}
+
+export class ABIArrayStaticType extends ABIType {
+  childType: ABIType;
+  staticLength: number;
+
+  constructor(argType: ABIType, arrayLength: number) {
+    super();
+    if (arrayLength < 1) {
+      throw new Error(
+        `static array must have a length greater than 0: ${arrayLength}`
+      );
+    }
+    this.childType = argType;
+    this.staticLength = arrayLength;
+  }
+
+  toString() {
+    return `${this.childType.toString()}[${this.staticLength}]`;
+  }
+
+  equal(other: ABIArrayStaticType) {
+    return (
+      this.constructor === other.constructor &&
+      this.childType === other.childType &&
+      this.staticLength === other.staticLength
+    );
+  }
+
+  isDynamic() {
+    return this.childType.isDynamic();
+  }
+
+  byteLen() {
+    if (this.childType.constructor === ABIBoolType) {
+      return Math.ceil(this.staticLength / 8);
+    }
+    return this.staticLength * this.childType.byteLen();
+  }
+
+  encode(values: ABIValue[]) {
+    if (values.length !== this.staticLength) {
+      throw new Error(`value array does not match static array length`);
+    }
+    const convertedTuple = this.toABITupleType();
+    return convertedTuple.encode(values);
+  }
+
+  decode(byteString: Uint8Array) {
+    const convertedTuple = this.toABITupleType();
+    return convertedTuple.decode(byteString);
+  }
+
+  toABITupleType() {
+    return new ABITupleType(Array(this.staticLength).fill(this.childType));
+  }
+}
+
+export class ABIArrayDynamicType extends ABIType {
+  childType: ABIType;
+
+  constructor(argType: ABIType) {
+    super();
+    this.childType = argType;
+  }
+
+  toString() {
+    return `${this.childType.toString()}[]`;
+  }
+
+  equal(other: ABIArrayDynamicType) {
+    return (
+      this.constructor === other.constructor &&
+      this.childType === other.childType
+    );
+  }
+
+  isDynamic() {
+    return true;
+  }
+
+  byteLen(): never {
+    throw new Error(`${this.toString()} is a dynamic type`);
+  }
+
+  encode(values: ABIValue[]) {
+    const convertedTuple = this.toABITupleType(values.length);
+    const encodedTuple = convertedTuple.encode(values);
+    const encodedLength = bigIntToBytes(
+      convertedTuple.childTypes.length,
+      LENGTH_ENCODE_BYTE_SIZE
+    );
+    const mergedBytes = concatArrays(encodedLength, encodedTuple);
+    return mergedBytes;
+  }
+
+  decode(byteString: Uint8Array) {
+    const buf = Buffer.from(byteString);
+    const byteLength = buf.readUIntBE(0, LENGTH_ENCODE_BYTE_SIZE);
+    const convertedTuple = this.toABITupleType(byteLength);
+    return convertedTuple.decode(
+      byteString.slice(LENGTH_ENCODE_BYTE_SIZE, byteString.length)
+    );
+  }
+
+  toABITupleType(length: number) {
+    return new ABITupleType(Array(length).fill(this.childType));
+  }
+}
+
+export class ABITupleType extends ABIType {
+  childTypes: ABIType[];
+
+  constructor(argTypes: ABIType[]) {
+    super();
+    if (argTypes.length >= MAX_LEN) {
+      throw new Error(
+        'tuple type child type number larger than maximum uint16 error'
+      );
+    }
+    this.childTypes = argTypes;
+  }
+
+  toString() {
+    const typeStrings: string[] = [];
+    for (let i = 0; i < this.childTypes.length; i++) {
+      typeStrings[i] = this.childTypes[i].toString();
+    }
+    return `(${typeStrings.join(',')})`;
+  }
+
+  equal(other: ABITupleType) {
+    return (
+      this.constructor === other.constructor &&
+      this.childTypes === other.childTypes
+    );
+  }
+
+  isDynamic() {
+    const isDynamic = (child: ABIType) => child.isDynamic();
+    return this.childTypes.some(isDynamic);
+  }
+
+  byteLen() {
+    let size = 0;
+    for (let i = 0; i < this.childTypes.length; i++) {
+      if (this.childTypes[i].constructor === ABIBoolType) {
+        const after = findBoolLR(this.childTypes, i, 1);
+        const boolNum = after + 1;
+        i += after;
+        size += Math.trunc((boolNum + 7) / 8);
+      } else {
+        const childByteSize = this.childTypes[i].byteLen();
+        size += childByteSize;
+      }
+    }
+    return size;
+  }
+
+  encode(values: ABIValue[]) {
+    if (values.length > MAX_LEN) {
+      throw new Error('length of tuple array should not exceed a uint16');
+    }
+    const tupleTypes = this.childTypes;
+    const heads: Uint8Array[] = [];
+    const tails: Uint8Array[] = [];
+    const isDynamicIndex = new Map<number, boolean>();
+    let i = 0;
+
+    while (i < tupleTypes.length) {
+      const tupleType = tupleTypes[i];
+      if (tupleType.isDynamic()) {
+        // Head is not pre-determined for dynamic types; store a placeholder for now
+        isDynamicIndex.set(heads.length, true);
+        heads.push(new Uint8Array([0, 0]));
+        tails.push(tupleType.encode(values[i]));
+      } else {
+        if (tupleType.constructor === ABIBoolType) {
+          const before = findBoolLR(tupleTypes, i, -1);
+          let after = findBoolLR(tupleTypes, i, 1);
+
+          // Pack bytes to heads and tails
+          if (before % 8 !== 0) {
+            throw new Error(
+              'expected before index should have number of bool mod 8 equal 0'
+            );
+          }
+          after = Math.min(7, after);
+          const compressedInt = compressMultipleBool(
+            values.slice(i, i + after + 1)
+          );
+          heads.push(bigIntToBytes(compressedInt, 1));
+          i += after;
+        } else {
+          const encodedTupleValue = tupleType.encode(values[i]);
+          heads.push(encodedTupleValue);
+        }
+        isDynamicIndex.set(i, false);
+        tails.push(new Uint8Array());
+      }
+      i += 1;
+    }
+
+    // Adjust head lengths for dynamic types
+    let headLength = 0;
+    for (const headElement of heads) {
+      headLength += headElement.length;
+    }
+
+    // encode any placeholders for dynamic types
+    let tailLength = 0;
+    for (let j = 0; j < heads.length; j++) {
+      if (isDynamicIndex.get(j)) {
+        const headValue = headLength + tailLength;
+        if (headValue > MAX_LEN) {
+          throw new Error(
+            `byte length of ${headValue} should not exceed a uint16`
+          );
+        }
+        heads[j] = bigIntToBytes(headValue, LENGTH_ENCODE_BYTE_SIZE);
+      }
+      tailLength += tails[j].length;
+    }
+
+    return concatArrays(...heads, ...tails);
+  }
+
+  decode(byteString: Uint8Array) {
+    const tupleTypes = this.childTypes;
+    const dynamicSegments: Segment[] = [];
+    const valuePartition: Uint8Array[] = [];
+    let i = 0;
+    let iterIndex = 0;
+    const buf = Buffer.from(byteString);
+
+    while (i < tupleTypes.length) {
+      const tupleType = tupleTypes[i];
+      if (tupleType.isDynamic()) {
+        if (
+          byteString.slice(iterIndex, byteString.length).length <
+          LENGTH_ENCODE_BYTE_SIZE
+        ) {
+          throw new Error('dynamic type in tuple is too short to be decoded');
+        }
+        const dynamicIndex = buf.readUIntBE(iterIndex, LENGTH_ENCODE_BYTE_SIZE);
+        if (dynamicSegments.length > 0) {
+          dynamicSegments[dynamicSegments.length - 1].right = dynamicIndex;
+          // Check that right side of segment is greater than the left side
+          if (dynamicIndex < dynamicSegments[dynamicSegments.length - 1].left) {
+            throw new Error(
+              'dynamic index segment miscalculation: left is greater than right index'
+            );
+          }
+        }
+        // Since we do not know where the current dynamic element ends, put a placeholder and update later
+        const seg: Segment = {
+          left: dynamicIndex,
+          right: -1,
+        };
+        dynamicSegments.push(seg);
+        valuePartition.push(null);
+        iterIndex += LENGTH_ENCODE_BYTE_SIZE;
+      } else {
+        // eslint-disable-next-line no-lonely-if
+        if (tupleType.constructor === ABIBoolType) {
+          const before = findBoolLR(this.childTypes, i, -1);
+          let after = findBoolLR(this.childTypes, i, 1);
+
+          if (before % 8 !== 0) {
+            throw new Error('expected before bool number mod 8 === 0');
+          }
+          after = Math.min(7, after);
+          // Parse bool in a byte to multiple byte strings
+          for (let boolIndex = 0; boolIndex <= after; boolIndex++) {
+            const boolMask = 0x80 >> boolIndex;
+            if ((byteString[iterIndex] & boolMask) > 0) {
+              valuePartition.push(new Uint8Array([128]));
+            } else {
+              valuePartition.push(new Uint8Array([0]));
+            }
+          }
+          i += after;
+          iterIndex += 1;
+        } else {
+          const currLen = tupleType.byteLen();
+          valuePartition.push(byteString.slice(iterIndex, iterIndex + currLen));
+          iterIndex += currLen;
+        }
+      }
+      if (i !== tupleTypes.length - 1 && iterIndex >= byteString.length) {
+        throw new Error('input byte not enough to decode');
+      }
+      i += 1;
+    }
+    if (dynamicSegments.length > 0) {
+      dynamicSegments[dynamicSegments.length - 1].right = byteString.length;
+      iterIndex = byteString.length;
+    }
+    if (iterIndex < byteString.length) {
+      throw new Error('input byte not fully consumed');
+    }
+
+    // Check segment indices are valid
+    // If the dynamic segment are not consecutive and well-ordered, we return error
+    for (let j = 0; j < dynamicSegments.length; j++) {
+      const seg = dynamicSegments[j];
+      if (seg.left > seg.right) {
+        throw new Error(
+          'dynamic segment should display a [l, r] space with l <= r'
+        );
+      }
+      if (
+        j !== dynamicSegments.length - 1 &&
+        seg.right !== dynamicSegments[j + 1].left
+      ) {
+        throw new Error('dynamic segment should be consecutive');
+      }
+    }
+
+    // Check dynamic element partitions
+    let segIndex = 0;
+    for (let j = 0; j < tupleTypes.length; j++) {
+      if (tupleTypes[j].isDynamic()) {
+        valuePartition[j] = byteString.slice(
+          dynamicSegments[segIndex].left,
+          dynamicSegments[segIndex].right
+        );
+        segIndex += 1;
+      }
+    }
+
+    // Decode each tuple element
+    const returnValues: ABIValue[] = [];
+    for (let j = 0; j < tupleTypes.length; j++) {
+      const valueTi = tupleTypes[j].decode(valuePartition[j]);
+      returnValues.push(valueTi);
+    }
+    return returnValues;
+  }
+
+  static parseTupleContent(str: string): string[] {
+    if (str.length === 0) {
+      return [];
+    }
+    if (str.endsWith(',') || str.startsWith(',')) {
+      throw new Error('tuple string should not start with comma');
+    }
+    if (str.includes(',,')) {
+      throw new Error('tuple string should not have consecutive commas');
+    }
+
+    const tupleStrings: string[] = [];
+    let depth = 0;
+    let word = '';
+
+    for (const char of str) {
+      word += char;
+      if (char === '(') {
+        depth += 1;
+      } else if (char === ')') {
+        depth -= 1;
+      } else if (char === ',') {
+        // If the comma is at depth 0, then append the word as token.
+        if (depth === 0) {
+          tupleStrings.push(word.slice(0, word.length - 1));
+          word = '';
+        }
+      }
+    }
+    if (word.length !== 0) {
+      tupleStrings.push(word);
+    }
+    if (depth !== 0) {
+      throw new Error('tuple string has mismatched parentheses');
+    }
+    return tupleStrings;
+  }
+}
+
+// compressMultipleBool compresses consecutive bool values into a byte in ABI tuple / array value.
+function compressMultipleBool(valueList: ABIValue[]): number {
+  let res = 0;
+  if (valueList.length > 8) {
+    throw new Error('value list passed in should be no greater than length 8');
+  }
+  for (let i = 0; i < valueList.length; i++) {
+    const boolVal = valueList[i];
+    if (typeof boolVal !== 'boolean') {
+      throw new Error('non-boolean values cannot be compressed into a byte');
+    }
+    if (boolVal) {
+      res |= 1 << (7 - i);
+    }
+  }
+  return res;
+}
+
+// Assume that the current index on the list of type is an ABI bool type.
+// It returns the difference between the current index and the index of the furthest consecutive Bool type.
+function findBoolLR(typeList: ABIType[], index: number, delta: -1 | 1): number {
+  let until = 0;
+  while (true) {
+    const curr = index + delta * until;
+    if (typeList[curr].constructor === ABIBoolType) {
+      if (curr !== typeList.length - 1 && delta === 1) {
+        until += 1;
+      } else if (curr > 0 && delta === -1) {
+        until += 1;
+      } else {
+        break;
+      }
+    } else {
+      until -= 1;
+      break;
+    }
+  }
+  return until;
+}
diff --git a/src/abi/contract.ts b/src/abi/contract.ts
new file mode 100644
index 0000000..2df7be2
--- /dev/null
+++ b/src/abi/contract.ts
@@ -0,0 +1,35 @@
+import { ABIMethod, ABIMethodParams } from './method';
+
+export interface ABIContractParams {
+  name: string;
+  appId: number;
+  methods: ABIMethodParams[];
+}
+
+export class ABIContract {
+  public readonly name: string;
+  public readonly appId: number;
+  public readonly methods: ABIMethod[];
+
+  constructor(params: ABIContractParams) {
+    if (
+      typeof params.name !== 'string' ||
+      typeof params.appId !== 'number' ||
+      !Array.isArray(params.methods)
+    ) {
+      throw new Error('Invalid ABIContract parameters');
+    }
+
+    this.name = params.name;
+    this.appId = params.appId;
+    this.methods = params.methods.map((method) => new ABIMethod(method));
+  }
+
+  toJSON(): ABIContractParams {
+    return {
+      name: this.name,
+      appId: this.appId,
+      methods: this.methods.map((method) => method.toJSON()),
+    };
+  }
+}
diff --git a/src/abi/index.ts b/src/abi/index.ts
new file mode 100644
index 0000000..2c24cf8
--- /dev/null
+++ b/src/abi/index.ts
@@ -0,0 +1,4 @@
+export * from './abi_type';
+export * from './contract';
+export * from './interface';
+export * from './method';
diff --git a/src/abi/interface.ts b/src/abi/interface.ts
new file mode 100644
index 0000000..1e6dc28
--- /dev/null
+++ b/src/abi/interface.ts
@@ -0,0 +1,27 @@
+import { ABIMethod, ABIMethodParams } from './method';
+
+export interface ABIInterfaceParams {
+  name: string;
+  methods: ABIMethodParams[];
+}
+
+export class ABIInterface {
+  public readonly name: string;
+  public readonly methods: ABIMethod[];
+
+  constructor(params: ABIInterfaceParams) {
+    if (typeof params.name !== 'string' || !Array.isArray(params.methods)) {
+      throw new Error('Invalid ABIInterface parameters');
+    }
+
+    this.name = params.name;
+    this.methods = params.methods.map((method) => new ABIMethod(method));
+  }
+
+  toJSON(): ABIInterfaceParams {
+    return {
+      name: this.name,
+      methods: this.methods.map((method) => method.toJSON()),
+    };
+  }
+}
diff --git a/src/abi/method.ts b/src/abi/method.ts
new file mode 100644
index 0000000..0c925d7
--- /dev/null
+++ b/src/abi/method.ts
@@ -0,0 +1,154 @@
+import { genericHash } from '../nacl/naclWrappers';
+import { TransactionType } from '../types/transactions/base';
+import { ABIType, ABITupleType } from './abi_type';
+
+export function abiTypeIsTransaction(type: string): type is TransactionType {
+  return type === 'txn' || Object.keys(TransactionType).includes(type);
+}
+
+function parseMethodSignature(
+  signature: string
+): { name: string; args: string[]; returns: string } {
+  const argsStart = signature.indexOf('(');
+  if (argsStart === -1) {
+    throw new Error(`Invalid method signature: ${signature}`);
+  }
+
+  let argsEnd = -1;
+  let depth = 0;
+  for (let i = argsStart; i < signature.length; i++) {
+    const char = signature[i];
+
+    if (char === '(') {
+      depth += 1;
+    } else if (char === ')') {
+      if (depth === 0) {
+        // unpaired parenthesis
+        break;
+      }
+
+      depth -= 1;
+      if (depth === 0) {
+        argsEnd = i;
+        break;
+      }
+    }
+  }
+
+  if (argsEnd === -1) {
+    throw new Error(`Invalid method signature: ${signature}`);
+  }
+
+  return {
+    name: signature.slice(0, argsStart),
+    args: ABITupleType.parseTupleContent(
+      signature.slice(argsStart + 1, argsEnd)
+    ),
+    returns: signature.slice(argsEnd + 1),
+  };
+}
+
+export interface ABIMethodParams {
+  name: string;
+  desc?: string;
+  args: Array<{ type: string; name?: string; desc?: string }>;
+  returns: { type: string; desc?: string };
+}
+
+export type ABIArgumentType = ABIType | TransactionType;
+
+export type ABIReturnType = ABIType | 'void';
+
+export class ABIMethod {
+  public readonly name: string;
+  public readonly description?: string;
+  public readonly args: Array<{
+    type: ABIArgumentType;
+    name?: string;
+    description?: string;
+  }>;
+
+  public readonly returns: { type: ABIReturnType; description?: string };
+
+  constructor(params: ABIMethodParams) {
+    if (
+      typeof params.name !== 'string' ||
+      typeof params.returns !== 'object' ||
+      !Array.isArray(params.args)
+    ) {
+      throw new Error('Invalid ABIMethod parameters');
+    }
+
+    this.name = params.name;
+    this.description = params.desc;
+    this.args = params.args.map(({ type, name, desc }) => {
+      if (abiTypeIsTransaction(type)) {
+        return {
+          type,
+          name,
+          description: desc,
+        };
+      }
+
+      return {
+        type: ABIType.from(type),
+        name,
+        description: desc,
+      };
+    });
+    this.returns = {
+      type:
+        params.returns.type === 'void'
+          ? params.returns.type
+          : ABIType.from(params.returns.type),
+      description: params.returns.desc,
+    };
+  }
+
+  getSignature(): string {
+    const args = this.args.map((arg) => arg.type.toString()).join(',');
+    const returns = this.returns.type.toString();
+    return `${this.name}(${args})${returns}`;
+  }
+
+  getSelector(): Uint8Array {
+    const hash = genericHash(this.getSignature());
+    return new Uint8Array(hash.slice(0, 4));
+  }
+
+  txnCount(): number {
+    let count = 1;
+    for (const arg of this.args) {
+      if (typeof arg.type === 'string' && abiTypeIsTransaction(arg.type)) {
+        count += 1;
+      }
+    }
+    return count;
+  }
+
+  toJSON(): ABIMethodParams {
+    return {
+      name: this.name,
+      desc: this.description,
+      args: this.args.map(({ type, name, description }) => ({
+        type: type.toString(),
+        name,
+        desc: description,
+      })),
+      returns: {
+        type: this.returns.type.toString(),
+        desc: this.returns.description,
+      },
+    };
+  }
+
+  static fromSignature(signature: string): ABIMethod {
+    const { name, args, returns } = parseMethodSignature(signature);
+
+    return new ABIMethod({
+      name,
+      args: args.map((arg) => ({ type: arg })),
+      returns: { type: returns },
+    });
+  }
+}
diff --git a/src/client/v2/algod/models/types.ts b/src/client/v2/algod/models/types.ts
index afc1c93..7228d2e 100644
--- a/src/client/v2/algod/models/types.ts
+++ b/src/client/v2/algod/models/types.ts
@@ -445,7 +445,7 @@ export class ApplicationParams extends BaseModel {
   public globalState?: TealKeyValue[];
 
   /**
-   * [\lsch) global schema
+   * [\gsch) global schema
    */
   public globalStateSchema?: ApplicationStateSchema;
 
@@ -462,7 +462,7 @@ export class ApplicationParams extends BaseModel {
    * parameters and global state for this application can be found.
    * @param extraProgramPages - (epp) the amount of extra program pages available to this app.
    * @param globalState - [\gs) global schema
-   * @param globalStateSchema - [\lsch) global schema
+   * @param globalStateSchema - [\gsch) global schema
    * @param localStateSchema - [\lsch) local schema
    */
   constructor({
@@ -1942,7 +1942,7 @@ export class TealKeyValue extends BaseModel {
  */
 export class TealValue extends BaseModel {
   /**
-   * (tt) value type.
+   * (tt) value type. Value `1` refers to **bytes**, value `2` refers to **uint**
    */
   public type: number | bigint;
 
@@ -1958,7 +1958,7 @@ export class TealValue extends BaseModel {
 
   /**
    * Creates a new `TealValue` object.
-   * @param type - (tt) value type.
+   * @param type - (tt) value type. Value `1` refers to **bytes**, value `2` refers to **uint**
    * @param bytes - (tb) bytes value.
    * @param uint - (ui) uint value.
    */
diff --git a/src/composer.ts b/src/composer.ts
new file mode 100644
index 0000000..06c66f6
--- /dev/null
+++ b/src/composer.ts
@@ -0,0 +1,497 @@
+import {
+  ABIType,
+  ABITupleType,
+  ABIValue,
+  ABIMethod,
+  abiTypeIsTransaction,
+} from './abi';
+import { Transaction, decodeSignedTransaction } from './transaction';
+import { makeApplicationCallTxnFromObject } from './makeTxn';
+import { assignGroupID } from './group';
+import { waitForConfirmation } from './wait';
+import Algodv2 from './client/v2/algod/algod';
+import {
+  TransactionSigner,
+  TransactionWithSigner,
+  isTransactionWithSigner,
+} from './signer';
+import {
+  OnApplicationComplete,
+  SuggestedParams,
+} from './types/transactions/base';
+
+export type ABIArgument = ABIValue | TransactionWithSigner;
+
+/** Represents the output from a successful ABI method call. */
+export interface ABIResult {
+  /** The TxID of the transaction that invoked the ABI method call. */
+  txID: string;
+  /**
+   * The raw bytes of the return value from the ABI method call. This will be empty if the method
+   * does not return a value (return type "void").
+   */
+  rawReturnValue: Uint8Array;
+  /**
+   * The return value from the ABI method call. This will be undefined if the method does not return
+   * a value (return type "void"), or if the SDK was unable to decode the returned value.
+   */
+  returnValue?: ABIValue;
+  /** If the SDK was unable to decode a return value, the error will be here. */
+  decodeError?: Error;
+}
+
+export enum AtomicTransactionComposerStatus {
+  /** The atomic group is still under construction. */
+  BUILDING,
+
+  /** The atomic group has been finalized, but not yet signed. */
+  BUILT,
+
+  /** The atomic group has been finalized and signed, but not yet submitted to the network. */
+  SIGNED,
+
+  /** The atomic group has been finalized, signed, and submitted to the network. */
+  SUBMITTED,
+
+  /** The atomic group has been finalized, signed, submitted, and successfully committed to a block. */
+  COMMITTED,
+}
+
+/** A class used to construct and execute atomic transaction groups */
+export class AtomicTransactionComposer {
+  /** The maximum size of an atomic transaction group. */
+  static MAX_GROUP_SIZE: number = 16;
+
+  private status = AtomicTransactionComposerStatus.BUILDING;
+  private transactions: TransactionWithSigner[] = [];
+  private methodCalls: Map<number, ABIMethod> = new Map();
+  private signedTxns: Uint8Array[] = [];
+  private txIDs: string[] = [];
+
+  /**
+   * Get the status of this composer's transaction group.
+   */
+  getStatus(): AtomicTransactionComposerStatus {
+    return this.status;
+  }
+
+  /**
+   * Get the number of transactions currently in this atomic group.
+   */
+  count(): number {
+    return this.transactions.length;
+  }
+
+  /**
+   * Create a new composer with the same underlying transactions. The new composer's status will be
+   * BUILDING, so additional transactions may be added to it.
+   */
+  clone(): AtomicTransactionComposer {
+    const theClone = new AtomicTransactionComposer();
+
+    theClone.transactions = this.transactions.map(({ txn, signer }) => ({
+      // not quite a deep copy, but good enough for our purposes (modifying txn.group in buildGroup)
+      txn: Transaction.from_obj_for_encoding({
+        ...txn.get_obj_for_encoding(),
+        // erase the group ID
+        grp: undefined,
+      }),
+      signer,
+    }));
+    theClone.methodCalls = new Map(this.methodCalls);
+
+    return theClone;
+  }
+
+  /**
+   * Add a transaction to this atomic group.
+   *
+   * An error will be thrown if the transaction has a nonzero group ID, the composer's status is
+   * not BUILDING, or if adding this transaction causes the current group to exceed MAX_GROUP_SIZE.
+   */
+  addTransaction(txnAndSigner: TransactionWithSigner): void {
+    if (this.status !== AtomicTransactionComposerStatus.BUILDING) {
+      throw new Error(
+        'Cannot add transactions when composer status is not BUILDING'
+      );
+    }
+
+    if (this.transactions.length === AtomicTransactionComposer.MAX_GROUP_SIZE) {
+      throw new Error(
+        `Adding an additional transaction exceeds the maximum atomic group size of ${AtomicTransactionComposer.MAX_GROUP_SIZE}`
+      );
+    }
+
+    if (txnAndSigner.txn.group && txnAndSigner.txn.group.some((v) => v !== 0)) {
+      throw new Error('Cannot add a transaction with nonzero group ID');
+    }
+
+    this.transactions.push(txnAndSigner);
+  }
+
+  /**
+   * 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.
+   */
+  addMethodCall({
+    appId,
+    method,
+    methodArgs,
+    sender,
+    suggestedParams,
+    onComplete,
+    note,
+    lease,
+    rekeyTo,
+    signer,
+  }: {
+    /** The ID of the smart contract to call */
+    appId: number;
+    /** The method to call on the smart contract */
+    method: ABIMethod;
+    /** The arguments to include in the method call. If omitted, no arguments will be passed to the method. */
+    methodArgs?: ABIArgument[];
+    /** The address of the sender of this application call */
+    sender: string;
+    /** Transactions params to use for this application call */
+    suggestedParams: SuggestedParams;
+    /** The OnComplete action to take for this application call. If omitted, OnApplicationComplete.NoOpOC will be used. */
+    onComplete?: OnApplicationComplete;
+    /** The note value for this application call */
+    note?: Uint8Array;
+    /** The lease value for this application call */
+    lease?: Uint8Array;
+    /** If provided, the address that the sender will be rekeyed to at the conclusion of this application call */
+    rekeyTo?: string;
+    /** A transaction signer that can authorize this application call from sender */
+    signer: TransactionSigner;
+  }): void {
+    if (this.status !== AtomicTransactionComposerStatus.BUILDING) {
+      throw new Error(
+        'Cannot add transactions when composer status is not BUILDING'
+      );
+    }
+
+    if (
+      this.transactions.length + method.txnCount() >
+      AtomicTransactionComposer.MAX_GROUP_SIZE
+    ) {
+      throw new Error(
+        `Adding additional transactions exceeds the maximum atomic group size of ${AtomicTransactionComposer.MAX_GROUP_SIZE}`
+      );
+    }
+
+    if (appId === 0) {
+      throw new Error('Application create call not supported');
+    }
+
+    if (methodArgs == null) {
+      // eslint-disable-next-line no-param-reassign
+      methodArgs = [];
+    }
+
+    if (methodArgs.length !== method.args.length) {
+      throw new Error(
+        `Incorrect number of method arguments. Expected ${method.args.length}, got ${methodArgs.length}`
+      );
+    }
+
+    let appArgTypes: ABIType[] = [];
+    let appArgValues: ABIValue[] = [];
+    const txnArgs: TransactionWithSigner[] = [];
+
+    for (let i = 0; i < methodArgs.length; i++) {
+      const argSpec = method.args[i];
+      const argValue = methodArgs[i];
+
+      if (
+        typeof argSpec.type === 'string' &&
+        abiTypeIsTransaction(argSpec.type)
+      ) {
+        if (
+          !isTransactionWithSigner(argValue) ||
+          argSpec.type !== argValue.txn.type
+        ) {
+          throw new Error(
+            `Expected ${argSpec.type} transaction for argument at index ${i}`
+          );
+        }
+        if (argValue.txn.group && argValue.txn.group.some((v) => v !== 0)) {
+          throw new Error('Cannot add a transaction with nonzero group ID');
+        }
+        txnArgs.push(argValue);
+        continue;
+      }
+
+      if (isTransactionWithSigner(argValue)) {
+        throw new Error(
+          `Expected non-transaction value for argument at index ${i}`
+        );
+      }
+
+      appArgTypes.push(argSpec.type);
+      appArgValues.push(argValue);
+    }
+
+    if (appArgTypes.length > 14) {
+      const lastArgTupleTypes = appArgTypes.slice(14);
+      const lastArgTupleValues = appArgValues.slice(14);
+
+      appArgTypes = appArgTypes.slice(0, 14);
+      appArgValues = appArgValues.slice(0, 14);
+
+      appArgTypes.push(new ABITupleType(lastArgTupleTypes));
+      appArgValues.push(lastArgTupleValues);
+    }
+
+    const appArgsEncoded: Uint8Array[] = [method.getSelector()];
+    for (let i = 0; i < appArgTypes.length; i++) {
+      appArgsEncoded.push(appArgTypes[i].encode(appArgValues[i]));
+    }
+
+    const appCall = {
+      txn: makeApplicationCallTxnFromObject({
+        from: sender,
+        appIndex: appId,
+        appArgs: appArgsEncoded,
+        onComplete:
+          onComplete == null ? OnApplicationComplete.NoOpOC : onComplete,
+        lease,
+        note,
+        rekeyTo,
+        suggestedParams,
+      }),
+      signer,
+    };
+
+    this.transactions.push(...txnArgs, appCall);
+    this.methodCalls.set(this.transactions.length - 1, method);
+  }
+
+  /**
+   * Finalize the transaction group and returned the finalized transactions.
+   *
+   * The composer's status will be at least BUILT after executing this method.
+   */
+  buildGroup(): TransactionWithSigner[] {
+    if (this.status === AtomicTransactionComposerStatus.BUILDING) {
+      if (this.transactions.length === 0) {
+        throw new Error('Cannot build a group with 0 transactions');
+      }
+      assignGroupID(
+        this.transactions.map((txnWithSigner) => txnWithSigner.txn)
+      );
+      this.status = AtomicTransactionComposerStatus.BUILT;
+    }
+    return this.transactions;
+  }
+
+  /**
+   * 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 A promise that resolves to an array of signed transactions.
+   */
+  async gatherSignatures(): Promise<Uint8Array[]> {
+    if (this.status >= AtomicTransactionComposerStatus.SIGNED) {
+      return this.signedTxns;
+    }
+
+    // retrieve built transactions and verify status is BUILT
+    const txnsWithSigners = this.buildGroup();
+    const txnGroup = txnsWithSigners.map((txnWithSigner) => txnWithSigner.txn);
+
+    const indexesPerSigner: Map<TransactionSigner, number[]> = new Map();
+
+    for (let i = 0; i < txnsWithSigners.length; i++) {
+      const { signer } = txnsWithSigners[i];
+
+      if (!indexesPerSigner.has(signer)) {
+        indexesPerSigner.set(signer, []);
+      }
+
+      indexesPerSigner.get(signer).push(i);
+    }
+
+    const orderedSigners = Array.from(indexesPerSigner);
+
+    const batchedSigs = await Promise.all(
+      orderedSigners.map(([signer, indexes]) => signer(txnGroup, indexes))
+    );
+
+    const signedTxns: Array<Uint8Array | null> = txnsWithSigners.map(
+      () => null
+    );
+
+    for (
+      let signerIndex = 0;
+      signerIndex < orderedSigners.length;
+      signerIndex++
+    ) {
+      const indexes = orderedSigners[signerIndex][1];
+      const sigs = batchedSigs[signerIndex];
+
+      for (let i = 0; i < indexes.length; i++) {
+        signedTxns[indexes[i]] = sigs[i];
+      }
+    }
+
+    if (!signedTxns.every((sig) => sig != null)) {
+      throw new Error(`Missing signatures. Got ${signedTxns}`);
+    }
+
+    const txIDs = signedTxns.map((stxn, index) => {
+      try {
+        return decodeSignedTransaction(stxn).txn.txID();
+      } catch (err) {
+        throw new Error(
+          `Cannot decode signed transaction at index ${index}. ${err}`
+        );
+      }
+    });
+
+    this.signedTxns = signedTxns;
+    this.txIDs = txIDs;
+    this.status = AtomicTransactionComposerStatus.SIGNED;
+
+    return signedTxns;
+  }
+
+  /**
+   * 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.
+   *
+   * @param client - An Algodv2 client
+   *
+   * @returns A promise that, upon success, resolves to a list of TxIDs of the submitted transactions.
+   */
+  async submit(client: Algodv2): Promise<string[]> {
+    if (this.status > AtomicTransactionComposerStatus.SUBMITTED) {
+      throw new Error('Transaction group cannot be resubmitted');
+    }
+
+    const stxns = await this.gatherSignatures();
+
+    await client.sendRawTransaction(stxns).do();
+
+    this.status = AtomicTransactionComposerStatus.SUBMITTED;
+
+    return this.txIDs;
+  }
+
+  /**
+   * 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.
+   *
+   * @param client - An Algodv2 client
+   * @param waitRounds - The maximum number of rounds to wait for transaction confirmation
+   *
+   * @returns A promise that, upon success, resolves to an object containing the confirmed round for
+   *   this transaction, the txIDs of the submitted transactions, and an array of results containing
+   *   one element for each method call transaction in this group.
+   */
+  async execute(
+    client: Algodv2,
+    waitRounds: number
+  ): Promise<{
+    confirmedRound: number;
+    txIDs: string[];
+    methodResults: ABIResult[];
+  }> {
+    if (this.status === AtomicTransactionComposerStatus.COMMITTED) {
+      throw new Error(
+        'Transaction group has already been executed successfully'
+      );
+    }
+
+    const txIDs = await this.submit(client);
+
+    const firstMethodCallIndex = this.transactions.findIndex((_, index) =>
+      this.methodCalls.has(index)
+    );
+    const indexToWaitFor =
+      firstMethodCallIndex === -1 ? 0 : firstMethodCallIndex;
+    const confirmedTxnInfo = await waitForConfirmation(
+      client,
+      txIDs[indexToWaitFor],
+      waitRounds
+    );
+
+    this.status = AtomicTransactionComposerStatus.COMMITTED;
+
+    const confirmedRound: number = confirmedTxnInfo['confirmed-round'];
+
+    const methodResults: ABIResult[] = [];
+
+    for (const [txnIndex, method] of this.methodCalls) {
+      const txID = txIDs[txnIndex];
+
+      const methodResult: ABIResult = {
+        txID,
+        rawReturnValue: new Uint8Array(),
+      };
+
+      try {
+        if (method.returns.type !== 'void') {
+          const pendingInfo =
+            txnIndex === firstMethodCallIndex
+              ? confirmedTxnInfo
+              : // eslint-disable-next-line no-await-in-loop
+                await client.pendingTransactionInformation(txID).do();
+
+          const logs: string[] = pendingInfo.logs || [];
+
+          // first 4 bytes of SHA-512/256 hash of "return"
+          const returnPrefix = Buffer.from([21, 31, 124, 117]);
+
+          let returnValueEncoded: Buffer | undefined;
+
+          for (let i = logs.length - 1; i >= 0; i--) {
+            const log = Buffer.from(logs[i], 'base64');
+            if (log.byteLength >= 4 && log.slice(0, 4).equals(returnPrefix)) {
+              returnValueEncoded = log.slice(4);
+              break;
+            }
+          }
+
+          if (returnValueEncoded == null) {
+            throw new Error('App call transaction did not log a return value');
+          }
+
+          methodResult.rawReturnValue = new Uint8Array(returnValueEncoded);
+          methodResult.returnValue = method.returns.type.decode(
+            methodResult.rawReturnValue
+          );
+        }
+      } catch (err) {
+        methodResult.decodeError = err;
+      }
+
+      methodResults.push(methodResult);
+    }
+
+    return {
+      confirmedRound,
+      txIDs,
+      methodResults,
+    };
+  }
+}
diff --git a/src/encoding/bigint.ts b/src/encoding/bigint.ts
new file mode 100644
index 0000000..1f1b15d
--- /dev/null
+++ b/src/encoding/bigint.ts
@@ -0,0 +1,33 @@
+/**
+ * bigIntToBytes converts a BigInt to a big-endian Uint8Array for encoding.
+ * @param bi - The bigint to convert.
+ * @param size - The size of the resulting byte array.
+ * @returns A byte array containing the big-endian encoding of the input bigint
+ */
+export function bigIntToBytes(bi: bigint | number, size: number) {
+  let hex = bi.toString(16);
+  // Pad the hex with zeros so it matches the size in bytes
+  if (hex.length !== size * 2) {
+    hex = hex.padStart(size * 2, '0');
+  }
+  const byteArray = new Uint8Array(hex.length / 2);
+  for (let i = 0, j = 0; i < hex.length / 2; i++, j += 2) {
+    byteArray[i] = parseInt(hex.slice(j, j + 2), 16);
+  }
+  return byteArray;
+}
+
+/**
+ * bytesToBigInt produces a bigint from a binary representation.
+ *
+ * @param bytes - The Uint8Array to convert.
+ * @returns The bigint that was encoded in the input data.
+ */
+export function bytesToBigInt(bytes: Uint8Array) {
+  let res = BigInt(0);
+  const buf = Buffer.from(bytes);
+  for (let i = 0; i < bytes.length; i++) {
+    res = BigInt(Number(buf.readUIntBE(i, 1))) + res * BigInt(256);
+  }
+  return res;
+}
diff --git a/src/logicsig.ts b/src/logicsig.ts
index c91b56a..3a42908 100644
--- a/src/logicsig.ts
+++ b/src/logicsig.ts
@@ -2,7 +2,7 @@ import * as nacl from './nacl/naclWrappers';
 import * as address from './encoding/address';
 import * as encoding from './encoding/encoding';
 import * as logic from './logic/logic';
-import * as multisig from './multisig';
+import { verifyMultisig } from './multisig';
 import * as utils from './utils/utils';
 import * as txnBuilder from './transaction';
 import {
@@ -109,7 +109,7 @@ export class LogicSig implements LogicSigStorageStructure {
       return nacl.verify(toBeSigned, this.sig, publicKey);
     }
 
-    return multisig.verifyMultisig(toBeSigned, this.msig, publicKey);
+    return verifyMultisig(toBeSigned, this.msig, publicKey);
   }
 
   /**
diff --git a/src/main.ts b/src/main.ts
index f5050c4..b5bbfa0 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,13 +2,10 @@ import * as nacl from './nacl/naclWrappers';
 import * as address from './encoding/address';
 import * as encoding from './encoding/encoding';
 import * as txnBuilder from './transaction';
-import * as multisig from './multisig';
 import * as LogicTemplatesCommonJSExport from './logicTemplates';
 import Bid, { BidOptions } from './bid';
 import * as convert from './convert';
 import * as utils from './utils/utils';
-import AnyTransaction, { EncodedSignedTransaction } from './types/transactions';
-import { MultisigMetadata } from './types/multisig';
 
 const SIGN_BYTES_PREFIX = Buffer.from([77, 88]); // "MX"
 
@@ -94,113 +91,6 @@ export function verifyBytes(
   return nacl.verify(toBeVerified, signature, pk);
 }
 
-/**
- * signMultisigTransaction takes a raw transaction (see signTransaction), a multisig preimage, a secret key, and returns
- * a multisig transaction, which is a blob representing a transaction and multisignature account preimage. The returned
- * multisig txn can accumulate additional signatures through mergeMultisigTransactions or appendMultisigTransaction.
- * @param txn - object with either payment or key registration fields
- * @param version - multisig version
- * @param threshold - multisig threshold
- * @param addrs - a list of Algorand addresses representing possible signers for this multisig. Order is important.
- * @param sk - Algorand secret key. The corresponding pk should be in the pre image.
- * @returns object containing txID, and blob of partially signed multisig transaction (with multisig preimage information)
- * If the final calculated fee is lower than the protocol minimum fee, the fee will be increased to match the minimum.
- */
-export function signMultisigTransaction(
-  txn: txnBuilder.TransactionLike,
-  { version, threshold, addrs }: MultisigMetadata,
-  sk: Uint8Array
-) {
-  // check that the from field matches the mSigPreImage. If from field is not populated, fill it in.
-  const expectedFromRaw = address.fromMultisigPreImgAddrs({
-    version,
-    threshold,
-    addrs,
-  });
-  if (!Object.prototype.hasOwnProperty.call(txn, 'from')) {
-    // eslint-disable-next-line no-param-reassign
-    txn.from = expectedFromRaw;
-  }
-  // build pks for partialSign
-  const pks = addrs.map((addr) => address.decodeAddress(addr).publicKey);
-  // `txn` needs to be handled differently if it's a constructed `Transaction` vs a dict of constructor args
-  const txnAlreadyBuilt = txn instanceof txnBuilder.Transaction;
-  let algoTxn: multisig.MultisigTransaction;
-  let blob: Uint8Array;
-  if (txnAlreadyBuilt) {
-    algoTxn = (txn as unknown) as multisig.MultisigTransaction;
-    blob = multisig.MultisigTransaction.prototype.partialSignTxn.call(
-      algoTxn,
-      { version, threshold, pks },
-      sk
-    );
-  } else {
-    algoTxn = new multisig.MultisigTransaction(txn as AnyTransaction);
-    blob = algoTxn.partialSignTxn({ version, threshold, pks }, sk);
-  }
-  return {
-    txID: algoTxn.txID().toString(),
-    blob,
-  };
-}
-
-/**
- * mergeMultisigTransactions takes a list of multisig transaction blobs, and merges them.
- * @param multisigTxnBlobs - a list of blobs representing encoded multisig txns
- * @returns blob representing encoded multisig txn
- */
-export function mergeMultisigTransactions(multisigTxnBlobs: Uint8Array[]) {
-  return multisig.mergeMultisigTransactions(multisigTxnBlobs);
-}
-
-/**
- * appendSignMultisigTransaction takes a multisig transaction blob, and appends our signature to it.
- * While we could derive public key preimagery from the partially-signed multisig transaction,
- * we ask the caller to pass it back in, to ensure they know what they are signing.
- * @param multisigTxnBlob - an encoded multisig txn. Supports non-payment txn types.
- * @param version - multisig version
- * @param threshold - multisig threshold
- * @param addrs - a list of Algorand addresses representing possible signers for this multisig. Order is important.
- * @param sk - Algorand secret key
- * @returns object containing txID, and blob representing encoded multisig txn
- */
-export function appendSignMultisigTransaction(
-  multisigTxnBlob: Uint8Array,
-  { version, threshold, addrs }: MultisigMetadata,
-  sk: Uint8Array
-) {
-  const pks = addrs.map((addr) => address.decodeAddress(addr).publicKey);
-  // obtain underlying txn, sign it, and merge it
-  const multisigTxObj = encoding.decode(
-    multisigTxnBlob
-  ) as EncodedSignedTransaction;
-  const msigTxn = multisig.MultisigTransaction.from_obj_for_encoding(
-    multisigTxObj.txn
-  );
-  const partialSignedBlob = msigTxn.partialSignTxn(
-    { version, threshold, pks },
-    sk
-  );
-  return {
-    txID: msigTxn.txID().toString(),
-    blob: mergeMultisigTransactions([multisigTxnBlob, partialSignedBlob]),
-  };
-}
-
-/**
- * multisigAddress takes multisig metadata (preimage) and returns the corresponding human readable Algorand address.
- * @param version - mutlisig version
- * @param threshold - multisig threshold
- * @param addrs - list of Algorand addresses
- */
-export function multisigAddress({
-  version,
-  threshold,
-  addrs,
-}: MultisigMetadata) {
-  return address.fromMultisigPreImgAddrs({ version, threshold, addrs });
-}
-
 /**
  * encodeObj takes a javascript object and returns its msgpack encoding
  * Note that the encoding sorts the fields alphabetically
@@ -233,6 +123,7 @@ export { default as Kmd } from './client/kmd';
 export { default as IntDecoding } from './types/intDecoding';
 export { default as Account } from './types/account';
 export { default as Indexer } from './client/v2/indexer/indexer';
+export { waitForConfirmation } from './wait';
 export {
   isValidAddress,
   encodeAddress,
@@ -265,8 +156,17 @@ export {
   tealSign,
   tealSignFromProgram,
 } from './logicsig';
+export {
+  signMultisigTransaction,
+  mergeMultisigTransactions,
+  appendSignMultisigTransaction,
+  multisigAddress,
+} from './multisig';
 export const LogicTemplates = LogicTemplatesCommonJSExport.default;
 
 export * from './makeTxn';
 export * from './transaction';
+export * from './signer';
+export * from './composer';
 export * from './types';
+export * from './abi';
diff --git a/src/makeTxn.ts b/src/makeTxn.ts
index 4592016..150d916 100644
--- a/src/makeTxn.ts
+++ b/src/makeTxn.ts
@@ -1392,7 +1392,7 @@ export function makeApplicationOptInTxn(
   appArgs?: AppOptInTxn['appArgs'],
   accounts?: AppOptInTxn['appAccounts'],
   foreignApps?: AppOptInTxn['appForeignApps'],
-  foreignAssets?: AppOptInTxn['appForeignApps'],
+  foreignAssets?: AppOptInTxn['appForeignAssets'],
   note?: AppOptInTxn['note'],
   lease?: AppOptInTxn['lease'],
   rekeyTo?: AppOptInTxn['reKeyTo']
@@ -1714,3 +1714,77 @@ export function makeApplicationNoOpTxnFromObject(
 }
 
 export { OnApplicationComplete } from './types/transactions/base';
+
+/**
+ * Generic function for creating any application call transaction.
+ */
+export function makeApplicationCallTxnFromObject(
+  options: Pick<
+    RenameProperties<
+      MustHaveSuggestedParams<AppCreateTxn>,
+      {
+        appOnComplete: 'onComplete';
+        appAccounts: 'accounts';
+        appForeignApps: 'foreignApps';
+        appForeignAssets: 'foreignAssets';
+        reKeyTo: 'rekeyTo';
+      }
+    >,
+    | 'from'
+    | 'suggestedParams'
+    | 'appIndex'
+    | 'onComplete'
+    | 'appArgs'
+    | 'accounts'
+    | 'foreignApps'
+    | 'foreignAssets'
+    | 'note'
+    | 'lease'
+    | 'rekeyTo'
+    | 'extraPages'
+  > &
+    Partial<
+      Pick<
+        RenameProperties<
+          MustHaveSuggestedParams<AppCreateTxn>,
+          {
+            appApprovalProgram: 'approvalProgram';
+            appClearProgram: 'clearProgram';
+            appLocalInts: 'numLocalInts';
+            appLocalByteSlices: 'numLocalByteSlices';
+            appGlobalInts: 'numGlobalInts';
+            appGlobalByteSlices: 'numGlobalByteSlices';
+          }
+        >,
+        | 'approvalProgram'
+        | 'clearProgram'
+        | 'numLocalInts'
+        | 'numLocalByteSlices'
+        | 'numGlobalInts'
+        | 'numGlobalByteSlices'
+      >
+    >
+) {
+  const o: AppCreateTxn = {
+    type: TransactionType.appl,
+    from: options.from,
+    suggestedParams: options.suggestedParams,
+    appIndex: options.appIndex,
+    appOnComplete: options.onComplete,
+    appLocalInts: options.numLocalInts,
+    appLocalByteSlices: options.numLocalByteSlices,
+    appGlobalInts: options.numGlobalInts,
+    appGlobalByteSlices: options.numGlobalByteSlices,
+    appApprovalProgram: options.approvalProgram,
+    appClearProgram: options.clearProgram,
+    appArgs: options.appArgs,
+    appAccounts: options.accounts,
+    appForeignApps: options.foreignApps,
+    appForeignAssets: options.foreignAssets,
+    note: options.note,
+    lease: options.lease,
+    reKeyTo: options.rekeyTo,
+    extraPages: options.extraPages,
+  };
+  return new txnBuilder.Transaction(o);
+}
diff --git a/src/multisig.ts b/src/multisig.ts
index 6a4dace..5c89fcb 100644
--- a/src/multisig.ts
+++ b/src/multisig.ts
@@ -3,7 +3,7 @@ import * as address from './encoding/address';
 import * as encoding from './encoding/encoding';
 import * as txnBuilder from './transaction';
 import * as utils from './utils/utils';
-import { EncodedTransaction } from './types/transactions';
+import AnyTransaction, { EncodedTransaction } from './types/transactions';
 import { MultisigMetadata } from './types/multisig';
 import {
   EncodedMultisig,
@@ -309,4 +309,98 @@ export function verifyMultisig(
   return true;
 }
 
-export default MultisigTransaction;
+/**
+ * signMultisigTransaction takes a raw transaction (see signTransaction), a multisig preimage, a secret key, and returns
+ * a multisig transaction, which is a blob representing a transaction and multisignature account preimage. The returned
+ * multisig txn can accumulate additional signatures through mergeMultisigTransactions or appendMultisigTransaction.
+ * @param txn - object with either payment or key registration fields
+ * @param version - multisig version
+ * @param threshold - multisig threshold
+ * @param addrs - a list of Algorand addresses representing possible signers for this multisig. Order is important.
+ * @param sk - Algorand secret key. The corresponding pk should be in the pre image.
+ * @returns object containing txID, and blob of partially signed multisig transaction (with multisig preimage information)
+ * If the final calculated fee is lower than the protocol minimum fee, the fee will be increased to match the minimum.
+ */
+export function signMultisigTransaction(
+  txn: txnBuilder.TransactionLike,
+  { version, threshold, addrs }: MultisigMetadata,
+  sk: Uint8Array
+) {
+  // check that the from field matches the mSigPreImage. If from field is not populated, fill it in.
+  const expectedFromRaw = address.fromMultisigPreImgAddrs({
+    version,
+    threshold,
+    addrs,
+  });
+  if (!Object.prototype.hasOwnProperty.call(txn, 'from')) {
+    // eslint-disable-next-line no-param-reassign
+    txn.from = expectedFromRaw;
+  }
+  // build pks for partialSign
+  const pks = addrs.map((addr) => address.decodeAddress(addr).publicKey);
+  // `txn` needs to be handled differently if it's a constructed `Transaction` vs a dict of constructor args
+  const txnAlreadyBuilt = txn instanceof txnBuilder.Transaction;
+  let algoTxn: MultisigTransaction;
+  let blob: Uint8Array;
+  if (txnAlreadyBuilt) {
+    algoTxn = (txn as unknown) as MultisigTransaction;
+    blob = MultisigTransaction.prototype.partialSignTxn.call(
+      algoTxn,
+      { version, threshold, pks },
+      sk
+    );
+  } else {
+    algoTxn = new MultisigTransaction(txn as AnyTransaction);
+    blob = algoTxn.partialSignTxn({ version, threshold, pks }, sk);
+  }
+  return {
+    txID: algoTxn.txID().toString(),
+    blob,
+  };
+}
+
+/**
+ * appendSignMultisigTransaction takes a multisig transaction blob, and appends our signature to it.
+ * While we could derive public key preimagery from the partially-signed multisig transaction,
+ * we ask the caller to pass it back in, to ensure they know what they are signing.
+ * @param multisigTxnBlob - an encoded multisig txn. Supports non-payment txn types.
+ * @param version - multisig version
+ * @param threshold - multisig threshold
+ * @param addrs - a list of Algorand addresses representing possible signers for this multisig. Order is important.
+ * @param sk - Algorand secret key
+ * @returns object containing txID, and blob representing encoded multisig txn
+ */
+export function appendSignMultisigTransaction(
+  multisigTxnBlob: Uint8Array,
+  { version, threshold, addrs }: MultisigMetadata,
+  sk: Uint8Array
+) {
+  const pks = addrs.map((addr) => address.decodeAddress(addr).publicKey);
+  // obtain underlying txn, sign it, and merge it
+  const multisigTxObj = encoding.decode(
+    multisigTxnBlob
+  ) as EncodedSignedTransaction;
+  const msigTxn = MultisigTransaction.from_obj_for_encoding(multisigTxObj.txn);
+  const partialSignedBlob = msigTxn.partialSignTxn(
+    { version, threshold, pks },
+    sk
+  );
+  return {
+    txID: msigTxn.txID().toString(),
+    blob: mergeMultisigTransactions([multisigTxnBlob, partialSignedBlob]),
+  };
+}
+
+/**
+ * multisigAddress takes multisig metadata (preimage) and returns the corresponding human readable Algorand address.
+ * @param version - mutlisig version
+ * @param threshold - multisig threshold
+ * @param addrs - list of Algorand addresses
+ */
+export function multisigAddress({
+  version,
+  threshold,
+  addrs,
+}: MultisigMetadata) {
+  return address.fromMultisigPreImgAddrs({ version, threshold, addrs });
+}
diff --git a/src/signer.ts b/src/signer.ts
new file mode 100644
index 0000000..1f8020a
--- /dev/null
+++ b/src/signer.ts
@@ -0,0 +1,105 @@
+import { Transaction } from './transaction';
+import Account from './types/account';
+import { LogicSigAccount, signLogicSigTransactionObject } from './logicsig';
+import { MultisigMetadata } from './types/multisig';
+import { signMultisigTransaction, mergeMultisigTransactions } from './multisig';
+
+/**
+ * This type represents a function which can sign transactions from an atomic transaction group.
+ * @param txnGroup - The atomic group containing transactions to be signed
+ * @param indexesToSign - An array of indexes in the atomic transaction group that should be signed
+ * @returns A promise which resolves 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]]
+ */
+export type TransactionSigner = (
+  txnGroup: Transaction[],
+  indexesToSign: number[]
+) => Promise<Uint8Array[]>;
+
+/**
+ * Create a TransactionSigner that can sign transactions for the provided basic Account.
+ */
+export function makeBasicAccountTransactionSigner(
+  account: Account
+): TransactionSigner {
+  return (txnGroup: Transaction[], indexesToSign: number[]) => {
+    const signed: Uint8Array[] = [];
+
+    for (const index of indexesToSign) {
+      signed.push(txnGroup[index].signTxn(account.sk));
+    }
+
+    return Promise.resolve(signed);
+  };
+}
+
+/**
+ * Create a TransactionSigner that can sign transactions for the provided LogicSigAccount.
+ */
+export function makeLogicSigAccountTransactionSigner(
+  account: LogicSigAccount
+): TransactionSigner {
+  return (txnGroup: Transaction[], indexesToSign: number[]) => {
+    const signed: Uint8Array[] = [];
+
+    for (const index of indexesToSign) {
+      const { blob } = signLogicSigTransactionObject(txnGroup[index], account);
+      signed.push(blob);
+    }
+
+    return Promise.resolve(signed);
+  };
+}
+
+/**
+ * Create a TransactionSigner that can sign transactions for the provided Multisig account.
+ * @param msig - The Multisig account metadata
+ * @param sks - An array of private keys belonging to the msig which should sign the transactions.
+ */
+export function makeMultiSigAccountTransactionSigner(
+  msig: MultisigMetadata,
+  sks: Uint8Array[]
+): TransactionSigner {
+  return (txnGroup: Transaction[], indexesToSign: number[]) => {
+    const signed: Uint8Array[] = [];
+
+    for (const index of indexesToSign) {
+      const txn = txnGroup[index];
+      const partialSigs: Uint8Array[] = [];
+
+      for (const sk of sks) {
+        const { blob } = signMultisigTransaction(txn, msig, sk);
+        partialSigs.push(blob);
+      }
+
+      signed.push(mergeMultisigTransactions(partialSigs));
+    }
+
+    return Promise.resolve(signed);
+  };
+}
+
+/** Represents an unsigned transactions and a signer that can authorize that transaction. */
+export interface TransactionWithSigner {
+  /** An unsigned transaction */
+  txn: Transaction;
+  /** A transaction signer that can authorize txn */
+  signer: TransactionSigner;
+}
+
+/**
+ * Check if a value conforms to the TransactionWithSigner structure.
+ * @param value - The value to check.
+ * @returns True if an only if the value has the structure of a TransactionWithSigner.
+ */
+export function isTransactionWithSigner(
+  value: any
+): value is TransactionWithSigner {
+  return (
+    typeof value === 'object' &&
+    Object.keys(value).length === 2 &&
+    typeof value.txn === 'object' &&
+    typeof value.signer === 'function'
+  );
+}
diff --git a/src/transaction.ts b/src/transaction.ts
index 3aa0a83..2d01f79 100644
--- a/src/transaction.ts
+++ b/src/transaction.ts
@@ -1047,6 +1047,10 @@ export class Transaction implements TransactionStorageStructure {
         (forPrinting.to as Address).publicKey
       );
     // things that need fixing:
+    if (forPrinting.freezeAccount !== undefined)
+      forPrinting.freezeAccount = address.encodeAddress(
+        (forPrinting.freezeAccount as Address).publicKey
+      );
     if (forPrinting.closeRemainderTo !== undefined)
       forPrinting.closeRemainderTo = address.encodeAddress(
         (forPrinting.closeRemainderTo as Address).publicKey
diff --git a/src/types/intDecoding.ts b/src/types/intDecoding.ts
index 999d3ba..e974b05 100644
--- a/src/types/intDecoding.ts
+++ b/src/types/intDecoding.ts
@@ -1,7 +1,6 @@
 /**
  * Configure how integers in JSON response will be decoded.
  */
-/* eslint-disable no-unused-vars,no-shadow */
 enum IntDecoding {
   /**
    * All integers will be decoded as Numbers, meaning any values greater than
@@ -26,6 +25,5 @@ enum IntDecoding {
    */
   BIGINT = 'bigint',
 }
-/* eslint-enable no-unused-vars,no-shadow */
 
 export default IntDecoding;
diff --git a/src/types/transactions/base.ts b/src/types/transactions/base.ts
index 536d31c..8f33146 100644
--- a/src/types/transactions/base.ts
+++ b/src/types/transactions/base.ts
@@ -1,5 +1,3 @@
-/* eslint-disable no-shadow,no-unused-vars */
-
 /**
  * Enum for application transaction types.
  *
diff --git a/src/wait.ts b/src/wait.ts
new file mode 100644
index 0000000..b83573e
--- /dev/null
+++ b/src/wait.ts
@@ -0,0 +1,46 @@
+import Algodv2 from './client/v2/algod/algod';
+
+/**
+ * Wait until a transaction has been confirmed or rejected by the network, or
+ * until 'waitRounds' number of rounds have passed.
+ * @param client - An Algodv2 client
+ * @param txid - The ID of the transaction to wait for.
+ * @param waitRounds - The maximum number of rounds to wait for.
+ * @returns A promise that, upon success, will resolve to the output of the
+ *   `pendingTransactionInformation` call for the confirmed transaction.
+ */
+export async function waitForConfirmation(
+  client: Algodv2,
+  txid: string,
+  waitRounds: number
+): Promise<Record<string, any>> {
+  // Wait until the transaction is confirmed or rejected, or until 'waitRounds'
+  // number of rounds have passed.
+
+  const status = await client.status().do();
+  if (typeof status === 'undefined') {
+    throw new Error('Unable to get node status');
+  }
+  const startRound = status['last-round'] + 1;
+  let currentRound = startRound;
+
+  /* eslint-disable no-await-in-loop */
+  while (currentRound < startRound + waitRounds) {
+    const pendingInfo = await client.pendingTransactionInformation(txid).do();
+
+    if (pendingInfo['confirmed-round']) {
+      // Got the completed Transaction
+      return pendingInfo;
+    }
+
+    if (pendingInfo['pool-error']) {
+      // If there was a pool error, then the transaction has been rejected!
+      throw new Error(`Transaction Rejected: ${pendingInfo['pool-error']}`);
+    }
+
+    await client.statusAfterBlock(currentRound).do();
+    currentRound += 1;
+  }
+  /* eslint-enable no-await-in-loop */
+  throw new Error(`Transaction not confirmed after ${waitRounds} rounds!`);
+}


Test patch:
diff --git a/tests/10.ABI.ts b/tests/10.ABI.ts
new file mode 100644
index 0000000..ca90f6f
--- /dev/null
+++ b/tests/10.ABI.ts
@@ -0,0 +1,586 @@
+import assert from 'assert';
+import {
+  ABIAddressType,
+  ABIArrayDynamicType,
+  ABIArrayStaticType,
+  ABIBoolType,
+  ABIByteType,
+  ABIStringType,
+  ABITupleType,
+  ABIUfixedType,
+  ABIUintType,
+  ABIType,
+} from '../src/abi/abi_type';
+import { decodeAddress } from '../src/encoding/address';
+
+describe('ABI type checking', () => {
+  it('should create the correct type from the string', () => {
+    for (let i = 8; i < 513; i += 8) {
+      let expected = new ABIUintType(i);
+      let actual = ABIType.from(`uint${i}`);
+      assert.deepStrictEqual(actual, expected);
+      for (let j = 1; j < 161; j++) {
+        expected = new ABIUfixedType(i, j);
+        actual = ABIType.from(`ufixed${i}x${j}`);
+        assert.deepStrictEqual(actual, expected);
+      }
+    }
+
+    const testCases = [
+      ['address', new ABIAddressType()],
+      ['bool', new ABIBoolType()],
+      ['byte', new ABIByteType()],
+      ['string', new ABIStringType()],
+      ['uint32[]', new ABIArrayDynamicType(new ABIUintType(32))],
+      [
+        'byte[][]',
+        new ABIArrayDynamicType(new ABIArrayDynamicType(new ABIByteType())),
+      ],
+      ['ufixed256x64[]', new ABIArrayDynamicType(new ABIUfixedType(256, 64))],
+      [
+        'ufixed128x10[100]',
+        new ABIArrayStaticType(new ABIUfixedType(128, 10), 100),
+      ],
+      [
+        'bool[256][100]',
+        new ABIArrayStaticType(
+          new ABIArrayStaticType(new ABIBoolType(), 256),
+          100
+        ),
+      ],
+      ['()', new ABITupleType([])],
+      [
+        '(uint16,(byte,address[10]))',
+        new ABITupleType([
+          new ABIUintType(16),
+          new ABITupleType([
+            new ABIByteType(),
+            new ABIArrayStaticType(new ABIAddressType(), 10),
+          ]),
+        ]),
+      ],
+      [
+        '(uint256,(byte,address[10]),(),bool)',
+        new ABITupleType([
+          new ABIUintType(256),
+          new ABITupleType([
+            new ABIByteType(),
+            new ABIArrayStaticType(new ABIAddressType(), 10),
+          ]),
+          new ABITupleType([]),
+          new ABIBoolType(),
+        ]),
+      ],
+      [
+        '(ufixed256x16,((string),bool,(address,uint8)))',
+        new ABITupleType([
+          new ABIUfixedType(256, 16),
+          new ABITupleType([
+            new ABITupleType([new ABIStringType()]),
+            new ABIBoolType(),
+            new ABITupleType([new ABIAddressType(), new ABIUintType(8)]),
+          ]),
+        ]),
+      ],
+    ];
+
+    for (const testCase of testCases) {
+      const actual = ABIType.from(testCase[0] as string);
+      assert.deepStrictEqual(actual, testCase[1]);
+    }
+  });
+  it('should fail for an invalid bit size or precision', () => {
+    const invalidSizes = [-1, 0, 9, 513, 1024];
+    const invalidPrecisions = [-1, 0, 161];
+
+    for (const size of invalidSizes) {
+      assert.throws(() => new ABIUintType(size));
+      assert.throws(() => new ABIUfixedType(size, 10));
+    }
+    for (const precision of invalidPrecisions) {
+      assert.throws(() => new ABIUfixedType(8, precision));
+    }
+  });
+  it('should fail for an invalid type string', () => {
+    const testCases = [
+      // uint
+      'uint 8',
+      'uint8 ',
+      'uint123x345',
+      'uint!8',
+      'uint[32]',
+      'uint-893',
+      'uint#120\\',
+      // ufixed
+      'ufixed000000000016x0000010',
+      'ufixed123x345',
+      'ufixed 128 x 100',
+      'ufixed64x10 ',
+      'ufixed!8x2 ',
+      'ufixed[32]x16',
+      'ufixed-64x+100',
+      'ufixed16x+12',
+      // dynamic array
+      'byte[] ',
+      '[][][]',
+      'stuff[]',
+      // static array
+      'ufixed32x10[0]',
+      'byte[10 ]',
+      'uint64[0x21]',
+      // tuple
+      '(ufixed128x10))',
+      '(,uint128,byte[])',
+      '(address,ufixed64x5,)',
+      '(byte[16],somethingwrong)',
+      '(                )',
+      '((uint32)',
+      '(byte,,byte)',
+      '((byte),,(byte))',
+    ];
+
+    for (const testCase of testCases) {
+      assert.throws(() => ABIType.from(testCase));
+    }
+  });
+  it('should properly return whether the type is dynamic', () => {
+    const testCases = [
+      [new ABIUintType(8).isDynamic(), false],
+      [new ABIUfixedType(16, 10).isDynamic(), false],
+      [new ABIByteType().isDynamic(), false],
+      [new ABIBoolType().isDynamic(), false],
+      [new ABIAddressType().isDynamic(), false],
+      [new ABIStringType().isDynamic(), true],
+      [new ABIArrayDynamicType(new ABIBoolType()).isDynamic(), true],
+      [
+        new ABIArrayDynamicType(
+          new ABIArrayDynamicType(new ABIByteType())
+        ).isDynamic(),
+        true,
+      ],
+      [ABIType.from('(string[100])').isDynamic(), true],
+      [ABIType.from('(address,bool,uint256)').isDynamic(), false],
+      [ABIType.from('(uint8,(byte[10]))').isDynamic(), false],
+      [ABIType.from('(string,uint256)').isDynamic(), true],
+      [ABIType.from('(bool,(ufixed16x10[],(byte,address)))').isDynamic(), true],
+      [
+        ABIType.from('(bool,(uint256,(byte,address,string)))').isDynamic(),
+        true,
+      ],
+    ];
+
+    for (const testCase of testCases) {
+      const actual = testCase[0];
+      const expected = testCase[1];
+      assert.deepStrictEqual(actual, expected);
+    }
+  });
+  it('should properly return the byte length of the type', () => {
+    const testCases = [
+      [new ABIAddressType().byteLen(), 32],
+      [new ABIByteType().byteLen(), 1],
+      [new ABIBoolType().byteLen(), 1],
+      [new ABIUintType(64).byteLen(), 8],
+      [new ABIUfixedType(256, 50).byteLen(), 32],
+      [ABIType.from('bool[81]').byteLen(), 11],
+      [ABIType.from('bool[80]').byteLen(), 10],
+      [ABIType.from('bool[88]').byteLen(), 11],
+      [ABIType.from('address[5]').byteLen(), 160],
+      [ABIType.from('uint16[20]').byteLen(), 40],
+      [ABIType.from('ufixed64x20[10]').byteLen(), 80],
+      //   [ABIType.from('(address,byte,ufixed16x20)').byteLen(), 35],
+      [
+        ABIType.from(
+          '((bool,address[10]),(bool,bool,bool),uint8[20])'
+        ).byteLen(),
+        342,
+      ],
+      [ABIType.from('(bool,bool)').byteLen(), 1],
+      [ABIType.from(`(${'bool,'.repeat(6)}uint8)`).byteLen(), 2],
+      [
+        ABIType.from(
+          `(${'bool,'.repeat(10)}uint8,${'bool,'.repeat(10)}byte)`
+        ).byteLen(),
+        6,
+      ],
+    ];
+
+    for (const testCase of testCases) {
+      const actual = testCase[0];
+      const expected = testCase[1];
+      assert.deepStrictEqual(actual, expected);
+    }
+
+    // Dynamic types should not have a byte length
+    assert.throws(() => new ABIStringType().byteLen());
+    assert.throws(() => new ABIArrayDynamicType(new ABIBoolType()).byteLen());
+  });
+});
+
+describe('ABI encoding', () => {
+  it('should encode the value correctly into bytes', () => {
+    const testCases = [
+      [new ABIUintType(8).encode(BigInt(0)), new Uint8Array([0])],
+      [new ABIUintType(16).encode(BigInt(3)), new Uint8Array([0, 3])],
+      [
+        new ABIUintType(64).encode(256),
+        new Uint8Array([0, 0, 0, 0, 0, 0, 1, 0]),
+      ],
+      [new ABIUfixedType(8, 30).encode(BigInt(255)), new Uint8Array([255])],
+      [new ABIUfixedType(32, 10).encode(33), new Uint8Array([0, 0, 0, 33])],
+      [
+        new ABIAddressType().encode(
+          'MO2H6ZU47Q36GJ6GVHUKGEBEQINN7ZWVACMWZQGIYUOE3RBSRVYHV4ACJI'
+        ),
+        decodeAddress(
+          'MO2H6ZU47Q36GJ6GVHUKGEBEQINN7ZWVACMWZQGIYUOE3RBSRVYHV4ACJI'
+        ).publicKey,
+      ],
+      [new ABIByteType().encode(10), new Uint8Array([10])],
+      [new ABIByteType().encode(255), new Uint8Array([255])],
+      [new ABIBoolType().encode(true), new Uint8Array([128])],
+      [new ABIBoolType().encode(false), new Uint8Array([0])],
+      [
+        new ABIStringType().encode('asdf'),
+        new Uint8Array([0, 4, 97, 115, 100, 102]),
+      ],
+      [
+        new ABIArrayStaticType(new ABIBoolType(), 3).encode([
+          true,
+          true,
+          false,
+        ]),
+        new Uint8Array([192]),
+      ],
+      [
+        new ABIArrayStaticType(new ABIBoolType(), 8).encode([
+          false,
+          true,
+          false,
+          false,
+          false,
+          false,
+          false,
+          false,
+        ]),
+        new Uint8Array([64]),
+      ],
+      [
+        new ABIArrayStaticType(new ABIBoolType(), 8).encode([
+          true,
+          true,
+          true,
+          true,
+          true,
+          true,
+          true,
+          true,
+        ]),
+        new Uint8Array([255]),
+      ],
+      [
+        new ABIArrayStaticType(new ABIBoolType(), 9).encode([
+          true,
+          false,
+          false,
+          true,
+          false,
+          false,
+          true,
+          false,
+          true,
+        ]),
+        new Uint8Array([146, 128]),
+      ],
+      [
+        new ABIArrayStaticType(new ABIUintType(64), 3).encode([
+          BigInt(1),
+          BigInt(2),
+          BigInt(3),
+        ]),
+        new Uint8Array([
+          0,
+          0,
+          0,
+          0,
+          0,
+          0,
+          0,
+          1,
+          0,
+          0,
+          0,
+          0,
+          0,
+          0,
+          0,
+          2,
+          0,
+          0,
+          0,
+          0,
+          0,
+          0,
+          0,
+          3,
+        ]),
+      ],
+      [
+        new ABIArrayDynamicType(new ABIBoolType()).encode([]),
+        new Uint8Array([0, 0]),
+      ],
+      [
+        new ABIArrayDynamicType(new ABIBoolType()).encode([true, true, false]),
+        new Uint8Array([0, 3, 192]),
+      ],
+      [
+        new ABIArrayDynamicType(new ABIBoolType()).encode([
+          false,
+          true,
+          false,
+          false,
+          false,
+          false,
+          false,
+          false,
+        ]),
+        new Uint8Array([0, 8, 64]),
+      ],
+      [
+        new ABIArrayDynamicType(new ABIBoolType()).encode([
+          true,
+          false,
+          false,
+          true,
+          false,
+          false,
+          true,
+          false,
+          true,
+        ]),
+        new Uint8Array([0, 9, 146, 128]),
+      ],
+      [ABIType.from('()').encode([]), new Uint8Array([])],
+      // 2^6 + 2^5 = 64 + 32 = 96
+      [
+        ABIType.from('(bool,bool,bool)').encode([false, true, true]),
+        new Uint8Array([96]),
+      ],
+      [
+        ABIType.from('(bool[3])').encode([[false, true, true]]),
+        new Uint8Array([96]),
+      ],
+      [
+        ABIType.from('(bool[])').encode([[false, true, true]]),
+        new Uint8Array([0, 2, 0, 3, 96]),
+      ],
+      [
+        ABIType.from('(bool[2],bool[])').encode([
+          [true, true],
+          [true, true],
+        ]),
+        new Uint8Array([192, 0, 3, 0, 2, 192]),
+      ],
+      [
+        ABIType.from('(bool[],bool[])').encode([[], []]),
+        new Uint8Array([0, 4, 0, 6, 0, 0, 0, 0]),
+      ],
+      [
+        ABIType.from('(string,bool,bool,bool,bool,string)').encode([
+          'AB',
+          true,
+          false,
+          true,
+          false,
+          'DE',
+        ]),
+        new Uint8Array([0, 5, 160, 0, 9, 0, 2, 65, 66, 0, 2, 68, 69]),
+      ],
+    ];
+
+    for (const testCase of testCases) {
+      const actual = testCase[0];
+      const expected = testCase[1];
+      assert.deepStrictEqual(actual, expected);
+    }
+  });
+
+  it('should fail for bad values during encoding', () => {
+    assert.throws(() => new ABIUintType(8).encode(BigInt(-1)));
+    assert.throws(() => new ABIUintType(512).encode(BigInt(2 ** 512)));
+    assert.throws(() => new ABIUfixedType(512, 10).encode(BigInt(-1)));
+    assert.throws(() => new ABIByteType().encode(-1));
+    assert.throws(() => new ABIByteType().encode(256));
+    assert.throws(() => new ABIAddressType().encode('BADADDRESS'));
+    assert.throws(() =>
+      new ABIArrayStaticType(new ABIBoolType(), 3).encode([true])
+    );
+    assert.throws(() =>
+      new ABIArrayStaticType(new ABIStringType(), 1).encode([true])
+    );
+    assert.throws(() =>
+      new ABIArrayStaticType(new ABIUintType(256), 1).encode(['hello'])
+    );
+    assert.throws(() =>
+      new ABIArrayDynamicType(new ABIAddressType()).encode([false])
+    );
+    assert.throws(() =>
+      new ABITupleType([new ABIBoolType(), new ABIUfixedType(128, 20)]).encode([
+        BigInt(3),
+        true,
+      ])
+    );
+  });
+
+  it('should decode the value correctly into bytes', () => {
+    const testCases = [
+      [new ABIUintType(8).decode(new Uint8Array([0])), BigInt(0)],
+      [new ABIUintType(16).decode(new Uint8Array([0, 3])), BigInt(3)],
+      [
+        new ABIUintType(64).decode(new Uint8Array([1, 0, 0, 0, 0, 0, 0, 0])),
+        BigInt(2 ** 56),
+      ],
+      [new ABIUfixedType(8, 30).decode(new Uint8Array([255])), BigInt(255)],
+      [
+        new ABIUfixedType(32, 10).decode(new Uint8Array([0, 0, 0, 33])),
+        BigInt(33),
+      ],
+      [
+        new ABIAddressType().decode(
+          decodeAddress(
+            'MO2H6ZU47Q36GJ6GVHUKGEBEQINN7ZWVACMWZQGIYUOE3RBSRVYHV4ACJI'
+          ).publicKey
+        ),
+        'MO2H6ZU47Q36GJ6GVHUKGEBEQINN7ZWVACMWZQGIYUOE3RBSRVYHV4ACJI',
+      ],
+      [new ABIByteType().decode(new Uint8Array([10])), 10],
+      [new ABIByteType().decode(new Uint8Array([255])), 255],
+      [new ABIBoolType().decode(new Uint8Array([128])), true],
+      [new ABIBoolType().decode(new Uint8Array([0])), false],
+      [
+        new ABIStringType().decode(new Uint8Array([0, 4, 97, 115, 100, 102])),
+        'asdf',
+      ],
+      [
+        new ABIArrayStaticType(new ABIBoolType(), 3).decode(
+          new Uint8Array([192])
+        ),
+        [true, true, false],
+      ],
+      [
+        new ABIArrayStaticType(new ABIBoolType(), 8).decode(
+          new Uint8Array([64])
+        ),
+        [false, true, false, false, false, false, false, false],
+      ],
+      [
+        new ABIArrayStaticType(new ABIBoolType(), 8).decode(
+          new Uint8Array([255])
+        ),
+        [true, true, true, true, true, true, true, true],
+      ],
+      [
+        new ABIArrayStaticType(new ABIBoolType(), 9).decode(
+          new Uint8Array([146, 128])
+        ),
+        [true, false, false, true, false, false, true, false, true],
+      ],
+      [
+        new ABIArrayStaticType(new ABIUintType(64), 3).decode(
+          new Uint8Array([
+            0,
+            0,
+            0,
+            0,
+            0,
+            0,
+            0,
+            1,
+            0,
+            0,
+            0,
+            0,
+            0,
+            0,
+            0,
+            2,
+            0,
+            0,
+            0,
+            0,
+            0,
+            0,
+            0,
+            3,
+          ])
+        ),
+        [BigInt(1), BigInt(2), BigInt(3)],
+      ],
+      [
+        new ABIArrayDynamicType(new ABIBoolType()).decode(
+          new Uint8Array([0, 0])
+        ),
+        [],
+      ],
+      [
+        new ABIArrayDynamicType(new ABIBoolType()).decode(
+          new Uint8Array([0, 3, 192])
+        ),
+        [true, true, false],
+      ],
+      [
+        new ABIArrayDynamicType(new ABIBoolType()).decode(
+          new Uint8Array([0, 8, 64])
+        ),
+        [false, true, false, false, false, false, false, false],
+      ],
+      [
+        new ABIArrayDynamicType(new ABIBoolType()).decode(
+          new Uint8Array([0, 9, 146, 128])
+        ),
+        [true, false, false, true, false, false, true, false, true],
+      ],
+      [ABIType.from('()').decode(new Uint8Array([])), []],
+      //   // 2^6 + 2^5 = 64 + 32 = 96
+      [
+        ABIType.from('(bool,bool,bool)').decode(new Uint8Array([96])),
+        [false, true, true],
+      ],
+      [
+        ABIType.from('(bool[3])').decode(new Uint8Array([96])),
+        [[false, true, true]],
+      ],
+      [
+        ABIType.from('(bool[])').decode(new Uint8Array([0, 2, 0, 3, 96])),
+        [[false, true, true]],
+      ],
+      [
+        ABIType.from('(bool[2],bool[])').decode(
+          new Uint8Array([192, 0, 3, 0, 2, 192])
+        ),
+        [
+          [true, true],
+          [true, true],
+        ],
+      ],
+      [
+        ABIType.from('(bool[],bool[])').decode(
+          new Uint8Array([0, 4, 0, 6, 0, 0, 0, 0])
+        ),
+        [[], []],
+      ],
+      [
+        ABIType.from('(string,bool,bool,bool,bool,string)').decode(
+          new Uint8Array([0, 5, 160, 0, 9, 0, 2, 65, 66, 0, 2, 68, 69])
+        ),
+        ['AB', true, false, true, false, 'DE'],
+      ],
+    ];
+
+    for (const testCase of testCases) {
+      const actual = testCase[0];
+      const expected = testCase[1];
+      assert.deepStrictEqual(actual, expected);
+    }
+  });
+});
diff --git a/tests/cucumber/browser/test.js b/tests/cucumber/browser/test.js
index 4e38df1..ec3fc13 100644
--- a/tests/cucumber/browser/test.js
+++ b/tests/cucumber/browser/test.js
@@ -42,8 +42,24 @@ window.makeUint8Array = function makeUint8Array(arg) {
   return new Uint8Array(arg);
 };
 
-window.makeEmptyObject = function makeEmptyObject() {
-  return {};
+window.makeABIMethod = function makeABIMethod(arg) {
+  return new window.algosdk.ABIMethod(arg);
+};
+
+window.makeABIContract = function makeABIContract(arg) {
+  return new window.algosdk.ABIContract(arg);
+};
+
+window.makeArray = function makeArray(...args) {
+  return args;
+};
+
+window.makeObject = function makeObject(obj) {
+  return { ...obj };
+};
+
+window.parseJSON = function parseJSON(json) {
+  return JSON.parse(json);
 };
 
 window.formatIncludeAll = function formatIncludeAll(includeAll) {
diff --git a/tests/cucumber/steps/index.js b/tests/cucumber/steps/index.js
index 8151fc4..03c70c0 100644
--- a/tests/cucumber/steps/index.js
+++ b/tests/cucumber/steps/index.js
@@ -224,6 +224,14 @@ const corsHeaders = {
   'Access-Control-Max-Age': 2592000,
 };
 
+function getUnitTestFileContentsAsString(fileName, directory) {
+  return fs
+    .readFileSync(
+      path.join(cucumberPath, 'features', 'unit', directory, fileName)
+    )
+    .toString();
+}
+
 function setupMockServerForResponses(fileName, jsonDirectory, mockServer) {
   const resultString = fs
     .readFileSync(
@@ -453,6 +461,13 @@ for (const name of Object.keys(steps.then)) {
         expectedRequestPath
       );
     });
+  } else if (
+    name === 'the produced json should equal {string} loaded from {string}'
+  ) {
+    Then(name, function (fileName, directory) {
+      const expectedJson = getUnitTestFileContentsAsString(fileName, directory);
+      return fn.call(this, expectedJson);
+    });
   } else {
     Then(name, fn);
   }
diff --git a/tests/cucumber/steps/steps.js b/tests/cucumber/steps/steps.js
index 9b34f9e..24f541f 100644
--- a/tests/cucumber/steps/steps.js
+++ b/tests/cucumber/steps/steps.js
@@ -36,22 +36,42 @@ async function loadResource(res) {
   });
 }
 
+// START OBJECT CREATION FUNCTIONS
+
 /**
- * This function must be used instead of creating Uint8Arrays directly because of this firefox
- * issue: https://github.com/mozilla/geckodriver/issues/1798
+ * If you wish to compare complex objects from different steps, these functions must be used instead
+ * of creating the objects directly. This is because of this firefox issue: https://github.com/mozilla/geckodriver/issues/1798
+ *
+ * If you get an assertion error on firefox that says 'Values identical but not reference-equal',
+ * you should probably use these functions or make new ones as needed.
  */
+
 function makeUint8Array(arg) {
   return new Uint8Array(arg);
 }
 
-/**
- * This function must be used instead of creating an empty object for use in assert.deepStructEqual
- * because of this firefox issue: https://github.com/mozilla/geckodriver/issues/1798
- */
-function makeEmptyObject() {
-  return {};
+function makeABIMethod(arg) {
+  return new algosdk.ABIMethod(arg);
+}
+
+function makeABIContract(arg) {
+  return new algosdk.ABIContract(arg);
 }
 
+function makeArray(...args) {
+  return args;
+}
+
+function makeObject(obj) {
+  return { ...obj };
+}
+
+function parseJSON(json) {
+  return JSON.parse(json);
+}
+
+// END OBJECT CREATION FUNCTIONS
+
 function formatIncludeAll(includeAll) {
   if (!['true', 'false'].includes(includeAll)) {
     throw new Error(`Unknown value for includeAll: ${includeAll}`);
@@ -501,7 +521,7 @@ module.exports = function getSteps(options) {
 
   Then('the node should be healthy', async function () {
     const health = await this.acl.healthCheck();
-    assert.deepStrictEqual(health, makeEmptyObject());
+    assert.deepStrictEqual(health, makeObject({}));
   });
 
   Then('I get the ledger supply', async function () {
@@ -3970,12 +3990,59 @@ module.exports = function getSteps(options) {
   Given(
     'a signing account with address {string} and mnemonic {string}',
     function (address, mnemonic) {
-      this.signingMnemonic = mnemonic;
+      this.signingAccount = algosdk.mnemonicToSecretKey(mnemonic);
+      if (this.signingAccount.addr !== address) {
+        throw new Error(
+          `Address does not match mnemonic: ${this.signingAccount.addr} !== ${address}`
+        );
+      }
+    }
+  );
+
+  Given(
+    'suggested transaction parameters fee {int}, flat-fee {string}, first-valid {int}, last-valid {int}, genesis-hash {string}, genesis-id {string}',
+    function (fee, flatFee, firstRound, lastRound, genesisHash, genesisID) {
+      assert.ok(['true', 'false'].includes(flatFee));
+
+      this.suggestedParams = {
+        flatFee: flatFee === 'true',
+        fee,
+        firstRound,
+        lastRound,
+        genesisID,
+        genesisHash,
+      };
+    }
+  );
+
+  Given(
+    'suggested transaction parameters from the algod v2 client',
+    async function () {
+      this.suggestedParams = await this.v2Client.getTransactionParams().do();
+    }
+  );
+
+  When(
+    'I build a payment transaction with sender {string}, receiver {string}, amount {int}, close remainder to {string}',
+    function (sender, receiver, amount, closeTo) {
+      const from = sender === 'transient' ? this.transientAccount.addr : sender;
+      const to =
+        receiver === 'transient' ? this.transientAccount.addr : receiver;
+
+      this.txn = algosdk.makePaymentTxnWithSuggestedParamsFromObject({
+        from,
+        to,
+        amount: parseInt(amount, 10),
+        closeRemainderTo: closeTo.length === 0 ? undefined : closeTo,
+        suggestedParams: this.suggestedParams,
+      });
     }
   );
 
   function operationStringToEnum(inString) {
     switch (inString) {
+      case 'noop':
+        return algosdk.OnApplicationComplete.NoOpOC;
       case 'call':
         return algosdk.OnApplicationComplete.NoOpOC;
       case 'create':
@@ -4196,21 +4263,30 @@ module.exports = function getSteps(options) {
   );
 
   When('sign the transaction', function () {
-    const result = algosdk.mnemonicToSecretKey(this.signingMnemonic);
-    this.stx = this.txn.signTxn(result.sk);
+    this.stx = this.txn.signTxn(this.signingAccount.sk);
   });
 
   Then(
-    'the base{int} encoded signed transaction should equal {string}',
-    function (base, base64golden) {
+    'the base64 encoded signed transaction should equal {string}',
+    function (base64golden) {
       const actualBase64 = Buffer.from(this.stx).toString('base64');
       assert.strictEqual(actualBase64, base64golden);
     }
   );
 
+  Then('the decoded transaction should equal the original', function () {
+    const decoded = algosdk.decodeSignedTransaction(this.stx);
+    // comparing the output of get_obj_for_encoding instead because the Transaction class instance
+    // may have some nonconsequential differences in internal representation
+    assert.deepStrictEqual(
+      decoded.txn.get_obj_for_encoding(),
+      this.txn.get_obj_for_encoding()
+    );
+  });
+
   Given(
-    'an algod v{int} client connected to {string} port {int} with token {string}',
-    function (clientVersion, host, port, token) {
+    'an algod v2 client connected to {string} port {int} with token {string}',
+    function (host, port, token) {
       let mutableHost = host;
 
       if (!mutableHost.startsWith('http')) {
@@ -4223,14 +4299,13 @@ module.exports = function getSteps(options) {
   Given(
     'I create a new transient account and fund it with {int} microalgos.',
     async function (fundingAmount) {
-      const generatedResult = algosdk.generateAccount();
-      this.transientSecretKey = generatedResult.sk;
-      this.transientAddress = generatedResult.addr;
+      this.transientAccount = algosdk.generateAccount();
+
       const sp = await this.v2Client.getTransactionParams().do();
       if (sp.firstRound === 0) sp.firstRound = 1;
       const fundingTxnArgs = {
         from: this.accounts[0],
-        to: this.transientAddress,
+        to: this.transientAccount.addr,
         amount: fundingAmount,
         suggestedParams: sp,
       };
@@ -4242,11 +4317,12 @@ module.exports = function getSteps(options) {
       const fundingResponse = await this.v2Client
         .sendRawTransaction(stxKmd)
         .do();
-      await this.v2Client.statusAfterBlock(sp.firstRound + 2).do();
-      const fundingConfirmation = await this.acl.transactionById(
-        fundingResponse.txId
+      const info = await algosdk.waitForConfirmation(
+        this.v2Client,
+        fundingResponse.txId,
+        2
       );
-      assert.deepStrictEqual(true, 'type' in fundingConfirmation);
+      assert.ok(info['confirmed-round'] > 0);
     }
   );
 
@@ -4318,7 +4394,7 @@ module.exports = function getSteps(options) {
       if (sp.firstRound === 0) sp.firstRound = 1;
       const o = {
         type: 'appl',
-        from: this.transientAddress,
+        from: this.transientAccount.addr,
         suggestedParams: sp,
         appIndex: this.currentApplicationIndex,
         appOnComplete: operation,
@@ -4342,7 +4418,7 @@ module.exports = function getSteps(options) {
     'I sign and submit the transaction, saving the txid. If there is an error it is {string}.',
     async function (errorString) {
       try {
-        const appStx = this.txn.signTxn(this.transientSecretKey);
+        const appStx = this.txn.signTxn(this.transientAccount.sk);
         this.appTxid = await this.v2Client.sendRawTransaction(appStx).do();
       } catch (err) {
         if (errorString !== '') {
@@ -4358,17 +4434,19 @@ module.exports = function getSteps(options) {
   );
 
   Given('I wait for the transaction to be confirmed.', async function () {
-    const sp = await this.v2Client.getTransactionParams().do();
-    await this.v2Client.statusAfterBlock(sp.firstRound + 2).do();
-    const confirmation = await this.acl.transactionById(this.appTxid.txId);
-    assert.deepStrictEqual(true, 'type' in confirmation);
+    const info = await algosdk.waitForConfirmation(
+      this.v2Client,
+      this.appTxid.txId,
+      2
+    );
+    assert.ok(info['confirmed-round'] > 0);
   });
 
   Given('I remember the new application ID.', async function () {
-    const infoResult = await this.acl.pendingTransactionInformation(
-      this.appTxid.txId
-    );
-    this.currentApplicationIndex = infoResult.txresults.createdapp;
+    const info = await this.v2Client
+      .pendingTransactionInformation(this.appTxid.txId)
+      .do();
+    this.currentApplicationIndex = info['application-index'];
   });
 
   Then(
@@ -4383,7 +4461,7 @@ module.exports = function getSteps(options) {
       stateValue
     ) {
       const accountInfo = await this.v2Client
-        .accountInformation(this.transientAddress)
+        .accountInformation(this.transientAccount.addr)
         .do();
       const appTotalSchema = accountInfo['apps-total-schema'];
       assert.strictEqual(appTotalSchema['num-byte-slice'], numByteSlices);
@@ -4473,6 +4551,384 @@ module.exports = function getSteps(options) {
     assert.strictEqual(!('fee' in txn), true);
   });
 
+  When(
+    'I create the Method object from method signature {string}',
+    function (signature) {
+      this.method = algosdk.ABIMethod.fromSignature(signature);
+    }
+  );
+
+  When(
+    'I create the Method object with name {string} first argument type {string} second argument type {string} and return type {string}',
+    function (name, firstArgType, secondArgType, returnType) {
+      this.method = makeABIMethod(
+        makeObject({
+          name,
+          args: makeArray(
+            makeObject({
+              type: firstArgType,
+            }),
+            makeObject({
+              type: secondArgType,
+            })
+          ),
+          returns: makeObject({ type: returnType }),
+        })
+      );
+    }
+  );
+
+  When(
+    'I create the Method object with name {string} first argument name {string} first argument type {string} second argument name {string} second argument type {string} and return type {string}',
+    function (
+      name,
+      firstArgName,
+      firstArgType,
+      secondArgName,
+      secondArgType,
+      returnType
+    ) {
+      this.method = makeABIMethod(
+        makeObject({
+          name,
+          args: makeArray(
+            makeObject({ name: firstArgName, type: firstArgType }),
+            makeObject({ name: secondArgName, type: secondArgType })
+          ),
+          returns: makeObject({ type: returnType }),
+        })
+      );
+    }
+  );
+
+  When(
+    'I create the Method object with name {string} method description {string} first argument type {string} first argument description {string} second argument type {string} second argument description {string} and return type {string}',
+    function (
+      name,
+      methodDesc,
+      firstArgType,
+      firstArgDesc,
+      secondArgType,
+      secondArgDesc,
+      returnType
+    ) {
+      this.method = makeABIMethod(
+        makeObject({
+          name,
+          desc: methodDesc,
+          args: makeArray(
+            makeObject({ type: firstArgType, desc: firstArgDesc }),
+            makeObject({ type: secondArgType, desc: secondArgDesc })
+          ),
+          returns: makeObject({ type: returnType }),
+        })
+      );
+    }
+  );
+
+  When('I serialize the Method object into json', function () {
+    this.json = JSON.stringify(this.method);
+  });
+
+  Then(
+    'the method selector should be {string}',
+    function (expectedSelectorHex) {
+      const actualSelector = this.method.getSelector();
+      const expectedSelector = makeUint8Array(
+        Buffer.from(expectedSelectorHex, 'hex')
+      );
+      assert.deepStrictEqual(actualSelector, expectedSelector);
+    }
+  );
+
+  Then('the txn count should be {int}', function (expectedCount) {
+    const actualCount = this.method.txnCount();
+    assert.strictEqual(actualCount, parseInt(expectedCount));
+  });
+
+  Then(
+    'the deserialized json should equal the original Method object',
+    function () {
+      const deserializedMethod = makeABIMethod(parseJSON(this.json));
+      assert.deepStrictEqual(deserializedMethod, this.method);
+    }
+  );
+
+  When(
+    'I create an Interface object from the Method object with name {string}',
+    function (name) {
+      this.interface = new algosdk.ABIInterface(
+        makeObject({
+          name,
+          methods: makeArray(this.method.toJSON()),
+        })
+      );
+    }
+  );
+
+  When('I serialize the Interface object into json', function () {
+    this.json = JSON.stringify(this.interface);
+  });
+
+  Then(
+    'the deserialized json should equal the original Interface object',
+    function () {
+      const deserializedInterface = new algosdk.ABIInterface(
+        parseJSON(this.json)
+      );
+      assert.deepStrictEqual(deserializedInterface, this.interface);
+    }
+  );
+
+  When(
+    'I create a Contract object from the Method object with name {string} and appId {int}',
+    function (name, appId) {
+      this.contract = makeABIContract(
+        makeObject({
+          name,
+          appId: parseInt(appId, 10),
+          methods: makeArray(this.method.toJSON()),
+        })
+      );
+    }
+  );
+
+  When('I serialize the Contract object into json', function () {
+    this.json = JSON.stringify(this.contract);
+  });
+
+  Then(
+    'the deserialized json should equal the original Contract object',
+    function () {
+      const deserializedContract = makeABIContract(parseJSON(this.json));
+      assert.deepStrictEqual(deserializedContract, this.contract);
+    }
+  );
+
+  Then(
+    'the produced json should equal {string} loaded from {string}',
+    function (expectedJson) {
+      // compare parsed JSON to avoid differences between encoded field order
+      assert.deepStrictEqual(JSON.parse(this.json), JSON.parse(expectedJson));
+    }
+  );
+
+  Given('a new AtomicTransactionComposer', function () {
+    this.composer = new algosdk.AtomicTransactionComposer();
+  });
+
+  Given('an application id {int}', function (appId) {
+    this.currentApplicationIndex = parseInt(appId, 10);
+  });
+
+  When('I make a transaction signer for the signing account.', function () {
+    this.transactionSigner = algosdk.makeBasicAccountTransactionSigner(
+      this.signingAccount
+    );
+  });
+
+  When('I make a transaction signer for the transient account.', function () {
+    this.transactionSigner = algosdk.makeBasicAccountTransactionSigner(
+      this.transientAccount
+    );
+  });
+
+  When(
+    'I create a transaction with signer with the current transaction.',
+    function () {
+      this.transactionWithSigner = {
+        txn: this.txn,
+        signer: this.transactionSigner,
+      };
+    }
+  );
+
+  When('I create a new method arguments array.', function () {
+    this.encodedMethodArguments = [];
+  });
+
+  When(
+    'I append the encoded arguments {string} to the method arguments array.',
+    function (commaSeparatedB64Args) {
+      if (commaSeparatedB64Args.length === 0) {
+        return;
+      }
+
+      const args = commaSeparatedB64Args
+        .split(',')
+        .map((b64Arg) => makeUint8Array(Buffer.from(b64Arg, 'base64')));
+      this.encodedMethodArguments.push(...args);
+    }
+  );
+
+  When(
+    'I append the current transaction with signer to the method arguments array.',
+    function () {
+      this.encodedMethodArguments.push(this.transactionWithSigner);
+    }
+  );
+
+  function addMethodCallToComposer(sender, onComplete) {
+    const methodArgs = [];
+
+    assert.strictEqual(
+      this.encodedMethodArguments.length,
+      this.method.args.length
+    );
+
+    for (let i = 0; i < this.method.args.length; i++) {
+      const argSpec = this.method.args[i];
+      const encodedArg = this.encodedMethodArguments[i];
+
+      let preparedArg = encodedArg;
+
+      if (typeof argSpec.type !== 'string') {
+        // the argument is an encoded ABI value
+        preparedArg = argSpec.type.decode(encodedArg);
+      }
+
+      methodArgs.push(preparedArg);
+    }
+
+    this.composer.addMethodCall({
+      appId: this.currentApplicationIndex,
+      method: this.method,
+      methodArgs,
+      sender,
+      suggestedParams: this.suggestedParams,
+      onComplete: operationStringToEnum(onComplete),
+      signer: this.transactionSigner,
+    });
+  }
+
+  When(
+    'I add a method call with the transient account, the current application, suggested params, on complete {string}, current transaction signer, current method arguments.',
+    function (onComplete) {
+      addMethodCallToComposer.call(
+        this,
+        this.transientAccount.addr,
+        onComplete
+      );
+    }
+  );
+
+  When(
+    'I add a method call with the signing account, the current application, suggested params, on complete {string}, current transaction signer, current method arguments.',
+    function (onComplete) {
+      addMethodCallToComposer.call(this, this.signingAccount.addr, onComplete);
+    }
+  );
+
+  When(
+    'I add the current transaction with signer to the composer.',
+    function () {
+      this.composer.addTransaction(this.transactionWithSigner);
+    }
+  );
+
+  When(
+    'I build the transaction group with the composer. If there is an error it is {string}.',
+    function (errorMsg) {
+      assert.strictEqual(errorMsg, ''); // error checking not yet implemented
+      this.composerBuiltGroup = this.composer.buildGroup();
+    }
+  );
+
+  Then('I clone the composer.', function () {
+    this.composer = this.composer.clone();
+  });
+
+  Then(
+    'The composer should have a status of {string}.',
+    function (expectedStatus) {
+      function statusStringToEnum(inString) {
+        switch (inString) {
+          case 'BUILDING':
+            return algosdk.AtomicTransactionComposerStatus.BUILDING;
+          case 'BUILT':
+            return algosdk.AtomicTransactionComposerStatus.BUILT;
+          case 'SIGNED':
+            return algosdk.AtomicTransactionComposerStatus.SIGNED;
+          case 'SUBMITTED':
+            return algosdk.AtomicTransactionComposerStatus.SUBMITTED;
+          case 'COMMITTED':
+            return algosdk.AtomicTransactionComposerStatus.COMMITTED;
+          default:
+            throw Error(
+              `did not recognize AtomicTransactionComposer status string ${inString}`
+            );
+        }
+      }
+
+      assert.strictEqual(
+        this.composer.getStatus(),
+        statusStringToEnum(expectedStatus)
+      );
+    }
+  );
+
+  Then('I gather signatures with the composer.', async function () {
+    this.composerSignedTransactions = await this.composer.gatherSignatures();
+  });
+
+  Then(
+    'the base64 encoded signed transactions should equal {string}',
+    function (commaSeparatedB64SignedTxns) {
+      const expectedSignedTxns = commaSeparatedB64SignedTxns
+        .split(',')
+        .map((b64SignedTxn) => Buffer.from(b64SignedTxn, 'base64'));
+
+      const actualSignedTxns = this.composerSignedTransactions.map(
+        (signedTxn) => Buffer.from(signedTxn)
+      );
+      assert.deepStrictEqual([...actualSignedTxns], [...expectedSignedTxns]);
+    }
+  );
+
+  Then(
+    'I execute the current transaction group with the composer.',
+    async function () {
+      this.composerExecuteResponse = await this.composer.execute(
+        this.v2Client,
+        4
+      );
+      assert.ok(this.composerExecuteResponse.confirmedRound > 0);
+    }
+  );
+
+  Then(
+    'The app should have returned {string}.',
+    function (base64ExpectedReturnValue) {
+      const { methodResults } = this.composerExecuteResponse;
+      assert.strictEqual(methodResults.length, 1);
+
+      const actualResult = methodResults[0];
+      const expectedReturnValue = Buffer.from(
+        base64ExpectedReturnValue,
+        'base64'
+      );
+
+      if (actualResult.decodeError) {
+        throw actualResult.decodeError;
+      }
+      assert.deepStrictEqual(
+        Buffer.from(actualResult.rawReturnValue),
+        expectedReturnValue
+      );
+
+      const returnType = this.method.returns.type;
+      if (returnType === 'void') {
+        assert.strictEqual(expectedReturnValue.byteLength, 0);
+        return;
+      }
+
+      assert.deepStrictEqual(
+        actualResult.returnValue,
+        returnType.decode(expectedReturnValue)
+      );
+    }
+  );
+
   if (!options.ignoreReturn) {
     return steps;
   }