diff --git a/algosdk/atomic_transaction_composer.py b/algosdk/atomic_transaction_composer.py index 8f55d685..20455b46 100644 --- a/algosdk/atomic_transaction_composer.py +++ b/algosdk/atomic_transaction_composer.py @@ -1,8 +1,8 @@ -from abc import ABC, abstractmethod import base64 import copy +from abc import ABC, abstractmethod from enum import IntEnum -from typing import Any, List, Optional, TypeVar, Union +from typing import Any, List, Optional, Tuple, TypeVar, Union from algosdk import abi, error from algosdk.abi.address_type import AddressType @@ -173,6 +173,7 @@ def add_method_call( note: bytes = None, lease: bytes = None, rekey_to: str = None, + boxes: List[Tuple[int, bytes]] = None, ) -> "AtomicTransactionComposer": """ Add a smart contract method call to this atomic group. @@ -210,6 +211,7 @@ def add_method_call( 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 + boxes (list[(int, bytes)], optional): list of tuples specifying app id and key for boxes the app may access """ if self.status != AtomicTransactionComposerStatus.BUILDING: @@ -259,6 +261,7 @@ def add_method_call( accounts = accounts[:] if accounts else [] foreign_apps = foreign_apps[:] if foreign_apps else [] foreign_assets = foreign_assets[:] if foreign_assets else [] + boxes = boxes[:] if boxes else [] app_args = [] raw_values = [] @@ -350,6 +353,7 @@ def add_method_call( lease=lease, rekey_to=rekey_to, extra_pages=extra_pages, + boxes=boxes, ) txn_with_signer = TransactionWithSigner(method_txn, signer) txn_list.append(txn_with_signer) diff --git a/algosdk/box_reference.py b/algosdk/box_reference.py new file mode 100644 index 00000000..eeb1fab4 --- /dev/null +++ b/algosdk/box_reference.py @@ -0,0 +1,94 @@ +from collections import OrderedDict +from typing import List, Tuple, Union + +from algosdk import encoding, error + + +class BoxReference: + """ + Represents a box reference with a foreign app index and the box name. + + Args: + app_index (int): index of the application in the foreign app array + name (bytes): key for the box in bytes + """ + + def __init__(self, app_index: int, name: bytes): + if app_index < 0: + raise ValueError( + f"Box app index must be a non-negative integer: {app_index}" + ) + self.app_index = app_index + self.name = name + + @staticmethod + def translate_box_reference( + ref: Tuple[int, Union[bytes, bytearray, str, int]], + foreign_apps: List[int], + this_app_id: int, + ) -> "BoxReference": + # Try checking reference id and name type. + ref_id, ref_name = ref[0], encoding.encode_as_bytes(ref[1]) + if not isinstance(ref_id, int): + raise TypeError("Box reference ID must be an int") + + index = 0 + try: + # Foreign apps start from index 1; index 0 is its own app ID. + index = foreign_apps.index(ref_id) + 1 + except (ValueError, AttributeError): + # Check if the app referenced is itself after checking the + # foreign apps array (in case its own app id is in its own + # foreign apps array). + if ref_id != 0 and ref_id != this_app_id: + raise error.InvalidForeignIndexError( + f"Box ref with appId {ref_id} not in foreign-apps" + ) + return BoxReference(index, ref_name) + + @staticmethod + def translate_box_references( + references: List[Tuple[int, Union[bytes, bytearray, str, int]]], + foreign_apps: List[int], + this_app_id: int, + ) -> List["BoxReference"]: + """ + Translates a list of tuples with app IDs and names into an array of + BoxReferences with foreign indices. + + Args: + references (list[(int, bytes)]): list of tuples specifying app id + and key for boxes the app may access + foreign_apps (list[int]): list of other applications in appl call + this_app_id (int): app ID of the box being references + """ + if not references: + return [] + + return [ + BoxReference.translate_box_reference( + ref, foreign_apps, this_app_id + ) + for ref in references + ] + + def dictify(self): + d = dict() + if self.app_index: + d["i"] = self.app_index + if self.name: + d["n"] = self.name + od = OrderedDict(sorted(d.items())) + return od + + @staticmethod + def undictify(d): + return BoxReference( + d["i"] if "i" in d else None, + d["n"] if "n" in d else None, + ) + + def __eq__(self, other): + if not isinstance(other, BoxReference): + return False + return self.app_index == other.app_index and self.name == other.name diff --git a/algosdk/encoding.py b/algosdk/encoding.py index 2edbb9a5..38f81f27 100644 --- a/algosdk/encoding.py +++ b/algosdk/encoding.py @@ -1,8 +1,11 @@ import base64 -import msgpack from collections import OrderedDict +from typing import Union + +import msgpack from Cryptodome.Hash import SHA512 -from . import transaction, error, auction, constants, future + +from algosdk import auction, constants, error, future, transaction def msgpack_encode(obj): @@ -235,3 +238,17 @@ def checksum(data): chksum = SHA512.new(truncate="256") chksum.update(data) return chksum.digest() + + +def encode_as_bytes( + e: Union[bytes, bytearray, str, int] +) -> Union[bytes, bytearray]: + """Confirm or coerce element to bytes.""" + if isinstance(e, (bytes, bytearray)): + return e + if isinstance(e, str): + return e.encode() + if isinstance(e, int): + # Uses 8 bytes, big endian to match TEAL's btoi + return e.to_bytes(8, "big") # raises for negative or too big + raise TypeError("{} is not bytes, bytearray, str, or int".format(e)) diff --git a/algosdk/error.py b/algosdk/error.py index 31583164..8a54ed69 100644 --- a/algosdk/error.py +++ b/algosdk/error.py @@ -222,3 +222,8 @@ def __init__(self, msg): class AtomicTransactionComposerError(Exception): def __init__(self, msg): super().__init__(msg) + + +class InvalidForeignIndexError(Exception): + def __init__(self, msg): + super().__init__(msg) diff --git a/algosdk/future/transaction.py b/algosdk/future/transaction.py index bb17809f..c73bbf6c 100644 --- a/algosdk/future/transaction.py +++ b/algosdk/future/transaction.py @@ -1,17 +1,14 @@ -from typing import List, Union import base64 +from collections import OrderedDict from enum import IntEnum +from typing import List, Union + import msgpack -from collections import OrderedDict -from .. import account -from .. import constants -from .. import encoding -from .. import error -from .. import logic -from .. import transaction -from ..v2client import algod, models -from nacl.signing import SigningKey, VerifyKey +from algosdk import account, constants, encoding, error, logic, transaction +from algosdk.box_reference import BoxReference +from algosdk.v2client import algod, models from nacl.exceptions import BadSignatureError +from nacl.signing import SigningKey, VerifyKey class SuggestedParams: @@ -1571,6 +1568,7 @@ class ApplicationCallTxn(Transaction): foreign_apps (list[int], optional): list of other applications (identified by index) involved in call foreign_assets (list[int], optional): list of assets involved in call extra_pages (int, optional): additional program space for supporting larger programs. A page is 1024 bytes. + boxes(list[(int, bytes)], optional): list of tuples specifying app id and key for boxes the app may access Attributes: sender (str) @@ -1589,6 +1587,7 @@ class ApplicationCallTxn(Transaction): foreign_apps (list[int]) foreign_assets (list[int]) extra_pages (int) + boxes (list[(int, bytes)]) """ def __init__( @@ -1609,6 +1608,7 @@ def __init__( lease=None, rekey_to=None, extra_pages=0, + boxes=None, ): Transaction.__init__( self, sender, sp, note, lease, constants.appcall_txn, rekey_to @@ -1624,6 +1624,7 @@ def __init__( self.foreign_apps = self.int_list(foreign_apps) self.foreign_assets = self.int_list(foreign_assets) self.extra_pages = extra_pages + self.boxes = BoxReference.translate_box_references(boxes, self.foreign_apps, self.index) # type: ignore if not sp.flat_fee: self.fee = max( self.estimate_size() * self.fee, constants.min_txn_fee @@ -1634,9 +1635,8 @@ def state_schema(schema): """Confirm the argument is a StateSchema, or false which is coerced to None""" if not schema or not schema.dictify(): return None # Coerce false/empty values to None, to help __eq__ - assert isinstance( - schema, StateSchema - ), "{} is not a StateSchema".format(schema) + if not isinstance(schema, StateSchema): + raise TypeError("{} is not a StateSchema".format(schema)) return schema @staticmethod @@ -1644,28 +1644,16 @@ def teal_bytes(teal): """Confirm the argument is bytes-like, or false which is coerced to None""" if not teal: return None # Coerce false values like "" to None, to help __eq__ - assert isinstance( - teal, (bytes, bytearray) - ), "Program {} is not bytes".format(teal) + if not isinstance(teal, (bytes, bytearray)): + raise TypeError("Program {} is not bytes".format(teal)) return teal @staticmethod def bytes_list(lst): """Confirm or coerce list elements to bytes. Return None for empty/false lst.""" - - def as_bytes(e): - if isinstance(e, (bytes, bytearray)): - return e - if isinstance(e, str): - return e.encode() - if isinstance(e, int): - # Uses 8 bytes, big endian to match TEAL's btoi - return e.to_bytes(8, "big") # raises for negative or too big - assert False, "{} is not bytes, str, or int".format(e) - if not lst: return None - return [as_bytes(elt) for elt in lst] + return [encoding.encode_as_bytes(elt) for elt in lst] @staticmethod def int_list(lst): @@ -1700,6 +1688,8 @@ def dictify(self): d["apas"] = self.foreign_assets if self.extra_pages: d["apep"] = self.extra_pages + if self.boxes: + d["apbx"] = [box.dictify() for box in self.boxes] d.update(super(ApplicationCallTxn, self).dictify()) od = OrderedDict(sorted(d.items())) @@ -1724,6 +1714,9 @@ def _undictify(d): "foreign_apps": d["apfa"] if "apfa" in d else None, "foreign_assets": d["apas"] if "apas" in d else None, "extra_pages": d["apep"] if "apep" in d else 0, + "boxes": [BoxReference.undictify(box) for box in d["apbx"]] + if "apbx" in d + else None, } if args["accounts"]: args["accounts"] = [ @@ -1748,6 +1741,7 @@ def __eq__(self, other): and self.foreign_apps == other.foreign_apps and self.foreign_assets == other.foreign_assets and self.extra_pages == other.extra_pages + and self.boxes == other.boxes ) @@ -1771,6 +1765,7 @@ class ApplicationCreateTxn(ApplicationCallTxn): lease(bytes, optional): transaction lease field rekey_to(str, optional): rekey-to field, see Transaction extra_pages(int, optional): provides extra program size + boxes(list[(int, bytes)], optional): list of tuples specifying app id and key for boxes the app may access Attributes: See ApplicationCallTxn @@ -1793,6 +1788,7 @@ def __init__( lease=None, rekey_to=None, extra_pages=0, + boxes=None, ): ApplicationCallTxn.__init__( self, @@ -1812,6 +1808,7 @@ def __init__( lease=lease, rekey_to=rekey_to, extra_pages=extra_pages, + boxes=boxes, ) @@ -1832,6 +1829,8 @@ class ApplicationUpdateTxn(ApplicationCallTxn): note(bytes, optional): transaction note field lease(bytes, optional): transaction lease field rekey_to(str, optional): rekey-to field, see Transaction + boxes(list[(int, bytes)], optional): list of tuples specifying app id and key for boxes the app may access + Attributes: See ApplicationCallTxn @@ -1851,6 +1850,7 @@ def __init__( note=None, lease=None, rekey_to=None, + boxes=None, ): ApplicationCallTxn.__init__( self, @@ -1867,6 +1867,7 @@ def __init__( note=note, lease=lease, rekey_to=rekey_to, + boxes=boxes, ) @@ -1885,6 +1886,7 @@ class ApplicationDeleteTxn(ApplicationCallTxn): note(bytes, optional): transaction note field lease(bytes, optional): transaction lease field rekey_to(str, optional): rekey-to field, see Transaction + boxes(list[(int, bytes)], optional): list of tuples specifying app id and key for boxes the app may access Attributes: See ApplicationCallTxn @@ -1902,6 +1904,7 @@ def __init__( note=None, lease=None, rekey_to=None, + boxes=None, ): ApplicationCallTxn.__init__( self, @@ -1916,6 +1919,7 @@ def __init__( note=note, lease=lease, rekey_to=rekey_to, + boxes=boxes, ) @@ -1934,6 +1938,7 @@ class ApplicationOptInTxn(ApplicationCallTxn): note(bytes, optional): transaction note field lease(bytes, optional): transaction lease field rekey_to(str, optional): rekey-to field, see Transaction + boxes(list[(int, bytes)], optional): list of tuples specifying app id and key for boxes the app may access Attributes: See ApplicationCallTxn @@ -1951,6 +1956,7 @@ def __init__( note=None, lease=None, rekey_to=None, + boxes=None, ): ApplicationCallTxn.__init__( self, @@ -1965,6 +1971,7 @@ def __init__( note=note, lease=lease, rekey_to=rekey_to, + boxes=boxes, ) @@ -1983,6 +1990,7 @@ class ApplicationCloseOutTxn(ApplicationCallTxn): note(bytes, optional): transaction note field lease(bytes, optional): transaction lease field rekey_to(str, optional): rekey-to field, see Transaction + boxes(list[(int, bytes)], optional): list of tuples specifying app id and key for boxes the app may access Attributes: See ApplicationCallTxn @@ -2000,6 +2008,7 @@ def __init__( note=None, lease=None, rekey_to=None, + boxes=None, ): ApplicationCallTxn.__init__( self, @@ -2014,6 +2023,7 @@ def __init__( note=note, lease=lease, rekey_to=rekey_to, + boxes=boxes, ) @@ -2032,6 +2042,7 @@ class ApplicationClearStateTxn(ApplicationCallTxn): note(bytes, optional): transaction note field lease(bytes, optional): transaction lease field rekey_to(str, optional): rekey-to field, see Transaction + boxes(list[(int, bytes)], optional): list of tuples specifying app id and key for boxes the app may access Attributes: See ApplicationCallTxn @@ -2049,6 +2060,7 @@ def __init__( note=None, lease=None, rekey_to=None, + boxes=None, ): ApplicationCallTxn.__init__( self, @@ -2063,6 +2075,7 @@ def __init__( note=note, lease=lease, rekey_to=rekey_to, + boxes=boxes, ) @@ -2082,6 +2095,7 @@ class ApplicationNoOpTxn(ApplicationCallTxn): note(bytes, optional): transaction note field lease(bytes, optional): transaction lease field rekey_to(str, optional): rekey-to field, see Transaction + boxes(list[(int, bytes)], optional): list of tuples specifying app id and key for boxes the app may access Attributes: See ApplicationCallTxn @@ -2099,6 +2113,7 @@ def __init__( note=None, lease=None, rekey_to=None, + boxes=None, ): ApplicationCallTxn.__init__( self, @@ -2113,6 +2128,7 @@ def __init__( note=note, lease=lease, rekey_to=rekey_to, + boxes=boxes, ) diff --git a/algosdk/v2client/algod.py b/algosdk/v2client/algod.py index 74d96782..7843504b 100644 --- a/algosdk/v2client/algod.py +++ b/algosdk/v2client/algod.py @@ -1,10 +1,10 @@ import base64 import json -from urllib import parse import urllib.error +from urllib import parse from urllib.request import Request, urlopen -from .. import constants, encoding, error, future, logic, util +from algosdk import constants, encoding, error, future, util api_version_path_prefix = "/v2" diff --git a/run_integration.sh b/run_integration.sh index 76b293e8..2cb16af3 100755 --- a/run_integration.sh +++ b/run_integration.sh @@ -7,7 +7,8 @@ pushd $rootdir # Reset test harness rm -rf test-harness -git clone --single-branch --branch master https://github.com/algorand/algorand-sdk-testing.git test-harness +# TODO: Before merging, change branch back to master +git clone --single-branch --branch feature/box-storage https://github.com/algorand/algorand-sdk-testing.git test-harness ## Copy feature files into the project resources mkdir -p test/features diff --git a/test/steps/application_v2_steps.py b/test/steps/application_v2_steps.py index 5a960e8d..9464868d 100644 --- a/test/steps/application_v2_steps.py +++ b/test/steps/application_v2_steps.py @@ -36,24 +36,47 @@ def operation_string_to_enum(operation): ) +# Takes in a tuple where first element is the encoding and second element is value. +# If there is only one element, then it is assumed to be an int. +def process_app_args(sub_arg): + if len(sub_arg) == 1: # assume int + return int(sub_arg[0]) + elif sub_arg[0] == "str": + return bytes(sub_arg[1], "ascii") + elif sub_arg[0] == "b64": + return base64.decodebytes(sub_arg[1].encode()) + elif sub_arg[0] == "int": + return int(sub_arg[1]) + elif sub_arg[0] == "addr": + return encoding.decode_address(sub_arg[1]) + + def split_and_process_app_args(in_args): split_args = in_args.split(",") sub_args = [sub_arg.split(":") for sub_arg in split_args] app_args = [] for sub_arg in sub_args: - if len(sub_arg) == 1: # assume int - app_args.append(int(sub_arg[0])) - elif sub_arg[0] == "str": - app_args.append(bytes(sub_arg[1], "ascii")) - elif sub_arg[0] == "b64": - app_args.append(base64.decodebytes(sub_arg[1].encode())) - elif sub_arg[0] == "int": - app_args.append(int(sub_arg[1])) - elif sub_arg[0] == "addr": - app_args.append(encoding.decode_address(sub_arg[1])) + app_args.append(process_app_args(sub_arg)) return app_args +def split_and_process_boxes(box_str: str): + boxes = [] + app_id = 0 + split_args = box_str.split(",") + # Box strings alternate between the app ID and the encoded app arg. + for token in split_args: + try: + app_id = int(token) + except ValueError: + sub_arg = token.split(":") + sub_arg = process_app_args(sub_arg) + boxes.append((app_id, sub_arg)) + # Sanity check that input correctly alternates between int and str. + assert len(boxes) == len(split_args) // 2 + return boxes + + def composer_status_string_to_enum(status): if status == "BUILDING": return ( @@ -181,7 +204,7 @@ def lookup_applications(context, application_id, round): @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}' + '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}, boxes "{boxes:MaybeString}"' ) def build_app_transaction( context, @@ -203,6 +226,7 @@ def build_app_transaction( last_valid, genesis_hash, extra_pages, + boxes, ): if operation == "none": operation = None @@ -236,6 +260,10 @@ def build_app_transaction( app_accounts = [ account_pubkey for account_pubkey in app_accounts.split(",") ] + if boxes == "none": + boxes = None + elif boxes: + boxes = split_and_process_boxes(boxes) if genesis_hash == "none": genesis_hash = None local_schema = transaction.StateSchema( @@ -268,11 +296,12 @@ def build_app_transaction( note=None, lease=None, rekey_to=None, + boxes=boxes, ) @step( - 'I build an application transaction with the transient account, the current application, suggested params, operation "{operation}", 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}", extra-pages {extra_pages}' + 'I build an application transaction with the transient account, the current application, suggested params, operation "{operation}", 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}", extra-pages {extra_pages}, boxes "{boxes:MaybeString}"' ) def build_app_txn_with_transient( context, @@ -288,6 +317,7 @@ def build_app_txn_with_transient( foreign_assets, app_accounts, extra_pages, + boxes, ): application_id = 0 if operation == "none": @@ -332,6 +362,10 @@ def build_app_txn_with_transient( app_accounts = [ account_pubkey for account_pubkey in app_accounts.split(",") ] + if boxes == "none": + boxes = None + elif boxes: + boxes = split_and_process_boxes(boxes) sp = context.app_acl.suggested_params() context.app_transaction = transaction.ApplicationCallTxn( @@ -351,6 +385,7 @@ def build_app_txn_with_transient( note=None, lease=None, rekey_to=None, + boxes=None, ) diff --git a/test/steps/other_v2_steps.py b/test/steps/other_v2_steps.py index b2673798..3d6e52ed 100644 --- a/test/steps/other_v2_steps.py +++ b/test/steps/other_v2_steps.py @@ -14,7 +14,6 @@ register_type, step, ) # pylint: disable=no-name-in-module - from glom import glom import parse @@ -33,10 +32,10 @@ Account, ApplicationLocalState, ) + from algosdk.testing.dryrun import DryrunTestCaseMixin -from test.steps.steps import token as daemon_token -from test.steps.steps import algod_port +from test.steps.steps import algod_port, token as daemon_token @parse.with_pattern(r".*") diff --git a/test_unit.py b/test_unit.py index 3ffb7969..dc21ec93 100644 --- a/test_unit.py +++ b/test_unit.py @@ -1,6 +1,7 @@ import base64 import copy import os +import pytest import random import string import sys @@ -22,19 +23,19 @@ ) from algosdk.abi import ( ABIType, - UintType, - UfixedType, - BoolType, - ByteType, AddressType, - StringType, ArrayDynamicType, ArrayStaticType, - TupleType, - Method, - Interface, + BoolType, + ByteType, Contract, + Interface, + Method, NetworkInfo, + StringType, + TupleType, + UfixedType, + UintType, ) from algosdk.future import template, transaction from algosdk.testing import dryrun @@ -1313,7 +1314,7 @@ def test_application_call(self): transaction.ApplicationCallTxn( self.sender, params, 10, oc, app_args=[2, 3, 0] ) # ints work - with self.assertRaises(AssertionError): + with self.assertRaises(TypeError): transaction.ApplicationCallTxn( self.sender, params, 10, oc, app_args=[3.4] ) # floats don't @@ -4140,6 +4141,112 @@ def test_contract(self): ) +class TestEncoding(unittest.TestCase): + """ + Miscellaneous unit tests for functions in `encoding.py` not covered elsewhere + """ + + def test_encode_as_bytes(self): + bs = b"blahblah" + assert bs == encoding.encode_as_bytes(bs) + + ba = bytearray("blueblue", "utf-8") + assert ba == encoding.encode_as_bytes(ba) + + s = "i am a ho hum string" + assert s.encode() == encoding.encode_as_bytes(s) + + i = 42 + assert i.to_bytes(8, "big") == encoding.encode_as_bytes(i) + + for bad_type in [ + 13.37, + type(self), + None, + {"hi": "there"}, + ["hello", "goodbye"], + ]: + with pytest.raises(TypeError) as te: + encoding.encode_as_bytes(bad_type) + + assert f"{bad_type} is not bytes, bytearray, str, or int" == str( + te.value + ) + + +class TestBoxReference(unittest.TestCase): + def test_translate_box_references(self): + # Test case: reference input, foreign app array, caller app id, expected output + test_cases = [ + ([], [], 9999, []), + ( + [(100, "potato")], + [100], + 9999, + [transaction.BoxReference(1, "potato".encode())], + ), + ( + [(9999, "potato"), (0, "tomato")], + [100], + 9999, + [ + transaction.BoxReference(0, "potato".encode()), + transaction.BoxReference(0, "tomato".encode()), + ], + ), + # Self referencing its own app id in foreign array. + ( + [(100, "potato")], + [100], + 100, + [transaction.BoxReference(1, "potato".encode())], + ), + ( + [(777, "tomato"), (888, "pomato")], + [100, 777, 888, 1000], + 9999, + [ + transaction.BoxReference(2, "tomato".encode()), + transaction.BoxReference(3, "pomato".encode()), + ], + ), + ] + for test_case in test_cases: + expected = test_case[3] + actual = transaction.BoxReference.translate_box_references( + test_case[0], test_case[1], test_case[2] + ) + + self.assertEqual(len(expected), len(actual)) + for i, actual_refs in enumerate(actual): + self.assertEqual(expected[i], actual_refs) + + def test_translate_invalid_box_references(self): + # Test case: reference input, foreign app array, error + test_cases_id_error = [ + ([(1, "tomato")], [], error.InvalidForeignIndexError), + ([(-1, "tomato")], [1], error.InvalidForeignIndexError), + ( + [(444, "pomato")], + [2, 3, 100, 888], + error.InvalidForeignIndexError, + ), + ( + [(2, "tomato"), (444, "pomato")], + [2, 3, 100, 888], + error.InvalidForeignIndexError, + ), + ([("tomato", "tomato")], [1], TypeError), + ([(2, "zomato")], None, error.InvalidForeignIndexError), + ] + + for test_case in test_cases_id_error: + with self.assertRaises(test_case[2]) as e: + transaction.BoxReference.translate_box_references( + test_case[0], test_case[1], 9999 + ) + + if __name__ == "__main__": to_run = [ TestPaymentTransaction, @@ -4159,6 +4266,8 @@ def test_contract(self): TestABIType, TestABIEncoding, TestABIInteraction, + TestEncoding, + TestBoxReference, ] loader = unittest.TestLoader() suites = [