#! /usr/bin/env python3 import sys import re import asyncio import logging from aiosocks import SocksConnectionError from aiohttp.client_exceptions import ClientConnectorError from decimal import Decimal import kivy kivy.require("1.10.0") from kivy.utils import platform from kivy.core.window import Window from kivy.app import App from kivy.clock import Clock from kivy.metrics import dp from kivy.properties import NumericProperty, StringProperty, ObjectProperty from kivy.uix.screenmanager import Screen from kivy.uix.behaviors import ButtonBehavior from kivymd.theming import ThemeManager from kivymd.list import TwoLineListItem, TwoLineIconListItem, ILeftBodyTouch from kivymd.button import MDIconButton, MDRaisedButton from kivymd.dialog import MDDialog from kivymd.label import MDLabel from kivymd.textfields import MDTextField from kivymd.menu import MDDropdownMenu, MDMenuItem from kivy.garden.qrcode import QRCodeWidget from pycoin.key import validate from pycoin.serialize import b2h import nowallet from nowallet.exchange_rate import fetch_exchange_rates from settings_json import settings_json __version__ = nowallet.__version__ if platform != "android": Window.size = (350, 550) # Declare screens class LoginScreen(Screen): pass class MainScreen(Screen): pass class WaitScreen(Screen): pass class UTXOScreen(Screen): pass class YPUBScreen(Screen): pass class PINScreen(Screen): pass class ZbarScreen(Screen): pass # Declare custom widgets class IconLeftSampleWidget(ILeftBodyTouch, MDIconButton): pass class BalanceLabel(ButtonBehavior, MDLabel): pass class PINButton(MDRaisedButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.height = dp(50) asyncio.ensure_future(self.bind_on_release()) async def bind_on_release(self): async for _ in self.async_bind("on_release"): app = App.get_running_app() app.update_pin_input(self.text) class UTXOListItem(TwoLineListItem): utxo = ObjectProperty() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) asyncio.ensure_future(self.bind_on_release()) async def bind_on_release(self): async for _ in self.async_bind("on_release"): app = App.get_running_app() app.utxo = self.utxo MDDropdownMenu(items=app.utxo_menu_items, width_mult=4).open(self) class MyMenuItem(MDMenuItem): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) asyncio.ensure_future(self.bind_on_release()) async def bind_on_release(self): async for _ in self.async_bind("on_release"): app = App.get_running_app() app.menu_item_handler(self.text) class ListItem(TwoLineIconListItem): icon = StringProperty("check-circle") history = ObjectProperty() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) asyncio.ensure_future(self.bind_on_release()) async def bind_on_release(self): async for _ in self.async_bind("on_release"): self.on_release() def on_release(self): app = App.get_running_app() base_url, chain = None, app.chain.chain_1209k txid = self.history.tx_obj.id() if app.explorer == "blockcypher": base_url = "https://live.blockcypher.com/{}/tx/{}/" if app.chain == nowallet.TBTC: chain = "btc-testnet" elif app.explorer == "smartbit": base_url = "https://{}.smartbit.com.au/tx/{}/" if app.chain == nowallet.BTC: chain = "www" elif app.chain == nowallet.TBTC: chain = "testnet" url = base_url.format(chain, txid) open_url(url) class FloatInput(MDTextField): pat = re.compile('[^0-9]') def insert_text(self, substring, from_undo=False): pat = self.pat if '.' in self.text: s = re.sub(pat, '', substring) else: s = '.'.join([re.sub(pat, '', s) for s in substring.split('.', 1)]) return super(FloatInput, self).insert_text(s, from_undo=from_undo) class NowalletApp(App): theme_cls = ThemeManager() theme_cls.theme_style = "Dark" theme_cls.primary_palette = "Grey" theme_cls.accent_palette = "LightGreen" units = StringProperty() currency = StringProperty() current_coin = StringProperty("0") current_fiat = StringProperty("0") current_fee = NumericProperty() current_utxo = ObjectProperty() def __init__(self, loop): self.chain = nowallet.TBTC self.loop = loop self.is_amount_inputs_locked = False self.fiat_balance = False self.bech32 = False self.exchange_rates = None self.menu_items = [{"viewclass": "MyMenuItem", "text": "View YPUB"}, {"viewclass": "MyMenuItem", "text": "Lock with PIN"}, {"viewclass": "MyMenuItem", "text": "Manage UTXOs"}, {"viewclass": "MyMenuItem", "text": "Settings"}] self.utxo_menu_items = [{"viewclass": "MyMenuItem", "text": "View Private key"}, {"viewclass": "MyMenuItem", "text": "View Redeem script"}] super().__init__() def show_dialog(self, title, message, qrdata=None, cb=None): if qrdata: dialog_height = 300 content = QRCodeWidget(data=qrdata, size=(dp(150), dp(150)), size_hint=(None, None)) else: dialog_height = 200 content = MDLabel(font_style='Body1', theme_text_color='Secondary', text=message, size_hint_y=None, valign='top') content.bind(texture_size=content.setter('size')) self.dialog = MDDialog(title=title, content=content, size_hint=(.8, None), height=dp(dialog_height), auto_dismiss=False) self.dialog.add_action_button( "Dismiss", action=cb if cb else lambda *x: self.dialog.dismiss()) self.dialog.open() async def bind_utxo_back(self): async for _ in self.root.ids.utxo_back_button.async_bind("on_release"): self.root.ids.sm.current = "main" async def bind_ypub_back(self): async for _ in self.root.ids.ypub_back_button.async_bind("on_release"): self.root.ids.sm.current = "main" async def bind_pin_back(self): async for _ in self.root.ids.pin_back_button.async_bind("on_release"): self.root.ids.sm.current = "main" async def bind_start_zbar(self): async for _ in self.root.ids.camera_button.async_bind("on_release"): self.start_zbar() def start_zbar(self): if platform != "android": return self.root.ids.sm.current = "zbar" self.root.ids.detector.start() def qrcode_handler(self, symbols): try: address, amount = nowallet.get_payable_from_BIP21URI(symbols[0]) except ValueError as ve: self.show_dialog("Error", str(ve)) return self.root.ids.address_input.text = address self.update_amounts(text=str(amount)) self.root.ids.detector.stop() self.root.ids.sm.current = "main" def menu_button_handler(self, button): if self.root.ids.sm.current == "main": MDDropdownMenu(items=self.menu_items, width_mult=4).open(button) def menu_item_handler(self, text): # Main menu items if "PUB" in text: self.root.ids.sm.current = "ypub" elif "PIN" in text: self.root.ids.sm.current = "pin" elif "UTXO" in text: self.root.ids.sm.current = "utxo" elif "Settings" in text: self.open_settings() # UTXO menu items elif self.root.ids.sm.current == "utxo": addr = self.utxo.address(self.chain.netcode) key = self.wallet.search_for_key(addr) if not key: key = self.wallet.search_for_key(addr, change=True) if "Private" in text: self.show_dialog("Private key", "", qrdata=key.wif()) if "Redeem" in text: if self.bech32: return script = b2h(key.p2wpkh_script()) self.show_dialog("Redeem script", "", qrdata=script) async def bind_fee_button(self): async for _ in self.root.ids.fee_button.async_bind("on_release"): self.fee_button_handler() def fee_button_handler(self): fee_input = self.root.ids.fee_input fee_button = self.root.ids.fee_button fee_input.disabled = not fee_input.disabled if not fee_input.disabled: fee_button.text = "Custom Fee" else: fee_button.text = "Normal Fee" fee_input.text = str(self.estimated_fee) self.current_fee = self.estimated_fee def fee_input_handler(self): text = self.root.ids.fee_input.text if text: self.current_fee = int(float(text)) def set_address_error(self, addr): netcode = self.chain.netcode is_valid = addr.strip() and validate.is_address_valid( addr.strip(), ["address", "pay_to_script"], [netcode]) == netcode self.root.ids.address_input.error = not is_valid def set_amount_error(self, amount): _amount = Decimal(amount) if amount else Decimal("0") is_valid = _amount / self.unit_factor <= self.wallet.balance self.root.ids.spend_amount_input.error = not is_valid async def do_spend(self, address, amount, fee_rate): self.spend_tuple = await self.wallet.spend( address, amount, fee_rate, rbf=self.rbf) async def bind_send_button(self): async for _ in self.root.ids.send_button.async_bind("on_release"): await self.send_button_handler() async def send_button_handler(self): addr_input = self.root.ids.address_input address = addr_input.text.strip() amount_str = self.root.ids.spend_amount_input.text amount = Decimal(amount_str) / self.unit_factor if addr_input.error or not address: self.show_dialog("Error", "Invalid address.") return elif amount > self.wallet.balance: self.show_dialog("Error", "Insufficient funds.") return elif not amount: self.show_dialog("Error", "Amount cannot be zero.") return fee_rate_sat = int(Decimal(self.current_fee)) fee_rate = nowallet.Wallet.satb_to_coinkb(fee_rate_sat) await self.do_spend(address, amount, fee_rate) logging.info("Finished doing spend") txid, decimal_fee = self.spend_tuple[:2] message = "Added a miner fee of: {} {}".format( decimal_fee, self.chain.chain_1209k.upper()) message += "\nTxID: {}...{}".format(txid[:13], txid[-13:]) self.show_dialog("Transaction sent!", message) def check_new_history(self): if self.wallet.new_history: self.update_screens() self.wallet.new_history = False @property def pub_char(self): if self.chain == nowallet.BTC: return "z" if self.bech32 else "y" elif self.chain == nowallet.TBTC: return "v" if self.bech32 else "u" async def bind_login(self): async for _ in self.root.ids.login_button.async_bind("on_release"): await self.do_login() async def do_login(self): email = self.root.ids.email_field.text passphrase = self.root.ids.pass_field.text confirm = self.root.ids.confirm_field.text if not email or not passphrase or not confirm: self.show_dialog("Error", "All fields are required.") return if passphrase != confirm: self.show_dialog("Error", "Passwords did not match.") return self.bech32 = self.root.ids.bech32_checkbox.active self.menu_items[0]["text"] = "View {}PUB".format(self.pub_char.upper()) self.root.ids.sm.current = "wait" try: await self.do_login_tasks(email, passphrase) except (SocksConnectionError, ClientConnectorError): self.show_dialog("Error", "Make sure Tor/Orbot is installed and running before using Nowallet.", cb=lambda x: sys.exit(1)) return self.update_screens() self.root.ids.sm.current = "main" await asyncio.gather( self.new_history_loop(), self.do_listen_task() ) async def do_listen_task(self): logging.info("Listening for new transactions.") await self.wallet.listen_to_addresses() async def do_login_tasks(self, email, passphrase): self.root.ids.wait_text.text = "Connecting.." server, port, proto = await nowallet.get_random_server(self.loop) connection = nowallet.Connection(self.loop, server, port, proto) await connection.do_connect() self.root.ids.wait_text.text = "Deriving Keys.." self.wallet = await self.loop.run_in_executor(None, nowallet.Wallet, email, passphrase, connection, self.loop, self.chain, self.bech32) self.root.ids.wait_text.text = "Fetching history.." await self.wallet.discover_all_keys() self.root.ids.wait_text.text = "Fetching exchange rates.." self.exchange_rates = await fetch_exchange_rates(nowallet.BTC.chain_1209k) self.root.ids.wait_text.text = "Getting fee estimate.." coinkb_fee = await self.wallet.get_fee_estimation() self.current_fee = self.estimated_fee = nowallet.Wallet.coinkb_to_satb(coinkb_fee) logging.info("Finished 'doing login tasks'") def update_screens(self): self.update_balance_screen() self.update_send_screen() self.update_recieve_screen() self.update_ypub_screen() self.update_utxo_screen() async def new_history_loop(self): while True: await asyncio.sleep(1) self.check_new_history() async def bind_balance_label(self): async for _ in self.root.ids.balance_label.async_bind("on_release"): self.toggle_balance_label() def toggle_balance_label(self): self.fiat_balance = not self.fiat_balance self.update_balance_screen() def balance_str(self, fiat=False): balance, units = None, None if not fiat: balance = self.unit_precision.format( self.wallet.balance * self.unit_factor) units = self.units else: balance = "{:.2f}".format(self.wallet.balance * self.get_rate()) units = self.currency return "{} {}".format(balance.rstrip("0").rstrip("."), units) def update_balance_screen(self): self.root.ids.balance_label.text = self.balance_str( fiat=self.fiat_balance) self.root.ids.recycleView.data_model.data = [] for hist in self.wallet.get_tx_history(): logging.info("Adding history item to balance screen\n{}".format(hist)) verb = "Sent" if hist.is_spend else "Recieved" hist_str = "{} {} {}".format( verb, hist.value * self.unit_factor, self.units) self.add_list_item(hist_str, hist) def update_utxo_screen(self): self.root.ids.utxoRecycleView.data_model.data = [] for utxo in self.wallet.utxos: value = Decimal(str(utxo.coin_value / nowallet.Wallet.COIN)) utxo_str = (self.unit_precision + " {}").format( value * self.unit_factor, self.units) self.add_utxo_list_item(utxo_str, utxo) def update_send_screen(self): self.root.ids.send_balance.text = \ "Available balance:\n" + self.balance_str() self.root.ids.fee_input.text = str(self.current_fee) def update_recieve_screen(self): address = self.update_recieve_qrcode() encoding = "bech32" if self.wallet.bech32 else "P2SH" self.root.ids.addr_label.text = \ "Current Address ({}):\n{}".format(encoding, address) def update_recieve_qrcode(self): address = self.wallet.get_address( self.wallet.get_next_unused_key(), addr=True) logging.info("Current address: {}".format(address)) amount = Decimal(self.current_coin) / self.unit_factor self.root.ids.addr_qrcode.data = \ "bitcoin:{}?amount={}".format(address, amount) return address def update_ypub_screen(self): ypub = self.wallet.ypub ypub = self.pub_char + ypub[1:] self.root.ids.ypub_label.text = "Extended Public Key (SegWit):\n" + ypub self.root.ids.ypub_qrcode.data = ypub def lock_UI(self, pin): if not pin: self.show_dialog("Error", "PIN field is empty.") return self.pin = pin self.root.ids.pin_back_button.disabled = True self.root.ids.lock_button.text = "unlock" def unlock_UI(self, attempt): if not attempt or attempt != self.pin: self.show_dialog("Error", "Bad PIN entered.") return self.root.ids.pin_back_button.disabled = False self.root.ids.lock_button.text = "lock" def update_pin_input(self, char): pin_input = self.root.ids.pin_input if char == "clear": pin_input.text = "" elif char == "lock": self.lock_UI(pin_input.text) pin_input.text = "" elif char == "unlock": self.unlock_UI(pin_input.text) pin_input.text = "" else: pin_input.text += char def update_unit(self): self.unit_factor = 1 self.unit_precision = "{:.8f}" if self.units[0] == "m": self.unit_factor = 1000 self.unit_precision = "{:.5f}" elif self.units[0] == "u": self.unit_factor = 1000000 self.unit_precision = "{:.2f}" coin = Decimal(self.current_coin) / self.unit_factor fiat = Decimal(self.current_fiat) / self.unit_factor self.update_amount_fields(coin, fiat) def get_rate(self): rate = self.exchange_rates[self.price_api][self.currency] \ if self.exchange_rates else 1 return Decimal(str(rate)) def update_amounts(self, text=None, type="coin"): if self.is_amount_inputs_locked: return amount = Decimal(text) if text else Decimal("0") rate = self.get_rate() / self.unit_factor new_amount = None if type == "coin": new_amount = amount * rate self.update_amount_fields(amount, new_amount) elif type == "fiat": new_amount = amount / rate self.update_amount_fields(new_amount, amount) self.update_recieve_qrcode() def update_amount_fields(self, coin, fiat): self.is_amount_inputs_locked = True _coin = self.unit_precision.format(coin) self.current_coin = _coin.rstrip("0").rstrip(".") _fiat = "{:.2f}".format(fiat) self.current_fiat = _fiat.rstrip("0").rstrip(".") self.is_amount_inputs_locked = False def on_start(self): asyncio.ensure_future(self.bind_utxo_back()) asyncio.ensure_future(self.bind_ypub_back()) asyncio.ensure_future(self.bind_pin_back()) asyncio.ensure_future(self.bind_start_zbar()) asyncio.ensure_future(self.bind_fee_button()) asyncio.ensure_future(self.bind_send_button()) asyncio.ensure_future(self.bind_login()) asyncio.ensure_future(self.bind_balance_label()) def build(self): self.icon = "icons/brain.png" self.use_kivy_settings = False self.rbf = self.config.get("nowallet", "rbf") self.units = self.config.get("nowallet", "units") self.update_unit() self.currency = self.config.get("nowallet", "currency") self.explorer = self.config.get("nowallet", "explorer") self.set_price_api(self.config.get("nowallet", "price_api")) def build_config(self, config): config.setdefaults("nowallet", { "rbf": False, "units": self.chain.chain_1209k.upper(), "currency": "USD", "explorer": "blockcypher", "price_api": "BitcoinAverage"}) Window.bind(on_keyboard=self.key_input) def build_settings(self, settings): coin = self.chain.chain_1209k.upper() settings.add_json_panel("Nowallet Settings", self.config, data=settings_json(coin)) def on_config_change(self, config, section, key, value): if key == "rbf": self.rbf = value elif key == "units": self.units = value self.update_unit() self.update_amounts() self.update_balance_screen() self.update_send_screen() self.update_utxo_screen() elif key == "currency": self.currency = value self.update_amounts() elif key == "explorer": self.explorer = value elif key == "price_api": self.set_price_api(value) self.update_amounts() def set_price_api(self, val): if val == "BitcoinAverage": self.price_api = "btcav" elif val == "CryptoCompare": self.price_api = "ccomp" def key_input(self, window, key, scancode, codepoint, modifier): if key == 27: # the back button / ESC return True # override the default behaviour else: # the key now does nothing return False def on_pause(self): return True def add_list_item(self, text, history): data = self.root.ids.recycleView.data_model.data icon = "check-circle" if history.height > 0 else "timer-sand" data.append({"text": text, "secondary_text": history.tx_obj.id(), "history": history, "icon": icon}) def add_utxo_list_item(self, text, utxo): data = self.root.ids.utxoRecycleView.data_model.data data.append({"text": text, "secondary_text": utxo.as_dict()["tx_hash_hex"], "utxo": utxo}) def open_url(url): if platform == 'android': ''' Open a webpage in the default Android browser. ''' from jnius import autoclass, cast context = autoclass('org.renpy.android.PythonActivity').mActivity Uri = autoclass('android.net.Uri') Intent = autoclass('android.content.Intent') intent = Intent() intent.setAction(Intent.ACTION_VIEW) intent.setData(Uri.parse(url)) currentActivity = cast('android.app.Activity', context) currentActivity.startActivity(intent) else: import webbrowser webbrowser.open(url) if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(NowalletApp(loop).async_run())