From 0f9fa29b5164626ef2579ce9e5daa6b2e175f3b6 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Wed, 22 Dec 2021 01:45:34 +0000 Subject: [PATCH 01/18] chore: add Jinja2 to requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) 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 From 1f7b55e74f0912c1532c8a70bdf491b4e5cf6a4d Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Wed, 22 Dec 2021 01:50:30 +0000 Subject: [PATCH 02/18] chore: mv token-template.vy -> templates/ERC20.vy --- brownie_tokens/{token-template.vy => templates/ERC20.vy} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename brownie_tokens/{token-template.vy => templates/ERC20.vy} (100%) diff --git a/brownie_tokens/token-template.vy b/brownie_tokens/templates/ERC20.vy similarity index 100% rename from brownie_tokens/token-template.vy rename to brownie_tokens/templates/ERC20.vy From 218f0cb25e8c77733785701247c6fdb9afcf0ee7 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Wed, 22 Dec 2021 03:00:23 +0000 Subject: [PATCH 03/18] fix: rewrite ERC20 template (follow spec) --- brownie_tokens/templates/ERC20.vy | 112 +++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 33 deletions(-) diff --git a/brownie_tokens/templates/ERC20.vy b/brownie_tokens/templates/ERC20.vy index 9fbaf45..778fbfc 100644 --- a/brownie_tokens/templates/ERC20.vy +++ b/brownie_tokens/templates/ERC20.vy @@ -1,66 +1,112 @@ -# @version 0.2.12 +# @version >=0.3.1 """ -@notice Mock non-standard ERC20 for testing +@notice Mock ERC20 +@dev See https://eips.ethereum.org/EIPS/eip-20 """ +from vyper.interfaces import ERC20 + +implements: ERC20 -event Transfer: - _from: indexed(address) - _to: indexed(address) - _value: uint256 event Approval: _owner: indexed(address) _spender: indexed(address) _value: uint256 +event Transfer: + _from: indexed(address) + _to: indexed(address) + _value: uint256 + + +NAME: immutable(String[128]) +SYMBOL: immutable(String[64]) +DECIMALS: immutable(uint8) + -name: public(String[64]) -symbol: public(String[32]) -decimals: public(uint256) -balanceOf: public(HashMap[address, uint256]) allowance: public(HashMap[address, HashMap[address, uint256]]) +balanceOf: public(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 +def __init__(_name: String[128], _symbol: String[64], _decimals: uint8): + NAME = _name + SYMBOL = _symbol + DECIMALS = _decimals + + +@external +def approve(_spender: address, _value: uint256) -> bool: + """ + @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 True @external -def transfer(_to : address, _value : uint256){return_type}: - if self.balanceOf[msg.sender] < _value: - {fail_statement} +def transfer(_to: address, _value: uint256) -> bool: + """ + @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. + """ self.balanceOf[msg.sender] -= _value self.balanceOf[_to] += _value + log Transfer(msg.sender, _to, _value) - {return_statement} + return True @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} +def transferFrom(_from: address, _to: address, _value: uint256) -> bool: + """ + @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. + """ + self.allowance[_from][msg.sender] -= _value + self.balanceOf[_from] -= _value self.balanceOf[_to] += _value - self.allowance[_from][msg.sender] -= _value + log Transfer(_from, _to, _value) - {return_statement} + return True +@view @external -def approve(_spender : address, _value : uint256){return_type}: - self.allowance[msg.sender][_spender] = _value - log Approval(msg.sender, _spender, _value) - {return_statement} +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 _mint_for_testing(_target: address, _value: uint256): - self.totalSupply += _value - self.balanceOf[_target] += _value - log Transfer(ZERO_ADDRESS, _target, _value) +def decimals() -> uint8: + """ + @notice Query the decimals of the token. + """ + return DECIMALS From fcbea89fdb2a260817b37afb0a27ab69317ade6d Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Wed, 22 Dec 2021 06:09:51 +0000 Subject: [PATCH 04/18] fix: use jinja to template out return stmts + return values --- brownie_tokens/templates/ERC20.vy | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/brownie_tokens/templates/ERC20.vy b/brownie_tokens/templates/ERC20.vy index 778fbfc..0ef892b 100644 --- a/brownie_tokens/templates/ERC20.vy +++ b/brownie_tokens/templates/ERC20.vy @@ -1,4 +1,14 @@ -# @version >=0.3.1 +{% 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 %} +# @version 0.3.1 """ @notice Mock ERC20 @dev See https://eips.ethereum.org/EIPS/eip-20 @@ -37,7 +47,7 @@ def __init__(_name: String[128], _symbol: String[64], _decimals: uint8): @external -def approve(_spender: address, _value: uint256) -> bool: +def approve(_spender: address, _value: uint256){{ return_type(retval) }}: """ @notice Allow `_spender` to transfer/withdraw from your account multiple times, up to `_value` amount. @@ -48,11 +58,11 @@ def approve(_spender: address, _value: uint256) -> bool: self.allowance[msg.sender][_spender] = _value log Approval(msg.sender, _spender, _value) - return True + {{ return_statement(retval) }} @external -def transfer(_to: address, _value: uint256) -> bool: +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. @@ -63,11 +73,11 @@ def transfer(_to: address, _value: uint256) -> bool: self.balanceOf[_to] += _value log Transfer(msg.sender, _to, _value) - return True + {{ return_statement(retval) }} @external -def transferFrom(_from: address, _to: address, _value: uint256) -> bool: +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 @@ -82,7 +92,7 @@ def transferFrom(_from: address, _to: address, _value: uint256) -> bool: self.balanceOf[_to] += _value log Transfer(_from, _to, _value) - return True + {{ return_statement(retval) }} @view From c49f2ee69499389ce5f9d728bf5a1460f158d652 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Wed, 22 Dec 2021 06:22:39 +0000 Subject: [PATCH 05/18] fix: modify ERC20 fn use jinja --- brownie_tokens/template.py | 84 ++++++++++++-------------------------- 1 file changed, 27 insertions(+), 57 deletions(-) diff --git a/brownie_tokens/template.py b/brownie_tokens/template.py index ace81f6..a649c18 100644 --- a/brownie_tokens/template.py +++ b/brownie_tokens/template.py @@ -1,42 +1,20 @@ 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, ) -> Contract: @@ -64,30 +42,22 @@ def ERC20( 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()] - - 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`.") - - 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 - - if deployer is None: - tx_params = {"from": "0x0000000000000000000000000000000000001337", "silent": True} - else: - tx_params = {"from": deployer} - return contract.deploy(name, symbol, decimals, tx_params) + # 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}'") + + # fetch and render template + template = env.get_template("ERC20.vy") + src = template.render(retval=success, failval=fail) + + ERC20 = compile_source(src, vyper_version="0.3.1").Vyper + + tx_params = { + "from": deployer or "0x0000000000000000000000000000000000001337", + "silent": deployer is not None, + } + return ERC20.deploy(name, symbol, decimals, tx_params) From 95620dbbccc38fcee593fd630b3f465d31f119ef Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Wed, 22 Dec 2021 07:39:43 +0000 Subject: [PATCH 06/18] fix: handle fail value templating --- brownie_tokens/templates/ERC20.vy | 53 +++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/brownie_tokens/templates/ERC20.vy b/brownie_tokens/templates/ERC20.vy index 0ef892b..4981bb5 100644 --- a/brownie_tokens/templates/ERC20.vy +++ b/brownie_tokens/templates/ERC20.vy @@ -1,4 +1,5 @@ -{% macro return_type(retval) %}{% if retval is boolean %} -> bool {%- endif %}{% endmacro %} +{% macro return_type(retval) %}{% if retval is boolean %} -> bool {%- endif %}{% endmacro -%} + {% macro return_statement(retval) %} {% if retval is true %} return True @@ -7,7 +8,26 @@ return False {% else %} return {% endif %} -{% endmacro %} +{% 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 %} +user_balance: uint256 = self.balanceOf[{{ from }}] +if user_balance < _value: + {% if failval is true %} + return True + {% else %} + return False + {% endif %} + +self.balanceOf[{{ from }}] = user_balance - _value +{% else %} +self.balanceOf[{{ from }}] -= _value +{% endif %} +{% endmacro -%} + # @version 0.3.1 """ @notice Mock ERC20 @@ -58,7 +78,7 @@ def approve(_spender: address, _value: uint256){{ return_type(retval) }}: self.allowance[msg.sender][_spender] = _value log Approval(msg.sender, _spender, _value) - {{ return_statement(retval) }} + {{ return_statement(retval)|trim }} @external @@ -69,11 +89,12 @@ def transfer(_to: address, _value: uint256){{ return_type(retval) }}: @param _to The address to increase the balance of. @param _value The amount of tokens to transfer. """ - self.balanceOf[msg.sender] -= _value + {# 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) }} + {{ return_statement(retval)|trim }} @external @@ -86,13 +107,27 @@ def transferFrom(_from: address, _to: address, _value: uint256){{ return_type(re @param _to The account to transfer tokens to. @param _value The amount of tokens to transfer. """ - self.allowance[_from][msg.sender] -= _value - - self.balanceOf[_from] -= _value + {# input validation is handled prior to template rendering #} + {% if failval is boolean %} + allowance: uint256 = self.allowance[msg.sender] + if allowance < _value: + {% if failval is true %} + return True + {% else %} + return False + {% endif %} + + {{ validate_transfer(failval, "_from")|indent|trim }} + self.balanceOf[_to] += _value + self.allowance[msg.sender] = allowance - _value + {% else %} + {{ validate_transfer(failval, "_from")|indent|trim }} self.balanceOf[_to] += _value + self.allowance[msg.sender] -= _value + {% endif %} log Transfer(_from, _to, _value) - {{ return_statement(retval) }} + {{ return_statement(retval)|trim }} @view From 373f371a1009aa70663a07a1db5a2391f27f437d Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Wed, 22 Dec 2021 07:44:29 +0000 Subject: [PATCH 07/18] fix: add `_mint_for_testing` fn --- brownie_tokens/templates/ERC20.vy | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/brownie_tokens/templates/ERC20.vy b/brownie_tokens/templates/ERC20.vy index 4981bb5..75a2a29 100644 --- a/brownie_tokens/templates/ERC20.vy +++ b/brownie_tokens/templates/ERC20.vy @@ -130,6 +130,17 @@ def transferFrom(_from: address, _to: address, _value: uint256){{ return_type(re {{ 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]: From 711a63cc9b1a1f632b2c6b55ce19f9ea5e678ced Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Wed, 22 Dec 2021 08:06:49 +0000 Subject: [PATCH 08/18] fix: add eip2612 support --- brownie_tokens/template.py | 5 +- brownie_tokens/templates/ERC20.vy | 90 +++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/brownie_tokens/template.py b/brownie_tokens/template.py index a649c18..72afed5 100644 --- a/brownie_tokens/template.py +++ b/brownie_tokens/template.py @@ -17,6 +17,7 @@ def ERC20( 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. @@ -36,6 +37,8 @@ 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 ------- @@ -52,7 +55,7 @@ def ERC20( # fetch and render template template = env.get_template("ERC20.vy") - src = template.render(retval=success, failval=fail) + src = template.render(retval=success, failval=fail, use_eip2612=use_eip2612) ERC20 = compile_source(src, vyper_version="0.3.1").Vyper diff --git a/brownie_tokens/templates/ERC20.vy b/brownie_tokens/templates/ERC20.vy index 75a2a29..a0bd124 100644 --- a/brownie_tokens/templates/ERC20.vy +++ b/brownie_tokens/templates/ERC20.vy @@ -49,6 +49,17 @@ event Transfer: _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) @@ -58,6 +69,10 @@ 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): @@ -65,6 +80,12 @@ def __init__(_name: String[128], _symbol: String[64], _decimals: uint8): 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) }}: @@ -80,6 +101,49 @@ def approve(_spender: address, _value: uint256){{ return_type(retval) }}: 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) }}: @@ -109,7 +173,7 @@ def transferFrom(_from: address, _to: address, _value: uint256){{ return_type(re """ {# input validation is handled prior to template rendering #} {% if failval is boolean %} - allowance: uint256 = self.allowance[msg.sender] + allowance: uint256 = self.allowance[_from][msg.sender] if allowance < _value: {% if failval is true %} return True @@ -119,11 +183,11 @@ def transferFrom(_from: address, _to: address, _value: uint256){{ return_type(re {{ validate_transfer(failval, "_from")|indent|trim }} self.balanceOf[_to] += _value - self.allowance[msg.sender] = allowance - _value + self.allowance[_from][msg.sender] = allowance - _value {% else %} {{ validate_transfer(failval, "_from")|indent|trim }} self.balanceOf[_to] += _value - self.allowance[msg.sender] -= _value + self.allowance[_from][msg.sender] -= _value {% endif %} log Transfer(_from, _to, _value) @@ -166,3 +230,23 @@ 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 %} From d61704140864a30d3b072746f551102e5dc5ec61 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 03:13:44 +0000 Subject: [PATCH 09/18] test: add conftest to unitary dir + rm single test file --- tests/unitary/conftest.py | 20 ++++++ tests/unitary/test_erc20_return_values.py | 83 ----------------------- 2 files changed, 20 insertions(+), 83 deletions(-) create mode 100644 tests/unitary/conftest.py delete mode 100644 tests/unitary/test_erc20_return_values.py diff --git a/tests/unitary/conftest.py b/tests/unitary/conftest.py new file mode 100644 index 0000000..946b371 --- /dev/null +++ b/tests/unitary/conftest.py @@ -0,0 +1,20 @@ +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): + contract = ERC20(success=success_retval, fail=fail_retval) + contract._mint_for_testing(alice, 10 ** 21, {"from": alice}) + return contract 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 From 8a11adcad0ecac00afd31a07f7bd8e904e501724 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 03:18:37 +0000 Subject: [PATCH 10/18] fix: add module isolation + fix fn isolation --- tests/conftest.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b366110..08f6d4b 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(chain, history): + 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] From fd727e696c5e8b16f1c1f82325483bbc4ab4089c Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 03:49:46 +0000 Subject: [PATCH 11/18] fix: prevent incompatible return statements on success/failure --- brownie_tokens/template.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/brownie_tokens/template.py b/brownie_tokens/template.py index 72afed5..0d59c2b 100644 --- a/brownie_tokens/template.py +++ b/brownie_tokens/template.py @@ -53,6 +53,12 @@ def ERC20( 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}`" + ) + # fetch and render template template = env.get_template("ERC20.vy") src = template.render(retval=success, failval=fail, use_eip2612=use_eip2612) From 84bb615a744fad39bcda05ad8c9485e302c9371e Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 03:50:38 +0000 Subject: [PATCH 12/18] fix: add accounts fixture --- tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 08f6d4b..aadabac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,7 +37,7 @@ def mod_isolation(): @pytest.fixture(autouse=True) -def isolation(chain, history): +def isolation(): start = len(brownie.history) yield end = len(brownie.history) @@ -53,3 +53,8 @@ def alice(): @pytest.fixture(scope="session") def bob(): yield brownie.accounts[1] + + +@pytest.fixture(scope="session") +def accounts(): + return brownie.accounts From b17cc2654e972d3a4c98f2c2c4a25913fcb7c711 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 04:29:13 +0000 Subject: [PATCH 13/18] fix: handle failval of None in transferFrom --- brownie_tokens/templates/ERC20.vy | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/brownie_tokens/templates/ERC20.vy b/brownie_tokens/templates/ERC20.vy index a0bd124..934f4e2 100644 --- a/brownie_tokens/templates/ERC20.vy +++ b/brownie_tokens/templates/ERC20.vy @@ -13,13 +13,15 @@ return {# failval should be either boolean or string #} {# from should be either `msg.sender` or `_from` #} {% macro validate_transfer(failval, from) %} -{% if failval is boolean %} +{% if failval is boolean or failval is none %} user_balance: uint256 = self.balanceOf[{{ from }}] if user_balance < _value: {% if failval is true %} return True - {% else %} + {% elif failval is false %} return False + {% else %} + return {% endif %} self.balanceOf[{{ from }}] = user_balance - _value @@ -172,13 +174,15 @@ def transferFrom(_from: address, _to: address, _value: uint256){{ return_type(re @param _value The amount of tokens to transfer. """ {# input validation is handled prior to template rendering #} - {% if failval is boolean %} + {% if failval is boolean or failval is none %} allowance: uint256 = self.allowance[_from][msg.sender] if allowance < _value: {% if failval is true %} return True - {% else %} + {% elif failval is false %} return False + {% else %} + return {% endif %} {{ validate_transfer(failval, "_from")|indent|trim }} From 19f8e23bbb3d71a9f3b4b85db380696740d96ea4 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 04:29:54 +0000 Subject: [PATCH 14/18] test: add charlie account fixture --- tests/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index aadabac..36b4ae4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,11 @@ 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 From f5cfcc57887fb3fc28a02334aba9ad7d70107bee Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 04:30:28 +0000 Subject: [PATCH 15/18] fix: change pytest.skip -> pytest.xfail More clarity --- tests/unitary/conftest.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/unitary/conftest.py b/tests/unitary/conftest.py index 946b371..9cecc24 100644 --- a/tests/unitary/conftest.py +++ b/tests/unitary/conftest.py @@ -15,6 +15,9 @@ def fail_retval(request): @pytest.fixture(scope="module") def token(alice, success_retval, fail_retval): - contract = ERC20(success=success_retval, fail=fail_retval) - contract._mint_for_testing(alice, 10 ** 21, {"from": alice}) - return contract + 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) From 62ea45e0e52fa995ede990cad514040a35556c76 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 04:31:33 +0000 Subject: [PATCH 16/18] test: add actual ERC20 approve/transfer/transferFrom test cases --- tests/unitary/test_approve.py | 59 +++++++++ tests/unitary/test_transfer.py | 89 +++++++++++++ tests/unitary/test_transfer_from.py | 189 ++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 tests/unitary/test_approve.py create mode 100644 tests/unitary/test_transfer.py create mode 100644 tests/unitary/test_transfer_from.py 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_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] From ef819a93a742b20cccc342ed815db39d71b3cae8 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 05:27:24 +0000 Subject: [PATCH 17/18] chore: update README --- README.md | 3 +++ 1 file changed, 3 insertions(+) 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: From c88383645706b75e819c3698b33a38213b4c56d0 Mon Sep 17 00:00:00 2001 From: Edward Amor Date: Thu, 23 Dec 2021 05:28:29 +0000 Subject: [PATCH 18/18] chore: update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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