electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

text.py (19938B)


      1 import tty
      2 import sys
      3 import curses
      4 import datetime
      5 import locale
      6 from decimal import Decimal
      7 import getpass
      8 import logging
      9 from typing import TYPE_CHECKING
     10 
     11 import electrum
     12 from electrum import util
     13 from electrum.util import format_satoshis
     14 from electrum.bitcoin import is_address, COIN
     15 from electrum.transaction import PartialTxOutput
     16 from electrum.wallet import Wallet
     17 from electrum.wallet_db import WalletDB
     18 from electrum.storage import WalletStorage
     19 from electrum.network import NetworkParameters, TxBroadcastError, BestEffortRequestFailed
     20 from electrum.interface import ServerAddr
     21 from electrum.logging import console_stderr_handler
     22 
     23 if TYPE_CHECKING:
     24     from electrum.daemon import Daemon
     25     from electrum.simple_config import SimpleConfig
     26     from electrum.plugin import Plugins
     27 
     28 
     29 _ = lambda x:x  # i18n
     30 
     31 
     32 class ElectrumGui:
     33 
     34     def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
     35 
     36         self.config = config
     37         self.network = daemon.network
     38         storage = WalletStorage(config.get_wallet_path())
     39         if not storage.file_exists():
     40             print("Wallet not found. try 'electrum create'")
     41             exit()
     42         if storage.is_encrypted():
     43             password = getpass.getpass('Password:', stream=None)
     44             storage.decrypt(password)
     45         db = WalletDB(storage.read(), manual_upgrades=False)
     46         self.wallet = Wallet(db, storage, config=config)
     47         self.wallet.start_network(self.network)
     48         self.contacts = self.wallet.contacts
     49 
     50         locale.setlocale(locale.LC_ALL, '')
     51         self.encoding = locale.getpreferredencoding()
     52 
     53         self.stdscr = curses.initscr()
     54         curses.noecho()
     55         curses.cbreak()
     56         curses.start_color()
     57         curses.use_default_colors()
     58         curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
     59         curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_CYAN)
     60         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE)
     61         self.stdscr.keypad(1)
     62         self.stdscr.border(0)
     63         self.maxy, self.maxx = self.stdscr.getmaxyx()
     64         self.set_cursor(0)
     65         self.w = curses.newwin(10, 50, 5, 5)
     66 
     67         console_stderr_handler.setLevel(logging.CRITICAL)
     68         self.tab = 0
     69         self.pos = 0
     70         self.popup_pos = 0
     71 
     72         self.str_recipient = ""
     73         self.str_description = ""
     74         self.str_amount = ""
     75         self.str_fee = ""
     76         self.history = None
     77 
     78         util.register_callback(self.update, ['wallet_updated', 'network_updated'])
     79 
     80         self.tab_names = [_("History"), _("Send"), _("Receive"), _("Addresses"), _("Contacts"), _("Banner")]
     81         self.num_tabs = len(self.tab_names)
     82 
     83 
     84     def set_cursor(self, x):
     85         try:
     86             curses.curs_set(x)
     87         except Exception:
     88             pass
     89 
     90     def restore_or_create(self):
     91         pass
     92 
     93     def verify_seed(self):
     94         pass
     95 
     96     def get_string(self, y, x):
     97         self.set_cursor(1)
     98         curses.echo()
     99         self.stdscr.addstr( y, x, " "*20, curses.A_REVERSE)
    100         s = self.stdscr.getstr(y,x)
    101         curses.noecho()
    102         self.set_cursor(0)
    103         return s
    104 
    105     def update(self, event, *args):
    106         self.update_history()
    107         if self.tab == 0:
    108             self.print_history()
    109         self.refresh()
    110 
    111     def print_history(self):
    112 
    113         width = [20, 40, 14, 14]
    114         delta = (self.maxx - sum(width) - 4)/3
    115         format_str = "%"+"%d"%width[0]+"s"+"%"+"%d"%(width[1]+delta)+"s"+"%"+"%d"%(width[2]+delta)+"s"+"%"+"%d"%(width[3]+delta)+"s"
    116 
    117         if self.history is None:
    118             self.update_history()
    119 
    120         self.print_list(self.history[::-1], format_str%( _("Date"), _("Description"), _("Amount"), _("Balance")))
    121 
    122     def update_history(self):
    123         width = [20, 40, 14, 14]
    124         delta = (self.maxx - sum(width) - 4)/3
    125         format_str = "%"+"%d"%width[0]+"s"+"%"+"%d"%(width[1]+delta)+"s"+"%"+"%d"%(width[2]+delta)+"s"+"%"+"%d"%(width[3]+delta)+"s"
    126 
    127         b = 0
    128         self.history = []
    129         for hist_item in self.wallet.get_history():
    130             if hist_item.tx_mined_status.conf:
    131                 timestamp = hist_item.tx_mined_status.timestamp
    132                 try:
    133                     time_str = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3]
    134                 except Exception:
    135                     time_str = "------"
    136             else:
    137                 time_str = 'unconfirmed'
    138 
    139             label = self.wallet.get_label_for_txid(hist_item.txid)
    140             if len(label) > 40:
    141                 label = label[0:37] + '...'
    142             self.history.append(format_str % (time_str, label, format_satoshis(hist_item.delta, whitespaces=True),
    143                                               format_satoshis(hist_item.balance, whitespaces=True)))
    144 
    145 
    146     def print_balance(self):
    147         if not self.network:
    148             msg = _("Offline")
    149         elif self.network.is_connected():
    150             if not self.wallet.up_to_date:
    151                 msg = _("Synchronizing...")
    152             else:
    153                 c, u, x =  self.wallet.get_balance()
    154                 msg = _("Balance")+": %f  "%(Decimal(c) / COIN)
    155                 if u:
    156                     msg += "  [%f unconfirmed]"%(Decimal(u) / COIN)
    157                 if x:
    158                     msg += "  [%f unmatured]"%(Decimal(x) / COIN)
    159         else:
    160             msg = _("Not connected")
    161 
    162         self.stdscr.addstr( self.maxy -1, 3, msg)
    163 
    164         for i in range(self.num_tabs):
    165             self.stdscr.addstr( 0, 2 + 2*i + len(''.join(self.tab_names[0:i])), ' '+self.tab_names[i]+' ', curses.A_BOLD if self.tab == i else 0)
    166 
    167         self.stdscr.addstr(self.maxy -1, self.maxx-30, ' '.join([_("Settings"), _("Network"), _("Quit")]))
    168 
    169     def print_receive(self):
    170         addr = self.wallet.get_receiving_address()
    171         self.stdscr.addstr(2, 1, "Address: "+addr)
    172         self.print_qr(addr)
    173 
    174     def print_contacts(self):
    175         messages = map(lambda x: "%20s   %45s "%(x[0], x[1][1]), self.contacts.items())
    176         self.print_list(messages, "%19s  %15s "%("Key", "Value"))
    177 
    178     def print_addresses(self):
    179         fmt = "%-35s  %-30s"
    180         messages = map(lambda addr: fmt % (addr, self.wallet.get_label(addr)), self.wallet.get_addresses())
    181         self.print_list(messages,   fmt % ("Address", "Label"))
    182 
    183     def print_edit_line(self, y, label, text, index, size):
    184         text += " "*(size - len(text) )
    185         self.stdscr.addstr( y, 2, label)
    186         self.stdscr.addstr( y, 15, text, curses.A_REVERSE if self.pos%6==index else curses.color_pair(1))
    187 
    188     def print_send_tab(self):
    189         self.stdscr.clear()
    190         self.print_edit_line(3, _("Pay to"), self.str_recipient, 0, 40)
    191         self.print_edit_line(5, _("Description"), self.str_description, 1, 40)
    192         self.print_edit_line(7, _("Amount"), self.str_amount, 2, 15)
    193         self.print_edit_line(9, _("Fee"), self.str_fee, 3, 15)
    194         self.stdscr.addstr( 12, 15, _("[Send]"), curses.A_REVERSE if self.pos%6==4 else curses.color_pair(2))
    195         self.stdscr.addstr( 12, 25, _("[Clear]"), curses.A_REVERSE if self.pos%6==5 else curses.color_pair(2))
    196         self.maxpos = 6
    197 
    198     def print_banner(self):
    199         if self.network and self.network.banner:
    200             banner = self.network.banner
    201             banner = banner.replace('\r', '')
    202             self.print_list(banner.split('\n'))
    203 
    204     def print_qr(self, data):
    205         import qrcode
    206         try:
    207             from StringIO import StringIO
    208         except ImportError:
    209             from io import StringIO
    210 
    211         s = StringIO()
    212         self.qr = qrcode.QRCode()
    213         self.qr.add_data(data)
    214         self.qr.print_ascii(out=s, invert=False)
    215         msg = s.getvalue()
    216         lines = msg.split('\n')
    217         try:
    218             for i, l in enumerate(lines):
    219                 l = l.encode("utf-8")
    220                 self.stdscr.addstr(i+5, 5, l, curses.color_pair(3))
    221         except curses.error:
    222             m = 'error. screen too small?'
    223             m = m.encode(self.encoding)
    224             self.stdscr.addstr(5, 1, m, 0)
    225 
    226 
    227     def print_list(self, lst, firstline = None):
    228         lst = list(lst)
    229         self.maxpos = len(lst)
    230         if not self.maxpos: return
    231         if firstline:
    232             firstline += " "*(self.maxx -2 - len(firstline))
    233             self.stdscr.addstr( 1, 1, firstline )
    234         for i in range(self.maxy-4):
    235             msg = lst[i] if i < len(lst) else ""
    236             msg += " "*(self.maxx - 2 - len(msg))
    237             m = msg[0:self.maxx - 2]
    238             m = m.encode(self.encoding)
    239             self.stdscr.addstr( i+2, 1, m, curses.A_REVERSE if i == (self.pos % self.maxpos) else 0)
    240 
    241     def refresh(self):
    242         if self.tab == -1: return
    243         self.stdscr.border(0)
    244         self.print_balance()
    245         self.stdscr.refresh()
    246 
    247     def main_command(self):
    248         c = self.stdscr.getch()
    249         print(c)
    250         cc = curses.unctrl(c).decode()
    251         if   c == curses.KEY_RIGHT: self.tab = (self.tab + 1)%self.num_tabs
    252         elif c == curses.KEY_LEFT: self.tab = (self.tab - 1)%self.num_tabs
    253         elif c == curses.KEY_DOWN: self.pos +=1
    254         elif c == curses.KEY_UP: self.pos -= 1
    255         elif c == 9: self.pos +=1 # tab
    256         elif cc in ['^W', '^C', '^X', '^Q']: self.tab = -1
    257         elif cc in ['^N']: self.network_dialog()
    258         elif cc == '^S': self.settings_dialog()
    259         else: return c
    260         if self.pos<0: self.pos=0
    261         if self.pos>=self.maxpos: self.pos=self.maxpos - 1
    262 
    263     def run_tab(self, i, print_func, exec_func):
    264         while self.tab == i:
    265             self.stdscr.clear()
    266             print_func()
    267             self.refresh()
    268             c = self.main_command()
    269             if c: exec_func(c)
    270 
    271 
    272     def run_history_tab(self, c):
    273         if c == 10:
    274             out = self.run_popup('',["blah","foo"])
    275 
    276 
    277     def edit_str(self, target, c, is_num=False):
    278         # detect backspace
    279         cc = curses.unctrl(c).decode()
    280         if c in [8, 127, 263] and target:
    281             target = target[:-1]
    282         elif not is_num or cc in '0123456789.':
    283             target += cc
    284         return target
    285 
    286 
    287     def run_send_tab(self, c):
    288         if self.pos%6 == 0:
    289             self.str_recipient = self.edit_str(self.str_recipient, c)
    290         if self.pos%6 == 1:
    291             self.str_description = self.edit_str(self.str_description, c)
    292         if self.pos%6 == 2:
    293             self.str_amount = self.edit_str(self.str_amount, c, True)
    294         elif self.pos%6 == 3:
    295             self.str_fee = self.edit_str(self.str_fee, c, True)
    296         elif self.pos%6==4:
    297             if c == 10: self.do_send()
    298         elif self.pos%6==5:
    299             if c == 10: self.do_clear()
    300 
    301 
    302     def run_receive_tab(self, c):
    303         if c == 10:
    304             out = self.run_popup('Address', ["Edit label", "Freeze", "Prioritize"])
    305 
    306     def run_contacts_tab(self, c):
    307         if c == 10 and self.contacts:
    308             out = self.run_popup('Address', ["Copy", "Pay to", "Edit label", "Delete"]).get('button')
    309             key = list(self.contacts.keys())[self.pos%len(self.contacts.keys())]
    310             if out == "Pay to":
    311                 self.tab = 1
    312                 self.str_recipient = key
    313                 self.pos = 2
    314             elif out == "Edit label":
    315                 s = self.get_string(6 + self.pos, 18)
    316                 if s:
    317                     self.wallet.set_label(key, s)
    318 
    319     def run_banner_tab(self, c):
    320         self.show_message(repr(c))
    321         pass
    322 
    323     def main(self):
    324 
    325         tty.setraw(sys.stdin)
    326         try:
    327             while self.tab != -1:
    328                 self.run_tab(0, self.print_history, self.run_history_tab)
    329                 self.run_tab(1, self.print_send_tab, self.run_send_tab)
    330                 self.run_tab(2, self.print_receive, self.run_receive_tab)
    331                 self.run_tab(3, self.print_addresses, self.run_banner_tab)
    332                 self.run_tab(4, self.print_contacts, self.run_contacts_tab)
    333                 self.run_tab(5, self.print_banner, self.run_banner_tab)
    334         except curses.error as e:
    335             raise Exception("Error with curses. Is your screen too small?") from e
    336         finally:
    337             tty.setcbreak(sys.stdin)
    338             curses.nocbreak()
    339             self.stdscr.keypad(0)
    340             curses.echo()
    341             curses.endwin()
    342 
    343     def stop(self):
    344         pass
    345 
    346     def do_clear(self):
    347         self.str_amount = ''
    348         self.str_recipient = ''
    349         self.str_fee = ''
    350         self.str_description = ''
    351 
    352     def do_send(self):
    353         if not is_address(self.str_recipient):
    354             self.show_message(_('Invalid Bitcoin address'))
    355             return
    356         try:
    357             amount = int(Decimal(self.str_amount) * COIN)
    358         except Exception:
    359             self.show_message(_('Invalid Amount'))
    360             return
    361         try:
    362             fee = int(Decimal(self.str_fee) * COIN)
    363         except Exception:
    364             self.show_message(_('Invalid Fee'))
    365             return
    366 
    367         if self.wallet.has_password():
    368             password = self.password_dialog()
    369             if not password:
    370                 return
    371         else:
    372             password = None
    373         try:
    374             tx = self.wallet.mktx(outputs=[PartialTxOutput.from_address_and_value(self.str_recipient, amount)],
    375                                   password=password,
    376                                   fee=fee)
    377         except Exception as e:
    378             self.show_message(repr(e))
    379             return
    380 
    381         if self.str_description:
    382             self.wallet.set_label(tx.txid(), self.str_description)
    383 
    384         self.show_message(_("Please wait..."), getchar=False)
    385         try:
    386             self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
    387         except TxBroadcastError as e:
    388             msg = e.get_message_for_gui()
    389             self.show_message(msg)
    390         except BestEffortRequestFailed as e:
    391             msg = repr(e)
    392             self.show_message(msg)
    393         else:
    394             self.show_message(_('Payment sent.'))
    395             self.do_clear()
    396             #self.update_contacts_tab()
    397 
    398     def show_message(self, message, getchar = True):
    399         w = self.w
    400         w.clear()
    401         w.border(0)
    402         for i, line in enumerate(message.split('\n')):
    403             w.addstr(2+i,2,line)
    404         w.refresh()
    405         if getchar: c = self.stdscr.getch()
    406 
    407     def run_popup(self, title, items):
    408         return self.run_dialog(title, list(map(lambda x: {'type':'button','label':x}, items)), interval=1, y_pos = self.pos+3)
    409 
    410     def network_dialog(self):
    411         if not self.network:
    412             return
    413         net_params = self.network.get_parameters()
    414         server_addr = net_params.server
    415         proxy_config, auto_connect = net_params.proxy, net_params.auto_connect
    416         srv = 'auto-connect' if auto_connect else str(self.network.default_server)
    417         out = self.run_dialog('Network', [
    418             {'label':'server', 'type':'str', 'value':srv},
    419             {'label':'proxy', 'type':'str', 'value':self.config.get('proxy', '')},
    420             ], buttons = 1)
    421         if out:
    422             if out.get('server'):
    423                 server_str = out.get('server')
    424                 auto_connect = server_str == 'auto-connect'
    425                 if not auto_connect:
    426                     try:
    427                         server_addr = ServerAddr.from_str(server_str)
    428                     except Exception:
    429                         self.show_message("Error:" + server_str + "\nIn doubt, type \"auto-connect\"")
    430                         return False
    431             if out.get('server') or out.get('proxy'):
    432                 proxy = electrum.network.deserialize_proxy(out.get('proxy')) if out.get('proxy') else proxy_config
    433                 net_params = NetworkParameters(server=server_addr,
    434                                                proxy=proxy,
    435                                                auto_connect=auto_connect)
    436                 self.network.run_from_another_thread(self.network.set_parameters(net_params))
    437 
    438     def settings_dialog(self):
    439         fee = str(Decimal(self.config.fee_per_kb()) / COIN)
    440         out = self.run_dialog('Settings', [
    441             {'label':'Default fee', 'type':'satoshis', 'value': fee }
    442             ], buttons = 1)
    443         if out:
    444             if out.get('Default fee'):
    445                 fee = int(Decimal(out['Default fee']) * COIN)
    446                 self.config.set_key('fee_per_kb', fee, True)
    447 
    448 
    449     def password_dialog(self):
    450         out = self.run_dialog('Password', [
    451             {'label':'Password', 'type':'password', 'value':''}
    452             ], buttons = 1)
    453         return out.get('Password')
    454 
    455 
    456     def run_dialog(self, title, items, interval=2, buttons=None, y_pos=3):
    457         self.popup_pos = 0
    458 
    459         self.w = curses.newwin( 5 + len(list(items))*interval + (2 if buttons else 0), 50, y_pos, 5)
    460         w = self.w
    461         out = {}
    462         while True:
    463             w.clear()
    464             w.border(0)
    465             w.addstr( 0, 2, title)
    466 
    467             num = len(list(items))
    468 
    469             numpos = num
    470             if buttons: numpos += 2
    471 
    472             for i in range(num):
    473                 item = items[i]
    474                 label = item.get('label')
    475                 if item.get('type') == 'list':
    476                     value = item.get('value','')
    477                 elif item.get('type') == 'satoshis':
    478                     value = item.get('value','')
    479                 elif item.get('type') == 'str':
    480                     value = item.get('value','')
    481                 elif item.get('type') == 'password':
    482                     value = '*'*len(item.get('value',''))
    483                 else:
    484                     value = ''
    485                 if value is None:
    486                     value = ''
    487                 if len(value)<20:
    488                     value += ' '*(20-len(value))
    489 
    490                 if 'value' in item:
    491                     w.addstr( 2+interval*i, 2, label)
    492                     w.addstr( 2+interval*i, 15, value, curses.A_REVERSE if self.popup_pos%numpos==i else curses.color_pair(1) )
    493                 else:
    494                     w.addstr( 2+interval*i, 2, label, curses.A_REVERSE if self.popup_pos%numpos==i else 0)
    495 
    496             if buttons:
    497                 w.addstr( 5+interval*i, 10, "[  ok  ]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-2) else curses.color_pair(2))
    498                 w.addstr( 5+interval*i, 25, "[cancel]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-1) else curses.color_pair(2))
    499 
    500             w.refresh()
    501 
    502             c = self.stdscr.getch()
    503             if c in [ord('q'), 27]: break
    504             elif c in [curses.KEY_LEFT, curses.KEY_UP]: self.popup_pos -= 1
    505             elif c in [curses.KEY_RIGHT, curses.KEY_DOWN]: self.popup_pos +=1
    506             else:
    507                 i = self.popup_pos%numpos
    508                 if buttons and c==10:
    509                     if i == numpos-2:
    510                         return out
    511                     elif i == numpos -1:
    512                         return {}
    513 
    514                 item = items[i]
    515                 _type = item.get('type')
    516 
    517                 if _type == 'str':
    518                     item['value'] = self.edit_str(item['value'], c)
    519                     out[item.get('label')] = item.get('value')
    520 
    521                 elif _type == 'password':
    522                     item['value'] = self.edit_str(item['value'], c)
    523                     out[item.get('label')] = item ['value']
    524 
    525                 elif _type == 'satoshis':
    526                     item['value'] = self.edit_str(item['value'], c, True)
    527                     out[item.get('label')] = item.get('value')
    528 
    529                 elif _type == 'list':
    530                     choices = item.get('choices')
    531                     try:
    532                         j = choices.index(item.get('value'))
    533                     except Exception:
    534                         j = 0
    535                     new_choice = choices[(j + 1)% len(choices)]
    536                     item['value'] = new_choice
    537                     out[item.get('label')] = item.get('value')
    538 
    539                 elif _type == 'button':
    540                     out['button'] = item.get('label')
    541                     break
    542 
    543         return out