diff --git a/nightly/pytest-rosetta.txt b/nightly/pytest-rosetta.txt new file mode 100644 index 00000000000..528f1111647 --- /dev/null +++ b/nightly/pytest-rosetta.txt @@ -0,0 +1,2 @@ +pytest rosetta/account-delete.py +pytest rosetta/account-delete.py --features nightly_protocol,nightly_protocol_features diff --git a/nightly/pytest.txt b/nightly/pytest.txt index 052071b643e..8e5537ad94a 100644 --- a/nightly/pytest.txt +++ b/nightly/pytest.txt @@ -1,5 +1,6 @@ ./pytest-adversarial.txt ./pytest-contracts.txt +./pytest-rosetta.txt ./pytest-sanity.txt ./pytest-spec.txt ./pytest-stress.txt diff --git a/pytest/lib/cluster.py b/pytest/lib/cluster.py index d3ab01e9005..498879dbf1a 100644 --- a/pytest/lib/cluster.py +++ b/pytest/lib/cluster.py @@ -664,13 +664,14 @@ def apply_config_changes(node_dir, client_config_change): config_json = json.loads(f.read()) # ClientConfig keys which are valid but may be missing from the config.json - # file. At the moment it’s only max_gas_burnt_view which is an Option and - # None by default. If None, the key is not present in the file. - allowed_missing_configs = ('max_gas_burnt_view',) + # file. Those are usually Option types which are not stored in JSON file + # when None. + allowed_missing_configs = ('max_gas_burnt_view', 'rosetta_rpc') for k, v in client_config_change.items(): - assert k in allowed_missing_configs or k in config_json - if isinstance(v, dict): + if not (k in allowed_missing_configs or k in config_json): + raise ValueError(f'Unknown configuration option: {k}') + if k in config_json and isinstance(v, dict): for key, value in v.items(): assert key in config_json[k], key config_json[k][key] = value diff --git a/pytest/lib/key.py b/pytest/lib/key.py index a43ff1f9be8..5721a50228f 100644 --- a/pytest/lib/key.py +++ b/pytest/lib/key.py @@ -1,31 +1,46 @@ import base58 import json +import os +import typing +import ed25519 -class Key(object): - def __init__(self, account_id, pk, sk): +class Key: + account_id: str + pk: str + sk: str + + def __init__(self, account_id: str, pk: str, sk: str) -> None: super(Key, self).__init__() self.account_id = account_id self.pk = pk self.sk = sk - def decoded_pk(self): - key = self.pk.split(':')[1] if ':' in self.pk else self.pk - return base58.b58decode(key.encode('ascii')) - - def decoded_sk(self): - key = self.sk.split(':')[1] if ':' in self.sk else self.sk - return base58.b58decode(key.encode('ascii')) + @classmethod + def implicit_account(cls) -> 'Key': + keys = ed25519.create_keypair(entropy=os.urandom) + account_id = keys[1].to_bytes().hex() + sk = 'ed25519:' + base58.b58encode(keys[0].to_bytes()).decode('ascii') + pk = 'ed25519:' + base58.b58encode(keys[1].to_bytes()).decode('ascii') + return cls(account_id, pk, sk) @classmethod - def from_json(self, j): + def from_json(self, j: typing.Dict[str, str]): return Key(j['account_id'], j['public_key'], j['secret_key']) @classmethod - def from_json_file(self, jf): - with open(jf) as f: - return Key.from_json(json.loads(f.read())) + def from_json_file(self, filename: str): + with open(filename) as rd: + return Key.from_json(json.load(rd)) + + def decoded_pk(self) -> bytes: + key = self.pk.split(':')[1] if ':' in self.pk else self.pk + return base58.b58decode(key.encode('ascii')) + + def decoded_sk(self) -> bytes: + key = self.sk.split(':')[1] if ':' in self.sk else self.sk + return base58.b58decode(key.encode('ascii')) def to_json(self): return { @@ -33,3 +48,7 @@ def to_json(self): 'public_key': self.pk, 'secret_key': self.sk } + + def sign_bytes(self, data: typing.Union[bytes, bytearray]) -> bytes: + sk = self.decoded_sk() + return ed25519.SigningKey(sk).sign(bytes(data)) diff --git a/pytest/tests/rosetta/account-delete.py b/pytest/tests/rosetta/account-delete.py new file mode 100644 index 00000000000..035662786c1 --- /dev/null +++ b/pytest/tests/rosetta/account-delete.py @@ -0,0 +1,187 @@ +import base58 +import json +import os +import pathlib +import sys +import time +import typing +import unittest + +import ed25519 +import requests + +sys.path.append('lib') + +from configured_logger import logger +import cluster +import key + +_Dict = typing.Dict[str, typing.Any] + + +class RosettaRPC: + href: str + network_identifier: _Dict + + def __init__(self, *, host: str = '127.0.0.1', port: int = 5040) -> None: + self.href = f'http://{host}:{port}' + self.network_identifier = self.get_network_identifier() + + def get_network_identifier(self): + result = requests.post(f'{self.href}/network/list', + headers={'content-type': 'application/json'}, + data=json.dumps({'metadata': {}})) + result.raise_for_status() + return result.json()['network_identifiers'][0] + + def rpc(self, path: str, **data: typing.Any) -> _Dict: + data['network_identifier'] = self.network_identifier + result = requests.post(f'{self.href}{path}', + headers={'content-type': 'application/json'}, + data=json.dumps(data, indent=True)) + result.raise_for_status() + data = result.json() + if 'code' in result: + raise RuntimeError(f'Got error from {path}:\n{json.dumps(data)}') + return data + + def exec_operations(self, signer: key.Key, *operations) -> str: + public_key = { + 'hex_bytes': signer.decoded_pk().hex(), + 'curve_type': 'edwards25519' + } + options = self.rpc('/construction/preprocess', + operations=operations)['options'] + metadata = self.rpc('/construction/metadata', + options=options, + public_keys=[public_key])['metadata'] + payloads = self.rpc('/construction/payloads', + operations=operations, + public_keys=[public_key], + metadata=metadata) + payload = payloads['payloads'][0] + unsigned = payloads['unsigned_transaction'] + signature = signer.sign_bytes(bytearray.fromhex(payload['hex_bytes'])) + signed = self.rpc('/construction/combine', + unsigned_transaction=unsigned, + signatures=[{ + 'signing_payload': payload, + 'hex_bytes': signature.hex(), + 'signature_type': 'ed25519', + 'public_key': public_key + }])['signed_transaction'] + tx = self.rpc('/construction/submit', signed_transaction=signed) + return tx['transaction_identifier']['hash'] + + def transfer(self, *, src: key.Key, dst: key.Key, amount: int) -> str: + currency = {'symbol': 'NEAR', 'decimals': 24} + return self.exec_operations( + src, { + 'operation_identifier': { + 'index': 0 + }, + 'type': 'TRANSFER', + 'account': { + 'address': src.account_id + }, + 'amount': { + 'value': str(-amount), + 'currency': currency + }, + }, { + 'operation_identifier': { + 'index': 1 + }, + 'related_operations': [{ + 'index': 0 + }], + 'type': 'TRANSFER', + 'account': { + 'address': dst.account_id + }, + 'amount': { + 'value': str(amount), + 'currency': currency + }, + }) + + def delete_account(self, account: key.Key, refund_to: key.Key) -> str: + return self.exec_operations( + account, + { + 'operation_identifier': { + 'index': 0 + }, + 'type': 'INITIATE_DELETE_ACCOUNT', + 'account': { + 'address': account.account_id + }, + }, + { + 'operation_identifier': { + 'index': 0 + }, + 'type': 'DELETE_ACCOUNT', + 'account': { + 'address': account.account_id + }, + }, + { + 'operation_identifier': { + 'index': 0 + }, + 'type': 'REFUND_DELETE_ACCOUNT', + 'account': { + 'address': refund_to.account_id + }, + }, + ) + + +def test_delete_implicit_account() -> None: + node = cluster.start_cluster( + 1, 0, 1, {}, {}, { + 0: { + 'rosetta_rpc': { + 'addr': '0.0.0.0:5040', + 'cors_allowed_origins': ['*'] + }, + } + })[0] + rosetta = RosettaRPC(host=node.rpc_addr()[0]) + validator = node.validator_key + implicit = key.Key.implicit_account() + + logger.info(f'Creating implicit account: {implicit.account_id}') + tx_hash = rosetta.transfer(src=validator, dst=implicit, amount=10**22) + logger.info(f'Transaction: {tx_hash}') + + for _ in range(10): + time.sleep(1) + result = node.get_account(implicit.account_id) + if 'error' not in result: + result = result['result'] + amount = result['amount'] + logger.info(f'Account balance {amount}') + assert int(amount) == 10**22, result + break + else: + assert False, f'Account {implicit.account_id} wasn’t created:\n{result}' + + + logger.info(f'Deleting implicit account: {implicit.account_id}') + tx_hash = rosetta.delete_account(implicit, refund_to=validator) + logger.info(f'Transaction: {tx_hash}') + + for _ in range(10): + time.sleep(1) + result = node.get_account(implicit.account_id) + if ('error' in result and + result['error']['cause']['name'] == 'UNKNOWN_ACCOUNT'): + break + else: + assert False, f'Account {implicit.account_id} wasn’t deleted:\n{result}' + + +if __name__ == '__main__': + test_delete_implicit_account()