├── .gitignore ├── LICENSE ├── README.md ├── contracts.py ├── helpers.py ├── media ├── blog-post.md ├── py-algorand-sdk-pyteal-pytest.png ├── pytest-run-by-name.png ├── pytest-run-verbose.png ├── pytest-run.png ├── running-tests-in-parallel.png ├── sandbox-up-and-running.png └── starting-sandbox.png ├── requirements.txt └── test_contracts.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __pycache__ 3 | *.pyc 4 | .pytest_cache 5 | *.code-workspace 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ivica Paleka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![py-algorand-sdk-pyteal-pytest](https://github.com/ipaleka/algorand-contracts-testing/blob/main/media/py-algorand-sdk-pyteal-pytest.png?raw=true) 2 | 3 | Create two Algorand smart contracts using [Python Algorand SDK](https://github.com/algorand/py-algorand-sdk), respectively [PyTeal](https://github.com/algorand/pyteal) package, and test them using [pytest](https://docs.pytest.org/). 4 | 5 | --- 6 | **Security warning** 7 | 8 | This project has not been audited and should not be used in a production environment. 9 | 10 | --- 11 | 12 | # Requirements 13 | 14 | You should have Python 3 installed on your system. Also, this tutorial uses `python3-venv` for creating virtual environments - install it in a Debian/Ubuntu based systems by issuing the following command: 15 | 16 | ```bash 17 | $ sudo apt-get install python3-venv 18 | ``` 19 | 20 | [Algorand Sandbox](https://github.com/algorand/sandbox) must be installed on your computer. It is implied that the Sandbox executable is in the `sandbox` directory next to this project directory: 21 | 22 | ```bash 23 | $ tree -L 1 24 | . 25 | ├── algorand-contracts-testing 26 | └── sandbox 27 | ``` 28 | 29 | If that's not the case, then you should set `SANDBOX_DIR` environment variable holding sandbox directory before running the tests, like the following: 30 | 31 | ```bash 32 | export SANDBOX_DIR="/home/ipaleka/dev/algorand/sandbox" 33 | ``` 34 | 35 | If you want to clone the repositories, not just download them, then you should have Git installed on your computer. 36 | 37 | 38 | # Setup 39 | 40 | At first create the root directory: 41 | 42 | ```bash 43 | cd ~ 44 | mkdir algorand 45 | cd algorand 46 | ``` 47 | 48 | Then clone both repositories: 49 | 50 | ```bash 51 | git clone https://github.com/ipaleka/algorand-contracts-testing.git 52 | git clone https://github.com/algorand/sandbox.git 53 | ``` 54 | 55 | As always for the Python-based projects, you should create a Python environment and activate it: 56 | 57 | ```bash 58 | python3 -m venv contractsvenv 59 | source contractsvenv/bin/activate 60 | ``` 61 | 62 | Now change the directory to the project root directory and install the project dependencies with: 63 | 64 | ```bash 65 | (contractsvenv) $ cd algorand-contracts-testing 66 | (contractsvenv) $ pip install -r requirements.txt 67 | ``` 68 | 69 | Please bear in mind that starting the Sandbox for the first time takes time. If that's the case then your first tests run will take longer than usual. 70 | 71 | Run the tests with: 72 | 73 | ```bash 74 | (contractsvenv) $ pytest -v 75 | ``` 76 | 77 | For speeding up the tests run, issue the following to use three of your processor's cores in parallel: 78 | 79 | ```bash 80 | (contractsvenv) $ pytest -v -n 3 81 | ``` 82 | 83 | 84 | # Troubleshooting 85 | 86 | If you want a fresh start, reset the Sandbox with: 87 | 88 | ```bash 89 | ../sandbox/sandbox clean 90 | ../sandbox/sandbox up 91 | ``` 92 | 93 | 94 | # TL; DR 95 | 96 | https://user-images.githubusercontent.com/49662536/128438519-1cc02e16-db55-4583-9ad9-1e8023939da9.mp4 97 | -------------------------------------------------------------------------------- /contracts.py: -------------------------------------------------------------------------------- 1 | """Module containing domain logic for smart contracts creation.""" 2 | 3 | import json 4 | 5 | from algosdk import template 6 | from pyteal import Addr, And, Global, Int, Mode, Txn, TxnType, compileTeal 7 | 8 | from helpers import ( 9 | account_balance, 10 | add_standalone_account, 11 | create_payment_transaction, 12 | fund_account, 13 | process_logic_sig_transaction, 14 | process_transactions, 15 | logic_signature, 16 | suggested_params, 17 | transaction_info, 18 | ) 19 | 20 | BANK_ACCOUNT_FEE = 1000 21 | 22 | 23 | # # BANK CONTRACT 24 | def bank_for_account(receiver): 25 | """Only allow receiver to withdraw funds from this contract account. 26 | 27 | Args: 28 | receiver (str): Base 32 Algorand address of the receiver. 29 | """ 30 | 31 | is_payment = Txn.type_enum() == TxnType.Payment 32 | is_single_tx = Global.group_size() == Int(1) 33 | is_correct_receiver = Txn.receiver() == Addr(receiver) 34 | no_close_out_addr = Txn.close_remainder_to() == Global.zero_address() 35 | no_rekey_addr = Txn.rekey_to() == Global.zero_address() 36 | acceptable_fee = Txn.fee() <= Int(BANK_ACCOUNT_FEE) 37 | 38 | return And( 39 | is_payment, 40 | is_single_tx, 41 | is_correct_receiver, 42 | no_close_out_addr, 43 | no_rekey_addr, 44 | acceptable_fee, 45 | ) 46 | 47 | 48 | def create_bank_transaction(logic_sig, escrow_address, receiver, amount, fee=1000): 49 | """Create bank transaction with provided amount.""" 50 | params = suggested_params() 51 | params.fee = fee 52 | params.flat_fee = True 53 | payment_transaction = create_payment_transaction( 54 | escrow_address, params, receiver, amount 55 | ) 56 | transaction_id = process_logic_sig_transaction(logic_sig, payment_transaction) 57 | return transaction_id 58 | 59 | 60 | def setup_bank_contract(**kwargs): 61 | """Initialize and return bank contract for provided receiver.""" 62 | receiver = kwargs.pop("receiver", add_standalone_account()[1]) 63 | 64 | teal_source = compileTeal( 65 | bank_for_account(receiver), 66 | mode=Mode.Signature, 67 | version=3, 68 | ) 69 | logic_sig = logic_signature(teal_source) 70 | escrow_address = logic_sig.address() 71 | fund_account(escrow_address) 72 | return logic_sig, escrow_address, receiver 73 | 74 | 75 | # # SPLIT CONTRACT 76 | def _create_grouped_transactions(split_contract, amount): 77 | """Create grouped transactions for the provided `split_contract` and `amount`.""" 78 | params = suggested_params() 79 | return split_contract.get_split_funds_transaction( 80 | split_contract.get_program(), 81 | amount, 82 | 1, 83 | params.first, 84 | params.last, 85 | params.gh, 86 | ) 87 | 88 | 89 | def _create_split_contract( 90 | owner, 91 | receiver_1, 92 | receiver_2, 93 | rat_1=1, 94 | rat_2=3, 95 | expiry_round=5000000, 96 | min_pay=3000, 97 | max_fee=2000, 98 | ): 99 | """Create and return split template instance from the provided arguments.""" 100 | return template.Split( 101 | owner, receiver_1, receiver_2, rat_1, rat_2, expiry_round, min_pay, max_fee 102 | ) 103 | 104 | 105 | def create_split_transaction(split_contract, amount): 106 | """Create transaction with provided amount for provided split contract.""" 107 | transactions = _create_grouped_transactions(split_contract, amount) 108 | transaction_id = process_transactions(transactions) 109 | return transaction_id 110 | 111 | 112 | def setup_split_contract(**kwargs): 113 | """Initialize and return split contract instance based on provided named arguments.""" 114 | owner = kwargs.pop("owner", add_standalone_account()[1]) 115 | receiver_1 = kwargs.pop("receiver_1", add_standalone_account()[1]) 116 | receiver_2 = kwargs.pop("receiver_2", add_standalone_account()[1]) 117 | 118 | split_contract = _create_split_contract(owner, receiver_1, receiver_2, **kwargs) 119 | escrow_address = split_contract.get_address() 120 | fund_account(escrow_address) 121 | return split_contract 122 | 123 | 124 | if __name__ == "__main__": 125 | """Example usage for contracts.""" 126 | 127 | _, local_receiver = add_standalone_account() 128 | amount = 5000000 129 | logic_sig, escrow_address, receiver = setup_bank_contract(receiver=local_receiver) 130 | assert receiver == local_receiver 131 | 132 | transaction_id = create_bank_transaction( 133 | logic_sig, escrow_address, local_receiver, amount 134 | ) 135 | print("amount: %s" % (amount,)) 136 | print("escrow: %s" % (escrow_address)) 137 | print("balance_escrow: %s" % (account_balance(escrow_address),)) 138 | print("balance_receiver: %s" % (account_balance(local_receiver),)) 139 | print(json.dumps(transaction_info(transaction_id), indent=2)) 140 | 141 | print("\n\n") 142 | 143 | _, local_owner = add_standalone_account() 144 | _, local_receiver_2 = add_standalone_account() 145 | amount = 5000000 146 | 147 | split_contract = setup_split_contract( 148 | owner=local_owner, 149 | receiver_2=local_receiver_2, 150 | rat_1=3, 151 | rat_2=7, 152 | ) 153 | assert split_contract.owner == local_owner 154 | assert split_contract.receiver_2 == local_receiver_2 155 | 156 | transaction_id = create_split_transaction(split_contract, amount) 157 | 158 | print("amount: %s" % (amount,)) 159 | 160 | print("escrow: %s" % (split_contract.get_address(),)) 161 | print("balance_escrow: %s" % (account_balance(split_contract.get_address()),)) 162 | print("owner: %s" % (split_contract.owner,)) 163 | print("balance_owner: %s" % (account_balance(split_contract.owner),)) 164 | print("receiver_1: %s" % (split_contract.receiver_1,)) 165 | print("balance_1: %s" % (account_balance(split_contract.receiver_2),)) 166 | print( 167 | "receiver_2: %s" % (split_contract.receiver_2), 168 | ) 169 | print("balance_2: %s" % (account_balance(split_contract.receiver_2),)) 170 | print(json.dumps(transaction_info(transaction_id), indent=2)) 171 | -------------------------------------------------------------------------------- /helpers.py: -------------------------------------------------------------------------------- 1 | """Module containing helper functions for accessing Algorand blockchain.""" 2 | 3 | import base64 4 | import os 5 | import pty 6 | import subprocess 7 | import time 8 | from pathlib import Path 9 | 10 | from algosdk import account, mnemonic 11 | from algosdk.error import IndexerHTTPError 12 | from algosdk.future.transaction import LogicSig, LogicSigTransaction, PaymentTxn 13 | from algosdk.v2client import algod, indexer 14 | 15 | INDEXER_TIMEOUT = 10 # 61 for devMode 16 | 17 | 18 | ## SANDBOX 19 | def _cli_passphrase_for_account(address): 20 | """Return passphrase for provided address.""" 21 | process = call_sandbox_command("goal", "account", "export", "-a", address) 22 | 23 | if process.stderr: 24 | raise RuntimeError(process.stderr.decode("utf8")) 25 | 26 | passphrase = "" 27 | parts = process.stdout.decode("utf8").split('"') 28 | if len(parts) > 1: 29 | passphrase = parts[1] 30 | if passphrase == "": 31 | raise ValueError( 32 | "Can't retrieve passphrase from the address: %s\nOutput: %s" 33 | % (address, process.stdout.decode("utf8")) 34 | ) 35 | return passphrase 36 | 37 | 38 | def _sandbox_directory(): 39 | """Return full path to Algorand's sandbox executable. 40 | 41 | The location of sandbox directory is retrieved either from the SANDBOX_DIR 42 | environment variable or if it's not set then the location of sandbox directory 43 | is implied to be the sibling of this Django project in the directory tree. 44 | """ 45 | return os.environ.get("SANDBOX_DIR") or str( 46 | Path(__file__).resolve().parent.parent / "sandbox" 47 | ) 48 | 49 | 50 | def _sandbox_executable(): 51 | """Return full path to Algorand's sandbox executable.""" 52 | return _sandbox_directory() + "/sandbox" 53 | 54 | 55 | def call_sandbox_command(*args): 56 | """Call and return sandbox command composed from provided arguments.""" 57 | return subprocess.run( 58 | [_sandbox_executable(), *args], stdin=pty.openpty()[1], capture_output=True 59 | ) 60 | 61 | 62 | ## CLIENTS 63 | def _algod_client(): 64 | """Instantiate and return Algod client object.""" 65 | algod_address = "http://localhost:4001" 66 | algod_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 67 | return algod.AlgodClient(algod_token, algod_address) 68 | 69 | 70 | def _indexer_client(): 71 | """Instantiate and return Indexer client object.""" 72 | indexer_address = "http://localhost:8980" 73 | indexer_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 74 | return indexer.IndexerClient(indexer_token, indexer_address) 75 | 76 | 77 | ## TRANSACTIONS 78 | def _add_transaction(sender, receiver, passphrase, amount, note): 79 | """Create and sign transaction from provided arguments. 80 | 81 | Returned non-empty tuple carries field where error was raised and description. 82 | If the first item is None then the error is non-field/integration error. 83 | Returned two-tuple of empty strings marks successful transaction. 84 | """ 85 | client = _algod_client() 86 | params = client.suggested_params() 87 | unsigned_txn = PaymentTxn(sender, params, receiver, amount, None, note.encode()) 88 | signed_txn = unsigned_txn.sign(mnemonic.to_private_key(passphrase)) 89 | transaction_id = client.send_transaction(signed_txn) 90 | _wait_for_confirmation(client, transaction_id, 4) 91 | return transaction_id 92 | 93 | 94 | def _wait_for_confirmation(client, transaction_id, timeout): 95 | """ 96 | Wait until the transaction is confirmed or rejected, or until 'timeout' 97 | number of rounds have passed. 98 | Args: 99 | transaction_id (str): the transaction to wait for 100 | timeout (int): maximum number of rounds to wait 101 | Returns: 102 | dict: pending transaction information, or throws an error if the transaction 103 | is not confirmed or rejected in the next timeout rounds 104 | """ 105 | start_round = client.status()["last-round"] + 1 106 | current_round = start_round 107 | 108 | while current_round < start_round + timeout: 109 | try: 110 | pending_txn = client.pending_transaction_info(transaction_id) 111 | except Exception: 112 | return 113 | if pending_txn.get("confirmed-round", 0) > 0: 114 | return pending_txn 115 | elif pending_txn["pool-error"]: 116 | raise Exception("pool error: {}".format(pending_txn["pool-error"])) 117 | client.status_after_block(current_round) 118 | current_round += 1 119 | raise Exception( 120 | "pending tx not found in timeout rounds, timeout value = : {}".format(timeout) 121 | ) 122 | 123 | 124 | def create_payment_transaction(escrow_address, params, receiver, amount): 125 | """Create and return payment transaction from provided arguments.""" 126 | return PaymentTxn(escrow_address, params, receiver, amount) 127 | 128 | 129 | def process_logic_sig_transaction(logic_sig, payment_transaction): 130 | """Create logic signature transaction and send it to the network.""" 131 | client = _algod_client() 132 | logic_sig_transaction = LogicSigTransaction(payment_transaction, logic_sig) 133 | transaction_id = client.send_transaction(logic_sig_transaction) 134 | _wait_for_confirmation(client, transaction_id, 4) 135 | return transaction_id 136 | 137 | 138 | def process_transactions(transactions): 139 | """Send provided grouped `transactions` to network and wait for confirmation.""" 140 | client = _algod_client() 141 | transaction_id = client.send_transactions(transactions) 142 | _wait_for_confirmation(client, transaction_id, 4) 143 | return transaction_id 144 | 145 | 146 | def suggested_params(): 147 | """Return the suggested params from the algod client.""" 148 | return _algod_client().suggested_params() 149 | 150 | 151 | ## CREATING 152 | def add_standalone_account(): 153 | """Create standalone account and return two-tuple of its private key and address.""" 154 | private_key, address = account.generate_account() 155 | return private_key, address 156 | 157 | 158 | def fund_account(address, initial_funds=1000000000): 159 | """Fund provided `address` with `initial_funds` amount of microAlgos.""" 160 | initial_funds_address = _initial_funds_address() 161 | if initial_funds_address is None: 162 | raise Exception("Initial funds weren't transferred!") 163 | _add_transaction( 164 | initial_funds_address, 165 | address, 166 | _cli_passphrase_for_account(initial_funds_address), 167 | initial_funds, 168 | "Initial funds", 169 | ) 170 | 171 | 172 | ## RETRIEVING 173 | def _initial_funds_address(): 174 | """Get the address of initially created account having enough funds. 175 | 176 | Such an account is used to transfer initial funds for the accounts 177 | created in this tutorial. 178 | """ 179 | return next( 180 | ( 181 | account.get("address") 182 | for account in _indexer_client().accounts().get("accounts", [{}, {}]) 183 | if account.get("created-at-round") == 0 184 | and account.get("status") == "Offline" # "Online" for devMode 185 | ), 186 | None, 187 | ) 188 | 189 | 190 | def account_balance(address): 191 | """Return funds balance of the account having provided address.""" 192 | account_info = _algod_client().account_info(address) 193 | return account_info.get("amount") 194 | 195 | 196 | def transaction_info(transaction_id): 197 | """Return transaction with provided id.""" 198 | timeout = 0 199 | while timeout < INDEXER_TIMEOUT: 200 | try: 201 | transaction = _indexer_client().transaction(transaction_id) 202 | break 203 | except IndexerHTTPError: 204 | time.sleep(1) 205 | timeout += 1 206 | else: 207 | raise TimeoutError( 208 | "Timeout reached waiting for transaction to be available in indexer" 209 | ) 210 | 211 | return transaction 212 | 213 | 214 | ## UTILITY 215 | def _compile_source(source): 216 | """Compile and return teal binary code.""" 217 | compile_response = _algod_client().compile(source) 218 | return base64.b64decode(compile_response["result"]) 219 | 220 | 221 | def logic_signature(teal_source): 222 | """Create and return logic signature for provided `teal_source`.""" 223 | compiled_binary = _compile_source(teal_source) 224 | return LogicSig(compiled_binary) 225 | -------------------------------------------------------------------------------- /media/blog-post.md: -------------------------------------------------------------------------------- 1 | ![py-algorand-sdk-pyteal-pytest](https://github.com/ipaleka/algorand-contracts-testing/blob/main/media/py-algorand-sdk-pyteal-pytest.png?raw=true) 2 | 3 | # Introduction 4 | 5 | In this tutorial, we're going to create two smart contracts using two different approaches and then we're going to test their implementation using [pytest](https://docs.pytest.org/). The first smart contract will be created using a predefined template that ships with the [Python Algorand SDK](https://github.com/algorand/py-algorand-sdk), while the other will be created using [PyTeal](https://github.com/algorand/pyteal) package. 6 | 7 | All the source code for this tutorial is available in a [public GitHub repository](https://github.com/ipaleka/algorand-contracts-testing). 8 | 9 | For those of you eager to get started quickly, here's a video that wraps around the process of installing the requirements and running the tests: 10 | 11 | https://user-images.githubusercontent.com/49662536/128438519-1cc02e16-db55-4583-9ad9-1e8023939da9.mp4 12 | 13 | 14 | # Requirements 15 | 16 | This project uses a [Python](https://www.python.org/) wrapper around [Algorand SDK](https://developer.algorand.org/docs/reference/sdks/), so you should have Python 3 installed on your system. Also, this project uses `python3-venv` package for creating virtual environments and you have to install it if it's not already installed in your system. For a Debian/Ubuntu based systems, you can do that by issuing the following command: 17 | 18 | ```bash 19 | $ sudo apt-get install python3-venv 20 | ``` 21 | 22 | If you're going to clone the Algorand Sandbox (as opposed to just download its installation archive), you'll also need [Git distributed version control system](https://git-scm.com/). 23 | 24 | 25 | # Setup and run Algorand Sandbox 26 | 27 | Let's create the root directory named `algorand` where this project and Sandbox will reside. 28 | 29 | ```bash 30 | cd ~ 31 | mkdir algorand 32 | cd algorand 33 | ``` 34 | 35 | This project depends on [Algorand Sandbox](https://github.com/algorand/sandbox) running in your computer. Use its README for the instructions on how to prepare its installation on your system. You may clone the Algorand Sandbox repository with the following command: 36 | 37 | ```bash 38 | git clone https://github.com/algorand/sandbox.git 39 | ``` 40 | 41 | The Sandbox Docker containers will be started automatically by running the tests from this project. As starting them for the first time takes time, it's advisable to start the Sandbox before running the tests by issuing `./sandbox/sandbox up`: 42 | 43 | ![Starting Sandbox](https://github.com/ipaleka/algorand-contracts-testing/blob/main/media/starting-sandbox.png?raw=true) 44 | 45 | The Sandbox will be up and running after a minute or two: 46 | 47 | ![Up and running Sandbox](https://github.com/ipaleka/algorand-contracts-testing/blob/main/media/sandbox-up-and-running.png?raw=true) 48 | 49 | --- 50 | **Note** 51 | 52 | This project's code implies that the Sandbox executable is in the `sandbox` directory which is a sibling to this project's directory: 53 | 54 | ```bash 55 | $ tree -L 1 56 | . 57 | ├── algorand-contracts-testing 58 | └── sandbox 59 | ``` 60 | 61 | If that's not the case, then you should set `SANDBOX_DIR` environment variable holding sandbox directory before running this project's tests: 62 | 63 | ```bash 64 | export SANDBOX_DIR="/home/ipaleka/dev/algorand/sandbox" 65 | ``` 66 | 67 | --- 68 | 69 | # Create and activate Python virtual environment 70 | 71 | Every Python-based project should run inside its own virtual environment. Create and activate one for this project with: 72 | 73 | ```bash 74 | python3 -m venv contractsvenv 75 | source contractsvenv/bin/activate 76 | ``` 77 | 78 | After successful activation, the environment name will be presented at your prompt and that indicates that all the Python package installations issued will reside only in that environment. 79 | 80 | ```bash 81 | (contractsvenv) $ 82 | ``` 83 | 84 | We're ready now to install our project's main dependencies: the [Python Algorand SDK](https://github.com/algorand/py-algorand-sdk), [PyTeal](https://github.com/algorand/pyteal), and [pytest](https://docs.pytest.org/). 85 | 86 | 87 | ```bash 88 | (contractsvenv) $ pip install py-algorand-sdk pyteal pytest 89 | ``` 90 | 91 | 92 | # Creating a smart contract from a template 93 | 94 | Our first smart contract will be a split payment contract where a transaction amount is split between two receivers at provided ratio. For that purpose we created a function that accepts contract's data as arguments: 95 | 96 | 97 | ```python 98 | from algosdk import template 99 | 100 | def _create_split_contract( 101 | owner, 102 | receiver_1, 103 | receiver_2, 104 | rat_1=1, 105 | rat_2=3, 106 | expiry_round=5000000, 107 | min_pay=3000, 108 | max_fee=2000, 109 | ): 110 | """Create and return split template instance from the provided arguments.""" 111 | return template.Split( 112 | owner, receiver_1, receiver_2, rat_1, rat_2, expiry_round, min_pay, max_fee 113 | ) 114 | ``` 115 | 116 | We use template's instance method `get_split_funds_transaction` in order to create a list of two transactions based on provided amount: 117 | 118 | ```python 119 | def _create_grouped_transactions(split_contract, amount): 120 | """Create grouped transactions for the provided `split_contract` and `amount`.""" 121 | params = suggested_params() 122 | return split_contract.get_split_funds_transaction( 123 | split_contract.get_program(), 124 | amount, 125 | 1, 126 | params.first, 127 | params.last, 128 | params.gh, 129 | ) 130 | 131 | def create_split_transaction(split_contract, amount): 132 | """Create transaction with provided amount for provided split contract.""" 133 | transactions = _create_grouped_transactions(split_contract, amount) 134 | transaction_id = process_transactions(transactions) 135 | return transaction_id 136 | ``` 137 | 138 | That list of two transactions is then sent to `process_transactions` helper function that is responsible for deploying our smart contract to the Algorand blockchain. 139 | 140 | ```python 141 | def _algod_client(): 142 | """Instantiate and return Algod client object.""" 143 | algod_address = "http://localhost:4001" 144 | algod_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 145 | return algod.AlgodClient(algod_token, algod_address) 146 | 147 | def process_transactions(transactions): 148 | """Send provided grouped `transactions` to network and wait for confirmation.""" 149 | client = _algod_client() 150 | transaction_id = client.send_transactions(transactions) 151 | _wait_for_confirmation(client, transaction_id, 4) 152 | return transaction_id 153 | ``` 154 | 155 | --- 156 | **Note** 157 | 158 | Some helper functions aren't shown here in the tutorial for the sake of simplicity. Please take a look at the [project's repository](https://github.com/ipaleka/algorand-contracts-testing/blob/main/helpers.py) for their implementation. 159 | 160 | --- 161 | 162 | # Creating a smart contract with PyTeal 163 | 164 | Our second smart contract is a simple bank for account contract where only a pre-defined receiver is able to withdraw funds from the smart contract: 165 | 166 | ```python 167 | def bank_for_account(receiver): 168 | """Only allow receiver to withdraw funds from this contract account. 169 | 170 | Args: 171 | receiver (str): Base 32 Algorand address of the receiver. 172 | """ 173 | is_payment = Txn.type_enum() == TxnType.Payment 174 | is_single_tx = Global.group_size() == Int(1) 175 | is_correct_receiver = Txn.receiver() == Addr(receiver) 176 | no_close_out_addr = Txn.close_remainder_to() == Global.zero_address() 177 | no_rekey_addr = Txn.rekey_to() == Global.zero_address() 178 | acceptable_fee = Txn.fee() <= Int(BANK_ACCOUNT_FEE) 179 | 180 | return And( 181 | is_payment, 182 | is_single_tx, 183 | is_correct_receiver, 184 | no_close_out_addr, 185 | no_rekey_addr, 186 | acceptable_fee, 187 | ) 188 | ``` 189 | 190 | The above PyTeal code is then compiled into TEAL byte-code using PyTeal's `compileTeal` function and a signed logic signature is created from the compiled source: 191 | 192 | ```python 193 | def setup_bank_contract(**kwargs): 194 | """Initialize and return bank contract for provided receiver.""" 195 | receiver = kwargs.pop("receiver", add_standalone_account()[1]) 196 | 197 | teal_source = compileTeal( 198 | bank_for_account(receiver), 199 | mode=Mode.Signature, 200 | version=3, 201 | ) 202 | logic_sig = logic_signature(teal_source) 203 | escrow_address = logic_sig.address() 204 | fund_account(escrow_address) 205 | return logic_sig, escrow_address, receiver 206 | 207 | def create_bank_transaction(logic_sig, escrow_address, receiver, amount, fee=1000): 208 | """Create bank transaction with provided amount.""" 209 | params = suggested_params() 210 | params.fee = fee 211 | params.flat_fee = True 212 | payment_transaction = create_payment_transaction( 213 | escrow_address, params, receiver, amount 214 | ) 215 | transaction_id = process_logic_sig_transaction(logic_sig, payment_transaction) 216 | return transaction_id 217 | ``` 218 | 219 | As you may notice, we provide some funds to the escrow account by calling the `fund_account` function. 220 | 221 | Among other used functions, the following helper functions are used for connecting to the blockchain and processing the smart contract: 222 | 223 | ```python 224 | import base64 225 | 226 | from algosdk import account 227 | from algosdk.future.transaction import LogicSig, LogicSigTransaction, PaymentTxn 228 | 229 | 230 | def create_payment_transaction(escrow_address, params, receiver, amount): 231 | """Create and return payment transaction from provided arguments.""" 232 | return PaymentTxn(escrow_address, params, receiver, amount) 233 | 234 | 235 | def process_logic_sig_transaction(logic_sig, payment_transaction): 236 | """Create logic signature transaction and send it to the network.""" 237 | client = _algod_client() 238 | logic_sig_transaction = LogicSigTransaction(payment_transaction, logic_sig) 239 | transaction_id = client.send_transaction(logic_sig_transaction) 240 | _wait_for_confirmation(client, transaction_id, 4) 241 | return transaction_id 242 | 243 | 244 | def _compile_source(source): 245 | """Compile and return teal binary code.""" 246 | compile_response = _algod_client().compile(source) 247 | return base64.b64decode(compile_response["result"]) 248 | 249 | 250 | def logic_signature(teal_source): 251 | """Create and return logic signature for provided `teal_source`.""" 252 | compiled_binary = _compile_source(teal_source) 253 | return LogicSig(compiled_binary) 254 | ``` 255 | 256 | That's all we need to prepare our smart contracts for testing. 257 | 258 | 259 | # Structure of a testing module 260 | 261 | In order for our `test_contracts.py` testing module to be discovered by pytest test runner, we named it with `test_` prefix. For a large-scale project, you may create `tests` directory and place your testing modules in it. 262 | 263 | Pytest allows running a special function before the very first test from the current module is run. In our testing module, we use it to run the Sandbox daemon: 264 | 265 | ```python 266 | from helpers import call_sandbox_command 267 | 268 | def setup_module(module): 269 | """Ensure Algorand Sandbox is up prior to running tests from this module.""" 270 | call_sandbox_command("up") 271 | ``` 272 | 273 | A test suite for each of the two smart contracts is created and the `setup_method` is run before each test in the suite. We use that setup method to create the needed accounts: 274 | 275 | ```python 276 | from contracts import setup_bank_contract, setup_split_contract 277 | from helpers import add_standalone_account 278 | 279 | 280 | class TestSplitContract: 281 | """Class for testing the split smart contract.""" 282 | 283 | def setup_method(self): 284 | """Create owner and receivers accounts before each test.""" 285 | _, self.owner = add_standalone_account() 286 | _, self.receiver_1 = add_standalone_account() 287 | _, self.receiver_2 = add_standalone_account() 288 | 289 | def _create_split_contract(self, **kwargs): 290 | """Helper method for creating a split contract from pre-existing accounts 291 | 292 | and provided named arguments. 293 | """ 294 | return setup_split_contract( 295 | owner=self.owner, 296 | receiver_1=self.receiver_1, 297 | receiver_2=self.receiver_2, 298 | **kwargs, 299 | ) 300 | 301 | 302 | class TestBankContract: 303 | """Class for testing the bank for account smart contract.""" 304 | 305 | def setup_method(self): 306 | """Create receiver account before each test.""" 307 | _, self.receiver = add_standalone_account() 308 | 309 | def _create_bank_contract(self, **kwargs): 310 | """Helper method for creating bank contract from pre-existing receiver 311 | 312 | and provided named arguments. 313 | """ 314 | return setup_bank_contract(receiver=self.receiver, **kwargs) 315 | ``` 316 | 317 | Instead of repeating the code, we've created a helper method in each suite. That way we adhere to the [DRY principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself). 318 | 319 | 320 | --- 321 | **Note** 322 | 323 | We use only the `setup_method` that is executed **before** each test. In order to execute some code **after** each test, use the `teardown_method`. The same goes for the module level with `teardown_module` function. 324 | 325 | --- 326 | 327 | 328 | # Testing smart contracts implementation 329 | 330 | Let's start our testing journey by creating a test confirming that the accounts created in the setup method take their roles in our smart contract: 331 | 332 | ```python 333 | class TestSplitContract: 334 | # 335 | def test_split_contract_uses_existing_accounts_when_they_are_provided(self): 336 | """Provided accounts should be used in the smart contract.""" 337 | contract = self._create_split_contract() 338 | assert contract.owner == self.owner 339 | assert contract.receiver_1 == self.receiver_1 340 | assert contract.receiver_2 == self.receiver_2 341 | ``` 342 | 343 | Start the test runner by issuing the `pytest` command from the project's root directory: 344 | 345 | ![Pytest run](https://github.com/ipaleka/algorand-contracts-testing/blob/main/media/pytest-run.png?raw=true) 346 | 347 | Well done, you have successfully tested the code responsible for creating the smart contract from a template! 348 | 349 | Now add a test that checks the original smart contract creation function without providing any accounts to it, together with two counterpart tests in the bank contract test suite: 350 | 351 | ```python 352 | class TestSplitContract: 353 | # 354 | def test_split_contract_creates_new_accounts(self): 355 | """Contract creation function `setup_split_contract` should create new accounts 356 | 357 | if existing are not provided to it. 358 | """ 359 | contract = setup_split_contract() 360 | assert contract.owner != self.owner 361 | assert contract.receiver_1 != self.receiver_1 362 | assert contract.receiver_2 != self.receiver_2 363 | 364 | 365 | class TestBankContract: 366 | # 367 | def test_bank_contract_creates_new_receiver(self): 368 | """Contract creation function `setup_bank_contract` should create new receiver 369 | 370 | if existing is not provided to it. 371 | """ 372 | _, _, receiver = setup_bank_contract() 373 | assert receiver != self.receiver 374 | 375 | def test_bank_contract_uses_existing_receiver_when_it_is_provided(self): 376 | """Provided receiver should be used in the smart contract.""" 377 | _, _, receiver = self._create_bank_contract() 378 | assert receiver == self.receiver 379 | ``` 380 | 381 | In order to make the output more verbose, add the `-v` argument to pytest command: 382 | 383 | ![Pytest verbose run](https://github.com/ipaleka/algorand-contracts-testing/blob/main/media/pytest-run-verbose.png?raw=true) 384 | 385 | --- 386 | **Note** 387 | 388 | As you can see from the provided screenshots, running these tests takes quite a lot of time. The initial delay is because we invoked the Sandbox daemon in the `setup_module` function, and processing the transactions in the blockchain spent the majority of the time (about 5 seconds for each of them). To considerably speed up the whole process, you may try implementing the devMode configuration which creates a block for every transaction. Please bear in mind that at the time of writing this tutorial the Algorand Sandbox doesn't ship with such a template [yet](https://github.com/algorand/sandbox/issues/62). 389 | 390 | --- 391 | 392 | 393 | # Testing smart contract transactions 394 | 395 | Now let's test the actual implementation of our smart contracts. As recording a transaction in the Algorand Indexer database takes some 5 seconds after it is submitted to the blockchain, we've created a helper function that will wait until the transaction can be retrieved: 396 | 397 | ```python 398 | import time 399 | 400 | from algosdk.error import IndexerHTTPError 401 | from algosdk.v2client import indexer 402 | 403 | INDEXER_TIMEOUT = 10 404 | 405 | 406 | def _indexer_client(): 407 | """Instantiate and return Indexer client object.""" 408 | indexer_address = "http://localhost:8980" 409 | indexer_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 410 | return indexer.IndexerClient(indexer_token, indexer_address) 411 | 412 | def transaction_info(transaction_id): 413 | """Return transaction with provided id.""" 414 | timeout = 0 415 | while timeout < INDEXER_TIMEOUT: 416 | try: 417 | transaction = _indexer_client().transaction(transaction_id) 418 | break 419 | except IndexerHTTPError: 420 | time.sleep(1) 421 | timeout += 1 422 | else: 423 | raise TimeoutError( 424 | "Timeout reached waiting for transaction to be available in indexer" 425 | ) 426 | 427 | return transaction 428 | ``` 429 | 430 | The code for our tests should be straightforward. We use the returned transaction's ID to retrieve a transaction as a Python dictionary and we check some of its values afterward. It is worth noting that in the case of a split contract we check that the *group* key holds a valid address as a value which means the transactions are grouped, while for the bank account we test exactly the opposite - that no group key even exists: 431 | 432 | ```python 433 | from algosdk import constants 434 | from algosdk.encoding import encode_address, is_valid_address 435 | 436 | from contracts import BANK_ACCOUNT_FEE, create_bank_transaction, create_split_transaction 437 | from helpers import transaction_info 438 | 439 | 440 | class TestSplitContract: 441 | # 442 | def test_split_contract_transaction(self): 443 | """Successful transaction should have sender equal to escrow account. 444 | 445 | Also, receiver should be contract's receiver_1, the type should be payment, 446 | and group should be a valid address. 447 | """ 448 | contract = setup_split_contract() 449 | transaction_id = create_split_transaction(contract, 1000000) 450 | transaction = transaction_info(transaction_id) 451 | assert transaction.get("transaction").get("tx-type") == constants.payment_txn 452 | assert transaction.get("transaction").get("sender") == contract.get_address() 453 | assert ( 454 | transaction.get("transaction").get("payment-transaction").get("receiver") 455 | == contract.receiver_1 456 | ) 457 | assert is_valid_address( 458 | encode_address( 459 | base64.b64decode(transaction.get("transaction").get("group")) 460 | ) 461 | ) 462 | 463 | 464 | class TestBankContract: 465 | # 466 | def test_bank_contract_transaction(self): 467 | """Successful transaction should have sender equal to escrow account. 468 | 469 | Also, the transaction type should be payment, payment receiver should be 470 | contract's receiver, and the payment amount should be equal to provided amount. 471 | Finally, there should be no group field in transaction. 472 | """ 473 | amount = 1000000 474 | logic_sig, escrow_address, receiver = self._create_bank_contract( 475 | fee=BANK_ACCOUNT_FEE 476 | ) 477 | transaction_id = create_bank_transaction( 478 | logic_sig, escrow_address, receiver, amount 479 | ) 480 | transaction = transaction_info(transaction_id) 481 | assert transaction.get("transaction").get("tx-type") == constants.payment_txn 482 | assert transaction.get("transaction").get("sender") == escrow_address 483 | assert ( 484 | transaction.get("transaction").get("payment-transaction").get("receiver") 485 | == receiver 486 | ) 487 | assert ( 488 | transaction.get("transaction").get("payment-transaction").get("amount") 489 | == amount 490 | ) 491 | assert transaction.get("transaction").get("group", None) is None 492 | ``` 493 | 494 | If you don't want to run all the existing tests every time, add the `-k` argument to pytest followed by a text that identifies the test(s) you wish to run: 495 | 496 | ![Run tests by name](https://github.com/ipaleka/algorand-contracts-testing/blob/main/media/pytest-run-by-name.png?raw=true) 497 | 498 | 499 | # Testing validity of provided arguments 500 | 501 | In the previous section, we made the assertions based on the returned values from the target functions. Another approach is to call a function with some arguments provided and test if it raises an error: 502 | 503 | 504 | ```python 505 | from algosdk.error import AlgodHTTPError, TemplateInputError 506 | 507 | from helpers import account_balance 508 | 509 | 510 | class TestSplitContract: 511 | # 512 | def test_split_contract_min_pay(self): 513 | """Transaction should be created when the split amount for receiver_1 514 | 515 | is greater than `min_pay`. 516 | """ 517 | min_pay = 250000 518 | contract = self._create_split_contract(min_pay=min_pay, rat_1=1, rat_2=3) 519 | amount = 2000000 520 | create_split_transaction(contract, amount) 521 | assert account_balance(contract.receiver_1) > min_pay 522 | 523 | def test_split_contract_min_pay_failed_transaction(self): 524 | """Transaction should fail when the split amount for receiver_1 525 | 526 | is less than `min_pay`. 527 | """ 528 | min_pay = 300000 529 | contract = self._create_split_contract(min_pay=min_pay, rat_1=1, rat_2=3) 530 | amount = 1000000 531 | 532 | with pytest.raises(TemplateInputError) as exception: 533 | create_split_transaction(contract, amount) 534 | assert ( 535 | str(exception.value) 536 | == f"the amount paid to receiver_1 must be greater than {min_pay}" 537 | ) 538 | 539 | 540 | class TestBankContract: 541 | # 542 | def test_bank_contract_raises_error_for_wrong_receiver(self): 543 | """Transaction should fail for a wrong receiver.""" 544 | _, other_receiver = add_standalone_account() 545 | 546 | logic_sig, escrow_address, _ = self._create_bank_contract() 547 | with pytest.raises(AlgodHTTPError) as exception: 548 | create_bank_transaction(logic_sig, escrow_address, other_receiver, 2000000) 549 | assert "rejected by logic" in str(exception.value) 550 | ``` 551 | 552 | 553 | # Parametrization of arguments for a test function 554 | 555 | Pytest allows defining multiple sets of arguments and fixtures at the test function or class. Add the `pytest.mark.parametrize` decorator holding your fixture data to test function and define the arguments with the same names as fixture elements. We've created six tests using the same test function with the following code: 556 | 557 | ```python 558 | class TestSplitContract: 559 | # 560 | @pytest.mark.parametrize( 561 | "amount,rat_1,rat_2", 562 | [ 563 | (1000000, 1, 3), 564 | (999999, 1, 2), 565 | (1400000, 2, 5), 566 | (1000000, 1, 9), 567 | (900000, 4, 5), 568 | (1200000, 5, 1), 569 | ], 570 | ) 571 | def test_split_contract_balances_of_involved_accounts(self, amount, rat_1, rat_2): 572 | """After successful transaction, balance of involved accounts should pass 573 | 574 | assertion to result of expressions calculated from the provided arguments. 575 | """ 576 | contract = self._create_split_contract(rat_1=rat_1, rat_2=rat_2) 577 | assert account_balance(contract.owner) == 0 578 | assert account_balance(contract.receiver_1) == 0 579 | assert account_balance(contract.receiver_2) == 0 580 | 581 | escrow = contract.get_address() 582 | escrow_balance = account_balance(escrow) 583 | 584 | create_split_transaction(contract, amount) 585 | assert account_balance(contract.owner) == 0 586 | assert account_balance(contract.receiver_1) == rat_1 * amount / (rat_1 + rat_2) 587 | assert account_balance(contract.receiver_2) == rat_2 * amount / (rat_1 + rat_2) 588 | assert account_balance(escrow) == escrow_balance - amount - contract.max_fee 589 | ``` 590 | 591 | You may take a look at the [pytest documentation](https://docs.pytest.org/en/6.2.x/fixture.html) on fixtures for the use case that best suits your needs. 592 | 593 | 594 | # Speeding up by running the tests in parallel 595 | 596 | If you have multiple CPU cores you can use those for a combined test run. All you have to do for that is to install the [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) plugin into your virtual environment: 597 | 598 | ```bash 599 | (contractsvenv) $ pip install pytest-xdist 600 | ``` 601 | 602 | After that, you'll be able to run tests in parallel on a number of cores set with the `-n` argument added to pytest command. The following example uses three cores running in parallel: 603 | 604 | ![Running tests in parallel](https://github.com/ipaleka/algorand-contracts-testing/blob/main/media/running-tests-in-parallel.png?raw=true) 605 | 606 | As you can see from this screenshot, some tests aren't shown here in the tutorial for the sake of simplicity. Please take a look at the [project's repository](https://github.com/ipaleka/algorand-contracts-testing/blob/main/test_contracts.py) for their implementation. 607 | 608 | 609 | # Conclusion 610 | 611 | We introduced the reader to the two ways of creating Algorand smart contracts using the Python programming language. We created the first smart contract using a template that ships with the [Python Algorand SDK](https://github.com/algorand/py-algorand-sdk). The second contract is created using [PyTeal](https://github.com/algorand/pyteal), a Python wrapper around the [Transaction Execution Approval Language](https://developer.algorand.org/docs/reference/teal/specification/) (TEAL). 612 | 613 | Finally, we created two test suites in [pytest](https://docs.pytest.org/) using best practices and explained the logic behind them. 614 | 615 | For any questions or suggestions, use the [issues section](https://github.com/ipaleka/algorand-contracts-testing/issues) of this project's repository or reach out in the Algorand [Discord channel](https://discord.com/invite/hbcUSuw). 616 | 617 | Enjoy your coding! 618 | -------------------------------------------------------------------------------- /media/py-algorand-sdk-pyteal-pytest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipaleka/algorand-contracts-testing/9e14113d2b814beb7c4cd2d71a0caf906afcb845/media/py-algorand-sdk-pyteal-pytest.png -------------------------------------------------------------------------------- /media/pytest-run-by-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipaleka/algorand-contracts-testing/9e14113d2b814beb7c4cd2d71a0caf906afcb845/media/pytest-run-by-name.png -------------------------------------------------------------------------------- /media/pytest-run-verbose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipaleka/algorand-contracts-testing/9e14113d2b814beb7c4cd2d71a0caf906afcb845/media/pytest-run-verbose.png -------------------------------------------------------------------------------- /media/pytest-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipaleka/algorand-contracts-testing/9e14113d2b814beb7c4cd2d71a0caf906afcb845/media/pytest-run.png -------------------------------------------------------------------------------- /media/running-tests-in-parallel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipaleka/algorand-contracts-testing/9e14113d2b814beb7c4cd2d71a0caf906afcb845/media/running-tests-in-parallel.png -------------------------------------------------------------------------------- /media/sandbox-up-and-running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipaleka/algorand-contracts-testing/9e14113d2b814beb7c4cd2d71a0caf906afcb845/media/sandbox-up-and-running.png -------------------------------------------------------------------------------- /media/starting-sandbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipaleka/algorand-contracts-testing/9e14113d2b814beb7c4cd2d71a0caf906afcb845/media/starting-sandbox.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyteal 2 | py-algorand-sdk 3 | pytest 4 | pytest-xdist -------------------------------------------------------------------------------- /test_contracts.py: -------------------------------------------------------------------------------- 1 | """Module for Algorand smart contracts integration testing.""" 2 | 3 | import base64 4 | 5 | import pytest 6 | from algosdk import constants 7 | from algosdk.encoding import encode_address, is_valid_address 8 | from algosdk.error import AlgodHTTPError, TemplateInputError 9 | 10 | from contracts import ( 11 | BANK_ACCOUNT_FEE, 12 | create_bank_transaction, 13 | create_split_transaction, 14 | setup_bank_contract, 15 | setup_split_contract, 16 | ) 17 | from helpers import ( 18 | account_balance, 19 | add_standalone_account, 20 | call_sandbox_command, 21 | transaction_info, 22 | ) 23 | 24 | 25 | def setup_module(module): 26 | """Ensure Algorand Sandbox is up prior to running tests from this module.""" 27 | call_sandbox_command("up") 28 | # call_sandbox_command("up", "dev") 29 | 30 | 31 | class TestBankContract: 32 | """Class for testing the bank for account smart contract.""" 33 | 34 | def setup_method(self): 35 | """Create receiver account before each test.""" 36 | _, self.receiver = add_standalone_account() 37 | 38 | def _create_bank_contract(self, **kwargs): 39 | """Helper method for creating bank contract from pre-existing receiver 40 | 41 | and provided named arguments. 42 | """ 43 | return setup_bank_contract(receiver=self.receiver, **kwargs) 44 | 45 | def test_bank_contract_creates_new_receiver(self): 46 | """Contract creation function `setup_bank_contract` should create new receiver 47 | 48 | if existing is not provided to it. 49 | """ 50 | _, _, receiver = setup_bank_contract() 51 | assert receiver != self.receiver 52 | 53 | def test_bank_contract_uses_existing_receiver_when_it_is_provided(self): 54 | """Provided receiver should be used in the smart contract.""" 55 | _, _, receiver = self._create_bank_contract() 56 | assert receiver == self.receiver 57 | 58 | def test_bank_contract_fee(self): 59 | """Transaction should be created and error shouldn't be raised 60 | 61 | when the fee is equal to BANK_ACCOUNT_FEE. 62 | """ 63 | logic_sig, escrow_address, receiver = self._create_bank_contract() 64 | transaction_id = create_bank_transaction( 65 | logic_sig, escrow_address, receiver, 2000000, fee=BANK_ACCOUNT_FEE 66 | ) 67 | assert len(transaction_id) > 48 68 | 69 | def test_bank_contract_fee_failed_transaction(self): 70 | """Transaction should fail when the fee is greater than BANK_ACCOUNT_FEE.""" 71 | fee = BANK_ACCOUNT_FEE + 1000 72 | logic_sig, escrow_address, receiver = self._create_bank_contract() 73 | with pytest.raises(AlgodHTTPError) as exception: 74 | create_bank_transaction( 75 | logic_sig, escrow_address, receiver, 2000000, fee=fee 76 | ) 77 | assert "rejected by logic" in str(exception.value) 78 | 79 | def test_bank_contract_raises_error_for_wrong_receiver(self): 80 | """Transaction should fail for a wrong receiver.""" 81 | _, other_receiver = add_standalone_account() 82 | 83 | logic_sig, escrow_address, _ = self._create_bank_contract() 84 | with pytest.raises(AlgodHTTPError) as exception: 85 | create_bank_transaction(logic_sig, escrow_address, other_receiver, 2000000) 86 | assert "rejected by logic" in str(exception.value) 87 | 88 | @pytest.mark.parametrize( 89 | "amount", 90 | [1000000, 500000, 504213, 2500000], 91 | ) 92 | def test_bank_contract_balances_of_involved_accounts(self, amount): 93 | """After successful transaction, balance of involved accounts should pass 94 | 95 | assertions to result of expressions calculated for the provided amount. 96 | """ 97 | logic_sig, escrow_address, receiver = self._create_bank_contract( 98 | fee=BANK_ACCOUNT_FEE 99 | ) 100 | escrow_balance = account_balance(escrow_address) 101 | create_bank_transaction(logic_sig, escrow_address, receiver, amount) 102 | 103 | assert account_balance(receiver) == amount 104 | assert ( 105 | account_balance(escrow_address) 106 | == escrow_balance - amount - BANK_ACCOUNT_FEE 107 | ) 108 | 109 | def test_bank_contract_transaction(self): 110 | """Successful transaction should have sender equal to escrow account. 111 | 112 | Also, the transaction type should be payment, payment receiver should be 113 | contract's receiver, and the payment amount should be equal to provided amount. 114 | Finally, there should be no group field in transaction. 115 | """ 116 | amount = 1000000 117 | logic_sig, escrow_address, receiver = self._create_bank_contract( 118 | fee=BANK_ACCOUNT_FEE 119 | ) 120 | transaction_id = create_bank_transaction( 121 | logic_sig, escrow_address, receiver, amount 122 | ) 123 | transaction = transaction_info(transaction_id) 124 | assert transaction.get("transaction").get("tx-type") == constants.payment_txn 125 | assert transaction.get("transaction").get("sender") == escrow_address 126 | assert ( 127 | transaction.get("transaction").get("payment-transaction").get("receiver") 128 | == receiver 129 | ) 130 | assert ( 131 | transaction.get("transaction").get("payment-transaction").get("amount") 132 | == amount 133 | ) 134 | assert transaction.get("transaction").get("group", None) is None 135 | 136 | 137 | class TestSplitContract: 138 | """Class for testing the split smart contract.""" 139 | 140 | def setup_method(self): 141 | """Create owner and receivers accounts before each test.""" 142 | _, self.owner = add_standalone_account() 143 | _, self.receiver_1 = add_standalone_account() 144 | _, self.receiver_2 = add_standalone_account() 145 | 146 | def _create_split_contract(self, **kwargs): 147 | """Helper method for creating a split contract from pre-existing accounts 148 | 149 | and provided named arguments. 150 | """ 151 | return setup_split_contract( 152 | owner=self.owner, 153 | receiver_1=self.receiver_1, 154 | receiver_2=self.receiver_2, 155 | **kwargs, 156 | ) 157 | 158 | def test_split_contract_creates_new_accounts(self): 159 | """Contract creation function `setup_split_contract` should create new accounts 160 | 161 | if existing are not provided to it. 162 | """ 163 | contract = setup_split_contract() 164 | assert contract.owner != self.owner 165 | assert contract.receiver_1 != self.receiver_1 166 | assert contract.receiver_2 != self.receiver_2 167 | 168 | def test_split_contract_uses_existing_accounts_when_they_are_provided(self): 169 | """Provided accounts should be used in the smart contract.""" 170 | contract = self._create_split_contract() 171 | assert contract.owner == self.owner 172 | assert contract.receiver_1 == self.receiver_1 173 | assert contract.receiver_2 == self.receiver_2 174 | 175 | def test_split_contract_min_pay(self): 176 | """Transaction should be created when the split amount for receiver_1 177 | 178 | is greater than `min_pay`. 179 | """ 180 | min_pay = 250000 181 | contract = self._create_split_contract(min_pay=min_pay, rat_1=1, rat_2=3) 182 | amount = 2000000 183 | create_split_transaction(contract, amount) 184 | assert account_balance(contract.receiver_1) > min_pay 185 | 186 | def test_split_contract_min_pay_failed_transaction(self): 187 | """Transaction should fail when the split amount for receiver_1 188 | 189 | is less than `min_pay`. 190 | """ 191 | min_pay = 300000 192 | contract = self._create_split_contract(min_pay=min_pay, rat_1=1, rat_2=3) 193 | amount = 1000000 194 | 195 | with pytest.raises(TemplateInputError) as exception: 196 | create_split_transaction(contract, amount) 197 | assert ( 198 | str(exception.value) 199 | == f"the amount paid to receiver_1 must be greater than {min_pay}" 200 | ) 201 | 202 | def test_split_contract_max_fee_failed_transaction(self): 203 | """Transaction should fail for the fee greater than `max_fee`.""" 204 | max_fee = 500 205 | contract = self._create_split_contract(max_fee=max_fee, rat_1=1, rat_2=3) 206 | amount = 1000000 207 | 208 | with pytest.raises(TemplateInputError) as exception: 209 | create_split_transaction(contract, amount) 210 | assert ( 211 | str(exception.value) 212 | == f"the transaction fee should not be greater than {max_fee}" 213 | ) 214 | 215 | @pytest.mark.parametrize( 216 | "amount,rat_1,rat_2", 217 | [ 218 | (1000000, 1, 2), 219 | (1000033, 1, 3), 220 | (1000000, 2, 5), 221 | ], 222 | ) 223 | def test_split_contract_invalid_ratios_for_amount(self, amount, rat_1, rat_2): 224 | """Transaction should fail for every combination of provided amount and ratios.""" 225 | contract = self._create_split_contract(rat_1=rat_1, rat_2=rat_2) 226 | with pytest.raises(TemplateInputError) as exception: 227 | create_split_transaction(contract, amount) 228 | assert ( 229 | str(exception.value) 230 | == f"the specified amount cannot be split into two parts with the ratio {rat_1}/{rat_2}" 231 | ) 232 | 233 | @pytest.mark.parametrize( 234 | "amount,rat_1,rat_2", 235 | [ 236 | (1000000, 1, 3), 237 | (999999, 1, 2), 238 | (1400000, 2, 5), 239 | (1000000, 1, 9), 240 | (900000, 4, 5), 241 | (1200000, 5, 1), 242 | ], 243 | ) 244 | def test_split_contract_balances_of_involved_accounts(self, amount, rat_1, rat_2): 245 | """After successful transaction, balance of involved accounts should pass 246 | 247 | assertion to result of expressions calculated from the provided arguments. 248 | """ 249 | contract = self._create_split_contract(rat_1=rat_1, rat_2=rat_2) 250 | assert account_balance(contract.owner) == 0 251 | assert account_balance(contract.receiver_1) == 0 252 | assert account_balance(contract.receiver_2) == 0 253 | 254 | escrow = contract.get_address() 255 | escrow_balance = account_balance(escrow) 256 | 257 | create_split_transaction(contract, amount) 258 | assert account_balance(contract.owner) == 0 259 | assert account_balance(contract.receiver_1) == rat_1 * amount / (rat_1 + rat_2) 260 | assert account_balance(contract.receiver_2) == rat_2 * amount / (rat_1 + rat_2) 261 | assert account_balance(escrow) == escrow_balance - amount - contract.max_fee 262 | 263 | 264 | def test_split_contract_transaction(self): 265 | """Successful transaction should have sender equal to escrow account. 266 | 267 | Also, receiver should be contract's receiver_1, the type should be payment, 268 | and group should be a valid address. 269 | """ 270 | contract = setup_split_contract() 271 | transaction_id = create_split_transaction(contract, 1000000) 272 | transaction = transaction_info(transaction_id) 273 | assert transaction.get("transaction").get("tx-type") == constants.payment_txn 274 | assert transaction.get("transaction").get("sender") == contract.get_address() 275 | assert ( 276 | transaction.get("transaction").get("payment-transaction").get("receiver") 277 | == contract.receiver_1 278 | ) 279 | assert is_valid_address( 280 | encode_address( 281 | base64.b64decode(transaction.get("transaction").get("group")) 282 | ) 283 | ) 284 | --------------------------------------------------------------------------------