tfile reorganization with top-level module - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 097ac144d976eb46dff809e1809783dc78ab6d8b DIR parent 30a7952cbb2e1c59c5eabaaee64d8a4e15a1b0cb HTML Author: Janus <ysangkok@gmail.com> Date: Wed, 11 Jul 2018 17:38:47 +0200 file reorganization with top-level module Diffstat: M .gitignore | 3 +-- M README.rst | 6 +++--- M contrib/build-osx/make_osx | 4 ++-- M contrib/build-osx/osx.spec | 193 +++++++++++++++---------------- M contrib/build-wine/build-electrum-… | 4 ++-- M contrib/build-wine/deterministic.s… | 41 +++++++++++++++---------------- M contrib/make_apk | 2 +- M contrib/make_locale | 11 +++++------ D electrum | 480 ------------------------------- M electrum-env | 2 +- A electrum/__init__.py | 14 ++++++++++++++ A electrum/base_crash_reporter.py | 128 +++++++++++++++++++++++++++++++ R lib/base_wizard.py -> electrum/bas… | 0 R lib/bitcoin.py -> electrum/bitcoin… | 0 R lib/blockchain.py -> electrum/bloc… | 0 R lib/checkpoints.json -> electrum/c… | 0 R lib/checkpoints_testnet.json -> el… | 0 R lib/coinchooser.py -> electrum/coi… | 0 A electrum/commands.py | 892 ++++++++++++++++++++++++++++++ R lib/constants.py -> electrum/const… | 0 R lib/contacts.py -> electrum/contac… | 0 R lib/crypto.py -> electrum/crypto.py | 0 R lib/currencies.json -> electrum/cu… | 0 A electrum/daemon.py | 316 +++++++++++++++++++++++++++++++ R lib/dnssec.py -> electrum/dnssec.py | 0 R lib/ecc.py -> electrum/ecc.py | 0 R lib/ecc_fast.py -> electrum/ecc_fa… | 0 A electrum/electrum | 2 ++ A electrum/exchange_rate.py | 573 +++++++++++++++++++++++++++++++ R gui/__init__.py -> electrum/gui/__… | 0 A electrum/gui/kivy/Makefile | 32 +++++++++++++++++++++++++++++++ R gui/kivy/Readme.md -> electrum/gui… | 0 R gui/kivy/__init__.py -> electrum/g… | 0 R gui/kivy/data/background.png -> el… | 0 R gui/kivy/data/fonts/Roboto-Bold.tt… | 0 R gui/kivy/data/fonts/Roboto-Condens… | 0 R gui/kivy/data/fonts/Roboto-Medium.… | 0 R gui/kivy/data/fonts/Roboto.ttf -> … | 0 R gui/kivy/data/fonts/tron/License.t… | 0 R gui/kivy/data/fonts/tron/Readme.tx… | 0 R gui/kivy/data/fonts/tron/Tr2n.ttf … | 0 R gui/kivy/data/glsl/default.fs -> e… | 0 R gui/kivy/data/glsl/default.png -> … | 0 R gui/kivy/data/glsl/default.vs -> e… | 0 R gui/kivy/data/glsl/header.fs -> el… | 0 R gui/kivy/data/glsl/header.vs -> el… | 0 R gui/kivy/data/images/defaulttheme-… | 0 R gui/kivy/data/images/defaulttheme.… | 0 R gui/kivy/data/java-classes/org/ele… | 0 R gui/kivy/data/logo/kivy-icon-32.pn… | 0 R gui/kivy/data/style.kv -> electrum… | 0 R gui/kivy/i18n.py -> electrum/gui/k… | 0 A electrum/gui/kivy/main.kv | 464 ++++++++++++++++++++++++++++++ A electrum/gui/kivy/main_window.py | 1028 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/nfc_scanner/__in… | 44 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/nfc_scanner/scan… | 242 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/nfc_scanner/scan… | 52 +++++++++++++++++++++++++++++++ R gui/kivy/theming/light/action_bar.… | 0 R gui/kivy/theming/light/action_butt… | 0 R gui/kivy/theming/light/action_grou… | 0 R gui/kivy/theming/light/action_grou… | 0 R gui/kivy/theming/light/add_contact… | 0 R gui/kivy/theming/light/arrow_back.… | 0 R gui/kivy/theming/light/bit_logo.pn… | 0 R gui/kivy/theming/light/blue_bg_rou… | 0 R gui/kivy/theming/light/btn_create_… | 0 R gui/kivy/theming/light/btn_create_… | 0 R gui/kivy/theming/light/btn_nfc.png… | 0 R gui/kivy/theming/light/btn_send_ad… | 0 R gui/kivy/theming/light/btn_send_nf… | 0 R gui/kivy/theming/light/calculator.… | 0 R gui/kivy/theming/light/camera.png … | 0 R gui/kivy/theming/light/card.png ->… | 0 R gui/kivy/theming/light/card_bottom… | 0 R gui/kivy/theming/light/card_btn.pn… | 0 R gui/kivy/theming/light/card_top.pn… | 0 R gui/kivy/theming/light/carousel_de… | 0 R gui/kivy/theming/light/carousel_se… | 0 R gui/kivy/theming/light/clock1.png … | 0 R gui/kivy/theming/light/clock2.png … | 0 R gui/kivy/theming/light/clock3.png … | 0 R gui/kivy/theming/light/clock4.png … | 0 R gui/kivy/theming/light/clock5.png … | 0 R gui/kivy/theming/light/close.png -… | 0 R gui/kivy/theming/light/closebutton… | 0 R gui/kivy/theming/light/confirmed.p… | 0 R gui/kivy/theming/light/contact.png… | 0 R gui/kivy/theming/light/contact_ove… | 0 R gui/kivy/theming/light/create_act_… | 0 R gui/kivy/theming/light/create_act_… | 0 R gui/kivy/theming/light/dialog.png … | 0 R gui/kivy/theming/light/dropdown_ba… | 0 R gui/kivy/theming/light/electrum_ic… | 0 R gui/kivy/theming/light/error.png -… | 0 R gui/kivy/theming/light/gear.png ->… | 0 R gui/kivy/theming/light/globe.png -… | 0 R gui/kivy/theming/light/icon_border… | 0 R gui/kivy/theming/light/important.p… | 0 R gui/kivy/theming/light/info.png ->… | 0 R gui/kivy/theming/light/lightblue_b… | 0 R gui/kivy/theming/light/logo.png ->… | 0 R gui/kivy/theming/light/logo_atom_d… | 0 R gui/kivy/theming/light/mail_icon.p… | 0 R gui/kivy/theming/light/manualentry… | 0 R gui/kivy/theming/light/network.png… | 0 R gui/kivy/theming/light/nfc.png -> … | 0 R gui/kivy/theming/light/nfc_clock.p… | 0 R gui/kivy/theming/light/nfc_phone.p… | 0 R gui/kivy/theming/light/nfc_stage_o… | 0 R gui/kivy/theming/light/overflow_ba… | 0 R gui/kivy/theming/light/overflow_bt… | 0 R gui/kivy/theming/light/paste_icon.… | 0 R gui/kivy/theming/light/pen.png -> … | 0 R gui/kivy/theming/light/qrcode.png … | 0 R gui/kivy/theming/light/save.png ->… | 0 R gui/kivy/theming/light/settings.pn… | 0 R gui/kivy/theming/light/shadow.png … | 0 R gui/kivy/theming/light/shadow_righ… | 0 R gui/kivy/theming/light/share.png -… | 0 R gui/kivy/theming/light/star_big_in… | 0 R gui/kivy/theming/light/stepper_ful… | 0 R gui/kivy/theming/light/stepper_lef… | 0 R gui/kivy/theming/light/stepper_res… | 0 R gui/kivy/theming/light/stepper_res… | 0 R gui/kivy/theming/light/tab.png -> … | 0 R gui/kivy/theming/light/tab_btn.png… | 0 R gui/kivy/theming/light/tab_btn_dis… | 0 R gui/kivy/theming/light/tab_btn_pre… | 0 R gui/kivy/theming/light/tab_disable… | 0 R gui/kivy/theming/light/tab_strip.p… | 0 R gui/kivy/theming/light/textinput_a… | 0 R gui/kivy/theming/light/unconfirmed… | 0 R gui/kivy/theming/light/wallet.png … | 0 R gui/kivy/theming/light/wallets.png… | 0 R gui/kivy/theming/light/white_bg_ro… | 0 R gui/kivy/tools/bitcoin_intent.xml … | 0 R gui/kivy/tools/blacklist.txt -> el… | 0 R gui/kivy/tools/buildozer.spec -> e… | 0 R gui/kivy/uix/__init__.py -> electr… | 0 R gui/kivy/uix/combobox.py -> electr… | 0 A electrum/gui/kivy/uix/context_menu… | 56 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/__in… | 220 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/addr… | 180 +++++++++++++++++++++++++++++++ R gui/kivy/uix/dialogs/amount_dialog… | 0 A electrum/gui/kivy/uix/dialogs/bump… | 118 +++++++++++++++++++++++++++++++ R gui/kivy/uix/dialogs/checkbox_dial… | 0 R gui/kivy/uix/dialogs/choice_dialog… | 0 R gui/kivy/uix/dialogs/crash_reporte… | 0 A electrum/gui/kivy/uix/dialogs/fee_… | 131 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/fx_d… | 111 ++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/inst… | 1038 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/invo… | 169 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/labe… | 55 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/nfc_… | 33 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/pass… | 142 +++++++++++++++++++++++++++++++ R gui/kivy/uix/dialogs/qr_dialog.py … | 0 A electrum/gui/kivy/uix/dialogs/qr_s… | 44 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/ques… | 53 ++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/requ… | 157 +++++++++++++++++++++++++++++++ R gui/kivy/uix/dialogs/seed_options.… | 0 A electrum/gui/kivy/uix/dialogs/sett… | 220 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/dialogs/tx_d… | 184 +++++++++++++++++++++++++++++++ R gui/kivy/uix/dialogs/wallets.py ->… | 0 R gui/kivy/uix/drawer.py -> electrum… | 0 R gui/kivy/uix/gridview.py -> electr… | 0 A electrum/gui/kivy/uix/menus.py | 95 ++++++++++++++++++++++++++++++ R gui/kivy/uix/qrcodewidget.py -> el… | 0 A electrum/gui/kivy/uix/screens.py | 484 +++++++++++++++++++++++++++++++ R gui/kivy/uix/ui_screens/about.kv -… | 0 A electrum/gui/kivy/uix/ui_screens/h… | 78 +++++++++++++++++++++++++++++++ R gui/kivy/uix/ui_screens/invoice.kv… | 0 R gui/kivy/uix/ui_screens/network.kv… | 0 R gui/kivy/uix/ui_screens/proxy.kv -… | 0 A electrum/gui/kivy/uix/ui_screens/r… | 142 +++++++++++++++++++++++++++++++ A electrum/gui/kivy/uix/ui_screens/s… | 127 +++++++++++++++++++++++++++++++ R gui/kivy/uix/ui_screens/server.kv … | 0 R gui/kivy/uix/ui_screens/status.kv … | 0 A electrum/gui/qt/__init__.py | 313 +++++++++++++++++++++++++++++++ R gui/qt/address_dialog.py -> electr… | 0 A electrum/gui/qt/address_list.py | 195 +++++++++++++++++++++++++++++++ R gui/qt/amountedit.py -> electrum/g… | 0 A electrum/gui/qt/completion_text_ed… | 120 +++++++++++++++++++++++++++++++ R gui/qt/console.py -> electrum/gui/… | 0 A electrum/gui/qt/contact_list.py | 98 +++++++++++++++++++++++++++++++ R gui/qt/exception_window.py -> elec… | 0 R gui/qt/fee_slider.py -> electrum/g… | 0 R gui/qt/history_list.py -> electrum… | 0 A electrum/gui/qt/installwizard.py | 644 +++++++++++++++++++++++++++++++ R gui/qt/invoice_list.py -> electrum… | 0 A electrum/gui/qt/main_window.py | 3220 +++++++++++++++++++++++++++++++ R gui/qt/network_dialog.py -> electr… | 0 A electrum/gui/qt/password_dialog.py | 305 +++++++++++++++++++++++++++++++ R gui/qt/paytoedit.py -> electrum/gu… | 0 R gui/qt/qrcodewidget.py -> electrum… | 0 A electrum/gui/qt/qrtextedit.py | 76 +++++++++++++++++++++++++++++++ A electrum/gui/qt/qrwindow.py | 89 +++++++++++++++++++++++++++++++ A electrum/gui/qt/request_list.py | 129 +++++++++++++++++++++++++++++++ A electrum/gui/qt/seed_dialog.py | 211 +++++++++++++++++++++++++++++++ A electrum/gui/qt/transaction_dialog… | 328 +++++++++++++++++++++++++++++++ R gui/qt/util.py -> electrum/gui/qt/… | 0 R gui/qt/utxo_list.py -> electrum/gu… | 0 R gui/stdio.py -> electrum/gui/stdio… | 0 A electrum/gui/text.py | 503 +++++++++++++++++++++++++++++++ A electrum/i18n.py | 81 ++++++++++++++++++++++++++++++ A electrum/interface.py | 407 +++++++++++++++++++++++++++++++ A electrum/jsonrpc.py | 98 +++++++++++++++++++++++++++++++ A electrum/keystore.py | 798 ++++++++++++++++++++++++++++++ A electrum/mnemonic.py | 183 +++++++++++++++++++++++++++++++ A electrum/msqr.py | 94 +++++++++++++++++++++++++++++++ A electrum/network.py | 1297 +++++++++++++++++++++++++++++++ A electrum/old_mnemonic.py | 1697 +++++++++++++++++++++++++++++++ A electrum/paymentrequest.proto | 47 +++++++++++++++++++++++++++++++ A electrum/paymentrequest.py | 523 +++++++++++++++++++++++++++++++ A electrum/paymentrequest_pb2.py | 367 ++++++++++++++++++++++++++++++ A electrum/pem.py | 191 +++++++++++++++++++++++++++++++ A electrum/plot.py | 63 +++++++++++++++++++++++++++++++ A electrum/plugin.py | 566 +++++++++++++++++++++++++++++++ A electrum/plugins/README | 31 +++++++++++++++++++++++++++++++ A electrum/plugins/__init__.py | 26 ++++++++++++++++++++++++++ A electrum/plugins/audio_modem/__ini… | 7 +++++++ A electrum/plugins/audio_modem/qt.py | 128 +++++++++++++++++++++++++++++++ A electrum/plugins/cosigner_pool/__i… | 9 +++++++++ A electrum/plugins/cosigner_pool/qt.… | 228 +++++++++++++++++++++++++++++++ A electrum/plugins/digitalbitbox/__i… | 6 ++++++ A electrum/plugins/digitalbitbox/cmd… | 14 ++++++++++++++ A electrum/plugins/digitalbitbox/dig… | 767 +++++++++++++++++++++++++++++++ A electrum/plugins/digitalbitbox/qt.… | 43 ++++++++++++++++++++++++++++++ A electrum/plugins/email_requests/__… | 5 +++++ A electrum/plugins/email_requests/qt… | 271 +++++++++++++++++++++++++++++++ A electrum/plugins/greenaddress_inst… | 5 +++++ A electrum/plugins/greenaddress_inst… | 107 +++++++++++++++++++++++++++++++ A electrum/plugins/hw_wallet/__init_… | 2 ++ A electrum/plugins/hw_wallet/cmdline… | 46 +++++++++++++++++++++++++++++++ A electrum/plugins/hw_wallet/plugin.… | 89 +++++++++++++++++++++++++++++++ A electrum/plugins/hw_wallet/qt.py | 235 +++++++++++++++++++++++++++++++ A electrum/plugins/keepkey/__init__.… | 7 +++++++ A electrum/plugins/keepkey/client.py | 14 ++++++++++++++ A electrum/plugins/keepkey/clientbas… | 250 +++++++++++++++++++++++++++++++ A electrum/plugins/keepkey/cmdline.py | 14 ++++++++++++++ A electrum/plugins/keepkey/keepkey.py | 438 +++++++++++++++++++++++++++++++ A electrum/plugins/keepkey/qt.py | 586 ++++++++++++++++++++++++++++++ A electrum/plugins/labels/__init__.py | 9 +++++++++ A electrum/plugins/labels/cmdline.py | 11 +++++++++++ A electrum/plugins/labels/kivy.py | 14 ++++++++++++++ A electrum/plugins/labels/labels.py | 167 +++++++++++++++++++++++++++++++ A electrum/plugins/labels/qt.py | 78 +++++++++++++++++++++++++++++++ A electrum/plugins/ledger/__init__.py | 7 +++++++ A electrum/plugins/ledger/auth2fa.py | 358 +++++++++++++++++++++++++++++++ A electrum/plugins/ledger/cmdline.py | 14 ++++++++++++++ A electrum/plugins/ledger/ledger.py | 637 +++++++++++++++++++++++++++++++ A electrum/plugins/ledger/qt.py | 81 ++++++++++++++++++++++++++++++ A electrum/plugins/revealer/DejaVuSa… | 0 A electrum/plugins/revealer/LICENSE_… | 99 +++++++++++++++++++++++++++++++ A electrum/plugins/revealer/SIL Open… | 44 +++++++++++++++++++++++++++++++ A electrum/plugins/revealer/SourceSa… | 0 A electrum/plugins/revealer/__init__… | 16 ++++++++++++++++ A electrum/plugins/revealer/qt.py | 724 +++++++++++++++++++++++++++++++ A electrum/plugins/trezor/__init__.py | 8 ++++++++ A electrum/plugins/trezor/client.py | 11 +++++++++++ A electrum/plugins/trezor/clientbase… | 265 +++++++++++++++++++++++++++++++ A electrum/plugins/trezor/cmdline.py | 14 ++++++++++++++ A electrum/plugins/trezor/qt.py | 613 +++++++++++++++++++++++++++++++ A electrum/plugins/trezor/transport.… | 95 ++++++++++++++++++++++++++++++ A electrum/plugins/trezor/trezor.py | 516 +++++++++++++++++++++++++++++++ A electrum/plugins/trustedcoin/__ini… | 11 +++++++++++ A electrum/plugins/trustedcoin/cmdli… | 45 +++++++++++++++++++++++++++++++ A electrum/plugins/trustedcoin/kivy.… | 110 +++++++++++++++++++++++++++++++ A electrum/plugins/trustedcoin/qt.py | 313 +++++++++++++++++++++++++++++++ A electrum/plugins/trustedcoin/trust… | 672 +++++++++++++++++++++++++++++++ A electrum/plugins/virtualkeyboard/_… | 5 +++++ A electrum/plugins/virtualkeyboard/q… | 61 +++++++++++++++++++++++++++++++ A electrum/qrscanner.py | 88 +++++++++++++++++++++++++++++++ A electrum/ripemd.py | 393 +++++++++++++++++++++++++++++++ A electrum/rsakey.py | 542 +++++++++++++++++++++++++++++++ A electrum/scripts/bip70.py | 35 +++++++++++++++++++++++++++++++ A electrum/scripts/block_headers.py | 29 +++++++++++++++++++++++++++++ A electrum/scripts/estimate_fee.py | 7 +++++++ A electrum/scripts/get_history.py | 18 ++++++++++++++++++ A electrum/scripts/peers.py | 14 ++++++++++++++ A electrum/scripts/servers.py | 10 ++++++++++ A electrum/scripts/txradar.py | 20 ++++++++++++++++++++ A electrum/scripts/util.py | 87 +++++++++++++++++++++++++++++++ A electrum/scripts/watch_address.py | 36 +++++++++++++++++++++++++++++++ A electrum/segwit_addr.py | 122 +++++++++++++++++++++++++++++++ A electrum/servers.json | 304 +++++++++++++++++++++++++++++++ A electrum/servers_regtest.json | 8 ++++++++ A electrum/servers_testnet.json | 31 +++++++++++++++++++++++++++++++ A electrum/simple_config.py | 552 ++++++++++++++++++++++++++++++ A electrum/storage.py | 645 +++++++++++++++++++++++++++++++ A electrum/synchronizer.py | 213 +++++++++++++++++++++++++++++++ A electrum/tests/__init__.py | 38 +++++++++++++++++++++++++++++++ A electrum/tests/test_bitcoin.py | 761 ++++++++++++++++++++++++++++++ A electrum/tests/test_commands.py | 33 +++++++++++++++++++++++++++++++ A electrum/tests/test_dnssec.py | 41 +++++++++++++++++++++++++++++++ A electrum/tests/test_interface.py | 28 ++++++++++++++++++++++++++++ A electrum/tests/test_mnemonic.py | 42 +++++++++++++++++++++++++++++++ A electrum/tests/test_simple_config.… | 149 +++++++++++++++++++++++++++++++ A electrum/tests/test_storage_upgrad… | 301 +++++++++++++++++++++++++++++++ A electrum/tests/test_transaction.py | 813 ++++++++++++++++++++++++++++++ A electrum/tests/test_util.py | 109 +++++++++++++++++++++++++++++++ A electrum/tests/test_wallet.py | 71 +++++++++++++++++++++++++++++++ A electrum/tests/test_wallet_vertica… | 1603 +++++++++++++++++++++++++++++++ A electrum/transaction.py | 1229 +++++++++++++++++++++++++++++++ A electrum/util.py | 903 ++++++++++++++++++++++++++++++ A electrum/verifier.py | 158 +++++++++++++++++++++++++++++++ A electrum/version.py | 18 ++++++++++++++++++ A electrum/wallet.py | 2374 +++++++++++++++++++++++++++++++ A electrum/websockets.py | 140 +++++++++++++++++++++++++++++++ A electrum/wordlist/chinese_simplifi… | 2048 +++++++++++++++++++++++++++++++ A electrum/wordlist/english.txt | 2048 +++++++++++++++++++++++++++++++ A electrum/wordlist/japanese.txt | 2048 +++++++++++++++++++++++++++++++ A electrum/wordlist/portuguese.txt | 1654 +++++++++++++++++++++++++++++++ A electrum/wordlist/spanish.txt | 2048 +++++++++++++++++++++++++++++++ A electrum/x509.py | 341 +++++++++++++++++++++++++++++++ D gui/kivy/Makefile | 32 ------------------------------- D gui/kivy/main.kv | 464 ------------------------------ D gui/kivy/main_window.py | 1028 ------------------------------- D gui/kivy/nfc_scanner/__init__.py | 44 ------------------------------- D gui/kivy/nfc_scanner/scanner_andro… | 242 ------------------------------- D gui/kivy/nfc_scanner/scanner_dummy… | 52 ------------------------------- D gui/kivy/uix/context_menu.py | 56 ------------------------------- D gui/kivy/uix/dialogs/__init__.py | 220 ------------------------------- D gui/kivy/uix/dialogs/addresses.py | 180 ------------------------------- D gui/kivy/uix/dialogs/bump_fee_dial… | 118 ------------------------------- D gui/kivy/uix/dialogs/fee_dialog.py | 131 ------------------------------- D gui/kivy/uix/dialogs/fx_dialog.py | 111 ------------------------------ D gui/kivy/uix/dialogs/installwizard… | 1038 ------------------------------- D gui/kivy/uix/dialogs/invoices.py | 169 ------------------------------- D gui/kivy/uix/dialogs/label_dialog.… | 55 ------------------------------- D gui/kivy/uix/dialogs/nfc_transacti… | 33 ------------------------------- D gui/kivy/uix/dialogs/password_dial… | 142 ------------------------------- D gui/kivy/uix/dialogs/qr_scanner.py | 44 ------------------------------- D gui/kivy/uix/dialogs/question.py | 53 ------------------------------ D gui/kivy/uix/dialogs/requests.py | 157 ------------------------------- D gui/kivy/uix/dialogs/settings.py | 220 ------------------------------- D gui/kivy/uix/dialogs/tx_dialog.py | 184 ------------------------------- D gui/kivy/uix/menus.py | 95 ------------------------------ D gui/kivy/uix/screens.py | 484 ------------------------------- D gui/kivy/uix/ui_screens/history.kv | 78 ------------------------------- D gui/kivy/uix/ui_screens/receive.kv | 142 ------------------------------- D gui/kivy/uix/ui_screens/send.kv | 127 ------------------------------- D gui/qt/__init__.py | 313 ------------------------------- D gui/qt/address_list.py | 195 ------------------------------- D gui/qt/completion_text_edit.py | 121 ------------------------------- D gui/qt/contact_list.py | 98 ------------------------------- D gui/qt/installwizard.py | 643 ------------------------------- D gui/qt/main_window.py | 3221 ------------------------------- D gui/qt/password_dialog.py | 305 ------------------------------- D gui/qt/qrtextedit.py | 76 ------------------------------- D gui/qt/qrwindow.py | 89 ------------------------------- D gui/qt/request_list.py | 129 ------------------------------- D gui/qt/seed_dialog.py | 211 ------------------------------- D gui/qt/transaction_dialog.py | 328 ------------------------------- D gui/text.py | 503 ------------------------------- D lib/__init__.py | 14 -------------- D lib/base_crash_reporter.py | 127 ------------------------------- D lib/commands.py | 892 ------------------------------ D lib/daemon.py | 316 ------------------------------- D lib/exchange_rate.py | 573 ------------------------------- D lib/i18n.py | 81 ------------------------------ D lib/interface.py | 407 ------------------------------- D lib/jsonrpc.py | 98 ------------------------------- D lib/keystore.py | 799 ------------------------------- D lib/mnemonic.py | 183 ------------------------------- D lib/msqr.py | 94 ------------------------------- D lib/network.py | 1297 ------------------------------- D lib/old_mnemonic.py | 1697 ------------------------------- D lib/paymentrequest.proto | 47 ------------------------------- D lib/paymentrequest.py | 528 ------------------------------- D lib/paymentrequest_pb2.py | 367 ------------------------------ D lib/pem.py | 191 ------------------------------- D lib/plot.py | 63 ------------------------------- D lib/plugins.py | 572 ------------------------------- D lib/qrscanner.py | 88 ------------------------------- D lib/ripemd.py | 393 ------------------------------- D lib/rsakey.py | 542 ------------------------------- D lib/segwit_addr.py | 122 ------------------------------- D lib/servers.json | 304 ------------------------------- D lib/servers_regtest.json | 8 -------- D lib/servers_testnet.json | 31 ------------------------------- D lib/simple_config.py | 552 ------------------------------ D lib/storage.py | 647 ------------------------------- D lib/synchronizer.py | 213 ------------------------------- D lib/tests/__init__.py | 38 ------------------------------- D lib/tests/test_bitcoin.py | 761 ------------------------------ D lib/tests/test_commands.py | 33 ------------------------------- D lib/tests/test_dnssec.py | 41 ------------------------------- D lib/tests/test_interface.py | 28 ---------------------------- D lib/tests/test_mnemonic.py | 42 ------------------------------- D lib/tests/test_simple_config.py | 149 ------------------------------- D lib/tests/test_storage_upgrade.py | 301 ------------------------------- D lib/tests/test_transaction.py | 813 ------------------------------ D lib/tests/test_util.py | 109 ------------------------------- D lib/tests/test_wallet.py | 71 ------------------------------- D lib/tests/test_wallet_vertical.py | 1604 ------------------------------- D lib/transaction.py | 1229 ------------------------------- D lib/util.py | 903 ------------------------------ D lib/verifier.py | 158 ------------------------------- D lib/version.py | 18 ------------------ D lib/wallet.py | 2377 ------------------------------- D lib/websockets.py | 140 ------------------------------- D lib/wordlist/chinese_simplified.txt | 2048 ------------------------------- D lib/wordlist/english.txt | 2048 ------------------------------- D lib/wordlist/japanese.txt | 2048 ------------------------------- D lib/wordlist/portuguese.txt | 1654 ------------------------------- D lib/wordlist/spanish.txt | 2048 ------------------------------- D lib/x509.py | 341 ------------------------------- D plugins/README | 31 ------------------------------- D plugins/__init__.py | 26 -------------------------- D plugins/audio_modem/__init__.py | 7 ------- D plugins/audio_modem/qt.py | 128 ------------------------------- D plugins/cosigner_pool/__init__.py | 9 --------- D plugins/cosigner_pool/qt.py | 228 ------------------------------- D plugins/digitalbitbox/__init__.py | 6 ------ D plugins/digitalbitbox/cmdline.py | 14 -------------- D plugins/digitalbitbox/digitalbitbo… | 768 ------------------------------- D plugins/digitalbitbox/qt.py | 43 ------------------------------ D plugins/email_requests/__init__.py | 5 ----- D plugins/email_requests/qt.py | 271 ------------------------------- D plugins/greenaddress_instant/__ini… | 5 ----- D plugins/greenaddress_instant/qt.py | 107 ------------------------------- D plugins/hw_wallet/__init__.py | 2 -- D plugins/hw_wallet/cmdline.py | 46 ------------------------------- D plugins/hw_wallet/plugin.py | 89 ------------------------------- D plugins/hw_wallet/qt.py | 235 ------------------------------- D plugins/keepkey/__init__.py | 7 ------- D plugins/keepkey/client.py | 14 -------------- D plugins/keepkey/clientbase.py | 250 ------------------------------- D plugins/keepkey/cmdline.py | 14 -------------- D plugins/keepkey/keepkey.py | 438 ------------------------------- D plugins/keepkey/qt.py | 586 ------------------------------ D plugins/labels/__init__.py | 9 --------- D plugins/labels/cmdline.py | 11 ----------- D plugins/labels/kivy.py | 14 -------------- D plugins/labels/labels.py | 168 ------------------------------- D plugins/labels/qt.py | 78 ------------------------------- D plugins/ledger/__init__.py | 7 ------- D plugins/ledger/auth2fa.py | 358 ------------------------------- D plugins/ledger/cmdline.py | 14 -------------- D plugins/ledger/ledger.py | 637 ------------------------------- D plugins/ledger/qt.py | 81 ------------------------------ D plugins/revealer/DejaVuSansMono-Bo… | 0 D plugins/revealer/LICENSE_DEJAVU.txt | 99 ------------------------------- D plugins/revealer/SIL Open Font Lic… | 44 ------------------------------- D plugins/revealer/SourceSansPro-Bol… | 0 D plugins/revealer/__init__.py | 17 ----------------- D plugins/revealer/qt.py | 723 ------------------------------- D plugins/trezor/__init__.py | 8 -------- D plugins/trezor/client.py | 11 ----------- D plugins/trezor/clientbase.py | 265 ------------------------------- D plugins/trezor/cmdline.py | 14 -------------- D plugins/trezor/qt.py | 613 ------------------------------- D plugins/trezor/transport.py | 95 ------------------------------ D plugins/trezor/trezor.py | 516 ------------------------------- D plugins/trustedcoin/__init__.py | 11 ----------- D plugins/trustedcoin/cmdline.py | 45 ------------------------------- D plugins/trustedcoin/kivy.py | 110 ------------------------------- D plugins/trustedcoin/qt.py | 313 ------------------------------- D plugins/trustedcoin/trustedcoin.py | 676 ------------------------------- D plugins/virtualkeyboard/__init__.py | 5 ----- D plugins/virtualkeyboard/qt.py | 61 ------------------------------- A run_electrum | 473 ++++++++++++++++++++++++++++++ D scripts/bip70 | 35 ------------------------------- D scripts/block_headers | 29 ----------------------------- D scripts/estimate_fee | 6 ------ D scripts/get_history | 18 ------------------ D scripts/peers | 14 -------------- D scripts/servers | 9 --------- D scripts/txradar | 19 ------------------- D scripts/util.py | 87 ------------------------------- D scripts/watch_address | 36 ------------------------------- M setup.py | 44 +++++++++++++++---------------- M snap/snapcraft.yaml | 2 +- M tox.ini | 2 +- 474 files changed, 51372 insertions(+), 51404 deletions(-) --- DIR diff --git a/.gitignore b/.gitignore t@@ -4,10 +4,9 @@ build/ dist/ *.egg/ -/electrum.py contrib/pyinstaller/ Electrum.egg-info/ -gui/qt/icons_rc.py +electrum/gui/qt/icons_rc.py locale/ .devlocaltmp/ *_trial_temp DIR diff --git a/README.rst b/README.rst t@@ -36,7 +36,7 @@ Electrum from its root directory, without installing it on your system; all the python dependencies are included in the 'packages' directory. To run Electrum from its root directory, just do:: - ./electrum + ./run_electrum You can also install Electrum on your system, by running this command:: t@@ -73,12 +73,12 @@ Render the SVG icons to PNGs (optional):: Compile the icons file for Qt:: sudo apt-get install pyqt5-dev-tools - pyrcc5 icons.qrc -o gui/qt/icons_rc.py + pyrcc5 icons.qrc -o electrum/gui/qt/icons_rc.py Compile the protobuf description file:: sudo apt-get install protobuf-compiler - protoc --proto_path=lib/ --python_out=lib/ lib/paymentrequest.proto + protoc --proto_path=electrum --python_out=electrum electrum/paymentrequest.proto Create translations (optional):: DIR diff --git a/contrib/build-osx/make_osx b/contrib/build-osx/make_osx t@@ -46,8 +46,8 @@ git submodule update rm -rf $BUILDDIR > /dev/null 2>&1 mkdir $BUILDDIR -cp -R ./contrib/deterministic-build/electrum-locale/locale/ ./lib/locale/ -cp ./contrib/deterministic-build/electrum-icons/icons_rc.py ./gui/qt/ +cp -R ./contrib/deterministic-build/electrum-locale/locale/ ./electrum/locale/ +cp ./contrib/deterministic-build/electrum-icons/icons_rc.py ./electrum/gui/qt/ info "Downloading libusb..." DIR diff --git a/contrib/build-osx/osx.spec b/contrib/build-osx/osx.spec t@@ -1,97 +1,96 @@ -# -*- mode: python -*- - -from PyInstaller.utils.hooks import collect_data_files, collect_submodules, collect_dynamic_libs - -import sys -import os - -PACKAGE='Electrum' -PYPKG='electrum' -MAIN_SCRIPT='electrum' -ICONS_FILE='electrum.icns' - -for i, x in enumerate(sys.argv): - if x == '--name': - VERSION = sys.argv[i+1] - break -else: - raise Exception('no version') - -electrum = os.path.abspath(".") + "/" -block_cipher = None - -# see https://github.com/pyinstaller/pyinstaller/issues/2005 -hiddenimports = [] -hiddenimports += collect_submodules('trezorlib') -hiddenimports += collect_submodules('btchip') -hiddenimports += collect_submodules('keepkeylib') -hiddenimports += collect_submodules('websocket') - -datas = [ - (electrum+'lib/*.json', PYPKG), - (electrum+'lib/wordlist/english.txt', PYPKG + '/wordlist'), - (electrum+'lib/locale', PYPKG + '/locale'), - (electrum+'plugins', PYPKG + '_plugins'), -] -datas += collect_data_files('trezorlib') -datas += collect_data_files('btchip') -datas += collect_data_files('keepkeylib') - -# Add libusb so Trezor will work -binaries = [(electrum + "contrib/build-osx/libusb-1.0.dylib", ".")] -binaries += [(electrum + "contrib/build-osx/libsecp256k1.0.dylib", ".")] - -# Workaround for "Retro Look": -binaries += [b for b in collect_dynamic_libs('PyQt5') if 'macstyle' in b[0]] - -# We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports -a = Analysis([electrum+MAIN_SCRIPT, - electrum+'gui/qt/main_window.py', - electrum+'gui/text.py', - electrum+'lib/util.py', - electrum+'lib/wallet.py', - electrum+'lib/simple_config.py', - electrum+'lib/bitcoin.py', - electrum+'lib/dnssec.py', - electrum+'lib/commands.py', - electrum+'plugins/cosigner_pool/qt.py', - electrum+'plugins/email_requests/qt.py', - electrum+'plugins/trezor/client.py', - electrum+'plugins/trezor/qt.py', - electrum+'plugins/keepkey/qt.py', - electrum+'plugins/ledger/qt.py', - ], - binaries=binaries, - datas=datas, - hiddenimports=hiddenimports, - hookspath=[]) - -# http://stackoverflow.com/questions/19055089/pyinstaller-onefile-warning-pyconfig-h-when-importing-scipy-or-scipy-signal -for d in a.datas: - if 'pyconfig' in d[0]: - a.datas.remove(d) - break - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE(pyz, - a.scripts, - a.binaries, - a.datas, - name=PACKAGE, - debug=False, - strip=False, - upx=True, - icon=electrum+ICONS_FILE, - console=False) - -app = BUNDLE(exe, - version = VERSION, - name=PACKAGE + '.app', - icon=electrum+ICONS_FILE, - bundle_identifier=None, - info_plist={ - 'NSHighResolutionCapable': 'True', - 'NSSupportsAutomaticGraphicsSwitching': 'True' - } -) +# -*- mode: python -*- + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, collect_dynamic_libs + +import sys +import os + +PACKAGE='Electrum' +PYPKG='electrum' +MAIN_SCRIPT='run_electrum' +ICONS_FILE='electrum.icns' + +for i, x in enumerate(sys.argv): + if x == '--name': + VERSION = sys.argv[i+1] + break +else: + raise Exception('no version') + +electrum = os.path.abspath(".") + "/" +block_cipher = None + +# see https://github.com/pyinstaller/pyinstaller/issues/2005 +hiddenimports = [] +hiddenimports += collect_submodules('trezorlib') +hiddenimports += collect_submodules('btchip') +hiddenimports += collect_submodules('keepkeylib') +hiddenimports += collect_submodules('websocket') + +datas = [ + (electrum+'electrum/*.json', PYPKG), + (electrum+'electrum/wordlist/english.txt', PYPKG + '/wordlist'), + (electrum+'electrum/locale', PYPKG + '/locale') +] +datas += collect_data_files('trezorlib') +datas += collect_data_files('btchip') +datas += collect_data_files('keepkeylib') + +# Add libusb so Trezor will work +binaries = [(electrum + "contrib/build-osx/libusb-1.0.dylib", ".")] +binaries += [(electrum + "contrib/build-osx/libsecp256k1.0.dylib", ".")] + +# Workaround for "Retro Look": +binaries += [b for b in collect_dynamic_libs('PyQt5') if 'macstyle' in b[0]] + +# We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports +a = Analysis([electrum+ MAIN_SCRIPT, + electrum+'electrum/gui/qt/main_window.py', + electrum+'electrum/gui/text.py', + electrum+'electrum/util.py', + electrum+'electrum/wallet.py', + electrum+'electrum/simple_config.py', + electrum+'electrum/bitcoin.py', + electrum+'electrum/dnssec.py', + electrum+'electrum/commands.py', + electrum+'electrum/plugins/cosigner_pool/qt.py', + electrum+'electrum/plugins/email_requests/qt.py', + electrum+'electrum/plugins/trezor/client.py', + electrum+'electrum/plugins/trezor/qt.py', + electrum+'electrum/plugins/keepkey/qt.py', + electrum+'electrum/plugins/ledger/qt.py', + ], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[]) + +# http://stackoverflow.com/questions/19055089/pyinstaller-onefile-warning-pyconfig-h-when-importing-scipy-or-scipy-signal +for d in a.datas: + if 'pyconfig' in d[0]: + a.datas.remove(d) + break + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.datas, + name=PACKAGE, + debug=False, + strip=False, + upx=True, + icon=electrum+ICONS_FILE, + console=False) + +app = BUNDLE(exe, + version = VERSION, + name=PACKAGE + '.app', + icon=electrum+ICONS_FILE, + bundle_identifier=None, + info_plist={ + 'NSHighResolutionCapable': 'True', + 'NSSupportsAutomaticGraphicsSwitching': 'True' + } +) DIR diff --git a/contrib/build-wine/build-electrum-git.sh b/contrib/build-wine/build-electrum-git.sh t@@ -62,8 +62,8 @@ popd rm -rf $WINEPREFIX/drive_c/electrum cp -r electrum $WINEPREFIX/drive_c/electrum cp electrum/LICENCE . -cp -r ./electrum/contrib/deterministic-build/electrum-locale/locale $WINEPREFIX/drive_c/electrum/lib/ -cp ./electrum/contrib/deterministic-build/electrum-icons/icons_rc.py $WINEPREFIX/drive_c/electrum/gui/qt/ +cp -r ./electrum/contrib/deterministic-build/electrum-locale/locale $WINEPREFIX/drive_c/electrum/electrum/ +cp ./electrum/contrib/deterministic-build/electrum-icons/icons_rc.py $WINEPREFIX/drive_c/electrum/electrum/gui/qt/ # Install frozen dependencies $PYTHON -m pip install -r ../../deterministic-build/requirements.txt DIR diff --git a/contrib/build-wine/deterministic.spec b/contrib/build-wine/deterministic.spec t@@ -31,10 +31,9 @@ binaries += [b for b in collect_dynamic_libs('PyQt5') if 'qwindowsvista' in b[0] binaries += [('C:/tmp/libsecp256k1.dll', '.')] datas = [ - (home+'lib/*.json', 'electrum'), - (home+'lib/wordlist/english.txt', 'electrum/wordlist'), - (home+'lib/locale', 'electrum/locale'), - (home+'plugins', 'electrum_plugins'), + (home+'electrum/*.json', 'electrum'), + (home+'electrum/wordlist/english.txt', 'electrum/wordlist'), + (home+'electrum/locale', 'electrum/locale'), ('C:\\Program Files (x86)\\ZBar\\bin\\', '.') ] datas += collect_data_files('trezorlib') t@@ -42,21 +41,21 @@ datas += collect_data_files('btchip') datas += collect_data_files('keepkeylib') # We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports -a = Analysis([home+'electrum', - home+'gui/qt/main_window.py', - home+'gui/text.py', - home+'lib/util.py', - home+'lib/wallet.py', - home+'lib/simple_config.py', - home+'lib/bitcoin.py', - home+'lib/dnssec.py', - home+'lib/commands.py', - home+'plugins/cosigner_pool/qt.py', - home+'plugins/email_requests/qt.py', - home+'plugins/trezor/client.py', - home+'plugins/trezor/qt.py', - home+'plugins/keepkey/qt.py', - home+'plugins/ledger/qt.py', +a = Analysis([home+'run_electrum', + home+'electrum/gui/qt/main_window.py', + home+'electrum/gui/text.py', + home+'electrum/util.py', + home+'electrum/wallet.py', + home+'electrum/simple_config.py', + home+'electrum/bitcoin.py', + home+'electrum/dnssec.py', + home+'electrum/commands.py', + home+'electrum/plugins/cosigner_pool/qt.py', + home+'electrum/plugins/email_requests/qt.py', + home+'electrum/plugins/trezor/client.py', + home+'electrum/plugins/trezor/qt.py', + home+'electrum/plugins/keepkey/qt.py', + home+'electrum/plugins/ledger/qt.py', #home+'packages/requests/utils.py' ], binaries=binaries, t@@ -68,7 +67,7 @@ a = Analysis([home+'electrum', # http://stackoverflow.com/questions/19055089/pyinstaller-onefile-warning-pyconfig-h-when-importing-scipy-or-scipy-signal for d in a.datas: - if 'pyconfig' in d[0]: + if 'pyconfig' in d[0]: a.datas.remove(d) break t@@ -85,7 +84,7 @@ exe_standalone = EXE( pyz, a.scripts, a.binaries, - a.datas, + a.datas, name=os.path.join('build\\pyi.win32\\electrum', cmdline_name + ".exe"), debug=False, strip=None, DIR diff --git a/contrib/make_apk b/contrib/make_apk t@@ -1,6 +1,6 @@ #!/bin/bash -pushd ./gui/kivy/ +pushd ./electrum/gui/kivy/ if [[ -n "$1" && "$1" == "release" ]] ; then echo -n Keystore Password: DIR diff --git a/contrib/make_locale b/contrib/make_locale t@@ -8,8 +8,7 @@ import requests os.chdir(os.path.dirname(os.path.realpath(__file__))) os.chdir('..') -code_directories = 'gui plugins lib' -cmd = "find {} -type f -name '*.py' -o -name '*.kv'".format(code_directories) +cmd = "find electrum -type f -name '*.py' -o -name '*.kv'" files = subprocess.check_output(cmd, shell=True) t@@ -19,13 +18,13 @@ with open("app.fil", "wb") as f: print("Found {} files to translate".format(len(files.splitlines()))) # Generate fresh translation template -if not os.path.exists('lib/locale'): - os.mkdir('lib/locale') -cmd = 'xgettext -s --from-code UTF-8 --language Python --no-wrap -f app.fil --output=lib/locale/messages.pot' +if not os.path.exists('electrum/locale'): + os.mkdir('electrum/locale') +cmd = 'xgettext -s --from-code UTF-8 --language Python --no-wrap -f app.fil --output=electrum/locale/messages.pot' print('Generate template') os.system(cmd) -os.chdir('lib') +os.chdir('electrum') crowdin_identifier = 'electrum' crowdin_file_name = 'files[electrum-client/messages.pot]' DIR diff --git a/electrum b/electrum t@@ -1,480 +0,0 @@ -#!/usr/bin/env python3 -# -*- mode: python -*- -# -# Electrum - lightweight Bitcoin client -# Copyright (C) 2011 thomasv@gitorious -# -# Permission is hereby granted, free of charge, to any person -# obtaining a copy of this software and associated documentation files -# (the "Software"), to deal in the Software without restriction, -# including without limitation the rights to use, copy, modify, merge, -# publish, distribute, sublicense, and/or sell copies of the Software, -# and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS -# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -import os -import sys - -script_dir = os.path.dirname(os.path.realpath(__file__)) -is_bundle = getattr(sys, 'frozen', False) -is_local = not is_bundle and os.path.exists(os.path.join(script_dir, "electrum.desktop")) -is_android = 'ANDROID_DATA' in os.environ - -# move this back to gui/kivy/__init.py once plugins are moved -os.environ['KIVY_DATA_DIR'] = os.path.abspath(os.path.dirname(__file__)) + '/gui/kivy/data/' - -if is_local or is_android: - sys.path.insert(0, os.path.join(script_dir, 'packages')) - - -def check_imports(): - # pure-python dependencies need to be imported here for pyinstaller - try: - import dns - import pyaes - import ecdsa - import requests - import qrcode - import pbkdf2 - import google.protobuf - import jsonrpclib - except ImportError as e: - sys.exit("Error: %s. Try 'sudo pip install <module-name>'"%str(e)) - # the following imports are for pyinstaller - from google.protobuf import descriptor - from google.protobuf import message - from google.protobuf import reflection - from google.protobuf import descriptor_pb2 - from jsonrpclib import SimpleJSONRPCServer - # make sure that certificates are here - assert os.path.exists(requests.utils.DEFAULT_CA_BUNDLE_PATH) - - -if not is_android: - check_imports() - -# load local module as electrum -if is_local or is_android: - import imp - imp.load_module('electrum', *imp.find_module('lib')) - imp.load_module('electrum_gui', *imp.find_module('gui')) - - - -from electrum import bitcoin, util -from electrum import constants -from electrum import SimpleConfig, Network -from electrum.wallet import Wallet, Imported_Wallet -from electrum.storage import WalletStorage, get_derivation_used_for_hw_device_encryption -from electrum.util import print_msg, print_stderr, json_encode, json_decode, UserCancelled -from electrum.util import set_verbosity, InvalidPassword -from electrum.commands import get_parser, known_commands, Commands, config_variables -from electrum import daemon -from electrum import keystore -from electrum.mnemonic import Mnemonic - -# get password routine -def prompt_password(prompt, confirm=True): - import getpass - password = getpass.getpass(prompt, stream=None) - if password and confirm: - password2 = getpass.getpass("Confirm: ") - if password != password2: - sys.exit("Error: Passwords do not match.") - if not password: - password = None - return password - - - -def run_non_RPC(config): - cmdname = config.get('cmd') - - storage = WalletStorage(config.get_wallet_path()) - if storage.file_exists(): - sys.exit("Error: Remove the existing wallet first!") - - def password_dialog(): - return prompt_password("Password (hit return if you do not wish to encrypt your wallet):") - - if cmdname == 'restore': - text = config.get('text').strip() - passphrase = config.get('passphrase', '') - password = password_dialog() if keystore.is_private(text) else None - if keystore.is_address_list(text): - wallet = Imported_Wallet(storage) - for x in text.split(): - wallet.import_address(x) - elif keystore.is_private_key_list(text): - k = keystore.Imported_KeyStore({}) - storage.put('keystore', k.dump()) - storage.put('use_encryption', bool(password)) - wallet = Imported_Wallet(storage) - for x in text.split(): - wallet.import_private_key(x, password) - storage.write() - else: - if keystore.is_seed(text): - k = keystore.from_seed(text, passphrase, False) - elif keystore.is_master_key(text): - k = keystore.from_master_key(text) - else: - sys.exit("Error: Seed or key not recognized") - if password: - k.update_password(None, password) - storage.put('keystore', k.dump()) - storage.put('wallet_type', 'standard') - storage.put('use_encryption', bool(password)) - storage.write() - wallet = Wallet(storage) - if not config.get('offline'): - network = Network(config) - network.start() - wallet.start_threads(network) - print_msg("Recovering wallet...") - wallet.synchronize() - wallet.wait_until_synchronized() - wallet.stop_threads() - # note: we don't wait for SPV - msg = "Recovery successful" if wallet.is_found() else "Found no history for this wallet" - else: - msg = "This wallet was restored offline. It may contain more addresses than displayed." - print_msg(msg) - - elif cmdname == 'create': - password = password_dialog() - passphrase = config.get('passphrase', '') - seed_type = 'segwit' if config.get('segwit') else 'standard' - seed = Mnemonic('en').make_seed(seed_type) - k = keystore.from_seed(seed, passphrase, False) - storage.put('keystore', k.dump()) - storage.put('wallet_type', 'standard') - wallet = Wallet(storage) - wallet.update_password(None, password, True) - wallet.synchronize() - print_msg("Your wallet generation seed is:\n\"%s\"" % seed) - print_msg("Please keep it in a safe place; if you lose it, you will not be able to restore your wallet.") - - wallet.storage.write() - print_msg("Wallet saved in '%s'" % wallet.storage.path) - sys.exit(0) - - -def init_daemon(config_options): - config = SimpleConfig(config_options) - storage = WalletStorage(config.get_wallet_path()) - if not storage.file_exists(): - print_msg("Error: Wallet file not found.") - print_msg("Type 'electrum create' to create a new wallet, or provide a path to a wallet with the -w option") - sys.exit(0) - if storage.is_encrypted(): - if storage.is_encrypted_with_hw_device(): - plugins = init_plugins(config, 'cmdline') - password = get_password_for_hw_device_encrypted_storage(plugins) - elif config.get('password'): - password = config.get('password') - else: - password = prompt_password('Password:', False) - if not password: - print_msg("Error: Password required") - sys.exit(1) - else: - password = None - config_options['password'] = password - - -def init_cmdline(config_options, server): - config = SimpleConfig(config_options) - cmdname = config.get('cmd') - cmd = known_commands[cmdname] - - if cmdname == 'signtransaction' and config.get('privkey'): - cmd.requires_wallet = False - cmd.requires_password = False - - if cmdname in ['payto', 'paytomany'] and config.get('unsigned'): - cmd.requires_password = False - - if cmdname in ['payto', 'paytomany'] and config.get('broadcast'): - cmd.requires_network = True - - # instantiate wallet for command-line - storage = WalletStorage(config.get_wallet_path()) - - if cmd.requires_wallet and not storage.file_exists(): - print_msg("Error: Wallet file not found.") - print_msg("Type 'electrum create' to create a new wallet, or provide a path to a wallet with the -w option") - sys.exit(0) - - # important warning - if cmd.name in ['getprivatekeys']: - print_stderr("WARNING: ALL your private keys are secret.") - print_stderr("Exposing a single private key can compromise your entire wallet!") - print_stderr("In particular, DO NOT use 'redeem private key' services proposed by third parties.") - - # commands needing password - if (cmd.requires_wallet and storage.is_encrypted() and server is None)\ - or (cmd.requires_password and (storage.get('use_encryption') or storage.is_encrypted())): - if storage.is_encrypted_with_hw_device(): - # this case is handled later in the control flow - password = None - elif config.get('password'): - password = config.get('password') - else: - password = prompt_password('Password:', False) - if not password: - print_msg("Error: Password required") - sys.exit(1) - else: - password = None - - config_options['password'] = password - - if cmd.name == 'password': - new_password = prompt_password('New password:') - config_options['new_password'] = new_password - - return cmd, password - - -def get_connected_hw_devices(plugins): - support = plugins.get_hardware_support() - if not support: - print_msg('No hardware wallet support found on your system.') - sys.exit(1) - # scan devices - devices = [] - devmgr = plugins.device_manager - for name, description, plugin in support: - try: - u = devmgr.unpaired_device_infos(None, plugin) - except: - devmgr.print_error("error", name) - continue - devices += list(map(lambda x: (name, x), u)) - return devices - - -def get_password_for_hw_device_encrypted_storage(plugins): - devices = get_connected_hw_devices(plugins) - if len(devices) == 0: - print_msg("Error: No connected hw device found. Cannot decrypt this wallet.") - sys.exit(1) - elif len(devices) > 1: - print_msg("Warning: multiple hardware devices detected. " - "The first one will be used to decrypt the wallet.") - # FIXME we use the "first" device, in case of multiple ones - name, device_info = devices[0] - plugin = plugins.get_plugin(name) - derivation = get_derivation_used_for_hw_device_encryption() - try: - xpub = plugin.get_xpub(device_info.device.id_, derivation, 'standard', plugin.handler) - except UserCancelled: - sys.exit(0) - password = keystore.Xpub.get_pubkey_from_xpub(xpub, ()) - return password - - -def run_offline_command(config, config_options, plugins): - cmdname = config.get('cmd') - cmd = known_commands[cmdname] - password = config_options.get('password') - if cmd.requires_wallet: - storage = WalletStorage(config.get_wallet_path()) - if storage.is_encrypted(): - if storage.is_encrypted_with_hw_device(): - password = get_password_for_hw_device_encrypted_storage(plugins) - config_options['password'] = password - storage.decrypt(password) - wallet = Wallet(storage) - else: - wallet = None - # check password - if cmd.requires_password and wallet.has_password(): - try: - seed = wallet.check_password(password) - except InvalidPassword: - print_msg("Error: This password does not decode this wallet.") - sys.exit(1) - if cmd.requires_network: - print_msg("Warning: running command offline") - # arguments passed to function - args = [config.get(x) for x in cmd.params] - # decode json arguments - if cmdname not in ('setconfig',): - args = list(map(json_decode, args)) - # options - kwargs = {} - for x in cmd.options: - kwargs[x] = (config_options.get(x) if x in ['password', 'new_password'] else config.get(x)) - cmd_runner = Commands(config, wallet, None) - func = getattr(cmd_runner, cmd.name) - result = func(*args, **kwargs) - # save wallet - if wallet: - wallet.storage.write() - return result - -def init_plugins(config, gui_name): - from electrum.plugins import Plugins - return Plugins(config, is_local or is_android, gui_name) - - -if __name__ == '__main__': - # The hook will only be used in the Qt GUI right now - util.setup_thread_excepthook() - # on macOS, delete Process Serial Number arg generated for apps launched in Finder - sys.argv = list(filter(lambda x: not x.startswith('-psn'), sys.argv)) - - # old 'help' syntax - if len(sys.argv) > 1 and sys.argv[1] == 'help': - sys.argv.remove('help') - sys.argv.append('-h') - - # read arguments from stdin pipe and prompt - for i, arg in enumerate(sys.argv): - if arg == '-': - if not sys.stdin.isatty(): - sys.argv[i] = sys.stdin.read() - break - else: - raise Exception('Cannot get argument from stdin') - elif arg == '?': - sys.argv[i] = input("Enter argument:") - elif arg == ':': - sys.argv[i] = prompt_password('Enter argument (will not echo):', False) - - # parse command line - parser = get_parser() - args = parser.parse_args() - - # config is an object passed to the various constructors (wallet, interface, gui) - if is_android: - config_options = { - 'verbose': True, - 'cmd': 'gui', - 'gui': 'kivy', - } - else: - config_options = args.__dict__ - f = lambda key: config_options[key] is not None and key not in config_variables.get(args.cmd, {}).keys() - config_options = {key: config_options[key] for key in filter(f, config_options.keys())} - if config_options.get('server'): - config_options['auto_connect'] = False - - config_options['cwd'] = os.getcwd() - - # fixme: this can probably be achieved with a runtime hook (pyinstaller) - if is_bundle and os.path.exists(os.path.join(sys._MEIPASS, 'is_portable')): - config_options['portable'] = True - - if config_options.get('portable'): - config_options['electrum_path'] = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'electrum_data') - - # kivy sometimes freezes when we write to sys.stderr - set_verbosity(config_options.get('verbose') and config_options.get('gui')!='kivy') - - # check uri - uri = config_options.get('url') - if uri: - if not uri.startswith('bitcoin:'): - print_stderr('unknown command:', uri) - sys.exit(1) - config_options['url'] = uri - - # todo: defer this to gui - config = SimpleConfig(config_options) - cmdname = config.get('cmd') - - if config.get('testnet'): - constants.set_testnet() - elif config.get('regtest'): - constants.set_regtest() - elif config.get('simnet'): - constants.set_simnet() - - # run non-RPC commands separately - if cmdname in ['create', 'restore']: - run_non_RPC(config) - sys.exit(0) - - if cmdname == 'gui': - fd, server = daemon.get_fd_or_server(config) - if fd is not None: - plugins = init_plugins(config, config.get('gui', 'qt')) - d = daemon.Daemon(config, fd, True) - d.start() - d.init_gui(config, plugins) - sys.exit(0) - else: - result = server.gui(config_options) - - elif cmdname == 'daemon': - subcommand = config.get('subcommand') - if subcommand in ['load_wallet']: - init_daemon(config_options) - - if subcommand in [None, 'start']: - fd, server = daemon.get_fd_or_server(config) - if fd is not None: - if subcommand == 'start': - pid = os.fork() - if pid: - print_stderr("starting daemon (PID %d)" % pid) - sys.exit(0) - init_plugins(config, 'cmdline') - d = daemon.Daemon(config, fd, False) - d.start() - if config.get('websocket_server'): - from electrum import websockets - websockets.WebSocketServer(config, d.network).start() - if config.get('requests_dir'): - path = os.path.join(config.get('requests_dir'), 'index.html') - if not os.path.exists(path): - print("Requests directory not configured.") - print("You can configure it using https://github.com/spesmilo/electrum-merchant") - sys.exit(1) - d.join() - sys.exit(0) - else: - result = server.daemon(config_options) - else: - server = daemon.get_server(config) - if server is not None: - result = server.daemon(config_options) - else: - print_msg("Daemon not running") - sys.exit(1) - else: - # command line - server = daemon.get_server(config) - init_cmdline(config_options, server) - if server is not None: - result = server.run_cmdline(config_options) - else: - cmd = known_commands[cmdname] - if cmd.requires_network: - print_msg("Daemon not running; try 'electrum daemon start'") - sys.exit(1) - else: - plugins = init_plugins(config, 'cmdline') - result = run_offline_command(config, config_options, plugins) - # print result - if isinstance(result, str): - print_msg(result) - elif type(result) is dict and result.get('error'): - print_stderr(result.get('error')) - elif result is not None: - print_msg(json_encode(result)) - sys.exit(0) DIR diff --git a/electrum-env b/electrum-env t@@ -22,6 +22,6 @@ fi export PYTHONPATH="/usr/local/lib/python${PYTHON_VER}/site-packages:$PYTHONPATH" -./electrum "$@" +./run_electrum "$@" deactivate DIR diff --git a/electrum/__init__.py b/electrum/__init__.py t@@ -0,0 +1,14 @@ +from .version import ELECTRUM_VERSION +from .util import format_satoshis, print_msg, print_error, set_verbosity +from .wallet import Synchronizer, Wallet +from .storage import WalletStorage +from .coinchooser import COIN_CHOOSERS +from .network import Network, pick_random_server +from .interface import Connection, Interface +from .simple_config import SimpleConfig, get_config, set_config +from . import bitcoin +from . import transaction +from . import daemon +from .transaction import Transaction +from .plugin import BasePlugin +from .commands import Commands, known_commands DIR diff --git a/electrum/base_crash_reporter.py b/electrum/base_crash_reporter.py t@@ -0,0 +1,128 @@ +# Electrum - lightweight Bitcoin client +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import json +import locale +import traceback +import subprocess +import sys +import os + +import requests + +from .version import ELECTRUM_VERSION +from .import constants +from .i18n import _ + + +class BaseCrashReporter(object): + report_server = "https://crashhub.electrum.org" + config_key = "show_crash_reporter" + issue_template = """<h2>Traceback</h2> +<pre> +{traceback} +</pre> + +<h2>Additional information</h2> +<ul> + <li>Electrum version: {app_version}</li> + <li>Python version: {python_version}</li> + <li>Operating system: {os}</li> + <li>Wallet type: {wallet_type}</li> + <li>Locale: {locale}</li> +</ul> + """ + CRASH_MESSAGE = _('Something went wrong while executing Electrum.') + CRASH_TITLE = _('Sorry!') + REQUEST_HELP_MESSAGE = _('To help us diagnose and fix the problem, you can send us a bug report that contains ' + 'useful debug information:') + DESCRIBE_ERROR_MESSAGE = _("Please briefly describe what led to the error (optional):") + ASK_CONFIRM_SEND = _("Do you want to send this report?") + + def __init__(self, exctype, value, tb): + self.exc_args = (exctype, value, tb) + + def send_report(self, endpoint="/crash"): + if constants.net.GENESIS[-4:] not in ["4943", "e26f"] and ".electrum.org" in BaseCrashReporter.report_server: + # Gah! Some kind of altcoin wants to send us crash reports. + raise Exception(_("Missing report URL.")) + report = self.get_traceback_info() + report.update(self.get_additional_info()) + report = json.dumps(report) + response = requests.post(BaseCrashReporter.report_server + endpoint, data=report) + return response + + def get_traceback_info(self): + exc_string = str(self.exc_args[1]) + stack = traceback.extract_tb(self.exc_args[2]) + readable_trace = "".join(traceback.format_list(stack)) + id = { + "file": stack[-1].filename, + "name": stack[-1].name, + "type": self.exc_args[0].__name__ + } + return { + "exc_string": exc_string, + "stack": readable_trace, + "id": id + } + + def get_additional_info(self): + args = { + "app_version": ELECTRUM_VERSION, + "python_version": sys.version, + "os": self.get_os_version(), + "wallet_type": "unknown", + "locale": locale.getdefaultlocale()[0] or "?", + "description": self.get_user_description() + } + try: + args["wallet_type"] = self.get_wallet_type() + except: + # Maybe the wallet isn't loaded yet + pass + try: + args["app_version"] = self.get_git_version() + except: + # This is probably not running from source + pass + return args + + @staticmethod + def get_git_version(): + dir = os.path.dirname(os.path.realpath(sys.argv[0])) + version = subprocess.check_output( + ['git', 'describe', '--always', '--dirty'], cwd=dir) + return str(version, "utf8").strip() + + def get_report_string(self): + info = self.get_additional_info() + info["traceback"] = "".join(traceback.format_exception(*self.exc_args)) + return self.issue_template.format(**info) + + def get_user_description(self): + raise NotImplementedError + + def get_wallet_type(self): + raise NotImplementedError + + def get_os_version(self): + raise NotImplementedError DIR diff --git a/lib/base_wizard.py b/electrum/base_wizard.py DIR diff --git a/lib/bitcoin.py b/electrum/bitcoin.py DIR diff --git a/lib/blockchain.py b/electrum/blockchain.py DIR diff --git a/lib/checkpoints.json b/electrum/checkpoints.json DIR diff --git a/lib/checkpoints_testnet.json b/electrum/checkpoints_testnet.json DIR diff --git a/lib/coinchooser.py b/electrum/coinchooser.py DIR diff --git a/electrum/commands.py b/electrum/commands.py t@@ -0,0 +1,892 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2011 thomasv@gitorious +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys +import datetime +import copy +import argparse +import json +import ast +import base64 +from functools import wraps +from decimal import Decimal + +from .import util, ecc +from .util import bfh, bh2u, format_satoshis, json_decode, print_error, json_encode +from . import bitcoin +from .bitcoin import is_address, hash_160, COIN, TYPE_ADDRESS +from .i18n import _ +from .transaction import Transaction, multisig_script +from .paymentrequest import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED +from .plugin import run_hook + +known_commands = {} + + +def satoshis(amount): + # satoshi conversion must not be performed by the parser + return int(COIN*Decimal(amount)) if amount not in ['!', None] else amount + + +class Command: + def __init__(self, func, s): + self.name = func.__name__ + self.requires_network = 'n' in s + self.requires_wallet = 'w' in s + self.requires_password = 'p' in s + self.description = func.__doc__ + self.help = self.description.split('.')[0] if self.description else None + varnames = func.__code__.co_varnames[1:func.__code__.co_argcount] + self.defaults = func.__defaults__ + if self.defaults: + n = len(self.defaults) + self.params = list(varnames[:-n]) + self.options = list(varnames[-n:]) + else: + self.params = list(varnames) + self.options = [] + self.defaults = [] + + +def command(s): + def decorator(func): + global known_commands + name = func.__name__ + known_commands[name] = Command(func, s) + @wraps(func) + def func_wrapper(*args, **kwargs): + c = known_commands[func.__name__] + wallet = args[0].wallet + password = kwargs.get('password') + if c.requires_wallet and wallet is None: + raise Exception("wallet not loaded. Use 'electrum daemon load_wallet'") + if c.requires_password and password is None and wallet.has_password(): + return {'error': 'Password required' } + return func(*args, **kwargs) + return func_wrapper + return decorator + + +class Commands: + + def __init__(self, config, wallet, network, callback = None): + self.config = config + self.wallet = wallet + self.network = network + self._callback = callback + + def _run(self, method, args, password_getter): + # this wrapper is called from the python console + cmd = known_commands[method] + if cmd.requires_password and self.wallet.has_password(): + password = password_getter() + if password is None: + return + else: + password = None + + f = getattr(self, method) + if cmd.requires_password: + result = f(*args, **{'password':password}) + else: + result = f(*args) + + if self._callback: + self._callback() + return result + + @command('') + def commands(self): + """List of commands""" + return ' '.join(sorted(known_commands.keys())) + + @command('') + def create(self, segwit=False): + """Create a new wallet""" + raise Exception('Not a JSON-RPC command') + + @command('wn') + def restore(self, text): + """Restore a wallet from text. Text can be a seed phrase, a master + public key, a master private key, a list of bitcoin addresses + or bitcoin private keys. If you want to be prompted for your + seed, type '?' or ':' (concealed) """ + raise Exception('Not a JSON-RPC command') + + @command('wp') + def password(self, password=None, new_password=None): + """Change wallet password. """ + if self.wallet.storage.is_encrypted_with_hw_device() and new_password: + raise Exception("Can't change the password of a wallet encrypted with a hw device.") + b = self.wallet.storage.is_encrypted() + self.wallet.update_password(password, new_password, b) + self.wallet.storage.write() + return {'password':self.wallet.has_password()} + + @command('') + def getconfig(self, key): + """Return a configuration variable. """ + return self.config.get(key) + + @classmethod + def _setconfig_normalize_value(cls, key, value): + if key not in ('rpcuser', 'rpcpassword'): + value = json_decode(value) + try: + value = ast.literal_eval(value) + except: + pass + return value + + @command('') + def setconfig(self, key, value): + """Set a configuration variable. 'value' may be a string or a Python expression.""" + value = self._setconfig_normalize_value(key, value) + self.config.set_key(key, value) + return True + + @command('') + def make_seed(self, nbits=132, language=None, segwit=False): + """Create a seed""" + from .mnemonic import Mnemonic + t = 'segwit' if segwit else 'standard' + s = Mnemonic(language).make_seed(t, nbits) + return s + + @command('n') + def getaddresshistory(self, address): + """Return the transaction history of any address. Note: This is a + walletless server query, results are not checked by SPV. + """ + sh = bitcoin.address_to_scripthash(address) + return self.network.get_history_for_scripthash(sh) + + @command('w') + def listunspent(self): + """List unspent outputs. Returns the list of unspent transaction + outputs in your wallet.""" + l = copy.deepcopy(self.wallet.get_utxos(exclude_frozen=False)) + for i in l: + v = i["value"] + i["value"] = str(Decimal(v)/COIN) if v is not None else None + return l + + @command('n') + def getaddressunspent(self, address): + """Returns the UTXO list of any address. Note: This + is a walletless server query, results are not checked by SPV. + """ + sh = bitcoin.address_to_scripthash(address) + return self.network.listunspent_for_scripthash(sh) + + @command('') + def serialize(self, jsontx): + """Create a transaction from json inputs. + Inputs must have a redeemPubkey. + Outputs must be a list of {'address':address, 'value':satoshi_amount}. + """ + keypairs = {} + inputs = jsontx.get('inputs') + outputs = jsontx.get('outputs') + locktime = jsontx.get('lockTime', 0) + for txin in inputs: + if txin.get('output'): + prevout_hash, prevout_n = txin['output'].split(':') + txin['prevout_n'] = int(prevout_n) + txin['prevout_hash'] = prevout_hash + sec = txin.get('privkey') + if sec: + txin_type, privkey, compressed = bitcoin.deserialize_privkey(sec) + pubkey = ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed) + keypairs[pubkey] = privkey, compressed + txin['type'] = txin_type + txin['x_pubkeys'] = [pubkey] + txin['signatures'] = [None] + txin['num_sig'] = 1 + + outputs = [(TYPE_ADDRESS, x['address'], int(x['value'])) for x in outputs] + tx = Transaction.from_io(inputs, outputs, locktime=locktime) + tx.sign(keypairs) + return tx.as_dict() + + @command('wp') + def signtransaction(self, tx, privkey=None, password=None): + """Sign a transaction. The wallet keys will be used unless a private key is provided.""" + tx = Transaction(tx) + if privkey: + txin_type, privkey2, compressed = bitcoin.deserialize_privkey(privkey) + pubkey_bytes = ecc.ECPrivkey(privkey2).get_public_key_bytes(compressed=compressed) + h160 = bitcoin.hash_160(pubkey_bytes) + x_pubkey = 'fd' + bh2u(b'\x00' + h160) + tx.sign({x_pubkey:(privkey2, compressed)}) + else: + self.wallet.sign_transaction(tx, password) + return tx.as_dict() + + @command('') + def deserialize(self, tx): + """Deserialize a serialized transaction""" + tx = Transaction(tx) + return tx.deserialize() + + @command('n') + def broadcast(self, tx): + """Broadcast a transaction to the network. """ + tx = Transaction(tx) + return self.network.broadcast_transaction(tx) + + @command('') + def createmultisig(self, num, pubkeys): + """Create multisig address""" + assert isinstance(pubkeys, list), (type(num), type(pubkeys)) + redeem_script = multisig_script(pubkeys, num) + address = bitcoin.hash160_to_p2sh(hash_160(bfh(redeem_script))) + return {'address':address, 'redeemScript':redeem_script} + + @command('w') + def freeze(self, address): + """Freeze address. Freeze the funds at one of your wallet\'s addresses""" + return self.wallet.set_frozen_state([address], True) + + @command('w') + def unfreeze(self, address): + """Unfreeze address. Unfreeze the funds at one of your wallet\'s address""" + return self.wallet.set_frozen_state([address], False) + + @command('wp') + def getprivatekeys(self, address, password=None): + """Get private keys of addresses. You may pass a single wallet address, or a list of wallet addresses.""" + if isinstance(address, str): + address = address.strip() + if is_address(address): + return self.wallet.export_private_key(address, password)[0] + domain = address + return [self.wallet.export_private_key(address, password)[0] for address in domain] + + @command('w') + def ismine(self, address): + """Check if address is in wallet. Return true if and only address is in wallet""" + return self.wallet.is_mine(address) + + @command('') + def dumpprivkeys(self): + """Deprecated.""" + return "This command is deprecated. Use a pipe instead: 'electrum listaddresses | electrum getprivatekeys - '" + + @command('') + def validateaddress(self, address): + """Check that an address is valid. """ + return is_address(address) + + @command('w') + def getpubkeys(self, address): + """Return the public keys for a wallet address. """ + return self.wallet.get_public_keys(address) + + @command('w') + def getbalance(self): + """Return the balance of your wallet. """ + c, u, x = self.wallet.get_balance() + out = {"confirmed": str(Decimal(c)/COIN)} + if u: + out["unconfirmed"] = str(Decimal(u)/COIN) + if x: + out["unmatured"] = str(Decimal(x)/COIN) + return out + + @command('n') + def getaddressbalance(self, address): + """Return the balance of any address. Note: This is a walletless + server query, results are not checked by SPV. + """ + sh = bitcoin.address_to_scripthash(address) + out = self.network.get_balance_for_scripthash(sh) + out["confirmed"] = str(Decimal(out["confirmed"])/COIN) + out["unconfirmed"] = str(Decimal(out["unconfirmed"])/COIN) + return out + + @command('n') + def getmerkle(self, txid, height): + """Get Merkle branch of a transaction included in a block. Electrum + uses this to verify transactions (Simple Payment Verification).""" + return self.network.get_merkle_for_transaction(txid, int(height)) + + @command('n') + def getservers(self): + """Return the list of available servers""" + return self.network.get_servers() + + @command('') + def version(self): + """Return the version of Electrum.""" + from .version import ELECTRUM_VERSION + return ELECTRUM_VERSION + + @command('w') + def getmpk(self): + """Get master public key. Return your wallet\'s master public key""" + return self.wallet.get_master_public_key() + + @command('wp') + def getmasterprivate(self, password=None): + """Get master private key. Return your wallet\'s master private key""" + return str(self.wallet.keystore.get_master_private_key(password)) + + @command('wp') + def getseed(self, password=None): + """Get seed phrase. Print the generation seed of your wallet.""" + s = self.wallet.get_seed(password) + return s + + @command('wp') + def importprivkey(self, privkey, password=None): + """Import a private key.""" + if not self.wallet.can_import_privkey(): + return "Error: This type of wallet cannot import private keys. Try to create a new wallet with that key." + try: + addr = self.wallet.import_private_key(privkey, password) + out = "Keypair imported: " + addr + except BaseException as e: + out = "Error: " + str(e) + return out + + def _resolver(self, x): + if x is None: + return None + out = self.wallet.contacts.resolve(x) + if out.get('type') == 'openalias' and self.nocheck is False and out.get('validated') is False: + raise Exception('cannot verify alias', x) + return out['address'] + + @command('n') + def sweep(self, privkey, destination, fee=None, nocheck=False, imax=100): + """Sweep private keys. Returns a transaction that spends UTXOs from + privkey to a destination address. The transaction is not + broadcasted.""" + from .wallet import sweep + tx_fee = satoshis(fee) + privkeys = privkey.split() + self.nocheck = nocheck + #dest = self._resolver(destination) + tx = sweep(privkeys, self.network, self.config, destination, tx_fee, imax) + return tx.as_dict() if tx else None + + @command('wp') + def signmessage(self, address, message, password=None): + """Sign a message with a key. Use quotes if your message contains + whitespaces""" + sig = self.wallet.sign_message(address, message, password) + return base64.b64encode(sig).decode('ascii') + + @command('') + def verifymessage(self, address, signature, message): + """Verify a signature.""" + sig = base64.b64decode(signature) + message = util.to_bytes(message) + return ecc.verify_message_with_address(address, sig, message) + + def _mktx(self, outputs, fee, change_addr, domain, nocheck, unsigned, rbf, password, locktime=None): + self.nocheck = nocheck + change_addr = self._resolver(change_addr) + domain = None if domain is None else map(self._resolver, domain) + final_outputs = [] + for address, amount in outputs: + address = self._resolver(address) + amount = satoshis(amount) + final_outputs.append((TYPE_ADDRESS, address, amount)) + + coins = self.wallet.get_spendable_coins(domain, self.config) + tx = self.wallet.make_unsigned_transaction(coins, final_outputs, self.config, fee, change_addr) + if locktime != None: + tx.locktime = locktime + if rbf is None: + rbf = self.config.get('use_rbf', True) + if rbf: + tx.set_rbf(True) + if not unsigned: + self.wallet.sign_transaction(tx, password) + return tx + + @command('wp') + def payto(self, destination, amount, fee=None, from_addr=None, change_addr=None, nocheck=False, unsigned=False, rbf=None, password=None, locktime=None): + """Create a transaction. """ + tx_fee = satoshis(fee) + domain = from_addr.split(',') if from_addr else None + tx = self._mktx([(destination, amount)], tx_fee, change_addr, domain, nocheck, unsigned, rbf, password, locktime) + return tx.as_dict() + + @command('wp') + def paytomany(self, outputs, fee=None, from_addr=None, change_addr=None, nocheck=False, unsigned=False, rbf=None, password=None, locktime=None): + """Create a multi-output transaction. """ + tx_fee = satoshis(fee) + domain = from_addr.split(',') if from_addr else None + tx = self._mktx(outputs, tx_fee, change_addr, domain, nocheck, unsigned, rbf, password, locktime) + return tx.as_dict() + + @command('w') + def history(self, year=None, show_addresses=False, show_fiat=False): + """Wallet history. Returns the transaction history of your wallet.""" + kwargs = {'show_addresses': show_addresses} + if year: + import time + start_date = datetime.datetime(year, 1, 1) + end_date = datetime.datetime(year+1, 1, 1) + kwargs['from_timestamp'] = time.mktime(start_date.timetuple()) + kwargs['to_timestamp'] = time.mktime(end_date.timetuple()) + if show_fiat: + from .exchange_rate import FxThread + fx = FxThread(self.config, None) + kwargs['fx'] = fx + return json_encode(self.wallet.get_full_history(**kwargs)) + + @command('w') + def setlabel(self, key, label): + """Assign a label to an item. Item may be a bitcoin address or a + transaction ID""" + self.wallet.set_label(key, label) + + @command('w') + def listcontacts(self): + """Show your list of contacts""" + return self.wallet.contacts + + @command('w') + def getalias(self, key): + """Retrieve alias. Lookup in your list of contacts, and for an OpenAlias DNS record.""" + return self.wallet.contacts.resolve(key) + + @command('w') + def searchcontacts(self, query): + """Search through contacts, return matching entries. """ + results = {} + for key, value in self.wallet.contacts.items(): + if query.lower() in key.lower(): + results[key] = value + return results + + @command('w') + def listaddresses(self, receiving=False, change=False, labels=False, frozen=False, unused=False, funded=False, balance=False): + """List wallet addresses. Returns the list of all addresses in your wallet. Use optional arguments to filter the results.""" + out = [] + for addr in self.wallet.get_addresses(): + if frozen and not self.wallet.is_frozen(addr): + continue + if receiving and self.wallet.is_change(addr): + continue + if change and not self.wallet.is_change(addr): + continue + if unused and self.wallet.is_used(addr): + continue + if funded and self.wallet.is_empty(addr): + continue + item = addr + if labels or balance: + item = (item,) + if balance: + item += (format_satoshis(sum(self.wallet.get_addr_balance(addr))),) + if labels: + item += (repr(self.wallet.labels.get(addr, '')),) + out.append(item) + return out + + @command('n') + def gettransaction(self, txid): + """Retrieve a transaction. """ + if self.wallet and txid in self.wallet.transactions: + tx = self.wallet.transactions[txid] + else: + raw = self.network.get_transaction(txid) + if raw: + tx = Transaction(raw) + else: + raise Exception("Unknown transaction") + return tx.as_dict() + + @command('') + def encrypt(self, pubkey, message): + """Encrypt a message with a public key. Use quotes if the message contains whitespaces.""" + public_key = ecc.ECPubkey(bfh(pubkey)) + encrypted = public_key.encrypt_message(message) + return encrypted + + @command('wp') + def decrypt(self, pubkey, encrypted, password=None): + """Decrypt a message encrypted with a public key.""" + return self.wallet.decrypt_message(pubkey, encrypted, password) + + def _format_request(self, out): + pr_str = { + PR_UNKNOWN: 'Unknown', + PR_UNPAID: 'Pending', + PR_PAID: 'Paid', + PR_EXPIRED: 'Expired', + } + out['amount (BTC)'] = format_satoshis(out.get('amount')) + out['status'] = pr_str[out.get('status', PR_UNKNOWN)] + return out + + @command('w') + def getrequest(self, key): + """Return a payment request""" + r = self.wallet.get_payment_request(key, self.config) + if not r: + raise Exception("Request not found") + return self._format_request(r) + + #@command('w') + #def ackrequest(self, serialized): + # """<Not implemented>""" + # pass + + @command('w') + def listrequests(self, pending=False, expired=False, paid=False): + """List the payment requests you made.""" + out = self.wallet.get_sorted_requests(self.config) + if pending: + f = PR_UNPAID + elif expired: + f = PR_EXPIRED + elif paid: + f = PR_PAID + else: + f = None + if f is not None: + out = list(filter(lambda x: x.get('status')==f, out)) + return list(map(self._format_request, out)) + + @command('w') + def createnewaddress(self): + """Create a new receiving address, beyond the gap limit of the wallet""" + return self.wallet.create_new_address(False) + + @command('w') + def getunusedaddress(self): + """Returns the first unused address of the wallet, or None if all addresses are used. + An address is considered as used if it has received a transaction, or if it is used in a payment request.""" + return self.wallet.get_unused_address() + + @command('w') + def addrequest(self, amount, memo='', expiration=None, force=False): + """Create a payment request, using the first unused address of the wallet. + The address will be considered as used after this operation. + If no payment is received, the address will be considered as unused if the payment request is deleted from the wallet.""" + addr = self.wallet.get_unused_address() + if addr is None: + if force: + addr = self.wallet.create_new_address(False) + else: + return False + amount = satoshis(amount) + expiration = int(expiration) if expiration else None + req = self.wallet.make_payment_request(addr, amount, memo, expiration) + self.wallet.add_payment_request(req, self.config) + out = self.wallet.get_payment_request(addr, self.config) + return self._format_request(out) + + @command('w') + def addtransaction(self, tx): + """ Add a transaction to the wallet history """ + tx = Transaction(tx) + if not self.wallet.add_transaction(tx.txid(), tx): + return False + self.wallet.save_transactions() + return tx.txid() + + @command('wp') + def signrequest(self, address, password=None): + "Sign payment request with an OpenAlias" + alias = self.config.get('alias') + if not alias: + raise Exception('No alias in your configuration') + alias_addr = self.wallet.contacts.resolve(alias)['address'] + self.wallet.sign_payment_request(address, alias, alias_addr, password) + + @command('w') + def rmrequest(self, address): + """Remove a payment request""" + return self.wallet.remove_payment_request(address, self.config) + + @command('w') + def clearrequests(self): + """Remove all payment requests""" + for k in list(self.wallet.receive_requests.keys()): + self.wallet.remove_payment_request(k, self.config) + + @command('n') + def notify(self, address, URL): + """Watch an address. Every time the address changes, a http POST is sent to the URL.""" + def callback(x): + import urllib.request + headers = {'content-type':'application/json'} + data = {'address':address, 'status':x.get('result')} + serialized_data = util.to_bytes(json.dumps(data)) + try: + req = urllib.request.Request(URL, serialized_data, headers) + response_stream = urllib.request.urlopen(req, timeout=5) + util.print_error('Got Response for %s' % address) + except BaseException as e: + util.print_error(str(e)) + self.network.subscribe_to_addresses([address], callback) + return True + + @command('wn') + def is_synchronized(self): + """ return wallet synchronization status """ + return self.wallet.is_up_to_date() + + @command('n') + def getfeerate(self, fee_method=None, fee_level=None): + """Return current suggested fee rate (in sat/kvByte), according to config + settings or supplied parameters. + """ + if fee_method is None: + dyn, mempool = None, None + elif fee_method.lower() == 'static': + dyn, mempool = False, False + elif fee_method.lower() == 'eta': + dyn, mempool = True, False + elif fee_method.lower() == 'mempool': + dyn, mempool = True, True + else: + raise Exception('Invalid fee estimation method: {}'.format(fee_method)) + if fee_level is not None: + fee_level = Decimal(fee_level) + return self.config.fee_per_kb(dyn=dyn, mempool=mempool, fee_level=fee_level) + + @command('') + def help(self): + # for the python console + return sorted(known_commands.keys()) + +param_descriptions = { + 'privkey': 'Private key. Type \'?\' to get a prompt.', + 'destination': 'Bitcoin address, contact or alias', + 'address': 'Bitcoin address', + 'seed': 'Seed phrase', + 'txid': 'Transaction ID', + 'pos': 'Position', + 'height': 'Block height', + 'tx': 'Serialized transaction (hexadecimal)', + 'key': 'Variable name', + 'pubkey': 'Public key', + 'message': 'Clear text message. Use quotes if it contains spaces.', + 'encrypted': 'Encrypted message', + 'amount': 'Amount to be sent (in BTC). Type \'!\' to send the maximum available.', + 'requested_amount': 'Requested amount (in BTC).', + 'outputs': 'list of ["address", amount]', + 'redeem_script': 'redeem script (hexadecimal)', +} + +command_options = { + 'password': ("-W", "Password"), + 'new_password':(None, "New Password"), + 'receiving': (None, "Show only receiving addresses"), + 'change': (None, "Show only change addresses"), + 'frozen': (None, "Show only frozen addresses"), + 'unused': (None, "Show only unused addresses"), + 'funded': (None, "Show only funded addresses"), + 'balance': ("-b", "Show the balances of listed addresses"), + 'labels': ("-l", "Show the labels of listed addresses"), + 'nocheck': (None, "Do not verify aliases"), + 'imax': (None, "Maximum number of inputs"), + 'fee': ("-f", "Transaction fee (in BTC)"), + 'from_addr': ("-F", "Source address (must be a wallet address; use sweep to spend from non-wallet address)."), + 'change_addr': ("-c", "Change address. Default is a spare address, or the source address if it's not in the wallet"), + 'nbits': (None, "Number of bits of entropy"), + 'segwit': (None, "Create segwit seed"), + 'language': ("-L", "Default language for wordlist"), + 'privkey': (None, "Private key. Set to '?' to get a prompt."), + 'unsigned': ("-u", "Do not sign transaction"), + 'rbf': (None, "Replace-by-fee transaction"), + 'locktime': (None, "Set locktime block number"), + 'domain': ("-D", "List of addresses"), + 'memo': ("-m", "Description of the request"), + 'expiration': (None, "Time in seconds"), + 'timeout': (None, "Timeout in seconds"), + 'force': (None, "Create new address beyond gap limit, if no more addresses are available."), + 'pending': (None, "Show only pending requests."), + 'expired': (None, "Show only expired requests."), + 'paid': (None, "Show only paid requests."), + 'show_addresses': (None, "Show input and output addresses"), + 'show_fiat': (None, "Show fiat value of transactions"), + 'year': (None, "Show history for a given year"), + 'fee_method': (None, "Fee estimation method to use"), + 'fee_level': (None, "Float between 0.0 and 1.0, representing fee slider position") +} + + +# don't use floats because of rounding errors +from .transaction import tx_from_str +json_loads = lambda x: json.loads(x, parse_float=lambda x: str(Decimal(x))) +arg_types = { + 'num': int, + 'nbits': int, + 'imax': int, + 'year': int, + 'tx': tx_from_str, + 'pubkeys': json_loads, + 'jsontx': json_loads, + 'inputs': json_loads, + 'outputs': json_loads, + 'fee': lambda x: str(Decimal(x)) if x is not None else None, + 'amount': lambda x: str(Decimal(x)) if x != '!' else '!', + 'locktime': int, + 'fee_method': str, + 'fee_level': json_loads, +} + +config_variables = { + + 'addrequest': { + 'requests_dir': 'directory where a bip70 file will be written.', + 'ssl_privkey': 'Path to your SSL private key, needed to sign the request.', + 'ssl_chain': 'Chain of SSL certificates, needed for signed requests. Put your certificate at the top and the root CA at the end', + 'url_rewrite': 'Parameters passed to str.replace(), in order to create the r= part of bitcoin: URIs. Example: \"(\'file:///var/www/\',\'https://electrum.org/\')\"', + }, + 'listrequests':{ + 'url_rewrite': 'Parameters passed to str.replace(), in order to create the r= part of bitcoin: URIs. Example: \"(\'file:///var/www/\',\'https://electrum.org/\')\"', + } +} + +def set_default_subparser(self, name, args=None): + """see http://stackoverflow.com/questions/5176691/argparse-how-to-specify-a-default-subcommand""" + subparser_found = False + for arg in sys.argv[1:]: + if arg in ['-h', '--help']: # global help if no subparser + break + else: + for x in self._subparsers._actions: + if not isinstance(x, argparse._SubParsersAction): + continue + for sp_name in x._name_parser_map.keys(): + if sp_name in sys.argv[1:]: + subparser_found = True + if not subparser_found: + # insert default in first position, this implies no + # global options without a sub_parsers specified + if args is None: + sys.argv.insert(1, name) + else: + args.insert(0, name) + +argparse.ArgumentParser.set_default_subparser = set_default_subparser + + +# workaround https://bugs.python.org/issue23058 +# see https://github.com/nickstenning/honcho/pull/121 + +def subparser_call(self, parser, namespace, values, option_string=None): + from argparse import ArgumentError, SUPPRESS, _UNRECOGNIZED_ARGS_ATTR + parser_name = values[0] + arg_strings = values[1:] + # set the parser name if requested + if self.dest is not SUPPRESS: + setattr(namespace, self.dest, parser_name) + # select the parser + try: + parser = self._name_parser_map[parser_name] + except KeyError: + tup = parser_name, ', '.join(self._name_parser_map) + msg = _('unknown parser {!r} (choices: {})').format(*tup) + raise ArgumentError(self, msg) + # parse all the remaining options into the namespace + # store any unrecognized options on the object, so that the top + # level parser can decide what to do with them + namespace, arg_strings = parser.parse_known_args(arg_strings, namespace) + if arg_strings: + vars(namespace).setdefault(_UNRECOGNIZED_ARGS_ATTR, []) + getattr(namespace, _UNRECOGNIZED_ARGS_ATTR).extend(arg_strings) + +argparse._SubParsersAction.__call__ = subparser_call + + +def add_network_options(parser): + parser.add_argument("-1", "--oneserver", action="store_true", dest="oneserver", default=None, help="connect to one server only") + parser.add_argument("-s", "--server", dest="server", default=None, help="set server host:port:protocol, where protocol is either t (tcp) or s (ssl)") + parser.add_argument("-p", "--proxy", dest="proxy", default=None, help="set proxy [type:]host[:port], where type is socks4,socks5 or http") + +def add_global_options(parser): + group = parser.add_argument_group('global options') + group.add_argument("-v", "--verbose", action="store_true", dest="verbose", default=False, help="Show debugging information") + group.add_argument("-D", "--dir", dest="electrum_path", help="electrum directory") + group.add_argument("-P", "--portable", action="store_true", dest="portable", default=False, help="Use local 'electrum_data' directory") + group.add_argument("-w", "--wallet", dest="wallet_path", help="wallet path") + group.add_argument("--testnet", action="store_true", dest="testnet", default=False, help="Use Testnet") + group.add_argument("--regtest", action="store_true", dest="regtest", default=False, help="Use Regtest") + group.add_argument("--simnet", action="store_true", dest="simnet", default=False, help="Use Simnet") + +def get_parser(): + # create main parser + parser = argparse.ArgumentParser( + epilog="Run 'electrum help <command>' to see the help for a command") + add_global_options(parser) + subparsers = parser.add_subparsers(dest='cmd', metavar='<command>') + # gui + parser_gui = subparsers.add_parser('gui', description="Run Electrum's Graphical User Interface.", help="Run GUI (default)") + parser_gui.add_argument("url", nargs='?', default=None, help="bitcoin URI (or bip70 file)") + parser_gui.add_argument("-g", "--gui", dest="gui", help="select graphical user interface", choices=['qt', 'kivy', 'text', 'stdio']) + parser_gui.add_argument("-o", "--offline", action="store_true", dest="offline", default=False, help="Run offline") + parser_gui.add_argument("-m", action="store_true", dest="hide_gui", default=False, help="hide GUI on startup") + parser_gui.add_argument("-L", "--lang", dest="language", default=None, help="default language used in GUI") + add_network_options(parser_gui) + add_global_options(parser_gui) + # daemon + parser_daemon = subparsers.add_parser('daemon', help="Run Daemon") + parser_daemon.add_argument("subcommand", choices=['start', 'status', 'stop', 'load_wallet', 'close_wallet'], nargs='?') + #parser_daemon.set_defaults(func=run_daemon) + add_network_options(parser_daemon) + add_global_options(parser_daemon) + # commands + for cmdname in sorted(known_commands.keys()): + cmd = known_commands[cmdname] + p = subparsers.add_parser(cmdname, help=cmd.help, description=cmd.description) + add_global_options(p) + if cmdname == 'restore': + p.add_argument("-o", "--offline", action="store_true", dest="offline", default=False, help="Run offline") + for optname, default in zip(cmd.options, cmd.defaults): + a, help = command_options[optname] + b = '--' + optname + action = "store_true" if type(default) is bool else 'store' + args = (a, b) if a else (b,) + if action == 'store': + _type = arg_types.get(optname, str) + p.add_argument(*args, dest=optname, action=action, default=default, help=help, type=_type) + else: + p.add_argument(*args, dest=optname, action=action, default=default, help=help) + + for param in cmd.params: + h = param_descriptions.get(param, '') + _type = arg_types.get(param, str) + p.add_argument(param, help=h, type=_type) + + cvh = config_variables.get(cmdname) + if cvh: + group = p.add_argument_group('configuration variables', '(set with setconfig/getconfig)') + for k, v in cvh.items(): + group.add_argument(k, nargs='?', help=v) + + # 'gui' is the default command + parser.set_default_subparser('gui') + return parser DIR diff --git a/lib/constants.py b/electrum/constants.py DIR diff --git a/lib/contacts.py b/electrum/contacts.py DIR diff --git a/lib/crypto.py b/electrum/crypto.py DIR diff --git a/lib/currencies.json b/electrum/currencies.json DIR diff --git a/electrum/daemon.py b/electrum/daemon.py t@@ -0,0 +1,316 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2015 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import ast +import os +import time +import traceback +import sys + +# from jsonrpc import JSONRPCResponseManager +import jsonrpclib +from .jsonrpc import VerifyingJSONRPCServer + +from .version import ELECTRUM_VERSION +from .network import Network +from .util import json_decode, DaemonThread +from .util import print_error, to_string +from .wallet import Wallet +from .storage import WalletStorage +from .commands import known_commands, Commands +from .simple_config import SimpleConfig +from .exchange_rate import FxThread +from .plugin import run_hook + + +def get_lockfile(config): + return os.path.join(config.path, 'daemon') + + +def remove_lockfile(lockfile): + os.unlink(lockfile) + + +def get_fd_or_server(config): + '''Tries to create the lockfile, using O_EXCL to + prevent races. If it succeeds it returns the FD. + Otherwise try and connect to the server specified in the lockfile. + If this succeeds, the server is returned. Otherwise remove the + lockfile and try again.''' + lockfile = get_lockfile(config) + while True: + try: + return os.open(lockfile, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644), None + except OSError: + pass + server = get_server(config) + if server is not None: + return None, server + # Couldn't connect; remove lockfile and try again. + remove_lockfile(lockfile) + + +def get_server(config): + lockfile = get_lockfile(config) + while True: + create_time = None + try: + with open(lockfile) as f: + (host, port), create_time = ast.literal_eval(f.read()) + rpc_user, rpc_password = get_rpc_credentials(config) + if rpc_password == '': + # authentication disabled + server_url = 'http://%s:%d' % (host, port) + else: + server_url = 'http://%s:%s@%s:%d' % ( + rpc_user, rpc_password, host, port) + server = jsonrpclib.Server(server_url) + # Test daemon is running + server.ping() + return server + except Exception as e: + print_error("[get_server]", e) + if not create_time or create_time < time.time() - 1.0: + return None + # Sleep a bit and try again; it might have just been started + time.sleep(1.0) + + +def get_rpc_credentials(config): + rpc_user = config.get('rpcuser', None) + rpc_password = config.get('rpcpassword', None) + if rpc_user is None or rpc_password is None: + rpc_user = 'user' + import ecdsa, base64 + bits = 128 + nbytes = bits // 8 + (bits % 8 > 0) + pw_int = ecdsa.util.randrange(pow(2, bits)) + pw_b64 = base64.b64encode( + pw_int.to_bytes(nbytes, 'big'), b'-_') + rpc_password = to_string(pw_b64, 'ascii') + config.set_key('rpcuser', rpc_user) + config.set_key('rpcpassword', rpc_password, save=True) + elif rpc_password == '': + from .util import print_stderr + print_stderr('WARNING: RPC authentication is disabled.') + return rpc_user, rpc_password + + +class Daemon(DaemonThread): + + def __init__(self, config, fd, is_gui): + DaemonThread.__init__(self) + self.config = config + if config.get('offline'): + self.network = None + else: + self.network = Network(config) + self.network.start() + self.fx = FxThread(config, self.network) + if self.network: + self.network.add_jobs([self.fx]) + self.gui = None + self.wallets = {} + # Setup JSONRPC server + self.init_server(config, fd, is_gui) + + def init_server(self, config, fd, is_gui): + host = config.get('rpchost', '127.0.0.1') + port = config.get('rpcport', 0) + + rpc_user, rpc_password = get_rpc_credentials(config) + try: + server = VerifyingJSONRPCServer((host, port), logRequests=False, + rpc_user=rpc_user, rpc_password=rpc_password) + except Exception as e: + self.print_error('Warning: cannot initialize RPC server on host', host, e) + self.server = None + os.close(fd) + return + os.write(fd, bytes(repr((server.socket.getsockname(), time.time())), 'utf8')) + os.close(fd) + self.server = server + server.timeout = 0.1 + server.register_function(self.ping, 'ping') + if is_gui: + server.register_function(self.run_gui, 'gui') + else: + server.register_function(self.run_daemon, 'daemon') + self.cmd_runner = Commands(self.config, None, self.network) + for cmdname in known_commands: + server.register_function(getattr(self.cmd_runner, cmdname), cmdname) + server.register_function(self.run_cmdline, 'run_cmdline') + + def ping(self): + return True + + def run_daemon(self, config_options): + config = SimpleConfig(config_options) + sub = config.get('subcommand') + assert sub in [None, 'start', 'stop', 'status', 'load_wallet', 'close_wallet'] + if sub in [None, 'start']: + response = "Daemon already running" + elif sub == 'load_wallet': + path = config.get_wallet_path() + wallet = self.load_wallet(path, config.get('password')) + if wallet is not None: + self.cmd_runner.wallet = wallet + run_hook('load_wallet', wallet, None) + response = wallet is not None + elif sub == 'close_wallet': + path = config.get_wallet_path() + if path in self.wallets: + self.stop_wallet(path) + response = True + else: + response = False + elif sub == 'status': + if self.network: + p = self.network.get_parameters() + current_wallet = self.cmd_runner.wallet + current_wallet_path = current_wallet.storage.path \ + if current_wallet else None + response = { + 'path': self.network.config.path, + 'server': p[0], + 'blockchain_height': self.network.get_local_height(), + 'server_height': self.network.get_server_height(), + 'spv_nodes': len(self.network.get_interfaces()), + 'connected': self.network.is_connected(), + 'auto_connect': p[4], + 'version': ELECTRUM_VERSION, + 'wallets': {k: w.is_up_to_date() + for k, w in self.wallets.items()}, + 'current_wallet': current_wallet_path, + 'fee_per_kb': self.config.fee_per_kb(), + } + else: + response = "Daemon offline" + elif sub == 'stop': + self.stop() + response = "Daemon stopped" + return response + + def run_gui(self, config_options): + config = SimpleConfig(config_options) + if self.gui: + #if hasattr(self.gui, 'new_window'): + # path = config.get_wallet_path() + # self.gui.new_window(path, config.get('url')) + # response = "ok" + #else: + # response = "error: current GUI does not support multiple windows" + response = "error: Electrum GUI already running" + else: + response = "Error: Electrum is running in daemon mode. Please stop the daemon first." + return response + + def load_wallet(self, path, password): + # wizard will be launched if we return + if path in self.wallets: + wallet = self.wallets[path] + return wallet + storage = WalletStorage(path, manual_upgrades=True) + if not storage.file_exists(): + return + if storage.is_encrypted(): + if not password: + return + storage.decrypt(password) + if storage.requires_split(): + return + if storage.get_action(): + return + wallet = Wallet(storage) + wallet.start_threads(self.network) + self.wallets[path] = wallet + return wallet + + def add_wallet(self, wallet): + path = wallet.storage.path + self.wallets[path] = wallet + + def get_wallet(self, path): + return self.wallets.get(path) + + def stop_wallet(self, path): + wallet = self.wallets.pop(path) + wallet.stop_threads() + + def run_cmdline(self, config_options): + password = config_options.get('password') + new_password = config_options.get('new_password') + config = SimpleConfig(config_options) + # FIXME this is ugly... + config.fee_estimates = self.network.config.fee_estimates.copy() + config.mempool_fees = self.network.config.mempool_fees.copy() + cmdname = config.get('cmd') + cmd = known_commands[cmdname] + if cmd.requires_wallet: + path = config.get_wallet_path() + wallet = self.wallets.get(path) + if wallet is None: + return {'error': 'Wallet "%s" is not loaded. Use "electrum daemon load_wallet"'%os.path.basename(path) } + else: + wallet = None + # arguments passed to function + args = map(lambda x: config.get(x), cmd.params) + # decode json arguments + args = [json_decode(i) for i in args] + # options + kwargs = {} + for x in cmd.options: + kwargs[x] = (config_options.get(x) if x in ['password', 'new_password'] else config.get(x)) + cmd_runner = Commands(config, wallet, self.network) + func = getattr(cmd_runner, cmd.name) + result = func(*args, **kwargs) + return result + + def run(self): + while self.is_running(): + self.server.handle_request() if self.server else time.sleep(0.1) + for k, wallet in self.wallets.items(): + wallet.stop_threads() + if self.network: + self.print_error("shutting down network") + self.network.stop() + self.network.join() + self.on_stop() + + def stop(self): + self.print_error("stopping, removing lockfile") + remove_lockfile(get_lockfile(self.config)) + DaemonThread.stop(self) + + def init_gui(self, config, plugins): + gui_name = config.get('gui', 'qt') + if gui_name in ['lite', 'classic']: + gui_name = 'qt' + gui = __import__('electrum.gui.' + gui_name, fromlist=['electrum']) + self.gui = gui.ElectrumGui(config, self, plugins) + try: + self.gui.main() + except BaseException as e: + traceback.print_exc(file=sys.stdout) + # app will exit now DIR diff --git a/lib/dnssec.py b/electrum/dnssec.py DIR diff --git a/lib/ecc.py b/electrum/ecc.py DIR diff --git a/lib/ecc_fast.py b/electrum/ecc_fast.py DIR diff --git a/electrum/electrum b/electrum/electrum t@@ -0,0 +1 @@ +../run_electrum +\ No newline at end of file DIR diff --git a/electrum/exchange_rate.py b/electrum/exchange_rate.py t@@ -0,0 +1,573 @@ +from datetime import datetime +import inspect +import requests +import sys +import os +import json +from threading import Thread +import time +import csv +import decimal +from decimal import Decimal + +from .bitcoin import COIN +from .i18n import _ +from .util import PrintError, ThreadJob, make_dir + + +# See https://en.wikipedia.org/wiki/ISO_4217 +CCY_PRECISIONS = {'BHD': 3, 'BIF': 0, 'BYR': 0, 'CLF': 4, 'CLP': 0, + 'CVE': 0, 'DJF': 0, 'GNF': 0, 'IQD': 3, 'ISK': 0, + 'JOD': 3, 'JPY': 0, 'KMF': 0, 'KRW': 0, 'KWD': 3, + 'LYD': 3, 'MGA': 1, 'MRO': 1, 'OMR': 3, 'PYG': 0, + 'RWF': 0, 'TND': 3, 'UGX': 0, 'UYI': 0, 'VND': 0, + 'VUV': 0, 'XAF': 0, 'XAU': 4, 'XOF': 0, 'XPF': 0} + + +class ExchangeBase(PrintError): + + def __init__(self, on_quotes, on_history): + self.history = {} + self.quotes = {} + self.on_quotes = on_quotes + self.on_history = on_history + + def get_json(self, site, get_string): + # APIs must have https + url = ''.join(['https://', site, get_string]) + response = requests.request('GET', url, headers={'User-Agent' : 'Electrum'}, timeout=10) + return response.json() + + def get_csv(self, site, get_string): + url = ''.join(['https://', site, get_string]) + response = requests.request('GET', url, headers={'User-Agent' : 'Electrum'}) + reader = csv.DictReader(response.content.decode().split('\n')) + return list(reader) + + def name(self): + return self.__class__.__name__ + + def update_safe(self, ccy): + try: + self.print_error("getting fx quotes for", ccy) + self.quotes = self.get_rates(ccy) + self.print_error("received fx quotes") + except BaseException as e: + self.print_error("failed fx quotes:", e) + self.on_quotes() + + def update(self, ccy): + t = Thread(target=self.update_safe, args=(ccy,)) + t.setDaemon(True) + t.start() + + def read_historical_rates(self, ccy, cache_dir): + filename = os.path.join(cache_dir, self.name() + '_'+ ccy) + if os.path.exists(filename): + timestamp = os.stat(filename).st_mtime + try: + with open(filename, 'r', encoding='utf-8') as f: + h = json.loads(f.read()) + h['timestamp'] = timestamp + except: + h = None + else: + h = None + if h: + self.history[ccy] = h + self.on_history() + return h + + def get_historical_rates_safe(self, ccy, cache_dir): + try: + self.print_error("requesting fx history for", ccy) + h = self.request_history(ccy) + self.print_error("received fx history for", ccy) + except BaseException as e: + self.print_error("failed fx history:", e) + return + filename = os.path.join(cache_dir, self.name() + '_' + ccy) + with open(filename, 'w', encoding='utf-8') as f: + f.write(json.dumps(h)) + h['timestamp'] = time.time() + self.history[ccy] = h + self.on_history() + + def get_historical_rates(self, ccy, cache_dir): + if ccy not in self.history_ccys(): + return + h = self.history.get(ccy) + if h is None: + h = self.read_historical_rates(ccy, cache_dir) + if h is None or h['timestamp'] < time.time() - 24*3600: + t = Thread(target=self.get_historical_rates_safe, args=(ccy, cache_dir)) + t.setDaemon(True) + t.start() + + def history_ccys(self): + return [] + + def historical_rate(self, ccy, d_t): + return self.history.get(ccy, {}).get(d_t.strftime('%Y-%m-%d'), 'NaN') + + def get_currencies(self): + rates = self.get_rates('') + return sorted([str(a) for (a, b) in rates.items() if b is not None and len(a)==3]) + +class BitcoinAverage(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('apiv2.bitcoinaverage.com', '/indices/global/ticker/short') + return dict([(r.replace("BTC", ""), Decimal(json[r]['last'])) + for r in json if r != 'timestamp']) + + def history_ccys(self): + return ['AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'EUR', 'GBP', 'IDR', 'ILS', + 'MXN', 'NOK', 'NZD', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'USD', + 'ZAR'] + + def request_history(self, ccy): + history = self.get_csv('apiv2.bitcoinaverage.com', + "/indices/global/history/BTC%s?period=alltime&format=csv" % ccy) + return dict([(h['DateTime'][:10], h['Average']) + for h in history]) + + +class Bitcointoyou(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('bitcointoyou.com', "/API/ticker.aspx") + return {'BRL': Decimal(json['ticker']['last'])} + + def history_ccys(self): + return ['BRL'] + + +class BitcoinVenezuela(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('api.bitcoinvenezuela.com', '/') + rates = [(r, json['BTC'][r]) for r in json['BTC'] + if json['BTC'][r] is not None] # Giving NULL for LTC + return dict(rates) + + def history_ccys(self): + return ['ARS', 'EUR', 'USD', 'VEF'] + + def request_history(self, ccy): + return self.get_json('api.bitcoinvenezuela.com', + "/historical/index.php?coin=BTC")[ccy +'_BTC'] + + +class Bitbank(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('public.bitbank.cc', '/btc_jpy/ticker') + return {'JPY': Decimal(json['data']['last'])} + + +class BitFlyer(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('bitflyer.jp', '/api/echo/price') + return {'JPY': Decimal(json['mid'])} + + +class Bitmarket(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('www.bitmarket.pl', '/json/BTCPLN/ticker.json') + return {'PLN': Decimal(json['last'])} + + +class BitPay(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('bitpay.com', '/api/rates') + return dict([(r['code'], Decimal(r['rate'])) for r in json]) + + +class Bitso(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('api.bitso.com', '/v2/ticker') + return {'MXN': Decimal(json['last'])} + + +class BitStamp(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('www.bitstamp.net', '/api/ticker/') + return {'USD': Decimal(json['last'])} + + +class Bitvalor(ExchangeBase): + + def get_rates(self,ccy): + json = self.get_json('api.bitvalor.com', '/v1/ticker.json') + return {'BRL': Decimal(json['ticker_1h']['total']['last'])} + + +class BlockchainInfo(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('blockchain.info', '/ticker') + return dict([(r, Decimal(json[r]['15m'])) for r in json]) + + +class BTCChina(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('data.btcchina.com', '/data/ticker') + return {'CNY': Decimal(json['ticker']['last'])} + + +class BTCParalelo(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('btcparalelo.com', '/api/price') + return {'VEF': Decimal(json['price'])} + + +class Coinbase(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('coinbase.com', + '/api/v1/currencies/exchange_rates') + return dict([(r[7:].upper(), Decimal(json[r])) + for r in json if r.startswith('btc_to_')]) + + +class CoinDesk(ExchangeBase): + + def get_currencies(self): + dicts = self.get_json('api.coindesk.com', + '/v1/bpi/supported-currencies.json') + return [d['currency'] for d in dicts] + + def get_rates(self, ccy): + json = self.get_json('api.coindesk.com', + '/v1/bpi/currentprice/%s.json' % ccy) + result = {ccy: Decimal(json['bpi'][ccy]['rate_float'])} + return result + + def history_starts(self): + return { 'USD': '2012-11-30', 'EUR': '2013-09-01' } + + def history_ccys(self): + return self.history_starts().keys() + + def request_history(self, ccy): + start = self.history_starts()[ccy] + end = datetime.today().strftime('%Y-%m-%d') + # Note ?currency and ?index don't work as documented. Sigh. + query = ('/v1/bpi/historical/close.json?start=%s&end=%s' + % (start, end)) + json = self.get_json('api.coindesk.com', query) + return json['bpi'] + + +class Coinsecure(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('api.coinsecure.in', '/v0/noauth/newticker') + return {'INR': Decimal(json['lastprice'] / 100.0 )} + + +class Foxbit(ExchangeBase): + + def get_rates(self,ccy): + json = self.get_json('api.bitvalor.com', '/v1/ticker.json') + return {'BRL': Decimal(json['ticker_1h']['exchanges']['FOX']['last'])} + + +class itBit(ExchangeBase): + + def get_rates(self, ccy): + ccys = ['USD', 'EUR', 'SGD'] + json = self.get_json('api.itbit.com', '/v1/markets/XBT%s/ticker' % ccy) + result = dict.fromkeys(ccys) + if ccy in ccys: + result[ccy] = Decimal(json['lastPrice']) + return result + + +class Kraken(ExchangeBase): + + def get_rates(self, ccy): + ccys = ['EUR', 'USD', 'CAD', 'GBP', 'JPY'] + pairs = ['XBT%s' % c for c in ccys] + json = self.get_json('api.kraken.com', + '/0/public/Ticker?pair=%s' % ','.join(pairs)) + return dict((k[-3:], Decimal(float(v['c'][0]))) + for k, v in json['result'].items()) + + +class LocalBitcoins(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('localbitcoins.com', + '/bitcoinaverage/ticker-all-currencies/') + return dict([(r, Decimal(json[r]['rates']['last'])) for r in json]) + + +class MercadoBitcoin(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('api.bitvalor.com', '/v1/ticker.json') + return {'BRL': Decimal(json['ticker_1h']['exchanges']['MBT']['last'])} + + +class NegocieCoins(ExchangeBase): + + def get_rates(self,ccy): + json = self.get_json('api.bitvalor.com', '/v1/ticker.json') + return {'BRL': Decimal(json['ticker_1h']['exchanges']['NEG']['last'])} + +class TheRockTrading(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('api.therocktrading.com', + '/v1/funds/BTCEUR/ticker') + return {'EUR': Decimal(json['last'])} + +class Unocoin(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('www.unocoin.com', 'trade?buy') + return {'INR': Decimal(json)} + + +class WEX(ExchangeBase): + + def get_rates(self, ccy): + json_eur = self.get_json('wex.nz', '/api/3/ticker/btc_eur') + json_rub = self.get_json('wex.nz', '/api/3/ticker/btc_rur') + json_usd = self.get_json('wex.nz', '/api/3/ticker/btc_usd') + return {'EUR': Decimal(json_eur['btc_eur']['last']), + 'RUB': Decimal(json_rub['btc_rur']['last']), + 'USD': Decimal(json_usd['btc_usd']['last'])} + + +class Winkdex(ExchangeBase): + + def get_rates(self, ccy): + json = self.get_json('winkdex.com', '/api/v0/price') + return {'USD': Decimal(json['price'] / 100.0)} + + def history_ccys(self): + return ['USD'] + + def request_history(self, ccy): + json = self.get_json('winkdex.com', + "/api/v0/series?start_time=1342915200") + history = json['series'][0]['results'] + return dict([(h['timestamp'][:10], h['price'] / 100.0) + for h in history]) + + +class Zaif(ExchangeBase): + def get_rates(self, ccy): + json = self.get_json('api.zaif.jp', '/api/1/last_price/btc_jpy') + return {'JPY': Decimal(json['last_price'])} + + +def dictinvert(d): + inv = {} + for k, vlist in d.items(): + for v in vlist: + keys = inv.setdefault(v, []) + keys.append(k) + return inv + +def get_exchanges_and_currencies(): + import os, json + path = os.path.join(os.path.dirname(__file__), 'currencies.json') + try: + with open(path, 'r', encoding='utf-8') as f: + return json.loads(f.read()) + except: + pass + d = {} + is_exchange = lambda obj: (inspect.isclass(obj) + and issubclass(obj, ExchangeBase) + and obj != ExchangeBase) + exchanges = dict(inspect.getmembers(sys.modules[__name__], is_exchange)) + for name, klass in exchanges.items(): + exchange = klass(None, None) + try: + d[name] = exchange.get_currencies() + print(name, "ok") + except: + print(name, "error") + continue + with open(path, 'w', encoding='utf-8') as f: + f.write(json.dumps(d, indent=4, sort_keys=True)) + return d + + +CURRENCIES = get_exchanges_and_currencies() + + +def get_exchanges_by_ccy(history=True): + if not history: + return dictinvert(CURRENCIES) + d = {} + exchanges = CURRENCIES.keys() + for name in exchanges: + klass = globals()[name] + exchange = klass(None, None) + d[name] = exchange.history_ccys() + return dictinvert(d) + + +class FxThread(ThreadJob): + + def __init__(self, config, network): + self.config = config + self.network = network + self.ccy = self.get_currency() + self.history_used_spot = False + self.ccy_combo = None + self.hist_checkbox = None + self.cache_dir = os.path.join(config.path, 'cache') + self.set_exchange(self.config_exchange()) + make_dir(self.cache_dir) + + def get_currencies(self, h): + d = get_exchanges_by_ccy(h) + return sorted(d.keys()) + + def get_exchanges_by_ccy(self, ccy, h): + d = get_exchanges_by_ccy(h) + return d.get(ccy, []) + + def ccy_amount_str(self, amount, commas): + prec = CCY_PRECISIONS.get(self.ccy, 2) + fmt_str = "{:%s.%df}" % ("," if commas else "", max(0, prec)) + try: + rounded_amount = round(amount, prec) + except decimal.InvalidOperation: + rounded_amount = amount + return fmt_str.format(rounded_amount) + + def run(self): + # This runs from the plugins thread which catches exceptions + if self.is_enabled(): + if self.timeout ==0 and self.show_history(): + self.exchange.get_historical_rates(self.ccy, self.cache_dir) + if self.timeout <= time.time(): + self.timeout = time.time() + 150 + self.exchange.update(self.ccy) + + def is_enabled(self): + return bool(self.config.get('use_exchange_rate')) + + def set_enabled(self, b): + return self.config.set_key('use_exchange_rate', bool(b)) + + def get_history_config(self): + return bool(self.config.get('history_rates')) + + def set_history_config(self, b): + self.config.set_key('history_rates', bool(b)) + + def get_history_capital_gains_config(self): + return bool(self.config.get('history_rates_capital_gains', False)) + + def set_history_capital_gains_config(self, b): + self.config.set_key('history_rates_capital_gains', bool(b)) + + def get_fiat_address_config(self): + return bool(self.config.get('fiat_address')) + + def set_fiat_address_config(self, b): + self.config.set_key('fiat_address', bool(b)) + + def get_currency(self): + '''Use when dynamic fetching is needed''' + return self.config.get("currency", "EUR") + + def config_exchange(self): + return self.config.get('use_exchange', 'BitcoinAverage') + + def show_history(self): + return self.is_enabled() and self.get_history_config() and self.ccy in self.exchange.history_ccys() + + def set_currency(self, ccy): + self.ccy = ccy + self.config.set_key('currency', ccy, True) + self.timeout = 0 # Because self.ccy changes + self.on_quotes() + + def set_exchange(self, name): + class_ = globals().get(name, BitcoinAverage) + self.print_error("using exchange", name) + if self.config_exchange() != name: + self.config.set_key('use_exchange', name, True) + self.exchange = class_(self.on_quotes, self.on_history) + # A new exchange means new fx quotes, initially empty. Force + # a quote refresh + self.timeout = 0 + self.exchange.read_historical_rates(self.ccy, self.cache_dir) + + def on_quotes(self): + if self.network: + self.network.trigger_callback('on_quotes') + + def on_history(self): + if self.network: + self.network.trigger_callback('on_history') + + def exchange_rate(self): + '''Returns None, or the exchange rate as a Decimal''' + rate = self.exchange.quotes.get(self.ccy) + if rate is None: + return Decimal('NaN') + return Decimal(rate) + + def format_amount(self, btc_balance): + rate = self.exchange_rate() + return '' if rate.is_nan() else "%s" % self.value_str(btc_balance, rate) + + def format_amount_and_units(self, btc_balance): + rate = self.exchange_rate() + return '' if rate.is_nan() else "%s %s" % (self.value_str(btc_balance, rate), self.ccy) + + def get_fiat_status_text(self, btc_balance, base_unit, decimal_point): + rate = self.exchange_rate() + return _(" (No FX rate available)") if rate.is_nan() else " 1 %s~%s %s" % (base_unit, + self.value_str(COIN / (10**(8 - decimal_point)), rate), self.ccy) + + def fiat_value(self, satoshis, rate): + return Decimal('NaN') if satoshis is None else Decimal(satoshis) / COIN * Decimal(rate) + + def value_str(self, satoshis, rate): + return self.format_fiat(self.fiat_value(satoshis, rate)) + + def format_fiat(self, value): + if value.is_nan(): + return _("No data") + return "%s" % (self.ccy_amount_str(value, True)) + + def history_rate(self, d_t): + if d_t is None: + return Decimal('NaN') + rate = self.exchange.historical_rate(self.ccy, d_t) + # Frequently there is no rate for today, until tomorrow :) + # Use spot quotes in that case + if rate == 'NaN' and (datetime.today().date() - d_t.date()).days <= 2: + rate = self.exchange.quotes.get(self.ccy, 'NaN') + self.history_used_spot = True + return Decimal(rate) + + def historical_value_str(self, satoshis, d_t): + return self.format_fiat(self.historical_value(satoshis, d_t)) + + def historical_value(self, satoshis, d_t): + return self.fiat_value(satoshis, self.history_rate(d_t)) + + def timestamp_rate(self, timestamp): + from .util import timestamp_to_datetime + date = timestamp_to_datetime(timestamp) + return self.history_rate(date) DIR diff --git a/gui/__init__.py b/electrum/gui/__init__.py DIR diff --git a/electrum/gui/kivy/Makefile b/electrum/gui/kivy/Makefile t@@ -0,0 +1,32 @@ +PYTHON = python3 + +# needs kivy installed or in PYTHONPATH + +.PHONY: theming apk clean + +theming: + $(PYTHON) -m kivy.atlas theming/light 1024 theming/light/*.png +prepare: + # running pre build setup + @cp tools/buildozer.spec ../../buildozer.spec + # copy electrum to main.py + @cp ../../../run_electrum ../../main.py + @-if [ ! -d "../../.buildozer" ];then \ + cd ../..; buildozer android debug;\ + cp -f electrum/gui/kivy/tools/blacklist.txt .buildozer/android/platform/python-for-android/src/blacklist.txt;\ + rm -rf ./.buildozer/android/platform/python-for-android/dist;\ + fi +apk: + @make prepare + @-cd ../..; buildozer android debug deploy run + @make clean +release: + @make prepare + @-cd ../..; buildozer android release + @make clean +clean: + # Cleaning up + # rename main.py to electrum + @-rm ../../main.py + # remove buildozer.spec + @-rm ../../buildozer.spec DIR diff --git a/gui/kivy/Readme.md b/electrum/gui/kivy/Readme.md DIR diff --git a/gui/kivy/__init__.py b/electrum/gui/kivy/__init__.py DIR diff --git a/gui/kivy/data/background.png b/electrum/gui/kivy/data/background.png Binary files differ. DIR diff --git a/gui/kivy/data/fonts/Roboto-Bold.ttf b/electrum/gui/kivy/data/fonts/Roboto-Bold.ttf Binary files differ. DIR diff --git a/gui/kivy/data/fonts/Roboto-Condensed.ttf b/electrum/gui/kivy/data/fonts/Roboto-Condensed.ttf Binary files differ. DIR diff --git a/gui/kivy/data/fonts/Roboto-Medium.ttf b/electrum/gui/kivy/data/fonts/Roboto-Medium.ttf Binary files differ. DIR diff --git a/gui/kivy/data/fonts/Roboto.ttf b/electrum/gui/kivy/data/fonts/Roboto.ttf Binary files differ. DIR diff --git a/gui/kivy/data/fonts/tron/License.txt b/electrum/gui/kivy/data/fonts/tron/License.txt DIR diff --git a/gui/kivy/data/fonts/tron/Readme.txt b/electrum/gui/kivy/data/fonts/tron/Readme.txt DIR diff --git a/gui/kivy/data/fonts/tron/Tr2n.ttf b/electrum/gui/kivy/data/fonts/tron/Tr2n.ttf Binary files differ. DIR diff --git a/gui/kivy/data/glsl/default.fs b/electrum/gui/kivy/data/glsl/default.fs DIR diff --git a/gui/kivy/data/glsl/default.png b/electrum/gui/kivy/data/glsl/default.png Binary files differ. DIR diff --git a/gui/kivy/data/glsl/default.vs b/electrum/gui/kivy/data/glsl/default.vs DIR diff --git a/gui/kivy/data/glsl/header.fs b/electrum/gui/kivy/data/glsl/header.fs DIR diff --git a/gui/kivy/data/glsl/header.vs b/electrum/gui/kivy/data/glsl/header.vs DIR diff --git a/gui/kivy/data/images/defaulttheme-0.png b/electrum/gui/kivy/data/images/defaulttheme-0.png Binary files differ. DIR diff --git a/gui/kivy/data/images/defaulttheme.atlas b/electrum/gui/kivy/data/images/defaulttheme.atlas DIR diff --git a/gui/kivy/data/java-classes/org/electrum/qr/SimpleScannerActivity.java b/electrum/gui/kivy/data/java-classes/org/electrum/qr/SimpleScannerActivity.java DIR diff --git a/gui/kivy/data/logo/kivy-icon-32.png b/electrum/gui/kivy/data/logo/kivy-icon-32.png Binary files differ. DIR diff --git a/gui/kivy/data/style.kv b/electrum/gui/kivy/data/style.kv DIR diff --git a/gui/kivy/i18n.py b/electrum/gui/kivy/i18n.py DIR diff --git a/electrum/gui/kivy/main.kv b/electrum/gui/kivy/main.kv t@@ -0,0 +1,464 @@ +#:import Clock kivy.clock.Clock +#:import Window kivy.core.window.Window +#:import Factory kivy.factory.Factory +#:import _ electrum.gui.kivy.i18n._ + + +########################### +# Global Defaults +########################### + +<Label> + markup: True + font_name: 'Roboto' + font_size: '16sp' + bound: False + on_text: if isinstance(self.text, _) and not self.bound: self.bound=True; _.bind(self) + +<TextInput> + on_focus: app._focused_widget = root + font_size: '18sp' + +<Button> + on_parent: self.MIN_STATE_TIME = 0.1 + +<ListItemButton> + font_size: '12sp' + +<Carousel>: + canvas.before: + Color: + rgba: 0.1, 0.1, 0.1, 1 + Rectangle: + size: self.size + pos: self.pos + +<ActionView>: + canvas.before: + Color: + rgba: 0.1, 0.1, 0.1, 1 + Rectangle: + size: self.size + pos: self.pos + + +# Custom Global Widgets + +<TopLabel> + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + +<VGridLayout@GridLayout>: + rows: 1 + size_hint: 1, None + height: self.minimum_height + + + +<IconButton@Button>: + icon: '' + AnchorLayout: + pos: self.parent.pos + size: self.parent.size + orientation: 'lr-tb' + Image: + source: self.parent.parent.icon + size_hint_x: None + size: '30dp', '30dp' + + + +######################### +# Dialogs +######################### +<BoxLabel@BoxLayout> + text: '' + value: '' + size_hint_y: None + height: max(lbl1.height, lbl2.height) + TopLabel + id: lbl1 + text: root.text + pos_hint: {'top':1} + TopLabel + id: lbl2 + text: root.value + +<OutputItem> + address: '' + value: '' + size_hint_y: None + height: max(lbl1.height, lbl2.height) + TopLabel + id: lbl1 + text: '[ref=%s]%s[/ref]'%(root.address, root.address) + font_size: '6pt' + shorten: True + size_hint_x: 0.65 + on_ref_press: + app._clipboard.copy(root.address) + app.show_info(_('Address copied to clipboard') + ' ' + root.address) + TopLabel + id: lbl2 + text: root.value + font_size: '6pt' + size_hint_x: 0.35 + halign: 'right' + + +<OutputList> + viewclass: 'OutputItem' + size_hint: 1, None + height: min(output_list_layout.minimum_height, dp(144)) + scroll_type: ['bars', 'content'] + bar_width: dp(15) + RecycleBoxLayout: + orientation: 'vertical' + default_size: None, pt(6) + default_size_hint: 1, None + size_hint: 1, None + height: self.minimum_height + id: output_list_layout + spacing: '10dp' + padding: '10dp' + canvas.before: + Color: + rgb: .3, .3, .3 + Rectangle: + size: self.size + pos: self.pos + +<RefLabel> + font_size: '6pt' + name: '' + data: '' + text: self.data + touched: False + padding: '10dp', '10dp' + on_touch_down: + touch = args[1] + if self.collide_point(*touch.pos): app.on_ref_label(self, touch) + else: self.touched = False + canvas.before: + Color: + rgb: .3, .3, .3 + Rectangle: + size: self.size + pos: self.pos + +<TxHashLabel@RefLabel> + data: '' + text: ' '.join(map(''.join, zip(*[iter(self.data)]*4))) if self.data else '' + +<InfoBubble> + size_hint: None, None + width: '270dp' if root.fs else min(self.width, dp(270)) + height: self.width if self.fs else (lbl.texture_size[1] + dp(27)) + BoxLayout: + padding: '5dp' if root.fs else 0 + Widget: + size_hint: None, 1 + width: '4dp' if root.fs else '2dp' + Image: + id: img + source: root.icon + mipmap: True + size_hint: None, 1 + width: (root.width - dp(20)) if root.fs else (0 if not root.icon else '32dp') + Widget: + size_hint_x: None + width: '5dp' + Label: + id: lbl + markup: True + font_size: '12sp' + text: root.message + text_size: self.width, None + valign: 'middle' + size_hint: 1, 1 + width: 0 if root.fs else (root.width - img.width) + + +<SendReceiveBlueBottom@GridLayout> + item_height: dp(42) + foreground_color: .843, .914, .972, 1 + cols: 1 + padding: '12dp', 0 + canvas.before: + Color: + rgba: 0.192, .498, 0.745, 1 + BorderImage: + source: 'atlas://electrum/gui/kivy/theming/light/card_bottom' + size: self.size + pos: self.pos + + +<AddressFilter@GridLayout> + item_height: dp(42) + item_width: dp(60) + foreground_color: .843, .914, .972, 1 + cols: 1 + canvas.before: + Color: + rgba: 0.192, .498, 0.745, 1 + BorderImage: + source: 'atlas://electrum/gui/kivy/theming/light/card_bottom' + size: self.size + pos: self.pos + +<SearchBox@GridLayout> + item_height: dp(42) + foreground_color: .843, .914, .972, 1 + cols: 1 + padding: '12dp', 0 + canvas.before: + Color: + rgba: 0.192, .498, 0.745, 1 + BorderImage: + source: 'atlas://electrum/gui/kivy/theming/light/card_bottom' + size: self.size + pos: self.pos + +<CardSeparator@Widget> + size_hint: 1, None + height: dp(1) + color: .909, .909, .909, 1 + canvas: + Color: + rgba: root.color if root.color else (0, 0, 0, 0) + Rectangle: + size: self.size + pos: self.pos + +<CardItem@ToggleButtonBehavior+BoxLayout> + size_hint: 1, None + height: '65dp' + group: 'requests' + padding: dp(12) + spacing: dp(5) + screen: None + on_release: + self.screen.show_menu(args[0]) if self.state == 'down' else self.screen.hide_menu() + canvas.before: + Color: + rgba: (0.192, .498, 0.745, 1) if self.state == 'down' else (0.15, 0.15, 0.17, 1) + Rectangle: + size: self.size + pos: self.pos + +<BlueButton@Button>: + background_color: 1, .585, .878, 0 + halign: 'left' + text_size: (self.width-10, None) + size_hint: 0.5, None + default_text: '' + text: self.default_text + padding: '5dp', '5dp' + height: '40dp' + text_color: self.foreground_color + disabled_color: 1, 1, 1, 1 + foreground_color: 1, 1, 1, 1 + canvas.before: + Color: + rgba: (0.9, .498, 0.745, 1) if self.state == 'down' else self.background_color + Rectangle: + size: self.size + pos: self.pos + +<AddressButton@Button>: + background_color: 1, .585, .878, 0 + halign: 'center' + text_size: (self.width, None) + shorten: True + size_hint: 0.5, None + default_text: '' + text: self.default_text + padding: '5dp', '5dp' + height: '40dp' + text_color: self.foreground_color + disabled_color: 1, 1, 1, 1 + foreground_color: 1, 1, 1, 1 + canvas.before: + Color: + rgba: (0.9, .498, 0.745, 1) if self.state == 'down' else self.background_color + Rectangle: + size: self.size + pos: self.pos + +<KButton@Button>: + size_hint: 1, None + height: '60dp' + font_size: '30dp' + on_release: + self.parent.update_amount(self.text) + + +<StripLayout> + padding: 0, 0, 0, 0 + +<TabbedPanelStrip>: + on_parent: + if self.parent: self.parent.bar_width = 0 + if self.parent: self.parent.scroll_x = 0.5 + + +<TabbedCarousel> + carousel: carousel + do_default_tab: False + Carousel: + scroll_timeout: 250 + scroll_distance: '100dp' + anim_type: 'out_quart' + min_move: .05 + anim_move_duration: .1 + anim_cancel_duration: .54 + on_index: root.on_index(*args) + id: carousel + + + +<CleanHeader@TabbedPanelHeader> + border: 16, 0, 16, 0 + markup: False + text_size: self.size + halign: 'center' + valign: 'middle' + bold: True + font_size: '12.5sp' + background_normal: 'atlas://electrum/gui/kivy/theming/light/tab_btn' + background_down: 'atlas://electrum/gui/kivy/theming/light/tab_btn_pressed' + + +<ColoredLabel@Label>: + font_size: '48sp' + color: (.6, .6, .6, 1) + canvas.before: + Color: + rgb: (.9, .9, .9) + Rectangle: + pos: self.x + sp(2), self.y + sp(2) + size: self.width - sp(4), self.height - sp(4) + + +<SettingsItem@ButtonBehavior+BoxLayout> + orientation: 'vertical' + title: '' + description: '' + size_hint: 1, None + height: '60dp' + canvas.before: + Color: + rgba: (0.192, .498, 0.745, 1) if self.state == 'down' else (0.3, 0.3, 0.3, 0) + Rectangle: + size: self.size + pos: self.pos + on_release: + Clock.schedule_once(self.action) + Widget + TopLabel: + id: title + text: self.parent.title + bold: True + halign: 'left' + TopLabel: + text: self.parent.description + color: 0.8, 0.8, 0.8, 1 + halign: 'left' + Widget + + + + +<ScreenTabs@Screen> + TabbedCarousel: + id: panel + tab_height: '48dp' + tab_width: panel.width/3 + strip_border: 0, 0, 0, 0 + SendScreen: + id: send_screen + tab: send_tab + HistoryScreen: + id: history_screen + tab: history_tab + ReceiveScreen: + id: receive_screen + tab: receive_tab + CleanHeader: + id: send_tab + text: _('Send') + slide: 0 + CleanHeader: + id: history_tab + text: _('Balance') + slide: 1 + CleanHeader: + id: receive_tab + text: _('Receive') + slide: 2 + + +<ActionOvrButton@ActionButton> + #on_release: + # fixme: the following line was commented out because it does not seem to do what it is intended + # Clock.schedule_once(lambda dt: self.parent.parent.dismiss() if self.parent else None, 0.05) + on_press: + Clock.schedule_once(lambda dt: app.popup_dialog(self.name), 0.05) + self.state = 'normal' + + +BoxLayout: + orientation: 'vertical' + + canvas.before: + Color: + rgb: .6, .6, .6 + Rectangle: + size: self.size + source: 'electrum/gui/kivy/data/background.png' + + ActionBar: + + ActionView: + id: av + ActionPrevious: + app_icon: 'atlas://electrum/gui/kivy/theming/light/logo' + app_icon_width: '100dp' + with_previous: False + size_hint_x: None + on_release: app.popup_dialog('network') + + ActionButton: + id: action_status + important: True + size_hint: 1, 1 + bold: True + color: 0.7, 0.7, 0.7, 1 + text: app.status + font_size: '22dp' + #minimum_width: '1dp' + on_release: app.popup_dialog('status') + + ActionOverflow: + id: ao + ActionOvrButton: + name: 'about' + text: _('About') + ActionOvrButton: + name: 'wallets' + text: _('Wallets') + ActionOvrButton: + name: 'network' + text: _('Network') + ActionOvrButton: + name: 'settings' + text: _('Settings') + on_parent: + # when widget overflow drop down is shown, adjust the width + parent = args[1] + if parent: ao._dropdown.width = sp(200) + ScreenManager: + id: manager + ScreenTabs: + id: tabs DIR diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py t@@ -0,0 +1,1028 @@ +import re +import os +import sys +import time +import datetime +import traceback +from decimal import Decimal +import threading + +from electrum.bitcoin import TYPE_ADDRESS +from electrum.storage import WalletStorage +from electrum.wallet import Wallet +from electrum.i18n import _ +from electrum.paymentrequest import InvoiceStore +from electrum.util import profiler, InvalidPassword +from electrum.plugin import run_hook +from electrum.util import format_satoshis, format_satoshis_plain +from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_UNKNOWN, PR_EXPIRED + +from kivy.app import App +from kivy.core.window import Window +from kivy.logger import Logger +from kivy.utils import platform +from kivy.properties import (OptionProperty, AliasProperty, ObjectProperty, + StringProperty, ListProperty, BooleanProperty, NumericProperty) +from kivy.cache import Cache +from kivy.clock import Clock +from kivy.factory import Factory +from kivy.metrics import inch +from kivy.lang import Builder + +## lazy imports for factory so that widgets can be used in kv +#Factory.register('InstallWizard', module='electrum.gui.kivy.uix.dialogs.installwizard') +#Factory.register('InfoBubble', module='electrum.gui.kivy.uix.dialogs') +#Factory.register('OutputList', module='electrum.gui.kivy.uix.dialogs') +#Factory.register('OutputItem', module='electrum.gui.kivy.uix.dialogs') + +from .uix.dialogs.installwizard import InstallWizard +from .uix.dialogs import InfoBubble, crash_reporter +from .uix.dialogs import OutputList, OutputItem +from .uix.dialogs import TopLabel, RefLabel + +#from kivy.core.window import Window +#Window.softinput_mode = 'below_target' + +# delayed imports: for startup speed on android +notification = app = ref = None +util = False + +# register widget cache for keeping memory down timeout to forever to cache +# the data +Cache.register('electrum_widgets', timeout=0) + +from kivy.uix.screenmanager import Screen +from kivy.uix.tabbedpanel import TabbedPanel +from kivy.uix.label import Label +from kivy.core.clipboard import Clipboard + +Factory.register('TabbedCarousel', module='electrum.gui.kivy.uix.screens') + +# Register fonts without this you won't be able to use bold/italic... +# inside markup. +from kivy.core.text import Label +Label.register('Roboto', + 'electrum/gui/kivy/data/fonts/Roboto.ttf', + 'electrum/gui/kivy/data/fonts/Roboto.ttf', + 'electrum/gui/kivy/data/fonts/Roboto-Bold.ttf', + 'electrum/gui/kivy/data/fonts/Roboto-Bold.ttf') + + +from electrum.util import (base_units, NoDynamicFeeEstimates, decimal_point_to_base_unit_name, + base_unit_name_to_decimal_point, NotEnoughFunds) + + +class ElectrumWindow(App): + + electrum_config = ObjectProperty(None) + language = StringProperty('en') + + # properties might be updated by the network + num_blocks = NumericProperty(0) + num_nodes = NumericProperty(0) + server_host = StringProperty('') + server_port = StringProperty('') + num_chains = NumericProperty(0) + blockchain_name = StringProperty('') + fee_status = StringProperty('Fee') + balance = StringProperty('') + fiat_balance = StringProperty('') + is_fiat = BooleanProperty(False) + blockchain_checkpoint = NumericProperty(0) + + auto_connect = BooleanProperty(False) + def on_auto_connect(self, instance, x): + host, port, protocol, proxy, auto_connect = self.network.get_parameters() + self.network.set_parameters(host, port, protocol, proxy, self.auto_connect) + def toggle_auto_connect(self, x): + self.auto_connect = not self.auto_connect + + def choose_server_dialog(self, popup): + from .uix.dialogs.choice_dialog import ChoiceDialog + protocol = 's' + def cb2(host): + from electrum import constants + pp = servers.get(host, constants.net.DEFAULT_PORTS) + port = pp.get(protocol, '') + popup.ids.host.text = host + popup.ids.port.text = port + servers = self.network.get_servers() + ChoiceDialog(_('Choose a server'), sorted(servers), popup.ids.host.text, cb2).open() + + def choose_blockchain_dialog(self, dt): + from .uix.dialogs.choice_dialog import ChoiceDialog + chains = self.network.get_blockchains() + def cb(name): + for index, b in self.network.blockchains.items(): + if name == b.get_name(): + self.network.follow_chain(index) + names = [self.network.blockchains[b].get_name() for b in chains] + if len(names) > 1: + cur_chain = self.network.blockchain().get_name() + ChoiceDialog(_('Choose your chain'), names, cur_chain, cb).open() + + use_rbf = BooleanProperty(False) + def on_use_rbf(self, instance, x): + self.electrum_config.set_key('use_rbf', self.use_rbf, True) + + use_change = BooleanProperty(False) + def on_use_change(self, instance, x): + self.electrum_config.set_key('use_change', self.use_change, True) + + use_unconfirmed = BooleanProperty(False) + def on_use_unconfirmed(self, instance, x): + self.electrum_config.set_key('confirmed_only', not self.use_unconfirmed, True) + + def set_URI(self, uri): + self.switch_to('send') + self.send_screen.set_URI(uri) + + def on_new_intent(self, intent): + if intent.getScheme() != 'bitcoin': + return + uri = intent.getDataString() + self.set_URI(uri) + + def on_language(self, instance, language): + Logger.info('language: {}'.format(language)) + _.switch_lang(language) + + def update_history(self, *dt): + if self.history_screen: + self.history_screen.update() + + def on_quotes(self, d): + Logger.info("on_quotes") + self._trigger_update_history() + + def on_history(self, d): + Logger.info("on_history") + self._trigger_update_history() + + def _get_bu(self): + decimal_point = self.electrum_config.get('decimal_point', 5) + return decimal_point_to_base_unit_name(decimal_point) + + def _set_bu(self, value): + assert value in base_units.keys() + decimal_point = base_unit_name_to_decimal_point(value) + self.electrum_config.set_key('decimal_point', decimal_point, True) + self._trigger_update_status() + self._trigger_update_history() + + base_unit = AliasProperty(_get_bu, _set_bu) + status = StringProperty('') + fiat_unit = StringProperty('') + + def on_fiat_unit(self, a, b): + self._trigger_update_history() + + def decimal_point(self): + return base_units[self.base_unit] + + def btc_to_fiat(self, amount_str): + if not amount_str: + return '' + if not self.fx.is_enabled(): + return '' + rate = self.fx.exchange_rate() + if rate.is_nan(): + return '' + fiat_amount = self.get_amount(amount_str + ' ' + self.base_unit) * rate / pow(10, 8) + return "{:.2f}".format(fiat_amount).rstrip('0').rstrip('.') + + def fiat_to_btc(self, fiat_amount): + if not fiat_amount: + return '' + rate = self.fx.exchange_rate() + if rate.is_nan(): + return '' + satoshis = int(pow(10,8) * Decimal(fiat_amount) / Decimal(rate)) + return format_satoshis_plain(satoshis, self.decimal_point()) + + def get_amount(self, amount_str): + a, u = amount_str.split() + assert u == self.base_unit + try: + x = Decimal(a) + except: + return None + p = pow(10, self.decimal_point()) + return int(p * x) + + + _orientation = OptionProperty('landscape', + options=('landscape', 'portrait')) + + def _get_orientation(self): + return self._orientation + + orientation = AliasProperty(_get_orientation, + None, + bind=('_orientation',)) + '''Tries to ascertain the kind of device the app is running on. + Cane be one of `tablet` or `phone`. + + :data:`orientation` is a read only `AliasProperty` Defaults to 'landscape' + ''' + + _ui_mode = OptionProperty('phone', options=('tablet', 'phone')) + + def _get_ui_mode(self): + return self._ui_mode + + ui_mode = AliasProperty(_get_ui_mode, + None, + bind=('_ui_mode',)) + '''Defines tries to ascertain the kind of device the app is running on. + Cane be one of `tablet` or `phone`. + + :data:`ui_mode` is a read only `AliasProperty` Defaults to 'phone' + ''' + + def __init__(self, **kwargs): + # initialize variables + self._clipboard = Clipboard + self.info_bubble = None + self.nfcscanner = None + self.tabs = None + self.is_exit = False + self.wallet = None + self.pause_time = 0 + + App.__init__(self)#, **kwargs) + + title = _('Electrum App') + self.electrum_config = config = kwargs.get('config', None) + self.language = config.get('language', 'en') + self.network = network = kwargs.get('network', None) + if self.network: + self.num_blocks = self.network.get_local_height() + self.num_nodes = len(self.network.get_interfaces()) + host, port, protocol, proxy_config, auto_connect = self.network.get_parameters() + self.server_host = host + self.server_port = port + self.auto_connect = auto_connect + self.proxy_config = proxy_config if proxy_config else {} + + self.plugins = kwargs.get('plugins', []) + self.gui_object = kwargs.get('gui_object', None) + self.daemon = self.gui_object.daemon + self.fx = self.daemon.fx + + self.use_rbf = config.get('use_rbf', True) + self.use_change = config.get('use_change', True) + self.use_unconfirmed = not config.get('confirmed_only', False) + + # create triggers so as to minimize updating a max of 2 times a sec + self._trigger_update_wallet = Clock.create_trigger(self.update_wallet, .5) + self._trigger_update_status = Clock.create_trigger(self.update_status, .5) + self._trigger_update_history = Clock.create_trigger(self.update_history, .5) + self._trigger_update_interfaces = Clock.create_trigger(self.update_interfaces, .5) + # cached dialogs + self._settings_dialog = None + self._password_dialog = None + self.fee_status = self.electrum_config.get_fee_status() + + def wallet_name(self): + return os.path.basename(self.wallet.storage.path) if self.wallet else ' ' + + def on_pr(self, pr): + if not self.wallet: + self.show_error(_('No wallet loaded.')) + return + if pr.verify(self.wallet.contacts): + key = self.wallet.invoices.add(pr) + if self.invoices_screen: + self.invoices_screen.update() + status = self.wallet.invoices.get_status(key) + if status == PR_PAID: + self.show_error("invoice already paid") + self.send_screen.do_clear() + else: + if pr.has_expired(): + self.show_error(_('Payment request has expired')) + else: + self.switch_to('send') + self.send_screen.set_request(pr) + else: + self.show_error("invoice error:" + pr.error) + self.send_screen.do_clear() + + def on_qr(self, data): + from electrum.bitcoin import base_decode, is_address + data = data.strip() + if is_address(data): + self.set_URI(data) + return + if data.startswith('bitcoin:'): + self.set_URI(data) + return + # try to decode transaction + from electrum.transaction import Transaction + from electrum.util import bh2u + try: + text = bh2u(base_decode(data, None, base=43)) + tx = Transaction(text) + tx.deserialize() + except: + tx = None + if tx: + self.tx_dialog(tx) + return + # show error + self.show_error("Unable to decode QR data") + + def update_tab(self, name): + s = getattr(self, name + '_screen', None) + if s: + s.update() + + @profiler + def update_tabs(self): + for tab in ['invoices', 'send', 'history', 'receive', 'address']: + self.update_tab(tab) + + def switch_to(self, name): + s = getattr(self, name + '_screen', None) + if s is None: + s = self.tabs.ids[name + '_screen'] + s.load_screen() + panel = self.tabs.ids.panel + tab = self.tabs.ids[name + '_tab'] + panel.switch_to(tab) + + def show_request(self, addr): + self.switch_to('receive') + self.receive_screen.screen.address = addr + + def show_pr_details(self, req, status, is_invoice): + from electrum.util import format_time + requestor = req.get('requestor') + exp = req.get('exp') + memo = req.get('memo') + amount = req.get('amount') + fund = req.get('fund') + popup = Builder.load_file('electrum/gui/kivy/uix/ui_screens/invoice.kv') + popup.is_invoice = is_invoice + popup.amount = amount + popup.requestor = requestor if is_invoice else req.get('address') + popup.exp = format_time(exp) if exp else '' + popup.description = memo if memo else '' + popup.signature = req.get('signature', '') + popup.status = status + popup.fund = fund if fund else 0 + txid = req.get('txid') + popup.tx_hash = txid or '' + popup.on_open = lambda: popup.ids.output_list.update(req.get('outputs', [])) + popup.export = self.export_private_keys + popup.open() + + def show_addr_details(self, req, status): + from electrum.util import format_time + fund = req.get('fund') + isaddr = 'y' + popup = Builder.load_file('electrum/gui/kivy/uix/ui_screens/invoice.kv') + popup.isaddr = isaddr + popup.is_invoice = False + popup.status = status + popup.requestor = req.get('address') + popup.fund = fund if fund else 0 + popup.export = self.export_private_keys + popup.open() + + def qr_dialog(self, title, data, show_text=False): + from .uix.dialogs.qr_dialog import QRDialog + popup = QRDialog(title, data, show_text) + popup.open() + + def scan_qr(self, on_complete): + if platform != 'android': + return + from jnius import autoclass, cast + from android import activity + PythonActivity = autoclass('org.kivy.android.PythonActivity') + SimpleScannerActivity = autoclass("org.electrum.qr.SimpleScannerActivity") + Intent = autoclass('android.content.Intent') + intent = Intent(PythonActivity.mActivity, SimpleScannerActivity) + + def on_qr_result(requestCode, resultCode, intent): + try: + if resultCode == -1: # RESULT_OK: + # this doesn't work due to some bug in jnius: + # contents = intent.getStringExtra("text") + String = autoclass("java.lang.String") + contents = intent.getStringExtra(String("text")) + on_complete(contents) + finally: + activity.unbind(on_activity_result=on_qr_result) + activity.bind(on_activity_result=on_qr_result) + PythonActivity.mActivity.startActivityForResult(intent, 0) + + def do_share(self, data, title): + if platform != 'android': + return + from jnius import autoclass, cast + JS = autoclass('java.lang.String') + Intent = autoclass('android.content.Intent') + sendIntent = Intent() + sendIntent.setAction(Intent.ACTION_SEND) + sendIntent.setType("text/plain") + sendIntent.putExtra(Intent.EXTRA_TEXT, JS(data)) + PythonActivity = autoclass('org.kivy.android.PythonActivity') + currentActivity = cast('android.app.Activity', PythonActivity.mActivity) + it = Intent.createChooser(sendIntent, cast('java.lang.CharSequence', JS(title))) + currentActivity.startActivity(it) + + def build(self): + return Builder.load_file('electrum/gui/kivy/main.kv') + + def _pause(self): + if platform == 'android': + # move activity to back + from jnius import autoclass + python_act = autoclass('org.kivy.android.PythonActivity') + mActivity = python_act.mActivity + mActivity.moveTaskToBack(True) + + def on_start(self): + ''' This is the start point of the kivy ui + ''' + import time + Logger.info('Time to on_start: {} <<<<<<<<'.format(time.clock())) + win = Window + win.bind(size=self.on_size, on_keyboard=self.on_keyboard) + win.bind(on_key_down=self.on_key_down) + #win.softinput_mode = 'below_target' + self.on_size(win, win.size) + self.init_ui() + crash_reporter.ExceptionHook(self) + # init plugins + run_hook('init_kivy', self) + # fiat currency + self.fiat_unit = self.fx.ccy if self.fx.is_enabled() else '' + # default tab + self.switch_to('history') + # bind intent for bitcoin: URI scheme + if platform == 'android': + from android import activity + from jnius import autoclass + PythonActivity = autoclass('org.kivy.android.PythonActivity') + mactivity = PythonActivity.mActivity + self.on_new_intent(mactivity.getIntent()) + activity.bind(on_new_intent=self.on_new_intent) + # connect callbacks + if self.network: + interests = ['updated', 'status', 'new_transaction', 'verified', 'interfaces'] + self.network.register_callback(self.on_network_event, interests) + self.network.register_callback(self.on_fee, ['fee']) + self.network.register_callback(self.on_quotes, ['on_quotes']) + self.network.register_callback(self.on_history, ['on_history']) + # load wallet + self.load_wallet_by_name(self.electrum_config.get_wallet_path()) + # URI passed in config + uri = self.electrum_config.get('url') + if uri: + self.set_URI(uri) + + + def get_wallet_path(self): + if self.wallet: + return self.wallet.storage.path + else: + return '' + + def on_wizard_complete(self, wizard, wallet): + if wallet: # wizard returned a wallet + wallet.start_threads(self.daemon.network) + self.daemon.add_wallet(wallet) + self.load_wallet(wallet) + elif not self.wallet: + # wizard did not return a wallet; and there is no wallet open atm + # try to open last saved wallet (potentially start wizard again) + self.load_wallet_by_name(self.electrum_config.get_wallet_path(), ask_if_wizard=True) + + def load_wallet_by_name(self, path, ask_if_wizard=False): + if not path: + return + if self.wallet and self.wallet.storage.path == path: + return + wallet = self.daemon.load_wallet(path, None) + if wallet: + if wallet.has_password(): + self.password_dialog(wallet, _('Enter PIN code'), lambda x: self.load_wallet(wallet), self.stop) + else: + self.load_wallet(wallet) + else: + Logger.debug('Electrum: Wallet not found or action needed. Launching install wizard') + + def launch_wizard(): + storage = WalletStorage(path, manual_upgrades=True) + wizard = Factory.InstallWizard(self.electrum_config, self.plugins, storage) + wizard.bind(on_wizard_complete=self.on_wizard_complete) + action = wizard.storage.get_action() + wizard.run(action) + if not ask_if_wizard: + launch_wizard() + else: + from .uix.dialogs.question import Question + + def handle_answer(b: bool): + if b: + launch_wizard() + else: + try: os.unlink(path) + except FileNotFoundError: pass + self.stop() + d = Question(_('Do you want to launch the wizard again?'), handle_answer) + d.open() + + def on_stop(self): + Logger.info('on_stop') + if self.wallet: + self.electrum_config.save_last_wallet(self.wallet) + self.stop_wallet() + + def stop_wallet(self): + if self.wallet: + self.daemon.stop_wallet(self.wallet.storage.path) + self.wallet = None + + def on_key_down(self, instance, key, keycode, codepoint, modifiers): + if 'ctrl' in modifiers: + # q=24 w=25 + if keycode in (24, 25): + self.stop() + elif keycode == 27: + # r=27 + # force update wallet + self.update_wallet() + elif keycode == 112: + # pageup + #TODO move to next tab + pass + elif keycode == 117: + # pagedown + #TODO move to prev tab + pass + #TODO: alt+tab_number to activate the particular tab + + def on_keyboard(self, instance, key, keycode, codepoint, modifiers): + if key == 27 and self.is_exit is False: + self.is_exit = True + self.show_info(_('Press again to exit')) + return True + # override settings button + if key in (319, 282): #f1/settings button on android + #self.gui.main_gui.toggle_settings(self) + return True + + def settings_dialog(self): + from .uix.dialogs.settings import SettingsDialog + if self._settings_dialog is None: + self._settings_dialog = SettingsDialog(self) + self._settings_dialog.update() + self._settings_dialog.open() + + def popup_dialog(self, name): + if name == 'settings': + self.settings_dialog() + elif name == 'wallets': + from .uix.dialogs.wallets import WalletDialog + d = WalletDialog() + d.open() + elif name == 'status': + popup = Builder.load_file('electrum/gui/kivy/uix/ui_screens/'+name+'.kv') + master_public_keys_layout = popup.ids.master_public_keys + for xpub in self.wallet.get_master_public_keys()[1:]: + master_public_keys_layout.add_widget(TopLabel(text=_('Master Public Key'))) + ref = RefLabel() + ref.name = _('Master Public Key') + ref.data = xpub + master_public_keys_layout.add_widget(ref) + popup.open() + else: + popup = Builder.load_file('electrum/gui/kivy/uix/ui_screens/'+name+'.kv') + popup.open() + + @profiler + def init_ui(self): + ''' Initialize The Ux part of electrum. This function performs the basic + tasks of setting up the ui. + ''' + #from weakref import ref + + self.funds_error = False + # setup UX + self.screens = {} + + #setup lazy imports for mainscreen + Factory.register('AnimatedPopup', + module='electrum.gui.kivy.uix.dialogs') + Factory.register('QRCodeWidget', + module='electrum.gui.kivy.uix.qrcodewidget') + + # preload widgets. Remove this if you want to load the widgets on demand + #Cache.append('electrum_widgets', 'AnimatedPopup', Factory.AnimatedPopup()) + #Cache.append('electrum_widgets', 'QRCodeWidget', Factory.QRCodeWidget()) + + # load and focus the ui + self.root.manager = self.root.ids['manager'] + + self.history_screen = None + self.contacts_screen = None + self.send_screen = None + self.invoices_screen = None + self.receive_screen = None + self.requests_screen = None + self.address_screen = None + self.icon = "icons/electrum.png" + self.tabs = self.root.ids['tabs'] + + def update_interfaces(self, dt): + self.num_nodes = len(self.network.get_interfaces()) + self.num_chains = len(self.network.get_blockchains()) + chain = self.network.blockchain() + self.blockchain_checkpoint = chain.get_checkpoint() + self.blockchain_name = chain.get_name() + interface = self.network.interface + if interface: + self.server_host = interface.host + + def on_network_event(self, event, *args): + Logger.info('network event: '+ event) + if event == 'interfaces': + self._trigger_update_interfaces() + elif event == 'updated': + self._trigger_update_wallet() + self._trigger_update_status() + elif event == 'status': + self._trigger_update_status() + elif event == 'new_transaction': + self._trigger_update_wallet() + elif event == 'verified': + self._trigger_update_wallet() + + @profiler + def load_wallet(self, wallet): + if self.wallet: + self.stop_wallet() + self.wallet = wallet + self.update_wallet() + # Once GUI has been initialized check if we want to announce something + # since the callback has been called before the GUI was initialized + if self.receive_screen: + self.receive_screen.clear() + self.update_tabs() + run_hook('load_wallet', wallet, self) + + def update_status(self, *dt): + self.num_blocks = self.network.get_local_height() + if not self.wallet: + self.status = _("No Wallet") + return + if self.network is None or not self.network.is_running(): + status = _("Offline") + elif self.network.is_connected(): + server_height = self.network.get_server_height() + server_lag = self.network.get_local_height() - server_height + if not self.wallet.up_to_date or server_height == 0: + status = _("Synchronizing...") + elif server_lag > 1: + status = _("Server lagging") + else: + status = '' + else: + status = _("Disconnected") + self.status = self.wallet.basename() + (' [size=15dp](%s)[/size]'%status if status else '') + # balance + c, u, x = self.wallet.get_balance() + text = self.format_amount(c+x+u) + self.balance = str(text.strip()) + ' [size=22dp]%s[/size]'% self.base_unit + self.fiat_balance = self.fx.format_amount(c+u+x) + ' [size=22dp]%s[/size]'% self.fx.ccy + + def get_max_amount(self): + if run_hook('abort_send', self): + return '' + inputs = self.wallet.get_spendable_coins(None, self.electrum_config) + if not inputs: + return '' + addr = str(self.send_screen.screen.address) or self.wallet.dummy_address() + outputs = [(TYPE_ADDRESS, addr, '!')] + try: + tx = self.wallet.make_unsigned_transaction(inputs, outputs, self.electrum_config) + except NoDynamicFeeEstimates as e: + Clock.schedule_once(lambda dt, bound_e=e: self.show_error(str(bound_e))) + return '' + except NotEnoughFunds: + return '' + amount = tx.output_value() + __, x_fee_amount = run_hook('get_tx_extra_fee', self.wallet, tx) or (None, 0) + amount_after_all_fees = amount - x_fee_amount + return format_satoshis_plain(amount_after_all_fees, self.decimal_point()) + + def format_amount(self, x, is_diff=False, whitespaces=False): + return format_satoshis(x, 0, self.decimal_point(), is_diff=is_diff, whitespaces=whitespaces) + + def format_amount_and_units(self, x): + return format_satoshis_plain(x, self.decimal_point()) + ' ' + self.base_unit + + #@profiler + def update_wallet(self, *dt): + self._trigger_update_status() + if self.wallet and (self.wallet.up_to_date or not self.network or not self.network.is_connected()): + self.update_tabs() + + def notify(self, message): + try: + global notification, os + if not notification: + from plyer import notification + icon = (os.path.dirname(os.path.realpath(__file__)) + + '/../../' + self.icon) + notification.notify('Electrum', message, + app_icon=icon, app_name='Electrum') + except ImportError: + Logger.Error('Notification: needs plyer; `sudo pip install plyer`') + + def on_pause(self): + self.pause_time = time.time() + # pause nfc + if self.nfcscanner: + self.nfcscanner.nfc_disable() + return True + + def on_resume(self): + now = time.time() + if self.wallet and self.wallet.has_password() and now - self.pause_time > 60: + self.password_dialog(self.wallet, _('Enter PIN'), None, self.stop) + if self.nfcscanner: + self.nfcscanner.nfc_enable() + + def on_size(self, instance, value): + width, height = value + self._orientation = 'landscape' if width > height else 'portrait' + self._ui_mode = 'tablet' if min(width, height) > inch(3.51) else 'phone' + + def on_ref_label(self, label, touch): + if label.touched: + label.touched = False + self.qr_dialog(label.name, label.data, True) + else: + label.touched = True + self._clipboard.copy(label.data) + Clock.schedule_once(lambda dt: self.show_info(_('Text copied to clipboard.\nTap again to display it as QR code.'))) + + def set_send(self, address, amount, label, message): + self.send_payment(address, amount=amount, label=label, message=message) + + def show_error(self, error, width='200dp', pos=None, arrow_pos=None, + exit=False, icon='atlas://electrum/gui/kivy/theming/light/error', duration=0, + modal=False): + ''' Show an error Message Bubble. + ''' + self.show_info_bubble( text=error, icon=icon, width=width, + pos=pos or Window.center, arrow_pos=arrow_pos, exit=exit, + duration=duration, modal=modal) + + def show_info(self, error, width='200dp', pos=None, arrow_pos=None, + exit=False, duration=0, modal=False): + ''' Show an Info Message Bubble. + ''' + self.show_error(error, icon='atlas://electrum/gui/kivy/theming/light/important', + duration=duration, modal=modal, exit=exit, pos=pos, + arrow_pos=arrow_pos) + + def show_info_bubble(self, text=_('Hello World'), pos=None, duration=0, + arrow_pos='bottom_mid', width=None, icon='', modal=False, exit=False): + '''Method to show an Information Bubble + + .. parameters:: + text: Message to be displayed + pos: position for the bubble + duration: duration the bubble remains on screen. 0 = click to hide + width: width of the Bubble + arrow_pos: arrow position for the bubble + ''' + info_bubble = self.info_bubble + if not info_bubble: + info_bubble = self.info_bubble = Factory.InfoBubble() + + win = Window + if info_bubble.parent: + win.remove_widget(info_bubble + if not info_bubble.modal else + info_bubble._modal_view) + + if not arrow_pos: + info_bubble.show_arrow = False + else: + info_bubble.show_arrow = True + info_bubble.arrow_pos = arrow_pos + img = info_bubble.ids.img + if text == 'texture': + # icon holds a texture not a source image + # display the texture in full screen + text = '' + img.texture = icon + info_bubble.fs = True + info_bubble.show_arrow = False + img.allow_stretch = True + info_bubble.dim_background = True + info_bubble.background_image = 'atlas://electrum/gui/kivy/theming/light/card' + else: + info_bubble.fs = False + info_bubble.icon = icon + #if img.texture and img._coreimage: + # img.reload() + img.allow_stretch = False + info_bubble.dim_background = False + info_bubble.background_image = 'atlas://data/images/defaulttheme/bubble' + info_bubble.message = text + if not pos: + pos = (win.center[0], win.center[1] - (info_bubble.height/2)) + info_bubble.show(pos, duration, width, modal=modal, exit=exit) + + def tx_dialog(self, tx): + from .uix.dialogs.tx_dialog import TxDialog + d = TxDialog(self, tx) + d.open() + + def sign_tx(self, *args): + threading.Thread(target=self._sign_tx, args=args).start() + + def _sign_tx(self, tx, password, on_success, on_failure): + try: + self.wallet.sign_transaction(tx, password) + except InvalidPassword: + Clock.schedule_once(lambda dt: on_failure(_("Invalid PIN"))) + return + on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success + Clock.schedule_once(lambda dt: on_success(tx)) + + def _broadcast_thread(self, tx, on_complete): + ok, txid = self.network.broadcast_transaction(tx) + Clock.schedule_once(lambda dt: on_complete(ok, txid)) + + def broadcast(self, tx, pr=None): + def on_complete(ok, msg): + if ok: + self.show_info(_('Payment sent.')) + if self.send_screen: + self.send_screen.do_clear() + if pr: + self.wallet.invoices.set_paid(pr, tx.txid()) + self.wallet.invoices.save() + self.update_tab('invoices') + else: + self.show_error(msg) + + if self.network and self.network.is_connected(): + self.show_info(_('Sending')) + threading.Thread(target=self._broadcast_thread, args=(tx, on_complete)).start() + else: + self.show_info(_('Cannot broadcast transaction') + ':\n' + _('Not connected')) + + def description_dialog(self, screen): + from .uix.dialogs.label_dialog import LabelDialog + text = screen.message + def callback(text): + screen.message = text + d = LabelDialog(_('Enter description'), text, callback) + d.open() + + def amount_dialog(self, screen, show_max): + from .uix.dialogs.amount_dialog import AmountDialog + amount = screen.amount + if amount: + amount, u = str(amount).split() + assert u == self.base_unit + def cb(amount): + screen.amount = amount + popup = AmountDialog(show_max, amount, cb) + popup.open() + + def invoices_dialog(self, screen): + from .uix.dialogs.invoices import InvoicesDialog + if len(self.wallet.invoices.sorted_list()) == 0: + self.show_info(' '.join([ + _('No saved invoices.'), + _('Signed invoices are saved automatically when you scan them.'), + _('You may also save unsigned requests or contact addresses using the save button.') + ])) + return + popup = InvoicesDialog(self, screen, None) + popup.update() + popup.open() + + def requests_dialog(self, screen): + from .uix.dialogs.requests import RequestsDialog + if len(self.wallet.get_sorted_requests(self.electrum_config)) == 0: + self.show_info(_('No saved requests.')) + return + popup = RequestsDialog(self, screen, None) + popup.update() + popup.open() + + def addresses_dialog(self, screen): + from .uix.dialogs.addresses import AddressesDialog + popup = AddressesDialog(self, screen, None) + popup.update() + popup.open() + + def fee_dialog(self, label, dt): + from .uix.dialogs.fee_dialog import FeeDialog + def cb(): + self.fee_status = self.electrum_config.get_fee_status() + fee_dialog = FeeDialog(self, self.electrum_config, cb) + fee_dialog.open() + + def on_fee(self, event, *arg): + self.fee_status = self.electrum_config.get_fee_status() + + def protected(self, msg, f, args): + if self.wallet.has_password(): + on_success = lambda pw: f(*(args + (pw,))) + self.password_dialog(self.wallet, msg, on_success, lambda: None) + else: + f(*(args + (None,))) + + def delete_wallet(self): + from .uix.dialogs.question import Question + basename = os.path.basename(self.wallet.storage.path) + d = Question(_('Delete wallet?') + '\n' + basename, self._delete_wallet) + d.open() + + def _delete_wallet(self, b): + if b: + basename = self.wallet.basename() + self.protected(_("Enter your PIN code to confirm deletion of {}").format(basename), self.__delete_wallet, ()) + + def __delete_wallet(self, pw): + wallet_path = self.get_wallet_path() + dirname = os.path.dirname(wallet_path) + basename = os.path.basename(wallet_path) + if self.wallet.has_password(): + try: + self.wallet.check_password(pw) + except: + self.show_error("Invalid PIN") + return + self.stop_wallet() + os.unlink(wallet_path) + self.show_error(_("Wallet removed: {}").format(basename)) + new_path = self.electrum_config.get_wallet_path() + self.load_wallet_by_name(new_path) + + def show_seed(self, label): + self.protected(_("Enter your PIN code in order to decrypt your seed"), self._show_seed, (label,)) + + def _show_seed(self, label, password): + if self.wallet.has_password() and password is None: + return + keystore = self.wallet.keystore + try: + seed = keystore.get_seed(password) + passphrase = keystore.get_passphrase(password) + except: + self.show_error("Invalid PIN") + return + label.text = _('Seed') + ':\n' + seed + if passphrase: + label.text += '\n\n' + _('Passphrase') + ': ' + passphrase + + def password_dialog(self, wallet, msg, on_success, on_failure): + from .uix.dialogs.password_dialog import PasswordDialog + if self._password_dialog is None: + self._password_dialog = PasswordDialog() + self._password_dialog.init(self, wallet, msg, on_success, on_failure) + self._password_dialog.open() + + def change_password(self, cb): + from .uix.dialogs.password_dialog import PasswordDialog + if self._password_dialog is None: + self._password_dialog = PasswordDialog() + message = _("Changing PIN code.") + '\n' + _("Enter your current PIN:") + def on_success(old_password, new_password): + self.wallet.update_password(old_password, new_password) + self.show_info(_("Your PIN code was updated")) + on_failure = lambda: self.show_error(_("PIN codes do not match")) + self._password_dialog.init(self, self.wallet, message, on_success, on_failure, is_change=1) + self._password_dialog.open() + + def export_private_keys(self, pk_label, addr): + if self.wallet.is_watching_only(): + self.show_info(_('This is a watching-only wallet. It does not contain private keys.')) + return + def show_private_key(addr, pk_label, password): + if self.wallet.has_password() and password is None: + return + if not self.wallet.can_export(): + return + try: + key = str(self.wallet.export_private_key(addr, password)[0]) + pk_label.data = key + except InvalidPassword: + self.show_error("Invalid PIN") + return + self.protected(_("Enter your PIN code in order to decrypt your private key"), show_private_key, (addr, pk_label)) DIR diff --git a/electrum/gui/kivy/nfc_scanner/__init__.py b/electrum/gui/kivy/nfc_scanner/__init__.py t@@ -0,0 +1,44 @@ +__all__ = ('NFCBase', 'NFCScanner') + +class NFCBase(Widget): + ''' This is the base Abstract definition class that the actual hardware dependent + implementations would be based on. If you want to define a feature that is + accessible and implemented by every platform implementation then define that + method in this class. + ''' + + payload = ObjectProperty(None) + '''This is the data gotten from the tag. + ''' + + def nfc_init(self): + ''' Initialize the adapter. + ''' + pass + + def nfc_disable(self): + ''' Disable scanning + ''' + pass + + def nfc_enable(self): + ''' Enable Scanning + ''' + pass + + def nfc_enable_exchange(self, data): + ''' Enable P2P Ndef exchange + ''' + pass + + def nfc_disable_exchange(self): + ''' Disable/Stop P2P Ndef exchange + ''' + pass + +# load NFCScanner implementation + +NFCScanner = core_select_lib('nfc_manager', ( + # keep the dummy implementation as the last one to make it the fallback provider.NFCScanner = core_select_lib('nfc_scanner', ( + ('android', 'scanner_android', 'ScannerAndroid'), + ('dummy', 'scanner_dummy', 'ScannerDummy')), True, 'electrum.gui.kivy') DIR diff --git a/electrum/gui/kivy/nfc_scanner/scanner_android.py b/electrum/gui/kivy/nfc_scanner/scanner_android.py t@@ -0,0 +1,242 @@ +'''This is the Android implementation of NFC Scanning using the +built in NFC adapter of some android phones. +''' + +from kivy.app import App +from kivy.clock import Clock +#Detect which platform we are on +from kivy.utils import platform +if platform != 'android': + raise ImportError +import threading + +from . import NFCBase +from jnius import autoclass, cast +from android.runnable import run_on_ui_thread +from android import activity + +BUILDVERSION = autoclass('android.os.Build$VERSION').SDK_INT +NfcAdapter = autoclass('android.nfc.NfcAdapter') +PythonActivity = autoclass('org.kivy.android.PythonActivity') +JString = autoclass('java.lang.String') +Charset = autoclass('java.nio.charset.Charset') +locale = autoclass('java.util.Locale') +Intent = autoclass('android.content.Intent') +IntentFilter = autoclass('android.content.IntentFilter') +PendingIntent = autoclass('android.app.PendingIntent') +Ndef = autoclass('android.nfc.tech.Ndef') +NdefRecord = autoclass('android.nfc.NdefRecord') +NdefMessage = autoclass('android.nfc.NdefMessage') + +app = None + + + +class ScannerAndroid(NFCBase): + ''' This is the class responsible for handling the interface with the + Android NFC adapter. See Module Documentation for details. + ''' + + name = 'NFCAndroid' + + def nfc_init(self): + ''' This is where we initialize NFC adapter. + ''' + # Initialize NFC + global app + app = App.get_running_app() + + # Make sure we are listening to new intent + activity.bind(on_new_intent=self.on_new_intent) + + # Configure nfc + self.j_context = context = PythonActivity.mActivity + self.nfc_adapter = NfcAdapter.getDefaultAdapter(context) + # Check if adapter exists + if not self.nfc_adapter: + return False + + # specify that we want our activity to remain on top when a new intent + # is fired + self.nfc_pending_intent = PendingIntent.getActivity(context, 0, + Intent(context, context.getClass()).addFlags( + Intent.FLAG_ACTIVITY_SINGLE_TOP), 0) + + # Filter for different types of action, by default we enable all. + # These are only for handling different NFC technologies when app is in foreground + self.ndef_detected = IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED) + #self.tech_detected = IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED) + #self.tag_detected = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED) + + # setup tag discovery for ourt tag type + try: + self.ndef_detected.addCategory(Intent.CATEGORY_DEFAULT) + # setup the foreground dispatch to detect all mime types + self.ndef_detected.addDataType('*/*') + + self.ndef_exchange_filters = [self.ndef_detected] + except Exception as err: + raise Exception(repr(err)) + return True + + def get_ndef_details(self, tag): + ''' Get all the details from the tag. + ''' + details = {} + + try: + #print 'id' + details['uid'] = ':'.join(['{:02x}'.format(bt & 0xff) for bt in tag.getId()]) + #print 'technologies' + details['Technologies'] = tech_list = [tech.split('.')[-1] for tech in tag.getTechList()] + #print 'get NDEF tag details' + ndefTag = cast('android.nfc.tech.Ndef', Ndef.get(tag)) + #print 'tag size' + details['MaxSize'] = ndefTag.getMaxSize() + #details['usedSize'] = '0' + #print 'is tag writable?' + details['writable'] = ndefTag.isWritable() + #print 'Data format' + # Can be made readonly + # get NDEF message details + ndefMesg = ndefTag.getCachedNdefMessage() + # get size of current records + details['consumed'] = len(ndefMesg.toByteArray()) + #print 'tag type' + details['Type'] = ndefTag.getType() + + # check if tag is empty + if not ndefMesg: + details['Message'] = None + return details + + ndefrecords = ndefMesg.getRecords() + length = len(ndefrecords) + #print 'length', length + # will contain the NDEF record types + recTypes = [] + for record in ndefrecords: + recTypes.append({ + 'type': ''.join(map(unichr, record.getType())), + 'payload': ''.join(map(unichr, record.getPayload())) + }) + + details['recTypes'] = recTypes + except Exception as err: + print(str(err)) + + return details + + def on_new_intent(self, intent): + ''' This function is called when the application receives a + new intent, for the ones the application has registered previously, + either in the manifest or in the foreground dispatch setup in the + nfc_init function above. + ''' + + action_list = (NfcAdapter.ACTION_NDEF_DISCOVERED,) + # get TAG + #tag = cast('android.nfc.Tag', intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)) + + #details = self.get_ndef_details(tag) + + if intent.getAction() not in action_list: + print('unknow action, avoid.') + return + + rawmsgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) + if not rawmsgs: + return + for message in rawmsgs: + message = cast(NdefMessage, message) + payload = message.getRecords()[0].getPayload() + print('payload: {}'.format(''.join(map(chr, payload)))) + + def nfc_disable(self): + '''Disable app from handling tags. + ''' + self.disable_foreground_dispatch() + + def nfc_enable(self): + '''Enable app to handle tags when app in foreground. + ''' + self.enable_foreground_dispatch() + + def create_AAR(self): + '''Create the record responsible for linking our application to the tag. + ''' + return NdefRecord.createApplicationRecord(JString("org.electrum.kivy")) + + def create_TNF_EXTERNAL(self, data): + '''Create our actual payload record. + ''' + if BUILDVERSION >= 14: + domain = "org.electrum" + stype = "externalType" + extRecord = NdefRecord.createExternal(domain, stype, data) + else: + # Creating the NdefRecord manually: + extRecord = NdefRecord( + NdefRecord.TNF_EXTERNAL_TYPE, + "org.electrum:externalType", + '', + data) + return extRecord + + def create_ndef_message(self, *recs): + ''' Create the Ndef message that will be written to tag + ''' + records = [] + for record in recs: + if record: + records.append(record) + + return NdefMessage(records) + + + @run_on_ui_thread + def disable_foreground_dispatch(self): + '''Disable foreground dispatch when app is paused. + ''' + self.nfc_adapter.disableForegroundDispatch(self.j_context) + + @run_on_ui_thread + def enable_foreground_dispatch(self): + '''Start listening for new tags + ''' + self.nfc_adapter.enableForegroundDispatch(self.j_context, + self.nfc_pending_intent, self.ndef_exchange_filters, self.ndef_tech_list) + + @run_on_ui_thread + def _nfc_enable_ndef_exchange(self, data): + # Enable p2p exchange + # Create record + ndef_record = NdefRecord( + NdefRecord.TNF_MIME_MEDIA, + 'org.electrum.kivy', '', data) + + # Create message + ndef_message = NdefMessage([ndef_record]) + + # Enable ndef push + self.nfc_adapter.enableForegroundNdefPush(self.j_context, ndef_message) + + # Enable dispatch + self.nfc_adapter.enableForegroundDispatch(self.j_context, + self.nfc_pending_intent, self.ndef_exchange_filters, []) + + @run_on_ui_thread + def _nfc_disable_ndef_exchange(self): + # Disable p2p exchange + self.nfc_adapter.disableForegroundNdefPush(self.j_context) + self.nfc_adapter.disableForegroundDispatch(self.j_context) + + def nfc_enable_exchange(self, data): + '''Enable Ndef exchange for p2p + ''' + self._nfc_enable_ndef_exchange() + + def nfc_disable_exchange(self): + ''' Disable Ndef exchange for p2p + ''' + self._nfc_disable_ndef_exchange() DIR diff --git a/electrum/gui/kivy/nfc_scanner/scanner_dummy.py b/electrum/gui/kivy/nfc_scanner/scanner_dummy.py t@@ -0,0 +1,52 @@ +''' Dummy NFC Provider to be used on desktops in case no other provider is found +''' +from . import NFCBase +from kivy.clock import Clock +from kivy.logger import Logger + +class ScannerDummy(NFCBase): + '''This is the dummy interface that gets selected in case any other + hardware interface to NFC is not available. + ''' + + _initialised = False + + name = 'NFCDummy' + + def nfc_init(self): + # print 'nfc_init()' + + Logger.debug('NFC: configure nfc') + self._initialised = True + self.nfc_enable() + return True + + def on_new_intent(self, dt): + tag_info = {'type': 'dymmy', + 'message': 'dummy', + 'extra details': None} + + # let Main app know that a tag has been detected + app = App.get_running_app() + app.tag_discovered(tag_info) + app.show_info('New tag detected.', duration=2) + Logger.debug('NFC: got new dummy tag') + + def nfc_enable(self): + Logger.debug('NFC: enable') + if self._initialised: + Clock.schedule_interval(self.on_new_intent, 22) + + def nfc_disable(self): + # print 'nfc_enable()' + Clock.unschedule(self.on_new_intent) + + def nfc_enable_exchange(self, data): + ''' Start sending data + ''' + Logger.debug('NFC: sending data {}'.format(data)) + + def nfc_disable_exchange(self): + ''' Disable/Stop ndef exchange + ''' + Logger.debug('NFC: disable nfc exchange') DIR diff --git a/gui/kivy/theming/light/action_bar.png b/electrum/gui/kivy/theming/light/action_bar.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/action_button_group.png b/electrum/gui/kivy/theming/light/action_button_group.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/action_group_dark.png b/electrum/gui/kivy/theming/light/action_group_dark.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/action_group_light.png b/electrum/gui/kivy/theming/light/action_group_light.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/add_contact.png b/electrum/gui/kivy/theming/light/add_contact.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/arrow_back.png b/electrum/gui/kivy/theming/light/arrow_back.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/bit_logo.png b/electrum/gui/kivy/theming/light/bit_logo.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/blue_bg_round_rb.png b/electrum/gui/kivy/theming/light/blue_bg_round_rb.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/btn_create_account.png b/electrum/gui/kivy/theming/light/btn_create_account.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/btn_create_act_disabled.png b/electrum/gui/kivy/theming/light/btn_create_act_disabled.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/btn_nfc.png b/electrum/gui/kivy/theming/light/btn_nfc.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/btn_send_address.png b/electrum/gui/kivy/theming/light/btn_send_address.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/btn_send_nfc.png b/electrum/gui/kivy/theming/light/btn_send_nfc.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/calculator.png b/electrum/gui/kivy/theming/light/calculator.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/camera.png b/electrum/gui/kivy/theming/light/camera.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/card.png b/electrum/gui/kivy/theming/light/card.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/card_bottom.png b/electrum/gui/kivy/theming/light/card_bottom.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/card_btn.png b/electrum/gui/kivy/theming/light/card_btn.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/card_top.png b/electrum/gui/kivy/theming/light/card_top.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/carousel_deselected.png b/electrum/gui/kivy/theming/light/carousel_deselected.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/carousel_selected.png b/electrum/gui/kivy/theming/light/carousel_selected.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/clock1.png b/electrum/gui/kivy/theming/light/clock1.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/clock2.png b/electrum/gui/kivy/theming/light/clock2.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/clock3.png b/electrum/gui/kivy/theming/light/clock3.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/clock4.png b/electrum/gui/kivy/theming/light/clock4.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/clock5.png b/electrum/gui/kivy/theming/light/clock5.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/close.png b/electrum/gui/kivy/theming/light/close.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/closebutton.png b/electrum/gui/kivy/theming/light/closebutton.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/confirmed.png b/electrum/gui/kivy/theming/light/confirmed.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/contact.png b/electrum/gui/kivy/theming/light/contact.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/contact_overlay.png b/electrum/gui/kivy/theming/light/contact_overlay.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/create_act_text.png b/electrum/gui/kivy/theming/light/create_act_text.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/create_act_text_active.png b/electrum/gui/kivy/theming/light/create_act_text_active.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/dialog.png b/electrum/gui/kivy/theming/light/dialog.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/dropdown_background.png b/electrum/gui/kivy/theming/light/dropdown_background.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/electrum_icon640.png b/electrum/gui/kivy/theming/light/electrum_icon640.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/error.png b/electrum/gui/kivy/theming/light/error.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/gear.png b/electrum/gui/kivy/theming/light/gear.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/globe.png b/electrum/gui/kivy/theming/light/globe.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/icon_border.png b/electrum/gui/kivy/theming/light/icon_border.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/important.png b/electrum/gui/kivy/theming/light/important.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/info.png b/electrum/gui/kivy/theming/light/info.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/lightblue_bg_round_lb.png b/electrum/gui/kivy/theming/light/lightblue_bg_round_lb.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/logo.png b/electrum/gui/kivy/theming/light/logo.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/logo_atom_dull.png b/electrum/gui/kivy/theming/light/logo_atom_dull.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/mail_icon.png b/electrum/gui/kivy/theming/light/mail_icon.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/manualentry.png b/electrum/gui/kivy/theming/light/manualentry.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/network.png b/electrum/gui/kivy/theming/light/network.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/nfc.png b/electrum/gui/kivy/theming/light/nfc.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/nfc_clock.png b/electrum/gui/kivy/theming/light/nfc_clock.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/nfc_phone.png b/electrum/gui/kivy/theming/light/nfc_phone.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/nfc_stage_one.png b/electrum/gui/kivy/theming/light/nfc_stage_one.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/overflow_background.png b/electrum/gui/kivy/theming/light/overflow_background.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/overflow_btn_dn.png b/electrum/gui/kivy/theming/light/overflow_btn_dn.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/paste_icon.png b/electrum/gui/kivy/theming/light/paste_icon.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/pen.png b/electrum/gui/kivy/theming/light/pen.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/qrcode.png b/electrum/gui/kivy/theming/light/qrcode.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/save.png b/electrum/gui/kivy/theming/light/save.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/settings.png b/electrum/gui/kivy/theming/light/settings.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/shadow.png b/electrum/gui/kivy/theming/light/shadow.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/shadow_right.png b/electrum/gui/kivy/theming/light/shadow_right.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/share.png b/electrum/gui/kivy/theming/light/share.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/star_big_inactive.png b/electrum/gui/kivy/theming/light/star_big_inactive.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/stepper_full.png b/electrum/gui/kivy/theming/light/stepper_full.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/stepper_left.png b/electrum/gui/kivy/theming/light/stepper_left.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/stepper_restore_password.png b/electrum/gui/kivy/theming/light/stepper_restore_password.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/stepper_restore_seed.png b/electrum/gui/kivy/theming/light/stepper_restore_seed.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/tab.png b/electrum/gui/kivy/theming/light/tab.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/tab_btn.png b/electrum/gui/kivy/theming/light/tab_btn.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/tab_btn_disabled.png b/electrum/gui/kivy/theming/light/tab_btn_disabled.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/tab_btn_pressed.png b/electrum/gui/kivy/theming/light/tab_btn_pressed.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/tab_disabled.png b/electrum/gui/kivy/theming/light/tab_disabled.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/tab_strip.png b/electrum/gui/kivy/theming/light/tab_strip.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/textinput_active.png b/electrum/gui/kivy/theming/light/textinput_active.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/unconfirmed.png b/electrum/gui/kivy/theming/light/unconfirmed.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/wallet.png b/electrum/gui/kivy/theming/light/wallet.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/wallets.png b/electrum/gui/kivy/theming/light/wallets.png Binary files differ. DIR diff --git a/gui/kivy/theming/light/white_bg_round_top.png b/electrum/gui/kivy/theming/light/white_bg_round_top.png Binary files differ. DIR diff --git a/gui/kivy/tools/bitcoin_intent.xml b/electrum/gui/kivy/tools/bitcoin_intent.xml DIR diff --git a/gui/kivy/tools/blacklist.txt b/electrum/gui/kivy/tools/blacklist.txt DIR diff --git a/gui/kivy/tools/buildozer.spec b/electrum/gui/kivy/tools/buildozer.spec DIR diff --git a/gui/kivy/uix/__init__.py b/electrum/gui/kivy/uix/__init__.py DIR diff --git a/gui/kivy/uix/combobox.py b/electrum/gui/kivy/uix/combobox.py DIR diff --git a/electrum/gui/kivy/uix/context_menu.py b/electrum/gui/kivy/uix/context_menu.py t@@ -0,0 +1,56 @@ +#!python +#!/usr/bin/env python +from kivy.app import App +from kivy.uix.bubble import Bubble +from kivy.animation import Animation +from kivy.uix.floatlayout import FloatLayout +from kivy.lang import Builder +from kivy.factory import Factory +from kivy.clock import Clock + +from electrum.gui.kivy.i18n import _ + +Builder.load_string(''' +<MenuItem@Button> + background_normal: '' + background_color: (0.192, .498, 0.745, 1) + height: '48dp' + size_hint: 1, None + +<ContextMenu> + size_hint: 1, None + height: '48dp' + pos: (0, 0) + show_arrow: False + arrow_pos: 'top_mid' + padding: 0 + orientation: 'horizontal' + BoxLayout: + size_hint: 1, 1 + height: '48dp' + padding: '12dp', '0dp' + spacing: '3dp' + orientation: 'horizontal' + id: buttons +''') + + +class MenuItem(Factory.Button): + pass + +class ContextMenu(Bubble): + + def __init__(self, obj, action_list): + Bubble.__init__(self) + self.obj = obj + for k, v in action_list: + l = MenuItem() + l.text = _(k) + def func(f=v): + Clock.schedule_once(lambda dt: f(obj), 0.15) + l.on_release = func + self.ids.buttons.add_widget(l) + + def hide(self): + if self.parent: + self.parent.hide_menu() DIR diff --git a/electrum/gui/kivy/uix/dialogs/__init__.py b/electrum/gui/kivy/uix/dialogs/__init__.py t@@ -0,0 +1,220 @@ +from kivy.app import App +from kivy.clock import Clock +from kivy.factory import Factory +from kivy.properties import NumericProperty, StringProperty, BooleanProperty +from kivy.core.window import Window +from kivy.uix.recycleview import RecycleView +from kivy.uix.boxlayout import BoxLayout + +from electrum.gui.kivy.i18n import _ + + + +class AnimatedPopup(Factory.Popup): + ''' An Animated Popup that animates in and out. + ''' + + anim_duration = NumericProperty(.36) + '''Duration of animation to be used + ''' + + __events__ = ['on_activate', 'on_deactivate'] + + + def on_activate(self): + '''Base function to be overridden on inherited classes. + Called when the popup is done animating. + ''' + pass + + def on_deactivate(self): + '''Base function to be overridden on inherited classes. + Called when the popup is done animating. + ''' + pass + + def open(self): + '''Do the initialization of incoming animation here. + Override to set your custom animation. + ''' + def on_complete(*l): + self.dispatch('on_activate') + + self.opacity = 0 + super(AnimatedPopup, self).open() + anim = Factory.Animation(opacity=1, d=self.anim_duration) + anim.bind(on_complete=on_complete) + anim.start(self) + + def dismiss(self): + '''Do the initialization of incoming animation here. + Override to set your custom animation. + ''' + def on_complete(*l): + super(AnimatedPopup, self).dismiss() + self.dispatch('on_deactivate') + + anim = Factory.Animation(opacity=0, d=.25) + anim.bind(on_complete=on_complete) + anim.start(self) + +class EventsDialog(Factory.Popup): + ''' Abstract Popup that provides the following events + .. events:: + `on_release` + `on_press` + ''' + + __events__ = ('on_release', 'on_press') + + def __init__(self, **kwargs): + super(EventsDialog, self).__init__(**kwargs) + + def on_release(self, instance): + pass + + def on_press(self, instance): + pass + + def close(self): + self.dismiss() + + +class SelectionDialog(EventsDialog): + + def add_widget(self, widget, index=0): + if self.content: + self.content.add_widget(widget, index) + return + super(SelectionDialog, self).add_widget(widget) + + +class InfoBubble(Factory.Bubble): + '''Bubble to be used to display short Help Information''' + + message = StringProperty(_('Nothing set !')) + '''Message to be displayed; defaults to "nothing set"''' + + icon = StringProperty('') + ''' Icon to be displayed along with the message defaults to '' + + :attr:`icon` is a `StringProperty` defaults to `''` + ''' + + fs = BooleanProperty(False) + ''' Show Bubble in half screen mode + + :attr:`fs` is a `BooleanProperty` defaults to `False` + ''' + + modal = BooleanProperty(False) + ''' Allow bubble to be hidden on touch. + + :attr:`modal` is a `BooleanProperty` defauult to `False`. + ''' + + exit = BooleanProperty(False) + '''Indicates whether to exit app after bubble is closed. + + :attr:`exit` is a `BooleanProperty` defaults to False. + ''' + + dim_background = BooleanProperty(False) + ''' Indicates Whether to draw a background on the windows behind the bubble. + + :attr:`dim` is a `BooleanProperty` defaults to `False`. + ''' + + def on_touch_down(self, touch): + if self.modal: + return True + self.hide() + if self.collide_point(*touch.pos): + return True + + def show(self, pos, duration, width=None, modal=False, exit=False): + '''Animate the bubble into position''' + self.modal, self.exit = modal, exit + if width: + self.width = width + if self.modal: + from kivy.uix.modalview import ModalView + self._modal_view = m = ModalView(background_color=[.5, .5, .5, .2]) + Window.add_widget(m) + m.add_widget(self) + else: + Window.add_widget(self) + + # wait for the bubble to adjust its size according to text then animate + Clock.schedule_once(lambda dt: self._show(pos, duration)) + + def _show(self, pos, duration): + + def on_stop(*l): + if duration: + Clock.schedule_once(self.hide, duration + .5) + + self.opacity = 0 + arrow_pos = self.arrow_pos + if arrow_pos[0] in ('l', 'r'): + pos = pos[0], pos[1] - (self.height/2) + else: + pos = pos[0] - (self.width/2), pos[1] + + self.limit_to = Window + + anim = Factory.Animation(opacity=1, pos=pos, d=.32) + anim.bind(on_complete=on_stop) + anim.cancel_all(self) + anim.start(self) + + + def hide(self, now=False): + ''' Auto fade out the Bubble + ''' + def on_stop(*l): + if self.modal: + m = self._modal_view + m.remove_widget(self) + Window.remove_widget(m) + Window.remove_widget(self) + if self.exit: + App.get_running_app().stop() + import sys + sys.exit() + else: + App.get_running_app().is_exit = False + + if now: + return on_stop() + + anim = Factory.Animation(opacity=0, d=.25) + anim.bind(on_complete=on_stop) + anim.cancel_all(self) + anim.start(self) + + + +class OutputItem(BoxLayout): + pass + +class OutputList(RecycleView): + + def __init__(self, **kwargs): + super(OutputList, self).__init__(**kwargs) + self.app = App.get_running_app() + + def update(self, outputs): + res = [] + for (type, address, amount) in outputs: + value = self.app.format_amount_and_units(amount) + res.append({'address': address, 'value': value}) + self.data = res + + +class TopLabel(Factory.Label): + pass + + +class RefLabel(TopLabel): + pass DIR diff --git a/electrum/gui/kivy/uix/dialogs/addresses.py b/electrum/gui/kivy/uix/dialogs/addresses.py t@@ -0,0 +1,180 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder +from decimal import Decimal + +Builder.load_string(''' +<AddressLabel@Label> + text_size: self.width, None + halign: 'left' + valign: 'top' + +<AddressItem@CardItem> + address: '' + memo: '' + amount: '' + status: '' + BoxLayout: + spacing: '8dp' + height: '32dp' + orientation: 'vertical' + Widget + AddressLabel: + text: root.address + shorten: True + Widget + AddressLabel: + text: (root.amount if root.status == 'Funded' else root.status) + ' ' + root.memo + color: .699, .699, .699, 1 + font_size: '13sp' + shorten: True + Widget + +<AddressesDialog@Popup> + id: popup + title: _('Addresses') + message: '' + pr_status: 'Pending' + show_change: 0 + show_used: 0 + on_message: + self.update() + BoxLayout: + id:box + padding: '12dp', '70dp', '12dp', '12dp' + spacing: '12dp' + orientation: 'vertical' + size_hint: 1, 1.1 + BoxLayout: + spacing: '6dp' + size_hint: 1, None + orientation: 'horizontal' + AddressFilter: + opacity: 1 + size_hint: 1, None + height: self.minimum_height + spacing: '5dp' + AddressButton: + id: search + text: {0:_('Receiving'), 1:_('Change'), 2:_('All')}[root.show_change] + on_release: + root.show_change = (root.show_change + 1) % 3 + Clock.schedule_once(lambda dt: root.update()) + AddressFilter: + opacity: 1 + size_hint: 1, None + height: self.minimum_height + spacing: '5dp' + AddressButton: + id: search + text: {0:_('All'), 1:_('Unused'), 2:_('Funded'), 3:_('Used')}[root.show_used] + on_release: + root.show_used = (root.show_used + 1) % 4 + Clock.schedule_once(lambda dt: root.update()) + AddressFilter: + opacity: 1 + size_hint: 1, None + height: self.minimum_height + spacing: '5dp' + canvas.before: + Color: + rgba: 0.9, 0.9, 0.9, 1 + AddressButton: + id: change + text: root.message if root.message else _('Search') + on_release: Clock.schedule_once(lambda dt: app.description_dialog(popup)) + RecycleView: + scroll_type: ['bars', 'content'] + bar_width: '15dp' + viewclass: 'AddressItem' + id: search_container + RecycleBoxLayout: + orientation: 'vertical' + default_size: None, dp(56) + default_size_hint: 1, None + size_hint_y: None + height: self.minimum_height +''') + + +from electrum.gui.kivy.i18n import _ +from electrum.gui.kivy.uix.context_menu import ContextMenu + + +class AddressesDialog(Factory.Popup): + + def __init__(self, app, screen, callback): + Factory.Popup.__init__(self) + self.app = app + self.screen = screen + self.callback = callback + self.context_menu = None + + def get_card(self, addr, balance, is_used, label): + ci = {} + ci['screen'] = self + ci['address'] = addr + ci['memo'] = label + ci['amount'] = self.app.format_amount_and_units(balance) + ci['status'] = _('Used') if is_used else _('Funded') if balance > 0 else _('Unused') + return ci + + def update(self): + self.menu_actions = [(_('Use'), self.do_use), (_('Details'), self.do_view)] + wallet = self.app.wallet + if self.show_change == 0: + _list = wallet.get_receiving_addresses() + elif self.show_change == 1: + _list = wallet.get_change_addresses() + else: + _list = wallet.get_addresses() + search = self.message + container = self.ids.search_container + n = 0 + cards = [] + for address in _list: + label = wallet.labels.get(address, '') + balance = sum(wallet.get_addr_balance(address)) + is_used = wallet.is_used(address) + if self.show_used == 1 and (balance or is_used): + continue + if self.show_used == 2 and balance == 0: + continue + if self.show_used == 3 and not is_used: + continue + card = self.get_card(address, balance, is_used, label) + if search and not self.ext_search(card, search): + continue + cards.append(card) + n += 1 + container.data = cards + if not n: + self.app.show_error('No address matching your search') + + def do_use(self, obj): + self.hide_menu() + self.dismiss() + self.app.show_request(obj.address) + + def do_view(self, obj): + req = { 'address': obj.address, 'status' : obj.status } + status = obj.status + c, u, x = self.app.wallet.get_addr_balance(obj.address) + balance = c + u + x + if balance > 0: + req['fund'] = balance + self.app.show_addr_details(req, status) + + def ext_search(self, card, search): + return card['memo'].find(search) >= 0 or card['amount'].find(search) >= 0 + + def show_menu(self, obj): + self.hide_menu() + self.context_menu = ContextMenu(obj, self.menu_actions) + self.ids.box.add_widget(self.context_menu) + + def hide_menu(self): + if self.context_menu is not None: + self.ids.box.remove_widget(self.context_menu) + self.context_menu = None DIR diff --git a/gui/kivy/uix/dialogs/amount_dialog.py b/electrum/gui/kivy/uix/dialogs/amount_dialog.py DIR diff --git a/electrum/gui/kivy/uix/dialogs/bump_fee_dialog.py b/electrum/gui/kivy/uix/dialogs/bump_fee_dialog.py t@@ -0,0 +1,118 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder + +from electrum.gui.kivy.i18n import _ + +Builder.load_string(''' +<BumpFeeDialog@Popup> + title: _('Bump fee') + size_hint: 0.8, 0.8 + pos_hint: {'top':0.9} + BoxLayout: + orientation: 'vertical' + padding: '10dp' + + GridLayout: + height: self.minimum_height + size_hint_y: None + cols: 1 + spacing: '10dp' + BoxLabel: + id: old_fee + text: _('Current Fee') + value: '' + BoxLabel: + id: new_fee + text: _('New Fee') + value: '' + Label: + id: tooltip1 + text: '' + size_hint_y: None + Label: + id: tooltip2 + text: '' + size_hint_y: None + Slider: + id: slider + range: 0, 4 + step: 1 + on_value: root.on_slider(self.value) + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.2 + Label: + text: _('Final') + CheckBox: + id: final_cb + Widget: + size_hint: 1, 1 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Button: + text: 'Cancel' + size_hint: 0.5, None + height: '48dp' + on_release: root.dismiss() + Button: + text: 'OK' + size_hint: 0.5, None + height: '48dp' + on_release: + root.dismiss() + root.on_ok() +''') + +class BumpFeeDialog(Factory.Popup): + + def __init__(self, app, fee, size, callback): + Factory.Popup.__init__(self) + self.app = app + self.init_fee = fee + self.tx_size = size + self.callback = callback + self.config = app.electrum_config + self.mempool = self.config.use_mempool_fees() + self.dynfees = self.config.is_dynfee() and bool(self.app.network) and self.config.has_dynamic_fees_ready() + self.ids.old_fee.value = self.app.format_amount_and_units(self.init_fee) + self.update_slider() + self.update_text() + + def update_text(self): + fee = self.get_fee() + self.ids.new_fee.value = self.app.format_amount_and_units(fee) + pos = int(self.ids.slider.value) + fee_rate = self.get_fee_rate() + text, tooltip = self.config.get_fee_text(pos, self.dynfees, self.mempool, fee_rate) + self.ids.tooltip1.text = text + self.ids.tooltip2.text = tooltip + + def update_slider(self): + slider = self.ids.slider + maxp, pos, fee_rate = self.config.get_fee_slider(self.dynfees, self.mempool) + slider.range = (0, maxp) + slider.step = 1 + slider.value = pos + + def get_fee_rate(self): + pos = int(self.ids.slider.value) + if self.dynfees: + fee_rate = self.config.depth_to_fee(pos) if self.mempool else self.config.eta_to_fee(pos) + else: + fee_rate = self.config.static_fee(pos) + return fee_rate + + def get_fee(self): + fee_rate = self.get_fee_rate() + return int(fee_rate * self.tx_size // 1000) + + def on_ok(self): + new_fee = self.get_fee() + is_final = self.ids.final_cb.active + self.callback(self.init_fee, new_fee, is_final) + + def on_slider(self, value): + self.update_text() DIR diff --git a/gui/kivy/uix/dialogs/checkbox_dialog.py b/electrum/gui/kivy/uix/dialogs/checkbox_dialog.py DIR diff --git a/gui/kivy/uix/dialogs/choice_dialog.py b/electrum/gui/kivy/uix/dialogs/choice_dialog.py DIR diff --git a/gui/kivy/uix/dialogs/crash_reporter.py b/electrum/gui/kivy/uix/dialogs/crash_reporter.py DIR diff --git a/electrum/gui/kivy/uix/dialogs/fee_dialog.py b/electrum/gui/kivy/uix/dialogs/fee_dialog.py t@@ -0,0 +1,131 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder + +from electrum.gui.kivy.i18n import _ + +Builder.load_string(''' +<FeeDialog@Popup> + id: popup + title: _('Transaction Fees') + size_hint: 0.8, 0.8 + pos_hint: {'top':0.9} + method: 0 + BoxLayout: + orientation: 'vertical' + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: _('Method') + ':' + Button: + text: _('Mempool') if root.method == 2 else _('ETA') if root.method == 1 else _('Static') + background_color: (0,0,0,0) + bold: True + on_release: + root.method = (root.method + 1) % 3 + root.update_slider() + root.update_text() + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: (_('Target') if root.method > 0 else _('Fee')) + ':' + Label: + id: fee_target + text: '' + Slider: + id: slider + range: 0, 4 + step: 1 + on_value: root.on_slider(self.value) + Widget: + size_hint: 1, 0.5 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + TopLabel: + id: fee_estimate + text: '' + font_size: '14dp' + Widget: + size_hint: 1, 0.5 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Button: + text: 'Cancel' + size_hint: 0.5, None + height: '48dp' + on_release: popup.dismiss() + Button: + text: 'OK' + size_hint: 0.5, None + height: '48dp' + on_release: + root.on_ok() + root.dismiss() +''') + +class FeeDialog(Factory.Popup): + + def __init__(self, app, config, callback): + Factory.Popup.__init__(self) + self.app = app + self.config = config + self.callback = callback + mempool = self.config.use_mempool_fees() + dynfees = self.config.is_dynfee() + self.method = (2 if mempool else 1) if dynfees else 0 + self.update_slider() + self.update_text() + + def update_text(self): + pos = int(self.ids.slider.value) + dynfees, mempool = self.get_method() + if self.method == 2: + fee_rate = self.config.depth_to_fee(pos) + target, estimate = self.config.get_fee_text(pos, dynfees, mempool, fee_rate) + msg = 'In the current network conditions, in order to be positioned %s, a transaction will require a fee of %s.' % (target, estimate) + elif self.method == 1: + fee_rate = self.config.eta_to_fee(pos) + target, estimate = self.config.get_fee_text(pos, dynfees, mempool, fee_rate) + msg = 'In the last few days, transactions that confirmed %s usually paid a fee of at least %s.' % (target.lower(), estimate) + else: + fee_rate = self.config.static_fee(pos) + target, estimate = self.config.get_fee_text(pos, dynfees, True, fee_rate) + msg = 'In the current network conditions, a transaction paying %s would be positioned %s.' % (target, estimate) + + self.ids.fee_target.text = target + self.ids.fee_estimate.text = msg + + def get_method(self): + dynfees = self.method > 0 + mempool = self.method == 2 + return dynfees, mempool + + def update_slider(self): + slider = self.ids.slider + dynfees, mempool = self.get_method() + maxp, pos, fee_rate = self.config.get_fee_slider(dynfees, mempool) + slider.range = (0, maxp) + slider.step = 1 + slider.value = pos + + def on_ok(self): + value = int(self.ids.slider.value) + dynfees, mempool = self.get_method() + self.config.set_key('dynamic_fees', dynfees, False) + self.config.set_key('mempool_fees', mempool, False) + if dynfees: + if mempool: + self.config.set_key('depth_level', value, True) + else: + self.config.set_key('fee_level', value, True) + else: + self.config.set_key('fee_per_kb', self.config.static_fee(value), True) + self.callback() + + def on_slider(self, value): + self.update_text() DIR diff --git a/electrum/gui/kivy/uix/dialogs/fx_dialog.py b/electrum/gui/kivy/uix/dialogs/fx_dialog.py t@@ -0,0 +1,111 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder + +Builder.load_string(''' +<FxDialog@Popup> + id: popup + title: 'Fiat Currency' + size_hint: 0.8, 0.8 + pos_hint: {'top':0.9} + BoxLayout: + orientation: 'vertical' + + Widget: + size_hint: 1, 0.1 + + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.1 + Label: + text: _('Currency') + height: '48dp' + Spinner: + height: '48dp' + id: ccy + on_text: popup.on_currency(self.text) + + Widget: + size_hint: 1, 0.1 + + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.1 + Label: + text: _('Source') + height: '48dp' + Spinner: + height: '48dp' + id: exchanges + on_text: popup.on_exchange(self.text) + + Widget: + size_hint: 1, 0.2 + + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.2 + Button: + text: 'Cancel' + size_hint: 0.5, None + height: '48dp' + on_release: popup.dismiss() + Button: + text: 'OK' + size_hint: 0.5, None + height: '48dp' + on_release: + root.callback() + popup.dismiss() +''') + + +from kivy.uix.label import Label +from kivy.uix.checkbox import CheckBox +from kivy.uix.widget import Widget +from kivy.clock import Clock + +from electrum.gui.kivy.i18n import _ +from functools import partial + +class FxDialog(Factory.Popup): + + def __init__(self, app, plugins, config, callback): + Factory.Popup.__init__(self) + self.app = app + self.config = config + self.callback = callback + self.fx = self.app.fx + self.fx.set_history_config(True) + self.add_currencies() + + def add_exchanges(self): + exchanges = sorted(self.fx.get_exchanges_by_ccy(self.fx.get_currency(), True)) if self.fx.is_enabled() else [] + mx = self.fx.exchange.name() if self.fx.is_enabled() else '' + ex = self.ids.exchanges + ex.values = exchanges + ex.text = (mx if mx in exchanges else exchanges[0]) if self.fx.is_enabled() else '' + + def on_exchange(self, text): + if not text: + return + if self.fx.is_enabled() and text != self.fx.exchange.name(): + self.fx.set_exchange(text) + + def add_currencies(self): + currencies = [_('None')] + self.fx.get_currencies(True) + my_ccy = self.fx.get_currency() if self.fx.is_enabled() else _('None') + self.ids.ccy.values = currencies + self.ids.ccy.text = my_ccy + + def on_currency(self, ccy): + b = (ccy != _('None')) + self.fx.set_enabled(b) + if b: + if ccy != self.fx.get_currency(): + self.fx.set_currency(ccy) + self.app.fiat_unit = ccy + else: + self.app.is_fiat = False + Clock.schedule_once(lambda dt: self.add_exchanges()) DIR diff --git a/electrum/gui/kivy/uix/dialogs/installwizard.py b/electrum/gui/kivy/uix/dialogs/installwizard.py t@@ -0,0 +1,1038 @@ + +from functools import partial +import threading +import os + +from kivy.app import App +from kivy.clock import Clock +from kivy.lang import Builder +from kivy.properties import ObjectProperty, StringProperty, OptionProperty +from kivy.core.window import Window +from kivy.uix.button import Button +from kivy.utils import platform +from kivy.uix.widget import Widget +from kivy.core.window import Window +from kivy.clock import Clock +from kivy.utils import platform + +from electrum.base_wizard import BaseWizard +from electrum.util import is_valid_email + + +from . import EventsDialog +from ...i18n import _ +from .password_dialog import PasswordDialog + +# global Variables +is_test = (platform == "linux") +test_seed = "time taxi field recycle tiny license olive virus report rare steel portion achieve" +test_seed = "grape impose jazz bind spatial mind jelly tourist tank today holiday stomach" +test_xpub = "xpub661MyMwAqRbcEbvVtRRSjqxVnaWVUMewVzMiURAKyYratih4TtBpMypzzefmv8zUNebmNVzB3PojdC5sV2P9bDgMoo9B3SARw1MXUUfU1GL" + +Builder.load_string(''' +#:import Window kivy.core.window.Window +#:import _ electrum.gui.kivy.i18n._ + + +<WizardTextInput@TextInput> + border: 4, 4, 4, 4 + font_size: '15sp' + padding: '15dp', '15dp' + background_color: (1, 1, 1, 1) if self.focus else (0.454, 0.698, 0.909, 1) + foreground_color: (0.31, 0.31, 0.31, 1) if self.focus else (0.835, 0.909, 0.972, 1) + hint_text_color: self.foreground_color + background_active: 'atlas://electrum/gui/kivy/theming/light/create_act_text_active' + background_normal: 'atlas://electrum/gui/kivy/theming/light/create_act_text_active' + size_hint_y: None + height: '48sp' + +<WizardButton@Button>: + root: None + size_hint: 1, None + height: '48sp' + on_press: if self.root: self.root.dispatch('on_press', self) + on_release: if self.root: self.root.dispatch('on_release', self) + +<BigLabel@Label> + color: .854, .925, .984, 1 + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + bold: True + +<-WizardDialog> + text_color: .854, .925, .984, 1 + value: '' + #auto_dismiss: False + size_hint: None, None + canvas.before: + Color: + rgba: .239, .588, .882, 1 + Rectangle: + size: Window.size + + crcontent: crcontent + # add electrum icon + BoxLayout: + orientation: 'vertical' if self.width < self.height else 'horizontal' + padding: + min(dp(27), self.width/32), min(dp(27), self.height/32),\ + min(dp(27), self.width/32), min(dp(27), self.height/32) + spacing: '10dp' + GridLayout: + id: grid_logo + cols: 1 + pos_hint: {'center_y': .5} + size_hint: 1, None + height: self.minimum_height + Label: + color: root.text_color + text: 'ELECTRUM' + size_hint: 1, None + height: self.texture_size[1] if self.opacity else 0 + font_size: '33sp' + font_name: 'electrum/gui/kivy/data/fonts/tron/Tr2n.ttf' + GridLayout: + cols: 1 + id: crcontent + spacing: '1dp' + Widget: + size_hint: 1, 0.3 + GridLayout: + rows: 1 + spacing: '12dp' + size_hint: 1, None + height: self.minimum_height + WizardButton: + id: back + text: _('Back') + root: root + WizardButton: + id: next + text: _('Next') + root: root + disabled: root.value == '' + + +<WizardMultisigDialog> + value: 'next' + Widget + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: _("Choose the number of signatures needed to unlock funds in your wallet") + Widget + size_hint: 1, 1 + GridLayout: + orientation: 'vertical' + cols: 2 + spacing: '14dp' + size_hint: 1, 1 + height: self.minimum_height + Label: + color: root.text_color + text: _('From {} cosigners').format(n.value) + Slider: + id: n + range: 2, 5 + step: 1 + value: 2 + Label: + color: root.text_color + text: _('Require {} signatures').format(m.value) + Slider: + id: m + range: 1, n.value + step: 1 + value: 2 + + +<WizardChoiceDialog> + message : '' + Widget: + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message + Widget + size_hint: 1, 1 + GridLayout: + row_default_height: '48dp' + orientation: 'vertical' + id: choices + cols: 1 + spacing: '14dp' + size_hint: 1, None + +<WizardConfirmDialog> + message : '' + Widget: + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message + Widget + size_hint: 1, 1 + +<WizardTOSDialog> + message : '' + size_hint: 1, 1 + ScrollView: + size_hint: 1, 1 + TextInput: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.minimum_height + text: root.message + disabled: True + +<WizardEmailDialog> + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: 'Please enter your email address' + WizardTextInput: + id: email + on_text: Clock.schedule_once(root.on_text) + multiline: False + on_text_validate: Clock.schedule_once(root.on_enter) + +<WizardKnownOTPDialog> + message : '' + message2: '' + Widget: + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message + Widget + size_hint: 1, 1 + WizardTextInput: + id: otp + on_text: Clock.schedule_once(root.on_text) + multiline: False + on_text_validate: Clock.schedule_once(root.on_enter) + Widget + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message2 + Widget + size_hint: 1, 1 + height: '48sp' + BoxLayout: + orientation: 'horizontal' + WizardButton: + id: cb + text: _('Request new secret') + on_release: root.request_new_secret() + size_hint: 1, None + WizardButton: + id: abort + text: _('Abort creation') + on_release: root.abort_wallet_creation() + size_hint: 1, None + + +<WizardNewOTPDialog> + message : '' + message2 : '' + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message + QRCodeWidget: + id: qr + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message2 + WizardTextInput: + id: otp + on_text: Clock.schedule_once(root.on_text) + multiline: False + on_text_validate: Clock.schedule_once(root.on_enter) + +<MButton@Button>: + size_hint: 1, None + height: '33dp' + on_release: + self.parent.update_amount(self.text) + +<WordButton@Button>: + size_hint: None, None + padding: '5dp', '5dp' + text_size: None, self.height + width: self.texture_size[0] + height: '30dp' + on_release: + self.parent.new_word(self.text) + + +<SeedButton@Button>: + height: dp(100) + border: 4, 4, 4, 4 + halign: 'justify' + valign: 'top' + font_size: '18dp' + text_size: self.width - dp(24), self.height - dp(12) + color: .1, .1, .1, 1 + background_normal: 'atlas://electrum/gui/kivy/theming/light/white_bg_round_top' + background_down: self.background_normal + size_hint_y: None + + +<SeedLabel@Label>: + font_size: '12sp' + text_size: self.width, None + size_hint: 1, None + height: self.texture_size[1] + halign: 'justify' + valign: 'middle' + border: 4, 4, 4, 4 + + +<RestoreSeedDialog> + message: '' + word: '' + BigLabel: + text: "ENTER YOUR SEED PHRASE" + GridLayout + cols: 1 + padding: 0, '12dp' + orientation: 'vertical' + spacing: '12dp' + size_hint: 1, None + height: self.minimum_height + SeedButton: + id: text_input_seed + text: '' + on_text: Clock.schedule_once(root.on_text) + on_release: root.options_dialog() + SeedLabel: + text: root.message + BoxLayout: + id: suggestions + height: '35dp' + size_hint: 1, None + new_word: root.on_word + BoxLayout: + id: line1 + update_amount: root.update_text + size_hint: 1, None + height: '30dp' + MButton: + text: 'Q' + MButton: + text: 'W' + MButton: + text: 'E' + MButton: + text: 'R' + MButton: + text: 'T' + MButton: + text: 'Y' + MButton: + text: 'U' + MButton: + text: 'I' + MButton: + text: 'O' + MButton: + text: 'P' + BoxLayout: + id: line2 + update_amount: root.update_text + size_hint: 1, None + height: '30dp' + Widget: + size_hint: 0.5, None + height: '33dp' + MButton: + text: 'A' + MButton: + text: 'S' + MButton: + text: 'D' + MButton: + text: 'F' + MButton: + text: 'G' + MButton: + text: 'H' + MButton: + text: 'J' + MButton: + text: 'K' + MButton: + text: 'L' + Widget: + size_hint: 0.5, None + height: '33dp' + BoxLayout: + id: line3 + update_amount: root.update_text + size_hint: 1, None + height: '30dp' + Widget: + size_hint: 1, None + MButton: + text: 'Z' + MButton: + text: 'X' + MButton: + text: 'C' + MButton: + text: 'V' + MButton: + text: 'B' + MButton: + text: 'N' + MButton: + text: 'M' + MButton: + text: ' ' + MButton: + text: '<' + +<AddXpubDialog> + title: '' + message: '' + BigLabel: + text: root.title + GridLayout + cols: 1 + padding: 0, '12dp' + orientation: 'vertical' + spacing: '12dp' + size_hint: 1, None + height: self.minimum_height + SeedButton: + id: text_input + text: '' + on_text: Clock.schedule_once(root.check_text) + SeedLabel: + text: root.message + GridLayout + rows: 1 + spacing: '12dp' + size_hint: 1, None + height: self.minimum_height + IconButton: + id: scan + height: '48sp' + on_release: root.scan_xpub() + icon: 'atlas://electrum/gui/kivy/theming/light/camera' + size_hint: 1, None + WizardButton: + text: _('Paste') + on_release: root.do_paste() + WizardButton: + text: _('Clear') + on_release: root.do_clear() + + +<ShowXpubDialog> + xpub: '' + message: _('Here is your master public key. Share it with your cosigners.') + BigLabel: + text: "MASTER PUBLIC KEY" + GridLayout + cols: 1 + padding: 0, '12dp' + orientation: 'vertical' + spacing: '12dp' + size_hint: 1, None + height: self.minimum_height + SeedButton: + id: text_input + text: root.xpub + SeedLabel: + text: root.message + GridLayout + rows: 1 + spacing: '12dp' + size_hint: 1, None + height: self.minimum_height + WizardButton: + text: _('QR code') + on_release: root.do_qr() + WizardButton: + text: _('Copy') + on_release: root.do_copy() + WizardButton: + text: _('Share') + on_release: root.do_share() + + +<ShowSeedDialog> + spacing: '12dp' + value: 'next' + BigLabel: + text: "PLEASE WRITE DOWN YOUR SEED PHRASE" + GridLayout: + id: grid + cols: 1 + pos_hint: {'center_y': .5} + size_hint_y: None + height: self.minimum_height + orientation: 'vertical' + spacing: '12dp' + SeedButton: + text: root.seed_text + on_release: root.options_dialog() + SeedLabel: + text: root.message + + +<LineDialog> + + BigLabel: + text: root.title + SeedLabel: + text: root.message + TextInput: + id: passphrase_input + multiline: False + size_hint: 1, None + height: '27dp' + SeedLabel: + text: root.warning + +''') + + + +class WizardDialog(EventsDialog): + ''' Abstract dialog to be used as the base for all Create Account Dialogs + ''' + crcontent = ObjectProperty(None) + + def __init__(self, wizard, **kwargs): + super(WizardDialog, self).__init__() + self.wizard = wizard + self.ids.back.disabled = not wizard.can_go_back() + self.app = App.get_running_app() + self.run_next = kwargs['run_next'] + _trigger_size_dialog = Clock.create_trigger(self._size_dialog) + Window.bind(size=_trigger_size_dialog, + rotation=_trigger_size_dialog) + _trigger_size_dialog() + self._on_release = False + + def _size_dialog(self, dt): + app = App.get_running_app() + if app.ui_mode[0] == 'p': + self.size = Window.size + else: + #tablet + if app.orientation[0] == 'p': + #portrait + self.size = Window.size[0]/1.67, Window.size[1]/1.4 + else: + self.size = Window.size[0]/2.5, Window.size[1] + + def add_widget(self, widget, index=0): + if not self.crcontent: + super(WizardDialog, self).add_widget(widget) + else: + self.crcontent.add_widget(widget, index=index) + + def on_dismiss(self): + app = App.get_running_app() + if app.wallet is None and not self._on_release: + app.stop() + + def get_params(self, button): + return (None,) + + def on_release(self, button): + self._on_release = True + self.close() + if not button: + self.parent.dispatch('on_wizard_complete', None) + return + if button is self.ids.back: + self.wizard.go_back() + return + params = self.get_params(button) + self.run_next(*params) + + +class WizardMultisigDialog(WizardDialog): + + def get_params(self, button): + m = self.ids.m.value + n = self.ids.n.value + return m, n + + +class WizardOTPDialogBase(WizardDialog): + + def get_otp(self): + otp = self.ids.otp.text + if len(otp) != 6: + return + try: + return int(otp) + except: + return + + def on_text(self, dt): + self.ids.next.disabled = self.get_otp() is None + + def on_enter(self, dt): + # press next + next = self.ids.next + if not next.disabled: + next.dispatch('on_release') + + +class WizardKnownOTPDialog(WizardOTPDialogBase): + + def __init__(self, wizard, **kwargs): + WizardOTPDialogBase.__init__(self, wizard, **kwargs) + self.message = _("This wallet is already registered with TrustedCoin. To finalize wallet creation, please enter your Google Authenticator Code.") + self.message2 =_("If you have lost your Google Authenticator account, you can request a new secret. You will need to retype your seed.") + self.request_new = False + + def get_params(self, button): + return (self.get_otp(), self.request_new) + + def request_new_secret(self): + self.request_new = True + self.on_release(True) + + def abort_wallet_creation(self): + self._on_release = True + os.unlink(self.wizard.storage.path) + self.wizard.terminate() + self.dismiss() + + +class WizardNewOTPDialog(WizardOTPDialogBase): + + def __init__(self, wizard, **kwargs): + WizardOTPDialogBase.__init__(self, wizard, **kwargs) + otp_secret = kwargs['otp_secret'] + uri = "otpauth://totp/%s?secret=%s"%('trustedcoin.com', otp_secret) + self.message = "Please scan the following QR code in Google Authenticator. You may also use the secret key: %s"%otp_secret + self.message2 = _('Then, enter your Google Authenticator code:') + self.ids.qr.set_data(uri) + + def get_params(self, button): + return (self.get_otp(), False) + +class WizardTOSDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + WizardDialog.__init__(self, wizard, **kwargs) + self.ids.next.text = 'Accept' + self.ids.next.disabled = False + self.message = kwargs['tos'] + self.message2 = _('Enter your email address:') + +class WizardEmailDialog(WizardDialog): + + def get_params(self, button): + return (self.ids.email.text,) + + def on_text(self, dt): + self.ids.next.disabled = not is_valid_email(self.ids.email.text) + + def on_enter(self, dt): + # press next + next = self.ids.next + if not next.disabled: + next.dispatch('on_release') + +class WizardConfirmDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + super(WizardConfirmDialog, self).__init__(wizard, **kwargs) + self.message = kwargs.get('message', '') + self.value = 'ok' + + def on_parent(self, instance, value): + if value: + app = App.get_running_app() + self._back = _back = partial(app.dispatch, 'on_back') + + def get_params(self, button): + return (True,) + +class WizardChoiceDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + super(WizardChoiceDialog, self).__init__(wizard, **kwargs) + self.message = kwargs.get('message', '') + choices = kwargs.get('choices', []) + layout = self.ids.choices + layout.bind(minimum_height=layout.setter('height')) + for action, text in choices: + l = WizardButton(text=text) + l.action = action + l.height = '48dp' + l.root = self + layout.add_widget(l) + + def on_parent(self, instance, value): + if value: + app = App.get_running_app() + self._back = _back = partial(app.dispatch, 'on_back') + + def get_params(self, button): + return (button.action,) + + + +class LineDialog(WizardDialog): + title = StringProperty('') + message = StringProperty('') + warning = StringProperty('') + + def __init__(self, wizard, **kwargs): + WizardDialog.__init__(self, wizard, **kwargs) + self.ids.next.disabled = False + + def get_params(self, b): + return (self.ids.passphrase_input.text,) + +class ShowSeedDialog(WizardDialog): + seed_text = StringProperty('') + message = _("If you forget your PIN or lose your device, your seed phrase will be the only way to recover your funds.") + ext = False + + def __init__(self, wizard, **kwargs): + super(ShowSeedDialog, self).__init__(wizard, **kwargs) + self.seed_text = kwargs['seed_text'] + + def on_parent(self, instance, value): + if value: + app = App.get_running_app() + self._back = _back = partial(self.ids.back.dispatch, 'on_release') + + def options_dialog(self): + from .seed_options import SeedOptionsDialog + def callback(status): + self.ext = status + d = SeedOptionsDialog(self.ext, callback) + d.open() + + def get_params(self, b): + return (self.ext,) + + +class WordButton(Button): + pass + +class WizardButton(Button): + pass + + +class RestoreSeedDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + super(RestoreSeedDialog, self).__init__(wizard, **kwargs) + self._test = kwargs['test'] + from electrum.mnemonic import Mnemonic + from electrum.old_mnemonic import words as old_wordlist + self.words = set(Mnemonic('en').wordlist).union(set(old_wordlist)) + self.ids.text_input_seed.text = test_seed if is_test else '' + self.message = _('Please type your seed phrase using the virtual keyboard.') + self.title = _('Enter Seed') + self.ext = False + + def options_dialog(self): + from .seed_options import SeedOptionsDialog + def callback(status): + self.ext = status + d = SeedOptionsDialog(self.ext, callback) + d.open() + + def get_suggestions(self, prefix): + for w in self.words: + if w.startswith(prefix): + yield w + + def on_text(self, dt): + self.ids.next.disabled = not bool(self._test(self.get_text())) + + text = self.ids.text_input_seed.text + if not text: + last_word = '' + elif text[-1] == ' ': + last_word = '' + else: + last_word = text.split(' ')[-1] + + enable_space = False + self.ids.suggestions.clear_widgets() + suggestions = [x for x in self.get_suggestions(last_word)] + + if last_word in suggestions: + b = WordButton(text=last_word) + self.ids.suggestions.add_widget(b) + enable_space = True + + for w in suggestions: + if w != last_word and len(suggestions) < 10: + b = WordButton(text=w) + self.ids.suggestions.add_widget(b) + + i = len(last_word) + p = set() + for x in suggestions: + if len(x)>i: p.add(x[i]) + + for line in [self.ids.line1, self.ids.line2, self.ids.line3]: + for c in line.children: + if isinstance(c, Button): + if c.text in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': + c.disabled = (c.text.lower() not in p) and bool(last_word) + elif c.text == ' ': + c.disabled = not enable_space + + def on_word(self, w): + text = self.get_text() + words = text.split(' ') + words[-1] = w + text = ' '.join(words) + self.ids.text_input_seed.text = text + ' ' + self.ids.suggestions.clear_widgets() + + def get_text(self): + ti = self.ids.text_input_seed + return ' '.join(ti.text.strip().split()) + + def update_text(self, c): + c = c.lower() + text = self.ids.text_input_seed.text + if c == '<': + text = text[:-1] + else: + text += c + self.ids.text_input_seed.text = text + + def on_parent(self, instance, value): + if value: + tis = self.ids.text_input_seed + tis.focus = True + #tis._keyboard.bind(on_key_down=self.on_key_down) + self._back = _back = partial(self.ids.back.dispatch, + 'on_release') + app = App.get_running_app() + + def on_key_down(self, keyboard, keycode, key, modifiers): + if keycode[0] in (13, 271): + self.on_enter() + return True + + def on_enter(self): + #self._remove_keyboard() + # press next + next = self.ids.next + if not next.disabled: + next.dispatch('on_release') + + def _remove_keyboard(self): + tis = self.ids.text_input_seed + if tis._keyboard: + tis._keyboard.unbind(on_key_down=self.on_key_down) + tis.focus = False + + def get_params(self, b): + return (self.get_text(), False, self.ext) + + +class ConfirmSeedDialog(RestoreSeedDialog): + def get_params(self, b): + return (self.get_text(),) + def options_dialog(self): + pass + + +class ShowXpubDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + WizardDialog.__init__(self, wizard, **kwargs) + self.xpub = kwargs['xpub'] + self.ids.next.disabled = False + + def do_copy(self): + self.app._clipboard.copy(self.xpub) + + def do_share(self): + self.app.do_share(self.xpub, _("Master Public Key")) + + def do_qr(self): + from .qr_dialog import QRDialog + popup = QRDialog(_("Master Public Key"), self.xpub, True) + popup.open() + + +class AddXpubDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + WizardDialog.__init__(self, wizard, **kwargs) + self.is_valid = kwargs['is_valid'] + self.title = kwargs['title'] + self.message = kwargs['message'] + self.allow_multi = kwargs.get('allow_multi', False) + + def check_text(self, dt): + self.ids.next.disabled = not bool(self.is_valid(self.get_text())) + + def get_text(self): + ti = self.ids.text_input + return ti.text.strip() + + def get_params(self, button): + return (self.get_text(),) + + def scan_xpub(self): + def on_complete(text): + if self.allow_multi: + self.ids.text_input.text += text + '\n' + else: + self.ids.text_input.text = text + self.app.scan_qr(on_complete) + + def do_paste(self): + self.ids.text_input.text = test_xpub if is_test else self.app._clipboard.paste() + + def do_clear(self): + self.ids.text_input.text = '' + + + + +class InstallWizard(BaseWizard, Widget): + ''' + events:: + `on_wizard_complete` Fired when the wizard is done creating/ restoring + wallet/s. + ''' + + __events__ = ('on_wizard_complete', ) + + def on_wizard_complete(self, wallet): + """overriden by main_window""" + pass + + def waiting_dialog(self, task, msg, on_finished=None): + '''Perform a blocking task in the background by running the passed + method in a thread. + ''' + def target(): + # run your threaded function + try: + task() + except Exception as err: + self.show_error(str(err)) + # on completion hide message + Clock.schedule_once(lambda dt: app.info_bubble.hide(now=True), -1) + if on_finished: + Clock.schedule_once(lambda dt: on_finished(), -1) + + app = App.get_running_app() + app.show_info_bubble( + text=msg, icon='atlas://electrum/gui/kivy/theming/light/important', + pos=Window.center, width='200sp', arrow_pos=None, modal=True) + t = threading.Thread(target = target) + t.start() + + def terminate(self, **kwargs): + self.dispatch('on_wizard_complete', self.wallet) + + def choice_dialog(self, **kwargs): + choices = kwargs['choices'] + if len(choices) > 1: + WizardChoiceDialog(self, **kwargs).open() + else: + f = kwargs['run_next'] + f(choices[0][0]) + + def multisig_dialog(self, **kwargs): WizardMultisigDialog(self, **kwargs).open() + def show_seed_dialog(self, **kwargs): ShowSeedDialog(self, **kwargs).open() + def line_dialog(self, **kwargs): LineDialog(self, **kwargs).open() + + def confirm_seed_dialog(self, **kwargs): + kwargs['title'] = _('Confirm Seed') + kwargs['message'] = _('Please retype your seed phrase, to confirm that you properly saved it') + ConfirmSeedDialog(self, **kwargs).open() + + def restore_seed_dialog(self, **kwargs): + RestoreSeedDialog(self, **kwargs).open() + + def confirm_dialog(self, **kwargs): + WizardConfirmDialog(self, **kwargs).open() + + def tos_dialog(self, **kwargs): + WizardTOSDialog(self, **kwargs).open() + + def email_dialog(self, **kwargs): + WizardEmailDialog(self, **kwargs).open() + + def otp_dialog(self, **kwargs): + if kwargs['otp_secret']: + WizardNewOTPDialog(self, **kwargs).open() + else: + WizardKnownOTPDialog(self, **kwargs).open() + + def add_xpub_dialog(self, **kwargs): + kwargs['message'] += ' ' + _('Use the camera button to scan a QR code.') + AddXpubDialog(self, **kwargs).open() + + def add_cosigner_dialog(self, **kwargs): + kwargs['title'] = _("Add Cosigner") + " %d"%kwargs['index'] + kwargs['message'] = _('Please paste your cosigners master public key, or scan it using the camera button.') + AddXpubDialog(self, **kwargs).open() + + def show_xpub_dialog(self, **kwargs): ShowXpubDialog(self, **kwargs).open() + + def show_message(self, msg): self.show_error(msg) + + def show_error(self, msg): + app = App.get_running_app() + Clock.schedule_once(lambda dt: app.show_error(msg)) + + def request_password(self, run_next, force_disable_encrypt_cb=False): + def on_success(old_pin, pin): + assert old_pin is None + run_next(pin, False) + def on_failure(): + self.show_error(_('PIN mismatch')) + self.run('request_password', run_next) + popup = PasswordDialog() + app = App.get_running_app() + popup.init(app, None, _('Choose PIN code'), on_success, on_failure, is_change=2) + popup.open() + + def action_dialog(self, action, run_next): + f = getattr(self, action) + f() DIR diff --git a/electrum/gui/kivy/uix/dialogs/invoices.py b/electrum/gui/kivy/uix/dialogs/invoices.py t@@ -0,0 +1,169 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder +from decimal import Decimal + +Builder.load_string(''' +<InvoicesLabel@Label> + #color: .305, .309, .309, 1 + text_size: self.width, None + halign: 'left' + valign: 'top' + +<InvoiceItem@CardItem> + requestor: '' + memo: '' + amount: '' + status: '' + date: '' + icon: 'atlas://electrum/gui/kivy/theming/light/important' + Image: + id: icon + source: root.icon + size_hint: None, 1 + width: self.height *.54 + mipmap: True + BoxLayout: + spacing: '8dp' + height: '32dp' + orientation: 'vertical' + Widget + InvoicesLabel: + text: root.requestor + shorten: True + Widget + InvoicesLabel: + text: root.memo + color: .699, .699, .699, 1 + font_size: '13sp' + shorten: True + Widget + BoxLayout: + spacing: '8dp' + height: '32dp' + orientation: 'vertical' + Widget + InvoicesLabel: + text: root.amount + font_size: '15sp' + halign: 'right' + width: '110sp' + Widget + InvoicesLabel: + text: root.status + font_size: '13sp' + halign: 'right' + color: .699, .699, .699, 1 + Widget + + +<InvoicesDialog@Popup> + id: popup + title: _('Invoices') + BoxLayout: + id: box + orientation: 'vertical' + spacing: '1dp' + ScrollView: + GridLayout: + cols: 1 + id: invoices_container + size_hint: 1, None + height: self.minimum_height + spacing: '2dp' + padding: '12dp' +''') + +from kivy.properties import BooleanProperty +from electrum.gui.kivy.i18n import _ +from electrum.util import format_time +from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_UNKNOWN, PR_EXPIRED +from electrum.gui.kivy.uix.context_menu import ContextMenu + +invoice_text = { + PR_UNPAID:_('Pending'), + PR_UNKNOWN:_('Unknown'), + PR_PAID:_('Paid'), + PR_EXPIRED:_('Expired') +} +pr_icon = { + PR_UNPAID: 'atlas://electrum/gui/kivy/theming/light/important', + PR_UNKNOWN: 'atlas://electrum/gui/kivy/theming/light/important', + PR_PAID: 'atlas://electrum/gui/kivy/theming/light/confirmed', + PR_EXPIRED: 'atlas://electrum/gui/kivy/theming/light/close' +} + + +class InvoicesDialog(Factory.Popup): + + def __init__(self, app, screen, callback): + Factory.Popup.__init__(self) + self.app = app + self.screen = screen + self.callback = callback + self.cards = {} + self.context_menu = None + + def get_card(self, pr): + key = pr.get_id() + ci = self.cards.get(key) + if ci is None: + ci = Factory.InvoiceItem() + ci.key = key + ci.screen = self + self.cards[key] = ci + ci.requestor = pr.get_requestor() + ci.memo = pr.get_memo() + amount = pr.get_amount() + if amount: + ci.amount = self.app.format_amount_and_units(amount) + status = self.app.wallet.invoices.get_status(ci.key) + ci.status = invoice_text[status] + ci.icon = pr_icon[status] + else: + ci.amount = _('No Amount') + ci.status = '' + exp = pr.get_expiration_date() + ci.date = format_time(exp) if exp else _('Never') + return ci + + def update(self): + self.menu_actions = [('Pay', self.do_pay), ('Details', self.do_view), ('Delete', self.do_delete)] + invoices_list = self.ids.invoices_container + invoices_list.clear_widgets() + _list = self.app.wallet.invoices.sorted_list() + for pr in _list: + ci = self.get_card(pr) + invoices_list.add_widget(ci) + + def do_pay(self, obj): + self.hide_menu() + self.dismiss() + pr = self.app.wallet.invoices.get(obj.key) + self.app.on_pr(pr) + + def do_view(self, obj): + pr = self.app.wallet.invoices.get(obj.key) + pr.verify(self.app.wallet.contacts) + self.app.show_pr_details(pr.get_dict(), obj.status, True) + + def do_delete(self, obj): + from .question import Question + def cb(result): + if result: + self.app.wallet.invoices.remove(obj.key) + self.hide_menu() + self.update() + d = Question(_('Delete invoice?'), cb) + d.open() + + def show_menu(self, obj): + self.hide_menu() + self.context_menu = ContextMenu(obj, self.menu_actions) + self.ids.box.add_widget(self.context_menu) + + def hide_menu(self): + if self.context_menu is not None: + self.ids.box.remove_widget(self.context_menu) + self.context_menu = None DIR diff --git a/electrum/gui/kivy/uix/dialogs/label_dialog.py b/electrum/gui/kivy/uix/dialogs/label_dialog.py t@@ -0,0 +1,55 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder + +Builder.load_string(''' +<LabelDialog@Popup> + id: popup + title: '' + size_hint: 0.8, 0.3 + pos_hint: {'top':0.9} + BoxLayout: + orientation: 'vertical' + Widget: + size_hint: 1, 0.2 + TextInput: + id:input + padding: '5dp' + size_hint: 1, None + height: '27dp' + pos_hint: {'center_y':.5} + text:'' + multiline: False + background_normal: 'atlas://electrum/gui/kivy/theming/light/tab_btn' + background_active: 'atlas://electrum/gui/kivy/theming/light/textinput_active' + hint_text_color: self.foreground_color + foreground_color: 1, 1, 1, 1 + font_size: '16dp' + focus: True + Widget: + size_hint: 1, 0.2 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Button: + text: 'Cancel' + size_hint: 0.5, None + height: '48dp' + on_release: popup.dismiss() + Button: + text: 'OK' + size_hint: 0.5, None + height: '48dp' + on_release: + root.callback(input.text) + popup.dismiss() +''') + +class LabelDialog(Factory.Popup): + + def __init__(self, title, text, callback): + Factory.Popup.__init__(self) + self.ids.input.text = text + self.callback = callback + self.title = title DIR diff --git a/electrum/gui/kivy/uix/dialogs/nfc_transaction.py b/electrum/gui/kivy/uix/dialogs/nfc_transaction.py t@@ -0,0 +1,32 @@ +class NFCTransactionDialog(AnimatedPopup): + + mode = OptionProperty('send', options=('send','receive')) + + scanner = ObjectProperty(None) + + def __init__(self, **kwargs): + # Delayed Init + global NFCSCanner + if NFCSCanner is None: + from electrum.gui.kivy.nfc_scanner import NFCScanner + self.scanner = NFCSCanner + + super(NFCTransactionDialog, self).__init__(**kwargs) + self.scanner.nfc_init() + self.scanner.bind() + + def on_parent(self, instance, value): + sctr = self.ids.sctr + if value: + def _cmp(*l): + anim = Animation(rotation=2, scale=1, opacity=1) + anim.start(sctr) + anim.bind(on_complete=_start) + + def _start(*l): + anim = Animation(rotation=350, scale=2, opacity=0) + anim.start(sctr) + anim.bind(on_complete=_cmp) + _start() + return + Animation.cancel_all(sctr) +\ No newline at end of file DIR diff --git a/electrum/gui/kivy/uix/dialogs/password_dialog.py b/electrum/gui/kivy/uix/dialogs/password_dialog.py t@@ -0,0 +1,142 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder +from decimal import Decimal +from kivy.clock import Clock + +from electrum.util import InvalidPassword +from electrum.gui.kivy.i18n import _ + +Builder.load_string(''' + +<PasswordDialog@Popup> + id: popup + title: 'Electrum' + message: '' + BoxLayout: + size_hint: 1, 1 + orientation: 'vertical' + Widget: + size_hint: 1, 0.05 + Label: + font_size: '20dp' + text: root.message + text_size: self.width, None + size: self.texture_size + Widget: + size_hint: 1, 0.05 + Label: + id: a + font_size: '50dp' + text: '*'*len(kb.password) + '-'*(6-len(kb.password)) + size: self.texture_size + Widget: + size_hint: 1, 0.05 + GridLayout: + id: kb + size_hint: 1, None + height: self.minimum_height + update_amount: popup.update_password + password: '' + on_password: popup.on_password(self.password) + spacing: '2dp' + cols: 3 + KButton: + text: '1' + KButton: + text: '2' + KButton: + text: '3' + KButton: + text: '4' + KButton: + text: '5' + KButton: + text: '6' + KButton: + text: '7' + KButton: + text: '8' + KButton: + text: '9' + KButton: + text: 'Clear' + KButton: + text: '0' + KButton: + text: '<' +''') + + +class PasswordDialog(Factory.Popup): + + def init(self, app, wallet, message, on_success, on_failure, is_change=0): + self.app = app + self.wallet = wallet + self.message = message + self.on_success = on_success + self.on_failure = on_failure + self.ids.kb.password = '' + self.success = False + self.is_change = is_change + self.pw = None + self.new_password = None + self.title = 'Electrum' + (' - ' + self.wallet.basename() if self.wallet else '') + + def check_password(self, password): + if self.is_change > 1: + return True + try: + self.wallet.check_password(password) + return True + except InvalidPassword as e: + return False + + def on_dismiss(self): + if not self.success: + if self.on_failure: + self.on_failure() + else: + # keep dialog open + return True + else: + if self.on_success: + args = (self.pw, self.new_password) if self.is_change else (self.pw,) + Clock.schedule_once(lambda dt: self.on_success(*args), 0.1) + + def update_password(self, c): + kb = self.ids.kb + text = kb.password + if c == '<': + text = text[:-1] + elif c == 'Clear': + text = '' + else: + text += c + kb.password = text + + def on_password(self, pw): + if len(pw) == 6: + if self.check_password(pw): + if self.is_change == 0: + self.success = True + self.pw = pw + self.message = _('Please wait...') + self.dismiss() + elif self.is_change == 1: + self.pw = pw + self.message = _('Enter new PIN') + self.ids.kb.password = '' + self.is_change = 2 + elif self.is_change == 2: + self.new_password = pw + self.message = _('Confirm new PIN') + self.ids.kb.password = '' + self.is_change = 3 + elif self.is_change == 3: + self.success = pw == self.new_password + self.dismiss() + else: + self.app.show_error(_('Wrong PIN')) + self.ids.kb.password = '' DIR diff --git a/gui/kivy/uix/dialogs/qr_dialog.py b/electrum/gui/kivy/uix/dialogs/qr_dialog.py DIR diff --git a/electrum/gui/kivy/uix/dialogs/qr_scanner.py b/electrum/gui/kivy/uix/dialogs/qr_scanner.py t@@ -0,0 +1,44 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.lang import Builder + +Factory.register('QRScanner', module='electrum.gui.kivy.qr_scanner') + +class QrScannerDialog(Factory.AnimatedPopup): + + __events__ = ('on_complete', ) + + def on_symbols(self, instance, value): + instance.stop() + self.dismiss() + data = value[0].data + self.dispatch('on_complete', data) + + def on_complete(self, x): + ''' Default Handler for on_complete event. + ''' + print(x) + + +Builder.load_string(''' +<QrScannerDialog> + title: + _(\ + '[size=18dp]Hold your QRCode up to the camera[/size][size=7dp]\\n[/size]') + title_size: '24sp' + border: 7, 7, 7, 7 + size_hint: None, None + size: '340dp', '290dp' + pos_hint: {'center_y': .53} + #separator_color: .89, .89, .89, 1 + #separator_height: '1.2dp' + #title_color: .437, .437, .437, 1 + #background: 'atlas://electrum/gui/kivy/theming/light/dialog' + on_activate: + qrscr.start() + qrscr.size = self.size + on_deactivate: qrscr.stop() + QRScanner: + id: qrscr + on_symbols: root.on_symbols(*args) +''') DIR diff --git a/electrum/gui/kivy/uix/dialogs/question.py b/electrum/gui/kivy/uix/dialogs/question.py t@@ -0,0 +1,53 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder +from kivy.uix.checkbox import CheckBox +from kivy.uix.label import Label +from kivy.uix.widget import Widget + +from electrum.gui.kivy.i18n import _ + +Builder.load_string(''' +<Question@Popup> + id: popup + title: '' + message: '' + size_hint: 0.8, 0.5 + pos_hint: {'top':0.9} + BoxLayout: + orientation: 'vertical' + Label: + id: label + text: root.message + text_size: self.width, None + Widget: + size_hint: 1, 0.1 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.2 + Button: + text: _('No') + size_hint: 0.5, None + height: '48dp' + on_release: + root.callback(False) + popup.dismiss() + Button: + text: _('Yes') + size_hint: 0.5, None + height: '48dp' + on_release: + root.callback(True) + popup.dismiss() +''') + + + +class Question(Factory.Popup): + + def __init__(self, msg, callback): + Factory.Popup.__init__(self) + self.title = _('Question') + self.message = msg + self.callback = callback DIR diff --git a/electrum/gui/kivy/uix/dialogs/requests.py b/electrum/gui/kivy/uix/dialogs/requests.py t@@ -0,0 +1,157 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder +from decimal import Decimal + +Builder.load_string(''' +<RequestLabel@Label> + #color: .305, .309, .309, 1 + text_size: self.width, None + halign: 'left' + valign: 'top' + +<RequestItem@CardItem> + address: '' + memo: '' + amount: '' + status: '' + date: '' + icon: 'atlas://electrum/gui/kivy/theming/light/important' + Image: + id: icon + source: root.icon + size_hint: None, 1 + width: self.height *.54 + mipmap: True + BoxLayout: + spacing: '8dp' + height: '32dp' + orientation: 'vertical' + Widget + RequestLabel: + text: root.address + shorten: True + Widget + RequestLabel: + text: root.memo + color: .699, .699, .699, 1 + font_size: '13sp' + shorten: True + Widget + BoxLayout: + spacing: '8dp' + height: '32dp' + orientation: 'vertical' + Widget + RequestLabel: + text: root.amount + halign: 'right' + font_size: '15sp' + Widget + RequestLabel: + text: root.status + halign: 'right' + font_size: '13sp' + color: .699, .699, .699, 1 + Widget + +<RequestsDialog@Popup> + id: popup + title: _('Requests') + BoxLayout: + id:box + orientation: 'vertical' + spacing: '1dp' + ScrollView: + GridLayout: + cols: 1 + id: requests_container + size_hint: 1, None + height: self.minimum_height + spacing: '2dp' + padding: '12dp' +''') + +from kivy.properties import BooleanProperty +from electrum.gui.kivy.i18n import _ +from electrum.util import format_time +from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_UNKNOWN, PR_EXPIRED +from electrum.gui.kivy.uix.context_menu import ContextMenu + +pr_icon = { + PR_UNPAID: 'atlas://electrum/gui/kivy/theming/light/important', + PR_UNKNOWN: 'atlas://electrum/gui/kivy/theming/light/important', + PR_PAID: 'atlas://electrum/gui/kivy/theming/light/confirmed', + PR_EXPIRED: 'atlas://electrum/gui/kivy/theming/light/close' +} +request_text = { + PR_UNPAID: _('Pending'), + PR_UNKNOWN: _('Unknown'), + PR_PAID: _('Received'), + PR_EXPIRED: _('Expired') +} + + +class RequestsDialog(Factory.Popup): + + def __init__(self, app, screen, callback): + Factory.Popup.__init__(self) + self.app = app + self.screen = screen + self.callback = callback + self.cards = {} + self.context_menu = None + + def get_card(self, req): + address = req['address'] + ci = self.cards.get(address) + if ci is None: + ci = Factory.RequestItem() + ci.address = address + ci.screen = self + self.cards[address] = ci + + amount = req.get('amount') + ci.amount = self.app.format_amount_and_units(amount) if amount else '' + ci.memo = req.get('memo', '') + status, conf = self.app.wallet.get_request_status(address) + ci.status = request_text[status] + ci.icon = pr_icon[status] + #exp = pr.get_expiration_date() + #ci.date = format_time(exp) if exp else _('Never') + return ci + + def update(self): + self.menu_actions = [(_('Show'), self.do_show), (_('Delete'), self.do_delete)] + requests_list = self.ids.requests_container + requests_list.clear_widgets() + _list = self.app.wallet.get_sorted_requests(self.app.electrum_config) + for pr in _list: + ci = self.get_card(pr) + requests_list.add_widget(ci) + + def do_show(self, obj): + self.hide_menu() + self.dismiss() + self.app.show_request(obj.address) + + def do_delete(self, req): + from .question import Question + def cb(result): + if result: + self.app.wallet.remove_payment_request(req.address, self.app.electrum_config) + self.hide_menu() + self.update() + d = Question(_('Delete request'), cb) + d.open() + + def show_menu(self, obj): + self.hide_menu() + self.context_menu = ContextMenu(obj, self.menu_actions) + self.ids.box.add_widget(self.context_menu) + + def hide_menu(self): + if self.context_menu is not None: + self.ids.box.remove_widget(self.context_menu) + self.context_menu = None DIR diff --git a/gui/kivy/uix/dialogs/seed_options.py b/electrum/gui/kivy/uix/dialogs/seed_options.py DIR diff --git a/electrum/gui/kivy/uix/dialogs/settings.py b/electrum/gui/kivy/uix/dialogs/settings.py t@@ -0,0 +1,220 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder + +from electrum.util import base_units_list +from electrum.i18n import languages +from electrum.gui.kivy.i18n import _ +from electrum.plugin import run_hook +from electrum import coinchooser + +from .choice_dialog import ChoiceDialog + +Builder.load_string(''' +#:import partial functools.partial +#:import _ electrum.gui.kivy.i18n._ + +<SettingsDialog@Popup> + id: settings + title: _('Electrum Settings') + disable_pin: False + use_encryption: False + BoxLayout: + orientation: 'vertical' + ScrollView: + GridLayout: + id: scrollviewlayout + cols:1 + size_hint: 1, None + height: self.minimum_height + padding: '10dp' + SettingsItem: + lang: settings.get_language_name() + title: 'Language' + ': ' + str(self.lang) + description: _('Language') + action: partial(root.language_dialog, self) + CardSeparator + SettingsItem: + disabled: root.disable_pin + title: _('PIN code') + description: _("Change your PIN code.") + action: partial(root.change_password, self) + CardSeparator + SettingsItem: + bu: app.base_unit + title: _('Denomination') + ': ' + self.bu + description: _("Base unit for Bitcoin amounts.") + action: partial(root.unit_dialog, self) + CardSeparator + SettingsItem: + status: root.fx_status() + title: _('Fiat Currency') + ': ' + self.status + description: _("Display amounts in fiat currency.") + action: partial(root.fx_dialog, self) + CardSeparator + SettingsItem: + status: 'ON' if bool(app.plugins.get('labels')) else 'OFF' + title: _('Labels Sync') + ': ' + self.status + description: _("Save and synchronize your labels.") + action: partial(root.plugin_dialog, 'labels', self) + CardSeparator + SettingsItem: + status: 'ON' if app.use_rbf else 'OFF' + title: _('Replace-by-fee') + ': ' + self.status + description: _("Create replaceable transactions.") + message: + _('If you check this box, your transactions will be marked as non-final,') \ + + ' ' + _('and you will have the possibility, while they are unconfirmed, to replace them with transactions that pays higher fees.') \ + + ' ' + _('Note that some merchants do not accept non-final transactions until they are confirmed.') + action: partial(root.boolean_dialog, 'use_rbf', _('Replace by fee'), self.message) + CardSeparator + SettingsItem: + status: _('Yes') if app.use_unconfirmed else _('No') + title: _('Spend unconfirmed') + ': ' + self.status + description: _("Use unconfirmed coins in transactions.") + message: _('Spend unconfirmed coins') + action: partial(root.boolean_dialog, 'use_unconfirmed', _('Use unconfirmed'), self.message) + CardSeparator + SettingsItem: + status: _('Yes') if app.use_change else _('No') + title: _('Use change addresses') + ': ' + self.status + description: _("Send your change to separate addresses.") + message: _('Send excess coins to change addresses') + action: partial(root.boolean_dialog, 'use_change', _('Use change addresses'), self.message) + + # disabled: there is currently only one coin selection policy + #CardSeparator + #SettingsItem: + # status: root.coinselect_status() + # title: _('Coin selection') + ': ' + self.status + # description: "Coin selection method" + # action: partial(root.coinselect_dialog, self) +''') + + + +class SettingsDialog(Factory.Popup): + + def __init__(self, app): + self.app = app + self.plugins = self.app.plugins + self.config = self.app.electrum_config + Factory.Popup.__init__(self) + layout = self.ids.scrollviewlayout + layout.bind(minimum_height=layout.setter('height')) + # cached dialogs + self._fx_dialog = None + self._proxy_dialog = None + self._language_dialog = None + self._unit_dialog = None + self._coinselect_dialog = None + + def update(self): + self.wallet = self.app.wallet + self.disable_pin = self.wallet.is_watching_only() if self.wallet else True + self.use_encryption = self.wallet.has_password() if self.wallet else False + + def get_language_name(self): + return languages.get(self.config.get('language', 'en_UK'), '') + + def change_password(self, item, dt): + self.app.change_password(self.update) + + def language_dialog(self, item, dt): + if self._language_dialog is None: + l = self.config.get('language', 'en_UK') + def cb(key): + self.config.set_key("language", key, True) + item.lang = self.get_language_name() + self.app.language = key + self._language_dialog = ChoiceDialog(_('Language'), languages, l, cb) + self._language_dialog.open() + + def unit_dialog(self, item, dt): + if self._unit_dialog is None: + def cb(text): + self.app._set_bu(text) + item.bu = self.app.base_unit + self._unit_dialog = ChoiceDialog(_('Denomination'), base_units_list, + self.app.base_unit, cb, keep_choice_order=True) + self._unit_dialog.open() + + def coinselect_status(self): + return coinchooser.get_name(self.app.electrum_config) + + def coinselect_dialog(self, item, dt): + if self._coinselect_dialog is None: + choosers = sorted(coinchooser.COIN_CHOOSERS.keys()) + chooser_name = coinchooser.get_name(self.config) + def cb(text): + self.config.set_key('coin_chooser', text) + item.status = text + self._coinselect_dialog = ChoiceDialog(_('Coin selection'), choosers, chooser_name, cb) + self._coinselect_dialog.open() + + def proxy_status(self): + server, port, protocol, proxy, auto_connect = self.app.network.get_parameters() + return proxy.get('host') +':' + proxy.get('port') if proxy else _('None') + + def proxy_dialog(self, item, dt): + if self._proxy_dialog is None: + server, port, protocol, proxy, auto_connect = self.app.network.get_parameters() + def callback(popup): + if popup.ids.mode.text != 'None': + proxy = { + 'mode':popup.ids.mode.text, + 'host':popup.ids.host.text, + 'port':popup.ids.port.text, + 'user':popup.ids.user.text, + 'password':popup.ids.password.text + } + else: + proxy = None + self.app.network.set_parameters(server, port, protocol, proxy, auto_connect) + item.status = self.proxy_status() + popup = Builder.load_file('electrum/gui/kivy/uix/ui_screens/proxy.kv') + popup.ids.mode.text = proxy.get('mode') if proxy else 'None' + popup.ids.host.text = proxy.get('host') if proxy else '' + popup.ids.port.text = proxy.get('port') if proxy else '' + popup.ids.user.text = proxy.get('user') if proxy else '' + popup.ids.password.text = proxy.get('password') if proxy else '' + popup.on_dismiss = lambda: callback(popup) + self._proxy_dialog = popup + self._proxy_dialog.open() + + def plugin_dialog(self, name, label, dt): + from .checkbox_dialog import CheckBoxDialog + def callback(status): + self.plugins.enable(name) if status else self.plugins.disable(name) + label.status = 'ON' if status else 'OFF' + status = bool(self.plugins.get(name)) + dd = self.plugins.descriptions.get(name) + descr = dd.get('description') + fullname = dd.get('fullname') + d = CheckBoxDialog(fullname, descr, status, callback) + d.open() + + def fee_status(self): + return self.config.get_fee_status() + + def boolean_dialog(self, name, title, message, dt): + from .checkbox_dialog import CheckBoxDialog + CheckBoxDialog(title, message, getattr(self.app, name), lambda x: setattr(self.app, name, x)).open() + + def fx_status(self): + fx = self.app.fx + if fx.is_enabled(): + source = fx.exchange.name() + ccy = fx.get_currency() + return '%s [%s]' %(ccy, source) + else: + return _('None') + + def fx_dialog(self, label, dt): + if self._fx_dialog is None: + from .fx_dialog import FxDialog + def cb(): + label.status = self.fx_status() + self._fx_dialog = FxDialog(self.app, self.plugins, self.config, cb) + self._fx_dialog.open() DIR diff --git a/electrum/gui/kivy/uix/dialogs/tx_dialog.py b/electrum/gui/kivy/uix/dialogs/tx_dialog.py t@@ -0,0 +1,184 @@ +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder +from kivy.clock import Clock +from kivy.uix.label import Label + +from electrum.gui.kivy.i18n import _ +from datetime import datetime +from electrum.util import InvalidPassword + +Builder.load_string(''' + +<TxDialog> + id: popup + title: _('Transaction') + is_mine: True + can_sign: False + can_broadcast: False + can_rbf: False + fee_str: '' + date_str: '' + date_label:'' + amount_str: '' + tx_hash: '' + status_str: '' + description: '' + outputs_str: '' + BoxLayout: + orientation: 'vertical' + ScrollView: + scroll_type: ['bars', 'content'] + bar_width: '25dp' + GridLayout: + height: self.minimum_height + size_hint_y: None + cols: 1 + spacing: '10dp' + padding: '10dp' + GridLayout: + height: self.minimum_height + size_hint_y: None + cols: 1 + spacing: '10dp' + BoxLabel: + text: _('Status') + value: root.status_str + BoxLabel: + text: _('Description') if root.description else '' + value: root.description + BoxLabel: + text: root.date_label + value: root.date_str + BoxLabel: + text: _('Amount sent') if root.is_mine else _('Amount received') + value: root.amount_str + BoxLabel: + text: _('Transaction fee') if root.fee_str else '' + value: root.fee_str + TopLabel: + text: _('Transaction ID') + ':' if root.tx_hash else '' + TxHashLabel: + data: root.tx_hash + name: _('Transaction ID') + TopLabel: + text: _('Outputs') + ':' + OutputList: + id: output_list + Widget: + size_hint: 1, 0.1 + + BoxLayout: + size_hint: 1, None + height: '48dp' + Button: + size_hint: 0.5, None + height: '48dp' + text: _('Sign') if root.can_sign else _('Broadcast') if root.can_broadcast else _('Bump fee') if root.can_rbf else '' + disabled: not(root.can_sign or root.can_broadcast or root.can_rbf) + opacity: 0 if self.disabled else 1 + on_release: + if root.can_sign: root.do_sign() + if root.can_broadcast: root.do_broadcast() + if root.can_rbf: root.do_rbf() + IconButton: + size_hint: 0.5, None + height: '48dp' + icon: 'atlas://electrum/gui/kivy/theming/light/qrcode' + on_release: root.show_qr() + Button: + size_hint: 0.5, None + height: '48dp' + text: _('Close') + on_release: root.dismiss() +''') + + +class TxDialog(Factory.Popup): + + def __init__(self, app, tx): + Factory.Popup.__init__(self) + self.app = app + self.wallet = self.app.wallet + self.tx = tx + + def on_open(self): + self.update() + + def update(self): + format_amount = self.app.format_amount_and_units + tx_hash, self.status_str, self.description, self.can_broadcast, self.can_rbf, amount, fee, height, conf, timestamp, exp_n = self.wallet.get_tx_info(self.tx) + self.tx_hash = tx_hash or '' + if timestamp: + self.date_label = _('Date') + self.date_str = datetime.fromtimestamp(timestamp).isoformat(' ')[:-3] + elif exp_n: + self.date_label = _('Mempool depth') + self.date_str = _('{} from tip').format('%.2f MB'%(exp_n/1000000)) + else: + self.date_label = '' + self.date_str = '' + + if amount is None: + self.amount_str = _("Transaction unrelated to your wallet") + elif amount > 0: + self.is_mine = False + self.amount_str = format_amount(amount) + else: + self.is_mine = True + self.amount_str = format_amount(-amount) + self.fee_str = format_amount(fee) if fee is not None else _('unknown') + self.can_sign = self.wallet.can_sign(self.tx) + self.ids.output_list.update(self.tx.outputs()) + + def do_rbf(self): + from .bump_fee_dialog import BumpFeeDialog + is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(self.tx) + if fee is None: + self.app.show_error(_("Can't bump fee: unknown fee for original transaction.")) + return + size = self.tx.estimated_size() + d = BumpFeeDialog(self.app, fee, size, self._do_rbf) + d.open() + + def _do_rbf(self, old_fee, new_fee, is_final): + if new_fee is None: + return + delta = new_fee - old_fee + if delta < 0: + self.app.show_error("fee too low") + return + try: + new_tx = self.wallet.bump_fee(self.tx, delta) + except BaseException as e: + self.app.show_error(str(e)) + return + if is_final: + new_tx.set_rbf(False) + self.tx = new_tx + self.update() + self.do_sign() + + def do_sign(self): + self.app.protected(_("Enter your PIN code in order to sign this transaction"), self._do_sign, ()) + + def _do_sign(self, password): + self.status_str = _('Signing') + '...' + Clock.schedule_once(lambda dt: self.__do_sign(password), 0.1) + + def __do_sign(self, password): + try: + self.app.wallet.sign_transaction(self.tx, password) + except InvalidPassword: + self.app.show_error(_("Invalid PIN")) + self.update() + + def do_broadcast(self): + self.app.broadcast(self.tx) + + def show_qr(self): + from electrum.bitcoin import base_encode, bfh + text = bfh(str(self.tx)) + text = base_encode(text, base=43) + self.app.qr_dialog(_("Raw Transaction"), text) DIR diff --git a/gui/kivy/uix/dialogs/wallets.py b/electrum/gui/kivy/uix/dialogs/wallets.py DIR diff --git a/gui/kivy/uix/drawer.py b/electrum/gui/kivy/uix/drawer.py DIR diff --git a/gui/kivy/uix/gridview.py b/electrum/gui/kivy/uix/gridview.py DIR diff --git a/electrum/gui/kivy/uix/menus.py b/electrum/gui/kivy/uix/menus.py t@@ -0,0 +1,95 @@ +from functools import partial + +from kivy.animation import Animation +from kivy.core.window import Window +from kivy.clock import Clock +from kivy.uix.bubble import Bubble, BubbleButton +from kivy.properties import ListProperty +from kivy.uix.widget import Widget + +from ..i18n import _ + +class ContextMenuItem(Widget): + '''abstract class + ''' + +class ContextButton(ContextMenuItem, BubbleButton): + pass + +class ContextMenu(Bubble): + + buttons = ListProperty([_('ok'), _('cancel')]) + '''List of Buttons to be displayed at the bottom''' + + __events__ = ('on_press', 'on_release') + + def __init__(self, **kwargs): + self._old_buttons = self.buttons + super(ContextMenu, self).__init__(**kwargs) + self.on_buttons(self, self.buttons) + + def on_touch_down(self, touch): + if not self.collide_point(*touch.pos): + self.hide() + return + return super(ContextMenu, self).on_touch_down(touch) + + def on_buttons(self, _menu, value): + if 'menu_content' not in self.ids.keys(): + return + if value == self._old_buttons: + return + blayout = self.ids.menu_content + blayout.clear_widgets() + for btn in value: + ib = ContextButton(text=btn) + ib.bind(on_press=partial(self.dispatch, 'on_press')) + ib.bind(on_release=partial(self.dispatch, 'on_release')) + blayout.add_widget(ib) + self._old_buttons = value + + def on_press(self, instance): + pass + + def on_release(self, instance): + pass + + def show(self, pos, duration=0): + Window.add_widget(self) + # wait for the bubble to adjust it's size according to text then animate + Clock.schedule_once(lambda dt: self._show(pos, duration)) + + def _show(self, pos, duration): + def on_stop(*l): + if duration: + Clock.schedule_once(self.hide, duration + .5) + + self.opacity = 0 + arrow_pos = self.arrow_pos + if arrow_pos[0] in ('l', 'r'): + pos = pos[0], pos[1] - (self.height/2) + else: + pos = pos[0] - (self.width/2), pos[1] + + self.limit_to = Window + + anim = Animation(opacity=1, pos=pos, d=.32) + anim.bind(on_complete=on_stop) + anim.cancel_all(self) + anim.start(self) + + + def hide(self, *dt): + + def on_stop(*l): + Window.remove_widget(self) + anim = Animation(opacity=0, d=.25) + anim.bind(on_complete=on_stop) + anim.cancel_all(self) + anim.start(self) + + def add_widget(self, widget, index=0): + if not isinstance(widget, ContextMenuItem): + super(ContextMenu, self).add_widget(widget, index) + return + menu_content.add_widget(widget, index) DIR diff --git a/gui/kivy/uix/qrcodewidget.py b/electrum/gui/kivy/uix/qrcodewidget.py DIR diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py t@@ -0,0 +1,484 @@ +from weakref import ref +from decimal import Decimal +import re +import datetime +import traceback, sys + +from kivy.app import App +from kivy.cache import Cache +from kivy.clock import Clock +from kivy.compat import string_types +from kivy.properties import (ObjectProperty, DictProperty, NumericProperty, + ListProperty, StringProperty) + +from kivy.uix.recycleview import RecycleView +from kivy.uix.label import Label + +from kivy.lang import Builder +from kivy.factory import Factory +from kivy.utils import platform + +from electrum.util import profiler, parse_URI, format_time, InvalidPassword, NotEnoughFunds, Fiat +from electrum import bitcoin +from electrum.util import timestamp_to_datetime +from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_UNKNOWN, PR_EXPIRED +from electrum.plugin import run_hook + +from .context_menu import ContextMenu + + +from electrum.gui.kivy.i18n import _ + +class HistoryRecycleView(RecycleView): + pass + +class CScreen(Factory.Screen): + __events__ = ('on_activate', 'on_deactivate', 'on_enter', 'on_leave') + action_view = ObjectProperty(None) + loaded = False + kvname = None + context_menu = None + menu_actions = [] + app = App.get_running_app() + + def _change_action_view(self): + app = App.get_running_app() + action_bar = app.root.manager.current_screen.ids.action_bar + _action_view = self.action_view + + if (not _action_view) or _action_view.parent: + return + action_bar.clear_widgets() + action_bar.add_widget(_action_view) + + def on_enter(self): + # FIXME: use a proper event don't use animation time of screen + Clock.schedule_once(lambda dt: self.dispatch('on_activate'), .25) + pass + + def update(self): + pass + + @profiler + def load_screen(self): + self.screen = Builder.load_file('electrum/gui/kivy/uix/ui_screens/' + self.kvname + '.kv') + self.add_widget(self.screen) + self.loaded = True + self.update() + setattr(self.app, self.kvname + '_screen', self) + + def on_activate(self): + if self.kvname and not self.loaded: + self.load_screen() + #Clock.schedule_once(lambda dt: self._change_action_view()) + + def on_leave(self): + self.dispatch('on_deactivate') + + def on_deactivate(self): + self.hide_menu() + + def hide_menu(self): + if self.context_menu is not None: + self.remove_widget(self.context_menu) + self.context_menu = None + + def show_menu(self, obj): + self.hide_menu() + self.context_menu = ContextMenu(obj, self.menu_actions) + self.add_widget(self.context_menu) + + +# note: this list needs to be kept in sync with another in qt +TX_ICONS = [ + "unconfirmed", + "close", + "unconfirmed", + "close", + "clock1", + "clock2", + "clock3", + "clock4", + "clock5", + "confirmed", +] + +class HistoryScreen(CScreen): + + tab = ObjectProperty(None) + kvname = 'history' + cards = {} + + def __init__(self, **kwargs): + self.ra_dialog = None + super(HistoryScreen, self).__init__(**kwargs) + self.menu_actions = [ ('Label', self.label_dialog), ('Details', self.show_tx)] + + def show_tx(self, obj): + tx_hash = obj.tx_hash + tx = self.app.wallet.transactions.get(tx_hash) + if not tx: + return + self.app.tx_dialog(tx) + + def label_dialog(self, obj): + from .dialogs.label_dialog import LabelDialog + key = obj.tx_hash + text = self.app.wallet.get_label(key) + def callback(text): + self.app.wallet.set_label(key, text) + self.update() + d = LabelDialog(_('Enter Transaction Label'), text, callback) + d.open() + + def get_card(self, tx_hash, height, conf, timestamp, value, balance): + status, status_str = self.app.wallet.get_tx_status(tx_hash, height, conf, timestamp) + icon = "atlas://electrum/gui/kivy/theming/light/" + TX_ICONS[status] + label = self.app.wallet.get_label(tx_hash) if tx_hash else _('Pruned transaction outputs') + ri = {} + ri['screen'] = self + ri['tx_hash'] = tx_hash + ri['icon'] = icon + ri['date'] = status_str + ri['message'] = label + ri['confirmations'] = conf + if value is not None: + ri['is_mine'] = value < 0 + if value < 0: value = - value + ri['amount'] = self.app.format_amount_and_units(value) + if self.app.fiat_unit: + fx = self.app.fx + fiat_value = value / Decimal(bitcoin.COIN) * self.app.wallet.price_at_timestamp(tx_hash, fx.timestamp_rate) + fiat_value = Fiat(fiat_value, fx.ccy) + ri['quote_text'] = str(fiat_value) + return ri + + def update(self, see_all=False): + if self.app.wallet is None: + return + history = reversed(self.app.wallet.get_history()) + history_card = self.screen.ids.history_container + count = 0 + history_card.data = [self.get_card(*item) for item in history] + + +class SendScreen(CScreen): + + kvname = 'send' + payment_request = None + payment_request_queued = None + + def set_URI(self, text): + if not self.app.wallet: + self.payment_request_queued = text + return + import electrum + try: + uri = electrum.util.parse_URI(text, self.app.on_pr) + except: + self.app.show_info(_("Not a Bitcoin URI")) + return + amount = uri.get('amount') + self.screen.address = uri.get('address', '') + self.screen.message = uri.get('message', '') + self.screen.amount = self.app.format_amount_and_units(amount) if amount else '' + self.payment_request = None + self.screen.is_pr = False + + def update(self): + if self.app.wallet and self.payment_request_queued: + self.set_URI(self.payment_request_queued) + self.payment_request_queued = None + + def do_clear(self): + self.screen.amount = '' + self.screen.message = '' + self.screen.address = '' + self.payment_request = None + self.screen.is_pr = False + + def set_request(self, pr): + self.screen.address = pr.get_requestor() + amount = pr.get_amount() + self.screen.amount = self.app.format_amount_and_units(amount) if amount else '' + self.screen.message = pr.get_memo() + if pr.is_pr(): + self.screen.is_pr = True + self.payment_request = pr + else: + self.screen.is_pr = False + self.payment_request = None + + def do_save(self): + if not self.screen.address: + return + if self.screen.is_pr: + # it should be already saved + return + # save address as invoice + from electrum.paymentrequest import make_unsigned_request, PaymentRequest + req = {'address':self.screen.address, 'memo':self.screen.message} + amount = self.app.get_amount(self.screen.amount) if self.screen.amount else 0 + req['amount'] = amount + pr = make_unsigned_request(req).SerializeToString() + pr = PaymentRequest(pr) + self.app.wallet.invoices.add(pr) + self.app.show_info(_("Invoice saved")) + if pr.is_pr(): + self.screen.is_pr = True + self.payment_request = pr + else: + self.screen.is_pr = False + self.payment_request = None + + def do_paste(self): + contents = self.app._clipboard.paste() + if not contents: + self.app.show_info(_("Clipboard is empty")) + return + self.set_URI(contents) + + def do_send(self): + if self.screen.is_pr: + if self.payment_request.has_expired(): + self.app.show_error(_('Payment request has expired')) + return + outputs = self.payment_request.get_outputs() + else: + address = str(self.screen.address) + if not address: + self.app.show_error(_('Recipient not specified.') + ' ' + _('Please scan a Bitcoin address or a payment request')) + return + if not bitcoin.is_address(address): + self.app.show_error(_('Invalid Bitcoin Address') + ':\n' + address) + return + try: + amount = self.app.get_amount(self.screen.amount) + except: + self.app.show_error(_('Invalid amount') + ':\n' + self.screen.amount) + return + outputs = [(bitcoin.TYPE_ADDRESS, address, amount)] + message = self.screen.message + amount = sum(map(lambda x:x[2], outputs)) + if self.app.electrum_config.get('use_rbf'): + from .dialogs.question import Question + d = Question(_('Should this transaction be replaceable?'), lambda b: self._do_send(amount, message, outputs, b)) + d.open() + else: + self._do_send(amount, message, outputs, False) + + def _do_send(self, amount, message, outputs, rbf): + # make unsigned transaction + config = self.app.electrum_config + coins = self.app.wallet.get_spendable_coins(None, config) + try: + tx = self.app.wallet.make_unsigned_transaction(coins, outputs, config, None) + except NotEnoughFunds: + self.app.show_error(_("Not enough funds")) + return + except Exception as e: + traceback.print_exc(file=sys.stdout) + self.app.show_error(str(e)) + return + if rbf: + tx.set_rbf(True) + fee = tx.get_fee() + msg = [ + _("Amount to be sent") + ": " + self.app.format_amount_and_units(amount), + _("Mining fee") + ": " + self.app.format_amount_and_units(fee), + ] + x_fee = run_hook('get_tx_extra_fee', self.app.wallet, tx) + if x_fee: + x_fee_address, x_fee_amount = x_fee + msg.append(_("Additional fees") + ": " + self.app.format_amount_and_units(x_fee_amount)) + + if fee >= config.get('confirm_fee', 100000): + msg.append(_('Warning')+ ': ' + _("The fee for this transaction seems unusually high.")) + msg.append(_("Enter your PIN code to proceed")) + self.app.protected('\n'.join(msg), self.send_tx, (tx, message)) + + def send_tx(self, tx, message, password): + if self.app.wallet.has_password() and password is None: + return + def on_success(tx): + if tx.is_complete(): + self.app.broadcast(tx, self.payment_request) + self.app.wallet.set_label(tx.txid(), message) + else: + self.app.tx_dialog(tx) + def on_failure(error): + self.app.show_error(error) + if self.app.wallet.can_sign(tx): + self.app.show_info("Signing...") + self.app.sign_tx(tx, password, on_success, on_failure) + else: + self.app.tx_dialog(tx) + + +class ReceiveScreen(CScreen): + + kvname = 'receive' + + def update(self): + if not self.screen.address: + self.get_new_address() + else: + status = self.app.wallet.get_request_status(self.screen.address) + self.screen.status = _('Payment received') if status == PR_PAID else '' + + def clear(self): + self.screen.address = '' + self.screen.amount = '' + self.screen.message = '' + + def get_new_address(self): + if not self.app.wallet: + return False + self.clear() + addr = self.app.wallet.get_unused_address() + if addr is None: + addr = self.app.wallet.get_receiving_address() or '' + b = False + else: + b = True + self.screen.address = addr + return b + + def on_address(self, addr): + req = self.app.wallet.get_payment_request(addr, self.app.electrum_config) + self.screen.status = '' + if req: + self.screen.message = req.get('memo', '') + amount = req.get('amount') + self.screen.amount = self.app.format_amount_and_units(amount) if amount else '' + status = req.get('status', PR_UNKNOWN) + self.screen.status = _('Payment received') if status == PR_PAID else '' + Clock.schedule_once(lambda dt: self.update_qr()) + + def get_URI(self): + from electrum.util import create_URI + amount = self.screen.amount + if amount: + a, u = self.screen.amount.split() + assert u == self.app.base_unit + amount = Decimal(a) * pow(10, self.app.decimal_point()) + return create_URI(self.screen.address, amount, self.screen.message) + + @profiler + def update_qr(self): + uri = self.get_URI() + qr = self.screen.ids.qr + qr.set_data(uri) + + def do_share(self): + uri = self.get_URI() + self.app.do_share(uri, _("Share Bitcoin Request")) + + def do_copy(self): + uri = self.get_URI() + self.app._clipboard.copy(uri) + self.app.show_info(_('Request copied to clipboard')) + + def save_request(self): + addr = self.screen.address + if not addr: + return False + amount = self.screen.amount + message = self.screen.message + amount = self.app.get_amount(amount) if amount else 0 + req = self.app.wallet.make_payment_request(addr, amount, message, None) + try: + self.app.wallet.add_payment_request(req, self.app.electrum_config) + added_request = True + except Exception as e: + self.app.show_error(_('Error adding payment request') + ':\n' + str(e)) + added_request = False + finally: + self.app.update_tab('requests') + return added_request + + def on_amount_or_message(self): + Clock.schedule_once(lambda dt: self.update_qr()) + + def do_new(self): + addr = self.get_new_address() + if not addr: + self.app.show_info(_('Please use the existing requests first.')) + + def do_save(self): + if self.save_request(): + self.app.show_info(_('Request was saved.')) + + +class TabbedCarousel(Factory.TabbedPanel): + '''Custom TabbedPanel using a carousel used in the Main Screen + ''' + + carousel = ObjectProperty(None) + + def animate_tab_to_center(self, value): + scrlv = self._tab_strip.parent + if not scrlv: + return + idx = self.tab_list.index(value) + n = len(self.tab_list) + if idx in [0, 1]: + scroll_x = 1 + elif idx in [n-1, n-2]: + scroll_x = 0 + else: + scroll_x = 1. * (n - idx - 1) / (n - 1) + mation = Factory.Animation(scroll_x=scroll_x, d=.25) + mation.cancel_all(scrlv) + mation.start(scrlv) + + def on_current_tab(self, instance, value): + self.animate_tab_to_center(value) + + def on_index(self, instance, value): + current_slide = instance.current_slide + if not hasattr(current_slide, 'tab'): + return + tab = current_slide.tab + ct = self.current_tab + try: + if ct.text != tab.text: + carousel = self.carousel + carousel.slides[ct.slide].dispatch('on_leave') + self.switch_to(tab) + carousel.slides[tab.slide].dispatch('on_enter') + except AttributeError: + current_slide.dispatch('on_enter') + + def switch_to(self, header): + # we have to replace the functionality of the original switch_to + if not header: + return + if not hasattr(header, 'slide'): + header.content = self.carousel + super(TabbedCarousel, self).switch_to(header) + try: + tab = self.tab_list[-1] + except IndexError: + return + self._current_tab = tab + tab.state = 'down' + return + + carousel = self.carousel + self.current_tab.state = "normal" + header.state = 'down' + self._current_tab = header + # set the carousel to load the appropriate slide + # saved in the screen attribute of the tab head + slide = carousel.slides[header.slide] + if carousel.current_slide != slide: + carousel.current_slide.dispatch('on_leave') + carousel.load_slide(slide) + slide.dispatch('on_enter') + + def add_widget(self, widget, index=0): + if isinstance(widget, Factory.CScreen): + self.carousel.add_widget(widget) + return + super(TabbedCarousel, self).add_widget(widget, index=index) DIR diff --git a/gui/kivy/uix/ui_screens/about.kv b/electrum/gui/kivy/uix/ui_screens/about.kv DIR diff --git a/electrum/gui/kivy/uix/ui_screens/history.kv b/electrum/gui/kivy/uix/ui_screens/history.kv t@@ -0,0 +1,78 @@ +#:import _ electrum.gui.kivy.i18n._ +#:import Factory kivy.factory.Factory +#:set font_light 'electrum/gui/kivy/data/fonts/Roboto-Condensed.ttf' +#:set btc_symbol chr(171) +#:set mbtc_symbol chr(187) + + + +<CardLabel@Label> + color: 0.95, 0.95, 0.95, 1 + size_hint: 1, None + text: '' + text_size: self.width, None + height: self.texture_size[1] + halign: 'left' + valign: 'top' + + +<HistoryItem@CardItem> + icon: 'atlas://electrum/gui/kivy/theming/light/important' + message: '' + is_mine: True + amount: '--' + action: _('Sent') if self.is_mine else _('Received') + amount_color: '#FF6657' if self.is_mine else '#2EA442' + confirmations: 0 + date: '' + quote_text: '' + Image: + id: icon + source: root.icon + size_hint: None, 1 + allow_stretch: True + width: self.height*1.5 + mipmap: True + BoxLayout: + orientation: 'vertical' + Widget + CardLabel: + text: + u'[color={color}]{s}[/color]'.format(s='<<' if root.is_mine else '>>', color=root.amount_color)\ + + ' ' + root.action + ' ' + (root.quote_text if app.is_fiat else root.amount) + font_size: '15sp' + CardLabel: + color: .699, .699, .699, 1 + font_size: '14sp' + shorten: True + text: root.date + ' ' + root.message + Widget + +<HistoryRecycleView>: + viewclass: 'HistoryItem' + RecycleBoxLayout: + default_size: None, dp(56) + default_size_hint: 1, None + size_hint: 1, None + height: self.minimum_height + orientation: 'vertical' + + +HistoryScreen: + name: 'history' + content: history_container + BoxLayout: + orientation: 'vertical' + Button: + background_color: 0, 0, 0, 0 + text: app.fiat_balance if app.is_fiat else app.balance + markup: True + color: .9, .9, .9, 1 + font_size: '30dp' + bold: True + size_hint: 1, 0.25 + on_release: app.is_fiat = not app.is_fiat if app.fx.is_enabled() else False + HistoryRecycleView: + id: history_container + scroll_type: ['bars', 'content'] + bar_width: '25dp' DIR diff --git a/gui/kivy/uix/ui_screens/invoice.kv b/electrum/gui/kivy/uix/ui_screens/invoice.kv DIR diff --git a/gui/kivy/uix/ui_screens/network.kv b/electrum/gui/kivy/uix/ui_screens/network.kv DIR diff --git a/gui/kivy/uix/ui_screens/proxy.kv b/electrum/gui/kivy/uix/ui_screens/proxy.kv DIR diff --git a/electrum/gui/kivy/uix/ui_screens/receive.kv b/electrum/gui/kivy/uix/ui_screens/receive.kv t@@ -0,0 +1,142 @@ +#:import _ electrum.gui.kivy.i18n._ +#:import Decimal decimal.Decimal +#:set btc_symbol chr(171) +#:set mbtc_symbol chr(187) +#:set font_light 'electrum/gui/kivy/data/fonts/Roboto-Condensed.ttf' + + + +ReceiveScreen: + id: s + name: 'receive' + + address: '' + amount: '' + message: '' + status: '' + + on_address: + self.parent.on_address(self.address) + on_amount: + self.parent.on_amount_or_message() + on_message: + self.parent.on_amount_or_message() + + BoxLayout + padding: '12dp', '12dp', '12dp', '12dp' + spacing: '12dp' + orientation: 'vertical' + size_hint: 1, 1 + FloatLayout: + id: bl + QRCodeWidget: + id: qr + size_hint: None, 1 + width: min(self.height, bl.width) + pos_hint: {'center': (.5, .5)} + shaded: False + foreground_color: (0, 0, 0, 0.5) if self.shaded else (0, 0, 0, 0) + on_touch_down: + touch = args[1] + if self.collide_point(*touch.pos): self.shaded = not self.shaded + Label: + text: root.status + opacity: 1 if root.status else 0 + pos_hint: {'center': (.5, .5)} + size_hint: None, 1 + width: min(self.height, bl.width) + bcolor: 0.3, 0.3, 0.3, 0.9 + canvas.before: + Color: + rgba: self.bcolor + Rectangle: + pos: self.pos + size: self.size + + SendReceiveBlueBottom: + id: blue_bottom + size_hint: 1, None + height: self.minimum_height + BoxLayout: + size_hint: 1, None + height: blue_bottom.item_height + spacing: '5dp' + Image: + source: 'atlas://electrum/gui/kivy/theming/light/globe' + size_hint: None, None + size: '22dp', '22dp' + pos_hint: {'center_y': .5} + BlueButton: + id: address_label + text: s.address if s.address else _('Bitcoin Address') + shorten: True + on_release: Clock.schedule_once(lambda dt: app.addresses_dialog(s)) + CardSeparator: + opacity: message_selection.opacity + color: blue_bottom.foreground_color + BoxLayout: + size_hint: 1, None + height: blue_bottom.item_height + spacing: '5dp' + Image: + source: 'atlas://electrum/gui/kivy/theming/light/calculator' + opacity: 0.7 + size_hint: None, None + size: '22dp', '22dp' + pos_hint: {'center_y': .5} + BlueButton: + id: amount_label + default_text: _('Amount') + text: s.amount if s.amount else _('Amount') + on_release: Clock.schedule_once(lambda dt: app.amount_dialog(s, False)) + CardSeparator: + opacity: message_selection.opacity + color: blue_bottom.foreground_color + BoxLayout: + id: message_selection + opacity: 1 + size_hint: 1, None + height: blue_bottom.item_height + spacing: '5dp' + Image: + source: 'atlas://electrum/gui/kivy/theming/light/pen' + size_hint: None, None + size: '22dp', '22dp' + pos_hint: {'center_y': .5} + BlueButton: + id: description + text: s.message if s.message else _('Description') + on_release: Clock.schedule_once(lambda dt: app.description_dialog(s)) + BoxLayout: + size_hint: 1, None + height: '48dp' + IconButton: + icon: 'atlas://electrum/gui/kivy/theming/light/save' + size_hint: 0.6, None + height: '48dp' + on_release: s.parent.do_save() + Button: + text: _('Requests') + size_hint: 1, None + height: '48dp' + on_release: Clock.schedule_once(lambda dt: app.requests_dialog(s)) + Button: + text: _('Copy') + size_hint: 1, None + height: '48dp' + on_release: s.parent.do_copy() + IconButton: + icon: 'atlas://electrum/gui/kivy/theming/light/share' + size_hint: 0.6, None + height: '48dp' + on_release: s.parent.do_share() + BoxLayout: + size_hint: 1, None + height: '48dp' + Widget + size_hint: 2, 1 + Button: + text: _('New') + size_hint: 1, None + height: '48dp' + on_release: Clock.schedule_once(lambda dt: s.parent.do_new()) DIR diff --git a/electrum/gui/kivy/uix/ui_screens/send.kv b/electrum/gui/kivy/uix/ui_screens/send.kv t@@ -0,0 +1,127 @@ +#:import _ electrum.gui.kivy.i18n._ +#:import Decimal decimal.Decimal +#:set btc_symbol chr(171) +#:set mbtc_symbol chr(187) +#:set font_light 'electrum/gui/kivy/data/fonts/Roboto-Condensed.ttf' + + +SendScreen: + id: s + name: 'send' + address: '' + amount: '' + message: '' + is_pr: False + BoxLayout + padding: '12dp', '12dp', '12dp', '12dp' + spacing: '12dp' + orientation: 'vertical' + SendReceiveBlueBottom: + id: blue_bottom + size_hint: 1, None + height: self.minimum_height + BoxLayout: + size_hint: 1, None + height: blue_bottom.item_height + spacing: '5dp' + Image: + source: 'atlas://electrum/gui/kivy/theming/light/globe' + size_hint: None, None + size: '22dp', '22dp' + pos_hint: {'center_y': .5} + BlueButton: + id: payto_e + text: s.address if s.address else _('Recipient') + shorten: True + on_release: Clock.schedule_once(lambda dt: app.show_info(_('Copy and paste the recipient address using the Paste button, or use the camera to scan a QR code.'))) + #on_release: Clock.schedule_once(lambda dt: app.popup_dialog('contacts')) + CardSeparator: + opacity: int(not root.is_pr) + color: blue_bottom.foreground_color + BoxLayout: + size_hint: 1, None + height: blue_bottom.item_height + spacing: '5dp' + Image: + source: 'atlas://electrum/gui/kivy/theming/light/calculator' + opacity: 0.7 + size_hint: None, None + size: '22dp', '22dp' + pos_hint: {'center_y': .5} + BlueButton: + id: amount_e + default_text: _('Amount') + text: s.amount if s.amount else _('Amount') + disabled: root.is_pr + on_release: Clock.schedule_once(lambda dt: app.amount_dialog(s, True)) + CardSeparator: + opacity: int(not root.is_pr) + color: blue_bottom.foreground_color + BoxLayout: + id: message_selection + size_hint: 1, None + height: blue_bottom.item_height + spacing: '5dp' + Image: + source: 'atlas://electrum/gui/kivy/theming/light/pen' + size_hint: None, None + size: '22dp', '22dp' + pos_hint: {'center_y': .5} + BlueButton: + id: description + text: s.message if s.message else (_('No Description') if root.is_pr else _('Description')) + disabled: root.is_pr + on_release: Clock.schedule_once(lambda dt: app.description_dialog(s)) + CardSeparator: + opacity: int(not root.is_pr) + color: blue_bottom.foreground_color + BoxLayout: + size_hint: 1, None + height: blue_bottom.item_height + spacing: '5dp' + Image: + source: 'atlas://electrum/gui/kivy/theming/light/star_big_inactive' + opacity: 0.7 + size_hint: None, None + size: '22dp', '22dp' + pos_hint: {'center_y': .5} + BlueButton: + id: fee_e + default_text: _('Fee') + text: app.fee_status + on_release: Clock.schedule_once(lambda dt: app.fee_dialog(s, True)) + BoxLayout: + size_hint: 1, None + height: '48dp' + IconButton: + size_hint: 0.6, 1 + on_release: s.parent.do_save() + icon: 'atlas://electrum/gui/kivy/theming/light/save' + Button: + text: _('Invoices') + size_hint: 1, 1 + on_release: Clock.schedule_once(lambda dt: app.invoices_dialog(s)) + Button: + text: _('Paste') + on_release: s.parent.do_paste() + IconButton: + id: qr + size_hint: 0.6, 1 + on_release: Clock.schedule_once(lambda dt: app.scan_qr(on_complete=app.on_qr)) + icon: 'atlas://electrum/gui/kivy/theming/light/camera' + BoxLayout: + size_hint: 1, None + height: '48dp' + Button: + text: _('Clear') + on_release: s.parent.do_clear() + Widget: + size_hint: 1, 1 + Button: + text: _('Pay') + size_hint: 1, 1 + on_release: s.parent.do_send() + Widget: + size_hint: 1, 1 + + DIR diff --git a/gui/kivy/uix/ui_screens/server.kv b/electrum/gui/kivy/uix/ui_screens/server.kv DIR diff --git a/gui/kivy/uix/ui_screens/status.kv b/electrum/gui/kivy/uix/ui_screens/status.kv DIR diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py t@@ -0,0 +1,313 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2012 thomasv@gitorious +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import signal +import sys +import traceback + + +try: + import PyQt5 +except Exception: + sys.exit("Error: Could not import PyQt5 on Linux systems, you may try 'sudo apt-get install python3-pyqt5'") + +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * +from PyQt5.QtCore import * +import PyQt5.QtCore as QtCore + +from electrum.i18n import _, set_language +from electrum.plugin import run_hook +from electrum.storage import WalletStorage +from electrum.base_wizard import GoBack +# from electrum.synchronizer import Synchronizer +# from electrum.verifier import SPV +# from electrum.util import DebugMem +from electrum.util import (UserCancelled, print_error, + WalletFileException, BitcoinException) +# from electrum.wallet import Abstract_Wallet + +from .installwizard import InstallWizard + + +try: + from . import icons_rc +except Exception as e: + print(e) + print("Error: Could not find icons file.") + print("Please run 'pyrcc5 icons.qrc -o electrum/gui/qt/icons_rc.py'") + sys.exit(1) + +from .util import * # * needed for plugins +from .main_window import ElectrumWindow +from .network_dialog import NetworkDialog + + +class OpenFileEventFilter(QObject): + def __init__(self, windows): + self.windows = windows + super(OpenFileEventFilter, self).__init__() + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.FileOpen: + if len(self.windows) >= 1: + self.windows[0].pay_to_URI(event.url().toEncoded()) + return True + return False + + +class QElectrumApplication(QApplication): + new_window_signal = pyqtSignal(str, object) + + +class QNetworkUpdatedSignalObject(QObject): + network_updated_signal = pyqtSignal(str, object) + + +class ElectrumGui: + + def __init__(self, config, daemon, plugins): + set_language(config.get('language')) + # Uncomment this call to verify objects are being properly + # GC-ed when windows are closed + #network.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer, + # ElectrumWindow], interval=5)]) + QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_X11InitThreads) + if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"): + QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) + if hasattr(QGuiApplication, 'setDesktopFileName'): + QGuiApplication.setDesktopFileName('electrum.desktop') + self.config = config + self.daemon = daemon + self.plugins = plugins + self.windows = [] + self.efilter = OpenFileEventFilter(self.windows) + self.app = QElectrumApplication(sys.argv) + self.app.installEventFilter(self.efilter) + self.timer = Timer() + self.nd = None + self.network_updated_signal_obj = QNetworkUpdatedSignalObject() + # init tray + self.dark_icon = self.config.get("dark_icon", False) + self.tray = QSystemTrayIcon(self.tray_icon(), None) + self.tray.setToolTip('Electrum') + self.tray.activated.connect(self.tray_activated) + self.build_tray_menu() + self.tray.show() + self.app.new_window_signal.connect(self.start_new_window) + self.set_dark_theme_if_needed() + run_hook('init_qt', self) + + def set_dark_theme_if_needed(self): + use_dark_theme = self.config.get('qt_gui_color_theme', 'default') == 'dark' + if use_dark_theme: + try: + import qdarkstyle + self.app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5()) + except BaseException as e: + use_dark_theme = False + print_error('Error setting dark theme: {}'.format(e)) + # Even if we ourselves don't set the dark theme, + # the OS/window manager/etc might set *a dark theme*. + # Hence, try to choose colors accordingly: + ColorScheme.update_from_widget(QWidget(), force_dark=use_dark_theme) + + def build_tray_menu(self): + # Avoid immediate GC of old menu when window closed via its action + if self.tray.contextMenu() is None: + m = QMenu() + self.tray.setContextMenu(m) + else: + m = self.tray.contextMenu() + m.clear() + for window in self.windows: + submenu = m.addMenu(window.wallet.basename()) + submenu.addAction(_("Show/Hide"), window.show_or_hide) + submenu.addAction(_("Close"), window.close) + m.addAction(_("Dark/Light"), self.toggle_tray_icon) + m.addSeparator() + m.addAction(_("Exit Electrum"), self.close) + + def tray_icon(self): + if self.dark_icon: + return QIcon(':icons/electrum_dark_icon.png') + else: + return QIcon(':icons/electrum_light_icon.png') + + def toggle_tray_icon(self): + self.dark_icon = not self.dark_icon + self.config.set_key("dark_icon", self.dark_icon, True) + self.tray.setIcon(self.tray_icon()) + + def tray_activated(self, reason): + if reason == QSystemTrayIcon.DoubleClick: + if all([w.is_hidden() for w in self.windows]): + for w in self.windows: + w.bring_to_top() + else: + for w in self.windows: + w.hide() + + def close(self): + for window in self.windows: + window.close() + + def new_window(self, path, uri=None): + # Use a signal as can be called from daemon thread + self.app.new_window_signal.emit(path, uri) + + def show_network_dialog(self, parent): + if not self.daemon.network: + parent.show_warning(_('You are using Electrum in offline mode; restart Electrum if you want to get connected'), title=_('Offline')) + return + if self.nd: + self.nd.on_update() + self.nd.show() + self.nd.raise_() + return + self.nd = NetworkDialog(self.daemon.network, self.config, + self.network_updated_signal_obj) + self.nd.show() + + def create_window_for_wallet(self, wallet): + w = ElectrumWindow(self, wallet) + self.windows.append(w) + self.build_tray_menu() + # FIXME: Remove in favour of the load_wallet hook + run_hook('on_new_window', w) + return w + + def start_new_window(self, path, uri, app_is_starting=False): + '''Raises the window for the wallet if it is open. Otherwise + opens the wallet and creates a new window for it''' + try: + wallet = self.daemon.load_wallet(path, None) + except BaseException as e: + traceback.print_exc(file=sys.stdout) + d = QMessageBox(QMessageBox.Warning, _('Error'), + _('Cannot load wallet') + ' (1):\n' + str(e)) + d.exec_() + if app_is_starting: + # do not return so that the wizard can appear + wallet = None + else: + return + if not wallet: + storage = WalletStorage(path, manual_upgrades=True) + wizard = InstallWizard(self.config, self.app, self.plugins, storage) + try: + wallet = wizard.run_and_get_wallet(self.daemon.get_wallet) + except UserCancelled: + pass + except GoBack as e: + print_error('[start_new_window] Exception caught (GoBack)', e) + except (WalletFileException, BitcoinException) as e: + traceback.print_exc(file=sys.stderr) + d = QMessageBox(QMessageBox.Warning, _('Error'), + _('Cannot load wallet') + ' (2):\n' + str(e)) + d.exec_() + return + finally: + wizard.terminate() + if not wallet: + return + + if not self.daemon.get_wallet(wallet.storage.path): + # wallet was not in memory + wallet.start_threads(self.daemon.network) + self.daemon.add_wallet(wallet) + try: + for w in self.windows: + if w.wallet.storage.path == wallet.storage.path: + w.bring_to_top() + return + w = self.create_window_for_wallet(wallet) + except BaseException as e: + traceback.print_exc(file=sys.stdout) + d = QMessageBox(QMessageBox.Warning, _('Error'), + _('Cannot create window for wallet') + ':\n' + str(e)) + d.exec_() + return + if uri: + w.pay_to_URI(uri) + w.bring_to_top() + w.setWindowState(w.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + + # this will activate the window + w.activateWindow() + return w + + def close_window(self, window): + self.windows.remove(window) + self.build_tray_menu() + # save wallet path of last open window + if not self.windows: + self.config.save_last_wallet(window.wallet) + run_hook('on_close_window', window) + + def init_network(self): + # Show network dialog if config does not exist + if self.daemon.network: + if self.config.get('auto_connect') is None: + wizard = InstallWizard(self.config, self.app, self.plugins, None) + wizard.init_network(self.daemon.network) + wizard.terminate() + + def main(self): + try: + self.init_network() + except UserCancelled: + return + except GoBack: + return + except BaseException as e: + traceback.print_exc(file=sys.stdout) + return + self.timer.start() + self.config.open_last_wallet() + path = self.config.get_wallet_path() + if not self.start_new_window(path, self.config.get('url'), app_is_starting=True): + return + signal.signal(signal.SIGINT, lambda *args: self.app.quit()) + + def quit_after_last_window(): + # on some platforms, not only does exec_ not return but not even + # aboutToQuit is emitted (but following this, it should be emitted) + if self.app.quitOnLastWindowClosed(): + self.app.quit() + self.app.lastWindowClosed.connect(quit_after_last_window) + + def clean_up(): + # Shut down the timer cleanly + self.timer.stop() + # clipboard persistence. see http://www.mail-archive.com/pyqt@riverbankcomputing.com/msg17328.html + event = QtCore.QEvent(QtCore.QEvent.Clipboard) + self.app.sendEvent(self.app.clipboard(), event) + self.tray.hide() + self.app.aboutToQuit.connect(clean_up) + + # main loop + self.app.exec_() + # on some platforms the exec_ call may not return, so use clean_up() DIR diff --git a/gui/qt/address_dialog.py b/electrum/gui/qt/address_dialog.py DIR diff --git a/electrum/gui/qt/address_list.py b/electrum/gui/qt/address_list.py t@@ -0,0 +1,195 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2015 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import webbrowser + +from electrum.i18n import _ +from electrum.util import block_explorer_URL +from electrum.plugin import run_hook +from electrum.bitcoin import is_address + +from .util import * + + +class AddressList(MyTreeWidget): + filter_columns = [0, 1, 2, 3] # Type, Address, Label, Balance + + def __init__(self, parent=None): + MyTreeWidget.__init__(self, parent, self.create_menu, [], 2) + self.refresh_headers() + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setSortingEnabled(True) + self.show_change = 0 + self.show_used = 0 + self.change_button = QComboBox(self) + self.change_button.currentIndexChanged.connect(self.toggle_change) + for t in [_('All'), _('Receiving'), _('Change')]: + self.change_button.addItem(t) + self.used_button = QComboBox(self) + self.used_button.currentIndexChanged.connect(self.toggle_used) + for t in [_('All'), _('Unused'), _('Funded'), _('Used')]: + self.used_button.addItem(t) + + def get_toolbar_buttons(self): + return QLabel(_("Filter:")), self.change_button, self.used_button + + def on_hide_toolbar(self): + self.show_change = 0 + self.show_used = 0 + self.update() + + def save_toolbar_state(self, state, config): + config.set_key('show_toolbar_addresses', state) + + def refresh_headers(self): + headers = [_('Type'), _('Address'), _('Label'), _('Balance')] + fx = self.parent.fx + if fx and fx.get_fiat_address_config(): + headers.extend([_(fx.get_currency()+' Balance')]) + headers.extend([_('Tx')]) + self.update_headers(headers) + + def toggle_change(self, state): + if state == self.show_change: + return + self.show_change = state + self.update() + + def toggle_used(self, state): + if state == self.show_used: + return + self.show_used = state + self.update() + + def on_update(self): + self.wallet = self.parent.wallet + item = self.currentItem() + current_address = item.data(0, Qt.UserRole) if item else None + if self.show_change == 1: + addr_list = self.wallet.get_receiving_addresses() + elif self.show_change == 2: + addr_list = self.wallet.get_change_addresses() + else: + addr_list = self.wallet.get_addresses() + self.clear() + for address in addr_list: + num = len(self.wallet.get_address_history(address)) + is_used = self.wallet.is_used(address) + label = self.wallet.labels.get(address, '') + c, u, x = self.wallet.get_addr_balance(address) + balance = c + u + x + if self.show_used == 1 and (balance or is_used): + continue + if self.show_used == 2 and balance == 0: + continue + if self.show_used == 3 and not is_used: + continue + balance_text = self.parent.format_amount(balance, whitespaces=True) + fx = self.parent.fx + # create item + if fx and fx.get_fiat_address_config(): + rate = fx.exchange_rate() + fiat_balance = fx.value_str(balance, rate) + address_item = SortableTreeWidgetItem(['', address, label, balance_text, fiat_balance, "%d"%num]) + else: + address_item = SortableTreeWidgetItem(['', address, label, balance_text, "%d"%num]) + # align text and set fonts + for i in range(address_item.columnCount()): + address_item.setTextAlignment(i, Qt.AlignVCenter) + if i not in (0, 2): + address_item.setFont(i, QFont(MONOSPACE_FONT)) + if fx and fx.get_fiat_address_config(): + address_item.setTextAlignment(4, Qt.AlignRight | Qt.AlignVCenter) + # setup column 0 + if self.wallet.is_change(address): + address_item.setText(0, _('change')) + address_item.setBackground(0, ColorScheme.YELLOW.as_color(True)) + else: + address_item.setText(0, _('receiving')) + address_item.setBackground(0, ColorScheme.GREEN.as_color(True)) + address_item.setData(0, Qt.UserRole, address) # column 0; independent from address column + # setup column 1 + if self.wallet.is_frozen(address): + address_item.setBackground(1, ColorScheme.BLUE.as_color(True)) + if self.wallet.is_beyond_limit(address): + address_item.setBackground(1, ColorScheme.RED.as_color(True)) + # add item + self.addChild(address_item) + if address == current_address: + self.setCurrentItem(address_item) + + def create_menu(self, position): + from electrum.wallet import Multisig_Wallet + is_multisig = isinstance(self.wallet, Multisig_Wallet) + can_delete = self.wallet.can_delete_address() + selected = self.selectedItems() + multi_select = len(selected) > 1 + addrs = [item.text(1) for item in selected] + if not addrs: + return + if not multi_select: + item = self.itemAt(position) + col = self.currentColumn() + if not item: + return + addr = addrs[0] + if not is_address(addr): + item.setExpanded(not item.isExpanded()) + return + + menu = QMenu() + if not multi_select: + column_title = self.headerItem().text(col) + copy_text = item.text(col) + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(copy_text)) + menu.addAction(_('Details'), lambda: self.parent.show_address(addr)) + if col in self.editable_columns: + menu.addAction(_("Edit {}").format(column_title), lambda: self.editItem(item, col)) + menu.addAction(_("Request payment"), lambda: self.parent.receive_at(addr)) + if self.wallet.can_export(): + menu.addAction(_("Private key"), lambda: self.parent.show_private_key(addr)) + if not is_multisig and not self.wallet.is_watching_only(): + menu.addAction(_("Sign/verify message"), lambda: self.parent.sign_verify_message(addr)) + menu.addAction(_("Encrypt/decrypt message"), lambda: self.parent.encrypt_message(addr)) + if can_delete: + menu.addAction(_("Remove from wallet"), lambda: self.parent.remove_address(addr)) + addr_URL = block_explorer_URL(self.config, 'addr', addr) + if addr_URL: + menu.addAction(_("View on block explorer"), lambda: webbrowser.open(addr_URL)) + + if not self.wallet.is_frozen(addr): + menu.addAction(_("Freeze"), lambda: self.parent.set_frozen_state([addr], True)) + else: + menu.addAction(_("Unfreeze"), lambda: self.parent.set_frozen_state([addr], False)) + + coins = self.wallet.get_utxos(addrs) + if coins: + menu.addAction(_("Spend from"), lambda: self.parent.spend_coins(coins)) + + run_hook('receive_menu', menu, addrs, self.wallet) + menu.exec_(self.viewport().mapToGlobal(position)) + + def on_permit_edit(self, item, column): + # labels for headings, e.g. "receiving" or "used" should not be editable + return item.childCount() == 0 DIR diff --git a/gui/qt/amountedit.py b/electrum/gui/qt/amountedit.py DIR diff --git a/electrum/gui/qt/completion_text_edit.py b/electrum/gui/qt/completion_text_edit.py t@@ -0,0 +1,120 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2018 The Electrum developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from PyQt5.QtGui import * +from PyQt5.QtCore import * +from PyQt5.QtWidgets import * +from .util import ButtonsTextEdit + +class CompletionTextEdit(ButtonsTextEdit): + + def __init__(self, parent=None): + super(CompletionTextEdit, self).__init__(parent) + self.completer = None + self.moveCursor(QTextCursor.End) + self.disable_suggestions() + + def set_completer(self, completer): + self.completer = completer + self.initialize_completer() + + def initialize_completer(self): + self.completer.setWidget(self) + self.completer.setCompletionMode(QCompleter.PopupCompletion) + self.completer.activated.connect(self.insert_completion) + self.enable_suggestions() + + def insert_completion(self, completion): + if self.completer.widget() != self: + return + text_cursor = self.textCursor() + extra = len(completion) - len(self.completer.completionPrefix()) + text_cursor.movePosition(QTextCursor.Left) + text_cursor.movePosition(QTextCursor.EndOfWord) + if extra == 0: + text_cursor.insertText(" ") + else: + text_cursor.insertText(completion[-extra:] + " ") + self.setTextCursor(text_cursor) + + def text_under_cursor(self): + tc = self.textCursor() + tc.select(QTextCursor.WordUnderCursor) + return tc.selectedText() + + def enable_suggestions(self): + self.suggestions_enabled = True + + def disable_suggestions(self): + self.suggestions_enabled = False + + def keyPressEvent(self, e): + if self.isReadOnly(): + return + + if self.is_special_key(e): + e.ignore() + return + + QPlainTextEdit.keyPressEvent(self, e) + + ctrlOrShift = e.modifiers() and (Qt.ControlModifier or Qt.ShiftModifier) + if self.completer is None or (ctrlOrShift and not e.text()): + return + + if not self.suggestions_enabled: + return + + eow = "~!@#$%^&*()_+{}|:\"<>?,./;'[]\\-=" + hasModifier = (e.modifiers() != Qt.NoModifier) and not ctrlOrShift + completionPrefix = self.text_under_cursor() + + if hasModifier or not e.text() or len(completionPrefix) < 1 or eow.find(e.text()[-1]) >= 0: + self.completer.popup().hide() + return + + if completionPrefix != self.completer.completionPrefix(): + self.completer.setCompletionPrefix(completionPrefix) + self.completer.popup().setCurrentIndex(self.completer.completionModel().index(0, 0)) + + cr = self.cursorRect() + cr.setWidth(self.completer.popup().sizeHintForColumn(0) + self.completer.popup().verticalScrollBar().sizeHint().width()) + self.completer.complete(cr) + + def is_special_key(self, e): + if self.completer != None and self.completer.popup().isVisible(): + if e.key() in [Qt.Key_Enter, Qt.Key_Return]: + return True + if e.key() in [Qt.Key_Tab, Qt.Key_Down, Qt.Key_Up]: + return True + return False + +if __name__ == "__main__": + app = QApplication([]) + completer = QCompleter(["alabama", "arkansas", "avocado", "breakfast", "sausage"]) + te = CompletionTextEdit() + te.set_completer(completer) + te.show() + app.exec_() DIR diff --git a/gui/qt/console.py b/electrum/gui/qt/console.py DIR diff --git a/electrum/gui/qt/contact_list.py b/electrum/gui/qt/contact_list.py t@@ -0,0 +1,98 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2015 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import webbrowser + +from electrum.i18n import _ +from electrum.bitcoin import is_address +from electrum.util import block_explorer_URL +from electrum.plugin import run_hook +from PyQt5.QtGui import * +from PyQt5.QtCore import * +from PyQt5.QtWidgets import ( + QAbstractItemView, QFileDialog, QMenu, QTreeWidgetItem) +from .util import MyTreeWidget, import_meta_gui, export_meta_gui + + +class ContactList(MyTreeWidget): + filter_columns = [0, 1] # Key, Value + + def __init__(self, parent): + MyTreeWidget.__init__(self, parent, self.create_menu, [_('Name'), _('Address')], 0, [0]) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setSortingEnabled(True) + + def on_permit_edit(self, item, column): + # openalias items shouldn't be editable + return item.text(1) != "openalias" + + def on_edited(self, item, column, prior): + if column == 0: # Remove old contact if renamed + self.parent.contacts.pop(prior) + self.parent.set_contact(item.text(0), item.text(1)) + + def import_contacts(self): + import_meta_gui(self.parent, _('contacts'), self.parent.contacts.import_file, self.on_update) + + def export_contacts(self): + export_meta_gui(self.parent, _('contacts'), self.parent.contacts.export_file) + + def create_menu(self, position): + menu = QMenu() + selected = self.selectedItems() + if not selected: + menu.addAction(_("New contact"), lambda: self.parent.new_contact_dialog()) + menu.addAction(_("Import file"), lambda: self.import_contacts()) + menu.addAction(_("Export file"), lambda: self.export_contacts()) + else: + names = [item.text(0) for item in selected] + keys = [item.text(1) for item in selected] + column = self.currentColumn() + column_title = self.headerItem().text(column) + column_data = '\n'.join([item.text(column) for item in selected]) + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) + if column in self.editable_columns: + item = self.currentItem() + menu.addAction(_("Edit {}").format(column_title), lambda: self.editItem(item, column)) + menu.addAction(_("Pay to"), lambda: self.parent.payto_contacts(keys)) + menu.addAction(_("Delete"), lambda: self.parent.delete_contacts(keys)) + URLs = [block_explorer_URL(self.config, 'addr', key) for key in filter(is_address, keys)] + if URLs: + menu.addAction(_("View on block explorer"), lambda: map(webbrowser.open, URLs)) + + run_hook('create_contact_menu', menu, selected) + menu.exec_(self.viewport().mapToGlobal(position)) + + def on_update(self): + item = self.currentItem() + current_key = item.data(0, Qt.UserRole) if item else None + self.clear() + for key in sorted(self.parent.contacts.keys()): + _type, name = self.parent.contacts[key] + item = QTreeWidgetItem([name, key]) + item.setData(0, Qt.UserRole, key) + self.addTopLevelItem(item) + if key == current_key: + self.setCurrentItem(item) + run_hook('update_contacts_tab', self) DIR diff --git a/gui/qt/exception_window.py b/electrum/gui/qt/exception_window.py DIR diff --git a/gui/qt/fee_slider.py b/electrum/gui/qt/fee_slider.py DIR diff --git a/gui/qt/history_list.py b/electrum/gui/qt/history_list.py DIR diff --git a/electrum/gui/qt/installwizard.py b/electrum/gui/qt/installwizard.py t@@ -0,0 +1,644 @@ + +import os +import sys +import threading +import traceback + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electrum.wallet import Wallet +from electrum.storage import WalletStorage +from electrum.util import UserCancelled, InvalidPassword +from electrum.base_wizard import BaseWizard, HWD_SETUP_DECRYPT_WALLET, GoBack +from electrum.i18n import _ + +from .seed_dialog import SeedLayout, KeysLayout +from .network_dialog import NetworkChoiceLayout +from .util import * +from .password_dialog import PasswordLayout, PasswordLayoutForHW, PW_NEW + + +MSG_ENTER_PASSWORD = _("Choose a password to encrypt your wallet keys.") + '\n'\ + + _("Leave this field empty if you want to disable encryption.") +MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\ + + _("Your wallet file does not contain secrets, mostly just metadata. ") \ + + _("It also contains your master public key that allows watching your addresses.") + '\n\n'\ + + _("Note: If you enable this setting, you will need your hardware device to open your wallet.") +WIF_HELP_TEXT = (_('WIF keys are typed in Electrum, based on script type.') + '\n\n' + + _('A few examples') + ':\n' + + 'p2pkh:KxZcY47uGp9a... \t-> 1DckmggQM...\n' + + 'p2wpkh-p2sh:KxZcY47uGp9a... \t-> 3NhNeZQXF...\n' + + 'p2wpkh:KxZcY47uGp9a... \t-> bc1q3fjfk...') +# note: full key is KxZcY47uGp9aVQAb6VVvuBs8SwHKgkSR2DbZUzjDzXf2N2GPhG9n + + +class CosignWidget(QWidget): + size = 120 + + def __init__(self, m, n): + QWidget.__init__(self) + self.R = QRect(0, 0, self.size, self.size) + self.setGeometry(self.R) + self.setMinimumHeight(self.size) + self.setMaximumHeight(self.size) + self.m = m + self.n = n + + def set_n(self, n): + self.n = n + self.update() + + def set_m(self, m): + self.m = m + self.update() + + def paintEvent(self, event): + bgcolor = self.palette().color(QPalette.Background) + pen = QPen(bgcolor, 7, Qt.SolidLine) + qp = QPainter() + qp.begin(self) + qp.setPen(pen) + qp.setRenderHint(QPainter.Antialiasing) + qp.setBrush(Qt.gray) + for i in range(self.n): + alpha = int(16* 360 * i/self.n) + alpha2 = int(16* 360 * 1/self.n) + qp.setBrush(Qt.green if i<self.m else Qt.gray) + qp.drawPie(self.R, alpha, alpha2) + qp.end() + + + +def wizard_dialog(func): + def func_wrapper(*args, **kwargs): + run_next = kwargs['run_next'] + wizard = args[0] + wizard.back_button.setText(_('Back') if wizard.can_go_back() else _('Cancel')) + try: + out = func(*args, **kwargs) + except GoBack: + wizard.go_back() if wizard.can_go_back() else wizard.close() + return + except UserCancelled: + return + #if out is None: + # out = () + if type(out) is not tuple: + out = (out,) + run_next(*out) + return func_wrapper + + + +# WindowModalDialog must come first as it overrides show_error +class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): + + accept_signal = pyqtSignal() + synchronized_signal = pyqtSignal(str) + + def __init__(self, config, app, plugins, storage): + BaseWizard.__init__(self, config, plugins, storage) + QDialog.__init__(self, None) + self.setWindowTitle('Electrum - ' + _('Install Wizard')) + self.app = app + self.config = config + # Set for base base class + self.language_for_seed = config.get('language') + self.setMinimumSize(600, 400) + self.accept_signal.connect(self.accept) + self.title = QLabel() + self.main_widget = QWidget() + self.back_button = QPushButton(_("Back"), self) + self.back_button.setText(_('Back') if self.can_go_back() else _('Cancel')) + self.next_button = QPushButton(_("Next"), self) + self.next_button.setDefault(True) + self.logo = QLabel() + self.please_wait = QLabel(_("Please wait...")) + self.please_wait.setAlignment(Qt.AlignCenter) + self.icon_filename = None + self.loop = QEventLoop() + self.rejected.connect(lambda: self.loop.exit(0)) + self.back_button.clicked.connect(lambda: self.loop.exit(1)) + self.next_button.clicked.connect(lambda: self.loop.exit(2)) + outer_vbox = QVBoxLayout(self) + inner_vbox = QVBoxLayout() + inner_vbox.addWidget(self.title) + inner_vbox.addWidget(self.main_widget) + inner_vbox.addStretch(1) + inner_vbox.addWidget(self.please_wait) + inner_vbox.addStretch(1) + scroll_widget = QWidget() + scroll_widget.setLayout(inner_vbox) + scroll = QScrollArea() + scroll.setWidget(scroll_widget) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setWidgetResizable(True) + icon_vbox = QVBoxLayout() + icon_vbox.addWidget(self.logo) + icon_vbox.addStretch(1) + hbox = QHBoxLayout() + hbox.addLayout(icon_vbox) + hbox.addSpacing(5) + hbox.addWidget(scroll) + hbox.setStretchFactor(scroll, 1) + outer_vbox.addLayout(hbox) + outer_vbox.addLayout(Buttons(self.back_button, self.next_button)) + self.set_icon(':icons/electrum.png') + self.show() + self.raise_() + self.refresh_gui() # Need for QT on MacOSX. Lame. + + def run_and_get_wallet(self, get_wallet_from_daemon): + + vbox = QVBoxLayout() + hbox = QHBoxLayout() + hbox.addWidget(QLabel(_('Wallet') + ':')) + self.name_e = QLineEdit() + hbox.addWidget(self.name_e) + button = QPushButton(_('Choose...')) + hbox.addWidget(button) + vbox.addLayout(hbox) + + self.msg_label = QLabel('') + vbox.addWidget(self.msg_label) + hbox2 = QHBoxLayout() + self.pw_e = QLineEdit('', self) + self.pw_e.setFixedWidth(150) + self.pw_e.setEchoMode(2) + self.pw_label = QLabel(_('Password') + ':') + hbox2.addWidget(self.pw_label) + hbox2.addWidget(self.pw_e) + hbox2.addStretch() + vbox.addLayout(hbox2) + self.set_layout(vbox, title=_('Electrum wallet')) + + wallet_folder = os.path.dirname(self.storage.path) + + def on_choose(): + path, __ = QFileDialog.getOpenFileName(self, "Select your wallet file", wallet_folder) + if path: + self.name_e.setText(path) + + def on_filename(filename): + path = os.path.join(wallet_folder, filename) + wallet_from_memory = get_wallet_from_daemon(path) + try: + if wallet_from_memory: + self.storage = wallet_from_memory.storage + else: + self.storage = WalletStorage(path, manual_upgrades=True) + self.next_button.setEnabled(True) + except BaseException: + traceback.print_exc(file=sys.stderr) + self.storage = None + self.next_button.setEnabled(False) + if self.storage: + if not self.storage.file_exists(): + msg =_("This file does not exist.") + '\n' \ + + _("Press 'Next' to create this wallet, or choose another file.") + pw = False + elif not wallet_from_memory: + if self.storage.is_encrypted_with_user_pw(): + msg = _("This file is encrypted with a password.") + '\n' \ + + _('Enter your password or choose another file.') + pw = True + elif self.storage.is_encrypted_with_hw_device(): + msg = _("This file is encrypted using a hardware device.") + '\n' \ + + _("Press 'Next' to choose device to decrypt.") + pw = False + else: + msg = _("Press 'Next' to open this wallet.") + pw = False + else: + msg = _("This file is already open in memory.") + "\n" \ + + _("Press 'Next' to create/focus window.") + pw = False + else: + msg = _('Cannot read file') + pw = False + self.msg_label.setText(msg) + if pw: + self.pw_label.show() + self.pw_e.show() + self.pw_e.setFocus() + else: + self.pw_label.hide() + self.pw_e.hide() + + button.clicked.connect(on_choose) + self.name_e.textChanged.connect(on_filename) + n = os.path.basename(self.storage.path) + self.name_e.setText(n) + + while True: + if self.loop.exec_() != 2: # 2 = next + return + if self.storage.file_exists() and not self.storage.is_encrypted(): + break + if not self.storage.file_exists(): + break + wallet_from_memory = get_wallet_from_daemon(self.storage.path) + if wallet_from_memory: + return wallet_from_memory + if self.storage.file_exists() and self.storage.is_encrypted(): + if self.storage.is_encrypted_with_user_pw(): + password = self.pw_e.text() + try: + self.storage.decrypt(password) + break + except InvalidPassword as e: + QMessageBox.information(None, _('Error'), str(e)) + continue + except BaseException as e: + traceback.print_exc(file=sys.stdout) + QMessageBox.information(None, _('Error'), str(e)) + return + elif self.storage.is_encrypted_with_hw_device(): + try: + self.run('choose_hw_device', HWD_SETUP_DECRYPT_WALLET) + except InvalidPassword as e: + QMessageBox.information( + None, _('Error'), + _('Failed to decrypt using this hardware device.') + '\n' + + _('If you use a passphrase, make sure it is correct.')) + self.stack = [] + return self.run_and_get_wallet(get_wallet_from_daemon) + except BaseException as e: + traceback.print_exc(file=sys.stdout) + QMessageBox.information(None, _('Error'), str(e)) + return + if self.storage.is_past_initial_decryption(): + break + else: + return + else: + raise Exception('Unexpected encryption version') + + path = self.storage.path + if self.storage.requires_split(): + self.hide() + msg = _("The wallet '{}' contains multiple accounts, which are no longer supported since Electrum 2.7.\n\n" + "Do you want to split your wallet into multiple files?").format(path) + if not self.question(msg): + return + file_list = '\n'.join(self.storage.split_accounts()) + msg = _('Your accounts have been moved to') + ':\n' + file_list + '\n\n'+ _('Do you want to delete the old file') + ':\n' + path + if self.question(msg): + os.remove(path) + self.show_warning(_('The file was removed')) + return + + action = self.storage.get_action() + if action and action not in ('new', 'upgrade_storage'): + self.hide() + msg = _("The file '{}' contains an incompletely created wallet.\n" + "Do you want to complete its creation now?").format(path) + if not self.question(msg): + if self.question(_("Do you want to delete '{}'?").format(path)): + os.remove(path) + self.show_warning(_('The file was removed')) + return + self.show() + if action: + # self.wallet is set in run + self.run(action) + return self.wallet + + self.wallet = Wallet(self.storage) + return self.wallet + + def finished(self): + """Called in hardware client wrapper, in order to close popups.""" + return + + def on_error(self, exc_info): + if not isinstance(exc_info[1], UserCancelled): + traceback.print_exception(*exc_info) + self.show_error(str(exc_info[1])) + + def set_icon(self, filename): + prior_filename, self.icon_filename = self.icon_filename, filename + self.logo.setPixmap(QPixmap(filename).scaledToWidth(60, mode=Qt.SmoothTransformation)) + return prior_filename + + def set_layout(self, layout, title=None, next_enabled=True): + self.title.setText("<b>%s</b>"%title if title else "") + self.title.setVisible(bool(title)) + # Get rid of any prior layout by assigning it to a temporary widget + prior_layout = self.main_widget.layout() + if prior_layout: + QWidget().setLayout(prior_layout) + self.main_widget.setLayout(layout) + self.back_button.setEnabled(True) + self.next_button.setEnabled(next_enabled) + if next_enabled: + self.next_button.setFocus() + self.main_widget.setVisible(True) + self.please_wait.setVisible(False) + + def exec_layout(self, layout, title=None, raise_on_cancel=True, + next_enabled=True): + self.set_layout(layout, title, next_enabled) + result = self.loop.exec_() + if not result and raise_on_cancel: + raise UserCancelled + if result == 1: + raise GoBack from None + self.title.setVisible(False) + self.back_button.setEnabled(False) + self.next_button.setEnabled(False) + self.main_widget.setVisible(False) + self.please_wait.setVisible(True) + self.refresh_gui() + return result + + def refresh_gui(self): + # For some reason, to refresh the GUI this needs to be called twice + self.app.processEvents() + self.app.processEvents() + + def remove_from_recently_open(self, filename): + self.config.remove_from_recently_open(filename) + + def text_input(self, title, message, is_valid, allow_multi=False): + slayout = KeysLayout(parent=self, header_layout=message, is_valid=is_valid, + allow_multi=allow_multi) + self.exec_layout(slayout, title, next_enabled=False) + return slayout.get_text() + + def seed_input(self, title, message, is_seed, options): + slayout = SeedLayout(title=message, is_seed=is_seed, options=options, parent=self) + self.exec_layout(slayout, title, next_enabled=False) + return slayout.get_seed(), slayout.is_bip39, slayout.is_ext + + @wizard_dialog + def add_xpub_dialog(self, title, message, is_valid, run_next, allow_multi=False, show_wif_help=False): + header_layout = QHBoxLayout() + label = WWLabel(message) + label.setMinimumWidth(400) + header_layout.addWidget(label) + if show_wif_help: + header_layout.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignRight) + return self.text_input(title, header_layout, is_valid, allow_multi) + + @wizard_dialog + def add_cosigner_dialog(self, run_next, index, is_valid): + title = _("Add Cosigner") + " %d"%index + message = ' '.join([ + _('Please enter the master public key (xpub) of your cosigner.'), + _('Enter their master private key (xprv) if you want to be able to sign for them.') + ]) + return self.text_input(title, message, is_valid) + + @wizard_dialog + def restore_seed_dialog(self, run_next, test): + options = [] + if self.opt_ext: + options.append('ext') + if self.opt_bip39: + options.append('bip39') + title = _('Enter Seed') + message = _('Please enter your seed phrase in order to restore your wallet.') + return self.seed_input(title, message, test, options) + + @wizard_dialog + def confirm_seed_dialog(self, run_next, test): + self.app.clipboard().clear() + title = _('Confirm Seed') + message = ' '.join([ + _('Your seed is important!'), + _('If you lose your seed, your money will be permanently lost.'), + _('To make sure that you have properly saved your seed, please retype it here.') + ]) + seed, is_bip39, is_ext = self.seed_input(title, message, test, None) + return seed + + @wizard_dialog + def show_seed_dialog(self, run_next, seed_text): + title = _("Your wallet generation seed is:") + slayout = SeedLayout(seed=seed_text, title=title, msg=True, options=['ext']) + self.exec_layout(slayout) + return slayout.is_ext + + def pw_layout(self, msg, kind, force_disable_encrypt_cb): + playout = PasswordLayout(None, msg, kind, self.next_button, + force_disable_encrypt_cb=force_disable_encrypt_cb) + playout.encrypt_cb.setChecked(True) + self.exec_layout(playout.layout()) + return playout.new_password(), playout.encrypt_cb.isChecked() + + @wizard_dialog + def request_password(self, run_next, force_disable_encrypt_cb=False): + """Request the user enter a new password and confirm it. Return + the password or None for no password.""" + return self.pw_layout(MSG_ENTER_PASSWORD, PW_NEW, force_disable_encrypt_cb) + + @wizard_dialog + def request_storage_encryption(self, run_next): + playout = PasswordLayoutForHW(None, MSG_HW_STORAGE_ENCRYPTION, PW_NEW, self.next_button) + playout.encrypt_cb.setChecked(True) + self.exec_layout(playout.layout()) + return playout.encrypt_cb.isChecked() + + def show_restore(self, wallet, network): + # FIXME: these messages are shown after the install wizard is + # finished and the window closed. On macOS they appear parented + # with a re-appeared ghost install wizard window... + if network: + def task(): + wallet.wait_until_synchronized() + if wallet.is_found(): + msg = _("Recovery successful") + else: + msg = _("No transactions found for this seed") + self.synchronized_signal.emit(msg) + self.synchronized_signal.connect(self.show_message) + t = threading.Thread(target = task) + t.daemon = True + t.start() + else: + msg = _("This wallet was restored offline. It may " + "contain more addresses than displayed.") + self.show_message(msg) + + @wizard_dialog + def confirm_dialog(self, title, message, run_next): + self.confirm(message, title) + + def confirm(self, message, title): + label = WWLabel(message) + vbox = QVBoxLayout() + vbox.addWidget(label) + self.exec_layout(vbox, title) + + @wizard_dialog + def action_dialog(self, action, run_next): + self.run(action) + + def terminate(self): + self.accept_signal.emit() + + def waiting_dialog(self, task, msg, on_finished=None): + label = WWLabel(msg) + vbox = QVBoxLayout() + vbox.addSpacing(100) + label.setMinimumWidth(300) + label.setAlignment(Qt.AlignCenter) + vbox.addWidget(label) + self.set_layout(vbox, next_enabled=False) + self.back_button.setEnabled(False) + + t = threading.Thread(target=task) + t.start() + while True: + t.join(1.0/60) + if t.is_alive(): + self.refresh_gui() + else: + break + if on_finished: + on_finished() + + @wizard_dialog + def choice_dialog(self, title, message, choices, run_next): + c_values = [x[0] for x in choices] + c_titles = [x[1] for x in choices] + clayout = ChoicesLayout(message, c_titles) + vbox = QVBoxLayout() + vbox.addLayout(clayout.layout()) + self.exec_layout(vbox, title) + action = c_values[clayout.selected_index()] + return action + + def query_choice(self, msg, choices): + """called by hardware wallets""" + clayout = ChoicesLayout(msg, choices) + vbox = QVBoxLayout() + vbox.addLayout(clayout.layout()) + self.exec_layout(vbox, '') + return clayout.selected_index() + + @wizard_dialog + def choice_and_line_dialog(self, title, message1, choices, message2, + test_text, run_next) -> (str, str): + vbox = QVBoxLayout() + + c_values = [x[0] for x in choices] + c_titles = [x[1] for x in choices] + c_default_text = [x[2] for x in choices] + def on_choice_click(clayout): + idx = clayout.selected_index() + line.setText(c_default_text[idx]) + clayout = ChoicesLayout(message1, c_titles, on_choice_click) + vbox.addLayout(clayout.layout()) + + vbox.addSpacing(50) + vbox.addWidget(WWLabel(message2)) + + line = QLineEdit() + def on_text_change(text): + self.next_button.setEnabled(test_text(text)) + line.textEdited.connect(on_text_change) + on_choice_click(clayout) # set default text for "line" + vbox.addWidget(line) + + self.exec_layout(vbox, title) + choice = c_values[clayout.selected_index()] + return str(line.text()), choice + + @wizard_dialog + def line_dialog(self, run_next, title, message, default, test, warning='', + presets=()): + vbox = QVBoxLayout() + vbox.addWidget(WWLabel(message)) + line = QLineEdit() + line.setText(default) + def f(text): + self.next_button.setEnabled(test(text)) + line.textEdited.connect(f) + vbox.addWidget(line) + vbox.addWidget(WWLabel(warning)) + + for preset in presets: + button = QPushButton(preset[0]) + button.clicked.connect(lambda __, text=preset[1]: line.setText(text)) + button.setMinimumWidth(150) + hbox = QHBoxLayout() + hbox.addWidget(button, alignment=Qt.AlignCenter) + vbox.addLayout(hbox) + + self.exec_layout(vbox, title, next_enabled=test(default)) + return ' '.join(line.text().split()) + + @wizard_dialog + def show_xpub_dialog(self, xpub, run_next): + msg = ' '.join([ + _("Here is your master public key."), + _("Please share it with your cosigners.") + ]) + vbox = QVBoxLayout() + layout = SeedLayout(xpub, title=msg, icon=False, for_seed_words=False) + vbox.addLayout(layout.layout()) + self.exec_layout(vbox, _('Master Public Key')) + return None + + def init_network(self, network): + message = _("Electrum communicates with remote servers to get " + "information about your transactions and addresses. The " + "servers all fulfill the same purpose only differing in " + "hardware. In most cases you simply want to let Electrum " + "pick one at random. However if you prefer feel free to " + "select a server manually.") + choices = [_("Auto connect"), _("Select server manually")] + title = _("How do you want to connect to a server? ") + clayout = ChoicesLayout(message, choices) + self.back_button.setText(_('Cancel')) + self.exec_layout(clayout.layout(), title) + r = clayout.selected_index() + if r == 1: + nlayout = NetworkChoiceLayout(network, self.config, wizard=True) + if self.exec_layout(nlayout.layout()): + nlayout.accept() + else: + network.auto_connect = True + self.config.set_key('auto_connect', True, True) + + @wizard_dialog + def multisig_dialog(self, run_next): + cw = CosignWidget(2, 2) + m_edit = QSlider(Qt.Horizontal, self) + n_edit = QSlider(Qt.Horizontal, self) + n_edit.setMinimum(2) + n_edit.setMaximum(15) + m_edit.setMinimum(1) + m_edit.setMaximum(2) + n_edit.setValue(2) + m_edit.setValue(2) + n_label = QLabel() + m_label = QLabel() + grid = QGridLayout() + grid.addWidget(n_label, 0, 0) + grid.addWidget(n_edit, 0, 1) + grid.addWidget(m_label, 1, 0) + grid.addWidget(m_edit, 1, 1) + def on_m(m): + m_label.setText(_('Require {0} signatures').format(m)) + cw.set_m(m) + def on_n(n): + n_label.setText(_('From {0} cosigners').format(n)) + cw.set_n(n) + m_edit.setMaximum(n) + n_edit.valueChanged.connect(on_n) + m_edit.valueChanged.connect(on_m) + on_n(2) + on_m(2) + vbox = QVBoxLayout() + vbox.addWidget(cw) + vbox.addWidget(WWLabel(_("Choose the number of signatures needed to unlock funds in your wallet:"))) + vbox.addLayout(grid) + self.exec_layout(vbox, _("Multi-Signature Wallet")) + m = int(m_edit.value()) + n = int(n_edit.value()) + return (m, n) DIR diff --git a/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py DIR diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py t@@ -0,0 +1,3220 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2012 thomasv@gitorious +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import sys, time, threading +import os, json, traceback +import shutil +import weakref +import webbrowser +import csv +from decimal import Decimal +import base64 +from functools import partial + +from PyQt5.QtGui import * +from PyQt5.QtCore import * +import PyQt5.QtCore as QtCore + +from .exception_window import Exception_Hook +from PyQt5.QtWidgets import * + +from electrum import (keystore, simple_config, ecc, constants, util, bitcoin, commands, + coinchooser, paymentrequest) +from electrum.bitcoin import COIN, is_address, TYPE_ADDRESS +from electrum.plugin import run_hook +from electrum.i18n import _ +from electrum.util import (format_time, format_satoshis, format_fee_satoshis, + format_satoshis_plain, NotEnoughFunds, PrintError, + UserCancelled, NoDynamicFeeEstimates, profiler, + export_meta, import_meta, bh2u, bfh, InvalidPassword, + base_units, base_units_list, base_unit_name_to_decimal_point, + decimal_point_to_base_unit_name, quantize_feerate) +from electrum.transaction import Transaction +from electrum.wallet import Multisig_Wallet, AddTransactionException, CannotBumpFee + +from .amountedit import AmountEdit, BTCAmountEdit, MyLineEdit, FeerateEdit +from .qrcodewidget import QRCodeWidget, QRDialog +from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit +from .transaction_dialog import show_transaction +from .fee_slider import FeeSlider +from .util import * +from .installwizard import WIF_HELP_TEXT + + +class StatusBarButton(QPushButton): + def __init__(self, icon, tooltip, func): + QPushButton.__init__(self, icon, '') + self.setToolTip(tooltip) + self.setFlat(True) + self.setMaximumWidth(25) + self.clicked.connect(self.onPress) + self.func = func + self.setIconSize(QSize(25,25)) + + def onPress(self, checked=False): + '''Drops the unwanted PyQt5 "checked" argument''' + self.func() + + def keyPressEvent(self, e): + if e.key() == Qt.Key_Return: + self.func() + + +from electrum.paymentrequest import PR_PAID + + +class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): + + payment_request_ok_signal = pyqtSignal() + payment_request_error_signal = pyqtSignal() + notify_transactions_signal = pyqtSignal() + new_fx_quotes_signal = pyqtSignal() + new_fx_history_signal = pyqtSignal() + network_signal = pyqtSignal(str, object) + alias_received_signal = pyqtSignal() + computing_privkeys_signal = pyqtSignal() + show_privkeys_signal = pyqtSignal() + + def __init__(self, gui_object, wallet): + QMainWindow.__init__(self) + + self.gui_object = gui_object + self.config = config = gui_object.config + + self.setup_exception_hook() + + self.network = gui_object.daemon.network + self.fx = gui_object.daemon.fx + self.invoices = wallet.invoices + self.contacts = wallet.contacts + self.tray = gui_object.tray + self.app = gui_object.app + self.cleaned_up = False + self.is_max = False + self.payment_request = None + self.checking_accounts = False + self.qr_window = None + self.not_enough_funds = False + self.pluginsdialog = None + self.require_fee_update = False + self.tx_notifications = [] + self.tl_windows = [] + self.tx_external_keypairs = {} + + self.create_status_bar() + self.need_update = threading.Event() + + self.decimal_point = config.get('decimal_point', 5) + self.num_zeros = int(config.get('num_zeros',0)) + + self.completions = QStringListModel() + + self.tabs = tabs = QTabWidget(self) + self.send_tab = self.create_send_tab() + self.receive_tab = self.create_receive_tab() + self.addresses_tab = self.create_addresses_tab() + self.utxo_tab = self.create_utxo_tab() + self.console_tab = self.create_console_tab() + self.contacts_tab = self.create_contacts_tab() + tabs.addTab(self.create_history_tab(), QIcon(":icons/tab_history.png"), _('History')) + tabs.addTab(self.send_tab, QIcon(":icons/tab_send.png"), _('Send')) + tabs.addTab(self.receive_tab, QIcon(":icons/tab_receive.png"), _('Receive')) + + def add_optional_tab(tabs, tab, icon, description, name): + tab.tab_icon = icon + tab.tab_description = description + tab.tab_pos = len(tabs) + tab.tab_name = name + if self.config.get('show_{}_tab'.format(name), False): + tabs.addTab(tab, icon, description.replace("&", "")) + + add_optional_tab(tabs, self.addresses_tab, QIcon(":icons/tab_addresses.png"), _("&Addresses"), "addresses") + add_optional_tab(tabs, self.utxo_tab, QIcon(":icons/tab_coins.png"), _("Co&ins"), "utxo") + add_optional_tab(tabs, self.contacts_tab, QIcon(":icons/tab_contacts.png"), _("Con&tacts"), "contacts") + add_optional_tab(tabs, self.console_tab, QIcon(":icons/tab_console.png"), _("Con&sole"), "console") + + tabs.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setCentralWidget(tabs) + + if self.config.get("is_maximized"): + self.showMaximized() + + self.setWindowIcon(QIcon(":icons/electrum.png")) + self.init_menubar() + + wrtabs = weakref.proxy(tabs) + QShortcut(QKeySequence("Ctrl+W"), self, self.close) + QShortcut(QKeySequence("Ctrl+Q"), self, self.close) + QShortcut(QKeySequence("Ctrl+R"), self, self.update_wallet) + QShortcut(QKeySequence("Ctrl+PgUp"), self, lambda: wrtabs.setCurrentIndex((wrtabs.currentIndex() - 1)%wrtabs.count())) + QShortcut(QKeySequence("Ctrl+PgDown"), self, lambda: wrtabs.setCurrentIndex((wrtabs.currentIndex() + 1)%wrtabs.count())) + + for i in range(wrtabs.count()): + QShortcut(QKeySequence("Alt+" + str(i + 1)), self, lambda i=i: wrtabs.setCurrentIndex(i)) + + self.payment_request_ok_signal.connect(self.payment_request_ok) + self.payment_request_error_signal.connect(self.payment_request_error) + self.notify_transactions_signal.connect(self.notify_transactions) + self.history_list.setFocus(True) + + # network callbacks + if self.network: + self.network_signal.connect(self.on_network_qt) + interests = ['updated', 'new_transaction', 'status', + 'banner', 'verified', 'fee'] + # To avoid leaking references to "self" that prevent the + # window from being GC-ed when closed, callbacks should be + # methods of this class only, and specifically not be + # partials, lambdas or methods of subobjects. Hence... + self.network.register_callback(self.on_network, interests) + # set initial message + self.console.showMessage(self.network.banner) + self.network.register_callback(self.on_quotes, ['on_quotes']) + self.network.register_callback(self.on_history, ['on_history']) + self.new_fx_quotes_signal.connect(self.on_fx_quotes) + self.new_fx_history_signal.connect(self.on_fx_history) + + # update fee slider in case we missed the callback + self.fee_slider.update() + self.load_wallet(wallet) + self.connect_slots(gui_object.timer) + self.fetch_alias() + + def on_history(self, b): + self.new_fx_history_signal.emit() + + def setup_exception_hook(self): + Exception_Hook(self) + + def on_fx_history(self): + self.history_list.refresh_headers() + self.history_list.update() + self.address_list.update() + + def on_quotes(self, b): + self.new_fx_quotes_signal.emit() + + def on_fx_quotes(self): + self.update_status() + # Refresh edits with the new rate + edit = self.fiat_send_e if self.fiat_send_e.is_last_edited else self.amount_e + edit.textEdited.emit(edit.text()) + edit = self.fiat_receive_e if self.fiat_receive_e.is_last_edited else self.receive_amount_e + edit.textEdited.emit(edit.text()) + # History tab needs updating if it used spot + if self.fx.history_used_spot: + self.history_list.update() + + def toggle_tab(self, tab): + show = not self.config.get('show_{}_tab'.format(tab.tab_name), False) + self.config.set_key('show_{}_tab'.format(tab.tab_name), show) + item_text = (_("Hide") if show else _("Show")) + " " + tab.tab_description + tab.menu_action.setText(item_text) + if show: + # Find out where to place the tab + index = len(self.tabs) + for i in range(len(self.tabs)): + try: + if tab.tab_pos < self.tabs.widget(i).tab_pos: + index = i + break + except AttributeError: + pass + self.tabs.insertTab(index, tab, tab.tab_icon, tab.tab_description.replace("&", "")) + else: + i = self.tabs.indexOf(tab) + self.tabs.removeTab(i) + + def push_top_level_window(self, window): + '''Used for e.g. tx dialog box to ensure new dialogs are appropriately + parented. This used to be done by explicitly providing the parent + window, but that isn't something hardware wallet prompts know.''' + self.tl_windows.append(window) + + def pop_top_level_window(self, window): + self.tl_windows.remove(window) + + def top_level_window(self, test_func=None): + '''Do the right thing in the presence of tx dialog windows''' + override = self.tl_windows[-1] if self.tl_windows else None + if override and test_func and not test_func(override): + override = None # only override if ok for test_func + return self.top_level_window_recurse(override, test_func) + + def diagnostic_name(self): + return "%s/%s" % (PrintError.diagnostic_name(self), + self.wallet.basename() if self.wallet else "None") + + def is_hidden(self): + return self.isMinimized() or self.isHidden() + + def show_or_hide(self): + if self.is_hidden(): + self.bring_to_top() + else: + self.hide() + + def bring_to_top(self): + self.show() + self.raise_() + + def on_error(self, exc_info): + if not isinstance(exc_info[1], UserCancelled): + try: + traceback.print_exception(*exc_info) + except OSError: + pass # see #4418; try to at least show popup: + self.show_error(str(exc_info[1])) + + def on_network(self, event, *args): + if event == 'updated': + self.need_update.set() + self.gui_object.network_updated_signal_obj.network_updated_signal \ + .emit(event, args) + elif event == 'new_transaction': + self.tx_notifications.append(args[0]) + self.notify_transactions_signal.emit() + elif event in ['status', 'banner', 'verified', 'fee']: + # Handle in GUI thread + self.network_signal.emit(event, args) + else: + self.print_error("unexpected network message:", event, args) + + def on_network_qt(self, event, args=None): + # Handle a network message in the GUI thread + if event == 'status': + self.update_status() + elif event == 'banner': + self.console.showMessage(args[0]) + elif event == 'verified': + self.history_list.update_item(*args) + elif event == 'fee': + if self.config.is_dynfee(): + self.fee_slider.update() + self.do_update_fee() + elif event == 'fee_histogram': + if self.config.is_dynfee(): + self.fee_slider.update() + self.do_update_fee() + # todo: update only unconfirmed tx + self.history_list.update() + else: + self.print_error("unexpected network_qt signal:", event, args) + + def fetch_alias(self): + self.alias_info = None + alias = self.config.get('alias') + if alias: + alias = str(alias) + def f(): + self.alias_info = self.contacts.resolve_openalias(alias) + self.alias_received_signal.emit() + t = threading.Thread(target=f) + t.setDaemon(True) + t.start() + + def close_wallet(self): + if self.wallet: + self.print_error('close_wallet', self.wallet.storage.path) + run_hook('close_wallet', self.wallet) + + @profiler + def load_wallet(self, wallet): + wallet.thread = TaskThread(self, self.on_error) + self.wallet = wallet + self.update_recently_visited(wallet.storage.path) + # address used to create a dummy transaction and estimate transaction fee + self.history_list.update() + self.address_list.update() + self.utxo_list.update() + self.need_update.set() + # Once GUI has been initialized check if we want to announce something since the callback has been called before the GUI was initialized + self.notify_transactions() + # update menus + self.seed_menu.setEnabled(self.wallet.has_seed()) + self.update_lock_icon() + self.update_buttons_on_seed() + self.update_console() + self.clear_receive_tab() + self.request_list.update() + self.tabs.show() + self.init_geometry() + if self.config.get('hide_gui') and self.gui_object.tray.isVisible(): + self.hide() + else: + self.show() + self.watching_only_changed() + run_hook('load_wallet', wallet, self) + + def init_geometry(self): + winpos = self.wallet.storage.get("winpos-qt") + try: + screen = self.app.desktop().screenGeometry() + assert screen.contains(QRect(*winpos)) + self.setGeometry(*winpos) + except: + self.print_error("using default geometry") + self.setGeometry(100, 100, 840, 400) + + def watching_only_changed(self): + name = "Electrum Testnet" if constants.net.TESTNET else "Electrum" + title = '%s %s - %s' % (name, self.wallet.electrum_version, + self.wallet.basename()) + extra = [self.wallet.storage.get('wallet_type', '?')] + if self.wallet.is_watching_only(): + self.warn_if_watching_only() + extra.append(_('watching only')) + title += ' [%s]'% ', '.join(extra) + self.setWindowTitle(title) + self.password_menu.setEnabled(self.wallet.may_have_password()) + self.import_privkey_menu.setVisible(self.wallet.can_import_privkey()) + self.import_address_menu.setVisible(self.wallet.can_import_address()) + self.export_menu.setEnabled(self.wallet.can_export()) + + def warn_if_watching_only(self): + if self.wallet.is_watching_only(): + msg = ' '.join([ + _("This wallet is watching-only."), + _("This means you will not be able to spend Bitcoins with it."), + _("Make sure you own the seed phrase or the private keys, before you request Bitcoins to be sent to this wallet.") + ]) + self.show_warning(msg, title=_('Information')) + + def open_wallet(self): + try: + wallet_folder = self.get_wallet_folder() + except FileNotFoundError as e: + self.show_error(str(e)) + return + filename, __ = QFileDialog.getOpenFileName(self, "Select your wallet file", wallet_folder) + if not filename: + return + self.gui_object.new_window(filename) + + + def backup_wallet(self): + path = self.wallet.storage.path + wallet_folder = os.path.dirname(path) + filename, __ = QFileDialog.getSaveFileName(self, _('Enter a filename for the copy of your wallet'), wallet_folder) + if not filename: + return + new_path = os.path.join(wallet_folder, filename) + if new_path != path: + try: + shutil.copy2(path, new_path) + self.show_message(_("A copy of your wallet file was created in")+" '%s'" % str(new_path), title=_("Wallet backup created")) + except BaseException as reason: + self.show_critical(_("Electrum was unable to copy your wallet file to the specified location.") + "\n" + str(reason), title=_("Unable to create backup")) + + def update_recently_visited(self, filename): + recent = self.config.get('recently_open', []) + try: + sorted(recent) + except: + recent = [] + if filename in recent: + recent.remove(filename) + recent.insert(0, filename) + recent = recent[:5] + self.config.set_key('recently_open', recent) + self.recently_visited_menu.clear() + for i, k in enumerate(sorted(recent)): + b = os.path.basename(k) + def loader(k): + return lambda: self.gui_object.new_window(k) + self.recently_visited_menu.addAction(b, loader(k)).setShortcut(QKeySequence("Ctrl+%d"%(i+1))) + self.recently_visited_menu.setEnabled(len(recent)) + + def get_wallet_folder(self): + return os.path.dirname(os.path.abspath(self.config.get_wallet_path())) + + def new_wallet(self): + try: + wallet_folder = self.get_wallet_folder() + except FileNotFoundError as e: + self.show_error(str(e)) + return + i = 1 + while True: + filename = "wallet_%d" % i + if filename in os.listdir(wallet_folder): + i += 1 + else: + break + full_path = os.path.join(wallet_folder, filename) + self.gui_object.start_new_window(full_path, None) + + def init_menubar(self): + menubar = QMenuBar() + + file_menu = menubar.addMenu(_("&File")) + self.recently_visited_menu = file_menu.addMenu(_("&Recently open")) + file_menu.addAction(_("&Open"), self.open_wallet).setShortcut(QKeySequence.Open) + file_menu.addAction(_("&New/Restore"), self.new_wallet).setShortcut(QKeySequence.New) + file_menu.addAction(_("&Save Copy"), self.backup_wallet).setShortcut(QKeySequence.SaveAs) + file_menu.addAction(_("Delete"), self.remove_wallet) + file_menu.addSeparator() + file_menu.addAction(_("&Quit"), self.close) + + wallet_menu = menubar.addMenu(_("&Wallet")) + wallet_menu.addAction(_("&Information"), self.show_master_public_keys) + wallet_menu.addSeparator() + self.password_menu = wallet_menu.addAction(_("&Password"), self.change_password_dialog) + self.seed_menu = wallet_menu.addAction(_("&Seed"), self.show_seed_dialog) + self.private_keys_menu = wallet_menu.addMenu(_("&Private keys")) + self.private_keys_menu.addAction(_("&Sweep"), self.sweep_key_dialog) + self.import_privkey_menu = self.private_keys_menu.addAction(_("&Import"), self.do_import_privkey) + self.export_menu = self.private_keys_menu.addAction(_("&Export"), self.export_privkeys_dialog) + self.import_address_menu = wallet_menu.addAction(_("Import addresses"), self.import_addresses) + wallet_menu.addSeparator() + + addresses_menu = wallet_menu.addMenu(_("&Addresses")) + addresses_menu.addAction(_("&Filter"), lambda: self.address_list.toggle_toolbar(self.config)) + labels_menu = wallet_menu.addMenu(_("&Labels")) + labels_menu.addAction(_("&Import"), self.do_import_labels) + labels_menu.addAction(_("&Export"), self.do_export_labels) + history_menu = wallet_menu.addMenu(_("&History")) + history_menu.addAction(_("&Filter"), lambda: self.history_list.toggle_toolbar(self.config)) + history_menu.addAction(_("&Summary"), self.history_list.show_summary) + history_menu.addAction(_("&Plot"), self.history_list.plot_history_dialog) + history_menu.addAction(_("&Export"), self.history_list.export_history_dialog) + contacts_menu = wallet_menu.addMenu(_("Contacts")) + contacts_menu.addAction(_("&New"), self.new_contact_dialog) + contacts_menu.addAction(_("Import"), lambda: self.contact_list.import_contacts()) + contacts_menu.addAction(_("Export"), lambda: self.contact_list.export_contacts()) + invoices_menu = wallet_menu.addMenu(_("Invoices")) + invoices_menu.addAction(_("Import"), lambda: self.invoice_list.import_invoices()) + invoices_menu.addAction(_("Export"), lambda: self.invoice_list.export_invoices()) + + wallet_menu.addSeparator() + wallet_menu.addAction(_("Find"), self.toggle_search).setShortcut(QKeySequence("Ctrl+F")) + + def add_toggle_action(view_menu, tab): + is_shown = self.config.get('show_{}_tab'.format(tab.tab_name), False) + item_name = (_("Hide") if is_shown else _("Show")) + " " + tab.tab_description + tab.menu_action = view_menu.addAction(item_name, lambda: self.toggle_tab(tab)) + + view_menu = menubar.addMenu(_("&View")) + add_toggle_action(view_menu, self.addresses_tab) + add_toggle_action(view_menu, self.utxo_tab) + add_toggle_action(view_menu, self.contacts_tab) + add_toggle_action(view_menu, self.console_tab) + + tools_menu = menubar.addMenu(_("&Tools")) + + # Settings / Preferences are all reserved keywords in macOS using this as work around + tools_menu.addAction(_("Electrum preferences") if sys.platform == 'darwin' else _("Preferences"), self.settings_dialog) + tools_menu.addAction(_("&Network"), lambda: self.gui_object.show_network_dialog(self)) + tools_menu.addAction(_("&Plugins"), self.plugins_dialog) + tools_menu.addSeparator() + tools_menu.addAction(_("&Sign/verify message"), self.sign_verify_message) + tools_menu.addAction(_("&Encrypt/decrypt message"), self.encrypt_message) + tools_menu.addSeparator() + + paytomany_menu = tools_menu.addAction(_("&Pay to many"), self.paytomany) + + raw_transaction_menu = tools_menu.addMenu(_("&Load transaction")) + raw_transaction_menu.addAction(_("&From file"), self.do_process_from_file) + raw_transaction_menu.addAction(_("&From text"), self.do_process_from_text) + raw_transaction_menu.addAction(_("&From the blockchain"), self.do_process_from_txid) + raw_transaction_menu.addAction(_("&From QR code"), self.read_tx_from_qrcode) + self.raw_transaction_menu = raw_transaction_menu + run_hook('init_menubar_tools', self, tools_menu) + + help_menu = menubar.addMenu(_("&Help")) + help_menu.addAction(_("&About"), self.show_about) + help_menu.addAction(_("&Official website"), lambda: webbrowser.open("https://electrum.org")) + help_menu.addSeparator() + help_menu.addAction(_("&Documentation"), lambda: webbrowser.open("http://docs.electrum.org/")).setShortcut(QKeySequence.HelpContents) + help_menu.addAction(_("&Report Bug"), self.show_report_bug) + help_menu.addSeparator() + help_menu.addAction(_("&Donate to server"), self.donate_to_server) + + self.setMenuBar(menubar) + + def donate_to_server(self): + d = self.network.get_donation_address() + if d: + host = self.network.get_parameters()[0] + self.pay_to_URI('bitcoin:%s?message=donation for %s'%(d, host)) + else: + self.show_error(_('No donation address for this server')) + + def show_about(self): + QMessageBox.about(self, "Electrum", + (_("Version")+" %s" % self.wallet.electrum_version + "\n\n" + + _("Electrum's focus is speed, with low resource usage and simplifying Bitcoin.") + " " + + _("You do not need to perform regular backups, because your wallet can be " + "recovered from a secret phrase that you can memorize or write on paper.") + " " + + _("Startup times are instant because it operates in conjunction with high-performance " + "servers that handle the most complicated parts of the Bitcoin system.") + "\n\n" + + _("Uses icons from the Icons8 icon pack (icons8.com)."))) + + def show_report_bug(self): + msg = ' '.join([ + _("Please report any bugs as issues on github:<br/>"), + "<a href=\"https://github.com/spesmilo/electrum/issues\">https://github.com/spesmilo/electrum/issues</a><br/><br/>", + _("Before reporting a bug, upgrade to the most recent version of Electrum (latest release or git HEAD), and include the version number in your report."), + _("Try to explain not only what the bug is, but how it occurs.") + ]) + self.show_message(msg, title="Electrum - " + _("Reporting Bugs")) + + def notify_transactions(self): + if not self.network or not self.network.is_connected(): + return + self.print_error("Notifying GUI") + if len(self.tx_notifications) > 0: + # Combine the transactions if there are at least three + num_txns = len(self.tx_notifications) + if num_txns >= 3: + total_amount = 0 + for tx in self.tx_notifications: + is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) + if v > 0: + total_amount += v + self.notify(_("{} new transactions received: Total amount received in the new transactions {}") + .format(num_txns, self.format_amount_and_units(total_amount))) + self.tx_notifications = [] + else: + for tx in self.tx_notifications: + if tx: + self.tx_notifications.remove(tx) + is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) + if v > 0: + self.notify(_("New transaction received: {}").format(self.format_amount_and_units(v))) + + def notify(self, message): + if self.tray: + try: + # this requires Qt 5.9 + self.tray.showMessage("Electrum", message, QIcon(":icons/electrum_dark_icon"), 20000) + except TypeError: + self.tray.showMessage("Electrum", message, QSystemTrayIcon.Information, 20000) + + + + # custom wrappers for getOpenFileName and getSaveFileName, that remember the path selected by the user + def getOpenFileName(self, title, filter = ""): + directory = self.config.get('io_dir', os.path.expanduser('~')) + fileName, __ = QFileDialog.getOpenFileName(self, title, directory, filter) + if fileName and directory != os.path.dirname(fileName): + self.config.set_key('io_dir', os.path.dirname(fileName), True) + return fileName + + def getSaveFileName(self, title, filename, filter = ""): + directory = self.config.get('io_dir', os.path.expanduser('~')) + path = os.path.join( directory, filename ) + fileName, __ = QFileDialog.getSaveFileName(self, title, path, filter) + if fileName and directory != os.path.dirname(fileName): + self.config.set_key('io_dir', os.path.dirname(fileName), True) + return fileName + + def connect_slots(self, sender): + sender.timer_signal.connect(self.timer_actions) + + def timer_actions(self): + # Note this runs in the GUI thread + if self.need_update.is_set(): + self.need_update.clear() + self.update_wallet() + # resolve aliases + # FIXME this is a blocking network call that has a timeout of 5 sec + self.payto_e.resolve() + # update fee + if self.require_fee_update: + self.do_update_fee() + self.require_fee_update = False + + def format_amount(self, x, is_diff=False, whitespaces=False): + return format_satoshis(x, self.num_zeros, self.decimal_point, is_diff=is_diff, whitespaces=whitespaces) + + def format_amount_and_units(self, amount): + text = self.format_amount(amount) + ' '+ self.base_unit() + x = self.fx.format_amount_and_units(amount) if self.fx else None + if text and x: + text += ' (%s)'%x + return text + + def format_fee_rate(self, fee_rate): + return format_fee_satoshis(fee_rate/1000, self.num_zeros) + ' sat/byte' + + def get_decimal_point(self): + return self.decimal_point + + def base_unit(self): + return decimal_point_to_base_unit_name(self.decimal_point) + + def connect_fields(self, window, btc_e, fiat_e, fee_e): + + def edit_changed(edit): + if edit.follows: + return + edit.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) + fiat_e.is_last_edited = (edit == fiat_e) + amount = edit.get_amount() + rate = self.fx.exchange_rate() if self.fx else Decimal('NaN') + if rate.is_nan() or amount is None: + if edit is fiat_e: + btc_e.setText("") + if fee_e: + fee_e.setText("") + else: + fiat_e.setText("") + else: + if edit is fiat_e: + btc_e.follows = True + btc_e.setAmount(int(amount / Decimal(rate) * COIN)) + btc_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet()) + btc_e.follows = False + if fee_e: + window.update_fee() + else: + fiat_e.follows = True + fiat_e.setText(self.fx.ccy_amount_str( + amount * Decimal(rate) / COIN, False)) + fiat_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet()) + fiat_e.follows = False + + btc_e.follows = False + fiat_e.follows = False + fiat_e.textChanged.connect(partial(edit_changed, fiat_e)) + btc_e.textChanged.connect(partial(edit_changed, btc_e)) + fiat_e.is_last_edited = False + + def update_status(self): + if not self.wallet: + return + + if self.network is None or not self.network.is_running(): + text = _("Offline") + icon = QIcon(":icons/status_disconnected.png") + + elif self.network.is_connected(): + server_height = self.network.get_server_height() + server_lag = self.network.get_local_height() - server_height + # Server height can be 0 after switching to a new server + # until we get a headers subscription request response. + # Display the synchronizing message in that case. + if not self.wallet.up_to_date or server_height == 0: + text = _("Synchronizing...") + icon = QIcon(":icons/status_waiting.png") + elif server_lag > 1: + text = _("Server is lagging ({} blocks)").format(server_lag) + icon = QIcon(":icons/status_lagging.png") + else: + c, u, x = self.wallet.get_balance() + text = _("Balance" ) + ": %s "%(self.format_amount_and_units(c)) + if u: + text += " [%s unconfirmed]"%(self.format_amount(u, is_diff=True).strip()) + if x: + text += " [%s unmatured]"%(self.format_amount(x, is_diff=True).strip()) + + # append fiat balance and price + if self.fx.is_enabled(): + text += self.fx.get_fiat_status_text(c + u + x, + self.base_unit(), self.get_decimal_point()) or '' + if not self.network.proxy: + icon = QIcon(":icons/status_connected.png") + else: + icon = QIcon(":icons/status_connected_proxy.png") + else: + if self.network.proxy: + text = "{} ({})".format(_("Not connected"), _("proxy enabled")) + else: + text = _("Not connected") + icon = QIcon(":icons/status_disconnected.png") + + self.tray.setToolTip("%s (%s)" % (text, self.wallet.basename())) + self.balance_label.setText(text) + self.status_button.setIcon( icon ) + + + def update_wallet(self): + self.update_status() + if self.wallet.up_to_date or not self.network or not self.network.is_connected(): + self.update_tabs() + + def update_tabs(self): + self.history_list.update() + self.request_list.update() + self.address_list.update() + self.utxo_list.update() + self.contact_list.update() + self.invoice_list.update() + self.update_completions() + + def create_history_tab(self): + from .history_list import HistoryList + self.history_list = l = HistoryList(self) + l.searchable_list = l + toolbar = l.create_toolbar(self.config) + toolbar_shown = self.config.get('show_toolbar_history', False) + l.show_toolbar(toolbar_shown) + return self.create_list_tab(l, toolbar) + + def show_address(self, addr): + from . import address_dialog + d = address_dialog.AddressDialog(self, addr) + d.exec_() + + def show_transaction(self, tx, tx_desc = None): + '''tx_desc is set only for txs created in the Send tab''' + show_transaction(tx, self, tx_desc) + + def create_receive_tab(self): + # A 4-column grid layout. All the stretch is in the last column. + # The exchange rate plugin adds a fiat widget in column 2 + self.receive_grid = grid = QGridLayout() + grid.setSpacing(8) + grid.setColumnStretch(3, 1) + + self.receive_address_e = ButtonsLineEdit() + self.receive_address_e.addCopyButton(self.app) + self.receive_address_e.setReadOnly(True) + msg = _('Bitcoin address where the payment should be received. Note that each payment request uses a different Bitcoin address.') + self.receive_address_label = HelpLabel(_('Receiving address'), msg) + self.receive_address_e.textChanged.connect(self.update_receive_qr) + self.receive_address_e.setFocusPolicy(Qt.ClickFocus) + grid.addWidget(self.receive_address_label, 0, 0) + grid.addWidget(self.receive_address_e, 0, 1, 1, -1) + + self.receive_message_e = QLineEdit() + grid.addWidget(QLabel(_('Description')), 1, 0) + grid.addWidget(self.receive_message_e, 1, 1, 1, -1) + self.receive_message_e.textChanged.connect(self.update_receive_qr) + + self.receive_amount_e = BTCAmountEdit(self.get_decimal_point) + grid.addWidget(QLabel(_('Requested amount')), 2, 0) + grid.addWidget(self.receive_amount_e, 2, 1) + self.receive_amount_e.textChanged.connect(self.update_receive_qr) + + self.fiat_receive_e = AmountEdit(self.fx.get_currency if self.fx else '') + if not self.fx or not self.fx.is_enabled(): + self.fiat_receive_e.setVisible(False) + grid.addWidget(self.fiat_receive_e, 2, 2, Qt.AlignLeft) + self.connect_fields(self, self.receive_amount_e, self.fiat_receive_e, None) + + self.expires_combo = QComboBox() + self.expires_combo.addItems([i[0] for i in expiration_values]) + self.expires_combo.setCurrentIndex(3) + self.expires_combo.setFixedWidth(self.receive_amount_e.width()) + msg = ' '.join([ + _('Expiration date of your request.'), + _('This information is seen by the recipient if you send them a signed payment request.'), + _('Expired requests have to be deleted manually from your list, in order to free the corresponding Bitcoin addresses.'), + _('The bitcoin address never expires and will always be part of this electrum wallet.'), + ]) + grid.addWidget(HelpLabel(_('Request expires'), msg), 3, 0) + grid.addWidget(self.expires_combo, 3, 1) + self.expires_label = QLineEdit('') + self.expires_label.setReadOnly(1) + self.expires_label.setFocusPolicy(Qt.NoFocus) + self.expires_label.hide() + grid.addWidget(self.expires_label, 3, 1) + + self.save_request_button = QPushButton(_('Save')) + self.save_request_button.clicked.connect(self.save_payment_request) + + self.new_request_button = QPushButton(_('New')) + self.new_request_button.clicked.connect(self.new_payment_request) + + self.receive_qr = QRCodeWidget(fixedSize=200) + self.receive_qr.mouseReleaseEvent = lambda x: self.toggle_qr_window() + self.receive_qr.enterEvent = lambda x: self.app.setOverrideCursor(QCursor(Qt.PointingHandCursor)) + self.receive_qr.leaveEvent = lambda x: self.app.setOverrideCursor(QCursor(Qt.ArrowCursor)) + + self.receive_buttons = buttons = QHBoxLayout() + buttons.addStretch(1) + buttons.addWidget(self.save_request_button) + buttons.addWidget(self.new_request_button) + grid.addLayout(buttons, 4, 1, 1, 2) + + self.receive_requests_label = QLabel(_('Requests')) + + from .request_list import RequestList + self.request_list = RequestList(self) + + # layout + vbox_g = QVBoxLayout() + vbox_g.addLayout(grid) + vbox_g.addStretch() + + hbox = QHBoxLayout() + hbox.addLayout(vbox_g) + hbox.addWidget(self.receive_qr) + + w = QWidget() + w.searchable_list = self.request_list + vbox = QVBoxLayout(w) + vbox.addLayout(hbox) + vbox.addStretch(1) + vbox.addWidget(self.receive_requests_label) + vbox.addWidget(self.request_list) + vbox.setStretchFactor(self.request_list, 1000) + + return w + + + def delete_payment_request(self, addr): + self.wallet.remove_payment_request(addr, self.config) + self.request_list.update() + self.clear_receive_tab() + + def get_request_URI(self, addr): + req = self.wallet.receive_requests[addr] + message = self.wallet.labels.get(addr, '') + amount = req['amount'] + URI = util.create_URI(addr, amount, message) + if req.get('time'): + URI += "&time=%d"%req.get('time') + if req.get('exp'): + URI += "&exp=%d"%req.get('exp') + if req.get('name') and req.get('sig'): + sig = bfh(req.get('sig')) + sig = bitcoin.base_encode(sig, base=58) + URI += "&name=" + req['name'] + "&sig="+sig + return str(URI) + + + def sign_payment_request(self, addr): + alias = self.config.get('alias') + alias_privkey = None + if alias and self.alias_info: + alias_addr, alias_name, validated = self.alias_info + if alias_addr: + if self.wallet.is_mine(alias_addr): + msg = _('This payment request will be signed.') + '\n' + _('Please enter your password') + password = None + if self.wallet.has_keystore_encryption(): + password = self.password_dialog(msg) + if not password: + return + try: + self.wallet.sign_payment_request(addr, alias, alias_addr, password) + except Exception as e: + self.show_error(str(e)) + return + else: + return + + def save_payment_request(self): + addr = str(self.receive_address_e.text()) + amount = self.receive_amount_e.get_amount() + message = self.receive_message_e.text() + if not message and not amount: + self.show_error(_('No message or amount')) + return False + i = self.expires_combo.currentIndex() + expiration = list(map(lambda x: x[1], expiration_values))[i] + req = self.wallet.make_payment_request(addr, amount, message, expiration) + try: + self.wallet.add_payment_request(req, self.config) + except Exception as e: + traceback.print_exc(file=sys.stderr) + self.show_error(_('Error adding payment request') + ':\n' + str(e)) + else: + self.sign_payment_request(addr) + self.save_request_button.setEnabled(False) + finally: + self.request_list.update() + self.address_list.update() + + def view_and_paste(self, title, msg, data): + dialog = WindowModalDialog(self, title) + vbox = QVBoxLayout() + label = QLabel(msg) + label.setWordWrap(True) + vbox.addWidget(label) + pr_e = ShowQRTextEdit(text=data) + vbox.addWidget(pr_e) + vbox.addLayout(Buttons(CopyCloseButton(pr_e.text, self.app, dialog))) + dialog.setLayout(vbox) + dialog.exec_() + + def export_payment_request(self, addr): + r = self.wallet.receive_requests.get(addr) + pr = paymentrequest.serialize_request(r).SerializeToString() + name = r['id'] + '.bip70' + fileName = self.getSaveFileName(_("Select where to save your payment request"), name, "*.bip70") + if fileName: + with open(fileName, "wb+") as f: + f.write(util.to_bytes(pr)) + self.show_message(_("Request saved successfully")) + self.saved = True + + def new_payment_request(self): + addr = self.wallet.get_unused_address() + if addr is None: + if not self.wallet.is_deterministic(): + msg = [ + _('No more addresses in your wallet.'), + _('You are using a non-deterministic wallet, which cannot create new addresses.'), + _('If you want to create new addresses, use a deterministic wallet instead.') + ] + self.show_message(' '.join(msg)) + return + if not self.question(_("Warning: The next address will not be recovered automatically if you restore your wallet from seed; you may need to add it manually.\n\nThis occurs because you have too many unused addresses in your wallet. To avoid this situation, use the existing addresses first.\n\nCreate anyway?")): + return + addr = self.wallet.create_new_address(False) + self.set_receive_address(addr) + self.expires_label.hide() + self.expires_combo.show() + self.new_request_button.setEnabled(False) + self.receive_message_e.setFocus(1) + + def set_receive_address(self, addr): + self.receive_address_e.setText(addr) + self.receive_message_e.setText('') + self.receive_amount_e.setAmount(None) + + def clear_receive_tab(self): + addr = self.wallet.get_receiving_address() or '' + self.receive_address_e.setText(addr) + self.receive_message_e.setText('') + self.receive_amount_e.setAmount(None) + self.expires_label.hide() + self.expires_combo.show() + + def toggle_qr_window(self): + from . import qrwindow + if not self.qr_window: + self.qr_window = qrwindow.QR_Window(self) + self.qr_window.setVisible(True) + self.qr_window_geometry = self.qr_window.geometry() + else: + if not self.qr_window.isVisible(): + self.qr_window.setVisible(True) + self.qr_window.setGeometry(self.qr_window_geometry) + else: + self.qr_window_geometry = self.qr_window.geometry() + self.qr_window.setVisible(False) + self.update_receive_qr() + + def show_send_tab(self): + self.tabs.setCurrentIndex(self.tabs.indexOf(self.send_tab)) + + def show_receive_tab(self): + self.tabs.setCurrentIndex(self.tabs.indexOf(self.receive_tab)) + + def receive_at(self, addr): + if not bitcoin.is_address(addr): + return + self.show_receive_tab() + self.receive_address_e.setText(addr) + self.new_request_button.setEnabled(True) + + def update_receive_qr(self): + addr = str(self.receive_address_e.text()) + amount = self.receive_amount_e.get_amount() + message = self.receive_message_e.text() + self.save_request_button.setEnabled((amount is not None) or (message != "")) + uri = util.create_URI(addr, amount, message) + self.receive_qr.setData(uri) + if self.qr_window and self.qr_window.isVisible(): + self.qr_window.set_content(addr, amount, message, uri) + + def set_feerounding_text(self, num_satoshis_added): + self.feerounding_text = (_('Additional {} satoshis are going to be added.') + .format(num_satoshis_added)) + + def create_send_tab(self): + # A 4-column grid layout. All the stretch is in the last column. + # The exchange rate plugin adds a fiat widget in column 2 + self.send_grid = grid = QGridLayout() + grid.setSpacing(8) + grid.setColumnStretch(3, 1) + + from .paytoedit import PayToEdit + self.amount_e = BTCAmountEdit(self.get_decimal_point) + self.payto_e = PayToEdit(self) + msg = _('Recipient of the funds.') + '\n\n'\ + + _('You may enter a Bitcoin address, a label from your list of contacts (a list of completions will be proposed), or an alias (email-like address that forwards to a Bitcoin address)') + payto_label = HelpLabel(_('Pay to'), msg) + grid.addWidget(payto_label, 1, 0) + grid.addWidget(self.payto_e, 1, 1, 1, -1) + + completer = QCompleter() + completer.setCaseSensitivity(False) + self.payto_e.set_completer(completer) + completer.setModel(self.completions) + + msg = _('Description of the transaction (not mandatory).') + '\n\n'\ + + _('The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.') + description_label = HelpLabel(_('Description'), msg) + grid.addWidget(description_label, 2, 0) + self.message_e = MyLineEdit() + grid.addWidget(self.message_e, 2, 1, 1, -1) + + self.from_label = QLabel(_('From')) + grid.addWidget(self.from_label, 3, 0) + self.from_list = MyTreeWidget(self, self.from_list_menu, ['','']) + self.from_list.setHeaderHidden(True) + self.from_list.setMaximumHeight(80) + grid.addWidget(self.from_list, 3, 1, 1, -1) + self.set_pay_from([]) + + msg = _('Amount to be sent.') + '\n\n' \ + + _('The amount will be displayed in red if you do not have enough funds in your wallet.') + ' ' \ + + _('Note that if you have frozen some of your addresses, the available funds will be lower than your total balance.') + '\n\n' \ + + _('Keyboard shortcut: type "!" to send all your coins.') + amount_label = HelpLabel(_('Amount'), msg) + grid.addWidget(amount_label, 4, 0) + grid.addWidget(self.amount_e, 4, 1) + + self.fiat_send_e = AmountEdit(self.fx.get_currency if self.fx else '') + if not self.fx or not self.fx.is_enabled(): + self.fiat_send_e.setVisible(False) + grid.addWidget(self.fiat_send_e, 4, 2) + self.amount_e.frozen.connect( + lambda: self.fiat_send_e.setFrozen(self.amount_e.isReadOnly())) + + self.max_button = EnterButton(_("Max"), self.spend_max) + self.max_button.setFixedWidth(140) + grid.addWidget(self.max_button, 4, 3) + hbox = QHBoxLayout() + hbox.addStretch(1) + grid.addLayout(hbox, 4, 4) + + msg = _('Bitcoin transactions are in general not free. A transaction fee is paid by the sender of the funds.') + '\n\n'\ + + _('The amount of fee can be decided freely by the sender. However, transactions with low fees take more time to be processed.') + '\n\n'\ + + _('A suggested fee is automatically added to this field. You may override it. The suggested fee increases with the size of the transaction.') + self.fee_e_label = HelpLabel(_('Fee'), msg) + + def fee_cb(dyn, pos, fee_rate): + if dyn: + if self.config.use_mempool_fees(): + self.config.set_key('depth_level', pos, False) + else: + self.config.set_key('fee_level', pos, False) + else: + self.config.set_key('fee_per_kb', fee_rate, False) + + if fee_rate: + fee_rate = Decimal(fee_rate) + self.feerate_e.setAmount(quantize_feerate(fee_rate / 1000)) + else: + self.feerate_e.setAmount(None) + self.fee_e.setModified(False) + + self.fee_slider.activate() + self.spend_max() if self.is_max else self.update_fee() + + self.fee_slider = FeeSlider(self, self.config, fee_cb) + self.fee_slider.setFixedWidth(140) + + def on_fee_or_feerate(edit_changed, editing_finished): + edit_other = self.feerate_e if edit_changed == self.fee_e else self.fee_e + if editing_finished: + if edit_changed.get_amount() is None: + # This is so that when the user blanks the fee and moves on, + # we go back to auto-calculate mode and put a fee back. + edit_changed.setModified(False) + else: + # edit_changed was edited just now, so make sure we will + # freeze the correct fee setting (this) + edit_other.setModified(False) + self.fee_slider.deactivate() + self.update_fee() + + class TxSizeLabel(QLabel): + def setAmount(self, byte_size): + self.setText(('x %s bytes =' % byte_size) if byte_size else '') + + self.size_e = TxSizeLabel() + self.size_e.setAlignment(Qt.AlignCenter) + self.size_e.setAmount(0) + self.size_e.setFixedWidth(140) + self.size_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) + + self.feerate_e = FeerateEdit(lambda: 0) + self.feerate_e.setAmount(self.config.fee_per_byte()) + self.feerate_e.textEdited.connect(partial(on_fee_or_feerate, self.feerate_e, False)) + self.feerate_e.editingFinished.connect(partial(on_fee_or_feerate, self.feerate_e, True)) + + self.fee_e = BTCAmountEdit(self.get_decimal_point) + self.fee_e.textEdited.connect(partial(on_fee_or_feerate, self.fee_e, False)) + self.fee_e.editingFinished.connect(partial(on_fee_or_feerate, self.fee_e, True)) + + def feerounding_onclick(): + text = (self.feerounding_text + '\n\n' + + _('To somewhat protect your privacy, Electrum tries to create change with similar precision to other outputs.') + ' ' + + _('At most 100 satoshis might be lost due to this rounding.') + ' ' + + _("You can disable this setting in '{}'.").format(_('Preferences')) + '\n' + + _('Also, dust is not kept as change, but added to the fee.')) + QMessageBox.information(self, 'Fee rounding', text) + + self.feerounding_icon = QPushButton(QIcon(':icons/info.png'), '') + self.feerounding_icon.setFixedWidth(20) + self.feerounding_icon.setFlat(True) + self.feerounding_icon.clicked.connect(feerounding_onclick) + self.feerounding_icon.setVisible(False) + + self.connect_fields(self, self.amount_e, self.fiat_send_e, self.fee_e) + + vbox_feelabel = QVBoxLayout() + vbox_feelabel.addWidget(self.fee_e_label) + vbox_feelabel.addStretch(1) + grid.addLayout(vbox_feelabel, 5, 0) + + self.fee_adv_controls = QWidget() + hbox = QHBoxLayout(self.fee_adv_controls) + hbox.setContentsMargins(0, 0, 0, 0) + hbox.addWidget(self.feerate_e) + hbox.addWidget(self.size_e) + hbox.addWidget(self.fee_e) + hbox.addWidget(self.feerounding_icon, Qt.AlignLeft) + hbox.addStretch(1) + + vbox_feecontrol = QVBoxLayout() + vbox_feecontrol.addWidget(self.fee_adv_controls) + vbox_feecontrol.addWidget(self.fee_slider) + + grid.addLayout(vbox_feecontrol, 5, 1, 1, -1) + + if not self.config.get('show_fee', False): + self.fee_adv_controls.setVisible(False) + + self.preview_button = EnterButton(_("Preview"), self.do_preview) + self.preview_button.setToolTip(_('Display the details of your transaction before signing it.')) + self.send_button = EnterButton(_("Send"), self.do_send) + self.clear_button = EnterButton(_("Clear"), self.do_clear) + buttons = QHBoxLayout() + buttons.addStretch(1) + buttons.addWidget(self.clear_button) + buttons.addWidget(self.preview_button) + buttons.addWidget(self.send_button) + grid.addLayout(buttons, 6, 1, 1, 3) + + self.amount_e.shortcut.connect(self.spend_max) + self.payto_e.textChanged.connect(self.update_fee) + self.amount_e.textEdited.connect(self.update_fee) + + def reset_max(text): + self.is_max = False + enable = not bool(text) and not self.amount_e.isReadOnly() + self.max_button.setEnabled(enable) + self.amount_e.textEdited.connect(reset_max) + self.fiat_send_e.textEdited.connect(reset_max) + + def entry_changed(): + text = "" + + amt_color = ColorScheme.DEFAULT + fee_color = ColorScheme.DEFAULT + feerate_color = ColorScheme.DEFAULT + + if self.not_enough_funds: + amt_color, fee_color = ColorScheme.RED, ColorScheme.RED + feerate_color = ColorScheme.RED + text = _( "Not enough funds" ) + c, u, x = self.wallet.get_frozen_balance() + if c+u+x: + text += ' (' + self.format_amount(c+u+x).strip() + ' ' + self.base_unit() + ' ' +_("are frozen") + ')' + + # blue color denotes auto-filled values + elif self.fee_e.isModified(): + feerate_color = ColorScheme.BLUE + elif self.feerate_e.isModified(): + fee_color = ColorScheme.BLUE + elif self.amount_e.isModified(): + fee_color = ColorScheme.BLUE + feerate_color = ColorScheme.BLUE + else: + amt_color = ColorScheme.BLUE + fee_color = ColorScheme.BLUE + feerate_color = ColorScheme.BLUE + + self.statusBar().showMessage(text) + self.amount_e.setStyleSheet(amt_color.as_stylesheet()) + self.fee_e.setStyleSheet(fee_color.as_stylesheet()) + self.feerate_e.setStyleSheet(feerate_color.as_stylesheet()) + + self.amount_e.textChanged.connect(entry_changed) + self.fee_e.textChanged.connect(entry_changed) + self.feerate_e.textChanged.connect(entry_changed) + + self.invoices_label = QLabel(_('Invoices')) + from .invoice_list import InvoiceList + self.invoice_list = InvoiceList(self) + + vbox0 = QVBoxLayout() + vbox0.addLayout(grid) + hbox = QHBoxLayout() + hbox.addLayout(vbox0) + w = QWidget() + vbox = QVBoxLayout(w) + vbox.addLayout(hbox) + vbox.addStretch(1) + vbox.addWidget(self.invoices_label) + vbox.addWidget(self.invoice_list) + vbox.setStretchFactor(self.invoice_list, 1000) + w.searchable_list = self.invoice_list + run_hook('create_send_tab', grid) + return w + + def spend_max(self): + if run_hook('abort_send', self): + return + self.is_max = True + self.do_update_fee() + + def update_fee(self): + self.require_fee_update = True + + def get_payto_or_dummy(self): + r = self.payto_e.get_recipient() + if r: + return r + return (TYPE_ADDRESS, self.wallet.dummy_address()) + + def do_update_fee(self): + '''Recalculate the fee. If the fee was manually input, retain it, but + still build the TX to see if there are enough funds. + ''' + freeze_fee = self.is_send_fee_frozen() + freeze_feerate = self.is_send_feerate_frozen() + amount = '!' if self.is_max else self.amount_e.get_amount() + if amount is None: + if not freeze_fee: + self.fee_e.setAmount(None) + self.not_enough_funds = False + self.statusBar().showMessage('') + else: + fee_estimator = self.get_send_fee_estimator() + outputs = self.payto_e.get_outputs(self.is_max) + if not outputs: + _type, addr = self.get_payto_or_dummy() + outputs = [(_type, addr, amount)] + is_sweep = bool(self.tx_external_keypairs) + make_tx = lambda fee_est: \ + self.wallet.make_unsigned_transaction( + self.get_coins(), outputs, self.config, + fixed_fee=fee_est, is_sweep=is_sweep) + try: + tx = make_tx(fee_estimator) + self.not_enough_funds = False + except (NotEnoughFunds, NoDynamicFeeEstimates) as e: + if not freeze_fee: + self.fee_e.setAmount(None) + if not freeze_feerate: + self.feerate_e.setAmount(None) + self.feerounding_icon.setVisible(False) + + if isinstance(e, NotEnoughFunds): + self.not_enough_funds = True + elif isinstance(e, NoDynamicFeeEstimates): + try: + tx = make_tx(0) + size = tx.estimated_size() + self.size_e.setAmount(size) + except BaseException: + pass + return + except BaseException: + traceback.print_exc(file=sys.stderr) + return + + size = tx.estimated_size() + self.size_e.setAmount(size) + + fee = tx.get_fee() + fee = None if self.not_enough_funds else fee + + # Displayed fee/fee_rate values are set according to user input. + # Due to rounding or dropping dust in CoinChooser, + # actual fees often differ somewhat. + if freeze_feerate or self.fee_slider.is_active(): + displayed_feerate = self.feerate_e.get_amount() + if displayed_feerate is not None: + displayed_feerate = quantize_feerate(displayed_feerate) + else: + # fallback to actual fee + displayed_feerate = quantize_feerate(fee / size) if fee is not None else None + self.feerate_e.setAmount(displayed_feerate) + displayed_fee = round(displayed_feerate * size) if displayed_feerate is not None else None + self.fee_e.setAmount(displayed_fee) + else: + if freeze_fee: + displayed_fee = self.fee_e.get_amount() + else: + # fallback to actual fee if nothing is frozen + displayed_fee = fee + self.fee_e.setAmount(displayed_fee) + displayed_fee = displayed_fee if displayed_fee else 0 + displayed_feerate = quantize_feerate(displayed_fee / size) if displayed_fee is not None else None + self.feerate_e.setAmount(displayed_feerate) + + # show/hide fee rounding icon + feerounding = (fee - displayed_fee) if fee else 0 + self.set_feerounding_text(int(feerounding)) + self.feerounding_icon.setToolTip(self.feerounding_text) + self.feerounding_icon.setVisible(abs(feerounding) >= 1) + + if self.is_max: + amount = tx.output_value() + __, x_fee_amount = run_hook('get_tx_extra_fee', self.wallet, tx) or (None, 0) + amount_after_all_fees = amount - x_fee_amount + self.amount_e.setAmount(amount_after_all_fees) + + def from_list_delete(self, item): + i = self.from_list.indexOfTopLevelItem(item) + self.pay_from.pop(i) + self.redraw_from_list() + self.update_fee() + + def from_list_menu(self, position): + item = self.from_list.itemAt(position) + menu = QMenu() + menu.addAction(_("Remove"), lambda: self.from_list_delete(item)) + menu.exec_(self.from_list.viewport().mapToGlobal(position)) + + def set_pay_from(self, coins): + self.pay_from = list(coins) + self.redraw_from_list() + + def redraw_from_list(self): + self.from_list.clear() + self.from_label.setHidden(len(self.pay_from) == 0) + self.from_list.setHidden(len(self.pay_from) == 0) + + def format(x): + h = x.get('prevout_hash') + return h[0:10] + '...' + h[-10:] + ":%d"%x.get('prevout_n') + u'\t' + "%s"%x.get('address') + + for item in self.pay_from: + self.from_list.addTopLevelItem(QTreeWidgetItem( [format(item), self.format_amount(item['value']) ])) + + def get_contact_payto(self, key): + _type, label = self.contacts.get(key) + return label + ' <' + key + '>' if _type == 'address' else key + + def update_completions(self): + l = [self.get_contact_payto(key) for key in self.contacts.keys()] + self.completions.setStringList(l) + + def protected(func): + '''Password request wrapper. The password is passed to the function + as the 'password' named argument. "None" indicates either an + unencrypted wallet, or the user cancelled the password request. + An empty input is passed as the empty string.''' + def request_password(self, *args, **kwargs): + parent = self.top_level_window() + password = None + while self.wallet.has_keystore_encryption(): + password = self.password_dialog(parent=parent) + if password is None: + # User cancelled password input + return + try: + self.wallet.check_password(password) + break + except Exception as e: + self.show_error(str(e), parent=parent) + continue + + kwargs['password'] = password + return func(self, *args, **kwargs) + return request_password + + def is_send_fee_frozen(self): + return self.fee_e.isVisible() and self.fee_e.isModified() \ + and (self.fee_e.text() or self.fee_e.hasFocus()) + + def is_send_feerate_frozen(self): + return self.feerate_e.isVisible() and self.feerate_e.isModified() \ + and (self.feerate_e.text() or self.feerate_e.hasFocus()) + + def get_send_fee_estimator(self): + if self.is_send_fee_frozen(): + fee_estimator = self.fee_e.get_amount() + elif self.is_send_feerate_frozen(): + amount = self.feerate_e.get_amount() # sat/byte feerate + amount = 0 if amount is None else amount * 1000 # sat/kilobyte feerate + fee_estimator = partial( + simple_config.SimpleConfig.estimate_fee_for_feerate, amount) + else: + fee_estimator = None + return fee_estimator + + def read_send_tab(self): + if self.payment_request and self.payment_request.has_expired(): + self.show_error(_('Payment request has expired')) + return + label = self.message_e.text() + + if self.payment_request: + outputs = self.payment_request.get_outputs() + else: + errors = self.payto_e.get_errors() + if errors: + self.show_warning(_("Invalid Lines found:") + "\n\n" + '\n'.join([ _("Line #") + str(x[0]+1) + ": " + x[1] for x in errors])) + return + outputs = self.payto_e.get_outputs(self.is_max) + + if self.payto_e.is_alias and self.payto_e.validated is False: + alias = self.payto_e.toPlainText() + msg = _('WARNING: the alias "{}" could not be validated via an additional ' + 'security check, DNSSEC, and thus may not be correct.').format(alias) + '\n' + msg += _('Do you wish to continue?') + if not self.question(msg): + return + + if not outputs: + self.show_error(_('No outputs')) + return + + for _type, addr, amount in outputs: + if addr is None: + self.show_error(_('Bitcoin Address is None')) + return + if _type == TYPE_ADDRESS and not bitcoin.is_address(addr): + self.show_error(_('Invalid Bitcoin Address')) + return + if amount is None: + self.show_error(_('Invalid Amount')) + return + + fee_estimator = self.get_send_fee_estimator() + coins = self.get_coins() + return outputs, fee_estimator, label, coins + + def do_preview(self): + self.do_send(preview = True) + + def do_send(self, preview = False): + if run_hook('abort_send', self): + return + r = self.read_send_tab() + if not r: + return + outputs, fee_estimator, tx_desc, coins = r + try: + is_sweep = bool(self.tx_external_keypairs) + tx = self.wallet.make_unsigned_transaction( + coins, outputs, self.config, fixed_fee=fee_estimator, + is_sweep=is_sweep) + except NotEnoughFunds: + self.show_message(_("Insufficient funds")) + return + except BaseException as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + + amount = tx.output_value() if self.is_max else sum(map(lambda x:x[2], outputs)) + fee = tx.get_fee() + + use_rbf = self.config.get('use_rbf', True) + if use_rbf: + tx.set_rbf(True) + + if fee < self.wallet.relayfee() * tx.estimated_size() / 1000: + self.show_error('\n'.join([ + _("This transaction requires a higher fee, or it will not be propagated by your current server"), + _("Try to raise your transaction fee, or use a server with a lower relay fee.") + ])) + return + + if preview: + self.show_transaction(tx, tx_desc) + return + + if not self.network: + self.show_error(_("You can't broadcast a transaction without a live network connection.")) + return + + # confirmation dialog + msg = [ + _("Amount to be sent") + ": " + self.format_amount_and_units(amount), + _("Mining fee") + ": " + self.format_amount_and_units(fee), + ] + + x_fee = run_hook('get_tx_extra_fee', self.wallet, tx) + if x_fee: + x_fee_address, x_fee_amount = x_fee + msg.append( _("Additional fees") + ": " + self.format_amount_and_units(x_fee_amount) ) + + confirm_rate = simple_config.FEERATE_WARNING_HIGH_FEE + if fee > confirm_rate * tx.estimated_size() / 1000: + msg.append(_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")) + + if self.wallet.has_keystore_encryption(): + msg.append("") + msg.append(_("Enter your password to proceed")) + password = self.password_dialog('\n'.join(msg)) + if not password: + return + else: + msg.append(_('Proceed?')) + password = None + if not self.question('\n'.join(msg)): + return + + def sign_done(success): + if success: + if not tx.is_complete(): + self.show_transaction(tx) + self.do_clear() + else: + self.broadcast_transaction(tx, tx_desc) + self.sign_tx_with_password(tx, sign_done, password) + + @protected + def sign_tx(self, tx, callback, password): + self.sign_tx_with_password(tx, callback, password) + + def sign_tx_with_password(self, tx, callback, password): + '''Sign the transaction in a separate thread. When done, calls + the callback with a success code of True or False. + ''' + def on_success(result): + callback(True) + def on_failure(exc_info): + self.on_error(exc_info) + callback(False) + on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success + if self.tx_external_keypairs: + # can sign directly + task = partial(Transaction.sign, tx, self.tx_external_keypairs) + else: + task = partial(self.wallet.sign_transaction, tx, password) + msg = _('Signing transaction...') + WaitingDialog(self, msg, task, on_success, on_failure) + + def broadcast_transaction(self, tx, tx_desc): + + def broadcast_thread(): + # non-GUI thread + pr = self.payment_request + if pr and pr.has_expired(): + self.payment_request = None + return False, _("Payment request has expired") + status, msg = self.network.broadcast_transaction(tx) + if pr and status is True: + self.invoices.set_paid(pr, tx.txid()) + self.invoices.save() + self.payment_request = None + refund_address = self.wallet.get_receiving_addresses()[0] + ack_status, ack_msg = pr.send_ack(str(tx), refund_address) + if ack_status: + msg = ack_msg + return status, msg + + # Capture current TL window; override might be removed on return + parent = self.top_level_window(lambda win: isinstance(win, MessageBoxMixin)) + + def broadcast_done(result): + # GUI thread + if result: + status, msg = result + if status: + if tx_desc is not None and tx.is_complete(): + self.wallet.set_label(tx.txid(), tx_desc) + parent.show_message(_('Payment sent.') + '\n' + msg) + self.invoice_list.update() + self.do_clear() + else: + parent.show_error(msg) + + WaitingDialog(self, _('Broadcasting transaction...'), + broadcast_thread, broadcast_done, self.on_error) + + def query_choice(self, msg, choices): + # Needed by QtHandler for hardware wallets + dialog = WindowModalDialog(self.top_level_window()) + clayout = ChoicesLayout(msg, choices) + vbox = QVBoxLayout(dialog) + vbox.addLayout(clayout.layout()) + vbox.addLayout(Buttons(OkButton(dialog))) + if not dialog.exec_(): + return None + return clayout.selected_index() + + def lock_amount(self, b): + self.amount_e.setFrozen(b) + self.max_button.setEnabled(not b) + + def prepare_for_payment_request(self): + self.show_send_tab() + self.payto_e.is_pr = True + for e in [self.payto_e, self.message_e]: + e.setFrozen(True) + self.lock_amount(True) + self.payto_e.setText(_("please wait...")) + return True + + def delete_invoice(self, key): + self.invoices.remove(key) + self.invoice_list.update() + + def payment_request_ok(self): + pr = self.payment_request + key = self.invoices.add(pr) + status = self.invoices.get_status(key) + self.invoice_list.update() + if status == PR_PAID: + self.show_message("invoice already paid") + self.do_clear() + self.payment_request = None + return + self.payto_e.is_pr = True + if not pr.has_expired(): + self.payto_e.setGreen() + else: + self.payto_e.setExpired() + self.payto_e.setText(pr.get_requestor()) + self.amount_e.setText(format_satoshis_plain(pr.get_amount(), self.decimal_point)) + self.message_e.setText(pr.get_memo()) + # signal to set fee + self.amount_e.textEdited.emit("") + + def payment_request_error(self): + self.show_message(self.payment_request.error) + self.payment_request = None + self.do_clear() + + def on_pr(self, request): + self.payment_request = request + if self.payment_request.verify(self.contacts): + self.payment_request_ok_signal.emit() + else: + self.payment_request_error_signal.emit() + + def pay_to_URI(self, URI): + if not URI: + return + try: + out = util.parse_URI(URI, self.on_pr) + except BaseException as e: + self.show_error(_('Invalid bitcoin URI:') + '\n' + str(e)) + return + self.show_send_tab() + r = out.get('r') + sig = out.get('sig') + name = out.get('name') + if r or (name and sig): + self.prepare_for_payment_request() + return + address = out.get('address') + amount = out.get('amount') + label = out.get('label') + message = out.get('message') + # use label as description (not BIP21 compliant) + if label and not message: + message = label + if address: + self.payto_e.setText(address) + if message: + self.message_e.setText(message) + if amount: + self.amount_e.setAmount(amount) + self.amount_e.textEdited.emit("") + + + def do_clear(self): + self.is_max = False + self.not_enough_funds = False + self.payment_request = None + self.payto_e.is_pr = False + for e in [self.payto_e, self.message_e, self.amount_e, self.fiat_send_e, + self.fee_e, self.feerate_e]: + e.setText('') + e.setFrozen(False) + self.fee_slider.activate() + self.feerate_e.setAmount(self.config.fee_per_byte()) + self.size_e.setAmount(0) + self.feerounding_icon.setVisible(False) + self.set_pay_from([]) + self.tx_external_keypairs = {} + self.update_status() + run_hook('do_clear', self) + + def set_frozen_state(self, addrs, freeze): + self.wallet.set_frozen_state(addrs, freeze) + self.address_list.update() + self.utxo_list.update() + self.update_fee() + + def create_list_tab(self, l, toolbar=None): + w = QWidget() + w.searchable_list = l + vbox = QVBoxLayout() + w.setLayout(vbox) + vbox.setContentsMargins(0, 0, 0, 0) + vbox.setSpacing(0) + if toolbar: + vbox.addLayout(toolbar) + vbox.addWidget(l) + return w + + def create_addresses_tab(self): + from .address_list import AddressList + self.address_list = l = AddressList(self) + toolbar = l.create_toolbar(self.config) + toolbar_shown = self.config.get('show_toolbar_addresses', False) + l.show_toolbar(toolbar_shown) + return self.create_list_tab(l, toolbar) + + def create_utxo_tab(self): + from .utxo_list import UTXOList + self.utxo_list = l = UTXOList(self) + return self.create_list_tab(l) + + def create_contacts_tab(self): + from .contact_list import ContactList + self.contact_list = l = ContactList(self) + return self.create_list_tab(l) + + def remove_address(self, addr): + if self.question(_("Do you want to remove {} from your wallet?").format(addr)): + self.wallet.delete_address(addr) + self.need_update.set() # history, addresses, coins + self.clear_receive_tab() + + def get_coins(self): + if self.pay_from: + return self.pay_from + else: + return self.wallet.get_spendable_coins(None, self.config) + + def spend_coins(self, coins): + self.set_pay_from(coins) + self.show_send_tab() + self.update_fee() + + def paytomany(self): + self.show_send_tab() + self.payto_e.paytomany() + msg = '\n'.join([ + _('Enter a list of outputs in the \'Pay to\' field.'), + _('One output per line.'), + _('Format: address, amount'), + _('You may load a CSV file using the file icon.') + ]) + self.show_message(msg, title=_('Pay to many')) + + def payto_contacts(self, labels): + paytos = [self.get_contact_payto(label) for label in labels] + self.show_send_tab() + if len(paytos) == 1: + self.payto_e.setText(paytos[0]) + self.amount_e.setFocus() + else: + text = "\n".join([payto + ", 0" for payto in paytos]) + self.payto_e.setText(text) + self.payto_e.setFocus() + + def set_contact(self, label, address): + if not is_address(address): + self.show_error(_('Invalid Address')) + self.contact_list.update() # Displays original unchanged value + return False + self.contacts[address] = ('address', label) + self.contact_list.update() + self.history_list.update() + self.update_completions() + return True + + def delete_contacts(self, labels): + if not self.question(_("Remove {} from your list of contacts?") + .format(" + ".join(labels))): + return + for label in labels: + self.contacts.pop(label) + self.history_list.update() + self.contact_list.update() + self.update_completions() + + def show_invoice(self, key): + pr = self.invoices.get(key) + if pr is None: + self.show_error('Cannot find payment request in wallet.') + return + pr.verify(self.contacts) + self.show_pr_details(pr) + + def show_pr_details(self, pr): + key = pr.get_id() + d = WindowModalDialog(self, _("Invoice")) + vbox = QVBoxLayout(d) + grid = QGridLayout() + grid.addWidget(QLabel(_("Requestor") + ':'), 0, 0) + grid.addWidget(QLabel(pr.get_requestor()), 0, 1) + grid.addWidget(QLabel(_("Amount") + ':'), 1, 0) + outputs_str = '\n'.join(map(lambda x: self.format_amount(x[2])+ self.base_unit() + ' @ ' + x[1], pr.get_outputs())) + grid.addWidget(QLabel(outputs_str), 1, 1) + expires = pr.get_expiration_date() + grid.addWidget(QLabel(_("Memo") + ':'), 2, 0) + grid.addWidget(QLabel(pr.get_memo()), 2, 1) + grid.addWidget(QLabel(_("Signature") + ':'), 3, 0) + grid.addWidget(QLabel(pr.get_verify_status()), 3, 1) + if expires: + grid.addWidget(QLabel(_("Expires") + ':'), 4, 0) + grid.addWidget(QLabel(format_time(expires)), 4, 1) + vbox.addLayout(grid) + def do_export(): + fn = self.getSaveFileName(_("Save invoice to file"), "*.bip70") + if not fn: + return + with open(fn, 'wb') as f: + data = f.write(pr.raw) + self.show_message(_('Invoice saved as' + ' ' + fn)) + exportButton = EnterButton(_('Save'), do_export) + def do_delete(): + if self.question(_('Delete invoice?')): + self.invoices.remove(key) + self.history_list.update() + self.invoice_list.update() + d.close() + deleteButton = EnterButton(_('Delete'), do_delete) + vbox.addLayout(Buttons(exportButton, deleteButton, CloseButton(d))) + d.exec_() + + def do_pay_invoice(self, key): + pr = self.invoices.get(key) + self.payment_request = pr + self.prepare_for_payment_request() + pr.error = None # this forces verify() to re-run + if pr.verify(self.contacts): + self.payment_request_ok() + else: + self.payment_request_error() + + def create_console_tab(self): + from .console import Console + self.console = console = Console() + return console + + def update_console(self): + console = self.console + console.history = self.config.get("console-history",[]) + console.history_index = len(console.history) + + console.updateNamespace({'wallet' : self.wallet, + 'network' : self.network, + 'plugins' : self.gui_object.plugins, + 'window': self}) + console.updateNamespace({'util' : util, 'bitcoin':bitcoin}) + + c = commands.Commands(self.config, self.wallet, self.network, lambda: self.console.set_json(True)) + methods = {} + def mkfunc(f, method): + return lambda *args: f(method, args, self.password_dialog) + for m in dir(c): + if m[0]=='_' or m in ['network','wallet']: continue + methods[m] = mkfunc(c._run, m) + + console.updateNamespace(methods) + + def create_status_bar(self): + + sb = QStatusBar() + sb.setFixedHeight(35) + qtVersion = qVersion() + + self.balance_label = QLabel("") + self.balance_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.balance_label.setStyleSheet("""QLabel { padding: 0 }""") + sb.addWidget(self.balance_label) + + self.search_box = QLineEdit() + self.search_box.textChanged.connect(self.do_search) + self.search_box.hide() + sb.addPermanentWidget(self.search_box) + + self.lock_icon = QIcon() + self.password_button = StatusBarButton(self.lock_icon, _("Password"), self.change_password_dialog ) + sb.addPermanentWidget(self.password_button) + + sb.addPermanentWidget(StatusBarButton(QIcon(":icons/preferences.png"), _("Preferences"), self.settings_dialog ) ) + self.seed_button = StatusBarButton(QIcon(":icons/seed.png"), _("Seed"), self.show_seed_dialog ) + sb.addPermanentWidget(self.seed_button) + self.status_button = StatusBarButton(QIcon(":icons/status_disconnected.png"), _("Network"), lambda: self.gui_object.show_network_dialog(self)) + sb.addPermanentWidget(self.status_button) + run_hook('create_status_bar', sb) + self.setStatusBar(sb) + + def update_lock_icon(self): + icon = QIcon(":icons/lock.png") if self.wallet.has_password() else QIcon(":icons/unlock.png") + self.password_button.setIcon(icon) + + def update_buttons_on_seed(self): + self.seed_button.setVisible(self.wallet.has_seed()) + self.password_button.setVisible(self.wallet.may_have_password()) + self.send_button.setVisible(not self.wallet.is_watching_only()) + + def change_password_dialog(self): + from electrum.storage import STO_EV_XPUB_PW + if self.wallet.get_available_storage_encryption_version() == STO_EV_XPUB_PW: + from .password_dialog import ChangePasswordDialogForHW + d = ChangePasswordDialogForHW(self, self.wallet) + ok, encrypt_file = d.run() + if not ok: + return + + try: + hw_dev_pw = self.wallet.keystore.get_password_for_storage_encryption() + except UserCancelled: + return + except BaseException as e: + traceback.print_exc(file=sys.stderr) + self.show_error(str(e)) + return + old_password = hw_dev_pw if self.wallet.has_password() else None + new_password = hw_dev_pw if encrypt_file else None + else: + from .password_dialog import ChangePasswordDialogForSW + d = ChangePasswordDialogForSW(self, self.wallet) + ok, old_password, new_password, encrypt_file = d.run() + + if not ok: + return + try: + self.wallet.update_password(old_password, new_password, encrypt_file) + except InvalidPassword as e: + self.show_error(str(e)) + return + except BaseException: + traceback.print_exc(file=sys.stdout) + self.show_error(_('Failed to update password')) + return + msg = _('Password was updated successfully') if self.wallet.has_password() else _('Password is disabled, this wallet is not protected') + self.show_message(msg, title=_("Success")) + self.update_lock_icon() + + def toggle_search(self): + tab = self.tabs.currentWidget() + #if hasattr(tab, 'searchable_list'): + # tab.searchable_list.toggle_toolbar() + #return + self.search_box.setHidden(not self.search_box.isHidden()) + if not self.search_box.isHidden(): + self.search_box.setFocus(1) + else: + self.do_search('') + + def do_search(self, t): + tab = self.tabs.currentWidget() + if hasattr(tab, 'searchable_list'): + tab.searchable_list.filter(t) + + def new_contact_dialog(self): + d = WindowModalDialog(self, _("New Contact")) + vbox = QVBoxLayout(d) + vbox.addWidget(QLabel(_('New Contact') + ':')) + grid = QGridLayout() + line1 = QLineEdit() + line1.setFixedWidth(280) + line2 = QLineEdit() + line2.setFixedWidth(280) + grid.addWidget(QLabel(_("Address")), 1, 0) + grid.addWidget(line1, 1, 1) + grid.addWidget(QLabel(_("Name")), 2, 0) + grid.addWidget(line2, 2, 1) + vbox.addLayout(grid) + vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) + if d.exec_(): + self.set_contact(line2.text(), line1.text()) + + def show_master_public_keys(self): + dialog = WindowModalDialog(self, _("Wallet Information")) + dialog.setMinimumSize(500, 100) + mpk_list = self.wallet.get_master_public_keys() + vbox = QVBoxLayout() + wallet_type = self.wallet.storage.get('wallet_type', '') + grid = QGridLayout() + basename = os.path.basename(self.wallet.storage.path) + grid.addWidget(QLabel(_("Wallet name")+ ':'), 0, 0) + grid.addWidget(QLabel(basename), 0, 1) + grid.addWidget(QLabel(_("Wallet type")+ ':'), 1, 0) + grid.addWidget(QLabel(wallet_type), 1, 1) + grid.addWidget(QLabel(_("Script type")+ ':'), 2, 0) + grid.addWidget(QLabel(self.wallet.txin_type), 2, 1) + vbox.addLayout(grid) + if self.wallet.is_deterministic(): + mpk_text = ShowQRTextEdit() + mpk_text.setMaximumHeight(150) + mpk_text.addCopyButton(self.app) + def show_mpk(index): + mpk_text.setText(mpk_list[index]) + # only show the combobox in case multiple accounts are available + if len(mpk_list) > 1: + def label(key): + if isinstance(self.wallet, Multisig_Wallet): + return _("cosigner") + ' ' + str(key+1) + return '' + labels = [label(i) for i in range(len(mpk_list))] + on_click = lambda clayout: show_mpk(clayout.selected_index()) + labels_clayout = ChoicesLayout(_("Master Public Keys"), labels, on_click) + vbox.addLayout(labels_clayout.layout()) + else: + vbox.addWidget(QLabel(_("Master Public Key"))) + show_mpk(0) + vbox.addWidget(mpk_text) + vbox.addStretch(1) + vbox.addLayout(Buttons(CloseButton(dialog))) + dialog.setLayout(vbox) + dialog.exec_() + + def remove_wallet(self): + if self.question('\n'.join([ + _('Delete wallet file?'), + "%s"%self.wallet.storage.path, + _('If your wallet contains funds, make sure you have saved its seed.')])): + self._delete_wallet() + + @protected + def _delete_wallet(self, password): + wallet_path = self.wallet.storage.path + basename = os.path.basename(wallet_path) + self.gui_object.daemon.stop_wallet(wallet_path) + self.close() + os.unlink(wallet_path) + self.show_error(_("Wallet removed: {}").format(basename)) + + @protected + def show_seed_dialog(self, password): + if not self.wallet.has_seed(): + self.show_message(_('This wallet has no seed')) + return + keystore = self.wallet.get_keystore() + try: + seed = keystore.get_seed(password) + passphrase = keystore.get_passphrase(password) + except BaseException as e: + self.show_error(str(e)) + return + from .seed_dialog import SeedDialog + d = SeedDialog(self, seed, passphrase) + d.exec_() + + def show_qrcode(self, data, title = _("QR code"), parent=None): + if not data: + return + d = QRDialog(data, parent or self, title) + d.exec_() + + @protected + def show_private_key(self, address, password): + if not address: + return + try: + pk, redeem_script = self.wallet.export_private_key(address, password) + except Exception as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + xtype = bitcoin.deserialize_privkey(pk)[0] + d = WindowModalDialog(self, _("Private key")) + d.setMinimumSize(600, 150) + vbox = QVBoxLayout() + vbox.addWidget(QLabel(_("Address") + ': ' + address)) + vbox.addWidget(QLabel(_("Script type") + ': ' + xtype)) + vbox.addWidget(QLabel(_("Private key") + ':')) + keys_e = ShowQRTextEdit(text=pk) + keys_e.addCopyButton(self.app) + vbox.addWidget(keys_e) + if redeem_script: + vbox.addWidget(QLabel(_("Redeem Script") + ':')) + rds_e = ShowQRTextEdit(text=redeem_script) + rds_e.addCopyButton(self.app) + vbox.addWidget(rds_e) + vbox.addLayout(Buttons(CloseButton(d))) + d.setLayout(vbox) + d.exec_() + + msg_sign = _("Signing with an address actually means signing with the corresponding " + "private key, and verifying with the corresponding public key. The " + "address you have entered does not have a unique public key, so these " + "operations cannot be performed.") + '\n\n' + \ + _('The operation is undefined. Not just in Electrum, but in general.') + + @protected + def do_sign(self, address, message, signature, password): + address = address.text().strip() + message = message.toPlainText().strip() + if not bitcoin.is_address(address): + self.show_message(_('Invalid Bitcoin address.')) + return + if self.wallet.is_watching_only(): + self.show_message(_('This is a watching-only wallet.')) + return + if not self.wallet.is_mine(address): + self.show_message(_('Address not in wallet.')) + return + txin_type = self.wallet.get_txin_type(address) + if txin_type not in ['p2pkh', 'p2wpkh', 'p2wpkh-p2sh']: + self.show_message(_('Cannot sign messages with this type of address:') + \ + ' ' + txin_type + '\n\n' + self.msg_sign) + return + task = partial(self.wallet.sign_message, address, message, password) + + def show_signed_message(sig): + try: + signature.setText(base64.b64encode(sig).decode('ascii')) + except RuntimeError: + # (signature) wrapped C/C++ object has been deleted + pass + + self.wallet.thread.add(task, on_success=show_signed_message) + + def do_verify(self, address, message, signature): + address = address.text().strip() + message = message.toPlainText().strip().encode('utf-8') + if not bitcoin.is_address(address): + self.show_message(_('Invalid Bitcoin address.')) + return + try: + # This can throw on invalid base64 + sig = base64.b64decode(str(signature.toPlainText())) + verified = ecc.verify_message_with_address(address, sig, message) + except Exception as e: + verified = False + if verified: + self.show_message(_("Signature verified")) + else: + self.show_error(_("Wrong signature")) + + def sign_verify_message(self, address=''): + d = WindowModalDialog(self, _('Sign/verify Message')) + d.setMinimumSize(610, 290) + + layout = QGridLayout(d) + + message_e = QTextEdit() + layout.addWidget(QLabel(_('Message')), 1, 0) + layout.addWidget(message_e, 1, 1) + layout.setRowStretch(2,3) + + address_e = QLineEdit() + address_e.setText(address) + layout.addWidget(QLabel(_('Address')), 2, 0) + layout.addWidget(address_e, 2, 1) + + signature_e = QTextEdit() + layout.addWidget(QLabel(_('Signature')), 3, 0) + layout.addWidget(signature_e, 3, 1) + layout.setRowStretch(3,1) + + hbox = QHBoxLayout() + + b = QPushButton(_("Sign")) + b.clicked.connect(lambda: self.do_sign(address_e, message_e, signature_e)) + hbox.addWidget(b) + + b = QPushButton(_("Verify")) + b.clicked.connect(lambda: self.do_verify(address_e, message_e, signature_e)) + hbox.addWidget(b) + + b = QPushButton(_("Close")) + b.clicked.connect(d.accept) + hbox.addWidget(b) + layout.addLayout(hbox, 4, 1) + d.exec_() + + @protected + def do_decrypt(self, message_e, pubkey_e, encrypted_e, password): + if self.wallet.is_watching_only(): + self.show_message(_('This is a watching-only wallet.')) + return + cyphertext = encrypted_e.toPlainText() + task = partial(self.wallet.decrypt_message, pubkey_e.text(), cyphertext, password) + + def setText(text): + try: + message_e.setText(text.decode('utf-8')) + except RuntimeError: + # (message_e) wrapped C/C++ object has been deleted + pass + + self.wallet.thread.add(task, on_success=setText) + + def do_encrypt(self, message_e, pubkey_e, encrypted_e): + message = message_e.toPlainText() + message = message.encode('utf-8') + try: + public_key = ecc.ECPubkey(bfh(pubkey_e.text())) + except BaseException as e: + traceback.print_exc(file=sys.stdout) + self.show_warning(_('Invalid Public key')) + return + encrypted = public_key.encrypt_message(message) + encrypted_e.setText(encrypted.decode('ascii')) + + def encrypt_message(self, address=''): + d = WindowModalDialog(self, _('Encrypt/decrypt Message')) + d.setMinimumSize(610, 490) + + layout = QGridLayout(d) + + message_e = QTextEdit() + layout.addWidget(QLabel(_('Message')), 1, 0) + layout.addWidget(message_e, 1, 1) + layout.setRowStretch(2,3) + + pubkey_e = QLineEdit() + if address: + pubkey = self.wallet.get_public_key(address) + pubkey_e.setText(pubkey) + layout.addWidget(QLabel(_('Public key')), 2, 0) + layout.addWidget(pubkey_e, 2, 1) + + encrypted_e = QTextEdit() + layout.addWidget(QLabel(_('Encrypted')), 3, 0) + layout.addWidget(encrypted_e, 3, 1) + layout.setRowStretch(3,1) + + hbox = QHBoxLayout() + b = QPushButton(_("Encrypt")) + b.clicked.connect(lambda: self.do_encrypt(message_e, pubkey_e, encrypted_e)) + hbox.addWidget(b) + + b = QPushButton(_("Decrypt")) + b.clicked.connect(lambda: self.do_decrypt(message_e, pubkey_e, encrypted_e)) + hbox.addWidget(b) + + b = QPushButton(_("Close")) + b.clicked.connect(d.accept) + hbox.addWidget(b) + + layout.addLayout(hbox, 4, 1) + d.exec_() + + def password_dialog(self, msg=None, parent=None): + from .password_dialog import PasswordDialog + parent = parent or self + d = PasswordDialog(parent, msg) + return d.run() + + def tx_from_text(self, txt): + from electrum.transaction import tx_from_str + try: + tx = tx_from_str(txt) + return Transaction(tx) + except BaseException as e: + self.show_critical(_("Electrum was unable to parse your transaction") + ":\n" + str(e)) + return + + def read_tx_from_qrcode(self): + from electrum import qrscanner + try: + data = qrscanner.scan_barcode(self.config.get_video_device()) + except BaseException as e: + self.show_error(str(e)) + return + if not data: + return + # if the user scanned a bitcoin URI + if str(data).startswith("bitcoin:"): + self.pay_to_URI(data) + return + # else if the user scanned an offline signed tx + try: + data = bh2u(bitcoin.base_decode(data, length=None, base=43)) + except BaseException as e: + self.show_error((_('Could not decode QR code')+':\n{}').format(e)) + return + tx = self.tx_from_text(data) + if not tx: + return + self.show_transaction(tx) + + def read_tx_from_file(self): + fileName = self.getOpenFileName(_("Select your transaction file"), "*.txn") + if not fileName: + return + try: + with open(fileName, "r") as f: + file_content = f.read() + except (ValueError, IOError, os.error) as reason: + self.show_critical(_("Electrum was unable to open your transaction file") + "\n" + str(reason), title=_("Unable to read file or no transaction found")) + return + return self.tx_from_text(file_content) + + def do_process_from_text(self): + text = text_dialog(self, _('Input raw transaction'), _("Transaction:"), _("Load transaction")) + if not text: + return + tx = self.tx_from_text(text) + if tx: + self.show_transaction(tx) + + def do_process_from_file(self): + tx = self.read_tx_from_file() + if tx: + self.show_transaction(tx) + + def do_process_from_txid(self): + from electrum import transaction + txid, ok = QInputDialog.getText(self, _('Lookup transaction'), _('Transaction ID') + ':') + if ok and txid: + txid = str(txid).strip() + try: + r = self.network.get_transaction(txid) + except BaseException as e: + self.show_message(str(e)) + return + tx = transaction.Transaction(r) + self.show_transaction(tx) + + @protected + def export_privkeys_dialog(self, password): + if self.wallet.is_watching_only(): + self.show_message(_("This is a watching-only wallet")) + return + + if isinstance(self.wallet, Multisig_Wallet): + self.show_message(_('WARNING: This is a multi-signature wallet.') + '\n' + + _('It cannot be "backed up" by simply exporting these private keys.')) + + d = WindowModalDialog(self, _('Private keys')) + d.setMinimumSize(980, 300) + vbox = QVBoxLayout(d) + + msg = "%s\n%s\n%s" % (_("WARNING: ALL your private keys are secret."), + _("Exposing a single private key can compromise your entire wallet!"), + _("In particular, DO NOT use 'redeem private key' services proposed by third parties.")) + vbox.addWidget(QLabel(msg)) + + e = QTextEdit() + e.setReadOnly(True) + vbox.addWidget(e) + + defaultname = 'electrum-private-keys.csv' + select_msg = _('Select file to export your private keys to') + hbox, filename_e, csv_button = filename_field(self, self.config, defaultname, select_msg) + vbox.addLayout(hbox) + + b = OkButton(d, _('Export')) + b.setEnabled(False) + vbox.addLayout(Buttons(CancelButton(d), b)) + + private_keys = {} + addresses = self.wallet.get_addresses() + done = False + cancelled = False + def privkeys_thread(): + for addr in addresses: + time.sleep(0.1) + if done or cancelled: + break + privkey = self.wallet.export_private_key(addr, password)[0] + private_keys[addr] = privkey + self.computing_privkeys_signal.emit() + if not cancelled: + self.computing_privkeys_signal.disconnect() + self.show_privkeys_signal.emit() + + def show_privkeys(): + s = "\n".join( map( lambda x: x[0] + "\t"+ x[1], private_keys.items())) + e.setText(s) + b.setEnabled(True) + self.show_privkeys_signal.disconnect() + nonlocal done + done = True + + def on_dialog_closed(*args): + nonlocal done + nonlocal cancelled + if not done: + cancelled = True + self.computing_privkeys_signal.disconnect() + self.show_privkeys_signal.disconnect() + + self.computing_privkeys_signal.connect(lambda: e.setText("Please wait... %d/%d"%(len(private_keys),len(addresses)))) + self.show_privkeys_signal.connect(show_privkeys) + d.finished.connect(on_dialog_closed) + threading.Thread(target=privkeys_thread).start() + + if not d.exec_(): + done = True + return + + filename = filename_e.text() + if not filename: + return + + try: + self.do_export_privkeys(filename, private_keys, csv_button.isChecked()) + except (IOError, os.error) as reason: + txt = "\n".join([ + _("Electrum was unable to produce a private key-export."), + str(reason) + ]) + self.show_critical(txt, title=_("Unable to create csv")) + + except Exception as e: + self.show_message(str(e)) + return + + self.show_message(_("Private keys exported.")) + + def do_export_privkeys(self, fileName, pklist, is_csv): + with open(fileName, "w+") as f: + if is_csv: + transaction = csv.writer(f) + transaction.writerow(["address", "private_key"]) + for addr, pk in pklist.items(): + transaction.writerow(["%34s"%addr,pk]) + else: + import json + f.write(json.dumps(pklist, indent = 4)) + + def do_import_labels(self): + def import_labels(path): + def _validate(data): + return data # TODO + + def import_labels_assign(data): + for key, value in data.items(): + self.wallet.set_label(key, value) + import_meta(path, _validate, import_labels_assign) + + def on_import(): + self.need_update.set() + import_meta_gui(self, _('labels'), import_labels, on_import) + + def do_export_labels(self): + def export_labels(filename): + export_meta(self.wallet.labels, filename) + export_meta_gui(self, _('labels'), export_labels) + + def sweep_key_dialog(self): + d = WindowModalDialog(self, title=_('Sweep private keys')) + d.setMinimumSize(600, 300) + + vbox = QVBoxLayout(d) + + hbox_top = QHBoxLayout() + hbox_top.addWidget(QLabel(_("Enter private keys:"))) + hbox_top.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignRight) + vbox.addLayout(hbox_top) + + keys_e = ScanQRTextEdit(allow_multi=True) + keys_e.setTabChangesFocus(True) + vbox.addWidget(keys_e) + + addresses = self.wallet.get_unused_addresses() + if not addresses: + try: + addresses = self.wallet.get_receiving_addresses() + except AttributeError: + addresses = self.wallet.get_addresses() + h, address_e = address_field(addresses) + vbox.addLayout(h) + + vbox.addStretch(1) + button = OkButton(d, _('Sweep')) + vbox.addLayout(Buttons(CancelButton(d), button)) + button.setEnabled(False) + + def get_address(): + addr = str(address_e.text()).strip() + if bitcoin.is_address(addr): + return addr + + def get_pk(): + text = str(keys_e.toPlainText()) + return keystore.get_private_keys(text) + + f = lambda: button.setEnabled(get_address() is not None and get_pk() is not None) + on_address = lambda text: address_e.setStyleSheet((ColorScheme.DEFAULT if get_address() else ColorScheme.RED).as_stylesheet()) + keys_e.textChanged.connect(f) + address_e.textChanged.connect(f) + address_e.textChanged.connect(on_address) + if not d.exec_(): + return + from electrum.wallet import sweep_preparations + try: + self.do_clear() + coins, keypairs = sweep_preparations(get_pk(), self.network) + self.tx_external_keypairs = keypairs + self.spend_coins(coins) + self.payto_e.setText(get_address()) + self.spend_max() + self.payto_e.setFrozen(True) + self.amount_e.setFrozen(True) + except BaseException as e: + self.show_message(str(e)) + return + self.warn_if_watching_only() + + def _do_import(self, title, header_layout, func): + text = text_dialog(self, title, header_layout, _('Import'), allow_multi=True) + if not text: + return + bad = [] + good = [] + for key in str(text).split(): + try: + addr = func(key) + good.append(addr) + except BaseException as e: + bad.append(key) + continue + if good: + self.show_message(_("The following addresses were added") + ':\n' + '\n'.join(good)) + if bad: + self.show_critical(_("The following inputs could not be imported") + ':\n'+ '\n'.join(bad)) + self.address_list.update() + self.history_list.update() + + def import_addresses(self): + if not self.wallet.can_import_address(): + return + title, msg = _('Import addresses'), _("Enter addresses")+':' + self._do_import(title, msg, self.wallet.import_address) + + @protected + def do_import_privkey(self, password): + if not self.wallet.can_import_privkey(): + return + title = _('Import private keys') + header_layout = QHBoxLayout() + header_layout.addWidget(QLabel(_("Enter private keys")+':')) + header_layout.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignRight) + self._do_import(title, header_layout, lambda x: self.wallet.import_private_key(x, password)) + + def update_fiat(self): + b = self.fx and self.fx.is_enabled() + self.fiat_send_e.setVisible(b) + self.fiat_receive_e.setVisible(b) + self.history_list.refresh_headers() + self.history_list.update() + self.address_list.refresh_headers() + self.address_list.update() + self.update_status() + + def settings_dialog(self): + self.need_restart = False + d = WindowModalDialog(self, _('Preferences')) + vbox = QVBoxLayout() + tabs = QTabWidget() + gui_widgets = [] + fee_widgets = [] + tx_widgets = [] + id_widgets = [] + + # language + lang_help = _('Select which language is used in the GUI (after restart).') + lang_label = HelpLabel(_('Language') + ':', lang_help) + lang_combo = QComboBox() + from electrum.i18n import languages + lang_combo.addItems(list(languages.values())) + lang_keys = list(languages.keys()) + lang_cur_setting = self.config.get("language", '') + try: + index = lang_keys.index(lang_cur_setting) + except ValueError: # not in list + index = 0 + lang_combo.setCurrentIndex(index) + if not self.config.is_modifiable('language'): + for w in [lang_combo, lang_label]: w.setEnabled(False) + def on_lang(x): + lang_request = list(languages.keys())[lang_combo.currentIndex()] + if lang_request != self.config.get('language'): + self.config.set_key("language", lang_request, True) + self.need_restart = True + lang_combo.currentIndexChanged.connect(on_lang) + gui_widgets.append((lang_label, lang_combo)) + + nz_help = _('Number of zeros displayed after the decimal point. For example, if this is set to 2, "1." will be displayed as "1.00"') + nz_label = HelpLabel(_('Zeros after decimal point') + ':', nz_help) + nz = QSpinBox() + nz.setMinimum(0) + nz.setMaximum(self.decimal_point) + nz.setValue(self.num_zeros) + if not self.config.is_modifiable('num_zeros'): + for w in [nz, nz_label]: w.setEnabled(False) + def on_nz(): + value = nz.value() + if self.num_zeros != value: + self.num_zeros = value + self.config.set_key('num_zeros', value, True) + self.history_list.update() + self.address_list.update() + nz.valueChanged.connect(on_nz) + gui_widgets.append((nz_label, nz)) + + msg = '\n'.join([ + _('Time based: fee rate is based on average confirmation time estimates'), + _('Mempool based: fee rate is targeting a depth in the memory pool') + ] + ) + fee_type_label = HelpLabel(_('Fee estimation') + ':', msg) + fee_type_combo = QComboBox() + fee_type_combo.addItems([_('Static'), _('ETA'), _('Mempool')]) + fee_type_combo.setCurrentIndex((2 if self.config.use_mempool_fees() else 1) if self.config.is_dynfee() else 0) + def on_fee_type(x): + self.config.set_key('mempool_fees', x==2) + self.config.set_key('dynamic_fees', x>0) + self.fee_slider.update() + fee_type_combo.currentIndexChanged.connect(on_fee_type) + fee_widgets.append((fee_type_label, fee_type_combo)) + + feebox_cb = QCheckBox(_('Edit fees manually')) + feebox_cb.setChecked(self.config.get('show_fee', False)) + feebox_cb.setToolTip(_("Show fee edit box in send tab.")) + def on_feebox(x): + self.config.set_key('show_fee', x == Qt.Checked) + self.fee_adv_controls.setVisible(bool(x)) + feebox_cb.stateChanged.connect(on_feebox) + fee_widgets.append((feebox_cb, None)) + + use_rbf_cb = QCheckBox(_('Use Replace-By-Fee')) + use_rbf_cb.setChecked(self.config.get('use_rbf', True)) + use_rbf_cb.setToolTip( + _('If you check this box, your transactions will be marked as non-final,') + '\n' + \ + _('and you will have the possibility, while they are unconfirmed, to replace them with transactions that pay higher fees.') + '\n' + \ + _('Note that some merchants do not accept non-final transactions until they are confirmed.')) + def on_use_rbf(x): + self.config.set_key('use_rbf', x == Qt.Checked) + use_rbf_cb.stateChanged.connect(on_use_rbf) + fee_widgets.append((use_rbf_cb, None)) + + msg = _('OpenAlias record, used to receive coins and to sign payment requests.') + '\n\n'\ + + _('The following alias providers are available:') + '\n'\ + + '\n'.join(['https://cryptoname.co/', 'http://xmr.link']) + '\n\n'\ + + 'For more information, see https://openalias.org' + alias_label = HelpLabel(_('OpenAlias') + ':', msg) + alias = self.config.get('alias','') + alias_e = QLineEdit(alias) + def set_alias_color(): + if not self.config.get('alias'): + alias_e.setStyleSheet("") + return + if self.alias_info: + alias_addr, alias_name, validated = self.alias_info + alias_e.setStyleSheet((ColorScheme.GREEN if validated else ColorScheme.RED).as_stylesheet(True)) + else: + alias_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True)) + def on_alias_edit(): + alias_e.setStyleSheet("") + alias = str(alias_e.text()) + self.config.set_key('alias', alias, True) + if alias: + self.fetch_alias() + set_alias_color() + self.alias_received_signal.connect(set_alias_color) + alias_e.editingFinished.connect(on_alias_edit) + id_widgets.append((alias_label, alias_e)) + + # SSL certificate + msg = ' '.join([ + _('SSL certificate used to sign payment requests.'), + _('Use setconfig to set ssl_chain and ssl_privkey.'), + ]) + if self.config.get('ssl_privkey') or self.config.get('ssl_chain'): + try: + SSL_identity = paymentrequest.check_ssl_config(self.config) + SSL_error = None + except BaseException as e: + SSL_identity = "error" + SSL_error = str(e) + else: + SSL_identity = "" + SSL_error = None + SSL_id_label = HelpLabel(_('SSL certificate') + ':', msg) + SSL_id_e = QLineEdit(SSL_identity) + SSL_id_e.setStyleSheet((ColorScheme.RED if SSL_error else ColorScheme.GREEN).as_stylesheet(True) if SSL_identity else '') + if SSL_error: + SSL_id_e.setToolTip(SSL_error) + SSL_id_e.setReadOnly(True) + id_widgets.append((SSL_id_label, SSL_id_e)) + + units = base_units_list + msg = (_('Base unit of your wallet.') + + '\n1 BTC = 1000 mBTC. 1 mBTC = 1000 bits. 1 bit = 100 sat.\n' + + _('This setting affects the Send tab, and all balance related fields.')) + unit_label = HelpLabel(_('Base unit') + ':', msg) + unit_combo = QComboBox() + unit_combo.addItems(units) + unit_combo.setCurrentIndex(units.index(self.base_unit())) + def on_unit(x, nz): + unit_result = units[unit_combo.currentIndex()] + if self.base_unit() == unit_result: + return + edits = self.amount_e, self.fee_e, self.receive_amount_e + amounts = [edit.get_amount() for edit in edits] + self.decimal_point = base_unit_name_to_decimal_point(unit_result) + self.config.set_key('decimal_point', self.decimal_point, True) + nz.setMaximum(self.decimal_point) + self.history_list.update() + self.request_list.update() + self.address_list.update() + for edit, amount in zip(edits, amounts): + edit.setAmount(amount) + self.update_status() + unit_combo.currentIndexChanged.connect(lambda x: on_unit(x, nz)) + gui_widgets.append((unit_label, unit_combo)) + + block_explorers = sorted(util.block_explorer_info().keys()) + msg = _('Choose which online block explorer to use for functions that open a web browser') + block_ex_label = HelpLabel(_('Online Block Explorer') + ':', msg) + block_ex_combo = QComboBox() + block_ex_combo.addItems(block_explorers) + block_ex_combo.setCurrentIndex(block_ex_combo.findText(util.block_explorer(self.config))) + def on_be(x): + be_result = block_explorers[block_ex_combo.currentIndex()] + self.config.set_key('block_explorer', be_result, True) + block_ex_combo.currentIndexChanged.connect(on_be) + gui_widgets.append((block_ex_label, block_ex_combo)) + + from electrum import qrscanner + system_cameras = qrscanner._find_system_cameras() + qr_combo = QComboBox() + qr_combo.addItem("Default","default") + for camera, device in system_cameras.items(): + qr_combo.addItem(camera, device) + #combo.addItem("Manually specify a device", config.get("video_device")) + index = qr_combo.findData(self.config.get("video_device")) + qr_combo.setCurrentIndex(index) + msg = _("Install the zbar package to enable this.") + qr_label = HelpLabel(_('Video Device') + ':', msg) + qr_combo.setEnabled(qrscanner.libzbar is not None) + on_video_device = lambda x: self.config.set_key("video_device", qr_combo.itemData(x), True) + qr_combo.currentIndexChanged.connect(on_video_device) + gui_widgets.append((qr_label, qr_combo)) + + colortheme_combo = QComboBox() + colortheme_combo.addItem(_('Light'), 'default') + colortheme_combo.addItem(_('Dark'), 'dark') + index = colortheme_combo.findData(self.config.get('qt_gui_color_theme', 'default')) + colortheme_combo.setCurrentIndex(index) + colortheme_label = QLabel(_('Color theme') + ':') + def on_colortheme(x): + self.config.set_key('qt_gui_color_theme', colortheme_combo.itemData(x), True) + self.need_restart = True + colortheme_combo.currentIndexChanged.connect(on_colortheme) + gui_widgets.append((colortheme_label, colortheme_combo)) + + usechange_cb = QCheckBox(_('Use change addresses')) + usechange_cb.setChecked(self.wallet.use_change) + if not self.config.is_modifiable('use_change'): usechange_cb.setEnabled(False) + def on_usechange(x): + usechange_result = x == Qt.Checked + if self.wallet.use_change != usechange_result: + self.wallet.use_change = usechange_result + self.wallet.storage.put('use_change', self.wallet.use_change) + multiple_cb.setEnabled(self.wallet.use_change) + usechange_cb.stateChanged.connect(on_usechange) + usechange_cb.setToolTip(_('Using change addresses makes it more difficult for other people to track your transactions.')) + tx_widgets.append((usechange_cb, None)) + + def on_multiple(x): + multiple = x == Qt.Checked + if self.wallet.multiple_change != multiple: + self.wallet.multiple_change = multiple + self.wallet.storage.put('multiple_change', multiple) + multiple_change = self.wallet.multiple_change + multiple_cb = QCheckBox(_('Use multiple change addresses')) + multiple_cb.setEnabled(self.wallet.use_change) + multiple_cb.setToolTip('\n'.join([ + _('In some cases, use up to 3 change addresses in order to break ' + 'up large coin amounts and obfuscate the recipient address.'), + _('This may result in higher transactions fees.') + ])) + multiple_cb.setChecked(multiple_change) + multiple_cb.stateChanged.connect(on_multiple) + tx_widgets.append((multiple_cb, None)) + + def fmt_docs(key, klass): + lines = [ln.lstrip(" ") for ln in klass.__doc__.split("\n")] + return '\n'.join([key, "", " ".join(lines)]) + + choosers = sorted(coinchooser.COIN_CHOOSERS.keys()) + if len(choosers) > 1: + chooser_name = coinchooser.get_name(self.config) + msg = _('Choose coin (UTXO) selection method. The following are available:\n\n') + msg += '\n\n'.join(fmt_docs(*item) for item in coinchooser.COIN_CHOOSERS.items()) + chooser_label = HelpLabel(_('Coin selection') + ':', msg) + chooser_combo = QComboBox() + chooser_combo.addItems(choosers) + i = choosers.index(chooser_name) if chooser_name in choosers else 0 + chooser_combo.setCurrentIndex(i) + def on_chooser(x): + chooser_name = choosers[chooser_combo.currentIndex()] + self.config.set_key('coin_chooser', chooser_name) + chooser_combo.currentIndexChanged.connect(on_chooser) + tx_widgets.append((chooser_label, chooser_combo)) + + def on_unconf(x): + self.config.set_key('confirmed_only', bool(x)) + conf_only = self.config.get('confirmed_only', False) + unconf_cb = QCheckBox(_('Spend only confirmed coins')) + unconf_cb.setToolTip(_('Spend only confirmed inputs.')) + unconf_cb.setChecked(conf_only) + unconf_cb.stateChanged.connect(on_unconf) + tx_widgets.append((unconf_cb, None)) + + def on_outrounding(x): + self.config.set_key('coin_chooser_output_rounding', bool(x)) + enable_outrounding = self.config.get('coin_chooser_output_rounding', False) + outrounding_cb = QCheckBox(_('Enable output value rounding')) + outrounding_cb.setToolTip( + _('Set the value of the change output so that it has similar precision to the other outputs.') + '\n' + + _('This might improve your privacy somewhat.') + '\n' + + _('If enabled, at most 100 satoshis might be lost due to this, per transaction.')) + outrounding_cb.setChecked(enable_outrounding) + outrounding_cb.stateChanged.connect(on_outrounding) + tx_widgets.append((outrounding_cb, None)) + + # Fiat Currency + hist_checkbox = QCheckBox() + hist_capgains_checkbox = QCheckBox() + fiat_address_checkbox = QCheckBox() + ccy_combo = QComboBox() + ex_combo = QComboBox() + + def update_currencies(): + if not self.fx: return + currencies = sorted(self.fx.get_currencies(self.fx.get_history_config())) + ccy_combo.clear() + ccy_combo.addItems([_('None')] + currencies) + if self.fx.is_enabled(): + ccy_combo.setCurrentIndex(ccy_combo.findText(self.fx.get_currency())) + + def update_history_cb(): + if not self.fx: return + hist_checkbox.setChecked(self.fx.get_history_config()) + hist_checkbox.setEnabled(self.fx.is_enabled()) + + def update_fiat_address_cb(): + if not self.fx: return + fiat_address_checkbox.setChecked(self.fx.get_fiat_address_config()) + + def update_history_capgains_cb(): + if not self.fx: return + hist_capgains_checkbox.setChecked(self.fx.get_history_capital_gains_config()) + hist_capgains_checkbox.setEnabled(hist_checkbox.isChecked()) + + def update_exchanges(): + if not self.fx: return + b = self.fx.is_enabled() + ex_combo.setEnabled(b) + if b: + h = self.fx.get_history_config() + c = self.fx.get_currency() + exchanges = self.fx.get_exchanges_by_ccy(c, h) + else: + exchanges = self.fx.get_exchanges_by_ccy('USD', False) + ex_combo.clear() + ex_combo.addItems(sorted(exchanges)) + ex_combo.setCurrentIndex(ex_combo.findText(self.fx.config_exchange())) + + def on_currency(hh): + if not self.fx: return + b = bool(ccy_combo.currentIndex()) + ccy = str(ccy_combo.currentText()) if b else None + self.fx.set_enabled(b) + if b and ccy != self.fx.ccy: + self.fx.set_currency(ccy) + update_history_cb() + update_exchanges() + self.update_fiat() + + def on_exchange(idx): + exchange = str(ex_combo.currentText()) + if self.fx and self.fx.is_enabled() and exchange and exchange != self.fx.exchange.name(): + self.fx.set_exchange(exchange) + + def on_history(checked): + if not self.fx: return + self.fx.set_history_config(checked) + update_exchanges() + self.history_list.refresh_headers() + if self.fx.is_enabled() and checked: + # reset timeout to get historical rates + self.fx.timeout = 0 + update_history_capgains_cb() + + def on_history_capgains(checked): + if not self.fx: return + self.fx.set_history_capital_gains_config(checked) + self.history_list.refresh_headers() + + def on_fiat_address(checked): + if not self.fx: return + self.fx.set_fiat_address_config(checked) + self.address_list.refresh_headers() + self.address_list.update() + + update_currencies() + update_history_cb() + update_history_capgains_cb() + update_fiat_address_cb() + update_exchanges() + ccy_combo.currentIndexChanged.connect(on_currency) + hist_checkbox.stateChanged.connect(on_history) + hist_capgains_checkbox.stateChanged.connect(on_history_capgains) + fiat_address_checkbox.stateChanged.connect(on_fiat_address) + ex_combo.currentIndexChanged.connect(on_exchange) + + fiat_widgets = [] + fiat_widgets.append((QLabel(_('Fiat currency')), ccy_combo)) + fiat_widgets.append((QLabel(_('Show history rates')), hist_checkbox)) + fiat_widgets.append((QLabel(_('Show capital gains in history')), hist_capgains_checkbox)) + fiat_widgets.append((QLabel(_('Show Fiat balance for addresses')), fiat_address_checkbox)) + fiat_widgets.append((QLabel(_('Source')), ex_combo)) + + tabs_info = [ + (fee_widgets, _('Fees')), + (tx_widgets, _('Transactions')), + (gui_widgets, _('Appearance')), + (fiat_widgets, _('Fiat')), + (id_widgets, _('Identity')), + ] + for widgets, name in tabs_info: + tab = QWidget() + grid = QGridLayout(tab) + grid.setColumnStretch(0,1) + for a,b in widgets: + i = grid.rowCount() + if b: + if a: + grid.addWidget(a, i, 0) + grid.addWidget(b, i, 1) + else: + grid.addWidget(a, i, 0, 1, 2) + tabs.addTab(tab, name) + + vbox.addWidget(tabs) + vbox.addStretch(1) + vbox.addLayout(Buttons(CloseButton(d))) + d.setLayout(vbox) + + # run the dialog + d.exec_() + + if self.fx: + self.fx.timeout = 0 + + self.alias_received_signal.disconnect(set_alias_color) + + run_hook('close_settings_dialog') + if self.need_restart: + self.show_warning(_('Please restart Electrum to activate the new GUI settings'), title=_('Success')) + + + def closeEvent(self, event): + # It seems in some rare cases this closeEvent() is called twice + if not self.cleaned_up: + self.cleaned_up = True + self.clean_up() + event.accept() + + def clean_up(self): + self.wallet.thread.stop() + if self.network: + self.network.unregister_callback(self.on_network) + self.config.set_key("is_maximized", self.isMaximized()) + if not self.isMaximized(): + g = self.geometry() + self.wallet.storage.put("winpos-qt", [g.left(),g.top(), + g.width(),g.height()]) + self.config.set_key("console-history", self.console.history[-50:], + True) + if self.qr_window: + self.qr_window.close() + self.close_wallet() + self.gui_object.close_window(self) + + def plugins_dialog(self): + self.pluginsdialog = d = WindowModalDialog(self, _('Electrum Plugins')) + + plugins = self.gui_object.plugins + + vbox = QVBoxLayout(d) + + # plugins + scroll = QScrollArea() + scroll.setEnabled(True) + scroll.setWidgetResizable(True) + scroll.setMinimumSize(400,250) + vbox.addWidget(scroll) + + w = QWidget() + scroll.setWidget(w) + w.setMinimumHeight(plugins.count() * 35) + + grid = QGridLayout() + grid.setColumnStretch(0,1) + w.setLayout(grid) + + settings_widgets = {} + + def enable_settings_widget(p, name, i): + widget = settings_widgets.get(name) + if not widget and p and p.requires_settings(): + widget = settings_widgets[name] = p.settings_widget(d) + grid.addWidget(widget, i, 1) + if widget: + widget.setEnabled(bool(p and p.is_enabled())) + + def do_toggle(cb, name, i): + p = plugins.toggle(name) + cb.setChecked(bool(p)) + enable_settings_widget(p, name, i) + run_hook('init_qt', self.gui_object) + + for i, descr in enumerate(plugins.descriptions.values()): + full_name = descr['__name__'] + prefix, _separator, name = full_name.rpartition('.') + p = plugins.get(name) + if descr.get('registers_keystore'): + continue + try: + cb = QCheckBox(descr['fullname']) + plugin_is_loaded = p is not None + cb_enabled = (not plugin_is_loaded and plugins.is_available(name, self.wallet) + or plugin_is_loaded and p.can_user_disable()) + cb.setEnabled(cb_enabled) + cb.setChecked(plugin_is_loaded and p.is_enabled()) + grid.addWidget(cb, i, 0) + enable_settings_widget(p, name, i) + cb.clicked.connect(partial(do_toggle, cb, name, i)) + msg = descr['description'] + if descr.get('requires'): + msg += '\n\n' + _('Requires') + ':\n' + '\n'.join(map(lambda x: x[1], descr.get('requires'))) + grid.addWidget(HelpButton(msg), i, 2) + except Exception: + self.print_msg("error: cannot display plugin", name) + traceback.print_exc(file=sys.stdout) + grid.setRowStretch(len(plugins.descriptions.values()), 1) + vbox.addLayout(Buttons(CloseButton(d))) + d.exec_() + + def cpfp(self, parent_tx, new_tx): + total_size = parent_tx.estimated_size() + new_tx.estimated_size() + d = WindowModalDialog(self, _('Child Pays for Parent')) + vbox = QVBoxLayout(d) + msg = ( + "A CPFP is a transaction that sends an unconfirmed output back to " + "yourself, with a high fee. The goal is to have miners confirm " + "the parent transaction in order to get the fee attached to the " + "child transaction.") + vbox.addWidget(WWLabel(_(msg))) + msg2 = ("The proposed fee is computed using your " + "fee/kB settings, applied to the total size of both child and " + "parent transactions. After you broadcast a CPFP transaction, " + "it is normal to see a new unconfirmed transaction in your history.") + vbox.addWidget(WWLabel(_(msg2))) + grid = QGridLayout() + grid.addWidget(QLabel(_('Total size') + ':'), 0, 0) + grid.addWidget(QLabel('%d bytes'% total_size), 0, 1) + max_fee = new_tx.output_value() + grid.addWidget(QLabel(_('Input amount') + ':'), 1, 0) + grid.addWidget(QLabel(self.format_amount(max_fee) + ' ' + self.base_unit()), 1, 1) + output_amount = QLabel('') + grid.addWidget(QLabel(_('Output amount') + ':'), 2, 0) + grid.addWidget(output_amount, 2, 1) + fee_e = BTCAmountEdit(self.get_decimal_point) + # FIXME with dyn fees, without estimates, there are all kinds of crashes here + def f(x): + a = max_fee - fee_e.get_amount() + output_amount.setText((self.format_amount(a) + ' ' + self.base_unit()) if a else '') + fee_e.textChanged.connect(f) + fee = self.config.fee_per_kb() * total_size / 1000 + fee_e.setAmount(fee) + grid.addWidget(QLabel(_('Fee' + ':')), 3, 0) + grid.addWidget(fee_e, 3, 1) + def on_rate(dyn, pos, fee_rate): + fee = fee_rate * total_size / 1000 + fee = min(max_fee, fee) + fee_e.setAmount(fee) + fee_slider = FeeSlider(self, self.config, on_rate) + fee_slider.update() + grid.addWidget(fee_slider, 4, 1) + vbox.addLayout(grid) + vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) + if not d.exec_(): + return + fee = fee_e.get_amount() + if fee > max_fee: + self.show_error(_('Max fee exceeded')) + return + new_tx = self.wallet.cpfp(parent_tx, fee) + new_tx.set_rbf(True) + self.show_transaction(new_tx) + + def bump_fee_dialog(self, tx): + is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) + if fee is None: + self.show_error(_("Can't bump fee: unknown fee for original transaction.")) + return + tx_label = self.wallet.get_label(tx.txid()) + tx_size = tx.estimated_size() + d = WindowModalDialog(self, _('Bump Fee')) + vbox = QVBoxLayout(d) + vbox.addWidget(QLabel(_('Current fee') + ': %s'% self.format_amount(fee) + ' ' + self.base_unit())) + vbox.addWidget(QLabel(_('New fee' + ':'))) + + fee_e = BTCAmountEdit(self.get_decimal_point) + fee_e.setAmount(fee * 1.5) + vbox.addWidget(fee_e) + + def on_rate(dyn, pos, fee_rate): + fee = fee_rate * tx_size / 1000 + fee_e.setAmount(fee) + fee_slider = FeeSlider(self, self.config, on_rate) + vbox.addWidget(fee_slider) + cb = QCheckBox(_('Final')) + vbox.addWidget(cb) + vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) + if not d.exec_(): + return + is_final = cb.isChecked() + new_fee = fee_e.get_amount() + delta = new_fee - fee + if delta < 0: + self.show_error("fee too low") + return + try: + new_tx = self.wallet.bump_fee(tx, delta) + except CannotBumpFee as e: + self.show_error(str(e)) + return + if is_final: + new_tx.set_rbf(False) + self.show_transaction(new_tx, tx_label) + + def save_transaction_into_wallet(self, tx): + win = self.top_level_window() + try: + if not self.wallet.add_transaction(tx.txid(), tx): + win.show_error(_("Transaction could not be saved.") + "\n" + + _("It conflicts with current history.")) + return False + except AddTransactionException as e: + win.show_error(e) + return False + else: + self.wallet.save_transactions(write=True) + # need to update at least: history_list, utxo_list, address_list + self.need_update.set() + msg = (_("Transaction added to wallet history.") + '\n\n' + + _("Note: this is an offline transaction, if you want the network " + "to see it, you need to broadcast it.")) + win.msg_box(QPixmap(":icons/offline_tx.png"), None, _('Success'), msg) + return True DIR diff --git a/gui/qt/network_dialog.py b/electrum/gui/qt/network_dialog.py DIR diff --git a/electrum/gui/qt/password_dialog.py b/electrum/gui/qt/password_dialog.py t@@ -0,0 +1,305 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2013 ecdsa@github +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * +from electrum.i18n import _ +from .util import * +import re +import math + +from electrum.plugin import run_hook + +def check_password_strength(password): + + ''' + Check the strength of the password entered by the user and return back the same + :param password: password entered by user in New Password + :return: password strength Weak or Medium or Strong + ''' + password = password + n = math.log(len(set(password))) + num = re.search("[0-9]", password) is not None and re.match("^[0-9]*$", password) is None + caps = password != password.upper() and password != password.lower() + extra = re.match("^[a-zA-Z0-9]*$", password) is None + score = len(password)*( n + caps + num + extra)/20 + password_strength = {0:"Weak",1:"Medium",2:"Strong",3:"Very Strong"} + return password_strength[min(3, int(score))] + + +PW_NEW, PW_CHANGE, PW_PASSPHRASE = range(0, 3) + + +class PasswordLayout(object): + + titles = [_("Enter Password"), _("Change Password"), _("Enter Passphrase")] + + def __init__(self, wallet, msg, kind, OK_button, force_disable_encrypt_cb=False): + self.wallet = wallet + + self.pw = QLineEdit() + self.pw.setEchoMode(2) + self.new_pw = QLineEdit() + self.new_pw.setEchoMode(2) + self.conf_pw = QLineEdit() + self.conf_pw.setEchoMode(2) + self.kind = kind + self.OK_button = OK_button + + vbox = QVBoxLayout() + label = QLabel(msg + "\n") + label.setWordWrap(True) + + grid = QGridLayout() + grid.setSpacing(8) + grid.setColumnMinimumWidth(0, 150) + grid.setColumnMinimumWidth(1, 100) + grid.setColumnStretch(1,1) + + if kind == PW_PASSPHRASE: + vbox.addWidget(label) + msgs = [_('Passphrase:'), _('Confirm Passphrase:')] + else: + logo_grid = QGridLayout() + logo_grid.setSpacing(8) + logo_grid.setColumnMinimumWidth(0, 70) + logo_grid.setColumnStretch(1,1) + + logo = QLabel() + logo.setAlignment(Qt.AlignCenter) + + logo_grid.addWidget(logo, 0, 0) + logo_grid.addWidget(label, 0, 1, 1, 2) + vbox.addLayout(logo_grid) + + m1 = _('New Password:') if kind == PW_CHANGE else _('Password:') + msgs = [m1, _('Confirm Password:')] + if wallet and wallet.has_password(): + grid.addWidget(QLabel(_('Current Password:')), 0, 0) + grid.addWidget(self.pw, 0, 1) + lockfile = ":icons/lock.png" + else: + lockfile = ":icons/unlock.png" + logo.setPixmap(QPixmap(lockfile).scaledToWidth(36, mode=Qt.SmoothTransformation)) + + grid.addWidget(QLabel(msgs[0]), 1, 0) + grid.addWidget(self.new_pw, 1, 1) + + grid.addWidget(QLabel(msgs[1]), 2, 0) + grid.addWidget(self.conf_pw, 2, 1) + vbox.addLayout(grid) + + # Password Strength Label + if kind != PW_PASSPHRASE: + self.pw_strength = QLabel() + grid.addWidget(self.pw_strength, 3, 0, 1, 2) + self.new_pw.textChanged.connect(self.pw_changed) + + self.encrypt_cb = QCheckBox(_('Encrypt wallet file')) + self.encrypt_cb.setEnabled(False) + grid.addWidget(self.encrypt_cb, 4, 0, 1, 2) + self.encrypt_cb.setVisible(kind != PW_PASSPHRASE) + + def enable_OK(): + ok = self.new_pw.text() == self.conf_pw.text() + OK_button.setEnabled(ok) + self.encrypt_cb.setEnabled(ok and bool(self.new_pw.text()) + and not force_disable_encrypt_cb) + self.new_pw.textChanged.connect(enable_OK) + self.conf_pw.textChanged.connect(enable_OK) + + self.vbox = vbox + + def title(self): + return self.titles[self.kind] + + def layout(self): + return self.vbox + + def pw_changed(self): + password = self.new_pw.text() + if password: + colors = {"Weak":"Red", "Medium":"Blue", "Strong":"Green", + "Very Strong":"Green"} + strength = check_password_strength(password) + label = (_("Password Strength") + ": " + "<font color=" + + colors[strength] + ">" + strength + "</font>") + else: + label = "" + self.pw_strength.setText(label) + + def old_password(self): + if self.kind == PW_CHANGE: + return self.pw.text() or None + return None + + def new_password(self): + pw = self.new_pw.text() + # Empty passphrases are fine and returned empty. + if pw == "" and self.kind != PW_PASSPHRASE: + pw = None + return pw + + +class PasswordLayoutForHW(object): + + def __init__(self, wallet, msg, kind, OK_button): + self.wallet = wallet + + self.kind = kind + self.OK_button = OK_button + + vbox = QVBoxLayout() + label = QLabel(msg + "\n") + label.setWordWrap(True) + + grid = QGridLayout() + grid.setSpacing(8) + grid.setColumnMinimumWidth(0, 150) + grid.setColumnMinimumWidth(1, 100) + grid.setColumnStretch(1,1) + + logo_grid = QGridLayout() + logo_grid.setSpacing(8) + logo_grid.setColumnMinimumWidth(0, 70) + logo_grid.setColumnStretch(1,1) + + logo = QLabel() + logo.setAlignment(Qt.AlignCenter) + + logo_grid.addWidget(logo, 0, 0) + logo_grid.addWidget(label, 0, 1, 1, 2) + vbox.addLayout(logo_grid) + + if wallet and wallet.has_storage_encryption(): + lockfile = ":icons/lock.png" + else: + lockfile = ":icons/unlock.png" + logo.setPixmap(QPixmap(lockfile).scaledToWidth(36, mode=Qt.SmoothTransformation)) + + vbox.addLayout(grid) + + self.encrypt_cb = QCheckBox(_('Encrypt wallet file')) + grid.addWidget(self.encrypt_cb, 1, 0, 1, 2) + + self.vbox = vbox + + def title(self): + return _("Toggle Encryption") + + def layout(self): + return self.vbox + + +class ChangePasswordDialogBase(WindowModalDialog): + + def __init__(self, parent, wallet): + WindowModalDialog.__init__(self, parent) + is_encrypted = wallet.has_storage_encryption() + OK_button = OkButton(self) + + self.create_password_layout(wallet, is_encrypted, OK_button) + + self.setWindowTitle(self.playout.title()) + vbox = QVBoxLayout(self) + vbox.addLayout(self.playout.layout()) + vbox.addStretch(1) + vbox.addLayout(Buttons(CancelButton(self), OK_button)) + self.playout.encrypt_cb.setChecked(is_encrypted) + + def create_password_layout(self, wallet, is_encrypted, OK_button): + raise NotImplementedError() + + +class ChangePasswordDialogForSW(ChangePasswordDialogBase): + + def __init__(self, parent, wallet): + ChangePasswordDialogBase.__init__(self, parent, wallet) + if not wallet.has_password(): + self.playout.encrypt_cb.setChecked(True) + + def create_password_layout(self, wallet, is_encrypted, OK_button): + if not wallet.has_password(): + msg = _('Your wallet is not protected.') + msg += ' ' + _('Use this dialog to add a password to your wallet.') + else: + if not is_encrypted: + msg = _('Your bitcoins are password protected. However, your wallet file is not encrypted.') + else: + msg = _('Your wallet is password protected and encrypted.') + msg += ' ' + _('Use this dialog to change your password.') + self.playout = PasswordLayout( + wallet, msg, PW_CHANGE, OK_button, + force_disable_encrypt_cb=not wallet.can_have_keystore_encryption()) + + def run(self): + if not self.exec_(): + return False, None, None, None + return True, self.playout.old_password(), self.playout.new_password(), self.playout.encrypt_cb.isChecked() + + +class ChangePasswordDialogForHW(ChangePasswordDialogBase): + + def __init__(self, parent, wallet): + ChangePasswordDialogBase.__init__(self, parent, wallet) + + def create_password_layout(self, wallet, is_encrypted, OK_button): + if not is_encrypted: + msg = _('Your wallet file is NOT encrypted.') + else: + msg = _('Your wallet file is encrypted.') + msg += '\n' + _('Note: If you enable this setting, you will need your hardware device to open your wallet.') + msg += '\n' + _('Use this dialog to toggle encryption.') + self.playout = PasswordLayoutForHW(wallet, msg, PW_CHANGE, OK_button) + + def run(self): + if not self.exec_(): + return False, None + return True, self.playout.encrypt_cb.isChecked() + + +class PasswordDialog(WindowModalDialog): + + def __init__(self, parent=None, msg=None): + msg = msg or _('Please enter your password') + WindowModalDialog.__init__(self, parent, _("Enter Password")) + self.pw = pw = QLineEdit() + pw.setEchoMode(2) + vbox = QVBoxLayout() + vbox.addWidget(QLabel(msg)) + grid = QGridLayout() + grid.setSpacing(8) + grid.addWidget(QLabel(_('Password')), 1, 0) + grid.addWidget(pw, 1, 1) + vbox.addLayout(grid) + vbox.addLayout(Buttons(CancelButton(self), OkButton(self))) + self.setLayout(vbox) + run_hook('password_dialog', pw, grid, 1) + + def run(self): + if not self.exec_(): + return + return self.pw.text() DIR diff --git a/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py DIR diff --git a/gui/qt/qrcodewidget.py b/electrum/gui/qt/qrcodewidget.py DIR diff --git a/electrum/gui/qt/qrtextedit.py b/electrum/gui/qt/qrtextedit.py t@@ -0,0 +1,76 @@ + +from electrum.i18n import _ +from electrum.plugin import run_hook +from PyQt5.QtGui import * +from PyQt5.QtCore import * +from PyQt5.QtWidgets import QFileDialog + +from .util import ButtonsTextEdit, MessageBoxMixin, ColorScheme + + +class ShowQRTextEdit(ButtonsTextEdit): + + def __init__(self, text=None): + ButtonsTextEdit.__init__(self, text) + self.setReadOnly(1) + self.addButton(":icons/qrcode.png", self.qr_show, _("Show as QR code")) + + run_hook('show_text_edit', self) + + def qr_show(self): + from .qrcodewidget import QRDialog + try: + s = str(self.toPlainText()) + except: + s = self.toPlainText() + QRDialog(s).exec_() + + def contextMenuEvent(self, e): + m = self.createStandardContextMenu() + m.addAction(_("Show as QR code"), self.qr_show) + m.exec_(e.globalPos()) + + +class ScanQRTextEdit(ButtonsTextEdit, MessageBoxMixin): + + def __init__(self, text="", allow_multi=False): + ButtonsTextEdit.__init__(self, text) + self.allow_multi = allow_multi + self.setReadOnly(0) + self.addButton(":icons/file.png", self.file_input, _("Read file")) + icon = ":icons/qrcode_white.png" if ColorScheme.dark_scheme else ":icons/qrcode.png" + self.addButton(icon, self.qr_input, _("Read QR code")) + run_hook('scan_text_edit', self) + + def file_input(self): + fileName, __ = QFileDialog.getOpenFileName(self, 'select file') + if not fileName: + return + try: + with open(fileName, "r") as f: + data = f.read() + except BaseException as e: + self.show_error(_('Error opening file') + ':\n' + str(e)) + else: + self.setText(data) + + def qr_input(self): + from electrum import qrscanner, get_config + try: + data = qrscanner.scan_barcode(get_config().get_video_device()) + except BaseException as e: + self.show_error(str(e)) + data = '' + if not data: + data = '' + if self.allow_multi: + new_text = self.text() + data + '\n' + else: + new_text = data + self.setText(new_text) + return data + + def contextMenuEvent(self, e): + m = self.createStandardContextMenu() + m.addAction(_("Read QR code"), self.qr_input) + m.exec_(e.globalPos()) DIR diff --git a/electrum/gui/qt/qrwindow.py b/electrum/gui/qt/qrwindow.py t@@ -0,0 +1,89 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2014 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import platform + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import * +from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QWidget + +from .qrcodewidget import QRCodeWidget +from electrum.i18n import _ + +if platform.system() == 'Windows': + MONOSPACE_FONT = 'Lucida Console' +elif platform.system() == 'Darwin': + MONOSPACE_FONT = 'Monaco' +else: + MONOSPACE_FONT = 'monospace' + +column_index = 4 + +class QR_Window(QWidget): + + def __init__(self, win): + QWidget.__init__(self) + self.win = win + self.setWindowTitle('Electrum - '+_('Payment Request')) + self.setMinimumSize(800, 250) + self.address = '' + self.label = '' + self.amount = 0 + self.setFocusPolicy(Qt.NoFocus) + + main_box = QHBoxLayout() + + self.qrw = QRCodeWidget() + main_box.addWidget(self.qrw, 1) + + vbox = QVBoxLayout() + main_box.addLayout(vbox) + + self.address_label = QLabel("") + #self.address_label.setFont(QFont(MONOSPACE_FONT)) + vbox.addWidget(self.address_label) + + self.label_label = QLabel("") + vbox.addWidget(self.label_label) + + self.amount_label = QLabel("") + vbox.addWidget(self.amount_label) + + vbox.addStretch(1) + self.setLayout(main_box) + + + def set_content(self, address, amount, message, url): + address_text = "<span style='font-size: 18pt'>%s</span>" % address if address else "" + self.address_label.setText(address_text) + if amount: + amount = self.win.format_amount(amount) + amount_text = "<span style='font-size: 21pt'>%s</span> <span style='font-size: 16pt'>%s</span> " % (amount, self.win.base_unit()) + else: + amount_text = '' + self.amount_label.setText(amount_text) + label_text = "<span style='font-size: 21pt'>%s</span>" % message if message else "" + self.label_label.setText(label_text) + self.qrw.setData(url) DIR diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py t@@ -0,0 +1,129 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2015 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from electrum.i18n import _ +from electrum.util import format_time, age +from electrum.plugin import run_hook +from electrum.paymentrequest import PR_UNKNOWN +from PyQt5.QtGui import * +from PyQt5.QtCore import * +from PyQt5.QtWidgets import QTreeWidgetItem, QMenu +from .util import MyTreeWidget, pr_tooltips, pr_icons + + +class RequestList(MyTreeWidget): + filter_columns = [0, 1, 2, 3, 4] # Date, Account, Address, Description, Amount + + + def __init__(self, parent): + MyTreeWidget.__init__(self, parent, self.create_menu, [_('Date'), _('Address'), '', _('Description'), _('Amount'), _('Status')], 3) + self.currentItemChanged.connect(self.item_changed) + self.itemClicked.connect(self.item_changed) + self.setSortingEnabled(True) + self.setColumnWidth(0, 180) + self.hideColumn(1) + + def item_changed(self, item): + if item is None: + return + if not item.isSelected(): + return + addr = str(item.text(1)) + req = self.wallet.receive_requests.get(addr) + if req is None: + self.update() + return + expires = age(req['time'] + req['exp']) if req.get('exp') else _('Never') + amount = req['amount'] + message = self.wallet.labels.get(addr, '') + self.parent.receive_address_e.setText(addr) + self.parent.receive_message_e.setText(message) + self.parent.receive_amount_e.setAmount(amount) + self.parent.expires_combo.hide() + self.parent.expires_label.show() + self.parent.expires_label.setText(expires) + self.parent.new_request_button.setEnabled(True) + + def on_update(self): + self.wallet = self.parent.wallet + # hide receive tab if no receive requests available + b = len(self.wallet.receive_requests) > 0 + self.setVisible(b) + self.parent.receive_requests_label.setVisible(b) + if not b: + self.parent.expires_label.hide() + self.parent.expires_combo.show() + + # update the receive address if necessary + current_address = self.parent.receive_address_e.text() + domain = self.wallet.get_receiving_addresses() + addr = self.wallet.get_unused_address() + if not current_address in domain and addr: + self.parent.set_receive_address(addr) + self.parent.new_request_button.setEnabled(addr != current_address) + + # clear the list and fill it again + self.clear() + for req in self.wallet.get_sorted_requests(self.config): + address = req['address'] + if address not in domain: + continue + timestamp = req.get('time', 0) + amount = req.get('amount') + expiration = req.get('exp', None) + message = req.get('memo', '') + date = format_time(timestamp) + status = req.get('status') + signature = req.get('sig') + requestor = req.get('name', '') + amount_str = self.parent.format_amount(amount) if amount else "" + item = QTreeWidgetItem([date, address, '', message, amount_str, pr_tooltips.get(status,'')]) + if signature is not None: + item.setIcon(2, self.icon_cache.get(":icons/seal.png")) + item.setToolTip(2, 'signed by '+ requestor) + if status is not PR_UNKNOWN: + item.setIcon(6, self.icon_cache.get(pr_icons.get(status))) + self.addTopLevelItem(item) + + + def create_menu(self, position): + item = self.itemAt(position) + if not item: + return + addr = str(item.text(1)) + req = self.wallet.receive_requests.get(addr) + if req is None: + self.update() + return + column = self.currentColumn() + column_title = self.headerItem().text(column) + column_data = item.text(column) + menu = QMenu(self) + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) + menu.addAction(_("Copy URI"), lambda: self.parent.view_and_paste('URI', '', self.parent.get_request_URI(addr))) + menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr)) + menu.addAction(_("Delete"), lambda: self.parent.delete_payment_request(addr)) + run_hook('receive_list_menu', menu, addr) + menu.exec_(self.viewport().mapToGlobal(position)) DIR diff --git a/electrum/gui/qt/seed_dialog.py b/electrum/gui/qt/seed_dialog.py t@@ -0,0 +1,211 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2013 ecdsa@github +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from electrum.i18n import _ +from electrum.mnemonic import Mnemonic +import electrum.old_mnemonic +from electrum.plugin import run_hook + + +from .util import * +from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit +from .completion_text_edit import CompletionTextEdit + + +def seed_warning_msg(seed): + return ''.join([ + "<p>", + _("Please save these {0} words on paper (order is important). "), + _("This seed will allow you to recover your wallet in case " + "of computer failure."), + "</p>", + "<b>" + _("WARNING") + ":</b>", + "<ul>", + "<li>" + _("Never disclose your seed.") + "</li>", + "<li>" + _("Never type it on a website.") + "</li>", + "<li>" + _("Do not store it electronically.") + "</li>", + "</ul>" + ]).format(len(seed.split())) + + +class SeedLayout(QVBoxLayout): + + def seed_options(self): + dialog = QDialog() + vbox = QVBoxLayout(dialog) + if 'ext' in self.options: + cb_ext = QCheckBox(_('Extend this seed with custom words')) + cb_ext.setChecked(self.is_ext) + vbox.addWidget(cb_ext) + if 'bip39' in self.options: + def f(b): + self.is_seed = (lambda x: bool(x)) if b else self.saved_is_seed + self.is_bip39 = b + self.on_edit() + if b: + msg = ' '.join([ + '<b>' + _('Warning') + ':</b> ', + _('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), + _('However, we do not generate BIP39 seeds, because they do not meet our safety standard.'), + _('BIP39 seeds do not include a version number, which compromises compatibility with future software.'), + _('We do not guarantee that BIP39 imports will always be supported in Electrum.'), + ]) + else: + msg = '' + self.seed_warning.setText(msg) + cb_bip39 = QCheckBox(_('BIP39 seed')) + cb_bip39.toggled.connect(f) + cb_bip39.setChecked(self.is_bip39) + vbox.addWidget(cb_bip39) + vbox.addLayout(Buttons(OkButton(dialog))) + if not dialog.exec_(): + return None + self.is_ext = cb_ext.isChecked() if 'ext' in self.options else False + self.is_bip39 = cb_bip39.isChecked() if 'bip39' in self.options else False + + def __init__(self, seed=None, title=None, icon=True, msg=None, options=None, + is_seed=None, passphrase=None, parent=None, for_seed_words=True): + QVBoxLayout.__init__(self) + self.parent = parent + self.options = options + if title: + self.addWidget(WWLabel(title)) + if seed: # "read only", we already have the text + if for_seed_words: + self.seed_e = ButtonsTextEdit() + else: # e.g. xpub + self.seed_e = ShowQRTextEdit() + self.seed_e.setReadOnly(True) + self.seed_e.setText(seed) + else: # we expect user to enter text + assert for_seed_words + self.seed_e = CompletionTextEdit() + self.seed_e.setTabChangesFocus(False) # so that tab auto-completes + self.is_seed = is_seed + self.saved_is_seed = self.is_seed + self.seed_e.textChanged.connect(self.on_edit) + self.initialize_completer() + + self.seed_e.setMaximumHeight(75) + hbox = QHBoxLayout() + if icon: + logo = QLabel() + logo.setPixmap(QPixmap(":icons/seed.png").scaledToWidth(64, mode=Qt.SmoothTransformation)) + logo.setMaximumWidth(60) + hbox.addWidget(logo) + hbox.addWidget(self.seed_e) + self.addLayout(hbox) + hbox = QHBoxLayout() + hbox.addStretch(1) + self.seed_type_label = QLabel('') + hbox.addWidget(self.seed_type_label) + + # options + self.is_bip39 = False + self.is_ext = False + if options: + opt_button = EnterButton(_('Options'), self.seed_options) + hbox.addWidget(opt_button) + self.addLayout(hbox) + if passphrase: + hbox = QHBoxLayout() + passphrase_e = QLineEdit() + passphrase_e.setText(passphrase) + passphrase_e.setReadOnly(True) + hbox.addWidget(QLabel(_("Your seed extension is") + ':')) + hbox.addWidget(passphrase_e) + self.addLayout(hbox) + self.addStretch(1) + self.seed_warning = WWLabel('') + if msg: + self.seed_warning.setText(seed_warning_msg(seed)) + self.addWidget(self.seed_warning) + + def initialize_completer(self): + english_list = Mnemonic('en').wordlist + old_list = electrum.old_mnemonic.words + self.wordlist = english_list + list(set(old_list) - set(english_list)) #concat both lists + self.wordlist.sort() + self.completer = QCompleter(self.wordlist) + self.seed_e.set_completer(self.completer) + + def get_seed(self): + text = self.seed_e.text() + return ' '.join(text.split()) + + def on_edit(self): + from electrum.bitcoin import seed_type + s = self.get_seed() + b = self.is_seed(s) + if not self.is_bip39: + t = seed_type(s) + label = _('Seed Type') + ': ' + t if t else '' + else: + from electrum.keystore import bip39_is_checksum_valid + is_checksum, is_wordlist = bip39_is_checksum_valid(s) + status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist' + label = 'BIP39' + ' (%s)'%status + self.seed_type_label.setText(label) + self.parent.next_button.setEnabled(b) + + # to account for bip39 seeds + for word in self.get_seed().split(" ")[:-1]: + if word not in self.wordlist: + self.seed_e.disable_suggestions() + return + self.seed_e.enable_suggestions() + +class KeysLayout(QVBoxLayout): + def __init__(self, parent=None, header_layout=None, is_valid=None, allow_multi=False): + QVBoxLayout.__init__(self) + self.parent = parent + self.is_valid = is_valid + self.text_e = ScanQRTextEdit(allow_multi=allow_multi) + self.text_e.textChanged.connect(self.on_edit) + if isinstance(header_layout, str): + self.addWidget(WWLabel(header_layout)) + else: + self.addLayout(header_layout) + self.addWidget(self.text_e) + + def get_text(self): + return self.text_e.text() + + def on_edit(self): + b = self.is_valid(self.get_text()) + self.parent.next_button.setEnabled(b) + + +class SeedDialog(WindowModalDialog): + + def __init__(self, parent, seed, passphrase): + WindowModalDialog.__init__(self, parent, ('Electrum - ' + _('Seed'))) + self.setMinimumWidth(400) + vbox = QVBoxLayout(self) + title = _("Your wallet generation seed is:") + slayout = SeedLayout(title=title, seed=seed, msg=True, passphrase=passphrase) + vbox.addLayout(slayout) + run_hook('set_seed', seed, slayout.seed_e) + vbox.addLayout(Buttons(CloseButton(self))) DIR diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py t@@ -0,0 +1,328 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2012 thomasv@gitorious +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import copy +import datetime +import json +import traceback + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electrum.bitcoin import base_encode +from electrum.i18n import _ +from electrum.plugin import run_hook +from electrum import simple_config + +from electrum.util import bfh +from electrum.wallet import AddTransactionException +from electrum.transaction import SerializationError + +from .util import * + + +SAVE_BUTTON_ENABLED_TOOLTIP = _("Save transaction offline") +SAVE_BUTTON_DISABLED_TOOLTIP = _("Please sign this transaction in order to save it") + + +dialogs = [] # Otherwise python randomly garbage collects the dialogs... + + +def show_transaction(tx, parent, desc=None, prompt_if_unsaved=False): + try: + d = TxDialog(tx, parent, desc, prompt_if_unsaved) + except SerializationError as e: + traceback.print_exc(file=sys.stderr) + parent.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)) + else: + dialogs.append(d) + d.show() + + +class TxDialog(QDialog, MessageBoxMixin): + + def __init__(self, tx, parent, desc, prompt_if_unsaved): + '''Transactions in the wallet will show their description. + Pass desc to give a description for txs not yet in the wallet. + ''' + # We want to be a top-level window + QDialog.__init__(self, parent=None) + # Take a copy; it might get updated in the main window by + # e.g. the FX plugin. If this happens during or after a long + # sign operation the signatures are lost. + self.tx = tx = copy.deepcopy(tx) + try: + self.tx.deserialize() + except BaseException as e: + raise SerializationError(e) + self.main_window = parent + self.wallet = parent.wallet + self.prompt_if_unsaved = prompt_if_unsaved + self.saved = False + self.desc = desc + + # if the wallet can populate the inputs with more info, do it now. + # as a result, e.g. we might learn an imported address tx is segwit, + # in which case it's ok to display txid + self.wallet.add_input_info_to_all_inputs(tx) + + self.setMinimumWidth(950) + self.setWindowTitle(_("Transaction")) + + vbox = QVBoxLayout() + self.setLayout(vbox) + + vbox.addWidget(QLabel(_("Transaction ID:"))) + self.tx_hash_e = ButtonsLineEdit() + qr_show = lambda: parent.show_qrcode(str(self.tx_hash_e.text()), 'Transaction ID', parent=self) + self.tx_hash_e.addButton(":icons/qrcode.png", qr_show, _("Show as QR code")) + self.tx_hash_e.setReadOnly(True) + vbox.addWidget(self.tx_hash_e) + self.tx_desc = QLabel() + vbox.addWidget(self.tx_desc) + self.status_label = QLabel() + vbox.addWidget(self.status_label) + self.date_label = QLabel() + vbox.addWidget(self.date_label) + self.amount_label = QLabel() + vbox.addWidget(self.amount_label) + self.size_label = QLabel() + vbox.addWidget(self.size_label) + self.fee_label = QLabel() + vbox.addWidget(self.fee_label) + + self.add_io(vbox) + + vbox.addStretch(1) + + self.sign_button = b = QPushButton(_("Sign")) + b.clicked.connect(self.sign) + + self.broadcast_button = b = QPushButton(_("Broadcast")) + b.clicked.connect(self.do_broadcast) + + self.save_button = b = QPushButton(_("Save")) + save_button_disabled = not tx.is_complete() + b.setDisabled(save_button_disabled) + if save_button_disabled: + b.setToolTip(SAVE_BUTTON_DISABLED_TOOLTIP) + else: + b.setToolTip(SAVE_BUTTON_ENABLED_TOOLTIP) + b.clicked.connect(self.save) + + self.export_button = b = QPushButton(_("Export")) + b.clicked.connect(self.export) + + self.cancel_button = b = QPushButton(_("Close")) + b.clicked.connect(self.close) + b.setDefault(True) + + self.qr_button = b = QPushButton() + b.setIcon(QIcon(":icons/qrcode.png")) + b.clicked.connect(self.show_qr) + + self.copy_button = CopyButton(lambda: str(self.tx), parent.app) + + # Action buttons + self.buttons = [self.sign_button, self.broadcast_button, self.cancel_button] + # Transaction sharing buttons + self.sharing_buttons = [self.copy_button, self.qr_button, self.export_button, self.save_button] + + run_hook('transaction_dialog', self) + + hbox = QHBoxLayout() + hbox.addLayout(Buttons(*self.sharing_buttons)) + hbox.addStretch(1) + hbox.addLayout(Buttons(*self.buttons)) + vbox.addLayout(hbox) + self.update() + + def do_broadcast(self): + self.main_window.push_top_level_window(self) + try: + self.main_window.broadcast_transaction(self.tx, self.desc) + finally: + self.main_window.pop_top_level_window(self) + self.saved = True + self.update() + + def closeEvent(self, event): + if (self.prompt_if_unsaved and not self.saved + and not self.question(_('This transaction is not saved. Close anyway?'), title=_("Warning"))): + event.ignore() + else: + event.accept() + try: + dialogs.remove(self) + except ValueError: + pass # was not in list already + + def show_qr(self): + text = bfh(str(self.tx)) + text = base_encode(text, base=43) + try: + self.main_window.show_qrcode(text, 'Transaction', parent=self) + except Exception as e: + self.show_message(str(e)) + + def sign(self): + def sign_done(success): + # note: with segwit we could save partially signed tx, because they have a txid + if self.tx.is_complete(): + self.prompt_if_unsaved = True + self.saved = False + self.save_button.setDisabled(False) + self.save_button.setToolTip(SAVE_BUTTON_ENABLED_TOOLTIP) + self.update() + self.main_window.pop_top_level_window(self) + + self.sign_button.setDisabled(True) + self.main_window.push_top_level_window(self) + self.main_window.sign_tx(self.tx, sign_done) + + def save(self): + self.main_window.push_top_level_window(self) + if self.main_window.save_transaction_into_wallet(self.tx): + self.save_button.setDisabled(True) + self.saved = True + self.main_window.pop_top_level_window(self) + + + def export(self): + name = 'signed_%s.txn' % (self.tx.txid()[0:8]) if self.tx.is_complete() else 'unsigned.txn' + fileName = self.main_window.getSaveFileName(_("Select where to save your signed transaction"), name, "*.txn") + if fileName: + with open(fileName, "w+") as f: + f.write(json.dumps(self.tx.as_dict(), indent=4) + '\n') + self.show_message(_("Transaction exported successfully")) + self.saved = True + + def update(self): + desc = self.desc + base_unit = self.main_window.base_unit() + format_amount = self.main_window.format_amount + tx_hash, status, label, can_broadcast, can_rbf, amount, fee, height, conf, timestamp, exp_n = self.wallet.get_tx_info(self.tx) + size = self.tx.estimated_size() + self.broadcast_button.setEnabled(can_broadcast) + can_sign = not self.tx.is_complete() and \ + (self.wallet.can_sign(self.tx) or bool(self.main_window.tx_external_keypairs)) + self.sign_button.setEnabled(can_sign) + self.tx_hash_e.setText(tx_hash or _('Unknown')) + if desc is None: + self.tx_desc.hide() + else: + self.tx_desc.setText(_("Description") + ': ' + desc) + self.tx_desc.show() + self.status_label.setText(_('Status:') + ' ' + status) + + if timestamp: + time_str = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3] + self.date_label.setText(_("Date: {}").format(time_str)) + self.date_label.show() + elif exp_n: + text = '%.2f MB'%(exp_n/1000000) + self.date_label.setText(_('Position in mempool: {} from tip').format(text)) + self.date_label.show() + else: + self.date_label.hide() + if amount is None: + amount_str = _("Transaction unrelated to your wallet") + elif amount > 0: + amount_str = _("Amount received:") + ' %s'% format_amount(amount) + ' ' + base_unit + else: + amount_str = _("Amount sent:") + ' %s'% format_amount(-amount) + ' ' + base_unit + size_str = _("Size:") + ' %d bytes'% size + fee_str = _("Fee") + ': %s' % (format_amount(fee) + ' ' + base_unit if fee is not None else _('unknown')) + if fee is not None: + fee_rate = fee/size*1000 + fee_str += ' ( %s ) ' % self.main_window.format_fee_rate(fee_rate) + confirm_rate = simple_config.FEERATE_WARNING_HIGH_FEE + if fee_rate > confirm_rate: + fee_str += ' - ' + _('Warning') + ': ' + _("high fee") + '!' + self.amount_label.setText(amount_str) + self.fee_label.setText(fee_str) + self.size_label.setText(size_str) + run_hook('transaction_dialog_update', self) + + def add_io(self, vbox): + if self.tx.locktime > 0: + vbox.addWidget(QLabel("LockTime: %d\n" % self.tx.locktime)) + + vbox.addWidget(QLabel(_("Inputs") + ' (%d)'%len(self.tx.inputs()))) + ext = QTextCharFormat() + rec = QTextCharFormat() + rec.setBackground(QBrush(ColorScheme.GREEN.as_color(background=True))) + rec.setToolTip(_("Wallet receive address")) + chg = QTextCharFormat() + chg.setBackground(QBrush(ColorScheme.YELLOW.as_color(background=True))) + chg.setToolTip(_("Wallet change address")) + twofactor = QTextCharFormat() + twofactor.setBackground(QBrush(ColorScheme.BLUE.as_color(background=True))) + twofactor.setToolTip(_("TrustedCoin (2FA) fee for the next batch of transactions")) + + def text_format(addr): + if self.wallet.is_mine(addr): + return chg if self.wallet.is_change(addr) else rec + elif self.wallet.is_billing_address(addr): + return twofactor + return ext + + def format_amount(amt): + return self.main_window.format_amount(amt, whitespaces=True) + + i_text = QTextEdit() + i_text.setFont(QFont(MONOSPACE_FONT)) + i_text.setReadOnly(True) + i_text.setMaximumHeight(100) + cursor = i_text.textCursor() + for x in self.tx.inputs(): + if x['type'] == 'coinbase': + cursor.insertText('coinbase') + else: + prevout_hash = x.get('prevout_hash') + prevout_n = x.get('prevout_n') + cursor.insertText(prevout_hash + ":%-4d " % prevout_n, ext) + addr = self.wallet.get_txin_address(x) + if addr is None: + addr = '' + cursor.insertText(addr, text_format(addr)) + if x.get('value'): + cursor.insertText(format_amount(x['value']), ext) + cursor.insertBlock() + + vbox.addWidget(i_text) + vbox.addWidget(QLabel(_("Outputs") + ' (%d)'%len(self.tx.outputs()))) + o_text = QTextEdit() + o_text.setFont(QFont(MONOSPACE_FONT)) + o_text.setReadOnly(True) + o_text.setMaximumHeight(100) + cursor = o_text.textCursor() + for addr, v in self.tx.get_outputs(): + cursor.insertText(addr, text_format(addr)) + if v is not None: + cursor.insertText('\t', ext) + cursor.insertText(format_amount(v), ext) + cursor.insertBlock() + vbox.addWidget(o_text) DIR diff --git a/gui/qt/util.py b/electrum/gui/qt/util.py DIR diff --git a/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py DIR diff --git a/gui/stdio.py b/electrum/gui/stdio.py DIR diff --git a/electrum/gui/text.py b/electrum/gui/text.py t@@ -0,0 +1,503 @@ +import tty, sys +import curses, datetime, locale +from decimal import Decimal +import getpass + +import electrum +from electrum.util import format_satoshis, set_verbosity +from electrum.bitcoin import is_address, COIN, TYPE_ADDRESS +from .. import Wallet, WalletStorage + +_ = lambda x:x + + + +class ElectrumGui: + + def __init__(self, config, daemon, plugins): + + self.config = config + self.network = daemon.network + storage = WalletStorage(config.get_wallet_path()) + if not storage.file_exists(): + print("Wallet not found. try 'electrum create'") + exit() + if storage.is_encrypted(): + password = getpass.getpass('Password:', stream=None) + storage.decrypt(password) + self.wallet = Wallet(storage) + self.wallet.start_threads(self.network) + self.contacts = self.wallet.contacts + + locale.setlocale(locale.LC_ALL, '') + self.encoding = locale.getpreferredencoding() + + self.stdscr = curses.initscr() + curses.noecho() + curses.cbreak() + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) + curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_CYAN) + curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE) + self.stdscr.keypad(1) + self.stdscr.border(0) + self.maxy, self.maxx = self.stdscr.getmaxyx() + self.set_cursor(0) + self.w = curses.newwin(10, 50, 5, 5) + + set_verbosity(False) + self.tab = 0 + self.pos = 0 + self.popup_pos = 0 + + self.str_recipient = "" + self.str_description = "" + self.str_amount = "" + self.str_fee = "" + self.history = None + + if self.network: + self.network.register_callback(self.update, ['updated']) + + self.tab_names = [_("History"), _("Send"), _("Receive"), _("Addresses"), _("Contacts"), _("Banner")] + self.num_tabs = len(self.tab_names) + + + def set_cursor(self, x): + try: + curses.curs_set(x) + except Exception: + pass + + def restore_or_create(self): + pass + + def verify_seed(self): + pass + + def get_string(self, y, x): + self.set_cursor(1) + curses.echo() + self.stdscr.addstr( y, x, " "*20, curses.A_REVERSE) + s = self.stdscr.getstr(y,x) + curses.noecho() + self.set_cursor(0) + return s + + def update(self, event): + self.update_history() + if self.tab == 0: + self.print_history() + self.refresh() + + def print_history(self): + + width = [20, 40, 14, 14] + delta = (self.maxx - sum(width) - 4)/3 + format_str = "%"+"%d"%width[0]+"s"+"%"+"%d"%(width[1]+delta)+"s"+"%"+"%d"%(width[2]+delta)+"s"+"%"+"%d"%(width[3]+delta)+"s" + + if self.history is None: + self.update_history() + + self.print_list(self.history[::-1], format_str%( _("Date"), _("Description"), _("Amount"), _("Balance"))) + + def update_history(self): + width = [20, 40, 14, 14] + delta = (self.maxx - sum(width) - 4)/3 + format_str = "%"+"%d"%width[0]+"s"+"%"+"%d"%(width[1]+delta)+"s"+"%"+"%d"%(width[2]+delta)+"s"+"%"+"%d"%(width[3]+delta)+"s" + + b = 0 + self.history = [] + for item in self.wallet.get_history(): + tx_hash, height, conf, timestamp, value, balance = item + if conf: + try: + time_str = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3] + except Exception: + time_str = "------" + else: + time_str = 'unconfirmed' + + label = self.wallet.get_label(tx_hash) + if len(label) > 40: + label = label[0:37] + '...' + self.history.append( format_str%( time_str, label, format_satoshis(value, whitespaces=True), format_satoshis(balance, whitespaces=True) ) ) + + + def print_balance(self): + if not self.network: + msg = _("Offline") + elif self.network.is_connected(): + if not self.wallet.up_to_date: + msg = _("Synchronizing...") + else: + c, u, x = self.wallet.get_balance() + msg = _("Balance")+": %f "%(Decimal(c) / COIN) + if u: + msg += " [%f unconfirmed]"%(Decimal(u) / COIN) + if x: + msg += " [%f unmatured]"%(Decimal(x) / COIN) + else: + msg = _("Not connected") + + self.stdscr.addstr( self.maxy -1, 3, msg) + + for i in range(self.num_tabs): + 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) + + self.stdscr.addstr(self.maxy -1, self.maxx-30, ' '.join([_("Settings"), _("Network"), _("Quit")])) + + def print_receive(self): + addr = self.wallet.get_receiving_address() + self.stdscr.addstr(2, 1, "Address: "+addr) + self.print_qr(addr) + + def print_contacts(self): + messages = map(lambda x: "%20s %45s "%(x[0], x[1][1]), self.contacts.items()) + self.print_list(messages, "%19s %15s "%("Key", "Value")) + + def print_addresses(self): + fmt = "%-35s %-30s" + messages = map(lambda addr: fmt % (addr, self.wallet.labels.get(addr,"")), self.wallet.get_addresses()) + self.print_list(messages, fmt % ("Address", "Label")) + + def print_edit_line(self, y, label, text, index, size): + text += " "*(size - len(text) ) + self.stdscr.addstr( y, 2, label) + self.stdscr.addstr( y, 15, text, curses.A_REVERSE if self.pos%6==index else curses.color_pair(1)) + + def print_send_tab(self): + self.stdscr.clear() + self.print_edit_line(3, _("Pay to"), self.str_recipient, 0, 40) + self.print_edit_line(5, _("Description"), self.str_description, 1, 40) + self.print_edit_line(7, _("Amount"), self.str_amount, 2, 15) + self.print_edit_line(9, _("Fee"), self.str_fee, 3, 15) + self.stdscr.addstr( 12, 15, _("[Send]"), curses.A_REVERSE if self.pos%6==4 else curses.color_pair(2)) + self.stdscr.addstr( 12, 25, _("[Clear]"), curses.A_REVERSE if self.pos%6==5 else curses.color_pair(2)) + self.maxpos = 6 + + def print_banner(self): + if self.network: + self.print_list( self.network.banner.split('\n')) + + def print_qr(self, data): + import qrcode + try: + from StringIO import StringIO + except ImportError: + from io import StringIO + + s = StringIO() + self.qr = qrcode.QRCode() + self.qr.add_data(data) + self.qr.print_ascii(out=s, invert=False) + msg = s.getvalue() + lines = msg.split('\n') + for i, l in enumerate(lines): + l = l.encode("utf-8") + self.stdscr.addstr(i+5, 5, l, curses.color_pair(3)) + + def print_list(self, lst, firstline = None): + lst = list(lst) + self.maxpos = len(lst) + if not self.maxpos: return + if firstline: + firstline += " "*(self.maxx -2 - len(firstline)) + self.stdscr.addstr( 1, 1, firstline ) + for i in range(self.maxy-4): + msg = lst[i] if i < len(lst) else "" + msg += " "*(self.maxx - 2 - len(msg)) + m = msg[0:self.maxx - 2] + m = m.encode(self.encoding) + self.stdscr.addstr( i+2, 1, m, curses.A_REVERSE if i == (self.pos % self.maxpos) else 0) + + def refresh(self): + if self.tab == -1: return + self.stdscr.border(0) + self.print_balance() + self.stdscr.refresh() + + def main_command(self): + c = self.stdscr.getch() + print(c) + cc = curses.unctrl(c).decode() + if c == curses.KEY_RIGHT: self.tab = (self.tab + 1)%self.num_tabs + elif c == curses.KEY_LEFT: self.tab = (self.tab - 1)%self.num_tabs + elif c == curses.KEY_DOWN: self.pos +=1 + elif c == curses.KEY_UP: self.pos -= 1 + elif c == 9: self.pos +=1 # tab + elif cc in ['^W', '^C', '^X', '^Q']: self.tab = -1 + elif cc in ['^N']: self.network_dialog() + elif cc == '^S': self.settings_dialog() + else: return c + if self.pos<0: self.pos=0 + if self.pos>=self.maxpos: self.pos=self.maxpos - 1 + + def run_tab(self, i, print_func, exec_func): + while self.tab == i: + self.stdscr.clear() + print_func() + self.refresh() + c = self.main_command() + if c: exec_func(c) + + + def run_history_tab(self, c): + if c == 10: + out = self.run_popup('',["blah","foo"]) + + + def edit_str(self, target, c, is_num=False): + # detect backspace + cc = curses.unctrl(c).decode() + if c in [8, 127, 263] and target: + target = target[:-1] + elif not is_num or cc in '0123456789.': + target += cc + return target + + + def run_send_tab(self, c): + if self.pos%6 == 0: + self.str_recipient = self.edit_str(self.str_recipient, c) + if self.pos%6 == 1: + self.str_description = self.edit_str(self.str_description, c) + if self.pos%6 == 2: + self.str_amount = self.edit_str(self.str_amount, c, True) + elif self.pos%6 == 3: + self.str_fee = self.edit_str(self.str_fee, c, True) + elif self.pos%6==4: + if c == 10: self.do_send() + elif self.pos%6==5: + if c == 10: self.do_clear() + + + def run_receive_tab(self, c): + if c == 10: + out = self.run_popup('Address', ["Edit label", "Freeze", "Prioritize"]) + + def run_contacts_tab(self, c): + if c == 10 and self.contacts: + out = self.run_popup('Address', ["Copy", "Pay to", "Edit label", "Delete"]).get('button') + key = list(self.contacts.keys())[self.pos%len(self.contacts.keys())] + if out == "Pay to": + self.tab = 1 + self.str_recipient = key + self.pos = 2 + elif out == "Edit label": + s = self.get_string(6 + self.pos, 18) + if s: + self.wallet.labels[key] = s + + def run_banner_tab(self, c): + self.show_message(repr(c)) + pass + + def main(self): + + tty.setraw(sys.stdin) + while self.tab != -1: + self.run_tab(0, self.print_history, self.run_history_tab) + self.run_tab(1, self.print_send_tab, self.run_send_tab) + self.run_tab(2, self.print_receive, self.run_receive_tab) + self.run_tab(3, self.print_addresses, self.run_banner_tab) + self.run_tab(4, self.print_contacts, self.run_contacts_tab) + self.run_tab(5, self.print_banner, self.run_banner_tab) + + tty.setcbreak(sys.stdin) + curses.nocbreak() + self.stdscr.keypad(0) + curses.echo() + curses.endwin() + + + def do_clear(self): + self.str_amount = '' + self.str_recipient = '' + self.str_fee = '' + self.str_description = '' + + def do_send(self): + if not is_address(self.str_recipient): + self.show_message(_('Invalid Bitcoin address')) + return + try: + amount = int(Decimal(self.str_amount) * COIN) + except Exception: + self.show_message(_('Invalid Amount')) + return + try: + fee = int(Decimal(self.str_fee) * COIN) + except Exception: + self.show_message(_('Invalid Fee')) + return + + if self.wallet.has_password(): + password = self.password_dialog() + if not password: + return + else: + password = None + try: + tx = self.wallet.mktx([(TYPE_ADDRESS, self.str_recipient, amount)], password, self.config, fee) + except Exception as e: + self.show_message(str(e)) + return + + if self.str_description: + self.wallet.labels[tx.txid()] = self.str_description + + self.show_message(_("Please wait..."), getchar=False) + status, msg = self.network.broadcast_transaction(tx) + + if status: + self.show_message(_('Payment sent.')) + self.do_clear() + #self.update_contacts_tab() + else: + self.show_message(_('Error')) + + + def show_message(self, message, getchar = True): + w = self.w + w.clear() + w.border(0) + for i, line in enumerate(message.split('\n')): + w.addstr(2+i,2,line) + w.refresh() + if getchar: c = self.stdscr.getch() + + def run_popup(self, title, items): + return self.run_dialog(title, list(map(lambda x: {'type':'button','label':x}, items)), interval=1, y_pos = self.pos+3) + + def network_dialog(self): + if not self.network: + return + params = self.network.get_parameters() + host, port, protocol, proxy_config, auto_connect = params + srv = 'auto-connect' if auto_connect else self.network.default_server + out = self.run_dialog('Network', [ + {'label':'server', 'type':'str', 'value':srv}, + {'label':'proxy', 'type':'str', 'value':self.config.get('proxy', '')}, + ], buttons = 1) + if out: + if out.get('server'): + server = out.get('server') + auto_connect = server == 'auto-connect' + if not auto_connect: + try: + host, port, protocol = server.split(':') + except Exception: + self.show_message("Error:" + server + "\nIn doubt, type \"auto-connect\"") + return False + if out.get('server') or out.get('proxy'): + proxy = electrum.network.deserialize_proxy(out.get('proxy')) if out.get('proxy') else proxy_config + self.network.set_parameters(host, port, protocol, proxy, auto_connect) + + def settings_dialog(self): + fee = str(Decimal(self.config.fee_per_kb()) / COIN) + out = self.run_dialog('Settings', [ + {'label':'Default fee', 'type':'satoshis', 'value': fee } + ], buttons = 1) + if out: + if out.get('Default fee'): + fee = int(Decimal(out['Default fee']) * COIN) + self.config.set_key('fee_per_kb', fee, True) + + + def password_dialog(self): + out = self.run_dialog('Password', [ + {'label':'Password', 'type':'password', 'value':''} + ], buttons = 1) + return out.get('Password') + + + def run_dialog(self, title, items, interval=2, buttons=None, y_pos=3): + self.popup_pos = 0 + + self.w = curses.newwin( 5 + len(list(items))*interval + (2 if buttons else 0), 50, y_pos, 5) + w = self.w + out = {} + while True: + w.clear() + w.border(0) + w.addstr( 0, 2, title) + + num = len(list(items)) + + numpos = num + if buttons: numpos += 2 + + for i in range(num): + item = items[i] + label = item.get('label') + if item.get('type') == 'list': + value = item.get('value','') + elif item.get('type') == 'satoshis': + value = item.get('value','') + elif item.get('type') == 'str': + value = item.get('value','') + elif item.get('type') == 'password': + value = '*'*len(item.get('value','')) + else: + value = '' + if value is None: + value = '' + if len(value)<20: + value += ' '*(20-len(value)) + + if 'value' in item: + w.addstr( 2+interval*i, 2, label) + w.addstr( 2+interval*i, 15, value, curses.A_REVERSE if self.popup_pos%numpos==i else curses.color_pair(1) ) + else: + w.addstr( 2+interval*i, 2, label, curses.A_REVERSE if self.popup_pos%numpos==i else 0) + + if buttons: + w.addstr( 5+interval*i, 10, "[ ok ]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-2) else curses.color_pair(2)) + w.addstr( 5+interval*i, 25, "[cancel]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-1) else curses.color_pair(2)) + + w.refresh() + + c = self.stdscr.getch() + if c in [ord('q'), 27]: break + elif c in [curses.KEY_LEFT, curses.KEY_UP]: self.popup_pos -= 1 + elif c in [curses.KEY_RIGHT, curses.KEY_DOWN]: self.popup_pos +=1 + else: + i = self.popup_pos%numpos + if buttons and c==10: + if i == numpos-2: + return out + elif i == numpos -1: + return {} + + item = items[i] + _type = item.get('type') + + if _type == 'str': + item['value'] = self.edit_str(item['value'], c) + out[item.get('label')] = item.get('value') + + elif _type == 'password': + item['value'] = self.edit_str(item['value'], c) + out[item.get('label')] = item ['value'] + + elif _type == 'satoshis': + item['value'] = self.edit_str(item['value'], c, True) + out[item.get('label')] = item.get('value') + + elif _type == 'list': + choices = item.get('choices') + try: + j = choices.index(item.get('value')) + except Exception: + j = 0 + new_choice = choices[(j + 1)% len(choices)] + item['value'] = new_choice + out[item.get('label')] = item.get('value') + + elif _type == 'button': + out['button'] = item.get('label') + break + + return out DIR diff --git a/electrum/i18n.py b/electrum/i18n.py t@@ -0,0 +1,81 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2012 thomasv@gitorious +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import os + +import gettext + +LOCALE_DIR = os.path.join(os.path.dirname(__file__), 'locale') +language = gettext.translation('electrum', LOCALE_DIR, fallback=True) + + +def _(x): + global language + return language.gettext(x) + + +def set_language(x): + global language + if x: + language = gettext.translation('electrum', LOCALE_DIR, fallback=True, languages=[x]) + + +languages = { + '': _('Default'), + 'ar_SA': _('Arabic'), + 'bg_BG': _('Bulgarian'), + 'cs_CZ': _('Czech'), + 'da_DK': _('Danish'), + 'de_DE': _('German'), + 'el_GR': _('Greek'), + 'eo_UY': _('Esperanto'), + 'en_UK': _('English'), + 'es_ES': _('Spanish'), + 'fa_IR': _('Persian'), + 'fr_FR': _('French'), + 'hu_HU': _('Hungarian'), + 'hy_AM': _('Armenian'), + 'id_ID': _('Indonesian'), + 'it_IT': _('Italian'), + 'ja_JP': _('Japanese'), + 'ky_KG': _('Kyrgyz'), + 'lv_LV': _('Latvian'), + 'nb_NO': _('Norwegian Bokmal'), + 'nl_NL': _('Dutch'), + 'pl_PL': _('Polish'), + 'pt_BR': _('Brasilian'), + 'pt_PT': _('Portuguese'), + 'ro_RO': _('Romanian'), + 'ru_RU': _('Russian'), + 'sk_SK': _('Slovak'), + 'sl_SI': _('Slovenian'), + 'sv_SE': _('Swedish'), + 'ta_IN': _('Tamil'), + 'th_TH': _('Thai'), + 'tr_TR': _('Turkish'), + 'uk_UA': _('Ukrainian'), + 'vi_VN': _('Vietnamese'), + 'zh_CN': _('Chinese Simplified'), + 'zh_TW': _('Chinese Traditional') +} DIR diff --git a/electrum/interface.py b/electrum/interface.py t@@ -0,0 +1,407 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2011 thomasv@gitorious +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import os +import re +import socket +import ssl +import sys +import threading +import time +import traceback + +import requests + +from .util import print_error + +ca_path = requests.certs.where() + +from . import util +from . import x509 +from . import pem + + +def Connection(server, queue, config_path): + """Makes asynchronous connections to a remote Electrum server. + Returns the running thread that is making the connection. + + Once the thread has connected, it finishes, placing a tuple on the + queue of the form (server, socket), where socket is None if + connection failed. + """ + host, port, protocol = server.rsplit(':', 2) + if not protocol in 'st': + raise Exception('Unknown protocol: %s' % protocol) + c = TcpConnection(server, queue, config_path) + c.start() + return c + + +class TcpConnection(threading.Thread, util.PrintError): + + def __init__(self, server, queue, config_path): + threading.Thread.__init__(self) + self.config_path = config_path + self.queue = queue + self.server = server + self.host, self.port, self.protocol = self.server.rsplit(':', 2) + self.host = str(self.host) + self.port = int(self.port) + self.use_ssl = (self.protocol == 's') + self.daemon = True + + def diagnostic_name(self): + return self.host + + def check_host_name(self, peercert, name): + """Simple certificate/host name checker. Returns True if the + certificate matches, False otherwise. Does not support + wildcards.""" + # Check that the peer has supplied a certificate. + # None/{} is not acceptable. + if not peercert: + return False + if 'subjectAltName' in peercert: + for typ, val in peercert["subjectAltName"]: + if typ == "DNS" and val == name: + return True + else: + # Only check the subject DN if there is no subject alternative + # name. + cn = None + for attr, val in peercert["subject"]: + # Use most-specific (last) commonName attribute. + if attr == "commonName": + cn = val + if cn is not None: + return cn == name + return False + + def get_simple_socket(self): + try: + l = socket.getaddrinfo(self.host, self.port, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror: + self.print_error("cannot resolve hostname") + return + e = None + for res in l: + try: + s = socket.socket(res[0], socket.SOCK_STREAM) + s.settimeout(10) + s.connect(res[4]) + s.settimeout(2) + s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + return s + except BaseException as _e: + e = _e + continue + else: + self.print_error("failed to connect", str(e)) + + @staticmethod + def get_ssl_context(cert_reqs, ca_certs): + context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_certs) + context.check_hostname = False + context.verify_mode = cert_reqs + + context.options |= ssl.OP_NO_SSLv2 + context.options |= ssl.OP_NO_SSLv3 + context.options |= ssl.OP_NO_TLSv1 + + return context + + def get_socket(self): + if self.use_ssl: + cert_path = os.path.join(self.config_path, 'certs', self.host) + if not os.path.exists(cert_path): + is_new = True + s = self.get_simple_socket() + if s is None: + return + # try with CA first + try: + context = self.get_ssl_context(cert_reqs=ssl.CERT_REQUIRED, ca_certs=ca_path) + s = context.wrap_socket(s, do_handshake_on_connect=True) + except ssl.SSLError as e: + self.print_error(e) + except: + return + else: + try: + peer_cert = s.getpeercert() + except OSError: + return + if self.check_host_name(peer_cert, self.host): + self.print_error("SSL certificate signed by CA") + return s + # get server certificate. + # Do not use ssl.get_server_certificate because it does not work with proxy + s = self.get_simple_socket() + if s is None: + return + try: + context = self.get_ssl_context(cert_reqs=ssl.CERT_NONE, ca_certs=None) + s = context.wrap_socket(s) + except ssl.SSLError as e: + self.print_error("SSL error retrieving SSL certificate:", e) + return + except: + return + + try: + dercert = s.getpeercert(True) + except OSError: + return + s.close() + cert = ssl.DER_cert_to_PEM_cert(dercert) + # workaround android bug + cert = re.sub("([^\n])-----END CERTIFICATE-----","\\1\n-----END CERTIFICATE-----",cert) + temporary_path = cert_path + '.temp' + util.assert_datadir_available(self.config_path) + with open(temporary_path, "w", encoding='utf-8') as f: + f.write(cert) + f.flush() + os.fsync(f.fileno()) + else: + is_new = False + + s = self.get_simple_socket() + if s is None: + return + + if self.use_ssl: + try: + context = self.get_ssl_context(cert_reqs=ssl.CERT_REQUIRED, + ca_certs=(temporary_path if is_new else cert_path)) + s = context.wrap_socket(s, do_handshake_on_connect=True) + except socket.timeout: + self.print_error('timeout') + return + except ssl.SSLError as e: + self.print_error("SSL error:", e) + if e.errno != 1: + return + if is_new: + rej = cert_path + '.rej' + if os.path.exists(rej): + os.unlink(rej) + os.rename(temporary_path, rej) + else: + util.assert_datadir_available(self.config_path) + with open(cert_path, encoding='utf-8') as f: + cert = f.read() + try: + b = pem.dePem(cert, 'CERTIFICATE') + x = x509.X509(b) + except: + traceback.print_exc(file=sys.stderr) + self.print_error("wrong certificate") + return + try: + x.check_date() + except: + self.print_error("certificate has expired:", cert_path) + os.unlink(cert_path) + return + self.print_error("wrong certificate") + if e.errno == 104: + return + return + except BaseException as e: + self.print_error(e) + traceback.print_exc(file=sys.stderr) + return + + if is_new: + self.print_error("saving certificate") + os.rename(temporary_path, cert_path) + + return s + + def run(self): + socket = self.get_socket() + if socket: + self.print_error("connected") + self.queue.put((self.server, socket)) + + +class Interface(util.PrintError): + """The Interface class handles a socket connected to a single remote + Electrum server. Its exposed API is: + + - Member functions close(), fileno(), get_responses(), has_timed_out(), + ping_required(), queue_request(), send_requests() + - Member variable server. + """ + + def __init__(self, server, socket): + self.server = server + self.host, _, _ = server.rsplit(':', 2) + self.socket = socket + + self.pipe = util.SocketPipe(socket) + self.pipe.set_timeout(0.0) # Don't wait for data + # Dump network messages. Set at runtime from the console. + self.debug = False + self.unsent_requests = [] + self.unanswered_requests = {} + self.last_send = time.time() + self.closed_remotely = False + + def diagnostic_name(self): + return self.host + + def fileno(self): + # Needed for select + return self.socket.fileno() + + def close(self): + if not self.closed_remotely: + try: + self.socket.shutdown(socket.SHUT_RDWR) + except socket.error: + pass + self.socket.close() + + def queue_request(self, *args): # method, params, _id + '''Queue a request, later to be send with send_requests when the + socket is available for writing. + ''' + self.request_time = time.time() + self.unsent_requests.append(args) + + def num_requests(self): + '''Keep unanswered requests below 100''' + n = 100 - len(self.unanswered_requests) + return min(n, len(self.unsent_requests)) + + def send_requests(self): + '''Sends queued requests. Returns False on failure.''' + self.last_send = time.time() + make_dict = lambda m, p, i: {'method': m, 'params': p, 'id': i} + n = self.num_requests() + wire_requests = self.unsent_requests[0:n] + try: + self.pipe.send_all([make_dict(*r) for r in wire_requests]) + except BaseException as e: + self.print_error("pipe send error:", e) + return False + self.unsent_requests = self.unsent_requests[n:] + for request in wire_requests: + if self.debug: + self.print_error("-->", request) + self.unanswered_requests[request[2]] = request + return True + + def ping_required(self): + '''Returns True if a ping should be sent.''' + return time.time() - self.last_send > 300 + + def has_timed_out(self): + '''Returns True if the interface has timed out.''' + if (self.unanswered_requests and time.time() - self.request_time > 10 + and self.pipe.idle_time() > 10): + self.print_error("timeout", len(self.unanswered_requests)) + return True + + return False + + def get_responses(self): + '''Call if there is data available on the socket. Returns a list of + (request, response) pairs. Notifications are singleton + unsolicited responses presumably as a result of prior + subscriptions, so request is None and there is no 'id' member. + Otherwise it is a response, which has an 'id' member and a + corresponding request. If the connection was closed remotely + or the remote server is misbehaving, a (None, None) will appear. + ''' + responses = [] + while True: + try: + response = self.pipe.get() + except util.timeout: + break + if not type(response) is dict: + responses.append((None, None)) + if response is None: + self.closed_remotely = True + self.print_error("connection closed remotely") + break + if self.debug: + self.print_error("<--", response) + wire_id = response.get('id', None) + if wire_id is None: # Notification + responses.append((None, response)) + else: + request = self.unanswered_requests.pop(wire_id, None) + if request: + responses.append((request, response)) + else: + self.print_error("unknown wire ID", wire_id) + responses.append((None, None)) # Signal + break + + return responses + + +def check_cert(host, cert): + try: + b = pem.dePem(cert, 'CERTIFICATE') + x = x509.X509(b) + except: + traceback.print_exc(file=sys.stdout) + return + + try: + x.check_date() + expired = False + except: + expired = True + + m = "host: %s\n"%host + m += "has_expired: %s\n"% expired + util.print_msg(m) + + +# Used by tests +def _match_hostname(name, val): + if val == name: + return True + + return val.startswith('*.') and name.endswith(val[1:]) + + +def test_certificates(): + from .simple_config import SimpleConfig + config = SimpleConfig() + mydir = os.path.join(config.path, "certs") + certs = os.listdir(mydir) + for c in certs: + p = os.path.join(mydir,c) + with open(p, encoding='utf-8') as f: + cert = f.read() + check_cert(c, cert) + +if __name__ == "__main__": + test_certificates() DIR diff --git a/electrum/jsonrpc.py b/electrum/jsonrpc.py t@@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2018 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer, SimpleJSONRPCRequestHandler +from base64 import b64decode +import time + +from . import util + + +class RPCAuthCredentialsInvalid(Exception): + def __str__(self): + return 'Authentication failed (bad credentials)' + + +class RPCAuthCredentialsMissing(Exception): + def __str__(self): + return 'Authentication failed (missing credentials)' + + +class RPCAuthUnsupportedType(Exception): + def __str__(self): + return 'Authentication failed (only basic auth is supported)' + + +# based on http://acooke.org/cute/BasicHTTPA0.html by andrew cooke +class VerifyingJSONRPCServer(SimpleJSONRPCServer): + + def __init__(self, *args, rpc_user, rpc_password, **kargs): + + self.rpc_user = rpc_user + self.rpc_password = rpc_password + + class VerifyingRequestHandler(SimpleJSONRPCRequestHandler): + def parse_request(myself): + # first, call the original implementation which returns + # True if all OK so far + if SimpleJSONRPCRequestHandler.parse_request(myself): + # Do not authenticate OPTIONS-requests + if myself.command.strip() == 'OPTIONS': + return True + try: + self.authenticate(myself.headers) + return True + except (RPCAuthCredentialsInvalid, RPCAuthCredentialsMissing, + RPCAuthUnsupportedType) as e: + myself.send_error(401, str(e)) + except BaseException as e: + import traceback, sys + traceback.print_exc(file=sys.stderr) + myself.send_error(500, str(e)) + return False + + SimpleJSONRPCServer.__init__( + self, requestHandler=VerifyingRequestHandler, *args, **kargs) + + def authenticate(self, headers): + if self.rpc_password == '': + # RPC authentication is disabled + return + + auth_string = headers.get('Authorization', None) + if auth_string is None: + raise RPCAuthCredentialsMissing() + + (basic, _, encoded) = auth_string.partition(' ') + if basic != 'Basic': + raise RPCAuthUnsupportedType() + + encoded = util.to_bytes(encoded, 'utf8') + credentials = util.to_string(b64decode(encoded), 'utf8') + (username, _, password) = credentials.partition(':') + if not (util.constant_time_compare(username, self.rpc_user) + and util.constant_time_compare(password, self.rpc_password)): + time.sleep(0.050) + raise RPCAuthCredentialsInvalid() DIR diff --git a/electrum/keystore.py b/electrum/keystore.py t@@ -0,0 +1,798 @@ +#!/usr/bin/env python2 +# -*- mode: python -*- +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2016 The Electrum developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from unicodedata import normalize + +from . import bitcoin, ecc, constants +from .bitcoin import * +from .ecc import string_to_number, number_to_string +from .crypto import pw_decode, pw_encode +from .util import (PrintError, InvalidPassword, hfu, WalletFileException, + BitcoinException) +from .mnemonic import Mnemonic, load_wordlist +from .plugin import run_hook + + +class KeyStore(PrintError): + + def has_seed(self): + return False + + def is_watching_only(self): + return False + + def can_import(self): + return False + + def may_have_password(self): + """Returns whether the keystore can be encrypted with a password.""" + raise NotImplementedError() + + def get_tx_derivations(self, tx): + keypairs = {} + for txin in tx.inputs(): + num_sig = txin.get('num_sig') + if num_sig is None: + continue + x_signatures = txin['signatures'] + signatures = [sig for sig in x_signatures if sig] + if len(signatures) == num_sig: + # input is complete + continue + for k, x_pubkey in enumerate(txin['x_pubkeys']): + if x_signatures[k] is not None: + # this pubkey already signed + continue + derivation = self.get_pubkey_derivation(x_pubkey) + if not derivation: + continue + keypairs[x_pubkey] = derivation + return keypairs + + def can_sign(self, tx): + if self.is_watching_only(): + return False + return bool(self.get_tx_derivations(tx)) + + def ready_to_sign(self): + return not self.is_watching_only() + + +class Software_KeyStore(KeyStore): + + def __init__(self): + KeyStore.__init__(self) + + def may_have_password(self): + return not self.is_watching_only() + + def sign_message(self, sequence, message, password): + privkey, compressed = self.get_private_key(sequence, password) + key = ecc.ECPrivkey(privkey) + return key.sign_message(message, compressed) + + def decrypt_message(self, sequence, message, password): + privkey, compressed = self.get_private_key(sequence, password) + ec = ecc.ECPrivkey(privkey) + decrypted = ec.decrypt_message(message) + return decrypted + + def sign_transaction(self, tx, password): + if self.is_watching_only(): + return + # Raise if password is not correct. + self.check_password(password) + # Add private keys + keypairs = self.get_tx_derivations(tx) + for k, v in keypairs.items(): + keypairs[k] = self.get_private_key(v, password) + # Sign + if keypairs: + tx.sign(keypairs) + + +class Imported_KeyStore(Software_KeyStore): + # keystore for imported private keys + + def __init__(self, d): + Software_KeyStore.__init__(self) + self.keypairs = d.get('keypairs', {}) + + def is_deterministic(self): + return False + + def get_master_public_key(self): + return None + + def dump(self): + return { + 'type': 'imported', + 'keypairs': self.keypairs, + } + + def can_import(self): + return True + + def check_password(self, password): + pubkey = list(self.keypairs.keys())[0] + self.get_private_key(pubkey, password) + + def import_privkey(self, sec, password): + txin_type, privkey, compressed = deserialize_privkey(sec) + pubkey = ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed) + # re-serialize the key so the internal storage format is consistent + serialized_privkey = serialize_privkey( + privkey, compressed, txin_type, internal_use=True) + # NOTE: if the same pubkey is reused for multiple addresses (script types), + # there will only be one pubkey-privkey pair for it in self.keypairs, + # and the privkey will encode a txin_type but that txin_type cannot be trusted. + # Removing keys complicates this further. + self.keypairs[pubkey] = pw_encode(serialized_privkey, password) + return txin_type, pubkey + + def delete_imported_key(self, key): + self.keypairs.pop(key) + + def get_private_key(self, pubkey, password): + sec = pw_decode(self.keypairs[pubkey], password) + txin_type, privkey, compressed = deserialize_privkey(sec) + # this checks the password + if pubkey != ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed): + raise InvalidPassword() + return privkey, compressed + + def get_pubkey_derivation(self, x_pubkey): + if x_pubkey[0:2] in ['02', '03', '04']: + if x_pubkey in self.keypairs.keys(): + return x_pubkey + elif x_pubkey[0:2] == 'fd': + addr = bitcoin.script_to_address(x_pubkey[2:]) + if addr in self.addresses: + return self.addresses[addr].get('pubkey') + + def update_password(self, old_password, new_password): + self.check_password(old_password) + if new_password == '': + new_password = None + for k, v in self.keypairs.items(): + b = pw_decode(v, old_password) + c = pw_encode(b, new_password) + self.keypairs[k] = c + + + +class Deterministic_KeyStore(Software_KeyStore): + + def __init__(self, d): + Software_KeyStore.__init__(self) + self.seed = d.get('seed', '') + self.passphrase = d.get('passphrase', '') + + def is_deterministic(self): + return True + + def dump(self): + d = {} + if self.seed: + d['seed'] = self.seed + if self.passphrase: + d['passphrase'] = self.passphrase + return d + + def has_seed(self): + return bool(self.seed) + + def is_watching_only(self): + return not self.has_seed() + + def add_seed(self, seed): + if self.seed: + raise Exception("a seed exists") + self.seed = self.format_seed(seed) + + def get_seed(self, password): + return pw_decode(self.seed, password) + + def get_passphrase(self, password): + return pw_decode(self.passphrase, password) if self.passphrase else '' + + +class Xpub: + + def __init__(self): + self.xpub = None + self.xpub_receive = None + self.xpub_change = None + + def get_master_public_key(self): + return self.xpub + + def derive_pubkey(self, for_change, n): + xpub = self.xpub_change if for_change else self.xpub_receive + if xpub is None: + xpub = bip32_public_derivation(self.xpub, "", "/%d"%for_change) + if for_change: + self.xpub_change = xpub + else: + self.xpub_receive = xpub + return self.get_pubkey_from_xpub(xpub, (n,)) + + @classmethod + def get_pubkey_from_xpub(self, xpub, sequence): + _, _, _, _, c, cK = deserialize_xpub(xpub) + for i in sequence: + cK, c = CKD_pub(cK, c, i) + return bh2u(cK) + + def get_xpubkey(self, c, i): + s = ''.join(map(lambda x: bitcoin.int_to_hex(x,2), (c, i))) + return 'ff' + bh2u(bitcoin.DecodeBase58Check(self.xpub)) + s + + @classmethod + def parse_xpubkey(self, pubkey): + assert pubkey[0:2] == 'ff' + pk = bfh(pubkey) + pk = pk[1:] + xkey = bitcoin.EncodeBase58Check(pk[0:78]) + dd = pk[78:] + s = [] + while dd: + n = int(bitcoin.rev_hex(bh2u(dd[0:2])), 16) + dd = dd[2:] + s.append(n) + assert len(s) == 2 + return xkey, s + + def get_pubkey_derivation(self, x_pubkey): + if x_pubkey[0:2] != 'ff': + return + xpub, derivation = self.parse_xpubkey(x_pubkey) + if self.xpub != xpub: + return + return derivation + + +class BIP32_KeyStore(Deterministic_KeyStore, Xpub): + + def __init__(self, d): + Xpub.__init__(self) + Deterministic_KeyStore.__init__(self, d) + self.xpub = d.get('xpub') + self.xprv = d.get('xprv') + + def format_seed(self, seed): + return ' '.join(seed.split()) + + def dump(self): + d = Deterministic_KeyStore.dump(self) + d['type'] = 'bip32' + d['xpub'] = self.xpub + d['xprv'] = self.xprv + return d + + def get_master_private_key(self, password): + return pw_decode(self.xprv, password) + + def check_password(self, password): + xprv = pw_decode(self.xprv, password) + if deserialize_xprv(xprv)[4] != deserialize_xpub(self.xpub)[4]: + raise InvalidPassword() + + def update_password(self, old_password, new_password): + self.check_password(old_password) + if new_password == '': + new_password = None + if self.has_seed(): + decoded = self.get_seed(old_password) + self.seed = pw_encode(decoded, new_password) + if self.passphrase: + decoded = self.get_passphrase(old_password) + self.passphrase = pw_encode(decoded, new_password) + if self.xprv is not None: + b = pw_decode(self.xprv, old_password) + self.xprv = pw_encode(b, new_password) + + def is_watching_only(self): + return self.xprv is None + + def add_xprv(self, xprv): + self.xprv = xprv + self.xpub = bitcoin.xpub_from_xprv(xprv) + + def add_xprv_from_seed(self, bip32_seed, xtype, derivation): + xprv, xpub = bip32_root(bip32_seed, xtype) + xprv, xpub = bip32_private_derivation(xprv, "m/", derivation) + self.add_xprv(xprv) + + def get_private_key(self, sequence, password): + xprv = self.get_master_private_key(password) + _, _, _, _, c, k = deserialize_xprv(xprv) + pk = bip32_private_key(sequence, k, c) + return pk, True + + + +class Old_KeyStore(Deterministic_KeyStore): + + def __init__(self, d): + Deterministic_KeyStore.__init__(self, d) + self.mpk = d.get('mpk') + + def get_hex_seed(self, password): + return pw_decode(self.seed, password).encode('utf8') + + def dump(self): + d = Deterministic_KeyStore.dump(self) + d['mpk'] = self.mpk + d['type'] = 'old' + return d + + def add_seed(self, seedphrase): + Deterministic_KeyStore.add_seed(self, seedphrase) + s = self.get_hex_seed(None) + self.mpk = self.mpk_from_seed(s) + + def add_master_public_key(self, mpk): + self.mpk = mpk + + def format_seed(self, seed): + from . import old_mnemonic, mnemonic + seed = mnemonic.normalize_text(seed) + # see if seed was entered as hex + if seed: + try: + bfh(seed) + return str(seed) + except Exception: + pass + words = seed.split() + seed = old_mnemonic.mn_decode(words) + if not seed: + raise Exception("Invalid seed") + return seed + + def get_seed(self, password): + from . import old_mnemonic + s = self.get_hex_seed(password) + return ' '.join(old_mnemonic.mn_encode(s)) + + @classmethod + def mpk_from_seed(klass, seed): + secexp = klass.stretch_key(seed) + privkey = ecc.ECPrivkey.from_secret_scalar(secexp) + return privkey.get_public_key_hex(compressed=False)[2:] + + @classmethod + def stretch_key(self, seed): + x = seed + for i in range(100000): + x = hashlib.sha256(x + seed).digest() + return string_to_number(x) + + @classmethod + def get_sequence(self, mpk, for_change, n): + return string_to_number(Hash(("%d:%d:"%(n, for_change)).encode('ascii') + bfh(mpk))) + + @classmethod + def get_pubkey_from_mpk(self, mpk, for_change, n): + z = self.get_sequence(mpk, for_change, n) + master_public_key = ecc.ECPubkey(bfh('04'+mpk)) + public_key = master_public_key + z*ecc.generator() + return public_key.get_public_key_hex(compressed=False) + + def derive_pubkey(self, for_change, n): + return self.get_pubkey_from_mpk(self.mpk, for_change, n) + + def get_private_key_from_stretched_exponent(self, for_change, n, secexp): + secexp = (secexp + self.get_sequence(self.mpk, for_change, n)) % ecc.CURVE_ORDER + pk = number_to_string(secexp, ecc.CURVE_ORDER) + return pk + + def get_private_key(self, sequence, password): + seed = self.get_hex_seed(password) + self.check_seed(seed) + for_change, n = sequence + secexp = self.stretch_key(seed) + pk = self.get_private_key_from_stretched_exponent(for_change, n, secexp) + return pk, False + + def check_seed(self, seed): + secexp = self.stretch_key(seed) + master_private_key = ecc.ECPrivkey.from_secret_scalar(secexp) + master_public_key = master_private_key.get_public_key_bytes(compressed=False)[1:] + if master_public_key != bfh(self.mpk): + print_error('invalid password (mpk)', self.mpk, bh2u(master_public_key)) + raise InvalidPassword() + + def check_password(self, password): + seed = self.get_hex_seed(password) + self.check_seed(seed) + + def get_master_public_key(self): + return self.mpk + + def get_xpubkey(self, for_change, n): + s = ''.join(map(lambda x: bitcoin.int_to_hex(x,2), (for_change, n))) + return 'fe' + self.mpk + s + + @classmethod + def parse_xpubkey(self, x_pubkey): + assert x_pubkey[0:2] == 'fe' + pk = x_pubkey[2:] + mpk = pk[0:128] + dd = pk[128:] + s = [] + while dd: + n = int(bitcoin.rev_hex(dd[0:4]), 16) + dd = dd[4:] + s.append(n) + assert len(s) == 2 + return mpk, s + + def get_pubkey_derivation(self, x_pubkey): + if x_pubkey[0:2] != 'fe': + return + mpk, derivation = self.parse_xpubkey(x_pubkey) + if self.mpk != mpk: + return + return derivation + + def update_password(self, old_password, new_password): + self.check_password(old_password) + if new_password == '': + new_password = None + if self.has_seed(): + decoded = pw_decode(self.seed, old_password) + self.seed = pw_encode(decoded, new_password) + + + +class Hardware_KeyStore(KeyStore, Xpub): + # Derived classes must set: + # - device + # - DEVICE_IDS + # - wallet_type + + #restore_wallet_class = BIP32_RD_Wallet + max_change_outputs = 1 + + def __init__(self, d): + Xpub.__init__(self) + KeyStore.__init__(self) + # Errors and other user interaction is done through the wallet's + # handler. The handler is per-window and preserved across + # device reconnects + self.xpub = d.get('xpub') + self.label = d.get('label') + self.derivation = d.get('derivation') + self.handler = None + run_hook('init_keystore', self) + + def set_label(self, label): + self.label = label + + def may_have_password(self): + return False + + def is_deterministic(self): + return True + + def dump(self): + return { + 'type': 'hardware', + 'hw_type': self.hw_type, + 'xpub': self.xpub, + 'derivation':self.derivation, + 'label':self.label, + } + + def unpaired(self): + '''A device paired with the wallet was disconnected. This can be + called in any thread context.''' + self.print_error("unpaired") + + def paired(self): + '''A device paired with the wallet was (re-)connected. This can be + called in any thread context.''' + self.print_error("paired") + + def can_export(self): + return False + + def is_watching_only(self): + '''The wallet is not watching-only; the user will be prompted for + pin and passphrase as appropriate when needed.''' + assert not self.has_seed() + return False + + def get_password_for_storage_encryption(self): + from .storage import get_derivation_used_for_hw_device_encryption + client = self.plugin.get_client(self) + derivation = get_derivation_used_for_hw_device_encryption() + xpub = client.get_xpub(derivation, "standard") + password = self.get_pubkey_from_xpub(xpub, ()) + return password + + def has_usable_connection_with_device(self): + if not hasattr(self, 'plugin'): + return False + client = self.plugin.get_client(self, force_pair=False) + if client is None: + return False + return client.has_usable_connection_with_device() + + def ready_to_sign(self): + return super().ready_to_sign() and self.has_usable_connection_with_device() + + +def bip39_normalize_passphrase(passphrase): + return normalize('NFKD', passphrase or '') + +def bip39_to_seed(mnemonic, passphrase): + import pbkdf2, hashlib, hmac + PBKDF2_ROUNDS = 2048 + mnemonic = normalize('NFKD', ' '.join(mnemonic.split())) + passphrase = bip39_normalize_passphrase(passphrase) + return pbkdf2.PBKDF2(mnemonic, 'mnemonic' + passphrase, + iterations = PBKDF2_ROUNDS, macmodule = hmac, + digestmodule = hashlib.sha512).read(64) + +# returns tuple (is_checksum_valid, is_wordlist_valid) +def bip39_is_checksum_valid(mnemonic): + words = [ normalize('NFKD', word) for word in mnemonic.split() ] + words_len = len(words) + wordlist = load_wordlist("english.txt") + n = len(wordlist) + checksum_length = 11*words_len//33 + entropy_length = 32*checksum_length + i = 0 + words.reverse() + while words: + w = words.pop() + try: + k = wordlist.index(w) + except ValueError: + return False, False + i = i*n + k + if words_len not in [12, 15, 18, 21, 24]: + return False, True + entropy = i >> checksum_length + checksum = i % 2**checksum_length + h = '{:x}'.format(entropy) + while len(h) < entropy_length/4: + h = '0'+h + b = bytearray.fromhex(h) + hashed = int(hfu(hashlib.sha256(b).digest()), 16) + calculated_checksum = hashed >> (256 - checksum_length) + return checksum == calculated_checksum, True + + +def from_bip39_seed(seed, passphrase, derivation, xtype=None): + k = BIP32_KeyStore({}) + bip32_seed = bip39_to_seed(seed, passphrase) + if xtype is None: + xtype = xtype_from_derivation(derivation) + k.add_xprv_from_seed(bip32_seed, xtype, derivation) + return k + + +def xtype_from_derivation(derivation: str) -> str: + """Returns the script type to be used for this derivation.""" + if derivation.startswith("m/84'"): + return 'p2wpkh' + elif derivation.startswith("m/49'"): + return 'p2wpkh-p2sh' + elif derivation.startswith("m/44'"): + return 'standard' + elif derivation.startswith("m/45'"): + return 'standard' + + bip32_indices = list(bip32_derivation(derivation)) + if len(bip32_indices) >= 4: + if bip32_indices[0] == 48 + BIP32_PRIME: + # m / purpose' / coin_type' / account' / script_type' / change / address_index + script_type_int = bip32_indices[3] - BIP32_PRIME + script_type = PURPOSE48_SCRIPT_TYPES_INV.get(script_type_int) + if script_type is not None: + return script_type + return 'standard' + + +# extended pubkeys + +def is_xpubkey(x_pubkey): + return x_pubkey[0:2] == 'ff' + + +def parse_xpubkey(x_pubkey): + assert x_pubkey[0:2] == 'ff' + return BIP32_KeyStore.parse_xpubkey(x_pubkey) + + +def xpubkey_to_address(x_pubkey): + if x_pubkey[0:2] == 'fd': + address = bitcoin.script_to_address(x_pubkey[2:]) + return x_pubkey, address + if x_pubkey[0:2] in ['02', '03', '04']: + pubkey = x_pubkey + elif x_pubkey[0:2] == 'ff': + xpub, s = BIP32_KeyStore.parse_xpubkey(x_pubkey) + pubkey = BIP32_KeyStore.get_pubkey_from_xpub(xpub, s) + elif x_pubkey[0:2] == 'fe': + mpk, s = Old_KeyStore.parse_xpubkey(x_pubkey) + pubkey = Old_KeyStore.get_pubkey_from_mpk(mpk, s[0], s[1]) + else: + raise BitcoinException("Cannot parse pubkey. prefix: {}" + .format(x_pubkey[0:2])) + if pubkey: + address = public_key_to_p2pkh(bfh(pubkey)) + return pubkey, address + +def xpubkey_to_pubkey(x_pubkey): + pubkey, address = xpubkey_to_address(x_pubkey) + return pubkey + +hw_keystores = {} + +def register_keystore(hw_type, constructor): + hw_keystores[hw_type] = constructor + +def hardware_keystore(d): + hw_type = d['hw_type'] + if hw_type in hw_keystores: + constructor = hw_keystores[hw_type] + return constructor(d) + raise WalletFileException('unknown hardware type: {}. hw_keystores: {}'.format(hw_type, list(hw_keystores.keys()))) + +def load_keystore(storage, name): + d = storage.get(name, {}) + t = d.get('type') + if not t: + raise WalletFileException( + 'Wallet format requires update.\n' + 'Cannot find keystore for name {}'.format(name)) + if t == 'old': + k = Old_KeyStore(d) + elif t == 'imported': + k = Imported_KeyStore(d) + elif t == 'bip32': + k = BIP32_KeyStore(d) + elif t == 'hardware': + k = hardware_keystore(d) + else: + raise WalletFileException( + 'Unknown type {} for keystore named {}'.format(t, name)) + return k + + +def is_old_mpk(mpk: str) -> bool: + try: + int(mpk, 16) + except: + return False + if len(mpk) != 128: + return False + try: + ecc.ECPubkey(bfh('04' + mpk)) + except: + return False + return True + + +def is_address_list(text): + parts = text.split() + return bool(parts) and all(bitcoin.is_address(x) for x in parts) + + +def get_private_keys(text): + parts = text.split('\n') + parts = map(lambda x: ''.join(x.split()), parts) + parts = list(filter(bool, parts)) + if bool(parts) and all(bitcoin.is_private_key(x) for x in parts): + return parts + + +def is_private_key_list(text): + return bool(get_private_keys(text)) + + +is_mpk = lambda x: is_old_mpk(x) or is_xpub(x) +is_private = lambda x: is_seed(x) or is_xprv(x) or is_private_key_list(x) +is_master_key = lambda x: is_old_mpk(x) or is_xprv(x) or is_xpub(x) +is_private_key = lambda x: is_xprv(x) or is_private_key_list(x) +is_bip32_key = lambda x: is_xprv(x) or is_xpub(x) + + +def bip44_derivation(account_id, bip43_purpose=44): + coin = constants.net.BIP44_COIN_TYPE + return "m/%d'/%d'/%d'" % (bip43_purpose, coin, int(account_id)) + + +def purpose48_derivation(account_id: int, xtype: str) -> str: + # m / purpose' / coin_type' / account' / script_type' / change / address_index + bip43_purpose = 48 + coin = constants.net.BIP44_COIN_TYPE + account_id = int(account_id) + script_type_int = PURPOSE48_SCRIPT_TYPES.get(xtype) + if script_type_int is None: + raise Exception('unknown xtype: {}'.format(xtype)) + return "m/%d'/%d'/%d'/%d'" % (bip43_purpose, coin, account_id, script_type_int) + + +def from_seed(seed, passphrase, is_p2sh): + t = seed_type(seed) + if t == 'old': + keystore = Old_KeyStore({}) + keystore.add_seed(seed) + elif t in ['standard', 'segwit']: + keystore = BIP32_KeyStore({}) + keystore.add_seed(seed) + keystore.passphrase = passphrase + bip32_seed = Mnemonic.mnemonic_to_seed(seed, passphrase) + if t == 'standard': + der = "m/" + xtype = 'standard' + else: + der = "m/1'/" if is_p2sh else "m/0'/" + xtype = 'p2wsh' if is_p2sh else 'p2wpkh' + keystore.add_xprv_from_seed(bip32_seed, xtype, der) + else: + raise BitcoinException('Unexpected seed type {}'.format(t)) + return keystore + +def from_private_key_list(text): + keystore = Imported_KeyStore({}) + for x in get_private_keys(text): + keystore.import_key(x, None) + return keystore + +def from_old_mpk(mpk): + keystore = Old_KeyStore({}) + keystore.add_master_public_key(mpk) + return keystore + +def from_xpub(xpub): + k = BIP32_KeyStore({}) + k.xpub = xpub + return k + +def from_xprv(xprv): + xpub = bitcoin.xpub_from_xprv(xprv) + k = BIP32_KeyStore({}) + k.xprv = xprv + k.xpub = xpub + return k + +def from_master_key(text): + if is_xprv(text): + k = from_xprv(text) + elif is_old_mpk(text): + k = from_old_mpk(text) + elif is_xpub(text): + k = from_xpub(text) + else: + raise BitcoinException('Invalid master key') + return k DIR diff --git a/electrum/mnemonic.py b/electrum/mnemonic.py t@@ -0,0 +1,183 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2014 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import os +import hmac +import math +import hashlib +import unicodedata +import string + +import ecdsa +import pbkdf2 + +from .util import print_error +from .bitcoin import is_old_seed, is_new_seed +from . import version + +# http://www.asahi-net.or.jp/~ax2s-kmtn/ref/unicode/e_asia.html +CJK_INTERVALS = [ + (0x4E00, 0x9FFF, 'CJK Unified Ideographs'), + (0x3400, 0x4DBF, 'CJK Unified Ideographs Extension A'), + (0x20000, 0x2A6DF, 'CJK Unified Ideographs Extension B'), + (0x2A700, 0x2B73F, 'CJK Unified Ideographs Extension C'), + (0x2B740, 0x2B81F, 'CJK Unified Ideographs Extension D'), + (0xF900, 0xFAFF, 'CJK Compatibility Ideographs'), + (0x2F800, 0x2FA1D, 'CJK Compatibility Ideographs Supplement'), + (0x3190, 0x319F , 'Kanbun'), + (0x2E80, 0x2EFF, 'CJK Radicals Supplement'), + (0x2F00, 0x2FDF, 'CJK Radicals'), + (0x31C0, 0x31EF, 'CJK Strokes'), + (0x2FF0, 0x2FFF, 'Ideographic Description Characters'), + (0xE0100, 0xE01EF, 'Variation Selectors Supplement'), + (0x3100, 0x312F, 'Bopomofo'), + (0x31A0, 0x31BF, 'Bopomofo Extended'), + (0xFF00, 0xFFEF, 'Halfwidth and Fullwidth Forms'), + (0x3040, 0x309F, 'Hiragana'), + (0x30A0, 0x30FF, 'Katakana'), + (0x31F0, 0x31FF, 'Katakana Phonetic Extensions'), + (0x1B000, 0x1B0FF, 'Kana Supplement'), + (0xAC00, 0xD7AF, 'Hangul Syllables'), + (0x1100, 0x11FF, 'Hangul Jamo'), + (0xA960, 0xA97F, 'Hangul Jamo Extended A'), + (0xD7B0, 0xD7FF, 'Hangul Jamo Extended B'), + (0x3130, 0x318F, 'Hangul Compatibility Jamo'), + (0xA4D0, 0xA4FF, 'Lisu'), + (0x16F00, 0x16F9F, 'Miao'), + (0xA000, 0xA48F, 'Yi Syllables'), + (0xA490, 0xA4CF, 'Yi Radicals'), +] + +def is_CJK(c): + n = ord(c) + for imin,imax,name in CJK_INTERVALS: + if n>=imin and n<=imax: return True + return False + + +def normalize_text(seed): + # normalize + seed = unicodedata.normalize('NFKD', seed) + # lower + seed = seed.lower() + # remove accents + seed = u''.join([c for c in seed if not unicodedata.combining(c)]) + # normalize whitespaces + seed = u' '.join(seed.split()) + # remove whitespaces between CJK + seed = u''.join([seed[i] for i in range(len(seed)) if not (seed[i] in string.whitespace and is_CJK(seed[i-1]) and is_CJK(seed[i+1]))]) + return seed + +def load_wordlist(filename): + path = os.path.join(os.path.dirname(__file__), 'wordlist', filename) + with open(path, 'r', encoding='utf-8') as f: + s = f.read().strip() + s = unicodedata.normalize('NFKD', s) + lines = s.split('\n') + wordlist = [] + for line in lines: + line = line.split('#')[0] + line = line.strip(' \r') + assert ' ' not in line + if line: + wordlist.append(line) + return wordlist + + +filenames = { + 'en':'english.txt', + 'es':'spanish.txt', + 'ja':'japanese.txt', + 'pt':'portuguese.txt', + 'zh':'chinese_simplified.txt' +} + + + +class Mnemonic(object): + # Seed derivation no longer follows BIP39 + # Mnemonic phrase uses a hash based checksum, instead of a wordlist-dependent checksum + + def __init__(self, lang=None): + lang = lang or 'en' + print_error('language', lang) + filename = filenames.get(lang[0:2], 'english.txt') + self.wordlist = load_wordlist(filename) + print_error("wordlist has %d words"%len(self.wordlist)) + + @classmethod + def mnemonic_to_seed(self, mnemonic, passphrase): + PBKDF2_ROUNDS = 2048 + mnemonic = normalize_text(mnemonic) + passphrase = normalize_text(passphrase) + return pbkdf2.PBKDF2(mnemonic, 'electrum' + passphrase, iterations = PBKDF2_ROUNDS, macmodule = hmac, digestmodule = hashlib.sha512).read(64) + + def mnemonic_encode(self, i): + n = len(self.wordlist) + words = [] + while i: + x = i%n + i = i//n + words.append(self.wordlist[x]) + return ' '.join(words) + + def get_suggestions(self, prefix): + for w in self.wordlist: + if w.startswith(prefix): + yield w + + def mnemonic_decode(self, seed): + n = len(self.wordlist) + words = seed.split() + i = 0 + while words: + w = words.pop() + k = self.wordlist.index(w) + i = i*n + k + return i + + def make_seed(self, seed_type='standard', num_bits=132): + prefix = version.seed_prefix(seed_type) + # increase num_bits in order to obtain a uniform distribution for the last word + bpw = math.log(len(self.wordlist), 2) + # rounding + n = int(math.ceil(num_bits/bpw) * bpw) + print_error("make_seed. prefix: '%s'"%prefix, "entropy: %d bits"%n) + entropy = 1 + while entropy < pow(2, n - bpw): + # try again if seed would not contain enough words + entropy = ecdsa.util.randrange(pow(2, n)) + nonce = 0 + while True: + nonce += 1 + i = entropy + nonce + seed = self.mnemonic_encode(i) + if i != self.mnemonic_decode(seed): + raise Exception('Cannot extract same entropy from mnemonic!') + if is_old_seed(seed): + continue + if is_new_seed(seed, prefix): + break + print_error('%d words'%len(seed.split())) + return seed DIR diff --git a/electrum/msqr.py b/electrum/msqr.py t@@ -0,0 +1,94 @@ +# from http://eli.thegreenplace.net/2009/03/07/computing-modular-square-roots-in-python/ + +def modular_sqrt(a, p): + """ Find a quadratic residue (mod p) of 'a'. p + must be an odd prime. + + Solve the congruence of the form: + x^2 = a (mod p) + And returns x. Note that p - x is also a root. + + 0 is returned is no square root exists for + these a and p. + + The Tonelli-Shanks algorithm is used (except + for some simple cases in which the solution + is known from an identity). This algorithm + runs in polynomial time (unless the + generalized Riemann hypothesis is false). + """ + # Simple cases + # + if legendre_symbol(a, p) != 1: + return 0 + elif a == 0: + return 0 + elif p == 2: + return p + elif p % 4 == 3: + return pow(a, (p + 1) // 4, p) + + # Partition p-1 to s * 2^e for an odd s (i.e. + # reduce all the powers of 2 from p-1) + # + s = p - 1 + e = 0 + while s % 2 == 0: + s //= 2 + e += 1 + + # Find some 'n' with a legendre symbol n|p = -1. + # Shouldn't take long. + # + n = 2 + while legendre_symbol(n, p) != -1: + n += 1 + + # Here be dragons! + # Read the paper "Square roots from 1; 24, 51, + # 10 to Dan Shanks" by Ezra Brown for more + # information + # + + # x is a guess of the square root that gets better + # with each iteration. + # b is the "fudge factor" - by how much we're off + # with the guess. The invariant x^2 = ab (mod p) + # is maintained throughout the loop. + # g is used for successive powers of n to update + # both a and b + # r is the exponent - decreases with each update + # + x = pow(a, (s + 1) // 2, p) + b = pow(a, s, p) + g = pow(n, s, p) + r = e + + while True: + t = b + m = 0 + for m in range(r): + if t == 1: + break + t = pow(t, 2, p) + + if m == 0: + return x + + gs = pow(g, 2 ** (r - m - 1), p) + g = (gs * gs) % p + x = (x * gs) % p + b = (b * g) % p + r = m + +def legendre_symbol(a, p): + """ Compute the Legendre symbol a|p using + Euler's criterion. p is a prime, a is + relatively prime to p (if p divides + a, then a|p = 0) + + Returns 1 if a has a square root modulo + p, -1 otherwise. + """ + ls = pow(a, (p - 1) // 2, p) + return -1 if ls == p - 1 else ls DIR diff --git a/electrum/network.py b/electrum/network.py t@@ -0,0 +1,1297 @@ +# Electrum - Lightweight Bitcoin Client +# Copyright (c) 2011-2016 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import time +import queue +import os +import errno +import random +import re +import select +from collections import defaultdict +import threading +import socket +import json +import sys +import ipaddress + +import dns +import dns.resolver +import socks + +from . import util +from .util import print_error +from . import bitcoin +from .bitcoin import COIN +from . import constants +from .interface import Connection, Interface +from . import blockchain +from .version import ELECTRUM_VERSION, PROTOCOL_VERSION +from .i18n import _ + + +NODES_RETRY_INTERVAL = 60 +SERVER_RETRY_INTERVAL = 10 + + +def parse_servers(result): + """ parse servers list into dict format""" + servers = {} + for item in result: + host = item[1] + out = {} + version = None + pruning_level = '-' + if len(item) > 2: + for v in item[2]: + if re.match("[st]\d*", v): + protocol, port = v[0], v[1:] + if port == '': port = constants.net.DEFAULT_PORTS[protocol] + out[protocol] = port + elif re.match("v(.?)+", v): + version = v[1:] + elif re.match("p\d*", v): + pruning_level = v[1:] + if pruning_level == '': pruning_level = '0' + if out: + out['pruning'] = pruning_level + out['version'] = version + servers[host] = out + return servers + + +def filter_version(servers): + def is_recent(version): + try: + return util.normalize_version(version) >= util.normalize_version(PROTOCOL_VERSION) + except Exception as e: + return False + return {k: v for k, v in servers.items() if is_recent(v.get('version'))} + + +def filter_protocol(hostmap, protocol='s'): + '''Filters the hostmap for those implementing protocol. + The result is a list in serialized form.''' + eligible = [] + for host, portmap in hostmap.items(): + port = portmap.get(protocol) + if port: + eligible.append(serialize_server(host, port, protocol)) + return eligible + + +def pick_random_server(hostmap = None, protocol = 's', exclude_set = set()): + if hostmap is None: + hostmap = constants.net.DEFAULT_SERVERS + eligible = list(set(filter_protocol(hostmap, protocol)) - exclude_set) + return random.choice(eligible) if eligible else None + + +from .simple_config import SimpleConfig + +proxy_modes = ['socks4', 'socks5', 'http'] + + +def serialize_proxy(p): + if not isinstance(p, dict): + return None + return ':'.join([p.get('mode'), p.get('host'), p.get('port'), + p.get('user', ''), p.get('password', '')]) + + +def deserialize_proxy(s): + if not isinstance(s, str): + return None + if s.lower() == 'none': + return None + proxy = { "mode":"socks5", "host":"localhost" } + args = s.split(':') + n = 0 + if proxy_modes.count(args[n]) == 1: + proxy["mode"] = args[n] + n += 1 + if len(args) > n: + proxy["host"] = args[n] + n += 1 + if len(args) > n: + proxy["port"] = args[n] + n += 1 + else: + proxy["port"] = "8080" if proxy["mode"] == "http" else "1080" + if len(args) > n: + proxy["user"] = args[n] + n += 1 + if len(args) > n: + proxy["password"] = args[n] + return proxy + + +def deserialize_server(server_str): + host, port, protocol = str(server_str).rsplit(':', 2) + if protocol not in 'st': + raise ValueError('invalid network protocol: {}'.format(protocol)) + int(port) # Throw if cannot be converted to int + return host, port, protocol + + +def serialize_server(host, port, protocol): + return str(':'.join([host, port, protocol])) + + +class Network(util.DaemonThread): + """The Network class manages a set of connections to remote electrum + servers, each connected socket is handled by an Interface() object. + Connections are initiated by a Connection() thread which stops once + the connection succeeds or fails. + + Our external API: + + - Member functions get_header(), get_interfaces(), get_local_height(), + get_parameters(), get_server_height(), get_status_value(), + is_connected(), set_parameters(), stop() + """ + + def __init__(self, config=None): + if config is None: + config = {} # Do not use mutables as default values! + util.DaemonThread.__init__(self) + self.config = SimpleConfig(config) if isinstance(config, dict) else config + self.num_server = 10 if not self.config.get('oneserver') else 0 + self.blockchains = blockchain.read_blockchains(self.config) # note: needs self.blockchains_lock + self.print_error("blockchains", self.blockchains.keys()) + self.blockchain_index = config.get('blockchain_index', 0) + if self.blockchain_index not in self.blockchains.keys(): + self.blockchain_index = 0 + # Server for addresses and transactions + self.default_server = self.config.get('server', None) + # Sanitize default server + if self.default_server: + try: + deserialize_server(self.default_server) + except: + self.print_error('Warning: failed to parse server-string; falling back to random.') + self.default_server = None + if not self.default_server: + self.default_server = pick_random_server() + + # locks: if you need to take multiple ones, acquire them in the order they are defined here! + self.interface_lock = threading.RLock() # <- re-entrant + self.callback_lock = threading.Lock() + self.pending_sends_lock = threading.Lock() + self.recent_servers_lock = threading.RLock() # <- re-entrant + self.subscribed_addresses_lock = threading.Lock() + self.blockchains_lock = threading.Lock() + + self.pending_sends = [] + self.message_id = 0 + self.debug = False + self.irc_servers = {} # returned by interface (list from irc) + self.recent_servers = self.read_recent_servers() # note: needs self.recent_servers_lock + + self.banner = '' + self.donation_address = '' + self.relay_fee = None + # callbacks passed with subscriptions + self.subscriptions = defaultdict(list) # note: needs self.callback_lock + self.sub_cache = {} # note: needs self.interface_lock + # callbacks set by the GUI + self.callbacks = defaultdict(list) # note: needs self.callback_lock + + dir_path = os.path.join(self.config.path, 'certs') + util.make_dir(dir_path) + + # subscriptions and requests + self.subscribed_addresses = set() # note: needs self.subscribed_addresses_lock + self.h2addr = {} + # Requests from client we've not seen a response to + self.unanswered_requests = {} + # retry times + self.server_retry_time = time.time() + self.nodes_retry_time = time.time() + # kick off the network. interface is the main server we are currently + # communicating with. interfaces is the set of servers we are connecting + # to or have an ongoing connection with + self.interface = None # note: needs self.interface_lock + self.interfaces = {} # note: needs self.interface_lock + self.auto_connect = self.config.get('auto_connect', True) + self.connecting = set() + self.requested_chunks = set() + self.socket_queue = queue.Queue() + self.start_network(deserialize_server(self.default_server)[2], + deserialize_proxy(self.config.get('proxy'))) + + def with_interface_lock(func): + def func_wrapper(self, *args, **kwargs): + with self.interface_lock: + return func(self, *args, **kwargs) + return func_wrapper + + def with_recent_servers_lock(func): + def func_wrapper(self, *args, **kwargs): + with self.recent_servers_lock: + return func(self, *args, **kwargs) + return func_wrapper + + def register_callback(self, callback, events): + with self.callback_lock: + for event in events: + self.callbacks[event].append(callback) + + def unregister_callback(self, callback): + with self.callback_lock: + for callbacks in self.callbacks.values(): + if callback in callbacks: + callbacks.remove(callback) + + def trigger_callback(self, event, *args): + with self.callback_lock: + callbacks = self.callbacks[event][:] + [callback(event, *args) for callback in callbacks] + + def read_recent_servers(self): + if not self.config.path: + return [] + path = os.path.join(self.config.path, "recent_servers") + try: + with open(path, "r", encoding='utf-8') as f: + data = f.read() + return json.loads(data) + except: + return [] + + @with_recent_servers_lock + def save_recent_servers(self): + if not self.config.path: + return + path = os.path.join(self.config.path, "recent_servers") + s = json.dumps(self.recent_servers, indent=4, sort_keys=True) + try: + with open(path, "w", encoding='utf-8') as f: + f.write(s) + except: + pass + + @with_interface_lock + def get_server_height(self): + return self.interface.tip if self.interface else 0 + + def server_is_lagging(self): + sh = self.get_server_height() + if not sh: + self.print_error('no height for main interface') + return True + lh = self.get_local_height() + result = (lh - sh) > 1 + if result: + self.print_error('%s is lagging (%d vs %d)' % (self.default_server, sh, lh)) + return result + + def set_status(self, status): + self.connection_status = status + self.notify('status') + + def is_connected(self): + return self.interface is not None + + def is_connecting(self): + return self.connection_status == 'connecting' + + @with_interface_lock + def queue_request(self, method, params, interface=None): + # If you want to queue a request on any interface it must go + # through this function so message ids are properly tracked + if interface is None: + interface = self.interface + if interface is None: + self.print_error('warning: dropping request', method, params) + return + message_id = self.message_id + self.message_id += 1 + if self.debug: + self.print_error(interface.host, "-->", method, params, message_id) + interface.queue_request(method, params, message_id) + return message_id + + @with_interface_lock + def send_subscriptions(self): + assert self.interface + self.print_error('sending subscriptions to', self.interface.server, len(self.unanswered_requests), len(self.subscribed_addresses)) + self.sub_cache.clear() + # Resend unanswered requests + requests = self.unanswered_requests.values() + self.unanswered_requests = {} + for request in requests: + message_id = self.queue_request(request[0], request[1]) + self.unanswered_requests[message_id] = request + self.queue_request('server.banner', []) + self.queue_request('server.donation_address', []) + self.queue_request('server.peers.subscribe', []) + self.request_fee_estimates() + self.queue_request('blockchain.relayfee', []) + with self.subscribed_addresses_lock: + for h in self.subscribed_addresses: + self.queue_request('blockchain.scripthash.subscribe', [h]) + + def request_fee_estimates(self): + from .simple_config import FEE_ETA_TARGETS + self.config.requested_fee_estimates() + self.queue_request('mempool.get_fee_histogram', []) + for i in FEE_ETA_TARGETS: + self.queue_request('blockchain.estimatefee', [i]) + + def get_status_value(self, key): + if key == 'status': + value = self.connection_status + elif key == 'banner': + value = self.banner + elif key == 'fee': + value = self.config.fee_estimates + elif key == 'fee_histogram': + value = self.config.mempool_fees + elif key == 'updated': + value = (self.get_local_height(), self.get_server_height()) + elif key == 'servers': + value = self.get_servers() + elif key == 'interfaces': + value = self.get_interfaces() + return value + + def notify(self, key): + if key in ['status', 'updated']: + self.trigger_callback(key) + else: + self.trigger_callback(key, self.get_status_value(key)) + + def get_parameters(self): + host, port, protocol = deserialize_server(self.default_server) + return host, port, protocol, self.proxy, self.auto_connect + + def get_donation_address(self): + if self.is_connected(): + return self.donation_address + + @with_interface_lock + def get_interfaces(self): + '''The interfaces that are in connected state''' + return list(self.interfaces.keys()) + + @with_recent_servers_lock + def get_servers(self): + out = constants.net.DEFAULT_SERVERS + if self.irc_servers: + out.update(filter_version(self.irc_servers.copy())) + else: + for s in self.recent_servers: + try: + host, port, protocol = deserialize_server(s) + except: + continue + if host not in out: + out[host] = {protocol: port} + return out + + @with_interface_lock + def start_interface(self, server): + if (not server in self.interfaces and not server in self.connecting): + if server == self.default_server: + self.print_error("connecting to %s as new interface" % server) + self.set_status('connecting') + self.connecting.add(server) + Connection(server, self.socket_queue, self.config.path) + + def start_random_interface(self): + with self.interface_lock: + exclude_set = self.disconnected_servers.union(set(self.interfaces)) + server = pick_random_server(self.get_servers(), self.protocol, exclude_set) + if server: + self.start_interface(server) + + def start_interfaces(self): + self.start_interface(self.default_server) + for i in range(self.num_server - 1): + self.start_random_interface() + + def set_proxy(self, proxy): + self.proxy = proxy + # Store these somewhere so we can un-monkey-patch + if not hasattr(socket, "_socketobject"): + socket._socketobject = socket.socket + socket._getaddrinfo = socket.getaddrinfo + if proxy: + self.print_error('setting proxy', proxy) + proxy_mode = proxy_modes.index(proxy["mode"]) + 1 + socks.setdefaultproxy(proxy_mode, + proxy["host"], + int(proxy["port"]), + # socks.py seems to want either None or a non-empty string + username=(proxy.get("user", "") or None), + password=(proxy.get("password", "") or None)) + socket.socket = socks.socksocket + # prevent dns leaks, see http://stackoverflow.com/questions/13184205/dns-over-proxy + socket.getaddrinfo = lambda *args: [(socket.AF_INET, socket.SOCK_STREAM, 6, '', (args[0], args[1]))] + else: + socket.socket = socket._socketobject + if sys.platform == 'win32': + # On Windows, socket.getaddrinfo takes a mutex, and might hold it for up to 10 seconds + # when dns-resolving. To speed it up drastically, we resolve dns ourselves, outside that lock. + # see #4421 + socket.getaddrinfo = self._fast_getaddrinfo + else: + socket.getaddrinfo = socket._getaddrinfo + + @staticmethod + def _fast_getaddrinfo(host, *args, **kwargs): + def needs_dns_resolving(host2): + try: + ipaddress.ip_address(host2) + return False # already valid IP + except ValueError: + pass # not an IP + if str(host) in ('localhost', 'localhost.',): + return False + return True + try: + if needs_dns_resolving(host): + answers = dns.resolver.query(host) + addr = str(answers[0]) + else: + addr = host + except dns.exception.DNSException: + # dns failed for some reason, e.g. dns.resolver.NXDOMAIN + # this is normal. Simply report back failure: + raise socket.gaierror(11001, 'getaddrinfo failed') + except BaseException as e: + # Possibly internal error in dnspython :( see #4483 + # Fall back to original socket.getaddrinfo to resolve dns. + print_error('dnspython failed to resolve dns with error:', e) + addr = host + return socket._getaddrinfo(addr, *args, **kwargs) + + @with_interface_lock + def start_network(self, protocol, proxy): + assert not self.interface and not self.interfaces + assert not self.connecting and self.socket_queue.empty() + self.print_error('starting network') + self.disconnected_servers = set([]) # note: needs self.interface_lock + self.protocol = protocol + self.set_proxy(proxy) + self.start_interfaces() + + @with_interface_lock + def stop_network(self): + self.print_error("stopping network") + for interface in list(self.interfaces.values()): + self.close_interface(interface) + if self.interface: + self.close_interface(self.interface) + assert self.interface is None + assert not self.interfaces + self.connecting = set() + # Get a new queue - no old pending connections thanks! + self.socket_queue = queue.Queue() + + def set_parameters(self, host, port, protocol, proxy, auto_connect): + proxy_str = serialize_proxy(proxy) + server = serialize_server(host, port, protocol) + # sanitize parameters + try: + deserialize_server(serialize_server(host, port, protocol)) + if proxy: + proxy_modes.index(proxy["mode"]) + 1 + int(proxy['port']) + except: + return + self.config.set_key('auto_connect', auto_connect, False) + self.config.set_key("proxy", proxy_str, False) + self.config.set_key("server", server, True) + # abort if changes were not allowed by config + if self.config.get('server') != server or self.config.get('proxy') != proxy_str: + return + self.auto_connect = auto_connect + if self.proxy != proxy or self.protocol != protocol: + # Restart the network defaulting to the given server + with self.interface_lock: + self.stop_network() + self.default_server = server + self.start_network(protocol, proxy) + elif self.default_server != server: + self.switch_to_interface(server) + else: + self.switch_lagging_interface() + self.notify('updated') + + def switch_to_random_interface(self): + '''Switch to a random connected server other than the current one''' + servers = self.get_interfaces() # Those in connected state + if self.default_server in servers: + servers.remove(self.default_server) + if servers: + self.switch_to_interface(random.choice(servers)) + + @with_interface_lock + def switch_lagging_interface(self): + '''If auto_connect and lagging, switch interface''' + if self.server_is_lagging() and self.auto_connect: + # switch to one that has the correct header (not height) + header = self.blockchain().read_header(self.get_local_height()) + filtered = list(map(lambda x: x[0], filter(lambda x: x[1].tip_header == header, self.interfaces.items()))) + if filtered: + choice = random.choice(filtered) + self.switch_to_interface(choice) + + @with_interface_lock + def switch_to_interface(self, server): + '''Switch to server as our interface. If no connection exists nor + being opened, start a thread to connect. The actual switch will + happen on receipt of the connection notification. Do nothing + if server already is our interface.''' + self.default_server = server + if server not in self.interfaces: + self.interface = None + self.start_interface(server) + return + + i = self.interfaces[server] + if self.interface != i: + self.print_error("switching to", server) + # stop any current interface in order to terminate subscriptions + # fixme: we don't want to close headers sub + #self.close_interface(self.interface) + self.interface = i + self.send_subscriptions() + self.set_status('connected') + self.notify('updated') + self.notify('interfaces') + + @with_interface_lock + def close_interface(self, interface): + if interface: + if interface.server in self.interfaces: + self.interfaces.pop(interface.server) + if interface.server == self.default_server: + self.interface = None + interface.close() + + @with_recent_servers_lock + def add_recent_server(self, server): + # list is ordered + if server in self.recent_servers: + self.recent_servers.remove(server) + self.recent_servers.insert(0, server) + self.recent_servers = self.recent_servers[0:20] + self.save_recent_servers() + + def process_response(self, interface, response, callbacks): + if self.debug: + self.print_error(interface.host, "<--", response) + error = response.get('error') + result = response.get('result') + method = response.get('method') + params = response.get('params') + + # We handle some responses; return the rest to the client. + if method == 'server.version': + interface.server_version = result + elif method == 'blockchain.headers.subscribe': + if error is None: + self.on_notify_header(interface, result) + else: + # no point in keeping this connection without headers sub + self.connection_down(interface.server) + return + elif method == 'server.peers.subscribe': + if error is None: + self.irc_servers = parse_servers(result) + self.notify('servers') + elif method == 'server.banner': + if error is None: + self.banner = result + self.notify('banner') + elif method == 'server.donation_address': + if error is None: + self.donation_address = result + elif method == 'mempool.get_fee_histogram': + if error is None: + self.print_error('fee_histogram', result) + self.config.mempool_fees = result + self.notify('fee_histogram') + elif method == 'blockchain.estimatefee': + if error is None and result > 0: + i = params[0] + fee = int(result*COIN) + self.config.update_fee_estimates(i, fee) + self.print_error("fee_estimates[%d]" % i, fee) + self.notify('fee') + elif method == 'blockchain.relayfee': + if error is None: + self.relay_fee = int(result * COIN) if result is not None else None + self.print_error("relayfee", self.relay_fee) + elif method == 'blockchain.block.headers': + self.on_block_headers(interface, response) + elif method == 'blockchain.block.get_header': + self.on_get_header(interface, response) + + for callback in callbacks: + callback(response) + + @classmethod + def get_index(cls, method, params): + """ hashable index for subscriptions and cache""" + return str(method) + (':' + str(params[0]) if params else '') + + def process_responses(self, interface): + responses = interface.get_responses() + for request, response in responses: + if request: + method, params, message_id = request + k = self.get_index(method, params) + # client requests go through self.send() with a + # callback, are only sent to the current interface, + # and are placed in the unanswered_requests dictionary + client_req = self.unanswered_requests.pop(message_id, None) + if client_req: + if interface != self.interface: + # we probably changed the current interface + # in the meantime; drop this. + return + callbacks = [client_req[2]] + else: + # fixme: will only work for subscriptions + k = self.get_index(method, params) + callbacks = list(self.subscriptions.get(k, [])) + + # Copy the request method and params to the response + response['method'] = method + response['params'] = params + # Only once we've received a response to an addr subscription + # add it to the list; avoids double-sends on reconnection + if method == 'blockchain.scripthash.subscribe': + with self.subscribed_addresses_lock: + self.subscribed_addresses.add(params[0]) + else: + if not response: # Closed remotely / misbehaving + self.connection_down(interface.server) + break + # Rewrite response shape to match subscription request response + method = response.get('method') + params = response.get('params') + k = self.get_index(method, params) + if method == 'blockchain.headers.subscribe': + response['result'] = params[0] + response['params'] = [] + elif method == 'blockchain.scripthash.subscribe': + response['params'] = [params[0]] # addr + response['result'] = params[1] + callbacks = list(self.subscriptions.get(k, [])) + + # update cache if it's a subscription + if method.endswith('.subscribe'): + with self.interface_lock: + self.sub_cache[k] = response + # Response is now in canonical form + self.process_response(interface, response, callbacks) + + def send(self, messages, callback): + '''Messages is a list of (method, params) tuples''' + messages = list(messages) + with self.pending_sends_lock: + self.pending_sends.append((messages, callback)) + + @with_interface_lock + def process_pending_sends(self): + # Requests needs connectivity. If we don't have an interface, + # we cannot process them. + if not self.interface: + return + + with self.pending_sends_lock: + sends = self.pending_sends + self.pending_sends = [] + + for messages, callback in sends: + for method, params in messages: + r = None + if method.endswith('.subscribe'): + k = self.get_index(method, params) + # add callback to list + l = list(self.subscriptions.get(k, [])) + if callback not in l: + l.append(callback) + with self.callback_lock: + self.subscriptions[k] = l + # check cached response for subscriptions + r = self.sub_cache.get(k) + + if r is not None: + self.print_error("cache hit", k) + callback(r) + else: + message_id = self.queue_request(method, params) + self.unanswered_requests[message_id] = method, params, callback + + def unsubscribe(self, callback): + '''Unsubscribe a callback to free object references to enable GC.''' + # Note: we can't unsubscribe from the server, so if we receive + # subsequent notifications process_response() will emit a harmless + # "received unexpected notification" warning + with self.callback_lock: + for v in self.subscriptions.values(): + if callback in v: + v.remove(callback) + + @with_interface_lock + def connection_down(self, server): + '''A connection to server either went down, or was never made. + We distinguish by whether it is in self.interfaces.''' + self.disconnected_servers.add(server) + if server == self.default_server: + self.set_status('disconnected') + if server in self.interfaces: + self.close_interface(self.interfaces[server]) + self.notify('interfaces') + with self.blockchains_lock: + for b in self.blockchains.values(): + if b.catch_up == server: + b.catch_up = None + + def new_interface(self, server, socket): + # todo: get tip first, then decide which checkpoint to use. + self.add_recent_server(server) + interface = Interface(server, socket) + interface.blockchain = None + interface.tip_header = None + interface.tip = 0 + interface.mode = 'default' + interface.request = None + with self.interface_lock: + self.interfaces[server] = interface + # server.version should be the first message + params = [ELECTRUM_VERSION, PROTOCOL_VERSION] + self.queue_request('server.version', params, interface) + self.queue_request('blockchain.headers.subscribe', [True], interface) + if server == self.default_server: + self.switch_to_interface(server) + #self.notify('interfaces') + + def maintain_sockets(self): + '''Socket maintenance.''' + # Responses to connection attempts? + while not self.socket_queue.empty(): + server, socket = self.socket_queue.get() + if server in self.connecting: + self.connecting.remove(server) + + if socket: + self.new_interface(server, socket) + else: + self.connection_down(server) + + # Send pings and shut down stale interfaces + # must use copy of values + with self.interface_lock: + interfaces = list(self.interfaces.values()) + for interface in interfaces: + if interface.has_timed_out(): + self.connection_down(interface.server) + elif interface.ping_required(): + self.queue_request('server.ping', [], interface) + + now = time.time() + # nodes + with self.interface_lock: + if len(self.interfaces) + len(self.connecting) < self.num_server: + self.start_random_interface() + if now - self.nodes_retry_time > NODES_RETRY_INTERVAL: + self.print_error('network: retrying connections') + self.disconnected_servers = set([]) + self.nodes_retry_time = now + + # main interface + with self.interface_lock: + if not self.is_connected(): + if self.auto_connect: + if not self.is_connecting(): + self.switch_to_random_interface() + else: + if self.default_server in self.disconnected_servers: + if now - self.server_retry_time > SERVER_RETRY_INTERVAL: + self.disconnected_servers.remove(self.default_server) + self.server_retry_time = now + else: + self.switch_to_interface(self.default_server) + else: + if self.config.is_fee_estimates_update_required(): + self.request_fee_estimates() + + def request_chunk(self, interface, index): + if index in self.requested_chunks: + return + interface.print_error("requesting chunk %d" % index) + self.requested_chunks.add(index) + height = index * 2016 + self.queue_request('blockchain.block.headers', [height, 2016], + interface) + + def on_block_headers(self, interface, response): + '''Handle receiving a chunk of block headers''' + error = response.get('error') + result = response.get('result') + params = response.get('params') + blockchain = interface.blockchain + if result is None or params is None or error is not None: + interface.print_error(error or 'bad response') + return + # Ignore unsolicited chunks + height = params[0] + index = height // 2016 + if index * 2016 != height or index not in self.requested_chunks: + interface.print_error("received chunk %d (unsolicited)" % index) + return + else: + interface.print_error("received chunk %d" % index) + self.requested_chunks.remove(index) + hexdata = result['hex'] + connect = blockchain.connect_chunk(index, hexdata) + if not connect: + self.connection_down(interface.server) + return + # If not finished, get the next chunk + if index >= len(blockchain.checkpoints) and blockchain.height() < interface.tip: + self.request_chunk(interface, index+1) + else: + interface.mode = 'default' + interface.print_error('catch up done', blockchain.height()) + blockchain.catch_up = None + self.notify('updated') + + def on_get_header(self, interface, response): + '''Handle receiving a single block header''' + header = response.get('result') + if not header: + interface.print_error(response) + self.connection_down(interface.server) + return + height = header.get('block_height') + if interface.request != height: + interface.print_error("unsolicited header",interface.request, height) + self.connection_down(interface.server) + return + chain = blockchain.check_header(header) + if interface.mode == 'backward': + can_connect = blockchain.can_connect(header) + if can_connect and can_connect.catch_up is None: + interface.mode = 'catch_up' + interface.blockchain = can_connect + interface.blockchain.save_header(header) + next_height = height + 1 + interface.blockchain.catch_up = interface.server + elif chain: + interface.print_error("binary search") + interface.mode = 'binary' + interface.blockchain = chain + interface.good = height + next_height = (interface.bad + interface.good) // 2 + assert next_height >= self.max_checkpoint(), (interface.bad, interface.good) + else: + if height == 0: + self.connection_down(interface.server) + next_height = None + else: + interface.bad = height + interface.bad_header = header + delta = interface.tip - height + next_height = max(self.max_checkpoint(), interface.tip - 2 * delta) + if height == next_height: + self.connection_down(interface.server) + next_height = None + + elif interface.mode == 'binary': + if chain: + interface.good = height + interface.blockchain = chain + else: + interface.bad = height + interface.bad_header = header + if interface.bad != interface.good + 1: + next_height = (interface.bad + interface.good) // 2 + assert next_height >= self.max_checkpoint() + elif not interface.blockchain.can_connect(interface.bad_header, check_height=False): + self.connection_down(interface.server) + next_height = None + else: + branch = self.blockchains.get(interface.bad) + if branch is not None: + if branch.check_header(interface.bad_header): + interface.print_error('joining chain', interface.bad) + next_height = None + elif branch.parent().check_header(header): + interface.print_error('reorg', interface.bad, interface.tip) + interface.blockchain = branch.parent() + next_height = None + else: + interface.print_error('checkpoint conflicts with existing fork', branch.path()) + branch.write(b'', 0) + branch.save_header(interface.bad_header) + interface.mode = 'catch_up' + interface.blockchain = branch + next_height = interface.bad + 1 + interface.blockchain.catch_up = interface.server + else: + bh = interface.blockchain.height() + next_height = None + if bh > interface.good: + if not interface.blockchain.check_header(interface.bad_header): + b = interface.blockchain.fork(interface.bad_header) + with self.blockchains_lock: + self.blockchains[interface.bad] = b + interface.blockchain = b + interface.print_error("new chain", b.checkpoint) + interface.mode = 'catch_up' + next_height = interface.bad + 1 + interface.blockchain.catch_up = interface.server + else: + assert bh == interface.good + if interface.blockchain.catch_up is None and bh < interface.tip: + interface.print_error("catching up from %d"% (bh + 1)) + interface.mode = 'catch_up' + next_height = bh + 1 + interface.blockchain.catch_up = interface.server + + self.notify('updated') + + elif interface.mode == 'catch_up': + can_connect = interface.blockchain.can_connect(header) + if can_connect: + interface.blockchain.save_header(header) + next_height = height + 1 if height < interface.tip else None + else: + # go back + interface.print_error("cannot connect", height) + interface.mode = 'backward' + interface.bad = height + interface.bad_header = header + next_height = height - 1 + + if next_height is None: + # exit catch_up state + interface.print_error('catch up done', interface.blockchain.height()) + interface.blockchain.catch_up = None + self.switch_lagging_interface() + self.notify('updated') + + else: + raise Exception(interface.mode) + # If not finished, get the next header + if next_height is not None: + if interface.mode == 'catch_up' and interface.tip > next_height + 50: + self.request_chunk(interface, next_height // 2016) + else: + self.request_header(interface, next_height) + else: + interface.mode = 'default' + interface.request = None + self.notify('updated') + + # refresh network dialog + self.notify('interfaces') + + def maintain_requests(self): + with self.interface_lock: + interfaces = list(self.interfaces.values()) + for interface in interfaces: + if interface.request and time.time() - interface.request_time > 20: + interface.print_error("blockchain request timed out") + self.connection_down(interface.server) + continue + + def wait_on_sockets(self): + # Python docs say Windows doesn't like empty selects. + # Sleep to prevent busy looping + if not self.interfaces: + time.sleep(0.1) + return + with self.interface_lock: + interfaces = list(self.interfaces.values()) + rin = [i for i in interfaces] + win = [i for i in interfaces if i.num_requests()] + try: + rout, wout, xout = select.select(rin, win, [], 0.1) + except socket.error as e: + if e.errno == errno.EINTR: + return + raise + assert not xout + for interface in wout: + interface.send_requests() + for interface in rout: + self.process_responses(interface) + + def init_headers_file(self): + b = self.blockchains[0] + filename = b.path() + length = 80 * len(constants.net.CHECKPOINTS) * 2016 + if not os.path.exists(filename) or os.path.getsize(filename) < length: + with open(filename, 'wb') as f: + if length>0: + f.seek(length-1) + f.write(b'\x00') + with b.lock: + b.update_size() + + def run(self): + self.init_headers_file() + while self.is_running(): + self.maintain_sockets() + self.wait_on_sockets() + self.maintain_requests() + self.run_jobs() # Synchronizer and Verifier + self.process_pending_sends() + self.stop_network() + self.on_stop() + + def on_notify_header(self, interface, header_dict): + try: + header_hex, height = header_dict['hex'], header_dict['height'] + except KeyError: + # no point in keeping this connection without headers sub + self.connection_down(interface.server) + return + header = blockchain.deserialize_header(util.bfh(header_hex), height) + if height < self.max_checkpoint(): + self.connection_down(interface.server) + return + interface.tip_header = header + interface.tip = height + if interface.mode != 'default': + return + b = blockchain.check_header(header) + if b: + interface.blockchain = b + self.switch_lagging_interface() + self.notify('updated') + self.notify('interfaces') + return + b = blockchain.can_connect(header) + if b: + interface.blockchain = b + b.save_header(header) + self.switch_lagging_interface() + self.notify('updated') + self.notify('interfaces') + return + with self.blockchains_lock: + tip = max([x.height() for x in self.blockchains.values()]) + if tip >=0: + interface.mode = 'backward' + interface.bad = height + interface.bad_header = header + self.request_header(interface, min(tip +1, height - 1)) + else: + chain = self.blockchains[0] + if chain.catch_up is None: + chain.catch_up = interface + interface.mode = 'catch_up' + interface.blockchain = chain + with self.blockchains_lock: + self.print_error("switching to catchup mode", tip, self.blockchains) + self.request_header(interface, 0) + else: + self.print_error("chain already catching up with", chain.catch_up.server) + + @with_interface_lock + def blockchain(self): + if self.interface and self.interface.blockchain is not None: + self.blockchain_index = self.interface.blockchain.checkpoint + return self.blockchains[self.blockchain_index] + + @with_interface_lock + def get_blockchains(self): + out = {} + with self.blockchains_lock: + blockchain_items = list(self.blockchains.items()) + for k, b in blockchain_items: + r = list(filter(lambda i: i.blockchain==b, list(self.interfaces.values()))) + if r: + out[k] = r + return out + + def follow_chain(self, index): + blockchain = self.blockchains.get(index) + if blockchain: + self.blockchain_index = index + self.config.set_key('blockchain_index', index) + with self.interface_lock: + interfaces = list(self.interfaces.values()) + for i in interfaces: + if i.blockchain == blockchain: + self.switch_to_interface(i.server) + break + else: + raise Exception('blockchain not found', index) + + with self.interface_lock: + if self.interface: + server = self.interface.server + host, port, protocol, proxy, auto_connect = self.get_parameters() + host, port, protocol = server.split(':') + self.set_parameters(host, port, protocol, proxy, auto_connect) + + def get_local_height(self): + return self.blockchain().height() + + @staticmethod + def __wait_for(it): + """Wait for the result of calling lambda `it`.""" + q = queue.Queue() + it(q.put) + try: + result = q.get(block=True, timeout=30) + except queue.Empty: + raise util.TimeoutException(_('Server did not answer')) + + if result.get('error'): + raise Exception(result.get('error')) + + return result.get('result') + + @staticmethod + def __with_default_synchronous_callback(invocation, callback): + """ Use this method if you want to make the network request + synchronous. """ + if not callback: + return Network.__wait_for(invocation) + + invocation(callback) + + def request_header(self, interface, height): + self.queue_request('blockchain.block.get_header', [height], interface) + interface.request = height + interface.req_time = time.time() + + def map_scripthash_to_address(self, callback): + def cb2(x): + x2 = x.copy() + p = x2.pop('params') + addr = self.h2addr[p[0]] + x2['params'] = [addr] + callback(x2) + return cb2 + + def subscribe_to_addresses(self, addresses, callback): + hash2address = { + bitcoin.address_to_scripthash(address): address + for address in addresses} + self.h2addr.update(hash2address) + msgs = [ + ('blockchain.scripthash.subscribe', [x]) + for x in hash2address.keys()] + self.send(msgs, self.map_scripthash_to_address(callback)) + + def request_address_history(self, address, callback): + h = bitcoin.address_to_scripthash(address) + self.h2addr.update({h: address}) + self.send([('blockchain.scripthash.get_history', [h])], self.map_scripthash_to_address(callback)) + + # NOTE this method handles exceptions and a special edge case, counter to + # what the other ElectrumX methods do. This is unexpected. + def broadcast_transaction(self, transaction, callback=None): + command = 'blockchain.transaction.broadcast' + invocation = lambda c: self.send([(command, [str(transaction)])], c) + + if callback: + invocation(callback) + return + + try: + out = Network.__wait_for(invocation) + except BaseException as e: + return False, "error: " + str(e) + + if out != transaction.txid(): + return False, "error: " + out + + return True, out + + def get_history_for_scripthash(self, hash, callback=None): + command = 'blockchain.scripthash.get_history' + invocation = lambda c: self.send([(command, [hash])], c) + + return Network.__with_default_synchronous_callback(invocation, callback) + + def subscribe_to_headers(self, callback=None): + command = 'blockchain.headers.subscribe' + invocation = lambda c: self.send([(command, [True])], c) + + return Network.__with_default_synchronous_callback(invocation, callback) + + def subscribe_to_address(self, address, callback=None): + command = 'blockchain.address.subscribe' + invocation = lambda c: self.send([(command, [address])], c) + + return Network.__with_default_synchronous_callback(invocation, callback) + + def get_merkle_for_transaction(self, tx_hash, tx_height, callback=None): + command = 'blockchain.transaction.get_merkle' + invocation = lambda c: self.send([(command, [tx_hash, tx_height])], c) + + return Network.__with_default_synchronous_callback(invocation, callback) + + def subscribe_to_scripthash(self, scripthash, callback=None): + command = 'blockchain.scripthash.subscribe' + invocation = lambda c: self.send([(command, [scripthash])], c) + + return Network.__with_default_synchronous_callback(invocation, callback) + + def get_transaction(self, transaction_hash, callback=None): + command = 'blockchain.transaction.get' + invocation = lambda c: self.send([(command, [transaction_hash])], c) + + return Network.__with_default_synchronous_callback(invocation, callback) + + def get_transactions(self, transaction_hashes, callback=None): + command = 'blockchain.transaction.get' + messages = [(command, [tx_hash]) for tx_hash in transaction_hashes] + invocation = lambda c: self.send(messages, c) + + return Network.__with_default_synchronous_callback(invocation, callback) + + def listunspent_for_scripthash(self, scripthash, callback=None): + command = 'blockchain.scripthash.listunspent' + invocation = lambda c: self.send([(command, [scripthash])], c) + + return Network.__with_default_synchronous_callback(invocation, callback) + + def get_balance_for_scripthash(self, scripthash, callback=None): + command = 'blockchain.scripthash.get_balance' + invocation = lambda c: self.send([(command, [scripthash])], c) + + return Network.__with_default_synchronous_callback(invocation, callback) + + def export_checkpoints(self, path): + # run manually from the console to generate checkpoints + cp = self.blockchain().get_checkpoints() + with open(path, 'w', encoding='utf-8') as f: + f.write(json.dumps(cp, indent=4)) + + @classmethod + def max_checkpoint(cls): + return max(0, len(constants.net.CHECKPOINTS) * 2016 - 1) DIR diff --git a/electrum/old_mnemonic.py b/electrum/old_mnemonic.py t@@ -0,0 +1,1697 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2011 thomasv@gitorious +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +# list of words from http://en.wiktionary.org/wiki/Wiktionary:Frequency_lists/Contemporary_poetry + +words = [ +"like", +"just", +"love", +"know", +"never", +"want", +"time", +"out", +"there", +"make", +"look", +"eye", +"down", +"only", +"think", +"heart", +"back", +"then", +"into", +"about", +"more", +"away", +"still", +"them", +"take", +"thing", +"even", +"through", +"long", +"always", +"world", +"too", +"friend", +"tell", +"try", +"hand", +"thought", +"over", +"here", +"other", +"need", +"smile", +"again", +"much", +"cry", +"been", +"night", +"ever", +"little", +"said", +"end", +"some", +"those", +"around", +"mind", +"people", +"girl", +"leave", +"dream", +"left", +"turn", +"myself", +"give", +"nothing", +"really", +"off", +"before", +"something", +"find", +"walk", +"wish", +"good", +"once", +"place", +"ask", +"stop", +"keep", +"watch", +"seem", +"everything", +"wait", +"got", +"yet", +"made", +"remember", +"start", +"alone", +"run", +"hope", +"maybe", +"believe", +"body", +"hate", +"after", +"close", +"talk", +"stand", +"own", +"each", +"hurt", +"help", +"home", +"god", +"soul", +"new", +"many", +"two", +"inside", +"should", +"true", +"first", +"fear", +"mean", +"better", +"play", +"another", +"gone", +"change", +"use", +"wonder", +"someone", +"hair", +"cold", +"open", +"best", +"any", +"behind", +"happen", +"water", +"dark", +"laugh", +"stay", +"forever", +"name", +"work", +"show", +"sky", +"break", +"came", +"deep", +"door", +"put", +"black", +"together", +"upon", +"happy", +"such", +"great", +"white", +"matter", +"fill", +"past", +"please", +"burn", +"cause", +"enough", +"touch", +"moment", +"soon", +"voice", +"scream", +"anything", +"stare", +"sound", +"red", +"everyone", +"hide", +"kiss", +"truth", +"death", +"beautiful", +"mine", +"blood", +"broken", +"very", +"pass", +"next", +"forget", +"tree", +"wrong", +"air", +"mother", +"understand", +"lip", +"hit", +"wall", +"memory", +"sleep", +"free", +"high", +"realize", +"school", +"might", +"skin", +"sweet", +"perfect", +"blue", +"kill", +"breath", +"dance", +"against", +"fly", +"between", +"grow", +"strong", +"under", +"listen", +"bring", +"sometimes", +"speak", +"pull", +"person", +"become", +"family", +"begin", +"ground", +"real", +"small", +"father", +"sure", +"feet", +"rest", +"young", +"finally", +"land", +"across", +"today", +"different", +"guy", +"line", +"fire", +"reason", +"reach", +"second", +"slowly", +"write", +"eat", +"smell", +"mouth", +"step", +"learn", +"three", +"floor", +"promise", +"breathe", +"darkness", +"push", +"earth", +"guess", +"save", +"song", +"above", +"along", +"both", +"color", +"house", +"almost", +"sorry", +"anymore", +"brother", +"okay", +"dear", +"game", +"fade", +"already", +"apart", +"warm", +"beauty", +"heard", +"notice", +"question", +"shine", +"began", +"piece", +"whole", +"shadow", +"secret", +"street", +"within", +"finger", +"point", +"morning", +"whisper", +"child", +"moon", +"green", +"story", +"glass", +"kid", +"silence", +"since", +"soft", +"yourself", +"empty", +"shall", +"angel", +"answer", +"baby", +"bright", +"dad", +"path", +"worry", +"hour", +"drop", +"follow", +"power", +"war", +"half", +"flow", +"heaven", +"act", +"chance", +"fact", +"least", +"tired", +"children", +"near", +"quite", +"afraid", +"rise", +"sea", +"taste", +"window", +"cover", +"nice", +"trust", +"lot", +"sad", +"cool", +"force", +"peace", +"return", +"blind", +"easy", +"ready", +"roll", +"rose", +"drive", +"held", +"music", +"beneath", +"hang", +"mom", +"paint", +"emotion", +"quiet", +"clear", +"cloud", +"few", +"pretty", +"bird", +"outside", +"paper", +"picture", +"front", +"rock", +"simple", +"anyone", +"meant", +"reality", +"road", +"sense", +"waste", +"bit", +"leaf", +"thank", +"happiness", +"meet", +"men", +"smoke", +"truly", +"decide", +"self", +"age", +"book", +"form", +"alive", +"carry", +"escape", +"damn", +"instead", +"able", +"ice", +"minute", +"throw", +"catch", +"leg", +"ring", +"course", +"goodbye", +"lead", +"poem", +"sick", +"corner", +"desire", +"known", +"problem", +"remind", +"shoulder", +"suppose", +"toward", +"wave", +"drink", +"jump", +"woman", +"pretend", +"sister", +"week", +"human", +"joy", +"crack", +"grey", +"pray", +"surprise", +"dry", +"knee", +"less", +"search", +"bleed", +"caught", +"clean", +"embrace", +"future", +"king", +"son", +"sorrow", +"chest", +"hug", +"remain", +"sat", +"worth", +"blow", +"daddy", +"final", +"parent", +"tight", +"also", +"create", +"lonely", +"safe", +"cross", +"dress", +"evil", +"silent", +"bone", +"fate", +"perhaps", +"anger", +"class", +"scar", +"snow", +"tiny", +"tonight", +"continue", +"control", +"dog", +"edge", +"mirror", +"month", +"suddenly", +"comfort", +"given", +"loud", +"quickly", +"gaze", +"plan", +"rush", +"stone", +"town", +"battle", +"ignore", +"spirit", +"stood", +"stupid", +"yours", +"brown", +"build", +"dust", +"hey", +"kept", +"pay", +"phone", +"twist", +"although", +"ball", +"beyond", +"hidden", +"nose", +"taken", +"fail", +"float", +"pure", +"somehow", +"wash", +"wrap", +"angry", +"cheek", +"creature", +"forgotten", +"heat", +"rip", +"single", +"space", +"special", +"weak", +"whatever", +"yell", +"anyway", +"blame", +"job", +"choose", +"country", +"curse", +"drift", +"echo", +"figure", +"grew", +"laughter", +"neck", +"suffer", +"worse", +"yeah", +"disappear", +"foot", +"forward", +"knife", +"mess", +"somewhere", +"stomach", +"storm", +"beg", +"idea", +"lift", +"offer", +"breeze", +"field", +"five", +"often", +"simply", +"stuck", +"win", +"allow", +"confuse", +"enjoy", +"except", +"flower", +"seek", +"strength", +"calm", +"grin", +"gun", +"heavy", +"hill", +"large", +"ocean", +"shoe", +"sigh", +"straight", +"summer", +"tongue", +"accept", +"crazy", +"everyday", +"exist", +"grass", +"mistake", +"sent", +"shut", +"surround", +"table", +"ache", +"brain", +"destroy", +"heal", +"nature", +"shout", +"sign", +"stain", +"choice", +"doubt", +"glance", +"glow", +"mountain", +"queen", +"stranger", +"throat", +"tomorrow", +"city", +"either", +"fish", +"flame", +"rather", +"shape", +"spin", +"spread", +"ash", +"distance", +"finish", +"image", +"imagine", +"important", +"nobody", +"shatter", +"warmth", +"became", +"feed", +"flesh", +"funny", +"lust", +"shirt", +"trouble", +"yellow", +"attention", +"bare", +"bite", +"money", +"protect", +"amaze", +"appear", +"born", +"choke", +"completely", +"daughter", +"fresh", +"friendship", +"gentle", +"probably", +"six", +"deserve", +"expect", +"grab", +"middle", +"nightmare", +"river", +"thousand", +"weight", +"worst", +"wound", +"barely", +"bottle", +"cream", +"regret", +"relationship", +"stick", +"test", +"crush", +"endless", +"fault", +"itself", +"rule", +"spill", +"art", +"circle", +"join", +"kick", +"mask", +"master", +"passion", +"quick", +"raise", +"smooth", +"unless", +"wander", +"actually", +"broke", +"chair", +"deal", +"favorite", +"gift", +"note", +"number", +"sweat", +"box", +"chill", +"clothes", +"lady", +"mark", +"park", +"poor", +"sadness", +"tie", +"animal", +"belong", +"brush", +"consume", +"dawn", +"forest", +"innocent", +"pen", +"pride", +"stream", +"thick", +"clay", +"complete", +"count", +"draw", +"faith", +"press", +"silver", +"struggle", +"surface", +"taught", +"teach", +"wet", +"bless", +"chase", +"climb", +"enter", +"letter", +"melt", +"metal", +"movie", +"stretch", +"swing", +"vision", +"wife", +"beside", +"crash", +"forgot", +"guide", +"haunt", +"joke", +"knock", +"plant", +"pour", +"prove", +"reveal", +"steal", +"stuff", +"trip", +"wood", +"wrist", +"bother", +"bottom", +"crawl", +"crowd", +"fix", +"forgive", +"frown", +"grace", +"loose", +"lucky", +"party", +"release", +"surely", +"survive", +"teacher", +"gently", +"grip", +"speed", +"suicide", +"travel", +"treat", +"vein", +"written", +"cage", +"chain", +"conversation", +"date", +"enemy", +"however", +"interest", +"million", +"page", +"pink", +"proud", +"sway", +"themselves", +"winter", +"church", +"cruel", +"cup", +"demon", +"experience", +"freedom", +"pair", +"pop", +"purpose", +"respect", +"shoot", +"softly", +"state", +"strange", +"bar", +"birth", +"curl", +"dirt", +"excuse", +"lord", +"lovely", +"monster", +"order", +"pack", +"pants", +"pool", +"scene", +"seven", +"shame", +"slide", +"ugly", +"among", +"blade", +"blonde", +"closet", +"creek", +"deny", +"drug", +"eternity", +"gain", +"grade", +"handle", +"key", +"linger", +"pale", +"prepare", +"swallow", +"swim", +"tremble", +"wheel", +"won", +"cast", +"cigarette", +"claim", +"college", +"direction", +"dirty", +"gather", +"ghost", +"hundred", +"loss", +"lung", +"orange", +"present", +"swear", +"swirl", +"twice", +"wild", +"bitter", +"blanket", +"doctor", +"everywhere", +"flash", +"grown", +"knowledge", +"numb", +"pressure", +"radio", +"repeat", +"ruin", +"spend", +"unknown", +"buy", +"clock", +"devil", +"early", +"false", +"fantasy", +"pound", +"precious", +"refuse", +"sheet", +"teeth", +"welcome", +"add", +"ahead", +"block", +"bury", +"caress", +"content", +"depth", +"despite", +"distant", +"marry", +"purple", +"threw", +"whenever", +"bomb", +"dull", +"easily", +"grasp", +"hospital", +"innocence", +"normal", +"receive", +"reply", +"rhyme", +"shade", +"someday", +"sword", +"toe", +"visit", +"asleep", +"bought", +"center", +"consider", +"flat", +"hero", +"history", +"ink", +"insane", +"muscle", +"mystery", +"pocket", +"reflection", +"shove", +"silently", +"smart", +"soldier", +"spot", +"stress", +"train", +"type", +"view", +"whether", +"bus", +"energy", +"explain", +"holy", +"hunger", +"inch", +"magic", +"mix", +"noise", +"nowhere", +"prayer", +"presence", +"shock", +"snap", +"spider", +"study", +"thunder", +"trail", +"admit", +"agree", +"bag", +"bang", +"bound", +"butterfly", +"cute", +"exactly", +"explode", +"familiar", +"fold", +"further", +"pierce", +"reflect", +"scent", +"selfish", +"sharp", +"sink", +"spring", +"stumble", +"universe", +"weep", +"women", +"wonderful", +"action", +"ancient", +"attempt", +"avoid", +"birthday", +"branch", +"chocolate", +"core", +"depress", +"drunk", +"especially", +"focus", +"fruit", +"honest", +"match", +"palm", +"perfectly", +"pillow", +"pity", +"poison", +"roar", +"shift", +"slightly", +"thump", +"truck", +"tune", +"twenty", +"unable", +"wipe", +"wrote", +"coat", +"constant", +"dinner", +"drove", +"egg", +"eternal", +"flight", +"flood", +"frame", +"freak", +"gasp", +"glad", +"hollow", +"motion", +"peer", +"plastic", +"root", +"screen", +"season", +"sting", +"strike", +"team", +"unlike", +"victim", +"volume", +"warn", +"weird", +"attack", +"await", +"awake", +"built", +"charm", +"crave", +"despair", +"fought", +"grant", +"grief", +"horse", +"limit", +"message", +"ripple", +"sanity", +"scatter", +"serve", +"split", +"string", +"trick", +"annoy", +"blur", +"boat", +"brave", +"clearly", +"cling", +"connect", +"fist", +"forth", +"imagination", +"iron", +"jock", +"judge", +"lesson", +"milk", +"misery", +"nail", +"naked", +"ourselves", +"poet", +"possible", +"princess", +"sail", +"size", +"snake", +"society", +"stroke", +"torture", +"toss", +"trace", +"wise", +"bloom", +"bullet", +"cell", +"check", +"cost", +"darling", +"during", +"footstep", +"fragile", +"hallway", +"hardly", +"horizon", +"invisible", +"journey", +"midnight", +"mud", +"nod", +"pause", +"relax", +"shiver", +"sudden", +"value", +"youth", +"abuse", +"admire", +"blink", +"breast", +"bruise", +"constantly", +"couple", +"creep", +"curve", +"difference", +"dumb", +"emptiness", +"gotta", +"honor", +"plain", +"planet", +"recall", +"rub", +"ship", +"slam", +"soar", +"somebody", +"tightly", +"weather", +"adore", +"approach", +"bond", +"bread", +"burst", +"candle", +"coffee", +"cousin", +"crime", +"desert", +"flutter", +"frozen", +"grand", +"heel", +"hello", +"language", +"level", +"movement", +"pleasure", +"powerful", +"random", +"rhythm", +"settle", +"silly", +"slap", +"sort", +"spoken", +"steel", +"threaten", +"tumble", +"upset", +"aside", +"awkward", +"bee", +"blank", +"board", +"button", +"card", +"carefully", +"complain", +"crap", +"deeply", +"discover", +"drag", +"dread", +"effort", +"entire", +"fairy", +"giant", +"gotten", +"greet", +"illusion", +"jeans", +"leap", +"liquid", +"march", +"mend", +"nervous", +"nine", +"replace", +"rope", +"spine", +"stole", +"terror", +"accident", +"apple", +"balance", +"boom", +"childhood", +"collect", +"demand", +"depression", +"eventually", +"faint", +"glare", +"goal", +"group", +"honey", +"kitchen", +"laid", +"limb", +"machine", +"mere", +"mold", +"murder", +"nerve", +"painful", +"poetry", +"prince", +"rabbit", +"shelter", +"shore", +"shower", +"soothe", +"stair", +"steady", +"sunlight", +"tangle", +"tease", +"treasure", +"uncle", +"begun", +"bliss", +"canvas", +"cheer", +"claw", +"clutch", +"commit", +"crimson", +"crystal", +"delight", +"doll", +"existence", +"express", +"fog", +"football", +"gay", +"goose", +"guard", +"hatred", +"illuminate", +"mass", +"math", +"mourn", +"rich", +"rough", +"skip", +"stir", +"student", +"style", +"support", +"thorn", +"tough", +"yard", +"yearn", +"yesterday", +"advice", +"appreciate", +"autumn", +"bank", +"beam", +"bowl", +"capture", +"carve", +"collapse", +"confusion", +"creation", +"dove", +"feather", +"girlfriend", +"glory", +"government", +"harsh", +"hop", +"inner", +"loser", +"moonlight", +"neighbor", +"neither", +"peach", +"pig", +"praise", +"screw", +"shield", +"shimmer", +"sneak", +"stab", +"subject", +"throughout", +"thrown", +"tower", +"twirl", +"wow", +"army", +"arrive", +"bathroom", +"bump", +"cease", +"cookie", +"couch", +"courage", +"dim", +"guilt", +"howl", +"hum", +"husband", +"insult", +"led", +"lunch", +"mock", +"mostly", +"natural", +"nearly", +"needle", +"nerd", +"peaceful", +"perfection", +"pile", +"price", +"remove", +"roam", +"sanctuary", +"serious", +"shiny", +"shook", +"sob", +"stolen", +"tap", +"vain", +"void", +"warrior", +"wrinkle", +"affection", +"apologize", +"blossom", +"bounce", +"bridge", +"cheap", +"crumble", +"decision", +"descend", +"desperately", +"dig", +"dot", +"flip", +"frighten", +"heartbeat", +"huge", +"lazy", +"lick", +"odd", +"opinion", +"process", +"puzzle", +"quietly", +"retreat", +"score", +"sentence", +"separate", +"situation", +"skill", +"soak", +"square", +"stray", +"taint", +"task", +"tide", +"underneath", +"veil", +"whistle", +"anywhere", +"bedroom", +"bid", +"bloody", +"burden", +"careful", +"compare", +"concern", +"curtain", +"decay", +"defeat", +"describe", +"double", +"dreamer", +"driver", +"dwell", +"evening", +"flare", +"flicker", +"grandma", +"guitar", +"harm", +"horrible", +"hungry", +"indeed", +"lace", +"melody", +"monkey", +"nation", +"object", +"obviously", +"rainbow", +"salt", +"scratch", +"shown", +"shy", +"stage", +"stun", +"third", +"tickle", +"useless", +"weakness", +"worship", +"worthless", +"afternoon", +"beard", +"boyfriend", +"bubble", +"busy", +"certain", +"chin", +"concrete", +"desk", +"diamond", +"doom", +"drawn", +"due", +"felicity", +"freeze", +"frost", +"garden", +"glide", +"harmony", +"hopefully", +"hunt", +"jealous", +"lightning", +"mama", +"mercy", +"peel", +"physical", +"position", +"pulse", +"punch", +"quit", +"rant", +"respond", +"salty", +"sane", +"satisfy", +"savior", +"sheep", +"slept", +"social", +"sport", +"tuck", +"utter", +"valley", +"wolf", +"aim", +"alas", +"alter", +"arrow", +"awaken", +"beaten", +"belief", +"brand", +"ceiling", +"cheese", +"clue", +"confidence", +"connection", +"daily", +"disguise", +"eager", +"erase", +"essence", +"everytime", +"expression", +"fan", +"flag", +"flirt", +"foul", +"fur", +"giggle", +"glorious", +"ignorance", +"law", +"lifeless", +"measure", +"mighty", +"muse", +"north", +"opposite", +"paradise", +"patience", +"patient", +"pencil", +"petal", +"plate", +"ponder", +"possibly", +"practice", +"slice", +"spell", +"stock", +"strife", +"strip", +"suffocate", +"suit", +"tender", +"tool", +"trade", +"velvet", +"verse", +"waist", +"witch", +"aunt", +"bench", +"bold", +"cap", +"certainly", +"click", +"companion", +"creator", +"dart", +"delicate", +"determine", +"dish", +"dragon", +"drama", +"drum", +"dude", +"everybody", +"feast", +"forehead", +"former", +"fright", +"fully", +"gas", +"hook", +"hurl", +"invite", +"juice", +"manage", +"moral", +"possess", +"raw", +"rebel", +"royal", +"scale", +"scary", +"several", +"slight", +"stubborn", +"swell", +"talent", +"tea", +"terrible", +"thread", +"torment", +"trickle", +"usually", +"vast", +"violence", +"weave", +"acid", +"agony", +"ashamed", +"awe", +"belly", +"blend", +"blush", +"character", +"cheat", +"common", +"company", +"coward", +"creak", +"danger", +"deadly", +"defense", +"define", +"depend", +"desperate", +"destination", +"dew", +"duck", +"dusty", +"embarrass", +"engine", +"example", +"explore", +"foe", +"freely", +"frustrate", +"generation", +"glove", +"guilty", +"health", +"hurry", +"idiot", +"impossible", +"inhale", +"jaw", +"kingdom", +"mention", +"mist", +"moan", +"mumble", +"mutter", +"observe", +"ode", +"pathetic", +"pattern", +"pie", +"prefer", +"puff", +"rape", +"rare", +"revenge", +"rude", +"scrape", +"spiral", +"squeeze", +"strain", +"sunset", +"suspend", +"sympathy", +"thigh", +"throne", +"total", +"unseen", +"weapon", +"weary" +] + + + +n = 1626 + +# Note about US patent no 5892470: Here each word does not represent a given digit. +# Instead, the digit represented by a word is variable, it depends on the previous word. + +def mn_encode( message ): + assert len(message) % 8 == 0 + out = [] + for i in range(len(message)//8): + word = message[8*i:8*i+8] + x = int(word, 16) + w1 = (x%n) + w2 = ((x//n) + w1)%n + w3 = ((x//n//n) + w2)%n + out += [ words[w1], words[w2], words[w3] ] + return out + + +def mn_decode( wlist ): + out = '' + for i in range(len(wlist)//3): + word1, word2, word3 = wlist[3*i:3*i+3] + w1 = words.index(word1) + w2 = (words.index(word2))%n + w3 = (words.index(word3))%n + x = w1 +n*((w2-w1)%n) +n*n*((w3-w2)%n) + out += '%08x'%x + return out + + +if __name__ == '__main__': + import sys + if len(sys.argv) == 1: + print('I need arguments: a hex string to encode, or a list of words to decode') + elif len(sys.argv) == 2: + print(' '.join(mn_encode(sys.argv[1]))) + else: + print(mn_decode(sys.argv[1:])) DIR diff --git a/electrum/paymentrequest.proto b/electrum/paymentrequest.proto t@@ -0,0 +1,47 @@ +// +// Simple Bitcoin Payment Protocol messages +// +// Use fields 1000+ for extensions; +// to avoid conflicts, register extensions via pull-req at +// https://github.com/bitcoin/bips/bip-0070/extensions.mediawiki +// + +syntax = "proto2"; +package payments; +option java_package = "org.bitcoin.protocols.payments"; +option java_outer_classname = "Protos"; + +// Generalized form of "send payment to this/these bitcoin addresses" +message Output { + optional uint64 amount = 1 [default = 0]; // amount is integer-number-of-satoshis + required bytes script = 2; // usually one of the standard Script forms +} +message PaymentDetails { + optional string network = 1 [default = "main"]; // "main" or "test" + repeated Output outputs = 2; // Where payment should be sent + required uint64 time = 3; // Timestamp; when payment request created + optional uint64 expires = 4; // Timestamp; when this request should be considered invalid + optional string memo = 5; // Human-readable description of request for the customer + optional string payment_url = 6; // URL to send Payment and get PaymentACK + optional bytes merchant_data = 7; // Arbitrary data to include in the Payment message +} +message PaymentRequest { + optional uint32 payment_details_version = 1 [default = 1]; + optional string pki_type = 2 [default = "none"]; // none / x509+sha256 / x509+sha1 + optional bytes pki_data = 3; // depends on pki_type + required bytes serialized_payment_details = 4; // PaymentDetails + optional bytes signature = 5; // pki-dependent signature +} +message X509Certificates { + repeated bytes certificate = 1; // DER-encoded X.509 certificate chain +} +message Payment { + optional bytes merchant_data = 1; // From PaymentDetails.merchant_data + repeated bytes transactions = 2; // Signed transactions that satisfy PaymentDetails.outputs + repeated Output refund_to = 3; // Where to send refunds, if a refund is necessary + optional string memo = 4; // Human-readable message for the merchant +} +message PaymentACK { + required Payment payment = 1; // Payment message that triggered this ACK + optional string memo = 2; // human-readable message for customer +} DIR diff --git a/electrum/paymentrequest.py b/electrum/paymentrequest.py t@@ -0,0 +1,523 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2014 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import hashlib +import sys +import time +import traceback +import json +import requests + +import urllib.parse + + +try: + from . import paymentrequest_pb2 as pb2 +except ImportError: + sys.exit("Error: could not find paymentrequest_pb2.py. Create it with 'protoc --proto_path=electrum/ --python_out=electrum/ electrum/paymentrequest.proto'") + +from . import bitcoin, ecc, util, transaction, x509, rsakey +from .util import print_error, bh2u, bfh +from .util import export_meta, import_meta + +from .bitcoin import TYPE_ADDRESS + +REQUEST_HEADERS = {'Accept': 'application/bitcoin-paymentrequest', 'User-Agent': 'Electrum'} +ACK_HEADERS = {'Content-Type':'application/bitcoin-payment','Accept':'application/bitcoin-paymentack','User-Agent':'Electrum'} + +ca_path = requests.certs.where() +ca_list = None +ca_keyID = None + +def load_ca_list(): + global ca_list, ca_keyID + if ca_list is None: + ca_list, ca_keyID = x509.load_certificates(ca_path) + + + +# status of payment requests +PR_UNPAID = 0 +PR_EXPIRED = 1 +PR_UNKNOWN = 2 # sent but not propagated +PR_PAID = 3 # send and propagated + + + +def get_payment_request(url): + u = urllib.parse.urlparse(url) + error = None + if u.scheme in ['http', 'https']: + try: + response = requests.request('GET', url, headers=REQUEST_HEADERS) + response.raise_for_status() + # Guard against `bitcoin:`-URIs with invalid payment request URLs + if "Content-Type" not in response.headers \ + or response.headers["Content-Type"] != "application/bitcoin-paymentrequest": + data = None + error = "payment URL not pointing to a payment request handling server" + else: + data = response.content + print_error('fetched payment request', url, len(response.content)) + except requests.exceptions.RequestException: + data = None + error = "payment URL not pointing to a valid server" + elif u.scheme == 'file': + try: + with open(u.path, 'r', encoding='utf-8') as f: + data = f.read() + except IOError: + data = None + error = "payment URL not pointing to a valid file" + else: + raise Exception("unknown scheme", url) + pr = PaymentRequest(data, error) + return pr + + +class PaymentRequest: + + def __init__(self, data, error=None): + self.raw = data + self.error = error + self.parse(data) + self.requestor = None # known after verify + self.tx = None + + def __str__(self): + return str(self.raw) + + def parse(self, r): + if self.error: + return + self.id = bh2u(bitcoin.sha256(r)[0:16]) + try: + self.data = pb2.PaymentRequest() + self.data.ParseFromString(r) + except: + self.error = "cannot parse payment request" + return + self.details = pb2.PaymentDetails() + self.details.ParseFromString(self.data.serialized_payment_details) + self.outputs = [] + for o in self.details.outputs: + addr = transaction.get_address_from_output_script(o.script)[1] + self.outputs.append((TYPE_ADDRESS, addr, o.amount)) + self.memo = self.details.memo + self.payment_url = self.details.payment_url + + def is_pr(self): + return self.get_amount() != 0 + #return self.get_outputs() != [(TYPE_ADDRESS, self.get_requestor(), self.get_amount())] + + def verify(self, contacts): + if self.error: + return False + if not self.raw: + self.error = "Empty request" + return False + pr = pb2.PaymentRequest() + try: + pr.ParseFromString(self.raw) + except: + self.error = "Error: Cannot parse payment request" + return False + if not pr.signature: + # the address will be displayed as requestor + self.requestor = None + return True + if pr.pki_type in ["x509+sha256", "x509+sha1"]: + return self.verify_x509(pr) + elif pr.pki_type in ["dnssec+btc", "dnssec+ecdsa"]: + return self.verify_dnssec(pr, contacts) + else: + self.error = "ERROR: Unsupported PKI Type for Message Signature" + return False + + def verify_x509(self, paymntreq): + load_ca_list() + if not ca_list: + self.error = "Trusted certificate authorities list not found" + return False + cert = pb2.X509Certificates() + cert.ParseFromString(paymntreq.pki_data) + # verify the chain of certificates + try: + x, ca = verify_cert_chain(cert.certificate) + except BaseException as e: + traceback.print_exc(file=sys.stderr) + self.error = str(e) + return False + # get requestor name + self.requestor = x.get_common_name() + if self.requestor.startswith('*.'): + self.requestor = self.requestor[2:] + # verify the BIP70 signature + pubkey0 = rsakey.RSAKey(x.modulus, x.exponent) + sig = paymntreq.signature + paymntreq.signature = b'' + s = paymntreq.SerializeToString() + sigBytes = bytearray(sig) + msgBytes = bytearray(s) + if paymntreq.pki_type == "x509+sha256": + hashBytes = bytearray(hashlib.sha256(msgBytes).digest()) + verify = pubkey0.verify(sigBytes, x509.PREFIX_RSA_SHA256 + hashBytes) + elif paymntreq.pki_type == "x509+sha1": + verify = pubkey0.hashAndVerify(sigBytes, msgBytes) + if not verify: + self.error = "ERROR: Invalid Signature for Payment Request Data" + return False + ### SIG Verified + self.error = 'Signed by Trusted CA: ' + ca.get_common_name() + return True + + def verify_dnssec(self, pr, contacts): + sig = pr.signature + alias = pr.pki_data + info = contacts.resolve(alias) + if info.get('validated') is not True: + self.error = "Alias verification failed (DNSSEC)" + return False + if pr.pki_type == "dnssec+btc": + self.requestor = alias + address = info.get('address') + pr.signature = b'' + message = pr.SerializeToString() + if ecc.verify_message_with_address(address, sig, message): + self.error = 'Verified with DNSSEC' + return True + else: + self.error = "verify failed" + return False + else: + self.error = "unknown algo" + return False + + def has_expired(self): + return self.details.expires and self.details.expires < int(time.time()) + + def get_expiration_date(self): + return self.details.expires + + def get_amount(self): + return sum(map(lambda x:x[2], self.outputs)) + + def get_address(self): + o = self.outputs[0] + assert o[0] == TYPE_ADDRESS + return o[1] + + def get_requestor(self): + return self.requestor if self.requestor else self.get_address() + + def get_verify_status(self): + return self.error if self.requestor else "No Signature" + + def get_memo(self): + return self.memo + + def get_dict(self): + return { + 'requestor': self.get_requestor(), + 'memo':self.get_memo(), + 'exp': self.get_expiration_date(), + 'amount': self.get_amount(), + 'signature': self.get_verify_status(), + 'txid': self.tx, + 'outputs': self.get_outputs() + } + + def get_id(self): + return self.id if self.requestor else self.get_address() + + def get_outputs(self): + return self.outputs[:] + + def send_ack(self, raw_tx, refund_addr): + pay_det = self.details + if not self.details.payment_url: + return False, "no url" + paymnt = pb2.Payment() + paymnt.merchant_data = pay_det.merchant_data + paymnt.transactions.append(bfh(raw_tx)) + ref_out = paymnt.refund_to.add() + ref_out.script = util.bfh(transaction.Transaction.pay_script(TYPE_ADDRESS, refund_addr)) + paymnt.memo = "Paid using Electrum" + pm = paymnt.SerializeToString() + payurl = urllib.parse.urlparse(pay_det.payment_url) + try: + r = requests.post(payurl.geturl(), data=pm, headers=ACK_HEADERS, verify=ca_path) + except requests.exceptions.SSLError: + print("Payment Message/PaymentACK verify Failed") + try: + r = requests.post(payurl.geturl(), data=pm, headers=ACK_HEADERS, verify=False) + except Exception as e: + print(e) + return False, "Payment Message/PaymentACK Failed" + if r.status_code >= 500: + return False, r.reason + try: + paymntack = pb2.PaymentACK() + paymntack.ParseFromString(r.content) + except Exception: + return False, "PaymentACK could not be processed. Payment was sent; please manually verify that payment was received." + print("PaymentACK message received: %s" % paymntack.memo) + return True, paymntack.memo + + +def make_unsigned_request(req): + from .transaction import Transaction + addr = req['address'] + time = req.get('time', 0) + exp = req.get('exp', 0) + if time and type(time) != int: + time = 0 + if exp and type(exp) != int: + exp = 0 + amount = req['amount'] + if amount is None: + amount = 0 + memo = req['memo'] + script = bfh(Transaction.pay_script(TYPE_ADDRESS, addr)) + outputs = [(script, amount)] + pd = pb2.PaymentDetails() + for script, amount in outputs: + pd.outputs.add(amount=amount, script=script) + pd.time = time + pd.expires = time + exp if exp else 0 + pd.memo = memo + pr = pb2.PaymentRequest() + pr.serialized_payment_details = pd.SerializeToString() + pr.signature = util.to_bytes('') + return pr + + +def sign_request_with_alias(pr, alias, alias_privkey): + pr.pki_type = 'dnssec+btc' + pr.pki_data = str(alias) + message = pr.SerializeToString() + ec_key = ecc.ECPrivkey(alias_privkey) + compressed = bitcoin.is_compressed(alias_privkey) + pr.signature = ec_key.sign_message(message, compressed) + + +def verify_cert_chain(chain): + """ Verify a chain of certificates. The last certificate is the CA""" + load_ca_list() + # parse the chain + cert_num = len(chain) + x509_chain = [] + for i in range(cert_num): + x = x509.X509(bytearray(chain[i])) + x509_chain.append(x) + if i == 0: + x.check_date() + else: + if not x.check_ca(): + raise Exception("ERROR: Supplied CA Certificate Error") + if not cert_num > 1: + raise Exception("ERROR: CA Certificate Chain Not Provided by Payment Processor") + # if the root CA is not supplied, add it to the chain + ca = x509_chain[cert_num-1] + if ca.getFingerprint() not in ca_list: + keyID = ca.get_issuer_keyID() + f = ca_keyID.get(keyID) + if f: + root = ca_list[f] + x509_chain.append(root) + else: + raise Exception("Supplied CA Not Found in Trusted CA Store.") + # verify the chain of signatures + cert_num = len(x509_chain) + for i in range(1, cert_num): + x = x509_chain[i] + prev_x = x509_chain[i-1] + algo, sig, data = prev_x.get_signature() + sig = bytearray(sig) + pubkey = rsakey.RSAKey(x.modulus, x.exponent) + if algo == x509.ALGO_RSA_SHA1: + verify = pubkey.hashAndVerify(sig, data) + elif algo == x509.ALGO_RSA_SHA256: + hashBytes = bytearray(hashlib.sha256(data).digest()) + verify = pubkey.verify(sig, x509.PREFIX_RSA_SHA256 + hashBytes) + elif algo == x509.ALGO_RSA_SHA384: + hashBytes = bytearray(hashlib.sha384(data).digest()) + verify = pubkey.verify(sig, x509.PREFIX_RSA_SHA384 + hashBytes) + elif algo == x509.ALGO_RSA_SHA512: + hashBytes = bytearray(hashlib.sha512(data).digest()) + verify = pubkey.verify(sig, x509.PREFIX_RSA_SHA512 + hashBytes) + else: + raise Exception("Algorithm not supported") + util.print_error(self.error, algo.getComponentByName('algorithm')) + if not verify: + raise Exception("Certificate not Signed by Provided CA Certificate Chain") + + return x509_chain[0], ca + + +def check_ssl_config(config): + from . import pem + key_path = config.get('ssl_privkey') + cert_path = config.get('ssl_chain') + with open(key_path, 'r', encoding='utf-8') as f: + params = pem.parse_private_key(f.read()) + with open(cert_path, 'r', encoding='utf-8') as f: + s = f.read() + bList = pem.dePemList(s, "CERTIFICATE") + # verify chain + x, ca = verify_cert_chain(bList) + # verify that privkey and pubkey match + privkey = rsakey.RSAKey(*params) + pubkey = rsakey.RSAKey(x.modulus, x.exponent) + assert x.modulus == params[0] + assert x.exponent == params[1] + # return requestor + requestor = x.get_common_name() + if requestor.startswith('*.'): + requestor = requestor[2:] + return requestor + +def sign_request_with_x509(pr, key_path, cert_path): + from . import pem + with open(key_path, 'r', encoding='utf-8') as f: + params = pem.parse_private_key(f.read()) + privkey = rsakey.RSAKey(*params) + with open(cert_path, 'r', encoding='utf-8') as f: + s = f.read() + bList = pem.dePemList(s, "CERTIFICATE") + certificates = pb2.X509Certificates() + certificates.certificate.extend(map(bytes, bList)) + pr.pki_type = 'x509+sha256' + pr.pki_data = certificates.SerializeToString() + msgBytes = bytearray(pr.SerializeToString()) + hashBytes = bytearray(hashlib.sha256(msgBytes).digest()) + sig = privkey.sign(x509.PREFIX_RSA_SHA256 + hashBytes) + pr.signature = bytes(sig) + + +def serialize_request(req): + pr = make_unsigned_request(req) + signature = req.get('sig') + requestor = req.get('name') + if requestor and signature: + pr.signature = bfh(signature) + pr.pki_type = 'dnssec+btc' + pr.pki_data = str(requestor) + return pr + + +def make_request(config, req): + pr = make_unsigned_request(req) + key_path = config.get('ssl_privkey') + cert_path = config.get('ssl_chain') + if key_path and cert_path: + sign_request_with_x509(pr, key_path, cert_path) + return pr + + + +class InvoiceStore(object): + + def __init__(self, storage): + self.storage = storage + self.invoices = {} + self.paid = {} + d = self.storage.get('invoices', {}) + self.load(d) + + def set_paid(self, pr, txid): + pr.tx = txid + pr_id = pr.get_id() + self.paid[txid] = pr_id + if pr_id not in self.invoices: + # in case the user had deleted it previously + self.add(pr) + + def load(self, d): + for k, v in d.items(): + try: + pr = PaymentRequest(bfh(v.get('hex'))) + pr.tx = v.get('txid') + pr.requestor = v.get('requestor') + self.invoices[k] = pr + if pr.tx: + self.paid[pr.tx] = k + except: + continue + + def import_file(self, path): + def validate(data): + return data # TODO + import_meta(path, validate, self.on_import) + + def on_import(self, data): + self.load(data) + self.save() + + def export_file(self, filename): + export_meta(self.dump(), filename) + + def dump(self): + d = {} + for k, pr in self.invoices.items(): + d[k] = { + 'hex': bh2u(pr.raw), + 'requestor': pr.requestor, + 'txid': pr.tx + } + return d + + def save(self): + self.storage.put('invoices', self.dump()) + + def get_status(self, key): + pr = self.get(key) + if pr is None: + print_error("[InvoiceStore] get_status() can't find pr for", key) + return + if pr.tx is not None: + return PR_PAID + if pr.has_expired(): + return PR_EXPIRED + return PR_UNPAID + + def add(self, pr): + key = pr.get_id() + self.invoices[key] = pr + self.save() + return key + + def remove(self, key): + self.invoices.pop(key) + self.save() + + def get(self, k): + return self.invoices.get(k) + + def sorted_list(self): + # sort + return self.invoices.values() + + def unpaid_invoices(self): + return [ self.invoices[k] for k in filter(lambda x: self.get_status(x)!=PR_PAID, self.invoices.keys())] DIR diff --git a/electrum/paymentrequest_pb2.py b/electrum/paymentrequest_pb2.py t@@ -0,0 +1,367 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: paymentrequest.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='paymentrequest.proto', + package='payments', parazyd.org:70 /git/electrum/commit/097ac144d976eb46dff809e1809783dc78ab6d8b.gph:20797: line too long