diff --git a/src/etheroll.kv b/src/etheroll.kv index 44484b4..03558c0 100644 --- a/src/etheroll.kv +++ b/src/etheroll.kv @@ -5,6 +5,7 @@ #:import MDBottomNavigation kivymd.tabs.MDBottomNavigation #:import MDBottomNavigationItem kivymd.tabs.MDBottomNavigationItem #:import MDTextField kivymd.textfields.MDTextField +#:import MDCheckbox kivymd.selectioncontrols.MDCheckbox #:import MDSlider kivymd.slider.MDSlider #:import Toolbar kivymd.toolbar.Toolbar #:import ROUND_DIGITS etheroll.ROUND_DIGITS @@ -70,6 +71,36 @@ ImportKeystore: +: + name: 'settings_screen' + BoxLayout: + orientation: 'vertical' + BoxLayout: + orientation: 'horizontal' + MDCheckbox: + id: mainnet_checkbox_id + group: 'network' + size_hint: None, None + size: dp(48), dp(48) + pos_hint: {'center_x': 0.5, 'center_y': 0.5} + active: True + on_release: + print(root.network) + MDLabel: + text: 'Mainnet' + BoxLayout: + orientation: 'horizontal' + MDCheckbox: + id: testnet_checkbox_id + group: 'network' + size_hint: None, None + size: dp(48), dp(48) + pos_hint: {'center_x': 0.5, 'center_y': 0.5} + MDLabel: + text: 'Testnet' + PushUp: + + : BoxLayout: orientation: "vertical" @@ -183,6 +214,12 @@ on_release: app.root.ids.screen_manager_id.transition.direction = "right" app.root.ids.screen_manager_id.current = "switch_account_screen" + NavigationDrawerIconButton: + icon: "settings" + text: "Settings" + on_release: + app.root.ids.screen_manager_id.transition.direction = "right" + app.root.ids.screen_manager_id.current = "settings_screen" NavigationDrawerIconButton: icon: "help" text: "About" @@ -208,6 +245,9 @@ Controller: SwitchAccountScreen: id: switch_account_screen_id name: "switch_account_screen" + SettingsScreen: + id: settings_screen_id + name: "settings_screen" AboutScreen: id: about_screen_id name: "about_screen" diff --git a/src/etheroll.py b/src/etheroll.py index 8565ab6..eb730b9 100755 --- a/src/etheroll.py +++ b/src/etheroll.py @@ -1,6 +1,4 @@ #!/usr/bin/env python -from __future__ import print_function, unicode_literals - import os from os.path import expanduser @@ -169,6 +167,31 @@ def on_account_selected(self, account): self.on_back() +class SettingsScreen(SubScreen): + """ + Screen for configuring network, gas price... + """ + + def __init__(self, **kwargs): + super(SettingsScreen, self).__init__(**kwargs) + # Clock.schedule_once(self._after_init) + + @property + def network(self): + """ + Returns selected network. + """ + if self.is_mainnet(): + return pyetheroll.ChainID.MAINNET + return pyetheroll.ChainID.ROPSTEN + + def is_mainnet(self): + return self.ids.mainnet_checkbox_id.active + + def is_testnet(self): + return self.ids.testnet_checkbox_id.active + + class AboutScreen(SubScreen): project_page_property = StringProperty( "https://github.com/AndreMiras/EtherollApp") @@ -293,7 +316,8 @@ def __init__(self, **kwargs): super(Controller, self).__init__(**kwargs) Clock.schedule_once(self._after_init) self._init_pyethapp() - self.account_passwords = {} + self._account_passwords = {} + self._pyetheroll = None def _after_init(self, dt): """ @@ -314,6 +338,17 @@ def _init_pyethapp(self, keystore_dir=None): config=dict(accounts=dict(keystore_dir=keystore_dir))) AccountsService.register_with_app(self.pyethapp) + @property + def pyetheroll(self): + """ + Gets or creates the Etheroll object. + Also recreates the object if the chain_id changed. + """ + chain_id = self.settings_screen.network + if self._pyetheroll is None or self._pyetheroll.chain_id != chain_id: + self._pyetheroll = pyetheroll.Etheroll(chain_id) + return self._pyetheroll + @classmethod def get_keystore_path(cls): """ @@ -410,11 +445,15 @@ def roll_screen(self): def switch_account_screen(self): return self.ids.switch_account_screen_id + @property + def settings_screen(self): + return self.ids.settings_screen_id + def on_unlock_clicked(self, dialog, account, password): """ Caches the password and call roll method again. """ - self.account_passwords[account.address.hex()] = password + self._account_passwords[account.address.hex()] = password dialog.dismiss() # calling roll again since the password is now cached self.roll() @@ -444,7 +483,7 @@ def get_account_password(self, account): """ address = account.address.hex() try: - return self.account_passwords[address] + return self._account_passwords[address] except KeyError: self.prompt_password_dialog(account) @@ -484,7 +523,7 @@ def roll(self): password = self.get_account_password(account) if password is not None: try: - tx_hash = pyetheroll.player_roll_dice( + tx_hash = self.pyetheroll.player_roll_dice( bet_size, chances, wallet_path, password) except ValueError as exception: self.dialog_roll_error(exception) diff --git a/src/pyetheroll.py b/src/pyetheroll.py old mode 100755 new mode 100644 index cf4e26f..b1245da --- a/src/pyetheroll.py +++ b/src/pyetheroll.py @@ -8,7 +8,6 @@ import json import os from enum import Enum -from pprint import pprint import eth_abi from ethereum.abi import decode_abi @@ -28,108 +27,148 @@ class ChainID(Enum): ROPSTEN = 3 -class RopstenContract(EtherscanContract): +class RopstenEtherscanContract(EtherscanContract): """ https://github.com/corpetty/py-etherscan-api/issues/24 """ PREFIX = 'https://api-ropsten.etherscan.io/api?' -# TODO: handle both mainnet and testnet -def get_contract_abi(contract_address): - """ - Given a contract address returns the contract ABI from Etherscan, refs #2. - """ - location = os.path.realpath( - os.path.join(os.getcwd(), os.path.dirname(__file__))) - api_key_path = str(os.path.join(location, 'api_key.json')) - with open(api_key_path, mode='r') as key_file: - key = json.loads(key_file.read())['key'] - api = RopstenContract(address=contract_address, api_key=key) - json_abi = api.get_abi() - abi = json.loads(json_abi) - return abi - - -def get_methods_infos(contract_abi): - """ - List of infos for each events. - """ - methods_infos = {} - # only retrieves functions and events, other existing types are: - # "fallback" and "constructor" - types = ['function', 'event'] - methods = [a for a in contract_abi if a['type'] in types] - for description in methods: - method_name = description['name'] - types = ','.join([x['type'] for x in description['inputs']]) - event_definition = "%s(%s)" % (method_name, types) - event_sha3 = Web3.sha3(text=event_definition) - method_info = { - 'definition': event_definition, - 'sha3': event_sha3, - 'abi': description, - } - methods_infos.update({method_name: method_info}) - return methods_infos +class ChainEtherscanContractFactory: + + CONTRACTS = { + ChainID.MAINNET: EtherscanContract, + ChainID.ROPSTEN: RopstenEtherscanContract, + } + @classmethod + def create(cls, chain_id=ChainID.MAINNET): + ChainEtherscanContract = cls.CONTRACTS[chain_id] + return ChainEtherscanContract -def decode_method(contract_abi, topics, log_data): - """ - Given a topic and log data, decode the event. - """ - topic = topics[0] - # each indexed field generates a new topics and is excluded from data - # hence we consider topics[1:] like data, assuming indexed fields - # always come first - # see https://codeburst.io/deep-dive-into-ethereum-logs-a8d2047c7371 - topics_log_data = b"".join(topics[1:]) - log_data = log_data.lower().replace("0x", "") - log_data = bytes.fromhex(log_data) - topics_log_data += log_data - methods_infos = get_methods_infos(contract_abi) - method_info = None - for event, info in methods_infos.items(): - if info['sha3'].lower() == topic.lower(): - method_info = info - event_inputs = method_info['abi']['inputs'] - types = [e_input['type'] for e_input in event_inputs] - names = [e_input['name'] for e_input in event_inputs] - values = eth_abi.decode_abi(types, topics_log_data) - call = {name: value for name, value in zip(names, values)} - decoded_method = { - 'method_info': method_info, - 'call': call, + +class HTTPProviderFactory: + + PROVIDER_URLS = { + ChainID.MAINNET: 'https://api.myetherapi.com/eth', + # also https://api.myetherapi.com/rop + ChainID.ROPSTEN: 'https://ropsten.infura.io', } - return decoded_method + @classmethod + def create(cls, chain_id=ChainID.MAINNET): + url = cls.PROVIDER_URLS[chain_id] + return HTTPProvider(url) -def decode_transaction_log(log): - """ - Given a transaction event log. - 1) downloads the ABI associated to the recipient address - 2) uses it to decode methods calls - """ - contract_address = log.address - contract_abi = get_contract_abi(contract_address) - topics = log.topics - log_data = log.data - decoded_method = decode_method(contract_abi, topics, log_data) - pprint(decoded_method) +class TransactionDebugger: -def decode_transaction_logs(eth, transaction_hash): - """ - Given a transaction hash, reads and decode the event log. - Params: - eth: web3.eth.Eth instance - """ - transaction_receipt = eth.getTransactionReceipt(transaction_hash) - logs = transaction_receipt.logs - for log in logs: - decode_transaction_log(log) + def __init__(self, chain_id=ChainID.MAINNET): + self.chain_id = chain_id + self.provider = HTTPProviderFactory.create(chain_id) + self.web3 = Web3(self.provider) + # print("blockNumber:", self.web3.eth.blockNumber) + + def get_contract_abi(self, contract_address): + """ + Given a contract address returns the contract ABI from Etherscan, + refs #2 + """ + location = os.path.realpath( + os.path.join(os.getcwd(), os.path.dirname(__file__))) + api_key_path = str(os.path.join(location, 'api_key.json')) + with open(api_key_path, mode='r') as key_file: + key = json.loads(key_file.read())['key'] + ChainEtherscanContract = ChainEtherscanContractFactory.create( + self.chain_id) + api = ChainEtherscanContract(address=contract_address, api_key=key) + json_abi = api.get_abi() + abi = json.loads(json_abi) + return abi + + @staticmethod + def get_methods_infos(contract_abi): + """ + List of infos for each events. + """ + methods_infos = {} + # only retrieves functions and events, other existing types are: + # "fallback" and "constructor" + types = ['function', 'event'] + methods = [a for a in contract_abi if a['type'] in types] + for description in methods: + method_name = description['name'] + types = ','.join([x['type'] for x in description['inputs']]) + event_definition = "%s(%s)" % (method_name, types) + event_sha3 = Web3.sha3(text=event_definition) + method_info = { + 'definition': event_definition, + 'sha3': event_sha3, + 'abi': description, + } + methods_infos.update({method_name: method_info}) + return methods_infos + + @classmethod + def decode_method(cls, contract_abi, topics, log_data): + """ + Given a topic and log data, decode the event. + """ + topic = topics[0] + # each indexed field generates a new topics and is excluded from data + # hence we consider topics[1:] like data, assuming indexed fields + # always come first + # see https://codeburst.io/deep-dive-into-ethereum-logs-a8d2047c7371 + topics_log_data = b"".join(topics[1:]) + log_data = log_data.lower().replace("0x", "") + log_data = bytes.fromhex(log_data) + topics_log_data += log_data + methods_infos = cls.get_methods_infos(contract_abi) + method_info = None + for event, info in methods_infos.items(): + if info['sha3'].lower() == topic.lower(): + method_info = info + event_inputs = method_info['abi']['inputs'] + types = [e_input['type'] for e_input in event_inputs] + names = [e_input['name'] for e_input in event_inputs] + values = eth_abi.decode_abi(types, topics_log_data) + call = {name: value for name, value in zip(names, values)} + decoded_method = { + 'method_info': method_info, + 'call': call, + } + return decoded_method + + def decode_transaction_log(self, log): + """ + Given a transaction event log. + 1) downloads the ABI associated to the recipient address + 2) uses it to decode methods calls + """ + contract_address = log.address + contract_abi = self.get_contract_abi(contract_address) + topics = log.topics + log_data = log.data + decoded_method = self.decode_method(contract_abi, topics, log_data) + # pprint(decoded_method) + return decoded_method + + def decode_transaction_logs(self, transaction_hash): + """ + Given a transaction hash, reads and decode the event log. + Params: + eth: web3.eth.Eth instance + """ + decoded_methods = [] + transaction_receipt = self.web3.eth.getTransactionReceipt( + transaction_hash) + logs = transaction_receipt.logs + for log in logs: + decoded_methods.append(self.decode_transaction_log(log)) + return decoded_methods +# TODO: move somewhere and unit test # def decode_contract_call(contract_abi: list, call_data: str): def decode_contract_call(contract_abi, call_data): call_data = call_data.lower().replace("0x", "") @@ -153,19 +192,23 @@ def decode_contract_call(contract_abi, call_data): class Etheroll: - # Main network - # CONTRACT_ADDRESS = '0xddf0d0b9914d530e0b743808249d9af901f1bd01' - # Testnet - CONTRACT_ADDRESS = '0xFE8a5f3a7Bb446e1cB4566717691cD3139289ED4' + CONTRACT_ADDRESSES = { + ChainID.MAINNET: '0x048717Ea892F23Fb0126F00640e2b18072efd9D2', + ChainID.ROPSTEN: '0xFE8a5f3a7Bb446e1cB4566717691cD3139289ED4', + } - def __init__(self): + def __init__(self, chain_id=ChainID.MAINNET, contract_address=None): + if contract_address is None: + contract_address = self.CONTRACT_ADDRESSES[chain_id] + self.contract_address = contract_address + self.chain_id = chain_id + self.contract_address = contract_address # ethereum_tester = EthereumTester() # self.provider = EthereumTesterProvider(ethereum_tester) - self.provider = HTTPProvider('https://ropsten.infura.io') - # self.provider = HTTPProvider('https://api.myetherapi.com/rop') - # self.provider = HTTPProvider('https://api.myetherapi.com/eth') + self.provider = HTTPProviderFactory.create(self.chain_id) self.web3 = Web3(self.provider) # print("blockNumber:", self.web3.eth.blockNumber) + # TODO: hardcoded contract ABI location = os.path.realpath( os.path.join(os.getcwd(), os.path.dirname(__file__))) contract_abi_path = str(os.path.join(location, 'contract_abi.json')) @@ -182,7 +225,7 @@ def __init__(self): # contract_factory_class = ConciseContract contract_factory_class = Contract self.contract = self.web3.eth.contract( - abi=self.abi, address=self.CONTRACT_ADDRESS, + abi=self.abi, address=self.contract_address, ContractFactoryClass=contract_factory_class) def events_abi(self, contract_abi=None): @@ -234,67 +277,58 @@ def events_logs(self, event_list): event_filter = self.web3.eth.filter({ "fromBlock": "earliest", "toBlock": "latest", - "address": self.CONTRACT_ADDRESS, + "address": self.contract_address, "topics": topics, }) events_logs = event_filter.get(False) return events_logs - -def play_with_contract(): - etheroll = Etheroll() - # transaction_hash = ( - # "0x330df22df6543c9816d80e582a4213b1fc11992f317be71775f49c3d853ed5be") - # decode_transaction_logs(etheroll.web3.eth, transaction_hash) - min_bet = etheroll.contract.call().minBet() - print("min_bet:", min_bet) - # events_definitions = etheroll.events_definitions() - # print(events_definitions) - # events_signatures = etheroll.events_signatures() - # # events_logs = etheroll.events_logs(['LogBet']) - # print(events_signatures) - # pending = etheroll.contract.call().playerWithdrawPendingTransactions() - # print("pending:", pending) - - -def player_roll_dice(bet_size_ether, chances, wallet_path, wallet_password): - """ - Work in progress: - https://github.com/AndreMiras/EtherollApp/issues/1 - """ - etheroll = Etheroll() - roll_under = chances - value_wei = w3.toWei(bet_size_ether, 'ether') - gas = 310000 - gas_price = w3.toWei(20, 'gwei') - # since Account.load is hanging while decrypting the password - # we set password to None and use `w3.eth.account.decrypt` instead - account = Account.load(wallet_path, password=None) - from_address_normalized = checksum_encode(account.address) - nonce = etheroll.web3.eth.getTransactionCount(from_address_normalized) - transaction = { - # 'chainId': ChainID.ROPSTEN.value, - 'chainId': int(etheroll.web3.net.version), - 'gas': gas, - 'gasPrice': gas_price, - 'nonce': nonce, - 'value': value_wei, - } - transaction = etheroll.contract.functions.playerRollDice( - roll_under).buildTransaction(transaction) - encrypted_key = open(wallet_path).read() - private_key = w3.eth.account.decrypt(encrypted_key, wallet_password) - signed_tx = etheroll.web3.eth.account.signTransaction( - transaction, private_key) - tx_hash = etheroll.web3.eth.sendRawTransaction(signed_tx.rawTransaction) - print("tx_hash:", tx_hash.hex()) - return tx_hash - - -def main(): - # play_with_contract() - player_roll_dice() - - -if __name__ == "__main__": - main() + @staticmethod + def play_with_contract(): + """ + This is just a test method that should go away at some point. + """ + etheroll = Etheroll() + min_bet = etheroll.contract.call().minBet() + print("min_bet:", min_bet) + # events_definitions = etheroll.events_definitions() + # print(events_definitions) + # events_signatures = etheroll.events_signatures() + # # events_logs = etheroll.events_logs(['LogBet']) + # print(events_signatures) + # pending = etheroll.contract.call( + # ).playerWithdrawPendingTransactions() + # print("pending:", pending) + + def player_roll_dice( + self, bet_size_ether, chances, wallet_path, wallet_password): + """ + Work in progress: + https://github.com/AndreMiras/EtherollApp/issues/1 + """ + roll_under = chances + value_wei = w3.toWei(bet_size_ether, 'ether') + gas = 310000 + gas_price = w3.toWei(20, 'gwei') + # since Account.load is hanging while decrypting the password + # we set password to None and use `w3.eth.account.decrypt` instead + account = Account.load(wallet_path, password=None) + from_address_normalized = checksum_encode(account.address) + nonce = self.web3.eth.getTransactionCount(from_address_normalized) + transaction = { + # 'chainId': ChainID.ROPSTEN.value, + 'chainId': int(self.web3.net.version), + 'gas': gas, + 'gasPrice': gas_price, + 'nonce': nonce, + 'value': value_wei, + } + transaction = self.contract.functions.playerRollDice( + roll_under).buildTransaction(transaction) + encrypted_key = open(wallet_path).read() + private_key = w3.eth.account.decrypt(encrypted_key, wallet_password) + signed_tx = self.web3.eth.account.signTransaction( + transaction, private_key) + tx_hash = self.web3.eth.sendRawTransaction(signed_tx.rawTransaction) + print("tx_hash:", tx_hash.hex()) + return tx_hash diff --git a/src/tests/test_pyetheroll.py b/src/tests/test_pyetheroll.py index d01e948..a91dd58 100644 --- a/src/tests/test_pyetheroll.py +++ b/src/tests/test_pyetheroll.py @@ -2,8 +2,7 @@ from hexbytes.main import HexBytes -# from .. import pyetheroll -import pyetheroll +from pyetheroll import ChainID, TransactionDebugger class TestUtils(unittest.TestCase): @@ -77,7 +76,7 @@ def test_decode_method_log1(self): "3a312c226d6178223a3130302c227265706c6163656d656e74223a747275652c" "2262617365223a3130247b5b6964656e746974795d20227d227d2c226964223a" "31247b5b6964656e746974795d20227d227d275d000000000000000000000000") - decoded_method = pyetheroll.decode_method( + decoded_method = TransactionDebugger.decode_method( contract_abi, topics, log_data) # TODO: simplify that arg call for unit testing self.assertEqual( @@ -169,7 +168,7 @@ def test_decode_method_log_bet(self): '0000000000000000000000000000000000000000000000000007533f2ecb6c34' '000000000000000000000000000000000000000000000000016345785d8a0000' '0000000000000000000000000000000000000000000000000000000000000062') - decoded_method = pyetheroll.decode_method( + decoded_method = TransactionDebugger.decode_method( contract_abi, topics, log_data) self.assertEqual( decoded_method['call'], @@ -188,3 +187,26 @@ def test_decode_method_log_bet(self): self.assertEqual( decoded_method['method_info']['sha3'].hex(), '0x1cb5bfc4e69cbacf65c8e05bdb84d7a327bd6bb4c034ff82359aefd7443775c4') + + +class TestTransactionDebugger(unittest.TestCase): + + # TODO: mock the request/response + def test_decode_transaction_logs(self): + chain_id = ChainID.ROPSTEN + transaction_debugger = TransactionDebugger(chain_id) + transaction_hash = ( + "0x330df22df6543c9816d80e582a4213b1fc11992f317be71775f49c3d853ed5be") + decoded_methods = transaction_debugger.decode_transaction_logs( + transaction_hash) + self.assertEqual(len(decoded_methods), 2) + decoded_method = decoded_methods[0] + self.assertEqual( + decoded_method['method_info']['definition'], + 'Log1(address,bytes32,uint256,string,string,uint256,bytes1,uint256)' + ) + decoded_method = decoded_methods[1] + self.assertEqual( + decoded_method['method_info']['definition'], + 'LogBet(bytes32,address,uint256,uint256,uint256,uint256)' + )