diff --git a/CHANGELOG.md b/CHANGELOG.md index 667932e..8d2f900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This changelog format is based on [Keep a Changelog](https://keepachangelog.com/ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased](https://github.com/iamdefinitelyahuman/brownie-token-tester) +- Use Jinja2 for templating ERC20 contract, add support for eip-2612 permit function ## [0.3.2](https://github.com/iamdefinitelyahuman/brownie-token-tester/tree/v0.3.2) - 2021-07-06 ### Fixed diff --git a/README.md b/README.md index 7eb7c76..24bcfe4 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,14 @@ def ERC20( decimals: int = 18, success: Union[bool, None] = True, fail: Union[bool, str, None] = "revert", + use_eip2612: bool = False, ) -> Contract: ``` - The `success` kwarg is used to set the token's return value upon a successful call to `approve`, `transfer` or `transferFrom`. Valid values are `True`, `False`, and `None`. - The `fail` kwarg sets the token's behaviour upon failed calls to the above methods. Use `"revert"` if the transaction should revert, or `True`, `False`, and `None` to return a value without reverting. +- The `use_eip2612` kwarg determines whether the token should support the `permit` function defined in `eip-2612` + The resulting deployment adheres to the [ERC20 Token Standard](https://eips.ethereum.org/EIPS/eip-20) and additionally implements one non-standard method: diff --git a/brownie_tokens/template.py b/brownie_tokens/template.py index ace81f6..0d59c2b 100644 --- a/brownie_tokens/template.py +++ b/brownie_tokens/template.py @@ -1,44 +1,23 @@ from brownie import Contract, compile_source from brownie.network.account import Account -from pathlib import Path -from typing import Dict, Union +from jinja2 import Environment, PackageLoader +from typing import Union -RETURN_TYPE: Dict = { - True: " -> bool", - False: " -> bool", - None: "", -} - -RETURN_STATEMENT: Dict = { - True: "return True", - False: "return False", - None: "return", -} - -FAIL_STATEMENT: Dict = { - "revert": "raise", - True: "return True", - False: "return False", - None: "return", -} - -STRING_CONVERT: Dict = { - "true": True, - "false": False, - "none": None, -} - -with Path(__file__).parent.joinpath("token-template.vy").open() as fp: - TEMPLATE = fp.read() +env = Environment( + trim_blocks=True, + lstrip_blocks=True, + loader=PackageLoader("brownie_tokens"), +) def ERC20( name: str = "Test Token", symbol: str = "TST", decimals: int = 18, - success: Union[bool, str, None] = True, + success: Union[bool, None] = True, fail: Union[bool, str, None] = "revert", deployer: Union[Account, str, None] = None, + use_eip2612: bool = False, ) -> Contract: """ Deploy an ERC20 contract for testing purposes. @@ -58,36 +37,36 @@ def ERC20( to make the transaction revert. deployer: Account | str, optional Address to deploy the contract from. + use_eip2612: bool, optional + Include EIP-2612 (permit) support in the resulting contract Returns ------- Contract Deployed ERC20 contract """ - # understand success and fail when given as strings - if isinstance(success, str) and success.lower() in STRING_CONVERT: - success = STRING_CONVERT[success.lower()] - if isinstance(fail, str) and fail.lower() in STRING_CONVERT: - fail = STRING_CONVERT[fail.lower()] + # input validation + if not (isinstance(success, bool) or success is None): + raise TypeError(f"Argument `success` has invalid type: '{type(success)}'") + if not (isinstance(fail, (bool, str)) or fail is None): + raise TypeError(f"Argument `fail` has invalid type: '{type(fail)}'") + if isinstance(fail, str) and fail.lower() != "revert": + raise ValueError(f"Argument `fail` has invalid value: '{fail}'") + + if success is None and isinstance(fail, bool) or fail is None and isinstance(success, bool): + raise ValueError( + "Return values for `success` and `fail` are incompatible: " + + f"success=`{success}`, fail=`{fail}`" + ) - if success not in RETURN_STATEMENT: - valid_keys = [str(i) for i in RETURN_STATEMENT.keys()] - raise ValueError(f"Invalid value for `success`, valid options are: {', '.join(valid_keys)}") - if fail not in FAIL_STATEMENT: - valid_keys = [str(i) for i in FAIL_STATEMENT.keys()] - raise ValueError(f"Invalid value for `fail`, valid options are: {', '.join(valid_keys)}") - if None in (fail, success) and fail is not success and fail != "revert": - raise ValueError("Cannot use `None` for only one of `success` and `fail`.") + # fetch and render template + template = env.get_template("ERC20.vy") + src = template.render(retval=success, failval=fail, use_eip2612=use_eip2612) - source = TEMPLATE.format( - return_type=RETURN_TYPE[success], - return_statement=RETURN_STATEMENT[success], - fail_statement=FAIL_STATEMENT[fail], - ) - contract = compile_source(source, vyper_version="0.2.12").Vyper + ERC20 = compile_source(src, vyper_version="0.3.1").Vyper - if deployer is None: - tx_params = {"from": "0x0000000000000000000000000000000000001337", "silent": True} - else: - tx_params = {"from": deployer} - return contract.deploy(name, symbol, decimals, tx_params) + tx_params = { + "from": deployer or "0x0000000000000000000000000000000000001337", + "silent": deployer is not None, + } + return ERC20.deploy(name, symbol, decimals, tx_params) diff --git a/brownie_tokens/templates/ERC20.vy b/brownie_tokens/templates/ERC20.vy new file mode 100644 index 0000000..934f4e2 --- /dev/null +++ b/brownie_tokens/templates/ERC20.vy @@ -0,0 +1,256 @@ +{% macro return_type(retval) %}{% if retval is boolean %} -> bool {%- endif %}{% endmacro -%} + +{% macro return_statement(retval) %} +{% if retval is true %} +return True +{% elif retval is false %} +return False +{% else %} +return +{% endif %} +{% endmacro -%} + +{# failval should be either boolean or string #} +{# from should be either `msg.sender` or `_from` #} +{% macro validate_transfer(failval, from) %} +{% if failval is boolean or failval is none %} +user_balance: uint256 = self.balanceOf[{{ from }}] +if user_balance < _value: + {% if failval is true %} + return True + {% elif failval is false %} + return False + {% else %} + return + {% endif %} + +self.balanceOf[{{ from }}] = user_balance - _value +{% else %} +self.balanceOf[{{ from }}] -= _value +{% endif %} +{% endmacro -%} + +# @version 0.3.1 +""" +@notice Mock ERC20 +@dev See https://eips.ethereum.org/EIPS/eip-20 +""" +from vyper.interfaces import ERC20 + +implements: ERC20 + + +event Approval: + _owner: indexed(address) + _spender: indexed(address) + _value: uint256 + +event Transfer: + _from: indexed(address) + _to: indexed(address) + _value: uint256 + + +{% if use_eip2612 is true %} +EIP712_TYPEHASH: constant(bytes32) = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") +PERMIT_TYPEHASH: constant(bytes32) = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") +VERSION: constant(String[8]) = "v0.1.0" +{% endif %} + + +{% if use_eip2612 is true %} +DOMAIN_SEPARATOR: immutable(bytes32) +{% endif %} + +NAME: immutable(String[128]) +SYMBOL: immutable(String[64]) +DECIMALS: immutable(uint8) + + +allowance: public(HashMap[address, HashMap[address, uint256]]) +balanceOf: public(HashMap[address, uint256]) +totalSupply: public(uint256) + +{% if use_eip2612 is true %} +nonces: public(HashMap[address, uint256]) +{% endif %} + + +@external +def __init__(_name: String[128], _symbol: String[64], _decimals: uint8): + NAME = _name + SYMBOL = _symbol + DECIMALS = _decimals + + {% if use_eip2612 is true %} + DOMAIN_SEPARATOR = keccak256( + _abi_encode(EIP712_TYPEHASH, keccak256(_name), keccak256(VERSION), chain.id, self) + ) + {% endif %} + + +@external +def approve(_spender: address, _value: uint256){{ return_type(retval) }}: + """ + @notice Allow `_spender` to transfer/withdraw from your account multiple times, up to `_value` + amount. + @dev If this function is called again it overwrites the current allowance with `_value`. + @param _spender The address given permission to transfer/withdraw tokens. + @param _value The amount of tokens that may be transferred. + """ + self.allowance[msg.sender][_spender] = _value + + log Approval(msg.sender, _spender, _value) + {{ return_statement(retval)|trim }} + +{% if use_eip2612 is true %} + +@external +def permit( + _owner: address, + _spender: address, + _value: uint256, + _deadline: uint256, + _v: uint8, + _r: bytes32, + _s: bytes32 +): + """ + @notice Approves spender by owner's signature to expend owner's tokens. + See https://eips.ethereum.org/EIPS/eip-2612. + @param _owner The address which is a source of funds and has signed the Permit. + @param _spender The address which is allowed to spend the funds. + @param _value The amount of tokens to be spent. + @param _deadline The timestamp after which the Permit is no longer valid. + @param _v The bytes[64] of the valid secp256k1 signature of permit by owner + @param _r The bytes[0:32] of the valid secp256k1 signature of permit by owner + @param _s The bytes[32:64] of the valid secp256k1 signature of permit by owner + """ + assert _owner != ZERO_ADDRESS + assert block.timestamp <= _deadline + + nonce: uint256 = self.nonces[_owner] + digest: bytes32 = keccak256( + concat( + b"\x19\x01", + DOMAIN_SEPARATOR, + keccak256(_abi_encode(PERMIT_TYPEHASH, _owner, _spender, _value, nonce, _deadline)) + ) + ) + + assert ecrecover(digest, convert(_v, uint256), convert(_r, uint256), convert(_s, uint256)) == _owner + + self.allowance[_owner][_spender] = _value + self.nonces[_owner] = nonce + 1 + + log Approval(_owner, _spender, _value) + +{% endif %} + +@external +def transfer(_to: address, _value: uint256){{ return_type(retval) }}: + """ + @notice Transfer `_value` amount of tokens to address `_to`. + @dev Reverts if caller's balance does not have enough tokens to spend. + @param _to The address to increase the balance of. + @param _value The amount of tokens to transfer. + """ + {# input validation is handled prior to template rendering #} + {{ validate_transfer(failval, "msg.sender")|indent|trim }} + self.balanceOf[_to] += _value + + log Transfer(msg.sender, _to, _value) + {{ return_statement(retval)|trim }} + + +@external +def transferFrom(_from: address, _to: address, _value: uint256){{ return_type(retval) }}: + """ + @notice Transfers ~_value` amount of tokens from address `_from` to address `_to`. + @dev Reverts if caller's allowance is not enough, or if `_from` address does not have a balance + great enough. + @param _from The account to transfer/withdraw tokens from. + @param _to The account to transfer tokens to. + @param _value The amount of tokens to transfer. + """ + {# input validation is handled prior to template rendering #} + {% if failval is boolean or failval is none %} + allowance: uint256 = self.allowance[_from][msg.sender] + if allowance < _value: + {% if failval is true %} + return True + {% elif failval is false %} + return False + {% else %} + return + {% endif %} + + {{ validate_transfer(failval, "_from")|indent|trim }} + self.balanceOf[_to] += _value + self.allowance[_from][msg.sender] = allowance - _value + {% else %} + {{ validate_transfer(failval, "_from")|indent|trim }} + self.balanceOf[_to] += _value + self.allowance[_from][msg.sender] -= _value + {% endif %} + + log Transfer(_from, _to, _value) + {{ return_statement(retval)|trim }} + + +@external +def _mint_for_testing(_to: address, _value: uint256): + """ + @notice Mint new tokens + """ + self.balanceOf[_to] += _value + self.totalSupply += _value + + log Transfer(ZERO_ADDRESS, _to, _value) + + +@view +@external +def name() -> String[128]: + """ + @notice Query the name of the token. + """ + return NAME + + +@view +@external +def symbol() -> String[64]: + """ + @notice Query the symbol of the token. + """ + return SYMBOL + + +@view +@external +def decimals() -> uint8: + """ + @notice Query the decimals of the token. + """ + return DECIMALS + + +{% if use_eip2612 is true %} +@view +@external +def DOMAIN_SEPARATOR() -> bytes32: + """ + @notice Query the DOMAIN_SEPARATOR immutable value + """ + return DOMAIN_SEPARATOR + + +@view +@external +def version() -> String[8]: + """ + @notice Query the contract version, used for EIP-2612 + """ + return VERSION +{% endif %} diff --git a/brownie_tokens/token-template.vy b/brownie_tokens/token-template.vy deleted file mode 100644 index 9fbaf45..0000000 --- a/brownie_tokens/token-template.vy +++ /dev/null @@ -1,66 +0,0 @@ -# @version 0.2.12 -""" -@notice Mock non-standard ERC20 for testing -""" - -event Transfer: - _from: indexed(address) - _to: indexed(address) - _value: uint256 - -event Approval: - _owner: indexed(address) - _spender: indexed(address) - _value: uint256 - - -name: public(String[64]) -symbol: public(String[32]) -decimals: public(uint256) -balanceOf: public(HashMap[address, uint256]) -allowance: public(HashMap[address, HashMap[address, uint256]]) -totalSupply: public(uint256) - - -@external -def __init__(_name: String[64], _symbol: String[32], _decimals: uint256): - self.name = _name - self.symbol = _symbol - self.decimals = _decimals - - -@external -def transfer(_to : address, _value : uint256){return_type}: - if self.balanceOf[msg.sender] < _value: - {fail_statement} - self.balanceOf[msg.sender] -= _value - self.balanceOf[_to] += _value - log Transfer(msg.sender, _to, _value) - {return_statement} - - -@external -def transferFrom(_from : address, _to : address, _value : uint256){return_type}: - if self.balanceOf[_from] < _value: - {fail_statement} - if self.allowance[_from][msg.sender] < _value: - {fail_statement} - self.balanceOf[_from] -= _value - self.balanceOf[_to] += _value - self.allowance[_from][msg.sender] -= _value - log Transfer(_from, _to, _value) - {return_statement} - - -@external -def approve(_spender : address, _value : uint256){return_type}: - self.allowance[msg.sender][_spender] = _value - log Approval(msg.sender, _spender, _value) - {return_statement} - - -@external -def _mint_for_testing(_target: address, _value: uint256): - self.totalSupply += _value - self.balanceOf[_target] += _value - log Transfer(ZERO_ADDRESS, _target, _value) diff --git a/requirements.txt b/requirements.txt index fa3f7ff..13f327c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ eth-brownie>=1.11.6,<2.0.0 +Jinja2>=3.0.3,<4.0.0 diff --git a/tests/conftest.py b/tests/conftest.py index b366110..36b4ae4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,13 +29,22 @@ def pytest_ignore_collect(path, config): return True -@pytest.fixture(scope="function", autouse=True) -def isolation(): +@pytest.fixture(scope="module", autouse=True) +def mod_isolation(): brownie.chain.snapshot() yield brownie.chain.revert() +@pytest.fixture(autouse=True) +def isolation(): + start = len(brownie.history) + yield + end = len(brownie.history) + if end - start > 0: + brownie.chain.undo(end - start) + + @pytest.fixture(scope="session") def alice(): yield brownie.accounts[0] @@ -44,3 +53,13 @@ def alice(): @pytest.fixture(scope="session") def bob(): yield brownie.accounts[1] + + +@pytest.fixture(scope="session") +def charlie(): + yield brownie.accounts[2] + + +@pytest.fixture(scope="session") +def accounts(): + return brownie.accounts diff --git a/tests/unitary/conftest.py b/tests/unitary/conftest.py new file mode 100644 index 0000000..9cecc24 --- /dev/null +++ b/tests/unitary/conftest.py @@ -0,0 +1,23 @@ +import pytest + +from brownie_tokens import ERC20 + + +@pytest.fixture(scope="module", params=(False, True, None)) +def success_retval(request): + return request.param + + +@pytest.fixture(scope="module", params=(False, True, None, "revert")) +def fail_retval(request): + return request.param + + +@pytest.fixture(scope="module") +def token(alice, success_retval, fail_retval): + case_a = success_retval is None and isinstance(fail_retval, bool) + case_b = fail_retval is None and isinstance(success_retval, bool) + if case_a or case_b: + pytest.xfail() + + return ERC20(success=success_retval, fail=fail_retval) diff --git a/tests/unitary/test_approve.py b/tests/unitary/test_approve.py new file mode 100644 index 0000000..22647f9 --- /dev/null +++ b/tests/unitary/test_approve.py @@ -0,0 +1,59 @@ +import pytest + + +@pytest.mark.parametrize("idx", range(5)) +def test_initial_approval_is_zero(alice, accounts, idx, token): + assert token.allowance(alice, accounts[idx]) == 0 + + +def test_approve(alice, bob, token): + token.approve(bob, 10 ** 19, {"from": alice}) + + assert token.allowance(alice, bob) == 10 ** 19 + + +def test_modify_approve_nonzero(alice, bob, token): + token.approve(bob, 10 ** 19, {"from": alice}) + token.approve(bob, 12345678, {"from": alice}) + + assert token.allowance(alice, bob) == 12345678 + + +def test_modify_approve_zero_nonzero(alice, bob, token): + token.approve(bob, 10 ** 19, {"from": alice}) + token.approve(bob, 0, {"from": alice}) + token.approve(bob, 12345678, {"from": alice}) + + assert token.allowance(alice, bob) == 12345678 + + +def test_revoke_approve(alice, bob, token): + token.approve(bob, 10 ** 19, {"from": alice}) + token.approve(bob, 0, {"from": alice}) + + assert token.allowance(alice, bob) == 0 + + +def test_approve_self(alice, bob, token): + token.approve(alice, 10 ** 19, {"from": alice}) + + assert token.allowance(alice, alice) == 10 ** 19 + + +def test_only_affects_target(alice, bob, token): + token.approve(bob, 10 ** 19, {"from": alice}) + + assert token.allowance(bob, alice) == 0 + + +def test_return_value(alice, bob, token, success_retval): + tx = token.approve(bob, 10 ** 19, {"from": alice}) + + assert tx.return_value is success_retval + + +def test_approval_event_fires(alice, bob, token): + tx = token.approve(bob, 10 ** 19, {"from": alice}) + + assert len(tx.events) == 1 + assert tx.events["Approval"].values() == [alice, bob, 10 ** 19] diff --git a/tests/unitary/test_erc20_return_values.py b/tests/unitary/test_erc20_return_values.py deleted file mode 100644 index 2913fee..0000000 --- a/tests/unitary/test_erc20_return_values.py +++ /dev/null @@ -1,83 +0,0 @@ -import itertools -import pytest -from brownie.exceptions import VirtualMachineError - -from brownie_tokens import ERC20 -from brownie_tokens.template import FAIL_STATEMENT, RETURN_STATEMENT - - -@pytest.fixture(scope="module", params=itertools.product(RETURN_STATEMENT, FAIL_STATEMENT)) -def token(request, alice): - success, fail = request.param - if None in (fail, success) and fail is not success: - pytest.skip() - contract = ERC20(success=success, fail=fail) - contract._mint_for_testing(alice, 10 ** 18, {"from": alice}) - yield (contract, success, fail) - - -def test_transfer_success(alice, bob, token): - token, success, fail = token - tx = token.transfer(bob, 0, {"from": alice}) - if success is None: - assert len(tx.return_value) == 0 - else: - assert tx.return_value is success - - -def test_transfer_fail(alice, bob, token): - token, success, fail = token - if fail == "revert": - with pytest.raises(VirtualMachineError): - token.transfer(bob, 10 ** 19, {"from": alice}) - else: - tx = token.transfer(bob, 10 ** 19, {"from": alice}) - if fail is None: - assert len(tx.return_value) == 0 - else: - assert tx.return_value is fail - - -def test_approve(alice, bob, token): - token, success, fail = token - tx = token.approve(alice, 10 ** 21, {"from": bob}) - if success is None: - assert len(tx.return_value) == 0 - else: - assert tx.return_value is success - - -def test_transferFrom_success(alice, bob, token): - token, success, fail = token - tx = token.transferFrom(alice, bob, 0, {"from": alice}) - if success is None: - assert len(tx.return_value) == 0 - else: - assert tx.return_value is success - - -def test_transferFrom_fail_allowance(alice, bob, token): - token, success, fail = token - if fail == "revert": - with pytest.raises(VirtualMachineError): - token.transferFrom(alice, bob, 10 ** 18, {"from": bob}) - else: - tx = token.transferFrom(alice, bob, 10 ** 18, {"from": bob}) - if fail is None: - assert len(tx.return_value) == 0 - else: - assert tx.return_value is fail - - -def test_transferFrom_fail_balance(alice, bob, token): - token, success, fail = token - token.approve(bob, 10 ** 21, {"from": alice}) - if fail == "revert": - with pytest.raises(VirtualMachineError): - token.transferFrom(alice, bob, 10 ** 21, {"from": bob}) - else: - tx = token.transferFrom(alice, bob, 10 ** 21, {"from": bob}) - if fail is None: - assert len(tx.return_value) == 0 - else: - assert tx.return_value is fail diff --git a/tests/unitary/test_transfer.py b/tests/unitary/test_transfer.py new file mode 100644 index 0000000..12f0d55 --- /dev/null +++ b/tests/unitary/test_transfer.py @@ -0,0 +1,89 @@ +import pytest +from brownie.exceptions import VirtualMachineError + + +@pytest.fixture(scope="module", autouse=True) +def setup(alice, token): + token._mint_for_testing(alice, 10 ** 24, {"from": alice}) + + +def test_sender_balance_decreases(alice, bob, token): + sender_balance = token.balanceOf(alice) + amount = sender_balance // 4 + + token.transfer(bob, amount, {"from": alice}) + + assert token.balanceOf(alice) == sender_balance - amount + + +def test_receiver_balance_increases(alice, bob, token): + receiver_balance = token.balanceOf(bob) + amount = token.balanceOf(alice) // 4 + + token.transfer(bob, amount, {"from": alice}) + + assert token.balanceOf(bob) == receiver_balance + amount + + +def test_total_supply_not_affected(alice, bob, token): + total_supply = token.totalSupply() + amount = token.balanceOf(alice) + + token.transfer(bob, amount, {"from": alice}) + + assert token.totalSupply() == total_supply + + +def test_return_value(alice, bob, token, success_retval): + amount = token.balanceOf(alice) + tx = token.transfer(bob, amount, {"from": alice}) + + assert tx.return_value is success_retval + + +def test_transfer_full_balance(alice, bob, token): + amount = token.balanceOf(alice) + receiver_balance = token.balanceOf(bob) + + token.transfer(bob, amount, {"from": alice}) + + assert token.balanceOf(alice) == 0 + assert token.balanceOf(bob) == receiver_balance + amount + + +def test_transfer_zero_tokens(alice, bob, token): + sender_balance = token.balanceOf(alice) + receiver_balance = token.balanceOf(bob) + + token.transfer(bob, 0, {"from": alice}) + + assert token.balanceOf(alice) == sender_balance + assert token.balanceOf(bob) == receiver_balance + + +def test_transfer_to_self(alice, bob, token): + sender_balance = token.balanceOf(alice) + amount = sender_balance // 4 + + token.transfer(alice, amount, {"from": alice}) + + assert token.balanceOf(alice) == sender_balance + + +def test_insufficient_balance(alice, bob, token, fail_retval): + balance = token.balanceOf(alice) + + if fail_retval == "revert": + with pytest.raises(VirtualMachineError): + token.transfer(bob, balance + 1, {"from": alice}) + else: + tx = token.transfer(bob, balance + 1, {"from": alice}) + assert tx.return_value is fail_retval + + +def test_transfer_event_fires(alice, bob, token): + amount = token.balanceOf(alice) + tx = token.transfer(bob, amount, {"from": alice}) + + assert len(tx.events) == 1 + assert tx.events["Transfer"].values() == [alice, bob, amount] diff --git a/tests/unitary/test_transfer_from.py b/tests/unitary/test_transfer_from.py new file mode 100644 index 0000000..cd4f5ae --- /dev/null +++ b/tests/unitary/test_transfer_from.py @@ -0,0 +1,189 @@ +import pytest +from brownie.exceptions import VirtualMachineError + + +@pytest.fixture(scope="module", autouse=True) +def setup(alice, token): + token._mint_for_testing(alice, 10 ** 24, {"from": alice}) + + +def test_sender_balance_decreases(alice, bob, charlie, token): + sender_balance = token.balanceOf(alice) + amount = sender_balance // 4 + + token.approve(bob, amount, {"from": alice}) + token.transferFrom(alice, charlie, amount, {"from": bob}) + + assert token.balanceOf(alice) == sender_balance - amount + + +def test_receiver_balance_increases(alice, bob, charlie, token): + receiver_balance = token.balanceOf(charlie) + amount = token.balanceOf(alice) // 4 + + token.approve(bob, amount, {"from": alice}) + token.transferFrom(alice, charlie, amount, {"from": bob}) + + assert token.balanceOf(charlie) == receiver_balance + amount + + +def test_caller_balance_not_affected(alice, bob, charlie, token): + caller_balance = token.balanceOf(bob) + amount = token.balanceOf(alice) + + token.approve(bob, amount, {"from": alice}) + token.transferFrom(alice, charlie, amount, {"from": bob}) + + assert token.balanceOf(bob) == caller_balance + + +def test_caller_approval_affected(alice, bob, charlie, token): + approval_amount = token.balanceOf(alice) + transfer_amount = approval_amount // 4 + + token.approve(bob, approval_amount, {"from": alice}) + token.transferFrom(alice, charlie, transfer_amount, {"from": bob}) + + assert token.allowance(alice, bob) == approval_amount - transfer_amount + + +def test_receiver_approval_not_affected(alice, bob, charlie, token): + approval_amount = token.balanceOf(alice) + transfer_amount = approval_amount // 4 + + token.approve(bob, approval_amount, {"from": alice}) + token.approve(charlie, approval_amount, {"from": alice}) + token.transferFrom(alice, charlie, transfer_amount, {"from": bob}) + + assert token.allowance(alice, charlie) == approval_amount + + +def test_total_supply_not_affected(alice, bob, charlie, token): + total_supply = token.totalSupply() + amount = token.balanceOf(alice) + + token.approve(bob, amount, {"from": alice}) + token.transferFrom(alice, charlie, amount, {"from": bob}) + + assert token.totalSupply() == total_supply + + +def test_return_value(alice, bob, charlie, token, success_retval): + amount = token.balanceOf(alice) + token.approve(bob, amount, {"from": alice}) + tx = token.transferFrom(alice, charlie, amount, {"from": bob}) + + assert tx.return_value is success_retval + + +def test_transfer_full_balance(alice, bob, charlie, token): + amount = token.balanceOf(alice) + receiver_balance = token.balanceOf(charlie) + + token.approve(bob, amount, {"from": alice}) + token.transferFrom(alice, charlie, amount, {"from": bob}) + + assert token.balanceOf(alice) == 0 + assert token.balanceOf(charlie) == receiver_balance + amount + + +def test_transfer_zero_tokens(alice, bob, charlie, token): + sender_balance = token.balanceOf(alice) + receiver_balance = token.balanceOf(charlie) + + token.approve(bob, sender_balance, {"from": alice}) + token.transferFrom(alice, charlie, 0, {"from": bob}) + + assert token.balanceOf(alice) == sender_balance + assert token.balanceOf(charlie) == receiver_balance + + +def test_transfer_zero_tokens_without_approval(alice, bob, charlie, token): + sender_balance = token.balanceOf(alice) + receiver_balance = token.balanceOf(charlie) + + token.transferFrom(alice, charlie, 0, {"from": bob}) + + assert token.balanceOf(alice) == sender_balance + assert token.balanceOf(charlie) == receiver_balance + + +def test_insufficient_balance(alice, bob, charlie, token, fail_retval): + balance = token.balanceOf(alice) + + token.approve(bob, balance + 1, {"from": alice}) + if fail_retval == "revert": + with pytest.raises(VirtualMachineError): + token.transferFrom(alice, charlie, balance + 1, {"from": bob}) + else: + tx = token.transferFrom(alice, charlie, balance + 1, {"from": bob}) + assert tx.return_value is fail_retval + + +def test_insufficient_approval(alice, bob, charlie, token, fail_retval): + balance = token.balanceOf(alice) + + token.approve(bob, balance - 1, {"from": alice}) + if fail_retval == "revert": + with pytest.raises(VirtualMachineError): + token.transferFrom(alice, charlie, balance, {"from": bob}) + else: + tx = token.transferFrom(alice, charlie, balance, {"from": bob}) + assert tx.return_value is fail_retval + + +def test_no_approval(alice, bob, charlie, token, fail_retval): + balance = token.balanceOf(alice) + + if fail_retval == "revert": + with pytest.raises(VirtualMachineError): + token.transferFrom(alice, charlie, balance, {"from": bob}) + else: + tx = token.transferFrom(alice, charlie, balance, {"from": bob}) + assert tx.return_value is fail_retval + + +def test_revoked_approval(alice, bob, charlie, token, fail_retval): + balance = token.balanceOf(alice) + + token.approve(bob, balance, {"from": alice}) + token.approve(bob, 0, {"from": alice}) + + if fail_retval == "revert": + with pytest.raises(VirtualMachineError): + token.transferFrom(alice, charlie, balance, {"from": bob}) + else: + tx = token.transferFrom(alice, charlie, balance, {"from": bob}) + assert tx.return_value is fail_retval + + +def test_transfer_to_self(alice, bob, token): + sender_balance = token.balanceOf(alice) + amount = sender_balance // 4 + + token.approve(alice, sender_balance, {"from": alice}) + token.transferFrom(alice, alice, amount, {"from": alice}) + + assert token.balanceOf(alice) == sender_balance + assert token.allowance(alice, alice) == sender_balance - amount + + +def test_transfer_to_self_no_approval(alice, bob, token, fail_retval): + balance = token.balanceOf(alice) + + if fail_retval == "revert": + with pytest.raises(VirtualMachineError): + token.transferFrom(alice, alice, balance, {"from": alice}) + else: + tx = token.transferFrom(alice, alice, balance, {"from": alice}) + assert tx.return_value is fail_retval + + +def test_transfer_event_fires(alice, bob, charlie, token): + balance = token.balanceOf(alice) + + token.approve(bob, balance, {"from": alice}) + tx = token.transferFrom(alice, charlie, balance, {"from": bob}) + + assert len(tx.events) == 1 + assert tx.events["Transfer"].values() == [alice, charlie, balance]