From 6a45d5faa70cfe4c759d8a7c52fb32790fcd4964 Mon Sep 17 00:00:00 2001 From: nolash Date: Tue, 29 Jun 2021 08:21:11 +0200 Subject: [PATCH] Move pytest fixtures to importable location, add allowance check for transfer from --- apps/cic-eth/cic_eth/api/api_task.py | 76 +++++++++ apps/cic-eth/cic_eth/error.py | 5 + apps/cic-eth/cic_eth/eth/erc20.py | 155 ++++++++++++++++++ .../pytest}/fixtures_celery.py | 0 .../pytest}/fixtures_config.py | 6 +- .../pytest}/fixtures_contract.py | 0 .../pytest}/fixtures_database.py | 3 +- .../pytest}/fixtures_role.py | 0 apps/cic-eth/tests/conftest.py | 10 +- 9 files changed, 246 insertions(+), 9 deletions(-) rename apps/cic-eth/{tests => cic_eth/pytest}/fixtures_celery.py (100%) rename apps/cic-eth/{tests => cic_eth/pytest}/fixtures_config.py (80%) rename apps/cic-eth/{tests => cic_eth/pytest}/fixtures_contract.py (100%) rename apps/cic-eth/{tests => cic_eth/pytest}/fixtures_database.py (93%) rename apps/cic-eth/{tests => cic_eth/pytest}/fixtures_role.py (100%) diff --git a/apps/cic-eth/cic_eth/api/api_task.py b/apps/cic-eth/cic_eth/api/api_task.py index aea00002..eed726c5 100644 --- a/apps/cic-eth/cic_eth/api/api_task.py +++ b/apps/cic-eth/cic_eth/api/api_task.py @@ -204,6 +204,82 @@ class Api: # return t + def transfer_from(self, from_address, to_address, value, token_symbol, spender_address): + """Executes a chain of celery tasks that performs a transfer of ERC20 tokens by one address on behalf of another address to a third party. + + :param from_address: Ethereum address of sender + :type from_address: str, 0x-hex + :param to_address: Ethereum address of recipient + :type to_address: str, 0x-hex + :param value: Estimated return from conversion + :type value: int + :param token_symbol: ERC20 token symbol of token to send + :type token_symbol: str + :param spender_address: Ethereum address of recipient + :type spender_address: str, 0x-hex + :returns: uuid of root task + :rtype: celery.Task + """ + s_check = celery.signature( + 'cic_eth.admin.ctrl.check_lock', + [ + [token_symbol], + self.chain_spec.asdict(), + LockEnum.QUEUE, + from_address, + ], + queue=self.queue, + ) + s_nonce = celery.signature( + 'cic_eth.eth.nonce.reserve_nonce', + [ + self.chain_spec.asdict(), + from_address, + ], + queue=self.queue, + ) + s_tokens = celery.signature( + 'cic_eth.eth.erc20.resolve_tokens_by_symbol', + [ + self.chain_spec.asdict(), + ], + queue=self.queue, + ) + s_allow = celery.signature( + 'cic_eth.eth.erc20.check_allowance', + [ + from_address, + value, + self.chain_spec.asdict(), + spender_address, + ], + queue=self.queue, + ) + s_transfer = celery.signature( + 'cic_eth.eth.erc20.transfer_from', + [ + from_address, + to_address, + value, + self.chain_spec.asdict(), + spender_address, + ], + queue=self.queue, + ) + s_tokens.link(s_allow) + s_nonce.link(s_tokens) + s_check.link(s_nonce) + if self.callback_param != None: + s_transfer.link(self.callback_success) + s_allow.link(s_transfer).on_error(self.callback_error) + else: + s_allow.link(s_transfer) + + t = s_check.apply_async(queue=self.queue) + return t + + + def transfer(self, from_address, to_address, value, token_symbol): """Executes a chain of celery tasks that performs a transfer of ERC20 tokens from one address to another. diff --git a/apps/cic-eth/cic_eth/error.py b/apps/cic-eth/cic_eth/error.py index 781fed85..3e72a439 100644 --- a/apps/cic-eth/cic_eth/error.py +++ b/apps/cic-eth/cic_eth/error.py @@ -80,3 +80,8 @@ class SignerError(SeppukuError): class RoleAgencyError(SeppukuError): """Exception raise when a role cannot perform its function. This is a critical exception """ + + +class YouAreBrokeError(Exception): + """Exception raised when a value transfer is attempted without access to sufficient funds + """ diff --git a/apps/cic-eth/cic_eth/eth/erc20.py b/apps/cic-eth/cic_eth/eth/erc20.py index 4daf693b..1f4751b4 100644 --- a/apps/cic-eth/cic_eth/eth/erc20.py +++ b/apps/cic-eth/cic_eth/eth/erc20.py @@ -24,6 +24,7 @@ from cic_eth.error import ( TokenCountError, PermanentTxError, OutOfGasError, + YouAreBrokeError, ) from cic_eth.queue.tx import register_tx from cic_eth.eth.gas import ( @@ -71,6 +72,117 @@ def balance(tokens, holder_address, chain_spec_dict): return tokens +@celery_app.task(bind=True) +def check_allowance(self, tokens, holder_address, value, chain_spec_dict, spender_address): + """Best-effort verification that the allowance for a transfer from spend is sufficient. + + :raises YouAreBrokeError: If allowance is insufficient + + :param tokens: Token addresses + :type tokens: list of str, 0x-hex + :param holder_address: Token holder address + :type holder_address: str, 0x-hex + :param value: Amount of token, in 'wei' + :type value: int + :param chain_str: Chain spec string representation + :type chain_str: str + :param spender_address: Address of account spending on behalf of holder + :type spender_address: str, 0x-hex + :return: Token list as passed to task + :rtype: dict + """ + logg.debug('tokens {}'.format(tokens)) + if len(tokens) != 1: + raise TokenCountError + t = tokens[0] + chain_spec = ChainSpec.from_dict(chain_spec_dict) + + rpc = RPCConnection.connect(chain_spec, 'default') + + caller_address = ERC20Token.caller_address + c = ERC20(chain_spec) + o = c.allowance(t['address'], holder_address, spender_address, sender_address=caller_address) + r = rpc.do(o) + allowance = c.parse_allowance(r) + if allowance < value: + errstr = 'allowance {} insufficent to transfer {} {} by {} on behalf of {}'.format(allowance, value, t['symbol'], spender_address, holder_address) + logg.error(errstr) + raise YouAreBrokeError(errstr) + + return tokens + + +@celery_app.task(bind=True, base=CriticalSQLAlchemyAndSignerTask) +def transfer_from(self, tokens, holder_address, receiver_address, value, chain_spec_dict, spender_address): + """Transfer ERC20 tokens between addresses + + First argument is a list of tokens, to enable the task to be chained to the symbol to token address resolver function. However, it accepts only one token as argument. + + :param tokens: Token addresses + :type tokens: list of str, 0x-hex + :param holder_address: Token holder address + :type holder_address: str, 0x-hex + :param receiver_address: Token receiver address + :type receiver_address: str, 0x-hex + :param value: Amount of token, in 'wei' + :type value: int + :param chain_str: Chain spec string representation + :type chain_str: str + :param spender_address: Address of account spending on behalf of holder + :type spender_address: str, 0x-hex + :raises TokenCountError: Either none or more then one tokens have been passed as tokens argument + :return: Transaction hash for tranfer operation + :rtype: str, 0x-hex + """ + # we only allow one token, one transfer + logg.debug('tokens {}'.format(tokens)) + if len(tokens) != 1: + raise TokenCountError + t = tokens[0] + chain_spec = ChainSpec.from_dict(chain_spec_dict) + queue = self.request.delivery_info.get('routing_key') + + rpc = RPCConnection.connect(chain_spec, 'default') + rpc_signer = RPCConnection.connect(chain_spec, 'signer') + + session = self.create_session() + nonce_oracle = CustodialTaskNonceOracle(holder_address, self.request.root_id, session=session) + gas_oracle = self.create_gas_oracle(rpc, MaxGasOracle.gas) + c = ERC20(chain_spec, signer=rpc_signer, gas_oracle=gas_oracle, nonce_oracle=nonce_oracle) + try: + (tx_hash_hex, tx_signed_raw_hex) = c.transfer_from(t['address'], spender_address, holder_address, receiver_address, value, tx_format=TxFormat.RLP_SIGNED) + except FileNotFoundError as e: + raise SignerError(e) + except ConnectionError as e: + raise SignerError(e) + + + rpc_signer.disconnect() + rpc.disconnect() + + cache_task = 'cic_eth.eth.erc20.cache_transfer_from_data' + + register_tx(tx_hash_hex, tx_signed_raw_hex, chain_spec, queue, cache_task=cache_task, session=session) + session.commit() + session.close() + + gas_pair = gas_oracle.get_gas(tx_signed_raw_hex) + gas_budget = gas_pair[0] * gas_pair[1] + logg.debug('transfer tx {} {} {}'.format(tx_hash_hex, queue, gas_budget)) + + s = create_check_gas_task( + [tx_signed_raw_hex], + chain_spec, + holder_address, + gas_budget, + [tx_hash_hex], + queue, + ) + s.apply_async() + return tx_hash_hex + + + @celery_app.task(bind=True, base=CriticalSQLAlchemyAndSignerTask) def transfer(self, tokens, holder_address, receiver_address, value, chain_spec_dict): """Transfer ERC20 tokens between addresses @@ -232,6 +344,7 @@ def resolve_tokens_by_symbol(self, token_symbols, chain_spec_dict): logg.debug('token {}'.format(token_address)) tokens.append({ 'address': token_address, + 'symbol': token_symbol, 'converters': [], }) rpc.disconnect() @@ -279,6 +392,48 @@ def cache_transfer_data( return (tx_hash_hex, cache_id) +@celery_app.task(base=CriticalSQLAlchemyTask) +def cache_transfer_from_data( + tx_hash_hex, + tx_signed_raw_hex, + chain_spec_dict, + ): + """Helper function for otx_cache_transfer_from + + :param tx_hash_hex: Transaction hash + :type tx_hash_hex: str, 0x-hex + :param tx: Signed raw transaction + :type tx: str, 0x-hex + :returns: Transaction hash and id of cache element in storage backend, respectively + :rtype: tuple + """ + chain_spec = ChainSpec.from_dict(chain_spec_dict) + tx_signed_raw_bytes = bytes.fromhex(strip_0x(tx_signed_raw_hex)) + tx = unpack(tx_signed_raw_bytes, chain_spec) + + tx_data = ERC20.parse_transfer_from_request(tx['data']) + spender_address = tx_data[0] + recipient_address = tx_data[1] + token_value = tx_data[2] + + session = SessionBase.create_session() + tx_cache = TxCache( + tx_hash_hex, + tx['from'], + recipient_address, + tx['to'], + tx['to'], + token_value, + token_value, + session=session, + ) + session.add(tx_cache) + session.commit() + cache_id = tx_cache.id + session.close() + return (tx_hash_hex, cache_id) + + @celery_app.task(base=CriticalSQLAlchemyTask) def cache_approve_data( tx_hash_hex, diff --git a/apps/cic-eth/tests/fixtures_celery.py b/apps/cic-eth/cic_eth/pytest/fixtures_celery.py similarity index 100% rename from apps/cic-eth/tests/fixtures_celery.py rename to apps/cic-eth/cic_eth/pytest/fixtures_celery.py diff --git a/apps/cic-eth/tests/fixtures_config.py b/apps/cic-eth/cic_eth/pytest/fixtures_config.py similarity index 80% rename from apps/cic-eth/tests/fixtures_config.py rename to apps/cic-eth/cic_eth/pytest/fixtures_config.py index 135df0fd..a4ddedd3 100644 --- a/apps/cic-eth/tests/fixtures_config.py +++ b/apps/cic-eth/cic_eth/pytest/fixtures_config.py @@ -2,13 +2,13 @@ import os import logging -# third-party imports +# external imports import pytest import confini script_dir = os.path.dirname(os.path.realpath(__file__)) -root_dir = os.path.dirname(script_dir) -logg = logging.getLogger(__file__) +root_dir = os.path.dirname(os.path.dirname(script_dir)) +logg = logging.getLogger(__name__) @pytest.fixture(scope='session') diff --git a/apps/cic-eth/tests/fixtures_contract.py b/apps/cic-eth/cic_eth/pytest/fixtures_contract.py similarity index 100% rename from apps/cic-eth/tests/fixtures_contract.py rename to apps/cic-eth/cic_eth/pytest/fixtures_contract.py diff --git a/apps/cic-eth/tests/fixtures_database.py b/apps/cic-eth/cic_eth/pytest/fixtures_database.py similarity index 93% rename from apps/cic-eth/tests/fixtures_database.py rename to apps/cic-eth/cic_eth/pytest/fixtures_database.py index eb57eb2a..d7fa81d8 100644 --- a/apps/cic-eth/tests/fixtures_database.py +++ b/apps/cic-eth/cic_eth/pytest/fixtures_database.py @@ -37,7 +37,8 @@ def init_database( database_engine, ): - rootdir = os.path.dirname(os.path.dirname(__file__)) + script_dir = os.path.dirname(os.path.realpath(__file__)) + rootdir = os.path.dirname(os.path.dirname(script_dir)) dbdir = os.path.join(rootdir, 'cic_eth', 'db') migrationsdir = os.path.join(dbdir, 'migrations', load_config.get('DATABASE_ENGINE')) if not os.path.isdir(migrationsdir): diff --git a/apps/cic-eth/tests/fixtures_role.py b/apps/cic-eth/cic_eth/pytest/fixtures_role.py similarity index 100% rename from apps/cic-eth/tests/fixtures_role.py rename to apps/cic-eth/cic_eth/pytest/fixtures_role.py diff --git a/apps/cic-eth/tests/conftest.py b/apps/cic-eth/tests/conftest.py index 169a6c71..25c10813 100644 --- a/apps/cic-eth/tests/conftest.py +++ b/apps/cic-eth/tests/conftest.py @@ -17,11 +17,11 @@ root_dir = os.path.dirname(script_dir) sys.path.insert(0, root_dir) # assemble fixtures -from tests.fixtures_config import * -from tests.fixtures_database import * -from tests.fixtures_celery import * -from tests.fixtures_role import * -from tests.fixtures_contract import * +from cic_eth.pytest.fixtures_config import * +from cic_eth.pytest.fixtures_celery import * +from cic_eth.pytest.fixtures_database import * +from cic_eth.pytest.fixtures_role import * +from cic_eth.pytest.fixtures_contract import * from chainlib.eth.pytest import * from eth_contract_registry.pytest import * from cic_eth_registry.pytest.fixtures_contracts import *