From 6464f651ec42b38361f4613b4a2474d229aadf14 Mon Sep 17 00:00:00 2001 From: Louis Holbrook Date: Wed, 30 Jun 2021 18:15:40 +0000 Subject: [PATCH] Add allowance check and transferFrom task --- apps/cic-cache/requirements.txt | 2 +- apps/cic-cache/test_requirements.txt | 2 +- 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 .../pytest}/testdata/Bogus.bin | 0 .../pytest}/testdata/Bogus.sol | 0 apps/cic-eth/cic_eth/version.py | 2 +- apps/cic-eth/requirements.txt | 2 +- apps/cic-eth/tests/conftest.py | 10 +- apps/cic-eth/tests/task/conftest.py | 2 +- apps/cic-eth/tests/task/test_task_erc20.py | 99 +++++++++++ apps/cic-notify/cic_notify/version.py | 2 +- apps/cic-notify/requirements.txt | 2 +- apps/cic-ussd/requirements.txt | 6 +- apps/data-seeding/requirements.txt | 2 +- apps/util/requirements/base_requirement.txt | 2 +- apps/util/requirements/update_base.sh | 5 +- 23 files changed, 361 insertions(+), 22 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%) rename apps/cic-eth/{tests => cic_eth/pytest}/testdata/Bogus.bin (100%) rename apps/cic-eth/{tests => cic_eth/pytest}/testdata/Bogus.sol (100%) diff --git a/apps/cic-cache/requirements.txt b/apps/cic-cache/requirements.txt index 2bc3a75a..00030b74 100644 --- a/apps/cic-cache/requirements.txt +++ b/apps/cic-cache/requirements.txt @@ -1,4 +1,4 @@ -cic-base==0.1.3a3+build.4aa03607 +cic-base==0.1.3a3+build.984b5cff alembic==1.4.2 confini~=0.3.6rc3 uwsgi==2.0.19.1 diff --git a/apps/cic-cache/test_requirements.txt b/apps/cic-cache/test_requirements.txt index f0c5fd81..f602fe01 100644 --- a/apps/cic-cache/test_requirements.txt +++ b/apps/cic-cache/test_requirements.txt @@ -6,5 +6,5 @@ sqlparse==0.4.1 pytest-celery==0.0.0a1 eth_tester==0.5.0b3 py-evm==0.3.0a20 -cic_base[full]==0.1.3a3+build.4aa03607 +cic_base[full]==0.1.3a3+build.984b5cff sarafu-faucet~=0.0.4a1 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/testdata/Bogus.bin b/apps/cic-eth/cic_eth/pytest/testdata/Bogus.bin similarity index 100% rename from apps/cic-eth/tests/testdata/Bogus.bin rename to apps/cic-eth/cic_eth/pytest/testdata/Bogus.bin diff --git a/apps/cic-eth/tests/testdata/Bogus.sol b/apps/cic-eth/cic_eth/pytest/testdata/Bogus.sol similarity index 100% rename from apps/cic-eth/tests/testdata/Bogus.sol rename to apps/cic-eth/cic_eth/pytest/testdata/Bogus.sol diff --git a/apps/cic-eth/cic_eth/version.py b/apps/cic-eth/cic_eth/version.py index 9a7a232e..720b21fd 100644 --- a/apps/cic-eth/cic_eth/version.py +++ b/apps/cic-eth/cic_eth/version.py @@ -10,7 +10,7 @@ version = ( 0, 11, 1, - 'alpha.2', + 'alpha.3', ) version_object = semver.VersionInfo( diff --git a/apps/cic-eth/requirements.txt b/apps/cic-eth/requirements.txt index 207045ed..60d27d13 100644 --- a/apps/cic-eth/requirements.txt +++ b/apps/cic-eth/requirements.txt @@ -1,4 +1,4 @@ -cic-base==0.1.3a3+build.4aa03607 +cic-base==0.1.3a3+build.984b5cff celery==4.4.7 crypto-dev-signer~=0.4.14b6 confini~=0.3.6rc3 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 * diff --git a/apps/cic-eth/tests/task/conftest.py b/apps/cic-eth/tests/task/conftest.py index 210aa07f..f6698a69 100644 --- a/apps/cic-eth/tests/task/conftest.py +++ b/apps/cic-eth/tests/task/conftest.py @@ -1 +1 @@ -from tests.fixtures_celery import * +from cic_eth.pytest.fixtures_celery import * diff --git a/apps/cic-eth/tests/task/test_task_erc20.py b/apps/cic-eth/tests/task/test_task_erc20.py index d0024278..5574178c 100644 --- a/apps/cic-eth/tests/task/test_task_erc20.py +++ b/apps/cic-eth/tests/task/test_task_erc20.py @@ -13,6 +13,7 @@ from chainlib.eth.tx import ( # local imports from cic_eth.queue.tx import register_tx +from cic_eth.error import YouAreBrokeError logg = logging.getLogger() @@ -167,3 +168,101 @@ def test_erc20_approve_task( r = t.get_leaf() logg.debug('result {}'.format(r)) + + +def test_erc20_transfer_from_task( + default_chain_spec, + foo_token, + agent_roles, + custodial_roles, + eth_signer, + eth_rpc, + init_database, + celery_session_worker, + token_roles, + ): + + token_object = { + 'address': foo_token, + } + transfer_value = 100 * (10 ** 6) + + nonce_oracle = RPCNonceOracle(token_roles['FOO_TOKEN_OWNER'], conn=eth_rpc) + c = ERC20(default_chain_spec, signer=eth_signer, nonce_oracle=nonce_oracle) + (tx_hash, o) = c.approve(foo_token, token_roles['FOO_TOKEN_OWNER'], agent_roles['ALICE'], transfer_value) + r = eth_rpc.do(o) + o = receipt(tx_hash) + r = eth_rpc.do(o) + assert r['status'] == 1 + + s_nonce = celery.signature( + 'cic_eth.eth.nonce.reserve_nonce', + [ + [token_object], + default_chain_spec.asdict(), + custodial_roles['FOO_TOKEN_GIFTER'], + ], + queue=None, + ) + s_transfer = celery.signature( + 'cic_eth.eth.erc20.transfer_from', + [ + custodial_roles['FOO_TOKEN_GIFTER'], + agent_roles['BOB'], + transfer_value, + default_chain_spec.asdict(), + agent_roles['ALICE'], + ], + queue=None, + ) + s_nonce.link(s_transfer) + t = s_nonce.apply_async() + r = t.get_leaf() + + logg.debug('result {}'.format(r)) + + +def test_erc20_allowance_check_task( + default_chain_spec, + foo_token, + agent_roles, + custodial_roles, + eth_signer, + eth_rpc, + init_database, + celery_session_worker, + token_roles, + ): + + token_object = { + 'address': foo_token, + 'symbol': 'FOO', + } + transfer_value = 100 * (10 ** 6) + + s_check = celery.signature( + 'cic_eth.eth.erc20.check_allowance', + [ + [token_object], + custodial_roles['FOO_TOKEN_GIFTER'], + transfer_value, + default_chain_spec.asdict(), + agent_roles['ALICE'] + ], + queue=None, + ) + t = s_check.apply_async() + with pytest.raises(YouAreBrokeError): + t.get() + + nonce_oracle = RPCNonceOracle(token_roles['FOO_TOKEN_OWNER'], conn=eth_rpc) + c = ERC20(default_chain_spec, signer=eth_signer, nonce_oracle=nonce_oracle) + (tx_hash, o) = c.approve(foo_token, token_roles['FOO_TOKEN_OWNER'], agent_roles['ALICE'], transfer_value) + r = eth_rpc.do(o) + o = receipt(tx_hash) + r = eth_rpc.do(o) + assert r['status'] == 1 + + t = s_check.apply_async() + t.get() + assert t.successful() diff --git a/apps/cic-notify/cic_notify/version.py b/apps/cic-notify/cic_notify/version.py index 4ba6055f..e8f70c71 100644 --- a/apps/cic-notify/cic_notify/version.py +++ b/apps/cic-notify/cic_notify/version.py @@ -9,7 +9,7 @@ import semver logg = logging.getLogger() -version = (0, 4, 0, 'alpha.6') +version = (0, 4, 0, 'alpha.7') version_object = semver.VersionInfo( major=version[0], diff --git a/apps/cic-notify/requirements.txt b/apps/cic-notify/requirements.txt index 67db2dfb..17b36f50 100644 --- a/apps/cic-notify/requirements.txt +++ b/apps/cic-notify/requirements.txt @@ -1 +1 @@ -cic_base[full_graph]==0.1.3a3+build.4aa03607 +cic_base[full_graph]==0.1.3a3+build.984b5cff diff --git a/apps/cic-ussd/requirements.txt b/apps/cic-ussd/requirements.txt index f527c4ef..f5eda5ac 100644 --- a/apps/cic-ussd/requirements.txt +++ b/apps/cic-ussd/requirements.txt @@ -1,4 +1,4 @@ -cic_base[full_graph]==0.1.3a3+build.4aa03607 -cic-eth~=0.11.1a2 -cic-notify~=0.4.0a6 +cic_base[full_graph]==0.1.3a3+build.984b5cff +cic-eth~=0.11.1a3 +cic-notify~=0.4.0a7 cic-types~=0.1.0a11 diff --git a/apps/data-seeding/requirements.txt b/apps/data-seeding/requirements.txt index c178b15f..0a1d5809 100644 --- a/apps/data-seeding/requirements.txt +++ b/apps/data-seeding/requirements.txt @@ -1,4 +1,4 @@ -cic_base[full_graph]==0.1.3a3+build.4aa03607 +cic_base[full_graph]==0.1.3a3+build.984b5cff sarafu-faucet==0.0.4a1 cic-eth==0.11.1a1 cic-types==0.1.0a13 diff --git a/apps/util/requirements/base_requirement.txt b/apps/util/requirements/base_requirement.txt index ea67cea4..71ea5ccd 100644 --- a/apps/util/requirements/base_requirement.txt +++ b/apps/util/requirements/base_requirement.txt @@ -1 +1 @@ -cic-base==0.1.3a3+build.4aa03607 +cic-base==0.1.3a3+build.984b5cff diff --git a/apps/util/requirements/update_base.sh b/apps/util/requirements/update_base.sh index 938867be..49eb3ee7 100644 --- a/apps/util/requirements/update_base.sh +++ b/apps/util/requirements/update_base.sh @@ -14,8 +14,11 @@ repos=(../../cic-cache ../../cic-eth ../../cic-ussd ../../data-seeding ../../cic for r in ${repos[@]}; do f="$r/requirements.txt" >&2 echo updating $f + pyreq-update $f base_requirement.txt -vv > $t + cp $t $f + f="$r/test_requirements.txt" >&2 echo updating $f - pyreq-update $f base_requirement.txt > $t + pyreq-update $f base_requirement.txt -vv > $t cp $t $f done