ttext.py - electrum - Electrum Bitcoin wallet
HTML git clone https://git.parazyd.org/electrum
DIR Log
DIR Files
DIR Refs
DIR Submodules
---
ttext.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