From e444328616d99c88b830366dedcbf75e9795dcb3 Mon Sep 17 00:00:00 2001 From: algochoi <86622919+algochoi@users.noreply.github.com> Date: Fri, 8 Oct 2021 16:00:11 -0400 Subject: [PATCH 1/6] ABI Type encoding support (#238) * Add initial support for ABI types * Finish up abi types and values * Add doc strings * Refactor and finish encoding functions * More cleanup * Fix minor bugs and add number decoding * Bump version to 1.8.0 and add changelog * Add change to changelog * Add decoding for arrays and dynamic types * Add more unit tests * Minor change from PR comment * Another small PR comment change * Split up files and remove circular imports * Address PR comments * Have arrays accept bytes and add docstrings * Minor clean up * Fix error messages for negative uint * Remove unnecessary imports * Refactor out abi_types since it is implicit by the class * Tuples don't need static lengths... * Address PR comments 1 * Fix head encoding placeholders * Fix the tuple docstring * Formatting fixes Co-authored-by: Brice Rising --- algosdk/__init__.py | 1 + algosdk/abi/__init__.py | 12 + algosdk/abi/address_type.py | 88 +++++ algosdk/abi/array_dynamic_type.py | 98 +++++ algosdk/abi/array_static_type.py | 108 ++++++ algosdk/abi/base_type.py | 52 +++ algosdk/abi/bool_type.py | 72 ++++ algosdk/abi/byte_type.py | 65 ++++ algosdk/abi/string_type.py | 72 ++++ algosdk/abi/tuple_type.py | 317 ++++++++++++++++ algosdk/abi/ufixed_type.py | 103 ++++++ algosdk/abi/uint_type.py | 88 +++++ algosdk/abi/util.py | 85 +++++ algosdk/error.py | 10 + test_unit.py | 580 ++++++++++++++++++++++++++++++ 15 files changed, 1751 insertions(+) create mode 100644 algosdk/abi/__init__.py create mode 100644 algosdk/abi/address_type.py create mode 100644 algosdk/abi/array_dynamic_type.py create mode 100644 algosdk/abi/array_static_type.py create mode 100644 algosdk/abi/base_type.py create mode 100644 algosdk/abi/bool_type.py create mode 100644 algosdk/abi/byte_type.py create mode 100644 algosdk/abi/string_type.py create mode 100644 algosdk/abi/tuple_type.py create mode 100644 algosdk/abi/ufixed_type.py create mode 100644 algosdk/abi/uint_type.py create mode 100644 algosdk/abi/util.py diff --git a/algosdk/__init__.py b/algosdk/__init__.py index d2303b37..16d1563f 100644 --- a/algosdk/__init__.py +++ b/algosdk/__init__.py @@ -1,3 +1,4 @@ +from . import abi from . import account from . import algod from . import auction diff --git a/algosdk/abi/__init__.py b/algosdk/abi/__init__.py new file mode 100644 index 00000000..161c0b91 --- /dev/null +++ b/algosdk/abi/__init__.py @@ -0,0 +1,12 @@ +from .util import type_from_string +from .uint_type import UintType +from .ufixed_type import UfixedType +from .bool_type import BoolType +from .byte_type import ByteType +from .address_type import AddressType +from .string_type import StringType +from .array_dynamic_type import ArrayDynamicType +from .array_static_type import ArrayStaticType +from .tuple_type import TupleType + +name = "abi" diff --git a/algosdk/abi/address_type.py b/algosdk/abi/address_type.py new file mode 100644 index 00000000..f51be89c --- /dev/null +++ b/algosdk/abi/address_type.py @@ -0,0 +1,88 @@ +from .base_type import Type +from .byte_type import ByteType +from .tuple_type import TupleType +from .. import error + +from algosdk import encoding + + +class AddressType(Type): + """ + Represents an Address ABI Type for encoding. + """ + + def __init__(self) -> None: + super().__init__() + + def __eq__(self, other) -> bool: + if not isinstance(other, AddressType): + return False + return True + + def __str__(self): + return "address" + + def byte_len(self): + return 32 + + def is_dynamic(self): + return False + + def _to_tuple_type(self): + child_type_array = list() + for _ in range(self.byte_len()): + child_type_array.append(ByteType()) + return TupleType(child_type_array) + + def encode(self, value): + """ + Encode an address string or a 32-byte public key into a Address ABI bytestring. + + Args: + value (str | bytes): value to be encoded. It can be either a base32 + address string or a 32-byte public key. + + Returns: + bytes: encoded bytes of the address + """ + # Check that the value is an address in string or the public key in bytes + if isinstance(value, str): + try: + value = encoding.decode_address(value) + except Exception as e: + raise error.ABIEncodingError( + "cannot encode the following address: {}".format(value) + ) from e + elif ( + not (isinstance(value, bytes) or isinstance(value, bytearray)) + or len(value) != 32 + ): + raise error.ABIEncodingError( + "cannot encode the following public key: {}".format(value) + ) + return bytes(value) + + def decode(self, bytestring): + """ + Decodes a bytestring to a base32 encoded address string. + + Args: + bytestring (bytes | bytearray): bytestring to be decoded + + Returns: + str: base32 encoded address from the encoded bytestring + """ + if ( + not ( + isinstance(bytestring, bytearray) + or isinstance(bytestring, bytes) + ) + or len(bytestring) != 32 + ): + raise error.ABIEncodingError( + "address string must be in bytes and correspond to a byte[32]: {}".format( + bytestring + ) + ) + # Return the base32 encoded address string + return encoding.encode_address(bytestring) diff --git a/algosdk/abi/array_dynamic_type.py b/algosdk/abi/array_dynamic_type.py new file mode 100644 index 00000000..74821356 --- /dev/null +++ b/algosdk/abi/array_dynamic_type.py @@ -0,0 +1,98 @@ +from .base_type import ABI_LENGTH_SIZE, Type +from .byte_type import ByteType +from .tuple_type import TupleType +from .. import error + + +class ArrayDynamicType(Type): + """ + Represents a ArrayDynamic ABI Type for encoding. + + Args: + child_type (Type): the type of the dynamic array. + + Attributes: + child_type (Type) + """ + + def __init__(self, arg_type) -> None: + super().__init__() + self.child_type = arg_type + + def __eq__(self, other) -> bool: + if not isinstance(other, ArrayDynamicType): + return False + return self.child_type == other.child_type + + def __str__(self): + return "{}[]".format(self.child_type) + + def byte_len(self): + raise error.ABITypeError( + "cannot get length of a dynamic type: {}".format(self) + ) + + def is_dynamic(self): + return True + + def _to_tuple_type(self, length): + child_type_array = [self.child_type] * length + return TupleType(child_type_array) + + def encode(self, value_array): + """ + Encodes a list of values into a ArrayDynamic ABI bytestring. + + Args: + value_array (list | bytes | bytearray): list of values to be encoded. + If the child types are ByteType, then bytes or bytearray can be + passed in to be encoded as well. + + Returns: + bytes: encoded bytes of the dynamic array + """ + if ( + isinstance(value_array, bytes) + or isinstance(value_array, bytearray) + ) and not isinstance(self.child_type, ByteType): + raise error.ABIEncodingError( + "cannot pass in bytes when the type of the array is not ByteType: {}".format( + value_array + ) + ) + converted_tuple = self._to_tuple_type(len(value_array)) + length_to_encode = len(converted_tuple.child_types).to_bytes( + 2, byteorder="big" + ) + encoded = converted_tuple.encode(value_array) + return bytes(length_to_encode) + encoded + + def decode(self, array_bytes): + """ + Decodes a bytestring to a dynamic list. + + Args: + array_bytes (bytes | bytearray): bytestring to be decoded + + Returns: + list: values from the encoded bytestring + """ + if not ( + isinstance(array_bytes, bytearray) + or isinstance(array_bytes, bytes) + ): + raise error.ABIEncodingError( + "value to be decoded must be in bytes: {}".format(array_bytes) + ) + if len(array_bytes) < ABI_LENGTH_SIZE: + raise error.ABIEncodingError( + "dynamic array is too short to be decoded: {}".format( + len(array_bytes) + ) + ) + + byte_length = int.from_bytes( + array_bytes[:ABI_LENGTH_SIZE], byteorder="big" + ) + converted_tuple = self._to_tuple_type(byte_length) + return converted_tuple.decode(array_bytes[ABI_LENGTH_SIZE:]) diff --git a/algosdk/abi/array_static_type.py b/algosdk/abi/array_static_type.py new file mode 100644 index 00000000..d9ebca9b --- /dev/null +++ b/algosdk/abi/array_static_type.py @@ -0,0 +1,108 @@ +import math + +from .base_type import Type +from .bool_type import BoolType +from .byte_type import ByteType +from .tuple_type import TupleType +from .. import error + + +class ArrayStaticType(Type): + """ + Represents a ArrayStatic ABI Type for encoding. + + Args: + child_type (Type): the type of the child_types array. + static_length (int): length of the static array. + + Attributes: + child_type (Type) + static_length (int) + """ + + def __init__(self, arg_type, array_len) -> None: + if array_len < 1: + raise error.ABITypeError( + "static array length must be a positive integer: {}".format( + len(array_len) + ) + ) + super().__init__() + self.child_type = arg_type + self.static_length = array_len + + def __eq__(self, other) -> bool: + if not isinstance(other, ArrayStaticType): + return False + return ( + self.child_type == other.child_type + and self.static_length == other.static_length + ) + + def __str__(self): + return "{}[{}]".format(self.child_type, self.static_length) + + def byte_len(self): + if isinstance(self.child_type, BoolType): + # 8 Boolean values can be encoded into 1 byte + return math.ceil(self.static_length / 8) + element_byte_length = self.child_type.byte_len() + return self.static_length * element_byte_length + + def is_dynamic(self): + return self.child_type.is_dynamic() + + def _to_tuple_type(self): + child_type_array = [self.child_type] * self.static_length + return TupleType(child_type_array) + + def encode(self, value_array): + """ + Encodes a list of values into a ArrayStatic ABI bytestring. + + Args: + value_array (list | bytes | bytearray): list of values to be encoded. + The number of elements must match the predefined length of array. + If the child types are ByteType, then bytes or bytearray can be + passed in to be encoded as well. + + Returns: + bytes: encoded bytes of the static array + """ + if len(value_array) != self.static_length: + raise error.ABIEncodingError( + "value array length does not match static array length: {}".format( + len(value_array) + ) + ) + if ( + isinstance(value_array, bytes) + or isinstance(value_array, bytearray) + ) and not isinstance(self.child_type, ByteType): + raise error.ABIEncodingError( + "cannot pass in bytes when the type of the array is not ByteType: {}".format( + value_array + ) + ) + converted_tuple = self._to_tuple_type() + return converted_tuple.encode(value_array) + + def decode(self, array_bytes): + """ + Decodes a bytestring to a static list. + + Args: + array_bytes (bytes | bytearray): bytestring to be decoded + + Returns: + list: values from the encoded bytestring + """ + if not ( + isinstance(array_bytes, bytearray) + or isinstance(array_bytes, bytes) + ): + raise error.ABIEncodingError( + "value to be decoded must be in bytes: {}".format(array_bytes) + ) + converted_tuple = self._to_tuple_type() + return converted_tuple.decode(array_bytes) diff --git a/algosdk/abi/base_type.py b/algosdk/abi/base_type.py new file mode 100644 index 00000000..54a5eec6 --- /dev/null +++ b/algosdk/abi/base_type.py @@ -0,0 +1,52 @@ +from abc import ABC, abstractmethod +from enum import IntEnum + +# Globals +ABI_LENGTH_SIZE = 2 # We use 2 bytes to encode the length of a dynamic element + + +class Type(ABC): + """ + Represents an ABI Type for encoding. + """ + + def __init__( + self, + ) -> None: + pass + + @abstractmethod + def __str__(self): + pass + + @abstractmethod + def __eq__(self, other) -> bool: + pass + + @abstractmethod + def is_dynamic(self): + """ + Return whether the ABI type is dynamic. + """ + pass + + @abstractmethod + def byte_len(self): + """ + Return the length in bytes of the ABI type. + """ + pass + + @abstractmethod + def encode(self, value): + """ + Serialize the ABI value into a byte string using ABI encoding rules. + """ + pass + + @abstractmethod + def decode(self, value_string): + """ + Deserialize the ABI type and value from a byte string using ABI encoding rules. + """ + pass diff --git a/algosdk/abi/bool_type.py b/algosdk/abi/bool_type.py new file mode 100644 index 00000000..1f11ba63 --- /dev/null +++ b/algosdk/abi/bool_type.py @@ -0,0 +1,72 @@ +from .base_type import Type +from .. import error + + +class BoolType(Type): + """ + Represents a Bool ABI Type for encoding. + """ + + def __init__(self) -> None: + super().__init__() + + def __eq__(self, other) -> bool: + if not isinstance(other, BoolType): + return False + return True + + def __str__(self): + return "bool" + + def byte_len(self): + return 1 + + def is_dynamic(self): + return False + + def encode(self, value): + """ + Encode a boolean value + + Args: + value (bool): value to be encoded + + Returns: + bytes: encoded bytes ("0x80" if True, "0x00" if False) of the boolean + """ + assert isinstance(value, bool) + if value: + # True value is encoded as having a 1 on the most significant bit (0x80) + return b"\x80" + return b"\x00" + + def decode(self, bytestring): + """ + Decodes a bytestring to a single boolean. + + Args: + bytestring (bytes | bytearray): bytestring to be decoded that contains a single boolean, i.e. "0x80" or "0x00" + + Returns: + bool: boolean from the encoded bytestring + """ + if ( + not ( + isinstance(bytestring, bytes) + or isinstance(bytestring, bytearray) + ) + or len(bytestring) != 1 + ): + raise error.ABIEncodingError( + "value string must be in bytes and correspond to a bool: {}".format( + bytestring + ) + ) + if bytestring == b"\x80": + return True + elif bytestring == b"\x00": + return False + else: + raise error.ABIEncodingError( + "boolean value could not be decoded: {}".format(bytestring) + ) diff --git a/algosdk/abi/byte_type.py b/algosdk/abi/byte_type.py new file mode 100644 index 00000000..d980b076 --- /dev/null +++ b/algosdk/abi/byte_type.py @@ -0,0 +1,65 @@ +from .base_type import Type +from .. import error + + +class ByteType(Type): + """ + Represents a Byte ABI Type for encoding. + """ + + def __init__(self) -> None: + super().__init__() + + def __eq__(self, other) -> bool: + if not isinstance(other, ByteType): + return False + return True + + def __str__(self): + return "byte" + + def byte_len(self): + return 1 + + def is_dynamic(self): + return False + + def encode(self, value): + """ + Encode a single byte or a uint8 + + Args: + value (int): value to be encoded + + Returns: + bytes: encoded bytes of the uint8 + """ + if not isinstance(value, int) or value < 0 or value > 255: + raise error.ABIEncodingError( + "value {} cannot be encoded into a byte".format(value) + ) + return bytes([value]) + + def decode(self, bytestring): + """ + Decodes a bytestring to a single byte. + + Args: + bytestring (bytes | bytearray): bytestring to be decoded + + Returns: + bytes: byte of the encoded bytestring + """ + if ( + not ( + isinstance(bytestring, bytes) + or isinstance(bytestring, bytearray) + ) + or len(bytestring) != 1 + ): + raise error.ABIEncodingError( + "value string must be in bytes and correspond to a byte: {}".format( + bytestring + ) + ) + return bytestring[0] diff --git a/algosdk/abi/string_type.py b/algosdk/abi/string_type.py new file mode 100644 index 00000000..64b0efa4 --- /dev/null +++ b/algosdk/abi/string_type.py @@ -0,0 +1,72 @@ +from .base_type import ABI_LENGTH_SIZE, Type +from .. import error + + +class StringType(Type): + """ + Represents a String ABI Type for encoding. + """ + + def __init__(self) -> None: + super().__init__() + + def __eq__(self, other) -> bool: + if not isinstance(other, StringType): + return False + return True + + def __str__(self): + return "string" + + def byte_len(self): + raise error.ABITypeError( + "cannot get length of a dynamic type: {}".format(self) + ) + + def is_dynamic(self): + return True + + def encode(self, string_val): + """ + Encode a value into a String ABI bytestring. + + Args: + value (str): string to be encoded. + + Returns: + bytes: encoded bytes of the string + """ + length_to_encode = len(string_val).to_bytes(2, byteorder="big") + encoded = string_val.encode("utf-8") + return length_to_encode + encoded + + def decode(self, bytestring): + """ + Decodes a bytestring to a string. + + Args: + bytestring (bytes | bytearray): bytestring to be decoded + + Returns: + str: string from the encoded bytestring + """ + if not ( + isinstance(bytestring, bytearray) or isinstance(bytestring, bytes) + ): + raise error.ABIEncodingError( + "value to be decoded must be in bytes: {}".format(bytestring) + ) + if len(bytestring) < ABI_LENGTH_SIZE: + raise error.ABIEncodingError( + "string is too short to be decoded: {}".format(len(bytestring)) + ) + byte_length = int.from_bytes( + bytestring[:ABI_LENGTH_SIZE], byteorder="big" + ) + if len(bytestring[ABI_LENGTH_SIZE:]) != byte_length: + raise error.ABIEncodingError( + "string length byte does not match actual length of string: {} != {}".format( + len(bytestring[ABI_LENGTH_SIZE:]), byte_length + ) + ) + return (bytestring[ABI_LENGTH_SIZE:]).decode("utf-8") diff --git a/algosdk/abi/tuple_type.py b/algosdk/abi/tuple_type.py new file mode 100644 index 00000000..54fcec15 --- /dev/null +++ b/algosdk/abi/tuple_type.py @@ -0,0 +1,317 @@ +from .base_type import ABI_LENGTH_SIZE, Type +from .bool_type import BoolType +from .. import error + + +class TupleType(Type): + """ + Represents a Tuple ABI Type for encoding. + + Args: + child_types (list): list of types in the tuple. + + Attributes: + child_types (list) + """ + + def __init__(self, arg_types) -> None: + if len(arg_types) >= 2 ** 16: + raise error.ABITypeError( + "tuple args cannot exceed a uint16: {}".format(len(arg_types)) + ) + super().__init__() + self.child_types = arg_types + + def __eq__(self, other) -> bool: + if not isinstance(other, TupleType): + return False + return self.child_types == other.child_types + + def __str__(self): + return "({})".format(",".join(str(t) for t in self.child_types)) + + def byte_len(self): + size = 0 + i = 0 + while i < len(self.child_types): + if isinstance(self.child_types[i], BoolType): + after = TupleType._find_bool(self.child_types, i, 1) + i += after + bool_num = after + 1 + size += bool_num // 8 + if bool_num % 8 != 0: + size += 1 + else: + child_byte_size = self.child_types[i].byte_len() + size += child_byte_size + i += 1 + return size + + def is_dynamic(self): + return any(child.is_dynamic() for child in self.child_types) + + @staticmethod + def _find_bool(type_list, index, delta): + """ + Helper function to find consecutive booleans from current index in a tuple. + """ + until = 0 + while True: + curr = index + delta * until + if isinstance(type_list[curr], BoolType): + if curr != len(type_list) - 1 and delta > 0: + until += 1 + elif curr > 0 and delta < 0: + until += 1 + else: + break + else: + until -= 1 + break + return until + + @staticmethod + def _parse_tuple(s): + """ + Given a tuple string, parses one layer of the tuple and returns tokens as a list. + i.e. 'x,(y,(z))' -> ['x', '(y,(z))'] + """ + # If the tuple content is empty, return an empty list + if not s: + return [] + + if s.startswith(",") or s.endswith(","): + raise error.ABITypeError( + "cannot have leading or trailing commas in {}".format(s) + ) + + if ",," in s: + raise error.ABITypeError( + "cannot have consecutive commas in {}".format(s) + ) + + tuple_strs = [] + depth = 0 + word = "" + for char in s: + word += char + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + elif char == ",": + # If the comma is at depth 0, put the word as a new token. + if depth == 0: + word = word[:-1] + tuple_strs.append(word) + word = "" + if word: + tuple_strs.append(word) + if depth != 0: + raise error.ABITypeError("parenthesis mismatch: {}".format(s)) + return tuple_strs + + @staticmethod + def _compress_multiple_bool(value_list): + """ + Compress consecutive boolean values into a byte for a Tuple/Array. + """ + result = 0 + if len(value_list) > 8: + raise error.ABIEncodingError( + "length of list should not be greater than 8" + ) + for i, value in enumerate(value_list): + assert isinstance(value, bool) + bool_val = value + if bool_val: + result |= 1 << (7 - i) + return result + + def encode(self, values): + """ + Encodes a list of values into a TupleType ABI bytestring. + + Args: + values (list): list of values to be encoded. + The length of the list cannot exceed a uint16. + + Returns: + bytes: encoded bytes of the tuple + """ + if len(self.child_types) >= (2 ** 16): + raise error.ABIEncodingError( + "length of tuple array should not exceed a uint16: {}".format( + len(self.child_types) + ) + ) + tuple_elements = self.child_types + + # Create a head/tail component and use it to concat bytes later + heads = list() + tails = list() + is_dynamic_index = list() + i = 0 + while i < len(tuple_elements): + element = tuple_elements[i] + if element.is_dynamic(): + # Head is not pre-determined for dynamic types; store a placeholder for now + heads.append(b"\x00\x00") + is_dynamic_index.append(True) + tail_encoding = element.encode(values[i]) + tails.append(tail_encoding) + else: + if isinstance(element, BoolType): + before = TupleType._find_bool(self.child_types, i, -1) + after = TupleType._find_bool(self.child_types, i, 1) + + # Pack bytes to heads and tails + if before % 8 != 0: + raise error.ABIEncodingError( + "expected before index should have number of bool mod 8 equal 0" + ) + after = min(7, after) + compressed_int = TupleType._compress_multiple_bool( + values[i : i + after + 1] + ) + heads.append(bytes([compressed_int])) + i += after + else: + encoded_tuple_element = element.encode(values[i]) + heads.append(encoded_tuple_element) + is_dynamic_index.append(False) + tails.append(b"") + i += 1 + + # Adjust heads for dynamic types + head_length = 0 + for head_element in heads: + # If the element is not a placeholder, append the length of the element + head_length += len(head_element) + + # Correctly encode dynamic types and replace placeholder + tail_curr_length = 0 + for i in range(len(heads)): + if is_dynamic_index[i]: + head_value = head_length + tail_curr_length + if head_value >= 2 ** 16: + raise error.ABIEncodingError( + "byte length {} should not exceed a uint16".format( + head_value + ) + ) + heads[i] = head_value.to_bytes( + ABI_LENGTH_SIZE, byteorder="big" + ) + tail_curr_length += len(tails[i]) + + # Concatenate bytes + return b"".join(heads) + b"".join(tails) + + def decode(self, bytestring): + """ + Decodes a bytestring to a tuple list. + + Args: + bytestring (bytes | bytearray): bytestring to be decoded + + Returns: + list: values from the encoded bytestring + """ + if not ( + isinstance(bytestring, bytes) or isinstance(bytestring, bytearray) + ): + raise error.ABIEncodingError( + "value string must be in bytes: {}".format(bytestring) + ) + tuple_elements = self.child_types + dynamic_segments = ( + list() + ) # Store the start and end of a dynamic element + value_partitions = list() + i = 0 + array_index = 0 + + while i < len(tuple_elements): + element = tuple_elements[i] + if element.is_dynamic(): + if len(bytestring[array_index:]) < ABI_LENGTH_SIZE: + raise error.ABIEncodingError( + "malformed value: dynamically typed values must contain a two-byte length specifier" + ) + # Decode the size of the dynamic element + dynamic_index = int.from_bytes( + bytestring[array_index : array_index + ABI_LENGTH_SIZE], + byteorder="big", + signed=False, + ) + if len(dynamic_segments) > 0: + dynamic_segments[-1][1] = dynamic_index + # Check that the right side of segment is greater than the left side + assert ( + dynamic_index + > dynamic_segments[len(dynamic_segments) - 1][0] + ) + # Since we do not know where the current dynamic element ends, put a placeholder and update later + dynamic_segments.append([dynamic_index, -1]) + value_partitions.append(None) + array_index += ABI_LENGTH_SIZE + else: + if isinstance(element, BoolType): + before = TupleType._find_bool(self.child_types, i, -1) + after = TupleType._find_bool(self.child_types, i, 1) + + if before % 8 != 0: + raise error.ABIEncodingError( + "expected before index should have number of bool mod 8 equal 0" + ) + after = min(7, after) + bits = int.from_bytes( + bytestring[array_index : array_index + 1], + byteorder="big", + ) + # Parse bool values into multiple byte strings + for bool_i in range(after + 1): + mask = 128 >> bool_i + if mask & bits: + value_partitions.append(b"\x80") + else: + value_partitions.append(b"\x00") + i += after + array_index += 1 + else: + curr_len = element.byte_len() + value_partitions.append( + bytestring[array_index : array_index + curr_len] + ) + array_index += curr_len + if array_index >= len(bytestring) and i != len(tuple_elements) - 1: + raise error.ABIEncodingError( + "input string is not long enough to be decoded: {}".format( + bytestring + ) + ) + i += 1 + + if len(dynamic_segments) > 0: + dynamic_segments[len(dynamic_segments) - 1][1] = len(bytestring) + array_index = len(bytestring) + if array_index < len(bytestring): + raise error.ABIEncodingError( + "input string was not fully consumed: {}".format(bytestring) + ) + + # Check dynamic element partitions + segment_index = 0 + for i, element in enumerate(tuple_elements): + if element.is_dynamic(): + segment_start, segment_end = dynamic_segments[segment_index] + value_partitions[i] = bytestring[segment_start:segment_end] + segment_index += 1 + + # Decode individual tuple elements + values = list() + for i, element in enumerate(tuple_elements): + val = element.decode(value_partitions[i]) + values.append(val) + return values diff --git a/algosdk/abi/ufixed_type.py b/algosdk/abi/ufixed_type.py new file mode 100644 index 00000000..7bb531bd --- /dev/null +++ b/algosdk/abi/ufixed_type.py @@ -0,0 +1,103 @@ +from .base_type import Type +from .. import error + + +class UfixedType(Type): + """ + Represents an Ufixed ABI Type for encoding. + + Args: + bit_size (int, optional): size of a ufixed type. + precision (int, optional): number of precision for a ufixed type. + + Attributes: + bit_size (int) + precision (int) + """ + + def __init__(self, type_size, type_precision) -> None: + if ( + not isinstance(type_size, int) + or type_size % 8 != 0 + or type_size < 8 + or type_size > 512 + ): + raise error.ABITypeError( + "unsupported ufixed bitSize: {}".format(type_size) + ) + if ( + not isinstance(type_precision, int) + or type_precision > 160 + or type_precision < 1 + ): + raise error.ABITypeError( + "unsupported ufixed precision: {}".format(type_precision) + ) + super().__init__() + self.bit_size = type_size + self.precision = type_precision + + def __eq__(self, other) -> bool: + if not isinstance(other, UfixedType): + return False + return ( + self.bit_size == other.bit_size + and self.precision == other.precision + ) + + def __str__(self): + return "ufixed{}x{}".format(self.bit_size, self.precision) + + def byte_len(self): + return self.bit_size // 8 + + def is_dynamic(self): + return False + + def encode(self, value): + """ + Encodes a value into a Ufixed ABI type bytestring. The precision denotes + the denominator and the value denotes the numerator. + + Args: + value (int): ufixed numerator value in uint to be encoded + + Returns: + bytes: encoded bytes of the ufixed numerator + """ + if ( + not isinstance(value, int) + or value >= (2 ** self.bit_size) + or value < 0 + ): + raise error.ABIEncodingError( + "value {} is not a non-negative int or is too big to fit in size {}".format( + value, self.bit_size + ) + ) + return value.to_bytes(self.bit_size // 8, byteorder="big") + + def decode(self, bytestring): + """ + Decodes a bytestring to a ufixed numerator. + + Args: + bytestring (bytes | bytearray): bytestring to be decoded + + Returns: + int: ufixed numerator value from the encoded bytestring + """ + if ( + not ( + isinstance(bytestring, bytes) + or isinstance(bytestring, bytearray) + ) + or len(bytestring) != self.bit_size // 8 + ): + raise error.ABIEncodingError( + "value string must be in bytes and correspond to a ufixed{}x{}: {}".format( + self.bit_size, self.precision, bytestring + ) + ) + # Convert bytes into an unsigned integer numerator + return int.from_bytes(bytestring, byteorder="big", signed=False) diff --git a/algosdk/abi/uint_type.py b/algosdk/abi/uint_type.py new file mode 100644 index 00000000..42ce09a7 --- /dev/null +++ b/algosdk/abi/uint_type.py @@ -0,0 +1,88 @@ +from .base_type import Type +from .. import error + + +class UintType(Type): + """ + Represents an Uint ABI Type for encoding. + + Args: + bit_size (int, optional): size of a uint type, e.g. for a uint8, the bit_size is 8. + + Attributes: + bit_size (int) + """ + + def __init__(self, type_size) -> None: + if ( + not isinstance(type_size, int) + or type_size % 8 != 0 + or type_size < 8 + or type_size > 512 + ): + raise error.ABITypeError( + "unsupported uint bitSize: {}".format(type_size) + ) + super().__init__() + self.bit_size = type_size + + def __eq__(self, other) -> bool: + if not isinstance(other, UintType): + return False + return self.bit_size == other.bit_size + + def __str__(self): + return "uint{}".format(self.bit_size) + + def byte_len(self): + return self.bit_size // 8 + + def is_dynamic(self): + return False + + def encode(self, value): + """ + Encodes a value into a Uint ABI type bytestring. + + Args: + value (int): uint value to be encoded + + Returns: + bytes: encoded bytes of the uint value + """ + if ( + not isinstance(value, int) + or value >= (2 ** self.bit_size) + or value < 0 + ): + raise error.ABIEncodingError( + "value {} is not a non-negative int or is too big to fit in size {}".format( + value, self.bit_size + ) + ) + return value.to_bytes(self.bit_size // 8, byteorder="big") + + def decode(self, bytestring): + """ + Decodes a bytestring to a uint. + + Args: + bytestring (bytes | bytearray): bytestring to be decoded + + Returns: + int: uint value from the encoded bytestring + """ + if ( + not ( + isinstance(bytestring, bytes) + or isinstance(bytestring, bytearray) + ) + or len(bytestring) != self.bit_size // 8 + ): + raise error.ABIEncodingError( + "value string must be in bytes and correspond to a uint{}: {}".format( + self.bit_size, bytestring + ) + ) + # Convert bytes into an unsigned integer + return int.from_bytes(bytestring, byteorder="big", signed=False) diff --git a/algosdk/abi/util.py b/algosdk/abi/util.py new file mode 100644 index 00000000..5deca770 --- /dev/null +++ b/algosdk/abi/util.py @@ -0,0 +1,85 @@ +import re + +from .uint_type import UintType +from .ufixed_type import UfixedType +from .byte_type import ByteType +from .bool_type import BoolType +from .address_type import AddressType +from .string_type import StringType +from .array_dynamic_type import ArrayDynamicType +from .array_static_type import ArrayStaticType +from .tuple_type import TupleType +from .. import error + + +# Globals +UFIXED_REGEX = r"^ufixed([1-9][\d]*)x([1-9][\d]*)$" +STATIC_ARRAY_REGEX = r"^([a-z\d\[\](),]+)\[([1-9][\d]*)]$" + + +def type_from_string(s): + """ + Convert a valid ABI string to a corresponding ABI type. + """ + if s.endswith("[]"): + array_arg_type = type_from_string(s[:-2]) + return ArrayDynamicType(array_arg_type) + elif s.endswith("]"): + matches = re.search(STATIC_ARRAY_REGEX, s) + try: + static_length = int(matches.group(2)) + array_type = type_from_string(matches.group(1)) + return ArrayStaticType(array_type, static_length) + except Exception as e: + raise error.ABITypeError( + "malformed static array string: {}".format(s) + ) from e + if s.startswith("uint"): + try: + if not s[4:].isdecimal(): + raise error.ABITypeError( + "uint string does not contain a valid size: {}".format(s) + ) + type_size = int(s[4:]) + return UintType(type_size) + except Exception as e: + raise error.ABITypeError( + "malformed uint string: {}".format(s) + ) from e + elif s == "byte": + return ByteType() + elif s.startswith("ufixed"): + matches = re.search(UFIXED_REGEX, s) + try: + bit_size = int(matches.group(1)) + precision = int(matches.group(2)) + return UfixedType(bit_size, precision) + except Exception as e: + raise error.ABITypeError( + "malformed ufixed string: {}".format(s) + ) from e + elif s == "bool": + return BoolType() + elif s == "address": + return AddressType() + elif s == "string": + return StringType() + elif len(s) >= 2 and s[0] == "(" and s[-1] == ")": + # Recursively parse parentheses from a tuple string + tuples = TupleType._parse_tuple(s[1:-1]) + tuple_list = [] + for tup in tuples: + if isinstance(tup, str): + tt = type_from_string(tup) + tuple_list.append(tt) + elif isinstance(tup, list): + tts = [type_from_string(t_) for t_ in tup] + tuple_list.append(tts) + else: + raise error.ABITypeError( + "cannot convert {} to an ABI type".format(tup) + ) + + return TupleType(tuple_list) + else: + raise error.ABITypeError("cannot convert {} to an ABI type".format(s)) diff --git a/algosdk/error.py b/algosdk/error.py index fd135e62..0ca52c71 100644 --- a/algosdk/error.py +++ b/algosdk/error.py @@ -207,3 +207,13 @@ class IndexerHTTPError(Exception): class ConfirmationTimeoutError(Exception): pass + + +class ABITypeError(Exception): + def __init__(self, msg): + super().__init__(msg) + + +class ABIEncodingError(Exception): + def __init__(self, msg): + super().__init__(msg) diff --git a/test_unit.py b/test_unit.py index 8c3372d9..3712bfda 100644 --- a/test_unit.py +++ b/test_unit.py @@ -2,6 +2,7 @@ import copy import os import random +import string import unittest import uuid from unittest.mock import Mock @@ -18,6 +19,18 @@ util, wordlist, ) +from algosdk.abi import ( + type_from_string, + UintType, + UfixedType, + BoolType, + ByteType, + AddressType, + StringType, + ArrayDynamicType, + ArrayStaticType, + TupleType, +) from algosdk.future import template, transaction from algosdk.testing import dryrun @@ -3600,6 +3613,571 @@ def test_local_state(self): ) +class TestABIType(unittest.TestCase): + def test_make_type_valid(self): + # Test for uint + for uint_index in range(8, 513, 8): + uint_type = UintType(uint_index) + self.assertEqual(str(uint_type), f"uint{uint_index}") + actual = type_from_string(str(uint_type)) + self.assertEqual(uint_type, actual) + + # Test for ufixed + for size_index in range(8, 513, 8): + for precision_index in range(1, 161): + ufixed_type = UfixedType(size_index, precision_index) + self.assertEqual( + str(ufixed_type), f"ufixed{size_index}x{precision_index}" + ) + actual = type_from_string(str(ufixed_type)) + self.assertEqual(ufixed_type, actual) + + test_cases = [ + # Test for byte/bool/address/strings + (ByteType(), f"byte"), + (BoolType(), f"bool"), + (AddressType(), f"address"), + (StringType(), f"string"), + # Test for dynamic array type + ( + ArrayDynamicType(UintType(32)), + f"uint32[]", + ), + ( + ArrayDynamicType(ArrayDynamicType(ByteType())), + f"byte[][]", + ), + ( + ArrayDynamicType(UfixedType(256, 64)), + f"ufixed256x64[]", + ), + # Test for static array type + ( + ArrayStaticType(UfixedType(128, 10), 100), + f"ufixed128x10[100]", + ), + ( + ArrayStaticType( + ArrayStaticType(BoolType(), 256), + 100, + ), + f"bool[256][100]", + ), + # Test for tuple + (TupleType([]), f"()"), + ( + TupleType( + [ + UintType(16), + TupleType( + [ + ByteType(), + ArrayStaticType(AddressType(), 10), + ] + ), + ] + ), + f"(uint16,(byte,address[10]))", + ), + ( + TupleType( + [ + UintType(256), + TupleType( + [ + ByteType(), + ArrayStaticType(AddressType(), 10), + ] + ), + TupleType([]), + BoolType(), + ] + ), + f"(uint256,(byte,address[10]),(),bool)", + ), + ( + TupleType( + [ + UfixedType(256, 16), + TupleType( + [ + TupleType( + [ + StringType(), + ] + ), + BoolType(), + TupleType( + [ + AddressType(), + UintType(8), + ] + ), + ] + ), + ] + ), + f"(ufixed256x16,((string),bool,(address,uint8)))", + ), + ] + for test_case in test_cases: + self.assertEqual(str(test_case[0]), test_case[1]) + self.assertEqual(test_case[0], type_from_string(test_case[1])) + + def test_make_type_invalid(self): + # Test for invalid uint + invalid_type_sizes = [-1, 0, 9, 513, 1024] + for i in invalid_type_sizes: + with self.assertRaises(error.ABITypeError) as e: + UintType(i) + self.assertIn(f"unsupported uint bitSize: {i}", str(e.exception)) + with self.assertRaises(TypeError) as e: + UintType() + + # Test for invalid ufixed + invalid_precisions = [-1, 0, 161] + for i in invalid_type_sizes: + with self.assertRaises(error.ABITypeError) as e: + UfixedType(i, 1) + self.assertIn(f"unsupported ufixed bitSize: {i}", str(e.exception)) + for j in invalid_precisions: + with self.assertRaises(error.ABITypeError) as e: + UfixedType(8, j) + self.assertIn( + f"unsupported ufixed precision: {j}", str(e.exception) + ) + + def test_type_from_string_invalid(self): + test_cases = ( + # 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 test_case in test_cases: + with self.assertRaises(error.ABITypeError) as e: + type_from_string(test_case) + + def test_is_dynamic(self): + test_cases = [ + (UintType(32), False), + (UfixedType(16, 10), False), + (ByteType(), False), + (BoolType(), False), + (AddressType(), False), + (StringType(), True), + ( + ArrayDynamicType(ArrayDynamicType(ByteType())), + True, + ), + # Test tuple child types + (type_from_string("(string[100])"), True), + (type_from_string("(address,bool,uint256)"), False), + (type_from_string("(uint8,(byte[10]))"), False), + (type_from_string("(string,uint256)"), True), + ( + type_from_string("(bool,(ufixed16x10[],(byte,address)))"), + True, + ), + ( + type_from_string("(bool,(uint256,(byte,address,string)))"), + True, + ), + ] + for test_case in test_cases: + self.assertEqual(test_case[0].is_dynamic(), test_case[1]) + + def test_byte_len(self): + test_cases = [ + (AddressType(), 32), + (ByteType(), 1), + (BoolType(), 1), + (UintType(64), 8), + (UfixedType(256, 50), 32), + (type_from_string("bool[81]"), 11), + (type_from_string("bool[80]"), 10), + (type_from_string("bool[88]"), 11), + (type_from_string("address[5]"), 160), + (type_from_string("uint16[20]"), 40), + (type_from_string("ufixed64x20[10]"), 80), + (type_from_string(f"(address,byte,ufixed16x20)"), 35), + ( + type_from_string( + f"((bool,address[10]),(bool,bool,bool),uint8[20])" + ), + 342, + ), + (type_from_string(f"(bool,bool)"), 1), + (type_from_string(f"({'bool,'*6}uint8)"), 2), + ( + type_from_string(f"({'bool,'*10}uint8,{'bool,'*10}byte)"), + 6, + ), + ] + for test_case in test_cases: + self.assertEqual(test_case[0].byte_len(), test_case[1]) + + def test_byte_len_invalid(self): + test_cases = ( + StringType(), + ArrayDynamicType(UfixedType(16, 64)), + ) + + for test_case in test_cases: + with self.assertRaises(error.ABITypeError) as e: + test_case.byte_len() + + +class TestABIEncoding(unittest.TestCase): + def test_uint_encoding(self): + uint_test_values = [0, 1, 10, 100, 254] + for uint_size in range(8, 513, 8): + for val in uint_test_values: + uint_type = UintType(uint_size) + actual = uint_type.encode(val) + self.assertEqual(len(actual), uint_type.bit_size // 8) + expected = val.to_bytes(uint_size // 8, byteorder="big") + self.assertEqual(actual, expected) + + # Test decoding + actual = uint_type.decode(actual) + expected = val + self.assertEqual(actual, expected) + # Test for the upper limit of each bit size + val = 2 ** uint_size - 1 + uint_type = UintType(uint_size) + actual = uint_type.encode(val) + self.assertEqual(len(actual), uint_type.bit_size // 8) + + expected = val.to_bytes(uint_size // 8, byteorder="big") + self.assertEqual(actual, expected) + + actual = uint_type.decode(actual) + expected = val + self.assertEqual(actual, expected) + + # Test bad values + with self.assertRaises(error.ABIEncodingError) as e: + UintType(uint_size).encode(-1) + with self.assertRaises(error.ABIEncodingError) as e: + UintType(uint_size).encode(2 ** uint_size) + with self.assertRaises(error.ABIEncodingError) as e: + UintType(uint_size).decode("ZZZZ") + with self.assertRaises(error.ABIEncodingError) as e: + UintType(uint_size).decode(b"\xFF" * (uint_size // 8 + 1)) + + def test_ufixed_encoding(self): + ufixed_test_values = [0, 1, 10, 100, 254] + for ufixed_size in range(8, 513, 8): + for precision in range(1, 161): + for val in ufixed_test_values: + ufixed_type = UfixedType(ufixed_size, precision) + actual = ufixed_type.encode(val) + self.assertEqual(len(actual), ufixed_type.bit_size // 8) + + expected = val.to_bytes(ufixed_size // 8, byteorder="big") + self.assertEqual(actual, expected) + + # Test decoding + actual = ufixed_type.decode(actual) + expected = val + self.assertEqual(actual, expected) + # Test for the upper limit of each bit size + val = 2 ** ufixed_size - 1 + ufixed_type = UfixedType(ufixed_size, precision) + actual = ufixed_type.encode(val) + self.assertEqual(len(actual), ufixed_type.bit_size // 8) + + expected = val.to_bytes(ufixed_size // 8, byteorder="big") + self.assertEqual(actual, expected) + + actual = ufixed_type.decode(actual) + expected = val + self.assertEqual(actual, expected) + + # Test bad values + with self.assertRaises(error.ABIEncodingError) as e: + UfixedType(ufixed_size, 10).encode(-1) + with self.assertRaises(error.ABIEncodingError) as e: + UfixedType(ufixed_size, 10).encode(2 ** ufixed_size) + with self.assertRaises(error.ABIEncodingError) as e: + UfixedType(ufixed_size, 10).decode("ZZZZ") + with self.assertRaises(error.ABIEncodingError) as e: + UfixedType(ufixed_size, 10).decode( + b"\xFF" * (ufixed_size // 8 + 1) + ) + + def test_bool_encoding(self): + actual = BoolType().encode(True) + expected = bytes.fromhex("80") + self.assertEqual(actual, expected) + actual = BoolType().decode(actual) + expected = True + self.assertEqual(actual, expected) + + actual = BoolType().encode(False) + expected = bytes.fromhex("00") + self.assertEqual(actual, expected) + actual = BoolType().decode(actual) + expected = False + self.assertEqual(actual, expected) + + with self.assertRaises(error.ABIEncodingError) as e: + ByteType().encode("1") + with self.assertRaises(error.ABIEncodingError) as e: + BoolType().decode(bytes.fromhex("8000")) + with self.assertRaises(error.ABIEncodingError) as e: + BoolType().decode(bytes.fromhex("30")) + + def test_byte_encoding(self): + for i in range(255): + # Pass in an int type to encode + actual = ByteType().encode(i) + expected = bytes([i]) + self.assertEqual(actual, expected) + + # Test decoding + actual = ByteType().decode(actual) + expected = i + self.assertEqual(actual, expected) + + # Try to encode a bad byte + with self.assertRaises(error.ABIEncodingError) as e: + ByteType().encode(256) + with self.assertRaises(error.ABIEncodingError) as e: + ByteType().encode(-1) + with self.assertRaises(error.ABIEncodingError) as e: + ByteType().encode((256).to_bytes(2, byteorder="big")) + with self.assertRaises(error.ABIEncodingError) as e: + ByteType().decode(bytes.fromhex("8000")) + with self.assertRaises(error.ABIEncodingError) as e: + ByteType().decode((256).to_bytes(2, byteorder="big")) + + def test_address_encoding(self): + for _ in range(100): + # Generate 100 random addresses as strings and as 32-byte public keys + random_addr_str = account.generate_account()[1] + addr_type = AddressType() + actual = addr_type.encode(random_addr_str) + expected = encoding.decode_address(random_addr_str) + self.assertEqual(actual, expected) + + actual = addr_type.encode(expected) + self.assertEqual(actual, expected) + + # Test decoding + actual = addr_type.decode(actual) + expected = random_addr_str + self.assertEqual(actual, expected) + + def test_string_encoding(self): + # Test *some* valid combinations of UTF-8 characters + chars = string.ascii_letters + string.digits + string.punctuation + for _ in range(1000): + test_case = "".join( + random.choice(chars) for i in range(random.randint(0, 1000)) + ) + str_type = StringType() + str_len = len(test_case).to_bytes(2, byteorder="big") + expected = str_len + bytes(test_case, "utf-8") + actual = str_type.encode(test_case) + self.assertEqual(actual, expected) + + # Test decoding + actual = str_type.decode(actual) + self.assertEqual(actual, test_case) + + with self.assertRaises(error.ABIEncodingError) as e: + StringType().decode((0).to_bytes(1, byteorder="big")) + with self.assertRaises(error.ABIEncodingError) as e: + StringType().decode((1).to_bytes(2, byteorder="big")) + + def test_array_static_encoding(self): + test_cases = [ + ( + ArrayStaticType(BoolType(), 3), + [True, True, False], + bytes([0b11000000]), + ), + ( + ArrayStaticType(BoolType(), 2), + [False, True], + bytes([0b01000000]), + ), + ( + ArrayStaticType(BoolType(), 8), + [False, True, False, False, False, False, False, False], + bytes([0b01000000]), + ), + ( + ArrayStaticType(BoolType(), 8), + [True, True, True, True, True, True, True, True], + bytes([0b11111111]), + ), + ( + ArrayStaticType(BoolType(), 9), + [True, False, False, True, False, False, True, False, True], + bytes.fromhex("92 80"), + ), + ( + ArrayStaticType(UintType(64), 3), + [1, 2, 3], + bytes.fromhex( + "00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00 03" + ), + ), + ] + + for test_case in test_cases: + actual = test_case[0].encode(test_case[1]) + expected = test_case[2] + self.assertEqual(actual, expected) + + # Test decoding + actual = test_case[0].decode(actual) + expected = test_case[1] + self.assertEqual(actual, expected) + + # Test if bytes can be passed into array + actual_bytes = b"\x05\x04\x03\x02\x01\x00" + actual = ArrayStaticType(ByteType(), 6).encode(actual_bytes) + expected = bytes([5, 4, 3, 2, 1, 0]) + # self.assertEqual sometimes has problems with byte comparisons + assert actual == expected + + actual = ArrayStaticType(ByteType(), 6).decode(expected) + expected = [5, 4, 3, 2, 1, 0] + assert actual == expected + + with self.assertRaises(error.ABIEncodingError) as e: + ArrayStaticType(BoolType(), 3).encode([True, False]) + + with self.assertRaises(error.ABIEncodingError) as e: + ArrayStaticType(AddressType(), 2).encode([True, False]) + + def test_array_dynamic_encoding(self): + test_cases = [ + ( + ArrayDynamicType(BoolType()), + [], + bytes.fromhex("00 00"), + ), + ( + ArrayDynamicType(BoolType()), + [True, True, False], + bytes.fromhex("00 03 C0"), + ), + ( + ArrayDynamicType(BoolType()), + [False, True, False, False, False, False, False, False], + bytes.fromhex("00 08 40"), + ), + ( + ArrayDynamicType(BoolType()), + [True, False, False, True, False, False, True, False, True], + bytes.fromhex("00 09 92 80"), + ), + ] + + for test_case in test_cases: + actual = test_case[0].encode(test_case[1]) + expected = test_case[2] + self.assertEqual(actual, expected) + + # Test decoding + actual = test_case[0].decode(actual) + expected = test_case[1] + self.assertEqual(actual, expected) + + # Test if bytes can be passed into array + actual_bytes = b"\x05\x04\x03\x02\x01\x00" + actual = ArrayDynamicType(ByteType()).encode(actual_bytes) + expected = bytes([0, 6, 5, 4, 3, 2, 1, 0]) + # self.assertEqual sometimes has problems with byte comparisons + assert actual == expected + + actual = ArrayDynamicType(ByteType()).decode(expected) + expected = [5, 4, 3, 2, 1, 0] + assert actual == expected + + with self.assertRaises(error.ABIEncodingError) as e: + ArrayDynamicType(AddressType()).encode([True, False]) + + def test_tuple_encoding(self): + test_cases = [ + ( + type_from_string("()"), + [], + b"", + ), + ( + type_from_string("(bool[3])"), + [[True, True, False]], + bytes([0b11000000]), + ), + ( + type_from_string("(bool[])"), + [[True, True, False]], + bytes.fromhex("00 02 00 03 C0"), + ), + ( + type_from_string("(bool[2],bool[])"), + [[True, True], [True, True]], + bytes.fromhex("C0 00 03 00 02 C0"), + ), + ( + type_from_string("(bool[],bool[])"), + [[], []], + bytes.fromhex("00 04 00 06 00 00 00 00"), + ), + ( + type_from_string("(string,bool,bool,bool,bool,string)"), + ["AB", True, False, True, False, "DE"], + bytes.fromhex("00 05 A0 00 09 00 02 41 42 00 02 44 45"), + ), + ] + + for test_case in test_cases: + actual = test_case[0].encode(test_case[1]) + expected = test_case[2] + self.assertEqual(actual, expected) + + # Test decoding + actual = test_case[0].decode(actual) + expected = test_case[1] + self.assertEqual(actual, expected) + + if __name__ == "__main__": to_run = [ TestPaymentTransaction, @@ -3617,6 +4195,8 @@ def test_local_state(self): TestLogicSigTransaction, TestTemplate, TestDryrun, + TestABIType, + TestABIEncoding, ] loader = unittest.TestLoader() suites = [ From 8cd3bb615ca3c1c15846bd9eb7ec180ac4267fc4 Mon Sep 17 00:00:00 2001 From: algochoi <86622919+algochoi@users.noreply.github.com> Date: Thu, 28 Oct 2021 11:20:17 -0400 Subject: [PATCH 2/6] Add CircleCI configs to the Python SDK repo (#246) * Add circle ci configs * Formatting * Update image --- .circleci/config.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..ffbbfa07 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,14 @@ +version: 2.1 +jobs: + build: + machine: + image: "ubuntu-2004:202104-01" + steps: + - checkout + - run: + command: | + pip3 install -r requirements.txt + black --check . + set -e + python3 test_unit.py + make docker-test From d8b29d9fd73ea729f077069ad6682217efa03984 Mon Sep 17 00:00:00 2001 From: algochoi <86622919+algochoi@users.noreply.github.com> Date: Wed, 24 Nov 2021 08:58:42 -0500 Subject: [PATCH 3/6] ABI Interaction Support for Python SDK (#247) * Start ABI JSON interaction * Add static annoation * Fix Method argument parsing * Add ABI Typing to Method arguments * [WIP] Add AtomicTransactionComposer build functions * [WIP] Sign and send atomic transaction groups * Add unit tests for object parsing * Clean up method calls * Address PR comments on JSON objects * Refactor ABI Type to ABIType so it can be exposed to outside world * Add cucumber steps for ABI tests and update existing implementation so it can pass these tests * Refactor TransactionSigner to Abstract class and merge signatures when signing * Update testing to reflect json unit tests and composer tests * Formatting and docstring fixes * Clean up imports * Fix unit test for appId * Refactor some names and add txn as an arg type * Partially address PR comments * Add some additional checks for safety * Fix a step so we check for empty string instead of None * MInclude returns tests and check for a valid returns from a ABI call * Addressing PR comments about type hints and returning self * Ensure group ids are zero when adding transactions --- Makefile | 4 +- algosdk/abi/__init__.py | 4 + algosdk/abi/address_type.py | 4 +- algosdk/abi/array_dynamic_type.py | 4 +- algosdk/abi/array_static_type.py | 4 +- algosdk/abi/base_type.py | 5 +- algosdk/abi/bool_type.py | 4 +- algosdk/abi/byte_type.py | 4 +- algosdk/abi/contract.py | 47 +++ algosdk/abi/interface.py | 39 ++ algosdk/abi/method.py | 222 ++++++++++ algosdk/abi/string_type.py | 4 +- algosdk/abi/tuple_type.py | 4 +- algosdk/abi/ufixed_type.py | 4 +- algosdk/abi/uint_type.py | 4 +- algosdk/atomic_transaction_composer.py | 544 +++++++++++++++++++++++++ algosdk/error.py | 5 + test/steps/v2_steps.py | 432 +++++++++++++++++++- test_unit.py | 86 ++++ 19 files changed, 1391 insertions(+), 33 deletions(-) create mode 100644 algosdk/abi/contract.py create mode 100644 algosdk/abi/interface.py create mode 100644 algosdk/abi/method.py create mode 100644 algosdk/atomic_transaction_composer.py diff --git a/Makefile b/Makefile index b3fdb786..b2c41234 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ unit: - behave --tags="@unit.offline or @unit.algod or @unit.indexer or @unit.rekey or @unit.tealsign or @unit.dryrun or @unit.applications or @unit.responses or @unit.transactions or @unit.responses.231 or @unit.feetest or @unit.indexer.logs" test -f progress2 + behave --tags="@unit.offline or @unit.algod or @unit.indexer or @unit.rekey or @unit.tealsign or @unit.dryrun or @unit.applications or @unit.responses or @unit.transactions or @unit.transactions.payment or @unit.responses.231 or @unit.feetest or @unit.indexer.logs or @unit.abijson or @unit.atomic_transaction_composer" test -f progress2 integration: - behave --tags="@algod or @assets or @auction or @kmd or @send or @template or @indexer or @indexer.applications or @rekey or @compile or @dryrun or @dryrun.testing or @applications or @applications.verified or @indexer.231" test -f progress2 + behave --tags="@algod or @assets or @auction or @kmd or @send or @template or @indexer or @indexer.applications or @rekey or @compile or @dryrun or @dryrun.testing or @applications or @applications.verified or @indexer.231 or @abi" test -f progress2 docker-test: ./run_integration.sh diff --git a/algosdk/abi/__init__.py b/algosdk/abi/__init__.py index 161c0b91..02e78ad5 100644 --- a/algosdk/abi/__init__.py +++ b/algosdk/abi/__init__.py @@ -1,6 +1,7 @@ from .util import type_from_string from .uint_type import UintType from .ufixed_type import UfixedType +from .base_type import ABIType from .bool_type import BoolType from .byte_type import ByteType from .address_type import AddressType @@ -8,5 +9,8 @@ from .array_dynamic_type import ArrayDynamicType from .array_static_type import ArrayStaticType from .tuple_type import TupleType +from .method import Method, Argument, Returns +from .interface import Interface +from .contract import Contract name = "abi" diff --git a/algosdk/abi/address_type.py b/algosdk/abi/address_type.py index f51be89c..a7c3d616 100644 --- a/algosdk/abi/address_type.py +++ b/algosdk/abi/address_type.py @@ -1,4 +1,4 @@ -from .base_type import Type +from .base_type import ABIType from .byte_type import ByteType from .tuple_type import TupleType from .. import error @@ -6,7 +6,7 @@ from algosdk import encoding -class AddressType(Type): +class AddressType(ABIType): """ Represents an Address ABI Type for encoding. """ diff --git a/algosdk/abi/array_dynamic_type.py b/algosdk/abi/array_dynamic_type.py index 74821356..b92a837e 100644 --- a/algosdk/abi/array_dynamic_type.py +++ b/algosdk/abi/array_dynamic_type.py @@ -1,10 +1,10 @@ -from .base_type import ABI_LENGTH_SIZE, Type +from .base_type import ABI_LENGTH_SIZE, ABIType from .byte_type import ByteType from .tuple_type import TupleType from .. import error -class ArrayDynamicType(Type): +class ArrayDynamicType(ABIType): """ Represents a ArrayDynamic ABI Type for encoding. diff --git a/algosdk/abi/array_static_type.py b/algosdk/abi/array_static_type.py index d9ebca9b..4d935d2a 100644 --- a/algosdk/abi/array_static_type.py +++ b/algosdk/abi/array_static_type.py @@ -1,13 +1,13 @@ import math -from .base_type import Type +from .base_type import ABIType from .bool_type import BoolType from .byte_type import ByteType from .tuple_type import TupleType from .. import error -class ArrayStaticType(Type): +class ArrayStaticType(ABIType): """ Represents a ArrayStatic ABI Type for encoding. diff --git a/algosdk/abi/base_type.py b/algosdk/abi/base_type.py index 54a5eec6..0fdcedee 100644 --- a/algosdk/abi/base_type.py +++ b/algosdk/abi/base_type.py @@ -1,11 +1,10 @@ from abc import ABC, abstractmethod -from enum import IntEnum # Globals ABI_LENGTH_SIZE = 2 # We use 2 bytes to encode the length of a dynamic element -class Type(ABC): +class ABIType(ABC): """ Represents an ABI Type for encoding. """ @@ -45,7 +44,7 @@ def encode(self, value): pass @abstractmethod - def decode(self, value_string): + def decode(self, bytestring): """ Deserialize the ABI type and value from a byte string using ABI encoding rules. """ diff --git a/algosdk/abi/bool_type.py b/algosdk/abi/bool_type.py index 1f11ba63..a7b0a913 100644 --- a/algosdk/abi/bool_type.py +++ b/algosdk/abi/bool_type.py @@ -1,8 +1,8 @@ -from .base_type import Type +from .base_type import ABIType from .. import error -class BoolType(Type): +class BoolType(ABIType): """ Represents a Bool ABI Type for encoding. """ diff --git a/algosdk/abi/byte_type.py b/algosdk/abi/byte_type.py index d980b076..ccca8087 100644 --- a/algosdk/abi/byte_type.py +++ b/algosdk/abi/byte_type.py @@ -1,8 +1,8 @@ -from .base_type import Type +from .base_type import ABIType from .. import error -class ByteType(Type): +class ByteType(ABIType): """ Represents a Byte ABI Type for encoding. """ diff --git a/algosdk/abi/contract.py b/algosdk/abi/contract.py new file mode 100644 index 00000000..054e3ee1 --- /dev/null +++ b/algosdk/abi/contract.py @@ -0,0 +1,47 @@ +import json + +from algosdk.abi.method import Method + + +class Contract: + """ + Represents a ABI contract description. + + Args: + name (string): name of the contract + app_id (int): application id associated with the contract + methods (list): list of Method objects + """ + + def __init__(self, name, app_id, methods) -> None: + self.name = name + self.app_id = int(app_id) + self.methods = methods + + def __eq__(self, o) -> bool: + if not isinstance(o, Contract): + return False + return ( + self.name == o.name + and self.app_id == o.app_id + and self.methods == o.methods + ) + + @staticmethod + def from_json(resp): + d = json.loads(resp) + return Contract.undictify(d) + + def dictify(self): + d = {} + d["name"] = self.name + d["appId"] = self.app_id + d["methods"] = [m.dictify() for m in self.methods] + return d + + @staticmethod + def undictify(d): + name = d["name"] + app_id = d["appId"] + method_list = [Method.undictify(method) for method in d["methods"]] + return Contract(name=name, app_id=app_id, methods=method_list) diff --git a/algosdk/abi/interface.py b/algosdk/abi/interface.py new file mode 100644 index 00000000..64bccdfe --- /dev/null +++ b/algosdk/abi/interface.py @@ -0,0 +1,39 @@ +import json + +from algosdk.abi.method import Method + + +class Interface: + """ + Represents a ABI interface description. + + Args: + name (string): name of the interface + methods (list): list of Method objects + """ + + def __init__(self, name, methods): + self.name = name + self.methods = methods + + def __eq__(self, o): + if not isinstance(o, Interface): + return False + return self.name == o.name and self.methods == o.methods + + @staticmethod + def from_json(resp): + d = json.loads(resp) + return Interface.undictify(d) + + def dictify(self): + d = {} + d["name"] = self.name + d["methods"] = [m.dictify() for m in self.methods] + return d + + @staticmethod + def undictify(d): + name = d["name"] + method_list = [Method.undictify(method) for method in d["methods"]] + return Interface(name=name, methods=method_list) diff --git a/algosdk/abi/method.py b/algosdk/abi/method.py new file mode 100644 index 00000000..1254a60c --- /dev/null +++ b/algosdk/abi/method.py @@ -0,0 +1,222 @@ +import json + +from Cryptodome.Hash import SHA512 + +from algosdk import abi, constants, error + + +TRANSACTION_ARGS = ( + "txn", # Denotes a placeholder for any of the six transaction types below + constants.PAYMENT_TXN, + constants.KEYREG_TXN, + constants.ASSETCONFIG_TXN, + constants.ASSETTRANSFER_TXN, + constants.ASSETFREEZE_TXN, + constants.APPCALL_TXN, +) + + +class Method: + """ + Represents a ABI method description. + + Args: + name (string): name of the method + args (list): list of Argument objects with type, name, and optional + description + returns (Returns): a Returns object with a type and optional description + desc (string, optional): optional description of the method + """ + + def __init__(self, name, args, returns, desc=None) -> None: + self.name = name + self.args = args + self.desc = desc + self.returns = returns + # Calculate number of method calls passed in as arguments and + # add one for this method call itself. + txn_count = 1 + for arg in self.args: + if arg.type in TRANSACTION_ARGS: + txn_count += 1 + self.txn_calls = txn_count + + def __eq__(self, o) -> bool: + if not isinstance(o, Method): + return False + return ( + self.name == o.name + and self.args == o.args + and self.returns == o.returns + and self.desc == o.desc + and self.txn_calls == o.txn_calls + ) + + def get_signature(self): + arg_string = ",".join(str(arg.type) for arg in self.args) + ret_string = self.returns.type if self.returns else "void" + return "{}({}){}".format(self.name, arg_string, ret_string) + + def get_selector(self): + """ + Returns the ABI method signature, which is the first four bytes of the + SHA-512/256 hash of the method signature. + + Returns: + bytes: first four bytes of the method signature hash + """ + hash = SHA512.new(truncate="256") + hash.update(self.get_signature().encode("utf-8")) + return hash.digest()[:4] + + def get_txn_calls(self): + """ + Returns the number of transactions needed to invoke this ABI method. + """ + return self.txn_calls + + @staticmethod + def _parse_string(s): + # Parses a method signature into three tokens, returned as a list: + # e.g. 'a(b,c)d' -> ['a', 'b,c', 'd'] + stack = [] + for i, char in enumerate(s): + if char == "(": + stack.append(i) + elif char == ")": + if not stack: + break + left_index = stack.pop() + if not stack: + return (s[:left_index], s[left_index + 1 : i], s[i + 1 :]) + + raise error.ABIEncodingError( + "ABI method string has mismatched parentheses: {}".format(s) + ) + + @staticmethod + def from_json(resp): + method_dict = json.loads(resp) + return Method.undictify(method_dict) + + @staticmethod + def from_signature(s): + # Split string into tokens around outer parentheses. + # The first token should always be the name of the method, + # the second token should be the arguments as a tuple, + # and the last token should be the return type (or void). + tokens = Method._parse_string(s) + argument_list = [ + Argument(t) for t in abi.TupleType._parse_tuple(tokens[1]) + ] + return_type = Returns(tokens[-1]) + return Method(name=tokens[0], args=argument_list, returns=return_type) + + def dictify(self): + d = {} + d["name"] = self.name + d["args"] = [arg.dictify() for arg in self.args] + if self.returns: + d["returns"] = self.returns.dictify() + if self.desc: + d["desc"] = self.desc + return d + + @staticmethod + def undictify(d): + name = d["name"] + arg_list = [Argument.undictify(arg) for arg in d["args"]] + return_obj = ( + Returns.undictify(d["returns"]) if "returns" in d else None + ) + desc = d["desc"] if "desc" in d else None + return Method(name=name, args=arg_list, returns=return_obj, desc=desc) + + +class Argument: + """ + Represents an argument for a ABI method + + Args: + arg_type (string): ABI type or transaction string of the method argument + name (string, optional): name of this method argument + desc (string, optional): description of this method argument + """ + + def __init__(self, arg_type, name=None, desc=None) -> None: + if arg_type in TRANSACTION_ARGS: + self.type = arg_type + else: + # If the type cannot be parsed into an ABI type, it will error + self.type = abi.util.type_from_string(arg_type) + self.name = name + self.desc = desc + + def __eq__(self, o) -> bool: + if not isinstance(o, Argument): + return False + return ( + self.name == o.name and self.type == o.type and self.desc == o.desc + ) + + def __str__(self): + return str(self.type) + + def dictify(self): + d = {} + d["type"] = str(self.type) + if self.name: + d["name"] = self.name + if self.desc: + d["desc"] = self.desc + return d + + @staticmethod + def undictify(d): + return Argument( + arg_type=d["type"], + name=d["name"] if "name" in d else None, + desc=d["desc"] if "desc" in d else None, + ) + + +class Returns: + """ + Represents a return type for a ABI method + + Args: + arg_type (string): ABI type of this return argument + desc (string, optional): description of this return argument + """ + + # Represents a void return. + VOID = "void" + + def __init__(self, arg_type, desc=None) -> None: + if arg_type == "void": + self.type = self.VOID + else: + # If the type cannot be parsed into an ABI type, it will error. + self.type = abi.util.type_from_string(arg_type) + self.desc = desc + + def __eq__(self, o) -> bool: + if not isinstance(o, Returns): + return False + return self.type == o.type and self.desc == o.desc + + def __str__(self): + return str(self.type) + + def dictify(self): + d = {} + d["type"] = str(self.type) + if self.desc: + d["desc"] = self.desc + return d + + @staticmethod + def undictify(d): + return Returns( + arg_type=d["type"], desc=d["desc"] if "desc" in d else None + ) diff --git a/algosdk/abi/string_type.py b/algosdk/abi/string_type.py index 64b0efa4..b2c207df 100644 --- a/algosdk/abi/string_type.py +++ b/algosdk/abi/string_type.py @@ -1,8 +1,8 @@ -from .base_type import ABI_LENGTH_SIZE, Type +from .base_type import ABI_LENGTH_SIZE, ABIType from .. import error -class StringType(Type): +class StringType(ABIType): """ Represents a String ABI Type for encoding. """ diff --git a/algosdk/abi/tuple_type.py b/algosdk/abi/tuple_type.py index 54fcec15..7ec5095e 100644 --- a/algosdk/abi/tuple_type.py +++ b/algosdk/abi/tuple_type.py @@ -1,9 +1,9 @@ -from .base_type import ABI_LENGTH_SIZE, Type +from .base_type import ABI_LENGTH_SIZE, ABIType from .bool_type import BoolType from .. import error -class TupleType(Type): +class TupleType(ABIType): """ Represents a Tuple ABI Type for encoding. diff --git a/algosdk/abi/ufixed_type.py b/algosdk/abi/ufixed_type.py index 7bb531bd..d86cfe3c 100644 --- a/algosdk/abi/ufixed_type.py +++ b/algosdk/abi/ufixed_type.py @@ -1,8 +1,8 @@ -from .base_type import Type +from .base_type import ABIType from .. import error -class UfixedType(Type): +class UfixedType(ABIType): """ Represents an Ufixed ABI Type for encoding. diff --git a/algosdk/abi/uint_type.py b/algosdk/abi/uint_type.py index 42ce09a7..b22c26ce 100644 --- a/algosdk/abi/uint_type.py +++ b/algosdk/abi/uint_type.py @@ -1,8 +1,8 @@ -from .base_type import Type +from .base_type import ABIType from .. import error -class UintType(Type): +class UintType(ABIType): """ Represents an Uint ABI Type for encoding. diff --git a/algosdk/atomic_transaction_composer.py b/algosdk/atomic_transaction_composer.py new file mode 100644 index 00000000..156dee20 --- /dev/null +++ b/algosdk/atomic_transaction_composer.py @@ -0,0 +1,544 @@ +from abc import ABC, abstractmethod +import base64 +import copy +from enum import IntEnum + +from algosdk import abi, error +from algosdk.future import transaction + +# The first four bytes of an ABI method call return must have this hash +ABI_RETURN_HASH = b"\x15\x1f\x7c\x75" + + +class AtomicTransactionComposerStatus(IntEnum): + # BUILDING indicates that the atomic group is still under construction + BUILDING = 0 + + # BUILT indicates that the atomic group has been finalized, + # but not yet signed. + BUILT = 1 + + # SIGNED indicates that the atomic group has been finalized and signed, + # but not yet submitted to the network. + SIGNED = 2 + + # SUBMITTED indicates that the atomic group has been finalized, signed, + # and submitted to the network. + SUBMITTED = 3 + + # COMMITTED indicates that the atomic group has been finalized, signed, + # submitted, and successfully committed to a block. + COMMITTED = 4 + + +class AtomicTransactionComposer: + """ + Constructs an atomic transaction group which may contain a combination of + Transactions and ABI Method calls. + + Args: + status (AtomicTransactionComposerStatus): IntEnum representing the current state of the composer + method_dict (dict): dictionary of an index in the transaction list to a Method object + txn_list (list[TransactionWithSigner]): list of transactions with signers + signed_txns (list[SignedTransaction]): list of signed transactions + tx_ids (list[str]): list of individual transaction IDs in this atomic group + """ + + # The maximum size of an atomic transaction group. + MAX_GROUP_SIZE = 16 + # The maximum number of app-args that can be individually packed for ABIs + MAX_ABI_APP_ARG_LIMIT = 14 + + def __init__(self) -> None: + self.status = AtomicTransactionComposerStatus.BUILDING + self.method_dict = {} + self.txn_list = [] + self.signed_txns = [] + self.tx_ids = [] + + def get_status(self): + """ + Returns the status of this composer's transaction group. + """ + return self.status + + def get_tx_count(self): + """ + Returns the number of transactions currently in this atomic group. + """ + return len(self.txn_list) + + def clone(self): + """ + Creates a new composer with the same underlying transactions. + The new composer's status will be BUILDING, so additional transactions + may be added to it. + """ + cloned = AtomicTransactionComposer() + cloned.method_dict = copy.deepcopy(self.method_dict) + cloned.txn_list = copy.deepcopy(self.txn_list) + for t in cloned.txn_list: + t.txn.group = None + cloned.status = AtomicTransactionComposerStatus.BUILDING + return cloned + + def add_transaction(self, txn_and_signer): + """ + Adds a transaction to this atomic group. + + An error will be thrown if the composer's status is not BUILDING, + or if adding this transaction causes the current group to exceed + MAX_GROUP_SIZE. + + Args: + txn_and_signer (TransactionWithSigner) + """ + if self.status != AtomicTransactionComposerStatus.BUILDING: + raise error.AtomicTransactionComposerError( + "AtomicTransactionComposer must be in BUILDING state for a transaction to be added" + ) + if len(self.txn_list) == self.MAX_GROUP_SIZE: + raise error.AtomicTransactionComposerError( + "AtomicTransactionComposer cannot exceed MAX_GROUP_SIZE {} transactions".format( + self.MAX_GROUP_SIZE + ) + ) + if not isinstance(txn_and_signer, TransactionWithSigner): + raise error.AtomicTransactionComposerError( + "expected TransactionWithSigner object to the AtomicTransactionComposer" + ) + if txn_and_signer.txn.group and txn_and_signer.txn.group != 0: + raise error.AtomicTransactionComposerError( + "cannot add a transaction with nonzero group ID" + ) + self.txn_list.append(txn_and_signer) + return self + + def add_method_call( + self, + app_id, + method, + sender, + sp, + signer, + method_args=None, + on_complete=transaction.OnComplete.NoOpOC, + note=None, + lease=None, + rekey_to=None, + ): + """ + Add a smart contract method call to this atomic group. + + An error will be thrown if the composer's status is not BUILDING, + if adding this transaction causes the current group to exceed + MAX_GROUP_SIZE, or if the provided arguments are invalid for + the given method. + + Args: + app_id (int): application id of app that the method is being invoked on + method (Method): ABI method object with initialized arguments and return types + sender (str): address of the sender + sp (SuggestedParams): suggested params from algod + signer (TransactionSigner): signer that will sign the transactions + method_args (list[ABIValue | TransactionWithSigner], optional): list of arguments to be encoded + or transactions that immediate precede this method call + on_complete (OnComplete, optional): intEnum representing what app should do on completion + and if blank, it will default to a NoOp call + note (bytes, optional): arbitrary optional bytes + lease (byte[32], optional): specifies a lease, and no other transaction + with the same sender and lease can be confirmed in this + transaction's valid rounds + rekey_to (str, optional): additionally rekey the sender to this address + + """ + if self.status != AtomicTransactionComposerStatus.BUILDING: + raise error.AtomicTransactionComposerError( + "AtomicTransactionComposer must be in BUILDING state for a transaction to be added" + ) + if len(self.txn_list) + method.get_txn_calls() > self.MAX_GROUP_SIZE: + raise error.AtomicTransactionComposerError( + "AtomicTransactionComposer cannot exceed MAX_GROUP_SIZE transactions" + ) + if not method_args: + method_args = [] + if len(method.args) != len(method_args): + raise error.AtomicTransactionComposerError( + "number of method arguments do not match the method signature" + ) + if not isinstance(method, abi.method.Method): + raise error.AtomicTransactionComposerError( + "invalid Method object was passed into AtomicTransactionComposer" + ) + if app_id == 0: + raise error.AtomicTransactionComposerError( + "application create call not supported" + ) + + app_args = [] + # For more than 14 args, including the selector, compact them into a tuple + additional_args = [] + additional_types = [] + txn_list = [] + # First app arg must be the selector of the method + app_args.append(method.get_selector()) + # Iterate through the method arguments and either pack a transaction + # or encode a ABI value. + for i, arg in enumerate(method.args): + if arg.type in abi.method.TRANSACTION_ARGS: + if not isinstance(method_args[i], TransactionWithSigner): + raise error.AtomicTransactionComposerError( + "expected TransactionWithSigner as method argument, but received: {}".format( + method_args[i] + ) + ) + if method_args[i].txn.group and method_args[i].txn.group != 0: + raise error.AtomicTransactionComposerError( + "cannot add a transaction with nonzero group ID" + ) + txn_list.append(method_args[i]) + elif len(app_args) > self.MAX_ABI_APP_ARG_LIMIT: + # Pack the remaining values as a tuple + additional_types.append(arg.type) + additional_args.append(method_args[i]) + else: + encoded_arg = arg.type.encode(method_args[i]) + app_args.append(encoded_arg) + + if additional_args: + remainder_args = abi.TupleType(additional_types).encode( + additional_args + ) + app_args.append(remainder_args) + + # Create a method call transaction + method_txn = transaction.ApplicationCallTxn( + sender=sender, + sp=sp, + index=app_id, + on_complete=on_complete, + app_args=app_args, + note=note, + lease=lease, + rekey_to=rekey_to, + ) + txn_with_signer = TransactionWithSigner(method_txn, signer) + txn_list.append(txn_with_signer) + + self.txn_list += txn_list + self.method_dict[len(self.txn_list) - 1] = method + return self + + def build_group(self): + """ + Finalize the transaction group and returns the finalized transactions with signers. + The composer's status will be at least BUILT after executing this method. + + Returns: + list[TransactionWithSigner]: list of transactions with signers + """ + if self.status >= AtomicTransactionComposerStatus.BUILT: + return self.txn_list + if not self.txn_list: + raise error.AtomicTransactionComposerError( + "no transactions to build for AtomicTransactionComposer" + ) + + # Get group transaction id + group_txns = [t.txn for t in self.txn_list] + group_id = transaction.calculate_group_id(group_txns) + for t in self.txn_list: + t.txn.group = group_id + self.tx_ids.append(t.txn.get_txid()) + + self.status = AtomicTransactionComposerStatus.BUILT + return self.txn_list + + def gather_signatures(self): + """ + Obtain signatures for each transaction in this group. If signatures have already been obtained, + this method will return cached versions of the signatures. + The composer's status will be at least SIGNED after executing this method. + An error will be thrown if signing any of the transactions fails. + + Returns: + list[SignedTransactions]: list of signed transactions + """ + if self.status >= AtomicTransactionComposerStatus.SIGNED: + # Return cached versions of the signatures + return self.signed_txns + + stxn_list = [None] * len(self.txn_list) + signer_indexes = {} # Map a signer to a list of indices to sign + txn_list = self.build_group() + for i, txn_with_signer in enumerate(txn_list): + if txn_with_signer.signer not in signer_indexes: + signer_indexes[txn_with_signer.signer] = [] + signer_indexes[txn_with_signer.signer].append(i) + + # Sign then merge the signed transactions in order + txns = [t.txn for t in self.txn_list] + for signer, indexes in signer_indexes.items(): + stxns = signer.sign_transactions(txns, indexes) + for i, stxn in enumerate(stxns): + index = indexes[i] + stxn_list[index] = stxn + + if None in stxn_list: + raise error.AtomicTransactionComposerError( + "missing signatures, got {}".format(stxn_list) + ) + + self.status = AtomicTransactionComposerStatus.SIGNED + self.signed_txns = stxn_list + return self.signed_txns + + def submit(self, client): + """ + Send the transaction group to the network, but don't wait for it to be + committed to a block. An error will be thrown if submission fails. + The composer's status must be SUBMITTED or lower before calling this method. + If submission is successful, this composer's status will update to SUBMITTED. + + Note: a group can only be submitted again if it fails. + + Args: + client (AlgodClient): Algod V2 client + + Returns: + list[Transaction]: list of submitted transactions + """ + if self.status <= AtomicTransactionComposerStatus.SUBMITTED: + self.gather_signatures() + else: + raise error.AtomicTransactionComposerError( + "AtomicTransactionComposerStatus must be submitted or lower to submit a group" + ) + + client.send_transactions(self.signed_txns) + self.status = AtomicTransactionComposerStatus.SUBMITTED + return self.tx_ids + + def execute(self, client, wait_rounds): + """ + Send the transaction group to the network and wait until it's committed + to a block. An error will be thrown if submission or execution fails. + The composer's status must be SUBMITTED or lower before calling this method, + since execution is only allowed once. If submission is successful, + this composer's status will update to SUBMITTED. + If the execution is also successful, this composer's status will update to COMMITTED. + + Note: a group can only be submitted again if it fails. + + Args: + client (AlgodClient): Algod V2 client + wait_rounds (int): maximum number of rounds to wait for transaction confirmation + + Returns: + AtomicTransactionResponse: Object with confirmed round for this transaction, + a list of txIDs of the submitted transactions, and an array of + results for each method call transaction in this group. If a + method has no return value (void), then the method results array + will contain None for that method's return value. + """ + if self.status > AtomicTransactionComposerStatus.SUBMITTED: + raise error.AtomicTransactionComposerError( + "AtomicTransactionComposerStatus must be submitted or lower to execute a group" + ) + + self.submit(client) + resp = transaction.wait_for_confirmation( + client, self.tx_ids[0], wait_rounds + ) + self.status = AtomicTransactionComposerStatus.COMMITTED + + confirmed_round = resp["confirmed-round"] + method_results = [] + + for i, tx_id in enumerate(self.tx_ids): + raw_value = None + return_value = None + decode_error = None + + if i not in self.method_dict: + continue + # Return is void + if self.method_dict[i].returns.type == abi.Returns.VOID: + method_results.append( + ABIResult( + tx_id=tx_id, + raw_value=raw_value, + return_value=return_value, + decode_error=decode_error, + ) + ) + continue + + # Parse log for ABI method return value + try: + resp = client.pending_transaction_info(tx_id) + confirmed_round = resp["confirmed-round"] + logs = resp["logs"] if "logs" in resp else [] + + # Look for the last returned value in the log + for result in reversed(logs): + # Check that the first four bytes is the hash of "return" + result_bytes = base64.b64decode(result) + if result_bytes[:4] != ABI_RETURN_HASH: + continue + raw_value = result_bytes[4:] + return_value = self.method_dict[i].returns.type.decode( + raw_value + ) + decode_error = None + break + except Exception as e: + decode_error = e + + abi_result = ABIResult( + tx_id=tx_id, + raw_value=raw_value, + return_value=return_value, + decode_error=decode_error, + ) + method_results.append(abi_result) + + return AtomicTransactionResponse( + confirmed_round=confirmed_round, + tx_ids=self.tx_ids, + results=method_results, + ) + + +class TransactionSigner(ABC): + """ + Represents an object which can sign transactions from an atomic transaction group. + """ + + def __init__(self) -> None: + pass + + @abstractmethod + def sign_transactions(self, txn_group, indexes): + pass + + +class AccountTransactionSigner(TransactionSigner): + """ + Represents a Transaction Signer for an account that can sign transactions from an + atomic transaction group. + + Args: + private_key (str): private key of signing account + """ + + def __init__(self, private_key) -> None: + super().__init__() + self.private_key = private_key + + def sign_transactions(self, txn_group, indexes): + """ + Sign transactions in a transaction group given the indexes. + + Returns an array of encoded signed transactions. The length of the + array will be the same as the length of indexesToSign, and each index i in the array + corresponds to the signed transaction from txnGroup[indexesToSign[i]]. + + Args: + txn_group (list[Transaction]): atomic group of transactions + indexes (list[int]): array of indexes in the atomic transaction group that should be signed + """ + stxns = [] + for i in indexes: + stxn = txn_group[i].sign(self.private_key) + stxns.append(stxn) + return stxns + + +class LogicSigTransactionSigner(TransactionSigner): + """ + Represents a Transaction Signer for a LogicSig that can sign transactions from an + atomic transaction group. + + Args: + lsig (LogicSigAccount): LogicSig account + """ + + def __init__(self, lsig) -> None: + super().__init__() + self.lsig = lsig + + def sign_transactions(self, txn_group, indexes): + """ + Sign transactions in a transaction group given the indexes. + + Returns an array of encoded signed transactions. The length of the + array will be the same as the length of indexesToSign, and each index i in the array + corresponds to the signed transaction from txnGroup[indexesToSign[i]]. + + Args: + txn_group (list[Transaction]): atomic group of transactions + indexes (list[int]): array of indexes in the atomic transaction group that should be signed + """ + stxns = [] + for i in indexes: + stxn = transaction.LogicSigTransaction(txn_group[i], self.lsig) + stxns.append(stxn) + return stxns + + +class MultisigTransactionSigner(TransactionSigner): + """ + Represents a Transaction Signer for a Multisig that can sign transactions from an + atomic transaction group. + + Args: + msig (Multisig): Multisig account + sks (str): private keys of multisig + """ + + def __init__(self, msig, sks) -> None: + super().__init__() + self.msig = msig + self.sks = sks + + def sign_transactions(self, txn_group, indexes): + """ + Sign transactions in a transaction group given the indexes. + + Returns an array of encoded signed transactions. The length of the + array will be the same as the length of indexesToSign, and each index i in the array + corresponds to the signed transaction from txnGroup[indexesToSign[i]]. + + Args: + txn_group (list[Transaction]): atomic group of transactions + indexes (list[int]): array of indexes in the atomic transaction group that should be signed + """ + stxns = [] + for i in indexes: + mtxn = transaction.MultisigTransaction(txn_group[i], self.msig) + for sk in self.sks: + mtxn.sign(sk) + stxns.append(mtxn) + return stxns + + +class TransactionWithSigner: + def __init__(self, txn, signer) -> None: + self.txn = txn + self.signer = signer + + +class ABIResult: + def __init__(self, tx_id, raw_value, return_value, decode_error) -> None: + self.tx_id = tx_id + self.raw_value = raw_value + self.return_value = return_value + self.decode_error = decode_error + + +class AtomicTransactionResponse: + def __init__(self, confirmed_round, tx_ids, results) -> None: + self.confirmed_round = confirmed_round + self.tx_ids = tx_ids + self.abi_results = results diff --git a/algosdk/error.py b/algosdk/error.py index 0ca52c71..31583164 100644 --- a/algosdk/error.py +++ b/algosdk/error.py @@ -217,3 +217,8 @@ def __init__(self, msg): class ABIEncodingError(Exception): def __init__(self, msg): super().__init__(msg) + + +class AtomicTransactionComposerError(Exception): + def __init__(self, msg): + super().__init__(msg) diff --git a/test/steps/v2_steps.py b/test/steps/v2_steps.py index 2e0306ae..bb3c8f44 100644 --- a/test/steps/v2_steps.py +++ b/test/steps/v2_steps.py @@ -16,7 +16,14 @@ ) # pylint: disable=no-name-in-module from algosdk.future import transaction -from algosdk import account, encoding, error, mnemonic +from algosdk import ( + abi, + account, + atomic_transaction_composer, + encoding, + error, + mnemonic, +) from algosdk.v2client import * from algosdk.v2client.models import ( DryrunRequest, @@ -1653,11 +1660,34 @@ def signing_account(context, address, mnemonic): context.signing_mnemonic = mnemonic +@given( + 'suggested transaction parameters fee {fee}, flat-fee "{flat_fee:MaybeBool}", first-valid {first_valid}, last-valid {last_valid}, genesis-hash "{genesis_hash}", genesis-id "{genesis_id}"' +) +def suggested_transaction_parameters( + context, fee, flat_fee, first_valid, last_valid, genesis_hash, genesis_id +): + context.suggested_params = transaction.SuggestedParams( + fee=int(fee), + flat_fee=flat_fee, + first=int(first_valid), + last=int(last_valid), + gh=genesis_hash, + gen=genesis_id, + ) + + +@given("suggested transaction parameters from the algod v2 client") +def get_sp_from_algod(context): + context.suggested_params = context.app_acl.suggested_params() + + def operation_string_to_enum(operation): if operation == "call": return transaction.OnComplete.NoOpOC elif operation == "create": return transaction.OnComplete.NoOpOC + elif operation == "noop": + return transaction.OnComplete.NoOpOC elif operation == "update": return transaction.OnComplete.UpdateApplicationOC elif operation == "optin": @@ -1688,6 +1718,27 @@ def split_and_process_app_args(in_args): return app_args +@when( + 'I build a payment transaction with sender "{sender:MaybeString}", receiver "{receiver:MaybeString}", amount {amount}, close remainder to "{close_remainder_to:MaybeString}"' +) +def build_payment_transaction( + context, sender, receiver, amount, close_remainder_to +): + if sender == "transient": + sender = context.transient_pk + if receiver == "transient": + receiver = context.transient_pk + if not close_remainder_to: + close_remainder_to = None + context.transaction = transaction.PaymentTxn( + sender=sender, + sp=context.suggested_params, + receiver=receiver, + amt=int(amount), + close_remainder_to=close_remainder_to, + ) + + @when( 'I build an application transaction with operation "{operation:MaybeString}", application-id {application_id}, sender "{sender:MaybeString}", approval-program "{approval_program:MaybeString}", clear-program "{clear_program:MaybeString}", global-bytes {global_bytes}, global-ints {global_ints}, local-bytes {local_bytes}, local-ints {local_ints}, app-args "{app_args:MaybeString}", foreign-apps "{foreign_apps:MaybeString}", foreign-assets "{foreign_assets:MaybeString}", app-accounts "{app_accounts:MaybeString}", fee {fee}, first-valid {first_valid}, last-valid {last_valid}, genesis-hash "{genesis_hash:MaybeString}", extra-pages {extra_pages}' ) @@ -1799,10 +1850,26 @@ def sign_transaction_with_signing_account(context): context.signed_transaction = context.transaction.sign(private_key) +@then('the base64 encoded signed transactions should equal "{goldens}"') +def compare_stxns_array_to_base64_golden(context, goldens): + golden_strings = goldens.split(",") + assert len(golden_strings) == len(context.signed_transactions) + for i, golden in enumerate(golden_strings): + actual_base64 = encoding.msgpack_encode(context.signed_transactions[i]) + assert golden == actual_base64, "actual is {}".format(actual_base64) + + @then('the base64 encoded signed transaction should equal "{golden}"') def compare_to_base64_golden(context, golden): actual_base64 = encoding.msgpack_encode(context.signed_transaction) - assert golden == actual_base64 + assert golden == actual_base64, "actual is {}".format(actual_base64) + + +@then("the decoded transaction should equal the original") +def compare_to_original(context): + encoded = encoding.msgpack_encode(context.signed_transaction) + decoded = encoding.future_msgpack_decode(encoded) + assert decoded.transaction == context.transaction @given( @@ -1943,7 +2010,7 @@ def sign_submit_save_txid_with_error(context, error_string): signed_app_transaction ) except Exception as e: - if error_string not in str(e): + if not error_string or error_string not in str(e): raise RuntimeError( "error string " + error_string @@ -1957,17 +2024,34 @@ def wait_for_app_txn_confirm(context): sp = context.app_acl.suggested_params() last_round = sp.first context.app_acl.status_after_block(last_round + 2) - assert "type" in context.acl.transaction_info( - context.transient_pk, context.app_txid - ) - assert "type" in context.acl.transaction_by_id(context.app_txid) + if hasattr(context, "acl"): + assert "type" in context.acl.transaction_info( + context.transient_pk, context.app_txid + ) + assert "type" in context.acl.transaction_by_id(context.app_txid) + else: + transaction.wait_for_confirmation( + context.app_acl, context.app_txid, 10 + ) @given("I remember the new application ID.") def remember_app_id(context): - context.current_application_id = context.acl.pending_transaction_info( - context.app_txid - )["txresults"]["createdapp"] + if hasattr(context, "acl"): + context.current_application_id = context.acl.pending_transaction_info( + context.app_txid + )["txresults"]["createdapp"] + else: + context.current_application_id = ( + context.app_acl.pending_transaction_info(context.app_txid)[ + "application-index" + ] + ) + + +@given("an application id {app_id}") +def set_app_id(context, app_id): + context.current_application_id = app_id @step( @@ -2264,3 +2348,331 @@ class TestCase(DryrunTestCaseMixin, unittest.TestCase): ts.assertNoError(drr) ts.assertLocalStateContains(drr, account, dict(key=key, value=val)) + + +@given("a new AtomicTransactionComposer") +def create_atomic_transaction_composer(context): + context.atomic_transaction_composer = ( + atomic_transaction_composer.AtomicTransactionComposer() + ) + + +@when("I make a transaction signer for the {account_type} account.") +def create_transaction_signer(context, account_type): + if account_type == "transient": + private_key = context.transient_sk + elif account_type == "signing": + private_key = mnemonic.to_private_key(context.signing_mnemonic) + else: + raise NotImplementedError( + "cannot make transaction signer for " + account_type + ) + context.transaction_signer = ( + atomic_transaction_composer.AccountTransactionSigner(private_key) + ) + + +@when('I create the Method object from method signature "{method_signature}"') +def build_abi_method(context, method_signature): + context.abi_method = abi.Method.from_signature(method_signature) + + +@when("I create a transaction with signer with the current transaction.") +def create_transaction_with_signer(context): + context.transaction_with_signer = ( + atomic_transaction_composer.TransactionWithSigner( + context.transaction, context.transaction_signer + ) + ) + + +@when("I add the current transaction with signer to the composer.") +def add_transaction_to_composer(context): + context.atomic_transaction_composer.add_transaction( + context.transaction_with_signer + ) + + +def split_and_process_abi_args(method, arg_string): + method_args = [] + arg_tokens = arg_string.split(",") + arg_index = 0 + for arg in method.args: + # Skip arg if it does not have a type + if isinstance(arg.type, abi.ABIType): + arg = arg.type.decode(base64.b64decode(arg_tokens[arg_index])) + method_args.append(arg) + arg_index += 1 + return method_args + + +@when("I create a new method arguments array.") +def create_abi_method_args(context): + context.method_args = [] + + +@when( + "I append the current transaction with signer to the method arguments array." +) +def append_txn_to_method_args(context): + context.method_args.append(context.transaction_with_signer) + + +@when( + 'I append the encoded arguments "{method_args:MaybeString}" to the method arguments array.' +) +def append_app_args_to_method_args(context, method_args): + # Returns a list of ABI method arguments + app_args = split_and_process_abi_args(context.abi_method, method_args) + context.method_args += app_args + + +@when( + 'I add a method call with the {account_type} account, the current application, suggested params, on complete "{operation}", current transaction signer, current method arguments.' +) +def add_abi_method_call(context, account_type, operation): + if account_type == "transient": + sender = context.transient_pk + elif account_type == "signing": + sender = mnemonic.to_public_key(context.signing_mnemonic) + else: + raise NotImplementedError( + "cannot make transaction signer for " + account_type + ) + context.atomic_transaction_composer.add_method_call( + app_id=context.current_application_id, + method=context.abi_method, + sender=sender, + sp=context.suggested_params, + signer=context.transaction_signer, + method_args=context.method_args, + on_complete=operation_string_to_enum(operation), + ) + + +@when( + 'I build the transaction group with the composer. If there is an error it is "{error_string:MaybeString}".' +) +def build_atomic_transaction_group(context, error_string): + # Error checking not yet implemented + assert not error_string + context.atomic_transaction_composer.build_group() + + +def composer_status_string_to_enum(status): + if status == "BUILDING": + return ( + atomic_transaction_composer.AtomicTransactionComposerStatus.BUILDING + ) + elif status == "BUILT": + return ( + atomic_transaction_composer.AtomicTransactionComposerStatus.BUILT + ) + elif status == "SIGNED": + return ( + atomic_transaction_composer.AtomicTransactionComposerStatus.SIGNED + ) + elif status == "SUBMITTED": + return ( + atomic_transaction_composer.AtomicTransactionComposerStatus.SUBMITTED + ) + elif status == "COMMITTED": + return ( + atomic_transaction_composer.AtomicTransactionComposerStatus.COMMITTED + ) + else: + raise NotImplementedError( + "no AtomicTransactionComposerStatus enum for " + status + ) + + +@then('The composer should have a status of "{status}".') +def check_atomic_transaction_composer_status(context, status): + assert ( + context.atomic_transaction_composer.get_status() + == composer_status_string_to_enum(status) + ) + + +@then("I gather signatures with the composer.") +def gather_signatures_composer(context): + context.signed_transactions = ( + context.atomic_transaction_composer.gather_signatures() + ) + + +@then("I clone the composer.") +def clone_atomic_transaction_composer(context): + context.atomic_transaction_composer = ( + context.atomic_transaction_composer.clone() + ) + + +@then("I execute the current transaction group with the composer.") +def execute_atomic_transaction_composer(context): + context.atomic_transaction_composer_return = ( + context.atomic_transaction_composer.execute(context.app_acl, 10) + ) + assert context.atomic_transaction_composer_return.confirmed_round > 0 + + +@then('The app should have returned "{returns:MaybeString}".') +def check_atomic_transaction_composer_response(context, returns): + if not returns: + expected_tokens = [] + assert len(context.atomic_transaction_composer_return.abi_results) == 1 + result = context.atomic_transaction_composer_return.abi_results[0] + assert result.return_value is None + assert result.decode_error is None + else: + expected_tokens = returns.split(",") + for i, expected in enumerate(expected_tokens): + result = context.atomic_transaction_composer_return.abi_results[i] + if not returns or not expected_tokens[i]: + assert result.return_value is None + assert result.decode_error is None + continue + expected_bytes = base64.b64decode(expected) + expected_value = context.abi_method.returns.type.decode( + expected_bytes + ) + + assert expected_bytes == result.raw_value, "actual is {}".format( + result.raw_value + ) + assert ( + expected_value == result.return_value + ), "actual is {}".format(result.return_value) + assert result.decode_error is None + + +@when("I serialize the Method object into json") +def serialize_method_to_json(context): + context.json_output = context.abi_method.dictify() + + +@then( + 'the produced json should equal "{json_path}" loaded from "{json_directory}"' +) +def check_json_output_equals(context, json_path, json_directory): + with open( + "test/features/unit/" + json_directory + "/" + json_path, "rb" + ) as f: + loaded_response = json.load(f) + assert context.json_output == loaded_response + + +@when( + 'I create the Method object with name "{method_name}" method description "{method_desc}" first argument type "{first_arg_type}" first argument description "{first_arg_desc}" second argument type "{second_arg_type}" second argument description "{second_arg_desc}" and return type "{return_arg_type}"' +) +def create_method_from_test_with_arg_name( + context, + method_name, + method_desc, + first_arg_type, + first_arg_desc, + second_arg_type, + second_arg_desc, + return_arg_type, +): + context.abi_method = abi.Method( + name=method_name, + args=[ + abi.Argument(arg_type=first_arg_type, desc=first_arg_desc), + abi.Argument(arg_type=second_arg_type, desc=second_arg_desc), + ], + returns=abi.Returns(return_arg_type), + desc=method_desc, + ) + + +@when( + 'I create the Method object with name "{method_name}" first argument name "{first_arg_name}" first argument type "{first_arg_type}" second argument name "{second_arg_name}" second argument type "{second_arg_type}" and return type "{return_arg_type}"' +) +def create_method_from_test_with_arg_name( + context, + method_name, + first_arg_name, + first_arg_type, + second_arg_name, + second_arg_type, + return_arg_type, +): + context.abi_method = abi.Method( + name=method_name, + args=[ + abi.Argument(arg_type=first_arg_type, name=first_arg_name), + abi.Argument(arg_type=second_arg_type, name=second_arg_name), + ], + returns=abi.Returns(return_arg_type), + ) + + +@when( + 'I create the Method object with name "{method_name}" first argument type "{first_arg_type}" second argument type "{second_arg_type}" and return type "{return_arg_type}"' +) +def create_method_from_test( + context, method_name, first_arg_type, second_arg_type, return_arg_type +): + context.abi_method = abi.Method( + name=method_name, + args=[abi.Argument(first_arg_type), abi.Argument(second_arg_type)], + returns=abi.Returns(return_arg_type), + ) + + +@then("the deserialized json should equal the original Method object") +def deserialize_method_to_object(context): + json_string = json.dumps(context.json_output) + actual = abi.Method.from_json(json_string) + assert actual == context.abi_method + + +@then("the txn count should be {txn_count}") +def check_method_txn_count(context, txn_count): + assert context.abi_method.get_txn_calls() == int(txn_count) + + +@then('the method selector should be "{method_selector}"') +def check_method_selector(context, method_selector): + assert context.abi_method.get_selector() == bytes.fromhex(method_selector) + + +@when( + 'I create an Interface object from the Method object with name "{interface_name}"' +) +def create_interface_object(context, interface_name): + context.abi_interface = abi.Interface( + name=interface_name, methods=[context.abi_method] + ) + + +@when("I serialize the Interface object into json") +def serialize_interface_to_json(context): + context.json_output = context.abi_interface.dictify() + + +@then("the deserialized json should equal the original Interface object") +def deserialize_json_to_interface(context): + actual = abi.Interface.undictify(context.json_output) + assert actual == context.abi_interface + + +@when( + 'I create a Contract object from the Method object with name "{contract_name}" and appId {app_id}' +) +def create_contract_object(context, contract_name, app_id): + context.abi_contract = abi.Contract( + name=contract_name, app_id=app_id, methods=[context.abi_method] + ) + + +@when("I serialize the Contract object into json") +def serialize_contract_to_json(context): + context.json_output = context.abi_contract.dictify() + + +@then("the deserialized json should equal the original Contract object") +def deserialize_json_to_contract(context): + actual = abi.Contract.undictify(context.json_output) + assert actual == context.abi_contract diff --git a/test_unit.py b/test_unit.py index 3712bfda..24596f45 100644 --- a/test_unit.py +++ b/test_unit.py @@ -30,6 +30,9 @@ ArrayDynamicType, ArrayStaticType, TupleType, + Method, + Interface, + Contract, ) from algosdk.future import template, transaction from algosdk.testing import dryrun @@ -4178,6 +4181,88 @@ def test_tuple_encoding(self): self.assertEqual(actual, expected) +class TestABIInteraction(unittest.TestCase): + def test_method(self): + # Parse method object from JSON + test_json = '{"name": "add", "desc": "Calculate the sum of two 64-bit integers", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ], "returns": { "type": "uint128", "desc": "..." } }' + m = Method.from_json(test_json) + self.assertEqual(m.get_signature(), "add(uint64,uint64)uint128") + self.assertEqual(m.get_selector(), b"\x8a\xa3\xb6\x1f") + self.assertEqual( + [(a.type) for a in m.args], + [type_from_string("uint64"), type_from_string("uint64")], + ) + self.assertEqual(m.get_txn_calls(), 1) + + # Parse method object from string + test_cases = [ + ( + "add(uint64,uint64)uint128", + b"\x8a\xa3\xb6\x1f", + [type_from_string("uint64"), type_from_string("uint64")], + type_from_string("uint128"), + 1, + ), + ( + "tupler((string,uint16),bool)void", + b"\x3d\x98\xe4\x5d", + [ + type_from_string("(string,uint16)"), + type_from_string("bool"), + ], + "void", + 1, + ), + ( + "txcalls(pay,pay,axfer,byte)bool", + b"\x05\x6d\x2e\xc0", + ["pay", "pay", "axfer", type_from_string("byte")], + type_from_string("bool"), + 4, + ), + ( + "getter()string", + b"\xa2\x59\x11\x1d", + [], + type_from_string("string"), + 1, + ), + ] + + for test_case in test_cases: + m = Method.from_signature(test_case[0]) + + # Check method signature + self.assertEqual(m.get_signature(), test_case[0]) + # Check selector + self.assertEqual(m.get_selector(), test_case[1]) + # Check args + self.assertEqual([(a.type) for a in m.args], test_case[2]) + # Check return + self.assertEqual(m.returns.type, test_case[3]) + # Check txn calls + self.assertEqual(m.get_txn_calls(), test_case[4]) + + def test_interface(self): + test_json = '{"name": "Calculator","methods": [{ "name": "add", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' + i = Interface.from_json(test_json) + self.assertEqual(i.name, "Calculator") + self.assertEqual( + [m.get_signature() for m in i.methods], + ["add(uint64,uint64)void", "multiply(uint64,uint64)void"], + ) + + def test_contract(self): + test_json = '{"name": "Calculator","appId": 3, "methods": [{ "name": "add", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' + c = Contract.from_json(test_json) + self.assertEqual(c.name, "Calculator") + self.assertEqual(c.app_id, 3) + self.assertEqual( + [m.get_signature() for m in c.methods], + ["add(uint64,uint64)void", "multiply(uint64,uint64)void"], + ) + + if __name__ == "__main__": to_run = [ TestPaymentTransaction, @@ -4197,6 +4282,7 @@ def test_tuple_encoding(self): TestDryrun, TestABIType, TestABIEncoding, + TestABIInteraction, ] loader = unittest.TestLoader() suites = [ From 0f521d0f3cc34e8976491c5c5240bad5b6bb29a2 Mon Sep 17 00:00:00 2001 From: algochoi <86622919+algochoi@users.noreply.github.com> Date: Wed, 24 Nov 2021 16:11:36 -0500 Subject: [PATCH 4/6] Add type hints and clean up ABI code (#253) * Add type hints and clean up ABI types * Change relative imports to absolute imports * Add type hints for ABI objects * Refactor composer with type hints * Address PR comments, change list type hints, and move type_from_string to ABIType --- algosdk/abi/__init__.py | 21 +++-- algosdk/abi/address_type.py | 22 ++--- algosdk/abi/array_dynamic_type.py | 30 +++---- algosdk/abi/array_static_type.py | 35 ++++---- algosdk/abi/base_type.py | 106 ++++++++++++++++++++++--- algosdk/abi/bool_type.py | 18 +++-- algosdk/abi/byte_type.py | 18 +++-- algosdk/abi/contract.py | 11 +-- algosdk/abi/interface.py | 11 +-- algosdk/abi/method.py | 64 ++++++++------- algosdk/abi/string_type.py | 18 +++-- algosdk/abi/tuple_type.py | 34 ++++---- algosdk/abi/ufixed_type.py | 24 +++--- algosdk/abi/uint_type.py | 22 ++--- algosdk/abi/util.py | 85 -------------------- algosdk/atomic_transaction_composer.py | 83 ++++++++++++------- test_unit.py | 76 +++++++++--------- 17 files changed, 366 insertions(+), 312 deletions(-) delete mode 100644 algosdk/abi/util.py diff --git a/algosdk/abi/__init__.py b/algosdk/abi/__init__.py index 02e78ad5..c0cdd484 100644 --- a/algosdk/abi/__init__.py +++ b/algosdk/abi/__init__.py @@ -1,14 +1,13 @@ -from .util import type_from_string -from .uint_type import UintType -from .ufixed_type import UfixedType -from .base_type import ABIType -from .bool_type import BoolType -from .byte_type import ByteType -from .address_type import AddressType -from .string_type import StringType -from .array_dynamic_type import ArrayDynamicType -from .array_static_type import ArrayStaticType -from .tuple_type import TupleType +from algosdk.abi.uint_type import UintType +from algosdk.abi.ufixed_type import UfixedType +from algosdk.abi.base_type import ABIType +from algosdk.abi.bool_type import BoolType +from algosdk.abi.byte_type import ByteType +from algosdk.abi.address_type import AddressType +from algosdk.abi.string_type import StringType +from algosdk.abi.array_dynamic_type import ArrayDynamicType +from algosdk.abi.array_static_type import ArrayStaticType +from algosdk.abi.tuple_type import TupleType from .method import Method, Argument, Returns from .interface import Interface from .contract import Contract diff --git a/algosdk/abi/address_type.py b/algosdk/abi/address_type.py index a7c3d616..57050c71 100644 --- a/algosdk/abi/address_type.py +++ b/algosdk/abi/address_type.py @@ -1,7 +1,9 @@ -from .base_type import ABIType -from .byte_type import ByteType -from .tuple_type import TupleType -from .. import error +from typing import Union + +from algosdk.abi.base_type import ABIType +from algosdk.abi.byte_type import ByteType +from algosdk.abi.tuple_type import TupleType +from algosdk import error from algosdk import encoding @@ -14,18 +16,18 @@ class AddressType(ABIType): def __init__(self) -> None: super().__init__() - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, AddressType): return False return True - def __str__(self): + def __str__(self) -> str: return "address" - def byte_len(self): + def byte_len(self) -> int: return 32 - def is_dynamic(self): + def is_dynamic(self) -> bool: return False def _to_tuple_type(self): @@ -34,7 +36,7 @@ def _to_tuple_type(self): child_type_array.append(ByteType()) return TupleType(child_type_array) - def encode(self, value): + def encode(self, value: Union[str, bytes]) -> bytes: """ Encode an address string or a 32-byte public key into a Address ABI bytestring. @@ -62,7 +64,7 @@ def encode(self, value): ) return bytes(value) - def decode(self, bytestring): + def decode(self, bytestring: Union[bytearray, bytes]) -> str: """ Decodes a bytestring to a base32 encoded address string. diff --git a/algosdk/abi/array_dynamic_type.py b/algosdk/abi/array_dynamic_type.py index b92a837e..97be5b15 100644 --- a/algosdk/abi/array_dynamic_type.py +++ b/algosdk/abi/array_dynamic_type.py @@ -1,7 +1,9 @@ -from .base_type import ABI_LENGTH_SIZE, ABIType -from .byte_type import ByteType -from .tuple_type import TupleType -from .. import error +from typing import Any, List, NoReturn, Union + +from algosdk.abi.base_type import ABI_LENGTH_SIZE, ABIType +from algosdk.abi.byte_type import ByteType +from algosdk.abi.tuple_type import TupleType +from algosdk import error class ArrayDynamicType(ABIType): @@ -9,37 +11,37 @@ class ArrayDynamicType(ABIType): Represents a ArrayDynamic ABI Type for encoding. Args: - child_type (Type): the type of the dynamic array. + child_type (ABIType): the type of the dynamic array. Attributes: - child_type (Type) + child_type (ABIType) """ - def __init__(self, arg_type) -> None: + def __init__(self, arg_type: ABIType) -> None: super().__init__() self.child_type = arg_type - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, ArrayDynamicType): return False return self.child_type == other.child_type - def __str__(self): + def __str__(self) -> str: return "{}[]".format(self.child_type) - def byte_len(self): + def byte_len(self) -> NoReturn: raise error.ABITypeError( "cannot get length of a dynamic type: {}".format(self) ) - def is_dynamic(self): + def is_dynamic(self) -> bool: return True - def _to_tuple_type(self, length): + def _to_tuple_type(self, length: int) -> TupleType: child_type_array = [self.child_type] * length return TupleType(child_type_array) - def encode(self, value_array): + def encode(self, value_array: Union[List[Any], bytes, bytearray]) -> bytes: """ Encodes a list of values into a ArrayDynamic ABI bytestring. @@ -67,7 +69,7 @@ def encode(self, value_array): encoded = converted_tuple.encode(value_array) return bytes(length_to_encode) + encoded - def decode(self, array_bytes): + def decode(self, array_bytes: Union[bytes, bytearray]) -> list: """ Decodes a bytestring to a dynamic list. diff --git a/algosdk/abi/array_static_type.py b/algosdk/abi/array_static_type.py index 4d935d2a..690917df 100644 --- a/algosdk/abi/array_static_type.py +++ b/algosdk/abi/array_static_type.py @@ -1,10 +1,11 @@ import math +from typing import Any, List, Union -from .base_type import ABIType -from .bool_type import BoolType -from .byte_type import ByteType -from .tuple_type import TupleType -from .. import error +from algosdk.abi.base_type import ABIType +from algosdk.abi.bool_type import BoolType +from algosdk.abi.byte_type import ByteType +from algosdk.abi.tuple_type import TupleType +from algosdk import error class ArrayStaticType(ABIType): @@ -12,26 +13,26 @@ class ArrayStaticType(ABIType): Represents a ArrayStatic ABI Type for encoding. Args: - child_type (Type): the type of the child_types array. - static_length (int): length of the static array. + child_type (ABIType): the type of the child_types array. + array_len (int): length of the static array. Attributes: - child_type (Type) + child_type (ABIType) static_length (int) """ - def __init__(self, arg_type, array_len) -> None: + def __init__(self, arg_type: ABIType, array_len: int) -> None: if array_len < 1: raise error.ABITypeError( "static array length must be a positive integer: {}".format( - len(array_len) + array_len ) ) super().__init__() self.child_type = arg_type self.static_length = array_len - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, ArrayStaticType): return False return ( @@ -39,24 +40,24 @@ def __eq__(self, other) -> bool: and self.static_length == other.static_length ) - def __str__(self): + def __str__(self) -> str: return "{}[{}]".format(self.child_type, self.static_length) - def byte_len(self): + def byte_len(self) -> int: if isinstance(self.child_type, BoolType): # 8 Boolean values can be encoded into 1 byte return math.ceil(self.static_length / 8) element_byte_length = self.child_type.byte_len() return self.static_length * element_byte_length - def is_dynamic(self): + def is_dynamic(self) -> bool: return self.child_type.is_dynamic() - def _to_tuple_type(self): + def _to_tuple_type(self) -> TupleType: child_type_array = [self.child_type] * self.static_length return TupleType(child_type_array) - def encode(self, value_array): + def encode(self, value_array: Union[List[Any], bytes, bytearray]) -> bytes: """ Encodes a list of values into a ArrayStatic ABI bytestring. @@ -87,7 +88,7 @@ def encode(self, value_array): converted_tuple = self._to_tuple_type() return converted_tuple.encode(value_array) - def decode(self, array_bytes): + def decode(self, array_bytes: Union[bytes, bytearray]) -> list: """ Decodes a bytestring to a static list. diff --git a/algosdk/abi/base_type.py b/algosdk/abi/base_type.py index 0fdcedee..d0878942 100644 --- a/algosdk/abi/base_type.py +++ b/algosdk/abi/base_type.py @@ -1,7 +1,14 @@ from abc import ABC, abstractmethod +import re +from typing import Any, Union + +from algosdk import error + # Globals ABI_LENGTH_SIZE = 2 # We use 2 bytes to encode the length of a dynamic element +UFIXED_REGEX = r"^ufixed([1-9][\d]*)x([1-9][\d]*)$" +STATIC_ARRAY_REGEX = r"^([a-z\d\[\](),]+)\[([1-9][\d]*)]$" class ABIType(ABC): @@ -9,43 +16,124 @@ class ABIType(ABC): Represents an ABI Type for encoding. """ - def __init__( - self, - ) -> None: + def __init__(self) -> None: pass @abstractmethod - def __str__(self): + def __str__(self) -> str: pass @abstractmethod - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: pass @abstractmethod - def is_dynamic(self): + def is_dynamic(self) -> bool: """ Return whether the ABI type is dynamic. """ pass @abstractmethod - def byte_len(self): + def byte_len(self) -> int: """ Return the length in bytes of the ABI type. """ pass @abstractmethod - def encode(self, value): + def encode(self, value: Any) -> bytes: """ Serialize the ABI value into a byte string using ABI encoding rules. """ pass @abstractmethod - def decode(self, bytestring): + def decode(self, bytestring: bytes) -> Any: """ Deserialize the ABI type and value from a byte string using ABI encoding rules. """ pass + + @staticmethod + def from_string(s: str) -> "ABIType": + """ + Convert a valid ABI string to a corresponding ABI type. + """ + # We define the imports here to avoid circular imports + from algosdk.abi.uint_type import UintType + from algosdk.abi.ufixed_type import UfixedType + from algosdk.abi.byte_type import ByteType + from algosdk.abi.bool_type import BoolType + from algosdk.abi.address_type import AddressType + from algosdk.abi.string_type import StringType + from algosdk.abi.array_dynamic_type import ArrayDynamicType + from algosdk.abi.array_static_type import ArrayStaticType + from algosdk.abi.tuple_type import TupleType + + if s.endswith("[]"): + array_arg_type = ABIType.from_string(s[:-2]) + return ArrayDynamicType(array_arg_type) + elif s.endswith("]"): + matches = re.search(STATIC_ARRAY_REGEX, s) + try: + static_length = int(matches.group(2)) + array_type = ABIType.from_string(matches.group(1)) + return ArrayStaticType(array_type, static_length) + except Exception as e: + raise error.ABITypeError( + "malformed static array string: {}".format(s) + ) from e + if s.startswith("uint"): + try: + if not s[4:].isdecimal(): + raise error.ABITypeError( + "uint string does not contain a valid size: {}".format( + s + ) + ) + type_size = int(s[4:]) + return UintType(type_size) + except Exception as e: + raise error.ABITypeError( + "malformed uint string: {}".format(s) + ) from e + elif s == "byte": + return ByteType() + elif s.startswith("ufixed"): + matches = re.search(UFIXED_REGEX, s) + try: + bit_size = int(matches.group(1)) + precision = int(matches.group(2)) + return UfixedType(bit_size, precision) + except Exception as e: + raise error.ABITypeError( + "malformed ufixed string: {}".format(s) + ) from e + elif s == "bool": + return BoolType() + elif s == "address": + return AddressType() + elif s == "string": + return StringType() + elif len(s) >= 2 and s[0] == "(" and s[-1] == ")": + # Recursively parse parentheses from a tuple string + tuples = TupleType._parse_tuple(s[1:-1]) + tuple_list = [] + for tup in tuples: + if isinstance(tup, str): + tt = ABIType.from_string(tup) + tuple_list.append(tt) + elif isinstance(tup, list): + tts = [ABIType.from_string(t_) for t_ in tup] + tuple_list.append(tts) + else: + raise error.ABITypeError( + "cannot convert {} to an ABI type".format(tup) + ) + + return TupleType(tuple_list) + else: + raise error.ABITypeError( + "cannot convert {} to an ABI type".format(s) + ) diff --git a/algosdk/abi/bool_type.py b/algosdk/abi/bool_type.py index a7b0a913..ee73ae5f 100644 --- a/algosdk/abi/bool_type.py +++ b/algosdk/abi/bool_type.py @@ -1,5 +1,7 @@ -from .base_type import ABIType -from .. import error +from typing import Union + +from algosdk.abi.base_type import ABIType +from algosdk import error class BoolType(ABIType): @@ -10,21 +12,21 @@ class BoolType(ABIType): def __init__(self) -> None: super().__init__() - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, BoolType): return False return True - def __str__(self): + def __str__(self) -> str: return "bool" - def byte_len(self): + def byte_len(self) -> int: return 1 - def is_dynamic(self): + def is_dynamic(self) -> bool: return False - def encode(self, value): + def encode(self, value: bool) -> bytes: """ Encode a boolean value @@ -40,7 +42,7 @@ def encode(self, value): return b"\x80" return b"\x00" - def decode(self, bytestring): + def decode(self, bytestring: Union[bytes, bytearray]) -> bool: """ Decodes a bytestring to a single boolean. diff --git a/algosdk/abi/byte_type.py b/algosdk/abi/byte_type.py index ccca8087..e719e80e 100644 --- a/algosdk/abi/byte_type.py +++ b/algosdk/abi/byte_type.py @@ -1,5 +1,7 @@ -from .base_type import ABIType -from .. import error +from typing import Union + +from algosdk.abi.base_type import ABIType +from algosdk import error class ByteType(ABIType): @@ -10,21 +12,21 @@ class ByteType(ABIType): def __init__(self) -> None: super().__init__() - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, ByteType): return False return True - def __str__(self): + def __str__(self) -> str: return "byte" - def byte_len(self): + def byte_len(self) -> int: return 1 - def is_dynamic(self): + def is_dynamic(self) -> bool: return False - def encode(self, value): + def encode(self, value: int) -> bytes: """ Encode a single byte or a uint8 @@ -40,7 +42,7 @@ def encode(self, value): ) return bytes([value]) - def decode(self, bytestring): + def decode(self, bytestring: Union[bytes, bytearray]) -> bytes: """ Decodes a bytestring to a single byte. diff --git a/algosdk/abi/contract.py b/algosdk/abi/contract.py index 054e3ee1..d607a8d8 100644 --- a/algosdk/abi/contract.py +++ b/algosdk/abi/contract.py @@ -1,4 +1,5 @@ import json +from typing import List, Union from algosdk.abi.method import Method @@ -13,12 +14,12 @@ class Contract: methods (list): list of Method objects """ - def __init__(self, name, app_id, methods) -> None: + def __init__(self, name: str, app_id: int, methods: List[Method]) -> None: self.name = name self.app_id = int(app_id) self.methods = methods - def __eq__(self, o) -> bool: + def __eq__(self, o: object) -> bool: if not isinstance(o, Contract): return False return ( @@ -28,11 +29,11 @@ def __eq__(self, o) -> bool: ) @staticmethod - def from_json(resp): + def from_json(resp: Union[str, bytes, bytearray]) -> "Contract": d = json.loads(resp) return Contract.undictify(d) - def dictify(self): + def dictify(self) -> dict: d = {} d["name"] = self.name d["appId"] = self.app_id @@ -40,7 +41,7 @@ def dictify(self): return d @staticmethod - def undictify(d): + def undictify(d: dict) -> "Contract": name = d["name"] app_id = d["appId"] method_list = [Method.undictify(method) for method in d["methods"]] diff --git a/algosdk/abi/interface.py b/algosdk/abi/interface.py index 64bccdfe..ff42d809 100644 --- a/algosdk/abi/interface.py +++ b/algosdk/abi/interface.py @@ -1,4 +1,5 @@ import json +from typing import List, Union from algosdk.abi.method import Method @@ -12,28 +13,28 @@ class Interface: methods (list): list of Method objects """ - def __init__(self, name, methods): + def __init__(self, name: str, methods: List[Method]) -> None: self.name = name self.methods = methods - def __eq__(self, o): + def __eq__(self, o: object) -> bool: if not isinstance(o, Interface): return False return self.name == o.name and self.methods == o.methods @staticmethod - def from_json(resp): + def from_json(resp: Union[str, bytes, bytearray]) -> "Interface": d = json.loads(resp) return Interface.undictify(d) - def dictify(self): + def dictify(self) -> dict: d = {} d["name"] = self.name d["methods"] = [m.dictify() for m in self.methods] return d @staticmethod - def undictify(d): + def undictify(d: dict) -> "Interface": name = d["name"] method_list = [Method.undictify(method) for method in d["methods"]] return Interface(name=name, methods=method_list) diff --git a/algosdk/abi/method.py b/algosdk/abi/method.py index 1254a60c..011310ba 100644 --- a/algosdk/abi/method.py +++ b/algosdk/abi/method.py @@ -1,4 +1,5 @@ import json +from typing import List, Union from Cryptodome.Hash import SHA512 @@ -28,7 +29,13 @@ class Method: desc (string, optional): optional description of the method """ - def __init__(self, name, args, returns, desc=None) -> None: + def __init__( + self, + name: str, + args: List["Argument"], + returns: "Returns", + desc: str = None, + ) -> None: self.name = name self.args = args self.desc = desc @@ -41,7 +48,7 @@ def __init__(self, name, args, returns, desc=None) -> None: txn_count += 1 self.txn_calls = txn_count - def __eq__(self, o) -> bool: + def __eq__(self, o: object) -> bool: if not isinstance(o, Method): return False return ( @@ -52,12 +59,12 @@ def __eq__(self, o) -> bool: and self.txn_calls == o.txn_calls ) - def get_signature(self): + def get_signature(self) -> str: arg_string = ",".join(str(arg.type) for arg in self.args) - ret_string = self.returns.type if self.returns else "void" + ret_string = self.returns.type return "{}({}){}".format(self.name, arg_string, ret_string) - def get_selector(self): + def get_selector(self) -> bytes: """ Returns the ABI method signature, which is the first four bytes of the SHA-512/256 hash of the method signature. @@ -69,14 +76,14 @@ def get_selector(self): hash.update(self.get_signature().encode("utf-8")) return hash.digest()[:4] - def get_txn_calls(self): + def get_txn_calls(self) -> int: """ Returns the number of transactions needed to invoke this ABI method. """ return self.txn_calls @staticmethod - def _parse_string(s): + def _parse_string(s: str) -> list: # Parses a method signature into three tokens, returned as a list: # e.g. 'a(b,c)d' -> ['a', 'b,c', 'd'] stack = [] @@ -88,19 +95,19 @@ def _parse_string(s): break left_index = stack.pop() if not stack: - return (s[:left_index], s[left_index + 1 : i], s[i + 1 :]) + return [s[:left_index], s[left_index + 1 : i], s[i + 1 :]] raise error.ABIEncodingError( "ABI method string has mismatched parentheses: {}".format(s) ) @staticmethod - def from_json(resp): + def from_json(resp: Union[str, bytes, bytearray]) -> "Method": method_dict = json.loads(resp) return Method.undictify(method_dict) @staticmethod - def from_signature(s): + def from_signature(s: str) -> "Method": # Split string into tokens around outer parentheses. # The first token should always be the name of the method, # the second token should be the arguments as a tuple, @@ -112,23 +119,20 @@ def from_signature(s): return_type = Returns(tokens[-1]) return Method(name=tokens[0], args=argument_list, returns=return_type) - def dictify(self): + def dictify(self) -> dict: d = {} d["name"] = self.name d["args"] = [arg.dictify() for arg in self.args] - if self.returns: - d["returns"] = self.returns.dictify() + d["returns"] = self.returns.dictify() if self.desc: d["desc"] = self.desc return d @staticmethod - def undictify(d): + def undictify(d: dict) -> "Method": name = d["name"] arg_list = [Argument.undictify(arg) for arg in d["args"]] - return_obj = ( - Returns.undictify(d["returns"]) if "returns" in d else None - ) + return_obj = Returns.undictify(d["returns"]) desc = d["desc"] if "desc" in d else None return Method(name=name, args=arg_list, returns=return_obj, desc=desc) @@ -143,26 +147,28 @@ class Argument: desc (string, optional): description of this method argument """ - def __init__(self, arg_type, name=None, desc=None) -> None: + def __init__( + self, arg_type: str, name: str = None, desc: str = None + ) -> None: if arg_type in TRANSACTION_ARGS: self.type = arg_type else: # If the type cannot be parsed into an ABI type, it will error - self.type = abi.util.type_from_string(arg_type) + self.type = abi.ABIType.from_string(arg_type) self.name = name self.desc = desc - def __eq__(self, o) -> bool: + def __eq__(self, o: object) -> bool: if not isinstance(o, Argument): return False return ( self.name == o.name and self.type == o.type and self.desc == o.desc ) - def __str__(self): + def __str__(self) -> str: return str(self.type) - def dictify(self): + def dictify(self) -> dict: d = {} d["type"] = str(self.type) if self.name: @@ -172,7 +178,7 @@ def dictify(self): return d @staticmethod - def undictify(d): + def undictify(d: dict) -> "Argument": return Argument( arg_type=d["type"], name=d["name"] if "name" in d else None, @@ -192,23 +198,23 @@ class Returns: # Represents a void return. VOID = "void" - def __init__(self, arg_type, desc=None) -> None: + def __init__(self, arg_type: str, desc: str = None) -> None: if arg_type == "void": self.type = self.VOID else: # If the type cannot be parsed into an ABI type, it will error. - self.type = abi.util.type_from_string(arg_type) + self.type = abi.ABIType.from_string(arg_type) self.desc = desc - def __eq__(self, o) -> bool: + def __eq__(self, o: object) -> bool: if not isinstance(o, Returns): return False return self.type == o.type and self.desc == o.desc - def __str__(self): + def __str__(self) -> str: return str(self.type) - def dictify(self): + def dictify(self) -> dict: d = {} d["type"] = str(self.type) if self.desc: @@ -216,7 +222,7 @@ def dictify(self): return d @staticmethod - def undictify(d): + def undictify(d: dict) -> "Returns": return Returns( arg_type=d["type"], desc=d["desc"] if "desc" in d else None ) diff --git a/algosdk/abi/string_type.py b/algosdk/abi/string_type.py index b2c207df..c5182e6e 100644 --- a/algosdk/abi/string_type.py +++ b/algosdk/abi/string_type.py @@ -1,5 +1,7 @@ -from .base_type import ABI_LENGTH_SIZE, ABIType -from .. import error +from typing import NoReturn, Union + +from algosdk.abi.base_type import ABI_LENGTH_SIZE, ABIType +from algosdk import error class StringType(ABIType): @@ -10,23 +12,23 @@ class StringType(ABIType): def __init__(self) -> None: super().__init__() - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, StringType): return False return True - def __str__(self): + def __str__(self) -> str: return "string" - def byte_len(self): + def byte_len(self) -> NoReturn: raise error.ABITypeError( "cannot get length of a dynamic type: {}".format(self) ) - def is_dynamic(self): + def is_dynamic(self) -> bool: return True - def encode(self, string_val): + def encode(self, string_val: str) -> bytes: """ Encode a value into a String ABI bytestring. @@ -40,7 +42,7 @@ def encode(self, string_val): encoded = string_val.encode("utf-8") return length_to_encode + encoded - def decode(self, bytestring): + def decode(self, bytestring: Union[bytes, bytearray]) -> str: """ Decodes a bytestring to a string. diff --git a/algosdk/abi/tuple_type.py b/algosdk/abi/tuple_type.py index 7ec5095e..b384381e 100644 --- a/algosdk/abi/tuple_type.py +++ b/algosdk/abi/tuple_type.py @@ -1,6 +1,8 @@ -from .base_type import ABI_LENGTH_SIZE, ABIType -from .bool_type import BoolType -from .. import error +from typing import Any, List, Union + +from algosdk.abi.base_type import ABI_LENGTH_SIZE, ABIType +from algosdk.abi.bool_type import BoolType +from algosdk import error class TupleType(ABIType): @@ -8,13 +10,13 @@ class TupleType(ABIType): Represents a Tuple ABI Type for encoding. Args: - child_types (list): list of types in the tuple. + arg_types (list): list of types in the tuple. Attributes: child_types (list) """ - def __init__(self, arg_types) -> None: + def __init__(self, arg_types: List[Any]) -> None: if len(arg_types) >= 2 ** 16: raise error.ABITypeError( "tuple args cannot exceed a uint16: {}".format(len(arg_types)) @@ -22,15 +24,15 @@ def __init__(self, arg_types) -> None: super().__init__() self.child_types = arg_types - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, TupleType): return False return self.child_types == other.child_types - def __str__(self): + def __str__(self) -> str: return "({})".format(",".join(str(t) for t in self.child_types)) - def byte_len(self): + def byte_len(self) -> int: size = 0 i = 0 while i < len(self.child_types): @@ -47,11 +49,11 @@ def byte_len(self): i += 1 return size - def is_dynamic(self): + def is_dynamic(self) -> bool: return any(child.is_dynamic() for child in self.child_types) @staticmethod - def _find_bool(type_list, index, delta): + def _find_bool(type_list: List[ABIType], index: int, delta: int) -> int: """ Helper function to find consecutive booleans from current index in a tuple. """ @@ -71,7 +73,7 @@ def _find_bool(type_list, index, delta): return until @staticmethod - def _parse_tuple(s): + def _parse_tuple(s: str) -> list: """ Given a tuple string, parses one layer of the tuple and returns tokens as a list. i.e. 'x,(y,(z))' -> ['x', '(y,(z))'] @@ -112,7 +114,7 @@ def _parse_tuple(s): return tuple_strs @staticmethod - def _compress_multiple_bool(value_list): + def _compress_multiple_bool(value_list: List[bool]) -> int: """ Compress consecutive boolean values into a byte for a Tuple/Array. """ @@ -128,13 +130,15 @@ def _compress_multiple_bool(value_list): result |= 1 << (7 - i) return result - def encode(self, values): + def encode(self, values: Union[List[Any], bytes, bytearray]) -> bytes: """ Encodes a list of values into a TupleType ABI bytestring. Args: - values (list): list of values to be encoded. + values (list | bytes | bytearray): list of values to be encoded. The length of the list cannot exceed a uint16. + If the child types are ByteType, then bytes or bytearray can be + passed in to be encoded as well. Returns: bytes: encoded bytes of the tuple @@ -208,7 +212,7 @@ def encode(self, values): # Concatenate bytes return b"".join(heads) + b"".join(tails) - def decode(self, bytestring): + def decode(self, bytestring: Union[bytes, bytearray]) -> list: """ Decodes a bytestring to a tuple list. diff --git a/algosdk/abi/ufixed_type.py b/algosdk/abi/ufixed_type.py index d86cfe3c..9827cab1 100644 --- a/algosdk/abi/ufixed_type.py +++ b/algosdk/abi/ufixed_type.py @@ -1,5 +1,7 @@ -from .base_type import ABIType -from .. import error +from typing import Union + +from algosdk.abi.base_type import ABIType +from algosdk import error class UfixedType(ABIType): @@ -7,15 +9,15 @@ class UfixedType(ABIType): Represents an Ufixed ABI Type for encoding. Args: - bit_size (int, optional): size of a ufixed type. - precision (int, optional): number of precision for a ufixed type. + type_size (int): size of a ufixed type. + type_precision (int): number of precision for a ufixed type. Attributes: bit_size (int) precision (int) """ - def __init__(self, type_size, type_precision) -> None: + def __init__(self, type_size: int, type_precision: int) -> None: if ( not isinstance(type_size, int) or type_size % 8 != 0 @@ -37,7 +39,7 @@ def __init__(self, type_size, type_precision) -> None: self.bit_size = type_size self.precision = type_precision - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, UfixedType): return False return ( @@ -45,16 +47,16 @@ def __eq__(self, other) -> bool: and self.precision == other.precision ) - def __str__(self): + def __str__(self) -> str: return "ufixed{}x{}".format(self.bit_size, self.precision) - def byte_len(self): + def byte_len(self) -> int: return self.bit_size // 8 - def is_dynamic(self): + def is_dynamic(self) -> bool: return False - def encode(self, value): + def encode(self, value: int) -> bytes: """ Encodes a value into a Ufixed ABI type bytestring. The precision denotes the denominator and the value denotes the numerator. @@ -77,7 +79,7 @@ def encode(self, value): ) return value.to_bytes(self.bit_size // 8, byteorder="big") - def decode(self, bytestring): + def decode(self, bytestring: Union[bytes, bytearray]) -> int: """ Decodes a bytestring to a ufixed numerator. diff --git a/algosdk/abi/uint_type.py b/algosdk/abi/uint_type.py index b22c26ce..44dd2648 100644 --- a/algosdk/abi/uint_type.py +++ b/algosdk/abi/uint_type.py @@ -1,5 +1,7 @@ -from .base_type import ABIType -from .. import error +from typing import Union + +from algosdk.abi.base_type import ABIType +from algosdk import error class UintType(ABIType): @@ -7,13 +9,13 @@ class UintType(ABIType): Represents an Uint ABI Type for encoding. Args: - bit_size (int, optional): size of a uint type, e.g. for a uint8, the bit_size is 8. + bit_size (int): size of a uint type, e.g. for a uint8, the bit_size is 8. Attributes: bit_size (int) """ - def __init__(self, type_size) -> None: + def __init__(self, type_size: int) -> None: if ( not isinstance(type_size, int) or type_size % 8 != 0 @@ -26,21 +28,21 @@ def __init__(self, type_size) -> None: super().__init__() self.bit_size = type_size - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, UintType): return False return self.bit_size == other.bit_size - def __str__(self): + def __str__(self) -> str: return "uint{}".format(self.bit_size) - def byte_len(self): + def byte_len(self) -> int: return self.bit_size // 8 - def is_dynamic(self): + def is_dynamic(self) -> bool: return False - def encode(self, value): + def encode(self, value: int) -> bytes: """ Encodes a value into a Uint ABI type bytestring. @@ -62,7 +64,7 @@ def encode(self, value): ) return value.to_bytes(self.bit_size // 8, byteorder="big") - def decode(self, bytestring): + def decode(self, bytestring: Union[bytes, bytearray]) -> int: """ Decodes a bytestring to a uint. diff --git a/algosdk/abi/util.py b/algosdk/abi/util.py deleted file mode 100644 index 5deca770..00000000 --- a/algosdk/abi/util.py +++ /dev/null @@ -1,85 +0,0 @@ -import re - -from .uint_type import UintType -from .ufixed_type import UfixedType -from .byte_type import ByteType -from .bool_type import BoolType -from .address_type import AddressType -from .string_type import StringType -from .array_dynamic_type import ArrayDynamicType -from .array_static_type import ArrayStaticType -from .tuple_type import TupleType -from .. import error - - -# Globals -UFIXED_REGEX = r"^ufixed([1-9][\d]*)x([1-9][\d]*)$" -STATIC_ARRAY_REGEX = r"^([a-z\d\[\](),]+)\[([1-9][\d]*)]$" - - -def type_from_string(s): - """ - Convert a valid ABI string to a corresponding ABI type. - """ - if s.endswith("[]"): - array_arg_type = type_from_string(s[:-2]) - return ArrayDynamicType(array_arg_type) - elif s.endswith("]"): - matches = re.search(STATIC_ARRAY_REGEX, s) - try: - static_length = int(matches.group(2)) - array_type = type_from_string(matches.group(1)) - return ArrayStaticType(array_type, static_length) - except Exception as e: - raise error.ABITypeError( - "malformed static array string: {}".format(s) - ) from e - if s.startswith("uint"): - try: - if not s[4:].isdecimal(): - raise error.ABITypeError( - "uint string does not contain a valid size: {}".format(s) - ) - type_size = int(s[4:]) - return UintType(type_size) - except Exception as e: - raise error.ABITypeError( - "malformed uint string: {}".format(s) - ) from e - elif s == "byte": - return ByteType() - elif s.startswith("ufixed"): - matches = re.search(UFIXED_REGEX, s) - try: - bit_size = int(matches.group(1)) - precision = int(matches.group(2)) - return UfixedType(bit_size, precision) - except Exception as e: - raise error.ABITypeError( - "malformed ufixed string: {}".format(s) - ) from e - elif s == "bool": - return BoolType() - elif s == "address": - return AddressType() - elif s == "string": - return StringType() - elif len(s) >= 2 and s[0] == "(" and s[-1] == ")": - # Recursively parse parentheses from a tuple string - tuples = TupleType._parse_tuple(s[1:-1]) - tuple_list = [] - for tup in tuples: - if isinstance(tup, str): - tt = type_from_string(tup) - tuple_list.append(tt) - elif isinstance(tup, list): - tts = [type_from_string(t_) for t_ in tup] - tuple_list.append(tts) - else: - raise error.ABITypeError( - "cannot convert {} to an ABI type".format(tup) - ) - - return TupleType(tuple_list) - else: - raise error.ABITypeError("cannot convert {} to an ABI type".format(s)) diff --git a/algosdk/atomic_transaction_composer.py b/algosdk/atomic_transaction_composer.py index 156dee20..9496231a 100644 --- a/algosdk/atomic_transaction_composer.py +++ b/algosdk/atomic_transaction_composer.py @@ -2,9 +2,12 @@ import base64 import copy from enum import IntEnum +from typing import Any, List, Union from algosdk import abi, error +from algosdk.abi.base_type import ABIType from algosdk.future import transaction +from algosdk.v2client import algod # The first four bytes of an ABI method call return must have this hash ABI_RETURN_HASH = b"\x15\x1f\x7c\x75" @@ -56,19 +59,19 @@ def __init__(self) -> None: self.signed_txns = [] self.tx_ids = [] - def get_status(self): + def get_status(self) -> AtomicTransactionComposerStatus: """ Returns the status of this composer's transaction group. """ return self.status - def get_tx_count(self): + def get_tx_count(self) -> int: """ Returns the number of transactions currently in this atomic group. """ return len(self.txn_list) - def clone(self): + def clone(self) -> "AtomicTransactionComposer": """ Creates a new composer with the same underlying transactions. The new composer's status will be BUILDING, so additional transactions @@ -82,7 +85,9 @@ def clone(self): cloned.status = AtomicTransactionComposerStatus.BUILDING return cloned - def add_transaction(self, txn_and_signer): + def add_transaction( + self, txn_and_signer: "TransactionWithSigner" + ) -> "AtomicTransactionComposer": """ Adds a transaction to this atomic group. @@ -116,17 +121,17 @@ def add_transaction(self, txn_and_signer): def add_method_call( self, - app_id, - method, - sender, - sp, - signer, - method_args=None, - on_complete=transaction.OnComplete.NoOpOC, - note=None, - lease=None, - rekey_to=None, - ): + app_id: int, + method: abi.method.Method, + sender: str, + sp: transaction.SuggestedParams, + signer: "TransactionSigner", + method_args: List[Union[Any]] = None, + on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, + note: bytes = None, + lease: bytes = None, + rekey_to: str = None, + ) -> "AtomicTransactionComposer": """ Add a smart contract method call to this atomic group. @@ -229,7 +234,7 @@ def add_method_call( self.method_dict[len(self.txn_list) - 1] = method return self - def build_group(self): + def build_group(self) -> list: """ Finalize the transaction group and returns the finalized transactions with signers. The composer's status will be at least BUILT after executing this method. @@ -254,7 +259,7 @@ def build_group(self): self.status = AtomicTransactionComposerStatus.BUILT return self.txn_list - def gather_signatures(self): + def gather_signatures(self) -> list: """ Obtain signatures for each transaction in this group. If signatures have already been obtained, this method will return cached versions of the signatures. @@ -293,7 +298,7 @@ def gather_signatures(self): self.signed_txns = stxn_list return self.signed_txns - def submit(self, client): + def submit(self, client: algod.AlgodClient) -> list: """ 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. @@ -319,7 +324,9 @@ def submit(self, client): self.status = AtomicTransactionComposerStatus.SUBMITTED return self.tx_ids - def execute(self, client, wait_rounds): + def execute( + self, client: algod.AlgodClient, wait_rounds: int + ) -> "AtomicTransactionResponse": """ 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. @@ -419,7 +426,9 @@ def __init__(self) -> None: pass @abstractmethod - def sign_transactions(self, txn_group, indexes): + def sign_transactions( + self, txn_group: List[transaction.Transaction], indexes: List[int] + ) -> list: pass @@ -432,11 +441,13 @@ class AccountTransactionSigner(TransactionSigner): private_key (str): private key of signing account """ - def __init__(self, private_key) -> None: + def __init__(self, private_key: str) -> None: super().__init__() self.private_key = private_key - def sign_transactions(self, txn_group, indexes): + def sign_transactions( + self, txn_group: List[transaction.Transaction], indexes: List[int] + ) -> list: """ Sign transactions in a transaction group given the indexes. @@ -464,11 +475,13 @@ class LogicSigTransactionSigner(TransactionSigner): lsig (LogicSigAccount): LogicSig account """ - def __init__(self, lsig) -> None: + def __init__(self, lsig: transaction.LogicSigAccount) -> None: super().__init__() self.lsig = lsig - def sign_transactions(self, txn_group, indexes): + def sign_transactions( + self, txn_group: List[transaction.Transaction], indexes: List[int] + ) -> list: """ Sign transactions in a transaction group given the indexes. @@ -497,12 +510,14 @@ class MultisigTransactionSigner(TransactionSigner): sks (str): private keys of multisig """ - def __init__(self, msig, sks) -> None: + def __init__(self, msig: transaction.Multisig, sks: str) -> None: super().__init__() self.msig = msig self.sks = sks - def sign_transactions(self, txn_group, indexes): + def sign_transactions( + self, txn_group: List[transaction.Transaction], indexes: List[int] + ) -> list: """ Sign transactions in a transaction group given the indexes. @@ -524,13 +539,21 @@ def sign_transactions(self, txn_group, indexes): class TransactionWithSigner: - def __init__(self, txn, signer) -> None: + def __init__( + self, txn: transaction.Transaction, signer: TransactionSigner + ) -> None: self.txn = txn self.signer = signer class ABIResult: - def __init__(self, tx_id, raw_value, return_value, decode_error) -> None: + def __init__( + self, + tx_id: int, + raw_value: bytes, + return_value: Any, + decode_error: error, + ) -> None: self.tx_id = tx_id self.raw_value = raw_value self.return_value = return_value @@ -538,7 +561,9 @@ def __init__(self, tx_id, raw_value, return_value, decode_error) -> None: class AtomicTransactionResponse: - def __init__(self, confirmed_round, tx_ids, results) -> None: + def __init__( + self, confirmed_round: int, tx_ids: List[str], results: ABIResult + ) -> None: self.confirmed_round = confirmed_round self.tx_ids = tx_ids self.abi_results = results diff --git a/test_unit.py b/test_unit.py index 24596f45..f0fe5ed5 100644 --- a/test_unit.py +++ b/test_unit.py @@ -20,7 +20,7 @@ wordlist, ) from algosdk.abi import ( - type_from_string, + ABIType, UintType, UfixedType, BoolType, @@ -3622,7 +3622,7 @@ def test_make_type_valid(self): for uint_index in range(8, 513, 8): uint_type = UintType(uint_index) self.assertEqual(str(uint_type), f"uint{uint_index}") - actual = type_from_string(str(uint_type)) + actual = ABIType.from_string(str(uint_type)) self.assertEqual(uint_type, actual) # Test for ufixed @@ -3632,7 +3632,7 @@ def test_make_type_valid(self): self.assertEqual( str(ufixed_type), f"ufixed{size_index}x{precision_index}" ) - actual = type_from_string(str(ufixed_type)) + actual = ABIType.from_string(str(ufixed_type)) self.assertEqual(ufixed_type, actual) test_cases = [ @@ -3725,7 +3725,7 @@ def test_make_type_valid(self): ] for test_case in test_cases: self.assertEqual(str(test_case[0]), test_case[1]) - self.assertEqual(test_case[0], type_from_string(test_case[1])) + self.assertEqual(test_case[0], ABIType.from_string(test_case[1])) def test_make_type_invalid(self): # Test for invalid uint @@ -3789,7 +3789,7 @@ def test_type_from_string_invalid(self): ) for test_case in test_cases: with self.assertRaises(error.ABITypeError) as e: - type_from_string(test_case) + ABIType.from_string(test_case) def test_is_dynamic(self): test_cases = [ @@ -3804,16 +3804,16 @@ def test_is_dynamic(self): True, ), # Test tuple child types - (type_from_string("(string[100])"), True), - (type_from_string("(address,bool,uint256)"), False), - (type_from_string("(uint8,(byte[10]))"), False), - (type_from_string("(string,uint256)"), True), + (ABIType.from_string("(string[100])"), True), + (ABIType.from_string("(address,bool,uint256)"), False), + (ABIType.from_string("(uint8,(byte[10]))"), False), + (ABIType.from_string("(string,uint256)"), True), ( - type_from_string("(bool,(ufixed16x10[],(byte,address)))"), + ABIType.from_string("(bool,(ufixed16x10[],(byte,address)))"), True, ), ( - type_from_string("(bool,(uint256,(byte,address,string)))"), + ABIType.from_string("(bool,(uint256,(byte,address,string)))"), True, ), ] @@ -3827,23 +3827,23 @@ def test_byte_len(self): (BoolType(), 1), (UintType(64), 8), (UfixedType(256, 50), 32), - (type_from_string("bool[81]"), 11), - (type_from_string("bool[80]"), 10), - (type_from_string("bool[88]"), 11), - (type_from_string("address[5]"), 160), - (type_from_string("uint16[20]"), 40), - (type_from_string("ufixed64x20[10]"), 80), - (type_from_string(f"(address,byte,ufixed16x20)"), 35), + (ABIType.from_string("bool[81]"), 11), + (ABIType.from_string("bool[80]"), 10), + (ABIType.from_string("bool[88]"), 11), + (ABIType.from_string("address[5]"), 160), + (ABIType.from_string("uint16[20]"), 40), + (ABIType.from_string("ufixed64x20[10]"), 80), + (ABIType.from_string(f"(address,byte,ufixed16x20)"), 35), ( - type_from_string( + ABIType.from_string( f"((bool,address[10]),(bool,bool,bool),uint8[20])" ), 342, ), - (type_from_string(f"(bool,bool)"), 1), - (type_from_string(f"({'bool,'*6}uint8)"), 2), + (ABIType.from_string(f"(bool,bool)"), 1), + (ABIType.from_string(f"({'bool,'*6}uint8)"), 2), ( - type_from_string(f"({'bool,'*10}uint8,{'bool,'*10}byte)"), + ABIType.from_string(f"({'bool,'*10}uint8,{'bool,'*10}byte)"), 6, ), ] @@ -4139,32 +4139,32 @@ def test_array_dynamic_encoding(self): def test_tuple_encoding(self): test_cases = [ ( - type_from_string("()"), + ABIType.from_string("()"), [], b"", ), ( - type_from_string("(bool[3])"), + ABIType.from_string("(bool[3])"), [[True, True, False]], bytes([0b11000000]), ), ( - type_from_string("(bool[])"), + ABIType.from_string("(bool[])"), [[True, True, False]], bytes.fromhex("00 02 00 03 C0"), ), ( - type_from_string("(bool[2],bool[])"), + ABIType.from_string("(bool[2],bool[])"), [[True, True], [True, True]], bytes.fromhex("C0 00 03 00 02 C0"), ), ( - type_from_string("(bool[],bool[])"), + ABIType.from_string("(bool[],bool[])"), [[], []], bytes.fromhex("00 04 00 06 00 00 00 00"), ), ( - type_from_string("(string,bool,bool,bool,bool,string)"), + ABIType.from_string("(string,bool,bool,bool,bool,string)"), ["AB", True, False, True, False, "DE"], bytes.fromhex("00 05 A0 00 09 00 02 41 42 00 02 44 45"), ), @@ -4190,7 +4190,7 @@ def test_method(self): self.assertEqual(m.get_selector(), b"\x8a\xa3\xb6\x1f") self.assertEqual( [(a.type) for a in m.args], - [type_from_string("uint64"), type_from_string("uint64")], + [ABIType.from_string("uint64"), ABIType.from_string("uint64")], ) self.assertEqual(m.get_txn_calls(), 1) @@ -4199,16 +4199,16 @@ def test_method(self): ( "add(uint64,uint64)uint128", b"\x8a\xa3\xb6\x1f", - [type_from_string("uint64"), type_from_string("uint64")], - type_from_string("uint128"), + [ABIType.from_string("uint64"), ABIType.from_string("uint64")], + ABIType.from_string("uint128"), 1, ), ( "tupler((string,uint16),bool)void", b"\x3d\x98\xe4\x5d", [ - type_from_string("(string,uint16)"), - type_from_string("bool"), + ABIType.from_string("(string,uint16)"), + ABIType.from_string("bool"), ], "void", 1, @@ -4216,15 +4216,15 @@ def test_method(self): ( "txcalls(pay,pay,axfer,byte)bool", b"\x05\x6d\x2e\xc0", - ["pay", "pay", "axfer", type_from_string("byte")], - type_from_string("bool"), + ["pay", "pay", "axfer", ABIType.from_string("byte")], + ABIType.from_string("bool"), 4, ), ( "getter()string", b"\xa2\x59\x11\x1d", [], - type_from_string("string"), + ABIType.from_string("string"), 1, ), ] @@ -4244,7 +4244,7 @@ def test_method(self): self.assertEqual(m.get_txn_calls(), test_case[4]) def test_interface(self): - test_json = '{"name": "Calculator","methods": [{ "name": "add", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' + test_json = '{"name": "Calculator","methods": [{ "name": "add", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' i = Interface.from_json(test_json) self.assertEqual(i.name, "Calculator") self.assertEqual( @@ -4253,7 +4253,7 @@ def test_interface(self): ) def test_contract(self): - test_json = '{"name": "Calculator","appId": 3, "methods": [{ "name": "add", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' + test_json = '{"name": "Calculator","appId": 3, "methods": [{ "name": "add", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] },{ "name": "multiply", "returns": {"type": "void"}, "args": [ { "name": "a", "type": "uint64", "desc": "..." },{ "name": "b", "type": "uint64", "desc": "..." } ] }]}' c = Contract.from_json(test_json) self.assertEqual(c.name, "Calculator") self.assertEqual(c.app_id, 3) From a168f90a68751442feae6727007d4f6b8c196587 Mon Sep 17 00:00:00 2001 From: John Lee Date: Thu, 25 Nov 2021 08:32:58 -0500 Subject: [PATCH 5/6] Bumped version to v1.9.0 --- CHANGELOG.md | 8 ++++++++ setup.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37f3d69f..3daa57be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.9.0 +### Added + +- ABI Interaction Support for Python SDK (#247) +- ABI Type encoding support (#238) +- Add type hints and clean up ABI code (#253) +- Add CircleCI configs to the Python SDK repo (#246) + ## 1.8.0 ### Added diff --git a/setup.py b/setup.py index c4f95f61..e0fe37c2 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ description="Algorand SDK in Python", author="Algorand", author_email="pypiservice@algorand.com", - version="1.8.0", + version="1.9.0", long_description=long_description, long_description_content_type="text/markdown", license="MIT", From f0f7cc1ff6025dae138469fdbd7b4cc56f1dd97d Mon Sep 17 00:00:00 2001 From: John Lee Date: Fri, 26 Nov 2021 08:31:11 -0500 Subject: [PATCH 6/6] Change version to 1.9.0b1 --- CHANGELOG.md | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3daa57be..a02385cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.9.0 +## 1.9.0b1 ### Added - ABI Interaction Support for Python SDK (#247) diff --git a/setup.py b/setup.py index e0fe37c2..b39dc011 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ description="Algorand SDK in Python", author="Algorand", author_email="pypiservice@algorand.com", - version="1.9.0", + version="1.9.0b1", long_description=long_description, long_description_content_type="text/markdown", license="MIT",