Repository: spesmilo/electrum
Branch: master
Commit: dceece1cda9b
Files: 776
Total size: 8.9 MB
Directory structure:
gitextract_3c6jsf9o/
├── .cirrus.yml
├── .editorconfig
├── .gitattributes
├── .github/
│ └── ISSUE_TEMPLATE/
│ ├── 01_issue.yml
│ └── config.yml
├── .gitignore
├── .gitmodules
├── AUTHORS
├── LICENCE
├── MANIFEST.in
├── README.md
├── RELEASE-NOTES
├── SECURITY.md
├── contrib/
│ ├── add_cosigner
│ ├── android/
│ │ ├── Dockerfile
│ │ ├── Makefile
│ │ ├── Readme.md
│ │ ├── apkdiff.py
│ │ ├── apt.preferences
│ │ ├── apt.sources.list
│ │ ├── bitcoin_intent.xml
│ │ ├── build.sh
│ │ ├── buildozer_qml.spec
│ │ ├── dl-ndk-ci.sh
│ │ ├── get_apk_versioncode.py
│ │ ├── make_apk.sh
│ │ ├── make_barcode_scanner.sh
│ │ └── p4a_recipes/
│ │ ├── README.md
│ │ ├── cffi/
│ │ │ └── __init__.py
│ │ ├── cryptography/
│ │ │ └── __init__.py
│ │ ├── hostpython3/
│ │ │ └── __init__.py
│ │ ├── libffi/
│ │ │ └── __init__.py
│ │ ├── libiconv/
│ │ │ └── __init__.py
│ │ ├── libsecp256k1/
│ │ │ └── __init__.py
│ │ ├── libzbar/
│ │ │ └── __init__.py
│ │ ├── openssl/
│ │ │ └── __init__.py
│ │ ├── packaging/
│ │ │ └── __init__.py
│ │ ├── ply/
│ │ │ └── __init__.py
│ │ ├── plyer/
│ │ │ └── __init__.py
│ │ ├── pycparser/
│ │ │ └── __init__.py
│ │ ├── pycryptodomex/
│ │ │ └── __init__.py
│ │ ├── pyjnius/
│ │ │ └── __init__.py
│ │ ├── pyparsing/
│ │ │ └── __init__.py
│ │ ├── pyqt6/
│ │ │ └── __init__.py
│ │ ├── pyqt6sip/
│ │ │ └── __init__.py
│ │ ├── pyqt_builder/
│ │ │ └── __init__.py
│ │ ├── python3/
│ │ │ └── __init__.py
│ │ ├── qt6/
│ │ │ └── __init__.py
│ │ ├── setuptools/
│ │ │ └── __init__.py
│ │ ├── sip/
│ │ │ └── __init__.py
│ │ ├── six/
│ │ │ └── __init__.py
│ │ ├── sqlite3/
│ │ │ └── __init__.py
│ │ ├── toml/
│ │ │ └── __init__.py
│ │ ├── tomli/
│ │ │ └── __init__.py
│ │ └── util.py
│ ├── apparmor/
│ │ ├── README.md
│ │ └── apparmor.d/
│ │ ├── abstractions/
│ │ │ └── electrum
│ │ ├── electrum.appimage
│ │ └── usr.local.bin.electrum
│ ├── ban_unicode.py
│ ├── build-linux/
│ │ ├── appimage/
│ │ │ ├── .dockerignore
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ ├── apprun.sh
│ │ │ ├── apt.preferences
│ │ │ ├── apt.sources.list
│ │ │ ├── build.sh
│ │ │ ├── make_appimage.sh
│ │ │ ├── make_type2_runtime.sh
│ │ │ └── patches/
│ │ │ ├── python-3.11-reproducible-buildinfo.diff
│ │ │ └── type2-runtime-reproducible-build.patch
│ │ └── sdist/
│ │ ├── .dockerignore
│ │ ├── Dockerfile
│ │ ├── README.md
│ │ ├── build.sh
│ │ └── make_sdist.sh
│ ├── build-wine/
│ │ ├── .dockerignore
│ │ ├── Dockerfile
│ │ ├── README.md
│ │ ├── README_windows.md
│ │ ├── apt.preferences
│ │ ├── apt.sources.list
│ │ ├── build-electrum-git.sh
│ │ ├── build.sh
│ │ ├── electrum.nsi
│ │ ├── gpg_keys/
│ │ │ └── 7ED10B6531D7C8E1BC296021FC624643487034E5.asc
│ │ ├── make_win.sh
│ │ ├── patches/
│ │ │ └── libiconv-fix-pointer-buf.patch
│ │ ├── prepare-wine.sh
│ │ ├── pyinstaller.spec
│ │ ├── sign.sh
│ │ └── unsign.sh
│ ├── build_tools_util.sh
│ ├── deterministic-build/
│ │ ├── README.md
│ │ ├── check_submodules.sh
│ │ ├── find_restricted_dependencies.py
│ │ ├── requirements-binaries-mac.txt
│ │ ├── requirements-binaries.txt
│ │ ├── requirements-build-android.txt
│ │ ├── requirements-build-appimage.txt
│ │ ├── requirements-build-base.txt
│ │ ├── requirements-build-mac.txt
│ │ ├── requirements-build-wine.txt
│ │ ├── requirements-hw.txt
│ │ └── requirements.txt
│ ├── docker_notes.md
│ ├── freeze_containers_distro.sh
│ ├── freeze_packages.sh
│ ├── generate_payreqpb2.sh
│ ├── locale/
│ │ ├── build_cleanlocale.sh
│ │ ├── build_locale.sh
│ │ ├── push_locale.py
│ │ └── stats.py
│ ├── make_download
│ ├── make_libsecp256k1.sh
│ ├── make_libusb.sh
│ ├── make_packages.sh
│ ├── make_plugin
│ ├── make_zbar.sh
│ ├── osx/
│ │ ├── README.md
│ │ ├── README_macos.md
│ │ ├── apply_sigs.sh
│ │ ├── cdrkit-deterministic.patch
│ │ ├── compare_dmg
│ │ ├── entitlements.plist
│ │ ├── extract_sigs.sh
│ │ ├── make_osx.sh
│ │ ├── notarize_app.sh
│ │ ├── package.sh
│ │ ├── pyinstaller.spec
│ │ └── sign_osx.sh
│ ├── print_electrum_version.py
│ ├── release.sh
│ ├── release_www.sh
│ ├── requirements/
│ │ ├── requirements-binaries-mac.txt
│ │ ├── requirements-binaries.txt
│ │ ├── requirements-build-android.txt
│ │ ├── requirements-build-appimage.txt
│ │ ├── requirements-build-base.txt
│ │ ├── requirements-build-mac.txt
│ │ ├── requirements-build-wine.txt
│ │ ├── requirements-ci.txt
│ │ ├── requirements-hw.txt
│ │ └── requirements.txt
│ ├── sign_packages
│ ├── trigger_deploy.sh
│ ├── udev/
│ │ ├── 20-hw1.rules
│ │ ├── 51-coinkite.rules
│ │ ├── 51-hid-digitalbitbox.rules
│ │ ├── 51-safe-t.rules
│ │ ├── 51-trezor.rules
│ │ ├── 51-usb-keepkey.rules
│ │ ├── 52-hid-digitalbitbox.rules
│ │ ├── 53-hid-bitbox02.rules
│ │ ├── 54-hid-bitbox02.rules
│ │ ├── 55-usb-jade.rules
│ │ └── README.md
│ └── upload.sh
├── electrum/
│ ├── __init__.py
│ ├── _vendor/
│ │ ├── __init__.py
│ │ ├── distutils/
│ │ │ ├── LICENSE
│ │ │ ├── __init__.py
│ │ │ └── version.py
│ │ └── pyperclip/
│ │ ├── LICENSE.txt
│ │ ├── README.md
│ │ └── __init__.py
│ ├── address_synchronizer.py
│ ├── base_crash_reporter.py
│ ├── bip21.py
│ ├── bip32.py
│ ├── bip39_recovery.py
│ ├── bip39_wallet_formats.json
│ ├── bitcoin.py
│ ├── blockchain.py
│ ├── chains/
│ │ ├── mainnet/
│ │ │ ├── checkpoints.json
│ │ │ ├── fallback_lnnodes.json
│ │ │ └── servers.json
│ │ ├── mutinynet/
│ │ │ ├── fallback_lnnodes.json
│ │ │ └── servers.json
│ │ ├── regtest/
│ │ │ └── servers.json
│ │ ├── signet/
│ │ │ ├── checkpoints.json
│ │ │ ├── fallback_lnnodes.json
│ │ │ └── servers.json
│ │ ├── testnet/
│ │ │ ├── checkpoints.json
│ │ │ ├── fallback_lnnodes.json
│ │ │ └── servers.json
│ │ └── testnet4/
│ │ ├── checkpoints.json
│ │ └── servers.json
│ ├── channel_db.py
│ ├── coinchooser.py
│ ├── commands.py
│ ├── constants.py
│ ├── contacts.py
│ ├── crypto.py
│ ├── currencies.json
│ ├── daemon.py
│ ├── descriptor.py
│ ├── dns_hacks.py
│ ├── dnssec.py
│ ├── exchange_rate.py
│ ├── fee_policy.py
│ ├── gui/
│ │ ├── __init__.py
│ │ ├── common_qt/
│ │ │ ├── __init__.py
│ │ │ ├── i18n.py
│ │ │ ├── plugins.py
│ │ │ └── util.py
│ │ ├── default_lang.py
│ │ ├── fonts/
│ │ │ └── PTMono.LICENSE
│ │ ├── icons/
│ │ │ ├── clock5.pdn
│ │ │ └── electrum.icns
│ │ ├── messages.py
│ │ ├── qml/
│ │ │ ├── __init__.py
│ │ │ ├── android_res/
│ │ │ │ └── layout/
│ │ │ │ └── scanner_layout.xml
│ │ │ ├── auth.py
│ │ │ ├── components/
│ │ │ │ ├── About.qml
│ │ │ │ ├── AddressDetails.qml
│ │ │ │ ├── Addresses.qml
│ │ │ │ ├── BIP39RecoveryDialog.qml
│ │ │ │ ├── BalanceDetails.qml
│ │ │ │ ├── ChannelDetails.qml
│ │ │ │ ├── ChannelOpenProgressDialog.qml
│ │ │ │ ├── Channels.qml
│ │ │ │ ├── CloseChannelDialog.qml
│ │ │ │ ├── ConfirmTxDialog.qml
│ │ │ │ ├── Constants.qml
│ │ │ │ ├── CpfpBumpFeeDialog.qml
│ │ │ │ ├── ExceptionDialog.qml
│ │ │ │ ├── ExportTxDialog.qml
│ │ │ │ ├── GenericShareDialog.qml
│ │ │ │ ├── History.qml
│ │ │ │ ├── ImportAddressesKeysDialog.qml
│ │ │ │ ├── ImportChannelBackupDialog.qml
│ │ │ │ ├── InvoiceDialog.qml
│ │ │ │ ├── Invoices.qml
│ │ │ │ ├── LightningPaymentDetails.qml
│ │ │ │ ├── LnurlPayRequestDialog.qml
│ │ │ │ ├── LnurlWithdrawRequestDialog.qml
│ │ │ │ ├── LoadingWalletDialog.qml
│ │ │ │ ├── MessageDialog.qml
│ │ │ │ ├── NetworkOverview.qml
│ │ │ │ ├── NewWalletWizard.qml
│ │ │ │ ├── NostrConfigDialog.qml
│ │ │ │ ├── NostrSwapServersDialog.qml
│ │ │ │ ├── NotificationPopup.qml
│ │ │ │ ├── OpenChannelDialog.qml
│ │ │ │ ├── OpenWalletDialog.qml
│ │ │ │ ├── OtpDialog.qml
│ │ │ │ ├── PasswordDialog.qml
│ │ │ │ ├── Preferences.qml
│ │ │ │ ├── ProxyConfigDialog.qml
│ │ │ │ ├── RbfBumpFeeDialog.qml
│ │ │ │ ├── RbfCancelDialog.qml
│ │ │ │ ├── ReceiveDetailsDialog.qml
│ │ │ │ ├── ReceiveDialog.qml
│ │ │ │ ├── ReceiveRequests.qml
│ │ │ │ ├── ScanDialog.qml
│ │ │ │ ├── SendDialog.qml
│ │ │ │ ├── ServerConfigDialog.qml
│ │ │ │ ├── ServerConnectWizard.qml
│ │ │ │ ├── SignVerifyMessageDialog.qml
│ │ │ │ ├── SwapDialog.qml
│ │ │ │ ├── SweepDialog.qml
│ │ │ │ ├── TermsOfUseWizard.qml
│ │ │ │ ├── TxDetails.qml
│ │ │ │ ├── WalletDetails.qml
│ │ │ │ ├── WalletMainView.qml
│ │ │ │ ├── WalletSummary.qml
│ │ │ │ ├── Wallets.qml
│ │ │ │ ├── controls/
│ │ │ │ │ ├── AddressDelegate.qml
│ │ │ │ │ ├── BalanceSummary.qml
│ │ │ │ │ ├── BtcField.qml
│ │ │ │ │ ├── ButtonContainer.qml
│ │ │ │ │ ├── ChannelBar.qml
│ │ │ │ │ ├── ChannelDelegate.qml
│ │ │ │ │ ├── CoinDelegate.qml
│ │ │ │ │ ├── ElCheckBox.qml
│ │ │ │ │ ├── ElComboBox.qml
│ │ │ │ │ ├── ElDialog.qml
│ │ │ │ │ ├── ElListView.qml
│ │ │ │ │ ├── ElRadioButton.qml
│ │ │ │ │ ├── ElTextArea.qml
│ │ │ │ │ ├── FeeMethodComboBox.qml
│ │ │ │ │ ├── FeePicker.qml
│ │ │ │ │ ├── FiatField.qml
│ │ │ │ │ ├── FlatButton.qml
│ │ │ │ │ ├── FormattedAmount.qml
│ │ │ │ │ ├── Heading.qml
│ │ │ │ │ ├── HelpButton.qml
│ │ │ │ │ ├── HelpDialog.qml
│ │ │ │ │ ├── HistoryItemDelegate.qml
│ │ │ │ │ ├── InfoBanner.qml
│ │ │ │ │ ├── InfoTextArea.qml
│ │ │ │ │ ├── InvoiceDelegate.qml
│ │ │ │ │ ├── LightningNetworkStatusIndicator.qml
│ │ │ │ │ ├── OnchainNetworkStatusIndicator.qml
│ │ │ │ │ ├── PaneInsetBackground.qml
│ │ │ │ │ ├── PasswordField.qml
│ │ │ │ │ ├── PasswordStrengthIndicator.qml
│ │ │ │ │ ├── Piechart.qml
│ │ │ │ │ ├── PrefsHeading.qml
│ │ │ │ │ ├── ProxyConfig.qml
│ │ │ │ │ ├── QRImage.qml
│ │ │ │ │ ├── QRScan.qml
│ │ │ │ │ ├── RequestExpiryComboBox.qml
│ │ │ │ │ ├── SeedKeyboard.qml
│ │ │ │ │ ├── SeedKeyboardKey.qml
│ │ │ │ │ ├── SeedTextArea.qml
│ │ │ │ │ ├── ServerConfig.qml
│ │ │ │ │ ├── ServerConnectModeComboBox.qml
│ │ │ │ │ ├── ServerDelegate.qml
│ │ │ │ │ ├── Tag.qml
│ │ │ │ │ ├── TextHighlightPane.qml
│ │ │ │ │ ├── Toaster.qml
│ │ │ │ │ ├── ToggleLabel.qml
│ │ │ │ │ ├── TxInput.qml
│ │ │ │ │ └── TxOutput.qml
│ │ │ │ ├── main.qml
│ │ │ │ └── wizard/
│ │ │ │ ├── WCConfirmExt.qml
│ │ │ │ ├── WCConfirmSeed.qml
│ │ │ │ ├── WCCosignerKeystore.qml
│ │ │ │ ├── WCCreateSeed.qml
│ │ │ │ ├── WCEnterExt.qml
│ │ │ │ ├── WCHaveMasterKey.qml
│ │ │ │ ├── WCHaveSeed.qml
│ │ │ │ ├── WCImport.qml
│ │ │ │ ├── WCKeystoreType.qml
│ │ │ │ ├── WCMultisig.qml
│ │ │ │ ├── WCProxyConfig.qml
│ │ │ │ ├── WCScriptAndDerivation.qml
│ │ │ │ ├── WCServerConfig.qml
│ │ │ │ ├── WCShowMasterPubkey.qml
│ │ │ │ ├── WCTermsOfUseRequest.qml
│ │ │ │ ├── WCWalletName.qml
│ │ │ │ ├── WCWalletPassword.qml
│ │ │ │ ├── WCWalletType.qml
│ │ │ │ ├── WCWelcome.qml
│ │ │ │ ├── Wizard.qml
│ │ │ │ └── WizardComponent.qml
│ │ │ ├── java_classes/
│ │ │ │ └── org/
│ │ │ │ └── electrum/
│ │ │ │ ├── biometry/
│ │ │ │ │ ├── BiometricActivity.java
│ │ │ │ │ └── BiometricHelper.java
│ │ │ │ └── qr/
│ │ │ │ └── SimpleScannerActivity.java
│ │ │ ├── qeaddressdetails.py
│ │ │ ├── qeaddresslistmodel.py
│ │ │ ├── qeapp.py
│ │ │ ├── qebiometrics.py
│ │ │ ├── qebip39recovery.py
│ │ │ ├── qebitcoin.py
│ │ │ ├── qechanneldetails.py
│ │ │ ├── qechannellistmodel.py
│ │ │ ├── qechannelopener.py
│ │ │ ├── qeconfig.py
│ │ │ ├── qedaemon.py
│ │ │ ├── qefx.py
│ │ │ ├── qeinvoice.py
│ │ │ ├── qeinvoicelistmodel.py
│ │ │ ├── qelnpaymentdetails.py
│ │ │ ├── qemodelfilter.py
│ │ │ ├── qenetwork.py
│ │ │ ├── qepiresolver.py
│ │ │ ├── qeqr.py
│ │ │ ├── qeqrscanner.py
│ │ │ ├── qerequestdetails.py
│ │ │ ├── qeserverlistmodel.py
│ │ │ ├── qeswaphelper.py
│ │ │ ├── qetransactionlistmodel.py
│ │ │ ├── qetxdetails.py
│ │ │ ├── qetxfinalizer.py
│ │ │ ├── qetypes.py
│ │ │ ├── qewallet.py
│ │ │ ├── qewizard.py
│ │ │ └── util.py
│ │ ├── qt/
│ │ │ ├── __init__.py
│ │ │ ├── address_dialog.py
│ │ │ ├── address_list.py
│ │ │ ├── amountedit.py
│ │ │ ├── balance_dialog.py
│ │ │ ├── bip39_recovery_dialog.py
│ │ │ ├── channel_details.py
│ │ │ ├── channels_list.py
│ │ │ ├── completion_text_edit.py
│ │ │ ├── confirm_tx_dialog.py
│ │ │ ├── console.py
│ │ │ ├── contact_list.py
│ │ │ ├── custom_model.py
│ │ │ ├── exception_window.py
│ │ │ ├── fee_slider.py
│ │ │ ├── history_list.py
│ │ │ ├── invoice_list.py
│ │ │ ├── lightning_dialog.py
│ │ │ ├── lightning_tx_dialog.py
│ │ │ ├── locktimeedit.py
│ │ │ ├── main_window.py
│ │ │ ├── my_treeview.py
│ │ │ ├── network_dialog.py
│ │ │ ├── new_channel_dialog.py
│ │ │ ├── password_dialog.py
│ │ │ ├── paytoedit.py
│ │ │ ├── plugins_dialog.py
│ │ │ ├── qrcodewidget.py
│ │ │ ├── qrreader/
│ │ │ │ ├── __init__.py
│ │ │ │ └── qtmultimedia/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── camera_dialog.py
│ │ │ │ ├── crop_blur_effect.py
│ │ │ │ ├── validator.py
│ │ │ │ ├── video_overlay.py
│ │ │ │ ├── video_surface.py
│ │ │ │ └── video_widget.py
│ │ │ ├── qrtextedit.py
│ │ │ ├── qrwindow.py
│ │ │ ├── rate_limiter.py
│ │ │ ├── rbf_dialog.py
│ │ │ ├── rebalance_dialog.py
│ │ │ ├── receive_tab.py
│ │ │ ├── request_list.py
│ │ │ ├── seed_dialog.py
│ │ │ ├── send_tab.py
│ │ │ ├── settings_dialog.py
│ │ │ ├── stylesheet_patcher.py
│ │ │ ├── swap_dialog.py
│ │ │ ├── transaction_dialog.py
│ │ │ ├── update_checker.py
│ │ │ ├── util.py
│ │ │ ├── utxo_dialog.py
│ │ │ ├── utxo_list.py
│ │ │ ├── wallet_info_dialog.py
│ │ │ └── wizard/
│ │ │ ├── __init__.py
│ │ │ ├── server_connect.py
│ │ │ ├── terms_of_use.py
│ │ │ ├── wallet.py
│ │ │ └── wizard.py
│ │ ├── stdio.py
│ │ └── text.py
│ ├── harden_memory_linux.py
│ ├── hw_wallet/
│ │ ├── __init__.py
│ │ ├── cmdline.py
│ │ ├── plugin.py
│ │ ├── qt.py
│ │ └── trezor_qt_pinmatrix.py
│ ├── i18n.py
│ ├── interface.py
│ ├── invoices.py
│ ├── json_db.py
│ ├── keystore.py
│ ├── lnaddr.py
│ ├── lnchannel.py
│ ├── lnhtlc.py
│ ├── lnmsg.py
│ ├── lnonion.py
│ ├── lnpeer.py
│ ├── lnrater.py
│ ├── lnrouter.py
│ ├── lnsweep.py
│ ├── lntransport.py
│ ├── lnurl.py
│ ├── lnutil.py
│ ├── lnverifier.py
│ ├── lnwatcher.py
│ ├── lnwire/
│ │ ├── README.md
│ │ ├── onion_wire.csv
│ │ └── peer_wire.csv
│ ├── lnworker.py
│ ├── logging.py
│ ├── lrucache.py
│ ├── mnemonic.py
│ ├── mpp_split.py
│ ├── network.py
│ ├── old_mnemonic.py
│ ├── onion_message.py
│ ├── payment_identifier.py
│ ├── paymentrequest.proto
│ ├── paymentrequest.py
│ ├── paymentrequest_pb2.py
│ ├── pem.py
│ ├── plot.py
│ ├── plugin.py
│ ├── plugins/
│ │ ├── README
│ │ ├── __init__.py
│ │ ├── audio_modem/
│ │ │ ├── __init__.py
│ │ │ ├── manifest.json
│ │ │ └── qt.py
│ │ ├── bitbox02/
│ │ │ ├── __init__.py
│ │ │ ├── bitbox02.py
│ │ │ ├── manifest.json
│ │ │ └── qt.py
│ │ ├── coldcard/
│ │ │ ├── README.md
│ │ │ ├── __init__.py
│ │ │ ├── cmdline.py
│ │ │ ├── coldcard.py
│ │ │ ├── manifest.json
│ │ │ └── qt.py
│ │ ├── digitalbitbox/
│ │ │ ├── __init__.py
│ │ │ ├── cmdline.py
│ │ │ ├── digitalbitbox.py
│ │ │ ├── manifest.json
│ │ │ └── qt.py
│ │ ├── jade/
│ │ │ ├── __init__.py
│ │ │ ├── cmdline.py
│ │ │ ├── jade.py
│ │ │ ├── jadepy/
│ │ │ │ ├── README.md
│ │ │ │ ├── __init__.py
│ │ │ │ ├── jade.py
│ │ │ │ ├── jade_error.py
│ │ │ │ ├── jade_serial.py
│ │ │ │ └── jade_tcp.py
│ │ │ ├── manifest.json
│ │ │ └── qt.py
│ │ ├── keepkey/
│ │ │ ├── __init__.py
│ │ │ ├── client.py
│ │ │ ├── clientbase.py
│ │ │ ├── cmdline.py
│ │ │ ├── keepkey.py
│ │ │ ├── manifest.json
│ │ │ └── qt.py
│ │ ├── labels/
│ │ │ ├── Labels.qml
│ │ │ ├── __init__.py
│ │ │ ├── cmdline.py
│ │ │ ├── labels.py
│ │ │ ├── manifest.json
│ │ │ ├── qml.py
│ │ │ └── qt.py
│ │ ├── ledger/
│ │ │ ├── __init__.py
│ │ │ ├── cmdline.py
│ │ │ ├── ledger.py
│ │ │ ├── manifest.json
│ │ │ └── qt.py
│ │ ├── nwc/
│ │ │ ├── __init__.py
│ │ │ ├── cmdline.py
│ │ │ ├── manifest.json
│ │ │ ├── nwcserver.py
│ │ │ └── qt.py
│ │ ├── psbt_nostr/
│ │ │ ├── __init__.py
│ │ │ ├── manifest.json
│ │ │ ├── psbt_nostr.py
│ │ │ ├── qml/
│ │ │ │ ├── PsbtReceiveDialog.qml
│ │ │ │ └── main.qml
│ │ │ ├── qml.py
│ │ │ └── qt.py
│ │ ├── revealer/
│ │ │ ├── LICENSE_DEJAVU.txt
│ │ │ ├── SIL Open Font License.txt
│ │ │ ├── SourceSans3-Bold.otf
│ │ │ ├── __init__.py
│ │ │ ├── hmac_drbg.py
│ │ │ ├── manifest.json
│ │ │ ├── qt.py
│ │ │ └── revealer.py
│ │ ├── safe_t/
│ │ │ ├── __init__.py
│ │ │ ├── client.py
│ │ │ ├── clientbase.py
│ │ │ ├── cmdline.py
│ │ │ ├── manifest.json
│ │ │ ├── qt.py
│ │ │ ├── safe_t.py
│ │ │ └── transport.py
│ │ ├── swapserver/
│ │ │ ├── __init__.py
│ │ │ ├── cmdline.py
│ │ │ ├── manifest.json
│ │ │ ├── server.py
│ │ │ └── swapserver.py
│ │ ├── timelock_recovery/
│ │ │ ├── __init__.py
│ │ │ ├── intro.txt
│ │ │ ├── manifest.json
│ │ │ ├── qt.py
│ │ │ └── timelock_recovery.py
│ │ ├── trezor/
│ │ │ ├── __init__.py
│ │ │ ├── clientbase.py
│ │ │ ├── cmdline.py
│ │ │ ├── manifest.json
│ │ │ ├── qt.py
│ │ │ └── trezor.py
│ │ ├── trustedcoin/
│ │ │ ├── __init__.py
│ │ │ ├── cmdline.py
│ │ │ ├── common_qt.py
│ │ │ ├── manifest.json
│ │ │ ├── qml/
│ │ │ │ ├── ChooseSeed.qml
│ │ │ │ ├── Disclaimer.qml
│ │ │ │ ├── KeepDisable.qml
│ │ │ │ ├── ShowConfirmOTP.qml
│ │ │ │ └── Terms.qml
│ │ │ ├── qml.py
│ │ │ ├── qt.py
│ │ │ └── trustedcoin.py
│ │ └── watchtower/
│ │ ├── __init__.py
│ │ ├── cmdline.py
│ │ ├── manifest.json
│ │ ├── server.py
│ │ └── watchtower.py
│ ├── qrreader/
│ │ ├── __init__.py
│ │ ├── abstract_base.py
│ │ └── zbar.py
│ ├── qrscanner.py
│ ├── ripemd.py
│ ├── rsakey.py
│ ├── scripts/
│ │ ├── README.md
│ │ ├── bip39_recovery.py
│ │ ├── block_headers.py
│ │ ├── bruteforce_pw.py
│ │ ├── estimate_fee.py
│ │ ├── get_history.py
│ │ ├── ln_features.py
│ │ ├── peers.py
│ │ ├── quick_start.py
│ │ ├── servers.py
│ │ ├── txbroadcast.py
│ │ ├── txradar.py
│ │ ├── update_default_servers.py
│ │ └── watch_address.py
│ ├── segwit_addr.py
│ ├── simple_config.py
│ ├── slip39.py
│ ├── sql_db.py
│ ├── storage.py
│ ├── submarine_swaps.py
│ ├── synchronizer.py
│ ├── trampoline.py
│ ├── transaction.py
│ ├── txbatcher.py
│ ├── util.py
│ ├── utils/
│ │ ├── __init__.py
│ │ ├── memory_leak.py
│ │ └── stacktracer.py
│ ├── verifier.py
│ ├── version.py
│ ├── wallet.py
│ ├── wallet_db.py
│ ├── wizard.py
│ ├── wordlist/
│ │ ├── chinese_simplified.txt
│ │ ├── english.txt
│ │ ├── japanese.txt
│ │ ├── portuguese.txt
│ │ ├── slip39.txt
│ │ └── spanish.txt
│ └── x509.py
├── electrum-env
├── electrum.desktop
├── fastlane/
│ └── metadata/
│ └── android/
│ └── en-US/
│ ├── full_description.txt
│ ├── short_description.txt
│ └── title.txt
├── org.electrum.electrum.metainfo.xml
├── pubkeys/
│ ├── Animazing.asc
│ ├── Emzy.asc
│ ├── ThomasV.asc
│ ├── bauerj.asc
│ ├── felixb_f321x.asc
│ ├── kyuupichan.asc
│ ├── sombernight.asc
│ ├── sombernight_releasekey.asc
│ └── wozz.asc
├── run_electrum
├── setup.cfg
├── setup.py
└── tests/
├── __init__.py
├── anchor-vectors.json
├── bip-0341/
│ └── wallet-test-vectors.json
├── blinded-onion-message-onion-test.json
├── cause_carbon_wallet.json
├── fiat_fx_data/
│ └── BitFinex_EUR
├── plugins/
│ ├── __init__.py
│ ├── test_revealer.py
│ ├── test_timelock_recovery/
│ │ └── default_wallet
│ └── test_timelock_recovery.py
├── qml/
│ ├── __init__.py
│ ├── qt_util.py
│ ├── test_qml_qeconfig.py
│ ├── test_qml_qetransactionlistmodel.py
│ └── test_qml_types.py
├── regtest/
│ ├── regtest.sh
│ ├── run_bitcoind.sh
│ └── run_electrumx.sh
├── regtest.py
├── slip39-vectors.json
├── test_bitcoin.py
├── test_blockchain.py
├── test_bolt11.py
├── test_callbackmgr.py
├── test_coinchooser.py
├── test_commands.py
├── test_contacts.py
├── test_daemon.py
├── test_descriptor.py
├── test_fee_policy.py
├── test_history_export/
│ ├── history_no_fx_client_4_5_2_9dk_with_ln.csv
│ ├── history_no_fx_client_4_5_2_9dk_with_ln.json
│ ├── history_with_fx_client_4_5_2_9dk_with_ln.csv
│ └── history_with_fx_client_4_5_2_9dk_with_ln.json
├── test_i18n.py
├── test_interface.py
├── test_invoices.py
├── test_jsondb.py
├── test_lnchannel.py
├── test_lnhtlc.py
├── test_lnmsg.py
├── test_lnpeer.py
├── test_lnpeermgr.py
├── test_lnrouter.py
├── test_lntransport.py
├── test_lnurl.py
├── test_lnutil.py
├── test_lnwallet.py
├── test_mnemonic.py
├── test_mpp_split.py
├── test_network.py
├── test_onion_message.py
├── test_payment_identifier.py
├── test_psbt.py
├── test_simple_config.py
├── test_storage_upgrade/
│ ├── client_1_9_8_seeded
│ ├── client_2_0_4_importedkeys
│ ├── client_2_0_4_multisig
│ ├── client_2_0_4_seeded
│ ├── client_2_0_4_trezor_multiacc
│ ├── client_2_0_4_trezor_singleacc
│ ├── client_2_0_4_watchaddresses
│ ├── client_2_1_1_importedkeys
│ ├── client_2_1_1_multisig
│ ├── client_2_1_1_seeded
│ ├── client_2_1_1_trezor_multiacc
│ ├── client_2_1_1_trezor_singleacc
│ ├── client_2_1_1_watchaddresses
│ ├── client_2_2_0_importedkeys
│ ├── client_2_2_0_multisig
│ ├── client_2_2_0_seeded
│ ├── client_2_2_0_trezor_multiacc
│ ├── client_2_2_0_trezor_singleacc
│ ├── client_2_2_0_watchaddresses
│ ├── client_2_3_2_importedkeys
│ ├── client_2_3_2_multisig
│ ├── client_2_3_2_seeded
│ ├── client_2_3_2_trezor_multiacc
│ ├── client_2_3_2_trezor_singleacc
│ ├── client_2_3_2_watchaddresses
│ ├── client_2_4_3_importedkeys
│ ├── client_2_4_3_multisig
│ ├── client_2_4_3_seeded
│ ├── client_2_4_3_trezor_multiacc
│ ├── client_2_4_3_trezor_singleacc
│ ├── client_2_4_3_watchaddresses
│ ├── client_2_5_4_importedkeys
│ ├── client_2_5_4_multisig
│ ├── client_2_5_4_seeded
│ ├── client_2_5_4_trezor_multiacc
│ ├── client_2_5_4_trezor_singleacc
│ ├── client_2_5_4_watchaddresses
│ ├── client_2_6_4_importedkeys
│ ├── client_2_6_4_multisig
│ ├── client_2_6_4_seeded
│ ├── client_2_6_4_watchaddresses
│ ├── client_2_7_18_importedkeys
│ ├── client_2_7_18_multisig
│ ├── client_2_7_18_seeded
│ ├── client_2_7_18_trezor_singleacc
│ ├── client_2_7_18_watchaddresses
│ ├── client_2_8_3_importedkeys
│ ├── client_2_8_3_importedkeys_flawed_previous_upgrade_from_2_7_18
│ ├── client_2_8_3_multisig
│ ├── client_2_8_3_seeded
│ ├── client_2_8_3_trezor_singleacc
│ ├── client_2_8_3_watchaddresses
│ ├── client_2_9_3_importedkeys
│ ├── client_2_9_3_importedkeys_keystore_changes
│ ├── client_2_9_3_multisig
│ ├── client_2_9_3_old_seeded_with_realistic_history
│ ├── client_2_9_3_seeded
│ ├── client_2_9_3_trezor_singleacc
│ ├── client_2_9_3_watchaddresses
│ ├── client_3_2_3_ledger_standard_keystore_changes
│ ├── client_3_3_8_xpub_with_realistic_history
│ └── client_4_5_2_9dk_with_ln
├── test_storage_upgrade.py
├── test_transaction.py
├── test_txbatcher.py
├── test_util.py
├── test_verifier.py
├── test_wallet.py
├── test_wallet_vertical.py
├── test_wizard.py
└── test_x509.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .cirrus.yml
================================================
# unittests using the 'latest' runtime python-dependencies
task:
container:
image: $ELECTRUM_IMAGE
cpu: 1
memory: 2G
matrix:
- name: "unittests: py$ELECTRUM_PYTHON_VERSION"
env:
ELECTRUM_IMAGE: python:$ELECTRUM_PYTHON_VERSION
matrix:
- env:
ELECTRUM_PYTHON_VERSION: 3.10
- env:
ELECTRUM_PYTHON_VERSION: 3.11
- env:
ELECTRUM_PYTHON_VERSION: 3.12
- env:
ELECTRUM_PYTHON_VERSION: 3.13
- env:
ELECTRUM_PYTHON_VERSION: 3.14
- name: "unittests: py3.14, debug-mode"
env:
ELECTRUM_PYTHON_VERSION: 3.14
# enable additional checks:
PYTHONASYNCIODEBUG: "1"
PYTHONDEVMODE: "1"
pip_cache:
folder: ~/.cache/pip
fingerprint_script: echo $ELECTRUM_IMAGE && cat $ELECTRUM_REQUIREMENTS_CI && cat $ELECTRUM_REQUIREMENTS
tag_script:
- git tag
libsecp_build_cache:
folder: contrib/_saved_secp256k1_build
fingerprint_script: sha256sum ./contrib/make_libsecp256k1.sh
populate_script:
- apt-get update
- apt-get -y install automake libtool
- ./contrib/make_libsecp256k1.sh
- mkdir contrib/_saved_secp256k1_build
- cp electrum/libsecp256k1.so.* contrib/_saved_secp256k1_build/
install_script:
- apt-get update
# qml test reqs:
- apt-get -y install libgl1 libegl1 libxkbcommon0 libdbus-1-3
- pip install -r $ELECTRUM_REQUIREMENTS_CI
# electrum itself:
- export ELECTRUM_ECC_DONT_COMPILE=1
- pip install ".[tests,qml_gui]"
version_script:
- python3 --version
- pip freeze --all
pytest_script:
- >
coverage run --source=electrum \
"--omit=electrum/gui/*,electrum/plugins/*,electrum/scripts/*" \
-m pytest tests -v
- coverage report
coveralls_script:
- if [ ! -z "$COVERALLS_REPO_TOKEN" ] && [ "$ELECTRUM_PYTHON_VERSION" = "3.10" ] ; then coveralls ; fi
env:
LD_LIBRARY_PATH: contrib/_saved_secp256k1_build/
ELECTRUM_REQUIREMENTS_CI: contrib/requirements/requirements-ci.txt
ELECTRUM_REQUIREMENTS: contrib/requirements/requirements.txt
# following CI_* env vars are set up for coveralls
CI_NAME: "CirrusCI"
CI_BUILD_NUMBER: $CIRRUS_BUILD_ID
CI_JOB_ID: $CIRRUS_TASK_ID
CI_BUILD_URL: "https://cirrus-ci.com/task/$CIRRUS_TASK_ID"
CI_BRANCH: $CIRRUS_BRANCH
CI_PULL_REQUEST: $CIRRUS_PR
# in addition, COVERALLS_REPO_TOKEN is set as an "override" in https://cirrus-ci.com/settings/...
depends_on:
- "linter: Flake8 Mandatory"
# unittests using the ~same frozen dependencies that are used in the released binaries
# note: not using pinned pyqt here, due to "qml_gui" extra
task:
container:
image: $ELECTRUM_IMAGE
cpu: 1
memory: 2G
name: "unittests: py3.10, frozen-deps"
pip_cache:
folder: ~/.cache/pip
fingerprint_script: echo $ELECTRUM_IMAGE && cat contrib/requirements/requirements*.txt && cat contrib/deterministic-build/requirements*.txt
tag_script:
- git tag
libsecp_build_cache:
folder: contrib/_saved_secp256k1_build
fingerprint_script: sha256sum ./contrib/make_libsecp256k1.sh
populate_script:
- apt-get update
- apt-get -y install automake libtool
- ./contrib/make_libsecp256k1.sh
- mkdir contrib/_saved_secp256k1_build
- cp electrum/libsecp256k1.so.* contrib/_saved_secp256k1_build/
install_script:
- apt-get update
# qml test reqs:
- apt-get -y install libgl1 libegl1 libxkbcommon0 libdbus-1-3
- pip install -r contrib/deterministic-build/requirements-build-base.txt
- pip install -r contrib/requirements/requirements-ci.txt
# electrum itself:
- export ELECTRUM_ECC_DONT_COMPILE=1
- pip install -r contrib/deterministic-build/requirements.txt -r contrib/deterministic-build/requirements-binaries.txt
- pip install ".[tests,qml_gui]"
version_script:
- python3 --version
- pip freeze --all
pytest_script:
- pytest tests -v
env:
ELECTRUM_IMAGE: python:3.10
LD_LIBRARY_PATH: contrib/_saved_secp256k1_build/
depends_on:
- "linter: Flake8 Mandatory"
task:
name: "locale: upload to crowdin"
container:
image: $ELECTRUM_IMAGE
cpu: 1
memory: 1G
pip_cache:
folder: ~/.cache/pip
fingerprint_script: echo Locale && echo $ELECTRUM_IMAGE && cat $ELECTRUM_REQUIREMENTS_CI
install_script:
- apt-get update
- apt-get -y install gettext qt6-l10n-tools
- pip install -r $ELECTRUM_REQUIREMENTS_CI
- pip install requests
submodules_script:
- git submodule update --init
locale_script:
- contrib/locale/push_locale.py
env:
ELECTRUM_IMAGE: python:3.10
ELECTRUM_REQUIREMENTS_CI: contrib/requirements/requirements-ci.txt
# in addition, crowdin_api_key is set as an "override" in https://cirrus-ci.com/settings/...
# - api key is for crowdin account: "SomberNight_CI_BOT"
# - see https://crowdin.com/settings#api-key
depends_on:
- "unittests: py3.10"
only_if: $CIRRUS_BRANCH == 'master'
task:
name: "Regtest functional tests"
compute_engine_instance:
image_project: cirrus-images
image: family/docker-builder
platform: linux
cpu: 1
memory: 1G
pip_cache:
folder: ~/.cache/pip
fingerprint_script: echo Regtest && echo docker_builder && cat $ELECTRUM_REQUIREMENTS
bitcoind_cache:
folder: /tmp/bitcoind
populate_script: mkdir -p /tmp/bitcoind
install_script:
- apt-get update
- apt-get -y install curl jq bc
- python3 -m pip install --user --upgrade pip
# install electrum
- export ELECTRUM_ECC_DONT_COMPILE=1 # we build manually to make caching it easier
- python3 -m pip install .[tests] --ignore-installed # ignore installed system installed attrs
# install e-x some commits after 1.18.0 tag
- python3 -m pip install git+https://github.com/spesmilo/electrumx.git@0b260d4345242cc41e316e97d7de10ae472fd172
- "BITCOIND_VERSION=$(curl https://bitcoincore.org/en/download/ | grep -E -i --only-matching 'Latest version: [0-9\\.]+' | grep -E --only-matching '[0-9\\.]+')"
- BITCOIND_FILENAME=bitcoin-$BITCOIND_VERSION-x86_64-linux-gnu.tar.gz
- BITCOIND_PATH=/tmp/bitcoind/$BITCOIND_FILENAME
- BITCOIND_URL=https://bitcoincore.org/bin/bitcoin-core-$BITCOIND_VERSION/$BITCOIND_FILENAME
- tar -xaf $BITCOIND_PATH || (rm -f /tmp/bitcoind/* && curl --output $BITCOIND_PATH $BITCOIND_URL && tar -xaf $BITCOIND_PATH)
- cp -a bitcoin-$BITCOIND_VERSION/* /usr/
libsecp_build_cache:
folder: contrib/_saved_secp256k1_build
fingerprint_script: sha256sum ./contrib/make_libsecp256k1.sh
populate_script:
- apt-get -y install automake libtool
- ./contrib/make_libsecp256k1.sh
- mkdir contrib/_saved_secp256k1_build
- cp electrum/libsecp256k1.so.* contrib/_saved_secp256k1_build/
bitcoind_service_background_script:
- tests/regtest/run_bitcoind.sh
electrumx_service_background_script:
- tests/regtest/run_electrumx.sh
# if any test fails, the test will get aborted (--failfast) and the wallet directories will be
# available for download in the Cirrus UI
regtest_script:
- sleep 10s
- python3 -m unittest tests/regtest.py --failfast || TEST_EXIT_CODE=$?
- tar -czf test_wallets.tar.gz /tmp/alice /tmp/bob /tmp/carol || true
- exit ${TEST_EXIT_CODE:-0}
on_failure:
wallet_artifacts:
path: "test_wallets.tar.gz"
env:
LD_LIBRARY_PATH: contrib/_saved_secp256k1_build/
ELECTRUM_REQUIREMENTS: contrib/requirements/requirements.txt
PIP_BREAK_SYSTEM_PACKAGES: 1
# ElectrumX exits with an error without this:
ALLOW_ROOT: 1
depends_on:
- "linter: Flake8 Mandatory"
task:
container:
image: $ELECTRUM_IMAGE
cpu: 1
memory: 1G
pip_cache:
folder: ~/.cache/pip
fingerprint_script: echo Flake8 && echo $ELECTRUM_IMAGE && cat $ELECTRUM_REQUIREMENTS
install_script:
- pip install "flake8==7.3.0" "flake8-bugbear==25.10.21"
flake8_script:
- flake8 . --count --select="$ELECTRUM_LINTERS" --ignore="$ELECTRUM_LINTERS_IGNORE" --show-source --statistics --exclude "*_pb2.py,electrum/_vendor/"
env:
ELECTRUM_IMAGE: python:3.10
ELECTRUM_REQUIREMENTS: contrib/requirements/requirements.txt
matrix:
- name: "linter: Flake8 Mandatory"
env:
# list of error codes:
# - https://flake8.pycqa.org/en/latest/user/error-codes.html
# - https://pycodestyle.pycqa.org/en/latest/intro.html#error-codes
# - https://github.com/PyCQA/flake8-bugbear/tree/8c0e7eb04217494d48d0ab093bf5b31db0921989#list-of-warnings
ELECTRUM_LINTERS: E9,E101,E129,E273,E274,E703,E71,E722,F5,F6,F7,F8,W191,W29,B,B909
ELECTRUM_LINTERS_IGNORE: B007,B009,B010,B036,B042,F541,F841
- name: "linter: Flake8 Non-Mandatory"
env:
ELECTRUM_LINTERS: E,F,W,C90,B
ELECTRUM_LINTERS_IGNORE: ""
allow_failures: true
task:
name: "linter: ban unicode"
container:
image: python:3.10
cpu: 1
memory: 1G
main_script:
- contrib/ban_unicode.py
# Cron jobs configured in https://cirrus-ci.com/settings/...
# - job "nightly" on branch "master" at "0 30 2 * * ?" (every day at 02:30Z)
task:
name: "build: Windows"
matrix:
- trigger_type: manual
only_if: $CIRRUS_CRON == ""
- trigger_type: automatic
only_if: $CIRRUS_CRON == "nightly"
container:
dockerfile: contrib/build-wine/Dockerfile
cpu: 1
memory: 3G
pip_cache:
folders:
- contrib/build-wine/.cache/win*/wine_pip_cache
fingerprint_script:
- echo $CIRRUS_TASK_NAME
- git ls-files -s contrib/deterministic-build/*.txt
- git ls-files -s contrib/build-wine/
build2_cache:
folders:
- contrib/build-wine/.cache/win*/build
fingerprint_script:
- echo $CIRRUS_TASK_NAME
- cat contrib/make_libsecp256k1.sh | sha256sum
- cat contrib/make_libusb.sh | sha256sum
- cat contrib/make_zbar.sh | sha256sum
- git ls-files -s contrib/build-wine/
build_script:
- cd contrib/build-wine
- ./make_win.sh
binaries_artifacts:
path: "contrib/build-wine/dist/*"
env:
CIRRUS_WORKING_DIR: /opt/wine64/drive_c/electrum
CIRRUS_DOCKER_CONTEXT: contrib/build-wine
depends_on:
- "unittests: py3.10"
task:
name: "build: Android (QML $APK_ARCH)"
matrix:
- trigger_type: manual
only_if: $CIRRUS_CRON == ""
- trigger_type: automatic
only_if: $CIRRUS_CRON == "nightly"
timeout_in: 90m
container:
dockerfile: contrib/android/Dockerfile
cpu: 8
memory: 24G
env:
APK_ARCH: arm64-v8a
packages_tld_folder_cache:
folder: packages
fingerprint_script:
- echo $CIRRUS_TASK_NAME && cat contrib/deterministic-build/requirements.txt && cat contrib/make_packages.sh
- git ls-files -s contrib/android/
p4a_cache:
folders:
- ".buildozer/android/platform/build-$APK_ARCH/packages"
- ".buildozer/android/platform/build-$APK_ARCH/build"
fingerprint_script:
# note: should *at least* depend on Dockerfile and p4a_recipes/, but contrib/android/ is simplest
- git ls-files -s contrib/android/
- echo "qml $APK_ARCH"
build_script:
- ./contrib/android/make_apk.sh qml "$APK_ARCH" debug
binaries_artifacts:
path: "dist/*"
depends_on:
- "unittests: py3.10"
## mac build disabled, as Cirrus CI no longer supports Intel-based mac builds
#task:
# name: "build: macOS"
# macos_instance:
# image: catalina-xcode-11.3.1
# env:
# TARGET_OS: macOS
# pip_cache:
# folder: ~/Library/Caches/pip
# fingerprint_script:
# - echo $CIRRUS_TASK_NAME
# - git ls-files -s contrib/deterministic-build/*.txt
# - git ls-files -s contrib/osx/
# build2_cache:
# folder: contrib/osx/.cache
# fingerprint_script:
# - echo $CIRRUS_TASK_NAME
# - cat contrib/make_libsecp256k1.sh | shasum -a 256
# - cat contrib/make_libusb.sh | shasum -a 256
# - cat contrib/make_zbar.sh | shasum -a 256
# - git ls-files -s contrib/osx/
# install_script:
# - git fetch --all --tags
# build_script:
# - ./contrib/osx/make_osx.sh
# sum_script:
# - ls -lah dist
# - shasum -a 256 dist/*.dmg
# binaries_artifacts:
# path: "dist/*"
task:
name: "build: AppImage"
matrix:
- trigger_type: manual
only_if: $CIRRUS_CRON == ""
- trigger_type: automatic
only_if: $CIRRUS_CRON == "nightly"
compute_engine_instance:
image_project: cirrus-images
image: family/docker-builder
platform: linux
cpu: 2
memory: 2G
pip_cache:
folder: contrib/build-linux/appimage/.cache/pip_cache
fingerprint_script:
- echo $CIRRUS_TASK_NAME
- git ls-files -s contrib/deterministic-build/*.txt
- git ls-files -s contrib/build-linux/appimage/
build2_cache:
folder: contrib/build-linux/appimage/.cache/appimage
fingerprint_script:
- echo $CIRRUS_TASK_NAME
- cat contrib/make_libsecp256k1.sh | sha256sum
- git ls-files -s contrib/build-linux/appimage/
build_script:
- ./contrib/build-linux/appimage/build.sh
binaries_artifacts:
path: "dist/*"
depends_on:
- "unittests: py3.10"
task:
container:
dockerfile: contrib/build-linux/sdist/Dockerfile
cpu: 1
memory: 1G
pip_cache:
folder: ~/.cache/pip
fingerprint_script:
- echo $CIRRUS_TASK_NAME
- git ls-files -s contrib/deterministic-build/*.txt
- git ls-files -s contrib/build-linux/sdist/
build_script:
- ./contrib/build-linux/sdist/make_sdist.sh
binaries_artifacts:
path: "dist/*"
matrix:
- name: "build: tarball"
- name: "build: source-only tarball"
env:
OMIT_UNCLEAN_FILES: 1
depends_on:
- "unittests: py3.10"
task:
name: "check submodules"
container:
image: python:3.10
cpu: 1
memory: 1G
fetch_script:
- git fetch --all --tags
check_script:
- ./contrib/deterministic-build/check_submodules.sh
only_if: $CIRRUS_TAG != ''
================================================
FILE: .editorconfig
================================================
# see https://EditorConfig.org
root = true
[*]
indent_style = space
trim_trailing_whitespace = true
end_of_line = lf
charset = utf-8
[*.py]
indent_size = 4
insert_final_newline = true
[*.sh]
indent_size = 4
insert_final_newline = true
================================================
FILE: .gitattributes
================================================
# Auto detect text files and perform end-of-line normalization (to LF)
* text=auto
# These Windows files should have CRLF line endings in checkout
*.bat text eol=crlf
*.ps1 text eol=crlf
# Never perform LF normalization on these files
*.ico binary
*.jar binary
*.png binary
*.zip binary
================================================
FILE: .github/ISSUE_TEMPLATE/01_issue.yml
================================================
name: Issue
description: Submit a new issue.
#labels: [bug]
body:
- type: markdown
attributes:
value: |
## Read this first!
* This issue tracker is for bug reports and development, not general questions.
* There is no private support! Scammers will reply to you here and tell you to go to their external website or contact them privately in email/telegram/whatsapp/etc.
They will try to **steal** your coins!
Be extremely suspicious of anyone offering help in a non-public way. Scammers will want to chat with you in private as then other people will not get a chance to point out the scam.
* Do not post issues about non-**Bitcoin** versions of Electrum.
----
#- type: checkboxes
# attributes:
# label: Is there an existing issue for this already?
# #description: Please search to see if this issue is already being tracked.
# options:
# - label: I have searched the existing issues
# required: true
- type: textarea
id: main-text-body
attributes:
label: Description
#description: Tell us what went wrong
placeholder: Please search existing issues for duplicates.
validations:
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
# - name: Electrum Security Policy
# url: https://github.com/spesmilo/electrum/blob/master/SECURITY.md
# about: View security policy
- name: Community Forum
url: https://bitcointalk.org/index.php?board=98.0
about: Ask non-development-related questions here
================================================
FILE: .gitignore
================================================
.git/
####-*.patch
**/*.pyc
*.swp
build/
dist/
*.egg/
Electrum.egg-info/
.devlocaltmp/
*_trial_temp
packages
env/
/.venv*/
.buildozer
.buildozer_*/
bin/
.idea
.mypy_cache
.vscode
electrum_data
.DS_Store
contrib/trigger_website
contrib/trigger_binaries
# tests/tox
.tox/
.cache/
.coverage
.pytest_cache
# build workspaces
contrib/build-wine/tmp/
contrib/build-wine/build/
contrib/build-wine/.cache/
contrib/build-wine/dist/
contrib/build-wine/signed/
contrib/build-linux/appimage/build/
contrib/build-linux/appimage/.cache/
contrib/osx/.cache/
contrib/osx/build-venv/
contrib/android/android_debug.keystore
contrib/android/.cache/
contrib/secp256k1/
contrib/zbar/
contrib/libusb/
contrib/.venv_make_packages/
# shared objects
electrum/*.so
electrum/*.so.*
electrum/*.dll
electrum/*.dylib
contrib/osx/*.dylib
================================================
FILE: .gitmodules
================================================
[submodule "electrum/locale"]
path = electrum/locale
url = https://github.com/spesmilo/electrum-locale
ignore = dirty
[submodule "electrum/plugins/keepkey/keepkeylib"]
path = electrum/plugins/keepkey/keepkeylib
url = https://github.com/spesmilo/electrum-keepkeylib.git
================================================
FILE: AUTHORS
================================================
ThomasV - Creator and maintainer.
Animazing / Tachikoma - Styled the new GUI. Mac version.
Azelphur - GUI stuff.
Coblee - Alternate coin support and py2app support.
Deafboy - Ubuntu packages.
Soren Stoutner - Debian packages and some Qt GUI layout.
EagleTM - Bugfixes.
ErebusBat - Mac distribution.
Genjix - Porting pro-mode functionality to lite-gui and worked on server
Slush - Work on the server. Designed the original Stratum spec.
Julian Toash (Tuxavant) - Various fixes to the client.
rdymac - Website and translations.
kyuupichan - Miscellaneous.
================================================
FILE: LICENCE
================================================
The MIT License (MIT)
Copyright (c) 2011-2024 The Electrum developers
Copyright (c) 2011-2024 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.
================================================
FILE: MANIFEST.in
================================================
include LICENCE RELEASE-NOTES AUTHORS
include README.md
include electrum.desktop
include *.py
include run_electrum
include org.electrum.electrum.metainfo.xml
recursive-include packages *.py
recursive-include packages cacert.pem
include contrib/requirements/requirements*.txt
include contrib/deterministic-build/requirements*.txt
include contrib/*.sh
graft electrum
graft tests
graft contrib/udev
exclude electrum/*.so
exclude electrum/*.so.0
exclude electrum/*.dll
exclude electrum/*.dylib
global-exclude __pycache__
global-exclude *.py[co~]
global-exclude *.py.orig
global-exclude *.py.rej
global-exclude .git
# We include both source (.po) and compiled (.mo) locale files (if present).
# When building the "sourceonly" tar.gz, the build script explicitly deletes the compiled files.
# exclude electrum/locale/locale/*/LC_MESSAGES/electrum.mo
================================================
FILE: README.md
================================================
# Electrum - Lightweight Bitcoin client
```
Licence: MIT Licence
Author: Thomas Voegtlin
Language: Python (>= 3.10)
Homepage: https://electrum.org/
```
[](https://cirrus-ci.com/github/spesmilo/electrum)
[](https://coveralls.io/github/spesmilo/electrum?branch=master)
[](https://crowdin.com/project/electrum)
## Getting started
_(If you've come here looking to simply run Electrum,
[you may download it here](https://electrum.org/#download).)_
Electrum itself is pure Python, and so are most of the required dependencies,
but not everything. The following sections describe how to run from source, but here
is a TL;DR:
```
$ sudo apt-get install libsecp256k1-dev
$ ELECTRUM_ECC_DONT_COMPILE=1 python3 -m pip install --user ".[gui,crypto]"
```
### Not pure-python dependencies
#### Qt GUI
If you want to use the Qt interface, install the Qt dependencies:
```
$ sudo apt-get install python3-pyqt6
```
#### libsecp256k1
For elliptic curve operations,
[libsecp256k1](https://github.com/bitcoin-core/secp256k1)
is a required dependency.
If you "pip install" Electrum, by default libsecp will get compiled locally,
as part of the `electrum-ecc` dependency. This can be opted-out of,
by setting the `ELECTRUM_ECC_DONT_COMPILE=1` environment variable.
For the compilation to work, besides a C compiler, you need at least:
```
$ sudo apt-get install automake libtool
```
If you opt out of the compilation, you need to provide libsecp in another way, e.g.:
```
$ sudo apt-get install libsecp256k1-dev
```
#### cryptography
Due to the need for fast symmetric ciphers,
[cryptography](https://github.com/pyca/cryptography) is required.
Install from your package manager (or from pip):
```
$ sudo apt-get install python3-cryptography
```
#### hardware-wallet support
If you would like hardware wallet support,
[see this](https://github.com/spesmilo/electrum-docs/blob/master/hardware-linux.rst).
### Running from tar.gz
If you downloaded the official package (tar.gz), you can run
Electrum from its root directory without installing it on your
system; all the pure python dependencies are included in the 'packages'
directory. To run Electrum from its root directory, just do:
```
$ ./run_electrum
```
You can also install Electrum on your system, by running this command:
```
$ sudo apt-get install python3-setuptools python3-pip
$ python3 -m pip install --user .
```
This will download and install the Python dependencies used by
Electrum instead of using the 'packages' directory.
It will also place an executable named `electrum` in `~/.local/bin`,
so make sure that is on your `PATH` variable.
### Development version (git clone)
_(For OS-specific instructions, see [here for Windows](contrib/build-wine/README_windows.md),
and [for macOS](contrib/osx/README_macos.md))_
Check out the code from GitHub:
```
$ git clone https://github.com/spesmilo/electrum.git
$ cd electrum
$ git submodule update --init
```
Run install (this should install dependencies):
```
$ python3 -m pip install --user -e .
```
Create translations (optional):
```
$ sudo apt-get install gettext
$ ./contrib/locale/build_locale.sh electrum/locale/locale electrum/locale/locale
```
Finally, to start Electrum:
```
$ ./run_electrum
```
### Run tests
Run unit tests with `pytest`:
```
$ pytest tests -v
```
(can be parallelized with `-n auto` option, using [`pytest-xdist`](https://github.com/pytest-dev/pytest-xdist) plugin)
To run a single file, specify it directly like this:
```
$ pytest tests/test_bitcoin.py -v
```
## Creating Binaries
- [Linux (tarball)](contrib/build-linux/sdist/README.md)
- [Linux (AppImage)](contrib/build-linux/appimage/README.md)
- [macOS](contrib/osx/README.md)
- [Windows](contrib/build-wine/README.md)
- [Android](contrib/android/Readme.md)
## Contributing
Any help testing the software, reporting or fixing bugs, reviewing pull requests
and recent changes, writing tests, or helping with outstanding issues is very welcome.
Implementing new features, or improving/refactoring the codebase, is of course
also welcome, but to avoid wasted effort, especially for larger changes,
we encourage discussing these on the issue tracker or IRC first.
Besides [GitHub](https://github.com/spesmilo/electrum),
most communication about Electrum development happens on IRC, in the
`#electrum` channel on Libera Chat. The easiest way to participate on IRC is
with the web client, [web.libera.chat](https://web.libera.chat/#electrum).
Please improve translations on [Crowdin](https://crowdin.com/project/electrum).
================================================
FILE: RELEASE-NOTES
================================================
# Release 4.7.1 (Feb 26, 2026)
* Qt GUI (desktop):
- new: changelog website accessible from "Help" toolbar menu (#10433)
- new: show translation completion percentage in language names (#10479)
- new: allow changing font size in console (#10494)
- changed: validate Electrum server address input with UI feedback (#10441)
- changed: stop showing anchor icon for lightning channels with anchor outputs (3979d70)
- fix: broken addresses tab for imported watch-only wallets (#10436)
* QML GUI & Android:
- new: show translation completion percentage in language names (#10479)
- changed: validate Electrum server address input with UI feedback (#10441)
- fix: handle Java import error causing startup crash on Android 7 and 8 devices (#10484)
* Onchain / Wallet:
- fix: improved fee estimation for replacement transactions (#10453)
* Database:
- fix: handle upgrade failure for users with pending Lightning HTLCs (#10489)
* Lightning:
- changed: send channel_update alongside node_announcement gossip messages (#10475)
- fix: improved safety when revealing preimage on-chain (#10442)
- fix: don't attempt to fetch gossip from Tor peers without a proxy enabled (#10448)
- fix: handle peer sending back our own channel_update (#10493)
* Dependencies:
- changed: bump electrum-ecc (and libsecp256k1) from 0.7.0 to 0.7.1 (#10495)
* Builds/binaries:
- Android:
- changed: bump docker base image to Debian 13 (#10452)
* Contrib:
- changed: translation: stop sorting source strings (6c1e085)
- changed: freeze_packages.py: use stdlib "venv" instead of 3rd party virtualenv (4f7b6e8)
- fix: add_cosigner.py: compatibility with Python 3.13 (b495ee7)
* Plugins:
- new: hook 'qt_utxo_menu' for Qt GUI UTXO list (cfe2a57)
* Hardware wallets:
- new: support Ledger Nano Gen 5 (#10457)
* Translations: Call for Proofreaders and Translators.
- Localisation of the UI has always been a community effort.
Recently we found several examples of vandalism and malicious behaviour
among the translated strings, including multiple bitcoin addresses
injected into UI strings. One user sent funds to one such address
and hence lost money. (see spesmilo/electrum-locale#46)
- We added some automated safeguards to try and prevent this in the future,
including basic regexes and an LLM proofreader. We also made the ongoing
git diffs for updating the frozen translations much smaller to make it
realistic to ~review. (see spesmilo/electrum-locale#47, #49, #51)
- However, the best solution would be per-language human review.
If we had 1-3 proofreaders per language on Crowdin, we could restrict
the set of translated strings that gets included in the binaries to the
"proofreader-approved" strings. We ask interested people to start contributing
and apply to be proofreaders. To get proofreader permissions, send us an email
or come to irc, with your crowdin username and some proof of work (such as
activity on crowdin in our or another project, contributions to open-source,
having an established identity on github/bitcointalk/stackoverflow/..., etc
-- just prove being a human and being well-intentioned).
(see https://github.com/spesmilo/electrum-locale/issues/47#issuecomment-3914866337)
# Release 4.7.0 (Jan 22, 2026)
* Qt GUI (desktop):
- new: "Submarine Payments": support reverse swaps to external address (#10303)
Allows doing onchain payments from the wallet's lightning balance.
- changed: flag console usage in crash reports (#10219)
- changed: add "Tools" text to the tools button for increased visibility (#10277)
- changed: improved UI feedback for send change to lightning function (#10247)
- fix: improve Network Tab behavior when switching connection mode (#10280)
- fix: re-add fiat values to csv/json history export (#10209)
- fix: not proposing tx batching in some cases (#10204)
* QML GUI & Android:
- new: allow manual editing of fee/feerate (#10371)
This also allows sending sub-1 sat/b transactions on Android.
- new: support biometric authentication (#10340)
Allows using the Android system lockscreen (e.g. fingerprints)
to unlock the wallet and authorize payments.
The previous optional built-in PIN code authentication is removed.
- changed: make UI compatible with edge-to-edge layout (#10178)
- changed: fee histogram colors: extend color palette to cover sub-1 s/b (#10307)
- changed: enforce the usage of a single password for all wallet files (#10345)
- changed: allow tap-to-focus in the qr code scanner (#10385)
- fix: allow opening passwordless wallets (#10423)
- fix: also protect address private keys from screenshots (#10426)
* Lightning:
- new: support LNURL-withdraw/LUD-3 (#9993)
Allows scanning QR codes to receive funds on lightning (e.g. ATMs, vouchers).
- changed: refactor handling of incoming htlcs (#10230)
- changed: collect htlcs failed back to us before re-splitting (#10274)
- fix: allow spending channel reserve if anchor channels are closed but not redeemed (2d17252)
- fix: logic bug in liquidity hint calculation (#10305)
- fix: race resulting in "Not enough balance" error when doing concurrent payments (#10325)
- fix: self payments (and rebalance function) (#10271)
- fix: gossip exchange with Core Lightning nodes (#10347)
- fix: only wait for pending htlcs to get removed if peer is connected (1845143)
* Electrum protocol:
- new: add support for Electrum Protocol version 1.6 (#10295)
See https://electrum-protocol.readthedocs.io/en/latest/protocol-changes.html#version-1-6
Min required version is still 1.4.
- changed: prevent connecting to server with different genesis hash (#10281)
- changed: add warmup budget before batching server rpc calls for faster startup (#10281)
- changed: optimistically guess scripthash status on new blocks to reduce network traffic
and improve privacy (#10290)
- fix: flush network buffer before disconnecting from server (6423323)
* Onchain / Wallet:
- changed: non-SPV verified transactions now considered unconfirmed (#10216)
- changed: always enforce dnssec validation for Openalias (#10349)
* Submarine swaps:
- new: cli commands to get swap statistics for swapserver operators (#10198):
'swapserver_get_history' and 'swapserver_get_summary'
* CLI/RPC:
- new: add 'export_lightning_preimage' command (#10242)
- changed: return lightning preimage from 'check_hold_invoice' command (#10242)
- changed: 'add_peer' now blocks until the connection is established (#10283)
- changed: 'version_info' now shows OpenSSL version (828fc56)
- fix: print warnings to stderr so output is still valid json (7bfe2dd)
- fix: imply enabled proxy when starting with proxy cli option (#10326)
* Plugins:
- changed: plugins can now use existing cli command names without colliding with builtin commands (9c4c7f0)
- changed: Timelock Recovery: check if locking address is ours or script (#10272)
- removed: payserver plugin, now an external plugin, moved to spesmilo/electrum-payserver (d36b753)
* Hardware wallets:
- Coldcard: fix: compatibility with ckcc-protocol v1.5.0 (2172dad)
* Contrib:
- new: add README to scripts/ directory (5a14a58)
* Dependencies:
- changed: bump min required electrum-aionostr to 0.1.0 (e188102)
* Builds/binaries:
- Android:
- new: support 16kb page size (#10148)
- changed: bump Android target SDK version to 35 (#10178)
- changed: bump OpenSSL from 1.1.1w to 3.0.18 (#10332)
- changed: switch from cryptography to pycryptodomex (#10332)
- changed: bump python version from 3.10.18 to 3.11.14 (#10388)
- AppImage:
- changed: migrate AppImage build to use modern/maintained appimagetool (#10019)
# Release 4.6.2 (Aug 25, 2025)
* General:
- changed: minrelayfee clamps from [1, 50] to [0.1, 50] sat/vbyte (#10096)
- new: add support for "mutinynet" signet test network (#10134)
- new: network: don't request same tx from server that we just broadcast to it (#10111)
- new: logging: add config.LOGS_MAX_TOTAL_SIZE_BYTES: to limit size on disk (#10159)
* QML GUI (Android):
- fix: cannot open keystore-encryption-only wallets (#10171)
- fix: wizard: restoring from seed broken if already opened a wallet (#10117)
- fix: handle invoice validation errors on save (#10122)
- fix: sweep: handle network errors gracefully (#10108)
- fix: sweep: handle unexpected script_types (#10145)
* Qt GUI (desktop):
- fix: wizard: hardware device: handle missing xpub (#10109)
- fix: wizard: enable-keystore for bip39 seeds and hw devices (#10123)
* Lightning:
- fix: slow down peers sending too much gossip, and other rate-limits (#10153)
* Submarine swaps: several bug fixes and improved reliability.
* CLI/RPC:
- changed: onchain_history: add back from_height/to_height params (#10119)
- changed: reverse_swap: new mandatory parameter 'prepayment' (#10165)
- new: get_submarine_swap_providers: added command to fetch swap providers (#10158)
* Plugins:
- Nostr Cosigner: fix: don't allow saving tx without txid (#10128)
# Release 4.6.1 (Aug 5, 2025)
* QML GUI (Android):
- fix: QR scanner crashes due to null/orphaned View in hierarchy (#10071)
- fix: creating a tx with a pre-segwit watchonly wallet (#10042)
* CLI/RPC:
- fix several bugs related to new hold_invoice APIs. This required
minor breaking changes in the new APIs. (#10059, #10082)
- add max_cltv, max_fee_msat parameters to lnpay command (#10067)
* Hardware wallets:
- bitbox02: bump required and bundled library to 7.0.0 (#10040)
This should add support for the new BitBox02 "Nova" devices.
* General:
- rework crash reporter (#10052)
- show additional confirmation popup on clicking "Send"
- remove the "Never" button and the corresponding config option.
The crash reporter is now always shown on uncaught exceptions.
This unifies some code paths: the crash-reporter-disabled case
was untested and buggy.
- don't show reporter multiple times for the "same" exception
- new: network: parallelize block-header-chunks downloads (#10033)
* Lightning:
- wallet: don't spend reserve utxo to create new reserve utxo (#10091)
* various UI fixes (#10060, #10062, #10081, ...)
# Release 4.6.0 (July 16, 2025)
* A 'Terms of Use' screen was added to the install wizard. While the
licence remains unchanged, we ask users to agree with the fact that
we are not a custodial service or a money transmitter. The Terms of
Use screen also makes it clear that all issues are to be resolved
in public, and that there is no user support via private channels.
* Nostr support: (using new dependency: electrum-aionostr)
Electrum now uses Nostr in the context of submarine swaps,
and in several plugins. Electrum will not connect to Nostr
by default, only if required.
* Submarine swaps over Nostr: The Electrum client will connect to
Nostr in order to discover submarine swap providers, and to perform
related RPCs. This means that:
- Anyone can become a swap provider (you need to run an Electrum
daemon with the 'swapserver' plugin). Submarine swap providers
advertise their fees and their liquidity on Nostr.
See https://electrum.readthedocs.io/en/latest/swapserver.html
for set-up documentation.
- Submarine swap providers do not need to provide an HTTP
endpoint, since RPCs are performed via Nostr. They also do not
need to have public lightning channels.
- Because a decentralized service needs to be trustless, the
option to perform zero-confirmation swaps has been removed from
Electrum.
Note that Electrum connections to Nostr relays are only initiated
when the user uses the swap service, and the nostr public key used
by the client is ephemeral. In contrast, swap providers use a
persisted identity.
* Third-party plugins:
- Electrum supports the installation of plugins distributed by
third-parties as ZIP files. While it has long been possible to
install third-party plugins when running Electrum from python
sources, the same is now possible when using desktop binaries
(Windows, MacOS, Linux). Third-party plugins are installed as ZIP
files in the user's electrum data directory.
- In order to prevent plugin installation by malware, third-party
plugins can only be enabled if the user enters a plugin
authorization password (distinct from the wallet password).
Setting up that plugin authorization password requires
administrator permissions on the local machine; a
password-derived public key must be written in the system.
* Lightning:
- Anchor channels (#9264): Newly created channels use
anchor commitments by default. Since sweeping outputs from anchor
channels may require external UTXOs, lightning can no longer be
enabled in wallets that do not have a software keystore (hardware
wallets, watching-only wallets). Existing wallets that are in that
situation cannot create new channels.
- wallets with anchor channels must always have utxos available (#9536)
- support added for onion messages (only CLI for now) (#9039)
- lots of fixes and improvements (#8857, #8547, #9700, #9083, ...)
* Qt Desktop GUI:
- migrate from Qt5 to Qt6 (#9189)
- new: screenshot "protection" on Windows (#9898). Inspired by Windows
Recall, by default screenshots will contain black rectangles in
place of the Electrum windows, to try to avoid leaking secret keys.
This is opt-out using a config variable.
- exposed option to connect to only a single server (--oneserver)
- Wallet file encryption:
- Non-multisig hardware wallet files can now be encrypted with
either using the hardware device or (new) a password. (#5561)
- The option to have a password-protected wallet without file
encryption has been removed from the Qt GUI. It is still possible
to create such a wallet using the command line.
- Wallet unlocking:
- Wallets can be unlocked in the Qt GUI. When a password-protected
wallet is unlocked, its password is kept in memory, and signing
transactions will not require to enter the password. The unlocked
state is rendered by the 'open lock' icon in the status bar.
- If a wallet needs to sweep anchor channel outputs using extra
UTXOs, the operations will be performed without requiring the
user password if the wallet is unlocked. If the wallet is locked,
the status bar will show a 'password required' button.
- Transaction batching: When creating a new payment, if the
output can be added to an existing mempool transaction, the 'New
transaction' window will show a drop-down menu, proposing a list of
transactions that can be batched with the current payment. This
replaces the previous 'batch' option checkbox, and gives more
control to the user.
- Keystore enabling/disabling (Qt):
- It is now possible to add a seed
to an existing watching-only wallet, or to a keystore within a
multisig wallet. Similarly, it is possible to pair a watching-only
keystore with a hardware device. These operations are performed
from the 'Wallet Information' dialog.
- Lightning address contacts:
- It is now possible to create contacts with (lnurl type) lightning
addresses as payment identifier.
- show warnings on wallet close if there are sensitive pending operations,
e.g. when in the middle of doing a swap (#9715)
- some performance improvements for large wallets (#9958, #9967, #9968)
- qr-reader: macos: add runtime requesting of camera permission (#9955)
* Accounting rules: In order to properly handle on-chain transactions
created by lightning channel force closures, we consider that funds
successfully redeemed from a script with several possible
recipients have never left the final recipient's wallet. This
avoids having to write balance changes that are cancelled
later. The corresponding addresses are rendered in the GUI as
'accounting addresses' (in orange).
* New plugins:
- Nostr Wallet Connect: This plugin allows remote control of
Electrum lightning wallets via Nostr NIP-47. (#9675)
- Nostr Cosigner: This plugin facilitates the exchange of
PSBTs between cosigners of a multisig wallet. It replaces the
former 'Cosigner pool' plugin. Instead of relying on a central
server, it uses Nostr to send/receive PSBTs. (#9261)
- Timelock Recovery: A timelock based inheritance scheme.
See timelockrecovery.com (#9589)
* CLI:
- The command line help has been improved; parameters are
documented in the same docstring as the command they belong to.
- If the --wallet parameter passed to a command is a simple filename,
it is now interpreted as relative to the users wallets directory,
rather than to the current working directory.
- Plugins may add extra commands to the CLI. Plugin commands must
be prefixed with the plugin's internal name.
- Support for hold invoices.
- new commands:
- listconfig, helpconfig, unsetconfig
- onchain_capital_gains (was previously a field of onchain_history)
- {add,settle,cancel,check}_hold_invoice
- send_onion_message, get_blinded_path_via
- wait_for_sync
* General:
- Mitigate against dust attacks; Add option to avoid spending from
used addresses. (#9636)
- Restrict process memory access on Linux. (#9749)
- locale: syntax-check i18n translations at runtime. Malformed translation
strings are now less likely to cause errors: instead we fallback to the
original English string (#10011)
- fix: would sometimes hang on startup if system clock jumped backwards (#9802)
* QML GUI (Android):
- "Sweep key" feature ported to mobile
- Estimate amount when Max is checked
- exposed option to connect to only a single server (--oneserver)
* Android:
- replace QR code scanning library to make scanning fun again (#9983)
- properly ask for (notification) OS permission access. (#9682)
- add option to prevent the app touching the screen brightness (#9321)
* Electrum protocol: add padding and some noise to messages (#9875)
* Hardware wallets:
- Coldcard: add feature to upload multisig wallet configuration to Coldcard via USB.
- KeepKey: we now vendor our fork of keepkeylib,
instead of using the unmaintained upstream as an external dependency (#9650)
- Ledger:
- rm support for "HW.1" and "Nano" (non-S) devices (#9652)
- rm dependency: btchip-python (#9370)
* Builds/binaries:
- new minimum OS requirements:
- Windows: x86_64, Windows 10 (1809)
note: 32-bit Windows is no longer supported.
- macOS: 11 "Big Sur"
- Linux AppImage: x86_64, glibc 2.31 ("debian 11"-equivalent)
* Dependencies:
- the minimum required python version was increased: 3.8->3.10 (#9418)
- new first-party dep: electrum-aionostr
- forked the seemingly unmaintained davestgermain/aionostr library
- new first-party dep: electrum-ecc
- split out our existing libsecp256k1 python bindings into
this separate package
# Release 4.5.8 (Oct 23, 2024)
* Qt Desktop GUI:
- fix: regression: bump_fee and dscancel dialogs erroring (#9273)
# Release 4.5.7 (Oct 21, 2024)
* General:
- new: add new historical exchange rate providers: Bitfinex and Bitstamp
- fix: wizard regression: 2fa wallet setup erroring (#9253)
- fix: python 3.13 compat: could not connect to some self-signed electrum
servers with weird TLS certs. As workaround, set pre-3.13 behaviour (#9258)
* Lightning:
- fix: send update_fee right away after channel_reestablish (3a465593)
This fixes a race that can result in a force-closure if we try sending
a payment very soon after reestablishing the channel.
* Qt Desktop GUI:
- fix: show fee warnings also in the transaction dialog (c4fe2796)
# Release 4.5.6 (Oct 16, 2024)
* General:
- new: add support for testnet4 (#9197)
- fix: wizard: allow passphrase for some '2fa' seeds (#9088)
- fix: trustedcoin wallet wizard continuation if file has keystore-only encryption (#9237)
- fix: trustedcoin: sanitize error messages coming from 2fa server
- fix: new wizard did not set keystore password if storage was not encrypted (#9147)
- changed: set stricter UNIX permissions for log files (fa8595b1)
* QML GUI (Android):
- new: show seed passphrase in WalletDetails (#9204)
- new: set max screen brightness when displaying QR codes (79c08536)
- fix: crash due to ConcurrentModificationException (450b9a0)
- fix: issue deactivating PIN when no wallet loaded (#8366)
- fix: only allow Channel Backup import on Lightning-enabled wallets (8d9bcda)
* Qt Desktop GUI:
- fix: scanning multi (privkeys, addresses) from QR (4dc64e4)
* Hardware wallets:
- ColdCard: new: export multisig wallet to coldcard over USB (#7682)
- Trezor:
- new: add support for new device "Safe 5" (#9171)
- update: fix compat with and bump pinned library to 0.13.9 (#9141)
- Ledger:
- new: add support for new device "Flex" (#9179)
- update: bump pinned library to 0.3.0, raise max lib to <0.4 (719292f8)
- Jade: update: bump library to 1.0.31 (9a84bb32)
* CLI/RPC:
- changed: require wallet password for lnpay and similar commands (#9236)
(This is in addition to the wallet needing to be loaded,
and requiring read access to the config file)
* Builds/binaries:
- changed: include unit tests in tarballs (#9207)
- android:
- changed: set target_sdk_version to 34 (2917fde5)
- update: bump python version (3.8->3.10) (08127a60)
- work towards F-Droid inclusion:
- reproducible apks: strip file path prefix from .pyc files (6ebdbf04)
- add fastlane metadata for f-droid (#9211)
- change versionCode calculation (#9221)
- build.gradle: set android.dependenciesInfo.includeInApk=false (af18df10)
- contrib/release_www.sh: put android versionCode in "version" file (#9233)
# Release 4.5.5 (May 30, 2024)
* General:
- fix: timeout error shadowed by aiorpcx cancellation bug (#8954)
- changed: Fiat exchange rates: do not overwrite the locally saved historical
data. Instead, merge old and new data (a2fb70d6). This also ~fixes the
CoinGecko historical API by only asking for the last 365 days.
- update: support latest revision of SLIP-39 mnemonic spec (to restore) (#9059)
* Lightning:
- new: unify max fee bounds for payments, make it configurable (#9041)
- changed: trampoline fees: instead of hardcoded list, use
exponential search, capped by configurable budget (#9033)
- fix: opening new channels with peer that has .onion address (#9002)
* Dependencies:
- remove bitstring (#9020)
* QML GUI (Android):
- new: add tx options to ConfirmTxDialog, RbfBumpFeeDialog (#8909)
- various UI fixes (#9018, 472a65eb)
* Qt Desktop GUI:
- fix: save notes whenever modified (#8951)
- fix: offline 2fa wallet creation failing in some cases (#9037)
- various UI fixes (#8962, #8874, #9012, 1047200a, #9058)
* Hardware wallets:
- Bitbox02: fix: call pairing dialog when necessary (#8971)
- Jade: update: bump library to 1.0.29 (#9007)
* Binaries:
- new: add AppArmor profiles for tarball and AppImage (#9003)
# Release 4.5.4 (March 14, 2024)
* General:
- fix: failing WalletDB upgrade(58) in 4.5.3 (#8913), for wallets with
partial txs saved into the history as local txs
* Lightning:
- changed: use longer final_cltv_delta for client-normal-swap, to
give more time for user to come back online while doing the swap (#8940)
- changed: create trampoline onions even when directly paying
a trampoline forwarder node (777c2ffb)
* Hardware wallets:
- Trezor:
- fix: allow adding SLIP-19 ownership proofs to complete inputs (#8910)
* Plugins:
- fix: a race in swapserver when handling server-normal-swaps (#8825)
# Release 4.5.3 (February 23, 2024)
* General:
- changed: label tx sizes as "vbytes", and feerates as "sat/vbyte" (#8864)
- fix: wizard regression not able to use HWW as cosigner for new wallets (643fbec)
- fix: onchain invoice paid detection broken if jsonpatch enabled (#8842)
- fix: program not starting because of bad "proxy" config value (#8837)
- fix: wizard: don't log sensitive values: replace blacklist with whitelist (638fdf11)
* Qt Desktop GUI:
- new: basic "add server as bookmark" functionality (#8865)
- fix: potential race condition in wizard page construction (c78a90a)
- fix: don't use lightning invoice when user specifies MAX amount (#8900)
- various UI fixes (#8874, 2882c4b, #8889, 66af6e6)
* QML GUI (Android):
- fix potential concurrency issue loading wallet (#8355)
- fix: wizard: fails to restore from 2fa seed: KeyError: 'x1' (#8861)
- various UI fixes (50a53aa, 0a6b2d5, #8782, 6738e1e, c0b8927, 016e500, #8898)
* Hardware wallets:
- Trezor:
- new: support SLIP-19 ownership proofs, for trezor-based Standard_Wallets (#8871)
- fix: regression in sign_transaction for trezor one for multisig (#8813)
* CLI/RPC:
- changed: nicer error messages and error-passing (#8888)
* Lightning:
- fix: timing issue in lnpeer.reestablish_channel, for replaying unacked updates (79d88dcb)
# Release 4.5.2 (January 20, 2024)
* Qt Desktop GUI:
- fix crash during startup/wizard-open (#8833)
# Release 4.5.1 (January 19, 2024)
* Lightning:
- fix: MPP regression when using gossip that made paying small invoices fail (95c55c542)
- fix: better handle dataloss (#8814)
- allow manually requesting force-close in WE_ARE_TOXIC state
- fix some timing issues
* General:
- localization: never translate CLI/RPC (0e5a1380)
- localization: simplify how default language is chosen (0e5a1380)
* QML GUI (Android):
- bump min required android version from android 5.0 to 6.0 (#8761)
(older versions have not been working in practice since at least 4.4.0)
- properly refresh history if addresses are deleted from imported wallets (#8782)
- fix crash when LNURLp is scanned/pasted (#8822)
- fix crash for new wallets having cosigner using hww #8808)
- fix crash in finalizer when txid is undefined (#8807)
- various UI fixes (291f0ce, 3d9996a, ec81f00)
* Qt Desktop GUI:
- also support unfinished wallets when opened through File>Open (#8809)
- fix handler for OpenFileEventFilter (6a28ef5)
# Release 4.5.0 (January 12, 2024)
* General:
- remove SSL options from config (012ce1c)
- make number of logfiles to keep configurable (5e8b14f)
- refactored SimpleConfig and added ConfigVars (#8454)
- incremental writes of wallet file (#8493)
- add warnings and prompt users when signing txs with non-default sighashes (#8687)
- refactored bip21/bolt11/lnurl/etc-handling into PaymentIdentifiers (#8462)
- add option to merge duplicate outputs (#8474)
- fix: consider bip21 URIs as invalid if they contain unknown req-* param (#8781)
* Lightning:
- fix BOLT-04 "MUST set `short_channel_id` to the `short_channel_id` used by the incoming onion" (ca93af2)
- add support for hold invoices (1acf426)
- add support for bundled payments (c4eb7d8)
- various MPP improvements (#7987, ..)
- support large channels (40f2087)
- new flow for normal submarine swaps (fd10ae3)
- the client now uses hold invoices, just like the server
- the client waits until HTLCs are received before going on-chain
- the user may cancel the swaps during that waiting time
- don't create invoice with duplicate route hints (a3997f8)
- don't set channel OPEN before channel_ready has been both sent and received (#8641)
- if trampoline is enabled, do not add non-trampoline nodes to invoices (120faa4)
* QML GUI (Android):
- port to Qt6 (#8545)
- fix regression for lnurl-pay (#8585)
- fix invoice amount bounds check (#8582)
- fix places where text was rendered off-screen for certain translations (#8611)
- fix lnworker undefined when node alias requested (#8635)
- fix BIP39 cosigner script type must be same as primary (8cd95f1)
- fix: never use current fiat exchange rate for old historical amounts (#8788)
- better handle android back-gesture (#8464)
- new: show private key in address details (016b5eb)
- new: show tx inputs in TxDetails and other dialogs (#8772)
- new: label sync plugin toggle (b6863b4)
- fix: properly suggest paying BOLT11 invoice onchain if insufficient balance (0a80460)
- new: message sign & verify (e5e1e46)
- new: allow never expiring payment requests (#8631)
- new: add coins/UTXOs to addresses list, add filters (cf91d2e)
- new: delete addresses from imported wallet (#8675)
- new: add support for lightning address and openalias (03dd38b)
- new: add setting to allow screenshots everywhere (0dae1733)
- simplify welcome page for first-start network settings (#8737)
- various UI fixes (b846eab, #8634, 9ed5f7b, 941f425, b20a4b9, af61b9d, 0fb47c8, 2995bc8, ..)
* Qt Desktop GUI:
- port wizard to new implementation
- fix fiat balance sorting in address list window (#8469, #8478)
- remove thousands separator when copying numbers to clipboard (#8479)
- new: option to use extra trampoline for legacy payments (b2053c6)
- new: send change to lightning option for on-chain payments (649ce97)
- new: notes tab for saving text in the (encrypted) wallet file (d691aa07)
- simplify welcome page for first-start network settings (#8737)
- various UI fixes (#8587, #6526, ..)
* Hardware wallets:
- Trezor: allow multiple change outputs (#3920)
- Trezor: support external pre-signed inputs (#8324)
- Bitbox02: update to 6.2.0 (#8459)
* Plugins:
- new: swapserver plugin (#8489)
* Builds/binaries:
- update bundled zbar, for security fixes (#8805)
# Release 4.4.6 (August 18, 2023) (security update)
* Lightning:
- security fix: multiple lightning-related security issues have
been fixed. See disclosures:
- https://github.com/spesmilo/electrum/security/advisories/GHSA-9gpc-prj9-89x7
- https://github.com/spesmilo/electrum/security/advisories/GHSA-8r85-vp7r-hjxf
- fix: cannot sweep from channel after local-force-close, if using
imported channel backup (#8536). Fixing this required adding a
new field (local_payment_pubkey) to the channel backup
import/export format and bumping its version number
(v0->v1). Both v0 and v1 can be imported, and we only export v1
backups. When you force close a channel, the GUI will prompt you
to save a backup. In that case, you must export the backup using
the updated Electrum, and not rely on a backup made with an older
release of Electrum. Note that if you request a force close from
the remote node or co-op close, you do not need to save a channel
backup.
- fix: we would sometimes attempt sending MPP even if not supported
by the invoice (2cf6173c)
* QML GUI:
- fix lnurl-pay when config.BTC_AMOUNTS_ADD_THOUSANDS_SEP is True
(5b4df759)
* Hardware wallets:
- Trezor: support longer than 9 character PIN codes (#8526)
- Jade: support more custom-built DIY Jade devices (#8546)
* Builds/binaries:
- include AppStream metainfo.xml in tarballs (#8501)
* fix: exceptions in some callbacks got lost and not logged (3e6580b9)
# Release 4.4.5 (June 20, 2023)
* Hardware wallets:
- jade: fix regression in sign_transaction (#8463)
* Lightning:
- fix "rebalance_channels" function (#8468)
* enforce that we run with python asserts enabled,
regardless of platform (d1c88108)
# Release 4.4.4 (May 31, 2023)
* QML GUI:
- fix creating multisig wallets involving BIP39 seeds (#8432)
- fix "cannot scroll to open a lightning channel" (#8446)
- wizard: "confirm seed" screen to normalize whitespaces (#8442)
- fix assert on address details screen (#8420)
* Qt GUI:
- better handle some expected errors in SwapDialog (#8430)
* libsecp256k1: bump bundled version to 0.3.2 (10574bb1)
# Release 4.4.3 (May 11, 2023)
* Intentionally break multisig wallets that have heterogeneous master
keys. Versions 4.4.0 to 4.4.2 of Electrum for Android did not check
that master keys used the same script type. This may have resulted
in the creation of multisig wallets that cannot be spent from
with any existing version of Electrum. It is not sure whether any
users are affected by this; if there are any, we will publish
instructions on how to spend those coins (#8417, #8418).
* Qt GUI:
- handle expected errors in DSCancelDialog (#8390)
- persist addresses tab toolbar "show/hide" state (b40a608b)
* QML GUI:
- implement bip39 account detection (0e0c7980)
- add share toolbutton for outputs in TxDetails (#8410)
* Hardware wallets:
- Ledger:
- fix old bitcoin app support (<2.1): "no sig for ..." (#8365)
- bump req ledger-bitcoin (0.2.0+), adapt to API change (30204991)
* Lightning:
- limit max feature bit we accept to 10_000 (#8403)
- do not disconnect on "warning" messages (6fade55d)
* fix wallet.get_tx_parents for chain of unconf txs (#8391)
* locale: translate more strings when using "default" lang (a0c43573)
* wallet: persist frozen state of addresses to disk right away (#8389)
# Release 4.4.2 (May 4, 2023)
* Qt GUI:
- fix undefined var check in swap_dialog (#8341)
- really fix "recursion depth exceeded" for utxo privacy analysis (#8315)
* QML GUI:
- fix signing txs for 2fa wallets (#8368)
- fix for wallets with encrypted-keystore but unencrypted-storage (#8374)
- properly delete wizard components after use (#8357)
- avoid entering loadWallet if daemon is already busy loading (#8355)
- no auto capitalization on import and master key text fields (5600375d)
- remove Qt virtual keyboard and add Seedkeyboard for seed entry (#8371, #8352)
- add runtime toggling of android SECURE_FLAG, to allow screenshots (#8351)
- restrict cases where server is shown "lagging" (53d61c01)
* fix hardened char "h" vs "'" needed for some hw wallets (#8364, 499f5153)
* fix digitalbitbox(1) support (22b8c4e3)
* fix wrong type for "history_rates" config option (#8367)
* fix issues with wallet.get_tx_parents (a1bfea61, 56fa8325)
# Release 4.4.1 (April 27, 2023)
* Qt GUI:
- fix sweeping (#8340)
- fix send tab input_qr_from_camera (#8342)
- fix crash reporter showing if send fails on typical errors (#8312)
- bumpfee: disallow targeting an abs fee. only allow feerate (#8318)
* QML GUI:
- fix offline-signing or co-signing pre-segwit txs (#8319)
- add option to show onchain address in ReceiveDetailsDialog (#8331)
- fix strings unique to QML did not get localized/translated (#8323)
- allow paying bip21 uri onchain that has both onchain and bolt11
if we cannot pay on LN (#8334, 312e50e9)
- virtual keyboard: make buttons somewhat larger (75e65c5c)
- fix(?) Android crash with some OS-accessibility settings (#8344)
- fix channelopener.connectStr qr scan popping under (#8335)
- fix restoring from old mpk (watchonly for "old" seeds) (#8356)
* libsecp256k1: add runtime support for 0.3.x, bump bundled to 0.3.1
* forbid paying to "http:" lnurls (enforce https or .onion) (1b5c7d46)
* fix wallet.bump_fee "decrease payment" erroring on too high target
fee rate (#8316)
* fix performance regressions in tx logic (ee521545, 910832c1)
* fix "recursion depth exceeded" for utxo privacy analysis (#8315)
# Release 4.4.0 (April 18, 2023)
* New Android app, using QML instead of Kivy
- Using Qt 5.15.7, PyQt 5.15.9
- This release still on python3.8
- Feature parity with Kivy
- Android Back button used throughout, for cancel/close/back
- Note: two topbar menus; tap wallet name for wallet menu, tap
network orb for application menu
- Note: long-press Receive/Send for list of payment requests/invoices
* Qt GUI improvements
- New onchain transaction creation flow, with configurable preview
- Various options have been moved to toolbars, where their effect
can be more directly observed.
* Privacy features:
- lightning: support for option scid_alias.
- Qt GUI: UTXO privacy analysis: this dialog displays all the
wallet transactions that are either parent of a UTXO, or can be
related to it through address reuse (Note that in the case of
address reuse, it does not display children transactions.)
- Coins tab: New menu that lets users easily spend a selection
of UTXOs into a new channel, or into a submarine swap (Qt GUI).
* Internal:
- Lightning invoices are regenerated every time routing hints are
deprecated due to liquidity changes.
- Script descriptors are used internally to sign transactions.
# Release 4.3.4 - Copyright is Dubious (January 26, 2023)
* Lightning:
- make sending trampoline payments more reliable (5251e7f8)
- use different trampoline feature bits than eclair (#8141)
* invoice-handling: fix get_request_by_addr incorrectly mapping
addresses to request ids when an address was reused (#8113)
* fix a deadlock in wallet.py (52e2da3a)
* CLI: detect if daemon is already running (c7e2125f)
* add an AppStream metainfo.xml file for Linux packagers (#8149)
* payserver plugin:
-replaced vendored qrcode lib
-added tabs for on-chain and lightning invoices
-revamped html and javascript
# Release 4.3.3 - (January 3, 2023)
* Lightning:
- fix handling failed HTLCs in gossip-based routing (#7995)
- fix LN cooperative-chan-close to witness v1 addr (#8012)
* PSBTs:
- never put ypub/zpub in psbts, only plain xpubs (#8036)
- for witness v0 txins, put both UTXO and WIT_UTXO in psbt (#8039)
* Hardware wallets:
- Trezor: optimize signing speed by not serializing tx (#8058)
- Ledger:
- modify plugin to support new bitcoin app v2.1.0 (#8041),
- added a deprecation warning when using Ledger HW.1 devices.
Ledger itself stopped supporting HW.1 some years ago, and it is
becoming a maintenance burden for us to keep supporting it.
Please migrate away from these devices. Support will be removed
in a future release.
* Binaries:
- tighten build system to only use source pkgs in more places
(#7999, #8000)
- Windows:
- use debian makensis instead of upstream windows exe (#8057)
- stop using debian sid, build missing dep instead (98d29cba)
- AppImage: fix failing to run on certain systems (#8011)
* commands:
- getinfo() to show if running in testnet mode (#8044)
- add a "convert_currency" command (for fiat FX rate) (#8091)
* Qt wizard: fix QR code not shown during 2fa wallet creation (#8071)
* rework Tor-socks-proxy detection to reduce Tor-log-spam (#7317)
* Android: add setting to enable debug logs (#7409)
* fix payserver (merchant) js for electrum 4.3 invoice api (0fc90e07)
* bip21: more robust handling of URIs that include a "lightning" key
(ac1d53f0, 2fd762c3, #8047)
# Release 4.3.2 - (September 26, 2022)
* When creating new requests, reuse addresses of expired requests
(fixes #7927).
* Index requests by ID instead of receiving address. This affects the
following commands: get_request, get_invoice, list_requests,
list_invoices, delete_request, delete_invoice
* Trampoline routing: remember routes that have failed. Try other
routes instead of systematically raising tampoline fees.
* Fix sweep to_local output from channel backup (#7959)
* Harden build script for macOS binary: avoid using
precompiled wheels from PyPI for most packages (#7918)
* The Windows/AppImage/Android binaries are now built on debian using
the snapshot.debian.org archive instead of ubuntu. This should help
with historical reproducibility. (#7926)
# Release 4.3.1 - (August 17, 2022)
* build: we now also distribute a "source-only"
Linux-packager-friendly tarball (d0de44a7, #7594), in addition
to the current "normal" tarball. The "source-only" tarball excludes
compiled locale files, generated protobuf files, and does not
vendor our runtime python dependencies (the packages/ folder).
* fix os.chmod when running in tmpfs on Linux (#7681)
* (Qt GUI) some improvements for high-DPI monitors (38881129)
* bring kivy request dialog more in-line with Qt (#7929)
* rm support of "legacy" (without static_remotekey) LN channels.
Opening these channels were never supported in a release version,
only during development prior to the first lightning-capable
release. Wallets with such channels will have to close them.
(1f403d1c, 7b8e257e)
* Qt: fix duplication of some OS notifications on onchain txs (#7943)
* fix multiple recent regressions:
- handle NotEnoughFunds when trying to pay LN invoice (#7920)
- handle NotEnoughFunds when trying to open LN channel (#7921)
- labels of payment requests were not propagated to
history/addresses (#7919)
- better default labels of outgoing txs (#7942)
- kivy: dust-valued requests could not be created for LN (#7928)
- when closing LN channels, future (timelocked) txs were not
shown in history (#7930)
- kivy: fix deleting "local" tx from history (#7933)
- kivy: fix paying amountless LN invoice (#7935)
- Qt: better handle unparseable URIs (#7941)
# Release 4.3.0 - (August 5, 2022)
* This version introduces a set of UI modifications that simplify the
use of Lightning. The idea is to abstract payments from the payment
layer, and to suggest solutions when a lightning payment is hindered
by liquidity issues.
- Invoice unification: on-chain and lightning invoices have been
merged into a unique type of invoice, and the GUI has a single
'create request' button. Unified invoices contain both a
lightning invoice and an onchain fallback address.
- The receive tab of the GUI can display, for each payment
request, a lightning invoice, a BIP21 URI, or an onchain
address. If the request is paid off-chain, the associated
on-chain address will be recycled in subsequent requests.
- The receive tab displays whether a payment can be received using
Lightning, given the current channel liquidity. If a payment
cannot be received, but may be received after a channel
rebalance or a submarine swap, the GUI will propose such an
operation.
- Similarly, if channels do not have enough liquidity to pay a
lightning invoice, the GUI will suggest available alternatives:
rebalance existing channels, open a new channel, perform a
submarine swap, or pay to the provided onchain fallback address.
- A single balance is shown in the GUI. A pie chart reflects how
that balance is distributed (on-chain, lightning, unconfirmed,
frozen, etc).
- The semantics of the wallet balance has been modified: only
incoming transactions are considered in the 'unconfirmed' part
of the balance. Indeed, if an outgoing transaction does not get
mined, that is not going to decrease the wallet balance. Thus,
change outputs of outgoing transactions are not subtracted from
the confirmed balance. (Before this change, the arithmetic
values of both incoming and outgoing transactions were added to
the unconfirmed balance, and could potentially cancel
each other.)
* In addition, the following new features are worth noting:
- support for the Blockstream Jade hardware wallet (#7633)
- support for LNURL-pay (LUD-06) (#7839)
- updated trampoline feature bit in invoices (#7801)
- the claim transactions of reverse swaps are not broadcast until
the parent transaction is confirmed. This can be overridden by
manually broadcasting the local transaction.
- the fee of submarine swap transactions can be bumped (#7724)
- better error handling for trampoline payments, which should
improve payment success rate (#7844)
- channel backups are removed automatically when the corresponding
channel is redeemed (#7513)
# Release 4.2.2 - (May 27, 2022)
* Lightning:
- watching onchain outputs: significant perf. improvements (#7781)
- enforce relative order of some msgs during chan reestablishment,
lack of which can lead to unwanted force-closures (#7830)
- fix: in case of a force-close containing incoming HTLCs, we were
redeeming all HTLCs that we know the preimage for. This might
publish the preimage of an incomplete MPP. (1a5ef554, e74e9d8e)
* Hardware wallets:
- smarter pairing during sign_transaction (238619f1)
- keepkey: fix pairing with device using a workaround (#7779)
* fix AppImage failing to run on certain systems (#7784)
* fix "Automated BIP39 recovery" not scanning change paths (#7804)
* bypass network proxy for localhost electrum server (#3126)
* security fix: remove support of "file://" URIs from BIP70 payment
requests, which could be used to trigger "open()" on arbitrary files
(see https://github.com/spesmilo/electrum/security/advisories/GHSA-4fh4-hx35-r355)
# Release 4.2.1 - (March 26, 2022)
* Binaries:
- Windows: we are dropping support for Windows 7. (#7728)
Version 4.2.0 already unintentionally broke compatibility with
Win7 and there is no easy way to restore and maintain support.
Existing users can keep using version 4.1.5 for now, but should
consider upgrading or changing their OS.
Win8.1 still works but only Win10 is regularly tested.
- bump bundled Python version (win, mac, appimage) to 3.9.11,
(android) to 3.8.13 (1bb7ef92, #7721)
(note these include a fix to an openssl DOS-vector CVE-2022-0778)
- windows: bump pyinstaller to 4.10 and wine to 7.0 (#7721)
* Kivy GUI:
- fix "Child Pays For Parent" not working on Android (#7723)
- revert to defaulting the UI language to English (25fee6a6)
* Qt GUI:
- macOS: fix opening "Preferences" segfaulting for some (#7725)
- more resilient startup: better error-handling and fallback (#7447)
* Library:
- fix LN error/warning message-handling, and fix regression that
errors during channel-open were not properly shown in GUI (a92dede4)
- during LN chan open, do not backup wallet automatically (#7733)
- Imported wallets: fix delete_address rm-ing too many txs (#7587)
- fix potential deadlock in wallet.py (d3476b6b)
* Hardware wallets:
- ledger: add progress indicator to sign_transaction (#7516)
* fix the "--portable" flag for AppImage, and for pip installs (#7732)
# Release 4.2.0 - (March 16, 2022)
* The minimum python version was increased to 3.8 (#7661)
* Lightning:
- redesigned MPP splitting algorithm (#7202)
- trampoline: implement multi-trampoline MPP (#7623)
- implement option_shutdown_anysegwit, and allow dust limits
below 546 sat (#7542)
- implement option_channel_type (#7636)
- implement modern closing negotiation (#7586, #7680)
* improve support for "lightning:" URIs on all platforms (#7301)
* Qt GUI:
- add setting "show amounts with msat precision" (5891e039)
- add setting "add thousand separators to bitcoin amounts" (#7427)
* CLI/RPC:
- implement Unix sockets and make them the default (#7545, #7566)
- add "bumpfee" command (#7438)
* Kivy GUI:
- show network setup on first start before wallet creation (#7464)
- add "Child Pays For Parent" option (#7487)
- improved locale handling (22bb52d5, 7cb11ced, 4293d6ec)
* Hardware wallets:
- trezor: bump trezorlib to 0.13 (#7590)
- bitbox02: bump bitbox02 to 6.0, support send-to-taproot (#7693)
- ledger: support "Ledger Nano S Plus" (#7692)
* Library:
- added support for sighash types beside "ALL" (#7453)
- signmessage: also accept Trezor-type sigs for segwit addrs (#7668)
- network: make request timeout configurable (#7696)
- paytomany (onchain txout batching) now allows multiple max("!")
amounts with specified weights (#7492)
* Binary builds
- AppImage: changed base image from ubuntu 16.04 to 18.04 (5d0aa63a)
* migrated from Travis CI to Cirrus CI (#7431)
* Lots of other minor bugfixes and usability improvements.
# Release 4.1.5 - (July 19, 2021)
* Builds/binaries:
- macOS: the .dmg binary should now be reproducible
* Kivy/Android: fix paying bip70 invoices (regression) (90579ccf)
* fix: payment requests not saved if process is killed (6a049d99)
* Lightning: improve payment success when using trampoline (3a7f5373)
* add support for signet test network (#7282)
* Qt GUI:
- allow restoring from SLIP39 seeds (#6917)
- rework QR code scanning on Windows and macOS (#7365)
- support smaller window sizes, decrease minimums (#7385)
* GUIs: add "funded or unused" filter option to Addresses tab (#5823)
# Release 4.1.4 - (June 17, 2021)
* Kivy/Android: fix a regression where a non-LN wallet
could not open the settings (c49d6995)
* CLI/RPC: fix "close_wallet" command (#7348)
# Release 4.1.3 - (June 16, 2021)
* Builds/binaries:
- Android: the binaries (APKs) should now be reproducible (#7263)
- AppImage: fix some startup issues by including libxcb deps (#7198)
* Lightning:
- smarter LN pathfinding (if trampoline is disabled):
- estimate liquidity in channels using previous attempts (#7152)
- consider inflight HTLCs and try to route around them (#7292)
- bugfix: add more safety checks to avoid "batch RBF" feature
merging LN funding txs (#7298)
- remove HTLC value upper limit of ~42 mBTC (#7328)
- Kivy GUI: implement freezing LN channels (11bb39ee)
* imported wallets: when enabling the "Use change addresses" option,
change will now be sent to a random unused imported address. (#7330)
As before, by default, change is sent back to the "from address".
* seed generation: make sure newly created electrum seeds don't have
correct bip39 checksum by chance (#6001)
* other minor fixes
# Release 4.1.2 - (April 8, 2021)
* Qt GUI:
- fix some crashes when exiting (#6889)
- make sure pressing Ctrl-C always quits (c41cd4ae)
* Kivy GUI (Android):
- fix bug with scrollbar, again (#7155)
- 2fa wallets: fix making transactions (#7190)
- implement freezing addresses (#7178)
* Android: use more modern application launcher/icon (#7187)
# Release 4.1.1 - (April 2, 2021)
* fix Qt crash with the swap dialog
* fix Kivy bug with scrollbar (#7155)
* fix localization issues (#7158 #4621)
* fix python crash with swaps (#7160)
* other minor fixes
# Release 4.1.0 - Kangaroo (March 30, 2021)
This version is our second major release with support for the
Lightning Network. While our initial Lightning release was mostly
about implementing the protocol, this release brings features that are
specifically aimed at keeping Electrum lightweight and trustless,
while avoiding single points of failure. Most of the features listed
below are user-visible.
* The wallet creation wizard no longer asks for a seed type, and
creates segwit wallets with bech32 addresses. Older seed types can
still be created with the command line.
* Paid invoices (both incoming and outgoing) are automatically
removed from the send/receive lists of the GUI (one confirmation is
needed for onchain invoices). Once removed from the list, invoice
details can still be accessed from the transaction history. In Qt,
invoice lists have been renamed to 'Sending queue' and 'Receiving
queue'.
* Lightning:
- recoverable channels (see below)
- trampoline payments (see below)
- support multi-part-payment
- support upfront-shutdown-script
* Recoverable channels (option):
- Recovery data is added to the channel funding transaction using
an OP_RETURN. This makes it possible to recover a static backup
of the channel from the wallet seed. Please note that static
backups only allow users to request a force-close of the channel
with the remote node, so that funds not locked in HTLCs can be
recovered. This assumes that the remote node is still online, did
not lose its data, and accepts to force close the channel.
- This option is only available for standard wallets with an
Electrum seed. It is not available for hardware wallets, because
it requires a deterministic derivation of the nodeID. It is also
not available in watching-only wallets, for the same reason. If a
wallet can have recoverable channels but has an old nodeID, users
who want to use that feature need to close all their existing
channels, and to restore their wallet from seed.
- Channel recovery data uses 20 bytes (16 bytes of the remote
NodeID plus 4 magic bytes) and is encrypted so that only the
wallet that owns it can decrypt it. However, blockchain analysis
will be able to tell that the transaction was probably created by
Electrum.
- If the 'use recoverable channels' option is enabled, other nodes
cannot open a channel to Electrum.
- If a channel is force-closed, the information in the on-chain
backup is not sufficient to retrieve the funds in the to_local
output, in case the wallet is lost in a boating accident before
expiration of the CSV delay. For that reason, an additional
backup is presented to the user if they force-close a channel.
* Trampoline routing (option): Trampoline is a solution that allows
light clients to delegate path-finding on the Lightning Network, so
that they do not have to download the entire network
graph. Trampoline routing was originally proposed by Bastien
Teinturier and is used in the Phoenix wallet. Here is how
Trampoline works in Electrum:
- Trampoline is enabled by default, in order to prevent unwanted
download of the network gossip. If trampoline is disabled, the
gossip will be downloaded, regardless of the existence of
channels.
- Because there is no discovery mechanism for trampoline nodes, the
list of available trampolines is hardcoded in the client (it will
remain so until support for trampoline routing is announced in
gossip). 3 trampoline nodes are currently available on mainnet:
ACINQ, Electrum and Hodlister.
- If Trampoline is enabled:
- payments use trampoline routing.
- gossip is disabled.
- the wallet can only open channels with trampoline nodes.
- pre-existing channels with non-trampoline nodes are frozen for
sending.
- There are two types of trampoline payments: legacy and trampoline
end-to-end. Legacy payments are possible with any receiver, but
they offer less privacy than end-to-end trampoline
payments. Electrum decides whether to perform legacy or
end-to-end based on the features in the invoice:
- OPTION_TRAMPOLINE_ROUTING_OPT (bit 25) for Electrum
- OPTION_TRAMPOLINE_ROUTING_OPT_ECLAIR (bit 51) for Eclair/Phoenix
- When performing a legacy payment, Electrum will add a second
trampoline node to the route in order to protect the privacy of
the payer and payee. It will fall back to a single trampoline if
the two-trampoline strategy has failed for all trampolines.
(Note: two-trampoline payments are currently not possible if the
first trampoline is the ACINQ node, and is disabled for that
node.)
- Similar to Phoenix, the fee and CLTV delay are found by
trial-and-error. If there is a second trampoline in the route, we
use the same fee/CLTV for both. This trial-and-error is
temporary; the final specification should add fee information in
the failure messages, so that we will be able to better fine-tune
trampoline fees.
* Qt: The increase fee dialog now has advanced options, and offers
the choice between different RBF strategies.
* Watchtowers: The 'use_local_watchtower' feature is deprecated, and
it has been removed from the Qt GUI. The 'use_remote_watchtower'
setting has been renamed to 'use_watchtower'.
* Password unification (Android only): When the Android app is
started, the entered password is checked against all wallets in
the directory. If the test passes:
- all wallets are encrypted
- new wallets will use the unified password
- password updates are performed on all wallets
Whether the password is unified can be seen in the GUI: In the
'Settings' dialog, the description for the password setting is
'Change password for this wallet' if the password is not unified,
and becomes 'Change password' if password is unified.
* Submarine swaps are now available on kivy/android.
* Android PIN reset: If the password is unified, the PIN can be reset
by providing the password.
* Android: on-chain fees have been removed from the settings
dialog. Instead, the fee slider is shown to the user every time an
on-chain transaction will be performed (sending a payment, opening
a channel, initiating a submarine swap)
* BIP-0350: use bech32m for witness version 1+ addresses (4315fa43).
We have supported sending to any witness version since Electrum
3.0, using BIP-0173 (bech32) addresses. BIP-0350 makes a breaking
change in address encoding, and recommends using a new encoding
(bech32m) for sending to witness version 1 and later.
* Block explorer: allow setting a custom URL in Qt GUI (#6965)
# Release 4.0.9 - (Dec 18, 2020)
* fixes a regression introduced in 4.0.8, that prevents from
paying BIP70 invoices (#6859)
* reflect frozen channels and disconnected peers in the displayed
'can send/can receive' amounts.
# Release 4.0.8 - (Dec 17, 2020)
* fix decoding BIP21 URIs with uppercase schema (d40bedb2)
* psbt: put full derivation paths into PSBT by default (c8155129)
* invoices: allow address-reuse (#6609, #6852)
* A few other minor bugfixes.
# Release 4.0.7 - (Dec 9, 2020)
* kivy: fix open channel with 'max' amount
* kivy: fix regression introduced in last release (a9fc440)
* other minor GUI fixes
* Dependencies: as part of adapting to new dnspython (#6828),
- python-ecdsa is no longer needed at all,
- cryptography is now required (min 2.6), the user can no
longer choose between cryptography and pycryptodomex
# Release 4.0.6 - (Dec 4, 2020)
* Fix 'Max' button issue for submarine swaps button (#6770)
* Fix 'Max' button in kivy (#6169)
* Various fixes for Kivy/Android install wizard
* More robust account keypath for BitBox02 (#6766)
# Release 4.0.5 - (Nov 18, 2020)
* Fix .dmg binary hanging on recently released macOS 11 Big Sur (#6461)
* Lightning:
- bugfix: during LN channel opening, if the client crashed at the
wrong moment, the channel might not get fully persisted to disk,
and would need manual console-tinkering to recover (#6656)
- Lightning is enabled by default. Electrum will not connect to
the Lightning Network until the user opens a channel. (#6639)
- smarter node recommendation (to open channels with) (#6705)
* user interface: some minor changes that aim to improve usability
* Ledger:
- fix enumerating devices with new bitcoin app (1.5.1) (b78cbcff)
- fix compat with HW.1 (200f547a)
* A few other minor bugfixes.
# Release 4.0.4 - (Oct 15, 2020)
* PSBT: fix regression in 4.0.3 where UTXO data was not included in
QR codes (#6600)
* new feature: "Cancel tx" (#6641). The Qt/kivy GUI allows cancelling
an unconfirmed RBF tx by double-spending its inputs to self.
* Windows binary:
- fix some issues with QR scanning by building zbar ourselves (#6593)
- when using setup exe, also install a debug binary (#6603)
* Ledger: fix "The derivation path is unusual" warnings (#6512)
(needs Bitcoin app 1.4.8+ installed on device)
* A few other minor bugfixes and usability improvements.
# Release 4.0.3 - (Sep 11, 2020)
* PSBT: restore compatibility with Bitcoin Core following CVE-2020-14199:
we now allow a PSBT input to have both UTXO and WITNESS_UTXO (#6429).
(PSBTs created since 4.0.1 already contained UTXO for segwit inputs)
* Hardware wallets:
- bitbox02: better multisig UX: implement get_soft_device_id (#6386)
- coldcard: fix "show address" for multisig (#6517)
- all: run all device communication on a dedicated thread (#6561).
This should resolve some threading issues.
* new feature: "Automated BIP39 recovery" (#6219, #6155)
When restoring from a BIP39 seed, add option to scan many known
derivation paths for history, and show them to user to choose from.
* show derivation path of keystores in Qt GUI Wallet>Information (#4700)
* fix "signtransaction" RPC command (#6502)
* Dependencies: pyaes is no longer needed (#6563)
* The tar.gz source dist now bundles make_libsecp256k1.sh, to help
users getting libsecp256k1 (#6323).
* A few other minor bugfixes and usability improvements.
# Release 4.0.2 - (July 8, 2020)
- rm old corrupted non-bip70 invoices (#6345)
- other minor fixes
# Release 4.0.1 - (July 3, 2020)
* Lightning Network support (experimental)
- Our implementation of Lightning relies on Electrum servers to
query channel states. Since servers can lie about the state of a
channel, users should either use a server that they trust, or
setup a private watchtower (see below). A watchtower is also
recommended for lightning wallets that remain offline for
extended periods of time (the default CSV 'to_self_delay' is 1
week). Please note that Electrum Personal Server (EPS) cannot be
used with lightning wallets, because channels funding addresses
are arbitrary.
- Lightning funds cannot be restored from seed. Instead, users need
to create static backups of their channels. Static backups cannot
be used to perform lightning transactions, they can only be used
to trigger a remote-force-close of a channel.
- Lightning-enabled wallet files must not be copied. Instead, a
backup of the wallet can be created from the Qt menu, and it will
contain static backups of all its channels. Backups can also be
exported for each channel (e.g. via QR code), and imported in
another wallet. Since backups are encrypted with a key derived
from the wallet's xpub, they can only be imported into another
instance of the same wallet, or a watch-only version of it. The
force-close is not triggered automatically when the backup is
imported; imported backups can live inside a wallet file.
- Lightning can be enabled in the GUI (Wallet>Information) or from
the CLI (init_lightning). Lightning is currently restricted to HD
p2wpkh wallets (including watch-only and hardware wallets). The
Qt GUI, CLI/RPC, and the kivy GUI (Android) all have LN support,
with feature-richness in that order.
- LN protocol details: dataloss_protect and static_remotekey are
required; varonion and payment_secret are implemented, MPP not yet.
Channels are not announced ('private'), forwarding is disabled.
We do not serve gossip queries, only consume them.
- Submarine swaps: the GUI integrates a service that offers
atomically exchanging on-chain and lightning bitcoins for a fee.
Electrum Technologies runs a central server for this, powered by
the Boltz backend.
- Watchtowers: Electrum can run a local watchtower (GUI setting),
or it can connect to a remote watchtower. A watchtower contains
pre-signed transactions and does not need your private keys. A
local watchtower will watch your channels whenever an Electrum
instance is running, without needing access to your wallet file.
An Electrum daemon can be configured to be used as a remote
watchtower by setting 'watchtower_address', 'watchtower_user' and
'watchtower_password'.
* Partially Signed Bitcoin Transactions (PSBT, BIP-174) are supported
(#5721). The previous Electrum partial transaction format is no
longer supported, i.e. this is an incompatible change. Users should
make sure that all instances of Electrum they use to co-sign or
offline sign, are updated together.
* Hardware wallets: several fixes in general; notable changes:
- The BitBox02 is now supported (#5993)
- Multisig support for Coldcard (#5440)
- Compatibility with latest Trezor fw (#6064, #6198, #5692)
* Dependencies (see README for install instructions):
- libsecp256k1 is now required (previously optional). python-ecdsa
remains a dependency but it is now only used for DNSSEC.
- Added: either one of pycryptodomex or cryptography is now required,
mainly due to LN (previously pycryptodomex was optional, for fast AES)
- Removed: jsonrpclib-pelix, the JSON-RPC library used for CLI/daemon
* Qt GUI: several changes, notably:
- Separation between output selection and transaction finalization.
- Coin selection moved to the Coins tab, and it affects all txns,
e.g. RBF fee-bumping, LN channel opens, submarine swaps.
- Editable tx preview dialog that allows e.g. changing the locktime,
toggling RBF, and manual coinjoins.
* HTTP PayServer: The configuration of a bitcoin-accepting website
using Electrum has been simplified and requires fewer steps (see
documentation). The Payserver supports BIP70 and Lightning payments.
* Android:
- We now build two APKs, one for ARMv7 and one for ARMv8
- The kivy GUI now supports importing BIP39 seeds
- Each wallet on kivy now can have a separate generic password,
using which the wallet files are encrypted. An optional PIN,
shared among all wallets, can be added to get prompted for spends.
* The API of several CLI/RPC commands have changed, and several new
commands have been introduced (mainly for LN).
* Distributables:
- The .tar.gz source dist is now built reproducibly.
Relatedly, we no longer distribute a .zip sdist.
- The MacOS binary now conforms to macOS 10.15; it is notarized
by Apple. This required bumping the min macOS version to 10.13.
Startup times should now be faster on 10.15. (#6128, #6225)
* Transactions:
- we now grind low R for ECDSA signatures to match bitcoind (#5820)
* Lots and lots of other minor bugfixes and improvements.
# Release 3.3.8 - (July 11, 2019)
* fix some bugs with recent bump fee (RBF) improvements (#5483, #5502)
* fix #5491: watch-only wallets could not bump fee in some cases
* appimage: URLs could not be opened on some desktop environments (#5425)
* faster tx signing for segwit inputs for really large txns (#5494)
* A few other minor bugfixes and usability improvements.
# Release 3.3.7 - (July 3, 2019)
* The AppImage Linux x86_64 binary and the Windows setup.exe
(so now all Windows binaries) are now built reproducibly.
* Bump fee (RBF) improvements:
Implemented a new fee-bump strategy that can add new inputs,
so now any tx can be fee-bumped (d0a4366). The old strategy
was to decrease the value of outputs (starting with change).
We will now try the new strategy first, and only use the old
as a fallback (needed e.g. when spending "Max").
* CoinChooser improvements:
- more likely to construct txs without change (when possible)
- less likely to construct txs with really small change (e864fa5)
- will now only spend negative effective value coins when
beneficial for privacy (cb69aa8)
* fix long-standing bug that broke wallets with >65k addresses (#5366)
* Windows binaries: we now build the PyInstaller boot loader ourselves,
as this seems to reduce anti-virus false positives (1d0f679)
* Android: (fix) BIP70 payment requests could not be paid (#5376)
* Android: allow copy-pasting partial transactions from/to clipboard
* Fix a performance regression for large wallets (c6a54f0)
* Qt: fix some high DPI issues related to text fields (37809be)
* Trezor:
- allow bypassing "too old firmware" error (#5391)
- use only the Bridge to scan devices if it is available (#5420)
* hw wallets: (known issue) on Win10-1903, some hw devices
(that also have U2F functionality) can only be detected with
Administrator privileges. (see #5420 and #5437)
A workaround is to run as Admin, or for Trezor to install the Bridge.
* Several other minor bugfixes and usability improvements.
# Release 3.3.6 - (May 16, 2019)
* qt: fix crash during 2FA wallet creation (#5334)
* fix synchronizer not to keep resubscribing to addresses of
already closed wallets (e415c0d9)
* fix removing addresses/keys from imported wallets (#4481)
* kivy: fix crash when aborting 2FA wallet creation (#5333)
* kivy: fix rare crash when changing exchange rate settings (#5329)
* A few other minor bugfixes and usability improvements.
# Release 3.3.5 - (May 9, 2019)
* The logging system has been overhauled (#5296).
Logs can now also optionally be written to disk, disabled by default.
* Fix a bug in synchronizer (#5122) where client could get stuck.
Also, show the progress of history sync in the GUI. (#5319)
* fix Revealer in Windows and MacOS binaries (#5027)
* fiat rate providers:
- added CoinGecko.com and CoinCap.io
- BitcoinAverage now only provides historical exchange rates for
paying customers. Changed default provider to CoinGecko.com (#5188)
* hardware wallets:
- Ledger: Nano X is now recognized (#5140)
- KeepKey:
- device was not getting detected using Windows binary (#5165)
- support firmware 6.0.0+ (#5205)
- Trezor: implemented "seedless" mode (#5118)
* Coin Control in Qt: implemented freezing individual UTXOs
in addition to freezing addresses (#5152)
* TrustedCoin (2FA wallets):
- better error messages (#5184)
- longer signing timeout (#5221)
* Kivy:
- fix bug with local transactions (#5156)
- allow selecting fiat rate providers without historical data (#5162)
* fix CPFP: the fees already paid by the parent were not included in
the calculation, so it always overestimated (#5244)
* Testnet: there is now a warning when the client is started in
testnet mode as there were a number of reports of users getting
scammed through social engineering (#5295)
* CoinChooser: performance of creating transactions has been improved
significantly for large wallets. (d56917f4)
* Importing/sweeping WIF keys: stricter checks (#4638, #5290)
* Electrum protocol: the client's "user agent" has been changed from
"3.3.5" to "electrum/3.3.5". Other libraries connecting to servers
can consider not "spoofing" to be Electrum. (#5246)
* Several other minor bugfixes and usability improvements.
# Release 3.3.4 - (February 13, 2019)
* AppImage: we now also distribute self-contained binaries for x86_64
Linux in the form of an AppImage (#5042). The Python interpreter,
PyQt5, libsecp256k1, PyCryptodomex, zbar, hidapi/libusb (including
hardware wallet libraries) are all bundled. Note that users of
hw wallets still need to set udev rules themselves.
* hw wallets: fix a regression during transaction signing that prompts
the user too many times for confirmations (commit 2729909)
* transactions now set nVersion to 2, to mimic Bitcoin Core
* fix Qt bug that made all hw wallets unusable on Windows 8.1 (#4960)
* fix bugs in wallet creation wizard that resulted in corrupted
wallets being created in rare cases (#5082, #5057)
* fix compatibility with Qt 5.12 (#5109)
# Release 3.3.3 - (January 25, 2019)
* Do not expose users to server error messages (#4968)
* Notify users of new releases. Release announcements must be signed,
and they are verified byElectrum using a hardcoded Bitcoin address.
* Hardware wallet fixes (#4991, #4993, #5006)
* Display only QR code in QRcode Window
* Fixed code signing on MacOS
* Randomise locktime of transactions
# Release 3.3.2 - (December 21, 2018)
* Fix Qt history export bug
* Improve network timeouts
* Prepend server transaction_broadcast error messages with
explanatory message. Render error messages as plain text.
# Release 3.3.1 - (December 20, 2018)
* Qt: Fix invoices tab crash (#4941)
* Android: Minor GUI improvements
# Release 3.3.0 - Hodler's Edition (December 19, 2018)
* The network layer has been rewritten using asyncio and aiorpcx.
In addition to easier maintenance, this makes the client
more robust against misbehaving servers.
* The minimum python version was increased to 3.6
* The blockchain headers and fork handling logic has been generalized.
Clients by default now follow chain based on most work, not length.
* New wallet creation defaults to native segwit (bech32).
* Segwit 2FA: TrustedCoin now supports native segwit p2wsh
two-factor wallets.
* RBF batching (opt-in): If the wallet has an unconfirmed RBF
transaction, new payments will be added to that transaction,
instead of creating new transactions.
* MacOS: support QR code scanner in binaries.
* Android APK:
- build using Google NDK instead of Crystax NDK
- target API 28
- do not use external storage (previously for block headers)
* hardware wallets:
- Coldcard now supports spending from p2wpkh-p2sh,
fixed p2pkh signing for fw 1.1.0
- Archos Safe-T mini: fix #4726 signing issue
- KeepKey: full segwit support
- Trezor: refactoring and compat with python-trezor 0.11
- Digital BitBox: support firmware v5.0.0
* fix bitcoin URI handling when app already running (#4796)
* Qt listings rewritten:
the History tab now uses QAbstractItemModel, the other tabs use
QStandardItemModel. Performance should be better for large wallets.
* Several other minor bugfixes and usability improvements.
# Release 3.2.4 - (December 30, 2018)
* backport anti-phishing measures from master
# Release 3.2.3 - (September 3, 2018)
* hardware wallet: the Safe-T mini from Archos is now supported.
* hardware wallet: the Coldcard from Coinkite is now supported.
* BIP39 seeds: if a seed extension (aka passphrase) contained
multiple consecutive whitespaces or leading/trailing whitespaces
then the derived addresses were not following spec. This has been
fixed, and affected should move their coins. The wizard will show a
warning in this case. (#4566)
* Revealer: the PRNG used has been changed (#4649)
* fix Linux distributables: 'typing' was not bundled, needed for python 3.4
* fix #4626: fix spending from segwit multisig wallets involving a Trezor
cosigner when using a custom derivation path
* fix #4491: on Android, if user had set "uBTC" as base unit, app crashed
* fix #4497: on Android, paying bip70 invoices from cold start did not work
* Several other minor bugfixes and usability improvements.
# Release 3.2.2 - (July 2nd, 2018)
* Fix DNS resolution on Windows
* Fix websocket bug in daemon
# Release 3.2.1 - (July 1st, 2018)
* fix Windows binaries: due to build process changes, the locale files
were not included; the language could not be changed from English
* fix Linux distributables: wordlists were not included (#4475)
# Release 3.2.0 - Satoshi's Vision (June 30, 2018)
* If present, libsecp256k1 is used to speed up elliptic curve
operations. The library is bundled in the Windows, MacOS, and
Android binaries. On Linux, it needs to be installed separately.
* Two-factor authentication is available on Android. Note that this
will only provide additional security if one time passwords are
generated on a separate device.
* Semi-automated crash reporting is implemented for Android.
* Transactions that are dropped from the mempool are kept in the
wallet as 'local', and can be rebroadcast. Previously these
transactions were deleted from the wallet.
* The scriptSig and witness part of transaction inputs are no longer
parsed, unless actually needed. The wallet will no longer display
'from' addresses corresponding to transaction inputs, except for
its own inputs.
* The partial transaction format has been incompatibly changed. This
was needed as for partial transactions the scriptSig/witness has to
be parsed, but for signed transactions we did not want to do the
parsing. Users should make sure that all instances of Electrum
they use to co-sign or offline sign, are updated together.
* Signing of partial transactions created with online imported
addresses wallets now supports significantly more
setups. Previously only online p2pkh address + offline WIF was
supported. Now the following setups are all supported:
- online {p2pkh, p2wpkh-p2sh, p2wpkh} address + offline WIF,
- online {p2pkh, p2wpkh-p2sh, p2wpkh} address + offline seed/xprv,
- online {p2sh, p2wsh-p2sh, p2wsh}-multisig address + offline seeds/xprvs
(potentially distributed among several different machines)
Note that for the online address + offline HD secret case, you need
the offline wallet to recognize the address (i.e. within gap
limit). Having an xpub on the online machine is still the
recommended setup, as this allows the online machine to generate
new addresses on demand.
* Segwit multisig for bip39 and hardware wallets is now enabled.
(both p2wsh-p2sh and native p2wsh)
* Ledger: offline signing for segwit inputs (#3302) This has already
worked for Trezor and Digital Bitbox. Offline segwit signing can be
combined with online imported addresses wallets.
* Added Revealer plugin. ( https://revealer.cc ) Revealer is a seed
phrase back-up solution. It allows you to create a cold, analog,
multi-factor backup of your wallet seeds, or of any arbitrary
secret. The Revealer utilizes a transparent plastic visual one time
pad.
* Fractional fee rates: the Qt GUI now displays fee rates with 0.1
sat/byte precision, and also allows this same resolution in the
Send tab.
* Hardware wallets: a "show address" button is now displayed in the
Receive tab of the Qt GUI. (#4316)
* Trezor One: implemented advanced/matrix recovery (#4329)
* Qt/Kivy: added "sat" as optional base unit.
* Kivy GUI: significant performance improvements when displaying
history and address list of large wallets; and transaction dialog
of large transactions.
* Windows: use dnspython to resolve dns instead of socket.getaddrinfo
(#4422)
* Importing minikeys: use uncompressed pubkey instead of compressed
(#4384)
* SPV proofs: check inner nodes not to be valid transactions (#4436)
* Qt GUI: there is now an optional "dark" theme (#4461)
* Several other minor bugfixes and usability improvements.
# Release 3.1.3 - (April 16, 2018)
* Qt GUI: seed word auto-complete during restore
* Android: fix some crashes
* performance improvements (wallet, and Qt GUI)
* hardware wallets: show debug message during device scan
* Digital Bitbox: enabled BIP84 (p2wpkh) wallet creation
* add regtest support (via --regtest flag)
* other minor bugfixes and usability improvements
# Release 3.1.2 - (March 28, 2018)
* Kivy/android: request PIN on startup
* Improve OSX build process
* Fix various bugs with hardware wallets
* Other minor bugfixes
# Release 3.1.1 - (March 12, 2018)
* fix #4031: Trezor T support
* partial fix #4060: proxy and hardware wallet can't be used together
* fix #4039: can't set address labels
* fix crash related to coinbase transactions
* MacOS: use internal graphics card
* fix openalias related crashes
* speed-up capital gains calculations
* hw wallet encryption: re-prompt for passphrase if incorrect
* other minor fixes.
# Release 3.1.0 - (March 5, 2018)
* Memory-pool based fee estimation. Dynamic fees can target a desired
depth in the memory pool. This feature is optional, and ETA-based
estimates from Bitcoin Core are still available. Note that miners
could exploit this feature, if they conspired and filled the memory
pool with expensive transactions that never get mined. However,
since the Electrum client already trusts an Electrum server with
fee estimates, activating this feature does not introduce any new
vulnerability. In addition, the client uses a hard threshold to
protect itself from servers sending excessive fee estimates. In
practice, ETA-based estimates have resulted in sticky fees, and
caused many users to overpay for transactions. Advanced users tend
to visit (and trust) websites that display memory-pool data in
order to set their fees.
* Capital gains: For each outgoing transaction, the difference
between the acquisition and liquidation prices of outgoing coins is
displayed in the wallet history. By default, historical exchange
rates are used to compute acquisition and liquidation prices. These
values can also be entered manually, in order to match the actual
price realized by the user. The order of liquidation of coins is
the natural order defined by the blockchain; this results in
capital gain values that are invariant to changes in the set of
addresses that are in the wallet. Any other ordering strategy (such
as FIFO, LIFO) would result in capital gain values that depend on
the presence of other addresses in the wallet.
* Local transactions: Transactions can be saved in the wallet without
being broadcast. The inputs of local transactions are considered as
spent, and their change outputs can be re-used in subsequent
transactions. This can be combined with cold storage, in order to
create several transactions before broadcasting them. Outgoing
transactions that have been removed from the memory pool are also
saved in the wallet, and can be broadcast again.
* Checkpoints: The initial download of a headers file was replaced
with hardcoded checkpoints. The wallet uses one checkpoint per
retargeting period. The headers for a retargeting period are
downloaded only if transactions need to be verified in this period.
* The 'privacy' and 'priority' coin selection policies have been
merged into one. Previously, the 'privacy' policy has been unusable
because it was was not prioritizing confirmed coins. The new policy
is similar to 'privacy', except that it de-prioritizes addresses
that have unconfirmed coins.
* The 'Send' tab of the Qt GUI displays how transaction fees are
computed from transaction size.
* The wallet history can be filtered by time interval.
* Replace-by-fee is enabled by default. Note that this might cause
some issues with wallets that do not display RBF transactions until
they are confirmed.
* Watching-only wallets and hardware wallets can be encrypted.
* Semi-automated crash reporting
* The SSL checkbox option was removed from the GUI.
* The Trezor T hardware wallet is now supported.
* BIP84: native segwit p2wpkh scripts for bip39 seeds and hardware
wallets can now be created when specifying a BIP84 derivation
path. This is usable with Trezor and Ledger.
* Windows: the binaries now include ZBar, and QR code scanning should work.
* The Wallet Import Format (WIF) for private keys that was extended in 3.0
is changed. Keys in the previous format can be imported, compatibility
is maintained. Newly exported keys will be serialized as
"script_type:original_wif_format_key".
* BIP32 master keys for testnet once again have different version bytes than
on mainnet. For the mainnet prefixes {x,y,Y,z,Z}|{pub,prv}, the
corresponding testnet prefixes are {t,u,U,v,V}|{pub,prv}.
More details and exact version bytes are specified at:
https://github.com/spesmilo/electrum-docs/blob/master/xpub_version_bytes.rst
Note that due to this change, testnet wallet files created with previous
versions of Electrum must be considered broken, and they need to be
recreated from seed words.
* A new version of the Electrum protocol is required by the client
(version 1.2). Servers using older versions of the protocol will
not be displayed in the GUI.
# Release 3.0.6 :
* Fix transaction parsing bug #3788
# Release 3.0.5 : (Security update)
This is a follow-up to the 3.0.4 release, which did not completely fix
issue #3374. Users should upgrade to 3.0.5.
* The JSONRPC interface is password protected
* JSONRPC commands are disabled if the GUI is running, except 'ping',
which is used to determine if a GUI is already running
# Release 3.0.4 : (Security update)
* Fix a vulnerability caused by Cross-Origin Resource Sharing (CORS)
in the JSONRPC interface. Previous versions of Electrum are
vulnerable to port scanning and deanonimization attacks from
malicious websites. Wallets that are not password-protected are
vulnerable to theft.
* Bundle QR scanner with Android app
* Minor bug fixes
# Release 3.0.3
* Qt GUI: sweeping now uses the Send tab, allowing fees to be set
* Windows: if using the installer binary, there is now a separate shortcut
for "Electrum Testnet"
* Digital Bitbox: added support for p2sh-segwit
* OS notifications for incoming transactions
* better transaction size estimation:
- fees for segwit txns were somewhat underestimated (#3347)
- some multisig txns were underestimated
- handle uncompressed pubkeys
* fix #3321: testnet for Windows binaries
* fix #3264: Ledger/dbb signing on some platforms
* fix #3407: KeepKey sending to p2sh output
* other minor fixes and usability improvements
# Release 3.0.2
* Android: replace requests tab with address tab, with access to
private keys
* sweeping minikeys: search for both compressed and uncompressed
pubkeys
* fix wizard crash when attempting to reset Google Authenticator
* fix #3248: fix Ledger+segwit signing
* fix #3262: fix SSL payment request signing
* other minor fixes.
# Release 3.0.1
* minor bug and usability fixes
# Release 3.0 - Uncanny Valley (November 1st, 2017)
* The project was migrated to Python3 and Qt5. Python2 is no longer
supported. If you cloned the source repository, you will need to
run "python3 setup.py install" in order to install the new
dependencies.
* Segwit support:
- Native segwit scripts are supported using a new type of
seed. The version number for segwit seeds is 0x100. The install
wizard will not create segwit seeds by default; users must
opt-in with the segwit option.
- Native segwit scripts are represented using bech32 addresses,
following BIP173. Please note that BIP173 is still in draft
status, and that other wallets/websites may not support
it. Thus, you should keep a non-segwit wallet in order to be
able to receive bitcoins during the transition period. If BIP173
ends up being rejected or substantially modified, your wallet
may have to be restored from seed. This will not affect funds
sent to bech32 addresses, and it will not affect the capacity of
Electrum to spend these funds.
- Segwit scripts embedded in p2sh are supported with hardware
wallets or bip39 seeds. To create a segwit-in-p2sh wallet,
trezor/ledger users will need to enter a BIP49 derivation path.
- The BIP32 master keys of segwit wallets are serialized using new
version numbers. The new version numbers encode the script type,
and they result in the following prefixes:
* xpub/xprv : p2pkh or p2sh
* ypub/yprv : p2wpkh-in-p2sh
* Ypub/Yprv : p2wsh-in-p2sh
* zpub/zprv : p2wpkh
* Zpub/Zprv : p2wsh
These values are identical for mainnet and testnet; tpub/tprv
prefixes are no longer used in testnet wallets.
- The Wallet Import Format (WIF) is similarly extended for segwit
scripts. After a base58-encoded key is decoded to binary, its
first byte encodes the script type:
* 128 + 0: p2pkh
* 128 + 1: p2wpkh
* 128 + 2: p2wpkh-in-p2sh
* 128 + 5: p2sh
* 128 + 6: p2wsh
* 128 + 7: p2wsh-in-p2sh
The distinction between p2sh and p2pkh in private key means that
it is not possible to import a p2sh private key and associate it
to a p2pkh address.
* A new version of the Electrum protocol is required by the client
(version 1.1). Servers using older versions of the protocol will
not be displayed in the GUI.
* By default, transactions are time-locked to the height of the
current block. Other values of locktime may be passed using the
command line.
# Release 2.9.4 (security update)
* Backport security fixes from 3.0.5 after vulnerability was
discovered in JSONRPC interface.
# Release 2.9.3
* fix configuration file issue #2719
* fix ledger signing of non-RBF transactions
* disable 'spend confirmed only' option by default
# Release 2.9.2
* force headers download if headers file is corrupted
* add websocket to windows builds
# Release 2.9.1
* fix initial headers download
* validate contacts on import
* command-line option for locktime
# Release 2.9 - Independence (July 27th, 2017)
* Multiple Chain Validation: Electrum will download and validate
block headers sent by servers that may follow different branches
of a fork in the Bitcoin blockchain. Instead of a linear sequence,
block headers are organized in a tree structure. Branching points
are located efficiently using binary search. The purpose of MCV is
to detect and handle blockchain forks that are invisible to the
classical SPV model.
* The desired branch of a blockchain fork can be selected using the
network dialog. Branches are identified by the hash and height of
the diverging block. Coin splitting is possible using RBF
transaction (a tutorial will be added).
* Multibit support: If the user enters a BIP39 seed (or uses a
hardware wallet), the full derivation path is configurable in the
install wizard.
* Option to send only confirmed coins
* Qt GUI:
- Network dialog uses tabs and gets updated by network events.
- The gui tabs use icons
* Kivy GUI:
- separation between network dialog and wallet settings dialog.
- option for manual server entry
- proxy configuration
* Daemon: The wallet password can be passed as parameter to the
JSONRPC API.
* Various other bugfixes and improvements.
# Release 2.8.3
* Fix crash on reading older wallet formats.
* TrustedCoin: remove pay-per-tx option
# Release 2.8.2
* show paid invoices in history tab
* improve CPFP dialog
* fixes for trezor, keepkey
* other minor bugfixes
# Release 2.8.1
* fix Digital Bitbox plugin
* fix daemon jsonrpc
* fix trustedcoin wallet creation
* other minor bugfixes
# Release 2.8.0 (March 9, 2017)
* Wallet file encryption using ECIES: A keypair is derived from the
wallet password. Once the wallet is decrypted, only the public key
is retained in memory, in order to save the encrypted file.
* The daemon requires wallets to be explicitly loaded before
commands can use them. Wallets can be loaded using: 'electrum
daemon load_wallet [-w path]'. This command will require a
password if the wallet is encrypted.
* Invoices and contacts are stored in the wallet file and are no
longer shared between wallets. Previously created invoices and
contacts files may be imported from the menu.
* Fees improvements:
- Dynamic fees are enabled by default.
- Child Pays For Parent (CPFP) dialog in the GUI.
- RBF is automatically proposed for low fee transactions.
* Support for Segregated Witness (testnet only).
* Support for Digital Bitbox hardware wallet.
* The GUI shows a blue icon when connected using a proxy.
# Release 2.7.18
* enforce https on exchange rate APIs
* use hardcoded list of exchanges
* move 'Freeze' menu to Coins (utxo) tab
* various bugfixes
# Release 2.7.17
* fix a few minor regressions in the Qt GUI
# Release 2.7.16
* add Testnet support (fix #541)
* allow daemon to be launched in the foreground (fix #1873)
* Qt: use separate tabs for addresses and UTXOs
* Qt: update fee slider with a network callback
* Ledger: new ui and mobile 2fa validation (neocogent)
# Release 2.7.15
* Use fee slider for both static and dynamic fees.
* Add fee slider to RBF dialog (fix #2083).
* Simplify fee preferences.
* Critical: Fix password update issue (#2097). This bug prevents
password updates in multisig and 2FA wallets. It may also cause
wallet corruption if the wallet contains several master private
keys (such as 2FA wallets that have been restored from
seed). Affected wallets will need to be restored again.
# Release 2.7.14
* Merge exchange_rate plugin with main code
* Faster synchronization and transaction creation
* Fix bugs #2096, #2016
# Release 2.7.13
* fix message signing with imported keys
* add size to transaction details window
* move plot plugin to main code
* minor bugfixes
# Release 2.7.12
various bugfixes
# Release 2.7.11
* fix offline signing (issue #195)
* fix android crashes caused by threads
# Release 2.7.10
* various fixes for hardware wallets
* improve fee bumping
* separate sign and broadcast buttons in Qt tx dialog
* allow spaces in private keys
# Release 2.7.9
* Fix a bug with the ordering of pubkeys in recent multisig wallets.
Affected wallets will regenerate their public keys when opened for
the first time. This bug does not affect address generation.
* Fix hardware wallet issues #1975, #1976
# Release 2.7.8
* Fix a bug with fee bumping
* Fix crash when parsing request (issue #1969)
# Release 2.7.7
* Fix utf8 encoding bug with old wallet seeds (issue #1967)
* Fix delete request from menu (issue #1968)
# Release 2.7.6
* Fixes a critical bug with imported private keys (issue #1966). Keys
imported in Electrum 2.7.x were not encrypted, even if the wallet
had a password. If you imported private keys using Electrum 2.7.x,
you will need to import those keys again. If you imported keys in
2.6 and converted with 2.7.x, you don't need to do anything, but
you still need to upgrade in order to be able to spend.
* Wizard: Hide seed options in a popup dialog.
# Release 2.7.5
* Add number of confirmations to request status. (issue #1757)
* In the GUI, refer to passphrase as 'seed extension'.
* Fix bug with utf8 encoded passphrases.
* Kivy wizard: add a dialog for seed options.
* Kivy wizard: add current word to suggestions, because some users
don't see the space key.
# Release 2.7.4
* Fix private key import in wizard
* Fix Ledger display (issue #1961)
* Fix old watching-only wallets (issue #1959)
* Fix Android compatibility (issue #1947)
# Release 2.7.3
* fix Trezor and Keepkey support in Windows builds
* fix sweep private key dialog
* minor fixes: #1958, #1959
# Release 2.7.2
* fix bug in password update (issue #1954)
* fix fee slider (issue #1953)
# Release 2.7.1
* fix wizard crash with old seeds
* fix issue #1948: fee slider
# Release 2.7.0 (Oct 2 2016)
* The wallet file format has been upgraded. This upgrade is not
backward compatible, which means that a wallet upgraded to the 2.7
format will not be readable by earlier versions of
Electrum. Multiple accounts inside the same wallet are not
supported in the new format; the Qt GUI will propose to split any
wallet that has several accounts. Make sure that you have saved
your seed phrase before you upgrade Electrum.
* This version introduces a separation between wallets types and
keystores types. 'Wallet type' defines the type of Bitcoin contract
used in the wallet, while 'keystore type' refers to the method used
to store private keys. Therefore, so-called 'hardware wallets' will
be referred to as 'hardware keystores'.
* Hardware keystores:
- The Ledger Nano S is supported.
- Hardware keystores can be used as cosigners in multi-signature
wallets.
- Multiple hardware cosigners can be used in the same multisig
wallet. One icon per keystore is displayed in the satus bar. Each
connected device will co-sign the transaction.
* Replace-By-Fee: RBF transactions are supported in both Qt and
Android. A warning is displayed in the history for transactions
that are replaceable, have unconfirmed parents, or that have very
low fees.
* Dynamic fees: Dynamic fees are enabled by default. A slider allows
the user to select the expected confirmation time of their
transaction. The expected confirmation times of incoming
transactions is also displayed in the history.
* The install wizards of Qt and Kivy have been unified.
* Qt GUI (Desktop):
- A fee slider is visible in the in send tab
- The Address tab is hidden by default, can be shown with Ctrl-A
- UTXOs are displayed in the Address tab
* Kivy GUI (Android):
- The GUI displays the complete transaction history.
- Multisig wallets are supported.
- Wallets can be created and deleted in the GUI.
* Seed phrases can be extended with a user-chosen passphrase. The
length of seed phrases is standardized to 12 words, using 132 bits
of entropy (including 2FA seeds). In the wizard, the type of the
seed is displayed in the seed input dialog.
* TrustedCoin users can request a reset of their Google Authenticator
account, if they still have their seed.
# Release 2.6.4 (bugfixes)
* fix coinchooser bug (#1703)
* fix daemon JSONRPC (#1731)
* fix command-line broadcast (#1728)
* QT: add colors to labels
# Release 2.6.3 (bugfixes)
* fix command line parsing of transactions
* fix signtransaction --privkey (#1715)
# Release 2.6.2 (bugfixes)
* fix Trustedcoin restore from seed (bug #1704)
* small improvements to kivy GUI
# Release 2.6.1 (bugfixes)
* fix broadcast command (bug #1688)
* fix tx dialog (bug #1690)
* kivy: support old-type seed phrases in wizard
# Release 2.6
* The source code is relicensed under the MIT Licence
* First official release of the Kivy GUI, with android APK
* The old 'android' and 'gtk' GUIs are deprecated
* Separation between plugins and GUIs
* The command line uses jsonrpc to communicate with the daemon
* New command: 'notify
'
* Alternative coin selection policy, designed to help preserve user
privacy. Enable it by setting the Coin Selection preference to
Privacy.
* The install wizard has been rewritten and improved
* Support minikeys as used in Casascius coins for private key import
and sweeping
* Much improved support for TREZOR and KeepKey devices:
- full device information display
- initialize a new or wiped device in 4 ways:
1) device generates a new wallet
2) you enter a seed
3) you enter a BIP39 mnemonic to generate the seed
4) you enter a master private key
- KeepKey secure seed recovery (KeepKey only)
- change / set / disable PIN
- set homescreen (TREZOR only)
- set a session timeout. Once a session has timed out, further use
of the device requires your PIN and passhphrase to be re-entered
- enable / disable passphrases
- device wipe
- multiple device support
# Release 2.5.4
* increase MIN_RELAY_TX_FEE to avoid dust transactions
# Release 2.5.3 (bugfixes)
* installwizard: do not allow direct copy-paste of the seed
* installwizard: fix bug #1531 (starting offline)
# Release 2.5.2 (bugfixes)
* fix bug #1513 (client tries to broadcast transaction while not connected)
* fix synchronization bug (#1520)
* fix command line bug (#1494)
* fixes for exchange rate plugin
# Release 2.5.1 (bugfixes)
* signatures in transactions were still using the old class
* make sure that setup.py uses python2
* fix wizard crash with trustedcoin plugin
* fix socket infinite loop
* fix history bug #1479
# Release 2.5
* Low-S values are used in signatures (BIP 62).
* The Kivy GUI has been merged into master.
* The Qt GUI supports multiple windows in the same process. When a
new Electrum instance is started, it checks for an already running
Electrum process, and connects to it.
* The network layer uses select(), so all server communication is
handled by a single thread. Moreover, the synchronizer, verifier,
and exchange rate plugin now run as separate jobs within the
networking thread instead of as their own threads.
* Plugins are revamped, particularly the exchange rate plugin.
# Release 2.4.4
* Fix bug with TrustedCoin plugin
# Release 2.4.3
* Support for KeepKey hardware wallet
* Simplified Chinese wordlist
* Minor bugfixes and GUI tweaks
# Release 2.4.2
* Command line can read arguments from stdin (pipe)
* Speedup fee computation for large transactions
* Various bugfixes
# Release 2.4.1
* Use ssl.PROTOCOL_TLSv1
* Fix DNSSEC issues with ECDSA signatures
* Replace TLSLite dependency with minimal RSA implementation
* Dynamic Fees: using estimatefee value returned by server
* Various GUI improvements
# Release 2.4
* Payment to DNS names storing a Bitcoin addresses (OpenAlias) is
supported directly, without activating a plugin. The verification
uses DNSSEC.
* The DNSSEC verification code was rewritten. The previous code,
which was part of the OpenAlias plugin, is vulnerable and should
not be trusted (Electrum 2.0 to 2.3).
* Payment requests can be signed using Bitcoin addresses stored
in DNS (OpenAlias). The identity of the requestor is verified using
DNSSEC.
* Payment requests signed with OpenAlias keys can be shared as
bitcoin: URIs, if they are simple (a single address-type
output). The BIP21 URI scheme is extended with 'name', 'sig',
'time', 'exp'.
* Arbitrary m-of-n multisig wallets are supported (n<=15).
* Multisig transactions can be signed with TREZOR. When you create
the multisig wallet, just enter the xpub of your existing TREZOR
wallet.
* Transaction fees set manually in the GUI are retained, including
when the user uses the '!' shortcut.
* New 'email' plugin, that enables sending and receiving payment
requests by email.
* The daemon supports Websocket notifications of payments.
# Release 2.3.3
* fix proxy settings (issue #1309)
* improvements to the transaction dialog:
- request password after showing transaction
- show change addresses in yellow color
# Release 2.3.2
* minor bugfixes
* updated ledger plugin
* sort inputs/outputs lexicographically (BIP-LI01)
# Release 2.3.1
* patch a bug with payment requests
# Release 2.3
* Improved logic for the network layer.
* More efficient coin selection. Spend oldest coins first, and
minimize the number of transaction inputs.
* Plugins are loaded independently of the GUI. As a result, Openalias,
TrustedCoin and TREZOR wallets can be used with the command
line. Example: 'electrum payto '
* The command line has been refactored:
- Arguments are parsed with argparse.
- The inline help includes a description of options.
- Some commands have been renamed. Notably, 'mktx' and 'payto' have
been merged into a single command, with a --broadcast option.
Type 'electrum --help' for a complete overview.
* The command line accepts the '!' syntax to send the maximum
amount available. It can be combined with the '--from' option.
Example: 'payto ! --from '
* The command line also accepts a '?' shortcut for private keys
arguments, that triggers a prompt.
* Payment requests can be managed with the command line, using the
following commands: 'addrequest', 'rmrequest', 'listrequests'.
Payment requests can be signed with a SSL certificate, and published
as bip70 files in a public web directory. To see the relevant
configuration variables, type 'electrum addrequest --help'
* Commands can be called with jsonrpc, using the 'jsonrpc' gui. The
jsonrpc interface may be called by php.
# Release 2.2
* Show amounts (thousands separators and decimal point)
according to locale in GUI
* Show unmatured coins in balance
* Fix exchange rates plugin
* Network layer: refactoring and fixes
# Release 2.1.1
* patch a bug that prevents new wallet creation.
* fix connection issue on osx binaries
# Release 2.1
* Faster startup, thanks to the following optimizations:
1. Transaction input/outputs are cached in the wallet file
2. Fast X509 certificate parser, not using pyasn1 anymore.
3. The Label Sync plugin only requests modified labels.
* The 'Invoices' and 'Send' tabs have been merged.
* Contacts are stored in a separate file, shared between wallets.
* A Search Box is available in the GUI (Ctrl-S)
* Payment requests have an expiration date and can be exported to
BIP70 files.
* file: scheme support in BIP72 URIs: "bitcoin:?r=file:///..."
* Own addresses are shown in green in the Transaction dialog.
* Address History dialog.
* The OpenAlias plugin was improved.
* Various bug fixes and GUI improvements.
* A new LabelSync backend is being used an import of the old
database was made but since the release came later it's
recommended that you do a full push when you upgrade.
# Release 2.0.4 - Minor GUI improvements
* The password dialog will ask for password again if the user enters
a wrong password
* The Master Public Key dialog displays which keys belong to the
wallet, and which are cosigners
* The transaction dialog will ask to save unsaved transaction
received from cosigner pool, when user clicks on 'Close'
* The multisig restore dialog accepts xprv keys.
* The network daemon must be started explicitly before using commands
that require a connection
Example:
electrum daemon start
electrum getaddressunspent
electrum daemon status
electrum daemon stop
If a daemon is running, the GUI will use it.
# Release 2.0.3 - bugfixes and minor GUI improvements
* Do not use daemon threads (fix #960)
* Add a zoom button to receive tab
* Add exchange rate conversion to receive tab
* Use Tor's default port number in default proxy config
# Release 2.0.2 - bugfixes
* Fix transaction sweep (#1066)
* Fix thread timing bug (#1054)
# Release 2.0.1 - bugfixes
* Fix critical bug in TREZOR address derivation: passphrases were not
NFKD normalized. TREZOR users who created a wallet protected by a
passphrase containing utf-8 characters with diacritics are
affected. These users will have to open their wallet with version
2.0 and to move their funds to a new wallet.
* Use a file socket for the daemon (fixes network dialog issues)
* Fix crash caused by QR scanner icon when zbar not installed.
* Fix CosignerPool plugin
* Label Sync plugin: Fix label sharing between multisig wallets
# Release 2.0
* Before you upgrade, make sure you have saved your wallet seed on
paper.
* Documentation is now hosted on a wiki: http://electrum.orain.org
* New seed derivation method (not compatible with BIP39). The seed
phrase includes a version number, that refers to the wallet
structure. The version number also serves as a checksum, and it
will prevent the import of seeds from incompatible wallets. Old
Electrum seeds are still supported.
* New address derivation (BIP32). Standard wallets are single account
and use a gap limit of 20.
* Support for Multisig wallets using parallel BIP32 derivations and
P2SH addresses ("2 of 2", "2 of 3").
* Compact serialization format for unsigned or partially signed
transactions, that includes the BIP32 master public key and
derivation needed to sign inputs. Serialized transactions can be
sent to cosigners or to cold storage using QR codes (using Andreas
Schildbach's base 43 idea).
* Support for BIP70 payment requests:
- Verification of the chain of signatures uses tlslite.
- In the GUI, payment requests are shown in the 'Invoices' tab.
* Support for hardware wallets: TREZOR (SatoshiLabs) and Btchip (Ledger).
* Two-factor authentication service by TrustedCoin. This service uses
"2 of 3" multisig wallets and Google Authenticator. Note that
wallets protected by this service can be deterministically restored
from seed, without Trustedcoin's server.
* Cosigner Pool plugin: encrypted communication channel for multisig
wallets, to send and receive partially signed transactions.
* Audio Modem plugin: send and receive transactions by sound.
* OpenAlias plugin: send bitcoins to aliases verified using DNSSEC.
* New 'Receive' tab in the GUI:
- create and manage payment requests, with QR Codes
- the former 'Receive' tab was renamed to 'Addresses'
- the former Point of Sale plugin is replaced by a resizable
window that pops up if you click on the QR code
* The 'Send' tab in the Qt GUI supports transactions with multiple
outputs, and raw hexadecimal scripts.
* The GUI can connect to the Electrum daemon: "electrum -d" will
start the daemon if it is not already running, and the GUI will
connect to it. The daemon can serve several clients. It times out
if no client uses if for more than 5 minutes.
* The install wizard can be used to import addresses or private
keys. A watching-only wallet is created by entering a list of
addresses in the wizard dialog.
* New file format: Wallets files are saved as JSON. Note that new
wallet files cannot be read by older versions of Electrum. Old
wallet files will be converted to the new format; this operation
may take some time, because public keys will be derived for each
address of your wallet.
* The client accepts servers with a CA-signed SSL certificate.
* ECIES encrypt/decrypt methods, available in the GUI and using
the command line:
encrypt
decrypt
* The Android GUI has received various updates and it is much more
stable. Another script was added to Android, called Authenticator,
that works completely offline: it reads an unsigned transaction
shown as QR code, signs it and shows the result as a QR code.
# Release 1.9.8
* Electrum servers were upgraded to version 0.9. The new server stores
a Patrica tree of all UTXOs, an idea proposed by Alan Reiner in the
bitcointalk forum. This property allows the client to directly
request the balance of any address. The new commands are:
1. getaddressbalance
2. getaddressunspent
3. getutxoaddress
* Command-line commands that require a connection to the network spawn
a daemon, that remains connected and handles subsequent
commands. The daemon terminates itself if it remains unused for more
than one minute. The purpose of this is to make scripting more
efficient. For example, a bash script using many electrum commands
will open only one connection.
# Release 1.9.7
* Fix for offline signing
* Various bugfixes
* GUI usability improvements
* Coinbase Buyback plugin
# Release 1.9.6
* During wallet creation, do not write seed to disk until it is encrypted.
* Confirmation dialog if the transaction fee is higher than 1mBTC.
* bugfixes
# Release 1.9.5
* Coin control: select addresses to send from
* Put addresses that have been used in a minimized section (Qt GUI)
* Allow non ascii chars in passwords
# Release 1.9.4
bugfixes: offline transactions
# Release 1.9.3
bugfixes: connection problems, transactions staying unverified
# Release 1.9.2
* fix a syntax error
# Release 1.9.1
* fix regression with --offline mode
* fix regression with --portable mode: use a dedicated directory
# Release 1.9
* The client connects to multiple servers in order to retrieve block headers and find the longest chain
* SSL certificate validation (to prevent MITM)
* Deterministic signatures (RFC 6979)
* Menu to create/restore/open wallets
* Create transactions with multiple outputs from CSV (comma separated values)
* New text gui: stdio
* Plugins are no longer tied to the qt GUI, they can reach all GUIs
* Proxy bugs have been fixed
# Release 1.8.1
* Notification option when receiving new transactions
* Confirm dialogue before sending large amounts
* Alternative datafile location for non-windows systems
* Fix offline wallet creation
* Remove enforced tx fee
* Tray icon improvements
* Various bugfixes
# Release 1.8
* Menubar in classic gui
* Updated the QR Code plugin to enable offline/online wallets to transmit unsigned/signed transactions via QR code.
* Fixed bug where never-confirmed transactions prevented further spending
# Release 1.7.4
* Increase default fee
* fix create and restore in command line
* fix verify message in the gui
# Release 1.7.3:
* Classic GUI can display amounts in mBTC
* Account selector in the classic GUI
* Changed the way the portable flag uses without supplying a -w argument
* Classic GUI asks users to enter their seed on wallet creation
# Release 1.7.2:
* Transactions that are in the same block are displayed in chronological order in the history.
* The client computes transaction priority and rejects zero-fee transactions that need a fee.
* The default fee was lowered to 200 uBTC per kb.
* Due to an internal format change, your history may be pruned when
you open your wallet for the first time after upgrading to 1.7.2. If
this is the case, please visit a full server to restore your full
history. You will only need to do that once.
# Release 1.7.1: bugfixes.
# Release 1.7
* The Classic GUI can be extended with plugins. Developers who want to
add new features or third-party services to Electrum are invited to
write plugins. Some previously existing and non-essential features of
Electrum (point-of-sale mode, qrcode scanner) were removed from the
core and are now available as plugins.
* The wallet waits for 2 confirmations before creating new
addresses. This makes recovery from seed more robust. Note that it
might create unwanted gaps if you use Electrum 1.7 together with older
versions of Electrum.
* An interactive Python console replaces the 'Wall' tab. The provided
python environment gives users access to the wallet and gui. Most
electrum commands are available as python function in the
console. Custom scripts an be loaded with a "run(filename)"
command. Tab-completions are available.
* The location of the Electrum folder in Windows changed from
LOCALAPPDATA to APPDATA. Discussion on this topic can be found here:
https://bitcointalk.org/index.php?topic=144575.0
* Private keys can be exported from within the classic GUI:
For a single address, use the address menu (right-click).
To export the keys of your entire wallet, use the settings dialog (import/export tab).
* It is possible to create, sign and redeem multisig transaction using the
command line interface. This is made possible by the following new commands:
dumpprivkey, listunspent, createmultisig, createrawtransaction, decoderawtransaction, signrawtransaction
The syntax of these commands is similar to their bitcoind counterpart.
For an example, see Gavin's tutorial: https://gist.github.com/gavinandresen/3966071
* Offline wallets now work in a way similar to Armory:
1. user creates an unsigned transaction using the online (watching-only) wallet.
2. unsigned transaction is copied to the offline computer, and signed by the offline wallet.
3. signed transaction is copied to the online computer, broadcasted by the online client.
4. All these steps can be done via the command line interface or the classic GUI.
* Many command line commands have been renamed in order to make the syntax consistent with bitcoind.
# Release 1.6.2
== Classic GUI
* Added new version notification
# Release 1.6.1 (11-01-2013)
== Core
* It is now possible to restore a wallet from MPK (this will create a watching-only wallet)
* A switch button allows to easily switch between Lite and Classic GUI.
== Classic GUI
* Seed and MPK help dialogs were rewritten
* Point of Sale: requested amounts can be expressed in other currencies and are converted to bitcoin.
== Lite GUI
* The receiving button was removed in favor of a menu item to keep it consistent with the history toggle.
# Release 1.6.0 (07-01-2013)
== Core
* (Feature) Add support for importing, signing and verifiying compressed keys
* (Feature) Auto reconnect to random server on disconnect
* (Feature) Ultimate fallback to HTTP port 80 if TCP doesn't work on any server
* (Bug) Under rare circumstances changing password with incorrect password could damage wallet
== Lite GUI
* (Chore) Use blockchain.info for exchange rate data
* (Feature) added currency conversion for BRL, CNY, RUB
* (Feature) Saraha theme
* (Feature) csv import/export for transactions including labels
== Classic GUI
* (Chore) pruning servers now called "p", full servers "f" to avoid confusion with terms
* (Feature) Debits in history shown in red
* (Feature) csv import/export for transactions including labels
# Release 1.5.8 (02-01-2013)
== Core
* (Bug) Fix pending address balance on received coins for pruning servers
* (Bug) Fix history command line option to show output again (regression by SPV)
* (Chore) Add timeout to blockchain headers file download by HTTP
* (Feature) new option: -L, --language: default language used in GUI.
== Lite GUI
* (Bug) Sending to auto-completed contacts works again
* (Chore) Added version number to title bar
== Classic GUI
* (Feature) Language selector in options.
# Release 1.5.7 (18-12-2012)
== Core
* The blockchain headers file is no longer included in the packages, it is downloaded on startup.
* New command line option: -P or --portable, for portable wallets. With this flag, all preferences are saved to the wallet file, and the blockchain headers file is in the same directory as the wallet
== Lite GUI
* (Feature) Added the ability to export your transactions to a CSV file.
* (Feature) Added a label dialog after sending a transaction.
* (Feature) Reworked receiving addresses; instead of a random selection from one of your receiving addresses a new widget will show listing unused addresses.
* (Chore) Removed server selection. With all the new server options a simple menu item does not suffice anymore.
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Reporting a Vulnerability
To report security issues, send an email to the addresses listed below.
(Not for support. Support requests will be *ignored*.)
Please send any report to *all* emails listed here.
The following GPG keys may be used to communicate sensitive information.
| Name | Email | GPG fingerprint |
|-------------|----------------------------------------|---------------------------------------------------|
| ThomasV | thomasv [AT] electrum [DOT] org | 6694 D8DE 7BE8 EE56 31BE D950 2BD5 824B 7F94 70E6 |
| SomberNight | somber.night [AT] protonmail [DOT] com | 4AD6 4339 DFA0 5E20 B3F6 AD51 E7B7 48CD AF5E 5ED9 |
#### Where to find GPG keys
You can import a key by running the following command with that
individual’s fingerprint: `gpg --recv-keys ""`
These public keys can also be found in the Electrum git repository,
in the top-level `pubkeys` folder.
================================================
FILE: contrib/add_cosigner
================================================
#!/usr/bin/python3
#
# This script is part of the workflow for BUILDERs to reproduce and sign the
# release binaries. (for builders who do not have sftp access to "electrum-downloads-airlock")
#
# env vars:
# - SSHUSER
#
#
# - BUILDER builds all binaries and checks they match the official releases
# (using release.sh, and perhaps some manual steps)
# - BUILDER creates a PR against https://github.com/spesmilo/electrum-signatures/
# to add their sigs for a given release, which then gets merged
# - SFTPUSER runs `$ SSHUSER=$SFTPUSER electrum/contrib/add_cosigner $BUILDER`
# - SFTPUSER runs `$ electrum/contrib/make_download $WWW_DIR`
# - $ (cd $WWW_DIR; git commit -a -m "add_cosigner"; git push)
# - SFTPUSER runs `$ electrum-web/publish.sh $SFTPUSER`
# - (for the website to be updated, both ThomasV and SomberNight needs to run publish.sh)
import re
import os
import sys
import importlib
import importlib.util
import subprocess
if len(sys.argv) < 2:
print(f"usage: {os.path.basename(__file__)} ", file=sys.stderr)
sys.exit(1)
# cd to project root
os.chdir(os.path.dirname(os.path.dirname(__file__)))
# load version.py; needlessly complicated alternative to "imp.load_source":
version_spec = importlib.util.spec_from_file_location('version', 'electrum/version.py')
version_module = importlib.util.module_from_spec(version_spec)
version_spec.loader.exec_module(version_module)
ELECTRUM_VERSION = version_module.ELECTRUM_VERSION
print("version", ELECTRUM_VERSION)
# GPG name of cosigner
cosigner = sys.argv[1]
version = version_win = version_mac = version_android = ELECTRUM_VERSION
files = {
"tgz": f"Electrum-{version}.tar.gz",
"tgz_srconly": f"Electrum-sourceonly-{version}.tar.gz",
"appimage": f"electrum-{version}-x86_64.AppImage",
"mac": f"electrum-{version_mac}.dmg",
"win": f"electrum-{version_win}.exe",
"win_setup": f"electrum-{version_win}-setup.exe",
"win_portable": f"electrum-{version_win}-portable.exe",
"apk_arm64": f"Electrum-{version_android}-arm64-v8a-release.apk",
"apk_armeabi": f"Electrum-{version_android}-armeabi-v7a-release.apk",
"apk_x86_64": f"Electrum-{version_android}-x86_64-release.apk",
}
for shortname, filename in files.items():
path = f"dist/{filename}"
link = f"https://download.electrum.org/{version}/{filename}"
if not os.path.exists(path):
os.system(f"wget -q {link} -O {path}")
if not os.path.getsize(path):
raise Exception(path)
sig_name = f"{filename}.{cosigner}.asc"
sig_url = f"https://raw.githubusercontent.com/spesmilo/electrum-signatures/master/{version}/{filename}/{sig_name}"
sig_path = f"dist/{sig_name}"
os.system(f"wget -nc {sig_url} -O {sig_path}")
if os.system(f"gpg --verify {sig_path} {path}") != 0:
raise Exception(sig_name)
print("Calling upload.sh now... This might take some time.")
subprocess.check_output(["./contrib/upload.sh", ])
================================================
FILE: contrib/android/Dockerfile
================================================
# based on https://github.com/kivy/python-for-android/blob/master/Dockerfile
FROM debian:trixie@sha256:a3b5f4f0286249a124bfe9845b3aec0f88de32ff31dd8d7e1b945f9f98d116b0
ENV DEBIAN_FRONTEND=noninteractive
ENV ANDROID_HOME="/opt/android"
# need ca-certificates before using snapshot packages
RUN apt update -qq > /dev/null && apt install -qq --yes --no-install-recommends \
ca-certificates
# pin the distro packages.
COPY contrib/android/apt.sources.list /etc/apt/sources.list
COPY contrib/android/apt.preferences /etc/apt/preferences.d/snapshot
# configure locale
RUN apt update -qq > /dev/null && apt install -qq --yes --no-install-recommends --allow-downgrades \
locales && \
locale-gen en_US.UTF-8
ENV LANG="en_US.UTF-8" \
LANGUAGE="en_US.UTF-8" \
LC_ALL="en_US.UTF-8"
RUN apt -y update -qq \
&& apt -y install -qq --no-install-recommends --allow-downgrades \
curl \
wget \
unzip \
ca-certificates \
python3 \
&& apt -y autoremove
ENV ANDROID_NDK_HOME="${ANDROID_HOME}/android-ndk"
#ENV ANDROID_NDK_VERSION="23b"
#ENV ANDROID_NDK_HASH="c6e97f9c8cfe5b7be0a9e6c15af8e7a179475b7ded23e2d1c1fa0945d6fb4382"
#ENV ANDROID_NDK_VERSION="27d"
#ENV ANDROID_NDK_HASH="601246087a682d1944e1e16dd85bc6e49560fe8b6d61255be2829178c8ed15d9"
ENV ANDROID_NDK_VERSION="23d-canary"
ENV ANDROID_NDK_HASH="6944ffc20ab018ff4ef6a403048d0a99d50a0630c3eae690c8f803c452f46f3e"
ENV ANDROID_NDK_HOME_V="${ANDROID_NDK_HOME}-r${ANDROID_NDK_VERSION}"
# get the latest version from https://developer.android.com/ndk/downloads/index.html
ENV ANDROID_NDK_ARCHIVE="android-ndk-r${ANDROID_NDK_VERSION}-linux.zip"
ENV ANDROID_NDK_DL_URL="https://dl.google.com/android/repository/${ANDROID_NDK_ARCHIVE}"
# below disabled in favor of CI build download
# download and install Android NDK
#RUN curl --location --progress-bar \
# "${ANDROID_NDK_DL_URL}" \
# --output "${ANDROID_NDK_ARCHIVE}" \
# && echo "${ANDROID_NDK_HASH} ${ANDROID_NDK_ARCHIVE}" | sha256sum -c - \
# && mkdir --parents "${ANDROID_NDK_HOME_V}" \
# && unzip -q "${ANDROID_NDK_ARCHIVE}" -d "${ANDROID_HOME}" \
# && ln -sfn "${ANDROID_NDK_HOME_V}" "${ANDROID_NDK_HOME}" \
# && rm -rf "${ANDROID_NDK_ARCHIVE}"
# temporary build using NDK from CI
ENV CI_REV="12186248"
ENV CI_NDK_FILE="android-ndk-${CI_REV}-linux-x86_64.zip"
COPY contrib/android/dl-ndk-ci.sh /tmp/
RUN /tmp/dl-ndk-ci.sh https://ci.android.com/builds/submitted/${CI_REV}/linux/latest/${CI_NDK_FILE} \
&& echo "${ANDROID_NDK_HASH} android-ndk-ci-linux-x86_64.zip" | sha256sum -c - \
&& mkdir --parents "${ANDROID_NDK_HOME_V}" \
&& unzip -q "android-ndk-ci-linux-x86_64.zip" -d "${ANDROID_HOME}" \
&& ln -sfn "${ANDROID_NDK_HOME_V}" "${ANDROID_NDK_HOME}" \
&& rm -rf "android-ndk-ci-linux-x86_64.zip"
ENV ANDROID_SDK_HOME="${ANDROID_HOME}/android-sdk"
# get the latest version from https://developer.android.com/studio/index.html
ENV ANDROID_SDK_TOOLS_VERSION="9477386"
ENV ANDROID_SDK_HASH="bd1aa17c7ef10066949c88dc6c9c8d536be27f992a1f3b5a584f9bd2ba5646a0"
ENV ANDROID_SDK_TOOLS_ARCHIVE="commandlinetools-linux-${ANDROID_SDK_TOOLS_VERSION}_latest.zip"
ENV ANDROID_SDK_TOOLS_DL_URL="https://dl.google.com/android/repository/${ANDROID_SDK_TOOLS_ARCHIVE}"
ENV ANDROID_SDK_MANAGER="${ANDROID_SDK_HOME}/cmdline-tools/bin/sdkmanager --sdk_root=${ANDROID_SDK_HOME}"
# download and install Android SDK
RUN curl --location --progress-bar \
"${ANDROID_SDK_TOOLS_DL_URL}" \
--output "${ANDROID_SDK_TOOLS_ARCHIVE}" \
&& echo "${ANDROID_SDK_HASH} ${ANDROID_SDK_TOOLS_ARCHIVE}" | sha256sum -c - \
&& mkdir --parents "${ANDROID_SDK_HOME}" \
&& unzip -q "${ANDROID_SDK_TOOLS_ARCHIVE}" -d "${ANDROID_SDK_HOME}" \
&& rm -rf "${ANDROID_SDK_TOOLS_ARCHIVE}"
# update Android SDK, install Android API, Build Tools...
RUN mkdir --parents "${ANDROID_SDK_HOME}/.android/" \
&& echo '### User Sources for Android SDK Manager' \
> "${ANDROID_SDK_HOME}/.android/repositories.cfg"
# download Java-17 (debian 13 only packages Java-21 and Java-25)
# - we download the amd64 binaries from debian 12 repos
# - we should try to upgrade to Java-21...
# - the main blocker seems to be having to update Gradle (to a version compatible with Java-21)
# - make_barcode_scanner.sh: markusfisch/{zxing-cpp, ...} pins old Gradle
ENV JAVA_JRE_DL_URL="https://snapshot.debian.org/archive/debian/20260130T143028Z/pool/main/o/openjdk-17/openjdk-17-jre-headless_17.0.18+8-1~deb12u1_amd64.deb"
ENV JAVA_JRE_ARCHIVE="openjdk-17-jre-headless.deb"
ENV JAVA_JRE_HASH="5bc36cbb4e383dbea4168d57b5fd9b42375ec8837dd62a1d56677632c3c960e0"
ENV JAVA_JDK_DL_URL="https://snapshot.debian.org/archive/debian/20260130T143028Z/pool/main/o/openjdk-17/openjdk-17-jdk-headless_17.0.18+8-1~deb12u1_amd64.deb"
ENV JAVA_JDK_ARCHIVE="openjdk-17-jdk-headless.deb"
ENV JAVA_JDK_HASH="8841044caa66860a71039342fe3c02b7853b61c518e05970e501faa215b1788a"
RUN apt -y update -qq \
&& apt -y install -qq --no-install-recommends \
ca-certificates-java \
java-common \
libcups2 \
libfontconfig1 \
liblcms2-2 \
libjpeg62-turbo \
libnss3 \
libasound2 \
libfreetype6 \
libharfbuzz0b \
libpcsclite1 \
&& apt -y autoremove \
&& cd /opt \
&& curl --location --progress-bar "${JAVA_JRE_DL_URL}" --output "${JAVA_JRE_ARCHIVE}" \
&& echo "${JAVA_JRE_HASH} ${JAVA_JRE_ARCHIVE}" | sha256sum -c - \
&& dpkg -i "${JAVA_JRE_ARCHIVE}" \
&& rm "${JAVA_JRE_ARCHIVE}" \
&& curl --location --progress-bar "${JAVA_JDK_DL_URL}" --output "${JAVA_JDK_ARCHIVE}" \
&& echo "${JAVA_JDK_HASH} ${JAVA_JDK_ARCHIVE}" | sha256sum -c - \
&& dpkg -i "${JAVA_JDK_ARCHIVE}" \
&& rm "${JAVA_JDK_ARCHIVE}"
# accept Android licenses (JDK necessary!)
RUN yes | ${ANDROID_SDK_MANAGER} --licenses > /dev/null
ENV ANDROID_SDK_BUILD_TOOLS_MAJOR_V="31"
ENV ANDROID_SDK_BUILD_TOOLS_VERSION="31.0.0"
# download platforms, API, build tools
RUN ${ANDROID_SDK_MANAGER} "platforms;android-${ANDROID_SDK_BUILD_TOOLS_MAJOR_V}" > /dev/null && \
${ANDROID_SDK_MANAGER} "build-tools;${ANDROID_SDK_BUILD_TOOLS_VERSION}" > /dev/null && \
${ANDROID_SDK_MANAGER} "extras;android;m2repository" > /dev/null && \
chmod +x "${ANDROID_SDK_HOME}/cmdline-tools/bin/avdmanager"
# download ANT
ENV APACHE_ANT_VERSION="1.10.13"
ENV APACHE_ANT_HASH="776be4a5704158f00ef3f23c0327546e38159389bc8f39abbfe114913f88bab1"
ENV APACHE_ANT_ARCHIVE="apache-ant-${APACHE_ANT_VERSION}-bin.tar.gz"
ENV APACHE_ANT_DL_URL="https://archive.apache.org/dist/ant/binaries/${APACHE_ANT_ARCHIVE}"
ENV APACHE_ANT_HOME="${ANDROID_HOME}/apache-ant"
ENV APACHE_ANT_HOME_V="${APACHE_ANT_HOME}-${APACHE_ANT_VERSION}"
RUN curl --location --progress-bar \
"${APACHE_ANT_DL_URL}" \
--output "${APACHE_ANT_ARCHIVE}" \
&& echo "${APACHE_ANT_HASH} ${APACHE_ANT_ARCHIVE}" | sha256sum -c - \
&& tar -xf "${APACHE_ANT_ARCHIVE}" -C "${ANDROID_HOME}" \
&& ln -sfn "${APACHE_ANT_HOME_V}" "${APACHE_ANT_HOME}" \
&& rm -rf "${APACHE_ANT_ARCHIVE}"
# install system/build dependencies
# https://github.com/kivy/buildozer/blob/master/docs/source/installation.rst#android-on-ubuntu-2004-64bit
RUN apt -y update -q \
&& apt -y install -q --no-install-recommends --allow-downgrades \
wget \
lbzip2 \
patch \
sudo \
git \
zip \
unzip \
rsync \
build-essential \
ccache \
autoconf \
autopoint \
libtool \
pkg-config \
zlib1g-dev \
libncurses-dev \
cmake \
libffi-dev \
libssl-dev \
automake \
gettext \
libltdl-dev \
&& apt -y autoremove \
&& apt -y clean
# cross compile deps for Qt6
RUN apt -y update -qq \
&& apt -y install -qq --no-install-recommends --allow-downgrades \
libopengl-dev \
libegl-dev \
dos2unix \
&& apt -y autoremove \
&& apt -y clean
# create new user to avoid using root; but with sudo access and no password for convenience.
ARG UID=1000
RUN if [ "$UID" != "0" ] ; then useradd --uid $UID --create-home --shell /bin/bash "user" ; fi
RUN usermod -append --groups sudo $(id -nu $UID || echo "user")
RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RUN HOME_DIR=$(getent passwd $UID | cut -d: -f6)
ENV WORK_DIR="${HOME_DIR}/wspace" \
PATH="${HOME_DIR}/.local/bin:${PATH}"
WORKDIR ${WORK_DIR}
RUN chown --recursive ${UID} ${WORK_DIR} ${ANDROID_SDK_HOME}
RUN chown ${UID} /opt
USER ${UID}
# build cpython. FIXME we can't use the python3 from apt, as it is too new o.O
# - p4a and buildozer require cython<3 (see https://github.com/kivy/python-for-android/issues/2919)
# but the last such version, cython 0.29.37, can only be built by up to python 3.12
ENV VENV_PYTHON_VERSION="3.12.12"
ENV VENV_PY_VER_MAJOR="3.12"
ENV VENV_PYTHON_HASH="487c908ddf4097a1b9ba859f25fe46d22ccaabfb335880faac305ac62bffb79b"
RUN mkdir --parents "/opt/cpython/download" && cd "/opt/cpython/download" \
&& wget "https://www.python.org/ftp/python/${VENV_PYTHON_VERSION}/Python-${VENV_PYTHON_VERSION}.tgz" \
&& echo "${VENV_PYTHON_HASH} Python-${VENV_PYTHON_VERSION}.tgz" | sha256sum -c - \
&& tar xf "Python-${VENV_PYTHON_VERSION}.tgz" -C "/opt/cpython/download" \
&& cd "Python-${VENV_PYTHON_VERSION}" \
&& mkdir "/opt/cpython/install" \
&& ./configure \
--prefix="/opt/cpython/install" \
-q \
&& make "-j$(nproc)" -s \
&& make -s altinstall \
&& ln -s "/opt/cpython/install/bin/python${VENV_PY_VER_MAJOR}" "/opt/cpython/install/bin/python3"
RUN "/opt/cpython/install/bin/python3" -m ensurepip
# venv, VIRTUAL_ENV is used by buildozer to indicate a venv environment
ENV VIRTUAL_ENV=/opt/venv
RUN "/opt/cpython/install/bin/python3" -m venv ${VIRTUAL_ENV}
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
COPY contrib/deterministic-build/requirements-build-base.txt /opt/deterministic-build/
COPY contrib/deterministic-build/requirements-build-android.txt /opt/deterministic-build/
RUN /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies \
-r /opt/deterministic-build/requirements-build-base.txt
RUN /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: \
-r /opt/deterministic-build/requirements-build-android.txt
# install buildozer
ENV BUILDOZER_CHECKOUT_COMMIT="4403ecf445f10b5fbf7c74f4621bf2b922ad35b5"
# ^ from branch electrum_20240930 (note: careful with force-pushing! see #8162)
RUN cd /opt \
&& git clone https://github.com/spesmilo/buildozer \
&& cd buildozer \
&& git checkout "${BUILDOZER_CHECKOUT_COMMIT}^{commit}" \
&& /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies -e .
# install python-for-android
ENV P4A_CHECKOUT_COMMIT="a01269f7799587ad74ee40e0b642d917b8db7d4e"
# ^ from branch electrum_20251211 (note: careful with force-pushing! see #8162)
RUN cd /opt \
&& git clone https://github.com/spesmilo/python-for-android \
&& cd python-for-android \
&& git checkout "${P4A_CHECKOUT_COMMIT}^{commit}" \
&& /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies -e .
# build env vars
ENV USE_SDK_WRAPPER=1
ENV GRADLE_OPTS="-Xmx1536M -Dorg.gradle.jvmargs='-Xmx1536M'"
#ENV P4A_FULL_DEBUG=1
================================================
FILE: contrib/android/Makefile
================================================
SHELL := /bin/bash
PYTHON = python3
# for reproducible builds
export LC_ALL := C
export TZ := UTC
ifndef ELEC_APK_USE_CURRENT_TIME
export SOURCE_DATE_EPOCH := $(shell git log -1 --pretty=%ct)
else
# p4a sets "private_version" based on SOURCE_DATE_EPOCH. "private_version" gets compiled into the apk,
# and is used at runtime to decide whether the already extracted project files in the app's datadir need updating.
# So, "private_version" needs to be reproducible, but it would be useful during development if it changed
# between subsequent builds (otherwise the new code won't be unpacked and used at runtime!).
# For this reason, for development purposes, we set SOURCE_DATE_EPOCH here to the current time.
# see https://github.com/kivy/python-for-android/blob/e8686e2104a553f05959cdaf7dd26867671fc8e6/pythonforandroid/bootstraps/common/build/build.py#L575-L587
export SOURCE_DATE_EPOCH := $(shell date +%s)
endif
export PYTHONHASHSEED := $(SOURCE_DATE_EPOCH)
export BUILD_DATE := $(shell LC_ALL=C TZ=UTC date +'%b %e %Y' -d @$(SOURCE_DATE_EPOCH))
export BUILD_TIME := $(shell LC_ALL=C TZ=UTC date +'%H:%M:%S' -d @$(SOURCE_DATE_EPOCH))
.PHONY: apk clean
prepare:
# running pre build setup
# copy electrum to main.py
@cp buildozer_$(ELEC_APK_GUI).spec ../../buildozer.spec
@cp ../../run_electrum ../../main.py
apk:
@make prepare
@-cd ../..; buildozer android debug
@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
================================================
FILE: contrib/android/Readme.md
================================================
# Qml GUI
The Qml GUI is used with Electrum on Android devices, since Electrum 4.4.
To generate an APK file, follow these instructions.
(note: older versions of Electrum for Android used the "kivy" GUI)
## Android binary with Docker
✓ _These binaries should be reproducible, meaning you should be able to generate
binaries that match the official releases._
- _Minimum supported target system (i.e. what end-users need): Android 6.0 (API 23)_
This assumes an Ubuntu (x86_64) host, but it should not be too hard to adapt to another
similar system.
1. Install Docker
See [`contrib/docker_notes.md`](../docker_notes.md).
(worth reading even if you already have docker)
2. Build binaries
The build script takes a few arguments. To see syntax, run it without providing any:
```
$ ./build.sh
```
For development, consider e.g. `$ ./build.sh qml arm64-v8a debug`
If you want reproducibility, try instead e.g.:
```
$ ELECBUILD_COMMIT=HEAD ./build.sh qml all release-unsigned
```
3. The generated binary is in `./dist`.
## Verifying reproducibility and comparing against official binary
Every user can verify that the official binary was created from the source code in this
repository.
1. Build your own binary as described above.
Make sure you don't build in `debug` mode,
instead use either of `release` or `release-unsigned`.
If you build in `release` mode, the apk will be signed, which requires a keystore
that you need to create manually (see source of `make_apk.sh` for an example).
2. Note that the binaries are not going to be byte-for-byte identical, as the official
release is signed by a keystore that only the project maintainers have.
You can use the `apkdiff.py` python script (written by the Signal developers) to compare
the two binaries.
```
$ python3 contrib/android/apkdiff.py Electrum_apk_that_you_built.apk Electrum_apk_official_release.apk
```
This should output `APKs match!`.
## FAQ
### I changed something but I don't see any differences on the phone. What did I do wrong?
You probably need to clear the cache: `rm -rf .buildozer/android/platform/build-*/{build,dists}`
### How do I deploy on connected phone for quick testing?
Assuming `adb` is installed:
```
$ adb -d install -r dist/Electrum-*-arm64-v8a-debug.apk
$ adb shell monkey -p org.electrum.electrum 1
```
Note `adb install` can take a `--user {userId}` option to install the app for a specific profile.
Without that, the default is to install to *all* profiles.
### How do I get an interactive shell inside docker?
```
$ docker run -it --rm \
-v $PWD:/home/user/wspace/electrum \
-v $PWD/.buildozer/.gradle:/home/user/.gradle \
--workdir /home/user/wspace/electrum \
electrum-android-builder-img
```
### How do I get more verbose logs for the build?
See `log_level` in `buildozer.spec`
### How can I see logs at runtime?
This should work OK for most scenarios:
```
adb logcat | grep python
```
Better `grep` but fragile because of `cut`:
```
adb logcat | grep -F "`adb shell ps | grep org.electrum.electrum | cut -c14-19`"
```
### The Qml GUI can be run directly on Linux Desktop. How?
Install requirements:
```
python3 -m pip install ".[qml_gui]"
```
Run electrum with the `-g` switch: `electrum -g qml`
Notes:
- pyqt ~6.4 would work best, as the gui has not yet been adapted to styling changes in 6.5
- However, pyqt6 as distributed on PyPI does not include a required module (PyQt6.QtQml) until 6.5
- Installing these deps from your OS package manager should also work,
except many don't distribute pyqt6 yet.
For pyqt5 on debian-based distros, this used to look like this:
```
sudo apt-get install python3-pyqt5 python3-pyqt5.qtquick python3-pyqt5.qtmultimedia
sudo apt-get install python3-pil
sudo apt-get install qml-module-qtquick-controls2 qml-module-qtquick-layouts \
qml-module-qtquick-window2 qml-module-qtmultimedia \
libqt5multimedia5-plugins qml-module-qt-labs-folderlistmodel
```
### debug vs release build
If you just follow the instructions above, you will build the apk
in debug mode. The most notable difference is that the apk will be
signed using a debug keystore. If you are planning to upload
what you build to e.g. the Play Store, you should create your own
keystore, back it up safely, and run `./build.sh` in `release` mode.
See e.g. [kivy wiki](https://github.com/kivy/kivy/wiki/Creating-a-Release-APK)
and [android dev docs](https://developer.android.com/studio/build/building-cmdline#sign_cmdline).
### Access datadir on Android from desktop (e.g. to copy wallet file)
Note that this only works for debug builds! Otherwise the security model
of Android does not let you access the internal storage of an app without root.
(See [this](https://stackoverflow.com/q/9017073))
To pull a file:
```
$ adb shell
adb$ run-as org.electrum.electrum ls /data/data/org.electrum.electrum/files/data
adb$ exit
$ adb exec-out run-as org.electrum.electrum cat /data/data/org.electrum.electrum/files/data/wallets/my_wallet > my_wallet
```
To push a file:
```
$ adb push ~/wspace/tmp/my_wallet /data/local/tmp
$ adb shell
adb$ ls -la /data/local/tmp
adb$ run-as org.electrum.testnet.electrum cp /data/local/tmp/my_wallet /data/data/org.electrum.testnet.electrum/files/data/testnet/wallets/
adb$ run-as org.electrum.testnet.electrum chmod -R 700 /data/data/org.electrum.testnet.electrum/files/data/testnet/wallets
adb$ run-as org.electrum.testnet.electrum chmod -R u-x,u+X /data/data/org.electrum.testnet.electrum/files/data/testnet/wallets
adb$ rm /data/local/tmp/my_wallet
```
Or use Android Studio: "Device File Explorer", which can download/upload data directly from device (via adb).
#### Device with multiple user profiles
There are further complications if using an Android device
[with multiple user profiles](https://source.android.com/docs/devices/admin/multi-user-testing)
(typical for GrapheneOS/etc).
Run `$ adb shell pm list users` to get a list of all existing users, and take note of the user ids.
Instead of `/data/data/{app.path}`, private app data is stored at `/data/user/{userId}/{app.path}`.
Further, instead of `adb$ run-as org.electrum.electrum`,
you need `adb$ run-as org.electrum.electrum --user {userId}`.
### How to investigate diff between binaries if reproducibility fails?
```
cd dist/
unzip Electrum-*.apk1 -d apk1
mkdir apk1/assets/private_mp3/
tar -xzvf apk1/assets/private.tar --directory apk1/assets/private_mp3/
mkdir apk1/lib/_libpybundle/
tar -xzvf apk1/lib/*/libpybundle.so --directory apk1/lib/_libpybundle/
unzip Electrum-*.apk2 -d apk2
mkdir apk2/assets/private_mp3/
tar -xzvf apk2/assets/private.tar --directory apk2/assets/private_mp3/
mkdir apk2/lib/_libpybundle/
tar -xzvf apk2/lib/*/libpybundle.so --directory apk2/lib/_libpybundle/
sudo chown --recursive "$(id -u -n)":"$(id -u -n)" apk1/ apk2/
chmod -R +Xr apk1/ apk2/
unzip apk1/lib/_libpybundle/_python_bundle/stdlib.zip -d apk1/lib/_libpybundle/_python_bundle/stdlib
unzip apk2/lib/_libpybundle/_python_bundle/stdlib.zip -d apk2/lib/_libpybundle/_python_bundle/stdlib
sudo chown --recursive "$(id -u -n)":"$(id -u -n)" apk1/ apk2/
chmod -R +Xr apk1/ apk2/
$(cd apk1; find -type f -exec sha256sum '{}' \; > ./../sha256sum1)
$(cd apk2; find -type f -exec sha256sum '{}' \; > ./../sha256sum2)
diff sha256sum1 sha256sum2 > d
cat d
```
### How to install apks built by the CI on my phone?
The CI (Cirrus) builds apks on most git commits.
See e.g. [here](https://github.com/spesmilo/electrum/runs/9272252577).
The task name should start with "Android build".
Click "View more details on Cirrus CI" to get to cirrus' website, and search for "Artifacts".
The apk is built in `debug` mode, and is signed using an ephemeral RSA key.
For tech demo purposes, you can directly install this apk on your phone.
However, if you already have electrum installed on your phone, Android's TOFU signing model
will not let you upgrade that to the CI apk due to mismatching signing keys. As the CI key
is ephemeral, it is not even possible to upgrade from an older CI apk to a newer CI apk.
However, it is possible to resign the apk manually with one's own key, using
e.g. [`apksigner`](https://developer.android.com/studio/command-line/apksigner),
mutating the apk in place, after which it should be possible to upgrade:
```
apksigner sign --ks ~/wspace/electrum/contrib/android/android_debug.keystore Electrum-*-arm64-v8a-debug.apk
```
================================================
FILE: contrib/android/apkdiff.py
================================================
#! /usr/bin/env python3
# from https://github.com/signalapp/Signal-Android/blob/2029ea378f249a70983c1fc3d55b9a63588bc06c/reproducible-builds/apkdiff/apkdiff.py
import sys
from zipfile import ZipFile
# FIXME it is possible to hide data in the apk signing block - and then the application
# can introspect itself at runtime and access that, even execute it as code... :/
# see https://source.android.com/docs/security/features/apksigning/v2#apk-signing-block
# https://android.izzysoft.de/articles/named/iod-scan-apkchecks
# https://github.com/obfusk/sigblock-code-poc
# I think if the app did this kind of introspection, that should be caught by code review,
# but still, note that with this current diff script it is possible to smuggle data in the apk.
class ApkDiff:
IGNORE_FILES = ["META-INF/MANIFEST.MF", "META-INF/CERT.RSA", "META-INF/CERT.SF"]
def compare(self, sourceApk, destinationApk) -> bool:
sourceZip = ZipFile(sourceApk, 'r')
destinationZip = ZipFile(destinationApk, 'r')
if self.compareManifests(sourceZip, destinationZip) and self.compareEntries(sourceZip, destinationZip):
print("APKs match!")
return True
else:
print("APKs don't match!")
return False
def compareManifests(self, sourceZip, destinationZip):
sourceEntrySortedList = sorted(sourceZip.namelist())
destinationEntrySortedList = sorted(destinationZip.namelist())
for ignoreFile in self.IGNORE_FILES:
while ignoreFile in sourceEntrySortedList: sourceEntrySortedList.remove(ignoreFile)
while ignoreFile in destinationEntrySortedList: destinationEntrySortedList.remove(ignoreFile)
if len(sourceEntrySortedList) != len(destinationEntrySortedList):
print("Manifest lengths differ!")
for (sourceEntryName, destinationEntryName) in zip(sourceEntrySortedList, destinationEntrySortedList):
if sourceEntryName != destinationEntryName:
print("Sorted manifests don't match, %s vs %s" % (sourceEntryName, destinationEntryName))
return False
return True
def compareEntries(self, sourceZip, destinationZip):
sourceInfoList = list(filter(lambda sourceInfo: sourceInfo.filename not in self.IGNORE_FILES, sourceZip.infolist()))
destinationInfoList = list(filter(lambda destinationInfo: destinationInfo.filename not in self.IGNORE_FILES, destinationZip.infolist()))
if len(sourceInfoList) != len(destinationInfoList):
print("APK info lists of different length!")
return False
for sourceEntryInfo in sourceInfoList:
for destinationEntryInfo in list(destinationInfoList):
if sourceEntryInfo.filename == destinationEntryInfo.filename:
sourceEntry = sourceZip.open(sourceEntryInfo, 'r')
destinationEntry = destinationZip.open(destinationEntryInfo, 'r')
if not self.compareFiles(sourceEntry, destinationEntry):
print("APK entry %s does not match %s!" % (sourceEntryInfo.filename, destinationEntryInfo.filename))
return False
destinationInfoList.remove(destinationEntryInfo)
break
return True
def compareFiles(self, sourceFile, destinationFile):
sourceChunk = sourceFile.read(1024)
destinationChunk = destinationFile.read(1024)
while sourceChunk != b"" or destinationChunk != b"":
if sourceChunk != destinationChunk:
return False
sourceChunk = sourceFile.read(1024)
destinationChunk = destinationFile.read(1024)
return True
if __name__ == '__main__':
if len(sys.argv) != 3:
print("Usage: apkdiff ")
sys.exit(1)
match = ApkDiff().compare(sys.argv[1], sys.argv[2])
if match:
sys.exit(0)
else:
sys.exit(1)
================================================
FILE: contrib/android/apt.preferences
================================================
Package: *
Pin: origin "snapshot.debian.org"
Pin-Priority: 1001
================================================
FILE: contrib/android/apt.sources.list
================================================
deb https://snapshot.debian.org/archive/debian/20260129T082333Z/ trixie main
deb-src https://snapshot.debian.org/archive/debian/20260129T082333Z/ trixie main
================================================
FILE: contrib/android/bitcoin_intent.xml
================================================
================================================
FILE: contrib/android/build.sh
================================================
#!/bin/bash
#
# env vars:
# - ELECBUILD_NOCACHE: if set, forces rebuild of docker image
# - ELECBUILD_COMMIT: if set, do a fresh clone and git checkout
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
PROJECT_ROOT_OR_FRESHCLONE_ROOT="$PROJECT_ROOT"
CONTRIB="$PROJECT_ROOT/contrib"
CONTRIB_ANDROID="$CONTRIB/android"
DISTDIR="$PROJECT_ROOT/dist"
BUILD_UID=$(/usr/bin/stat -c %u "$PROJECT_ROOT")
. "$CONTRIB"/build_tools_util.sh
# check arguments
if [[ -n "$3" \
&& ( "$1" == "qml" ) \
&& ( "$2" == "all" || "$2" == "armeabi-v7a" || "$2" == "arm64-v8a" || "$2" == "x86" || "$2" == "x86_64" ) \
&& ( "$3" == "debug" || "$3" == "release" || "$3" == "release-unsigned" ) ]] ; then
info "arguments $1 $2 $3"
else
fail "usage: build.sh "
exit 1
fi
# create symlink
rm -f ${PROJECT_ROOT}/.buildozer
mkdir -p "${PROJECT_ROOT}/.buildozer_$1"
ln -s ".buildozer_$1" ${PROJECT_ROOT}/.buildozer
DOCKER_BUILD_FLAGS=""
if [ ! -z "$ELECBUILD_NOCACHE" ] ; then
info "ELECBUILD_NOCACHE is set. forcing rebuild of docker image."
DOCKER_BUILD_FLAGS="--pull --no-cache"
fi
if [ -z "$ELECBUILD_COMMIT" ] ; then # local dev build
DOCKER_BUILD_FLAGS="$DOCKER_BUILD_FLAGS --build-arg UID=$BUILD_UID"
fi
info "building docker image."
docker build \
$DOCKER_BUILD_FLAGS \
-t electrum-android-builder-img \
--file "$CONTRIB_ANDROID/Dockerfile" \
"$PROJECT_ROOT"
# maybe do fresh clone
if [ ! -z "$ELECBUILD_COMMIT" ] ; then
info "ELECBUILD_COMMIT=$ELECBUILD_COMMIT. doing fresh clone and git checkout."
FRESH_CLONE=${FRESH_CLONE:-"/tmp/electrum_build/android/fresh_clone/electrum"}
rm -rf "$FRESH_CLONE" 2>/dev/null || ( info "we need sudo to rm prev FRESH_CLONE." && sudo rm -rf "$FRESH_CLONE" )
umask 0022
git clone "$PROJECT_ROOT" "$FRESH_CLONE"
cd "$FRESH_CLONE"
git checkout "$ELECBUILD_COMMIT"
PROJECT_ROOT_OR_FRESHCLONE_ROOT="$FRESH_CLONE"
else
info "not doing fresh clone."
fi
DOCKER_RUN_FLAGS=""
if [[ "$3" == "release" ]] ; then
info "'release' mode selected. mounting ~/.keystore inside container."
DOCKER_RUN_FLAGS="-v $HOME/.keystore:/home/user/.keystore"
fi
if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
info "/dev/tty is available and usable"
DOCKER_RUN_FLAGS="$DOCKER_RUN_FLAGS -it"
fi
info "building binary..."
mkdir --parents "$PROJECT_ROOT_OR_FRESHCLONE_ROOT"/.buildozer/.gradle
# check uid and maybe chown. see #8261
if [ ! -z "$ELECBUILD_COMMIT" ] ; then # fresh clone (reproducible build)
if [ $(id -u) != "1000" ] || [ $(id -g) != "1000" ] ; then
info "need to chown -R FRESH_CLONE dir. prompting for sudo."
sudo chown -R 1000:1000 "$FRESH_CLONE"
fi
fi
docker run --rm \
--name electrum-android-builder-cont \
-v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT":/home/user/wspace/electrum \
-v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT"/.buildozer/.gradle:/home/user/.gradle \
$DOCKER_RUN_FLAGS \
--workdir /home/user/wspace/electrum \
electrum-android-builder-img \
./contrib/android/make_apk.sh "$@"
# make sure resulting binary location is independent of fresh_clone
if [ ! -z "$ELECBUILD_COMMIT" ] ; then
mkdir --parents "$DISTDIR/"
cp -f "$FRESH_CLONE/dist"/* "$DISTDIR/"
fi
================================================
FILE: contrib/android/buildozer_qml.spec
================================================
[app]
# (str) Title of your application
title = Electrum
# (str) Package name
package.name = Electrum
# (str) Package domain (needed for android/ios packaging)
package.domain = org.electrum
# (str) Source code where the main.py live
source.dir = .
# (list) Source files to include (let empty to include all the files)
source.include_exts = py,png,jpg,qml,qmltypes,ttf,txt,gif,pem,mo,json,csv,so,svg
# (list) Source files to exclude (let empty to not exclude anything)
source.exclude_exts = spec
# (list) List of directory to exclude (let empty to not exclude anything)
source.exclude_dirs =
bin,
build,
dist,
contrib,
env,
tests,
fastlane,
electrum/www,
electrum/scripts,
electrum/utils,
electrum/gui/qt,
electrum/plugins/audio_modem,
electrum/plugins/bitbox02,
electrum/plugins/coldcard,
electrum/plugins/digitalbitbox,
electrum/plugins/jade,
electrum/plugins/keepkey,
electrum/plugins/ledger,
electrum/plugins/nwc,
electrum/plugins/payserver,
electrum/plugins/revealer,
electrum/plugins/safe_t,
electrum/plugins/swapserver,
electrum/plugins/timelock_recovery,
electrum/plugins/trezor,
electrum/plugins/watchtower,
packages/qdarkstyle,
packages/qtpy,
packages/bin,
packages/share,
packages/pkg_resources,
packages/setuptools
# (list) List of exclusions using pattern matching
source.exclude_patterns = Makefile,setup*,
# not reproducible:
packages/aiohttp-*.dist-info/*,
packages/frozenlist-*.dist-info/*
# (str) Application versioning (method 1)
version.regex = ELECTRUM_VERSION = '(.*)'
version.filename = %(source.dir)s/electrum/version.py
# (str) Application versioning (method 2)
#version = 1.9.8
# (list) Application requirements
# note: versions and hashes are pinned in ./p4a_recipes/*
requirements =
hostpython3,
python3,
android,
openssl,
plyer,
libffi,
libsecp256k1,
pycryptodomex,
pyqt6sip,
pyqt6,
libzbar
# (str) Presplash of the application
presplash.filename = %(source.dir)s/electrum/gui/icons/electrum_presplash.png
# (str) Icon of the application
icon.filename = %(source.dir)s/electrum/gui/icons/android_electrum_icon_legacy.png
icon.adaptive_foreground.filename = %(source.dir)s/electrum/gui/icons/android_electrum_icon_foreground.png
icon.adaptive_background.filename = %(source.dir)s/electrum/gui/icons/android_electrum_icon_background.png
# (str) Supported orientation (one of landscape, portrait or all)
orientation = portrait
# (bool) Indicate if the application should be fullscreen or not
fullscreen = False
#
# Android specific
#
# (list) Permissions
android.permissions = INTERNET, CAMERA, WRITE_EXTERNAL_STORAGE, POST_NOTIFICATIONS, USE_BIOMETRIC
# (int) Android API to use (compileSdkVersion)
# note: when changing, Dockerfile also needs to be changed to install corresponding build tools
android.api = 31
# (int) Android targetSdkVersion
android.target_sdk_version = 35
# (int) Minimum API required. You will need to set the android.ndk_api to be as low as this value.
android.minapi = 23
# (str) Android NDK version to use
android.ndk = 23b
# (int) Android NDK API to use (optional). This is the minimum API your app will support.
android.ndk_api = 23
# (bool) Use --private data storage (True) or --dir public storage (False)
#android.private_storage = True
# (str) Android NDK directory (if empty, it will be automatically downloaded.)
android.ndk_path = /opt/android/android-ndk
# (str) Android SDK directory (if empty, it will be automatically downloaded.)
android.sdk_path = /opt/android/android-sdk
# (str) ANT directory (if empty, it will be automatically downloaded.)
android.ant_path = /opt/android/apache-ant
# (bool) If True, then skip trying to update the Android sdk
# This can be useful to avoid excess Internet downloads or save time
# when an update is due and you just want to test/build your package
# note(ghost43): probably needed for reproducibility. versions pinned in Dockerfile.
android.skip_update = True
# (bool) If True, then automatically accept SDK license
# agreements. This is intended for automation only. If set to False,
# the default, you will be shown the license when first running
# buildozer.
android.accept_sdk_license = True
# (str) Android entry point, default is ok for Kivy-based app
#android.entrypoint = org.renpy.android.PythonActivity
# (list) List of Java .jar files to add to the libs so that pyjnius can access
# their classes. Don't add jars that you do not need, since extra jars can slow
# down the build process. Allows wildcards matching, for example:
# OUYA-ODK/libs/*.jar
#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar
#android.add_jars = lib/android/zbar.jar
android.add_jars = .buildozer/android/platform/*/build/libs_collections/Electrum/jar/*.jar
android.add_aars =
contrib/android/.cache/aars/BarcodeScannerView.aar,
contrib/android/.cache/aars/CameraView.aar,
contrib/android/.cache/aars/zxing-cpp.aar
# (list) List of Java files to add to the android project (can be java or a
# directory containing the files)
android.add_src = electrum/gui/qml/java_classes/
# kotlin-stdlib is required for zxing-cpp (BarcodeScannerView)
android.gradle_dependencies =
com.android.support:support-compat:28.0.0,
org.jetbrains.kotlin:kotlin-stdlib:1.8.22
android.add_activities = org.electrum.qr.SimpleScannerActivity, org.electrum.biometry.BiometricActivity
# (list) Put these files or directories in the apk res directory.
# The option may be used in three ways, the value may contain one or zero ':'
# Some examples:
# 1) A file to add to resources, legal resource names contain ['a-z','0-9','_']
# android.add_resources = my_icons/all-inclusive.png:drawable/all_inclusive.png
# 2) A directory, here 'legal_icons' must contain resources of one kind
# android.add_resources = legal_icons:drawable
# 3) A directory, here 'legal_resources' must contain one or more directories,
# each of a resource kind: drawable, xml, etc...
# android.add_resources = legal_resources
android.add_resources = electrum/gui/qml/android_res/layout:layout
# (str) python-for-android branch to use, if not master, useful to try
# not yet merged features.
#android.branch = master
# (str) OUYA Console category. Should be one of GAME or APP
# If you leave this blank, OUYA support will not be enabled
#android.ouya.category = GAME
# (str) Filename of OUYA Console icon. It must be a 732x412 png image.
#android.ouya.icon.filename = %(source.dir)s/data/ouya_icon.png
# (str) XML file to include as an intent filters in tag
android.manifest.intent_filters = contrib/android/bitcoin_intent.xml
# (str) launchMode to set for the main activity
android.manifest.launch_mode = singleTask
# (list) Android additional libraries to copy into libs/armeabi
#android.add_libs_armeabi = lib/android/*.so
# (bool) Indicate whether the screen should stay on
# Don't forget to add the WAKE_LOCK permission if you set this to True
#android.wakelock = False
# (str) The Android arch to build for, choices: armeabi-v7a, arm64-v8a, x86, x86_64
# note: can be overwritten by APP_ANDROID_ARCH env var
#android.arch = armeabi-v7a
# (int) overrides automatic versionCode computation (used in build.gradle)
# this is not the same as app version and should only be edited if you know what you're doing
# android.numeric_version = 1
# (list) Android application meta-data to set (key=value format)
#android.meta_data =
# (list) Android library project to add (will be added in the
# project.properties automatically.)
#android.library_references =
android.whitelist = lib-dynload/_csv.so
# (bool) enables Android auto backup feature (Android API >=23)
android.allow_backup = False
# (str) The format used to package the app for release mode (aab or apk or aar).
android.release_artifact = apk
# (str) The format used to package the app for debug mode (apk or aar).
android.debug_artifact = apk
#
# Python for android (p4a) specific
#
# (str) python-for-android git clone directory (if empty, it will be automatically cloned from github)
p4a.source_dir = /opt/python-for-android
# (str) The directory in which python-for-android should look for your own build recipes (if any)
p4a.local_recipes = %(source.dir)s/contrib/android/p4a_recipes/
# (str) Filename to the hook for p4a
#p4a.hook =
# (str) Bootstrap to use for android builds
p4a.bootstrap = qt6
# (int) port number to specify an explicit --port= p4a argument (eg for bootstrap flask)
#p4a.port =
#
# iOS specific
#
# (str) Name of the certificate to use for signing the debug version
# Get a list of available identities: buildozer ios list_identities
#ios.codesign.debug = "iPhone Developer: ()"
# (str) Name of the certificate to use for signing the release version
#ios.codesign.release = %(ios.codesign.debug)s
[buildozer]
# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2
# (str) Path to build output (i.e. .apk, .ipa) storage
bin_dir = ./dist
# -----------------------------------------------------------------------------
# List as sections
#
# You can define all the "list" as [section:key].
# Each line will be considered as a option to the list.
# Let's take [app] / source.exclude_patterns.
# Instead of doing:
#
# [app]
# source.exclude_patterns = license,data/audio/*.wav,data/images/original/*
#
# This can be translated into:
#
# [app:source.exclude_patterns]
# license
# data/audio/*.wav
# data/images/original/*
#
# -----------------------------------------------------------------------------
# Profiles
#
# You can extend section / key with a profile
# For example, you want to deploy a demo version of your application without
# HD content. You could first change the title to add "(demo)" in the name
# and extend the excluded directories to remove the HD content.
#
# [app@demo]
# title = My Application (demo)
#
# [app:source.exclude_patterns@demo]
# images/hd/*
#
# Then, invoke the command line with the "demo" profile:
#
# buildozer --profile demo android debug
================================================
FILE: contrib/android/dl-ndk-ci.sh
================================================
#!/bin/sh
if [ -z "$1" ]; then
echo "missing url"
exit 1
fi
echo $1
curl $1 | grep "var JSVariables" | python3 -c "import sys; line=sys.stdin.read(); line=line[line.find('{'):-2]; import json; j=json.loads(line); print(j['artifactUrl'])" | wget -i - -O android-ndk-ci-linux-x86_64.zip
================================================
FILE: contrib/android/get_apk_versioncode.py
================================================
#!/usr/bin/python3
import importlib.util
import os
import sys
ARCH_DICT = {
"x86_64": "4",
"arm64-v8a": "3",
"armeabi-v7a": "2",
"x86": "1",
"null": "0",
}
def get_electrum_version() -> str:
project_root = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
version_file_path = os.path.join(project_root, "electrum", "version.py")
# load version.py; needlessly complicated alternative to "imp.load_source":
version_spec = importlib.util.spec_from_file_location('version', version_file_path)
version_module = version = importlib.util.module_from_spec(version_spec)
version_spec.loader.exec_module(version_module)
return version.ELECTRUM_VERSION
def get_android_versioncode(*, arch_name: str) -> int:
version_code = 0
# add ELECTRUM_VERSION
app_version = get_electrum_version()
# if alpha/beta, and not stable: strip out alpha/beta part from last component.
# NOTE: we REUSE the version_code int between alphas/betas and the final stable.
# This is not allowed on Google Play or F-Droid.
# This means we MUST NOT upload alphas/betas there.
if any(c in app_version for c in ("a", "b")):
c_pos = app_version.find("a")
if c_pos == -1:
c_pos = app_version.find("b")
app_version = app_version[:c_pos]
# now the app_version str must contain exactly three dot-delimited components
app_version_components = app_version.split('.')
assert len(app_version_components) == 3, f"version str expected to have 3 components, but got {app_version!r}"
# convert to int
for i in app_version_components:
version_code *= 100
version_code += int(i)
# add arch
arch_code = ARCH_DICT[arch_name]
assert len(arch_code) == 1
version_code *= 10
version_code += int(arch_code)
# compensate for legacy scheme
# note: up until version 4.5.5, we used a different scheme for version_code.
# 4_______________4_05_05_00
# ^ android arch, ^ app_version (4.5.5.0)
# This offset ensures that all new-scheme version codes are larger than the old-scheme version codes.
offset_due_to_legacy_scheme = 45_000_000
version_code += offset_due_to_legacy_scheme
return version_code
if __name__ == '__main__':
try:
android_arch = sys.argv[1]
except Exception:
print(f"usage: {os.path.basename(__file__)} ", file=sys.stderr)
sys.exit(1)
if android_arch not in ARCH_DICT:
print(f"usage: {os.path.basename(__file__)} ", file=sys.stderr)
print(f"error: unknown {android_arch=}", file=sys.stderr)
print(f" should be one of: {list(ARCH_DICT.keys())}", file=sys.stderr)
sys.exit(1)
version_code = get_android_versioncode(arch_name=android_arch)
assert isinstance(version_code, int), f"{version_code=!r} must be an int."
print(version_code, file=sys.stdout)
================================================
FILE: contrib/android/make_apk.sh
================================================
#!/bin/bash
set -e
CONTRIB_ANDROID="$(dirname "$(readlink -e "$0")")"
CONTRIB="$CONTRIB_ANDROID"/..
PROJECT_ROOT="$CONTRIB"/..
PACKAGES="$PROJECT_ROOT"/packages/
. "$CONTRIB"/build_tools_util.sh
git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
# arguments have been checked in build.sh
export ELEC_APK_GUI=$1
if [ ! -d "$PACKAGES" ]; then
"$CONTRIB"/make_packages.sh || fail "make_packages failed"
fi
# update locale
info "preparing electrum-locale."
(
"$CONTRIB/locale/build_cleanlocale.sh"
# we want the binary to have only compiled (.mo) locale files; not source (.po) files
rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/electrum.po
)
pushd "$CONTRIB_ANDROID"
info "apk building phase starts."
# Uncomment and change below to set a custom android package id,
# e.g. to allow simultaneous mainnet and testnet installs of the apk.
# defaults:
# export APP_PACKAGE_NAME=Electrum
# export APP_PACKAGE_DOMAIN=org.electrum
# FIXME: changing "APP_PACKAGE_NAME" seems to require a clean rebuild of ".buildozer/",
# to avoid that, maybe change "APP_PACKAGE_DOMAIN" instead.
# So, in particular, to build a testnet apk, simply uncomment:
#export APP_PACKAGE_DOMAIN=org.electrum.testnet
if [ $CI ]; then
# override log level specified in buildozer.spec to "debug":
export BUILDOZER_LOG_LEVEL=2
fi
if [[ "$3" == "release" ]] ; then
# do release build, and sign the APKs.
TARGET="release"
export P4A_RELEASE_KEYSTORE_PASSWD="$4"
export P4A_RELEASE_KEYALIAS_PASSWD="$4"
export P4A_RELEASE_KEYSTORE=~/.keystore
export P4A_RELEASE_KEYALIAS=electrum
if [ -z "$P4A_RELEASE_KEYSTORE_PASSWD" ] || [ -z "$P4A_RELEASE_KEYALIAS_PASSWD" ]; then
echo "p4a password not defined"
exit 1
fi
elif [[ "$3" == "release-unsigned" ]] ; then
# do release build, but do not sign the APKs.
TARGET="release"
elif [[ "$3" == "debug" ]] ; then
# do debug build.
TARGET="apk"
export P4A_DEBUG_KEYSTORE="$CONTRIB_ANDROID"/android_debug.keystore
export P4A_DEBUG_KEYSTORE_PASSWD=unsafepassword
export P4A_DEBUG_KEYALIAS_PASSWD=unsafepassword
export P4A_DEBUG_KEYALIAS=electrum
# create keystore if needed
if [ ! -f "$P4A_DEBUG_KEYSTORE" ]; then
keytool -genkey -v -keystore "$CONTRIB_ANDROID"/android_debug.keystore \
-alias "$P4A_DEBUG_KEYALIAS" -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=mqttserver.ibm.com, OU=ID, O=IBM, L=Hursley, S=Hants, C=GB" \
-storepass "$P4A_DEBUG_KEYSTORE_PASSWD" \
-keypass "$P4A_DEBUG_KEYALIAS_PASSWD"
fi
export ELEC_APK_USE_CURRENT_TIME=1
else
fail "unknown build type"
fi
if [[ "$2" == "all" ]] ; then
# build all apks
# FIXME failures are not propagated out: we should fail the script if any arch build fails
export APP_ANDROID_ARCHS=armeabi-v7a
export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
"$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
make $TARGET
export APP_ANDROID_ARCHS=arm64-v8a
export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
"$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
make $TARGET
export APP_ANDROID_ARCHS=x86_64
export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
"$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
make $TARGET
else
export APP_ANDROID_ARCHS=$2
export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
"$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
make $TARGET
fi
popd
info "done."
ls -la "$PROJECT_ROOT/dist"
sha256sum "$PROJECT_ROOT/dist"/*
================================================
FILE: contrib/android/make_barcode_scanner.sh
================================================
#!/bin/bash
# script to clone and build https://github.com/markusfisch/BarcodeScannerView and its dependencies,
# https://github.com/markusfisch/CameraView/ and https://github.com/markusfisch/zxing-cpp
# which are being used as barcode scanner in the Android app.
# To bump the version of BarcodeScannerView, get the newest version tag from the github repo,
# then get the required dependencies from
# https://github.com/markusfisch/BarcodeScannerView/blob/**VERSION_TAG**/barcodescannerview/build.gradle
# then update the commit hashes below. Also update kotlin-stdlib in buildozer_qml.spec to the
# "kotlin-version" specified in the used zxing-cpp commit:
# https://github.com/markusfisch/zxing-cpp/blob/master/wrappers/aar/build.gradle
BARCODE_SCANNER_VIEW_COMMIT_HASH="0bdb69269c252bb6daef2f871b76403c8b051945" # 1.6.5
BARCODE_SCANNER_VIEW_REPO="https://github.com/markusfisch/BarcodeScannerView.git"
CAMERA_VIEW_COMMIT_HASH="745597d05bc6abfdb3637a09a8ecaf30fdce7b6e" # 1.10.0
CAMERA_VIEW_REPO="https://github.com/markusfisch/CameraView.git"
ZXING_CPP_COMMIT_HASH="79f5adc6250e90de0bd635eb9181c5f8a18affda" # v2.3.0.4 using kotlin-stdlib 1.8.22
ZXING_CPP_REPO="https://github.com/markusfisch/zxing-cpp.git"
########################################################################################################
set -e
CONTRIB_ANDROID="$(dirname "$(readlink -e "$0")")"
CONTRIB="$CONTRIB_ANDROID"/..
CACHEDIR="$CONTRIB_ANDROID/.cache"
BUILDDIR="$CACHEDIR/builds"
. "$CONTRIB"/build_tools_util.sh
# target architecture passed as argument by`make_apk.sh`
TARGET_ARCH="$1"
# check if TARGET_ARCH is set and supported
if [[ "$TARGET_ARCH" != "armeabi-v7a" \
&& "$TARGET_ARCH" != "arm64-v8a" \
&& "$TARGET_ARCH" != "x86_64" ]]; then
fail "make_barcode_scanner.sh invalid target architecture argument: $TARGET_ARCH"
fi
info "Building BarcodeScannerView and deps for architecture: $TARGET_ARCH"
# check if directories exist, create them if not
if [ ! -d "$CACHEDIR/aars" ]; then
mkdir -p "$CACHEDIR/aars"
fi
if [ ! -d "$BUILDDIR" ]; then
mkdir -p "$BUILDDIR"
fi
####### zxing-cpp ########
# check if zxing-cpp aar is already in cachedir, else build it
ZXING_CPP_BUILD_ID="$TARGET_ARCH-$ZXING_CPP_COMMIT_HASH"
if [ -f "$CACHEDIR/aars/zxing-cpp-$ZXING_CPP_BUILD_ID.aar" ]; then
info "zxing-cpp for $ZXING_CPP_BUILD_ID already exists in cache, skipping build."
cp "$CACHEDIR/aars/zxing-cpp-$ZXING_CPP_BUILD_ID.aar" "$CACHEDIR/aars/zxing-cpp.aar"
else
info "Building zxing-cpp for $ZXING_CPP_BUILD_ID..."
ZXING_CPP_DIR="$BUILDDIR/zxing-cpp"
clone_or_update_repo "$ZXING_CPP_REPO" "$ZXING_CPP_COMMIT_HASH" "$ZXING_CPP_DIR"
cd "$ZXING_CPP_DIR/wrappers/aar"
chmod +x gradlew
# Set local.properties to use SDK of docker container
echo "sdk.dir=${ANDROID_SDK_HOME}" > local.properties
# gradlew will install a specific NDK version required by zxing-cpp
./gradlew :zxingcpp:assembleRelease -Pandroid.injected.build.abi="$TARGET_ARCH"
# Copy the built AAR to cache directory
ZXING_AAR_SOURCE="$ZXING_CPP_DIR/wrappers/aar/zxingcpp/build/outputs/aar/zxingcpp-release.aar"
ZXING_AAR_DEST_GENERIC="$CACHEDIR/aars/zxing-cpp.aar"
ZXING_AAR_DEST_SPECIFIC="$CACHEDIR/aars/zxing-cpp-$ZXING_CPP_BUILD_ID.aar"
if [ ! -f "$ZXING_AAR_SOURCE" ]; then
fail "zxing-cpp AAR not found at $ZXING_AAR_SOURCE, build failed?"
fi
cp "$ZXING_AAR_SOURCE" "$ZXING_AAR_DEST_GENERIC"
# keeping an arch specific copy allows to skip the build later if it already exists
cp "$ZXING_AAR_SOURCE" "$ZXING_AAR_DEST_SPECIFIC"
info "zxing-cpp AAR copied to $ZXING_AAR_DEST_GENERIC"
fi
########### CameraView ###########
CAMERA_VIEW_BUILD_ID="$CAMERA_VIEW_COMMIT_HASH"
if [ -f "$CACHEDIR/aars/CameraView-$CAMERA_VIEW_BUILD_ID.aar" ]; then
info "CameraView AAR already exists in cache, skipping build."
cp "$CACHEDIR/aars/CameraView-$CAMERA_VIEW_BUILD_ID.aar" "$CACHEDIR/aars/CameraView.aar"
else
info "Building CameraView..."
CAMERA_VIEW_DIR="$BUILDDIR/CameraView"
clone_or_update_repo "$CAMERA_VIEW_REPO" "$CAMERA_VIEW_COMMIT_HASH" "$CAMERA_VIEW_DIR"
cd "$CAMERA_VIEW_DIR"
chmod +x gradlew
echo "sdk.dir=${ANDROID_SDK_HOME}" > local.properties
./gradlew :cameraview:assembleRelease
CAMERA_AAR_SOURCE="$CAMERA_VIEW_DIR/cameraview/build/outputs/aar/cameraview-release.aar"
CAMERA_AAR_DEST_GENERIC="$CACHEDIR/aars/CameraView.aar"
CAMERA_AAR_DEST_SPECIFIC="$CACHEDIR/aars/CameraView-$CAMERA_VIEW_BUILD_ID.aar"
if [ ! -f "$CAMERA_AAR_SOURCE" ]; then
fail "CameraView AAR not found at $CAMERA_AAR_SOURCE"
fi
cp "$CAMERA_AAR_SOURCE" "$CAMERA_AAR_DEST_GENERIC"
cp "$CAMERA_AAR_SOURCE" "$CAMERA_AAR_DEST_SPECIFIC"
info "CameraView AAR copied to $CAMERA_AAR_DEST_GENERIC"
fi
########### BarcodeScannerView ###########
BARCODE_SCANNER_VIEW_BUILD_ID="$BARCODE_SCANNER_VIEW_COMMIT_HASH"
if [ -f "$CACHEDIR/aars/BarcodeScannerView-$BARCODE_SCANNER_VIEW_BUILD_ID.aar" ]; then
info "BarcodeScannerView AAR already exists in cache, skipping build."
cp "$CACHEDIR/aars/BarcodeScannerView-$BARCODE_SCANNER_VIEW_BUILD_ID.aar" "$CACHEDIR/aars/BarcodeScannerView.aar"
else
info "Building BarcodeScannerView..."
BARCODE_SCANNER_VIEW_DIR="$BUILDDIR/BarcodeScannerView"
clone_or_update_repo "$BARCODE_SCANNER_VIEW_REPO" "$BARCODE_SCANNER_VIEW_COMMIT_HASH" "$BARCODE_SCANNER_VIEW_DIR"
cd "$BARCODE_SCANNER_VIEW_DIR"
chmod +x gradlew
echo "sdk.dir=${ANDROID_SDK_HOME}" > local.properties
./gradlew :barcodescannerview:assembleRelease
BARCODE_AAR_SOURCE="$BARCODE_SCANNER_VIEW_DIR/barcodescannerview/build/outputs/aar/barcodescannerview-release.aar"
BARCODE_AAR_DEST_GENERIC="$CACHEDIR/aars/BarcodeScannerView.aar"
BARCODE_AAR_DEST_SPECIFIC="$CACHEDIR/aars/BarcodeScannerView-$BARCODE_SCANNER_VIEW_BUILD_ID.aar"
if [ ! -f "$BARCODE_AAR_SOURCE" ]; then
fail "BarcodeScannerView AAR not found at $BARCODE_AAR_SOURCE"
fi
cp "$BARCODE_AAR_SOURCE" "$BARCODE_AAR_DEST_GENERIC"
cp "$BARCODE_AAR_SOURCE" "$BARCODE_AAR_DEST_SPECIFIC"
info "BarcodeScannerView AAR copied to $BARCODE_AAR_DEST_GENERIC"
fi
info "All barcode scanner libraries built successfully for $TARGET_ARCH"
================================================
FILE: contrib/android/p4a_recipes/README.md
================================================
python-for-android local recipes
--------------------------------
These folders are recipes (build scripts) for most of our direct and transitive
dependencies for the Android app. python-for-android has recipes built-in for
many packages but it also allows users to specify their "local" recipes.
Local recipes have precedence over the built-in recipes.
The local recipes we have here are mostly just used to pin down specific
versions and hashes for reproducibility. The hashes are updated manually.
================================================
FILE: contrib/android/p4a_recipes/cffi/__init__.py
================================================
import os
from pythonforandroid.recipes.cffi import CffiRecipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert CffiRecipe._version == "1.15.1"
assert CffiRecipe.depends == ['setuptools', 'pycparser', 'libffi', 'python3']
assert CffiRecipe.python_depends == []
class CffiRecipePinned(util.InheritedRecipeMixin, CffiRecipe):
version = "1.17.1"
sha512sum = "907129891d56351ca5cb885aae62334ad432321826d6eddfaa32195b4c7b7689a80333e6d14d0aab479a646aba148b9852c0815b80344dfffa4f183a5e74372c"
recipe = CffiRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/cryptography/__init__.py
================================================
from pythonforandroid.recipes.cryptography import CryptographyRecipe
assert CryptographyRecipe._version == "2.8"
assert CryptographyRecipe.depends == ['openssl', 'six', 'setuptools', 'cffi', 'python3']
assert CryptographyRecipe.python_depends == []
class CryptographyRecipePinned(CryptographyRecipe):
sha512sum = "000816a5513691bfbb01c5c65d96fb3567a5ff25300da4b485e716b6d4dc789aec05ed0fe65df9c5e3e60127aa9110f04e646407db5b512f88882b0659f7123f"
recipe = CryptographyRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/hostpython3/__init__.py
================================================
import os
from pythonforandroid.recipes.hostpython3 import HostPython3Recipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert HostPython3Recipe.depends == []
assert HostPython3Recipe.python_depends == []
class HostPython3RecipePinned(util.InheritedRecipeMixin, HostPython3Recipe):
# PYTHON_VERSION= # < line here so that I can grep the codebase and teleport here
version = "3.11.14"
sha512sum = "41fb3ae22ce4ac0e8bb6b9ae8db88a810af1001d944e3f1abc9e86824ae4be31347e3e3a70425ab12271c6b7eeef552f00164ef23cfffa2551c3c9d1fe5ab91f"
recipe = HostPython3RecipePinned()
================================================
FILE: contrib/android/p4a_recipes/libffi/__init__.py
================================================
import os
from pythonforandroid.recipes.libffi import LibffiRecipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert LibffiRecipe._version == "v3.4.2"
assert LibffiRecipe.depends == []
assert LibffiRecipe.python_depends == []
class LibffiRecipePinned(util.InheritedRecipeMixin, LibffiRecipe):
version = "v3.4.8"
sha512sum = "064a43ddae005f3d0fa56db4da6071fae93aaae87a755b84888c0cb9c8fa2fe9bb452b3d9a382fab64c442c19d98a20ba15b8be92eba7bf3773815b31fb7824c"
recipe = LibffiRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/libiconv/__init__.py
================================================
import os
from pythonforandroid.recipes.libiconv import LibIconvRecipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert LibIconvRecipe._version == "1.16"
assert LibIconvRecipe.depends == []
assert LibIconvRecipe.python_depends == []
class LibIconvRecipePinned(util.InheritedRecipeMixin, LibIconvRecipe):
version = "1.18"
sha512sum = "a55eb3b7b785a78ab8918db8af541c9e11deb5ff4f89d54483287711ed797d87848ce0eafffa7ce26d9a7adb4b5a9891cb484f94bd4f51d3ce97a6a47b4c719a"
recipe = LibIconvRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/libsecp256k1/__init__.py
================================================
from pythonforandroid.recipes.libsecp256k1 import LibSecp256k1Recipe
assert LibSecp256k1Recipe.depends == []
assert LibSecp256k1Recipe.python_depends == []
class LibSecp256k1RecipePinned(LibSecp256k1Recipe):
version = "1a53f4961f337b4d166c25fce72ef0dc88806618"
url = "https://github.com/bitcoin-core/secp256k1/archive/{version}.zip"
sha512sum = "4072e45517bc1bb416250bc8e4fa4ed94f83b4eebbe25a70925fd7cc9759df3edbce64ab0116519c335f82353f6a029cde92018ed7116f2f85c8092a9adeb532"
recipe = LibSecp256k1RecipePinned()
================================================
FILE: contrib/android/p4a_recipes/libzbar/__init__.py
================================================
import os
from pythonforandroid.recipes.libzbar import LibZBarRecipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert LibZBarRecipe.depends == ['libiconv']
assert LibZBarRecipe.python_depends == []
class LibZBarRecipePinned(util.InheritedRecipeMixin, LibZBarRecipe):
version = "bb05ec54eec57f8397cb13fb9161372a281a1219"
url = "https://github.com/mchehab/zbar/archive/{version}.zip"
sha512sum = "186312ef0a50404efef79a5fbed34534569fab2873a6bb6d2e3d8ea64fa461c5537ca4fb0e659670d72b021e514f8fd4651b1e85954bf987015d8eb2e6f68375"
patches = [] # werror.patch not needed for modern zbar
recipe = LibZBarRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/openssl/__init__.py
================================================
import os
from pythonforandroid.recipes.openssl import OpenSSLRecipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert OpenSSLRecipe._version == "3.0.18"
assert OpenSSLRecipe.depends == []
assert OpenSSLRecipe.python_depends == []
class OpenSSLRecipePinned(util.InheritedRecipeMixin, OpenSSLRecipe):
version = "3.0.18"
sha512sum = "6bdd16f33b83ae2a12777230c4ff00d0595bbc00253ac8c3ac31e1375e818fc74d7f491bd2e507ff33cab9f0498cfb28fa8690f75a98663568d40901523cdf3c"
recipe = OpenSSLRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/packaging/__init__.py
================================================
from pythonforandroid.recipes.packaging import PackagingRecipe
assert PackagingRecipe._version == "21.3"
assert PackagingRecipe.depends == ["setuptools", "pyparsing", "python3"]
assert PackagingRecipe.python_depends == []
class PackagingRecipePinned(PackagingRecipe):
#version = "21.3"
# note: 21.3 is the last version to use setup.py, so newer versions don't work. see comment for PyparsingRecipePinned
sha512sum = "2e3aa276a4229ac7dc0654d586799473ced9761a83aa4159660d37ae1a2a8f30e987248dd0e260e2834106b589f259a57ce9936eef0dcc3c430a99ac6b663e05"
recipe = PackagingRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/ply/__init__.py
================================================
import os
from pythonforandroid.recipes.ply import PlyRecipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert PlyRecipe._version == "3.11"
assert PlyRecipe.depends == ['packaging', 'python3']
assert PlyRecipe.python_depends == []
class PlyRecipePinned(util.InheritedRecipeMixin, PlyRecipe):
sha512sum = "37e39a4f930874933223be58a3da7f259e155b75135f1edd47069b3b40e5e96af883ebf1c8a1bbd32f914a9e92cfc12e29fec05cf61b518f46c1d37421b20008"
recipe = PlyRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/plyer/__init__.py
================================================
from pythonforandroid.recipe import PythonRecipe
assert PythonRecipe.depends == ['python3']
assert PythonRecipe.python_depends == []
class PlyerRecipePinned(PythonRecipe):
version = "5262087c85b2c82c69e702fe944069f1d8465fdf"
url = "git+https://github.com/SomberNight/plyer"
depends = ["setuptools"]
recipe = PlyerRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/pycparser/__init__.py
================================================
from pythonforandroid.recipes.pycparser import PycparserRecipe
assert PycparserRecipe._version == "2.14"
assert PycparserRecipe.depends == ['setuptools', 'python3']
assert PycparserRecipe.python_depends == []
class PycparserRecipePinned(PycparserRecipe):
version = "2.22"
sha512sum = "c9a81c78d87162f71281a32a076b279f4f7f2e17253fe14c89c6db5f9b3554a6563ff700c385549a8b51ef8832f99f7bb4ac07f22754c7c475dd91feeb0cf87f"
recipe = PycparserRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/pycryptodomex/__init__.py
================================================
from pythonforandroid.recipe import PythonRecipe
assert PythonRecipe.depends == ['python3']
assert PythonRecipe.python_depends == []
class PycryptodomexRecipe(PythonRecipe):
version = "3.23.0"
sha512sum = "951cebaad2e19b9f9d04fe85c73ab1ff8b515069c1e0e8e3cd6845ec9ccd5ef3e5737259e0934ed4a6536e289dee6aabac58e1c822a5a6393e86b482c60afc89"
url = "https://github.com/Legrandin/pycryptodome/archive/v{version}x.tar.gz"
depends = ["setuptools", "cffi"]
recipe = PycryptodomexRecipe()
================================================
FILE: contrib/android/p4a_recipes/pyjnius/__init__.py
================================================
import os
from pythonforandroid.recipes.pyjnius import PyjniusRecipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert PyjniusRecipe._version == "1.5.0"
assert PyjniusRecipe.depends == [('genericndkbuild', 'sdl2', 'qt6'), 'six', 'python3']
assert PyjniusRecipe.python_depends == []
class PyjniusRecipePinned(util.InheritedRecipeMixin, PyjniusRecipe):
version = "1.6.1"
sha512sum = "deb5ac566479111c6f4c6adb895821b263d72bf88414fb093bdfd5ad5d0b7aea56b53d5ef0967e28db360f4fb6fb1c2264123f15c747884799df55848191c424"
recipe = PyjniusRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/pyparsing/__init__.py
================================================
from pythonforandroid.recipes.pyparsing import PyparsingRecipe
assert PyparsingRecipe._version == "3.0.7"
assert PyparsingRecipe.depends == ["setuptools", "python3"]
assert PyparsingRecipe.python_depends == []
class PyparsingRecipePinned(PyparsingRecipe):
#version = "3.0.7"
# note: 3.0.7 is the last version to use setup.py, so newer versions don't work,
# as p4a runs "$ python3 setup.py install". This is only going become a larger problem, needs fix upstream.
# see https://github.com/kivy/python-for-android/blob/be3de2e28e5a52d5f8949f3969f8a3b7f9eb3cba/pythonforandroid/recipe.py#L983
# - but maybe upstream p4a already has a workaround?
# see "PyProjectRecipe" from https://github.com/kivy/python-for-android/pull/3007
sha512sum = "1e692f4cdaa6b6e8ca2729d0a3e2ba16d978f1957c538b6de3a4220ec7d996bdbe87c41c43abab851fffa3b0498a05841373e435602917b8c095042e273badb5"
recipe = PyparsingRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/pyqt6/__init__.py
================================================
import os
from pythonforandroid.recipes.pyqt6 import PyQt6Recipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert PyQt6Recipe._version == "6.4.2"
assert PyQt6Recipe.depends == ['qt6', 'pyjnius', 'setuptools', 'pyqt6sip', 'hostpython3', 'pyqt_builder']
assert PyQt6Recipe.python_depends == []
class PyQt6RecipePinned(util.InheritedRecipeMixin, PyQt6Recipe):
sha512sum = "51e5f0d028ee7984876da1653cb135d61e2c402f18b939a92477888cc7c86d3bc2889477403dee6b3d9f66519ee3236d344323493b4c2c2e658e1637b10e53bf"
recipe = PyQt6RecipePinned()
================================================
FILE: contrib/android/p4a_recipes/pyqt6sip/__init__.py
================================================
import os
from pythonforandroid.recipes.pyqt6sip import PyQt6SipRecipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert PyQt6SipRecipe._version == "13.5.1"
assert PyQt6SipRecipe.depends == ['setuptools', 'python3']
assert PyQt6SipRecipe.python_depends == []
class PyQt6SipRecipePinned(util.InheritedRecipeMixin, PyQt6SipRecipe):
sha512sum = "1e4170d167a326afe6df86e4a35e209299548054981cb2e5d56da234ef9db4d8594bcb05b6be363c3bc6252776ae9de63d589a3d9f33fba8250d39cdb5e9061a"
recipe = PyQt6SipRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/pyqt_builder/__init__.py
================================================
from pythonforandroid.recipes.pyqt_builder import PyQtBuilderRecipe
assert PyQtBuilderRecipe._version == "1.15.1"
assert PyQtBuilderRecipe.depends == ["sip", "packaging", "python3"]
assert PyQtBuilderRecipe.python_depends == []
class PyQtBuilderRecipePinned(PyQtBuilderRecipe):
sha512sum = "61ee73b6bb922c04739da60025ab50d35d345d2e298943305fcbd3926cda31d732cc5e5b0dbfc39f5eb85c0f0b091b8c3f5fee00dcc240d7849c5c4191c1368a"
recipe = PyQtBuilderRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/python3/__init__.py
================================================
import os
from pythonforandroid.recipes.python3 import Python3Recipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert Python3Recipe.depends == ['hostpython3', 'sqlite3', 'openssl', 'libffi']
assert Python3Recipe.python_depends == []
class Python3RecipePinned(util.InheritedRecipeMixin, Python3Recipe):
# PYTHON_VERSION= # < line here so that I can grep the codebase and teleport here
version = "3.11.14"
sha512sum = "41fb3ae22ce4ac0e8bb6b9ae8db88a810af1001d944e3f1abc9e86824ae4be31347e3e3a70425ab12271c6b7eeef552f00164ef23cfffa2551c3c9d1fe5ab91f"
recipe = Python3RecipePinned()
================================================
FILE: contrib/android/p4a_recipes/qt6/__init__.py
================================================
import os
from pythonforandroid.recipes.qt6 import Qt6Recipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert Qt6Recipe._version == "6.4.3"
# assert Qt6Recipe._version == "6.5.3"
assert Qt6Recipe.depends == ['python3', 'hostqt6']
assert Qt6Recipe.python_depends == []
class Qt6RecipePinned(util.InheritedRecipeMixin, Qt6Recipe):
sha512sum = "0bdbe8b9a43390c98cf19e851ec5394bc78438d227cf9d0d7a3748aee9a32a7f14fc46f52d4fa283819f21413567080aee7225c566af5278557f5e1992674da3"
# sha512sum = "ca8ea3b81c121886636988275f7fa8ae6d19f7be02669e63ab19b4285b611057a41279db9532c25ae87baa3904b010e1db68b899cd0eda17a5a8d3d87098b4d5"
recipe = Qt6RecipePinned()
================================================
FILE: contrib/android/p4a_recipes/setuptools/__init__.py
================================================
from pythonforandroid.recipes.setuptools import SetuptoolsRecipe
assert SetuptoolsRecipe._version == "51.3.3"
assert SetuptoolsRecipe.depends == ['python3']
assert SetuptoolsRecipe.python_depends == []
class SetuptoolsRecipePinned(SetuptoolsRecipe):
sha512sum = "5a3572466a68c6f650111448ce3343f64c62044650bb8635edbff97e2bc7b216b8bbe3b4e3bccf34e6887f3bedc911b27ca5f9a515201cae49cf44fbacf03345"
recipe = SetuptoolsRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/sip/__init__.py
================================================
from pythonforandroid.recipes.sip import SipRecipe
assert SipRecipe._version == "6.7.9"
assert SipRecipe.depends == ["setuptools", "packaging", "tomli", "ply", "python3"], SipRecipe.depends
assert SipRecipe.python_depends == []
class SipRecipePinned(SipRecipe):
sha512sum = "bb9d0d0d92002b6fd33f7e8ebe8cd62456dacc16b5734b73760b1ba14fb9b1f2b9b6640b40196c6cf5f345e1afde48bdef39675c4d3480041771325d4cf3c233"
recipe = SipRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/six/__init__.py
================================================
from pythonforandroid.recipes.six import SixRecipe
assert SixRecipe._version == "1.15.0"
assert SixRecipe.depends == ['setuptools', 'python3']
assert SixRecipe.python_depends == []
class SixRecipePinned(SixRecipe):
version = "1.17.0"
sha512sum = "fcfa58b03877ac3ac00a4f85b5fea4fecb2a010244451aa95013637a0aa21529f3dcfe25c0a07c72da46da1fa12bc0c16b6c641c40c6ab2133e5b5cbb5a71e4b"
recipe = SixRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/sqlite3/__init__.py
================================================
import os
from pythonforandroid.recipes.sqlite3 import Sqlite3Recipe
from pythonforandroid.util import load_source
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert Sqlite3Recipe._version == "3.35.5"
assert Sqlite3Recipe.depends == []
assert Sqlite3Recipe.python_depends == []
class Sqlite3RecipePinned(util.InheritedRecipeMixin, Sqlite3Recipe):
version = "3.50.0"
url = 'https://www.sqlite.org/2025/sqlite-amalgamation-3500000.zip'
sha512sum = "0fd87f2b8140300ce165600f6708aafef19041a181e9f00ed14f7aeaa3c06805c8c54c53751a9ce74d4d666f018ca6f48e3f5b5c874ccb9e1424a528c92326f0"
recipe = Sqlite3RecipePinned()
================================================
FILE: contrib/android/p4a_recipes/toml/__init__.py
================================================
from pythonforandroid.recipes.toml import TomlRecipe
assert TomlRecipe._version == "0.10.2"
assert TomlRecipe.depends == ["setuptools", "python3"]
assert TomlRecipe.python_depends == []
class TomlRecipePinned(TomlRecipe):
sha512sum = "ede2c8fed610a3827dba828f6e7ab7a8dbd5745e8ef7c0cd955219afdc83b9caea714deee09e853627f05ad1c525dc60426a6e9e16f58758aa028cb4d3db4b39"
recipe = TomlRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/tomli/__init__.py
================================================
from pythonforandroid.recipes.tomli import TomliRecipe
assert TomliRecipe._version == "2.0.1"
assert TomliRecipe.depends == ["setuptools", "python3"]
assert TomliRecipe.python_depends == []
class TomliRecipePinned(TomliRecipe):
#version = "2.0.1"
# note: can't be easily updated as base recipe has version number hardcoded in custom "patch"-like setup.py
sha512sum = "fd410039e255e2b3359e999d69a5a2d38b9b89b77e8557f734f2621dfbd5e1207e13aecc11589197ec22594c022f07f41b4cfe486a3a719281a595c95fd19ecf"
recipe = TomliRecipePinned()
================================================
FILE: contrib/android/p4a_recipes/util.py
================================================
import os
class InheritedRecipeMixin:
def get_recipe_dir(self):
"""This is used to replace pythonforandroid.recipe.Recipe.get_recipe_dir.
If one of our local recipes inherits from a built-in p4a recipe, this override
ensures that potential patches and other local files used by the recipe will
be looked for in the built-in recipe's folder.
"""
return os.path.join(self.ctx.root_dir, 'recipes', self.name)
================================================
FILE: contrib/apparmor/README.md
================================================
# Electrum AppArmor Profiles
AppArmor is a Mandatory Access Control (MAC) system which confines programs to a limited set of resources.
AppArmor confinement is provided via profiles loaded into the kernel.
## Installation
Copy the AppArmor profile from `contrib/apparmor/apparmor.d/` to `/etc/apparmor.d/`:
```
sudo cp -R -L contrib/apparmor/apparmor.d/* /etc/apparmor.d
```
Reload the AppArmor profiles to apply the changes:
```
sudo systemctl reload apparmor
```
Verify that the profile is loaded:
```
sudo apparmor_status
```
Look for the entry corresponding to `electrum`
## Usage
After installing the AppArmor profile, electrum will be restricted to the permissions specified in the profile.
## Compatibility
The help tab may not function as expected as browser permissions can be tricky (Tarball Binaries)
These AppArmor profiles have been tested on the following operating systems:
```
Debian 12
Ubuntu 23.10
Kali Linux 6.6
```
================================================
FILE: contrib/apparmor/apparmor.d/abstractions/electrum
================================================
include
include
include
include
include
include
include
include
include
include
include
include if exists
include if exists
include if exists
include if exists
owner @{PROC}/@{pid}/{mounts,fd/} r,
/{usr/,}sbin/ldconfig ix,
/{usr/,}bin/{file,dash,dirname,uname} rix,
/{usr/,}bin/@{multiarch}-gcc-8 ix,
/{usr/,}bin/@{multiarch}-ld.bfd ix,
/etc/mime.types r,
@{system_share_dirs}/{mime,icons}/{**,} r,
/dev/bus/usb/ r,
/dev/bus/usb/** rw,
@{sys}/class/ r,
@{sys}/bus/ r,
/etc/udev/udev.conf r,
/etc/magic r,
@{sys}/devices/pci*/**/usb*/**{busnum,devnum,descriptors,speed,bConfigurationValue} r,
/dev/ r,
/{var/,}run/udev/data/* r,
@{sys}/bus/usb/devices/ r,
/{usr/,}/bin/uname rix,
owner @{user_share_dirs}/mime/** r,
/{,run/}user/**/dconf/* rw,
/{var/,}lib/dbus/** r,
/etc/apt/apt.conf.d/ r,
/etc/machine-id r,
/{usr/,}bin/xdg-open ix,
/{usr/,}bin/evince ix,
================================================
FILE: contrib/apparmor/apparmor.d/electrum.appimage
================================================
# Credits : Mikhail Morfikov
abi ,
include
@{exec_path} = /{usr/,}bin/fusermount{,3}
profile fusermount @{exec_path} {
include
include
# To mount anything:
# fusermount: mount failed: Operation not permitted
capability sys_admin,
# For jmtpfs
capability dac_read_search,
@{exec_path} mr,
# Where to mount ISO files
owner @{HOME}/*/ rw,
owner @{HOME}/*/*/ rw,
owner @{HOME}/.cache/**/ rw,
# Be able to mount ISO images
mount fstype={fuse,fuse.*},
unmount fstype={fuse,fuse.*},
/etc/fuse.conf r,
/dev/fuse rw,
@{PROC}/@{pid}/mounts r,
include if exists
}
================================================
FILE: contrib/apparmor/apparmor.d/usr.local.bin.electrum
================================================
#Credits: Anton Nesterov
abi ,
include
@{electrum_exec_path} = /{usr/,usr/local/,*/*/.local/,}bin/electrum
profile electrum @{electrum_exec_path} {
include
@{electrum_exec_path} mr,
owner @{HOME}/.electrum/{**,} rw,
owner @{HOME}/.local/{**,} mrw,
}
================================================
FILE: contrib/ban_unicode.py
================================================
#!/usr/bin/env python3
#
# Copyright (C) 2025 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
#
# This script scans the whole codebase for unicode characters and
# errors if it finds any, unless the character is specifically whitelisted below.
# The motivation is to protect against homoglyph attacks, invisible unicode characters,
# bidirectional and other control characters, and other malicious unicode usage.
# Given that we mostly expect to use ASCII characters in the source code,
# the most robust and generic fix seems to be to just ban all unicode usage.
import os.path
import subprocess
import sys
project_root = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
os.chdir(project_root)
EXCLUDE_PATH_PREFIX = {
"electrum/wordlist/",
"fastlane/",
"tests/",
}
EXCLUDE_EXTENSIONS = {
".jpg", ".jpeg", ".png", ".ttf", ".otf", ".pdn", ".icns", ".ico", ".gif",
}
UNICODE_WHITELIST = {
"💬", "🗯", "⚠", chr(0xfe0f), "✓", "▷", "▽", "…", "•", "█", "™", "≈",
"á", "é", "’",
"│", "─", "└", "├", "📋",
}
exit_code = 0
bfiles = subprocess.check_output(["git", "ls-files"])
bfiles = bfiles.decode("utf-8")
for file_path in bfiles.splitlines():
if os.path.isdir(file_path):
continue
if any(file_path.startswith(pattern) for pattern in EXCLUDE_PATH_PREFIX):
continue
_fname, ext = os.path.splitext(file_path)
if ext in EXCLUDE_EXTENSIONS:
continue
# open file
try:
with open(file_path, "r", encoding="utf-8") as f:
for line_no, line in enumerate(f.read().splitlines()):
for char in line:
if ord(char)>0x7f and char not in UNICODE_WHITELIST:
print(f"{file_path}:{line_no}. {line=}. hex={hex(ord(char))}. {char=}")
exit_code = 1
except UnicodeDecodeError as e:
raise Exception(f"cannot parse file {file_path=}") from e
sys.exit(exit_code)
================================================
FILE: contrib/build-linux/appimage/.dockerignore
================================================
build/
.cache/
================================================
FILE: contrib/build-linux/appimage/Dockerfile
================================================
# Note: we deliberately use an old Debian stable as base image.
# from https://docs.appimage.org/introduction/concepts.html :
# "[AppImages] should be built on the oldest possible system, allowing them to run on newer system[s]"
FROM debian:bullseye@sha256:cf48c31af360e1c0a0aedd33aae4d928b68c2cdf093f1612650eb1ff434d1c34
ENV LC_ALL=C.UTF-8 LANG=C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive
# need ca-certificates before using snapshot packages
RUN apt update -qq > /dev/null && apt install -qq --yes --no-install-recommends \
ca-certificates
# pin the distro packages
COPY apt.sources.list /etc/apt/sources.list
COPY apt.preferences /etc/apt/preferences.d/snapshot
RUN apt-get update -q && \
apt-get install -qy --allow-downgrades \
sudo \
git \
wget \
python3 \
make \
autotools-dev \
autoconf \
libtool \
autopoint \
pkg-config \
xz-utils \
libssl-dev \
libssl1.1 \
openssl \
zlib1g-dev \
libffi-dev \
libncurses5-dev \
libncurses5 \
libtinfo-dev \
libtinfo5 \
libsqlite3-dev \
libusb-1.0-0-dev \
libudev-dev \
libudev1 \
gettext \
libdbus-1-3 \
xutils-dev \
libxkbcommon0 \
libxkbcommon-x11-0 \
libxcb1-dev \
libxcb-xinerama0 \
libxcb-randr0 \
libxcb-render0 \
libxcb-shm0 \
libxcb-shape0 \
libxcb-sync1 \
libxcb-xfixes0 \
libxcb-xkb1 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-util1 \
libxcb-render-util0 \
libxcb-cursor0 \
libx11-xcb1 \
libc6-dev \
libc6 \
libc-dev-bin \
libv4l-dev \
libjpeg62-turbo-dev \
libx11-dev \
desktop-file-utils \
&& \
rm -rf /var/lib/apt/lists/* && \
apt-get autoremove -y && \
apt-get clean
# create new user to avoid using root; but with sudo access and no password for convenience.
ARG UID=1000
RUN if [ "$UID" != "0" ] ; then useradd --uid $UID --create-home --shell /bin/bash "user" ; fi
RUN usermod -append --groups sudo $(id -nu $UID || echo "user")
RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RUN HOME_DIR=$(getent passwd $UID | cut -d: -f6)
ENV WORK_DIR="${HOME_DIR}/wspace" \
PATH="${HOME_DIR}/.local/bin:${PATH}"
WORKDIR ${WORK_DIR}
RUN chown --recursive ${UID} ${WORK_DIR}
USER ${UID}
================================================
FILE: contrib/build-linux/appimage/README.md
================================================
AppImage binary for Electrum
============================
✓ _This binary should be reproducible, meaning you should be able to generate
binaries that match the official releases._
- _Minimum supported target system (i.e. what end-users need): x86_64, glibc 2.31_
This assumes an Ubuntu host, but it should not be too hard to adapt to another
similar system. The host architecture should be x86_64 (amd64).
We currently only build a single AppImage, for x86_64 architecture.
Help to adapt these scripts to build for (some flavor of) ARM would be welcome,
see [issue #5159](https://github.com/spesmilo/electrum/issues/5159).
1. Install Docker
See [`contrib/docker_notes.md`](../../docker_notes.md).
(worth reading even if you already have docker)
2. Build binary
```
$ ./build.sh
```
If you want reproducibility, try instead e.g.:
```
$ ELECBUILD_COMMIT=HEAD ./build.sh
```
3. The generated binary is in `./dist`.
## FAQ
### How can I see what is included in the AppImage?
Execute the binary as follows: `./electrum*.AppImage --appimage-extract`
### How to investigate diff between binaries if reproducibility fails?
```
cd dist/
./electrum-*-x86_64.AppImage1 --appimage-extract
mv squashfs-root/ squashfs-root1/
./electrum-*-x86_64.AppImage2 --appimage-extract
mv squashfs-root/ squashfs-root2/
$(cd squashfs-root1; find -type f -exec sha256sum '{}' \; > ./../sha256sum1)
$(cd squashfs-root2; find -type f -exec sha256sum '{}' \; > ./../sha256sum2)
diff sha256sum1 sha256sum2 > d
cat d
```
For file metadata, e.g. timestamps:
```
rsync -n -a -i --delete squashfs-root1/ squashfs-root2/
```
Useful binary comparison tools:
- vbindiff
- diffoscope
================================================
FILE: contrib/build-linux/appimage/apprun.sh
================================================
#!/bin/bash
set -e
APPDIR="$(dirname "$(readlink -e "$0")")"
export LD_LIBRARY_PATH="${APPDIR}/usr/lib/:${APPDIR}/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH+:$LD_LIBRARY_PATH}"
export PATH="${APPDIR}/usr/bin:${PATH}"
export LDFLAGS="-L${APPDIR}/usr/lib/x86_64-linux-gnu -L${APPDIR}/usr/lib"
exec "${APPDIR}/usr/bin/python3" -s "${APPDIR}/usr/bin/electrum" "$@"
================================================
FILE: contrib/build-linux/appimage/apt.preferences
================================================
Package: *
Pin: origin "snapshot.debian.org"
Pin-Priority: 1001
================================================
FILE: contrib/build-linux/appimage/apt.sources.list
================================================
deb https://snapshot.debian.org/archive/debian/20250530T143637Z/ bullseye main
deb-src https://snapshot.debian.org/archive/debian/20250530T143637Z/ bullseye main
================================================
FILE: contrib/build-linux/appimage/build.sh
================================================
#!/bin/bash
#
# env vars:
# - ELECBUILD_NOCACHE: if set, forces rebuild of docker image
# - ELECBUILD_COMMIT: if set, do a fresh clone and git checkout
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
PROJECT_ROOT_OR_FRESHCLONE_ROOT="$PROJECT_ROOT"
CONTRIB="$PROJECT_ROOT/contrib"
CONTRIB_APPIMAGE="$CONTRIB/build-linux/appimage"
DISTDIR="$PROJECT_ROOT/dist"
BUILD_UID=$(/usr/bin/stat -c %u "$PROJECT_ROOT")
. "$CONTRIB"/build_tools_util.sh
DOCKER_BUILD_FLAGS=""
if [ ! -z "$ELECBUILD_NOCACHE" ] ; then
info "ELECBUILD_NOCACHE is set. forcing rebuild of docker image."
DOCKER_BUILD_FLAGS="--pull --no-cache"
fi
if [ -z "$ELECBUILD_COMMIT" ] ; then # local dev build
DOCKER_BUILD_FLAGS="$DOCKER_BUILD_FLAGS --build-arg UID=$BUILD_UID"
fi
info "building docker image."
docker build \
$DOCKER_BUILD_FLAGS \
-t electrum-appimage-builder-img \
"$CONTRIB_APPIMAGE"
# maybe do fresh clone
if [ ! -z "$ELECBUILD_COMMIT" ] ; then
info "ELECBUILD_COMMIT=$ELECBUILD_COMMIT. doing fresh clone and git checkout."
FRESH_CLONE="/tmp/electrum_build/appimage/fresh_clone/electrum"
rm -rf "$FRESH_CLONE" 2>/dev/null || ( info "we need sudo to rm prev FRESH_CLONE." && sudo rm -rf "$FRESH_CLONE" )
umask 0022
git clone "$PROJECT_ROOT" "$FRESH_CLONE"
cd "$FRESH_CLONE"
git checkout "$ELECBUILD_COMMIT"
PROJECT_ROOT_OR_FRESHCLONE_ROOT="$FRESH_CLONE"
else
info "not doing fresh clone."
fi
# build the type2-runtime binary, this build step uses a separate docker container
# defined in the type2-runtime repo (patched with type2-runtime-reproducible-build.patch)
"$PROJECT_ROOT_OR_FRESHCLONE_ROOT/contrib/build-linux/appimage/make_type2_runtime.sh" || fail "Error building type2-runtime."
DOCKER_RUN_FLAGS=""
if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
info "/dev/tty is available and usable"
DOCKER_RUN_FLAGS="-it"
fi
info "building binary..."
# check uid and maybe chown. see #8261
if [ ! -z "$ELECBUILD_COMMIT" ] ; then # fresh clone (reproducible build)
if [ $(id -u) != "1000" ] || [ $(id -g) != "1000" ] ; then
info "need to chown -R FRESH_CLONE dir. prompting for sudo."
sudo chown -R 1000:1000 "$FRESH_CLONE"
fi
fi
docker run $DOCKER_RUN_FLAGS \
--name electrum-appimage-builder-cont \
-v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT":/opt/electrum \
--rm \
--workdir /opt/electrum/contrib/build-linux/appimage \
electrum-appimage-builder-img \
./make_appimage.sh
# make sure resulting binary location is independent of fresh_clone
if [ ! -z "$ELECBUILD_COMMIT" ] ; then
mkdir --parents "$DISTDIR/"
cp -f "$FRESH_CLONE/dist"/* "$DISTDIR/"
fi
================================================
FILE: contrib/build-linux/appimage/make_appimage.sh
================================================
#!/bin/bash
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
CONTRIB="$PROJECT_ROOT/contrib"
CONTRIB_APPIMAGE="$CONTRIB/build-linux/appimage"
DISTDIR="$PROJECT_ROOT/dist"
BUILDDIR="$CONTRIB_APPIMAGE/build/appimage"
APPDIR="$BUILDDIR/electrum.AppDir"
CACHEDIR="$CONTRIB_APPIMAGE/.cache/appimage"
TYPE2_RUNTIME_REPO_DIR="$CACHEDIR/type2-runtime"
export DLL_TARGET_DIR="$CACHEDIR/dlls"
PIP_CACHE_DIR="$CONTRIB_APPIMAGE/.cache/pip_cache"
. "$CONTRIB"/build_tools_util.sh
git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
export GCC_STRIP_BINARIES="1"
# pinned versions
PYTHON_VERSION=3.12.11
PY_VER_MAJOR="3.12" # as it appears in fs paths
PKG2APPIMAGE_COMMIT="a9c85b7e61a3a883f4a35c41c5decb5af88b6b5d"
VERSION=$(git describe --tags --dirty --always)
APPIMAGE="$DISTDIR/electrum-$VERSION-x86_64.AppImage"
rm -rf "$BUILDDIR"
mkdir -p "$APPDIR" "$CACHEDIR" "$PIP_CACHE_DIR" "$DISTDIR" "$DLL_TARGET_DIR"
# potential leftover from setuptools that might make pip put garbage in binary
rm -rf "$PROJECT_ROOT/build"
info "downloading some dependencies."
download_if_not_exist "$CACHEDIR/functions.sh" "https://raw.githubusercontent.com/AppImage/pkg2appimage/$PKG2APPIMAGE_COMMIT/functions.sh"
verify_hash "$CACHEDIR/functions.sh" "8f67711a28635b07ce539a9b083b8c12d5488c00003d6d726c7b134e553220ed"
download_if_not_exist "$CACHEDIR/appimagetool" "https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
verify_hash "$CACHEDIR/appimagetool" "46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
# note: desktop-file-utils in the docker image is needed to run desktop-file-validate for appimagetool <= 1.9.0, so it can be removed once
# appimagetool tags a new release (see https://github.com/AppImage/appimagetool/pull/47)
download_if_not_exist "$CACHEDIR/Python-$PYTHON_VERSION.tar.xz" "https://www.python.org/ftp/python/$PYTHON_VERSION/Python-$PYTHON_VERSION.tar.xz"
verify_hash "$CACHEDIR/Python-$PYTHON_VERSION.tar.xz" "c30bb24b7f1e9a19b11b55a546434f74e739bb4c271a3e3a80ff4380d49f7adb"
info "building python."
tar xf "$CACHEDIR/Python-$PYTHON_VERSION.tar.xz" -C "$CACHEDIR"
(
if [ -f "$CACHEDIR/Python-$PYTHON_VERSION/python" ]; then
info "python already built, skipping"
exit 0
fi
cd "$CACHEDIR/Python-$PYTHON_VERSION"
LC_ALL=C export BUILD_DATE=$(date -u -d "@$SOURCE_DATE_EPOCH" "+%b %d %Y")
LC_ALL=C export BUILD_TIME=$(date -u -d "@$SOURCE_DATE_EPOCH" "+%H:%M:%S")
# Patches taken from Ubuntu http://archive.ubuntu.com/ubuntu/pool/main/p/python3.11/python3.11_3.11.6-3.debian.tar.xz
patch -p1 < "$CONTRIB_APPIMAGE/patches/python-3.11-reproducible-buildinfo.diff"
./configure \
--cache-file="$CACHEDIR/python.config.cache" \
--prefix="$APPDIR/usr" \
--enable-ipv6 \
--enable-shared \
-q
make "-j$CPU_COUNT" -s || fail "Could not build Python"
)
info "installing python."
(
cd "$CACHEDIR/Python-$PYTHON_VERSION"
make -s install > /dev/null || fail "Could not install Python"
# When building in docker on macOS, python builds with .exe extension because the
# case insensitive file system of macOS leaks into docker. This causes the build
# to result in a different output on macOS compared to Linux. We simply patch
# sysconfigdata to remove the extension.
# Some more info: https://bugs.python.org/issue27631
sed -i -e 's/\.exe//g' "${APPDIR}/usr/lib/python${PY_VER_MAJOR}"/_sysconfigdata*
)
if ls "$DLL_TARGET_DIR"/libsecp256k1.so.* 1> /dev/null 2>&1; then
info "libsecp256k1 already built, skipping"
else
"$CONTRIB"/make_libsecp256k1.sh || fail "Could not build libsecp"
fi
cp -f "$DLL_TARGET_DIR"/libsecp256k1.so.* "$APPDIR/usr/lib/" || fail "Could not copy libsecp to its destination"
if [ -f "$DLL_TARGET_DIR/libzbar.so.0" ]; then
info "libzbar already built, skipping"
else
# note: could instead just use the libzbar0 pkg from debian/apt, but that is too old and missing fixes for CVE-2023-40889
"$CONTRIB"/make_zbar.sh || fail "Could not build zbar"
fi
cp -f "$DLL_TARGET_DIR/libzbar.so.0" "$APPDIR/usr/lib/" || fail "Could not copy libzbar to its destination"
appdir_python() {
env \
PYTHONNOUSERSITE=1 \
LD_LIBRARY_PATH="$APPDIR/usr/lib:$APPDIR/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH+:$LD_LIBRARY_PATH}" \
"$APPDIR/usr/bin/python${PY_VER_MAJOR}" "$@"
}
python='appdir_python'
info "installing pip."
"$python" -m ensurepip
break_legacy_easy_install
info "preparing electrum-locale."
(
"$CONTRIB/locale/build_cleanlocale.sh"
# we want the binary to have only compiled (.mo) locale files; not source (.po) files
rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/electrum.po
)
info "Installing build dependencies."
# note: re pip installing from PyPI,
# we prefer compiling C extensions ourselves, instead of using binary wheels,
# hence "--no-binary :all:" flags. However, we specifically allow
# - PyQt6, as it's harder to build from source
# - cryptography, as it's harder to build from source
# - the whole of "requirements-build-base.txt", which includes pip and friends, as it also includes "wheel",
# and I am not quite sure how to break the circular dependence there (I guess we could introduce
# "requirements-build-base-base.txt" with just wheel in it...)
"$python" -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
--cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements-build-base.txt"
"$python" -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
--cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements-build-appimage.txt"
# opt out of compiling C extensions
export YARL_NO_EXTENSIONS=1
export FROZENLIST_NO_EXTENSIONS=1
export ELECTRUM_ECC_DONT_COMPILE=1
info "installing electrum and its dependencies."
"$python" -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
--cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements.txt"
"$python" -m pip install --no-build-isolation --no-dependencies --no-binary :all: --only-binary PyQt6,PyQt6-Qt6,cryptography --no-warn-script-location \
--cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements-binaries.txt"
"$python" -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
--cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements-hw.txt"
"$python" -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
--cache-dir "$PIP_CACHE_DIR" "$PROJECT_ROOT"
# was only needed during build time, not runtime
"$python" -m pip uninstall -y Cython
info "desktop integration."
cp "$PROJECT_ROOT/electrum.desktop" "$APPDIR/electrum.desktop"
cp "$PROJECT_ROOT/electrum/gui/icons/electrum.png" "$APPDIR/electrum.png"
# add launcher
cp "$CONTRIB_APPIMAGE/apprun.sh" "$APPDIR/AppRun"
info "finalizing AppDir."
(
export PKG2AICOMMIT="$PKG2APPIMAGE_COMMIT"
. "$CACHEDIR/functions.sh"
cd "$APPDIR"
# copy system dependencies
copy_deps; copy_deps; copy_deps
move_lib
# apply global appimage blacklist to exclude stuff
# move usr/include out of the way to preserve usr/include/python${PY_VER_MAJOR}.
mv usr/include usr/include.tmp
delete_blacklisted
mv usr/include.tmp usr/include
)
info "Copying additional libraries"
(
# On some systems it can cause problems to use the system libusb (on AppImage excludelist)
cp -f /usr/lib/x86_64-linux-gnu/libusb-1.0.so "$APPDIR/usr/lib/libusb-1.0.so" || fail "Could not copy libusb"
# some distros lack libxkbcommon-x11
cp -f /usr/lib/x86_64-linux-gnu/libxkbcommon-x11.so.0 "$APPDIR"/usr/lib/x86_64-linux-gnu || fail "Could not copy libxkbcommon-x11"
# some distros lack some libxcb libraries (see https://github.com/Electron-Cash/Electron-Cash/issues/2196)
cp -f /usr/lib/x86_64-linux-gnu/libxcb-* "$APPDIR"/usr/lib/x86_64-linux-gnu || fail "Could not copy libxcb"
)
info "stripping binaries from debug symbols."
# "-R .note.gnu.build-id" also strips the build id
# "-R .comment" also strips the GCC version information
strip_binaries()
{
chmod u+w -R "$APPDIR"
{
printf '%s\0' "$APPDIR/usr/bin/python${PY_VER_MAJOR}"
find "$APPDIR" -type f -regex '.*\.so\(\.[0-9.]+\)?$' -print0
} | xargs -0 --no-run-if-empty --verbose strip -R .note.gnu.build-id -R .comment
}
strip_binaries
remove_emptydirs()
{
find "$APPDIR" -type d -empty -print0 | xargs -0 --no-run-if-empty rmdir -vp --ignore-fail-on-non-empty
}
remove_emptydirs
info "removing some unneeded stuff to decrease binary size."
rm -rf "$APPDIR"/usr/{share,include}
PYDIR="$APPDIR/usr/lib/python${PY_VER_MAJOR}"
rm -rf "$PYDIR"/{test,ensurepip,lib2to3,idlelib,turtledemo}
rm -rf "$PYDIR"/{ctypes,sqlite3,tkinter,unittest}/test
rm -rf "$PYDIR"/distutils/{command,tests}
rm -rf "$PYDIR"/config-3.*-x86_64-linux-gnu
rm -rf "$PYDIR"/site-packages/{opt,pip,setuptools,wheel}
rm -rf "$PYDIR"/site-packages/Cryptodome/SelfTest
rm -rf "$PYDIR"/site-packages/{psutil,qrcode,websocket}/tests
# rm lots of unused parts of Qt/PyQt. (assuming PyQt 6 layout)
for component in connectivity declarative help location multimedia quickcontrols2 serialport webengine websockets xmlpatterns ; do
rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/translations/qt${component}_*
rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/resources/qt${component}_*
done
rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/{qml,libexec}
rm -rf "$PYDIR"/site-packages/PyQt6/{pyrcc*.so,pylupdate*.so,uic}
rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/plugins/{bearer,gamepads,geometryloaders,geoservices,playlistformats,position,renderplugins,sceneparsers,sensors,sqldrivers,texttospeech,webview}
for component in Bluetooth Concurrent Designer Help Location NetworkAuth Nfc Positioning PositioningQuick Qml Quick Sensors SerialPort Sql Test Web Xml Labs ShaderTools SpatialAudio ; do
rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/lib/libQt6${component}*
rm -rf "$PYDIR"/site-packages/PyQt6/Qt${component}*
rm -rf "$PYDIR"/site-packages/PyQt6/bindings/Qt${component}*
done
for component in Qml Quick ; do
rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/lib/libQt6*${component}.so*
done
rm -rf "$PYDIR"/site-packages/PyQt6/Qt.so
# these are deleted as they were not deterministic; and are not needed anyway
find "$APPDIR" -path '*/__pycache__*' -delete
# although note that *.dist-info might be needed by certain packages...
# e.g. slip10 uses importlib that needs it
for f in "$PYDIR"/site-packages/slip10-*.dist-info; do mv "$f" "$(echo "$f" | sed s/\.dist-info/\.dist-info2/)"; done
rm -rf "$PYDIR"/site-packages/*.dist-info/
rm -rf "$PYDIR"/site-packages/*.egg-info/
for f in "$PYDIR"/site-packages/slip10-*.dist-info2; do mv "$f" "$(echo "$f" | sed s/\.dist-info2/\.dist-info/)"; done
find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
info "creating the AppImage."
(
cd "$BUILDDIR"
cp "$CACHEDIR/appimagetool" "$CACHEDIR/appimagetool_copy"
# zero out "appimage" magic bytes, as on some systems they confuse the linker
sed -i 's|AI\x02|\x00\x00\x00|' "$CACHEDIR/appimagetool_copy"
chmod +x "$CACHEDIR/appimagetool_copy"
"$CACHEDIR/appimagetool_copy" --appimage-extract
# We build a small wrapper for mksquashfs that removes the -mkfs-time option
# as it conflicts with SOURCE_DATE_EPOCH.
mv "$BUILDDIR/squashfs-root/usr/bin/mksquashfs" "$BUILDDIR/squashfs-root/usr/bin/mksquashfs_orig"
cat > "$BUILDDIR/squashfs-root/usr/bin/mksquashfs" << EOF
#!/bin/sh
args=\$(echo "\$@" | sed -e 's/-mkfs-time 0//')
"$BUILDDIR/squashfs-root/usr/bin/mksquashfs_orig" \$args
EOF
chmod +x "$BUILDDIR/squashfs-root/usr/bin/mksquashfs"
env VERSION="$VERSION" ARCH=x86_64 ./squashfs-root/AppRun --runtime-file "$TYPE2_RUNTIME_REPO_DIR/runtime-x86_64" --no-appstream --verbose "$APPDIR" "$APPIMAGE"
)
info "done."
ls -la "$DISTDIR"
sha256sum "$DISTDIR"/*
================================================
FILE: contrib/build-linux/appimage/make_type2_runtime.sh
================================================
#!/bin/bash
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
CONTRIB="$PROJECT_ROOT/contrib"
CONTRIB_APPIMAGE="$CONTRIB/build-linux/appimage"
# when bumping the runtime commit also check if the `type2-runtime-reproducible-build.patch` still works
TYPE2_RUNTIME_COMMIT="5e7217b7cfeecee1491c2d251e355c3cf8ba6e4d"
TYPE2_RUNTIME_REPO="https://github.com/AppImage/type2-runtime.git"
. "$CONTRIB"/build_tools_util.sh
TYPE2_RUNTIME_REPO_DIR="$PROJECT_ROOT/contrib/build-linux/appimage/.cache/appimage/type2-runtime"
if [ -f "$TYPE2_RUNTIME_REPO_DIR/runtime-x86_64" ]; then
info "type2-runtime already built, skipping"
exit 0
fi
clone_or_update_repo "$TYPE2_RUNTIME_REPO" "$TYPE2_RUNTIME_COMMIT" "$TYPE2_RUNTIME_REPO_DIR"
# Apply patch to make runtime build reproducible
info "Applying type2-runtime patch..."
cd "$TYPE2_RUNTIME_REPO_DIR"
git apply "$CONTRIB_APPIMAGE/patches/type2-runtime-reproducible-build.patch" || fail "Failed to apply runtime repo patch"
info "building type2-runtime in build container..."
cd "$TYPE2_RUNTIME_REPO_DIR/scripts/docker"
env ARCH=x86_64 ./build-with-docker.sh
mv "./runtime-x86_64" "$TYPE2_RUNTIME_REPO_DIR/"
# clean up the empty created 'out' dir to prevent permission issues
rm -rf "$TYPE2_RUNTIME_REPO_DIR/out"
info "runtime build successful: $(sha256sum "$TYPE2_RUNTIME_REPO_DIR/runtime-x86_64")"
================================================
FILE: contrib/build-linux/appimage/patches/python-3.11-reproducible-buildinfo.diff
================================================
Description: Build reproduceable date and time into build info
Build information is encoded into getbuildinfo.o at build time.
Use the date and time from the debian changelog, to make this reproduceable.
Forwarded: no
--- a/Makefile.pre.in
+++ b/Makefile.pre.in
@@ -1248,6 +1248,8 @@
-DGITVERSION="\"`LC_ALL=C $(GITVERSION)`\"" \
-DGITTAG="\"`LC_ALL=C $(GITTAG)`\"" \
-DGITBRANCH="\"`LC_ALL=C $(GITBRANCH)`\"" \
+ $(if $(BUILD_DATE),-DDATE='"$(BUILD_DATE)"') \
+ $(if $(BUILD_TIME),-DTIME='"$(BUILD_TIME)"') \
-o $@ $(srcdir)/Modules/getbuildinfo.c
Modules/getpath.o: $(srcdir)/Modules/getpath.c Python/frozen_modules/getpath.h Makefile $(PYTHON_HEADERS)
================================================
FILE: contrib/build-linux/appimage/patches/type2-runtime-reproducible-build.patch
================================================
From 0c54d91dd1d33235ae97566600e692edfb613642 Mon Sep 17 00:00:00 2001
From: f321x
Date: Thu, 10 Jul 2025 17:45:20 +0200
Subject: [PATCH] make docker build reproducible
attempts to make the docker build more reproducible by:
* pinning the docker image (alpine:3.21) to a hash
* version pinning the apk packages in the dockerfile
* setting TZ, LC_ALL and SOURCE_DATE_EPOCH in the container
* only building single threaded (make -j1)
* use a fixed build directory in `build-runtime.sh` instead of mktemp
* prevent linker from adding build id (-Wl,--build-id=none)
* replace absolute build paths in debug info with relative paths
(-fdebug-prefix-map=$(PWD)=.)
* replace absolute paths in all compiler output with relative paths
(-ffile-prefix-map=$(PWD)=.)
* stop adding gnu-debuglink to runtime binary
---
scripts/build-runtime.sh | 18 +++++++++++----
scripts/common/install-dependencies.sh | 2 +-
scripts/docker/Dockerfile | 32 ++++++++++++++++++++++----
src/runtime/Makefile | 2 +-
4 files changed, 42 insertions(+), 12 deletions(-)
diff --git a/scripts/build-runtime.sh b/scripts/build-runtime.sh
index 3ce3b91..e11f082 100755
--- a/scripts/build-runtime.sh
+++ b/scripts/build-runtime.sh
@@ -8,8 +8,10 @@ set -euo pipefail
out_dir="$(readlink -f "$(pwd)")"/out
mkdir -p "$out_dir"
-# we create a temporary build directory
-build_dir="$(mktemp -d -t type2-runtime-build-XXXXXX)"
+# we create a temporary build directory with a fixed name for reproducibility
+build_dir="$(readlink -f "$(pwd)")"/build-runtime-temp
+rm -rf "$build_dir"
+mkdir -p "$build_dir"
# since the plain ol' Makefile doesn't support out-of-source builds at all, we need to copy all the files
cp -R src "$build_dir"/
@@ -17,13 +19,14 @@ cp -R src "$build_dir"/
pushd "$build_dir"
pushd src/runtime/
-make -j"$(nproc)" runtime
+make -j1 runtime
file runtime
objcopy --only-keep-debug runtime runtime.debug
-strip --strip-debug --strip-unneeded runtime
+# strip --strip-debug --strip-unneeded runtime
+strip --strip-all runtime
ls -lh runtime runtime.debug
@@ -50,7 +53,7 @@ fi
mv runtime runtime-"$architecture"
mv runtime.debug runtime-"$architecture".debug
-objcopy --add-gnu-debuglink runtime-"$architecture".debug runtime-"$architecture"
+# objcopy --add-gnu-debuglink runtime-"$architecture".debug runtime-"$architecture"
# "classic" magic bytes which cannot be embedded with compiler magic, always do AFTER strip
# needs to be done after calls to objcopy, strip etc.
@@ -61,3 +64,8 @@ cp runtime-"$architecture" "$out_dir"/
cp runtime-"$architecture".debug "$out_dir"/
ls -al "$out_dir"
+
+# cleanup
+popd # return to build_dir
+popd # return to original working directory
+rm -rf "$build_dir"
diff --git a/scripts/common/install-dependencies.sh b/scripts/common/install-dependencies.sh
index 0e21cdb..5237079 100755
--- a/scripts/common/install-dependencies.sh
+++ b/scripts/common/install-dependencies.sh
@@ -39,7 +39,7 @@ tar xf 0.5.2.tar.gz
pushd squashfuse-*/
./autogen.sh
./configure LDFLAGS="-static"
-make -j"$(nproc)"
+make -j1
make install
/usr/bin/install -c -m 644 ./*.h '/usr/local/include/squashfuse'
popd
diff --git a/scripts/docker/Dockerfile b/scripts/docker/Dockerfile
index 07b6533..fba9c6e 100644
--- a/scripts/docker/Dockerfile
+++ b/scripts/docker/Dockerfile
@@ -1,13 +1,35 @@
-FROM alpine:3.21
+FROM alpine:3.21@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
# includes dependencies from https://git.alpinelinux.org/aports/tree/main/fuse3/APKBUILD
RUN apk add --no-cache \
- bash alpine-sdk util-linux strace file autoconf automake libtool xz \
- eudev-dev gettext-dev linux-headers meson \
- zstd-dev zstd-static zlib-dev zlib-static clang musl-dev mimalloc-dev
+ bash=5.2.37-r0 \
+ alpine-sdk=1.1-r0 \
+ util-linux=2.40.4-r1 \
+ strace=6.12-r0 \
+ file=5.46-r2 \
+ autoconf=2.72-r0 \
+ automake=1.17-r0 \
+ libtool=2.4.7-r3 \
+ xz=5.6.3-r1 \
+ eudev-dev=3.2.14-r5 \
+ gettext-dev=0.22.5-r0 \
+ linux-headers=6.6-r1 \
+ meson=1.6.1-r0 \
+ zstd-dev=1.5.6-r2 \
+ zstd-static=1.5.6-r2 \
+ zlib-dev=1.3.1-r2 \
+ zlib-static=1.3.1-r2 \
+ clang19=19.1.4-r0 \
+ musl-dev=1.2.5-r9 \
+ mimalloc2-dev=2.1.7-r0
COPY scripts/common/install-dependencies.sh /tmp/scripts/common/install-dependencies.sh
COPY patches/ /tmp/patches/
+# Set environment variables for reproducible build
+ENV SOURCE_DATE_EPOCH=1640995200
+ENV TZ=UTC
+ENV LC_ALL=C
+
WORKDIR /tmp
-RUN bash scripts/common/install-dependencies.sh
+RUN bash scripts/common/install-dependencies.sh
\ No newline at end of file
diff --git a/src/runtime/Makefile b/src/runtime/Makefile
index 9fd4165..3a3cbaa 100644
--- a/src/runtime/Makefile
+++ b/src/runtime/Makefile
@@ -1,6 +1,6 @@
GIT_COMMIT := $(shell cat version)
CC = clang
-CFLAGS = -std=gnu99 -Os -D_FILE_OFFSET_BITS=64 -DGIT_COMMIT=\"$(GIT_COMMIT)\" -T data_sections.ld -ffunction-sections -fdata-sections -Wl,--gc-sections -static -Wall -Werror -static-pie
+CFLAGS = -std=gnu99 -Os -D_FILE_OFFSET_BITS=64 -DGIT_COMMIT=\"$(GIT_COMMIT)\" -T data_sections.ld -ffunction-sections -fdata-sections -Wl,--gc-sections -Wl,--build-id=none -static -Wall -Werror -static-pie -fdebug-prefix-map=$(PWD)=. -ffile-prefix-map=$(PWD)=.
LIBS = -lsquashfuse -lsquashfuse_ll -lzstd -lz -lfuse3 -lmimalloc
all: runtime
--
2.50.0
================================================
FILE: contrib/build-linux/sdist/.dockerignore
================================================
================================================
FILE: contrib/build-linux/sdist/Dockerfile
================================================
FROM debian:bookworm@sha256:b877a1a3fdf02469440f1768cf69c9771338a875b7add5e80c45b756c92ac20a
ENV LC_ALL=C.UTF-8 LANG=C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update -q && \
apt-get install -qy \
git \
gettext \
python3 \
python3-pip \
python3-setuptools \
python3-venv \
&& \
rm -rf /var/lib/apt/lists/* && \
apt-get autoremove -y && \
apt-get clean
# create new user to avoid using root; but with sudo access and no password for convenience.
ARG UID=1000
RUN if [ "$UID" != "0" ] ; then useradd --uid $UID --create-home --shell /bin/bash "user" ; fi
RUN usermod -append --groups sudo $(id -nu $UID || echo "user")
RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RUN HOME_DIR=$(getent passwd $UID | cut -d: -f6)
ENV WORK_DIR="${HOME_DIR}/wspace" \
PATH="${HOME_DIR}/.local/bin:${PATH}"
WORKDIR ${WORK_DIR}
RUN chown --recursive ${UID} ${WORK_DIR}
USER ${UID}
================================================
FILE: contrib/build-linux/sdist/README.md
================================================
# Source tarballs
✓ _These tarballs should be reproducible, meaning you should be able to generate
distributables that match the official releases._
This assumes an Ubuntu (x86_64) host, but it should not be too hard to adapt to another
similar system.
We distribute two tarballs, a "normal" one (the default, recommended for users),
and a strictly source-only one (for Linux distro packagers).
The normal tarball, in addition to including everything from
the source-only one, also includes:
- compiled (`.mo`) locale files (in addition to source `.po` locale files)
- compiled (`_pb2.py`) protobuf files (in addition to source `.proto` files)
- the `packages/` folder containing source-only pure-python runtime dependencies
## Build steps
1. Install Docker
See [`contrib/docker_notes.md`](../../docker_notes.md).
(worth reading even if you already have docker)
2. Build tarball
(set envvar `OMIT_UNCLEAN_FILES=1` to build the "source-only" tarball)
```
$ ./build.sh
```
If you want reproducibility, try instead e.g.:
```
$ ELECBUILD_COMMIT=HEAD ELECBUILD_NOCACHE=1 ./build.sh
$ ELECBUILD_COMMIT=HEAD ELECBUILD_NOCACHE=1 OMIT_UNCLEAN_FILES=1 ./build.sh
```
3. The generated distributables are in `./dist`.
## Differences between the `sourceonly` vs "normal" tar.gz
These scripts can either build a source-only or a "normal" tarball.
The official release process builds both.
The source-only tarball is aimed at Linux distro packagers.
Users wanting to run from source should typically use the normal tarball.
The differences are as follows:
- the normal tarball bundles all the pure-python dependencies of Electrum.
These are placed into the `packages/` folder, and they are automatically
found and used at runtime.
- the normal tarball includes compiled (.mo) locale files, the source-only tarball does not.
Both tarballs contain (.po) source locale files. If you are packaging for a Linux distro,
you probably want to compile the .mo locale files yourself (see `contrib/locale/build_locale.sh`).
- the normal tarball includes generated `*_pb2.py` files. These are created
using `protobuf-compiler` from `.proto` files (see `contrib/generate_payreqpb2.sh`)
================================================
FILE: contrib/build-linux/sdist/build.sh
================================================
#!/bin/bash
#
# env vars:
# - ELECBUILD_NOCACHE: if set, forces rebuild of docker image
# - ELECBUILD_COMMIT: if set, do a fresh clone and git checkout
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
PROJECT_ROOT_OR_FRESHCLONE_ROOT="$PROJECT_ROOT"
CONTRIB="$PROJECT_ROOT/contrib"
CONTRIB_SDIST="$CONTRIB/build-linux/sdist"
DISTDIR="$PROJECT_ROOT/dist"
BUILD_UID=$(/usr/bin/stat -c %u "$PROJECT_ROOT")
. "$CONTRIB"/build_tools_util.sh
DOCKER_BUILD_FLAGS=""
if [ ! -z "$ELECBUILD_NOCACHE" ] ; then
info "ELECBUILD_NOCACHE is set. forcing rebuild of docker image."
DOCKER_BUILD_FLAGS="--pull --no-cache"
fi
if [ -z "$ELECBUILD_COMMIT" ] ; then # local dev build
DOCKER_BUILD_FLAGS="$DOCKER_BUILD_FLAGS --build-arg UID=$BUILD_UID"
fi
info "building docker image."
docker build \
$DOCKER_BUILD_FLAGS \
-t electrum-sdist-builder-img \
"$CONTRIB_SDIST"
# maybe do fresh clone
if [ ! -z "$ELECBUILD_COMMIT" ] ; then
info "ELECBUILD_COMMIT=$ELECBUILD_COMMIT. doing fresh clone and git checkout."
FRESH_CLONE="/tmp/electrum_build/sdist/fresh_clone/electrum"
rm -rf "$FRESH_CLONE" 2>/dev/null || ( info "we need sudo to rm prev FRESH_CLONE." && sudo rm -rf "$FRESH_CLONE" )
umask 0022
git clone "$PROJECT_ROOT" "$FRESH_CLONE"
cd "$FRESH_CLONE"
git checkout "$ELECBUILD_COMMIT"
PROJECT_ROOT_OR_FRESHCLONE_ROOT="$FRESH_CLONE"
else
info "not doing fresh clone."
fi
DOCKER_RUN_FLAGS=""
if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
info "/dev/tty is available and usable"
DOCKER_RUN_FLAGS="-it"
fi
info "building binary..."
# check uid and maybe chown. see #8261
if [ ! -z "$ELECBUILD_COMMIT" ] ; then # fresh clone (reproducible build)
if [ $(id -u) != "1000" ] || [ $(id -g) != "1000" ] ; then
info "need to chown -R FRESH_CLONE dir. prompting for sudo."
sudo chown -R 1000:1000 "$FRESH_CLONE"
fi
fi
docker run $DOCKER_RUN_FLAGS \
--name electrum-sdist-builder-cont \
-v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT":/opt/electrum \
--rm \
--workdir /opt/electrum/contrib/build-linux/sdist \
--env OMIT_UNCLEAN_FILES \
electrum-sdist-builder-img \
./make_sdist.sh
# make sure resulting binary location is independent of fresh_clone
if [ ! -z "$ELECBUILD_COMMIT" ] ; then
mkdir --parents "$DISTDIR/"
cp -f "$FRESH_CLONE/dist"/* "$DISTDIR/"
fi
================================================
FILE: contrib/build-linux/sdist/make_sdist.sh
================================================
#!/bin/bash
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
CONTRIB="$PROJECT_ROOT/contrib"
CONTRIB_SDIST="$CONTRIB/build-linux/sdist"
DISTDIR="$PROJECT_ROOT/dist"
BUILDDIR="$CONTRIB_SDIST/build"
. "$CONTRIB"/build_tools_util.sh
git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
rm -rf "$BUILDDIR"
mkdir -p "$BUILDDIR" "$DISTDIR"
python3 --version || fail "python interpreter not found"
break_legacy_easy_install
rm -rf "$PROJECT_ROOT/packages/"
if ([ "$OMIT_UNCLEAN_FILES" != 1 ]); then
"$CONTRIB"/make_packages.sh || fail "make_packages failed"
fi
info "preparing electrum-locale."
(
"$CONTRIB/locale/build_cleanlocale.sh"
# By default, include both source (.po) and compiled (.mo) locale files in the source dist.
# Set option OMIT_UNCLEAN_FILES=1 to exclude the compiled locale files
# see https://askubuntu.com/a/144139 (also see MANIFEST.in)
if ([ "$OMIT_UNCLEAN_FILES" = 1 ]); then
rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/LC_MESSAGES/electrum.mo
fi
)
if ([ "$OMIT_UNCLEAN_FILES" = 1 ]); then
# FIXME side-effecting repo... though in practice, this script probably runs in fresh_clone
rm -f "$PROJECT_ROOT/electrum/paymentrequest_pb2.py"
fi
(
cd "$PROJECT_ROOT"
find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
# note: .zip sdists would not be reproducible due to https://bugs.python.org/issue40963
if ([ "$OMIT_UNCLEAN_FILES" = 1 ]); then
PY_DISTDIR="$BUILDDIR/dist1/_sourceonly" # The DISTDIR variable of this script is only used to find where the output is *finally* placed.
else
PY_DISTDIR="$BUILDDIR/dist1"
fi
# build initial tar.gz
python3 setup.py --quiet sdist --format=gztar --dist-dir="$PY_DISTDIR"
VERSION=$("$CONTRIB"/print_electrum_version.py)
if ([ "$OMIT_UNCLEAN_FILES" = 1 ]); then
FINAL_DISTNAME="Electrum-sourceonly-$VERSION.tar.gz"
else
FINAL_DISTNAME="Electrum-$VERSION.tar.gz"
fi
if ([ "$OMIT_UNCLEAN_FILES" = 1 ]); then
mv "$PY_DISTDIR/Electrum-$VERSION.tar.gz" "$PY_DISTDIR/../$FINAL_DISTNAME"
rmdir "$PY_DISTDIR"
fi
# the initial tar.gz is not reproducible, see https://github.com/pypa/setuptools/issues/2133
# so we untar, fix timestamps, and then re-tar
mkdir -p "$BUILDDIR/dist2"
cd "$BUILDDIR/dist2"
tar -xzf "$BUILDDIR/dist1/$FINAL_DISTNAME"
find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
GZIP=-n tar --sort=name -czf "$FINAL_DISTNAME" "Electrum-$VERSION/"
mv "$FINAL_DISTNAME" "$DISTDIR/$FINAL_DISTNAME"
)
info "done."
ls -la "$DISTDIR"
sha256sum "$DISTDIR"/*
================================================
FILE: contrib/build-wine/.dockerignore
================================================
tmp/
build/
.cache/
dist/
signed/
================================================
FILE: contrib/build-wine/Dockerfile
================================================
FROM debian:trixie@sha256:13f29b6806e531c3ff3b565bb6eed73f2132506c8c9d41bb996065ca20fb27f2
# need ca-certificates before using snapshot packages
RUN apt update -qq > /dev/null && apt install -qq --yes --no-install-recommends \
ca-certificates
# pin the distro packages.
COPY apt.sources.list /etc/apt/sources.list
COPY apt.preferences /etc/apt/preferences.d/snapshot
ENV LC_ALL=C.UTF-8 LANG=C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive
RUN dpkg --add-architecture i386 && \
apt-get update -q && \
apt-get install -qy --allow-downgrades \
lsb-release \
wget \
gnupg2 \
dirmngr \
python3 \
git \
p7zip-full \
make \
cmake \
pkgconf \
mingw-w64 \
mingw-w64-tools \
autotools-dev \
autoconf \
autopoint \
libtool \
gettext \
sudo \
nsis \
&& \
rm -rf /var/lib/apt/lists/* && \
apt-get autoremove -y && \
apt-get clean
RUN DEBIAN_CODENAME=$(lsb_release --codename --short) && \
WINEVERSION="11.0.0.0~${DEBIAN_CODENAME}-1" && \
wget -nc https://dl.winehq.org/wine-builds/winehq.key && \
echo "d965d646defe94b3dfba6d5b4406900ac6c81065428bf9d9303ad7a72ee8d1b8 winehq.key" | sha256sum -c - && \
cat winehq.key | gpg --dearmor -o /etc/apt/keyrings/winehq.gpg && \
echo deb [signed-by=/etc/apt/keyrings/winehq.gpg] https://dl.winehq.org/wine-builds/debian/ ${DEBIAN_CODENAME} main >> /etc/apt/sources.list.d/winehq.list && \
rm winehq.key && \
apt-get update -q && \
apt-get install -qy --allow-downgrades \
wine-stable-amd64:amd64=${WINEVERSION} \
wine-stable-i386:i386=${WINEVERSION} \
wine-stable:amd64=${WINEVERSION} \
winehq-stable:amd64=${WINEVERSION} \
&& \
rm -rf /var/lib/apt/lists/* && \
apt-get autoremove -y && \
apt-get clean
# create new user to avoid using root; but with sudo access and no password for convenience.
ARG UID=1000
RUN if [ "$UID" != "0" ] ; then useradd --uid $UID --create-home --shell /bin/bash "user" ; fi
RUN usermod -append --groups sudo $(id -nu $UID || echo "user")
RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RUN HOME_DIR=$(getent passwd $UID | cut -d: -f6)
ENV WORK_DIR="${HOME_DIR}/wspace" \
PATH="${HOME_DIR}/.local/bin:${PATH}"
WORKDIR ${WORK_DIR}
RUN chown --recursive ${UID} ${WORK_DIR}
RUN chown ${UID} /opt
USER ${UID}
RUN mkdir --parents "/opt/wine64/drive_c/electrum"
================================================
FILE: contrib/build-wine/README.md
================================================
# Windows binaries
✓ _These binaries should be reproducible, meaning you should be able to generate
binaries that match the official releases._
- _Minimum supported target system (i.e. what end-users need): x86_64, Windows 10 (1809)_
This assumes an Ubuntu (x86_64) host, but it should not be too hard to adapt to another
similar system.
1. Install Docker
See [`contrib/docker_notes.md`](../docker_notes.md).
(worth reading even if you already have docker)
Note: older versions of Docker might not work well
(see [#6971](https://github.com/spesmilo/electrum/issues/6971)).
If having problems, try to upgrade to at least `docker 20.10`.
2. Build Windows binaries
```
$ ./build.sh
```
If you want reproducibility, try instead e.g.:
```
$ ELECBUILD_COMMIT=HEAD ./build.sh
```
3. The generated binaries are in `./contrib/build-wine/dist`.
## Code Signing
Electrum Windows builds are signed with a Microsoft Authenticode™ code signing
certificate in addition to the GPG-based signatures.
The advantage of using Authenticode is that Electrum users won't receive a
Windows SmartScreen warning when starting it.
The release signing procedure involves a signer (the holder of the
certificate/key) and one or multiple trusted verifiers:
| Signer | Verifier |
|-----------------------------------------------------------|--------------------------------------|
| Build .exe files using `make_win.sh` | |
| Sign .exe with `./sign.sh` | |
| Upload signed files to download server | |
| | Build .exe files using `make_win.sh` |
| | Compare files using `unsign.sh` |
| | Sign .exe file using `gpg -b` |
| Signer and verifiers: |
|--------------------------------------------------------------------------------------------------|
| Upload signatures to 'electrum-signatures' repo, as `$version/$filename.$builder.asc` |
## Verify Integrity of signed binary
Every user can verify that the official binary was created from the source code in this
repository. To do so, the Authenticode signature needs to be stripped since the signature
is not reproducible.
This procedure removes the differences between the signed and unsigned binary:
1. Remove the signature from the signed binary using osslsigncode or signtool.
2. Set the COFF image checksum for the signed binary to 0x0. This is necessary
because pyinstaller doesn't generate a checksum.
3. Append null bytes to the _unsigned_ binary until the byte count is a multiple
of 8.
The script `unsign.sh` performs these steps.
## FAQ
### How to investigate diff between binaries if reproducibility fails?
`pyi-archive_viewer` is needed, for that run `$ pip install pyinstaller`.
As a first pass overview, run:
```
pyi-archive_viewer -l electrum-*.exe1 > f1
pyi-archive_viewer -l electrum-*.exe2 > f2
diff f1 f2 > d
cat d
```
Then investigate manually:
```
$ pyi-archive_viewer electrum-*.exe1
? help
```
================================================
FILE: contrib/build-wine/README_windows.md
================================================
# Running Electrum from source on Windows (development version)
## Prerequisites
- [python3](https://www.python.org/)
- [git](https://gitforwindows.org/)
## Main steps
### 1. Check out the code from GitHub:
```
> git clone https://github.com/spesmilo/electrum.git
> cd electrum
> git submodule update --init
```
Run install (this should install most dependencies):
```
> python3 -m pip install --user -e ".[gui,crypto]"
```
### 2. Install `libsecp256k1`
[comment]: # (technically the dll should be put into site-packages/electrum_ecc/,
but putting it into electrum/ also works because of the `os.add_dll_directory` call in
electrum/__init__.py)
[libsecp256k1](https://github.com/bitcoin-core/secp256k1) is a required dependency.
This is a C library, which you need to compile yourself.
Electrum needs a dll, named `libsecp256k1-0.dll` (or newer `libsecp256k1-*.dll`),
placed into the inner `electrum/` folder.
For Unix-like systems, the (`contrib/make_libsecp256k1.sh`) script does this for you,
however it does not work on Windows.
If you have access to a Linux machine (e.g. VM) or perhaps even using
WSL (Windows Subsystem for Linux), you can cross-compile from there to Windows,
and build this dll:
```
$ GCC_TRIPLET_HOST="x86_64-w64-mingw32" ./contrib/make_libsecp256k1.sh
```
Alternatively, MSYS2 and MinGW-w64 can be used directly on Windows, as follows.
- download and install [MSYS2](https://www.msys2.org/)
- run MSYS2
- inside the MSYS2 shell:
```
$ pacman -Syu
$ pacman -S --needed git base-devel mingw-w64-x86_64-toolchain mingw-w64-x86_64-autotools
$ export PATH="$PATH:/mingw64/bin"
```
`cd` into the git clone, e.g. `C:\wspace\electrum` (auto-mounted at `/c/wspace/electrum`)
```
$ cd /c/wspace/electrum
$ GCC_TRIPLET_HOST="x86_64-w64-mingw32" ./contrib/make_libsecp256k1.sh
```
(note: this is a bit cumbersome, see [issue #5976](https://github.com/spesmilo/electrum/issues/5976)
for discussion)
### 3. Run electrum:
```
> python3 ./run_electrum
```
================================================
FILE: contrib/build-wine/apt.preferences
================================================
Package: *
Pin: origin "snapshot.debian.org"
Pin-Priority: 1001
================================================
FILE: contrib/build-wine/apt.sources.list
================================================
deb https://snapshot.debian.org/archive/debian/20260227T144551Z/ trixie main
deb-src https://snapshot.debian.org/archive/debian/20260227T144551Z/ trixie main
================================================
FILE: contrib/build-wine/build-electrum-git.sh
================================================
#!/bin/bash
NAME_ROOT=electrum
PROJECT_ROOT="$WINEPREFIX/drive_c/electrum"
export PYTHONDONTWRITEBYTECODE=1 # don't create __pycache__/ folders with .pyc files
# Let's begin!
set -e
. "$CONTRIB"/build_tools_util.sh
pushd "$PROJECT_ROOT"
VERSION=$(git describe --tags --dirty --always)
info "Last commit: $VERSION"
info "preparing electrum-locale."
(
"$CONTRIB/locale/build_cleanlocale.sh"
# we want the binary to have only compiled (.mo) locale files; not source (.po) files
rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/electrum.po
)
find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
popd
# opt out of compiling C extensions
export AIOHTTP_NO_EXTENSIONS=1
export YARL_NO_EXTENSIONS=1
export MULTIDICT_NO_EXTENSIONS=1
export FROZENLIST_NO_EXTENSIONS=1
export PROPCACHE_NO_EXTENSIONS=1
export ELECTRUM_ECC_DONT_COMPILE=1
info "Installing requirements..."
$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
--cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements.txt
info "Installing dependencies specific to binaries..."
# TODO tighten "--no-binary :all:" (but we don't have a C compiler...)
$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
--no-binary :all: --only-binary cffi,cryptography,PyQt6,PyQt6-Qt6,PyQt6-sip \
--cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-binaries.txt
info "Installing hardware wallet requirements..."
$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
--no-binary :all: --only-binary cffi,cryptography,hidapi \
--cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-hw.txt
pushd "$PROJECT_ROOT"
# see https://github.com/pypa/pip/issues/2195 -- pip makes a copy of the entire directory
info "Pip installing Electrum. This might take a long time if the project folder is large."
$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location .
# pyinstaller needs to be able to "import electrum_ecc", for which we need libsecp256k1:
# (or could try "pip install -e" instead)
cp electrum/libsecp256k1-*.dll "$WINEPREFIX/drive_c/python3/Lib/site-packages/electrum_ecc/"
popd
rm -rf dist/
# build standalone and portable versions
info "Running pyinstaller..."
ELECTRUM_CMDLINE_NAME="$NAME_ROOT-$VERSION" wine "$WINE_PYHOME/scripts/pyinstaller.exe" --noconfirm --clean pyinstaller.spec
# set timestamps in dist, in order to make the installer reproducible
pushd dist
find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
popd
info "building NSIS installer"
# $VERSION could be passed to the electrum.nsi script, but this would require some rewriting in the script itself.
makensis -DPRODUCT_VERSION=$VERSION electrum.nsi
cd dist
mv electrum-setup.exe $NAME_ROOT-$VERSION-setup.exe
cd ..
info "Padding binaries to 8-byte boundaries, and fixing COFF image checksum in PE header"
# note: 8-byte boundary padding is what osslsigncode uses:
# https://github.com/mtrojnar/osslsigncode/blob/6c8ec4427a0f27c145973450def818e35d4436f6/osslsigncode.c#L3047
(
cd dist
for binary_file in ./*.exe; do
info ">> fixing $binary_file..."
# code based on https://github.com/erocarrera/pefile/blob/bbf28920a71248ed5c656c81e119779c131d9bd4/pefile.py#L5877
python3 <> 32)
if checksum > 2 ** 32:
checksum = (checksum & 0xffffffff) + (checksum >> 32)
checksum = (checksum & 0xffff) + (checksum >> 16)
checksum = (checksum) + (checksum >> 16)
checksum = checksum & 0xffff
checksum += len(binary)
# Set the checksum
binary[checksum_offset : checksum_offset + 4] = int.to_bytes(checksum, byteorder="little", length=4)
with open(pe_file, "wb") as f:
f.write(binary)
EOF
done
)
sha256sum dist/electrum*.exe
================================================
FILE: contrib/build-wine/build.sh
================================================
#!/bin/bash
#
# env vars:
# - ELECBUILD_NOCACHE: if set, forces rebuild of docker image
# - ELECBUILD_COMMIT: if set, do a fresh clone and git checkout
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
PROJECT_ROOT_OR_FRESHCLONE_ROOT="$PROJECT_ROOT"
CONTRIB="$PROJECT_ROOT/contrib"
CONTRIB_WINE="$CONTRIB/build-wine"
BUILD_UID=$(/usr/bin/stat -c %u "$PROJECT_ROOT")
. "$CONTRIB"/build_tools_util.sh
info "Clearing $CONTRIB_WINE/dist..."
rm -rf "$CONTRIB_WINE"/dist/*
DOCKER_BUILD_FLAGS=""
if [ ! -z "$ELECBUILD_NOCACHE" ] ; then
info "ELECBUILD_NOCACHE is set. forcing rebuild of docker image."
DOCKER_BUILD_FLAGS="--pull --no-cache"
fi
if [ -z "$ELECBUILD_COMMIT" ] ; then # local dev build
DOCKER_BUILD_FLAGS="$DOCKER_BUILD_FLAGS --build-arg UID=$BUILD_UID"
fi
info "building docker image."
docker build \
$DOCKER_BUILD_FLAGS \
-t electrum-wine-builder-img \
"$CONTRIB_WINE"
# maybe do fresh clone
if [ ! -z "$ELECBUILD_COMMIT" ] ; then
info "ELECBUILD_COMMIT=$ELECBUILD_COMMIT. doing fresh clone and git checkout."
FRESH_CLONE="/tmp/electrum_build/windows/fresh_clone/electrum"
rm -rf "$FRESH_CLONE" 2>/dev/null || ( info "we need sudo to rm prev FRESH_CLONE." && sudo rm -rf "$FRESH_CLONE" )
umask 0022
git clone "$PROJECT_ROOT" "$FRESH_CLONE"
cd "$FRESH_CLONE"
git checkout "$ELECBUILD_COMMIT"
PROJECT_ROOT_OR_FRESHCLONE_ROOT="$FRESH_CLONE"
else
info "not doing fresh clone."
fi
DOCKER_RUN_FLAGS=""
if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
info "/dev/tty is available and usable"
DOCKER_RUN_FLAGS="-it"
fi
info "building binary..."
# check uid and maybe chown. see #8261
if [ ! -z "$ELECBUILD_COMMIT" ] ; then # fresh clone (reproducible build)
if [ $(id -u) != "1000" ] || [ $(id -g) != "1000" ] ; then
info "need to chown -R FRESH_CLONE dir. prompting for sudo."
sudo chown -R 1000:1000 "$FRESH_CLONE"
fi
fi
docker run $DOCKER_RUN_FLAGS \
--name electrum-wine-builder-cont \
-v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT":/opt/wine64/drive_c/electrum \
--rm \
--workdir /opt/wine64/drive_c/electrum/contrib/build-wine \
electrum-wine-builder-img \
./make_win.sh
# make sure resulting binary location is independent of fresh_clone
if [ ! -z "$ELECBUILD_COMMIT" ] ; then
mkdir --parents "$PROJECT_ROOT/contrib/build-wine/dist/"
cp -f "$FRESH_CLONE/contrib/build-wine/dist"/*.exe "$PROJECT_ROOT/contrib/build-wine/dist/"
fi
================================================
FILE: contrib/build-wine/electrum.nsi
================================================
;--------------------------------
;Include Modern UI
!include "TextFunc.nsh" ;Needed for the $GetSize function. I know, doesn't sound logical, it isn't.
!include "MUI2.nsh"
;--------------------------------
;Variables
!define PRODUCT_NAME "Electrum"
!define PRODUCT_WEB_SITE "https://github.com/spesmilo/electrum"
!define PRODUCT_PUBLISHER "Electrum Technologies GmbH"
!define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}"
;--------------------------------
;General
;Name and file
Name "${PRODUCT_NAME}"
OutFile "dist/electrum-setup.exe"
;Default installation folder
InstallDir "$PROGRAMFILES64\${PRODUCT_NAME}"
;Get installation folder from registry if available
InstallDirRegKey HKCU "Software\${PRODUCT_NAME}" ""
;Request application privileges for Windows Vista
RequestExecutionLevel admin
;Specifies whether or not the installer will perform a CRC on itself before allowing an install
CRCCheck on
;Sets whether or not the details of the install are shown. Can be 'hide' (the default) to hide the details by default, allowing the user to view them, or 'show' to show them by default, or 'nevershow', to prevent the user from ever seeing them.
ShowInstDetails show
;Sets whether or not the details of the uninstall are shown. Can be 'hide' (the default) to hide the details by default, allowing the user to view them, or 'show' to show them by default, or 'nevershow', to prevent the user from ever seeing them.
ShowUninstDetails show
;Sets the colors to use for the install info screen (the default is 00FF00 000000. Use the form RRGGBB (in hexadecimal, as in HTML, only minus the leading '#', since # can be used for comments). Note that if "/windows" is specified as the only parameter, the default windows colors will be used.
InstallColors /windows
;This command sets the compression algorithm used to compress files/data in the installer. (http://nsis.sourceforge.net/Reference/SetCompressor)
SetCompressor /SOLID lzma
;Sets the dictionary size in megabytes (MB) used by the LZMA compressor (default is 8 MB).
SetCompressorDictSize 64
;Sets the text that is shown (by default it is 'Nullsoft Install System vX.XX') in the bottom of the install window. Setting this to an empty string ("") uses the default; to set the string to blank, use " " (a space).
BrandingText "${PRODUCT_NAME} Installer v${PRODUCT_VERSION}"
;Sets what the titlebars of the installer will display. By default, it is 'Name Setup', where Name is specified with the Name command. You can, however, override it with 'MyApp Installer' or whatever. If you specify an empty string (""), the default will be used (you can however specify " " to achieve a blank string)
Caption "${PRODUCT_NAME}"
;Adds the Product Version on top of the Version Tab in the Properties of the file.
VIProductVersion 1.0.0.0
;VIAddVersionKey - Adds a field in the Version Tab of the File Properties. This can either be a field provided by the system or a user defined field.
VIAddVersionKey ProductName "${PRODUCT_NAME} Installer"
VIAddVersionKey Comments "The installer for ${PRODUCT_NAME}"
VIAddVersionKey CompanyName "${PRODUCT_NAME}"
VIAddVersionKey LegalCopyright "2013-2018 ${PRODUCT_PUBLISHER}"
VIAddVersionKey FileDescription "${PRODUCT_NAME} Installer"
VIAddVersionKey FileVersion ${PRODUCT_VERSION}
VIAddVersionKey ProductVersion ${PRODUCT_VERSION}
VIAddVersionKey InternalName "${PRODUCT_NAME} Installer"
VIAddVersionKey LegalTrademarks "${PRODUCT_NAME} is a trademark of ${PRODUCT_PUBLISHER}"
VIAddVersionKey OriginalFilename "${PRODUCT_NAME}.exe"
;--------------------------------
;Interface Settings
!define MUI_ABORTWARNING
!define MUI_ABORTWARNING_TEXT "Are you sure you wish to abort the installation of ${PRODUCT_NAME}?"
!define MUI_ICON "..\..\electrum\gui\icons\electrum.ico"
;--------------------------------
;Pages
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
;--------------------------------
;Languages
!insertmacro MUI_LANGUAGE "English"
;--------------------------------
;Functions
!macro CreateEnsureNotRunning prefix operation
Function ${prefix}EnsureNotRunning
; pop the directory to check from the stack into $R0
Pop $R0
; if the dir at $R0 doesn't exist, jump to nodir
IfFileExists "$R0" 0 nodir
; Find all .exe files in the directory, $1 is the handle, $2 is the filename
FindFirst $1 $2 "$R0\*.exe"
IfErrors noexe 0
checkloop:
; Skip checking the uninstaller if we are the uninstaller to avoid locking the uninstaller itself
!if "${prefix}" == "un."
StrCmp $2 "Uninstall.exe" skipfile 0
!endif
; Check if we can append to the .exe file. If we can't that means it is still running.
retryopen:
FileOpen $0 "$R0\$2" a
IfErrors 0 closeexe
MessageBox MB_RETRYCANCEL "Can not ${operation} because $2 is still running. Close it and retry." /SD IDCANCEL IDRETRY retryopen
FindClose $1
Abort
closeexe:
FileClose $0
skipfile:
; Find next .exe file
FindNext $1 $2
IfErrors done 0
Goto checkloop
done:
FindClose $1
noexe:
nodir:
FunctionEnd
!macroend
; The function has to be created twice, once for the installer and once for the uninstaller
!insertmacro CreateEnsureNotRunning "" "install"
!insertmacro CreateEnsureNotRunning "un." "uninstall"
;--------------------------------
;Installer Sections
;Check if we have Administrator rights
Function .onInit
UserInfo::GetAccountType
pop $0
${If} $0 != "admin" ;Require admin rights on NT4+
MessageBox mb_iconstop "Administrator rights required!"
SetErrorLevel 740 ;ERROR_ELEVATION_REQUIRED
Quit
${EndIf}
; Check if already installed and ensure the process is not running if it is
ReadRegStr $R0 HKCU "Software\${PRODUCT_NAME}" ""
IfErrors noinstdir 0
Push $R0
Call EnsureNotRunning
noinstdir:
ClearErrors
FunctionEnd
Section
SetOutPath $INSTDIR
;Uninstall previous version files
RMDir /r "$INSTDIR\*.*"
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
Delete "$SMPROGRAMS\${PRODUCT_NAME}\*.*"
;Files to pack into the installer
File /r "dist\electrum\*.*"
File "..\..\electrum\gui\icons\electrum.ico"
;Store installation folder
WriteRegStr HKCU "Software\${PRODUCT_NAME}" "" $INSTDIR
;Create uninstaller
DetailPrint "Creating uninstaller..."
WriteUninstaller "$INSTDIR\Uninstall.exe"
;Create desktop shortcut
DetailPrint "Creating desktop shortcut..."
CreateShortCut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\electrum-${PRODUCT_VERSION}.exe" ""
;Create start-menu items
DetailPrint "Creating start-menu items..."
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall.lnk" "$INSTDIR\Uninstall.exe" "" "$INSTDIR\Uninstall.exe" 0
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" "$INSTDIR\electrum-${PRODUCT_VERSION}.exe" "" "$INSTDIR\electrum-${PRODUCT_VERSION}.exe" 0
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME} Testnet.lnk" "$INSTDIR\electrum-${PRODUCT_VERSION}.exe" "--testnet" "$INSTDIR\electrum-${PRODUCT_VERSION}.exe" 0
;Links bitcoin: and lightning: URIs to Electrum
WriteRegStr HKCU "Software\Classes\bitcoin" "" "URL:bitcoin Protocol"
WriteRegStr HKCU "Software\Classes\bitcoin" "URL Protocol" ""
WriteRegStr HKCU "Software\Classes\bitcoin" "DefaultIcon" "$\"$INSTDIR\electrum.ico, 0$\""
WriteRegStr HKCU "Software\Classes\bitcoin\shell\open\command" "" "$\"$INSTDIR\electrum-${PRODUCT_VERSION}.exe$\" $\"%1$\""
WriteRegStr HKCU "Software\Classes\lightning" "" "URL:lightning Protocol"
WriteRegStr HKCU "Software\Classes\lightning" "URL Protocol" ""
WriteRegStr HKCU "Software\Classes\lightning" "DefaultIcon" "$\"$INSTDIR\electrum.ico, 0$\""
WriteRegStr HKCU "Software\Classes\lightning\shell\open\command" "" "$\"$INSTDIR\electrum-${PRODUCT_VERSION}.exe$\" $\"%1$\""
;Adds an uninstaller possibility to Windows Uninstall or change a program section
WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "DisplayName" "$(^Name)"
WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "UninstallString" "$INSTDIR\Uninstall.exe"
WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "DisplayVersion" "${PRODUCT_VERSION}"
WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "URLInfoAbout" "${PRODUCT_WEB_SITE}"
WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "Publisher" "${PRODUCT_PUBLISHER}"
WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "DisplayIcon" "$INSTDIR\electrum.ico"
;Fixes Windows broken size estimates
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${PRODUCT_UNINST_KEY}" "EstimatedSize" "$0"
SectionEnd
;--------------------------------
;Descriptions
;--------------------------------
;Uninstaller Section
Section "Uninstall"
RMDir /r "$INSTDIR\*.*"
RMDir "$INSTDIR"
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
Delete "$SMPROGRAMS\${PRODUCT_NAME}\*.*"
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
DeleteRegKey HKCU "Software\Classes\bitcoin"
DeleteRegKey HKCU "Software\${PRODUCT_NAME}"
DeleteRegKey HKCU "${PRODUCT_UNINST_KEY}"
SectionEnd
Function UN.onInit
; Ensure the process is not running in the uninstallation directory
Push $INSTDIR
Call un.EnsureNotRunning
FunctionEnd
================================================
FILE: contrib/build-wine/gpg_keys/7ED10B6531D7C8E1BC296021FC624643487034E5.asc
================================================
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: User-ID: Steve Dower (Python Release Signing)
Comment: Created: 2015-04-06 02:32
Comment: Type: 4096-bit RSA
Comment: Usage: Signing, Encryption, Certifying User-IDs
Comment: Fingerprint: 7ED10B6531D7C8E1BC296021FC624643487034E5
mQINBFUh1AUBEACdUPt6PwJVO23zGZqgtgBeA9JsO22dk3CMzrwPJdUmMd6mcRWa
vl4BoAba66fuC17GvOgGXimKI+iaw5Vt9QI3uSjUjFSfc24J8T7NB/yAr/0zEcex
raHD2dxT/JpE/iY0yWHxRlitvwGSw1Qlq3NnY8tDI1DJEJD+gBuCktvVvu1FfQTw
6bd+aEq0c4sWJHAOnKLuLH0pNFOznnynAFGPGBBsm/YwYc5BP2JVvka775LUjA+W
1h2Sgg3FAUPIm64pc4Pq6mUo6Tulw72xsWMpCL1/5atXNPXT6rJUOB8euTcNMr4l
1O6GKSsiLeLAuvq4bmhOKtLzjWzXnY1gDVoOfdgpD6o4ZHk4xiVsdVE8hCa/ylz8
1ZwRW2gGo2jP8t3hKciR2i+Qs+6lPNZpeFIxa6Uo9ER1IBgCHHapIR/UdcOFyoS0
MNn7Ui7DLQNM4gI/G17eG9tfvjW2dl4SgFSYWMq/OtXnPDUBGqFUWsn8adOL2PFL
B7kM5ZRTPc5SnY9hoSGa5E20rJZIXcpy1aygRz/xUjoKwNzAySSEyyIorUxZ8KaH
EEBQSsqwe04MXIENqnDozH0/cvP4JXEDSl8EkzMSCWSoavQSIYD5pQppyFQpGHqa
5CuOA25Ja+sgp2xqahtr3fEqZUknPQSoYlnJbaHnzsGSlRAVWMsklsZibQARAQAB
tEBTdGV2ZSBEb3dlciAoUHl0aG9uIFJlbGVhc2UgU2lnbmluZykgPHN0ZXZlLmRv
d2VyQG1pY3Jvc29mdC5jb20+iQEcBBABCAAGBQJYsBphAAoJEEhSKohZ29goZggI
ALKlgyoecD5v3ulh1eoctRqtCOxkAoENEfPt3l5x6N8Wq89yHzf10T1rVioEXOHh
Di1m37DDoQmRJD0sOYQymq10xDGRYAJjyOf3X0pvRkZ+F7T0U4dSV3DasLIHcN26
kRwv1yCYsf0QvhgT6EJZKyUNHtV9qrb9u3A1Zp6epC/EyT8zMZj+21GzTUrnbnug
3Ak9p7+APCZS4Ahh9ZHFuD38MZ7+OwrUd6ot+6cbb1nnQLSAGQOHSp6EP6ktrnsK
zts0L+tzHurxtJgUkR01imJuSFfYpLoZa/L7qXNyEpEUTC/SWzRWD9y2QkM7DLzX
caReVAyJr9rix1lDQbEFIquJAhwEEAEIAAYFAlW2TwMACgkQKeBHm5nIo5fahg/+
IQSSE/yH8Cf82PYI7IGqDVNwRw2o7dq8iscB+fhFHfFFhXANwUUFpzPeDMrMrdmq
Bke7Vg1D3bIFocXYOiNwf2J7f4mBO6OL0VAvDX02Vyh/C2ZSc15uZyU6CWFQMCG8
JOSmgQFs3kMHkL4qtut1Y5reoYesmteIe06UVyRw8yT1R1BkxP2whZ97qwsvUUE9
cVD08wCvH486efw7EswIzYGa1KcZXji0MvjXfksVtkEQQbxMMI7SVXo0345ZReww
buioGL5gvvAPObgU43skORanFHFxiHEKmqgHBHXK/LKqaFUFMKcb4iFTNs2XKrhE
XsEi5EMI1AFsJzjcXRqT50Wi2cZhXeRc70uF6gzqrdWvowa2oOPiO6zGDiTqZCW1
AArk/QBzGtPjVh+nKEdHwnvpK9913UAkAN682h8QkoVPYXOvIKDYZRBr5EfpUyQt
y2r9MYewz0YN4zlGP1PFS9FxncdSZiZJqQVif0CkOp1tdSxLynHcujQgATZNtgcu
X9JwUwPp60MurgOcIZiW3nZw/z/5vzBBadSa9/TIFSJAFNBlqeKdIGQuik0UH+Cz
RRtSFb38F7jMPwr0QUSktuntQ0HWuvNqj4N8DFm45/n5rN190eRotrVDXZmjGein
qWPITuICslGIKAp+Q6y3t7JA71MIbeu/ZY6ZcftOka6JAhwEEAEIAAYFAlZRWicA
CgkQxiNM8COVzQq5bRAAktnXceO3GCivMt9yR1Qr0Ov4A4Q+CJSIL45efLFmS30k
cbkHHtaq+0FZNh2ZaMartC16MUja4a2OUejg53VBhaSVkQrVk/6M/HA6/o6CvIhb
FW/5C+nRWBd5gfvwsWvjrtC3cKZco4wg+yYclkDbSH+2EPDZOKIHpBy46YTz9WQ1
8SJ51WVkNUNiZqRBA6Ny5GFoyd6EpWZYEPelmzNemv3zOrQdVzLV24/mLejcLL2t
KmI6ngX4XViXUCRUU3MH8/V+V2YTQGcTM/6HGaHpN0LTqknf6zEto9q9FiRTaiU2
kzExhBq8Qf+cVqwm+1kMt0FGOgpT47VBWMeUWq62gQ3h5NfAs4DfriLgNURlTC1d
JYAEquFhB/8oBQD1h/d9CjQyk88iib2pJInRBDsK2FcfQBap9iaeBFYoBWTzMQJx
g+RuWK1wIm2n0oqa5urBYZtRHE5RIdDP8ZLogrBOFkfXGJxlRBQD1Gab77qohdp0
SnErGw4Ne3gJH/SNhK+zzHkHERIrRZCR95zdYkKfZ2jyOPzSuABVRigEQVQPCDn0
hbv3cblTCeJYwG2mfRdmfyqSMALKIgXe9yvJ2kl8QgaVOsJjNfQzIKeoHFPIm5Uw
3YB6jgDFc5uzEaH7WSz74A7KhGYjC7huw2TugosHbWxphJKddwxfK1WujYaAeJyJ
AhwEEAEIAAYFAlf2sPIACgkQfb+tds3soNuXEQ//XkWYHmJsKyeDZC8MFU+/vsVq
dhnFs6UXZkvf7MoNFkuMDL+zgVoMpFHftTdyBqNAoEnndakk212jK8YWF8g4kQXI
a9uMRqJLM4mqCl9yco/twJ9z9EMA+JLSXYK0ZbTkLdutSDZEDKgpHbmekx2C1OsW
lRLs9PahF5PAZQs0N+m+LJBnw6bEHOSTv4OE5uVUf9nvdes3OARvkGSEGURNmUaF
chxWtZ/SF1q9Jfj0K/xgs9Gt855oueveRXLIGpjiEVoKH/drsgyKFMJVrpZDDgS4
GVXG8bq3GTFiMAs7BPPd9bjI+jgvqttgItZcYsW/IQK1BIoG6Fere4cPvu+IshCc
km9T8nOK98tZuov8hLbND9mW2d7LChJI1r/HbzbKIl0k6OigdFMrJlun2zmtDxT9
Tp3uxOYSaW2YggcpNUjI28tv6AwoA8okVY93LWjO5kdZGkbliRnf/eJy7NJYn0LO
ogsvMUJClRAGnZTHLEr32Whq0MImlXa43kr6oPJT5dwXXyw5ELstEQztczCd1PYB
kbQHUpD5j3PwgNVOinCnbd4pc/qVtYSqpg2g6TJi1XiJ1638jhn2k+i8wop/dyet
iN8lGR76twYGex9AavEAUpVR9r6qfpp4KBibEhdvL6o2O03RQu17GcRzXSAYzmUi
5U5jZ3dBz5MYUjgUZM+JAhwEEAEKAAYFAllTh9gACgkQXLNh5VL7DRAk7Q//X8eU
hwEvl/d9Sv2kBNCZFjAW3QmZp2L/sxhScJZXrOFzKUdmjap9Xlul1qr6/Wif7YLK
bOdNUI7KziEBn+9SEd90XauoVkzU2F0Jn9ILGQfUHAIpocRTKuCwBrncaBozHQwD
O3Dk33AhZ6lqTv/AVLRKHQXwigGTBJxK4cCEZ+VwK9tKk6BrQB48Rm7pg9HF5ey5
JGPRWgUnn1v0IJN5ysZ5m9ChYbqF8VwvMw0txmgKgvdDKpXbF/S59Bp4TH/7Dr2D
kAeNTcuzTFBaFE+siMgksZIYKZ1VkVoiN2qQA7ZaA5LQbUom0WdrKZGefFfPt9ES
A4wyL3OfxRsmWmd/5Fxrwm1VbzgPoMd1Dc5ExlyqnecdGzDui2bmltNqRJd9ytRq
6YUGYzXp4qQkWO61CoC3mkm2M8Ex7DGbUtXhdg0zoa08w9lXuOtHVhY7XlLWjO1U
p8cp4DVxsN/wOXtyH1pcleGo4aEsgyU/DH57prFLGz7Egp2JhRDHnZmlonWp74G1
VLfqkOqZlqTU4mPA827C8qPCx6cMsRvFS7OEiDBswkFWBKjkUCw4rLC1tBMBCxJW
tZlc+Y0LNyOryJ3h6EJmRIHO57oLen345e1WOi4ROOC/wQMErFk7B3P41Lqmrwb8
HGuKn3ca+Aw70hVrZ+7Q3RRFTLlOS/vv107Fqu6JAjkEEwEIACMFAlUh1AUCGwMH
CwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIXgAAKCRD8YkZDSHA05RfdD/97wPXnoe7e
ipP7UXQ942z1buV6pTGv0Lea2aHn20o2BBjHp97YXroF/e/8W6h+Y+Fq8hWoXdYJ
dC9DVgzJhvbXAIG8VrF6/IDGQ62r4ff/AIyQY+kiCOCCVhjwuqOTjVYw2pYRUcI3
UwXVPeptDSXcIZkHCLtEUnS5YMTdkPuZrAmucCCnfcJtevXbHD2yJYP4vwfXMbal
sNBDKJi6uYAFc4yv+/DyS13rfXJvu2pYGvtRd+fs7mBETvUTubhI440pIss6TX6M
lxWexX6Ty8vI5HCQT281H4zqdbe5GdzGmIx1EiYx1sJbgSBNqCh5sRJY5/BXzVJ3
dfM/Mv5QYY4ulO/qUNFdC8f1cZm0euOo3maB4jY+Sjaff7t0WIz0GufO4dHARwJg
3s0LO9Wf5+z/fbWOMcfvvcfaHNbhaKWk16kslc/g7NYvMfOuleM06YGyGPz//a9c
baX53OiMupNvLlhyPO5NfGppvRn5xAElcAw1RLhHJcgvTtIs/zVVfHPaK41u8A9c
XKnmIUC39K4BGvOpPzEvCdQ2ZbAqzQLmZ1UICr15w1Nfs6uoERJbnuq+JgOPOcOk
ezAWELi5LdZTElnpJpZPTDQ03+3GvxD4R9sR+l5RT8Ul7kF+3PPPzekfQzF+Nisr
BhPFb2lPt3Hw32FgTTIuXCMRTKEBb/6z77kCDQRVIdQFARAAtmnsZ9A8ovJIJ9Rl
WeIylEhHRyQifqzgc/r50uDZVPBjewOA462LjH3+F6zFGEkU+q2aqSe0A0SJPF/W
hj6MNYXLoibxi5D4mGkoIao9ExnXt4LXAc6ogQpY6vFQBJU5Nr8XCefQbm0loa/o
y5uK8JHLWCZ2jAossnVpzDwNeN27+B8h5+OifnWhQCTun1xz5EJiyc0yoBmf46zf
mU4CMUBsPvrXcLmw4J3wp35qmrHg1tNyPhd7VBlikMrgtrWX9IaPZ40dnrGG/WjO
FYB3CKxGb0pTCj7GC4ubxo2upeWZqHLmdIVc7Nzsfp8EcwJbTj+jZ2Zfq6F8y+je
sbgh8CaxYn4hEs23aPYRq5H4/buVmZhUw3/AAL9ZmyX6AtAQ0HktVtQe7ykP7DLs
EpeLG+vPJFY363QeDsLHwOoxnZSfGziVlB4N/KqIkixNWcFTG8GSE1zKcdJVNoW+
3MB3+FtMZWUJhH0FyKg5qLaJCtC7Yo5gsddU+QCqTn6gcZBnMX5j4LaAmW4hh1RX
ffwwsbfviK5uhXQCeUnbUaokieetDx4s6Kay6t9ahTRr0r/Z3VWzvr+xATxNWZzi
xTdezCGOB2ycZ0vq4bKXBuN8CAyOy5X1hf7Rc1BiAVQCILHJDtz0Ak/Hax6DAa2A
Hnx9YlugHQf000KroLEY+GaxqYEAEQEAAYkCHwQYAQgACQUCVSHUBQIbDAAKCRD8
YkZDSHA05RtyEACdOEmGolL1xG6I+lDVdot6oBZqC9e021aLWqCUpWJFDp0m0aTm
CfmOI1gTaFjScxhq1W0GPUoJKUZhk3tlVfdSCtUckI+xuWKEfqJYtvUtTXpK4jDe
aZBovJ3KNpJRIynbr1566zCSQJhHiCGWmE/M5KN3gPsORbCBQXEkONSVsslf1Wm6
6hU6uqSWUaceD+4fl5LClbck1DPWchAP7+uLKPEOtORyH6KRTgKl73zYo7xU1K4Q
MN/1aMjobPkqNvvkXnUNwO7QMz18Nx+WqPc4ksJgW1O1aPQ2qL/ARY5jatZ6BBd7
iytfz7d6JOh0FOIlmhBqbWd7fEGrLsSA+EjBGBwW5BnIMmxP1xhjhwrcI18y8kAK
5UzdW2hbbAlc2rlsuxEc+xOYh8kGcc+mZ1j/aMn4gALsTbSO/0T+YJhfODNnL1dC
j7oPbJGmmG6pb/o7P4azBUVC9lHOuV3XlAPjSmJylnNsV7+PxwPlXlvKgh4S4C4Z
PUc/iPetsxXR2djccOoNxVU4CqJBqYKgul/pUphXkh7QfEKyH+42UETbVhstdBVU
azJ6SeUnv9ClVDGsCEhfEZfNOnOoDzJGxDfESoAw7ih91vIhTyHHsK83p2HLDMLP
ptLzx/0AFBfo6MWGGpd2RSnMWNbvh59wiThlDeI+Das3ln5nsAo67dMYdA==
=fjOq
-----END PGP PUBLIC KEY BLOCK-----
================================================
FILE: contrib/build-wine/make_win.sh
================================================
#!/bin/bash
set -e
here="$(dirname "$(readlink -e "$0")")"
test -n "$here" -a -d "$here" || exit
if [ -z "$WIN_ARCH" ] ; then
export WIN_ARCH="win64" # default
fi
if [ "$WIN_ARCH" = "win32" ] ; then
export GCC_TRIPLET_HOST="i686-w64-mingw32"
elif [ "$WIN_ARCH" = "win64" ] ; then
export GCC_TRIPLET_HOST="x86_64-w64-mingw32"
else
echo "unexpected WIN_ARCH: $WIN_ARCH"
exit 1
fi
export BUILD_TYPE="wine"
export GCC_TRIPLET_BUILD="x86_64-pc-linux-gnu"
export GCC_STRIP_BINARIES="1"
export CONTRIB="$here/.."
export PROJECT_ROOT="$CONTRIB/.."
export CACHEDIR="$here/.cache/$WIN_ARCH/build"
export PIP_CACHE_DIR="$here/.cache/$WIN_ARCH/wine_pip_cache"
export WINE_PIP_CACHE_DIR="c:/electrum/contrib/build-wine/.cache/$WIN_ARCH/wine_pip_cache"
export DLL_TARGET_DIR="$CACHEDIR/dlls"
export WINEPREFIX="/opt/wine64"
export WINEDEBUG=-all
export WINE_PYHOME="c:/python3"
export WINE_PYTHON="wine $WINE_PYHOME/python.exe -B"
. "$CONTRIB"/build_tools_util.sh
git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
info "Clearing $here/build and $here/dist..."
rm "$here"/build/* -rf
rm "$here"/dist/* -rf
mkdir -p "$CACHEDIR" "$DLL_TARGET_DIR" "$PIP_CACHE_DIR"
if ls "$DLL_TARGET_DIR"/libsecp256k1-*.dll 1> /dev/null 2>&1; then
info "libsecp256k1 already built, skipping"
else
"$CONTRIB"/make_libsecp256k1.sh || fail "Could not build libsecp"
fi
if [ -f "$DLL_TARGET_DIR/libzbar-0.dll" ]; then
info "libzbar already built, skipping"
else
(
# iconv is needed for zbar. see https://github.com/mchehab/zbar/blob/a549566ea11eb03622bd4458a1728ffe3f589163/README-windows.md
# (previously were using win-iconv, but changed to GNU libiconv due to compilation errors with modern gcc)
LIBICONV_VER="1.18"
download_if_not_exist "$CACHEDIR/libiconv-${LIBICONV_VER}.tar.gz" "https://ftp.gnu.org/pub/gnu/libiconv/libiconv-${LIBICONV_VER}.tar.gz"
verify_hash "$CACHEDIR/libiconv-${LIBICONV_VER}.tar.gz" "3b08f5f4f9b4eb82f151a7040bfd6fe6c6fb922efe4b1659c66ea933276965e8"
tar xf "$CACHEDIR/libiconv-${LIBICONV_VER}.tar.gz" -C "$CACHEDIR"
# ref https://github.com/msys2/MINGW-packages/blob/7f68e9f2488737bbe03888ade094eaee8021d1c5/mingw-w64-libiconv/PKGBUILD
info "Building libiconv..."
cd "$CACHEDIR/libiconv-${LIBICONV_VER}"
# Patches taken from msys2/MINGW-packages
patch -p1 < "$here/patches/libiconv-fix-pointer-buf.patch"
./configure \
$AUTOCONF_FLAGS \
--prefix="/usr/${GCC_TRIPLET_HOST}" \
--disable-static \
--enable-shared \
--enable-extra-encodings \
--enable-relocatable \
--disable-rpath \
--enable-silent-rules \
--enable-nls
CC="${GCC_TRIPLET_HOST}-gcc" make "-j$CPU_COUNT" || fail "Could not build libiconv"
cp -fpv "libcharset/lib/.libs/libcharset-1.dll" "$DLL_TARGET_DIR/" || fail "Could not copy the libcharset binary to DLL_TARGET_DIR"
cp -fpv "lib/.libs/libiconv-2.dll" "$DLL_TARGET_DIR/" || fail "Could not copy the libiconv binary to DLL_TARGET_DIR"
# FIXME avoid using sudo
sudo make install || fail "Could not install libiconv"
# workaround to delete files owned by root, created by "make install":
make clean
)
"$CONTRIB"/make_zbar.sh || fail "Could not build zbar"
fi
if [ -f "$DLL_TARGET_DIR/libusb-1.0.dll" ]; then
info "libusb already built, skipping"
else
"$CONTRIB"/make_libusb.sh || fail "Could not build libusb"
fi
"$here/prepare-wine.sh" || fail "prepare-wine failed"
info "Resetting modification time in C:\Python..."
# (Because of some bugs in pyinstaller)
pushd /opt/wine64/drive_c/python*
find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
popd
ls -l /opt/wine64/drive_c/python*
"$here/build-electrum-git.sh" || fail "build-electrum-git failed"
info "Done."
================================================
FILE: contrib/build-wine/patches/libiconv-fix-pointer-buf.patch
================================================
--- a/lib/iconv.c 2018-05-03 23:18:55.997221700 -0400
+++ b/lib/iconv.c 2018-05-03 23:26:47.611682700 -0400
@@ -170,12 +170,12 @@ static const struct stringpool2_t string
#include "aliases2.h"
#undef S
};
#define stringpool2 ((const char *) &stringpool2_contents)
static const struct alias sysdep_aliases[] = {
-#define S(tag,name,encoding_index) { (int)(long)&((struct stringpool2_t *)0)->stringpool_##tag, encoding_index },
+#define S(tag,name,encoding_index) { (int)(intptr_t)&((struct stringpool2_t *)0)->stringpool_##tag, encoding_index },
#include "aliases2.h"
#undef S
};
#ifdef __GNUC__
__inline
#else
--- a/lib/genaliases.c 2023-01-14 00:00:00.000000000 +0000
+++ b/lib/genaliases.c 2023-01-14 10:18:00.000000000 +0000
@@ -50,7 +50,7 @@
putc(c, out2);
}
}
- fprintf(out2,"\")' tmp.h | sed -e 's|^.*\\(stringpool_str[0-9]*\\).*$| (int)(long)\\&((struct stringpool_t *)0)->\\1,|'\n");
+ fprintf(out2,"\")' tmp.h | sed -e 's|^.*\\(stringpool_str[0-9]*\\).*$| (int)(intptr_t)\\&((struct stringpool_t *)0)->\\1,|'\n");
for (; n > 0; names++, n--)
emit_alias(out1, *names, c_name);
}
--- a/lib/genaliases2.c 2023-01-14 00:00:00.000000000 +0000
+++ b/lib/genaliases2.c 2023-01-14 10:18:00.000000000 +0000
@@ -44,6 +44,6 @@
static void emit_encoding (FILE* out1, FILE* out2, const char* tag, const char* const* names, size_t n, const char* c_name)
{
- fprintf(out2," (int)(long)&((struct stringpool2_t *)0)->stringpool_%s_%u,\n",tag,counter);
+ fprintf(out2," (int)(intptr_t)&((struct stringpool2_t *)0)->stringpool_%s_%u,\n",tag,counter);
for (; n > 0; names++, n--)
emit_alias(out1, tag, *names, c_name);
}
================================================
FILE: contrib/build-wine/prepare-wine.sh
================================================
#!/bin/bash
PYINSTALLER_REPO="https://github.com/pyinstaller/pyinstaller.git"
PYINSTALLER_COMMIT="306d4d92580fea7be7ff2c89ba112cdc6f73fac1"
# ^ tag "v6.13.0"
PYTHON_VERSION=3.12.10
# Let's begin!
set -e
here="$(dirname "$(readlink -e "$0")")"
. "$CONTRIB"/build_tools_util.sh
info "Booting wine."
wine 'wineboot'
cd "$CACHEDIR"
mkdir -p $WINEPREFIX/drive_c/tmp
info "Installing Python."
# note: you might need "sudo apt-get install dirmngr" for the following
# keys from https://www.python.org/downloads/#pubkeys
KEYRING_PYTHON_DEV="keyring-electrum-build-python-dev.gpg"
gpg --no-default-keyring --keyring $KEYRING_PYTHON_DEV --import "$here"/gpg_keys/7ED10B6531D7C8E1BC296021FC624643487034E5.asc
if [ "$WIN_ARCH" = "win32" ] ; then
PYARCH="win32"
elif [ "$WIN_ARCH" = "win64" ] ; then
PYARCH="amd64"
else
fail "unexpected WIN_ARCH: $WIN_ARCH"
fi
PYTHON_DOWNLOADS="$CACHEDIR/python$PYTHON_VERSION"
mkdir -p "$PYTHON_DOWNLOADS"
for msifile in core dev exe lib pip; do
echo "Installing $msifile..."
download_if_not_exist "$PYTHON_DOWNLOADS/${msifile}.msi" "https://www.python.org/ftp/python/$PYTHON_VERSION/$PYARCH/${msifile}.msi"
download_if_not_exist "$PYTHON_DOWNLOADS/${msifile}.msi.asc" "https://www.python.org/ftp/python/$PYTHON_VERSION/$PYARCH/${msifile}.msi.asc"
verify_signature "$PYTHON_DOWNLOADS/${msifile}.msi.asc" $KEYRING_PYTHON_DEV || fail "invalid sig for ${msifile}.msi"
wine msiexec /i "$PYTHON_DOWNLOADS/${msifile}.msi" /qb TARGETDIR=$WINE_PYHOME || fail "wine msiexec failed for ${msifile}.msi"
done
break_legacy_easy_install
info "Installing build dependencies."
$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
--cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-build-base.txt
$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
--cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-build-wine.txt
# copy already built DLLs
cp "$DLL_TARGET_DIR"/*.dll "$WINEPREFIX/drive_c/electrum/electrum/" || fail "Could not copy DLLs to destination"
info "Building PyInstaller."
# we build our own PyInstaller boot loader as the default one has high
# anti-virus false positives
(
if [ "$WIN_ARCH" = "win32" ] ; then
PYINST_ARCH="32bit"
elif [ "$WIN_ARCH" = "win64" ] ; then
PYINST_ARCH="64bit"
else
fail "unexpected WIN_ARCH: $WIN_ARCH"
fi
if [ -f "$CACHEDIR/pyinstaller/PyInstaller/bootloader/Windows-$PYINST_ARCH-intel/runw.exe" ]; then
info "pyinstaller already built, skipping"
exit 0
fi
cd "$WINEPREFIX/drive_c/electrum"
ELECTRUM_COMMIT_HASH=$(git rev-parse HEAD)
cd "$CACHEDIR"
rm -rf pyinstaller
mkdir pyinstaller
cd pyinstaller
# Shallow clone
git init
git remote add origin $PYINSTALLER_REPO
git fetch --depth 1 origin $PYINSTALLER_COMMIT
git checkout -b pinned "${PYINSTALLER_COMMIT}^{commit}"
rm -fv PyInstaller/bootloader/Windows-*/run*.exe || true
# add reproducible randomness. this ensures we build a different bootloader for each commit.
# if we built the same one for all releases, that might also get anti-virus false positives
echo "const char *electrum_tag = \"tagged by Electrum@$ELECTRUM_COMMIT_HASH\";" >> ./bootloader/src/pyi_main.c
pushd bootloader
# cross-compile to Windows using host python
python3 ./waf all CC="${GCC_TRIPLET_HOST}-gcc" \
CFLAGS="-static"
popd
# sanity check bootloader is there:
[[ -e "PyInstaller/bootloader/Windows-$PYINST_ARCH-intel/runw.exe" ]] || fail "Could not find runw.exe in target dir!"
)
info "Installing PyInstaller."
$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location ./pyinstaller
info "Wine is configured."
================================================
FILE: contrib/build-wine/pyinstaller.spec
================================================
# -*- mode: python -*-
import sys
import os
from typing import TYPE_CHECKING
from PyInstaller.utils.hooks import collect_data_files, collect_submodules, collect_dynamic_libs, copy_metadata
if TYPE_CHECKING:
from PyInstaller.building.build_main import Analysis, PYZ, EXE, COLLECT
PYPKG="electrum"
MAIN_SCRIPT="run_electrum"
PROJECT_ROOT = "C:/electrum"
ICONS_FILE=f"{PROJECT_ROOT}/{PYPKG}/gui/icons/electrum.ico"
cmdline_name = os.environ.get("ELECTRUM_CMDLINE_NAME")
if not cmdline_name:
raise Exception('no name')
# see https://github.com/pyinstaller/pyinstaller/issues/2005
hiddenimports = []
hiddenimports += collect_submodules('pkg_resources') # workaround for https://github.com/pypa/setuptools/issues/1963
hiddenimports += collect_submodules(f"{PYPKG}.plugins")
binaries = []
# Workaround for "Retro Look":
binaries += [b for b in collect_dynamic_libs('PyQt6') if 'qwindowsvista' in b[0]]
# add libsecp256k1, libusb, etc:
binaries += [(f"{PROJECT_ROOT}/{PYPKG}/*.dll", '.')]
datas = [
(f"{PROJECT_ROOT}/{PYPKG}/*.json", PYPKG),
(f"{PROJECT_ROOT}/{PYPKG}/lnwire/*.csv", f"{PYPKG}/lnwire"),
(f"{PROJECT_ROOT}/{PYPKG}/wordlist/english.txt", f"{PYPKG}/wordlist"),
(f"{PROJECT_ROOT}/{PYPKG}/wordlist/slip39.txt", f"{PYPKG}/wordlist"),
(f"{PROJECT_ROOT}/{PYPKG}/chains", f"{PYPKG}/chains"),
(f"{PROJECT_ROOT}/{PYPKG}/locale", f"{PYPKG}/locale"),
(f"{PROJECT_ROOT}/{PYPKG}/plugins", f"{PYPKG}/plugins"),
(f"{PROJECT_ROOT}/{PYPKG}/gui/icons", f"{PYPKG}/gui/icons"),
(f"{PROJECT_ROOT}/{PYPKG}/gui/fonts", f"{PYPKG}/gui/fonts"),
]
datas += collect_data_files(f"{PYPKG}.plugins")
datas += collect_data_files('trezorlib') # TODO is this needed? and same question for other hww libs
datas += collect_data_files('safetlib')
datas += collect_data_files('ckcc')
datas += collect_data_files('bitbox02')
# some deps rely on importlib metadata
datas += copy_metadata('slip10') # from trezor->slip10
# Exclude parts of Qt that we never use. Reduces binary size by tens of MBs. see #4815
excludes = [
"PyQt6.QtBluetooth",
"PyQt6.QtDesigner",
"PyQt6.QtNfc",
"PyQt6.QtPositioning",
"PyQt6.QtQml",
"PyQt6.QtQuick",
"PyQt6.QtQuick3D",
"PyQt6.QtQuickWidgets",
"PyQt6.QtRemoteObjects",
"PyQt6.QtSensors",
"PyQt6.QtSerialPort",
"PyQt6.QtSpatialAudio",
"PyQt6.QtSql",
"PyQt6.QtTest",
"PyQt6.QtTextToSpeech",
"PyQt6.QtWebChannel",
"PyQt6.QtWebSockets",
"PyQt6.QtXml",
# "PyQt6.QtNetwork", # needed by QtMultimedia. kinda weird but ok.
]
# 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([f"{PROJECT_ROOT}/{MAIN_SCRIPT}",
f"{PROJECT_ROOT}/{PYPKG}/gui/qt/main_window.py",
f"{PROJECT_ROOT}/{PYPKG}/gui/qt/qrreader/qtmultimedia/camera_dialog.py",
f"{PROJECT_ROOT}/{PYPKG}/gui/text.py",
f"{PROJECT_ROOT}/{PYPKG}/util.py",
f"{PROJECT_ROOT}/{PYPKG}/wallet.py",
f"{PROJECT_ROOT}/{PYPKG}/simple_config.py",
f"{PROJECT_ROOT}/{PYPKG}/bitcoin.py",
f"{PROJECT_ROOT}/{PYPKG}/dnssec.py",
f"{PROJECT_ROOT}/{PYPKG}/commands.py",
],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
excludes=excludes,
)
# 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
# hotfix for #3171 (pre-Win10 binaries)
a.binaries = [x for x in a.binaries if not x[1].lower().startswith(r'c:\windows')]
pyz = PYZ(a.pure)
#####
# "standalone" exe with all dependencies packed into it
exe_standalone = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
name=os.path.join("build", "pyi.win32", PYPKG, f"{cmdline_name}.exe"),
debug=False,
strip=None,
upx=False,
icon=ICONS_FILE,
console=False)
# console=True makes an annoying black box pop up, but it does make Electrum output command line commands, with this turned off no output will be given but commands can still be used
exe_portable = EXE(
pyz,
a.scripts,
a.binaries,
a.datas + [('is_portable', 'README.md', 'DATA')],
name=os.path.join("build", "pyi.win32", PYPKG, f"{cmdline_name}-portable.exe"),
debug=False,
strip=None,
upx=False,
icon=ICONS_FILE,
console=False)
#####
# exe and separate files that NSIS uses to build installer "setup" exe
exe_inside_setup_noconsole = EXE(
pyz,
a.scripts,
exclude_binaries=True,
name=os.path.join("build", "pyi.win32", PYPKG, f"{cmdline_name}.exe"),
debug=False,
strip=None,
upx=False,
icon=ICONS_FILE,
console=False)
exe_inside_setup_console = EXE(
pyz,
a.scripts,
exclude_binaries=True,
name=os.path.join("build", "pyi.win32", PYPKG, f"{cmdline_name}-debug.exe"),
debug=False,
strip=None,
upx=False,
icon=ICONS_FILE,
console=True)
coll = COLLECT(
exe_inside_setup_noconsole,
exe_inside_setup_console,
a.binaries,
a.zipfiles,
a.datas,
strip=None,
upx=True,
debug=False,
icon=ICONS_FILE,
console=False,
name=os.path.join('dist', PYPKG))
================================================
FILE: contrib/build-wine/sign.sh
================================================
#!/bin/bash
set -e
here="$(dirname "$0")"
if [ -z "$WIN_SIGNING_PASSWORD" ]; then
echo "password missing"
exit 1
fi
test -n "$here" -a -d "$here" || exit
cd $here
CERT_FILE=${CERT_FILE:-~/codesigning/cert.pem}
KEY_FILE=${KEY_FILE:-~/codesigning/key.pem}
if [[ ! -f "$CERT_FILE" ]]; then
ls "$CERT_FILE"
echo "Make sure that $CERT_FILE and $KEY_FILE exist"
fi
if ! which osslsigncode > /dev/null 2>&1; then
echo "Please install osslsigncode"
fi
rm -rf signed
mkdir -p signed >/dev/null 2>&1
cd dist
echo "Found $(ls *.exe | wc -w) files to sign."
for f in $(ls *.exe); do
echo "Signing $f..."
osslsigncode sign \
-pass "$WIN_SIGNING_PASSWORD" \
-h sha256 \
-certs "$CERT_FILE" \
-key "$KEY_FILE" \
-n "Electrum" \
-i "https://electrum.org/" \
-t "http://timestamp.digicert.com/" \
-in "$f" \
-out "../signed/$f"
ls "../signed/$f" -lah
done
================================================
FILE: contrib/build-wine/unsign.sh
================================================
#!/bin/bash
# exit if command fails
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
CONTRIB="$PROJECT_ROOT/contrib"
here="$(dirname "$0")"
test -n "$here" -a -d "$here" || exit
cd "$here"
if ! which osslsigncode > /dev/null 2>&1; then
echo "Please install osslsigncode"
exit 1
fi
rm -rf signed/stripped
mkdir -p signed >/dev/null 2>&1
mkdir -p signed/stripped >/dev/null 2>&1
version=$("$CONTRIB"/print_electrum_version.py)
echo "Found $(ls dist/*.exe | wc -w) files to verify."
for mine in dist/*.exe; do
echo "---------------"
f="$(basename "$mine")"
if test -f "signed/$f"; then
echo "Found file at signed/$f"
else
echo "Downloading https://download.electrum.org/$version/$f"
wget -q "https://download.electrum.org/$version/$f" -O "signed/$f"
fi
out="signed/stripped/$f"
# Remove PE signature from signed binary
osslsigncode remove-signature -in "signed/$f" -out "$out" > /dev/null 2>&1
chmod +x "$out"
if cmp -s "$out" "$mine"; then
echo "Success: $f"
#gpg --sign --armor --detach signed/$f
else
echo "Failure: $f"
exit 1
fi
done
exit 0
================================================
FILE: contrib/build_tools_util.sh
================================================
#!/usr/bin/env bash
set -e
# Set a fixed umask as this leaks into docker containers
umask 0022
RED='\033[0;31m'
BLUE='\033[0;34m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
function info {
printf "\r💬 ${BLUE}INFO:${NC} ${1}\n"
}
function fail {
printf "\r🗯 ${RED}ERROR:${NC} ${1}\n"
exit 1
}
function warn {
printf "\r⚠️ ${YELLOW}WARNING:${NC} ${1}\n"
}
# based on https://superuser.com/questions/497940/script-to-verify-a-signature-with-gpg
function verify_signature() {
local file=$1 keyring=$2 out=
if out=$(gpg --no-default-keyring --keyring "$keyring" --status-fd 1 --verify "$file" 2>/dev/null) &&
echo "$out" | grep -qs "^\[GNUPG:\] VALIDSIG "; then
return 0
else
echo "$out" >&2
exit 1
fi
}
function verify_hash() {
local file=$1 expected_hash=$2
actual_hash=$(sha256sum "$file" | awk '{print $1}')
if [ "$actual_hash" == "$expected_hash" ]; then
return 0
else
echo "$file $actual_hash (unexpected hash)" >&2
rm "$file"
exit 1
fi
}
function download_if_not_exist() {
local file_name=$1 url=$2
if [ ! -e "$file_name" ] ; then
wget -O "$file_name" "$url"
fi
}
# Function to clone or update a git repository to a specific commit
clone_or_update_repo() {
local repo_url=$1
local commit_hash=$2
local repo_dir=$3
if [ -z "$repo_url" ] || [ -z "$commit_hash" ] || [ -z "$repo_dir" ]; then
fail "clone_or_update_repo: invalid arguments: repo_url='$repo_url', commit_hash='$commit_hash', repo_dir='$repo_dir'"
fi
if [ -d "$repo_dir" ]; then
info "Repository $repo_url exists in $repo_dir, updating..."
git -C "$repo_dir" clean -ffxd >/dev/null 2>&1 || fail "Failed to clean repository $repo_dir"
git -C "$repo_dir" fetch --all >/dev/null 2>&1 || fail "Failed to fetch from repository"
git -C "$repo_dir" reset --hard "$commit_hash^{commit}" >/dev/null 2>&1 || fail "Failed to reset to commit $commit_hash"
else
info "Cloning repository: $repo_url to $repo_dir"
git clone "$repo_url" "$repo_dir" >/dev/null 2>&1 || fail "Failed to clone repository $repo_url"
git -C "$repo_dir" checkout "$commit_hash^{commit}" >/dev/null 2>&1 || fail "Failed to checkout commit $commit_hash"
fi
}
apply_patch() {
local patch=$1
local path=$2
if [ -z "$patch" ] || [ -z "$path" ]; then
fail "apply_patch: invalid arguments: patch='$patch', path='$path'"
fi
if [ -d "$path" ]; then
info "Patching: $patch"
cd "$path"
patch -p1 <"$patch"
cd -
else
fail "apply_patch: path='$path' not found"
fi
}
# https://github.com/travis-ci/travis-build/blob/master/lib/travis/build/templates/header.sh
function retry() {
local result=0
local count=1
while [ $count -le 3 ]; do
[ $result -ne 0 ] && {
echo -e "\nThe command \"$@\" failed. Retrying, $count of 3.\n" >&2
}
! { "$@"; result=$?; }
[ $result -eq 0 ] && break
count=$(($count + 1))
sleep 1
done
[ $count -gt 3 ] && {
echo -e "\nThe command \"$@\" failed 3 times.\n" >&2
}
return $result
}
function gcc_with_triplet()
{
TRIPLET="$1"
CMD="$2"
shift 2
if [ -n "$TRIPLET" ] ; then
"$TRIPLET-$CMD" "$@"
else
"$CMD" "$@"
fi
}
function gcc_host()
{
gcc_with_triplet "$GCC_TRIPLET_HOST" "$@"
}
function gcc_build()
{
gcc_with_triplet "$GCC_TRIPLET_BUILD" "$@"
}
function host_strip()
{
if [ "$GCC_STRIP_BINARIES" -ne "0" ] ; then
case "$BUILD_TYPE" in
linux|wine)
gcc_host strip "$@"
;;
darwin)
# TODO: Strip on macOS?
;;
esac
fi
}
# on MacOS, there is no realpath by default
if ! [ -x "$(command -v realpath)" ]; then
function realpath() {
[[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"
}
fi
export SOURCE_DATE_EPOCH=1530212462
export ZERO_AR_DATE=1 # for macOS
export PYTHONHASHSEED=22
# Set the build type, overridden by wine build
export BUILD_TYPE="${BUILD_TYPE:-$(uname | tr '[:upper:]' '[:lower:]')}"
# Add host / build flags if the triplets are set
if [ -n "$GCC_TRIPLET_HOST" ] ; then
export AUTOCONF_FLAGS="$AUTOCONF_FLAGS --host=$GCC_TRIPLET_HOST"
fi
if [ -n "$GCC_TRIPLET_BUILD" ] ; then
export AUTOCONF_FLAGS="$AUTOCONF_FLAGS --build=$GCC_TRIPLET_BUILD"
fi
export GCC_STRIP_BINARIES="${GCC_STRIP_BINARIES:-0}"
if [ -n "$CIRRUS_CPU" ] ; then
# special-case for CI. see https://github.com/cirruslabs/cirrus-ci-docs/issues/1115
export CPU_COUNT="$CIRRUS_CPU"
else
export CPU_COUNT="$(nproc 2> /dev/null || sysctl -n hw.ncpu)"
fi
info "Found $CPU_COUNT CPUs, which we might use for building."
function break_legacy_easy_install() {
# We don't want setuptools sneakily installing dependencies, invisible to pip.
# This ensures that if setuptools calls distutils which then calls easy_install,
# easy_install will not download packages over the network.
# see https://pip.pypa.io/en/stable/reference/pip_install/#controlling-setup-requires
# see https://github.com/pypa/setuptools/issues/1916#issuecomment-743350566
info "Intentionally breaking legacy easy_install."
DISTUTILS_CFG="${HOME}/.pydistutils.cfg"
DISTUTILS_CFG_BAK="${HOME}/.pydistutils.cfg.orig"
# If we are not inside docker, we might be overwriting a config file on the user's system...
if [ -e "$DISTUTILS_CFG" ] && [ ! -e "$DISTUTILS_CFG_BAK" ]; then
warn "Overwriting python distutils config file at '$DISTUTILS_CFG'. A copy will be saved at '$DISTUTILS_CFG_BAK'."
mv "$DISTUTILS_CFG" "$DISTUTILS_CFG_BAK"
fi
cat < "$DISTUTILS_CFG"
[easy_install]
index_url = ''
find_links = ''
EOF
}
================================================
FILE: contrib/deterministic-build/README.md
================================================
# Notes
The frozen dependency lists in this folder are *generated* files.
- Starting from `contrib/requirements/requirements*.txt`,
- we use the `contrib/freeze_packages.sh` script,
- to generate `contrib/deterministic-build/requirements*.txt`.
The source files list direct dependencies with loose version requirements,
while the output files list all transitive dependencies with exact version+hash pins.
The build scripts only use these hash pinned requirement files.
================================================
FILE: contrib/deterministic-build/check_submodules.sh
================================================
#!/usr/bin/env bash
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
LOCALE="$PROJECT_ROOT/electrum/locale/"
cd "$PROJECT_ROOT"
git submodule init
git submodule update
function get_git_mtime {
if [ $# -eq 1 ]; then
git log --pretty=%at -n1 -- $1
else
git log --pretty=%ar -n1 -- $2
fi
}
fail=0
if [ $(date +%s -d "2 weeks ago") -gt $(get_git_mtime "$LOCALE") ]; then
echo "Last update from electrum-locale is older than 2 weeks."\
"Please update it to incorporate the latest translations from crowdin."
fail=1
fi
exit ${fail}
================================================
FILE: contrib/deterministic-build/find_restricted_dependencies.py
================================================
#!/usr/bin/env python3
import sys
try:
import requests
except ImportError as e:
sys.exit(f"Error: {str(e)}. Try 'python3 -m pip install '")
def is_dependency_edge_blacklisted(*, parent_pkg: str, dep: str) -> bool:
"""Sometimes a package declares a hard dependency
for some niche functionality that we really do not care about.
"""
dep = dep.lower()
parent_pkg = parent_pkg.lower()
return (parent_pkg, dep) in {
("qrcode", "colorama"), # only needed for using qrcode-CLI on Windows.
("click", "colorama"), # 'click' is a CLI tool, and it only needs colorama on Windows.
# In fact, we should blacklist 'click' itself, but that should be done elsewhere.
}
def check_restriction(*, dep: str, restricted: str, parent_pkg: str):
# See: https://www.python.org/dev/peps/pep-0496/
# Hopefully we don't need to parse the whole microlanguage
if is_dependency_edge_blacklisted(dep=dep, parent_pkg=parent_pkg):
return False
if "extra" in restricted and "[" not in dep:
return False
for marker in ["os_name", "platform_release", "sys_platform", "platform_system"]:
if marker in restricted:
return True
return False
def main():
for p in sys.stdin.read().split():
p = p.strip()
if not p:
continue
assert "==" in p, "This script expects a list of packages with pinned version, e.g. package==1.2.3, not {}".format(p)
p, v = p.rsplit("==", 1)
try:
data = requests.get("https://pypi.org/pypi/{}/{}/json".format(p, v)).json()["info"]
except ValueError:
raise Exception("Package could not be found: {}=={}".format(p, v))
try:
for r in data["requires_dist"]: # type: str
if ";" not in r:
continue
# example value for "r" at this point: "pefile (>=2017.8.1) ; sys_platform == \"win32\""
dep, restricted = r.split(";", 1)
dep = dep.strip()
restricted = restricted.strip()
dep_basename = dep.split(" ")[0]
if check_restriction(dep=dep, restricted=restricted, parent_pkg=p):
print(dep_basename, sep=" ")
print("Installing {} from {} although it is only needed for {}".format(dep, p, restricted), file=sys.stderr)
except TypeError:
# Has no dependencies at all
continue
if __name__ == "__main__":
main()
================================================
FILE: contrib/deterministic-build/requirements-binaries-mac.txt
================================================
cffi==1.17.1 \
--hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
--hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
--hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
--hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
--hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
--hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
--hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
--hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
--hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
--hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
--hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
--hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
--hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
--hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
--hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
--hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
--hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
--hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
--hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
--hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
--hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
--hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
--hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
--hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
--hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
--hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
--hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
--hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
--hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
--hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
--hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
--hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
--hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
--hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
--hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
--hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
--hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
--hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
--hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
--hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
--hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
--hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
--hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
--hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
--hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
--hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
--hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
--hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
--hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
--hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
--hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
--hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
--hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
--hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
--hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
--hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
--hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
--hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
--hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
--hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
--hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
--hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
--hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
--hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
--hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
--hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
--hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
cryptography==45.0.3 \
--hash=sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc \
--hash=sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972 \
--hash=sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b \
--hash=sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4 \
--hash=sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56 \
--hash=sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716 \
--hash=sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710 \
--hash=sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8 \
--hash=sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8 \
--hash=sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782 \
--hash=sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578 \
--hash=sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0 \
--hash=sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71 \
--hash=sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1 \
--hash=sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490 \
--hash=sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497 \
--hash=sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca \
--hash=sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc \
--hash=sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19 \
--hash=sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b \
--hash=sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9 \
--hash=sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57 \
--hash=sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1 \
--hash=sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06 \
--hash=sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942 \
--hash=sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab \
--hash=sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342 \
--hash=sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b \
--hash=sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2 \
--hash=sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c \
--hash=sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899 \
--hash=sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e \
--hash=sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49 \
--hash=sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7 \
--hash=sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65 \
--hash=sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f \
--hash=sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9
pip==25.1.1 \
--hash=sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af \
--hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
pycparser==2.22 \
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
PyQt6==6.6.1 \
--hash=sha256:03a656d5dc5ac31b6a9ad200f7f4f7ef49fa00ad7ce7a991b9bb691617141d12 \
--hash=sha256:5aa0e833cb5a79b93813f8181d9f145517dd5a46f4374544bcd1e93a8beec537 \
--hash=sha256:6b43878d0bbbcf8b7de165d305ec0cb87113c8930c92de748a11c473a6db5085 \
--hash=sha256:9f158aa29d205142c56f0f35d07784b8df0be28378d20a97bcda8bd64ffd0379
PyQt6-Qt6==6.6.2 \
--hash=sha256:5a41fe9d53b9e29e9ec5c23f3c5949dba160f90ca313ee8b96b8ffe6a5059387 \
--hash=sha256:7ef446d3ffc678a8586ff6dc9f0d27caf4dff05dea02c353540d2f614386faf9 \
--hash=sha256:8d7f674a4ec43ca00191e14945ca4129acbe37a2172ed9d08214ad58b170bc11 \
--hash=sha256:b8363d88623342a72ac17da9127dc12f259bb3148796ea029762aa2d499778d9
PyQt6-sip==13.10.2 \
--hash=sha256:061d4a2eb60a603d8be7db6c7f27eb29d9cea97a09aa4533edc1662091ce4f03 \
--hash=sha256:07f77e89d93747dda71b60c3490b00d754451729fbcbcec840e42084bf061655 \
--hash=sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277 \
--hash=sha256:1a6c2f168773af9e6c7ef5e52907f16297d4efd346e4c958eda54ea9135be18e \
--hash=sha256:37af463dcce39285e686d49523d376994d8a2508b9acccb7616c4b117c9c4ed7 \
--hash=sha256:38b5823dca93377f8a4efac3cbfaa1d20229aa5b640c31cf6ebbe5c586333808 \
--hash=sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b \
--hash=sha256:45ac06f0380b7aa4fcffd89f9e8c00d1b575dc700c603446a9774fda2dcfc0de \
--hash=sha256:464ad156bf526500ce6bd05cac7a82280af6309974d816739b4a9a627156fafe \
--hash=sha256:4ccf197f8fa410e076936bee28ad9abadb450931d5be5625446fd20e0d8b27a6 \
--hash=sha256:4ffa71ddff6ef031d52cd4f88b8bba08b3516313c023c7e5825cf4a0ba598712 \
--hash=sha256:5506b9a795098df3b023cc7d0a37f93d3224a9c040c43804d4bc06e0b2b742b0 \
--hash=sha256:8132ec1cbbecc69d23dcff23916ec07218f1a9bbbc243bf6f1df967117ce303e \
--hash=sha256:83e6a56d3e715f748557460600ec342cbd77af89ec89c4f2a68b185fa14ea46c \
--hash=sha256:8b5d06a0eac36038fa8734657d99b5fe92263ae7a0cd0a67be6acfe220a063e1 \
--hash=sha256:9c67ed66e21b11e04ffabe0d93bc21df22e0a5d7e2e10ebc8c1d77d2f5042991 \
--hash=sha256:ad376a6078da37b049fdf9d6637d71b52727e65c4496a80b753ddc8d27526aca \
--hash=sha256:b1d3cc9015a1bd8c8d3e86a009591e897d4d46b0c514aede7d2970a2208749cd \
--hash=sha256:c7b34a495b92790c70eae690d9e816b53d3b625b45eeed6ae2c0fe24075a237e \
--hash=sha256:c80cc059d772c632f5319632f183e7578cd0976b9498682833035b18a3483e92 \
--hash=sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244 \
--hash=sha256:ddd578a8d975bfb5fef83751829bf09a97a1355fa1de098e4fb4d1b74ee872fc \
--hash=sha256:e455a181d45a28ee8d18d42243d4f470d269e6ccdee60f2546e6e71218e05bb4 \
--hash=sha256:e907394795e61f1174134465c889177f584336a98d7a10beade2437bf5942244
setuptools==80.9.0 \
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
--hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
================================================
FILE: contrib/deterministic-build/requirements-binaries.txt
================================================
cffi==1.17.1 \
--hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
--hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
--hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
--hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
--hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
--hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
--hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
--hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
--hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
--hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
--hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
--hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
--hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
--hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
--hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
--hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
--hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
--hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
--hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
--hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
--hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
--hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
--hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
--hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
--hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
--hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
--hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
--hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
--hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
--hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
--hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
--hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
--hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
--hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
--hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
--hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
--hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
--hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
--hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
--hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
--hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
--hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
--hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
--hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
--hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
--hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
--hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
--hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
--hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
--hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
--hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
--hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
--hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
--hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
--hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
--hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
--hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
--hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
--hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
--hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
--hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
--hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
--hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
--hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
--hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
--hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
--hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
cryptography==45.0.3 \
--hash=sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc \
--hash=sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972 \
--hash=sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b \
--hash=sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4 \
--hash=sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56 \
--hash=sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716 \
--hash=sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710 \
--hash=sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8 \
--hash=sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8 \
--hash=sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782 \
--hash=sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578 \
--hash=sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0 \
--hash=sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71 \
--hash=sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1 \
--hash=sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490 \
--hash=sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497 \
--hash=sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca \
--hash=sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc \
--hash=sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19 \
--hash=sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b \
--hash=sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9 \
--hash=sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57 \
--hash=sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1 \
--hash=sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06 \
--hash=sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942 \
--hash=sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab \
--hash=sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342 \
--hash=sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b \
--hash=sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2 \
--hash=sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c \
--hash=sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899 \
--hash=sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e \
--hash=sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49 \
--hash=sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7 \
--hash=sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65 \
--hash=sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f \
--hash=sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9
pip==25.1.1 \
--hash=sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af \
--hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
pycparser==2.22 \
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
PyQt6==6.9.0 \
--hash=sha256:0c8b7251608e05b479cfe731f95857e853067459f7cbbcfe90f89de1bcf04280 \
--hash=sha256:1cbc5a282454cf19691be09eadbde019783f1ae0523e269b211b0173b67373f6 \
--hash=sha256:5344240747e81bde1a4e0e98d4e6e2d96ad56a985d8f36b69cd529c1ca9ff760 \
--hash=sha256:6a8ff8e3cd18311bb7d937f7d741e787040ae7ff47ce751c28a94c5cddc1b4e6 \
--hash=sha256:d36482000f0cd7ce84a35863766f88a5e671233d5f1024656b600cd8915b3752 \
--hash=sha256:e344868228c71fc89a0edeb325497df4ff731a89cfa5fe57a9a4e9baecc9512b
PyQt6-Qt6==6.9.0 \
--hash=sha256:1188f118d1c570d27fba39707e3d8a48525f979816e73de0da55b9e6fa9ad0a1 \
--hash=sha256:6d3875119dec6bf5f799facea362aa0ad39bb23aa9654112faa92477abccb5ff \
--hash=sha256:9c0e603c934e4f130c110190fbf2c482ff1221a58317266570678bc02db6b152 \
--hash=sha256:b1c4e4a78f0f22fbf88556e3d07c99e5ce93032feae5c1e575958d914612e0f9 \
--hash=sha256:c825a6f5a9875ef04ef6681eda16aa3a9e9ad71847aa78dfafcf388c8007aa0a \
--hash=sha256:cf840e8ae20a0704e0343810cf0e485552db28bf09ea976e58ec0e9b7bb27fcd
PyQt6-sip==13.10.2 \
--hash=sha256:061d4a2eb60a603d8be7db6c7f27eb29d9cea97a09aa4533edc1662091ce4f03 \
--hash=sha256:07f77e89d93747dda71b60c3490b00d754451729fbcbcec840e42084bf061655 \
--hash=sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277 \
--hash=sha256:1a6c2f168773af9e6c7ef5e52907f16297d4efd346e4c958eda54ea9135be18e \
--hash=sha256:37af463dcce39285e686d49523d376994d8a2508b9acccb7616c4b117c9c4ed7 \
--hash=sha256:38b5823dca93377f8a4efac3cbfaa1d20229aa5b640c31cf6ebbe5c586333808 \
--hash=sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b \
--hash=sha256:45ac06f0380b7aa4fcffd89f9e8c00d1b575dc700c603446a9774fda2dcfc0de \
--hash=sha256:464ad156bf526500ce6bd05cac7a82280af6309974d816739b4a9a627156fafe \
--hash=sha256:4ccf197f8fa410e076936bee28ad9abadb450931d5be5625446fd20e0d8b27a6 \
--hash=sha256:4ffa71ddff6ef031d52cd4f88b8bba08b3516313c023c7e5825cf4a0ba598712 \
--hash=sha256:5506b9a795098df3b023cc7d0a37f93d3224a9c040c43804d4bc06e0b2b742b0 \
--hash=sha256:8132ec1cbbecc69d23dcff23916ec07218f1a9bbbc243bf6f1df967117ce303e \
--hash=sha256:83e6a56d3e715f748557460600ec342cbd77af89ec89c4f2a68b185fa14ea46c \
--hash=sha256:8b5d06a0eac36038fa8734657d99b5fe92263ae7a0cd0a67be6acfe220a063e1 \
--hash=sha256:9c67ed66e21b11e04ffabe0d93bc21df22e0a5d7e2e10ebc8c1d77d2f5042991 \
--hash=sha256:ad376a6078da37b049fdf9d6637d71b52727e65c4496a80b753ddc8d27526aca \
--hash=sha256:b1d3cc9015a1bd8c8d3e86a009591e897d4d46b0c514aede7d2970a2208749cd \
--hash=sha256:c7b34a495b92790c70eae690d9e816b53d3b625b45eeed6ae2c0fe24075a237e \
--hash=sha256:c80cc059d772c632f5319632f183e7578cd0976b9498682833035b18a3483e92 \
--hash=sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244 \
--hash=sha256:ddd578a8d975bfb5fef83751829bf09a97a1355fa1de098e4fb4d1b74ee872fc \
--hash=sha256:e455a181d45a28ee8d18d42243d4f470d269e6ccdee60f2546e6e71218e05bb4 \
--hash=sha256:e907394795e61f1174134465c889177f584336a98d7a10beade2437bf5942244
setuptools==80.9.0 \
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
--hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
================================================
FILE: contrib/deterministic-build/requirements-build-android.txt
================================================
appdirs==1.4.4 \
--hash=sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41
colorama==0.4.5 \
--hash=sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4
Cython==0.29.37 \
--hash=sha256:f813d4a6dd94adee5d4ff266191d1d95bf6d4164a4facc535422c021b2504cfb
Jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d
MarkupSafe==3.0.2 \
--hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0
pep517==0.13.1 \
--hash=sha256:1b2fa2ffd3938bb4beffe5d6146cbcb2bda996a5a4da9f31abffd8b24e07b317
pexpect==4.9.0 \
--hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f
pip==25.1.1 \
--hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
ptyprocess==0.7.0 \
--hash=sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220
setuptools==80.9.0 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
sh==2.2.2 \
--hash=sha256:653227a7c41a284ec5302173fbc044ee817c7bad5e6e4d8d55741b9aeb9eb65b
toml==0.10.2 \
--hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
tomli==2.2.1 \
--hash=sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff
typing-extensions==4.13.2 \
--hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
================================================
FILE: contrib/deterministic-build/requirements-build-appimage.txt
================================================
Cython==3.1.1 \
--hash=sha256:505ccd413669d5132a53834d792c707974248088c4f60c497deb1b416e366397
pip==25.1.1 \
--hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
setuptools==80.9.0 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
================================================
FILE: contrib/deterministic-build/requirements-build-base.txt
================================================
expandvars==1.0.0 \
--hash=sha256:f04070b8260264185f81142cd85e5df9ceef7229e836c5844302c4ccfa00c30d \
--hash=sha256:ff1690eceb90bbdeefd1e4b15f4d217f22a3e66f776c2cb060635d2dde4a7689
flit-core==3.12.0 \
--hash=sha256:18f63100d6f94385c6ed57a72073443e1a71a4acb4339491615d0f16d6ff01b2 \
--hash=sha256:e7a0304069ea895172e3c7bb703292e992c5d1555dd1233ab7b5621b5b69e62c
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
pip==25.1.1 \
--hash=sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af \
--hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
poetry-core==2.1.3 \
--hash=sha256:0522a015477ed622c89aad56a477a57813cace0c8e7ff2a2906b7ef4a2e296a4 \
--hash=sha256:2c704f05016698a54ca1d327f46ce2426d72eaca6ff614132c8477c292266771
setuptools==80.9.0 \
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
setuptools-scm==8.3.1 \
--hash=sha256:332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3 \
--hash=sha256:3d555e92b75dacd037d32bafdf94f97af51ea29ae8c7b234cf94b7a5bd242a63
tomli==2.0.2 \
--hash=sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38 \
--hash=sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
--hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
================================================
FILE: contrib/deterministic-build/requirements-build-mac.txt
================================================
altgraph==0.17.4 \
--hash=sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406
Cython==3.1.1 \
--hash=sha256:505ccd413669d5132a53834d792c707974248088c4f60c497deb1b416e366397
macholib==1.16.3 \
--hash=sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30
packaging==25.0 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
pip==25.1.1 \
--hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
pyinstaller-hooks-contrib==2025.4 \
--hash=sha256:5ce1afd1997b03e70f546207031cfdf2782030aabacc102190677059e2856446
setuptools==80.9.0 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
================================================
FILE: contrib/deterministic-build/requirements-build-wine.txt
================================================
altgraph==0.17.4 \
--hash=sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406
packaging==25.0 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
pefile==2023.2.7 \
--hash=sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc
pip==25.1.1 \
--hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
pyinstaller-hooks-contrib==2025.4 \
--hash=sha256:5ce1afd1997b03e70f546207031cfdf2782030aabacc102190677059e2856446
pywin32-ctypes==0.2.3 \
--hash=sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755
setuptools==80.9.0 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
================================================
FILE: contrib/deterministic-build/requirements-hw.txt
================================================
base58==2.1.1 \
--hash=sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2 \
--hash=sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c
bitbox02==7.0.0 \
--hash=sha256:27d5105eb15a553719fa9d3e68921c864b00c861b3a644044d9ac68426f18447 \
--hash=sha256:4b5b8422b94390b09962a4a93f4a9861429c093eb0f0b6c2d7661bbc1dd0e242
cbor2==5.6.5 \
--hash=sha256:3038523b8fc7de312bb9cdcbbbd599987e64307c4db357cd2030c472a6c7d468 \
--hash=sha256:34cf5ab0dc310c3d0196caa6ae062dc09f6c242e2544bea01691fe60c0230596 \
--hash=sha256:37096663a5a1c46a776aea44906cbe5fa3952f29f50f349179c00525d321c862 \
--hash=sha256:38886c41bebcd7dca57739439455bce759f1e4c551b511f618b8e9c1295b431b \
--hash=sha256:3d1a18b3a58dcd9b40ab55c726160d4a6b74868f2a35b71f9e726268b46dc6a2 \
--hash=sha256:4586a4f65546243096e56a3f18f29d60752ee9204722377021b3119a03ed99ff \
--hash=sha256:47261f54a024839ec649b950013c4de5b5f521afe592a2688eebbe22430df1dc \
--hash=sha256:54c72a3207bb2d4480c2c39dad12d7971ce0853a99e3f9b8d559ce6eac84f66f \
--hash=sha256:559dcf0d897260a9e95e7b43556a62253e84550b77147a1ad4d2c389a2a30192 \
--hash=sha256:5b856fda4c50c5bc73ed3664e64211fa4f015970ed7a15a4d6361bd48462feaf \
--hash=sha256:5ce13a27ef8fddf643fc17a753fe34aa72b251d03c23da6a560c005dc171085b \
--hash=sha256:5cff06464b8f4ca6eb9abcba67bda8f8334a058abc01005c8e616728c387ad32 \
--hash=sha256:61ceb77e6aa25c11c814d4fe8ec9e3bac0094a1f5bd8a2a8c95694596ea01e08 \
--hash=sha256:66dd25dd919cddb0b36f97f9ccfa51947882f064729e65e6bef17c28535dc459 \
--hash=sha256:6797b824b26a30794f2b169c0575301ca9b74ae99064e71d16e6ba0c9057de51 \
--hash=sha256:6e14a1bf6269d25e02ef1d4008e0ce8880aa271d7c6b4c329dba48645764f60e \
--hash=sha256:73b9647eed1493097db6aad61e03d8f1252080ee041a1755de18000dd2c05f37 \
--hash=sha256:7488aec919f8408f9987a3a32760bd385d8628b23a35477917aa3923ff6ad45f \
--hash=sha256:7f6d69f38f7d788b04c09ef2b06747536624b452b3c8b371ab78ad43b0296fab \
--hash=sha256:824f202b556fc204e2e9a67d6d6d624e150fbd791278ccfee24e68caec578afd \
--hash=sha256:863e0983989d56d5071270790e7ed8ddbda88c9e5288efdb759aba2efee670bc \
--hash=sha256:87026fc838370d69f23ed8572939bd71cea2b3f6c8f8bb8283f573374b4d7f33 \
--hash=sha256:8f747b7a9aaa58881a0c5b4cd4a9b8fb27eca984ed261a769b61de1f6b5bd1e6 \
--hash=sha256:90bfa36944caccec963e6ab7e01e64e31cc6664535dc06e6295ee3937c999cbb \
--hash=sha256:93676af02bd9a0b4a62c17c5b20f8e9c37b5019b1a24db70a2ee6cb770423568 \
--hash=sha256:94885903105eec66d7efb55f4ce9884fdc5a4d51f3bd75b6fedc68c5c251511b \
--hash=sha256:97a7e409b864fecf68b2ace8978eb5df1738799a333ec3ea2b9597bfcdd6d7d2 \
--hash=sha256:a34ee99e86b17444ecbe96d54d909dd1a20e2da9f814ae91b8b71cf1ee2a95e4 \
--hash=sha256:a3ac50485cf67dfaab170a3e7b527630e93cb0a6af8cdaa403054215dff93adf \
--hash=sha256:a83b76367d1c3e69facbcb8cdf65ed6948678e72f433137b41d27458aa2a40cb \
--hash=sha256:a88f029522aec5425fc2f941b3df90da7688b6756bd3f0472ab886d21208acbd \
--hash=sha256:a8947c102cac79d049eadbd5e2ffb8189952890df7cbc3ee262bbc2f95b011a9 \
--hash=sha256:ae2b49226224e92851c333b91d83292ec62eba53a19c68a79890ce35f1230d70 \
--hash=sha256:b682820677ee1dbba45f7da11898d2720f92e06be36acec290867d5ebf3d7e09 \
--hash=sha256:b9d15b638539b68aa5d5eacc56099b4543a38b2d2c896055dccf7e83d24b7955 \
--hash=sha256:e16c4a87fc999b4926f5c8f6c696b0d251b4745bc40f6c5aee51d69b30b15ca2 \
--hash=sha256:e25c2aebc9db99af7190e2261168cdde8ed3d639ca06868e4f477cf3a228a8e9 \
--hash=sha256:f0d0a9c5aabd48ecb17acf56004a7542a0b8d8212be52f3102b8218284bd881e \
--hash=sha256:f2764804ffb6553283fc4afb10a280715905a4cea4d6dc7c90d3e89c4a93bc8d \
--hash=sha256:f4c7dbcdc59ea7f5a745d3e30ee5e6b6ff5ce7ac244aa3de6786391b10027bb3 \
--hash=sha256:f91e6d74fa6917df31f8757fdd0e154203b0dd0609ec53eb957016a2b474896a \
--hash=sha256:fa61a02995f3a996c03884cf1a0b5733f88cbfd7fa0e34944bf678d4227ee712 \
--hash=sha256:fde21ac1cf29336a31615a2c469a9cb03cf0add3ae480672d4d38cda467d07fc \
--hash=sha256:fe11c2eb518c882cfbeed456e7a552e544893c17db66fe5d3230dbeaca6b615c
certifi==2025.4.26 \
--hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \
--hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3
cffi==1.17.1 \
--hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
--hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
--hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
--hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
--hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
--hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
--hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
--hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
--hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
--hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
--hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
--hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
--hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
--hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
--hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
--hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
--hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
--hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
--hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
--hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
--hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
--hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
--hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
--hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
--hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
--hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
--hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
--hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
--hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
--hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
--hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
--hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
--hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
--hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
--hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
--hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
--hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
--hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
--hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
--hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
--hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
--hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
--hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
--hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
--hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
--hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
--hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
--hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
--hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
--hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
--hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
--hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
--hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
--hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
--hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
--hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
--hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
--hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
--hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
--hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
--hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
--hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
--hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
--hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
--hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
--hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
--hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
charset-normalizer==3.4.2 \
--hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \
--hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \
--hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \
--hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \
--hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \
--hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \
--hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \
--hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \
--hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \
--hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \
--hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \
--hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \
--hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \
--hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \
--hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \
--hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \
--hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \
--hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \
--hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \
--hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \
--hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \
--hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \
--hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \
--hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \
--hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \
--hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \
--hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \
--hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \
--hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \
--hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \
--hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \
--hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \
--hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \
--hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \
--hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \
--hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \
--hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \
--hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \
--hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \
--hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \
--hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \
--hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \
--hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \
--hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \
--hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \
--hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \
--hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \
--hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \
--hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \
--hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \
--hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \
--hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \
--hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \
--hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \
--hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \
--hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \
--hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \
--hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \
--hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \
--hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \
--hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \
--hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \
--hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \
--hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \
--hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \
--hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \
--hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \
--hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \
--hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \
--hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \
--hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \
--hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \
--hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \
--hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \
--hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \
--hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \
--hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \
--hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \
--hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \
--hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \
--hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \
--hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \
--hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \
--hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \
--hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \
--hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \
--hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \
--hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \
--hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \
--hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \
--hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \
--hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f
ckcc-protocol==1.4.0 \
--hash=sha256:c5fcc4705b4b78ec515b39549642570a660142407fa684c278cb0aea8122defa \
--hash=sha256:cd93d4d3e3308ea4580aa6be5b4613a8266fd96b0cc1af51e7168def27bbece5
click==8.1.8 \
--hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \
--hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a
construct==2.10.70 \
--hash=sha256:4d2472f9684731e58cc9c56c463be63baa1447d674e0d66aeb5627b22f512c29 \
--hash=sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30
construct-classes==0.1.2 \
--hash=sha256:72ac1abbae5bddb4918688713f991f5a7fb6c9b593646a82f4bf3ac53de7eeb5 \
--hash=sha256:e82437261790758bda41e45fb3d5622b54cfbf044ceb14774af68346faf5e08e
cryptography==45.0.3 \
--hash=sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc \
--hash=sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972 \
--hash=sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b \
--hash=sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4 \
--hash=sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56 \
--hash=sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716 \
--hash=sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710 \
--hash=sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8 \
--hash=sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8 \
--hash=sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782 \
--hash=sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578 \
--hash=sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0 \
--hash=sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71 \
--hash=sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1 \
--hash=sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490 \
--hash=sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497 \
--hash=sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca \
--hash=sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc \
--hash=sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19 \
--hash=sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b \
--hash=sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9 \
--hash=sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57 \
--hash=sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1 \
--hash=sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06 \
--hash=sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942 \
--hash=sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab \
--hash=sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342 \
--hash=sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b \
--hash=sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2 \
--hash=sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c \
--hash=sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899 \
--hash=sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e \
--hash=sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49 \
--hash=sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7 \
--hash=sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65 \
--hash=sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f \
--hash=sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9
ecdsa==0.19.1 \
--hash=sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3 \
--hash=sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61
hidapi==0.14.0.post4 \
--hash=sha256:01747e681d138ec614321ef6f069e5be3743fa210112e529a34d3e99635e4ac0 \
--hash=sha256:04357092b39631d8034b17fd111c5583be2790ad7979ac1983173344d28824e7 \
--hash=sha256:0d51f8102a2441ce22e080576f8f370d25cb3962161818a89f236b0401840f18 \
--hash=sha256:10a01af155c51a8089fe44e627af2fbd323cfbef7bd55a86837d971aef6088b0 \
--hash=sha256:129d684c2760fafee9014ce63a58d8e2699cdf00cd1a11bb3d706d4715f5ff96 \
--hash=sha256:1304fdeb694f581c46e7b0d6aebc6adfa66219177f04cacddbec0bd906bd5b7c \
--hash=sha256:142374bb39c8973c6d04a2b8b767d64891741d05b09364b32531d9389c3a15bb \
--hash=sha256:1487312ad50cf2c08a5ea786167b3229afd6478c4b26974157c3845a84e91231 \
--hash=sha256:1591e98c0e6db4cc1e34e96295b4ea68eaf37d365d664570441388869e8e3618 \
--hash=sha256:1807ff8abe3c5dcfa9d8acd71b1ab9f0aeb69cdbb039ddcbb150ed9fbbfd1ba7 \
--hash=sha256:1bee0f731874d78367a3bf131cb0325578bc9fee0678ed00c4ca3ded45d11c20 \
--hash=sha256:20a466e4cf2230687d21f55ffffb1a2384a2262fc343e507dd01d1ab981f7573 \
--hash=sha256:21627bb8a0e2023da1dfb7cb7b970c30d6a86e6498721f1123d018b2f64b426f \
--hash=sha256:21ebd1420db116733536fae227f1cb30ad74bded5090269cdda4facfa73a8867 \
--hash=sha256:293b207e737df4577d27661c5135e7c16976f706d3739d7a53a169dde1aaebaa \
--hash=sha256:2acadb4f1ae569c4f73ddb461af8733e8f5efcb290c3d0ef1b0671ba793b0ae3 \
--hash=sha256:2d1c102f754b2085b270e7c29cb8a148ffb05e10325c373d05ac16e2cbce131c \
--hash=sha256:3253d198b193065d633cde3f9a59dabeeb1608ece26f0f319a151e8c7775d7ae \
--hash=sha256:348e68e3a2145a6ec6bebce13ffdf3e5883d8c720752c365027f16e16764def6 \
--hash=sha256:380a74e743afe7a0241e0efce73ce9697f41d4e2e0a030be5458a44f9119427a \
--hash=sha256:4169893fe5e368777fce7575a8bdedc1861f13d8fb9fda6b05e8155dde6eb7f1 \
--hash=sha256:41d532d5a358a63db4d7fc1e57ea107150445c90167b39ba6f8fb84597396a48 \
--hash=sha256:48fce253e526d17b663fbf9989c71c7ef7653ced5f4be65f1437c313fb3dbdf6 \
--hash=sha256:4939faf6382d1c89462e72aa08636bbfe97ecb5464a34b14997e0ca3e1f92906 \
--hash=sha256:4f04de00e40db2efc0bcdd047c160274ba7ccd861100fd87c295dd63cb932f2f \
--hash=sha256:56d7538a4e156041bb80f07f47c327f8944e39da469b010041ce44e324d0657c \
--hash=sha256:58a0a0c029886de8b301ce1ee2e7fd6914ae1ca49feb37cc9930c26baa683427 \
--hash=sha256:5a5af70dad759b45536a9946d8232ef7d90859845d3554c93bea3e790250df75 \
--hash=sha256:5c14c54cbfd45553cd3e6a23014f8e8f2d12c41cd2783e84c2cb774976d4648f \
--hash=sha256:60115947607b8b0a719420726a541bad68728ece38b20654e81fef77c9e0bd2f \
--hash=sha256:6270677da02e86b56b81afd5f6f313736b8315b493f3c8a431da285e3a3c5de9 \
--hash=sha256:6439fc9686518d0336fac8c5e370093279f53c997540065fce131c97567118d8 \
--hash=sha256:68d7e9ba5c48e50f322057b9f88d799c105b5d46c966981aa8e5047b6091541f \
--hash=sha256:6b424ec16068d58d13fb67c7fb728824a3888f8f7fb6ffa3c82d5a54d8b74b7f \
--hash=sha256:6e08884ee9e1e3963701c1cdf22edd17c7ff708728f163efc396964460b3f9b4 \
--hash=sha256:6eaff1d120c47e1a121eada8dc85eac007d1ed81f3db7fc0da5b6ed17d8edefb \
--hash=sha256:6f96ae777e906f0a9d6f75e873313145dfec2b774f558bfcae8ba34f09792460 \
--hash=sha256:707b1ebf5cb051b020e94b039e603351bf2e6620b48fc970228e0dd5d3a91fca \
--hash=sha256:74ae8ce339655b2568d74e49c8ef644d34a445dd0a9b4b89d1bf09447b83f5af \
--hash=sha256:7d099c259aadcab2bc3f4fb5a1db579ec886c2cade7533016f62778235150746 \
--hash=sha256:80fa94668d21b12daf62b034f647d71236470a8ba9a7580e220c47e9c119d932 \
--hash=sha256:87218eeba366c871adcc273407aacbabab781d6a964919712d5583eded5ca50f \
--hash=sha256:884fa003d899113e14908bd3b519c60b48fc3cec0410264dcbdad1c4a8fc2e8d \
--hash=sha256:8a2d466b995f8ff387d68c052d3b74ee981a4ddc4f1a99f32f2dc7022273dc11 \
--hash=sha256:8d924bd002a1c17ca51905b3b7b3d580e80ec211a9b8fe4667b73db0ff9e9b54 \
--hash=sha256:8de94caca7f2616e41466c0ccdf7a96f567914e9e85e89e0b607018777fc0755 \
--hash=sha256:8e20d0a1298a4bd342d7d927d928f1a5a29e5fc9dbf9a79e95dc6e2d386d5070 \
--hash=sha256:949f437f517e81bc567429f41fb1e67349046eb43e52d47b2852b5847de452ee \
--hash=sha256:97192b7756dd854cb2ebc8a1862ffa009cdc203e0399777764462cae3c459d58 \
--hash=sha256:9e4b462fc1f2b160442618448132aebadb71c06b6eb7654eae4385c490100a67 \
--hash=sha256:9f14ac2737fd6f58d88d2e6bf8ebd03aac7b486c14d3f570b7b1d0013d61b726 \
--hash=sha256:a18af6ebd751eea7ddfb093ddf7d0371b05ba0f9a2f8593c7255a34e6bd753ff \
--hash=sha256:a28de4a03fc276614518d8d0997d8152d0edaf8ca9166522316ef1c455e8bc29 \
--hash=sha256:a2c4c3b3d77b759a4a118aa8428da1daf21c01b49915f44d7a3f198bcee4aa7b \
--hash=sha256:a90cfdd29c10425cd4e4cff34adb12d25048561fc946f3562679e45721060a1c \
--hash=sha256:ac3e6e794a0fd6ee4634bf1beea1c3c91ab6faf8b16f3f672a42541f9c5ea41f \
--hash=sha256:b6b9c4dbf7d7e2635ff129ce6ea82174865c073b75888b8b97dda5a3d9a70493 \
--hash=sha256:bca568a2b7d0d454c7921d70b1cc44f427eb6f95961b6d7b3b9b4532d0de74ef \
--hash=sha256:c45a493dffdfe614a9943a8c7f0df617254f836f1242478f7780fbeafb18a131 \
--hash=sha256:c8f722864a03c1d243a9538f0872e233d07fc3fe1d945c66c0cb632060d6d009 \
--hash=sha256:cb1a2b5da0dcfab6837281342d1785cc373484bd3f27bd06fd2211d88075a7bd \
--hash=sha256:d8ab5ba9fce95e342335ef48640221a46600c1afb66847432fad9823d40a2022 \
--hash=sha256:da700db947562f8c0ac530215b74b5a27e4c669916ec99cfb5accd14ba08562c \
--hash=sha256:da777638f5ecf9ef6c979f6c793417f54104d56ac99a48312d6f7e47858c2dd8 \
--hash=sha256:e11d475429a1bc943ceac4ad8da4be63b240e00da5e10863fc3cbd9a35fdb51c \
--hash=sha256:e1f6409854c0a8ed4d1fdbe88d5ee4baf6f19996d1561f76889a132cb083574d \
--hash=sha256:e70eab52781e58e819730d99e3c825e92c15ec2138b6902ed078c8cd73317ce0 \
--hash=sha256:e749b79d9cafc1e9fd9d397d8039377c928ca10a36847fda6407169513802f68 \
--hash=sha256:e9af3c9191b7a4dade9152454001622519f4ecfa674b78929b739cfbf4b35d51 \
--hash=sha256:f0cc21e82e95cb92ef951df8eb8acf5626ac8fa14ab5292abdab1b2349970445 \
--hash=sha256:f27c74deda0282a97dd0f006fd79d6d08fdb16c7a3ba156d52fce85e48515b0a \
--hash=sha256:f3ce310d366335e1ac9416d8e4a27d6eef2ae896fbee0135484d39d001711bea \
--hash=sha256:f67e60eaa287e0fa35223f2d1f9afda81dd7312c7ba07e08fbdaf1af8a923530 \
--hash=sha256:f787b76288450f60250895597dabb080894f0ea09ad5df0433412fee42452435 \
--hash=sha256:fa66391be8acb358b381c30f32be5880d591a3358e531d980832d593dfe83d5a \
--hash=sha256:fbd2835ff193d0261e0de375fea006cb7cb18a30ae1657af48a43e381f6a0995 \
--hash=sha256:fedb9c3be6a2376de436d13fcb37a686a9b6bc988585bcc4f5ec61cad925e794 \
--hash=sha256:ff021ed0962f2d5d67405ae53c85f6cb3ab8c5af3dff7db8c74672f79f7a39d1 \
--hash=sha256:ff67139fbaa91eed55e7e916bdc1ccdaf8c909a80a9c480011caa65c4ba82a97
idna==3.10 \
--hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
--hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
ledger-bitcoin==0.4.0 \
--hash=sha256:2242452e78cf4b57c8b8d3509e831860fd4851b0a1bfab95f2f5e3f47d4d1500 \
--hash=sha256:a33e78710671ec21e1003d0483406e955b48866ccf515fd8e7d8d81f4e1c1cf9
ledgercomm==1.2.1 \
--hash=sha256:015cfc05f16b8c59f8cc1d9fc0b8935923f1fcc3806d33eeb6b0e055b44f5a91 \
--hash=sha256:8ffef5703355b8ec7b73bca325f70288f4d0dafcb299c09833de9c197fb6dd34
libusb1==3.3.1 \
--hash=sha256:0ef69825173ce74af34444754c081cc324233edc6acc405658b3ad784833e076 \
--hash=sha256:3951d360f2daf0e0eacf839e15d2d1d2f4f5e7830231eb3188eeffef2dd17bad \
--hash=sha256:6e21b772d80d6487fbb55d3d2141218536db302da82f1983754e96c72781c102 \
--hash=sha256:808c9362299dcee01651aa87e71e9d681ccedb27fc4dbd70aaf14e245fb855f1
mnemonic==0.21 \
--hash=sha256:1fe496356820984f45559b1540c80ff10de448368929b9c60a2b55744cc88acf \
--hash=sha256:72dc9de16ec5ef47287237b9b6943da11647a03fe7cf1f139fc3d7c4a7439288
noiseprotocol==0.3.1 \
--hash=sha256:2e1a603a38439636cf0ffd8b3e8b12cee27d368a28b41be7dbe568b2abb23111 \
--hash=sha256:b092a871b60f6a8f07f17950dc9f7098c8fe7d715b049bd4c24ee3752b90d645
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
pip==25.1.1 \
--hash=sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af \
--hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
protobuf==3.20.3 \
--hash=sha256:03038ac1cfbc41aa21f6afcbcd357281d7521b4157926f30ebecc8d4ea59dcb7 \
--hash=sha256:28545383d61f55b57cf4df63eebd9827754fd2dc25f80c5253f9184235db242c \
--hash=sha256:2e3427429c9cffebf259491be0af70189607f365c2f41c7c3764af6f337105f2 \
--hash=sha256:398a9e0c3eaceb34ec1aee71894ca3299605fa8e761544934378bbc6c97de23b \
--hash=sha256:44246bab5dd4b7fbd3c0c80b6f16686808fab0e4aca819ade6e8d294a29c7050 \
--hash=sha256:447d43819997825d4e71bf5769d869b968ce96848b6479397e29fc24c4a5dfe9 \
--hash=sha256:67a3598f0a2dcbc58d02dd1928544e7d88f764b47d4a286202913f0b2801c2e7 \
--hash=sha256:74480f79a023f90dc6e18febbf7b8bac7508420f2006fabd512013c0c238f454 \
--hash=sha256:819559cafa1a373b7096a482b504ae8a857c89593cf3a25af743ac9ecbd23480 \
--hash=sha256:899dc660cd599d7352d6f10d83c95df430a38b410c1b66b407a6b29265d66469 \
--hash=sha256:8c0c984a1b8fef4086329ff8dd19ac77576b384079247c770f29cc8ce3afa06c \
--hash=sha256:9aae4406ea63d825636cc11ffb34ad3379335803216ee3a856787bcf5ccc751e \
--hash=sha256:a7ca6d488aa8ff7f329d4c545b2dbad8ac31464f1d8b1c87ad1346717731e4db \
--hash=sha256:b6cc7ba72a8850621bfec987cb72623e703b7fe2b9127a161ce61e61558ad905 \
--hash=sha256:bf01b5720be110540be4286e791db73f84a2b721072a3711efff6c324cdf074b \
--hash=sha256:c02ce36ec760252242a33967d51c289fd0e1c0e6e5cc9397e2279177716add86 \
--hash=sha256:d9e4432ff660d67d775c66ac42a67cf2453c27cb4d738fc22cb53b5d84c135d4 \
--hash=sha256:daa564862dd0d39c00f8086f88700fdbe8bc717e993a21e90711acfed02f2402 \
--hash=sha256:de78575669dddf6099a8a0f46a27e82a1783c557ccc38ee620ed8cc96d3be7d7 \
--hash=sha256:e64857f395505ebf3d2569935506ae0dfc4a15cb80dc25261176c784662cdcc4 \
--hash=sha256:f4bd856d702e5b0d96a00ec6b307b0f51c1982c2bf9c0052cf9019e9a544ba99 \
--hash=sha256:f4c42102bc82a51108e449cbb32b19b180022941c727bac0cfd50170341f16ee
pyaes==1.6.1 \
--hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f
pycparser==2.22 \
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
pyserial==3.5 \
--hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \
--hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0
requests==2.32.3 \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
safet==0.1.5 \
--hash=sha256:a7fd4b68bb1bc6185298af665c8e8e00e2bb2bcbddbb22844ead929b845c635e \
--hash=sha256:f966a23243312f64d14c7dfe02e8f13f6eeba4c3f51341f2c11ae57831f07de3
semver==3.0.4 \
--hash=sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746 \
--hash=sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602
setuptools==80.9.0 \
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
shamir-mnemonic==0.3.0 \
--hash=sha256:188c6b5bd00d5e756e12e2b186c3cb7c98ff7ff44df608d4c1d2077f6b6e730f \
--hash=sha256:bc04886a1ddfe2a64d8a3ec51abf0f664d98d5b557cc7e78a8ad2d10a1d87438
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
slip10==1.0.1 \
--hash=sha256:02b350ae557b591791428b17551f95d7ac57e9211f37debdc814c90b4a123a54 \
--hash=sha256:4aa764369db0a261e468160ec1afeeb2b22d26392dd118c49b9daa91f642947b
trezor==0.13.10 \
--hash=sha256:7a0b6ae4628dd0c31a5ceb51258918d9bbdd3ad851388837225826b228ee504f \
--hash=sha256:7c85dc2c47998765c84d309fc753d2b116c943d447289157895488899c95706d
typing-extensions==4.13.2 \
--hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \
--hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef
urllib3==1.26.20 \
--hash=sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e \
--hash=sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
--hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
================================================
FILE: contrib/deterministic-build/requirements.txt
================================================
aiohappyeyeballs==2.6.1 \
--hash=sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558
aiohttp==3.12.9 \
--hash=sha256:2c9914c8914ff40b68c6e4ed5da33e88d4e8f368fddd03ceb0eb3175905ca782
aiohttp-socks==0.10.1 \
--hash=sha256:49f2e1f8051f2885719beb1b77e312b5a27c3e4b60f0b045a388f194d995e068
aiorpcX==0.25.0 \
--hash=sha256:940fa250ea5e9fd372d4c6acdc20dcb603bd1960ca324759d29864a4aaf64570
aiosignal==1.3.2 \
--hash=sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54
async-timeout==5.0.1 \
--hash=sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3
attrs==22.2.0 \
--hash=sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99
certifi==2025.4.26 \
--hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6
dnspython==2.4.2 \
--hash=sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984
electrum-aionostr==0.1.0 \
--hash=sha256:3774f8e8312388272e10851a869c9f4d3d4a54d8d564851c36e2dc40297bec84
electrum-ecc==0.0.7 \
--hash=sha256:ed4134e1dbff0fd83022764c6acc97a02cde3512927d7c41f4d48b9a06e91fb2
frozenlist==1.6.0 \
--hash=sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68
idna==3.10 \
--hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9
jsonpatch==1.33 \
--hash=sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c
jsonpointer==3.0.0 \
--hash=sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef
multidict==6.4.4 \
--hash=sha256:69ee9e6ba214b5245031b76233dd95408a0fd57fdb019ddcc1ead4790932a8e8
packaging==25.0 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
pip==25.1.1 \
--hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
propcache==0.3.1 \
--hash=sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf
protobuf==3.20.3 \
--hash=sha256:2e3427429c9cffebf259491be0af70189607f365c2f41c7c3764af6f337105f2
python-socks==2.7.1 \
--hash=sha256:f1a0bb603830fe81e332442eada96757b8f8dec02bd22d1d6f5c99a79704c550
QDarkStyle==3.2.3 \
--hash=sha256:0c0b7f74a6e92121008992b369bab60468157db1c02cd30d64a5e9a3b402f1ae
qrcode==8.2 \
--hash=sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c
QtPy==2.4.3 \
--hash=sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb
setuptools==80.9.0 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
typing-extensions==4.13.2 \
--hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
yarl==1.20.0 \
--hash=sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307
================================================
FILE: contrib/docker_notes.md
================================================
# Using the build scripts
Most of our build scripts are docker-based.
(All, except the macOS build, which is a separate beast and always has to be special-cased
at the cost of significant maintenance burden...)
Typically, the build flow is:
- build a docker image, based on debian
- the apt sources mirror used is `snapshot.debian.org`
- (except for the source tarball build, which is simple enough not to need this)
- this helps with historical reproducibility
- note that `snapshot.debian.org` is often slow and sometimes keeps timing out :/
(see #8496)
- a potential alternative would be `snapshot.notset.fr`, but that mirror is missing
e.g. `binary-i386`, which is needed for the wine/windows build.
- if you are just trying to build for yourself and don't need reproducibility,
you can just switch back to the default debian apt sources mirror.
- docker caches the build (locally), and so this step only needs to be rerun
if we update the Dockerfile. This caching happens automatically and by default.
- you can disable the caching by setting envvar `ELECBUILD_NOCACHE=1`. See below.
- create a docker container from the image, and build the final binary inside the container
## Notes about using Docker
- To install Docker:
This assumes an Ubuntu (x86_64) host, but it should not be too hard to adapt to another similar system.
```
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
$ sudo apt-get update
$ sudo apt-get install -y docker-ce
```
- To communicate with the docker daemon, the build scripts either need to be called via sudo,
or the unix user on the host system (e.g. the user you run as) needs to be
part of the `docker` group. i.e.:
```
$ sudo usermod -aG docker ${USER}
```
(and then reboot or similar for it to take effect)
## Environment variables
- `ELECBUILD_COMMIT`
When unset or empty, we build directly from the local git clone. These builds
are *not* reproducible.
When non-empty, it should be set to a git ref. We will create a fresh git clone
checked out at that reference in `/tmp/electrum_build/`, and build there.
- `ELECBUILD_NOCACHE=1`
A non-empty value forces a rebuild of the docker image.
Before we started using `snapshot.debian.org` for apt sources,
setting this was necessary to properly test historical reproducibility.
(we were version-pinning packages installed using `apt`, but it was not realistic to
version-pin all transitive dependencies, and sometimes an update of those resulted in
changes to our binary builds)
I think setting this is no longer necessary for building reproducibly.
================================================
FILE: contrib/freeze_containers_distro.sh
================================================
#!/bin/sh
# Run this after a new release to update pin for build container distro packages
set -e
DEBIAN_SNAPSHOT_BASE="https://snapshot.debian.org/archive/debian/"
DEBIAN_APPIMAGE_DISTRO="bullseye" # should match build-linux/appimage Dockerfile base
DEBIAN_WINE_DISTRO="trixie" # should match build-wine Dockerfile base
DEBIAN_ANDROID_DISTRO="trixie" # should match android Dockerfile base
contrib="$(dirname "$0")"
if [ ! -x /bin/wget ]; then
echo "no wget"
exit 1
fi
DEBIAN_SNAPSHOT_LATEST=$(wget -O- "${DEBIAN_SNAPSHOT_BASE}$(date +"?year=%Y&month=%m")" 2>/dev/null | grep "^/dev/null
echo "Valid!"
# build-linux
echo "deb ${DEBIAN_SNAPSHOT} ${DEBIAN_APPIMAGE_DISTRO} main" > "$contrib/build-linux/appimage/apt.sources.list"
echo "deb-src ${DEBIAN_SNAPSHOT} ${DEBIAN_APPIMAGE_DISTRO} main" >> "$contrib/build-linux/appimage/apt.sources.list"
# build-wine
echo "deb ${DEBIAN_SNAPSHOT} ${DEBIAN_WINE_DISTRO} main" > "$contrib/build-wine/apt.sources.list"
echo "deb-src ${DEBIAN_SNAPSHOT} ${DEBIAN_WINE_DISTRO} main" >> "$contrib/build-wine/apt.sources.list"
# android
echo "deb ${DEBIAN_SNAPSHOT} ${DEBIAN_ANDROID_DISTRO} main" > "$contrib/android/apt.sources.list"
echo "deb-src ${DEBIAN_SNAPSHOT} ${DEBIAN_ANDROID_DISTRO} main" >> "$contrib/android/apt.sources.list"
echo "updated APT sources to ${DEBIAN_SNAPSHOT}"
================================================
FILE: contrib/freeze_packages.sh
================================================
#!/bin/bash
# Run this after a new release to update dependencies
set -e
venv_dir=~/.electrum-venv
contrib="$(dirname "$0")"
# note: we should not use a higher version of python than what the binaries bundle
if [[ ! "$SYSTEM_PYTHON" ]] ; then
SYSTEM_PYTHON=$(which python3.10) || printf ""
else
SYSTEM_PYTHON=$(which "$SYSTEM_PYTHON") || printf ""
fi
if [[ ! "$SYSTEM_PYTHON" ]] ; then
echo "Please specify which python to use in \$SYSTEM_PYTHON" && exit 1
fi
"${SYSTEM_PYTHON}" -m hashin -h > /dev/null 2>&1 || { "${SYSTEM_PYTHON}" -m pip install hashin; }
for suffix in '' '-hw' '-binaries' '-binaries-mac' '-build-wine' '-build-mac' '-build-base' '-build-appimage' '-build-android'; do
reqfile="requirements${suffix}.txt"
rm -rf "$venv_dir"
"${SYSTEM_PYTHON}" -m venv "$venv_dir"
source "$venv_dir/bin/activate"
echo "Installing dependencies... (${reqfile})"
# We pin all python packaging tools (pip and friends). Some of our dependencies might
# pull some of them in (e.g. protobuf->setuptools), and all transitive dependencies
# must be pinned, so we might as well pin all packaging tools. This however means
# that we should explicitly install them now, so that we pin latest versions if possible.
python -m pip install --upgrade pip setuptools wheel
python -m pip install -r "$contrib/requirements/${reqfile}" --upgrade
echo "OK."
requirements=$(pip freeze --all)
restricted=$(echo $requirements | ${SYSTEM_PYTHON} "$contrib/deterministic-build/find_restricted_dependencies.py")
if [ ! -z "$restricted" ]; then
python -m pip install $restricted
requirements=$(pip freeze --all)
fi
echo "Generating package hashes... (${reqfile})"
rm -f "$contrib/deterministic-build/${reqfile}"
touch "$contrib/deterministic-build/${reqfile}"
# restrict ourselves to source-only packages.
# TODO expand this to all reqfiles...
HASHIN_FLAGS=""
if [[
"${suffix}" == "" ||
"${suffix}" == "-build-wine" ||
"${suffix}" == "-build-mac" ||
"${suffix}" == "-build-appimage" ||
"${suffix}" == "-build-android" ||
"0" == "1"
]] ;
then
HASHIN_FLAGS="--python-version source"
fi
echo -e "\r Hashing requirements for $reqfile..."
${SYSTEM_PYTHON} -m hashin $HASHIN_FLAGS -r "$contrib/deterministic-build/${reqfile}" $requirements
echo "OK."
done
echo "Done. Updated requirements"
================================================
FILE: contrib/generate_payreqpb2.sh
================================================
#!/bin/bash
# Generates the file paymentrequest_pb2.py
set -e
CONTRIB="$(dirname "$(readlink -e "$0")")"
EL="$CONTRIB"/../electrum
if ! which protoc > /dev/null 2>&1; then
echo "Please install 'protoc'"
echo "If you're on Debian, try 'sudo apt install protobuf-compiler'?"
exit 1
fi
protoc --proto_path="$EL" --python_out="$EL" "$EL"/paymentrequest.proto
================================================
FILE: contrib/locale/build_cleanlocale.sh
================================================
#!/bin/bash
set -e
CONTRIB_LOCALE="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
CONTRIB="$CONTRIB_LOCALE"/..
PROJECT_ROOT="$CONTRIB"/..
cd "$PROJECT_ROOT"
git submodule update --init
LOCALE="$PROJECT_ROOT/electrum/locale/"
cd "$LOCALE"
git clean -ffxd
git reset --hard
rm -rf llm_proofreader
"$CONTRIB_LOCALE/build_locale.sh" "$LOCALE/locale" "$LOCALE/locale"
================================================
FILE: contrib/locale/build_locale.sh
================================================
#!/bin/bash
#
# This script converts human-readable (.po) locale files to compiled (.mo) locale files.
set -e
CONTRIB_LOCALE="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
if [[ ! -d "$1" || -z "$2" ]]; then
echo "usage: $0 locale_source_dir locale_dest_dir"
echo " The dirs can match, to build in place."
# ^ note: these are the paths to the "inner" locale/ dir
exit 1
fi
# convert $1 and $2 to abs paths
SRC_DIR="$(realpath "$1" 2> /dev/null || grealpath "$1")"
DST_DIR="$(realpath "$2" 2> /dev/null || grealpath "$2")"
if ! which msgfmt > /dev/null 2>&1; then
echo "Please install gettext"
exit 1
fi
cd "$SRC_DIR"
mkdir -p "$DST_DIR"
for i in *; do
dir="$DST_DIR/$i/LC_MESSAGES"
mkdir -p "$dir"
(msgfmt --output-file="$dir/electrum.mo" "$i/electrum.po" || true)
done
echo "running stats.py"
"$CONTRIB_LOCALE/stats.py"
================================================
FILE: contrib/locale/push_locale.py
================================================
#!/usr/bin/env python3
#
# This script extracts "raw" strings from the codebase,
# and uploads them to crowdin, for the community to translate them.
#
# Dependencies:
# $ sudo apt-get install python3-requests gettext qt6-l10n-tools
import glob
import os
import subprocess
import sys
try:
import requests
except ImportError as e:
sys.exit(f"Error: {str(e)}. Try 'python3 -m pip install --user '")
# set cwd
project_root = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
os.chdir(project_root)
locale_dir = os.path.join(project_root, "electrum", "locale")
if not os.path.exists(os.path.join(locale_dir, "locale")):
raise Exception(f"missing git submodule for locale? {locale_dir}")
# check dependencies are available
try:
subprocess.check_output(["xgettext", "--version"])
subprocess.check_output(["msgcat", "--version"])
except (subprocess.CalledProcessError, OSError) as e2:
raise Exception("missing gettext. Maybe try 'apt install gettext'")
QT_LUPDATE="lupdate"
QT_LCONVERT="lconvert"
try:
subprocess.check_output([QT_LUPDATE, "-version"])
subprocess.check_output([QT_LCONVERT, "-h"])
except (subprocess.CalledProcessError, OSError) as e1:
QT_LUPDATE="/usr/lib/qt6/bin/lupdate" # workaround qt5/qt6 confusion on ubuntu 22.04
QT_LCONVERT="/usr/lib/qt6/bin/lconvert"
try:
subprocess.check_output([QT_LUPDATE, "-version"])
subprocess.check_output([QT_LCONVERT, "-h"])
except (subprocess.CalledProcessError, OSError) as e2:
raise Exception("missing Qt lupdate/convert tools. Maybe try 'apt install qt6-l10n-tools'")
# create build dir
build_dir = os.path.join(locale_dir, "build")
if not os.path.exists(build_dir):
os.mkdir(build_dir)
# add .py files
files_list = glob.glob("electrum/**/*.py", recursive=True)
files_list = sorted(files_list) # makes output deterministic across CI runs
with open(f"{build_dir}/app.fil", "w", encoding="utf-8") as f:
for item in files_list:
f.write(item + "\n")
print("Found {} .py files to translate".format(len(files_list)))
# Generate fresh translation template
print('Generating template...')
# note: do not use xgettext option "--sort-output", as that makes human translators have to context-switch all the time
cmd = ["xgettext", "--from-code", "UTF-8", "--language", "Python", "--no-wrap", "-f", f"{build_dir}/app.fil", f"--output={build_dir}/messages_gettext.pot"]
subprocess.check_output(cmd)
# add QML translations
files_list = glob.glob("electrum/gui/qml/**/*.qml", recursive=True)
files_list = sorted(files_list) # makes output deterministic across CI runs
with open(f"{build_dir}/qml.lst", "w", encoding="utf-8") as f:
for item in files_list:
f.write(item + "\n")
print("Found {} QML files to translate".format(len(files_list)))
# note: lupdate writes relative paths into its output .ts file, relative to the .ts file itself :/
cmd = [QT_LUPDATE, f"@{build_dir}/qml.lst","-ts", f"{build_dir}/qml.ts"]
print('Collecting strings')
subprocess.check_output(cmd)
cmd = [QT_LCONVERT, "-of", "po", "-o", f"{build_dir}/messages_qml.pot", f"{build_dir}/qml.ts"]
print('Convert to gettext')
subprocess.check_output(cmd)
print("Fixing some paths in messages_qml.pot")
# sed from " ../../gui/qml/"
# to " electrum/gui/qml/"
cmd = ["sed", "-i", r"s/ ..\/..\/gui\/qml\// electrum\/gui\/qml\//g", f"{build_dir}/messages_qml.pot"]
subprocess.check_output(cmd)
cmd = ["msgcat", "-u", "-o", f"{build_dir}/messages.pot", f"{build_dir}/messages_gettext.pot", f"{build_dir}/messages_qml.pot"]
print('Generate template')
subprocess.check_output(cmd)
# Add a custom PO header entry to messages.pot. This header survives crowdin,
# and will still be in the translated .po files, and will get compiled into the final .mo files.
cnt_src_strings = 0
with open(f"{build_dir}/messages.pot", "r", encoding="utf-8") as f:
for line in f.readlines():
if line.startswith('msgid '):
cnt_src_strings += 1
with open(f"{build_dir}/messages_customheader.pot", "w", encoding="utf-8") as f:
f.write('''msgid ""\n''')
f.write('''msgstr ""\n''')
f.write(f'''"X-Electrum-SourceStringCount: {cnt_src_strings}"\n''')
cmd = ["msgcat", "-u", "-o", f"{build_dir}/messages.pot", f"{build_dir}/messages.pot", f"{build_dir}/messages_customheader.pot"]
print('Add custom header to template')
subprocess.check_output(cmd)
# prepare uploading to crowdin
os.chdir(os.path.join(project_root, "electrum"))
crowdin_api_key = None
filename = os.path.expanduser('~/.crowdin_api_key')
if os.path.exists(filename):
with open(filename) as f:
crowdin_api_key = f.read().strip()
if "crowdin_api_key" in os.environ:
crowdin_api_key = os.environ["crowdin_api_key"]
if not crowdin_api_key:
print('Missing crowdin_api_key. Cannot push.')
sys.exit(1)
print('Found crowdin_api_key. Will push updated source-strings to crowdin.')
crowdin_project_id = 20482 # for "Electrum" project on crowdin
locale_file_name = os.path.join(build_dir, "messages.pot")
crowdin_file_name = "messages.pot"
crowdin_file_id = 68 # for "/electrum-client/messages.pot"
global_headers = {"Authorization": "Bearer {}".format(crowdin_api_key)}
# client.storages.add_storage(f)
# https://support.crowdin.com/developer/api/v2/?q=api#tag/Storage/operation/api.storages.post
print(f"Uploading to temp storage...")
url = f'https://api.crowdin.com/api/v2/storages'
with open(locale_file_name, 'rb') as f:
headers = {**global_headers, **{"Crowdin-API-FileName": crowdin_file_name}}
response = requests.request("POST", url, data=f, headers=headers)
response.raise_for_status()
print("", "storages.add_storage:", "-" * 20, response.text, "-" * 20, sep="\n")
storage_id = response.json()["data"]["id"]
# client.source_files.update_file(projectId=crowdin_project_id, storageId=storage_id, fileId=crowdin_file_id)
# https://support.crowdin.com/developer/api/v2/?q=api#tag/Source-Files/operation/api.projects.files.put
print(f"Copying from temp storage and updating file in perm storage...")
url = f'https://api.crowdin.com/api/v2/projects/{crowdin_project_id}/files/{crowdin_file_id}'
headers = {**global_headers, **{"content-type": "application/json"}}
response = requests.request("PUT", url, json={"storageId": storage_id}, headers=headers)
response.raise_for_status()
print("", "source_files.update_file:", "-" * 20, response.text, "-" * 20, sep="\n")
# client.translations.build_crowdin_project_translation(projectId=crowdin_project_id)
# https://support.crowdin.com/developer/api/v2/?q=api#tag/Translations/operation/api.projects.translations.builds.post
print(f"Rebuilding translations...")
url = f'https://api.crowdin.com/api/v2/projects/{crowdin_project_id}/translations/builds'
headers = {**global_headers, **{"content-type": "application/json"}}
json_data = {
#"exportApprovedOnly": True, # only include translated-strings approved by users with "Proofreader" permission
} # note: these settings MUST be verified by electrum-locale/update.py again, at download-time.
response = requests.request("POST", url, json=json_data, headers=headers)
response.raise_for_status()
print("", "translations.build_crowdin_project_translation:", "-" * 20, response.text, "-" * 20, sep="\n")
================================================
FILE: contrib/locale/stats.py
================================================
#!/usr/bin/env python3
#
# Copyright (C) 2026 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
#
#
# This generates a 'stats.json' file containing some statistics about translation completeness.
import gettext
import glob
import json
import os
PROJECT_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
LOCALE_DIR = os.path.join(PROJECT_ROOT, "electrum", "locale", "locale")
if __name__ == '__main__':
catalog_size = {} # type: dict[str, int]
source_string_count = None
# - calc stats
files_list = glob.glob(f"{LOCALE_DIR}/*/LC_MESSAGES/*.mo")
for fname in files_list:
lang_code = os.path.basename(os.path.dirname(os.path.dirname(fname)))
try:
t = gettext.translation('electrum', LOCALE_DIR, languages=[lang_code])
except OSError as e:
raise Exception(f"cannot find or parse .mo file matching {fname!r}") from e
# calc catalog size of translated strings
catalog_size[lang_code] = len(t._catalog)
# same SourceStringCount header should be present in all .mo files:
t_info = t.info()
try:
ss_cnt = int(t_info["x-electrum-sourcestringcount"])
except Exception as e:
raise Exception(
f"missing or malformed 'x-electrum-sourcestringcount' header, for {lang_code!r}.\n"
f"found {t_info}"
) from e
if source_string_count is None:
source_string_count = ss_cnt
elif source_string_count != ss_cnt:
raise Exception(
f"inconsistent 'x-electrum-sourcestringcount' headers! "
f"prev_cnt={source_string_count}, new_cnt={ss_cnt} (for lang={lang_code})")
# - convert to json data. example:
# {
# "source_string_count": 9999,
# "translations": {
# "de_DE": {
# "string_count": 400,
# },
# ...
# }
# }
json_data = {
"source_string_count": source_string_count,
"translations": {},
}
for lang_code in catalog_size:
json_data["translations"][lang_code] = {}
json_data["translations"][lang_code]["string_count"] = catalog_size[lang_code]
# - write json to disk
with open(f"{LOCALE_DIR}/stats.json", "w", encoding="utf-8") as f:
json_str = json.dumps(
json_data,
indent=4,
sort_keys=True
)
f.write(json_str)
print(f"done. created file '{LOCALE_DIR}/stats.json'")
================================================
FILE: contrib/make_download
================================================
#!/usr/bin/python3
import re
import os
import sys
import importlib
from collections import defaultdict
if len(sys.argv) < 2:
print(f"ERROR. usage: {os.path.basename(__file__)} ", file=sys.stderr)
sys.exit(1)
# cd to project root
os.chdir(os.path.dirname(os.path.dirname(__file__)))
# load version.py; needlessly complicated alternative to "imp.load_source":
version_spec = importlib.util.spec_from_file_location('version', 'electrum/version.py')
version_module = importlib.util.module_from_spec(version_spec)
version_spec.loader.exec_module(version_module)
ELECTRUM_VERSION = version_module.ELECTRUM_VERSION
print(f"version: {ELECTRUM_VERSION}", file=sys.stderr)
dirname = sys.argv[1]
print(f"directory: {dirname}", file=sys.stderr)
download_page = os.path.join(dirname, "panel-download.html")
download_template = download_page + ".template"
with open(download_template) as f:
download_page_str = f.read()
version = version_win = version_mac = version_android = ELECTRUM_VERSION
download_page_str = download_page_str.replace("##VERSION##", version)
download_page_str = download_page_str.replace("##VERSION_WIN##", version_win)
download_page_str = download_page_str.replace("##VERSION_MAC##", version_mac)
download_page_str = download_page_str.replace("##VERSION_ANDROID##", version_android)
download_page_str = download_page_str.replace("##VERSION_APK##", version_android)
# note: all dist files need to be listed here that we expect sigs for,
# even if they are not linked to from the website
files = {
"tgz": f"Electrum-{version}.tar.gz",
"tgz_srconly": f"Electrum-sourceonly-{version}.tar.gz",
"appimage": f"electrum-{version}-x86_64.AppImage",
"mac": f"electrum-{version_mac}.dmg",
"win": f"electrum-{version_win}.exe",
"win_setup": f"electrum-{version_win}-setup.exe",
"win_portable": f"electrum-{version_win}-portable.exe",
"apk_arm64": f"Electrum-{version_android}-arm64-v8a-release.apk",
"apk_armeabi": f"Electrum-{version_android}-armeabi-v7a-release.apk",
"apk_x86_64": f"Electrum-{version_android}-x86_64-release.apk",
}
# default signers
signers = ['ThomasV', 'sombernight_releasekey']
# detect extra signers
list_dir = sorted(os.listdir('dist'))
detected_sigs = defaultdict(set)
for f in list_dir:
if f.endswith('.asc'):
parts = f.split('.')
signer = parts[-2]
filename = '.'.join(parts[0:-2])
detected_sigs[signer].add(filename)
for k, v in detected_sigs.items():
if v == set(files.values()):
if k not in signers:
signers.append(k)
print(f"signers: {signers}", file=sys.stderr)
friendly_nick = lambda x: 'SomberNight' if x=='sombernight_releasekey' else x
signers_list = ', '.join("%s"%(x, friendly_nick(x)) for x in signers)
download_page_str = download_page_str.replace("##signers_list##", signers_list)
for k, filename in files.items():
path = "dist/%s"%filename
assert filename in list_dir
link = "https://download.electrum.org/%s/%s"%(version, filename)
download_page_str = download_page_str.replace("##link_%s##" % k, link)
download_page_str = download_page_str.replace("##sigs_%s##" % k, link + '.asc')
# download page has been constructed from template; now insert it into index.html
index_html_path = os.path.join(dirname, "index.html")
with open(f"{index_html_path}.template") as f:
index_html_str = f.read()
index_html_str = index_html_str.replace("##DOWNLOAD_PAGE##", download_page_str)
with open(index_html_path, 'w') as f:
f.write(index_html_str)
================================================
FILE: contrib/make_libsecp256k1.sh
================================================
#!/bin/bash
# This script was tested on Linux and MacOS hosts, where it can be used
# to build native libsecp256k1 binaries.
#
# It can also be used to cross-compile to Windows:
# $ sudo apt-get install mingw-w64
# For a Windows x86 (32-bit) target, run:
# $ GCC_TRIPLET_HOST="i686-w64-mingw32" ./contrib/make_libsecp256k1.sh
# Or for a Windows x86_64 (64-bit) target, run:
# $ GCC_TRIPLET_HOST="x86_64-w64-mingw32" ./contrib/make_libsecp256k1.sh
#
# To cross-compile to Linux x86:
# sudo apt-get install gcc-multilib g++-multilib
# $ AUTOCONF_FLAGS="--host=i686-linux-gnu CFLAGS=-m32 CXXFLAGS=-m32 LDFLAGS=-m32" ./contrib/make_libsecp256k1.sh
LIBSECP_VERSION="1a53f4961f337b4d166c25fce72ef0dc88806618"
# ^ tag "v0.7.1"
# note: this version is duplicated in contrib/android/p4a_recipes/libsecp256k1/__init__.py
# (and also in electrum-ecc, for the "secp256k1" git submodule)
set -e
. "$(dirname "$0")/build_tools_util.sh" || (echo "Could not source build_tools_util.sh" && exit 1)
here="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
CONTRIB="$here"
PROJECT_ROOT="$CONTRIB/.."
pkgname="secp256k1"
info "Building $pkgname..."
(
cd "$CONTRIB"
if [ ! -d secp256k1 ]; then
git clone https://github.com/bitcoin-core/secp256k1.git
fi
cd secp256k1
if ! $(git cat-file -e ${LIBSECP_VERSION}) ; then
info "Could not find requested version $LIBSECP_VERSION in local clone; fetching..."
git fetch --all
fi
git reset --hard
git clean -dfxq
git checkout "${LIBSECP_VERSION}^{commit}"
if ! [ -x configure ] ; then
echo "LDFLAGS = -no-undefined" >> Makefile.am
./autogen.sh || fail "Could not run autogen for $pkgname. Please make sure you have automake and libtool installed, and try again."
fi
if ! [ -r config.status ] ; then
./configure \
$AUTOCONF_FLAGS \
--prefix="$here/$pkgname/dist" \
--enable-module-recovery \
--enable-module-extrakeys \
--enable-module-schnorrsig \
--enable-experimental \
--enable-module-ecdh \
--disable-benchmark \
--disable-tests \
--disable-exhaustive-tests \
--disable-static \
--enable-shared || fail "Could not configure $pkgname. Please make sure you have a C compiler installed and try again."
fi
make "-j$CPU_COUNT" || fail "Could not build $pkgname"
make install || fail "Could not install $pkgname"
. "$here/$pkgname/dist/lib/libsecp256k1.la"
host_strip "$here/$pkgname/dist/lib/$dlname"
if [ -n "$DLL_TARGET_DIR" ] ; then
cp -fpv "$here/$pkgname/dist/lib/$dlname" "$DLL_TARGET_DIR/" || fail "Could not copy the $pkgname binary to DLL_TARGET_DIR"
else
cp -fpv "$here/$pkgname/dist/lib/$dlname" "$PROJECT_ROOT/electrum" || fail "Could not copy the $pkgname binary to its destination"
info "$dlname has been placed in the 'electrum' folder."
fi
)
================================================
FILE: contrib/make_libusb.sh
================================================
#!/bin/bash
LIBUSB_VERSION="d52e355daa09f17ce64819122cb067b8a2ee0d4b"
# ^ tag v1.0.27
set -e
. "$(dirname "$0")/build_tools_util.sh" || (echo "Could not source build_tools_util.sh" && exit 1)
here="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
CONTRIB="$here"
PROJECT_ROOT="$CONTRIB/.."
pkgname="libusb"
info "Building $pkgname..."
(
cd "$CONTRIB"
if [ ! -d libusb ]; then
git clone https://github.com/libusb/libusb.git
fi
cd libusb
if ! $(git cat-file -e ${LIBUSB_VERSION}) ; then
info "Could not find requested version $LIBUSB_VERSION in local clone; fetching..."
git fetch --all
fi
git reset --hard
git clean -dfxq
git checkout "${LIBUSB_VERSION}^{commit}"
if [ "$BUILD_TYPE" = "wine" ] ; then
echo "libusb_1_0_la_LDFLAGS += -Wc,-static" >> libusb/Makefile.am
fi
./bootstrap.sh || fail "Could not bootstrap libusb"
if ! [ -r config.status ] ; then
if [ "$BUILD_TYPE" = "wine" ] ; then
# windows target
LDFLAGS="-Wl,--no-insert-timestamp"
elif [ $(uname) == "Darwin" ]; then
# macos target
LDFLAGS="-Wl -lm"
else
# linux target
LDFLAGS=""
fi
LDFLAGS="$LDFLAGS" ./configure \
$AUTOCONF_FLAGS \
|| fail "Could not configure $pkgname. Please make sure you have a C compiler installed and try again."
fi
make "-j$CPU_COUNT" || fail "Could not build $pkgname"
make install || warn "Could not install $pkgname"
. "$here/$pkgname/libusb/.libs/libusb-1.0.la"
host_strip "$here/$pkgname/libusb/.libs/$dlname"
TARGET_NAME="$dlname"
if [ $(uname) == "Darwin" ]; then # on mac, dlname is "libusb-1.0.0.dylib"
TARGET_NAME="libusb-1.0.dylib"
fi
cp -fpv "$here/$pkgname/libusb/.libs/$dlname" "$PROJECT_ROOT/electrum/$TARGET_NAME" || fail "Could not copy the $pkgname binary to its destination"
info "$TARGET_NAME has been placed in the inner 'electrum' folder."
if [ -n "$DLL_TARGET_DIR" ] ; then
cp -fpv "$here/$pkgname/libusb/.libs/$dlname" "$DLL_TARGET_DIR/$TARGET_NAME" || fail "Could not copy the $pkgname binary to DLL_TARGET_DIR"
fi
)
================================================
FILE: contrib/make_packages.sh
================================================
#!/bin/bash
# This script installs our pure python dependencies into the 'packages' folder.
set -e
CONTRIB="$(dirname "$(readlink -e "$0")")"
PROJECT_ROOT="$CONTRIB"/..
PACKAGES="$PROJECT_ROOT"/packages/
test -n "$CONTRIB" -a -d "$CONTRIB" || exit
cd "$CONTRIB"
if [ -d "$PACKAGES" ]; then
rm -r "$PACKAGES"
fi
# create virtualenv
# note: venv path needs to be deterministic as some produced files will contain it
venv_dir="$CONTRIB/.venv_make_packages/"
rm -rf "$venv_dir"
python3 -m venv "$venv_dir"
source "$venv_dir"/bin/activate
# installing pinned build-time requirements, such as pip/wheel/setuptools
python3 -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
-r "$CONTRIB"/deterministic-build/requirements-build-base.txt
# opt out of compiling C extensions
export AIOHTTP_NO_EXTENSIONS=1
export YARL_NO_EXTENSIONS=1
export MULTIDICT_NO_EXTENSIONS=1
export FROZENLIST_NO_EXTENSIONS=1
export PROPCACHE_NO_EXTENSIONS=1
export ELECTRUM_ECC_DONT_COMPILE=1
# see https://github.com/python-websockets/websockets/blob/e6d0ea1d6b13a979924329d02fb82f79d82c7236/setup.py#L22
export BUILD_EXTENSION="no"
# if we end up having to compile something, at least give reproducibility a fighting chance
export LC_ALL=C
export TZ=UTC
export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct 2>/dev/null || printf 1530212462)"
export PYTHONHASHSEED="$SOURCE_DATE_EPOCH"
export BUILD_DATE="$(LC_ALL=C TZ=UTC date +'%b %e %Y' -d @$SOURCE_DATE_EPOCH)"
export BUILD_TIME="$(LC_ALL=C TZ=UTC date +'%H:%M:%S' -d @$SOURCE_DATE_EPOCH)"
# FIXME aiohttp will compile some .so files using distutils
# (until https://github.com/aio-libs/aiohttp/pull/4079 gets released),
# which are not reproducible unless using at least python 3.9
# (as it needs https://github.com/python/cpython/commit/0d30ae1a03102de07758650af9243fd31211325a).
# Hence "aiohttp-*.dist-info/" is not reproducible either.
# All this means that downstream users of this script, such as the sdist build
# and the android apk build need to make sure these files get excluded.
# note: --no-build-isolation is needed so that pip uses the locally available setuptools and wheel,
# instead of downloading the latest ones
python3 -m pip install --no-build-isolation --no-compile --no-dependencies --no-binary :all: \
-r "$CONTRIB"/deterministic-build/requirements.txt -t "$PACKAGES"
echo "Pure-python dependencies have been placed into $PACKAGES"
================================================
FILE: contrib/make_plugin
================================================
#!/usr/bin/python3
import os
import sys
import hashlib
import json
import zipfile
import zipimport
# todo: use version number
if len(sys.argv) != 2:
print(f"usage: {os.path.basename(__file__)} ", file=sys.stderr)
sys.exit(1)
source_dir = sys.argv[1] # where the plugin source code is
if source_dir.endswith('/'):
source_dir = source_dir[:-1]
plugin_name = os.path.basename(source_dir)
dest_dir = os.getcwd()
zip_path = os.path.join(dest_dir, plugin_name + '.zip')
# remove old zipfile
if os.path.exists(zip_path):
os.unlink(zip_path)
# create zipfile
print('creating', zip_path)
with zipfile.ZipFile(zip_path, 'w') as zip_object:
for folder_name, sub_folders, file_names in os.walk(source_dir):
for filename in file_names:
file_path = os.path.join(folder_name, filename)
dest_path = os.path.join(plugin_name, os.path.relpath(folder_name, source_dir), os.path.basename(file_path))
zip_object.write(file_path, dest_path)
print('added', dest_path)
# read version
try:
with open(os.path.join(source_dir, 'manifest.json'), 'r') as f:
manifest = json.load(f)
version = manifest.get('version')
except FileNotFoundError:
raise Exception(f"plugin doesn't contain manifest.json")
if version:
versioned_plugin_name = plugin_name + '-' + version + '.zip'
zip_path_with_version = os.path.join(dest_dir, versioned_plugin_name)
# rename zip file
os.rename(zip_path, zip_path_with_version)
print(f'Created {zip_path_with_version}')
else:
print(f'Created {zip_path}')
================================================
FILE: contrib/make_zbar.sh
================================================
#!/bin/bash
# This script can be used on Linux hosts to build native libzbar binaries.
# sudo apt-get install pkg-config libx11-dev libx11-6 libv4l-dev libxv-dev libxext-dev libjpeg-dev
#
# It can also be used to cross-compile to Windows:
# $ sudo apt-get install mingw-w64 mingw-w64-tools win-iconv-mingw-w64-dev
# For a Windows x86 (32-bit) target, run:
# $ GCC_TRIPLET_HOST="i686-w64-mingw32" BUILD_TYPE="wine" ./contrib/make_zbar.sh
# Or for a Windows x86_64 (64-bit) target, run:
# $ GCC_TRIPLET_HOST="x86_64-w64-mingw32" BUILD_TYPE="wine" ./contrib/make_zbar.sh
ZBAR_VERSION="bb05ec54eec57f8397cb13fb9161372a281a1219"
# ^ tag 0.23.93
set -e
. "$(dirname "$0")/build_tools_util.sh" || (echo "Could not source build_tools_util.sh" && exit 1)
here="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
CONTRIB="$here"
PROJECT_ROOT="$CONTRIB/.."
pkgname="zbar"
info "Building $pkgname..."
(
cd "$CONTRIB"
if [ ! -d zbar ]; then
git clone https://github.com/mchehab/zbar.git
fi
cd zbar
if ! $(git cat-file -e ${ZBAR_VERSION}) ; then
info "Could not find requested version $ZBAR_VERSION in local clone; fetching..."
git fetch --all
fi
git reset --hard
git clean -dfxq
git checkout "${ZBAR_VERSION}^{commit}"
if [ "$BUILD_TYPE" = "wine" ] ; then
echo "libzbar_la_LDFLAGS += -Wc,-static" >> zbar/Makefile.am
echo "LDFLAGS += -Wc,-static" >> Makefile.am
fi
if ! [ -x configure ] ; then
autoreconf -vfi || fail "Could not run autoreconf for $pkgname. Please make sure you have automake and libtool installed, and try again."
fi
if ! [ -r config.status ] ; then
if [ "$BUILD_TYPE" = "wine" ] ; then
# windows target
AUTOCONF_FLAGS="$AUTOCONF_FLAGS \
--with-x=no \
--enable-video=yes \
--with-jpeg=no \
--with-directshow=yes \
--disable-dependency-tracking"
elif [ $(uname) == "Darwin" ]; then
# macos target
AUTOCONF_FLAGS="$AUTOCONF_FLAGS \
--with-x=no \
--enable-video=no \
--with-jpeg=no"
else
# linux target
AUTOCONF_FLAGS="$AUTOCONF_FLAGS \
--with-x=yes \
--enable-video=yes \
--with-jpeg=yes"
fi
./configure \
$AUTOCONF_FLAGS \
--prefix="$here/$pkgname/dist" \
--enable-pthread=no \
--enable-doc=no \
--with-python=no \
--with-gtk=no \
--with-qt=no \
--with-java=no \
--with-imagemagick=no \
--with-dbus=no \
--enable-codes=qrcode \
--disable-static \
--enable-shared || fail "Could not configure $pkgname. Please make sure you have a C compiler installed and try again."
fi
make "-j$CPU_COUNT" || fail "Could not build $pkgname"
make install || fail "Could not install $pkgname"
. "$here/$pkgname/dist/lib/libzbar.la"
host_strip "$here/$pkgname/dist/lib/$dlname"
cp -fpv "$here/$pkgname/dist/lib/$dlname" "$PROJECT_ROOT/electrum" || fail "Could not copy the $pkgname binary to its destination"
info "$dlname has been placed in the inner 'electrum' folder."
if [ -n "$DLL_TARGET_DIR" ] ; then
cp -fpv "$here/$pkgname/dist/lib/$dlname" "$DLL_TARGET_DIR/" || fail "Could not copy the $pkgname binary to DLL_TARGET_DIR"
fi
)
================================================
FILE: contrib/osx/README.md
================================================
Building macOS binaries
=======================
✓ _This binary should be reproducible, meaning you should be able to generate
binaries that match the official releases._
- _Minimum supported target system (i.e. what end-users need): macOS 11_
This guide explains how to build Electrum binaries for macOS systems.
## Building the binary
This needs to be done on a system running macOS or OS X.
The script is only tested on Intel-based (x86_64) Macs, and the binary built
targets `x86_64` currently.
Notes about compatibility with different macOS versions:
- In general the binary is not guaranteed to run on an older version of macOS
than what the build machine has. This is due to bundling the compiled Python into
the [PyInstaller binary](https://github.com/pyinstaller/pyinstaller/issues/1191).
- The [bundled version of Qt](https://github.com/spesmilo/electrum/issues/3685) also
imposes a minimum supported macOS version.
- If you want to build binaries that conform to the macOS "Gatekeeper", so as to
minimise the warnings users get, the binaries need to be codesigned with a
certificate issued by Apple, and starting with macOS 10.15 (targets) the binaries also
need to be notarized by Apple's central server. To be able to build
binaries that Apple will notarize (due to the requirements on the binaries themselves,
e.g. hardened runtime) the build machine needs at least macOS 10.14.
See [#6128](https://github.com/spesmilo/electrum/issues/6128).
- There are two tools that can be used to notarize a binary, both part of Xcode:
the old `altool` and the newer `notarytool`. `altool`
[was deprecated](https://developer.apple.com/news/?id=y5mjxqmn) by Apple.
`notarytool` requires Xcode 13+, and that in turn requires macOS 11.3+.
We currently build the release binaries on macOS 11.7.10, and these seem to run on
11 or newer.
#### Notes about reproducibility
- We recommend creating a VM with a macOS guest, e.g. using VirtualBox,
and building there.
- The guest should run macOS 11.7.10 (that specific version).
- The unix username should be `vagrant`, and `electrum` should be cloned directly
to the user's home dir: `/Users/vagrant/electrum`.
- Builders need to use the same version of Xcode; and note that
full Xcode and Xcode commandline tools differ!
We use the Xcode CLI tools as installed by brew. (version 13.2)
Sanity checks:
```
$ sw_vers
ProductName: macOS
ProductVersion: 11.7.10
BuildVersion: 20G1427
$ xcode-select -p
/Library/Developer/CommandLineTools
$ xcrun --show-sdk-path
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk
$ pkgutil --pkg-info=com.apple.pkg.CLTools_Executables
package-id: com.apple.pkg.CLTools_Executables
version: 13.2.0.0.1.1638488800
volume: /
location: /
install-time: XXXXXXXXXX
groups: com.apple.FindSystemFiles.pkg-group
$ gcc --version
Configured with: --prefix=/Library/Developer/CommandLineTools/usr --with-gxx-include-dir=/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/4.2.1
Apple clang version 13.0.0 (clang-1300.0.29.30)
Target: x86_64-apple-darwin20.6.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin
```
- Installing extraneous brew packages can result in build differences.
For example, pyinstaller seems to pick up and bundle brew-installed `libffi`.
So having a dedicated "electrum binary builder macOS VM" is recommended.
- Make sure that you are building from a fresh clone of electrum
(or run e.g. `git clean -ffxd` to rm all local changes).
#### 1. Install brew
Install [`brew`](https://brew.sh/).
Let brew install the Xcode CLI tools.
#### 2. Build Electrum
cd electrum
./contrib/osx/make_osx.sh
This creates both a folder named Electrum.app and the .dmg file (both unsigned).
##### 2.1. For release binaries, here be dragons
If you want the binaries codesigned for macOS and notarised by Apple's central server,
also run the `sign_osx.sh` script:
CODESIGN_CERT="Developer ID Application: Electrum Technologies GmbH (L6P37P7P56)" \
APPLE_TEAM_ID="L6P37P7P56" \
APPLE_ID_USER="me@email.com" \
APPLE_ID_PASSWORD="1234" \
./contrib/osx/sign_osx.sh
(note: `APPLE_ID_PASSWORD` is an app-specific password, *not* the account password)
## Verifying reproducibility and comparing against official binary
Every user can verify that the official binary was created from the source code in this
repository.
1. Build your own binary as described above.
2. Use the provided `compare_dmg` script to compare the binary you built with
the official release binary.
```
$ ./contrib/osx/compare_dmg dist/electrum-*.dmg electrum_dmg_official_release.dmg
```
The `compare_dmg` script is mostly only needed as the official release binary is
codesigned and notarized. Otherwise, the built `.app` bundles should be byte-identical.
(Note that we are using `hdutil` to create the `.dmg`, and its output is not
deterministic, but we cannot compare the `.dmg` files directly anyway as they contain
codesigned files)
## FAQ
### What is macOS "codesigning" and "notarization"?
Codesigning is the macOS OS-native signing of executables/shared-libs,
that needs to be done using an ~x509-like certificate that chains back to Apple's root CA.
Once a developer certificate is obtained from Apple, it can be used to codesign locally
on a dev machine.
Notarization is a further step usually done after, which entails uploading a distributable
over the network to the Apple mothership central server, which runs some arbitrary checks on it,
and if it finds the file ok, the central server gives the dev a notarization staple.
This staple can then be optionally "attached" to the distributable, mutating it, which we do.
(If the staple is not attached, enduser machines request it from the mothership at runtime.)
Both these steps should be done during the build process.
### What is "codesigned" and/or "notarized", re the official release?
- `make_osx.sh` builds a `.app`, which is unsigned/unnotarized
- at this point, this `.app` is ~"byte-for-byte" reproducible
- this is the sanity-check hash printed at the end of `make_osx.sh`
- `make_osx.sh` creates a `.dmg` from the `.app`
- this `.dmg` is not used for the official release at all, but used as the basis of
testing reproducibility using the `compare_dmg` script
- `sign_osx.sh` codesigns the `.app` (mutating it)
- `sign_osx.sh` -> `notarize_app.sh` notarizes the `.app` (mutating it)
- `sign_osx.sh` creates a `.dmg` from the `.app`
- `sign_osx.sh` codesigns the `.dmg` (mutating it)
- this `.dmg` becomes the official release distributable
That is, the official release `.dmg` is codesigned but NOT notarized.
It contains a `.app`, which is codesigned AND notarized.
### How to check if a file is codesigned?
Both the `.dmg` and the contained `.app` are codesigned:
```
$ codesign --verify --deep --strict --verbose=2 $HOME/Desktop/electrum-4.5.8.dmg && echo "signed"
/Users/vagrant/Desktop/electrum-4.5.8.dmg: valid on disk
/Users/vagrant/Desktop/electrum-4.5.8.dmg: satisfies its Designated Requirement
signed
```
```
$ codesign --verify --deep --strict --verbose=1 $HOME/Desktop/Electrum-4.5.8.app && echo "signed"
/Users/vagrant/Desktop/Electrum-4.5.8.app: valid on disk
/Users/vagrant/Desktop/Electrum-4.5.8.app: satisfies its Designated Requirement
signed
```
Also see `$ codesign -dvvv $HOME/Desktop/electrum-4.5.8.dmg`
### How to check if a file is notarized?
The outer `.dmg` is NOT notarized, but the inner `.app` is notarized:
```
$ spctl -a -vvv -t install $HOME/Desktop/electrum-4.5.8.dmg
/Users/vagrant/Desktop/electrum-4.5.8.dmg: rejected
source=Unnotarized Developer ID
origin=Developer ID Application: Electrum Technologies GmbH (L6P37P7P56)
```
```
$ spctl -a -vvv -t install $HOME/Desktop/Electrum-4.5.8.app
/Users/vagrant/Desktop/Electrum-4.5.8.app: accepted
source=Notarized Developer ID
origin=Developer ID Application: Electrum Technologies GmbH (L6P37P7P56)
```
### How to simulate the signing procedure?
It is possible to run `sign_osx.sh` using a self-signed certificate to test the
signing procedure without using a production certificate.
Note that the notarization process will be skipped as it is not possible to notarize
an executable with Apple using a self-signed certificate.
#### To generate a self-signed certificate, inside your **MacOS VM**:
1. Open the `Keychain Access` application.
2. In the menubar go to `Keychain Access` > `Certificate Assistant` > `Create a Certificate...`
3. Set a name (e.g. `signing_dummy`)
4. Change `Certificate Type` to *'Code Signing'*
5. Click `Create` and `Continue`.
You now have a self-signed certificate `signing_dummy` added to your `login` keychain.
#### To sign the executables with the self-signed certificate:
Assuming you have the two unsigned outputs of `make_osx.sh` inside `~/electrum/dist`
(e.g. `Electrum.app` and `electrum-4.5.4-1368-gc8db684cc-unsigned.dmg`).
In `~/electrum` run:
`$ CODESIGN_CERT="signing_dummy" ./contrib/osx/sign_osx.sh`
After `sign_osx.sh` finished, you will have a new `*.dmg` inside `electrum/dist`
(without the `-unsigned` postfix) which is signed with your certificate.
#### To compare the unsigned executable with the self-signed executable:
Running `compare_dmg` with `IS_NOTARIZED=false` should succeed:
`$ IS_NOTARIZED=false ./electrum/contrib/osx/compare_dmg `
================================================
FILE: contrib/osx/README_macos.md
================================================
# Running Electrum from source on macOS (development version)
## Prerequisites
- [brew](https://brew.sh/)
- python3
- git
## Main steps
### 1. Check out the code from GitHub:
```
$ git clone https://github.com/spesmilo/electrum.git
$ cd electrum
$ git submodule update --init
```
### 2. Prepare for compiling libsecp256k1
To be able to build the `electrum-ecc` package from source
(which is pulled in when installing Electrum in the next step),
you need:
```
$ brew install autoconf automake libtool coreutils
```
### 3. Install Electrum
Run install (this should install the dependencies):
```
$ python3 -m pip install --user -e ".[gui,crypto]"
```
### 4. Run electrum:
```
$ ./run_electrum
```
================================================
FILE: contrib/osx/apply_sigs.sh
================================================
#!/bin/sh
# Copyright (c) 2014-2019 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
#
# This script is based on https://github.com/bitcoin/bitcoin/blob/194b9b8792d9b0798fdb570b79fa51f1d1f5ebaf/contrib/macdeploy/detached-sig-apply.sh
export LC_ALL=C
set -e
if [ $(uname) != "Darwin" ]; then
echo "This script needs to be run on macOS."
exit 1
fi
CP=gcp
UNSIGNED="$1"
SIGNATURE="$2"
ARCH=x86_64
OUTDIR="/tmp/electrum_compare_dmg/signed_app"
if [ -z "$UNSIGNED" ]; then
echo "usage: $0 "
exit 1
fi
if [ -z "$SIGNATURE" ]; then
echo "usage: $0 "
exit 1
fi
rm -rf ${OUTDIR} && mkdir -p ${OUTDIR}
${CP} -rf ${UNSIGNED} ${OUTDIR}
tar xf "${SIGNATURE}" -C ${OUTDIR}
find ${OUTDIR} -name "*.sign" | while read i; do
SIZE=$(gstat -c %s "${i}")
TARGET_FILE="$(echo "${i}" | sed 's/\.sign$//')"
if [ -z ${QUIET} ]; then
echo "Allocating space for the signature of size ${SIZE} in ${TARGET_FILE}"
fi
codesign_allocate -i "${TARGET_FILE}" -a ${ARCH} ${SIZE} -o "${i}.tmp"
OFFSET=$(pagestuff "${i}.tmp" -p | tail -2 | grep offset | sed 's/[^0-9]*//g')
if [ -z ${QUIET} ]; then
echo "Attaching signature at offset ${OFFSET}"
fi
dd if="$i" of="${i}.tmp" bs=1 seek=${OFFSET} count=${SIZE} 2>/dev/null
mv "${i}.tmp" "${TARGET_FILE}"
rm "${i}"
if [ -z ${QUIET} ]; then
echo "Success."
fi
done
echo "Done. .app with sigs applied is at: ${OUTDIR}"
================================================
FILE: contrib/osx/cdrkit-deterministic.patch
================================================
--- cdrkit-1.1.11.old/genisoimage/tree.c 2008-10-21 19:57:47.000000000 -0400
+++ cdrkit-1.1.11/genisoimage/tree.c 2013-12-06 00:23:18.489622668 -0500
@@ -1139,8 +1139,9 @@
scan_directory_tree(struct directory *this_dir, char *path,
struct directory_entry *de)
{
- DIR *current_dir;
+ int current_file;
char whole_path[PATH_MAX];
+ struct dirent **d_list;
struct dirent *d_entry;
struct directory *parent;
int dflag;
@@ -1164,7 +1165,8 @@
this_dir->dir_flags |= DIR_WAS_SCANNED;
errno = 0; /* Paranoia */
- current_dir = opendir(path);
+ //current_dir = opendir(path);
+ current_file = scandir(path, &d_list, NULL, alphasort);
d_entry = NULL;
/*
@@ -1173,12 +1175,12 @@
*/
old_path = path;
- if (current_dir) {
+ if (current_file >= 0) {
errno = 0;
- d_entry = readdir(current_dir);
+ d_entry = d_list[0];
}
- if (!current_dir || !d_entry) {
+ if (current_file < 0 || !d_entry) {
int ret = 1;
#ifdef USE_LIBSCHILY
@@ -1191,8 +1193,8 @@
de->isorec.flags[0] &= ~ISO_DIRECTORY;
ret = 0;
}
- if (current_dir)
- closedir(current_dir);
+ if(d_list)
+ free(d_list);
return (ret);
}
#ifdef ABORT_DEEP_ISO_ONLY
@@ -1208,7 +1210,7 @@
errmsgno(EX_BAD, "use Rock Ridge extensions via -R or -r,\n");
errmsgno(EX_BAD, "or allow deep ISO9660 directory nesting via -D.\n");
}
- closedir(current_dir);
+ free(d_list);
return (1);
}
#endif
@@ -1250,13 +1252,13 @@
* The first time through, skip this, since we already asked
* for the first entry when we opened the directory.
*/
- if (dflag)
- d_entry = readdir(current_dir);
+ if (dflag && current_file >= 0)
+ d_entry = d_list[current_file];
dflag++;
- if (!d_entry)
+ if (current_file < 0)
break;
-
+ current_file--;
/* OK, got a valid entry */
/* If we do not want all files, then pitch the backups. */
@@ -1348,7 +1350,7 @@
insert_file_entry(this_dir, whole_path, d_entry->d_name);
#endif /* APPLE_HYB */
}
- closedir(current_dir);
+ free(d_list);
#ifdef APPLE_HYB
/*
================================================
FILE: contrib/osx/compare_dmg
================================================
#!/usr/bin/env bash
set -e
if [ $(uname) != "Darwin" ]; then
echo "This script needs to be run on macOS."
exit 1
fi
UNSIGNED_DMG="$1"
RELEASE_DMG="$2"
CONTRIB_OSX="$(dirname "$(grealpath "$0")")"
PROJECT_ROOT="$CONTRIB_OSX/../.."
WORKSPACE="/tmp/electrum_compare_dmg"
WS_VOL1="$WORKSPACE/vol1"
WS_VOL2="$WORKSPACE/vol2"
if [ -z "$UNSIGNED_DMG" ]; then
echo "usage: $0 "
exit 1
fi
if [ -z "$RELEASE_DMG" ]; then
echo "usage: $0 "
exit 1
fi
UNSIGNED_DMG=$(grealpath "$UNSIGNED_DMG")
RELEASE_DMG=$(grealpath "$RELEASE_DMG")
cd "$PROJECT_ROOT"
rm -rf "$WORKSPACE"
mkdir -p "$WORKSPACE" "$WS_VOL1" "$WS_VOL2"
DMG_UNSIGNED_UNPACKED="$WORKSPACE/dmg1"
DMG_RELEASE_UNPACKED="$WORKSPACE/dmg2"
hdiutil attach -mountroot "$WS_VOL1" "$UNSIGNED_DMG"
cp -r "$WS_VOL1"/Electrum "$DMG_UNSIGNED_UNPACKED"
hdiutil detach "$WS_VOL1"/Electrum
hdiutil attach -mountroot "$WS_VOL2" "$RELEASE_DMG"
cp -r "$WS_VOL2"/Electrum "$DMG_RELEASE_UNPACKED"
hdiutil detach "$WS_VOL2"/Electrum
# copy signatures from RELEASE_DMG to UNSIGNED_DMG
echo "Extracting signatures from release app..."
QUIET="1" "$CONTRIB_OSX/extract_sigs.sh" "$DMG_RELEASE_UNPACKED"/Electrum.app
echo "Applying extracted signatures to unsigned app..."
QUIET="1" "$CONTRIB_OSX/apply_sigs.sh" "$DMG_UNSIGNED_UNPACKED"/Electrum.app mac_extracted_sigs.tar.gz
rm mac_extracted_sigs.tar.gz
rm -rf "$DMG_UNSIGNED_UNPACKED"
set -x
diff=$(diff -qr "$WORKSPACE/signed_app" "$DMG_RELEASE_UNPACKED") || diff="diff errored"
set +x
echo $diff
if [ "$diff" ]; then
echo "DMGs do *not* match."
echo "failure"
exit 1
else
echo "DMGs match."
echo "success"
exit 0
fi
================================================
FILE: contrib/osx/entitlements.plist
================================================
com.apple.security.cs.allow-unsigned-executable-memorycom.apple.security.cs.disable-library-validationcom.apple.security.cs.allow-dyld-environment-variablescom.apple.security.cs.allow-jitcom.apple.security.device.camera
================================================
FILE: contrib/osx/extract_sigs.sh
================================================
#!/bin/sh
# Copyright (c) 2014-2019 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
#
# This script is based on https://github.com/bitcoin/bitcoin/blob/194b9b8792d9b0798fdb570b79fa51f1d1f5ebaf/contrib/macdeploy/detached-sig-create.sh
export LC_ALL=C
set -e
if [ $(uname) != "Darwin" ]; then
echo "This script needs to be run on macOS."
exit 1
fi
TEMPDIR="/tmp/electrum_compare_dmg/sigs.temp"
OUT=mac_extracted_sigs.tar.gz
OUTROOT=.
if [ -z "$1" ]; then
echo "usage: $0 "
exit 1
fi
BUNDLE="$1"
BUNDLE_BASENAME=$(basename "$BUNDLE")
rm -rf ${TEMPDIR}
mkdir -p ${TEMPDIR}
MAYBE_SIGNED_FILES=$(
find "$BUNDLE/Contents/MacOS/" -type f;
find "$BUNDLE/Contents/Frameworks/" -type f;
find "$BUNDLE/Contents/Resources/" -type f
)
echo "${MAYBE_SIGNED_FILES}" | while read i; do
# skip files where pagestuff errors; these probably do not need signing:
pagestuff "$i" -p 1>/dev/null 2>/dev/null || continue
TARGETFILE="${BUNDLE_BASENAME}/$(echo "${i}" | sed "s|.*${BUNDLE}/||")"
SIZE=$(pagestuff "$i" -p | tail -2 | grep size | sed 's/[^0-9]*//g')
OFFSET=$(pagestuff "$i" -p | tail -2 | grep offset | sed 's/[^0-9]*//g')
SIGNFILE="${TEMPDIR}/${OUTROOT}/${TARGETFILE}.sign"
DIRNAME="$(dirname "${SIGNFILE}")"
mkdir -p "${DIRNAME}"
if [ -z ${QUIET} ]; then
echo "Adding detached signature for: ${TARGETFILE}. Size: ${SIZE}. Offset: ${OFFSET}"
fi
dd if="$i" of="${SIGNFILE}" bs=1 skip=${OFFSET} count=${SIZE} 2>/dev/null
done
# note: "$BUNDLE/Contents/CodeResources" is the "notarization staple id"
FILES_TO_COPY=$(cat << EOF
$BUNDLE/Contents/_CodeSignature/CodeResources
$([ "${IS_NOTARIZED:-true}" != "false" ] && echo "$BUNDLE/Contents/CodeResources")
EOF
)
echo "${FILES_TO_COPY}" | while read i; do
TARGETFILE="${BUNDLE_BASENAME}/$(echo "${i}" | sed "s|.*${BUNDLE}/||")"
RESOURCE="${TEMPDIR}/${OUTROOT}/${TARGETFILE}"
DIRNAME="$(dirname "${RESOURCE}")"
mkdir -p "${DIRNAME}"
if [ -z ${QUIET} ]; then
echo "Adding resource for: \"${TARGETFILE}\""
fi
cp "${i}" "${RESOURCE}"
done
tar -C "${TEMPDIR}" -czf "${OUT}" .
rm -rf "${TEMPDIR}"
echo "Created ${OUT}"
================================================
FILE: contrib/osx/make_osx.sh
================================================
#!/usr/bin/env bash
set -e
# Parameterize
PYTHON_VERSION=3.12.10
PY_VER_MAJOR="3.12" # as it appears in fs paths
PACKAGE=Electrum
GIT_REPO=https://github.com/spesmilo/electrum
export GCC_STRIP_BINARIES="1"
export PYTHONDONTWRITEBYTECODE=1 # don't create __pycache__/ folders with .pyc files
. "$(dirname "$0")/../build_tools_util.sh"
CONTRIB_OSX="$(dirname "$(realpath "$0")")"
CONTRIB="$CONTRIB_OSX/.."
PROJECT_ROOT="$CONTRIB/.."
CACHEDIR="$CONTRIB_OSX/.cache"
export DLL_TARGET_DIR="$CACHEDIR/dlls"
PIP_CACHE_DIR="$CACHEDIR/pip_cache"
mkdir -p "$CACHEDIR" "$DLL_TARGET_DIR" "$PIP_CACHE_DIR"
cd "$PROJECT_ROOT"
git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
which brew > /dev/null 2>&1 || fail "Please install brew from https://brew.sh/ to continue"
which xcodebuild > /dev/null 2>&1 || fail "Please install xcode command line tools to continue"
info "Installing Python $PYTHON_VERSION"
PKG_FILE="python-${PYTHON_VERSION}-macos11.pkg"
if [ ! -f "$CACHEDIR/$PKG_FILE" ]; then
curl -o "$CACHEDIR/$PKG_FILE" "https://www.python.org/ftp/python/${PYTHON_VERSION}/$PKG_FILE"
fi
echo "8373e58da4ea146b3eb1c1f9834f19a319440b6b679b06050b1f9ee3237aa8e4 $CACHEDIR/$PKG_FILE" | shasum -a 256 -c \
|| fail "python pkg checksum mismatched"
sudo installer -pkg "$CACHEDIR/$PKG_FILE" -target / \
|| fail "failed to install python"
# sanity check "python3" has the version we just installed.
FOUND_PY_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:3])))')
if [[ "$FOUND_PY_VERSION" != "$PYTHON_VERSION" ]]; then
fail "python version mismatch: $FOUND_PY_VERSION != $PYTHON_VERSION"
fi
break_legacy_easy_install
# create a fresh virtualenv
# This helps to avoid older versions of pip-installed dependencies interfering with the build.
VENV_DIR="$CONTRIB_OSX/build-venv"
rm -rf "$VENV_DIR"
python3 -m venv "$VENV_DIR"
source "$VENV_DIR/bin/activate"
# don't add debug info to compiled C files (e.g. when pip calls setuptools/wheel calls gcc)
# see https://github.com/pypa/pip/issues/6505#issuecomment-526613584
# note: this does not seem sufficient when cython is involved (although it is on linux, just not on mac... weird.)
# see additional "strip" pass on built files later in the file.
export CFLAGS="-g0"
# Do not build universal binaries. The default on macos 11+ and xcode 12+ is "-arch arm64 -arch x86_64"
# but with that e.g. "hid.cpython-310-darwin.so" is not reproducible as built by clang.
export ARCHFLAGS="-arch x86_64"
info "Installing build dependencies"
# note: re pip installing from PyPI,
# we prefer compiling C extensions ourselves, instead of using binary wheels,
# hence "--no-binary :all:" flags. However, we specifically allow
# - PyQt6, as it's harder to build from source
# - cryptography, as it's harder to build from source
# - the whole of "requirements-build-base.txt", which includes pip and friends, as it also includes "wheel",
# and I am not quite sure how to break the circular dependence there (I guess we could introduce
# "requirements-build-base-base.txt" with just wheel in it...)
python3 -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
--cache-dir "$PIP_CACHE_DIR" -Ir ./contrib/deterministic-build/requirements-build-base.txt \
|| fail "Could not install build dependencies (base)"
python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
--cache-dir "$PIP_CACHE_DIR" -Ir ./contrib/deterministic-build/requirements-build-mac.txt \
|| fail "Could not install build dependencies (mac)"
info "Installing some build-time deps for compilation..."
brew install autoconf automake libtool gettext coreutils pkgconfig
info "Building PyInstaller."
PYINSTALLER_REPO="https://github.com/pyinstaller/pyinstaller.git"
PYINSTALLER_COMMIT="306d4d92580fea7be7ff2c89ba112cdc6f73fac1"
# ^ tag "v6.13.0"
(
if [ -f "$CACHEDIR/pyinstaller/PyInstaller/bootloader/Darwin-64bit/runw" ]; then
info "pyinstaller already built, skipping"
exit 0
fi
cd "$PROJECT_ROOT"
ELECTRUM_COMMIT_HASH=$(git rev-parse HEAD)
cd "$CACHEDIR"
rm -rf pyinstaller
mkdir pyinstaller
cd pyinstaller
# Shallow clone
git init
git remote add origin $PYINSTALLER_REPO
git fetch --depth 1 origin $PYINSTALLER_COMMIT
git checkout -b pinned "${PYINSTALLER_COMMIT}^{commit}"
rm -fv PyInstaller/bootloader/Darwin-*/run* || true
# add reproducible randomness. this ensures we build a different bootloader for each commit.
# if we built the same one for all releases, that might also get anti-virus false positives
echo "const char *electrum_tag = \"tagged by Electrum@$ELECTRUM_COMMIT_HASH\";" >> ./bootloader/src/pyi_main.c
pushd bootloader
# compile bootloader
python3 ./waf all CFLAGS="-static"
popd
# sanity check bootloader is there:
[[ -e "PyInstaller/bootloader/Darwin-64bit/runw" ]] || fail "Could not find runw in target dir!"
)
info "Installing PyInstaller."
python3 -m pip install --no-build-isolation --no-dependencies \
--cache-dir "$PIP_CACHE_DIR" --no-warn-script-location "$CACHEDIR/pyinstaller"
info "Using these versions for building $PACKAGE:"
sw_vers
python3 --version
echo -n "Pyinstaller "
pyinstaller --version
rm -rf ./dist
info "resetting git submodules."
# note: --force is less critical in other build scripts, but as the mac build is not doing a fresh clone,
# it is very useful here for reproducibility
git submodule update --init --force
info "preparing electrum-locale."
(
if ! which msgfmt > /dev/null 2>&1; then
brew install gettext
brew link --force gettext
fi
"$CONTRIB/locale/build_cleanlocale.sh"
# we want the binary to have only compiled (.mo) locale files; not source (.po) files
rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/electrum.po
)
if ls "$DLL_TARGET_DIR"/libsecp256k1.*.dylib 1> /dev/null 2>&1; then
info "libsecp256k1 already built, skipping"
else
info "Building libsecp256k1 dylib..."
"$CONTRIB"/make_libsecp256k1.sh || fail "Could not build libsecp"
fi
cp -f "$DLL_TARGET_DIR"/libsecp256k1.*.dylib "$PROJECT_ROOT/electrum" || fail "Could not copy libsecp256k1 dylib"
if [ ! -f "$DLL_TARGET_DIR/libzbar.0.dylib" ]; then
info "Building ZBar dylib..."
"$CONTRIB"/make_zbar.sh || fail "Could not build ZBar dylib"
else
info "Skipping ZBar build: reusing already built dylib."
fi
cp -f "$DLL_TARGET_DIR/libzbar.0.dylib" "$PROJECT_ROOT/electrum/" || fail "Could not copy ZBar dylib"
if [ ! -f "$DLL_TARGET_DIR/libusb-1.0.dylib" ]; then
info "Building libusb dylib..."
"$CONTRIB"/make_libusb.sh || fail "Could not build libusb dylib"
else
info "Skipping libusb build: reusing already built dylib."
fi
cp -f "$DLL_TARGET_DIR/libusb-1.0.dylib" "$PROJECT_ROOT/electrum/" || fail "Could not copy libusb dylib"
# opt out of compiling C extensions
export YARL_NO_EXTENSIONS=1
export PROPCACHE_NO_EXTENSIONS=1
export ELECTRUM_ECC_DONT_COMPILE=1
info "Installing requirements..."
python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: \
--cache-dir "$PIP_CACHE_DIR" --no-warn-script-location \
-Ir ./contrib/deterministic-build/requirements.txt \
|| fail "Could not install requirements"
info "Installing hardware wallet requirements..."
python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: --only-binary cryptography \
--cache-dir "$PIP_CACHE_DIR" --no-warn-script-location \
-Ir ./contrib/deterministic-build/requirements-hw.txt \
|| fail "Could not install hardware wallet requirements"
info "Installing dependencies specific to binaries..."
python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: --only-binary PyQt6,PyQt6-Qt6,cryptography \
--cache-dir "$PIP_CACHE_DIR" --no-warn-script-location \
-Ir ./contrib/deterministic-build/requirements-binaries-mac.txt \
|| fail "Could not install dependencies specific to binaries"
info "Building $PACKAGE..."
python3 -m pip install --no-build-isolation --no-dependencies \
--cache-dir "$PIP_CACHE_DIR" --no-warn-script-location . > /dev/null || fail "Could not build $PACKAGE"
# pyinstaller needs to be able to "import electrum_ecc", for which we need libsecp256k1:
# (or could try "pip install -e" instead)
cp "$DLL_TARGET_DIR"/libsecp256k1.*.dylib "$VENV_DIR/lib/python$PY_VER_MAJOR/site-packages/electrum_ecc/"
# strip debug symbols of some compiled libs
# - hidapi (hid.cpython-39-darwin.so) in particular is not reproducible without this
find "$VENV_DIR/lib/python$PY_VER_MAJOR/site-packages/" -type f -name '*.so' -print0 \
| xargs -0 -t strip -x
info "Faking timestamps..."
find . -exec touch -t '200101220000' {} + || true
# note: no --dirty, as we have dirtied electrum/locale/ ourselves.
VERSION=$(git describe --tags --always)
info "Building binary"
ELECTRUM_VERSION=$VERSION pyinstaller --noconfirm --clean contrib/osx/pyinstaller.spec || fail "Could not build binary"
info "Finished building unsigned dist/${PACKAGE}.app. This hash should be reproducible:"
find "dist/${PACKAGE}.app" -type f -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256
info "Creating unsigned .DMG"
hdiutil create -fs HFS+ -volname $PACKAGE -srcfolder dist/$PACKAGE.app dist/electrum-$VERSION-unsigned.dmg || fail "Could not create .DMG"
info "App was built successfully but was not code signed. Users may get security warnings from macOS."
info "Now you also need to run sign_osx.sh to codesign/notarize the binary."
================================================
FILE: contrib/osx/notarize_app.sh
================================================
#!/usr/bin/env bash
# from https://github.com/metabrainz/picard/blob/e1354632d2db305b7a7624282701d34d73afa225/scripts/package/macos-notarize-app.sh
set -e
if [ -z "$1" ]; then
echo "Specify app bundle as first parameter"
exit 1
fi
if [ -z "$APPLE_ID_USER" ] || [ -z "$APPLE_ID_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then
echo "You need to set your Apple ID credentials with \$APPLE_ID_USER and \$APPLE_ID_PASSWORD."
exit 1
fi
APP_BUNDLE=$(basename "$1")
APP_BUNDLE_DIR=$(dirname "$1")
cd "$APP_BUNDLE_DIR" || exit 1
# Package app for submission
echo "Generating ZIP archive ${APP_BUNDLE}.zip..."
ditto -c -k --rsrc --keepParent "$APP_BUNDLE" "${APP_BUNDLE}.zip"
# Submit for notarization
echo "Submitting $APP_BUNDLE for notarization..."
RESULT=$(xcrun notarytool submit \
--team-id "$APPLE_TEAM_ID" \
--apple-id "$APPLE_ID_USER" \
--password "$APPLE_ID_PASSWORD" \
--output-format plist \
--wait \
--timeout 10m \
"${APP_BUNDLE}.zip"
)
if [ $? -ne 0 ]; then
echo "Submitting $APP_BUNDLE failed:"
echo "$RESULT"
exit 1
fi
STATUS=$(echo "$RESULT" | xpath -e \
"//key[normalize-space(text()) = 'status']/following-sibling::string[1]/text()" 2> /dev/null)
if [ "$STATUS" = "Accepted" ]; then
echo "Notarization of $APP_BUNDLE succeeded!"
else
echo "Notarization of $APP_BUNDLE failed:"
echo "$RESULT"
exit 1
fi
# Staple the notary ticket
xcrun stapler staple "$APP_BUNDLE"
# rm zip
rm "${APP_BUNDLE}.zip"
================================================
FILE: contrib/osx/package.sh
================================================
#!/usr/bin/env bash
set -ex
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
CONTRIB="$PROJECT_ROOT/contrib"
. "$CONTRIB"/build_tools_util.sh
# note: GCC 10.1 will need an extra option, see https://github.com/bitcoin/bitcoin/pull/19553
cdrkit_version=1.1.11
cdrkit_download_path=http://distro.ibiblio.org/fatdog/source/600/c
cdrkit_file_name=cdrkit-${cdrkit_version}.tar.bz2
cdrkit_sha256_hash=b50d64c214a65b1a79afe3a964c691931a4233e2ba605d793eb85d0ac3652564
cdrkit_patches=cdrkit-deterministic.patch
genisoimage=genisoimage-$cdrkit_version
libdmg_url=https://github.com/theuni/libdmg-hfsplus
export LD_PRELOAD=$(locate libfaketime.so.1)
export FAKETIME="2000-01-22 00:00:00"
export PATH=$PATH:~/bin
if [ -z "$1" ]; then
echo "Usage: $0 Electrum.app"
exit -127
fi
mkdir -p ~/bin
if ! which ${genisoimage} > /dev/null 2>&1; then
mkdir -p /tmp/electrum-macos
cd /tmp/electrum-macos
info "Downloading cdrkit $cdrkit_version"
wget -nc ${cdrkit_download_path}/${cdrkit_file_name}
tar xvf ${cdrkit_file_name}
info "Patching genisoimage"
cd cdrkit-${cdrkit_version}
patch -p1 <$CONTRIB/osx/cdrkit-deterministic.patch
info "Building genisoimage"
cmake . -Wno-dev
make genisoimage
cp genisoimage/genisoimage ~/bin/${genisoimage}
fi
if ! which dmg > /dev/null 2>&1; then
mkdir -p /tmp/electrum-macos
cd /tmp/electrum-macos
info "Downloading libdmg"
LD_PRELOAD= git clone ${libdmg_url}
cd libdmg-hfsplus
info "Building libdmg"
cmake .
make
cp dmg/dmg ~/bin
fi
${genisoimage} -version || fail "Unable to install genisoimage"
dmg - || fail "Unable to install libdmg"
plist=$1/Contents/Info.plist
test -f "$plist" || fail "Info.plist not found"
VERSION=$(grep -1 ShortVersionString $plist | tail -1 | gawk 'match($0, /(.*)<\/string>/, a) {print a[1]}')
echo $VERSION
rm -rf /tmp/electrum-macos/image > /dev/null 2>&1
mkdir /tmp/electrum-macos/image/
cp -r $1 /tmp/electrum-macos/image/
build_dir=$(dirname "$1")
test -n "$build_dir" -a -d "$build_dir" || exit
cd $build_dir
${genisoimage} \
-no-cache-inodes \
-D \
-l \
-probe \
-V "Electrum" \
-no-pad \
-r \
-dir-mode 0755 \
-apple \
-o Electrum_uncompressed.dmg \
/tmp/electrum-macos/image || fail "Unable to create uncompressed dmg"
dmg dmg Electrum_uncompressed.dmg electrum-$VERSION.dmg || fail "Unable to create compressed dmg"
rm Electrum_uncompressed.dmg
echo "Done."
sha256sum electrum-$VERSION.dmg
================================================
FILE: contrib/osx/pyinstaller.spec
================================================
# -*- mode: python -*-
import sys
import os
from typing import TYPE_CHECKING
from PyInstaller.utils.hooks import collect_data_files, collect_submodules, collect_dynamic_libs, copy_metadata
if TYPE_CHECKING:
from PyInstaller.building.build_main import Analysis, PYZ, EXE, BUNDLE
PACKAGE_NAME='Electrum.app'
PYPKG='electrum'
MAIN_SCRIPT='run_electrum'
PROJECT_ROOT = os.path.abspath(".")
ICONS_FILE=f"{PROJECT_ROOT}/{PYPKG}/gui/icons/electrum.icns"
VERSION = os.environ.get("ELECTRUM_VERSION")
if not VERSION:
raise Exception('no version')
block_cipher = None
# see https://github.com/pyinstaller/pyinstaller/issues/2005
hiddenimports = []
hiddenimports += collect_submodules('pkg_resources') # workaround for https://github.com/pypa/setuptools/issues/1963
hiddenimports += collect_submodules(f"{PYPKG}.plugins")
binaries = []
# Workaround for "Retro Look":
binaries += [b for b in collect_dynamic_libs('PyQt6') if 'macstyle' in b[0]]
# add libsecp256k1, libusb, etc:
binaries += [(f"{PROJECT_ROOT}/{PYPKG}/*.dylib", ".")]
datas = [
(f"{PROJECT_ROOT}/{PYPKG}/*.json", PYPKG),
(f"{PROJECT_ROOT}/{PYPKG}/lnwire/*.csv", f"{PYPKG}/lnwire"),
(f"{PROJECT_ROOT}/{PYPKG}/wordlist/english.txt", f"{PYPKG}/wordlist"),
(f"{PROJECT_ROOT}/{PYPKG}/wordlist/slip39.txt", f"{PYPKG}/wordlist"),
(f"{PROJECT_ROOT}/{PYPKG}/chains", f"{PYPKG}/chains"),
(f"{PROJECT_ROOT}/{PYPKG}/locale", f"{PYPKG}/locale"),
(f"{PROJECT_ROOT}/{PYPKG}/plugins", f"{PYPKG}/plugins"),
(f"{PROJECT_ROOT}/{PYPKG}/gui/icons", f"{PYPKG}/gui/icons"),
(f"{PROJECT_ROOT}/{PYPKG}/gui/fonts", f"{PYPKG}/gui/fonts"),
]
datas += collect_data_files(f"{PYPKG}.plugins")
datas += collect_data_files('trezorlib') # TODO is this needed? and same question for other hww libs
datas += collect_data_files('safetlib')
datas += collect_data_files('ckcc')
datas += collect_data_files('bitbox02')
# some deps rely on importlib metadata
datas += copy_metadata('slip10') # from trezor->slip10
# Exclude parts of Qt that we never use. Reduces binary size by tens of MBs. see #4815
excludes = [
"PyQt6.QtBluetooth",
"PyQt6.QtDesigner",
"PyQt6.QtNfc",
"PyQt6.QtPositioning",
"PyQt6.QtQml",
"PyQt6.QtQuick",
"PyQt6.QtQuick3D",
"PyQt6.QtQuickWidgets",
"PyQt6.QtRemoteObjects",
"PyQt6.QtSensors",
"PyQt6.QtSerialPort",
"PyQt6.QtSpatialAudio",
"PyQt6.QtSql",
"PyQt6.QtTest",
"PyQt6.QtTextToSpeech",
"PyQt6.QtWebChannel",
"PyQt6.QtWebSockets",
"PyQt6.QtXml",
# "PyQt6.QtNetwork", # needed by QtMultimedia. kinda weird but ok.
]
# 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([f"{PROJECT_ROOT}/{MAIN_SCRIPT}",
f"{PROJECT_ROOT}/{PYPKG}/gui/qt/main_window.py",
f"{PROJECT_ROOT}/{PYPKG}/gui/qt/qrreader/qtmultimedia/camera_dialog.py",
f"{PROJECT_ROOT}/{PYPKG}/gui/text.py",
f"{PROJECT_ROOT}/{PYPKG}/util.py",
f"{PROJECT_ROOT}/{PYPKG}/wallet.py",
f"{PROJECT_ROOT}/{PYPKG}/simple_config.py",
f"{PROJECT_ROOT}/{PYPKG}/bitcoin.py",
f"{PROJECT_ROOT}/{PYPKG}/dnssec.py",
f"{PROJECT_ROOT}/{PYPKG}/commands.py",
],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
excludes=excludes,
)
# 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,
exclude_binaries=True,
name=MAIN_SCRIPT,
debug=False,
strip=False,
upx=True,
icon=ICONS_FILE,
console=False,
target_arch='x86_64', # TODO investigate building 'universal2'
)
app = BUNDLE(
exe,
a.binaries,
a.zipfiles,
a.datas,
version=VERSION,
name=PACKAGE_NAME,
icon=ICONS_FILE,
bundle_identifier=None,
info_plist={
'NSHighResolutionCapable': 'True',
'NSSupportsAutomaticGraphicsSwitching': 'True',
'CFBundleURLTypes':
[{
'CFBundleURLName': 'bitcoin',
'CFBundleURLSchemes': ['bitcoin', 'lightning', ],
}],
'LSMinimumSystemVersion': '11',
'NSCameraUsageDescription': 'Electrum would like to access the camera to scan for QR codes',
},
)
================================================
FILE: contrib/osx/sign_osx.sh
================================================
#!/usr/bin/env bash
set -e
security -v unlock-keychain login.keychain
PACKAGE=Electrum
. "$(dirname "$0")/../build_tools_util.sh"
CONTRIB_OSX="$(dirname "$(realpath "$0")")"
CONTRIB="$CONTRIB_OSX/.."
PROJECT_ROOT="$CONTRIB/.."
CACHEDIR="$CONTRIB_OSX/.cache"
cd "$PROJECT_ROOT"
# Code Signing: See https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html
if [ -n "$CODESIGN_CERT" ]; then
# Test the identity is valid for signing by doing this hack. There is no other way to do this.
cp -f /bin/ls ./CODESIGN_TEST
set +e
codesign -s "$CODESIGN_CERT" --dryrun -f ./CODESIGN_TEST > /dev/null 2>&1
res=$?
set -e
rm -f ./CODESIGN_TEST
if ((res)); then
fail "Code signing identity \"$CODESIGN_CERT\" appears to be invalid."
fi
unset res
info "Code signing enabled using identity \"$CODESIGN_CERT\""
else
fail "Code signing DISABLED. Specify a valid macOS Developer identity installed on the system to enable signing."
fi
function DoCodeSignMaybe { # ARGS: infoName fileOrDirName
infoName="$1"
file="$2"
deep=""
if [ -z "$CODESIGN_CERT" ]; then
# no cert -> we won't codesign
return
fi
if [ -d "$file" ]; then
deep="--deep"
fi
if [ -z "$infoName" ] || [ -z "$file" ] || [ ! -e "$file" ]; then
fail "Argument error to internal function DoCodeSignMaybe()"
fi
hardened_arg="--entitlements=${CONTRIB_OSX}/entitlements.plist -o runtime"
info "Code signing ${infoName}..."
codesign -f -v $deep -s "$CODESIGN_CERT" $hardened_arg "$file" || fail "Could not code sign ${infoName}"
}
# note: no --dirty, as we have dirtied electrum/locale/ ourselves.
VERSION=$(git describe --tags --always)
DoCodeSignMaybe "app bundle" "dist/${PACKAGE}.app"
if [ ! -z "$CODESIGN_CERT" ]; then
if [ ! -z "$APPLE_ID_USER" ]; then
info "Notarizing .app with Apple's central server..."
"${CONTRIB_OSX}/notarize_app.sh" "dist/${PACKAGE}.app" || fail "Could not notarize binary."
else
warn "AppleID details not set! Skipping Apple notarization."
fi
fi
info "Creating .DMG"
hdiutil create -fs HFS+ -volname $PACKAGE -srcfolder dist/$PACKAGE.app dist/electrum-$VERSION.dmg || fail "Could not create .DMG"
DoCodeSignMaybe ".DMG" "dist/electrum-${VERSION}.dmg"
================================================
FILE: contrib/print_electrum_version.py
================================================
#!/usr/bin/python3
# For usage in shell, to get the version of electrum, without needing electrum installed.
# usage: ./print_electrum_version.py []
#
# For example:
# $ VERSION=$("$CONTRIB"/print_electrum_version.py)
# instead of
# $ VERSION=$(python3 -c "import electrum; print(electrum.version.ELECTRUM_VERSION)")
import importlib.util
import os
import sys
if __name__ == '__main__':
if len(sys.argv) >= 2:
attr_name = sys.argv[1]
else:
attr_name = "ELECTRUM_VERSION"
project_root = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
version_file_path = os.path.join(project_root, "electrum", "version.py")
# load version.py; needlessly complicated alternative to "imp.load_source":
version_spec = importlib.util.spec_from_file_location('version', version_file_path)
version_module = version = importlib.util.module_from_spec(version_spec)
version_spec.loader.exec_module(version_module)
attr_val = getattr(version, attr_name)
print(attr_val, file=sys.stdout)
================================================
FILE: contrib/release.sh
================================================
#!/bin/bash
#
# This script is used for stage 1 of the release process. It operates exclusively on the airlock.
# This script, for the RELEASEMANAGER (RM):
# - builds and uploads all binaries to airlock,
# - assumes all keys are available, and signs everything
# This script, for other builders:
# - builds all reproducible binaries,
# - downloads binaries built by the release manager (from airlock if SFTPUSER, else from website),
# compares and signs them,
# - and then uploads sigs (if SFTPUSER), else they can be submitted as PR to spesmilo/electrum-signatures
# Note: the .dmg should be built separately beforehand and copied into dist/
# (as it is built on a separate machine)
#
#
# env vars:
# - ELECBUILD_NOCACHE: if set, forces rebuild of docker images
#
# "uploadserver" is set in /etc/hosts
#
# Note: steps before doing a new release:
# - update locale:
# 1. cd /opt/electrum-locale && ./update.py && git push
# 2. cd to the submodule dir, and git pull
# 3. cd .. && git push
# - update RELEASE-NOTES and version.py
# - $ git tag -s "$VERSION" -m "$VERSION"
# - $ git push "$REMOTE_ORIGIN" tag "$VERSION"
#
# -----
# Then, typical release flow:
# - RM runs release.sh
# - Another SFTPUSER BUILDER runs `$ ./release.sh`
# - now airlock contains new binaries and two sigs for each
# - deploy.sh will verify sigs and move binaries across airlock
# - new binaries are now publicly available on uploadserver, but not linked from website yet
# - other BUILDERS can now also try to reproduce binaries and open PRs with sigs against spesmilo/electrum-signatures
# - these PRs can get merged as they come
# - run add_cosigner
# - after some time, RM can run release_www.sh to create and commit website-update
# - then run WWW_DIR/publish.sh to update website
# - at least two people need to run WWW_DIR/publish.sh
#
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/.."
CONTRIB="$PROJECT_ROOT/contrib"
cd "$PROJECT_ROOT"
. "$CONTRIB"/build_tools_util.sh
# rm -rf dist/*
# rm -f .buildozer
GPGUSER=$1
if [ -z "$GPGUSER" ]; then
fail "usage: $0 gpg_username"
fi
RELEASEMANAGER=""
if [ "$GPGUSER" == "ThomasV" ]; then
PUBKEY="--local-user 6694D8DE7BE8EE5631BED9502BD5824B7F9470E6"
export SSHUSER=thomasv
RELEASEMANAGER=1
elif [ "$GPGUSER" == "sombernight_releasekey" ]; then
PUBKEY="--local-user 0EEDCFD5CAFB459067349B23CA9EEEC43DF911DC"
export SSHUSER=sombernight
else
warn "unexpected GPGUSER=$GPGUSER"
PUBKEY=""
export SSHUSER=""
fi
if [ ! -z "$RELEASEMANAGER" ] ; then
echo -n "Code signing passphrase:"
read -s password
# tests password against keystore
keytool -list -storepass $password
# the same password is used for windows signing
export WIN_SIGNING_PASSWORD=$password
fi
VERSION=$("$CONTRIB"/print_electrum_version.py)
info "VERSION: $VERSION"
REV=$(git describe --tags)
info "REV: $REV"
COMMIT=$(git rev-parse HEAD)
export ELECBUILD_COMMIT="${COMMIT}^{commit}"
git_status=$(git status --porcelain)
if [ ! -z "$git_status" ]; then
echo "$git_status"
fail "git repo not clean, aborting"
fi
set -x
# create tarball
tarball="Electrum-$VERSION.tar.gz"
if test -f "dist/$tarball"; then
info "file exists: $tarball"
else
./contrib/build-linux/sdist/build.sh
fi
# create source-only tarball
srctarball="Electrum-sourceonly-$VERSION.tar.gz"
if test -f "dist/$srctarball"; then
info "file exists: $srctarball"
else
OMIT_UNCLEAN_FILES=1 ./contrib/build-linux/sdist/build.sh
fi
# appimage
appimage="electrum-$REV-x86_64.AppImage"
if test -f "dist/$appimage"; then
info "file exists: $appimage"
else
./contrib/build-linux/appimage/build.sh
fi
# windows
win1="electrum-$REV.exe"
win2="electrum-$REV-portable.exe"
win3="electrum-$REV-setup.exe"
if test -f "dist/$win1"; then
info "file exists: $win1"
else
pushd .
if test -f "contrib/build-wine/dist/$win1"; then
info "unsigned file exists: $win1"
else
./contrib/build-wine/build.sh
fi
cd contrib/build-wine/
if [ ! -z "$RELEASEMANAGER" ] ; then
./sign.sh
cp ./signed/*.exe "$PROJECT_ROOT/dist/"
else
cp ./dist/*.exe "$PROJECT_ROOT/dist/"
fi
popd
fi
# android
apk1="Electrum-$VERSION-armeabi-v7a-release.apk"
apk2="Electrum-$VERSION-arm64-v8a-release.apk"
apk3="Electrum-$VERSION-x86_64-release.apk"
for arch in armeabi-v7a arm64-v8a x86_64
do
apk="Electrum-$VERSION-$arch-release.apk"
apk_unsigned="Electrum-$VERSION-$arch-release-unsigned.apk"
if test -f "dist/$apk"; then
info "file exists: $apk"
else
info "file does not exists: $apk"
if [ ! -z "$RELEASEMANAGER" ] ; then
./contrib/android/build.sh qml $arch release $password
else
./contrib/android/build.sh qml $arch release-unsigned
mv "dist/$apk_unsigned" "dist/$apk"
fi
fi
done
# the macos binary is built on a separate machine.
# the file that needs to be copied over is the codesigned release binary (regardless of builder role)
dmg="electrum-$VERSION.dmg"
if ! test -f "dist/$dmg"; then
if [ ! -z "$RELEASEMANAGER" ] ; then # RM
fail "dmg is missing, aborting. Please build and codesign the dmg on a mac and copy it over."
else # other builders
fail "dmg is missing, aborting. Please build the unsigned dmg on a mac, compare it with file built by RM, and if matches, copy RM's dmg."
fi
fi
# now that we have all binaries, if we are the RM, sign them.
if [ ! -z "$RELEASEMANAGER" ] ; then
if test -f "dist/$dmg.asc"; then
info "packages are already signed"
else
info "signing packages"
./contrib/sign_packages "$GPGUSER"
fi
fi
info "build complete"
sha256sum dist/*.tar.gz
sha256sum dist/*.AppImage
sha256sum contrib/build-wine/dist/*.exe
echo -n "proceed (y/n)? "
read answer
if [ "$answer" != "y" ]; then
echo "exit"
exit 1
fi
if [ -z "$RELEASEMANAGER" ] ; then
# people OTHER THAN release manager.
# download binaries built by RM
rm -rf "$PROJECT_ROOT/dist/releasemanager"
mkdir --parent "$PROJECT_ROOT/dist/releasemanager"
cd "$PROJECT_ROOT/dist/releasemanager"
if [ -z "$SSHUSER" ]; then
info "No SFTP access, downloading binaries from website"
BASE_URL="https://download.electrum.org/$VERSION"
FILES_TO_DOWNLOAD=(
"$tarball"
"$srctarball"
"$appimage"
"$win1"
"$win2"
"$win3"
"$apk1"
"$apk2"
"$apk3"
"$dmg"
)
for filename in "${FILES_TO_DOWNLOAD[@]}"; do
if [ ! -f "$filename" ]; then
info "Downloading $filename..."
wget -q "$BASE_URL/$filename" -O "$filename" || fail "Failed to download $filename"
else
info "File already exists: $filename"
fi
done
else
# TODO check somehow that RM had finished uploading
sftp -oBatchMode=no -b - "$SSHUSER@uploadserver" <<-EOF
cd electrum-downloads-airlock
cd "$VERSION"
mget *
bye
EOF
fi
# check we have each binary
test -f "$tarball" || fail "tarball not found among sftp downloads"
test -f "$srctarball" || fail "srctarball not found among sftp downloads"
test -f "$appimage" || fail "appimage not found among sftp downloads"
test -f "$win1" || fail "win1 not found among sftp downloads"
test -f "$win2" || fail "win2 not found among sftp downloads"
test -f "$win3" || fail "win3 not found among sftp downloads"
test -f "$apk1" || fail "apk1 not found among sftp downloads"
test -f "$apk2" || fail "apk2 not found among sftp downloads"
test -f "$apk3" || fail "apk3 not found among sftp downloads"
test -f "$dmg" || fail "dmg not found among sftp downloads"
test -f "$PROJECT_ROOT/dist/$tarball" || fail "tarball not found among built files"
test -f "$PROJECT_ROOT/dist/$srctarball" || fail "srctarball not found among built files"
test -f "$PROJECT_ROOT/dist/$appimage" || fail "appimage not found among built files"
test -f "$CONTRIB/build-wine/dist/$win1" || fail "win1 not found among built files"
test -f "$CONTRIB/build-wine/dist/$win2" || fail "win2 not found among built files"
test -f "$CONTRIB/build-wine/dist/$win3" || fail "win3 not found among built files"
test -f "$PROJECT_ROOT/dist/$apk1" || fail "apk1 not found among built files"
test -f "$PROJECT_ROOT/dist/$apk2" || fail "apk2 not found among built files"
test -f "$PROJECT_ROOT/dist/$apk3" || fail "apk3 not found among built files"
test -f "$PROJECT_ROOT/dist/$dmg" || fail "dmg not found among built files"
# compare downloaded binaries against ones we built
cmp --silent "$tarball" "$PROJECT_ROOT/dist/$tarball" || fail "files are different. tarball."
cmp --silent "$srctarball" "$PROJECT_ROOT/dist/$srctarball" || fail "files are different. srctarball."
cmp --silent "$appimage" "$PROJECT_ROOT/dist/$appimage" || fail "files are different. appimage."
rm -rf "$CONTRIB/build-wine/signed/" && mkdir --parents "$CONTRIB/build-wine/signed/"
cp -f "$win1" "$win2" "$win3" "$CONTRIB/build-wine/signed/"
"$CONTRIB/build-wine/unsign.sh" || fail "files are different. windows."
"$CONTRIB/android/apkdiff.py" "$apk1" "$PROJECT_ROOT/dist/$apk1" || fail "files are different. android."
"$CONTRIB/android/apkdiff.py" "$apk2" "$PROJECT_ROOT/dist/$apk2" || fail "files are different. android."
"$CONTRIB/android/apkdiff.py" "$apk3" "$PROJECT_ROOT/dist/$apk3" || fail "files are different. android."
cmp --silent "$dmg" "$PROJECT_ROOT/dist/$dmg" || fail "files are different. macos."
# all files matched. sign them.
rm -rf "$PROJECT_ROOT/dist/sigs/"
mkdir --parents "$PROJECT_ROOT/dist/sigs/"
for fname in "$tarball" "$srctarball" "$appimage" "$win1" "$win2" "$win3" "$apk1" "$apk2" "$apk3" "$dmg" ; do
signame="$fname.$GPGUSER.asc"
gpg --sign --armor --detach $PUBKEY --output "$PROJECT_ROOT/dist/sigs/$signame" "$fname"
done
if [ -z "$SSHUSER" ]; then
info "Signing successfully, now open a pull request with your signatures to spesmilo/electrum-signatures"
exit 0
else
# upload sigs
ELECBUILD_UPLOADFROM="$PROJECT_ROOT/dist/sigs/" "$CONTRIB/upload.sh"
fi
else
# ONLY release manager
cd "$PROJECT_ROOT"
# check we have each binary
test -f "$PROJECT_ROOT/dist/$tarball" || fail "tarball not found among built files"
test -f "$PROJECT_ROOT/dist/$srctarball" || fail "srctarball not found among built files"
test -f "$PROJECT_ROOT/dist/$appimage" || fail "appimage not found among built files"
test -f "$PROJECT_ROOT/dist/$win1" || fail "win1 not found among built files"
test -f "$PROJECT_ROOT/dist/$win2" || fail "win2 not found among built files"
test -f "$PROJECT_ROOT/dist/$win3" || fail "win3 not found among built files"
test -f "$PROJECT_ROOT/dist/$apk1" || fail "apk1 not found among built files"
test -f "$PROJECT_ROOT/dist/$apk2" || fail "apk2 not found among built files"
test -f "$PROJECT_ROOT/dist/$apk3" || fail "apk3 not found among built files"
test -f "$PROJECT_ROOT/dist/$dmg" || fail "dmg not found among built files"
if [ "$REV" != "$VERSION" ]; then
fail "versions differ, not uploading"
fi
# upload the files
./contrib/upload.sh
fi
set +x
info "release.sh finished successfully."
info "After two people ran release.sh, the binaries will be publicly available on uploadserver."
info "Then, we wait for additional signers, and run add_cosigner for them."
info "Finally, release_www.sh needs to be run, for the website to be updated."
================================================
FILE: contrib/release_www.sh
================================================
#!/bin/bash
#
# env vars:
# - WWW_DIR: path to "electrum-web" git clone
# - for signing the version announcement file:
# - ELECTRUM_SIGNING_ADDRESS (required)
# - ELECTRUM_SIGNING_WALLET (required)
#
set -e
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/.."
CONTRIB="$PROJECT_ROOT/contrib"
cd "$PROJECT_ROOT"
. "$CONTRIB"/build_tools_util.sh
echo -n "Remember to run add_cosigner to add any additional sigs. Continue (y/n)? "
read answer
if [ "$answer" != "y" ]; then
echo "exit"
exit 1
fi
if [ -z "$WWW_DIR" ] ; then
WWW_DIR=/opt/electrum-web
fi
if [ -z "$ELECTRUM_SIGNING_WALLET" ] || [ -z "$ELECTRUM_SIGNING_ADDRESS" ]; then
echo "You need to set env vars ELECTRUM_SIGNING_WALLET and ELECTRUM_SIGNING_ADDRESS!"
exit 1
fi
VERSION=$("$CONTRIB"/print_electrum_version.py)
info "VERSION: $VERSION"
ANDROID_VERSIONCODE_NULLARCH=$("$CONTRIB"/android/get_apk_versioncode.py "null")
# ^ note: should parse as an integer in the final json
info "ANDROID_VERSIONCODE_NULLARCH: $ANDROID_VERSIONCODE_NULLARCH"
set -x
info "updating www repo"
./contrib/make_download "$WWW_DIR"
info "signing the version announcement file"
sig=$(./run_electrum -o signmessage "$ELECTRUM_SIGNING_ADDRESS" "$VERSION" -w "$ELECTRUM_SIGNING_WALLET")
# note: the contents of "extradata" are currently not signed. We could add another field, extradata_sigs,
# containing signature(s) for "extradata". extradata, being json, would have to be canonically
# serialized before signing.
cat < "$WWW_DIR"/version
{
"version": "$VERSION",
"signatures": {"$ELECTRUM_SIGNING_ADDRESS": "$sig"},
"extradata": {
"android_versioncode_nullarch": $ANDROID_VERSIONCODE_NULLARCH
}
}
EOF
# push changes to website repo
pushd "$WWW_DIR"
git diff
git commit -a -m "version $VERSION"
git push
popd
info "release_www.sh finished successfully."
info "now you should run WWW_DIR/publish.sh to sign the website commit and upload signature"
================================================
FILE: contrib/requirements/requirements-binaries-mac.txt
================================================
# Qt 6.8 would require macOS 12+, 6.7 still supports macOS 11
# Qt 6.7 has issue "No QtMultimedia backends found." (i.e. camera does not work)
# PyQt6-Qt6==6.6.3 segfaults with "illegal hardware instruction"
PyQt6<6.7
PyQt6-Qt6<6.7,!=6.6.3
cryptography>=2.6
================================================
FILE: contrib/requirements/requirements-binaries.txt
================================================
PyQt6
# we need at least cryptography>=2.1 for electrum.crypto,
# and at least cryptography>=2.6 for dnspython[DNSSEC]
cryptography>=2.6
================================================
FILE: contrib/requirements/requirements-build-android.txt
================================================
pip
setuptools
wheel
# needed by buildozer:
pexpect
sh
# some p4a recipes don't work with cython 3+
cython<3.0
# needed by python-for-android:
appdirs
# colorama upper bound to avoid needing hatchling
colorama>=0.3.3,<0.4.6
jinja2
sh>=1.10
pep517
toml
# needed for the Qt/QML Android GUI:
# TODO double-check this
typing-extensions
================================================
FILE: contrib/requirements/requirements-build-appimage.txt
================================================
pip
setuptools
wheel
# Note: hidapi requires Cython at build-time (not needed at runtime).
# For reproducible builds, the version of Cython must be pinned down.
# The pinned Cython must be installed before hidapi is built;
# otherwise when installing hidapi, pip just downloads the latest Cython.
# see https://github.com/spesmilo/electrum/issues/5859
Cython>=0.27
================================================
FILE: contrib/requirements/requirements-build-base.txt
================================================
# This file contains build-time dependencies needed to build other higher level build-time dependencies
# and runtime dependencies.
# For reproducibility, some build-time deps, most notably "wheel", need to be pinned. (see #7640)
# By default, when doing e.g. "pip install", pip downloads the latest version of wheel (and setuptools, etc),
# regardless whether a sufficiently recent version of wheel is already installed locally...
# The only way I have found to avoid this, is to use the "--no-build-isolation" flag,
# in which case it becomes our responsibility to install *all* build time deps...
pip
setuptools
wheel
# importlib_metadata also needs:
# https://github.com/python/importlib_metadata/blob/1e2381fe101fd70742a0171e51c1be82aedf519b/pyproject.toml#L2
setuptools_scm[toml]>=3.4.1
# from https://github.com/pypa/setuptools-scm/commit/c766df10c18c3c5a6b5741e9f372e193412c0f69 :
# (but also to avoid the binary wheels introduced in tomli 2.2)
tomli<=2.0.2
# dnspython also needs:
# https://github.com/rthalley/dnspython/blob/1a7c14fb6c200be02ef5c2f3bb9fd84b85004459/pyproject.toml#L64
poetry-core
# typing-extensions also needs:
# https://github.com/python/typing/blob/a2371460d184c96aab7a69acc47fd059f875e3b4/typing_extensions/pyproject.toml#L3
flit_core>=3.4,<4
# aio-libs/frozenlist and aio-libs/propcache needs:
# https://github.com/aio-libs/frozenlist/blob/c28f32d6816ca0fa56a5876e84831c46084bb85d/pyproject.toml#L6
expandvars
================================================
FILE: contrib/requirements/requirements-build-mac.txt
================================================
pip
setuptools
wheel
# needed by pyinstaller:
# fixme: ugly to have to duplicate this here from upstream
macholib>=1.8
altgraph
pyinstaller-hooks-contrib>=2025.2
packaging>=22.0
# Note: hidapi requires Cython at build-time (not needed at runtime).
# For reproducible builds, the version of Cython must be pinned down.
# The pinned Cython must be installed before hidapi is built;
# otherwise when installing hidapi, pip just downloads the latest Cython.
# see https://github.com/spesmilo/electrum/issues/5859
Cython>=0.27
================================================
FILE: contrib/requirements/requirements-build-wine.txt
================================================
pip
setuptools
wheel
# needed by pyinstaller:
# fixme: ugly to have to duplicate this here from upstream
pefile>=2022.5.30,!=2024.8.26
altgraph
pywin32-ctypes>=0.2.1
pyinstaller-hooks-contrib>=2025.2
packaging>=22.0
================================================
FILE: contrib/requirements/requirements-ci.txt
================================================
pytest
coverage
coveralls
================================================
FILE: contrib/requirements/requirements-hw.txt
================================================
hidapi
# device plugin: trezor
trezor[hidapi]>=0.13.0,<0.14
# device plugin: safe_t
safet>=0.1.5
# device plugin: keepkey
ecdsa>=0.9
protobuf>=3.20
mnemonic>=0.8
hidapi>=0.7.99.post15
libusb1>=1.6
# device plugin: ledger
ledger-bitcoin>=0.2.0,<1.0
hidapi
# device plugin: coldcard
ckcc-protocol>=0.7.7
# device plugin: bitbox02
bitbox02>=7.0.0
# device plugin: jade
cbor2>=5.4.6,<6.0.0
pyserial>=3.5.0,<4.0.0
# prefer older urllib3 to avoid needing hatchling
# (pulled in via trezor -> requests -> urllib3)
urllib3<2
================================================
FILE: contrib/requirements/requirements.txt
================================================
qrcode
protobuf>=3.20
qdarkstyle>=3.2
aiorpcx>=0.25.0,<0.26
aiohttp>=3.11.0,<4.0.0
aiohttp_socks>=0.9.2
certifi
jsonpatch
electrum_ecc>=0.0.4,<0.1
electrum_aionostr>=0.1.0,<0.2
# - upper limit to avoid needing hatchling at build-time :/
# (however newer versions should work at runtime)
attrs>=20.1.0,<23
# Note that we also need the dnspython[DNSSEC] extra which pulls in cryptography,
# but as that is not pure-python it cannot be listed in this file!
# - upper limit to avoid needing hatchling at build-time :/
# (however newer versions should work at runtime)
dnspython>=2.2,<2.5
================================================
FILE: contrib/sign_packages
================================================
#!/usr/bin/env python3
import os, sys
if __name__ == '__main__':
username = sys.argv[1]
os.chdir("dist")
for fname in os.listdir('.'):
if fname.endswith('asc'):
continue
sig_name = fname + '.' + username + '.asc'
os.system(f"gpg --sign --armor --detach --output {sig_name} {fname}")
os.chdir("..")
================================================
FILE: contrib/trigger_deploy.sh
================================================
#!/bin/bash
# Triggers deploy.sh to maybe update the website or move binaries.
# uploadserver needs to be defined in /etc/hosts
SSHUSER=$1
TRIGGERVERSION=$2
if [ -z "$SSHUSER" ] || [ -z "$TRIGGERVERSION" ]; then
echo "usage: $0 SSHUSER TRIGGERVERSION"
echo "e.g. $0 thomasv 3.0.0"
echo "e.g. $0 thomasv website"
exit 1
fi
set -ex
cd "$(dirname "$0")"
if [ "$TRIGGERVERSION" == "website" ]; then
rm -f trigger_website
touch trigger_website
echo "uploading file: trigger_website..."
sftp -oBatchMode=no -b - "$SSHUSER@uploadserver" << !
cd electrum-downloads-airlock
mput trigger_website
bye
!
else
rm -f trigger_binaries
printf "$TRIGGERVERSION" > trigger_binaries
echo "uploading file: trigger_binaries..."
sftp -oBatchMode=no -b - "$SSHUSER@uploadserver" << !
cd electrum-downloads-airlock
mput trigger_binaries
bye
!
fi
================================================
FILE: contrib/udev/20-hw1.rules
================================================
# HW.1, Nano
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2581", ATTRS{idProduct}=="1b7c|2b7c|3b7c|4b7c", TAG+="uaccess", TAG+="udev-acl"
# Blue, NanoS, Aramis, HW.2, Nano X, NanoSP, Stax, Ledger Test,
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", TAG+="uaccess", TAG+="udev-acl"
# Same, but with hidraw-based library (instead of libusb)
KERNEL=="hidraw*", ATTRS{idVendor}=="2c97", MODE="0666"
================================================
FILE: contrib/udev/51-coinkite.rules
================================================
# Linux udev support file.
#
# This is a example udev file for HIDAPI devices which changes the permissions
# to 0666 (world readable/writable) for a specific device on Linux systems.
#
# - Copy this file into /etc/udev/rules.d and unplug and re-plug your Coldcard.
# - Udev does not have to be restarted.
#
# probably not needed:
SUBSYSTEMS=="usb", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0666"
# required:
# from
KERNEL=="hidraw*", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0666"
================================================
FILE: contrib/udev/51-hid-digitalbitbox.rules
================================================
SUBSYSTEM=="usb", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="dbb%n", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2402"
================================================
FILE: contrib/udev/51-safe-t.rules
================================================
# Put this file into /usr/lib/udev/rules.d or /etc/udev/rules.d
# Archos Safe-T mini
SUBSYSTEM=="usb", ATTR{idVendor}=="0e79", ATTR{idProduct}=="6000", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="safe-tr%n"
KERNEL=="hidraw*", ATTRS{idVendor}=="0e79", ATTRS{idProduct}=="6000", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
# Archos Safe-T mini Bootloader
SUBSYSTEM=="usb", ATTR{idVendor}=="0e79", ATTR{idProduct}=="6001", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="safe-t%n"
KERNEL=="hidraw*", ATTRS{idVendor}=="0e79", ATTRS{idProduct}=="6001", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
================================================
FILE: contrib/udev/51-trezor.rules
================================================
# Trezor: The Original Hardware Wallet
# https://trezor.io/
#
# Put this file into /etc/udev/rules.d
#
# If you are creating a distribution package,
# put this into /usr/lib/udev/rules.d or /lib/udev/rules.d
# depending on your distribution
# Trezor
SUBSYSTEM=="usb", ATTR{idVendor}=="534c", ATTR{idProduct}=="0001", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="trezor%n"
KERNEL=="hidraw*", ATTRS{idVendor}=="534c", ATTRS{idProduct}=="0001", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
# Trezor v2
SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="53c0", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="trezor%n"
SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="53c1", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="trezor%n"
KERNEL=="hidraw*", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="53c1", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
================================================
FILE: contrib/udev/51-usb-keepkey.rules
================================================
# KeepKey: Your Private Bitcoin Vault
# http://www.keepkey.com/
# Put this file into /usr/lib/udev/rules.d or /etc/udev/rules.d
# KeepKey HID Firmware/Bootloader
SUBSYSTEM=="usb", ATTR{idVendor}=="2b24", ATTR{idProduct}=="0001", MODE="0666", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="keepkey%n"
KERNEL=="hidraw*", ATTRS{idVendor}=="2b24", ATTRS{idProduct}=="0001", MODE="0666", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
# KeepKey WebUSB Firmware/Bootloader
SUBSYSTEM=="usb", ATTR{idVendor}=="2b24", ATTR{idProduct}=="0002", MODE="0666", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="keepkey%n"
KERNEL=="hidraw*", ATTRS{idVendor}=="2b24", ATTRS{idProduct}=="0002", MODE="0666", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
================================================
FILE: contrib/udev/52-hid-digitalbitbox.rules
================================================
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2402", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="dbbf%n"
================================================
FILE: contrib/udev/53-hid-bitbox02.rules
================================================
SUBSYSTEM=="usb", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02_%n", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403"
================================================
FILE: contrib/udev/54-hid-bitbox02.rules
================================================
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02-%n"
================================================
FILE: contrib/udev/55-usb-jade.rules
================================================
KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n"
KERNEL=="ttyACM*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d4", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n"
================================================
FILE: contrib/udev/README.md
================================================
# udev rules
This directory contains all of the udev rules for the supported devices
as retrieved from vendor websites and repositories.
These are necessary for the devices to be usable on Linux environments.
- `20-hw1.rules` (Ledger): https://github.com/LedgerHQ/udev-rules/blob/master/20-hw1.rules
- `51-coinkite.rules` (Coldcard): https://github.com/Coldcard/ckcc-protocol/blob/master/51-coinkite.rules
- `51-hid-digitalbitbox.rules`, `52-hid-digitalbitbox.rules` (Digital Bitbox): https://github.com/digitalbitbox/bitbox-wallet-app/blob/master/frontends/qt/resources/deb-afterinstall.sh
- `53-hid-bitbox02.rules`, `54-hid-bitbox02.rules` (BitBox02): https://github.com/digitalbitbox/bitbox-wallet-app/blob/master/frontends/qt/resources/deb-afterinstall.sh
- `51-trezor.rules` (Trezor): https://github.com/trezor/trezor-common/blob/master/udev/51-trezor.rules
- `51-usb-keepkey.rules` (Keepkey): https://github.com/keepkey/udev-rules/blob/master/51-usb-keepkey.rules
- `51-safe-t.rules` (Archos): https://github.com/archos-safe-t/safe-t-common/blob/master/udev/51-safe-t.rules
- `55-usb-jade.rules` (Blockstream Jade): https://github.com/Blockstream/Jade
# Usage
Apply these rules by copying them to `/etc/udev/rules.d/` and notifying `udevadm`.
Your user will need to be added to the `plugdev` group, which needs to be created if it does not already exist.
```
$ sudo groupadd plugdev
$ sudo usermod -aG plugdev $(whoami)
$ sudo cp contrib/udev/*.rules /etc/udev/rules.d/
$ sudo udevadm control --reload-rules && sudo udevadm trigger
```
================================================
FILE: contrib/upload.sh
================================================
#!/bin/bash
# uploadserver is set in /etc/hosts
#
# env vars:
# - ELECBUILD_UPLOADFROM
# - SSHUSER
set -ex
PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/.."
CONTRIB="$PROJECT_ROOT/contrib"
if [ -z "$SSHUSER" ]; then
SSHUSER=thomasv
fi
cd "$PROJECT_ROOT"
VERSION=$("$CONTRIB"/print_electrum_version.py)
echo "$VERSION"
if [ -z "$ELECBUILD_UPLOADFROM" ]; then
cd "$PROJECT_ROOT/dist"
else
cd "$ELECBUILD_UPLOADFROM"
fi
# do not fail sftp if directory exists
# see https://stackoverflow.com/questions/51437924/bash-shell-sftp-check-if-directory-exists-before-creating
sftp -oBatchMode=no -b - "$SSHUSER@uploadserver" << !
cd electrum-downloads-airlock
-mkdir "$VERSION"
-chmod 777 "$VERSION"
cd "$VERSION"
-mput *
-chmod 444 * # this prevents future re-uploads of same file
bye
!
"$CONTRIB/trigger_deploy.sh" "$SSHUSER" "$VERSION"
================================================
FILE: electrum/__init__.py
================================================
import sys
import os
# these are ~duplicated from run_electrum:
is_bundle = getattr(sys, 'frozen', False)
is_local = not is_bundle and os.path.exists(os.path.join(os.path.dirname(os.path.dirname(__file__)), "electrum.desktop"))
# when running from source, on Windows, also search for DLLs in inner 'electrum' folder
if is_local and os.name == 'nt': # fixme: duplicated between main script and __init__.py :(
os.add_dll_directory(os.path.dirname(__file__))
class GuiImportError(ImportError):
pass
from .version import ELECTRUM_VERSION
from .util import format_satoshis
from .wallet import Wallet
from .storage import WalletStorage
from .coinchooser import COIN_CHOOSERS
from .network import Network, pick_random_server
from .interface import Interface
from .simple_config import SimpleConfig
from . import bitcoin
from . import transaction
from . import daemon
from .transaction import Transaction
from .plugin import BasePlugin
from .commands import Commands, known_commands
from .logging import get_logger
__version__ = ELECTRUM_VERSION
_logger = get_logger(__name__)
# Ensure that asserts are enabled. For sanity and paranoia, we require this.
# Code *should not rely* on asserts being enabled. In particular, safety and security checks should
# always explicitly raise exceptions. However, this rule is mistakenly broken occasionally...
try:
assert False # noqa: B011
except AssertionError:
pass
else:
raise ImportError("Running with asserts disabled. Refusing to continue. Exiting...")
# Check that os.urandom works
import zlib
length = len(zlib.compress(os.urandom(1000)))
if length <= 900:
raise ImportError("Broken PRNG. Refusing to continue. Exiting...")
================================================
FILE: electrum/_vendor/__init__.py
================================================
================================================
FILE: electrum/_vendor/distutils/LICENSE
================================================
A. HISTORY OF THE SOFTWARE
==========================
Python was created in the early 1990s by Guido van Rossum at Stichting
Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands
as a successor of a language called ABC. Guido remains Python's
principal author, although it includes many contributions from others.
In 1995, Guido continued his work on Python at the Corporation for
National Research Initiatives (CNRI, see http://www.cnri.reston.va.us)
in Reston, Virginia where he released several versions of the
software.
In May 2000, Guido and the Python core development team moved to
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
year, the PythonLabs team moved to Digital Creations, which became
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
https://www.python.org/psf/) was formed, a non-profit organization
created specifically to own Python-related Intellectual Property.
Zope Corporation was a sponsoring member of the PSF.
All Python releases are Open Source (see http://www.opensource.org for
the Open Source Definition). Historically, most, but not all, Python
releases have also been GPL-compatible; the table below summarizes
the various releases.
Release Derived Year Owner GPL-
from compatible? (1)
0.9.0 thru 1.2 1991-1995 CWI yes
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
1.6 1.5.2 2000 CNRI no
2.0 1.6 2000 BeOpen.com no
1.6.1 1.6 2001 CNRI yes (2)
2.1 2.0+1.6.1 2001 PSF no
2.0.1 2.0+1.6.1 2001 PSF yes
2.1.1 2.1+2.0.1 2001 PSF yes
2.1.2 2.1.1 2002 PSF yes
2.1.3 2.1.2 2002 PSF yes
2.2 and above 2.1.1 2001-now PSF yes
Footnotes:
(1) GPL-compatible doesn't mean that we're distributing Python under
the GPL. All Python licenses, unlike the GPL, let you distribute
a modified version without making your changes open source. The
GPL-compatible licenses make it possible to combine Python with
other software that is released under the GPL; the others don't.
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
because its license has a choice of law clause. According to
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
is "not incompatible" with the GPL.
Thanks to the many outside volunteers who have worked under Guido's
direction to make these releases possible.
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
===============================================================
Python software and documentation are licensed under the
Python Software Foundation License Version 2.
Starting with Python 3.8.6, examples, recipes, and other code in
the documentation are dual licensed under the PSF License Version 2
and the Zero-Clause BSD license.
Some software incorporated into Python is under different licenses.
The licenses are listed with code falling under that license.
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
--------------------------------------------
1. This LICENSE AGREEMENT is between the Python Software Foundation
("PSF"), and the Individual or Organization ("Licensee") accessing and
otherwise using this software ("Python") in source or binary form and
its associated documentation.
2. Subject to the terms and conditions of this License Agreement, PSF hereby
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
analyze, test, perform and/or display publicly, prepare derivative works,
distribute, and otherwise use Python alone or in any derivative version,
provided, however, that PSF's License Agreement and PSF's notice of copyright,
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022 Python Software Foundation;
All Rights Reserved" are retained in Python alone or in any derivative version
prepared by Licensee.
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python.
4. PSF is making Python available to Licensee on an "AS IS"
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. Nothing in this License Agreement shall be deemed to create any
relationship of agency, partnership, or joint venture between PSF and
Licensee. This License Agreement does not grant permission to use PSF
trademarks or trade name in a trademark sense to endorse or promote
products or services of Licensee, or any third party.
8. By copying, installing or otherwise using Python, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
-------------------------------------------
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
Individual or Organization ("Licensee") accessing and otherwise using
this software in source or binary form and its associated
documentation ("the Software").
2. Subject to the terms and conditions of this BeOpen Python License
Agreement, BeOpen hereby grants Licensee a non-exclusive,
royalty-free, world-wide license to reproduce, analyze, test, perform
and/or display publicly, prepare derivative works, distribute, and
otherwise use the Software alone or in any derivative version,
provided, however, that the BeOpen Python License is retained in the
Software, alone or in any derivative version prepared by Licensee.
3. BeOpen is making the Software available to Licensee on an "AS IS"
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
5. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
6. This License Agreement shall be governed by and interpreted in all
respects by the law of the State of California, excluding conflict of
law provisions. Nothing in this License Agreement shall be deemed to
create any relationship of agency, partnership, or joint venture
between BeOpen and Licensee. This License Agreement does not grant
permission to use BeOpen trademarks or trade names in a trademark
sense to endorse or promote products or services of Licensee, or any
third party. As an exception, the "BeOpen Python" logos available at
http://www.pythonlabs.com/logos.html may be used according to the
permissions granted on that web page.
7. By copying, installing or otherwise using the software, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
---------------------------------------
1. This LICENSE AGREEMENT is between the Corporation for National
Research Initiatives, having an office at 1895 Preston White Drive,
Reston, VA 20191 ("CNRI"), and the Individual or Organization
("Licensee") accessing and otherwise using Python 1.6.1 software in
source or binary form and its associated documentation.
2. Subject to the terms and conditions of this License Agreement, CNRI
hereby grants Licensee a nonexclusive, royalty-free, world-wide
license to reproduce, analyze, test, perform and/or display publicly,
prepare derivative works, distribute, and otherwise use Python 1.6.1
alone or in any derivative version, provided, however, that CNRI's
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
1995-2001 Corporation for National Research Initiatives; All Rights
Reserved" are retained in Python 1.6.1 alone or in any derivative
version prepared by Licensee. Alternately, in lieu of CNRI's License
Agreement, Licensee may substitute the following text (omitting the
quotes): "Python 1.6.1 is made available subject to the terms and
conditions in CNRI's License Agreement. This Agreement together with
Python 1.6.1 may be located on the internet using the following
unique, persistent identifier (known as a handle): 1895.22/1013. This
Agreement may also be obtained from a proxy server on the internet
using the following URL: http://hdl.handle.net/1895.22/1013".
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python 1.6.1 or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python 1.6.1.
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. This License Agreement shall be governed by the federal
intellectual property law of the United States, including without
limitation the federal copyright law, and, to the extent such
U.S. federal law does not apply, by the law of the Commonwealth of
Virginia, excluding Virginia's conflict of law provisions.
Notwithstanding the foregoing, with regard to derivative works based
on Python 1.6.1 that incorporate non-separable material that was
previously distributed under the GNU General Public License (GPL), the
law of the Commonwealth of Virginia shall govern this License
Agreement only as to issues arising under or with respect to
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
License Agreement shall be deemed to create any relationship of
agency, partnership, or joint venture between CNRI and Licensee. This
License Agreement does not grant permission to use CNRI trademarks or
trade name in a trademark sense to endorse or promote products or
services of Licensee, or any third party.
8. By clicking on the "ACCEPT" button where indicated, or by copying,
installing or otherwise using Python 1.6.1, Licensee agrees to be
bound by the terms and conditions of this License Agreement.
ACCEPT
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
--------------------------------------------------
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
The Netherlands. All rights reserved.
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation, and that the name of Stichting Mathematisch
Centrum or CWI not be used in advertising or publicity pertaining to
distribution of the software without specific, written prior
permission.
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
----------------------------------------------------------------------
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
================================================
FILE: electrum/_vendor/distutils/__init__.py
================================================
"""(part of) distutils, taken from the cpython standard library
at commit https://github.com/python/cpython/tree/9d38120e335357a3b294277fd5eff0a10e46e043/Lib/distutils
"""
================================================
FILE: electrum/_vendor/distutils/version.py
================================================
#
# distutils/version.py
#
# Implements multiple version numbering conventions for the
# Python Module Distribution Utilities.
#
# $Id$
#
"""Provides classes to represent module version numbers (one class for
each style of version numbering). There are currently two such classes
implemented: StrictVersion and LooseVersion.
Every version number class implements the following interface:
* the 'parse' method takes a string and parses it to some internal
representation; if the string is an invalid version number,
'parse' raises a ValueError exception
* the class constructor takes an optional string argument which,
if supplied, is passed to 'parse'
* __str__ reconstructs the string that was passed to 'parse' (or
an equivalent string -- ie. one that will generate an equivalent
version number instance)
* __repr__ generates Python code to recreate the version number instance
* _cmp compares the current instance with either another instance
of the same class or a string (which will be parsed to an instance
of the same class, thus must follow the same rules)
"""
import re
class Version:
"""Abstract base class for version numbering classes. Just provides
constructor (__init__) and reproducer (__repr__), because those
seem to be the same for all version numbering classes; and route
rich comparisons to _cmp.
"""
def __init__ (self, vstring=None):
if vstring:
self.parse(vstring)
def __repr__ (self):
return "%s ('%s')" % (self.__class__.__name__, str(self))
def __eq__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c == 0
def __lt__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c < 0
def __le__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c <= 0
def __gt__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c > 0
def __ge__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c >= 0
# Interface for version-number classes -- must be implemented
# by the following classes (the concrete ones -- Version should
# be treated as an abstract class).
# __init__ (string) - create and take same action as 'parse'
# (string parameter is optional)
# parse (string) - convert a string representation to whatever
# internal representation is appropriate for
# this style of version numbering
# __str__ (self) - convert back to a string; should be very similar
# (if not identical to) the string supplied to parse
# __repr__ (self) - generate Python code to recreate
# the instance
# _cmp (self, other) - compare two version numbers ('other' may
# be an unparsed version string, or another
# instance of your version class)
class StrictVersion (Version):
"""Version numbering for anal retentives and software idealists.
Implements the standard interface for version number classes as
described above. A version number consists of two or three
dot-separated numeric components, with an optional "pre-release" tag
on the end. The pre-release tag consists of the letter 'a' or 'b'
followed by a number. If the numeric components of two version
numbers are equal, then one with a pre-release tag will always
be deemed earlier (lesser) than one without.
The following are valid version numbers (shown in the order that
would be obtained by sorting according to the supplied cmp function):
0.4 0.4.0 (these two are equivalent)
0.4.1
0.5a1
0.5b3
0.5
0.9.6
1.0
1.0.4a3
1.0.4b1
1.0.4
The following are examples of invalid version numbers:
1
2.7.2.2
1.3.a4
1.3pl1
1.3c4
The rationale for this version numbering system will be explained
in the distutils documentation.
"""
version_re = re.compile(r'^(\d+) \. (\d+) (\. (\d+))? ([ab](\d+))?$',
re.VERBOSE | re.ASCII)
def parse (self, vstring):
match = self.version_re.match(vstring)
if not match:
raise ValueError("invalid version number '%s'" % vstring)
(major, minor, patch, prerelease, prerelease_num) = \
match.group(1, 2, 4, 5, 6)
if patch:
self.version = tuple(map(int, [major, minor, patch]))
else:
self.version = tuple(map(int, [major, minor])) + (0,)
if prerelease:
self.prerelease = (prerelease[0], int(prerelease_num))
else:
self.prerelease = None
def __str__ (self):
if self.version[2] == 0:
vstring = '.'.join(map(str, self.version[0:2]))
else:
vstring = '.'.join(map(str, self.version))
if self.prerelease:
vstring = vstring + self.prerelease[0] + str(self.prerelease[1])
return vstring
def _cmp (self, other):
if isinstance(other, str):
other = StrictVersion(other)
elif not isinstance(other, StrictVersion):
return NotImplemented
if self.version != other.version:
# numeric versions don't match
# prerelease stuff doesn't matter
if self.version < other.version:
return -1
else:
return 1
# have to compare prerelease
# case 1: neither has prerelease; they're equal
# case 2: self has prerelease, other doesn't; other is greater
# case 3: self doesn't have prerelease, other does: self is greater
# case 4: both have prerelease: must compare them!
if (not self.prerelease and not other.prerelease):
return 0
elif (self.prerelease and not other.prerelease):
return -1
elif (not self.prerelease and other.prerelease):
return 1
elif (self.prerelease and other.prerelease):
if self.prerelease == other.prerelease:
return 0
elif self.prerelease < other.prerelease:
return -1
else:
return 1
else:
assert False, "never get here"
# end class StrictVersion
# The rules according to Greg Stein:
# 1) a version number has 1 or more numbers separated by a period or by
# sequences of letters. If only periods, then these are compared
# left-to-right to determine an ordering.
# 2) sequences of letters are part of the tuple for comparison and are
# compared lexicographically
# 3) recognize the numeric components may have leading zeroes
#
# The LooseVersion class below implements these rules: a version number
# string is split up into a tuple of integer and string components, and
# comparison is a simple tuple comparison. This means that version
# numbers behave in a predictable and obvious way, but a way that might
# not necessarily be how people *want* version numbers to behave. There
# wouldn't be a problem if people could stick to purely numeric version
# numbers: just split on period and compare the numbers as tuples.
# However, people insist on putting letters into their version numbers;
# the most common purpose seems to be:
# - indicating a "pre-release" version
# ('alpha', 'beta', 'a', 'b', 'pre', 'p')
# - indicating a post-release patch ('p', 'pl', 'patch')
# but of course this can't cover all version number schemes, and there's
# no way to know what a programmer means without asking him.
#
# The problem is what to do with letters (and other non-numeric
# characters) in a version number. The current implementation does the
# obvious and predictable thing: keep them as strings and compare
# lexically within a tuple comparison. This has the desired effect if
# an appended letter sequence implies something "post-release":
# eg. "0.99" < "0.99pl14" < "1.0", and "5.001" < "5.001m" < "5.002".
#
# However, if letters in a version number imply a pre-release version,
# the "obvious" thing isn't correct. Eg. you would expect that
# "1.5.1" < "1.5.2a2" < "1.5.2", but under the tuple/lexical comparison
# implemented here, this just isn't so.
#
# Two possible solutions come to mind. The first is to tie the
# comparison algorithm to a particular set of semantic rules, as has
# been done in the StrictVersion class above. This works great as long
# as everyone can go along with bondage and discipline. Hopefully a
# (large) subset of Python module programmers will agree that the
# particular flavour of bondage and discipline provided by StrictVersion
# provides enough benefit to be worth using, and will submit their
# version numbering scheme to its domination. The free-thinking
# anarchists in the lot will never give in, though, and something needs
# to be done to accommodate them.
#
# Perhaps a "moderately strict" version class could be implemented that
# lets almost anything slide (syntactically), and makes some heuristic
# assumptions about non-digits in version number strings. This could
# sink into special-case-hell, though; if I was as talented and
# idiosyncratic as Larry Wall, I'd go ahead and implement a class that
# somehow knows that "1.2.1" < "1.2.2a2" < "1.2.2" < "1.2.2pl3", and is
# just as happy dealing with things like "2g6" and "1.13++". I don't
# think I'm smart enough to do it right though.
#
# In any case, I've coded the test suite for this module (see
# ../test/test_version.py) specifically to fail on things like comparing
# "1.2a2" and "1.2". That's not because the *code* is doing anything
# wrong, it's because the simple, obvious design doesn't match my
# complicated, hairy expectations for real-world version numbers. It
# would be a snap to fix the test suite to say, "Yep, LooseVersion does
# the Right Thing" (ie. the code matches the conception). But I'd rather
# have a conception that matches common notions about version numbers.
class LooseVersion (Version):
"""Version numbering for anarchists and software realists.
Implements the standard interface for version number classes as
described above. A version number consists of a series of numbers,
separated by either periods or strings of letters. When comparing
version numbers, the numeric components will be compared
numerically, and the alphabetic components lexically. The following
are all valid version numbers, in no particular order:
1.5.1
1.5.2b2
161
3.10a
8.02
3.4j
1996.07.12
3.2.pl0
3.1.1.6
2g6
11g
0.960923
2.2beta29
1.13++
5.5.kw
2.0b1pl0
In fact, there is no such thing as an invalid version number under
this scheme; the rules for comparison are simple and predictable,
but may not always give the results you want (for some definition
of "want").
"""
component_re = re.compile(r'(\d+ | [a-z]+ | \.)', re.VERBOSE)
def __init__ (self, vstring=None):
if vstring:
self.parse(vstring)
def parse (self, vstring):
# I've given up on thinking I can reconstruct the version string
# from the parsed tuple -- so I just store the string here for
# use by __str__
self.vstring = vstring
components = [x for x in self.component_re.split(vstring)
if x and x != '.']
for i, obj in enumerate(components):
try:
components[i] = int(obj)
except ValueError:
pass
self.version = components
def __str__ (self):
return self.vstring
def __repr__ (self):
return "LooseVersion ('%s')" % str(self)
def _cmp (self, other):
if isinstance(other, str):
other = LooseVersion(other)
elif not isinstance(other, LooseVersion):
return NotImplemented
if self.version == other.version:
return 0
if self.version < other.version:
return -1
if self.version > other.version:
return 1
# end class LooseVersion
================================================
FILE: electrum/_vendor/pyperclip/LICENSE.txt
================================================
Copyright (c) 2014, Al Sweigart
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the {organization} nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================
FILE: electrum/_vendor/pyperclip/README.md
================================================
This is a stripped-down copy of the 3rd-party `pyperclip` package.
It is used by the "text" GUI.
At revision https://github.com/asweigart/pyperclip/blob/781603ea491eefce3b58f4f203bf748dbf9ff003/src/pyperclip/__init__.py
(version 1.8.2)
Modifications:
- excluded most files
- added support for pyqt6
================================================
FILE: electrum/_vendor/pyperclip/__init__.py
================================================
"""
Pyperclip
A cross-platform clipboard module for Python, with copy & paste functions for plain text.
By Al Sweigart al@inventwithpython.com
BSD License
Usage:
import pyperclip
pyperclip.copy('The text to be copied to the clipboard.')
spam = pyperclip.paste()
if not pyperclip.is_available():
print("Copy functionality unavailable!")
On Windows, no additional modules are needed.
On Mac, the pyobjc module is used, falling back to the pbcopy and pbpaste cli
commands. (These commands should come with OS X.).
On Linux, install xclip, xsel, or wl-clipboard (for "wayland" sessions) via package manager.
For example, in Debian:
sudo apt-get install xclip
sudo apt-get install xsel
sudo apt-get install wl-clipboard
Otherwise on Linux, you will need the gtk or PyQt5/PyQt4 modules installed.
gtk and PyQt4 modules are not available for Python 3,
and this module does not work with PyGObject yet.
Note: There seems to be a way to get gtk on Python 3, according to:
https://askubuntu.com/questions/697397/python3-is-not-supporting-gtk-module
Cygwin is currently not supported.
Security Note: This module runs programs with these names:
- which
- where
- pbcopy
- pbpaste
- xclip
- xsel
- wl-copy/wl-paste
- klipper
- qdbus
A malicious user could rename or add programs with these names, tricking
Pyperclip into running them with whatever permissions the Python process has.
"""
__version__ = '1.8.2'
import contextlib
import ctypes
import os
import platform
import subprocess
import sys
import time
import warnings
from ctypes import c_size_t, sizeof, c_wchar_p, get_errno, c_wchar
# `import PyQt4` sys.exit()s if DISPLAY is not in the environment.
# Thus, we need to detect the presence of $DISPLAY manually
# and not load PyQt4 if it is absent.
HAS_DISPLAY = os.getenv("DISPLAY", False)
EXCEPT_MSG = """
Pyperclip could not find a copy/paste mechanism for your system.
For more information, please visit https://pyperclip.readthedocs.io/en/latest/index.html#not-implemented-error """
PY2 = sys.version_info[0] == 2
STR_OR_UNICODE = unicode if PY2 else str # For paste(): Python 3 uses str, Python 2 uses unicode.
ENCODING = 'utf-8'
try:
from shutil import which as _executable_exists
except ImportError:
# The "which" unix command finds where a command is.
if platform.system() == 'Windows':
WHICH_CMD = 'where'
else:
WHICH_CMD = 'which'
def _executable_exists(name):
return subprocess.call([WHICH_CMD, name],
stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0
# Exceptions
class PyperclipException(RuntimeError):
pass
class PyperclipWindowsException(PyperclipException):
def __init__(self, message):
message += " (%s)" % ctypes.WinError()
super(PyperclipWindowsException, self).__init__(message)
class PyperclipTimeoutException(PyperclipException):
pass
def _stringifyText(text):
if PY2:
acceptedTypes = (unicode, str, int, float, bool)
else:
acceptedTypes = (str, int, float, bool)
if not isinstance(text, acceptedTypes):
raise PyperclipException('only str, int, float, and bool values can be copied to the clipboard, not %s' % (text.__class__.__name__))
return STR_OR_UNICODE(text)
def init_osx_pbcopy_clipboard():
def copy_osx_pbcopy(text):
text = _stringifyText(text) # Converts non-str values to str.
p = subprocess.Popen(['pbcopy', 'w'],
stdin=subprocess.PIPE, close_fds=True)
p.communicate(input=text.encode(ENCODING))
def paste_osx_pbcopy():
p = subprocess.Popen(['pbpaste', 'r'],
stdout=subprocess.PIPE, close_fds=True)
stdout, stderr = p.communicate()
return stdout.decode(ENCODING)
return copy_osx_pbcopy, paste_osx_pbcopy
def init_osx_pyobjc_clipboard():
def copy_osx_pyobjc(text):
'''Copy string argument to clipboard'''
text = _stringifyText(text) # Converts non-str values to str.
newStr = Foundation.NSString.stringWithString_(text).nsstring()
newData = newStr.dataUsingEncoding_(Foundation.NSUTF8StringEncoding)
board = AppKit.NSPasteboard.generalPasteboard()
board.declareTypes_owner_([AppKit.NSStringPboardType], None)
board.setData_forType_(newData, AppKit.NSStringPboardType)
def paste_osx_pyobjc():
"Returns contents of clipboard"
board = AppKit.NSPasteboard.generalPasteboard()
content = board.stringForType_(AppKit.NSStringPboardType)
return content
return copy_osx_pyobjc, paste_osx_pyobjc
def init_gtk_clipboard():
global gtk
import gtk
def copy_gtk(text):
global cb
text = _stringifyText(text) # Converts non-str values to str.
cb = gtk.Clipboard()
cb.set_text(text)
cb.store()
def paste_gtk():
clipboardContents = gtk.Clipboard().wait_for_text()
# for python 2, returns None if the clipboard is blank.
if clipboardContents is None:
return ''
else:
return clipboardContents
return copy_gtk, paste_gtk
def init_qt_clipboard():
global QApplication
# $DISPLAY should exist
# Try to import from qtpy, but if that fails try PyQt5 then PyQt4
try:
from qtpy.QtWidgets import QApplication
except:
try:
from PyQt6.QtWidgets import QApplication
except:
try:
from PyQt5.QtWidgets import QApplication
except:
from PyQt4.QtGui import QApplication
app = QApplication.instance()
if app is None:
app = QApplication([])
def copy_qt(text):
text = _stringifyText(text) # Converts non-str values to str.
cb = app.clipboard()
cb.setText(text)
def paste_qt():
cb = app.clipboard()
return STR_OR_UNICODE(cb.text())
return copy_qt, paste_qt
def init_xclip_clipboard():
DEFAULT_SELECTION='c'
PRIMARY_SELECTION='p'
def copy_xclip(text, primary=False):
text = _stringifyText(text) # Converts non-str values to str.
selection=DEFAULT_SELECTION
if primary:
selection=PRIMARY_SELECTION
p = subprocess.Popen(['xclip', '-selection', selection],
stdin=subprocess.PIPE, close_fds=True)
p.communicate(input=text.encode(ENCODING))
def paste_xclip(primary=False):
selection=DEFAULT_SELECTION
if primary:
selection=PRIMARY_SELECTION
p = subprocess.Popen(['xclip', '-selection', selection, '-o'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=True)
stdout, stderr = p.communicate()
# Intentionally ignore extraneous output on stderr when clipboard is empty
return stdout.decode(ENCODING)
return copy_xclip, paste_xclip
def init_xsel_clipboard():
DEFAULT_SELECTION='-b'
PRIMARY_SELECTION='-p'
def copy_xsel(text, primary=False):
text = _stringifyText(text) # Converts non-str values to str.
selection_flag = DEFAULT_SELECTION
if primary:
selection_flag = PRIMARY_SELECTION
p = subprocess.Popen(['xsel', selection_flag, '-i'],
stdin=subprocess.PIPE, close_fds=True)
p.communicate(input=text.encode(ENCODING))
def paste_xsel(primary=False):
selection_flag = DEFAULT_SELECTION
if primary:
selection_flag = PRIMARY_SELECTION
p = subprocess.Popen(['xsel', selection_flag, '-o'],
stdout=subprocess.PIPE, close_fds=True)
stdout, stderr = p.communicate()
return stdout.decode(ENCODING)
return copy_xsel, paste_xsel
def init_wl_clipboard():
PRIMARY_SELECTION = "-p"
def copy_wl(text, primary=False):
text = _stringifyText(text) # Converts non-str values to str.
args = ["wl-copy"]
if primary:
args.append(PRIMARY_SELECTION)
if not text:
args.append('--clear')
subprocess.check_call(args, close_fds=True)
else:
pass
p = subprocess.Popen(args, stdin=subprocess.PIPE, close_fds=True)
p.communicate(input=text.encode(ENCODING))
def paste_wl(primary=False):
args = ["wl-paste", "-n"]
if primary:
args.append(PRIMARY_SELECTION)
p = subprocess.Popen(args, stdout=subprocess.PIPE, close_fds=True)
stdout, _stderr = p.communicate()
return stdout.decode(ENCODING)
return copy_wl, paste_wl
def init_klipper_clipboard():
def copy_klipper(text):
text = _stringifyText(text) # Converts non-str values to str.
p = subprocess.Popen(
['qdbus', 'org.kde.klipper', '/klipper', 'setClipboardContents',
text.encode(ENCODING)],
stdin=subprocess.PIPE, close_fds=True)
p.communicate(input=None)
def paste_klipper():
p = subprocess.Popen(
['qdbus', 'org.kde.klipper', '/klipper', 'getClipboardContents'],
stdout=subprocess.PIPE, close_fds=True)
stdout, stderr = p.communicate()
# Workaround for https://bugs.kde.org/show_bug.cgi?id=342874
# TODO: https://github.com/asweigart/pyperclip/issues/43
clipboardContents = stdout.decode(ENCODING)
# even if blank, Klipper will append a newline at the end
assert len(clipboardContents) > 0
# make sure that newline is there
assert clipboardContents.endswith('\n')
if clipboardContents.endswith('\n'):
clipboardContents = clipboardContents[:-1]
return clipboardContents
return copy_klipper, paste_klipper
def init_dev_clipboard_clipboard():
def copy_dev_clipboard(text):
text = _stringifyText(text) # Converts non-str values to str.
if text == '':
warnings.warn('Pyperclip cannot copy a blank string to the clipboard on Cygwin. This is effectively a no-op.')
if '\r' in text:
warnings.warn('Pyperclip cannot handle \\r characters on Cygwin.')
fo = open('/dev/clipboard', 'wt')
fo.write(text)
fo.close()
def paste_dev_clipboard():
fo = open('/dev/clipboard', 'rt')
content = fo.read()
fo.close()
return content
return copy_dev_clipboard, paste_dev_clipboard
def init_no_clipboard():
class ClipboardUnavailable(object):
def __call__(self, *args, **kwargs):
raise PyperclipException(EXCEPT_MSG)
if PY2:
def __nonzero__(self):
return False
else:
def __bool__(self):
return False
return ClipboardUnavailable(), ClipboardUnavailable()
# Windows-related clipboard functions:
class CheckedCall(object):
def __init__(self, f):
super(CheckedCall, self).__setattr__("f", f)
def __call__(self, *args):
ret = self.f(*args)
if not ret and get_errno():
raise PyperclipWindowsException("Error calling " + self.f.__name__)
return ret
def __setattr__(self, key, value):
setattr(self.f, key, value)
def init_windows_clipboard():
global HGLOBAL, LPVOID, DWORD, LPCSTR, INT, HWND, HINSTANCE, HMENU, BOOL, UINT, HANDLE
from ctypes.wintypes import (HGLOBAL, LPVOID, DWORD, LPCSTR, INT, HWND,
HINSTANCE, HMENU, BOOL, UINT, HANDLE)
windll = ctypes.windll
msvcrt = ctypes.CDLL('msvcrt')
safeCreateWindowExA = CheckedCall(windll.user32.CreateWindowExA)
safeCreateWindowExA.argtypes = [DWORD, LPCSTR, LPCSTR, DWORD, INT, INT,
INT, INT, HWND, HMENU, HINSTANCE, LPVOID]
safeCreateWindowExA.restype = HWND
safeDestroyWindow = CheckedCall(windll.user32.DestroyWindow)
safeDestroyWindow.argtypes = [HWND]
safeDestroyWindow.restype = BOOL
OpenClipboard = windll.user32.OpenClipboard
OpenClipboard.argtypes = [HWND]
OpenClipboard.restype = BOOL
safeCloseClipboard = CheckedCall(windll.user32.CloseClipboard)
safeCloseClipboard.argtypes = []
safeCloseClipboard.restype = BOOL
safeEmptyClipboard = CheckedCall(windll.user32.EmptyClipboard)
safeEmptyClipboard.argtypes = []
safeEmptyClipboard.restype = BOOL
safeGetClipboardData = CheckedCall(windll.user32.GetClipboardData)
safeGetClipboardData.argtypes = [UINT]
safeGetClipboardData.restype = HANDLE
safeSetClipboardData = CheckedCall(windll.user32.SetClipboardData)
safeSetClipboardData.argtypes = [UINT, HANDLE]
safeSetClipboardData.restype = HANDLE
safeGlobalAlloc = CheckedCall(windll.kernel32.GlobalAlloc)
safeGlobalAlloc.argtypes = [UINT, c_size_t]
safeGlobalAlloc.restype = HGLOBAL
safeGlobalLock = CheckedCall(windll.kernel32.GlobalLock)
safeGlobalLock.argtypes = [HGLOBAL]
safeGlobalLock.restype = LPVOID
safeGlobalUnlock = CheckedCall(windll.kernel32.GlobalUnlock)
safeGlobalUnlock.argtypes = [HGLOBAL]
safeGlobalUnlock.restype = BOOL
wcslen = CheckedCall(msvcrt.wcslen)
wcslen.argtypes = [c_wchar_p]
wcslen.restype = UINT
GMEM_MOVEABLE = 0x0002
CF_UNICODETEXT = 13
@contextlib.contextmanager
def window():
"""
Context that provides a valid Windows hwnd.
"""
# we really just need the hwnd, so setting "STATIC"
# as predefined lpClass is just fine.
hwnd = safeCreateWindowExA(0, b"STATIC", None, 0, 0, 0, 0, 0,
None, None, None, None)
try:
yield hwnd
finally:
safeDestroyWindow(hwnd)
@contextlib.contextmanager
def clipboard(hwnd):
"""
Context manager that opens the clipboard and prevents
other applications from modifying the clipboard content.
"""
# We may not get the clipboard handle immediately because
# some other application is accessing it (?)
# We try for at least 500ms to get the clipboard.
t = time.time() + 0.5
success = False
while time.time() < t:
success = OpenClipboard(hwnd)
if success:
break
time.sleep(0.01)
if not success:
raise PyperclipWindowsException("Error calling OpenClipboard")
try:
yield
finally:
safeCloseClipboard()
def copy_windows(text):
# This function is heavily based on
# http://msdn.com/ms649016#_win32_Copying_Information_to_the_Clipboard
text = _stringifyText(text) # Converts non-str values to str.
with window() as hwnd:
# http://msdn.com/ms649048
# If an application calls OpenClipboard with hwnd set to NULL,
# EmptyClipboard sets the clipboard owner to NULL;
# this causes SetClipboardData to fail.
# => We need a valid hwnd to copy something.
with clipboard(hwnd):
safeEmptyClipboard()
if text:
# http://msdn.com/ms649051
# If the hMem parameter identifies a memory object,
# the object must have been allocated using the
# function with the GMEM_MOVEABLE flag.
count = wcslen(text) + 1
handle = safeGlobalAlloc(GMEM_MOVEABLE,
count * sizeof(c_wchar))
locked_handle = safeGlobalLock(handle)
ctypes.memmove(c_wchar_p(locked_handle), c_wchar_p(text), count * sizeof(c_wchar))
safeGlobalUnlock(handle)
safeSetClipboardData(CF_UNICODETEXT, handle)
def paste_windows():
with clipboard(None):
handle = safeGetClipboardData(CF_UNICODETEXT)
if not handle:
# GetClipboardData may return NULL with errno == NO_ERROR
# if the clipboard is empty.
# (Also, it may return a handle to an empty buffer,
# but technically that's not empty)
return ""
locked_handle = safeGlobalLock(handle)
return_value = c_wchar_p(locked_handle).value
safeGlobalUnlock(handle)
return return_value
return copy_windows, paste_windows
def init_wsl_clipboard():
def copy_wsl(text):
text = _stringifyText(text) # Converts non-str values to str.
p = subprocess.Popen(['clip.exe'],
stdin=subprocess.PIPE, close_fds=True)
p.communicate(input=text.encode(ENCODING))
def paste_wsl():
# '-noprofile' speeds up load time
p = subprocess.Popen(['powershell.exe', '-noprofile', '-command', 'Get-Clipboard'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=True)
stdout, stderr = p.communicate()
# WSL appends "\r\n" to the contents.
return stdout[:-2].decode(ENCODING)
return copy_wsl, paste_wsl
# Automatic detection of clipboard mechanisms and importing is done in deteremine_clipboard():
def determine_clipboard():
'''
Determine the OS/platform and set the copy() and paste() functions
accordingly.
'''
global Foundation, AppKit, gtk, qtpy, PyQt4, PyQt5, PyQt6
# Setup for the CYGWIN platform:
if 'cygwin' in platform.system().lower(): # Cygwin has a variety of values returned by platform.system(), such as 'CYGWIN_NT-6.1'
# FIXME: pyperclip currently does not support Cygwin,
# see https://github.com/asweigart/pyperclip/issues/55
if os.path.exists('/dev/clipboard'):
warnings.warn('Pyperclip\'s support for Cygwin is not perfect, see https://github.com/asweigart/pyperclip/issues/55')
return init_dev_clipboard_clipboard()
# Setup for the WINDOWS platform:
elif os.name == 'nt' or platform.system() == 'Windows':
return init_windows_clipboard()
if platform.system() == 'Linux' and os.path.isfile('/proc/version'):
with open('/proc/version', 'r') as f:
if "microsoft" in f.read().lower():
return init_wsl_clipboard()
# Setup for the MAC OS X platform:
if os.name == 'mac' or platform.system() == 'Darwin':
try:
import Foundation # check if pyobjc is installed
import AppKit
except ImportError:
return init_osx_pbcopy_clipboard()
else:
return init_osx_pyobjc_clipboard()
# Setup for the LINUX platform:
if HAS_DISPLAY:
try:
import gtk # check if gtk is installed
except ImportError:
pass # We want to fail fast for all non-ImportError exceptions.
else:
return init_gtk_clipboard()
if (
os.environ.get("WAYLAND_DISPLAY") and
_executable_exists("wl-copy")
):
return init_wl_clipboard()
if _executable_exists("xsel"):
return init_xsel_clipboard()
if _executable_exists("xclip"):
return init_xclip_clipboard()
if _executable_exists("klipper") and _executable_exists("qdbus"):
return init_klipper_clipboard()
try:
# qtpy is a small abstraction layer that lets you write applications using a single api call to either PyQt or PySide.
# https://pypi.python.org/pypi/QtPy
import qtpy # check if qtpy is installed
except ImportError:
# If qtpy isn't installed, fall back on importing PyQt4.
try:
import PyQt6 # check if PyQt6 is installed
except ImportError:
try:
import PyQt5 # check if PyQt5 is installed
except ImportError:
try:
import PyQt4 # check if PyQt4 is installed
except ImportError:
pass # We want to fail fast for all non-ImportError exceptions.
else:
return init_qt_clipboard()
else:
return init_qt_clipboard()
else:
return init_qt_clipboard()
else:
return init_qt_clipboard()
return init_no_clipboard()
def set_clipboard(clipboard):
'''
Explicitly sets the clipboard mechanism. The "clipboard mechanism" is how
the copy() and paste() functions interact with the operating system to
implement the copy/paste feature. The clipboard parameter must be one of:
- pbcopy
- pbobjc (default on Mac OS X)
- gtk
- qt
- xclip
- xsel
- klipper
- windows (default on Windows)
- no (this is what is set when no clipboard mechanism can be found)
'''
global copy, paste
clipboard_types = {
"pbcopy": init_osx_pbcopy_clipboard,
"pyobjc": init_osx_pyobjc_clipboard,
"gtk": init_gtk_clipboard,
"qt": init_qt_clipboard, # TODO - split this into 'qtpy', 'pyqt4', and 'pyqt5'
"xclip": init_xclip_clipboard,
"xsel": init_xsel_clipboard,
"wl-clipboard": init_wl_clipboard,
"klipper": init_klipper_clipboard,
"windows": init_windows_clipboard,
"no": init_no_clipboard,
}
if clipboard not in clipboard_types:
raise ValueError('Argument must be one of %s' % (', '.join([repr(_) for _ in clipboard_types.keys()])))
# Sets pyperclip's copy() and paste() functions:
copy, paste = clipboard_types[clipboard]()
def lazy_load_stub_copy(text):
'''
A stub function for copy(), which will load the real copy() function when
called so that the real copy() function is used for later calls.
This allows users to import pyperclip without having determine_clipboard()
automatically run, which will automatically select a clipboard mechanism.
This could be a problem if it selects, say, the memory-heavy PyQt4 module
but the user was just going to immediately call set_clipboard() to use a
different clipboard mechanism.
The lazy loading this stub function implements gives the user a chance to
call set_clipboard() to pick another clipboard mechanism. Or, if the user
simply calls copy() or paste() without calling set_clipboard() first,
will fall back on whatever clipboard mechanism that determine_clipboard()
automatically chooses.
'''
global copy, paste
copy, paste = determine_clipboard()
return copy(text)
def lazy_load_stub_paste():
'''
A stub function for paste(), which will load the real paste() function when
called so that the real paste() function is used for later calls.
This allows users to import pyperclip without having determine_clipboard()
automatically run, which will automatically select a clipboard mechanism.
This could be a problem if it selects, say, the memory-heavy PyQt4 module
but the user was just going to immediately call set_clipboard() to use a
different clipboard mechanism.
The lazy loading this stub function implements gives the user a chance to
call set_clipboard() to pick another clipboard mechanism. Or, if the user
simply calls copy() or paste() without calling set_clipboard() first,
will fall back on whatever clipboard mechanism that determine_clipboard()
automatically chooses.
'''
global copy, paste
copy, paste = determine_clipboard()
return paste()
def is_available():
return copy != lazy_load_stub_copy and paste != lazy_load_stub_paste
# Initially, copy() and paste() are set to lazy loading wrappers which will
# set `copy` and `paste` to real functions the first time they're used, unless
# set_clipboard() or determine_clipboard() is called first.
copy, paste = lazy_load_stub_copy, lazy_load_stub_paste
def waitForPaste(timeout=None):
"""This function call blocks until a non-empty text string exists on the
clipboard. It returns this text.
This function raises PyperclipTimeoutException if timeout was set to
a number of seconds that has elapsed without non-empty text being put on
the clipboard."""
startTime = time.time()
while True:
clipboardText = paste()
if clipboardText != '':
return clipboardText
time.sleep(0.01)
if timeout is not None and time.time() > startTime + timeout:
raise PyperclipTimeoutException('waitForPaste() timed out after ' + str(timeout) + ' seconds.')
def waitForNewPaste(timeout=None):
"""This function call blocks until a new text string exists on the
clipboard that is different from the text that was there when the function
was first called. It returns this text.
This function raises PyperclipTimeoutException if timeout was set to
a number of seconds that has elapsed without non-empty text being put on
the clipboard."""
startTime = time.time()
originalText = paste()
while True:
currentText = paste()
if currentText != originalText:
return currentText
time.sleep(0.01)
if timeout is not None and time.time() > startTime + timeout:
raise PyperclipTimeoutException('waitForNewPaste() timed out after ' + str(timeout) + ' seconds.')
__all__ = ['copy', 'paste', 'waitForPaste', 'waitForNewPaste', 'set_clipboard', 'determine_clipboard']
================================================
FILE: electrum/address_synchronizer.py
================================================
# 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.
import asyncio
import copy
import dataclasses
import threading
import itertools
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple, NamedTuple, Sequence, List
from .crypto import sha256
from . import bitcoin, util
from .bitcoin import COINBASE_MATURITY
from .util import profiler, bfh, TxMinedInfo, UnrelatedTransactionException, with_lock, OldTaskGroup
from .transaction import Transaction, TxOutput, TxInput, PartialTxInput, TxOutpoint, PartialTransaction, tx_from_any
from .synchronizer import Synchronizer
from .verifier import SPV
from .blockchain import hash_header, Blockchain
from .i18n import _
from .logging import Logger
from .util import EventListener, event_listener
if TYPE_CHECKING:
from .network import Network
from .wallet_db import WalletDB
from .simple_config import SimpleConfig
TX_HEIGHT_FUTURE = -3
TX_HEIGHT_LOCAL = -2
TX_HEIGHT_UNCONF_PARENT = -1
TX_HEIGHT_UNCONFIRMED = 0
TX_TIMESTAMP_INF = 999_999_999_999
TX_HEIGHT_INF = 10 ** 9
from enum import IntEnum, auto
class TxMinedDepth(IntEnum):
""" IntEnum because we call min() in get_deepest_tx_mined_depth_for_txids """
DEEP = auto()
SHALLOW = auto()
MEMPOOL = auto()
FREE = auto()
class HistoryItem(NamedTuple):
txid: str
tx_mined_status: TxMinedInfo
delta: int
fee: Optional[int]
balance: int
class AddressSynchronizer(Logger, EventListener):
""" address database """
network: Optional['Network']
asyncio_loop: Optional['asyncio.AbstractEventLoop'] = None
synchronizer: Optional['Synchronizer']
verifier: Optional['SPV']
def __init__(self, db: 'WalletDB', config: 'SimpleConfig', *, name: str = None):
self.db = db
self.config = config
self.name = name
self.network = None
Logger.__init__(self)
# verifier (SPV) and synchronizer are started in start_network
self.synchronizer = None
self.verifier = None
self.lock = threading.RLock()
self.future_tx = {} # type: Dict[str, int] # txid -> wanted (abs) height
# Txs the server claims are mined but still pending verification:
self.unverified_tx = defaultdict(int) # type: Dict[str, int] # txid -> height. Access with self.lock.
# Txs the server claims are in the mempool:
self.unconfirmed_tx = defaultdict(int) # type: Dict[str, int] # txid -> height. Access with self.lock.
# thread local storage for caching stuff
self.threadlocal_cache = threading.local()
self._get_balance_cache = {}
self._get_utxos_cache = {}
self.load_and_cleanup()
@with_lock
def invalidate_cache(self):
self._get_balance_cache.clear()
self._get_utxos_cache.clear()
def diagnostic_name(self):
return self.name or ""
@with_lock
def load_and_cleanup(self):
self.load_local_history()
self.check_history()
self.load_unverified_transactions()
self.remove_local_transactions_we_dont_have()
def is_mine(self, address: Optional[str]) -> bool:
"""Returns whether an address is in our set.
Differences between adb.is_mine and wallet.is_mine:
- adb.is_mine: addrs that we are watching (e.g. via Synchronizer)
- lnwatcher adds its own lightning-related addresses that are not part of the wallet
- wallet.is_mine: addrs that are part of the wallet balance or the wallet might sign for
- an offline wallet might learn from a PSBT about addrs beyond its gap limit
Neither set is guaranteed to be a subset of the other.
"""
if not address: return False
return self.db.is_addr_in_history(address)
def get_addresses(self):
return sorted(self.db.get_history())
@with_lock
def get_address_history(self, addr: str) -> Dict[str, int]:
"""Returns the history for the address, as a txid->height dict.
In addition to what we have from the server, this includes local and future txns.
Note: heights are SPV-verified.
Also see related method db.get_addr_history, which stores the response from the server,
so that only includes txns the server sees.
"""
h = {}
related_txns = self._history_local.get(addr, set())
for tx_hash in related_txns:
tx_height = self.get_tx_height(tx_hash).height()
h[tx_hash] = tx_height
return h
def get_address_history_len(self, addr: str) -> int:
"""Return number of transactions where address is involved."""
return len(self._history_local.get(addr, ()))
@with_lock
def get_txin_address(self, txin: TxInput) -> Optional[str]:
if txin.address:
return txin.address
prevout_hash = txin.prevout.txid.hex()
prevout_n = txin.prevout.out_idx
for addr in self.db.get_txo_addresses(prevout_hash):
d = self.db.get_txo_addr(prevout_hash, addr)
if prevout_n in d:
return addr
tx = self.db.get_transaction(prevout_hash)
if tx:
return tx.outputs()[prevout_n].address
return None
@with_lock
def get_txin_value(self, txin: TxInput, *, address: str = None) -> Optional[int]:
if txin.value_sats() is not None:
return txin.value_sats()
prevout_hash = txin.prevout.txid.hex()
prevout_n = txin.prevout.out_idx
if address is None:
address = self.get_txin_address(txin)
if address:
d = self.db.get_txo_addr(prevout_hash, address)
try:
v, cb = d[prevout_n]
return v
except KeyError:
pass
tx = self.db.get_transaction(prevout_hash)
if tx:
return tx.outputs()[prevout_n].value
return None
@with_lock
def load_unverified_transactions(self):
# review transactions that are in the history
for addr in self.db.get_history():
hist = self.db.get_addr_history(addr)
for tx_hash, tx_height in hist:
# add it in case it was previously unconfirmed
self.add_unverified_or_unconfirmed_tx(tx_hash, tx_height)
def start_network(self, network: Optional['Network']) -> None:
assert self.network is None, "already started"
self.network = network
if self.network is not None:
self.synchronizer = Synchronizer(self)
self.verifier = SPV(self.network, self)
self.asyncio_loop = network.asyncio_loop
self.register_callbacks()
self._update_stored_local_height()
@event_listener
@with_lock
def on_event_blockchain_updated(self, *args):
self.invalidate_cache()
self._update_stored_local_height()
async def stop(self):
if self.network:
try:
async with OldTaskGroup() as group:
if self.synchronizer:
await group.spawn(self.synchronizer.stop())
if self.verifier:
await group.spawn(self.verifier.stop())
finally: # even if we get cancelled
self.synchronizer = None
self.verifier = None
self.unregister_callbacks()
self.network = None
def add_address(self, address: str) -> None:
if address not in self.db.history:
self.db.history[address] = []
if self.synchronizer:
self.synchronizer.add(address)
self.up_to_date_changed()
@with_lock
def get_conflicting_transactions(self, tx: Transaction, *, include_self: bool = False) -> Set[str]:
"""Returns a set of transaction hashes from the wallet history that are
directly conflicting with tx, i.e. they have common outpoints being
spent with tx.
include_self specifies whether the tx itself should be reported as a
conflict (if already in wallet history)
"""
conflicting_txns = set()
for txin in tx.inputs():
if txin.is_coinbase_input():
continue
prevout_hash = txin.prevout.txid.hex()
prevout_n = txin.prevout.out_idx
spending_tx_hash = self.db.get_spent_outpoint(prevout_hash, prevout_n)
if spending_tx_hash is None:
continue
# this outpoint has already been spent, by spending_tx
# annoying assert that has revealed several bugs over time:
assert self.db.get_transaction(spending_tx_hash), "spending tx not in wallet db"
conflicting_txns |= {spending_tx_hash}
if tx_hash := tx.txid():
if tx_hash in conflicting_txns:
# this tx is already in history, so it conflicts with itself
if len(conflicting_txns) > 1:
raise Exception('Found conflicting transactions already in wallet history.')
if not include_self:
conflicting_txns -= {tx_hash}
return conflicting_txns
@with_lock
def get_transaction(self, txid: str) -> Optional[Transaction]:
tx = self.db.get_transaction(txid)
if tx:
tx.deserialize()
for txin in tx._inputs:
tx_mined_info = self.get_tx_height(txin.prevout.txid.hex())
txin.block_height = tx_mined_info.height()
txin.block_txpos = tx_mined_info.txpos
return tx
def add_transaction(self, tx: Transaction, *, allow_unrelated=False, is_new=True) -> bool:
"""
Returns whether the tx was successfully added to the wallet history.
Note that a transaction may need to be added several times, if our
list of addresses has increased. This will return True even if the
transaction was already in self.db.
"""
assert tx, tx
# note: tx.is_complete() is not necessarily True; tx might be partial
# but it *needs* to have a txid:
tx_hash = tx.txid()
if tx_hash is None:
raise Exception("cannot add tx without txid to wallet history")
# For sanity, try to serialize and deserialize tx early:
tx_from_any(str(tx)) # see if raises (no-side-effects)
with self.lock:
# NOTE: returning if tx in self.transactions might seem like a good idea
# BUT we track is_mine inputs in a txn, and during subsequent calls
# of add_transaction tx, we might learn of more-and-more inputs of
# being is_mine, as we roll the gap_limit forward
is_coinbase = tx.inputs()[0].is_coinbase_input()
tx_height = self.get_tx_height(tx_hash, force_local_if_missing_tx=False).height()
if not allow_unrelated:
# note that during sync, if the transactions are not properly sorted,
# it could happen that we think tx is unrelated but actually one of the inputs is is_mine.
# this is the main motivation for allow_unrelated
is_mine = any([self.is_mine(self.get_txin_address(txin)) for txin in tx.inputs()])
is_for_me = any([self.is_mine(txo.address) for txo in tx.outputs()])
if not is_mine and not is_for_me:
raise UnrelatedTransactionException()
# Find all conflicting transactions.
# In case of a conflict,
# 1. confirmed > mempool > local
# 2. this new txn has priority over existing ones
# When this method exits, there must NOT be any conflict, so
# either keep this txn and remove all conflicting (along with dependencies)
# or drop this txn
conflicting_txns = self.get_conflicting_transactions(tx)
if conflicting_txns:
existing_mempool_txn = any(
self.get_tx_height(tx_hash2).height() in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT)
for tx_hash2 in conflicting_txns)
existing_confirmed_txn = any(
self.get_tx_height(tx_hash2).height() > 0
for tx_hash2 in conflicting_txns)
if existing_confirmed_txn and tx_height <= 0:
# this is a non-confirmed tx that conflicts with confirmed txns; drop.
return False
if existing_mempool_txn and tx_height == TX_HEIGHT_LOCAL:
# this is a local tx that conflicts with non-local txns; drop.
return False
# keep this txn and remove all conflicting
for tx_hash2 in conflicting_txns:
self.remove_transaction(tx_hash2)
# add inputs
def add_value_from_prev_output():
# note: this takes linear time in num is_mine outputs of prev_tx
addr = self.get_txin_address(txi)
if addr and self.is_mine(addr):
outputs = self.db.get_txo_addr(prevout_hash, addr)
try:
v, is_cb = outputs[prevout_n]
except KeyError:
pass
else:
self.db.add_txi_addr(tx_hash, addr, ser, v)
self.invalidate_cache()
for txi in tx.inputs():
if txi.is_coinbase_input():
continue
prevout_hash = txi.prevout.txid.hex()
prevout_n = txi.prevout.out_idx
ser = txi.prevout.to_str()
self.db.set_spent_outpoint(prevout_hash, prevout_n, tx_hash)
add_value_from_prev_output()
# add outputs
for n, txo in enumerate(tx.outputs()):
v = txo.value
ser = tx_hash + ':%d'%n
scripthash = bitcoin.script_to_scripthash(txo.scriptpubkey)
self.db.add_prevout_by_scripthash(scripthash, prevout=TxOutpoint.from_str(ser), value=v)
addr = txo.address
if addr and self.is_mine(addr):
self.db.add_txo_addr(tx_hash, addr, n, v, is_coinbase)
self.invalidate_cache()
# give v to txi that spends me
next_tx = self.db.get_spent_outpoint(tx_hash, n)
if next_tx is not None:
self.db.add_txi_addr(next_tx, addr, ser, v)
self._add_tx_to_local_history(next_tx)
# add to local history
self._add_tx_to_local_history(tx_hash)
# save
self.db.add_transaction(tx_hash, tx)
self.db.add_num_inputs_to_tx(tx_hash, len(tx.inputs()))
if is_new:
util.trigger_callback('adb_added_tx', self, tx_hash, tx)
return True
@with_lock
def remove_transaction(self, tx_hash: str) -> None:
"""Removes a transaction AND all its dependents/children
from the wallet history.
"""
to_remove = {tx_hash}
to_remove |= self.get_depending_transactions(tx_hash)
for txid in to_remove:
self._remove_transaction(txid)
def _remove_transaction(self, tx_hash: str) -> None:
"""Removes a single transaction from the wallet history, and attempts
to undo all effects of the tx (spending inputs, creating outputs, etc).
"""
def remove_from_spent_outpoints():
# undo spends in spent_outpoints
if tx is not None:
# if we have the tx, this branch is faster
for txin in tx.inputs():
if txin.is_coinbase_input():
continue
prevout_hash = txin.prevout.txid.hex()
prevout_n = txin.prevout.out_idx
self.db.remove_spent_outpoint(prevout_hash, prevout_n)
else:
# expensive but always works
for prevout_hash, prevout_n in self.db.list_spent_outpoints():
spending_txid = self.db.get_spent_outpoint(prevout_hash, prevout_n)
if spending_txid == tx_hash:
self.db.remove_spent_outpoint(prevout_hash, prevout_n)
with self.lock:
self.logger.info(f"removing tx from history {tx_hash}")
tx = self.db.remove_transaction(tx_hash)
remove_from_spent_outpoints()
self._remove_tx_from_local_history(tx_hash)
self.invalidate_cache()
self.db.remove_txi(tx_hash)
self.db.remove_txo(tx_hash)
self.db.remove_tx_fee(tx_hash)
self.db.remove_verified_tx(tx_hash)
self.unverified_tx.pop(tx_hash, None)
self.unconfirmed_tx.pop(tx_hash, None)
if tx:
for idx, txo in enumerate(tx.outputs()):
scripthash = bitcoin.script_to_scripthash(txo.scriptpubkey)
prevout = TxOutpoint(bfh(tx_hash), idx)
self.db.remove_prevout_by_scripthash(scripthash, prevout=prevout, value=txo.value)
util.trigger_callback('adb_removed_tx', self, tx_hash, tx)
@with_lock
def get_depending_transactions(self, tx_hash: str) -> Set[str]:
"""Returns all (grand-)children of tx_hash in this wallet."""
children = set()
for n in self.db.get_spent_outpoints(tx_hash):
other_hash = self.db.get_spent_outpoint(tx_hash, n)
children.add(other_hash)
children |= self.get_depending_transactions(other_hash)
return children
@with_lock
def receive_tx_callback(self, tx: Transaction, *, tx_height: Optional[int] = None) -> None:
txid = tx.txid()
assert txid is not None
if tx_height is not None:
# note: tx_height is only set by the unit tests: to inject a tx into the history
self.add_unverified_or_unconfirmed_tx(txid, tx_height)
self.add_transaction(tx, allow_unrelated=True)
@with_lock
def receive_history_callback(self, addr: str, hist, tx_fees: Dict[str, int]):
old_hist = self.get_address_history(addr)
for tx_hash, height in old_hist.items():
if (tx_hash, height) not in hist:
# make tx local
self.unverified_tx.pop(tx_hash, None)
self.unconfirmed_tx.pop(tx_hash, None)
self.db.remove_verified_tx(tx_hash)
if self.verifier:
self.verifier.remove_spv_proof_for_tx(tx_hash)
self.db.set_addr_history(addr, hist)
for tx_hash, tx_height in hist:
# add it in case it was previously unconfirmed
self.add_unverified_or_unconfirmed_tx(tx_hash, tx_height)
# if addr is new, we have to recompute txi and txo
tx = self.db.get_transaction(tx_hash)
if tx is None:
continue
self.add_transaction(tx, allow_unrelated=True, is_new=False)
# if we already had this tx, see if its height changed (e.g. local->unconfirmed)
old_height = old_hist.get(tx_hash, None)
if old_height is not None and old_height != tx_height:
util.trigger_callback('adb_tx_height_changed', self, tx_hash, old_height, tx_height)
# Store fees
for tx_hash, fee_sat in tx_fees.items():
self.db.add_tx_fee_from_server(tx_hash, fee_sat)
@with_lock
@profiler
def load_local_history(self):
self._history_local = {} # type: Dict[str, Set[str]] # address -> set(txid)
self._address_history_changed_events = defaultdict(asyncio.Event) # address -> Event
for txid in itertools.chain(self.db.list_txi(), self.db.list_txo()):
self._add_tx_to_local_history(txid)
@with_lock
@profiler
def check_history(self):
hist_addrs_mine = list(filter(lambda k: self.is_mine(k), self.db.get_history()))
hist_addrs_not_mine = list(filter(lambda k: not self.is_mine(k), self.db.get_history()))
for addr in hist_addrs_not_mine:
self.db.remove_addr_history(addr)
for addr in hist_addrs_mine:
hist = self.db.get_addr_history(addr)
for tx_hash, tx_height in hist:
if self.db.get_txi_addresses(tx_hash) or self.db.get_txo_addresses(tx_hash):
continue
tx = self.db.get_transaction(tx_hash)
if tx is not None:
self.add_transaction(tx, allow_unrelated=True)
@with_lock
def remove_local_transactions_we_dont_have(self):
for txid in itertools.chain(self.db.list_txi(), self.db.list_txo()):
tx_height = self.get_tx_height(txid).height()
if tx_height == TX_HEIGHT_LOCAL and not self.db.get_transaction(txid):
self.remove_transaction(txid)
@with_lock
def clear_history(self):
self.db.clear_history()
self._history_local.clear()
self.invalidate_cache()
@with_lock
def _get_tx_sort_key(self, tx_hash: str) -> Tuple[int, int]:
"""Returns a key to be used for sorting txs."""
tx_mined_info = self.get_tx_height(tx_hash)
height = self.tx_height_to_sort_height(tx_mined_info.height())
txpos = tx_mined_info.txpos or -1
return height, txpos
@classmethod
def tx_height_to_sort_height(cls, height: int = None):
"""Return a height-like value to be used for sorting txs."""
if height is not None:
if height > 0:
return height
if height == TX_HEIGHT_UNCONFIRMED:
return TX_HEIGHT_INF
if height == TX_HEIGHT_UNCONF_PARENT:
return TX_HEIGHT_INF + 1
if height == TX_HEIGHT_FUTURE:
return TX_HEIGHT_INF + 2
if height == TX_HEIGHT_LOCAL:
return TX_HEIGHT_INF + 3
return TX_HEIGHT_INF + 100
def with_local_height_cached(func):
# get local height only once, as it's relatively expensive.
# take care that nested calls work as expected
def f(self, *args, **kwargs):
orig_val = getattr(self.threadlocal_cache, 'local_height', None)
self.threadlocal_cache.local_height = orig_val or self.get_local_height()
try:
return func(self, *args, **kwargs)
finally:
self.threadlocal_cache.local_height = orig_val
return f
@with_lock
@with_local_height_cached
def get_history(self, domain) -> Sequence[HistoryItem]:
domain = set(domain)
# 1. Get the history of each address in the domain, maintain the
# delta of a tx as the sum of its deltas on domain addresses
tx_deltas = defaultdict(int) # type: Dict[str, int]
for addr in domain:
h = self.get_address_history(addr).items()
for tx_hash, height in h:
tx_deltas[tx_hash] += self.get_tx_delta(tx_hash, addr)
# 2. create sorted history
history = []
for tx_hash in tx_deltas:
delta = tx_deltas[tx_hash]
tx_mined_status = self.get_tx_height(tx_hash)
fee = self.get_tx_fee(tx_hash)
history.append((tx_hash, tx_mined_status, delta, fee))
history.sort(key = lambda x: self._get_tx_sort_key(x[0]))
# 3. add balance
h2 = []
balance = 0
for tx_hash, tx_mined_status, delta, fee in history:
balance += delta
h2.append(HistoryItem(
txid=tx_hash,
tx_mined_status=tx_mined_status,
delta=delta,
fee=fee,
balance=balance))
# sanity check
c, u, x = self.get_balance(domain)
if balance != c + u + x:
self.logger.error(f'sanity check failed! c={c},u={u},x={x} while history balance={balance}')
raise Exception("wallet.get_history() failed balance sanity-check")
return h2
@with_lock
def _add_tx_to_local_history(self, txid):
for addr in itertools.chain(self.db.get_txi_addresses(txid), self.db.get_txo_addresses(txid)):
cur_hist = self._history_local.get(addr, set())
cur_hist.add(txid)
self._history_local[addr] = cur_hist
self._mark_address_history_changed(addr)
@with_lock
def _remove_tx_from_local_history(self, txid):
for addr in itertools.chain(self.db.get_txi_addresses(txid), self.db.get_txo_addresses(txid)):
cur_hist = self._history_local.get(addr, set())
try:
cur_hist.remove(txid)
except KeyError:
pass
else:
self._history_local[addr] = cur_hist
self._mark_address_history_changed(addr)
def _mark_address_history_changed(self, addr: str) -> None:
def set_and_clear():
event = self._address_history_changed_events[addr]
# history for this address changed, wake up coroutines:
event.set()
# clear event immediately so that coroutines can wait() for the next change:
event.clear()
if self.asyncio_loop:
self.asyncio_loop.call_soon_threadsafe(set_and_clear)
async def wait_for_address_history_to_change(self, addr: str) -> None:
"""Wait until the server tells us about a new transaction related to addr.
Unconfirmed and confirmed transactions are not distinguished, and so e.g. SPV
is not taken into account.
"""
assert self.is_mine(addr), "address needs to be is_mine to be watched"
await self._address_history_changed_events[addr].wait()
@with_lock
def add_unverified_or_unconfirmed_tx(self, tx_hash: str, tx_height: int) -> None:
assert tx_height >= TX_HEIGHT_UNCONF_PARENT, f"got {tx_height=} for {tx_hash=}" # forbid local/future txs here
if self.db.is_in_verified_tx(tx_hash):
if tx_height <= 0:
# tx was previously SPV-verified but now in mempool (probably reorg)
self.db.remove_verified_tx(tx_hash)
self.unconfirmed_tx[tx_hash] = tx_height
if self.verifier:
self.verifier.remove_spv_proof_for_tx(tx_hash)
else:
if tx_height > 0:
self.unverified_tx[tx_hash] = tx_height
else:
self.unconfirmed_tx[tx_hash] = tx_height
@with_lock
def remove_unverified_tx(self, tx_hash: str, tx_height: int) -> None:
new_height = self.unverified_tx.get(tx_hash)
if new_height == tx_height:
self.unverified_tx.pop(tx_hash, None)
def add_verified_tx(self, tx_hash: str, info: TxMinedInfo):
# Remove from the unverified map and add to the verified map
with self.lock:
self.unverified_tx.pop(tx_hash, None)
self.db.add_verified_tx(tx_hash, info)
self.invalidate_cache()
util.trigger_callback('adb_added_verified_tx', self, tx_hash)
@with_lock
def get_unverified_txs(self) -> Dict[str, int]:
'''Returns a map from tx hash to transaction height'''
return dict(self.unverified_tx) # copy
def undo_verifications(self, blockchain: Blockchain, above_height: int) -> Set[str]:
'''Used by the verifier when a reorg has happened'''
txs = set()
with self.lock:
for tx_hash in self.db.list_verified_tx():
info = self.db.get_verified_tx(tx_hash)
tx_height = info._height
if tx_height > above_height:
header = blockchain.read_header(tx_height)
if not header or hash_header(header) != info.header_hash:
self.db.remove_verified_tx(tx_hash)
# NOTE: we should add these txns to self.unverified_tx,
# but with what height?
# If on the new fork after the reorg, the txn is at the
# same height, we will not get a status update for the
# address. If the txn is not mined or at a diff height,
# we should get a status update. Unless we put tx into
# unverified_tx, it will turn into local. So we put it
# into unverified_tx with the old height, and if we get
# a status update, that will overwrite it.
self.unverified_tx[tx_hash] = tx_height
txs.add(tx_hash)
for tx_hash in txs:
util.trigger_callback('adb_removed_verified_tx', self, tx_hash)
return txs
def get_local_height(self) -> int:
""" return last known height if we are offline """
cached_local_height = getattr(self.threadlocal_cache, 'local_height', None)
if cached_local_height is not None:
return cached_local_height
return self.network.get_local_height() if self.network else self.db.get('stored_height', 0)
def _update_stored_local_height(self) -> None:
self.db.put('stored_height', self.get_local_height())
def set_future_tx(self, txid: str, *, wanted_height: int):
"""Mark a local tx as "future" (encumbered by a timelock).
wanted_height is the min (abs) block height at which the tx can get into the mempool (be broadcast).
note: tx becomes consensus-valid to be mined in a block at height wanted_height+1
In case of a CSV-locked tx with unconfirmed inputs, the wanted_height is a best-case guess.
"""
with self.lock:
old_height = self.future_tx.get(txid) or None
self.future_tx[txid] = wanted_height
if old_height != wanted_height:
util.trigger_callback('adb_set_future_tx', self, txid)
def get_tx_height(
self,
tx_hash: str,
*,
force_local_if_missing_tx: bool = True,
) -> TxMinedInfo:
if tx_hash is None: # ugly backwards compat...
return TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0)
with self.lock:
if verified_tx_mined_info := self.db.get_verified_tx(tx_hash): # mined and spv-ed
conf = max(self.get_local_height() - verified_tx_mined_info._height + 1, 0)
tx_mined_info = dataclasses.replace(verified_tx_mined_info, conf=conf)
elif tx_hash in self.unverified_tx: # mined, no spv
height = self.unverified_tx[tx_hash]
tx_mined_info = TxMinedInfo(_height=height, conf=0)
elif tx_hash in self.unconfirmed_tx: # mempool
height = self.unconfirmed_tx[tx_hash]
tx_mined_info = TxMinedInfo(_height=height, conf=0)
elif wanted_height := self.future_tx.get(tx_hash): # future
if wanted_height > self.get_local_height():
tx_mined_info = TxMinedInfo(_height=TX_HEIGHT_FUTURE, conf=0, wanted_height=wanted_height)
else:
tx_mined_info = TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0)
else: # local
tx_mined_info = TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0)
if tx_mined_info.height() in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return tx_mined_info
if force_local_if_missing_tx:
# It can happen for a txid in any state (unconf/unverified/verified) that we
# don't have the raw tx yet, simply due to network timing.
# Having only a partial tx is another variant of this.
# FIXME in fact even if we have a complete tx saved, the server might have
# a different tx if only the witness differs. We should compare wtxids.
tx = self.db.get_transaction(tx_hash)
if tx is None or isinstance(tx, PartialTransaction):
return TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0)
return tx_mined_info
def up_to_date_changed(self) -> None:
# fire triggers
util.trigger_callback('adb_set_up_to_date', self)
def is_up_to_date(self):
if not self.synchronizer or not self.verifier:
return False
return self.synchronizer.is_up_to_date() and self.verifier.is_up_to_date()
def reset_netrequest_counters(self) -> None:
if self.synchronizer:
self.synchronizer.reset_request_counters()
if self.verifier:
self.verifier.reset_request_counters()
def get_history_sync_state_details(self) -> Tuple[int, int]:
nsent, nans = 0, 0
if self.synchronizer:
n1, n2 = self.synchronizer.num_requests_sent_and_answered()
nsent += n1
nans += n2
if self.verifier:
n1, n2 = self.verifier.num_requests_sent_and_answered()
nsent += n1
nans += n2
return nsent, nans
@with_lock
def get_tx_delta(self, tx_hash: str, address: str) -> int:
"""effect of tx on address"""
delta = 0
# subtract the value of coins sent from address
d = self.db.get_txi_addr(tx_hash, address)
for n, v in d:
delta -= v
# add the value of the coins received at address
d = self.db.get_txo_addr(tx_hash, address)
for n, (v, cb) in d.items():
delta += v
return delta
@with_lock
def get_tx_fee(self, txid: str) -> Optional[int]:
"""Returns tx_fee or None. Use server fee only if tx is unconfirmed and not mine.
Note: being fast is prioritised over completeness here. We try to avoid deserializing
the tx, as that is expensive if we are called for the whole history. We sometimes
incorrectly early-exit and return None, e.g. for not-all-ismine-input txs,
where we could calculate the fee if we deserialized (but to see if we have all
the parent txs available, we would have to deserialize first).
More expensive but more complete alternative: wallet.get_tx_info(tx).fee
"""
# check if stored fee is available
fee = self.db.get_tx_fee(txid, trust_server=False)
if fee is not None:
return fee
# delete server-sent fee for confirmed txns
confirmed = self.get_tx_height(txid).conf > 0
if confirmed:
self.db.add_tx_fee_from_server(txid, None)
# if all inputs are ismine, try to calc fee now;
# otherwise, return stored value
num_all_inputs = self.db.get_num_all_inputs_of_tx(txid)
if num_all_inputs is not None:
# check if tx is mine
num_ismine_inputs = self.db.get_num_ismine_inputs_of_tx(txid)
assert num_ismine_inputs <= num_all_inputs, (num_ismine_inputs, num_all_inputs)
# trust server if tx is unconfirmed and not mine
if num_ismine_inputs < num_all_inputs:
return None if confirmed else self.db.get_tx_fee(txid, trust_server=True)
# lookup tx and deserialize it.
# note that deserializing is expensive, hence above hacks
tx = self.db.get_transaction(txid)
if not tx:
return None
# compute fee if possible
v_in = v_out = 0
for txin in tx.inputs():
addr = self.get_txin_address(txin)
value = self.get_txin_value(txin, address=addr)
if value is None:
v_in = None
elif v_in is not None:
v_in += value
for txout in tx.outputs():
v_out += txout.value
if v_in is not None:
fee = v_in - v_out
else:
fee = None
# save result
self.db.add_tx_fee_we_calculated(txid, fee)
self.db.add_num_inputs_to_tx(txid, len(tx.inputs()))
return fee
@with_lock
def get_addr_io(self, address: str):
h = self.get_address_history(address).items()
received = {} # type: Dict[str, tuple[int, int, int, bool]]
sent = {} # type: Dict[str, tuple[str, int, int]]
for tx_hash, height in h:
tx_mined_info = self.get_tx_height(tx_hash)
txpos = tx_mined_info.txpos if tx_mined_info.txpos is not None else -1
d = self.db.get_txo_addr(tx_hash, address)
for n, (v, is_cb) in d.items():
received[tx_hash + ':%d'%n] = (height, txpos, v, is_cb)
l = self.db.get_txi_addr(tx_hash, address)
for txi, v in l:
sent[txi] = tx_hash, height, txpos
return received, sent
def get_addr_outputs(self, address: str) -> Dict[TxOutpoint, PartialTxInput]:
received, sent = self.get_addr_io(address)
out = {}
for prevout_str, v in received.items():
tx_height, tx_pos, value, is_cb = v
prevout = TxOutpoint.from_str(prevout_str)
utxo = PartialTxInput(prevout=prevout, is_coinbase_output=is_cb)
utxo._trusted_address = address
utxo._trusted_value_sats = value
utxo.block_height = tx_height
utxo.block_txpos = tx_pos
if prevout_str in sent:
txid, height, pos = sent[prevout_str]
utxo.spent_txid = txid
utxo.spent_height = height
else:
utxo.spent_txid = None
utxo.spent_height = None
out[prevout] = utxo
return out
def get_addr_utxo(self, address: str) -> Dict[TxOutpoint, PartialTxInput]:
out = self.get_addr_outputs(address)
for k, v in list(out.items()):
if v.spent_height is not None:
out.pop(k)
return out
# return the total amount ever received by an address
def get_addr_received(self, address):
received, sent = self.get_addr_io(address)
return sum([value for height, pos, value, is_cb in received.values()])
@with_lock
@with_local_height_cached
def get_balance(self, domain, *, excluded_addresses: Set[str] = None,
excluded_coins: Set[str] = None) -> Tuple[int, int, int]:
"""Return the balance of a set of addresses:
confirmed and matured, unconfirmed, unmatured
Note: intended for display-purposes. would need extreme care for "has enough funds" checks (see #8835)
"""
if excluded_addresses is None:
excluded_addresses = set()
assert isinstance(excluded_addresses, set), f"excluded_addresses should be set, not {type(excluded_addresses)}"
domain = set(domain) - excluded_addresses
if excluded_coins is None:
excluded_coins = set()
assert isinstance(excluded_coins, set), f"excluded_coins should be set, not {type(excluded_coins)}"
cache_key = sha256(','.join(sorted(domain)) + ';'
+ ','.join(sorted(excluded_coins)))
cached_value = self._get_balance_cache.get(cache_key)
if cached_value:
return cached_value
coins = {}
for address in domain:
coins.update(self.get_addr_outputs(address))
c = u = x = 0
mempool_height = self.get_local_height() + 1 # height of next block
for utxo in coins.values(): # type: PartialTxInput
if utxo.spent_height is not None:
continue
if utxo.prevout.to_str() in excluded_coins:
continue
v = utxo.value_sats()
tx_height = utxo.block_height
is_cb = utxo.is_coinbase_output()
if is_cb and tx_height + COINBASE_MATURITY > mempool_height:
x += v
elif tx_height > 0:
c += v
else:
txid = utxo.prevout.txid.hex()
tx = self.db.get_transaction(txid)
assert tx is not None # txid comes from get_addr_io
# we look at the outputs that are spent by this transaction
# if those outputs are ours and confirmed, we count this coin as confirmed
confirmed_spent_amount = 0
for txin in tx.inputs():
if txin.prevout in coins:
coin = coins[txin.prevout]
if coin.block_height > 0:
confirmed_spent_amount += coin.value_sats()
# Compare amount, in case tx has confirmed and unconfirmed inputs, or is a coinjoin.
# (fixme: tx may have multiple change outputs)
if confirmed_spent_amount >= v:
c += v
else:
c += confirmed_spent_amount
u += v - confirmed_spent_amount
result = c, u, x
# cache result.
# Cache needs to be invalidated if a transaction is added to/
# removed from history; or on new blocks (maturity...)
self._get_balance_cache[cache_key] = result
return result
@with_lock
@with_local_height_cached
def get_utxos(
self,
domain,
*,
excluded_addresses=None,
mature_only: bool = False,
confirmed_funding_only: bool = False,
confirmed_spending_only: bool = False,
nonlocal_only: bool = False,
block_height: int = None,
) -> Sequence[PartialTxInput]:
if block_height is not None:
# caller wants the UTXOs we had at a given height; check other parameters
assert confirmed_funding_only
assert confirmed_spending_only
assert nonlocal_only
else:
block_height = self.get_local_height()
coins = []
domain = set(domain)
if excluded_addresses:
domain = set(domain) - set(excluded_addresses)
mempool_height = block_height + 1 # height of next block
cache_key = sha256(
','.join(sorted(domain))
+ f";{mature_only};{confirmed_funding_only};{confirmed_spending_only};{nonlocal_only};{block_height}"
)
cached = self._get_utxos_cache.get(cache_key)
if cached is not None:
return copy.deepcopy(cached)
for addr in domain:
txos = self.get_addr_outputs(addr)
for txo in txos.values():
if txo.spent_height is not None:
if not confirmed_spending_only:
continue
if confirmed_spending_only and 0 < txo.spent_height <= block_height:
continue
if confirmed_funding_only and not (0 < txo.block_height <= block_height):
continue
if nonlocal_only and txo.block_height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
continue
if (mature_only and txo.is_coinbase_output()
and txo.block_height + COINBASE_MATURITY > mempool_height):
continue
coins.append(txo)
continue
self._get_utxos_cache[cache_key] = copy.deepcopy(coins)
return coins
def is_used(self, address: str) -> bool:
"""Whether any tx ever touched `address`."""
return self.get_address_history_len(address) != 0
def is_used_as_from_address(self, address: str) -> bool:
"""Whether any tx ever spent from `address`."""
received, sent = self.get_addr_io(address)
return len(sent) > 0
def is_empty(self, address: str) -> bool:
coins = self.get_addr_utxo(address)
return not bool(coins)
@with_lock
@with_local_height_cached
def address_is_old(self, address: str, *, req_conf: int = 3) -> bool:
"""Returns whether address has any history that is deeply confirmed.
Used for reorg-safe(ish) gap limit roll-forward.
"""
max_conf = -1
h = self.db.get_addr_history(address)
needs_spv_check = not self.config.NETWORK_SKIPMERKLECHECK
for tx_hash, tx_height in h:
if needs_spv_check:
tx_age = self.get_tx_height(tx_hash).conf
else:
if tx_height <= 0:
tx_age = 0
else:
tx_age = self.get_local_height() - tx_height + 1
max_conf = max(max_conf, tx_age)
return max_conf >= req_conf
@with_lock
def get_spender(self, outpoint: str) -> Optional[str]:
"""
returns txid spending outpoint.
subscribes to addresses as a side effect.
"""
prev_txid, index = outpoint.split(':')
spender_txid = self.db.get_spent_outpoint(prev_txid, int(index))
# discard local spenders
tx_mined_status = self.get_tx_height(spender_txid)
if tx_mined_status.height() in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]:
spender_txid = None
if not spender_txid:
return None
spender_tx = self.get_transaction(spender_txid)
for i, o in enumerate(spender_tx.outputs()):
if o.address is None:
continue
if not self.is_mine(o.address):
self.add_address(o.address)
return spender_txid
def get_tx_mined_depth(self, txid: str):
if not txid:
return TxMinedDepth.FREE
tx_mined_depth = self.get_tx_height(txid)
height, conf = tx_mined_depth.height(), tx_mined_depth.conf
if conf > 20: # FIXME unify with lnutil.REDEEM_AFTER_DOUBLE_SPENT_DELAY ?
return TxMinedDepth.DEEP
elif conf > 0:
return TxMinedDepth.SHALLOW
elif height in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT):
return TxMinedDepth.MEMPOOL
elif height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return TxMinedDepth.FREE
elif height > 0 and conf == 0:
# unverified but claimed to be mined
return TxMinedDepth.MEMPOOL
else:
raise NotImplementedError()
def is_deeply_mined(self, txid):
return self.get_tx_mined_depth(txid) == TxMinedDepth.DEEP
================================================
FILE: electrum/base_crash_reporter.py
================================================
# 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 asyncio
import json
import locale
import traceback
import sys
import queue
from typing import TYPE_CHECKING, NamedTuple, Optional, TypedDict
from types import TracebackType
from .version import ELECTRUM_VERSION
from . import constants
from .i18n import _
from .util import make_aiohttp_session, error_text_str_to_safe_str
from .logging import describe_os_version, Logger, get_git_version
from .crypto import sha256
if TYPE_CHECKING:
from .network import ProxySettings
_tainted_by_console = False
def taint_reports_by_console_usage():
global _tainted_by_console
_tainted_by_console = True
class CrashReportResponse(NamedTuple):
status: Optional[str]
text: str
url: Optional[str]
class BaseCrashReporter(Logger):
report_server = "https://crashhub.electrum.org"
issue_template = """
Traceback
{traceback}
Additional information
Electrum version: {app_version}
Python version: {python_version}
Operating system: {os}
Wallet type: {wallet_type}
Locale: {locale}
"""
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?")
USER_COMMENT_PLACEHOLDER = _("Do not enter sensitive/private information here. "
"The report will be visible on the public issue tracker.")
exc_args: tuple[type[BaseException], BaseException, TracebackType | None]
def __init__(
self,
exctype: type[BaseException],
excvalue: BaseException,
tb: TracebackType | None,
):
Logger.__init__(self)
self.exc_args = (exctype, excvalue, tb)
def send_report(self, asyncio_loop, proxy: 'ProxySettings', *, timeout=None) -> CrashReportResponse:
# FIXME the caller needs to catch generic "Exception", as this method does not have a well-defined API...
if (constants.net.GENESIS[-4:] not in [
"e26f", # mainnet
"4943", # testnet 3
"f043", # testnet 4
"1ef6", # signet
] 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(*self.exc_args)
report.update(self.get_additional_info())
report = json.dumps(report)
coro = self.do_post(proxy, BaseCrashReporter.report_server + "/crash.json", data=report)
response = asyncio.run_coroutine_threadsafe(coro, asyncio_loop).result(timeout)
self.logger.info(
f"Crash report sent. Got response [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(response)}")
response = json.loads(response)
assert isinstance(response, dict), type(response)
# sanitize URL
if location := response.get("location"):
assert isinstance(location, str)
base_issues_url = constants.GIT_REPO_ISSUES_URL
if not base_issues_url.endswith("/"):
base_issues_url = base_issues_url + "/"
if not location.startswith(base_issues_url):
location = None
ret = CrashReportResponse(
status=response.get("status"),
url=location,
text=_("Thanks for reporting this issue!"),
)
return ret
async def do_post(self, proxy: 'ProxySettings', url, data) -> str:
async with make_aiohttp_session(proxy) as session:
async with session.post(url, data=data, raise_for_status=True) as resp:
return await resp.text()
@classmethod
def get_traceback_info(
cls,
exctype: type[BaseException],
excvalue: BaseException,
tb: TracebackType | None,
) -> TypedDict('TBInfo', {'exc_string': str, 'stack': str, 'id': dict[str, str]}):
exc_string = str(excvalue)
stack = traceback.extract_tb(tb)
readable_trace = cls._get_traceback_str_to_send(exctype, excvalue, tb)
_id = {
"file": stack[-1].filename if len(stack) else '',
"name": stack[-1].name if len(stack) else '',
"type": exctype.__name__
} # note: this is the "id" the crash reporter server uses to group together reports.
return {
"exc_string": exc_string,
"stack": readable_trace,
"id": _id,
}
@classmethod
def get_traceback_groupid_hash(
cls,
exctype: type[BaseException],
excvalue: BaseException,
tb: TracebackType | None,
) -> bytes:
tb_info = cls.get_traceback_info(exctype, excvalue, tb)
_id = tb_info["id"]
return sha256(str(_id))
def get_additional_info(self):
app_version = (get_git_version() or ELECTRUM_VERSION)
if _tainted_by_console:
app_version += "-consoletaint"
args = {
"app_version": app_version,
"python_version": sys.version,
"os": describe_os_version(),
"wallet_type": "unknown",
"locale": locale.getlocale()[0] or "?",
"description": self.get_user_description()
}
try:
args["wallet_type"] = self.get_wallet_type()
except Exception:
# Maybe the wallet isn't loaded yet
pass
return args
@classmethod
def _get_traceback_str_to_send(
cls,
exctype: type[BaseException],
excvalue: BaseException,
tb: TracebackType | None,
) -> str:
# make sure that traceback sent to crash reporter contains
# e.__context__ and e.__cause__, i.e. if there was a chain of
# exceptions, we want the full traceback for the whole chain.
return "".join(traceback.format_exception(exctype, excvalue, tb))
def _get_traceback_str_to_display(self) -> str:
# overridden in Qt subclass
return self._get_traceback_str_to_send(*self.exc_args)
def get_report_string(self):
info = self.get_additional_info()
info["traceback"] = self._get_traceback_str_to_display()
return self.issue_template.format(**info)
def get_user_description(self):
raise NotImplementedError
def get_wallet_type(self) -> str:
raise NotImplementedError
class EarlyExceptionsQueue:
"""Helper singleton for explicitly sending exceptions to crash reporter.
Typically the GUIs set up an "exception hook" that catches all otherwise
uncaught exceptions (which unroll the stack of a thread completely).
This class provides methods to report *any* exception, and queueing logic
that delays processing until the exception hook is set up.
"""
_is_exc_hook_ready = False
_exc_queue = queue.Queue()
@classmethod
def set_hook_as_ready(cls):
"""Flush the queue and disable it for future exceptions."""
if cls._is_exc_hook_ready:
return
cls._is_exc_hook_ready = True
while cls._exc_queue.qsize() > 0:
e = cls._exc_queue.get()
cls._send_exception_to_crash_reporter(e)
@classmethod
def send_exception_to_crash_reporter(cls, e: BaseException):
if cls._is_exc_hook_ready:
cls._send_exception_to_crash_reporter(e)
else:
cls._exc_queue.put(e)
@staticmethod
def _send_exception_to_crash_reporter(e: BaseException):
assert EarlyExceptionsQueue._is_exc_hook_ready
sys.excepthook(type(e), e, e.__traceback__)
send_exception_to_crash_reporter = EarlyExceptionsQueue.send_exception_to_crash_reporter
def trigger_crash():
# note: do not change the type of the exception, the message,
# or the name of this method. All reports generated through this
# method will be grouped together by the crash reporter, and thus
# don't spam the issue tracker.
class TestingException(Exception):
pass
def crash_test():
raise TestingException("triggered crash for testing purposes")
import threading
t = threading.Thread(target=crash_test)
t.start()
================================================
FILE: electrum/bip21.py
================================================
import urllib
import urllib.parse
import re
from decimal import Decimal
from typing import Optional
from . import bitcoin
from .util import format_satoshis_plain
from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from .lnaddr import lndecode, LnDecodeException
# note: when checking against these, use .lower() to support case-insensitivity
BITCOIN_BIP21_URI_SCHEME = 'bitcoin'
LIGHTNING_URI_SCHEME = 'lightning'
class InvalidBitcoinURI(Exception):
pass
def parse_bip21_URI(uri: str) -> dict:
"""Raises InvalidBitcoinURI on malformed URI."""
if not isinstance(uri, str):
raise InvalidBitcoinURI(f"expected string, not {repr(uri)}")
if ':' not in uri:
if not bitcoin.is_address(uri):
raise InvalidBitcoinURI("Not a bitcoin address")
return {'address': uri}
u = urllib.parse.urlparse(uri)
if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME:
raise InvalidBitcoinURI("Not a bitcoin URI")
address = u.path
# python for android fails to parse query
if address.find('?') > 0:
address, query = u.path.split('?')
pq = urllib.parse.parse_qs(query)
else:
pq = urllib.parse.parse_qs(u.query)
for k, v in pq.items():
if len(v) != 1:
raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}')
if k.startswith('req-'):
# we have no support for any req-* query parameters
raise InvalidBitcoinURI(f'Unsupported Key: {repr(k)}')
out = {k: v[0] for k, v in pq.items()}
if address:
if not bitcoin.is_address(address):
raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}")
out['address'] = address
if 'amount' in out:
am = out['amount']
try:
m = re.match(r'([0-9.]+)X([0-9])', am)
if m:
k = int(m.group(2)) - 8
amount = Decimal(m.group(1)) * pow(Decimal(10), k)
else:
amount = Decimal(am) * COIN
if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN or amount <= 0:
raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC")
out['amount'] = int(amount)
except Exception as e:
raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e
if 'message' in out:
out['message'] = out['message']
out['memo'] = out['message']
if 'time' in out:
try:
out['time'] = int(out['time'])
except Exception as e:
raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e
if 'exp' in out:
try:
out['exp'] = int(out['exp'])
except Exception as e:
raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e
if 'sig' in out:
try:
out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex()
except Exception as e:
raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e
if 'lightning' in out:
try:
lnaddr = lndecode(out['lightning'])
except LnDecodeException as e:
raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e
amount_sat = out.get('amount')
if amount_sat:
# allow small leeway due to msat precision
if lnaddr.get_amount_sat() is None or abs(amount_sat - int(lnaddr.get_amount_sat())) > 1:
raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount")
address = out.get('address')
ln_fallback_addr = lnaddr.get_fallback_address()
if address and ln_fallback_addr:
if ln_fallback_addr != address:
raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address")
return out
def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str],
*, extra_query_params: Optional[dict] = None) -> str:
if not bitcoin.is_address(addr):
return ""
if extra_query_params is None:
extra_query_params = {}
query = []
if amount_sat:
query.append('amount=%s' % format_satoshis_plain(amount_sat))
if message:
query.append('message=%s' % urllib.parse.quote(message))
for k, v in extra_query_params.items():
if not isinstance(k, str) or k != urllib.parse.quote(k):
raise Exception(f"illegal key for URI: {repr(k)}")
v = urllib.parse.quote(v)
query.append(f"{k}={v}")
p = urllib.parse.ParseResult(
scheme=BITCOIN_BIP21_URI_SCHEME,
netloc='',
path=addr,
params='',
query='&'.join(query),
fragment=''
)
return str(urllib.parse.urlunparse(p))
================================================
FILE: electrum/bip32.py
================================================
# Copyright (C) 2018 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
import binascii
import hashlib
import struct
from typing import List, Tuple, NamedTuple, Union, Iterable, Sequence, Optional
import electrum_ecc as ecc
from .util import bfh, BitcoinException
from . import constants
from .crypto import hash_160, hmac_oneshot
from .bitcoin import EncodeBase58Check, DecodeBase58Check
from .logging import get_logger
_logger = get_logger(__name__)
BIP32_PRIME = 0x80000000
UINT32_MAX = (1 << 32) - 1
BIP32_HARDENED_CHAR = "h" # default "hardened" char we put in str paths
def protect_against_invalid_ecpoint(func):
def func_wrapper(*args):
child_index = args[-1]
while True:
is_prime = child_index & BIP32_PRIME
try:
return func(*args[:-1], child_index=child_index)
except ecc.InvalidECPointException:
_logger.warning('bip32 protect_against_invalid_ecpoint: skipping index')
child_index += 1
is_prime2 = child_index & BIP32_PRIME
if is_prime != is_prime2: raise OverflowError()
return func_wrapper
@protect_against_invalid_ecpoint
def CKD_priv(parent_privkey: bytes, parent_chaincode: bytes, child_index: int) -> Tuple[bytes, bytes]:
"""Child private key derivation function (from master private key)
If n is hardened (i.e. the 32nd bit is set), the resulting private key's
corresponding public key can NOT be determined without the master private key.
However, if n is not hardened, the resulting private key's corresponding
public key can be determined without the master private key.
"""
if child_index < 0: raise ValueError('the bip32 index needs to be non-negative')
is_hardened_child = bool(child_index & BIP32_PRIME)
return _CKD_priv(parent_privkey=parent_privkey,
parent_chaincode=parent_chaincode,
child_index=int.to_bytes(child_index, length=4, byteorder="big", signed=False),
is_hardened_child=is_hardened_child)
def _CKD_priv(parent_privkey: bytes, parent_chaincode: bytes,
child_index: bytes, is_hardened_child: bool) -> Tuple[bytes, bytes]:
try:
keypair = ecc.ECPrivkey(parent_privkey)
except ecc.InvalidECPointException as e:
raise BitcoinException('Impossible xprv (not within curve order)') from e
parent_pubkey = keypair.get_public_key_bytes(compressed=True)
if is_hardened_child:
data = bytes([0]) + parent_privkey + child_index
else:
data = parent_pubkey + child_index
I = hmac_oneshot(parent_chaincode, data, hashlib.sha512)
I_left = ecc.string_to_number(I[0:32])
child_privkey = (I_left + ecc.string_to_number(parent_privkey)) % ecc.CURVE_ORDER
if I_left >= ecc.CURVE_ORDER or child_privkey == 0:
raise ecc.InvalidECPointException()
child_privkey = int.to_bytes(child_privkey, length=32, byteorder='big', signed=False)
child_chaincode = I[32:]
return child_privkey, child_chaincode
@protect_against_invalid_ecpoint
def CKD_pub(parent_pubkey: bytes, parent_chaincode: bytes, child_index: int) -> Tuple[bytes, bytes]:
"""Child public key derivation function (from public key only)
This function allows us to find the nth public key, as long as n is
not hardened. If n is hardened, we need the master private key to find it.
"""
if child_index < 0: raise ValueError('the bip32 index needs to be non-negative')
if child_index & BIP32_PRIME: raise Exception('not possible to derive hardened child from parent pubkey')
return _CKD_pub(parent_pubkey=parent_pubkey,
parent_chaincode=parent_chaincode,
child_index=int.to_bytes(child_index, length=4, byteorder="big", signed=False))
# helper function, callable with arbitrary 'child_index' byte-string.
# i.e.: 'child_index' does not need to fit into 32 bits here! (c.f. trustedcoin billing)
def _CKD_pub(parent_pubkey: bytes, parent_chaincode: bytes, child_index: bytes) -> Tuple[bytes, bytes]:
I = hmac_oneshot(parent_chaincode, parent_pubkey + child_index, hashlib.sha512)
pubkey = ecc.ECPrivkey(I[0:32]) + ecc.ECPubkey(parent_pubkey)
if pubkey.is_at_infinity():
raise ecc.InvalidECPointException()
child_pubkey = pubkey.get_public_key_bytes(compressed=True)
child_chaincode = I[32:]
return child_pubkey, child_chaincode
def xprv_header(xtype: str, *, net=None) -> bytes:
if net is None:
net = constants.net
return net.XPRV_HEADERS[xtype].to_bytes(length=4, byteorder="big")
def xpub_header(xtype: str, *, net=None) -> bytes:
if net is None:
net = constants.net
return net.XPUB_HEADERS[xtype].to_bytes(length=4, byteorder="big")
class InvalidMasterKeyVersionBytes(BitcoinException): pass
class BIP32Node(NamedTuple):
xtype: str
eckey: Union[ecc.ECPubkey, ecc.ECPrivkey]
chaincode: bytes
depth: int = 0
fingerprint: bytes = b'\x00'*4 # as in serialized format, this is the *parent's* fingerprint
child_number: bytes = b'\x00'*4
@classmethod
def from_xkey(
cls,
xkey: str,
*,
net=None,
allow_custom_headers: bool = True, # to also accept ypub/zpub
) -> 'BIP32Node':
if net is None:
net = constants.net
xkey = DecodeBase58Check(xkey)
if len(xkey) != 78:
raise BitcoinException('Invalid length for extended key: {}'
.format(len(xkey)))
depth = xkey[4]
fingerprint = xkey[5:9]
child_number = xkey[9:13]
chaincode = xkey[13:13 + 32]
header = int.from_bytes(xkey[0:4], byteorder='big')
if header in net.XPRV_HEADERS_INV:
headers_inv = net.XPRV_HEADERS_INV
is_private = True
elif header in net.XPUB_HEADERS_INV:
headers_inv = net.XPUB_HEADERS_INV
is_private = False
else:
raise InvalidMasterKeyVersionBytes(f'Invalid extended key format: {hex(header)}')
xtype = headers_inv[header]
if not allow_custom_headers and xtype != "standard":
raise ValueError(f"only standard xpub/xprv allowed. found custom xtype={xtype}")
if is_private:
eckey = ecc.ECPrivkey(xkey[13 + 33:])
else:
eckey = ecc.ECPubkey(xkey[13 + 32:])
return BIP32Node(xtype=xtype,
eckey=eckey,
chaincode=chaincode,
depth=depth,
fingerprint=fingerprint,
child_number=child_number)
@classmethod
def from_rootseed(cls, seed: bytes, *, xtype: str) -> 'BIP32Node':
I = hmac_oneshot(b"Bitcoin seed", seed, hashlib.sha512)
master_k = I[0:32]
master_c = I[32:]
return BIP32Node(xtype=xtype,
eckey=ecc.ECPrivkey(master_k),
chaincode=master_c)
@classmethod
def from_bytes(cls, b: bytes) -> 'BIP32Node':
if len(b) != 78:
raise Exception(f"unexpected xkey raw bytes len {len(b)} != 78")
xkey = EncodeBase58Check(b)
return cls.from_xkey(xkey)
def to_xprv(self, *, net=None) -> str:
payload = self.to_xprv_bytes(net=net)
return EncodeBase58Check(payload)
def to_xprv_bytes(self, *, net=None) -> bytes:
if not self.is_private():
raise Exception("cannot serialize as xprv; private key missing")
payload = (xprv_header(self.xtype, net=net) +
bytes([self.depth]) +
self.fingerprint +
self.child_number +
self.chaincode +
bytes([0]) +
self.eckey.get_secret_bytes())
assert len(payload) == 78, f"unexpected xprv payload len {len(payload)}"
return payload
def to_xpub(self, *, net=None) -> str:
payload = self.to_xpub_bytes(net=net)
return EncodeBase58Check(payload)
def to_xpub_bytes(self, *, net=None) -> bytes:
payload = (xpub_header(self.xtype, net=net) +
bytes([self.depth]) +
self.fingerprint +
self.child_number +
self.chaincode +
self.eckey.get_public_key_bytes(compressed=True))
assert len(payload) == 78, f"unexpected xpub payload len {len(payload)}"
return payload
def to_xkey(self, *, net=None) -> str:
if self.is_private():
return self.to_xprv(net=net)
else:
return self.to_xpub(net=net)
def to_bytes(self, *, net=None) -> bytes:
if self.is_private():
return self.to_xprv_bytes(net=net)
else:
return self.to_xpub_bytes(net=net)
def convert_to_public(self) -> 'BIP32Node':
if not self.is_private():
return self
pubkey = ecc.ECPubkey(self.eckey.get_public_key_bytes())
return self._replace(eckey=pubkey)
def is_private(self) -> bool:
return isinstance(self.eckey, ecc.ECPrivkey)
def subkey_at_private_derivation(self, path: Union[str, Iterable[int]]) -> 'BIP32Node':
if path is None:
raise Exception("derivation path must not be None")
if isinstance(path, str):
path = convert_bip32_strpath_to_intpath(path)
if not self.is_private():
raise Exception("cannot do bip32 private derivation; private key missing")
if not path:
return self
depth = self.depth
chaincode = self.chaincode
privkey = self.eckey.get_secret_bytes()
for child_index in path:
parent_privkey = privkey
privkey, chaincode = CKD_priv(privkey, chaincode, child_index)
depth += 1
parent_pubkey = ecc.ECPrivkey(parent_privkey).get_public_key_bytes(compressed=True)
fingerprint = hash_160(parent_pubkey)[0:4]
child_number = child_index.to_bytes(length=4, byteorder="big")
return BIP32Node(xtype=self.xtype,
eckey=ecc.ECPrivkey(privkey),
chaincode=chaincode,
depth=depth,
fingerprint=fingerprint,
child_number=child_number)
def subkey_at_public_derivation(self, path: Union[str, Iterable[int]]) -> 'BIP32Node':
if path is None:
raise Exception("derivation path must not be None")
if isinstance(path, str):
path = convert_bip32_strpath_to_intpath(path)
if not path:
return self.convert_to_public()
depth = self.depth
chaincode = self.chaincode
pubkey = self.eckey.get_public_key_bytes(compressed=True)
for child_index in path:
parent_pubkey = pubkey
pubkey, chaincode = CKD_pub(pubkey, chaincode, child_index)
depth += 1
fingerprint = hash_160(parent_pubkey)[0:4]
child_number = child_index.to_bytes(length=4, byteorder="big")
return BIP32Node(xtype=self.xtype,
eckey=ecc.ECPubkey(pubkey),
chaincode=chaincode,
depth=depth,
fingerprint=fingerprint,
child_number=child_number)
def calc_fingerprint_of_this_node(self) -> bytes:
"""Returns the fingerprint of this node.
Note that self.fingerprint is of the *parent*.
"""
# TODO cache this
return hash_160(self.eckey.get_public_key_bytes(compressed=True))[0:4]
def xpub_type(x: str) -> str:
assert x is not None
return BIP32Node.from_xkey(x).xtype
def is_xpub(text: str) -> bool:
try:
node = BIP32Node.from_xkey(text)
return not node.is_private()
except Exception:
return False
def is_xprv(text: str) -> bool:
try:
node = BIP32Node.from_xkey(text)
return node.is_private()
except Exception:
return False
def xpub_from_xprv(xprv: str) -> str:
return BIP32Node.from_xkey(xprv).to_xpub()
def convert_bip32_strpath_to_intpath(n: str) -> List[int]:
"""Convert bip32 path str to list of uint32 integers with prime flags
m/0/-1/1' -> [0, 0x80000001, 0x80000001]
based on code in trezorlib
"""
if not n:
return []
if n.endswith("/"):
n = n[:-1]
n = n.split('/')
# cut leading "m" if present, but do not require it
if n[0] == "m":
n = n[1:]
path = []
for x in n:
if x == '':
# gracefully allow repeating "/" chars in path.
# makes concatenating paths easier
continue
prime = 0
if x.endswith("'") or x.endswith("h"): # note: some implementations also accept "H", "p", "P"
x = x[:-1]
prime = BIP32_PRIME
if x.startswith('-'):
if prime:
raise ValueError(f"bip32 path child index is signalling hardened level in multiple ways")
prime = BIP32_PRIME
try:
x_int = int(x)
except ValueError as e:
raise ValueError(f"failed to parse bip32 path: {(str(e))}") from None
child_index = abs(x_int) | prime
if child_index > UINT32_MAX:
raise ValueError(f"bip32 path child index too large: {child_index} > {UINT32_MAX}")
path.append(child_index)
return path
def convert_bip32_intpath_to_strpath(path: Sequence[int], *, hardened_char=BIP32_HARDENED_CHAR) -> str:
assert isinstance(hardened_char, str), hardened_char
assert len(hardened_char) == 1, hardened_char
s = "m/"
for child_index in path:
if not isinstance(child_index, int):
raise TypeError(f"bip32 path child index must be int: {child_index}")
if not (0 <= child_index <= UINT32_MAX):
raise ValueError(f"bip32 path child index out of range: {child_index}")
prime = ""
if child_index & BIP32_PRIME:
prime = hardened_char
child_index = child_index ^ BIP32_PRIME
s += str(child_index) + prime + '/'
# cut trailing "/"
s = s[:-1]
return s
def is_bip32_derivation(s: str) -> bool:
try:
if not (s == 'm' or s.startswith('m/')):
return False
convert_bip32_strpath_to_intpath(s)
except Exception:
return False
else:
return True
def normalize_bip32_derivation(s: Optional[str], *, hardened_char=BIP32_HARDENED_CHAR) -> Optional[str]:
if s is None:
return None
if not is_bip32_derivation(s):
raise ValueError(f"invalid bip32 derivation: {s}")
ints = convert_bip32_strpath_to_intpath(s)
return convert_bip32_intpath_to_strpath(ints, hardened_char=hardened_char)
def is_all_public_derivation(path: Union[str, Iterable[int]]) -> bool:
"""Returns whether all levels in path use non-hardened derivation."""
if isinstance(path, str):
path = convert_bip32_strpath_to_intpath(path)
for child_index in path:
if child_index < 0:
raise ValueError('the bip32 index needs to be non-negative')
if child_index & BIP32_PRIME:
return False
return True
def root_fp_and_der_prefix_from_xkey(xkey: str) -> Tuple[Optional[str], Optional[str]]:
"""Returns the root bip32 fingerprint and the derivation path from the
root to the given xkey, if they can be determined. Otherwise (None, None).
"""
node = BIP32Node.from_xkey(xkey)
derivation_prefix = None
root_fingerprint = None
assert node.depth >= 0, node.depth
if node.depth == 0:
derivation_prefix = 'm'
root_fingerprint = node.calc_fingerprint_of_this_node().hex().lower()
elif node.depth == 1:
child_number_int = int.from_bytes(node.child_number, 'big')
derivation_prefix = convert_bip32_intpath_to_strpath([child_number_int])
root_fingerprint = node.fingerprint.hex()
return root_fingerprint, derivation_prefix
def is_xkey_consistent_with_key_origin_info(xkey: str, *,
derivation_prefix: str = None,
root_fingerprint: str = None) -> bool:
bip32node = BIP32Node.from_xkey(xkey)
int_path = None
if derivation_prefix is not None:
int_path = convert_bip32_strpath_to_intpath(derivation_prefix)
if int_path is not None and len(int_path) != bip32node.depth:
return False
if bip32node.depth == 0:
if bfh(root_fingerprint) != bip32node.calc_fingerprint_of_this_node():
return False
if bip32node.child_number != bytes(4):
return False
if int_path is not None and bip32node.depth > 0:
if int.from_bytes(bip32node.child_number, 'big') != int_path[-1]:
return False
if bip32node.depth == 1:
if bfh(root_fingerprint) != bip32node.fingerprint:
return False
return True
class KeyOriginInfo:
"""
Object representing the origin of a key.
from https://github.com/bitcoin-core/HWI/blob/5f300d3dee7b317a6194680ad293eaa0962a3cc7/hwilib/key.py
# Copyright (c) 2020 The HWI developers
# Distributed under the MIT software license.
"""
def __init__(self, fingerprint: bytes, path: Sequence[int]) -> None:
"""
:param fingerprint: The 4 byte BIP 32 fingerprint of a parent key from which this key is derived from
:param path: The derivation path to reach this key from the key at ``fingerprint``
"""
self.fingerprint: bytes = fingerprint
self.path: Sequence[int] = path
@classmethod
def deserialize(cls, s: bytes) -> 'KeyOriginInfo':
"""
Deserialize a serialized KeyOriginInfo.
They will be serialized in the same way that PSBTs serialize derivation paths
"""
fingerprint = s[0:4]
s = s[4:]
path = list(struct.unpack("<" + "I" * (len(s) // 4), s))
return cls(fingerprint, path)
def serialize(self) -> bytes:
"""
Serializes the KeyOriginInfo in the same way that derivation paths are stored in PSBTs
"""
r = self.fingerprint
r += struct.pack("<" + "I" * len(self.path), *self.path)
return r
def _path_string(self) -> str:
strpath = self.get_derivation_path()
if len(strpath) >= 2:
assert strpath.startswith("m/")
return strpath[1:] # cut leading "m"
def to_string(self) -> str:
"""
Return the KeyOriginInfo as a string in the form ///...
This is the same way that KeyOriginInfo is shown in descriptors
"""
s = binascii.hexlify(self.fingerprint).decode()
s += self._path_string()
return s
@classmethod
def from_string(cls, s: str) -> 'KeyOriginInfo':
"""
Create a KeyOriginInfo from the string
:param s: The string to parse
"""
s = s.lower()
entries = s.split("/")
fingerprint = binascii.unhexlify(s[0:8])
path: Sequence[int] = []
if len(entries) > 1:
path = convert_bip32_strpath_to_intpath(s[9:])
return cls(fingerprint, path)
def get_derivation_path(self) -> str:
"""
Return the string for just the path
"""
return convert_bip32_intpath_to_strpath(self.path)
def get_full_int_list(self) -> List[int]:
"""
Return a list of ints representing this KeyOriginInfo.
The first int is the fingerprint, followed by the path
"""
xfp = [struct.unpack(" bool:
if not isinstance(other, KeyOriginInfo):
return False
return self.serialize() == other.serialize()
def __repr__(self) -> str:
return f""
================================================
FILE: electrum/bip39_recovery.py
================================================
# Copyright (C) 2020 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
from typing import TYPE_CHECKING, Optional
import itertools
from . import bitcoin
from .constants import BIP39_WALLET_FORMATS
from .bip32 import BIP32_PRIME, BIP32Node
from .bip32 import convert_bip32_strpath_to_intpath as bip32_str_to_ints
from .bip32 import convert_bip32_intpath_to_strpath as bip32_ints_to_str
from .util import OldTaskGroup, NetworkOfflineException
if TYPE_CHECKING:
from .network import Network
async def account_discovery(network: Optional['Network'], get_account_xpub):
if network is None:
raise NetworkOfflineException()
async with OldTaskGroup() as group:
account_scan_tasks = []
for wallet_format in BIP39_WALLET_FORMATS:
account_scan = scan_for_active_accounts(network, get_account_xpub, wallet_format)
account_scan_tasks.append(await group.spawn(account_scan))
active_accounts = []
for task in account_scan_tasks:
active_accounts.extend(task.result())
return active_accounts
async def scan_for_active_accounts(network: 'Network', get_account_xpub, wallet_format):
active_accounts = []
account_path = bip32_str_to_ints(wallet_format["derivation_path"])
while True:
account_xpub = get_account_xpub(account_path)
account_node = BIP32Node.from_xkey(account_xpub)
has_history = await account_has_history(network, account_node, wallet_format["script_type"])
if has_history:
account = format_account(wallet_format, account_path)
active_accounts.append(account)
if not has_history or not wallet_format["iterate_accounts"]:
break
account_path[-1] = account_path[-1] + 1
return active_accounts
async def account_has_history(network: 'Network', account_node: BIP32Node, script_type: str) -> bool:
# note: scan both receiving and change addresses. some wallets send change across accounts.
path_suffixes = itertools.chain(
itertools.product((0,), range(20)), # ad-hoc gap limits
itertools.product((1,), range(10)),
)
async with OldTaskGroup() as group:
get_history_tasks = []
for path_suffix in path_suffixes:
address_node = account_node.subkey_at_public_derivation(path_suffix)
pubkey = address_node.eckey.get_public_key_hex()
address = bitcoin.pubkey_to_address(script_type, pubkey)
script = bitcoin.address_to_script(address)
scripthash = bitcoin.script_to_scripthash(script)
get_history = network.get_history_for_scripthash(scripthash)
get_history_tasks.append(await group.spawn(get_history))
for task in get_history_tasks:
history = task.result()
if len(history) > 0:
return True
return False
def format_account(wallet_format, account_path):
description = wallet_format["description"]
if wallet_format["iterate_accounts"]:
account_index = account_path[-1] % BIP32_PRIME
description = f'{description} (Account {account_index})'
return {
"description": description,
"derivation_path": bip32_ints_to_str(account_path),
"script_type": wallet_format["script_type"],
}
================================================
FILE: electrum/bip39_wallet_formats.json
================================================
[
{
"description": "Standard BIP44 legacy",
"derivation_path": "m/44'/0'/0'",
"script_type": "p2pkh",
"iterate_accounts": true
},
{
"description": "Standard BIP49 compatibility segwit",
"derivation_path": "m/49'/0'/0'",
"script_type": "p2wpkh-p2sh",
"iterate_accounts": true
},
{
"description": "Standard BIP84 native segwit",
"derivation_path": "m/84'/0'/0'",
"script_type": "p2wpkh",
"iterate_accounts": true
},
{
"description": "Non-standard legacy",
"derivation_path": "m/0'",
"script_type": "p2pkh",
"iterate_accounts": true
},
{
"description": "Non-standard compatibility segwit",
"derivation_path": "m/0'",
"script_type": "p2wpkh-p2sh",
"iterate_accounts": true
},
{
"description": "Non-standard native segwit",
"derivation_path": "m/0'",
"script_type": "p2wpkh",
"iterate_accounts": true
},
{
"description": "Non-standard legacy on BIP84 path",
"derivation_path": "m/84'/0'/0'",
"script_type": "p2pkh",
"iterate_accounts": true
},
{
"description": "Non-standard compatibility segwit on BIP84 path",
"derivation_path": "m/84'/0'/0'",
"script_type": "p2wpkh-p2sh",
"iterate_accounts": true
},
{
"description": "Non-standard legacy on BIP49 path",
"derivation_path": "m/49'/0'/0'",
"script_type": "p2pkh",
"iterate_accounts": true
},
{
"description": "Non-standard native segwit on BIP49 path",
"derivation_path": "m/49'/0'/0'",
"script_type": "p2wpkh",
"iterate_accounts": true
},
{
"description": "Copay native segwit",
"derivation_path": "m/44'/0'/0'",
"script_type": "p2wpkh",
"iterate_accounts": true
},
{
"description": "Coolwallet S derivation path using bip44 but with segwit script format",
"derivation_path": "m/44'/0'/0'",
"script_type": "p2wpkh-p2sh",
"iterate_accounts": true
},
{
"description": "Samourai Bad Bank (toxic change)",
"derivation_path": "m/84'/0'/2147483644'",
"script_type": "p2wpkh",
"iterate_accounts": false
},
{
"description": "Samourai Whirlpool Pre Mix",
"derivation_path": "m/84'/0'/2147483645'",
"script_type": "p2wpkh",
"iterate_accounts": false
},
{
"description": "Samourai Whirlpool Post Mix",
"derivation_path": "m/84'/0'/2147483646'",
"script_type": "p2wpkh",
"iterate_accounts": false
},
{
"description": "Samourai Ricochet legacy",
"derivation_path": "m/44'/0'/2147483647'",
"script_type": "p2pkh",
"iterate_accounts": false
},
{
"description": "Samourai Ricochet compatibility segwit",
"derivation_path": "m/49'/0'/2147483647'",
"script_type": "p2wpkh-p2sh",
"iterate_accounts": false
},
{
"description": "Samourai Ricochet native segwit",
"derivation_path": "m/84'/0'/2147483647'",
"script_type": "p2wpkh",
"iterate_accounts": false
}
]
================================================
FILE: electrum/bitcoin.py
================================================
# -*- coding: utf-8 -*-
#
# 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.
from typing import Tuple, TYPE_CHECKING, Optional, Union, Sequence, Mapping, Any
import enum
from enum import IntEnum, Enum
import electrum_ecc as ecc
from electrum_ecc.util import bip340_tagged_hash
from .util import bfh, BitcoinException, assert_bytes, to_bytes, inv_dict, is_hex_str, classproperty
from . import segwit_addr
from . import constants
from .crypto import sha256d, sha256, hash_160
if TYPE_CHECKING:
from .network import Network
from .transaction import OPPushDataGeneric
################################## transactions
COINBASE_MATURITY = 100
COIN = 100000000
TOTAL_COIN_SUPPLY_LIMIT_IN_BTC = 21000000
NLOCKTIME_MIN = 0
NLOCKTIME_BLOCKHEIGHT_MAX = 500_000_000 - 1
NLOCKTIME_MAX = 2 ** 32 - 1
# supported types of transaction outputs
# TODO kill these with fire
TYPE_ADDRESS = 0
TYPE_PUBKEY = 1
TYPE_SCRIPT = 2
class opcodes(IntEnum):
# push value
OP_0 = 0x00
OP_FALSE = OP_0
OP_PUSHDATA1 = 0x4c
OP_PUSHDATA2 = 0x4d
OP_PUSHDATA4 = 0x4e
OP_1NEGATE = 0x4f
OP_RESERVED = 0x50
OP_1 = 0x51
OP_TRUE = OP_1
OP_2 = 0x52
OP_3 = 0x53
OP_4 = 0x54
OP_5 = 0x55
OP_6 = 0x56
OP_7 = 0x57
OP_8 = 0x58
OP_9 = 0x59
OP_10 = 0x5a
OP_11 = 0x5b
OP_12 = 0x5c
OP_13 = 0x5d
OP_14 = 0x5e
OP_15 = 0x5f
OP_16 = 0x60
# control
OP_NOP = 0x61
OP_VER = 0x62
OP_IF = 0x63
OP_NOTIF = 0x64
OP_VERIF = 0x65
OP_VERNOTIF = 0x66
OP_ELSE = 0x67
OP_ENDIF = 0x68
OP_VERIFY = 0x69
OP_RETURN = 0x6a
# stack ops
OP_TOALTSTACK = 0x6b
OP_FROMALTSTACK = 0x6c
OP_2DROP = 0x6d
OP_2DUP = 0x6e
OP_3DUP = 0x6f
OP_2OVER = 0x70
OP_2ROT = 0x71
OP_2SWAP = 0x72
OP_IFDUP = 0x73
OP_DEPTH = 0x74
OP_DROP = 0x75
OP_DUP = 0x76
OP_NIP = 0x77
OP_OVER = 0x78
OP_PICK = 0x79
OP_ROLL = 0x7a
OP_ROT = 0x7b
OP_SWAP = 0x7c
OP_TUCK = 0x7d
# splice ops
OP_CAT = 0x7e
OP_SUBSTR = 0x7f
OP_LEFT = 0x80
OP_RIGHT = 0x81
OP_SIZE = 0x82
# bit logic
OP_INVERT = 0x83
OP_AND = 0x84
OP_OR = 0x85
OP_XOR = 0x86
OP_EQUAL = 0x87
OP_EQUALVERIFY = 0x88
OP_RESERVED1 = 0x89
OP_RESERVED2 = 0x8a
# numeric
OP_1ADD = 0x8b
OP_1SUB = 0x8c
OP_2MUL = 0x8d
OP_2DIV = 0x8e
OP_NEGATE = 0x8f
OP_ABS = 0x90
OP_NOT = 0x91
OP_0NOTEQUAL = 0x92
OP_ADD = 0x93
OP_SUB = 0x94
OP_MUL = 0x95
OP_DIV = 0x96
OP_MOD = 0x97
OP_LSHIFT = 0x98
OP_RSHIFT = 0x99
OP_BOOLAND = 0x9a
OP_BOOLOR = 0x9b
OP_NUMEQUAL = 0x9c
OP_NUMEQUALVERIFY = 0x9d
OP_NUMNOTEQUAL = 0x9e
OP_LESSTHAN = 0x9f
OP_GREATERTHAN = 0xa0
OP_LESSTHANOREQUAL = 0xa1
OP_GREATERTHANOREQUAL = 0xa2
OP_MIN = 0xa3
OP_MAX = 0xa4
OP_WITHIN = 0xa5
# crypto
OP_RIPEMD160 = 0xa6
OP_SHA1 = 0xa7
OP_SHA256 = 0xa8
OP_HASH160 = 0xa9
OP_HASH256 = 0xaa
OP_CODESEPARATOR = 0xab
OP_CHECKSIG = 0xac
OP_CHECKSIGVERIFY = 0xad
OP_CHECKMULTISIG = 0xae
OP_CHECKMULTISIGVERIFY = 0xaf
# expansion
OP_NOP1 = 0xb0
OP_CHECKLOCKTIMEVERIFY = 0xb1
OP_NOP2 = OP_CHECKLOCKTIMEVERIFY
OP_CHECKSEQUENCEVERIFY = 0xb2
OP_NOP3 = OP_CHECKSEQUENCEVERIFY
OP_NOP4 = 0xb3
OP_NOP5 = 0xb4
OP_NOP6 = 0xb5
OP_NOP7 = 0xb6
OP_NOP8 = 0xb7
OP_NOP9 = 0xb8
OP_NOP10 = 0xb9
OP_INVALIDOPCODE = 0xff
def hex(self) -> str:
return bytes([self]).hex()
def script_num_to_bytes(i: int) -> bytes:
"""See CScriptNum in Bitcoin Core.
Encodes an integer as bytes, to be used in script.
ported from https://github.com/bitcoin/bitcoin/blob/8cbc5c4be4be22aca228074f087a374a7ec38be8/src/script/script.h#L326
"""
if i == 0:
return b""
result = bytearray()
neg = i < 0
absvalue = abs(i)
while absvalue > 0:
result.append(absvalue & 0xff)
absvalue >>= 8
if result[-1] & 0x80:
result.append(0x80 if neg else 0x00)
elif neg:
result[-1] |= 0x80
return bytes(result)
def var_int(i: int) -> bytes:
# https://en.bitcoin.it/wiki/Protocol_specification#Variable_length_integer
# https://github.com/bitcoin/bitcoin/blob/efe1ee0d8d7f82150789f1f6840f139289628a2b/src/serialize.h#L247
# "CompactSize"
assert i >= 0, i
if i < 0xfd:
return int.to_bytes(i, length=1, byteorder="little", signed=False)
elif i <= 0xffff:
return b"\xfd" + int.to_bytes(i, length=2, byteorder="little", signed=False)
elif i <= 0xffffffff:
return b"\xfe" + int.to_bytes(i, length=4, byteorder="little", signed=False)
else:
return b"\xff" + int.to_bytes(i, length=8, byteorder="little", signed=False)
def witness_push(item: bytes) -> bytes:
"""Returns data in the form it should be present in the witness."""
return var_int(len(item)) + item
def _op_push(i: int) -> bytes:
if i < opcodes.OP_PUSHDATA1:
return int.to_bytes(i, length=1, byteorder="little", signed=False)
elif i <= 0xff:
return bytes([opcodes.OP_PUSHDATA1]) + int.to_bytes(i, length=1, byteorder="little", signed=False)
elif i <= 0xffff:
return bytes([opcodes.OP_PUSHDATA2]) + int.to_bytes(i, length=2, byteorder="little", signed=False)
else:
return bytes([opcodes.OP_PUSHDATA4]) + int.to_bytes(i, length=4, byteorder="little", signed=False)
def push_script(data: bytes) -> bytes:
"""Returns pushed data to the script, automatically
choosing canonical opcodes depending on the length of the data.
ported from https://github.com/btcsuite/btcd/blob/fdc2bc867bda6b351191b5872d2da8270df00d13/txscript/scriptbuilder.go#L128
"""
data_len = len(data)
# "small integer" opcodes
if data_len == 0 or data_len == 1 and data[0] == 0:
return bytes([opcodes.OP_0])
elif data_len == 1 and data[0] <= 16:
return bytes([opcodes.OP_1 - 1 + data[0]])
elif data_len == 1 and data[0] == 0x81:
return bytes([opcodes.OP_1NEGATE])
return _op_push(data_len) + data
def make_op_return(x: bytes) -> bytes:
return bytes([opcodes.OP_RETURN]) + push_script(x)
def add_number_to_script(i: int) -> bytes:
return push_script(script_num_to_bytes(i))
def construct_witness(items: Sequence[Union[str, int, bytes]]) -> bytes:
"""Constructs a witness from the given stack items."""
witness = bytearray()
witness += var_int(len(items))
for item in items:
if type(item) is int:
item = script_num_to_bytes(item)
elif isinstance(item, (bytes, bytearray)):
pass # use as-is
else:
assert is_hex_str(item), repr(item)
item = bfh(item)
witness += witness_push(item)
return bytes(witness)
def construct_script(
items: Sequence[Union[str, int, bytes, opcodes, 'OPPushDataGeneric']],
*,
values: Optional[Mapping[int, Any]] = None, # can be used to substitute into OPPushDataGeneric
) -> bytes:
"""Constructs bitcoin script from given items."""
from .transaction import OPPushDataGeneric
script = bytearray()
values = values or {}
for i, item in enumerate(items):
if i in values:
assert OPPushDataGeneric.is_instance(item), f"tried to substitute into {item=!r}"
item = values[i]
if isinstance(item, opcodes):
script += bytes([item])
elif type(item) is int:
script += add_number_to_script(item)
elif isinstance(item, (bytes, bytearray)):
script += push_script(item)
elif isinstance(item, str):
assert is_hex_str(item)
script += push_script(bfh(item))
else:
raise Exception(f'unexpected item for script: {item!r} at idx={i}')
return bytes(script)
def relayfee(network: 'Network' = None) -> int:
"""Returns feerate in sat/kbyte."""
from .fee_policy import FEERATE_MIN_RELAY, FEERATE_DEFAULT_RELAY, FEERATE_MAX_RELAY
if network and network.relay_fee is not None:
fee = network.relay_fee
else:
fee = FEERATE_DEFAULT_RELAY
# sanity safeguards, as network.relay_fee is coming from a server:
fee = min(fee, FEERATE_MAX_RELAY)
fee = max(fee, FEERATE_MIN_RELAY)
return fee
# see https://github.com/bitcoin/bitcoin/blob/a62f0ed64f8bbbdfe6467ac5ce92ef5b5222d1bd/src/policy/policy.cpp#L14
# and https://github.com/lightningnetwork/lightning-rfc/blob/7e3dce42cbe4fa4592320db6a4e06c26bb99122b/03-transactions.md#dust-limits
DUST_LIMIT_P2PKH = 546
DUST_LIMIT_P2SH = 540
DUST_LIMIT_UNKNOWN_SEGWIT = 354
DUST_LIMIT_P2WSH = 330
DUST_LIMIT_P2WPKH = 294
def dust_threshold(network: 'Network' = None) -> int:
"""Returns the dust limit in satoshis."""
return DUST_LIMIT_P2PKH
def hash_encode(x: bytes) -> str:
return x[::-1].hex()
def hash_decode(x: str) -> bytes:
return bfh(x)[::-1]
############ functions from pywallet #####################
def hash160_to_b58_address(h160: bytes, addrtype: int) -> str:
s = bytes([addrtype]) + h160
s = s + sha256d(s)[0:4]
return base_encode(s, base=58)
def b58_address_to_hash160(addr: str) -> Tuple[int, bytes]:
addr = to_bytes(addr, 'ascii')
_bytes = DecodeBase58Check(addr)
if len(_bytes) != 21:
raise Exception(f'expected 21 payload bytes in base58 address. got: {len(_bytes)}')
return _bytes[0], _bytes[1:21]
def hash160_to_p2pkh(h160: bytes, *, net=None) -> str:
if net is None: net = constants.net
return hash160_to_b58_address(h160, net.ADDRTYPE_P2PKH)
def hash160_to_p2sh(h160: bytes, *, net=None) -> str:
if net is None: net = constants.net
return hash160_to_b58_address(h160, net.ADDRTYPE_P2SH)
def public_key_to_p2pkh(public_key: bytes, *, net=None) -> str:
return hash160_to_p2pkh(hash_160(public_key), net=net)
def hash_to_segwit_addr(h: bytes, witver: int, *, net=None) -> str:
if net is None: net = constants.net
addr = segwit_addr.encode_segwit_address(net.SEGWIT_HRP, witver, h)
assert addr is not None
return addr
def public_key_to_p2wpkh(public_key: bytes, *, net=None) -> str:
return hash_to_segwit_addr(hash_160(public_key), witver=0, net=net)
def script_to_p2wsh(script: bytes, *, net=None) -> str:
return hash_to_segwit_addr(sha256(script), witver=0, net=net)
def p2wsh_nested_script(witness_script: bytes) -> bytes:
wsh = sha256(witness_script)
return construct_script([0, wsh])
def pubkey_to_address(txin_type: str, pubkey: str, *, net=None) -> str:
from . import descriptor
desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey, script_type=txin_type)
return desc.expand().address(net=net)
# TODO this method is confusingly named
def redeem_script_to_address(txin_type: str, scriptcode: bytes, *, net=None) -> str:
assert isinstance(scriptcode, bytes)
if txin_type == 'p2sh':
# given scriptcode is a redeem_script
return hash160_to_p2sh(hash_160(scriptcode), net=net)
elif txin_type == 'p2wsh':
# given scriptcode is a witness_script
return script_to_p2wsh(scriptcode, net=net)
elif txin_type == 'p2wsh-p2sh':
# given scriptcode is a witness_script
redeem_script = p2wsh_nested_script(scriptcode)
return hash160_to_p2sh(hash_160(redeem_script), net=net)
else:
raise NotImplementedError(txin_type)
def script_to_address(script: bytes, *, net=None) -> Optional[str]:
from .transaction import get_address_from_output_script
return get_address_from_output_script(script, net=net)
def address_to_script(addr: str, *, net=None) -> bytes:
if net is None: net = constants.net
if not is_address(addr, net=net):
raise BitcoinException(f"invalid bitcoin address: {addr}")
witver, witprog = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, addr)
if witprog is not None:
if not (0 <= witver <= 16):
raise BitcoinException(f'impossible witness version: {witver}')
return construct_script([witver, bytes(witprog)])
addrtype, hash_160_ = b58_address_to_hash160(addr)
if addrtype == net.ADDRTYPE_P2PKH:
script = pubkeyhash_to_p2pkh_script(hash_160_)
elif addrtype == net.ADDRTYPE_P2SH:
script = construct_script([opcodes.OP_HASH160, hash_160_, opcodes.OP_EQUAL])
else:
raise BitcoinException(f'unknown address type: {addrtype}')
return script
class OnchainOutputType(Enum):
"""Opaque types of scriptPubKeys.
In case of p2sh, p2wsh and similar, no knowledge of redeem script, etc.
"""
P2PKH = enum.auto()
P2SH = enum.auto()
WITVER0_P2WPKH = enum.auto()
WITVER0_P2WSH = enum.auto()
WITVER1_P2TR = enum.auto()
def address_to_payload(addr: str, *, net=None) -> Tuple[OnchainOutputType, bytes]:
"""Return (type, pubkey hash / witness program) for an address."""
if net is None: net = constants.net
if not is_address(addr, net=net):
raise BitcoinException(f"invalid bitcoin address: {addr}")
witver, witprog = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, addr)
if witprog is not None:
if witver == 0:
if len(witprog) == 20:
return OnchainOutputType.WITVER0_P2WPKH, bytes(witprog)
elif len(witprog) == 32:
return OnchainOutputType.WITVER0_P2WSH, bytes(witprog)
else:
raise BitcoinException(f"unexpected length for segwit witver=0 witprog: len={len(witprog)}")
elif witver == 1:
if len(witprog) == 32:
return OnchainOutputType.WITVER1_P2TR, bytes(witprog)
else:
raise BitcoinException(f"unexpected length for segwit witver=1 witprog: len={len(witprog)}")
else:
raise BitcoinException(f"not implemented handling for witver={witver}")
addrtype, hash_160_ = b58_address_to_hash160(addr)
if addrtype == net.ADDRTYPE_P2PKH:
return OnchainOutputType.P2PKH, hash_160_
elif addrtype == net.ADDRTYPE_P2SH:
return OnchainOutputType.P2SH, hash_160_
raise BitcoinException(f"unknown address type: {addrtype}")
def address_to_scripthash(addr: str, *, net=None) -> str:
script = address_to_script(addr, net=net)
return script_to_scripthash(script)
def script_to_scripthash(script: bytes) -> str:
h = sha256(script)
return h[::-1].hex()
def pubkeyhash_to_p2pkh_script(pubkey_hash160: bytes) -> bytes:
return construct_script([
opcodes.OP_DUP,
opcodes.OP_HASH160,
pubkey_hash160,
opcodes.OP_EQUALVERIFY,
opcodes.OP_CHECKSIG
])
__b58chars = b'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
assert len(__b58chars) == 58
__b58chars_inv = inv_dict(dict(enumerate(__b58chars)))
__b43chars = b'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:'
assert len(__b43chars) == 43
__b43chars_inv = inv_dict(dict(enumerate(__b43chars)))
class BaseDecodeError(BitcoinException): pass
def base_encode(v: bytes, *, base: int) -> str:
""" encode v, which is a string of bytes, to base58."""
assert_bytes(v)
if base not in (58, 43):
raise ValueError('not supported base: {}'.format(base))
chars = __b58chars
if base == 43:
chars = __b43chars
origlen = len(v)
v = v.lstrip(b'\x00')
newlen = len(v)
num = int.from_bytes(v, byteorder='big')
string = b""
while num:
num, idx = divmod(num, base)
string = chars[idx:idx + 1] + string
result = chars[0:1] * (origlen - newlen) + string
return result.decode('ascii')
def base_decode(v: Union[bytes, str], *, base: int) -> Optional[bytes]:
""" decode v into a string of len bytes.
based on the work of David Keijser in https://github.com/keis/base58
"""
# assert_bytes(v)
v = to_bytes(v, 'ascii')
if base not in (58, 43):
raise ValueError('not supported base: {}'.format(base))
chars = __b58chars
chars_inv = __b58chars_inv
if base == 43:
chars = __b43chars
chars_inv = __b43chars_inv
origlen = len(v)
v = v.lstrip(chars[0:1])
newlen = len(v)
num = 0
try:
for char in v:
num = num * base + chars_inv[char]
except KeyError:
raise BaseDecodeError('Forbidden character {} for base {}'.format(char, base))
return num.to_bytes(origlen - newlen + (num.bit_length() + 7) // 8, 'big')
class InvalidChecksum(BaseDecodeError):
pass
def EncodeBase58Check(vchIn: bytes) -> str:
hash = sha256d(vchIn)
return base_encode(vchIn + hash[0:4], base=58)
def DecodeBase58Check(psz: Union[bytes, str]) -> bytes:
vchRet = base_decode(psz, base=58)
payload = vchRet[0:-4]
csum_found = vchRet[-4:]
csum_calculated = sha256d(payload)[0:4]
if csum_calculated != csum_found:
raise InvalidChecksum(f'calculated {csum_calculated.hex()}, found {csum_found.hex()}')
else:
return payload
# backwards compat
# extended WIF for segwit (used in 3.0.x; but still used internally)
# the keys in this dict should be a superset of what Imported Wallets can import
WIF_SCRIPT_TYPES = {
'p2pkh': 0,
'p2wpkh': 1,
'p2wpkh-p2sh': 2,
'p2sh': 5,
'p2wsh': 6,
'p2wsh-p2sh': 7
}
WIF_SCRIPT_TYPES_INV = inv_dict(WIF_SCRIPT_TYPES)
def is_segwit_script_type(txin_type: str) -> bool:
return txin_type in ('p2wpkh', 'p2wpkh-p2sh', 'p2wsh', 'p2wsh-p2sh')
def serialize_privkey(secret: bytes, compressed: bool, txin_type: str, *,
internal_use: bool = False) -> str:
# we only export secrets inside curve range
secret = ecc.ECPrivkey.normalize_secret_bytes(secret)
if internal_use:
prefix = bytes([(WIF_SCRIPT_TYPES[txin_type] + constants.net.WIF_PREFIX) & 255])
else:
prefix = bytes([constants.net.WIF_PREFIX])
suffix = b'\01' if compressed else b''
vchIn = prefix + secret + suffix
base58_wif = EncodeBase58Check(vchIn)
if internal_use:
return base58_wif
else:
return '{}:{}'.format(txin_type, base58_wif)
def deserialize_privkey(key: str) -> Tuple[str, bytes, bool]:
if is_minikey(key):
return 'p2pkh', minikey_to_private_key(key), False
txin_type = None
if ':' in key:
txin_type, key = key.split(sep=':', maxsplit=1)
if txin_type not in WIF_SCRIPT_TYPES:
raise BitcoinException('unknown script type: {}'.format(txin_type))
try:
vch = DecodeBase58Check(key)
except Exception as e:
neutered_privkey = str(key)[:3] + '..' + str(key)[-2:]
raise BaseDecodeError(f"cannot deserialize privkey {neutered_privkey}") from e
if txin_type is None:
# keys exported in version 3.0.x encoded script type in first byte
prefix_value = vch[0] - constants.net.WIF_PREFIX
try:
txin_type = WIF_SCRIPT_TYPES_INV[prefix_value]
except KeyError as e:
raise BitcoinException('invalid prefix ({}) for WIF key (1)'.format(vch[0])) from None
else:
# all other keys must have a fixed first byte
if vch[0] != constants.net.WIF_PREFIX:
raise BitcoinException('invalid prefix ({}) for WIF key (2)'.format(vch[0]))
if len(vch) not in [33, 34]:
raise BitcoinException('invalid vch len for WIF key: {}'.format(len(vch)))
compressed = False
if len(vch) == 34:
if vch[33] == 0x01:
compressed = True
else:
raise BitcoinException(f'invalid WIF key. length suggests compressed pubkey, '
f'but last byte is {vch[33]} != 0x01')
if is_segwit_script_type(txin_type) and not compressed:
raise BitcoinException('only compressed public keys can be used in segwit scripts')
secret_bytes = vch[1:33]
# we accept secrets outside curve range; cast into range here:
secret_bytes = ecc.ECPrivkey.normalize_secret_bytes(secret_bytes)
return txin_type, secret_bytes, compressed
def is_compressed_privkey(sec: str) -> bool:
return deserialize_privkey(sec)[2]
def address_from_private_key(sec: str) -> str:
txin_type, privkey, compressed = deserialize_privkey(sec)
public_key = ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed)
return pubkey_to_address(txin_type, public_key)
def is_segwit_address(addr: str, *, net=None) -> bool:
if net is None: net = constants.net
try:
witver, witprog = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, addr)
except Exception as e:
return False
return witprog is not None
def is_taproot_address(addr: str, *, net=None) -> bool:
if net is None: net = constants.net
try:
witver, witprog = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, addr)
except Exception as e:
return False
return witver == 1
def is_b58_address(addr: str, *, net=None) -> bool:
if net is None: net = constants.net
try:
# test length, checksum, encoding:
addrtype, h = b58_address_to_hash160(addr)
except Exception as e:
return False
if addrtype not in [net.ADDRTYPE_P2PKH, net.ADDRTYPE_P2SH]:
return False
return True
def is_address(addr: str, *, net=None) -> bool:
return is_segwit_address(addr, net=net) \
or is_b58_address(addr, net=net)
def is_private_key(key: str, *, raise_on_error=False) -> bool:
try:
deserialize_privkey(key)
return True
except BaseException as e:
if raise_on_error:
raise
return False
########### end pywallet functions #######################
def is_minikey(text: str) -> bool:
# Minikeys are typically 22 or 30 characters, but this routine
# permits any length of 20 or more provided the minikey is valid.
# A valid minikey must begin with an 'S', be in base58, and when
# suffixed with '?' have its SHA256 hash begin with a zero byte.
# They are widely used in Casascius physical bitcoins.
return (len(text) >= 20 and text[0] == 'S'
and all(ord(c) in __b58chars for c in text)
and sha256(text + '?')[0] == 0x00)
def minikey_to_private_key(text: str) -> bytes:
return sha256(text)
def _get_dummy_address(purpose: str) -> str:
return redeem_script_to_address('p2wsh', sha256(bytes(purpose, "utf8")))
_dummy_addr_funcs = set()
class DummyAddress:
"""dummy address for fee estimation of funding tx
Use e.g. as: DummyAddress.CHANNEL
"""
def purpose(func):
_dummy_addr_funcs.add(func)
return classproperty(func)
@purpose
def CHANNEL(self) -> str:
return _get_dummy_address("channel")
@purpose
def SWAP(self) -> str:
return _get_dummy_address("swap")
@classmethod
def is_dummy_address(cls, addr: str) -> bool:
return addr in (f(cls) for f in _dummy_addr_funcs)
class DummyAddressUsedInTxException(Exception): pass
def taproot_tweak_pubkey(pubkey32: bytes, h: bytes) -> Tuple[int, bytes]:
assert isinstance(pubkey32, bytes), type(pubkey32)
assert isinstance(h, bytes), type(h)
assert len(pubkey32) == 32, len(pubkey32)
int_from_bytes = lambda x: int.from_bytes(x, byteorder="big", signed=False)
tweak = int_from_bytes(bip340_tagged_hash(b"TapTweak", pubkey32 + h))
if tweak >= ecc.CURVE_ORDER:
raise ValueError
P = ecc.ECPubkey(b"\x02" + pubkey32)
Q = P + (ecc.GENERATOR * tweak)
return 0 if Q.has_even_y() else 1, Q.get_public_key_bytes(compressed=True)[1:]
def taproot_tweak_seckey(seckey0: bytes, h: bytes) -> bytes:
assert isinstance(seckey0, bytes), type(seckey0)
assert isinstance(h, bytes), type(h)
assert len(seckey0) == 32, len(seckey0)
int_from_bytes = lambda x: int.from_bytes(x, byteorder="big", signed=False)
P = ecc.ECPrivkey(seckey0)
seckey = P.secret_scalar if P.has_even_y() else ecc.CURVE_ORDER - P.secret_scalar
pubkey32 = P.get_public_key_bytes(compressed=True)[1:]
tweak = int_from_bytes(bip340_tagged_hash(b"TapTweak", pubkey32 + h))
if tweak >= ecc.CURVE_ORDER:
raise ValueError
return int.to_bytes((seckey + tweak) % ecc.CURVE_ORDER, length=32, byteorder="big", signed=False)
# a TapTree is either:
# - a (leaf_version, script) tuple (leaf_version is 0xc0 for BIP-0342 scripts)
# - a list of two elements, each with the same structure as TapTree itself
TapTreeLeaf = Tuple[int, bytes]
TapTree = Union[TapTreeLeaf, Sequence['TapTree']]
def taproot_tree_helper(script_tree: TapTree):
if isinstance(script_tree, tuple):
leaf_version, script = script_tree
h = bip340_tagged_hash(b"TapLeaf", bytes([leaf_version]) + witness_push(script))
return [((leaf_version, script), bytes())], h
left, left_h = taproot_tree_helper(script_tree[0])
right, right_h = taproot_tree_helper(script_tree[1])
ret = [(l, c + right_h) for l, c in left] + [(l, c + left_h) for l, c in right]
if right_h < left_h:
left_h, right_h = right_h, left_h
return ret, bip340_tagged_hash(b"TapBranch", left_h + right_h)
def taproot_output_script(internal_pubkey: bytes, *, script_tree: Optional[TapTree]) -> bytes:
"""Given an internal public key and a tree of scripts, compute the output script."""
assert isinstance(internal_pubkey, bytes), type(internal_pubkey)
assert len(internal_pubkey) == 32, len(internal_pubkey)
if script_tree is None:
merkle_root = bytes()
else:
_, merkle_root = taproot_tree_helper(script_tree)
_, output_pubkey = taproot_tweak_pubkey(internal_pubkey, merkle_root)
return construct_script([1, output_pubkey])
def control_block_for_taproot_script_spend(
*, internal_pubkey: bytes, script_tree: TapTree, script_num: int,
) -> Tuple[bytes, bytes]:
"""Constructs the control block necessary for spending a taproot UTXO using a script.
script_num indicates which script to use, which indexes into (flattened) script_tree.
"""
assert isinstance(internal_pubkey, bytes), type(internal_pubkey)
assert len(internal_pubkey) == 32, len(internal_pubkey)
info, merkle_root = taproot_tree_helper(script_tree)
(leaf_version, leaf_script), merkle_path = info[script_num]
output_pubkey_y_parity, _ = taproot_tweak_pubkey(internal_pubkey, merkle_root)
pubkey_data = bytes([output_pubkey_y_parity + leaf_version]) + internal_pubkey
control_block = pubkey_data + merkle_path
return (leaf_script, control_block)
# user message signing
def usermessage_magic(message: bytes) -> bytes:
length = var_int(len(message))
return b"\x18Bitcoin Signed Message:\n" + length + message
def ecdsa_sign_usermessage(ec_privkey, message: Union[bytes, str], *, is_compressed: bool) -> bytes:
message = to_bytes(message, 'utf8')
msg32 = sha256d(usermessage_magic(message))
return ec_privkey.ecdsa_sign_recoverable(msg32, is_compressed=is_compressed)
def verify_usermessage_with_address(address: str, sig65: bytes, message: bytes, *, net=None) -> bool:
from electrum_ecc import ECPubkey
assert_bytes(sig65, message)
if net is None: net = constants.net
h = sha256d(usermessage_magic(message))
try:
public_key, compressed, txin_type_guess = ECPubkey.from_ecdsa_sig65(sig65, h)
except Exception as e:
return False
# check public key using the address
pubkey_hex = public_key.get_public_key_hex(compressed)
txin_types = (txin_type_guess,) if txin_type_guess else ('p2pkh', 'p2wpkh', 'p2wpkh-p2sh')
for txin_type in txin_types:
addr = pubkey_to_address(txin_type, pubkey_hex, net=net)
if address == addr:
break
else:
return False
# check message
# note: `$ bitcoin-cli verifymessage` does NOT enforce the low-S rule for ecdsa sigs
return public_key.ecdsa_verify(sig65[1:], h, enforce_low_s=False)
================================================
FILE: electrum/blockchain.py
================================================
# Electrum - lightweight Bitcoin client
# Copyright (C) 2012 thomasv@ecdsa.org
#
# 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 threading
import time
from typing import Optional, Dict, Mapping, Sequence, TYPE_CHECKING
from . import util
from .bitcoin import hash_encode
from .crypto import sha256d
from . import constants
from .util import bfh, with_lock
from .logging import get_logger, Logger
if TYPE_CHECKING:
from .simple_config import SimpleConfig
_logger = get_logger(__name__)
HEADER_SIZE = 80 # bytes
CHUNK_SIZE = 2016 # num headers in a difficulty retarget period
# see https://github.com/bitcoin/bitcoin/blob/feedb9c84e72e4fff489810a2bbeec09bcda5763/src/chainparams.cpp#L76
MAX_TARGET = 0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff # compact: 0x1d00ffff
class MissingHeader(Exception):
pass
class InvalidHeader(Exception):
pass
def serialize_header(header_dict: dict) -> bytes:
s = (
int.to_bytes(header_dict['version'], length=4, byteorder="little", signed=False)
+ bfh(header_dict['prev_block_hash'])[::-1]
+ bfh(header_dict['merkle_root'])[::-1]
+ int.to_bytes(int(header_dict['timestamp']), length=4, byteorder="little", signed=False)
+ int.to_bytes(int(header_dict['bits']), length=4, byteorder="little", signed=False)
+ int.to_bytes(int(header_dict['nonce']), length=4, byteorder="little", signed=False))
return s
def deserialize_header(s: bytes, height: int) -> dict:
if not s:
raise InvalidHeader('Invalid header: {}'.format(s))
if len(s) != HEADER_SIZE:
raise InvalidHeader('Invalid header length: {}'.format(len(s)))
h = {}
h['version'] = int.from_bytes(s[0:4], byteorder='little')
h['prev_block_hash'] = hash_encode(s[4:36])
h['merkle_root'] = hash_encode(s[36:68])
h['timestamp'] = int.from_bytes(s[68:72], byteorder='little')
h['bits'] = int.from_bytes(s[72:76], byteorder='little')
h['nonce'] = int.from_bytes(s[76:80], byteorder='little')
h['block_height'] = height
return h
def hash_header(header: dict) -> str:
if header is None:
return '0' * 64
if header.get('prev_block_hash') is None:
header['prev_block_hash'] = '00'*32
return hash_raw_header(serialize_header(header))
def hash_raw_header(header: bytes) -> str:
assert isinstance(header, bytes)
return hash_encode(sha256d(header))
pow_hash_header = hash_header
# key: blockhash hex at forkpoint
# the chain at some key is the best chain that includes the given hash
blockchains = {} # type: Dict[str, Blockchain]
blockchains_lock = threading.RLock() # lock order: take this last; so after Blockchain.lock
def read_blockchains(config: 'SimpleConfig'):
best_chain = Blockchain(config=config,
forkpoint=0,
parent=None,
forkpoint_hash=constants.net.GENESIS,
prev_hash=None)
blockchains[constants.net.GENESIS] = best_chain
# consistency checks
if best_chain.height() > constants.net.max_checkpoint():
header_after_cp = best_chain.read_header(constants.net.max_checkpoint()+1)
if not header_after_cp or not best_chain.can_connect(header_after_cp, check_height=False):
_logger.info("[blockchain] deleting best chain. cannot connect header after last cp to last cp.")
os.unlink(best_chain.path())
best_chain.update_size()
# forks
fdir = os.path.join(util.get_headers_dir(config), 'forks')
util.make_dir(fdir)
# files are named as: fork2_{forkpoint}_{prev_hash}_{first_hash}
l = filter(lambda x: x.startswith('fork2_') and '.' not in x, os.listdir(fdir))
l = sorted(l, key=lambda x: int(x.split('_')[1])) # sort by forkpoint
def delete_chain(filename, reason):
_logger.info(f"[blockchain] deleting chain {filename}: {reason}")
os.unlink(os.path.join(fdir, filename))
def instantiate_chain(filename):
__, forkpoint, prev_hash, first_hash = filename.split('_')
forkpoint = int(forkpoint)
prev_hash = (64-len(prev_hash)) * "0" + prev_hash # left-pad with zeroes
first_hash = (64-len(first_hash)) * "0" + first_hash
# forks below the max checkpoint are not allowed
if forkpoint <= constants.net.max_checkpoint():
delete_chain(filename, "deleting fork below max checkpoint")
return
# find parent (sorting by forkpoint guarantees it's already instantiated)
for parent in blockchains.values():
if parent.check_hash(forkpoint - 1, prev_hash):
break
else:
delete_chain(filename, "cannot find parent for chain")
return
b = Blockchain(config=config,
forkpoint=forkpoint,
parent=parent,
forkpoint_hash=first_hash,
prev_hash=prev_hash)
# consistency checks
h = b.read_header(b.forkpoint)
if first_hash != hash_header(h):
delete_chain(filename, "incorrect first hash for chain")
return
if not b.parent.can_connect(h, check_height=False):
delete_chain(filename, "cannot connect chain to parent")
return
chain_id = b.get_id()
assert first_hash == chain_id, (first_hash, chain_id)
blockchains[chain_id] = b
for filename in l:
instantiate_chain(filename)
def get_best_chain() -> 'Blockchain':
return blockchains[constants.net.GENESIS]
# block hash -> chain work; up to and including that block
_CHAINWORK_CACHE = {
"0000000000000000000000000000000000000000000000000000000000000000": 0, # virtual block at height -1
} # type: Dict[str, int]
def init_headers_file_for_best_chain():
b = get_best_chain()
filename = b.path()
length = HEADER_SIZE * len(constants.net.CHECKPOINTS) * CHUNK_SIZE
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')
util.ensure_sparse_file(filename)
with b.lock:
b.update_size()
class Blockchain(Logger):
"""
Manages blockchain headers and their verification
"""
def __init__(self, config: 'SimpleConfig', forkpoint: int, parent: Optional['Blockchain'],
forkpoint_hash: str, prev_hash: Optional[str]):
assert isinstance(forkpoint_hash, str) and len(forkpoint_hash) == 64, forkpoint_hash
assert (prev_hash is None) or (isinstance(prev_hash, str) and len(prev_hash) == 64), prev_hash
# assert (parent is None) == (forkpoint == 0)
if 0 < forkpoint <= constants.net.max_checkpoint():
raise Exception(f"cannot fork below max checkpoint. forkpoint: {forkpoint}")
Logger.__init__(self)
self.config = config
self.forkpoint = forkpoint # height of first header
self.parent = parent
self._forkpoint_hash = forkpoint_hash # blockhash at forkpoint. "first hash"
self._prev_hash = prev_hash # blockhash immediately before forkpoint
self.lock = threading.RLock()
self.update_size()
@property
def checkpoints(self):
return constants.net.CHECKPOINTS
def get_max_child(self) -> Optional[int]:
children = self.get_direct_children()
return max([x.forkpoint for x in children]) if children else None
def get_max_forkpoint(self) -> int:
"""Returns the max height where there is a fork
related to this chain.
"""
mc = self.get_max_child()
return mc if mc is not None else self.forkpoint
def get_direct_children(self) -> Sequence['Blockchain']:
with blockchains_lock:
return list(filter(lambda y: y.parent==self, blockchains.values()))
def get_parent_heights(self) -> Mapping['Blockchain', int]:
"""Returns map: (parent chain -> height of last common block)"""
with self.lock, blockchains_lock:
result = {self: self.height()}
chain = self
while True:
parent = chain.parent
if parent is None: break
result[parent] = chain.forkpoint - 1
chain = parent
return result
def get_height_of_last_common_block_with_chain(self, other_chain: 'Blockchain') -> int:
last_common_block_height = 0
our_parents = self.get_parent_heights()
their_parents = other_chain.get_parent_heights()
for chain in our_parents:
if chain in their_parents:
h = min(our_parents[chain], their_parents[chain])
last_common_block_height = max(last_common_block_height, h)
return last_common_block_height
@with_lock
def get_branch_size(self) -> int:
return self.height() - self.get_max_forkpoint() + 1
def get_name(self) -> str:
return self.get_hash(self.get_max_forkpoint()).lstrip('0')[0:10]
def check_header(self, header: dict) -> bool:
header_hash = hash_header(header)
height = header.get('block_height')
return self.check_hash(height, header_hash)
def check_hash(self, height: int, header_hash: str) -> bool:
"""Returns whether the hash of the block at given height
is the given hash.
"""
assert isinstance(header_hash, str) and len(header_hash) == 64, header_hash # hex
try:
return header_hash == self.get_hash(height)
except Exception:
return False
def fork(parent, header: dict) -> 'Blockchain':
if not parent.can_connect(header, check_height=False):
raise Exception("forking header does not connect to parent chain")
forkpoint = header.get('block_height')
self = Blockchain(config=parent.config,
forkpoint=forkpoint,
parent=parent,
forkpoint_hash=hash_header(header),
prev_hash=parent.get_hash(forkpoint-1))
self.assert_headers_file_available(parent.path())
open(self.path(), 'w+').close()
self.save_header(header)
# put into global dict. note that in some cases
# save_header might have already put it there but that's OK
chain_id = self.get_id()
with blockchains_lock:
blockchains[chain_id] = self
return self
@with_lock
def height(self) -> int:
return self.forkpoint + self.size() - 1
@with_lock
def size(self) -> int:
return self._size
@with_lock
def update_size(self) -> None:
p = self.path()
self._size = os.path.getsize(p)//HEADER_SIZE if os.path.exists(p) else 0
@classmethod
def verify_header(cls, header: dict, prev_hash: str, target: int, expected_header_hash: str=None) -> None:
_hash = hash_header(header)
if expected_header_hash and expected_header_hash != _hash:
raise InvalidHeader("hash mismatches with expected: {} vs {}".format(expected_header_hash, _hash))
if prev_hash != header.get('prev_block_hash'):
raise InvalidHeader("prev hash mismatch: %s vs %s" % (prev_hash, header.get('prev_block_hash')))
if constants.net.TESTNET:
return
bits = cls.target_to_bits(target)
if bits != header.get('bits'):
raise InvalidHeader("bits mismatch: %s vs %s" % (bits, header.get('bits')))
_pow_hash = pow_hash_header(header)
pow_hash_as_num = int.from_bytes(bfh(_pow_hash), byteorder='big')
if pow_hash_as_num > target:
raise InvalidHeader(f"insufficient proof of work: {pow_hash_as_num} vs target {target}")
def verify_chunk(self, index: int, data: bytes) -> None:
num = len(data) // HEADER_SIZE
start_height = index * CHUNK_SIZE
prev_hash = self.get_hash(start_height - 1)
target = self.get_target(index-1)
for i in range(num):
height = start_height + i
try:
expected_header_hash = self.get_hash(height)
except MissingHeader:
expected_header_hash = None
raw_header = data[i*HEADER_SIZE : (i+1)*HEADER_SIZE]
header = deserialize_header(raw_header, index*CHUNK_SIZE + i)
self.verify_header(header, prev_hash, target, expected_header_hash)
prev_hash = hash_header(header)
@with_lock
def path(self):
d = util.get_headers_dir(self.config)
if self.parent is None:
filename = 'blockchain_headers'
else:
assert self.forkpoint > 0, self.forkpoint
prev_hash = self._prev_hash.lstrip('0')
first_hash = self._forkpoint_hash.lstrip('0')
basename = f'fork2_{self.forkpoint}_{prev_hash}_{first_hash}'
filename = os.path.join('forks', basename)
return os.path.join(d, filename)
@with_lock
def save_chunk(self, index: int, chunk: bytes):
assert index >= 0, index
chunk_within_checkpoint_region = index < len(self.checkpoints)
# chunks in checkpoint region are the responsibility of the 'main chain'
if chunk_within_checkpoint_region and self.parent is not None:
main_chain = get_best_chain()
main_chain.save_chunk(index, chunk)
return
delta_height = (index * CHUNK_SIZE - self.forkpoint)
delta_bytes = delta_height * HEADER_SIZE
# if this chunk contains our forkpoint, only save the part after forkpoint
# (the part before is the responsibility of the parent)
if delta_bytes < 0:
chunk = chunk[-delta_bytes:]
delta_bytes = 0
truncate = not chunk_within_checkpoint_region
self.write(chunk, delta_bytes, truncate)
self.swap_with_parent()
def swap_with_parent(self) -> None:
with self.lock, blockchains_lock:
# do the swap; possibly multiple ones
cnt = 0
while True:
old_parent = self.parent
if not self._swap_with_parent():
break
# make sure we are making progress
cnt += 1
if cnt > len(blockchains):
raise Exception(f'swapping fork with parent too many times: {cnt}')
# we might have become the parent of some of our former siblings
for old_sibling in old_parent.get_direct_children():
if self.check_hash(old_sibling.forkpoint - 1, old_sibling._prev_hash):
old_sibling.parent = self
def _swap_with_parent(self) -> bool:
"""Check if this chain became stronger than its parent, and swap
the underlying files if so. The Blockchain instances will keep
'containing' the same headers, but their ids change and so
they will be stored in different files."""
if self.parent is None:
return False
if self.parent.get_chainwork() >= self.get_chainwork():
return False
self.logger.info(f"swapping {self.forkpoint} {self.parent.forkpoint}")
parent_branch_size = self.parent.height() - self.forkpoint + 1
forkpoint = self.forkpoint # type: Optional[int]
parent = self.parent # type: Optional[Blockchain]
child_old_id = self.get_id()
parent_old_id = parent.get_id()
# swap files
# child takes parent's name
# parent's new name will be something new (not child's old name)
self.assert_headers_file_available(self.path())
child_old_name = self.path()
with open(self.path(), 'rb') as f:
my_data = f.read()
self.assert_headers_file_available(parent.path())
assert forkpoint > parent.forkpoint, (f"forkpoint of parent chain ({parent.forkpoint}) "
f"should be at lower height than children's ({forkpoint})")
with open(parent.path(), 'rb') as f:
f.seek((forkpoint - parent.forkpoint)*HEADER_SIZE)
parent_data = f.read(parent_branch_size*HEADER_SIZE)
self.write(parent_data, 0)
parent.write(my_data, (forkpoint - parent.forkpoint)*HEADER_SIZE)
# swap parameters
self.parent, parent.parent = parent.parent, self # type: Optional[Blockchain], Optional[Blockchain]
self.forkpoint, parent.forkpoint = parent.forkpoint, self.forkpoint
self._forkpoint_hash, parent._forkpoint_hash = parent._forkpoint_hash, hash_raw_header(parent_data[:HEADER_SIZE])
self._prev_hash, parent._prev_hash = parent._prev_hash, self._prev_hash
# parent's new name
os.replace(child_old_name, parent.path())
self.update_size()
parent.update_size()
# update pointers
blockchains.pop(child_old_id, None)
blockchains.pop(parent_old_id, None)
blockchains[self.get_id()] = self
blockchains[parent.get_id()] = parent
return True
def get_id(self) -> str:
return self._forkpoint_hash
def assert_headers_file_available(self, path):
if os.path.exists(path):
return
elif not os.path.exists(util.get_headers_dir(self.config)):
raise FileNotFoundError('Electrum headers_dir does not exist. Was it deleted while running?')
else:
raise FileNotFoundError('Cannot find headers file but headers_dir is there. Should be at {}'.format(path))
@with_lock
def write(self, data: bytes, offset: int, truncate: bool = True, *, fsync: bool = True) -> None:
filename = self.path()
self.assert_headers_file_available(filename)
with open(filename, 'rb+') as f:
if truncate and offset != self._size * HEADER_SIZE:
f.seek(offset)
f.truncate()
f.seek(offset)
f.write(data)
if fsync:
f.flush()
os.fsync(f.fileno())
self.update_size()
@with_lock
def save_header(self, header: dict) -> None:
delta = header.get('block_height') - self.forkpoint
data = serialize_header(header)
# headers are only _appended_ to the end:
assert delta == self.size(), (delta, self.size())
assert len(data) == HEADER_SIZE
# note: we don't fsync, to improve perf. losing headers at end of file is ok.
self.write(data, delta*HEADER_SIZE, fsync=False)
self.swap_with_parent()
@with_lock
def read_header(self, height: int) -> Optional[dict]:
if height < 0:
return
if height < self.forkpoint:
return self.parent.read_header(height)
if height > self.height():
return
delta = height - self.forkpoint
name = self.path()
self.assert_headers_file_available(name)
with open(name, 'rb') as f:
f.seek(delta * HEADER_SIZE)
h = f.read(HEADER_SIZE)
if len(h) < HEADER_SIZE:
raise Exception('Expected to read a full header. This was only {} bytes'.format(len(h)))
if h == bytes([0])*HEADER_SIZE:
return None
return deserialize_header(h, height)
def header_at_tip(self) -> Optional[dict]:
"""Return latest header."""
height = self.height()
return self.read_header(height)
def is_tip_stale(self) -> bool:
STALE_DELAY = 8 * 60 * 60 # in seconds
header = self.header_at_tip()
if not header:
return True
# note: We check the timestamp only in the latest header.
# The Bitcoin consensus has a lot of leeway here:
# - needs to be greater than the median of the timestamps of the past 11 blocks, and
# - up to at most 2 hours into the future compared to local clock
# so there is ~2 hours of leeway in either direction
if header['timestamp'] + STALE_DELAY < time.time():
return True
return False
def get_hash(self, height: int) -> str:
def is_height_checkpoint():
within_cp_range = height <= constants.net.max_checkpoint()
at_chunk_boundary = (height+1) % CHUNK_SIZE == 0
return within_cp_range and at_chunk_boundary
if height == -1:
return '0000000000000000000000000000000000000000000000000000000000000000'
elif height == 0:
return constants.net.GENESIS
elif is_height_checkpoint():
index = height // CHUNK_SIZE
h, t = self.checkpoints[index]
return h
else:
header = self.read_header(height)
if header is None:
raise MissingHeader(height)
return hash_header(header)
def get_target(self, index: int) -> int:
# compute target from chunk x, used in chunk x+1
if constants.net.TESTNET:
return 0
if index == -1:
return MAX_TARGET
if index < len(self.checkpoints):
h, t = self.checkpoints[index]
return t
# new target
first = self.read_header(index * CHUNK_SIZE)
last = self.read_header((index+1) * CHUNK_SIZE - 1)
if not first or not last:
raise MissingHeader()
bits = last.get('bits')
target = self.bits_to_target(bits)
nActualTimespan = last.get('timestamp') - first.get('timestamp')
nTargetTimespan = 14 * 24 * 60 * 60
nActualTimespan = max(nActualTimespan, nTargetTimespan // 4)
nActualTimespan = min(nActualTimespan, nTargetTimespan * 4)
new_target = min(MAX_TARGET, (target * nActualTimespan) // nTargetTimespan)
# not any target can be represented in 32 bits:
new_target = self.bits_to_target(self.target_to_bits(new_target))
return new_target
@classmethod
def bits_to_target(cls, bits: int) -> int:
# arith_uint256::SetCompact in Bitcoin Core
if not (0 <= bits < (1 << 32)):
raise InvalidHeader(f"bits should be uint32. got {bits!r}")
bitsN = (bits >> 24) & 0xff
bitsBase = bits & 0x7fffff
if bitsN <= 3:
target = bitsBase >> (8 * (3-bitsN))
else:
target = bitsBase << (8 * (bitsN-3))
if target != 0 and bits & 0x800000 != 0:
# Bit number 24 (0x800000) represents the sign of N
raise InvalidHeader("target cannot be negative")
if (target != 0 and
(bitsN > 34 or
(bitsN > 33 and bitsBase > 0xff) or
(bitsN > 32 and bitsBase > 0xffff))):
raise InvalidHeader("target has overflown")
return target
@classmethod
def target_to_bits(cls, target: int) -> int:
# arith_uint256::GetCompact in Bitcoin Core
# see https://github.com/bitcoin/bitcoin/blob/7fcf53f7b4524572d1d0c9a5fdc388e87eb02416/src/arith_uint256.cpp#L223
c = target.to_bytes(length=32, byteorder='big')
bitsN = len(c)
while bitsN > 0 and c[0] == 0:
c = c[1:]
bitsN -= 1
if len(c) < 3:
c += b'\x00'
bitsBase = int.from_bytes(c[:3], byteorder='big')
if bitsBase >= 0x800000:
bitsN += 1
bitsBase >>= 8
return bitsN << 24 | bitsBase
def chainwork_of_header_at_height(self, height: int) -> int:
"""work done by single header at given height"""
chunk_idx = height // CHUNK_SIZE - 1
target = self.get_target(chunk_idx)
work = ((2 ** 256 - target - 1) // (target + 1)) + 1
return work
@with_lock
def get_chainwork(self, height=None) -> int:
if height is None:
height = max(0, self.height())
if constants.net.TESTNET:
# On testnet/regtest, difficulty works somewhat different.
# It's out of scope to properly implement that.
return height
last_retarget = height // CHUNK_SIZE * CHUNK_SIZE - 1
cached_height = last_retarget
while _CHAINWORK_CACHE.get(self.get_hash(cached_height)) is None:
if cached_height <= -1:
break
cached_height -= CHUNK_SIZE
assert cached_height >= -1, cached_height
running_total = _CHAINWORK_CACHE[self.get_hash(cached_height)]
while cached_height < last_retarget:
cached_height += CHUNK_SIZE
work_in_single_header = self.chainwork_of_header_at_height(cached_height)
work_in_chunk = CHUNK_SIZE * work_in_single_header
running_total += work_in_chunk
_CHAINWORK_CACHE[self.get_hash(cached_height)] = running_total
cached_height += CHUNK_SIZE
work_in_single_header = self.chainwork_of_header_at_height(cached_height)
work_in_last_partial_chunk = (height % CHUNK_SIZE + 1) * work_in_single_header
return running_total + work_in_last_partial_chunk
def can_connect(self, header: dict, *, check_height: bool = True) -> bool:
if header is None:
return False
height = header['block_height']
if check_height and self.height() != height - 1:
return False
if height == 0:
return hash_header(header) == constants.net.GENESIS
try:
prev_hash = self.get_hash(height - 1)
except Exception:
return False
if prev_hash != header.get('prev_block_hash'):
return False
try:
target = self.get_target(height // CHUNK_SIZE - 1)
except MissingHeader:
return False
try:
self.verify_header(header, prev_hash, target)
except BaseException as e:
return False
return True
def connect_chunk(self, idx: int, data: bytes) -> bool:
assert idx >= 0, idx
try:
self.verify_chunk(idx, data)
self.save_chunk(idx, data)
return True
except BaseException as e:
self.logger.info(f'verify_chunk idx {idx} failed: {repr(e)}')
return False
def get_checkpoints(self):
# for each chunk, store the hash of the last block and the target after the chunk
cp = []
n = self.height() // CHUNK_SIZE
for index in range(n):
h = self.get_hash((index+1) * CHUNK_SIZE -1)
target = self.get_target(index)
cp.append((h, target))
return cp
def check_header(header: dict) -> Optional[Blockchain]:
"""Returns any Blockchain that contains header, or None."""
if type(header) is not dict:
return None
with blockchains_lock: chains = list(blockchains.values())
for b in chains:
if b.check_header(header):
return b
return None
def can_connect(header: dict) -> Optional[Blockchain]:
"""Returns the Blockchain that has a tip that directly links up
with header, or None.
"""
with blockchains_lock: chains = list(blockchains.values())
for b in chains:
if b.can_connect(header):
return b
return None
def get_chains_that_contain_header(height: int, header_hash: str) -> Sequence[Blockchain]:
"""Returns a list of Blockchains that contain header, best chain first."""
with blockchains_lock: chains = list(blockchains.values())
chains = [chain for chain in chains
if chain.check_hash(height=height, header_hash=header_hash)]
chains = sorted(chains, key=lambda x: x.get_chainwork(), reverse=True)
return chains
================================================
FILE: electrum/chains/mainnet/checkpoints.json
================================================
[
[
"00000000693067b0e6b440bc51450b9f3850561b07f6d3c021c54fbd6abb9763",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"00000000f037ad09d0b05ee66b8c1da83030abaf909d2b1bf519c3c7d2cd3fdf",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"000000006ce8b5f16fcedde13acbc9641baa1c67734f177d770a4069c06c9de8",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"00000000563298de120522b5ae17da21aaae02eee2d7fcb5be65d9224dbd601c",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"000000009b0a4b2833b4a0aa61171ee75b8eb301ac45a18713795a72e461a946",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"00000000fa8a7363e8f6fdc88ec55edf264c9c7b31268c26e497a4587c750584",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"000000008ac55b5cd76a5c176f2457f0e9df5ff1c719d939f1022712b1ba2092",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"000000007f0c796631f00f542c0b402d638d3518bc208f8c9e5d29d2f169c084",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"00000000ffb062296c9d4eb5f87bbf905d30669d26eab6bced341bd3f1dba5fd",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"0000000074c108842c3ec2252bba62db4050bf0dddfee3ddaa5f847076b8822f",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"0000000067dc2f84a73fbf5d3c70678ce4a1496ef3a62c557bc79cbdd1d49f22",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"00000000dbf06f47c0624262ecb197bccf6bdaaabc2d973708ac401ac8955acc",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"000000009260fe30ec89ef367122f429dcc59f61735760f2b2288f2e854f04ac",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"00000000f9f1a700898c4e0671af6efd441eaf339ba075a5c5c7b0949473c80b",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"000000005107662c86452e7365f32f8ffdc70d8d87aa6f78630a79f7d77fbfe6",
26959535291011309493156476344723991336010898738574164086137773096960
],
[
"00000000984f962134a7291e3693075ae03e521f0ee33378ec30a334d860034b",
22791060871177364286867400663010583169263383106957897897309909286912
],
[
"000000005e36047e39452a7beaaa6721048ac408a3e75bb60a8b0008713653ce",
20657664212610420653213483117824978239553266057163961604478437687296
],
[
"00000000128d789579ffbec00203a371cbb39cee27df35d951fd66e62ed59258",
20055820920770189543295303139304627292355830414308479769458683936768
],
[
"000000008dde642fb80481bb5e1671cb04c6716de5b7f783aa3388456d5c8a85",
14823939180767414932263578623363531361763221729526512593941781544960
],
[
"000000008135b689ad1557d4e148a8b9e58e2c4a67240fc87962abb69710231a",
10665477591887247494381404907447500979192021944764506987270680608768
],
[
"00000000308496ef3e4f9fa542a772df637b4aaf1dcce404424611feacfc09e7",
7129927859545590787920041835044506526699926406309469412482969763840
],
[
"000000001a2e0c63d7d012003c9173acfd04ccd6372027718979228c461b5ed5",
5949911473257063494842414979623989957440207170696926280907451531264
],
[
"000000002e0c0ac26ccde91b51ab018576b3a126b413e9f6f787b36637f1b174",
5905492491837656485645884063467495540781288435542782321354050895872
],
[
"00000000103226f85fe2b68795f087dcec345e523363f18017e60b5c94175355",
4430143390146946405787502162943966061454423600514874825749833973760
],
[
"000000001ae6f66fd4de47f8d6f357e798943bbfc4f39ebf14b0975fab059173",
3447600406241317909690675945127070282093452846402311540118831235072
],
[
"000000000a3f22690162744d3bc0b674c92e661a25afb3d2ac8b39b27ac14373",
2351604382534916182160036119666703740669209516522695514729880748032
],
[
"0000000006dc436c3c515a97446af858c1203a501c85d26c4a30afa380aba4a1",
2098151686442211199940455690614286210348997571531298297574806519808
],
[
"000000000943fe1680ffcc498ce50790ff8e842a8af2c157664e4fbc1cb7cb46",
2275790652544821279950241890112140030244814501479017131553197129728
],
[
"000000000847b2144376c1fb057ea1d5a027d5a6004277ed4c72422e93df04e9",
1622203955679450683159610732218403647246163922223729367236739072000
],
[
"00000000094505954deb1d31382b86d0510fd280a34143400b1856a4d52b4c93",
1551048739079662593758612650769536967206480773659027300489594142720
],
[
"000000000109272cecb3f7e98ac12cf149fa8a1b2aaab248e1b006b0dc595a3a",
1389323280429349294447518501872137680563441219958739463959193059328
],
[
"0000000009e6aa0fe39b790625ffeb18a2d6ff5060a5bd14e699e83c54109977",
1147152896345386682952518188670047452875537662186691235300769792000
],
[
"0000000000d14af55c4eae0121184919baba2deb8bf89c3af6b8e4c4f35c8e4e",
594007861936424273334637371358095438347537381057796937154824241152
],
[
"0000000003dfbfa2b33707e691ab2ab7cda7503be2c2cce43d1b21cd1cc757fb",
148501965484106068333659342839523859586884345264449234288706060288
],
[
"0000000000c169d181d66d242901f70d006f3e088c1ae9cacb88b94b8266e9c3",
110393429764504113949181711819653188468070301266890302199533928448
],
[
"000000000009f7d1439d6a2fc1a456db8e843674275bf0133fc7b43b5f45b96e",
76554528428498296726819074079132986384157750623812250673757552640
],
[
"000000000011b8a8fad7973548b50f6d4b2ba1690f7487c374e43248c576354f",
52678642966898219212816601311127992435882858542187514726849708032
],
[
"000000000077e856b6cc475d9cf784119811214c9cac8d7b674ec24faa7c2c0c",
43246870766561725070861386869077695524372774526710079316876591104
],
[
"00000000004cbb474f2cbf3a65f690efa09804512af3351ba3a0888c806c6625",
37817516728945957090904676150631917288430706594442690521085247488
],
[
"0000000000235b1ec6656d8e91f3dde3b6ab9ad7e75b332e4da9355ce60d860e",
29373101246077110899697012205905070265841442578602225419818106880
],
[
"00000000002a153a2c95a8e5493db93086b0e3fe590b636a5871ace57523ef93",
20444488966645742314409346972440253478913291170842138088329707520
],
[
"00000000000e9550e084908cf91a4e8b74f9f1315d1bc4020709f9e7f261bb18",
19563849255781403323327768731100757126732627316116500830377476096
],
[
"00000000002c2cfef3bb85b463d3fcd39b73a6d3d5ae11c1e2a8113e3794f28d",
12545026348036226200394850922278603223904369245268262607334146048
],
[
"00000000000fa92b757ee29674aa97e98a49ba3ad340d2baa94155d71648dfe1",
8719867261221084516486306056196045840260667577454435863762042880
],
[
"0000000000030571601dbc8e13d00d45004eee6ea8b6ab3cdfb38d2546fee21c",
5942996718418989293499865695368015163438891473576991811912597504
],
[
"00000000000bb6adef42e63082b20fd2b1dc1b324c51973512a4c31f29a9986e",
3926013280397599483741094494745234959951218212740030386090803200
],
[
"000000000000765094788a98dbb8adac30d248b7129b59b1441ee2b7ef9e332f",
3337321571246095014985518819479127172783474909736415373333364736
],
[
"00000000000431a0aa9625f82975709f3c6f4f64d04c559512af051599872084",
2200419182034594781720344474937177839165432393990533906392154112
],
[
"00000000000292b850b8f8578e6b4d03cbb4a78ada44afbb4d2f80a16490e8f9",
1861311314983800126815643622927230076368334845814253369901973504
],
[
"0000000000025afe84e27423011af25f777e5a94545dbd00fd04bebe9050f7dd",
1653206561150525499452195696179626311675293455763937233695932416
],
[
"0000000000000e389cccae2a40437be574fd806909e24136711e7f8bce671d65",
1462200632444444190489436459820840230299714881944341127503020032
],
[
"0000000000030510bf6bc1649726cf2e6e4010c64a2c8fd3fde5dc92535ca40e",
1224744150896501443874292381730317417444978877835711165914677248
],
[
"00000000000082648057f14fc835779c6ce46a407bafb2e5c2ac1d20d9f4e822",
1036989760889350435547200084292752907272941324136347429599444992
],
[
"000000000000f38accd6b22959010471a6d9f159d43bf2a9d4c53c220201254e",
739430030225080220618328322475016688484025266646974337550123008
],
[
"0000000000004ed7a73133678b5eb883cd8882bf14dfb26c104ae0c3f94cf4ee",
484975157177710342494716926626447514974484083994735770500857856
],
[
"00000000000037bb3ff4cf649a1757d4028ecc10f893529b4a2214792c981f96",
353833947722011807976659613996792948209273674048993161457434624
],
[
"0000000000008008f46559fe7f181e9dc0648f213472a1e576e8bf506b88f22f",
390843739553851677760235428436025349398613161749553108945469440
],
[
"000000000000691d0c2444db713bf6c088844cc95a37cdc55cc269bb0a31d8c8",
327394795212563108599383268946242257264650552916910648089116672
],
[
"00000000000071153b0afcc64a425f8442c29749610797119e732dd4b723f675",
291935447509363748964474894494542149680088347011133317125767168
],
[
"000000000000a384acb522e4e5935ad2bc31366ecf1f16f1f11023e967ef033d",
245823858161213192073337185391658632187400443916100519594033152
],
[
"0000000000002e532093d43e901292121fb7c6583caf2d13b666fe7e194b4a97",
171262555713783851185422181139260521316022447660158187451973632
],
[
"00000000000033e435c4bbddc7eb255146aa7f18e61a832983af3a9ee5dd144d",
110438984653392107399822606842181601255647711092336854093004800
],
[
"00000000000028ff4b0bd45f0e3e713f91fa1821d28a276a1a1f32f786662f13",
61993465896324436412959469550829248888675813063783317791309824
],
[
"0000000000001ef9c75318e116a607af4de68fb4f67c788677ee6779fb5fa0d5",
47525089675259291211422247200069659468817014361857087365971968
],
[
"0000000000000e6e98694ccb8247aad63aaa1e2bec5a7be14329407e4cea6223",
30742228348699538311994447367921718297595975288392383715082240
],
[
"000000000000000a2153574b2523a6d1844c3cb82d085e2575846dd8c5d4ebb4",
19547336162709893274575855467812492508787617050928192350584832
],
[
"00000000000002a92c1b1ffb2a8388979cf30798e312335ae2a1b922927ee83d",
17248274092338559882155796390905381469049315669915374897332224
],
[
"00000000000004d54b1422ce733922e7672a4e2ecc86dcf96c0de06565cddaa6",
15943936487596784557029840069157210316687734428242467413295104
],
[
"00000000000009dd91ae96cbbf67af42340b0bc715b3606aa725f630b470262d",
14273467308195657992975774342458504496649432985410431166185472
],
[
"00000000000007d33d78522fa95bdcd4a25072aeac844cbe9b6bc5d0cc885d0a",
14930233597189143322113827544414041000381079823613435714732032
],
[
"00000000000003dd57f5dd1228f68390b586700063225d26bac972bd120546d2",
15164766714763258952996988973449124317842091658872414191747072
],
[
"000000000000076bdeca878b47c392f51fbda543b1e69612cf7d305deb537604",
15357836632983707094928406965317628870031114888441593128288256
],
[
"00000000000008eb1bb7e18d9dfe62210d761cbf114d59ca08e4f638b8563e30",
15958672964717750944291813934170287689797412223641384931819520
],
[
"00000000000001b0d8d885e4d77d7c51e8f1fdaba68f229ac04d191915845f09",
18362361570655080300849714079315004638119732162003921272832000
],
[
"000000000000081baa3a716d5f9ab072c9fc3b798900234c9be23ab02a287c30",
22401652017447755518156310839596703571934659990690572544245760
],
[
"00000000000005b88d0224b9b0d4b65d3de9a61d93609bb91c9297440f1c4657",
22607619418140130980719672680045705126213018528712048676700160
],
[
"000000000000027d6a6870403fa43a650b7d9a6e61243f375a79ea935ad9ef1f",
24717289559589094364468373797949472355802981654048927838633984
],
[
"0000000000000810a3490b86e4f302f6557f9621c5c8620c2b09ec8f0cf72794",
23340814324747679919001773364939281849550099124416593832968192
],
[
"000000000000073833bca8d0ea909fde717e251576b7b3ccaaa58ad5d39eed60",
23242391331131109072962566885467580392541369223033474166816768
],
[
"000000000000031b7fd2ed1f28ff74e969aa891297706c38bd2e1d3bc48183c4",
21554562042243053719921017803645315870071034703425342074257408
],
[
"0000000000000b0738bcba382983811d40b531f2e68cd57126092755f1be4ba6",
20615546854515052444405957679617344022137222968655050411343872
],
[
"000000000000000664cbfd5e3fa497c07614c33a0934b83e01fbe980634a9aa4",
19540887421473929614259883543522244007742949396702043752628224
],
[
"000000000000021eb520df39289a70e40c59822a8c47924dc4940e7d0c1455c4",
19588382523276445241758125434587686389961661359576757951266816
],
[
"0000000000000275e0c41b11bc250fe887c5e60c8ebaaa449f5c28c67133d496",
18009299117968233362105684657812007807160912568078774269116416
],
[
"000000000000097fb0fdbeee0cee7e8f4e1a4ef8fad49f3d549624b0d47abed0",
17993483763986497389087426516491816616385967180337839494660096
],
[
"000000000000053f199ae19d34365277e534f978ea2f6c69cd4757a4fc099af5",
16574638092431222848464934504874974361824393751455373256032256
],
[
"0000000000000217b2e7b4f61682d24b9357d62ad29f27ed45ea2a32dc1f32f6",
17085559845791583266730740536950670241169412424878408752693248
],
[
"000000000000039c1d77acd4702393f48ca61983c64fc0209ade141c694b2359",
17870687961287995446644888885900316642120964851955511819501568
],
[
"0000000000000ae53f0c78330f6c2fbece2752909bc3742823e4fab29c5fd2b0",
15554707140145502641228553657813466188995512591033787398225920
],
[
"00000000000004b4d72b8631a85ec7d226dc696f1913ba1bf735b7c8dec207b8",
16944226977030767532657500340718760127019357828074148225613824
],
[
"00000000000006e06735bffb7d2f215dcadd8311fc33f4a46661fdca3dc0560e",
17028747171100603034973679895960153979114298528140818252824576
],
[
"000000000000055fc0110d4a38ffb338eabc30c8b0aef355d4643d21b5b6a860",
15614535766060906942258863525753414259523988166363835227176960
],
[
"000000000000081b69cb4de006c14084c4861f0e4a140c37200117a738733fe8",
15392654931672180089790308609774483894682932641297604569726976
],
[
"00000000000009920770f2d40b5b6a8aba33d969b855c91b0f56e3db9c27e41a",
14444739009842829731785903206212823051010663269705670545375232
],
[
"0000000000000791dd1cb7a684a54c72ccde51f459fff0fc3e6e051641b1e941",
13237058963854547748734324548161076199478283141947127217782784
],
[
"000000000000019da474a1a598b5cf28534b7fd9b214eed0f36c67c203a9b449",
12305424274651356593961118223415860240572779254789271782948864
],
[
"000000000000074333e888bac730f9772b65e4cc9d07edb122c6e3c6606bc8ab",
11046080738989403765716562970384822165842244193743674858799104
],
[
"000000000000067080669115c445f378f3dec19787558d0e03263b9dec5d7720",
10007073282210984973971337419529346944295676968729147521105920
],
[
"0000000000000304760bf583f4ac241c5ffe77312fa213634eba252c720530f1",
9412783771427520201810837309176674245361798887059324066070528
],
[
"000000000000041fb61665c8a31b8b5c3ae8fe81903ea81530c979d5094e6f9d",
8825801199382903987726989797449454220615414953524072026210304
],
[
"000000000000022fc7f2a5c87b2bab742d71c4eb662d572df33f18193d6abf0e",
8774971387283464186072960143252932765613148614319486309236736
],
[
"000000000000013c6d43ba38bc5f24e699515b9d78602694112fefdc64606640",
8158785580212107593904235970576336449063725988071903546310656
],
[
"00000000000001665176b9a810fddf27cca60dfcfd80bf113289fcc8ffed0284",
8002789794116287035234223109988652176644807295346590313611264
],
[
"00000000000002dc6ef80f56a00f1091471d942ce9bfb656ebdab4ea0b77eb0b",
7839560629067579481152758851432818444879208153964570478641152
],
[
"00000000000002a1fa5546ec48ca88b9e5710e2c6d895bb3675004fdacd6ab13",
7999430563890709006856701613305138698914315019190763857641472
],
[
"00000000000000f517517c11e649b98feca7da84ae44fb643de5a86798fe3c31",
9047927233058169382412882048952728634925849476849852060008448
],
[
"0000000000000299cab92a923348acf9251f656bcbacdb641fd0a66d895a6e8f",
8296391419817537486273948666838217011279219811331013552898048
],
[
"000000000000027508b977f72c3a0f06f1f36e311ad079536630661880934501",
9081029136740872581753422344739175313292014241889017867010048
],
[
"00000000000001925959229452cc6fbfef0104ebed7ccd6f584f2439c5dd1f1b",
8230751570811169734692743946971314968326461977249645504495616
],
[
"00000000000003b34ca89509da5f558af468c194afaa8d458bbeb07c50cc7c74",
7384127474250891166670391848516180960454656786677558849568768
],
[
"0000000000000076559e314ab0c86cc552e34fd79488415d3d17f6ea3c01adb3",
6172230000534146257480611019445716458048957888854766248787968
],
[
"000000000000003a58043252cdc30ed2f37fb17e6ef1658324b1478f16c1463b",
5561365017980676031428107027647386014985059524839404952616960
],
[
"000000000000011babf767e60240658195b693711c217d7da0d9215ccab45333",
4026319404534786334009451711043898716884778820756489262596096
],
[
"000000000000027579d28fb480ccad8e2516d1219d4c1919e3fd4fc0c882955d",
3513558656525386849113615662535622466519417660386833443323904
],
[
"0000000000000074546fe07f80ba15fc81897ec56a5535de727df9fda9dab500",
3004083578955603829930099910053556479043735076695139267117056
],
[
"00000000000000b6c55833b80c07894f4c4d3bb686e5ddbc1b1d162e22752ca3",
2675541054922611112919804040984964595022815308724929898217472
],
[
"00000000000001326f2f970753122e35bfdf3358d046ddf5ea22e57f5d82b00d",
2409843108029446766213067266805752590003732794677225687351296
],
[
"00000000000000641084745613912464ff73c974bafd0bf6dd306295f019d306",
2218268905456883731807407021635746739577921454491297946533888
],
[
"000000000000011ae105ddb1a5bbac6931a6578d95c201525f3a945276a64559",
1727551573307299192250197436766000536509732237655131060961280
],
[
"00000000000000d9b66fee19af89eaaf3f3933d1acd2617924c107f0abbe0a41",
1394031503757574068227953656553224448260418805016069352194048
],
[
"0000000000000011956d42670c2f75eeb344ac0657a806775998e2c58fa4b157",
1263610003247723462826224891154624535497729630761756072607744
],
[
"00000000000000959b1ea990368fd16d494e68ee13bd7245ddd9cdfba3330100",
1030450001678223668360152541055867895065240185756254103142400
],
[
"0000000000000091f86b1e423e24fe358c72db181cfcc2738c85f2f51871a960",
862513010327976103705811440432628413487564277790886242287616
],
[
"0000000000000055e146e473b49fe656a1f2f4b8c33e72b80acc18f84d9fcc26",
720982641204331278205950312227594303241470815982254303477760
],
[
"000000000000004f6a191a3261274735292bc30a1f79f23a143e4ee7dd2f64c1",
530591525189316709998942710962548491505413142398652303540224
],
[
"000000000000005327c8e714272803c60277333362e74ec88b9ffab5410c2358",
410030579894253754102159787320079652501746816512444002729984
],
[
"0000000000000002e2a62b8705564c38d6a746fc8e971a450a69989152b5ee97",
310118479516817784682897231521434079438159381558537557639168
],
[
"00000000000000202bf3ff30109538bfd9b5075c6438ab5ef64ebe2cf9b61404",
239366800071949252578530950352093786414793290792735831228416
],
[
"000000000000001c997105893f5991cb45765ff856b6e503f8466cb22cdd330a",
181156297885756721946540202079438048595571151633323613224960
],
[
"0000000000000010c13ce182a3d8fc6748b75640447eb360d7739a5fe984ffc1",
142431093377788751676361246670241704468765375727695350988800
],
[
"000000000000000bbb49db68b79ecc8393376d78272d237bb612288af64c1de8",
100696259189502783924473792493100546893980348528488767029248
],
[
"0000000000000001bbfd0973c367d30eef2416d9e94bdddea53bccf541a4858f",
68962778243821519216393853205209897734463141354237780295680
],
[
"0000000000000004ee5b6ace996ab746f1e6dd952cdbc74c0b4f8b9ac51c7335",
52765641310467331636297188681879886184148735229489015947264
],
[
"0000000000000002f2f23b515085d0c9f37a2824304ccb7ca1546a48548d0dac",
44233472386696495417387091608220539804351405166731810832384
],
[
"00000000000000045590c3fdeca1753d148a87614a70fa0897a17f90bb321654",
38110290672195532365762668664552282566878756832852091863040
],
[
"0000000000000002b704edc0bf1435fe2116040b547adb1bc2d196eb81779834",
29679649578007061283718812081441644170496168236939550392320
],
[
"00000000000000038cc59dc6dd68ae0fbe2ded8a3de65dbd9a2f9a36d26772df",
22829202948393929850749706076701368331072452018388575715328
],
[
"0000000000000000a979bc50075e7cdf0da5274f7314910b2d798b1aeaf6543f",
19005913916847449503306572434028937600915626422125897711616
],
[
"0000000000000001dd8e548c8cf5b77cde6e5631cd542e39f42c41952e5e7085",
15065005852539512185984435657022720640916062598235628240896
],
[
"0000000000000002513542a461de351a5a94f96b4bcd3e324a48d2d71b403fe0",
12288698618318346282960995223961541766142764336009759948800
],
[
"000000000000000150cc07163e78d599a7e56c0d1040641bffb382705ac17df0",
10284386012808371892335572105827331142617405906583881252864
],
[
"00000000000000009051d83d276dad5c547612f67c2907acf6a143039bddb1bb",
8614444778121073626993210829679478604092861119379437256704
],
[
"00000000000000000b83d3947d2790ab0bcbbb61eba1eb8d8f0f0eb3e9d461e0",
7065379129219572345353864175298106702426244380437224882176
],
[
"00000000000000005a4fbbaeffee6d52fa329dd8c559f90c9b30264c46ad33fd",
6343094824615218102798845742064326605321937397913065881600
],
[
"00000000000000006b6834bae83e895a78c5026a8c8141388040d90506cf3148",
5384518863803604621895699676581808210968416076987222720512
],
[
"0000000000000000bf3c066c9acdb008e7fff3672f1391b35c8877b76b9e295e",
4405349994161605759458363322921957536960017949107037405184
],
[
"00000000000000006bcf448b771c8f4db4e2ca653474e3b29504ec08422b3fba",
3863038134637689339706803268689141874606936642244315185152
],
[
"000000000000000098686ab04cc22fec77e4fa2d76d5a3cc0eb8cbf4ed800cdc",
3369574570478873127315415525946742317481702644901195284480
],
[
"000000000000000036cc637d80982595b1fa30f877efe8904965e6fd70aeae1a",
3045099693687311168583241534842989903432036285033490677760
],
[
"00000000000000000ee9b585e0a707347d7c80f3a905f48fa32d448917335366",
2578448441038522347123624842639328775756428679710156783616
],
[
"00000000000000000401800189014bad6a3ca1af029e19b362d6ef3c5425a8dc",
2293149852232440455888971398133692017055281498246925516800
],
[
"00000000000000001b44d4645ac00773be676f3de8a8bff1a5fdd1fb04d2b3b2",
2002553378451099534811946324256852041059202347552707969024
],
[
"00000000000000003ff2a53152ee98910d7383c0177459ad258c4b2d2c4d4610",
1602972750958019380418919163663316163747908621623690788864
],
[
"00000000000000001bb242c9463b511b9e6a99a6d48bd783acb070ca27861c2b",
1555090122338762644529309082074529684497336694348804259840
],
[
"000000000000000019d43247356b848a7ef8b1c786d8c833b76e382608cb59e9",
1438882362326364789097016808333128944459434864174551793664
],
[
"00000000000000003711b624fbde8c77d4c7e25334cfa8bc176b7248ca67b24b",
1366448002777625511026173062127977611952455397852592472064
],
[
"0000000000000000092c1f996e0b6d07fd0e73dfe6409a5c2adc1206e997c3a2",
1130631509982695295834811811892052032638591596239280668672
],
[
"000000000000000020ce180d66df9d3c28aee9fcec7896071ec67091a9753283",
982897592923314645728937741958820396011314229953349812224
],
[
"000000000000000018d37d53ae02e13634eefb8d9246253e99c1bdf65ac293ea",
903780639904017349860452775965599807564731663176966340608
],
[
"00000000000000001607d1a21507dea1c0e5f398daf94d35fb7e0a3238f96a0f",
777796486219054632155478957346406689849105796561635377152
],
[
"00000000000000001acae244523061f650ddab9c3271d13c0cd86071ae6e8a5f",
770217816864616291160628694313702426464491250746461782016
],
[
"0000000000000000104430189dba1219b0e3dd90824e8c2271609aca5b71250f",
749174812297985386116525053725808178560617045558724395008
],
[
"00000000000000001aa260733b6d8f8faa2092af35e55973278bb17f8eaeca6b",
680733321990486529407107157001552378184394215934016880640
],
[
"000000000000000009925ad5866a9cb3a1d83d9399137bccc7b5470b38b1db2b",
668970595596618687654683311252875969389523722950049529856
],
[
"00000000000000001133acacb92e43e24af63a487923361a4a98c87a5550dffe",
673862533877092685902494685124943911912916060357898797056
],
[
"000000000000000018c66b4a76ca69204e24ee069da9368c7a9883adb36c24af",
683252062220249508849116041812776958610205092831121375232
],
[
"000000000000000010b13aed220b96c35ccd5f07125b51308db976eefcd718f9",
663358803453687177159928221638562617962497973903752691712
],
[
"0000000000000000031b14ece1cfda0e23774e473cd2676834f73155e4f46a2b",
613111582105360026820898034285227810088764320248934432768
],
[
"000000000000000010bfa427c8d305d861ab5ee4776d87d6d911f5fb3045c754",
653202279051259096361833571150520065936493508031976308736
],
[
"000000000000000005d1e9e192a43a19e2fbd933ffb27df2623187ad5ce10adc",
606439838822957553646521558653356639834299145437709336576
],
[
"00000000000000000f9e30784bd647e91f6923263a674c9c5c18084fe79a41f8",
577485176368838834686684127480472050622611986764206702592
],
[
"00000000000000000036d3e1c36e4b959a3e4ad6376ce9ae65961e60350c86e8",
568436119447114618883887501211268589217582000336195813376
],
[
"00000000000000000b3ec9df7aebc319bb12491ba651337f9b3541e78446eca8",
577075114085443079269506210404847846798089003835028668416
],
[
"000000000000000012d24ce222e3c81d4c148f2bce88f752c0dba184c3bc6844",
545227566982404669720599751103563308707559049533419683840
],
[
"000000000000000000c4ccbdd98c267bd16bda12b63b648c47af3ac51c1cc574",
566251116039239425785056264238964437451875594947144974336
],
[
"00000000000000000056bfec1dca8e82710f411af64b1d3b04a2d2364a81993f",
565860883410058976058672534759150528155363303710710038528
],
[
"00000000000000001275d1cadce690546f74f77f6d4a6190e2137a8a819946f6",
552364745922238091561919045022000637317595931246011088896
],
[
"000000000000000003816ae80c6413b84cbee2f639ba497ab5872ec9711eb256",
566500670366816952120145379831520408210047884740723212288
],
[
"00000000000000000d92953224570f521b09553194da1ca3c4b31a09a238f4f6",
542528489142608155505707877213460200687386787807972294656
],
[
"000000000000000006721943f23cfacf20c17c2ad6ea4e902af36b01f92e3c06",
545717322027080804612101478705745866012577831152301113344
],
[
"0000000000000000031d9af2fe38cc02410361fb213181fdb667c74e210d54c4",
527827980769521817826567786138322798799309668948178370560
],
[
"0000000000000000142e8a13ef6994961655c8e86aece3f0abebd2ee05473e75",
515692606534173891771672037645739723025219384908133171200
],
[
"00000000000000000c7a8db37a746d6637ef6a6eab28735608fd715ee2f394e7",
511567664312971151375333957573881285830542480898837708800
],
[
"000000000000000007854877c66c71a49af40d20f2d6f817becfe4d66d5e5a81",
496889230460615059653870414954457230681194245244172894208
],
[
"000000000000000005ce1d2d10aeb9def4d38233e859d98a4a168ea3fa36687a",
473325989086544548323169648982069700877697035484407005184
],
[
"000000000000000007c71decfe74855ad99dc2aa4a2e713165db5a8d6da5f32a",
454358737757395076722955683517864397151243915416267915264
],
[
"000000000000000008ce4f34161be6760569877c685e37ebebce3546ea42a767",
443316987659242217350916733941384923365365929826941140992
],
[
"0000000000000000086233f4843682eb47bacb58930a5577fbfd5c9ebd57ddf9",
442802913227320896234856097023585967110900073490544590848
],
[
"000000000000000010a904eee4fc763c6b88d378884f368fd652f63c1af71580",
433057199397126884276233483897801969646324654385408245760
],
[
"00000000000000000c114754749d622d4fa2f78c84d7147c345b2b99a8e83d2e",
409419129139225030716120689261979366152221060879441985536
],
[
"000000000000000000a5039e32cc9a89aeffbde1391e8bc9ae9724127904f01d",
370716507988397359530778284103407727265240291588416995328
],
[
"000000000000000003b0b73d9b3259c318cca48a6335b5d64545583f7f3773fa",
340818253309165415058055171484606858815006633875327680512
],
[
"00000000000000000198bcc5bd65fd0ccd1c7e3b49e0170ea80296cbfee05042",
288495652867775987986282369150900282132304927019642126336
],
[
"00000000000000000a60f379d3dc1413491f360809a97cbb02c81442c613dce7",
259524902203633530447121351815377152077137395840706412544
],
[
"0000000000000000038973a5f8ba8cdc7e371dcc8f4b24337ef695f24b962907",
237834253647442358407456603145452341381064939329604812800
],
[
"000000000000000004b8ec471974913d052a3af7dc2a8c6f01c2ac2f3d1f7b19",
224600391397450328424792273873642383828872941895338164224
],
[
"0000000000000000075d572eef1c4210adc7abf4e40986d7f0a80003853bfec4",
187067719845325692996306936867878122094522982476155977728
],
[
"0000000000000000074f9edbfc07648dc74392ba8248f0983ffea63431b3bc20",
164898540577033087399552264895286015147022701908103004160
],
[
"000000000000000003c4a4d9c62b3a7f4893afe14eef8a6a377229d23ad4b1ea",
170169861298531990750482624090969781281789404909188153344
],
[
"00000000000000000404b6939e6c35a5448386e5d58f318c82ce2fefb7d73e47",
162900609378736249874251099581569547607832255884553093120
],
[
"0000000000000000034656c96781091b5fbc799c881ea85b41cba0b88128eff7",
161578008857017275969393492955354620126364423170461532160
],
[
"0000000000000000045645e2acd740a88d2b3a09369e9f0f80d5376e4b6c5189",
150883090635422687830679296233896712896447026244773478400
],
[
"00000000000000000381e6a138308c6547d6fe3eb3437250ffefdebbf71eefd1",
150899178845446426410002882396535253739927398750206558208
],
[
"0000000000000000012100ddbb2102e65fb1ebbf104ead754a4110abffc4b8bc",
138784382553152119468195441786396823230753870240366460928
],
[
"0000000000000000046f56e59b9b1293b5e7c1587aa6d29c4f3f79b98cf22ee6",
135262935280049154152065372885142255350817451144176992256
],
[
"000000000000000001bd1c291e91f4476f93454d4542d2ed7e44fc86902c93bb",
137505556928474480767543871928291413858290772017802117120
],
[
"000000000000000001c37a483375ff6fd6ed7c5b79d80167b027a8fdb0721dcd",
128713911367130082233924624261304605948946745676720504832
],
[
"0000000000000000051804b4c2da5298c4573386bf1d4242bf0e26a49ec32e42",
126333978716874242627475052620752087219210710628817698816
],
[
"0000000000000000034bff7888f1f7294311f0199322f77c1457018c875bd9e1",
126278605342839049377710151409810132688161986656629424128
],
[
"00000000000000000506b43c9283ccbc40f583e0c734e4a8af2ce6a4262c6221",
133533639774706835230353390473157702360903922769486413824
],
[
"000000000000000003937068e19a0750a33978050f019d2b60f430e3da707db9",
124022888639743237872084547350559836284832548627419234304
],
[
"000000000000000002e2f6ec3c9eb965aa706c788da7dede201b6b4b8fae3971",
122123731568103772089607259872577666017242529148853813248
],
[
"000000000000000000b3076636b13562bb4315f895bcb324e0c962763c2196b1",
119378259820331825692479928211144812308894309500762193920
],
[
"00000000000000000025b8961d1d0cfba33b0205ec10b3ce541618e352b0bbd5",
111759931157462873316041289986819959868258380300102402048
],
[
"00000000000000000421d58b78b9f063a4b20e181d55c9c79082f9e4b8b30925",
104283029085035157753191385936387396702868516379761311744
],
[
"0000000000000000027fd968d41741f31c73c4a3b304472da0165245278e2ea3",
106299667504289830835845558415962632664710558339861315584
],
[
"00000000000000000364a23184b8a2c009d13172094421c22e4d9bc85dcf90a5",
105881374043672627773432318187360570734220873198601240576
],
[
"0000000000000000042a2ed4a504424060407825d774a54f2e148fa769ee72ff",
95668727978371040303278646201741713440261619517174579200
],
[
"0000000000000000025f769f13f2806fed19d9948b1a7ef19048177789afc5d3",
94012390634764280055243391736606357298689315295029362688
],
[
"000000000000000000b3ff31d54e9e83515ee18360c7dc59e30697d083c745ff",
86923102180582917240747796162767475850640519180006195200
],
[
"0000000000000000021ecdcb2368ce66c23efd8bd8ab6a88a8bb70571c6e67f0",
84861566431029438820446406485131195674434646972185968640
],
[
"000000000000000001972cb33b862b27c1dc3f3a723f7d1cfd69aebe0409126c",
80022382513656536844370512820784980102919810105407963136
],
[
"000000000000000000cb26d2b1018d80670ccc41d89c7da92175bd6b00f27a3e",
68605739707508652902977299640495787127103841947617329152
],
[
"00000000000000000276deb4022f66cacd929c690cd6b4f7e740836b614b21f4",
63859343606086615291372321518809062931940920926127783936
],
[
"000000000000000000587912ced677698c86eec8b1d70144dccb1c6b0bad0f17",
61163258921643354765656928775243357859392914550528409600
],
[
"0000000000000000009f989a246ac4221ebdced8ccebae9b8d5c83b69bb5e7c8",
58509826700983959310706392369835644790490546910263246848
],
[
"000000000000000000038bed8b89c4e82c13076dd64dc5f7a349c39d3921d607",
56672777602924507578641088682504585686103825941044133888
],
[
"00000000000000000122f47d580700a3a5b4b6cb46669a36e4fa974c720ab6cd",
53958359841942568206719748916397287559357255547625668608
],
[
"00000000000000000172ad9ea56a90bdfed0f364a902500e9ff4d74f000ced99",
51764751112426770751506128647798102319231116027761786880
],
[
"00000000000000000201d7429db233c7055e9699c5bfb57b167ca8d0c710dc71",
51649140486907347007064544362790913467244253139882213376
],
[
"000000000000000000c0549b2a8adbefbf6c909f61fdc4d6087c44a549cf8201",
48144529712666433692552181910809237167694270386587828224
],
[
"0000000000000000015b6789cdc5dc13766f58b38f16d5b35bf79ce4b040f7fd",
45240046586752885057924289339576851866807485277820420096
],
[
"0000000000000000013a31b29f845d97465bff53f901027f8ab4b1a2f59118a8",
39718797393257298660757754408019939605415460564426031104
],
[
"00000000000000000088cdeaa7389a7de9f09e3a28b3647630fea3bd1b107134",
37880625861940376795251270290737354395669643839013912576
],
[
"000000000000000001389446206ebcd378c32cd00b4920a8a1ba7b540ca7d699",
38043004539854389433075372490391464304285496568268718080
],
[
"000000000000000000f41e2b7f056b6edef47477d0d0f5833d5d4a047151f2dc",
33509870757351677175294676059494700127350769223450230784
],
[
"0000000000000000010e0373719b7538e713e47d8d7189826dce4264d85a79b8",
31340207270661909233492904963194738468218672502370467840
],
[
"00000000000000000053e2d10bd703ad5b7787614965711d6170b69b133aa366",
29201223626342991605750065618903157022235193117232857088
],
[
"000000000000000000cbeff0b533f8e1189cf09dfbebf57a8ebe349362811b80",
30353962581764818649842367179120467226026534727449575424
],
[
"000000000000000000d0ad638ad61e7c4c3113618b8b26b2044347c00c042278",
29217311836366730185073651781541697865715565622665936896
],
[
"000000000000000000a7bda943639876a2d7a8caf4cac45678fb237d59c28ba1",
24433127148609864747615599184820261456796420809345204224
],
[
"000000000000000000fb6c6a307c8363e923873499ba6299597769c10a438e61",
23988269434232535193761088780698748366141469438183997440
],
[
"0000000000000000006f408147ffbcaa0fb1dcf1f199c527ffdaf159d86e5cd9",
22526487188587264742197108840494583820145762956159746048
],
[
"000000000000000000e3be3cf7343d7792c0d47d3c39ddb9ceaf19961e9eeab4",
18556440756915402760741928101946749165024073301499052032
],
[
"000000000000000000b3fb09d6def197657e20f9c1d5e9680cfcac1e1f9aa269",
19758940920085072387393228723348383373068660102939017216
],
[
"000000000000000000bfe71f044145e1b42fdfb3a523ee2a215e80fa6afc2a98",
20014481558369106100835306608979160026489460596213284864
],
[
"000000000000000000cee3bff56ee49c0f96d1cbd17fa17dc6f84b3f48aed765",
16946123176864917983795071264823963343174695083267063808
],
[
"00000000000000000089ef13654974b8896b0b0909dd9ae8e350b8a8a7807ce3",
14392961660539521116256653268419249019684881662910398464
],
[
"0000000000000000003105a067417c318dab31e25ae1583fa2b27be226945fdd",
13960450711994363030255127593764523087979983609872252928
],
[
"000000000000000000720da39f66f29337b9a29223e1ce05fd5ee57bb72a9223",
12101157559014734955774763823279522156034099347349045248
],
[
"0000000000000000006a8957cbd52c2038861514f106f7f9f76392d5cb83fd4c",
10356793971791534424976101420669664288187918308140384256
],
[
"0000000000000000006b68e55432541794388c94fe9e805652038e7b3cac0681",
9378292318569022964986206758839123913433917663832178688
],
[
"00000000000000000001c9deea9f0302eadb1250df1ad53da802dfb40d47face",
8964447668935855171055978546867850348456065181232922624
],
[
"00000000000000000013aaa8778111530a626a3fe57e4e6f4a878c92669b04d1",
8192878571041388924351625416816775770172128369752145920
],
[
"0000000000000000002f67aa98789b98304a32e54bffbb34c8693eb0acac4c30",
7786052052270684126234611299412205796254663675224260608
],
[
"0000000000000000002e5f072398ee27b25b6cdcf69051bcdbbece417093c979",
7678459224733657715202292429397298472913633233275453440
],
[
"00000000000000000028d7447c20ade2053bbaf49e8a16eb5fb1bc74335d0d18",
7021961458254440109762706424650140438182306270565892096
],
[
"00000000000000000042d89446b9043387be2d4c09aa9e9524176c5754616510",
6702918573828378664524678433037841287557455508299317248
],
[
"00000000000000000018ec4d369bab2c13174834a02138decea7c85685d46bd6",
6505870154073602347674948421782035713149324747260035072
],
[
"0000000000000000000d4a6c2237c6c46b963b17f60d9c850c4915518deb6678",
6259542822111302646229226565336702507884435252736688128
],
[
"00000000000000000031adb986da21237ce06b57ae5390b7f0f890ab8e21b66a",
5456617206587901877414813377199700077413780408546361344
],
[
"000000000000000000031df41201cd3789559333cd9529f99834a805014c9b13",
5309609141393698345581459330931267317315649121846034432
],
[
"00000000000000000020c68bfc8de14bc9dd2d6cf45161a67e0c6455cf28cfd8",
5026314587016750785722693470327208449351582469580652544
],
[
"00000000000000000009dce52e227d46a6bdf38a8c1f2e88c6044893289c2bf0",
5205879062684137510961952799929229129995569309608312832
],
[
"0000000000000000002eca92f4e44dcf144115851689ace0ff4ce271792f16fe",
4531442825108320403104334767545311437480985430866264064
],
[
"00000000000000000000943de85f4495f053ff55f27d135edc61c27990c2eec5",
4219470685603665866184576203153693664105230070242607104
],
[
"0000000000000000001d9d48d93793aaa85b5f6d17c176d4ef905c7e7112b1cf",
4007526641161212986792514236082843733160766044725313536
],
[
"0000000000000000001877e616b546d1ba5cf9e8b8edd9eba480a4fbb9f02bce",
3840827764407250199942201944063224491938810378873470976
],
[
"00000000000000000025eb2c783f2f29d68ab4260f4b0248450c0038debc7ba4",
3769176185135465353474348091454476000617158630021529600
],
[
"0000000000000000000c61b8a7779dcc46e88ca343b9a3fcc6763917fe3b87e2",
3616317728887026217259424694800679959591344645351669760
],
[
"00000000000000000003dba9fedba6a0b92b640167eeda0d41485a3c85ac4ac6",
3753318892370425056811838111019504329853891761930240000
],
[
"0000000000000000001ac75bed7eb6169255893f99de28f24e3e0e57b6f7db7b",
3752507758961706405692235065937346792777982719368888320
],
[
"0000000000000000000e5796e9c5cdc8a8a2de84fd17287d7dfe89074de31766",
4052052750044136275098507698196378011637603685579620352
],
[
"00000000000000000015fe695e8d2e5ed3a7de81d3818ef43a444e1ee7b3ace2",
4774638159061819979596346127394133648234752261950013440
],
[
"00000000000000000015a08d0a60237487070fe0d956d5fb5fd9d21ad6d7b2d3",
5279534360700703025330663904443631645337169341976674304
],
[
"00000000000000000008f4f64baaa9b28d4476f2a000c459df492d5664320b12",
4798269179035823348880781507454323228379569035237392384
],
[
"00000000000000000028a69d9498c46b2b073752133e3e9e585965e7dab55065",
4581847093576588582947343450056030606262879232408420352
],
[
"00000000000000000014dbca1d9ea7256a3993253c033a50d8b3064a2cbd056b",
4636475101776743072223960781733299832971578678999777280
],
[
"00000000000000000019046cf62aa17f6e526636c71c09161c8e730b64d755ae",
4447653474738502407900799312400854215681091162244907008
],
[
"00000000000000000017e5c36734296b27065045f181e028c0d91cebb336d50c",
4440088742263677654396177039706714734771352055402463232
],
[
"0000000000000000002296c06935b34f3ed946d98781ff471a99101796e8611b",
4442250303185290059812200289574302117357423179633524736
],
[
"0000000000000000001ccf7aa37a7f07e4d709eef9c6c4abd0b808686b14c314",
4226119056551884143559484765457720035561644907380604928
],
[
"0000000000000000000de3e7a7711130dbac9fb0a14e5ad6ab72d080182f3321",
4217024131862773934699503234743726606330326039165665280
],
[
"0000000000000000000e6829c1245de98ce5a35c177a75f67e9c1678cb6e24aa",
4243570847603252455305754966045185171099356397876281344
],
[
"00000000000000000001b2505c11119fcf29be733ec379f686518bf1090a522a",
4022508494445492072607020209303018350395259009223360512
],
[
"0000000000000000000a4adf6c5192128535d4dcb56cfb5753755f8d392b26bf",
4021030916290150529756716283937142188262386861422411776
],
[
"0000000000000000000485ab94f5ea60203aacfc9740b3e42700d7e7012f76d7",
3614033401827878015998272335407144409231622422786998272
],
[
"0000000000000000000cbc6dfb3f2afbd6ed1427e30ed1f3167898ac4aa4c673",
3638558860803927897868648370584956354584468626790678528
],
[
"0000000000000000001d9865df58f5f300552699fefc09aa840ba25ac044a534",
3397669776434136486181562425402160438435718857259745280
],
[
"000000000000000000115eb6c10b7a98bf23a46002baec8fbbbb2cf0583439a6",
2974300520630483197933400799376074857018768662277914624
],
[
"000000000000000000113978c5b95531173923ba81ed4d1df3b09db37ae0f0cf",
2990922178751847556822131306978557143801315583089180672
],
[
"000000000000000000096b8d24db6471fb5871e9ae8bd1d7384fbee9c80a6052",
2699909434228155498652331786772923585210445951064342528
],
[
"00000000000000000016e0dd8fe86bf34feaa611b4c52180b6822b5ad31b68ff",
2647377219375933524160418539145769508351933111739613184
],
[
"00000000000000000011e20e47a868d12a2bf3de814ebd067e83514aa2725745",
2502742632840755378666227277045667991877723059489079296
],
[
"0000000000000000000c48f6bed594da7bb5e75731b4e78501670e834d426e87",
2267299103571658911252368261549572946260211294613274624
],
[
"0000000000000000000f7871dc40f51b1ecd6343a6d9fd614d0e2235a7d9e3fd",
2112846149036891759953684644743283440459952687539027968
],
[
"0000000000000000001558c0f33a360d105b52a749103eb2abd4a66a68d52664",
2072520395859657486634608572838975759381606196813234176
],
[
"0000000000000000000676463abf3771ea01e0f8c948d1c93658a1d82d95df5a",
1969073848467738847181233556694484530967339635488849920
],
[
"0000000000000000000e24396612da4ec125ee6c0b4507e854c5cfed1884cd30",
2119459443945814095658556318611324621123895782295994368
],
[
"00000000000000000002fb021eeb13e47021920faf6e5daa3c40bc552c4d248e",
2078088717097888226752964612051624797686495299801972736
],
[
"000000000000000000067b904af747b653ba448a79779f7846bf1ea5537b8a4d",
2093644940525638357414324633411056914147713045789409280
],
[
"000000000000000000080ae07ccf2f1b6d1d089f5dcbc1fac50a6b93d005f1e0",
2082043540528505650049623783208955059537684253263265792
],
[
"00000000000000000008f9ddf24dbec1459689fc399329e9738b2795860e4361",
1953761695813422977307213550702116033770404430236090368
],
[
"0000000000000000000aacba541ebb7b56b0831e4ae33faf20ff1e528bb9a657",
1824503568004603261415443256727022530945994444270206976
],
[
"00000000000000000010fe23dd08a4b6465c4850984bb538e9dfcb93995a23cc",
1743137387349479903250289511035208906392689711805104128
],
[
"0000000000000000001166c174a9d34b0743953e724162fe44388e38d078204c",
1734095076719313606895363312975193263350078457161711616
],
[
"00000000000000000006da92c61b6b63ea910be27cab5fd951137105314f2969",
1740794600224838465872409004248364704712181251938713600
],
[
"000000000000000000043f26353c41c2343a277ad72f115171fb49d3be52dbbc",
1628687194130096895725758951785196783123433634364653568
],
[
"0000000000000000000bc6800858a1b3be08fb26b55d4b989c95e06ad50a350c",
1937788944419033539314165479165359776648584743473905664
],
[
"0000000000000000000c799dc0e36302db7fbb471711f140dc308508ef19e343",
1832085838499075985755083973639154607251969422303166464
],
[
"0000000000000000000de98650125747f239134cf7e2b7362033e325a8003a14",
1689336589076054705025375464973257095873115523033071616
],
[
"0000000000000000001138f586983520b0de3645c0873164f4b214b90cf3aedc",
1674005436900453533413418811078063286996924790657253376
],
[
"0000000000000000000e87ecbff47d9ab75e78d92328d5951351f9702597dace",
1780912820169571750977100152906426673601736600243404800
],
[
"00000000000000000007c4dac98234149700771e9d1756956660b63cca88c36b",
1963213226902041926479236780515292236058519345991516160
],
[
"00000000000000000003030a3de58b57be352e2ca79016cefe19777e02ba0520",
1707948812427463753688699391317898960128433823967870976
],
[
"0000000000000000000cfd1300625612513c6cd1413245fcdaf1eeb766e33a93",
1708005810991319658902509335026374895166200405337047040
],
[
"0000000000000000000830b0a5ac4b78b5eb99209ebb4790be1fae1428c7f77c",
1554226608711362053849117616927967595838003183165112320
],
[
"0000000000000000000ed5cf2e86791b44abce69e178e58613e64ed47e1c02a3",
1600203988720154928752887338080389143353359165034594304
],
[
"0000000000000000000aac5c93f7945c60d82828990448cde97d3d7128830a6d",
1590739304116800001454600275103718494518067345886281728
],
[
"000000000000000000049a66ca322371799e1cb51d85c8937764ba6a2abb8ed9",
1535456543183121267670627692621392373016562041515671552
],
[
"0000000000000000000657c7aa925caa49d18e0c02cab9992be315012d8fab06",
1554222224206450061140363005873469446988944215367483392
],
[
"000000000000000000061250f1186194229157967d10a01a2b36ab19d4304da5",
1395807138732878832030429199485686097922398375169228800
],
[
"0000000000000000000d2e17e6d3179b4182518bd678f20bbda8b29e5e494d54",
1397005570075490172423356221048513449998516239854469120
],
[
"00000000000000000005e2dea23567cb4fe092a354e7d1b50b59571715de22f6",
1348156339349342073285316259199804406349536350538039296
],
[
"00000000000000000005e17383e25f65b531d50060b99ed66f673ea251949e4b",
1605902383604108119230963505243149930846997646019657728
],
[
"000000000000000000090386439b3e1c7dc56d2e450694e910b366895f05b9ef",
1532070243889425565609149754863988745260019245813596160
],
[
"000000000000000000046f183ba323cfceb2d11660376c59fb55e8521c4d32a5",
1407282849589201081744164532792174352192736757496676352
],
[
"00000000000000000006d248288fe5c88d55836f0ffcd9acae8333c824106a54",
1443989924712404039437768281050676516514415159246061568
],
[
"0000000000000000000a047b7c5b3b06db1bd4b858e757c7214d192cc491e2e9",
1449469094350757594478113895488529861555105250349678592
],
[
"0000000000000000000ea5abc8d23ce15f85afbdf574da6c82c67bef5df0d752",
1308244191135472445492091830103155433365752488721907712
],
[
"00000000000000000008ad1e1340f31a30d1fee7d0e3e56a0cf7dd571dc653aa",
1294666840924668357381979598007221164113148875397660672
],
[
"00000000000000000004f29390852281bae27d3662f648020bb47cced0d883b8",
1257769770588612382309009370720465882998915202417688576
],
[
"00000000000000000008aa78bb2eb233395c99d1276ab90a7fe728882b5c2907",
1240994654795328278613867476210548386499304408689410048
],
[
"0000000000000000000b842cbf7dfe7345b48deb00b76298f58ae0f58ce821ae",
1256955714176619069383569918268642913356966847991250944
],
[
"000000000000000000065952ab35814a9a021f9e5138623779c93c6c56ad6cf4",
1232968087803106959787092839109270560155354027163385856
],
[
"00000000000000000000b136fc67072ab98643c2346ff07c4076d94c35d9481c",
1165190949371886336955396954992052935118810155482873856
],
[
"00000000000000000005a2db60197fa9012b70f75e4745b362afc16a052d6ee6",
1143226041264440196997713775641159917616401145294487552
],
[
"000000000000000000035d5c18a83cb7e0ad0880a3b7936d277becaad9d5a00f",
1308153578033957929511163201643527023818533820904243200
],
[
"0000000000000000000d882df633c1ec8ef9ded1dbf09f02114cf9c999d1dde1",
1076379879376199359324913638762382564863378102643851264
],
[
"00000000000000000006248c28751a176336f5c070f901dc86df190c391d761d",
1280876111474813957445809627925710317539675495922139136
],
[
"00000000000000000001464428893b618817bff3128a6e17a2c043de53ca4673",
1352521844740049480301990665795127943729248621043908608
],
[
"00000000000000000009cbc816ab1d430e7a9cc24ffdb6702870112c84a9657c",
1877009475827353279654828838027187714710153476809162752
],
[
"0000000000000000000b964e653343c6ae97d73db7f526cbc3187ff7829b0c42",
1971793703014811657512010614168169533666919325951328256
],
[
"00000000000000000003c1a7a8f2e45be0b6ba0d2fac227ff2a43cf8b7eaec29",
1859734526474102007161661283304481249417820354151186432
],
[
"00000000000000000000019999808926fea81b376f1060e7411bc3a5d2853594",
1733053026051896673114684085689466553557063777258569728
],
[
"000000000000000000098d881262beb65d58d46176ae565dee9bb050f7b3d516",
1530484514612921535942898756820491578183692559004467200
],
[
"0000000000000000000019cb70a38e25e348efd12435797631ec48088e4abe63",
1463986190114365453164631096931900700789347628299059200
],
[
"0000000000000000000515117969caee77eadd03b30d8d50d044269172d844d8",
1419099090327021431837841324664685500406654972106637312
],
[
"0000000000000000000e1c751629fb7e8112c37eda91f9f6d8e223cd2ee50c05",
1355224161267474319797749279050820351032592440315871232
],
[
"000000000000000000029da63650d127e160033c93393da77302320bd8ee4958",
1342441867947378242875139851503883739742681654295003136
],
[
"00000000000000000006c0cd9b33f4d579e31ba1e6bbf4c5297324fa78fbf151",
1244706868954148772026104835685647745369230477348569088
],
[
"000000000000000000013712fc242ee6dd28476d0e9c931c75f83e6974c6bccc",
1188998811044006745492934980917001185509005296607952896
],
[
"00000000000000000001871f43a65da0527d3455aea40b0af27e0ef31b3d6b98",
1207017664730659447571468211219560238858343288930304000
],
[
"0000000000000000000a8c54b4dfa4ba0152a0c4d5bcbf999025cbcbf0efc039",
1114247386799443053935571112778061457902663314832359424
],
[
"00000000000000000004680d21d3c5e94dc03d197a3bb5653b1f9d7d5015a2a9",
1110710552837102268873518195482888052995095958078357504
],
[
"00000000000000000000135a8473d7d3a3b091c928246c65ce2a396dd2a5ca9a",
1106174051754827146215413957762136710502083943464960000
],
[
"00000000000000000002d5d3caf5cd22bd5cc211e4fb7e283c1713dabaa96df6",
1011873581609325297224157601300783981224824207994519552
],
[
"000000000000000000001fc83bc1767e7aa5c31bf9dc4aeb505247f6fca1d96d",
1010078857598682948440603476326208385676686722831745024
],
[
"00000000000000000004ddb634472f403757e89fefe4590b70a3841e7b307bf6",
963971403944167623177113627223675088972581362965938176
],
[
"000000000000000000041e39e07eefe69a84140b1020fe46b329a20ce5b74a77",
978555728783092703397868198169350877225727913812295680
],
[
"00000000000000000005f63eed68a9a3979f0db78aec853df62aae1234f43306",
982035564181577583246111171756048347095528689197121536
],
[
"000000000000000000076c23a2f567ea68fa523055dac2a0d94579ad277459d8",
943064623022149056932209915691668660376403247938666496
],
[
"00000000000000000001a5cc47cfd6abaf57dd33f891342f35bd8b8176f8867d",
955133703543227653230735945040239725552721938878562304
],
[
"00000000000000000002c1452eafc55a3a7b2e23b87dc94c53257be3b9fd2d92",
904852201212495269232856372055468724544479235670016000
],
[
"00000000000000000005c7686bd1dd938bf1e1849ae17d71efcd868e897a2ae2",
862674725460762741916416231468109512880228678412271616
],
[
"00000000000000000008b5ffa0ae1b604dd27bf4af84602ea53f7920320a3c96",
901734818220068453308327912307284892863553131555848192
],
[
"000000000000000000050c04aa3e3ca62420b6366e20ebea29ed3042320d2e4b",
890244492347372894565410542152469475763018189902970880
],
[
"0000000000000000000882e7306788b58cb5805c04f432203863a8e8c8290902",
911713951399763858433822672345071673321763838959288320
],
[
"000000000000000000018b957c6b644c23f532ab7d8fbbeca6fa77ad078a6b29",
924766622522766152396299781586060796970310972500606976
],
[
"0000000000000000000528876999d7bdfc25ad64e66ffe7f27a1e371bd9835c5",
973529624652311728262165726029639579921131161797001216
],
[
"00000000000000000001095f6deb27964f80c74f38217a32044c20265e0f40e3",
956871428990014096800480126306339386063092842672160768
],
[
"0000000000000000000073616e48a83e3e27b70ba27cd38c683d0014189b41b9",
950899733299880027476699870079860653644778702301560832
],
[
"000000000000000000016ca393ebd4f388b294134f5633a62d4268b3e4b544dc",
870306687010904716955275873664553942808871958151692288
],
[
"000000000000000000028a4784e2bd24e775437108c0e7a90469bff4a62895d2",
841292956506611632223096322365470292302662385308532736
],
[
"0000000000000000000136eb131bc275961ef87f4a06f56d849ef7de6067a83c",
859664032087861081904916640712713969859737457373741056
],
[
"0000000000000000000009f301f2215237cab791aedc296f102fd7b9dfbff456",
757060771140682373435345150716700036747812369126653952
],
[
"00000000000000000007ac57aa98595dd1daa3db89f17a817b445b083d696e0f",
731886405437657570669286679473162061734238931073892352
],
[
"00000000000000000006e6f83c247026057e769aace3815f8138941b256a3ad2",
733349368576625804490408567990711061036914519549411328
],
[
"00000000000000000001348162a93f4734709f6a142b19aeefd8714f46d0b8f9",
729612308889970685728561745873455525355654300037021696
],
[
"0000000000000000000428fc10bebcc825140bc83b01b8be32488bcd408e1389",
787270009984312136754615316208945606764100494789967872
],
[
"0000000000000000000152525a810f2033976f89ba434694c0fe5bf97730f625",
762342638057996256581733267702136683580848909336969216
],
[
"00000000000000000001fe51e048f4f42e4212098de90827f929403e17b71988",
790751306884434347505776493480475792916920926107336704
],
[
"000000000000000000064d0f3321a86b27d4883ab1ccedd12cf0941fb74f01a7",
717191006474295341826748628480199835971598529354268672
],
[
"000000000000000000053916f5f3b68319baf095c4205ad4edca517cd89b4a51",
685105199528332699160504931662746558558072186305773568
],
[
"00000000000000000006813d0260b4726b64e83025e904165ffaaf06d17227d7",
688509036841676372057001313638142781710850853198364672
],
[
"00000000000000000005c39a2670a916dc4075afaaf8fe30e495c13ba323e141",
626181838016062686207286970262124176054603953970610176
],
[
"00000000000000000005e07bf1518ede202f3a18b1a710e9700046755b3c7550",
619023402996415923713925321951479821824329196375113728
],
[
"00000000000000000005776a4242de5ebfc59d247a114188851e3d24fd173f61",
575524729764536260159429050275345090310309676098519040
],
[
"00000000000000000000eb00afd3cdc013b3033d0419037fa9c6f4243b5a7a79",
562973353703138465897895804931977651737504527419441152
],
[
"0000000000000000000060df647feb8b98f0b5e7ce312c35101bd5c6de001fcc",
553442901526103647968289576137834770166328191306694656
],
[
"000000000000000000044cb8431ecd498363f5ec16e05553b7f1f69b1a3a6a93",
561592234655860762640193322765060764283929671166328832
],
[
"00000000000000000001c951f76bd2d92beda3c1ab615d57558ecf654c900042",
544090752548823200194704196893283275123549878964191232
],
[
"000000000000000000036eb2f0d13b6e4fdb17da672479713a9e08d0f0875dbf",
526200511006255617572972890856003254679941608705622016
],
[
"000000000000000000033c59708025b67a8b8518d3760151eb4980e7a3a21e62",
514982024438103606772841406080073066221062670505738240
],
[
"0000000000000000000062afa9e3b9df5eee17c2edfc89f60778afe3abccb593",
532311049351936122673982497141590033985123062667804672
],
[
"00000000000000000003a3bbc3dac0c578948918c796426cda4c1958fc8454d3",
500073246235691066104245617101534263137552502634840064
],
[
"00000000000000000000dc8c2caadf5e8e8635adc7ff4d90dcfba264a3493e6a",
515199788182065911307653755120147792390991404454641664
],
[
"000000000000000000034e1a8f7c1efee7c36209a1556a377568d6368431dd17",
514581572989474939373253596435908804673676944988962816
],
[
"00000000000000000000967ad630a7aac2aeed84cc4c10ef1bce932cefdb374e",
484696787509332636501824648976526249487752436350189568
],
[
"00000000000000000002f985a10a40897d04888ac10c8ac75867a50b016eb6ef",
497866378763321402697758053004132675777872044494946304
],
[
"000000000000000000027ecc78c2da1cc5c0b0496706baa7e4d7c80812c10bf3",
471981723264553781113452590931894587216745823226298368
],
[
"000000000000000000047381dac259c4a8ce569c8498a2b9d11db6c080500343",
470321457404545875398373204961928889706416683857477632
],
[
"00000000000000000001df9394219ced52cb3789dc1c31408d6e01bec37c0b2a",
441737408381628076124145537003663826407985955181953024
],
[
"0000000000000000000353813d30e99afcc4579d469b57718f4521d570b3dce6",
431604817530012926192239390058441836232711374861500416
],
[
"000000000000000000009af85a7f58a31a21d77ad6dbeb8f52a11de6ec716b79",
416823189970048174077527321660349349771911273121841152
],
[
"00000000000000000000cc85f351633ed7b58e8b827ad0176798c4905084e95c",
396710004437100288117208210992507862855406329465405440
],
[
"0000000000000000000125ab365abaa653d41dd6acb9ecddf9bb9a944d1e0bfd",
400552292241643231889165698417718970914081776120889344
],
[
"0000000000000000000078f4e50a09391670d061deea0b3b87c3fa60cfaaf782",
374406027949793378682501776760424667692437142927048704
],
[
"00000000000000000001595b16989798f8997d89ac778ccc7d0d6c7e88ab4ec9",
368311566122123513513592411007997767500471904222838784
],
[
"000000000000000000026ab1b5e445f9320cf2ec3dc6720b501877d4e0b40eff",
383255420363831995852225088422521761376453814474768384
],
[
"0000000000000000000268358721568565da476fdf07250d18ee210bc2f874c0",
357069695527774208266769667274744118513278471102267392
],
[
"000000000000000000005b3388af9acbfd8c00b5b3ec888a75085011cc69cbb2",
329879919066870090376508314646890389215599502072741888
],
[
"000000000000000000010e2bc83838d1b9479b88341e4d0033dff524d16d5ef5",
339749439623765677783137798322223448447336014535458816
],
[
"0000000000000000000350156217f3a450f8994e7054b631db2f0e07f43f268d",
321145985282180614537323094086577881890135649195917312
],
[
"00000000000000000000c1d14709a7659153a2299083637a01475b7865926b0b",
324317443835188673869825090173572216042789022814175232
],
[
"000000000000000000023aeab989430385cf0c085e9e25580d1813ea3daee028",
312072983117630369221114618645075196904111619969122304
],
[
"00000000000000000002bf1e60049e942ac34b728911adda77d704cc8401e84b",
305996059309608474887223697110640892108382252455428096
],
[
"000000000000000000021342b77cc83903ed85341a53b9ec571fb7b0a503124c",
324234138241860812403487480138107387910668634659225600
],
[
"00000000000000000000a2a87e6a371a8d2e63eb61a051333c7a3251b717c723",
319495949933634025142671133910441198360944101354897408
],
[
"0000000000000000000132f5df736574a143d1e41e1a0cdb9c7d1656a906124c",
322033116776040472608672730780036665683066800249503744
],
[
"0000000000000000000112beeccb1e3ba4e55ee2987685cc397cdd21e4c65fe2",
322192420454509541026756932426802740532209296896688128
],
[
"000000000000000000016e792fc3bd650030e9acd074a910c7905bbe9bb79899",
339134147434449367654574047007649893296060866934865920
],
[
"00000000000000000001a481e800d3a60641bfc442db64a2c2d9d11d3a298705",
328583567114557579488061646200271046177164689907122176
],
[
"00000000000000000001b39765b103785831b8f5a23d9fb42187226d1faf82f3",
297348354121521522320212493955458645481078099598639104
],
[
"0000000000000000000314bc1981218b70bf539ae13ac1e41eefc1ad7a605049",
310338180674118587457206844748640968959780028040609792
],
[
"0000000000000000000073036581ef712215c5f9aebfeb7d2fba84a2f71dd69f",
301319254070149585548971905645948786445483268317904896
],
[
"000000000000000000028030eb24a8d0bd042bd589839edc932c876dda457c9c",
290914823913990887674279873321841567628552684544458752
],
[
"00000000000000000000a98d2c6a1875258ba9611e24b421c88325ec5155e2c8",
304956931645466202912380877194579614881406884417372160
],
[
"00000000000000000001924bab37e9d87715e84aa7bcd0b52405f893dfe7005f",
292880543616200952099263829421844968289989913814761472
],
[
"000000000000000000007c7decd9c85fe0c5273691cf361b548855d91176fdab",
281789207690496729853016065226361096453821041746116608
],
[
"00000000000000000001b4ceba765aa1ecec00c4e3710b7d5115a90060c48cf6",
265227471136262937983931908702020177275080014169112576
],
[
"0000000000000000000026f3661ace0cda87b922d13d35280c9ac61c6ad364cf",
263561359269705708657179707992723614632672251070119936
],
[
"00000000000000000001a2a4d658523967c398ae9c4adda72c02f4072ec398f7",
259426771137696584301581483600969249970065617906040832
],
[
"0000000000000000000087427d61da6ce8b57e2726a9c62be8637cc906bad7e5",
248423125310232216230425940495448355115076101789974528
],
[
"000000000000000000002428ab473fa8570115d4a000e637814b86248d35d370",
245573197117436955539928755071651603226747033331171328
],
[
"000000000000000000006d0b2fecfc61125fe5a7c6387fdca048c4b3cd5ffa84",
244083926948996765466279200227113710829717638069878784
],
[
"00000000000000000001651e40ff05e359a13e1740d9c21f400abdc5f1b287c9",
249381870384321288544767557745710236775970393538166784
],
[
"0000000000000000000268d24974a0f3d8ec73bfc6b0a470c6bc891951b1e3b2",
236140665550103308105842173161300712617887644698804224
],
[
"00000000000000000001a3361e40ce264e71620908f8e8cd555b4182f7813bb7",
243826702660826526552675351696555645018258193942315008
],
[
"000000000000000000009e2b057f15897a5bbe33a9f5a4ca96d426368554f34e",
240389250809824242889060284970006947356027440601235456
],
[
"000000000000000000023d9b58d8793462a5d4fd778b8fe1ba9d8cd8f26816eb",
236991259503029893604236717733941589335327397438816256
],
[
"00000000000000000002065cd1b5c268e8e375d1a176da03d22479d0c02e2da6",
221874948068116364721256005509157074063026087146815488
],
[
"00000000000000000000ca2f64794d814b198eb237974573222e4521545582d5",
218766334085513534214236767869969540080217918627905536
],
[
"000000000000000000008765cffc1a859242108b85e6415ae144ec918a05787e",
226329605058700956815940836879276304706937369537806336
],
[
"000000000000000000006ebc27176d134d4885a125cd7e3a13783a6c9bcd12c9",
221600185760298154972633712760606412855330771828736000
],
[
"00000000000000000000a4d531b0328083c2f19a7f6c31e22e18cb73ff7a4d2c",
212309419851785605121612888279029001699378008653037568
],
[
"0000000000000000000108312d8ba9c66bf9c806b9c7bf2e923d282508941005",
213268164925874677435954505529290883360272300401229824
],
[
"000000000000000000009d9d77c8ae464658da8ad3ba1b2d07eca1937b9cef39",
230505115236555346453248764446346725294094368813088768
],
[
"000000000000000000011a2a7498613bba536d6f0f5649aca4e6a484d6312211",
213504928191122283708703502472190921209456561473191936
],
[
"00000000000000000000a4b1cede1eba74d67df322ca415468f277aa7b508f47",
211248369663083369602997013090476980227107801626836992
],
[
"000000000000000000016e02290e770058422eef8df58d85c037a2d21ca9afb4",
208285905844213629387798143934561074546265226362224640
],
[
"000000000000000000010ee7b1a4f44b8cf580f3fe692f362c43741f10b81c8b",
207862070369387667541519075333073352470565005924761600
],
[
"00000000000000000000b34c3fbb0151989a03d8fe589af6a5621528e6786c29",
198173776015521112096746848576997112333265829097373696
],
[
"000000000000000000019f01622aa9ccac55b505788f3310454eb40145b7d73a",
189398920184986370975851924841368549083251610109345792
],
[
"000000000000000000010919a63dfd4bb86037ad9f2c26cb8e4f3b18d345cb4a",
178729958232470779672965025562539683039763302545620992
],
[
"00000000000000000001d4b4cbfc67c0904765c9b6d6dd00ac3a72fd4efaac90",
183753139359977093002831090332585547778320742695829504
],
[
"0000000000000000000095fdebd0bf892913da1bf6f109a2fbddd7b4d13bb8fb",
172847414142213895427195194110856643885648174060142592
],
[
"000000000000000000014b809628248ecd176df547224411fedafd577041929d",
177049231349540241317030788004915957567158980121198592
],
[
"00000000000000000001c83578eeac30ea13eacc3668da7e37c8aed04992ec95",
180571450295507717349901668451762199644529777549770752
],
[
"0000000000000000000144a6109dda22b210490144247f34903e23ea98281065",
181918954805126809840485465867526612588652547354394624
],
[
"00000000000000000001d39c254559a52d78a36cb5373269db169857d9aa490e",
181841495218348271985820670571392649588610782929616896
],
[
"0000000000000000000159186ff01cb830839027afce1c072816ad1caf6735c2",
184058593202179251712735660462623250929428832597311488
],
[
"00000000000000000000fd7f37b8fb43c92b11c9d2809f115d22a6b5dbeb7836",
190300666695219538076383598383154495706379320488361984
],
[
"00000000000000000001209d8bd5b083d0eec0a0cfb72a9f922a5f46e3b4744f",
214194756963942469886095641713233006794734161633476608
]
]
================================================
FILE: electrum/chains/mainnet/fallback_lnnodes.json
================================================
{
"0294ac3e099def03c12a37e30fe5364b1223fd60069869142ef96580c8439c2e0a": {
"host": "8.210.134.135",
"port": 26658
},
"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f": {
"host": "3.33.236.230",
"port": 9735
},
"03cde60a6323f7122d5178255766e38114b4722ede08f7c9e0c5df9b912cc201d6": {
"host": "34.65.85.39",
"port": 9745
},
"027100442c3b79f606f80f322d98d499eefcb060599efc5d4ecb00209c2cb54190": {
"host": "3.230.33.224",
"port": 9735
},
"033d8656219478701227199cbd6f670335c8d408a92ae88b962c49d4dc0e83e025": {
"host": "34.65.85.39",
"port": 9735
},
"02f1a8c87607f415c8f22c00593002775941dea48869ce23096af27b0cfdcc0b69": {
"host": "52.13.118.208",
"port": 9735
},
"034ea80f8b148c750463546bd999bf7321a0e6dfc60aaf84bd0400a2e8d376c0d5": {
"host": "213.174.156.79",
"port": 9735
},
"033e9ce4e8f0e68f7db49ffb6b9eecc10605f3f3fcb3c630545887749ab515b9c7": {
"host": "213.174.156.72",
"port": 9735
},
"035e4ff418fc8b5554c5d9eea66396c227bd429a3251c8cbc711002ba215bfc226": {
"host": "170.75.163.209",
"port": 9735
},
"037659a0ac8eb3b8d0a720114efc861d3a940382dcfa1403746b4f8f6b2e8810ba": {
"host": "34.78.139.195",
"port": 9735
},
"03a93b87bf9f052b8e862d51ebbac4ce5e97b5f4137563cd5128548d7f5978dda9": {
"host": "134.209.139.244",
"port": 9735
},
"0288be11d147e1525f7f234f304b094d6627d2c70f3313d7ba3696887b261c4447": {
"host": "18.219.93.203",
"port": 9735
},
"0322d0e43b3d92d30ed187f4e101a9a9605c3ee5fc9721e6dac3ce3d7732fbb13e": {
"host": "164.92.106.32",
"port": 9735
},
"02c197ffa4c2aa4105dd4c4b7279ba1b9061b22910ebbfa759b0001bed9ee48a16": {
"host": "18.181.210.139",
"port": 9735
},
"03c8e5f583585cac1de2b7503a6ccd3c12ba477cfd139cd4905be504c2f48e86bd": {
"host": "34.73.189.183",
"port": 9735
},
"021c97a90a411ff2b10dc2a8e32de2f29d2fa49d41bfbb52bd416e460db0747d0d": {
"host": "54.184.88.251",
"port": 9735
},
"037f990e61acee8a7697966afd29dd88f3b1f8a7b14d625c4f8742bd952003a590": {
"host": "185.5.53.91",
"port": 9735
},
"0326e692c455dd554c709bbb470b0ca7e0bb04152f777d1445fd0bf3709a2833a3": {
"host": "57.129.59.146",
"port": 9735
},
"02e4971e61a3f55718ae31e2eed19aaf2e32caf3eb5ef5ff03e01aa3ada8907e78": {
"host": "52.38.27.190",
"port": 9735
},
"0391904d140fdf88d19423513945a5fcc49c606521b65a85f6d6fe46ebdd1c7665": {
"host": "5.75.184.195",
"port": 35933
},
"026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2": {
"host": "45.86.229.190",
"port": 9735
},
"027ce055380348d7812d2ae7745701c9f93e70c1adeb2657f053f91df4f2843c71": {
"host": "157.90.112.145",
"port": 9735
},
"029efe15ef5f0fcc2fdd6b910405e78056b28c9b64e1feff5f13b8dce307e67cad": {
"host": "103.126.161.206",
"port": 9742
},
"03abf6f44c355dec0d5aa155bdbdd6e0c8fefe318eff402de65c6eb2e1be55dc3e": {
"host": "3.132.230.42",
"port": 9735
},
"03271338633d2d37b285dae4df40b413d8c6c791fbee7797bc5dc70812196d7d5c": {
"host": "63.35.146.37",
"port": 9735
},
"03037dc08e9ac63b82581f79b662a4d0ceca8a8ca162b1af3551595b8f2d97b70a": {
"host": "34.68.41.206",
"port": 9735
}
}
================================================
FILE: electrum/chains/mainnet/servers.json
================================================
{
"5.9.83.108": {
"pruning": "-",
"s": "50002",
"version": "1.6.0"
},
"104.248.139.211": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"128.0.190.26": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"142.93.6.38": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"157.245.172.236": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"159.65.53.177": {
"pruning": "-",
"t": "50001",
"version": "1.4.2"
},
"167.172.42.31": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"188.230.155.0": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"22mgr2fndslabzvx4sj7ialugn2jv3cfqjb3dnj67a6vnrkp7g4l37ad.onion": {
"pruning": "-",
"t": "50001",
"version": "1.4.2"
},
"2AZZARITA.hopto.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"2electrumx.hopto.me": {
"pruning": "-",
"s": "56022",
"t": "56021",
"version": "1.4.2"
},
"2ex.digitaleveryware.com": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"37.205.9.165": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"68.183.188.105": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"73.92.198.54": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"89.248.168.53": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"E-X.not.fyi": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"VPS.hsmiths.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"alviss.coinjoined.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"assuredly.not.fyi": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"bejqtnc64qttdempkczylydg7l3ordwugbdar7yqbndck53ukx7wnwad.onion": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.5"
},
"bitcoin.aranguren.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"bitcoin.lu.ke": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"bitcoins.sk": {
"pruning": "-",
"s": "56002",
"t": "56001",
"version": "1.4.2"
},
"blackie.c3-soft.com": {
"pruning": "-",
"s": "57002",
"t": "57001",
"version": "1.4.5"
},
"blkhub.net": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"blockstream.info": {
"pruning": "-",
"s": "700",
"t": "110",
"version": "1.4"
},
"btc.electroncash.dk": {
"pruning": "-",
"s": "60002",
"t": "60001",
"version": "1.4.5"
},
"btc.litepay.ch": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"btc.ocf.sh": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"btce.iiiiiii.biz": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"de.poiuty.com": {
"pruning": "-",
"s": "50002",
"t": "50004",
"version": "1.4.5"
},
"e.keff.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"e2.keff.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"eai.coincited.net": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"ecdsa.net": {
"pruning": "-",
"s": "110",
"t": "50001",
"version": "1.4"
},
"egyh5mutxwcvwhlvjubf6wytwoq5xxvfb2522ocx77puc6ihmffrh6id.onion": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"electrum.bitaroo.net": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"electrum.blockstream.info": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"electrum.dcn.io": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"electrum.diynodes.com": {
"pruning": "-",
"s": "50022",
"version": "1.4"
},
"electrum.emzy.de": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"electrum.hodlister.co": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"electrum.hsmiths.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"electrum.jhoenicke.de": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.6"
},
"electrum.pabu.io": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"electrum.qtornado.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"electrum3.hodlister.co": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"electrum5.hodlister.co": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"electrumx.alexridevski.net": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"electrumx.erbium.eu": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"electrumx.schulzemic.net": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"elx.bitske.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"ex.btcmp.com": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"ex03.axalgo.com": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion": {
"pruning": "-",
"t": "110",
"version": "1.4"
},
"exs.dyshek.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"fortress.qtornado.com": {
"pruning": "-",
"s": "443",
"version": "1.5"
},
"fulcrum.grey.pw": {
"pruning": "-",
"s": "51002",
"t": "51001",
"version": "1.4.5"
},
"fulcrum.sethforprivacy.com": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"gall.pro": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"guichet.centure.cc": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"hodlers.beer": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"horsey.cryptocowboys.net": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"kareoke.qoppa.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"kittycp2gatrqhlwpmbczk5rblw62enrpo2rzwtkfrrr27hq435d4vid.onion": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"node.degga.net": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"node1.btccuracao.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"nuzzg3pku3xbctgamzq3pf7ztakkiidnmmier64arqwh3ajdddovatad.onion": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"qly7g5n5t3f3h23xvbp44vs6vpmayurno4basuu5rcvrupli7y2jmgid.onion": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"rzspa374ob3hlyjptkdgz6a62wim2mpanuw6m3shlwn2cxg2smy3p7yd.onion": {
"pruning": "-",
"s": "50004",
"t": "50003",
"version": "1.4.2"
},
"skbxmit.coinjoined.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"smmalis37.ddns.net": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"stavver.dyshek.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"tardis.bauerj.eu": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"ty6cgwaf2pbc244gijtmpfvte3wwfp32wgz57eltjkgtsel2q7jufjyd.onion": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"udfpzbte2hommnvag5f3qlouqkhvp3xybhlus2yvfeqdwlhjroe4bbyd.onion": {
"pruning": "-",
"s": "60002",
"t": "60001",
"version": "1.4.5"
},
"v7gtzf7nua6hdmb2wtqaqioqmesdb4xrlly4zwr7bvayxv2bpg665pqd.onion": {
"pruning": "-",
"t": "50001",
"version": "1.4.2"
},
"v7o2hkemnt677k3jxcbosmjjxw3p5khjyu7jwv7orfy6rwtkizbshwqd.onion": {
"pruning": "-",
"t": "57001",
"version": "1.4.5"
},
"venmrle3xuwkgkd42wg7f735l6cghst3sdfa3w3ryib2rochfhld6lid.onion": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"vmd71287.contaboserver.net": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"vmd84592.contaboserver.net": {
"pruning": "-",
"s": "50002",
"version": "1.4.2"
},
"wsw6tua3xl24gsmi264zaep6seppjyrkyucpsmuxnjzyt3f3j6swshad.onion": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
},
"xtrum.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4.2"
}
}
================================================
FILE: electrum/chains/mutinynet/fallback_lnnodes.json
================================================
{
"02465ed5be53d04fde66c9418ff14a5f2267723810176c9212b722e542dc1afb1b": {
"host": "45.79.52.207",
"port": 9735
},
"032ae843e4d7d177f151d021ac8044b0636ec72b1ce3ffcde5c04748db2517ab03": {
"host": "45.79.201.241",
"port": 9735
},
"0220566172d9e324b41ec6f74ca44d377d3faf72ddb310fd263e6d5bcde4882492": {
"host": "185.90.61.24",
"port": 9735
},
"035a4e767bb1be29ed20219b40f07d9be03656a5f83485821878963c05290a877c": {
"host": "54.158.203.78",
"port": 9746
}
}
================================================
FILE: electrum/chains/mutinynet/servers.json
================================================
{
"5.9.83.108": {
"pruning": "-",
"s": "51234",
"version": "1.4"
}
}
================================================
FILE: electrum/chains/regtest/servers.json
================================================
{
"127.0.0.1": {
"pruning": "-",
"s": "51002",
"t": "51001",
"version": "1.4"
}
}
================================================
FILE: electrum/chains/signet/checkpoints.json
================================================
[
[
"000000c0a3841a6ae64c45864ae25314b40fd522bfb299a4b6bd5ef288cae74d",
0
],
[
"000001299957ea59afccbd3f1c4719a466350aea3fda78d419dfde37f9823420",
0
],
[
"000000647c13ba87cb352cbbb464d47f15f1733332c3b910888ff36d858961ce",
0
],
[
"00000096b144d9ab24117213adf6e9e52afb606629f30f10d084ebb4bb80e3c3",
0
],
[
"0000000d41b251c50f6e7e54855b68f4a57004164b9d6014f84c111cd60c2680",
0
],
[
"00000102ea9911f35f8ee910b2bf56946b8163766b8d71b6aae668b2bd1f1501",
0
],
[
"00000108f2a56ca7ccdb6bef0372eead4ba9eab39b2795be544fc8e90b93c01c",
0
],
[
"0000014938ef851ea035d13c02ccdca9e8c3499d33466daab04a1a0a798bbbfc",
0
],
[
"000000739a899ef6a642dc478966abc174a789a88280d83579cc057b95d19aef",
0
],
[
"000000d5237e345c02c467e9d4eda4a207a35af96473daca024c6cd363c7db83",
0
],
[
"00000000bbf300f7561f228cf55f51237beb76f23a4370363ea41e930ebf63a1",
0
],
[
"0000007dcebd40572e399d89055df55c19ea5a67d2a29155e0dd2f35313aeec0",
0
],
[
"0000013d4b88ae84d4b29f64e2ae2466dd0130dfb2f2ce8d9496a469a543fe55",
0
],
[
"0000014ce59f8d976fe17e7cc8d8e5698890995b98073c78bd7c493ba72a2e62",
0
],
[
"000000933087962ab5518c60cc4ca4bdbfeb6dbc7fd3d3d420bc8ef46dc53202",
0
],
[
"000000b622bcdf74d742db0b4d9653034d2549a4e9642962091cad05b1082645",
0
],
[
"000000dfd2d4606684bbeb504959ce225c2ad84d0343c35cdad78d59dc86395c",
0
],
[
"00000120bd3e4e26b6b1418860ead049560f7beec69a4a496cbc780e3f34d8bb",
0
],
[
"000000db40817d098b504b8a524b2df500b6f6c9b9abd954c4116dda7620e0dc",
0
],
[
"000000dbc1b9e4c662ac78c95fc46e689b54d0c2c284dde285dd54dd9b24b123",
0
],
[
"00000041d0bd45cf26d854ff7188e773b87466fd33bfdbf7397ecdade8724318",
0
],
[
"0000002327e4edf0f436fbb87392a68c2bfeb6284d215704a017e741fdc1154a",
0
],
[
"000000f198a4c393044bd9b3c4b340bebe811ce721b5d6788f931282f5eac3fd",
0
],
[
"00000106876fcfbb809096bf96df6dfe1de43e408bee2f88500954d38bf3cb72",
0
],
[
"0000003e477f554425d1b570dc23535f5365e0424a1b4a4b81dc373db1421430",
0
],
[
"0000002cd0191c4d8cc646fe277a6d9be51d5d5e116ed75491291bf18ab7fe68",
0
],
[
"000000940edf438dc29af2fe8e77d831eb3e2434f372d68598da354310289d4a",
0
],
[
"00000103744843bf10a3f0ddd264fae8c7199fadd7187883975c3dc9976b6a61",
0
],
[
"00000141abbf269a681f83cf6b243e505d5e29f483f18f366f3cb9419bace51d",
0
],
[
"000000d0a1eb4b089678e0ebd64f211e0b9cb68f2f1fd1b56ab1d5eca03d167d",
0
],
[
"0000005a587db11708cfd476c5a8308e9ddb210dbda90c64ccae2092fe218367",
0
],
[
"0000011dd3d96c272e17eb43ddf33651238871aa525c9b7885e88ee4c5898337",
0
],
[
"000000055aee09f4d9a965638fea3e45130a3f601363d0614e618481bad3b519",
0
],
[
"00000038bcef9e43f8a75438a925403aeb1b77ed9013c1c719b57c77e8137d2e",
0
],
[
"0000003ce9a4b46fbb431c198aedf59ef322c0791f03e3bf153548fea29dc375",
0
],
[
"000000715d041a78f798c6ef7cce244a0a7751acc839446bfe52d84087696162",
0
],
[
"0000005bcfde62a40e676a73f6fd481b4d5d524891b7b5a7e5874da0af640885",
0
],
[
"0000003a0ebac1a5bf4ec8e7d9f94d672f9ce217eecbcfe1837b0b4d68bb7efb",
0
],
[
"00000002b09a09076aaa8cdeddf164c14bd69c0d3c7c700cc2a1e70d5782fba3",
0
],
[
"0000010e5f6260be27d95a9c6b77b3427f675ac6a79ffa848d2065122e0a49fb",
0
],
[
"00000027c6cadacbf945fde93ca6fa9b2c404216b7b54d46d12acd6bd2084403",
0
],
[
"0000011d5a1230c91fcb07e1ffed7c74a77824f49632d7a85984afc5fdb84210",
0
],
[
"000001420feee79c0518790bae4fcf3d11379f46b7c92568d5d49ce061c14aaf",
0
],
[
"0000012beab6aa1841d9ca386986bf4a1227e4a1c9c4f6bada49dfc5c45a7b4c",
0
],
[
"000001425fa8c62dfd856ae0fee3b36add930a5826778f62c54c5e7a089cb2cd",
0
],
[
"00000047aa330a7bfd668afa6fceec2d71b0a01d4c3e940f01fea16cf8a5495a",
0
],
[
"000001138c3f76d7b699e3f610946b35ab8d0fb670d7b277e848cf93e2963478",
0
],
[
"0000011579b1ffe43056593c6d2541b20ecad58654a70b926b80f02bf7e3afa2",
0
],
[
"0000002a0af0dd33f39c900d2b08ca7b53ec7ad5cf8cdac6c0a594c824528554",
0
],
[
"000000174c981f1f0f785a066db5ab640fd81db7131d3bc6c0ae5f1c881a5869",
0
],
[
"00000010b2e80adbf6ebef457bfe030f028fd7c054eb91967067ecbe32391e6a",
0
],
[
"00000056b45f9fba1d07427704b8929aa5a6273864c3c28fbf0561a970927eba",
0
],
[
"0000015fee8d0e568e47beb80e459c1e55f0939bcfb67c8d71d6aea4bbef07da",
0
],
[
"00000026f4c92697d195eb8380852e8e2f72f00bd237ed7b6a34ab27d46df667",
0
],
[
"0000006997e8fb35db20f6cba346e8a4f5ad3e53b2816b04b5f15ee7d300c507",
0
],
[
"00000153eeef8fe2c11990a87b680c86595bd1eefe62e912bd4d2d0181dd275c",
0
],
[
"0000011255d8a68c30b8dc8f9c0d5bd83484dbd1a67c19f9773e942b869184d4",
0
],
[
"00000127cd6eb4b0bb794bafce0990f25ad88da0338aaa643b08f73679ee7c40",
0
],
[
"000001351829786299ce95aa0e4fff6ca6fb0579176476ebb23cd9c5b18dc38b",
0
],
[
"0000003cd9b6c744cb5c8fdc012c200c9fbb72c8e82a535286ad29e9daae0b7b",
0
],
[
"000001195e3dc8e195350793514679ce2b2951c8e69e2ab759627b7d4dae0174",
0
],
[
"000001469586fbf43e84517bd8aebd65f556a3f8caae21676fde3d8a37d548c5",
0
],
[
"000000f0298f92b43e87c972155819d6e6fa80a99a309a7a63a447748fdbcae2",
0
],
[
"000000793ff1190b63052655624f2e3e771257ee5e4a95d0fe8bf8900119f4a7",
0
],
[
"0000002b535d083651a6a816526ab64e18a3473117e11bf9fdb37409fd2b8476",
0
],
[
"00000056df0365d1e9ac806b3124919922d8d2fa528ec230562b384ef957e049",
0
],
[
"000000e4d73bc1ff88044907fdabcd18fb4feeae8d89214e00a835497eff9d44",
0
],
[
"000001164ada2f23afdc6e7eb7a549d0240ec00795c97a6b7b0da459ba144236",
0
],
[
"0000003a02c60020ea02296f487408b092ea86fcace4b71fe65ddf8e9d1227af",
0
],
[
"00000011c1bc4de2a5565d4f6b52e2d20830ae03cc33da840752a9b082f22970",
0
],
[
"000000590d9132c7ec8cf2fdc08d841aa30f1a6c98baaa41bb2f2ced082ffc56",
0
],
[
"000000aad06d12ca4f1614a04529b9b0db229edd3b999c8f2387bf4499e7e823",
0
],
[
"000000f21e5a641ff87de60c94f9f95c28291989fa52980862a96b1da9420c20",
0
],
[
"000000389253c73a42abfce6f70dd8b4b47c1e94ff70f3650136315f1c7c3d51",
0
],
[
"00000052d02504f8361173e62831ffb1ed06502e39bfe56e7b2e8534527acf54",
0
],
[
"0000006e903dbc8f9c6a155c31ac0eabc64a0f538ec1956c12bf0296365ea10f",
0
],
[
"0000012d6d51f5edeb1bc181aa56058ba399c56a233196a609d17ea9fe1260f4",
0
],
[
"00000078fe34ae6dcfebd6c54750fc5b23c9ccfccb85410a67dc873950089470",
0
],
[
"0000008ba099907877add94606ade4d8c4fce72e78e4c9a7211511e191cb3090",
0
],
[
"000000ba16aeb089d8272fda74e376bc74063e202b41c1d910466b28d0e22aa1",
0
],
[
"000000e9305b2194cc90a8f36ac85d6e335f9c40eebe31a63111b1a2cd62ef60",
0
],
[
"000000b20594e8e7b7c883ebcd4fb5038ac6e5490d8089643e63fcbbed54159c",
0
],
[
"000000ebaf4db2459b51830938659a587f72bd56fc713f8c15bbd877eb6e4cdf",
0
],
[
"00000121189334c3922651f449fb752026ac9d3c83ca36f1b379629acb8a4bc8",
0
],
[
"0000011331e953cf2ebd0231d00cf8ce3b0925d4642d32359922d87dde28e6a5",
0
],
[
"000001088db354ec53ca636d01718cbd5948d240d37b2bde660cf46e64b7bd13",
0
],
[
"000000a163f70345b7c90e1053b582aa415f8211ced4f9693edf493032d8b969",
0
],
[
"000000116824a8e4a6c2d6d0e32b8df377f701091d6a4ff2457790052fec8c2f",
0
],
[
"00000071aed5672329b4ceaa9fe0dc56a73a9aaa6ae368e83fd49d2fc613d182",
0
],
[
"0000006980fba2dab31f8a62e96484952512fefd1ef1a7d1ac6b2b70520298d4",
0
],
[
"0000010a5c600b597167962263ee3f34a9844f11cf62eae87254140fd1ca4c4b",
0
],
[
"00000032c5455af9f5ecbd52120902a2476b525bdb1bdc95304c72a75298576c",
0
],
[
"00000145fda2984b2467e98dca41b15a210817ce8507ed6470bfabaf6e1d58a0",
0
],
[
"000000fc300d0bb620acb44dcb5fd4a75746de32a250724e26346798781b762c",
0
],
[
"0000013835a5ae1a46e514eca524b79ea5acbf16bbd16d4913d5711fbfd0b43a",
0
],
[
"000001105408d97b67a382a8682793c6fa17e8a9e14431ff46931852d98809a9",
0
],
[
"000000b246b4b35ffc56055ff91448ba0c395a7697369680460aab62560c71c8",
0
],
[
"0000001af62d9a537a82ca68c50cab0726fe74a209358fa7ae02aa12fb68dc03",
0
],
[
"000000357adc640f81f63c32cc2bf924953a9087465478eace40ad368bbb610b",
0
],
[
"00000074d93559ca744568b640e31497e54448f5e81eb458d5e6309a866fbdc0",
0
],
[
"0000000e5f9f2f28a8e84fba06965b939eb33d10f367b285e0b765f73432293e",
0
],
[
"000000bc8fbad304cc9f7a2129b3ada77205993d6797aaa3c412d47378cfe1d3",
0
],
[
"0000001bfaddc170487792c89290b630bf2987cf465bceee6228c98339d5b51c",
0
],
[
"0000005d328174d05468a64f3a1c9b876607b229803896be461d4bf71a604ad6",
0
],
[
"000000767328d1681b20d958b8e3ace547f35d780e3fbca8f6917896d7a040fb",
0
],
[
"0000013f4af09699526f35b5d991b6acc392ad3b71d97955acbc061896f613da",
0
],
[
"000000a5bee294beb00596090e6f193fe72463b22feda343c108ed89f81cb395",
0
],
[
"0000008c2e331069893d102b28aae90710ece9fd4926d005de976f97e44b66e7",
0
],
[
"0000015205ced605eded9c5d62e3cf8ed5229f9a72e7beb4bc8d63655b36abf3",
0
],
[
"0000013ae7e94521be9703ffe12b1214d47fcf56fb1268a6652db590ec06bbc2",
0
],
[
"000000d18fb8d06bda4847c4a629e17295a2fa1092e14ffbff0d97a2eae72993",
0
],
[
"0000010f9a0dae27ef69bef205f2e6d900e120b29e684a2a08286abc95579840",
0
],
[
"0000006e29c2511c1ad31fe21d0064594c557e5fac6f73f7961f2b53cf36b983",
0
],
[
"000000dffd144d2e1eac01a786558c04cb65a8ed0002397db7b43011c223174e",
0
],
[
"0000008365805832101e989fbf04bdca8c364b8afcb794b2166db74a4a92cf8f",
0
],
[
"0000004a9fde1918b96cb079d4b48a98098231d34f162e8e898919e0573ada05",
0
],
[
"00000063f777d9004ebcf1685a07dc2e36c3ed40758d5ed5b7dc82ca2165f8da",
0
],
[
"00000084364ae6e59f9223629293d017a1718f587e08b45478e79ca3681ba813",
0
],
[
"000000e826cbbeb467e73577566db478f432295952489e154a3de4db0012dc91",
0
],
[
"000000d2f92728dbccd1782fde6a555a89f47ba4102d3bc0e0d07ac6c99cb468",
0
],
[
"00000066d082b9414191fbf2b18746513e10f403eb3d6e837f4a007375f3bcbf",
0
],
[
"0000007b872779a56424e1eae91113b3f43e7da689c5d2e9e86c5b8bbe2f3315",
0
],
[
"000000057927903d3c9ea07cc6344cfa7299541470c7c97caf24b9d9e39bfb38",
0
],
[
"0000000842d0fa799de1397043352af7fe7da168314c9a0a173fa13304d396a8",
0
],
[
"0000000b66f892759e13adc4249d1200a4c9bba92d56830816d5804cd2254d29",
0
],
[
"0000000bd582d47ad9e2457a02af92c63fd1be6bd7aec7f71d7aec5e145e2bed",
0
],
[
"0000000111a8861cafcd74da47754738fef19072b5da46ee663a52e11f042d6b",
0
],
[
"0000000c210b5e309f204bea96516a1d2b147b4855f2afc501445463ca55726f",
0
],
[
"0000000f0532cadd9795ad911a851d75ce9e198bf671a0e1299a39b86f9ff0ea",
0
],
[
"00000006a125ea1b6fecbdbc49a106a8ab3c8eb6e3ed6f49daf33818ba635d59",
0
],
[
"000000021f43833bc6dc117c0a99abd8428b30e042865f2bdfe89258ab5ce450",
0
],
[
"0000000bb33efcb77b81fb0ee743523a02a93f662d2ea8fb083662bbb47e02ca",
0
],
[
"0000000c62a5d5e42d512c83c542bd936bb5f735cb416d555476be79185f6206",
0
],
[
"0000000d896f71fab78384d2c78c8e6a2367315d282464be8233b0bc21d877bd",
0
],
[
"00000000f5d2c35c12db500f5d250ad24e8f363d2937d1c487b5f86731adb552",
0
],
[
"00000000d72f5a316078cd8747e8b8027baa8f661649bf1a8ed722e4f4231d3a",
0
],
[
"000000079f91871f027e6e3fc37dd638759f1e7b8498fd71c7ff397132b20f86",
0
],
[
"00000004cafb2f9b4aa75f426cfcd4bd2774cf14b3e7d89a8b1945e5d8de8195",
0
],
[
"000000034c3e13346b9843061f780d74ce2c18a264610b26f26576f7631c7aa1",
0
],
[
"000000110f528afa3f436cdb42d15287c378559a85d1e1e110a11ffd5309d4e7",
0
],
[
"0000000019963af20c504e4e78fc7c5bbc5fd52efaa19b4ee778f6f213072c93",
0
],
[
"000000046df3b5b7b222fe177bfc016e92d760bf31d8670be3e5b2318a57c27f",
0
],
[
"000000003da79540fc14abc1d14ee5b4cd635e4e903cf116862abc21e82db694",
0
],
[
"000000102cd648540dabef56265de9ebfec7c43bd3192d873613f06e9bac372e",
0
]
]
================================================
FILE: electrum/chains/signet/fallback_lnnodes.json
================================================
{
"02357a375a846279fc1e8413f5e182652a125e5f6a4f4653bffabebb8177a6d8aa": {
"host": "34.68.95.152",
"port": 9735
},
"0305061295fa30847df41ae6ee809b560e78d65c2a7337a41c725ea3920b65e08a": {
"host": "34.124.125.201",
"port": 9735
},
"027554f8d4d99a43cf1b49d274f698ee5045273cd377206eba62ea308b4386a4fa": {
"host": "35.247.14.99",
"port": 9735
},
"0244bb7ba2392ab2d493ad04ad4afcd482ca44a2bfe5b42bcc830bfe00e5b08082": {
"host": "34.138.100.228",
"port": 9735
},
"03adf6efe5346d455172c750a655b07fb85be4f50f5b555f9f91a853a6b448c3bf": {
"host": "34.74.81.232",
"port": 9735
},
"03ea42c9408a73dabdcb5655e2923956d132fbb25cb71e7c00a29e10c73e937e64": {
"host": "34.138.237.159",
"port": 9735
},
"024d899b60d5de58e8d66af042445323a48b6962d6c667c033802421dc49abc232": {
"host": "34.75.211.29",
"port": 9735
},
"02e8430ba207ce87bd2d4ab36497b9eac10e6d5d86f9fda8aa270c48877e0a8259": {
"host": "34.73.252.102",
"port": 9735
},
"0265ed138065b84d6b9448f9e0a2fd4ceb63fef08efe1dfc949a63d5d43110e4c0": {
"host": "175.45.182.145",
"port": 39735
},
"0307238136c48cd35084c4efadc486143a7e8a7acd8ff8ac053fdab4efabc551c4": {
"host": "104.244.73.68",
"port": 9735
},
"020ee56ff81d12d17d5d3eea5306a8982a5763522ca73e0e220ce282030543c90c": {
"host": "84.247.50.180",
"port": 44149
},
"0271cf3881e6eadad960f47125434342e57e65b98a78afa99f9b4191c02dd7ab3b": {
"host": "signet-eclair.wakiyamap.dev",
"port": 9735
}
}
================================================
FILE: electrum/chains/signet/servers.json
================================================
{
"signet-electrumx.wakiyamap.dev": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"electrum.emzy.de": {
"pruning": "-",
"s": "53002",
"version": "1.4"
},
"mempool.space": {
"pruning": "-",
"s": "60602",
"version": "1.4"
}
}
================================================
FILE: electrum/chains/testnet/checkpoints.json
================================================
[
[
"00000000864b744c5025331036aa4a16e9ed1cbb362908c625272150fa059b29",
0
],
[
"000000002e9ccffc999166ccf8d72129e1b2e9c754f6c90ad2f77cab0d9fb4c7",
0
],
[
"0000000009b9f0436a9c733e2c9a9d9c8fe3475d383bdc1beb7bfa995f90be70",
0
],
[
"000000000a9c9c79f246042b9e2819822287f2be7cd6487aecf7afab6a88bed5",
0
],
[
"000000003a7002e1247b0008cba36cd46f57cd7ce56ac9d9dc5644265064df09",
0
],
[
"00000000061e01e82afff6e7aaea4eb841b78cc0eed3af11f6706b14471fa9c8",
0
],
[
"000000003911e011ae2459e44d4581ac69ba703fb26e1421529bd326c538f12d",
0
],
[
"000000000a5984d6c73396fe40de392935f5fc2a8e48eedf38034ce0a3178a60",
0
],
[
"000000000786bdc642fa54c0a791d58b732ed5676516fffaeca04492be97c243",
0
],
[
"000000001359c49f9618f3ee69afbd1b3196f1832acc47557d42256fcc6b7f48",
0
],
[
"00000000270dde98d582af35dff5aed02087dad8529dc5c808c67573d6dabaf4",
0
],
[
"00000000425c160908c215c4adf998771a2d1c472051bc58320696f3a5eb0644",
0
],
[
"0000000006a5976471986377805d4a148d8822bb7f458138c83f167d197817c9",
0
],
[
"000000000318394ea17038ef369f3cccc79b3d7dfda957af6c8cd4a471ffa814",
0
],
[
"000000000ad4f9d0b8e86871478cc849f7bc42fb108ebec50e4a795afc284926",
0
],
[
"000000000207e63e68f2a7a4c067135883d726fd65e3620142fb9bdf50cce1f6",
0
],
[
"00000000003b426d2c12ee66b2eedb4dcc05d5e158685b222240d31e43687762",
0
],
[
"00000000017cf6ee86e3d483f9a978ded72be1fa5af37d287a71c5dfb87cdd83",
0
],
[
"00000000004b1d9fe16fc0c72cfa0395c98a3e460cd2affb8640e28bca295a4a",
0
],
[
"0000000046d191b09f7726e4f8bfaffed6c30734afbf1f95e6bddbe0b07d9e88",
0
],
[
"0000000082cec8200e9ea055c2991bf74560eb7e7140691ea53e7828dbdc9553",
0
],
[
"000000003775b96d6b362d4804afe2d9c3cf3cbb46a45c3ccc377c94e83edd23",
0
],
[
"00000000037835a92404acb2f18768a49d4f93685ead30aad6bb3b073f411e02",
0
],
[
"0000000006cf75d17706d1f62e6b08e6ba5facfde38a8920b7d808a6b6781ff2",
0
],
[
"0000000003dff257cdae43703fcd0ca91fda0970f5fc04258b4608fb1942a6f6",
0
],
[
"0000000000532d97d18867658e08c789f627535652382147e33bf8626d4131bc",
0
],
[
"000000000266dfb79bb11dedd0ae748505863ab3ab731269cd71a2c2fbd159b3",
0
],
[
"00000000349ff0119d5c0dd8ffad8bf41cd6126a88416148b81fa4dcaebc42e1",
0
],
[
"000000003c61939b4799eeea4335218d30de9b1071605126d719dce0f0d14810",
0
],
[
"000000003d9284570ed648d2b12ad24046ac8b9abcf05c4e9813ea110490cf73",
0
],
[
"0000000001360b66e6dc0ccfbd75356034e721ae55c3d5c71a58be5d281c252b",
0
],
[
"000000000c114f42504916bfb2ee26ed8307b3f7f74226c1cfe1f5302ec23d26",
0
],
[
"0000000007acac3fcf97b4ca81821263b704364adaa2736fce0a0722bfed4f8d",
0
],
[
"00000000059768ef7731d27f9c2be48c6e16d7cb56680625f08ff25ead504280",
0
],
[
"000000000351c8908f1f52518ce4bd251b896ca3fbccb69a2607db6624bafcfc",
0
],
[
"0000000068d7ccae048e212e9e2ecb4d944f583b4490df4fbf654b4915597052",
0
],
[
"000000000e2aaa36417187233ff55325473bd5b7a164b358da60c96d1920fd77",
0
],
[
"000000001eb11ef6dbe0647bc87a8d218f6e59c2b9690f17edcf0dbd39cd0308",
0
],
[
"00000000022e7855e24cc3fff67ce093242434a8ffa45882333a0f08a40aad9c",
0
],
[
"000000000210130ff4e3186258c09a8463c1e196f5c5432b4c7b6954e907bf63",
0
],
[
"0000000000e01372ede322bf88ee5ed8a46dd4fd8df832eca16180263fc8b1ef",
0
],
[
"00000000a0701896e26d5d884834b267512e0af52c92edc4bccf1c5c803d3c4f",
0
],
[
"00000000869fc8d9ac1588f3e5bdfd60253e9824083800b7794010e0e9c6b6fe",
0
],
[
"000000001d43b3165ec30736f28f0761600b092686f861db23ec38f2d92b0ec6",
0
],
[
"000000000ef4092da8c2056e5933de0e1530194c3ad941a9b393fbb26f98862e",
0
],
[
"0000000001e3fed39f70023909f962bea146b03bc8e94e5d19d7da93123f4f64",
0
],
[
"0000000000b4b8c877bbe3cde97649845290bb78999ecff4621b9bf2ab16aa2e",
0
],
[
"00000000006095ba3b4742883a0ec427a3fd685ffb65b987ea77ebfedea7da82",
0
],
[
"000000000168f0a76a6068a34fc042553aff4aa63b906028f28c2a4c327328e1",
0
],
[
"0000000000af10f3079b4989ac4ff0baaecab38220510cdae9672d6922e93919",
0
],
[
"0000000000312791ada0f6a4c5eaf2a1cd57cd06f5970a8ab49923817b862c35",
0
],
[
"000000000055f3d4f45c4d199d9c230cb2cfeb68c8e934cfd061bd616358655a",
0
],
[
"000000000036b6129bb5a786bfdd75cb4b932f7dcae9da469d3ba35096f1e821",
0
],
[
"00000000002fbccf271c13e486673251ecd7951ecc12ee73c4390e0ff09e9b59",
0
],
[
"0000000000314e297a81bf002fc40eb391d8883ea45ee4e782385aa0fdba6452",
0
],
[
"00000000d3c473819ec3b3c268f7b555df22772e407bc8f246a47cfc579ec61f",
0
],
[
"0000000075a438fda6bdb391263d0a2a6e8e68edd9dd8f70fe5734eab9351eb8",
0
],
[
"0000000017ebae0a2bec50008b4a4ea8839798cbd9ff228e76aba087d0ff1736",
0
],
[
"000000000800466ba31c0bbc12b125f16d05ed27788de045e25d6f093817d29c",
0
],
[
"00000000002163c41f2264f202e611aeb9ba6c0a3ee95cd8e5e7e571edc64edf",
0
],
[
"0000000000de9882d417786fce8c755cfaad17f40cda744d4badedfe5e414e31",
0
],
[
"00000000002af352cf41f60a5ebf033bf7e4967c0597cee706ba877b795aefb4",
0
],
[
"0000000000009ca0030f1dd0b09cc628f2d4d278c87b20781a1b136dc395debf",
0
],
[
"00000000ffd27370a76d06a0da0e3805f47e35e2cf584d73d2c5ecaa2e525642",
0
],
[
"00000000720da6910aa75099baa020cb8db37e1dc19cdff66152225b7609c23a",
0
],
[
"000000000a5c2cc704bce5e8527ce91bac7430c659624ecd86e6a1bb9b697962",
0
],
[
"00000000084273545134e9a06483c8fab00c2b0628056bb1967f310c74a971bc",
0
],
[
"0000000002f66f4da52804647b1c3e1f89d17bdb05e9cd4ebbd922007c773f21",
0
],
[
"00000000c46146c9d0a67a354b3f82947e52670a3bded6d8513ab34a68ae18bd",
0
],
[
"000000002f61c429d7dbe7bde75796086efe574998766806138710a2d6001eba",
0
],
[
"0000000001daf3e3e78a57df2c2d2ddd14093d10515925e75c818bec3bbd30c2",
0
],
[
"0000000002e133a7427a9aac6ceca969b27507c14111a45512cdf8f52a436de0",
0
],
[
"0000000000f7c4374d458666740de1d0e8c55229a209ced7c38e38708781487c",
0
],
[
"000000000035bb9ea329ba30b83eeb4ea6f57c2fe703b97f9b879f21e22643e0",
0
],
[
"00000000001220503e0aaee266bca85de09ce97b0091f24972d1ad1c8afe8609",
0
],
[
"000000000010a614c60457f8d2ae2bb826d037f52113252888fadda8ed773c9c",
0
],
[
"00000000585a8b882ecff8aa8434feeac4ef199ca669bd81ed473e37f0bb4528",
0
],
[
"000000009504ffdb5fe82ad88218fb5e75a8bc185247e30e22d23b9fd9b7f282",
0
],
[
"000000000ddec7d73bcd653168d82e34cf5746e006bccda8a9c031c3289b9568",
0
],
[
"000000000cb6620ee4e8cb8b6b4d51251e5961f7ae2e83538ab3a4fef3bcc773",
0
],
[
"000000000239224a0841738513c1eda712b73266ea958aa75f44a3985ebfab82",
0
],
[
"00000000002630c7c3586fcc19079300403c54dc293bcfdf8a9981f85a5c31bc",
0
],
[
"000000000028d8c34f44e51fd71f5401094a983f6566e6d08ce86ec5d1bd639c",
0
],
[
"00000000000dca95f1828adc3c37b4625f60aeb35a6614a4358322b7a6bc2f7d",
0
],
[
"00000000d72ec84fda18959ddc474d1a31a3a13b1d94695136c4810af8c01a0b",
0
],
[
"00000000327c29604996eb7f0a208160969ee4408a1cad277a956334f94e0f35",
0
],
[
"000000000e1bd41d009c1910fcfee7bf1cc1adb04b0b7a632ac36c1092f01bb7",
0
],
[
"000000000201a5afed48b9d095b949229e9882ef8bc96767be3097c87264dfb6",
0
],
[
"00000000003f28e8f3f9c80b1269bb0aa3b57501c12458550ef04fd43aca6a33",
0
],
[
"000000000029e09fc14e38a6a0103c8c67383f41af7d76998055682525f4ca89",
0
],
[
"00000000285ce297602995582ba5d32d583d618a6a92643566e25dd36cf2b7ab",
0
],
[
"00000000657045fa54fac52b8480dc84bd4c418940ba63679f4bd6add6a39962",
0
],
[
"0000000017b7bb58be05a47ff7c4ead27db750813d6bcf3f99cbcc35324cf445",
0
],
[
"00000000003a310e39b6df17f17450496b4f5c1593399bfa1ab8b4d39bac9b25",
0
],
[
"00000000000bfbc5294f003548a9636ebbcea3ba42577821266317676fbc363c",
0
],
[
"000000002329351dd70c24da2eea5ac19f65b6053c4611aa4eb93bcc2783c57e",
0
],
[
"000000004ce02f1005aa6fa4d158c6e4fce95ab053d88ae74881dd080c24e057",
0
],
[
"0000000000fdaaa54cdaade8cfb75245de0747c60c0307ad11be9fe154535565",
0
],
[
"0000000003dc49f7472f960eedb4fb2d1ccc8b0530ca6c75ed2bba9718b6f297",
0
],
[
"00000000014ca604d769d4b99fff03ae3ac84d1e8eb991c5dac7c3cd4d9e68ee",
0
],
[
"0000000000190ab8ecef3a3d5583563851672d81a4d4d952b8cf3bd503c655e5",
0
],
[
"00000000001204d263b607987fab11e1c19c94b7e3e674cc73cc2fb7b05fbf07",
0
],
[
"0000000000141e8d7f7ac359a8ae58e35ce6010c25ddd6f1881f41c0b939332e",
0
],
[
"00000000946344dd06ef5ddd13fb74f20c475daf911ff4e3f1dcdf64c330e274",
0
],
[
"00000000ec77a7892e48b85bcbaf404d16d7fc93747d7e9e3ba6195a9b6f1525",
0
],
[
"0000000018a305c04dea8e93e423ce9569872e0ec5af49d23a0e3872b0ad6297",
0
],
[
"00000000055e32c5f8a86c9a712eeb6440bbf9810ae6da12d0cea2493138a885",
0
],
[
"0000000001913fcbe67badbce4234e86e35a1ea867ecd69814b5f5ab039b7d4b",
0
],
[
"00000000002c71fe4403aee704720ceafd21f9f8c9c97a8bfbd25bb46223aa40",
0
],
[
"0000000000343a42da0c811836d0785c272591facd816f0e7fdcfb1109d8f9a8",
0
],
[
"00000000000309b182608b3eea7fafd0d72e3c79a0a3a9cda03cde3947e332e1",
0
],
[
"00000000000204cc04e421c3958a64d7bc024a474ce792d42ab5b48a5a6f3927",
0
],
[
"000000005eaa010e7255bd37e0b00780575074a74d889e17c4dbc578f917348d",
0
],
[
"00000000a0d425f62d9196c069286dc6635ded9d027de40070d397e45bd63e0e",
0
],
[
"000000003355fd37068ce2d5d2a94ef964eeb9b687f21f4a00850a3e6cc4a71f",
0
],
[
"000000000ca9148dabe9424cd8c96860c90d836ab25970a3e91856764e2e640c",
0
],
[
"0000000000bde23f829dde8edef35436be4b8978da21fd2c3a8100ef5334e3cc",
0
],
[
"000000000028bb26f1427fbfabeae65d55a9e59e18230713e40f0f7c9c2dee12",
0
],
[
"00000000002ac05422d254e597ee6b5e0f8be9b3e2f887486442d720c7766919",
0
],
[
"00000000000e36d0b6f187dd9601b1d1dcd987c3e0f6a081ffd039c7c5e32462",
0
],
[
"0000000000048d7b1f2a2a11fda34a5cfeea067ab03e482931e5a0f463f438ba",
0
],
[
"00000000f780ab88c8a4f4247573a749fbb087a4e3fb6a7d29926de8a9ab3462",
0
],
[
"000000000313bbe6a940e6a8c40ba091aa1ebbaad135bbbff3ed8ae07cf574d2",
0
],
[
"000000001d4ab29721aa2722482562670a0d71dc1eb73231c5dafb64756b04e8",
0
],
[
"0000000006588bcbdec38d19962b96cf0352cbf1b90f3379cc6787d018cdb96d",
0
],
[
"000000000022e79539a21ac24f9daa2cbddf2bb4a3125f88a5efc20d13ea856b",
0
],
[
"0000000000dd284b7fee584cc578a10fbe57e8efe6bf6ebacb23c0ac5d46cdf7",
0
],
[
"00000000001451143787f411c93d5506065c3fb597966f2fd7a4a5c078ee6aa2",
0
],
[
"00000000000ca977394af1e414dc1f9d83efa007f7226e11d3a00f59a1fdfad1",
0
],
[
"0000000000011f8caa80580e7a796bbce5b84e60731bf48e03c6ff5c6bba868e",
0
],
[
"000000000001705beb1376af1af08b437acef6befbe7d3b60c5fbaf6bb7f38c9",
0
],
[
"000000000000c838f1f45422d93ca9b5838368a37423efa8439ee24b2bf247a2",
0
],
[
"00000000000111ad857d31d07fdc8b32d17af2522c18bdaccfef449b29d17362",
0
],
[
"000000000000312a7718fc616b0ecfdbf6066f71ec1a4a8c43f50f02f61cc398",
0
],
[
"0000000000007d232b217a59b804ef67091c5720a5460c2c16bf97b97a24801e",
0
],
[
"000000000000177235c33695aced585685b4c500eb76e72caad02e17503900eb",
0
],
[
"00000000000037f5c5890da7a8e2acd2b0669ad7db648ac43140c637a1c81637",
0
],
[
"0000000000002123904063f223bc35135c426a4f9a0b74c1907e837b810f0321",
0
],
[
"0000000000000961db809da357d91a9341170fafef9f24896d8730bd05cf3f96",
0
],
[
"000000000d2e8fcd05eb874e98cfc3a6e239f6974950e6f50b0487513ecab760",
0
],
[
"00000000017e362508c8db23fae0431eaed708d9db13e48fd5d318066bf6733f",
0
],
[
"000000000011b2bc4fe36f90b7ba5a62f974db250bfdc285b70c71148023c7e3",
0
],
[
"000000000001be28570b378dd5dd2eb3aa495c229913b6757fe8900dfa3cce99",
0
],
[
"0000000000242bd0bb16d0a5324e0b4b5a83697dabb3b4a059084557478e50b9",
0
],
[
"0000000000d8ce69d18da32ed52e503d6b5ad48d970b90545f956b2d2af2edf6",
0
],
[
"0000000000366655bf0cb3dd0cd7801e0adbd26b5b441b77a9e3642597effb00",
0
],
[
"00000000000dc7aa00d4607ca8374d40d1187f1c084b620edb45fc39bc8d2db8",
0
],
[
"000000000003baf60d9c6e70a765cf517f66a124509191188e9547ad09edf68b",
0
],
[
"000000000000e0f476893b8fb4d37e855353075fde73dbc1fe181cc956349f19",
0
],
[
"00000000000032ed16b7de758abadf4a4fb2df7a101ff275c51f29e1555a89a5",
0
],
[
"0000000000000a564d03f0f2fe20f6fb5f038d931f732d817641cd7fff3b0acd",
0
],
[
"000000000000011aa4d0fdcea8d4ca85cd5d548e322e2b6abd17f8444be855c5",
0
],
[
"0000000000000610588540267a0eb544531047d4c8af0f21fca7cd3d96205cfc",
0
],
[
"00000000000002770dab5e14843149df8f76b8dc8458ed3ed2ed8a14a6e2e564",
0
],
[
"00000000000006b70ebc9f75bd32f466602cbd4b86c3c2d2379059542bb8bec6",
0
],
[
"00000000000000ef579af389fa7674f98a2371063fa8b218c5ca0ad94e21b896",
0
],
[
"000000000000021b6108dc988f9153383f9501ab9001109aa87902ddd4c8a4d1",
0
],
[
"000000000000022c02ff22bc0af5201f0e1a14a75879c494731e4fbf999218c8",
0
],
[
"000000000000032651c988edc1ccd08e82b888cbb8135e24a958ac0c0b640d5d",
0
],
[
"000000000000015aefdfa0790bed326c38c358c07aac0674f5b2e771258b8df3",
0
],
[
"00000000000000822e1534c86afef911b67d3fa20cf2b12d93d20d64005f54d7",
0
],
[
"00000000000000338b871276768c923b1c603fd6150bd054c2287e532e61de7f",
0
],
[
"00000000000002d0af52c0cae894bf836b61137ace2bd7500abd13a584c02741",
0
],
[
"000000006f8443a458f38d8731821c07a2fda0ecdbb1cf797f541844d468ce0c",
0
],
[
"0000000000b6fbd8b4e227f5514979a61d8b0b918d2adc154e585ca926386704",
0
],
[
"000000000f4f5e49b10278e27d9dee15b92f9d4a257138a206831e0c00188767",
0
],
[
"0000000002c7e9769bd8ae9906fc5682e937b5c31ab5b5b86e4d70af2c15a95c",
0
],
[
"0000000000f68a1db8cd387e0a2f93f45149fe1ee4a230bb386313bdd42058e8",
0
],
[
"0000000000f0f65c360c8f0f9853ad1142f16675dc1175d61afdbef977776b25",
0
],
[
"000000000004f734e634156511cbef7dfefebdf317e7488aa6c2562572d7ecb7",
0
],
[
"0000000000002a46a7a16787e8317dc567ae26816324c2035be0186ba54d5cb8",
0
],
[
"000000000001a593e6f01875b77e270163538d88452779bb557df7c2607c28e0",
0
],
[
"0000000000004f24cfafa10bd50a452535f64be577a6161e51c7c71542f654c4",
0
],
[
"00000000597cce73e84b63f08cfcb9b01f5e7621752d8c8e08fabbd6ab5c0dd5",
0
],
[
"000000007cad379df01247771fff471bc99faea1b86218602f45ab13efc5e9f6",
0
],
[
"000000000d6085aab25892be49c49d6c0a3949befdc3ddce2faa46b104e1e804",
0
],
[
"0000000002be5996786b42d6a229093896aea9966b1854ea261e01e84da1f420",
0
],
[
"00000000002684b72056e270b115d80b12b2f68eac7412355287226aecd9b5e0",
0
],
[
"0000000079ea27efb24366c87856a9e371c56fcbd59d09d3164a5c2fc15fcbca",
0
],
[
"000000001694120525dba4548ca54087544da1fbefa51c38f0208d683418825d",
0
],
[
"000000000693e80d372938f3553151ab9d0a9a6922182591c701df739dc9a502",
0
],
[
"0000000002950d9cb23c8511937811910b712f73d448e6fdc2e39e029b86848b",
0
],
[
"000000000091c40056c6a48f33db17764af89c01f62ae653aa5e494146164cee",
0
],
[
"00000000001f373c47e1a39af4e1ebcd8c88411ec49d6bd520c2781564070971",
0
],
[
"00000000000809ca4b2170c57958709b867095b1972d80a2ee55359fbd0940fe",
0
],
[
"0000000000038e7bd66fc3308447b1370dbdd0661c427c512bdbc641ff360fb2",
0
],
[
"000000009a3325df76e2de1fc1970cc2f241fa8a41da9ad745a0d9666d9ff51d",
0
],
[
"000000003176e92ff837bf43a48a995c1a321b166475f586ffb4b962e0254a4a",
0
],
[
"0000000001ae3292e81ca3859b75bccd5bff825cd9f496efd085160c716ed05e",
0
],
[
"00000000033bdac4f0d36bb912fba28bb5caa54d1b611759a10f79ff3c969cf2",
0
],
[
"00000000004c6db7fa0e2c9f08693abfeb128c5827b511a5c46c623a103b416b",
0
],
[
"00000000003d87f48bb95e9431760d0c5f4f93c77d02fce9dd1673e9f5b01029",
0
],
[
"00000000000e214fc3d8b97571eb75d248ca29f8e25a584c33de8488ceee72b0",
0
],
[
"00000000000133269b7159b828700d02de770a8cbd91f3d166e6bbc95d8e0dfc",
0
],
[
"000000000000cc92e2dd933a08f7fd87f84451627982fb66583587858217c059",
0
],
[
"00000000000030708136c20c4c8216314005b3cb5c551ded33b26cf64d2ff47d",
0
],
[
"00000000c472a1341d479ed02f31b699e448c035049a7092670b38f4ec6121f0",
0
],
[
"000000000a358834d6eed41b9b7161a338aba53828111414cdea7552ed15548a",
0
],
[
"000000000e13e77372daea775c8358916e57ed11835899c14e5140ed9be11089",
0
],
[
"00000000008252cd0931f94b2465bd4f93e4bfeec6697962c5b034cf3d12cf7c",
0
],
[
"00000000019812cd6cde3a43831234be71e68118be24a80161349b8b327acb5b",
0
],
[
"00000000005865499f301adfb59f8380743e4c3b3ab220ca4eb97dc6628df626",
0
],
[
"000000000015f77e1e61329560a4378eb401fa5bf0ef90b0a014a4d7857ca7a8",
0
],
[
"00000000e9cbcbb625e8a463ba8e7f14be46ba9538ffe93338784ccad3d992e8",
0
],
[
"000000000fb27169efcc2873cfaac223ebb91cc5e1e5ad7e9a312d42bedf7c42",
0
],
[
"000000000c9c96d62ebfbf3fa4003f1d46d175140ab084dee17e8125fa40f24a",
0
],
[
"000000000311e3a766b1ab2064b68a344a561eb496d595126808ffb166c71cc1",
0
],
[
"00000000677568c82262ac3a4ca3f909bdfb0b35145ad490fa3fbdc719d06b91",
0
],
[
"000000000ee77ba9ab657e51fd9140f5c9b46731d9341e98188f929c97d04746",
0
],
[
"0000000008a67eb9c91a6d74168f3f385270fa942ea00bdd31924d1b6ea11148",
0
],
[
"00000000017f93c9e0026e90d579e18c83b4a8557f0c00e9b85ab164cf4466c5",
0
],
[
"0000000000994efa379235c03711a8e6b29895d928b5fde96cb01c02374c0602",
0
],
[
"00000000b3be9f23c943d71d7c7dbdf6dd672d77a712f6c83e9796a85e4379f2",
0
],
[
"000000000713e1089b0b2bdcba462b740c9396f822f1c73e090713978a7f1314",
0
],
[
"0000000002fc44d358401a7ac9ce4ddcb17f3cbac08e40242e755e60ab2292ed",
0
],
[
"00000000021ef2c04fd30be7049f73b9a8353ac96a467dd5f0b9c1457be1bc5e",
0
],
[
"000000000023b95b440ccbbdcb914172cf675cd15d6111bd7f5a436a4925d36e",
0
],
[
"00000000001983521dbffd1b742a6d4b5dfda3f46579fbbdd83a2ebf9a039bec",
0
],
[
"0000000000044d53dbea312432e68fa90dc2148946f613216dbdeec86f6a67c1",
0
],
[
"00000000000107667692f12d21a55a72ff1dce828f96872e36c35bfbae475a8d",
0
],
[
"000000000000252d1d0c01744ec25af801ef7c57e2581c95295070b6a8a85bd5",
0
],
[
"000000001c1da54e16dc06158677024d9e74bff39bfaec83434ac33673fcc251",
0
],
[
"00000000b4d0c6ae86bfdf7ba4c205fc3e6b3b6d63836b85e30e9d8bac922301",
0
],
[
"000000002b16179cb022bf678bd847dd6fc1908d0df04abf0c7874981eb33ee7",
0
],
[
"000000000e6783554aae41856424d184dc4fa061f40676efd107e6f933a25641",
0
],
[
"00000000005ae4acbab519895b4b523d97a09e381c9e4b044e642f73b8c0f1b0",
0
],
[
"000000000010372b59c9595d947064804b75ab21868dd075a3842ab7d2df6181",
0
],
[
"00000000002f9f587ea304093be049d3142ac0c92f9c68928a4f82d12b929b69",
0
],
[
"000000000005d4cae51b3c76dc3c61bed0c265c4f228c0c4d1d3d147146c34eb",
0
],
[
"000000000001a5b6c0e0a0b485a490cb52ccdf9b22596656039b51545bb07be5",
0
],
[
"000000000000d723d0976338edf55d08edab995dd6283cbb688855f0dca6e8f5",
0
],
[
"00000000bfebfae90208a82c7fa06c0f61674dbf1e4f9162e370656c38d611bb",
0
],
[
"000000000c91cd144b2a92ab5024c87f70cc1d76a4a7f26a82a98c5aaad62850",
0
],
[
"00000000077c8114eb5cfb69c3924c699d0c70334360dd1daa95db0db4816953",
0
],
[
"000000000348a6443e091db8f68e88a10afad7c6e3e5392247902c4b4feade43",
0
],
[
"0000000000d63b70351e05829ad8a56336521b361b0d50eb7ea1f5b46c25b00a",
0
],
[
"00000000004658603163f0ede572120a1bbfce8d313aa282ae54d2ffd9fe9079",
0
],
[
"0000000000048063b410c793db34856f23acfb19a0ce72f5997fa572773378c8",
0
],
[
"00000000000228fb6e587fa593ff8b4764064bba8bfc2f43ba5b1f12af33d04a",
0
],
[
"00000000000082e3ddb75c0ea2a98922b1556ce10346f9bb0cedd97ccb3fdf62",
0
],
[
"00000000000005571b54d4886b44b81c21dfbefa554cd7c23430e5aeff6b5ae2",
0
],
[
"00000000306a603ca1a0d961e08e103a9f13f3615163c3373d1bd2a67cadc2a7",
0
],
[
"00000000195d93ba7ae19832b622de86ebdadf3c78f1751ef2b2e9b0e3a530d8",
0
],
[
"0000000000476d0d00cbc68bb20b4893f0e608b02a1e029b8c6c73e169c49e69",
0
],
[
"000000000051348044bc10fc05960c244c3ccd3b3b6c145ffd9958a1c8bc0215",
0
],
[
"0000000001e4df369203badca9aedc28c240d592b12d284ce0b0463fc7537c09",
0
],
[
"000000000091cc1ccd448b0ec9185618a84dea96f52477cfb9b9ca2b60cebe83",
0
],
[
"000000000024a50299c0ef0c6dec9c64336b6cf5c1a1b0013e22fd4fcee1d7d1",
0
],
[
"00000000000349248c1df06c3783d1270cd97ce7f605b9036fca0fdc2f0fbb96",
0
],
[
"000000000001afe6793e7427a3d780876d26eb7f2ded92563f991bf7302aea69",
0
],
[
"0000000000007148006e139e24d9fccc307661c9a0cbcd1af983487c2f0780c9",
0
],
[
"0000000000002734722a341984738177a3f6f264291424e4984f2128d921bf29",
0
],
[
"000000000109b02caaa95e49a477757a41a42daed40e92f54fa09e63f5538cd2",
0
],
[
"000000009a11c7ff8b8fa7fbff5a04c25906f701ab5bd67195736f9ccc839ab9",
0
],
[
"000000002b1d77f8e0cd60af1c62ef6d381e8905665b15a7fbc546d0c1a45e18",
0
],
[
"0000000002588cb017de9e2f23cea7edc5082f1b3faec890f9252d556efeac40",
0
],
[
"00000000008b07f177adc24a4b1a64d2dbcfbcc903ba861d493e11d6b33af7dc",
0
],
[
"0000000000bab8db5020aa8e052165275e8eb3e7c843533246bf6e4c8374757e",
0
],
[
"0000000000138488fdca8bfc327e6dbd6c72c5f1dc5868d9c0ea886665b9b56b",
0
],
[
"0000000000094021fc954efbf08be667fef1b817e8715d4093a561fc30264aa7",
0
],
[
"000000000000e8183e64072db79adfc6c09b650c4178001be3fade4050b06005",
0
],
[
"0000000000004c93e8661c75974cd191c68dd66999da4f70d039c0ba4a12b970",
0
],
[
"00000000000021c675b3ec404bb996f5e68f9eeceeac6946e5a6822987824d33",
0
],
[
"0000000000000ad85684d30f25d1ec34638f099df2f33b418a07307c68fe3c2d",
0
],
[
"000000000009c6add76ac42a1942c4ce74d25d1b8975d4e3ac8932185e785a44",
0
],
[
"000000001e7d828d354716881683eb6fb5caec5d91afce298e4e3bcee9574924",
0
],
[
"000000000a0e438ab203d8fd3e56100f2f14759f704bff6c699df0bb4e9aad64",
0
],
[
"000000000b7d5c2895df8bc1fdf5d31e0f663564cb5cff3b18642c44a71b6248",
0
],
[
"000000000193209ecd92fce00a75975446423d94a325ed525c15d5ab921da273",
0
],
[
"000000000020835bdc30ac67efdbc785d15186914bc14e86387f97450df46418",
0
],
[
"00000000000c9078321f0030214c75e170b01ec664d39bab1b1e48460a54eb63",
0
],
[
"00000000000ac68b63d486ade190dc9108eb3730d25e7537649fe21c30e0121f",
0
],
[
"000000000002a94dfc5f4b677b251a7a7647dbb99c0803df8658222227fe3e3f",
0
],
[
"000000000000b076bbef0e50593b1595ffb3d571e7ad95dbdf06dca8824ef7f3",
0
],
[
"000000000000167075c8bcd24233d25cd268271c0e8fcb6f301ee1b6f6ff0341",
0
],
[
"00000000013107aa587bcf12ac445330ff0325d73c5253f7e6a49ed8c50257bb",
0
],
[
"00000000090ff53d49c9ffd51511af8d5cba2038a8e25e3b17186b1bc941f43d",
0
],
[
"000000000d9e704d5607f77f8983cc56069571a3761d5bd5da55f05ec5d8e844",
0
],
[
"0000000002b2b4c0950fb6390f0ae860840e84eb0a82e5e8a9bc37c14bbf43b0",
0
],
[
"0000000000be10137a2434dce1d97850b768ce878c1c80ec905f6e9f21e65fa7",
0
],
[
"00000000005cd966f80183d4c048e63a5c14f649298dfd261d989d9e3c026bf4",
0
],
[
"00000000000e8f30e55006a4082380c4b1a372b7ad919d3a9b0a52fe5ee881d3",
0
],
[
"0000000000018c70a4c27bdba237ad19ebae5d3ca23f1394ccc746d73669a1c4",
0
],
[
"0000000000022acc8432c883953227786f7a6560aeaf0176d232c8affa5b25b4",
0
],
[
"0000000000001854e95b28b4efcb2cfeb08c76d8cf1fb03f2055b3fb758f3a1c",
0
],
[
"000000000000187080c2c39f5a3ea8be72ac4d3ec0d16b21cd34f1541bef23be",
0
],
[
"0000000000001593766a3c63b524f658ec7690df467cc7bbcebbdb56385500d4",
0
],
[
"00000000000012d6966dc51a41f2c617192169ec8418405e164ba83b9f7ecdfe",
0
],
[
"0000000000001d0c7d0a2605e127b00448b71e756ad96625116ab8ca18f74900",
0
],
[
"000000000009cb439ea49282d257595ad1f7602856c16cc26fff423f7783c792",
0
],
[
"0000000000889282b98336c994d7420a639221e0484b511227fd616d78dbd028",
0
],
[
"000000000071a4a2ad6767864bd21239c74c9912a40ca9fd3b209e21b66460d9",
0
],
[
"0000000000f3ed2c3c9a7c3a7291e859cecba8cf9243d23a4892e6be8ea9b70f",
0
],
[
"00000000006a4258ffdff8b7f6f4f685ce18c6eb1d7a1cf501ca9e02fcb7620a",
0
],
[
"00000000004af78f1a109d1267a9c24d69c6a4b30fea49f0efa6c8834cf394f9",
0
],
[
"0000000000193bf3efbb145747198470a81b2cd33c991057676742d5c22a64b2",
0
],
[
"000000000006b436798c7e4a8c3bdbf054a66707feee5a18ce9ca57eb95bb48a",
0
],
[
"0000000000001db50c7caa3a02ea4f173343f958f334a8bf3f8638add9e69b34",
0
],
[
"0000000000003c621629cc0bcec5968d61d2e42c6673de4d46555118ad5001d8",
0
],
[
"0000000000001262bef2918265f6dd4534013a4650444054fb4f5e490c5ed57b",
0
],
[
"0000000000000120ceee972d70cc84430006645997c7337976c673bd75cbef2b",
0
],
[
"00000000ba16134dc0c418a116b97ad5deccd6bf6e3daa028a8a6a80d7823faf",
0
],
[
"00000000a1a00d6d6fe0660e63402a5a7c7248589211594d37fd800456ce84b6",
0
],
[
"00000000394766cec78f962c29aaa715b66e3ad34e1f2323dba45e087cb3b395",
0
],
[
"0000000008b15a3020676f5e084210ecc05f646885eca1cf6a10e9ae9e3995cc",
0
],
[
"0000000002cf7eb98abe784f6e516670a88b9028a6faabfd099a364c2dc5c42b",
0
],
[
"000000000054015fec337a9ee43eea501d2292f031f5bc1f09758d20f5cd3135",
0
],
[
"0000000000068d24d31a9f1192d848155a2f90939627bc456c9a337135a923fa",
0
],
[
"000000000006262bd09358258edcc455f9ba46b7f9d6e69d0f6b9da89488a4a5",
0
],
[
"000000000002327bf77ae67961463ea98a78dab06c24ac7d58b1727c5f856626",
0
],
[
"0000000000006672235c1606fbacd7861b16b267d203b4d687708eeb1fc25e6d",
0
],
[
"000000000000ac0c9a39a47313a8715f125c46d6ea8be8741b99b1db4a8aae47",
0
],
[
"0000000000007e93f6578e7856aae0ecf6341e1312664d9e1d812ff254c37ae6",
0
],
[
"0000000000002a980acdb1443926875e7d4a57859b2b45ce3fa92c7716319f62",
0
],
[
"0000000000683bfd82c63514bc58a80daf699a6bcd040bb2a499540baf52463d",
0
],
[
"00000000373e6262928d7a6cac965b294aef35f90b72c85100ef91501775e06a",
0
],
[
"0000000000f7bc44061b65c62d4d7747138df127dd2a30f583c3ebb66a25c7a4",
0
],
[
"000000000212a71c38d0e13ab7c5646c949d4b7ca23afedbe351a43b7607043b",
0
],
[
"0000000000a836e88f76ee5dcca1e884572f32f4460a3b024280738d76e98ced",
0
],
[
"0000000000413f6c1b1c9841961636bb3290f2410ba0731f3522c4ff3faa2e0e",
0
],
[
"0000000000082336107412226110ab2a53016d4faad4deec048828507a300248",
0
],
[
"000000000000a91e7a3f35a23f01621dd051e314da617714991467131808d3bf",
0
],
[
"000000000000cd6576950f6f238227c3ba7f62405ed1bf3af4878c6dc1b04635",
0
],
[
"0000000000674099e9741e44da03e9531402a2607a19a65660b57470340828db",
0
],
[
"0000000030c4744001ae85f9e6b46ed0664449927b86b8fbf25b22b851d23671",
0
],
[
"00000000002f5095ad1a12eb9eedf88ce1e7268368461b6b4e10051148f436cb",
0
],
[
"000000000057d3e2a77eadb8b9613cb839ab02a96094dd5d0a6d1f09026c3936",
0
],
[
"00000000004e0a28be887d6ed037cd9102cbbda7d6c9e584ba51f2c2dce96232",
0
],
[
"0000000000211346d8099f7ecea72481c4cd45591f5e0d7e347725ac2162f142",
0
],
[
"0000000000199ae9fc06c5acee766db6033b86f76c266cadefe1461c611c2198",
0
],
[
"00000000004c9e5748558d4f5a75bc824171e3b958152dfd6844330f1e907f8c",
0
],
[
"0000000000137addf1521361dad1ee007eb9e6dd4eb8441492ebfaa3c240d556",
0
],
[
"000000000054d4c77bb7964e5327c35760d87b890ea336aec5ecdeb783350738",
0
],
[
"00000000006b7b06d04818e97a4df66164b471912f88d9cd02de4af6c8bbe74f",
0
],
[
"0000000000380fa9858e3e90335c061a3776a26bee1e8b6851de33ec63670782",
0
],
[
"00000000000842598b03fb79ce7386e9f9181a02dcf1effc8f70d3ff7368ccd5",
0
],
[
"000000000003d3475edecd733fc7b82432882d9c9f1350a98ef8921b87db4dec",
0
],
[
"00000000000000e330a8d57a38dbcc0b0a5dc7a4210f231b8082b9be5f9e4bce",
0
],
[
"000000000000218ff87fd50cfba2fd04203a78d2600cb2c4dcb039d803426e19",
0
],
[
"00000000007c96e6e3ed3146260348ac79ea7dc2ec2ae6bf8dc203400a37721d",
0
],
[
"000000005abaa10bf7260470c28ba32f1755b4cfd3734aad580681e39a9605a5",
0
],
[
"00000000005e77c226e6fffccafa56055e68f0ea0a30101e6a243ab9b3e07db0",
0
],
[
"0000000000e989fe27f85b89c1e852d7bc94b09033cc6c8b32fbbbd9383a9ae1",
0
],
[
"000000000091a1e962438583146293ef34156962445ffc5e81e4d0fe327d37ac",
0
],
[
"0000000000477978a6903217e2817d10e99bdfedb4f8bc396b96fd5b0b93b522",
0
],
[
"00000000000bfd9e5f13a9c03c48e8b58a937cf1ae2849160f1ca11f8fcced3c",
0
],
[
"00000000000158dd3c31b6379887b4353ef2898c03b7ce55458fcd57cb6f0639",
0
],
[
"00000000000029d7009eb56b9d38366005576b82a9b59fc845522a34ad36a38a",
0
],
[
"0000000000e6e207a82b8ad7136352204bb8e9ccfcd25885a715d3c65cbee997",
0
],
[
"0000000000fadc4429f50fc534ccac4db5e51a313df25034d6c5c25f7e83448c",
0
],
[
"000000000019c58defcfdab6c6ab9497685e61118effda4c2613bf44be19fcbd",
0
],
[
"000000000006cf444d846093c5045d42ddc0986ca805f261476d0fd2eb474c39",
0
],
[
"0000000000d0856a3d6a1e5b1ac7e388cc029bd8410b3b1489598974fe470568",
0
],
[
"00000000003d9aae63ed532b78082ca5386211e22410fd24ebd5318d1a4cd1da",
0
],
[
"00000000000345003879f86021a6d5e3fe93813246818c145947b7e225691177",
0
],
[
"00000000000175393730cde3e49de7af2b81ae736eee005a9f9c4a1e878c52ec",
0
],
[
"00000000000087a8c621c879aec2a897258632d6aa631b9a38ba4d564e08682a",
0
],
[
"0000000000002ea641b2975935bd9caf337b51ac9f9bb90a54f6ea6ee5d3112b",
0
],
[
"0000000000000c544f9b6a8cbab6d25caf949875622bf75139234850b10affe1",
0
],
[
"0000000000000f66fc4e37232a29f3389c493863a980d58a1d570eddd5268999",
0
],
[
"00000000001213fe2bbb8aacb1fc14983586e09db964151cb507956a81b35f25",
0
],
[
"0000000000ba82c2160602ddc1913bc4c133ad0af8848e014367c84110d00e05",
0
],
[
"0000000000b7a98b364b1cf9521275a915c7a1b3a0f0c052c7d8efb620ec0870",
0
],
[
"000000000047dc62db23540ab4aee43e54812aedb623a2a158aa3244fc784722",
0
],
[
"00000000005291002da10e53c3855882251a6e5a425b5e639ef9be3bd05767ca",
0
],
[
"00000000005ffbcbc0d9b380584bdc78050a6f0c3582b4c9c5103a150cbc71f5",
0
],
[
"00000000000a7a69cc06b0a68b27a8fa5d29727ec3b6db8d32d61cf7489b5ff3",
0
],
[
"000000000007212eb8c49758d98cefaa6098da2b877a6055be341f5f7c0ad301",
0
],
[
"000000000068d1099d8cf3f43f6d164f2925b1d52ede75640cc65ca020e1de1c",
0
],
[
"0000000008d5ddef4468a4414bd08184c2eba0ec536b85a743b1091828a6a884",
0
],
[
"000000000acae40db93b589783b0cde70b98552955cb3c12f08de1b417d9008d",
0
],
[
"000000000066a51eaa3a54036f338719da3d5779180c0bc3787b533410de90e5",
0
],
[
"00000000008b521677a6e897950aac69640e52efb01b7af10bba3820ecd09a89",
0
],
[
"00000000001823f0e399311cab0fcf57403e094feebf99b22030bafd2004da87",
0
],
[
"00000000000bf821c2abf5bcd00ca96439ddf5b0b593be5601145fda5338efdc",
0
],
[
"000000000003f4fd19b2af0141289177014ecc6dce6ea8fb50bab93d4a291095",
0
],
[
"00000000000011842d892a02e55ca594caddc9f3cea1979ddffefc070eda8498",
0
],
[
"000000000000208aa0259d20f51c0e7b8895e18a93aea79af9b3832e710ef134",
0
],
[
"00000000000007218f849e72dee1f7fb6fcf36f3b6745c6468187ed2ed13287f",
0
],
[
"00000000000f79f656cae641c2b74554c6ecd673c0c7550671c4c2af940661b3",
0
],
[
"0000000000199b4d178c05fd1c3154c9a4632eadc7bfc734c4522176c977ce8a",
0
],
[
"00000000085d0682d481635cb2e6de2e4d9884589455a86194f0b222f9acb3c6",
0
],
[
"00000000015972a5a6786a14b009bf582c4bbf7b9854591dd8d26f82b43ddaef",
0
],
[
"000000000064bf72b7bdbfcbe96dbbd0efcaf7aa94c0f92cb4e6662819468fe4",
0
],
[
"00000000003df36b7962bb4ad62266c462382eddc93f4bfeac464b95f7a89ee9",
0
],
[
"000000000006516d3a9f424eb61db5dfb85aeee29708b78c65d24827bd926263",
0
],
[
"000000000001c1709fe1b294712638db356e89155650f6fbecde79ec47a92af7",
0
],
[
"000000000000dfc23251344b593c16c28cd195abcb337519d7bc82175721a033",
0
],
[
"0000000000000aae2dd2bf0b8581d137fcfa3d9c4cadbe3ef3834d7cae4268c0",
0
],
[
"000000000000092a5baff3d9a5ae87689b2afe668e71bac3b342c7d383f0060f",
0
],
[
"00000000000fa906eeff7d2e126698d88b8cda01d32ea2c039c26984daaa17a3",
0
],
[
"00000000002d4315e5bdc2bcfdb245b914130764a50943a2b2e02ea3acf5c47b",
0
],
[
"0000000000fc2bc9bb83e04cbe922d64719295bfef6320027725402306bcf1a0",
0
],
[
"000000000142690e7c334b97612746d6db208e6153bdfa8479d86d1b575feacd",
0
],
[
"0000000000629a7820e8cdbbed18dcfe16c992152badc745ca73b9b34e53fb0d",
0
],
[
"000000000023c2e9dbf3fe03248e40f4ec3fb2dc81ac573d5a6a4f490c701877",
0
],
[
"000000000013658a43b6d1c4be95fa36e32d3edf80716de3a8f7e98858016adb",
0
],
[
"000000000007c847295d8c4b6da9d8a64b57c3a2307e64387bf8882b9d35d6de",
0
],
[
"0000000000032bf90b823332af80bd2ea18f411f081c7dca8f2fe79d9215526b",
0
],
[
"000000000000001bc0655da6f24c6952e811006897a0c6dd8b6bd94f178636c8",
0
],
[
"0000000000001e1d09b15393190cf686e25488db7fcbc2f1ebacc8165fe6e3a0",
0
],
[
"00000000000cc79ae066badb4157def4067057cefd705bf87f1d832845a7ab36",
0
],
[
"000000000014408398244b94b4eff6b54875802ede6df2d1d21915333a195719",
0
],
[
"0000000000114135a1bc757110c05162fa649b694db9569be117e34832c87257",
0
],
[
"00000000009b15fb2bcee1af904989ba0761e4cddc6b3ee214c0bb07dac6211f",
0
],
[
"000000000012be506dde2c54adf355bdb41a457b0abec436202a3be73f0b052c",
0
],
[
"00000000000963760ceb5fc65570650d494805e05c9d753f3ea6d44247ad3d08",
0
],
[
"00000000000bfec54977673f68b6fe5f088398e697d778fa7987f8bab6a70825",
0
],
[
"000000000000e7f428bb413c17032c0031af0d26133ba93f744a5a0c16cf7e1a",
0
],
[
"00000000000036bc80378323c6eaff8ab350b6d89955f602960cb7c93d2feb4c",
0
],
[
"00000000000f0d5edcaeba823db17f366be49a80d91d15b77747c2e017b8c20a",
0
],
[
"00000000001ff8fd57798082ab5a7452ada211e1c3be38745155505601498829",
0
],
[
"000000000020f960b535eac585e5810ad64f158c1142f0eecd925c8058172933",
0
],
[
"0000000000067bd89409368d221507a160e5c45972eeb01efe210054fe8e7d85",
0
],
[
"00000000003521f2d5ea3232d4835ca6c6bae083ba90458f67d4cd765ce93b09",
0
],
[
"000000000005ab3ff3a0c484eff7b571fb78ce27d93f77a480074232e5ce0c1d",
0
],
[
"00000000001048c9eca7cc1cbb86946c04498052071f7e7c775bba565ada337c",
0
],
[
"00000000000154caacde41be616f924d7d478812148242fba85605eefec9ac61",
0
],
[
"000000000000c34f75bd6f338c0206a31a8d5021cc2ded51e88a6ef4fe686d10",
0
],
[
"0000000000001e0581d86c49a6ca14ba88639ef908abb09210b57989e06b1a1f",
0
],
[
"0000000000d0e6dc0bf830b50bde3e400e16ec4f772f92a55390e62d4aa73af3",
0
],
[
"00000000069c2501a2f32cc69af72a602ff674438ae04dd05516f72a71b9ab26",
0
],
[
"0000000000c926b38954550c9b8d363ff058c2eb135eebdb3e640cfa67df803d",
0
],
[
"000000000011e9ad9c18e9e2095c3662af5be1e918dff653758583aa45dc8197",
0
],
[
"0000000000f311624ff4dcdf07400d0d2fec8b16b14c1c16babc377a2d85ad21",
0
],
[
"00000000002e455cabfdc2a8955e8ddfe717b12efe5b80937b0c0ad6ac977fc5",
0
],
[
"00000000000fed8889a22339b340f599ac7908e790bfc3cfca9b78078a52d228",
0
],
[
"0000000000012ca4492956b3f859b00e5db14b54d422cd95c68c7150743db365",
0
],
[
"0000000000004c58e8f7bac59eb4a036764a4d8e0da51c0290858ab14fb72481",
0
],
[
"0000000000002f60bc99563ff5b4b800c176fe8bde95e8f968fd6b53d74c9cef",
0
],
[
"0000000000000bffd10a3fb0b5b86d8b2561f39d07f8a4c41dfa08e3e49b7db5",
0
],
[
"00000000000006a296be9cd8fd4e3145c146863adbe08b71831abb8a869d032c",
0
],
[
"0000000000000c557f496e82891039ff22e277bd604be6e2e8b95e519bee91f9",
0
],
[
"0000000000399b30d2111c4bf3051c1f7f2f35bba7ff290d92393341ae47df55",
0
],
[
"000000001f88733439e4e8d3c474504aed62037faa16f3845b4c671f69732e26",
0
],
[
"0000000018aa2f93d2ab76a7e2f1bf5b565b4a1b0ececb6ee46490984f6c0d4b",
0
],
[
"0000000005e22674fcf65ce7be896a0557205ab26d1f76d73a717f5f14a6d6ad",
0
],
[
"0000000000223d866b324c097973210f8fc715c9535908359d61d8e1ab2f0100",
0
],
[
"00000000002b321fd6452ab43849bd7a781953ec4485554e0fdc579f2a52c90a",
0
],
[
"0000000000173132748c51b5754b0341232325bd118455bf3c8d25164d3eb92a",
0
],
[
"00000000000143158cdea5fbb9453bbe1a7a900e6feba1e2193e4f5c106d9fba",
0
],
[
"0000000000014677751456af5630025b3d9921a4eafb4d36a06498f0c6a84c56",
0
],
[
"000000000000243976cf2d30ecd3cb1fd0b805fba4da92d2758f78e1c6f8ae92",
0
],
[
"0000000000001323db1ab3f247bcb1e92592004b43e4bed0966ed09f675cf269",
0
],
[
"000000000000017a410c22c4b6caf710f5ccf005d644caf276ea8626a538798d",
0
],
[
"0000000000170b2b1374e3a0dfdce2fbc5e302e1e0e9fb419dc057c9959902d1",
0
],
[
"000000000015b4fad4d929630487680cda2d3aada138c58cc08241ef6dd4ab09",
0
],
[
"00000000000abebab869f1620843d413a3d9e06dc7d9f5201a414d547ace1f99",
0
],
[
"00000000000b0bdaf05c2fe8b12ebd2372f49d8eabcfbccdadd68b5e5b7c9565",
0
],
[
"00000000000ca1af42ee1be2c8895d94f39dab5fcdbe0b4b4065f4be534e7294",
0
],
[
"000000000069d0cc8c0452bf86cff87db05232f801a162acab2d080d6e4e9ea9",
0
],
[
"000000000019c7f7685f5bdc3afbb5e978cb3f4f70fea7b2b410139741303b53",
0
],
[
"00000000000d3874ce21db78f4d1883ad9ae8b26c1d7c13f3d723ff85629d595",
0
],
[
"0000000000033f87c25275ff72b58630d8da90221f2c84bcbd77c8e615709f8b",
0
],
[
"000000000000dc72adaaae6483eb6737de7d21b3a24b2426330e80b078ceaed1",
0
],
[
"00000000000002fb1337228db02ac464565271f22f045c1b6ee5e449f057a829",
0
],
[
"00000000000001902376ff640d3088899af0819dbd15f602156a13ac2fc8e94e",
0
],
[
"000000000000007ee49761a1c8284a3b8acefa39e37e455be4773d648e2db794",
0
],
[
"00000000000005b4d495a77f57018dbc72bf47993d494349329a3c653f04ab93",
0
],
[
"000000000000009dcb3ae6d68828e2f5ccfd58780abb260354e74484106f81ce",
0
],
[
"00000000a3ceb118021fb42d39be52db951c6f852bb9a241046e972706f7329a",
0
],
[
"00000000574e8e1c27fa54c77b4e7cd1b79de070f0d3ad5b383206ab9777d983",
0
],
[
"0000000039d562f640c1743421d53e7e04c3e8ba222c339fff6f3d25b1d4a7fe",
0
],
[
"000000000001cb1559d55c697871e18d5c26800f77fb11587241bfbec3b15e26",
0
],
[
"000000000006e01a93090319756c7ca826ef655feb0cc2ef9abcc59d67de5e5b",
0
],
[
"000000000000a81aaf5a4c013032638a077af6aad8bc449d74daef8ad3a74419",
0
],
[
"00000000000087d0574963c1582f2161298e2de5e48f74566291ef9afc2be24a",
0
],
[
"0000000000033251e71c347cd663945fb68efe82a8c6666c0b41e93f1c46658d",
0
],
[
"000000000000f592857e6f0e4711b5b93fdf95f2b21a5963bde15be750a07908",
0
],
[
"0000000000004353c8426e18b942a5012934ddac8322b86d6ab98ed7c0ee86ed",
0
],
[
"00000000004f027845b699f42e7d0d30c530e99524c5f97186ce6a250a5fac42",
0
],
[
"000000002fc6407edc060df90785082834867331e6746a43ed34a26fbdc5df64",
0
],
[
"0000000000048733007c91ea3665bd4e1653b10799e3f43abee0fe830ffbb3ad",
0
],
[
"0000000000025a9b1c5afceba0c78c4b0320797acdc1ad50b4e040f148fbff7f",
0
],
[
"00000000007ca6d026d27387edc1c5570de41c61bacbcb1dad2c0f300b49e637",
0
],
[
"00000000000258f683a77ad509da82a4fab24188fdb4b4690e212c50794a9abb",
0
],
[
"0000000000015111bce7b6ac13c930484e14e31e13e43355cb4d63c8f1782440",
0
],
[
"000000000001ca074fdecac7749d95f28f10c83a7e13787fd865bfbe505382bc",
0
],
[
"0000000000001c11a6505dd44ab405fdc07ddfc015f3c1166a5d9352ab58b52c",
0
],
[
"0000000000000c83f7f8e1cab4efa08d6c68c4555fb6ab542e01b87edd8f56ac",
0
],
[
"00000000000009561d0ceba15388573d2a994aff24512ec3ed7d7881aa0997dd",
0
],
[
"00000000007dc7cfbbb94db1fbc076a70a1252fd595686b4d75b2ea77ed6ee9e",
0
],
[
"00000000000251feb68a8c90852f73aeb29ebda191038737b7edd37c9475f4ac",
0
],
[
"0000000000013f9a97045ea9047654e514951288911b2c3986787c27bab49106",
0
],
[
"0000000006e8c37735c61f22bec69f4cb7eba03172349e7012b7704652f3e83a",
0
],
[
"0000000001f341add5657043d8e50e53ba079fe24966a2668f904be5579c84b9",
0
],
[
"000000000029a6275cd477d77939424bd183c2f1308a9912f45aa7cc9ed13b56",
0
],
[
"00000000000a0336239e5e1faedf5bd2eedf38c9a5ba34a832356aea70aeb102",
0
],
[
"000000000003c1a2b25093a64eb624055f6a3a26e18b8e7ea2d9382ec7a3609a",
0
],
[
"000000000001bd89bf7e8740ce22adfa6e8793bd1716a647e558ed1742ee8329",
0
],
[
"0000000000001320421f1bb2c94000e11a621f581fc277c0e2911c3b89f680bd",
0
],
[
"000000000054ce90a949f5ae2d43c4ace599668c6ccbc50620f6d5705922ea7c",
0
],
[
"00000000200d16fea4857e6b73169cc593421a57971acdbcaf87a31d7d8d72c8",
0
],
[
"0000000000e75602181c88f713b91c49de291ed878be305d25b75c0ec5fbe942",
0
],
[
"000000000081f8169c3c3665f20351dc0fe499612ae232ec0b55858a8e5dc6e9",
0
],
[
"0000000000d7ad232e7593fb435d125343b8113bbdb3705ab58ac0e18c26cc79",
0
],
[
"0000000000076df615d887e33193ca2dc0f2fc0e70744512c95da6242e9b1a81",
0
],
[
"0000000000084a62093d1929843e74456686429b698a7ea9b1901c1565779f58",
0
],
[
"00000000000251d1da01e9de9fcaf3ca3a64bff78a5faf51a8e697dfab6b5e4b",
0
],
[
"000000000000609a8798996b1f1fe0b66060a628eadc380d0d369a2318c2d0ec",
0
],
[
"00000000000014770aeab044a022e86d888a6ede75b6474022c71aead3a1db74",
0
],
[
"00000000000004101d04ebc90ade5d4b911aa13c038ecf25e9887d877203ddb8",
0
],
[
"000000007c700410b61eb7ff1aaccbfc3a79e4e4484ad7a2b0eda4d91dc4b613",
0
],
[
"00000000055ff438a031413ee042fd3c0a2b69be98690542806ff123b7988024",
0
],
[
"000000002eca5f9f2c3b656d2550662fdee4c95da133eade51a5cae653bc69fe",
0
],
[
"000000000c679b76ccf0c5b943095fdee8fa466311edbea2c4a05f9430ffef3f",
0
],
[
"00000000007c6f494e32d5d9de58fa008a770fdc0a7b4a141be5b7c2de3ab970",
0
],
[
"0000000000d5dcd5a26c8ad29c1293e70401e2f90d8288469df3816b8cc6d4aa",
0
],
[
"00000000000d754d94f36cacbfb620710672afb1558499cabe17ca62c54a7d3a",
0
],
[
"000000000004096bb78fba714b130f7f1f929e2803c75a7a85619f7a2b86567f",
0
],
[
"0000000000020e686c38d44c35896df35f9f1b7723a82a826a5e2393c25ef68c",
0
],
[
"000000000000504f9af6885c0cb6484109ea205a956c8efae9557a1f5b9233da",
0
],
[
"0000000000000e8746e52e4320ec17e66434a3936a3825f7046fe874e92275fb",
0
],
[
"0000000000000f48d818a9a026270c9f733f629959bea25192596d59874b1ce2",
0
],
[
"00000000eaa9214cb05b241828a1cfb0c4209fb7ea64429815d61f7c1d98939e",
0
],
[
"000000001f7f915a6002cce4edd5cba392307f3a199a520ee8937327a9135162",
0
],
[
"0000000009674ee0c606d687bdcddf8e023462927e2902b3381bc4bb862a7397",
0
],
[
"0000000001f3f3528c083a4b11eb2f04d8bbeca92b57f05d8282909bde78bc77",
0
],
[
"000000000131917ac459aefb91774dbb42caeca497afc0cfd1766e0338cc7f88",
0
],
[
"000000000027634444081e1289354cb50034a506bb306a2ac1d8280683771c5c",
0
],
[
"000000000017a852acff78fbee573329d45bb8b121e9f6fc1e4f687bb3778ada",
0
],
[
"000000000006789e1a00eca982fb2827f680b254c4a0ecb005af4464f3585a02",
0
],
[
"0000000000015d2e9f54b1e9419d6b32ce68ae626cdd7f2a1954f22ca39ae0fa",
0
],
[
"0000000000002f7893bc169165ed9fefb434b6201103f23cc84a747a68ff8797",
0
],
[
"00000000000008471ccf356a18dd48aa12506ef0b6162cb8f98a8d8bb0465902",
0
],
[
"0000000000000596f00b9db53c4111bcde16f3781471c5307af1a996e34ec20a",
0
],
[
"000000000000007b5d2406f64f5f5833c063a6906552e815e603140c00bca951",
0
],
[
"0000000093ca5d935740a1b25f10ce092fd777c2bb521f3156619389ae78931e",
0
],
[
"00000000292f3a48559527341f72400a0f8a783aebcaae5bfa0e390dfaa5286b",
0
],
[
"000000001e852ed7ddf0108d1fce0f4f686f43c8c1b85bcb12c43e564dc7630e",
0
],
[
"000000000c4bea8fb1e7f3a1f3e6c6b3f71388c0ec7eef3de381853767e89f87",
0
],
[
"00000000029ef31a21711b55c4300efa38ace0b706091e373f48285286f2c578",
0
],
[
"0000000000979060786bb008f193d3917e28667bb1b28329f3adadc172e4cce7",
0
],
[
"000000000019030ceb98013b1627517b45b04ee055ef445813bbebaa25fa1ed3",
0
],
[
"00000000000adf202247bb794fc9a3c82cd8767143f1e6ed5f60940ee18b09a8",
0
],
[
"000000000000b19061e2481d8be6183b3d881b0d58601072d2a32729435f6af3",
0
],
[
"0000000000007a6d34f59b29e8d4da53e51e3414acd18527466d064945fe19fc",
0
],
[
"0000000000002e66ca213a2c3e9eb5fa62de29feb83880a0bd29f90fca8ad199",
0
],
[
"0000000000000b4ca10aa100728d0928f37db5296303db1b74ffe29e4a17b6cd",
0
],
[
"0000000000000143309f6b19567955743775f61f8dc6932c0b46cf5fb11c6c72",
0
],
[
"00000000000000b04d5409b3ac60cc18c0b9a3d58b303594635a8f75a9d2abd5",
0
],
[
"000000000000040a2699f62a552703a278608248c2ce823f4cd8845376e9a371",
0
],
[
"00000000000005cfcb850db7e83d4963994f958bae9b1de1483f5aeb3d449925",
0
],
[
"00000000000190f80220e70c1481153671a7c90fd856988c183ab0e3d9313df8",
0
],
[
"000000009374563a06178641d06776f66554c2a094b5319f0801fe35cef72ccf",
0
],
[
"00000000003e4e6e5e8e4a89e7de50eed104d4a49d2992ff101b6740beec7cb5",
0
],
[
"0000000000618cd377d14aaa441cbdb92527894f98da316eca81664f8ab5488d",
0
],
[
"00000000000d977ab2897885fee712f58612fce8c10ffbe9400326fe3429b77b",
0
],
[
"00000000000c3575b487dd0f938c5bc744fa65ca4ca3a9c981b8bda903ec110b",
0
],
[
"0000000000247ac689595ed8d62678bfe53e5af13c0f5455e558f5e6bb375c16",
0
],
[
"0000000000093d175376aa621176511f335a48f824b66d998e8082f85134a48b",
0
],
[
"000000000000c0c0448fe922f2c737946297d35f2c25ad7cc223e11bbe58e1f8",
0
],
[
"00000000000016abe4e7c10ddb658bb089b2ef3b1de3f3329097cf679eedf2b5",
0
],
[
"000000000000242757cea5b68c52b83dd8c2eb9257492074fc69dfa30bd4cbf4",
0
],
[
"00000000000006813f3dd7726a509fbe3101835db155dfd35d44aeae6aedb316",
0
],
[
"000000000000053cc4f39cff1c8cee1aff7e289a85dee84164d2d981afc8f17a",
0
],
[
"00000000000000789724805cf1d37ef689acf52c47a460507f540d5e5ca79bfa",
0
],
[
"00000000000003d71618bb8952887f65540270a5e54d6246b9419e08831b5e4e",
0
],
[
"0000000000000251a513a33eadfad67c015f6e3b291dfd0ae1cc4bb3a43006dc",
0
],
[
"00000000968009e3f8d6e6071e7def68298307717a9af6c2d44986deaae297d5",
0
],
[
"0000000062bcacb734df83bbfa3e1b9a8dfa570ecffb6c29eaaf8e9498cccd30",
0
],
[
"000000001d4618c0931bd3c25ee592c35341f30ff3b549a671f637b3c26ef414",
0
],
[
"000000000418b329df96a004f1b652ad06a7ca295f9f2e711c412d00493f5a86",
0
],
[
"000000000302bfb88e9027237d023c4b969e106c9a7a23a84103776de7880836",
0
],
[
"000000000069b9f7d9134fd93c8b7e3af8b26bbcbb5553af02fb6ed644d7fca5",
0
],
[
"00000000000411ec444240ee91e2777ad18b80dee854e3e838e32209e84774fa",
0
],
[
"0000000000007c73f322eba4dee5463305227c7e1a8139f1b7b296444f265052",
0
],
[
"00000000000129adf0f9c0242aedbb9d87935d67ee4ddea758c00344d4b6a29e",
0
],
[
"000000000000343594e671158b6e1b4b6499f6ad66e2aeabf1f6d295d3dba850",
0
],
[
"000000000000320f0d5c22ba22b588b97a0e02273034bcd53669b1c8c4eeda1b",
0
],
[
"0000000000001e8cdb2d98471a5c60bdbddbe644b9ad08e17a97b3a7dce1e332",
0
],
[
"0000000000000026c9994ccdd027e86f51a2e36812f754bd855a7f9b1ca56511",
0
],
[
"00000000000002746a820a2c08b35b8d0493c4b5d468fcc971b9c88003e84849",
0
],
[
"000000000002949f844e92645df73ce9c093e5aac0d962a0fa13eb076eec835c",
0
],
[
"00000000000156fbda67468ae2863993b98a41227c420246e4bc4e68c84df0e8",
0
],
[
"000000000003b43c6c807122c8dd10e2a0cffbf72946f41c97c1ab82d416f74d",
0
],
[
"000000000004e0635c2438b1b649007e5d424b3de846299a8db53049ebf4da0c",
0
],
[
"00000000000258e4b79e3cca2ab7d12b35ba77fc491572f2e794f0a10f5236d9",
0
],
[
"0000000000f5816875d9fece105e499b0467b8fb23ea973c48d828a235acdebd",
0
],
[
"000000000001353bbaec810af7a4c74b4964ae072361c0889ed6d59cf16db6fd",
0
],
[
"00000000000b354d8c389473670ca6bed7e3dffa069f270d35ec9dad810af141",
0
],
[
"000000000002fa1f39e7cd8730fa08085ba2b532146ad1ef3b400a13e835ca36",
0
],
[
"000000000000d2c7943eee59652a9783bff27e474a92ec206c5c6e3cdd58d0d7",
0
],
[
"00000000000036034181b4d9a84a97490b49fbee4262b9cfb25a7bfc9c0eec9f",
0
],
[
"00000000000007deb59381cce692f152fc902732d96a7e7d463bc83915b37c0a",
0
],
[
"00000000ea7d32833462c0f72ade0cae4766e6065caa4e510331929c56d16632",
0
],
[
"000000000068fce0ddd370d4c8f9129a7bc7843e75fc57666202d3b90239e269",
0
],
[
"0000000026b4a2212c9c9493f8bd9d5331cab6d8eda8ee017410e58a783ca069",
0
],
[
"0000000009535ea2dc7e83c31cd17f1db1bb78b0a678fc0610844273de143bf5",
0
],
[
"00000000008607cbd5baca91d5b8b82ee965aace335744a3e21578af22bee8ba",
0
],
[
"000000000030dcedae0f5e98c4e176f9569ce76c4d4135bb028fc3144ef381d9",
0
],
[
"0000000000297c3f0e3fa85731222ba934a955bf513247a72a33c74c498cadbe",
0
],
[
"0000000000020a0d4a1e8120cbdb486e758b58919c9df12e0edc8ca1f2795e94",
0
],
[
"000000000000078773afc9023182bfb6534a60158672e6bc6e8aa5052854da80",
0
],
[
"00000000000102ecdd67800807d9e137357805b9bbf8a439ed86bde5b19fbeb7",
0
],
[
"0000000000005c3d2e3c7ee737c67ab465533acb233e0df902c1525fc11c3a55",
0
],
[
"0000000000001a77771650cdbbceff87caa4461391ba6a4ddc9815b5b0ab47b0",
0
],
[
"000000000000071ec390bbd28fa2a84e52ab5b32901d0723d22646b04ae01dc3",
0
],
[
"00000000000005c3ec3194f710c6f26ee736d59cc935ddfa574440f39846433a",
0
],
[
"00000000000001cc3df6924591939269d61ead563b9eb68402a2ca01d7ff99e2",
0
],
[
"000000008c778b3554ceaf3a13a856acbfe46b5750fb86fd92ba30651c2852f4",
0
],
[
"00000000107ca31f75f8ea76073dda3c33330b2706c1ec20c3ec240e853b65c5",
0
],
[
"0000000006ba99b08e7f2869ce113e2ad7464891de7b4cfa96f330d706a2da46",
0
],
[
"000000000f31036bd51b2818f6dfb90ada9be5019abf55fb15694b181e269865",
0
],
[
"00000000004fcc101bc47eb7a379b9f608d5c00ac04d2d0ea165ae2937070796",
0
],
[
"000000000044d5ca3eda838edef0df7e69e1934047f8482822ce58ff7a18466d",
0
],
[
"000000000029bdfb157be6d400c4dd3370d98afdd8cd3db6f1ada8c19bbf4650",
0
],
[
"000000000005e9699ad8035caa4f73af781ac2040c87b8aa77459b3607209aa8",
0
],
[
"000000000001c0ba033f7d85beeaa167c9bde0e192240653a7ff6d9b81679842",
0
],
[
"0000000000000e0176111f29e800b49c7b8c7226dbbf4df715f1a4f06bcaaa49",
0
],
[
"00000000ac3bb2cf42192e9053f5384355228a2b3d70b4ece4d45773a5d5ddd2",
0
],
[
"000000000f29f7b60842b1044b2db7998e9bcbd92f8ec6fe8d159c6d582f1f1a",
0
],
[
"00000000352f86bc5f9760961a25de009940508bb2cd0b37f378fbc87dc97eef",
0
],
[
"000000000e9b3086008679ed57f59857f64c3954368ba1088117dbf88d5839cd",
0
],
[
"000000000015324bd8fed0e61b62bd1d6c663b862cb98ea03c494a92e4a8d0af",
0
],
[
"000000000020475a181b7a084b341860a72fc0c1fdfcc13a85adeb0471444b0f",
0
],
[
"0000000000031905c508a975707b74f24e733880382775ee0e6250666473e1d8",
0
],
[
"000000000000ca38b15d2ea33a6eef505a9c661540a18882f79ba9a3c575a9bd",
0
],
[
"000000000002739979a7a89fa279303b6606885e750b19e91ed637d7f222b392",
0
],
[
"00000000000091e935fc266facc2c92759d5468a39aee5be6b76b519a9bc7567",
0
],
[
"00000000000006e339938254208203b67c3c400f703fc29535fc646699e36e58",
0
],
[
"00000000000008f6f1d1150d77f93a7f1baa24b65ceb471b1825b2e92ca6997c",
0
],
[
"000000000000004894e1edcc5421dbcec77d47c5c50bf27b2cff3f1c242c9eb3",
0
],
[
"000000000000054e97fb5e1a8bd7900f7c329385895761aaa40d11b3c75b0c8e",
0
],
[
"0000000000000600f4bcc5a89527eede43d1d3342dc12eee1371ab534b0102dc",
0
],
[
"00000000d1ad5c3ef8c3bb4610b34c264e4ca1ea51c4c8bac18b215e7dc96948",
0
],
[
"0000000062f6a07ae11f9724b8ba9dc2b7348ffd02b59edd3cd2bf387fab9723",
0
],
[
"000000000014e4c97c9b09ff20203213f3336b0927fd19d214cef1f544756e39",
0
],
[
"0000000000d004681880e127aed3fa73255a2e75c2e5c8580cd555526614b294",
0
],
[
"000000000008093189bba28d40662d6964afc1c0fc9b5c1681bbe32e8bee6c0b",
0
],
[
"00000000002df10cf8165b2204ef4db6721c8c2119d60463b040fbc81c266bbf",
0
],
[
"00000000000c28c789e7cd9800b98c1dd32e2dda54048116ff47ed856a14acfb",
0
],
[
"000000000003e8e7755d9b8299b28c71d9f0e18909f25bc9f3eeec3464ece1dd",
0
],
[
"0000000000004b95a0103abe2cb97806caca76f6922d9c5df003cf4a467df822",
0
],
[
"0000000000005f12d2ab72bfa715860444c281265ef77e09dc2d041ce89506c0",
0
],
[
"00000000000016eeedb3f367daaee93334188db877fb01cd0282b990f60812b3",
0
],
[
"00000000000001daf3bd8306b6f6899af8aa656d87ac2aa37d493fdcb0cb3000",
0
],
[
"0000000000000390b86892ad0bed9b520783056961cad7362ace8049aa00471c",
0
],
[
"00000000000002105d01b4de7d3e3ada9c757a239151d50b5dd193e3951a23cc",
0
],
[
"00000000000002362fa802df308201a4b1fff2fd8a91892915a46f5d54098ff4",
0
],
[
"00000000000004fb8aa6c6aecb64b9d8d7e691a6cd56fad69fc5278b9e8d98cb",
0
],
[
"00000000000000ce3bd9752b2508ddae1ee71332e905163a3c0d7e10b8c472f7",
0
],
[
"00000000000002d0d8520982f15a45d4a405334c61886b6d13d95843386af647",
0
],
[
"00000000cafd25502ad67d5d409edfc98f5bbd3173e86e085c69658d58da5f70",
0
],
[
"00000000b01e0675317a29a07731ea092fa029016a40ed8bb4fc17cde50eda05",
0
],
[
"000000002676805396ed2883ccc8ad401aa0a974627559fbae2416ba5c54999c",
0
],
[
"0000000000030ab759158f3d425824228dc5c91f32db91d404bee29ee3a41878",
0
],
[
"00000000000da1c8040ec08e7490fb201ca1fb3571f29c0efd3351ae197d3017",
0
],
[
"000000000004e3cba890c16ffc7d1c019d4ab88afa39315164e1b08b8e6a9330",
0
],
[
"00000000000bdcfb630b43977be44529e54daa02d199014a0967deac669bd060",
0
],
[
"000000000007254038f9c621d6df0d9fbd90b5697e4170cd6090daaf579f3790",
0
],
[
"000000000002263e27ea1cec943632bf469a28b067f0bfde3b9a6b48540981b4",
0
],
[
"000000000000f194a8d17e683d17f222d23a9032f034d4dc4497263fd785dfa0",
0
],
[
"00000000000036e359b7b07044e3cd5b132a3c72501a0f3f9ccde167f5316bba",
0
],
[
"0000000000000b10e98a90e0fd1ffbf7d5fc5a76e8e6e960c6fb158711af6f48",
0
],
[
"0000000000000104e1e4303b8dae78389bb4e6c38f3eb3fe42aec6464bd5c397",
0
],
[
"00000000000000bde368a635921f5ad25aeb4b784651de24d624cf20c27691c7",
0
],
[
"0000000081a626a33cff134e7e56dc0f0a67b1735c96256774885d5d095807c0",
0
],
[
"0000000055d357cdf39130eb767f416101e79025515906bea528f43cb6446920",
0
],
[
"0000000012558b30f9c1a156fd80b02451e8dfcc7fe0350fb4adeeb84951a0a6",
0
],
[
"000000000001a4868924fc7cca0334ffc4dd49c07fb841c1da059a7c219bdf95",
0
],
[
"00000000000010086bd2bba88c71b08cfc7e24183d610a2803e6d382049d52c0",
0
],
[
"0000000000018c83992fe05d820b097228de93787e3f59e65cb89ad4c385e364",
0
],
[
"00000000000023ab80324770ff4c6802d09e5e1e7de78d2a8e64783904d47f19",
0
],
[
"000000000000287fa294ea557835d8c98bfe94c4d8b18d5b10f1b62d68957113",
0
],
[
"000000000001d842f5a0dff13820ba1e151fd8c886e28e648a0be41f3a3f1cb3",
0
],
[
"000000000000906854973b2ec51409f0b78b25b074eef3f0dbb31e1060c07c3d",
0
],
[
"00000000000009e694e22b97a4757bffef74f0ccd832398b3e815171636e3a85",
0
],
[
"0000000000000594b95678610bd47671b1142eb575d1c1d4a0073f69a71a3c65",
0
],
[
"00000000000002ac6d5c058c9932f350aeef84f6e334f4e01b40be4db537f8c2",
0
],
[
"00000000000000c9a91d8277c58eab3bfda59d3068142dd54216129e5597ccbd",
0
],
[
"0000000000000051bff2f64c9078fb346d6a2a209ba5c3ffa0048c6b7027e47f",
0
],
[
"000000000000df3c366a105ce9ed82a4917c9e19f0736493894feaba2542c7cd",
0
],
[
"0000000000007c8006959f91675b2dbf6264a1172279c826ae7f561b70e88b12",
0
],
[
"0000000000015ab3720de7669e8731c84c392aae3509d937b8d883c304e0ca86",
0
],
[
"0000000000016d7156ee43da389020fb5d30f05e11498c54f7e324561d6a6039",
0
],
[
"0000000000009c9592f83d63fe39839080ced253e1d71c52bce576f823b7722a",
0
],
[
"00000000003dee6b438ddf51b831fbedb9d2ee91644aaf5866e3a85c740b3a99",
0
],
[
"00000000000155f5594d8a3ade605d1504ee9a6f6389f1c4516e974698ebb9e4",
0
],
[
"000000000001e21adfc306bf4aa2ad90e3c2aa4a43263d1bbdc70bf9f1593416",
0
],
[
"0000000000008218e84ba7d9850a5c12b77ec5d1348e7cbdfdcb86f8fe929682",
0
],
[
"00000000000054fb41b42b30fff1738104c3edca6dab47c75e4d3565bc4b9e34",
0
],
[
"0000000000002763b825c315ba35959dcc1bd8114627949ede769ac2eece8248",
0
],
[
"00000000000007437044da0baed38a28e2991c6a527f495e91739a8d9c35acbb",
0
],
[
"000000000000032d74ad8eb0a0be6b39b8e095bd9ca8537da93aae15087aafaf",
0
],
[
"000000000000006d4025181f5b54cca6d730cc26313817c6529ba9ed62cc83b3",
0
],
[
"000000001c3ad81ffea0b74d356b6886fd3381506b7c568f96c88a78815ede09",
0
],
[
"000000000140739d224af1254712d8c4e9fb9082b381baf22c628e459157ce49",
0
],
[
"000000000306491c835f1a03c8d1e17645435296d3593dacba8ab1a7d9341d38",
0
],
[
"000000000002b383618b228eb8e4cfcf269ba647b91ac6d60ddd070295709ad1",
0
],
[
"000000000000c90fc724a76407b4405032474fc8d1649817f7ad238b96856c6a",
0
],
[
"0000000000002d5a62b323a5f213152dd84e2f415a3c6c28043c0ccaaddb3229",
0
],
[
"0000000000008c086a21457ba523b682356c760538000a480650cd667a29647a",
0
],
[
"00000000000007c586d36266aa83d8cc702aa29f31e3cc01c6eeac5a0f5f9887",
0
],
[
"0000000000013bf175e35603f24758bf8d40b1f5c266e707e3ba4de6fae43a7f",
0
],
[
"00000000000096841c486983a4333afb2525549abe57e7263723b9782e9cfef1",
0
],
[
"00000000000012dfd7c4e1f40a1dd4833da2d010a33fc65c053871884146c941",
0
],
[
"0000000000000b47eb6bc8c6562b5a30cefcf81623a37f6f61cc7497a530eb33",
0
],
[
"0000000000000021ca4558aeb796f900e581c029d751f89e1a69ae9ba9f6ebb3",
0
],
[
"00000000000000a5bf9029aebb1956200304ffee31bc09f1323ae412d81fa2b2",
0
],
[
"0000000000000046f38ada53de3346d8191f69c8f3c0ba9e1950f5bf291989c4",
0
],
[
"00000000658b5a572ea407ac49a1dccf85d67d0adfc5f613b17fa3fff1d99d51",
0
],
[
"000000005d6be9ae758c520b0061feee99cd0a231f982cc074e4d0ced1f96952",
0
],
[
"0000000001aa4671747707d329a94c398c04aaf2268e551ac5d6a7f29ffd4acd",
0
],
[
"0000000004b441b97963463faca7a933469fabfa3e7b243621159e445e5c192a",
0
],
[
"0000000002ce8842113bc875330fa77f3b984a90806a5ec0bb73321fef3c76c6",
0
],
[
"0000000000019761bf9a1c6f679b880e9fb45b3f6dc1accdbdcfce01368c9377",
0
],
[
"0000000000008a069efd1a7923557be3d9584d307b2555dc0a56d66e74e083e1",
0
],
[
"000000000001c14cec52030659ef7d45318ca574f1633ef69e9c8c9bd7e45289",
0
],
[
"0000000000009cfccb8a27f66f1d9ff40c9d47449f78d82fee2465daca582ab7",
0
],
[
"0000000000007f30cfae7fbb8ff965f70d500b98be202b1dd57ea418500c922d",
0
],
[
"0000000000002cbd2dbab4352fe4979e0d5afc47f21ef575ae0e3bb620a5478a",
0
],
[
"000000000000017a872a5c7a15b3cb6e1ecf9e009759848b85c19ca6e7bd16d2",
0
],
[
"00000000000001ade79216032b49854c966a1061fd3f8c6c56a0d38d0024629e",
0
],
[
"0000000000000090b8dfe4dde9f9f8d675642db97b3649bd147f60d1fc64cd76",
0
],
[
"0000000000000109ed5f0d6fc387ad1bc45db1e522f76adce131067fc64440ec",
0
],
[
"000000000000003105650f0b8e7b4cb466cd32ff5608f59906879aff5cad64a7",
0
],
[
"0000000000000113d4262419a8aa3a4fe928c0ea81893a2d2ffee5258b2085d8",
0
],
[
"00000000000000f15b8a196b1c3568d14b5a7856da2fef7a7f5548266582ff28",
0
],
[
"0000000000000034fb9e91c8b5f7147bd1a4f089d19a266d183df6f8497d1dff",
0
],
[
"000000000000005e51ad800c9e8ab11abb4b945f5ea86b120fa140c8af6301e0",
0
],
[
"00000000000000e903f2002fd08a732fd5380ea1f2dac26bb84d57e247af8ac2",
0
],
[
"000000000015115dac432884296259f508dae6b6f5f15cef17939840f5a295c3",
0
],
[
"000000000029913c80e5f49d413603d91f5fd67b76a7e187f76c077973be6f8a",
0
],
[
"00000000002e864e470ccec1fec0ca5f2053c9a9b8978a40f3482b4d30f683a9",
0
],
[
"00000000001ccf523df85df9abdb7c5bbad5c5fcbd12a4a8eb4700de7291f03b",
0
],
[
"00000000002aa81027df021e3ccde48dff6e7f01a4aba27727308f2ce17f2f1a",
0
],
[
"000000000015a577d71d65bde7e8f5359458336218dc024584f7510b38dc1259",
0
],
[
"00000000003aef1877bcc6817cac497aeb95af3336ba2908e8194f96a2c9fc29",
0
],
[
"00000000000ccd42d542ddca68300ec2a9db2564327108234641535fd51aa7f3",
0
],
[
"000000000000a2652b2e523866f3c4d5c07dc1c204d439b627f2ab2848bfa139",
0
],
[
"0000000000002c065179a394d8da754c2e2db5fed21def076c16c24a902b448d",
0
],
[
"000000000000175a878558186e53b559e494ce7e9f687bf0462d63169bfcce03",
0
],
[
"00000000000007524a71cc81cadbd1ddf9d38848fa8081ad2a72eade4b70d1c1",
0
],
[
"0000000000000159321405d24d99131df6bf69ffeca30c4a949926807c4175ad",
0
],
[
"000000000000016c271ae44c8dca3567b332ec178a243be2a7dfa7a0aef270c3",
0
],
[
"00000000000000a7d62de601cdf73e25c49c1c99717c94ffd574fc657fd42fa8",
0
],
[
"0000000000000052d492170de491c1355d640bae48f4d954009e963f6f9a18c3",
0
],
[
"000000006f5707f2f707b9ddcce2739723e911210b131da4ca1efdff581212ad",
0
],
[
"00000000021be68dc9c33db0c2222e97cd2c06fc43834e8f5292133c45c2abb4",
0
],
[
"00000000019ca3eaf7c39f70a7a1a736f74021abf885bebc5d91aa946496bac5",
0
],
[
"00000000006e4752fbe2627ebb2d0118f7437908a8219f973324727195335209",
0
],
[
"00000000038471612a0955307f367071888985707ec0e42c82f9145caed8fea1",
0
],
[
"000000000004604d2d7d921b21d86f2ade82ded3af33877ec59d47072023d763",
0
],
[
"000000000034a3e45665a8dcbb94e7a218375a5199b3f3ca2cc7b5fe151bb198",
0
],
[
"0000000000043fb2c2ff5db60c6d2d35a633746e8585e04a096a9b55a4787fe6",
0
],
[
"0000000000020d4d8735b66134c1fcdd1d3f3d135b9ff3f70968ef96c227fb75",
0
],
[
"0000000000004f3f4dc1fa11a6ad9bd320413b042eb599c4599a14d341f6825f",
0
],
[
"0000000000001e0a495d23acf46a44f8b569ada39ac70730da5e9109871b77e9",
0
],
[
"00000000000002257a08acca858f239fabb258a7cc1665fc464f6e18e9372d32",
0
],
[
"00000000000002845d416fbfa05a5d40ba5ba5418a64f06443042a53cf1fd608",
0
],
[
"00000000000000fee91a2ae8b8d1bb9a687c9b28b0185723c8ff6ffdac2e9ce4",
0
],
[
"00000000000001d6874b4d88e387098c0b7100ff674d99781fc7045a78216a15",
0
],
[
"00000000000144a03e701c199673d72fc63766bcf0cdaf565f4c941c7ef72971",
0
],
[
"000000009b6cc4d8aee22cca6880e4d7bb30bff2851034ad437d63d3a7278de7",
0
],
[
"0000000023e998d64618475e31b4aee9d83d2bc32cb6d062aa97c0b4651fed08",
0
],
[
"0000000000036f4bf6b42a7776a97872fa24362064c5bc4bc946acb70ab6fbf4",
0
],
[
"0000000001e2252455ffd0cf0b4109ace996a0d2a03999f5cc5c5e08fb6130ac",
0
],
[
"0000000000002713db42d53f0c2d86c904f4e0338652acc1cbda953c530a15bb",
0
],
[
"000000000001b075f9ccc604a50326732f5d42373c4a831978be0e2d830cac75",
0
],
[
"0000000000000bfa7d93c6b36298b933b1a652c95ee9f0de4151e007f3180391",
0
],
[
"000000000002c60a0af1cfeb9c26c60970b354897fd0a94c8e5c414d0767b06b",
0
],
[
"0000000000001f2d9462507a9408859fb0b5f97013d6b4577337b0382340c5aa",
0
],
[
"0000000000000b7428e0d3c6c7fd2df623a74125db4989b1c61c78eeed1bcde5",
0
],
[
"00000000000002e8b4f1fa041a37515c1b76d59994792f1c772c9a4993c194dc",
0
],
[
"0000000000094e70c0cf5185b480542a1faa8392a3f2f7f583d91e033856d7ce",
0
],
[
"000000005b036d8c18ed5d1219e4137bd71438c9b1ba7ff4d10a626e9a7bcc98",
0
],
[
"0000000008745d4a943e958f5cb5084646c0fe1cae57eeab666c3ad0d4ff1dec",
0
],
[
"00000000000f8c5b3455e540d074b5c71709e37f8950975953798d27bdc701fa",
0
],
[
"0000000000050885884f7ac233bb174cf7b33c037f81907f7766afe9d0ad9091",
0
],
[
"000000000002d7cd1043ccd0581a47d6fdf82a7cf1646b61495f917a48ebeb5c",
0
],
[
"000000000003a2b3e3d7ef47829db1672bfd79e49f32ef3a04ec7c4df355392b",
0
],
[
"0000000000032a6c7e5bc3878c1815bc6759594a4736638fdacaa5642be3e649",
0
],
[
"000000000001386a3904f0ba4f25dc7ace09b67a6fe8977e7aecc55813fa9ac5",
0
],
[
"0000000000003fe030a2231da87076679c1d38d323bf56b45ceb49a5128fb4b1",
0
],
[
"000000000000147cd3b6195c6a727cd4fe6b3a879d7934e52bf29020ed9c6fcc",
0
],
[
"00000000000003ed5a0a7176f3f1b3ed26510045af2860e5b6313b358774fbad",
0
],
[
"00000000000000c2952ac8a580895ac13799a9c29badb6599bc4a86c1fc83b6e",
0
],
[
"0000000000000056f49d6f7b8243eecf6597946158efe044b07fd091398e380d",
0
],
[
"000000000000006b039683c36b18ec712346521edce4dc5b81cdaf6475d89bd7",
0
],
[
"00000000000000525de83fba2439549ef0ed78d6d08516a0513abb972b0fca95",
0
],
[
"000000000000006c5403ae9c42acf37362885c75c1a71a6b7fe20f9cfc5304a7",
0
],
[
"000000000000006f881a62bc5ec9d4c4da83ddc6619a7eee82617e26e2c7ef3c",
0
],
[
"000000000000012941300197c5b6627a66f9cf48ae9c6791b36c63c0218a1be9",
0
],
[
"00000000000002cd7ec2e00992a4dc6c5e0a56cfbc19b5afa9730bd94f174b5b",
0
],
[
"000000000022e09ee2ee7b3fd223cb9ccfe11058cca5ad0c705fe5a0c26b28dc",
0
],
[
"0000000007d35ebaf81412d40d1224bdc5792bfbc70827c09f05dc5fb168e67f",
0
],
[
"00000000328e1b1aecf68947ad53fb11c58a383704ddbb8b29704669e22225bd",
0
],
[
"000000000003d3b3f171fd10fda1be9d4464b1438bb9443081c2c224a047cc4e",
0
],
[
"000000000001e3c5dcea0586d3c8f69c0f35658fae283d29f64df9b5301bc721",
0
],
[
"00000000000ce5f3757a0cab09a8cb131b3f2c63303375ad1c84fe423866d33f",
0
],
[
"00000000000ca01b96070fb643bcebbc862cff4da78dcd52de1418c940d4f466",
0
],
[
"0000000000006eb74e5036cf42888759c4ebf91a5eb128463e60ae9ab02876a3",
0
],
[
"000000000003aae0765dfee956b322477d786a2cde617ff073e0bc4eeaf7c252",
0
],
[
"00000000000033421d804b4bc0f7dc61715d2fc0cc2a98904ff5e1f9ef909010",
0
],
[
"0000000000002a24b916b5f03bd47250276ad32f08a1684334c7f181b0b7a055",
0
],
[
"00000000000002a7399ec806255c4ae63d7583001bbde70e2038e9b90fb824f4",
0
],
[
"00000000000000ec89aaa13c7222b3ec787a487cdc7a17c1ee87ce313e6ed4d3",
0
],
[
"00000000000001564cf9db3397bd0983a68f450d5b7e59824339fe1d46ba1c75",
0
],
[
"00000000000e932953388774b6b3492d8756f936d74fda1d33eace33538fb0bb",
0
],
[
"0000000084c2d56f703e72f6ad637105409552792ee482bbc14376cfb29c30d9",
0
],
[
"00000000392f30ba333fac2e4937e162105ba2b20fe953848b1a4c004f460223",
0
],
[
"00000000000842b42c56e4dc573efd9b6b6864dba81730c4f95b837d52078ad5",
0
],
[
"0000000003e4cca12f6109687fcccfc5c3827bf3bca2487096fec0293b4b351e",
0
],
[
"00000000007b7eece3ebbf77ed583a711c8427284ea9b556ec67efd14e7f5d90",
0
],
[
"000000000002c0e026657401be7998fce1618869ec073a49ac935a15d16c5741",
0
],
[
"00000000000cf19ef67151f6d06b426371dfa63d9d2bbd6024cca520cf4d96b4",
0
],
[
"0000000000019a6ef183423833a4347d77e8687b4fc83a85f4c98c579631acbe",
0
],
[
"000000000000a292b9ff43becd4770243d2750e2b3c4e81a6ed79b8abd2f5052",
0
],
[
"000000000000280db4a9a31097024bc81f0358ba624f1f8dd83a2362a156a817",
0
],
[
"00000000000009b17b295d898cda8899ce547183fd63fa901b9f502aed00c45d",
0
],
[
"0000000000000013f5c40f6b0e7e8fe854045135564a4df6ff4ca736861d7ea8",
0
],
[
"000000000000c39ffca7d1daad0d4f8af9ee108443bb1b4352cd740fd8297aef",
0
],
[
"000000000002f42ee90d7d459393eb90e2ea5a3ed292394ce1dc5f7a42d66ce0",
0
],
[
"0000000000010d6bd31805e0a9b8629192c0ad704641d2b08c28865052bbf469",
0
],
[
"0000000001015f5067612dc0d681d71b33d278c50ca88d7756322ab90f753290",
0
],
[
"000000000003dadd324301ee6157c29e7aa9f120edefaf05369d849510e6d60c",
0
],
[
"000000000000a62107ea11c5db9929d819181d8903624e9088b8700d1dc66ea7",
0
],
[
"00000000000022b91e1b652f626cd3a81bfb2ff70717ace53c488dd45c75fcbb",
0
],
[
"0000000000002845027a6a08c436c6e99aa8af0f7c744a722fd598ba0f66f4cb",
0
],
[
"000000000000ae5347baecbcb3cd01265f0e52c8819f830dcfc6dafa1ec4327a",
0
],
[
"0000000000008dd3169522647ae90ca0a3acc405f0e8c2b53dab013433708921",
0
],
[
"00000000000023abea5dd709951fb1fa5c34a75670ddc7eea46d2d23c6033669",
0
],
[
"00000000000006fe20edd4be3beabc4432fbe410ab53466660105ced53056190",
0
],
[
"000000000000003f6d6889d2917ba88f6e286c156028baebf05be409e1b97ef8",
0
],
[
"000000000000005d871f102aaa25e60855c96c1aa8404f004db1c8bbfab341e9",
0
],
[
"0000000000000197fac06dd6c7f80c838b6a21f1ce72f10aa6ba0aff40c3cb92",
0
],
[
"0000000000000289a999cf132efbee896d8c22e2f9d1036381b00d72c41660e3",
0
],
[
"00000000e9f6bd4700dea0c0841272461e4e9d125b8fe2c35a2ca39f77269321",
0
],
[
"00000000f91f03ac1d08214a3646c2bef1878961a8c40d867254d733fd9cb2a3",
0
],
[
"000000003d42ef351c6a1fb5e2d43d1a28ca095052be35ad9bb901b097c667c8",
0
],
[
"000000000014b426a9844698b6369c0e2befe4e369f1dd01c157dbdd472c9136",
0
],
[
"000000000016dfa525db05b9db92a080e0da65a4a0b15e538649eb4c0c670cf4",
0
],
[
"0000000000027a82eb5b1ab46a276a9aa19e3a1e52e2328c07a50db314664148",
0
],
[
"000000000026945c53ba1f9b0c34f9e502f3aa64c9979ce583b93daf347d2292",
0
],
[
"00000000000f64a42d38e16119aa724e6d859d8b7ed2964bd0929a226e57c838",
0
],
[
"0000000000011bee42dca16315be14fd0be451e4385c787a66c7dc6c0a498ce2",
0
],
[
"0000000000007fcace99545546c5ee4df862e21840543865ad0944ca7b82baf7",
0
],
[
"0000000000003b3a9be8e418e11db77aa16dbf9f04a9b43b34466e7b41520fa2",
0
],
[
"00000000000004ae741f8cd7f6f20231f8be6b89946e50339f0089a2e5c6d4d6",
0
],
[
"0000000000000379b21385de297e65a62e4d15ee27fbf1e3b4fa7a46b4a274ba",
0
],
[
"000000000001fd6b7db603c305be360c602800e5d9068bd65bae111b4561d5ab",
0
],
[
"000000003925c7eb3144eb77e7891a607152b662b161cd4a052e2a5689c4b694",
0
],
[
"000000000000a8476194924cd6612277821149e22f7326a054c09c7d55b8a9d5",
0
],
[
"0000000009ddc12332eb5903b89ddfd116bfd9b300c4d70821e749a302fa438b",
0
],
[
"0000000000028fe3bfc47a9ad8a71c90fa3edea0c1d04f823c5a9d8674b9d1c5",
0
],
[
"000000000000075849c07342e632fa3f2b4e137de35703e91c62cb568a8583ea",
0
],
[
"0000000000001100406d8447ce19989346956134e2dabb87f93ff1b32208dc21",
0
],
[
"0000000000006a8a2fd9d16a22f28523940811b3c4f179f888249b6f5f19c708",
0
],
[
"000000000001af7c8a48d294945d937c3f1ab297617bab1a0eb1d9a40e543139",
0
],
[
"00000000000040eafb8f54cb988a19d0370379be0b2917787e640720677ba6de",
0
],
[
"000000000000025f7bc6cb5759f267fd649620c69f6518213729bb6aeb4d98d3",
0
],
[
"0000000000000217a8588f1af88d2f73a96a658f0aea62de5c53b5b348346456",
0
],
[
"00000000000001b8aa8353bbafb6f47125f67a711c0a2a7a00bfebff5a8df093",
0
],
[
"000000004ca77c8921259d7da52f341526df3f34edb62e3e2888b7ce42b8c29f",
0
],
[
"000000005c8253a86af2492291e888d78d0a69a7a657a221e59b23eb6291fcff",
0
],
[
"0000000000fba14ebb3757a9348a05b07ec207b25aaffeac4118237e665fc566",
0
],
[
"0000000008f01a3c024cb6d1814e54659c72b17e34e2b60fd35af2184b6bd3ea",
0
],
[
"0000000003da1325f0d607889753f3a7214c3e559b9834c6f0e37bd52e14eaec",
0
],
[
"0000000000d303f0b50fc25ea141ad3c26d0dfe61fa4cfcc6875edbcef902163",
0
],
[
"00000000002131de3bcff721c93c169e34450054c18fc02cd5a8e08c7c3fd567",
0
],
[
"00000000000c69cdb751a4ef5f527ae244909ddfda10a4caed4d6f8dd44e51fe",
0
],
[
"0000000000024819bfbc99fd2032441181dcb2456ada1d047c4b6b7829be62a0",
0
],
[
"00000000000077021c5164bc1014b24abd321f160bb914a1257a86645f923385",
0
],
[
"00000000000038e149b42e964bdeb10f01fbbfd38ce57ec25eb3fdfb712cf9b0",
0
],
[
"000000000000047dd3d1ce9862add6979aa622a7cb2141b4c6ec569b172dd776",
0
],
[
"0000000090c401521295d1040e0f9b6cb65da914085bb9346e60477837dab234",
0
],
[
"00000000f36784781eaf4b0d3ef92525b6cf55e910c782bd4f355b71ee40dc36",
0
],
[
"000000001d3848f040d48696a9e258798bea34969e810ad01e8092183f201dfd",
0
],
[
"0000000007658642f1e8ac45feec2766358f425030b14ad824f3a6df30b9eb15",
0
],
[
"00000000028e5b819d9e197b1d3f1246a2a6990d8e2360371dbf258c2c5861fb",
0
],
[
"00000000002a8dbd19a807d955c7d01962fea32f5ae027345121176ac10c20f4",
0
],
[
"0000000000144908febd5cbacd1d9b828817f0350211be3248a1ec2d3ac3e251",
0
],
[
"00000000000a302f19d696c7be172c6ac92ec2adf956417bba482d3e5285e5d7",
0
],
[
"000000000000a289eb62cae8c41644d7c9de31148f711744aa5409164b90d6e3",
0
],
[
"000000000000036a6f6002c633b6be318745d2f2ff1520daa6a49db7649bca67",
0
],
[
"0000000000000293db488f4a3c7289489664e6e7e1ec917dc58c83ec828a4730",
0
],
[
"0000000000000e24d4ce3b9247d6316791438ab82ea755e788112bb9729730cf",
0
],
[
"00000000000003a18b92493908ebe4ccecf24bfeda95bf3b8a026e3c01af116a",
0
],
[
"0000000000000007a2b7ba9dd58c20651b477daf83df5a7ac24b856b22f1fb25",
0
],
[
"000000000000000ce321e0271dd532a6ce58737151baa84a77a585df614c2ab6",
0
],
[
"000000000000004ebec3379d6a8569295a2d0a0c0e0c815d2b01803315032185",
0
],
[
"000000000000001bb9ed28d9b0a70fee0b6d42f91f3db53f2086eef4daabce30",
0
],
[
"000000007c5711c573d147a6fae21faf529c039220c97dfe2ba96e732d88fa89",
0
],
[
"000000008e5a5e820d1a10dbeecf6f6df3bf7ab56e46eec275d8ca1a52e86b68",
0
],
[
"000000003fa06ace5db33de18cf03b0c56d4e62cdaf8ab533919953c22bffaf1",
0
],
[
"000000000000e6442b0c74fa811319edf2edd5f8d9b2e3ee831b4bdee644fbd0",
0
],
[
"00000000011d0c3f98e9c3db6b51468be632bdef0c47f5e45871b771e5b0bc57",
0
],
[
"000000000000e3c0978d872ed3b3a43f6f319995459105159b5f4e92143d40d2",
0
],
[
"000000000000cdf25c3e15601dcb798c6cf8d2dd89002a4e046b746be6b87fa0",
0
],
[
"000000000000521507052d13f4fac6c01c0099466720bea95c2e9349aef7fa5f",
0
],
[
"00000000000064823750f1a6b7cd1748dfcc73376086cfdba987d2a36fcddb71",
0
],
[
"0000000000000b4a41be0612f47a58efb899dc1cc0965c1c1fac89e1ea69f587",
0
],
[
"00000000000010aab857bf7d475d9a594dca8b1144597a9e69c70f20fdd20b4f",
0
],
[
"0000000000000c264f193e8d5099f2c20c08fdf9e5ca9006fb53778c0d8eb869",
0
],
[
"00000000000002adcce72a5cce517f1afc33c765927b77ccbce5cdc6f5f68e45",
0
],
[
"00000000b179a6096a58938311b3b8cc4479ccdf3909667a58598acc4ebd0192",
0
],
[
"000000004e86c06d23b8a4c20e6cb5a4c51cad24fca30e41695f8ad00852a88e",
0
],
[
"000000000bafa134d62d9df490ffdbc1f2b86b4373b86c079c5b730034aad214",
0
],
[
"00000000033e9b623ca1d89418114f63af55e042dafbfe97952e7a5fe7a3ebf4",
0
],
[
"000000000119025b6c9bbc3390708b1a77e85eda69fcb79666418ac2cb874a17",
0
],
[
"000000000000feafbf3a525a1dd7950fa53f7df1b0210e79337ce588d35a8b9a",
0
],
[
"0000000000007044088a1cc9ddc0c3779c0e156dee10fa15a760897ed4249f8f",
0
],
[
"000000000001a10e8b1ad577278f946252298b49b74ac9db70ea80c0a9c12db3",
0
],
[
"000000000001281354a7d86b3c750681283276c0bdde2b18c38d8354138ca4e1",
0
],
[
"0000000000000398b17fcd5d4d59ccb31d642f7b60c2a4d4d2aa7239ebc0efa9",
0
],
[
"00000000000021a571a2c475115fe723b593633efb85bf0ec0f7d67b780e70c3",
0
],
[
"00000000000002d1506c82becd7b480c85402d27f23a1248cfa128b7a8c009a6",
0
],
[
"00000000000001978f804f5cf8e4a0dc0c454fce0f0e2614510b8eae6e504b2e",
0
],
[
"00000000000001c4558889a43ac35208f502bccd9d38c741571723e9d79bcc26",
0
],
[
"000000000000005c782bbbc75358216e1ffc37973cd43a474b87dfbac4c61fab",
0
],
[
"0000000053bffe3e3db3672c5f050fa54239f93833ec5c38af92e83dec71a9fc",
0
],
[
"000000000001362fd5182f1cbfc1981937cd67ba54bc7b6d7f0a68f94e369f0a",
0
],
[
"000000000386ae84caa25e9dfa7816594b7c30a079e340bfcd951be2b5c092b2",
0
],
[
"0000000003cc09a351d647c0e12063d45b20e6f99c27c18ea62342b9d246581d",
0
],
[
"0000000002527c4756350bafee88786cd7ea27bc802f482c4e50cafc547ff9f7",
0
],
[
"00000000003d7288f44aa0b725af7816d2d333e118de12c390423d641139d5d5",
0
],
[
"000000000008c0a0fadcfbe27a880ce9c387425d3a2c6b06c1a599e4ce51ec92",
0
],
[
"00000000000158ab2486a8f1251c5c94502763ced9eb85847bb9d2eb476b515a",
0
],
[
"000000000000c817e5775378accf08412657e2557d2895df0fbb8475b5e190ba",
0
],
[
"00000000000078d59d08215b3aecdf0e0665d3a16ae1716e408df790a3566e72",
0
],
[
"0000000000002208404b39b95cc20845de19b47e05e8146146056d3d9bb382ae",
0
],
[
"0000000000000543e9315ca8b3b72bd3590f24535e4ddc6ccb1050b607777530",
0
],
[
"00000000000000abb8d3ffd3cc347cee5c092dde5355a7dc5d288036a28760fb",
0
],
[
"000000000000008bfbcce7d768df6f4610205dcb40173e8c4c417a2325487f34",
0
],
[
"00000000209e49391ad09577f87d1e0ffda27d2e749fd305c51692112627c99d",
0
],
[
"000000000005561eb4b2e0cb8107c81617284e7bcd7d390d16a3cd5925cf42a9",
0
],
[
"00000000006b24215c790a371bc18c53c83ff35e2c82d459bb6240cd9615dde5",
0
],
[
"0000000000af315d6fbde8488d68dbd055a56d79555ed32c3ad4d70286b4df2a",
0
],
[
"00000000019e49bc89fcabc4050521fb8835f926a62cc10b68e9618ffc117162",
0
],
[
"00000000009c0dcde4e694463245e8e5e45d2897e7fa67772ce0ef37094f3afd",
0
],
[
"000000000005efbda8c010f29a5b81606d186459047ce4b7eacde8d9659dce97",
0
],
[
"0000000000051c1655579a441a7f4d543c323d482405cf1d1250c3ccb665d426",
0
],
[
"0000000000007f13adadd1fc6462fbc5231425b81826af4e5f0cbb0de54a5b3a",
0
],
[
"00000000000011e00df09353fcb53766447279b96228da0525d769f33026bebb",
0
],
[
"0000000000002b91e6bb56015e0e60dc650a63666aa3943058e9641d4d679fa3",
0
],
[
"00000000000008e4d5fbcf207583267efff33e6c8d0a5fbdaa5704aeb674fe29",
0
],
[
"000000000000018aeeabcb422b5b0a46cf3a5f2458125c043c5781ffafeffbf9",
0
],
[
"000000000000004ca501cc9138ef5fef4b7b235682b81ab9719b3cf215e94f73",
0
],
[
"000000000000002b5bb1c4c43059575556a0ed10099ce5095f805d3d9ae10cab",
0
],
[
"00000000000000018523a377832f154b2b65142098bd18dc175273c92ec938c8",
0
],
[
"000000000000001f58a18e73b959b3fba53f697f78aedeb431d4b5df42cc2eb9",
0
],
[
"000000000000002df432cf9306f7eae841b8e5b7c137c5763fd4bfb46f8a309c",
0
],
[
"0000000000000004941ebdbe86526e806cd13ed226daafd0dce886bfa23d2352",
0
],
[
"000000000000000cef306e6a9eea2d7c83d051eab259bc3f6e985de5f4ac3d6a",
0
],
[
"00000000816ec1fe265e8abbf9f1de03498abf8cb6cda9d29a7ec6c8518524a8",
0
],
[
"00000000000011d74604be4f183ed34f00c15d7218834802163c2728d0338535",
0
],
[
"000000000762bc47cfbc6be9269fdd65dde20d2c88719ffd90a6f2945f7c6fe9",
0
],
[
"0000000000002db5f8794e7dae8b50458ecd05742d4d371123252e7472573619",
0
],
[
"000000000002b4f1a7ae7549fa44b1b320421aa1b59e1c5ca19b086873109677",
0
],
[
"00000000000047b8f650640a6c7ade46b2116b25e9e31138272ed319ea5b2844",
0
],
[
"0000000000001256a95ea8f9b361e7eabc372d62653e9eaa0dd7fabccc61af5e",
0
],
[
"00000000000035948053b0e71b73d618b490cf735780b470d55d96be66abd773",
0
],
[
"0000000000002e1c730a9822a12a74ad4891d1083ae398430520d835487c3dd8",
0
],
[
"00000000000014437f28476cbe0cb6637ae1615a20e661daa90bbfc291f00660",
0
],
[
"0000000000002f1c8dd72d46575a0c4c98a1197313ad1450c21b190086d40a89",
0
],
[
"00000000000008260b87de90b439d1e8e854eafa3c271bb9218994e9d903a779",
0
],
[
"00000000000003476bac3afdde7dbf55d6a974817a87ce6cf4b20564916bb48b",
0
],
[
"000000000000006b4e74497ddcfac98e9965bfc81a5087f5f091de0d9f5118f6",
0
],
[
"0000000000000033ded358c7074b4e19f90fe1db1f258cf6ed9fd0227923d09e",
0
],
[
"00000000000000048fc3781990e18e064e3d5f3d73bab1a199d0a6519f4eda1b",
0
],
[
"000000000000000e1871dc667a7e6596c15a320c1f2a9a81a784aa14c62f15f3",
0
],
[
"0000000043dcbf92d928170baccef4bafe45f5009ad6e8c7a4fbc924bf1e659b",
0
],
[
"00000000a5409910e5907b6b1db12c4bd8a8063e15f39e749488fa9c035de6e9",
0
],
[
"0000000000008c9975f8e4192fae850a9247e14e38638ec745dd42c230137728",
0
],
[
"0000000000003f0df4894e1939e7ab333536e1b71a06b676419e699210a9780a",
0
],
[
"0000000000003771556faa6de8495f58a1b1eee15abdc71f3fee10e03e72756e",
0
],
[
"0000000000000f9c4ee2d147531d9381bd7fb8140d4d7be0f8f058b4017133ab",
0
],
[
"0000000000001833e3116f1c5f9a1c5be36918c19d6f0850842f2b54b6e674d8",
0
],
[
"0000000000005c08d8ac7add24fb3a01857e68b0a806951986d0f412a9ada58a",
0
],
[
"0000000000001f70115a7353a76b574f844b9ca9d551d346c741c005cfe2a06c",
0
],
[
"00000000000028fb7a7dfb30d02e93afa2cd462c8b4a12b022036714c9e6d2f1",
0
],
[
"000000000000386fa93b299ece82b0faab9e169c3167c671517090a2aafb3825",
0
],
[
"00000000000002a47ca39571cb0a79edbcdbfac91f9297776c3c2a9d8deec299",
0
],
[
"00000000000001cc4e0aee1cc285d411eb1cb56ac4c8fe2978e63ade53607002",
0
],
[
"0000000000000016bb8db548039b254578f550bb702c66eed1c441ed7fbec8d3",
0
],
[
"000000000000000f0cecd63a2292a5cd53a542fba78ed0d6fd3d93e9f963bca2",
0
],
[
"000000000000001781f58897dbcf54dc50fc2c4e5c949090a79aecd98723608a",
0
],
[
"0000000000000011ec668d99fd0aacd40bfc8ccf7b364d4879248e8e628bc5b5",
0
],
[
"000000000000001a381d52991c224ed1c6d7c4c2ee763098e022d0c04eb78381",
0
],
[
"000000005b04a3f6f1e7f273875826c8538d9bcee2ce58a98e61ced5bc1fb902",
0
],
[
"0000000049e220ce8e607b6cd231aa1f6ba7758521195d8c60afb920900ed146",
0
],
[
"000000002467012239401cdfe357bf8d49f4bc74be65a3145925a230dfb360f1",
0
],
[
"000000000bb9e52739adc38fb6924c68ed1f1962a45e75aaa18066bb7700cfa6",
0
],
[
"000000000001e91216fc79f80d58182417dee38dc449e592328991a344079a0d",
0
],
[
"000000000000ae1204d3836b126e30685d7391787820e6f8481afa7f4891d88b",
0
],
[
"000000000005fb7034adda5521e1deacec2a95f6ce7f65df5123742f4350a633",
0
],
[
"00000000000006a1154387c23fd70c2cea8036dab861d51fdf687639b2881ef8",
0
],
[
"000000000000fde46c03685cff7a1eff85bcb8e1604577e0bca9e3dc1cf3690d",
0
],
[
"000000000000d85499ad0085b4c7b9960d28e542c1ff7d8422ccf2a94fbc33f5",
0
],
[
"00000000000013b7bc74a6565c5c0a7d32d21567623ae8e2d18b43d5cb3c9040",
0
],
[
"0000000000000f26070144a87fe5ebe3676f0e6a2a2eefbf6556401293baca89",
0
],
[
"0000000000000302522fb697dbe69844a8cdd7696faf16a5c8a43842c2a3bdce",
0
],
[
"00000000000000b34c0ff70cd3b532a9cb1633896d2e683aa53827c6e0f1a25b",
0
],
[
"0000000000000018e983ee8b65a40d3587cab91a4fa8d29b68353778a6b7f862",
0
],
[
"00000000982be2494520c626711cf47b5bfff996c7f74189f9a9898a96057b11",
0
],
[
"00000000c1379d510ab27bb0a267a32ccf5af9e698fde308635634515496b25b",
0
],
[
"00000000000183573830f2976678ca06a90570c40090b7cdb52b3d3940eabffe",
0
],
[
"0000000004799482beb4d1622b71685fd616c923ec99a91f2b6309195814194e",
0
],
[
"000000000009dddcccfbcc285ce93d763201404707b6ff30740bd8e508a411a9",
0
],
[
"0000000000bfc6f8a9569e69ec3b57a385d81e920a3ec84d0e97a070f27107af",
0
],
[
"00000000000022d8d77623449f408a6dec9e7fa847c08c8c246b049c15f9d054",
0
],
[
"000000000006daa9307e62107c6984c2b90dae469f1fd1bb156dd7681de0eade",
0
],
[
"000000000002cffd12d7d9867a7837ae6b45b383c74f0563ac5709e4eb28cbf5",
0
],
[
"0000000000008653ff3c6a0a517ba04729eb63a0ae60c7baa975a407fd561bbf",
0
],
[
"00000000000005e761b3a0236e409d3ac72dc993e9cc6835f3504e62b3786d5e",
0
],
[
"0000000000000bb93ca70e18034211414c6769524031248b7345401ff7dcdc6a",
0
],
[
"000000000000004f9e54d85a4de7e13a6e64b8145713d4402927196d6c788c01",
0
],
[
"00000000000000d19157da447106f9827f098b01f43810b3fd97eb13488599c9",
0
],
[
"0000000000004db0a4da49aef66b6b47c3d2fe28152c4eed5327f36a2e1049fc",
0
],
[
"0000000000657115d4dbbab98df8f80baa50f6906250c62d9d634a45f2512b9f",
0
],
[
"00000000000dca4d6732b889fb9b824a7b80a1f509f81c52a02805ad4b008184",
0
],
[
"0000000000cbbcd73925dafd962774ac5457ba8ed5b60966f19915e2a92d985a",
0
],
[
"00000000006b6eba960b8ff4e8a84b343167ae795a05d508b36261b36a0183fd",
0
],
[
"00000000007a284f086b9118b29d4f1bb80b36097f1676ed923caa96f86259f8",
0
],
[
"000000000002065b5c30c9149183ff50f298ae4afc718002cbbd0963b07b5747",
0
],
[
"000000000000e2b2be6cc8930626a1cca225656f4dfd4323bc114bff9381de4d",
0
],
[
"0000000000014bdabb06ea628423a845a974dc4f78b841cbfd68fac5b5ae4bff",
0
],
[
"000000000000075561bf4f468313206932c444c56f8a0bdfe7d786674f39195a",
0
],
[
"00000000000029bc154a17276deb51e57fa4c67be260879e9e90c4a99e484812",
0
],
[
"00000000000001df7d47715a9ee239c41ea4f6927fc10513fa8e196d030a7c48",
0
],
[
"0000000000000190df480d22883145a8137ca5364e6370181e481f363b2dd942",
0
],
[
"000000000000009f95d274cfccced7b153b6bdc7a1f479086aba1af3ee51e2d0",
0
],
[
"000000000000000c555ee275a7c75daeea5fc8c9cc3589ce8ffc485b0e2f9c84",
0
],
[
"000000000000156317725ec06f7396591ade0dff87ba7e5592ae7ca8922397c9",
0
],
[
"00000000004bd131ef3e9b59e54b2713dc907f11ef9bcc6bf3b0d7ad791500f7",
0
],
[
"0000000000c373a55ff87189465e900beb21621c91cfd99f6c303a78206c17ae",
0
],
[
"0000000000380431e66d9fd20f35080dd6078d0506716b8a5d7e39613a98403d",
0
],
[
"000000000025fc2f72b36008becacae888c81811238a88086b45b28c5c394067",
0
],
[
"000000000033b750e7cb47cdfe186e26d38c5c461e0d8395e489c0614056041d",
0
],
[
"00000000001331a7e5cdf0b13e176b4f9bdabe9e5b2db5356d59cb0dfe1f0e46",
0
],
[
"000000000006eebd9a33e800fe588dcd34bd363357b593f6808e397893c6028f",
0
],
[
"0000000000028315ddfff7fb95659327af8eb64092b7d130fbcbcaa784a3d4ec",
0
],
[
"00000000000029d270b7cabee53577c273d9c01c87c140e5343757d3953d4b63",
0
],
[
"0000000000000fef07c22f210c2b7ad92b06570cdad0c82e195835e0d0378aa4",
0
],
[
"00000000000002b56610f192a962de55e7060686e269e3cbea07c55eaf0ce120",
0
],
[
"00000000000003c962e2f34b9e525dec519a6393abe3f17db6af189455cd7baa",
0
],
[
"0000000000000057e6976dff338a43dfa1925a5a43e97de23f01b96eb0b839af",
0
],
[
"000000000000002db1fe2ead78cdd2d14392b5deea255c363b5e734adc9b467b",
0
],
[
"0000000000000019b25e3df2e045927729efd35896654add081f6aaeebd71f47",
0
],
[
"00000000000000063c34230877c91acd3017621542e7ba4d9b7ef64d0b5cbe93",
0
],
[
"0000000000000028e023aee554cec2607f92c29e99611eb14736e2347e7bf42c",
0
],
[
"00000000000000369d46aac842d5505dba93ade92d5d3be1157e00ae5c047cb5",
0
],
[
"000000000000002077102f09a5563275c1efdbea9f6395f5146f1d6037970d7b",
0
],
[
"00000000fe37a092e270e704da91edd5d7c4234766916e878c8b0a02ef64870b",
0
],
[
"000000000039588e49d18e9ef322feab3d3cb6d14893262060890a9331b32f73",
0
],
[
"00000000006613c34279b3c58d8d54667ae590630aef080faa50f07ccedd3bfe",
0
],
[
"0000000000869e7bc5a3b34ebc2729be68c77c712317e79af584564555957b8a",
0
],
[
"000000000009637269a88dd5ac43497b8f27ae313211a3061470e8137d555e59",
0
],
[
"0000000000004a8598c4be94b043e7fbc600226fcd7547b83b64020d09a61fc4",
0
],
[
"000000000000100695b5f09c3a1e8c29fbe67df508481578d2948258a04d0fdd",
0
],
[
"000000000001bd1d57b3da64d209230bd6e314724b25a9b850e6181f49a6cd03",
0
],
[
"000000000000d00e36399e3a40021ac0a6adfe27973edff1ff66d6420df5a98a",
0
],
[
"000000000000a63aa2dcd75bf8fbccd35e3f00b684df023adbe2785eb4d8de82",
0
],
[
"000000000000308579c854b0c09965e609ba1c63f109f98e7cab1be5cbf364b3",
0
],
[
"00000000000000dba13892fba29915fb44c60e1a179d0022ca33e94c63a158bb",
0
],
[
"0000000000000384bfe36508812b0068cdf46b0cf1942db32cdc17a7f5dba83f",
0
],
[
"00000000000000f2916cb59cb479f2edcf2c2fcdee3547e772b904db17fc6d93",
0
],
[
"00000000000000313b0f9b9b90f1dc0194a5d54abe389817e195501274970183",
0
],
[
"0000000000000028d70cea4d1f3e5f3601b8c72c7e844f434972b2dcc343d3b2",
0
],
[
"000000000000007514deae00c442bc8c8121f2a943083c45a223ebdef390bc39",
0
],
[
"0000000096309ddb64473b6a32ef5814b5b197afd1de684c25b9978abb17d5bd",
0
],
[
"000000002201773b4ba28e91606a3925ce89fd0558a4d016565b6468d4966184",
0
],
[
"0000000000001cd5ff5078c2311f2f9ee10089d2630029c07febd67016245583",
0
],
[
"00000000001b93e7b197099b50a98462f56421e91533bbb5a5e6316d7748d271",
0
],
[
"00000000000001d5b12737375e9a24d85e35425534b80eb33f1f6067a3c9ee9e",
0
],
[
"000000000000a528259f38acd4ff2e48aa80c51e8d32d92ef22ac0319b7a4123",
0
],
[
"0000000000001d7a4a2d35e55b00c097a4e3cfc78b27acec46fc35e14ec3e71d",
0
],
[
"0000000000017069a0165b85c9a568d821ce1a23aa87fc32730ff2054d4e147c",
0
],
[
"00000000000163737ba8bd06de10bfd9610f897832edd1b9c00986b8db41e294",
0
],
[
"0000000000000f2a5f2ec3414e50228ea1a647b12b9af39005299c318adfb469",
0
],
[
"0000000000002ee72d7899719ed3a4f101a4bacb5a341e5de28354b668e5f534",
0
],
[
"00000000000000855dac9ef2f084ae3ba0609977b2d47ca7174ecc1219866c20",
0
],
[
"0000000000002037546232e2809ff4f0ea5416827c134afe7107d74a4939fc68",
0
],
[
"000000007b4f678d7e1d8b9653e1761e05d464eec7da2e65d5d3f44db1853c55",
0
],
[
"000000000000635cc05816a3b7d24b63cabcbfc4debbfa5410df25cf5c618351",
0
],
[
"00000000000064fa9ff8ad2346da5d75e029ee1d2c3d9499b2656c6a6d562c91",
0
],
[
"000000000000a1b3bcce8190963b2d801579b0f1fed6129ad4e21a223f52357f",
0
],
[
"0000000000005a78a3d8d9f33b6ece86970c75fb3b608125078d61c77bbc0788",
0
],
[
"000000000000d48d0c9e7933b8749939110764bc1a827f0242c43233af5512db",
0
],
[
"0000000000001725a132f59b477a352d478e0f6049f295fc609eed2f5e4ca3f1",
0
],
[
"000000000000e6308f8f026cb8b44bb0d4b15d2710446c7f3719662fe7aa5138",
0
],
[
"000000000000edd1062f75807cc0de78a3e19fa07763e23f744d544b1d5cce0f",
0
],
[
"000000000000217223dd962210aaf8ce96de32318adf53387ebf53f3b2b30539",
0
],
[
"000000000000021e3df79714b32722b720f916a9fbbf9102e7d753a7f242d8b1",
0
],
[
"00000000000002c7dc3f4aca17976008f1a3f2547bcb15b3a138af988f3e4f2d",
0
],
[
"00000000000000398aaeea76bd1c64e573b103ed06c11bc954e74c4c0b437add",
0
],
[
"000000000000001c1bf7b854488eee158545d3216e54ef714b83471d3a73c48e",
0
],
[
"000000000000002784b2be9801c004cefaf2b95f7626b19bec5aac1dd514fdf6",
0
],
[
"000000008e2cab9ea92cb5ef0416b8cbf824b37596d3a5b60406f1b3028f7faa",
0
],
[
"00000000104964a57ca5cbaa3008047f78705c4113033c750409a4b259936ba3",
0
],
[
"000000002f524bc577bcce253fde6910eba3ef06d15dd27232131b5e4345fe47",
0
],
[
"0000000000052c7116b0b643643af581ddca810363916a440bda2ace38add369",
0
],
[
"000000000000f00137a5682f26cd18abf5c6f3ef61f910c59a59d798477cb152",
0
],
[
"000000000002a0894c9aec54bfdcdb4a064e4507a6de62fa990177980ae17fde",
0
],
[
"000000000000be2b65c45a2b5d56f1be99f2975b1a9a52d27ace45f5928c7020",
0
],
[
"00000000000057d1aa7d139b6f28389f9f00682367e11cd6e179db6d78c21c5a",
0
],
[
"0000000000006e312b5a03b58c2ce0ea1f4e575290499e3e8628e55f57819810",
0
],
[
"0000000000009679b04fec87a5ef0cf0ceb8d799a6d01d5c87c2fd24c29468cd",
0
],
[
"00000000000026a1b24da68760401f5ae058d964a1830f8823c7958d6f0cb115",
0
],
[
"0000000000000ac34da5d627b47fc7f7b25599081a8b187ae52d70f05db9bcb6",
0
],
[
"000000000000025312bb3c989c7bff8448905d7e591c6d3cb16d3a560e632157",
0
],
[
"000000000000005e28077829e782ff8df6e9b907bb9df6493e4462d8285ea9e9",
0
],
[
"00000000000000498293c5ba0eea98e1bc9bc21178cb13033a11f8b49f5e774f",
0
],
[
"000000000000387bb87b8e7873d04bf3c2f70af050336a8536ad3f7735119f58",
0
],
[
"0000000041190a125657d94ea0ea26b2238b7a68f5eae7af62eaf902ac585923",
0
],
[
"000000000045beda231f53f748d8d0a0adcb0090603945f5664273e5f28e4c6e",
0
],
[
"00000000000c12330af00f371874b47f1f29a7d9bbb89f5d0a1e3a2dd53eaeb2",
0
],
[
"0000000001e172477961534ad79703de1bef9d7b11b27417c19dc4a8a1f3acf6",
0
],
[
"000000000022c7e8bfb93d5a5289cddd8d3083699997533f9f74cfe634f71f71",
0
],
[
"000000000024222a6945a270088ba37d36c48661d153c85676f87a879bdfe080",
0
],
[
"0000000000020caf59c7f3798fb533287568e2c5924b2cc5dfcb1da89786879d",
0
],
[
"000000000001a5f0697c842dd77697934e30bd8798c4a4aff1b7441dc6f23dca",
0
],
[
"0000000000000ba524c5e6b6bf1262f8ab7d9f74879bb41df26f1f5e5d13b4de",
0
],
[
"00000000000005f7c7e2f10a0f7d9d371a350ee6e6fb567b5ba6e7a43a9a2bf3",
0
],
[
"000000000000060cec749aeb779875b1be98f34ddcf541454cf4f1aff8e5d998",
0
],
[
"00000000000001f8ba23177cda6b436e7a2301e211fa35198854077254abeed9",
0
],
[
"0000000000000067d5b94b837fd148ab7eed397d1c0acd4599d7a8880c60e83c",
0
],
[
"000000001620cde7948f7134c978193490736b7ed19effdceeb142ac5c60c4f7",
0
],
[
"0000000000010b0daf2238423537f93efeabece0cba64ad3b5f8ac73c7382a99",
0
],
[
"000000000005b0676f677edb2b6710516e06bba963c09dafbfc2aac314126423",
0
],
[
"000000000007ccc73eca4f241bc1ff75302682c9fdb0939b6f706b9da00a52a9",
0
],
[
"00000000000a2a1678999a3493f1d603901e35518d90b3e739ce6daf127ebe26",
0
],
[
"000000000003b394dc98407ee9fd88e4f4e22507a788f915dcf72cc34c87cadb",
0
],
[
"00000000000351a1e8062b8fe34889239ba38ca52b3f7e44c7122fc1f0fe6e6e",
0
],
[
"000000000000df60569c57f26ca8796e636eed1357c8f0369d0821c0919ca7bc",
0
],
[
"00000000000087b5af3cfb531ded71bda8932cc80a453f22ead0bf93f50b08e0",
0
],
[
"00000000000057424fa933cffa9bd1116892a76ac912f2c8ee7313b2bdee3351",
0
],
[
"00000000000015c3e9e26653d1ef02aaa6d024489f90e83588f5f9ebf28d7b63",
0
],
[
"00000000000009016aa385782a3ec788fb22b41412855c89aeb73147743a6f19",
0
],
[
"00000000000001317fe059d7442ae8afc5aebccaf4d37ed31813a5caa3638c87",
0
],
[
"000000000000001d8f256752a735a1de33b92a928052f71b738d19ff366db867",
0
],
[
"000000000000001e9cf12031d4ab136ffd2fbc280607c4f996685d4aef460a11",
0
],
[
"00000000000000211e7a6e837358cae29446cb9c6dd5aa15c2284b5314f3bf46",
0
],
[
"0000000067af625d738843195c7dbf37d509f859a1875ae674e9a1dc8ad89e0a",
0
],
[
"00000000bba6b47cc6dcf36f0a6e33c7c60e9bb23ee1f72f62cb4e90d7f04332",
0
],
[
"0000000000041dde2cdeb55e44cc00c8d4c1f448c9220ed2c0153de7a2d55779",
0
],
[
"000000000000288c3e28aed36614437d861224fe8ccaf182e727b3e1fe1d633d",
0
],
[
"0000000000047b66b2cf5b617d9e7ece2ea2be7f886eca7e7f8f831a4c71a8e4",
0
],
[
"0000000000007a9fa498e65a666bd261e9a5ed00e6c3026e7916554841bea631",
0
],
[
"000000000006b245a6d974176dde00529110ea44bcbe8f78c567fea3e47a3d78",
0
],
[
"000000000002e1faeafaceacace03930ea6ea7d8d42ea4cbf141c2b578f3128b",
0
],
[
"00000000000167a7dbf143b59eaf118c2eeb5556061758b56981a4cbc6c7a08e",
0
],
[
"000000000000a352cbb22a1652956aa5d66610b696a19118b933048538192d2b",
0
],
[
"00000000000002c418d64c823e2ec8c77220717c0c7fe4e34b25afc4c0bad5e3",
0
],
[
"0000000000000d7373e7a596fa105a5d1b7dfddfbc274d45c864c724f0b9a2fc",
0
],
[
"00000000000002c3c89de3acbb14f5e38a6f5c3aed34d4c6c45b2222fc0fe3ab",
0
],
[
"000000000000001178732537c78ae21bbe8f9fed898f3b2c63692b9c93aba4e9",
0
],
[
"00000000dd9c07faaa65b8ce71e266699422567278b94487e9ebe4227d1ef2d9",
0
],
[
"00000000d226cc764f56ca5ec5a62562cdfc1bf3a4435350f0a27afdc5f94a79",
0
],
[
"0000000000009baebc276aeb84b5ccf3fd7aa95efc67c0f982bdfb084e40e9df",
0
],
[
"0000000000005d841db70c88b40085e6003fa220b0c91a810e04fb010e7f84e1",
0
],
[
"0000000000004a5013716cac6fe9e3d3c91b5688463feca69fc90045f00e2a17",
0
],
[
"000000000000cedd7a99d53af44ec7144316eec8aa0b04164f2618d090fa32fa",
0
],
[
"000000000000e311840eae32a946c81d8ed9a7fd5d1788444b5376a56def23df",
0
],
[
"000000000000c3babf9150364160f0bc91c0cafbf63849cf70a29574ed12751f",
0
],
[
"000000000000521dd5efaa7a298cecdd047c6ed85f981ff9185763beb9519c49",
0
],
[
"00000000000092695a2324a45f524b51ab700b458e5995790522f9d4002e374b",
0
],
[
"0000000000000925141aa9c81dd875e55a986bcec38a9ad9e627c81ee6437a88",
0
],
[
"0000000000000ae07f7535851cb685259a447d1ad5d3206fc4ee3693bb7421a3",
0
],
[
"00000000000000d41e23f89aad486957071e016d836382605770b65d7539d161",
0
],
[
"0000000000000016b6843174d892b13a0fa39cf807879b56b723075de7492118",
0
],
[
"000000000000001a7c215c98d09179f0558b18f6987150cfcf5e57afca65b98a",
0
],
[
"0000000000000027150d0a4ebf9001e210ebed81ab239535aca8fb5a489a1ead",
0
],
[
"00000000000000312d9a4fbd4bbde5e3f2266047e65f9a5e84474d62afea0514",
0
],
[
"00000000f82b6cc148557cb060b8cc3d697e38250630bc2e7188ad4500291b5e",
0
],
[
"0000000083ccf3997bad3cb32d0e46ff7875a0f454a3c48c2ff910d010801ad2",
0
],
[
"000000000000f40bd4d7c3d374a984d0c8a744c3816b713e8f43b9dcf75f7848",
0
],
[
"0000000000002c0374e865c02fed16da853c3086a28b0c212591469c95a71205",
0
],
[
"0000000000008301f16f29f38442d1b1f521650eba2382ea1e0055a291ca6422",
0
],
[
"000000000000a954b023180407c904341edba6375a982627b0724afc56f16505",
0
],
[
"000000000000c59c851bff2090533bc3009ae76cb1ee89e247dfaf11a5c77e7f",
0
],
[
"000000000000d779b30aab849a7f8a3af7f283bb95579d2d05714b6c3aeed955",
0
],
[
"00000000000016bca79f9de99fd3d0399f812f0fd5be4f84bd7ee442b846498f",
0
],
[
"000000000000dfdeb639143c64a17b99b2025a7d9bbb53e993ceb7c1656ceac1",
0
],
[
"00000000000023e3d31bc565be6041cad487f77bb23109aeaa804d72bb22d6df",
0
],
[
"00000000000007ebb67ae92e144382f52aebbc63a4604c8a07bfebfcf8a19546",
0
],
[
"0000000000000136c4a1582c01a5824f4fde4ddf91d653899b53994c4da9a3e1",
0
],
[
"000000000000001c197b662a51a6b9d5a4eb9521bc52c82f06aee07e8b58f47a",
0
],
[
"00000000ff1ae8e1ad7dc6a82747e125d99e099969d5fff2f193246529b225c9",
0
],
[
"0000000048af658629f65a2ef6052fc8cca3234f33d3fc329a0a2b4a73fadefc",
0
],
[
"0000000000008cef9b1eb41f402fa0f1f6a1ff6641ef3484d7decd9ceb7f1efe",
0
],
[
"0000000000003ff9199a773f976eda5420300f1f42f213d9d793ca002c17a5fe",
0
],
[
"0000000000001c6f16503cf6ce37c09f31eceac19e9a46eda67061bec5c6abac",
0
],
[
"0000000000002ee7040adca7f697117c59b8df1dc65519e3145d720b01add98e",
0
],
[
"0000000000009718224588a74633c646a7539d05ed503064e38f7734b146be9e",
0
],
[
"00000000000046fd759769b3296aa5636c3d113a309d633743e092b463072842",
0
],
[
"0000000000003594adc1bf018bbb36c059ac293164d83357817eb9b7b3ea320a",
0
],
[
"00000000000069f72a110745c74ead6d9f488906ee79cd2e6dfaa77f9371c300",
0
],
[
"0000000000001763cf5fffd8e1b0122a7d4bb0e1c2cb17bdf22fa25b70fa6e49",
0
],
[
"0000000000000c91abea6900d1ee2c168bc34abef1260776a164caecaaf283db",
0
],
[
"000000000000034624e683ff5df51b1a78fe027c67633c77258d3b1fca48a124",
0
],
[
"00000000000000b8afa5359b5cc460a77b047cbf8b1aaf640b8779c036c1cc77",
0
],
[
"000000000000001733362d084551627b7a71cf84b9365d4a8b1131d8e1f0fae9",
0
],
[
"000000000000001c3cd1aef22fb58f8917110976d342a0573aed0a466702adca",
0
],
[
"0000000000000015a4454fac29770dce9fc9786152a68807322ef00d74da0640",
0
],
[
"0000000000000005628d26b0af6507d1218a6665e8e6867ab37b84a69ba15cd8",
0
],
[
"000000000000001551c40d57827ecb548a09f2512ab66a3d3dd86f00f8083ae1",
0
],
[
"0000000000000014b20ef449f75f3c015bcef0e19d302e440f9ecb4c183300dc",
0
],
[
"000000000000000e814b868e01fe7a4a78d966e4ef73f5293c633005ef5718dc",
0
],
[
"000000000000001e4d4ba22dad9356f9753b1065765799c080807a075964f8a8",
0
],
[
"000000000000000c2726580d5aaf194818abcb0dc9275266fa6604b792dcc41c",
0
],
[
"000000000000002660dbfdb21d80939f5341395eacbd2edd67a61abd58044345",
0
],
[
"0000000000000027fb8a498f621a696f6ff1d9b45839940a0a45700f2e211bc5",
0
],
[
"000000000000001d3fc884a35029348110a0af44cde5f299c89a89b2f5e3c1e3",
0
],
[
"000000000000000edcb8421d37f2d46963dcd2ab31a0359574b49918627c4772",
0
],
[
"00000000000000148683ea86525485d22e85bda982e8682f5010865ca3ff3da2",
0
],
[
"000000000000000329cf100ca7c05279430275cedc3f4573dce6ac1d418b7734",
0
],
[
"00000000000000054a4bd09b4cf258f0bdd86feb97fdc38d66753f3e04a70524",
0
],
[
"000000000000000faf34df569486fe78f0b17236e2e855a507e5d36759b95751",
0
],
[
"0000000000000008c72a42e89efffa7da953b94c2217f50328d23a098933d6cc",
0
],
[
"0000000000000db7c34e4a5e1e2445b7f4e07231e5736251103a1d1dc5b5943b",
0
],
[
"000000007795c87d221390511e079257789f1563bcc772571e4481ca3b448832",
0
],
[
"0000000004e5b3b03b0223706a7ce40d576d64026784c2ff8614e6562f4d018c",
0
],
[
"00000000004a0ea723460162104583b750bea77bac8c967d801c11f5058ccc04",
0
],
[
"0000000000000ce34ac861ba5d727e47e9e19cf0db6df7926ad1871e9b94066e",
0
],
[
"0000000000001ac9fe6e55ac14cb60f3bc8e2bbd3e35b86a39a64aaa8b71ca54",
0
],
[
"0000000000001673268e6db4c8f163bfe25914d4bd50673da5715d8313b7d191",
0
],
[
"000000000000068296732b209e33279ba86496a74bff278c6b66e8004c8d29c5",
0
],
[
"00000000000091c08da0ddc2340050cac7b30bc5c1c83dd3e7dedf5b08dc3078",
0
],
[
"000000000000770a2e7934ceffff83c29b049fa202759b4faf4598fc0fa67ea3",
0
],
[
"0000000000001b5d77a10cd4842db233e04c268e57e3b2669aea7701da12adf1",
0
],
[
"0000000000000182a40214f1c538f5a44b696d630664456aeaab29264a6f184b",
0
],
[
"00000000000001245d616ac4b49515cfed74ba0ff4b7e8934bf18848075937b9",
0
],
[
"00000000000000321a47c18d57d5d8d058bfecb43cc19af593359a66851fc605",
0
],
[
"00000000000000231e08d8080db64b437e37f17295c82b8561e528829970e9b2",
0
],
[
"000000000000000fb903e2942029e3a65aa8d26efb48aa8537aafb3314ff6d60",
0
],
[
"000000000000000109729ddaea18bfb7a1f3dda86e11332946cc34d27a988420",
0
],
[
"00000000000019477d9b273423ac45be472ac63d88616e5169efe9e3bdb03fb9",
0
],
[
"00000000ea0c1249ac03ea5667ca8ff4f327468d873b6a9bb78206e9c3b8cd63",
0
],
[
"000000000006321457285a8cca551c77825721f502b83ca06972d697c9e5ce1f",
0
],
[
"00000000000214094cbb0ffedab25fb82fcb3db22ee6031e6b952b73fbacfedb",
0
],
[
"00000000000541d2b25e067f1707b06d91f19351b84d41aae164ad41facec281",
0
],
[
"00000000000164bec334a2c1f79e4af9dce78d838d573b90fc5acd16f544b3da",
0
],
[
"0000000000030c337a7f2bde97cbf6a7c71a0b4d24e1e4600e46eac009069221",
0
],
[
"00000000000555a5e32651e24a8b83f4aadfd689ba44d49e63329bd2ed484078",
0
],
[
"00000000000207e99a06cfc158f5d7d8cb27234cab15ab00fec24fd8b8956aa4",
0
],
[
"000000000000c819f87342c54f0d4970443264a296a88bf38848ef1bcefa05af",
0
],
[
"0000000000001838f689e4d297db56c3d50753378cb2458dbb5f4392ea90c585",
0
],
[
"0000000000000996e7334726403dcb4d2818096f645f6782222e0c9fa8ab366d",
0
],
[
"00000000000002502c65e6a2ea56681d90d9cc6e774929a6ac17c1adcb0c6aeb",
0
],
[
"00000000000000f2b4ec952983f6e68cb3a46d34738c38a958de3ced1da54e42",
0
],
[
"00000000ab4d311932bee7b754ad97e081cc24b0225d3089dae7440fc084c623",
0
],
[
"000000001104264440198251f8c9046dca4a3ffbacbc48be03baf996c3a48094",
0
],
[
"0000000000023cb28ed8ef3637a80e73ec92ea7210088053002be02818fb9f98",
0
],
[
"0000000000002bb738283a319f1f1b8bbe365b7f6f295982c29836d193808f6f",
0
],
[
"0000000000001a15e8f691a8bc14f3d694abef473c5818017ed391615b45fa39",
0
],
[
"0000000000733fe4dca7d1dae0a748bbd9b3b99e687b97281b806a199e35ca54",
0
],
[
"00000000002636ed79a1d367fefb464aa532b26dbbe8687ffa5d5f26eeee06dd",
0
],
[
"000000000006f45c16402f05d9075db49d3571cf5273cf4cbeaa2aa295f7c833",
0
],
[
"000000000000dcaf9949232b5cf92cc2976ee59521f93ec2197a9c762c6a3c54",
0
],
[
"0000000000006c7706b78284427681abcb62c432adc364975590f3b33d94b773",
0
],
[
"0000000000000efdfdbe08218cf961d25cc1956d464a9f067f25545301b79222",
0
],
[
"00000000000009ca0d54316bdadcbaa48c53fab88b9fb0556472ed9e91751602",
0
],
[
"00000000000002395eb7f05c543dbbc241cd4b5d64b3c948f64d8ac2083b197c",
0
],
[
"00000000000000e9883c7e7284799cdb9ff4e4208a313050a182172c950f7e73",
0
],
[
"0000000000000010b95f7caba61a90def8a4b527ce0574092718f478af780c10",
0
],
[
"000000000000001fa1eeb7f86389fada9fa3e05a8e497c23a08ed6a1dda63a8b",
0
],
[
"000000000000001086a413f0cebe3ab3aad747f6b34a5038856592675f4efef9",
0
],
[
"0000000000000028a8f135e691760cbe5c4b7a0d88a4318b8fb353aca220025e",
0
],
[
"000000000000002460d6c79c6e2cde645eec7b64d4fd48a1c71d756cc9c3dcb1",
0
],
[
"00000000000cae97fda2191050892ea192e7c15881773b17e3bc1331822fe4bc",
0
],
[
"00000000002d5f11ec72e37b756a94058c299fc647d1603efd6067e20c24d306",
0
],
[
"0000000000035d32b34aa575bbf8420ca6caa6646840539c47d842c8b6700771",
0
],
[
"00000000000038ea640ea920adb8ac40cccdc56ada27cb52e3ae06ba0580572d",
0
],
[
"000000000063dfc0373ccbd4d400980fe603c0af468d21d3ff8eb568b480ea65",
0
],
[
"000000000000a46891a576d73cad07d20f3ad9308657bc676b12a4f066915ce2",
0
],
[
"00000000000d58b8cf55bd312e564ec6d2959162b546dba8849b0e4e0dae37a2",
0
],
[
"000000000001e72b87fa955829ec8dc21878f11db8181c7475e2d03c79bd0e13",
0
],
[
"00000000000032848796aec72da3f9dc0ed83ccb99023e9afafd7f3d9bdf7103",
0
],
[
"0000000000002e93d090b9aa138cd12a5362e0cd4232a3b96561fa7bf280a103",
0
],
[
"0000000000000dbc7b9754521c68f2553265437d589fb6b2615dfe4d960ad690",
0
],
[
"0000000000000fe4edb18d30fbc59db0c34a39b33c878108825a7d8df4d99b2f",
0
],
[
"000000000000003fe4956957ad4d5c19c79613f9840ab51bb841da535b449861",
0
],
[
"00000000000000fd3eef340a6953ffc756eab83dbe091bda721387f7249835c6",
0
],
[
"0000000000000062e7ccfc5414355a0a1b5151b496d1b77abe7606920c0f5251",
0
],
[
"000000000000001f6d8dc4976552a596eff2eb0df15b0d9ee61a55091a2050c2",
0
],
[
"0000000000000018a7ce07d0ac46c3eebd72bd2db0db627675e146fcf9278e4e",
0
],
[
"000000000000000be86cb1664031f8666023b52d247063327613b00619f66514",
0
],
[
"000000000000000c1589a255f9fe686ee448e8bf60424529c1f842b71bb317c6",
0
],
[
"000000000000000a2d14a86314974abad5934ed38f63276fec039d636f33d652",
0
],
[
"000000000000001737d639951d593f9d269bafda7d5fe5a667d41f8d8d2c9cfe",
0
],
[
"00000000000000033f81ecaba0452707d61b7e76d1b18809b20db18de30f3b00",
0
],
[
"000000007f31278326e2cf458be3fbc904d4f98f3b348c8f2f3042d590fe2ddd",
0
],
[
"0000000000018ca40c2e36e0d484b57d41714c2bf5bf69ab06eb214c252d63f3",
0
],
[
"0000000000026de8f53cc5ecf634c484105891ef12e8abfc3e83d750c07d89b5",
0
],
[
"00000000000d340049196286b501419b72c66bc6a45ad177690e0ff641c6418b",
0
],
[
"00000000000e70c72347a1043a7e1fd35483242689618a5576fdac0845dc96a0",
0
],
[
"000000000005aba9cf40fb0f07b3226bddeab8b997de4e827ff9d9f7198f7ffd",
0
],
[
"00000000000c1c2b6e4b064438ecedc3028edb111fa571062d62f84912f407c0",
0
],
[
"0000000000016e1ed29108b0224b172a3247f61b7589526566cc62ac69ce9b5e",
0
],
[
"000000000000f0e2e3a76f57f843ef8823216e1789c9cfb97f17f87b719a22fd",
0
],
[
"00000000000022998f49c0c1a4519125272270b9d59857f0d302c76017171c84",
0
],
[
"00000000000008588b178655052953dc2eccf8c0a0648f15c1d5ceeedda91372",
0
],
[
"000000002cc217b3217e3016d6e6eba2584619caacfe944342ae905fe24c87ef",
0
],
[
"00000000000030120f45e78e7852729d6925cdb3c5dbc87eb8f167674e722bf3",
0
],
[
"000000000000355aff4c8c416f3ec39b77071a6614cf604c213f3e52a405a221",
0
],
[
"000000001d5087abd326f27fcff310c58999d8881e5bf0cf30c826177680508e",
0
],
[
"0000000000002393b75ee9ce1a0433cbefb3fad205406fab65bbc0a61a757149",
0
],
[
"000000000000c57b6c1be1998660d54eb13f10ba3371cb735859dbb7c4396799",
0
],
[
"000000000000bbe163a8a5f68e9ca2c99bf9af0a9fbecad4170384a0bf907b26",
0
],
[
"0000000000005d2078380dd0389664a0de79dd7c6a8c1b94e0522b0bec15f74d",
0
],
[
"00000000000029b25af79b8d27068c32865d2a41817fd1a34586ee280c7455ad",
0
],
[
"0000000000003af3be1fa30b8c225a0b9061aa07e3389cb44161d11b15ee59b5",
0
],
[
"00000000000006450af5e500b7a650da7a2043c7f3936e3aa93cd08c22457341",
0
],
[
"000000000000a012ef365be0880e3d7ba88a9a8378429733d608c6cb6d7fe59d",
0
],
[
"00000000b3a9e0680e5ecae9a639b6ccb19456c59eb66ea1b53495f8cb579874",
0
],
[
"00000000015eb6c7abe02adb992d359926f511d9830c6a18b8cfb8b3bec85cc3",
0
],
[
"000000007699589244ef2cc09903aba91032e7e86d4e773ef639ff0150db2fe7",
0
],
[
"000000000000fe9483a6e60c2589355742d93b30406a2d273e10ec58558cf34c",
0
],
[
"000000000000b02a657620c34404936792268e4453882f203ae7add74573083b",
0
],
[
"0000000000002c7630b21c1ec243de251dcee944ef0ec3cd9d559c34f1fa7d4f",
0
],
[
"0000000000018c93ec3fde56ceea4839e6d08856aca055508c562d96e16dbd71",
0
],
[
"00000000000015db7b745e849b5a05399ef66a96d31809fcd79556ed7479965d",
0
],
[
"00000000776cbc7a008bdfa21270d6f18c58d8a794dbf7355cc71ea8a0c8c063",
0
],
[
"0000000000002ffc1ec2f0f2a757d589e452794b76e50366a452ad3d318f15c2",
0
],
[
"0000000000002d727d581f7f0a93b74f5585e62dcc6fdc9cc3b8b19eff10f700",
0
],
[
"00000000000010bb6ada118d713af3ca721db8622bf222477c3d208f8b3061c9",
0
],
[
"000000000000e03cf9e4498fba163d2bcd2646991441169481b940d291fd075f",
0
],
[
"00000000000026e1da1de58906d3d12221a79250f10abb77623a230c8a62fdea",
0
],
[
"0000000000002475dbf8647609a128ac7211c4ca7b728a989f1b5481e626a328",
0
],
[
"0000000000001f2e545dd92ec4a9410e3d6a5bf11a6bccf97f049310538ded2f",
0
],
[
"000000000000ac4ecb8bc7ff25f1aa9633a9cfa28d0bef19870c51e0a9431f30",
0
],
[
"0000000000003c493ecc2121c5c3bdca88a141444b2631d6a9b720a504bf455a",
0
],
[
"00000000ad4ae3258584eca0ac04c31af2ea25d1d2b811279994c017136d160c",
0
],
[
"0000000000edeb17c5a1443fbd6f9f9ff20a1691bde5fa42ced3fbc6413a5e01",
0
],
[
"000000000b61f092a5c1660d0f8f6fb3294c9b065c4ef2943a2955a2d03c3708",
0
],
[
"0000000000000ece5c82d89b13143a1a28db9b12bac561928c0a1d709faeb479",
0
],
[
"000000000000000929103fb8a1aa3f8215c61e990f02cc6085627a6cbe197e00",
0
],
[
"00000000000002c9b7ef56519aeb047fa614198081393923ce8f78db40e7ff43",
0
],
[
"00000000534f7159b8c190680b93775e91460d5203160789615a92e821e3beca",
0
],
[
"0000000000003d1a6c7cf11ac53c59a12fe127eb9c2058cc6a2e5229136c8c3a",
0
],
[
"0000000000001749960590ed08ebd150823d21b32020ad8b219adce32e7344c8",
0
],
[
"0000000000003384f9d5b10cb0321baf1a99b5f37458155ab794649b12eec2a4",
0
],
[
"0000000000000bd9def7511faf3303d889daae01de92f5716a6409c44f9ccdba",
0
],
[
"00000000000014b324e5afdc4b5899942a9bc776d9cb00ec2af5b795c3a74fda",
0
],
[
"000000000000415e1f654f779786094eab0dc703010b3f686baf7defb83343d0",
0
],
[
"0000000000001c9b4a7c7e351e10f47852dc9d5c6b7c7c4518591fb6ddaab3a0",
0
],
[
"00000000000052b7b9fea051b3fe3244d1b86516aaea33f2d5a55e977fe9c026",
0
],
[
"00000000293536c90c630e207de478560eaaac89ae8afc33333aa7963dd8b7b2",
0
],
[
"000000001a45d126188b331059d04a98df3588887adf6a2b520225a2a2b03567",
0
],
[
"000000000010a96f7c94770392a3fb38777d9d75ff755b5043919475ac396121",
0
],
[
"0000000000007e19b090529e49795e4c115c7f00d327da0568fec93d542ec878",
0
],
[
"00000000000016a2c2a305974b890779ed07afecdc45e9de20397f52a88efe36",
0
],
[
"00000000001588ca17c8d2f9051d75f085daaa519b85c7e25d14b5871c4cf25a",
0
],
[
"0000000000000e31ba2c3036fb984c5faf3e2860e41799aedd75b469287e8f34",
0
],
[
"0000000000003236b8dea540661d16c62aed83879951331d7ce97290a761006a",
0
],
[
"0000000000001f30b54b080cec64d819fb2270932883cc048b7e614ff3405b1f",
0
],
[
"0000000000002d5f3ebb70abd3119117802b2878af6fcefb58f43794a152b6fe",
0
],
[
"00000000000001ba1b76793d0ef69a1cf35b8af6421daba0224db06bacafbc1e",
0
],
[
"0000000071289acecc04f86e0d71e4dd9af80c1d62d7d3d8d2a4730b458c1694",
0
],
[
"000000000077d6b5ce95ac4bb2607afebae36fae00f4d9f668275a1d42025a1d",
0
],
[
"000000000001f970e0880b7f3f9456e5b9211d2e9a407a664a0cf5d79fb0d07e",
0
],
[
"0000000000000c5cda8f16ddba06e9307903d6e75b6f0fede174f9d1214e85af",
0
],
[
"000000000000339de4dd4e26cfeb620ee6958b460f30d7bfb31f027736a51fbf",
0
],
[
"00000000000039e58cfdcc21226f2499dc5f88a8c28e39c20687d2ad5d4f41ef",
0
],
[
"0000000000002db5a8e0bdced8ffd6e4803bc51a425436e2c05f90a1c69cac03",
0
],
[
"000000000000c76e1a3b84465e3ef6de278a563e602e2e9040c18a19053bec8c",
0
],
[
"0000000000002cd1ffe2aec0e5d9d6147b21e89f638b3960daac6c5ff98f6083",
0
],
[
"00000000000016305181caa8d88541b5495065c6231b67cc3df5430b4d0b8d46",
0
],
[
"0000000000000161c619a838b35752b87b780f702d0e314a6acf517af3e36232",
0
],
[
"0000000023588058c8895766d31ad2c44ccee1150dcfdb6218add8711b205544",
0
],
[
"00000000004acb3364caf13a892af294d3ac17340c3713f38e866647e7d2c2d9",
0
],
[
"0000000000c238dddd6860a7b597f0974e5f870b39f79f00035734e287d8891c",
0
],
[
"0000000003f232ff7c873bd2668c438e93974a157e8e44dd6057a23c406a04bb",
0
],
[
"0000000000003684f0ccaceb330a90045b3f5b6e790c55fd9214adb694bc6151",
0
],
[
"0000000000000ca6a9286942ffa849e82cf75b15f785bcc0be82009625341703",
0
],
[
"0000000000002a7dfd1764890b04221aae58e78638dad039a952be3aa2b270f4",
0
],
[
"0000000000006b39c0cef1f968bfa7373478222c7804c47671e92b81df352e0f",
0
],
[
"0000000000007f70368bff3515e745dbd8da7bc9bf5846bbec997c76c4e9f598",
0
],
[
"00000000000016ba9493ea10dfdc68483cdd3a1246bfe5a42c3042fe46e31452",
0
],
[
"000000003315cf62b16bd31e5ffff48a77c4332466e75653de573596dc9e6811",
0
],
[
"00000000000112b139086612b6ab6d98836eff016c28da0b68cf51a63d1c5ab8",
0
],
[
"000000000000740c7bd5664ec75f595399815676a1cb31da175f55b216c0489c",
0
],
[
"00000000000040e34183c66e6d8803b523f132aaa0c53eb0d27581f1cd202fdc",
0
],
[
"0000000000212b96599a158f45a8f8ace26be06d8c77ca7b5dc4f4cd5d2479ee",
0
],
[
"00000000000047ec63694024c7f4a742b506dcdf87dd6d99ef5166fe070ffb20",
0
],
[
"000000000008bdd2ec2fc909564ad5770d9ab7f88c95c7a2b0efd6452b7ffd12",
0
],
[
"0000000079fcb43bb7271e55d15c27b718cefec8d1d22a1cead4061896d25f12",
0
],
[
"000000000000e0d307898c7a1ef362c31bb7c4436075444c6f8cb235f02239a5",
0
],
[
"0000000000001a9256df4ffa52b4e184e713b5a0f5315b0d4ce1a3e77c916464",
0
],
[
"0000000000006b3b9c9117ab64f197146a313f0874535fa4c9a3de4fc37b0461",
0
],
[
"00000000e14400a3e77585e044d3a526fad991db8af00392286b7fc143e69b12",
0
],
[
"00000000000e8b4bd2bdd5ad3c06cb2fa234c271ec25ca87ce3941dcbc49afa8",
0
],
[
"00000000017ed9122f0371f08682c5fe6aa8418b265efbab0993b1ed00a89f45",
0
],
[
"00000000006f039a8997a1b95777b781d9650bb184cd80eabab31aac94a278f1",
0
],
[
"000000000000e4b80abe3cd2441ea4eb0a5cdb6b89c0e8ff3f369cfcedd0cb68",
0
],
[
"00000000000026117fc0fe66f1a60bd42bc2d091d13ffb349dc60277da717fb5",
0
],
[
"000000000000085133f8602b7408ee4d7b9e777174d520da980bac6a2bc746ad",
0
],
[
"000000000000b0429a45f601bc84f00778ce1e7f5c3636a7c215ad6721c7927c",
0
],
[
"0000000000001cf9d3f7f37d9c3999b05d52e6ba5434931741aeacf262bcb9c1",
0
],
[
"00000000000007f291ae30aeb2f85877116865e06e3f10658c3b3d8de7ba00fe",
0
],
[
"0000000000000c460fafe518178f959512c3966bbf6569869e51ca81d69533ba",
0
],
[
"00000000000001ded7aca43353f5258fabb96db1b785fab0daef4c427e9fe170",
0
],
[
"0000000000000002bb51deb7934ee1abaddb6607b267a9f7947f279c2848f064",
0
],
[
"000000000000004b2fac327d5bdd9b7e12512547ec70fd7c3bfa0e73c8bc17da",
0
],
[
"0000000000000002b5ae6d256f769f4c445f595277e679ff34b78dee498566f9",
0
],
[
"000000000000000760ab2425e4fbdbdb5fd588b5e2bbc15d57e1431e90699fdc",
0
],
[
"000000000000000047c410d3799304f0e61c423993f6442291ae77cd06bdb113",
0
],
[
"0000000038cd3783bb8bea435b42e3870544e610dcd642da655faf8c4011d455",
0
],
[
"000000000000048d4df1b36218595e7626bced446edf8efb8394b68a3fbd1e6e",
0
],
[
"0000000000003fb4daabfe5cd01b7da1808668ab18c68353d1ee0546453682fc",
0
],
[
"000000000000277e15671d375ce9a4499b66f1f8b33c4f6afd74b5ea20158614",
0
],
[
"0000000000002ab11ec0ab446ac363dfc93a9bf09a28d83a85b9bc163d5fa5fd",
0
],
[
"00000000000002d74bb89f8320bd80b07f7b8bc4b77987039e6fce894d63c966",
0
],
[
"0000000000000f1c86e38fec5fb04c19440c066205f89f0ba151cfad2a41cbfb",
0
],
[
"0000000000000168baff0b9e9c678a5153004f7f241f52ef1e49d88c1bad2ef6",
0
],
[
"0000000000001b3bd58011210021d92656719030cfde3c033bedb3c1e6a81eef",
0
],
[
"00000000afe6a4017bfcd2c3c9a21efe4c41951888a9c97c5c3d3bba9f30ea68",
0
],
[
"0000000000000836cb2bd14463313660efed9d0e23339fcaa97c54295e9716fd",
0
],
[
"000000000003e336698dce2d615f693645b4d04f8e5f98d02d94fca99bd5a60d",
0
],
[
"00000000035b7a00de4f346dfda49c16db195867f3c395d5d83785acf51908a5",
0
],
[
"00000000000018ba5cb649b9e7a5458467e7275e4e3284dd1d2dee9f0a64a2f2",
0
],
[
"000000000000239369e70e689f53bd3ab435c3eb6e5a26cccd52dcfdab18b255",
0
],
[
"00000000000030694285ec9b1b6349dd97757b16355fe07de048f969119575d9",
0
],
[
"0000000000000657b62914a9c16779754d11d1c444b3bd0965a571f7afdeab21",
0
],
[
"0000000000000e1429ee6f6266eaa4a21b71335503eb73bdfb4f736a1d9fcd3c",
0
],
[
"00000000000021d4eb583b65086025e1147e7783b71b61e94b0b1d01dac32479",
0
],
[
"0000000000000afd677ed22ae10ce1ddc3c9f3e73e1c1d1398cdfb581cd43cb3",
0
],
[
"00000000000003fdd00758300566d8f27cc9461e70c64f3b1ed8e20c1c2f26ff",
0
],
[
"000000000000003057478e768b1d129fe5f1acb1a99818df164966c68cf22471",
0
],
[
"000000000000000072bdeb5162f6c77003778bf3c77520028687e0137a58d8dc",
0
],
[
"00000000000000070be95e5fb581cd857fee7617219ce4047d31aea097cf9fb7",
0
],
[
"000000000000000737a7a678467547316706f3ceaf9d8d0a56e50d63bb834a74",
0
],
[
"000000000000000591b541ed7088c4ce52fd10a0b99a4b5db377a3c1ab198756",
0
],
[
"00000000193b4863d9b143d45b6db44ab8706e1eb4cf76e960b6390d8654f317",
0
],
[
"000000000000d99c10079c94e38f6153dabe29d5b0315968016e70049f0ba72e",
0
],
[
"00000000001603022822335be28b6e82ae1761ff293913016f577fc6db2e2d46",
0
],
[
"0000000000002ad9d4f5d7246b599fc9acdffd10b30eeb1adea05d38ddb9986a",
0
],
[
"000000000000050756381c62b7009c293513d5141798b5061530fd74aac98bb1",
0
],
[
"0000000000000dfe8be3b1e0dd83a4babb473a79242f40264f21936baac8facb",
0
],
[
"000000000000f77717ca2bb34b74dacd3ee6cda1e660f18498a386da0a884bbb",
0
],
[
"00000000000a110d1be3a230af9e48d99457e00a17ace1b3c5d425e95611bf05",
0
],
[
"000000000003d50b2c17cbf2d5df1556f0849a33096d72bdb3e64c426202961e",
0
],
[
"00000000000067dc944a93268621c566e31b879bdab5c1966510d73254aa48c8",
0
],
[
"00000000000035fabad3658e0d84928ec43f7b66b9efb872d907e1af0a86dde3",
0
],
[
"0000000000000a1a7a49cee85178df151625b1f9fe1857ad2bf0d47aabe0c2b5",
0
],
[
"000000000000021c7bed7852ba91de97ffea03f5f3ae1d6b7dfdfd002ff6b01f",
0
],
[
"0000000000000031bdc46f3de83a4d5a9b164348aa33393ff74fb67c22f73dd9",
0
],
[
"00000000000000388320f1a185ea53ee5bafeb4bb7b23ae05355db3ec3b9f3d5",
0
],
[
"00000000ea0ddfa9fcf128f566b08d4341b608b85d2d0cdd05156a6b5b49b663",
0
],
[
"0000000000016eb90eda23984b9615fa7f989aef9b339d8521624793c32aec32",
0
],
[
"00000000249b02d570ff2e7a1886eab612d591c58c15caffac878f9d7e69a2a2",
0
],
[
"0000000000001601a74f55eb45449c945c069cd228bbd28b662a4cf929a07b26",
0
],
[
"0000000000001d54ce071b6e1adc6489ead4869d27cf4467e09865417d7ede04",
0
],
[
"00000000000006b263d940ab956c185c9e8a39806c5d3ac9f04dda3b8cade6a6",
0
],
[
"00000000000018eb028b23c464eda0f6addbb9c36adc134fd7300f6c3306df95",
0
],
[
"00000000000008e0b67636b199f21f04fd3d413e9678c39d27e30536a1b249c0",
0
],
[
"00000000000008df86828e07c01137240d45cf9ed5fff249d071d45786d1f4b3",
0
],
[
"0000000000001bba9b529e6aa2cf8cdc83264c806e079f5127831fcf44166bb8",
0
],
[
"00000000000025e87dc84bc04654c2b30a87d862c0a3420ecd3084670b647d7e",
0
],
[
"00000000e916d01f1d6d4f170d88cd65a778a940ce11c55996a397f21030dfcf",
0
],
[
"000000001ea4d2da640a2a166e8a51a3cc5557fca1c2de40acc810ed9f9d29ad",
0
],
[
"00000000000018a01a5f01b8b543f5c211f55d532a03c9a04384ae4257bf61e6",
0
],
[
"0000000000000690cedd07d7bdd3ca2bd2029abe58919600b4fabe99fe4e412c",
0
],
[
"0000000000002ee49f8029332f30be5783dc2edca08ad279f114bf6c231d1dbf",
0
],
[
"0000000000003aca3ef92013e9c559c124b27d9df45bc5b9c620f30987cd6d71",
0
],
[
"0000000000003b73bb514fba5f8f063fba66c91534f1f6ec4ad49ea89bcaeb4a",
0
],
[
"0000000000001e461eef655c3ee4f013a079ff05e750578cf3195399f274688e",
0
],
[
"0000000000001d16126b33bf95b244af4526a6f2af6afd2d96797f4b592624d5",
0
],
[
"00000000000020dba1c8f9785e2279ea96a7885a13124af2baeeadc75927ec9d",
0
],
[
"00000000000003e1975d0b4a32af071ea8584ef8b575527e2d81cac453e6a4ce",
0
],
[
"000000007cbf5b51fb38ed9058deb8e967476ec30fb04487496f1f7802a9eefc",
0
],
[
"000000000000041a6a7ad96bda602e7d528542ec9694ab3ef72908d59a660150",
0
],
[
"000000000000afa91fc6839e2d12370cafca0ab3679e3906a436e1d6e5ec038c",
0
],
[
"0000000002b73257cf72d60433d542fea3fdb0362158a238338e49d6beb109b5",
0
],
[
"00000000000018fe0e90bfafc82833f8993636604d963ff226d6605a3e81c887",
0
],
[
"000000000000028245c822181fcd9bf9e1f22d6a547fea3ca46de3a3070fc28f",
0
],
[
"00000000debae16db51d4ef186b18f53de6749b95fd75efe3e5f628e85cd1d42",
0
],
[
"0000000020703f051f8a4d4fbfdd21f7a857570dfc0617ffb7287a7b259cafd0",
0
],
[
"0000000000000225bc005b876850b2cdea99b82bd6a8637ecc7f45c49d4848de",
0
],
[
"0000000000000178dee2f53404f5a999e1c60913658ce963a671ba778ec88fb0",
0
],
[
"00000000000013acc2e31eb20cc2f99d213fc962710967d20aaeb2976b6bfdc5",
0
],
[
"000000000000042447cf56d117295a3117a19412861e7b3987846c31ef6b0797",
0
],
[
"0000000000001c84c583dc747fe32cac73edd483cab5a3119f68baa4110929b7",
0
],
[
"000000000000011875b33bcaaf4dc9535a659684fbe4c780ddf88ad607d922e3",
0
],
[
"00000000000017e9433e75fedbf0da7bcfeab74ad1f8ced7dff6dc2bfbd12d96",
0
],
[
"0000000000002cff5e0c5e2d765b92df11d26d4b308146af9c3d971945407061",
0
],
[
"000000000000083c1306d75a0c18b0942d0ad0aecb878e24c164a9caa3fb2ad3",
0
],
[
"000000008addb7a8e8081084ce3290e7f3806ec3cccf747d487f1e1540f7c398",
0
],
[
"000000000000048f3edca928245bf247645385b9a493abfd1b5800ad16cc0d1f",
0
],
[
"000000000000103e9be4a5421fbc8bfcb1551b0f24857ce6c37a1c343aeae48c",
0
],
[
"00000000000015a51f35436fb51d0fece5cc4e436e8a064b03d8605e47bcd16d",
0
],
[
"0000000000000f080e1a6f704a41308db272cde3db3ce7ecec09a95b41964e83",
0
],
[
"0000000000001fe64f07e3a57ba7f1f3a621ce247fbb352b80b2d0c5b70ec6de",
0
],
[
"00000000000026ced680e9c4cfd857f9e3620c09853402e3adfee74143bad265",
0
],
[
"000000000000090b4e9b558c597679770325c9b22acc7561662002db0cf7e710",
0
],
[
"0000000000001e1214657a7da88bd6370b3dd6f4ab346f0a8d94e4e987f87503",
0
],
[
"0000000000000a15c42118c002d59514deb80f9f5466d27963c04330e0e21012",
0
],
[
"00000000000007a242146cd528e009c348cc08eb1f95f4d670c7dc158f2bbfd3",
0
],
[
"00000000000001e36bbd55b594405000f74d9617538306d1a80beca516598c19",
0
],
[
"0000000000000039a5021d0c7f2786e5809c059d1bbc282c75f1b23aab96a88e",
0
],
[
"00000000fbd8390594a4ba46a5bdabdca857c83875b424d25a77b0eea3dc3237",
0
],
[
"00000000062932ff37ac56e6fa111466872a8dd63a40e075cf5ac17711cfc54d",
0
],
[
"000000000acbd85b88534f49001b7ece8fd3bb6a7d9ac1579e266c504d4806b9",
0
],
[
"0000000000000c69ee7072ca34b8e0de031d0e1a94ac4808fa9e0d5d54bfe09b",
0
],
[
"00000000000136712b53c3fa040965e914ae2c77cc575b3043ca721288720a7d",
0
],
[
"00000000000013bbcc926582af7d0a9916b373a8a915c5db4b7fc5384ecc57ec",
0
],
[
"0000000000001d67d8a253a97e007bc9b7610d837a4b152012dcf33e8ec52677",
0
],
[
"0000000000020090573bf3f7cecba4b23ab397d04fa40ff74fea9689841969a4",
0
],
[
"000000000000045744fc4904ecdf15ad85d3069df9992d4077511bc8cf476149",
0
],
[
"000000000137d3cf68eae26792414e81cabe328d7364b5c407e0b4ce0a2f5a4c",
0
],
[
"0000000000000419e67c1b77993aec325ee5315eec3fe8b4d165898e5c77fe96",
0
],
[
"0000000000001cf0d2c8b2717072cd15f060e622bc73414e357324c792e1aa59",
0
],
[
"00000000000018c48cf760d66c867ffcd0e113a4eef5b5f85d0dab65b727b460",
0
],
[
"0000000000002f9b1c3e3dd47a73bc9711b6f755d72514ec5d81ffb9524bb626",
0
],
[
"0000000000001bc7822a17596f813039ba79ddd9e114f81a3dcdc596533cb493",
0
],
[
"0000000006202b1fafe78be2c564ece74bea3a1ebc294497935854cbdcf2533c",
0
],
[
"0000000000005d7567c3851bdbc75d1c6c9372db838ad0f1e6bd472b5a100825",
0
],
[
"0000000002590ac2a684ff9da31826f8354865c081b243ee04abac8e799f74e4",
0
],
[
"000000000256d70b8e6030ee6b56ff47872fb089a5d007adb5057e7c4930d0e3",
0
],
[
"0000000000030d7ea2ccfee60b00002337ba48466d40bf541c27b01b5b40d0ce",
0
],
[
"00000000000002ea99316525bd7497de5d9fb6ae98d6e69008e4036b9f1a96f8",
0
],
[
"00000000000017633c6c08c6e76e58397497d88a74a77ca1428e7daf7260c9ab",
0
],
[
"00000000000017b937aa4b9327600dd33804deeddcdf6abfd847455c3365fda1",
0
],
[
"0000000000001bbbc95f689697902e961c1ae60b0ba48630cb275292128d457f",
0
],
[
"000000000bccd05d924bd0583dbbb9f2b09cbbd04633e2905e44d9fa9347a150",
0
],
[
"000000000d269ad70d5bef52c953079d690c8c20a3a9265c0dd4c37ab311f47d",
0
],
[
"0000000007406099654ec641983c8ba1027fbf288eb23e7352302dc0670ed259",
0
],
[
"0000000000003e6d99ffbf2efbfec8eac8a595214064640922bfa725967de2c5",
0
],
[
"0000000000002107d0e8507d9168c684c45d4ee62dceaa757890d8e34320ef99",
0
],
[
"00000000003493e7933f834a0f38e56e46cfd0dafba38c4d972ddb5a2e491e10",
0
],
[
"0000000000001be81450f65ba6bf3274e4681762d78f22827e7ebf30e6aab8b1",
0
],
[
"00000000000024530945d47086a0f0f15e2063d70fe5435f40796b9d754406bb",
0
],
[
"000000000000ab099a527c022454155a19f1c6ca6065201817400bfeed39cc4d",
0
],
[
"000000000000081b516b4f53c442ead11bb3e64e1d3f6fb628d3d6428f88371d",
0
],
[
"0000000000000d2d06a76e91d8f3e6a3634de9f1e53c4ecfdc94122b696bd4c6",
0
],
[
"00000000000001e9af5fb09645618a1ea84b537f0532511d921e0bd03e79a34a",
0
],
[
"00000000aa98879ff0193e4d5b5fccda8a680513bcf062330aec102cfddebbed",
0
],
[
"0000000000002bb5eaa52edd3ae8571422d99a23a71feb313866f1c177c5fa52",
0
],
[
"00000000037d95e70f0026b2747c3302f84668ee3e112cd335efe0b71de93742",
0
],
[
"0000000000fcc1b88fd91cf4cf853f6a2218f750d1f5fd4b7c3d0c4309c2d48b",
0
],
[
"000000000005bbb90407d1cd8864a4e0acdca910cefcecc42d90b2b80579a233",
0
],
[
"0000000000002f7c9303dfb193c69d0807f9bad18bd1ee5b705680afc2ec4a23",
0
],
[
"0000000000046dba12423dc70bff9d5c8257c5acb4bd0118d459defc904c84e3",
0
],
[
"000000000002b9f9c12c825135a280583c0b838b18e45da81aa44c3adc60fe49",
0
],
[
"0000000000001681ca0ed67c0cb2c566c238ea955a4699e281de06d36285a51d",
0
],
[
"0000000000003a24f300fdc9cf9bbbd23a151ee9e2a79afcb6e9414663addad9",
0
],
[
"0000000000000d1d80f15f3afe5c108688f0fd8266f7fe06dbb6234ee4c9d66e",
0
],
[
"000000008c6a6332a8c9351fd6e6af767a849eaa31838bdefefc36e8905ee111",
0
],
[
"000000000d2ec08ea27ca63313014891f00250ab2d0e9e8a1368541766a3637e",
0
],
[
"0000000002fe57f4c0d782dae857905dd16c6ca3eab6cb68f14ac96f9c5e903b",
0
],
[
"0000000000934f789f1d061a0f385037de57c46644929ceebf8ae7a7e8a797d5",
0
],
[
"000000000039aee8e147cd4bbe399ecb938d22e2b0d403d4b81ad3a36d2ebf0b",
0
],
[
"00000000000626939a469fdcf2109460bed2634dfc5a799b267c3053761cd78b",
0
],
[
"0000000000091af9d3c8ea0d3a35a15376126f4a3cda6905c7feab16db260ede",
0
],
[
"0000000000008fe017eccdd0f1e295efd15813211320952b9649df11cdd099b7",
0
],
[
"0000000000002edf8ddf0646a8e66768a7067d866d6feb3c7832cce16087d4a5",
0
],
[
"0000000000001f290b0b8f50d90e11aafcddff259207a8234e2764be0a6102d3",
0
],
[
"0000000000000039501569acd824c3e514ecc1046076c62756c850696a27aea1",
0
],
[
"00000000000002d87f5b96dbaa8ad6c601c886c1a5a3cab8ea2c8732201aaaf6",
0
],
[
"0000000050d5bb27724bd6a99a31160f8554f2e4868631c719ae0a2ec0b73aac",
0
],
[
"000000000feaf2eaf424a989ebdd2ba9f5ecac93ee5cd495cc9645f3af3d4702",
0
],
[
"000000000f79be6b3023a12b57ad8132f0987442e2e7f40d2e04d3a2699f8699",
0
],
[
"0000000002c329dfd4f21d40ecec041beba86df7a01c39d0775133278b88a980",
0
],
[
"00000000000016229b9dafbf5fabb6dca805de40ac63709bba75352e6f662352",
0
],
[
"0000000000b9ea4cfabe7ac942aa7bdd91689b03fbce3035cda3371e57b3c140",
0
],
[
"000000000b510434311994128aa067b94020fee893e67dc72998eceeb4650d1c",
0
],
[
"0000000007b3d6eca2c9d0a8d05c884412cfede2aede572076c7263a38d6306f",
0
],
[
"00000000006dd9fff347f4c9490b29544fec60a3e0504883d13ec43cea0e5260",
0
],
[
"000000000000e2719af18f948f63b60d06fc7f35bd7cae88ef43d2c9edd611d4",
0
],
[
"0000000000004ff5733da92e83ad0970b7eefa8719ebe7346f65c8da62be79e8",
0
],
[
"0000000000000247d49cdb1e1ce982cfafcd8ceedb9bb1090d2cf8dd52fd7362",
0
],
[
"0000000007aab1ae7198606046c23cb4616edafd19c0410660dba6ef065dc522",
0
],
[
"000000000d6a048170bb18c0421b153bf709cc85e090602c2a5b0a626dbcc3f4",
0
],
[
"00000000069a824155cbf3f02f60363bf5b41ab7e8a83fe647fc4169b34f2808",
0
],
[
"00000000009d32bba8dd11262ada78187ff56417fa1df97b3444196c987683f9",
0
],
[
"0000000002468dbde6d879e2027447918f4bfeba25ff32a4f19edd953d42aba8",
0
],
[
"0000000000e92e9a687fc2fd40b221a02f76ad72228503660b5b6702db9757a2",
0
],
[
"0000000002549c36360bcdf2c495f9093f117ac4a28e205d954ec803dfe7aa57",
0
],
[
"0000000000e1deed95098ca573091dbb6b696787ba265277bc2ff65ae73cfde0",
0
],
[
"000000000bfbb8b7a1941598009350c5d741e05c6575323591a04d57c206e205",
0
],
[
"000000000dff311ba823e48522c286a4962bf9e9bd7a0dde66aa5e5d3d9796d9",
0
],
[
"00000000033b0dc90fb39c5c46e144dd08a52572967a712988a7b543195185c9",
0
],
[
"0000000000003830feb05937c05d8250b9a465c6a5d29dae49b127dd9abc99e4",
0
],
[
"0000000000014d788b90d6a52d49e8e74a054e26429c25ef8a4f3cddf9463e6a",
0
],
[
"000000000000007cf539b80a7e7f7d38a511f7c3fc94161cfb3b960d70c54359",
0
],
[
"0000000000003c7c0f340bbd67e0dc9dd89a340829de0c4a3f1d53db38499a79",
0
],
[
"00000000000017e131be27185a58f3f9f0e239b1fd15571f500bc01aabf3640f",
0
],
[
"0000000000002f688fa71606e8feea09acf4d97493f71adab27bf782c1c11658",
0
],
[
"0000000000000b38da817e3cd6a112626414b5a7af0cd8dede33f8facb87e58c",
0
],
[
"000000000000020f5ce46cceddf4b42457687c20c2fba80ec2c797354ae32deb",
0
],
[
"00000000000000b1c5d067792aae5ae74e4cf30b0f28caa064b5d736b2b9ea08",
0
],
[
"000000000000000bd96df6cfb597259a34b8abed672dc344c6de48c4c26e4b6d",
0
],
[
"00000000000000061b79bafcf1a446752df3217b60b83bdd768a3030f04b21bf",
0
],
[
"0000000008f3bcc3504c464581b257b1236e0bfed8b0472b58281a161fd1287d",
0
],
[
"00000000000019b757e0f7768982ecb439e31b022efd60bfb09509e2c920d063",
0
],
[
"0000000000001e5a69bda72ad2c5f4bea6018f11ec4e3bfb6f974e08c81814b9",
0
],
[
"00000000000002bdd9c3c6c9359582934a594ca82d6c9104f15277ed10349bb4",
0
],
[
"00000000000015b7c29f74c5dad9d6121d0d4d3af9f44333b2008fe5bcbe2eed",
0
],
[
"0000000000003638e630bf33aedb0b0152837f695224c199ef96c248a6b22f16",
0
],
[
"00000000000011be4e7ceacd148ea83abee5b61063cf679c879d0e13d0a40655",
0
],
[
"0000000000002542493ba1cf5396d5209244f266a2c5764d4bdd843c5986d82a",
0
],
[
"00000000000039197e58f1ef6c98eee126a41ef156036ea51114b15708401701",
0
],
[
"00000000000004b893eeb6b43cf0723b5f0fc2c6e245e6671f543421ef8c5f69",
0
],
[
"00000000000024bc586bca1c86f4254b3d74666f22ba2f5742f588d7d50ca6bd",
0
],
[
"000000000dd0843d947ba235aaf2cb1b5e2320f1b15d1f79d32ccb01040735f0",
0
],
[
"0000000000002e9524d79e1972ecece3c432eae5e8c2aba1e5d61584c8b3c780",
0
],
[
"0000000000003c02f0d0743d391caac03c631ebb5518a7e4261487482586587f",
0
],
[
"00000000000015a72bd8520f0d8fb9d7d658d0e62cae6830bf94a2efdfd85e65",
0
],
[
"0000000002f4dcab6bbc94ae9a1b0761ab7b52cd56272b6183715fa1a959101e",
0
],
[
"000000000000005ca13d4fc8bebb498e46dd764ec5f727ffff7ac9c90da8ed28",
0
],
[
"0000000000003ba5816aaac8c6efd085bc08676ff2b51e7c3d94263b4a555c17",
0
],
[
"0000000000000881711d6afcd9ad84823ef6cce98b1cd8a90422867f23644a37",
0
],
[
"0000000000003d627ca2ad44d333543e86cc377e63ca507ddea5aca7ab4a0829",
0
],
[
"0000000006ae50b5cb9404746f3702b2400a3743a05893cbac1b372aeb122834",
0
],
[
"000000000000197307176984fc5e3b612c9b6a89510e5a3604711f90739846d7",
0
],
[
"0000000000001709130852704239c0328a00d406ef2914dabab52a137bf59aaa",
0
],
[
"0000000000002d8039783ae0208445429241d2f5b4485192e1c50f6921579daf",
0
],
[
"00000000000028ab4cf7ef62580408801e9c4a3b955f6f1dcfbf339f08f335ac",
0
],
[
"0000000000001730f6260b57593f30c6a24ad49f5afde41daf8dad5bb83cd737",
0
],
[
"00000000000026800054645b160790f1d226f87a2fd809acbe06d018ffc13fae",
0
],
[
"000000000112993137d21171ba742be3beef4af19eba815f326fd6ecbf54b547",
0
],
[
"000000000000236d85b62dda3bfe9850e4e523e3876684461b7e1107153556a2",
0
],
[
"0000000000003fe1c0f4a23adfb74f07bbd4cfbce44db35119332a5c43500bf8",
0
],
[
"000000000000381ff5b96685ca16305b427c4a1dcba2d410e6ee0598619f75f9",
0
],
[
"0000000000001d8eea8071cc6e93b6dd058924a6fe171438974c304374e1f1cf",
0
],
[
"0000000004da6faf93f8594e82f9d282d95583e72f79365083c00fc2f4fe249a",
0
],
[
"000000000000254e1d80fc91204b555a4269d18d892c60a845c907175fbc018d",
0
],
[
"0000000000001a945f9ea3b8d08ea7befd5074bacd9732a05d1dda579b197538",
0
],
[
"00000000000028ca1712b95cf77145ff45e9049c5c39458d18223b3e17812d60",
0
],
[
"000000000b06ec0c9b1443bfddc2b3376e71789298ebe3575be1b0f5a34a37f2",
0
],
[
"0000000000006562376c1164d7b6f5281d64fa7dd0112bd34e4ec9c7537698f6",
0
],
[
"00000000000027ab842f086d6c6691aca8f51967c1c88281a4e49ad89878a806",
0
],
[
"000000000000ed2292a4f779dcc4ea3315b855243a3b70804da3168377f408ca",
0
],
[
"000000000000c287b24e25768d8524cf3d8b892da291f868446cff373f878777",
0
],
[
"000000000bf32a8711ff9536b7ccece30a2d6ac40538123206862e01a8ef2350",
0
],
[
"0000000037bfd22d6171e4f1c7c7cb1cd73b91a19a3cb473bd9f33335150e001",
0
],
[
"000000000000045f2524a3a3faee2a2d36cdfc9531f429670106622d0c1bb558",
0
],
[
"0000000000007e262915f1b1ac2bbfb97ef84bfece7bb8ae3340fe4eafe7017c",
0
],
[
"000000000000727ee42b831c566ed9c2ad92e9b14db0c8058b9d6d6066752e71",
0
],
[
"00000000000002227876ff70290cd31e6b08ecdbdc2dd34ff0c69412ad863f4a",
0
],
[
"00000000000013b9729fe455d8409547ba3dd30d62eb1661ce6a38bfcd358d02",
0
],
[
"000000000e61c29a6e384bb7f4685608774b790cb7dcaea51fb99f8a10e2e03a",
0
],
[
"00000000075f63db5180b1afce047b919bd4c06cd81d6dbe5224189cb6fb3e51",
0
],
[
"0000000000a83265ca1a81e9d610bc6de821e07739c89a137b93812b545f5488",
0
],
[
"000000000003ba66b509ba5ab7cdab0c705c8b4145e1aade97b86e0f8a1ec6c1",
0
],
[
"000000000002b0ad2b5fd6f15946ba21f819f074af0b96cbcd9a58f5be2ff6d0",
0
],
[
"000000000000103e6ffc413e63a85bcbba243a9c2acf45b433d6dc36acb6ae1f",
0
],
[
"0000000000002226a204b75f5086a8ef6576e8e79af7e904bc9a75287d518b81",
0
],
[
"000000000000738f4fc499a14bcd7bca5c16273f59d7695decaa9b177cea0873",
0
],
[
"0000000000ec7ebd6c07b47fdcdb2d924e0659a22d2b2bbc0f5baa15cf940825",
0
],
[
"000000000002c9d4f581fb0fe2f40a9a24db42de1a85064b4a304a25338a62d2",
0
],
[
"0000000000003ede1becaaff56312310787669e07c38decfb43fc6df9f7950d9",
0
],
[
"0000000000f76fd9b371fb6231cbf696f0e02967dcbbbbc32558b08a6bdf1c0d",
0
],
[
"0000000000009c6e722f7908de61abcf6edb2e530e13c97b4b226a2e1dd9cb27",
0
],
[
"00000000000008730a8476c058a573bc8100dff1da03b3d942a8164a107a00b0",
0
],
[
"000000000a2ff7a14ba1fddfe3239d3202f310490841a5d1294d53df83a471af",
0
],
[
"0000000000a5584c9d4f74046f9fe4e3c9080a0bf3025330065b9607c2142a2b",
0
],
[
"000000000000d55a8cc2e86b72a1a0502a25962b257e0cac2203b79270dc54ae",
0
],
[
"0000000000a2f8ff2e196c0def66a352622984d03216ef0610b17813ca4accfd",
0
],
[
"00000000077267090ddd0a5287bff41a19e4aff8772befcf9f6147d5dddc96c2",
0
],
[
"00000000001c56edcba9d4ae7b7372bcc7f8ed6db3c3f7fcbbed555028cccf27",
0
],
[
"0000000000009c304f5a5cc26c3bce7378205db954646d85049dcf77ef98eead",
0
],
[
"000000000000987179da28a7bbe0cda559b26e25249d826406f002c65a5ffcf1",
0
],
[
"000000000c08930caf906884d095bf68c98781a191bdff9b8677fe25660173e8",
0
],
[
"00000000000070168a5836c8e1676f6b9fa8e316fce6c38b91d6ce7226e7e897",
0
],
[
"0000000000001d7cdc82f066c2adf9b6b8658d531a6dce864bab36660c7e7518",
0
],
[
"0000000000000213c8fac899b3ceae3b36a66a63e94e570b3642c8bc4459fea9",
0
],
[
"000000000000803753b883992776bcd9b0f4c3553ad19e524e82362180a777d5",
0
],
[
"0000000001286d5f36e3d987a6d6ee4c2990799738733331b53e1606e5db55b9",
0
],
[
"000000000000d656dbf9b01aa7cd45bbc7ca206e5fdee253c1138c8d01dde543",
0
],
[
"0000000000004ed5d68da0abdc500801ecbcb13c2a0d0cd22c664c48cf1fea0e",
0
],
[
"0000000000aa80295620b2da59ab5af04caf0bc3a91660a61458b08d9a0ee5b8",
0
],
[
"000000000000f9389fa442251d5fa38a34a628b26d3dc573da88427c0e330067",
0
],
[
"00000000000072ab2052ab08cad2d2e73a46c385a828682335828607d9237d09",
0
],
[
"000000000000f6fd78689cd91839bad209db4d373546fd657801b00f3186fce5",
0
],
[
"000000000ef3589b0434e77221be33e79b0751e3063b0d0ee03bd70ae43adee5",
0
],
[
"0000000000009c9c3db06deb8bc77fab2765817b2293aab7393e527a5ef33e5e",
0
],
[
"000000000c17c570e13201f910b29ec68a66a21f4a2ed7866987905012c1330e",
0
],
[
"000000000000095a4b6c94eaf359cbc8301ae2568927eb9b151266a0286748a7",
0
],
[
"0000000000004cb69211ccf74e6d84ccb6dd421c8dcf16795e8d3b476ff7473e",
0
],
[
"00000000000006db56d24caed9018565840fce8363a7cd9e912504114d0b4c3b",
0
],
[
"0000000000004a15f1dc5253af6641c141a8f4566ac14fac2842ca6ba53bddb6",
0
],
[
"0000000000002aa51c0812c9a1438b0c3bd8e95330a8c4fac62439d2797d168a",
0
],
[
"0000000000002af6ab6d23bf33123e2393c1c9df7e7c2e4f722aa130b0f0db97",
0
],
[
"000000000dc6e875c0a047d3d05dd885c93ca1de0d9cd1a08ee6523e321ca9eb",
0
],
[
"0000000000006a0b4e485b2ad027de8edecfa97a822a87e9623b21be8353cf91",
0
],
[
"0000000000006ccdf09196936d0cf74da3a3c433487cd13e3386bbf0e84e64a6",
0
],
[
"000000000000134acb738b7e99f9991d63519e28da5a1c50c60a31b596f39034",
0
],
[
"000000000000bb7e53229e6557a53be46d98a9dbdc4c827b2b094492d775d1a6",
0
],
[
"000000000000e8f54f39df60e09d81653a5f80707d084edcd6e8bfdb7427c588",
0
],
[
"00000000034b27b5cea0fe55f4dac79fc32e25b4a09ce643f99bbad2e30e9335",
0
],
[
"00000000000042edd05861e88b20e03ec95a7a5ba8f60e595ac044d77ac5198a",
0
],
[
"00000000000021bb194f73f614ab1f4216227f09789edaa2677da691ef5d73bc",
0
],
[
"0000000000009b2dea9b3b6c8025c7e632fd7918316400a79170d7114fde31cc",
0
],
[
"000000000000b6e17bb1f2574590815ad6d20456e9d7f219221c58e3aa3767fb",
0
],
[
"0000000000009c762fd6211c015e5c6a42723136e950cb2333d30504ca2b0173",
0
],
[
"000000000000733c889bfdba15a7e9880890a8ae6814d4310ee6896e8e7c1dec",
0
],
[
"00000000000022d2fcb2ebb92f8cea3f174969805f69de7518ebd8bf26140255",
0
],
[
"000000000982ba146e85fa32f2c3ecc0321d865a7960a6cda8928983ea679b0c",
0
],
[
"000000000000e004dc4ba124a02643e3a2cc4f36665c75d97c2590be07d1e304",
0
],
[
"000000000000a7d0dd7994be5e5acd32495145008362e072eb02b297e1431207",
0
],
[
"00000000000011af0549ad5774dcb338f3c8e6757ff7ba2bf4da4f6932651862",
0
],
[
"00000000000016a682c3e571c0fa6f982f5193f4e416f474c8a7ddc79d88dda6",
0
],
[
"0000000000009d072bc459efeef121a836c353d88a8dea2e73ff17c2f2933c67",
0
],
[
"00000000000091d01b9faa03a2f5076d194b5f889425e2a85b2064aa4b4a8258",
0
],
[
"0000000000004b035f4693d1d32e518c7977a8ea2e77b7b6f4f51b10763ff505",
0
],
[
"000000000000c330f00b190b1ee5e60aa8cc3871472a02d98bade6dada6351ed",
0
],
[
"0000000000003f970f8df4843d6a66b74793b5b7ff3bfe0dc128f6eb00b242ed",
0
],
[
"0000000000000a649d33eb372d40599bdc493d6baa50cfdd667073897c09487a",
0
],
[
"000000000000026b4a6c9b6241aaa2b3f5927e6fff5fa0f2c2fdfb4ef39f00fd",
0
],
[
"00000000000000789d9b77f871481ec9cd1b8b877fe885c49eec83e97df988d3",
0
],
[
"0000000000000028abd92b26a2328ff5bf15d3e9104d2491d23d5f789bed2c1f",
0
],
[
"00000000078b55bf69f546804a25e86ab88ab12b3aa0d614952c23e82f7b2c99",
0
],
[
"00000000000030ca4bda5ab3c07f5476eacb61ed54cf7080b3ce112efbb0703a",
0
],
[
"000000000000175f0ffd64634d958ebc5ea9659e5b9c0e91c2b31cbc9db3a8ab",
0
],
[
"000000000000355f3794e3a81787699b8fd649eded08f829d1e6e47e5f0b9daf",
0
],
[
"0000000000002e5396ffff0cb008c6a5c773ac7b56456f3574442ff22da9c8c8",
0
],
[
"00000000000013f7a1e25a9fd016d1ec5abeba0dc69d1af2b656e86f19fc73bc",
0
],
[
"00000000000018cf2326052e78cf74dd3671c63bf64b58c5dc4b1ac964b712fd",
0
],
[
"0000000000000f321300ff44eb5bc2806eb15ac3e73ecd8dadc08e00921d35a1",
0
],
[
"0000000000000b3af62155af09d7d3612eed2522fbc7f9eb1751c67c56e20903",
0
],
[
"0000000000002cbf583243f17ff8b6ad7e4477d3e58e41cb6589882fbed15406",
0
],
[
"00000000000001b336201e7d8876fde8def3923ceb44bad051cde464431d4937",
0
],
[
"000000000000000cf89bb1e492b07c8e771b880995d293bbb5a2b6312d9a1584",
0
],
[
"0000000006b21f9e08db1b242dec441df0a12a8e5b93d5fb39fe6069cebd197b",
0
],
[
"0000000000002d81bfd983a54e8fb65d48868e361c5524fed9646bd999dbe894",
0
],
[
"00000000011b6d38162fa8128f2a69ce0ab991bbbc619482312665414d32ff2c",
0
],
[
"0000000000003ae808923e1323e4790f207895a1e04353a6dc5c74502401ca17",
0
],
[
"000000000000253ef210a4ee95c1950a3095825114dd67ea0fb7a7bf0f75006c",
0
],
[
"0000000000001c6a7b8d2fa299c8ed9433e00defee0f97b5d93427afc753edeb",
0
],
[
"0000000000002c54c1b035f0d6768d24899d2c79933aba096074302075389ec9",
0
],
[
"000000000000373ab0c2c8fddb53cc8c1890448d11deb2abbb3e36c986d2cb17",
0
],
[
"00000000000031898465c27a7055012064a6184c41f8cbc89700419b802744d2",
0
],
[
"0000000000003aa31991360c4b63ed0760d83ea3eb3279789b406498db586bea",
0
],
[
"0000000000000e5f6c671e63b383a90347b7d95313fe06570e5641442d30025a",
0
],
[
"00000000000000eb1edebd17248bc5dbdb02eb7010fd41eb425a6c78975dadea",
0
],
[
"00000000000000c2d84b76a602b417861cfe4580194ea4076405872b1e954808",
0
],
[
"0000000000000013d788f8faccb96b3d702a1a2a94f547be4552946cb04d6b02",
0
],
[
"0000000000000007ddb93d3d5d2cf8521ad8d8bfeaa031138e4271669a0000b6",
0
],
[
"000000000a43bfdeb9b9f513518ed0b4a523075c3ead0b049913821466f40624",
0
],
[
"0000000000001b9b9cff2195dc6868da50a4ae6eb4f3661f31feeeaf2a5fed7a",
0
],
[
"000000000000346a1dfdcfc23f8bf474030c42683288ef5e6419eac5e55720f4",
0
],
[
"0000000000001274395c0a6d1b509d8b242ef570c99167f388848999c9d00b87",
0
],
[
"0000000000000684d1b654d0c2a3c9fc1919d2f20948106d48d2365bf1d52d11",
0
],
[
"0000000000003d31b55f30b2d91979b6c4aa249ab5e782bfd61c6cd99a199962",
0
],
[
"0000000000003b4e4f3fe1eb3f17922cdff9538b862fdd4be332b38825132504",
0
],
[
"00000000000002b62a4334c696eab73339ab3cc497716b163beaa5c568be70d3",
0
],
[
"0000000000003b0a1b1ddad166962bd5d29335590c53ef5d6079fabc90e238d3",
0
],
[
"000000000000289da5bf12022ca27bf0b8b1f097a9f1e612302a64714f737c35",
0
],
[
"00000000000001b05c01730ed74a9dfd86fdfcaa28f97f9128867b5d6dddaa87",
0
],
[
"000000000000016a9af16f51a69f5ca833eb52c97cb062c4255f97296850f8a8",
0
],
[
"00000000000000576c2c704125e6704e9e1b1b0b5269f0ad7e14291f8bb875ba",
0
],
[
"0000000000000036675b4282e91cbcf341e0b5c68085b5c002f7c623afb01506",
0
],
[
"000000000000000029531ba2b1f27a4a1ac57a946ef322848417ec83d7cebf96",
0
],
[
"0000000000000000764a389696612dc41f5ac2c5ecebb887e296c226303754d0",
0
],
[
"000000002c3a2e26f7891923807fe41d14ff232de4a9496bf99afbc53af94006",
0
],
[
"000000000a27812c8ea52565338cd4881b783d18f0d4bd9a7ba8f4ea4b8d1e6e",
0
],
[
"00000000000044c58e6a1e82f51dc23b11ba4b6c4eed7ad317654198e9a51022",
0
],
[
"0000000008c67913ebb49ff07a2776c081113fb20adb56b1f8c8fcd1094721f2",
0
],
[
"0000000001cdc1f92512c05d201fb32ea48b23e1d2568899ab06f14ffa329848",
0
],
[
"0000000000003eef51183eb12076427c8c223ab73760373c163bb4da036f7cf2",
0
],
[
"000000000000743bc379610841698c4d333e5cc721b8ad95d027b4231a365b1f",
0
],
[
"0000000000005e9274e286a066e145689530db6183a175f8e6721c44171cdd16",
0
],
[
"00000000000021a56fbf607f3805c158393c0d8b3683633afb0d0ac7d64d0496",
0
],
[
"0000000000001e223329a43536dfaabc1467f2ad7d44e55478f62fd80d9e48b6",
0
],
[
"000000000004c61719fe71b56a2d8706b5119e2df77da7eb9330aeda1825fa4b",
0
],
[
"0000000000001e89565a9c898bd51bede84e2f231aa29a38f3b47b987a2ab0e1",
0
],
[
"000000000000712743a8df968020c48e3955170166e90ee9a2e02cb460349ddd",
0
],
[
"00000000000019192acf1ff42fd9fd8d9aac23e51837f4698a8f02596f6cb7e9",
0
],
[
"0000000000000f4f49cebf0d2f8731f5136903bbe9c8bde1b1ca94048119fd3a",
0
],
[
"0000000000000141958bef54ea397bde360d8ac3eb117c1dd785f8006856ac92",
0
],
[
"00000000069650f051d27f2cb54045c19304eb99425c042e010ee982daaa9002",
0
],
[
"00000000000087523d9819f2661f47140dda70be7ae7af0b2e75324dc3d6fdbb",
0
],
[
"000000000000384f0be3649578b7ff6b578e5976e18f79829e5a40c975f54ca6",
0
],
[
"0000000000001a9f45b1b809fe1000a7177d284475c0db05c0bb18645e1f04a8",
0
],
[
"00000000000374404f82c755ad76a64a96ff4ff449c365ed7769ed50437bb09e",
0
],
[
"00000000002b4291a859b5489268c232ca7f86f76944690b668d82b94538e0f8",
0
],
[
"00000000000aa4227550ccf273d27412aba102a2a90520140b715c95fb4822c9",
0
],
[
"000000000001cad06fc2b9a27871b4e6b8c643d6fb3dc559073c2e2aa51ea4cb",
0
],
[
"000000000000ac7c3ceff73b328f172c1e1906f0d706885cfaa4f60b6bad42c0",
0
],
[
"0000000000002c0c170a510d880aae66dc7beb2ff0f4d5db3effd802e0a2e89d",
0
],
[
"0000000000000db55eafce7adecec679b6793d81a60f33c19a7e9055ed47ca41",
0
],
[
"00000000000003201aa00eb05c7b2bef9b4972ebc640a04be522fb749a12afc6",
0
],
[
"000000000000004faab34097be7f67a51b3ff1e7b2527d750697691b2753a996",
0
],
[
"000000000fc33ffa85423a62215dd3707a31dd5b13f2b9ba868060670d5c456b",
0
],
[
"0000000000030b22b40f979c6104cdb59113cf76a15fdd676317c721c1c9a4ba",
0
],
[
"00000000000096a0202c2467783d606246faae84a897c96e5c05c42602d2bd72",
0
],
[
"0000000000013b2316c180c52c60e4f38866430e94e46d2c7f0b63a6cad57616",
0
],
[
"000000000002bd04bc3dbd8a3a91e1d658ee83acf1a7a66f1c0a5cccc70e88d5",
0
],
[
"00000000000026d791eacacc6c13689a83e057c77cf2986a3da3a43e1c6924ba",
0
],
[
"00000000000a03a783e832ace54ae9a71ceb9888102e1f91332005c5ef5dd139",
0
],
[
"000000000003d078e9ac9a2a9cc3b31190e1ce78bcc449a6b471ccb4cb6fceb7",
0
],
[
"0000000000007d94fd9860466fc526d3f9b327ad9653ac7297bfb9991a2fdca1",
0
],
[
"0000000000000f7bfcc5d12df9a0989837a98d371f495627660321ad857912e5",
0
],
[
"0000000000000303be9bf59b1161dbf359d6c65d99a38eb03619428d0d0c090f",
0
],
[
"00000000000002dcad26269a9f62413ec0a71f0fb228872030f1617cf634fe3c",
0
],
[
"00000000000000bc9f405c6a6088973fae16ee48061ab01e97c8e4193c88509c",
0
],
[
"0000000000000040d4a88b68cc37ca890f3ab23958f567253ef2ce87eaa36bb0",
0
],
[
"000000000000000f961ed7d57a81e0c232669ff9e8ccc4867632545016c78ae2",
0
],
[
"0000000000000003c996d48410bf2660521bfa1151fd316d65600f72c4adf778",
0
],
[
"00000000001c7e8824a780a745fd728e6106a49a50b19b45e0653fccdde06b90",
0
],
[
"000000000000094011e26b89dfbd3355d001db8b07b29474a5ced7b5883ed2cf",
0
],
[
"0000000000002ed4eb9f36f5bad0ac0f0c4c0e6a0b02d9e6267472f0ec96a06f",
0
],
[
"0000000004fe1536748aef5d7286f1699f4cd0f8afc845aae9ff90435e81713e",
0
],
[
"0000000006d789445291bccf72dd7f126f3e1d3d03319df61683079826b94f89",
0
],
[
"000000000dfaf0283c12eee49338b0fd14181a50302074e51d928ce2280b8814",
0
],
[
"000000000117b2c05c27806291a5596c1d65e784ca67e886701df617cae96bc1",
0
],
[
"0000000000001db99ab74f6cae68d350a095d5967636070247363b6b2f9fe87d",
0
],
[
"00000000000010047f90a66416b399f265a55d9dbeed30cd320ae2c1cfe4ace3",
0
],
[
"00000000000021366ac9446eed3b71b3d437ac3fdc6647dafc52803fa8b1e920",
0
],
[
"0000000000000dec6efe565871e1b9212c8b6463cc644c4e79e32ac2758bf4a9",
0
],
[
"0000000000db56b3d3e67e1581d443fbf695834694b7af2c7d9f9e96f017659f",
0
],
[
"000000000af313478dc82a2e3a71147d678b41a83149cd0c1b0016c796e666f6",
0
],
[
"0000000005e838486868ee22a713d261e0bab20414bd5805fd914075906e27e9",
0
],
[
"000000000000343b7029da5dcf93316a95e2b83a34647370529efb7a1f3fbe96",
0
],
[
"0000000000002797189eb8edebadad2192491f56fc93ab08424e5e00161c3fca",
0
],
[
"000000000000948cfcdc2645c13d25093500954bd8c49fd3534f695e31d0f383",
0
],
[
"00000000000015db4d59d0de0af7880fb9f710f20909ec39c42eeb49e96eb5cc",
0
],
[
"00000000000002ee9a047e4a3123d527298be5eb4a1b4280b98937f85ac908b7",
0
],
[
"0000000000009260261421c980b8cf2dcddc6a18c49bd1bc804dfa6f0df359bf",
0
],
[
"0000000000003b963afa52c2187dee9fb9b2cceb799994f7e71f4aefe9255e84",
0
],
[
"0000000000000d9d821f0edd2940e911b16ffb7d21ef2f43b089c18f0152c1d4",
0
],
[
"000000000000004117fdabe3fad201f486169db3445064231bfbd78a74fa7f3e",
0
],
[
"000000000000007ec434b48b3414447b10656380c1ad15ca0caedeccf95c5cdb",
0
],
[
"0000000003c5bf907909fc3656595f18ec47a05c4cfdf3b682d004a333a1d9ba",
0
],
[
"0000000000ac007e356a1bedb70fe31ca56a970f746e3b65d6f54d002b4d439a",
0
],
[
"00000000014b2c8f10765caec75f6fc14888d231b1acb22307f7a13c926e28db",
0
],
[
"00000000079cea540986fd74da98751311b2735eb07a745b7c683eaa3f15b830",
0
],
[
"000000000d27fe2305af6080e0d5eee7344b0489cb1c0da19e2d9c537f698126",
0
],
[
"000000000a1ab2ea2304c980c0e84e2920317b554e638cb5da8ed1a6bc6f30e2",
0
],
[
"00000000006e744c182c88b16e773daaa57c890c3bdfa3ba32590acbecdee7b2",
0
],
[
"000000000379c716212dcfba61597cfc096cd8c73d38b1fd3e2ee5e10eec7776",
0
],
[
"000000000000f0051043df801de031dac8d58b7309a586ad22f94467e8192532",
0
],
[
"00000000002354f15017f05b70a8d4cf2ced4e9c699e4e729cee091579dcb332",
0
],
[
"000000000004dd9488c9451e76542f6b5100d0b01998f9c50691916c62b32d76",
0
],
[
"00000000000297f0a1b8437c1c361cb06c230a7fca5a16cc10fc5a9fd5444d3d",
0
],
[
"0000000000003dd884b7190ae6d8535515bb332b4726e45fbe9309e12bd824f6",
0
],
[
"00000000000004aa0500e9668e48ce227ec9d9ae2f68fac9edfea161c2f653e3",
0
],
[
"0000000000a0cbcd8a3d3106851c1f0bb80890c8040b8204f9b89853f646b800",
0
],
[
"0000000000399d0689f2c25d5b15321a28e3ff7a4ef283522b8cbafb9b2d4015",
0
],
[
"00000000004b2624e3a7089c4e438e32193f42a7821455e9d3bc82d525002eaf",
0
],
[
"000000000b219e11ff7d9ca4bab7bc32b30cf6c07fe8b757bba4f1ce7a4ba782",
0
],
[
"000000000000672ec8538e66c5e7880d89d47166238043bd05cfb2b6975202d1",
0
],
[
"0000000001b26f4ec0022ba8732d14e031adf2529f6ace8b2c4c6f44500bd102",
0
],
[
"0000000000b667df345a9d121c978608de555b7bc028c89841dc77aaee768b88",
0
],
[
"00000000008b87d9107d8d72e57191e3482d0cc563988c0a505c40545c960090",
0
],
[
"00000000003842b24eca3193c245c6d9548ab6aa12f585375f24c571ac92a6ed",
0
],
[
"000000000000b40b38ed6426cdf09de75db790198c862dfab1910cd3a180c823",
0
],
[
"00000000000166d9973adc0bf4f130ac15f29b10a6c04cb8e25ce9e6bf07b148",
0
],
[
"0000000000006bb52313e489ba4ced2480a5b39764c76eee634a1d997d1ea92d",
0
],
[
"0000000000003e2b6d802c3854c819713661bd5437374ceea6193de52f69ee84",
0
],
[
"000000000be0c0b056526b6b891040109d45adfb3f34b65c05169695f64b989c",
0
],
[
"00000000095fea1fe858606e16d44802a2a2ea55a4cf8c20e4f489a8ef840866",
0
],
[
"00000000012593712d8d9c7cc07edb52cc2926dcd3cd9d5776c256d9059facad",
0
],
[
"000000000000c427ba2ce29f13fdafc925c19929c5512bc0e542f7d201de6c50",
0
],
[
"0000000002bd5cabd8b2b9c2c1122e681f6e0d19aa8ac8733fa1e89a544a726a",
0
],
[
"00000000007f2a119a05c0113ed25aeea6d6f42c29eabe17fd4fd0eed2218857",
0
],
[
"0000000000a65e7bc4ea0927f20073536ba2bb15185487eb3653ca382c2529bd",
0
],
[
"0000000000026e2c50b815fe655f39ddd2a5ad67363eb346030c72ee468fd861",
0
],
[
"0000000000cb8615b605a76852358420fec223cb1f45b58f36fef593feb775a6",
0
],
[
"000000000000a9d62c3c9e45990ee3e3a0afe8ba09ee76ea8508f3501490f752",
0
],
[
"00000000000d6d07bf649e10f2a43b6f3ffbb3d3b8d44d88dd56968bda1e4576",
0
],
[
"0000000000016cdf6d13beb2a804607bba4cb410019bc15a0033d428f0885dad",
0
],
[
"00000000000046d0925f6aa3cb1035ce6baa03c895dde5869b63770a95135f25",
0
],
[
"0000000000000acc63320accbe3cfa9536987b7f46e995cbc9b6edd22631587c",
0
],
[
"00000000000003492c43af082403eb2bd11e02087afb5c590ba8af828c6a4b08",
0
],
[
"0000000000e1a369418931a7c1be9dca780253897ba64322ec598ad0ff68cfef",
0
],
[
"0000000006b04f242391578d9aa5233c26e6a18557ef7440ea8b05d4e7554180",
0
],
[
"000000000c92c04b11e0408a047f4b3d78b420dfd05668f78cc077e03ec81093",
0
],
[
"0000000003574dd81c51433aac32302d0e30e39703522f39a254207ef5e64dd9",
0
],
[
"0000000000db3d7596d805ac36b95ff841bdb7f95f60ebe2422c2c6ce1b76ed5",
0
],
[
"00000000000bb41a19a11b12d37b2a5cf561ae02d7ff916d37f79d8adc6f2d05",
0
],
[
"00000000000067675fe5b48bb05cc19526474522d1ebf71a3bbe991d95156e57",
0
],
[
"0000000000002a8601d3b33b5341689ebaef6e99051f5e34330af0775953714b",
0
],
[
"000000000000cded0095c064ee88824555bf50916806d36d27a5d5975abf8c7f",
0
],
[
"00000000000007d4f762e90fdb571facd1c5bcfe43f501bae3294c604427cbc0",
0
],
[
"0000000000000837e0ed6ba79a36a447df9395d7220c177a7dc44489766383be",
0
],
[
"000000000000024903240e9236ffa291ae98d057f09c79792bba7c12f49ee3eb",
0
],
[
"0000000000000000237351eac112eff88756b58451d6a272477690b1c2e50b40",
0
],
[
"0000000000000030bb4840680a28b8fa5d6fdec773404f12751b9311815a8dcf",
0
],
[
"00000000069d1ee42ce15859a6c76d7b3e5c4c7b76caa80b26933758b0e55f9b",
0
],
[
"0000000000006369a1181c0496b55cbcf0ed789c7e851b4bc7311246dee03229",
0
],
[
"0000000003fc3d65eae8617cdc1efc6f903bb17fdd30e6a0556fc785e33b0741",
0
],
[
"000000000053dc70c6af06084c6ee584e753ae6bfd18e4f748226ec8e7d8f146",
0
],
[
"000000000165d59df8044f95ae2ffdea9a6d3e218e5543a4ac442d90fcd1116c",
0
],
[
"000000000015c229ad9ccf607263dec26e90ba17b011c993a31b521047295b98",
0
],
[
"000000000009b5115bb2cf88e82053430e3bda4bf7a5776100b647d11739effc",
0
],
[
"000000000001e854436057811dd868b4be38acbfd420d90abf59e88cca893921",
0
],
[
"000000000000e89f85eedc448bf69d39ded3ec12829776e4512706983d678faa",
0
],
[
"00000000000003aeb06e89a38668d5b16d0dce97815493cc4a964f3a4c4cb208",
0
],
[
"000000000000079d25e51546fdac3e854af63829cf1956ca82477fbe6e373bcd",
0
],
[
"00000000000001a4644c28121e5b90a9dc5f574ee7a6b1531ab2a2a73bb027e0",
0
],
[
"000000000000010d3b4c4e22444c3bbcbe32d95462da935e94df0600c36d5d66",
0
],
[
"00000000000000223ce8299ac8293241676dde2a26dc673ce3aaa0d603300226",
0
],
[
"000000000ece8881ee79a62cd6a94669b5529ebda8ea35cb8a5b16f22157bff4",
0
],
[
"000000000051d022d139ac89c2b6adda09b91ae70bd9dcadd963cdef1ba3b69a",
0
],
[
"000000000339ded080a76b132a9ce123ed47b91b849809887179947af7013ec9",
0
],
[
"00000000002f914fdbdf00942b25df72be09c52a35fe5b718da39483543ddafb",
0
],
[
"00000000016b02a598d4e3e3b3cd7a31660f5cc96c68286b5e228c79ed4c5d23",
0
],
[
"000000000126e5cd7cef8f07840a563c23c28b60d043cedf8b4cd9ec0c72a648",
0
],
[
"000000000b0f3b81a581d5407f719ad5eae9eb3f9294403ad85e5f1555af22dd",
0
],
[
"000000000d3061ac9ed91baebf222a531a5473e7d8f979e1bee457e3e1cf6dc6",
0
],
[
"0000000000000f1528cfa6f357f44f677bfd4c92f77e863e1945a86da556f20c",
0
],
[
"0000000003b0f3fc912d574cc0bb312cb35859cd829c54a9db605cbb9bd52675",
0
],
[
"0000000000035d15430eeea3107cf09fbe0bb6ad2a7b21553b7c3f578d2c2b92",
0
],
[
"0000000000005cb1be53a0827995ac577379392c904740854d6f48c2b6c38936",
0
],
[
"00000000000026834560e7c8db380e9c26ecd5f8d6a7c1baae85385f7ba79f03",
0
],
[
"000000000001d0bec9f90d83e118c9e6685eda9c57b2337f4b149f2174138085",
0
],
[
"00000000000003b8d4d3278ae3147815537a87f9265ec6653798f30e45be92ce",
0
],
[
"000000000000038c72c5f71a607f5ed676253259f02b6022603b75f51a02d622",
0
],
[
"0000000000000d3dc9970c1feef03569f332a1d7ecbac5b980f382e49e5c6218",
0
],
[
"0000000001ffe2564d69c58d1440f82873b47bcf513c85df334b4f1ffa537f0b",
0
],
[
"0000000000886705178f570b099f8b2841312d41e819884d32eb76a2d928841f",
0
],
[
"0000000003cee4fba7e8d113973432642840f3a914a79164694cd752e06bc4fa",
0
],
[
"0000000002f1db982508e0bc313b7268f000587bfd1b247e28e553ffbd5ff58f",
0
],
[
"000000000026008cf3dff1367884185aa740203ea5149785fbdbfe40d4ed2851",
0
],
[
"000000000014798b39735397575ae0f4f389907d296fd63c9fb7029b0f7d7891",
0
],
[
"0000000000088f1729049693bfe1d19d256e2951970df4bf0b102f716899b894",
0
],
[
"000000000000f29d3ab2228ccce57b3f1b3ece81937bf780bb7fdd853ce2753f",
0
],
[
"000000000b021a42f081054616a39f815bb183eefff8cc0f852a28ab8a531103",
0
],
[
"00000000023bf7dd7ed3005de65b537c2c8876f2fbba8848132397f7c2e19b8a",
0
],
[
"000000000235d39c23216dfa2f37e027e5cfaec163e0f932bdb2f16cfcd6f59a",
0
],
[
"0000000000021dc9864764ff14afdee090b2e338f414d4e51b652b8102c00f21",
0
],
[
"0000000000000a15856916bee7d7605138b2baabd2e30c124b41b1061196a5ca",
0
],
[
"00000000000017d756a7caa1493256118efe69d7505f1add42ab9f66dca8e8bf",
0
],
[
"00000000000007e7c316d7143d8c4ecd91aa79405907215b87635f1aeb1b54c0",
0
],
[
"00000000000010b865a46467e275a653fbe7db33a3295f9586c969f7af4d91cb",
0
],
[
"000000000000eb5a46ee76387df02b00449911bde789a09ad2817e5f8f768771",
0
],
[
"0000000000001c8fccc5f507a27f1ebe034fedf98135f291096b17de6e490d73",
0
],
[
"00000000000005cf2c8aca82ce281df04e64b8191f96711394a96df2a49ae7c8",
0
],
[
"000000000000027621b9a7ee46c8632ee23b4963a27baab60c8e2b2874ca9e34",
0
],
[
"000000000e9f58dc29cc60e6afae3b3b6603c69f888e2b88569103926dbfac77",
0
],
[
"00000000000012f42df0d44cd1e5bfa062f3bb2f0760cc581d659cbd07c1bd96",
0
],
[
"000000000e2fbad37dc42819b05e83fe3d302c87265c46d82f62eb7dff3efd7f",
0
],
[
"00000000008c95ceb4b1b636e726c3e7cdf80c74dafab1c3d13cb130cee40664",
0
],
[
"00000000000277a72404c15eeca384bbe32fd4b546e5de1d0d39ed6a5a9a3519",
0
],
[
"00000000000dffbab9cc1c0744c8585aec07ba68ab4df86628e079cffadc4ff4",
0
],
[
"00000000000cea01bccef6472e11cf5080901900592072b3c7dc109183ec60f4",
0
],
[
"0000000000010e6f1b85517945fc164c74b6e60d85c9948484f9047caee44de4",
0
],
[
"000000000000386c87e9fa493f75937b7654721e8e411e8899b18e49e79c4f8f",
0
],
[
"00000000000000ec39027769af5367e61b3589d7f665296f5a01aea47a367837",
0
],
[
"000000000000021fef1e6512646e72fee67cb9678d7a3d3208a108f0d7a11c22",
0
],
[
"00000000000001b04b9a0c761f676bfeb77f47f7270799abd749afb210335acc",
0
],
[
"000000000000003e289ce157208586cb8a0ecfc45984c217ce6ae38b28101396",
0
],
[
"0000000000face4d3828727f7b30d3d7c7ba25e80f39076942d64f2b8266524f",
0
],
[
"000000000244a130e6c88855ed65f6e08cf1e24dd05a4562afa9e0aeac869dde",
0
],
[
"000000000104f9420a1ceb288ce3da17e22d3bbc4a427727a44438c4df52afbf",
0
],
[
"0000000002e43dff8a2776df4b712344e210bfbf92e06d90661c59576488cb04",
0
],
[
"000000000083c35652af9b1bab1e9e6cf7ee37ce9912cddb887ce29787aa115c",
0
],
[
"00000000000ffb2fca7f2c1713f8737863bbe8d7ca9bcec97d4ae783b782a1ba",
0
],
[
"000000000001416d9ae0fef049dff1ecfe2cc84573a80a8414254cfc07d9b539",
0
],
[
"0000000000006988c41ed46e245865a8ff5f8837cd31443cb2ba89c75d90b8e6",
0
],
[
"0000000000009d35aa98fa14329c525c8b5065beeab1a5e902611fefbe3f37f6",
0
],
[
"00000000000031fcc3682ae1e909d1d3b4efd1e58cafbe6eef8d3ae5ad4c7665",
0
],
[
"0000000000071a3ae9a9912d030e3c724dd325ddfdb0349a0e0babd1a20f5e5f",
0
],
[
"000000000fcb06fa14d45c00dbb937cb5e456d37862814a97d79527e44ef75a9",
0
],
[
"000000000000586af55ff52933e66d8fa10a4b87b0a85a369cb1679f216361d3",
0
],
[
"000000000349ccaf247304093a4bc06d2699b1c08600375ac4532beb386bf056",
0
],
[
"0000000000001d3c3190d3ffa359605719bc0007d750667b693ecccdf2ae4a53",
0
],
[
"00000000000047a5bd3d3a42273f93dbf0dd06058bf90e256c9ed63e911bbcc8",
0
],
[
"0000000000005910b434e32db0053bb9eac75e1aa1f707cf6e04e1513be209b5",
0
],
[
"00000000000017c0df4e9859b84a83e36bd23377792a7ab218e19bc1712fb0d7",
0
],
[
"00000000000022140a4f1b51dd23bb5e3a33611941628a88a38441457dcf6b70",
0
],
[
"0000000008700121530a0b7cecb70331bff9678570e9791a3ddd1a4f3d7bb4ea",
0
],
[
"00000000000005d11acd598fe7a7382712449e4dfc942469d762d87c7648c7de",
0
],
[
"0000000000000d245ef989d5ae64249984177dd10e1c0b2f04077276d89a0b09",
0
],
[
"0000000000003b8f9bade81533ef7ec7133274c55d8e29f6db4bb6545c2f97d2",
0
],
[
"0000000000a0489089b019a7fae1ca64ec8ef179b352e84561ac0a055675dbca",
0
],
[
"00000000062041b268ffc19bba83c5db5fbbec734c4c2f969d0f02cb33dd22ae",
0
],
[
"0000000003ffa51fff511505a918462d890a0d8e9b3038b43e01ced914ee023c",
0
],
[
"00000000062afff83211b48e13f909c97c8a6cbb0d702df73ac553a7871c2e57",
0
],
[
"000000000280223436db85632d21bb9673ea9ad270260b46b77fc6d8c2b23e6d",
0
],
[
"000000000000097ffea9886e82d5fed96fcb91c1b3c337b445aaaa47966cd15e",
0
],
[
"0000000000005f791780e489a7dbca3f372bb36ad7bad1f1d4d8584f7fba980e",
0
],
[
"0000000000002b8b3a8285b208bc7cba07e08f42c9705367cb5d2cc99604c346",
0
],
[
"00000000000002cd2f944504f4aa6c2e20dea005ca70568b18607a91c590ef11",
0
],
[
"0000000000003eacdfd49b651e0871ecbac1edea9e8c07014c19f2b888d28b08",
0
],
[
"000000000000274849103a544fe5e64a0d97ff81f0cb4fcbea269ffe01babdea",
0
],
[
"0000000000000279b536695aeb598fd6c01adc9f19e5b5bfb9de513a477adef0",
0
],
[
"0000000000082feae81a5123d7562cacd2b60476acdc27687b57cb0cb023bbac",
0
],
[
"000000000000db4029f289ef085ce1e55939b3225ca0e3297d0a66abd9d54c39",
0
],
[
"0000000002583614b4cf8c846f81f4452602f5a3663fb684a7f8d2382f83e9b3",
0
],
[
"0000000000d62998c90ece4773c2251143d3b16891fff7066a3fe7fe42fd6978",
0
],
[
"0000000000ce441f798e9188f58d9075e7a6b54010a69b8320d106584581c929",
0
],
[
"000000000063c5f940edd14e3d91bcdb145ed07d983d72dfe0776474fd5daf93",
0
],
[
"00000000002fcd1ab04af227ead9b7db9c87758953c4dacbff4b6428f6d0c6d2",
0
],
[
"00000000000bd2a500f81c5500a50af4c59b04b6c73dac9bbee911ac8b5e688c",
0
],
[
"00000000000b362c449fea49d85cdbecf81ca83e55b71476e06da440ec60dc81",
0
],
[
"0000000000036d40982a6d48c72de13c448f6796c55c52fc19d58b3372ec253b",
0
],
[
"0000000000002f469ca442dcc594acaa75b0c4cef2f4bdd4c5460ade34f956c3",
0
],
[
"0000000000000225af0f1a97bb54a20992fddef86ef259a52629abaa28b45e1f",
0
],
[
"0000000000000be9a2718132ed7891e27316eac5fca42dc196bcaa0e317e80bb",
0
],
[
"000000000000013c85749bdbe405cb0dc0bdd4e361281ba7a198ce820ab0405e",
0
],
[
"00000000000000f7939565cafaca6f31c9f61f8321ca0fa69211594cb96e7117",
0
],
[
"00000000007d22b331772cf0dd919000ff5ec754e1b9254b90fea7d694501a85",
0
],
[
"0000000000c41b7447a1a24841abdb7c3f2b3d68bf7707966b3124ff594c1da2",
0
],
[
"00000000007976d00aac373b41e6f3e574ff607beba8551dcb7fe6e0a6a5990c",
0
],
[
"0000000000014c8c2b476c525b656908b1ad6cc5f7593f63147b882ca38c5870",
0
],
[
"0000000000bd0a837703bb57aafa84dacb49a264b5335c42442f0e83fcac2841",
0
],
[
"00000000003ea8019a2641d1778bb416f6d9e0ab9b068323654bc55cf7b2d304",
0
],
[
"00000000000d0a442a0865a501b427e6425151f5923606bed29e94a72f35a07e",
0
],
[
"0000000000030dd08b942ffb56e9a8eda95a7e6f28a03b9d1f488a29c7fc14cb",
0
],
[
"000000000629ebd7d6c56c34e858ac6ee105aec8d8db05db5f4b1e7bfec8033a",
0
],
[
"000000000038d3dc0b68fcd3275c38b3225bb99bca973a513afb2fe505362d6b",
0
],
[
"000000000072b439db6364b048becc5254682c31214f44522566290171447c9b",
0
],
[
"0000000000eb455b495292a97133957356fb0e145a593c9920e99bbc1fcbb91d",
0
],
[
"0000000000f3c0c53d7b41b7366a44090d09af626aab3f207f94e0506eb2abd0",
0
],
[
"000000000014463bee2df062f3a0d84752b55b96c6cc16595899fb2452f23960",
0
],
[
"0000000000089435da00b1ff30d48724160cb59877b19bcb3f449d9c279824b9",
0
],
[
"00000000000033ddc62f3518f87c4acdf8c0962e5e0753c86e023537bc73906d",
0
],
[
"000000000000cfaf02a175dc7a7efdb4ee10eb83d3a04678f0adb5c300b9acfe",
0
],
[
"00000000000014a5fd14c81bd67ad64fb81d9672b8d03877a9d3824c97a715c6",
0
],
[
"0000000000000b79c643a371a491bb6a27726102f8797c7d08ddf87ed6b334af",
0
],
[
"00000000000000499f0d78b9f43fe1f8d4a981828049a2546059d1392f2babc7",
0
],
[
"000000000000006494d55236e0ced5ddb0a42dade401b3d77d456e447f666ddc",
0
],
[
"0000000000000003ad1c7710bc02bcdd76fe6fca54869899b9048091cccb93b0",
0
],
[
"00000000089b0706b3a6b5808ddafb57be5f77e51009afbc81aba91ead1af1c8",
0
],
[
"0000000000038dffc62eb25ad2301fc45ccc82a9ca0f3a730d7d25f4cc7695f9",
0
],
[
"0000000000007cdc062f569a2227502e75afa99c7ff7c9fd3e043ee61f5e2899",
0
],
[
"00000000000050f82f9fe703482a89ba92aad0be11aa686fcafba4c7ca0c4b01",
0
],
[
"0000000000005d08ba1d46fef300bd60382be096d46c036622a9c52466fba6fc",
0
],
[
"0000000000001c124114694c9c3bbce398d3470bbd8d0795152541a5de1e8189",
0
],
[
"00000000000047ae2c50d65efcc15af2014d5b0049d244d8dc78b482151f0e62",
0
],
[
"00000000000064a0f4a34ab095a76b9c394069f55bd6bd2b783b5de028b9f87c",
0
],
[
"000000000000f33234204685893b980d24bac914bc8b1978c69d8580fb63f542",
0
],
[
"0000000000003aac07cd725764eadfa1d887b0d4508858f31e46e711aa801e8a",
0
],
[
"00000000000005c12a0970101bbe661f57f63686c48825136bd298b951e86e2a",
0
],
[
"00000000000003c4ea51aa96323c5708289580f8e7b6e89d0d3bdc08d0db94ff",
0
],
[
"000000000000016b84c8036b3b3fcd2f31928c949d4d35b4400ae80ba23b247a",
0
],
[
"00000000031da21f07983eac7205006b839fad1e14f339c065c3ba0789f47a8a",
0
],
[
"00000000010a23cc7ddef199edbe37e42acd2461bc9848710e467739423ce2dc",
0
],
[
"0000000001ce777eefe9a1f1aee20f33629aba343e5782b3f4ec02e41e282a85",
0
],
[
"00000000017972967a46f08c4e79bc29283566c539ff8590247819223cf3f1d0",
0
],
[
"000000000000fdb8834f2e67f6bbb4409935c299d46392c8db426359deeffc27",
0
],
[
"00000000000023f8eba6eb6d59206adf050dc76b2dbf41b66421df4060c7007b",
0
],
[
"000000000000327a31e6e0bf7261edf25cba59c81b895c9d1568cfb9e12371aa",
0
],
[
"00000000000033faab4075e74d8b908cc5b586bee4b99464163798ebde37925c",
0
],
[
"00000000000062006eae8582aa0d3b6877216793ddd70a1028d65e0cb2c475d3",
0
],
[
"0000000009cde9229b24519ef150d00e78249587cd2be3a293b2ccf807f7b1a8",
0
],
[
"000000000fcbf2f23f11d11aec4df1375ee8ad149fe2c32b52615fe01b327b15",
0
],
[
"000000000a26326c9fe65505e8dbb95f2d10bc5915912be2cf3a3c66f4617c2b",
0
],
[
"00000000005e1f0b9aad53b290a662096d88bb719b451007ea9e23ef990a42b7",
0
],
[
"00000000000061bdb1e6cba904842454e0eaa79cfef1d2de098cdf62fae9f67e",
0
],
[
"000000000000238380880d6af14a72d9f5cfae0a6032eaa0a4ef1291161a83d3",
0
],
[
"00000000000007f7088a701efba448b2a3465fb0a03ef2a83215bf5cd9138fdb",
0
],
[
"0000000008b6f1711e2cad14256e563ca3e7927957c3d6779ff381f5c5cd3220",
0
],
[
"00000000081b05a0ae2b231dc43bddea3c258f70a3ca20500773e29a7b5d75ad",
0
],
[
"0000000000000003a9eb8f76429fa078e2e20af75ce5b75da0937ed06cea43a7",
0
],
[
"000000000bcaea5fa7f75beb66951d22d9fb7330bad2b84b0e2039a61f9d5a4c",
0
],
[
"0000000000000558d535b20ba51c6212e683e0414f8430ce4a9a5aada7c7ac4a",
0
],
[
"000000000000379c6eeca100e6f171c71df430611b187043c2b422449560dce1",
0
],
[
"0000000005c7361afbd59825f0243a94c47f62dfc2856f490ce11df9f449b787",
0
],
[
"00000000076248cbffb87ade5efc8be5dfe3329a2ce1d2a488fdb23d48efb75a",
0
],
[
"000000000e283546c050293ac632a61036c18dee9bb810835b661fdfb08f6635",
0
],
[
"000000000321148782953d6b0b23eebf7b7feda7df39204ae52e28d224752b65",
0
],
[
"000000000077be32cafd14823697979dfcb4843cc11d8d44f2afff9bad1b6d0f",
0
],
[
"000000000000567176a554c5a19cab87b7f0a86d98402a09d6613c0f389ca567",
0
],
[
"0000000000000b1996700d0427eb92eb52986a3f4639c9e11b8ce37f2dda66aa",
0
],
[
"0000000000001b3e599b0d31519e75ec45aefd549df5b6028aac6d4d69cc829e",
0
],
[
"00000000000046df035e6fe190b203dcb62c9a1a7cdb08389c79d3987c6dd8d7",
0
],
[
"00000000000003e9e7a602aaebb90b73d5004844d5f6f24925b14188e34afc2e",
0
],
[
"0000000000000e6263183a20de2b098d31b20c4b857f6d11c523755f5c287fa2",
0
],
[
"00000000000002cb548427aef898e94253e0a0ec8ee53c000813551229eeaf6c",
0
],
[
"000000000000004ccbf0d92b3aa2285be79b17fbe0fe261ee1c682cd1dd927e7",
0
],
[
"00000000000000314c5668fca293d377ad4715bd2b5379318ae2e81fadcb1502",
0
],
[
"000000000073f9d5a07e50d1701103c1c12506c54af4e2d96736b267e3b8b3ae",
0
],
[
"0000000000ed2345b418284bb0378152d0bc8f360844955f1bdc1797f557a621",
0
],
[
"0000000000001da2c63c314956dd2c633f40f8cfefc034479bd60089fad02b88",
0
],
[
"00000000091985d3f909245582e26d7836edecc5eb47515e590a5d0b08940de9",
0
],
[
"0000000006a654ca1a6bb988584b478197fd0eddbfa9edebae55e2f419712212",
0
],
[
"000000000c36097cf9734e7a3fde243870fc9122121e34a9a22fd235c0ce3bdd",
0
],
[
"0000000000001ed546608547145905444cf18e7d27d537bc3253b295ebf6164c",
0
],
[
"00000000011c08a153b85244c93e601cfd715845ef7bfb8678d52d72eeb33fe2",
0
],
[
"00000000003950ad79520b26976725c64556e65d21c7b5029d467decc056d30f",
0
],
[
"0000000000003ea7f0a49bb8e6be6c09de704de3a8d329e018d5f34cd88433d7",
0
],
[
"000000000002048815f8a57a4688e0ff2ec7b0c6dfd5a855082097e16c1b4f0a",
0
],
[
"00000000000021f8ee9909f7102cab8462d8d0ac43a451a864bc792dba0541c5",
0
],
[
"0000000004515238ac80d8e7ad1403ef3c97a9651493c9de89a4d6ce06bfe858",
0
],
[
"000000000ed4662b59df94409e92509ea67f0753396010c136f5652cca559592",
0
],
[
"000000000faacffdbbc3459c6490f7d2d058c40913ec9b1efe2ce0765af4c993",
0
],
[
"0000000000001adb00aa71e333150e5ed06bb90e92cb871d74e7fad5c076071a",
0
],
[
"0000000000b3091c11666e2e25f66ceb5cc89c43fe4cad628ea96bc5688ed556",
0
],
[
"0000000000001796f7dca239170f18edea5d2eba4ad7ace947c4986c9bf5ca06",
0
],
[
"0000000003504db8c65ba8631a483894da912c31ab48805d9d75a69456e6cf93",
0
],
[
"0000000003f643be16ab8974aa0dc202a16c77a333918b715dc068859ed60ff1",
0
],
[
"000000000ff7209cd7acc0e8be21437ec4afce623a80a44c05a655aa3423eae7",
0
],
[
"0000000003d1fa3d1c9a8082d1c12da56b3f537b5cab23295dc03187923dc33c",
0
],
[
"0000000000392fc59c41a98a986cd160c1ca491fbe3d638bf9acfc9f565344c3",
0
],
[
"00000000000077d2ef23f91f9eaec66263551275f1cd58e42f212cbef1f1514c",
0
],
[
"00000000005360c2362e89b2001957e8563f2f29bfc5c67453643f972603d457",
0
],
[
"000000000ec466e23f09fad26ed11d1611d948e82327c21392bf1ecc761d9dfb",
0
],
[
"0000000000bd6cf941aa95726e74c57689dbacda79c61f61bd099a3da98ca08c",
0
],
[
"00000000006dcdae735bd55c3a6472c3d8b62d6ada8734b396ef768dc160edcc",
0
],
[
"0000000000e8d3a687d796d63c201570bf58e3a2bfc56ff5b2b98dba0590e8c9",
0
],
[
"000000000077c5459d889081076e7bc3596d1afff69401e3ba9a747a41527075",
0
],
[
"0000000000288bc04badcaeb4dad4c95188aa2af23b9713ab516acb7a2ba08a1",
0
],
[
"00000000000018f0c4517333aad28a6bd17bf6599b23f30e736f8b81453c91b5",
0
],
[
"0000000000002bf35f1ddf7f173451b8fdd99b09ca7e7acdaacd741a2ac4ee78",
0
],
[
"0000000000001b73317bb3e44d0e0c478b607ffca894cc64c4cb7aa46372e395",
0
],
[
"00000000000036f9c27a5fb4a1e3408c0b5af23433a8fb914d1ecd980a78cd8a",
0
],
[
"0000000000000db0c1c9f82751891801d681a1c45fb1c41595c81443f0a1e950",
0
],
[
"00000000004a8225a3861f2d8fd6a2c4f2c97f9580269a3d47c4fd36e1f835e1",
0
],
[
"00000000005bda823369e5f94c49834cd98af02630cf33bf63a5126d81aeedad",
0
],
[
"00000000096bb045d967239d012773b9c74995439fa29169586283b9771ffe11",
0
],
[
"000000000fbb4587b5a097ed17d5a3f4611a55c5697df344759e90b2074df8be",
0
],
[
"000000000a34e610a3b5a3cea87bf4dc50cf5ef4f4256fbbbf713850a1931750",
0
],
[
"000000000c7115c2136182db49423e85b3f03b72df0095d8b48d41a6e7a5c4c9",
0
],
[
"000000000dc75a70535dd6998830eab1201680703badef3cc0c506845b05b411",
0
],
[
"0000000006209e2c1585e816bf6ff79aaa52d709e3e15d8c79b907b4b9af482c",
0
],
[
"000000000df4fa6c4fb5d4277ca22c2b3432aa3d3d4d34c93fae21c5c6e18f13",
0
],
[
"000000000cc1645c4f9dcba4ab347a632d3c3d00b0ae56f72f9a8791c2589ca7",
0
],
[
"00000000035de13d1b950ba799262cc730f65cda1ab33c86cfed67c14ab59b12",
0
],
[
"0000000000003c0666573e408040e408df8265c1b24d4ce53cf6efa00c289d51",
0
],
[
"00000000002a301f000f086912f45dfa1a6608d6d2a14f1eceb23f852cae290e",
0
],
[
"00000000000774b67925ebdf31b1f455db3ad534fd9aec0e43bb79925c77b221",
0
],
[
"0000000000001b8f1f932360a5936ec6f007ea22ccea5579cc8667f7616977cb",
0
],
[
"00000000000035a0698b037925a892e4766d659ff3f7979209552a9ca646203c",
0
],
[
"00000000000001437edac6d3e1bb669d907b93ad506f8c096a99b8a0f8cca1b1",
0
],
[
"0000000000000e1daa4bf888d63a83097c13cc37318174868d6cf47fd5951b38",
0
],
[
"0000000000000067094d226dce682adf9b13225891e3c4bb800cf7087bd20804",
0
],
[
"000000000888d73db90a3cfc554070f42ab6e66d7ed603ac31dcf75f56ea2496",
0
],
[
"0000000000dc11380abc06c3c9919c140ff2f79ee854457b7ec6219111b24a07",
0
],
[
"0000000000002519e28eaeb2a1c9f7dbab73f220408b2cadffc4edf2ba252e4c",
0
],
[
"00000000005aaf523f208621a67f1d79bbc49a55d23fc174fd51d5b20ab046b2",
0
],
[
"00000000000017eb2b540a7ad338edb2291d3a718624670f0e1eee89e78bab4a",
0
],
[
"0000000000000d3119520427d81eecb014cc8911390560ccfaa21932801a5e91",
0
],
[
"00000000000d6e620685d7f868d6164e548c0296b8e32263cd557cb3a38826a1",
0
],
[
"00000000060ba49b218cee56dd5fa41d66c45476fd9f3da6ec338df98f8f3af8",
0
],
[
"0000000000180f7317cc1d110cb221b6767d908b0c6582c859e78c9f04dfb800",
0
],
[
"00000000072fea2d890c6368162518bb9ad345adae68b106cf15cb20ff241350",
0
],
[
"000000000026fe3d8e277d69708a69cef90a7f2a761796602ce9112644518dae",
0
],
[
"000000000045c77f25f1807d143280fa8464ac6df9c7a4b56bc4e34a9416b9ce",
0
],
[
"00000000000542f8da30137f2283a54e81f586985f924561e558d097bf972a9e",
0
],
[
"0000000000001c3c44d10ea8bb958ca80dbfa5733ba66b8bed179b05d6106d5a",
0
],
[
"000000000001c1c53fede30f9b35b3cc05605514e2ab909f4bcff86bde884f7c",
0
],
[
"00000000000009b60a8790fe0524faa3c2399a3791fdcc217d21d02cd935c287",
0
],
[
"0000000000000d86b1ab286a7d2b41db146575311106f58cf3117d831ca81ddd",
0
],
[
"00000000000002b444cde3ce10aff0f4557d918ed7f7d3d33e217e2c4b4d6ef8",
0
],
[
"000000000000006c1bcb0b8650557494a2dcbaebb6372dd6a893f372da0cad56",
0
],
[
"0000000000000032288278d76032074a25b0f45b2b470a99b08641a0b789b085",
0
],
[
"0000000000412b6c96e0c0382e0d8d154f567562dbdd6d515ed3b008fe4c8d7b",
0
],
[
"00000000032d4d5006d3753adb0a9c6e0a0a320adb6de9a14fe89c184977fedc",
0
],
[
"000000000b8aa72c0093b8dbf56ea257c2aa752467ce63be33b4009429932b3b",
0
],
[
"00000000000036cb2a9820fe0fa9cd6b28a0226ff4d7c0e06f3f9de336746dcd",
0
],
[
"0000000001010dfc436ee032a16952fd8ad1342752e7ff2b0244d150dc6118a4",
0
],
[
"000000000023567623e752cfe32267a6ed9fbbff4f167d88f1e3f9c3528cfb54",
0
],
[
"0000000000001ad1b428da9f6e81ee3ad109b4c31532842f59dcd03fa1d830fc",
0
],
[
"000000000000555f57a00b2f4ef10748595582e34ad13241838217e295eb9d46",
0
],
[
"000000000000bafd77624538518bee5f02e497de67285b907c7e7a1de38905a5",
0
],
[
"0000000000000aa8dd2bdc793b12bbf8e548fba5e8b35944631c326d4eb4b0ee",
0
],
[
"0000000000000579a497cd1f9e7bde38f0baf65366cc6ad2073159977fc9f332",
0
],
[
"0000000000000030ba581e96eee41480ed631a7c2f6d67503f316760491079ed",
0
],
[
"000000000000007ce65511b1c34e1445607ea53bdecf9fae9b2e0eca10a6064c",
0
],
[
"000000000721e295cbeeb7e1d460d55d6f48396139e3bbe5000b76256d87e936",
0
],
[
"0000000004db6aaa2d703469e774cdc2dfa758072c3b32bc1498494b7333ee8f",
0
],
[
"00000000010de49ec83b2a5ad987e222639c489a53bf7f6b1a9e74baee07dc05",
0
],
[
"0000000006380beeb928bed1a6b15d78f1456b775a4fc40ddbe98b7189dc000c",
0
],
[
"0000000001699320ce4fabdade058c18d404468378882bb8e2e3130502210956",
0
],
[
"000000000086702466b4b3d53a45c78acd02e6fcd4060915482f7bcb8ab18825",
0
],
[
"00000000001fd44a586b1f3c45d531d74a21364cbfa32922a5dff0747295c201",
0
],
[
"0000000004600d0aa3fae03ccfb2f52c6cda7a2f0c0377ac93f0ced5705c8083",
0
],
[
"000000000f3524398295e430afabf4f947faceaaa913e7a50d52bbd04fdd945c",
0
],
[
"000000000fa978185c0f6b6b08a5d3c920528c3044da2af5de67066adf8c20d8",
0
],
[
"0000000003285aa7311c3522e58954a630cd15df159f312bbf77fe30572c6680",
0
],
[
"0000000000000fb07884c4ff829424937d02586452ba4eb6285feaa27d68d59b",
0
],
[
"00000000001944317acf0e191bd6dc7e743cdec33fe09ee3c0d4b540e36a8202",
0
],
[
"000000000004af4f58f7974bf4434017104050fd14cfcde5473e84239c9e8507",
0
],
[
"0000000000002531b5caa3f5f0b58102a04c4462998dfe8790001b39823619c7",
0
],
[
"000000000000fd6e966ce4b55f4c8319fda3aeb7ccc7f0162879f85050454499",
0
],
[
"00000000000001f4b22e4bce9c874dc7f81fd81de30890bf5c0037c132f399da",
0
],
[
"000000000000013703a8d800d1e40ca4db69243003ce1f32eef75cecfbce34c6",
0
],
[
"000000000a9261a8d5f86317e4a4ad56ff96a95b3ded98f22ece31e9e7e1699c",
0
],
[
"00000000000ffc1562d4903732ec53396d298a488ed52b2d4099148721f47185",
0
],
[
"00000000000f57177045b1df6c0010e813089f7fccddf88ca9050008e043d5b1",
0
],
[
"00000000002f57d47c85dbe00927776e12bf3c332d7848e2668f7cc1412be086",
0
],
[
"0000000000e1cc3c5215f5a848f065ebb3a43d30bc4580681ccc32dd7093e82c",
0
],
[
"00000000001b961551a1797ace18d54443928afab8b53a9d883b4d33c7065794",
0
],
[
"000000000006eb5f5c91d53b92f2f237abfe4215e16aba860b9c83ff4531a363",
0
],
[
"0000000000028a7ed33b6625a08785a54f58c6e40c686a8868bec43ea07cf524",
0
],
[
"0000000000004c23f5e2291ea84114504ed53c0cdfe39fd0962ad51e3f44a71a",
0
],
[
"000000000000359cd2356a0ef9843a6b0b089469e20f39d86de6336167e3bb31",
0
],
[
"00000000000007e77bf73bf030331c461ab57c17c7b52e1789b1992a3bc6a036",
0
],
[
"0000000000eb8fd6531c52d17c8b78367415b12b946fc6c906d63239b85194cc",
0
],
[
"0000000005b862746087e30836e21d6169e88dcc891edc9965ea8d3c6a5d1ca8",
0
],
[
"00000000004c3948942914866eed7fdb5b21c8d6f7926e633d4ee463661f1236",
0
],
[
"00000000000f838dc5fe195485eefc05e57064ff5744c8ce5a40fb038d611ed3",
0
],
[
"000000000018fa4d5ad9133b828d1d4847017f9c28f266858380ac84aa48bf20",
0
],
[
"000000000034b78e1088b935f089917350ccd7183dde2e70482bbe70fb802377",
0
],
[
"000000000000023432a73d4cdb6a9b81e64cfb0e7465e2cdfb3df78140622485",
0
],
[
"0000000000000dbd6ea9ea2f6c0846f78890747eb7de4b83727a4e9f769a3bc7",
0
],
[
"0000000000004add97d7579bf34384dd3164cb25d4c5add6534401910de4f2c7",
0
],
[
"00000000000002053b7bf544342efc88c71785472fb1f4f92b592d9bba7ad26d",
0
],
[
"0000000000000c2e80a064b77191131706bebc1196bac9ac913aa63f44a3584f",
0
],
[
"000000000000008a76191fb3b5e341056c982b5a0631c81a7d23652224968841",
0
],
[
"00000000004fcd575e591cec98931fe9b35df1224b6ee9cda2915ef2d6a907fc",
0
],
[
"0000000000001d6b8d2caf598365e3f41789bde086859a9c093ef5c6ac4531f5",
0
],
[
"00000000000d778a5dcf7e84197ba476035f802a17562af2a6746cc46c6a8ed0",
0
],
[
"0000000000029a99f42369b9f1aea9bbd47390d2bacad09a40637536183ee2a5",
0
],
[
"000000000004e100d497e03ae45b7d039127985bb66f822f7d0b68a2919e0c1a",
0
],
[
"00000000000a961916fe000f2b7761f867a16f2e8a29bb6cc58dbac63c26b71b",
0
],
[
"000000000004e13f7bd19836f89f3a8757ce80ebc7c4ce2041dfe196966b9b76",
0
],
[
"000000000001d5530f7faa64ac828bac0dd66f058bdda86f320ccb4ea6b4705c",
0
],
[
"000000000000184818f0675a919869bce1ad0484f5a0734a8a3c6678716b35db",
0
],
[
"0000000000001d3cef6965a0591426ac446aec5e714b6da2f698f240a8c7b297",
0
],
[
"0000000000000a6251cb71b0e7c79d577ba70f502a0fc8a235188856f062148e",
0
],
[
"0000000001a0d7ae29d0fb16055f476831d372d3c3b9aa6ffd30fc3136df2618",
0
],
[
"0000000001b5c42166bf291e425ffe152401360ef771fe656a37612a6bc9c32b",
0
],
[
"000000000ee998ed1c31cd407a8ab2de2d119c1d38fe166f3a133e1b529162ec",
0
],
[
"000000000f6d809b34d3a42d9b4c93271c62e225e5a7e1fd20a9ed92af3bd988",
0
],
[
"0000000009a167f6d9d8cc6852aaf12be5f4b3819929a6e12a372f66296a8e8b",
0
],
[
"000000000345f1453151412ea96a82be32be8fb6ce6d646ee608b8bc8ae146b3",
0
],
[
"0000000000be58836d26e311860860cf884d9d098abc2cdd6423ba1b2b2e41d3",
0
],
[
"00000000000010d95c3b409b8ea4df1780d3c9f9c7ca087b24c7c0c141fc9c97",
0
],
[
"000000000b81334ce2ff301cfc3c94c86dd02bbf72cf3772278eff39cd71832c",
0
],
[
"000000000c2bf411d96473fa1c463950040d008ebedda1189a1cdb54d7bb31f2",
0
],
[
"000000000e7ece2a317027c09c9cc2210c177a865bf9b7c85012b9854f2bd850",
0
],
[
"000000002731fab33587e08e23ac94c1d373a15d7b9875766188845ee5a969eb",
0
],
[
"0000000008a30b71218412c43ed85a015fc4fbeb1dd2243992578b501407a0e5",
0
],
[
"000000000f7e4666ec661ed4ad11b4767cdfcce1208e8cba788d74a9bad05b1f",
0
],
[
"000000002503bfac95c7fbd3dcf3ff56b9d555c5e604f5f36d87f2e19eb0d353",
0
],
[
"0000000000006abbe507bdea8936ccfb72c08195aaa81dc11b1d3f550d4c54dc",
0
],
[
"000000000000ad055b6e8966c482af5305e07754af2d8d520d5c70bece826807",
0
],
[
"000000000b342baee465a347a4569cf50ab95376e59f87bdde38d9433f6428de",
0
],
[
"0000000008a12eeaba440680a497d1323f27c4a27342087ec4c0460b12a7805f",
0
],
[
"00000000086695a3bcdb7a9ad86f91f6da9da221197983bcfc7ee5a2ba69da9a",
0
],
[
"000000000bab17aa2bb225314742c07ccb247feef53a4cbf08d65622e7bac06c",
0
],
[
"0000000007f776d9cd5e718b2677c24596029fe9c8694a208b75e1170f534735",
0
],
[
"00000000000004ec0928faac1f3a1031b2ab5e462936419c0ae17124dabec4af",
0
],
[
"000000000006a2cc94305823c0a5d30a03f1340d45c36ce754cd299631bac67a",
0
],
[
"000000000acbd3a599053a3b03901ea2bb4cff6ca66824b064f2d901ad66bdfe",
0
],
[
"0000000000001148be4d8ab2acf66cf0dc68f636fa394a059cc6c50a57ac579e",
0
],
[
"0000000000eae9077d1dfe58b371c399d3e73f6dff18e9f7bd52fa5528734af8",
0
],
[
"0000000000343826ded7a6de205d98ba2b4f4f64ce05af8275e6bce684c62631",
0
],
[
"0000000000094454634a162499692b4b625fbfb119df402e2c42dffe54feadca",
0
],
[
"000000000029f9cd34b0f879107f884772c7a345a4e625cad45055f5df847629",
0
],
[
"0000000000067abee9818a634edc244ff42eb6b71cd6ee88d41b1791d7bac8c3",
0
],
[
"000000000000c4c96ca78a2607c59b175dbc83c6a386964230dd9a29d59f3789",
0
],
[
"000000000000715f720c3ed3e8a988499cb39f9b84f448fc7e14546cc62c44c6",
0
],
[
"000000000000358b18bde41a77fb5e97ee1185e1be83b832c18d0c2ae38a073a",
0
],
[
"0000000000000375efa661ab6166a51e19497512052940d8218b8d893fcacaa6",
0
],
[
"00000000000003af2ce5930d4c686f2630ad58447ef6e5da36fb6d0c5c73acb6",
0
],
[
"000000000000008ff35d010d7f94b841f37f2c9801291acd029e49975c6941c0",
0
],
[
"0000000000000011f4f1c7929b38eadfa067cf148026cf40a4cd0f87747c07b1",
0
],
[
"00000000027219ff91c48b554b87d0375e2231946d6eb4ecc547f4a7234f6d18",
0
],
[
"000000006354b8f3148131ffdec12cd0b63c76c0c2a0e037d0725d2c24511ef1",
0
],
[
"000000000000037c25c08ec40b55159f87e78cf00df700e2ecf9d5fe537a0e7f",
0
],
[
"00000000000d74e182a79f8ceda21b27f63c6622c66e75512b617e3ce8a736f2",
0
],
[
"0000000000cc362906f644f2a649a9f17a9e3a08e123bf08971d829cb92e448c",
0
],
[
"00000000001dd7a9253268e93bc0ca0cdda096cbbf14445a5a979a17607207cf",
0
],
[
"0000000000060fb6d03127b2d0669e1b061b730c20ead99200d5ec8f0028975f",
0
],
[
"00000000000008637fd1b69af2bf8b94065fa444382cac2b4745484b203e6822",
0
],
[
"0000000006c7f6d4c0bf9d2b313d3039a3864c9baedc9fa0f829b8c2c65690ad",
0
],
[
"00000000002512b588ec16f52ce7ea23ce183d5956b381a951f35373814b5382",
0
],
[
"00000000000c3374ff0eecba8f087b61d7e35d8e66b57d47b81e0c64c83ac526",
0
],
[
"00000000005e81310d1afe6822be62de5c2137334ae33ab48770023f07c59c84",
0
],
[
"000000000002802db6ef175423f18eaeb1a1b4a31b93ddb9508a78c37380ff1c",
0
],
[
"00000000000e324cc1ce853af182634f58096aaef2725d9220fd6a7ec349703a",
0
],
[
"000000000009a845400bba282bd1e2907e6423dcf74fb1fb6f96ebaf6abd9b84",
0
],
[
"000000000000d81431902f3998dc0283a54ef5652fc8df091bb65d80c996d98c",
0
],
[
"00000000000066bd25372ccecbe69d487fb2190d81a8e67679e484d541ff83f1",
0
],
[
"00000000000002a470b897fa91318a1f34ad9d7dfa1203cfa6b7d106bd8cca07",
0
],
[
"00000000000000a9846dea5cb42c990fc532488d38e92ee7faccf66f91fcdaa1",
0
],
[
"000000000000004084ee64746c686cb4a021ad22a97588f6c503188db6d1d917",
0
],
[
"00000000000000142ce2586d6439bfafd0d53d32ff41c1d56602e394f32edfda",
0
],
[
"0000000000000071fcc3a3cbd37aa7feb1c54580e532ef7219a9a24648b9ab09",
0
],
[
"000000000e155f0afa09985dc14fe86f8121819c6d5e455e7773e1b27290b2e6",
0
],
[
"0000000000001a44a33ef9aaa52457c6ba952ee215bf1c75afb31b51ec65a59b",
0
],
[
"000000000000001dab82737f9c30cd23319dc19f129f1a6a958e11443bf96c63",
0
],
[
"000000000000320acee35e29f344c28a6b0e61d930e3454b41160e02547fab38",
0
],
[
"00000000000023a7455cfc34eb472b2cf2e3cea92c075207aedb73a843683e5b",
0
],
[
"00000000000003cc17e82a58382a746c79ce383fe25387a18dc4a7162695b24b",
0
],
[
"00000000000004a50ed62cf7fb18ee8d8375e9df71fa6577ea66690d2560d769",
0
],
[
"00000000000011c599d9481072c314e1d6f9dd7ae862a8c8afae2777b71d0340",
0
],
[
"0000000000001b1e2885bf70ac03266d7bbd77d99da7216be9d7e3055281243a",
0
],
[
"0000000000002f9ffc72d5ed2db1dbd5d4421e848a486ec0740cc23099a1d6d7",
0
],
[
"000000000000181ad0f3edb9bd9dbff6a4bc4081c72e6882ecb0a7ced233037e",
0
],
[
"0000000000000360d9d309f32e14dc4dc2cccc57715bcd0afcfaab3c305fea59",
0
],
[
"00000000000001954c349d2b1dfe936f34ec475aad948cb5c1033ca5d0a7e385",
0
],
[
"00000000000000800aedc44c721f51e6ac502ecac49734e1e085d6f04f45d265",
0
],
[
"00000000cd7d09dda2b4d4116552fab663ad600ec316fb81fee153ec10e56da1",
0
],
[
"00000000000032520799302b06cec40a5181120451a0f61183a5c60d75c77a40",
0
],
[
"00000000000033982563fa344700e74b209035cadaa0ef965b99d1fb7b6dcd32",
0
],
[
"00000000000028b83e4a14503b6ec74f8333a77e02b4911af4fec814eef81695",
0
],
[
"0000000000006c0b30adc040e886bd7788d62a8b41ea1db9dcbc0799e6f2e737",
0
],
[
"000000000000014a918d5e373be7f26e3a140a7fae398f32479971242815aba2",
0
],
[
"0000000000002e992b9179f941e33aacc33e1fbc0d68f47ccf5df1f9156770b4",
0
],
[
"0000000000000d09de158157254cd426a88b8b61073a81775aa14b90df3d5133",
0
],
[
"0000000000006e2930da07f5be918af235aac0d8c51b20314399066a7ae9b8db",
0
],
[
"0000000000007d6168adc3577a27c3093c228d1d29b924e44ee6553d19627e13",
0
],
[
"000000000000195f3294a3e529151b1fbdeec735d0ca2f2787714dc387392753",
0
],
[
"0000000000000101d519d0775e4fddd0c73d80a210cbfe720899f8cbe41976bf",
0
],
[
"00000000000000133a76f07bc3fd48a608e48a3e4cda84e577c8b923bb224f47",
0
],
[
"00000000c577e64709e648ee42fa3dba41d59175acdcd99b644ba7b5ca2e5c40",
0
],
[
"0000000000001b8dbc7720c23866311b9599e45256b568286099d90661c90e3d",
0
],
[
"00000000000005caae0c34a171ff49b9e887357644f44a4917ffccdb1f42dbc4",
0
],
[
"00000000002f91f1dd7cfb25d8ee2f61a995e50d0ec22b077c042e1de7b5f6d9",
0
],
[
"0000000000002dec1164348f1b13e7198015ebb69d21d17f1db540f9f82254d2",
0
],
[
"000000000002082aabff07e386e279c12fe27d45e9929b13ee218bcd42cc917a",
0
],
[
"00000000000016e4462971bf4c30bcef675d766829063f337523bbdf39ad2dc8",
0
],
[
"0000000000000ff9b10fecfaf454ecc47fe228d9f8412b1e24aa3c9634109660",
0
],
[
"000000000000262c9ede4171783f97e7f559fd7dbdcb95e7d89414f3e91774e9",
0
],
[
"00000000000020a280fe02e039f9ac8f443afcc0f17869f2bbbe9f983b7ae4f8",
0
],
[
"0000000000000d214d8dbb194e92c0de0d817ccde5d3662b20bf41c39ba7c46c",
0
],
[
"00000000000001140f819a403f931fe6ead3c996781715ae1a2bae7108964dd1",
0
],
[
"000000000000006d98a1ac170738d4d9d31173f18d3c4eea36b8b2b39d6c4dc6",
0
],
[
"0000000057a23ba17eab7ea1ac8f5607497d80f17add1683166cb7dcc4bfb1ef",
0
],
[
"0000000000003a5db5ddfc931eb257c1fd8240f56289b987ee7e12563ece7e91",
0
],
[
"00000000000000ef1e4978348aa888a63d97fb20961e22f51b88e80c73a0adf6",
0
],
[
"00000000000000ded525277b70d1c22012dbef28f1abcc4519c9b1330d1c6b98",
0
],
[
"00000000001884ae12a9ddd3a274af90e28fe77c7d53d4e25ca614cde1aba3ae",
0
],
[
"00000000003db05cf4d82f61a1d52cc5d958c21f516aa3c348d39a4d7aa37f3c",
0
],
[
"0000000000121061c34c8807ba8036093628ada5e046d36d257a2b520d29bd30",
0
],
[
"0000000000001c7d96178988db6aa1450281a8208b67ba6da8d02e25a9ad9cd2",
0
],
[
"00000000000038a153341ef0baee829b4e5c6208710b145cd4492f644b825ddc",
0
],
[
"0000000000000d2f02bf068bd1344713ac3a52dae68b2431d860e85ba727dcbb",
0
],
[
"0000000000000535659de712f2344e83fc893955480c4e0d18a9d959c4ae35e2",
0
],
[
"000000000000033daebb675ae26fe3e3a2d0fcbf281f70fb40d1b1b1f41104bb",
0
],
[
"00000000000001373a277a18b25690db1720f038e373e45b56bfe4402418a293",
0
],
[
"000000000000002e92327500efa91d3c0352f394b2c6b205e1b2a8a0323f1ac4",
0
],
[
"00000000fdc454e72a6d98495fefe2197fea1ad03e6202c23843cce9a2962f09",
0
],
[
"0000000000009d979e5a4dfd64efe714f96e3a77f9296a917d6b109e76457c36",
0
],
[
"0000000000001cdafba80309177c007468e7a5e9512663ad5247659194f18802",
0
],
[
"0000000000018ff4e57f8865ea7de7725896df3ab4e5c6077b0a6c20df6511bd",
0
],
[
"0000000000040f4902037267b28a2ee8e87dca565d23f0e9723dd437782c77b1",
0
],
[
"0000000000004397345295d78ae41518a4af486f7ebc990dcc67bb3750c828ab",
0
],
[
"0000000000002174f6cdf300e2f9c2706850e0dea1f0119d9b0914328b93a4bb",
0
],
[
"0000000000039a00fb8ff791b9b2d83ba37485801d856bd9e7925ae540e4dc5e",
0
],
[
"00000000000004694e5fba3428cd48c9858cf6932f64be4f290c95fd9e393063",
0
],
[
"00000000000019a0ae2b62d65095889aa0fe28a6629f465e51cc06d56061d134",
0
],
[
"00000000000005be0e01d3cfb0e689bc7d9fe50a74f3dbb8e5cedc5792f8f1c7",
0
],
[
"00000000000000227b2f3dd83533e9fefb68bc59d62ec71244acf616552533ef",
0
],
[
"000000000000000bdf712efe085711c72387eaa76f5d79606d135d147bba6fa5",
0
],
[
"000000009f5b37f19334d54e6a778650ddfa021ed10b3f0948952c858f71aa11",
0
],
[
"000000000000389f3959130c192582eac8607121d2c8452166a4a715fb5c770b",
0
],
[
"000000000001b49aebd3997878570e8d1d78aa4062bdb83f72b178faea1e1599",
0
],
[
"000000000000ce62dcb191b681f6d552389dabb663c0cfe0680c6e308a443ac4",
0
],
[
"00000000000053cfbd088bd7c065ff1b24bdd0b343d976de7d5f0da1b2cd19d9",
0
],
[
"000000000002e5e6a1f262f0dffb01eba71ff07acb5ff901d8e977d8fc17322f",
0
],
[
"000000000001b494fefce702f5ea369d5dc91b4e517c570b656a83795bc69e44",
0
],
[
"000000000000c42a983d3e4a9d7b331ef07831d89e321cf28bfb9e049bffcaa7",
0
],
[
"000000000000d69faa189d6bece30215641349a60f7eef40c28b3e0d8fafdb3c",
0
],
[
"000000000000062f8df83420fc0db7bc81dcabafa3de817eae9a7988f727e4f5",
0
],
[
"00000000000001b2ef133ac5d1c35068e8a28a35bf39527ec6e9fe1c22faa94f",
0
],
[
"00000000000001b0a6a20b50dcab2725f528f062e1b8eef6ce04bf1398cca5b9",
0
],
[
"000000000000a1a77fd497e4166f9d49336e2fa1910be398e99e673a8c164db2",
0
],
[
"0000000000025e2694cc21be786db74fd365dbb0f4da514148ad99e4f2ebee1a",
0
],
[
"0000000000030422a5adcc3e6715937cf36b180d78a5171a786413afe5cf92c6",
0
],
[
"00000000000267cfac724ff60632456a778438c2754351694c8924f4bbcd5c79",
0
],
[
"000000000001c6b251f0b7a1dab3490af8bd59c132c47c92093e385a654d00b7",
0
],
[
"000000000002a52cea59e1aee38b14ab4df4a61d3c33dcf239c617ba0c2a8414",
0
],
[
"0000000000000035326f862d2769a4179098a9790328285e41c25c591f3d8b47",
0
],
[
"0000000000035473af9fbe2ddfbec0f70188b341f330329c5b0bcaa5159de523",
0
],
[
"00000000000007f012e2d4c8a02bc44092f4c2caf2a8b42db8111df9a40c301d",
0
],
[
"0000000000003dbab565a793a2174c70e3d9bd059a72d70d5962f33afb0508e6",
0
],
[
"0000000000000875cfa1b9ef3980e432e0b94e631fbbe26c5cf4be48d1f80d5f",
0
],
[
"00000000000000d7aac746ab42e51f5ea6bda208909be3b73ee35b1d529f0549",
0
],
[
"000000000000006b4a360564ade70833bc8ca4077e54693f58c7d658fa42060e",
0
],
[
"0000000000002d474eabb223a62c70ae60261eb7e1b60774ab7b89bd1b4e862b",
0
],
[
"0000000070ac5425cbc994b059cd7e379e600cd608256ca371c6bdcd80673e70",
0
],
[
"000000000000157983c64e148ee82a1b956b3dfc5961e4193c6ed9a01babf7aa",
0
],
[
"0000000000001bdb3697b4cf9c73e5494a6aceab8e26a767af183a14adfbee96",
0
],
[
"0000000000001024636de30482825e12a55ab284beac76dd32bd84008e7a56dc",
0
],
[
"000000000000077901852d1720e9fe55b317b1d707acfe7eeead14ca39d3e858",
0
],
[
"0000000000064673f804cd7023f8d6a4167c96a058fa2f6abeb8c52b23b8e48c",
0
],
[
"00000000000d74afb5c3e78596646e72c459064f491fcac9392f4662191fad85",
0
],
[
"00000000000284052da60a4948011dd9b8b47ee9600070c167a3fd38269089f9",
0
],
[
"00000000e8160e59aea361907f43ae001e0f57f81be07d8fae7eedaa0ac7e874",
0
],
[
"0000000000351cc9f437d474751a41873e425adfd90f1e04a685b62b22b735ef",
0
],
[
"000000000019ec9bd658f85ef2a8fb8b7117cbab8224b3f6da58ec84323283b7",
0
],
[
"000000000018fe65ad2c59fd8c42a9f8b8355e09339c68bed4dfb8dc7a96c464",
0
],
[
"000000000011ea14747c257f6a6b70fd8ffa1d1acb450ce487d75e0afdcc4a6c",
0
],
[
"0000000000112673666bbeb30ac2600f5c33f50dcd3f45a84a0b369c82b2265a",
0
],
[
"000000000006fc35a1b46ee63f58174344798c3945aeff136b18696fc8b37db9",
0
],
[
"0000000000041cabad64c90b9a6011cc275bc4274d4483c9d08c61354f61f87b",
0
],
[
"000000000000c49853970e4dcc57747e09c025b20a1b5399feea8e4da52e838f",
0
],
[
"0000000000005bdae23cd7d0fd132f647baeff5bcf27c128af9d4452918ab935",
0
],
[
"00000000000005f2a7320a92d69ca7a43fd50c0f33663a3620fd3b1997d7ee07",
0
],
[
"00000000000000fdb92b762b90c620b7fbcfd638d2cb5885970e0d0bc78b39dc",
0
],
[
"00000000000000f8d6ac6a65d665cd5dec111aa7ce9d35e8ec8e1f35838be3ac",
0
],
[
"000000000000000c4d57eea373335af6bd32da3ff748277e80fc8c8448a50363",
0
],
[
"000000004b67788b148a9ec401da418e9b3d498064158e9c05a15e88fff770ea",
0
],
[
"000000000133f2f73a74c399ffe67f7a3556db333a70eb3cba385e3c8a8a0c2d",
0
],
[
"000000000c4ed1736d5cce4a0ec7633800645cd1b9a5d360fb68df326f48df14",
0
],
[
"000000000950c87b961f40073b6b940765037a331dc0f7abbfd4c2a93db2def7",
0
],
[
"000000000000333379fa00b0b1ec72dfe6177075d25800caae3bbea5ce0ed6fb",
0
],
[
"0000000000002b26a1064652fe46da8356565adcc084ea5998dee2b19de66dfb",
0
],
[
"00000000000077c470e5a167d683ea0584a8f7a4b20c63ad5ce502f054cee10b",
0
],
[
"0000000000001583cd8eb780acf4ea25ff4d915d02ba99ca280b26ed5b4ca7bd",
0
],
[
"0000000000007e9819e4ef5116a0a1ecfd973e15de5786879a3538fe9070de0c",
0
],
[
"000000000000352b18af7591d822bc692a5a360cfee0d261d53f91e5d221bbe4",
0
],
[
"0000000000001e7f3b9aefdb3dd75677b1b182467941550bab8cbf7906a555cb",
0
],
[
"0000000000000fb3fd1f154a1669e779555793c5d1d1c18cab76cbfae70b27d2",
0
],
[
"00000000000003b68cfba858740b14b9493bad9e6eb3bea3a4fcbe08cca911f4",
0
],
[
"000000000000002a909cd56c02a14c89deb9acd9ce0e28a0bc34aecb4ad9921b",
0
],
[
"000000000000008316ea71503de71d0f539ee3cce6b1fb3106ec49a580cdf012",
0
],
[
"00000000c8cd6b15785e1789435a1fae16fc4b6528144c5d49c5b071dd16b0dd",
0
],
[
"0000000000001837a16eae4f4d934fba9bd58abee864ee10c1a6768f13e276ba",
0
],
[
"0000000000003559bab6ad82347a5bcd62b07572a7de146234a373b9d09b5bc8",
0
],
[
"00000000000020cbeb38ca68af925b736cd6a0694a8152b5ea9c6b06af972e9d",
0
],
[
"00000000000886977197c1d7ce749af9951e7cb08c448eb1c1034509048793b7",
0
],
[
"00000000002b307d0991eab1cc694be1eb3c607ebf1ff0fadfedbb038baabac6",
0
],
[
"00000000000c6570eaea754f2f6075ea71a68b46c90c08b271d08009eaefdfc6",
0
],
[
"0000000000004a6b77b80f36d0b4c843642c472b14060c659db079488394a548",
0
],
[
"00000000000026c176ab34dee51c7d6bcd101ace82b9d3a27cae9158fe4615e7",
0
],
[
"00000000000008740ccd0cd6f875599e851bb50daa188da402dc2e82cbe63432",
0
],
[
"000000000000027e5f3c4cc7fb4929504cf0a67192473b936ae4ffad6121eba8",
0
],
[
"0000000000000528bf19fababa130f28a8082d004750ff42ca1c7f2bde6be14e",
0
],
[
"000000000000014455b175ee9e2218fea02a4f64ca4219dda6cb8014c266fdc6",
0
]
]
================================================
FILE: electrum/chains/testnet/fallback_lnnodes.json
================================================
{
"038863cf8ab91046230f561cd5b386cbff8309fa02e3f0c3ed161a3aeb64a643b9": {
"host": "203.132.95.10",
"port": 9735
},
"03236a685d30096b26692dce0cf0fa7c8528bdf61dbf5363a3ef6d5c92733a3016": {
"host": "50.116.3.223",
"port": 9734
},
"03d5e17a3c213fe490e1b0c389f8cfcfcea08a29717d50a9f453735e0ab2a7c003": {
"host": "3.16.119.191",
"port": 9735
},
"03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134": {
"host": "34.250.234.192",
"port": 9735
},
"0260d9119979caedc570ada883ff614c6efb93f7f7382e25d73ecbeba0b62df2d7": {
"host": "88.99.209.230",
"port": 9735
},
"023ea0a53af875580899da0ab0a21455d9c19160c4ea1b7774c9d4be6810b02d2c": {
"host": "160.16.233.215",
"port": 9735
},
"0269a94e8b32c005e4336bfb743c08a6e9beb13d940d57c479d95c8e687ccbdb9f": {
"host": "197.155.6.173",
"port": 9735
},
"030f0bf260acdbd3edcad84d7588ec7c5df4711e87e6a23016f989b8d3a4147230": {
"host": "163.172.94.64",
"port": 9735
},
"02312627fdf07fbdd7e5ddb136611bdde9b00d26821d14d94891395452f67af248": {
"host": "23.237.77.12",
"port": 9735
},
"02ae2f22b02375e3e9b4b4a2db4f12e1b50752b4062dbefd6e01332acdaf680379": {
"host": "197.155.6.172",
"port": 9735
},
"034fe52e98a0e9d3c21b767e1b371881265d8c7578c21f5afd6d6438da10348b36": {
"host": "23.239.23.44",
"port": 9740
},
"02889be42fc32093d2dcbfa59369df262e3577b333d8a45e5859dcdd6a4139839a": {
"host": "2a09:8280:1::42:a6f3",
"port": 9735
},
"021713d5331898c206b57c4f7d40635079de9a97d97782646f31dac18a53f2d979": {
"host": "2a09:8280:1::15:a57c",
"port": 9735
}
}
================================================
FILE: electrum/chains/testnet/servers.json
================================================
{
"blackie.c3-soft.com": {
"pruning": "-",
"s": "57006",
"t": "57005",
"version": "1.4.5"
},
"blockstream.info": {
"pruning": "-",
"s": "993",
"t": "143",
"version": "1.4"
},
"electrum.blockstream.info": {
"pruning": "-",
"s": "60002",
"t": "60001",
"version": "1.4"
},
"explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion": {
"pruning": "-",
"t": "143",
"version": "1.4"
},
"testnet.aranguren.org": {
"pruning": "-",
"s": "51002",
"t": "51001",
"version": "1.4.2"
},
"testnet.hsmiths.com": {
"pruning": "-",
"s": "53012",
"version": "1.4.2"
},
"testnet.qtornado.com": {
"pruning": "-",
"s": "51002",
"t": "51001",
"version": "1.5"
},
"tn.not.fyi": {
"pruning": "-",
"s": "55002",
"t": "55001",
"version": "1.4"
}
}
================================================
FILE: electrum/chains/testnet4/checkpoints.json
================================================
[
[
"00000000962a7fc2ef639196051fe181ed53ac6aa4cdfead14dca90f58aa36bc",
0
],
[
"000000002ad661157c553c0bbbb2490407adb1c8ac09f2b2a7174f87eeeb64bf",
0
],
[
"000000000be3ff43cde9eed4d6b2d4ad16c4f9509ccb94e1001af68e2f6647b3",
0
],
[
"00000000001ef2e4c2fc174354ed357cf313725fc336092733b2699d36342ff8",
0
],
[
"000000000025269f9fa4b0832ccbfef682d59c0fa8845b0c22cc24a1973f011a",
0
],
[
"000000000014b2d6b2ad804d5deb8d5b4a58caf152f6cea5600af0d9348dff29",
0
],
[
"000000000003c067c302d43c9499da6e382260252a2a29caf9748ee6972d5f01",
0
],
[
"000000000002180d23f15ba0b8161d9d38d03c61ab51d050c57928e1a7d98e0c",
0
],
[
"000000000000ed8722220a13b09d968a59686af5fc5c1e0a86371a498209fa72",
0
],
[
"0000000000003e82df3830ff7c05a58745a463a59d1097e160e47ac7aeb5323a",
0
],
[
"0000000000000c3f18b9a30269c4b53dd107bacf20482e4ec660e9970999a99f",
0
],
[
"00000000000002901853780dc8a63efd4d72359d8de7e14dc0398ccfc53d45cd",
0
],
[
"00000000000000a6ff1615113d25eeed8554813e4994f8ef7ce96458083d14cf",
0
],
[
"000000000000006e2d4fa8204c67c0986f9bb0214990b11043d0653d50755f54",
0
],
[
"00000000eaf8e0ea253d833614892aed70c55e5dc4b4d6709dd6420b8284debb",
0
],
[
"0000000000000063d3ca489d113ded6196c99f3785b61a8ded9254ebb96bc765",
0
],
[
"000000000000003f684cab6cdb7fe6e98cb13318bb45acdc2d2e2d7405b8bcbe",
0
],
[
"000000000000001f735b5a23732fb201cf6343b373c94a35f04e6b6075591889",
0
],
[
"000000001c247a1eb479ecc56ea7d7529f0c4afb6b7025f437a7d235454cd6a4",
0
],
[
"00000000acd1400a4801f361d675644993ad05e5b735a881f26746ece767521e",
0
],
[
"00000000542792e54a720567ba66157d48cdae7bfd01c1b678d0f07a2ed56e99",
0
],
[
"00000000ca301f565989627133247615bc937b52c68f8f4b342b6c2aeebff7ba",
0
],
[
"00000000e4ad2ec95dfddce6a554f626c9995e465b067e72528f6ae164fc58d2",
0
],
[
"00000000761cd6bff5e11258943e401e1bb094a8013e810e1d6031ce273a4b7c",
0
],
[
"00000000082032b915c151f1bb9892fe924013539924a34ec8b9bdea16eb7374",
0
],
[
"0000000000853161fa2a440407ce597524e85db6a27f5dffd35162b38e7627e7",
0
],
[
"00000000001c7ef1bace4a08448d9e462ff8b2e8c389f16026834c5d0c97252c",
0
],
[
"000000000ae11eebf9807d5ac0f9966282c59a1e613e229aa2bf7aea266d4535",
0
],
[
"00000000973e9926efc6a2a11413f7125242911ae10925e3616e872307a3e401",
0
],
[
"0000000000000003d66460a7e1ed89080427ac2004b2b3adae05e6e89725dc1f",
0
],
[
"000000009f8fe2dbafd82a0432d69d620d0ebeea033c8a2621bf06a6e6d3b5c4",
0
],
[
"0000000000000003ac6ce3118b61709d347142cc04daeb2c973b937904e4390b",
0
],
[
"0000000000003844aab8ac81fb69b47e83c037cee505bfd5cb6522aa4d2351bc",
0
],
[
"00000000000060397e5918c319db04ffac74f2a5c7724083112fca8958ea54de",
0
],
[
"00000000213b70c1bcec26b90a5503960a95d7a5fa5d9de498b9d794afeaebd5",
0
],
[
"0000000000000e5f474161d1b68932ab607035f5026f1ac4aa5816f24e76017a",
0
],
[
"00000000b56d4faf52043bfe98e126c69fb51671ba9bcea67c191620255d291d",
0
],
[
"0000000000052b94098008985919f6b525ead0d5cb5608fd60015f2647701ea1",
0
],
[
"00000000e5c06639c50bdb710b84ebf58d1df666db033157db685ec592932276",
0
],
[
"000000006f596242c0b5dd79b5f638e95a215252c44374133a5b263fcb5e9f89",
0
],
[
"00000000a2dc7b8c63c737543dd41e5a7a529d1824c32e2d454cae998c5a7298",
0
],
[
"000000008a995172d20119cc9b48a28ea4bf350711c2925849e5db760d0bde05",
0
],
[
"000000000bcec41b64702945a8cc7aceb06bc51fa93528b39619180ddc03e9bd",
0
],
[
"00000000003f767fd29141200c4b64fc0b224edcf28ef65f66c6345b76a60f32",
0
],
[
"00000000046883bd13b616c3525b243bbf3c7a9688a66f8ce46506ec25f6e798",
0
],
[
"00000000068670ee282a076d17900a07da93da50b3705405b1e7ca8616856535",
0
],
[
"0000000026b1f5d8ffa89385b048ca9004b97b412eee44000810ea8177a4bef4",
0
],
[
"0000000000000002e72644dda2132c93ac54edd6c155f47c51ca8cf4b918c0bf",
0
],
[
"00000000000000000a20f50c208d1ae3c3397fc6059b2cf1fe6b698e42023178",
0
],
[
"000000000a0b5c3e57dd4c3b751d13562a92953d794e19aff8f9e2ca6607604f",
0
],
[
"0000000003b9f6443b23bf86f200ef85005dfc508564960812eb85774327e6ce",
0
],
[
"000000000668f8e866d39a9ec07937e716a6035a02cfd0757ee716ac7cdca2fd",
0
],
[
"00000000041c239cab44d0afc2014a1351250375e796c692298ba8a8fcdb41de",
0
],
[
"0000000008c8148de5f2a96c0b82b32f8726ef7061386d370a68e078ade8e3e5",
0
],
[
"00000000089663c835fe83325ffaced64ad9c924468d1e1339f400bf8ed57883",
0
],
[
"00000000039229460de251e6b8b303841f76a1bc022025e8ca66a6ace20ffdf3",
0
],
[
"000000006b606bff2dfa618d2975fdfc2bea491f558ebe16be2ca7a0aa1c0181",
0
],
[
"0000000005dfc1e853b8d644598b77bdd97ef7578f9ccbb840aa3860fda9ddac",
0
],
[
"0000000002e9de570afba91146a43378199cc3b38fd52b2f4f1989734d22084a",
0
],
[
"000000000d1359300f79d95e0db59f4c14099684718838720db7569d98348844",
0
]
]
================================================
FILE: electrum/chains/testnet4/servers.json
================================================
{
"testnet4-electrumx.wakiyamap.dev": {
"pruning": "-",
"s": "51002",
"t": "51001",
"version": "1.4"
},
"blackie.c3-soft.com": {
"pruning": "-",
"s": "57010",
"t": "57009",
"version": "1.4"
},
"mempool.space": {
"pruning": "-",
"s": "40002",
"version": "1.4"
}
}
================================================
FILE: electrum/channel_db.py
================================================
# -*- coding: utf-8 -*-
#
# 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.
import ipaddress
import time
import random
import os
from collections import defaultdict
from typing import Sequence, List, Tuple, Optional, Dict, NamedTuple, TYPE_CHECKING, Set
import binascii
import base64
import asyncio
import threading
from enum import IntEnum
import functools
from aiorpcx import NetAddress
from electrum_ecc import ECPubkey
from .sql_db import SqlDB, sql
from . import constants, util
from .util import profiler, get_headers_dir, is_ip_address, json_normalize, UserFacingException, is_private_netaddress
from .lntransport import LNPeerAddr
from .lnutil import (ShortChannelID, validate_features, IncompatibleOrInsaneFeatures,
InvalidGossipMsg, GossipForwardingMessage, GossipTimestampFilter)
from .lnverifier import LNChannelVerifier, verify_sig_for_channel_update
from .lnmsg import decode_msg
from .crypto import sha256d
from .lnmsg import FailedToParseMsg
if TYPE_CHECKING:
from .network import Network
from .lnchannel import Channel
from .lnrouter import RouteEdge
from .simple_config import SimpleConfig
FLAG_DISABLE = 1 << 1
FLAG_DIRECTION = 1 << 0
class ChannelDBNotLoaded(UserFacingException): pass
class ChannelInfo(NamedTuple):
short_channel_id: ShortChannelID
node1_id: bytes
node2_id: bytes
capacity_sat: Optional[int]
raw: Optional[bytes] = None
@staticmethod
def from_msg(payload: dict) -> 'ChannelInfo':
features = int.from_bytes(payload['features'], 'big')
features = validate_features(features)
channel_id = payload['short_channel_id']
node_id_1 = payload['node_id_1']
node_id_2 = payload['node_id_2']
assert list(sorted([node_id_1, node_id_2])) == [node_id_1, node_id_2]
capacity_sat = None
return ChannelInfo(
short_channel_id = ShortChannelID.normalize(channel_id),
node1_id = node_id_1,
node2_id = node_id_2,
capacity_sat = capacity_sat,
raw = payload.get('raw')
)
@staticmethod
def from_raw_msg(raw: bytes) -> 'ChannelInfo':
payload_dict = decode_msg(raw)[1]
payload_dict['raw'] = raw
return ChannelInfo.from_msg(payload_dict)
@staticmethod
def from_route_edge(route_edge: 'RouteEdge') -> 'ChannelInfo':
node1_id, node2_id = sorted([route_edge.start_node, route_edge.end_node])
return ChannelInfo(
short_channel_id=route_edge.short_channel_id,
node1_id=node1_id,
node2_id=node2_id,
capacity_sat=None,
)
class Policy(NamedTuple):
key: bytes
cltv_delta: int
htlc_minimum_msat: int
htlc_maximum_msat: Optional[int]
fee_base_msat: int
fee_proportional_millionths: int
channel_flags: int
message_flags: int
timestamp: int
raw: Optional[bytes] = None
@staticmethod
def from_msg(payload: dict) -> 'Policy':
return Policy(
key = payload['short_channel_id'] + payload['start_node'],
cltv_delta = payload['cltv_expiry_delta'],
htlc_minimum_msat = payload['htlc_minimum_msat'],
htlc_maximum_msat = payload.get('htlc_maximum_msat', None),
fee_base_msat = payload['fee_base_msat'],
fee_proportional_millionths = payload['fee_proportional_millionths'],
message_flags = int.from_bytes(payload['message_flags'], "big"),
channel_flags = int.from_bytes(payload['channel_flags'], "big"),
timestamp = payload['timestamp'],
raw = payload.get('raw'),
)
@staticmethod
def from_raw_msg(key: bytes, raw: bytes) -> 'Policy':
payload = decode_msg(raw)[1]
payload['start_node'] = key[8:]
payload['raw'] = raw
return Policy.from_msg(payload)
@staticmethod
def from_route_edge(route_edge: 'RouteEdge') -> 'Policy':
return Policy(
key=route_edge.short_channel_id + route_edge.start_node,
cltv_delta=route_edge.cltv_delta,
htlc_minimum_msat=0,
htlc_maximum_msat=None,
fee_base_msat=route_edge.fee_base_msat,
fee_proportional_millionths=route_edge.fee_proportional_millionths,
channel_flags=0,
message_flags=0,
timestamp=0,
)
def is_disabled(self):
return self.channel_flags & FLAG_DISABLE
@property
def short_channel_id(self) -> ShortChannelID:
return ShortChannelID.normalize(self.key[0:8])
@property
def start_node(self) -> bytes:
return self.key[8:]
class NodeInfo(NamedTuple):
node_id: bytes
features: int
timestamp: int
alias: str
raw: Optional[bytes]
@staticmethod
def from_msg(payload) -> Tuple['NodeInfo', Sequence['LNPeerAddr']]:
node_id = payload['node_id']
features = int.from_bytes(payload['features'], "big")
features = validate_features(features)
addresses = NodeInfo.parse_addresses_field(payload['addresses'])
peer_addrs = []
for host, port in addresses:
try:
peer_addrs.append(LNPeerAddr(host=host, port=port, pubkey=node_id))
except ValueError:
pass
alias = payload['alias'].rstrip(b'\x00')
try:
alias = alias.decode('utf8')
except Exception:
alias = ''
timestamp = payload['timestamp']
node_info = NodeInfo(
node_id=node_id,
features=features,
timestamp=timestamp,
alias=alias,
raw=payload.get('raw'))
return node_info, peer_addrs
@staticmethod
def from_raw_msg(raw: bytes) -> Tuple['NodeInfo', Sequence['LNPeerAddr']]:
payload_dict = decode_msg(raw)[1]
payload_dict['raw'] = raw
return NodeInfo.from_msg(payload_dict)
@staticmethod
def to_addresses_field(hostname: str, port: int) -> bytes:
"""Encodes a hostname/port pair into a BOLT-7 'addresses' field."""
if (NodeInfo.invalid_announcement_hostname(hostname)
or port is None or port <= 0 or port > 65535):
return b''
port_bytes = port.to_bytes(2, 'big')
if is_ip_address(hostname): # ipv4 or ipv6
ip_addr = ipaddress.ip_address(hostname)
if ip_addr.version == 4:
return b'\x01' + ip_addr.packed + port_bytes
elif ip_addr.version == 6:
return b'\x02' + ip_addr.packed + port_bytes
return b''
elif hostname.endswith('.onion'): # Tor onion v3
onion_addr: bytes = base64.b32decode(hostname[:-6], casefold=True)
return b'\x04' + onion_addr + port_bytes
else:
try:
hostname_ascii: bytes = hostname.encode('ascii')
except UnicodeEncodeError:
# encoding single characters to punycode (according to spec) doesn't make sense
# as you can't differentiate them from regular ascii? encoding the whole string to punycode
# doesn't work either as the receiver would interpret it as regular ascii.
# hostname_ascii: bytes = hostname.encode('punycode')
return b''
if len(hostname_ascii) + 3 > 258: # + 1 byte for length and 2 for port
return b'' # too long
return b'\x05' + len(hostname_ascii).to_bytes(1, "big") + hostname_ascii + port_bytes
@staticmethod
def invalid_announcement_hostname(hostname: Optional[str]) -> bool:
"""Returns True if hostname unsuited for publishing in a NodeAnnouncement."""
if (hostname is None or hostname == ""
or is_private_netaddress(hostname)
or hostname.startswith("http://") # not catching 'http' due to onion addresses
or hostname.startswith("https://")):
return True
if hostname.endswith('.onion'):
if len(hostname) != 62: # not an onion v3 link (probably onion v2)
return True
return False
@staticmethod
def parse_addresses_field(addresses_field):
buf = addresses_field
def read(n):
nonlocal buf
data, buf = buf[0:n], buf[n:]
return data
addresses = []
while buf:
atype = ord(read(1))
if atype == 0:
pass
elif atype == 1: # IPv4
ipv4_addr = '.'.join(map(lambda x: '%d' % x, read(4)))
port = int.from_bytes(read(2), 'big')
if is_ip_address(ipv4_addr) and port != 0:
addresses.append((ipv4_addr, port))
elif atype == 2: # IPv6
ipv6_addr = b':'.join([binascii.hexlify(read(2)) for i in range(8)])
ipv6_addr = ipv6_addr.decode('ascii')
port = int.from_bytes(read(2), 'big')
if is_ip_address(ipv6_addr) and port != 0:
addresses.append((ipv6_addr, port))
elif atype == 3: # onion v2
read(12) # we skip onion v2 as it is deprecated
elif atype == 4: # onion v3
host = base64.b32encode(read(35)) + b'.onion'
host = host.decode('ascii').lower()
port = int.from_bytes(read(2), 'big')
addresses.append((host, port))
elif atype == 5: # dns hostname
len_hostname = int.from_bytes(read(1), 'big')
host = read(len_hostname).decode('ascii')
port = int.from_bytes(read(2), 'big')
if not NodeInfo.invalid_announcement_hostname(host) and port > 0:
addresses.append((host, port))
else:
# unknown address type
# we don't know how long it is -> have to escape
# if there are other addresses we could have parsed later, they are lost.
break
return addresses
class UpdateStatus(IntEnum):
ORPHANED = 0
EXPIRED = 1
DEPRECATED = 2
UNCHANGED = 3
GOOD = 4
class CategorizedChannelUpdates(NamedTuple):
orphaned: List # no channel announcement for channel update
expired: List # update older than two weeks
deprecated: List # update older than database entry
unchanged: List # unchanged policies
good: List # good updates
def get_mychannel_info(short_channel_id: ShortChannelID,
my_channels: Dict[ShortChannelID, 'Channel']) -> Optional[ChannelInfo]:
chan = my_channels.get(short_channel_id)
if not chan:
return
raw_msg, _ = chan.construct_channel_announcement_without_sigs()
ci = ChannelInfo.from_raw_msg(raw_msg)
return ci._replace(capacity_sat=chan.constraints.capacity)
def get_mychannel_policy(short_channel_id: bytes, node_id: bytes,
my_channels: Dict[ShortChannelID, 'Channel']) -> Optional[Policy]:
chan = my_channels.get(short_channel_id) # type: Optional[Channel]
if not chan:
return
if node_id == chan.node_id: # incoming direction (to us)
remote_update_raw = chan.get_remote_update()
if not remote_update_raw:
return
now = int(time.time())
remote_update_decoded = decode_msg(remote_update_raw)[1]
remote_update_decoded['timestamp'] = now
remote_update_decoded['start_node'] = node_id
return Policy.from_msg(remote_update_decoded)
elif node_id == chan.get_local_pubkey(): # outgoing direction (from us)
local_update_decoded = decode_msg(chan.get_outgoing_gossip_channel_update())[1]
local_update_decoded['start_node'] = node_id
return Policy.from_msg(local_update_decoded)
class _LoadDataAborted(Exception): pass
create_channel_info = """
CREATE TABLE IF NOT EXISTS channel_info (
short_channel_id BLOB(8),
msg BLOB,
PRIMARY KEY(short_channel_id)
)"""
create_policy = """
CREATE TABLE IF NOT EXISTS policy (
key BLOB(41),
msg BLOB,
PRIMARY KEY(key)
)"""
create_address = """
CREATE TABLE IF NOT EXISTS address (
node_id BLOB(33),
host STRING(256),
port INTEGER NOT NULL,
timestamp INTEGER,
PRIMARY KEY(node_id, host, port)
)"""
create_node_info = """
CREATE TABLE IF NOT EXISTS node_info (
node_id BLOB(33),
msg BLOB,
PRIMARY KEY(node_id)
)"""
class ChannelDB(SqlDB):
NUM_MAX_RECENT_PEERS = 20
PRIVATE_CHAN_UPD_CACHE_TTL_NORMAL = 600
PRIVATE_CHAN_UPD_CACHE_TTL_SHORT = 120
def __init__(self, network: 'Network'):
path = self.get_file_path(network.config)
super().__init__(network.asyncio_loop, path, commit_interval=100)
self.lock = threading.RLock()
self.num_nodes = 0
self.num_channels = 0
self.num_policies = 0
self._channel_updates_for_private_channels = {} # type: Dict[Tuple[bytes, bytes], Tuple[dict, int]]
# note: ^ we could maybe move this cache into PaySession instead of being global.
# That would only make sense though if PaySessions were never too short
# (e.g. consider trampoline forwarding).
self.ca_verifier = LNChannelVerifier(network, self)
# initialized in load_data
# note: modify/iterate needs self.lock
self._channels = {} # type: Dict[ShortChannelID, ChannelInfo]
self._policies = {} # type: Dict[Tuple[bytes, ShortChannelID], Policy] # (node_id, scid) -> Policy
self._nodes = {} # type: Dict[bytes, NodeInfo] # node_id -> NodeInfo
# node_id -> NetAddress -> timestamp
self._addresses = defaultdict(dict) # type: Dict[bytes, Dict[NetAddress, int]]
self._channels_for_node = defaultdict(set) # type: Dict[bytes, Set[ShortChannelID]]
self._recent_peers = [] # type: List[bytes] # list of node_ids
self._chans_with_0_policies = set() # type: Set[ShortChannelID]
self._chans_with_1_policies = set() # type: Set[ShortChannelID]
self._chans_with_2_policies = set() # type: Set[ShortChannelID]
self.forwarding_lock = threading.RLock()
self.fwd_channels = [] # type: List[GossipForwardingMessage]
self.fwd_orphan_channels = [] # type: List[GossipForwardingMessage]
self.fwd_channel_updates = [] # type: List[GossipForwardingMessage]
self.fwd_node_announcements = [] # type: List[GossipForwardingMessage]
self.data_loaded = asyncio.Event()
self.network = network # only for callback
@classmethod
def get_file_path(cls, config: 'SimpleConfig') -> str:
return os.path.join(get_headers_dir(config), 'gossip_db')
def update_counts(self):
self.num_nodes = len(self._nodes)
self.num_channels = len(self._channels)
self.num_policies = len(self._policies)
util.trigger_callback('channel_db', self.num_nodes, self.num_channels, self.num_policies)
util.trigger_callback('ln_gossip_sync_progress')
def get_channel_ids(self):
with self.lock:
return set(self._channels.keys())
def add_recent_peer(self, peer: LNPeerAddr):
now = int(time.time())
node_id = peer.pubkey
with self.lock:
self._addresses[node_id][peer.net_addr()] = now
# list is ordered
if node_id in self._recent_peers:
self._recent_peers.remove(node_id)
self._recent_peers.insert(0, node_id)
self._recent_peers = self._recent_peers[:self.NUM_MAX_RECENT_PEERS]
self._db_save_node_address(peer, now)
def get_200_randomly_sorted_nodes_not_in(self, node_ids):
with self.lock:
unshuffled = set(self._nodes.keys()) - node_ids
return random.sample(list(unshuffled), min(200, len(unshuffled)))
def get_last_good_address(self, node_id: bytes) -> Optional[LNPeerAddr]:
"""Returns latest address we successfully connected to, for given node."""
addr_to_ts = self._addresses.get(node_id)
if not addr_to_ts:
return None
addr = sorted(list(addr_to_ts), key=lambda a: addr_to_ts[a], reverse=True)[0]
try:
return LNPeerAddr(str(addr.host), addr.port, node_id)
except ValueError:
return None
def get_recent_peers(self):
if not self.data_loaded.is_set():
raise ChannelDBNotLoaded("channelDB data not loaded yet!")
with self.lock:
ret = [self.get_last_good_address(node_id)
for node_id in self._recent_peers]
return ret
# note: currently channel announcements are trusted by default (trusted=True);
# they are not SPV-verified. Verifying them would make the gossip sync
# even slower; especially as servers will start throttling us.
# It would probably put significant strain on servers if all clients
# verified the complete gossip.
def add_channel_announcements(self, msg_payloads, *, trusted=True):
# note: signatures have already been verified.
if type(msg_payloads) is dict:
msg_payloads = [msg_payloads]
added = 0
for msg in msg_payloads:
short_channel_id = ShortChannelID(msg['short_channel_id'])
if short_channel_id in self._channels:
continue
if constants.net.rev_genesis_bytes() != msg['chain_hash']:
self.logger.info("ChanAnn has unexpected chain_hash {}".format(msg['chain_hash'].hex()))
continue
try:
channel_info = ChannelInfo.from_msg(msg)
except IncompatibleOrInsaneFeatures as e:
self.logger.info(f"unknown or insane feature bits: {e!r}")
continue
if trusted:
added += 1
self.add_verified_channel_info(msg)
else:
added += self.ca_verifier.add_new_channel_info(short_channel_id, msg)
self.update_counts()
def add_verified_channel_info(self, msg: dict, *, capacity_sat: int = None) -> None:
try:
channel_info = ChannelInfo.from_msg(msg)
except IncompatibleOrInsaneFeatures:
return
channel_info = channel_info._replace(capacity_sat=capacity_sat)
with self.lock:
self._channels[channel_info.short_channel_id] = channel_info
self._channels_for_node[channel_info.node1_id].add(channel_info.short_channel_id)
self._channels_for_node[channel_info.node2_id].add(channel_info.short_channel_id)
self._update_num_policies_for_chan(channel_info.short_channel_id)
if 'raw' in msg:
self._db_save_channel(channel_info.short_channel_id, msg['raw'])
with self.forwarding_lock:
if fwd_msg := GossipForwardingMessage.from_payload(msg):
self.fwd_channels.append(fwd_msg)
def policy_changed(self, old_policy: Policy, new_policy: Policy, verbose: bool) -> bool:
changed = False
if old_policy.cltv_delta != new_policy.cltv_delta:
changed |= True
if verbose:
self.logger.info(f'cltv_expiry_delta: {old_policy.cltv_delta} -> {new_policy.cltv_delta}')
if old_policy.htlc_minimum_msat != new_policy.htlc_minimum_msat:
changed |= True
if verbose:
self.logger.info(f'htlc_minimum_msat: {old_policy.htlc_minimum_msat} -> {new_policy.htlc_minimum_msat}')
if old_policy.htlc_maximum_msat != new_policy.htlc_maximum_msat:
changed |= True
if verbose:
self.logger.info(f'htlc_maximum_msat: {old_policy.htlc_maximum_msat} -> {new_policy.htlc_maximum_msat}')
if old_policy.fee_base_msat != new_policy.fee_base_msat:
changed |= True
if verbose:
self.logger.info(f'fee_base_msat: {old_policy.fee_base_msat} -> {new_policy.fee_base_msat}')
if old_policy.fee_proportional_millionths != new_policy.fee_proportional_millionths:
changed |= True
if verbose:
self.logger.info(f'fee_proportional_millionths: {old_policy.fee_proportional_millionths} -> {new_policy.fee_proportional_millionths}')
if old_policy.channel_flags != new_policy.channel_flags:
changed |= True
if verbose:
self.logger.info(f'channel_flags: {old_policy.channel_flags} -> {new_policy.channel_flags}')
if old_policy.message_flags != new_policy.message_flags:
changed |= True
if verbose:
self.logger.info(f'message_flags: {old_policy.message_flags} -> {new_policy.message_flags}')
if not changed and verbose:
self.logger.info(f'policy unchanged: {old_policy.timestamp} -> {new_policy.timestamp}')
return changed
def add_channel_update(
self, payload, *, max_age=None, verify=True, verbose=True) -> UpdateStatus:
now = int(time.time())
short_channel_id = ShortChannelID(payload['short_channel_id'])
timestamp = payload['timestamp']
if max_age and now - timestamp > max_age:
return UpdateStatus.EXPIRED
if timestamp - now > 60:
return UpdateStatus.DEPRECATED
channel_info = self._channels.get(short_channel_id)
if not channel_info:
return UpdateStatus.ORPHANED
flags = int.from_bytes(payload['channel_flags'], 'big')
direction = flags & FLAG_DIRECTION
start_node = channel_info.node1_id if direction == 0 else channel_info.node2_id
payload['start_node'] = start_node
# compare updates to existing database entries
short_channel_id = ShortChannelID(payload['short_channel_id'])
key = (start_node, short_channel_id)
old_policy = self._policies.get(key)
if old_policy and timestamp <= old_policy.timestamp + 60:
return UpdateStatus.DEPRECATED
if verify:
self.verify_channel_update(payload)
policy = Policy.from_msg(payload)
with self.lock:
self._policies[key] = policy
self._update_num_policies_for_chan(short_channel_id)
if 'raw' in payload:
self._db_save_policy(policy.key, payload['raw'])
if old_policy and not self.policy_changed(old_policy, policy, verbose):
return UpdateStatus.UNCHANGED
else:
if policy.message_flags & 0b10 == 0: # check if its `dont_forward`
with self.forwarding_lock:
if fwd_msg := GossipForwardingMessage.from_payload(payload):
self.fwd_channel_updates.append(fwd_msg)
return UpdateStatus.GOOD
def add_channel_updates(self, payloads, max_age=None) -> CategorizedChannelUpdates:
orphaned = []
expired = []
deprecated = []
unchanged = []
good = []
for payload in payloads:
r = self.add_channel_update(payload, max_age=max_age, verbose=False, verify=True)
if r == UpdateStatus.ORPHANED:
orphaned.append(payload)
elif r == UpdateStatus.EXPIRED:
expired.append(payload)
elif r == UpdateStatus.DEPRECATED:
deprecated.append(payload)
elif r == UpdateStatus.UNCHANGED:
unchanged.append(payload)
elif r == UpdateStatus.GOOD:
good.append(payload)
self.update_counts()
return CategorizedChannelUpdates(
orphaned=orphaned,
expired=expired,
deprecated=deprecated,
unchanged=unchanged,
good=good)
def create_database(self):
c = self.conn.cursor()
c.execute(create_node_info)
c.execute(create_address)
c.execute(create_policy)
c.execute(create_channel_info)
self.conn.commit()
@sql
def _db_save_policy(self, key: bytes, msg: bytes):
# 'msg' is a 'channel_update' message
c = self.conn.cursor()
c.execute("""REPLACE INTO policy (key, msg) VALUES (?,?)""", [key, msg])
@sql
def _db_delete_policy(self, node_id: bytes, short_channel_id: ShortChannelID):
key = short_channel_id + node_id
c = self.conn.cursor()
c.execute("""DELETE FROM policy WHERE key=?""", (key,))
@sql
def _db_save_channel(self, short_channel_id: ShortChannelID, msg: bytes):
# 'msg' is a 'channel_announcement' message
c = self.conn.cursor()
c.execute("REPLACE INTO channel_info (short_channel_id, msg) VALUES (?,?)", [short_channel_id, msg])
@sql
def _db_delete_channel(self, short_channel_id: ShortChannelID):
c = self.conn.cursor()
c.execute("""DELETE FROM channel_info WHERE short_channel_id=?""", (short_channel_id,))
@sql
def _db_save_node_info(self, node_id: bytes, msg: bytes):
# 'msg' is a 'node_announcement' message
c = self.conn.cursor()
c.execute("REPLACE INTO node_info (node_id, msg) VALUES (?,?)", [node_id, msg])
@sql
def _db_save_node_address(self, peer: LNPeerAddr, timestamp: int):
c = self.conn.cursor()
c.execute("REPLACE INTO address (node_id, host, port, timestamp) VALUES (?,?,?,?)",
(peer.pubkey, peer.host, peer.port, timestamp))
@sql
def _db_save_node_addresses(self, node_addresses: Sequence[LNPeerAddr]):
c = self.conn.cursor()
for addr in node_addresses:
c.execute("SELECT * FROM address WHERE node_id=? AND host=? AND port=?", (addr.pubkey, addr.host, addr.port))
r = c.fetchall()
if r == []:
c.execute("INSERT INTO address (node_id, host, port, timestamp) VALUES (?,?,?,?)", (addr.pubkey, addr.host, addr.port, 0))
@classmethod
def verify_channel_update(cls, payload, *, start_node: bytes = None) -> None:
short_channel_id = payload['short_channel_id']
short_channel_id = ShortChannelID(short_channel_id)
if constants.net.rev_genesis_bytes() != payload['chain_hash']:
raise InvalidGossipMsg('wrong chain hash')
start_node = payload.get('start_node', None) or start_node
assert start_node is not None
if not verify_sig_for_channel_update(payload, start_node):
raise InvalidGossipMsg(f'failed verifying channel update for {short_channel_id}')
@classmethod
def verify_channel_announcement(cls, payload) -> None:
h = sha256d(payload['raw'][2+256:])
pubkeys = [payload['node_id_1'], payload['node_id_2'], payload['bitcoin_key_1'], payload['bitcoin_key_2']]
sigs = [payload['node_signature_1'], payload['node_signature_2'], payload['bitcoin_signature_1'], payload['bitcoin_signature_2']]
for pubkey, sig in zip(pubkeys, sigs):
if not ECPubkey(pubkey).ecdsa_verify(sig, h):
raise InvalidGossipMsg('signature failed')
@classmethod
def verify_node_announcement(cls, payload) -> None:
pubkey = payload['node_id']
signature = payload['signature']
h = sha256d(payload['raw'][66:])
if not ECPubkey(pubkey).ecdsa_verify(signature, h):
raise InvalidGossipMsg('signature failed')
def add_node_announcements(self, msg_payloads):
# note: signatures have already been verified.
if type(msg_payloads) is dict:
msg_payloads = [msg_payloads]
new_nodes = set() # type: Set[bytes]
for msg_payload in msg_payloads:
try:
node_info, node_addresses = NodeInfo.from_msg(msg_payload)
except IncompatibleOrInsaneFeatures:
continue
node_id = node_info.node_id
# Ignore node if it has no associated channel (DoS protection)
if node_id not in self._channels_for_node:
#self.logger.info('ignoring orphan node_announcement')
continue
node = self._nodes.get(node_id)
if node and node.timestamp >= node_info.timestamp:
continue
new_nodes.add(node_id)
# save
with self.lock:
self._nodes[node_id] = node_info
if 'raw' in msg_payload:
self._db_save_node_info(node_id, msg_payload['raw'])
with self.lock:
for addr in node_addresses:
net_addr = NetAddress(addr.host, addr.port)
self._addresses[node_id][net_addr] = self._addresses[node_id].get(net_addr) or 0
self._db_save_node_addresses(node_addresses)
with self.forwarding_lock:
if fwd_msg := GossipForwardingMessage.from_payload(msg_payload):
self.fwd_node_announcements.append(fwd_msg)
self.update_counts()
def get_old_policies(self, delta) -> Sequence[Tuple[bytes, ShortChannelID]]:
with self.lock:
_policies = self._policies.copy()
now = int(time.time())
return list(k for k, v in _policies.items() if v.timestamp <= now - delta)
def prune_old_policies(self, delta):
old_policies = self.get_old_policies(delta)
if old_policies:
for key in old_policies:
node_id, scid = key
with self.lock:
self._policies.pop(key)
self._db_delete_policy(*key)
self._update_num_policies_for_chan(scid)
self.update_counts()
self.logger.info(f'Deleting {len(old_policies)} old policies')
def prune_orphaned_channels(self):
with self.lock:
orphaned_chans = self._chans_with_0_policies.copy()
if orphaned_chans:
for short_channel_id in orphaned_chans:
self.remove_channel(short_channel_id)
self.update_counts()
self.logger.info(f'Deleting {len(orphaned_chans)} orphaned channels')
def _get_channel_update_for_private_channel(
self,
start_node_id: bytes,
short_channel_id: ShortChannelID,
*,
now: int = None, # unix ts
) -> Optional[dict]:
if now is None:
now = int(time.time())
key = (start_node_id, short_channel_id)
chan_upd_dict, cache_expiration = self._channel_updates_for_private_channels.get(key, (None, 0))
if cache_expiration < now:
chan_upd_dict = None # already expired
# TODO rm expired entries from cache (note: perf vs thread-safety)
return chan_upd_dict
def add_channel_update_for_private_channel(
self,
msg_payload: dict,
start_node_id: bytes,
*,
cache_ttl: int = None, # seconds
) -> bool:
"""Returns True iff the channel update was successfully added and it was different than
what we had before (if any).
"""
if not verify_sig_for_channel_update(msg_payload, start_node_id):
return False # ignore
now = int(time.time())
short_channel_id = ShortChannelID(msg_payload['short_channel_id'])
msg_payload['start_node'] = start_node_id
prev_chanupd = self._get_channel_update_for_private_channel(start_node_id, short_channel_id, now=now)
if prev_chanupd == msg_payload:
return False
if cache_ttl is None:
cache_ttl = self.PRIVATE_CHAN_UPD_CACHE_TTL_NORMAL
cache_expiration = now + cache_ttl
key = (start_node_id, short_channel_id)
with self.lock:
self._channel_updates_for_private_channels[key] = msg_payload, cache_expiration
return True
def remove_channel(self, short_channel_id: ShortChannelID):
# FIXME what about rm-ing policies?
with self.lock:
channel_info = self._channels.pop(short_channel_id, None)
if channel_info:
self._channels_for_node[channel_info.node1_id].remove(channel_info.short_channel_id)
self._channels_for_node[channel_info.node2_id].remove(channel_info.short_channel_id)
self._update_num_policies_for_chan(short_channel_id)
# delete from database
self._db_delete_channel(short_channel_id)
def get_node_addresses(self, node_id: bytes) -> Sequence[Tuple[str, int, int]]:
"""Returns list of (host, port, timestamp)."""
addr_to_ts = self._addresses.get(node_id)
if not addr_to_ts:
return []
return [(str(net_addr.host), net_addr.port, ts)
for net_addr, ts in addr_to_ts.items()]
def handle_abort(func):
@functools.wraps(func)
def wrapper(self: 'ChannelDB', *args, **kwargs):
try:
return func(self, *args, **kwargs)
except _LoadDataAborted:
return
return wrapper
@sql
@profiler
@handle_abort
def load_data(self):
if self.data_loaded.is_set():
return
# Note: this method takes several seconds... mostly due to lnmsg.decode_msg being slow.
def maybe_abort():
if self.stopping:
self.logger.info("load_data() was asked to stop. exiting early.")
raise _LoadDataAborted()
c = self.conn.cursor()
c.execute("""SELECT * FROM address""")
for x in c:
maybe_abort()
node_id, host, port, timestamp = x
try:
net_addr = NetAddress(host, port)
except Exception:
continue
self._addresses[node_id][net_addr] = int(timestamp or 0)
def newest_ts_for_node_id(node_id):
newest_ts = 0
for addr, ts in self._addresses[node_id].items():
newest_ts = max(newest_ts, ts)
return newest_ts
sorted_node_ids = sorted(self._addresses.keys(), key=newest_ts_for_node_id, reverse=True)
self._recent_peers = sorted_node_ids[:self.NUM_MAX_RECENT_PEERS]
c.execute("""SELECT * FROM channel_info""")
for short_channel_id, msg in c:
maybe_abort()
try:
ci = ChannelInfo.from_raw_msg(msg)
except IncompatibleOrInsaneFeatures:
continue
except FailedToParseMsg:
continue
self._channels[ShortChannelID.normalize(short_channel_id)] = ci
c.execute("""SELECT * FROM node_info""")
for node_id, msg in c:
maybe_abort()
try:
node_info, node_addresses = NodeInfo.from_raw_msg(msg)
except IncompatibleOrInsaneFeatures:
continue
except FailedToParseMsg:
continue
# don't load node_addresses because they dont have timestamps
self._nodes[node_id] = node_info
c.execute("""SELECT * FROM policy""")
for key, msg in c:
maybe_abort()
try:
p = Policy.from_raw_msg(key, msg)
except FailedToParseMsg:
continue
self._policies[(p.start_node, p.short_channel_id)] = p
for channel_info in self._channels.values():
self._channels_for_node[channel_info.node1_id].add(channel_info.short_channel_id)
self._channels_for_node[channel_info.node2_id].add(channel_info.short_channel_id)
self._update_num_policies_for_chan(channel_info.short_channel_id)
self.logger.info(f'data loaded. {len(self._channels)} chans. {len(self._policies)} policies. '
f'{len(self._channels_for_node)} nodes.')
self.update_counts()
(nchans_with_0p, nchans_with_1p, nchans_with_2p) = self.get_num_channels_partitioned_by_policy_count()
self.logger.info(f'num_channels_partitioned_by_policy_count. '
f'0p: {nchans_with_0p}, 1p: {nchans_with_1p}, 2p: {nchans_with_2p}')
self.asyncio_loop.call_soon_threadsafe(self.data_loaded.set)
util.trigger_callback('gossip_db_loaded')
def _update_num_policies_for_chan(self, short_channel_id: ShortChannelID) -> None:
channel_info = self.get_channel_info(short_channel_id)
if channel_info is None:
with self.lock:
self._chans_with_0_policies.discard(short_channel_id)
self._chans_with_1_policies.discard(short_channel_id)
self._chans_with_2_policies.discard(short_channel_id)
return
p1 = self.get_policy_for_node(short_channel_id, channel_info.node1_id)
p2 = self.get_policy_for_node(short_channel_id, channel_info.node2_id)
with self.lock:
self._chans_with_0_policies.discard(short_channel_id)
self._chans_with_1_policies.discard(short_channel_id)
self._chans_with_2_policies.discard(short_channel_id)
if p1 is not None and p2 is not None:
self._chans_with_2_policies.add(short_channel_id)
elif p1 is None and p2 is None:
self._chans_with_0_policies.add(short_channel_id)
else:
self._chans_with_1_policies.add(short_channel_id)
def get_num_channels_partitioned_by_policy_count(self) -> Tuple[int, int, int]:
nchans_with_0p = len(self._chans_with_0_policies)
nchans_with_1p = len(self._chans_with_1_policies)
nchans_with_2p = len(self._chans_with_2_policies)
return nchans_with_0p, nchans_with_1p, nchans_with_2p
def get_policy_for_node(
self,
short_channel_id: ShortChannelID,
node_id: bytes,
*,
my_channels: Dict[ShortChannelID, 'Channel'] = None,
private_route_edges: Dict[ShortChannelID, 'RouteEdge'] = None,
now: int = None, # unix ts
) -> Optional['Policy']:
channel_info = self.get_channel_info(short_channel_id)
if channel_info is not None: # publicly announced channel
policy = self._policies.get((node_id, short_channel_id))
if policy:
return policy
elif chan_upd_dict := self._get_channel_update_for_private_channel(node_id, short_channel_id, now=now):
return Policy.from_msg(chan_upd_dict)
# check if it's one of our own channels
if my_channels:
policy = get_mychannel_policy(short_channel_id, node_id, my_channels)
if policy:
return policy
if private_route_edges:
route_edge = private_route_edges.get(short_channel_id, None)
if route_edge:
return Policy.from_route_edge(route_edge)
def get_channel_info(
self,
short_channel_id: ShortChannelID,
*,
my_channels: Dict[ShortChannelID, 'Channel'] = None,
private_route_edges: Dict[ShortChannelID, 'RouteEdge'] = None,
) -> Optional[ChannelInfo]:
ret = self._channels.get(short_channel_id)
if ret:
return ret
# check if it's one of our own channels
if my_channels:
channel_info = get_mychannel_info(short_channel_id, my_channels)
if channel_info:
return channel_info
if private_route_edges:
route_edge = private_route_edges.get(short_channel_id)
if route_edge:
return ChannelInfo.from_route_edge(route_edge)
def get_channels_for_node(
self,
node_id: bytes,
*,
my_channels: Dict[ShortChannelID, 'Channel'] = None,
private_route_edges: Dict[ShortChannelID, 'RouteEdge'] = None,
) -> Set[ShortChannelID]:
"""Returns the set of short channel IDs where node_id is one of the channel participants."""
if not self.data_loaded.is_set():
raise ChannelDBNotLoaded("channelDB data not loaded yet!")
relevant_channels = self._channels_for_node.get(node_id) or set()
relevant_channels = set(relevant_channels) # copy
# add our own channels # TODO maybe slow?
if my_channels:
for chan in my_channels.values():
if node_id in (chan.node_id, chan.get_local_pubkey()):
relevant_channels.add(chan.short_channel_id)
# add private channels # TODO maybe slow?
if private_route_edges:
for route_edge in private_route_edges.values():
if node_id in (route_edge.start_node, route_edge.end_node):
relevant_channels.add(route_edge.short_channel_id)
return relevant_channels
def get_endnodes_for_chan(self, short_channel_id: ShortChannelID, *,
my_channels: Dict[ShortChannelID, 'Channel'] = None) -> Optional[Tuple[bytes, bytes]]:
channel_info = self.get_channel_info(short_channel_id)
if channel_info is not None: # publicly announced channel
return channel_info.node1_id, channel_info.node2_id
# check if it's one of our own channels
if not my_channels:
return
chan = my_channels.get(short_channel_id) # type: Optional[Channel]
if not chan:
return
return chan.get_local_pubkey(), chan.node_id
def get_node_info_for_node_id(self, node_id: bytes) -> Optional['NodeInfo']:
return self._nodes.get(node_id)
def get_node_infos(self) -> Dict[bytes, NodeInfo]:
with self.lock:
return self._nodes.copy()
def get_node_policies(self) -> Dict[Tuple[bytes, ShortChannelID], Policy]:
with self.lock:
return self._policies.copy()
def get_node_by_prefix(self, prefix):
with self.lock:
for k in self._addresses.keys():
if k.startswith(prefix):
return k
raise Exception('node not found')
def clear_forwarding_gossip(self) -> None:
with self.forwarding_lock:
self.fwd_channels.clear()
self.fwd_channel_updates.clear()
self.fwd_node_announcements.clear()
def filter_orphan_channel_anns(
self, channel_anns: List[GossipForwardingMessage]
) -> Tuple[List, List]:
"""Check if the channel announcements we want to forward have at least 1 update"""
to_forward_anns = []
orphaned_channel_anns = []
for channel in channel_anns:
if channel.scid is None:
continue
elif (channel.scid in self._chans_with_1_policies
or channel.scid in self._chans_with_2_policies):
to_forward_anns.append(channel)
continue
orphaned_channel_anns.append(channel)
return to_forward_anns, orphaned_channel_anns
def set_fwd_channel_anns_ts(self, channel_anns: List[GossipForwardingMessage]) \
-> List[GossipForwardingMessage]:
"""Set the timestamps of the passed channel announcements from the corresponding policies"""
timestamped_chan_anns: List[GossipForwardingMessage] = []
with self.lock:
policies = self._policies.copy()
channels = self._channels.copy()
for chan_ann in channel_anns:
if chan_ann.timestamp is not None:
timestamped_chan_anns.append(chan_ann)
continue
scid = chan_ann.scid
if (channel_info := channels.get(scid)) is None:
continue
policy1 = policies.get((channel_info.node1_id, scid))
policy2 = policies.get((channel_info.node2_id, scid))
potential_timestamps = []
for policy in [policy1, policy2]:
if policy is not None:
potential_timestamps.append(policy.timestamp)
if not potential_timestamps:
continue
chan_ann.timestamp = min(potential_timestamps)
timestamped_chan_anns.append(chan_ann)
return timestamped_chan_anns
def get_forwarding_gossip_batch(self) -> List[GossipForwardingMessage]:
with self.forwarding_lock:
fwd_gossip = self.fwd_channel_updates + self.fwd_node_announcements
channel_anns = self.fwd_channels.copy()
self.clear_forwarding_gossip()
fwd_chan_anns1, _ = self.filter_orphan_channel_anns(self.fwd_orphan_channels)
fwd_chan_anns2, self.fwd_orphan_channels = self.filter_orphan_channel_anns(channel_anns)
channel_anns = self.set_fwd_channel_anns_ts(fwd_chan_anns1 + fwd_chan_anns2)
return channel_anns + fwd_gossip
def get_gossip_in_timespan(self, timespan: GossipTimestampFilter) \
-> List[GossipForwardingMessage]:
"""Return a list of gossip messages matching the requested timespan."""
forwarding_gossip = []
with self.lock:
chans = self._channels.copy()
policies = self._policies.copy()
nodes = self._nodes.copy()
for short_id, chan in chans.items():
# fetching the timestamp from the channel update (according to BOLT-07)
chan_up_n1 = policies.get((chan.node1_id, short_id))
chan_up_n2 = policies.get((chan.node2_id, short_id))
updates = []
for policy in [chan_up_n1, chan_up_n2]:
if policy and policy.raw and timespan.in_range(policy.timestamp):
if policy.message_flags & 0b10 == 0: # check that its not "dont_forward"
updates.append(GossipForwardingMessage(
msg=policy.raw,
timestamp=policy.timestamp))
if not updates or chan.raw is None:
continue
chan_ann_ts = min(update.timestamp for update in updates)
channel_announcement = GossipForwardingMessage(msg=chan.raw, timestamp=chan_ann_ts)
forwarding_gossip.extend([channel_announcement] + updates)
for node_ann in nodes.values():
if timespan.in_range(node_ann.timestamp) and node_ann.raw:
forwarding_gossip.append(GossipForwardingMessage(
msg=node_ann.raw,
timestamp=node_ann.timestamp))
return forwarding_gossip
def get_channels_in_range(self, first_blocknum: int, number_of_blocks: int) -> List[ShortChannelID]:
with self.lock:
channels = self._channels.copy()
scids: List[ShortChannelID] = []
for scid in channels:
if first_blocknum <= scid.block_height < first_blocknum + number_of_blocks:
scids.append(scid)
scids.sort()
return scids
def get_gossip_for_scid_request(self, scid: ShortChannelID) -> List[bytes]:
requested_gossip = []
chan_ann = self._channels.get(scid)
if not chan_ann or not chan_ann.raw:
return []
chan_up1 = self._policies.get((chan_ann.node1_id, scid))
chan_up2 = self._policies.get((chan_ann.node2_id, scid))
node_ann1 = self._nodes.get(chan_ann.node1_id)
node_ann2 = self._nodes.get(chan_ann.node2_id)
for msg in [chan_ann, chan_up1, chan_up2, node_ann1, node_ann2]:
if msg and msg.raw:
requested_gossip.append(msg.raw)
return requested_gossip
def to_dict(self) -> dict:
""" Generates a graph representation in terms of a dictionary.
The dictionary contains only native python types and can be encoded
to json.
"""
with self.lock:
graph = {'nodes': [], 'channels': []}
# gather nodes
for pk, nodeinfo in self._nodes.items():
# use _asdict() to convert NamedTuples to json encodable dicts
graph['nodes'].append(
nodeinfo._asdict(),
)
graph['nodes'][-1]['addresses'] = [
{'host': str(addr.host), 'port': addr.port, 'timestamp': ts}
for addr, ts in self._addresses[pk].items()
]
# gather channels
for cid, channelinfo in self._channels.items():
graph['channels'].append(
channelinfo._asdict(),
)
policy1 = self._policies.get(
(channelinfo.node1_id, channelinfo.short_channel_id))
policy2 = self._policies.get(
(channelinfo.node2_id, channelinfo.short_channel_id))
graph['channels'][-1]['policy1'] = policy1._asdict() if policy1 else None
graph['channels'][-1]['policy2'] = policy2._asdict() if policy2 else None
# need to use json_normalize otherwise json encoding in rpc server fails
graph = json_normalize(graph)
return graph
================================================
FILE: electrum/coinchooser.py
================================================
#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2015 kyuupichan@gmail
#
# 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 collections import defaultdict
from math import floor, log10
from typing import NamedTuple, List, Callable, Sequence, Dict, Tuple, Mapping, Type, TYPE_CHECKING
from decimal import Decimal
from .bitcoin import sha256, COIN, is_address
from .transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput
from .util import NotEnoughFunds
from .logging import Logger
if TYPE_CHECKING:
from .simple_config import SimpleConfig
# A simple deterministic PRNG. Used to deterministically shuffle a
# set of coins - the same set of coins should produce the same output.
# Although choosing UTXOs "randomly" we want it to be deterministic,
# so if sending twice from the same UTXO set we choose the same UTXOs
# to spend. This prevents attacks on users by malicious or stale
# servers.
class PRNG:
def __init__(self, seed):
self.sha = sha256(seed)
self.pool = bytearray()
def get_bytes(self, n: int) -> bytes:
while len(self.pool) < n:
self.pool.extend(self.sha)
self.sha = sha256(self.sha)
result, self.pool = self.pool[:n], self.pool[n:]
return bytes(result)
def randint(self, start, end):
# Returns random integer in [start, end)
n = end - start
r = 0
p = 1
while p < n:
r = self.get_bytes(1)[0] + (r << 8)
p = p << 8
return start + (r % n)
def choice(self, seq):
return seq[self.randint(0, len(seq))]
def shuffle(self, x):
for i in reversed(range(1, len(x))):
# pick an element in x[:i+1] with which to exchange x[i]
j = self.randint(0, i+1)
x[i], x[j] = x[j], x[i]
class Bucket(NamedTuple):
desc: str
weight: int # as in BIP-141
value: int # in satoshis
effective_value: int # estimate of value left after subtracting fees. in satoshis
coins: List[PartialTxInput] # UTXOs
min_height: int # min block height where a coin was confirmed
witness: bool # whether any coin uses segwit
class ScoredCandidate(NamedTuple):
penalty: float
tx: PartialTransaction
buckets: List[Bucket]
def strip_unneeded(bkts: List[Bucket], sufficient_funds: Callable) -> List[Bucket]:
'''Remove buckets that are unnecessary in achieving the spend amount'''
if sufficient_funds([], bucket_value_sum=0):
# none of the buckets are needed
return []
bkts = sorted(bkts, key=lambda bkt: bkt.value, reverse=True)
bucket_value_sum = 0
for i in range(len(bkts)):
bucket_value_sum += (bkts[i]).value
if sufficient_funds(bkts[:i+1], bucket_value_sum=bucket_value_sum):
return bkts[:i+1]
raise Exception("keeping all buckets is still not enough")
class CoinChooserBase(Logger):
def __init__(self, *, enable_output_value_rounding: bool):
Logger.__init__(self)
self.enable_output_value_rounding = enable_output_value_rounding
def keys(self, coins: Sequence[PartialTxInput]) -> Sequence[str]:
raise NotImplementedError
def bucketize_coins(
self,
coins: Sequence[PartialTxInput],
*,
fee_estimator_vb: Callable[[int | float | Decimal], int],
):
keys = self.keys(coins)
buckets = defaultdict(list) # type: Dict[str, List[PartialTxInput]]
for key, coin in zip(keys, coins):
buckets[key].append(coin)
# fee_estimator returns fee to be paid, for given vbytes.
# guess whether it is just returning a constant as follows.
constant_fee = fee_estimator_vb(2000) == fee_estimator_vb(200)
def make_Bucket(desc: str, coins: List[PartialTxInput]):
witness = any(coin.is_segwit(guess_for_address=True) for coin in coins)
# note that we're guessing whether the tx uses segwit based
# on this single bucket
weight = sum(Transaction.estimated_input_weight(coin, witness)
for coin in coins)
value = sum(coin.value_sats() for coin in coins)
min_height = min(coin.block_height for coin in coins)
assert min_height is not None
# the fee estimator is typically either a constant or a linear function,
# so the "function:" effective_value(bucket) will be homomorphic for addition
# i.e. effective_value(b1) + effective_value(b2) = effective_value(b1 + b2)
if constant_fee:
effective_value = value
else:
# when converting from weight to vBytes, instead of rounding up,
# keep fractional part, to avoid overestimating fee
fee = fee_estimator_vb(Decimal(weight) / 4)
effective_value = value - fee
return Bucket(desc=desc,
weight=weight,
value=value,
effective_value=effective_value,
coins=coins,
min_height=min_height,
witness=witness)
return list(map(make_Bucket, buckets.keys(), buckets.values()))
def penalty_func(
self,
base_tx: Transaction,
*,
tx_from_buckets: Callable[[List[Bucket]], Tuple[PartialTransaction, List[PartialTxOutput]]],
) -> Callable[[List[Bucket]], ScoredCandidate]:
raise NotImplementedError
def _change_amounts(self, tx: PartialTransaction, count: int, fee_estimator_numchange) -> List[int]:
# Break change up if bigger than max_change
output_amounts = [o.value for o in tx.outputs()]
# Don't split change of less than 0.02 BTC
max_change = max([0.02 * COIN] + output_amounts) * 1.25
# Use N change outputs
for n in range(1, count + 1):
# How much is left if we add this many change outputs?
change_amount = max(0, tx.get_fee() - fee_estimator_numchange(n))
if change_amount // n <= max_change:
break
# Get a handle on the precision of the output amounts; round our
# change to look similar
def trailing_zeroes(val):
s = str(val)
return len(s) - len(s.rstrip('0'))
zeroes = [trailing_zeroes(i) for i in output_amounts]
min_zeroes = min([8] + zeroes)
max_zeroes = max([0] + zeroes)
if n > 1:
zeroes = range(max(0, min_zeroes - 1), (max_zeroes + 1) + 1)
else:
# if there is only one change output, this will ensure that we aim
# to have one that is exactly as precise as the most precise output
zeroes = [min_zeroes]
# Calculate change; randomize it a bit if using more than 1 output
remaining = change_amount
amounts = []
while n > 1:
average = remaining / n
amount = self.p.randint(int(average * 0.7), int(average * 1.3))
precision = min(self.p.choice(zeroes), int(floor(log10(amount))))
amount = int(round(amount, -precision))
amounts.append(amount)
remaining -= amount
n -= 1
# Last change output. Round down to maximum precision but lose
# no more than 10**max_dp_to_round_for_privacy
# e.g. a max of 2 decimal places means losing 100 satoshis to fees
# don't round if the fee estimator is set to 0 fixed fee, so a 0 fee tx remains a 0 fee tx
is_zero_fee_tx = True if fee_estimator_numchange(1) == 0 else False
output_value_rounding = self.enable_output_value_rounding and not is_zero_fee_tx
max_dp_to_round_for_privacy = 2 if output_value_rounding else 0
N = int(pow(10, min(max_dp_to_round_for_privacy, zeroes[0])))
amount = (remaining // N) * N
amounts.append(amount)
assert sum(amounts) <= change_amount
return amounts
def _change_outputs(self, tx: PartialTransaction, change_addrs, fee_estimator_numchange,
dust_threshold) -> List[PartialTxOutput]:
amounts = self._change_amounts(tx, len(change_addrs), fee_estimator_numchange)
assert min(amounts) >= 0
assert len(change_addrs) >= len(amounts)
assert all([isinstance(amt, int) for amt in amounts])
# If change is above dust threshold after accounting for the
# size of the change output, add it to the transaction.
amounts = [amount for amount in amounts if amount >= dust_threshold]
change = [PartialTxOutput.from_address_and_value(addr, amount)
for addr, amount in zip(change_addrs, amounts)]
for c in change:
c.is_change = True
return change
def _construct_tx_from_selected_buckets(
self, *, buckets: Sequence[Bucket],
base_tx: PartialTransaction, change_addrs,
fee_estimator_w, dust_threshold,
base_weight,
BIP69_sort: bool,
) -> Tuple[PartialTransaction, List[PartialTxOutput]]:
# make a copy of base_tx so it won't get mutated
tx = PartialTransaction.from_io(base_tx.inputs()[:], base_tx.outputs()[:], BIP69_sort=BIP69_sort)
tx.add_inputs([coin for b in buckets for coin in b.coins], BIP69_sort=BIP69_sort)
tx_weight = self._get_tx_weight(buckets, base_weight=base_weight)
# change is sent back to sending address unless specified
if not change_addrs:
change_addrs = [tx.inputs()[0].address]
# note: this is not necessarily the final "first input address"
# because the inputs had not been sorted at this point
assert is_address(change_addrs[0])
# This takes a count of change outputs and returns a tx fee
output_weight = 4 * Transaction.estimated_output_size_for_address(change_addrs[0])
fee_estimator_numchange = lambda count: fee_estimator_w(tx_weight + count * output_weight)
change = self._change_outputs(tx, change_addrs, fee_estimator_numchange, dust_threshold)
tx.add_outputs(change, BIP69_sort=BIP69_sort)
return tx, change
def _get_tx_weight(self, buckets: Sequence[Bucket], *, base_weight: int) -> int:
"""Given a collection of buckets, return the total weight of the
resulting transaction.
base_weight is the weight of the tx that includes the fixed (non-change)
outputs and potentially some fixed inputs. Note that the change outputs
at this point are not yet known so they are NOT accounted for.
"""
total_weight = base_weight + sum(bucket.weight for bucket in buckets)
is_segwit_tx = any(bucket.witness for bucket in buckets)
if is_segwit_tx:
total_weight += 2 # marker and flag
# non-segwit inputs were previously assumed to have
# a witness of '' instead of '00' (hex)
# note that mixed legacy/segwit buckets are already ok
num_legacy_inputs = sum((not bucket.witness) * len(bucket.coins)
for bucket in buckets)
total_weight += num_legacy_inputs
return total_weight
def make_tx(
self, *,
coins: Sequence[PartialTxInput],
inputs: List[PartialTxInput],
outputs: List[PartialTxOutput],
change_addrs: Sequence[str],
fee_estimator_vb: Callable[[int | float | Decimal], int],
dust_threshold: int,
BIP69_sort: bool = True,
) -> PartialTransaction:
"""Select unspent coins to spend to pay outputs. If the change is
greater than dust_threshold (after adding the change output to
the transaction) it is kept, otherwise none is sent and it is
added to the transaction fee.
`inputs` and `outputs` are guaranteed to be a subset of the
inputs and outputs of the resulting transaction.
`coins` are further UTXOs we can choose from.
Note: fee_estimator_vb expects virtual bytes
"""
# Deterministic randomness from coins
utxos = [c.prevout.serialize_to_network() for c in coins]
self.p = PRNG(b''.join(sorted(utxos)))
assert len(outputs) > 0 or len(change_addrs) == 1, \
"sweeps with 0 outputs should not use multiple change addresses"
# Copy the outputs so when adding change we don't modify "outputs"
base_tx = PartialTransaction.from_io(inputs[:], outputs[:], BIP69_sort=BIP69_sort)
input_value = base_tx.input_value()
# Weight of the transaction with no inputs and no change
# Note: this will use legacy tx serialization as the need for "segwit"
# would be detected from inputs. The only side effect should be that the
# marker and flag are excluded, which is compensated in get_tx_weight()
# FIXME calculation will be off by this (2 wu) in case of RBF batching
base_weight = base_tx.estimated_weight()
# by setting spent_amount = dust_threshold if there are no outputs we ensure that
# enough inputs are added so there is always at least a change output created
# as txs have to have at least 1 output according to consensus rules
spent_amount = base_tx.output_value() if outputs else dust_threshold
def fee_estimator_w(weight):
return fee_estimator_vb(Transaction.virtual_size_from_weight(weight))
def sufficient_funds(buckets: List[Bucket], *, bucket_value_sum: int) -> bool:
'''Given a list of buckets, return True if it has enough
value to pay for the transaction'''
# assert bucket_value_sum == sum(bucket.value for bucket in buckets) # expensive!
total_input = input_value + bucket_value_sum
if total_input < spent_amount: # shortcut for performance
return False
# any bitcoin tx must have at least 1 input by consensus
# (check we add some new UTXOs now or already have some fixed inputs)
if not buckets and not inputs:
return False
# note re performance: so far this was constant time
# what follows is linear in len(buckets)
total_weight = self._get_tx_weight(buckets, base_weight=base_weight)
return total_input >= spent_amount + fee_estimator_w(total_weight)
def tx_from_buckets(buckets):
return self._construct_tx_from_selected_buckets(
buckets=buckets,
base_tx=base_tx,
change_addrs=change_addrs,
fee_estimator_w=fee_estimator_w,
dust_threshold=dust_threshold,
base_weight=base_weight,
BIP69_sort=BIP69_sort,
)
# Collect the coins into buckets
all_buckets = self.bucketize_coins(coins, fee_estimator_vb=fee_estimator_vb)
# Filter some buckets out. Only keep those that have positive effective value.
# Note that this filtering is intentionally done on the bucket level
# instead of per-coin, as each bucket should be either fully spent or not at all.
# (e.g. CoinChooserPrivacy ensures that same-address coins go into one bucket)
all_buckets = list(filter(lambda b: b.effective_value > 0, all_buckets))
# Choose a subset of the buckets
scored_candidate = self.choose_buckets(all_buckets, sufficient_funds,
self.penalty_func(base_tx, tx_from_buckets=tx_from_buckets))
tx = scored_candidate.tx
self.logger.info(f"using {len(tx.inputs())} inputs")
self.logger.info(f"using buckets: {[bucket.desc for bucket in scored_candidate.buckets]}")
return tx
def choose_buckets(self, buckets: List[Bucket],
sufficient_funds: Callable,
penalty_func: Callable[[List[Bucket]], ScoredCandidate]) -> ScoredCandidate:
raise NotImplemented('To be subclassed')
class CoinChooserRandom(CoinChooserBase):
def bucket_candidates_any(
self,
buckets: List[Bucket],
sufficient_funds: Callable,
) -> List[List[Bucket]]:
'''Returns a list of bucket sets.'''
if not buckets:
if sufficient_funds([], bucket_value_sum=0):
return [[]]
else:
raise NotEnoughFunds()
candidates = set()
# Add all singletons
for n, bucket in enumerate(buckets):
if sufficient_funds([bucket], bucket_value_sum=bucket.value):
candidates.add((n,))
# And now some random ones
attempts = min(100, (len(buckets) - 1) * 10 + 1)
permutation = list(range(len(buckets)))
for i in range(attempts):
# Get a random permutation of the buckets, and
# incrementally combine buckets until sufficient
self.p.shuffle(permutation)
bkts = []
bucket_value_sum = 0
for count, index in enumerate(permutation):
bucket = buckets[index]
bkts.append(bucket)
bucket_value_sum += bucket.value
if sufficient_funds(bkts, bucket_value_sum=bucket_value_sum):
candidates.add(tuple(sorted(permutation[:count + 1])))
break
else:
# note: this assumes that the effective value of any bkt is >= 0
raise NotEnoughFunds()
candidates = [[buckets[n] for n in c] for c in candidates]
return [strip_unneeded(c, sufficient_funds) for c in candidates]
def bucket_candidates_prefer_confirmed(
self,
buckets: List[Bucket],
sufficient_funds: Callable,
) -> List[List[Bucket]]:
"""Returns a list of bucket sets preferring confirmed coins.
Any bucket can be:
1. "confirmed" if it only contains confirmed coins; else
2. "unconfirmed" if it does not contain coins with unconfirmed parents
3. other: e.g. "unconfirmed parent" or "local"
This method tries to only use buckets of type 1, and if the coins there
are not enough, tries to use the next type but while also selecting
all buckets of all previous types.
"""
conf_buckets = [bkt for bkt in buckets if bkt.min_height > 0]
unconf_buckets = [bkt for bkt in buckets if bkt.min_height == 0]
other_buckets = [bkt for bkt in buckets if bkt.min_height < 0]
bucket_sets = [conf_buckets, unconf_buckets, other_buckets]
already_selected_buckets = []
already_selected_buckets_value_sum = 0
for bkts_choose_from in bucket_sets:
try:
def sfunds(
bkts: List[Bucket], *, bucket_value_sum: int,
already_selected_buckets_value_sum=already_selected_buckets_value_sum,
already_selected_buckets=already_selected_buckets,
):
bucket_value_sum += already_selected_buckets_value_sum
return sufficient_funds(already_selected_buckets + bkts,
bucket_value_sum=bucket_value_sum)
candidates = self.bucket_candidates_any(bkts_choose_from, sfunds)
break
except NotEnoughFunds:
already_selected_buckets += bkts_choose_from
already_selected_buckets_value_sum += sum(bucket.value for bucket in bkts_choose_from)
else:
raise NotEnoughFunds()
candidates = [(already_selected_buckets + c) for c in candidates]
return [strip_unneeded(c, sufficient_funds) for c in candidates]
def choose_buckets(self, buckets, sufficient_funds, penalty_func):
candidates = self.bucket_candidates_prefer_confirmed(buckets, sufficient_funds)
scored_candidates = [penalty_func(cand) for cand in candidates]
winner = min(scored_candidates, key=lambda x: x.penalty)
self.logger.info(f"Total number of buckets: {len(buckets)}")
self.logger.info(f"Num candidates considered: {len(candidates)}. "
f"Winning penalty: {winner.penalty}")
return winner
class CoinChooserPrivacy(CoinChooserRandom):
"""Attempts to better preserve user privacy.
First, if any coin is spent from a user address, all coins are.
Compared to spending from other addresses to make up an amount, this reduces
information leakage about sender holdings. It also helps to
reduce blockchain UTXO bloat, and reduce future privacy loss that
would come from reusing that address' remaining UTXOs.
Second, it penalizes change that is quite different to the sent amount.
Third, it penalizes change that is too big.
"""
def keys(self, coins):
return [coin.scriptpubkey.hex() for coin in coins]
def penalty_func(self, base_tx, *, tx_from_buckets):
if _outputs := base_tx.outputs():
min_change = min(o.value for o in _outputs) * 0.75
max_change = max(o.value for o in _outputs) * 1.33
else:
min_change = 0
max_change = 0.02 * COIN
def penalty(buckets: List[Bucket]) -> ScoredCandidate:
# Penalize using many buckets (~inputs)
badness = len(buckets) - 1
tx, change_outputs = tx_from_buckets(buckets)
change = sum(o.value for o in change_outputs)
# Penalize change not roughly in output range
if change == 0:
pass # no change is great!
elif change < min_change:
badness += (min_change - change) / (min_change + 10000)
# Penalize really small change; under 1 mBTC ~= using 1 more input
if change < COIN / 1000:
badness += 1
elif change > max_change:
badness += (change - max_change) / (max_change + 10000)
# Penalize large change; 5 BTC excess ~= using 1 more input
badness += change / (COIN * 5)
return ScoredCandidate(badness, tx, buckets)
return penalty
COIN_CHOOSERS = {
'Privacy': CoinChooserPrivacy,
} # type: Mapping[str, Type[CoinChooserBase]]
def get_name(config: 'SimpleConfig') -> str:
kind = config.WALLET_COIN_CHOOSER_POLICY
if kind not in COIN_CHOOSERS:
kind = config.cv.WALLET_COIN_CHOOSER_POLICY.get_default_value()
return kind
def get_coin_chooser(config: 'SimpleConfig') -> CoinChooserBase:
klass = COIN_CHOOSERS[get_name(config)]
# note: we enable enable_output_value_rounding by default as
# - for sacrificing a few satoshis
# + it gives better privacy for the user re change output
# + it also helps the network as a whole as fees will become noisier
# (trying to counter the heuristic that "whole integer sat/byte feerates" are common)
coinchooser = klass(
enable_output_value_rounding=config.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING,
)
return coinchooser
================================================
FILE: electrum/commands.py
================================================
#!/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 io
import sys
import datetime
import time
import argparse
import json
import ast
import binascii
import base64
import asyncio
import inspect
from asyncio import CancelledError
from collections import defaultdict
from functools import wraps
from decimal import Decimal, InvalidOperation
from typing import Optional, TYPE_CHECKING, Dict, List, Any, Union
import os
import re
import electrum_ecc as ecc
from . import util
from .lnmsg import OnionWireSerializer
from .lnworker import LN_P2P_NETWORK_TIMEOUT
from .logging import Logger
from .onion_message import create_blinded_path, send_onion_message_to
from .submarine_swaps import NostrTransport
from .util import (
bfh, json_decode, json_normalize, is_hash256_str, is_hex_str, to_bytes, parse_max_spend, to_decimal,
UserFacingException, InvalidPassword
)
from . import bitcoin
from .bitcoin import is_address, hash_160, COIN
from .bip32 import BIP32Node
from .i18n import _
from .transaction import (
Transaction, multisig_script, PartialTransaction, PartialTxOutput, tx_from_any, PartialTxInput, TxOutpoint,
convert_raw_tx_to_hex
)
from . import transaction
from .invoices import Invoice, PR_PAID, PR_UNPAID, PR_EXPIRED
from .synchronizer import Notifier
from .wallet import (
Abstract_Wallet, create_new_wallet, restore_wallet_from_text, Deterministic_Wallet, BumpFeeStrategy,
Imported_Wallet
)
from .address_synchronizer import TX_HEIGHT_LOCAL
from .mnemonic import Mnemonic
from .lnutil import (channel_id_from_funding_tx, LnFeatures, SENT, RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED,
PaymentFeeBudget, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE)
from .plugin import run_hook, DeviceMgr, Plugins
from .version import ELECTRUM_VERSION
from .simple_config import SimpleConfig
from .fee_policy import FeePolicy, FEE_ETA_TARGETS, FEERATE_DEFAULT_RELAY
from . import GuiImportError
from . import crypto
from . import constants
from . import descriptor
if TYPE_CHECKING:
from .network import Network
from .daemon import Daemon
from electrum.lnworker import PaymentInfo
known_commands = {} # type: Dict[str, Command]
class NotSynchronizedException(UserFacingException):
pass
def satoshis_or_max(amount):
return satoshis(amount) if not parse_max_spend(amount) else amount
def satoshis(amount):
# satoshi conversion must not be performed by the parser
return int(COIN*to_decimal(amount)) if amount is not None else None
def format_satoshis(x: Union[float, int, Decimal, None]) -> Optional[str]:
"""
input: satoshis as a Number
output: str formatted as bitcoin amount
"""
if x is None:
return None
return util.format_satoshis_plain(x, is_max_allowed=False)
class Command:
def __init__(self, func, name, s):
self.name = name
self.requires_network = 'n' in s # better name would be "requires daemon"
self.requires_wallet = 'w' in s
self.requires_password = 'p' in s
self.requires_lightning = 'l' in s
self.parse_docstring(func.__doc__)
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 = []
# sanity checks
if self.requires_password:
assert self.requires_wallet
for varname in ('wallet_path', 'wallet'):
if varname in varnames:
assert varname in self.options, f"cmd: {self.name}: {varname} not in options {self.options}"
assert not ('wallet_path' in varnames and 'wallet' in varnames)
if self.requires_wallet:
assert 'wallet' in varnames
def parse_docstring(self, docstring):
docstring = docstring or ''
docstring = docstring.strip()
self.description = docstring
self.arg_descriptions = {}
self.arg_types = {}
for x in re.finditer(r'arg:(.*?):(.*?):(.*)$', docstring, flags=re.MULTILINE):
self.arg_descriptions[x.group(2)] = x.group(3)
self.arg_types[x.group(2)] = x.group(1)
self.description = self.description.replace(x.group(), '')
self.short_description = self.description.split('.')[0]
def command(s):
def decorator(func):
if hasattr(func, '__wrapped__'):
# plugin command function
name = func.plugin_name + '_' + func.__name__
known_commands[name] = Command(func.__wrapped__, name, s)
else:
# regular command function
name = func.__name__
known_commands[name] = Command(func, name, s)
@wraps(func)
async def func_wrapper(*args, **kwargs):
cmd_runner = args[0] # type: Commands
cmd = known_commands[name] # type: Command
password = kwargs.get('password')
daemon = cmd_runner.daemon
if daemon:
if 'wallet_path' in cmd.options or cmd.requires_wallet:
kwargs['wallet_path'] = daemon.config.maybe_complete_wallet_path(kwargs.get('wallet_path'))
if 'wallet' in cmd.options:
wallet_path = kwargs.pop('wallet_path', None) # unit tests may set wallet and not wallet_path
wallet = kwargs.get('wallet', None) # run_offline_command sets both
if wallet is None and wallet_path is not None:
wallet = daemon.get_wallet(wallet_path)
if wallet is None:
raise UserFacingException('wallet not loaded')
kwargs['wallet'] = wallet
if cmd.requires_password and password is None and wallet and wallet.has_password():
password = wallet.get_unlocked_password()
if password:
kwargs['password'] = password
else:
raise UserFacingException('Password required. Unlock the wallet, or add a --password option to your command')
wallet = kwargs.get('wallet') # type: Optional[Abstract_Wallet]
if cmd.requires_wallet and not wallet:
raise UserFacingException('wallet not loaded')
if cmd.requires_password and wallet.has_password():
if password is None:
raise UserFacingException('Password required')
try:
wallet.check_password(password)
except InvalidPassword as e:
raise UserFacingException(str(e)) from None
if cmd.requires_lightning and (not wallet or not wallet.has_lightning()):
raise UserFacingException('Lightning not enabled in this wallet')
return await func(*args, **kwargs)
return func_wrapper
return decorator
class Commands(Logger):
def __init__(self, *, config: 'SimpleConfig',
network: 'Network' = None,
daemon: 'Daemon' = None, callback=None):
Logger.__init__(self)
self.config = config
self.daemon = daemon
self.network = network
self._callback = callback
def _run(self, method, args, password_getter=None, **kwargs):
"""This wrapper is called from unit tests and the Qt python console."""
cmd = known_commands[method]
password = kwargs.get('password', None)
wallet = kwargs.get('wallet', None)
if (cmd.requires_password and wallet and wallet.has_password()
and password is None):
password = password_getter()
if password is None:
return
f = getattr(self, method)
if cmd.requires_password:
kwargs['password'] = password
if 'wallet' in kwargs:
sig = inspect.signature(f)
if 'wallet' not in sig.parameters:
kwargs.pop('wallet')
coro = f(*args, **kwargs)
fut = asyncio.run_coroutine_threadsafe(coro, util.get_asyncio_loop())
result = fut.result()
if self._callback:
self._callback()
return result
@command('n')
async def getinfo(self):
""" network info """
net_params = self.network.get_parameters()
response = {
'network': constants.net.NET_NAME,
'path': self.network.config.path,
'server': net_params.server.host,
'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': net_params.auto_connect,
'version': ELECTRUM_VERSION,
'fee_estimates': self.network.fee_estimates.get_data()
}
return response
@command('n')
async def stop(self):
"""Stop daemon"""
await self.daemon.stop()
return "Daemon stopped"
@command('n')
async def list_wallets(self):
"""List wallets open in daemon"""
return [
{
'path': w.db.storage.path,
'synchronized': w.is_up_to_date(),
'unlocked': not w.has_password() or (w.get_unlocked_password() is not None),
}
for w in self.daemon.get_wallets().values()
]
@command('n')
async def load_wallet(self, wallet_path=None, password=None):
"""
Load the wallet in memory
"""
wallet = self.daemon.load_wallet(wallet_path, password, upgrade=True)
if wallet is None:
raise UserFacingException('could not load wallet')
run_hook('load_wallet', wallet, None)
return wallet_path
@command('n')
async def close_wallet(self, wallet_path=None):
"""Close wallet"""
return await self.daemon._stop_wallet(wallet_path)
@command('')
async def create(self, passphrase=None, password=None, encrypt_file=True, seed_type=None, wallet_path=None):
"""Create a new wallet.
If you want to be prompted for an argument, type '?' or ':' (concealed)
arg:str:passphrase:Seed extension
arg:str:seed_type:The type of wallet to create, e.g. 'standard' or 'segwit'
arg:bool:encrypt_file:Whether the file on disk should be encrypted with the provided password
"""
d = create_new_wallet(
path=wallet_path,
passphrase=passphrase,
password=password,
encrypt_file=encrypt_file,
seed_type=seed_type,
config=self.config)
return {
'seed': d['seed'],
'path': d['wallet'].storage.path,
'msg': d['msg'],
}
@command('')
async def restore(self, text, passphrase=None, password=None, encrypt_file=True, wallet_path=None):
"""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 an argument, type '?' or ':' (concealed)
arg:str:text:seed phrase
arg:str:passphrase:Seed extension
arg:bool:encrypt_file:Whether the file on disk should be encrypted with the provided password
"""
# TODO create a separate command that blocks until wallet is synced
d = restore_wallet_from_text(
text,
path=wallet_path,
passphrase=passphrase,
password=password,
encrypt_file=encrypt_file,
config=self.config)
return {
'path': d['wallet'].storage.path,
'msg': d['msg'],
}
@command('wp')
async def password(self, password=None, new_password=None, encrypt_file=None, wallet: Abstract_Wallet = None):
"""
Change wallet password.
arg:bool:encrypt_file:Whether the file on disk should be encrypted with the provided password (default=true)
arg:str:new_password:New Password
"""
if wallet.storage.is_encrypted_with_hw_device() and new_password:
raise UserFacingException("Can't change the password of a wallet encrypted with a hw device.")
if encrypt_file is None:
if not password and new_password:
# currently no password, setting one now: we encrypt by default
encrypt_file = True
else:
encrypt_file = wallet.storage.is_encrypted()
wallet.update_password(password, new_password, encrypt_storage=encrypt_file)
wallet.save_db()
return {'password': wallet.has_password()}
@command('w')
async def get(self, key, wallet: Abstract_Wallet = None):
"""
Return item from wallet storage
arg:str:key:storage key
"""
return wallet.db.get(key)
@command('')
async def getconfig(self, key):
"""Return the current value of a configuration variable.
arg:str:key:name of the configuration variable
"""
if Plugins.is_plugin_enabler_config_key(key):
return self.config.get(key)
else:
cv = self.config.cv.from_key(key)
return cv.get()
@classmethod
def _setconfig_normalize_value(cls, key, value):
if key not in (SimpleConfig.RPC_USERNAME.key(), SimpleConfig.RPC_PASSWORD.key()):
value = json_decode(value)
# call literal_eval for backward compatibility (see #4225)
try:
value = ast.literal_eval(value)
except Exception:
pass
return value
def _setconfig(self, key, value):
value = self._setconfig_normalize_value(key, value)
if self.daemon and key == SimpleConfig.RPC_USERNAME.key():
self.daemon.commands_server.rpc_user = value
if self.daemon and key == SimpleConfig.RPC_PASSWORD.key():
self.daemon.commands_server.rpc_password = value
if Plugins.is_plugin_enabler_config_key(key):
self.config.set_key(key, value)
else:
cv = self.config.cv.from_key(key)
cv.set(value)
@command('')
async def setconfig(self, key, value):
"""
Set a configuration variable.
arg:str:key:name of the configuration variable
arg:str:value:value. may be a string or a Python expression.
"""
self._setconfig(key, value)
@command('')
async def unsetconfig(self, key):
"""
Clear a configuration variable.
The variable will be reset to its default value.
arg:str:key:name of the configuration variable
"""
self._setconfig(key, None)
@command('')
async def listconfig(self):
"""Returns the list of all configuration variables. """
return self.config.list_config_vars()
@command('')
async def helpconfig(self, key):
"""Returns help about a configuration variable.
arg:str:key:name of the configuration variable
"""
cv = self.config.cv.from_key(key)
short = cv.get_short_desc()
long = cv.get_long_desc()
if short and long:
return short + "\n---\n\n" + long
elif short or long:
return short or long
else:
return f"No description available for '{key}'"
@command('')
async def make_seed(self, nbits=None, language=None, seed_type=None):
"""
Create a seed
arg:int:nbits:Number of bits of entropy
arg:str:seed_type:The type of seed to create, e.g. 'standard' or 'segwit'
arg:str:language:Default language for wordlist
"""
s = Mnemonic(language).make_seed(seed_type=seed_type, num_bits=nbits)
return s
@command('n')
async def getaddresshistory(self, address):
"""
Return the transaction history of any address. Note: This is a
walletless server query, results are not checked by SPV.
arg:str:address:Bitcoin address
"""
sh = bitcoin.address_to_scripthash(address)
return await self.network.get_history_for_scripthash(sh)
@command('wp')
async def unlock(self, wallet: Abstract_Wallet = None, password=None):
"""Unlock the wallet (store the password in memory)."""
wallet.unlock(password)
@command('w')
async def listunspent(self, wallet: Abstract_Wallet = None):
"""List unspent outputs. Returns the list of unspent transaction
outputs in your wallet."""
coins = []
for txin in wallet.get_utxos():
d = txin.to_json()
v = d.pop("value_sats")
d["value"] = format_satoshis(v)
coins.append(d)
return coins
@command('n')
async def getaddressunspent(self, address):
"""
Returns the UTXO list of any address. Note: This
is a walletless server query, results are not checked by SPV.
arg:str:address:Bitcoin address
"""
sh = bitcoin.address_to_scripthash(address)
return await self.network.listunspent_for_scripthash(sh)
@command('')
async def serialize(self, jsontx):
"""Create a signed raw transaction from a json tx template.
Example value for "jsontx" arg: {
"inputs": [
{"prevout_hash": "9d221a69ca3997cbeaf5624d723e7dc5f829b1023078c177d37bdae95f37c539", "prevout_n": 1,
"value_sats": 1000000, "privkey": "p2wpkh:cVDXzzQg6RoCTfiKpe8MBvmm5d5cJc6JLuFApsFDKwWa6F5TVHpD"}
],
"outputs": [
{"address": "tb1q4s8z6g5jqzllkgt8a4har94wl8tg0k9m8kv5zd", "value_sats": 990000}
]
}
arg:json:jsontx:Transaction in json
"""
keypairs = {}
inputs = [] # type: List[PartialTxInput]
locktime = jsontx.get('locktime', 0)
for txin_idx, txin_dict in enumerate(jsontx.get('inputs')):
if txin_dict.get('prevout_hash') is not None and txin_dict.get('prevout_n') is not None:
prevout = TxOutpoint(txid=bfh(txin_dict['prevout_hash']), out_idx=int(txin_dict['prevout_n']))
elif txin_dict.get('output'):
prevout = TxOutpoint.from_str(txin_dict['output'])
else:
raise UserFacingException(f"missing prevout for txin {txin_idx}")
txin = PartialTxInput(prevout=prevout)
try:
txin._trusted_value_sats = int(txin_dict.get('value') or txin_dict['value_sats'])
except KeyError:
raise UserFacingException(f"missing 'value_sats' field for txin {txin_idx}")
nsequence = txin_dict.get('nsequence', None)
if nsequence is not None:
txin.nsequence = nsequence
sec = txin_dict.get('privkey')
if sec:
txin_type, privkey, compressed = bitcoin.deserialize_privkey(sec)
pubkey = ecc.ECPrivkey(privkey).get_public_key_bytes(compressed=compressed)
keypairs[pubkey] = privkey
desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey.hex(), script_type=txin_type)
txin.script_descriptor = desc
inputs.append(txin)
outputs = [] # type: List[PartialTxOutput]
for txout_idx, txout_dict in enumerate(jsontx.get('outputs')):
try:
txout_addr = txout_dict['address']
except KeyError:
raise UserFacingException(f"missing 'address' field for txout {txout_idx}")
try:
txout_val = int(txout_dict.get('value') or txout_dict['value_sats'])
except KeyError:
raise UserFacingException(f"missing 'value_sats' field for txout {txout_idx}")
txout = PartialTxOutput.from_address_and_value(txout_addr, txout_val)
outputs.append(txout)
tx = PartialTransaction.from_io(inputs, outputs, locktime=locktime)
tx.sign(keypairs)
return tx.serialize()
@command('')
async def signtransaction_with_privkey(self, tx, privkey):
"""Sign a transaction with private keys passed as parameter.
arg:tx:tx:Transaction to sign
arg:str:privkey:private key or list of private keys
"""
tx = tx_from_any(tx)
txins_dict = defaultdict(list)
for txin in tx.inputs():
txins_dict[txin.address].append(txin)
if not isinstance(privkey, list):
privkey = [privkey]
for priv in privkey:
txin_type, priv2, compressed = bitcoin.deserialize_privkey(priv)
pubkey = ecc.ECPrivkey(priv2).get_public_key_bytes(compressed=compressed)
desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey.hex(), script_type=txin_type)
address = desc.expand().address()
if address in txins_dict.keys():
for txin in txins_dict[address]:
txin.script_descriptor = desc
tx.sign({pubkey: priv2})
return tx.serialize()
@command('wp')
async def signtransaction(self, tx, password=None, wallet: Abstract_Wallet = None, ignore_warnings: bool=False):
"""
Sign a transaction with the current wallet.
arg:tx:tx:transaction
arg:bool:ignore_warnings:ignore warnings
"""
tx = tx_from_any(tx)
wallet.sign_transaction(tx, password, ignore_warnings=ignore_warnings)
return tx.serialize()
@command('')
async def deserialize(self, tx):
"""
Deserialize a transaction
arg:str:tx:Serialized transaction
"""
tx = tx_from_any(tx)
return tx.to_json()
@command('n')
async def broadcast(self, tx):
"""
Broadcast a transaction to the network.
arg:str:tx:Serialized transaction (must be hexadecimal)
"""
tx = Transaction(tx)
await self.network.broadcast_transaction(tx)
return tx.txid()
@command('')
async def createmultisig(self, num, pubkeys):
"""
Create multisig 'n of m' address
arg:int:num:Number of cosigners required
arg:json:pubkeys:List of public keys
"""
assert isinstance(pubkeys, list), (type(num), type(pubkeys))
redeem_script = multisig_script(pubkeys, num)
address = bitcoin.hash160_to_p2sh(hash_160(redeem_script))
return {'address': address, 'redeemScript': redeem_script.hex()}
@command('w')
async def freeze(self, address: str, wallet: Abstract_Wallet = None):
"""
Freeze address. Freeze the funds at one of your wallet\'s addresses
arg:str:address:Bitcoin address
"""
return wallet.set_frozen_state_of_addresses([address], True)
@command('w')
async def unfreeze(self, address: str, wallet: Abstract_Wallet = None):
"""
Unfreeze address. Unfreeze the funds at one of your wallet\'s address
arg:str:address:Bitcoin address
"""
return wallet.set_frozen_state_of_addresses([address], False)
@command('w')
async def freeze_utxo(self, coin: str, wallet: Abstract_Wallet = None):
"""
Freeze a UTXO so that the wallet will not spend it.
arg:str:coin:outpoint, in the format
"""
wallet.set_frozen_state_of_coins([coin], True)
return True
@command('w')
async def unfreeze_utxo(self, coin: str, wallet: Abstract_Wallet = None):
"""Unfreeze a UTXO so that the wallet might spend it.
arg:str:coin:outpoint
"""
wallet.set_frozen_state_of_coins([coin], False)
return True
@command('wp')
async def getprivatekeys(self, address, password=None, wallet: Abstract_Wallet = None):
"""
Get private keys of addresses. You may pass a single wallet address, or a list of wallet addresses.
arg:str:address:Bitcoin address
"""
if isinstance(address, str):
address = address.strip()
if is_address(address):
return wallet.export_private_key(address, password)
domain = address
return [wallet.export_private_key(address, password) for address in domain]
@command('wp')
async def getprivatekeyforpath(self, path, password=None, wallet: Abstract_Wallet = None):
"""Get private key corresponding to derivation path (address index).
arg:str:path:Derivation path. Can be either a str such as "m/0/50", or a list of ints such as [0, 50].
"""
return wallet.export_private_key_for_path(path, password)
@command('w')
async def ismine(self, address, wallet: Abstract_Wallet = None):
"""
Check if address is in wallet. Return true if and only address is in wallet
arg:str:address:Bitcoin address
"""
return wallet.is_mine(address)
@command('')
async def dumpprivkeys(self):
"""Deprecated."""
return "This command is deprecated. Use a pipe instead: 'electrum listaddresses | electrum getprivatekeys - '"
@command('')
async def validateaddress(self, address):
"""Check that an address is valid.
arg:str:address:Bitcoin address
"""
return is_address(address)
@command('w')
async def getpubkeys(self, address, wallet: Abstract_Wallet = None):
"""
Return the public keys for a wallet address.
arg:str:address:Bitcoin address
"""
return wallet.get_public_keys(address)
@command('w')
async def getbalance(self, wallet: Abstract_Wallet = None):
"""Return the balance of your wallet. """
c, u, x = wallet.get_balance()
l = wallet.lnworker.get_balance() if wallet.lnworker else None
out = {"confirmed": format_satoshis(c)}
if u:
out["unconfirmed"] = format_satoshis(u)
if x:
out["unmatured"] = format_satoshis(x)
if l:
out["lightning"] = format_satoshis(l)
return out
@command('n')
async def getaddressbalance(self, address):
"""
Return the balance of any address. Note: This is a walletless
server query, results are not checked by SPV.
arg:str:address:Bitcoin address
"""
sh = bitcoin.address_to_scripthash(address)
out = await self.network.get_balance_for_scripthash(sh)
out["confirmed"] = format_satoshis(out["confirmed"])
out["unconfirmed"] = format_satoshis(out["unconfirmed"])
return out
@command('n')
async def getmerkle(self, txid, height):
"""Get Merkle branch of a transaction included in a block. Electrum
uses this to verify transactions (Simple Payment Verification).
arg:txid:txid:Transaction ID
arg:int:height:Block height
"""
return await self.network.get_merkle_for_transaction(txid, int(height))
@command('n')
async def getservers(self):
"""Return the list of known servers (candidates for connecting)."""
return self.network.get_servers()
@command('')
async def version(self):
"""Return the version of Electrum."""
return ELECTRUM_VERSION
@command('')
async def version_info(self):
"""Return information about dependencies, such as their version and path."""
ret = {
"electrum.version": ELECTRUM_VERSION,
"electrum.path": os.path.dirname(os.path.realpath(__file__)),
"python.version": sys.version,
"python.path": sys.executable,
}
# add currently running GUI
if self.daemon and self.daemon.gui_object:
ret.update(self.daemon.gui_object.version_info())
# always add Qt GUI, so we get info even when running this from CLI
try:
from .gui.qt import ElectrumGui as QtElectrumGui
ret.update(QtElectrumGui.version_info())
except GuiImportError:
pass
# Add shared libs (.so/.dll), and non-pure-python dependencies.
# Such deps can be installed in various ways - often via the Linux distro's pkg manager,
# instead of using pip, hence it is useful to list them for debugging.
from electrum_ecc import ecc_fast
ret.update(ecc_fast.version_info())
from . import qrscanner
ret.update(qrscanner.version_info())
ret.update(DeviceMgr.version_info())
ret.update(crypto.version_info())
# add some special cases
import aiohttp
ret["aiohttp.version"] = aiohttp.__version__
import aiorpcx
ret["aiorpcx.version"] = aiorpcx._version_str
import certifi
ret["certifi.version"] = certifi.__version__
import dns
ret["dnspython.version"] = dns.__version__
import ssl
ret["openssl.version"] = ssl.OPENSSL_VERSION
return ret
@command('w')
async def getmpk(self, wallet: Abstract_Wallet = None):
"""Get master public key. Return your wallet\'s master public key"""
return wallet.get_master_public_key()
@command('wp')
async def getmasterprivate(self, password=None, wallet: Abstract_Wallet = None):
"""Get master private key. Return your wallet\'s master private key"""
return str(wallet.keystore.get_master_private_key(password))
@command('')
async def convert_xkey(self, xkey, xtype):
"""Convert xtype of a master key. e.g. xpub -> ypub
arg:str:xkey:the key
arg:str:xtype:the type, eg 'xpub'
"""
try:
node = BIP32Node.from_xkey(xkey)
except Exception:
raise UserFacingException('xkey should be a master public/private key')
return node._replace(xtype=xtype).to_xkey()
@command('wp')
async def getseed(self, password=None, wallet: Abstract_Wallet = None):
"""Get seed phrase. Print the generation seed of your wallet."""
s = wallet.get_seed(password)
return s
@command('wp')
async def importprivkey(self, privkey, password=None, wallet: Abstract_Wallet = None):
"""Import a private key or a list of private keys.
arg:str:privkey:Private key. Type \'?\' to get a prompt.
"""
if not wallet.can_import_privkey():
return "Error: This type of wallet cannot import private keys. Try to create a new wallet with that key."
assert isinstance(wallet, Imported_Wallet)
keys = privkey.split()
if not keys:
return "Error: no keys given"
elif len(keys) == 1:
try:
addr = wallet.import_private_key(keys[0], password)
out = "Keypair imported: " + addr
except Exception as e:
out = "Error: " + repr(e)
return out
else:
good_inputs, bad_inputs = wallet.import_private_keys(keys, password)
return {
"good_keys": len(good_inputs),
"bad_keys": len(bad_inputs),
}
async def _resolver(self, x, wallet: Abstract_Wallet):
if x is None:
return None
out = await wallet.contacts.resolve(x)
return out['address']
@command('n')
async def sweep(self, privkey, destination, fee=None, feerate=None, imax=100):
"""
Sweep private keys. Returns a transaction that spends UTXOs from
privkey to a destination address. The transaction will not be broadcast.
arg:str:privkey:Private key. Type \'?\' to get a prompt.
arg:str:destination:Bitcoin address, contact or alias
arg:decimal:fee:Transaction fee (absolute, in BTC)
arg:decimal:feerate:Transaction fee rate (in sat/vbyte)
arg:int:imax:Maximum number of inputs
"""
from .wallet import sweep
fee_policy = self._get_fee_policy(fee, feerate)
privkeys = privkey.split()
#dest = self._resolver(destination)
tx = await sweep(
privkeys,
network=self.network,
to_address=destination,
fee_policy=fee_policy,
imax=imax,
)
return tx.serialize() if tx else None
@command('wp')
async def signmessage(self, address, message, password=None, wallet: Abstract_Wallet = None):
"""Sign a message with a key. Use quotes if your message contains
whitespaces
arg:str:address:Bitcoin address
arg:str:message:Clear text message. Use quotes if it contains spaces.
"""
sig = wallet.sign_message(address, message, password)
return base64.b64encode(sig).decode('ascii')
@command('')
async def verifymessage(self, address, signature, message):
"""Verify a signature.
arg:str:address:Bitcoin address
arg:str:message:Clear text message. Use quotes if it contains spaces.
arg:str:signature:The signature, base64-encoded.
"""
try:
sig = base64.b64decode(signature, validate=True)
except binascii.Error:
return False
message = util.to_bytes(message)
return bitcoin.verify_usermessage_with_address(address, sig, message)
def _get_fee_policy(self, fee: str, feerate: str):
if fee is not None and feerate is not None:
raise Exception('Cannot set both fee and feerate')
if fee is not None:
fee_sats = satoshis(fee)
fee_policy = FeePolicy(f'fixed:{fee_sats}')
elif feerate is not None:
sat_per_kvbyte = int(1000 * to_decimal(feerate))
fee_policy = FeePolicy(f'feerate:{sat_per_kvbyte}')
else:
fee_policy = FeePolicy(self.config.FEE_POLICY)
return fee_policy
@command('wp')
async def payto(self, destination, amount, fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None,
unsigned=False, rbf=True, password=None, locktime=None, addtransaction=False, wallet: Abstract_Wallet = None):
"""Create an on-chain transaction.
arg:str:destination:Bitcoin address, contact or alias
arg:decimal_or_max:amount:Amount to be sent (in BTC). Type '!' to send the maximum available.
arg:decimal:fee:Transaction fee (absolute, in BTC)
arg:decimal:feerate:Transaction fee rate (in sat/vbyte)
arg:str:from_addr:Source address (must be a wallet address; use sweep to spend from non-wallet address)
arg:str:change_addr:Change address. Default is a spare address, or the source address if it's not in the wallet
arg:bool:rbf:Whether to signal opt-in Replace-By-Fee in the transaction (true/false)
arg:bool:addtransaction:Whether transaction is to be used for broadcasting afterwards. Adds transaction to the wallet
arg:int:locktime:Set locktime block number
arg:bool:unsigned:Do not sign transaction
arg:json:from_coins:Source coins (must be in wallet; use sweep to spend from non-wallet address)
"""
return await self.paytomany(
outputs=[(destination, amount),],
fee=fee,
feerate=feerate,
from_addr=from_addr,
from_coins=from_coins,
change_addr=change_addr,
unsigned=unsigned,
rbf=rbf,
password=password,
locktime=locktime,
addtransaction=addtransaction,
wallet=wallet,
)
@command('wp')
async def paytomany(self, outputs, fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None,
unsigned=False, rbf=True, password=None, locktime=None, addtransaction=False, wallet: Abstract_Wallet = None):
"""Create a multi-output transaction.
arg:json:outputs:json list of ["address", "amount in BTC"]
arg:bool:rbf:Whether to signal opt-in Replace-By-Fee in the transaction (true/false)
arg:decimal:fee:Transaction fee (absolute, in BTC)
arg:decimal:feerate:Transaction fee rate (in sat/vbyte)
arg:str:from_addr:Source address (must be a wallet address; use sweep to spend from non-wallet address)
arg:str:change_addr:Change address. Default is a spare address, or the source address if it's not in the wallet
arg:bool:addtransaction:Whether transaction is to be used for broadcasting afterwards. Adds transaction to the wallet
arg:int:locktime:Set locktime block number
arg:bool:unsigned:Do not sign transaction
arg:json:from_coins:Source coins (must be in wallet; use sweep to spend from non-wallet address)
"""
fee_policy = self._get_fee_policy(fee, feerate)
domain_addr = from_addr.split(',') if from_addr else None
domain_coins = from_coins.split(',') if from_coins else None
change_addr = await self._resolver(change_addr, wallet)
if domain_addr is not None:
resolvers = [self._resolver(addr, wallet) for addr in domain_addr]
domain_addr = await asyncio.gather(*resolvers)
final_outputs = []
for address, amount in outputs:
address = await self._resolver(address, wallet)
amount_sat = satoshis_or_max(amount)
final_outputs.append(PartialTxOutput.from_address_and_value(address, amount_sat))
coins = wallet.get_spendable_coins(domain_addr)
if domain_coins is not None:
coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)]
tx = wallet.make_unsigned_transaction(
outputs=final_outputs,
fee_policy=fee_policy,
change_addr=change_addr,
coins=coins,
rbf=rbf,
locktime=locktime,
)
if not unsigned:
wallet.sign_transaction(tx, password)
result = tx.serialize()
if addtransaction:
await self.addtransaction(result, wallet=wallet)
return result
def get_year_timestamps(self, year: int) -> dict[str, Any]:
kwargs = {}
if year:
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())
return kwargs
@command('w')
async def onchain_capital_gains(self, year=None, wallet: Abstract_Wallet = None):
"""
Capital gains, using utxo pricing.
This cannot be used with lightning.
arg:int:year:Show cap gains for a given year
"""
kwargs = self.get_year_timestamps(year)
from .exchange_rate import FxThread
fx = self.daemon.fx if self.daemon else FxThread(config=self.config)
return json_normalize(wallet.get_onchain_capital_gains(fx, **kwargs))
@command('wp')
async def bumpfee(self, tx, new_fee_rate, from_coins=None, decrease_payment=False, password=None, unsigned=False, wallet: Abstract_Wallet = None):
"""
Bump the fee for an unconfirmed transaction.
'tx' can be either a raw hex tx or a txid. If txid, the corresponding tx must already be part of the wallet history.
arg:str:tx:Serialized transaction (hexadecimal)
arg:str:new_fee_rate: The Updated/Increased Transaction fee rate (in sats/vbyte)
arg:bool:decrease_payment:Whether payment amount will be decreased (true/false)
arg:bool:unsigned:Do not sign transaction
arg:json:from_coins:Coins that may be used to inncrease the fee (must be in wallet)
"""
if is_hash256_str(tx): # txid
tx = wallet.db.get_transaction(tx)
if tx is None:
raise UserFacingException("Transaction not in wallet.")
else: # raw tx
try:
tx = Transaction(tx)
tx.deserialize()
except transaction.SerializationError as e:
raise UserFacingException(f"Failed to deserialize transaction: {e}") from e
domain_coins = from_coins.split(',') if from_coins else None
coins = wallet.get_spendable_coins(None)
if domain_coins is not None:
coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)]
tx.add_info_from_wallet(wallet)
await tx.add_info_from_network(self.network)
new_tx = wallet.bump_fee(
tx=tx,
coins=coins,
strategy=BumpFeeStrategy.DECREASE_PAYMENT if decrease_payment else BumpFeeStrategy.PRESERVE_PAYMENT,
new_fee_rate=new_fee_rate)
if not unsigned:
wallet.sign_transaction(new_tx, password)
return new_tx.serialize()
@command('w')
async def onchain_history(
self, show_fiat=False, year=None, show_addresses=False,
from_height=None, to_height=None,
wallet: Abstract_Wallet = None,
):
"""Wallet onchain history. Returns the transaction history of your wallet.
arg:bool:show_addresses:Show input and output addresses
arg:bool:show_fiat:Show fiat value of transactions
arg:int:year:Show history for a given year
arg:int:from_height:Only show transactions that confirmed after(inclusive) given block height
arg:int:to_height:Only show transactions that confirmed before(exclusive) given block height
"""
# trigger lnwatcher callbacks for their side effects: setting labels and accounting_addresses
if not self.network and wallet.lnworker:
await wallet.lnworker.lnwatcher.trigger_callbacks(requires_synchronizer=False)
kwargs = self.get_year_timestamps(year)
kwargs['from_height'] = from_height
kwargs['to_height'] = to_height
onchain_history = wallet.get_onchain_history(**kwargs)
out = [x.to_dict() for x in onchain_history.values()]
if show_fiat:
from .exchange_rate import FxThread
fx = self.daemon.fx if self.daemon else FxThread(config=self.config)
else:
fx = None
for item in out:
if show_addresses:
tx = wallet.db.get_transaction(item['txid'])
item['inputs'] = list(map(lambda x: x.to_json(), tx.inputs()))
item['outputs'] = list(map(lambda x: {'address': x.get_ui_address_str(), 'value_sat': x.value},
tx.outputs()))
if fx:
fiat_fields = wallet.get_tx_item_fiat(tx_hash=item['txid'], amount_sat=item['amount_sat'], fx=fx, tx_fee=item['fee_sat'])
item.update(fiat_fields)
return json_normalize(out)
@command('wl')
async def lightning_history(self, wallet: Abstract_Wallet = None):
""" lightning history. """
lightning_history = wallet.lnworker.get_lightning_history() if wallet.lnworker else {}
sorted_hist= sorted(lightning_history.values(), key=lambda x: x.timestamp)
return json_normalize([x.to_dict() for x in sorted_hist])
@command('w')
async def setlabel(self, key, label, wallet: Abstract_Wallet = None):
"""
Assign a label to an item. Item may be a bitcoin address or a
transaction ID
arg:str:key:Key
arg:str:label:Label
"""
wallet.set_label(key, label)
@command('w')
async def listcontacts(self, wallet: Abstract_Wallet = None):
"""Show your list of contacts"""
return wallet.contacts
@command('w')
async def getopenalias(self, key, wallet: Abstract_Wallet = None):
"""
Retrieve alias. Lookup in your list of contacts, and for an OpenAlias DNS record.
arg:str:key:the alias to be retrieved
"""
d = await wallet.contacts.resolve(key)
if d.get("type") == "openalias":
# we always validate DNSSEC now
d["validated"] = True
return d
@command('w')
async def searchcontacts(self, query, wallet: Abstract_Wallet = None):
"""
Search through your wallet contacts, return matching entries.
arg:str:query:Search query
"""
results = {}
for key, value in wallet.contacts.items():
if query.lower() in key.lower():
results[key] = value
return results
@command('w')
async def listaddresses(self, receiving=False, change=False, labels=False, frozen=False, unused=False, funded=False, balance=False, wallet: Abstract_Wallet = None):
"""List wallet addresses. Returns the list of all addresses in your wallet. Use optional arguments to filter the results.
arg:bool:receiving:Show only receiving addresses
arg:bool:change:Show only change addresses
arg:bool:frozen:Show only frozen addresses
arg:bool:unused:Show only unused addresses
arg:bool:funded:Show only funded addresses
arg:bool:balance:Show the balances of listed addresses
arg:bool:labels:Show the labels of listed addresses
"""
out = []
for addr in wallet.get_addresses():
if frozen and not wallet.is_frozen_address(addr):
continue
if receiving and wallet.is_change(addr):
continue
if change and not wallet.is_change(addr):
continue
if unused and wallet.adb.is_used(addr):
continue
if funded and wallet.adb.is_empty(addr):
continue
item = addr
if labels or balance:
item = (item,)
if balance:
item += (format_satoshis(sum(wallet.get_addr_balance(addr))),)
if labels:
item += (repr(wallet.get_label_for_address(addr)),)
out.append(item)
return out
@command('n')
async def gettransaction(self, txid, wallet: Abstract_Wallet = None):
"""Retrieve a transaction.
arg:txid:txid:Transaction ID
"""
tx = None
if wallet:
tx = wallet.db.get_transaction(txid)
if tx is None:
raw = await self.network.get_transaction(txid)
if raw:
tx = Transaction(raw)
else:
raise UserFacingException("Unknown transaction")
if tx.txid() != txid:
raise UserFacingException("Mismatching txid")
return tx.serialize()
@command('')
async def encrypt(self, pubkey, message) -> str:
"""
Encrypt a message with a public key. Use quotes if the message contains whitespaces.
arg:str:pubkey:Public key
arg:str:message:Clear text message. Use quotes if it contains spaces.
"""
if not is_hex_str(pubkey):
raise UserFacingException(f"pubkey must be a hex string instead of {repr(pubkey)}")
try:
message = to_bytes(message)
except TypeError:
raise UserFacingException(f"message must be a string-like object instead of {repr(message)}")
public_key = ecc.ECPubkey(bfh(pubkey))
encrypted = crypto.ecies_encrypt_message(public_key, message)
return encrypted.decode('utf-8')
@command('wp')
async def decrypt(self, pubkey, encrypted, password=None, wallet: Abstract_Wallet = None) -> str:
"""Decrypt a message encrypted with a public key.
arg:str:encrypted:Encrypted message
arg:str:pubkey:Public key of one of your wallet addresses
"""
if not is_hex_str(pubkey):
raise UserFacingException(f"pubkey must be a hex string instead of {repr(pubkey)}")
if not isinstance(encrypted, (str, bytes, bytearray)):
raise UserFacingException(f"encrypted must be a string-like object instead of {repr(encrypted)}")
decrypted = wallet.decrypt_message(pubkey, encrypted, password)
return decrypted.decode('utf-8')
@command('w')
async def get_request(self, request_id, wallet: Abstract_Wallet = None):
"""Returns a payment request
arg:str:request_id:The request ID, as seen in list_requests or add_request
"""
r = wallet.get_request(request_id)
if not r:
raise UserFacingException("Request not found")
return wallet.export_request(r)
@command('w')
async def get_invoice(self, invoice_id, wallet: Abstract_Wallet = None):
"""
Returns an invoice (request for outgoing payment)
arg:str:invoice_id:The invoice ID, as seen in list_invoices
"""
r = wallet.get_invoice(invoice_id)
if not r:
raise UserFacingException("Request not found")
return wallet.export_invoice(r)
def _filter_invoices(self, _list, wallet, pending, expired, paid):
if pending:
f = PR_UNPAID
elif expired:
f = PR_EXPIRED
elif paid:
f = PR_PAID
else:
f = None
if f is not None:
_list = [x for x in _list if f == wallet.get_invoice_status(x)]
return _list
@command('w')
async def list_requests(self, pending=False, expired=False, paid=False, wallet: Abstract_Wallet = None):
"""
Returns the list of incoming payment requests saved in the wallet.
arg:bool:paid:Show only paid requests
arg:bool:pending:Show only pending requests
arg:bool:expired:Show only expired requests
"""
l = wallet.get_sorted_requests()
l = self._filter_invoices(l, wallet, pending, expired, paid)
return [wallet.export_request(x) for x in l]
@command('w')
async def list_invoices(self, pending=False, expired=False, paid=False, wallet: Abstract_Wallet = None):
"""
Returns the list of invoices (requests for outgoing payments) saved in the wallet.
arg:bool:paid:Show only paid invoices
arg:bool:pending:Show only pending invoices
arg:bool:expired:Show only expired invoices
"""
l = wallet.get_invoices()
l = self._filter_invoices(l, wallet, pending, expired, paid)
return [wallet.export_invoice(x) for x in l]
@command('w')
async def createnewaddress(self, wallet: Abstract_Wallet = None):
"""Create a new receiving address, beyond the gap limit of the wallet"""
return wallet.create_new_address(False)
@command('w')
async def changegaplimit(self, new_limit, iknowwhatimdoing=False, wallet: Abstract_Wallet = None):
"""
Change the gap limit of the wallet.
arg:int:new_limit:new gap limit
arg:bool:iknowwhatimdoing:Acknowledge that I understand the full implications of what I am about to do
"""
if not iknowwhatimdoing:
raise UserFacingException(
"WARNING: Are you SURE you want to change the gap limit?\n"
"It makes recovering your wallet from seed difficult!\n"
"Please do your research and make sure you understand the implications.\n"
"Typically only merchants and power users might want to do this.\n"
"To proceed, try again, with the --iknowwhatimdoing option.")
if not isinstance(wallet, Deterministic_Wallet):
raise UserFacingException("This wallet is not deterministic.")
return wallet.change_gap_limit(new_limit)
@command('wn')
async def getminacceptablegap(self, wallet: Abstract_Wallet = None):
"""Returns the minimum value for gap limit that would be sufficient to discover all
known addresses in the wallet.
"""
if not isinstance(wallet, Deterministic_Wallet):
raise UserFacingException("This wallet is not deterministic.")
if not wallet.is_up_to_date():
raise NotSynchronizedException("Wallet not fully synchronized.")
return wallet.min_acceptable_gap()
@command('w')
async def getunusedaddress(self, wallet: Abstract_Wallet = None):
"""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 wallet.get_unused_address()
@command('w')
async def add_request(self, amount, memo='', expiry=3600, lightning=False, force=False, wallet: Abstract_Wallet = None):
"""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.
arg:decimal:amount:Requested amount (in btc)
arg:str:memo:Description of the request
arg:bool:force:Create new address beyond gap limit, if no more addresses are available.
arg:bool:lightning:Create lightning request.
arg:int:expiry:Time in seconds.
"""
amount = satoshis(amount)
if not lightning:
addr = wallet.get_unused_address()
if addr is None:
if force:
addr = wallet.create_new_address(False)
else:
return False
else:
addr = None
expiry = int(expiry) if expiry else None
key = wallet.create_request(amount, memo, expiry, addr)
req = wallet.get_request(key)
return wallet.export_request(req)
@command('wnl')
async def add_hold_invoice(
self,
payment_hash: str,
amount: Optional[Decimal] = None,
memo: str = "",
expiry: int = 3600,
min_final_cltv_expiry_delta: int = MIN_FINAL_CLTV_DELTA_ACCEPTED * 2,
wallet: Abstract_Wallet = None
) -> dict:
"""
Create a lightning hold invoice for the given payment hash. Hold invoices have to get settled manually later.
HTLCs will get failed automatically if block_height + 144 > htlc.cltv_abs, if the intention is to
settle them as late as possible a safety margin of some blocks should be used to prevent them
from getting failed accidentally.
arg:str:payment_hash:Hex encoded payment hash to be used for the invoice
arg:decimal:amount:Optional requested amount (in btc)
arg:str:memo:Optional description of the invoice
arg:int:expiry:Optional expiry in seconds (default: 3600s)
arg:int:min_final_cltv_expiry_delta:Optional min final cltv expiry delta (default: 294 blocks)
"""
assert len(payment_hash) == 64, f"Invalid payment hash length: {len(payment_hash)} != 64"
assert not wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED), "Payment hash already used!"
assert payment_hash not in wallet.lnworker.dont_expire_htlcs, "Payment hash already used!"
assert wallet.lnworker.get_preimage(bfh(payment_hash)) is None, "Already got a preimage for this payment hash!"
assert MIN_FINAL_CLTV_DELTA_ACCEPTED < min_final_cltv_expiry_delta < 576, "Use a sane min_final_cltv_expiry_delta value"
amount = amount if amount and satoshis(amount) > 0 else None # make amount either >0 or None
inbound_capacity = wallet.lnworker.num_sats_can_receive()
assert inbound_capacity > satoshis(amount or 0), \
f"Not enough inbound capacity [{inbound_capacity} sat] to receive this payment"
wallet.lnworker.add_payment_info_for_hold_invoice(
bfh(payment_hash),
lightning_amount_sat=satoshis(amount) if amount else None,
min_final_cltv_delta=min_final_cltv_expiry_delta,
exp_delay=expiry,
)
info = wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED)
lnaddr, invoice = wallet.lnworker.get_bolt11_invoice(
payment_info=info,
message=memo,
fallback_address=None
)
# this prevents incoming htlcs from getting expired while the preimage isn't set.
# If their blocks to expiry fall below MIN_FINAL_CLTV_DELTA_ACCEPTED they will get failed.
wallet.lnworker.dont_expire_htlcs[payment_hash] = MIN_FINAL_CLTV_DELTA_ACCEPTED
wallet.set_label(payment_hash, memo)
result = {
"invoice": invoice
}
return result
@command('wnl')
async def settle_hold_invoice(self, preimage: str, wallet: Abstract_Wallet = None) -> dict:
"""
Settles lightning hold invoice with the given preimage.
Doesn't block until actual settlement of the HTLCs.
arg:str:preimage:Hex encoded preimage of the invoice to be settled
"""
assert len(preimage) == 64, f"Invalid payment_hash length: {len(preimage)} != 64"
payment_hash: str = crypto.sha256(bfh(preimage)).hex()
assert payment_hash not in wallet.lnworker._preimages, f"Invoice {payment_hash=} already settled"
info = wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED)
assert info, f"Couldn't find lightning invoice for {payment_hash=}"
assert payment_hash in wallet.lnworker.dont_expire_htlcs, f"Invoice {payment_hash=} not a hold invoice?"
assert wallet.lnworker.is_complete_mpp(bfh(payment_hash)), \
f"MPP incomplete, cannot settle hold invoice {payment_hash} yet"
assert (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) >= (info.amount_msat or 0)
wallet.lnworker.save_preimage(bfh(payment_hash), bfh(preimage))
util.trigger_callback('wallet_updated', wallet)
result = {
"settled": payment_hash
}
return result
@command('wnl')
async def cancel_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict:
"""
Cancels lightning hold invoice 'payment_hash'.
arg:str:payment_hash:Payment hash in hex of the hold invoice
"""
assert wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED), \
f"Couldn't find lightning invoice for payment hash {payment_hash}"
assert payment_hash not in wallet.lnworker._preimages, "Cannot cancel anymore, preimage already given."
assert payment_hash in wallet.lnworker.dont_expire_htlcs, f"{payment_hash=} not a hold invoice?"
# set to PR_UNPAID so it can get deleted
wallet.lnworker.set_payment_status(bfh(payment_hash), PR_UNPAID, direction=RECEIVED)
wallet.lnworker.delete_payment_info(payment_hash, direction=RECEIVED)
wallet.set_label(payment_hash, None)
del wallet.lnworker.dont_expire_htlcs[payment_hash]
while wallet.lnworker.is_complete_mpp(bfh(payment_hash)):
# block until the htlcs got failed
await asyncio.sleep(0.1)
result = {
"cancelled": payment_hash
}
return result
@command('wnl')
async def check_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict:
"""
Checks the status of a lightning hold invoice 'payment_hash'.
Returns: {
"status": unpaid | paid | settled | unknown (cancelled or not found),
"received_amount_sat": currently received amount (pending htlcs or final after settling),
"invoice_amount_sat": Invoice amount, Optional (only if invoice is found),
"closest_htlc_expiry_height": Closest absolute expiry height of all received htlcs
(Note: HTLCs will get failed automatically if block_height + 144 > htlc_expiry_height)
}
arg:str:payment_hash:Payment hash in hex of the hold invoice
"""
assert len(payment_hash) == 64, f"Invalid payment_hash length: {len(payment_hash)} != 64"
info: Optional['PaymentInfo'] = wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED)
is_complete_mpp: bool = wallet.lnworker.is_complete_mpp(bfh(payment_hash))
amount_sat = (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) // 1000
result = {
"status": "unknown",
"received_amount_sat": amount_sat,
}
if info is None:
pass
elif not is_complete_mpp and not wallet.lnworker.get_preimage_hex(payment_hash):
# is_complete_mpp is False for settled payments
result["status"] = "unpaid"
elif is_complete_mpp and payment_hash in wallet.lnworker.dont_expire_htlcs:
result["status"] = "paid"
payment_key: str = wallet.lnworker._get_payment_key(bfh(payment_hash)).hex()
htlc_status = wallet.lnworker.received_mpp_htlcs[payment_key]
result["closest_htlc_expiry_height"] = min(
mpp_htlc.htlc.cltv_abs for mpp_htlc in htlc_status.htlcs
)
elif wallet.lnworker.get_preimage_hex(payment_hash) is not None:
result["status"] = "settled"
plist = wallet.lnworker.get_payments(status='settled')[bfh(payment_hash)]
_dir, amount_msat, _fee, _ts = wallet.lnworker.get_payment_value(None, plist)
result["received_amount_sat"] = amount_msat // 1000
result['preimage'] = wallet.lnworker.get_preimage_hex(payment_hash)
if info is not None:
result["invoice_amount_sat"] = (info.amount_msat or 0) // 1000
return result
@command('wl')
async def export_lightning_preimage(self, payment_hash: str, wallet: 'Abstract_Wallet' = None) -> Optional[str]:
"""
Returns the stored preimage of the given payment_hash if it is known.
arg:str:payment_hash: Hash of the preimage
"""
preimage = wallet.lnworker.get_preimage_hex(payment_hash)
assert preimage is None or crypto.sha256(bytes.fromhex(preimage)).hex() == payment_hash
return preimage
@command('w')
async def addtransaction(self, tx, wallet: Abstract_Wallet = None):
"""
Add a transaction to the wallet history, without broadcasting it.
arg:tx:tx:Transaction, in hexadecimal format.
"""
tx = Transaction(tx)
if not wallet.adb.add_transaction(tx):
return False
wallet.save_db()
return tx.txid()
@command('w')
async def delete_request(self, request_id, wallet: Abstract_Wallet = None):
"""Remove an incoming payment request
arg:str:request_id:The request ID, as returned in list_invoices
"""
return wallet.delete_request(request_id)
@command('w')
async def delete_invoice(self, invoice_id, wallet: Abstract_Wallet = None):
"""Remove an outgoing payment invoice
arg:str:invoice_id:The invoice ID, as returned in list_invoices
"""
return wallet.delete_invoice(invoice_id)
@command('w')
async def clear_requests(self, wallet: Abstract_Wallet = None):
"""Remove all payment requests"""
wallet.clear_requests()
return True
@command('w')
async def clear_invoices(self, wallet: Abstract_Wallet = None):
"""Remove all invoices"""
wallet.clear_invoices()
return True
@command('n')
async def notify(self, address: str, URL: Optional[str]):
"""
Watch an address. Every time the address changes, a http POST is sent to the URL.
Call with an empty URL to stop watching an address.
arg:str:address:Bitcoin address
arg:str:URL:The callback URL
"""
if not hasattr(self, "_notifier"):
self._notifier = Notifier(self.network)
if URL:
await self._notifier.start_watching_addr(address, URL)
else:
await self._notifier.stop_watching_addr(address)
return True
@command('wn')
async def is_synchronized(self, wallet: Abstract_Wallet = None):
""" return wallet synchronization status """
return wallet.is_up_to_date()
@command('wn')
async def wait_for_sync(self, wallet: Abstract_Wallet = None):
"""Block until the wallet synchronization finishes."""
while True:
if wallet.is_up_to_date():
return True
await wallet.up_to_date_changed_event.wait()
@command('n')
async def getfeerate(self):
"""
Return current fee estimate given network conditions (in sat/kvByte).
To change the fee policy, use 'getconfig/setconfig fee_policy'
"""
fee_policy = FeePolicy(self.config.FEE_POLICY)
description = fee_policy.get_target_text()
feerate = fee_policy.fee_per_kb(self.network)
tooltip = fee_policy.get_estimate_text(self.network)
return {
'policy': fee_policy.get_descriptor(),
'description': description,
'sat/kvB': feerate,
'tooltip': tooltip,
}
@command('n')
async def test_inject_fee_etas(self, fee_est):
"""
Inject fee estimates into the network object, as if they were coming from connected servers.
`setconfig 'test_disable_automatic_fee_eta_update' true` to prevent Network from overriding
the configured fees.
Useful on regtest.
arg:str:fee_est:dict of ETA-based fee estimates, encoded as str
"""
if not isinstance(fee_est, dict):
fee_est = ast.literal_eval(fee_est)
assert isinstance(fee_est, dict), f"unexpected type for fee_est. got {repr(fee_est)}"
# populate missing high-block-number estimates using default relay fee.
# e.g. {"25": 2222} -> {"25": 2222, "144": 1000, "1008": 1000}
furthest_estimate = max(fee_est.keys()) if fee_est else 0
further_fee_est = {
eta_target: FEERATE_DEFAULT_RELAY for eta_target in FEE_ETA_TARGETS
if eta_target > furthest_estimate
}
fee_est.update(further_fee_est)
self.network.update_fee_estimates(fee_est=fee_est)
@command('w')
async def removelocaltx(self, txid, wallet: Abstract_Wallet = None):
"""Remove a 'local' transaction from the wallet, and its dependent
transactions.
arg:txid:txid:Transaction ID
"""
height = wallet.adb.get_tx_height(txid).height()
if height != TX_HEIGHT_LOCAL:
raise UserFacingException(
f'Only local transactions can be removed. '
f'This tx has height: {height} != {TX_HEIGHT_LOCAL}')
wallet.adb.remove_transaction(txid)
wallet.save_db()
@command('wn')
async def get_tx_status(self, txid, wallet: Abstract_Wallet = None):
"""Returns some information regarding the tx. For now, only confirmations.
The transaction must be related to the wallet.
arg:txid:txid:Transaction ID
"""
if not wallet.db.get_transaction(txid):
raise UserFacingException("Transaction not in wallet.")
return {
"confirmations": wallet.adb.get_tx_height(txid).conf,
}
@command('')
async def help(self):
"""Show help about a command"""
# for the python console
return sorted(known_commands.keys())
# lightning network commands
@command('wnl')
async def add_peer(self, connection_string, timeout=20, gossip=False, wallet: Abstract_Wallet = None):
"""
Connect to a lightning node
arg:str:connection_string:Lightning network node ID or network address
arg:bool:gossip:Apply command to your gossip node instead of wallet node
arg:int:timeout:Timeout in seconds (default=20)
"""
lnworker = self.network.lngossip if gossip else wallet.lnworker
peer = await lnworker.lnpeermgr.add_peer(connection_string)
try:
await util.wait_for2(peer.initialized, timeout=LN_P2P_NETWORK_TIMEOUT)
except (CancelledError, Exception) as e:
# FIXME often simply CancelledError and real cause (e.g. timeout) remains hidden
raise UserFacingException(f"Connection failed: {repr(e)}")
return True
@command('wnl')
async def gossip_info(self, wallet: Abstract_Wallet = None):
"""Display statistics about lightninig gossip"""
lngossip = self.network.lngossip
channel_db = lngossip.channel_db
forwarded = dict([(key.hex(), p._num_gossip_messages_forwarded) for key, p in wallet.lnworker.lnpeermgr.peers.items()]),
out = {
'received': {
'channel_announcements': lngossip._num_chan_ann,
'channel_updates': lngossip._num_chan_upd,
'channel_updates_good': lngossip._num_chan_upd_good,
'node_announcements': lngossip._num_node_ann,
},
'database': {
'nodes': channel_db.num_nodes,
'channels': channel_db.num_channels,
'channel_policies': channel_db.num_policies,
},
'forwarded': forwarded,
}
return out
@command('wnl')
async def list_peers(self, gossip=False, wallet: Abstract_Wallet = None):
"""
List lightning peers of your node
arg:bool:gossip:Apply command to your gossip node instead of wallet node
"""
lnworker = self.network.lngossip if gossip else wallet.lnworker
return [{
'node_id': p.pubkey.hex(),
'address': p.transport.name(),
'initialized': p.is_initialized(),
'features': str(LnFeatures(p.features)),
'channels': [c.funding_outpoint.to_str() for c in p.channels.values()],
} for p in lnworker.lnpeermgr.peers.values()]
@command('wpnl')
async def open_channel(self, connection_string, amount, push_amount=0, public=False, zeroconf=False, password=None, wallet: Abstract_Wallet = None):
"""
Open a lightning channel with a peer
arg:str:connection_string:Lightning network node ID or network address
arg:decimal_or_max:amount:funding amount (in BTC)
arg:decimal:push_amount:Push initial amount (in BTC)
arg:bool:public:The channel will be announced
arg:bool:zeroconf:request zeroconf channel
"""
if not wallet.can_have_lightning():
raise UserFacingException("This wallet cannot create new channels")
funding_sat = satoshis(amount)
push_sat = satoshis(push_amount)
peer = await wallet.lnworker.lnpeermgr.add_peer(connection_string)
chan, funding_tx = await wallet.lnworker.open_channel_with_peer(
peer, funding_sat,
push_sat=push_sat,
public=public,
zeroconf=zeroconf,
password=password)
return chan.funding_outpoint.to_str()
@command('')
async def decode_invoice(self, invoice: str):
"""
Decode a lightning invoice
arg:str:invoice:Lightning invoice (bolt 11)
"""
invoice = Invoice.from_bech32(invoice)
return invoice.to_debug_json()
@command('wnpl')
async def lnpay(
self,
invoice: str,
timeout: int = 120,
max_cltv: Optional[int] = None,
max_fee_msat: Optional[int] = None,
password=None,
wallet: Abstract_Wallet = None
):
"""
Pay a lightning invoice
Note: it is *not* safe to try paying the same invoice multiple times with a timeout.
It is only safe to retry paying the same invoice if there are no more pending HTLCs
with the same payment_hash. # FIXME should there even be a default timeout? just block forever.
arg:str:invoice:Lightning invoice (bolt 11)
arg:int:timeout:Timeout in seconds (default=120)
arg:int:max_cltv:Maximum total time lock for the route (default=4032+invoice_final_cltv_delta)
arg:int:max_fee_msat:Maximum absolute fee budget for the payment (if unset, the default is a percentage fee based on config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
"""
# note: The "timeout" param works via black magic.
# The CLI-parser stores it in the config, and the argname matches config.cv.CLI_TIMEOUT.key().
# - it works when calling the CLI and there is also a daemon (online command)
# - FIXME it does NOT work when calling an offline command (-o)
# - FIXME it does NOT work when calling RPC directly (e.g. curl)
lnworker = wallet.lnworker
lnaddr = lnworker._check_bolt11_invoice(invoice) # also checks if amount is given
payment_hash = lnaddr.paymenthash
invoice_obj = Invoice.from_bech32(invoice)
assert not max_fee_msat or max_fee_msat < max(invoice_obj.amount_msat // 2, 1_000_000), \
f"{max_fee_msat=} > max(invoice amount msat / 2, 1_000_000)"
wallet.save_invoice(invoice_obj)
if max_cltv is not None:
# The cltv budget excludes the final cltv delta which is why it is deducted here
# so the whole used cltv is <= max_cltv
assert max_cltv <= NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, \
f"{max_cltv=} > {NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE=}"
max_cltv_remaining = max_cltv - lnaddr.get_min_final_cltv_delta()
assert max_cltv_remaining > 0, f"{max_cltv=} - {lnaddr.get_min_final_cltv_delta()=} < 1"
max_cltv = max_cltv_remaining
budget = PaymentFeeBudget.from_invoice_amount(
config=wallet.config,
invoice_amount_msat=invoice_obj.amount_msat,
max_cltv_delta=max_cltv,
max_fee_msat=max_fee_msat,
)
success, log = await lnworker.pay_invoice(invoice_obj, budget=budget)
return {
'payment_hash': payment_hash.hex(),
'success': success,
'preimage': lnworker.get_preimage(payment_hash).hex() if success else None,
'log': [x.formatted_tuple() for x in log]
}
@command('wl')
async def nodeid(self, wallet: Abstract_Wallet = None):
"""Return the Lightning Node ID of a wallet"""
listen_addr = self.config.LIGHTNING_LISTEN
return wallet.lnworker.node_keypair.pubkey.hex() + (('@' + listen_addr) if listen_addr else '')
@command('wl')
async def list_channels(self, public: bool = False, private: bool = False, active: bool = False, open: bool = False, wallet: Abstract_Wallet = None):
"""Return the list of channels in the wallet
arg:bool:public:list only public channels
arg:bool:private:list only private channels
arg:bool:open:list only open channels
arg:bool:active:list only active channels
"""
from .lnutil import LOCAL, REMOTE, format_short_channel_id
if public and private:
raise Exception("incompatible options")
def _filter(chan):
if public and not chan.is_public():
return False
if private and chan.is_public():
return False
if active and not chan.is_redeemed():
return False
if open and not chan.is_open():
return False
return True
return [
{
'short_channel_id': format_short_channel_id(chan.short_channel_id) if chan.short_channel_id else None,
'channel_id': chan.channel_id.hex(),
'channel_point': chan.funding_outpoint.to_str(),
'closing_txid': chan.get_closing_height()[0] if chan.get_closing_height() else None,
'state': chan.get_state().name,
'peer_state': chan.peer_state.name,
'remote_pubkey': chan.node_id.hex(),
'local_balance': chan.balance(LOCAL)//1000,
'remote_balance': chan.balance(REMOTE)//1000,
'local_ctn': chan.get_latest_ctn(LOCAL),
'remote_ctn': chan.get_latest_ctn(REMOTE),
'local_reserve': chan.config[REMOTE].reserve_sat, # their config has our reserve
'remote_reserve': chan.config[LOCAL].reserve_sat,
'local_unsettled_sent': chan.balance_tied_up_in_htlcs_by_direction(LOCAL, direction=SENT) // 1000,
'remote_unsettled_sent': chan.balance_tied_up_in_htlcs_by_direction(REMOTE, direction=SENT) // 1000,
} for chan in wallet.lnworker.channels.values() if _filter(chan)
]
@command('wl')
async def list_channel_backups(self, wallet: Abstract_Wallet = None):
"""Return the list of channel backups in the wallet"""
# FIXME: we need to be online to display capacity of backups
from .lnutil import LOCAL, REMOTE, format_short_channel_id
return [
{
'short_channel_id': format_short_channel_id(chan.short_channel_id) if chan.short_channel_id else None,
'channel_id': chan.channel_id.hex(),
'channel_point': chan.funding_outpoint.to_str(),
'closing_txid': chan.get_closing_height()[0] if chan.get_closing_height() else None,
'state': chan.get_state().name,
} for chan in wallet.lnworker.channel_backups.values()
]
@command('wnl')
async def enable_htlc_settle(self, b: bool, wallet: Abstract_Wallet = None):
"""
command used in regtests
arg:bool:b:boolean
"""
wallet.lnworker.enable_htlc_settle = b
@command('n')
async def clear_ln_blacklist(self):
if self.network.path_finder:
self.network.path_finder.clear_blacklist()
@command('n')
async def reset_liquidity_hints(self):
if self.network.path_finder:
self.network.path_finder.liquidity_hints.reset_liquidity_hints()
self.network.path_finder.clear_blacklist()
@command('wnpl')
async def close_channel(self, channel_point, force=False, password=None, wallet: Abstract_Wallet = None):
"""
Close a lightning channel.
Returns txid of closing tx.
arg:str:channel_point:channel point
arg:bool:force:Force closes (broadcast local commitment transaction)
"""
txid, index = channel_point.split(':')
chan_id, _ = channel_id_from_funding_tx(txid, int(index))
if chan_id not in wallet.lnworker.channels:
raise UserFacingException(f'Unknown channel {channel_point}')
coro = wallet.lnworker.force_close_channel(chan_id) if force else wallet.lnworker.close_channel(chan_id)
return await coro
@command('wnpl')
async def request_force_close(self, channel_point, connection_string=None, password=None, wallet: Abstract_Wallet = None):
"""
Requests the remote to force close a channel.
If a connection string is passed, can be used without having state or any backup for the channel.
Assumes that channel was originally opened with the same local peer (node_keypair).
arg:str:connection_string:Lightning network node ID or network address
arg:str:channel_point:channel point
"""
txid, index = channel_point.split(':')
chan_id, _ = channel_id_from_funding_tx(txid, int(index))
if chan_id not in wallet.lnworker.channels and chan_id not in wallet.lnworker.channel_backups:
raise UserFacingException(f'Unknown channel {channel_point}')
await wallet.lnworker.request_force_close(chan_id, connect_str=connection_string)
@command('wpl')
async def export_channel_backup(self, channel_point, password=None, wallet: Abstract_Wallet = None):
"""
Returns an encrypted channel backup
arg:str:channel_point:Channel outpoint
"""
txid, index = channel_point.split(':')
chan_id, _ = channel_id_from_funding_tx(txid, int(index))
if chan_id not in wallet.lnworker.channels:
raise UserFacingException(f'Unknown channel {channel_point}')
return wallet.lnworker.export_channel_backup(chan_id)
@command('wl')
async def import_channel_backup(self, encrypted, wallet: Abstract_Wallet = None):
"""
arg:str:encrypted:Encrypted channel backup
"""
return wallet.lnworker.import_channel_backup(encrypted)
@command('wnpl')
async def get_channel_ctx(self, channel_point, password=None, iknowwhatimdoing=False, wallet: Abstract_Wallet = None):
"""
return the current commitment transaction of a channel
arg:str:channel_point:Channel outpoint
arg:bool:iknowwhatimdoing:Acknowledge that I understand the full implications of what I am about to do
"""
if not iknowwhatimdoing:
raise UserFacingException(
"WARNING: this command is potentially unsafe.\n"
"To proceed, try again, with the --iknowwhatimdoing option.")
txid, index = channel_point.split(':')
chan_id, _ = channel_id_from_funding_tx(txid, int(index))
if chan_id not in wallet.lnworker.channels:
raise UserFacingException(f'Unknown channel {channel_point}')
chan = wallet.lnworker.channels[chan_id]
tx = chan.force_close_tx()
return tx.serialize()
@command('wnl')
async def get_watchtower_ctn(self, channel_point, wallet: Abstract_Wallet = None):
"""
Return the local watchtower's ctn of channel. used in regtests
arg:str:channel_point:Channel outpoint (txid:index)
"""
return wallet.lnworker.get_watchtower_ctn(channel_point)
@command('wnpl')
async def rebalance_channels(self, from_scid, dest_scid, amount, password=None, wallet: Abstract_Wallet = None):
"""
Rebalance channels.
If trampoline is used, channels must be with different trampolines.
arg:str:from_scid:Short channel ID
arg:str:dest_scid:Short channel ID
arg:decimal:amount:Amount (in BTC)
"""
from .lnutil import ShortChannelID
from_scid = ShortChannelID.from_str(from_scid)
dest_scid = ShortChannelID.from_str(dest_scid)
from_channel = wallet.lnworker.get_channel_by_short_id(from_scid)
dest_channel = wallet.lnworker.get_channel_by_short_id(dest_scid)
amount_sat = satoshis(amount)
success, log = await wallet.lnworker.rebalance_channels(
from_channel,
dest_channel,
amount_msat=amount_sat * 1000,
)
return {
'success': success,
'log': [x.formatted_tuple() for x in log]
}
@command('wnl')
async def get_submarine_swap_providers(self, query_time=15, wallet: Abstract_Wallet = None):
"""
Queries nostr relays for available submarine swap providers.
To configure one of the providers use:
setconfig swapserver_npub 'npub...'
arg:int:query_time:Optional timeout how long the relays should be queried for provider announcements. Default: 15 sec
"""
sm = wallet.lnworker.swap_manager
async with sm.create_transport() as transport:
assert isinstance(transport, NostrTransport)
await asyncio.sleep(query_time)
offers = transport.get_recent_offers()
result = {}
for offer in offers:
result[offer.server_npub] = {
"percentage_fee": float(offer.pairs.percentage),
"max_forward_sat": offer.pairs.max_forward,
"max_reverse_sat": offer.pairs.max_reverse,
"min_amount_sat": offer.pairs.min_amount,
"prepayment": 2 * offer.pairs.mining_fee,
}
return result
@command('wnpl')
async def normal_swap(self, onchain_amount, lightning_amount, password=None, wallet: Abstract_Wallet = None):
"""
Normal submarine swap: send on-chain BTC, receive on Lightning
arg:decimal_or_dryrun:lightning_amount:Amount to be received, in BTC. Set it to 'dryrun' to receive a value
arg:decimal_or_dryrun:onchain_amount:Amount to be sent, in BTC. Set it to 'dryrun' to receive a value
"""
sm = wallet.lnworker.swap_manager
assert self.config.SWAPSERVER_NPUB or self.config.SWAPSERVER_URL, \
"Configure swap provider first. See 'get_submarine_swap_providers'."
async with sm.create_transport() as transport:
try:
await asyncio.wait_for(sm.is_initialized.wait(), timeout=15)
except asyncio.TimeoutError:
raise TimeoutError("Could not find configured swap provider. Setup another one. See 'get_submarine_swap_providers'")
if lightning_amount == 'dryrun':
onchain_amount_sat = satoshis(onchain_amount)
lightning_amount_sat = sm.get_recv_amount(onchain_amount_sat, is_reverse=False)
txid = None
elif onchain_amount == 'dryrun':
lightning_amount_sat = satoshis(lightning_amount)
onchain_amount_sat = sm.get_send_amount(lightning_amount_sat, is_reverse=False)
txid = None
else:
lightning_amount_sat = satoshis(lightning_amount)
onchain_amount_sat = satoshis(onchain_amount)
txid = await wallet.lnworker.swap_manager.normal_swap(
transport=transport,
lightning_amount_sat=lightning_amount_sat,
expected_onchain_amount_sat=onchain_amount_sat,
password=password,
)
return {
'txid': txid,
'lightning_amount': format_satoshis(lightning_amount_sat),
'onchain_amount': format_satoshis(onchain_amount_sat),
}
@command('wnpl')
async def reverse_swap(
self, lightning_amount, onchain_amount, prepayment='dryrun', password=None, wallet: Abstract_Wallet = None,
):
"""
Reverse submarine swap: send on Lightning, receive on-chain
arg:decimal_or_dryrun:lightning_amount:Amount to be sent, in BTC. Set it to 'dryrun' to receive a value
arg:decimal_or_dryrun:onchain_amount:Amount to be received, in BTC. Set it to 'dryrun' to receive a value
arg:decimal_or_dryrun:prepayment:Lightning payment required by the swap provider in order to cover their mining fees. This is included in lightning_amount. However, this part of the operation is not trustless; the provider is trusted to fail this payment if the swap fails.
"""
sm = wallet.lnworker.swap_manager
assert self.config.SWAPSERVER_NPUB or self.config.SWAPSERVER_URL, \
"Configure swap provider first. See 'get_submarine_swap_providers'."
async with sm.create_transport() as transport:
try:
await asyncio.wait_for(sm.is_initialized.wait(), timeout=15)
except asyncio.TimeoutError:
raise TimeoutError("Could not find configured swap provider. Setup another one. See 'get_submarine_swap_providers'")
if onchain_amount == 'dryrun':
lightning_amount_sat = satoshis(lightning_amount)
onchain_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True)
assert prepayment == "dryrun", f"Cannot use {prepayment=} in dryrun. Set it to 'dryrun'."
prepayment_sat = 2 * sm.mining_fee
funding_txid = None
elif lightning_amount == 'dryrun':
onchain_amount_sat = satoshis(onchain_amount)
lightning_amount_sat = sm.get_send_amount(onchain_amount_sat, is_reverse=True)
assert prepayment == "dryrun", f"Cannot use {prepayment=} in dryrun. Set it to 'dryrun'."
prepayment_sat = 2 * sm.mining_fee
funding_txid = None
else:
lightning_amount_sat = satoshis(lightning_amount)
claim_fee = sm.get_fee_for_txbatcher()
onchain_amount_sat = satoshis(onchain_amount) + claim_fee
assert prepayment != "dryrun", "Provide the 'prepayment' obtained from the dryrun."
prepayment_sat = satoshis(prepayment)
funding_txid = await wallet.lnworker.swap_manager.reverse_swap(
transport=transport,
lightning_amount_sat=lightning_amount_sat,
expected_onchain_amount_sat=onchain_amount_sat,
prepayment_sat=prepayment_sat,
)
return {
'funding_txid': funding_txid,
'lightning_amount': format_satoshis(lightning_amount_sat),
'onchain_amount': format_satoshis(onchain_amount_sat),
'prepayment': format_satoshis(prepayment_sat)
}
@command('n')
async def convert_currency(self, from_amount=1, from_ccy='', to_ccy=''):
"""
Converts the given amount of currency to another using the
configured exchange rate source.
arg:decimal:from_amount:Amount to convert (default=1)
arg:str:from_ccy:Currency to convert from
arg:str:to_ccy:Currency to convert to
"""
if not self.daemon.fx.is_enabled():
raise UserFacingException("FX is disabled. To enable, run: 'electrum setconfig use_exchange_rate true'")
# Currency codes are uppercase
from_ccy = from_ccy.upper()
to_ccy = to_ccy.upper()
# Default currencies
if from_ccy == '':
from_ccy = "BTC" if to_ccy != "BTC" else self.daemon.fx.ccy
if to_ccy == '':
to_ccy = "BTC" if from_ccy != "BTC" else self.daemon.fx.ccy
# Get current rates
rate_from = self.daemon.fx.exchange.get_cached_spot_quote(from_ccy)
rate_to = self.daemon.fx.exchange.get_cached_spot_quote(to_ccy)
# Test if currencies exist
if rate_from.is_nan():
raise UserFacingException(f'Currency to convert from ({from_ccy}) is unknown or rate is unavailable')
if rate_to.is_nan():
raise UserFacingException(f'Currency to convert to ({to_ccy}) is unknown or rate is unavailable')
# Conversion
try:
from_amount = to_decimal(from_amount)
to_amount = from_amount / rate_from * rate_to
except InvalidOperation:
raise Exception("from_amount is not a number")
return {
"from_amount": self.daemon.fx.ccy_amount_str(from_amount, add_thousands_sep=False, ccy=from_ccy),
"to_amount": self.daemon.fx.ccy_amount_str(to_amount, add_thousands_sep=False, ccy=to_ccy),
"from_ccy": from_ccy,
"to_ccy": to_ccy,
"source": self.daemon.fx.exchange.name(),
}
@command('wnl')
async def send_onion_message(self, node_id_or_blinded_path_hex: str, message: str, wallet: Abstract_Wallet = None):
"""
Send an onion message with onionmsg_tlv.message payload to node_id.
arg:str:node_id_or_blinded_path_hex:node id or blinded path
arg:str:message:Message to send
"""
assert wallet
assert wallet.lnworker
assert node_id_or_blinded_path_hex
assert message
node_id_or_blinded_path = bfh(node_id_or_blinded_path_hex)
assert len(node_id_or_blinded_path) >= 33
destination_payload = {
'message': {'text': message.encode('utf-8')}
}
try:
send_onion_message_to(wallet.lnworker, node_id_or_blinded_path, destination_payload)
return {'success': True}
except Exception as e:
msg = str(e)
return {
'success': False,
'msg': msg
}
@command('wnl')
async def get_blinded_path_via(self, node_id: str, dummy_hops: int = 0, wallet: Abstract_Wallet = None):
"""
Create a blinded path with node_id as introduction point. Introduction point must be direct peer of me.
arg:str:node_id:Node pubkey in hex format
arg:int:dummy_hops:Number of dummy hops to add
"""
# TODO: allow introduction_point to not be a direct peer and construct a route
assert wallet
assert node_id
pubkey = bfh(node_id)
assert len(pubkey) == 33, 'invalid node_id'
peer = wallet.lnworker.lnpeermgr.peers[pubkey]
assert peer, 'node_id not a peer'
path = [pubkey, wallet.lnworker.node_keypair.pubkey]
session_key = os.urandom(32)
blinded_path = create_blinded_path(session_key, path=path, final_recipient_data={}, dummy_hops=dummy_hops)
with io.BytesIO() as blinded_path_fd:
OnionWireSerializer.write_field(
fd=blinded_path_fd,
field_type='blinded_path',
count=1,
value=blinded_path)
encoded_blinded_path = blinded_path_fd.getvalue()
return encoded_blinded_path.hex()
def plugin_command(s, plugin_name):
"""Decorator to register a cli command inside a plugin. To be used within a commands.py file
in the plugins root."""
# atm all plugin commands require a daemon, cannot be run in 'offline' mode:
if 'n' not in s:
s += 'n'
def decorator(func):
assert len(plugin_name) > 0, "Plugin name must not be empty"
func.plugin_name = plugin_name
name = plugin_name + '_' + func.__name__
if name in known_commands or hasattr(Commands, name):
raise Exception(f"Command name {name} already exists. Plugin commands should not overwrite other commands.")
assert inspect.iscoroutinefunction(func), f"Plugin commands must be a coroutine: {name}"
@command(s)
@wraps(func)
async def func_wrapper(*args, **kwargs):
cmd_runner = args[0] # type: Commands
daemon = cmd_runner.daemon
assert daemon is not None
kwargs['plugin'] = daemon._plugins.get_plugin(plugin_name)
return await func(*args, **kwargs)
setattr(Commands, name, func_wrapper)
return func_wrapper
return decorator
def eval_bool(x: str) -> bool:
if x == 'false':
return False
if x == 'true':
return True
# assume python, raise if malformed
return bool(ast.literal_eval(x))
# don't use floats because of rounding errors
json_loads = lambda x: json.loads(x, parse_float=lambda x: str(to_decimal(x)))
def check_txid(txid):
if not is_hash256_str(txid):
raise UserFacingException(f"{repr(txid)} is not a txid")
return txid
arg_types = {
'int': int,
'bool': eval_bool,
'str': str,
'txid': check_txid,
'tx': convert_raw_tx_to_hex,
'json': json_loads,
'decimal': lambda x: str(to_decimal(x)),
'decimal_or_dryrun': lambda x: str(to_decimal(x)) if x != 'dryrun' else x,
'decimal_or_max': lambda x: str(to_decimal(x)) if not parse_max_spend(x) else x,
}
config_variables = {
'addrequest': {
'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', '--version']: # global help/version 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):
group = parser.add_argument_group('network options')
group.add_argument(
"-f", "--serverfingerprint", dest=SimpleConfig.NETWORK_SERVERFINGERPRINT.key(), default=None,
help="only allow connecting to servers with a matching SSL certificate SHA256 fingerprint. " +
"To calculate this yourself: '$ openssl x509 -noout -fingerprint -sha256 -inform pem -in mycertfile.crt'. Enter as 64 hex chars.")
group.add_argument(
"-1", "--oneserver", action="store_true", dest=SimpleConfig.NETWORK_ONESERVER.key(), default=None,
help="connect to one server only")
group.add_argument(
"-s", "--server", dest=SimpleConfig.NETWORK_SERVER.key(), default=None,
help="set server host:port:protocol, where protocol is either t (tcp) or s (ssl)")
group.add_argument(
"-p", "--proxy", dest=SimpleConfig.NETWORK_PROXY.key(), default=None,
help="set proxy [type:]host:port (or 'none' to disable proxy), where type is socks4 or socks5")
group.add_argument(
"--proxyuser", dest=SimpleConfig.NETWORK_PROXY_USER.key(), default=None,
help="set proxy username")
group.add_argument(
"--proxypassword", dest=SimpleConfig.NETWORK_PROXY_PASSWORD.key(), default=None,
help="set proxy password")
group.add_argument(
"--noonion", action="store_true", dest=SimpleConfig.NETWORK_NOONION.key(), default=None,
help="do not try to connect to onion servers")
group.add_argument(
"--skipmerklecheck", action="store_true", dest=SimpleConfig.NETWORK_SKIPMERKLECHECK.key(), default=None,
help="Tolerate invalid merkle proofs from Electrum server")
def add_global_options(parser, suppress=False):
group = parser.add_argument_group('global options')
group.add_argument(
"-v", dest="verbosity", default='',
help=argparse.SUPPRESS if suppress else "Set verbosity (log levels)")
group.add_argument(
"-D", "--dir", dest="electrum_path",
help=argparse.SUPPRESS if suppress else "electrum directory")
group.add_argument(
"-w", "--wallet", dest="wallet_path",
help=argparse.SUPPRESS if suppress else "wallet path")
group.add_argument(
"-P", "--portable", action="store_true", dest="portable", default=False,
help=argparse.SUPPRESS if suppress else "Use local 'electrum_data' directory")
for chain in constants.NETS_LIST:
group.add_argument(
f"--{chain.cli_flag()}", action="store_true", dest=chain.config_key(), default=False,
help=argparse.SUPPRESS if suppress else f"Use {chain.NET_NAME} chain")
group.add_argument(
"-o", "--offline", action="store_true", dest=SimpleConfig.NETWORK_OFFLINE.key(), default=None,
help=argparse.SUPPRESS if suppress else "Run offline")
group.add_argument(
"--rpcuser", dest=SimpleConfig.RPC_USERNAME.key(), default=argparse.SUPPRESS,
help=argparse.SUPPRESS if suppress else "RPC user")
group.add_argument(
"--rpcpassword", dest=SimpleConfig.RPC_PASSWORD.key(), default=argparse.SUPPRESS,
help=argparse.SUPPRESS if suppress else "RPC password")
group.add_argument(
"--forgetconfig", action="store_true", dest=SimpleConfig.CONFIG_FORGET_CHANGES.key(), default=None,
help=argparse.SUPPRESS if suppress else "Forget config on exit")
def get_simple_parser():
""" simple parser that figures out the path of the config file and ignore unknown args """
from optparse import OptionParser, BadOptionError, AmbiguousOptionError
class PassThroughOptionParser(OptionParser):
# see https://stackoverflow.com/questions/1885161/how-can-i-get-optparses-optionparser-to-ignore-invalid-options
def _process_args(self, largs, rargs, values):
while rargs:
try:
OptionParser._process_args(self, largs, rargs, values)
except (BadOptionError, AmbiguousOptionError) as e:
largs.append(e.opt_str)
parser = PassThroughOptionParser()
parser.add_option("-D", "--dir", dest="electrum_path", help="electrum directory")
parser.add_option("-P", "--portable", action="store_true", dest="portable", default=False, help="Use local 'electrum_data' directory")
for chain in constants.NETS_LIST:
parser.add_option(f"--{chain.cli_flag()}", action="store_true", dest=chain.config_key(), default=False, help=f"Use {chain.NET_NAME} chain")
return parser
def get_parser():
# create main parser
parser = argparse.ArgumentParser(
epilog="Run 'electrum help ' to see the help for a command")
parser.add_argument("--version", dest="cmd", action='store_const', const='version', help="Return the version of Electrum.")
add_global_options(parser)
subparsers = parser.add_subparsers(dest='cmd', metavar='')
# 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=SimpleConfig.GUI_NAME.key(), help="select graphical user interface", choices=['qt', 'text', 'stdio', 'qml'])
parser_gui.add_argument("-m", action="store_true", dest=SimpleConfig.GUI_QT_HIDE_ON_STARTUP.key(), default=False, help="hide GUI on startup")
parser_gui.add_argument("-L", "--lang", dest=SimpleConfig.LOCALIZATION_LANGUAGE.key(), default=None, help="default language used in GUI")
parser_gui.add_argument("--daemon", action="store_true", dest="daemon", default=False, help="keep daemon running after GUI is closed")
parser_gui.add_argument("--nosegwit", action="store_true", dest=SimpleConfig.WIZARD_DONT_CREATE_SEGWIT.key(), default=False, help="Do not create segwit wallets")
add_network_options(parser_gui)
add_global_options(parser_gui)
# daemon
parser_daemon = subparsers.add_parser('daemon', help="Run Daemon")
parser_daemon.add_argument("-d", "--detached", action="store_true", dest="detach", default=False, help="run daemon in detached mode")
# FIXME: all these options are rpc-server-side. The CLI client-side cannot use e.g. --rpcport,
# instead it reads it from the daemon lockfile.
parser_daemon.add_argument("--rpchost", dest=SimpleConfig.RPC_HOST.key(), default=argparse.SUPPRESS, help="RPC host")
parser_daemon.add_argument("--rpcport", dest=SimpleConfig.RPC_PORT.key(), type=int, default=argparse.SUPPRESS, help="RPC port")
parser_daemon.add_argument("--rpcsock", dest=SimpleConfig.RPC_SOCKET_TYPE.key(), default=None, help="what socket type to which to bind RPC daemon", choices=['unix', 'tcp', 'auto'])
parser_daemon.add_argument("--rpcsockpath", dest=SimpleConfig.RPC_SOCKET_FILEPATH.key(), help="where to place RPC file socket")
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,
description=cmd.description,
help=cmd.short_description,
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Run 'electrum -h' to see the list of global options",
)
for optname, default in zip(cmd.options, cmd.defaults):
if optname in ['wallet_path', 'wallet', 'plugin']:
continue
if optname == 'password':
p.add_argument("--password", dest='password', help="Wallet password. Use '--password :' if you want a prompt.")
continue
help = cmd.arg_descriptions.get(optname)
if not help:
print(f'undocumented argument {cmdname}::{optname}', file=sys.stderr)
action = "store_true" if default is False else 'store'
if action == 'store':
type_descriptor = cmd.arg_types.get(optname)
_type = arg_types.get(type_descriptor, str)
p.add_argument('--' + optname, dest=optname, action=action, default=default, help=help, type=_type)
else:
p.add_argument('--' + optname, dest=optname, action=action, default=default, help=help)
add_global_options(p, suppress=True)
for param in cmd.params:
if param in ['wallet_path', 'wallet']:
continue
help = cmd.arg_descriptions.get(param)
if not help:
print(f'undocumented argument {cmdname}::{param}', file=sys.stderr)
type_descriptor = cmd.arg_types.get(param)
_type = arg_types.get(type_descriptor)
if help is not None and _type is None:
print(f'unknown type \'{_type}\' for {cmdname}::{param}', file=sys.stderr)
p.add_argument(param, help=help, 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
# note: set_default_subparser modifies sys.argv
parser.set_default_subparser('gui')
return parser
================================================
FILE: electrum/constants.py
================================================
# -*- coding: utf-8 -*-
#
# 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.
import os
import json
from typing import Sequence, Tuple, Mapping, Type, List, Optional
from .lntransport import LNPeerAddr
from .util import inv_dict, all_subclasses, classproperty
from . import bitcoin
def read_json(filename, default=None):
path = os.path.join(os.path.dirname(__file__), filename)
try:
with open(path, 'r') as f:
r = json.loads(f.read())
except Exception:
if default is None:
# Sometimes it's better to hard-fail: the file might be missing
# due to a packaging issue, which might otherwise go unnoticed.
raise
r = default
return r
def create_fallback_node_list(fallback_nodes_dict: dict[str, dict]) -> List[LNPeerAddr]:
"""Take a json dict of fallback nodes like: k:node_id, v:{k:'host', k:'port'} and return LNPeerAddr list"""
fallback_nodes = []
for node_id, address in fallback_nodes_dict.items():
fallback_nodes.append(
LNPeerAddr(host=address['host'], port=int(address['port']), pubkey=bytes.fromhex(node_id)))
return fallback_nodes
GIT_REPO_URL = "https://github.com/spesmilo/electrum"
GIT_REPO_ISSUES_URL = "https://github.com/spesmilo/electrum/issues"
RELEASE_NOTES_URL = "https://raw.githubusercontent.com/spesmilo/electrum/refs/heads/master/RELEASE-NOTES"
BIP39_WALLET_FORMATS = read_json('bip39_wallet_formats.json')
class AbstractNet:
NET_NAME: str
TESTNET: bool
WIF_PREFIX: int
ADDRTYPE_P2PKH: int
ADDRTYPE_P2SH: int
SEGWIT_HRP: str
BOLT11_HRP: str
GENESIS: str
BLOCK_HEIGHT_FIRST_LIGHTNING_CHANNELS: int = 0
BIP44_COIN_TYPE: int
LN_REALM_BYTE: int
DEFAULT_PORTS: Mapping[str, str]
LN_DNS_SEEDS: Sequence[str]
XPRV_HEADERS: Mapping[str, int]
XPRV_HEADERS_INV: Mapping[int, str]
XPUB_HEADERS: Mapping[str, int]
XPUB_HEADERS_INV: Mapping[int, str]
@classmethod
def max_checkpoint(cls) -> int:
return max(0, len(cls.CHECKPOINTS) * 2016 - 1)
@classmethod
def rev_genesis_bytes(cls) -> bytes:
return bytes.fromhex(cls.GENESIS)[::-1]
@classmethod
def set_as_network(cls) -> None:
global net
net = cls
_cached_default_servers = None
@classproperty
def DEFAULT_SERVERS(cls) -> Mapping[str, Mapping[str, str]]:
if cls._cached_default_servers is None:
default_file = {} if cls.TESTNET else None # for mainnet we hard-fail if the file is missing.
cls._cached_default_servers = read_json(os.path.join('chains', cls.NET_NAME, 'servers.json'), default_file)
return cls._cached_default_servers
_cached_fallback_lnnodes = None
@classproperty
def FALLBACK_LN_NODES(cls) -> Sequence[LNPeerAddr]:
if cls._cached_fallback_lnnodes is None:
default_file = {} if cls.TESTNET else None # for mainnet we hard-fail if the file is missing.
d = read_json(os.path.join('chains', cls.NET_NAME, 'fallback_lnnodes.json'), default_file)
cls._cached_fallback_lnnodes = create_fallback_node_list(d)
return cls._cached_fallback_lnnodes
_cached_checkpoints = None
@classproperty
def CHECKPOINTS(cls) -> Sequence[Tuple[str, int]]:
if cls._cached_checkpoints is None:
default_file = [] if cls.TESTNET else None # for mainnet we hard-fail if the file is missing.
cls._cached_checkpoints = read_json(os.path.join('chains', cls.NET_NAME, 'checkpoints.json'), default_file)
return cls._cached_checkpoints
@classmethod
def datadir_subdir(cls) -> Optional[str]:
"""The name of the folder in the filesystem.
None means top-level, used by mainnet.
"""
return cls.NET_NAME
@classmethod
def cli_flag(cls) -> str:
"""as used in e.g. `$ run_electrum --testnet4`"""
return cls.NET_NAME
@classmethod
def config_key(cls) -> str:
"""as used for SimpleConfig.get()"""
return cls.NET_NAME
class BitcoinMainnet(AbstractNet):
NET_NAME = "mainnet"
TESTNET = False
WIF_PREFIX = 0x80
ADDRTYPE_P2PKH = 0
ADDRTYPE_P2SH = 5
SEGWIT_HRP = "bc"
BOLT11_HRP = SEGWIT_HRP
GENESIS = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
DEFAULT_PORTS = {'t': '50001', 's': '50002'}
BLOCK_HEIGHT_FIRST_LIGHTNING_CHANNELS = 497000
XPRV_HEADERS = {
'standard': 0x0488ade4, # xprv
'p2wpkh-p2sh': 0x049d7878, # yprv
'p2wsh-p2sh': 0x0295b005, # Yprv
'p2wpkh': 0x04b2430c, # zprv
'p2wsh': 0x02aa7a99, # Zprv
}
XPRV_HEADERS_INV = inv_dict(XPRV_HEADERS)
XPUB_HEADERS = {
'standard': 0x0488b21e, # xpub
'p2wpkh-p2sh': 0x049d7cb2, # ypub
'p2wsh-p2sh': 0x0295b43f, # Ypub
'p2wpkh': 0x04b24746, # zpub
'p2wsh': 0x02aa7ed3, # Zpub
}
XPUB_HEADERS_INV = inv_dict(XPUB_HEADERS)
BIP44_COIN_TYPE = 0
LN_REALM_BYTE = 0
LN_DNS_SEEDS = [
'nodes.lightning.directory.',
'lseed.bitcoinstats.com.',
'lseed.darosior.ninja',
]
@classmethod
def datadir_subdir(cls):
return None
class BitcoinTestnet(AbstractNet):
NET_NAME = "testnet"
TESTNET = True
WIF_PREFIX = 0xef
ADDRTYPE_P2PKH = 111
ADDRTYPE_P2SH = 196
SEGWIT_HRP = "tb"
BOLT11_HRP = SEGWIT_HRP
GENESIS = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"
DEFAULT_PORTS = {'t': '51001', 's': '51002'}
XPRV_HEADERS = {
'standard': 0x04358394, # tprv
'p2wpkh-p2sh': 0x044a4e28, # uprv
'p2wsh-p2sh': 0x024285b5, # Uprv
'p2wpkh': 0x045f18bc, # vprv
'p2wsh': 0x02575048, # Vprv
}
XPRV_HEADERS_INV = inv_dict(XPRV_HEADERS)
XPUB_HEADERS = {
'standard': 0x043587cf, # tpub
'p2wpkh-p2sh': 0x044a5262, # upub
'p2wsh-p2sh': 0x024289ef, # Upub
'p2wpkh': 0x045f1cf6, # vpub
'p2wsh': 0x02575483, # Vpub
}
XPUB_HEADERS_INV = inv_dict(XPUB_HEADERS)
BIP44_COIN_TYPE = 1
LN_REALM_BYTE = 1
LN_DNS_SEEDS = [ # TODO investigate this again
#'test.nodes.lightning.directory.', # times out.
#'lseed.bitcoinstats.com.', # ignores REALM byte and returns mainnet peers...
]
class BitcoinTestnet4(BitcoinTestnet):
NET_NAME = "testnet4"
GENESIS = "00000000da84f2bafbbc53dee25a72ae507ff4914b867c565be350b0da8bf043"
LN_DNS_SEEDS = []
class BitcoinRegtest(BitcoinTestnet):
NET_NAME = "regtest"
SEGWIT_HRP = "bcrt"
BOLT11_HRP = SEGWIT_HRP
GENESIS = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206"
LN_DNS_SEEDS = []
class BitcoinSimnet(BitcoinTestnet):
NET_NAME = "simnet"
WIF_PREFIX = 0x64
ADDRTYPE_P2PKH = 0x3f
ADDRTYPE_P2SH = 0x7b
SEGWIT_HRP = "sb"
BOLT11_HRP = SEGWIT_HRP
GENESIS = "683e86bd5c6d110d91b94b97137ba6bfe02dbbdb8e3dff722a669b5d69d77af6"
LN_DNS_SEEDS = []
class BitcoinSignet(BitcoinTestnet):
NET_NAME = "signet"
BOLT11_HRP = "tbs"
GENESIS = "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6"
LN_DNS_SEEDS = []
class BitcoinMutinynet(BitcoinTestnet):
NET_NAME = "mutinynet"
BOLT11_HRP = "tbs"
GENESIS = "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6"
LN_DNS_SEEDS = []
NETS_LIST = tuple(all_subclasses(AbstractNet)) # type: Sequence[Type[AbstractNet]]
NETS_LIST = tuple(sorted(NETS_LIST, key=lambda x: x.NET_NAME))
assert len(NETS_LIST) == len(set([chain.NET_NAME for chain in NETS_LIST])), "NET_NAME must be unique for each concrete AbstractNet"
assert len(NETS_LIST) == len(set([chain.datadir_subdir() for chain in NETS_LIST])), "datadir must be unique for each concrete AbstractNet"
assert len(NETS_LIST) == len(set([chain.cli_flag() for chain in NETS_LIST])), "cli_flag must be unique for each concrete AbstractNet"
assert len(NETS_LIST) == len(set([chain.config_key() for chain in NETS_LIST])), "config_key must be unique for each concrete AbstractNet"
# don't import net directly, import the module instead (so that net is singleton)
net = BitcoinMainnet # type: Type[AbstractNet]
================================================
FILE: electrum/contacts.py
================================================
# 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 re
from typing import Optional, Tuple, Dict, Any, TYPE_CHECKING
import asyncio
import dns
from dns.exception import DNSException
from . import bitcoin
from . import dnssec
from .util import read_json_file, write_json_file, to_string, is_valid_email
from .logging import Logger, get_logger
from .util import trigger_callback, get_asyncio_loop
if TYPE_CHECKING:
from .wallet_db import WalletDB
from .simple_config import SimpleConfig
_logger = get_logger(__name__)
class AliasNotFoundException(Exception):
pass
class Contacts(dict, Logger):
def __init__(self, db: 'WalletDB'):
Logger.__init__(self)
self.db = db
d = self.db.get('contacts', {})
try:
self.update(d)
except Exception:
return
# backward compatibility
for k, v in self.items():
_type, n = v
if _type == 'address' and bitcoin.is_address(n):
self.pop(k)
self[n] = ('address', k)
def save(self):
self.db.put('contacts', dict(self))
trigger_callback('contacts_updated')
def import_file(self, path):
data = read_json_file(path)
data = self._validate(data)
self.update(data)
self.save()
def export_file(self, path):
write_json_file(path, self)
def __setitem__(self, key, value):
dict.__setitem__(self, key, value)
self.save()
def pop(self, key):
if key in self.keys():
res = dict.pop(self, key)
self.save()
return res
return None
async def resolve(self, k) -> dict:
if bitcoin.is_address(k):
return {
'address': k,
'type': 'address'
}
for address, (_type, label) in self.items():
if k.casefold() != label.casefold():
continue
if _type in ('address', 'lnaddress'):
return {
'address': address,
'type': 'contact'
}
if openalias := await self.resolve_openalias(k):
return openalias
raise AliasNotFoundException("Invalid Bitcoin address or alias", k)
@classmethod
async def resolve_openalias(cls, url: str) -> Dict[str, Any]:
out = await cls._resolve_openalias(url)
if out:
address, name = out
return {
'address': address,
'name': name,
'type': 'openalias',
}
return {}
def by_name(self, name):
for k in self.keys():
_type, addr = self[k]
if addr.casefold() == name.casefold():
return {
'name': addr,
'type': _type,
'address': k
}
return None
def fetch_openalias(self, config: 'SimpleConfig'):
self.alias_info = None
alias = config.OPENALIAS_ID
if alias:
alias = str(alias)
async def f():
self.alias_info = await self._resolve_openalias(alias)
trigger_callback('alias_received')
asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop())
@classmethod
async def _resolve_openalias(cls, url: str) -> Optional[Tuple[str, str]]:
# support email-style addresses, per the OA standard
url = url.replace('@', '.')
try:
records, validated = await dnssec.query(url, dns.rdatatype.TXT)
except DNSException as e:
_logger.info(f'Error resolving openalias: {repr(e)}')
return None
if not validated: # enforce DNSSEC validation. without it, DNS is completely insecure
_logger.info(f"DNSSEC validation failed for {url=!r}, or maybe dependencies are missing and could not even try.")
return None
prefix = 'btc'
for record in records:
if record.rdtype != dns.rdatatype.TXT:
continue
string = to_string(record.strings[0], 'utf8')
if string.startswith('oa1:' + prefix):
address = cls.find_regex(string, r'recipient_address=([A-Za-z0-9]+)')
name = cls.find_regex(string, r'recipient_name=([^;]+)')
if not name:
name = address
if not address:
continue
return address, name
return None
@staticmethod
def find_regex(haystack, needle):
regex = re.compile(needle)
try:
return regex.search(haystack).groups()[0]
except AttributeError:
return None
def _validate(self, data):
for k, v in list(data.items()):
if k == 'contacts':
return self._validate(v)
if not (bitcoin.is_address(k) or is_valid_email(k)):
data.pop(k)
else:
_type, _ = v
if _type not in ('address', 'lnaddress'):
data.pop(k)
return data
================================================
FILE: electrum/crypto.py
================================================
# -*- coding: utf-8 -*-
#
# 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.
import base64
import binascii
import os
import sys
import hashlib
import hmac
from typing import Union, Mapping, Optional
import electrum_ecc as ecc
from .util import assert_bytes, InvalidPassword, to_bytes, to_string, WalletFileException, versiontuple
from .i18n import _
from .logging import get_logger
_logger = get_logger(__name__)
HAS_PYAES = False
try:
import pyaes
except Exception:
pass
else:
HAS_PYAES = True
HAS_CRYPTODOME = False
MIN_CRYPTODOME_VERSION = "3.7"
try:
import Cryptodome
if versiontuple(Cryptodome.__version__) < versiontuple(MIN_CRYPTODOME_VERSION):
_logger.warning(f"found module 'Cryptodome' but it is too old: {Cryptodome.__version__}<{MIN_CRYPTODOME_VERSION}")
raise Exception()
from Cryptodome.Cipher import ChaCha20_Poly1305 as CD_ChaCha20_Poly1305
from Cryptodome.Cipher import ChaCha20 as CD_ChaCha20
from Cryptodome.Cipher import AES as CD_AES
except Exception:
pass
else:
HAS_CRYPTODOME = True
HAS_CRYPTOGRAPHY = False
MIN_CRYPTOGRAPHY_VERSION = "2.1"
try:
import cryptography
if versiontuple(cryptography.__version__) < versiontuple(MIN_CRYPTOGRAPHY_VERSION):
_logger.warning(f"found module 'cryptography' but it is too old: {cryptography.__version__}<{MIN_CRYPTOGRAPHY_VERSION}")
raise Exception()
from cryptography import exceptions
from cryptography.hazmat.primitives.ciphers import Cipher as CG_Cipher
from cryptography.hazmat.primitives.ciphers import algorithms as CG_algorithms
from cryptography.hazmat.primitives.ciphers import modes as CG_modes
from cryptography.hazmat.backends import default_backend as CG_default_backend
import cryptography.hazmat.primitives.ciphers.aead as CG_aead
except Exception:
pass
else:
HAS_CRYPTOGRAPHY = True
if not (HAS_CRYPTODOME or HAS_CRYPTOGRAPHY):
sys.exit(f"Error: at least one of ('pycryptodomex', 'cryptography') needs to be installed.")
def version_info() -> Mapping[str, Optional[str]]:
ret = {}
if HAS_PYAES:
ret["pyaes.version"] = ".".join(map(str, pyaes.VERSION[:3]))
else:
ret["pyaes.version"] = None
if HAS_CRYPTODOME:
ret["cryptodome.version"] = Cryptodome.__version__
if hasattr(Cryptodome, "__path__"):
ret["cryptodome.path"] = ", ".join(Cryptodome.__path__ or [])
else:
ret["cryptodome.version"] = None
if HAS_CRYPTOGRAPHY:
ret["cryptography.version"] = cryptography.__version__
if hasattr(cryptography, "__path__"):
ret["cryptography.path"] = ", ".join(cryptography.__path__ or [])
else:
ret["cryptography.version"] = None
return ret
class InvalidPadding(Exception):
pass
class CiphertextFormatError(Exception):
pass
def append_PKCS7_padding(data: bytes) -> bytes:
assert_bytes(data)
padlen = 16 - (len(data) % 16)
return data + bytes([padlen]) * padlen
def strip_PKCS7_padding(data: bytes) -> bytes:
assert_bytes(data)
if len(data) % 16 != 0 or len(data) == 0:
raise InvalidPadding("invalid length")
padlen = data[-1]
if not (0 < padlen <= 16):
raise InvalidPadding("invalid padding byte (out of range)")
for i in data[-padlen:]:
if i != padlen:
raise InvalidPadding("invalid padding byte (inconsistent)")
return data[0:-padlen]
def aes_encrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes:
assert_bytes(key, iv, data)
data = append_PKCS7_padding(data)
if HAS_CRYPTODOME:
e = CD_AES.new(key, CD_AES.MODE_CBC, iv).encrypt(data)
elif HAS_CRYPTOGRAPHY:
cipher = CG_Cipher(CG_algorithms.AES(key), CG_modes.CBC(iv), backend=CG_default_backend())
encryptor = cipher.encryptor()
e = encryptor.update(data) + encryptor.finalize()
elif HAS_PYAES:
aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv)
aes = pyaes.Encrypter(aes_cbc, padding=pyaes.PADDING_NONE)
e = aes.feed(data) + aes.feed() # empty aes.feed() flushes buffer
else:
raise Exception("no AES backend found")
return e
def aes_decrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes:
assert_bytes(key, iv, data)
if HAS_CRYPTODOME:
cipher = CD_AES.new(key, CD_AES.MODE_CBC, iv)
data = cipher.decrypt(data)
elif HAS_CRYPTOGRAPHY:
cipher = CG_Cipher(CG_algorithms.AES(key), CG_modes.CBC(iv), backend=CG_default_backend())
decryptor = cipher.decryptor()
data = decryptor.update(data) + decryptor.finalize()
elif HAS_PYAES:
aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv)
aes = pyaes.Decrypter(aes_cbc, padding=pyaes.PADDING_NONE)
data = aes.feed(data) + aes.feed() # empty aes.feed() flushes buffer
else:
raise Exception("no AES backend found")
try:
return strip_PKCS7_padding(data)
except InvalidPadding:
raise InvalidPassword()
def EncodeAES_bytes(secret: bytes, msg: bytes) -> bytes:
assert_bytes(msg)
iv = bytes(os.urandom(16))
ct = aes_encrypt_with_iv(secret, iv, msg)
return iv + ct
def DecodeAES_bytes(secret: bytes, ciphertext: bytes) -> bytes:
assert_bytes(ciphertext)
iv, e = ciphertext[:16], ciphertext[16:]
s = aes_decrypt_with_iv(secret, iv, e)
return s
PW_HASH_VERSION_LATEST = 1
KNOWN_PW_HASH_VERSIONS = (1, 2,)
SUPPORTED_PW_HASH_VERSIONS = (1,)
assert PW_HASH_VERSION_LATEST in KNOWN_PW_HASH_VERSIONS
assert PW_HASH_VERSION_LATEST in SUPPORTED_PW_HASH_VERSIONS
class UnexpectedPasswordHashVersion(InvalidPassword, WalletFileException):
def __init__(self, version):
InvalidPassword.__init__(self)
WalletFileException.__init__(self)
self.version = version
def __str__(self):
return "{unexpected}: {version}\n{instruction}".format(
unexpected=_("Unexpected password hash version"),
version=self.version,
instruction=_('You are most likely using an outdated version of Electrum. Please update.'))
class UnsupportedPasswordHashVersion(InvalidPassword, WalletFileException):
def __init__(self, version):
InvalidPassword.__init__(self)
WalletFileException.__init__(self)
self.version = version
def __str__(self):
return "{unsupported}: {version}\n{instruction}".format(
unsupported=_("Unsupported password hash version"),
version=self.version,
instruction=f"To open this wallet, try 'git checkout password_v{self.version}'.\n"
"Alternatively, restore from seed.")
def _hash_password(password: Union[bytes, str], *, version: int) -> bytes:
pw = to_bytes(password, 'utf8')
if version not in SUPPORTED_PW_HASH_VERSIONS:
raise UnsupportedPasswordHashVersion(version)
if version == 1:
return sha256d(pw)
else:
assert version not in KNOWN_PW_HASH_VERSIONS
raise UnexpectedPasswordHashVersion(version)
def _pw_encode_raw(data: bytes, password: Union[bytes, str], *, version: int) -> bytes:
if version not in KNOWN_PW_HASH_VERSIONS:
raise UnexpectedPasswordHashVersion(version)
# derive key from password
secret = _hash_password(password, version=version)
# encrypt given data
ciphertext = EncodeAES_bytes(secret, data)
return ciphertext
def _pw_decode_raw(data_bytes: bytes, password: Union[bytes, str], *, version: int) -> bytes:
if version not in KNOWN_PW_HASH_VERSIONS:
raise UnexpectedPasswordHashVersion(version)
# derive key from password
secret = _hash_password(password, version=version)
# decrypt given data
try:
d = DecodeAES_bytes(secret, data_bytes)
except Exception as e:
raise InvalidPassword() from e
return d
def pw_encode_bytes(data: bytes, password: Union[bytes, str], *, version: int) -> str:
"""plaintext bytes -> base64 ciphertext"""
ciphertext = _pw_encode_raw(data, password, version=version)
ciphertext_b64 = base64.b64encode(ciphertext)
return ciphertext_b64.decode('utf8')
def pw_decode_bytes(data: str, password: Union[bytes, str], *, version:int) -> bytes:
"""base64 ciphertext -> plaintext bytes"""
if version not in KNOWN_PW_HASH_VERSIONS:
raise UnexpectedPasswordHashVersion(version)
try:
data_bytes = bytes(base64.b64decode(data, validate=True))
except binascii.Error as e:
raise CiphertextFormatError("ciphertext not valid base64") from e
return _pw_decode_raw(data_bytes, password, version=version)
def pw_encode_with_version_and_mac(data: bytes, password: Union[bytes, str]) -> str:
"""plaintext bytes -> base64 ciphertext"""
# https://crypto.stackexchange.com/questions/202/should-we-mac-then-encrypt-or-encrypt-then-mac
# Encrypt-and-MAC. The MAC will be used to detect invalid passwords
version = PW_HASH_VERSION_LATEST
mac = sha256(data)[0:4]
ciphertext = _pw_encode_raw(data, password, version=version)
ciphertext_b64 = base64.b64encode(bytes([version]) + ciphertext + mac)
return ciphertext_b64.decode('utf8')
def pw_decode_with_version_and_mac(data: str, password: Union[bytes, str]) -> bytes:
"""base64 ciphertext -> plaintext bytes"""
try:
data_bytes = bytes(base64.b64decode(data, validate=True))
except binascii.Error as e:
raise CiphertextFormatError("ciphertext not valid base64") from e
version = int(data_bytes[0])
encrypted = data_bytes[1:-4]
mac = data_bytes[-4:]
if version not in KNOWN_PW_HASH_VERSIONS:
raise UnexpectedPasswordHashVersion(version)
decrypted = _pw_decode_raw(encrypted, password, version=version)
if sha256(decrypted)[0:4] != mac:
raise InvalidPassword()
return decrypted
def pw_encode(data: str, password: Union[bytes, str, None], *, version: int) -> str:
"""plaintext str -> base64 ciphertext"""
if not password:
return data
plaintext_bytes = to_bytes(data, "utf8")
return pw_encode_bytes(plaintext_bytes, password, version=version)
def pw_decode(data: str, password: Union[bytes, str, None], *, version: int) -> str:
"""base64 ciphertext -> plaintext str"""
if password is None:
return data
plaintext_bytes = pw_decode_bytes(data, password, version=version)
try:
plaintext_str = to_string(plaintext_bytes, "utf8")
except UnicodeDecodeError as e:
raise InvalidPassword() from e
return plaintext_str
def sha256(x: Union[bytes, str]) -> bytes:
x = to_bytes(x, 'utf8')
return bytes(hashlib.sha256(x).digest())
def sha256d(x: Union[bytes, str]) -> bytes:
x = to_bytes(x, 'utf8')
out = bytes(sha256(sha256(x)))
return out
def hash_160(x: bytes) -> bytes:
return ripemd(sha256(x))
def ripemd(x: bytes) -> bytes:
try:
md = hashlib.new('ripemd160')
md.update(x)
return md.digest()
except BaseException:
# ripemd160 is not guaranteed to be available in hashlib on all platforms.
# Historically, our Android builds had hashlib/openssl which did not have it.
# see https://github.com/spesmilo/electrum/issues/7093
# We bundle a pure python implementation as fallback that gets used now:
from . import ripemd
md = ripemd.new(x)
return md.digest()
def hmac_oneshot(key: bytes, msg: bytes, digest) -> bytes:
return hmac.digest(key, msg, digest)
def chacha20_poly1305_encrypt(
*,
key: bytes,
nonce: bytes,
associated_data: bytes = None,
data: bytes
) -> bytes:
assert isinstance(key, (bytes, bytearray))
assert isinstance(nonce, (bytes, bytearray))
assert isinstance(associated_data, (bytes, bytearray, type(None)))
assert isinstance(data, (bytes, bytearray))
assert len(key) == 32, f"unexpected key size: {len(key)} (expected: 32)"
assert len(nonce) == 12, f"unexpected nonce size: {len(nonce)} (expected: 12)"
if HAS_CRYPTODOME:
cipher = CD_ChaCha20_Poly1305.new(key=key, nonce=nonce)
if associated_data is not None:
cipher.update(associated_data)
ciphertext, mac = cipher.encrypt_and_digest(plaintext=data)
return ciphertext + mac
if HAS_CRYPTOGRAPHY:
a = CG_aead.ChaCha20Poly1305(key)
return a.encrypt(nonce, data, associated_data)
raise Exception("no chacha20 backend found")
def chacha20_poly1305_decrypt(
*,
key: bytes,
nonce: bytes,
associated_data: bytes = None,
data: bytes
) -> bytes:
assert isinstance(key, (bytes, bytearray))
assert isinstance(nonce, (bytes, bytearray))
assert isinstance(associated_data, (bytes, bytearray, type(None)))
assert isinstance(data, (bytes, bytearray))
assert len(key) == 32, f"unexpected key size: {len(key)} (expected: 32)"
assert len(nonce) == 12, f"unexpected nonce size: {len(nonce)} (expected: 12)"
if HAS_CRYPTODOME:
cipher = CD_ChaCha20_Poly1305.new(key=key, nonce=nonce)
if associated_data is not None:
cipher.update(associated_data)
# raises ValueError if not valid (e.g. incorrect MAC)
return cipher.decrypt_and_verify(ciphertext=data[:-16], received_mac_tag=data[-16:])
if HAS_CRYPTOGRAPHY:
a = CG_aead.ChaCha20Poly1305(key)
try:
return a.decrypt(nonce, data, associated_data)
except cryptography.exceptions.InvalidTag as e:
raise ValueError("invalid tag") from e
raise Exception("no chacha20 backend found")
def chacha20_encrypt(*, key: bytes, nonce: bytes, data: bytes) -> bytes:
"""note: for any new protocol you design, please consider using chacha20_poly1305_encrypt instead
(for its Authenticated Encryption property).
"""
assert isinstance(key, (bytes, bytearray))
assert isinstance(nonce, (bytes, bytearray))
assert isinstance(data, (bytes, bytearray))
assert len(key) == 32, f"unexpected key size: {len(key)} (expected: 32)"
assert len(nonce) in (8, 12), f"unexpected nonce size: {len(nonce)} (expected: 8 or 12)"
if HAS_CRYPTODOME:
cipher = CD_ChaCha20.new(key=key, nonce=nonce)
return cipher.encrypt(data)
if HAS_CRYPTOGRAPHY:
nonce = bytes(16 - len(nonce)) + nonce # cryptography wants 16 byte nonces
algo = CG_algorithms.ChaCha20(key=key, nonce=nonce)
cipher = CG_Cipher(algo, mode=None, backend=CG_default_backend())
encryptor = cipher.encryptor()
return encryptor.update(data)
raise Exception("no chacha20 backend found")
def chacha20_decrypt(*, key: bytes, nonce: bytes, data: bytes) -> bytes:
assert isinstance(key, (bytes, bytearray))
assert isinstance(nonce, (bytes, bytearray))
assert isinstance(data, (bytes, bytearray))
assert len(key) == 32, f"unexpected key size: {len(key)} (expected: 32)"
assert len(nonce) in (8, 12), f"unexpected nonce size: {len(nonce)} (expected: 8 or 12)"
if HAS_CRYPTODOME:
cipher = CD_ChaCha20.new(key=key, nonce=nonce)
return cipher.decrypt(data)
if HAS_CRYPTOGRAPHY:
nonce = bytes(16 - len(nonce)) + nonce # cryptography wants 16 byte nonces
algo = CG_algorithms.ChaCha20(key=key, nonce=nonce)
cipher = CG_Cipher(algo, mode=None, backend=CG_default_backend())
decryptor = cipher.decryptor()
return decryptor.update(data)
raise Exception("no chacha20 backend found")
def ecies_encrypt_message(
ec_pubkey: 'ecc.ECPubkey',
message: bytes,
*,
magic: bytes = b'BIE1',
) -> bytes:
"""
ECIES encryption/decryption methods; AES-128-CBC with PKCS7 is used as the cipher; hmac-sha256 is used as the mac
"""
assert_bytes(message)
ephemeral = ecc.ECPrivkey.generate_random_key()
ecdh_key = (ec_pubkey * ephemeral.secret_scalar).get_public_key_bytes(compressed=True)
key = hashlib.sha512(ecdh_key).digest()
iv, key_e, key_m = key[0:16], key[16:32], key[32:]
ciphertext = aes_encrypt_with_iv(key_e, iv, message)
ephemeral_pubkey = ephemeral.get_public_key_bytes(compressed=True)
encrypted = magic + ephemeral_pubkey + ciphertext
mac = hmac_oneshot(key_m, encrypted, hashlib.sha256)
return base64.b64encode(encrypted + mac)
def ecies_decrypt_message(
ec_privkey: 'ecc.ECPrivkey',
encrypted: Union[str, bytes],
*,
magic: bytes = b'BIE1',
) -> bytes:
encrypted = base64.b64decode(encrypted, validate=True) # type: bytes
if len(encrypted) < 85:
raise Exception('invalid ciphertext: length')
magic_found = encrypted[:4]
ephemeral_pubkey_bytes = encrypted[4:37]
ciphertext = encrypted[37:-32]
mac = encrypted[-32:]
if magic_found != magic:
raise Exception('invalid ciphertext: invalid magic bytes')
try:
ephemeral_pubkey = ecc.ECPubkey(ephemeral_pubkey_bytes)
except ecc.InvalidECPointException as e:
raise Exception('invalid ciphertext: invalid ephemeral pubkey') from e
ecdh_key = (ephemeral_pubkey * ec_privkey.secret_scalar).get_public_key_bytes(compressed=True)
key = hashlib.sha512(ecdh_key).digest()
iv, key_e, key_m = key[0:16], key[16:32], key[32:]
if mac != hmac_oneshot(key_m, encrypted[:-32], hashlib.sha256):
raise InvalidPassword()
return aes_decrypt_with_iv(key_e, iv, ciphertext)
def get_ecdh(priv: bytes, pub: bytes) -> bytes:
pt = ecc.ECPubkey(pub) * ecc.string_to_number(priv)
return sha256(pt.get_public_key_bytes())
def privkey_to_pubkey(priv: bytes) -> bytes:
return ecc.ECPrivkey(priv[:32]).get_public_key_bytes()
================================================
FILE: electrum/currencies.json
================================================
{
"Bit2C": [
"ILS"
],
"BitFinex": [
"EUR",
"GBP",
"JPY",
"TRY",
"USD",
"UST"
],
"BitFlyer": [
"JPY"
],
"BitPay": [
"AED",
"AFN",
"ALL",
"AMD",
"ANG",
"AOA",
"APE",
"ARS",
"AUD",
"AWG",
"AZN",
"BAM",
"BBD",
"BCH",
"BDT",
"BGN",
"BHD",
"BIF",
"BMD",
"BND",
"BOB",
"BRL",
"BSD",
"BTC",
"BTN",
"BWP",
"BYN",
"BZD",
"CAD",
"CDF",
"CHF",
"CLF",
"CLP",
"CNY",
"COP",
"CRC",
"CUP",
"CVE",
"CZK",
"DAI",
"DJF",
"DKK",
"DOP",
"DZD",
"EGP",
"ETB",
"ETH",
"EUR",
"FJD",
"FKP",
"GBP",
"GEL",
"GHS",
"GIP",
"GMD",
"GNF",
"GTQ",
"GYD",
"HKD",
"HNL",
"HRK",
"HTG",
"HUF",
"IDR",
"ILS",
"INR",
"IQD",
"IRR",
"ISK",
"JEP",
"JMD",
"JOD",
"JPY",
"KES",
"KGS",
"KHR",
"KMF",
"KPW",
"KRW",
"KWD",
"KYD",
"KZT",
"LAK",
"LBP",
"LKR",
"LRD",
"LSL",
"LTC",
"LYD",
"MAD",
"MDL",
"MGA",
"MKD",
"MMK",
"MNT",
"MOP",
"MRU",
"MUR",
"MVR",
"MWK",
"MXN",
"MYR",
"MZN",
"NAD",
"NGN",
"NIO",
"NOK",
"NPR",
"NZD",
"OMR",
"PAB",
"PAX",
"PEN",
"PGK",
"PHP",
"PKR",
"PLN",
"PYG",
"QAR",
"RON",
"RSD",
"RUB",
"RWF",
"SAR",
"SBD",
"SCR",
"SDG",
"SEK",
"SGD",
"SHP",
"SLL",
"SOS",
"SRD",
"STN",
"SVC",
"SYP",
"SZL",
"THB",
"TJS",
"TMT",
"TND",
"TOP",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"USD",
"UYU",
"UZS",
"VES",
"VND",
"VUV",
"WST",
"XAF",
"XAG",
"XAU",
"XCD",
"XOF",
"XPF",
"XRP",
"YER",
"ZAR",
"ZMW",
"ZWL"
],
"BitStamp": [
"USD",
"EUR",
"GBP"
],
"Bitbank": [
"JPY"
],
"Bitso": [
"MXN"
],
"Bitvalor": [
"BRL"
],
"BlockchainInfo": [
"ARS",
"AUD",
"BRL",
"CAD",
"CHF",
"CLP",
"CNY",
"CZK",
"DKK",
"EUR",
"GBP",
"HKD",
"HRK",
"HUF",
"INR",
"ISK",
"JPY",
"KRW",
"NZD",
"PLN",
"RON",
"RUB",
"SEK",
"SGD",
"THB",
"TRY",
"TWD",
"USD"
],
"Bylls": [
"CAD"
],
"CoinCap": [
"USD"
],
"CoinDesk": [
"AED",
"AFN",
"ALL",
"AMD",
"ANG",
"AOA",
"ARS",
"AUD",
"AWG",
"AZN",
"BAM",
"BBD",
"BDT",
"BGN",
"BHD",
"BIF",
"BMD",
"BND",
"BOB",
"BRL",
"BSD",
"BTC",
"BTN",
"BWP",
"BYR",
"BZD",
"CAD",
"CDF",
"CHF",
"CLF",
"CLP",
"CNY",
"COP",
"CRC",
"CUC",
"CUP",
"CVE",
"CZK",
"DJF",
"DKK",
"DOP",
"DZD",
"EGP",
"ERN",
"ETB",
"EUR",
"FJD",
"FKP",
"GBP",
"GEL",
"GGP",
"GHS",
"GIP",
"GMD",
"GNF",
"GTQ",
"GYD",
"HKD",
"HNL",
"HRK",
"HTG",
"HUF",
"IDR",
"ILS",
"IMP",
"INR",
"IQD",
"IRR",
"ISK",
"JEP",
"JMD",
"JOD",
"JPY",
"KES",
"KGS",
"KHR",
"KMF",
"KPW",
"KRW",
"KWD",
"KYD",
"KZT",
"LAK",
"LBP",
"LKR",
"LRD",
"LSL",
"LYD",
"MAD",
"MDL",
"MGA",
"MKD",
"MMK",
"MNT",
"MOP",
"MRU",
"MUR",
"MVR",
"MWK",
"MXN",
"MYR",
"MZN",
"NAD",
"NGN",
"NIO",
"NOK",
"NPR",
"NZD",
"OMR",
"PAB",
"PEN",
"PGK",
"PHP",
"PKR",
"PLN",
"PYG",
"QAR",
"RON",
"RSD",
"RUB",
"RWF",
"SAR",
"SBD",
"SCR",
"SDG",
"SEK",
"SGD",
"SHP",
"SLL",
"SOS",
"SRD",
"STD",
"STN",
"SVC",
"SYP",
"SZL",
"THB",
"TJS",
"TMT",
"TND",
"TOP",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"USD",
"UYU",
"UZS",
"VES",
"VND",
"VUV",
"WST",
"XAF",
"XAG",
"XAU",
"XBT",
"XCD",
"XDR",
"XOF",
"XPF",
"YER",
"ZAR",
"ZMW",
"ZWL"
],
"CoinGecko": [
"AED",
"ARS",
"AUD",
"BCH",
"BDT",
"BHD",
"BMD",
"BNB",
"BRL",
"BTC",
"CAD",
"CHF",
"CLP",
"CNY",
"CZK",
"DKK",
"DOT",
"EOS",
"ETH",
"EUR",
"GBP",
"GEL",
"HKD",
"HUF",
"IDR",
"ILS",
"INR",
"JPY",
"KRW",
"KWD",
"LKR",
"LTC",
"MMK",
"MXN",
"MYR",
"NGN",
"NOK",
"NZD",
"PHP",
"PKR",
"PLN",
"RUB",
"SAR",
"SEK",
"SGD",
"THB",
"TRY",
"TWD",
"UAH",
"USD",
"VEF",
"VND",
"XAG",
"XAU",
"XDR",
"XLM",
"XRP",
"YFI",
"ZAR"
],
"Coinbase": [
"ABT",
"ACH",
"ACS",
"ACX",
"ADA",
"AED",
"AFN",
"AKT",
"ALL",
"AMD",
"AMP",
"ANG",
"ANT",
"AOA",
"APE",
"APT",
"ARB",
"ARS",
"ASM",
"AST",
"ATA",
"AUD",
"AVT",
"AWG",
"AXL",
"AXS",
"AZN",
"BAL",
"BAM",
"BAT",
"BBD",
"BCH",
"BDT",
"BGN",
"BHD",
"BIF",
"BIT",
"BLZ",
"BMD",
"BND",
"BNT",
"BOB",
"BRL",
"BSD",
"BSV",
"BTC",
"BTN",
"BWP",
"BYN",
"BYR",
"BZD",
"C98",
"CAD",
"CDF",
"CHF",
"CHZ",
"CLF",
"CLP",
"CLV",
"CNH",
"CNY",
"COP",
"COW",
"CRC",
"CRO",
"CRV",
"CTX",
"CUC",
"CUP",
"CVC",
"CVE",
"CVX",
"CZK",
"DAI",
"DAR",
"DDX",
"DIA",
"DJF",
"DKK",
"DNT",
"DOP",
"DOT",
"DYP",
"DZD",
"EEK",
"EGP",
"ELA",
"ENJ",
"ENS",
"EOS",
"ERN",
"ETB",
"ETC",
"ETH",
"EUR",
"FET",
"FIL",
"FIS",
"FJD",
"FKP",
"FLR",
"FOX",
"FTM",
"GAL",
"GBP",
"GEL",
"GFI",
"GGP",
"GHS",
"GIP",
"GLM",
"GMD",
"GMT",
"GNF",
"GNO",
"GNT",
"GRT",
"GST",
"GTC",
"GTQ",
"GYD",
"HFT",
"HKD",
"HNL",
"HNT",
"HRK",
"HTG",
"HUF",
"ICP",
"IDR",
"ILS",
"ILV",
"IMP",
"IMX",
"INJ",
"INR",
"INV",
"IQD",
"IRR",
"ISK",
"JEP",
"JMD",
"JOD",
"JPY",
"JTO",
"JUP",
"KES",
"KGS",
"KHR",
"KMF",
"KNC",
"KPW",
"KRL",
"KRW",
"KSM",
"KWD",
"KYD",
"KZT",
"LAK",
"LBP",
"LCX",
"LDO",
"LIT",
"LKR",
"LPT",
"LRC",
"LRD",
"LSL",
"LTC",
"LTL",
"LVL",
"LYD",
"MAD",
"MDL",
"MDT",
"MGA",
"MIR",
"MKD",
"MKR",
"MLN",
"MMK",
"MNT",
"MOP",
"MPL",
"MRO",
"MRU",
"MTL",
"MUR",
"MVR",
"MWK",
"MXC",
"MXN",
"MYR",
"MZN",
"NAD",
"NCT",
"NGN",
"NIO",
"NKN",
"NMR",
"NOK",
"NPR",
"NZD",
"OGN",
"OMG",
"OMR",
"ORN",
"OXT",
"PAB",
"PAX",
"PEN",
"PGK",
"PHP",
"PKR",
"PLA",
"PLN",
"PLU",
"PNG",
"POL",
"PRO",
"PRQ",
"PYG",
"PYR",
"QAR",
"QNT",
"RAD",
"RAI",
"RBN",
"REN",
"REP",
"REQ",
"RGT",
"RLC",
"RLY",
"RON",
"RPL",
"RSD",
"RUB",
"RWF",
"SAR",
"SBD",
"SCR",
"SDG",
"SEI",
"SEK",
"SGD",
"SHP",
"SKK",
"SKL",
"SLL",
"SNT",
"SNX",
"SOL",
"SOS",
"SPA",
"SRD",
"SSP",
"STD",
"STG",
"STX",
"SUI",
"SVC",
"SYN",
"SYP",
"SZL",
"THB",
"TIA",
"TJS",
"TMM",
"TMT",
"TND",
"TOP",
"TRB",
"TRU",
"TRY",
"TTD",
"TVK",
"TWD",
"TZS",
"UAH",
"UGX",
"UMA",
"UNI",
"UPI",
"USD",
"UST",
"UYU",
"UZS",
"VEF",
"VES",
"VET",
"VGX",
"VND",
"VUV",
"WST",
"XAF",
"XAG",
"XAU",
"XBC",
"XCD",
"XCN",
"XDR",
"XLM",
"XOF",
"XPD",
"XPF",
"XPT",
"XRP",
"XTZ",
"XYO",
"YER",
"YFI",
"ZAR",
"ZEC",
"ZEN",
"ZMK",
"ZMW",
"ZRX",
"ZWD"
],
"CointraderMonitor": [
"BRL"
],
"Kraken": [
"CAD",
"EUR",
"GBP",
"JPY",
"USD"
],
"Walltime": [
"BRL"
],
"Yadio": [
"AED",
"ALL",
"ANG",
"AOA",
"ARS",
"AUD",
"AZN",
"BDT",
"BGN",
"BHD",
"BIF",
"BMD",
"BOB",
"BRL",
"BTC",
"BWP",
"BYN",
"BZD",
"CAD",
"CDF",
"CHF",
"CLP",
"CNY",
"COP",
"CRC",
"CUP",
"CZK",
"DJF",
"DKK",
"DOP",
"DZD",
"EGP",
"ETB",
"EUR",
"GBP",
"GEL",
"GHS",
"GNF",
"GTQ",
"HKD",
"HNL",
"HUF",
"IDR",
"ILS",
"INR",
"IRR",
"IRT",
"ISK",
"JMD",
"JOD",
"JPY",
"KES",
"KGS",
"KRW",
"KZT",
"LBP",
"LKR",
"MAD",
"MGA",
"MLC",
"MRU",
"MWK",
"MXN",
"MYR",
"NAD",
"NGN",
"NIO",
"NOK",
"NPR",
"NZD",
"PAB",
"PEN",
"PHP",
"PKR",
"PLN",
"PYG",
"QAR",
"RON",
"RSD",
"RUB",
"RWF",
"SAR",
"SEK",
"SGD",
"SYP",
"THB",
"TND",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"USD",
"UYU",
"UZS",
"VES",
"VND",
"XAF",
"XAG",
"XAU",
"XOF",
"XPT",
"ZAR",
"ZMW"
],
"Zaif": [
"JPY"
]
}
================================================
FILE: electrum/daemon.py
================================================
#!/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 asyncio
import ast
import errno
import os
import time
import traceback
import sys
import threading
from typing import Dict, Optional, Tuple, Callable, Union, Sequence, Mapping, TYPE_CHECKING
from base64 import b64decode, b64encode
import json
import socket
import aiohttp
from aiohttp import web, client_exceptions
from aiorpcx import ignore_after
from . import util
from .network import Network
from .util import (
json_decode, to_bytes, to_string, profiler, standardize_path, constant_time_compare, InvalidPassword,
log_exceptions, randrange, OldTaskGroup, UserFacingException, JsonRPCError
)
from .wallet import Wallet, Abstract_Wallet
from .storage import WalletStorage
from .wallet_db import WalletDB, WalletUnfinished
from .commands import known_commands, Commands
from .simple_config import SimpleConfig
from .exchange_rate import FxThread
from .logging import get_logger, Logger
from . import GuiImportError
from .plugin import run_hook, Plugins
if TYPE_CHECKING:
from electrum import gui
_logger = get_logger(__name__)
class DaemonNotRunning(Exception):
pass
def get_rpcsock_defaultpath(config: SimpleConfig):
return os.path.join(config.path, 'daemon_rpc_socket')
def get_rpcsock_default_type(config: SimpleConfig):
if config.RPC_PORT:
return 'tcp'
# Use unix domain sockets when available,
# with the extra paranoia that in case windows "implements" them,
# we want to test it before making it the default there.
if hasattr(socket, 'AF_UNIX') and sys.platform != 'win32':
return 'unix'
return 'tcp'
def get_lockfile(config: SimpleConfig):
return os.path.join(config.path, 'daemon')
def remove_lockfile(lockfile):
os.unlink(lockfile)
def get_file_descriptor(config: SimpleConfig):
'''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)
except OSError:
pass
try:
request(config, 'ping')
return None
except DaemonNotRunning:
# Couldn't connect; remove lockfile and try again.
remove_lockfile(lockfile)
def request(config: SimpleConfig, endpoint, args=(), timeout: Union[float, int] = 60):
lockfile = get_lockfile(config)
for attempt in range(5):
create_time = None # type: Optional[float | int]
path = None
try:
with open(lockfile) as f:
socktype, address, create_time = ast.literal_eval(f.read())
int(create_time) # raise if not numeric
if socktype == 'unix':
path = address
(host, port) = "127.0.0.1", 0
# We still need a host and port for e.g. HTTP Host header
elif socktype == 'tcp':
(host, port) = address
else:
raise Exception(f"corrupt lockfile; socktype={socktype!r}")
except Exception:
raise DaemonNotRunning()
rpc_user, rpc_password = get_rpc_credentials(config)
server_url = 'http://%s:%d' % (host, port)
auth = aiohttp.BasicAuth(login=rpc_user, password=rpc_password)
loop = util.get_asyncio_loop()
async def request_coroutine(
*, socktype=socktype, path=path, auth=auth, server_url=server_url, endpoint=endpoint,
):
if socktype == 'unix':
connector = aiohttp.UnixConnector(path=path)
elif socktype == 'tcp':
connector = None # This will transform into TCP.
else:
raise Exception(f"impossible socktype ({socktype!r})")
async with aiohttp.ClientSession(auth=auth, connector=connector) as session:
c = util.JsonRPCClient(session, server_url)
return await c.request(endpoint, *args)
try:
fut = asyncio.run_coroutine_threadsafe(request_coroutine(), loop)
return fut.result(timeout=timeout)
except aiohttp.client_exceptions.ClientConnectorError as e:
_logger.info(f"failed to connect to JSON-RPC server {e}")
# We cannot communicate with the daemon.
# If daemon's creation time is very recent, it might still be starting up.
# In any other case, we raise: - too old create_time means daemon is likely dead,
# - create_time in future means our clock cannot be trusted.
if not (create_time <= time.time() <= create_time + 1.0):
raise DaemonNotRunning()
# Sleep a bit and try again; daemon might have just been started
time.sleep(1.0)
# how did we even get here?! the clock must be going haywire.
_logger.error(f"Failed to connect to JSON-RPC server. Exhausted all attempts.")
raise DaemonNotRunning()
def wait_until_daemon_becomes_ready(*, config: SimpleConfig, timeout=5) -> bool:
t0 = time.monotonic()
while True:
if time.monotonic() > t0 + timeout:
return False # timeout
try:
request(config, 'ping')
return True # success
except DaemonNotRunning:
time.sleep(0.05)
continue
def get_rpc_credentials(config: SimpleConfig) -> Tuple[str, str]:
rpc_user = config.RPC_USERNAME or None
rpc_password = config.RPC_PASSWORD or None
if rpc_user is None or rpc_password is None:
rpc_user = 'user'
bits = 128
nbytes = bits // 8 + (bits % 8 > 0)
pw_int = randrange(pow(2, bits))
pw_b64 = b64encode(
pw_int.to_bytes(nbytes, 'big'), b'-_')
rpc_password = to_string(pw_b64, 'ascii')
config.RPC_USERNAME = rpc_user
config.RPC_PASSWORD = rpc_password
return rpc_user, rpc_password
class AuthenticationError(Exception):
pass
class AuthenticationInvalidOrMissing(AuthenticationError):
pass
class AuthenticationCredentialsInvalid(AuthenticationError):
pass
class AuthenticatedServer(Logger):
def __init__(self, rpc_user, rpc_password):
Logger.__init__(self)
self.rpc_user = rpc_user
self.rpc_password = rpc_password
self.auth_lock = asyncio.Lock()
self._methods = {} # type: Dict[str, Callable]
def register_method(self, name: str, f):
assert name not in self._methods, f"name collision for {name}"
self._methods[name] = f
async 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 AuthenticationInvalidOrMissing('CredentialsMissing')
basic, _, encoded = auth_string.partition(' ')
if basic != 'Basic':
raise AuthenticationInvalidOrMissing('UnsupportedType')
encoded = to_bytes(encoded, 'utf8')
credentials = to_string(b64decode(encoded, validate=True), 'utf8')
username, _, password = credentials.partition(':')
if not (constant_time_compare(username, self.rpc_user)
and constant_time_compare(password, self.rpc_password)):
await asyncio.sleep(0.050)
raise AuthenticationCredentialsInvalid('Invalid Credentials')
async def handle(self, request):
async with self.auth_lock:
try:
await self.authenticate(request.headers)
except AuthenticationInvalidOrMissing:
return web.Response(headers={"WWW-Authenticate": "Basic realm=Electrum"},
text='Unauthorized', status=401)
except AuthenticationCredentialsInvalid:
return web.Response(text='Forbidden', status=403)
try:
request = await request.text()
request = json.loads(request)
method = request['method']
_id = request['id']
params = request.get('params', []) # type: Union[Sequence, Mapping]
if method not in self._methods:
raise Exception(f"attempting to use unregistered method: {method}")
f = self._methods[method]
except Exception as e:
self.logger.exception("invalid request")
return web.Response(text='Invalid Request', status=500)
response = {
'id': _id,
'jsonrpc': '2.0',
}
try:
if isinstance(params, dict):
response['result'] = await f(**params)
else:
response['result'] = await f(*params)
except UserFacingException as e:
response['error'] = {
'code': JsonRPCError.Codes.USERFACING,
'message': str(e),
}
except BaseException as e:
self.logger.exception("internal error while executing RPC")
response['error'] = {
'code': JsonRPCError.Codes.INTERNAL,
'message': "internal error while executing RPC",
'data': {
"exception": repr(e),
"traceback": "".join(traceback.format_exception(e)),
},
}
return web.json_response(response)
class CommandsServer(AuthenticatedServer):
def __init__(self, daemon: 'Daemon', fd):
rpc_user, rpc_password = get_rpc_credentials(daemon.config)
AuthenticatedServer.__init__(self, rpc_user, rpc_password)
self.daemon = daemon
self.fd = fd
self.config = daemon.config
sockettype = self.config.RPC_SOCKET_TYPE
self.socktype = sockettype if sockettype != 'auto' else get_rpcsock_default_type(self.config)
self.sockpath = self.config.RPC_SOCKET_FILEPATH or get_rpcsock_defaultpath(self.config)
self.host = self.config.RPC_HOST
self.port = self.config.RPC_PORT
self.app = web.Application()
self.app.router.add_post("/", self.handle)
self.register_method('ping', self.ping)
self.register_method('gui', self.gui)
self.cmd_runner = Commands(config=self.config, network=self.daemon.network, daemon=self.daemon)
for cmdname in known_commands:
self.register_method(cmdname, getattr(self.cmd_runner, cmdname))
self.register_method('run_cmdline', self.run_cmdline)
def _socket_config_str(self) -> str:
if self.socktype == 'unix':
return f""
elif self.socktype == 'tcp':
return f""
else:
raise Exception(f"unknown socktype '{self.socktype!r}'")
async def run(self):
self.runner = web.AppRunner(self.app)
await self.runner.setup()
if self.socktype == 'unix':
site = web.UnixSite(self.runner, self.sockpath)
elif self.socktype == 'tcp':
site = web.TCPSite(self.runner, self.host, self.port)
else:
raise Exception(f"unknown socktype '{self.socktype!r}'")
try:
await site.start()
except Exception as e:
raise Exception(f"failed to start CommandsServer at {self._socket_config_str()}. got exc: {e!r}") from None
socket = site._server.sockets[0]
if self.socktype == 'unix':
addr = self.sockpath
elif self.socktype == 'tcp':
addr = socket.getsockname()
else:
raise Exception(f"impossible socktype ({self.socktype!r})")
os.write(self.fd, bytes(repr((self.socktype, addr, time.time())), 'utf8'))
os.close(self.fd)
self.logger.info(f"now running and listening. socktype={self.socktype}, addr={addr}")
async def ping(self):
return True
async def gui(self, config_options):
# note: "config_options" is coming from the short-lived CLI-invocation,
# while self.config is the config of the long-lived daemon process.
# "config_options" should have priority.
if self.daemon.gui_object:
if hasattr(self.daemon.gui_object, 'new_window'):
if config_options.get(SimpleConfig.NETWORK_OFFLINE.key()) and not self.config.NETWORK_OFFLINE:
raise UserFacingException(
"error: current GUI is running online, so it cannot open a new wallet offline.")
path = config_options.get('wallet_path') or self.config.get_wallet_path()
self.daemon.gui_object.new_window(path, config_options.get('url'))
return True
else:
raise UserFacingException("error: current GUI does not support multiple windows")
else:
raise UserFacingException("error: Electrum is running in daemon mode. Please stop the daemon first.")
async def run_cmdline(self, config_options):
cmdname = config_options['cmd']
cmd = known_commands.get(cmdname)
if not cmd:
return f"unknown command: {cmdname}"
# arguments passed to function
args = [config_options.get(x) for x in 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 'wallet_path' in cmd.options or 'wallet' in cmd.options:
wallet_path = config_options.get('wallet_path')
if len(self.daemon._wallets) > 1 and wallet_path is None:
raise UserFacingException("error: wallet not specified")
kwargs['wallet_path'] = wallet_path
func = getattr(self.cmd_runner, cmd.name)
# execute requested command now. note: cmd can raise, the caller (self.handle) will wrap it.
result = await func(*args, **kwargs)
return result
class Daemon(Logger):
network: Optional[Network] = None
gui_object: Optional['gui.BaseElectrumGui'] = None
@profiler
def __init__(
self,
config: SimpleConfig,
fd=None,
*,
listen_jsonrpc: bool = True,
start_network: bool = True, # setting to False allows customising network settings before starting it
):
Logger.__init__(self)
self.config = config
self.listen_jsonrpc = listen_jsonrpc
if fd is None and listen_jsonrpc:
fd = get_file_descriptor(config)
if fd is None:
raise Exception('failed to lock daemon; already running?')
self._plugins = None # type: Optional[Plugins]
self.asyncio_loop = util.get_asyncio_loop()
if not self.config.NETWORK_OFFLINE:
self.network = Network(config, daemon=self)
self.fx = FxThread(config=config)
# wallet_key -> wallet
self._wallets = {} # type: Dict[str, Abstract_Wallet]
self._wallet_lock = threading.RLock()
self._stop_entered = False
self._stopping_soon_or_errored = threading.Event()
self._stopped_event = threading.Event()
self.taskgroup = OldTaskGroup()
asyncio.run_coroutine_threadsafe(self._run(), self.asyncio_loop)
if start_network and self.network:
self.start_network()
# Setup commands server
self.commands_server = None
if listen_jsonrpc:
self.commands_server = CommandsServer(self, fd)
asyncio.run_coroutine_threadsafe(self.taskgroup.spawn(self.commands_server.run()), self.asyncio_loop)
@log_exceptions
async def _run(self):
self.logger.info("starting taskgroup.")
try:
async with self.taskgroup as group:
await group.spawn(asyncio.Event().wait) # run forever (until cancel)
except Exception as e:
self.logger.exception("taskgroup died.")
util.send_exception_to_crash_reporter(e)
finally:
self.logger.info("taskgroup stopped.")
# note: we could just "await self.stop()", but in that case GUI users would
# not see the exception (especially if the GUI did not start yet).
self._stopping_soon_or_errored.set()
def start_network(self):
self.logger.info(f"starting network.")
assert not self.config.NETWORK_OFFLINE
assert self.network
self.network.start(jobs=[self.fx.run])
# prepare lightning functionality, also load channel db early
if self.config.LIGHTNING_USE_GOSSIP:
self.network.start_gossip()
@staticmethod
def _wallet_key_from_path(path) -> str:
"""This does stricter path standardization than 'standardize_path'.
It is used for keying the _wallets dict,
but MUST NOT be used as a *path* for the actual filesystem operations. (see #8495)
"""
path = standardize_path(path)
# The extra normalisation makes it even harder to open the same wallet file multiple times simultaneously.
# - "realpath" resolves symlinks:
# note: the path returned by realpath has been observed NOT to work for FS operations!
# (e.g. for Cryptomator WinFSP/FUSE mounts, see #8495).
# It is okay for us to use it for computing a canonical wallet *key*, but cannot be used as a path!
try:
path = os.path.realpath(path, strict=False)
except OSError as e: # see #10182
_logger.warning(f"could not parse {path!r}: {e!r}")
path = path
# - "normcase" does Windows-specific case and slash normalisation:
path = os.path.normcase(path)
# - prepend header to break usage of wallet keys as fs paths
header = "WALLETKEY-"
return header + str(path)
def with_wallet_lock(func):
def func_wrapper(self: 'Daemon', *args, **kwargs):
with self._wallet_lock:
return func(self, *args, **kwargs)
return func_wrapper
@with_wallet_lock
def load_wallet(
self,
path,
password: Optional[str],
*,
upgrade: bool = False,
force_check_password: bool = False,
) -> Optional[Abstract_Wallet]:
"""
force_check_password: if False, the password arg is only used if it needed to decrypt the storage.
if True, the password arg is always validated.
"""
assert password != ''
path = standardize_path(path)
wallet_key = self._wallet_key_from_path(path)
# wizard will be launched if we return
if wallet := self._wallets.get(wallet_key):
if force_check_password:
wallet.check_password(password)
if self.config.get('wallet_path') is None:
self.config.CURRENT_WALLET = path
return wallet
wallet = self._load_wallet(
path, password, upgrade=upgrade, config=self.config, force_check_password=force_check_password)
if self.network:
wallet.start_network(self.network)
elif wallet.lnworker:
# in offline mode, we need to trigger callbacks
coro = wallet.lnworker.lnwatcher.trigger_callbacks(requires_synchronizer=False)
asyncio.run_coroutine_threadsafe(coro, self.asyncio_loop)
self.add_wallet(wallet)
if self.config.get('wallet_path') is None:
self.config.CURRENT_WALLET = path
self.update_recently_opened_wallets(path)
return wallet
@staticmethod
@profiler
def _load_wallet(
path,
password: Optional[str],
*,
upgrade: bool = False,
config: SimpleConfig,
force_check_password: bool = False, # if set, always validate password
) -> Optional[Abstract_Wallet]:
path = standardize_path(path)
storage = WalletStorage(path, allow_partial_writes=config.WALLET_PARTIAL_WRITES)
if not storage.file_exists():
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path)
if storage.is_encrypted():
if not password:
raise InvalidPassword('No password given')
storage.decrypt(password)
# read data, pass it to db
db = WalletDB(storage.read(), storage=storage, upgrade=upgrade)
if db.get_action():
raise WalletUnfinished(db)
wallet = Wallet(db, config=config)
if force_check_password:
wallet.check_password(password)
return wallet
@with_wallet_lock
def add_wallet(self, wallet: Abstract_Wallet) -> None:
path = wallet.storage.path
wallet_key = self._wallet_key_from_path(path)
self._wallets[wallet_key] = wallet
run_hook('daemon_wallet_loaded', self, wallet)
def get_wallet(self, path: str) -> Optional[Abstract_Wallet]:
wallet_key = self._wallet_key_from_path(path)
return self._wallets.get(wallet_key)
@with_wallet_lock
def get_wallets(self) -> Dict[str, Abstract_Wallet]:
return dict(self._wallets) # copy
def delete_wallet(self, path: str) -> bool:
self.stop_wallet(path)
if os.path.exists(path):
os.unlink(path)
self.update_recently_opened_wallets(path, remove=True)
if self.config.CURRENT_WALLET == path:
self.config.CURRENT_WALLET = None
return True
return False
def stop_wallet(self, path: str) -> bool:
"""Returns True iff a wallet was found."""
assert util.get_running_loop() != util.get_asyncio_loop(), 'must not be called from asyncio thread'
fut = asyncio.run_coroutine_threadsafe(self._stop_wallet(path), self.asyncio_loop)
return fut.result()
@with_wallet_lock
async def _stop_wallet(self, path: str) -> bool:
"""Returns True iff a wallet was found."""
path = standardize_path(path)
wallet_key = self._wallet_key_from_path(path)
wallet = self._wallets.pop(wallet_key, None)
if not wallet:
return False
await wallet.stop()
if self.config.get('wallet_path') is None:
wallet_paths = [w.db.storage.path for w in self._wallets.values()
if w.db.storage and w.db.storage.path]
if self.config.CURRENT_WALLET == path and wallet_paths:
self.config.CURRENT_WALLET = wallet_paths[0]
return True
def run_daemon(self):
if 'wallet_path' in self.config.cmdline_options:
self.logger.warning("Ignoring parameter 'wallet_path' for daemon. "
"Use the load_wallet command instead.")
# init plugins
self._plugins = Plugins(self.config, 'cmdline')
# block until we are stopping
try:
self._stopping_soon_or_errored.wait()
except KeyboardInterrupt:
self.logger.info("got KeyboardInterrupt")
# we either initiate shutdown now,
# or it has already been initiated (in which case this is a no-op):
self.logger.info("run_daemon is calling stop()")
asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result()
# wait until "stop" finishes:
self._stopped_event.wait()
async def stop(self):
if self._stop_entered:
return
self._stop_entered = True
self._stopping_soon_or_errored.set()
self.logger.info("stop() entered. initiating shutdown")
try:
if self.gui_object:
self.gui_object.stop()
self.logger.info("stopping all wallets")
async with OldTaskGroup() as group:
for k, wallet in self._wallets.items():
await group.spawn(wallet.stop())
self.logger.info("stopping network and taskgroup")
async with ignore_after(2):
async with OldTaskGroup() as group:
if self.network:
await group.spawn(self.network.stop(full_shutdown=True))
await group.spawn(self.taskgroup.cancel_remaining())
if self._plugins:
self.logger.info("stopping plugins")
self._plugins.stop()
async with ignore_after(1):
await self._plugins.stopped_event_async.wait()
finally:
if self.listen_jsonrpc:
self.logger.info("removing lockfile")
remove_lockfile(get_lockfile(self.config))
self.logger.info("stopped")
self._stopped_event.set()
def run_gui(self) -> None:
assert self.config
threading.current_thread().name = 'GUI'
gui_name = self.config.GUI_NAME
if gui_name in ['lite', 'classic']:
gui_name = 'qt'
self._plugins = Plugins(self.config, gui_name) # init plugins
self.logger.info(f'launching GUI: {gui_name}')
try:
try:
gui = __import__('electrum.gui.' + gui_name, fromlist=['electrum'])
except GuiImportError as e:
sys.exit(str(e))
self.gui_object = gui.ElectrumGui(config=self.config, daemon=self, plugins=self._plugins)
if not self._stop_entered:
self.gui_object.main()
else:
# If daemon.stop() was called before gui_object got created, stop gui now.
self.gui_object.stop()
except BaseException as e:
self.logger.error(f'GUI raised exception: {repr(e)}. shutting down.')
raise
finally:
# app will exit now
asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result()
@with_wallet_lock
def check_password_for_directory(self, *, old_password, new_password=None, wallet_dir: str) -> Tuple[bool, bool, list[str]]:
"""Checks password against all wallets (in dir), returns whether they can be unified and whether they are already.
If new_password is not None, update all wallet passwords to new_password.
"""
assert os.path.exists(wallet_dir), f"path {wallet_dir!r} does not exist"
succeeded = []
failed = []
is_unified = True
for filename in os.listdir(wallet_dir):
path = os.path.join(wallet_dir, filename)
path = standardize_path(path)
if not os.path.isfile(path):
continue
wallet = self.get_wallet(path)
# note: we only create a new wallet object if one was not loaded into the daemon already.
# This is to avoid having two wallet objects contending for the same file.
# Take care: this only works if the daemon knows about all wallet objects.
# if other code already has created a Wallet() for a file but did not tell the daemon,
# hard-to-understand bugs will follow...
if wallet is None:
try:
wallet = self._load_wallet(path, old_password, upgrade=True, config=self.config)
except util.InvalidPassword:
pass
except Exception:
self.logger.exception(f'failed to load wallet at {path!r}:')
if wallet is None:
failed.append(path)
continue
if not wallet.storage.is_encrypted():
is_unified = False
try:
try:
wallet.check_password(old_password)
old_password_real = old_password
except util.InvalidPassword:
wallet.check_password(None)
old_password_real = None
except Exception:
failed.append(path)
continue
if new_password:
self.logger.info(f'updating password for wallet: {path!r}')
wallet.update_password(old_password_real, new_password, encrypt_storage=True)
succeeded.append(path)
can_be_unified = failed == []
is_unified = can_be_unified and is_unified
return can_be_unified, is_unified, succeeded
@with_wallet_lock
def update_password_for_directory(
self,
*,
old_password,
new_password,
wallet_dir: Optional[str] = None,
) -> bool:
"""returns whether password is unified"""
if new_password is None:
# we opened a non-encrypted wallet
return False
if wallet_dir is None:
wallet_dir = os.path.dirname(self.config.get_wallet_path())
can_be_unified, is_unified, _ = self.check_password_for_directory(
old_password=old_password, new_password=None, wallet_dir=wallet_dir)
if not can_be_unified:
return False
if is_unified and old_password == new_password:
return True
self.check_password_for_directory(
old_password=old_password, new_password=new_password, wallet_dir=wallet_dir)
return True
def update_recently_opened_wallets(self, wallet_path, *, remove: bool = False):
recent = self.config.RECENTLY_OPEN_WALLET_FILES or []
if wallet_path in recent:
recent.remove(wallet_path)
if not remove:
recent.insert(0, wallet_path)
recent = [path for path in recent if os.path.exists(path)]
recent = recent[:5]
self.config.RECENTLY_OPEN_WALLET_FILES = recent
util.trigger_callback('recently_opened_wallets_update')
================================================
FILE: electrum/descriptor.py
================================================
# Copyright (c) 2017 Andrew Chow
# Copyright (c) 2023 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
#
# forked from https://github.com/bitcoin-core/HWI/blob/5f300d3dee7b317a6194680ad293eaa0962a3cc7/hwilib/descriptor.py
#
# Output Script Descriptors
# See https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md
#
# TODO allow xprv
# TODO hardened derivation
# TODO allow WIF privkeys
# TODO impl ADDR descriptors
# TODO impl RAW descriptors
from binascii import unhexlify
import enum
from enum import Enum
from typing import (
List,
NamedTuple,
Optional,
Tuple,
Sequence,
Mapping,
Set,
Union,
)
import electrum_ecc as ecc
from .bip32 import convert_bip32_strpath_to_intpath, BIP32Node, KeyOriginInfo, BIP32_PRIME
from . import bitcoin
from .bitcoin import construct_script, opcodes, construct_witness, taproot_output_script
from . import constants
from .crypto import hash_160, sha256
from . import segwit_addr
MAX_TAPROOT_DEPTH = 128
# we guess that signatures will be 72 bytes long
# note: DER-encoded ECDSA signatures are 71 or 72 bytes in practice
# See https://bitcoin.stackexchange.com/questions/77191/what-is-the-maximum-size-of-a-der-encoded-ecdsa-signature
# We assume low S (as that is a bitcoin standardness rule).
# We do not assume low R (even though the sigs we create conform), as external sigs,
# e.g. from a hw signer cannot be expected to have a low R.
DUMMY_DER_SIG = 72 * b"\x00"
class ExpandedScripts:
def __init__(
self,
*,
output_script: bytes, # "scriptPubKey"
redeem_script: Optional[bytes] = None,
witness_script: Optional[bytes] = None,
scriptcode_for_sighash: Optional[bytes] = None
):
self.output_script = output_script
self.redeem_script = redeem_script
self.witness_script = witness_script
self.scriptcode_for_sighash = scriptcode_for_sighash
@property
def scriptcode_for_sighash(self) -> Optional[bytes]:
if self._scriptcode_for_sighash:
return self._scriptcode_for_sighash
return self.witness_script or self.redeem_script or self.output_script
@scriptcode_for_sighash.setter
def scriptcode_for_sighash(self, value: Optional[bytes]):
self._scriptcode_for_sighash = value
def address(self, *, net=None) -> Optional[str]:
return bitcoin.script_to_address(self.output_script, net=net)
class ScriptSolutionInner(NamedTuple):
witness_items: Optional[Sequence] = None
class ScriptSolutionTop(NamedTuple):
witness: Optional[bytes] = None
script_sig: Optional[bytes] = None
class MissingSolutionPiece(Exception): pass
def PolyMod(c: int, val: int) -> int:
"""
:meta private:
Function to compute modulo over the polynomial used for descriptor checksums
From: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp
"""
c0 = c >> 35
c = ((c & 0x7ffffffff) << 5) ^ val
if (c0 & 1):
c ^= 0xf5dee51989
if (c0 & 2):
c ^= 0xa9fdca3312
if (c0 & 4):
c ^= 0x1bab10e32d
if (c0 & 8):
c ^= 0x3706b1677a
if (c0 & 16):
c ^= 0x644d626ffd
return c
_INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
_INPUT_CHARSET_INV = {c: i for (i, c) in enumerate(_INPUT_CHARSET)}
_CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def DescriptorChecksum(desc: str) -> str:
"""
Compute the checksum for a descriptor
:param desc: The descriptor string to compute a checksum for
:return: A checksum
"""
c = 1
cls = 0
clscount = 0
for ch in desc:
try:
pos = _INPUT_CHARSET_INV[ch]
except KeyError:
return ""
c = PolyMod(c, pos & 31)
cls = cls * 3 + (pos >> 5)
clscount += 1
if clscount == 3:
c = PolyMod(c, cls)
cls = 0
clscount = 0
if clscount > 0:
c = PolyMod(c, cls)
for j in range(0, 8):
c = PolyMod(c, 0)
c ^= 1
ret = [''] * 8
for j in range(0, 8):
ret[j] = _CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31]
return ''.join(ret)
def AddChecksum(desc: str) -> str:
"""
Compute and attach the checksum for a descriptor
:param desc: The descriptor string to add a checksum to
:return: Descriptor with checksum
"""
return desc + "#" + DescriptorChecksum(desc)
class PubkeyProvider(object):
"""
A public key expression in a descriptor.
Can contain the key origin info, the pubkey itself, and subsequent derivation paths for derivation from the pubkey
The pubkey can be a typical pubkey or an extended pubkey.
"""
def __init__(
self,
origin: Optional['KeyOriginInfo'],
pubkey: str,
deriv_path: Optional[str]
) -> None:
"""
:param origin: The key origin if one is available
:param pubkey: The public key. Either a hex string or a serialized extended pubkey
:param deriv_path: Additional derivation path (suffix) if the pubkey is an extended pubkey
"""
self.origin = origin
self.pubkey = pubkey
self.deriv_path = deriv_path
if deriv_path:
wildcard_count = deriv_path.count("*")
if wildcard_count > 1:
raise ValueError("only one wildcard(*) is allowed in a descriptor")
if wildcard_count == 1:
if deriv_path[-1] != "*":
raise ValueError("wildcard in descriptor only allowed in last position")
if deriv_path[0] != "/":
raise ValueError(f"deriv_path suffix must start with a '/'. got {deriv_path!r}")
# Make ExtendedKey from pubkey if it isn't hex
self.extkey = None
try:
unhexlify(self.pubkey)
# Is hex, normal pubkey
except Exception:
# Not hex, maybe xpub (but don't allow ypub/zpub)
self.extkey = BIP32Node.from_xkey(pubkey, allow_custom_headers=False)
if deriv_path and self.extkey is None:
raise ValueError("deriv_path suffix present for simple pubkey")
@classmethod
def parse(cls, s: str) -> 'PubkeyProvider':
"""
Deserialize a key expression from the string into a ``PubkeyProvider``.
:param s: String containing the key expression
:return: A new ``PubkeyProvider`` containing the details given by ``s``
"""
origin = None
deriv_path = None
if s[0] == "[":
end = s.index("]")
origin = KeyOriginInfo.from_string(s[1:end])
s = s[end + 1:]
pubkey = s
slash_idx = s.find("/")
if slash_idx != -1:
pubkey = s[:slash_idx]
deriv_path = s[slash_idx:]
return cls(origin, pubkey, deriv_path)
def to_string(self) -> str:
"""
Serialize the pubkey expression to a string to be used in a descriptor
:return: The pubkey expression as a string
"""
s = ""
if self.origin:
s += "[{}]".format(self.origin.to_string())
s += self.pubkey
if self.deriv_path:
s += self.deriv_path
return s
def get_pubkey_bytes(self, *, pos: Optional[int] = None) -> bytes:
if self.is_range() and pos is None:
raise ValueError("pos must be set for ranged descriptor")
# note: if not ranged, we ignore pos.
if self.extkey is not None:
compressed = True # bip32 implies compressed pubkeys
if self.deriv_path is None:
assert not self.is_range()
return self.extkey.eckey.get_public_key_bytes(compressed=compressed)
else:
path_str = self.deriv_path[1:]
if self.is_range():
assert path_str[-1] == "*"
path_str = path_str[:-1] + str(pos)
path = convert_bip32_strpath_to_intpath(path_str)
child_key = self.extkey.subkey_at_public_derivation(path)
return child_key.eckey.get_public_key_bytes(compressed=compressed)
else:
assert not self.is_range()
return unhexlify(self.pubkey)
def get_full_derivation_path(self, *, pos: Optional[int] = None) -> str:
"""
Returns the full derivation path at the given position, including the origin
"""
if self.is_range() and pos is None:
raise ValueError("pos must be set for ranged descriptor")
path = self.origin.get_derivation_path() if self.origin is not None else "m"
path += self.deriv_path if self.deriv_path is not None else ""
if path[-1] == "*":
path = path[:-1] + str(pos)
return path
def get_full_derivation_int_list(self, *, pos: Optional[int] = None) -> List[int]:
"""
Returns the full derivation path as an integer list at the given position.
Includes the origin and master key fingerprint as an int
"""
if self.is_range() and pos is None:
raise ValueError("pos must be set for ranged descriptor")
path: List[int] = self.origin.get_full_int_list() if self.origin is not None else []
path.extend(self.get_der_suffix_int_list(pos=pos))
return path
def get_der_suffix_int_list(self, *, pos: Optional[int] = None) -> List[int]:
if not self.deriv_path:
return []
der_suffix = self.deriv_path
assert (wc_count := der_suffix.count("*")) <= 1, wc_count
der_suffix = der_suffix.replace("*", str(pos))
return convert_bip32_strpath_to_intpath(der_suffix)
def __lt__(self, other: 'PubkeyProvider') -> bool:
return self.pubkey < other.pubkey
def is_range(self) -> bool:
if not self.deriv_path:
return False
if self.deriv_path[-1] == "*": # TODO hardened
return True
return False
def has_uncompressed_pubkey(self) -> bool:
if self.is_range(): # bip32 implies compressed
return False
return b"\x04" == self.get_pubkey_bytes()[:1]
class Descriptor(object):
r"""
An abstract class for Descriptors themselves.
Descriptors can contain multiple :class:`PubkeyProvider`\ s and multiple ``Descriptor`` as subdescriptors.
Note: a significant portion of input validation logic is in parse_descriptor(),
maybe these checks should be moved to (or also done in) this class?
For example, sh() must be top-level, or segwit mandates compressed pubkeys,
or bare-multisig cannot have >3 pubkeys.
"""
def __init__(
self,
pubkeys: List['PubkeyProvider'],
subdescriptors: List['Descriptor'],
name: str
) -> None:
r"""
:param pubkeys: The :class:`PubkeyProvider`\ s that are part of this descriptor
:param subdescriptor: The ``Descriptor``\ s that are part of this descriptor
:param name: The name of the function for this descriptor
"""
self.pubkeys = pubkeys
self.subdescriptors = subdescriptors
self.name = name
def to_string_no_checksum(self) -> str:
"""
Serializes the descriptor as a string without the descriptor checksum
:return: The descriptor string
"""
return "{}({}{})".format(
self.name,
",".join([p.to_string() for p in self.pubkeys]),
self.subdescriptors[0].to_string_no_checksum() if len(self.subdescriptors) > 0 else ""
)
def to_string(self) -> str:
"""
Serializes the descriptor as a string with the checksum
:return: The descriptor with a checksum
"""
return AddChecksum(self.to_string_no_checksum())
def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
"""
Returns the scripts for a descriptor at the given `pos` for ranged descriptors.
"""
raise NotImplementedError("The Descriptor base class does not implement this method")
def _satisfy_inner(
self,
*,
sigdata: Mapping[bytes, bytes] = None, # pubkey -> sig
allow_dummy: bool = False,
) -> ScriptSolutionInner:
raise NotImplementedError("The Descriptor base class does not implement this method")
def satisfy(
self,
*,
sigdata: Mapping[bytes, bytes] = None, # pubkey -> sig
allow_dummy: bool = False,
) -> ScriptSolutionTop:
"""Construct a witness and/or scriptSig to be used in a txin, to satisfy the bitcoin SCRIPT.
Raises MissingSolutionPiece if satisfaction is not yet possible due to e.g. missing a signature,
unless `allow_dummy` is set to True, in which case dummy data is used where needed (e.g. for size estimation).
"""
assert not self.is_range()
sol = self._satisfy_inner(sigdata=sigdata, allow_dummy=allow_dummy)
witness = None
script_sig = None
if self.is_segwit():
witness = construct_witness(sol.witness_items)
else:
script_sig = construct_script(sol.witness_items)
return ScriptSolutionTop(
witness=witness,
script_sig=script_sig,
)
def get_satisfaction_progress(
self,
*,
sigdata: Mapping[bytes, bytes] = None, # pubkey -> sig
) -> Tuple[int, int]:
"""Returns (num_sigs_we_have, num_sigs_required) towards satisfying this script.
Besides signatures, later this can also consider hash-preimages.
"""
assert not self.is_range()
nhave, nreq = 0, 0
for desc in self.subdescriptors:
a, b = desc.get_satisfaction_progress(sigdata=sigdata)
nhave += a
nreq += b
return nhave, nreq
def is_range(self) -> bool:
for pubkey in self.pubkeys:
if pubkey.is_range():
return True
for desc in self.subdescriptors:
if desc.is_range():
return True
return False
def is_segwit(self) -> bool:
return any([desc.is_segwit() for desc in self.subdescriptors])
def is_taproot(self) -> bool:
return False
def get_all_pubkeys(self) -> Set[bytes]:
"""Returns set of pubkeys that appear at any level in this descriptor."""
assert not self.is_range()
all_pubkeys = set([p.get_pubkey_bytes() for p in self.pubkeys])
for desc in self.subdescriptors:
all_pubkeys |= desc.get_all_pubkeys()
return all_pubkeys
def get_simple_singlesig(self) -> Optional['Descriptor']:
"""Returns innermost pk/pkh/wpkh descriptor, or None if we are not a simple singlesig.
note: besides pk,pkh,sh(wpkh),wpkh, overly complicated stuff such as sh(pk),wsh(sh(pkh),etc is also accepted
"""
if len(self.subdescriptors) == 1:
return self.subdescriptors[0].get_simple_singlesig()
return None
def get_simple_multisig(self) -> Optional['MultisigDescriptor']:
"""Returns innermost multi descriptor, or None if we are not a simple multisig."""
if len(self.subdescriptors) == 1:
return self.subdescriptors[0].get_simple_multisig()
return None
def to_legacy_electrum_script_type(self) -> str:
if isinstance(self, PKDescriptor):
return "p2pk"
elif isinstance(self, PKHDescriptor):
return "p2pkh"
elif isinstance(self, WPKHDescriptor):
return "p2wpkh"
elif isinstance(self, SHDescriptor) and isinstance(self.subdescriptors[0], WPKHDescriptor):
return "p2wpkh-p2sh"
elif isinstance(self, SHDescriptor) and isinstance(self.subdescriptors[0], MultisigDescriptor):
return "p2sh"
elif isinstance(self, WSHDescriptor) and isinstance(self.subdescriptors[0], MultisigDescriptor):
return "p2wsh"
elif (isinstance(self, SHDescriptor) and isinstance(self.subdescriptors[0], WSHDescriptor)
and isinstance(self.subdescriptors[0].subdescriptors[0], MultisigDescriptor)):
return "p2wsh-p2sh"
return "unknown"
class PKDescriptor(Descriptor):
"""
A descriptor for ``pk()`` descriptors
"""
def __init__(
self,
pubkey: 'PubkeyProvider'
) -> None:
"""
:param pubkey: The :class:`PubkeyProvider` for this descriptor
"""
super().__init__([pubkey], [], "pk")
def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
pubkey = self.pubkeys[0].get_pubkey_bytes(pos=pos)
script = construct_script([pubkey, opcodes.OP_CHECKSIG])
return ExpandedScripts(output_script=script)
def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
if sigdata is None: sigdata = {}
assert not self.is_range()
assert not self.subdescriptors
pubkey = self.pubkeys[0].get_pubkey_bytes()
sig = sigdata.get(pubkey)
if sig is None and allow_dummy:
sig = DUMMY_DER_SIG
if sig is None:
raise MissingSolutionPiece(f"no sig for {pubkey.hex()}")
return ScriptSolutionInner(
witness_items=(sig,),
)
def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]:
if sigdata is None: sigdata = {}
signatures = list(sigdata.values())
return len(signatures), 1
def get_simple_singlesig(self) -> Optional['Descriptor']:
return self
class PKHDescriptor(Descriptor):
"""
A descriptor for ``pkh()`` descriptors
"""
def __init__(
self,
pubkey: 'PubkeyProvider'
) -> None:
"""
:param pubkey: The :class:`PubkeyProvider` for this descriptor
"""
super().__init__([pubkey], [], "pkh")
def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
pubkey = self.pubkeys[0].get_pubkey_bytes(pos=pos)
pkh = hash_160(pubkey)
script = bitcoin.pubkeyhash_to_p2pkh_script(pkh)
return ExpandedScripts(output_script=script)
def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
if sigdata is None: sigdata = {}
assert not self.is_range()
assert not self.subdescriptors
pubkey = self.pubkeys[0].get_pubkey_bytes()
sig = sigdata.get(pubkey)
if sig is None and allow_dummy:
sig = DUMMY_DER_SIG
if sig is None:
raise MissingSolutionPiece(f"no sig for {pubkey.hex()}")
return ScriptSolutionInner(
witness_items=(sig, pubkey),
)
def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]:
if sigdata is None: sigdata = {}
signatures = list(sigdata.values())
return len(signatures), 1
def get_simple_singlesig(self) -> Optional['Descriptor']:
return self
class WPKHDescriptor(Descriptor):
"""
A descriptor for ``wpkh()`` descriptors
"""
def __init__(
self,
pubkey: 'PubkeyProvider'
) -> None:
"""
:param pubkey: The :class:`PubkeyProvider` for this descriptor
"""
super().__init__([pubkey], [], "wpkh")
def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
pkh = hash_160(self.pubkeys[0].get_pubkey_bytes(pos=pos))
output_script = construct_script([0, pkh])
scriptcode = bitcoin.pubkeyhash_to_p2pkh_script(pkh)
return ExpandedScripts(
output_script=output_script,
scriptcode_for_sighash=scriptcode,
)
def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
if sigdata is None: sigdata = {}
assert not self.is_range()
assert not self.subdescriptors
pubkey = self.pubkeys[0].get_pubkey_bytes()
sig = sigdata.get(pubkey)
if sig is None and allow_dummy:
sig = DUMMY_DER_SIG
if sig is None:
raise MissingSolutionPiece(f"no sig for {pubkey.hex()}")
return ScriptSolutionInner(
witness_items=(sig, pubkey),
)
def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]:
if sigdata is None: sigdata = {}
signatures = list(sigdata.values())
return len(signatures), 1
def is_segwit(self) -> bool:
return True
def get_simple_singlesig(self) -> Optional['Descriptor']:
return self
class MultisigDescriptor(Descriptor):
"""
A descriptor for ``multi()`` and ``sortedmulti()`` descriptors
"""
def __init__(
self,
pubkeys: List['PubkeyProvider'],
thresh: int,
is_sorted: bool
) -> None:
r"""
:param pubkeys: The :class:`PubkeyProvider`\ s for this descriptor
:param thresh: The number of keys required to sign this multisig
:param is_sorted: Whether this is a ``sortedmulti()`` descriptor
"""
super().__init__(pubkeys, [], "sortedmulti" if is_sorted else "multi")
if not (1 <= thresh <= len(pubkeys) <= 15):
raise ValueError(f'{thresh=}, {len(pubkeys)=}')
self.thresh = thresh
self.is_sorted = is_sorted
if self.is_sorted:
if not self.is_range():
# sort xpubs using the order of pubkeys
der_pks = [p.get_pubkey_bytes() for p in self.pubkeys]
self.pubkeys = [x[1] for x in sorted(zip(der_pks, self.pubkeys))]
else:
# not possible to sort according to final order in expanded scripts,
# but for easier visual comparison, we do a lexicographical sort
self.pubkeys.sort()
def to_string_no_checksum(self) -> str:
return "{}({},{})".format(self.name, self.thresh, ",".join([p.to_string() for p in self.pubkeys]))
def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
der_pks = [p.get_pubkey_bytes(pos=pos) for p in self.pubkeys]
if self.is_sorted:
der_pks.sort()
script = construct_script([self.thresh, *der_pks, len(der_pks), opcodes.OP_CHECKMULTISIG])
return ExpandedScripts(output_script=script)
def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
if sigdata is None: sigdata = {}
assert not self.is_range()
assert not self.subdescriptors
der_pks = [p.get_pubkey_bytes() for p in self.pubkeys]
if self.is_sorted:
der_pks.sort()
signatures = []
for pubkey in der_pks:
if sig := sigdata.get(pubkey):
signatures.append(sig)
if len(signatures) >= self.thresh:
break
if allow_dummy:
dummy_sig = DUMMY_DER_SIG
signatures += (self.thresh - len(signatures)) * [dummy_sig]
if len(signatures) < self.thresh:
raise MissingSolutionPiece(f"not enough sigs")
assert len(signatures) == self.thresh, f"thresh={self.thresh}, but got {len(signatures)} sigs"
return ScriptSolutionInner(
witness_items=(0, *signatures),
)
def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]:
if sigdata is None: sigdata = {}
signatures = list(sigdata.values())
return len(signatures), self.thresh
def get_simple_multisig(self) -> Optional['MultisigDescriptor']:
return self
class SHDescriptor(Descriptor):
"""
A descriptor for ``sh()`` descriptors
"""
def __init__(
self,
subdescriptor: 'Descriptor'
) -> None:
"""
:param subdescriptor: The :class:`Descriptor` that is a sub-descriptor for this descriptor
"""
super().__init__([], [subdescriptor], "sh")
def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
assert len(self.subdescriptors) == 1
sub_scripts = self.subdescriptors[0].expand(pos=pos)
redeem_script = sub_scripts.output_script
witness_script = sub_scripts.witness_script
script = construct_script([opcodes.OP_HASH160, hash_160(redeem_script), opcodes.OP_EQUAL])
return ExpandedScripts(
output_script=script,
redeem_script=redeem_script,
witness_script=witness_script,
scriptcode_for_sighash=sub_scripts.scriptcode_for_sighash,
)
def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
raise Exception("does not make sense for sh()")
def satisfy(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionTop:
assert not self.is_range()
assert len(self.subdescriptors) == 1
subdesc = self.subdescriptors[0]
redeem_script = self.expand().redeem_script
witness = None
if isinstance(subdesc, (WSHDescriptor, WPKHDescriptor)): # witness_v0 nested in p2sh
witness = subdesc.satisfy(sigdata=sigdata, allow_dummy=allow_dummy).witness
script_sig = construct_script([redeem_script])
else: # legacy p2sh
subsol = subdesc._satisfy_inner(sigdata=sigdata, allow_dummy=allow_dummy)
script_sig = construct_script([*subsol.witness_items, redeem_script])
return ScriptSolutionTop(
witness=witness,
script_sig=script_sig,
)
class WSHDescriptor(Descriptor):
"""
A descriptor for ``wsh()`` descriptors
"""
def __init__(
self,
subdescriptor: 'Descriptor'
) -> None:
"""
:param subdescriptor: The :class:`Descriptor` that is a sub-descriptor for this descriptor
"""
super().__init__([], [subdescriptor], "wsh")
def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
assert len(self.subdescriptors) == 1
sub_scripts = self.subdescriptors[0].expand(pos=pos)
witness_script = sub_scripts.output_script
output_script = construct_script([0, sha256(witness_script)])
return ExpandedScripts(
output_script=output_script,
witness_script=witness_script,
)
def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
raise Exception("does not make sense for wsh()")
def satisfy(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionTop:
assert not self.is_range()
assert len(self.subdescriptors) == 1
subsol = self.subdescriptors[0]._satisfy_inner(sigdata=sigdata, allow_dummy=allow_dummy)
witness_script = self.expand().witness_script
witness = construct_witness([*subsol.witness_items, witness_script])
return ScriptSolutionTop(
witness=witness,
)
def is_segwit(self) -> bool:
return True
class TRDescriptor(Descriptor):
"""
A descriptor for ``tr()`` descriptors
"""
def __init__(
self,
internal_key: 'PubkeyProvider',
desc_tree: List[Union['Descriptor', List]] = None,
) -> None:
r"""
:param internal_key: The :class:`PubkeyProvider` that is the internal key for this descriptor
:param desc_tree: Taproot script binary tree, as a nested list of Descriptors
"""
if desc_tree is None:
desc_tree = []
self.desc_tree = desc_tree
desc_list = []
if desc_tree:
if self.get_max_tree_depth() > MAX_TAPROOT_DEPTH:
raise ValueError(f"tr() supports at most {MAX_TAPROOT_DEPTH} nesting levels")
def flatten(tree_node):
if isinstance(tree_node, Descriptor):
return [tree_node]
assert len(tree_node) == 2, len(tree_node)
return flatten(tree_node[0]) + flatten(tree_node[1])
desc_list = flatten(desc_tree)
super().__init__(
pubkeys=[internal_key],
subdescriptors=desc_list, # FIXME we could do without the flattened list (dupl)
name="tr",
)
def to_string_no_checksum(self) -> str:
ret = f"{self.name}({self.pubkeys[0].to_string()}"
if self.desc_tree:
ret += ","
def tree_to_str(tree_node):
if isinstance(tree_node, Descriptor):
return tree_node.to_string_no_checksum()
assert len(tree_node) == 2, len(tree_node)
return "{" + tree_to_str(tree_node[0]) + "," + tree_to_str(tree_node[1]) + "}"
ret += tree_to_str(self.desc_tree)
ret += ")"
return ret
def is_segwit(self) -> bool:
return True
def is_taproot(self) -> bool:
return True
# TODO add more test vectors from BIP-0386
def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
internal_pubkey = self.pubkeys[0].get_pubkey_bytes(pos=pos)
script_tree = None
if self.desc_tree:
def transform(tree_node):
if isinstance(tree_node, Descriptor):
leaf_version = 0xc0
leaf_script = tree_node.expand(pos=pos).scriptcode_for_sighash # FIXME maybe rename scriptcode_for_sighash
return (leaf_version, leaf_script)
assert len(tree_node) == 2, len(tree_node)
return [transform(tree_node[0]), transform(tree_node[1])]
script_tree = transform(self.desc_tree)
output_script = taproot_output_script(internal_pubkey, script_tree=script_tree)
return ExpandedScripts(
output_script=output_script,
)
def get_max_tree_depth(self) -> Optional[int]:
if not self.desc_tree:
return None
def depth(tree_node) -> int:
if isinstance(tree_node, Descriptor):
return 0
assert len(tree_node) == 2, len(tree_node)
return 1 + max(depth(tree_node[0]), depth(tree_node[1]))
return depth(self.desc_tree)
def _get_func_expr(s: str) -> Tuple[str, str]:
"""
Get the function name and then the expression inside
:param s: The string that begins with a function name
:return: The function name as the first element of the tuple, and the expression contained within the function as the second element
:raises: ValueError: if a matching pair of parentheses cannot be found
"""
try:
start = s.index("(")
end = s.rindex(")")
return s[0:start], s[start + 1:end]
except ValueError:
raise ValueError("A matching pair of parentheses cannot be found")
def _get_const(s: str, const: str) -> str:
"""
Get the first character of the string, make sure it is the expected character,
and return the rest of the string
:param s: The string that begins with a constant character
:param const: The constant character
:return: The remainder of the string without the constant character
:raises: ValueError: if the first character is not the constant character
"""
if s[0] != const:
raise ValueError(f"Expected '{const}' but got '{s[0]}'")
return s[1:]
def _get_expr(s: str) -> Tuple[str, str]:
"""
Extract the expression that ``s`` begins with.
This will return the initial part of ``s``, up to the first comma or closing brace,
skipping ones that are surrounded by braces.
:param s: The string to extract the expression from
:return: A pair with the first item being the extracted expression and the second the rest of the string
"""
level: int = 0
for i, c in enumerate(s):
if c in ["(", "{"]:
level += 1
elif level > 0 and c in [")", "}"]:
level -= 1
elif level == 0 and c in [")", "}", ","]:
break
else:
return s, ""
return s[0:i], s[i:]
def parse_pubkey(expr: str, *, ctx: '_ParseDescriptorContext') -> Tuple['PubkeyProvider', str]:
"""
Parses an individual pubkey expression from a string that may contain more than one pubkey expression.
:param expr: The expression to parse a pubkey expression from
:return: The :class:`PubkeyProvider` that is parsed as the first item of a tuple, and the remainder of the expression as the second item.
"""
end = len(expr)
comma_idx = expr.find(",")
next_expr = ""
if comma_idx != -1:
end = comma_idx
next_expr = expr[end + 1:]
pubkey_provider = PubkeyProvider.parse(expr[:end])
permit_uncompressed = ctx in (_ParseDescriptorContext.TOP, _ParseDescriptorContext.P2SH)
if not permit_uncompressed and pubkey_provider.has_uncompressed_pubkey():
raise ValueError("uncompressed pubkeys are not allowed")
return pubkey_provider, next_expr
class _ParseDescriptorContext(Enum):
"""
:meta private:
Enum representing the level that we are in when parsing a descriptor.
Some expressions aren't allowed at certain levels, this helps us track those.
"""
TOP = enum.auto() # The top level, not within any descriptor
P2SH = enum.auto() # Within an sh() descriptor
P2WPKH = enum.auto() # Within wpkh() descriptor
P2WSH = enum.auto() # Within a wsh() descriptor
P2TR = enum.auto() # Within a tr() descriptor
def _parse_descriptor(desc: str, *, ctx: '_ParseDescriptorContext') -> 'Descriptor':
"""
:meta private:
Parse a descriptor given the context level we are in.
Used recursively to parse subdescriptors
:param desc: The descriptor string to parse
:param ctx: The :class:`_ParseDescriptorContext` indicating the level we are in
:return: The parsed descriptor
:raises: ValueError: if the descriptor is malformed
"""
func, expr = _get_func_expr(desc)
if func == "pk":
pubkey, expr = parse_pubkey(expr, ctx=ctx)
if expr:
raise ValueError("more than one pubkey in pk descriptor")
return PKDescriptor(pubkey)
if func == "pkh":
if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH):
raise ValueError("Can only have pkh at top level, in sh(), or in wsh()")
pubkey, expr = parse_pubkey(expr, ctx=ctx)
if expr:
raise ValueError("More than one pubkey in pkh descriptor")
return PKHDescriptor(pubkey)
if func == "sortedmulti" or func == "multi":
if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH):
raise ValueError("Can only have multi/sortedmulti at top level, in sh(), or in wsh()")
is_sorted = func == "sortedmulti"
comma_idx = expr.index(",")
thresh = int(expr[:comma_idx])
expr = expr[comma_idx + 1:]
pubkeys = []
while expr:
pubkey, expr = parse_pubkey(expr, ctx=ctx)
pubkeys.append(pubkey)
if len(pubkeys) == 0 or len(pubkeys) > 15:
raise ValueError("Cannot have {} keys in a multisig; must have between 1 and 15 keys, inclusive".format(len(pubkeys)))
elif thresh < 1:
raise ValueError("Multisig threshold cannot be {}, must be at least 1".format(thresh))
elif thresh > len(pubkeys):
raise ValueError("Multisig threshold cannot be larger than the number of keys; threshold is {} but only {} keys specified".format(thresh, len(pubkeys)))
if ctx == _ParseDescriptorContext.TOP and len(pubkeys) > 3:
raise ValueError("Cannot have {} pubkeys in bare multisig: only at most 3 pubkeys")
return MultisigDescriptor(pubkeys, thresh, is_sorted)
if func == "wpkh":
if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH):
raise ValueError("Can only have wpkh() at top level or inside sh()")
pubkey, expr = parse_pubkey(expr, ctx=_ParseDescriptorContext.P2WPKH)
if expr:
raise ValueError("More than one pubkey in pkh descriptor")
return WPKHDescriptor(pubkey)
if func == "sh":
if ctx != _ParseDescriptorContext.TOP:
raise ValueError("Can only have sh() at top level")
subdesc = _parse_descriptor(expr, ctx=_ParseDescriptorContext.P2SH)
return SHDescriptor(subdesc)
if func == "wsh":
if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH):
raise ValueError("Can only have wsh() at top level or inside sh()")
subdesc = _parse_descriptor(expr, ctx=_ParseDescriptorContext.P2WSH)
return WSHDescriptor(subdesc)
if func == "tr":
if ctx != _ParseDescriptorContext.TOP:
raise ValueError("Can only have tr at top level")
internal_key, expr = parse_pubkey(expr, ctx=ctx)
desc_tree = []
if expr:
def parse_tree(tree_str):
if len(tree_str) == 0:
raise ValueError("Invalid Taproot tree expression")
if tree_str[0] != "{": # leaf
sarg, remaining = _get_expr(tree_str)
return _parse_descriptor(sarg, ctx=_ParseDescriptorContext.P2TR), remaining
if len(tree_str) < len("{x,y}") or tree_str[-1] != "}":
raise ValueError("Invalid Taproot tree expression")
left, remaining = parse_tree(tree_str[1:])
if remaining[0] != ",": raise ValueError
right, remaining = parse_tree(remaining[1:])
if remaining[0] != "}": raise ValueError
return [left, right], remaining[1:]
desc_tree, _remaining = parse_tree(expr)
if len(_remaining) != 0: raise ValueError
return TRDescriptor(internal_key, desc_tree)
if ctx == _ParseDescriptorContext.P2SH:
raise ValueError("A function is needed within P2SH")
elif ctx == _ParseDescriptorContext.P2WSH:
raise ValueError("A function is needed within P2WSH")
raise ValueError("{} is not a valid descriptor function".format(func))
def parse_descriptor(desc: str) -> 'Descriptor':
"""
Parse a descriptor string into a :class:`Descriptor`.
Validates the checksum if one is provided in the string
:param desc: The descriptor string
:return: The parsed :class:`Descriptor`
:raises: ValueError: if the descriptor string is malformed
"""
i = desc.find("#")
if i != -1:
checksum = desc[i + 1:]
desc = desc[:i]
computed = DescriptorChecksum(desc)
if computed != checksum:
raise ValueError("The checksum does not match; Got {}, expected {}".format(checksum, computed))
return _parse_descriptor(desc, ctx=_ParseDescriptorContext.TOP)
#####
class NotLegacySinglesigScriptType(Exception): pass
def get_singlesig_descriptor_from_legacy_leaf(*, pubkey: str, script_type: str) -> Optional[Descriptor]:
pubkey = PubkeyProvider.parse(pubkey)
if script_type == 'p2pk':
return PKDescriptor(pubkey=pubkey)
elif script_type == 'p2pkh':
return PKHDescriptor(pubkey=pubkey)
elif script_type == 'p2wpkh':
return WPKHDescriptor(pubkey=pubkey)
elif script_type == 'p2wpkh-p2sh':
wpkh = WPKHDescriptor(pubkey=pubkey)
return SHDescriptor(subdescriptor=wpkh)
else:
raise NotLegacySinglesigScriptType(f"unexpected {script_type=}")
def create_dummy_descriptor_from_address(addr: Optional[str]) -> 'Descriptor':
# It's not possible to tell the script type in general just from an address.
# - "1" addresses are of course p2pkh
# - "3" addresses are p2sh but we don't know the redeem script...
# - "bc1" addresses (if they are 42-long) are p2wpkh
# - "bc1" addresses that are 62-long are p2wsh but we don't know the script...
# If we don't know the script, we _guess_ it is pubkeyhash.
# As this method is used e.g. for tx size estimation,
# the estimation will not be precise.
def guess_script_type(addr: Optional[str]) -> str:
if addr is None:
return 'p2wpkh' # the default guess
witver, witprog = segwit_addr.decode_segwit_address(constants.net.SEGWIT_HRP, addr)
if witprog is not None:
return 'p2wpkh'
addrtype, hash_160_ = bitcoin.b58_address_to_hash160(addr)
if addrtype == constants.net.ADDRTYPE_P2PKH:
return 'p2pkh'
elif addrtype == constants.net.ADDRTYPE_P2SH:
return 'p2wpkh-p2sh'
raise Exception(f'unrecognized address: {repr(addr)}')
script_type = guess_script_type(addr)
# guess pubkey-len to be 33-bytes:
pubkey = ecc.GENERATOR.get_public_key_bytes(compressed=True).hex()
desc = get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey, script_type=script_type)
return desc
================================================
FILE: electrum/dns_hacks.py
================================================
# Copyright (C) 2020 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
import sys
import socket
import concurrent
from concurrent import futures
import ipaddress
import asyncio
import dns
import dns.asyncresolver
from .logging import get_logger
from .util import get_asyncio_loop
from . import util
_logger = get_logger(__name__)
def configure_dns_resolver() -> None:
# Store this somewhere so we can un-monkey-patch:
if not hasattr(socket, "_getaddrinfo"):
socket._getaddrinfo = socket.getaddrinfo
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 https://github.com/spesmilo/electrum/issues/4421
try:
_prepare_windows_dns_hack()
except Exception as e:
_logger.exception('failed to apply windows dns hack.')
else:
socket.getaddrinfo = _fast_getaddrinfo
def _prepare_windows_dns_hack():
# enable dns cache
resolver = dns.asyncresolver.get_default_resolver()
if resolver.cache is None:
resolver.cache = dns.resolver.Cache()
# ensure overall timeout for requests is long enough
resolver.lifetime = max(resolver.lifetime or 1, 30.0)
def _is_force_system_dns_for_host(host: str) -> bool:
return str(host) in ('localhost', 'localhost.',)
def _fast_getaddrinfo(host, *args, **kwargs):
def needs_dns_resolving(host):
try:
ipaddress.ip_address(host)
return False # already valid IP
except ValueError:
pass # not an IP
if _is_force_system_dns_for_host(host):
return False
return True
def resolve_with_dnspython(host):
addrs = []
expected_errors = (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer,
concurrent.futures.CancelledError, concurrent.futures.TimeoutError)
loop = get_asyncio_loop()
assert util.get_running_loop() != loop, 'must not be called from asyncio thread'
ipv6_fut = asyncio.run_coroutine_threadsafe(
dns.asyncresolver.resolve(host, dns.rdatatype.AAAA),
loop,
)
ipv4_fut = asyncio.run_coroutine_threadsafe(
dns.asyncresolver.resolve(host, dns.rdatatype.A),
loop,
)
# try IPv6
try:
answers = ipv6_fut.result()
addrs += [str(answer) for answer in answers]
except expected_errors as e:
pass
except BaseException as e:
_logger.info(f'dnspython failed to resolve dns (AAAA) for {repr(host)} with error: {repr(e)}')
# try IPv4
try:
answers = ipv4_fut.result()
addrs += [str(answer) for answer in answers]
except expected_errors as e:
# dns failed for some reason, e.g. dns.resolver.NXDOMAIN this is normal.
# Simply report back failure; except if we already have some results.
if not addrs:
raise socket.gaierror(11001, 'getaddrinfo failed') from e
except BaseException as e:
# Possibly internal error in dnspython :( see #4483 and #5638
_logger.info(f'dnspython failed to resolve dns (A) for {repr(host)} with error: {repr(e)}')
if addrs:
return addrs
# Fall back to original socket.getaddrinfo to resolve dns.
return [host]
addrs = [host]
if needs_dns_resolving(host):
addrs = resolve_with_dnspython(host)
list_of_list_of_socketinfos = [socket._getaddrinfo(addr, *args, **kwargs) for addr in addrs]
list_of_socketinfos = [item for lst in list_of_list_of_socketinfos for item in lst]
return list_of_socketinfos
================================================
FILE: electrum/dnssec.py
================================================
#!/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.
# Check DNSSEC trust chain.
# Todo: verify expiration dates
#
# Based on
# http://backreference.org/2010/11/17/dnssec-verification-with-dig/
# https://github.com/rthalley/dnspython/blob/master/tests/test_dnssec.py
import logging
import dns
import dns.name
import dns.asyncquery
import dns.dnssec
import dns.message
import dns.asyncresolver
import dns.rdatatype
import dns.rdtypes.ANY.NS
import dns.rdtypes.ANY.CNAME
import dns.rdtypes.ANY.DLV
import dns.rdtypes.ANY.DNSKEY
import dns.rdtypes.ANY.DS
import dns.rdtypes.ANY.NSEC
import dns.rdtypes.ANY.NSEC3
import dns.rdtypes.ANY.NSEC3PARAM
import dns.rdtypes.ANY.RRSIG
import dns.rdtypes.ANY.SOA
import dns.rdtypes.ANY.TXT
import dns.rdtypes.IN.A
import dns.rdtypes.IN.AAAA
from .logging import get_logger
from typing import Tuple
_logger = get_logger(__name__)
# hard-coded trust anchors (root KSKs)
trust_anchors = [
# KSK-2017:
dns.rrset.from_text('.', 1 , 'IN', 'DNSKEY', '257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU='),
# KSK-2010:
dns.rrset.from_text('.', 15202, 'IN', 'DNSKEY', '257 3 8 AwEAAagAIKlVZrpC6Ia7gEzahOR+9W29euxhJhVVLOyQbSEW0O8gcCjF FVQUTf6v58fLjwBd0YI0EzrAcQqBGCzh/RStIoO8g0NfnfL2MTJRkxoX bfDaUeVPQuYEhg37NZWAJQ9VnMVDxP/VHL496M/QZxkjf5/Efucp2gaD X6RS6CXpoY68LsvPVjR0ZSwzz1apAzvN9dlzEheX7ICJBBtuA6G3LQpz W5hOA2hzCTMjJPJ8LbqF6dsV6DoBQzgul0sGIcGOYl7OyQdXfZ57relS Qageu+ipAdTTJ25AsRTAoub8ONGcLmqrAmRLKBP1dfwhYB4N7knNnulq QxA+Uk1ihz0='),
]
async def _check_query(ns, sub, _type, keys) -> dns.rrset.RRset:
q = dns.message.make_query(sub, _type, want_dnssec=True)
response = await dns.asyncquery.tcp(q, ns, timeout=5)
assert response.rcode() == 0, 'No answer'
answer = response.answer
assert len(answer) != 0, ('No DNS record found', sub, _type)
assert len(answer) != 1, ('No DNSSEC record found', sub, _type)
if answer[0].rdtype == dns.rdatatype.RRSIG:
rrsig, rrset = answer
elif answer[1].rdtype == dns.rdatatype.RRSIG:
rrset, rrsig = answer
else:
raise Exception('No signature set in record')
if keys is None:
keys = {dns.name.from_text(sub):rrset}
dns.dnssec.validate(rrset, rrsig, keys)
return rrset
async def _get_and_validate(ns, url, _type) -> dns.rrset.RRset:
# get trusted root key
root_rrset = None
for dnskey_rr in trust_anchors:
try:
# Check if there is a valid signature for the root dnskey
root_rrset = await _check_query(ns, '', dns.rdatatype.DNSKEY, {dns.name.root: dnskey_rr})
break
except dns.dnssec.ValidationFailure:
# It's OK as long as one key validates
continue
if not root_rrset:
raise dns.dnssec.ValidationFailure('None of the trust anchors found in DNS')
keys = {dns.name.root: root_rrset}
# top-down verification
parts = url.split('.')
for i in range(len(parts), 0, -1):
sub = '.'.join(parts[i-1:])
name = dns.name.from_text(sub)
# If server is authoritative, don't fetch DNSKEY
query = dns.message.make_query(sub, dns.rdatatype.NS)
response = await dns.asyncquery.udp(query, ns, 3)
assert response.rcode() == dns.rcode.NOERROR, "query error"
rrset = response.authority[0] if len(response.authority) > 0 else response.answer[0]
rr = rrset[0]
if rr.rdtype == dns.rdatatype.SOA:
continue
# get DNSKEY (self-signed)
rrset = await _check_query(ns, sub, dns.rdatatype.DNSKEY, None)
# get DS (signed by parent)
ds_rrset = await _check_query(ns, sub, dns.rdatatype.DS, keys)
# verify that a signed DS validates DNSKEY
for ds in ds_rrset:
for dnskey in rrset:
htype = 'SHA256' if ds.digest_type == 2 else 'SHA1'
good_ds = dns.dnssec.make_ds(name, dnskey, htype)
if ds == good_ds:
break
else:
continue
break
else:
raise Exception("DS does not match DNSKEY")
# set key for next iteration
keys = {name: rrset}
# get TXT record (signed by zone)
rrset = await _check_query(ns, url, _type, keys)
return rrset
async def query(url: str, rtype: dns.rdatatype.RdataType) -> Tuple[dns.rrset.RRset, bool]:
"""Try to do DNS resolution, including DNSSEC.
'validated' shows whether the DNSSEC checks passed. DNS is completely INSECURE without DNSSEC,
so the caller must carefully consider whether the response can be used for anything if validated=False.
"""
# FIXME this method is not using the network proxy. (although the proxy might not support UDP?)
# 8.8.8.8 is Google's public DNS server
nameservers = ['8.8.8.8']
ns = nameservers[0]
try:
out = await _get_and_validate(ns, url, rtype)
validated = True
except Exception as e:
log_level = logging.WARNING if isinstance(e, ImportError) else logging.INFO
_logger.log(log_level, f"DNSSEC error: {repr(e)}")
out = await dns.asyncresolver.resolve(url, rtype)
validated = False
return out, validated
================================================
FILE: electrum/exchange_rate.py
================================================
import asyncio
from datetime import datetime
import inspect
import sys
import os
import json
import time
import csv
import decimal
from decimal import Decimal
from typing import Sequence, Optional, Mapping, Dict, Union, Tuple
from aiorpcx.curio import timeout_after, ignore_after
import aiohttp
from . import util
from .bitcoin import COIN
from .i18n import _
from .util import (
ThreadJob, make_dir, log_exceptions, OldTaskGroup, make_aiohttp_session, resource_path, EventListener,
event_listener, to_decimal, timestamp_to_datetime
)
from .util import NetworkRetryManager
from .network import Network
from .simple_config import SimpleConfig
from .logging import Logger
# 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,
# Cryptocurrencies
'BTC': 8, 'LTC': 6, 'XRP': 4, 'ETH': 8,
}
SPOT_RATE_REFRESH_TARGET = 150 # approx. every 2.5 minutes, try to refresh spot price
SPOT_RATE_CLOSE_TO_STALE = 450 # try harder to fetch an update if price is getting old
SPOT_RATE_EXPIRY = 600 # spot price becomes stale after 10 minutes -> we no longer show/use it
class ExchangeBase(Logger):
def __init__(self, on_quotes, on_history):
Logger.__init__(self)
self._history = {} # type: Dict[str, Dict[str, str | float]]
self._quotes = {} # type: Dict[str, Optional[Decimal]]
self._quotes_timestamp = 0 # type: Union[int, float]
self.on_quotes = on_quotes
self.on_history = on_history
async def get_raw(self, site, get_string):
# APIs must have https
url = ''.join(['https://', site, get_string])
network = Network.get_instance()
proxy = network.proxy if network else None
async with make_aiohttp_session(proxy) as session:
async with session.get(url) as response:
response.raise_for_status()
return await response.text()
async def get_json(self, site, get_string):
# APIs must have https
url = ''.join(['https://', site, get_string])
network = Network.get_instance()
proxy = network.proxy if network else None
async with make_aiohttp_session(proxy) as session:
async with session.get(url) as response:
response.raise_for_status()
# set content_type to None to disable checking MIME type
return await response.json(content_type=None)
async def get_csv(self, site, get_string):
raw = await self.get_raw(site, get_string)
reader = csv.DictReader(raw.split('\n'))
return list(reader)
def name(self):
return self.__class__.__name__
async def update_safe(self, ccy: str) -> None:
try:
self.logger.info(f"getting fx quotes for {ccy}")
self._quotes = await self.get_rates(ccy)
assert all(isinstance(rate, (Decimal, type(None))) for rate in self._quotes.values()), \
f"fx rate must be Decimal, got {self._quotes}"
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
self.logger.info(f"failed fx quotes: {repr(e)}")
self.on_quotes()
except Exception as e:
self.logger.exception(f"failed fx quotes: {repr(e)}")
self.on_quotes()
else:
self.logger.debug("received fx quotes")
self._quotes_timestamp = time.time()
self.on_quotes(received_new_data=True)
@staticmethod
def _read_historical_rates_from_file(
*, exchange_name: str, ccy: str, cache_dir: str,
) -> Tuple[Optional[Dict[str, str]], Optional[float]]:
filename = os.path.join(cache_dir, f"{exchange_name}_{ccy}")
if not os.path.exists(filename):
return None, None
timestamp = os.stat(filename).st_mtime
try:
with open(filename, 'r', encoding='utf-8') as f:
h = json.loads(f.read())
except Exception:
return None, None
if not h: # e.g. empty dict
return None, None
# cast rates to str
h = {date_str: str(rate) for (date_str, rate) in h.items()}
return h, timestamp
def read_historical_rates(self, ccy: str, cache_dir: str) -> Optional[dict]:
h, timestamp = self._read_historical_rates_from_file(
exchange_name=self.name(),
ccy=ccy,
cache_dir=cache_dir,
)
if not h:
return None
assert timestamp is not None
h['timestamp'] = timestamp
self._history[ccy] = h
self.on_history()
return h
@staticmethod
def _write_historical_rates_to_file(
*, exchange_name: str, ccy: str, cache_dir: str, history: Dict[str, str],
) -> None:
# sanity check types of history dict
assert 'timestamp' not in history
for key, rate in history.items():
assert isinstance(key, str), f"{exchange_name=}. {ccy=}. {key=!r}. {rate=!r}"
assert isinstance(rate, str), f"{exchange_name=}. {ccy=}. {key=!r}. {rate=!r}"
# write to file
filename = os.path.join(cache_dir, f"{exchange_name}_{ccy}")
with open(filename, 'w', encoding='utf-8') as f:
f.write(json.dumps(history, sort_keys=True))
@log_exceptions
async def get_historical_rates_safe(self, ccy: str, cache_dir: str) -> None:
try:
self.logger.info(f"requesting fx history for {ccy}")
h_new = await self.request_history(ccy)
self.logger.debug(f"received fx history for {ccy}")
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
self.logger.info(f"failed fx history: {repr(e)}")
return
except Exception as e:
self.logger.exception(f"failed fx history: {repr(e)}")
return
# cast rates to str
h_new = {date_str: str(rate) for (date_str, rate) in h_new.items()} # type: Dict[str, str]
# merge old history and new history. resolve duplicate dates using new data.
h_old, _timestamp = self._read_historical_rates_from_file(
exchange_name=self.name(), ccy=ccy, cache_dir=cache_dir,
)
h_old = h_old or {}
h = {**h_old, **h_new}
# write merged data to disk cache
self._write_historical_rates_to_file(
exchange_name=self.name(), ccy=ccy, cache_dir=cache_dir, history=h,
)
h['timestamp'] = time.time() # note: this is the only item in h that has a float value
self._history[ccy] = h
self.on_history()
def get_historical_rates(self, ccy: str, cache_dir: str) -> None:
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:
util.get_asyncio_loop().create_task(self.get_historical_rates_safe(ccy, cache_dir))
def history_ccys(self) -> Sequence[str]:
return []
def historical_rate(self, ccy: str, d_t: datetime) -> Decimal:
date_str = d_t.strftime('%Y-%m-%d')
rate = self._history.get(ccy, {}).get(date_str) or 'NaN'
try:
return Decimal(rate)
except Exception: # guard against garbage coming from exchange
#self.logger.debug(f"found corrupted historical_rate: {rate=!r}. for {ccy=} at {date_str}")
return Decimal('NaN')
async def request_history(self, ccy: str) -> Dict[str, Union[str, float]]:
raise NotImplementedError() # implemented by subclasses
async def get_rates(self, ccy: str) -> Mapping[str, Optional[Decimal]]:
raise NotImplementedError() # implemented by subclasses
async def get_currencies(self) -> Sequence[str]:
rates = await self.get_rates('')
return sorted([str(a) for (a, b) in rates.items() if b is not None and len(a)==3])
def get_cached_spot_quote(self, ccy: str) -> Decimal:
"""Returns the cached exchange rate as a Decimal"""
if ccy == 'BTC':
return Decimal(1)
rate = self._quotes.get(ccy)
if not rate: # don't return 0 to prevent DivisionByZero exceptions
return Decimal('NaN')
if self._quotes_timestamp + SPOT_RATE_EXPIRY < time.time():
# Our rate is stale. Probably better to return no rate than an incorrect one.
return Decimal('NaN')
return Decimal(rate)
class Yadio(ExchangeBase):
async def get_currencies(self):
dicts = await self.get_json('api.yadio.io', '/currencies')
return list(dicts.keys())
async def get_rates(self, ccy: str) -> Mapping[str, Optional[Decimal]]:
json = await self.get_json('api.yadio.io', '/rate/%s/BTC' % ccy)
return {ccy: to_decimal(json['rate'])}
class BitcoinAverage(ExchangeBase):
# note: historical rates used to be freely available
# but this is no longer the case. see #5188
async def get_rates(self, ccy):
json = await self.get_json('apiv2.bitcoinaverage.com', '/indices/global/ticker/short')
return dict([(r.replace("BTC", ""), to_decimal(json[r]['last']))
for r in json if r != 'timestamp'])
class Bitcointoyou(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('bitcointoyou.com', "/API/ticker.aspx")
return {'BRL': to_decimal(json['ticker']['last'])}
class BitcoinVenezuela(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('api.bitcoinvenezuela.com', '/')
rates = [(r, to_decimal(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']
async def request_history(self, ccy):
json = await self.get_json('api.bitcoinvenezuela.com', "/historical/index.php?coin=BTC")
return json[ccy + '_BTC']
class Bitbank(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('public.bitbank.cc', '/btc_jpy/ticker')
return {'JPY': to_decimal(json['data']['last'])}
class BitFinex(ExchangeBase):
async def get_currencies(self):
json = await self.get_json(
'api-pub.bitfinex.com',
f"/v2/conf/pub:list:pair:exchange")
pairs = [pair for pair in json[0]
if len(pair) == 6 and pair[:3] == "BTC"]
return [pair[3:] for pair in pairs]
def history_ccys(self):
return CURRENCIES[self.name()]
async def get_rates(self, ccy):
# ref https://docs.bitfinex.com/reference/rest-public-ticker
json = await self.get_json(
'api-pub.bitfinex.com',
f"/v2/ticker/tBTC{ccy}")
return {ccy: to_decimal(json[6])}
async def request_history(self, ccy):
# ref https://docs.bitfinex.com/reference/rest-public-candles
history = await self.get_json(
'api.bitfinex.com',
f"/v2/candles/trade:1D:tBTC{ccy}/hist?limit=10000")
return dict([(timestamp_to_datetime(h[0] // 1000, utc=True).strftime('%Y-%m-%d'), str(h[2]))
for h in history])
class BitFlyer(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('bitflyer.jp', '/api/echo/price')
return {'JPY': to_decimal(json['mid'])}
class BitPay(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('bitpay.com', '/api/rates')
return dict([(r['code'], to_decimal(r['rate'])) for r in json])
class Bitso(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('api.bitso.com', '/v2/ticker')
return {'MXN': to_decimal(json['last'])}
class BitStamp(ExchangeBase):
async def get_currencies(self):
# ref https://www.bitstamp.net/api/#tag/Tickers/operation/GetCurrencyPairTickers
json = await self.get_json(
'www.bitstamp.net',
f"/api/v2/ticker/")
pairs = [ticker["pair"] for ticker in json]
pairs = [pair for pair in pairs
if len(pair) == 7 and pair[:4] == "BTC/"]
return [pair[4:] for pair in pairs]
async def get_rates(self, ccy):
# ref https://www.bitstamp.net/api/#tag/Tickers/operation/GetMarketTicker
if ccy in CURRENCIES[self.name()]:
json = await self.get_json('www.bitstamp.net', f'/api/v2/ticker/btc{ccy.lower()}/')
return {ccy: to_decimal(json['last'])}
return {}
def history_ccys(self):
return CURRENCIES[self.name()]
async def request_history(self, ccy):
# ref https://www.bitstamp.net/api/#tag/Market-info/operation/GetOHLCData
merged_history = {}
history_starts = 1313625600 # for BTCUSD pair (probably earliest)
items_per_request = 1000
step = 86400
async def populate_history(endtime: int):
history = await self.get_json(
'www.bitstamp.net',
f"/api/v2/ohlc/btc{ccy.lower()}/?step={step}&limit={items_per_request}&end={endtime}")
history = dict([
(timestamp_to_datetime(int(h["timestamp"]), utc=True).strftime('%Y-%m-%d'), str(h["close"]))
for h in history["data"]["ohlc"]])
merged_history.update(history)
async with OldTaskGroup() as group:
endtime = int(time.time())
while True:
if endtime < history_starts:
break
await group.spawn(populate_history(endtime=endtime))
endtime = endtime - items_per_request * step
return merged_history
class Bitvalor(ExchangeBase):
async def get_rates(self,ccy):
json = await self.get_json('api.bitvalor.com', '/v1/ticker.json')
return {'BRL': to_decimal(json['ticker_1h']['total']['last'])}
class BlockchainInfo(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('blockchain.info', '/ticker')
return dict([(r, to_decimal(json[r]['15m'])) for r in json])
class Bylls(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('bylls.com', '/api/price?from_currency=BTC&to_currency=CAD')
return {'CAD': to_decimal(json['public_price']['to_price'])}
class Coinbase(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('api.coinbase.com',
'/v2/exchange-rates?currency=BTC')
return {ccy: to_decimal(rate) for (ccy, rate) in json["data"]["rates"].items()}
class CoinCap(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('api.coincap.io', '/v2/rates/bitcoin/')
return {'USD': to_decimal(json['data']['rateUsd'])}
def history_ccys(self):
return ['USD']
async def request_history(self, ccy):
# Currently 2000 days is the maximum in 1 API call
# (and history starts on 2017-03-23)
history = await self.get_json('api.coincap.io',
'/v2/assets/bitcoin/history?interval=d1&limit=2000')
return dict([(timestamp_to_datetime(h['time']/1000, utc=True).strftime('%Y-%m-%d'), str(h['priceUsd']))
for h in history['data']])
class CoinDesk(ExchangeBase):
async def get_currencies(self):
dicts = await self.get_json('api.coindesk.com',
'/v1/bpi/supported-currencies.json')
return [d['currency'] for d in dicts]
async def get_rates(self, ccy):
json = await self.get_json('api.coindesk.com',
'/v1/bpi/currentprice/%s.json' % ccy)
result = {ccy: to_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()
async 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 = await self.get_json('api.coindesk.com', query)
return json['bpi']
class CoinGecko(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('api.coingecko.com', '/api/v3/exchange_rates')
return dict([(ccy.upper(), to_decimal(d['value']))
for ccy, d in json['rates'].items()])
def history_ccys(self):
# CoinGecko seems to have historical data for all ccys it supports
return CURRENCIES[self.name()]
async def request_history(self, ccy):
# ref https://docs.coingecko.com/v3.0.1/reference/coins-id-market-chart
num_days = 365
# Setting `num_days = "max"` started erroring (around 2024-04) with:
# > Your request exceeds the allowed time range. Public API users are limited to querying
# > historical data within the past 365 days. Upgrade to a paid plan to enjoy full historical data access
history = await self.get_json('api.coingecko.com',
f"/api/v3/coins/bitcoin/market_chart?vs_currency={ccy}&days={num_days}")
return dict([(timestamp_to_datetime(h[0]/1000, utc=True).strftime('%Y-%m-%d'), str(h[1]))
for h in history['prices']])
class Bit2C(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('bit2c.co.il', '/Exchanges/BtcNis/Ticker.json')
return {'ILS': to_decimal(json['ll'])}
def history_ccys(self):
return CURRENCIES[self.name()]
async def request_history(self, ccy):
history = await self.get_json('bit2c.co.il',
'/Exchanges/BtcNis/KLines?resolution=1D&from=1357034400&to=%s' % int(time.time()))
return dict([(timestamp_to_datetime(h[0], utc=True).strftime('%Y-%m-%d'), str(h[6]))
for h in history])
class CointraderMonitor(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('cointradermonitor.com', '/api/pbb/v1/ticker')
return {'BRL': to_decimal(json['last'])}
class itBit(ExchangeBase):
async def get_rates(self, ccy):
ccys = ['USD', 'EUR', 'SGD']
json = await self.get_json('api.itbit.com', '/v1/markets/XBT%s/ticker' % ccy)
result = dict.fromkeys(ccys)
if ccy in ccys:
result[ccy] = to_decimal(json['lastPrice'])
return result
class Kraken(ExchangeBase):
async def get_rates(self, ccy):
# ref https://docs.kraken.com/api/docs/rest-api/get-ticker-information
ccys = ['EUR', 'USD', 'CAD', 'GBP', 'JPY']
pairs = ['XBT%s' % c for c in ccys]
json = await self.get_json('api.kraken.com',
'/0/public/Ticker?pair=%s' % ','.join(pairs))
return dict((k[-3:], to_decimal(v['c'][0]))
for k, v in json['result'].items())
# async def request_history(self, ccy):
# # ref https://docs.kraken.com/api/docs/rest-api/get-ohlc-data
# pass # limited to last 720 steps (step can by 1 day / 7 days / 15 days)
class MercadoBitcoin(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('api.bitvalor.com', '/v1/ticker.json')
return {'BRL': to_decimal(json['ticker_1h']['exchanges']['MBT']['last'])}
class Winkdex(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('winkdex.com', '/api/v0/price')
return {'USD': to_decimal(json['price']) / 100}
def history_ccys(self):
return ['USD']
async def request_history(self, ccy):
json = await self.get_json('winkdex.com',
"/api/v0/series?start_time=1342915200")
history = json['series'][0]['results']
return dict([(h['timestamp'][:10], str(to_decimal(h['price']) / 100))
for h in history])
class Zaif(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('api.zaif.jp', '/api/1/last_price/btc_jpy')
return {'JPY': to_decimal(json['last_price'])}
class Bitragem(ExchangeBase):
async def get_rates(self,ccy):
json = await self.get_json('api.bitragem.com', '/v1/index?asset=BTC&market=BRL')
return {'BRL': to_decimal(json['response']['index'])}
class Biscoint(ExchangeBase):
async def get_rates(self,ccy):
json = await self.get_json('api.biscoint.io', '/v1/ticker?base=BTC"e=BRL')
return {'BRL': to_decimal(json['data']['last'])}
class Walltime(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('s3.amazonaws.com',
'/data-production-walltime-info/production/dynamic/walltime-info.json')
return {'BRL': to_decimal(json['BRL_XBT']['last_inexact'])}
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():
# load currencies.json from disk
path = resource_path('currencies.json')
try:
with open(path, 'r', encoding='utf-8') as f:
return json.loads(f.read())
except Exception:
pass
# or if not present, generate it now.
print("cannot find currencies.json. will regenerate it now.")
d = {}
is_exchange = lambda obj: (inspect.isclass(obj)
and issubclass(obj, ExchangeBase)
and obj != ExchangeBase)
exchanges = dict(inspect.getmembers(sys.modules[__name__], is_exchange))
async def get_currencies_safe(name, exchange):
try:
d[name] = await exchange.get_currencies()
print(name, "ok")
except Exception:
print(name, "error")
async def query_all_exchanges_for_their_ccys_over_network():
async with timeout_after(10):
async with OldTaskGroup() as group:
for name, klass in exchanges.items():
exchange = klass(None, None)
await group.spawn(get_currencies_safe(name, exchange))
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(query_all_exchanges_for_their_ccys_over_network())
except Exception as e:
pass
finally:
loop.close()
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, EventListener, NetworkRetryManager[str]):
def __init__(self, *, config: SimpleConfig):
ThreadJob.__init__(self)
NetworkRetryManager.__init__(
self,
max_retry_delay_normal=SPOT_RATE_REFRESH_TARGET,
init_retry_delay_normal=SPOT_RATE_REFRESH_TARGET,
max_retry_delay_urgent=SPOT_RATE_REFRESH_TARGET,
init_retry_delay_urgent=1,
) # note: we poll every 5 seconds for action, so we won't attempt connections more frequently than that.
self.config = config
self.register_callbacks()
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') # type: str
self._trigger = asyncio.Event()
self._trigger.set()
self.set_exchange(self.config_exchange())
make_dir(self.cache_dir)
@event_listener
def on_event_proxy_set(self, *args):
self._clear_addr_retry_times()
self._trigger.set()
@staticmethod
def get_currencies(history: bool) -> Sequence[str]:
d = get_exchanges_by_ccy(history)
return sorted(d.keys())
@staticmethod
def get_exchanges_by_ccy(ccy: str, history: bool) -> Sequence[str]:
d = get_exchanges_by_ccy(history)
return d.get(ccy, [])
@staticmethod
def remove_thousands_separator(text: str) -> str:
return text.replace(util.THOUSANDS_SEP, "")
def ccy_amount_str(self, amount, *, add_thousands_sep: bool = False, ccy=None) -> str:
prec = CCY_PRECISIONS.get(self.ccy if ccy is None else ccy, 2)
fmt_str = "{:%s.%df}" % ("," if add_thousands_sep else "", max(0, prec))
try:
rounded_amount = round(amount, prec)
except decimal.InvalidOperation:
rounded_amount = amount
text = fmt_str.format(rounded_amount)
# replace "," -> THOUSANDS_SEP
# replace "." -> DECIMAL_POINT
dp_loc = text.find(".")
text = text.replace(",", util.THOUSANDS_SEP)
if dp_loc == -1:
return text
return text[:dp_loc] + util.DECIMAL_POINT + text[dp_loc+1:]
def ccy_precision(self, ccy=None) -> int:
return CCY_PRECISIONS.get(self.ccy if ccy is None else ccy, 2)
async def run(self):
while True:
# keep polling and see if we should refresh spot price or historical prices
manually_triggered = False
async with ignore_after(5):
await self._trigger.wait()
self._trigger.clear()
manually_triggered = True
if not self.is_enabled():
continue
if manually_triggered and self.has_history(): # maybe refresh historical prices
self.exchange.get_historical_rates(self.ccy, self.cache_dir)
now = time.time()
if not manually_triggered and self.exchange._quotes_timestamp + SPOT_RATE_REFRESH_TARGET > now:
continue # last quote still fresh
# If the last quote is relatively recent, we poll at fixed time intervals.
# Once it gets close to cache expiry, we change to an exponential backoff, to try to get
# a quote before it expires. Also, on Android, we might come back from a sleep after a long time,
# with the last quote close to expiry or already expired, in that case we go into exponential backoff.
is_urgent = self.exchange._quotes_timestamp + SPOT_RATE_CLOSE_TO_STALE < now
addr_name = "spot-urgent" if is_urgent else "spot" # this separates retry-counters
if self._can_retry_addr(addr_name, urgent=is_urgent):
self._trying_addr_now(addr_name)
# refresh spot price
await self.exchange.update_safe(self.ccy)
def is_enabled(self) -> bool:
return self.config.FX_USE_EXCHANGE_RATE
def set_enabled(self, b: bool) -> None:
self.config.FX_USE_EXCHANGE_RATE = b
self.trigger_update()
def can_have_history(self):
return self.is_enabled() and self.ccy in self.exchange.history_ccys()
def has_history(self) -> bool:
return self.can_have_history() and self.config.FX_HISTORY_RATES
def get_currency(self) -> str:
'''Use when dynamic fetching is needed'''
return self.config.FX_CURRENCY
def config_exchange(self):
return self.config.FX_EXCHANGE
def set_currency(self, ccy: str):
self.ccy = ccy
self.config.FX_CURRENCY = ccy
self.trigger_update()
self.on_quotes()
def trigger_update(self):
self._clear_addr_retry_times()
loop = util.get_asyncio_loop()
loop.call_soon_threadsafe(self._trigger.set)
def set_exchange(self, name):
class_ = globals().get(name) or globals().get(self.config.cv.FX_EXCHANGE.get_default_value())
self.logger.info(f"using exchange {name}")
if self.config_exchange() != name:
self.config.FX_EXCHANGE = name
assert issubclass(class_, ExchangeBase), f"unexpected type {class_} for {name}"
self.exchange = class_(self.on_quotes, self.on_history) # type: ExchangeBase
# A new exchange means new fx quotes, initially empty. Force
# a quote refresh
self.trigger_update()
self.exchange.read_historical_rates(self.ccy, self.cache_dir)
def on_quotes(self, *, received_new_data: bool = False):
if received_new_data:
self._clear_addr_retry_times()
util.trigger_callback('on_quotes')
def on_history(self):
util.trigger_callback('on_history')
def exchange_rate(self) -> Decimal:
"""Returns the exchange rate as a Decimal"""
if not self.is_enabled():
return Decimal('NaN')
return self.exchange.get_cached_spot_quote(self.ccy)
def format_amount(self, btc_balance, *, timestamp: int = None) -> str:
if timestamp is None:
rate = self.exchange_rate()
else:
rate = self.timestamp_rate(timestamp)
return '' if rate.is_nan() else "%s" % self.value_str(btc_balance, rate)
def format_amount_and_units(self, btc_balance, *, timestamp: int = None) -> str:
if timestamp is None:
rate = self.exchange_rate()
else:
rate = self.timestamp_rate(timestamp)
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()
if rate.is_nan():
return _(" (No FX rate available)")
amount = 1000 if decimal_point == 0 else 1
value = self.value_str(amount * COIN / (10**(8 - decimal_point)), rate)
return " %d %s~%s %s" % (amount, base_unit, value, self.ccy)
def fiat_value(self, satoshis, rate) -> Decimal:
return Decimal('NaN') if satoshis is None else Decimal(satoshis) / COIN * Decimal(rate)
def value_str(self, satoshis, rate, *, add_thousands_sep: bool = None) -> str:
fiat_val = self.fiat_value(satoshis, rate)
return self.format_fiat(fiat_val, add_thousands_sep=add_thousands_sep)
def format_fiat(self, value: Decimal, *, add_thousands_sep: bool = None) -> str:
if value.is_nan():
return _("No data")
if add_thousands_sep is None:
add_thousands_sep = True
return self.ccy_amount_str(value, add_thousands_sep=add_thousands_sep)
def history_rate(self, d_t: Optional[datetime]) -> Decimal:
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.is_nan() and (datetime.today().date() - d_t.date()).days <= 2:
rate = self.exchange.get_cached_spot_quote(self.ccy)
self.history_used_spot = True
if rate is None:
rate = 'NaN'
return Decimal(rate)
def historical_value_str(self, satoshis, d_t: Optional[datetime]) -> str:
return self.format_fiat(self.historical_value(satoshis, d_t))
def historical_value(self, satoshis, d_t: Optional[datetime]) -> Decimal:
return self.fiat_value(satoshis, self.history_rate(d_t))
def timestamp_rate(self, timestamp: Optional[int]) -> Decimal:
from .util import timestamp_to_datetime
date = timestamp_to_datetime(timestamp)
return self.history_rate(date)
assert globals().get(SimpleConfig.FX_EXCHANGE.get_default_value()), f"default exchange {SimpleConfig.FX_EXCHANGE.get_default_value()} does not exist"
================================================
FILE: electrum/fee_policy.py
================================================
from typing import Optional, Sequence, Tuple, Union, TYPE_CHECKING, Dict
from decimal import Decimal
from numbers import Real
from enum import IntEnum
import math
from .i18n import _
from .util import NoDynamicFeeEstimates, quantize_feerate, format_fee_satoshis, FEERATE_PRECISION
from . import util, constants
from .logging import Logger
if TYPE_CHECKING:
from .network import Network
# 1008 = max conf target of core's estimatesmartfee, requesting more results in rpc error.
# estimatesmartfee guarantees that the fee will get accepted into the mempool
FEE_ETA_TARGETS = [1008, 144, 25, 10, 5, 2, 1]
FEE_DEPTH_TARGETS = [10_000_000, 5_000_000, 2_000_000, 1_000_000,
800_000, 600_000, 400_000, 250_000, 100_000]
FEERATE_STATIC_VALUES = [1000, 2000, 5000, 10000, 20000, 30000,
50000, 70000, 100000, 150000, 200000, 300000]
# satoshi per kbyte
FEERATE_MAX_DYNAMIC = 1500000
FEERATE_WARNING_HIGH_FEE = 600000
FEERATE_FALLBACK_STATIC_FEE = 150000
FEERATE_REGTEST_STATIC_FEE = FEERATE_FALLBACK_STATIC_FEE # hardcoded fee used on regtest
FEERATE_MIN_RELAY = 100
FEERATE_DEFAULT_RELAY = 1000 # conservative "min relay fee"
FEERATE_MAX_RELAY = 50000
assert FEERATE_MIN_RELAY <= FEERATE_DEFAULT_RELAY <= FEERATE_MAX_RELAY
# warn user if fee/amount for on-chain tx is higher than this
FEE_RATIO_HIGH_WARNING = 0.05
# note: make sure the network is asking for estimates for these targets
FEE_LN_ETA_TARGET = 2
FEE_LN_LOW_ETA_TARGET = 25
FEE_LN_MINIMUM_ETA_TARGET = 1008
# The min feerate_per_kw that can be used in lightning so that
# the resulting onchain tx pays the min relay fee.
# This would be FEERATE_DEFAULT_RELAY / 4 if not for rounding errors,
# see https://github.com/ElementsProject/lightning/commit/2e687b9b352c9092b5e8bd4a688916ac50b44af0
FEERATE_PER_KW_MIN_RELAY_LIGHTNING = 253
def closest_index(value, array) -> int:
dist = list(map(lambda x: abs(x - value), array))
return min(range(len(dist)), key=dist.__getitem__)
class FeeMethod(IntEnum):
# note: careful changing these names! they appear in the config files.
FIXED = 0 # fixed absolute fee
FEERATE = 1 # fixed fee rate
ETA = 2 # dynamic, ETA based
MEMPOOL = 3 # dynamic, mempool based
@classmethod
def slider_values(cls):
return [FeeMethod.FEERATE, FeeMethod.ETA, FeeMethod.MEMPOOL]
def name_for_GUI(self):
names = {
FeeMethod.FIXED: _('FIXED'),
FeeMethod.FEERATE: _('Feerate'),
FeeMethod.ETA: _('ETA'),
FeeMethod.MEMPOOL: _('Mempool')
}
return names[self]
@classmethod
def slider_index_of_method(cls, method):
try:
i = FeeMethod.slider_values().index(method)
except ValueError:
i = -1
return i
class FeePolicy(Logger):
# object associated to a fee slider
def __init__(self, descriptor: str):
Logger.__init__(self)
try:
name, value = descriptor.split(':')
self.method = FeeMethod[name.upper()]
self.value = int(value) # target (e.g. num blocks, nbytes from mempool tip, sat/kbyte)
except Exception:
self.logger.warning(f"Could not parse fee policy descriptor '{descriptor}'. Falling back to 'eta:2'")
self.method = FeeMethod.ETA
self.value = 2
def __repr__(self):
return self.get_descriptor()
def get_descriptor(self) -> str:
return self.method.name.lower() + ':' + str(self.value)
def set_method(self, method: FeeMethod):
assert isinstance(method, FeeMethod)
self.method = method
# default values
if self.method == FeeMethod.MEMPOOL:
self.value = 1000000 # 1 mb from tip
elif self.method == FeeMethod.ETA:
self.value = 2 # 2 blocks
elif self.method == FeeMethod.FEERATE:
self.value = 5000 # sats per vkb
else:
self.value = 10 # sats
def _get_array(self) -> Sequence[int]:
if self.method == FeeMethod.MEMPOOL:
return FEE_DEPTH_TARGETS
elif self.method == FeeMethod.ETA:
return FEE_ETA_TARGETS
elif self.method == FeeMethod.FEERATE:
return FEERATE_STATIC_VALUES
else:
raise Exception('')
def set_value_from_slider_pos(self, slider_pos: int):
array = self._get_array()
slider_pos = max(0, min(slider_pos, len(array)-1))
self.value = array[slider_pos]
def get_slider_pos(self) -> int:
array = self._get_array()
return closest_index(self.value, array)
def get_slider_max(self) -> int:
array = self._get_array()
maxp = len(array) - 1
return maxp
@property
def use_dynamic_estimates(self):
return self.method in [FeeMethod.ETA, FeeMethod.MEMPOOL]
@classmethod
def depth_target(cls, slider_pos: int) -> int:
"""Returns mempool depth target in bytes for a fee slider position."""
slider_pos = max(slider_pos, 0)
slider_pos = min(slider_pos, len(FEE_DEPTH_TARGETS)-1)
return FEE_DEPTH_TARGETS[slider_pos]
def eta_target(self, slider_pos: int) -> int:
"""Returns 'num blocks' ETA target for a fee slider position."""
return FEE_ETA_TARGETS[slider_pos]
@classmethod
def eta_tooltip(cls, x):
if x < 0:
return _('Low fee')
elif x == 1:
return _('In the next block')
elif x == 144:
return _('Within one day')
elif x == 1008:
return _("Within one week")
else:
return _('Within {} blocks').format(x)
def get_target_text(self):
""" Description of what the target is: static fee / num blocks to confirm in / mempool depth """
if self.method == FeeMethod.ETA:
return self.eta_tooltip(self.value)
elif self.method == FeeMethod.MEMPOOL:
return self.depth_tooltip(self.value)
elif self.method == FeeMethod.FEERATE:
fee_per_byte = self.value/1000
return format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE}"
elif self.method == FeeMethod.FIXED:
return f'{self.value} {util.UI_UNIT_NAME_FIXED_SAT}'
def get_estimate_text(self, network: 'Network') -> str:
"""
Description of the current fee estimate corresponding to the target
"""
fee_per_kb = self.fee_per_kb(network)
fee_per_byte = fee_per_kb/1000 if fee_per_kb is not None else None
tooltip = ''
if self.use_dynamic_estimates:
if fee_per_byte is not None:
tooltip = format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE}"
elif self.method == FeeMethod.FEERATE:
assert fee_per_kb is not None
assert fee_per_byte is not None
if network and network.mempool_fees.has_data():
depth = network.mempool_fees.fee_to_depth(fee_per_byte)
tooltip = self.depth_tooltip(depth)
if network and network.fee_estimates.has_data():
eta = network.fee_estimates.fee_to_eta(fee_per_kb)
tooltip += '\n' + self.eta_tooltip(eta)
return tooltip
def get_tooltip(self, network: 'Network'):
target = self.get_target_text()
estimate = self.get_estimate_text(network)
if self.use_dynamic_estimates:
return _('Target') + ': ' + target + '\n' + _('Current rate') + ': ' + estimate
else:
return _('Fixed rate') + ': ' + target + '\n' + _('Estimate') + ': ' + estimate
@classmethod
def depth_tooltip(cls, depth: Optional[int]) -> str:
"""Returns text tooltip for given mempool depth (in vbytes)."""
if depth is None:
return "unknown from tip"
depth_mb = cls.get_depth_mb_str(depth)
return _("{} from tip").format(depth_mb)
@classmethod
def get_depth_mb_str(cls, depth: int) -> str:
# e.g. 500_000 -> "0.50 MB"
depth_mb = "{:.2f}".format(depth / 1_000_000) # maybe .rstrip("0") ?
return f"{depth_mb} {util.UI_UNIT_NAME_MEMPOOL_MB}"
def fee_per_kb(self, network: 'Network') -> Optional[int]:
"""Returns sat/kvB fee to pay for a txn.
Note: might return None.
"""
if self.method == FeeMethod.FEERATE:
fee_rate = self.value
elif self.method == FeeMethod.MEMPOOL:
if network:
fee_rate = network.mempool_fees.depth_to_fee(self.get_slider_pos())
else:
fee_rate = None
elif self.method == FeeMethod.ETA:
if network:
fee_rate = network.fee_estimates.eta_to_fee(self.get_slider_pos())
else:
fee_rate = None
elif self.method == FeeMethod.FIXED:
fee_rate = None
else:
raise Exception(self.method)
if fee_rate is not None:
fee_rate = int(fee_rate)
return fee_rate
def fee_per_byte(self, network: 'Network') -> Optional[int]:
"""Returns sat/vB fee to pay for a txn.
Note: might return None.
"""
fee_per_kb = self.fee_per_kb(network)
return fee_per_kb / 1000 if fee_per_kb is not None else None
def estimate_fee(
self, size: Union[int, float, Decimal], *,
network: 'Network' = None,
allow_fallback_to_static_rates: bool = False,
) -> int:
if self.method == FeeMethod.FIXED:
return self.value
fee_per_kb = self.fee_per_kb(network)
if fee_per_kb is None and self.use_dynamic_estimates:
if allow_fallback_to_static_rates:
fee_per_kb = FEERATE_FALLBACK_STATIC_FEE
else:
raise NoDynamicFeeEstimates()
return self.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=size)
@classmethod
def estimate_fee_for_feerate(
cls,
*,
fee_per_kb: Union[int, float, Decimal],
size: Union[int, float, Decimal],
) -> int:
# note: 'size' is in vbytes
size = Decimal(size)
fee_per_kb = Decimal(fee_per_kb)
fee_per_byte = fee_per_kb / 1000
# to be consistent with what is displayed in the GUI,
# the calculation needs to use the same precision:
fee_per_byte = quantize_feerate(fee_per_byte)
return math.ceil(fee_per_byte * size)
class FixedFeePolicy(FeePolicy):
def __init__(self, fee):
FeePolicy.__init__(self, 'fixed:%d' % fee)
def impose_hard_limits_on_fee(func):
def get_fee_within_limits(self, *args, **kwargs):
fee = func(self, *args, **kwargs)
if fee is None:
return fee
fee = min(FEERATE_MAX_DYNAMIC, fee)
# Clamp dynamic feerates with conservative min relay fee,
# to ensure txs propagate well:
fee = max(FEERATE_DEFAULT_RELAY, fee)
return fee
return get_fee_within_limits
class FeeHistogram:
def __init__(self):
self._data = None # type: Optional[Sequence[Tuple[Union[float, int], int]]]
def has_data(self) -> bool:
return self._data is not None
def set_data(self, data):
self._data = data
def fee_to_depth(self, target_fee: Real) -> Optional[int]:
"""For a given sat/vbyte fee, returns an estimate of how deep
it would be in the current mempool in vbytes.
Pessimistic == overestimates the depth.
"""
if self._data is None:
return None
depth = 0
for fee, s in self._data:
depth += s
if fee <= target_fee:
break
return depth
@impose_hard_limits_on_fee
def depth_target_to_fee(self, target: int) -> Optional[int]:
"""Returns fee in sat/kbyte.
target: desired mempool depth in vbytes
"""
if self._data is None:
return None
depth = 0
for fee, s in self._data:
depth += s
if depth > target:
break
else:
return 0
# add one sat/byte as currently that is the max precision of the histogram
# note: precision depends on server.
# old ElectrumX <1.16 has 1 s/b prec, >=1.16 has 0.1 s/b prec.
# electrs seems to use untruncated double-precision floating points.
# # TODO decrease this to 0.1 s/b next time we bump the required protocol version
fee += 1
# convert to sat/kbyte
return int(fee * 1000)
def depth_to_fee(self, slider_pos) -> Optional[int]:
"""Returns fee in sat/kbyte."""
target = FeePolicy.depth_target(slider_pos)
return self.depth_target_to_fee(target)
def get_capped_data(self):
""" used by QML """
data = self._data or [[FEERATE_DEFAULT_RELAY/1000, 1]]
# cap the histogram to a limited number of megabytes
bytes_limit = 10*1000*1000
bytes_current = 0
capped_histogram = []
for item in sorted(data, key=lambda x: x[0], reverse=True):
if bytes_current >= bytes_limit:
break
slot = min(item[1], bytes_limit - bytes_current)
bytes_current += slot
# round & limit precision
value = int(item[0] * 10**FEERATE_PRECISION) / 10**FEERATE_PRECISION
capped_histogram.append([
max(FEERATE_MIN_RELAY/1000, value), # clamped to [FEERATE_MIN_RELAY/1000, inf)
slot, # width of bucket
bytes_current, # cumulative depth at far end of bucket
])
return capped_histogram, bytes_current
class FeeTimeEstimates:
def __init__(self):
self.data = {} # type: Dict[int, int]
def get_data(self):
return self.data
def has_data(self) -> bool:
"""Returns if we have estimates for *all* targets requested.
Note: if wanting an estimate for a specific target, instead of checking has_data(),
just try to do the estimate and handle a potential None result. That way,
estimation works for targets we have, even if some targets are missing.
"""
targets = set(FEE_ETA_TARGETS)
targets.discard(1) # rm "next block" target
return all(target in self.data for target in targets)
def set_data(self, nblock_target: int, fee_per_kb: int):
assert isinstance(nblock_target, int), f"expected int, got {nblock_target!r}"
assert isinstance(fee_per_kb, int), f"expected int, got {fee_per_kb!r}"
self.data[nblock_target] = fee_per_kb
def fee_to_eta(self, fee_per_kb: Optional[int]) -> int:
"""Returns 'num blocks' ETA estimate for given fee rate,
or -1 for low fee.
"""
import operator
lst = list(self.data.items())
next_block_fee = self.eta_target_to_fee(1)
if next_block_fee is not None:
lst += [(1, next_block_fee)]
if not lst or fee_per_kb is None:
return -1
dist = map(lambda x: (x[0], abs(x[1] - fee_per_kb)), lst)
min_target, min_value = min(dist, key=operator.itemgetter(1))
if fee_per_kb < self.data.get(FEE_ETA_TARGETS[0])/2:
min_target = -1
return min_target
def eta_to_fee(self, slider_pos) -> Optional[int]:
"""Returns fee in sat/kbyte."""
slider_pos = max(slider_pos, 0)
slider_pos = min(slider_pos, len(FEE_ETA_TARGETS) - 1)
if slider_pos < len(FEE_ETA_TARGETS) - 1:
num_blocks = FEE_ETA_TARGETS[int(slider_pos)]
fee = self.eta_target_to_fee(num_blocks)
else:
fee = self.eta_target_to_fee(1)
return fee
@impose_hard_limits_on_fee
def eta_target_to_fee(self, num_blocks: int) -> Optional[int]:
"""Returns fee in sat/kbyte."""
if num_blocks == 1:
fee = self.data.get(2)
if fee is not None:
fee += fee / 2
fee = int(fee)
else:
fee = self.data.get(num_blocks)
if fee is not None:
fee = int(fee)
# fallback for regtest
if fee is None and constants.net is constants.BitcoinRegtest:
return FEERATE_REGTEST_STATIC_FEE
return fee
================================================
FILE: electrum/gui/__init__.py
================================================
# To create a new GUI, please add its code to this directory.
# Three objects are passed to the ElectrumGui: config, daemon and plugins
# The Wallet object is instantiated by the GUI
# Notifications about network events are sent to the GUI by using network.register_callback()
from typing import TYPE_CHECKING, Mapping, Optional
if TYPE_CHECKING:
from . import qt
from electrum.simple_config import SimpleConfig
from electrum.daemon import Daemon
from electrum.plugin import Plugins
class BaseElectrumGui:
def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
self.config = config
self.daemon = daemon
self.plugins = plugins
def main(self) -> None:
raise NotImplementedError()
def stop(self) -> None:
"""Stops the GUI.
This method must be thread-safe.
"""
pass
@classmethod
def version_info(cls) -> Mapping[str, Optional[str]]:
return {}
================================================
FILE: electrum/gui/common_qt/__init__.py
================================================
# Copyright (C) 2023 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
================================================
FILE: electrum/gui/common_qt/i18n.py
================================================
from PyQt6.QtCore import QTranslator
from electrum.i18n import _
class ElectrumTranslator(QTranslator):
"""Delegator for Qt translations to gettext"""
def __init__(self, parent=None):
super().__init__(parent)
# explicit enumeration of translatable strings from Qt standard library, so these
# will be included in the electrum gettext translation template
self._strings = [_('&Undo'), _('&Redo'), _('Cu&t'), _('&Copy'), _('&Paste'), _('Select All'),
_('Copy &Link Location')]
def translate(self, context, source_text: str, disambiguation, n):
return _(source_text, context=context)
================================================
FILE: electrum/gui/common_qt/plugins.py
================================================
import sys
from typing import TYPE_CHECKING, Optional
from PyQt6.QtCore import pyqtSignal, pyqtProperty, QObject
from electrum.logging import get_logger
if TYPE_CHECKING:
from electrum.gui.qml import ElectrumQmlApplication
from electrum.plugin import BasePlugin
class PluginQObject(QObject):
logger = get_logger(__name__)
pluginChanged = pyqtSignal()
busyChanged = pyqtSignal()
pluginEnabledChanged = pyqtSignal()
def __init__(self, plugin: 'BasePlugin', parent: Optional['ElectrumQmlApplication']):
super().__init__(parent)
self._busy = False
self.plugin = plugin
self.app = parent
@pyqtProperty(str, notify=pluginChanged)
def name(self): return self._name
@pyqtProperty(bool, notify=busyChanged)
def busy(self): return self._busy
# below only used for QML, not compatible yet with Qt
@pyqtProperty(bool, notify=pluginEnabledChanged)
def pluginEnabled(self): return self.plugin.is_enabled()
@pluginEnabled.setter
def pluginEnabled(self, enabled):
if enabled != self.plugin.is_enabled():
self.logger.debug(f'can {self.plugin.can_user_disable()}, {self.plugin.is_available()}')
if not self.plugin.can_user_disable() and not enabled:
return
if enabled:
self.app.plugins.enable(self.plugin.name)
else:
self.app.plugins.disable(self.plugin.name)
self.pluginEnabledChanged.emit()
================================================
FILE: electrum/gui/common_qt/util.py
================================================
import queue
import sys
from functools import wraps
from typing import Optional, NamedTuple, Callable
import os.path
from PyQt6 import QtGui
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QColor, QPen, QPaintDevice, QFontDatabase, QImage
import qrcode
from electrum.i18n import _
from electrum.logging import Logger
from electrum.util import EventListener, event_listener
_cached_font_ids: dict[str, int] = {}
def get_font_id(filename: str) -> int:
font_id = _cached_font_ids.get(filename)
if font_id is not None:
return font_id
# font_id will be negative on error
font_id = QFontDatabase.addApplicationFont(
os.path.join(os.path.dirname(__file__), '..', 'fonts', filename)
)
_cached_font_ids[filename] = font_id
return font_id
def draw_qr(
*,
qr: Optional[qrcode.main.QRCode],
paint_device: QPaintDevice, # target to paint on
is_enabled: bool = True,
min_boxsize: int = 2, # min size in pixels of single black/white unit box of the qr code
) -> None:
"""Draw 'qr' onto 'paint_device'.
- qr.box_size is ignored. We will calculate our own boxsize to fill the whole size of paint_device.
- qr.border is respected.
"""
black = QColor(0, 0, 0, 255)
grey = QColor(196, 196, 196, 255)
white = QColor(255, 255, 255, 255)
black_pen = QPen(black) if is_enabled else QPen(grey)
black_pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin)
if not qr:
qp = QtGui.QPainter()
qp.begin(paint_device)
qp.setBrush(white)
qp.setPen(white)
r = qp.viewport()
qp.drawRect(0, 0, r.width(), r.height())
qp.end()
return
# note: next line can raise qrcode.exceptions.DataOverflowError (or ValueError)
matrix = qr.get_matrix() # includes qr.border
k = len(matrix)
qp = QtGui.QPainter()
qp.begin(paint_device)
r = qp.viewport()
framesize = min(r.width(), r.height())
boxsize = int(framesize / k)
if boxsize < min_boxsize:
# The amount of data is still within what can fit into a QR code,
# however we don't have enough pixels to draw it.
qp.setBrush(white)
qp.setPen(white)
qp.drawRect(0, 0, r.width(), r.height())
qp.setBrush(black)
qp.setPen(black)
qp.drawText(0, 20, _("Cannot draw QR code") + ":")
qp.drawText(0, 40, _("Not enough space available."))
qp.end()
return
size = k * boxsize
left = (framesize - size) / 2
top = (framesize - size) / 2
# Draw white background with margin
qp.setBrush(white)
qp.setPen(white)
qp.drawRect(0, 0, framesize, framesize)
# Draw qr code
qp.setBrush(black if is_enabled else grey)
qp.setPen(black_pen)
for r in range(k):
for c in range(k):
if matrix[r][c]:
qp.drawRect(
int(left + c * boxsize), int(top + r * boxsize),
boxsize - 1, boxsize - 1)
qp.end()
def paintQR(data) -> Optional[QImage]:
if not data:
return None
# Create QR code
qr = qrcode.QRCode()
qr.add_data(data)
# Create a QImage to draw on
matrix = qr.get_matrix()
k = len(matrix)
boxsize = 5
size = k * boxsize
# Create the image with appropriate size
base_img = QImage(size, size, QImage.Format.Format_ARGB32)
# Use draw_qr to paint on the image
draw_qr(
qr=qr,
paint_device=base_img,
is_enabled=True,
min_boxsize=boxsize
)
return base_img
class TaskThread(QThread, Logger):
"""Thread that runs background tasks. Callbacks are guaranteed
to happen in the context of its parent."""
class Task(NamedTuple):
task: Callable
cb_success: Optional[Callable]
cb_done: Optional[Callable]
cb_error: Optional[Callable]
cancel: Optional[Callable] = None
doneSig = pyqtSignal(object, object, object)
def __init__(self, parent, on_error=None):
QThread.__init__(self, parent)
Logger.__init__(self)
self.on_error = on_error
self.tasks = queue.Queue()
self._cur_task = None # type: Optional[TaskThread.Task]
self._stopping = False
self.doneSig.connect(self.on_done)
self.start()
def add(self, task, on_success=None, on_done=None, on_error=None, *, cancel=None):
if self._stopping:
self.logger.warning(f"stopping or already stopped but tried to add new task.")
return
on_error = on_error or self.on_error
task_ = TaskThread.Task(task, on_success, on_done, on_error, cancel=cancel)
self.tasks.put(task_)
def run(self):
while True:
if self._stopping:
break
task = self.tasks.get() # type: TaskThread.Task
self._cur_task = task
if not task or self._stopping:
break
try:
result = task.task()
self.doneSig.emit(result, task.cb_done, task.cb_success)
except BaseException:
self.doneSig.emit(sys.exc_info(), task.cb_done, task.cb_error)
def on_done(self, result, cb_done, cb_result):
# This runs in the parent's thread.
if cb_done:
cb_done()
if cb_result:
cb_result(result)
def stop(self):
self._stopping = True
# try to cancel currently running task now.
# if the task does not implement "cancel", we will have to wait until it finishes.
task = self._cur_task
if task and task.cancel:
task.cancel()
# cancel the remaining tasks in the queue
while True:
try:
task = self.tasks.get_nowait()
except queue.Empty:
break
if task and task.cancel:
task.cancel()
self.tasks.put(None) # in case the thread is still waiting on the queue
self.exit()
self.wait()
class QtEventListener(EventListener):
qt_callback_signal = pyqtSignal(tuple)
def register_callbacks(self):
self.qt_callback_signal.connect(self.on_qt_callback_signal)
EventListener.register_callbacks(self)
def unregister_callbacks(self):
try:
self.qt_callback_signal.disconnect()
except (RuntimeError, TypeError): # wrapped Qt object might be deleted
# "TypeError: disconnect() failed between 'qt_callback_signal' and all its connections"
pass
EventListener.unregister_callbacks(self)
def on_qt_callback_signal(self, args):
func = args[0]
return func(self, *args[1:])
# decorator for members of the QtEventListener class
def qt_event_listener(func):
func = event_listener(func)
@wraps(func)
def decorator(self, *args):
self.qt_callback_signal.emit((func,) + args)
return decorator
================================================
FILE: electrum/gui/default_lang.py
================================================
# Copyright (C) 2023 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
#
# Note: try not to import modules from electrum, or at least from GUIs.
# This is to avoid evaluating module-level string-translations before we get
# a chance to set the default language.
import os
from typing import Optional
from electrum.i18n import languages
jLocale = None
if "ANDROID_DATA" in os.environ:
from jnius import autoclass, cast
jLocale = autoclass("java.util.Locale")
def get_default_language(*, gui_name: Optional[str] = None) -> str:
if gui_name == "qt":
from PyQt6.QtCore import QLocale
name = QLocale.system().name()
return name if name in languages else "en_UK"
elif gui_name == "qml":
from PyQt6.QtCore import QLocale
# On Android QLocale does not return the system locale
try:
name = str(jLocale.getDefault().toString())
except Exception:
name = QLocale.system().name()
return name if name in languages else "en_GB"
return ""
================================================
FILE: electrum/gui/fonts/PTMono.LICENSE
================================================
Copyright (c) 2011, ParaType Ltd. (http://www.paratype.com/public),
with Reserved Font Names "PT Sans", "PT Serif", "PT Mono" and "ParaType".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
================================================
FILE: electrum/gui/messages.py
================================================
from electrum.i18n import _
from electrum.submarine_swaps import MIN_FINAL_CLTV_DELTA_FOR_CLIENT
def to_rtf(msg):
return '\n'.join(['
' + x + '
' for x in msg.split('\n\n')])
MSG_COOPERATIVE_CLOSE = _(
"""Your node will negotiate the transaction fee with the remote node. This method of closing the channel usually results in the lowest fees."""
)
MSG_REQUEST_FORCE_CLOSE = _(
"""If you request a force-close, your node will pretend that it has lost its data and ask the remote node to broadcast their latest state. Doing so from time to time helps make sure that nodes are honest, because your node can punish them if they broadcast a revoked state."""
)
MSG_CREATED_NON_RECOVERABLE_CHANNEL = _(
"""The channel you created is not recoverable from seed.
To prevent fund losses, please save this backup on another device.
It may be imported in another Electrum wallet with the same seed."""
)
MSG_LIGHTNING_WARNING = _(
"""Electrum uses static channel backups. If you lose your wallet file, you will need to request your channel to be force-closed by the remote peer in order to recover your funds. This assumes that the remote peer is reachable, and has not lost its own data."""
)
MSG_THIRD_PARTY_PLUGIN_WARNING = ' '.join([
'' + _('Warning: Third-party plugins have access to your wallet!') + '',
'
',
_('Installing this plugin will grant third-party software access to your wallet. You must trust the plugin not to be malicious.'),
_('You should at minimum check who the author of the plugin is, and be careful of imposters.'),
'
',
_('Third-party plugins are not endorsed by Electrum.'),
_('Electrum will not be responsible in case of theft, loss of funds or privacy that might result from third-party plugins.'),
'
',
_('To install this plugin, please enter your plugin authorization password') + ':'
])
MSG_CONFLICTING_BACKUP_INSTANCE = _(
"""Another instance of this wallet (same seed) has an open channel with the same remote node. If you create this channel, you will not be able to use both wallets at the same time.
Are you sure?"""
)
MSG_LN_EXPLAIN_SCB_BACKUPS = "".join([
_("Channel backups can be imported in another instance of the same wallet."), " ",
_("In the Electrum mobile app, use the 'Send' button to scan this QR code."), " ",
"\n\n",
_("Please note that channel backups cannot be used to restore your channels."), " ",
_("If you lose your wallet file, the only thing you can do with a backup is to request your channel to be closed, so that your funds will be sent on-chain."),
])
MSG_CAPITAL_GAINS = _(
"""This summary covers only on-chain transactions (no lightning!). Capital gains are computed by attaching an acquisition price to each UTXO in the wallet, and uses the order of blockchain events (not FIFO)."""
)
MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP = _(
"""This channel is with a non-trampoline node; it cannot be used if trampoline is enabled.
If you want to keep using this channel, you need to disable trampoline routing in your preferences."""
)
MSG_FREEZE_ADDRESS = _("When you freeze an address, the funds in that address will not be used for sending bitcoins.")
MSG_FREEZE_COIN = _("When you freeze a coin, it will not be used for sending bitcoins.")
MSG_FORWARD_SWAP_FUNDING_MEMPOOL = (
_('Your funding transaction has been broadcast.') + " " +
_("Please remain online until the funding transaction is confirmed.") + "\n\n" +
_('The swap will be finalized once your transaction is confirmed.') + " " +
_("After the funding transaction is mined, the server will reveal the preimage needed to "
"fulfill the pending received lightning HTLCs. The HTLCs expire in {} blocks. "
"You will need to be online after the funding transaction is confirmed but before the HTLCs expire, "
"to claim your money. If you go offline for several days while the swap is pending, "
"you risk losing the swap amount!").format(MIN_FINAL_CLTV_DELTA_FOR_CLIENT)
)
MSG_REVERSE_SWAP_FUNDING_MEMPOOL = (
_('The funding transaction has been detected.') + " " +
_('Your claiming transaction will be broadcast when the funding transaction is confirmed.') + " " +
_("If you go offline before broadcasting the claiming transaction and let the swap time out, "
"you will not get back the already pre-paid mining fees.")
)
MSG_FORCE_CLOSE_WARNING = (
_('You will need to come back online after the commitment transaction is confirmed, in order to broadcast second-stage htlc transactions.') + ' ' +
_('If you remain offline for more than {} blocks, your channel counterparty will be able to sweep those funds.')
)
MSG_FORWARD_SWAP_WARNING = (
_('You will need to come back online after the funding transaction is confirmed, in order to settle the swap.') + ' ' +
_('If you remain offline for more than {} blocks, your channel will be force closed and you might lose the funds you sent in the swap.')
)
MSG_REVERSE_SWAP_WARNING = (
_('You will need to come back online after the funding transaction is confirmed, in order to settle the swap.') + ' ' +
_('If you remain offline for more than {} blocks, the swap will be cancelled and you will lose the prepaid mining fees.')
)
MSG_LN_UTXO_RESERVE = (
_("You do not have enough on-chain funds to protect your Lightning channels.") + ' ' +
_("You should have at least {} on-chain in order to be able to sweep channel outputs.")
)
# not to be translated
MSG_TERMS_OF_USE = (
"""1. Electrum is distributed under the MIT licence by Electrum Technologies GmbH. Most notably, this means that the Electrum software is provided as is, and that it comes without warranty.
2. We are neither a bank nor a financial service provider. In addition, we do not store user account data, and we are not an intermediary in the interaction between our software and the Bitcoin blockchain. Therefore, we do not have the possibility to freeze funds or to undo a fraudulent transaction.
3. We do not provide private user support. All issue resolutions are public, and take place on Github or public forums. If someone posing as 'Electrum support' proposes to help you via a private channel, this person is most likely an imposter trying to steal your bitcoins."""
)
TERMS_OF_USE_LATEST_VERSION : int = 1 # bump this if we want users re-prompted due to changes
MSG_CONNECTMODE_AUTOCONNECT = _('Auto-connect')
MSG_CONNECTMODE_MANUAL = _('Manual server selection')
MSG_CONNECTMODE_ONESERVER = _('Connect only to a single server')
MSG_CONNECTMODE_SERVER_HELP = _(
"Electrum connects to a unique server in order to receive your transaction history. "
"This server will learn your wallet addresses."
)
MSG_CONNECTMODE_NODES_HELP = _(
"In addition to your history server, Electrum will try to maintain connections with ~10 extra servers, in order to download block headers and find out the longest blockchain. "
"These servers are only used for block header notifications and fee estimates; they do not learn your wallet addresses. "
"Getting block headers from multiple sources is useful to detect lagging servers and forks. "
"Fork detection is security-critical for determining number of confirmations."
)
MSG_CONNECTMODE_AUTOCONNECT_HELP = _(
"Electrum will always use a history server that is on the longest blockchain. "
"If your current server is unresponsive or lagging, Electrum will switch to another server."
)
MSG_CONNECTMODE_MANUAL_HELP = _(
"Electrum will stay with the server you selected. It will warn you if your server is lagging."
)
MSG_CONNECTMODE_ONESERVER_HELP = _(
"Electrum will stay with the server you selected, and it will not connect to additional nodes. "
"This will disable fork detection. "
"This mode is only intended for connecting to your own fully trusted server. "
"Using this option on a public server is a security risk and is discouraged."
)
MSG_SUBMARINE_PAYMENT_HELP_TEXT = ''.join((
_("Submarine Payments use a reverse submarine swap to do on-chain transactions directly "
"from your lightning balance."), '\n\n',
_("Submarine Payments happen in two stages. In the first stage, your wallet sends a lightning "
"payment to the submarine swap provider. The swap provider will lock funds to a "
"funding output in an on-chain transaction (the funding transaction)."), '\n',
_("Once the funding transaction has one confirmation, your wallet will broadcast a claim "
"transaction as the second stage of the payment. This claim transaction spends the funding "
"output to the payee's address."), '\n\n',
_("Warning:"), '\n',
_('The funding transaction is not visible to the payee. They will only see a pending '
'transaction in the mempool after your wallet broadcasts the claim transaction. '
'Since confirmation of the funding transaction can take over 30 minutes, avoid using '
'Submarine Payments when the payee expects to see the transaction within a limited '
'time frame (e.g., an online shop checkout). Use a regular on-chain payment instead.'),
))
MSG_RELAYFEE = ' '.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.")
])
================================================
FILE: electrum/gui/qml/__init__.py
================================================
import os
import signal
import sys
import threading
from typing import TYPE_CHECKING
try:
import PyQt6
except Exception as e:
from electrum import GuiImportError
raise GuiImportError(
"Error: Could not import PyQt6. On Linux systems, "
"you may try 'sudo apt-get install python3-pyqt6'") from e
try:
import PyQt6.QtQml
except Exception as e:
from electrum import GuiImportError
raise GuiImportError(
"Error: Could not import PyQt6.QtQml. On Linux systems, "
"you may try 'sudo apt-get install python3-pyqt6.qtquick'") from e
from PyQt6.QtCore import (Qt, QCoreApplication, QLocale, QTimer, QT_VERSION_STR, PYQT_VERSION_STR)
from PyQt6.QtGui import QGuiApplication
from electrum.plugin import run_hook
from electrum.util import profiler
from electrum.logging import Logger
from electrum.gui import BaseElectrumGui
from electrum.gui.common_qt.i18n import ElectrumTranslator
if TYPE_CHECKING:
from electrum.daemon import Daemon
from electrum.simple_config import SimpleConfig
from electrum.plugin import Plugins
from .qeapp import ElectrumQmlApplication, Exception_Hook
class ElectrumGui(BaseElectrumGui, Logger):
@profiler
def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
BaseElectrumGui.__init__(self, config=config, daemon=daemon, plugins=plugins)
Logger.__init__(self)
# uncomment to debug plugin and import tracing
# os.environ['QML_IMPORT_TRACE'] = '1'
# os.environ['QT_DEBUG_PLUGINS'] = '1'
os.environ['QT_ANDROID_DISABLE_ACCESSIBILITY'] = '1'
# set default locale to en_GB. This is for l10n (e.g. number formatting, number input etc),
# but not for i18n, which is handled by the Translator
# this can be removed once the backend wallet is fully l10n aware
QLocale.setDefault(QLocale('en_GB'))
self.logger.info(f"Qml GUI starting up... Qt={QT_VERSION_STR}, PyQt={PYQT_VERSION_STR}")
self.logger.info("CWD=%s" % os.getcwd())
# Uncomment this call to verify objects are being properly
# GC-ed when windows are closed
#plugins.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer,
# ElectrumWindow], interval=5)])
if hasattr(Qt, "AA_ShareOpenGLContexts"):
QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts)
if hasattr(QGuiApplication, 'setDesktopFileName'):
QGuiApplication.setDesktopFileName('electrum')
if "QT_QUICK_CONTROLS_STYLE" not in os.environ:
os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material"
self.gui_thread = threading.current_thread()
self.app = ElectrumQmlApplication(sys.argv, config=config, daemon=daemon, plugins=plugins)
self.translator = ElectrumTranslator()
self.app.installTranslator(self.translator)
# timer
self.timer = QTimer(self.app)
self.timer.setSingleShot(False)
self.timer.setInterval(500) # msec
self.timer.timeout.connect(lambda: None) # periodically enter python scope
# hook for crash reporter
Exception_Hook.maybe_setup(slot=self.app.appController.crash)
# Initialize any QML plugins
run_hook('init_qml', self.app)
self.app.engine.load('electrum/gui/qml/components/main.qml')
def close(self):
self.app.quit()
def main(self):
if not self.app._valid:
return
self.timer.start()
signal.signal(signal.SIGINT, lambda *args: self._handle_sigint())
self.logger.info('Entering main loop')
self.app.exec()
def _handle_sigint(self):
self.app.appController.wantClose = True
self.stop()
def stop(self):
self.logger.info('closing GUI')
self.app.quit()
================================================
FILE: electrum/gui/qml/android_res/layout/scanner_layout.xml
================================================
================================================
FILE: electrum/gui/qml/auth.py
================================================
from functools import wraps, partial
from PyQt6.QtCore import pyqtSignal, pyqtSlot
from electrum.logging import get_logger
def auth_protect(func=None, reject=None, method='payment_auth', message=''):
"""
Supported methods:
* payment_auth: If the user has enabled the 'Payment authentication' config
they need to authenticate to continue. If biometrics are enabled they
can authenticate using the Android system dialog, else they will see the
wallet password dialog.
If the option is disabled they will have to confirm a dialog.
* wallet: Same as payment_auth, but not dependent on user configuration,
always requires authentication.
* wallet_password_only: No biometric/system authentication, user has to enter wallet password.
"""
if func is None:
return partial(auth_protect, reject=reject, method=method, message=message)
@wraps(func)
def wrapper(self, *args, **kwargs):
_logger = get_logger(__name__)
_logger.debug(f'{str(self)}.{func.__name__}')
if hasattr(self, '__auth_fcall'):
_logger.debug('object already has a pending authed function call')
raise Exception('object already has a pending authed function call')
setattr(self, '__auth_fcall', (func, args, kwargs, reject))
getattr(self, 'authRequired').emit(method, message)
return wrapper
class AuthMixin:
_auth_logger = get_logger(__name__)
authRequired = pyqtSignal([str, str], arguments=['method', 'authMessage'])
@pyqtSlot()
def authProceed(self):
self._auth_logger.debug('Proceeding with authed fn()')
try:
self._auth_logger.debug(str(getattr(self, '__auth_fcall')))
(func, args, kwargs, reject) = getattr(self, '__auth_fcall')
r = func(self, *args, **kwargs)
return r
except Exception as e:
self._auth_logger.error(f'Error executing wrapped fn(): {repr(e)}')
raise e
finally:
delattr(self, '__auth_fcall')
@pyqtSlot()
def authCancel(self):
self._auth_logger.debug('Cancelling authed fn()')
if not hasattr(self, '__auth_fcall'):
return
try:
(func, args, kwargs, reject) = getattr(self, '__auth_fcall')
if reject is not None:
if hasattr(self, reject):
getattr(self, reject)()
else:
self._auth_logger.error(f'Reject method "{reject}" not defined')
except Exception as e:
self._auth_logger.error(f'Error executing reject function "{reject}": {repr(e)}')
raise e
finally:
delattr(self, '__auth_fcall')
================================================
FILE: electrum/gui/qml/components/About.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
Pane {
objectName: 'About'
property string title: qsTr("About Electrum")
Flickable {
anchors.fill: parent
contentHeight: rootLayout.height
interactive: height < contentHeight
GridLayout {
id: rootLayout
columns: 2
width: parent.width
Item {
Layout.columnSpan: 2
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: parent.width
Layout.preferredHeight: parent.width * 3/4 // reduce height, empty space in png
Image {
id: electrum_logo
width: parent.width
height: width
source: '../../icons/electrum_presplash.png'
}
}
Label {
text: qsTr('Version')
Layout.alignment: Qt.AlignRight
}
Label {
text: BUILD.electrum_version
}
Label {
text: qsTr('Protocol version')
Layout.alignment: Qt.AlignRight
}
Label {
text: BUILD.protocol_version
}
Label {
text: qsTr('Qt Version')
Layout.alignment: Qt.AlignRight
}
Label {
text: BUILD.qt_version
}
Label {
text: qsTr('PyQt Version')
Layout.alignment: Qt.AlignRight
}
Label {
text: BUILD.pyqt_version
}
Label {
text: qsTr('License')
Layout.alignment: Qt.AlignRight
}
Label {
text: qsTr('MIT License')
}
Label {
text: qsTr('Homepage')
Layout.alignment: Qt.AlignRight
}
Label {
text: 'https://electrum.org'
textFormat: Text.RichText
onLinkActivated: Qt.openUrlExternally(link)
}
Label {
text: qsTr('Developers')
Layout.alignment: Qt.AlignRight
}
Label {
text: 'Thomas Voegtlin\nSomberNight\nSander van Grieken'
}
Item {
width: 1
height: constants.paddingXLarge
Layout.columnSpan: 2
}
Label {
text: qsTr('Distributed by Electrum Technologies GmbH')
Layout.columnSpan: 2
Layout.alignment: Qt.AlignHCenter
}
}
}
}
================================================
FILE: electrum/gui/qml/components/AddressDetails.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
Pane {
id: root
width: parent.width
height: parent.height
padding: 0
property string address
signal addressDetailsChanged
signal addressDeleted
ColumnLayout {
anchors.fill: parent
spacing: 0
Flickable {
Layout.fillWidth: true
Layout.fillHeight: true
leftMargin: constants.paddingLarge
rightMargin: constants.paddingLarge
topMargin: constants.paddingLarge
contentHeight: rootLayout.height
clip:true
interactive: height < contentHeight
GridLayout {
id: rootLayout
width: parent.width
columns: 2
Heading {
Layout.columnSpan: 2
text: qsTr('Address details')
}
RowLayout {
Layout.columnSpan: 2
Label {
text: qsTr('Address')
color: Material.accentColor
}
Tag {
visible: addressdetails.isFrozen
text: qsTr('Frozen')
labelcolor: 'white'
}
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
RowLayout {
width: parent.width
Label {
text: root.address
font.pixelSize: constants.fontSizeLarge
font.family: FixedFont
Layout.fillWidth: true
wrapMode: Text.Wrap
}
ToolButton {
icon.source: '../../icons/share.png'
icon.color: 'transparent'
onClicked: {
var dialog = app.genericShareDialog.createObject(root,
{ title: qsTr('Address'), text: root.address }
)
dialog.open()
}
}
}
}
Label {
text: qsTr('Balance')
color: Material.accentColor
}
FormattedAmount {
amount: addressdetails.balance
}
Label {
text: qsTr('Transactions')
color: Material.accentColor
}
Label {
text: addressdetails.numTx
}
Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
text: qsTr('Label')
color: Material.accentColor
}
TextHighlightPane {
id: labelContent
property bool editmode: false
Layout.columnSpan: 2
Layout.fillWidth: true
RowLayout {
width: parent.width
Label {
visible: !labelContent.editmode
text: addressdetails.label
wrapMode: Text.Wrap
Layout.fillWidth: true
font.pixelSize: constants.fontSizeLarge
}
ToolButton {
visible: !labelContent.editmode
icon.source: '../../icons/pen.png'
icon.color: 'transparent'
onClicked: {
labelEdit.text = addressdetails.label
labelContent.editmode = true
labelEdit.focus = true
}
}
TextField {
id: labelEdit
visible: labelContent.editmode
text: addressdetails.label
font.pixelSize: constants.fontSizeLarge
Layout.fillWidth: true
}
ToolButton {
visible: labelContent.editmode
icon.source: '../../icons/confirmed.png'
icon.color: 'transparent'
onClicked: {
labelContent.editmode = false
addressdetails.setLabel(labelEdit.text)
}
}
ToolButton {
visible: labelContent.editmode
icon.source: '../../icons/closebutton.png'
icon.color: 'transparent'
onClicked: labelContent.editmode = false
}
}
}
Heading {
Layout.columnSpan: 2
text: qsTr('Technical Properties')
}
Label {
Layout.topMargin: constants.paddingSmall
text: qsTr('Script type')
color: Material.accentColor
}
Label {
Layout.topMargin: constants.paddingSmall
Layout.fillWidth: true
text: addressdetails.scriptType
}
Label {
visible: addressdetails.derivationPath
text: qsTr('Derivation path')
color: Material.accentColor
}
Label {
visible: addressdetails.derivationPath
text: addressdetails.derivationPath
}
Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
visible: addressdetails.pubkeys.length
text: addressdetails.pubkeys.length > 1
? qsTr('Public keys')
: qsTr('Public key')
color: Material.accentColor
}
Repeater {
model: addressdetails.pubkeys
delegate: TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
RowLayout {
width: parent.width
Label {
text: modelData
Layout.fillWidth: true
wrapMode: Text.Wrap
font.pixelSize: constants.fontSizeLarge
font.family: FixedFont
}
ToolButton {
icon.source: '../../icons/share.png'
enabled: modelData
onClicked: {
var dialog = app.genericShareDialog.createObject(root, {
title: qsTr('Public key'),
text: modelData
})
dialog.open()
}
}
}
}
}
Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
visible: !Daemon.currentWallet.isWatchOnly
text: qsTr('Private key')
color: Material.accentColor
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: !Daemon.currentWallet.isWatchOnly
RowLayout {
width: parent.width
Label {
id: privateKeyText
Layout.fillWidth: true
visible: addressdetails.privkey
text: addressdetails.privkey
wrapMode: Text.Wrap
font.pixelSize: constants.fontSizeLarge
font.family: FixedFont
}
Label {
id: showPrivateKeyText
Layout.fillWidth: true
visible: !addressdetails.privkey
horizontalAlignment: Text.AlignHCenter
text: qsTr('Tap to show private key')
wrapMode: Text.Wrap
font.pixelSize: constants.fontSizeLarge
}
ToolButton {
icon.source: '../../icons/share.png'
visible: addressdetails.privkey
onClicked: {
var dialog = app.genericShareDialog.createObject(root, {
title: qsTr('Private key'),
text: addressdetails.privkey
})
dialog.open()
}
}
MouseArea {
anchors.fill: parent
enabled: !addressdetails.privkey
onClicked: addressdetails.requestShowPrivateKey()
}
}
}
}
}
ButtonContainer {
Layout.fillWidth: true
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
text: addressdetails.isFrozen ? qsTr('Unfreeze address') : qsTr('Freeze address')
onClicked: addressdetails.freeze(!addressdetails.isFrozen)
icon.source: '../../icons/freeze.png'
}
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
visible: Daemon.currentWallet.canSignMessage
text: qsTr('Sign/Verify')
icon.source: '../../icons/pen.png'
onClicked: {
var dialog = app.signVerifyMessageDialog.createObject(app, {
address: root.address
})
dialog.open()
}
}
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
visible: addressdetails.canDelete
text: qsTr('Delete')
onClicked: {
var confirmdialog = app.messageDialog.createObject(root, {
text: qsTr('Are you sure you want to delete this address from the wallet?'),
yesno: true
})
confirmdialog.accepted.connect(function () {
var success = addressdetails.deleteAddress()
if (success) {
addressDeleted()
app.stack.pop()
}
})
confirmdialog.open()
}
icon.source: '../../icons/delete.png'
}
}
}
AddressDetails {
id: addressdetails
wallet: Daemon.currentWallet
address: root.address
onFrozenChanged: addressDetailsChanged()
onLabelChanged: addressDetailsChanged()
onAuthRequired: (method, authMessage) => {
app.handleAuthRequired(addressdetails, method, authMessage)
}
onAddressDeleteFailed: (message) => {
var dialog = app.messageDialog.createObject(root, {
text: message
})
dialog.open()
}
}
Binding {
target: AppController
property: 'secureWindow'
value: Boolean(addressdetails.privkey)
}
}
================================================
FILE: electrum/gui/qml/components/Addresses.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import QtQml.Models
import org.electrum 1.0
import "controls"
Pane {
id: rootItem
objectName: 'Addresses'
padding: 0
ColumnLayout {
anchors.fill: parent
spacing: 0
ColumnLayout {
id: layout
Layout.fillWidth: true
Layout.fillHeight: true
Pane {
id: filtersPane
Layout.fillWidth: true
GridLayout {
columns: 3
width: parent.width
CheckBox {
id: showUsed
text: qsTr('Show Used')
enabled: listview.filterModel.showAddressesCoins != 2
onCheckedChanged: {
listview.filterModel.showUsed = checked
if (activeFocus) {
Config.addresslistShowUsed = checked
}
}
Component.onCompleted: {
checked = Config.addresslistShowUsed
listview.filterModel.showUsed = checked
}
}
RowLayout {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.alignment: Qt.AlignRight
Label {
text: qsTr('Show')
}
ElComboBox {
id: showCoinsAddresses
textRole: 'text'
valueRole: 'value'
model: ListModel {
id: showCoinsAddressesModel
Component.onCompleted: {
// we need to fill the model like this, as ListElement can't evaluate script
showCoinsAddressesModel.append({'text': qsTr('Addresses'), 'value': 1})
showCoinsAddressesModel.append({'text': qsTr('Coins'), 'value': 2})
showCoinsAddressesModel.append({'text': qsTr('Both'), 'value': 3})
listview.filterModel.showAddressesCoins = Config.addresslistShowType
for (let i=0; i < showCoinsAddressesModel.count; i++) {
if (showCoinsAddressesModel.get(i).value == listview.filterModel.showAddressesCoins) {
showCoinsAddresses.currentIndex = i
break
}
}
}
}
onCurrentValueChanged: {
if (activeFocus && currentValue) {
listview.filterModel.showAddressesCoins = currentValue
Config.addresslistShowType = currentValue
}
}
}
}
TextField {
id: searchEdit
Layout.fillWidth: true
Layout.columnSpan: 3
placeholderText: qsTr('search')
inputMethodHints: Qt.ImhNoPredictiveText
onTextChanged: listview.filterModel.filterText = text
Image {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
source: Qt.resolvedUrl('../../icons/zoom.png')
sourceSize.width: constants.iconSizeMedium
sourceSize.height: constants.iconSizeMedium
}
}
}
}
Frame {
id: channelsFrame
Layout.fillWidth: true
Layout.fillHeight: true
verticalPadding: 0
horizontalPadding: 0
background: PaneInsetBackground {}
ElListView {
id: listview
anchors.fill: parent
clip: true
property QtObject backingModel: Daemon.currentWallet.addressCoinModel
property QtObject filterModel: Daemon.currentWallet.addressCoinModel.filterModel
property bool selectMode: false
property bool freeze: true
model: visualModel
currentIndex: -1
section.property: 'type'
section.criteria: ViewSection.FullString
section.delegate: sectionDelegate
function getSelectedItems() {
var items = []
for (let i = 0; i < selectedGroup.count; i++) {
let modelitem = selectedGroup.get(i).model
if (modelitem.outpoint)
items.push(modelitem.outpoint)
else
items.push(modelitem.address)
}
return items
}
DelegateModel {
id: visualModel
model: listview.filterModel
groups: [
DelegateModelGroup {
id: selectedGroup;
name: 'selected'
onCountChanged: {
if (count == 0)
listview.selectMode = false
}
}
]
delegate: Loader {
id: loader
width: parent.width
sourceComponent: model.outpoint ? _coinDelegate : _addressDelegate
function toggle() {
loader.DelegateModel.inSelected = !loader.DelegateModel.inSelected
}
Component {
id: _addressDelegate
AddressDelegate {
id: addressDelegate
width: parent.width
property bool selected: loader.DelegateModel.inSelected
highlighted: selected
onClicked: {
if (!listview.selectMode) {
var page = app.stack.push(Qt.resolvedUrl('AddressDetails.qml'), {
address: model.address
})
page.addressDetailsChanged.connect(function() {
// update listmodel when details change
listview.backingModel.updateAddress(model.address)
})
page.addressDeleted.connect(function() {
// update listmodel when address removed
listview.backingModel.deleteAddress(model.address)
})
} else {
loader.toggle()
}
}
onPressAndHold: {
loader.toggle()
if (!listview.selectMode && selectedGroup.count > 0)
listview.selectMode = true
}
}
}
Component {
id: _coinDelegate
Pane {
height: coinDelegate.height
padding: 0
background: Rectangle {
color: Qt.darker(constants.darkerBackground, 1.10)
}
CoinDelegate {
id: coinDelegate
width: parent.width
property bool selected: loader.DelegateModel.inSelected
highlighted: selected
indent: listview.filterModel.showAddressesCoins == 2 ? 0 : constants.paddingLarge * 2
onClicked: {
if (!listview.selectMode) {
var page = app.stack.push(Qt.resolvedUrl('TxDetails.qml'), {
txid: model.txid
})
} else {
loader.toggle()
}
}
onPressAndHold: {
loader.toggle()
if (!listview.selectMode && selectedGroup.count > 0)
listview.selectMode = true
}
}
}
}
}
}
add: Transition {
NumberAnimation { properties: "opacity"; from: 0.0; to: 1.0; duration: 300
easing.type: Easing.OutQuad
}
}
onSelectModeChanged: {
if (selectMode) {
listview.freeze = !selectedGroup.get(0).model.held
}
}
ScrollIndicator.vertical: ScrollIndicator { }
}
}
}
ButtonContainer {
Layout.fillWidth: true
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
text: listview.freeze ? qsTr('Freeze') : qsTr('Unfreeze')
icon.source: '../../icons/freeze.png'
visible: listview.selectMode
onClicked: {
var items = listview.getSelectedItems()
listview.backingModel.setFrozenForItems(listview.freeze, items)
selectedGroup.remove(0, selectedGroup.count)
}
}
// FlatButton {
// Layout.fillWidth: true
// Layout.preferredWidth: 1
// text: qsTr('Pay from...')
// icon.source: '../../icons/tab_send.png'
// visible: listview.selectMode
// enabled: false // TODO
// onClicked: {
// //
// }
// }
}
}
Component {
id: sectionDelegate
Item {
id: root
width: ListView.view.width
height: childrenRect.height
required property string section
property string section_label: section == 'receive'
? qsTr('receive addresses')
: section == 'change'
? qsTr('change addresses')
: section == 'imported'
? qsTr('imported addresses')
: section + ' ' + qsTr('addresses')
ColumnLayout {
width: parent.width
Heading {
Layout.leftMargin: constants.paddingLarge
Layout.rightMargin: constants.paddingLarge
text: root.section_label
}
}
}
}
Component.onCompleted: {
Daemon.currentWallet.addressCoinModel.initModel()
}
}
================================================
FILE: electrum/gui/qml/components/BIP39RecoveryDialog.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
ElDialog {
id: dialog
title: qsTr("Detect BIP39 accounts")
property string seed
property string seedExtraWords
property string walletType
property string derivationPath
property string scriptType
needsSystemBarPadding: false
z: 1 // raise z so it also covers wizard dialog
anchors.centerIn: parent
padding: 0
width: parent.width * 4/5
height: parent.height * 4/5
ColumnLayout {
id: rootLayout
width: parent.width
height: parent.height
InfoTextArea {
Layout.fillWidth: true
Layout.margins: constants.paddingMedium
text: bip39RecoveryListModel.state == Bip39RecoveryListModel.Scanning
? qsTr('Scanning for accounts...')
: bip39RecoveryListModel.state == Bip39RecoveryListModel.Success
? listview.count > 0
? qsTr('Choose an account to restore.')
: qsTr('No existing accounts found.')
: bip39RecoveryListModel.state == Bip39RecoveryListModel.Failed
? qsTr('Recovery failed')
: qsTr('Recovery cancelled')
iconStyle: bip39RecoveryListModel.state == Bip39RecoveryListModel.Scanning
? InfoTextArea.IconStyle.Spinner
: bip39RecoveryListModel.state == Bip39RecoveryListModel.Success
? InfoTextArea.IconStyle.Info
: InfoTextArea.IconStyle.Error
}
Frame {
id: accountsFrame
Layout.fillWidth: true
Layout.fillHeight: true
Layout.topMargin: constants.paddingLarge
Layout.bottomMargin: constants.paddingLarge
Layout.leftMargin: constants.paddingMedium
Layout.rightMargin: constants.paddingMedium
verticalPadding: 0
horizontalPadding: 0
background: PaneInsetBackground {}
ColumnLayout {
spacing: 0
anchors.fill: parent
ListView {
id: listview
Layout.preferredWidth: parent.width
Layout.fillHeight: true
clip: true
model: bip39RecoveryListModel
delegate: ItemDelegate {
width: ListView.view.width
height: itemLayout.height
onClicked: {
dialog.derivationPath = model.derivation_path
dialog.scriptType = model.script_type
dialog.doAccept()
}
GridLayout {
id: itemLayout
columns: 3
rowSpacing: 0
anchors {
left: parent.left
right: parent.right
leftMargin: constants.paddingMedium
rightMargin: constants.paddingMedium
}
Item {
Layout.columnSpan: 3
Layout.preferredHeight: constants.paddingLarge
Layout.preferredWidth: 1
}
Image {
Layout.rowSpan: 3
source: Qt.resolvedUrl('../../icons/wallet.png')
}
Label {
Layout.columnSpan: 2
Layout.fillWidth: true
text: model.description
wrapMode: Text.Wrap
}
Label {
text: qsTr('script type')
color: Material.accentColor
}
Label {
Layout.fillWidth: true
text: model.script_type
}
Label {
text: qsTr('derivation path')
color: Material.accentColor
}
Label {
Layout.fillWidth: true
text: model.derivation_path
}
Item {
Layout.columnSpan: 3
Layout.preferredHeight: constants.paddingLarge
Layout.preferredWidth: 1
}
}
}
ScrollIndicator.vertical: ScrollIndicator { }
}
}
}
}
Bip39RecoveryListModel {
id: bip39RecoveryListModel
}
Component.onCompleted: {
bip39RecoveryListModel.startScan(walletType, seed, seedExtraWords)
}
}
================================================
FILE: electrum/gui/qml/components/BalanceDetails.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
Pane {
id: rootItem
objectName: 'BalanceDetails'
padding: 0
ColumnLayout {
id: rootLayout
anchors.fill: parent
spacing: 0
Flickable {
Layout.fillWidth: true
Layout.fillHeight: true
contentHeight: flickableRoot.height
clip:true
interactive: height < contentHeight
Pane {
id: flickableRoot
width: parent.width
padding: constants.paddingLarge
ColumnLayout {
width: parent.width
spacing: constants.paddingLarge
InfoTextArea {
Layout.fillWidth: true
Layout.bottomMargin: constants.paddingLarge
visible: Daemon.currentWallet.synchronizing || !Network.isConnected
text: Daemon.currentWallet.synchronizing
? qsTr('Your wallet is not synchronized. The displayed balance may be inaccurate.')
: qsTr('Your wallet is not connected to an Electrum server. The displayed balance may be outdated.')
iconStyle: InfoTextArea.IconStyle.Warn
}
Heading {
text: qsTr('Wallet balance')
}
Piechart {
id: piechart
property real total: 0
visible: total > 0
Layout.preferredWidth: parent.width
implicitHeight: 220 // TODO: sane value dependent on screen
innerOffset: 6
function updateSlices() {
var p = Daemon.currentWallet.getBalancesForPiechart()
total = p['total']
piechart.slices = [
{ v: p['lightning']/total,
color: constants.colorPiechartLightning, text: qsTr('Lightning') },
{ v: p['confirmed']/total,
color: constants.colorPiechartOnchain, text: qsTr('On-chain') },
{ v: p['frozen']/total,
color: constants.colorPiechartFrozen, text: qsTr('On-chain (frozen)') },
{ v: p['unconfirmed']/total,
color: constants.colorPiechartUnconfirmed, text: qsTr('Unconfirmed') },
{ v: p['unmatured']/total,
color: constants.colorPiechartUnmatured, text: qsTr('Unmatured') },
{ v: p['f_lightning']/total,
color: constants.colorPiechartLightningFrozen, text: qsTr('Frozen Lightning') },
]
}
}
GridLayout {
Layout.alignment: Qt.AlignHCenter
visible: Daemon.currentWallet
columns: 2
RowLayout {
Rectangle {
Layout.preferredWidth: constants.iconSizeXSmall
Layout.preferredHeight: constants.iconSizeXSmall
border.color: constants.colorPiechartTotal
color: 'transparent'
radius: constants.iconSizeXSmall/2
}
Label {
text: qsTr('Total')
}
}
FormattedAmount {
amount: Daemon.currentWallet.totalBalance
}
RowLayout {
visible: Daemon.currentWallet.isLightning
Rectangle {
Layout.preferredWidth: constants.iconSizeXSmall
Layout.preferredHeight: constants.iconSizeXSmall
color: constants.colorPiechartLightning
}
Label {
text: qsTr('Lightning')
}
}
FormattedAmount {
visible: Daemon.currentWallet.isLightning
amount: Daemon.currentWallet.lightningBalance
}
RowLayout {
visible: Daemon.currentWallet.isLightning && !Daemon.currentWallet.lightningBalanceFrozen.isEmpty
Rectangle {
Layout.leftMargin: constants.paddingLarge
Layout.preferredWidth: constants.iconSizeXSmall
Layout.preferredHeight: constants.iconSizeXSmall
color: constants.colorPiechartLightningFrozen
}
Label {
text: qsTr('Frozen')
}
}
FormattedAmount {
visible: Daemon.currentWallet.isLightning && !Daemon.currentWallet.lightningBalanceFrozen.isEmpty
amount: Daemon.currentWallet.lightningBalanceFrozen
}
RowLayout {
visible: Daemon.currentWallet.isLightning || !Daemon.currentWallet.frozenBalance.isEmpty
Rectangle {
Layout.preferredWidth: constants.iconSizeXSmall
Layout.preferredHeight: constants.iconSizeXSmall
color: constants.colorPiechartOnchain
}
Label {
text: qsTr('On-chain')
}
}
FormattedAmount {
visible: Daemon.currentWallet.isLightning || !Daemon.currentWallet.frozenBalance.isEmpty
amount: Daemon.currentWallet.confirmedBalance
}
RowLayout {
visible: !Daemon.currentWallet.frozenBalance.isEmpty
Rectangle {
Layout.leftMargin: constants.paddingLarge
Layout.preferredWidth: constants.iconSizeXSmall
Layout.preferredHeight: constants.iconSizeXSmall
color: constants.colorPiechartFrozen
}
Label {
text: qsTr('Frozen')
}
}
FormattedAmount {
amount: Daemon.currentWallet.frozenBalance
visible: !Daemon.currentWallet.frozenBalance.isEmpty
}
RowLayout {
visible: !Daemon.currentWallet.unconfirmedBalance.isEmpty
Rectangle {
Layout.preferredWidth: constants.iconSizeXSmall
Layout.preferredHeight: constants.iconSizeXSmall
color: constants.colorPiechartUnconfirmed
}
Label {
text: qsTr('Unconfirmed')
}
}
FormattedAmount {
amount: Daemon.currentWallet.unconfirmedBalance
visible: !Daemon.currentWallet.unconfirmedBalance.isEmpty
}
}
Heading {
text: qsTr('Lightning Liquidity')
visible: Daemon.currentWallet.isLightning
}
GridLayout {
Layout.alignment: Qt.AlignHCenter
visible: Daemon.currentWallet && Daemon.currentWallet.isLightning
columns: 2
Label {
text: qsTr('Can send')
}
FormattedAmount {
amount: Daemon.currentWallet.lightningCanSend
}
Label {
text: qsTr('Can receive')
}
FormattedAmount {
amount: Daemon.currentWallet.lightningCanReceive
}
}
}
}
}
ButtonContainer {
Layout.fillWidth: true
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
text: qsTr('Lightning swap');
visible: Daemon.currentWallet.isLightning
enabled: Daemon.currentWallet.lightningCanSend.satsInt > 0 || Daemon.currentWallet.lightningCanReceive.satInt > 0
icon.source: Qt.resolvedUrl('../../icons/update.png')
onClicked: app.startSwap()
}
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
text: qsTr('Open Channel')
visible: Daemon.currentWallet.isLightning
enabled: Daemon.currentWallet.confirmedBalance.satsInt > 0
onClicked: {
var dialog = openChannelDialog.createObject(rootItem)
dialog.open()
}
icon.source: '../../icons/lightning.png'
}
}
}
Component {
id: openChannelDialog
OpenChannelDialog {
onClosed: destroy()
}
}
Connections {
target: Daemon.currentWallet
function onBalanceChanged() {
piechart.updateSlices()
}
}
Component.onCompleted: {
piechart.updateSlices()
}
}
================================================
FILE: electrum/gui/qml/components/ChannelDetails.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
Pane {
id: root
width: parent.width
height: parent.height
padding: 0
property string channelid
ColumnLayout {
anchors.fill: parent
spacing: 0
Flickable {
Layout.preferredWidth: parent.width
Layout.fillHeight: true
leftMargin: constants.paddingLarge
rightMargin: constants.paddingLarge
topMargin: constants.paddingLarge
contentHeight: rootLayout.height
clip:true
interactive: height < contentHeight
ColumnLayout {
id: rootLayout
width: parent.width
Heading {
text: !channeldetails.isBackup ? qsTr('Lightning Channel') : qsTr('Channel Backup')
}
GridLayout {
Layout.fillWidth: true
columns: 2
Label {
visible: channeldetails.name
text: qsTr('Node name')
color: Material.accentColor
}
Label {
Layout.fillWidth: true
visible: channeldetails.name
text: channeldetails.name
}
Label {
text: qsTr('State')
color: Material.accentColor
}
Label {
text: channeldetails.state
color: channeldetails.state == 'OPEN'
? constants.colorChannelOpen
: Material.foreground
}
Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
text: qsTr('Capacity and ratio')
color: Material.accentColor
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
padding: constants.paddingLarge
GridLayout {
width: parent.width
columns: 2
rowSpacing: constants.paddingSmall
ChannelBar {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.topMargin: constants.paddingLarge
Layout.bottomMargin: constants.paddingXLarge
visible: channeldetails.stateCode != ChannelDetails.Redeemed
&& channeldetails.stateCode != ChannelDetails.Closed
&& !channeldetails.isBackup
capacity: channeldetails.capacity
localCapacity: channeldetails.localCapacity
remoteCapacity: channeldetails.remoteCapacity
canSend: channeldetails.canSend
canReceive: channeldetails.canReceive
frozenForSending: channeldetails.frozenForSending
frozenForReceiving: channeldetails.frozenForReceiving
}
Label {
text: qsTr('Capacity')
color: Material.accentColor
}
FormattedAmount {
amount: channeldetails.capacity
}
Label {
text: qsTr('Local balance')
color: Material.accentColor
}
FormattedAmount {
visible: channeldetails.isOpen
amount: channeldetails.localCapacity
}
Label {
visible: !channeldetails.isOpen
text: qsTr('n/a (channel not open)')
}
Label {
text: qsTr('Can send')
color: Material.accentColor
}
RowLayout {
visible: channeldetails.isOpen
FormattedAmount {
visible: !channeldetails.frozenForSending
amount: channeldetails.canSend
singleLine: false
}
Label {
visible: channeldetails.frozenForSending
text: qsTr('n/a (frozen)')
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: 1
}
Pane {
background: Rectangle { color: Material.dialogColor }
padding: 0
FlatButton {
Layout.minimumWidth: implicitWidth
text: channeldetails.frozenForSending ? qsTr('Unfreeze') : qsTr('Freeze')
onClicked: channeldetails.freezeForSending()
}
}
}
Label {
visible: !channeldetails.isOpen
text: qsTr('n/a (channel not open)')
}
Label {
text: qsTr('Can receive')
color: Material.accentColor
}
RowLayout {
visible: channeldetails.isOpen
FormattedAmount {
visible: !channeldetails.frozenForReceiving
amount: channeldetails.canReceive
singleLine: false
}
Label {
visible: channeldetails.frozenForReceiving
text: qsTr('n/a (frozen)')
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: 1
}
Pane {
background: Rectangle { color: Material.dialogColor }
padding: 0
FlatButton {
Layout.minimumWidth: implicitWidth
text: channeldetails.frozenForReceiving ? qsTr('Unfreeze') : qsTr('Freeze')
onClicked: channeldetails.freezeForReceiving()
}
}
}
Label {
visible: !channeldetails.isOpen
text: qsTr('n/a (channel not open)')
}
}
}
Heading {
Layout.columnSpan: 2
text: qsTr('Technical properties')
}
Label {
text: qsTr('Short channel ID')
color: Material.accentColor
}
Label {
Layout.fillWidth: true
text: channeldetails.shortCid
}
Label {
text: qsTr('Local SCID alias')
color: Material.accentColor
visible: channeldetails.localScidAlias
}
Label {
Layout.fillWidth: true
text: channeldetails.localScidAlias
visible: channeldetails.localScidAlias
}
Label {
text: qsTr('Remote SCID alias')
color: Material.accentColor
visible: channeldetails.remoteScidAlias
}
Label {
Layout.fillWidth: true
text: channeldetails.remoteScidAlias
visible: channeldetails.remoteScidAlias
}
Label {
visible: !channeldetails.isBackup
text: qsTr('Initiator')
color: Material.accentColor
}
Label {
visible: !channeldetails.isBackup
text: channeldetails.initiator
}
Label {
text: qsTr('Channel type')
color: Material.accentColor
}
Label {
Layout.fillWidth: true
text: channeldetails.channelType
wrapMode: Text.Wrap
}
Label {
text: qsTr('Current feerate')
color: Material.accentColor
visible: channeldetails.currentFeerate
}
Label {
Layout.fillWidth: true
text: channeldetails.currentFeerate
visible: channeldetails.currentFeerate
}
Label {
visible: channeldetails.isBackup
text: qsTr('Backup type')
color: Material.accentColor
}
Label {
visible: channeldetails.isBackup
text: channeldetails.backupType == 'imported'
? qsTr('imported')
: channeldetails.backupType == 'on-chain'
? qsTr('on-chain')
: '?'
}
Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
text: qsTr('Remote node ID')
color: Material.accentColor
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
RowLayout {
width: parent.width
Label {
text: channeldetails.pubkey
font.pixelSize: constants.fontSizeLarge
font.family: FixedFont
Layout.fillWidth: true
wrapMode: Text.Wrap
}
ToolButton {
icon.source: '../../icons/share.png'
icon.color: 'transparent'
onClicked: {
var dialog = app.genericShareDialog.createObject(root, {
title: qsTr('Channel node ID'),
text: channeldetails.pubkey
})
dialog.open()
}
}
}
}
Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
text: qsTr('Funding Outpoint')
color: Material.accentColor
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
RowLayout {
width: parent.width
Label {
text: channeldetails.fundingOutpoint.txid + ':' + channeldetails.fundingOutpoint.index
font.pixelSize: constants.fontSizeLarge
font.family: FixedFont
Layout.fillWidth: true
wrapMode: Text.Wrap
TapHandler {
onTapped: {
app.stack.push(Qt.resolvedUrl('TxDetails.qml'), {
txid: channeldetails.fundingOutpoint.txid
})
}
}
}
ToolButton {
icon.source: '../../icons/share.png'
icon.color: 'transparent'
onClicked: {
var dialog = app.genericShareDialog.createObject(root, {
title: qsTr('Funding Outpoint'),
text: channeldetails.fundingOutpoint.txid + ':' + channeldetails.fundingOutpoint.index
})
dialog.open()
}
}
}
}
Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
visible: channeldetails.closingTxid
text: qsTr('Closing transaction')
color: Material.accentColor
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: channeldetails.closingTxid
RowLayout {
width: parent.width
Label {
text: channeldetails.closingTxid
font.pixelSize: constants.fontSizeLarge
font.family: FixedFont
Layout.fillWidth: true
wrapMode: Text.Wrap
TapHandler {
onTapped: {
app.stack.push(Qt.resolvedUrl('TxDetails.qml'), {
txid: channeldetails.closingTxid
})
}
}
}
ToolButton {
icon.source: '../../icons/share.png'
icon.color: 'transparent'
onClicked: {
var dialog = app.genericShareDialog.createObject(root, {
title: qsTr('Channel close transaction'),
text: channeldetails.closingTxid
})
dialog.open()
}
}
}
}
}
}
}
ButtonContainer {
Layout.fillWidth: true
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
visible: !channeldetails.isBackup
text: qsTr('Backup')
icon.source: '../../icons/file.png'
onClicked: {
var dialog = app.genericShareDialog.createObject(root, {
title: qsTr('Channel Backup for %1').arg(channeldetails.shortCid),
text_qr: channeldetails.channelBackup(),
text_help: channeldetails.channelBackupHelpText(),
iconSource: Qt.resolvedUrl('../../icons/file.png')
})
dialog.open()
}
}
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
text: qsTr('Close channel');
visible: channeldetails.canClose
icon.source: '../../icons/closebutton.png'
onClicked: {
var dialog = closechannel.createObject(root, { channelid: channelid })
dialog.open()
}
}
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
text: qsTr('Delete channel');
visible: channeldetails.canDelete
icon.source: '../../icons/delete.png'
onClicked: {
var dialog = app.messageDialog.createObject(root, {
title: qsTr('Are you sure?'),
text: channeldetails.isBackup ? '' : qsTr('This will purge associated transactions from your wallet history.'),
yesno: true
})
dialog.accepted.connect(function() {
channeldetails.deleteChannel()
app.stack.pop()
Daemon.currentWallet.historyModel.initModel(true) // needed here?
Daemon.currentWallet.channelModel.removeChannel(channelid)
})
dialog.open()
}
}
}
}
ChannelDetails {
id: channeldetails
wallet: Daemon.currentWallet
channelid: root.channelid
onTrampolineFrozenInGossipMode: {
var dialog = app.messageDialog.createObject(root, {
title: qsTr('Cannot unfreeze channel'),
text: [qsTr('Non-Trampoline channels cannot be used for sending while in trampoline mode.'),
qsTr('Disable trampoline mode to enable sending from this channel.')].join(' ')
})
dialog.open()
}
}
Component {
id: closechannel
CloseChannelDialog {}
}
}
================================================
FILE: electrum/gui/qml/components/ChannelOpenProgressDialog.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
ElDialog {
id: dialog
width: parent.width
height: parent.height
title: qsTr('Opening Channel...')
allowClose: false
property alias state: s.state
property alias error: errorText.text
property alias info: infoText.text
property alias peer: peerText.text
property string channelBackup
function reset() {
state = ''
errorText.text = ''
peerText.text = ''
channelBackup = ''
}
Item {
id: s
state: ''
states: [
State {
name: 'success'
PropertyChanges { target: dialog; allowClose: true }
PropertyChanges { target: stateText; text: qsTr('Success!') }
PropertyChanges { target: infoText; visible: true }
PropertyChanges { target: icon; source: '../../icons/confirmed.png' }
},
State {
name: 'failed'
PropertyChanges { target: dialog; allowClose: true }
PropertyChanges { target: stateText; text: qsTr('Problem opening channel') }
PropertyChanges { target: errorText; visible: true }
PropertyChanges { target: icon; source: '../../icons/warning.png' }
}
]
}
ColumnLayout {
id: content
anchors.centerIn: parent
width: parent.width
spacing: constants.paddingLarge
RowLayout {
Layout.alignment: Qt.AlignHCenter
Image {
id: icon
source: ''
visible: source != ''
Layout.preferredWidth: constants.iconSizeLarge
Layout.preferredHeight: constants.iconSizeLarge
}
BusyIndicator {
id: spinner
running: visible
visible: s.state == ''
Layout.preferredWidth: constants.iconSizeLarge
Layout.preferredHeight: constants.iconSizeLarge
}
Label {
id: stateText
text: qsTr('Opening Channel...')
font.pixelSize: constants.fontSizeXXLarge
}
}
TextHighlightPane {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: dialog.width * 3/4
Label {
id: peerText
font.pixelSize: constants.fontSizeMedium
width: parent.width
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
}
}
InfoTextArea {
id: errorText
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: dialog.width * 2/3
visible: false
iconStyle: InfoTextArea.IconStyle.Error
textFormat: TextEdit.PlainText
}
InfoTextArea {
id: infoText
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: dialog.width * 2/3
visible: false
textFormat: TextEdit.PlainText
}
}
onClosed: {
if (!dialog.channelBackup)
return
var sharedialog = app.genericShareDialog.createObject(app, {
title: qsTr('Save Channel Backup'),
text_qr: dialog.channelBackup,
text_help: qsTr('The channel you created is not recoverable from seed.')
+ ' ' + qsTr('To prevent fund losses, please save this backup on another device.')
+ ' ' + qsTr('It may be imported in another Electrum wallet with the same seed.')
})
sharedialog.open()
}
}
================================================
FILE: electrum/gui/qml/components/Channels.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
Pane {
id: root
objectName: 'Channels'
padding: 0
ColumnLayout {
id: layout
width: parent.width
height: parent.height
spacing: 0
GridLayout {
id: summaryLayout
Layout.preferredWidth: parent.width
Layout.topMargin: constants.paddingLarge
Layout.leftMargin: constants.paddingLarge
Layout.rightMargin: constants.paddingLarge
columns: 2
Heading {
Layout.columnSpan: 2
text: qsTr('Lightning Channels')
}
Label {
Layout.columnSpan: 2
text: qsTr('You have %1 open channels').arg(Daemon.currentWallet.channelModel.numOpenChannels)
color: Material.accentColor
}
Label {
text: qsTr('You can send') + ':'
color: Material.accentColor
}
FormattedAmount {
amount: Daemon.currentWallet.lightningCanSend
}
Label {
text: qsTr('You can receive') + ':'
color: Material.accentColor
}
FormattedAmount {
amount: Daemon.currentWallet.lightningCanReceive
}
}
Frame {
id: channelsFrame
Layout.fillWidth: true
Layout.fillHeight: true
Layout.topMargin: constants.paddingLarge
Layout.bottomMargin: constants.paddingLarge
Layout.leftMargin: constants.paddingMedium
Layout.rightMargin: constants.paddingMedium
verticalPadding: 0
horizontalPadding: 0
background: PaneInsetBackground {}
ColumnLayout {
spacing: 0
anchors.fill: parent
ElListView {
id: listview
Layout.preferredWidth: parent.width
Layout.fillHeight: true
clip: true
model: Daemon.currentWallet.channelModel
section.property: 'is_backup'
section.criteria: ViewSection.FullString
section.delegate: RowLayout {
width: ListView.view.width
required property string section
Label {
visible: section == 'true'
text: qsTr('Channel backups')
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: constants.paddingLarge
font.pixelSize: constants.fontSizeSmall
color: Material.accentColor
}
}
delegate: ChannelDelegate {
onClicked: {
app.stack.push(Qt.resolvedUrl('ChannelDetails.qml'), { 'channelid': model.cid })
}
}
ScrollIndicator.vertical: ScrollIndicator { }
Label {
visible: listview.model.count == 0
anchors.centerIn: parent
width: listview.width * 4/5
font.pixelSize: constants.fontSizeXXLarge
color: constants.mutedForeground
text: qsTr('No Lightning channels yet in this wallet')
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
}
}
}
}
ButtonContainer {
Layout.fillWidth: true
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
text: qsTr('Swap');
enabled: Daemon.currentWallet.lightningCanSend.satsInt > 0 ||
(Daemon.currentWallet.lightningCanReceive.satsInt > 0 && Daemon.currentWallet.confirmedBalance.satsInt > 0)
icon.source: Qt.resolvedUrl('../../icons/update.png')
onClicked: app.startSwap()
}
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
enabled: Daemon.currentWallet.canHaveLightning && Daemon.currentWallet.confirmedBalance.satsInt > 0
text: qsTr('Open Channel')
onClicked: {
if (Daemon.currentWallet.channelModel.count == 0) {
var txt = Daemon.currentWallet.channelModel.lightningWarningMessage() + '\n\n' +
qsTr('Do you want to create your first channel?')
var confirmdialog = app.messageDialog.createObject(root, {
text: txt,
yesno: true
})
confirmdialog.accepted.connect(function () {
var dialog = openChannelDialog.createObject(root)
dialog.open()
})
confirmdialog.open()
} else {
var dialog = openChannelDialog.createObject(root)
dialog.open()
}
}
icon.source: '../../icons/lightning.png'
}
}
}
Component {
id: openChannelDialog
OpenChannelDialog {
onClosed: destroy()
}
}
}
================================================
FILE: electrum/gui/qml/components/CloseChannelDialog.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
ElDialog {
id: dialog
width: parent.width
height: parent.height
property string channelid
title: qsTr('Close Channel')
iconSource: Qt.resolvedUrl('../../icons/lightning_disconnected.png')
property string _closing_method
padding: 0
ColumnLayout {
anchors.fill: parent
spacing: 0
Flickable {
Layout.preferredWidth: parent.width
Layout.fillHeight: true
leftMargin: constants.paddingLarge
rightMargin: constants.paddingLarge
contentHeight: rootLayout.height
clip:true
interactive: height < contentHeight
GridLayout {
id: rootLayout
width: parent.width
columns: 2
Label {
Layout.fillWidth: true
visible: channeldetails.name
text: qsTr('Channel name')
color: Material.accentColor
}
Label {
Layout.fillWidth: true
visible: channeldetails.name
text: channeldetails.name
}
Label {
text: qsTr('Short channel ID')
color: Material.accentColor
}
Label {
text: channeldetails.shortCid
}
Label {
text: qsTr('Remote node ID')
Layout.columnSpan: 2
color: Material.accentColor
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
Label {
width: parent.width
text: channeldetails.pubkey
font.pixelSize: constants.fontSizeLarge
font.family: FixedFont
Layout.fillWidth: true
wrapMode: Text.Wrap
}
}
Item { Layout.preferredHeight: constants.paddingMedium; Layout.preferredWidth: 1; Layout.columnSpan: 2 }
InfoTextArea {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.bottomMargin: constants.paddingLarge
text: channeldetails.messageForceClose
}
Label {
text: qsTr('Choose closing method')
Layout.columnSpan: 2
color: Material.accentColor
}
ColumnLayout {
Layout.columnSpan: 2
Layout.alignment: Qt.AlignHCenter
ButtonGroup {
id: closetypegroup
}
ElRadioButton {
id: closetypeCoop
ButtonGroup.group: closetypegroup
property string closetype: 'cooperative'
enabled: !channeldetails.isClosing && channeldetails.canCoopClose
text: qsTr('Cooperative close')
}
ElRadioButton {
id: closetypeRemoteForce
ButtonGroup.group: closetypegroup
property string closetype: 'remote_force'
enabled: !channeldetails.isClosing && channeldetails.canRequestForceClose
text: qsTr('Request Force-close')
}
ElRadioButton {
id: closetypeLocalForce
ButtonGroup.group: closetypegroup
property string closetype: 'local_force'
enabled: !channeldetails.isClosing && channeldetails.canLocalForceClose && !channeldetails.isBackup
text: qsTr('Local Force-close')
}
}
ColumnLayout {
Layout.columnSpan: 2
Layout.maximumWidth: parent.width
InfoTextArea {
id: errorText
Layout.alignment: Qt.AlignHCenter
Layout.maximumWidth: parent.width
visible: !channeldetails.isClosing && errorText.text
iconStyle: InfoTextArea.IconStyle.Error
}
Label {
Layout.alignment: Qt.AlignHCenter
text: qsTr('Closing...')
visible: channeldetails.isClosing
}
BusyIndicator {
Layout.alignment: Qt.AlignHCenter
visible: channeldetails.isClosing
}
}
}
}
FlatButton {
Layout.columnSpan: 2
Layout.fillWidth: true
text: qsTr('Close channel')
icon.source: '../../icons/closebutton.png'
enabled: !channeldetails.isClosing
onClicked: {
if (closetypegroup.checkedButton.closetype == 'local_force') {
showBackupThenClose()
} else {
doCloseChannel()
}
}
}
}
function showBackupThenClose() {
var sharedialog = app.genericShareDialog.createObject(app, {
title: qsTr('Save channel backup and force close'),
text_qr: channeldetails.channelBackup(),
text_help: channeldetails.messageForceCloseBackup,
helpTextIconStyle: InfoTextArea.IconStyle.Warn
})
sharedialog.closed.connect(function() {
doCloseChannel()
})
sharedialog.open()
}
function doCloseChannel() {
_closing_method = closetypegroup.checkedButton.closetype
channeldetails.closeChannel(_closing_method)
}
function showCloseMessage(text) {
var msgdialog = app.messageDialog.createObject(app, {
text: text
})
msgdialog.open()
}
ChannelDetails {
id: channeldetails
wallet: Daemon.currentWallet
channelid: dialog.channelid
onAuthRequired: (method, authMessage) => {
app.handleAuthRequired(channeldetails, method, authMessage)
}
onChannelChanged: {
if (!channeldetails.canClose || channeldetails.isClosing)
return
// init default choice
if (channeldetails.canCoopClose)
closetypeCoop.checked = true
else if (channeldetails.canRequestForceClose)
closetypeRemoteForce.checked = true
else
closetypeLocalForce.checked = true
}
onChannelCloseSuccess: {
if (_closing_method == 'local_force') {
showCloseMessage(qsTr('Channel closed. You may need to wait at least %1 blocks, because of CSV delays').arg(channeldetails.toSelfDelay))
} else if (_closing_method == 'remote_force') {
showCloseMessage(qsTr('Request sent'))
} else if (_closing_method == 'cooperative') {
showCloseMessage(qsTr('Channel closed'))
}
dialog.close()
}
onChannelCloseFailed: (message) => {
errorText.text = message
}
}
}
================================================
FILE: electrum/gui/qml/components/ConfirmTxDialog.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
ElDialog {
id: dialog
required property QtObject finalizer
required property Amount satoshis
property string address
property string message
property bool showOptions: true
property alias amountLabelText: amountLabel.text
property alias sendButtonText: sendButton.text
title: qsTr('Transaction Fee')
iconSource: Qt.resolvedUrl('../../icons/question.png')
// copy these to finalizer
onAddressChanged: finalizer.address = address
onSatoshisChanged: finalizer.amount = satoshis
width: parent.width
height: parent.height
padding: 0
function updateAmountText() {
if (finalizer.valid) {
btcValue.text = Config.formatSats(finalizer.effectiveAmount, false)
fiatValue.text = Daemon.fx.enabled
? Daemon.fx.fiatValue(finalizer.effectiveAmount, false)
: ''
} else {
btcValue.text = Config.formatSats(finalizer.amount, false)
fiatValue.text = Daemon.fx.enabled
? Daemon.fx.fiatValue(finalizer.amount, false)
: ''
}
}
ColumnLayout {
anchors.fill: parent
spacing: 0
Flickable {
Layout.fillWidth: true
Layout.fillHeight: true
leftMargin: constants.paddingLarge
rightMargin: constants.paddingLarge
contentHeight: rootLayout.height
clip: true
interactive: height < contentHeight
GridLayout {
id: rootLayout
width: parent.width
columns: 2
Label {
id: amountLabel
Layout.columnSpan: 2
text: qsTr('Amount to send')
color: Material.accentColor
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
GridLayout {
columns: 2
Label {
id: btcValue
Layout.alignment: Qt.AlignRight
font.pixelSize: constants.fontSizeXLarge
font.family: FixedFont
font.bold: true
}
Label {
Layout.fillWidth: true
text: Config.baseUnit
color: Material.accentColor
font.pixelSize: constants.fontSizeXLarge
}
Label {
id: fiatValue
Layout.alignment: Qt.AlignRight
visible: Daemon.fx.enabled
font.pixelSize: constants.fontSizeMedium
color: constants.mutedForeground
}
Label {
Layout.fillWidth: true
visible: Daemon.fx.enabled
text: Daemon.fx.fiatCurrency
font.pixelSize: constants.fontSizeMedium
color: constants.mutedForeground
}
Component.onCompleted: updateAmountText()
Connections {
target: finalizer
function onEffectiveAmountChanged() {
updateAmountText()
}
}
}
}
Label {
Layout.columnSpan: 2
text: qsTr('Fee')
color: Material.accentColor
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
height: feepicker.height
FeePicker {
id: feepicker
width: parent.width
finalizer: dialog.finalizer
Label {
visible: !finalizer.extraFee.isEmpty
text: qsTr('Extra fee')
color: Material.accentColor
}
FormattedAmount {
visible: !finalizer.extraFee.isEmpty
amount: finalizer.extraFee
}
}
}
ToggleLabel {
id: optionstoggle
Layout.columnSpan: 2
labelText: qsTr('Options')
color: Material.accentColor
visible: showOptions
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: optionstoggle.visible && !optionstoggle.collapsed
height: optionslayout.height
GridLayout {
id: optionslayout
width: parent.width
columns: 2
ElCheckBox {
Layout.fillWidth: true
text: qsTr('Use multiple change addresses')
onCheckedChanged: {
if (activeFocus) {
Daemon.currentWallet.multipleChange = checked
finalizer.doUpdate()
}
}
Component.onCompleted: {
checked = Daemon.currentWallet.multipleChange
}
}
HelpButton {
heading: qsTr('Use multiple change addresses')
helptext: [qsTr('In some cases, use up to 3 change addresses in order to break up large coin amounts and obfuscate the recipient address.'),
qsTr('This may result in higher transactions fees.')].join(' ')
}
ElCheckBox {
Layout.fillWidth: true
text: Config.shortDescFor('WALLET_COIN_CHOOSER_OUTPUT_ROUNDING')
onCheckedChanged: {
if (activeFocus) {
Config.outputValueRounding = checked
finalizer.doUpdate()
}
}
Component.onCompleted: {
checked = Config.outputValueRounding
}
}
HelpButton {
heading: Config.shortDescFor('WALLET_COIN_CHOOSER_OUTPUT_ROUNDING')
helptext: Config.longDescFor('WALLET_COIN_CHOOSER_OUTPUT_ROUNDING')
}
}
}
InfoTextArea {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.topMargin: constants.paddingLarge
Layout.bottomMargin: constants.paddingLarge
visible: finalizer.warning != ''
text: finalizer.warning
iconStyle: InfoTextArea.IconStyle.Warn
}
ToggleLabel {
id: inputs_label
Layout.columnSpan: 2
Layout.topMargin: constants.paddingMedium
visible: finalizer.valid
labelText: qsTr('Inputs (%1)').arg(finalizer.inputs.length)
color: Material.accentColor
}
Repeater {
model: inputs_label.collapsed
? undefined
: finalizer.inputs
delegate: TxInput {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: finalizer.valid
idx: index
model: modelData
}
}
ToggleLabel {
id: outputs_label
Layout.columnSpan: 2
Layout.topMargin: constants.paddingMedium
visible: finalizer.valid
labelText: qsTr('Outputs (%1)').arg(finalizer.outputs.length)
color: Material.accentColor
}
Repeater {
model: outputs_label.collapsed
? undefined
: finalizer.outputs
delegate: TxOutput {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: finalizer.valid
allowShare: false
allowClickAddress: false
idx: index
model: modelData
}
}
}
}
FlatButton {
id: sendButton
Layout.fillWidth: true
text: (Daemon.currentWallet.isWatchOnly || !Daemon.currentWallet.canSignWithoutCosigner)
? qsTr('Finalize...')
: qsTr('Pay...')
icon.source: '../../icons/confirmed.png'
enabled: finalizer.valid
onClicked: doAccept()
}
}
onClosed: doReject()
}
================================================
FILE: electrum/gui/qml/components/Constants.qml
================================================
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
Item {
readonly property int paddingXXSmall: 4
readonly property int paddingXSmall: 6
readonly property int paddingSmall: 8
readonly property int paddingMedium: 12
readonly property int paddingLarge: 16
readonly property int paddingXLarge: 20
readonly property int paddingXXLarge: 28
readonly property int fontSizeXSmall: 10
readonly property int fontSizeSmall: 12
readonly property int fontSizeMedium: 15
readonly property int fontSizeLarge: 18
readonly property int fontSizeXLarge: 22
readonly property int fontSizeXXLarge: 28
readonly property int iconSizeXSmall: 12
readonly property int iconSizeSmall: 16
readonly property int iconSizeMedium: 24
readonly property int iconSizeLarge: 32
readonly property int iconSizeXLarge: 48
readonly property int iconSizeXXLarge: 64
readonly property int fingerWidth: 64 // TODO: determine finger width from screen dimensions and resolution
property color mutedForeground: 'gray' //Qt.lighter(Material.background, 2)
property color darkerBackground: Qt.darker(Material.background, 1.20)
property color lighterBackground: Qt.lighter(Material.background, 1.10)
property color darkerDialogBackground: Qt.darker(Material.dialogColor, 1.20)
property color notificationBackground: Qt.lighter(Material.background, 1.5)
property color colorCredit: "#ff80ff80"
property color colorDebit: "#ffff8080"
property color colorInfo: Material.accentColor
property color colorWarning: 'yellow'
property color colorError: '#ffff8080'
property color colorProgress: '#ffffff80'
property color colorDone: '#ff80ff80'
property color colorValidBackground: '#ff008000'
property color colorInvalidBackground: '#ff800000'
property color colorAcceptable: '#ff8080ff'
property color colorOk: colorDone
property color colorLightningLocal: "#6060ff"
property color colorLightningLocalReserve: "#0000a0"
property color colorLightningRemote: "yellow"
property color colorLightningRemoteReserve: Qt.darker(colorLightningRemote, 1.5)
property color colorChannelOpen: "#ff80ff80"
property color colorPiechartTotal: Material.accentColor
property color colorPiechartOnchain: Qt.darker(Material.accentColor, 1.50)
property color colorPiechartFrozen: 'gray'
property color colorPiechartLightning: 'orange'
property color colorPiechartLightningFrozen: Qt.darker('orange', 1.20)
property color colorPiechartUnconfirmed: Qt.darker(Material.accentColor, 2.00)
property color colorPiechartUnmatured: 'magenta'
property color colorPiechartParticipant: 'gray'
property color colorPiechartSignature: 'yellow'
property color colorAddressExternal: "#8af296" //Qt.rgba(0,1,0,0.5)
property color colorAddressInternal: "#ffff00" //Qt.rgba(1,0.93,0,0.75)
property color colorAddressUsed: Qt.rgba(0.5,0.5,0.5,1)
property color colorAddressUsedWithBalance: Qt.rgba(0.75,0.75,0.75,1)
property color colorAddressFrozen: Qt.rgba(0.5,0.5,1,1)
property color colorAddressBilling: "#8cb3f2"
property color colorAddressSwap: colorAddressBilling
property color colorAddressAccounting: "#ff9b45"
function colorAlpha(baseColor, alpha) {
return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, alpha)
}
}
================================================
FILE: electrum/gui/qml/components/CpfpBumpFeeDialog.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
ElDialog {
id: dialog
required property QtObject cpfpfeebumper
title: qsTr('Bump Fee')
iconSource: Qt.resolvedUrl('../../icons/rocket.png')
width: parent.width
height: parent.height
padding: 0
ColumnLayout {
anchors.fill: parent
spacing: 0
Flickable {
Layout.fillWidth: true
Layout.fillHeight: true
leftMargin: constants.paddingLarge
rightMargin: constants.paddingLarge
contentHeight: rootLayout.height
clip: true
interactive: height < contentHeight
GridLayout {
id: rootLayout
width: parent.width
columns: 2
Label {
Layout.fillWidth: true
text: qsTr('A CPFP is a transaction that sends an unconfirmed output back to yourself, with a high fee.')
wrapMode: Text.Wrap
}
HelpButton {
heading: qsTr('CPFP - Child Pays For Parent')
helptext: qsTr('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.')
+ '
',
qsTr('Please save these %1 words on paper (order is important).').arg(numwords),
qsTr('This seed will allow you to recover your wallet in case of computer failure.'),
'
',
'' + qsTr('WARNING') + ':',
'
',
'
' + qsTr('Never disclose your seed.') + '
',
'
' + qsTr('Never type it on a website.') + '
',
'
' + qsTr('Do not store it electronically.') + '
',
'
'
]
warningtext.text = t.join(' ')
}
Flickable {
anchors.fill: parent
contentHeight: mainLayout.height
clip:true
interactive: height < contentHeight
GridLayout {
id: mainLayout
width: parent.width
columns: 1
InfoTextArea {
id: warningtext
Layout.fillWidth: true
iconStyle: InfoTextArea.IconStyle.Warn
}
Label {
Layout.topMargin: constants.paddingMedium
Layout.fillWidth: true
wrapMode: Text.Wrap
text: qsTr('Your wallet generation seed is:')
}
SeedTextArea {
id: seedtext
readOnly: true
Layout.fillWidth: true
BusyIndicator {
anchors.centerIn: parent
height: parent.height * 2/3
visible: seedtext.text == ''
}
}
Component.onCompleted : {
setWarningText(12)
}
}
}
Component.onCompleted: {
bitcoin.generateSeed(wizard_data['seed_type'])
}
Bitcoin {
id: bitcoin
onGeneratedSeedChanged: {
seedtext.text = generatedSeed
setWarningText(generatedSeed.split(' ').length)
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCEnterExt.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "../controls"
WizardComponent {
id: root
securePage: true
valid: true
property int cosigner: 0
function apply() {
var seed_extend = extendcb.checked
if (cosigner) {
wizard_data['multisig_cosigner_data'][cosigner.toString()]['seed_extend'] = seed_extend
wizard_data['multisig_cosigner_data'][cosigner.toString()]['seed_extra_words'] = seed_extend ? customwordstext.text : ''
} else {
wizard_data['seed_extend'] = seed_extend
wizard_data['seed_extra_words'] = seed_extend ? customwordstext.text : ''
}
}
function checkValid() {
valid = false
validationtext.text = ''
if (extendcb.checked && customwordstext.text == '') {
return
} else {
// passphrase is either disabled or filled with text
apply()
if (cosigner && wizard_data['multisig_cosigner_data'][cosigner.toString()]['seed_variant'] == 'electrum') {
// check if master keys are not duplicated after entering passphrase
if (wiz.hasDuplicateMasterKeys(wizard_data)) {
validationtext.text = qsTr('Error: duplicate master public key')
return
}
}
}
valid = true
}
Flickable {
anchors.fill: parent
contentHeight: mainLayout.height
clip: true
interactive: height < contentHeight
ColumnLayout {
id: mainLayout
width: parent.width
spacing: constants.paddingLarge
InfoTextArea {
id: validationtext
Layout.fillWidth: true
Layout.columnSpan: 2
visible: text
iconStyle: InfoTextArea.IconStyle.Error
}
Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
text: [
qsTr('You may extend your seed with custom words.'),
qsTr('Your seed extension must be saved together with your seed.'),
qsTr('Note that this is NOT your encryption password.'),
' ',
qsTr('Do not enable it unless you know what it does!'),
].join(' ')
}
ElCheckBox {
id: extendcb
Layout.columnSpan: 2
Layout.fillWidth: true
text: qsTr('Extend seed with custom words')
onCheckedChanged: checkValid()
}
TextField {
id: customwordstext
enabled: extendcb.checked
Layout.fillWidth: true
Layout.columnSpan: 2
placeholderText: qsTr('Enter your custom word(s)')
inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase
onTextChanged: startValidationTimer()
}
}
}
function startValidationTimer() {
valid = false
validationTimer.restart()
}
Timer {
id: validationTimer
interval: 250
repeat: false
onTriggered: checkValid()
}
Component.onCompleted: {
if (wizard_data['wallet_type'] == 'multisig') {
if ('multisig_current_cosigner' in wizard_data)
cosigner = wizard_data['multisig_current_cosigner']
}
checkValid()
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCHaveMasterKey.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "../controls"
WizardComponent {
id: root
securePage: true
valid: false
property int cosigner: 0
property int participants: 0
property string multisigMasterPubkey
function apply() {
applyMasterKey(masterkey_ta.text)
}
function applyMasterKey(key) {
key = key.trim()
if (cosigner) {
wizard_data['multisig_cosigner_data'][cosigner.toString()]['master_key'] = key
} else {
wizard_data['master_key'] = key
}
}
function verifyMasterKey(key) {
valid = false
validationtext.text = ''
key = key.trim()
if (!key) {
validationtext.text = ''
return false
}
if (!bitcoin.verifyMasterKey(key, wizard_data['wallet_type'])) {
validationtext.text = qsTr('Error: invalid master key')
return false
}
if (cosigner) {
applyMasterKey(key)
if (wiz.hasDuplicateMasterKeys(wizard_data)) {
validationtext.text = qsTr('Error: duplicate master public key')
return false
}
if (wiz.hasHeterogeneousMasterKeys(wizard_data)) {
validationtext.text = qsTr('Error: master public key types do not match')
return false
}
}
return valid = true
}
ColumnLayout {
width: parent.width
Label {
Layout.fillWidth: true
visible: cosigner
text: qsTr('Here is your master public key. Please share it with your cosigners')
wrapMode: Text.Wrap
}
TextHighlightPane {
Layout.fillWidth: true
visible: cosigner
RowLayout {
width: parent.width
Label {
Layout.fillWidth: true
text: multisigMasterPubkey
font.pixelSize: constants.fontSizeMedium
font.family: FixedFont
wrapMode: Text.Wrap
}
ToolButton {
icon.source: '../../../icons/share.png'
icon.color: 'transparent'
onClicked: {
var dialog = app.genericShareDialog.createObject(app, {
title: qsTr('Master public key'),
text: multisigMasterPubkey
})
dialog.open()
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
Layout.topMargin: constants.paddingLarge
Layout.bottomMargin: constants.paddingLarge
visible: cosigner
color: Material.accentColor
}
Label {
text: qsTr('Cosigner #%1 of %2').arg(cosigner).arg(participants)
visible: cosigner
}
Label {
Layout.fillWidth: true
text: cosigner
? [qsTr('Please enter the master public key (xpub) of your cosigner.'),
qsTr('Enter their master private key (xprv) if you want to be able to sign for them.')
].join('\n')
: [qsTr('Please enter your master private key (xprv).'),
qsTr('You can also enter a public key (xpub) here, but be aware you will then create a watch-only wallet if all cosigners are added using public keys')
].join('\n')
wrapMode: Text.Wrap
}
RowLayout {
ElTextArea {
id: masterkey_ta
Layout.fillWidth: true
Layout.minimumHeight: 160
font.family: FixedFont
wrapMode: TextEdit.WrapAnywhere
onTextChanged: {
if (anyActiveFocus) {
verifyMasterKey(text)
}
}
inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase
background: PaneInsetBackground {
baseColor: constants.darkerDialogBackground
}
}
ColumnLayout {
Layout.alignment: Qt.AlignTop
ToolButton {
icon.source: '../../../icons/paste.png'
icon.height: constants.iconSizeMedium
icon.width: constants.iconSizeMedium
onClicked: {
if (verifyMasterKey(AppController.clipboardToText()))
masterkey_ta.text = AppController.clipboardToText()
else
masterkey_ta.text = ''
}
}
ToolButton {
icon.source: '../../../icons/qrcode.png'
icon.height: constants.iconSizeMedium
icon.width: constants.iconSizeMedium
scale: 1.2
onClicked: {
var dialog = app.scanDialog.createObject(app, {
hint: cosigner
? qsTr('Scan a cosigner master public key')
: qsTr('Scan a master key')
})
dialog.onFoundText.connect(function(data) {
if (verifyMasterKey(data))
masterkey_ta.text = data
else
masterkey_ta.text = ''
dialog.close()
})
dialog.open()
}
}
}
}
TextArea {
id: validationtext
visible: text
Layout.fillWidth: true
readOnly: true
wrapMode: TextInput.WordWrap
background: Rectangle {
color: 'transparent'
}
}
}
Bitcoin {
id: bitcoin
onValidationMessageChanged: {
validationtext.text = validationMessage
}
}
Component.onCompleted: {
if (wizard_data['wallet_type'] == 'multisig') {
if ('multisig_current_cosigner' in wizard_data)
cosigner = wizard_data['multisig_current_cosigner']
participants = wizard_data['multisig_participants']
if ('multisig_master_pubkey' in wizard_data) {
multisigMasterPubkey = wizard_data['multisig_master_pubkey']
}
}
Qt.callLater(masterkey_ta.forceActiveFocus)
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCHaveSeed.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "../controls"
WizardComponent {
id: root
securePage: true
valid: false
property bool is2fa: false
property int cosigner: 0
property int participants: 0
property string multisigMasterPubkey: wizard_data['multisig_master_pubkey']
property string _seedType
property string _validationMessage
property bool _canPassphrase
property bool _seedValid
function apply() {
if (cosigner) {
wizard_data['multisig_cosigner_data'][cosigner.toString()]['seed'] = seedtext.text
wizard_data['multisig_cosigner_data'][cosigner.toString()]['seed_variant'] = seed_variant_cb.currentValue
wizard_data['multisig_cosigner_data'][cosigner.toString()]['seed_type'] = _seedType
wizard_data['multisig_cosigner_data'][cosigner.toString()]['seed_extend'] = _canPassphrase
} else {
wizard_data['seed'] = seedtext.text
wizard_data['seed_variant'] = seed_variant_cb.currentValue
wizard_data['seed_type'] = _seedType
wizard_data['seed_extend'] = _canPassphrase
// determine script type from electrum seed type
// (used to limit script type options for bip39 cosigners)
if (wizard_data['wallet_type'] == 'multisig' && seed_variant_cb.currentValue == 'electrum') {
wizard_data['script_type'] = {
'standard': 'p2sh',
'segwit': 'p2wsh'
}[_seedType]
}
}
}
function setSeedTypeHelpText() {
var t = {
'electrum': [
// not shown as electrum is the default seed type anyways and the name is self-explanatory
qsTr('Electrum seeds are the default seed type.'),
qsTr('If you are restoring from a seed previously created by Electrum, choose this option')
].join(' '),
'bip39': [
qsTr('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'),
qsTr('BIP39 seeds do not include a version number, which compromises compatibility with future software.'),
].join(' '),
'slip39': [
qsTr('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'),
].join(' ')
}
infotext.text = t[seed_variant_cb.currentValue]
infotext.visible = !cosigner && !is2fa && seed_variant_cb.currentValue != 'electrum'
}
function checkValid() {
valid = false
_seedValid = false
var verifyResult = wiz.verifySeed(seedtext.text, seed_variant_cb.currentValue, wizard_data['wallet_type'])
_validationMessage = verifyResult.message
_seedType = verifyResult.type
_canPassphrase = verifyResult.can_passphrase
if (!cosigner || !verifyResult.valid) {
_seedValid = verifyResult.valid
} else {
// bip39 validate after derivation path is known
if (seed_variant_cb.currentValue == 'electrum') {
apply()
if (wiz.hasDuplicateMasterKeys(wizard_data)) {
_validationMessage = qsTr('Error: duplicate master public key')
return
} else if (wiz.hasHeterogeneousMasterKeys(wizard_data)) {
_validationMessage = qsTr('Error: master public key types do not match')
return
} else {
_seedValid = true
}
} else {
_seedValid = true
}
}
valid = _seedValid
}
Flickable {
anchors.fill: parent
contentHeight: mainLayout.height
clip:true
interactive: height < contentHeight
GridLayout {
id: mainLayout
width: parent.width
columns: 2
Label {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: cosigner
text: qsTr('Here is your master public key. Please share it with your cosigners')
wrapMode: Text.Wrap
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: cosigner
RowLayout {
width: parent.width
Label {
Layout.fillWidth: true
text: multisigMasterPubkey
font.pixelSize: constants.fontSizeMedium
font.family: FixedFont
wrapMode: Text.Wrap
}
ToolButton {
icon.source: '../../../icons/share.png'
icon.color: 'transparent'
onClicked: {
var dialog = app.genericShareDialog.createObject(app,
{ title: qsTr('Master public key'), text: multisigMasterPubkey }
)
dialog.open()
}
}
}
}
Rectangle {
Layout.columnSpan: 2
Layout.preferredWidth: parent.width
Layout.preferredHeight: 1
Layout.topMargin: constants.paddingLarge
Layout.bottomMargin: constants.paddingLarge
visible: cosigner
color: Material.accentColor
}
Label {
Layout.columnSpan: 2
visible: cosigner
text: qsTr('Cosigner #%1 of %2').arg(cosigner).arg(participants)
}
Label {
Layout.fillWidth: true
visible: !is2fa
text: qsTr('Seed Type')
}
ComboBox {
id: seed_variant_cb
visible: !is2fa
textRole: 'text'
valueRole: 'value'
model: [
{ text: 'Electrum', value: 'electrum' },
{ text: 'BIP39', value: 'bip39' }
]
onActivated: {
setSeedTypeHelpText()
checkIsLast()
checkValid()
}
}
InfoTextArea {
id: infotext
Layout.fillWidth: true
Layout.columnSpan: 2
Layout.bottomMargin: constants.paddingLarge
}
SeedTextArea {
id: seedtext
Layout.fillWidth: true
Layout.columnSpan: 2
placeholderText: cosigner ? qsTr('Enter cosigner seed') : qsTr('Enter your seed')
indicatorValid: root._seedValid
? root._seedType == 'bip39' && root._validationMessage
? false
: root._seedValid
: root._seedValid
indicatorText: root._validationMessage
? root._validationMessage
: root._seedType
onTextChanged: {
startValidationTimer()
}
}
}
}
function startValidationTimer() {
valid = false
root._seedType = ''
root._validationMessage = ''
validationTimer.restart()
}
Timer {
id: validationTimer
interval: 500
repeat: false
onTriggered: {
checkValid()
// checkIsLast depends on 'seed_extend'(_canPassphrase) getting set in apply()
checkIsLast()
}
}
Component.onCompleted: {
if (wizard_data['wallet_type'] == '2fa') {
is2fa = true
} else if (wizard_data['wallet_type'] == 'multisig') {
participants = wizard_data['multisig_participants']
if ('multisig_current_cosigner' in wizard_data)
cosigner = wizard_data['multisig_current_cosigner']
}
setSeedTypeHelpText()
Qt.callLater(seedtext.forceActiveFocus)
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCImport.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import org.electrum 1.0
import "../controls"
WizardComponent {
id: root
securePage: true
valid: false
function apply() {
if (bitcoin.isAddressList(import_ta.text)) {
wizard_data['address_list'] = import_ta.text
} else if (bitcoin.isPrivateKeyList(import_ta.text)) {
wizard_data['private_key_list'] = import_ta.text
}
}
function verify(text) {
return bitcoin.isAddressList(text) || bitcoin.isPrivateKeyList(text)
}
ColumnLayout {
width: parent.width
height: parent.height
InfoTextArea {
Layout.preferredWidth: parent.width
text: qsTr('Enter a list of Bitcoin addresses (this will create a watching-only wallet), or a list of private keys.')
}
RowLayout {
Layout.topMargin: constants.paddingMedium
Layout.fillHeight: true
ElTextArea {
id: import_ta
Layout.fillWidth: true
Layout.fillHeight: true
font.family: FixedFont
wrapMode: TextEdit.WrapAnywhere
onTextChanged: valid = verify(text)
inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase
background: PaneInsetBackground {
baseColor: constants.darkerDialogBackground
}
}
ColumnLayout {
Layout.alignment: Qt.AlignTop
ToolButton {
icon.source: '../../../icons/paste.png'
icon.height: constants.iconSizeMedium
icon.width: constants.iconSizeMedium
onClicked: {
if (verify(AppController.clipboardToText())) {
if (import_ta.text != '')
import_ta.text = import_ta.text + '\n'
import_ta.text = import_ta.text + AppController.clipboardToText()
}
}
}
ToolButton {
icon.source: '../../../icons/qrcode.png'
icon.height: constants.iconSizeMedium
icon.width: constants.iconSizeMedium
scale: 1.2
onClicked: {
var dialog = app.scanDialog.createObject(app, {
hint: bitcoin.isAddressList(import_ta.text)
? qsTr('Scan another address')
: bitcoin.isPrivateKeyList(import_ta.text)
? qsTr('Scan another private key')
: qsTr('Scan a private key or an address')
})
dialog.onFoundText.connect(function(data) {
if (verify(data)) {
if (import_ta.text != '')
import_ta.text = import_ta.text + '\n'
import_ta.text = import_ta.text + data
}
dialog.close()
})
dialog.open()
}
}
}
}
}
Bitcoin {
id: bitcoin
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCKeystoreType.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import "../controls"
WizardComponent {
valid: keystoregroup.checkedButton !== null
function apply() {
wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype
}
ButtonGroup {
id: keystoregroup
}
ColumnLayout {
width: parent.width
Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
text: qsTr('Do you want to create a new seed, restore using an existing seed, or restore from master key?')
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: keystoregroup
property string keystoretype: 'createseed'
checked: true
text: qsTr('Create a new seed')
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: keystoregroup
property string keystoretype: 'haveseed'
text: qsTr('I already have a seed')
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: keystoregroup
property string keystoretype: 'masterkey'
text: qsTr('Use a master key')
}
ElRadioButton {
Layout.fillWidth: true
enabled: false
visible: false
ButtonGroup.group: keystoregroup
property string keystoretype: 'hardware'
text: qsTr('Use a hardware device')
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCMultisig.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import org.electrum 1.0
import "../controls"
WizardComponent {
id: root
valid: true
property int participants: 2
property int signatures: 2
onParticipantsChanged: {
if (participants < signatures)
signatures = participants
piechart.updateSlices()
}
onSignaturesChanged: {
piechart.updateSlices()
}
function apply() {
wizard_data['multisig_participants'] = participants
wizard_data['multisig_signatures'] = signatures
wizard_data['multisig_cosigner_data'] = {}
}
Flickable {
anchors.fill: parent
contentHeight: rootLayout.height
clip:true
interactive: height < contentHeight
ColumnLayout {
id: rootLayout
width: parent.width
InfoTextArea {
Layout.preferredWidth: parent.width
text: qsTr('Choose the number of participants, and the number of signatures needed to unlock funds in your wallet.')
}
Piechart {
id: piechart
Layout.preferredWidth: parent.width * 1/2
Layout.alignment: Qt.AlignHCenter
Layout.preferredHeight: 200 // TODO
showLegend: false
innerOffset: 3
function updateSlices() {
var s = []
for (let i=0; i < participants; i++) {
var item = {
v: (1/participants),
color: i < signatures ? constants.colorPiechartSignature : constants.colorPiechartParticipant
}
s.push(item)
}
piechart.slices = s
}
}
Label {
text: qsTr('Number of cosigners: %1').arg(participants)
}
Slider {
id: participants_slider
Layout.preferredWidth: parent.width * 4/5
Layout.alignment: Qt.AlignHCenter
snapMode: Slider.SnapAlways
stepSize: 1
from: 2
to: 15
onValueChanged: {
if (activeFocus)
participants = value
}
}
Label {
text: qsTr('Number of signatures: %1').arg(signatures)
}
Slider {
id: signatures_slider
Layout.preferredWidth: parent.width * 4/5
Layout.alignment: Qt.AlignHCenter
snapMode: Slider.SnapAlways
stepSize: 1
from: 1
to: participants
value: signatures
onValueChanged: {
if (activeFocus)
signatures = value
}
}
}
}
Component.onCompleted: piechart.updateSlices()
}
================================================
FILE: electrum/gui/qml/components/wizard/WCProxyConfig.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import "../controls"
WizardComponent {
valid: true
title: qsTr('Proxy')
function apply() {
wizard_data['proxy'] = pc.toProxyDict()
}
ColumnLayout {
width: parent.width
spacing: constants.paddingLarge
ProxyConfig {
id: pc
Layout.fillWidth: true
proxy_enabled: false
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCScriptAndDerivation.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import ".."
import "../controls"
WizardComponent {
valid: false
property bool isMultisig: false
property int cosigner: 0
property int participants: 0
function apply() {
if (cosigner) {
wizard_data['multisig_cosigner_data'][cosigner.toString()]['script_type'] = scripttypegroup.checkedButton.scripttype
wizard_data['multisig_cosigner_data'][cosigner.toString()]['derivation_path'] = derivationpathtext.text
} else {
wizard_data['script_type'] = scripttypegroup.checkedButton.scripttype
wizard_data['derivation_path'] = derivationpathtext.text
}
}
function getScriptTypePurposeDict() {
return {
'p2pkh': 44,
'p2wpkh-p2sh': 49,
'p2wpkh': 84
}
}
function getMultisigScriptTypePurposeDict() {
return {
'p2sh': 45,
'p2wsh-p2sh': 48,
'p2wsh': 48
}
}
function validate() {
valid = false
validationtext.text = ''
var p = isMultisig ? getMultisigScriptTypePurposeDict() : getScriptTypePurposeDict()
if (!scripttypegroup.checkedButton.scripttype in p)
return
if (!bitcoin.verifyDerivationPath(derivationpathtext.text)) {
validationtext.text = qsTr('Invalid derivation path')
return
}
if (isMultisig && cosigner) {
apply()
if (wiz.hasDuplicateMasterKeys(wizard_data)) {
validationtext.text = qsTr('Error: duplicate master public key')
return
} else if (wiz.hasHeterogeneousMasterKeys(wizard_data)) {
validationtext.text = qsTr('Error: master public key types do not match')
return
}
}
valid = true
}
function setDerivationPath() {
var p = isMultisig ? getMultisigScriptTypePurposeDict() : getScriptTypePurposeDict()
var scripttype = scripttypegroup.checkedButton.scripttype
if (isMultisig) {
if (scripttype == 'p2sh')
derivationpathtext.text = "m/" + p[scripttype] + "'/0"
else
derivationpathtext.text = "m/" + p[scripttype] + "'/"
+ (Network.isTestNet ? 1 : 0) + "'/0'/"
+ (scripttype == 'p2wsh' ? 2 : 1) + "'"
} else {
derivationpathtext.text =
"m/" + p[scripttypegroup.checkedButton.scripttype] + "'/"
+ (Network.isTestNet ? 1 : 0) + "'/0'"
}
}
ButtonGroup {
id: scripttypegroup
onCheckedButtonChanged: {
setDerivationPath()
}
}
Flickable {
anchors.fill: parent
contentHeight: mainLayout.height
clip:true
interactive: height < contentHeight
ColumnLayout {
id: mainLayout
width: parent.width
Label {
Layout.fillWidth: true
text: qsTr('Choose the type of addresses in your wallet.')
wrapMode: Text.Wrap
}
// standard
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: scripttypegroup
property string scripttype: 'p2pkh'
text: qsTr('legacy (p2pkh)')
visible: !isMultisig
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: scripttypegroup
property string scripttype: 'p2wpkh-p2sh'
text: qsTr('wrapped segwit (p2wpkh-p2sh)')
visible: !isMultisig
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: scripttypegroup
property string scripttype: 'p2wpkh'
checked: !isMultisig
text: qsTr('native segwit (p2wpkh)')
visible: !isMultisig
}
// multisig
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: scripttypegroup
property string scripttype: 'p2sh'
text: qsTr('legacy multisig (p2sh)')
visible: isMultisig
enabled: !cosigner || wizard_data['script_type'] == 'p2sh'
checked: cosigner ? wizard_data['script_type'] == 'p2sh' : false
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: scripttypegroup
property string scripttype: 'p2wsh-p2sh'
text: qsTr('p2sh-segwit multisig (p2wsh-p2sh)')
visible: isMultisig
enabled: !cosigner || wizard_data['script_type'] == 'p2wsh-p2sh'
checked: cosigner ? wizard_data['script_type'] == 'p2wsh-p2sh' : false
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: scripttypegroup
property string scripttype: 'p2wsh'
text: qsTr('native segwit multisig (p2wsh)')
visible: isMultisig
enabled: !cosigner || wizard_data['script_type'] == 'p2wsh'
checked: cosigner ? wizard_data['script_type'] == 'p2wsh' : isMultisig
}
InfoTextArea {
Layout.fillWidth: true
text: qsTr('You can override the suggested derivation path.') + ' ' +
qsTr('If you are not sure what this is, leave this field unchanged.')
}
Label {
text: qsTr('Derivation path')
}
TextField {
id: derivationpathtext
Layout.fillWidth: true
Layout.leftMargin: constants.paddingMedium
inputMethodHints: Qt.ImhNoPredictiveText
onTextChanged: validate()
}
InfoTextArea {
id: validationtext
Layout.fillWidth: true
visible: text
iconStyle: InfoTextArea.IconStyle.Error
}
Pane {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: constants.paddingLarge
padding: 0
visible: !isMultisig
background: Rectangle {
color: Qt.lighter(Material.dialogColor, 1.5)
}
FlatButton {
text: qsTr('Detect Existing Accounts')
onClicked: {
var dialog = bip39recoveryDialog.createObject(mainLayout, {
walletType: wizard_data['wallet_type'],
seed: wizard_data['seed'],
seedExtraWords: wizard_data['seed_extra_words']
})
dialog.accepted.connect(function () {
// select matching script type button and set derivation path
for (var i = 0; i < scripttypegroup.buttons.length; i++) {
var btn = scripttypegroup.buttons[i]
if (btn.visible && btn.scripttype == dialog.scriptType) {
btn.checked = true
derivationpathtext.text = dialog.derivationPath
return
}
}
})
dialog.open()
}
}
}
}
}
Bitcoin {
id: bitcoin
}
Component {
id: bip39recoveryDialog
BIP39RecoveryDialog { }
}
Component.onCompleted: {
isMultisig = wizard_data['wallet_type'] == 'multisig'
if (isMultisig) {
participants = wizard_data['multisig_participants']
if ('multisig_current_cosigner' in wizard_data)
cosigner = wizard_data['multisig_current_cosigner']
validate()
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCServerConfig.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import "../controls"
WizardComponent {
valid: sc.addressValid
last: true
title: qsTr('Server')
function apply() {
wizard_data['server'] = sc.address
wizard_data['autoconnect'] = sc.serverConnectMode == ServerConnectModeComboBox.Mode.Autoconnect
wizard_data['one_server'] = sc.serverConnectMode == ServerConnectModeComboBox.Mode.Single
}
ColumnLayout {
anchors.fill: parent
spacing: constants.paddingLarge
ServerConfig {
id: sc
Layout.fillWidth: true
Layout.fillHeight: true
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCShowMasterPubkey.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import org.electrum 1.0
import "../controls"
WizardComponent {
valid: true
property string masterPubkey: wizard_data['multisig_master_pubkey']
ColumnLayout {
width: parent.width
Label {
text: qsTr('Here is your master public key. Please share it with your cosigners')
Layout.fillWidth: true
wrapMode: Text.Wrap
}
TextHighlightPane {
Layout.fillWidth: true
RowLayout {
width: parent.width
Label {
Layout.fillWidth: true
text: masterPubkey
font.pixelSize: constants.fontSizeMedium
font.family: FixedFont
wrapMode: Text.Wrap
}
ToolButton {
icon.source: '../../../icons/share.png'
icon.color: 'transparent'
onClicked: {
var dialog = app.genericShareDialog.createObject(app,
{ title: qsTr('Master public key'), text: masterPubkey }
)
dialog.open()
}
}
}
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCTermsOfUseRequest.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import "../controls"
WizardComponent {
valid: true
last: true
Flickable {
anchors.fill: parent
contentHeight: mainLayout.height
clip: true
interactive: height < contentHeight
ColumnLayout {
id: mainLayout
width: parent.width
spacing: constants.paddingLarge
Image {
Layout.fillWidth: true
fillMode: Image.PreserveAspectFit
source: Qt.resolvedUrl('../../../icons/electrum_presplash.png')
// reduce spacing a bit
Layout.topMargin: -100
Layout.bottomMargin: -200
}
Label {
Layout.fillWidth: true
text: qsTr("Terms of Use")
font.pixelSize: constants.fontSizeLarge
font.bold: true
horizontalAlignment: Text.AlignHCenter
}
Label {
Layout.fillWidth: true
text: wiz.termsOfUseText
wrapMode: Text.WordWrap
font.pixelSize: constants.fontSizeMedium
padding: constants.paddingSmall
}
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCWalletName.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import org.electrum 1.0
WizardComponent {
valid: wiz.isValidNewWalletName(wallet_name.text)
function apply() {
wizard_data['wallet_name'] = wallet_name.text
}
ColumnLayout {
width: parent.width
Label {
text: qsTr('Wallet name')
}
TextField {
id: wallet_name
Layout.fillWidth: true
focus: true
text: Daemon.suggestWalletName()
inputMethodHints: Qt.ImhNoPredictiveText
}
}
Component.onCompleted: {
wallet_name.forceActiveFocus()
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCWalletPassword.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import "../controls"
// We will only end up here if Daemon.singlePasswordEnabled == False.
// If there are existing wallets, the user must reuse the password of one of them.
// This way they are guided towards password unification.
// NOTE: This also needs to be enforced when changing a wallets password.
WizardComponent {
id: root
valid: isInputValid()
property bool enforceExistingPassword: Config.walletShouldUseSinglePassword && Daemon.availableWallets.rowCount() > 0
property bool passwordMatchesAnyExisting: false
function apply() {
wizard_data['password'] = password1.text
wizard_data['encrypt'] = password1.text != ''
}
function isInputValid() {
if (password1.text == "") {
return false
}
if (enforceExistingPassword) {
return passwordMatchesAnyExisting
}
return password1.text === password2.text && password1.text.length >= 6
}
Timer {
id: passwordComparisonTimer
interval: 500
repeat: false
onTriggered: {
root.passwordMatchesAnyExisting = Daemon.numWalletsWithPassword(password1.text) > 0
}
}
ColumnLayout {
anchors.fill: parent
Label {
Layout.fillWidth: true
text: !enforceExistingPassword ? qsTr('Enter password') : qsTr('Enter existing password')
wrapMode: Text.Wrap
}
PasswordField {
id: password1
onTextChanged: {
if (enforceExistingPassword) {
root.passwordMatchesAnyExisting = false
passwordComparisonTimer.restart()
}
}
}
Label {
text: qsTr('Enter password (again)')
visible: !enforceExistingPassword
}
PasswordField {
id: password2
showReveal: false
echoMode: password1.echoMode
visible: !enforceExistingPassword
}
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: constants.paddingXLarge
Layout.rightMargin: constants.paddingXLarge
Layout.topMargin: constants.paddingXLarge
visible: password1.text != '' && !enforceExistingPassword
Label {
Layout.rightMargin: constants.paddingLarge
text: qsTr('Strength')
}
PasswordStrengthIndicator {
Layout.fillWidth: true
password: password1.text
}
}
Item {
Layout.preferredWidth: 1
Layout.fillHeight: true
}
InfoTextArea {
Layout.alignment: Qt.AlignCenter
text: qsTr('Passwords don\'t match')
visible: (password1.text != password2.text) && !enforceExistingPassword
iconStyle: InfoTextArea.IconStyle.Warn
}
InfoTextArea {
Layout.alignment: Qt.AlignCenter
text: qsTr('Password too short')
visible: (password1.text == password2.text) && !valid && !enforceExistingPassword
iconStyle: InfoTextArea.IconStyle.Warn
}
InfoTextArea {
Layout.alignment: Qt.AlignCenter
Layout.fillWidth: true
visible: password1.text == "" && enforceExistingPassword
text: [
qsTr("Use the password of any existing wallet."),
qsTr("Creating new wallets with different passwords is not supported.")
].join("\n")
iconStyle: InfoTextArea.IconStyle.Info
}
InfoTextArea {
Layout.alignment: Qt.AlignCenter
Layout.fillWidth: true
visible: password1.text != "" && !valid && enforceExistingPassword
text: qsTr('Password does not match any existing wallets password.')
iconStyle: InfoTextArea.IconStyle.Warn
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCWalletType.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import "../controls"
WizardComponent {
valid: wallettypegroup.checkedButton !== null
function apply() {
// apply gets called when the page is rendered and implicitly
// sets the first radio button or the last selected one when going back
wizard_data['wallet_type'] = wallettypegroup.checkedButton.wallettype
delete wizard_data['seed_type']
if (wizard_data['wallet_type'] == 'standard')
wizard_data['seed_type'] = 'segwit'
else if (wizard_data['wallet_type'] == '2fa')
wizard_data['seed_type'] = '2fa_segwit'
else if (wizard_data['wallet_type'] == 'multisig')
wizard_data['seed_type'] = 'segwit'
}
ButtonGroup {
id: wallettypegroup
}
ColumnLayout {
width: parent.width
Label {
Layout.fillWidth: true
text: qsTr('What kind of wallet do you want to create?')
wrapMode: Text.Wrap
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: wallettypegroup
property string wallettype: 'standard'
checked: true
text: qsTr('Standard Wallet')
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: wallettypegroup
property string wallettype: '2fa'
text: qsTr('Wallet with two-factor authentication')
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: wallettypegroup
property string wallettype: 'multisig'
text: qsTr('Multi-signature wallet')
}
ElRadioButton {
Layout.fillWidth: true
ButtonGroup.group: wallettypegroup
property string wallettype: 'imported'
text: qsTr('Import Bitcoin addresses or private keys')
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WCWelcome.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import "../controls"
WizardComponent {
valid: true
wizard_title: qsTr('Network Configuration')
function apply() {
wizard_data['use_defaults'] = !config_proxy.checked && !config_server.checked
wizard_data['want_proxy'] = config_proxy.checked
wizard_data['autoconnect'] = !config_server.checked
}
ColumnLayout {
width: parent.width
Label {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: parent.width
text: qsTr("Optional settings to customize your network connection") + ":"
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHLeft
font.pixelSize: constants.fontSizeLarge
}
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 2*constants.paddingXLarge; Layout.bottomMargin: 2*constants.paddingXLarge
CheckBox {
id: config_proxy
text: qsTr('Configure Proxy')
checked: false
onCheckedChanged: checkIsLast()
}
CheckBox {
id: config_server
text: qsTr('Select Server')
checked: false
onCheckedChanged: checkIsLast()
}
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: parent.width
text: qsTr("If you are unsure what this is, leave them unchecked and Electrum will automatically select servers.")
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHLeft
font.pixelSize: constants.fontSizeMedium
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/Wizard.qml
================================================
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import org.electrum 1.0
import "../controls"
ElDialog {
id: wizard
focus: true
width: parent.width
height: parent.height
padding: 0
title: (pages.currentItem.wizard_title ? pages.currentItem.wizard_title : wizardTitle) +
(pages.currentItem.title ? ' - ' + pages.currentItem.title : '')
iconSource: '../../../icons/electrum.png'
// android back button triggers close() on Popups. Disabling close here,
// we handle that via Keys.onReleased event handler in the root layout.
closePolicy: Popup.NoAutoClose
property string wizardTitle
property var wizard_data
property alias pages: pages
property QtObject wiz
property alias finishButtonText: finishButton.text
function doClose() {
if (pages.currentIndex == 0)
reject()
else
pages.prev()
}
function _setWizardData(wdata) {
wizard_data = {}
Object.assign(wizard_data, wdata) // deep copy
// console.log('wizard data is now :' + JSON.stringify(wizard_data))
}
// helper function to dynamically load wizard page components
// and add them to the SwipeView
// Here we do some manual binding of page.valid -> pages.pagevalid and
// page.last -> pages.lastpage to propagate the state without the binding
// going stale.
function _loadNextComponent(view, wdata={}) {
// remove any existing pages after current page
while (pages.contentChildren[pages.currentIndex+1]) {
pages.takeItem(pages.currentIndex+1).destroy()
}
var url = Qt.resolvedUrl(wiz.viewToComponent(view))
var comp = Qt.createComponent(url)
if (comp.status == Component.Error) {
console.log(comp.errorString())
return null
}
// make a deepcopy of wdata and pass it to the component
var wdata_copy={}
Object.assign(wdata_copy, wdata)
var page = comp.createObject(pages, {wizard_data: wdata_copy})
page.validChanged.connect(function() {
if (page != pages.currentItem)
return
pages.pagevalid = page.valid
})
page.lastChanged.connect(function() {
if (page != pages.currentItem)
return
pages.lastpage = page.last
})
page.next.connect(function() {
var newview = wiz.submit(page.wizard_data)
if (newview.view) {
console.log('next view: ' + newview.view)
var newpage = _loadNextComponent(newview.view, newview.wizard_data)
} else {
console.log('END')
}
})
page.finish.connect(function() {
// run wizard.submit() a final time, so that the navmap[view]['accept'] handler can run (if any)
var newview = wiz.submit(page.wizard_data)
_setWizardData(newview.wizard_data)
console.log('wizard finished')
// finish wizard
wizard.doAccept()
})
page.prev.connect(function() {
var wdata = wiz.prev()
})
pages.pagevalid = page.valid
pages.lastpage = page.last
return page
}
ColumnLayout {
anchors.fill: parent
spacing: 0
// root Item in Wizard, capture back button here and delegate to main
Keys.onReleased: {
if (event.key == Qt.Key_Back) {
console.log("Back button within wizard")
app.close() // this handles unwind of dialogs/stack
}
}
SwipeView {
id: pages
Layout.fillWidth: true
Layout.fillHeight: true
interactive: false
clip:true
function prev() {
currentItem.prev()
currentIndex = currentIndex - 1
_setWizardData(pages.contentChildren[currentIndex].wizard_data)
pages.pagevalid = pages.contentChildren[currentIndex].valid
pages.lastpage = pages.contentChildren[currentIndex].last
}
function next() {
currentItem.accept()
_setWizardData(pages.contentChildren[currentIndex].wizard_data)
currentItem.next()
currentIndex = currentIndex + 1
}
function finish() {
currentItem.accept()
_setWizardData(pages.contentChildren[currentIndex].wizard_data)
currentItem.finish()
}
property bool pagevalid: false
property bool lastpage: false
Component.onCompleted: {
_setWizardData({})
}
Binding {
target: AppController
property: 'secureWindow'
value: pages.contentChildren[pages.currentIndex].securePage
}
}
ButtonContainer {
Layout.fillWidth: true
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
visible: pages.currentIndex == 0
text: qsTr("Cancel")
onClicked: wizard.doReject()
}
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
visible: pages.currentIndex > 0
text: qsTr('Back')
onClicked: pages.prev()
}
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
text: qsTr("Next")
visible: !pages.lastpage
enabled: pages.pagevalid
onClicked: pages.next()
}
FlatButton {
id: finishButton
Layout.fillWidth: true
Layout.preferredWidth: 1
text: qsTr("Finish")
visible: pages.lastpage
enabled: pages.pagevalid
onClicked: pages.finish()
}
}
}
}
================================================
FILE: electrum/gui/qml/components/wizard/WizardComponent.qml
================================================
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
Pane {
id: root
signal next
signal finish
signal prev
signal accept
property var wizard_data : ({})
property bool valid
property bool last: false
property string wizard_title: ''
property string title: ''
property bool securePage: false
leftPadding: constants.paddingXLarge
rightPadding: constants.paddingXLarge
background: Rectangle {
color: Material.dialogColor
TapHandler {
onTapped: root.forceActiveFocus()
}
}
onAccept: {
apply()
}
// override this in descendants to put data from the view in wizard_data
function apply() { }
function checkIsLast() {
apply()
last = wizard.wiz.isLast(wizard_data)
}
Component.onCompleted: {
// NOTE: Use Qt.callLater to execute checkIsLast(), and by extension apply(),
// otherwise Component.onCompleted handler in descendants is processed
// _after_ apply() is called, which may lead to setting the wrong
// wizard_data keys if apply() depends on variables set in descendant
// Component.onCompleted handler.
Qt.callLater(checkIsLast)
// move focus to root of WizardComponent, otherwise Android back button
// might be missed in Wizard root Item.
root.forceActiveFocus()
}
}
================================================
FILE: electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java
================================================
package org.electrum.biometry;
import android.app.Activity;
import android.os.Build;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.content.Intent;
import android.hardware.biometrics.BiometricManager;
import android.hardware.biometrics.BiometricPrompt;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import android.util.Log;
import android.widget.Toast;
import java.nio.charset.Charset;
import java.security.KeyStore;
import java.util.concurrent.Executor;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import org.electrum.electrum.res.R;
public class BiometricActivity extends Activity {
private static final String TAG = "BiometricActivity";
private static final String KEY_NAME = "electrum_biometric_key";
private static final int RESULT_SETUP_FAILED = 101;
private static final int RESULT_POPUP_CANCELLED = 102;
private CancellationSignal cancellationSignal;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
Log.e(TAG, "Biometrics not supported on this Android version (requires API 30+)");
setResult(RESULT_CANCELED);
finish();
return;
}
handleIntent();
}
private void handleIntent() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return;
Intent intent = getIntent();
String action = intent.getStringExtra("action");
String authMessage = intent.getStringExtra("auth_message");
Executor executor = getMainExecutor();
BiometricPrompt biometricPrompt = new BiometricPrompt.Builder(this)
.setTitle("Electrum Wallet")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL)
.setSubtitle(authMessage)
.build();
cancellationSignal = new CancellationSignal();
BiometricPrompt.AuthenticationCallback callback = new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationError(int errorCode, CharSequence errString) {
super.onAuthenticationError(errorCode, errString);
Log.e(TAG, "Authentication error: " + errorCode + " " + errString);
if (
errorCode == BiometricPrompt.BIOMETRIC_ERROR_CANCELED ||
errorCode == BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.BIOMETRIC_ERROR_TIMEOUT
) {
setResult(RESULT_POPUP_CANCELLED);
} else {
setResult(RESULT_CANCELED);
}
finish();
}
@Override
public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
super.onAuthenticationSucceeded(result);
Log.d(TAG, "Authentication succeeded!");
handleAuthenticationSuccess(result);
}
@Override
public void onAuthenticationFailed() {
super.onAuthenticationFailed();
Log.d(TAG, "Authentication failed");
}
};
try {
if ("ENCRYPT".equals(action)) {
Cipher cipher = getCipher();
SecretKey secretKey = genSecretKey();
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
biometricPrompt.authenticate(new BiometricPrompt.CryptoObject(cipher), cancellationSignal, executor, callback);
} else if ("DECRYPT".equals(action)) {
String ivStr = intent.getStringExtra("iv");
byte[] iv = Base64.decode(ivStr, Base64.NO_WRAP);
Cipher cipher = getCipher();
SecretKey secretKey = getSecretKey();
cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
biometricPrompt.authenticate(new BiometricPrompt.CryptoObject(cipher), cancellationSignal, executor, callback);
} else {
finish();
}
} catch (Exception e) {
Log.e(TAG, "Setup error", e);
Toast.makeText(this, "Biometric setup failed: " + e.getMessage(), Toast.LENGTH_SHORT).show();
setResult(RESULT_SETUP_FAILED);
finish();
}
}
private void handleAuthenticationSuccess(BiometricPrompt.AuthenticationResult result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return;
try {
BiometricPrompt.CryptoObject cryptoObject = result.getCryptoObject();
Cipher cipher = cryptoObject.getCipher();
Intent intent = getIntent();
String action = intent.getStringExtra("action");
Intent resultIntent = new Intent();
if ("ENCRYPT".equals(action)) {
String data = intent.getStringExtra("data"); // wrap_key string to encrypt
byte[] encrypted = cipher.doFinal(data.getBytes(Charset.forName("UTF-8")));
resultIntent.putExtra("data", Base64.encodeToString(encrypted, Base64.NO_WRAP));
resultIntent.putExtra("iv", Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP));
} else {
String dataStr = intent.getStringExtra("data"); // Encrypted blob
byte[] encrypted = Base64.decode(dataStr, Base64.NO_WRAP);
byte[] decrypted = cipher.doFinal(encrypted);
resultIntent.putExtra("data", new String(decrypted, Charset.forName("UTF-8")));
}
setResult(RESULT_OK, resultIntent);
} catch (Exception e) {
Log.e(TAG, "Crypto error", e);
setResult(RESULT_CANCELED);
}
finish();
}
private SecretKey getSecretKey() throws Exception {
KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
keyStore.load(null);
return (SecretKey) keyStore.getKey(KEY_NAME, null);
}
private SecretKey genSecretKey() throws Exception {
// https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder?hl=en
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(KEY_NAME,
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG | KeyProperties.AUTH_DEVICE_CREDENTIAL);
keyGenerator.init(builder.build());
keyGenerator.generateKey();
return getSecretKey();
}
private Cipher getCipher() throws Exception {
return Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/"
+ KeyProperties.BLOCK_MODE_CBC + "/"
+ KeyProperties.ENCRYPTION_PADDING_PKCS7);
}
}
================================================
FILE: electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java
================================================
package org.electrum.biometry;
import android.content.Context;
import android.content.pm.PackageManager;
import android.hardware.biometrics.BiometricManager;
import android.hardware.fingerprint.FingerprintManager;
import android.os.Build;
public class BiometricHelper {
public static boolean isAvailable(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // API 30+
BiometricManager biometricManager = context.getSystemService(BiometricManager.class);
return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS;
}
return false;
}
}
================================================
FILE: electrum/gui/qml/java_classes/org/electrum/qr/SimpleScannerActivity.java
================================================
package org.electrum.qr;
import android.app.Activity;
import android.os.Bundle;
import android.os.Build;
import android.util.Log;
import android.content.Intent;
import android.Manifest;
import android.content.ClipData;
import android.content.ClipDescription;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import androidx.core.app.ActivityCompat;
import java.util.Arrays;
import de.markusfisch.android.barcodescannerview.widget.BarcodeScannerView;
import de.markusfisch.android.zxingcpp.ZxingCpp.Result;
import de.markusfisch.android.zxingcpp.ZxingCpp.ContentType;
import org.electrum.electrum.res.R; // package set in build.gradle
public class SimpleScannerActivity extends Activity {
private static final int MY_PERMISSIONS_CAMERA = 1002;
private BarcodeScannerView mScannerView = null;
final String TAG = "org.electrum.qr.SimpleScannerActivity";
private boolean mAlreadyRequestedPermissions = false;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.scanner_layout);
// change top text
Intent intent = getIntent();
String text = intent.getStringExtra(intent.EXTRA_TEXT);
TextView hintTextView = (TextView) findViewById(R.id.hint);
hintTextView.setText(text);
// bind "paste" button
Button btn = (Button) findViewById(R.id.paste_btn);
btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard.hasPrimaryClip()
&& (clipboard.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)
|| clipboard.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML))) {
ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0);
String clipboardText = item.getText().toString();
// limit size of content. avoid https://developer.android.com/reference/android/os/TransactionTooLargeException.html
if (clipboardText.length() > 512 * 1024) {
Toast.makeText(SimpleScannerActivity.this, "Clipboard contents too large.", Toast.LENGTH_SHORT).show();
return;
}
SimpleScannerActivity.this.setResultAndClose(null, clipboardText);
} else {
Toast.makeText(SimpleScannerActivity.this, "Clipboard is empty.", Toast.LENGTH_SHORT).show();
}
}
});
setupEdgeToEdge();
}
@Override
public void onResume() {
super.onResume();
if (this.hasPermission()) {
this.startCamera();
} else if (!mAlreadyRequestedPermissions) {
mAlreadyRequestedPermissions = true;
this.requestPermission();
}
}
@Override
public void onPause() {
super.onPause();
if (null != mScannerView) {
mScannerView.close(); // Stop camera on pause
}
}
private void startCamera() {
if (mScannerView == null) {
mScannerView = new BarcodeScannerView(this);
// Set crop ratio to 75% (this defines the square area shown in the scanner view)
mScannerView.setCropRatio(0.75f);
// allow tap to focus (note: some devices don't support autofocus which is enabled by default)
mScannerView.setTapToFocus();
// by default only Format.QR_CODE is set
ViewGroup contentFrame = (ViewGroup) findViewById(R.id.content_frame);
contentFrame.addView(mScannerView);
mScannerView.setOnBarcodeListener(result -> {
// Handle the scan result
this.setResultAndClose(result, null);
// Return false to stop scanning after first result
return false;
});
}
mScannerView.openAsync(); // Start camera on resume
}
private void setResultAndClose(Result scanResult, String textOnly) {
Intent resultIntent = new Intent();
if (textOnly != null) {
Log.v(TAG, "clipboard contentType TEXT");
resultIntent.putExtra("text", textOnly);
} else if (scanResult != null) {
if (scanResult.getContentType() == ContentType.TEXT) {
Log.v(TAG, "scanResult contentType TEXT");
resultIntent.putExtra("text", scanResult.getText());
} else if (scanResult.getContentType() == ContentType.BINARY) {
Log.v(TAG, "scanResult contentType BINARY");
resultIntent.putExtra("binary", scanResult.getRawBytes());
} else {
Log.v(TAG, "scanresult contenttype unknown");
}
}
setResult(Activity.RESULT_OK, resultIntent);
this.finish();
}
private boolean hasPermission() {
return (ActivityCompat.checkSelfPermission(this,
Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED);
}
private void requestPermission() {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.CAMERA},
MY_PERMISSIONS_CAMERA);
}
@Override
public void onRequestPermissionsResult(int requestCode,
String permissions[], int[] grantResults) {
switch (requestCode) {
case MY_PERMISSIONS_CAMERA: {
if (grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// permission was granted, yay!
this.startCamera();
} else {
// permission denied
//this.finish();
}
return;
}
}
}
private boolean enforcesEdgeToEdge() {
// if true the UI needs to be padded to be e2e compatible
return Build.VERSION.SDK_INT >= 35;
}
private void setupEdgeToEdge() {
if (!enforcesEdgeToEdge()) {
return;
}
// Get the root view and set up insets listener
getWindow().getDecorView().setOnApplyWindowInsetsListener((v, insets) -> {
android.graphics.Insets systemBars = insets.getInsets(WindowInsets.Type.systemBars());
// Apply padding to content frame to keep scanner focus area centered
ViewGroup contentFrame = findViewById(R.id.content_frame);
if (contentFrame != null) {
contentFrame.setPadding(
systemBars.left,
systemBars.top,
systemBars.right,
systemBars.bottom
);
}
// Apply top padding to hint text for status bar
TextView hintTextView = findViewById(R.id.hint);
if (hintTextView != null) {
hintTextView.setPadding(
hintTextView.getPaddingLeft(),
systemBars.top,
hintTextView.getPaddingRight(),
hintTextView.getPaddingBottom()
);
}
// Apply bottom margin to paste button for navigation bar
Button pasteButton = findViewById(R.id.paste_btn);
if (pasteButton != null) {
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) pasteButton.getLayoutParams();
params.bottomMargin = systemBars.bottom;
pasteButton.setLayoutParams(params);
}
return insets;
});
}
}
================================================
FILE: electrum/gui/qml/qeaddressdetails.py
================================================
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum.logging import get_logger
from electrum.util import UserFacingException
from .auth import auth_protect, AuthMixin
from .qetransactionlistmodel import QETransactionListModel
from .qetypes import QEAmount
from .qewallet import QEWallet
class QEAddressDetails(AuthMixin, QObject):
_logger = get_logger(__name__)
detailsChanged = pyqtSignal()
addressDeleteFailed = pyqtSignal([str], arguments=['message'])
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None
self._address = None
self._label = None
self._frozen = False
self._scriptType = None
self._status = None
self._balance = QEAmount()
self._pubkeys = None
self._privkey = None
self._derivationPath = None
self._numtx = 0
self._candelete = False
self._historyModel = None
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self):
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self.walletChanged.emit()
addressChanged = pyqtSignal()
@pyqtProperty(str, notify=addressChanged)
def address(self):
return self._address
@address.setter
def address(self, address: str):
if self._address != address:
self._logger.debug('address changed')
self._address = address
self.addressChanged.emit()
self.update()
@pyqtProperty(str, notify=detailsChanged)
def scriptType(self):
return self._scriptType
@pyqtProperty(QEAmount, notify=detailsChanged)
def balance(self):
return self._balance
@pyqtProperty('QStringList', notify=detailsChanged)
def pubkeys(self):
return self._pubkeys
@pyqtProperty(str, notify=detailsChanged)
def privkey(self):
return self._privkey
@pyqtProperty(str, notify=detailsChanged)
def derivationPath(self):
return self._derivationPath
@pyqtProperty(int, notify=detailsChanged)
def numTx(self):
return self._numtx
@pyqtProperty(bool, notify=detailsChanged)
def canDelete(self):
return self._candelete
frozenChanged = pyqtSignal()
@pyqtProperty(bool, notify=frozenChanged)
def isFrozen(self):
return self._frozen
labelChanged = pyqtSignal()
@pyqtProperty(str, notify=labelChanged)
def label(self):
return self._label
@pyqtSlot(bool)
def freeze(self, freeze: bool):
if freeze != self._frozen:
self._wallet.wallet.set_frozen_state_of_addresses([self._address], freeze=freeze)
self._frozen = freeze
self.frozenChanged.emit()
self._wallet.balanceChanged.emit()
@pyqtSlot(str)
def setLabel(self, label: str):
if label != self._label:
self._wallet.wallet.set_label(self._address, label)
self._label = label
self.labelChanged.emit()
historyModelChanged = pyqtSignal()
@pyqtProperty(QETransactionListModel, notify=historyModelChanged)
def historyModel(self):
if self._historyModel is None:
self._historyModel = QETransactionListModel(self._wallet.wallet,
onchain_domain=[self._address], include_lightning=False)
return self._historyModel
@pyqtSlot()
def requestShowPrivateKey(self):
self.retrieve_private_key()
@auth_protect(method='wallet')
def retrieve_private_key(self):
try:
self._privkey = self._wallet.wallet.export_private_key(self._address, self._wallet.password)
except Exception as e:
self._logger.error(f'problem retrieving privkey: {str(e)}')
self._privkey = ''
self.detailsChanged.emit()
@pyqtSlot(result=bool)
def deleteAddress(self):
assert self.canDelete
try:
self._wallet.wallet.delete_address(self._address)
self._wallet.historyModel.setDirty()
except UserFacingException as e:
self.addressDeleteFailed.emit(str(e))
return False
return True
def update(self):
if self._wallet is None:
self._logger.error('wallet undefined')
return
self._frozen = self._wallet.wallet.is_frozen_address(self._address)
self.frozenChanged.emit()
self._scriptType = self._wallet.wallet.get_txin_type(self._address)
self._label = self._wallet.wallet.get_label_for_address(self._address)
c, u, x = self._wallet.wallet.get_addr_balance(self._address)
self._balance = QEAmount(amount_sat=c + u + x)
self._pubkeys = self._wallet.wallet.get_public_keys(self._address)
self._derivationPath = self._wallet.wallet.get_address_path_str(self._address)
if self._wallet.derivationPrefix:
self._derivationPath = self._derivationPath.replace('m', self._wallet.derivationPrefix)
self._numtx = self._wallet.wallet.adb.get_address_history_len(self._address)
self._candelete = self.wallet.wallet.can_delete_address()
self.detailsChanged.emit()
================================================
FILE: electrum/gui/qml/qeaddresslistmodel.py
================================================
from typing import TYPE_CHECKING, List
from PyQt6.QtCore import pyqtSlot, QSortFilterProxyModel, pyqtSignal, pyqtProperty
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from electrum.logging import get_logger
from electrum.util import Satoshis
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .qeconfig import QEConfig
from .qetypes import QEAmount
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
from electrum.transaction import PartialTxInput
class QEAddressCoinFilterProxyModel(QSortFilterProxyModel):
_logger = get_logger(__name__)
def __init__(self, parent_model, parent=None):
super().__init__(parent)
self._filter_text = None
self._show_coins = True
self._show_addresses = True
self._show_used = False
self._parent_model = parent_model
self.setSourceModel(parent_model)
countChanged = pyqtSignal()
@pyqtProperty(int, notify=countChanged)
def count(self):
return self.rowCount(QModelIndex())
def filterAcceptsRow(self, s_row, s_parent):
parent_model = self.sourceModel()
addridx = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['addridx'])
if addridx is None: # coin
if not self._show_coins:
return False
else:
if not self._show_addresses:
return False
balance = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['balance'])
numtx = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['numtx'])
if balance.isEmpty and numtx and not self._show_used:
return False
if self._filter_text:
label = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['label'])
address = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['address'])
outpoint = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['outpoint'])
amount_i = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['amount'])
amount = parent_model.wallet.config.format_amount(amount_i.satsInt) if amount_i else None
filter_text = self._filter_text.casefold()
for item in [label, address, outpoint, amount]:
if item is not None and filter_text in str(item).casefold():
return True
return False
return True
showAddressesCoinsChanged = pyqtSignal()
@pyqtProperty(int, notify=showAddressesCoinsChanged)
def showAddressesCoins(self) -> int:
result = 0
if self._show_addresses:
result += 1
if self._show_coins:
result += 2
return result
@showAddressesCoins.setter
def showAddressesCoins(self, show_addresses_coins: int):
show_addresses = show_addresses_coins in [1, 3]
show_coins = show_addresses_coins in [2, 3]
if self._show_addresses != show_addresses or self._show_coins != show_coins:
self._show_addresses = show_addresses
self._show_coins = show_coins
self.invalidateFilter()
self.showAddressesCoinsChanged.emit()
showUsedChanged = pyqtSignal()
@pyqtProperty(bool, notify=showUsedChanged)
def showUsed(self) -> bool:
return self._show_used
@showUsed.setter
def showUsed(self, show_used: bool):
if self._show_used != show_used:
self._show_used = show_used
self.invalidateFilter()
self.showUsedChanged.emit()
filterTextChanged = pyqtSignal()
@pyqtProperty(str, notify=filterTextChanged)
def filterText(self) -> str:
return self._filter_text
@filterText.setter
def filterText(self, filter_text: str):
if self._filter_text != filter_text:
self._filter_text = filter_text
self.invalidateFilter()
self.filterTextChanged.emit()
class QEAddressCoinListModel(QAbstractListModel, QtEventListener):
_logger = get_logger(__name__)
# define listmodel rolemap
_ROLE_NAMES=('type', 'addridx', 'address', 'label', 'balance', 'numtx', 'held', 'height', 'amount', 'outpoint',
'short_outpoint', 'short_id', 'txid')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
_ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))
def __init__(self, wallet: 'Abstract_Wallet', parent=None):
super().__init__(parent)
self.wallet = wallet
self._items = []
self._filterModel = None
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
QEConfig.instance.freezeReusedAddressUtxosChanged.connect(lambda: self.setDirty())
self._dirty = True
self.initModel()
def on_destroy(self):
self.unregister_callbacks()
@qt_event_listener
def on_event_labels_received(self, wallet, labels):
if wallet == self.wallet:
self.setDirty()
def rowCount(self, index):
return len(self._items)
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
address = self._items[index.row()]
role_index = role - Qt.ItemDataRole.UserRole
try:
value = address[self._ROLE_NAMES[role_index]]
except KeyError:
return None
if isinstance(value, (bool, list, int, str, QEAmount)) or value is None:
return value
if isinstance(value, Satoshis):
return value.value
return str(value)
def clear(self):
self.beginResetModel()
self._items = []
self.endResetModel()
def addr_to_model(self, addrtype: str, addridx: int, address: str):
c, u, x = self.wallet.get_addr_balance(address)
item = {
'type': addrtype,
'addridx': addridx,
'address': address,
'numtx': self.wallet.adb.get_address_history_len(address),
'label': self.wallet.get_label_for_address(address),
'balance': QEAmount(amount_sat=c + u + x),
'held': self.wallet.is_frozen_address(address)
}
return item
def coin_to_model(self, addrtype: str, coin: 'PartialTxInput'):
txid = coin.prevout.txid.hex()
short_id = ''
# check below duplicated from TxInput as we cannot get short_id unambiguously
if coin.block_txpos is not None and coin.block_txpos >= 0:
short_id = str(coin.short_id)
item = {
'type': addrtype,
'amount': QEAmount(amount_sat=coin.value_sats()),
'address': coin.address,
'height': coin.block_height,
'outpoint': coin.prevout.to_str(),
'short_outpoint': coin.prevout.short_name(),
'short_id': short_id,
'txid': txid,
'label': self.wallet.get_label_for_txid(txid) or '',
'held': self.wallet.is_frozen_coin(coin),
'coin': coin
}
return item
@pyqtSlot()
def setDirty(self):
self._dirty = True
# initial model data
@pyqtSlot()
@pyqtSlot(bool)
def initModel(self, force: bool = False):
if not self._dirty and not force:
return
r_addresses = self.wallet.get_receiving_addresses()
c_addresses = self.wallet.get_change_addresses() if self.wallet.wallet_type != 'imported' else []
n_addresses = len(r_addresses) + len(c_addresses)
def insert_address(atype, address, addridx):
item = self.addr_to_model(atype, addridx, address)
self._items.append(item)
utxos = self.wallet.get_utxos([address])
utxos.sort(key=lambda x: x.block_height)
for i, coin in enumerate(utxos):
self._items.append(self.coin_to_model(atype, coin))
self.clear()
self.beginInsertRows(QModelIndex(), 0, n_addresses - 1)
if self.wallet.wallet_type != 'imported':
for i, address in enumerate(r_addresses):
insert_address('receive', address, i)
for i, address in enumerate(c_addresses):
insert_address('change', address, i)
else:
for i, address in enumerate(r_addresses):
insert_address('imported', address, i)
self.endInsertRows()
self._dirty = False
if self._filterModel is not None:
self._filterModel.invalidate()
@pyqtSlot(str)
def updateAddress(self, address):
for i, a in enumerate(self._items):
if a['address'] == address:
self.do_update(i, a)
return
@pyqtSlot(str)
def deleteAddress(self, address):
first = -1
last = -1
for i, a in enumerate(self._items):
if a['address'] == address:
if first < 0:
first = i
last = i
if not first >= 0:
return
self.beginRemoveRows(QModelIndex(), first, last)
self._items = self._items[0:first] + self._items[last+1:]
self.endRemoveRows()
def updateCoin(self, outpoint):
for i, a in enumerate(self._items):
if a.get('outpoint') == outpoint:
self.do_update(i, a)
return
def do_update(self, modelindex, modelitem):
mi = self.createIndex(modelindex, 0)
self._logger.debug(repr(modelitem))
if modelitem.get('outpoint'):
modelitem.update(self.coin_to_model(modelitem['type'], modelitem['coin']))
else:
modelitem.update(self.addr_to_model(modelitem['type'], modelitem['addridx'], modelitem['address']))
self._logger.debug(repr(modelitem))
self.dataChanged.emit(mi, mi, self._ROLE_KEYS)
filterModelChanged = pyqtSignal()
@pyqtProperty(QEAddressCoinFilterProxyModel, notify=filterModelChanged)
def filterModel(self):
if self._filterModel is None:
self._filterModel = QEAddressCoinFilterProxyModel(self)
return self._filterModel
@pyqtSlot(bool, list)
def setFrozenForItems(self, freeze: bool, items: List[str]):
self._logger.debug(f'set frozen to {freeze} for {items!r}')
coins = list(filter(lambda x: ':' in x, items))
if len(coins):
self.wallet.set_frozen_state_of_coins(coins, freeze)
for coin in coins:
self.updateCoin(coin)
addresses = list(filter(lambda x: ':' not in x, items))
if len(addresses):
self.wallet.set_frozen_state_of_addresses(addresses, freeze)
for address in addresses:
self.updateAddress(address)
================================================
FILE: electrum/gui/qml/qeapp.py
================================================
import re
import queue
import time
import os
import sys
import html
import threading
from functools import partial
from typing import TYPE_CHECKING, Set, List, Optional, Callable
from PyQt6.QtCore import (pyqtSlot, pyqtSignal, pyqtProperty, QObject, QT_VERSION_STR, PYQT_VERSION_STR,
qInstallMessageHandler, QTimer, QSortFilterProxyModel)
from PyQt6.QtGui import QGuiApplication
from PyQt6.QtQml import qmlRegisterType, QQmlApplicationEngine
import electrum
from electrum import version, constants
from electrum.i18n import _
from electrum.logging import Logger, get_logger
from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue
from electrum.network import Network
from electrum.plugin import run_hook
from electrum.gui.common_qt.util import get_font_id
from electrum.util import profiler
from .qeconfig import QEConfig
from .qedaemon import QEDaemon
from .qenetwork import QENetwork
from .qewallet import QEWallet
from .qeqr import QEQRParser, QEQRImageProvider, QEQRImageProviderHelper
from .qeqrscanner import QEQRScanner
from .qebitcoin import QEBitcoin
from .qefx import QEFX
from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer, FeeSlider
from .qeinvoice import QEInvoice, QEInvoiceParser
from .qepiresolver import QEPIResolver
from .qerequestdetails import QERequestDetails
from .qetypes import QEAmount, QEBytes
from .qeaddressdetails import QEAddressDetails
from .qetxdetails import QETxDetails
from .qechannelopener import QEChannelOpener
from .qelnpaymentdetails import QELnPaymentDetails
from .qechanneldetails import QEChannelDetails
from .qeswaphelper import QESwapHelper
from .qewizard import QENewWalletWizard, QEServerConnectWizard, QETermsOfUseWizard
from .qemodelfilter import QEFilterProxyModel
from .qebip39recovery import QEBip39RecoveryListModel
from .qebiometrics import QEBiometrics
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
from electrum.wallet import Abstract_Wallet
from electrum.daemon import Daemon
from electrum.plugin import Plugins
if 'ANDROID_DATA' in os.environ:
from jnius import autoclass, cast
from android import activity, permissions
jpythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity
jHfc = autoclass('android.view.HapticFeedbackConstants')
jString = autoclass('java.lang.String')
jIntent = autoclass('android.content.Intent')
jview = jpythonActivity.getWindow().getDecorView()
systemSdkVersion = autoclass('android.os.Build$VERSION').SDK_INT
notification = None
class QEAppController(BaseCrashReporter, QObject):
_dummy = pyqtSignal()
userNotify = pyqtSignal(str, str)
uriReceived = pyqtSignal(str)
showException = pyqtSignal('QVariantMap')
sendingBugreport = pyqtSignal()
sendingBugreportSuccess = pyqtSignal(str)
sendingBugreportFailure = pyqtSignal(str)
secureWindowChanged = pyqtSignal()
wantCloseChanged = pyqtSignal()
pluginLoaded = pyqtSignal(str)
startupFinished = pyqtSignal()
def __init__(self, qeapp: 'ElectrumQmlApplication', plugins: 'Plugins'):
BaseCrashReporter.__init__(self, None, None, None)
QObject.__init__(self)
self._app = qeapp
self._plugins = plugins
self.config = QEConfig.instance.config
self._crash_user_text = ''
self._app_started = False
self._intent = ''
self._secureWindow = False
# map of permissions and grant status _after_ asking user
self._permissions = {} # type: dict[str, bool]
# set up notification queue and notification_timer
self.user_notification_queue = queue.Queue()
self.user_notification_last_time = 0
self.notification_timer = QTimer(self)
self.notification_timer.setSingleShot(False)
self.notification_timer.setInterval(500) # msec
self.notification_timer.timeout.connect(self.on_notification_timer)
QEDaemon.instance.walletLoaded.connect(self.on_wallet_loaded)
self.userNotify.connect(self.doNotify)
if self.isAndroid():
self.bindIntent()
self._want_close = False
def on_wallet_loaded(self):
qewallet = QEDaemon.instance.currentWallet
if not qewallet:
return
# register wallet in Exception_Hook
Exception_Hook.maybe_setup(wallet=qewallet.wallet)
# attach to the wallet user notification events
# connect only once
try:
qewallet.userNotify.disconnect(self.on_wallet_usernotify)
except Exception:
pass
qewallet.userNotify.connect(self.on_wallet_usernotify)
def on_wallet_usernotify(self, wallet, message):
self.logger.debug(message)
self.user_notification_queue.put((wallet, message))
if not self.notification_timer.isActive():
self.logger.debug('starting app notification timer')
self.notification_timer.start()
self.on_notification_timer()
def on_notification_timer(self):
if self.user_notification_queue.qsize() == 0:
self.logger.debug('queue empty, stopping app notification timer')
self.notification_timer.stop()
return
now = time.time()
rate_limit = 20 # seconds
if self.user_notification_last_time + rate_limit > now:
return
self.user_notification_last_time = now
self.logger.info("Notifying GUI about new user notifications")
# request permission and defer notify until after permission request callback
# note: permission request is only shown to user once, so it is safe to request
# multiple times
if self.isAndroid() and not self.hasPermission(permissions.Permission.POST_NOTIFICATIONS) \
and self._permissions.get(permissions.Permission.POST_NOTIFICATIONS) is None:
self.request_permission(permissions.Permission.POST_NOTIFICATIONS)
return
try:
wallet, message = self.user_notification_queue.get_nowait()
self.userNotify.emit(str(wallet), message)
except queue.Empty:
pass
def doNotify(self, wallet_name, message):
self.logger.debug(f'sending push notification to OS: {message=!r}')
if os.name == 'nt':
icon = "" # plyer wants image to be in .ico format on Windows
else:
icon = os.path.join(
os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "icons", "electrum.png",
)
try:
# TODO: lazy load not in UI thread please
global notification
if not notification:
from plyer import notification
notification.notify('Electrum', message, app_icon=icon, app_name='Electrum')
except ImportError:
self.logger.warning('Notification: needs plyer; `python3 -m pip install plyer`')
except Exception as e:
self.logger.error(repr(e))
def bindIntent(self):
if not self.isAndroid():
return
try:
self.on_new_intent(jpythonActivity.getIntent())
activity.bind(on_new_intent=self.on_new_intent)
except Exception as e:
self.logger.error(f'unable to bind intent: {repr(e)}')
@pyqtSlot(str, result=bool)
def hasPermission(self, permissionFqcn: str) -> bool:
if not self.isAndroid():
return True
result = permissions.check_permission(permissionFqcn)
return result
def request_permission(self, permissionFqcn: str, permission_result_cb: Optional[Callable] = None):
if not self.isAndroid():
return True
self.logger.debug(f'requesting {permissionFqcn=}')
permissions.request_permission(
permissionFqcn,
callback=partial(self.on_request_permissions_result, permissionFqcn, permission_result_cb)
)
def on_request_permissions_result(
self,
permission: str,
permission_result_cb: Optional[Callable[[bool], None]],
permissions: List[str],
grant_results: List[bool]
):
self.logger.debug(f'on_request_permissions_result, len={len(permissions)}, p={repr(permissions)}, g={repr(grant_results)}')
grant_result = None
try:
grant_result = grant_results[permissions.index(permission)]
except ValueError:
pass
if grant_result is not None:
self._permissions[permission] = grant_result
if permission_result_cb:
permission_result_cb(grant_result)
def on_new_intent(self, intent):
if not self._app_started:
self._intent = intent
return
data = str(intent.getDataString())
self.logger.debug(f'received intent: {repr(data)}')
scheme = str(intent.getScheme()).lower()
if scheme == BITCOIN_BIP21_URI_SCHEME or scheme == LIGHTNING_URI_SCHEME:
self.uriReceived.emit(data)
def startup_finished(self):
self._app_started = True
self.startupFinished.emit()
for plugin_name in self._plugins.plugins.keys():
self.pluginLoaded.emit(plugin_name)
if self._intent:
self.on_new_intent(self._intent)
@pyqtProperty(bool, notify=wantCloseChanged)
def wantClose(self):
return self._want_close
@wantClose.setter
def wantClose(self, want_close):
if want_close != self._want_close:
self._want_close = want_close
self.wantCloseChanged.emit()
@pyqtSlot(str, str)
def doShare(self, data, title):
if not self.isAndroid():
return
sendIntent = jIntent()
sendIntent.setAction(jIntent.ACTION_SEND)
sendIntent.setType("text/plain")
sendIntent.putExtra(jIntent.EXTRA_TEXT, jString(data))
it = jIntent.createChooser(sendIntent, cast('java.lang.CharSequence', jString(title)))
jpythonActivity.startActivity(it)
@pyqtSlot(result=bool)
def isMaxBrightnessOnQrDisplayEnabled(self):
return self.config.GUI_QML_SET_MAX_BRIGHTNESS_ON_QR_DISPLAY
@pyqtSlot()
def setMaxScreenBrightness(self):
self._set_screen_brightness(1.0)
@pyqtSlot()
def resetScreenBrightness(self):
self._set_screen_brightness(-1.0)
def _set_screen_brightness(self, br: float) -> None:
"""br is the desired screen brightness, a value in the [0, 1] interval.
A negative value, e.g. -1.0, means a "reset" back to the system preferred value.
"""
if not self.isAndroid():
return
from android.runnable import run_on_ui_thread
@run_on_ui_thread
def set_br():
window = jpythonActivity.getWindow()
attrs = window.getAttributes()
attrs.screenBrightness = br
window.setAttributes(attrs)
set_br()
@pyqtSlot('QString')
def textToClipboard(self, text):
QGuiApplication.clipboard().setText(text)
@pyqtSlot(result='QString')
def clipboardToText(self):
clip = QGuiApplication.clipboard()
return clip.text() if clip.mimeData().hasText() else ''
@pyqtSlot(str, result=QObject)
def plugin(self, plugin_name):
self.logger.debug(f'now {self._plugins.count()} plugins loaded')
plugin = self._plugins.get(plugin_name)
self.logger.debug(f'plugin with name {plugin_name} is {str(type(plugin))}')
if plugin and hasattr(plugin, 'so'):
return plugin.so
else:
self.logger.debug('None!')
return None
@pyqtProperty('QVariantList', notify=_dummy)
def plugins(self):
s = []
for item in self._plugins.descriptions:
s.append({
'name': item,
'fullname': self._plugins.descriptions[item]['fullname'],
'enabled': bool(self._plugins.get(item))
})
return s
@pyqtSlot(str, bool)
def setPluginEnabled(self, plugin: str, enabled: bool):
if enabled:
self._plugins.enable(plugin)
# note: all enabled plugins will receive this hook:
run_hook('init_qml', self._app)
else:
self._plugins.disable(plugin)
@pyqtSlot(str, result=bool)
def isPluginEnabled(self, plugin: str):
return bool(self._plugins.get(plugin))
@pyqtSlot(result=bool)
def isAndroid(self):
return 'ANDROID_DATA' in os.environ
@pyqtSlot(result='QVariantMap')
def crashData(self):
return {
'traceback': self.get_traceback_info(*self.exc_args),
'extra': self.get_additional_info(),
'reportstring': self.get_report_string()
}
@pyqtSlot(object, object, object)
def crash(self, e, text, tb):
self.exc_args = (e, text, tb) # for BaseCrashReporter
self.showException.emit(self.crashData())
@pyqtSlot(str)
def sendReport(self, user_text: str):
self._crash_user_text = user_text
network = Network.get_instance()
proxy = network.proxy
def report_task():
self.logger.debug('starting report_task')
try:
response = BaseCrashReporter.send_report(self, network.asyncio_loop, proxy)
except Exception as e:
self.logger.error('There was a problem with the automatic reporting', exc_info=e)
self.sendingBugreportFailure.emit(_('There was a problem with the automatic reporting:') + ' ' +
repr(e)[:120] + '
' +
_("Please report this issue manually") +
f' on GitHub.')
else:
text = response.text
if response.url:
text += f" You can track further progress on GitHub."
self.sendingBugreportSuccess.emit(text)
self.sendingBugreport.emit()
threading.Thread(target=report_task, daemon=True).start()
def _get_traceback_str_to_display(self) -> str:
# The msg_box that shows the report uses rich_text=True, so
# if traceback contains special HTML characters, e.g. '<',
# they need to be escaped to avoid formatting issues.
traceback_str = super()._get_traceback_str_to_display()
return html.escape(traceback_str).replace(''', ''')
def get_user_description(self):
return self._crash_user_text
def get_wallet_type(self):
wallet_types = Exception_Hook._INSTANCE.wallet_types_seen
return ",".join(wallet_types)
@pyqtSlot()
def haptic(self):
if not self.isAndroid():
return
jview.performHapticFeedback(jHfc.VIRTUAL_KEY)
@pyqtProperty(bool, notify=secureWindowChanged)
def secureWindow(self):
return self._secureWindow
@secureWindow.setter
def secureWindow(self, secure):
if not self.isAndroid():
return
if self.config.GUI_QML_ALWAYS_ALLOW_SCREENSHOTS:
return
if self._secureWindow != secure:
jpythonActivity.setSecureWindow(secure)
self._secureWindow = secure
self.secureWindowChanged.emit()
@pyqtSlot(result=bool)
def enforcesEdgeToEdge(self) -> bool:
if not self.isAndroid():
return False
return bool(systemSdkVersion >= 35)
@profiler(min_threshold=0.02)
def _getSystemBarHeight(self, bar_type: str) -> int:
if not self.enforcesEdgeToEdge():
return 0
assert systemSdkVersion >= 30, \
f"Android WindowInsets unavailable on {systemSdkVersion=}"
try:
root_insets = jview.getRootWindowInsets()
window_insets_type = autoclass('android.view.WindowInsets$Type')
if bar_type == 'status':
ins = root_insets.getInsets(window_insets_type.statusBars())
elif bar_type == 'navigation':
ins = root_insets.getInsets(window_insets_type.navigationBars())
else:
raise ValueError(f"Invalid bar_type: {bar_type}")
# Get the display metrics to convert pixels to dp
display_metrics = jpythonActivity.getResources().getDisplayMetrics()
density = display_metrics.density
height = int(max(ins.bottom, ins.right, ins.left, ins.top, 0))
if not height > 0:
return 0
# Convert from pixels to dp for QML
height_dp = int(height / density)
self.logger.debug(f"_getSystemBarHeight: {height=}, {height_dp=}, {bar_type=}")
return max(0, height_dp)
except Exception as e:
self.logger.debug(f"{bar_type} fallback due to: {e!r}")
return 0
@pyqtSlot(result=int)
def getStatusBarHeight(self) -> int:
return self._getSystemBarHeight('status')
@pyqtSlot(result=int)
def getNavigationBarHeight(self) -> int:
return self._getSystemBarHeight('navigation')
class ElectrumQmlApplication(QGuiApplication):
_valid = True
def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
super().__init__(args)
self.logger = get_logger(__name__)
# TODO QT6 order of declaration is important now?
qmlRegisterType(QEAmount, 'org.electrum', 1, 0, 'Amount')
qmlRegisterType(QEBytes, 'org.electrum', 1, 0, 'Bytes')
qmlRegisterType(QENewWalletWizard, 'org.electrum', 1, 0, 'QNewWalletWizard')
qmlRegisterType(QETermsOfUseWizard, 'org.electrum', 1, 0, 'QTermsOfUseWizard')
qmlRegisterType(QEServerConnectWizard, 'org.electrum', 1, 0, 'QServerConnectWizard')
qmlRegisterType(QEFilterProxyModel, 'org.electrum', 1, 0, 'FilterProxyModel')
qmlRegisterType(QSortFilterProxyModel, 'org.electrum', 1, 0, 'QSortFilterProxyModel')
qmlRegisterType(QEWallet, 'org.electrum', 1, 0, 'Wallet')
qmlRegisterType(QEBitcoin, 'org.electrum', 1, 0, 'Bitcoin')
qmlRegisterType(QEQRParser, 'org.electrum', 1, 0, 'QRParser')
qmlRegisterType(QEQRScanner, 'org.electrum', 1, 0, 'QRScanner')
qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX')
qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer')
qmlRegisterType(QEPIResolver, 'org.electrum', 1, 0, 'PIResolver')
qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice')
qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser')
qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails')
qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails')
qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener')
qmlRegisterType(QELnPaymentDetails, 'org.electrum', 1, 0, 'LnPaymentDetails')
qmlRegisterType(QEChannelDetails, 'org.electrum', 1, 0, 'ChannelDetails')
qmlRegisterType(QESwapHelper, 'org.electrum', 1, 0, 'SwapHelper')
qmlRegisterType(QERequestDetails, 'org.electrum', 1, 0, 'RequestDetails')
qmlRegisterType(QETxRbfFeeBumper, 'org.electrum', 1, 0, 'TxRbfFeeBumper')
qmlRegisterType(QETxCpfpFeeBumper, 'org.electrum', 1, 0, 'TxCpfpFeeBumper')
qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller')
qmlRegisterType(QETxSweepFinalizer, 'org.electrum', 1, 0, 'SweepFinalizer')
qmlRegisterType(QEBip39RecoveryListModel, 'org.electrum', 1, 0, 'Bip39RecoveryListModel')
qmlRegisterType(FeeSlider, 'org.electrum', 1, 0, 'FeeSlider')
# TODO QT6: these were declared as uncreatable, but that doesn't seem to work for pyqt6
# qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property')
# qmlRegisterUncreatableType(QENewWalletWizard, 'org.electrum', 1, 0, 'QNewWalletWizard', 'QNewWalletWizard can only be used as property')
# qmlRegisterUncreatableType(QEServerConnectWizard, 'org.electrum', 1, 0, 'QServerConnectWizard', 'QServerConnectWizard can only be used as property')
# qmlRegisterUncreatableType(QEFilterProxyModel, 'org.electrum', 1, 0, 'FilterProxyModel', 'FilterProxyModel can only be used as property')
# qmlRegisterUncreatableType(QSortFilterProxyModel, 'org.electrum', 1, 0, 'QSortFilterProxyModel', 'QSortFilterProxyModel can only be used as property')
self.engine = QQmlApplicationEngine(parent=self)
screensize = self.primaryScreen().size()
qr_size = min(screensize.width(), screensize.height()) * 7/8
self.qr_ip = QEQRImageProvider(qr_size)
self.engine.addImageProvider('qrgen', self.qr_ip)
self.qr_ip_h = QEQRImageProviderHelper(qr_size)
# add a monospace font as we can't rely on device having one
self.fixedFont = 'PT Mono'
not_loaded = get_font_id('PTMono-Regular.ttf') < 0
not_loaded = get_font_id('PTMono-Bold.ttf') < 0 and not_loaded
if not_loaded:
self.logger.warning('Could not load font PT Mono')
self.fixedFont = 'Monospace' # hope for the best
self.context = self.engine.rootContext()
self.plugins = plugins
self.config = QEConfig(config)
self.network = QENetwork(daemon.network)
self.daemon = QEDaemon(daemon, self.plugins)
self.appController = QEAppController(self, self.plugins)
self.maxAmount = QEAmount(is_max=True)
self.biometrics = QEBiometrics(config=config, parent=self)
self.context.setContextProperty('AppController', self.appController)
self.context.setContextProperty('Config', self.config)
self.context.setContextProperty('Network', self.network)
self.context.setContextProperty('Daemon', self.daemon)
self.context.setContextProperty('FixedFont', self.fixedFont)
self.context.setContextProperty('MAX', self.maxAmount)
self.context.setContextProperty('QRIP', self.qr_ip_h)
self.context.setContextProperty('Biometrics', self.biometrics)
self.context.setContextProperty('BUILD', {
'electrum_version': version.ELECTRUM_VERSION,
'protocol_version': f"[{version.PROTOCOL_VERSION_MIN}, {version.PROTOCOL_VERSION_MAX}]",
'qt_version': QT_VERSION_STR,
'pyqt_version': PYQT_VERSION_STR
})
self.context.setContextProperty('UI_UNIT_NAME', {
"FEERATE_SAT_PER_VBYTE": electrum.util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE,
"FEERATE_SAT_PER_VB": electrum.util.UI_UNIT_NAME_FEERATE_SAT_PER_VB,
"FIXED_SAT": electrum.util.UI_UNIT_NAME_FIXED_SAT,
"TXSIZE_VBYTES": electrum.util.UI_UNIT_NAME_TXSIZE_VBYTES,
"MEMPOOL_MB": electrum.util.UI_UNIT_NAME_MEMPOOL_MB,
})
self.plugins.load_plugin_by_name('trustedcoin')
qInstallMessageHandler(self.message_handler)
# get notified whether root QML document loads or not
self.engine.objectCreated.connect(self.objectCreated)
# slot is called after loading root QML. If object is None, it has failed.
@pyqtSlot('QObject*', 'QUrl')
def objectCreated(self, object, url):
self.engine.objectCreated.disconnect(self.objectCreated)
if object is None:
self._valid = False
else:
self.appController.startup_finished()
def message_handler(self, line, funct, file):
# filter out common harmless messages
if re.search('file:///.*TypeError: Cannot read property.*null$', file):
return
self.logger.warning(file)
class Exception_Hook(QObject, Logger):
_report_exception = pyqtSignal(object, object, object)
_INSTANCE = None # type: Optional[Exception_Hook] # singleton
def __init__(self, *, slot):
QObject.__init__(self)
Logger.__init__(self)
assert self._INSTANCE is None, "Exception_Hook is supposed to be a singleton"
self.wallet_types_seen = set() # type: Set[str]
self.exception_ids_seen = set() # type: Set[bytes]
sys.excepthook = self.handler
threading.excepthook = self.handler
if slot:
self._report_exception.connect(slot)
EarlyExceptionsQueue.set_hook_as_ready()
@classmethod
def maybe_setup(cls, *, wallet: 'Abstract_Wallet' = None, slot=None) -> None:
if not cls._INSTANCE:
cls._INSTANCE = Exception_Hook(slot=slot)
if wallet:
cls._INSTANCE.wallet_types_seen.add(wallet.wallet_type)
def handler(self, *exc_info):
self.logger.error('exception caught by crash reporter', exc_info=exc_info)
groupid_hash = BaseCrashReporter.get_traceback_groupid_hash(*exc_info)
if groupid_hash in self.exception_ids_seen:
return # to avoid annoying the user, only show crash reporter once per exception groupid
self.exception_ids_seen.add(groupid_hash)
self._report_exception.emit(*exc_info)
================================================
FILE: electrum/gui/qml/qebiometrics.py
================================================
import os
import secrets
from enum import Enum
from typing import Optional, TYPE_CHECKING
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.base_crash_reporter import send_exception_to_crash_reporter
from electrum.crypto import aes_encrypt_with_iv, aes_decrypt_with_iv
from .auth import auth_protect, AuthMixin
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
_logger = get_logger(__name__)
jBiometricHelper = None
jBiometricActivity = None
jPythonActivity = None
jIntent = None
jString = None
if 'ANDROID_DATA' in os.environ:
from jnius import autoclass, JavaException
from android import activity
try:
jPythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity
jIntent = autoclass('android.content.Intent')
jString = autoclass('java.lang.String')
jBiometricActivity = autoclass('org.electrum.biometry.BiometricActivity')
jBiometricHelper = autoclass('org.electrum.biometry.BiometricHelper')
except JavaException as e:
_logger.error(f"Could not load Biometric java classes (maybe due to old api version): {e}")
class BiometricAction(str, Enum):
ENCRYPT = "ENCRYPT"
DECRYPT = "DECRYPT"
class QEBiometrics(AuthMixin, QObject):
REQUEST_CODE_BIOMETRIC_ACTIVITY = 24553 # random 16 bit int
RESULT_CODE_SETUP_FAILED = 101 # codes duplicated from BiometricActivity.java
RESULT_CODE_POPUP_CANCELLED = 102
enablingFailed = pyqtSignal(str, arguments=['error'])
unlockSuccess = pyqtSignal(str, arguments=['password'])
unlockError = pyqtSignal(str, arguments=['error'])
def __init__(self, *, config: 'SimpleConfig', parent=None):
super().__init__(parent)
self.config = config
self._current_action: Optional[BiometricAction] = None
@pyqtProperty(bool, constant=True)
def isAvailable(self) -> bool:
if 'ANDROID_DATA' not in os.environ or jBiometricHelper is None:
return False
try:
return jBiometricHelper.isAvailable(jPythonActivity)
except Exception as e:
send_exception_to_crash_reporter(e)
return False
isEnabledChanged = pyqtSignal()
@pyqtProperty(bool, notify=isEnabledChanged)
def isEnabled(self) -> bool:
return self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION
@pyqtSlot(str)
def enable(self, unified_wallet_password: str):
"""
We encrypt (`wrap`) the wallet password with a random key 'wrap_key' and encrypt the random key
with the AndroidKeyStore.
Both the encrypted wrap_key and the encrypted wallet password are stored in the config.
The encryption key for the wrap_key is stored in the AndroidKeyStore.
This way the wallet password doesn't have to leave the process.
"""
wrap_key, iv = secrets.token_bytes(32), secrets.token_bytes(16)
wrapped_wallet_password = aes_encrypt_with_iv(
key=wrap_key,
iv=iv,
data=unified_wallet_password.encode('utf-8'),
)
encrypted_password_bundle = f"{iv.hex()}:{wrapped_wallet_password.hex()}"
self.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = encrypted_password_bundle
self._start_activity(BiometricAction.ENCRYPT, data=wrap_key.hex())
@pyqtSlot()
def disable(self):
self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = False
self.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = ''
self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = ''
self.isEnabledChanged.emit()
_logger.info("Android biometric authentication disabled")
@pyqtSlot()
@auth_protect(method='wallet_password_only', reject='_disable_protected_failed')
def disableProtected(self):
"""
Exists to ensure the user knows the wallet password when manually disabling
biometric authentication. If they don't remember the password they can still do a seed
backup or transactions if biometrics stay enabled. However, note it is still possible for
biometrics to get disabled automatically on invalidation or error, so this cannot
fully protect the user from forgetting their wallet password either.
"""
self.disable()
def _disable_protected_failed(self):
self.isEnabledChanged.emit()
@pyqtSlot()
@pyqtSlot(str)
def unlock(self, auth_message: str = None):
"""
Called when the user needs to authenticate.
Makes the AndroidKeyStore decrypt our encrypted wrap key, we then use the decrypted wrap key
to decrypt the encrypted wallet password.
auth_message is shown in the system auth popup and defaults to 'Confirm your identity'.
"""
encrypted_wrap_key = self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY
assert encrypted_wrap_key, "shouldn't unlock if biometric auth is disabled"
self._start_activity(BiometricAction.DECRYPT, data=encrypted_wrap_key, auth_message=auth_message)
def _start_activity(self, action: BiometricAction, data: str, auth_message: str = None):
self._current_action = action
_logger.debug(f"_start_activity: {action.value}, {len(data)=}")
intent = jIntent(jPythonActivity, jBiometricActivity)
intent.putExtra(jString("action"), jString(action.value))
intent.putExtra(jString("auth_message"), jString(auth_message or _("Confirm your identity")))
if action == BiometricAction.ENCRYPT:
intent.putExtra(jString("data"), jString(data)) # wrap_key
elif action == BiometricAction.DECRYPT:
assert ':' in data, f"malformed encrypted_bundle: {data=}"
iv, encrypted_wrap_key = data.split(':')
intent.putExtra(jString("iv"), jString(iv))
intent.putExtra(jString("data"), jString(encrypted_wrap_key))
else:
raise ValueError(f"unsupported {action=}")
activity.bind(on_activity_result=self._on_activity_result)
jPythonActivity.startActivityForResult(intent, self.REQUEST_CODE_BIOMETRIC_ACTIVITY)
def _on_activity_result(self, requestCode: int, resultCode: int, intent):
if requestCode != self.REQUEST_CODE_BIOMETRIC_ACTIVITY:
return
action = self._current_action
self._current_action = None
try:
activity.unbind(on_activity_result=self._on_activity_result)
if resultCode == -1: # RESULT_OK
data = intent.getStringExtra(jString("data"))
if action == BiometricAction.ENCRYPT:
iv = intent.getStringExtra(jString("iv"))
encrypted_bundle = f"{iv}:{data}"
self._on_wrap_key_encrypted(encrypted_bundle=encrypted_bundle)
else:
self._on_wrap_key_decrypted(wrap_key=data)
return
except Exception as e: # prevent exc from getting lost
send_exception_to_crash_reporter(e)
# on qml side we act on specific errors, so these error strings shouldn't be changed
if resultCode == self.RESULT_CODE_SETUP_FAILED and action == BiometricAction.DECRYPT:
# setup failed, we need to delete the biometry data, it cannot be decrypted anymore
_logger.debug(f"biometric decryption failed, probably invalidated key")
error = 'INVALIDATED'
self.disable() # reset
elif resultCode == self.RESULT_CODE_POPUP_CANCELLED: # user clicked cancel on auth popup
_logger.debug(f"biometric auth cancelled by user")
error = 'CANCELLED'
else: # some other error
_logger.error(f"biometric auth failed: {action=}, {resultCode=}")
error = f"{resultCode=}"
if action == BiometricAction.DECRYPT:
self.unlockError.emit(error)
else:
self.disable() # reset
self.enablingFailed.emit(error)
def _on_wrap_key_decrypted(self, *, wrap_key: str):
encrypted_password_bundle = self.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD
assert encrypted_password_bundle and ':' in encrypted_password_bundle
iv, encrypted_password = encrypted_password_bundle.split(':')
decrypted_password = aes_decrypt_with_iv(
key=bytes.fromhex(wrap_key),
iv=bytes.fromhex(iv),
data=bytes.fromhex(encrypted_password),
)
self.unlockSuccess.emit(decrypted_password.decode('utf-8'))
def _on_wrap_key_encrypted(self, *, encrypted_bundle: str):
self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = encrypted_bundle
self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = True
self.isEnabledChanged.emit()
================================================
FILE: electrum/gui/qml/qebip39recovery.py
================================================
import asyncio
import concurrent
from enum import IntEnum
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, pyqtEnum
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from electrum import Network, keystore
from electrum.bip32 import BIP32Node
from electrum.bip39_recovery import account_discovery
from electrum.logging import get_logger
from electrum.util import get_asyncio_loop
from electrum.gui.common_qt.util import TaskThread
class QEBip39RecoveryListModel(QAbstractListModel):
_logger = get_logger(__name__)
@pyqtEnum
class State(IntEnum):
Idle = -1
Scanning = 0
Success = 1
Failed = 2
Cancelled = 3
recoveryFailed = pyqtSignal()
stateChanged = pyqtSignal()
# define listmodel rolemap
_ROLE_NAMES=('description', 'derivation_path', 'script_type')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
def __init__(self, config, parent=None):
super().__init__(parent)
self._accounts = []
self._thread = None
self._root_seed = None
self._state = QEBip39RecoveryListModel.State.Idle
def rowCount(self, index):
return len(self._accounts)
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
account = self._accounts[index.row()]
role_index = role - Qt.ItemDataRole.UserRole
value = account[self._ROLE_NAMES[role_index]]
if isinstance(value, (bool, list, int, str)) or value is None:
return value
return str(value)
def clear(self):
self.beginResetModel()
self._accounts = []
self.endResetModel()
@pyqtProperty(int, notify=stateChanged)
def state(self):
return self._state
@state.setter
def state(self, state: State):
if state != self._state:
self._state = state
self.stateChanged.emit()
@pyqtSlot(str, str)
@pyqtSlot(str, str, str)
def startScan(self, wallet_type: str, seed: str, seed_extra_words: str = None):
if not seed or not wallet_type:
return
assert wallet_type == 'standard'
self._root_seed = keystore.bip39_to_seed(seed, passphrase=seed_extra_words)
self.clear()
self._thread = TaskThread(self)
network = Network.get_instance()
coro = account_discovery(network, self.get_account_xpub)
self.state = QEBip39RecoveryListModel.State.Scanning
fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
self._thread.add(
fut.result,
on_success=self.on_recovery_success,
on_error=self.on_recovery_error,
cancel=fut.cancel,
)
def addAccount(self, account):
self._logger.debug(f'addAccount {account!r}')
self.beginInsertRows(QModelIndex(), len(self._accounts), len(self._accounts))
self._accounts.append(account)
self.endInsertRows()
def on_recovery_success(self, accounts):
self.state = QEBip39RecoveryListModel.State.Success
for account in accounts:
self.addAccount(account)
self._thread.stop()
def on_recovery_error(self, exc_info):
e = exc_info[1]
if isinstance(e, concurrent.futures.CancelledError):
self.state = QEBip39RecoveryListModel.State.Cancelled
return
self._logger.error(f'recovery error', exc_info=exc_info)
self.state = QEBip39RecoveryListModel.State.Failed
self._thread.stop()
def get_account_xpub(self, account_path):
root_node = BIP32Node.from_rootseed(self._root_seed, xtype='standard')
account_node = root_node.subkey_at_private_derivation(account_path)
account_xpub = account_node.to_xpub()
return account_xpub
================================================
FILE: electrum/gui/qml/qebitcoin.py
================================================
import asyncio
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum import mnemonic
from electrum import keystore
from electrum.i18n import _
from electrum.bip32 import is_bip32_derivation, xpub_type
from electrum.logging import get_logger
from electrum.util import get_asyncio_loop
from electrum.transaction import tx_from_any
from electrum.mnemonic import Mnemonic
from electrum.old_mnemonic import wordlist as old_wordlist
from electrum.bitcoin import is_address
class QEBitcoin(QObject):
_logger = get_logger(__name__)
generatedSeedChanged = pyqtSignal()
seedTypeChanged = pyqtSignal()
validationMessageChanged = pyqtSignal()
def __init__(self, config, parent=None):
super().__init__(parent)
self.config = config
self._seed_type = ''
self._generated_seed = ''
self._validationMessage = ''
self._words = None
@pyqtProperty(str, notify=generatedSeedChanged)
def generatedSeed(self):
return self._generated_seed
@pyqtProperty(str, notify=seedTypeChanged)
def seedType(self):
return self._seed_type
@pyqtProperty(str, notify=validationMessageChanged)
def validationMessage(self):
return self._validationMessage
@validationMessage.setter
def validationMessage(self, msg):
if self._validationMessage != msg:
self._validationMessage = msg
self.validationMessageChanged.emit()
@pyqtSlot()
@pyqtSlot(str)
@pyqtSlot(str, str)
def generateSeed(self, seed_type='segwit', language='en'):
self._logger.debug('generating seed of type ' + str(seed_type))
async def co_gen_seed(seed_type, language):
self._generated_seed = mnemonic.Mnemonic(language).make_seed(seed_type=seed_type)
self._logger.debug('seed generated')
self.generatedSeedChanged.emit()
asyncio.run_coroutine_threadsafe(co_gen_seed(seed_type, language), get_asyncio_loop())
@pyqtSlot(str, str, result=bool)
def verifyMasterKey(self, key, wallet_type='standard'):
self.validationMessage = ''
if not keystore.is_master_key(key):
self.validationMessage = _('Not a master key')
return False
k = keystore.from_master_key(key)
if wallet_type == 'standard':
if isinstance(k, keystore.Xpub): # has bip32 xpub
t1 = xpub_type(k.xpub)
if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: # disallow Ypub/Zpub
self.validationMessage = '%s: %s' % (_('Wrong key type'), t1)
return False
elif isinstance(k, keystore.Old_KeyStore):
pass
else:
self._logger.error(f"unexpected keystore type: {type(keystore)}")
return False
elif wallet_type == 'multisig':
if not isinstance(k, keystore.Xpub): # old mpk?
self.validationMessage = '%s: %s' % (_('Wrong key type'), "not bip32")
return False
t1 = xpub_type(k.xpub)
if t1 not in ['standard', 'p2wsh', 'p2wsh-p2sh']: # disallow ypub/zpub
self.validationMessage = '%s: %s' % (_('Wrong key type'), t1)
return False
else:
self.validationMessage = '%s: %s' % (_('Unsupported wallet type'), wallet_type)
self._logger.error(f'Unsupported wallet type: {wallet_type}')
return False
# looks okay
return True
@pyqtSlot(str, result=bool)
def verifyDerivationPath(self, path):
return is_bip32_derivation(path)
@pyqtSlot(str, result=bool)
def isRawTx(self, rawtx):
try:
tx_from_any(rawtx)
return True
except Exception:
return False
@pyqtSlot(str, result=bool)
def isAddress(self, addr: str):
return is_address(addr)
@pyqtSlot(str, result=bool)
def isAddressList(self, csv: str):
return keystore.is_address_list(csv)
@pyqtSlot(str, result=bool)
def isPrivateKeyList(self, csv: str):
return keystore.is_private_key_list(csv)
@pyqtSlot(str, result='QVariantList')
def mnemonicsFor(self, fragment):
if not fragment:
return []
if not self._words:
self._words = set(Mnemonic('en').wordlist).union(set(old_wordlist))
return sorted(filter(lambda x: x.startswith(fragment), self._words))
================================================
FILE: electrum/gui/qml/qechanneldetails.py
================================================
import threading
from enum import IntEnum
from typing import Optional, TYPE_CHECKING
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum
from electrum.i18n import _
from electrum.gui import messages
from electrum.logging import get_logger
from electrum.lnutil import LOCAL, REMOTE
from electrum.lnchannel import ChanCloseOption, ChannelState, AbstractChannel, Channel, ChannelBackup
from electrum.util import format_short_id, event_listener
from electrum.gui.common_qt.util import QtEventListener
from .auth import AuthMixin, auth_protect
from .qewallet import QEWallet
from .qetypes import QEAmount
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
class QEChannelDetails(AuthMixin, QObject, QtEventListener):
_logger = get_logger(__name__)
@pyqtEnum
class State(IntEnum): # subset, only ones we currently need in UI
Closed = ChannelState.CLOSED
Redeemed = ChannelState.REDEEMED
channelChanged = pyqtSignal()
channelCloseSuccess = pyqtSignal()
channelCloseFailed = pyqtSignal([str], arguments=['message'])
isClosingChanged = pyqtSignal()
trampolineFrozenInGossipMode = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None # type: Optional[QEWallet]
self._channelid = None # type: Optional[str]
self._channel = None # type: Optional[AbstractChannel]
self._capacity = QEAmount()
self._local_capacity = QEAmount()
self._remote_capacity = QEAmount()
self._can_receive = QEAmount()
self._can_send = QEAmount()
self._is_closing = False
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
@event_listener
def on_event_channel(self, wallet: 'Abstract_Wallet', channel: 'AbstractChannel'):
if wallet == self._wallet.wallet and self._channelid == channel.channel_id.hex():
self.channelChanged.emit()
def on_destroy(self):
self.unregister_callbacks()
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self) -> QEWallet:
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self.walletChanged.emit()
channelidChanged = pyqtSignal()
@pyqtProperty(str, notify=channelidChanged)
def channelid(self) -> str:
return self._channelid
@channelid.setter
def channelid(self, channelid: str):
if self._channelid != channelid:
self._channelid = channelid
if channelid:
self.load()
self.channelidChanged.emit()
def load(self):
lnchannels = self._wallet.wallet.lnworker.get_channel_objects()
for channel in lnchannels.values():
if self._channelid == channel.channel_id.hex():
self._channel = channel
self.channelChanged.emit()
@pyqtProperty(str, notify=channelChanged)
def name(self) -> str:
if not self._channel:
return ''
return self._wallet.wallet.lnworker.lnpeermgr.get_node_alias(self._channel.node_id) or ''
@pyqtProperty(str, notify=channelChanged)
def pubkey(self) -> str:
return self._channel.node_id.hex()
@pyqtProperty(str, notify=channelChanged)
def shortCid(self) -> str:
return self._channel.short_id_for_GUI()
@pyqtProperty(str, notify=channelChanged)
def localScidAlias(self) -> str:
lsa = self._channel.get_local_scid_alias()
return format_short_id(lsa) if lsa else ''
@pyqtProperty(str, notify=channelChanged)
def remoteScidAlias(self) -> str:
rsa = self._channel.get_remote_scid_alias()
return format_short_id(rsa) if rsa else ''
@pyqtProperty(str, notify=channelChanged)
def currentFeerate(self) -> str:
if self._channel.is_backup():
return ''
assert isinstance(self._channel, Channel)
return self._wallet.wallet.config.format_fee_rate(4 * self._channel.get_latest_feerate(LOCAL))
@pyqtProperty(str, notify=channelChanged)
def state(self) -> str:
return self._channel.get_state_for_GUI()
@pyqtProperty(int, notify=channelChanged)
def stateCode(self) -> ChannelState:
return self._channel.get_state()
@pyqtProperty(str, notify=channelChanged)
def initiator(self) -> str:
if self._channel.is_backup():
return ''
assert isinstance(self._channel, Channel)
return 'Local' if self._channel.constraints.is_initiator else 'Remote'
@pyqtProperty('QVariantMap', notify=channelChanged)
def fundingOutpoint(self) -> dict:
outpoint = self._channel.funding_outpoint
return {
'txid': outpoint.txid,
'index': outpoint.output_index
}
@pyqtProperty(str, notify=channelChanged)
def closingTxid(self) -> str:
if not self._channel.is_closed():
return ''
item = self._channel.get_closing_height()
if item:
closing_txid, closing_height, timestamp = item
return closing_txid
else:
return ''
@pyqtProperty(QEAmount, notify=channelChanged)
def capacity(self) -> QEAmount:
self._capacity.copyFrom(QEAmount(amount_sat=self._channel.get_capacity()))
return self._capacity
@pyqtProperty(QEAmount, notify=channelChanged)
def localCapacity(self) -> QEAmount:
if not self._channel.is_backup():
self._local_capacity.copyFrom(QEAmount(amount_msat=self._channel.balance(LOCAL)))
return self._local_capacity
@pyqtProperty(QEAmount, notify=channelChanged)
def remoteCapacity(self) -> QEAmount:
if not self._channel.is_backup():
self._remote_capacity.copyFrom(QEAmount(amount_msat=self._channel.balance(REMOTE)))
return self._remote_capacity
@pyqtProperty(QEAmount, notify=channelChanged)
def canSend(self) -> QEAmount:
if not self._channel.is_backup():
self._can_send.copyFrom(QEAmount(amount_msat=self._channel.available_to_spend(LOCAL)))
return self._can_send
@pyqtProperty(QEAmount, notify=channelChanged)
def canReceive(self) -> QEAmount:
if not self._channel.is_backup():
self._can_receive.copyFrom(QEAmount(amount_msat=self._channel.available_to_spend(REMOTE)))
return self._can_receive
@pyqtProperty(bool, notify=channelChanged)
def frozenForSending(self) -> bool:
return self._channel.is_frozen_for_sending()
@pyqtProperty(bool, notify=channelChanged)
def frozenForReceiving(self) -> bool:
return self._channel.is_frozen_for_receiving()
@pyqtProperty(str, notify=channelChanged)
def channelType(self) -> str:
return self._channel.storage['channel_type'].name_minimal if 'channel_type' in self._channel.storage else 'Channel Backup'
@pyqtProperty(bool, notify=channelChanged)
def isOpen(self) -> bool:
return self._channel.is_open()
@pyqtProperty(bool, notify=channelChanged)
def canClose(self) -> bool:
return self.canCoopClose or self.canLocalForceClose or self.canRequestForceClose
@pyqtProperty(bool, notify=channelChanged)
def canCoopClose(self) -> bool:
return ChanCloseOption.COOP_CLOSE in self._channel.get_close_options()
@pyqtProperty(bool, notify=channelChanged)
def canLocalForceClose(self) -> bool:
return ChanCloseOption.LOCAL_FCLOSE in self._channel.get_close_options()
@pyqtProperty(bool, notify=channelChanged)
def canRequestForceClose(self) -> bool:
return ChanCloseOption.REQUEST_REMOTE_FCLOSE in self._channel.get_close_options()
@pyqtProperty(bool, notify=channelChanged)
def canDelete(self) -> bool:
return self._channel.can_be_deleted()
@pyqtProperty(str, notify=channelChanged)
def messageForceClose(self) -> str:
return messages.MSG_REQUEST_FORCE_CLOSE.strip()
@pyqtProperty(str, notify=channelChanged)
def messageForceCloseBackup(self):
return ' '.join([
_('If you force-close this channel, the funds you have in it will not be available for {} blocks.').format(self.toSelfDelay),
_('During that time, funds will not be recoverable from your seed, and may be lost if you lose your device.'),
_('To prevent that, please save this channel backup.'),
_('It may be imported in another wallet with the same seed.')
])
@pyqtProperty(bool, notify=channelChanged)
def isBackup(self):
return self._channel.is_backup()
@pyqtProperty(str, notify=channelChanged)
def backupType(self):
if not self.isBackup:
return ''
assert isinstance(self._channel, ChannelBackup)
return 'imported' if self._channel.is_imported else 'on-chain'
@pyqtProperty(int, notify=channelChanged)
def toSelfDelay(self):
return self._channel.config[REMOTE].to_self_delay
@pyqtProperty(bool, notify=isClosingChanged)
def isClosing(self):
# Note: isClosing only applies to a closing action started by this instance, not
# whether the channel is closing
return self._is_closing
@pyqtSlot()
def freezeForSending(self):
assert isinstance(self._channel, Channel)
lnworker = self._channel.lnworker
if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id):
self._channel.set_frozen_for_sending(not self.frozenForSending)
self.channelChanged.emit()
else:
self._logger.debug(messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP)
self.trampolineFrozenInGossipMode.emit()
@pyqtSlot()
def freezeForReceiving(self):
assert isinstance(self._channel, Channel)
lnworker = self._channel.lnworker
if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id):
self._channel.set_frozen_for_receiving(not self.frozenForReceiving)
self.channelChanged.emit()
else:
self._logger.debug(messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP)
@pyqtSlot(str)
def closeChannel(self, closetype):
self.do_close_channel(closetype)
@auth_protect(message=_('Close Lightning channel?'))
def do_close_channel(self, closetype: str):
channel_id = self._channel.channel_id
def handle_result(success: bool, msg: str = ''):
try:
if success:
self.channelCloseSuccess.emit()
else:
self.channelCloseFailed.emit(msg)
self._is_closing = False
self.isClosingChanged.emit()
except RuntimeError: # QEChannelDetails might be deleted at this point if the user closed the dialog.
pass
def do_close():
try:
self._is_closing = True
self.isClosingChanged.emit()
if closetype == 'remote_force':
self._wallet.wallet.network.run_from_another_thread(self._wallet.wallet.lnworker.request_force_close(channel_id))
elif closetype == 'local_force':
self._wallet.wallet.network.run_from_another_thread(self._wallet.wallet.lnworker.force_close_channel(channel_id))
else:
self._wallet.wallet.network.run_from_another_thread(self._wallet.wallet.lnworker.close_channel(channel_id))
self._logger.debug('Channel close successful')
handle_result(True)
except Exception as e:
self._logger.exception("Could not close channel: " + repr(e))
handle_result(False, _('Could not close channel: ') + repr(e))
threading.Thread(target=do_close, daemon=True).start()
@pyqtSlot()
def deleteChannel(self):
if self.isBackup:
self._wallet.wallet.lnworker.remove_channel_backup(self._channel.channel_id)
else:
self._wallet.wallet.lnworker.remove_channel(self._channel.channel_id)
@pyqtSlot(result=str)
def channelBackup(self):
return self._wallet.wallet.lnworker.export_channel_backup(self._channel.channel_id)
@pyqtSlot(result=str)
def channelBackupHelpText(self):
return messages.MSG_LN_EXPLAIN_SCB_BACKUPS
================================================
FILE: electrum/gui/qml/qechannellistmodel.py
================================================
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
from electrum.lnchannel import ChannelState
from electrum.lnutil import LOCAL, REMOTE
from electrum.logging import get_logger
from electrum.util import Satoshis
from electrum.gui import messages
from electrum.gui.common_qt.util import qt_event_listener, QtEventListener
from .qetypes import QEAmount
from .qemodelfilter import QEFilterProxyModel
class QEChannelListModel(QAbstractListModel, QtEventListener):
_logger = get_logger(__name__)
# define listmodel rolemap
_ROLE_NAMES=('cid', 'state', 'state_code', 'initiator', 'capacity', 'can_send',
'can_receive', 'l_csv_delay', 'r_csv_delay', 'send_frozen', 'receive_frozen',
'type', 'node_id', 'node_alias', 'short_cid', 'funding_tx', 'is_trampoline',
'is_backup', 'is_imported', 'local_capacity', 'remote_capacity')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
_ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))
_network_signal = pyqtSignal(str, object)
def __init__(self, wallet, parent=None):
super().__init__(parent)
self.wallet = wallet
self._channels = []
self._fm_backups = None
self._fm_nobackups = None
self.initModel()
# 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.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
@qt_event_listener
def on_event_channel(self, wallet, channel):
if wallet == self.wallet:
self.on_channel_updated(channel)
@qt_event_listener
def on_event_channels_updated(self, wallet):
if wallet == self.wallet:
self.initModel()
def on_destroy(self):
self.unregister_callbacks()
def rowCount(self, index):
return len(self._channels)
# also expose rowCount as a property
countChanged = pyqtSignal()
@pyqtProperty(int, notify=countChanged)
def count(self):
return len(self._channels)
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
tx = self._channels[index.row()]
role_index = role - Qt.ItemDataRole.UserRole
value = tx[self._ROLE_NAMES[role_index]]
if isinstance(value, (bool, list, int, str, QEAmount)) or value is None:
return value
if isinstance(value, Satoshis):
return value.value
return str(value)
def clear(self):
self.beginResetModel()
self._channels = []
self.endResetModel()
def channel_to_model(self, lnc):
lnworker = self.wallet.lnworker
item = {
'cid': lnc.channel_id.hex(),
'node_id': lnc.node_id.hex(),
'node_alias': lnworker.lnpeermgr.get_node_alias(lnc.node_id) or '',
'short_cid': lnc.short_id_for_GUI(),
'state': lnc.get_state_for_GUI(),
'state_code': int(lnc.get_state()),
'is_backup': lnc.is_backup(),
'is_trampoline': lnworker.is_trampoline_peer(lnc.node_id),
'capacity': QEAmount(amount_sat=lnc.get_capacity())
}
if lnc.is_backup():
item['can_send'] = QEAmount()
item['can_receive'] = QEAmount()
item['local_capacity'] = QEAmount()
item['remote_capacity'] = QEAmount()
item['send_frozen'] = True
item['receive_frozen'] = True
item['is_imported'] = lnc.is_imported
else:
item['can_send'] = QEAmount(amount_msat=lnc.available_to_spend(LOCAL))
item['can_receive'] = QEAmount(amount_msat=lnc.available_to_spend(REMOTE))
item['local_capacity'] = QEAmount(amount_msat=lnc.balance(LOCAL))
item['remote_capacity'] = QEAmount(amount_msat=lnc.balance(REMOTE))
item['send_frozen'] = lnc.is_frozen_for_sending()
item['receive_frozen'] = lnc.is_frozen_for_receiving()
item['is_imported'] = False
return item
numOpenChannelsChanged = pyqtSignal()
@pyqtProperty(int, notify=numOpenChannelsChanged)
def numOpenChannels(self):
return sum([1 if x['state_code'] == ChannelState.OPEN else 0 for x in self._channels])
@pyqtSlot()
def initModel(self):
self._logger.debug('init_model')
if not self.wallet.lnworker:
self._logger.warning('lnworker should be defined')
return
channels = []
lnchannels = self.wallet.lnworker.get_channel_objects()
for channel in lnchannels.values():
item = self.channel_to_model(channel)
channels.append(item)
# sort, for now simply by state
def chan_sort_score(c):
return c['state_code'] + (10 if c['is_backup'] else 0)
channels.sort(key=chan_sort_score)
self.clear()
self.beginInsertRows(QModelIndex(), 0, len(channels) - 1)
self._channels = channels
self.endInsertRows()
self.countChanged.emit()
def on_channel_updated(self, channel):
for i, c in enumerate(self._channels):
if c['cid'] == channel.channel_id.hex():
self.do_update(i, channel)
break
def do_update(self, modelindex, channel):
self._logger.debug(f'updating our channel {channel.short_id_for_GUI()}')
modelitem = self._channels[modelindex]
modelitem.update(self.channel_to_model(channel))
mi = self.createIndex(modelindex, 0)
self.dataChanged.emit(mi, mi, self._ROLE_KEYS)
self.numOpenChannelsChanged.emit()
@pyqtSlot(str)
def newChannel(self, cid):
self._logger.debug('new channel with cid %s' % cid)
lnchannels = self.wallet.lnworker.channels
for channel in lnchannels.values():
if cid == channel.channel_id.hex():
item = self.channel_to_model(channel)
self._logger.debug(item)
self.beginInsertRows(QModelIndex(), 0, 0)
self._channels.insert(0, item)
self.endInsertRows()
self.countChanged.emit()
return
@pyqtSlot(str)
def removeChannel(self, cid):
self._logger.debug('remove channel with cid %s' % cid)
for i, channel in enumerate(self._channels):
if cid == channel['cid']:
self._logger.debug(cid)
self.beginRemoveRows(QModelIndex(), i, i)
self._channels.remove(channel)
self.endRemoveRows()
self.countChanged.emit()
return
def filterModel(self, role, match):
_filterModel = QEFilterProxyModel(self, self)
assert role in self._ROLE_RMAP
_filterModel.setFilterRole(self._ROLE_RMAP[role])
_filterModel.setFilterValue(match)
return _filterModel
@pyqtSlot(result=QEFilterProxyModel)
def filterModelBackups(self):
self._fm_backups = self.filterModel('is_backup', True)
return self._fm_backups
@pyqtSlot(result=QEFilterProxyModel)
def filterModelNoBackups(self):
self._fm_nobackups = self.filterModel('is_backup', False)
return self._fm_nobackups
@pyqtSlot(result=str)
def lightningWarningMessage(self):
return messages.MSG_LIGHTNING_WARNING
================================================
FILE: electrum/gui/qml/qechannelopener.py
================================================
import threading
from concurrent.futures import CancelledError
from asyncio.exceptions import TimeoutError
from typing import Optional
import electrum_ecc as ecc
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum.i18n import _
from electrum.gui import messages
from electrum.util import bfh
from electrum.lnutil import MIN_FUNDING_SAT
from electrum.lntransport import extract_nodeid, ConnStringFormatError
from electrum.bitcoin import DummyAddress
from electrum.lnworker import hardcoded_trampoline_nodes
from electrum.logging import get_logger
from electrum.fee_policy import FeePolicy
from electrum.transaction import PartialTransaction
from .auth import AuthMixin, auth_protect
from .qetxfinalizer import QETxFinalizer
from .qetxdetails import QETxDetails
from .qetypes import QEAmount
from .qewallet import QEWallet
class QEChannelOpener(QObject, AuthMixin):
_logger = get_logger(__name__)
validationError = pyqtSignal([str, str], arguments=['code', 'message'])
conflictingBackup = pyqtSignal([str], arguments=['message'])
channelOpening = pyqtSignal([str], arguments=['peer'])
channelOpenError = pyqtSignal([str], arguments=['message'])
channelOpenSuccess = pyqtSignal([str, bool, int, bool],
arguments=['cid', 'has_onchain_backup', 'min_depth', 'tx_complete'])
dataChanged = pyqtSignal() # generic notify signal
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None # type: Optional[QEWallet]
self._connect_str = None
self._amount = QEAmount()
self._valid = False
self._opentx = None
self._txdetails = None
self._warning = ''
self._determine_max_message = None
self._finalizer = None
self._node_pubkey = None
self._connect_str_resolved = None
self._updating_max = False
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self):
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self.walletChanged.emit()
connectStrChanged = pyqtSignal()
@pyqtProperty(str, notify=connectStrChanged)
def connectStr(self):
return self._connect_str
@connectStr.setter
def connectStr(self, connect_str: str):
if self._connect_str != connect_str:
self._logger.debug('connectStr set -> %s' % connect_str)
self._connect_str = connect_str
self.connectStrChanged.emit()
self.validate()
amountChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=amountChanged)
def amount(self):
return self._amount
@amount.setter
def amount(self, amount: QEAmount):
if self._amount != amount:
self._amount.copyFrom(amount)
self.amountChanged.emit()
self.validate()
validChanged = pyqtSignal()
@pyqtProperty(bool, notify=validChanged)
def valid(self):
return self._valid
def setValid(self, is_valid):
if self._valid != is_valid:
self._valid = is_valid
self.validChanged.emit()
warningChanged = pyqtSignal()
@pyqtProperty(str, notify=warningChanged)
def warning(self):
return self._warning
def setWarning(self, warning):
if self._warning != warning:
self._warning = warning
self.warningChanged.emit()
finalizerChanged = pyqtSignal()
@pyqtProperty(QETxFinalizer, notify=finalizerChanged)
def finalizer(self):
return self._finalizer
txDetailsChanged = pyqtSignal()
@pyqtProperty(QETxDetails, notify=txDetailsChanged)
def txDetails(self):
return self._txdetails
@pyqtProperty(list, notify=dataChanged)
def trampolineNodeNames(self):
return list(hardcoded_trampoline_nodes().keys())
# FIXME have requested funding amount
def validate(self):
"""side-effects: sets self._node_pubkey, self._connect_str_resolved"""
connect_str_valid = False
if self._connect_str:
self._logger.debug(f'checking if {self._connect_str=!r} is valid')
if not self._wallet.wallet.config.LIGHTNING_USE_GOSSIP:
# using trampoline: connect_str is the name of a trampoline node
peer_addr = hardcoded_trampoline_nodes()[self._connect_str]
self._node_pubkey = peer_addr.pubkey
self._connect_str_resolved = str(peer_addr)
connect_str_valid = True
else:
# using gossip: connect_str is anything extract_nodeid() can parse
try:
self._node_pubkey, _rest = extract_nodeid(self._connect_str)
except ConnStringFormatError:
pass
else:
self._connect_str_resolved = self._connect_str
connect_str_valid = True
self.setWarning('')
if not connect_str_valid:
self.setValid(False)
return
self._logger.debug(f'amount={self._amount}')
if not self._amount or not (self._amount.satsInt > 0 or self._amount.isMax):
self.setValid(False)
return
# for MAX, estimate is assumed to be calculated and set in self._amount.satsInt
if self._amount.satsInt < MIN_FUNDING_SAT:
message = _('Minimum required amount: {}').format(
self._wallet.wallet.config.format_amount_and_units(MIN_FUNDING_SAT)
)
if self._amount.isMax and self._determine_max_message:
message += '\n' + self._determine_max_message
self.setWarning(message)
self.setValid(False)
return
if self._amount.satsInt > self._wallet.wallet.config.LIGHTNING_MAX_FUNDING_SAT:
self.setWarning(_('Amount is above maximum channel size: {}').format(
self._wallet.wallet.config.format_amount_and_units(self._wallet.wallet.config.LIGHTNING_MAX_FUNDING_SAT)
))
self.setValid(False)
return
self.setValid(True)
@pyqtSlot(str, result=bool)
def validateConnectString(self, connect_str):
try:
extract_nodeid(connect_str)
except ConnStringFormatError as e:
self._logger.debug(f'invalid connect_str. {e!r}')
return False
return True
# FIXME "max" button in amount_dialog should enforce LIGHTNING_MAX_FUNDING_SAT
@pyqtSlot()
@pyqtSlot(bool)
def openChannel(self, confirm_backup_conflict=False):
if not self.valid:
return
self._logger.debug(f'Connect String: {self._connect_str!r}')
lnworker = self._wallet.wallet.lnworker
if lnworker.has_conflicting_backup_with(self._node_pubkey) and not confirm_backup_conflict:
self.conflictingBackup.emit(messages.MSG_CONFLICTING_BACKUP_INSTANCE)
return
amount = '!' if self._amount.isMax else self._amount.satsInt
self._logger.debug('amount = %s' % str(amount))
coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True)
mktx = lambda amt, fee_policy: lnworker.mktx_for_open_channel(
coins=coins,
funding_sat=amt,
node_id=self._node_pubkey,
fee_policy=fee_policy)
acpt = lambda tx: self.do_open_channel(tx, self._connect_str_resolved, self._wallet.password)
self._finalizer = QETxFinalizer(self, make_tx=mktx, accept=acpt)
self._finalizer.canRbf = False
self._finalizer.amount = self._amount
self._finalizer.wallet = self._wallet
self.finalizerChanged.emit()
@auth_protect(message=_('Open Lightning channel?'))
def do_open_channel(self, funding_tx: PartialTransaction, conn_str, password):
"""
conn_str: a connection string that extract_nodeid can parse, i.e. cannot be a trampoline name
"""
self._logger.debug('opening channel')
# read funding_sat from tx; converts '!' to int value
funding_sat = funding_tx.output_value_for_address(DummyAddress.CHANNEL)
lnworker = self._wallet.wallet.lnworker
def open_thread():
error = None
try:
chan, _funding_tx = lnworker.open_channel(
connect_str=conn_str,
funding_tx=funding_tx,
funding_sat=funding_sat,
push_amt_sat=0,
password=password)
self._logger.debug('opening channel succeeded')
self.channelOpenSuccess.emit(chan.channel_id.hex(), chan.has_onchain_backup(),
chan.constraints.funding_txn_minimum_depth, funding_tx.is_complete())
# TODO: handle incomplete TX
# if not funding_tx.is_complete():
# self._txdetails = QETxDetails(self)
# self._txdetails.rawTx = funding_tx
# self._txdetails.wallet = self._wallet
# self.txDetailsChanged.emit()
except (CancelledError, TimeoutError):
error = _('Could not connect to channel peer')
except Exception as e:
error = str(e)
if not error:
error = repr(e)
finally:
if error:
self._logger.exception("Problem opening channel: %s", error)
self.channelOpenError.emit(error)
self._logger.debug('starting open thread')
self.channelOpening.emit(conn_str)
threading.Thread(target=open_thread, daemon=True).start()
@pyqtSlot(str, result=str)
def channelBackup(self, cid):
return self._wallet.wallet.lnworker.export_channel_backup(bfh(cid))
@pyqtSlot()
def updateMaxAmount(self):
if self._updating_max:
return
self._updating_max = True
def calc_max():
try:
coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True)
dummy_nodeid = ecc.GENERATOR.get_public_key_bytes(compressed=True)
make_tx = lambda fee_policy: self._wallet.wallet.lnworker.mktx_for_open_channel(
coins=coins,
funding_sat='!',
node_id=dummy_nodeid,
fee_policy=fee_policy)
amount, self._determine_max_message = self._wallet.determine_max(mktx=make_tx)
self._amount.satsInt = amount if amount else 0
finally:
self._updating_max = False
self.validate()
threading.Thread(target=calc_max, daemon=True).start()
================================================
FILE: electrum/gui/qml/qeconfig.py
================================================
import copy
from decimal import Decimal
from typing import TYPE_CHECKING
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRegularExpression
from electrum.bitcoin import TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from electrum.i18n import set_language, get_gui_lang_names
from electrum.logging import get_logger
from electrum.util import base_unit_name_to_decimal_point
from electrum.gui import messages
from .qetypes import QEAmount
from .auth import AuthMixin, auth_protect
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
class QEConfig(AuthMixin, QObject):
instance = None # type: Optional[QEConfig]
_logger = get_logger(__name__)
def __init__(self, config: 'SimpleConfig', parent=None):
super().__init__(parent)
if QEConfig.instance:
raise RuntimeError('There should only be one QEConfig instance')
QEConfig.instance = self
self.config = config
@pyqtSlot(str, result=str)
def shortDescFor(self, key) -> str:
cv = getattr(self.config.cv, key)
return cv.get_short_desc() if cv else ''
@pyqtSlot(str, result=str)
def longDescFor(self, key) -> str:
cv = getattr(self.config.cv, key)
if not cv:
return ""
desc = cv.get_long_desc()
return messages.to_rtf(desc)
@pyqtSlot(str, result=str)
def getTranslatedMessage(self, key) -> str:
return getattr(messages, key)
languageChanged = pyqtSignal()
@pyqtProperty(str, notify=languageChanged)
def language(self):
return self.config.LOCALIZATION_LANGUAGE
@language.setter
def language(self, language):
if language not in get_gui_lang_names():
return
if self.config.LOCALIZATION_LANGUAGE != language:
self.config.LOCALIZATION_LANGUAGE = language
set_language(language)
self.languageChanged.emit()
languagesChanged = pyqtSignal()
@pyqtProperty('QVariantList', notify=languagesChanged)
def languagesAvailable(self):
langs = get_gui_lang_names()
langs_list = list(map(lambda x: {'value': x[0], 'text': x[1]}, langs.items()))
return langs_list
termsOfUseChanged = pyqtSignal()
@pyqtProperty(bool, notify=termsOfUseChanged)
def termsOfUseAccepted(self) -> bool:
return self.config.TERMS_OF_USE_ACCEPTED >= messages.TERMS_OF_USE_LATEST_VERSION
@termsOfUseAccepted.setter
def termsOfUseAccepted(self, accepted: bool) -> None:
if accepted:
self.config.TERMS_OF_USE_ACCEPTED = messages.TERMS_OF_USE_LATEST_VERSION
else:
self.config.TERMS_OF_USE_ACCEPTED = 0
self.termsOfUseChanged.emit()
baseUnitChanged = pyqtSignal()
@pyqtProperty(str, notify=baseUnitChanged)
def baseUnit(self):
return self.config.get_base_unit()
@baseUnit.setter
def baseUnit(self, unit):
self.config.set_base_unit(unit)
self.baseUnitChanged.emit()
@pyqtProperty('QRegularExpression', notify=baseUnitChanged)
def btcAmountRegex(self):
return self._btcAmountRegex()
@pyqtProperty('QRegularExpression', notify=baseUnitChanged)
def btcAmountRegexMsat(self):
return self._btcAmountRegex(3)
def _btcAmountRegex(self, extra_precision: int = 0):
decimal_point = base_unit_name_to_decimal_point(self.config.get_base_unit())
max_digits_before_dp = (
len(str(TOTAL_COIN_SUPPLY_LIMIT_IN_BTC))
+ (base_unit_name_to_decimal_point("BTC") - decimal_point))
exp = '^[0-9]{0,%d}' % max_digits_before_dp
decimal_point += extra_precision
if decimal_point > 0:
exp += '(\\.[0-9]{0,%d})?' % decimal_point
exp += '$'
return QRegularExpression(exp)
thousandsSeparatorChanged = pyqtSignal()
@pyqtProperty(bool, notify=thousandsSeparatorChanged)
def thousandsSeparator(self):
return self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP
@thousandsSeparator.setter
def thousandsSeparator(self, checked):
self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP = checked
self.config.amt_add_thousands_sep = checked
self.thousandsSeparatorChanged.emit()
spendUnconfirmedChanged = pyqtSignal()
@pyqtProperty(bool, notify=spendUnconfirmedChanged)
def spendUnconfirmed(self):
return not self.config.WALLET_SPEND_CONFIRMED_ONLY
@spendUnconfirmed.setter
def spendUnconfirmed(self, checked):
self.config.WALLET_SPEND_CONFIRMED_ONLY = not checked
self.spendUnconfirmedChanged.emit()
freezeReusedAddressUtxosChanged = pyqtSignal()
@pyqtProperty(bool, notify=freezeReusedAddressUtxosChanged)
def freezeReusedAddressUtxos(self):
return self.config.WALLET_FREEZE_REUSED_ADDRESS_UTXOS
@freezeReusedAddressUtxos.setter
def freezeReusedAddressUtxos(self, checked):
self.config.WALLET_FREEZE_REUSED_ADDRESS_UTXOS = checked
self.freezeReusedAddressUtxosChanged.emit()
requestExpiryChanged = pyqtSignal()
@pyqtProperty(int, notify=requestExpiryChanged)
def requestExpiry(self):
return self.config.WALLET_PAYREQ_EXPIRY_SECONDS
@requestExpiry.setter
def requestExpiry(self, expiry):
self.config.WALLET_PAYREQ_EXPIRY_SECONDS = expiry
self.requestExpiryChanged.emit()
paymentAuthenticationChanged = pyqtSignal()
@pyqtProperty(bool, notify=paymentAuthenticationChanged)
def paymentAuthentication(self):
return self.config.GUI_QML_PAYMENT_AUTHENTICATION
@paymentAuthentication.setter
def paymentAuthentication(self, enabled: bool):
if enabled:
self.config.GUI_QML_PAYMENT_AUTHENTICATION = True
self.paymentAuthenticationChanged.emit()
else:
self._disable_payment_authentication()
@auth_protect(method='wallet', reject='_payment_auth_reject')
def _disable_payment_authentication(self):
self.config.GUI_QML_PAYMENT_AUTHENTICATION = False
self.paymentAuthenticationChanged.emit()
def _payment_auth_reject(self):
self.paymentAuthenticationChanged.emit()
useGossipChanged = pyqtSignal()
@pyqtProperty(bool, notify=useGossipChanged)
def useGossip(self):
return self.config.LIGHTNING_USE_GOSSIP
@useGossip.setter
def useGossip(self, gossip):
self.config.LIGHTNING_USE_GOSSIP = gossip
self.useGossipChanged.emit()
enableDebugLogsChanged = pyqtSignal()
@pyqtProperty(bool, notify=enableDebugLogsChanged)
def enableDebugLogs(self):
gui_setting = self.config.GUI_ENABLE_DEBUG_LOGS
return gui_setting or bool(self.config.get('verbosity'))
@pyqtProperty(bool, notify=enableDebugLogsChanged)
def canToggleDebugLogs(self):
gui_setting = self.config.GUI_ENABLE_DEBUG_LOGS
return not self.config.get('verbosity') or gui_setting
@enableDebugLogs.setter
def enableDebugLogs(self, enable):
self.config.GUI_ENABLE_DEBUG_LOGS = enable
self.enableDebugLogsChanged.emit()
alwaysAllowScreenshotsChanged = pyqtSignal()
@pyqtProperty(bool, notify=alwaysAllowScreenshotsChanged)
def alwaysAllowScreenshots(self):
return self.config.GUI_QML_ALWAYS_ALLOW_SCREENSHOTS
@alwaysAllowScreenshots.setter
def alwaysAllowScreenshots(self, enable):
self.config.GUI_QML_ALWAYS_ALLOW_SCREENSHOTS = enable
self.alwaysAllowScreenshotsChanged.emit()
setMaxBrightnessOnQrDisplayChanged = pyqtSignal()
@pyqtProperty(bool, notify=setMaxBrightnessOnQrDisplayChanged)
def setMaxBrightnessOnQrDisplay(self):
return self.config.GUI_QML_SET_MAX_BRIGHTNESS_ON_QR_DISPLAY
@setMaxBrightnessOnQrDisplay.setter
def setMaxBrightnessOnQrDisplay(self, enable):
self.config.GUI_QML_SET_MAX_BRIGHTNESS_ON_QR_DISPLAY = enable
useRecoverableChannelsChanged = pyqtSignal()
@pyqtProperty(bool, notify=useRecoverableChannelsChanged)
def useRecoverableChannels(self):
return self.config.LIGHTNING_USE_RECOVERABLE_CHANNELS
@useRecoverableChannels.setter
def useRecoverableChannels(self, useRecoverableChannels):
self.config.LIGHTNING_USE_RECOVERABLE_CHANNELS = useRecoverableChannels
self.useRecoverableChannelsChanged.emit()
trustedcoinPrepayChanged = pyqtSignal()
@pyqtProperty(int, notify=trustedcoinPrepayChanged)
def trustedcoinPrepay(self):
return self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY
@trustedcoinPrepay.setter
def trustedcoinPrepay(self, num_prepay):
if num_prepay != self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY:
self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY = num_prepay
self.trustedcoinPrepayChanged.emit()
preferredRequestTypeChanged = pyqtSignal()
@pyqtProperty(str, notify=preferredRequestTypeChanged)
def preferredRequestType(self):
return self.config.GUI_QML_PREFERRED_REQUEST_TYPE
@preferredRequestType.setter
def preferredRequestType(self, preferred_request_type):
if preferred_request_type != self.config.GUI_QML_PREFERRED_REQUEST_TYPE:
self.config.GUI_QML_PREFERRED_REQUEST_TYPE = preferred_request_type
self.preferredRequestTypeChanged.emit()
userKnowsPressAndHoldChanged = pyqtSignal()
@pyqtProperty(bool, notify=userKnowsPressAndHoldChanged)
def userKnowsPressAndHold(self):
return self.config.GUI_QML_USER_KNOWS_PRESS_AND_HOLD
@userKnowsPressAndHold.setter
def userKnowsPressAndHold(self, userKnowsPressAndHold):
if userKnowsPressAndHold != self.config.GUI_QML_USER_KNOWS_PRESS_AND_HOLD:
self.config.GUI_QML_USER_KNOWS_PRESS_AND_HOLD = userKnowsPressAndHold
self.userKnowsPressAndHoldChanged.emit()
addresslistShowTypeChanged = pyqtSignal()
@pyqtProperty(int, notify=addresslistShowTypeChanged)
def addresslistShowType(self):
return self.config.GUI_QML_ADDRESS_LIST_SHOW_TYPE
@addresslistShowType.setter
def addresslistShowType(self, addresslistShowType):
if addresslistShowType != self.config.GUI_QML_ADDRESS_LIST_SHOW_TYPE:
self.config.GUI_QML_ADDRESS_LIST_SHOW_TYPE = addresslistShowType
self.addresslistShowTypeChanged.emit()
addresslistShowUsedChanged = pyqtSignal()
@pyqtProperty(bool, notify=addresslistShowUsedChanged)
def addresslistShowUsed(self):
return self.config.GUI_QML_ADDRESS_LIST_SHOW_USED
@addresslistShowUsed.setter
def addresslistShowUsed(self, addresslistShowUsed):
if addresslistShowUsed != self.config.GUI_QML_ADDRESS_LIST_SHOW_USED:
self.config.GUI_QML_ADDRESS_LIST_SHOW_USED = addresslistShowUsed
self.addresslistShowUsedChanged.emit()
outputValueRoundingChanged = pyqtSignal()
@pyqtProperty(bool, notify=outputValueRoundingChanged)
def outputValueRounding(self):
return self.config.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING
@outputValueRounding.setter
def outputValueRounding(self, outputValueRounding):
if outputValueRounding != self.config.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING:
self.config.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING = outputValueRounding
self.outputValueRoundingChanged.emit()
lightningPaymentFeeMaxMillionthsChanged = pyqtSignal()
@pyqtProperty(int, notify=lightningPaymentFeeMaxMillionthsChanged)
def lightningPaymentFeeMaxMillionths(self):
return self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS
@lightningPaymentFeeMaxMillionths.setter
def lightningPaymentFeeMaxMillionths(self, lightningPaymentFeeMaxMillionths):
if lightningPaymentFeeMaxMillionths != self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS:
self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS = lightningPaymentFeeMaxMillionths
self.lightningPaymentFeeMaxMillionthsChanged.emit()
nostrRelaysChanged = pyqtSignal()
@pyqtProperty(str, notify=nostrRelaysChanged)
def nostrRelays(self):
return self.config.NOSTR_RELAYS
@nostrRelays.setter
def nostrRelays(self, nostr_relays):
if nostr_relays != self.config.NOSTR_RELAYS:
self.config.NOSTR_RELAYS = nostr_relays if nostr_relays else None
self.nostrRelaysChanged.emit()
swapServerNPubChanged = pyqtSignal()
@pyqtProperty(str, notify=swapServerNPubChanged)
def swapServerNPub(self):
return self.config.SWAPSERVER_NPUB
@swapServerNPub.setter
def swapServerNPub(self, swapserver_npub):
if swapserver_npub != self.config.SWAPSERVER_NPUB:
self.config.SWAPSERVER_NPUB = swapserver_npub
self.swapServerNPubChanged.emit()
lnUtxoReserveChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=lnUtxoReserveChanged)
def lnUtxoReserve(self):
self._lnutxoreserve = QEAmount(amount_sat=self.config.LN_UTXO_RESERVE)
return self._lnutxoreserve
walletShouldUseSinglePasswordChanged = pyqtSignal()
@pyqtProperty(bool, notify=walletShouldUseSinglePasswordChanged)
def walletShouldUseSinglePassword(self):
"""
NOTE: this only indicates if we even want to use a single password, to check if we
actually use a single password the daemon needs to be checked.
"""
return self.config.WALLET_SHOULD_USE_SINGLE_PASSWORD
walletDidUseSinglePasswordChanged = pyqtSignal()
@pyqtProperty(bool, notify=walletDidUseSinglePasswordChanged)
def walletDidUseSinglePassword(self):
"""
Allows to guess if this is a unified password instance without having
unlocked any wallet yet. Might be out of sync e.g. if wallet files get copied manually.
"""
# TODO: consider removing once encrypted wallet file headers are available
return self.config.WALLET_DID_USE_SINGLE_PASSWORD
@pyqtSlot('qint64', result=str)
@pyqtSlot(QEAmount, result=str)
def formatSatsForEditing(self, satoshis):
if isinstance(satoshis, QEAmount):
satoshis = satoshis.satsInt
return self.config.format_amount(
satoshis,
add_thousands_sep=False,
)
@pyqtSlot('qint64', result=str)
@pyqtSlot('qint64', bool, result=str)
@pyqtSlot(QEAmount, result=str)
@pyqtSlot(QEAmount, bool, result=str)
def formatSats(self, satoshis, with_unit=False):
if isinstance(satoshis, QEAmount):
satoshis = satoshis.satsInt
if with_unit:
return self.config.format_amount_and_units(satoshis)
else:
return self.config.format_amount(satoshis)
@pyqtSlot(QEAmount, result=str)
@pyqtSlot(QEAmount, bool, result=str)
def formatMilliSats(self, amount, with_unit=False):
assert isinstance(amount, QEAmount), f"unexpected type for amount: {type(amount)}"
msats = amount.msatsInt
precision = 3 # config.amt_precision_post_satoshi is not exposed in preferences
if with_unit:
return self.config.format_amount_and_units(msats/1000, precision=precision)
else:
return self.config.format_amount(msats/1000, precision=precision)
@pyqtSlot(str, result=QEAmount)
def unitsToSats(self, unitAmount):
self._amount = QEAmount()
try:
x = Decimal(unitAmount)
except Exception:
return self._amount
sat_max_precision = self.config.BTC_AMOUNTS_DECIMAL_POINT
msat_max_precision = self.config.BTC_AMOUNTS_DECIMAL_POINT + 3
sat_max_prec_amount = int(pow(10, sat_max_precision) * x)
msat_max_prec_amount = int(pow(10, msat_max_precision) * x)
self._amount = QEAmount(amount_sat=sat_max_prec_amount, amount_msat=msat_max_prec_amount)
return self._amount
@pyqtSlot('quint64', result=float)
def satsToUnits(self, satoshis):
return satoshis / pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT)
================================================
FILE: electrum/gui/qml/qedaemon.py
================================================
import base64
import os
import threading
from typing import TYPE_CHECKING
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.util import WalletFileException, standardize_path, InvalidPassword, send_exception_to_crash_reporter
from electrum.plugin import run_hook
from electrum.lnchannel import ChannelState
from electrum.bitcoin import is_address
from electrum.bitcoin import verify_usermessage_with_address
from electrum.storage import StorageReadWriteError
from .auth import AuthMixin, auth_protect
from .qefx import QEFX
from .qewallet import QEWallet
from .qewizard import QENewWalletWizard, QEServerConnectWizard, QETermsOfUseWizard
if TYPE_CHECKING:
from electrum.daemon import Daemon
from electrum.plugin import Plugins
# wallet list model. supports both wallet basenames (wallet file basenames)
# and whole Wallet instances (loaded wallets)
from .util import check_password_strength
class QEWalletListModel(QAbstractListModel):
_logger = get_logger(__name__)
# define listmodel rolemap
_ROLE_NAMES= ('name', 'path', 'active')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
def __init__(self, daemon: 'Daemon', parent=None):
QAbstractListModel.__init__(self, parent)
self.daemon = daemon
self._wallets = []
self.reload()
def rowCount(self, index):
return len(self._wallets)
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
(wallet_name, wallet_path) = self._wallets[index.row()]
role_index = role - Qt.ItemDataRole.UserRole
role_name = self._ROLE_NAMES[role_index]
if role_name == 'name':
return wallet_name
if role_name == 'path':
return wallet_path
if role_name == 'active':
return self.daemon.get_wallet(wallet_path) is not None
@pyqtSlot()
def reload(self):
self._logger.debug('enumerating available wallets')
self.beginResetModel()
self._wallets = []
self.endResetModel()
available = []
wallet_folder = os.path.dirname(self.daemon.config.get_wallet_path())
with os.scandir(wallet_folder) as it:
for i in it:
if i.is_file() and not i.name.startswith('.'):
available.append(i.path)
for path in sorted(available):
wallet = self.daemon.get_wallet(path)
self.add_wallet(wallet_path=path)
def add_wallet(self, wallet_path):
self.beginInsertRows(QModelIndex(), len(self._wallets), len(self._wallets))
wallet_name = os.path.basename(wallet_path)
wallet_path = standardize_path(wallet_path)
item = (wallet_name, wallet_path)
self._wallets.append(item)
self.endInsertRows()
def remove_wallet(self, path):
i = 0
wallets = []
remove = -1
for wallet_name, wallet_path in self._wallets:
if wallet_path == path:
remove = i
else:
wallets.append((wallet_name, wallet_path))
i += 1
if remove >= 0:
self.beginRemoveRows(QModelIndex(), remove, remove)
self._wallets = wallets
self.endRemoveRows()
@pyqtSlot(str, result=bool)
def wallet_name_exists(self, name):
for wallet_name, wallet_path in self._wallets:
if name == wallet_name:
return True
return False
@pyqtSlot(str)
def updateWallet(self, path):
i = 0
for wallet_name, wallet_path in self._wallets:
if wallet_path == path:
mi = self.createIndex(i, i)
self.dataChanged.emit(mi, mi, self._ROLE_KEYS)
return
i += 1
class QEDaemon(AuthMixin, QObject):
instance = None # type: Optional[QEDaemon]
_logger = get_logger(__name__)
_available_wallets = None
_current_wallet = None
_new_wallet_wizard = None
_terms_of_use_wizard = None
_server_connect_wizard = None
_path = None
_name = None
_use_single_password = False
_password = None
_loading = False
_backendWalletLoaded = pyqtSignal([str], arguments=['password'])
availableWalletsChanged = pyqtSignal()
fxChanged = pyqtSignal()
newWalletWizardChanged = pyqtSignal()
termsOfUseWizardChanged = pyqtSignal()
serverConnectWizardChanged = pyqtSignal()
loadingChanged = pyqtSignal()
requestNewPassword = pyqtSignal()
walletLoaded = pyqtSignal([str, str], arguments=['name', 'path'])
walletRequiresPassword = pyqtSignal([str, str], arguments=['name', 'path'])
walletOpenError = pyqtSignal([str], arguments=["error"])
walletDeleteError = pyqtSignal([str, str], arguments=['code', 'message'])
def __init__(self, daemon: 'Daemon', plugins: 'Plugins', parent=None):
super().__init__(parent)
if QEDaemon.instance:
raise RuntimeError('There should only be one QEDaemon instance')
QEDaemon.instance = self
self.daemon = daemon
self.plugins = plugins
self.qefx = QEFX(daemon.fx, daemon.config)
self._backendWalletLoaded.connect(self._on_backend_wallet_loaded)
@pyqtSlot()
def passwordValidityCheck(self):
if not self._walletdb._validPassword:
self.walletRequiresPassword.emit(self._name, self._path)
@pyqtSlot()
@pyqtSlot(str)
@pyqtSlot(str, str)
def loadWallet(self, path=None, password=None):
if self._loading:
return
self._loading = True
if path is None:
self._path = self.daemon.config.get('wallet_path') # command line -w option
if self._path is None:
self._path = self.daemon.config.CURRENT_WALLET
else:
self._path = path
if self._path is None:
self._loading = False
return
self.loadingChanged.emit()
self._path = standardize_path(self._path)
self._name = os.path.basename(self._path)
self._logger.debug('load wallet ' + str(self._path))
# password unification helper:
# - if pw not given (None), try pw of current wallet.
# - but "" empty str passwords are kept as-is, to open passwordless wallets
if password is None:
password = self._password
# map explicit empty str password to None. the backend disallows empty str passwords.
if password == '':
password = None
wallet_already_open = self.daemon.get_wallet(self._path)
if wallet_already_open is not None:
password = QEWallet.getInstanceFor(wallet_already_open).password
def load_wallet_task():
success = False
try:
local_password = password # need this in local scope
wallet = None
try:
wallet = self.daemon.load_wallet(
self._path,
password=local_password,
upgrade=True,
# might have a keystore password, but unencrypted storage. we want to prompt for pw even then:
force_check_password=True,
)
except InvalidPassword:
self.walletRequiresPassword.emit(self._name, self._path)
except FileNotFoundError:
self.walletOpenError.emit(_('File not found') + f":\n{self._path}")
except StorageReadWriteError:
self.walletOpenError.emit(_('Could not read/write file'))
except WalletFileException as e:
self.walletOpenError.emit(_('Could not open wallet: {}').format(str(e)))
if e.should_report_crash:
send_exception_to_crash_reporter(e)
if wallet is None:
return
if self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD:
self._use_single_password = self._update_password_for_directory_and_unlock_wallets(old_password=local_password, new_password=local_password)
if not self._use_single_password and self.daemon.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION:
# we need to disable biometric auth if the user creates wallets with different passwords as
# we only store one encrypted password which is not associated to a specific wallet
self._logger.warning(f"disabling biometric authentication, not in single password mode")
self.daemon.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = False
self.daemon.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = ''
self.daemon.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = ''
self._password = local_password
self.singlePasswordChanged.emit()
self._logger.info(f'use single password: {self._use_single_password}')
else:
self._logger.info('use single password disabled by config')
self.daemon.config.WALLET_DID_USE_SINGLE_PASSWORD = self._use_single_password
run_hook('load_wallet', wallet)
success = True
self._backendWalletLoaded.emit(local_password)
finally:
if not success: # if successful, _loading guard will be reset by _on_backend_wallet_loaded
self._loading = False
self.loadingChanged.emit()
threading.Thread(target=load_wallet_task, daemon=False).start()
@pyqtSlot()
@pyqtSlot(str)
def _on_backend_wallet_loaded(self, password=None):
self._logger.debug('_on_backend_wallet_loaded')
wallet = self.daemon.get_wallet(self._path)
assert wallet is not None
self._current_wallet = QEWallet.getInstanceFor(wallet)
self.availableWallets.updateWallet(self._path)
wallet.unlock(password or None) # not conditional on wallet.requires_unlock in qml, as
# the auth wrapper doesn't pass the entered password, but instead we rely on the password in memory
self._loading = False
self.loadingChanged.emit()
self.walletLoaded.emit(self._name, self._path)
@pyqtSlot(QEWallet)
@pyqtSlot(QEWallet, bool)
@pyqtSlot(QEWallet, bool, bool)
def checkThenDeleteWallet(self, wallet, confirm_requests=False, confirm_balance=False):
if wallet.wallet.lnworker:
lnchannels = wallet.wallet.lnworker.get_channel_objects()
if any([channel.get_state() != ChannelState.REDEEMED and not channel.is_backup() for channel in lnchannels.values()]):
self.walletDeleteError.emit('unclosed_channels', _('There are still channels that are not fully closed'))
return
num_requests = len(wallet.wallet.get_unpaid_requests())
if num_requests > 0 and not confirm_requests:
self.walletDeleteError.emit('unpaid_requests', _('There are still unpaid requests. Really delete?'))
return
c, u, x = wallet.wallet.get_balance()
if c+u+x > 0 and not wallet.wallet.is_watching_only() and not confirm_balance:
self.walletDeleteError.emit('balance', _('There are still coins present in this wallet. Really delete?'))
return
self.delete_wallet(wallet)
@auth_protect(message=_('Really delete this wallet?'))
def delete_wallet(self, wallet):
path = standardize_path(wallet.wallet.storage.path)
self._logger.debug('deleting wallet with path %s' % path)
self._current_wallet = None
# TODO walletLoaded signal is confusing
self.walletLoaded.emit(None, None)
if not self.daemon.delete_wallet(path):
self.walletDeleteError.emit('error', _('Problem deleting wallet'))
return
self.availableWallets.remove_wallet(path)
@pyqtProperty(bool, notify=loadingChanged)
def loading(self):
return self._loading
@pyqtProperty(QEWallet, notify=walletLoaded)
def currentWallet(self):
return self._current_wallet
@pyqtProperty(QEWalletListModel, notify=availableWalletsChanged)
def availableWallets(self):
if not self._available_wallets:
self._available_wallets = QEWalletListModel(self.daemon)
return self._available_wallets
@pyqtProperty(QEFX, notify=fxChanged)
def fx(self):
return self.qefx
@pyqtSlot(str, result=list)
def getWalletsUnlockableWithPassword(self, password: str) -> list[str]:
"""
Returns any wallet that can be unlocked with the given password.
Can be used as fallback to unlock another wallet the user entered a
password that doesn't work for the current wallet but might work for another one.
"""
wallet_dir = os.path.dirname(self.daemon.config.get_wallet_path())
_, _, wallet_paths_can_unlock = self.daemon.check_password_for_directory(
old_password=password,
new_password=None,
wallet_dir=wallet_dir,
)
if not wallet_paths_can_unlock:
return []
self._logger.debug(f"getWalletsUnlockableWithPassword: can unlock {len(wallet_paths_can_unlock)} wallets")
return [str(path) for path in wallet_paths_can_unlock]
@pyqtSlot(str, result=int)
def numWalletsWithPassword(self, password: str) -> int:
"""Returns the number of wallets that can be unlocked with the given password"""
wallet_paths_can_unlock = self.getWalletsUnlockableWithPassword(password)
return len(wallet_paths_can_unlock)
singlePasswordChanged = pyqtSignal()
@pyqtProperty(bool, notify=singlePasswordChanged)
def singlePasswordEnabled(self):
"""
singlePasswordEnabled is False if:
a.) the user has no wallet (and password) yet
b.) the user has wallets with different passwords (legacy)
c.) all wallets are locked, we couldn't check yet if they all use the same password
d.) we are on desktop where different passwords are allowed
"""
return self._use_single_password
@pyqtProperty(str, notify=singlePasswordChanged)
def singlePassword(self):
"""
self._password is also set to the last loaded wallet password if we WANT a single password,
but don't actually have a single password yet. So singlePassword being set doesn't strictly
mean all wallets use the same password.
"""
return self._password
@singlePassword.setter
def singlePassword(self, password: str):
assert password
assert self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD
if self._password != password:
self._password = password
self.singlePasswordChanged.emit()
@pyqtSlot(result=str)
def suggestWalletName(self):
# FIXME why not use util.get_new_wallet_name ?
i = 1
while self.availableWallets.wallet_name_exists(f'wallet_{i}'):
i = i + 1
return f'wallet_{i}'
@pyqtSlot()
@auth_protect(method='wallet_password_only')
def startChangePassword(self):
if self._use_single_password:
self.requestNewPassword.emit()
else:
self.currentWallet.requestNewPassword.emit()
@pyqtSlot(str, result=bool)
def setPassword(self, password):
assert self._use_single_password
assert password
if not self._update_password_for_directory_and_unlock_wallets(old_password=self._password, new_password=password):
return False
self._password = password
return True
def _update_password_for_directory_and_unlock_wallets(self, *, old_password, new_password):
# note: this assumes all wallet files are in a single directory.
# change wallet passwords:
ret = self.daemon.update_password_for_directory(old_password=old_password, new_password=new_password)
# If some wallets just had their password changed, they got "locked" by wallet.update_password().
# If the password is not unified yet, other loaded wallets might still be unlocked.
# restore the invariant that all loaded wallets in qml must be unlocked:
for w in self.daemon.get_wallets().values():
if not w.is_unlocked():
w.unlock(new_password)
assert w.is_unlocked()
return ret
@pyqtProperty(QENewWalletWizard, notify=newWalletWizardChanged)
def newWalletWizard(self):
if not self._new_wallet_wizard:
self._new_wallet_wizard = QENewWalletWizard(self, self.plugins)
return self._new_wallet_wizard
@pyqtProperty(QEServerConnectWizard, notify=serverConnectWizardChanged)
def serverConnectWizard(self):
if not self._server_connect_wizard:
self._server_connect_wizard = QEServerConnectWizard(self)
return self._server_connect_wizard
@pyqtProperty(QETermsOfUseWizard, notify=termsOfUseWizardChanged)
def termsOfUseWizard(self):
if not self._terms_of_use_wizard:
self._terms_of_use_wizard = QETermsOfUseWizard(self)
return self._terms_of_use_wizard
@pyqtSlot()
def startNetwork(self):
self.daemon.start_network()
@pyqtSlot(str, str, str, result=bool)
def verifyMessage(self, address, message, signature):
address = address.strip()
message = message.strip().encode('utf-8')
if not is_address(address):
return False
try:
# This can throw on invalid base64
sig = base64.b64decode(str(signature.strip()), validate=True)
verified = verify_usermessage_with_address(address, sig, message)
except Exception as e:
verified = False
return verified
@pyqtSlot(str, result=int)
def passwordStrength(self, password):
if len(password) == 0:
return 0
return check_password_strength(password)[0]
================================================
FILE: electrum/gui/qml/qefx.py
================================================
from datetime import datetime, timedelta
from decimal import Decimal
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRegularExpression
from electrum.bitcoin import COIN
from electrum.exchange_rate import FxThread
from electrum.logging import get_logger
from electrum.simple_config import SimpleConfig
from electrum.util import event_listener
from electrum.gui.common_qt.util import QtEventListener
from .qetypes import QEAmount
class QEFX(QObject, QtEventListener):
_logger = get_logger(__name__)
quotesUpdated = pyqtSignal()
def __init__(self, fxthread: FxThread, config: SimpleConfig, parent=None):
super().__init__(parent)
self.fx = fxthread
self.config = config
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
def on_destroy(self):
self.unregister_callbacks()
@event_listener
def on_event_on_quotes(self, *args):
self._logger.debug('new quotes')
self.quotesUpdated.emit()
historyUpdated = pyqtSignal()
@event_listener
def on_event_on_history(self, *args):
self._logger.debug('new history')
self.historyUpdated.emit()
currenciesChanged = pyqtSignal()
@pyqtProperty('QVariantList', notify=currenciesChanged)
def currencies(self):
return self.fx.get_currencies(self.historicRates)
rateSourcesChanged = pyqtSignal()
@pyqtProperty('QVariantList', notify=rateSourcesChanged)
def rateSources(self):
return self.fx.get_exchanges_by_ccy(self.fiatCurrency, self.historicRates)
fiatCurrencyChanged = pyqtSignal()
@pyqtProperty(str, notify=fiatCurrencyChanged)
def fiatCurrency(self):
return self.fx.get_currency()
@fiatCurrency.setter
def fiatCurrency(self, currency):
if currency != self.fiatCurrency:
self.fx.set_currency(currency)
self.enabled = self.enabled and currency != ''
self.fiatCurrencyChanged.emit()
self.rateSourcesChanged.emit()
@pyqtProperty('QRegularExpression', notify=fiatCurrencyChanged)
def fiatAmountRegex(self):
decimals = self.fx.ccy_precision()
exp = '[0-9]*'
if decimals:
exp += '\\.'
exp += '[0-9]{0,%d}' % decimals
return QRegularExpression(exp)
historicRatesChanged = pyqtSignal()
@pyqtProperty(bool, notify=historicRatesChanged)
def historicRates(self):
if not self.fx.config.cv.FX_HISTORY_RATES.is_set():
self.fx.config.FX_HISTORY_RATES = True # override default
return self.fx.config.FX_HISTORY_RATES
@historicRates.setter
def historicRates(self, checked):
if checked != self.historicRates:
self.fx.config.FX_HISTORY_RATES = bool(checked)
self.historicRatesChanged.emit()
self.rateSourcesChanged.emit()
rateSourceChanged = pyqtSignal()
@pyqtProperty(str, notify=rateSourceChanged)
def rateSource(self):
return self.fx.config_exchange()
@rateSource.setter
def rateSource(self, source):
if source != self.rateSource:
self.fx.set_exchange(source)
self.rateSourceChanged.emit()
enabledUpdated = pyqtSignal() # curiously, enabledChanged is clashing, so name it enabledUpdated
@pyqtProperty(bool, notify=enabledUpdated)
def enabled(self):
return self.fx.is_enabled()
@enabled.setter
def enabled(self, enable):
if enable != self.enabled:
self.fx.set_enabled(enable)
self.enabledUpdated.emit()
@pyqtSlot(str, result=str)
@pyqtSlot(str, bool, result=str)
@pyqtSlot(QEAmount, result=str)
@pyqtSlot(QEAmount, bool, result=str)
def fiatValue(self, satoshis, plain=True):
rate = self.fx.exchange_rate()
if isinstance(satoshis, QEAmount):
satoshis = satoshis.msatsInt / 1000 if satoshis.msatsInt != 0 else satoshis.satsInt
else:
try:
sd = Decimal(satoshis)
except Exception:
return ''
if plain:
return self.fx.ccy_amount_str(self.fx.fiat_value(satoshis, rate), add_thousands_sep=False)
else:
return self.fx.value_str(satoshis, rate)
@pyqtSlot(str, str, result=str)
@pyqtSlot(str, str, bool, result=str)
@pyqtSlot(QEAmount, str, result=str)
@pyqtSlot(QEAmount, str, bool, result=str)
def fiatValueHistoric(self, satoshis, timestamp, plain=True):
if isinstance(satoshis, QEAmount):
satoshis = satoshis.msatsInt / 1000 if satoshis.msatsInt != 0 else satoshis.satsInt
else:
try:
sd = Decimal(satoshis)
except Exception:
return ''
try:
td = Decimal(timestamp)
if td == 0:
return ''
except Exception:
return ''
dt = datetime.fromtimestamp(int(td))
if plain:
return self.fx.ccy_amount_str(self.fx.historical_value(satoshis, dt), add_thousands_sep=False)
else:
return self.fx.historical_value_str(satoshis, dt)
@pyqtSlot(str, result=str)
@pyqtSlot(str, bool, result=str)
def satoshiValue(self, fiat, plain=True):
rate = self.fx.exchange_rate()
try:
fd = Decimal(fiat)
except Exception:
return ''
v = fd / Decimal(rate) * COIN
if v.is_nan():
return ''
if plain:
return str(v.to_integral_value())
else:
return self.config.format_amount(v)
@pyqtSlot(str, result=bool)
def isRecent(self, timestamp):
# return True if unknown, e.g. timestamp not known yet, tx in mempool
try:
td = Decimal(timestamp)
if td == 0:
return True
except Exception:
return True
dt = datetime.fromtimestamp(int(td))
return dt + timedelta(days=1) > datetime.today()
================================================
FILE: electrum/gui/qml/qeinvoice.py
================================================
import copy
import threading
from enum import IntEnum
from typing import Optional, Dict, Any, Tuple
from urllib.parse import urlparse
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QTimer
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.invoices import (
Invoice, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED,
PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER
)
from electrum.transaction import PartialTxOutput, TxOutput
from electrum.lnutil import format_short_channel_id
from electrum.lnurl import LNURL6Data
from electrum.bitcoin import COIN, address_to_script
from electrum.paymentrequest import PaymentRequest
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
from electrum.network import Network
from electrum.util import event_listener
from electrum.gui.common_qt.util import QtEventListener
from .qetypes import QEAmount
from .qewallet import QEWallet
from .util import status_update_timer_interval
from ...util import InvoiceError
class QEInvoice(QObject, QtEventListener):
@pyqtEnum
class Type(IntEnum):
Invalid = -1
OnchainInvoice = 0
LightningInvoice = 1
LNURLPayRequest = 2
@pyqtEnum
class Status(IntEnum):
Unpaid = PR_UNPAID
Expired = PR_EXPIRED
Unknown = PR_UNKNOWN
Paid = PR_PAID
Inflight = PR_INFLIGHT
Failed = PR_FAILED
Routing = PR_ROUTING
Unconfirmed = PR_UNCONFIRMED
_logger = get_logger(__name__)
invoiceChanged = pyqtSignal()
invoiceSaved = pyqtSignal([str], arguments=['key'])
amountOverrideChanged = pyqtSignal()
maxAmountMessage = pyqtSignal([str], arguments=['message'])
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None # type: Optional[QEWallet]
self._isSaved = False
self._canSave = False
self._canPay = False
self._key = None
self._invoiceType = QEInvoice.Type.Invalid
self._effectiveInvoice = None # type: Optional[Invoice]
self._userinfo = ''
self._lnprops = {}
self._amount = QEAmount()
self._amountOverride = QEAmount()
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.updateStatusString)
self._amountOverride.valueChanged.connect(self._on_amountoverride_value_changed)
self._updating_max = False
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
def on_destroy(self):
self.unregister_callbacks()
@event_listener
def on_event_payment_succeeded(self, wallet, key):
if wallet == self._wallet.wallet and key == self.key:
self.statusChanged.emit()
self.determine_can_pay()
self.userinfo = _('Paid!')
@event_listener
def on_event_payment_failed(self, wallet, key, reason):
if wallet == self._wallet.wallet and key == self.key:
self.statusChanged.emit()
self.determine_can_pay()
self.userinfo = _('Payment failed: ') + reason
@event_listener
def on_event_invoice_status(self, wallet, key, status):
if self._wallet and wallet == self._wallet.wallet and key == self.key:
self.update_userinfo()
self.determine_can_pay()
self.statusChanged.emit()
@event_listener
def on_event_channel(self, wallet, channel):
if self._wallet and wallet == self._wallet.wallet:
self.update_userinfo()
self.determine_can_pay()
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self):
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self.walletChanged.emit()
@pyqtProperty(int, notify=invoiceChanged)
def invoiceType(self):
return self._invoiceType
# not a qt setter, don't let outside set state
def setInvoiceType(self, invoiceType: Type):
self._invoiceType = invoiceType
@pyqtProperty(str, notify=invoiceChanged)
def message(self):
return self._effectiveInvoice.message if self._effectiveInvoice else ''
@pyqtProperty('quint64', notify=invoiceChanged)
def time(self):
return self._effectiveInvoice.time if self._effectiveInvoice else 0
@pyqtProperty('quint64', notify=invoiceChanged)
def expiration(self):
return self._effectiveInvoice.exp if self._effectiveInvoice else 0
@pyqtProperty(str, notify=invoiceChanged)
def address(self):
return self._effectiveInvoice.get_address() if self._effectiveInvoice else ''
@pyqtProperty(QEAmount, notify=invoiceChanged)
def amount(self):
if not self._effectiveInvoice:
self._amount.clear()
return self._amount
self._amount.copyFrom(QEAmount(from_invoice=self._effectiveInvoice))
return self._amount
@pyqtProperty(QEAmount, notify=amountOverrideChanged)
def amountOverride(self):
return self._amountOverride
@amountOverride.setter
def amountOverride(self, new_amount: QEAmount):
self._logger.debug(f'set new override amount {repr(new_amount)}')
self._amountOverride.copyFrom(new_amount)
self.amountOverrideChanged.emit()
@pyqtSlot()
def _on_amountoverride_value_changed(self):
self.update_userinfo()
self.determine_can_pay()
statusChanged = pyqtSignal()
@pyqtProperty(int, notify=statusChanged)
def status(self):
if not self._effectiveInvoice:
return PR_UNKNOWN
if self.invoiceType == QEInvoice.Type.OnchainInvoice and self._effectiveInvoice.get_amount_sat() == 0:
# no amount set, not a final invoice, get_invoice_status would be wrong
return PR_UNPAID
return self._wallet.wallet.get_invoice_status(self._effectiveInvoice)
@pyqtProperty(str, notify=statusChanged)
def statusString(self):
if not self._effectiveInvoice:
return ''
status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice)
return self._effectiveInvoice.get_status_str(status)
isSavedChanged = pyqtSignal()
@pyqtProperty(bool, notify=isSavedChanged)
def isSaved(self):
return self._isSaved
canSaveChanged = pyqtSignal()
@pyqtProperty(bool, notify=canSaveChanged)
def canSave(self):
return self._canSave
@canSave.setter
def canSave(self, canSave):
if self._canSave != canSave:
self._canSave = canSave
self.canSaveChanged.emit()
canPayChanged = pyqtSignal()
@pyqtProperty(bool, notify=canPayChanged)
def canPay(self):
return self._canPay
@canPay.setter
def canPay(self, canPay):
if self._canPay != canPay:
self._canPay = canPay
self.canPayChanged.emit()
keyChanged = pyqtSignal()
@pyqtProperty(str, notify=keyChanged)
def key(self):
return self._key
@key.setter
def key(self, key):
self._key = key
invoice = copy.copy(self._wallet.wallet.get_invoice(key)) # copy, so any mutations stay out of wallet invoice list
self._logger.debug(f'invoice from key {key}: {repr(invoice)}')
self.set_effective_invoice(invoice)
self.keyChanged.emit()
userinfoChanged = pyqtSignal()
@pyqtProperty(str, notify=userinfoChanged)
def userinfo(self):
return self._userinfo
@userinfo.setter
def userinfo(self, userinfo):
if self._userinfo != userinfo:
self._userinfo = userinfo
self.userinfoChanged.emit()
@pyqtProperty('QVariantMap', notify=invoiceChanged)
def lnprops(self):
return self._lnprops
def set_lnprops(self):
self._lnprops = {}
if not self.invoiceType == QEInvoice.Type.LightningInvoice:
return
lnaddr = self._effectiveInvoice._lnaddr
ln_routing_info = lnaddr.get_routing_info('r')
self._logger.debug(str(ln_routing_info))
self._lnprops = {
'pubkey': lnaddr.pubkey.serialize().hex(),
'payment_hash': lnaddr.paymenthash.hex(),
'r': [{
'node': self.name_for_node_id(x[-1][0]),
'scid': format_short_channel_id(x[-1][1])
} for x in ln_routing_info] if ln_routing_info else []
}
def name_for_node_id(self, node_id):
lnworker = self._wallet.wallet.lnworker
return (lnworker.lnpeermgr.get_node_alias(node_id) if lnworker else None) or node_id.hex()
def set_effective_invoice(self, invoice: Invoice):
self._effectiveInvoice = invoice
if invoice is None:
self.setInvoiceType(QEInvoice.Type.Invalid)
else:
if invoice.is_lightning():
self.setInvoiceType(QEInvoice.Type.LightningInvoice)
else:
self.setInvoiceType(QEInvoice.Type.OnchainInvoice)
self._isSaved = self._wallet.wallet.get_invoice(invoice.get_id()) is not None
self.set_lnprops()
self.update_userinfo()
self.determine_can_pay()
self.invoiceChanged.emit()
self.statusChanged.emit()
self.isSavedChanged.emit()
self.set_status_timer()
def set_status_timer(self):
if self.status != PR_EXPIRED:
if self.expiration > 0 and self.expiration != LN_EXPIRY_NEVER:
interval = status_update_timer_interval(self.time + self.expiration)
if interval > 0:
self._timer.setInterval(interval) # msec
self._timer.start()
else:
self.update_userinfo()
self.determine_can_pay() # status went to PR_EXPIRED
@pyqtSlot()
def updateStatusString(self):
self.statusChanged.emit()
self.set_status_timer()
def update_userinfo(self):
self.userinfo = ''
if not self.amountOverride.isEmpty:
amount = self.amountOverride
else:
amount = self.amount
if self.amount.isEmpty:
self.userinfo = _('Enter the amount you want to send')
status = self.status
if amount.isEmpty and status == PR_UNPAID: # unspecified amount
return
def userinfo_for_invoice_status(_status: int) -> str:
return {
PR_EXPIRED: _('This invoice has expired'),
PR_PAID: _('This invoice was already paid'),
PR_INFLIGHT: _('Payment in progress...'),
PR_ROUTING: _('Payment in progress...'),
PR_BROADCASTING: _('Payment in progress...') + ' (' + _('broadcasting') + ')',
PR_BROADCAST: _('Payment in progress...') + ' (' + _('broadcast successfully') + ')',
PR_UNCONFIRMED: _('Payment in progress...') + ' (' + _('waiting for confirmation') + ')',
PR_UNKNOWN: _('Invoice has unknown status'),
}[_status]
if status in [PR_UNPAID, PR_FAILED]:
x, self.userinfo = self.check_can_pay_amount(amount)
else:
self.userinfo = userinfo_for_invoice_status(status)
def determine_can_pay(self):
self.canPay = False
self.canSave = False
if self.invoiceType not in [QEInvoice.Type.LightningInvoice, QEInvoice.Type.OnchainInvoice]:
return
if not self.amountOverride.isEmpty:
amount = self.amountOverride
else:
amount = self.amount
self.canSave = not bool(self._wallet.wallet.get_invoice(self._effectiveInvoice.get_id()))
status = self.status
if amount.isEmpty and status == PR_UNPAID: # unspecified amount
return
if status in [PR_UNPAID, PR_FAILED]:
self.canPay, x = self.check_can_pay_amount(amount)
def check_can_pay_amount(self, amount: QEAmount) -> Tuple[bool, Optional[str]]:
assert self.status in [PR_UNPAID, PR_FAILED]
if self.invoiceType == QEInvoice.Type.LightningInvoice:
if self.get_max_spendable_lightning() * 1000 >= amount.msatsInt:
lnaddr = self._effectiveInvoice._lnaddr
if lnaddr.amount and amount.msatsInt < lnaddr.amount * COIN * 1000:
return False, _('Cannot pay less than the amount specified in the invoice')
else:
return True, None
elif self.address and self.get_max_spendable_onchain() > amount.satsInt:
return True, None
elif self.invoiceType == QEInvoice.Type.OnchainInvoice:
if (amount.isMax and self.get_max_spendable_onchain() > 0) or (self.get_max_spendable_onchain() >= amount.satsInt):
return True, None
return False, _('Insufficient balance')
@pyqtSlot()
def payLightningInvoice(self):
if not self.canPay:
raise Exception('can not pay invoice, canPay is false')
if self.invoiceType != QEInvoice.Type.LightningInvoice:
raise Exception('payLightningInvoice can only pay lightning invoices')
amount_msat = None
if self.amount.isEmpty:
if self.amountOverride.isEmpty:
raise Exception('can not pay 0 amount')
amount_msat = self.amountOverride.msatsInt
self._wallet.pay_lightning_invoice(self._effectiveInvoice, amount_msat)
def get_max_spendable_onchain(self):
return self._wallet.wallet.get_spendable_balance_sat()
def get_max_spendable_lightning(self):
return self._wallet.wallet.lnworker.num_sats_can_send() if self._wallet.wallet.lnworker else 0
@pyqtSlot()
def updateMaxAmount(self):
if self._updating_max:
return
assert self.invoiceType == QEInvoice.Type.OnchainInvoice
# only single address invoice supported
invoice_address = self._effectiveInvoice.get_address()
self._updating_max = True
def calc_max(address):
try:
outputs = [PartialTxOutput(scriptpubkey=address_to_script(address), value='!')]
make_tx = lambda fee_policy, *, confirmed_only=False: self._wallet.wallet.make_unsigned_transaction(
coins=self._wallet.wallet.get_spendable_coins(None),
outputs=outputs,
fee_policy=fee_policy,
is_sweep=False)
amount, message = self._wallet.determine_max(mktx=make_tx)
if amount is None:
self._amountOverride.isMax = False
else:
self._amountOverride.satsInt = amount
if message:
self.maxAmountMessage.emit(message)
finally:
self._updating_max = False
threading.Thread(target=calc_max, args=(invoice_address,), daemon=True).start()
class QEInvoiceParser(QEInvoice):
_logger = get_logger(__name__)
validationSuccess = pyqtSignal()
validationWarning = pyqtSignal([str, str], arguments=['code', 'message'])
validationError = pyqtSignal([str, str], arguments=['code', 'message'])
invoiceCreateError = pyqtSignal([str, str], arguments=['code', 'message'])
lnurlRetrieved = pyqtSignal()
lnurlError = pyqtSignal([str, str], arguments=['code', 'message'])
busyChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._pi = None # type: Optional[PaymentIdentifier]
self._lnurlData = None
self._busy = False
self.clear()
@pyqtSlot(object)
def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None:
self.clear()
self.amountOverride = QEAmount()
if resolved_pi:
assert not resolved_pi.need_resolve()
self.validateRecipient(resolved_pi)
@pyqtProperty('QVariantMap', notify=lnurlRetrieved)
def lnurlData(self):
return self._lnurlData
@pyqtProperty(bool, notify=lnurlRetrieved)
def isLnurlPay(self):
return self._lnurlData is not None
@pyqtProperty(bool, notify=busyChanged)
def busy(self):
return self._busy
@pyqtSlot()
def clear(self):
self.setInvoiceType(QEInvoice.Type.Invalid)
self._lnurlData = None
self.canSave = False
self.canPay = False
self.userinfo = ''
self.invoiceChanged.emit()
def setValidOnchainInvoice(self, invoice: Invoice):
self._logger.debug('setValidOnchainInvoice')
if invoice.is_lightning():
raise Exception('unexpected LN invoice')
self.set_effective_invoice(invoice)
def setValidLightningInvoice(self, invoice: Invoice):
self._logger.debug('setValidLightningInvoice')
if not invoice.is_lightning():
raise Exception('unexpected Onchain invoice')
self._key = invoice.get_id()
self.set_effective_invoice(invoice)
def setValidLNURLPayRequest(self):
self._logger.debug('setValidLNURLPayRequest')
self.setInvoiceType(QEInvoice.Type.LNURLPayRequest)
self._effectiveInvoice = None
self.invoiceChanged.emit()
def create_onchain_invoice(self, outputs, message, payment_request, uri):
return self._wallet.wallet.create_invoice(
outputs=outputs,
message=message,
pr=payment_request,
URI=uri
)
def _bip70_payment_request_resolved(self, pr: 'PaymentRequest'):
self._logger.debug('resolved payment request')
if Network.run_from_another_thread(pr.verify()):
invoice = Invoice.from_bip70_payreq(pr, height=0)
if self._wallet.wallet.get_invoice_status(invoice) == PR_PAID:
self.validationError.emit('unknown', _('Invoice already paid'))
elif pr.has_expired():
self.validationError.emit('unknown', _('Payment request has expired'))
else:
self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit()
else:
self.validationError.emit('unknown', f'invoice error:\n{pr.error}')
def validateRecipient(self, pi: PaymentIdentifier):
if not pi:
self.setInvoiceType(QEInvoice.Type.Invalid)
return
self._pi = pi
if not self._pi.is_valid() or self._pi.type not in [
PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11,
PaymentIdentifierType.LNADDR, PaymentIdentifierType.LNURLP,
PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE,
PaymentIdentifierType.OPENALIAS,
]:
self.validationError.emit('unknown', _('Unknown invoice'))
return
if self._pi.type == PaymentIdentifierType.SPK:
txo = TxOutput(scriptpubkey=self._pi.spk, value=0)
if not txo.address:
self.validationError.emit('unknown', _('Unknown invoice'))
return
self._update_from_payment_identifier()
def _update_from_payment_identifier(self):
assert not self._pi.need_resolve(), "Should have been resolved by QEPIResolver"
if self._pi.type in [
PaymentIdentifierType.LNURLP,
PaymentIdentifierType.LNADDR,
]:
self.on_lnurl_pay(self._pi.lnurl_data)
return
if self._pi.type == PaymentIdentifierType.BIP70:
self._bip70_payment_request_resolved(self._pi.bip70_data)
return
if self._pi.is_available():
if self._pi.type in [PaymentIdentifierType.SPK, PaymentIdentifierType.OPENALIAS]:
outputs = [PartialTxOutput(scriptpubkey=self._pi.spk, value=0)]
invoice = self.create_onchain_invoice(outputs, None, None, None)
self._logger.debug(repr(invoice))
self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit()
return
elif self._pi.type == PaymentIdentifierType.BOLT11:
lninvoice = self._pi.bolt11
if not self._wallet.wallet.has_lightning() and not lninvoice.get_address():
self.validationError.emit('no_lightning',
_('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.'))
return
if self._wallet.wallet.lnworker and not self._wallet.wallet.lnworker.channels and not lninvoice.get_address():
self.validationWarning.emit('no_channels',
_('Detected valid Lightning invoice, but there are no open channels'))
self.setValidLightningInvoice(lninvoice)
self.validationSuccess.emit()
elif self._pi.type == PaymentIdentifierType.BIP21:
if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11:
lninvoice = self._pi.bolt11
self.setValidLightningInvoice(lninvoice)
self.validationSuccess.emit()
else:
self._validateRecipient_bip21_onchain(self._pi.bip21)
def _validateRecipient_bip21_onchain(self, bip21: Dict[str, Any]) -> None:
if 'address' not in bip21:
self._logger.debug('Neither LN invoice nor address in bip21 uri')
self.validationError.emit('unknown', _('Unknown invoice'))
return
amount = bip21.get('amount', 0)
outputs = [PartialTxOutput.from_address_and_value(bip21['address'], amount)]
self._logger.debug(outputs)
message = bip21.get('message', '')
invoice = self.create_onchain_invoice(outputs, message, None, bip21)
self._logger.debug(repr(invoice))
self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit()
def on_lnurl_pay(self, lnurldata: LNURL6Data):
assert isinstance(lnurldata, LNURL6Data)
self._logger.debug('on_lnurl')
self._logger.debug(f'{repr(lnurldata)}')
self._lnurlData = {
'domain': urlparse(lnurldata.callback_url).netloc,
'callback_url': lnurldata.callback_url,
'min_sendable_sat': lnurldata.min_sendable_sat,
'max_sendable_sat': lnurldata.max_sendable_sat,
'metadata_plaintext': lnurldata.metadata_plaintext,
'comment_allowed': lnurldata.comment_allowed,
}
self.setValidLNURLPayRequest()
self.lnurlRetrieved.emit()
@pyqtSlot()
@pyqtSlot(str)
def lnurlGetInvoice(self, comment=None):
assert self._lnurlData
assert self._pi.need_finalize()
assert self.invoiceType == QEInvoice.Type.LNURLPayRequest
self._logger.debug(f'{repr(self._lnurlData)}')
amount = self.amountOverride.satsInt
if self._lnurlData['comment_allowed'] == 0:
comment = None
def on_finished(pi):
self._busy = False
self.busyChanged.emit()
if pi.is_error():
if pi.state == PaymentIdentifierState.INVALID_AMOUNT:
self.lnurlError.emit('amount', pi.get_error())
else:
self.lnurlError.emit('lnurl', pi.get_error())
else:
self.on_lnurl_invoice(self.amountOverride.satsInt, pi.bolt11)
self._busy = True
self.busyChanged.emit()
self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished)
def on_lnurl_invoice(self, orig_amount, invoice):
self._logger.debug('on_lnurl_invoice')
self._logger.debug(f'{repr(invoice)}')
# assure no shenanigans with the bolt11 invoice we get back
if orig_amount * 1000 != invoice.amount_msat: # TODO msat precision can cause trouble here
raise Exception('Unexpected amount in invoice, differs from lnurl-pay specified amount')
self.amountOverride = QEAmount()
self.validateRecipient(
PaymentIdentifier(self._wallet.wallet, invoice.lightning_invoice)
)
@pyqtSlot(result=bool)
def saveInvoice(self) -> bool:
if not self._effectiveInvoice:
return False
if self.isSaved:
return False
try:
if not self._effectiveInvoice.amount_msat and not self.amountOverride.isEmpty:
if self.invoiceType == QEInvoice.Type.OnchainInvoice and self.amountOverride.isMax:
self._effectiveInvoice.set_amount_msat('!')
else:
self._effectiveInvoice.set_amount_msat(self.amountOverride.satsInt * 1000)
except InvoiceError as e:
self.invoiceCreateError.emit('validation', str(e))
return False
self.canSave = False
self._wallet.wallet.save_invoice(self._effectiveInvoice)
self._key = self._effectiveInvoice.get_id()
self._wallet.invoiceModel.addInvoice(self._key)
self.invoiceSaved.emit(self._key)
return True
================================================
FILE: electrum/gui/qml/qeinvoicelistmodel.py
================================================
from abc import abstractmethod
from typing import TYPE_CHECKING, List, Dict, Any
from PyQt6.QtCore import pyqtSlot, QTimer
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from electrum.logging import get_logger
from electrum.util import Satoshis, format_time
from electrum.invoices import BaseInvoice, PR_EXPIRED, LN_EXPIRY_NEVER, Invoice, Request, PR_PAID
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .util import status_update_timer_interval
from .qetypes import QEAmount
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
class QEAbstractInvoiceListModel(QAbstractListModel):
_logger = get_logger(__name__)
# define listmodel rolemap
_ROLE_NAMES=('key', 'is_lightning', 'timestamp', 'date', 'message', 'amount',
'status', 'status_str', 'address', 'expiry', 'type', 'onchain_fallback',
'lightning_invoice')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
_ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))
def __init__(self, wallet: 'Abstract_Wallet', parent=None):
super().__init__(parent)
self.wallet = wallet
self._invoices = []
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.updateStatusStrings)
try:
self.initModel()
except Exception as e:
self._logger.error(f'{repr(e)}')
raise e
def rowCount(self, index):
return len(self._invoices)
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
invoice = self._invoices[index.row()]
role_index = role - Qt.ItemDataRole.UserRole
value = invoice[self._ROLE_NAMES[role_index]]
if isinstance(value, (bool, list, int, str, QEAmount)) or value is None:
return value
if isinstance(value, Satoshis):
return value.value
return str(value)
def clear(self):
self.beginResetModel()
self._invoices = []
self.endResetModel()
@pyqtSlot()
def initModel(self):
invoices = []
for invoice in self.get_invoice_list():
item = self.invoice_to_model(invoice)
invoices.append(item)
self.clear()
self.beginInsertRows(QModelIndex(), 0, len(invoices) - 1)
self._invoices = invoices
self.endInsertRows()
self.set_status_timer()
def add_invoice(self, invoice: BaseInvoice):
# skip if already in list
key = invoice.get_id()
for x in self._invoices:
if x['key'] == key:
return
item = self.invoice_to_model(invoice)
self._logger.debug(str(item))
self.beginInsertRows(QModelIndex(), 0, 0)
self._invoices.insert(0, item)
self.endInsertRows()
self.set_status_timer()
@pyqtSlot(str)
def addInvoice(self, key):
self.add_invoice(self.get_invoice_for_key(key))
def delete_invoice(self, key: str):
for i, invoice in enumerate(self._invoices):
if invoice['key'] == key:
self.beginRemoveRows(QModelIndex(), i, i)
self._invoices.pop(i)
self.endRemoveRows()
break
self.set_status_timer()
def get_model_invoice(self, key: str):
for invoice in self._invoices:
if invoice['key'] == key:
return invoice
return None
@pyqtSlot(str, int)
def updateInvoice(self, key, status):
self._logger.debug(f'updating invoice for {key} to {status}')
for i, item in enumerate(self._invoices):
if item['key'] == key:
invoice = self.get_invoice_for_key(key)
item['status'] = status
item['status_str'] = invoice.get_status_str(status)
index = self.index(i, 0)
self.dataChanged.emit(index, index, [self._ROLE_RMAP['status'], self._ROLE_RMAP['status_str']])
return
def invoice_to_model(self, invoice: BaseInvoice):
item = self.get_invoice_as_dict(invoice)
item['key'] = invoice.get_id()
item['is_lightning'] = invoice.is_lightning()
if invoice.is_lightning() and 'address' not in item:
item['address'] = ''
item['date'] = format_time(item['timestamp'])
item['amount'] = QEAmount(from_invoice=invoice)
item['onchain_fallback'] = invoice.is_lightning() and bool(invoice.get_address())
return item
def set_status_timer(self):
nearest_interval = LN_EXPIRY_NEVER
for invoice in self._invoices:
if invoice['status'] != PR_EXPIRED:
if invoice['expiry'] > 0 and invoice['expiry'] != LN_EXPIRY_NEVER:
interval = status_update_timer_interval(invoice['timestamp'] + invoice['expiry'])
if interval > 0:
nearest_interval = nearest_interval if nearest_interval < interval else interval
if nearest_interval != LN_EXPIRY_NEVER:
self._timer.setInterval(nearest_interval) # msec
self._timer.start()
@pyqtSlot()
def updateStatusStrings(self):
for i, item in enumerate(self._invoices):
invoice = self.get_invoice_for_key(item['key'])
if invoice is None: # invoice might be removed from the backend
self._logger.debug(f'invoice {item["key"]} not found')
continue
item['status'] = self.wallet.get_invoice_status(invoice)
item['status_str'] = invoice.get_status_str(item['status'])
index = self.index(i, 0)
self.dataChanged.emit(index, index, [self._ROLE_RMAP['status'], self._ROLE_RMAP['status_str']])
self.set_status_timer()
@abstractmethod
def get_invoice_for_key(self, key: str):
raise Exception('provide impl')
@abstractmethod
def get_invoice_list(self) -> List[BaseInvoice]:
raise Exception('provide impl')
@abstractmethod
def get_invoice_as_dict(self, invoice: BaseInvoice) -> Dict[str, Any]:
raise Exception('provide impl')
class QEInvoiceListModel(QEAbstractInvoiceListModel, QtEventListener):
def __init__(self, wallet, parent=None):
super().__init__(wallet, parent)
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
_logger = get_logger(__name__)
def on_destroy(self):
self.unregister_callbacks()
@qt_event_listener
def on_event_invoice_status(self, wallet, key, status):
if wallet == self.wallet:
self._logger.debug(f'invoice status update for key {key} to {status}')
self.updateInvoice(key, status)
def invoice_to_model(self, invoice: BaseInvoice):
item = super().invoice_to_model(invoice)
item['type'] = 'invoice'
return item
def get_invoice_list(self):
lst = self.wallet.get_unpaid_invoices()
lst.reverse()
return lst
def get_invoice_for_key(self, key: str):
return self.wallet.get_invoice(key)
def get_invoice_as_dict(self, invoice: Invoice):
return self.wallet.export_invoice(invoice)
class QERequestListModel(QEAbstractInvoiceListModel, QtEventListener):
def __init__(self, wallet, parent=None):
super().__init__(wallet, parent)
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
_logger = get_logger(__name__)
def on_destroy(self):
self.unregister_callbacks()
@qt_event_listener
def on_event_request_status(self, wallet, key, status):
if wallet == self.wallet:
self._logger.debug(f'request status update for key {key} to {status}')
self.updateRequest(key, status)
def invoice_to_model(self, invoice: BaseInvoice):
item = super().invoice_to_model(invoice)
item['type'] = 'request'
return item
def get_invoice_list(self):
lst = self.wallet.get_unpaid_requests()
lst.reverse()
return lst
def get_invoice_for_key(self, key: str):
return self.wallet.get_request(key)
def get_invoice_as_dict(self, invoice: Request):
return self.wallet.export_request(invoice)
@pyqtSlot(str, int)
def updateRequest(self, key, status):
if status == PR_PAID:
self.delete_invoice(key)
else:
self.updateInvoice(key, status)
================================================
FILE: electrum/gui/qml/qelnpaymentdetails.py
================================================
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum.logging import get_logger
from electrum.util import bfh, format_time
from .qetypes import QEAmount
from .qewallet import QEWallet
class QELnPaymentDetails(QObject):
_logger = get_logger(__name__)
detailsChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None
self._key = None
self._label = ''
self._date = None
self._timestamp = 0
self._fee = QEAmount()
self._amount = QEAmount()
self._status = ''
self._phash = ''
self._preimage = ''
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self):
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self.walletChanged.emit()
keyChanged = pyqtSignal()
@pyqtProperty(str, notify=keyChanged)
def key(self):
return self._key
@key.setter
def key(self, key: str):
if self._key != key:
self._logger.debug(f'key set -> {key}')
self._key = key
self.keyChanged.emit()
self.update()
labelChanged = pyqtSignal()
@pyqtProperty(str, notify=labelChanged)
def label(self):
return self._label
@pyqtSlot(str)
def setLabel(self, label: str):
if label != self._label:
self._wallet.wallet.set_label(self._key, label)
self._label = label
self.labelChanged.emit()
@pyqtProperty(str, notify=detailsChanged)
def status(self):
return self._status
@pyqtProperty(str, notify=detailsChanged)
def date(self):
return self._date
@pyqtProperty(int, notify=detailsChanged)
def timestamp(self):
return self._timestamp
@pyqtProperty(str, notify=detailsChanged)
def paymentHash(self):
return self._phash
@pyqtProperty(str, notify=detailsChanged)
def preimage(self):
return self._preimage
@pyqtProperty(QEAmount, notify=detailsChanged)
def amount(self):
return self._amount
@pyqtProperty(QEAmount, notify=detailsChanged)
def fee(self):
return self._fee
def update(self):
if self._wallet is None:
self._logger.error('wallet undefined')
return
# TODO this is horribly inefficient. need a payment getter/query method
tx = self._wallet.wallet.lnworker.get_lightning_history()[self._key]
self._logger.debug(str(tx))
self._fee.msatsInt = 0 if not tx.fee_msat else int(tx.fee_msat)
self._amount.msatsInt = int(tx.amount_msat)
self._label = tx.label
self._date = format_time(tx.timestamp)
self._timestamp = tx.timestamp
self._status = 'settled' # TODO: other states? get_lightning_history is deciding the filter for us :(
self._phash = tx.payment_hash
self._preimage = tx.preimage
self.detailsChanged.emit()
================================================
FILE: electrum/gui/qml/qemodelfilter.py
================================================
from PyQt6.QtCore import pyqtSignal, pyqtProperty, QSortFilterProxyModel, QModelIndex, pyqtSlot
from electrum.logging import get_logger
class QEFilterProxyModel(QSortFilterProxyModel):
_logger = get_logger(__name__)
def __init__(self, parent_model, parent=None):
super().__init__(parent)
self._filter_value = None
self.setSourceModel(parent_model)
countChanged = pyqtSignal()
@pyqtProperty(int, notify=countChanged)
def count(self):
return self.rowCount(QModelIndex())
def isCustomFilter(self):
return self._filter_value is not None
@pyqtSlot(str)
def setFilterValue(self, filter_value):
self._filter_value = filter_value
self.invalidate()
def filterAcceptsRow(self, s_row, s_parent):
if not self.isCustomFilter:
return super().filterAcceptsRow(s_row, s_parent)
parent_model = self.sourceModel()
d = parent_model.data(parent_model.index(s_row, 0, s_parent), self.filterRole())
return True if self._filter_value is None else d == self._filter_value
================================================
FILE: electrum/gui/qml/qenetwork.py
================================================
from typing import TYPE_CHECKING
from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject, pyqtSlot
from electrum.logging import get_logger
from electrum import constants
from electrum.network import ProxySettings
from electrum.interface import ServerAddr
from electrum.fee_policy import FEERATE_DEFAULT_RELAY
from electrum.util import event_listener
from electrum.gui.common_qt.util import QtEventListener
from .qeconfig import QEConfig
from .qeserverlistmodel import QEServerListModel
if TYPE_CHECKING:
from electrum.network import Network
class QENetwork(QObject, QtEventListener):
_logger = get_logger(__name__)
networkUpdated = pyqtSignal()
blockchainUpdated = pyqtSignal()
heightChanged = pyqtSignal([int], arguments=['height']) # local blockchain height
serverHeightChanged = pyqtSignal([int], arguments=['height'])
proxySet = pyqtSignal()
proxyChanged = pyqtSignal()
torProbeFinished = pyqtSignal([str, int], arguments=['host', 'port'])
statusChanged = pyqtSignal()
feeHistogramUpdated = pyqtSignal()
chaintipsChanged = pyqtSignal()
isLaggingChanged = pyqtSignal()
gossipUpdated = pyqtSignal()
# shared signal for static properties
dataChanged = pyqtSignal()
_height = 0
_server = ""
_is_connected = False
_server_status = ""
_network_status = ""
_chaintips = 1
_islagging = False
_fee_histogram = []
_gossipPeers = 0
_gossipUnknownChannels = 0
_gossipDbNodes = 0
_gossipDbChannels = 0
_gossipDbPolicies = 0
def __init__(self, network: 'Network', parent=None):
super().__init__(parent)
assert network, "--offline is not yet implemented for this GUI" # TODO
self.network = network
self._serverListModel = None
self._height = network.get_local_height() # init here, update event can take a while
self._server_height = network.get_server_height() # init here, update event can take a while
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
QEConfig.instance.useGossipChanged.connect(self.on_gossip_setting_changed)
def on_destroy(self):
self.unregister_callbacks()
@event_listener
def on_event_network_updated(self, *args):
self.networkUpdated.emit()
self._update_status()
@event_listener
def on_event_blockchain_updated(self):
if self._height != self.network.get_local_height():
self._height = self.network.get_local_height()
self._logger.debug('new height: %d' % self._height)
self.heightChanged.emit(self._height)
self.blockchainUpdated.emit()
@event_listener
def on_event_default_server_changed(self, *args):
self._update_status()
@event_listener
def on_event_proxy_set(self, *args):
self._logger.debug('proxy set')
self.proxySet.emit()
self.proxyTorChanged.emit()
@event_listener
def on_event_tor_probed(self, *args):
self.proxyTorChanged.emit()
def _update_status(self):
server = str(self.network.get_parameters().server)
if self._server != server:
self._server = server
self.statusChanged.emit()
network_status = self.network.get_status()
if self._network_status != network_status:
self._logger.debug('network_status updated: %s' % network_status)
self._network_status = network_status
self.statusChanged.emit()
is_connected = self.network.is_connected()
if self._is_connected != is_connected:
self._is_connected = is_connected
self.statusChanged.emit()
server_status = self.network.get_connection_status_for_GUI()
if self._server_status != server_status:
self._logger.debug('server_status updated: %s' % server_status)
self._server_status = server_status
self.statusChanged.emit()
server_height = self.network.get_server_height()
if self._server_height != server_height:
self._logger.debug(f'server_height updated: {server_height}')
self._server_height = server_height
self.serverHeightChanged.emit(server_height)
chains = len(self.network.get_blockchains())
if chains != self._chaintips:
self._logger.debug('chain tips # changed: %d', chains)
self._chaintips = chains
self.chaintipsChanged.emit()
server_lag = self.network.get_local_height() - self.network.get_server_height()
if self._islagging ^ (server_lag > 1):
self._logger.debug('lagging changed: %s', str(server_lag > 1))
self._islagging = server_lag > 1
self.isLaggingChanged.emit()
@event_listener
def on_event_status(self, *args):
self._update_status()
@event_listener
def on_event_fee_histogram(self, histogram):
self._logger.debug(f'fee histogram updated')
self.update_histogram(histogram)
def update_histogram(self, histogram):
capped_histogram, bytes_current = histogram.get_capped_data()
# add clamping attributes for the GUI
self._fee_histogram = {
'histogram': capped_histogram,
'total': bytes_current,
'min_fee': capped_histogram[-1][0] if capped_histogram else FEERATE_DEFAULT_RELAY/1000,
'max_fee': capped_histogram[0][0] if capped_histogram else FEERATE_DEFAULT_RELAY/1000
}
self.feeHistogramUpdated.emit()
@event_listener
def on_event_channel_db(self, num_nodes, num_channels, num_policies):
changed = False
if self._gossipDbNodes != num_nodes:
self._gossipDbNodes = num_nodes
changed = True
if self._gossipDbChannels != num_channels:
self._gossipDbChannels = num_channels
changed = True
if self._gossipDbPolicies != num_policies:
self._gossipDbPolicies = num_policies
changed = True
if changed:
self._logger.debug(f'channel_db: {num_nodes} nodes, {num_channels} channels, {num_policies} policies')
self.gossipUpdated.emit()
@event_listener
def on_event_gossip_peers(self, num_peers):
self._logger.debug(f'gossip peers {num_peers}')
self._gossipPeers = num_peers
self.gossipUpdated.emit()
@event_listener
def on_event_unknown_channels(self, unknown):
if unknown == 0 and self._gossipUnknownChannels == 0: # TODO: backend sends a lot of unknown=0 events
return
self._logger.debug(f'unknown channels {unknown}')
self._gossipUnknownChannels = unknown
self.gossipUpdated.emit()
def on_gossip_setting_changed(self):
if not self.network:
return
if QEConfig.instance.useGossip:
self.network.start_gossip()
else:
self.network.run_from_another_thread(self.network.stop_gossip())
@pyqtProperty(int, notify=heightChanged)
def height(self): # local blockchain height
return self._height
@pyqtProperty(int, notify=serverHeightChanged)
def serverHeight(self):
return self._server_height
autoConnectChanged = pyqtSignal()
@pyqtProperty(bool, notify=autoConnectChanged)
def autoConnect(self):
return self.network.config.NETWORK_AUTO_CONNECT
# auto_connect is actually a tri-state, expose the undefined case
@pyqtProperty(bool, notify=autoConnectChanged)
def autoConnectDefined(self):
return self.network.config.cv.NETWORK_AUTO_CONNECT.is_set()
@pyqtProperty(str, notify=statusChanged)
def server(self):
return self._server
@pyqtSlot(str, result=bool)
def isValidServerAddress(self, server: str) -> bool:
return ServerAddr.from_str_with_inference(server) is not None
@pyqtSlot(str, bool, bool)
def setServerParameters(self, server_str: str, auto_connect: bool, one_server: bool):
net_params = self.network.get_parameters()
server = ServerAddr.from_str_with_inference(server_str)
if server == net_params.server and auto_connect == net_params.auto_connect and one_server == net_params.oneserver:
return
if server != net_params.server:
if server is None:
if not auto_connect:
return
server = net_params.server
self.statusChanged.emit()
if auto_connect != net_params.auto_connect:
self.network.config.NETWORK_AUTO_CONNECT = auto_connect
self.autoConnectChanged.emit()
net_params = net_params._replace(server=server, auto_connect=auto_connect, oneserver=one_server)
self.network.run_from_another_thread(self.network.set_parameters(net_params))
@pyqtProperty(str, notify=statusChanged)
def serverWithStatus(self):
server = self._server
if not self.network.is_connected(): # connecting or disconnected
return f'{server} (connecting...)'
return server
@pyqtProperty(str, notify=statusChanged)
def status(self):
return self._network_status
@pyqtProperty(str, notify=statusChanged)
def serverStatus(self):
return self.network.get_connection_status_for_GUI()
@pyqtProperty(bool, notify=statusChanged)
def isConnected(self):
return self._is_connected
@pyqtProperty(int, notify=chaintipsChanged)
def chaintips(self):
return self._chaintips
@pyqtProperty(bool, notify=isLaggingChanged)
def isLagging(self):
return self._islagging
@pyqtProperty(bool, notify=dataChanged)
def isTestNet(self):
return constants.net.TESTNET
@pyqtProperty(str, notify=dataChanged)
def networkName(self):
return constants.net.__name__.replace('Bitcoin', '')
@pyqtProperty('QVariantMap', notify=proxyChanged)
def proxy(self):
net_params = self.network.get_parameters()
proxy = net_params.proxy
return proxy.to_dict()
@proxy.setter
def proxy(self, proxy_dict):
net_params = self.network.get_parameters()
proxy = ProxySettings.from_dict(proxy_dict)
net_params = net_params._replace(proxy=proxy)
self.network.run_from_another_thread(self.network.set_parameters(net_params))
self.proxyChanged.emit()
proxyTorChanged = pyqtSignal()
@pyqtProperty(bool, notify=proxyTorChanged)
def isProxyTor(self):
return bool(self.network.is_proxy_tor)
@pyqtProperty(bool, notify=statusChanged)
def oneServer(self):
return self.network.oneserver
@pyqtProperty('QVariant', notify=feeHistogramUpdated)
def feeHistogram(self):
return self._fee_histogram
@pyqtProperty('QVariantMap', notify=gossipUpdated)
def gossipInfo(self):
return {
'peers': self._gossipPeers,
'unknown_channels': self._gossipUnknownChannels,
'db_nodes': self._gossipDbNodes,
'db_channels': self._gossipDbChannels,
'db_policies': self._gossipDbPolicies
}
serverListModelChanged = pyqtSignal()
@pyqtProperty(QEServerListModel, notify=serverListModelChanged)
def serverListModel(self):
if self._serverListModel is None:
self._serverListModel = QEServerListModel(self.network)
return self._serverListModel
@pyqtSlot()
def probeTor(self):
ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal
================================================
FILE: electrum/gui/qml/qepiresolver.py
================================================
from enum import IntEnum
from typing import Optional
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer
from electrum.logging import get_logger
from electrum.i18n import _
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
from .qewallet import QEWallet
class QEPIResolver(QObject):
"""Intended to handle a user input Payment Identifier (PI), resolve it if necessary, then
allow to distinguish between a Request/voucher/lnurlw and an Invoice (e.g. b11 or lnurlp)."""
_logger = get_logger(__name__)
busyChanged = pyqtSignal()
resolveError = pyqtSignal([str, str], arguments=['code', 'message'])
invoiceResolved = pyqtSignal([object], arguments=['pi'])
requestResolved = pyqtSignal([object], arguments=['pi'])
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None # type: Optional[QEWallet]
self._recipient = None
self._pi = None
self._busy = False
self.clear()
recipientChanged = pyqtSignal()
@pyqtProperty(str, notify=recipientChanged)
def recipient(self) -> Optional[str]:
return self._recipient
@recipient.setter
def recipient(self, recipient: str) -> None:
self.clear()
if not recipient:
return
self._recipient = recipient
self.recipientChanged.emit()
self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
if self._pi.need_resolve():
self.resolve_pi()
else:
# assuming if the PI is an invoice if it doesn't need resolving
# as there are no request types that do not need resolving currently
self.invoiceResolved.emit(self._pi)
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self) -> Optional[QEWallet]:
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet) -> None:
self._wallet = wallet
@pyqtProperty(bool, notify=busyChanged)
def busy(self):
return self._busy
def resolve_pi(self) -> None:
assert self._pi is not None
assert self._pi.need_resolve()
def on_finished(pi: PaymentIdentifier):
self._busy = False
self.busyChanged.emit()
if pi.is_error():
if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]:
msg = _('Could not resolve address')
elif pi.type == PaymentIdentifierType.LNURL:
msg = _('Could not resolve LNURL') + "\n\n" + pi.get_error()
elif pi.type == PaymentIdentifierType.BIP70:
msg = _('Could not resolve BIP70 payment request: {}').format(pi.error)
else:
msg = _('Could not resolve')
self.resolveError.emit('resolve', msg)
else:
if pi.type == PaymentIdentifierType.LNURLW:
self.requestResolved.emit(pi)
else:
self.invoiceResolved.emit(pi)
self._busy = True
self.busyChanged.emit()
self._pi.resolve(on_finished=on_finished)
def clear(self) -> None:
self._recipient = None
self._pi = None
self._busy = False
self.busyChanged.emit()
self.recipientChanged.emit()
================================================
FILE: electrum/gui/qml/qeqr.py
================================================
import asyncio
import qrcode
from qrcode.exceptions import DataOverflowError
import math
import urllib
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRect
from PyQt6.QtGui import QImage, QColor
from PyQt6.QtQuick import QQuickImageProvider
try:
from PyQt6.QtMultimedia import QVideoSink
except ImportError:
# stub QVideoSink when not found, as it's not essential on android
# and requires many dependencies when unit testing.
# Note: missing QtMultimedia will lead to errors when using QR scanner on desktop
from PyQt6.QtCore import QObject as QVideoSink
from electrum.logging import get_logger
from electrum.qrreader import get_qr_reader
from electrum.i18n import _
from electrum.util import profiler, get_asyncio_loop
from electrum.gui.common_qt.util import draw_qr
class QEQRParser(QObject):
_logger = get_logger(__name__)
busyChanged = pyqtSignal()
dataChanged = pyqtSignal()
sizeChanged = pyqtSignal()
videoSinkChanged = pyqtSignal()
def __init__(self, text=None, parent=None):
super().__init__(parent)
self._busy = False
self._data = None
self._video_sink = None
self._text = text
self.qrreader = get_qr_reader()
if not self.qrreader:
raise Exception(_("The platform QR detection library is not available."))
@pyqtProperty(QVideoSink, notify=videoSinkChanged)
def videoSink(self):
return self._video_sink
@videoSink.setter
def videoSink(self, sink: QVideoSink):
if self._video_sink != sink:
self._video_sink = sink
self._video_sink.videoFrameChanged.connect(self.onVideoFrame)
def onVideoFrame(self, videoframe):
if self._busy or self._data:
return
self._busy = True
self.busyChanged.emit()
if not videoframe.isValid():
self._logger.debug('invalid frame')
return
async def co_parse_qr(frame):
image = frame.toImage()
self._parseQR(image)
asyncio.run_coroutine_threadsafe(co_parse_qr(videoframe), get_asyncio_loop())
def _parseQR(self, image: QImage):
self._size = min(image.width(), image.height())
self.sizeChanged.emit()
img_crop_rect = self._get_crop(image, self._size)
frame_cropped = image.copy(img_crop_rect)
# Convert to Y800 / GREY FourCC (single 8-bit channel)
frame_y800 = frame_cropped.convertToFormat(QImage.Format.Format_Grayscale8)
self.frame_id = 0
# Read the QR codes from the frame
self.qrreader_res = self.qrreader.read_qr_code(
frame_y800.constBits().__int__(),
frame_y800.sizeInBytes(),
frame_y800.bytesPerLine(),
frame_y800.width(),
frame_y800.height(),
self.frame_id
)
if len(self.qrreader_res) > 0:
result = self.qrreader_res[0]
self._data = result
self.dataChanged.emit()
self._busy = False
self.busyChanged.emit()
def _get_crop(self, image: QImage, scan_size: int) -> QRect:
"""Returns a QRect that is scan_size x scan_size in the middle of the resolution"""
scan_pos_x = (image.width() - scan_size) // 2
scan_pos_y = (image.height() - scan_size) // 2
return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size)
@pyqtProperty(bool, notify=busyChanged)
def busy(self):
return self._busy
@pyqtProperty(int, notify=sizeChanged)
def size(self):
return self._size
@pyqtProperty(str, notify=dataChanged)
def data(self):
if not self._data:
return ''
return self._data.data
@pyqtSlot()
def reset(self):
self._data = None
self.dataChanged.emit()
class QEQRImageProvider(QQuickImageProvider):
MAX_QR_PIXELSIZE = 400
ERROR_CORRECT_LEVEL = qrcode.constants.ERROR_CORRECT_M
# ^ note: this is higher than for desktop. but on desktop we don't put a logo in the middle.
QR_BORDER = 2
def __init__(self, max_size, parent=None):
super().__init__(QQuickImageProvider.ImageType.Image)
self._max_size = max_size
self.qimg = None
_logger = get_logger(__name__)
@profiler
def requestImage(self, qstr, size):
# Qt does a urldecode before passing the string here
# but BIP21 (and likely other uri based specs) requires urlencoding,
# so we re-encode percent-quoted if a known 'scheme' is found in the string
# (unknown schemes might be found when a colon is in a serialized TX, which
# leads to mangling of the tx, so we check for supported schemes.)
uri = urllib.parse.urlparse(qstr)
if uri.scheme and uri.scheme in ['bitcoin', 'lightning']:
# urlencode request parameters
query = urllib.parse.parse_qs(uri.query)
query = urllib.parse.urlencode(query, doseq=True, quote_via=urllib.parse.quote)
uri = uri._replace(query=query)
qstr = urllib.parse.urlunparse(uri)
qr = qrcode.main.QRCode(border=self.QR_BORDER, error_correction=self.ERROR_CORRECT_LEVEL)
# calculate best box_size
pixelsize = min(self._max_size, self.MAX_QR_PIXELSIZE)
try:
qr.add_data(qstr)
modules = len(qr.get_matrix())
qr.box_size = math.floor(pixelsize/modules)
qr.make(fit=True)
self.qimg = QImage(modules * qr.box_size, modules * qr.box_size, QImage.Format.Format_RGB32)
draw_qr(qr=qr, paint_device=self.qimg)
except (ValueError, qrcode.exceptions.DataOverflowError):
# fake it
modules = 17 + qr.border * 2
box_size = math.floor(pixelsize/modules)
self.qimg = QImage(box_size * modules, box_size * modules, QImage.Format.Format_RGB32)
self.qimg.fill(QColor('gray'))
return self.qimg, self.qimg.size()
# helper for placing icon exactly where it should go on the QR code
# pyqt5 is unwilling to accept slots on QEQRImageProvider, so we need to define
# a separate class (sigh)
class QEQRImageProviderHelper(QObject):
def __init__(self, max_size, parent=None):
super().__init__(parent)
self._max_size = max_size
@pyqtSlot(str, result='QVariantMap')
def getDimensions(self, qstr):
qr = qrcode.QRCode(
border=QEQRImageProvider.QR_BORDER,
error_correction=QEQRImageProvider.ERROR_CORRECT_LEVEL,
)
# calculate best box_size
pixelsize = min(self._max_size, QEQRImageProvider.MAX_QR_PIXELSIZE)
try:
qr.add_data(qstr)
modules = len(qr.get_matrix())
valid = True
except (ValueError, qrcode.exceptions.DataOverflowError):
# fake it
modules = 17 + qr.border * 2
valid = False
qr.box_size = math.floor(pixelsize/modules)
# calculate icon width in modules
icon_modules = int(modules / 5)
icon_modules += (icon_modules+1) % 2 # force odd
return {
'qr_pixelsize': modules * qr.box_size,
'icon_pixelsize': icon_modules * qr.box_size,
'valid': valid
}
================================================
FILE: electrum/gui/qml/qeqrscanner.py
================================================
import os
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Qt
from PyQt6.QtGui import QGuiApplication
from electrum.gui.qml.qetypes import QEBytes
from electrum.util import send_exception_to_crash_reporter
from electrum.logging import get_logger
from electrum.i18n import _
if 'ANDROID_DATA' in os.environ:
from jnius import autoclass
from android import activity
jpythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity
jString = autoclass('java.lang.String')
jIntent = autoclass('android.content.Intent')
class QEQRScanner(QObject):
REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY = 30368 # random 16 bit int
_logger = get_logger(__name__)
foundText = pyqtSignal(str)
foundBinary = pyqtSignal(QEBytes)
finished = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._hint = _("Scan a QR code.")
self.finished.connect(self._unbind, Qt.ConnectionType.QueuedConnection)
self.destroyed.connect(lambda: self.on_destroy())
def on_destroy(self):
self._unbind()
@pyqtProperty(str)
def hint(self):
return self._hint
@hint.setter
def hint(self, v: str):
self._hint = v
@pyqtSlot()
def open(self):
if 'ANDROID_DATA' not in os.environ:
self._scan_qr_non_android()
return
jSimpleScannerActivity = autoclass("org.electrum.qr.SimpleScannerActivity")
intent = jIntent(jpythonActivity, jSimpleScannerActivity)
intent.putExtra(jIntent.EXTRA_TEXT, jString(self._hint))
activity.bind(on_activity_result=self.on_qr_activity_result)
jpythonActivity.startActivityForResult(intent, self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY)
@pyqtSlot()
def close(self):
# no-op to prevent qml type error
pass
def on_qr_activity_result(self, requestCode, resultCode, intent):
if requestCode != self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY:
self._logger.warning(f"got activity result with invalid {requestCode=}")
return
try:
if resultCode == -1: # RESULT_OK:
if (contents := intent.getStringExtra(jString("text"))) is not None:
self.foundText.emit(contents)
if (contents := intent.getByteArrayExtra(jString("binary"))) is not None:
self._binary_content = QEBytes(bytes(contents.tolist()))
self.foundBinary.emit(self._binary_content)
except Exception as e: # exc would otherwise get lost
send_exception_to_crash_reporter(e)
finally:
self.finished.emit()
@pyqtSlot()
def _unbind(self):
if 'ANDROID_DATA' in os.environ:
activity.unbind(on_activity_result=self.on_qr_activity_result)
def _scan_qr_non_android(self):
data = QGuiApplication.clipboard().text()
self.foundText.emit(data)
self.finished.emit()
return
================================================
FILE: electrum/gui/qml/qerequestdetails.py
================================================
from enum import IntEnum
from typing import Optional
from urllib.parse import urlparse
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum
from electrum.logging import get_logger
from electrum.invoices import (
PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, LN_EXPIRY_NEVER
)
from electrum.lnutil import MIN_FUNDING_SAT, RECEIVED
from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierType
from electrum.i18n import _
from electrum.network import Network
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .qewallet import QEWallet
from .qetypes import QEAmount
from .util import status_update_timer_interval
class QERequestDetails(QObject, QtEventListener):
@pyqtEnum
class Status(IntEnum):
Unpaid = PR_UNPAID
Expired = PR_EXPIRED
Unknown = PR_UNKNOWN
Paid = PR_PAID
Inflight = PR_INFLIGHT
Failed = PR_FAILED
Routing = PR_ROUTING
Unconfirmed = PR_UNCONFIRMED
_logger = get_logger(__name__)
detailsChanged = pyqtSignal() # generic request properties changed signal
statusChanged = pyqtSignal()
needsLNURLUserInput = pyqtSignal()
lnurlError = pyqtSignal(str, str) # code, message
busyChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None # type: Optional[QEWallet]
self._key = None
self._req = None
self._timer = None
self._amount = None
self._lnurlData = None # type: Optional[dict]
self._busy = False
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.updateStatusString)
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
def on_destroy(self):
self.unregister_callbacks()
if self._timer:
self._timer.stop()
self._timer = None
@qt_event_listener
def on_event_request_status(self, wallet, key, status):
if wallet == self._wallet.wallet and key == self._key:
self._logger.debug('request status %d for key %s' % (status, key))
self.statusChanged.emit()
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self):
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self.walletChanged.emit()
self.initRequest()
keyChanged = pyqtSignal()
@pyqtProperty(str, notify=keyChanged)
def key(self):
return self._key
@key.setter
def key(self, key):
if self._key != key:
self._key = key
self._logger.debug(f'key={key}')
self.keyChanged.emit()
self.initRequest()
@pyqtProperty(int, notify=statusChanged)
def status(self):
return self._wallet.wallet.get_invoice_status(self._req)
@pyqtProperty(str, notify=statusChanged)
def status_str(self):
return self._req.get_status_str(self.status) if self._req else ''
@pyqtProperty(bool, notify=detailsChanged)
def isLightning(self):
return self._req.is_lightning() if self._req else False
@pyqtProperty(str, notify=detailsChanged)
def address(self):
addr = self._req.get_address() if self._req else ''
return addr if addr else ''
@pyqtProperty(str, notify=detailsChanged)
def message(self):
return self._req.get_message() if self._req else ''
@pyqtProperty(QEAmount, notify=detailsChanged)
def amount(self):
return self._amount
@pyqtProperty(int, notify=detailsChanged)
def timestamp(self):
return self._req.get_time()
@pyqtProperty(int, notify=detailsChanged)
def expiration(self):
return self._req.get_expiration_date()
@pyqtProperty(str, notify=statusChanged)
def paidTxid(self):
"""only used when Request status is PR_PAID"""
if not self._req:
return ''
is_paid, conf_needed, txids = self._wallet.wallet._is_onchain_invoice_paid(self._req)
if len(txids) > 0:
return txids[0]
return ''
@pyqtProperty(str, notify=detailsChanged)
def bolt11(self):
wallet = self._wallet.wallet
if not wallet.lnworker:
return ''
amount_sat = self._req.get_amount_sat() or 0 if self._req else 0
can_receive = wallet.lnworker.num_sats_can_receive()
will_req_zeroconf = wallet.lnworker.receive_requires_jit_channel(amount_msat=amount_sat*1000)
if self._req and ((can_receive > 0 and amount_sat <= can_receive)
or (will_req_zeroconf and amount_sat >= MIN_FUNDING_SAT)):
bolt11 = wallet.get_bolt11_invoice(self._req)
else:
return ''
# encode lightning invoices as uppercase so QR encoding can use
# alphanumeric mode; resulting in smaller QR codes
bolt11 = bolt11.upper()
return bolt11
@pyqtProperty(str, notify=detailsChanged)
def bip21(self):
return self._req.get_bip21_URI() if self._req else ''
@pyqtProperty('QVariantMap', notify=detailsChanged)
def lnurlData(self) -> Optional[dict]:
return self._lnurlData
@pyqtProperty(bool, notify=busyChanged)
def busy(self):
return self._busy
def initRequest(self):
if self._wallet is None or self._key is None:
return
self._req = self._wallet.wallet.get_request(self._key)
if self._req is None:
self._logger.error(f'payment request key {self._key} unknown in wallet {self._wallet.name}')
return
self._amount = QEAmount(from_invoice=self._req)
self.detailsChanged.emit()
self.statusChanged.emit()
self.set_status_timer()
def set_status_timer(self):
if self.status == PR_UNPAID:
if self.expiration > 0 and self.expiration != LN_EXPIRY_NEVER:
self._logger.debug(f'set_status_timer, expiration={self.expiration}')
interval = status_update_timer_interval(self.expiration)
if interval > 0:
self._logger.debug(f'setting status update timer to {interval}')
self._timer.setInterval(interval) # msec
self._timer.start()
@pyqtSlot()
def updateStatusString(self):
self.statusChanged.emit()
self.set_status_timer()
@pyqtSlot(object)
def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None:
"""
Called when a payment identifier is resolved to a request (currently only LNURLW, but
could also be used for other "voucher" type input like redeeming ecash tokens or
some bolt12 thing).
"""
if not self._wallet:
return
if resolved_pi.type == PaymentIdentifierType.LNURLW:
lnurldata = resolved_pi.lnurl_data
assert isinstance(lnurldata, LNURL3Data), "Expected LNURL3Data type"
self._lnurlData = {
'domain': urlparse(lnurldata.callback_url).netloc,
'callback_url': lnurldata.callback_url,
'min_withdrawable_sat': lnurldata.min_withdrawable_sat,
'max_withdrawable_sat': lnurldata.max_withdrawable_sat,
'default_description': lnurldata.default_description,
'k1': lnurldata.k1,
}
self.needsLNURLUserInput.emit()
else:
raise NotImplementedError("Cannot request withdrawal for this payment identifier type")
@pyqtSlot(int)
def lnurlRequestWithdrawal(self, amount_sat: int) -> None:
assert self._lnurlData
self._logger.debug(f'requesting lnurlw: {repr(self._lnurlData)}')
try:
key = self._wallet.wallet.create_request(
amount_sat=amount_sat,
message=self._lnurlData.get('default_description', ''),
exp_delay=120,
address=None,
)
req = self._wallet.wallet.get_request(key)
info = self._wallet.wallet.lnworker.get_payment_info(req.payment_hash, direction=RECEIVED)
_lnaddr, b11_invoice = self._wallet.wallet.lnworker.get_bolt11_invoice(
payment_info=info,
message=req.get_message(),
fallback_address=None,
)
except Exception as e:
self._logger.exception('')
self.lnurlError.emit(
'lnurl',
_("Failed to create payment request for withdrawal: {}").format(str(e))
)
return
self._busy = True
self.busyChanged.emit()
coro = request_lnurl_withdraw_callback(
callback_url=self._lnurlData['callback_url'],
k1=self._lnurlData['k1'],
bolt_11=b11_invoice,
)
try:
Network.run_from_another_thread(coro)
except LNURLError as e:
self.lnurlError.emit('lnurl', str(e))
finally:
self._busy = False
self.busyChanged.emit()
================================================
FILE: electrum/gui/qml/qeserverlistmodel.py
================================================
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from electrum.logging import get_logger
from electrum.util import Satoshis
from electrum.interface import ServerAddr, PREFERRED_NETWORK_PROTOCOL
from electrum import blockchain
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
class QEServerListModel(QAbstractListModel, QtEventListener):
_logger = get_logger(__name__)
# define listmodel rolemap
_ROLE_NAMES=('name', 'address', 'is_connected', 'is_primary', 'is_tor', 'chain', 'height')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
_ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))
def __init__(self, network, parent=None):
super().__init__(parent)
self._chaintips = 0
self._servers = []
self.network = network
self.initModel()
self.register_callbacks()
self.destroyed.connect(lambda: self.unregister_callbacks())
@qt_event_listener
def on_event_network_updated(self):
self._logger.info(f'network updated')
self.initModel()
@qt_event_listener
def on_event_blockchain_updated(self):
self._logger.info(f'blockchain updated')
self.initModel()
@qt_event_listener
def on_event_default_server_changed(self):
self._logger.info(f'default server changed')
self.initModel()
def rowCount(self, index):
return len(self._servers)
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
server = self._servers[index.row()]
role_index = role - Qt.ItemDataRole.UserRole
value = server[self._ROLE_NAMES[role_index]]
if isinstance(value, (bool, list, int, str)) or value is None:
return value
if isinstance(value, Satoshis):
return value.value
return str(value)
def clear(self):
self.beginResetModel()
self._servers = []
self.endResetModel()
chaintipsChanged = pyqtSignal()
@pyqtProperty(int, notify=chaintipsChanged)
def chaintips(self):
return self._chaintips
def get_chains(self):
chains = self.network.get_blockchains()
n_chains = len(chains)
if n_chains != self._chaintips:
self._chaintips = n_chains
self.chaintipsChanged.emit()
return chains
@pyqtSlot()
def initModel(self):
self.clear()
servers = []
chains = self.get_chains()
for chain_id, interfaces in chains.items():
self._logger.debug(f'chain {chain_id} has {len(interfaces)} interfaces')
b = blockchain.blockchains.get(chain_id)
if b is None:
continue
name = b.get_name()
self._logger.debug(f'chain {chain_id} has name={name}, max_forkpoint=@{b.get_max_forkpoint()}, height={b.height()}')
for i in interfaces:
server = {
'chain': name,
'chain_height': b.height(),
'is_primary': i == self.network.interface,
'is_connected': True,
'name': str(i.server),
'address': i.server.to_friendly_name(),
'height': i.tip
}
servers.append(server)
# disconnected servers
for s in self.network.get_disconnected_server_addrs():
server = {
'chain': '',
'chain_height': 0,
'height': 0,
'is_primary': False,
'is_connected': False,
'name': s.to_friendly_name()
}
server['address'] = server['name']
servers.append(server)
self.beginInsertRows(QModelIndex(), 0, len(servers) - 1)
self._servers = servers
self.endInsertRows()
================================================
FILE: electrum/gui/qml/qeswaphelper.py
================================================
import asyncio
import bisect
from enum import IntEnum
from typing import Union, Optional, TYPE_CHECKING, Sequence
from PyQt6.QtCore import (pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum, QAbstractListModel, Qt,
QModelIndex)
from PyQt6.QtGui import QColor
from electrum.i18n import _
from electrum.bitcoin import DummyAddress
from electrum.logging import get_logger
from electrum.transaction import PartialTxOutput, PartialTransaction
from electrum.util import (NotEnoughFunds, NoDynamicFeeEstimates, profiler, get_asyncio_loop, age,
wait_for2, send_exception_to_crash_reporter)
from electrum.submarine_swaps import NostrTransport, SwapServerTransport, pubkey_to_rgb_color
from electrum.fee_policy import FeePolicy
from electrum.gui import messages
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .auth import AuthMixin, auth_protect
from .qetypes import QEAmount
from .qewallet import QEWallet
if TYPE_CHECKING:
import concurrent.futures
from electrum.submarine_swaps import SwapOffer
class InvalidSwapParameters(Exception): pass
class QESwapServerNPubListModel(QAbstractListModel):
_logger = get_logger(__name__)
# define listmodel rolemap
_ROLE_NAMES= ('npub', 'server_pubkey', 'timestamp', 'percentage_fee', 'mining_fee',
'min_amount', 'max_forward_amount', 'max_reverse_amount', 'pow_bits', 'color')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
def __init__(self, config, parent=None):
super().__init__(parent)
self.config = config
self._services = []
def rowCount(self, index):
return len(self._services)
# also expose rowCount as a property
countChanged = pyqtSignal()
@pyqtProperty(int, notify=countChanged)
def count(self):
return len(self._services)
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
service = self._services[index.row()]
role_index = role - Qt.ItemDataRole.UserRole
value = service[self._ROLE_NAMES[role_index]]
if isinstance(value, (bool, list, int, str, QColor)) or value is None:
return value
return str(value)
def clear(self):
self.beginResetModel()
self._services = []
self.endResetModel()
def offer_to_model(self, x: 'SwapOffer'):
return {
'npub': x.server_npub,
'server_pubkey': x.server_pubkey,
'percentage_fee': float(x.pairs.percentage),
'mining_fee': x.pairs.mining_fee,
'min_amount': x.pairs.min_amount,
'max_forward_amount': x.pairs.max_forward,
'max_reverse_amount': x.pairs.max_reverse,
'timestamp': age(x.timestamp),
'pow_bits': x.pow_bits,
'color': QColor(*pubkey_to_rgb_color(x.server_pubkey)),
}
def updateModel(self, items: Sequence['SwapOffer']):
offers = items.copy()
remove = []
for i, x in enumerate(self._services):
if matches := list(filter(lambda offer: offer.server_npub == x['npub'], offers)):
# update
self._services[i] = self.offer_to_model(matches[0])
index = self.index(i, 0)
self.dataChanged.emit(index, index, self._ROLE_KEYS)
offers.remove(matches[0])
else:
# add offer to remove items
remove.append(i)
# # remove offers from model
for ri in reversed(remove):
self.beginRemoveRows(QModelIndex(), ri, ri)
self._services.pop(ri)
self.endRemoveRows()
# add new offers
if offers:
for offer in offers:
# offers are sorted by pow_bits
insertion_index = bisect.bisect_left(
self._services,
-offer.pow_bits, # negate the values to get ascending order
key=lambda service: -service['pow_bits'],
)
self.beginInsertRows(QModelIndex(), insertion_index, insertion_index)
self._services.insert(insertion_index, self.offer_to_model(offer))
self.endInsertRows()
if offers or remove:
self.countChanged.emit()
@pyqtSlot(str, result=int)
def indexFor(self, npub: str):
for i, item in enumerate(self._services):
if npub == item['npub']:
return i
return -1
class QESwapHelper(AuthMixin, QObject, QtEventListener):
_logger = get_logger(__name__)
MESSAGE_SWAP_HOWTO = ' '.join([
_('Move the slider to set the amount and direction of the swap.'),
_('Swapping lightning funds for onchain funds will increase your capacity to receive lightning payments.'),
])
@pyqtEnum
class State(IntEnum):
Initializing = 0
Initialized = 1
NoService = 2
ServiceReady = 3
Started = 4
Failed = 5
Success = 6
Cancelled = 7
confirm = pyqtSignal([str], arguments=['message'])
error = pyqtSignal([str], arguments=['message'])
undefinedNPub = pyqtSignal()
offersUpdated = pyqtSignal()
requestTxUpdate = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None # type: Optional[QEWallet]
self._sliderPos = 0
self._rangeMin = -1
self._rangeMax = 1
self._tx = None
self._valid = False
self._state = QESwapHelper.State.Initialized
self._userinfo = QESwapHelper.MESSAGE_SWAP_HOWTO
self._tosend = QEAmount()
self._toreceive = QEAmount()
self._serverfeeperc = ''
self._server_miningfee = QEAmount()
self._miningfee = QEAmount()
self._isReverse = False
self._canCancel = False
self._swap = None
self._fut_htlc_wait = None
self._service_available = False
self._send_amount = 0
self._receive_amount = 0
self._leftVoid = 0
self._rightVoid = 0
self._available_swapservers = None
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
self._fwd_swap_updatetx_timer = QTimer(self)
self._fwd_swap_updatetx_timer.setSingleShot(True)
self._fwd_swap_updatetx_timer.timeout.connect(self.fwd_swap_updatetx)
self.requestTxUpdate.connect(self.tx_update_pushback_timer)
self.offersUpdated.connect(self.on_offers_updated)
self.transport_task: Optional[asyncio.Task] = None
self.swap_transport: Optional[SwapServerTransport] = None
self.recent_offers = []
def on_destroy(self):
if self.transport_task is not None:
self.transport_task.cancel()
self.unregister_callbacks()
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self):
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self.run_swap_manager()
self.walletChanged.emit()
sliderPosChanged = pyqtSignal()
@pyqtProperty(float, notify=sliderPosChanged)
def sliderPos(self):
return self._sliderPos
@sliderPos.setter
def sliderPos(self, sliderPos):
if self._sliderPos != sliderPos:
self._sliderPos = sliderPos
self.swap_slider_moved()
self.sliderPosChanged.emit()
rangeMinChanged = pyqtSignal()
@pyqtProperty(float, notify=rangeMinChanged)
def rangeMin(self):
return self._rangeMin
@rangeMin.setter
def rangeMin(self, rangeMin):
if self._rangeMin != rangeMin:
self._rangeMin = rangeMin
self.rangeMinChanged.emit()
rangeMaxChanged = pyqtSignal()
@pyqtProperty(float, notify=rangeMaxChanged)
def rangeMax(self):
return self._rangeMax
@rangeMax.setter
def rangeMax(self, rangeMax):
if self._rangeMax != rangeMax:
self._rangeMax = rangeMax
self.rangeMaxChanged.emit()
leftVoidChanged = pyqtSignal()
@pyqtProperty(float, notify=leftVoidChanged)
def leftVoid(self):
return self._leftVoid
rightVoidChanged = pyqtSignal()
@pyqtProperty(float, notify=rightVoidChanged)
def rightVoid(self):
return self._rightVoid
validChanged = pyqtSignal()
@pyqtProperty(bool, notify=validChanged)
def valid(self):
return self._valid
@valid.setter
def valid(self, valid):
if self._valid != valid:
self._valid = valid
self.validChanged.emit()
stateChanged = pyqtSignal()
@pyqtProperty(int, notify=stateChanged)
def state(self):
return self._state
@state.setter
def state(self, state):
if self._state != state:
self._state = state
self.stateChanged.emit()
userinfoChanged = pyqtSignal()
@pyqtProperty(str, notify=userinfoChanged)
def userinfo(self):
return self._userinfo
@userinfo.setter
def userinfo(self, userinfo):
if self._userinfo != userinfo:
self._userinfo = userinfo
self.userinfoChanged.emit()
tosendChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=tosendChanged)
def tosend(self):
return self._tosend
@tosend.setter
def tosend(self, tosend):
if self._tosend != tosend:
self._tosend = tosend
self.tosendChanged.emit()
toreceiveChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=toreceiveChanged)
def toreceive(self):
return self._toreceive
@toreceive.setter
def toreceive(self, toreceive):
if self._toreceive != toreceive:
self._toreceive = toreceive
self.toreceiveChanged.emit()
serverMiningfeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=serverMiningfeeChanged)
def serverMiningfee(self):
return self._server_miningfee
@serverMiningfee.setter
def serverMiningfee(self, server_miningfee):
if self._server_miningfee != server_miningfee:
self._server_miningfee = server_miningfee
self.serverMiningfeeChanged.emit()
serverfeepercChanged = pyqtSignal()
@pyqtProperty(str, notify=serverfeepercChanged)
def serverfeeperc(self):
return self._serverfeeperc
@serverfeeperc.setter
def serverfeeperc(self, serverfeeperc):
if self._serverfeeperc != serverfeeperc:
self._serverfeeperc = serverfeeperc
self.serverfeepercChanged.emit()
miningfeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=miningfeeChanged)
def miningfee(self):
return self._miningfee
@miningfee.setter
def miningfee(self, miningfee):
if self._miningfee != miningfee:
self._miningfee = miningfee
self.miningfeeChanged.emit()
isReverseChanged = pyqtSignal()
@pyqtProperty(bool, notify=isReverseChanged)
def isReverse(self):
return self._isReverse
@isReverse.setter
def isReverse(self, isReverse):
if self._isReverse != isReverse:
self._isReverse = isReverse
self.isReverseChanged.emit()
canCancelChanged = pyqtSignal()
@pyqtProperty(bool, notify=canCancelChanged)
def canCancel(self):
return self._canCancel
@canCancel.setter
def canCancel(self, canCancel):
if self._canCancel != canCancel:
self._canCancel = canCancel
self.canCancelChanged.emit()
availableSwapServersChanged = pyqtSignal()
@pyqtProperty(QESwapServerNPubListModel, notify=availableSwapServersChanged)
def availableSwapServers(self):
if not self._available_swapservers:
self._available_swapservers = QESwapServerNPubListModel(self._wallet.wallet.config)
return self._available_swapservers
def on_offers_updated(self):
self.availableSwapServers.updateModel(self.recent_offers)
@pyqtSlot(result=bool)
def isNostr(self):
return True # TODO
def run_swap_manager(self):
self._logger.debug('run_swap_manager')
if (lnworker := self._wallet.wallet.lnworker) is None:
return
swap_manager = lnworker.swap_manager
assert not swap_manager.is_server, 'running as swap server not supported'
# if not self._wallet.wallet.config.SWAPSERVER_URL and not self._wallet.wallet.config.SWAPSERVER_NPUB: # TODO enable nostr
# self._logger.debug('nostr is preferred but swapserver npub still undefined')
# FIXME: clearing is_initialized, we might be called because the npub was changed
swap_manager.is_initialized.clear()
self.state = QESwapHelper.State.Initialized if swap_manager.is_initialized.is_set() else QESwapHelper.State.Initializing
swap_transport = swap_manager.create_transport()
async def swap_transport_task(transport: SwapServerTransport):
async with transport:
self.swap_transport = transport
if not swap_manager.is_initialized.is_set():
self.userinfo = _('Initializing...')
try:
# is_initialized is set if we receive the event of our configured SWAPSERVER_NPUB
# This will timeout if no server is configured, or our server didn't publish recently.
timeout = transport.connect_timeout + 1
await wait_for2(swap_manager.is_initialized.wait(), timeout=timeout)
self._logger.debug('swapmanager initialized')
self.state = QESwapHelper.State.Initialized
except asyncio.TimeoutError:
# only fail if we didn't get any offers or couldn't connect at all
# otherwise the timeout just means that no offer of the selected npub has
# been found (or that there is no npub selected at all), so the prompt should open
if isinstance(transport, NostrTransport) and not transport.is_connected.is_set():
self.userinfo = _('Error') + ': ' + '\n'.join([
_('Could not connect to a Nostr relay.'),
_('Please check your relays and network connection')
])
self.state = QESwapHelper.State.NoService
return
elif not isinstance(transport, NostrTransport) or not transport.get_recent_offers():
self._logger.debug('Could not find a swap provider.')
self.userinfo = _('Could not find a swap provider.')
self.state = QESwapHelper.State.NoService
return
except Exception as e:
try: # swaphelper might be destroyed at this point
self.userinfo = _('Error') + ': ' + str(e)
self.state = QESwapHelper.State.NoService
self._logger.error(str(e))
except RuntimeError:
pass
return
if isinstance(transport, NostrTransport) and not swap_manager.is_initialized.is_set():
# not is_initialized.is_set() = configured provider was not found (or no provider configured)
# prompt user to select a swapserver
self.recent_offers = transport.get_recent_offers()
self.offersUpdated.emit()
self.undefinedNPub.emit()
elif swap_manager.is_initialized.is_set():
self.setReadyState()
while True:
# keep fetching new incoming offer events
# the slider range will not get updated continuously as it would irritate the user
if isinstance(transport, NostrTransport):
if (recent_offers := transport.get_recent_offers()) != self.recent_offers:
self._logger.debug(f"received new swap offer")
self.recent_offers = recent_offers
self.offersUpdated.emit()
await asyncio.sleep(1)
def transport_closed_cb(fut: 'concurrent.futures.Future'):
self.transport_task = None
if fut.cancelled():
return
exc = fut.exception()
if exc:
send_exception_to_crash_reporter(exc)
self.transport_task = asyncio.run_coroutine_threadsafe(
swap_transport_task(swap_transport),
get_asyncio_loop()
)
self.transport_task.add_done_callback(transport_closed_cb)
@pyqtSlot()
def npubSelectionCancelled(self):
if (self._wallet.wallet.config.SWAPSERVER_NPUB
not in [offer.server_npub for offer in self.recent_offers]):
self._logger.debug('nostr is preferred but swapserver npub still undefined')
if not self._wallet.wallet.config.SWAPSERVER_NPUB:
self.userinfo = _('No swap provider selected.')
else:
self.userinfo = _('Select one of the available swap providers.')
self.state = QESwapHelper.State.NoService
@pyqtSlot()
def setReadyState(self):
if self._wallet.wallet.config.SWAPSERVER_NPUB \
or not isinstance(self.swap_transport, NostrTransport):
self.state = QESwapHelper.State.ServiceReady
self.userinfo = QESwapHelper.MESSAGE_SWAP_HOWTO
self.initSwapSliderRange()
def update_swap_manager_pair(self):
"""Updates the swap manager pairs to the recent pairs of the selected server"""
assert self.swap_transport is not None, "No swap transport"
if isinstance(self.swap_transport, NostrTransport):
swap_manager = self._wallet.wallet.lnworker.swap_manager
pair = self.swap_transport.get_offer(self._wallet.wallet.config.SWAPSERVER_NPUB)
swap_manager.update_pairs(pair.pairs)
@pyqtSlot()
def initSwapSliderRange(self):
lnworker = self._wallet.wallet.lnworker
swap_manager = lnworker.swap_manager
# update the swap_manager pair so the newest available data is used below
self.update_swap_manager_pair()
"""Sets the minimal and maximal amount that can be swapped for the swap
slider."""
# tx is updated again afterwards with send_amount in case of normal swap
# this is just to estimate the maximal spendable onchain amount for HTLC
self.update_tx('!')
try:
max_onchain_spend = self._tx.output_value_for_address(DummyAddress.SWAP)
except AttributeError: # happens if there are no utxos
max_onchain_spend = 0
reverse = int(min(lnworker.num_sats_can_send(),
swap_manager.get_provider_max_forward_amount()))
max_recv_amt_ln = min(swap_manager.get_provider_max_reverse_amount(), int(lnworker.num_sats_can_receive()))
max_recv_amt_oc = swap_manager.get_send_amount(max_recv_amt_ln, is_reverse=False) or 0
forward = int(min(max_recv_amt_oc,
# maximally supported swap amount by provider
swap_manager.get_provider_max_reverse_amount(),
max_onchain_spend))
# we expect range to adjust the value of the swap slider to be in the
# correct range, i.e., to correct an overflow when reducing the limits
self._logger.debug(f'Slider range {-reverse} - {forward}. Pos {self._sliderPos}')
self.rangeMin = -reverse
self.rangeMax = forward
# percentage of void, right or left
if reverse < forward:
self._leftVoid = 0.5 * (forward - reverse) / forward
self._rightVoid = 0
elif reverse > forward:
self._leftVoid = 0
self._rightVoid = - 0.5 * (forward - reverse) / reverse
else:
self._leftVoid = 0
self._rightVoid = 0
self.leftVoidChanged.emit()
self.rightVoidChanged.emit()
if not self.rangeMin <= self._sliderPos <= self.rangeMax:
# clamp the slider pos into the given limits
if abs(self._sliderPos - self.rangeMin) < abs(self._sliderPos - self.rangeMax):
self._sliderPos = self.rangeMin
else:
self._sliderPos = self.rangeMax
self.swap_slider_moved()
@profiler
def update_tx(self, onchain_amount: Union[int, str]):
"""Updates the transaction associated with a forward swap."""
if onchain_amount is None:
self._tx = None
self.valid = False
return
outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]
coins = self._wallet.wallet.get_spendable_coins(None)
fee_policy = FeePolicy('eta:2')
try:
self._tx = self._wallet.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs,
fee_policy=fee_policy)
except (NotEnoughFunds, NoDynamicFeeEstimates):
self._tx = None
self.valid = False
@qt_event_listener
def on_event_fee_histogram(self, *args):
self.swap_slider_moved()
@qt_event_listener
def on_event_fee(self, *args):
self.swap_slider_moved()
def swap_slider_moved(self):
if self._state in [QESwapHelper.State.Initializing, QESwapHelper.State.Initialized, QESwapHelper.State.NoService]:
return
position = int(self._sliderPos)
swap_manager = self._wallet.wallet.lnworker.swap_manager
# pay_amount and receive_amounts are always with fees already included
# so they reflect the net balance change after the swap
self.isReverse = (position < 0)
self._send_amount = abs(position)
self.tosend = QEAmount(amount_sat=self._send_amount)
self._receive_amount = swap_manager.get_recv_amount(send_amount=self._send_amount, is_reverse=self.isReverse)
self.toreceive = QEAmount(amount_sat=self._receive_amount)
# fee breakdown
self.serverfeeperc = f'{swap_manager.percentage:0.2f}%'
server_miningfee = swap_manager.mining_fee
self.serverMiningfee = QEAmount(amount_sat=server_miningfee)
if self.isReverse:
self.miningfee = QEAmount(amount_sat=swap_manager.get_fee_for_txbatcher())
self.check_valid(self._send_amount, self._receive_amount)
else:
# update tx only if slider isn't moved for a while
self.valid = False
# trigger tx_update_pushback_timer through signal, as this might be called from other thread
self.requestTxUpdate.emit()
def tx_update_pushback_timer(self):
self._fwd_swap_updatetx_timer.start(250)
def check_valid(self, send_amount, receive_amount):
if send_amount and receive_amount:
self.valid = True
else:
# add more nuanced error reporting?
self.valid = False
def fwd_swap_updatetx(self):
# if slider is on reverse swap side when timer hits, ignore
if self.isReverse:
return
self.update_tx(self._send_amount)
# add lockup fees, but the swap amount is position
pay_amount = self._send_amount + self._tx.get_fee() if self._tx else 0
self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) if self._tx else QEAmount()
self.check_valid(pay_amount, self._receive_amount)
def do_normal_swap(self, lightning_amount, onchain_amount):
assert self._tx
if lightning_amount is None or onchain_amount is None:
return
async def swap_task():
assert self.swap_transport is not None, "Swap transport not available"
try:
dummy_tx = self._create_tx(onchain_amount)
self.userinfo = _('Performing swap...')
self.state = QESwapHelper.State.Started
self._swap, invoice = await self._wallet.wallet.lnworker.swap_manager.request_normal_swap(
transport=self.swap_transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount,
)
tx = self._wallet.wallet.lnworker.swap_manager.create_funding_tx(self._swap, dummy_tx, password=self._wallet.password)
coro2 = self._wallet.wallet.lnworker.swap_manager.wait_for_htlcs_and_broadcast(
transport=self.swap_transport, swap=self._swap, invoice=invoice, tx=tx)
self._fut_htlc_wait = fut = asyncio.create_task(coro2)
self.canCancel = True
txid = await fut
try: # swaphelper might be destroyed at this point
if txid:
self.userinfo = _('Success!')
self.state = QESwapHelper.State.Success
else:
self.userinfo = _('Swap failed!')
self.state = QESwapHelper.State.Failed
except RuntimeError:
pass
except asyncio.CancelledError:
self._wallet.wallet.lnworker.swap_manager.cancel_normal_swap(self._swap)
self.userinfo = _('Swap cancelled')
self.state = QESwapHelper.State.Cancelled
except Exception as e:
try: # swaphelper might be destroyed at this point
self.state = QESwapHelper.State.Failed
self.userinfo = _('Error') + ': ' + str(e)
self._logger.error(str(e))
except RuntimeError:
pass
finally:
try: # swaphelper might be destroyed at this point
self.canCancel = False
self._swap = None
self._fut_htlc_wait = None
except RuntimeError:
pass
asyncio.run_coroutine_threadsafe(swap_task(), get_asyncio_loop())
def _create_tx(self, onchain_amount: Union[int, str, None]) -> PartialTransaction:
# TODO: func taken from qt GUI, this should be common code
assert not self.isReverse
if onchain_amount is None:
raise InvalidSwapParameters("onchain_amount is None")
# coins = self.window.get_coins()
coins = self._wallet.wallet.get_spendable_coins()
if onchain_amount == '!':
max_amount = sum(c.value_sats() for c in coins)
max_swap_amount = self._wallet.wallet.lnworker.swap_manager.client_max_amount_forward_swap()
if max_swap_amount is None:
raise InvalidSwapParameters("swap_manager.client_max_amount_forward_swap() is None")
if max_amount > max_swap_amount:
onchain_amount = max_swap_amount
outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]
fee_policy = FeePolicy('eta:2')
try:
tx = self._wallet.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs,
send_change_to_lightning=False,
fee_policy=fee_policy
)
except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
raise InvalidSwapParameters(str(e)) from e
return tx
def do_reverse_swap(self, lightning_amount, onchain_amount):
if lightning_amount is None or onchain_amount is None:
return
async def swap_task():
assert self.swap_transport is not None, "Swap transport not available"
swap_manager = self._wallet.wallet.lnworker.swap_manager
try:
self.userinfo = _('Performing swap...')
self.state = QESwapHelper.State.Started
await swap_manager.is_initialized.wait()
txid = await swap_manager.reverse_swap(
transport=self.swap_transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount + swap_manager.get_fee_for_txbatcher(),
prepayment_sat=2 * self.serverMiningfee.satsInt,
)
try: # swaphelper might be destroyed at this point
if txid:
self.userinfo = _('Success!')
self.state = QESwapHelper.State.Success
else:
self.userinfo = _('Swap failed!')
self.state = QESwapHelper.State.Failed
except RuntimeError:
pass
except Exception as e:
try: # swaphelper might be destroyed at this point
self.state = QESwapHelper.State.Failed
msg = _('Timeout') if isinstance(e, TimeoutError) else str(e)
self.userinfo = _('Error') + ': ' + msg
self._logger.error(str(e))
except RuntimeError:
pass
asyncio.run_coroutine_threadsafe(swap_task(), get_asyncio_loop())
@pyqtSlot()
def executeSwap(self):
if not self._wallet.wallet.network:
self.error.emit(_("You are offline."))
return
self._do_execute_swap()
@auth_protect(message=_('Confirm Lightning swap?'))
def _do_execute_swap(self):
if self.isReverse:
lightning_amount = self._send_amount
onchain_amount = self._receive_amount
self.do_reverse_swap(lightning_amount, onchain_amount)
else:
lightning_amount = self._receive_amount
onchain_amount = self._send_amount
self.do_normal_swap(lightning_amount, onchain_amount)
@pyqtSlot()
def cancelNormalSwap(self):
assert self._swap
self.canCancel = False
self._fut_htlc_wait.cancel()
================================================
FILE: electrum/gui/qml/qetransactionlistmodel.py
================================================
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Dict, Any
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from electrum.logging import get_logger
from electrum.util import Satoshis, TxMinedInfo
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .qetypes import QEAmount
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
class QETransactionListModel(QAbstractListModel, QtEventListener):
_logger = get_logger(__name__)
# define listmodel rolemap
_ROLE_NAMES=('txid', 'fee_sat', 'height', 'confirmations', 'timestamp', 'monotonic_timestamp',
'incoming', 'value', 'date', 'label', 'txpos_in_block', 'fee',
'inputs', 'outputs', 'section', 'type', 'lightning', 'payment_hash', 'key', 'complete')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
_ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))
requestRefresh = pyqtSignal()
def __init__(self, wallet: 'Abstract_Wallet', parent=None, *, onchain_domain=None, include_lightning=True):
super().__init__(parent)
self.wallet = wallet
self.onchain_domain = onchain_domain
self.include_lightning = include_lightning
self.tx_history = []
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
self.requestRefresh.connect(lambda: self.initModel())
self._dirty = True
self.initModel()
def on_destroy(self):
self.unregister_callbacks()
@qt_event_listener
def on_event_verified(self, wallet, txid, info):
if wallet == self.wallet:
self._logger.debug('verified event for txid %s' % txid)
self.on_tx_verified(txid, info)
@qt_event_listener
def on_event_adb_set_future_tx(self, adb, txid):
if adb != self.wallet.adb:
return
self._logger.debug(f'adb_set_future_tx event for txid {txid}')
for i, item in enumerate(self.tx_history):
if 'txid' in item and item['txid'] == txid:
self._update_future_txitem(i)
return
@qt_event_listener
def on_event_fee_histogram(self, histogram):
self._logger.debug(f'fee histogram updated')
for i, tx_item in enumerate(self.tx_history):
if 'height' not in tx_item: # filter to on-chain
continue
if tx_item['confirmations'] > 0: # filter out already mined
continue
txid = tx_item['txid']
tx = self.wallet.db.get_transaction(txid)
if not tx:
continue
txinfo = self.wallet.get_tx_info(tx)
status, status_str = self.wallet.get_tx_status(txid, txinfo.tx_mined_status)
tx_item['date'] = status_str
index = self.index(i, 0)
roles = [self._ROLE_RMAP['date']]
self.dataChanged.emit(index, index, roles)
@qt_event_listener
def on_event_labels_received(self, wallet, labels):
if wallet == self.wallet:
self.initModel(True) # TODO: be less dramatic
def rowCount(self, index):
return len(self.tx_history)
# also expose rowCount as a property
countChanged = pyqtSignal()
@pyqtProperty(int, notify=countChanged)
def count(self):
return len(self.tx_history)
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
tx = self.tx_history[index.row()]
role_index = role - Qt.ItemDataRole.UserRole
try:
value = tx[self._ROLE_NAMES[role_index]]
except KeyError as e:
self._logger.error(f'non-existing key "{self._ROLE_NAMES[role_index]}" requested')
value = None
if isinstance(value, (bool, list, int, str, QEAmount)) or value is None:
return value
if isinstance(value, Satoshis):
return value.value
return str(value)
@pyqtSlot()
def setDirty(self):
self._dirty = True
def clear(self):
self.beginResetModel()
self.tx_history = []
self.endResetModel()
def tx_to_model(self, tx_item):
#self._logger.debug(str(tx_item))
item = tx_item
item['key'] = item.get('txid') or item['payment_hash'] or item['group_id'] # fixme: this is fragile
if 'lightning' not in item:
item['lightning'] = False
if item['lightning']:
item['value'] = QEAmount(amount_sat=item['value'].value, amount_msat=item['amount_msat'])
item['incoming'] = True if item['amount_msat'] > 0 else False
item['confirmations'] = 0
else:
item['value'] = QEAmount(amount_sat=item['value'].value)
if 'txid' in item:
tx = self.wallet.db.get_transaction(item['txid'])
if tx:
item['complete'] = tx.is_complete()
else: # due to races, tx might have already been removed from history
item['complete'] = False
# newly arriving txs, or (partially/fully signed) local txs have no (block) timestamp
# FIXME just use wallet.get_tx_status, and change that as needed
if not item['timestamp']: # onchain: local or mempool or unverified txs
if not item['lightning']:
txid = item['txid']
assert txid
tx_mined_info = self._tx_mined_info_from_tx_item(tx_item)
item['section'] = 'local' if tx_mined_info.is_local_like() else 'mempool'
status, status_str = self.wallet.get_tx_status(txid, tx_mined_info=tx_mined_info)
item['date'] = status_str
else: # lightning or already mined (and SPV-ed) onchain txs
item['section'] = self.get_section_by_timestamp(item['timestamp'])
item['date'] = self.format_date_by_section(item['section'], datetime.fromtimestamp(item['timestamp']))
return item
@staticmethod
def get_section_by_timestamp(timestamp):
txts = datetime.fromtimestamp(timestamp)
today = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
if txts > today:
return 'today'
elif txts > today - timedelta(days=1):
return 'yesterday'
elif txts > today - timedelta(days=7):
return 'lastweek'
elif txts > today - timedelta(days=31):
return 'lastmonth'
else:
return 'older'
@staticmethod
def format_date_by_section(section: str, date: datetime):
# TODO: l10n
dfmt = {
'today': '%H:%M',
'yesterday': '%H:%M',
'lastweek': '%a, %H:%M',
'lastmonth': '%a %d, %H:%M',
'older': '%Y-%m-%d %H:%M'
}
if section not in dfmt:
section = 'older'
return date.strftime(dfmt[section])
@staticmethod
def _tx_mined_info_from_tx_item(tx_item: Dict[str, Any]) -> TxMinedInfo:
# FIXME a bit hackish to have to reconstruct the TxMinedInfo... same thing in qt-gui
tx_mined_info = TxMinedInfo(
_height=tx_item['height'],
conf=tx_item['confirmations'],
timestamp=tx_item['timestamp'],
wanted_height=tx_item.get('wanted_height', None),
)
return tx_mined_info
@pyqtSlot()
@pyqtSlot(bool)
def initModel(self, force: bool = False):
# only (re)construct if dirty or forced
if not self._dirty and not force:
return
self._logger.debug('retrieving history')
history = self.wallet.get_full_history(
onchain_domain=self.onchain_domain,
include_lightning=self.include_lightning,
)
txs = []
for key, tx in history.items():
txs.append(self.tx_to_model(tx))
self.clear()
self.beginInsertRows(QModelIndex(), 0, len(txs) - 1)
self.tx_history = txs
self.tx_history.reverse()
self.endInsertRows()
self.countChanged.emit()
self._dirty = False
def on_tx_verified(self, txid: str, info: TxMinedInfo):
for i, tx in enumerate(self.tx_history):
if 'txid' in tx and tx['txid'] == txid:
tx['height'] = info.height()
tx['confirmations'] = info.conf
tx['timestamp'] = info.timestamp
tx['section'] = self.get_section_by_timestamp(info.timestamp)
tx['date'] = self.format_date_by_section(tx['section'], datetime.fromtimestamp(info.timestamp))
index = self.index(i, 0)
roles = [self._ROLE_RMAP[x] for x in ['section', 'height', 'confirmations', 'timestamp', 'date']]
self.dataChanged.emit(index, index, roles)
return
def _update_future_txitem(self, tx_item_idx: int):
tx_item = self.tx_history[tx_item_idx]
# note: local txs can transition to future, as "future" state is not persisted
if tx_item.get('height') not in (TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL):
return
txid = tx_item['txid']
tx = self.wallet.db.get_transaction(txid)
if tx is None:
return
txinfo = self.wallet.get_tx_info(tx)
status, status_str = self.wallet.get_tx_status(txid, txinfo.tx_mined_status)
tx_item['date'] = status_str
# note: if the height changes, that might affect the history order, but we won't re-sort now.
tx_item['height'] = self.wallet.adb.get_tx_height(txid).height()
index = self.index(tx_item_idx, 0)
roles = [self._ROLE_RMAP[x] for x in ['height', 'date']]
self.dataChanged.emit(index, index, roles)
@pyqtSlot(str, str)
def updateTxLabel(self, key, label):
for i, tx in enumerate(self.tx_history):
if tx['key'] == key:
tx['label'] = label
index = self.index(i, 0)
self.dataChanged.emit(index, index, [self._ROLE_RMAP['label']])
return
@pyqtSlot(int)
def updateBlockchainHeight(self, height):
self._logger.debug('updating height to %d' % height)
for i, tx_item in enumerate(self.tx_history):
if 'height' in tx_item:
if tx_item['height'] > 0:
tx_item['confirmations'] = height - tx_item['height'] + 1
index = self.index(i, 0)
roles = [self._ROLE_RMAP['confirmations']]
self.dataChanged.emit(index, index, roles)
elif tx_item['height'] in (TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL):
self._update_future_txitem(i)
================================================
FILE: electrum/gui/qml/qetxdetails.py
================================================
from typing import Optional
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.bitcoin import DummyAddress
from electrum.util import format_time, TxMinedInfo
from electrum.transaction import tx_from_any, Transaction, PartialTransaction
from electrum.network import Network
from electrum.address_synchronizer import TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE
from electrum.wallet import TxSighashDanger
from electrum.fee_policy import FeePolicy
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .qewallet import QEWallet
from .qetypes import QEAmount
class QETxDetails(QObject, QtEventListener):
_logger = get_logger(__name__)
confirmRemoveLocalTx = pyqtSignal([str], arguments=['message'])
txRemoved = pyqtSignal()
saveTxError = pyqtSignal([str, str], arguments=['code', 'message'])
saveTxSuccess = pyqtSignal()
detailsChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
self._wallet = None # type: Optional[QEWallet]
self._txid = ''
self._rawtx = ''
self._label = ''
self._tx = None # type: Optional[Transaction]
self._status = ''
self._amount = QEAmount()
self._lnamount = QEAmount()
self._fee = QEAmount()
self._feerate_str = ''
self._inputs = []
self._outputs = []
self._is_lightning_funding_tx = False
self._can_bump = False
self._can_dscancel = False
self._can_broadcast = False
self._can_cpfp = False
self._can_save_as_local = False
self._can_remove = False
self._can_sign = False
self._is_unrelated = False
self._is_complete = False
self._is_mined = False
self._is_rbf_enabled = False
self._is_removed = False
self._lock_delay = 0
self._sighash_danger = TxSighashDanger()
self._mempool_depth = ''
self._in_mempool = False
self._date = ''
self._timestamp = 0
self._confirmations = 0
self._header_hash = ''
self._short_id = ""
def on_destroy(self):
self.unregister_callbacks()
@qt_event_listener
def on_event_verified(self, wallet, txid, info):
if wallet == self._wallet.wallet and txid == self._txid:
self._logger.debug(f'verified event for our txid {txid}')
self.update()
@qt_event_listener
def on_event_new_transaction(self, wallet, tx):
if wallet == self._wallet.wallet and tx.txid() == self._txid:
self._logger.debug(f'new_transaction event for our txid {self._txid}')
self.update()
@qt_event_listener
def on_event_removed_transaction(self, wallet, tx):
if wallet == self._wallet.wallet and tx.txid() == self._txid:
self._logger.debug(f'removed my transaction {tx.txid()}')
self._is_removed = True
self.update()
self.txRemoved.emit()
@qt_event_listener
def on_event_fee_histogram(self, histogram):
if not self._wallet or not self._tx:
return
self.update()
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self):
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self.walletChanged.emit()
txidChanged = pyqtSignal()
@pyqtProperty(str, notify=txidChanged)
def txid(self):
return self._txid
@txid.setter
def txid(self, txid: str):
if self._txid != txid:
self._logger.debug(f'txid set -> {txid}')
self._txid = txid
self.txidChanged.emit()
self.update(from_txid=True)
@pyqtProperty(str, notify=detailsChanged)
def rawtx(self):
return self._rawtx
@rawtx.setter
def rawtx(self, rawtx: str):
if self._rawtx != rawtx:
self._logger.debug(f'rawtx set -> {rawtx}')
self._rawtx = rawtx
if not rawtx:
return
try:
self._tx = tx_from_any(rawtx, deserialize=True)
self._txid = self._tx.txid()
self.txidChanged.emit()
self.update()
except Exception as e:
self._tx = None
self._logger.error(repr(e))
labelChanged = pyqtSignal()
@pyqtProperty(str, notify=labelChanged)
def label(self):
return self._label
@pyqtSlot(str)
def setLabel(self, label: str):
if label != self._label:
self._wallet.wallet.set_label(self._txid, label)
self._label = label
self.labelChanged.emit()
@pyqtProperty(str, notify=detailsChanged)
def status(self):
return self._status
@pyqtProperty(str, notify=detailsChanged)
def warning(self):
return self._sighash_danger.get_long_message()
@pyqtProperty(QEAmount, notify=detailsChanged)
def amount(self):
return self._amount
@pyqtProperty(QEAmount, notify=detailsChanged)
def lnAmount(self):
return self._lnamount
@pyqtProperty(QEAmount, notify=detailsChanged)
def fee(self):
return self._fee
@pyqtProperty(str, notify=detailsChanged)
def feeRateStr(self):
return self._feerate_str
@pyqtProperty('QVariantList', notify=detailsChanged)
def inputs(self):
return self._inputs
@pyqtProperty('QVariantList', notify=detailsChanged)
def outputs(self):
return self._outputs
@pyqtProperty(bool, notify=detailsChanged)
def isMined(self):
return self._is_mined
@pyqtProperty(bool, notify=detailsChanged)
def isRemoved(self):
return self._is_removed
@pyqtProperty(str, notify=detailsChanged)
def mempoolDepth(self):
return self._mempool_depth
@pyqtProperty(bool, notify=detailsChanged)
def inMempool(self):
return self._in_mempool
@pyqtProperty(str, notify=detailsChanged)
def date(self):
return self._date
@pyqtProperty(int, notify=detailsChanged)
def timestamp(self):
return self._timestamp
@pyqtProperty(int, notify=detailsChanged)
def confirmations(self):
return self._confirmations
@pyqtProperty(str, notify=detailsChanged)
def shortId(self):
return self._short_id
@pyqtProperty(str, notify=detailsChanged)
def headerHash(self):
return self._header_hash
@pyqtProperty(bool, notify=detailsChanged)
def isLightningFundingTx(self):
return self._is_lightning_funding_tx
@pyqtProperty(bool, notify=detailsChanged)
def canBump(self):
return self._can_bump
@pyqtProperty(bool, notify=detailsChanged)
def canCancel(self):
return self._can_dscancel
@pyqtProperty(bool, notify=detailsChanged)
def canBroadcast(self):
return self._can_broadcast
@pyqtProperty(bool, notify=detailsChanged)
def canCpfp(self):
return self._can_cpfp
@pyqtProperty(bool, notify=detailsChanged)
def canSaveAsLocal(self):
return self._can_save_as_local
@pyqtProperty(bool, notify=detailsChanged)
def canRemove(self):
return self._can_remove
@pyqtProperty(bool, notify=detailsChanged)
def canSign(self):
return self._can_sign
@pyqtProperty(bool, notify=detailsChanged)
def isUnrelated(self):
return self._is_unrelated
@pyqtProperty(bool, notify=detailsChanged)
def isComplete(self):
return self._is_complete
@pyqtProperty(bool, notify=detailsChanged)
def isRbfEnabled(self):
return self._is_rbf_enabled
@pyqtProperty(int, notify=detailsChanged)
def lockDelay(self):
return self._lock_delay
@pyqtProperty(bool, notify=detailsChanged)
def shouldConfirm(self):
return self._sighash_danger.needs_confirm()
def update(self, from_txid: bool = False):
assert self._wallet
if self._is_removed:
self._logger.debug('tx removed, disable gui options')
self._can_broadcast = False
self._can_bump = False
self._can_dscancel = False
self._can_cpfp = False
self._can_save_as_local = False
self._can_remove = False
self._can_sign = False
self._mempool_depth = ''
self._status = _('removed')
self.detailsChanged.emit()
return
if from_txid:
self._tx = self._wallet.wallet.db.get_transaction(self._txid)
assert self._tx is not None, f'unknown txid "{self._txid}"'
#self._logger.debug(repr(self._tx.to_json()))
self._logger.debug('adding info from wallet')
self._tx.add_info_from_wallet(self._wallet.wallet)
if not self._tx.is_complete() and self._tx.is_missing_info_from_network():
Network.run_from_another_thread(
self._tx.add_info_from_network(self._wallet.wallet.network, timeout=10)) # FIXME is this needed?...
sm = self._wallet.wallet.lnworker.swap_manager if self._wallet.wallet.lnworker else None
self._inputs = list(map(lambda x: {
'short_id': x.prevout.short_name(),
'value': x.value_sats(),
'address': x.address,
'is_mine': self._wallet.wallet.is_mine(x.address),
'is_change': self._wallet.wallet.is_change(x.address),
'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(x.address) or x.address == DummyAddress.SWAP,
'is_accounting': self._wallet.wallet.is_accounting_address(x.address)
}, self._tx.inputs()))
self._outputs = list(map(lambda x: {
'address': x.get_ui_address_str(),
'value': QEAmount(amount_sat=x.value),
'short_id': '', # TODO
'is_mine': self._wallet.wallet.is_mine(x.get_ui_address_str()),
'is_change': self._wallet.wallet.is_change(x.get_ui_address_str()),
'is_billing': self._wallet.wallet.is_billing_address(x.get_ui_address_str()),
'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(x.get_ui_address_str()) or x.get_ui_address_str() == DummyAddress.SWAP,
'is_accounting': self._wallet.wallet.is_accounting_address(x.get_ui_address_str())
}, self._tx.outputs()))
txinfo = self._wallet.wallet.get_tx_info(self._tx)
self._logger.debug(repr(txinfo))
# can be None if outputs unrelated to wallet seed,
# e.g. to_local local_force_close commitment CSV-locked p2wsh script
if txinfo.amount is None:
self._amount.satsInt = 0
else:
self._amount.satsInt = txinfo.amount
self._status = txinfo.status
self._fee.satsInt = txinfo.fee
self._feerate_str = ""
if txinfo.fee is not None:
size = self._tx.estimated_size()
fee_per_kb = txinfo.fee / size * 1000
self._feerate_str = self._wallet.wallet.config.format_fee_rate(fee_per_kb)
self._sighash_danger = TxSighashDanger()
self._lock_delay = 0
self._in_mempool = False
self._is_mined = False if not txinfo.tx_mined_status else txinfo.tx_mined_status.height() > 0
if self._is_mined:
self.update_mined_status(txinfo.tx_mined_status)
else:
if txinfo.tx_mined_status.height() in [TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT]:
self._mempool_depth = FeePolicy.depth_tooltip(txinfo.mempool_depth_bytes)
self._in_mempool = True
elif txinfo.tx_mined_status.height() == TX_HEIGHT_FUTURE:
self._lock_delay = txinfo.tx_mined_status.wanted_height - self._wallet.wallet.adb.get_local_height()
if isinstance(self._tx, PartialTransaction):
self._sighash_danger = self._wallet.wallet.check_sighash(self._tx)
if self._wallet.wallet.lnworker:
# Calling wallet.get_full_history here is inefficient.
# We should probably pass the tx_item to the constructor.
full_history = self._wallet.wallet.get_full_history()
item = full_history.get('group:' + self._txid)
self._lnamount.satsInt = int(item['ln_value'].value) if item else 0
else:
self._lnamount.satsInt = 0
self._is_complete = self._tx.is_complete()
self._is_rbf_enabled = self._tx.is_rbf_enabled()
self._is_unrelated = txinfo.amount is None and self._lnamount.isEmpty
self._is_lightning_funding_tx = txinfo.is_lightning_funding_tx
self._can_broadcast = txinfo.can_broadcast
self._can_bump = txinfo.can_bump
self._can_dscancel = txinfo.can_dscancel
self._can_cpfp = txinfo.can_cpfp
self._can_save_as_local = txinfo.can_save_as_local
self._can_remove = txinfo.can_remove
self._can_sign = (
not self._is_complete
and self._wallet.wallet.can_sign(self._tx)
and not self._sighash_danger.needs_reject()
)
self.detailsChanged.emit()
if self._txid:
label = self._wallet.wallet.get_label_for_txid(self._txid)
if self._label != label:
self._label = label
self.labelChanged.emit()
def update_mined_status(self, tx_mined_info: TxMinedInfo):
self._mempool_depth = ''
self._date = format_time(tx_mined_info.timestamp)
self._timestamp = tx_mined_info.timestamp
self._confirmations = tx_mined_info.conf
self._header_hash = tx_mined_info.header_hash
self._short_id = tx_mined_info.short_id() or ""
@pyqtSlot()
def signAndBroadcast(self):
self._sign(broadcast=True)
@pyqtSlot()
def sign(self):
self._sign(broadcast=False)
def _sign(self, broadcast):
# TODO: connecting/disconnecting signal handlers here is hmm
try:
if broadcast:
self._wallet.broadcastSucceeded.disconnect(self.onBroadcastSucceeded)
self._wallet.broadcastFailed.disconnect(self.onBroadcastFailed)
except Exception:
pass
if broadcast:
self._wallet.broadcastSucceeded.connect(self.onBroadcastSucceeded)
self._wallet.broadcastFailed.connect(self.onBroadcastFailed)
self._wallet.sign_and_broadcast(self._tx, on_success=self.on_signed_tx)
else:
self._wallet.sign(self._tx, on_success=self.on_signed_tx)
# side-effect: signing updates self._tx
# we rely on this for broadcast
def on_signed_tx(self, tx: Transaction):
self._logger.debug('on_signed_tx')
self.update()
@pyqtSlot()
def broadcast(self):
assert self._tx.is_complete()
try:
self._wallet.broadcastFailed.disconnect(self.onBroadcastFailed)
except Exception:
pass
self._wallet.broadcastFailed.connect(self.onBroadcastFailed)
self._can_broadcast = False
self.detailsChanged.emit()
self._wallet.broadcast(self._tx)
@pyqtSlot(str)
def onBroadcastSucceeded(self, txid):
if txid != self._txid:
return
self._logger.debug('onBroadcastSucceeded')
try:
self._wallet.broadcastSucceeded.disconnect(self.onBroadcastSucceeded)
except Exception:
pass
self._can_broadcast = False
self.detailsChanged.emit()
@pyqtSlot(str, str, str)
def onBroadcastFailed(self, txid, code, reason):
if txid != self._txid:
return
try:
self._wallet.broadcastFailed.disconnect(self.onBroadcastFailed)
except Exception:
pass
self._can_broadcast = True
self.detailsChanged.emit()
@pyqtSlot()
@pyqtSlot(bool)
def removeLocalTx(self, confirm=False):
assert self._can_remove, 'cannot remove'
txid = self._txid
assert txid, 'txid unset'
if not confirm:
num_child_txs = len(self._wallet.wallet.adb.get_depending_transactions(txid))
question = _("Are you sure you want to remove this transaction?")
if num_child_txs > 0:
question = (
_("Are you sure you want to remove this transaction and {} child transactions?")
.format(num_child_txs))
self.confirmRemoveLocalTx.emit(question)
return
self._wallet.wallet.adb.remove_transaction(txid)
self._wallet.wallet.save_db()
# NOTE: from here, the tx/txid is unknown and all properties are invalid.
# UI should close TxDetails and avoid interacting with this qetxdetails instance.
self._tx = None
@pyqtSlot()
def save(self):
if not self._tx:
return
if self._wallet.save_tx(self._tx):
self._can_save_as_local = False
self._can_remove = True
self.detailsChanged.emit()
@pyqtSlot(result='QVariantList')
def getSerializedTx(self):
txqr = self._tx.to_qr_data()
label = ""
if txid := self._tx.txid():
label = self._wallet.wallet.get_label_for_txid(txid)
return [str(self._tx), txqr[0], txqr[1], label]
================================================
FILE: electrum/gui/qml/qetxfinalizer.py
================================================
import copy
from enum import IntEnum
import threading
from decimal import Decimal
from typing import Optional, TYPE_CHECKING, Callable
from functools import partial
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum
from electrum.logging import get_logger
from electrum.i18n import _
from electrum.bitcoin import DummyAddress
from electrum.transaction import PartialTxOutput, PartialTransaction, Transaction, TxOutpoint
from electrum.util import (
NotEnoughFunds, profiler, quantize_feerate, UserFacingException, NoDynamicFeeEstimates, event_listener
)
from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy, sweep_preparations
from electrum import keystore
from electrum.plugin import run_hook
from electrum.fee_policy import FeePolicy, FeeMethod
from electrum.network import NetworkException
from electrum.gui import messages
from electrum.gui.common_qt.util import QtEventListener
from .qewallet import QEWallet
from .qetypes import QEAmount
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
class FeeSlider(QObject):
@pyqtEnum
class FSMethod(IntEnum):
FEERATE = 0
ETA = 1
MEMPOOL = 2
MANUAL = 3
def to_fee_method(self) -> 'FeeMethod':
return {
self.FEERATE: FeeMethod.FEERATE,
self.ETA: FeeMethod.ETA,
self.MEMPOOL: FeeMethod.MEMPOOL,
self.MANUAL: FeeMethod.FIXED
}[self]
@classmethod
def from_fee_method(cls, fm: FeeMethod) -> 'FeeSlider.FSMethod':
return {
FeeMethod.FEERATE: cls.FEERATE,
FeeMethod.ETA: cls.ETA,
FeeMethod.MEMPOOL: cls.MEMPOOL,
FeeMethod.FIXED: cls.MANUAL
}[fm]
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None # type: Optional[QEWallet]
self._sliderSteps = 0
self._sliderPos = 0
self._fee_method = None # type: Optional[FeeSlider.FSMethod]
self._fee_policy = None # type: Optional[FeePolicy]
self._target = ''
self._config = None # type: Optional[SimpleConfig]
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self):
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self._config = self._wallet.wallet.config
self.read_config()
self.walletChanged.emit()
sliderStepsChanged = pyqtSignal()
@pyqtProperty(int, notify=sliderStepsChanged)
def sliderSteps(self):
return self._sliderSteps
sliderPosChanged = pyqtSignal()
@pyqtProperty(int, notify=sliderPosChanged)
def sliderPos(self):
return self._sliderPos
@sliderPos.setter
def sliderPos(self, sliderPos):
if self._sliderPos != sliderPos:
self._sliderPos = sliderPos
self.save_config()
self.sliderPosChanged.emit()
methodChanged = pyqtSignal()
@pyqtProperty(int, notify=methodChanged)
def method(self) -> int:
fsmethod = self.FSMethod.from_fee_method(self._fee_policy.method)
return int(fsmethod)
@method.setter
def method(self, method: int):
if self._fee_method != FeeSlider.FSMethod(method):
self._fee_method = self.FSMethod(method)
self._fee_policy.set_method(self._fee_method.to_fee_method())
self.update_slider()
self.methodChanged.emit()
self.save_config()
targetChanged = pyqtSignal()
@pyqtProperty(str, notify=targetChanged)
def target(self):
return self._target
@target.setter
def target(self, target):
if self._target != target:
self._target = target
self.targetChanged.emit()
def update_slider(self):
if self._fee_method == FeeSlider.FSMethod.MANUAL:
return
self._sliderSteps = self._fee_policy.get_slider_max()
self._sliderPos = self._fee_policy.get_slider_pos()
self.sliderStepsChanged.emit()
self.sliderPosChanged.emit()
def update_target(self):
self.target = self._fee_policy.get_target_text()
def read_config(self):
self._fee_policy = FeePolicy(self._config.FEE_POLICY)
self._fee_method = self.FSMethod.from_fee_method(self._fee_policy.method)
self.update_slider()
self.methodChanged.emit()
self.update()
def save_config(self):
if self._fee_method != FeeSlider.FSMethod.MANUAL:
value = int(self._sliderPos)
self._fee_policy.set_value_from_slider_pos(value)
self._config.FEE_POLICY = self._fee_policy.get_descriptor()
self.update()
def update(self):
raise NotImplementedError()
class TxFeeSlider(FeeSlider):
def __init__(self, parent=None):
super().__init__(parent)
self._fee = QEAmount()
self._feeRate = ''
self._userFee = ''
self._userFeerate = ''
self._is_user_feerate_last = True
self._rbf = False
self._tx = None # type: Optional[PartialTransaction]
self._inputs = []
self._outputs = []
self._finalized_txid = ''
self._valid = False
self._warning = ''
feeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=feeChanged)
def fee(self):
return self._fee
@fee.setter
def fee(self, fee):
if self._fee != fee:
self._fee.copyFrom(fee)
self.feeChanged.emit()
feeRateChanged = pyqtSignal()
@pyqtProperty(str, notify=feeRateChanged)
def feeRate(self):
return self._feeRate
@feeRate.setter
def feeRate(self, feeRate):
if self._feeRate != feeRate:
self._feeRate = feeRate
self.feeRateChanged.emit()
userFeeChanged = pyqtSignal()
@pyqtProperty(str, notify=userFeeChanged)
def userFee(self):
return self._userFee
@userFee.setter
def userFee(self, userFee):
if self._userFee != userFee:
self._logger.warn('userFee')
self._userFee = userFee
user_fee = int(userFee) if userFee else 0
self._fee_policy = FeePolicy(f'fixed:{user_fee}')
self.userFeeChanged.emit()
self.isUserFeerateLast = False
self.update()
userFeerateChanged = pyqtSignal()
@pyqtProperty(str, notify=userFeerateChanged)
def userFeerate(self):
return self._userFeerate
@userFeerate.setter
def userFeerate(self, userFeerate):
if self._userFeerate != userFeerate:
self._logger.warn('userFeerate')
self._userFeerate = userFeerate
as_decimal = Decimal(userFeerate) if userFeerate else 0
user_feerate = int(as_decimal * 1000)
self._fee_policy = FeePolicy(f'feerate:{user_feerate}')
self.userFeerateChanged.emit()
self.isUserFeerateLast = True
self.update()
isUserFeerateLastChanged = pyqtSignal()
@pyqtProperty(bool, notify=isUserFeerateLastChanged)
def isUserFeerateLast(self):
return self._is_user_feerate_last
@isUserFeerateLast.setter
def isUserFeerateLast(self, isUserFeerateLast):
if self._is_user_feerate_last != isUserFeerateLast:
self._is_user_feerate_last = isUserFeerateLast
self.isUserFeerateLastChanged.emit()
rbfChanged = pyqtSignal()
@pyqtProperty(bool, notify=rbfChanged)
def rbf(self):
return self._rbf
@rbf.setter
def rbf(self, rbf):
if self._rbf != rbf:
self._rbf = rbf
self.update()
self.rbfChanged.emit()
inputsChanged = pyqtSignal()
@pyqtProperty('QVariantList', notify=inputsChanged)
def inputs(self):
return self._inputs
@inputs.setter
def inputs(self, inputs):
if self._inputs != inputs:
self._inputs = inputs
self.inputsChanged.emit()
outputsChanged = pyqtSignal()
@pyqtProperty('QVariantList', notify=outputsChanged)
def outputs(self):
return self._outputs
@outputs.setter
def outputs(self, outputs):
if self._outputs != outputs:
self._outputs = outputs
self.outputsChanged.emit()
finalizedTxidChanged = pyqtSignal()
@pyqtProperty(str, notify=finalizedTxidChanged)
def finalizedTxid(self):
return self._finalized_txid
@finalizedTxid.setter
def finalizedTxid(self, finalized_txid):
if self._finalized_txid != finalized_txid:
self._finalized_txid = finalized_txid
self.finalizedTxidChanged.emit()
warningChanged = pyqtSignal()
@pyqtProperty(str, notify=warningChanged)
def warning(self):
return self._warning
@warning.setter
def warning(self, warning):
if self._warning != warning:
self._warning = warning
self.warningChanged.emit()
validChanged = pyqtSignal()
@pyqtProperty(bool, notify=validChanged)
def valid(self):
return self._valid
@pyqtSlot()
def doUpdate(self):
self.update()
def update_from_tx(self, tx: PartialTransaction):
tx_size = tx.estimated_size()
fee = tx.get_fee()
feerate = Decimal(fee) / tx_size # sat/byte
self.fee = QEAmount(amount_sat=int(fee))
self.feeRate = f'{feerate:.1f}'
self.finalizedTxid = tx.txid()
self.update_inputs_from_tx(tx)
self.update_outputs_from_tx(tx)
self.update_target()
self.update_manual_fields()
def update_manual_fields(self):
if self._fee_method == FeeSlider.FSMethod.MANUAL:
if self._fee_policy.method == FeeMethod.FIXED:
self._userFeerate = self.feeRate
self.userFeerateChanged.emit()
else:
self._userFee = self.fee.satsStr
self.userFeeChanged.emit()
def update_inputs_from_tx(self, tx: Transaction):
inputs = []
for inp in tx.inputs():
# addr = self.wallet.adb.get_txin_address(txin)
addr = inp.address
address_str = '' if addr is None else addr
txin_value = inp.value_sats() if inp.value_sats() else 0
inputs.append({
'address': address_str,
'short_id': str(inp.short_id),
'value': QEAmount(amount_sat=txin_value),
'is_coinbase': inp.is_coinbase_input(),
'is_mine': self._wallet.wallet.is_mine(addr),
'is_change': self._wallet.wallet.is_change(addr),
'prevout_txid': inp.prevout.txid.hex(),
'is_swap': False
})
self.inputs = inputs
def update_outputs_from_tx(self, tx: PartialTransaction):
sm = self._wallet.wallet.lnworker.swap_manager if self._wallet.wallet.lnworker else None
outputs = []
for idx, o in enumerate(tx.outputs()):
outputs.append({
'address': o.get_ui_address_str(),
'value': o.value,
'short_id': str(TxOutpoint(bytes.fromhex(tx.txid()), idx).short_name()) if tx.txid() else '',
'is_mine': self._wallet.wallet.is_mine(o.get_ui_address_str()),
'is_change': self._wallet.wallet.is_change(o.get_ui_address_str()),
'is_billing': self._wallet.wallet.is_billing_address(o.get_ui_address_str()),
'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(o.get_ui_address_str()) or o.get_ui_address_str() == DummyAddress.SWAP,
'is_accounting': self._wallet.wallet.is_accounting_address(o.get_ui_address_str()),
'is_reserve': o.is_utxo_reserve
})
self.outputs = outputs
def update_fee_warning_from_tx(self, *, tx: PartialTransaction, invoice_amt: Optional[int]):
if invoice_amt is None:
invoice_amt = sum([txo.value for txo in tx.outputs() if not txo.is_mine])
if invoice_amt == 0:
invoice_amt = tx.output_value()
fee_warning_tuple = self._wallet.wallet.get_tx_fee_warning(
invoice_amt=invoice_amt, tx_size=tx.estimated_size(), fee=tx.get_fee(), txid=tx.txid())
if fee_warning_tuple:
allow_send, long_warning, short_warning = fee_warning_tuple
self.warning = _('Warning') + ': ' + long_warning
else:
self.warning = ''
def save_config(self):
if self._fee_method == FeeSlider.FSMethod.MANUAL:
if self.fee:
self.userFee = self.fee.satsStr
if self.feeRate:
self.userFeerate = self.feeRate
super().save_config()
class QETxFinalizer(TxFeeSlider):
_logger = get_logger(__name__)
finished = pyqtSignal([bool, bool, bool], arguments=['signed', 'saved', 'complete'])
signError = pyqtSignal([str], arguments=['message'])
def __init__(
self,
parent=None,
*,
make_tx: Callable[[int, FeePolicy], PartialTransaction] = None,
accept: Callable[[PartialTransaction], None] = None,
):
super().__init__(parent)
self.f_make_tx = make_tx
self.f_accept = accept
self._address = ''
self._amount = QEAmount()
self._effectiveAmount = QEAmount()
self._extraFee = QEAmount()
self._canRbf = False
addressChanged = pyqtSignal()
@pyqtProperty(str, notify=addressChanged)
def address(self):
return self._address
@address.setter
def address(self, address):
if self._address != address:
self._address = address
self.addressChanged.emit()
amountChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=amountChanged)
def amount(self):
return self._amount
@amount.setter
def amount(self, amount):
if self._amount != amount:
self._logger.debug(str(amount))
self._amount.copyFrom(amount)
self.amountChanged.emit()
effectiveAmountChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=effectiveAmountChanged)
def effectiveAmount(self):
return self._effectiveAmount
extraFeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=extraFeeChanged)
def extraFee(self):
return self._extraFee
@extraFee.setter
def extraFee(self, extrafee):
if self._extraFee != extrafee:
self._extraFee.copyFrom(extrafee)
self.extraFeeChanged.emit()
canRbfChanged = pyqtSignal()
@pyqtProperty(bool, notify=canRbfChanged)
def canRbf(self):
return self._canRbf
@canRbf.setter
def canRbf(self, canRbf):
if self._canRbf != canRbf:
self._canRbf = canRbf
self.canRbfChanged.emit()
self.rbf = self._canRbf # if we can RbF, we do RbF
@profiler
def make_tx(self, amount):
self._logger.debug(f'make_tx amount={amount}')
if self.f_make_tx:
tx = self.f_make_tx(amount, self._fee_policy)
else:
# default impl
coins = self._wallet.wallet.get_spendable_coins(None)
outputs = [PartialTxOutput.from_address_and_value(self.address, amount)]
tx = self._wallet.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs,
fee_policy=self._fee_policy,
rbf=self._rbf)
self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs())))
return tx
def update(self):
if not self._wallet:
self._logger.debug('wallet not set, ignoring update()')
return
try:
# make unsigned transaction
amount = '!' if self._amount.isMax else self._amount.satsInt
tx = self.make_tx(amount=amount)
except NotEnoughFunds:
self.warning = self._wallet.wallet.get_text_not_enough_funds_mentioning_frozen(for_amount=amount)
self._valid = False
self.validChanged.emit()
return
except NoDynamicFeeEstimates:
self.warning = _('No dynamic fee estimates available')
self._valid = False
self.validChanged.emit()
return
except Exception as e:
self._logger.error(str(e))
self.warning = repr(e)
self._valid = False
self.validChanged.emit()
return
self._tx = tx
amount = self._amount.satsInt if not self._amount.isMax else tx.output_value()
self._effectiveAmount.satsInt = amount
self.effectiveAmountChanged.emit()
self.update_from_tx(tx)
x_fee = run_hook('get_tx_extra_fee', self._wallet.wallet, tx)
if x_fee:
x_fee_address, x_fee_amount = x_fee
self.extraFee = QEAmount(amount_sat=x_fee_amount)
self.update_fee_warning_from_tx(tx=tx, invoice_amt=amount)
if self._amount.isMax and not self.warning:
if reserve_sats := self._wallet.wallet.tx_keeps_ln_utxo_reserve(
tx,
gui_spend_max=self._amount.isMax
):
reserve_str = self._config.format_amount_and_units(reserve_sats)
self.warning = ' '.join([
_('Warning') + ':',
_('Could not spend max: a security reserve of {} was kept for your Lightning channels.')
.format(reserve_str)
])
self._valid = True
self.validChanged.emit()
@pyqtSlot()
def saveOrShow(self):
if not self._valid or not self._tx:
self._logger.debug('no valid tx')
return
saved = False
if self._tx.txid():
if self._wallet.save_tx(self._tx):
saved = True
self.finished.emit(False, saved, self._tx.is_complete())
@pyqtSlot()
def signAndSend(self):
if not self._valid or not self._tx:
self._logger.debug('no valid tx')
return
if self.f_accept:
self.f_accept(self._tx)
return
self._wallet.sign_and_broadcast(self._tx, on_success=partial(self.on_signed_tx, False), on_failure=self.on_sign_failed)
@pyqtSlot()
def sign(self):
if not self._valid or not self._tx:
self._logger.error('no valid tx')
return
self._wallet.sign(self._tx, on_success=partial(self.on_signed_tx, True), on_failure=self.on_sign_failed)
def on_signed_tx(self, save: bool, tx: Transaction):
self._logger.debug('on_signed_tx')
saved = False
if save and self._tx.txid():
if self._wallet.save_tx(self._tx):
saved = True
else:
self._logger.error('Could not save tx')
self.finished.emit(True, saved, tx.is_complete())
def on_sign_failed(self, msg: str = None):
self._logger.debug('on_sign_failed')
self.signError.emit(msg)
@pyqtSlot(result='QVariantList')
def getSerializedTx(self):
txqr = self._tx.to_qr_data()
label = ""
if txid := self._tx.txid():
label = self._wallet.wallet.get_label_for_txid(txid)
return [str(self._tx), txqr[0], txqr[1], label]
class TxMonMixin(QtEventListener):
""" mixin for watching an existing TX based on its txid for verified or removed event.
requires self._wallet to contain a QEWallet instance.
exposes txid qt property.
calls get_tx() once txid is set.
calls tx_verified() and emits txMined signal once tx is verified.
emits txRemoved signal if tx is removed (e.g. replace-by-fee)
"""
txMined = pyqtSignal()
txRemoved = pyqtSignal()
def __init__(self, parent=None):
self._logger.debug('TxMonMixin.__init__')
self._txid = ''
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
def on_destroy(self):
self.unregister_callbacks()
@event_listener
def on_event_verified(self, wallet, txid, info):
if wallet == self._wallet.wallet and txid == self._txid:
self._logger.debug('verified event for our txid %s' % txid)
self.tx_verified()
self.txMined.emit()
@event_listener
def on_event_removed_transaction(self, wallet, tx):
if wallet == self._wallet.wallet and tx.txid() == self._txid:
self._logger.debug('remove tx for our txid %s' % self._txid)
self.tx_removed()
self.txRemoved.emit()
txidChanged = pyqtSignal()
@pyqtProperty(str, notify=txidChanged)
def txid(self):
return self._txid
@txid.setter
def txid(self, txid):
if self._txid != txid:
self._txid = txid
self.get_tx()
self.txidChanged.emit()
# override
def get_tx(self) -> None:
pass
# override
def tx_verified(self) -> None:
pass
# override
def tx_removed(self) -> None:
pass
class QETxRbfFeeBumper(TxFeeSlider, TxMonMixin):
_logger = get_logger(__name__)
def __init__(self, parent=None):
super().__init__(parent)
self._oldfee = QEAmount()
self._oldfee_rate = '0'
self._orig_tx = None
self._rbf = True
self._bump_method = BumpFeeStrategy.PRESERVE_PAYMENT.name
self._bump_methods_available = []
oldfeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=oldfeeChanged)
def oldfee(self):
return self._oldfee
@oldfee.setter
def oldfee(self, oldfee):
if self._oldfee != oldfee:
self._oldfee.copyFrom(oldfee)
self.oldfeeChanged.emit()
oldfeeRateChanged = pyqtSignal()
@pyqtProperty(str, notify=oldfeeRateChanged)
def oldfeeRate(self):
return self._oldfee_rate
@oldfeeRate.setter
def oldfeeRate(self, oldfeerate):
if self._oldfee_rate != oldfeerate:
self._oldfee_rate = oldfeerate
self.oldfeeRateChanged.emit()
bumpMethodChanged = pyqtSignal()
@pyqtProperty(str, notify=bumpMethodChanged)
def bumpMethod(self):
return self._bump_method
@bumpMethod.setter
def bumpMethod(self, bumpmethod: str) -> None:
if self._bump_method != bumpmethod:
self._bump_method = bumpmethod
self.bumpMethodChanged.emit()
self.update()
bumpMethodsAvailableChanged = pyqtSignal()
@pyqtProperty('QVariantList', notify=bumpMethodsAvailableChanged)
def bumpMethodsAvailable(self):
return self._bump_methods_available
def get_tx(self):
assert self._txid
self._orig_tx = self._wallet.wallet.db.get_transaction(self._txid)
assert self._orig_tx
strategies, def_strat_idx = self._wallet.wallet.get_bumpfee_strategies_for_tx(tx=self._orig_tx)
self._bump_methods_available = [{'value': strat.name, 'text': strat.text()} for strat in strategies]
self.bumpMethodsAvailableChanged.emit()
self.bumpMethod = strategies[def_strat_idx].name
if not isinstance(self._orig_tx, PartialTransaction):
self._orig_tx = PartialTransaction.from_tx(self._orig_tx)
if not self._orig_tx.add_info_from_wallet_and_network(wallet=self._wallet.wallet, show_error=self._logger.error):
return
self.update_from_tx(self._orig_tx)
self.oldfee = self.fee
self.oldfeeRate = self.feeRate
self.update()
def tx_verified(self):
self._valid = False
self.validChanged.emit()
self.warning = _('Base transaction has been mined')
def tx_removed(self):
self._valid = False
self.validChanged.emit()
self.warning = _('Base transaction disappeared')
def update(self):
if not self._txid or not self._orig_tx:
# not initialized yet
return
if self._fee_policy.method == FeeMethod.FIXED:
fee = self._fee_policy.value
fee_per_kb = 1000 * fee / self._orig_tx.estimated_size()
else:
fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network)
if fee_per_kb is None:
# dynamic method and no network
self._logger.debug('no fee_per_kb')
self.warning = _('Cannot determine dynamic fees, not connected')
return
new_fee_rate = fee_per_kb / 1000
if new_fee_rate <= float(self._oldfee_rate):
self._valid = False
self.validChanged.emit()
self.warning = _("The new fee rate needs to be higher than the old fee rate.")
return
if not self._orig_tx.add_info_from_wallet_and_network(wallet=self._wallet.wallet, show_error=self._logger.error):
self._valid = False
self.validChanged.emit()
self.warning = _("Transaction is missing info from network")
return
try:
self._tx = self._wallet.wallet.bump_fee(
tx=self._orig_tx,
new_fee_rate=new_fee_rate,
strategy=BumpFeeStrategy[self._bump_method],
)
except CannotBumpFee as e:
self._valid = False
self.validChanged.emit()
self._logger.error(str(e))
self.warning = str(e)
return
else:
self.warning = ''
self._tx.set_rbf(self.rbf)
self.update_from_tx(self._tx)
self.update_fee_warning_from_tx(tx=self._tx, invoice_amt=None)
self._valid = True
self.validChanged.emit()
@pyqtSlot(result=str)
def getNewTx(self):
return str(self._tx)
class QETxCanceller(TxFeeSlider, TxMonMixin):
_logger = get_logger(__name__)
def __init__(self, parent=None):
super().__init__(parent)
self._oldfee = QEAmount()
self._oldfee_rate = '0'
self._orig_tx = None
self._txid = ''
self._rbf = True
oldfeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=oldfeeChanged)
def oldfee(self):
return self._oldfee
@oldfee.setter
def oldfee(self, oldfee):
if self._oldfee != oldfee:
self._oldfee.copyFrom(oldfee)
self.oldfeeChanged.emit()
oldfeeRateChanged = pyqtSignal()
@pyqtProperty(str, notify=oldfeeRateChanged)
def oldfeeRate(self):
return self._oldfee_rate
@oldfeeRate.setter
def oldfeeRate(self, oldfeerate):
if self._oldfee_rate != oldfeerate:
self._oldfee_rate = oldfeerate
self.oldfeeRateChanged.emit()
def get_tx(self):
assert self._txid
self._orig_tx = self._wallet.wallet.db.get_transaction(self._txid)
assert self._orig_tx
if not isinstance(self._orig_tx, PartialTransaction):
self._orig_tx = PartialTransaction.from_tx(self._orig_tx)
if not self._orig_tx.add_info_from_wallet_and_network(wallet=self._wallet.wallet, show_error=self._logger.error):
return
self.update_from_tx(self._orig_tx)
self.oldfee = self.fee
self.oldfeeRate = self.feeRate
self.update()
def tx_verified(self):
self._valid = False
self.validChanged.emit()
self.warning = _('Base transaction has been mined')
def tx_removed(self):
self._valid = False
self.validChanged.emit()
self.warning = _('Base transaction disappeared')
def update(self):
if not self._txid or not self._orig_tx:
# not initialized yet
return
if self._fee_policy.method == FeeMethod.FIXED:
fee = self._fee_policy.value
fee_per_kb = 1000 * fee / self._orig_tx.estimated_size()
else:
fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network)
if fee_per_kb is None:
# dynamic method and no network
self._logger.debug('no fee_per_kb')
self.warning = _('Cannot determine dynamic fees, not connected')
return
new_fee_rate = fee_per_kb / 1000
if new_fee_rate <= float(self._oldfee_rate):
self._valid = False
self.validChanged.emit()
self.warning = _("The new fee rate needs to be higher than the old fee rate.")
return
if fee_per_kb < self._wallet.wallet.relayfee():
self._valid = False
self.validChanged.emit()
self._logger.warning('feerate too low for relay')
self.warning = messages.MSG_RELAYFEE
return
if not self._orig_tx.add_info_from_wallet_and_network(wallet=self._wallet.wallet, show_error=self._logger.error):
self._valid = False
self.validChanged.emit()
self.warning = _("Transaction is missing info from network")
return
try:
self._tx = self._wallet.wallet.dscancel(
tx=self._orig_tx,
new_fee_rate=new_fee_rate,
)
except CannotDoubleSpendTx as e:
self._valid = False
self.validChanged.emit()
self._logger.error(str(e))
self.warning = str(e)
return
else:
self.warning = ''
self._tx.set_rbf(self.rbf)
self.update_from_tx(self._tx)
self.update_fee_warning_from_tx(tx=self._tx, invoice_amt=None)
self._valid = True
self.validChanged.emit()
@pyqtSlot(result=str)
def getNewTx(self):
return str(self._tx)
class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin):
_logger = get_logger(__name__)
def __init__(self, parent=None):
super().__init__(parent)
self._input_amount = QEAmount()
self._output_amount = QEAmount()
self._total_fee = QEAmount()
self._total_fee_rate = 0
self._total_size = 0
self._parent_tx = None
self._new_tx = None
self._parent_tx_size = 0
self._parent_fee = 0
self._max_fee = 0
self._txid = ''
self._rbf = True
totalFeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=totalFeeChanged)
def totalFee(self):
return self._total_fee
@totalFee.setter
def totalFee(self, totalfee):
if self._total_fee != totalfee:
self._total_fee.copyFrom(totalfee)
self.totalFeeChanged.emit()
totalFeeRateChanged = pyqtSignal()
@pyqtProperty(str, notify=totalFeeRateChanged)
def totalFeeRate(self):
return self._total_fee_rate
@totalFeeRate.setter
def totalFeeRate(self, totalfeerate):
if self._total_fee_rate != totalfeerate:
self._total_fee_rate = totalfeerate
self.totalFeeRateChanged.emit()
inputAmountChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=inputAmountChanged)
def inputAmount(self):
return self._input_amount
outputAmountChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=outputAmountChanged)
def outputAmount(self):
return self._output_amount
totalSizeChanged = pyqtSignal()
@pyqtProperty(int, notify=totalSizeChanged)
def totalSize(self):
return self._total_size
def get_tx(self):
assert self._txid
self._parent_tx = self._wallet.wallet.db.get_transaction(self._txid)
assert self._parent_tx
if isinstance(self._parent_tx, PartialTransaction):
self._logger.error('unexpected PartialTransaction')
return
self._parent_tx_size = self._parent_tx.estimated_size()
self._parent_fee = self._wallet.wallet.get_tx_info(self._parent_tx).fee
if self._parent_fee is None:
self._logger.error(_("Can't CPFP: unknown fee for parent transaction."))
self.warning = _("Can't CPFP: unknown fee for parent transaction.")
return
self._new_tx = self._wallet.wallet.cpfp(self._parent_tx, 0)
self._total_size = self._parent_tx_size + self._new_tx.estimated_size()
self.totalSizeChanged.emit()
self._max_fee = self._new_tx.output_value()
self._input_amount.satsInt = self._max_fee
self.update()
def get_child_fee_from_total_feerate(self, fee_per_kb: Optional[int]) -> Optional[int]:
if fee_per_kb is None:
return None
package_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=self._total_size)
return self.get_child_fee_from_total_fee(package_fee)
def get_child_fee_from_total_fee(self, fee: int) -> int:
child_fee = fee - self._parent_fee
child_fee = min(self._max_fee, child_fee)
return child_fee
def tx_verified(self):
self._valid = False
self.validChanged.emit()
self.warning = _('Base transaction has been mined')
def tx_removed(self):
self._valid = False
self.validChanged.emit()
self.warning = _('Base transaction disappeared')
def update(self):
if not self._txid: # not initialized yet
return
assert self._parent_tx
self._valid = False
self.validChanged.emit()
self.warning = ''
if self._parent_fee is None:
self._logger.error(_("Can't CPFP: unknown fee for parent transaction."))
self.warning = _("Can't CPFP: unknown fee for parent transaction.")
return
if self._fee_policy.method == FeeMethod.FIXED:
fee = self.get_child_fee_from_total_fee(self._fee_policy.value)
else:
fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network)
if fee_per_kb is None:
# dynamic method and no network
self._logger.debug('no fee_per_kb')
self.warning = _('Cannot determine dynamic fees, not connected')
return
fee = self.get_child_fee_from_total_feerate(fee_per_kb=fee_per_kb)
if fee is None:
self._logger.warning('no fee')
self.warning = _('No fee')
return
if fee > self._max_fee:
self._logger.warning('max fee exceeded')
self.warning = _('Max fee exceeded')
return
min_child_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=self._wallet.wallet.relayfee(), size=self._total_size)
if fee < min_child_fee:
self._logger.warning('feerate too low for relay')
self.warning = messages.MSG_RELAYFEE
return
comb_fee = fee + self._parent_fee
comb_feerate = comb_fee / self._total_size
if comb_feerate < (self._parent_fee / self._parent_tx_size):
self._logger.debug('combined feerate below parent tx feerate')
self.warning = _('Combined feerate should be greater than the parent tx feerate')
return
self._fee.satsInt = fee
self._output_amount.satsInt = self._max_fee - fee
self.outputAmountChanged.emit()
self._total_fee.satsInt = fee + self._parent_fee
self._total_fee_rate = str(quantize_feerate(comb_feerate))
try:
self._new_tx = self._wallet.wallet.cpfp(self._parent_tx, fee)
except CannotCPFP as e:
self._logger.error(str(e))
self.warning = str(e)
return
child_feerate = fee / self._new_tx.estimated_size()
self.feeRate = str(quantize_feerate(child_feerate))
self.update_inputs_from_tx(self._new_tx)
self.update_outputs_from_tx(self._new_tx)
self.update_target()
self.update_manual_fields()
self._valid = True
self.validChanged.emit()
def update_manual_fields(self):
if self._fee_method == FeeSlider.FSMethod.MANUAL:
if self._fee_policy.method == FeeMethod.FIXED:
self._userFeerate = self._total_fee_rate
self.userFeerateChanged.emit()
else:
self._userFee = self._total_fee.satsStr
self.userFeeChanged.emit()
@pyqtSlot(result=str)
def getNewTx(self):
return str(self._new_tx)
class QETxSweepFinalizer(QETxFinalizer):
_logger = get_logger(__name__)
txinsRetrieved = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._private_keys = ''
self._txins = None
self._amount = QEAmount(is_max=True)
self.txinsRetrieved.connect(self.update)
privateKeysChanged = pyqtSignal()
@pyqtProperty(str, notify=privateKeysChanged)
def privateKeys(self):
return self._private_keys
@privateKeys.setter
def privateKeys(self, private_keys):
if self._private_keys != private_keys:
self._private_keys = private_keys
self.update_privkeys()
self.privateKeysChanged.emit()
def make_sweep_tx(self):
address = self._wallet.wallet.get_receiving_address()
assert self._wallet.wallet.is_mine(address)
assert self._txins is not None
coins, keypairs = copy.deepcopy(self._txins)
outputs = [PartialTxOutput.from_address_and_value(address, value='!')]
tx = self._wallet.wallet.make_unsigned_transaction(
coins=coins, outputs=outputs, fee_policy=self._fee_policy, rbf=self._rbf, is_sweep=True)
self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs())))
tx.sign(keypairs)
return tx
def update_privkeys(self):
privkeys = keystore.get_private_keys(self._private_keys)
def fetch_privkeys_info():
try:
self._txins = self._wallet.wallet.network.run_from_another_thread(sweep_preparations(privkeys, self._wallet.wallet.network))
self._logger.debug(f'txins {self._txins!r}')
except NetworkException as e:
self.warning = _('Network error') + ': ' + str(e)
return
except UserFacingException as e:
self.warning = str(e)
return
self.txinsRetrieved.emit()
threading.Thread(target=fetch_privkeys_info, daemon=True).start()
def update(self):
if not self._wallet:
self._logger.debug('wallet not set, ignoring update()')
return
if not self._private_keys:
self._logger.debug('private keys not set, ignoring update()')
return
if self._txins is None:
self._logger.debug('txins not set, ignoring update()')
return
try:
# make unsigned transaction
tx = self.make_sweep_tx()
except NoDynamicFeeEstimates:
self.warning = _('No dynamic fee estimates available')
self._valid = False
self.validChanged.emit()
return
except NotEnoughFunds:
self.warning = _('Not enough funds')
self._valid = False
self.validChanged.emit()
return
self._tx = tx
amount = tx.output_value()
self._effectiveAmount.satsInt = amount
self.effectiveAmountChanged.emit()
self.update_from_tx(tx)
self.update_fee_warning_from_tx(tx=self._tx, invoice_amt=amount)
self._valid = True
self.validChanged.emit()
self.on_signed_tx(False, tx)
@pyqtSlot()
def send(self):
self._wallet.broadcast(self._tx)
self._wallet.wallet.set_label(self._tx.txid(), _('Sweep transaction'))
================================================
FILE: electrum/gui/qml/qetypes.py
================================================
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum.logging import get_logger
from electrum.i18n import _
class QEAmount(QObject):
"""Container for bitcoin amounts that can be passed around more
easily between python, QML-property and QML-javascript contexts.
Note: millisat and sat amounts are not synchronized!
QML type 'int' in property definitions is 32 bit signed, so will overflow easily
on (milli)satoshi amounts! 'int' in QML-javascript seems to be larger than 32 bit, and
can be used to store q(u)int64 types.
QML 'quint64' and 'qint64' can be used, but be aware these will in some cases be downcast
by QML to 'int' (e.g. when using the property in a property binding, _even_ when a binding
is done between two q(u)int64 properties (at least up until Qt6.4))
"""
_logger = get_logger(__name__)
def __init__(self, *, amount_sat: int = 0, amount_msat: int = 0, is_max: bool = False, from_invoice=None, parent=None):
super().__init__(parent)
self._amount_sat = int(amount_sat) if amount_sat is not None else None
self._amount_msat = int(amount_msat) if amount_msat is not None else None
self._is_max = is_max
if from_invoice:
inv_amt = from_invoice.get_amount_msat()
if inv_amt == '!':
self._is_max = True
elif inv_amt is not None:
self._amount_msat = int(inv_amt)
self._amount_sat = int(from_invoice.get_amount_sat())
valueChanged = pyqtSignal()
@pyqtProperty('qint64', notify=valueChanged)
def satsInt(self):
if self._amount_sat is None: # should normally be defined when accessing this property
self._logger.warning('amount_sat is undefined, returning 0')
return 0
return self._amount_sat
@satsInt.setter
def satsInt(self, sats):
if self._amount_sat != sats:
self._amount_sat = sats
self.valueChanged.emit()
@pyqtProperty('qint64', notify=valueChanged)
def msatsInt(self):
if self._amount_msat is None: # should normally be defined when accessing this property
self._logger.warning('amount_msat is undefined, returning 0')
return 0
return self._amount_msat
@msatsInt.setter
def msatsInt(self, msats):
if self._amount_msat != msats:
self._amount_msat = msats
self.valueChanged.emit()
@pyqtProperty(str, notify=valueChanged)
def satsStr(self):
return str(self._amount_sat)
@pyqtProperty(str, notify=valueChanged)
def msatsStr(self):
return str(self._amount_msat)
@pyqtProperty(bool, notify=valueChanged)
def isMax(self):
return self._is_max
@isMax.setter
def isMax(self, ismax):
if self._is_max != ismax:
self._is_max = ismax
self.valueChanged.emit()
@pyqtProperty(bool, notify=valueChanged)
def isEmpty(self):
return not(self._is_max or self._amount_sat or self._amount_msat)
@pyqtSlot()
def clear(self):
self._amount_sat = 0
self._amount_msat = 0
self._is_max = False
self.valueChanged.emit()
@pyqtSlot('QVariant')
def copyFrom(self, amount):
if not amount:
self._logger.warning('copyFrom with None argument. assuming 0') # TODO
amount = QEAmount()
self.satsInt = amount.satsInt
self.msatsInt = amount.msatsInt
self.isMax = amount.isMax
def __eq__(self, other):
if isinstance(other, QEAmount):
return self._amount_sat == other._amount_sat and self._amount_msat == other._amount_msat and self._is_max == other._is_max
elif isinstance(other, int):
return self._amount_sat == other
elif isinstance(other, str):
return self.satsStr == other
return False
def __str__(self):
s = _('Amount')
if self._is_max:
return '%s(MAX)' % s
return '%s(sats=%d, msats=%d)' % (s, self._amount_sat, self._amount_msat)
def __repr__(self):
return f""
class QEBytes(QObject):
def __init__(self, data: bytes = None, *, parent=None):
super().__init__(parent)
self.data = data
@property
def data(self):
return self._data
@data.setter
def data(self, _data):
self._data = _data
@pyqtProperty(bool)
def isEmpty(self):
return self._data is None or self._data == bytes()
def __str__(self):
return f'{self._data}'
def __repr__(self):
return f""
================================================
FILE: electrum/gui/qml/qewallet.py
================================================
import asyncio
import base64
import queue
import threading
import time
from typing import TYPE_CHECKING, Callable, Optional, Any, Tuple
from functools import partial
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer
from electrum.i18n import _
from electrum.invoices import InvoiceError, PR_PAID, PR_BROADCASTING, PR_BROADCAST
from electrum.logging import get_logger
from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.transaction import PartialTransaction, Transaction
from electrum.util import (
InvalidPassword, event_listener, AddTransactionException, get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates
)
from electrum.lnutil import MIN_FUNDING_SAT
from electrum.plugin import run_hook
from electrum.wallet import Multisig_Wallet
from electrum.crypto import pw_decode_with_version_and_mac
from electrum.fee_policy import FeePolicy, FixedFeePolicy
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .auth import AuthMixin, auth_protect
from .qeaddresslistmodel import QEAddressCoinListModel
from .qechannellistmodel import QEChannelListModel
from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel
from .qetransactionlistmodel import QETransactionListModel
from .qetypes import QEAmount
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
from electrum.invoices import Invoice
class QEWallet(AuthMixin, QObject, QtEventListener):
__instances = []
# this factory method should be used to instantiate QEWallet
# so we have only one QEWallet for each electrum.wallet
@classmethod
def getInstanceFor(cls, wallet):
for i in cls.__instances:
if i.wallet == wallet:
return i
i = QEWallet(wallet)
cls.__instances.append(i)
return i
_logger = get_logger(__name__)
# emitted when wallet wants to display a user notification
# actual presentation should be handled on app or window level
userNotify = pyqtSignal(object, object)
# shared signal for many static wallet properties
dataChanged = pyqtSignal()
balanceChanged = pyqtSignal()
requestStatusChanged = pyqtSignal([str, int], arguments=['key', 'status'])
requestCreateSuccess = pyqtSignal([str], arguments=['key'])
requestCreateError = pyqtSignal([str], arguments=['error'])
invoiceStatusChanged = pyqtSignal([str, int], arguments=['key', 'status'])
invoiceCreateSuccess = pyqtSignal()
invoiceCreateError = pyqtSignal([str, str], arguments=['code', 'error'])
paymentAuthRejected = pyqtSignal()
paymentSucceeded = pyqtSignal([str], arguments=['key'])
paymentFailed = pyqtSignal([str, str], arguments=['key', 'reason'])
requestNewPassword = pyqtSignal()
broadcastSucceeded = pyqtSignal([str], arguments=['txid'])
broadcastFailed = pyqtSignal([str, str, str], arguments=['txid', 'code', 'reason'])
saveTxSuccess = pyqtSignal([str], arguments=['txid'])
saveTxError = pyqtSignal([str, str, str], arguments=['txid', 'code', 'message'])
importChannelBackupFailed = pyqtSignal([str], arguments=['message'])
otpRequested = pyqtSignal()
otpSuccess = pyqtSignal()
otpFailed = pyqtSignal([str, str], arguments=['code', 'message'])
peersUpdated = pyqtSignal()
seedRetrieved = pyqtSignal()
messageSigned = pyqtSignal([str], arguments=['signature'])
_network_signal = pyqtSignal(str, object)
def __init__(self, wallet: 'Abstract_Wallet', parent=None):
super().__init__(parent)
self.wallet = wallet
self._logger = get_logger(f'{__name__}.[{wallet}]')
self._synchronizing = False
self._synchronizing_progress = ''
self._historyModel = None
self._addressCoinModel = None
self._requestModel = None
self._invoiceModel = None
self._channelModel = None
self._lightningbalance = QEAmount()
self._confirmedbalance = QEAmount()
self._unconfirmedbalance = QEAmount()
self._frozenbalance = QEAmount()
self._totalbalance = QEAmount()
self._lightningcanreceive = QEAmount()
self._minchannelfunding = QEAmount(amount_sat=int(MIN_FUNDING_SAT))
self._lightningcansend = QEAmount()
self._lightningbalancefrozen = QEAmount()
self._seed = ''
self._seed_passphrase = ''
self.tx_notification_queue = queue.Queue()
self.tx_notification_last_time = 0
self.notification_timer = QTimer(self)
self.notification_timer.setSingleShot(False)
self.notification_timer.setInterval(500) # msec
self.notification_timer.timeout.connect(self.notify_transactions)
self.sync_progress_timer = QTimer(self)
self.sync_progress_timer.setSingleShot(False)
self.sync_progress_timer.setInterval(2000)
self.sync_progress_timer.timeout.connect(self.update_sync_progress)
# post-construction init in GUI thread
# QMetaObject.invokeMethod(self, 'qt_init', Qt.QueuedConnection)
# 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.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
self.synchronizing = not wallet.is_up_to_date()
synchronizingChanged = pyqtSignal()
@pyqtProperty(bool, notify=synchronizingChanged)
def synchronizing(self):
return self._synchronizing
@synchronizing.setter
def synchronizing(self, synchronizing):
if self._synchronizing != synchronizing:
self._logger.debug(f'SYNC {self._synchronizing} -> {synchronizing}')
self._synchronizing = synchronizing
self.synchronizingChanged.emit()
if synchronizing:
if not self.sync_progress_timer.isActive():
self.update_sync_progress()
self.sync_progress_timer.start()
else:
self.sync_progress_timer.stop()
synchronizingProgressChanged = pyqtSignal()
@pyqtProperty(str, notify=synchronizingProgressChanged)
def synchronizingProgress(self):
return self._synchronizing_progress
@synchronizingProgress.setter
def synchronizingProgress(self, progress):
if self._synchronizing_progress != progress:
self._synchronizing_progress = progress
self._logger.info(progress)
self.synchronizingProgressChanged.emit()
multipleChangeChanged = pyqtSignal()
@pyqtProperty(bool, notify=multipleChangeChanged)
def multipleChange(self):
return self.wallet.multiple_change
@multipleChange.setter
def multipleChange(self, multiple_change):
if self.wallet.multiple_change != multiple_change:
self.wallet.multiple_change = multiple_change
self.wallet.db.put('multiple_change', self.wallet.multiple_change)
self.multipleChangeChanged.emit()
@qt_event_listener
def on_event_request_status(self, wallet, key, status):
if wallet == self.wallet:
self._logger.debug('request status %d for key %s' % (status, key))
self.requestStatusChanged.emit(key, status)
if status == PR_PAID:
# might be new incoming LN payment, update history
# TODO: only update if it was paid over lightning,
# and even then, we can probably just add the payment instead
# of recreating the whole history (expensive)
self.historyModel.initModel(True)
@event_listener
def on_event_invoice_status(self, wallet, key, status):
if wallet == self.wallet:
self._logger.debug(f'invoice status update for key {key} to {status}')
self.invoiceStatusChanged.emit(key, status)
@qt_event_listener
def on_event_new_transaction(self, wallet: 'Abstract_Wallet', tx: Transaction):
if wallet == self.wallet:
self._logger.info(f'new transaction {tx.txid()}')
self.add_tx_notification(tx)
self.addressCoinModel.setDirty()
self.historyModel.setDirty() # assuming wallet.is_up_to_date triggers after
self.balanceChanged.emit()
@qt_event_listener
def on_event_adb_tx_height_changed(self, adb, txid, old_height, new_height):
if adb == self.wallet.adb:
self._logger.info(f'tx_height_changed {txid}. {old_height} -> {new_height}')
self.historyModel.setDirty() # assuming wallet.is_up_to_date triggers after
@qt_event_listener
def on_event_removed_transaction(self, wallet, tx):
# NOTE: this event only triggers once, only for the first deleted tx, when for imported wallets an address
# is deleted along with multiple associated txs
if wallet == self.wallet:
self._logger.info(f'removed transaction {tx.txid()}')
self.addressCoinModel.setDirty()
self.historyModel.setDirty()
self.balanceChanged.emit()
@qt_event_listener
def on_event_wallet_updated(self, wallet):
if wallet == self.wallet:
self._logger.debug('wallet_updated')
self.balanceChanged.emit()
self.historyModel.setDirty()
self.synchronizing = not wallet.is_up_to_date()
if not self.synchronizing:
self.historyModel.initModel() # refresh if dirty
@event_listener
def on_event_channel(self, wallet, channel):
if wallet == self.wallet:
self.balanceChanged.emit()
self.peersUpdated.emit()
@event_listener
def on_event_channels_updated(self, wallet):
if wallet == self.wallet:
self.balanceChanged.emit()
self.peersUpdated.emit()
@qt_event_listener
def on_event_payment_succeeded(self, wallet, key):
if wallet == self.wallet:
self.paymentSucceeded.emit(key)
self.historyModel.initModel(True) # TODO: be less dramatic
@event_listener
def on_event_payment_failed(self, wallet, key, reason):
if wallet == self.wallet:
self.paymentFailed.emit(key, reason)
def on_destroy(self):
self.unregister_callbacks()
def add_tx_notification(self, tx: Transaction):
self._logger.debug('new transaction event')
self.tx_notification_queue.put(tx)
if not self.notification_timer.isActive():
self._logger.debug('starting wallet notification timer')
self.notification_timer.start()
def notify_transactions(self):
if self.tx_notification_queue.qsize() == 0:
self._logger.debug('queue empty, stopping wallet notification timer')
self.notification_timer.stop()
return
if not self.wallet.is_up_to_date():
return # no notifications while syncing
now = time.time()
rate_limit = 20 # seconds
if self.tx_notification_last_time + rate_limit > now:
return
self.tx_notification_last_time = now
self._logger.info("Notifying app about new transactions")
txns = []
while True:
try:
txns.append(self.tx_notification_queue.get_nowait())
except queue.Empty:
break
for notification in self.wallet.get_user_notifications_for_new_txns(txns):
self.userNotify.emit(self.wallet, notification)
def update_sync_progress(self):
if self.wallet.network and self.wallet.network.is_connected():
num_sent, num_answered = self.wallet.adb.get_history_sync_state_details()
self.synchronizingProgress = \
("{} ({}/{})".format(_("Synchronizing..."), num_answered, num_sent))
historyModelChanged = pyqtSignal()
@pyqtProperty(QETransactionListModel, notify=historyModelChanged)
def historyModel(self):
if self._historyModel is None:
self._historyModel = QETransactionListModel(self.wallet)
return self._historyModel
addressCoinModelChanged = pyqtSignal()
@pyqtProperty(QEAddressCoinListModel, notify=addressCoinModelChanged)
def addressCoinModel(self):
if self._addressCoinModel is None:
self._addressCoinModel = QEAddressCoinListModel(self.wallet)
return self._addressCoinModel
requestModelChanged = pyqtSignal()
@pyqtProperty(QERequestListModel, notify=requestModelChanged)
def requestModel(self):
if self._requestModel is None:
self._requestModel = QERequestListModel(self.wallet)
return self._requestModel
invoiceModelChanged = pyqtSignal()
@pyqtProperty(QEInvoiceListModel, notify=invoiceModelChanged)
def invoiceModel(self):
if self._invoiceModel is None:
self._invoiceModel = QEInvoiceListModel(self.wallet)
return self._invoiceModel
channelModelChanged = pyqtSignal()
@pyqtProperty(QEChannelListModel, notify=channelModelChanged)
def channelModel(self):
if self._channelModel is None:
self._channelModel = QEChannelListModel(self.wallet)
return self._channelModel
nameChanged = pyqtSignal()
@pyqtProperty(str, notify=nameChanged)
def name(self):
return self.wallet.basename()
isLightningChanged = pyqtSignal()
@pyqtProperty(bool, notify=isLightningChanged)
def isLightning(self):
return bool(self.wallet.lnworker)
billingInfoChanged = pyqtSignal()
@pyqtProperty('QVariantMap', notify=billingInfoChanged)
def billingInfo(self):
if self.wallet.wallet_type != '2fa':
return {}
return self.wallet.billing_info if self.wallet.billing_info is not None else {}
@pyqtProperty(bool, notify=dataChanged)
def canHaveLightning(self):
return self.wallet.can_have_lightning()
@pyqtProperty(str, notify=dataChanged)
def walletType(self):
return self.wallet.wallet_type
@pyqtProperty(bool, notify=dataChanged)
def isMultisig(self):
return isinstance(self.wallet, Multisig_Wallet)
@pyqtProperty(bool, notify=dataChanged)
def hasSeed(self):
return self.wallet.has_seed()
@pyqtProperty(str, notify=dataChanged)
def seed(self):
return self._seed
@pyqtProperty(str, notify=dataChanged)
def seedPassphrase(self):
return self._seed_passphrase
@pyqtProperty(str, notify=dataChanged)
def txinType(self):
if self.wallet.wallet_type == 'imported':
return self.wallet.txin_type
return self.wallet.get_txin_type(self.wallet.dummy_address())
@pyqtProperty(str, notify=dataChanged)
def seedType(self):
return self.wallet.get_seed_type()
@pyqtProperty(bool, notify=dataChanged)
def isWatchOnly(self):
return self.wallet.is_watching_only()
@pyqtProperty(bool, notify=dataChanged)
def isDeterministic(self):
return self.wallet.is_deterministic()
@pyqtProperty(bool, notify=dataChanged)
def isEncrypted(self):
return self.wallet.storage.is_encrypted()
@pyqtProperty(bool, notify=dataChanged)
def isHardware(self):
return self.wallet.storage.is_encrypted_with_hw_device()
@pyqtProperty('QVariantList', notify=dataChanged)
def keystores(self):
result = []
for k in self.wallet.get_keystores():
result.append({
'keystore_type': k.type,
'watch_only': k.is_watching_only(),
'derivation_prefix': (k.get_derivation_prefix() if k.is_deterministic() else '') or '',
'master_pubkey': (k.get_master_public_key() if k.is_deterministic() else '') or '',
'fingerprint': (k.get_root_fingerprint() if k.is_deterministic() else '') or '',
'num_imported': len(k.keypairs) if k.can_import() else 0,
})
return result
@pyqtProperty(str, notify=dataChanged)
def lightningNodePubkey(self):
return self.wallet.lnworker.node_keypair.pubkey.hex() if self.wallet.lnworker else ''
@pyqtProperty(bool, notify=dataChanged)
def lightningHasDeterministicNodeId(self):
return self.wallet.lnworker.has_deterministic_node_id() if self.wallet.lnworker else False
@pyqtProperty(str, notify=dataChanged)
def derivationPrefix(self):
keystores = self.wallet.get_keystores()
if len(keystores) > 1:
self._logger.debug('multiple keystores not supported yet')
if len(keystores) == 0:
self._logger.debug('no keystore')
return ''
if not self.isDeterministic:
return ''
return keystores[0].get_derivation_prefix()
@pyqtProperty(str, notify=dataChanged)
def masterPubkey(self):
return self.wallet.get_master_public_key()
@pyqtProperty(bool, notify=dataChanged)
def canSignWithoutServer(self):
return self.wallet.can_sign_without_server() if self.wallet.wallet_type == '2fa' else True
@pyqtProperty(bool, notify=dataChanged)
def canSignWithoutCosigner(self):
if isinstance(self.wallet, Multisig_Wallet):
if self.wallet.wallet_type == '2fa': # 2fa is multisig, but it handles cosigning itself
return True
return self.wallet.m == 1
return True
@pyqtProperty(bool, notify=dataChanged)
def canSignMessage(self):
return not isinstance(self.wallet, Multisig_Wallet) and not self.wallet.is_watching_only()
canGetZeroconfChannelChanged = pyqtSignal()
@pyqtProperty(bool, notify=canGetZeroconfChannelChanged)
def canGetZeroconfChannel(self) -> bool:
return self.wallet.lnworker and self.wallet.lnworker.can_get_zeroconf_channel()
@pyqtProperty(QEAmount, notify=balanceChanged)
def frozenBalance(self):
c, u, x = self.wallet.get_frozen_balance()
self._frozenbalance.satsInt = c+x
return self._frozenbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def unconfirmedBalance(self):
self._unconfirmedbalance.satsInt = self.wallet.get_balance()[1]
return self._unconfirmedbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def confirmedBalance(self):
c, u, x = self.wallet.get_balance()
self._confirmedbalance.satsInt = c+x
return self._confirmedbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def lightningBalance(self):
if self.isLightning:
self._lightningbalance.satsInt = int(self.wallet.lnworker.get_balance())
return self._lightningbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def lightningBalanceFrozen(self):
if self.isLightning:
self._lightningbalancefrozen.satsInt = int(self.wallet.lnworker.get_balance(frozen=True))
return self._lightningbalancefrozen
@pyqtProperty(QEAmount, notify=balanceChanged)
def totalBalance(self):
total = self.confirmedBalance.satsInt + self.lightningBalance.satsInt
self._totalbalance.satsInt = total
return self._totalbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def lightningCanSend(self):
if self.isLightning:
self._lightningcansend.satsInt = int(self.wallet.lnworker.num_sats_can_send())
return self._lightningcansend
@pyqtProperty(QEAmount, notify=balanceChanged)
def lightningCanReceive(self):
if self.isLightning:
self._lightningcanreceive.satsInt = int(self.wallet.lnworker.num_sats_can_receive())
return self._lightningcanreceive
@pyqtProperty(bool, notify=balanceChanged)
def isLowReserve(self):
return self.wallet.is_low_reserve()
@pyqtProperty(QEAmount, notify=dataChanged)
def minChannelFunding(self):
return self._minchannelfunding
@pyqtProperty(int, notify=peersUpdated)
def lightningNumPeers(self):
if self.isLightning:
return self.wallet.lnworker.lnpeermgr.num_peers()
return 0
@pyqtSlot()
def enableLightning(self):
self.wallet.init_lightning(password=self.password)
self.isLightningChanged.emit()
self.dataChanged.emit()
@auth_protect(message=_('Sign and send on-chain transaction?'))
def sign_and_broadcast(self, tx, *,
on_success: Callable[[Transaction], None] = None,
on_failure: Callable[[Optional[Any]], None] = None) -> None:
self.do_sign(tx, True, on_success, on_failure)
@auth_protect(message=_('Sign on-chain transaction?'))
def sign(self, tx, *,
on_success: Callable[[Transaction], None] = None,
on_failure: Callable[[Optional[Any]], None] = None) -> None:
self.do_sign(tx, False, on_success, on_failure)
def do_sign(self, tx, broadcast, on_success: Callable[[Transaction], None] = None, on_failure: Callable[[Optional[Any]], None] = None):
# tc_sign_wrapper is only used by 2fa. don't pass on_failure handler, it is handled via otpFailed signal
sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx,
partial(self.on_sign_complete, broadcast, on_success),
partial(self.on_sign_failed, None))
try:
# ignore_warnings=True, because UI checks and asks user confirmation itself
tx = self.wallet.sign_transaction(tx, self.password, ignore_warnings=True)
except BaseException as e:
self._logger.error(f'{e!r}')
if on_failure:
on_failure(str(e))
return
if tx is None:
self._logger.info('did not sign')
if on_failure:
on_failure()
return
if sign_hook:
self._logger.debug('plugin needs to sign tx too')
sign_hook(tx)
return
txid = tx.txid()
self._logger.debug(f'do_sign(), txid={txid}')
if not tx.is_complete():
self._logger.debug('tx not complete')
broadcast = False
if broadcast:
self.broadcast(tx)
else:
# not broadcasted, so refresh history here
self.historyModel.initModel(True)
if on_success:
on_success(tx)
# this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok
def on_sign_complete(self, broadcast, cb: Callable[[Transaction], None] = None, tx: Transaction = None):
self.otpSuccess.emit()
if cb:
cb(tx)
if broadcast:
self.broadcast(tx)
# this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok
def on_sign_failed(self, cb: Callable[[], None] = None, error: str = None):
self.otpFailed.emit('error', error)
if cb:
cb()
def request_otp(self, on_submit):
self._otp_on_submit = on_submit
self.otpRequested.emit()
@pyqtSlot(str)
def submitOtp(self, otp):
def submit_otp_task():
self._otp_on_submit(otp)
threading.Thread(target=submit_otp_task, daemon=True).start()
def broadcast(self, tx):
assert tx.is_complete()
def broadcast_thread():
self.wallet.set_broadcasting(tx, broadcasting_status=PR_BROADCASTING)
try:
self._logger.info('running broadcast in thread')
self.wallet.network.run_from_another_thread(self.wallet.network.broadcast_transaction(tx))
except TxBroadcastError as e:
self._logger.error(repr(e))
self.broadcastFailed.emit(tx.txid(), '', e.get_message_for_gui())
self.wallet.set_broadcasting(tx, broadcasting_status=None)
except BestEffortRequestFailed as e:
self._logger.error(repr(e))
self.broadcastFailed.emit(tx.txid(), '', repr(e))
self.wallet.set_broadcasting(tx, broadcasting_status=None)
else:
self._logger.info('broadcast success')
self.broadcastSucceeded.emit(tx.txid())
self.historyModel.requestRefresh.emit() # via qt thread
self.wallet.set_broadcasting(tx, broadcasting_status=PR_BROADCAST)
threading.Thread(target=broadcast_thread, daemon=True).start()
# TODO: properly catch server side errors, e.g. bad-txns-inputs-missingorspent
def save_tx(self, tx: 'PartialTransaction') -> bool:
assert tx
try:
if not self.wallet.adb.add_transaction(tx):
self.saveTxError.emit(tx.txid(), 'conflict',
_("Transaction could not be saved.") + "\n" + _("It conflicts with current history."))
return False
self.wallet.save_db()
self.saveTxSuccess.emit(tx.txid())
self.historyModel.initModel(True)
return True
except AddTransactionException as e:
self.saveTxError.emit(tx.txid(), 'error', str(e))
return False
def ln_auth_rejected(self):
self.paymentAuthRejected.emit()
@auth_protect(message=_('Pay lightning invoice?'), reject='ln_auth_rejected')
def pay_lightning_invoice(self, invoice: 'Invoice', amount_msat: int = None):
# at this point, the user confirmed the payment, potentially with an override amount.
# we save the invoice with the override amount if there was no amount defined in the invoice.
# (this is similar to what the desktop client does)
#
# Note: amount_msat can be greater than the invoice-specified amount. This is validated and handled
# in lnworker.pay_invoice()
if amount_msat is not None:
assert type(amount_msat) is int
if invoice.get_amount_msat() is None:
invoice.set_amount_msat(amount_msat)
else:
amount_msat = invoice.get_amount_msat()
self.wallet.save_invoice(invoice)
if self._invoiceModel:
self._invoiceModel.initModel()
def pay_thread():
try:
coro = self.wallet.lnworker.pay_invoice(invoice, amount_msat=amount_msat)
fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
fut.result()
except Exception as e:
self._logger.error(f'pay_invoice failed! {e!r}')
self.paymentFailed.emit(invoice.get_id(), str(e))
threading.Thread(target=pay_thread, daemon=True).start()
@pyqtSlot()
def deleteExpiredRequests(self):
keys = self.wallet.delete_expired_requests()
for key in keys:
self.requestModel.delete_invoice(key)
@pyqtSlot(QEAmount, str, int)
@pyqtSlot(QEAmount, str, int, bool)
@pyqtSlot(QEAmount, str, int, bool, bool)
@pyqtSlot(QEAmount, str, int, bool, bool, bool)
def createRequest(self, amount: QEAmount, message: str, expiration: int, lightning: bool = False, reuse_address: bool = False):
self.deleteExpiredRequests()
try:
amount = amount.satsInt
if not lightning:
addr = self.wallet.get_unused_address()
if addr is None:
if reuse_address:
addr = self.wallet.get_receiving_address()
else:
msg = [
_('No address available.'),
_('All your addresses are used in pending requests.'),
_('To see the list, press and hold the Receive button.'),
]
self.requestCreateError.emit(' '.join(msg))
return
else:
addr = None
key = self.wallet.create_request(amount, message, expiration, addr)
except InvoiceError as e:
self.requestCreateError.emit(_('Error creating payment request') + ':\n' + str(e))
return
assert key is not None
self._logger.debug(f'created request with key {key} addr {addr}')
self.addressCoinModel.setDirty()
self.requestModel.add_invoice(self.wallet.get_request(key))
self.requestCreateSuccess.emit(key)
@pyqtSlot(str)
def deleteRequest(self, key: str):
self._logger.debug('delete req %s' % key)
self.wallet.delete_request(key)
self.requestModel.delete_invoice(key)
@pyqtSlot(str)
def deleteInvoice(self, key: str):
self._logger.debug('delete inv %s' % key)
self.wallet.delete_invoice(key)
self.invoiceModel.delete_invoice(key)
@pyqtSlot(str, result=bool)
def verifyPassword(self, password):
if not self.wallet.has_password():
return not bool(password)
try:
self.wallet.check_password(password)
return True
except InvalidPassword as e:
return False
@pyqtSlot(str, result=bool)
def setPassword(self, password):
if password == '':
password = None
storage = self.wallet.storage
# HW wallet not supported yet
if storage.is_encrypted_with_hw_device():
return False
current_password = self.password if self.password != '' else None
try:
self._logger.info('setting new password')
self.wallet.update_password(current_password, password, encrypt_storage=True)
# restore the invariant that all loaded wallets in qml must be unlocked:
self.wallet.unlock(password)
return True
except InvalidPassword as e:
self._logger.exception(repr(e))
return False
@property
def password(self):
return self.wallet.get_unlocked_password()
@pyqtSlot(str)
def importAddresses(self, addresslist):
self.wallet.import_addresses(addresslist.split())
if self._addressCoinModel:
self._addressCoinModel.setDirty()
self.dataChanged.emit()
@pyqtSlot(str)
def importPrivateKeys(self, keyslist):
self.wallet.import_private_keys(keyslist.split(), self.password)
if self._addressCoinModel:
self._addressCoinModel.setDirty()
self.dataChanged.emit()
@pyqtSlot(str)
def importChannelBackup(self, backup_str):
try:
self.wallet.lnworker.import_channel_backup(backup_str)
except Exception as e:
self._logger.debug(f'could not import channel backup: {repr(e)}')
self.importChannelBackupFailed.emit(f'Failed to import backup:\n\n{str(e)}')
@pyqtSlot(str, result=bool)
def isValidChannelBackup(self, backup_str):
try:
assert backup_str.startswith('channel_backup:')
encrypted = backup_str[15:]
xpub = self.wallet.get_fingerprint()
decrypted = pw_decode_with_version_and_mac(encrypted, xpub)
return True
except Exception:
return False
@pyqtSlot()
def requestShowSeed(self):
self.retrieve_seed()
@auth_protect(method='wallet')
def retrieve_seed(self):
try:
self._seed = self.wallet.get_seed(self.password)
self._seed_passphrase = self.wallet.keystore.get_passphrase(self.password)
self.seedRetrieved.emit()
except Exception:
self._seed = ''
self._seed_passphrase = ''
self.dataChanged.emit()
@pyqtSlot(str, result='QVariantList')
def getSerializedTx(self, txid):
tx = self.wallet.db.get_transaction(txid)
txqr = tx.to_qr_data()
return [str(tx), txqr[0], txqr[1]]
@pyqtSlot(result='QVariantMap')
def getBalancesForPiechart(self):
p_bal = self.wallet.get_balances_for_piechart()
return {
'confirmed': p_bal.confirmed,
'unconfirmed': p_bal.unconfirmed,
'unmatured': p_bal.unmatured,
'frozen': p_bal.frozen,
'lightning': int(p_bal.lightning),
'f_lightning': int(p_bal.lightning_frozen),
'total': int(p_bal.total())
}
@pyqtSlot(str, result=bool)
def isAddressMine(self, addr):
return self.wallet.is_mine(addr)
@pyqtSlot(str, str)
@auth_protect(message=_("Sign message?"))
def signMessage(self, address, message):
sig = self.wallet.sign_message(address, message, self.password)
result = base64.b64encode(sig).decode('ascii')
self.messageSigned.emit(result)
def determine_max(self, *, mktx: Callable[[FeePolicy], PartialTransaction]) -> Tuple[Optional[int], Optional[str]]:
# TODO: merge with SendTab.spend_max() and move to backend wallet
amount = message = None
try:
try:
fee_policy = FeePolicy(self.wallet.config.FEE_POLICY)
tx = mktx(fee_policy)
except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
# Check if we had enough funds excluding fees,
# if so, still provide opportunity to set lower fees.
fee_policy = FixedFeePolicy(0)
tx = mktx(fee_policy)
amount = tx.output_value()
except NotEnoughFunds as e:
self._logger.debug(str(e))
message = self.wallet.get_text_not_enough_funds_mentioning_frozen(for_amount='!')
return amount, message
================================================
FILE: electrum/gui/qml/qewizard.py
================================================
import os
from typing import TYPE_CHECKING
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum.base_crash_reporter import send_exception_to_crash_reporter
from electrum.logging import get_logger
from electrum import mnemonic
from electrum.wizard import NewWalletWizard, ServerConnectWizard, TermsOfUseWizard
from electrum.storage import WalletStorage, StorageReadWriteError
from electrum.util import WalletFileException, UserFacingException
from electrum.gui import messages
if TYPE_CHECKING:
from electrum.gui.qml.qedaemon import QEDaemon
from electrum.plugin import Plugins
class QEAbstractWizard(QObject):
""" Concrete subclasses of QEAbstractWizard must also inherit from a concrete AbstractWizard subclass.
QEAbstractWizard forms the base for all QML GUI based wizards, while AbstractWizard defines
the base for non-gui wizard flow navigation functionality.
"""
_logger = get_logger(__name__)
def __init__(self, parent=None):
QObject.__init__(self, parent)
@pyqtSlot(result=str)
def startWizard(self):
self.start()
return self._current.view
@pyqtSlot(str, result=str)
def viewToComponent(self, view):
return self.navmap[view]['gui'] + '.qml'
@pyqtSlot('QJSValue', result='QVariant')
def submit(self, wizard_data):
wdata = wizard_data.toVariant()
view = self.resolve_next(self._current.view, wdata)
return { 'view': view.view, 'wizard_data': view.wizard_data }
@pyqtSlot(result='QVariant')
def prev(self):
viewstate = self.resolve_prev()
return viewstate.wizard_data
@pyqtSlot('QJSValue', result=bool)
def isLast(self, wizard_data):
wdata = wizard_data.toVariant()
return self.is_last_view(self._current.view, wdata)
class QENewWalletWizard(NewWalletWizard, QEAbstractWizard):
createError = pyqtSignal([str], arguments=["error"])
createSuccess = pyqtSignal()
def __init__(self, daemon: 'QEDaemon', plugins: 'Plugins', parent=None):
NewWalletWizard.__init__(self, daemon.daemon, plugins)
QEAbstractWizard.__init__(self, parent)
self._qedaemon = daemon
self._path = None
self._password = None
# attach view names and accept handlers
self.navmap_merge({
'wallet_name': {'gui': 'WCWalletName'},
'wallet_type': {'gui': 'WCWalletType'},
'keystore_type': {'gui': 'WCKeystoreType'},
'create_seed': {'gui': 'WCCreateSeed'},
'create_ext': {'gui': 'WCEnterExt'},
'confirm_seed': {'gui': 'WCConfirmSeed'},
'confirm_ext': {'gui': 'WCConfirmExt'},
'have_seed': {'gui': 'WCHaveSeed'},
'have_ext': {'gui': 'WCEnterExt'},
'script_and_derivation': {'gui': 'WCScriptAndDerivation'},
'have_master_key': {'gui': 'WCHaveMasterKey'},
'multisig': {'gui': 'WCMultisig'},
'multisig_cosigner_keystore': {'gui': 'WCCosignerKeystore'},
'multisig_cosigner_key': {'gui': 'WCHaveMasterKey'},
'multisig_cosigner_seed': {'gui': 'WCHaveSeed'},
'multisig_cosigner_have_ext': {'gui': 'WCEnterExt'},
'multisig_cosigner_script_and_derivation': {'gui': 'WCScriptAndDerivation'},
'imported': {'gui': 'WCImport'},
'wallet_password': {'gui': 'WCWalletPassword'}
})
pathChanged = pyqtSignal()
@pyqtProperty(str, notify=pathChanged)
def path(self):
return self._path
@path.setter
def path(self, path):
self._path = path
self.pathChanged.emit()
def is_single_password(self):
return self._qedaemon.singlePasswordEnabled
@pyqtSlot('QJSValue', result=bool)
def hasDuplicateMasterKeys(self, js_data):
self._logger.info('Checking for duplicate masterkeys')
data = js_data.toVariant()
return self.has_duplicate_masterkeys(data)
@pyqtSlot('QJSValue', result=bool)
def hasHeterogeneousMasterKeys(self, js_data):
self._logger.info('Checking for heterogeneous masterkeys')
data = js_data.toVariant()
return self.has_heterogeneous_masterkeys(data)
@pyqtSlot(str, str, result=bool)
def isMatchingSeed(self, seed, seed_again):
return mnemonic.is_matching_seed(seed=seed, seed_again=seed_again)
@pyqtSlot(str, str, str, result='QVariantMap')
def verifySeed(self, seed, seed_variant, wallet_type='standard'):
seed_valid, seed_type, validation_message, can_passphrase = self.validate_seed(seed, seed_variant, wallet_type)
return {
'valid': seed_valid,
'type': seed_type,
'message': validation_message,
'can_passphrase': can_passphrase
}
def _wallet_path_from_wallet_name(self, wallet_name: str) -> str:
return os.path.join(self._qedaemon.daemon.config.get_datadir_wallet_path(), wallet_name)
@pyqtSlot(str, result=bool)
def isValidNewWalletName(self, wallet_name: str) -> bool:
if not wallet_name:
return False
if self._qedaemon.availableWallets.wallet_name_exists(wallet_name):
return False
wallet_path = self._wallet_path_from_wallet_name(wallet_name)
# note: we should probably restrict wallet names to be alphanumeric (plus underscore, etc)...
# try to prevent sketchy path traversals:
for forbidden_char in ("/", "\\", ):
if forbidden_char in wallet_name:
return False
if os.path.basename(wallet_name) != wallet_name:
return False
# validate that the path looks sane to the filesystem:
try:
temp_storage = WalletStorage(wallet_path)
except (StorageReadWriteError, WalletFileException) as e:
return False
except Exception as e:
self._logger.exception("")
return False
if temp_storage.file_exists():
return False
return True
@pyqtSlot('QJSValue', bool, str)
def createStorage(self, js_data, single_password_enabled, single_password):
self._logger.info('Creating wallet from wizard data')
data = js_data.toVariant()
if single_password_enabled and single_password:
data['encrypt'] = True
data['password'] = single_password
path = self._wallet_path_from_wallet_name(data['wallet_name'])
try:
self.create_storage(path, data)
# minimally populate self after create
self._password = data['password']
self.path = path
self.createSuccess.emit()
except UserFacingException as e:
self._logger.debug(f"createStorage errored: {e!r}", exc_info=True)
self.createError.emit(str(e))
except Exception as e:
self._logger.exception(f"createStorage errored: {e!r}")
send_exception_to_crash_reporter(e)
class QEServerConnectWizard(ServerConnectWizard, QEAbstractWizard):
def __init__(self, daemon: 'QEDaemon', parent=None):
ServerConnectWizard.__init__(self, daemon.daemon)
QEAbstractWizard.__init__(self, parent)
# attach view names
self.navmap_merge({
'welcome': {'gui': 'WCWelcome'},
'proxy_config': {'gui': 'WCProxyConfig'},
'server_config': {'gui': 'WCServerConfig'},
})
class QETermsOfUseWizard(TermsOfUseWizard, QEAbstractWizard):
def __init__(self, daemon: 'QEDaemon', parent=None):
TermsOfUseWizard.__init__(self, daemon.daemon.config)
QEAbstractWizard.__init__(self, parent)
# attach gui classes
self.navmap_merge({
'terms_of_use': {'gui': 'WCTermsOfUseRequest'},
})
termsOfUseChanged = pyqtSignal()
@pyqtProperty(str, notify=termsOfUseChanged)
def termsOfUseText(self):
return messages.MSG_TERMS_OF_USE
================================================
FILE: electrum/gui/qml/util.py
================================================
import math
import re
from time import time
from typing import Tuple
from electrum.i18n import _
# return delay in msec when expiry time string should be updated
# returns 0 when expired or expires > 1 day away (no updates needed)
def status_update_timer_interval(exp):
# very roughly according to util.age
exp_in = int(exp - time())
exp_in_min = int(exp_in/60)
interval = 0
if exp_in < 0:
interval = 0
elif exp_in_min < 2:
interval = 1000
elif exp_in_min < 90:
interval = 1000 * 60
elif exp_in_min < 1440:
interval = 1000 * 60 * 60
return interval
# TODO: copied from qt password_dialog.py, move to common code
def check_password_strength(password: str) -> Tuple[int, str]:
"""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 min(3, int(score)), password_strength[min(3, int(score))]
================================================
FILE: electrum/gui/qt/__init__.py
================================================
#!/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 signal
import sys
import threading
from typing import Optional, TYPE_CHECKING, List, Sequence, Union
try:
import PyQt6
import PyQt6.QtGui
except Exception as e:
from electrum import GuiImportError
raise GuiImportError(
"Error: Could not import PyQt6. On Linux systems, "
"you may try 'sudo apt-get install python3-pyqt6'") from e
from PyQt6.QtGui import QGuiApplication, QCursor
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QWidget, QMenu, QMessageBox, QDialog, QToolTip
from PyQt6.QtCore import QObject, pyqtSignal, QTimer, Qt
import PyQt6.QtCore as QtCore
from electrum.logging import Logger, get_logger
_logger = get_logger(__name__)
try:
# Preload QtMultimedia at app start, if available.
# We use QtMultimedia on some platforms for camera-handling, and
# lazy-loading it later led to some crashes. Maybe due to bugs in PyQt. (see #7725)
from PyQt6.QtMultimedia import QMediaDevices; del QMediaDevices
except (ImportError, RuntimeError) as e:
_logger.debug(f"failed to import optional dependency: PyQt6.QtMultimedia. exc={repr(e)}")
pass # failure is ok; it is an optional dependency.
else:
_logger.debug(f"successfully preloaded optional dependency: PyQt6.QtMultimedia")
if sys.platform == "linux" and os.environ.get("APPIMAGE"):
# For AppImage, we default to xcb qt backend, for better support of older system.
# qt6 normally defaults to QT_QPA_PLATFORM=wayland instead of QT_QPA_PLATFORM=xcb.
# However, the wayland QPA plugin requires libwayland-client0>=1.19, which is too new
# for debian 11 or ubuntu 20.04. So instead, we default to the X11 integration (and not wayland).
# see https://bugreports.qt.io/browse/QTBUG-114635
os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
from electrum.i18n import _, set_language
from electrum.plugin import run_hook
from electrum.util import (UserCancelled, profiler, send_exception_to_crash_reporter,
WalletFileException, get_new_wallet_name, InvalidPassword,
standardize_path, UserFacingException)
from electrum.wallet import Wallet, Abstract_Wallet
from electrum.wallet_db import WalletRequiresSplit, WalletRequiresUpgrade, WalletUnfinished
from electrum.gui import BaseElectrumGui
from electrum.simple_config import SimpleConfig
from electrum.wizard import WizardViewState
from electrum.keystore import load_keystore
from electrum.bip32 import is_xprv
from electrum import constants
from electrum.gui.common_qt.i18n import ElectrumTranslator
from electrum.gui.messages import TERMS_OF_USE_LATEST_VERSION
from .util import (read_QIcon, ColorScheme, custom_message_box, MessageBoxMixin, WWLabel,
set_windows_os_screenshot_protection_drm_flag)
from .main_window import ElectrumWindow
from .network_dialog import NetworkDialog
from .stylesheet_patcher import patch_qt_stylesheet
from .lightning_dialog import LightningDialog
from .exception_window import Exception_Hook
from .wizard.server_connect import QEServerConnectWizard
from .wizard.wallet import QENewWalletWizard
if TYPE_CHECKING:
from electrum.daemon import Daemon
from electrum.plugin import Plugins
class OpenFileEventFilter(QObject):
def __init__(self, windows: Sequence[ElectrumWindow]):
self.windows = windows
super(OpenFileEventFilter, self).__init__()
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.Type.FileOpen:
if len(self.windows) >= 1:
self.windows[0].set_payment_identifier(event.url().toString())
return True
return False
class ScreenshotProtectionEventFilter(QObject):
def __init__(self):
super().__init__()
def eventFilter(self, obj, event):
if (
event.type() == QtCore.QEvent.Type.Show
and isinstance(obj, QWidget)
and obj.isWindow()
):
set_windows_os_screenshot_protection_drm_flag(obj)
return False
class QElectrumApplication(QApplication):
new_window_signal = pyqtSignal(str, object)
quit_signal = pyqtSignal()
refresh_tabs_signal = pyqtSignal()
refresh_amount_edits_signal = pyqtSignal()
update_status_signal = pyqtSignal()
update_fiat_signal = pyqtSignal()
alias_received_signal = pyqtSignal()
class ElectrumGui(BaseElectrumGui, Logger):
network_dialog: Optional['NetworkDialog']
lightning_dialog: Optional['LightningDialog']
@profiler
def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
BaseElectrumGui.__init__(self, config=config, daemon=daemon, plugins=plugins)
Logger.__init__(self)
self.logger.info(f"Qt GUI starting up... Qt={QtCore.QT_VERSION_STR}, PyQt={QtCore.PYQT_VERSION_STR}")
# Uncomment this call to verify objects are being properly
# GC-ed when windows are closed
#plugins.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer,
# ElectrumWindow], interval=5)])
if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"):
QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts)
if hasattr(QGuiApplication, 'setDesktopFileName'):
QGuiApplication.setDesktopFileName('electrum')
QGuiApplication.setApplicationName("Electrum")
self.gui_thread = threading.current_thread()
self.windows = [] # type: List[ElectrumWindow]
self.open_file_efilter = OpenFileEventFilter(self.windows)
self.app = QElectrumApplication(sys.argv)
self.app.installEventFilter(self.open_file_efilter)
self.screenshot_protection_efilter = ScreenshotProtectionEventFilter()
if sys.platform in ['win32', 'windows'] and self.config.GUI_QT_SCREENSHOT_PROTECTION:
self.app.installEventFilter(self.screenshot_protection_efilter)
# explicitly set 'AA_DontShowIconsInMenus' False so menu icons are shown on MacOS
self.app.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus, on=False)
self.app.setWindowIcon(read_QIcon("electrum.png"))
self.translator = ElectrumTranslator()
self.app.installTranslator(self.translator)
self._cleaned_up = False
self.network_dialog = None
self.lightning_dialog = None
self._num_wizards_in_progress = 0
self._num_wizards_lock = threading.Lock()
self.dark_icon = self.config.GUI_QT_DARK_TRAY_ICON
self.tray = None # type: Optional[QSystemTrayIcon]
self._init_tray()
self.app.new_window_signal.connect(self.start_new_window)
self.app.quit_signal.connect(self.app.quit, Qt.ConnectionType.QueuedConnection)
# maybe set dark theme
self._default_qtstylesheet = self.app.styleSheet()
self.reload_app_stylesheet()
def _init_tray(self):
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()
def reload_app_stylesheet(self):
"""Set the Qt stylesheet and custom colors according to the user-selected
light/dark theme.
TODO this can ~almost be used to change the theme at runtime (without app restart),
except for util.ColorScheme... widgets already created with colors set using
ColorSchemeItem.as_stylesheet() and similar will not get recolored.
See e.g.
- in Coins tab, the color for "frozen" UTXOs, or
- in TxDialog, the receiving/change address colors
"""
use_dark_theme = self.config.GUI_QT_COLOR_THEME == 'dark'
if use_dark_theme:
try:
import qdarkstyle
self.app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt6())
except BaseException as e:
use_dark_theme = False
self.logger.warning(f'Error setting dark theme: {repr(e)}')
else:
self.app.setStyleSheet(self._default_qtstylesheet)
# Apply any necessary stylesheet patches
patch_qt_stylesheet(use_dark_theme=use_dark_theme)
# 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):
if not self.tray:
return
# 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()
network = self.daemon.network
m.addAction(_("Plugins"), self.show_plugins_dialog)
if network:
m.addAction(_("Network"), self.show_network_dialog)
if network and network.lngossip:
m.addAction(_("Lightning Network"), self.show_lightning_dialog)
for window in self.windows:
name = window.wallet.basename()
submenu = m.addMenu(name)
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.app.quit)
def tray_icon(self):
if self.dark_icon:
return read_QIcon('electrum_dark_icon.png')
else:
return read_QIcon('electrum_light_icon.png')
def toggle_tray_icon(self):
if not self.tray:
return
self.dark_icon = not self.dark_icon
self.config.GUI_QT_DARK_TRAY_ICON = self.dark_icon
self.tray.setIcon(self.tray_icon())
def tray_activated(self, reason):
if reason == QSystemTrayIcon.ActivationReason.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 _cleanup_before_exit(self):
if self._cleaned_up:
return
self._cleaned_up = True
self.app.new_window_signal.disconnect()
self.app.removeEventFilter(self.open_file_efilter)
self.open_file_efilter = None
# it is save to remove the filter, even if it has not been installed
self.app.removeEventFilter(self.screenshot_protection_efilter)
self.screenshot_protection_efilter = None
# If there are still some open windows, try to clean them up.
for window in list(self.windows):
window.close()
window.clean_up()
if self.network_dialog:
self.network_dialog.close()
self.network_dialog = None
if self.lightning_dialog:
self.lightning_dialog.close()
self.lightning_dialog = None
# clipboard persistence. see http://www.mail-archive.com/pyqt@riverbankcomputing.com/msg17328.html
event = QtCore.QEvent(QtCore.QEvent.Type.Clipboard)
self.app.sendEvent(self.app.clipboard(), event)
if self.tray:
self.tray.hide()
self.tray.deleteLater()
self.tray = None
def _maybe_quit_if_no_windows_open(self) -> None:
"""Check if there are any open windows and decide whether we should quit."""
# keep daemon running after close
if self.config.get('daemon'):
return
# check if a wizard is in progress
with self._num_wizards_lock:
if self._num_wizards_in_progress > 0 or len(self.windows) > 0:
return
self.app.quit()
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_lightning_dialog(self):
if not self.daemon.network.has_channel_db():
return
if not self.lightning_dialog:
self.lightning_dialog = LightningDialog(self)
self.lightning_dialog.bring_to_top()
def show_plugins_dialog(self):
from .plugins_dialog import PluginsDialog
d = PluginsDialog(self.config, self.plugins, gui_object=self)
d.exec()
def show_network_dialog(self, proxy_tab=False):
if self.network_dialog:
self.network_dialog.show(proxy_tab=proxy_tab)
self.network_dialog.raise_()
return
self.network_dialog = NetworkDialog(network=self.daemon.network)
self.network_dialog.show(proxy_tab=proxy_tab)
def _create_window_for_wallet(self, wallet):
w = ElectrumWindow(self, wallet)
self.windows.append(w)
self.build_tray_menu()
w.warn_if_testnet()
w.warn_if_watching_only()
return w
def count_wizards_in_progress(func):
def wrapper(self: 'ElectrumGui', *args, **kwargs):
with self._num_wizards_lock:
self._num_wizards_in_progress += 1
try:
return func(self, *args, **kwargs)
finally:
with self._num_wizards_lock:
self._num_wizards_in_progress -= 1
self._maybe_quit_if_no_windows_open()
return wrapper
def get_window_for_wallet(self, wallet):
for window in self.windows:
if window.wallet.storage.path == wallet.storage.path:
return window
@count_wizards_in_progress
def start_new_window(
self,
path,
uri: Optional[str],
*,
app_is_starting: bool = False,
force_wizard: bool = False,
) -> Optional[ElectrumWindow]:
"""Raises the window for the wallet if it is open.
Otherwise, opens the wallet and creates a new window for it.
Warning: the returned window might be for a completely different wallet
than the provided path, as we allow user interaction to change the path.
"""
if not self.has_accepted_terms_of_use():
self.logger.warning(f"terms of use not accepted, rejecting to start new window")
return None
wallet = None
# Try to open with daemon first. If this succeeds, there won't be a wizard at all
# (the wallet main window will appear directly).
if not force_wizard:
try:
wallet = self.daemon.load_wallet(path, None)
except FileNotFoundError:
pass # open with wizard below
except InvalidPassword:
pass # open with wizard below
except WalletRequiresSplit:
pass # open with wizard below
except WalletRequiresUpgrade:
pass # open with wizard below
except WalletUnfinished:
pass # open with wizard below
except Exception as e:
self.logger.exception('')
err_text = str(e) if isinstance(e, WalletFileException) else repr(e)
custom_message_box(icon=QMessageBox.Icon.Warning,
parent=None,
title=_('Error'),
text=_('Cannot load wallet') + ' (1):\n' + err_text)
if isinstance(e, WalletFileException) and e.should_report_crash:
send_exception_to_crash_reporter(e)
# if app is starting, still let wizard appear
if not app_is_starting:
return
# Open a wizard window. This lets the user e.g. enter a password, or select
# a different wallet.
try:
if not wallet:
wallet = self._start_wizard_to_select_or_create_wallet(path)
if not wallet:
return
window = self.get_window_for_wallet(wallet)
# create or raise window
if not window:
window = self._create_window_for_wallet(wallet)
except UserCancelled:
return
except Exception as e:
self.logger.exception('')
if isinstance(e, UserFacingException) \
or isinstance(e, WalletFileException) and not e.should_report_crash:
err_text = str(e) if isinstance(e, WalletFileException) else repr(e)
custom_message_box(icon=QMessageBox.Icon.Warning,
parent=None,
title=_('Error'),
text=_('Cannot load wallet') + '(2) :\n' + err_text)
else:
send_exception_to_crash_reporter(e)
if app_is_starting:
# If we raise in this context, there are no more fallbacks, we will shut down.
# Worst case scenario, we might have gotten here without user interaction,
# in which case, if we raise now without user interaction, the same sequence of
# events is likely to repeat when the user restarts the process.
# So we play it safe: clear path, clear uri, force a wizard to appear.
try:
wallet_dir = os.path.dirname(path)
filename = get_new_wallet_name(wallet_dir)
except OSError:
path = self.config.get_fallback_wallet_path()
else:
path = os.path.join(wallet_dir, filename)
return self.start_new_window(path, uri=None, force_wizard=True)
return
window.bring_to_top()
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive)
window.activateWindow()
if uri:
window.show_send_tab()
window.send_tab.set_payment_identifier(uri)
return window
def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]:
wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path)
result = wizard.exec()
# TODO: use dialog.open() instead to avoid new event loop spawn?
self.logger.info(f'wizard dialog exec result={result}')
if result == QDialog.DialogCode.Rejected:
self.logger.info('wizard dialog cancelled by user')
return
d = wizard.get_wizard_data()
if d['wallet_is_open']:
wallet_path = standardize_path(d['wallet_name'])
for window in self.windows:
if window.wallet.storage.path == wallet_path:
return window.wallet
raise Exception('found by wizard but not here?!')
if not d['wallet_exists']:
self.logger.info('about to create wallet')
wizard.create_storage()
if d['wallet_type'] == '2fa' and 'x3' not in d:
return
wallet_file = wizard.path
else:
wallet_file = d['wallet_name']
password = d.get('password') or None # convert '' to None
try:
wallet = self.daemon.load_wallet(wallet_file, password, upgrade=True)
return wallet
except WalletRequiresSplit as e:
wizard.run_split(wallet_file, e._split_data)
return
except WalletUnfinished as e:
# wallet creation is not complete, 2fa online phase
db = e._wallet_db
action = db.get_action()
assert action[1] == 'accept_terms_of_use', 'only support for resuming trustedcoin split setup'
k1 = load_keystore(db, 'x1')
if password is not None:
xprv = k1.get_master_private_key(password)
else:
xprv = db.get('x1')['xprv']
if not is_xprv(xprv):
xprv = k1
_wiz_data_updates = {
'wallet_name': wallet_file,
'xprv1': xprv,
'xpub1': db.get('x1')['xpub'],
'xpub2': db.get('x2')['xpub'],
}
data = {**d, **_wiz_data_updates}
wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path,
start_viewstate=WizardViewState('trustedcoin_tos', data, {}))
result = wizard.exec()
if result == QDialog.DialogCode.Rejected:
self.logger.info('wizard dialog cancelled by user')
return
db.put('x3', wizard.get_wizard_data()['x3'])
db.write_and_force_consolidation() # TODO API for db is a bit weird: there should be a close method
wallet = self.daemon.load_wallet(wallet_file, password, upgrade=True)
return wallet
def close_window(self, window: ElectrumWindow):
if window in self.windows:
self.windows.remove(window)
self.build_tray_menu()
run_hook('on_close_window', window)
if window.should_stop_wallet_on_close:
self.daemon.stop_wallet(window.wallet.storage.path)
def reload_window(self, window):
# bump counter so that we do not close the app
self._num_wizards_in_progress += 1
wallet = window.wallet
window.should_stop_wallet_on_close = False
window.close()
self._create_window_for_wallet(wallet)
self._num_wizards_in_progress -= 1
def reload_windows(self):
for window in list(self.windows):
self.reload_window(window)
def has_accepted_terms_of_use(self) -> bool:
if self.config.TERMS_OF_USE_ACCEPTED >= TERMS_OF_USE_LATEST_VERSION\
or constants.net.NET_NAME == "regtest":
return True
return False
def ask_terms_of_use(self):
"""Ask the user to accept the terms of use.
This is only shown if the user has not accepted them yet.
"""
if self.has_accepted_terms_of_use():
return
from electrum.gui.qt.wizard.terms_of_use import QETermsOfUseWizard
dialog = QETermsOfUseWizard(self.config, self.app)
result = dialog.exec()
if result == QDialog.DialogCode.Rejected:
self.logger.info('terms of use not accepted by user')
raise UserCancelled()
def init_network(self):
"""Start the network, including showing a first-start network dialog if config does not exist."""
if self.daemon.network:
# first-start network-setup
if not self.config.cv.NETWORK_AUTO_CONNECT.is_set():
dialog = QEServerConnectWizard(self.config, self.app, self.plugins, self.daemon)
result = dialog.exec()
if result == QDialog.DialogCode.Rejected:
self.logger.info('network wizard dialog cancelled by user')
raise UserCancelled()
# start network
self.daemon.start_network()
def main(self):
# setup Ctrl-C handling and tear-down code first, so that user can easily exit whenever
self.app.setQuitOnLastWindowClosed(False) # so _we_ can decide whether to quit
self.app.lastWindowClosed.connect(self._maybe_quit_if_no_windows_open)
self.app.aboutToQuit.connect(self._cleanup_before_exit)
signal.signal(signal.SIGINT, lambda *args: self.app.quit())
# hook for crash reporter
Exception_Hook.maybe_setup(config=self.config)
# start network, and maybe show first-start network-setup
try:
self.ask_terms_of_use()
self.init_network()
except UserCancelled:
return
except Exception as e:
self.logger.exception('')
return
# start wizard to select/create wallet
path = self.config.get_wallet_path()
try:
if not self.start_new_window(path, self.config.get('url'), app_is_starting=True):
return
except Exception as e:
self.logger.error("error loading wallet (or creating window for it)")
send_exception_to_crash_reporter(e)
# Let Qt event loop start properly so that crash reporter window can appear.
# We will shutdown when the user closes that window, via lastWindowClosed signal.
# main loop
self.logger.info("starting Qt main loop")
self.app.exec()
# on some platforms the exec_ call may not return, so use _cleanup_before_exit
def stop(self):
self.logger.info('closing GUI')
self.app.quit_signal.emit()
@classmethod
def version_info(cls):
ret = {
"qt.version": QtCore.QT_VERSION_STR,
"pyqt.version": QtCore.PYQT_VERSION_STR,
}
if hasattr(PyQt6, "__path__"):
ret["pyqt.path"] = ", ".join(PyQt6.__path__ or [])
return ret
def do_copy(self, text: str, *, title: str = None) -> None:
self.app.clipboard().setText(text)
message = _("Text copied to Clipboard") if title is None else _("{} copied to Clipboard").format(title)
# tooltip cannot be displayed immediately when called from a menu; wait 200ms
QTimer.singleShot(200, lambda: QToolTip.showText(QCursor.pos(), message, None))
def standalone_exception_dialog(exception: Union[str, BaseException]) -> None:
app = QApplication.instance()
if not app:
app = QApplication([])
msg_box = QMessageBox()
msg_box.setWindowTitle(_("Error starting Electrum"))
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setText(_("An error occurred") + ":")
msg_box.setInformativeText(str(exception))
# Add detailed traceback if available
if hasattr(exception, "__traceback__"):
import traceback
detailed_text = ''.join(traceback.format_exception(
type(exception), exception, exception.__traceback__)
)
msg_box.setDetailedText(detailed_text)
msg_box.exec()
================================================
FILE: electrum/gui/qt/address_dialog.py
================================================
#!/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.
from typing import TYPE_CHECKING
from PyQt6.QtWidgets import QVBoxLayout, QLabel
from electrum.i18n import _
from .util import WindowModalDialog, ButtonsLineEdit, ShowQRLineEdit, Buttons, CloseButton
from .history_list import HistoryList, HistoryModel
from .qrtextedit import ShowQRTextEdit
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class AddressHistoryModel(HistoryModel):
def __init__(self, window: 'ElectrumWindow', address):
super().__init__(window)
self.address = address
def get_domain(self):
return [self.address]
def should_include_lightning_payments(self) -> bool:
return False
class AddressDialog(WindowModalDialog):
def __init__(self, window: 'ElectrumWindow', address: str, *, parent=None):
if parent is None:
parent = window
WindowModalDialog.__init__(self, parent, _("Address"))
self.address = address
self.window = window
self.config = window.config
self.wallet = window.wallet
self.app = window.app
self.saved = True
self.setMinimumWidth(700)
vbox = QVBoxLayout()
self.setLayout(vbox)
vbox.addWidget(QLabel(_("Address") + ":"))
self.addr_e = ShowQRLineEdit(self.address, self.config, title=_("Address"))
vbox.addWidget(self.addr_e)
try:
pubkeys = self.wallet.get_public_keys(address)
except BaseException as e:
pubkeys = None
if pubkeys:
vbox.addWidget(QLabel(_("Public keys") + ':'))
for pubkey in pubkeys:
pubkey_e = ShowQRLineEdit(pubkey, self.config, title=_("Public Key"))
vbox.addWidget(pubkey_e)
redeem_script = self.wallet.get_redeem_script(address)
if redeem_script:
vbox.addWidget(QLabel(_("Redeem Script") + ':'))
redeem_e = ShowQRTextEdit(text=redeem_script, config=self.config)
redeem_e.addCopyButton()
vbox.addWidget(redeem_e)
witness_script = self.wallet.get_witness_script(address)
if witness_script:
vbox.addWidget(QLabel(_("Witness Script") + ':'))
witness_e = ShowQRTextEdit(text=witness_script, config=self.config)
witness_e.addCopyButton()
vbox.addWidget(witness_e)
address_path_str = self.wallet.get_address_path_str(address)
if address_path_str:
vbox.addWidget(QLabel(_("Derivation path") + ':'))
der_path_e = ButtonsLineEdit(address_path_str)
der_path_e.addCopyButton()
der_path_e.setReadOnly(True)
vbox.addWidget(der_path_e)
addr_hist_model = AddressHistoryModel(self.window, self.address)
self.hw = HistoryList(self.window, addr_hist_model)
self.hw.num_tx_label = QLabel('')
addr_hist_model.set_view(self.hw)
vbox.addWidget(self.hw.num_tx_label)
vbox.addWidget(self.hw)
vbox.addLayout(Buttons(CloseButton(self)))
self.format_amount = self.window.format_amount
addr_hist_model.refresh('address dialog constructor')
def show_qr(self):
text = self.address
try:
self.window.show_qrcode(text, 'Address', parent=self)
except Exception as e:
self.show_message(repr(e))
================================================
FILE: electrum/gui/qt/address_list.py
================================================
#!/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 enum
from enum import IntEnum
from typing import TYPE_CHECKING, Optional
from PyQt6.QtCore import Qt, QPersistentModelIndex, QModelIndex
from PyQt6.QtGui import QStandardItemModel, QStandardItem, QFont
from PyQt6.QtWidgets import QAbstractItemView, QComboBox, QMenu
from electrum.i18n import _
from electrum.util import block_explorer_URL, profiler
from electrum.plugin import run_hook
from electrum.bitcoin import is_address
from electrum.wallet import InternalAddressCorruption
from electrum.simple_config import SimpleConfig
from .util import MONOSPACE_FONT, ColorScheme, webopen
from .my_treeview import MyTreeView, MySortModel
from ..messages import MSG_FREEZE_ADDRESS
if TYPE_CHECKING:
from .main_window import ElectrumWindow
from electrum.wallet import AddressIndexGeneric
class AddressUsageStateFilter(IntEnum):
ALL = 0
UNUSED = 1
FUNDED = 2
USED_AND_EMPTY = 3
FUNDED_OR_UNUSED = 4
def ui_text(self) -> str:
return {
self.ALL: _('All status'),
self.UNUSED: _('Unused'),
self.FUNDED: _('Funded'),
self.USED_AND_EMPTY: _('Used'),
self.FUNDED_OR_UNUSED: _('Funded or Unused'),
}[self]
class AddressTypeFilter(IntEnum):
ALL = 0
RECEIVING = 1
CHANGE = 2
def ui_text(self) -> str:
return {
self.ALL: _('All types'),
self.RECEIVING: _('Receiving'),
self.CHANGE: _('Change'),
}[self]
class AddressList(MyTreeView):
class Columns(MyTreeView.BaseColumnsEnum):
TYPE = enum.auto()
ADDRESS = enum.auto()
LABEL = enum.auto()
COIN_BALANCE = enum.auto()
FIAT_BALANCE = enum.auto()
NUM_TXS = enum.auto()
filter_columns = [Columns.TYPE, Columns.ADDRESS, Columns.LABEL, Columns.COIN_BALANCE]
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000
ROLE_ADDRESS_STR = Qt.ItemDataRole.UserRole + 1001
key_role = ROLE_ADDRESS_STR
def __init__(self, main_window: 'ElectrumWindow'):
super().__init__(
main_window=main_window,
stretch_column=self.Columns.LABEL,
editable_columns=[self.Columns.LABEL],
)
self.wallet = self.main_window.wallet
self._address_list_status = 0 # type: int
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSortingEnabled(True)
self.show_change = AddressTypeFilter.ALL # type: AddressTypeFilter
self.show_used = AddressUsageStateFilter.ALL # type: AddressUsageStateFilter
self.change_button = QComboBox(self)
self.change_button.currentIndexChanged.connect(self.toggle_change)
for addr_type in AddressTypeFilter.__members__.values(): # type: AddressTypeFilter
self.change_button.addItem(addr_type.ui_text())
self.used_button = QComboBox(self)
self.used_button.currentIndexChanged.connect(self.toggle_used)
for addr_usage_state in AddressUsageStateFilter.__members__.values(): # type: AddressUsageStateFilter
self.used_button.addItem(addr_usage_state.ui_text())
self.std_model = QStandardItemModel(self)
self.proxy = MySortModel(self, sort_role=self.ROLE_SORT_ORDER)
self.proxy.setSourceModel(self.std_model)
self.setModel(self.proxy)
self.update()
self.sortByColumn(self.Columns.TYPE, Qt.SortOrder.AscendingOrder)
if self.config:
self.configvar_show_toolbar = self.config.cv.GUI_QT_ADDRESSES_TAB_SHOW_TOOLBAR
def on_double_click(self, idx):
addr = self.get_role_data_for_current_item(col=0, role=self.ROLE_ADDRESS_STR)
self.main_window.show_address(addr)
def create_toolbar(self, config: 'SimpleConfig'):
toolbar, menu = self.create_toolbar_with_menu('')
self.num_addr_label = toolbar.itemAt(0).widget()
self._toolbar_checkbox = menu.addToggle(_("Show Filter"), lambda: self.toggle_toolbar())
menu.addConfig(config.cv.FX_SHOW_FIAT_BALANCE_FOR_ADDRESSES, callback=self.main_window.app.update_fiat_signal.emit)
hbox = self.create_toolbar_buttons()
toolbar.insertLayout(1, hbox)
return toolbar
def should_show_fiat(self):
return self.main_window.fx and self.main_window.fx.is_enabled() and self.config.FX_SHOW_FIAT_BALANCE_FOR_ADDRESSES
def get_toolbar_buttons(self):
return self.change_button, self.used_button
def on_hide_toolbar(self):
self.show_change = AddressTypeFilter.ALL # type: AddressTypeFilter
self.show_used = AddressUsageStateFilter.ALL # type: AddressUsageStateFilter
self.update()
def refresh_headers(self):
if self.should_show_fiat():
ccy = self.main_window.fx.get_currency()
else:
ccy = _('Fiat')
headers = {
self.Columns.TYPE: _('Type'),
self.Columns.ADDRESS: _('Address'),
self.Columns.LABEL: _('Label'),
self.Columns.COIN_BALANCE: _('Balance'),
self.Columns.FIAT_BALANCE: ccy + ' ' + _('Balance'),
self.Columns.NUM_TXS: _('Tx'),
}
self.update_headers(headers)
def toggle_change(self, state: int):
if state == self.show_change:
return
self.show_change = AddressTypeFilter(state)
self.update()
def toggle_used(self, state: int):
if state == self.show_used:
return
self.show_used = AddressUsageStateFilter(state)
self.update()
@profiler
def update(self):
if self.maybe_defer_update():
return
current_address = self.get_role_data_for_current_item(col=0, role=self.ROLE_ADDRESS_STR)
if self.show_change == AddressTypeFilter.RECEIVING:
addr_list = self.wallet.get_receiving_addresses()
elif self.show_change == AddressTypeFilter.CHANGE:
addr_list = self.wallet.get_change_addresses()
else:
addr_list = self.wallet.get_addresses()
self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change
self.std_model.clear()
self.refresh_headers()
set_address = None
num_shown = 0
new_address_list_status = 0
self.addresses_beyond_gap_limit = self.wallet.get_all_known_addresses_beyond_gap_limit()
for address in addr_list:
c, u, x = self.wallet.get_addr_balance(address)
balance = c + u + x
is_used_and_empty = self.wallet.adb.is_used(address) and balance == 0
if self.show_used == AddressUsageStateFilter.UNUSED and (balance or is_used_and_empty):
continue
if self.show_used == AddressUsageStateFilter.FUNDED and balance == 0:
continue
if self.show_used == AddressUsageStateFilter.USED_AND_EMPTY and not is_used_and_empty:
continue
if self.show_used == AddressUsageStateFilter.FUNDED_OR_UNUSED and is_used_and_empty:
continue
num_shown += 1
new_address_list_status = hash((new_address_list_status, address, c, u, x, is_used_and_empty))
labels = [""] * len(self.Columns)
labels[self.Columns.ADDRESS] = address
address_item = [QStandardItem(e) for e in labels]
# align text and set fonts
for i, item in enumerate(address_item):
item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter)
if i not in (self.Columns.TYPE, self.Columns.LABEL):
item.setFont(QFont(MONOSPACE_FONT))
self.set_editability(address_item)
address_item[self.Columns.FIAT_BALANCE].setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
# setup column 0
if self.wallet.is_change(address):
address_item[self.Columns.TYPE].setText(_('change'))
address_item[self.Columns.TYPE].setBackground(ColorScheme.YELLOW.as_color(True))
else:
address_item[self.Columns.TYPE].setText(_('receiving'))
address_item[self.Columns.TYPE].setBackground(ColorScheme.GREEN.as_color(True))
address_item[self.Columns.TYPE].setData(address, self.ROLE_ADDRESS_STR)
address_path = self.wallet.get_address_index(address)
address_item[self.Columns.TYPE].setData(self.address_index_as_sortable_key(address_path), self.ROLE_SORT_ORDER)
address_path_str = self.wallet.get_address_path_str(address)
if address_path_str is not None:
address_item[self.Columns.TYPE].setToolTip(address_path_str)
# add item
count = self.std_model.rowCount()
self.std_model.insertRow(count, address_item)
self.refresh_row(address, count)
address_idx = self.std_model.index(count, self.Columns.LABEL)
if address == current_address:
set_address = QPersistentModelIndex(address_idx)
self.set_current_idx(set_address)
# show/hide columns
if self.should_show_fiat():
self.showColumn(self.Columns.FIAT_BALANCE)
else:
self.hideColumn(self.Columns.FIAT_BALANCE)
if self._address_list_status != new_address_list_status:
self._address_list_status = new_address_list_status
self.close_menu()
self.filter()
self.proxy.setDynamicSortFilter(True)
# update counter
self.num_addr_label.setText(_("{} addresses").format(num_shown))
@staticmethod
def address_index_as_sortable_key(address_index: Optional['AddressIndexGeneric']) -> str:
if isinstance(address_index, str): # pubkey hex
return address_index
elif address_index is None:
return ""
else:
return "".join(f"{i:08x}" for i in address_index)
def refresh_row(self, key, row):
assert row is not None
address = key
label = self.wallet.get_label_for_address(address)
num = self.wallet.adb.get_address_history_len(address)
c, u, x = self.wallet.get_addr_balance(address)
balance = c + u + x
balance_text = self.main_window.format_amount(balance, whitespaces=True)
balance_text_nots = self.main_window.format_amount(balance, whitespaces=False, add_thousands_sep=False)
# create item
fx = self.main_window.fx
if self.should_show_fiat():
rate = fx.exchange_rate()
fiat_balance_str = fx.value_str(balance, rate, add_thousands_sep=True)
fiat_balance_str_nots = fx.value_str(balance, rate, add_thousands_sep=False)
else:
fiat_balance_str = ''
fiat_balance_str_nots = ''
address_item = [self.std_model.item(row, col) for col in self.Columns]
address_item[self.Columns.LABEL].setText(label)
address_item[self.Columns.COIN_BALANCE].setText(balance_text)
address_item[self.Columns.COIN_BALANCE].setData(balance, self.ROLE_SORT_ORDER)
address_item[self.Columns.COIN_BALANCE].setData(balance_text_nots, self.ROLE_CLIPBOARD_DATA)
address_item[self.Columns.FIAT_BALANCE].setText(fiat_balance_str)
address_item[self.Columns.FIAT_BALANCE].setData(balance, self.ROLE_SORT_ORDER)
address_item[self.Columns.FIAT_BALANCE].setData(fiat_balance_str_nots, self.ROLE_CLIPBOARD_DATA)
address_item[self.Columns.NUM_TXS].setText("%d"%num)
c = ColorScheme.BLUE.as_color(True) if self.wallet.is_frozen_address(address) else self._default_bg_brush
address_item[self.Columns.ADDRESS].setBackground(c)
if address in self.addresses_beyond_gap_limit:
address_item[self.Columns.ADDRESS].setBackground(ColorScheme.RED.as_color(True))
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.selected_in_column(self.Columns.ADDRESS)
if not selected:
return
multi_select = len(selected) > 1
addrs = [self.item_from_index(item).text() for item in selected]
menu = QMenu()
menu.setToolTipsVisible(True)
if not multi_select:
idx = self.indexAt(position)
if not idx.isValid():
return
item = self.item_from_index(idx)
if not item:
return
addr = addrs[0]
menu.addAction(_('Details'), lambda: self.main_window.show_address(addr))
addr_column_title = self.std_model.horizontalHeaderItem(self.Columns.LABEL).text()
addr_idx = idx.sibling(idx.row(), self.Columns.LABEL)
self.add_copy_menu(menu, idx)
persistent = QPersistentModelIndex(addr_idx)
menu.addAction(_("Edit {}").format(addr_column_title), lambda p=persistent: self.edit(QModelIndex(p)))
#menu.addAction(_("Request payment"), lambda: self.main_window.receive_at(addr))
if self.wallet.can_export():
menu.addAction(_("Private key"), lambda: self.main_window.show_private_key(addr))
if not is_multisig and not self.wallet.is_watching_only():
menu.addAction(_("Sign/verify message"), lambda: self.main_window.sign_verify_message(addr))
menu.addAction(_("Encrypt/decrypt message"), lambda: self.main_window.encrypt_message(addr))
if can_delete:
menu.addAction(_("Remove from wallet"), lambda: self.main_window.remove_address(addr))
addr_URL = block_explorer_URL(self.config, 'addr', addr)
if addr_URL:
menu.addAction(_("View on block explorer"), lambda: webopen(addr_URL))
if not self.wallet.is_frozen_address(addr):
act = menu.addAction(_("Freeze"), lambda: self.main_window.set_frozen_state_of_addresses([addr], True))
else:
act = menu.addAction(_("Unfreeze"), lambda: self.main_window.set_frozen_state_of_addresses([addr], False))
act.setToolTip(MSG_FREEZE_ADDRESS)
else:
# multiple items selected
act = menu.addAction(_("Freeze"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, True))
act.setToolTip(MSG_FREEZE_ADDRESS)
act = menu.addAction(_("Unfreeze"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, False))
act.setToolTip(MSG_FREEZE_ADDRESS)
coins = self.wallet.get_spendable_coins(addrs)
if coins:
if self.main_window.utxo_list.are_in_coincontrol(coins):
menu.addAction(_("Remove from coin control"), lambda: self.main_window.utxo_list.remove_from_coincontrol(coins))
else:
menu.addAction(_("Add to coin control"), lambda: self.main_window.utxo_list.add_to_coincontrol(coins))
run_hook('receive_menu', menu, addrs, self.wallet)
self.open_menu(menu, position)
def place_text_on_clipboard(self, text: str, *, title: str = None) -> None:
if is_address(text):
try:
self.wallet.check_address_for_corruption(text)
except InternalAddressCorruption as e:
self.main_window.show_error(str(e))
raise
super().place_text_on_clipboard(text, title=title)
def get_edit_key_from_coordinate(self, row, col):
if col != self.Columns.LABEL:
return None
return self.get_role_data_from_coordinate(row, 0, role=self.ROLE_ADDRESS_STR)
def on_edited(self, idx, edit_key, *, text):
self.wallet.set_label(edit_key, text)
self.main_window.history_model.refresh('address label edited')
self.main_window.utxo_list.update()
self.main_window.update_completions()
================================================
FILE: electrum/gui/qt/amountedit.py
================================================
# -*- coding: utf-8 -*-
from decimal import Decimal
from typing import Union
from PyQt6.QtCore import pyqtSignal, Qt, QSize
from PyQt6.QtGui import QPainter
from PyQt6.QtWidgets import (QLineEdit, QStyle, QStyleOptionFrame, QSizePolicy)
from .util import char_width_in_lineedit, ColorScheme
from electrum.util import (format_satoshis_plain, decimal_point_to_base_unit_name,
FEERATE_PRECISION, quantize_feerate, DECIMAL_POINT, UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE)
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
_NOT_GIVEN = object() # sentinel value
class FreezableLineEdit(QLineEdit):
frozen = pyqtSignal()
def setFrozen(self, b):
self.setReadOnly(b)
self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '')
self.frozen.emit()
def isFrozen(self):
return self.isReadOnly()
class SizedFreezableLineEdit(FreezableLineEdit):
def __init__(self, *, width: int, parent=None):
super().__init__(parent)
self._width = width
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self.setMaximumWidth(width)
def sizeHint(self) -> QSize:
sh = super().sizeHint()
return QSize(self._width, sh.height())
class AmountEdit(SizedFreezableLineEdit):
shortcut = pyqtSignal()
def __init__(self, base_unit, is_int=False, parent=None, *, max_amount=None):
# This seems sufficient for hundred-BTC amounts with 8 decimals
width = 16 * char_width_in_lineedit()
super().__init__(width=width, parent=parent)
self.base_unit = base_unit
self.textChanged.connect(self.numbify)
self.is_int = is_int
self.is_shortcut = False
self.extra_precision = 0
self.max_amount = max_amount
def decimal_point(self):
return 8
def max_precision(self):
return self.decimal_point() + self.extra_precision
def numbify(self):
text = self.text().strip()
if text == '!':
self.shortcut.emit()
return
pos = self.cursorPosition()
chars = '0123456789'
if not self.is_int: chars += DECIMAL_POINT
s = ''.join([i for i in text if i in chars])
if not self.is_int:
if DECIMAL_POINT in s:
p = s.find(DECIMAL_POINT)
s = s.replace(DECIMAL_POINT, '')
s = s[:p] + DECIMAL_POINT + s[p:p+self.max_precision()]
if self.max_amount:
if (amt := self._get_amount_from_text(s)) and amt >= self.max_amount:
s = self._get_text_from_amount(self.max_amount)
self.setText(s)
# setText sets Modified to False. Instead we want to remember
# if updates were because of user modification.
self.setModified(self.hasFocus())
self.setCursorPosition(pos)
def paintEvent(self, event):
QLineEdit.paintEvent(self, event)
if self.base_unit:
panel = QStyleOptionFrame()
self.initStyleOption(panel)
textRect = self.style().subElementRect(QStyle.SubElement.SE_LineEditContents, panel, self)
textRect.adjust(2, 0, -10, 0)
painter = QPainter(self)
painter.setPen(ColorScheme.GRAY.as_color())
painter.drawText(textRect, int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), self.base_unit())
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
try:
text = text.replace(DECIMAL_POINT, '.')
return (int if self.is_int else Decimal)(text)
except Exception:
return None
def get_amount(self) -> Union[None, Decimal, int]:
amt = self._get_amount_from_text(str(self.text()))
if self.max_amount and amt and amt >= self.max_amount:
return self.max_amount
return amt
def _get_text_from_amount(self, amount) -> str:
return "%d" % amount
def setAmount(self, amount):
text = self._get_text_from_amount(amount)
self.setText(text)
class BTCAmountEdit(AmountEdit):
def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=_NOT_GIVEN):
if max_amount is _NOT_GIVEN:
max_amount = TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN
AmountEdit.__init__(self, self._base_unit, is_int, parent, max_amount=max_amount)
self.decimal_point = decimal_point
def _base_unit(self):
return decimal_point_to_base_unit_name(self.decimal_point())
def _get_amount_from_text(self, text):
# returns amt in satoshis
try:
text = text.replace(DECIMAL_POINT, '.')
x = Decimal(text)
except Exception:
return None
# scale it to max allowed precision, make it an int
power = pow(10, self.max_precision())
max_prec_amount = int(power * x)
# if the max precision is simply what unit conversion allows, just return
if self.max_precision() == self.decimal_point():
return max_prec_amount
# otherwise, scale it back to the expected unit
amount = Decimal(max_prec_amount) / pow(10, self.max_precision()-self.decimal_point())
return Decimal(amount) if not self.is_int else int(amount)
def _get_text_from_amount(self, amount_sat):
text = format_satoshis_plain(amount_sat, decimal_point=self.decimal_point())
text = text.replace('.', DECIMAL_POINT)
return text
def setAmount(self, amount_sat):
if amount_sat is None:
self.setText(" ") # Space forces repaint in case units changed
else:
text = self._get_text_from_amount(amount_sat)
self.setText(text)
self.setFrozen(self.isFrozen()) # re-apply styling, as it is nuked by setText (?)
self.repaint() # macOS hack for #6269
class FeerateEdit(BTCAmountEdit):
def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=_NOT_GIVEN):
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
self.extra_precision = FEERATE_PRECISION
def _base_unit(self):
return UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE
def _get_amount_from_text(self, text):
sat_per_byte_amount = super()._get_amount_from_text(text)
return quantize_feerate(sat_per_byte_amount)
def _get_text_from_amount(self, amount):
amount = quantize_feerate(amount)
return super()._get_text_from_amount(amount)
================================================
FILE: electrum/gui/qt/balance_dialog.py
================================================
#!/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 typing import TYPE_CHECKING
from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QWidget, QGridLayout, QToolButton, QPushButton
from PyQt6.QtCore import QRect, Qt
from PyQt6.QtGui import QPen, QPainter, QPixmap
from electrum.i18n import _
from electrum.gui.messages import MSG_LN_UTXO_RESERVE
from .util import Buttons, CloseButton, WindowModalDialog, ColorScheme, font_height, AmountLabel, icon_path
if TYPE_CHECKING:
from .main_window import ElectrumWindow
from electrum.wallet import Abstract_Wallet
# Todo:
# show lightning funds that are not usable
# pie chart mouse interactive, to prepare a swap
COLOR_CONFIRMED = Qt.GlobalColor.green
COLOR_UNCONFIRMED = Qt.GlobalColor.red
COLOR_UNMATURED = Qt.GlobalColor.magenta
COLOR_FROZEN = ColorScheme.BLUE.as_color(True)
COLOR_LIGHTNING = Qt.GlobalColor.yellow
COLOR_FROZEN_LIGHTNING = Qt.GlobalColor.cyan
class PieChartObject:
def paintEvent(self, event):
pen = QPen(Qt.GlobalColor.gray, 1, Qt.PenStyle.SolidLine)
qp = QPainter()
qp.begin(self)
qp.setPen(pen)
qp.setRenderHint(QPainter.RenderHint.Antialiasing)
qp.setBrush(Qt.GlobalColor.gray)
total = sum([x[2] for x in self._list])
if total == 0:
return
alpha = 0
s = 0
for name, color, amount in self._list:
qp.setBrush(color)
if amount == 0:
continue
elif amount == total:
qp.drawEllipse(self.R)
else:
delta = int(16 * 360 * amount/total)
qp.drawPie(self.R, alpha, delta)
alpha += delta
qp.end()
class PieChartWidget(QWidget, PieChartObject):
def __init__(self, size, l):
QWidget.__init__(self)
self.size = size
self.R = QRect(0, 0, self.size, self.size)
self.setGeometry(self.R)
self.setMinimumWidth(self.size)
self.setMaximumWidth(self.size)
self.setMinimumHeight(self.size)
self.setMaximumHeight(self.size)
self._list = l # list[ (name, color, amount)]
self.update()
def update_list(self, l):
self._list = l
self.update()
class BalanceToolButton(QToolButton, PieChartObject):
def __init__(self):
QToolButton.__init__(self)
self._list = []
self._update_size()
self._warning = False
@property
def has_warning(self) -> bool:
return bool(self._warning)
def update_list(self, l, warning: bool):
self._warning = warning
self._list = l
self.update()
def setText(self, text):
# this is a hack
QToolButton.setText(self, ' ' + text)
def paintEvent(self, event):
QToolButton.paintEvent(self, event)
if not self._warning:
PieChartObject.paintEvent(self, event)
else:
pixmap = QPixmap(icon_path("warning.png"))
qp = QPainter()
qp.begin(self)
qp.drawPixmap(self.R, pixmap)
qp.end()
def resizeEvent(self, e):
super().resizeEvent(e)
self._update_size()
def _update_size(self):
size = round(font_height(self) * 1.1)
self.R = QRect(6, 3, size, size)
class LegendWidget(QWidget):
size = 20
def __init__(self, color):
QWidget.__init__(self)
self.color = color
self.R = QRect(0, 0, self.size, int(self.size*0.75))
self.setGeometry(self.R)
self.setMinimumWidth(self.size)
self.setMaximumWidth(self.size)
self.setMinimumHeight(self.size)
self.setMaximumHeight(self.size)
def paintEvent(self, event):
pen = QPen(Qt.GlobalColor.gray, 1, Qt.PenStyle.SolidLine)
qp = QPainter()
qp.begin(self)
qp.setPen(pen)
qp.setRenderHint(QPainter.RenderHint.Antialiasing)
qp.setBrush(self.color)
qp.drawRect(self.R)
qp.end()
class BalanceDialog(WindowModalDialog):
def __init__(self, parent: 'ElectrumWindow', *, wallet: 'Abstract_Wallet'):
WindowModalDialog.__init__(self, parent, _("Wallet Balance"))
self.wallet = wallet
self.window = parent
self.config = parent.config
self.fx = parent.fx
p_bal = self.wallet.get_balances_for_piechart()
confirmed = p_bal.confirmed
unconfirmed = p_bal.unconfirmed
unmatured = p_bal.unmatured
frozen = p_bal.frozen
lightning = p_bal.lightning
f_lightning = p_bal.lightning_frozen
frozen_str = self.config.format_amount_and_units(frozen)
confirmed_str = self.config.format_amount_and_units(confirmed)
unconfirmed_str = self.config.format_amount_and_units(unconfirmed)
unmatured_str = self.config.format_amount_and_units(unmatured)
lightning_str = self.config.format_amount_and_units(lightning)
f_lightning_str = self.config.format_amount_and_units(f_lightning)
frozen_fiat_str = self.fx.format_amount_and_units(frozen) if self.fx else ''
confirmed_fiat_str = self.fx.format_amount_and_units(confirmed) if self.fx else ''
unconfirmed_fiat_str = self.fx.format_amount_and_units(unconfirmed) if self.fx else ''
unmatured_fiat_str = self.fx.format_amount_and_units(unmatured) if self.fx else ''
lightning_fiat_str = self.fx.format_amount_and_units(lightning) if self.fx else ''
f_lightning_fiat_str = self.fx.format_amount_and_units(f_lightning) if self.fx else ''
piechart = PieChartWidget(
max(120, 9 * font_height()),
[
(_('Frozen'), COLOR_FROZEN, frozen),
(_('Unmatured'), COLOR_UNMATURED, unmatured),
(_('Unconfirmed'), COLOR_UNCONFIRMED, unconfirmed),
(_('On-chain'), COLOR_CONFIRMED, confirmed),
(_('Lightning'), COLOR_LIGHTNING, lightning),
(_('Lightning frozen'), COLOR_FROZEN_LIGHTNING, f_lightning),
]
)
vbox = QVBoxLayout()
if self.wallet.is_low_reserve():
reserve_str = self.config.format_amount_and_units(self.config.LN_UTXO_RESERVE)
hbox = QHBoxLayout()
msg = _('Warning') + ': ' + MSG_LN_UTXO_RESERVE.format(reserve_str)
label = QLabel(msg)
label.setWordWrap(True)
logo = QLabel('')
logo.setPixmap(
QPixmap(icon_path("warning.png")).scaledToWidth(
25, mode=Qt.TransformationMode.SmoothTransformation)
)
logo.setMaximumWidth(28)
hbox.addWidget(logo)
hbox.addWidget(label)
vbox.addLayout(hbox)
vbox.addWidget(piechart)
grid = QGridLayout()
#grid.addWidget(QLabel(_("Onchain") + ':'), 0, 1)
#grid.addWidget(QLabel(onchain_str), 0, 2, alignment=Qt.AlignmentFlag.AlignRight)
#grid.addWidget(QLabel(onchain_fiat_str), 0, 3, alignment=Qt.AlignmentFlag.AlignRight)
if frozen:
grid.addWidget(LegendWidget(COLOR_FROZEN), 0, 0)
grid.addWidget(QLabel(_("Frozen") + ':'), 0, 1)
grid.addWidget(AmountLabel(frozen_str), 0, 2, alignment=Qt.AlignmentFlag.AlignRight)
grid.addWidget(AmountLabel(frozen_fiat_str), 0, 3, alignment=Qt.AlignmentFlag.AlignRight)
if unconfirmed:
grid.addWidget(LegendWidget(COLOR_UNCONFIRMED), 2, 0)
grid.addWidget(QLabel(_("Unconfirmed") + ':'), 2, 1)
grid.addWidget(AmountLabel(unconfirmed_str), 2, 2, alignment=Qt.AlignmentFlag.AlignRight)
grid.addWidget(AmountLabel(unconfirmed_fiat_str), 2, 3, alignment=Qt.AlignmentFlag.AlignRight)
if unmatured:
grid.addWidget(LegendWidget(COLOR_UNMATURED), 3, 0)
grid.addWidget(QLabel(_("Unmatured") + ':'), 3, 1)
grid.addWidget(AmountLabel(unmatured_str), 3, 2, alignment=Qt.AlignmentFlag.AlignRight)
grid.addWidget(AmountLabel(unmatured_fiat_str), 3, 3, alignment=Qt.AlignmentFlag.AlignRight)
if confirmed:
grid.addWidget(LegendWidget(COLOR_CONFIRMED), 1, 0)
grid.addWidget(QLabel(_("On-chain") + ':'), 1, 1)
grid.addWidget(AmountLabel(confirmed_str), 1, 2, alignment=Qt.AlignmentFlag.AlignRight)
grid.addWidget(AmountLabel(confirmed_fiat_str), 1, 3, alignment=Qt.AlignmentFlag.AlignRight)
if lightning:
grid.addWidget(LegendWidget(COLOR_LIGHTNING), 4, 0)
grid.addWidget(QLabel(_("Lightning") + ':'), 4, 1)
grid.addWidget(AmountLabel(lightning_str), 4, 2, alignment=Qt.AlignmentFlag.AlignRight)
grid.addWidget(AmountLabel(lightning_fiat_str), 4, 3, alignment=Qt.AlignmentFlag.AlignRight)
if f_lightning:
grid.addWidget(LegendWidget(COLOR_FROZEN_LIGHTNING), 5, 0)
grid.addWidget(QLabel(_("Lightning (frozen)") + ':'), 5, 1)
grid.addWidget(AmountLabel(f_lightning_str), 5, 2, alignment=Qt.AlignmentFlag.AlignRight)
grid.addWidget(AmountLabel(f_lightning_fiat_str), 5, 3, alignment=Qt.AlignmentFlag.AlignRight)
vbox.addLayout(grid)
vbox.addStretch(1)
buttons = [CloseButton(self)]
if self.window.wallet.has_lightning():
swap_button = QPushButton(_('Swap'))
swap_button.clicked.connect(lambda: self.window.run_swap_dialog())
buttons.insert(0, swap_button)
vbox.addLayout(Buttons(*buttons))
self.setLayout(vbox)
def run(self):
self.exec()
================================================
FILE: electrum/gui/qt/bip39_recovery_dialog.py
================================================
# Copyright (C) 2020 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
import asyncio
import concurrent.futures
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem
from electrum.i18n import _
from electrum.network import Network
from electrum.bip39_recovery import account_discovery
from electrum.logging import get_logger
from electrum.util import get_asyncio_loop, UserFacingException
from electrum.gui.common_qt.util import TaskThread
from .util import WindowModalDialog, Buttons, CancelButton, OkButton
_logger = get_logger(__name__)
class Bip39RecoveryDialog(WindowModalDialog):
ROLE_ACCOUNT = Qt.ItemDataRole.UserRole
def __init__(self, parent: QWidget, get_account_xpub, on_account_select):
self.get_account_xpub = get_account_xpub
self.on_account_select = on_account_select
WindowModalDialog.__init__(self, parent, _('BIP39 Recovery'))
self.setMinimumWidth(400)
vbox = QVBoxLayout(self)
self.content = QVBoxLayout()
self.content.addWidget(QLabel(_('Scanning common paths for existing accounts...')))
vbox.addLayout(self.content)
self.thread = TaskThread(self)
self.thread.finished.connect(self.deleteLater) # see #3956
network = Network.get_instance()
coro = account_discovery(network, self.get_account_xpub)
fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
self.thread.add(
fut.result,
on_success=self.on_recovery_success,
on_error=self.on_recovery_error,
cancel=fut.cancel,
)
self.ok_button = OkButton(self)
self.ok_button.clicked.connect(self.on_ok_button_click)
self.ok_button.setEnabled(False)
cancel_button = CancelButton(self)
cancel_button.clicked.connect(fut.cancel)
vbox.addLayout(Buttons(cancel_button, self.ok_button))
self.finished.connect(self.on_finished)
self.show()
def on_finished(self):
self.thread.stop()
def on_ok_button_click(self):
item = self.list.currentItem()
account = item.data(self.ROLE_ACCOUNT)
self.on_account_select(account)
def on_recovery_success(self, accounts):
self.clear_content()
if len(accounts) == 0:
self.content.addWidget(QLabel(_('No existing accounts found.')))
return
self.content.addWidget(QLabel(_('Choose an account to restore.')))
self.list = QListWidget()
for account in accounts:
item = QListWidgetItem(account['description'])
item.setData(self.ROLE_ACCOUNT, account)
self.list.addItem(item)
self.list.clicked.connect(lambda: self.ok_button.setEnabled(True))
self.content.addWidget(self.list)
def on_recovery_error(self, exc_info):
e = exc_info[1]
if isinstance(e, concurrent.futures.CancelledError):
return
self.clear_content()
msg = _('Error: Account discovery failed.')
if isinstance(e, UserFacingException):
msg += f"\n{e}"
else:
_logger.error(f"recovery error", exc_info=exc_info)
self.content.addWidget(QLabel(msg))
def clear_content(self):
for i in reversed(range(self.content.count())):
self.content.itemAt(i).widget().setParent(None)
================================================
FILE: electrum/gui/qt/channel_details.py
================================================
from typing import TYPE_CHECKING, Sequence
import PyQt6.QtGui as QtGui
import PyQt6.QtWidgets as QtWidgets
import PyQt6.QtCore as QtCore
from PyQt6.QtWidgets import QLabel, QLineEdit, QHBoxLayout, QGridLayout
from electrum.util import EventListener, ShortID
from electrum.i18n import _
from electrum.util import format_time
from electrum.lnutil import format_short_channel_id, LOCAL, REMOTE, UpdateAddHtlc, Direction
from electrum.lnchannel import htlcsum, Channel, AbstractChannel, HTLCWithStatus
from electrum.lnaddr import LnAddr, lndecode
from electrum.bitcoin import COIN
from electrum.wallet import Abstract_Wallet
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .util import Buttons, CloseButton, ShowQRLineEdit, MessageBoxMixin, WWLabel, VLine
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class HTLCItem(QtGui.QStandardItem):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setEditable(False)
class SelectableLabel(QtWidgets.QLabel):
def __init__(self, text=''):
super().__init__(text)
self.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
class LinkedLabel(QtWidgets.QLabel):
def __init__(self, text, on_clicked):
super().__init__(text)
self.linkActivated.connect(on_clicked)
class ChannelDetailsDialog(QtWidgets.QDialog, MessageBoxMixin, QtEventListener):
def __init__(self, window: 'ElectrumWindow', chan: AbstractChannel):
super().__init__(window)
# initialize instance fields
self.window = window
self.wallet = window.wallet
self.chan = chan
self.format_msat = lambda msat: window.format_amount_and_units(msat / 1000)
self.format_sat = lambda sat: window.format_amount_and_units(sat)
# register callbacks for updating
self.register_callbacks()
title = _('Lightning Channel') if not self.chan.is_backup() else _('Channel Backup')
self.setWindowTitle(title)
self.setMinimumSize(800, 400)
# activity labels. not used for backups.
self.local_balance_label = SelectableLabel()
self.remote_balance_label = SelectableLabel()
self.can_send_label = SelectableLabel()
self.can_receive_label = SelectableLabel()
# add widgets
vbox = QtWidgets.QVBoxLayout(self)
if self.chan.is_backup():
vbox.addWidget(QLabel('\n'.join([
_("This is a channel backup."),
_("It shows a channel that was opened with another instance of this wallet"),
_("A backup does not contain information about your local balance in the channel."),
_("You can use it to request a force close.")
])))
form = self.get_common_form(chan)
vbox.addLayout(form)
if not self.chan.is_closed() and not self.chan.is_backup():
hbox_stats = self.get_hbox_stats(chan)
form.addRow(QLabel(_('Channel stats')+ ':'), hbox_stats)
if not self.chan.is_backup():
# add htlc tree view to vbox (wouldn't scale correctly in QFormLayout)
vbox.addWidget(QLabel(_('Payments (HTLCs):')))
w = self.create_htlc_list(chan)
vbox.addWidget(w)
vbox.addLayout(Buttons(CloseButton(self)))
# initialize sent/received fields
self.update()
def make_htlc_item(self, i: UpdateAddHtlc, direction: Direction) -> HTLCItem:
it = HTLCItem(_('Sent HTLC with ID {}' if Direction.SENT == direction else 'Received HTLC with ID {}').format(i.htlc_id))
it.appendRow([HTLCItem(_('Amount')),HTLCItem(self.format_msat(i.amount_msat))])
it.appendRow([HTLCItem(_('CLTV expiry')), HTLCItem(str(i.cltv_abs))])
it.appendRow([HTLCItem(_('Payment hash')),HTLCItem(i.payment_hash.hex())])
return it
def make_model(self, htlcs: Sequence[HTLCWithStatus]) -> QtGui.QStandardItemModel:
model = QtGui.QStandardItemModel(0, 2)
model.setHorizontalHeaderLabels(['HTLC', 'Property value'])
parentItem = model.invisibleRootItem()
folder_types = {
'settled': _('Fulfilled HTLCs'),
'inflight': _('HTLCs in current commitment transaction'),
'failed': _('Failed HTLCs'),
}
self.folders = {}
self.keyname_rows = {}
for keyname, i in folder_types.items():
myFont=QtGui.QFont()
myFont.setBold(True)
folder = HTLCItem(i)
folder.setFont(myFont)
parentItem.appendRow(folder)
self.folders[keyname] = folder
mapping = {}
num = 0
for htlc_with_status in htlcs:
if htlc_with_status.status != keyname:
continue
htlc = htlc_with_status.htlc
it = self.make_htlc_item(htlc, htlc_with_status.direction)
self.folders[keyname].appendRow(it)
mapping[htlc.payment_hash] = num
num += 1
self.keyname_rows[keyname] = mapping
return model
def move(self, fro: str, to: str, payment_hash: bytes):
assert fro != to
row_idx = self.keyname_rows[fro].pop(payment_hash)
row = self.folders[fro].takeRow(row_idx)
self.folders[to].appendRow(row)
dest_mapping = self.keyname_rows[to]
dest_mapping[payment_hash] = len(dest_mapping)
@qt_event_listener
def on_event_channel(self, wallet, chan):
if chan == self.chan:
self.update()
@qt_event_listener
def on_event_htlc_added(self, chan, htlc, direction):
if chan != self.chan:
return
mapping = self.keyname_rows['inflight']
mapping[htlc.payment_hash] = len(mapping)
self.folders['inflight'].appendRow(self.make_htlc_item(htlc, direction))
@qt_event_listener
def on_event_htlc_fulfilled(self, payment_hash, chan, htlc_id):
if chan.channel_id != self.chan.channel_id:
return
self.move('inflight', 'settled', payment_hash)
self.update()
@qt_event_listener
def on_event_htlc_failed(self, payment_hash, chan, htlc_id):
if chan.channel_id != self.chan.channel_id:
return
self.move('inflight', 'failed', payment_hash)
self.update()
def update(self):
if self.chan.is_closed() or self.chan.is_backup():
return
assert isinstance(self.chan, Channel), type(self.chan)
self.can_send_label.setText(self.format_msat(self.chan.available_to_spend(LOCAL)))
self.can_receive_label.setText(self.format_msat(self.chan.available_to_spend(REMOTE)))
self.sent_label.setText(self.format_msat(self.chan.total_msat(Direction.SENT)))
self.received_label.setText(self.format_msat(self.chan.total_msat(Direction.RECEIVED)))
self.local_balance_label.setText(self.format_msat(self.chan.balance(LOCAL)))
self.remote_balance_label.setText(self.format_msat(self.chan.balance(REMOTE)))
self.current_feerate.setText(self.window.format_fee_rate(4 * self.chan.get_latest_feerate(LOCAL)))
@QtCore.pyqtSlot(str)
def show_tx(self, link_text: str):
tx = self.wallet.adb.get_transaction(link_text)
if not tx:
self.show_error(_("Transaction not found."))
return
self.window.show_transaction(tx)
def get_common_form(self, chan: AbstractChannel):
form = QtWidgets.QFormLayout(None)
remote_id_e = ShowQRLineEdit(chan.node_id.hex(), self.window.config, title=_("Remote Node ID"))
form.addRow(QLabel(_('Remote Node') + ':'), remote_id_e)
channel_id_e = ShowQRLineEdit(chan.channel_id.hex(), self.window.config, title=_("Channel ID"))
form.addRow(QLabel(_('Channel ID') + ':'), channel_id_e)
form.addRow(QLabel(_('Short Channel ID') + ':'), SelectableLabel(str(chan.short_channel_id)))
if local_scid_alias := chan.get_local_scid_alias():
form.addRow(QLabel('Local SCID Alias:'), SelectableLabel(str(ShortID(local_scid_alias))))
if remote_scid_alias := chan.get_remote_scid_alias():
form.addRow(QLabel('Remote SCID Alias:'), SelectableLabel(str(ShortID(remote_scid_alias))))
form.addRow(QLabel(_('State') + ':'), SelectableLabel(chan.get_state_for_GUI()))
if remote_peer_sent_error := chan.get_remote_peer_sent_error():
err_label = WWLabel(remote_peer_sent_error) # note: text is already truncated to reasonable len
err_label.setTextFormat(QtCore.Qt.TextFormat.PlainText)
form.addRow(WWLabel(_('Remote peer sent error [DO NOT TRUST]') + ':'), err_label)
self.capacity = self.format_sat(chan.get_capacity())
form.addRow(QLabel(_('Capacity') + ':'), SelectableLabel(self.capacity))
if not chan.is_backup():
form.addRow(QLabel(_('Channel type:')), SelectableLabel(chan.storage['channel_type'].name_minimal))
initiator = 'Local' if chan.constraints.is_initiator else 'Remote'
form.addRow(QLabel(_('Initiator:')), SelectableLabel(initiator))
else:
form.addRow(QLabel("Backup Type"), QLabel("imported" if self.chan.is_imported else "on-chain"))
funding_txid = chan.funding_outpoint.txid
funding_label_text = f'{funding_txid}:{chan.funding_outpoint.output_index}'
form.addRow(QLabel(_('Funding Outpoint') + ':'), LinkedLabel(funding_label_text, self.show_tx))
if chan.is_closed():
item = chan.get_closing_height()
if item:
closing_txid, closing_height, timestamp = item
closing_label_text = f'{closing_txid}'
form.addRow(QLabel(_('Closing Transaction') + ':'), LinkedLabel(closing_label_text, self.show_tx))
return form
def get_hbox_stats(self, chan: Channel):
hbox_stats = QHBoxLayout()
form_layout_left = QtWidgets.QFormLayout(None)
form_layout_right = QtWidgets.QFormLayout(None)
form_layout_left.addRow(_('Local balance') + ':', self.local_balance_label)
form_layout_right.addRow(_('Remote balance') + ':', self.remote_balance_label)
form_layout_left.addRow(_('Can send') + ':', self.can_send_label)
form_layout_right.addRow(_('Can receive') + ':', self.can_receive_label)
local_reserve_label = SelectableLabel("{}".format(
self.format_sat(chan.config[LOCAL].reserve_sat),
))
remote_reserve_label = SelectableLabel("{}".format(
self.format_sat(chan.config[REMOTE].reserve_sat),
))
form_layout_left.addRow(_('Local reserve') + ':', local_reserve_label)
form_layout_right.addRow(_('Remote reserve' + ':'), remote_reserve_label)
#self.htlc_minimum_msat = SelectableLabel(str(chan.config[REMOTE].htlc_minimum_msat))
#form_layout_left.addRow(_('Minimum HTLC value accepted by peer (mSAT):'), self.htlc_minimum_msat)
#self.max_htlcs = SelectableLabel(str(chan.config[REMOTE].max_accepted_htlcs))
#form_layout_left.addRow(_('Maximum number of concurrent HTLCs accepted by peer:'), self.max_htlcs)
#self.max_htlc_value = SelectableLabel(self.format_sat(chan.config[REMOTE].max_htlc_value_in_flight_msat / 1000))
#form_layout_left.addRow(_('Maximum value of in-flight HTLCs accepted by peer:'), self.max_htlc_value)
local_dust_limit_label = SelectableLabel("{}".format(
self.format_sat(chan.config[LOCAL].dust_limit_sat),
))
remote_dust_limit_label = SelectableLabel("{}".format(
self.format_sat(chan.config[REMOTE].dust_limit_sat),
))
form_layout_left.addRow(_('Local dust limit') + ':', local_dust_limit_label)
form_layout_right.addRow(_('Remote dust limit') + ':', remote_dust_limit_label)
self.received_label = SelectableLabel()
self.sent_label = SelectableLabel()
form_layout_left.addRow(_('Total sent') + ':', self.sent_label)
form_layout_right.addRow(_('Total received') + ':', self.received_label)
# to-self-delay
csv_local_header = SelectableLabel(_("Remote force-close CSV delay") + ":")
csv_local_header.setToolTip(_("Force-close CSV delay imposed on them"))
csv_remote_header = SelectableLabel(_("Local force-close CSV delay") + ":")
csv_remote_header.setToolTip(_("Force-close CSV delay imposed on us"))
csv_local_label = SelectableLabel(_("{} blocks").format(chan.config[LOCAL].to_self_delay))
csv_remote_label = SelectableLabel(_("{} blocks").format(chan.config[REMOTE].to_self_delay))
form_layout_left.addRow(csv_local_header, csv_local_label)
form_layout_right.addRow(csv_remote_header, csv_remote_label)
# onchain feerate
self.current_feerate = SelectableLabel()
form_layout_left.addRow(_('Current feerate') + ':', self.current_feerate)
# channel stats left column
hbox_stats.addLayout(form_layout_left, 50)
# vertical line separator
hbox_stats.addWidget(VLine())
# channel stats right column
hbox_stats.addLayout(form_layout_right, 50)
return hbox_stats
def create_htlc_list(self, chan):
w = QtWidgets.QTreeView(self)
htlc_dict = chan.get_payments()
htlc_list = []
for rhash, plist in htlc_dict.items():
for htlc_with_status in plist:
htlc_list.append(htlc_with_status)
w.setModel(self.make_model(htlc_list))
w.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
return w
def closeEvent(self, event):
self.unregister_callbacks()
event.accept()
================================================
FILE: electrum/gui/qt/channels_list.py
================================================
# -*- coding: utf-8 -*-
import traceback
import enum
from typing import Sequence, Optional, Dict, TYPE_CHECKING
from abc import abstractmethod, ABC
from PyQt6 import QtCore, QtGui
from PyQt6.QtCore import Qt, QRect, QSize
from PyQt6.QtWidgets import QMenu, QLabel, QVBoxLayout, QGridLayout, QAbstractItemView, QCheckBox, QToolTip
from PyQt6.QtGui import QFont, QStandardItem, QBrush, QPainter, QIcon, QHelpEvent
from electrum.i18n import _
from electrum.lnchannel import AbstractChannel, ChannelBackup, Channel, ChanCloseOption
from electrum.wallet import Abstract_Wallet
from electrum.lnutil import LOCAL, REMOTE
from electrum.lnworker import LNWallet
from electrum.gui import messages
from .util import WindowModalDialog, Buttons, OkButton, EnterButton, WaitingDialog, MONOSPACE_FONT, ColorScheme
from .util import read_QIcon, font_height
from .my_treeview import MyTreeView
if TYPE_CHECKING:
from .main_window import ElectrumWindow
ROLE_CHANNEL_ID = Qt.ItemDataRole.UserRole
class ChannelsList(MyTreeView):
update_rows = QtCore.pyqtSignal(Abstract_Wallet)
update_single_row = QtCore.pyqtSignal(Abstract_Wallet, AbstractChannel)
gossip_db_loaded = QtCore.pyqtSignal()
class Columns(MyTreeView.BaseColumnsEnum):
FEATURES = enum.auto()
SHORT_CHANID = enum.auto()
NODE_ALIAS = enum.auto()
CAPACITY = enum.auto()
LOCAL_BALANCE = enum.auto()
REMOTE_BALANCE = enum.auto()
CHANNEL_STATUS = enum.auto()
LONG_CHANID = enum.auto()
headers = {
Columns.SHORT_CHANID: _('Short Channel ID'),
Columns.LONG_CHANID: _('Channel ID'),
Columns.NODE_ALIAS: _('Node alias'),
Columns.FEATURES: "",
Columns.CAPACITY: _('Capacity'),
Columns.LOCAL_BALANCE: _('Can send'),
Columns.REMOTE_BALANCE: _('Can receive'),
Columns.CHANNEL_STATUS: _('Status'),
}
filter_columns = [
Columns.SHORT_CHANID,
Columns.LONG_CHANID,
Columns.NODE_ALIAS,
Columns.CHANNEL_STATUS,
]
_default_item_bg_brush = None # type: Optional[QBrush]
def __init__(self, main_window: 'ElectrumWindow'):
super().__init__(
main_window=main_window,
stretch_column=self.Columns.NODE_ALIAS,
)
self.setModel(QtGui.QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.gossip_db_loaded.connect(self.on_gossip_db)
self.update_rows.connect(self.do_update_rows)
self.update_single_row.connect(self.do_update_single_row)
self.network = self.main_window.network
self.wallet = self.main_window.wallet
self.setSortingEnabled(True)
@property
# property because lnworker might be initialized at runtime
def lnworker(self):
return self.wallet.lnworker
def format_fields(self, chan: AbstractChannel) -> Dict['ChannelsList.Columns', str]:
labels = {}
for subject in (REMOTE, LOCAL):
if isinstance(chan, Channel):
can_send = chan.available_to_spend(subject) / 1000
label = self.main_window.format_amount(can_send, whitespaces=True)
other = subject.inverted()
bal_other = chan.balance(other)//1000
bal_minus_htlcs_other = chan.balance_minus_outgoing_htlcs(other)//1000
if bal_other != bal_minus_htlcs_other:
label += ' (+' + self.main_window.format_amount(bal_other - bal_minus_htlcs_other, whitespaces=False) + ')'
else:
assert isinstance(chan, ChannelBackup)
label = ''
labels[subject] = label
status = chan.get_state_for_GUI()
closed = chan.is_closed()
node_alias = self.lnworker.lnpeermgr.get_node_alias(chan.node_id) or chan.node_id.hex()
capacity_str = self.main_window.format_amount(chan.get_capacity(), whitespaces=True)
return {
self.Columns.SHORT_CHANID: chan.short_id_for_GUI(),
self.Columns.LONG_CHANID: chan.channel_id.hex(),
self.Columns.NODE_ALIAS: node_alias,
self.Columns.FEATURES: '',
self.Columns.CAPACITY: capacity_str,
self.Columns.LOCAL_BALANCE: '' if closed else labels[LOCAL],
self.Columns.REMOTE_BALANCE: '' if closed else labels[REMOTE],
self.Columns.CHANNEL_STATUS: status,
}
def on_channel_closed(self, txid):
self.main_window.show_message('Channel closed' + '\n' + txid)
def on_failure(self, exc_info):
type_, e, tb = exc_info
traceback.print_tb(tb)
self.main_window.show_error('Failed to close channel:\n{}'.format(repr(e)))
def close_channel(self, channel_id):
self.is_force_close = False
msg = _('Cooperative close?')
msg += '\n\n' + messages.MSG_COOPERATIVE_CLOSE
if not self.main_window.question(msg):
return
coro = self.lnworker.close_channel(channel_id)
on_success = self.on_channel_closed
def task():
return self.network.run_from_another_thread(coro)
WaitingDialog(self, _('Please wait...'), task, on_success, self.on_failure)
def force_close(self, channel_id):
self.save_backup = True
backup_cb = QCheckBox('Create a backup now', checked=True)
def on_checked(_x):
self.save_backup = backup_cb.isChecked()
backup_cb.stateChanged.connect(on_checked)
chan = self.lnworker.channels[channel_id]
to_self_delay = chan.config[REMOTE].to_self_delay
msg = '' + _('Force-close channel?') + ' '\
+ '
' + _('If you force-close this channel, the funds you have in it will not be available for {} blocks.').format(to_self_delay) + ' '\
+ _('After that delay, funds will be swept to an address derived from your wallet seed.') + '
'\
+ '' + _('Please create a backup of your wallet file!') + ' '\
+ '
' + _('Funds in this channel will not be recoverable from seed until they are swept back into your wallet, and might be lost if you lose your wallet file.') + ' '\
+ _('To prevent that, you should save a backup of your wallet on another device.') + '
'
if not self.main_window.question(msg, title=_('Force-close channel'), rich_text=True, checkbox=backup_cb):
return
if self.save_backup:
if not self.main_window.backup_wallet():
return
def task():
coro = self.lnworker.force_close_channel(channel_id)
return self.network.run_from_another_thread(coro)
WaitingDialog(self, _('Please wait...'), task, self.on_channel_closed, self.on_failure)
def remove_channel(self, channel_id):
if self.main_window.question(_('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.')):
self.lnworker.remove_channel(channel_id)
def remove_channel_backup(self, channel_id):
if self.main_window.question(_('Remove channel backup?')):
self.lnworker.remove_channel_backup(channel_id)
def export_channel_backup(self, channel_id):
msg = messages.MSG_LN_EXPLAIN_SCB_BACKUPS
data = self.lnworker.export_channel_backup(channel_id)
self.main_window.show_qrcode(data, 'channel backup', help_text=msg,
show_copy_text_btn=True)
def request_force_close(self, channel_id):
msg = _('Request force-close from remote peer?')
msg += '\n\n' + messages.MSG_REQUEST_FORCE_CLOSE
if not self.main_window.question(msg):
return
def task():
coro = self.lnworker.request_force_close(channel_id)
return self.network.run_from_another_thread(coro)
def on_done(b):
self.main_window.show_message(_('Request scheduled'))
WaitingDialog(self, _('Please wait...'), task, on_done, self.on_failure)
def set_frozen(self, chan, *, for_sending, value):
if not self.lnworker.uses_trampoline() or self.lnworker.is_trampoline_peer(chan.node_id):
if for_sending:
chan.set_frozen_for_sending(value)
else:
chan.set_frozen_for_receiving(value)
else:
msg = messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP
self.main_window.show_warning(msg, title=_('Channel is frozen for sending'))
def get_rebalance_pair(self):
selected = self.selected_in_column(self.Columns.NODE_ALIAS)
if len(selected) == 2:
idx1 = selected[0]
idx2 = selected[1]
channel_id1 = idx1.sibling(idx1.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID)
channel_id2 = idx2.sibling(idx2.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID)
chan1 = self.lnworker.get_channel_by_id(channel_id1)
chan2 = self.lnworker.get_channel_by_id(channel_id2)
if chan1 and chan2 and (not self.lnworker.uses_trampoline() or chan1.node_id != chan2.node_id):
return chan1, chan2
return None, None
def on_rebalance(self):
chan1, chan2 = self.get_rebalance_pair()
if chan1 is None:
self.main_window.show_error("Select two active channels to rebalance.")
return
self.main_window.rebalance_dialog(chan1, chan2)
def on_double_click(self, idx):
channel_id = idx.sibling(idx.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID)
chan = self.lnworker.get_channel_by_id(channel_id) or self.lnworker.channel_backups[channel_id]
self.main_window.show_channel_details(chan)
def create_menu(self, position):
menu = QMenu()
menu.setSeparatorsCollapsible(True) # consecutive separators are merged together
selected = self.selected_in_column(self.Columns.NODE_ALIAS)
if not selected:
menu.exec(self.viewport().mapToGlobal(position))
return
if len(selected) == 2:
chan1, chan2 = self.get_rebalance_pair()
if chan1 and chan2:
menu.addAction(_("Rebalance channels"), lambda: self.main_window.rebalance_dialog(chan1, chan2))
menu.exec(self.viewport().mapToGlobal(position))
return
elif len(selected) > 2:
return
idx = self.indexAt(position)
if not idx.isValid():
return
item = self.model().itemFromIndex(idx)
if not item:
return
channel_id = idx.sibling(idx.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID)
chan = self.lnworker.get_channel_by_id(channel_id) or self.lnworker.channel_backups[channel_id]
menu.addAction(_("Details"), lambda: self.main_window.show_channel_details(chan))
menu.addSeparator()
cc = self.add_copy_menu(menu, idx)
cc.addAction(_("Node ID"), lambda: self.place_text_on_clipboard(
chan.node_id.hex(), title=_("Node ID")))
cc.addAction(_("Long Channel ID"), lambda: self.place_text_on_clipboard(
channel_id.hex(), title=_("Long Channel ID")))
if not chan.is_backup() and not chan.is_closed():
fm = menu.addMenu(_("Freeze"))
if not chan.is_frozen_for_sending():
fm.addAction(_("Freeze for sending"), lambda: self.set_frozen(chan, for_sending=True, value=True))
else:
fm.addAction(_("Unfreeze for sending"), lambda: self.set_frozen(chan, for_sending=True, value=False))
if not chan.is_frozen_for_receiving():
fm.addAction(_("Freeze for receiving"), lambda: self.set_frozen(chan, for_sending=False, value=True))
else:
fm.addAction(_("Unfreeze for receiving"), lambda: self.set_frozen(chan, for_sending=False, value=False))
if close_opts := chan.get_close_options():
cm = menu.addMenu(_("Close"))
if ChanCloseOption.COOP_CLOSE in close_opts:
cm.addAction(_("Cooperative close"), lambda: self.close_channel(channel_id))
if ChanCloseOption.LOCAL_FCLOSE in close_opts:
cm.addAction(_("Force-close"), lambda: self.force_close(channel_id))
if ChanCloseOption.REQUEST_REMOTE_FCLOSE in close_opts:
cm.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id))
if not chan.is_backup():
menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id))
if chan.can_be_deleted():
menu.addSeparator()
if chan.is_backup():
menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id))
else:
menu.addAction(_("Delete"), lambda: self.remove_channel(channel_id))
self.open_menu(menu, position)
@QtCore.pyqtSlot(Abstract_Wallet, AbstractChannel)
def do_update_single_row(self, wallet: Abstract_Wallet, chan: AbstractChannel):
if wallet != self.wallet:
return
for row in range(self.model().rowCount()):
item = self.model().item(row, self.Columns.NODE_ALIAS)
if item.data(ROLE_CHANNEL_ID) != chan.channel_id:
continue
for column, v in self.format_fields(chan).items():
self.model().item(row, column).setData(v, QtCore.Qt.ItemDataRole.DisplayRole)
items = [self.model().item(row, column) for column in self.Columns]
self._update_chan_frozen_bg(chan=chan, items=items)
if wallet.lnworker:
self.update_can_send(wallet.lnworker)
@QtCore.pyqtSlot()
def on_gossip_db(self):
self.do_update_rows(self.wallet)
@QtCore.pyqtSlot(Abstract_Wallet)
def do_update_rows(self, wallet):
if wallet != self.wallet:
return
self.model().clear()
self.update_headers(self.headers)
self.set_visibility_of_columns()
if not wallet.lnworker:
return
self.update_can_send(wallet.lnworker)
channels = wallet.lnworker.get_channel_objects()
for chan in channels.values():
field_map = self.format_fields(chan)
items = [QtGui.QStandardItem(field_map[col]) for col in sorted(field_map)]
self.set_editability(items)
if self._default_item_bg_brush is None:
self._default_item_bg_brush = items[self.Columns.NODE_ALIAS].background()
items[self.Columns.NODE_ALIAS].setData(chan.channel_id, ROLE_CHANNEL_ID)
items[self.Columns.NODE_ALIAS].setFont(QFont(MONOSPACE_FONT))
items[self.Columns.LOCAL_BALANCE].setFont(QFont(MONOSPACE_FONT))
items[self.Columns.REMOTE_BALANCE].setFont(QFont(MONOSPACE_FONT))
items[self.Columns.FEATURES].setData(ChannelFeatureIcons.from_channel(chan), self.ROLE_CUSTOM_PAINT)
items[self.Columns.CAPACITY].setFont(QFont(MONOSPACE_FONT))
self._update_chan_frozen_bg(chan=chan, items=items)
self.model().insertRow(0, items)
# FIXME sorting by SHORT_CHANID should treat values as tuple, not as string ( 50x1x1 > 8x1x1 )
self.sortByColumn(self.Columns.SHORT_CHANID, Qt.SortOrder.DescendingOrder)
def _update_chan_frozen_bg(self, *, chan: AbstractChannel, items: Sequence[QStandardItem]):
assert self._default_item_bg_brush is not None
# frozen for sending
item = items[self.Columns.LOCAL_BALANCE]
if chan.is_frozen_for_sending():
item.setBackground(ColorScheme.BLUE.as_color(True))
item.setToolTip(_("This channel is frozen for sending. It will not be used for outgoing payments."))
else:
item.setBackground(self._default_item_bg_brush)
item.setToolTip("")
# frozen for receiving
item = items[self.Columns.REMOTE_BALANCE]
if chan.is_frozen_for_receiving():
item.setBackground(ColorScheme.BLUE.as_color(True))
item.setToolTip(_("This channel is frozen for receiving. It will not be included in invoices."))
else:
item.setBackground(self._default_item_bg_brush)
item.setToolTip("")
def update_can_send(self, lnworker: LNWallet):
msg = _('Can send') + ' ' + self.main_window.format_amount(lnworker.num_sats_can_send())\
+ ' ' + self.main_window.base_unit() + '; '\
+ _('can receive') + ' ' + self.main_window.format_amount(lnworker.num_sats_can_receive())\
+ ' ' + self.main_window.base_unit()
self.can_send_label.setText(msg)
def create_toolbar(self, config):
toolbar, menu = self.create_toolbar_with_menu('')
self.can_send_label = toolbar.itemAt(0).widget()
menu.addAction(_('Rebalance channels'), lambda: self.on_rebalance())
menu.addAction(read_QIcon('update.png'), _('Submarine swap'), lambda: self.main_window.run_swap_dialog())
menu.addSeparator()
menu.addAction(_("Import channel backup"), lambda: self.main_window.do_process_from_text_channel_backup())
# only enable menu if has LN. Or we could selectively enable menu items?
# and maybe add item "main_window.init_lightning_dialog()" when applicable
menu.setEnabled(self.wallet.has_lightning())
self.new_channel_button = EnterButton(_('New Channel'), self.main_window.new_channel_dialog)
if not self.wallet.can_have_lightning():
self.new_channel_button.setEnabled(False)
self.new_channel_button.setToolTip(_("Lightning is not available for this wallet."))
else:
self.new_channel_button.setToolTip(_("Open a channel to send payments over the Lightning network."))
toolbar.insertWidget(2, self.new_channel_button)
return toolbar
def statistics_dialog(self):
channel_db = self.network.channel_db
capacity = self.main_window.format_amount(channel_db.capacity()) + ' '+ self.main_window.base_unit()
d = WindowModalDialog(self.main_window, _('Lightning Network Statistics'))
d.setMinimumWidth(400)
vbox = QVBoxLayout(d)
h = QGridLayout()
h.addWidget(QLabel(_('Nodes') + ':'), 0, 0)
h.addWidget(QLabel('{}'.format(channel_db.num_nodes)), 0, 1)
h.addWidget(QLabel(_('Channels') + ':'), 1, 0)
h.addWidget(QLabel('{}'.format(channel_db.num_channels)), 1, 1)
h.addWidget(QLabel(_('Capacity') + ':'), 2, 0)
h.addWidget(QLabel(capacity), 2, 1)
vbox.addLayout(h)
vbox.addLayout(Buttons(OkButton(d)))
d.exec()
def set_visibility_of_columns(self):
def set_visible(col: int, b: bool):
self.showColumn(col) if b else self.hideColumn(col)
set_visible(self.Columns.LONG_CHANID, False)
class ChannelFeature(ABC):
def __init__(self):
self.rect = QRect()
@abstractmethod
def tooltip(self) -> str:
pass
@abstractmethod
def icon(self) -> QIcon:
pass
class ChanFeatChannel(ChannelFeature):
def tooltip(self) -> str:
return _("This is a channel")
def icon(self) -> QIcon:
return read_QIcon("lightning")
class ChanFeatBackup(ChannelFeature):
def tooltip(self) -> str:
return _("This is a static channel backup")
def icon(self) -> QIcon:
return read_QIcon("lightning_disconnected")
class ChanFeatTrampoline(ChannelFeature):
def tooltip(self) -> str:
return _("The channel peer can route Trampoline payments.")
def icon(self) -> QIcon:
return read_QIcon("kangaroo")
class ChanFeatNoOnchainBackup(ChannelFeature):
def tooltip(self) -> str:
return _("This channel cannot be recovered from your seed. You must back it up manually.")
def icon(self) -> QIcon:
return read_QIcon("cloud_no")
class ChannelFeatureIcons:
def __init__(self, features: Sequence['ChannelFeature']):
size = max(16, font_height())
self.icon_size = QSize(size, size)
self.features = features
@classmethod
def from_channel(cls, chan: AbstractChannel) -> 'ChannelFeatureIcons':
feats = []
if chan.is_backup():
feats.append(ChanFeatBackup())
if chan.is_imported:
feats.append(ChanFeatNoOnchainBackup())
else:
feats.append(ChanFeatChannel())
if chan.lnworker.is_trampoline_peer(chan.node_id):
feats.append(ChanFeatTrampoline())
if not chan.has_onchain_backup():
feats.append(ChanFeatNoOnchainBackup())
return ChannelFeatureIcons(feats)
def paint(self, painter: QPainter, rect: QRect) -> None:
painter.save()
cur_x = rect.x()
for feat in self.features:
icon_rect = QRect(cur_x, rect.y(), self.icon_size.width(), self.icon_size.height())
feat.rect = icon_rect
if rect.contains(icon_rect): # stay inside parent
painter.drawPixmap(icon_rect, feat.icon().pixmap(self.icon_size))
cur_x += self.icon_size.width() + 1
painter.restore()
def sizeHint(self, default_size: QSize) -> QSize:
if not self.features:
return default_size
width = len(self.features) * (self.icon_size.width() + 1)
return QSize(width, default_size.height())
def show_tooltip(self, evt: QHelpEvent) -> bool:
assert isinstance(evt, QHelpEvent)
for feat in self.features:
if feat.rect.contains(evt.pos()):
QToolTip.showText(evt.globalPos(), feat.tooltip())
break
else:
QToolTip.hideText()
evt.ignore()
return True
================================================
FILE: electrum/gui/qt/completion_text_edit.py
================================================
#!/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 PyQt6.QtGui import QTextCursor
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QCompleter, QPlainTextEdit, QApplication
from .util import ButtonsTextEdit
class CompletionTextEdit(ButtonsTextEdit):
def __init__(self):
ButtonsTextEdit.__init__(self)
self.completer = None
self.moveCursor(QTextCursor.MoveOperation.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.CompletionMode.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.MoveOperation.Left)
text_cursor.movePosition(QTextCursor.MoveOperation.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.SelectionType.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)
if self.isReadOnly(): # if field became read-only *after* keyPress, exit now
return
ctrlOrShift = ((Qt.KeyboardModifier.ControlModifier in e.modifiers())
or (Qt.KeyboardModifier.ShiftModifier in e.modifiers()))
if self.completer is None or (ctrlOrShift and not e.text()):
return
if not self.suggestions_enabled:
return
eow = "~!@#$%^&*()_+{}|:\"<>?,./;'[]\\-="
hasModifier = (e.modifiers() != Qt.KeyboardModifier.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 and self.completer.popup().isVisible():
if e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
return True
if e.key() == Qt.Key.Key_Tab:
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()
================================================
FILE: electrum/gui/qt/confirm_tx_dialog.py
================================================
#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (2019) 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.
import asyncio
from decimal import Decimal
from functools import partial
from typing import TYPE_CHECKING, Optional, Union
from concurrent.futures import Future
from enum import Enum, auto
from PyQt6.QtCore import Qt, QTimer, pyqtSlot, pyqtSignal
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton,
QComboBox, QTabWidget, QWidget, QStackedWidget)
from electrum.i18n import _
from electrum.util import (UserCancelled, quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
get_asyncio_loop, wait_for2, UserFacingException)
from electrum.plugin import run_hook
from electrum.transaction import PartialTransaction, PartialTxOutput
from electrum.wallet import InternalAddressCorruption
from electrum.bitcoin import DummyAddress
from electrum.fee_policy import FeePolicy, FixedFeePolicy, FeeMethod
from electrum.logging import Logger
from electrum.submarine_swaps import NostrTransport, HttpTransport, SwapServerTransport, SwapServerError
from electrum.gui.messages import MSG_SUBMARINE_PAYMENT_HELP_TEXT
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel,
read_QIcon, IconLabel, HelpButton, RunCoroutineDialog)
from .transaction_dialog import TxSizeLabel, TxFiatLabel, TxInOutWidget
from .fee_slider import FeeSlider, FeeComboBox
from .amountedit import FeerateEdit, BTCAmountEdit
from .locktimeedit import LockTimeEdit
from .my_treeview import QMenuWithConfig
from .swap_dialog import SwapProvidersButton
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class TxEditorContext(Enum):
"""
Context for which the TxEditor gets launched.
Allows to enable/disable certain features.
"""
PAYMENT = auto()
CHANNEL_FUNDING = auto()
class TxEditor(WindowModalDialog, QtEventListener, Logger):
swap_availability_changed = pyqtSignal()
def __init__(
self, *, title='',
window: 'ElectrumWindow',
make_tx,
output_value: Union[int, str],
payee_outputs: Optional[list[PartialTxOutput]] = None,
context: TxEditorContext = TxEditorContext.PAYMENT,
batching_candidates=None,
):
WindowModalDialog.__init__(self, window, title=title)
Logger.__init__(self)
self.main_window = window
self.make_tx = make_tx
self.output_value = output_value
# used only for submarine payments as they construct tx independently of make_tx
self.payee_outputs = payee_outputs
self.tx = None # type: Optional[PartialTransaction]
self.messages = []
self.error = '' # set by side effect
self.config = window.config
self.network = window.network
self.fee_policy = FeePolicy(self.config.FEE_POLICY)
self.wallet = window.wallet
self.feerounding_sats = 0
self.not_enough_funds = False
self.no_dynfee_estimates = False
self.needs_update = False
self.context = context
self.is_preview = False
self._base_tx = None # for batching
self.batching_candidates = batching_candidates
self.swap_manager = self.wallet.lnworker.swap_manager if self.wallet.has_lightning() else None
self.swap_transport = None # type: Optional[SwapServerTransport]
self.swap_availability_changed.connect(self.on_swap_availability_changed, Qt.ConnectionType.QueuedConnection)
self.ongoing_swap_transport_connection_attempt = None # type: Optional[Future]
self.did_swap = False # used to clear the PI on send tab
self.locktime_e = LockTimeEdit(self)
self.locktime_e.valueEdited.connect(self.trigger_update)
self.locktime_label = QLabel(_("LockTime") + ": ")
self.io_widget = TxInOutWidget(self.main_window, self.wallet)
self.create_fee_controls()
onchain_vbox = QVBoxLayout()
onchain_top = self.create_top_bar(self.help_text)
onchain_grid = self.create_grid()
onchain_vbox.addLayout(onchain_top)
onchain_vbox.addLayout(onchain_grid)
onchain_vbox.addWidget(self.io_widget)
self.message_label = WWLabel('')
self.message_label.setMinimumHeight(70)
onchain_vbox.addWidget(self.message_label)
onchain_buttons = self.create_buttons_bar()
onchain_vbox.addStretch(1)
onchain_vbox.addLayout(onchain_buttons)
# onchain tab is the main tab and the content is also shown if tabs are disabled
self.onchain_tab = QWidget()
self.onchain_tab.setContentsMargins(0,0,0,0)
self.onchain_tab.setLayout(onchain_vbox)
# optional submarine payment tab, the tab is only shown if the option is enabled
self.submarine_payment_tab = self.create_submarine_payment_tab()
self.tab_widget = QTabWidget()
self.tab_widget.setTabBarAutoHide(True) # hides the tab bar if there is only one tab
self.tab_widget.setContentsMargins(0, 0, 0, 0)
self.tab_widget.currentChanged.connect(self.on_tab_changed)
self.main_layout = QVBoxLayout()
self.main_layout.addWidget(self.tab_widget)
self.main_layout.setContentsMargins(6, 6, 6, 6) # reduce outermost margins a bit
self.setLayout(self.main_layout)
self.set_io_visible()
self.set_fee_edit_visible()
self.set_locktime_visible()
self.update_fee_target()
self.update_tab_visibility()
self.resize_to_fit_content()
self.timer = QTimer(self)
self.timer.setInterval(500)
self.timer.setSingleShot(False)
self.timer.timeout.connect(self.timer_actions)
self.timer.start()
self.register_callbacks()
# debug_widget_layouts(self) # enable to show red lines around all elements
def accept(self):
self._cleanup()
super().accept()
def reject(self):
self._cleanup()
super().reject()
def closeEvent(self, event):
self._cleanup()
super().closeEvent(event)
def _cleanup(self):
self.unregister_callbacks()
if self.ongoing_swap_transport_connection_attempt:
self.ongoing_swap_transport_connection_attempt.cancel()
if isinstance(self.swap_transport, NostrTransport):
asyncio.run_coroutine_threadsafe(self.swap_transport.stop(), get_asyncio_loop())
self.swap_transport = None # HTTPTransport doesn't need to be closed
def on_tab_changed(self, index):
if self.tab_widget.widget(index) == self.submarine_payment_tab:
self.prepare_swap_transport()
self.update_submarine_payment_tab()
else:
self.update()
def is_batching(self) -> bool:
return self._base_tx is not None
def timer_actions(self):
if self.needs_update:
self.update()
self.needs_update = False
def update(self):
self.update_tx()
self.set_locktime()
self._update_widgets()
def stop_editor_updates(self):
self.timer.stop()
def update_tx(self, *, fallback_to_zero_fee: bool = False):
# expected to set self.tx, self.message and self.error
raise NotImplementedError()
def create_grid(self) -> QGridLayout:
raise NotImplementedError()
@property
def help_text(self) -> str:
raise NotImplementedError()
def update_fee_target(self):
if self.fee_slider.is_active():
text = self.fee_policy.get_target_text()
else:
text = ""
self.fee_target.setText(text)
def update_feerate_label(self):
self.feerate_label.setText(self.feerate_e.text() + ' ' + self.feerate_e.base_unit())
def create_fee_controls(self):
self.fee_label = QLabel('')
self.fee_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.size_label = TxSizeLabel()
self.size_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.size_label.setAmount(0)
self.size_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
self.feerate_label = QLabel('')
self.feerate_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.fiat_fee_label = TxFiatLabel()
self.fiat_fee_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.fiat_fee_label.setAmount(0)
self.fiat_fee_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
self.feerate_e = FeerateEdit(lambda: 0)
self.feerate_e.textEdited.connect(partial(self.on_fee_or_feerate, self.feerate_e, False))
self.feerate_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.feerate_e, True))
self.update_feerate_label()
self.fee_e = BTCAmountEdit(self.main_window.get_decimal_point)
self.fee_e.textEdited.connect(partial(self.on_fee_or_feerate, self.fee_e, False))
self.fee_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.fee_e, True))
self.feerate_e.setFixedWidth(150)
self.fee_e.setFixedWidth(150)
if self.fee_policy.method != FeeMethod.FIXED:
self.feerate_e.setAmount(self.fee_policy.fee_per_byte(self.network))
else:
self.fee_e.setAmount(self.fee_policy.value)
self.fee_e.textChanged.connect(self.entry_changed)
self.feerate_e.textChanged.connect(self.entry_changed)
self.fee_target = QLabel('')
self.fee_slider = FeeSlider(parent=self, network=self.network, fee_policy=self.fee_policy, callback=self.fee_slider_callback)
self.fee_combo = FeeComboBox(self.fee_slider)
self.fee_combo.setFocusPolicy(Qt.FocusPolicy.NoFocus)
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.') + '\n' +
_('Also, when batching RBF transactions, BIP 125 imposes a lower bound on the fee.'))
self.show_message(title=_('Fee rounding'), msg=text)
self.feerounding_icon = QToolButton()
self.feerounding_icon.setStyleSheet("background-color: rgba(255, 255, 255, 0); ")
self.feerounding_icon.setAutoRaise(True)
self.feerounding_icon.clicked.connect(feerounding_onclick)
self.set_feerounding_visibility(False)
self.fee_hbox = fee_hbox = QHBoxLayout()
fee_hbox.addWidget(self.feerate_e)
fee_hbox.addWidget(self.feerate_label)
fee_hbox.addWidget(self.size_label)
fee_hbox.addWidget(self.fee_e)
fee_hbox.addWidget(self.fee_label)
fee_hbox.addWidget(self.fiat_fee_label)
fee_hbox.addWidget(self.feerounding_icon)
fee_hbox.addStretch()
self.fee_target_hbox = fee_target_hbox = QHBoxLayout()
fee_target_hbox.addWidget(self.fee_target)
fee_target_hbox.addWidget(self.fee_slider)
fee_target_hbox.addWidget(self.fee_combo)
fee_target_hbox.addStretch()
# set feerate_label to same size as feerate_e
self.feerate_label.setFixedSize(self.feerate_e.sizeHint())
self.fee_label.setFixedSize(self.fee_e.sizeHint())
self.fee_slider.setFixedWidth(200)
self.fee_target.setFixedSize(self.feerate_e.sizeHint())
def update_tab_visibility(self):
"""Update self.tab_widget to show all tabs that are enabled."""
# first remove all tabs
while self.tab_widget.count() > 0:
self.tab_widget.removeTab(0)
# always show onchain payment tab
self.tab_widget.addTab(self.onchain_tab, _('Onchain Transaction'))
allow_swaps = self.context == TxEditorContext.PAYMENT and self.payee_outputs and self.swap_manager
if self.config.WALLET_ENABLE_SUBMARINE_PAYMENTS and allow_swaps:
i = self.tab_widget.addTab(self.submarine_payment_tab, _('Submarine Payment'))
tooltip = self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS.get_long_desc()
if len(self.payee_outputs) > 1:
self.tab_widget.setTabEnabled(i, False)
tooltip = _("Submarine Payments don't support multiple outputs (Pay-to-many).")
elif self.payee_outputs[0].value == '!':
self.tab_widget.setTabEnabled(i, False)
self.submarine_payment_tab.setEnabled(False)
tooltip = _("Submarine Payments don't support 'Max' value spends.")
self.tab_widget.tabBar().setTabToolTip(i, tooltip)
# enable document mode if there is only one tab to hide the frame
self.tab_widget.setDocumentMode(self.tab_widget.count() < 2)
self.resize_to_fit_content()
def trigger_update(self):
# set tx to None so that the ok button is disabled while we compute the new tx
self.tx = None
self.messages = []
self.error = ''
self._update_widgets()
self.needs_update = True
def fee_slider_callback(self, fee_rate):
self.fee_slider.activate()
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.update_fee_target()
self.update_feerate_label()
self.trigger_update()
def on_fee_or_feerate(self, 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()
# do not call trigger_update on editing_finished,
# because that event is emitted when we press OK
self.trigger_update()
def is_send_fee_frozen(self) -> bool:
return self.fee_e.isVisible() and self.fee_e.isModified() \
and (bool(self.fee_e.text()) or self.fee_e.hasFocus())
def is_send_feerate_frozen(self) -> bool:
return self.feerate_e.isVisible() and self.feerate_e.isModified() \
and (bool(self.feerate_e.text()) or self.feerate_e.hasFocus())
def feerounding_text(self):
return (_('Additional {} satoshis are going to be added.').format(self.feerounding_sats))
def set_feerounding_visibility(self, b:bool):
# we do not use setVisible because it affects the layout
self.feerounding_icon.setIcon(read_QIcon('info.png') if b else QIcon())
self.feerounding_icon.setEnabled(b)
def get_fee_policy(self):
feerate = self.feerate_e.get_amount()
fee_amount = self.fee_e.get_amount()
if self.is_send_fee_frozen() and fee_amount is not None:
fee_policy = FixedFeePolicy(fee_amount)
elif self.is_send_feerate_frozen() and feerate is not None:
feerate_per_kb = int(feerate * 1000)
fee_policy = FeePolicy(f'feerate:{feerate_per_kb}')
else:
fee_policy = self.fee_slider.get_policy()
return fee_policy
def entry_changed(self):
# blue color denotes auto-filled values
text = ""
fee_color = ColorScheme.DEFAULT
feerate_color = ColorScheme.DEFAULT
if self.not_enough_funds:
fee_color = ColorScheme.RED
feerate_color = ColorScheme.RED
elif self.fee_e.isModified():
feerate_color = ColorScheme.BLUE
elif self.feerate_e.isModified():
fee_color = ColorScheme.BLUE
else:
fee_color = ColorScheme.BLUE
feerate_color = ColorScheme.BLUE
self.fee_e.setStyleSheet(fee_color.as_stylesheet())
self.feerate_e.setStyleSheet(feerate_color.as_stylesheet())
self.needs_update = True
def update_fee_fields(self):
freeze_fee = self.is_send_fee_frozen()
freeze_feerate = self.is_send_feerate_frozen()
tx = self.tx
if self.no_dynfee_estimates and tx:
size = tx.estimated_size()
self.size_label.setAmount(size)
#self.size_e.setAmount(size)
if self.not_enough_funds or self.no_dynfee_estimates:
if not freeze_fee:
self.fee_e.setAmount(None)
if not freeze_feerate:
self.feerate_e.setAmount(None)
self.set_feerounding_visibility(False)
return
assert tx is not None
size = tx.estimated_size()
fee = tx.get_fee()
#self.size_e.setAmount(size)
self.size_label.setAmount(size)
fiat_fee = self.main_window.format_fiat_and_units(fee)
self.fiat_fee_label.setAmount(fiat_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)
elif self.fee_slider.is_active():
# fallback to actual fee
displayed_feerate = quantize_feerate(fee / size) if fee is not None else None
self.feerate_e.setAmount(displayed_feerate)
if displayed_feerate is not None:
displayed_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=displayed_feerate * 1000, size=size)
else:
displayed_fee = 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)
# set fee rounding icon to empty if there is no rounding
feerounding = (fee - displayed_fee) if (fee and displayed_fee is not None) else 0
self.feerounding_sats = int(feerounding)
self.feerounding_icon.setToolTip(self.feerounding_text())
self.set_feerounding_visibility(abs(feerounding) >= 1)
# feerate_label needs to be updated from feerate_e
self.update_feerate_label()
self.update_fee_target()
def create_buttons_bar(self):
self.change_to_ln_swap_providers_button = SwapProvidersButton(lambda: self.swap_transport, self.config, self.main_window)
self.preview_button = QPushButton(_('Preview'))
self.preview_button.clicked.connect(self.on_preview)
self.preview_button.setVisible(self.context != TxEditorContext.CHANNEL_FUNDING)
self.ok_button = QPushButton(_('OK'))
self.ok_button.clicked.connect(self.on_send)
self.ok_button.setDefault(True)
buttons = Buttons(CancelButton(self), self.preview_button, self.ok_button)
buttons.insertWidget(0, self.change_to_ln_swap_providers_button)
if self.batching_candidates is not None and len(self.batching_candidates) > 0:
batching_combo = QComboBox()
batching_combo.addItems([_('Do not batch')] + [_('Batch with') + ' ' + tx.txid()[0:10] for tx in self.batching_candidates])
buttons.insertWidget(0, batching_combo)
def on_batching_combo(x):
self._base_tx = self.batching_candidates[x - 1] if x > 0 else None
self.trigger_update()
batching_combo.currentIndexChanged.connect(on_batching_combo)
return buttons
def create_top_bar(self, text):
self.pref_menu = QMenuWithConfig(self.config)
def cb():
self.set_io_visible()
self.resize_to_fit_content()
self.pref_menu.addConfig(self.config.cv.GUI_QT_TX_EDITOR_SHOW_IO, callback=cb)
def cb():
self.set_fee_edit_visible()
self.resize_to_fit_content()
self.pref_menu.addConfig(self.config.cv.GUI_QT_TX_EDITOR_SHOW_FEE_DETAILS, callback=cb)
def cb():
self.set_locktime_visible()
self.resize_to_fit_content()
self.pref_menu.addConfig(self.config.cv.GUI_QT_TX_EDITOR_SHOW_LOCKTIME, callback=cb)
self.pref_menu.addSeparator()
can_have_lightning = self.wallet.can_have_lightning()
send_ch_to_ln = self.pref_menu.addConfig(
self.config.cv.WALLET_SEND_CHANGE_TO_LIGHTNING,
callback=lambda: (self.prepare_swap_transport(), self.trigger_update()), # type: ignore
checked=False if not can_have_lightning else None,
)
sub_payments = self.pref_menu.addConfig(
self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS,
callback=self.update_tab_visibility,
checked=False if not can_have_lightning else None,
)
if not can_have_lightning: # disable the buttons and override tooltip
ln_unavailable_msg = _("Not available for this wallet.") \
+ "\n" + _("Requires a wallet with Lightning network support.")
for ln_conf in (send_ch_to_ln, sub_payments):
ln_conf.setEnabled(False)
ln_conf.setToolTip(ln_unavailable_msg)
self.pref_menu.addToggle(
_('Use change addresses'),
self.toggle_use_change,
default_state=self.wallet.use_change,
tooltip=_('Using change addresses makes it more difficult for other people to track your transactions.'))
self.use_multi_change_menu = self.pref_menu.addToggle(
_('Use multiple change addresses'),
self.toggle_multiple_change,
default_state=self.wallet.multiple_change,
tooltip='\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.')
]))
self.use_multi_change_menu.setEnabled(self.wallet.use_change)
# fixme: some of these options (WALLET_SEND_CHANGE_TO_LIGHTNING, WALLET_MERGE_DUPLICATE_OUTPUTS)
# only make sense when we create a new tx, and should not be visible/enabled in rbf dialog
self.pref_menu.addConfig(self.config.cv.WALLET_MERGE_DUPLICATE_OUTPUTS, callback=self.trigger_update)
self.pref_menu.addConfig(self.config.cv.WALLET_SPEND_CONFIRMED_ONLY, callback=self.trigger_update)
self.pref_menu.addConfig(self.config.cv.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING, callback=self.trigger_update)
self.pref_button = QToolButton()
self.pref_button.setIcon(read_QIcon("preferences.png"))
self.pref_button.setText(_('Tools'))
self.pref_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.pref_button.setMenu(self.pref_menu)
self.pref_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.pref_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
hbox = QHBoxLayout()
hbox.addWidget(QLabel(text))
hbox.addStretch()
hbox.addWidget(self.pref_button)
return hbox
@profiler(min_threshold=0.02)
def resize_to_fit_content(self):
# update all geometries so the updated size hints are used for size adjustment
for widget in self.findChildren(QWidget):
widget.updateGeometry()
self.adjustSize()
def toggle_use_change(self):
self.wallet.use_change = not self.wallet.use_change
self.wallet.db.put('use_change', self.wallet.use_change)
self.use_multi_change_menu.setEnabled(self.wallet.use_change)
self.trigger_update()
def toggle_multiple_change(self):
self.wallet.multiple_change = not self.wallet.multiple_change
self.wallet.db.put('multiple_change', self.wallet.multiple_change)
self.trigger_update()
def set_io_visible(self):
self.io_widget.setVisible(self.config.GUI_QT_TX_EDITOR_SHOW_IO)
def set_fee_edit_visible(self):
b = self.config.GUI_QT_TX_EDITOR_SHOW_FEE_DETAILS
detailed = [self.feerounding_icon, self.feerate_e, self.fee_e]
basic = [self.fee_label, self.feerate_label]
# first hide, then show
for w in (basic if b else detailed):
w.hide()
for w in (detailed if b else basic):
w.show()
def set_locktime_visible(self):
b = self.config.GUI_QT_TX_EDITOR_SHOW_LOCKTIME
for w in [
self.locktime_e,
self.locktime_label]:
w.setVisible(b)
def run(self):
if self.config.WALLET_SEND_CHANGE_TO_LIGHTNING:
# if disabled but submarine payments are enabled we only connect once the other tab gets opened
self.prepare_swap_transport()
cancelled = not self.exec()
self.stop_editor_updates()
self.deleteLater() # see #3956
return self.tx if not cancelled else None
def on_send(self):
if self.tx and self.tx.get_dummy_output(DummyAddress.SWAP):
if not self.request_forward_swap():
return
self.accept()
def on_preview(self):
assert not self.tx.get_dummy_output(DummyAddress.SWAP), "no preview when sending change to ln"
self.is_preview = True
self.accept()
def _update_widgets(self):
# side effect: self.error
self._update_amount_label()
if self.not_enough_funds:
self.error = _('Not enough funds.')
confirmed_only = self.config.WALLET_SPEND_CONFIRMED_ONLY
if confirmed_only and self.can_pay_assuming_zero_fees(confirmed_only=False):
self.error += ' ' + _('Change your settings to allow spending unconfirmed coins.')
elif self.can_pay_assuming_zero_fees(confirmed_only=confirmed_only):
self.error += ' ' + _('You need to set a lower fee.')
elif frozen_bal := self.wallet.get_frozen_balance_str():
self.error = self.wallet.get_text_not_enough_funds_mentioning_frozen(
for_amount=self.output_value,
hint=_('Can be unfrozen in the Addresses or in the Coins tab')
)
if not self.tx:
if self.not_enough_funds:
self.io_widget.update(None)
self.set_feerounding_visibility(False)
self.messages = [_('Preparing transaction...')]
else:
self.messages = self.get_messages()
self.update_fee_fields()
if self.locktime_e.get_locktime() is None:
self.locktime_e.set_locktime(self.tx.locktime)
self.io_widget.update(self.tx)
self.fee_label.setText(self.main_window.config.format_amount_and_units(self.tx.get_fee()))
self._update_extra_fees()
if self.config.WALLET_SEND_CHANGE_TO_LIGHTNING:
self.change_to_ln_swap_providers_button.setVisible(True)
self.change_to_ln_swap_providers_button.fetching = bool(self.ongoing_swap_transport_connection_attempt)
self.change_to_ln_swap_providers_button.update()
else:
self.change_to_ln_swap_providers_button.setVisible(False)
self._update_send_button()
self._update_message()
def get_messages(self):
# side effect: self.error
messages = []
fee = self.tx.get_fee()
assert fee is not None
amount = self.tx.output_value() if self.output_value == '!' else self.output_value
tx_size = self.tx.estimated_size()
fee_warning_tuple = self.wallet.get_tx_fee_warning(
invoice_amt=amount, tx_size=tx_size, fee=fee, txid=self.tx.txid())
if fee_warning_tuple:
allow_send, long_warning, short_warning = fee_warning_tuple
if not allow_send:
self.error = long_warning
else:
messages.append(long_warning)
if self.no_dynfee_estimates:
self.error = _('Fee estimates not available. Please set a fixed fee or feerate.')
if dummy_output := self.tx.get_dummy_output(DummyAddress.SWAP):
swap_msg = _('Will send change to lightning')
swap_fee_msg = "."
if self.swap_manager and self.swap_manager.is_initialized.is_set() and isinstance(dummy_output.value, int):
ln_amount_we_recv = self.swap_manager.get_recv_amount(send_amount=dummy_output.value, is_reverse=False)
if ln_amount_we_recv:
swap_fees = dummy_output.value - ln_amount_we_recv
swap_fee_msg = " [" + _("Swap fees:") + " " + self.main_window.format_amount_and_units(swap_fees) + "]."
messages.append(swap_msg + swap_fee_msg)
elif self.config.WALLET_SEND_CHANGE_TO_LIGHTNING \
and not self.ongoing_swap_transport_connection_attempt \
and self.tx.has_change():
swap_msg = _('Will not send change to Lightning')
swap_msg_reason = None
change_amount = sum(c.value for c in self.tx.get_change_outputs() if isinstance(c.value, int))
if not self.wallet.has_lightning():
swap_msg_reason = _('Lightning is not enabled.')
elif change_amount > int(self.wallet.lnworker.num_sats_can_receive()):
swap_msg_reason = _("Your channels cannot receive this amount.")
elif self.wallet.lnworker.swap_manager.is_initialized.is_set():
min_amount = self.wallet.lnworker.swap_manager.get_min_amount()
max_amount = self.wallet.lnworker.swap_manager.get_provider_max_reverse_amount()
if change_amount < min_amount:
swap_msg_reason = _("Below the swap providers minimum value of {}.").format(
self.main_window.format_amount_and_units(min_amount)
)
else:
swap_msg_reason = _('Change amount exceeds the swap providers maximum value of {}.').format(
self.main_window.format_amount_and_units(max_amount)
)
messages.append(swap_msg + (f": {swap_msg_reason}" if swap_msg_reason else '.'))
elif self.ongoing_swap_transport_connection_attempt:
messages.append(_("Fetching submarine swap providers..."))
# warn if spending unconf
if any((txin.block_height is not None and txin.block_height<=0) for txin in self.tx.inputs()):
messages.append(_('This transaction will spend unconfirmed coins.'))
# warn if a reserve utxo was added
if reserve_sats := self.wallet.tx_keeps_ln_utxo_reserve(self.tx, gui_spend_max=bool(self.output_value == '!')):
reserve_str = self.main_window.config.format_amount_and_units(reserve_sats)
messages.append(_('Could not spend max: a security reserve of {} was kept for your Lightning channels.').format(reserve_str))
# warn if we merge from mempool
if self.is_batching():
messages.append(_('This payment will be merged with another existing transaction.'))
# warn if we use multiple change outputs
num_change = sum(int(o.is_change) for o in self.tx.outputs())
num_ismine = sum(int(o.is_mine) for o in self.tx.outputs())
if num_change > 1:
messages.append(_('This transaction has {} change outputs.'.format(num_change)))
# warn if there is no ismine output, as it might be problematic to RBF the tx later.
# (though RBF is still possible by adding new inputs, if the wallet has more utxos)
if num_ismine == 0:
messages.append(_('Make sure you pay enough mining fees; you will not be able to bump the fee later.'))
# TODO: warn if we send change back to input address
return messages
def set_locktime(self):
if not self.tx:
return
locktime = self.locktime_e.get_locktime()
if locktime is not None:
self.tx.locktime = locktime
def _update_amount_label(self):
pass
def _update_extra_fees(self):
pass
def _update_message(self):
style = ColorScheme.RED if self.error else ColorScheme.BLUE
message_str = '\n'.join(self.messages) if self.messages else ''
self.message_label.setStyleSheet(style.as_stylesheet())
self.message_label.setText(self.error or message_str)
def _update_send_button(self):
# disable preview button when sending change to lightning to prevent the user from saving or
# exporting the transaction and broadcasting it later somehow.
send_change_to_ln = self.tx and self.tx.get_dummy_output(DummyAddress.SWAP)
enabled = bool(self.tx) and not self.error
self.preview_button.setEnabled(enabled and not send_change_to_ln)
self.preview_button.setToolTip(_("Can't show preview when sending change to lightning") if send_change_to_ln else "")
self.ok_button.setEnabled(enabled)
def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool:
raise NotImplementedError
### --- Shared functionality for submarine swaps (change to ln and submarine payments) ---
def prepare_swap_transport(self):
if not self.swap_manager:
return # no swaps possible, lightning disabled
if self.swap_transport is not None and self.swap_transport.is_connected.is_set():
# we already have a connected transport, no need to create a new one
return
if self.ongoing_swap_transport_connection_attempt:
# another task is currently trying to connect
return
# there should only be a connected transport.
# a useless transport should get cleaned up and not stored.
assert self.swap_transport is None, "swap transport wasn't cleaned up properly"
new_swap_transport = self.main_window.create_sm_transport()
if not new_swap_transport:
# user declined to enable Nostr and has no http server configured
self.swap_availability_changed.emit()
return
async def _initialize_transport(transport):
try:
if isinstance(transport, NostrTransport):
asyncio.create_task(transport.main_loop())
else:
assert isinstance(transport, HttpTransport)
asyncio.create_task(transport.get_pairs_just_once())
if not await self.wait_for_swap_transport(transport):
return
self.swap_transport = transport
except Exception:
self.logger.exception("failed to create swap transport")
finally:
self.ongoing_swap_transport_connection_attempt = None
self.swap_availability_changed.emit()
# this task will get cancelled if the TxEditor gets closed
self.ongoing_swap_transport_connection_attempt = asyncio.run_coroutine_threadsafe(
_initialize_transport(new_swap_transport),
get_asyncio_loop(),
)
async def wait_for_swap_transport(self, new_swap_transport: Union[HttpTransport, NostrTransport]) -> bool:
"""
Wait until we found the announcement event of the configured swap server.
If it is not found but the relay connection is established return True anyway,
the user will then need to select a different swap server.
"""
timeout = new_swap_transport.connect_timeout + 1
try:
# swap_manager.is_initialized gets set once we got pairs of the configured swap server
await wait_for2(self.swap_manager.is_initialized.wait(), timeout)
except asyncio.TimeoutError:
self.logger.debug(f"swap transport initialization timed out after {timeout} sec")
if self.swap_manager.is_initialized.is_set():
return True
# timed out above
if self.config.SWAPSERVER_URL:
# http swapserver didn't return pairs
self.logger.error(f"couldn't request pairs from {self.config.SWAPSERVER_URL=}")
return False
elif new_swap_transport.is_connected.is_set():
assert isinstance(new_swap_transport, NostrTransport)
# couldn't find announcement of configured swapserver, maybe it is gone.
# update_submarine_payment_tab will tell the user to select a different swap server.
return True
# we couldn't even connect to the relays, this transport is useless. maybe network issues.
return False
@qt_event_listener
def on_event_swap_provider_changed(self):
self.swap_availability_changed.emit()
@qt_event_listener
def on_event_channel(self, wallet, _channel):
# useful e.g. if the user quickly opens the tab after startup before the channels are initialized
if wallet == self.wallet and self.swap_manager and self.swap_manager.is_initialized.is_set():
self.swap_availability_changed.emit()
@qt_event_listener
def on_event_swap_offers_changed(self, _):
self.change_to_ln_swap_providers_button.update()
self.submarine_payment_provider_button.update()
if self.ongoing_swap_transport_connection_attempt:
return
self.swap_availability_changed.emit()
@pyqtSlot()
def on_swap_availability_changed(self):
# uses a signal/slot to update the gui so we can schedule an update from the asyncio thread
if self.tab_widget.currentWidget() == self.submarine_payment_tab:
self.update_submarine_payment_tab()
else:
self.update()
### --- Functionality for reverse submarine swaps to external address ---
def create_submarine_payment_tab(self) -> QWidget:
"""Returns widget for submarine payment functionality to be added as tab"""
tab_widget = QWidget()
vbox = QVBoxLayout(tab_widget)
# stack two views, a warning view and the regular one. The warning view is shown if
# the swap cannot be performed, e.g. due to missing liquidity.
self.submarine_stacked_widget = QStackedWidget()
# Normal layout page
normal_page = QWidget()
h = QGridLayout(normal_page)
help_button = HelpButton(MSG_SUBMARINE_PAYMENT_HELP_TEXT)
self.submarine_lightning_send_amount_label = QLabel()
self.submarine_onchain_send_amount_label = QLabel()
self.submarine_claim_mining_fee_label = QLabel()
self.submarine_server_fee_label = QLabel()
self.submarine_we_send_label = IconLabel(text=_('You send')+':')
self.submarine_we_send_label.setIcon(read_QIcon('lightning.png'))
self.submarine_they_receive_label = IconLabel(text=_('They receive')+':')
self.submarine_they_receive_label.setIcon(read_QIcon('bitcoin.png'))
# column 0 (labels)
h.addWidget(self.submarine_we_send_label, 0, 0)
h.addWidget(self.submarine_they_receive_label, 1, 0)
h.addWidget(QLabel(_('Swap fee')+':'), 2, 0)
h.addWidget(QLabel(_('Mining fee')+':'), 3, 0)
# column 1 (spacing)
h.setColumnStretch(1, 1)
# column 2 (amounts)
h.addWidget(self.submarine_lightning_send_amount_label, 0, 2)
h.addWidget(self.submarine_onchain_send_amount_label, 1, 2)
h.addWidget(self.submarine_server_fee_label, 2, 2, 1, 2)
h.addWidget(self.submarine_claim_mining_fee_label, 3, 2, 1, 2)
# column 3 (spacing)
h.setColumnStretch(3, 1)
# column 4 (help button)
h.addWidget(help_button, 0, 4)
# Warning layout page
warning_page = QWidget()
warning_layout = QVBoxLayout(warning_page)
self.submarine_warning_label = QLabel('')
warning_layout.addWidget(self.submarine_warning_label)
self.submarine_stacked_widget.addWidget(normal_page)
self.submarine_stacked_widget.addWidget(warning_page)
vbox.addWidget(self.submarine_stacked_widget)
vbox.addStretch(1)
self.submarine_payment_provider_button = SwapProvidersButton(lambda: self.swap_transport, self.config, self.main_window)
self.submarine_ok_button = QPushButton(_('OK'))
self.submarine_ok_button.setDefault(True)
self.submarine_ok_button.setEnabled(False)
# pay button must not self.accept() as this triggers closing the transport
self.submarine_ok_button.clicked.connect(self.start_submarine_payment)
buttons = Buttons(CancelButton(self), self.submarine_ok_button)
buttons.insertWidget(0, self.submarine_payment_provider_button)
vbox.addLayout(buttons)
return tab_widget
def show_swap_transport_connection_message(self):
self.submarine_stacked_widget.setCurrentIndex(1)
self.submarine_warning_label.setText(_("Connecting, please wait..."))
self.submarine_ok_button.setEnabled(False)
def start_submarine_payment(self):
assert self.payee_outputs and len(self.payee_outputs) == 1
payee_output = self.payee_outputs[0]
assert self.expected_onchain_amount_sat is not None
assert self.lightning_send_amount_sat is not None
assert self.last_server_mining_fee_sat is not None
assert self.swap_transport.is_connected.is_set()
assert self.swap_manager.is_initialized.is_set()
self.tx = None # prevent broadcasting
self.submarine_ok_button.setEnabled(False)
coro = self.swap_manager.reverse_swap(
transport=self.swap_transport,
lightning_amount_sat=self.lightning_send_amount_sat,
expected_onchain_amount_sat=self.expected_onchain_amount_sat,
prepayment_sat=2 * self.last_server_mining_fee_sat,
claim_to_output=payee_output,
)
try:
funding_txid = self.main_window.run_coroutine_dialog(coro, _('Initiating Submarine Payment...'))
except Exception as e:
self.close()
self.main_window.show_error(_("Submarine Payment failed:") + "\n" + str(e))
return
self.did_swap = True
# accepting closes the swap transport, so it needs to happen after the swap
self.accept()
self.main_window.on_swap_result(funding_txid, is_reverse=True)
def update_submarine_payment_tab(self):
assert self.tab_widget.currentWidget() == self.submarine_payment_tab
assert self.payee_outputs, "Opened submarine payment tab without outputs?"
assert len(self.payee_outputs) == \
len([o for o in self.payee_outputs if not o.is_change and not isinstance(o.value, str)])
f = self.main_window.format_amount_and_units
self.logger.debug(f"TxEditor updating submarine payment tab")
if not self.swap_manager:
self.set_submarine_payment_tab_warning(_("Enable Lightning in the 'Channels' tab to use Submarine Swaps."))
return
if not self.swap_manager.is_initialized.is_set() \
and self.ongoing_swap_transport_connection_attempt:
self.show_swap_transport_connection_message()
return
if not self.swap_transport:
# couldn't connect to nostr relays or http server didn't respond
self.set_submarine_payment_tab_warning(_("Submarine swap provider unavailable."))
return
# Update the swapserver selection button text
self.submarine_payment_provider_button.update()
if not self.swap_manager.is_initialized.is_set():
# connected to nostr relays but couldn't find swapserver announcement
assert isinstance(self.swap_transport, NostrTransport), "HTTPTransport shouldn't get set if it cannot fetch pairs"
assert self.swap_transport.is_connected.is_set(), "closed transport wasn't cleaned up"
if self.config.SWAPSERVER_NPUB:
msg = _("Couldn't connect to your swap provider. Please select a different provider.")
else:
msg = _('Please select a submarine swap provider.')
self.set_submarine_payment_tab_warning(msg)
return
# update values
self.lightning_send_amount_sat = self.swap_manager.get_send_amount(
self.payee_outputs[0].value, # claim tx fee reserve gets added in get_send_amount
is_reverse=True,
)
self.last_server_mining_fee_sat = self.swap_manager.mining_fee
self.expected_onchain_amount_sat = (
self.payee_outputs[0].value + self.swap_manager.get_fee_for_txbatcher()
)
# get warning
warning_text = self.get_swap_warning()
if warning_text:
self.set_submarine_payment_tab_warning(warning_text)
return
# There is no warning, show the normal view (amounts etc.)
self.submarine_stacked_widget.setCurrentIndex(0)
# label showing the payment amount (the amount the user entered in SendTab)
self.submarine_onchain_send_amount_label.setText(f(self.payee_outputs[0].value))
# the fee we pay to claim the funding output to the onchain address, shown as "Mining Fee"
claim_tx_mining_fee = self.swap_manager.get_fee_for_txbatcher()
self.submarine_claim_mining_fee_label.setText(f(claim_tx_mining_fee))
assert self.lightning_send_amount_sat is not None
self.submarine_lightning_send_amount_label.setText(f(self.lightning_send_amount_sat))
# complete fee we pay to the server
server_fee = self.lightning_send_amount_sat - self.expected_onchain_amount_sat
self.submarine_server_fee_label.setText(f(server_fee))
self.submarine_ok_button.setEnabled(True)
def get_swap_warning(self) -> Optional[str]:
f = self.main_window.format_amount_and_units
ln_can_send = int(self.wallet.lnworker.num_sats_can_send())
if self.expected_onchain_amount_sat < self.swap_manager.get_min_amount():
return '\n'.join([
_("Payment amount below the minimum possible swap amount."),
_("Minimum amount: {}").format(f(self.swap_manager.get_min_amount())), "",
_("You need to send a higher amount to be able to do a Submarine Payment."),
])
too_low_outbound_liquidity_msg = ''.join([
_("You don't have enough outgoing capacity in your lightning channels."), '\n',
_("Your lightning channels can send: {}").format(f(ln_can_send)), '\n',
_("For this transaction you need: {}").format(f(self.lightning_send_amount_sat)) if self.lightning_send_amount_sat else '',
'\n\n' if self.lightning_send_amount_sat else '\n',
_("To add outgoing capacity you can open a new lightning channel or do a submarine swap."),
])
# prioritize showing the swap provider liquidity warning before the channel liquidity warning
# as it could be annoying for the user to be told to open a new channel just to come back to
# notice there is no provider supporting their swap amount
if self.lightning_send_amount_sat is None:
provider_liquidity = self.swap_manager.get_provider_max_forward_amount()
if provider_liquidity < self.swap_manager.get_min_amount():
provider_liquidity = 0
msg = [
_("The selected swap provider is unable to offer a forward swap of this value."),
_("Available liquidity") + f": {f(provider_liquidity)}", "",
_("In order to continue select a different provider or try to send a smaller amount."),
]
# we don't know exactly how much we need to send on ln yet, so we can assume 0 provider fees
probably_too_low_outbound_liquidity = self.expected_onchain_amount_sat > ln_can_send
if probably_too_low_outbound_liquidity:
msg.extend([
"",
"Please also note:",
too_low_outbound_liquidity_msg,
])
return "\n".join(msg)
# if we have lightning_send_amount_sat our provider has enough liquidity, so we know the exact
# amount we need to send including the providers fees
too_low_outbound_liquidity = self.lightning_send_amount_sat > ln_can_send
if too_low_outbound_liquidity:
return too_low_outbound_liquidity_msg
return None
def set_submarine_payment_tab_warning(self, warning: str):
msg = _('Submarine Payment not possible:') + '\n' + warning
self.submarine_warning_label.setText(msg)
self.submarine_stacked_widget.setCurrentIndex(1)
self.submarine_ok_button.setEnabled(False)
# --- send change to lightning swap functionality ---
def request_forward_swap(self):
swap_dummy_output = self.tx.get_dummy_output(DummyAddress.SWAP)
sm, transport = self.swap_manager, self.swap_transport
assert sm and transport and swap_dummy_output and isinstance(swap_dummy_output.value, int)
coro = sm.request_swap_for_amount(transport=transport, onchain_amount=int(swap_dummy_output.value))
coro_dialog = RunCoroutineDialog(self, _('Requesting swap invoice...'), coro)
try:
swap, swap_invoice = coro_dialog.run()
except (SwapServerError, UserFacingException) as e:
self.show_error(str(e))
return False
except UserCancelled:
return False
self.tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
assert self.tx.get_dummy_output(DummyAddress.SWAP) is None
self.tx.swap_invoice = swap_invoice
self.tx.swap_payment_hash = swap.payment_hash
return True
class ConfirmTxDialog(TxEditor):
help_text = '' #_('Set the mining fee of your transaction')
def __init__(
self, *,
window: 'ElectrumWindow',
make_tx,
output_value: Union[int, str],
payee_outputs: Optional[list[PartialTxOutput]] = None,
context: TxEditorContext = TxEditorContext.PAYMENT,
batching_candidates=None,
):
TxEditor.__init__(
self,
window=window,
make_tx=make_tx,
output_value=output_value,
payee_outputs=payee_outputs,
title=_("New Transaction"), # todo: adapt title for channel funding tx, swaps
context=context,
batching_candidates=batching_candidates,
)
self.trigger_update()
def _update_amount_label(self):
tx = self.tx
if self.output_value == '!':
if tx:
amount = tx.output_value()
amount_str = self.main_window.format_amount_and_units(amount)
else:
amount_str = "max"
else:
amount = self.output_value
amount_str = self.main_window.format_amount_and_units(amount)
self.amount_label.setText(amount_str)
def update_tx(self, *, fallback_to_zero_fee: bool = False):
self.fee_policy = fee_policy = self.get_fee_policy()
if fee_policy.method != FeeMethod.FIXED:
self.config.FEE_POLICY = fee_policy.get_descriptor()
confirmed_only = self.config.WALLET_SPEND_CONFIRMED_ONLY
base_tx = self._base_tx
try:
self.tx = self.make_tx(fee_policy, confirmed_only=confirmed_only, base_tx=base_tx)
self.not_enough_funds = False
self.no_dynfee_estimates = False
except NotEnoughFunds:
self.not_enough_funds = True
self.tx = None
if fallback_to_zero_fee:
try:
self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only, base_tx=base_tx)
except BaseException:
return
else:
return
except NoDynamicFeeEstimates:
# is this still needed?
self.no_dynfee_estimates = True
self.tx = None
try:
self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only, base_tx=base_tx)
except NotEnoughFunds:
self.not_enough_funds = True
return
except BaseException:
return
except InternalAddressCorruption as e:
self.tx = None
self.main_window.show_error(str(e))
raise
self.tx.set_rbf(True)
def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool:
# called in send_tab.py
try:
tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only, base_tx=None)
except NotEnoughFunds:
return False
else:
return True
def create_grid(self):
grid = QGridLayout()
msg = (_('The amount to be received by the recipient.') + ' '
+ _('Fees are paid by the sender.'))
self.amount_label = QLabel('')
self.amount_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
grid.addWidget(HelpLabel(_("Amount to be sent") + ": ", msg), 0, 0)
grid.addWidget(self.amount_label, 0, 1)
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.')
grid.addWidget(HelpLabel(_("Mining Fee") + ": ", msg), 1, 0)
grid.addLayout(self.fee_hbox, 1, 1, 1, 3)
grid.addWidget(HelpLabel(_("Fee policy") + ": ", self.fee_combo.help_msg), 3, 0)
grid.addLayout(self.fee_target_hbox, 3, 1, 1, 3)
grid.setColumnStretch(4, 1)
# extra fee
self.extra_fee_label = QLabel(_("Additional fees") + ": ")
self.extra_fee_label.setVisible(False)
self.extra_fee_value = QLabel('')
self.extra_fee_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.extra_fee_value.setVisible(False)
grid.addWidget(self.extra_fee_label, 5, 0)
grid.addWidget(self.extra_fee_value, 5, 1)
# locktime editor
grid.addWidget(self.locktime_label, 6, 0)
grid.addWidget(self.locktime_e, 6, 1, 1, 2)
return grid
def _update_extra_fees(self):
x_fee = run_hook('get_tx_extra_fee', self.wallet, self.tx)
if x_fee:
x_fee_address, x_fee_amount = x_fee
self.extra_fee_label.setVisible(True)
self.extra_fee_value.setVisible(True)
self.extra_fee_value.setText(self.main_window.format_amount_and_units(x_fee_amount))
================================================
FILE: electrum/gui/qt/console.py
================================================
# source: http://stackoverflow.com/questions/2758159/how-to-embed-a-python-interpreter-in-a-pyqt-widget
import sys
import os
import re
import traceback
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from electrum import util
from electrum.i18n import _
from electrum.base_crash_reporter import taint_reports_by_console_usage
from .util import MONOSPACE_FONT, font_height
# sys.ps1 and sys.ps2 are only declared if an interpreter is in interactive mode.
sys.ps1 = '>>> '
sys.ps2 = '... '
class OverlayLabel(QtWidgets.QLabel):
STYLESHEET = '''
QLabel, QLabel link {
color: rgb(0, 0, 0);
background-color: rgb(248, 240, 200);
border: 1px solid;
border-color: rgb(255, 114, 47);
padding: 2px;
}
'''
def __init__(self, text, parent):
super().__init__(text, parent)
self.setMinimumHeight(max(150, 10 * font_height()))
self.setGeometry(0, 0, self.width(), self.height())
self.setStyleSheet(self.STYLESHEET)
self.setMargin(0)
parent.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setWordWrap(True)
def mousePressEvent(self, e):
self.hide()
def on_resize(self, w):
padding = 2 # px, from the stylesheet above
self.setFixedWidth(w - padding)
class Console(QtWidgets.QPlainTextEdit):
DEFAULT_FONT_SIZE = 10
MIN_FONT_SIZE = 6
MAX_FONT_SIZE = 32
def __init__(self, parent=None):
QtWidgets.QPlainTextEdit.__init__(self, parent)
self.history = []
self.namespace = {}
self.construct = []
self.font_size = self.DEFAULT_FONT_SIZE
self.setGeometry(50, 75, 600, 400)
self.setWordWrapMode(QtGui.QTextOption.WrapMode.WrapAnywhere)
self.setUndoRedoEnabled(False)
self.setFont(QtGui.QFont(MONOSPACE_FONT, self.font_size, QtGui.QFont.Weight.Normal))
self.newPrompt("") # make sure there is always a prompt, even before first server.banner
self.updateNamespace({'run':self.run_script})
self.set_json(False)
warning_text = "
{}
{}
{}".format(
_("Warning!"),
_("Do not paste code here that you don't understand. Executing the wrong code could lead "
"to your coins being irreversibly lost."),
_("Click here to hide this message.")
)
self.messageOverlay = OverlayLabel(warning_text, self)
def set_font_size(self, size: int):
size = max(self.MIN_FONT_SIZE, min(self.MAX_FONT_SIZE, size))
self.font_size = size
self.setFont(QtGui.QFont(MONOSPACE_FONT, self.font_size, QtGui.QFont.Weight.Normal))
def resizeEvent(self, e):
super().resizeEvent(e)
vertical_scrollbar_width = self.verticalScrollBar().width() * self.verticalScrollBar().isVisible()
self.messageOverlay.on_resize(self.width() - vertical_scrollbar_width)
def set_json(self, b):
self.is_json = b
def run_script(self, filename):
with open(filename) as f:
script = f.read()
self._exec_command(script)
def updateNamespace(self, namespace):
self.namespace.update(namespace)
def showMessage(self, message):
curr_line = self.getCommand(strip=False)
self.appendPlainText(message)
self.newPrompt(curr_line)
def clear(self):
curr_line = self.getCommand()
self.setPlainText('')
self.newPrompt(curr_line)
def keyboard_interrupt(self):
self.construct = []
self.appendPlainText('KeyboardInterrupt')
self.newPrompt('')
def newPrompt(self, curr_line):
if self.construct:
prompt = sys.ps2 + curr_line
else:
prompt = sys.ps1 + curr_line
self.completions_pos = self.textCursor().position()
self.completions_visible = False
self.appendPlainText(prompt)
self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
def getCommand(self, *, strip=True):
doc = self.document()
curr_line = doc.findBlockByLineNumber(doc.lineCount() - 1).text()
if strip:
curr_line = curr_line.rstrip()
curr_line = curr_line[len(sys.ps1):]
return curr_line
def setCommand(self, command):
if self.getCommand() == command:
return
doc = self.document()
curr_line = doc.findBlockByLineNumber(doc.lineCount() - 1).text()
self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
for i in range(len(curr_line) - len(sys.ps1)):
self.moveCursor(QtGui.QTextCursor.MoveOperation.Left, QtGui.QTextCursor.MoveMode.KeepAnchor)
self.textCursor().removeSelectedText()
self.textCursor().insertText(command)
self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
def show_completions(self, completions):
if self.completions_visible:
self.hide_completions()
c = self.textCursor()
c.setPosition(self.completions_pos)
completions = map(lambda x: x.split('.')[-1], completions)
t = '\n' + ' '.join(completions)
if len(t) > 500:
t = t[:500] + '...'
c.insertText(t)
self.completions_end = c.position()
self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
self.completions_visible = True
def hide_completions(self):
if not self.completions_visible:
return
c = self.textCursor()
c.setPosition(self.completions_pos)
l = self.completions_end - self.completions_pos
for x in range(l): c.deleteChar()
self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
self.completions_visible = False
def getConstruct(self, command):
if self.construct:
self.construct.append(command)
if not command:
ret_val = '\n'.join(self.construct)
self.construct = []
return ret_val
else:
return ''
else:
if command and command[-1] == (':'):
self.construct.append(command)
return ''
else:
return command
def addToHistory(self, command):
if not self.construct and command[0:1] == ' ':
return
if command and (not self.history or self.history[-1] != command):
while len(self.history) >= 50:
self.history.remove(self.history[0])
self.history.append(command)
self.history_index = len(self.history)
def getPrevHistoryEntry(self):
if self.history:
self.history_index = max(0, self.history_index - 1)
return self.history[self.history_index]
return ''
def getNextHistoryEntry(self):
if self.history:
hist_len = len(self.history)
self.history_index = min(hist_len, self.history_index + 1)
if self.history_index < hist_len:
return self.history[self.history_index]
return ''
def getCursorPosition(self):
c = self.textCursor()
return c.position() - c.block().position() - len(sys.ps1)
def setCursorPosition(self, position):
self.moveCursor(QtGui.QTextCursor.MoveOperation.StartOfLine)
for i in range(len(sys.ps1) + position):
self.moveCursor(QtGui.QTextCursor.MoveOperation.Right)
def run_command(self):
command = self.getCommand()
self.addToHistory(command)
command = self.getConstruct(command)
if command:
self._exec_command(command)
self.newPrompt('')
self.set_json(False)
def _exec_command(self, command):
tmp_stdout = sys.stdout
taint_reports_by_console_usage()
class StdoutProxy:
def __init__(self, write_func):
self.write_func = write_func
self.skip = False
def flush(self):
pass
def write(self, text):
if not self.skip:
stripped_text = text.rstrip('\n')
self.write_func(stripped_text)
QtCore.QCoreApplication.processEvents()
self.skip = not self.skip
if type(self.namespace.get(command)) == type(lambda: None):
self.appendPlainText("'{}' is a function. Type '{}()' to use it in the Python console."
.format(command, command))
return
sys.stdout = StdoutProxy(self.appendPlainText)
try:
try:
# eval is generally considered bad practice. use it wisely!
result = eval(command, self.namespace, self.namespace)
if result is not None:
if self.is_json:
util.print_msg(util.json_encode(result))
else:
self.appendPlainText(repr(result))
except SyntaxError:
# exec is generally considered bad practice. use it wisely!
exec(command, self.namespace, self.namespace)
except SystemExit:
self.close()
except BaseException as e:
te = traceback.TracebackException.from_exception(e)
# rm part of traceback mentioning this file.
# (note: we rm stack items before converting to str, instead of removing lines from the str,
# as this is more reliable. The latter would differ whether the traceback has source text lines,
# which is not always the case.)
te.stack = traceback.StackSummary.from_list(te.stack[1:])
tb_str = "".join(te.format())
# rm last linebreak:
if tb_str.endswith("\n"):
tb_str = tb_str[:-1]
self.appendPlainText(tb_str)
sys.stdout = tmp_stdout
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Tab:
self.completions()
return
self.hide_completions()
if event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
self.run_command()
return
if event.key() == Qt.Key.Key_Home:
self.setCursorPosition(0)
return
if event.key() == Qt.Key.Key_PageUp:
return
elif event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Backspace):
if self.getCursorPosition() == 0:
return
elif event.key() == Qt.Key.Key_Up:
self.setCommand(self.getPrevHistoryEntry())
return
elif event.key() == Qt.Key.Key_Down:
self.setCommand(self.getNextHistoryEntry())
return
elif event.key() == Qt.Key.Key_L and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
self.clear()
elif event.key() == Qt.Key.Key_C and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
if not self.textCursor().selectedText():
self.keyboard_interrupt()
elif event.key() == Qt.Key.Key_Plus and Qt.KeyboardModifier.ControlModifier in event.modifiers():
self.set_font_size(self.font_size + 1)
return
elif event.key() == Qt.Key.Key_Minus and Qt.KeyboardModifier.ControlModifier in event.modifiers():
self.set_font_size(self.font_size - 1)
return
super(Console, self).keyPressEvent(event)
def completions(self):
cmd = self.getCommand()
# note for regex: new words start after ' ' or '(' or ')'
lastword = re.split(r'[ ()]', cmd)[-1]
beginning = cmd[0:-len(lastword)]
path = lastword.split('.')
prefix = '.'.join(path[:-1])
prefix = (prefix + '.') if prefix else prefix
ns = self.namespace.keys()
if len(path) == 1:
ns = ns
else:
assert len(path) > 1
obj = self.namespace.get(path[0])
try:
for attr in path[1:-1]:
obj = getattr(obj, attr)
except AttributeError:
ns = []
else:
ns = dir(obj)
completions = []
for name in ns:
if name[0] == '_':continue
if name.startswith(path[-1]):
completions.append(prefix+name)
completions.sort()
if not completions:
self.hide_completions()
elif len(completions) == 1:
self.hide_completions()
self.setCommand(beginning + completions[0])
else:
# find common prefix
p = os.path.commonprefix(completions)
if len(p)>len(lastword):
self.hide_completions()
self.setCommand(beginning + p)
else:
self.show_completions(completions)
================================================
FILE: electrum/gui/qt/contact_list.py
================================================
#!/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 enum
from typing import TYPE_CHECKING
from PyQt6.QtGui import QStandardItemModel, QStandardItem
from PyQt6.QtCore import Qt, QPersistentModelIndex, QModelIndex
from PyQt6.QtWidgets import (QAbstractItemView, QMenu)
from electrum.i18n import _
from electrum.bitcoin import is_address
from electrum.util import block_explorer_URL
from electrum.plugin import run_hook
from electrum.gui.qt.util import read_QIcon
from .util import webopen
from .my_treeview import MyTreeView
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class ContactList(MyTreeView):
class Columns(MyTreeView.BaseColumnsEnum):
NAME = enum.auto()
ADDRESS = enum.auto()
headers = {
Columns.NAME: _('Name'),
Columns.ADDRESS: _('Address'),
}
filter_columns = [Columns.NAME, Columns.ADDRESS]
ROLE_CONTACT_KEY = Qt.ItemDataRole.UserRole + 1000
key_role = ROLE_CONTACT_KEY
def __init__(self, main_window: 'ElectrumWindow'):
super().__init__(
main_window=main_window,
stretch_column=self.Columns.ADDRESS,
editable_columns=[self.Columns.NAME],
)
self.setModel(QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSortingEnabled(True)
self.std_model = self.model()
self.update()
def on_edited(self, idx, edit_key, *, text):
_type, prior_name = self.main_window.contacts.pop(edit_key)
self.main_window.set_contact(text, edit_key)
self.update()
def create_menu(self, position):
menu = QMenu()
idx = self.indexAt(position)
column = idx.column() or self.Columns.NAME
selected_keys = []
for s_idx in self.selected_in_column(self.Columns.NAME):
sel_key = self.model().itemFromIndex(s_idx).data(self.ROLE_CONTACT_KEY)
selected_keys.append(sel_key)
if selected_keys and idx.isValid():
column_title = self.model().horizontalHeaderItem(column).text()
column_data = '\n'.join(self.model().itemFromIndex(s_idx).text()
for s_idx in self.selected_in_column(column))
menu.addAction(_("Copy {}").format(column_title), lambda: self.place_text_on_clipboard(column_data, title=column_title))
if column in self.editable_columns:
item = self.model().itemFromIndex(idx)
if item.isEditable():
# would not be editable if openalias
persistent = QPersistentModelIndex(idx)
menu.addAction(_("Edit {}").format(column_title), lambda p=persistent: self.edit(QModelIndex(p)))
menu.addAction(_("Pay to"), lambda: self.main_window.payto_contacts(selected_keys))
menu.addAction(_("Delete"), lambda: self.main_window.delete_contacts(selected_keys))
URLs = [block_explorer_URL(self.config, 'addr', key) for key in filter(is_address, selected_keys)]
if URLs:
menu.addAction(_("View on block explorer"), lambda: [webopen(u) for u in URLs])
run_hook('create_contact_menu', menu, selected_keys)
self.open_menu(menu, position)
def update(self):
if self.maybe_defer_update():
return
current_key = self.get_role_data_for_current_item(col=self.Columns.NAME, role=self.ROLE_CONTACT_KEY)
self.model().clear()
self.update_headers(self.__class__.headers)
set_current = None
for key in sorted(self.main_window.contacts.keys()):
contact_type, name = self.main_window.contacts[key]
labels = [""] * len(self.Columns)
labels[self.Columns.NAME] = name
labels[self.Columns.ADDRESS] = key
items = [QStandardItem(x) for x in labels]
items[self.Columns.NAME].setEditable(contact_type != 'openalias')
items[self.Columns.ADDRESS].setEditable(False)
items[self.Columns.NAME].setData(key, self.ROLE_CONTACT_KEY)
items[self.Columns.NAME].setIcon(
read_QIcon("lightning" if contact_type == 'lnaddress' else "bitcoin")
)
row_count = self.model().rowCount()
self.model().insertRow(row_count, items)
if key == current_key:
idx = self.model().index(row_count, self.Columns.NAME)
set_current = QPersistentModelIndex(idx)
self.set_current_idx(set_current)
# FIXME refresh loses sort order; so set "default" here:
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
self.filter()
run_hook('update_contacts_tab', self)
def refresh_row(self, key, row):
# nothing to update here
pass
def get_edit_key_from_coordinate(self, row, col):
if col != self.Columns.NAME:
return None
return self.get_role_data_from_coordinate(row, col, role=self.ROLE_CONTACT_KEY)
def create_toolbar(self, config):
toolbar, menu = self.create_toolbar_with_menu('')
menu.addAction(_("&New contact"), self.main_window.new_contact_dialog)
menu.addAction(_("Import"), lambda: self.main_window.import_contacts())
menu.addAction(_("Export"), lambda: self.main_window.export_contacts())
return toolbar
================================================
FILE: electrum/gui/qt/custom_model.py
================================================
# loosely based on
# http://trevorius.com/scrapbook/uncategorized/pyqt-custom-abstractitemmodel/
from PyQt6 import QtCore
class CustomNode:
def __init__(self, model: 'CustomModel', data):
self.model = model
self._data = data
self._children = []
self._parent = None
self._row = 0
def get_data(self):
return self._data
def get_data_for_role(self, index, role):
# define in child class
raise NotImplementedError()
def childCount(self):
return len(self._children)
def child(self, row):
if row >= 0 and row < self.childCount():
return self._children[row]
def parent(self):
return self._parent
def row(self):
return self._row
def addChild(self, child):
child._parent = self
child._row = len(self._children)
self._children.append(child)
class CustomModel(QtCore.QAbstractItemModel):
def __init__(self, parent, columncount):
QtCore.QAbstractItemModel.__init__(self, parent)
self._root = CustomNode(self, None)
self._columncount = columncount
def rowCount(self, index):
if index.isValid():
return index.internalPointer().childCount()
return self._root.childCount()
def columnCount(self, index):
return self._columncount
def addChild(self, node, _parent):
if not _parent or not _parent.isValid():
parent = self._root
else:
parent = _parent.internalPointer()
parent.addChild(self, node)
def index(self, row, column, _parent=None):
# Performance-critical function
if not _parent or not _parent.isValid():
parent = self._root
else:
parent = _parent.internalPointer()
# Open-coded
# if not QtCore.QAbstractItemModel.hasIndex(self, row, column, _parent):
# the implementation is equivalent but it's in C++,
# so VM entries take up inordinate amounts of time (up to 25% of refresh()):
if row < 0 or column < 0 or row >= self.rowCount(_parent) or column >= self._columncount:
return QtCore.QModelIndex()
child = parent.child(row)
if child:
return QtCore.QAbstractItemModel.createIndex(self, row, column, child)
else:
return QtCore.QModelIndex()
def parent(self, index):
if index.isValid():
node = index.internalPointer()
if node:
p = node.parent()
if p:
return QtCore.QAbstractItemModel.createIndex(self, p.row(), 0, p)
else:
return QtCore.QModelIndex()
return QtCore.QModelIndex()
def data(self, index, role):
if not index.isValid():
return None
node = index.internalPointer()
return node.get_data_for_role(index, role)
================================================
FILE: electrum/gui/qt/exception_window.py
================================================
#!/usr/bin/env python
#
# 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 sys
import html
from typing import TYPE_CHECKING, Optional, Set
from PyQt6.QtCore import QObject, Qt
import PyQt6.QtCore as QtCore
from PyQt6.QtWidgets import (QWidget, QLabel, QPushButton, QTextEdit,
QMessageBox, QHBoxLayout, QVBoxLayout, QDialog, QScrollArea)
from electrum.i18n import _
from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue, CrashReportResponse
from electrum.logging import Logger
from electrum import constants
from electrum.network import Network
from .util import MessageBoxMixin, read_QIcon, WaitingDialog, font_height
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
from electrum.wallet import Abstract_Wallet
class Exception_Window(BaseCrashReporter, QWidget, MessageBoxMixin, Logger):
_active_window = None
def __init__(self, config: 'SimpleConfig', exctype, value, tb):
BaseCrashReporter.__init__(self, exctype, value, tb)
self.network = Network.get_instance()
self.config = config
QWidget.__init__(self)
self.setWindowTitle('Electrum - ' + _('An Error Occurred'))
self.setMinimumSize(600, 300)
Logger.__init__(self)
main_box = QVBoxLayout()
heading = QLabel('
' + BaseCrashReporter.CRASH_TITLE + '
')
main_box.addWidget(heading)
main_box.addWidget(QLabel(BaseCrashReporter.CRASH_MESSAGE))
main_box.addWidget(QLabel(BaseCrashReporter.REQUEST_HELP_MESSAGE))
self._report_contents_dlg = None # type: Optional[ReportContentsDialog]
collapse_info = QPushButton(_("Show report contents"))
collapse_info.clicked.connect(lambda _checked: self.show_report_contents_dlg())
main_box.addWidget(collapse_info)
main_box.addWidget(QLabel(BaseCrashReporter.DESCRIBE_ERROR_MESSAGE))
self.description_textfield = QTextEdit()
self.description_textfield.setFixedHeight(4 * font_height())
self.description_textfield.setPlaceholderText(self.USER_COMMENT_PLACEHOLDER)
main_box.addWidget(self.description_textfield)
main_box.addWidget(QLabel(BaseCrashReporter.ASK_CONFIRM_SEND))
buttons = QHBoxLayout()
report_button = QPushButton(_('Send Bug Report'))
report_button.clicked.connect(lambda _checked: self._ask_for_confirm_to_send_report())
report_button.setIcon(read_QIcon("tab_send.png"))
buttons.addWidget(report_button)
close_button = QPushButton(_('Not Now'))
close_button.clicked.connect(lambda _checked: self.close())
buttons.addWidget(close_button)
main_box.addLayout(buttons)
# prioritizes the window input over all other windows
self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
self.setLayout(main_box)
self.show()
def _ask_for_confirm_to_send_report(self):
if self.question("Confirm to send bugreport?"):
self.send_report()
def send_report(self):
def on_success(response: CrashReportResponse):
text = response.text
if response.url:
text += f" You can track further progress on GitHub."
self.show_message(parent=self,
title=_("Crash report"),
msg=text,
rich_text=True)
self.close()
def on_failure(exc_info):
e = exc_info[1]
self.logger.error('There was a problem with the automatic reporting', exc_info=exc_info)
self.show_critical(parent=self,
msg=(_('There was a problem with the automatic reporting:') + ' ' +
repr(e)[:120] + '
' +
_("Please report this issue manually") +
f' on GitHub.'),
rich_text=True)
proxy = self.network.proxy
task = lambda: BaseCrashReporter.send_report(self, self.network.asyncio_loop, proxy)
msg = _('Sending crash report...')
WaitingDialog(self, msg, task, on_success, on_failure)
def on_close(self):
Exception_Window._active_window = None
self.close()
def closeEvent(self, event):
self.on_close()
event.accept()
def get_user_description(self):
return self.description_textfield.toPlainText()
def get_wallet_type(self):
wallet_types = Exception_Hook._INSTANCE.wallet_types_seen
return ",".join(wallet_types)
def _get_traceback_str_to_display(self) -> str:
# The msg_box that shows the report uses rich_text=True, so
# if traceback contains special HTML characters, e.g. '<',
# they need to be escaped to avoid formatting issues.
traceback_str = super()._get_traceback_str_to_display()
return html.escape(traceback_str)
def show_report_contents_dlg(self):
if self._report_contents_dlg is None:
self._report_contents_dlg = ReportContentsDialog(
parent=self,
text=self.get_report_string(),
)
self._report_contents_dlg.show()
self._report_contents_dlg.raise_()
def _show_window(*args):
if not Exception_Window._active_window:
Exception_Window._active_window = Exception_Window(*args)
class Exception_Hook(QObject, Logger):
_report_exception = QtCore.pyqtSignal(object, object, object, object)
_INSTANCE = None # type: Optional[Exception_Hook] # singleton
def __init__(self, *, config: 'SimpleConfig'):
QObject.__init__(self)
Logger.__init__(self)
assert self._INSTANCE is None, "Exception_Hook is supposed to be a singleton"
self.config = config
self.wallet_types_seen = set() # type: Set[str]
self.exception_ids_seen = set() # type: Set[bytes]
sys.excepthook = self.handler
self._report_exception.connect(_show_window)
EarlyExceptionsQueue.set_hook_as_ready()
@classmethod
def maybe_setup(cls, *, config: 'SimpleConfig', wallet: 'Abstract_Wallet' = None) -> None:
if not cls._INSTANCE:
cls._INSTANCE = Exception_Hook(config=config)
if wallet:
cls._INSTANCE.wallet_types_seen.add(wallet.wallet_type)
def handler(self, *exc_info):
self.logger.error('exception caught by crash reporter', exc_info=exc_info)
groupid_hash = BaseCrashReporter.get_traceback_groupid_hash(*exc_info)
if groupid_hash in self.exception_ids_seen:
return # to avoid annoying the user, only show crash reporter once per exception groupid
self.exception_ids_seen.add(groupid_hash)
self._report_exception.emit(self.config, *exc_info)
class ReportContentsDialog(QDialog):
def __init__(self, *, parent: QWidget, text: str):
QDialog.__init__(self, parent)
self.setWindowTitle(_("Report contents"))
self.setMinimumSize(800, 500)
vbox = QVBoxLayout(self)
scroll_area = QScrollArea(self)
report_text = QLabel(text)
report_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
report_text.setTextFormat(Qt.TextFormat.AutoText) # likely rich text
scroll_area.setWidget(report_text)
vbox.addWidget(scroll_area)
================================================
FILE: electrum/gui/qt/fee_slider.py
================================================
import threading
from typing import Callable, Optional
from PyQt6.QtGui import QCursor
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QSlider, QToolTip, QComboBox, QWidget
from electrum.i18n import _
from electrum.fee_policy import FeeMethod, FeePolicy
from electrum.network import Network
class FeeComboBox(QComboBox):
def __init__(self, fee_slider: 'FeeSlider'):
QComboBox.__init__(self)
self.fee_slider = fee_slider
self.addItems([x.name_for_GUI() for x in FeeMethod.slider_values()])
index = FeeMethod.slider_index_of_method(self.fee_slider.fee_policy.method)
self.setCurrentIndex(index)
self.currentIndexChanged.connect(self.on_fee_type)
self.help_msg = '\n'.join([
_('Feerate: the fee slider uses static feerate values'),
_('ETA: fee rate is based on average confirmation time estimates'),
_('Mempool based: fee rate is targeting a depth in the memory pool')
]
)
def on_fee_type(self, x):
method = FeeMethod.slider_values()[x]
self.fee_slider.fee_policy.set_method(method)
self.fee_slider.update(is_initialized=True)
class FeeSlider(QSlider):
def __init__(
self,
*,
parent: Optional[QWidget],
network: Network,
fee_policy: FeePolicy,
callback: Callable[[Optional[int]], None],
):
QSlider.__init__(self, Qt.Orientation.Horizontal, parent=parent)
self.network = network
self.callback = callback
self.fee_policy = fee_policy
self.lock = threading.RLock()
self.update(is_initialized=False)
self.valueChanged.connect(self.moved)
self._active = True
@property
def dyn(self) -> bool:
return self.fee_policy.use_dynamic_estimates
def get_policy(self) -> FeePolicy:
return self.fee_policy
def moved(self, pos):
with self.lock:
if self.fee_policy.method == FeeMethod.FIXED:
return
self.fee_policy.set_value_from_slider_pos(pos)
fee_rate = self.fee_policy.fee_per_kb(self.network)
tooltip = self.fee_policy.get_tooltip(self.network)
QToolTip.showText(QCursor.pos(), tooltip, self)
self.setToolTip(tooltip)
self.callback(fee_rate)
def update(self, *, is_initialized: bool = True):
with self.lock:
if self.fee_policy.method == FeeMethod.FIXED:
return
pos = self.fee_policy.get_slider_pos()
maxp = self.fee_policy.get_slider_max()
self.setRange(0, maxp)
self.setValue(pos)
if is_initialized:
self.moved(pos)
def activate(self):
self._active = True
self.setStyleSheet('')
def deactivate(self):
self._active = False
# TODO it would be nice to find a platform-independent solution
# that makes the slider look as if it was disabled
self.setStyleSheet(
"""
QSlider::groove:horizontal {
border: 1px solid #999999;
height: 8px;
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #B1B1B1, stop:1 #B1B1B1);
margin: 2px 0;
}
QSlider::handle:horizontal {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #b4b4b4, stop:1 #8f8f8f);
border: 1px solid #5c5c5c;
width: 12px;
margin: -2px 0;
border-radius: 3px;
}
"""
)
def is_active(self):
return self._active
================================================
FILE: electrum/gui/qt/history_list.py
================================================
#!/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 os
import time
import datetime
from datetime import date
from typing import TYPE_CHECKING, Tuple, Dict, Any
import threading
import enum
from decimal import Decimal
from PyQt6.QtGui import QFont, QBrush, QColor
from PyQt6.QtCore import (Qt, QPersistentModelIndex, QModelIndex,
QSortFilterProxyModel, QVariant, QItemSelectionModel, QDate, QPoint)
from PyQt6.QtWidgets import (QMenu, QHeaderView, QLabel, QPushButton, QComboBox, QVBoxLayout, QCalendarWidget,
QGridLayout)
from electrum.gui import messages
from electrum.address_synchronizer import TX_HEIGHT_LOCAL
from electrum.i18n import _
from electrum.util import (block_explorer_URL, profiler, TxMinedInfo,
OrderedDictWithIndex, timestamp_to_datetime,
Satoshis, format_time)
from electrum.logging import get_logger, Logger
from electrum.simple_config import SimpleConfig
from .custom_model import CustomNode, CustomModel
from .util import (read_QIcon, MONOSPACE_FONT, Buttons, CancelButton, OkButton,
filename_field, AcceptFileDragDrop, WindowModalDialog,
CloseButton, webopen, WWLabel)
from .my_treeview import MyTreeView
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
from .main_window import ElectrumWindow
_logger = get_logger(__name__)
TX_ICONS = [
"unconfirmed.png",
"warning.png",
"offline_tx.png",
"offline_tx.png",
"clock1.png",
"clock2.png",
"clock3.png",
"clock4.png",
"clock5.png",
"confirmed.png",
]
class HistorySortModel(QSortFilterProxyModel):
def data_for(self, index: QModelIndex):
col = index.column()
if col == HistoryColumns.STATUS:
# respect sort order of self.transactions (wallet.get_full_history)
return index.row()
else:
node = index.internalPointer()
return node.sort_keys[col]
def lessThan(self, source_left: QModelIndex, source_right: QModelIndex):
return self.data_for(source_left) < self.data_for(source_right)
def get_item_key(tx_item):
return tx_item.get('txid') or tx_item['payment_hash']
def flatten_sort_key(v):
if v is None or isinstance(v, Decimal) and v.is_nan():
return -float("inf")
else:
return v
class HistoryNode(CustomNode):
model: 'HistoryModel'
def __init__(self, model: 'CustomModel', tx_item):
super().__init__(model, tx_item)
if tx_item is None:
tx_item = {}
is_lightning = tx_item.get('lightning', False)
short_id = ""
if not is_lightning:
txpos_in_block = tx_item.get('txpos_in_block') or -1
if txpos_in_block >= 0:
short_id = f"{tx_item['height']}x{txpos_in_block}"
self.sort_keys = {
HistoryColumns.DESCRIPTION: flatten_sort_key(
tx_item.get('label')),
HistoryColumns.AMOUNT: flatten_sort_key(
(tx_item['bc_value'].value if 'bc_value' in tx_item else 0)\
+ (tx_item['ln_value'].value if 'ln_value' in tx_item else 0)),
HistoryColumns.BALANCE: 0,
HistoryColumns.FIAT_VALUE: flatten_sort_key(
tx_item['fiat_value'].value if 'fiat_value' in tx_item else None),
HistoryColumns.FIAT_ACQ_PRICE: flatten_sort_key(
tx_item['acquisition_price'].value if 'acquisition_price' in tx_item else None),
HistoryColumns.FIAT_CAP_GAINS: flatten_sort_key(
tx_item['capital_gain'].value if 'capital_gain' in tx_item else None),
HistoryColumns.TXID: flatten_sort_key(
tx_item.get('txid') if not is_lightning else None),
HistoryColumns.SHORT_ID:
short_id,
}
def set_balance(self, balance):
self._data['balance'] = Satoshis(balance)
self.sort_keys[HistoryColumns.BALANCE] = balance
def get_data_for_role(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant:
assert index.isValid()
col = index.column()
window = self.model.window
tx_item = self.get_data()
is_lightning = tx_item.get('lightning', False)
if not is_lightning and 'txid' not in tx_item:
# this may happen if two lightning tx have the same group id
# and the group does not have an onchain tx
is_lightning = True
timestamp = tx_item['timestamp']
if is_lightning:
status = 0
if timestamp is None:
status_str = 'unconfirmed'
else:
status_str = format_time(int(timestamp))
else:
tx_hash = tx_item['txid']
conf = tx_item['confirmations']
try:
status, status_str = self.model.tx_status_cache[tx_hash]
except KeyError:
tx_mined_info = self.model._tx_mined_info_from_tx_item(tx_item)
status, status_str = window.wallet.get_tx_status(tx_hash, tx_mined_info)
if role == MyTreeView.ROLE_EDIT_KEY:
return QVariant(get_item_key(tx_item))
if role not in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, MyTreeView.ROLE_CLIPBOARD_DATA):
if col == HistoryColumns.STATUS and role == Qt.ItemDataRole.DecorationRole:
icon = "lightning" if is_lightning else TX_ICONS[status]
return QVariant(read_QIcon(icon))
elif col == HistoryColumns.STATUS and role == Qt.ItemDataRole.ToolTipRole:
if is_lightning:
msg = 'lightning transaction'
else: # on-chain
if tx_item['height'] == TX_HEIGHT_LOCAL:
# note: should we also explain double-spends?
msg = _("This transaction is only available on your local machine.\n"
"The currently connected server does not know about it.\n"
"You can either broadcast it now, or simply remove it.")
else:
msg = str(conf) + _(" confirmation" + ("s" if conf != 1 else ""))
return QVariant(msg)
elif col > HistoryColumns.DESCRIPTION and role == Qt.ItemDataRole.TextAlignmentRole:
return QVariant(int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter))
elif col > HistoryColumns.DESCRIPTION and role == Qt.ItemDataRole.FontRole:
monospace_font = QFont(MONOSPACE_FONT)
return QVariant(monospace_font)
#elif col == HistoryColumns.DESCRIPTION and role == Qt.ItemDataRole.DecorationRole and not is_lightning\
# and self.parent.wallet.invoices.paid.get(tx_hash):
# return QVariant(read_QIcon("seal"))
elif col in (HistoryColumns.DESCRIPTION, HistoryColumns.AMOUNT) \
and role == Qt.ItemDataRole.ForegroundRole and tx_item['value'].value < 0:
red_brush = QBrush(QColor("#BC1E1E"))
return QVariant(red_brush)
elif col == HistoryColumns.FIAT_VALUE and role == Qt.ItemDataRole.ForegroundRole \
and not tx_item.get('fiat_default') and tx_item.get('fiat_value') is not None:
blue_brush = QBrush(QColor("#1E1EFF"))
return QVariant(blue_brush)
return QVariant()
add_thousands_sep = None
whitespaces = True
if role == MyTreeView.ROLE_CLIPBOARD_DATA:
add_thousands_sep = False
whitespaces = False
if col == HistoryColumns.STATUS:
return QVariant(status_str)
elif col == HistoryColumns.DESCRIPTION and 'label' in tx_item:
return QVariant(tx_item['label'])
elif col == HistoryColumns.AMOUNT:
bc_value = tx_item['bc_value'].value if 'bc_value' in tx_item else 0
ln_value = tx_item['ln_value'].value if 'ln_value' in tx_item else 0
value = bc_value + ln_value
v_str = window.format_amount(value, is_diff=True, whitespaces=whitespaces, add_thousands_sep=add_thousands_sep)
return QVariant(v_str)
elif col == HistoryColumns.BALANCE:
balance = tx_item['balance'].value if 'balance' in tx_item else None
balance_str = window.format_amount(balance, whitespaces=whitespaces, add_thousands_sep=add_thousands_sep) if balance is not None else ''
return QVariant(balance_str)
elif col == HistoryColumns.FIAT_VALUE and 'fiat_value' in tx_item:
value_str = window.fx.format_fiat(tx_item['fiat_value'].value, add_thousands_sep=add_thousands_sep)
return QVariant(value_str)
elif col == HistoryColumns.FIAT_ACQ_PRICE and \
tx_item['value'].value < 0 and 'acquisition_price' in tx_item:
# fixme: should use is_mine
acq = tx_item['acquisition_price'].value
return QVariant(window.fx.format_fiat(acq, add_thousands_sep=add_thousands_sep))
elif col == HistoryColumns.FIAT_CAP_GAINS and 'capital_gain' in tx_item:
cg = tx_item['capital_gain'].value
return QVariant(window.fx.format_fiat(cg, add_thousands_sep=add_thousands_sep))
elif col == HistoryColumns.TXID:
return QVariant(tx_hash) if not is_lightning else QVariant('')
elif col == HistoryColumns.SHORT_ID:
return QVariant(self.sort_keys[HistoryColumns.SHORT_ID])
return QVariant()
class HistoryModel(CustomModel, Logger):
def __init__(self, window: 'ElectrumWindow'):
CustomModel.__init__(self, window, len(HistoryColumns))
Logger.__init__(self)
self.window = window
self.view = None # type: HistoryList
self.transactions = OrderedDictWithIndex()
self.tx_status_cache = {} # type: Dict[str, Tuple[int, str]]
def set_view(self, history_list: 'HistoryList'):
# FIXME HistoryModel and HistoryList mutually depend on each other.
# After constructing both, this method needs to be called.
self.view = history_list # type: HistoryList
self.set_visibility_of_columns()
def update_label(self, index):
tx_item = index.internalPointer().get_data()
tx_item['label'] = self.window.wallet.get_label_for_txid(
get_item_key(tx_item)) # FIXME get_item_key might return an RHASH, but we call get_label_for_txid?!
topLeft = bottomRight = self.createIndex(index.row(), HistoryColumns.DESCRIPTION)
self.dataChanged.emit(topLeft, bottomRight, [Qt.ItemDataRole.DisplayRole])
self.window.utxo_list.update()
def get_domain(self):
"""Overridden in address_dialog.py"""
return None
def should_include_lightning_payments(self) -> bool:
"""Overridden in address_dialog.py"""
return True
def should_show_fiat(self):
if not self.window.config.FX_HISTORY_RATES:
return False
fx = self.window.fx
if not fx or not fx.is_enabled():
return False
return fx.has_history()
def should_show_capital_gains(self):
return self.should_show_fiat() and self.window.config.FX_HISTORY_RATES_CAPITAL_GAINS
@profiler
def refresh(self, reason: str):
self.logger.info(f"refreshing... reason: {reason}")
assert self.window.gui_thread == threading.current_thread(), 'must be called from GUI thread'
assert self.view, 'view not set'
if self.view.maybe_defer_update():
return
selected = self.view.selectionModel().currentIndex()
selected_row = None
if selected:
selected_row = selected.row()
fx = self.window.fx
if fx:
fx.history_used_spot = False
wallet = self.window.wallet
self.set_visibility_of_columns()
transactions = wallet.get_full_history(
fx=self.window.fx if self.should_show_fiat() else None,
onchain_domain=self.get_domain(),
include_lightning=self.should_include_lightning_payments(),
)
old_length = self._root.childCount()
if old_length != 0:
self.beginRemoveRows(QModelIndex(), 0, old_length)
self.transactions.clear()
self._root = HistoryNode(self, None)
self.endRemoveRows()
parents = {}
for tx_item in transactions.values():
node = HistoryNode(self, tx_item)
self._root.addChild(node)
for child_item in tx_item.get('children', []):
child_node = HistoryNode(self, child_item)
# add child to parent
node.addChild(child_node)
# compute balance once all children have been added
balance = 0
for node in self._root._children:
balance += node._data['value'].value
node.set_balance(balance)
# update tx_status_cache (before endInsertRows() triggers get_data_for_role() calls)
self.tx_status_cache.clear()
for txid, tx_item in transactions.items():
if not tx_item.get('lightning', False):
tx_mined_info = self._tx_mined_info_from_tx_item(tx_item)
self.tx_status_cache[txid] = self.window.wallet.get_tx_status(txid, tx_mined_info)
new_length = self._root.childCount()
self.beginInsertRows(QModelIndex(), 0, new_length-1)
self.transactions = transactions
self.endInsertRows()
if selected_row:
self.view.selectionModel().select(
self.createIndex(selected_row, 0),
QItemSelectionModel.SelectionFlag.Rows | QItemSelectionModel.SelectionFlag.SelectCurrent)
self.view.filter()
# update time filter
if not self.view.years and self.transactions:
start_date = date.today()
end_date = date.today()
if len(self.transactions) > 0:
start_date = self.transactions.value_from_pos(0).get('date') or start_date
end_date = self.transactions.value_from_pos(len(self.transactions) - 1).get('date') or end_date
self.view.years = [str(i) for i in range(start_date.year, end_date.year + 1)]
self.view.period_combo.insertItems(1, self.view.years)
# update counter
num_tx = len(self.transactions)
if self.view:
self.view.num_tx_label.setText(_("{} transactions").format(num_tx))
def set_visibility_of_columns(self):
def set_visible(col: int, b: bool):
self.view.showColumn(col) if b else self.view.hideColumn(col)
# txid
set_visible(HistoryColumns.TXID, False)
set_visible(HistoryColumns.SHORT_ID, False)
# fiat
history = self.should_show_fiat()
cap_gains = self.should_show_capital_gains()
set_visible(HistoryColumns.FIAT_VALUE, history)
set_visible(HistoryColumns.FIAT_ACQ_PRICE, history and cap_gains)
set_visible(HistoryColumns.FIAT_CAP_GAINS, history and cap_gains)
def update_fiat(self, idx):
tx_item = idx.internalPointer().get_data()
txid = tx_item['txid']
fee = tx_item.get('fee')
value = tx_item['value'].value
fiat_fields = self.window.wallet.get_tx_item_fiat(
tx_hash=txid, amount_sat=value, fx=self.window.fx, tx_fee=fee.value if fee else None)
tx_item.update(fiat_fields)
self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ForegroundRole])
def update_tx_mined_status(self, tx_hash: str, tx_mined_info: TxMinedInfo):
try:
row = self.transactions.pos_from_key(tx_hash)
tx_item = self.transactions[tx_hash]
except KeyError:
return
self.tx_status_cache[tx_hash] = self.window.wallet.get_tx_status(tx_hash, tx_mined_info)
tx_item.update({
'confirmations': tx_mined_info.conf,
'timestamp': tx_mined_info.timestamp,
'txpos_in_block': tx_mined_info.txpos,
'date': timestamp_to_datetime(tx_mined_info.timestamp),
})
topLeft = self.createIndex(row, 0)
bottomRight = self.createIndex(row, len(HistoryColumns) - 1)
self.dataChanged.emit(topLeft, bottomRight)
def on_fee_histogram(self):
for tx_hash, tx_item in list(self.transactions.items()):
if tx_item.get('lightning'):
continue
tx_mined_info = self._tx_mined_info_from_tx_item(tx_item)
if tx_mined_info.conf > 0:
# note: we could actually break here if we wanted to rely on the order of txns in self.transactions
continue
self.update_tx_mined_status(tx_hash, tx_mined_info)
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole):
assert orientation == Qt.Orientation.Horizontal
if role != Qt.ItemDataRole.DisplayRole:
return None
fx = self.window.fx
fiat_title = 'n/a fiat value'
fiat_acq_title = 'n/a fiat acquisition price'
fiat_cg_title = 'n/a fiat capital gains'
if self.should_show_fiat():
fiat_title = '%s ' % fx.ccy + _('Value')
fiat_acq_title = '%s ' % fx.ccy + _('Acquisition price')
fiat_cg_title = '%s ' % fx.ccy + _('Capital Gains')
return {
HistoryColumns.STATUS: _('Date'),
HistoryColumns.DESCRIPTION: _('Description'),
HistoryColumns.AMOUNT: _('Amount'),
HistoryColumns.BALANCE: _('Balance'),
HistoryColumns.FIAT_VALUE: fiat_title,
HistoryColumns.FIAT_ACQ_PRICE: fiat_acq_title,
HistoryColumns.FIAT_CAP_GAINS: fiat_cg_title,
HistoryColumns.TXID: 'TXID',
HistoryColumns.SHORT_ID: 'Short ID',
}[section]
def flags(self, idx: QModelIndex) -> Qt.ItemFlag:
extra_flags = Qt.ItemFlag.NoItemFlags # type: Qt.ItemFlag
if idx.column() in self.view.editable_columns:
extra_flags |= Qt.ItemFlag.ItemIsEditable
return super().flags(idx) | extra_flags
@staticmethod
def _tx_mined_info_from_tx_item(tx_item: Dict[str, Any]) -> TxMinedInfo:
# FIXME a bit hackish to have to reconstruct the TxMinedInfo... same thing in qml-gui
tx_mined_info = TxMinedInfo(
_height=tx_item['height'],
conf=tx_item['confirmations'],
timestamp=tx_item['timestamp'],
wanted_height=tx_item.get('wanted_height', None),
)
return tx_mined_info
class HistoryList(MyTreeView, AcceptFileDragDrop):
class Columns(MyTreeView.BaseColumnsEnum):
STATUS = enum.auto()
DESCRIPTION = enum.auto()
AMOUNT = enum.auto()
BALANCE = enum.auto()
FIAT_VALUE = enum.auto()
FIAT_ACQ_PRICE = enum.auto()
FIAT_CAP_GAINS = enum.auto()
TXID = enum.auto()
SHORT_ID = enum.auto() # ~SCID
filter_columns = [
Columns.STATUS,
Columns.DESCRIPTION,
Columns.AMOUNT,
Columns.TXID,
Columns.SHORT_ID,
]
def tx_item_from_proxy_row(self, proxy_row):
hm_idx = self.model().mapToSource(self.model().index(proxy_row, 0))
return hm_idx.internalPointer().get_data()
def should_hide(self, proxy_row):
if self.start_date and self.end_date:
tx_item = self.tx_item_from_proxy_row(proxy_row)
date = tx_item['date']
if date:
in_interval = self.start_date <= date <= self.end_date
if not in_interval:
return True
return False
def __init__(self, main_window: 'ElectrumWindow', model: HistoryModel):
super().__init__(
main_window=main_window,
stretch_column=HistoryColumns.DESCRIPTION,
editable_columns=[HistoryColumns.DESCRIPTION, HistoryColumns.FIAT_VALUE],
)
self.hm = model
self.proxy = HistorySortModel(self)
self.proxy.setSourceModel(model)
self.setModel(self.proxy)
AcceptFileDragDrop.__init__(self, ".txn")
self.setSortingEnabled(True)
self.start_date = None
self.end_date = None
self.years = []
self.period_combo = QComboBox()
self.start_button = QPushButton('-')
self.start_button.pressed.connect(self.select_start_date)
self.start_button.setEnabled(False)
self.end_button = QPushButton('-')
self.end_button.pressed.connect(self.select_end_date)
self.end_button.setEnabled(False)
self.period_combo.addItems([_('All'), _('Custom')])
self.period_combo.activated.connect(self.on_combo)
self.wallet = self.main_window.wallet # type: Abstract_Wallet
self.sortByColumn(HistoryColumns.STATUS, Qt.SortOrder.DescendingOrder)
self.setRootIsDecorated(True)
self.header().setStretchLastSection(False)
for col in HistoryColumns:
sm = QHeaderView.ResizeMode.Stretch if col == self.stretch_column else QHeaderView.ResizeMode.ResizeToContents
self.header().setSectionResizeMode(col, sm)
if self.config:
self.configvar_show_toolbar = self.config.cv.GUI_QT_HISTORY_TAB_SHOW_TOOLBAR
def update(self):
self.hm.refresh('HistoryList.update()')
def format_date(self, d):
return str(datetime.date(d.year, d.month, d.day)) if d else _('None')
def on_combo(self, x):
s = self.period_combo.itemText(x)
x = s == _('Custom')
self.start_button.setEnabled(x)
self.end_button.setEnabled(x)
if s == _('All'):
self.start_date = None
self.end_date = None
self.start_button.setText("-")
self.end_button.setText("-")
else:
try:
year = int(s)
except Exception:
return
self.start_date = datetime.datetime(year, 1, 1)
self.end_date = datetime.datetime(year+1, 1, 1)
self.start_button.setText(_('From') + ' ' + self.format_date(self.start_date))
self.end_button.setText(_('To') + ' ' + self.format_date(self.end_date))
self.hide_rows()
def create_toolbar(self, config: 'SimpleConfig'):
toolbar, menu = self.create_toolbar_with_menu('')
self.num_tx_label = toolbar.itemAt(0).widget()
self._toolbar_checkbox = menu.addToggle(_("Filter by Date"), lambda: self.toggle_toolbar())
self.menu_fiat = menu.addConfig(config.cv.FX_HISTORY_RATES, short_desc=_('Show Fiat Values'), callback=self.main_window.app.update_fiat_signal.emit)
self.menu_capgains = menu.addConfig(config.cv.FX_HISTORY_RATES_CAPITAL_GAINS, callback=self.main_window.app.update_fiat_signal.emit)
self.menu_summary = menu.addAction(_("&Summary"), self.show_summary)
menu.addAction(_("&Plot"), self.plot_history_dialog)
menu.addAction(_("&Export"), self.export_history_dialog)
hbox = self.create_toolbar_buttons()
toolbar.insertLayout(1, hbox)
self.update_toolbar_menu()
return toolbar
def update_toolbar_menu(self):
fx = self.main_window.fx
self.menu_fiat.setEnabled(fx and fx.can_have_history())
# setChecked because has_history can be modified through settings dialog
self.menu_fiat.setChecked(fx and fx.has_history())
self.menu_capgains.setEnabled(fx and fx.has_history())
self.menu_summary.setEnabled(fx and fx.has_history())
def get_toolbar_buttons(self):
return self.period_combo, self.start_button, self.end_button
def on_hide_toolbar(self):
self.start_date = None
self.end_date = None
self.hide_rows()
def select_start_date(self):
self.start_date = self.select_date(self.start_button)
self.hide_rows()
def select_end_date(self):
self.end_date = self.select_date(self.end_button)
self.hide_rows()
def select_date(self, button):
d = WindowModalDialog(self, _("Select date"))
d.setMinimumSize(600, 150)
d.date = None
vbox = QVBoxLayout()
def on_date(date):
d.date = date
cal = QCalendarWidget()
cal.setGridVisible(True)
cal.clicked[QDate].connect(on_date)
vbox.addWidget(cal)
vbox.addLayout(Buttons(OkButton(d), CancelButton(d)))
d.setLayout(vbox)
if d.exec():
if d.date is None:
return None
date = d.date.toPyDate()
button.setText(self.format_date(date))
return datetime.datetime(date.year, date.month, date.day)
def show_summary(self):
if not self.hm.should_show_fiat():
self.main_window.show_message(_("Enable fiat exchange rate with history."))
return
fx = self.main_window.fx
summary = self.wallet.get_onchain_capital_gains(
from_timestamp=time.mktime(self.start_date.timetuple()) if self.start_date else None,
to_timestamp=time.mktime(self.end_date.timetuple()) if self.end_date else None,
fx=fx)
if not summary:
self.main_window.show_message(_("Nothing to summarize."))
return
start = summary['begin']
end = summary['end']
flow = summary['flow']
start_date = start.get('date')
end_date = end.get('date')
format_amount = lambda x: self.main_window.format_amount(x.value) + ' ' + self.main_window.base_unit()
format_fiat = lambda x: str(x) + ' ' + self.main_window.fx.ccy
d = WindowModalDialog(self, _("Summary"))
d.setMinimumSize(600, 150)
vbox = QVBoxLayout()
msg = messages.to_rtf(messages.MSG_CAPITAL_GAINS)
vbox.addWidget(WWLabel(msg))
grid = QGridLayout()
grid.addWidget(QLabel(_("Begin")), 0, 1)
grid.addWidget(QLabel(_("End")), 0, 2)
#
grid.addWidget(QLabel(_("Date")), 1, 0)
grid.addWidget(QLabel(self.format_date(start_date)), 1, 1)
grid.addWidget(QLabel(self.format_date(end_date)), 1, 2)
#
grid.addWidget(QLabel(_("BTC balance")), 2, 0)
grid.addWidget(QLabel(format_amount(start['BTC_balance'])), 2, 1)
grid.addWidget(QLabel(format_amount(end['BTC_balance'])), 2, 2)
#
grid.addWidget(QLabel(_("BTC Fiat price")), 3, 0)
grid.addWidget(QLabel(format_fiat(start.get('BTC_fiat_price'))), 3, 1)
grid.addWidget(QLabel(format_fiat(end.get('BTC_fiat_price'))), 3, 2)
#
grid.addWidget(QLabel(_("Fiat balance")), 4, 0)
grid.addWidget(QLabel(format_fiat(start.get('fiat_balance'))), 4, 1)
grid.addWidget(QLabel(format_fiat(end.get('fiat_balance'))), 4, 2)
#
grid.addWidget(QLabel(_("Acquisition price")), 5, 0)
grid.addWidget(QLabel(format_fiat(start.get('acquisition_price', ''))), 5, 1)
grid.addWidget(QLabel(format_fiat(end.get('acquisition_price', ''))), 5, 2)
#
grid.addWidget(QLabel(_("Unrealized capital gains")), 6, 0)
grid.addWidget(QLabel(format_fiat(start.get('unrealized_gains', ''))), 6, 1)
grid.addWidget(QLabel(format_fiat(end.get('unrealized_gains', ''))), 6, 2)
#
grid2 = QGridLayout()
grid2.addWidget(QLabel(_("BTC incoming")), 0, 0)
grid2.addWidget(QLabel(format_amount(flow['BTC_incoming'])), 0, 1)
grid2.addWidget(QLabel(_("Fiat incoming")), 1, 0)
grid2.addWidget(QLabel(format_fiat(flow.get('fiat_incoming'))), 1, 1)
grid2.addWidget(QLabel(_("BTC outgoing")), 2, 0)
grid2.addWidget(QLabel(format_amount(flow['BTC_outgoing'])), 2, 1)
grid2.addWidget(QLabel(_("Fiat outgoing")), 3, 0)
grid2.addWidget(QLabel(format_fiat(flow.get('fiat_outgoing'))), 3, 1)
#
grid2.addWidget(QLabel(_("Realized capital gains")), 4, 0)
grid2.addWidget(QLabel(format_fiat(flow.get('realized_capital_gains'))), 4, 1)
vbox.addLayout(grid)
vbox.addWidget(QLabel(_('Cash flow')))
vbox.addLayout(grid2)
vbox.addLayout(Buttons(CloseButton(d)))
d.setLayout(vbox)
d.exec()
def plot_history_dialog(self):
try:
from electrum.plot import plot_history, NothingToPlotException
except ImportError as e:
_logger.error(f"could not import electrum.plot. This feature needs matplotlib to be installed. exc={e!r}")
self.main_window.show_message("\n\n".join([
_("This feature requires the 'matplotlib' Python library which is not "
"included in Electrum by default."),
_("If you run Electrum from source you can install matplotlib to use this feature."),
_("It is not possible to install matplotlib inside the binary executables "
"(e.g. AppImage or Windows installation).")
]))
return
try:
plt = plot_history(list(self.hm.transactions.values()))
plt.show()
except NothingToPlotException as e:
self.main_window.show_message(str(e))
def on_edited(self, idx, edit_key, *, text):
index = self.model().mapToSource(idx)
tx_item = index.internalPointer().get_data()
column = index.column()
key = get_item_key(tx_item)
if column == HistoryColumns.DESCRIPTION:
if self.wallet.set_label(key, text): # changed
self.hm.update_label(index)
self.main_window.update_completions()
elif column == HistoryColumns.FIAT_VALUE:
self.wallet.set_fiat_value(key, self.main_window.fx.ccy, text, self.main_window.fx, tx_item['value'].value)
value = tx_item['value'].value
if value is not None:
self.hm.update_fiat(index)
else:
raise Exception(f"did not expect {column=!r} to get edited")
def on_double_click(self, idx):
tx_item = idx.internalPointer().get_data()
if tx_item.get('lightning'):
if tx_item['type'] == 'payment':
self.main_window.show_lightning_transaction(tx_item)
return
tx_hash = tx_item['txid']
tx = self.wallet.adb.get_transaction(tx_hash)
if not tx:
return
self.main_window.show_transaction(tx)
def add_copy_menu(self, menu, idx):
cc = menu.addMenu(_("Copy"))
for column in HistoryColumns:
if self.isColumnHidden(column):
continue
column_title = self.hm.headerData(column, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole)
idx2 = idx.sibling(idx.row(), column)
clipboard_data = self.hm.data(idx2, self.ROLE_CLIPBOARD_DATA).value()
if clipboard_data is None:
clipboard_data = (self.hm.data(idx2, Qt.ItemDataRole.DisplayRole).value() or '').strip()
cc.addAction(
column_title,
lambda text=clipboard_data, title=column_title:
self.place_text_on_clipboard(text, title=title))
return cc
def create_menu(self, position: QPoint):
org_idx: QModelIndex = self.indexAt(position)
idx = self.proxy.mapToSource(org_idx)
if not idx.isValid():
# can happen e.g. before list is populated for the first time
return
tx_item = idx.internalPointer().get_data()
if tx_item.get('lightning'):
menu = QMenu()
menu.addAction(_("Details"), lambda: self.main_window.show_lightning_transaction(tx_item))
cc = self.add_copy_menu(menu, idx)
cc.addAction(_("Payment Hash"), lambda: self.place_text_on_clipboard(tx_item['payment_hash'], title="Payment Hash"))
cc.addAction(_("Preimage"), lambda: self.place_text_on_clipboard(tx_item['preimage'], title="Preimage"))
key = tx_item['payment_hash']
log = self.wallet.lnworker.logs.get(key)
if log:
menu.addAction(_("View log"), lambda: self.main_window.send_tab.invoice_list.show_log(key, log))
menu.exec(self.viewport().mapToGlobal(position))
return
tx_hash = tx_item['txid']
tx = self.wallet.adb.get_transaction(tx_hash)
if not tx:
return
tx_URL = block_explorer_URL(self.config, 'tx', tx_hash)
tx_details = self.wallet.get_tx_info(tx)
is_unconfirmed = tx_details.tx_mined_status.height() <= 0
menu = QMenu()
menu.addAction(_("Details"), lambda: self.main_window.show_transaction(tx))
if tx_details.can_remove:
menu.addAction(_("Remove"), lambda: self.remove_local_tx(tx_hash))
copy_menu = self.add_copy_menu(menu, idx)
copy_menu.addAction(_("Transaction ID"), lambda: self.place_text_on_clipboard(tx_hash, title="TXID"))
menu_edit = menu.addMenu(_("Edit"))
for c in self.editable_columns:
if self.isColumnHidden(c):
continue
label = self.hm.headerData(c, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole)
# TODO use siblingAtColumn when min Qt version is >=5.11
persistent = QPersistentModelIndex(org_idx.sibling(org_idx.row(), c))
menu_edit.addAction(_("{}").format(label), lambda p=persistent: self.edit(QModelIndex(p)))
channel_id = tx_item.get('channel_id')
if channel_id and self.wallet.lnworker and (chan := self.wallet.lnworker.get_channel_by_id(bytes.fromhex(channel_id))):
menu.addAction(_("View Channel"), lambda: self.main_window.show_channel_details(chan))
if is_unconfirmed and tx:
if tx_details.can_bump:
menu.addAction(_("Increase fee"), lambda: self.main_window.bump_fee_dialog(tx))
else:
if tx_details.can_cpfp:
menu.addAction(_("Child pays for parent"), lambda: self.main_window.cpfp_dialog(tx))
if tx_details.can_dscancel:
menu.addAction(_("Cancel (double-spend)"), lambda: self.main_window.dscancel_dialog(tx))
invoices = self.wallet.get_relevant_invoices_for_tx(tx_hash)
if len(invoices) == 1:
menu.addAction(_("View invoice"), lambda inv=invoices[0]: self.main_window.show_onchain_invoice(inv))
elif len(invoices) > 1:
menu_invs = menu.addMenu(_("Related invoices"))
for inv in invoices:
menu_invs.addAction(_("View invoice"), lambda inv=inv: self.main_window.show_onchain_invoice(inv))
if tx_URL:
menu.addAction(_("View on block explorer"), lambda: webopen(tx_URL))
self.open_menu(menu, position)
def remove_local_tx(self, tx_hash: str):
num_child_txs = len(self.wallet.adb.get_depending_transactions(tx_hash))
question = _("Are you sure you want to remove this transaction?")
if num_child_txs > 0:
question = (_("Are you sure you want to remove this transaction and {} child transactions?")
.format(num_child_txs))
if not self.main_window.question(msg=question, title=_("Please confirm")):
return
self.wallet.adb.remove_transaction(tx_hash)
self.wallet.save_db()
# need to update at least: history_list, utxo_list, address_list
self.main_window.need_update.set()
def onFileAdded(self, fn):
try:
with open(fn) as f:
tx = self.main_window.tx_from_text(f.read())
except IOError as e:
self.main_window.show_error(e)
return
if not tx:
return
self.main_window.save_transaction_into_wallet(tx)
def export_history_dialog(self):
d = WindowModalDialog(self, _('Export History'))
d.setMinimumSize(400, 200)
vbox = QVBoxLayout(d)
defaultname = f'electrum-history-{self.wallet.basename()}.csv'
select_msg = _('Select file to export your wallet transactions to')
hbox, filename_e, csv_button = filename_field(self, self.config, defaultname, select_msg)
vbox.addLayout(hbox)
vbox.addStretch(1)
hbox = Buttons(CancelButton(d), OkButton(d, _('Export')))
vbox.addLayout(hbox)
#run_hook('export_history_dialog', self, hbox)
self.update()
if not d.exec():
return
filename = filename_e.text()
if not filename:
return
try:
self.wallet.export_history_to_file(
fx=self.main_window.fx if self.hm.should_show_fiat() else None,
file_path=filename,
is_csv=csv_button.isChecked(),
)
except (IOError, os.error) as reason:
export_error_label = _("Electrum was unable to produce a transaction export.")
self.main_window.show_critical(export_error_label + "\n" + str(reason), title=_("Unable to export history"))
return
self.main_window.show_message(_("Your wallet history has been successfully exported."))
def get_text_from_coordinate(self, row, col):
return self.get_role_data_from_coordinate(row, col, role=Qt.ItemDataRole.DisplayRole)
def get_role_data_from_coordinate(self, row, col, *, role):
idx = self.model().mapToSource(self.model().index(row, col))
return self.hm.data(idx, role).value()
HistoryColumns = HistoryList.Columns
================================================
FILE: electrum/gui/qt/invoice_list.py
================================================
#!/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 enum
from typing import Sequence, TYPE_CHECKING
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QStandardItemModel, QStandardItem
from PyQt6.QtWidgets import QAbstractItemView
from PyQt6.QtWidgets import QMenu, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView
from electrum.i18n import _
from electrum.util import format_time
from electrum.invoices import PR_UNPAID, PR_INFLIGHT, PR_FAILED
from electrum.lnutil import HtlcLog
from .util import read_QIcon, pr_icons
from .util import CloseButton, Buttons
from .util import WindowModalDialog
from .my_treeview import MyTreeView, MySortModel
if TYPE_CHECKING:
from .send_tab import SendTab
ROLE_REQUEST_TYPE = Qt.ItemDataRole.UserRole
ROLE_REQUEST_ID = Qt.ItemDataRole.UserRole + 1
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 2
class InvoiceList(MyTreeView):
key_role = ROLE_REQUEST_ID
class Columns(MyTreeView.BaseColumnsEnum):
DATE = enum.auto()
DESCRIPTION = enum.auto()
AMOUNT = enum.auto()
STATUS = enum.auto()
headers = {
Columns.DATE: _('Date'),
Columns.DESCRIPTION: _('Description'),
Columns.AMOUNT: _('Amount'),
Columns.STATUS: _('Status'),
}
filter_columns = [Columns.DATE, Columns.DESCRIPTION, Columns.AMOUNT]
def __init__(self, send_tab: 'SendTab'):
window = send_tab.window
super().__init__(
main_window=window,
stretch_column=self.Columns.DESCRIPTION,
)
self.wallet = window.wallet
self.send_tab = send_tab
self.std_model = QStandardItemModel(self)
self.proxy = MySortModel(self, sort_role=ROLE_SORT_ORDER)
self.proxy.setSourceModel(self.std_model)
self.setModel(self.proxy)
self.setSortingEnabled(True)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
def on_double_click(self, idx):
key = idx.sibling(idx.row(), self.Columns.DATE).data(ROLE_REQUEST_ID)
self.show_invoice(key)
def refresh_row(self, key, row):
assert row is not None
invoice = self.wallet.get_invoice(key)
if invoice is None:
return
model = self.std_model
status_item = model.item(row, self.Columns.STATUS)
status = self.wallet.get_invoice_status(invoice)
status_str = invoice.get_status_str(status)
if self.wallet.lnworker:
log = self.wallet.lnworker.logs.get(key)
if log and status == PR_INFLIGHT:
status_str += '... (%d)'%len(log)
status_item.setText(status_str)
status_item.setIcon(read_QIcon(pr_icons.get(status)))
def update(self):
# not calling maybe_defer_update() as it interferes with conditional-visibility
self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change
self.std_model.clear()
self.update_headers(self.__class__.headers)
for idx, item in enumerate(self.wallet.get_unpaid_invoices()):
key = item.get_id()
if item.is_lightning():
icon_name = 'lightning.png'
else:
icon_name = 'bitcoin.png'
if item.bip70:
icon_name = 'seal.png'
status = self.wallet.get_invoice_status(item)
amount = item.get_amount_sat()
amount_str = self.main_window.format_amount(amount, whitespaces=True) if amount else ""
amount_str_nots = self.main_window.format_amount(amount, whitespaces=True, add_thousands_sep=False) if amount else ""
timestamp = item.time or 0
labels = [""] * len(self.Columns)
labels[self.Columns.DATE] = format_time(timestamp) if timestamp else _('Unknown')
labels[self.Columns.DESCRIPTION] = item.message
labels[self.Columns.AMOUNT] = amount_str
labels[self.Columns.STATUS] = item.get_status_str(status)
items = [QStandardItem(e) for e in labels]
self.set_editability(items)
items[self.Columns.DATE].setIcon(read_QIcon(icon_name))
items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status)))
items[self.Columns.DATE].setData(key, role=ROLE_REQUEST_ID)
#items[self.Columns.DATE].setData(item.type, role=ROLE_REQUEST_TYPE)
items[self.Columns.DATE].setData(timestamp, role=ROLE_SORT_ORDER)
items[self.Columns.AMOUNT].setData(amount_str_nots.strip(), role=self.ROLE_CLIPBOARD_DATA)
self.std_model.insertRow(idx, items)
self.filter()
self.proxy.setDynamicSortFilter(True)
# sort requests by date
self.sortByColumn(self.Columns.DATE, Qt.SortOrder.DescendingOrder)
self.hide_if_empty()
def show_invoice(self, key):
invoice = self.wallet.get_invoice(key)
if not invoice:
self.update()
return
if invoice.is_lightning():
self.main_window.show_lightning_invoice(invoice)
else:
self.main_window.show_onchain_invoice(invoice)
def hide_if_empty(self):
b = self.std_model.rowCount() > 0
self.setVisible(b)
self.send_tab.invoices_label.setVisible(b)
def create_menu(self, position):
wallet = self.wallet
items = self.selected_in_column(0)
if len(items) > 1:
keys = [item.data(ROLE_REQUEST_ID) for item in items]
invoices = [wallet.get_invoice(key) for key in keys]
can_batch_pay = all([not i.is_lightning() and wallet.get_invoice_status(i) == PR_UNPAID for i in invoices])
menu = QMenu(self)
if can_batch_pay:
menu.addAction(_("Batch pay invoices") + "...", lambda: self.send_tab.pay_multiple_invoices(invoices))
menu.addAction(_("Delete invoices"), lambda: self.delete_invoices(keys))
menu.exec(self.viewport().mapToGlobal(position))
return
idx = self.indexAt(position)
item = self.item_from_index(idx)
item_col0 = self.item_from_index(idx.sibling(idx.row(), self.Columns.DATE))
if not item or not item_col0:
return
key = item_col0.data(ROLE_REQUEST_ID)
invoice = self.wallet.get_invoice(key)
menu = QMenu(self)
menu.addAction(_("Details"), lambda: self.show_invoice(key))
copy_menu = self.add_copy_menu(menu, idx)
address = invoice.get_address()
if address:
copy_menu.addAction(_("Address"), lambda: self.main_window.do_copy(invoice.get_address(), title='Bitcoin Address'))
status = wallet.get_invoice_status(invoice)
if status == PR_UNPAID:
if bool(invoice.get_amount_sat()):
menu.addAction(_("Pay") + "...", lambda: self.send_tab.do_pay_invoice(invoice))
else:
menu.addAction(_("Pay") + "...", lambda: self.send_tab.do_edit_invoice(invoice))
if status == PR_FAILED:
menu.addAction(_("Retry"), lambda: self.send_tab.do_pay_invoice(invoice))
if self.wallet.lnworker:
log = self.wallet.lnworker.logs.get(key)
if log:
menu.addAction(_("View log"), lambda: self.show_log(key, log))
menu.addAction(_("Delete"), lambda: self.delete_invoices([key]))
self.open_menu(menu, position)
def show_log(self, key, log: Sequence[HtlcLog]):
d = WindowModalDialog(self, _("Payment log"))
d.setMinimumWidth(600)
vbox = QVBoxLayout(d)
log_w = QTreeWidget()
log_w.setHeaderLabels([_('Hops'), _('Channel ID'), _('Message')])
log_w.header().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
log_w.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
for payment_attempt_log in log:
route_str, chan_str, message = payment_attempt_log.formatted_tuple()
x = QTreeWidgetItem([route_str, chan_str, message])
log_w.addTopLevelItem(x)
vbox.addWidget(log_w)
vbox.addLayout(Buttons(CloseButton(d)))
d.exec()
def delete_invoices(self, keys):
for key in keys:
self.wallet.delete_invoice(key, write_to_disk=False)
self.delete_item(key)
self.wallet.save_db()
================================================
FILE: electrum/gui/qt/lightning_dialog.py
================================================
#!/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.
from typing import TYPE_CHECKING
from PyQt6.QtWidgets import (QDialog, QLabel, QVBoxLayout, QPushButton)
from electrum.i18n import _
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .util import Buttons
if TYPE_CHECKING:
from . import ElectrumGui
class LightningDialog(QDialog, QtEventListener):
def __init__(self, gui_object: 'ElectrumGui'):
QDialog.__init__(self)
self.gui_object = gui_object
self.config = gui_object.config
self.network = gui_object.daemon.network
assert self.network
self.setWindowTitle(_('Lightning Network'))
self.setMinimumWidth(600)
vbox = QVBoxLayout(self)
self.num_peers = QLabel('')
vbox.addWidget(self.num_peers)
self.num_nodes = QLabel('')
vbox.addWidget(self.num_nodes)
self.num_channels = QLabel('')
vbox.addWidget(self.num_channels)
self.status = QLabel('')
vbox.addWidget(self.status)
vbox.addStretch(1)
b = QPushButton(_('Close'))
b.clicked.connect(self.close)
vbox.addLayout(Buttons(b))
self.register_callbacks()
self.network.channel_db.update_counts() # trigger callback
if self.network.lngossip:
self.on_event_gossip_peers(self.network.lngossip.lnpeermgr.num_peers())
self.on_event_unknown_channels(len(self.network.lngossip.unknown_ids))
else:
self.num_peers.setText(_('Lightning gossip not active.'))
@qt_event_listener
def on_event_channel_db(self, num_nodes, num_channels, num_policies):
self.num_nodes.setText(_('{} nodes').format(num_nodes))
self.num_channels.setText(_('{} channels').format(num_channels))
@qt_event_listener
def on_event_gossip_peers(self, num_peers):
self.num_peers.setText(_('Connected to {} peers').format(num_peers))
@qt_event_listener
def on_event_unknown_channels(self, unknown):
self.status.setText(_('Requesting {} channels...').format(unknown) if unknown else '')
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 closeEvent(self, event):
self.unregister_callbacks()
self.gui_object.lightning_dialog = None
event.accept()
================================================
FILE: electrum/gui/qt/lightning_tx_dialog.py
================================================
#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2020 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 typing import TYPE_CHECKING
from decimal import Decimal
import datetime
from PyQt6.QtWidgets import QVBoxLayout, QLabel
from electrum.i18n import _
from electrum.lnworker import PaymentDirection
from .util import WindowModalDialog, ShowQRLineEdit, Buttons, CloseButton, font_height, ButtonsLineEdit
from .qrtextedit import ShowQRTextEdit
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class LightningTxDialog(WindowModalDialog):
def __init__(self, parent: 'ElectrumWindow', tx_item: dict):
WindowModalDialog.__init__(self, parent, _("Lightning Payment"))
self.main_window = parent
self.config = parent.config
self.label = tx_item['label']
self.timestamp = tx_item['timestamp']
self.amount = Decimal(tx_item['amount_msat']) / 1000
self.payment_hash = tx_item['payment_hash']
self.preimage = tx_item['preimage']
self.invoice = ""
invoice = self.main_window.wallet.get_invoice(self.payment_hash) # only check outgoing invoices
if invoice:
assert invoice.is_lightning(), f"{self.invoice!r}"
self.invoice = invoice.lightning_invoice
self.setMinimumWidth(700)
vbox = QVBoxLayout()
self.setLayout(vbox)
amount_str = self.main_window.format_amount_and_units(self.amount, timestamp=self.timestamp)
vbox.addWidget(QLabel(_("Amount") + f": {amount_str}"))
fee_msat = tx_item.get('fee_msat')
if fee_msat is not None:
fee_sat = Decimal(fee_msat) / 1000 if fee_msat is not None else None
fee_str = self.main_window.format_amount_and_units(fee_sat, timestamp=self.timestamp)
vbox.addWidget(QLabel(_("Fee: {}").format(fee_str)))
time_str = datetime.datetime.fromtimestamp(self.timestamp).isoformat(' ')[:-3]
vbox.addWidget(QLabel(_("Date") + ": " + time_str))
self.tx_desc_label = QLabel(_("Description:"))
vbox.addWidget(self.tx_desc_label)
self.tx_desc = ButtonsLineEdit(self.label)
def on_edited():
text = self.tx_desc.text()
if self.main_window.wallet.set_label(self.payment_hash, text):
self.main_window.history_list.update()
self.main_window.utxo_list.update()
self.main_window.labels_changed_signal.emit()
self.tx_desc.editingFinished.connect(on_edited)
self.tx_desc.addCopyButton()
vbox.addWidget(self.tx_desc)
vbox.addWidget(QLabel(_("Payment hash") + ":"))
self.hash_e = ShowQRLineEdit(self.payment_hash, self.config, title=_("Payment hash"))
vbox.addWidget(self.hash_e)
vbox.addWidget(QLabel(_("Preimage") + ":"))
self.preimage_e = ShowQRLineEdit(self.preimage, self.config, title=_("Preimage"))
vbox.addWidget(self.preimage_e)
if self.invoice:
vbox.addWidget(QLabel(_("Lightning Invoice") + ":"))
self.invoice_e = ShowQRTextEdit(self.invoice, config=self.config)
self.invoice_e.setMaximumHeight(max(150, 10 * font_height()))
self.invoice_e.addCopyButton()
vbox.addWidget(self.invoice_e)
self.close_button = CloseButton(self)
vbox.addLayout(Buttons(self.close_button))
self.close_button.setFocus()
================================================
FILE: electrum/gui/qt/locktimeedit.py
================================================
# Copyright (C) 2020 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
import time
from datetime import datetime
from typing import Optional, Any
from PyQt6.QtCore import Qt, QDateTime, pyqtSignal
from PyQt6.QtGui import QPainter
from PyQt6.QtWidgets import (QWidget, QLineEdit, QStyle, QStyleOptionFrame, QComboBox,
QHBoxLayout, QDateTimeEdit)
from electrum.i18n import _
from electrum.bitcoin import NLOCKTIME_MIN, NLOCKTIME_MAX, NLOCKTIME_BLOCKHEIGHT_MAX
from .util import char_width_in_lineedit, ColorScheme
class LockTimeEdit(QWidget):
valueEdited = pyqtSignal()
def __init__(self, parent=None):
QWidget.__init__(self, parent)
hbox = QHBoxLayout()
self.setLayout(hbox)
hbox.setContentsMargins(0, 0, 0, 0)
hbox.setSpacing(0)
self.locktime_raw_e = LockTimeRawEdit(self)
self.locktime_height_e = LockTimeHeightEdit(self)
self.locktime_date_e = LockTimeDateEdit(self)
self.editors = [self.locktime_raw_e, self.locktime_height_e, self.locktime_date_e]
self.combo = QComboBox()
options = [_("Raw"), _("Block height"), _("Date")]
option_index_to_editor_map = {
0: self.locktime_raw_e,
1: self.locktime_height_e,
2: self.locktime_date_e,
}
default_index = 1
self.combo.addItems(options)
def on_current_index_changed(i):
for w in self.editors:
w.setVisible(False)
w.setEnabled(False)
prev_locktime = self.editor.get_locktime()
self.editor = option_index_to_editor_map[i]
if self.editor.is_acceptable_locktime(prev_locktime):
self.editor.set_locktime(prev_locktime)
self.editor.setVisible(True)
self.editor.setEnabled(True)
self.editor = option_index_to_editor_map[default_index]
self.combo.currentIndexChanged.connect(on_current_index_changed)
self.combo.setCurrentIndex(default_index)
on_current_index_changed(default_index)
hbox.addWidget(self.combo)
for w in self.editors:
hbox.addWidget(w)
hbox.addStretch(1)
self.locktime_height_e.textEdited.connect(self.valueEdited.emit)
self.locktime_raw_e.textEdited.connect(self.valueEdited.emit)
self.locktime_date_e.dateTimeChanged.connect(self.valueEdited.emit)
self.combo.currentIndexChanged.connect(self.valueEdited.emit)
def get_locktime(self) -> Optional[int]:
return self.editor.get_locktime()
def set_locktime(self, x: Any) -> None:
self.editor.set_locktime(x)
class _LockTimeEditor:
min_allowed_value = NLOCKTIME_MIN
max_allowed_value = NLOCKTIME_MAX
def get_locktime(self) -> Optional[int]:
raise NotImplementedError()
def set_locktime(self, x: Any) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
def __init__(self, parent=None):
QLineEdit.__init__(self, parent)
self.setFixedWidth(14 * char_width_in_lineedit())
self.textChanged.connect(self.numbify)
def numbify(self):
text = self.text().strip()
chars = '0123456789'
pos = self.cursorPosition()
pos = len(''.join([i for i in text[:pos] if i in chars]))
s = ''.join([i for i in text if i in chars])
self.set_locktime(s)
# setText sets Modified to False. Instead we want to remember
# if updates were because of user modification.
self.setModified(self.hasFocus())
self.setCursorPosition(pos)
def get_locktime(self) -> Optional[int]:
try:
return int(str(self.text()))
except Exception:
return None
def set_locktime(self, x: Any) -> None:
try:
x = int(x)
except Exception:
self.setText('')
return
x = max(x, self.min_allowed_value)
x = min(x, self.max_allowed_value)
self.setText(str(x))
class LockTimeHeightEdit(LockTimeRawEdit):
max_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX
def __init__(self, parent=None):
LockTimeRawEdit.__init__(self, parent)
self.setFixedWidth(20 * char_width_in_lineedit())
def paintEvent(self, event):
super().paintEvent(event)
panel = QStyleOptionFrame()
self.initStyleOption(panel)
textRect = self.style().subElementRect(QStyle.SubElement.SE_LineEditContents, panel, self)
textRect.adjust(2, 0, -10, 0)
painter = QPainter(self)
painter.setPen(ColorScheme.GRAY.as_color())
painter.drawText(textRect, int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), "height")
def get_max_allowed_timestamp() -> int:
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is platform-dependent).
# see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2 ** 31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
min_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + 1
max_allowed_value = get_max_allowed_timestamp()
def __init__(self, parent=None):
QDateTimeEdit.__init__(self, parent)
self.setMinimumDateTime(datetime.fromtimestamp(self.min_allowed_value))
self.setMaximumDateTime(datetime.fromtimestamp(self.max_allowed_value))
self.setDateTime(QDateTime.currentDateTime())
def get_locktime(self) -> Optional[int]:
dt = self.dateTime().toPyDateTime()
locktime = int(time.mktime(dt.timetuple()))
return locktime
def set_locktime(self, x: Any) -> None:
if not self.is_acceptable_locktime(x):
self.setDateTime(QDateTime.currentDateTime())
return
try:
x = int(x)
except Exception:
self.setDateTime(QDateTime.currentDateTime())
return
dt = datetime.fromtimestamp(x)
self.setDateTime(dt)
================================================
FILE: electrum/gui/qt/main_window.py
================================================
#!/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
import time
import threading
import os
import json
import weakref
import csv
from decimal import Decimal
import base64
from functools import partial
import queue
import asyncio
from typing import Optional, TYPE_CHECKING, Sequence, Union, Dict, Mapping, Callable, List, Set
import concurrent.futures
import inspect
from PyQt6.QtGui import QPixmap, QKeySequence, QIcon, QCursor, QFont, QFontMetrics, QAction, QShortcut
from PyQt6.QtCore import Qt, QRect, QStringListModel, QSize, pyqtSignal, QTimer
from PyQt6.QtWidgets import (QMessageBox, QTabWidget, QMenuBar, QFileDialog, QCheckBox, QLabel,
QVBoxLayout, QGridLayout, QLineEdit, QHBoxLayout, QPushButton, QScrollArea, QTextEdit,
QMainWindow, QInputDialog, QWidget, QSizePolicy, QStatusBar, QToolTip,
QMenu, QToolButton, QDialog)
import electrum_ecc as ecc
import electrum
from electrum.gui import messages
from electrum import (keystore, constants, util, bitcoin, commands,
paymentrequest, lnutil)
from electrum.bitcoin import COIN, is_address, DummyAddress
from electrum.plugin import run_hook
from electrum.i18n import _
from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPassword,
UserFacingException, get_new_wallet_name,
send_exception_to_crash_reporter,
AddTransactionException, os_chmod, UI_UNIT_NAME_TXSIZE_VBYTES,
is_valid_email, ChoiceItem, event_listener)
from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME
from electrum.payment_identifier import PaymentIdentifier
from electrum.invoices import PR_PAID, Invoice
from electrum.transaction import (Transaction, PartialTxInput, TxOutput,
PartialTransaction, PartialTxOutput)
from electrum.wallet import (Multisig_Wallet, Abstract_Wallet,
sweep_preparations, InternalAddressCorruption,
CannotCPFP)
from electrum.version import ELECTRUM_VERSION
from electrum.network import Network, UntrustedServerReturnedError
from electrum.exchange_rate import FxThread
from electrum.simple_config import SimpleConfig
from electrum.logging import Logger
from electrum.lntransport import extract_nodeid, ConnStringFormatError
from electrum.lnaddr import lndecode, LnAddr
from electrum.submarine_swaps import SwapServerTransport, NostrTransport
from electrum.fee_policy import FeePolicy
from electrum.gui.common_qt.util import TaskThread, QtEventListener, qt_event_listener
from .rate_limiter import rate_limited
from .exception_window import Exception_Hook
from .amountedit import BTCAmountEdit
from .qrcodewidget import QRDialog
from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit, ScanShowQRTextEdit
from .transaction_dialog import show_transaction
from .fee_slider import FeeSlider, FeeComboBox
from .util import (read_QIcon, ColorScheme, text_dialog, icon_path, WaitingDialog,
WindowModalDialog, HelpLabel, Buttons,
OkButton, InfoButton, WWLabel, CancelButton,
CloseButton, MessageBoxMixin, EnterButton, import_meta_gui, export_meta_gui,
filename_field, address_field, char_width_in_lineedit, webopen,
TRANSACTION_FILE_EXTENSION_FILTER_ANY, MONOSPACE_FONT,
getOpenFileName, getSaveFileName, ShowQRLineEdit, scan_qr_from_screenshot)
from .wizard.wallet import WIF_HELP_TEXT
from .history_list import HistoryList, HistoryModel
from .update_checker import UpdateCheck, UpdateCheckThread
from .channels_list import ChannelsList
from .confirm_tx_dialog import ConfirmTxDialog, TxEditorContext
from .rbf_dialog import BumpFeeDialog, DSCancelDialog
from .qrreader import scan_qrcode_from_camera
from .swap_dialog import SwapDialog, InvalidSwapParameters
from .balance_dialog import (BalanceToolButton, COLOR_FROZEN, COLOR_UNMATURED, COLOR_UNCONFIRMED, COLOR_CONFIRMED,
COLOR_LIGHTNING, COLOR_FROZEN_LIGHTNING)
if TYPE_CHECKING:
from . import ElectrumGui
from electrum.submarine_swaps import SwapOffer
from electrum.lnchannel import Channel
class StatusBarButton(QToolButton):
# note: this class has a custom stylesheet applied in stylesheet_patcher.py
def __init__(self, icon, tooltip, func, sb_height):
QToolButton.__init__(self)
self.setText('')
self.setIcon(icon)
self.setToolTip(tooltip)
self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.setAutoRaise(True)
size = max(25, round(0.9 * sb_height))
self.setMaximumWidth(size)
self.clicked.connect(self.onPress)
self.func = func
self.setIconSize(QSize(size, size))
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
def onPress(self, checked=False):
'''Drops the unwanted PyQt "checked" argument'''
self.func()
def keyPressEvent(self, e):
if e.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter]:
self.func()
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
msg = kwargs.get('message')
while self._protected_requires_password():
password = self.wallet.get_unlocked_password() or self.password_dialog(parent=parent, msg=msg)
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
class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
computing_privkeys_signal = pyqtSignal()
show_privkeys_signal = pyqtSignal()
show_error_signal = pyqtSignal(str)
show_message_signal = pyqtSignal(str)
labels_changed_signal = pyqtSignal()
def __init__(self, gui_object: 'ElectrumGui', wallet: Abstract_Wallet):
QMainWindow.__init__(self)
self.gui_object = gui_object
self.should_stop_wallet_on_close = True
self.config = config = gui_object.config # type: SimpleConfig
self.gui_thread = gui_object.gui_thread
assert wallet, "no wallet"
self.wallet = wallet
self._protected_requires_password = self.wallet.has_keystore_encryption
if wallet.has_lightning() and not self.config.cv.GUI_QT_SHOW_TAB_CHANNELS.is_set():
self.config.GUI_QT_SHOW_TAB_CHANNELS = True # override default, but still allow disabling tab manually
Exception_Hook.maybe_setup(config=self.config, wallet=self.wallet)
self.network = gui_object.daemon.network # type: Network
self.fx = gui_object.daemon.fx # type: FxThread
self.contacts = wallet.contacts
self.tray = gui_object.tray
self.app = gui_object.app
self._cleaned_up = False
self.qr_window = None
self.pluginsdialog = None
self.showing_cert_mismatch_error = False
self.tl_windows = []
Logger.__init__(self)
self._coroutines_scheduled = {} # type: Dict[concurrent.futures.Future, str]
self._coroutines_scheduled_lock = threading.Lock()
self.thread = TaskThread(self, self.on_error)
self.tx_notification_queue = queue.Queue()
self.tx_notification_last_time = 0
self.create_status_bar()
self.need_update = threading.Event()
self.completions = QStringListModel()
coincontrol_sb = self.create_coincontrol_statusbar()
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.notes_tab = self.create_notes_tab()
self.contacts_tab = self.create_contacts_tab()
self.channels_tab = self.create_channels_tab()
tabs.addTab(self.create_history_tab(), read_QIcon("tab_history.png"), _('History'))
tabs.addTab(self.send_tab, read_QIcon("tab_send.png"), _('Send'))
tabs.addTab(self.receive_tab, read_QIcon("tab_receive.png"), _('Receive'))
def add_optional_tab(tabs, tab, icon, description):
tab.tab_icon = icon
tab.tab_description = description
tab.tab_pos = len(tabs)
if tab.is_shown_cv.get():
tabs.addTab(tab, icon, description.replace("&", ""))
add_optional_tab(tabs, self.addresses_tab, read_QIcon("tab_addresses.png"), _("&Addresses"))
add_optional_tab(tabs, self.channels_tab, read_QIcon("lightning.png"), _("Channels"))
add_optional_tab(tabs, self.utxo_tab, read_QIcon("tab_coins.png"), _("Co&ins"))
add_optional_tab(tabs, self.contacts_tab, read_QIcon("tab_contacts.png"), _("Con&tacts"))
add_optional_tab(tabs, self.console_tab, read_QIcon("tab_console.png"), _("Con&sole"))
add_optional_tab(tabs, self.notes_tab, read_QIcon("pen.png"), _("&Notes"))
tabs.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
central_widget = QScrollArea()
vbox = QVBoxLayout(central_widget)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addWidget(tabs)
vbox.addWidget(coincontrol_sb)
self.setCentralWidget(central_widget)
self.setMinimumWidth(640)
self.setMinimumHeight(400)
if self.config.GUI_QT_WINDOW_IS_MAXIMIZED:
self.showMaximized()
self.setWindowIcon(read_QIcon("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("F5"), 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.app.refresh_tabs_signal.connect(self.refresh_tabs)
self.app.refresh_amount_edits_signal.connect(self.refresh_amount_edits)
self.app.update_status_signal.connect(self.update_status)
self.app.update_fiat_signal.connect(self.update_fiat)
self.show_error_signal.connect(self.show_error)
self.show_message_signal.connect(self.show_message)
self.history_list.setFocus()
# network callbacks
self.register_callbacks()
# wallet closing warning callbacks
self.closing_warning_callbacks = [] # type: List[Callable[[], Optional[str]]]
self.register_closing_warning_callback(self._check_ongoing_submarine_swaps_callback)
self.register_closing_warning_callback(self._check_ongoing_force_closures)
# banner may already be there
if self.network and self.network.banner:
self.console.showMessage(self.network.banner)
# update fee slider in case we missed the callback
#self.fee_slider.update()
self.load_wallet(wallet)
self.timer = QTimer(self)
self.timer.setInterval(500)
self.timer.setSingleShot(False)
self.timer.timeout.connect(self.timer_actions)
self.timer.start()
self.contacts.fetch_openalias(self.config)
# If the option hasn't been set yet
if not config.cv.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS.is_set():
choice = self.question(title="Electrum - " + _("Enable update check"),
msg=_("For security reasons we advise that you always use the latest version of Electrum.") + " " +
_("Would you like to be notified when there is a newer version of Electrum available?"))
config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS = bool(choice)
self._update_check_thread = None
if config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS:
# The references to both the thread and the window need to be stored somewhere
# to prevent GC from getting in our way.
def on_version_received(v):
if UpdateCheck.is_newer(v):
self.update_check_button.setText(_("Update to Electrum {} is available").format(v))
self.update_check_button.clicked.connect(lambda: self.show_update_check(v))
self.update_check_button.show()
self._update_check_thread = UpdateCheckThread()
self._update_check_thread.checked.connect(on_version_received)
self._update_check_thread.start()
def run_coroutine_dialog(self, coro, text):
""" run coroutine in a waiting dialog, with a Cancel button that cancels the coroutine"""
from .util import RunCoroutineDialog
d = RunCoroutineDialog(self, text, coro)
return d.run()
def run_coroutine_from_thread(self, coro, name, on_result=None):
if self._cleaned_up:
self.logger.warning(f"stopping or already stopped but run_coroutine_from_thread was called.")
return
async def wrapper():
try:
res = await coro
except Exception as e:
self.logger.exception("exception in coro scheduled via window.wallet")
self.show_error_signal.emit(repr(e))
else:
if on_result:
on_result(res)
finally:
with self._coroutines_scheduled_lock:
self._coroutines_scheduled.pop(fut)
self.need_update.set()
fut = asyncio.run_coroutine_threadsafe(wrapper(), self.network.asyncio_loop)
with self._coroutines_scheduled_lock:
self._coroutines_scheduled[fut] = name
self.need_update.set()
def toggle_lock(self):
if self.wallet.get_unlocked_password():
self.lock_wallet()
else:
msg = ' '.join([
_('Your wallet is locked.'),
_('If you unlock it, its password will not be required to sign transactions.'),
_('Enter your password to unlock your wallet:')
])
self.unlock_wallet(message=msg)
def update_lock_menu(self):
self.lock_menu.setEnabled(self._protected_requires_password())
text = _('Lock') if self.wallet.get_unlocked_password() else _('Unlock')
self.lock_menu.setText(text)
@protected
def unlock_wallet(self, password, message=None):
self.wallet.unlock(password)
self.update_lock_icon()
self.update_lock_menu()
self.wallet.txbatcher.set_password_future(password)
icon = read_QIcon("unlock.png")
msg = ' '.join([
_('Your wallet is unlocked.'),
_('Its password will not be required to sign transactions.'),
])
self.show_message(msg, icon=icon.pixmap(30))
def lock_wallet(self):
self.wallet.lock_wallet()
self.update_lock_icon()
self.update_lock_menu()
icon = read_QIcon("lock.png")
msg = ' '.join([
_('Your wallet is locked.'),
_('Its password will be required to sign transactions.'),
])
self.show_message(msg, icon=icon.pixmap(30))
def on_fx_history(self):
self.history_model.refresh('fx_history')
self.address_list.refresh_all()
def on_fx_quotes(self):
self.update_status()
# Refresh edits with the new rate
edit = self.send_tab.fiat_send_e if self.send_tab.fiat_send_e.is_last_edited else self.send_tab.amount_e
edit.textEdited.emit(edit.text())
edit = self.receive_tab.fiat_receive_e if self.receive_tab.fiat_receive_e.is_last_edited else self.receive_tab.receive_amount_e
edit.textEdited.emit(edit.text())
# History tab needs updating if it used spot
if self.fx.history_used_spot:
self.history_model.refresh('fx_quotes')
self.address_list.refresh_all()
def toggle_tab(self, tab):
show = not tab.is_shown_cv.get()
tab.is_shown_cv.set(show)
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 '{}:{}'.format(self.__class__.__name__, self.wallet.diagnostic_name())
return self.wallet.diagnostic_name()
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):
e = exc_info[1]
if isinstance(e, (UserCancelled, concurrent.futures.CancelledError)):
pass
elif isinstance(e, UserFacingException):
self.show_error(str(e))
else:
# TODO would be nice if we just sent these to the crash reporter...
# anything we don't want to send there, we should explicitly catch
# send_exception_to_crash_reporter(e)
try:
self.logger.error("on_error", exc_info=exc_info)
except OSError:
pass # see #4418
self.show_error(repr(e))
@event_listener
def on_event_wallet_updated(self, wallet):
if wallet == self.wallet:
self.need_update.set()
@event_listener
def on_event_new_transaction(self, wallet: Abstract_Wallet, tx: Transaction):
if wallet == self.wallet:
self.tx_notification_queue.put(tx)
self.need_update.set()
@qt_event_listener
def on_event_password_required(self, wallet):
if wallet == self.wallet:
self.password_required_button.show()
@qt_event_listener
def on_event_password_not_required(self, wallet):
if wallet == self.wallet:
self.password_required_button.hide()
def on_password_required_button_clicked(self):
if self.wallet.txbatcher.password_future is None:
return
txids = self.wallet.txbatcher.password_future.txids
labels = [ ' - %s ' % (self.wallet.get_label_for_txid(txid) or (txid[0:15] + '...')) for txid in txids ]
message = _('Your password is needed to sign the following transactions:') + '\n' + '\n'.join(labels)
password = self.get_password(message=message)
if password:
self.wallet.txbatcher.set_password_future(password)
@qt_event_listener
def on_event_status(self):
self.update_status()
@qt_event_listener
def on_event_network_updated(self, *args):
self.update_status()
@qt_event_listener
def on_event_blockchain_updated(self, *args):
# update the number of confirmations in history
self.refresh_tabs()
@qt_event_listener
def on_event_on_quotes(self, *args):
self.on_fx_quotes()
@qt_event_listener
def on_event_on_history(self, *args):
self.on_fx_history()
@qt_event_listener
def on_event_gossip_db_loaded(self, *args):
self.channels_list.gossip_db_loaded.emit(*args)
@qt_event_listener
def on_event_channels_updated(self, *args):
wallet = args[0]
if wallet == self.wallet:
self.channels_list.update_rows.emit(*args)
@qt_event_listener
def on_event_channel(self, *args):
wallet = args[0]
if wallet == self.wallet:
self.channels_list.update_single_row.emit(*args)
self.update_status()
@qt_event_listener
def on_event_banner(self, *args):
self.console.showMessage(args[0])
@qt_event_listener
def on_event_adb_set_future_tx(self, adb, txid):
if adb == self.wallet.adb:
self.history_model.refresh('set_future_tx')
self.utxo_list.refresh_all() # for coin frozen status
self.update_status() # frozen balance
@qt_event_listener
def on_event_verified(self, *args):
wallet, tx_hash, tx_mined_status = args
if wallet == self.wallet:
self.history_model.update_tx_mined_status(tx_hash, tx_mined_status)
@qt_event_listener
def on_event_fee_histogram(self, *args):
self.history_model.on_fee_histogram()
@qt_event_listener
def on_event_ln_gossip_sync_progress(self, *args):
self.update_lightning_icon()
@qt_event_listener
def on_event_cert_mismatch(self, *args):
self.show_cert_mismatch_error()
@qt_event_listener
def on_event_tor_probed(self, is_tor):
self.tor_button.setVisible(is_tor)
@qt_event_listener
def on_event_proxy_set(self, *args):
self.tor_button.setVisible(False)
@qt_event_listener
def on_event_recently_opened_wallets_update(self, *args):
self.update_recently_opened_menu()
def close_wallet(self):
if self.wallet:
self.logger.info(f'close_wallet {self.wallet.storage.path}')
run_hook('close_wallet', self.wallet)
@profiler
def load_wallet(self, wallet: Abstract_Wallet):
self.update_recently_opened_menu()
if wallet.has_lightning():
util.trigger_callback('channels_updated', wallet)
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
# update menus
self.seed_menu.setEnabled(self.wallet.has_seed())
self.update_lock_icon()
self.update_buttons_on_seed()
self.update_console()
self.receive_tab.do_clear()
self.receive_tab.request_list.update()
self.channels_list.update()
self.tabs.show()
self.init_geometry()
if self.config.GUI_QT_HIDE_ON_STARTUP and self.gui_object.tray.isVisible():
self.hide()
else:
self.show()
self.watching_only_changed()
run_hook('load_wallet', wallet, self)
try:
wallet.try_detecting_internal_addresses_corruption()
except InternalAddressCorruption as e:
self.show_error(str(e))
send_exception_to_crash_reporter(e)
def init_geometry(self):
# note: does not support multiple monitors well
winpos = self.wallet.db.get("winpos-qt")
try:
winrect = QRect(*winpos)
except TypeError:
winrect = None
screen = self.app.primaryScreen().geometry()
if winrect and screen.contains(winrect):
self.setGeometry(winrect)
else:
self.logger.info("using default geometry")
self.setGeometry(100, 100, 840, 400)
@classmethod
def get_app_name_and_version_str(cls) -> str:
name = "Electrum"
if constants.net.TESTNET:
name += " " + constants.net.NET_NAME.capitalize()
return f"{name} {ELECTRUM_VERSION}"
def watching_only_changed(self):
name_and_version = self.get_app_name_and_version_str()
title = f"{name_and_version} - {self.wallet.basename()}"
extra = [self.wallet.db.get('wallet_type', '?')]
if self.wallet.is_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=_('Watch-only wallet'))
def warn_if_testnet(self):
if not constants.net.TESTNET:
return
# user might have opted out already
if self.config.DONT_SHOW_TESTNET_WARNING:
return
# only show once per process lifecycle
if getattr(self.gui_object, '_warned_testnet', False):
return
self.gui_object._warned_testnet = True
msg = ''.join([
_("You are in testnet mode."), ' ',
_("Testnet coins are worthless."), '\n',
_("Testnet is separate from the main Bitcoin network. It is used for testing.")
])
cb = QCheckBox(_("Don't show this again."))
cb_checked = False
def on_cb(_x):
nonlocal cb_checked
cb_checked = cb.isChecked()
cb.stateChanged.connect(on_cb)
self.show_warning(msg, title=_('Testnet'), checkbox=cb)
if cb_checked:
self.config.DONT_SHOW_TESTNET_WARNING = True
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 select_backup_dir(self, b):
name = self.config.WALLET_BACKUP_DIRECTORY or ""
dirname = QFileDialog.getExistingDirectory(self, "Select your wallet backup directory", name)
if dirname:
self.config.WALLET_BACKUP_DIRECTORY = dirname
self.backup_dir_e.setText(dirname)
def backup_wallet(self):
d = WindowModalDialog(self, _("File Backup"))
vbox = QVBoxLayout(d)
grid = QGridLayout()
backup_help = ""
backup_dir = self.config.WALLET_BACKUP_DIRECTORY
backup_dir_label = HelpLabel(_('Backup directory') + ':', backup_help)
msg = _('Please select a backup directory')
if self.wallet.has_lightning() and self.wallet.lnworker.channels:
msg += '\n\n' + ' '.join([
_("Note that lightning channels will be converted to channel backups."),
_("You cannot use channel backups to perform lightning payments."),
_("Channel backups can only be used to request your channels to be closed.")
])
self.backup_dir_e = QPushButton(backup_dir)
self.backup_dir_e.clicked.connect(self.select_backup_dir)
grid.addWidget(backup_dir_label, 1, 0)
grid.addWidget(self.backup_dir_e, 1, 1)
vbox.addLayout(grid)
vbox.addWidget(WWLabel(msg))
vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
if not d.exec():
return False
backup_dir = self.config.get_backup_dir()
if backup_dir is None:
self.show_message(_("You need to configure a backup directory in your preferences"), title=_("Backup not configured"))
return
try:
new_path = self.wallet.save_backup(backup_dir)
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"))
return
msg = _("A copy of your wallet file was created in")+" '%s'" % str(new_path)
self.show_message(msg, title=_("Wallet backup created"))
return True
def update_recently_opened_menu(self):
recent = self.config.RECENTLY_OPEN_WALLET_FILES or []
self.recently_visited_menu.clear()
for i, k in enumerate(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(bool(len(recent)))
def get_wallet_folder(self):
return os.path.abspath(self.config.get_datadir_wallet_path())
def new_wallet(self):
try:
wallet_folder = self.get_wallet_folder()
except FileNotFoundError as e:
self.show_error(str(e))
return
try:
filename = get_new_wallet_name(wallet_folder)
except OSError as e:
self.logger.exception("")
self.show_error(repr(e))
path = self.config.get_fallback_wallet_path()
else:
path = os.path.join(wallet_folder, filename)
self.gui_object.start_new_window(path, uri=None, force_wizard=True)
def init_menubar(self):
menubar = QMenuBar()
self.file_menu = menubar.addMenu(_("&File"))
self.recently_visited_menu = self.file_menu.addMenu(_("&Recently open"))
self.file_menu.addAction(_("&Open"), self.open_wallet).setShortcut(QKeySequence.StandardKey.Open)
self.file_menu.addAction(_("&New/Restore"), self.new_wallet).setShortcut(QKeySequence.StandardKey.New)
self.file_menu.addAction(_("&Save backup"), self.backup_wallet).setShortcut(QKeySequence.StandardKey.SaveAs)
self.file_menu.addAction(_("Delete"), self.remove_wallet)
self.file_menu.addSeparator()
self.file_menu.addAction(_("&Quit"), self.close)
self.wallet_menu = menubar.addMenu(_("&Wallet"))
self.wallet_menu.addAction(_("&Information"), self.show_wallet_info)
self.wallet_menu.addSeparator()
self.password_menu = self.wallet_menu.addAction(_("&Password"), self.change_password_dialog)
self.lock_menu = self.wallet_menu.addAction(_("&Unlock"), self.toggle_lock)
self.update_lock_menu()
self.seed_menu = self.wallet_menu.addAction(_("&Seed"), self.show_seed_dialog)
self.private_keys_menu = self.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 = self.wallet_menu.addAction(_("Import addresses"), self.import_addresses)
self.labels_menu = self.wallet_menu.addMenu(_("&Labels"))
self.labels_menu.addAction(_("&Import"), self.do_import_labels)
self.labels_menu.addAction(_("&Export"), self.do_export_labels)
self.wallet_menu.addAction(_("Find"), self.toggle_search).setShortcut(QKeySequence("Ctrl+F"))
self.wallet_menu.addSeparator()
def add_toggle_action(tab):
is_shown = tab.is_shown_cv.get()
tab.menu_action = self.view_menu.addAction(tab.tab_description, lambda: self.toggle_tab(tab))
tab.menu_action.setCheckable(True)
tab.menu_action.setChecked(is_shown)
self.view_menu = menubar.addMenu(_("&View"))
add_toggle_action(self.addresses_tab)
add_toggle_action(self.utxo_tab)
add_toggle_action(self.channels_tab)
add_toggle_action(self.contacts_tab)
add_toggle_action(self.console_tab)
add_toggle_action(self.notes_tab)
self.tools_menu = menubar.addMenu(_("&Tools")) # type: QMenu
preferences_action = self.tools_menu.addAction(_("Preferences"), self.settings_dialog) # type: QAction
if sys.platform == 'darwin':
# "Settings"/"Preferences" are all reserved keywords in macOS.
# preferences_action will get picked up based on name (and put into a standardized location,
# and given a standard reserved hotkey)
# Hence, this menu item will be at a "uniform location re macOS processes"
preferences_action.setMenuRole(QAction.MenuRole.PreferencesRole) # make sure OS recognizes it as preferences
# Add another preferences item, to also have a "uniform location for Electrum between different OSes"
self.tools_menu.addAction(_("Electrum preferences"), self.settings_dialog)
self.tools_menu.addAction(_("&Network"), self.gui_object.show_network_dialog).setEnabled(bool(self.network))
self.tools_menu.addAction(_("&Plugins"), self.gui_object.show_plugins_dialog)
self.tools_menu.addSeparator()
self.tools_menu.addAction(_("&Sign/verify message"), self.sign_verify_message)
self.tools_menu.addAction(_("&Encrypt/decrypt message"), self.encrypt_message)
self.tools_menu.addSeparator()
raw_transaction_menu = self.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
self.help_menu = menubar.addMenu(_("&Help"))
if sys.platform != 'darwin':
self.help_menu.addAction(_("&About"), self.show_about)
else:
# macOS reserves the "About" menu item name, similarly to "Preferences" (see above).
# The "About" keyword seems even more strictly locked down:
# not allowed as either a prefix or a suffix.
about_action = QAction(self)
about_action.triggered.connect(self.show_about)
about_action.setMenuRole(QAction.MenuRole.AboutRole) # make sure OS recognizes it as "About"
self.help_menu.addAction(about_action)
self.help_menu.addAction(_("&Changelog"), lambda: webopen(constants.RELEASE_NOTES_URL))
self.help_menu.addAction(_("&Check for updates"), self.show_update_check)
self.help_menu.addAction(_("&Official website"), lambda: webopen("https://electrum.org"))
self.help_menu.addSeparator()
self.help_menu.addAction(_("&Documentation"), lambda: webopen("http://docs.electrum.org/")).setShortcut(QKeySequence.StandardKey.HelpContents)
if not constants.net.TESTNET:
self.help_menu.addAction(_("&Bitcoin Paper"), self.show_bitcoin_paper)
self.help_menu.addAction(_("&Report Bug"), self.show_report_bug)
self.help_menu.addSeparator()
if self.network:
self.help_menu.addAction(_("&Donate to server"), self.donate_to_server)
run_hook('init_menubar', self)
self.setMenuBar(menubar)
def donate_to_server(self):
d = self.network.get_donation_address()
if d:
self.show_send_tab()
host = self.network.get_parameters().server.host
self.handle_payment_identifier('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" % 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_bitcoin_paper(self):
filename = os.path.join(self.config.path, 'bitcoin.pdf')
if not os.path.exists(filename):
def fetch_bitcoin_paper():
s = self._fetch_tx_from_network("54e48e5f5c656b26c3bca14a8c95aa583d07ebe84dde3b7dd4a78f4e4186e713")
if not s:
raise concurrent.futures.CancelledError
s = s.split("0100000000000000")[1:-1]
out = ''.join(x[6:136] + x[138:268] + x[270:400] if len(x) > 136 else x[6:] for x in s)[16:-20]
with open(filename, 'wb') as f:
f.write(bytes.fromhex(out))
WaitingDialog(
self,
_("Fetching Bitcoin Paper..."),
fetch_bitcoin_paper,
on_success=lambda _: webopen('file:///' + filename),
on_error=self.on_error,
)
return
webopen('file:///' + filename)
def show_update_check(self, version=None):
self.gui_object._update_check = UpdateCheck(latest_version=version)
def show_report_bug(self):
msg = ' '.join([
_("Please report any bugs as issues on github: "),
f'''{constants.GIT_REPO_ISSUES_URL}
''',
_("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"), rich_text=True)
def notify_transactions(self):
if self.tx_notification_queue.qsize() == 0:
return
if not self.wallet.is_up_to_date():
return # no notifications while syncing
now = time.time()
rate_limit = 20 # seconds
if self.tx_notification_last_time + rate_limit > now:
return
self.tx_notification_last_time = now
self.logger.info("Notifying GUI about new transactions")
txns = []
while True:
try:
txns.append(self.tx_notification_queue.get_nowait())
except queue.Empty:
break
for notification in self.wallet.get_user_notifications_for_new_txns(txns):
self.notify(notification)
def notify(self, message):
if self.tray:
self.tray.showMessage("Electrum", message, read_QIcon("electrum_dark_icon"), 20000)
def timer_actions(self):
# refresh invoices and requests because they show ETA
self.receive_tab.request_list.refresh_all()
self.send_tab.invoice_list.refresh_all()
# Note this runs in the GUI thread
if self.need_update.is_set():
self.need_update.clear()
self.update_wallet()
elif not self.wallet.is_up_to_date():
# this updates "synchronizing" progress
self.update_status()
# resolve aliases
# FIXME this might do blocking network calls that has a timeout of several seconds
# self.send_tab.payto_e.on_timer_check_text()
self.notify_transactions()
def format_amount(
self,
amount_sat,
is_diff=False,
whitespaces=False,
*,
add_thousands_sep: bool = None,
) -> str:
"""Formats amount as string, converting to desired unit.
E.g. 500_000 -> '0.005'
"""
return self.config.format_amount(
amount_sat,
is_diff=is_diff,
whitespaces=whitespaces,
add_thousands_sep=add_thousands_sep,
)
def format_amount_and_units(self, amount_sat, *, timestamp: int = None) -> str:
"""Returns string with both bitcoin and fiat amounts, in desired units.
E.g. 500_000 -> '0.005 BTC (191.42 EUR)'
"""
text = self.config.format_amount_and_units(amount_sat)
fiat = self.fx.format_amount_and_units(amount_sat, timestamp=timestamp) if self.fx else None
if text and fiat:
text += f' ({fiat})'
return text
def format_fiat_and_units(self, amount_sat) -> str:
"""Returns string of FX fiat amount, in desired units.
E.g. 500_000 -> '191.42 EUR'
"""
return self.fx.format_amount_and_units(amount_sat) if self.fx else ''
def format_fee_rate(self, fee_rate) -> str:
"""fee_rate is in sat/kvByte."""
return self.config.format_fee_rate(fee_rate)
def get_decimal_point(self):
return self.config.BTC_AMOUNTS_DECIMAL_POINT
def base_unit(self):
return self.config.get_base_unit()
def connect_fields(self, btc_e, fiat_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("")
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
else:
fiat_e.follows = True
fiat_e.setText(self.fx.ccy_amount_str(
amount * Decimal(rate) / COIN, add_thousands_sep=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
network_text = ""
balance_text = ""
if self.tor_button:
self.tor_button.setVisible(self.network and bool(self.network.is_proxy_tor))
if self.network is None:
network_text = _("Offline")
icon = read_QIcon("status_disconnected.png")
elif self.network.is_connected():
server_height = self.network.get_server_height()
server_lag = self.network.get_local_height() - server_height
fork_str = "_fork" if len(self.network.get_blockchains())>1 else ""
# 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.is_up_to_date() or server_height == 0:
num_sent, num_answered = self.wallet.adb.get_history_sync_state_details()
network_text = ("{} ({}/{})"
.format(_("Synchronizing..."), num_answered, num_sent))
icon = read_QIcon("status_waiting.png")
elif server_lag > 1:
network_text = _("Server is lagging ({} blocks)").format(server_lag)
icon = read_QIcon("status_lagging%s.png"%fork_str)
else:
network_text = _("Connected")
p_bal = self.wallet.get_balances_for_piechart()
self.balance_label.update_list(
[
(_('Frozen'), COLOR_FROZEN, p_bal.frozen),
(_('Unmatured'), COLOR_UNMATURED, p_bal.unmatured),
(_('Unconfirmed'), COLOR_UNCONFIRMED, p_bal.unconfirmed),
(_('On-chain'), COLOR_CONFIRMED, p_bal.confirmed),
(_('Lightning'), COLOR_LIGHTNING, p_bal.lightning),
(_('Lightning frozen'), COLOR_FROZEN_LIGHTNING, p_bal.lightning_frozen),
],
warning = self.wallet.is_low_reserve(),
)
balance = p_bal.total()
balance_text = _("Balance") + ": %s "%(self.format_amount_and_units(balance))
# append fiat balance and price
if self.fx.is_enabled():
balance_text += self.fx.get_fiat_status_text(balance,
self.base_unit(), self.get_decimal_point()) or ''
if not self.network.proxy or not self.network.proxy.enabled:
icon = read_QIcon("status_connected%s.png"%fork_str)
else:
icon = read_QIcon("status_connected_proxy%s.png"%fork_str)
else:
if self.network.proxy and self.network.proxy.enabled:
network_text = "{} ({})".format(_("Not connected"), _("proxy enabled"))
else:
network_text = _("Not connected")
icon = read_QIcon("status_disconnected.png")
if self.tray:
# note: don't include balance in systray tooltip, as some OSes persist tooltips,
# hence "leaking" the wallet balance (see #5665)
name_and_version = self.get_app_name_and_version_str()
self.tray.setToolTip(f"{name_and_version} ({network_text})")
self.balance_label.setText(balance_text or network_text)
if self.status_button:
self.status_button.setIcon(icon)
num_tasks = self.num_tasks()
if num_tasks == 0:
name = ''
elif num_tasks == 1:
with self._coroutines_scheduled_lock:
name = list(self._coroutines_scheduled.values())[0] + '...'
else:
name = f"{num_tasks} " + _('tasks') + '...'
self.tasks_label.setText(name)
self.tasks_label.setVisible(num_tasks > 0)
def num_tasks(self):
# For the moment, all the coroutines in this set are outgoing LN payments,
# so we can use this to disable buttons for rebalance/swap suggestions
return len(self._coroutines_scheduled)
def update_wallet(self):
self.update_status()
if self.wallet.is_up_to_date() or not self.network or not self.network.is_connected():
self.update_tabs()
def update_tabs(self, wallet=None):
if wallet is None:
wallet = self.wallet
if wallet != self.wallet:
return
self.history_model.refresh('update_tabs')
self.receive_tab.request_list.update()
self.receive_tab.update_current_request()
self.send_tab.invoice_list.update()
self.address_list.update()
self.utxo_list.update()
self.contact_list.update()
self.channels_list.update_rows.emit(wallet)
self.update_completions()
def refresh_tabs(self, wallet=None):
self.history_model.refresh('refresh_tabs')
self.receive_tab.request_list.refresh_all()
self.send_tab.invoice_list.refresh_all()
self.address_list.refresh_all()
self.utxo_list.refresh_all()
self.contact_list.refresh_all()
self.channels_list.update_rows.emit(self.wallet)
def create_channels_tab(self):
self.channels_list = ChannelsList(self)
tab = self.create_list_tab(self.channels_list)
tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_CHANNELS
return tab
def create_history_tab(self):
self.history_model = HistoryModel(self)
self.history_list = l = HistoryList(self, self.history_model)
self.history_model.set_view(self.history_list)
l.searchable_list = l
tab = self.create_list_tab(self.history_list)
return tab
def show_address(self, addr: str, *, parent: QWidget = None):
from . import address_dialog
d = address_dialog.AddressDialog(self, addr, parent=parent)
d.exec()
def show_utxo(self, utxo):
from . import utxo_dialog
d = utxo_dialog.UTXODialog(self, utxo)
d.exec()
def show_channel_details(self, chan):
from .channel_details import ChannelDetailsDialog
ChannelDetailsDialog(self, chan).show()
def show_transaction(
self,
tx: Transaction,
*,
prompt_if_complete_unsaved: bool = True,
external_keypairs: Mapping[bytes, bytes] = None,
invoice: Invoice = None,
on_closed: Callable[[Optional[Transaction]], None] = None,
show_sign_button: bool = True,
show_broadcast_button: bool = True,
):
show_transaction(
tx,
parent=self,
prompt_if_complete_unsaved=prompt_if_complete_unsaved,
external_keypairs=external_keypairs,
invoice=invoice,
on_closed=on_closed,
show_sign_button=show_sign_button,
show_broadcast_button=show_broadcast_button,
)
def show_lightning_transaction(self, tx_item):
from .lightning_tx_dialog import LightningTxDialog
d = LightningTxDialog(self, tx_item)
d.show()
def create_receive_tab(self):
from .receive_tab import ReceiveTab
return ReceiveTab(self)
def do_copy(self, text: str, *, title: str = None) -> None:
self.gui_object.do_copy(text, title=title)
def show_tooltip_after_delay(self, message):
# tooltip cannot be displayed immediately when called from a menu; wait 200ms
QTimer.singleShot(200, lambda: QToolTip.showText(QCursor.pos(), message, self))
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.receive_tab.update_receive_qr_window()
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 create_send_tab(self):
from .send_tab import SendTab
return SendTab(self)
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)
@protected
def protect(self, func, args, password):
return func(*args, password)
def run_swap_dialog(
self,
is_reverse: Optional[bool] = None,
recv_amount_sat_or_max: Optional[Union[int, str]] = None,
channels: Optional[Sequence['Channel']] = None,
) -> bool:
if not self.network:
self.show_error(_("You are offline."))
return False
if not self.wallet.lnworker:
self.show_error(_('Lightning is disabled'))
return False
if not self.wallet.lnworker.num_sats_can_send() and not self.wallet.lnworker.num_sats_can_receive():
self.show_error(_("You do not have liquidity in your active channels."))
return False
transport = self.create_sm_transport()
if not transport:
return False
with transport:
if not self.initialize_swap_manager(transport):
return False
d = SwapDialog(
self,
transport,
is_reverse=is_reverse,
recv_amount_sat_or_max=recv_amount_sat_or_max,
channels=channels
)
try:
return d.run(transport)
except InvalidSwapParameters as e:
self.show_error(str(e))
return False
except UserCancelled:
return False
def create_sm_transport(self) -> Optional['SwapServerTransport']:
sm = self.wallet.lnworker.swap_manager
if sm.is_server:
self.show_error(_('Swap server is active'))
return None
if self.network is None:
return None
if not self.config.SWAPSERVER_URL and not self.config.SWAPSERVER_NPUB:
if not self.question('\n'.join([
_('Electrum uses Nostr in order to find liquidity providers.'),
_('Do you want to enable Nostr?'),
])):
return None
return sm.create_transport()
def initialize_swap_manager(self, transport: 'SwapServerTransport'):
sm = self.wallet.lnworker.swap_manager
if not sm.is_initialized.is_set():
async def wait_until_initialized():
timeout = transport.connect_timeout + 1
try:
await asyncio.wait_for(sm.is_initialized.wait(), timeout=timeout)
except asyncio.TimeoutError:
return
try:
self.run_coroutine_dialog(wait_until_initialized(), _('Please wait...'))
except UserCancelled:
return False
except Exception as e:
self.show_error(str(e))
return False
if not sm.is_initialized.is_set():
if not self.config.SWAPSERVER_URL:
if not self.choose_swapserver_dialog(transport):
return False
else:
self.show_error(f'Could not contact swap server at {self.config.SWAPSERVER_URL:}')
return False
assert sm.is_initialized.is_set()
return True
def choose_swapserver_dialog(self, transport: NostrTransport) -> bool:
assert isinstance(transport, NostrTransport)
if not transport.is_connected.is_set():
self.show_message(
'\n'.join([
_('Could not connect to a Nostr relay.'),
_('Please check your relays and network connection'),
]))
return False
recent_offers = transport.get_recent_offers()
if not recent_offers:
self.show_message(
'\n'.join([
_('Could not find a swap provider.'),
]))
return False
sm = self.wallet.lnworker.swap_manager
from .swap_dialog import SwapServerDialog
d = SwapServerDialog(self, recent_offers)
choice = d.run()
if choice is None:
return False
self.config.SWAPSERVER_NPUB = choice
offer = transport.get_offer(choice)
sm.update_pairs(offer.pairs)
return True
@qt_event_listener
def on_event_request_status(self, wallet, key, status):
if wallet != self.wallet:
return
req = self.wallet.get_request(key)
if req is None:
return
if status == PR_PAID:
# FIXME notification should only be shown if request was not PAID before
msg = _('Payment received')
amount = req.get_amount_sat()
if amount:
msg += ': ' + self.format_amount_and_units(amount)
msg += '\n' + req.get_message()
self.notify(msg)
self.receive_tab.request_list.delete_item(key)
self.receive_tab.do_clear()
self.need_update.set()
else:
self.receive_tab.request_list.refresh_item(key)
@qt_event_listener
def on_event_invoice_status(self, wallet, key, status):
if wallet != self.wallet:
return
if status == PR_PAID:
self.send_tab.invoice_list.delete_item(key)
else:
self.send_tab.invoice_list.refresh_item(key)
@qt_event_listener
def on_event_payment_succeeded(self, wallet, key):
# sent by lnworker, redundant with invoice_status
if wallet != self.wallet:
return
description = self.wallet.get_label_for_rhash(key)
self.notify(_('Payment sent') + '\n\n' + description)
self.need_update.set()
@qt_event_listener
def on_event_payment_failed(self, wallet, key, reason):
if wallet != self.wallet:
return
description = self.wallet.get_label_for_rhash(key)
self.notify(_('Payment failed') + '\n\n' + description + '\n\n' + reason)
def get_coins(self, **kwargs) -> Sequence[PartialTxInput]:
coins = self.get_manually_selected_coins()
if coins is not None:
return coins
else:
return self.wallet.get_spendable_coins(None, **kwargs)
def get_manually_selected_coins(self) -> Optional[Sequence[PartialTxInput]]:
"""Return a list of selected coins or None.
Note: None means selection is not being used,
while an empty sequence means the user specifically selected that.
"""
return self.utxo_list.get_spend_list()
def broadcast_or_show(self, tx: Transaction, *, invoice: 'Invoice' = None):
if not tx.is_complete():
self.show_transaction(tx, invoice=invoice)
return
if not self.network:
self.show_error(_("You can't broadcast a transaction without a live network connection."))
self.show_transaction(tx, invoice=invoice)
return
self.broadcast_transaction(tx, invoice=invoice)
def broadcast_transaction(self, tx: Transaction, *, invoice: Invoice = None):
self.send_tab.broadcast_transaction(tx, invoice=invoice)
@protected
def sign_tx(
self,
tx: PartialTransaction,
*,
callback,
external_keypairs: Optional[Mapping[bytes, bytes]],
password,
):
self.sign_tx_with_password(tx, callback=callback, password=password, external_keypairs=external_keypairs)
def sign_tx_with_password(
self,
tx: PartialTransaction,
*,
callback,
password,
external_keypairs: Mapping[bytes, bytes] = None,
):
'''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 external_keypairs:
# can sign directly
task = partial(tx.sign, external_keypairs)
else:
# ignore_warnings=True, because UI checks and asks user confirmation itself
task = partial(self.wallet.sign_transaction, tx, password, ignore_warnings=True)
msg = _('Signing transaction...')
WaitingDialog(self, msg, task, on_success, on_failure)
def mktx_for_open_channel(self, *, funding_sat, node_id):
def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
assert base_tx is None
return self.wallet.lnworker.mktx_for_open_channel(
coins=self.get_coins(nonlocal_only=True, confirmed_only=confirmed_only),
funding_sat=funding_sat,
node_id=node_id,
fee_policy=fee_policy)
return make_tx
def open_channel(self, connect_str, funding_sat, push_amt):
try:
node_id, rest = extract_nodeid(connect_str)
except ConnStringFormatError as e:
self.show_error(str(e))
return
if self.wallet.lnworker.has_conflicting_backup_with(node_id):
msg = messages.MSG_CONFLICTING_BACKUP_INSTANCE
if not self.question(msg):
return
# we need to know the fee before we broadcast, because the txid is required
make_tx = self.mktx_for_open_channel(funding_sat=funding_sat, node_id=node_id)
funding_tx, _, _ = self.confirm_tx_dialog(make_tx, funding_sat, context=TxEditorContext.CHANNEL_FUNDING)
if not funding_tx:
return
self._open_channel(connect_str, funding_sat, push_amt, funding_tx)
def confirm_tx_dialog(
self,
make_tx,
output_value, *,
payee_outputs: Optional[list[TxOutput]] = None,
context: TxEditorContext = TxEditorContext.PAYMENT,
batching_candidates=None,
) -> tuple[Optional[PartialTransaction], bool, bool]:
d = ConfirmTxDialog(
window=self,
make_tx=make_tx,
output_value=output_value,
payee_outputs=payee_outputs,
context=context,
batching_candidates=batching_candidates,
)
return d.run(), d.is_preview, d.did_swap
@protected
def _open_channel(self, connect_str, funding_sat, push_amt, funding_tx, password):
# read funding_sat from tx; converts '!' to int value
funding_sat = funding_tx.output_value_for_address(DummyAddress.CHANNEL)
def task():
return self.wallet.lnworker.open_channel(
connect_str=connect_str,
funding_tx=funding_tx,
funding_sat=funding_sat,
push_amt_sat=push_amt,
password=password)
def on_failure(exc_info):
type_, e, traceback = exc_info
#self.logger.error("Could not open channel", exc_info=exc_info)
self.show_error(_('Could not open channel: {}').format(repr(e)))
WaitingDialog(self, _('Opening channel...'), task, self.on_open_channel_success, on_failure)
def on_open_channel_success(self, args):
chan, funding_tx = args
lnworker = self.wallet.lnworker
if not chan.has_onchain_backup():
data = lnworker.export_channel_backup(chan.channel_id)
help_text = messages.MSG_CREATED_NON_RECOVERABLE_CHANNEL
help_text += '\n\n' + _('Alternatively, you can save a backup of your wallet file from the File menu')
self.show_qrcode(
data, _('Save channel backup'),
help_text=help_text,
show_copy_text_btn=True)
n = chan.constraints.funding_txn_minimum_depth
message = '\n'.join([
_('Channel established.'),
_('Remote peer ID') + ':' + chan.node_id.hex(),
_('This channel will be usable after {} confirmations').format(n)
])
if not funding_tx.is_complete():
message += '\n\n' + _('Please sign and broadcast the funding transaction')
self.show_message(message)
self.show_transaction(funding_tx)
else:
self.show_message(message)
def handle_payment_identifier(self, text: str):
pi = PaymentIdentifier(self.wallet, text)
if pi.is_valid():
self.send_tab.set_payment_identifier(text)
else:
if pi.error:
self.show_error(str(pi.error))
def set_frozen_state_of_addresses(self, addrs, freeze: bool):
self.wallet.set_frozen_state_of_addresses(addrs, freeze)
self.address_list.refresh_all()
self.utxo_list.refresh_all()
self.address_list.selectionModel().clearSelection()
def set_frozen_state_of_coins(self, utxos: Sequence[PartialTxInput], freeze: bool):
utxos_str = {utxo.prevout.to_str() for utxo in utxos}
self.wallet.set_frozen_state_of_coins(utxos_str, freeze)
self.utxo_list.refresh_all()
self.utxo_list.selectionModel().clearSelection()
def create_list_tab(self, l):
w = QWidget()
w.searchable_list = l
vbox = QVBoxLayout()
w.setLayout(vbox)
#vbox.setContentsMargins(0, 0, 0, 0)
#vbox.setSpacing(0)
toolbar = l.create_toolbar(self.config)
if toolbar:
vbox.addLayout(toolbar)
vbox.addWidget(l)
if toolbar:
l.show_toolbar()
return w
def create_addresses_tab(self):
from .address_list import AddressList
self.address_list = AddressList(self)
tab = self.create_list_tab(self.address_list)
tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_ADDRESSES
return tab
def create_utxo_tab(self):
from .utxo_list import UTXOList
self.utxo_list = UTXOList(self)
tab = self.create_list_tab(self.utxo_list)
tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_UTXO
return tab
def create_contacts_tab(self):
from .contact_list import ContactList
self.contact_list = l = ContactList(self)
tab = self.create_list_tab(l)
tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_CONTACTS
return tab
def remove_address(self, addr):
if not self.question(_("Do you want to remove {} from your wallet?").format(addr)):
return
try:
self.wallet.delete_address(addr)
except UserFacingException as e:
self.show_error(str(e))
else:
self.need_update.set() # history, addresses, coins
self.receive_tab.do_clear()
def payto_contacts(self, labels):
self.send_tab.payto_contacts(labels)
def set_contact(self, label, address):
if not (is_address(address) or is_valid_email(address)): # email = lightning address
self.show_error(_('Invalid Address'))
self.contact_list.update() # Displays original unchanged value
return False
address_type = 'address' if is_address(address) else 'lnaddress'
self.contacts[address] = (address_type, 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_onchain_invoice(self, invoice: Invoice):
amount_str = self.format_amount(invoice.get_amount_sat()) + ' ' + self.base_unit()
d = WindowModalDialog(self, _("Onchain Invoice"))
vbox = QVBoxLayout(d)
grid = QGridLayout()
grid.addWidget(QLabel(_("Amount") + ':'), 1, 0)
grid.addWidget(QLabel(amount_str), 1, 1)
if len(invoice.outputs) == 1:
grid.addWidget(QLabel(_("Address") + ':'), 2, 0)
grid.addWidget(QLabel(invoice.get_address()), 2, 1)
else:
outputs_str = '\n'.join(map(lambda x: x.address + ' : ' + self.format_amount(x.value)+ self.base_unit(), invoice.outputs))
grid.addWidget(QLabel(_("Outputs") + ':'), 2, 0)
grid.addWidget(QLabel(outputs_str), 2, 1)
grid.addWidget(QLabel(_("Description") + ':'), 3, 0)
grid.addWidget(QLabel(invoice.message), 3, 1)
if invoice.exp:
grid.addWidget(QLabel(_("Expires") + ':'), 4, 0)
grid.addWidget(QLabel(format_time(invoice.exp + invoice.time)), 4, 1)
if invoice.bip70:
pr = paymentrequest.PaymentRequest(bytes.fromhex(invoice.bip70))
Network.run_from_another_thread(pr.verify())
grid.addWidget(QLabel(_("Requestor") + ':'), 5, 0)
grid.addWidget(QLabel(pr.get_requestor()), 5, 1)
grid.addWidget(QLabel(_("Signature") + ':'), 6, 0)
grid.addWidget(QLabel(pr.get_verify_status()), 6, 1)
def do_export():
name = pr.get_name_for_export() or "payment_request"
name = f"{name}.bip70"
fn = getSaveFileName(
parent=self,
title=_("Save invoice to file"),
filename=name,
filter="*.bip70",
config=self.config,
)
if not fn:
return
with open(fn, 'wb') as f:
data = f.write(pr.raw)
self.show_message(_('BIP70 invoice saved as {}').format(fn))
exportButton = EnterButton(_('Export'), do_export)
buttons = Buttons(exportButton, CloseButton(d))
else:
buttons = Buttons(CloseButton(d))
vbox.addLayout(grid)
vbox.addLayout(buttons)
d.exec()
def show_lightning_invoice(self, invoice: Invoice):
from electrum.util import format_short_id
lnaddr = lndecode(invoice.lightning_invoice)
d = WindowModalDialog(self, _("Lightning Invoice"))
vbox = QVBoxLayout(d)
grid = QGridLayout()
pubkey_e = ShowQRLineEdit(lnaddr.pubkey.serialize().hex(), self.config, title=_("Public Key"))
pubkey_e.setMinimumWidth(700)
grid.addWidget(QLabel(_("Public Key") + ':'), 0, 0)
grid.addWidget(pubkey_e, 0, 1)
grid.addWidget(QLabel(_("Amount") + ':'), 1, 0)
amount_str = self.format_amount(invoice.get_amount_sat()) + ' ' + self.base_unit()
grid.addWidget(QLabel(amount_str), 1, 1)
grid.addWidget(QLabel(_("Description") + ':'), 2, 0)
grid.addWidget(QLabel(invoice.message), 2, 1)
grid.addWidget(QLabel(_("Creation time") + ':'), 3, 0)
grid.addWidget(QLabel(format_time(invoice.time)), 3, 1)
if invoice.exp:
grid.addWidget(QLabel(_("Expiration time") + ':'), 4, 0)
grid.addWidget(QLabel(format_time(invoice.time + invoice.exp)), 4, 1)
grid.addWidget(QLabel(_('Features') + ':'), 5, 0)
grid.addWidget(QLabel(', '.join(lnaddr.get_features().get_names())), 5, 1)
payhash_e = ShowQRLineEdit(lnaddr.paymenthash.hex(), self.config, title=_("Payment Hash"))
grid.addWidget(QLabel(_("Payment Hash") + ':'), 6, 0)
grid.addWidget(payhash_e, 6, 1)
fallback = lnaddr.get_fallback_address()
if fallback:
fallback_e = ShowQRLineEdit(fallback, self.config, title=_("Fallback address"))
grid.addWidget(QLabel(_("Fallback address") + ':'), 7, 0)
grid.addWidget(fallback_e, 7, 1)
invoice_e = ShowQRTextEdit(config=self.config)
invoice_e.setFont(QFont(MONOSPACE_FONT))
invoice_e.addCopyButton()
invoice_e.setText(invoice.lightning_invoice)
grid.addWidget(QLabel(_('Text') + ':'), 8, 0)
grid.addWidget(invoice_e, 8, 1)
r_tags = lnaddr.get_routing_info('r')
r_tags = '\n'.join(repr(r) for r in LnAddr.format_bolt11_routing_info_as_human_readable(r_tags))
routing_e = QTextEdit(str(r_tags))
routing_e.setReadOnly(True)
grid.addWidget(QLabel(_("Routing Hints") + ':'), 9, 0)
grid.addWidget(routing_e, 9, 1)
vbox.addLayout(grid)
vbox.addLayout(Buttons(CloseButton(d),))
d.exec()
def create_console_tab(self):
from .console import Console
self.console = console = Console()
console.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_CONSOLE
return console
def create_notes_tab(self):
from PyQt6 import QtGui, QtWidgets
notes_tab = QtWidgets.QPlainTextEdit()
notes_tab.setWordWrapMode(QtGui.QTextOption.WrapMode.WrapAnywhere)
notes_tab.setFont(QtGui.QFont(MONOSPACE_FONT, 10, QtGui.QFont.Weight.Normal))
notes_tab.setPlainText(self.wallet.db.get('notes_text', ''))
notes_tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_NOTES
notes_tab.textChanged.connect(self.maybe_save_notes_text)
return notes_tab
@rate_limited(10, ts_after=True)
def maybe_save_notes_text(self):
self.save_notes_text()
def save_notes_text(self):
self.logger.info('saving notes')
self.wallet.db.put('notes_text', self.notes_tab.toPlainText())
def update_console(self):
console = self.console
console.history = self.wallet.db.get_stored_item("qt-console-history", [])
console.history_index = len(console.history)
console.updateNamespace({
'wallet': self.wallet,
'network': self.network,
'plugins': self.gui_object.plugins,
'window': self,
'config': self.config,
'electrum': electrum,
'daemon': self.gui_object.daemon,
'util': util,
'bitcoin': bitcoin,
'lnutil': lnutil,
'channels': list(self.wallet.lnworker.channels.values()) if self.wallet.lnworker else [],
'scan_qr': scan_qr_from_screenshot,
})
c = commands.Commands(
config=self.config,
daemon=self.gui_object.daemon,
network=self.network,
callback=lambda: self.console.set_json(True))
methods = {}
def mkfunc(f, method):
return lambda *args, **kwargs: f(method,
args,
self.password_dialog,
**{**kwargs, 'wallet': self.wallet})
for m in dir(c):
if m[0]=='_' or m in ['network','wallet','config','daemon']: continue
methods[m] = mkfunc(c._run, m)
console.updateNamespace(methods)
def show_balance_dialog(self):
balance = self.wallet.get_balances_for_piechart().total()
if balance == 0 and not self.balance_label.has_warning:
return
from .balance_dialog import BalanceDialog
d = BalanceDialog(self, wallet=self.wallet)
d.run()
def create_status_bar(self):
sb = QStatusBar()
self.balance_label = BalanceToolButton()
self.balance_label.setText("Loading wallet...")
self.balance_label.setAutoRaise(True)
self.balance_label.clicked.connect(self.show_balance_dialog)
sb.addWidget(self.balance_label)
font_height = QFontMetrics(self.balance_label.font()).height()
sb_height = max(35, int(2 * font_height))
sb.setFixedHeight(sb_height)
# remove border of all items in status bar
self.setStyleSheet("QStatusBar::item { border: 0px;} ")
self.search_box = QLineEdit()
self.search_box.textChanged.connect(self.do_search)
self.search_box.hide()
sb.addPermanentWidget(self.search_box)
self.update_check_button = QPushButton("")
self.update_check_button.setFlat(True)
self.update_check_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.update_check_button.setIcon(read_QIcon("update.png"))
self.update_check_button.hide()
sb.addPermanentWidget(self.update_check_button)
self.password_required_button = QPushButton(_('Password required'))
self.password_required_button.setFlat(True)
self.password_required_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.password_required_button.setIcon(read_QIcon("warning.png"))
self.password_required_button.setIconSize(self.password_required_button.iconSize() * 1.3)
self.password_required_button.clicked.connect(self.on_password_required_button_clicked)
self.password_required_button.hide()
sb.addPermanentWidget(self.password_required_button)
self.tasks_label = QLabel('')
sb.addPermanentWidget(self.tasks_label)
self.password_button = StatusBarButton(QIcon(), _("Password"), self.change_password_dialog, sb_height)
sb.addPermanentWidget(self.password_button)
sb.addPermanentWidget(StatusBarButton(read_QIcon("preferences.png"), _("Preferences"), self.settings_dialog, sb_height))
self.seed_button = StatusBarButton(read_QIcon("seed.png"), _("Seed"), self.show_seed_dialog, sb_height)
sb.addPermanentWidget(self.seed_button)
self.lightning_button = StatusBarButton(read_QIcon("lightning.png"), _("Lightning Network"), self.gui_object.show_lightning_dialog, sb_height)
sb.addPermanentWidget(self.lightning_button)
self.update_lightning_icon()
self.status_button = None
self.tor_button = None
if self.network:
self.tor_button = StatusBarButton(
read_QIcon("tor_logo.png"),
_("Tor"),
partial(self.gui_object.show_network_dialog, proxy_tab=True),
sb_height,
)
sb.addPermanentWidget(self.tor_button)
self.tor_button.setVisible(False)
# add status btn last, to place it at rightmost pos
self.status_button = StatusBarButton(
read_QIcon("status_disconnected.png"),
_("Network"),
self.gui_object.show_network_dialog,
sb_height,
)
sb.addPermanentWidget(self.status_button)
# add plugins
run_hook('create_status_bar', sb)
self.setStatusBar(sb)
def create_coincontrol_statusbar(self):
self.coincontrol_sb = sb = QStatusBar()
sb.setSizeGripEnabled(False)
#sb.setFixedHeight(3 * char_width_in_lineedit())
sb.setStyleSheet('QStatusBar::item {border: None;} '
+ ColorScheme.GREEN.as_stylesheet(True))
self.coincontrol_label = QLabel()
self.coincontrol_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
self.coincontrol_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
sb.addWidget(self.coincontrol_label)
clear_cc_button = EnterButton(_('Reset'), lambda: self.utxo_list.clear_coincontrol())
clear_cc_button.setStyleSheet("margin-right: 5px;")
sb.addPermanentWidget(clear_cc_button)
sb.setVisible(False)
return sb
def set_coincontrol_msg(self, msg: Optional[str]) -> None:
if not msg:
self.coincontrol_label.setText("")
self.coincontrol_sb.setVisible(False)
return
self.coincontrol_label.setText(msg)
self.coincontrol_sb.setVisible(True)
def update_lightning_icon(self):
if not self.wallet.has_lightning():
self.lightning_button.setVisible(False)
return
if self.network is None or self.network.channel_db is None:
self.lightning_button.setVisible(False)
return
self.lightning_button.setVisible(True)
cur, total, progress_percent = self.network.lngossip.get_sync_progress_estimate()
# self.logger.debug(f"updating lngossip sync progress estimate: cur={cur}, total={total}")
progress_str = "??%"
if progress_percent is not None:
progress_str = f"{progress_percent}%"
if progress_percent and progress_percent >= 100:
self.lightning_button.setMaximumWidth(25)
self.lightning_button.setText('')
self.lightning_button.setToolTip(_("The Lightning Network graph is fully synced."))
else:
self.lightning_button.setMaximumWidth(25 + 6 * char_width_in_lineedit())
self.lightning_button.setText(progress_str)
self.lightning_button.setToolTip(_("The Lightning Network graph is syncing...\n"
"Payments are more likely to succeed with a more complete graph."))
def update_lock_icon(self):
icon = read_QIcon("lock.png") if self.wallet.has_password() and (self.wallet.get_unlocked_password() is None) else read_QIcon("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())
def change_password_dialog(self):
from electrum.storage import StorageEncryptionVersion
if StorageEncryptionVersion.XPUB_PASSWORD in self.wallet.get_available_storage_encryption_versions():
from .password_dialog import ChangePasswordDialogForHW
d = ChangePasswordDialogForHW(self, self.wallet)
ok, old_password, new_password, encrypt_with_xpub = d.run()
if not ok:
return
has_xpub_encryption = self.wallet.storage.get_encryption_version() == StorageEncryptionVersion.XPUB_PASSWORD
def on_password(hw_dev_pw):
self._update_wallet_password(
old_password = hw_dev_pw if has_xpub_encryption else old_password,
new_password = hw_dev_pw if encrypt_with_xpub else new_password,
xpub_encrypt=encrypt_with_xpub,
)
self.thread.add(
self.wallet.keystore.get_password_for_storage_encryption,
on_success=on_password)
else:
from .password_dialog import ChangePasswordDialogForSW
d = ChangePasswordDialogForSW(self, self.wallet)
ok, old_password, new_password, encrypt_file = d.run()
if not ok:
return
self._update_wallet_password(
old_password=old_password, new_password=new_password)
self.update_lock_menu()
def _update_wallet_password(self, *, old_password, new_password, xpub_encrypt=False):
try:
self.wallet.update_password(old_password, new_password, encrypt_storage=True, xpub_encrypt=xpub_encrypt)
except InvalidPassword as e:
self.show_error(str(e))
return
except BaseException:
self.logger.exception('Failed to update password')
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):
self.search_box.setHidden(not self.search_box.isHidden())
if not self.search_box.isHidden():
self.search_box.setFocus()
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_channel_dialog(self, *, amount_sat=None, min_amount_sat=None):
from electrum.lnutil import MIN_FUNDING_SAT
from .new_channel_dialog import NewChannelDialog
assert self.wallet.can_have_lightning()
confirmed = self.wallet.get_spendable_balance_sat(confirmed_only=True)
min_amount_sat = min_amount_sat or MIN_FUNDING_SAT
if confirmed < min_amount_sat:
msg = _('Not enough funds') + '\n\n' + _('You need at least {} to open a channel.').format(self.format_amount_and_units(min_amount_sat))
self.show_error(msg)
return
if not self.wallet.has_lightning() and not self.init_lightning_dialog():
return
lnworker = self.wallet.lnworker
if not lnworker.channels and not lnworker.channel_backups:
msg = _('Do you want to create your first channel?') + '\n\n' + messages.MSG_LIGHTNING_WARNING
if not self.question(msg):
return
d = NewChannelDialog(self, amount_sat, min_amount_sat)
return d.run()
def new_contact_dialog(self):
d = WindowModalDialog(self, _("New Contact"))
vbox = QVBoxLayout(d)
vbox.addWidget(QLabel(_('New Contact') + ':'))
grid = QGridLayout()
line1 = QLineEdit()
line1.setFixedWidth(32 * char_width_in_lineedit())
line2 = QLineEdit()
line2.setFixedWidth(32 * char_width_in_lineedit())
address_label = QLabel(_("Address"))
address_label.setToolTip(_("Bitcoin- or Lightning address"))
grid.addWidget(address_label, 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 init_lightning_dialog(self, close_dialog: Optional[QDialog] = None) -> bool:
assert not self.wallet.has_lightning()
if self.wallet.can_have_deterministic_lightning():
msg = _(
"Lightning is not enabled because this wallet was created with an old version of Electrum. "
"Create lightning keys?")
else:
msg = _(
"Warning: this wallet type does not support channel recovery from seed. "
"You will need to backup your wallet every time you create a new channel. "
"Create lightning keys?")
if self.question(msg):
self._init_lightning_dialog(close_dialog=close_dialog)
return self.wallet.has_lightning()
@protected
def _init_lightning_dialog(self, *, close_dialog: Optional[QDialog], password):
if close_dialog is not None:
close_dialog.close()
self.wallet.init_lightning(password=password)
self.update_lightning_icon()
self.show_message(_('Lightning keys have been initialized.'))
def show_wallet_info(self):
from .wallet_info_dialog import WalletInfoDialog
d = WalletInfoDialog(self, window=self)
d.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)
r = self.gui_object.daemon.delete_wallet(wallet_path)
self.close()
if r:
self.show_error(_("Wallet removed: {}").format(basename))
else:
self.show_error(_("Wallet file not found: {}").format(basename))
@protected
def get_password(self, password, message=None):
# may be used by plugins to get password
return password
@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(repr(e))
return
from .seed_dialog import SeedDialog
d = SeedDialog(self, seed, passphrase, config=self.config)
d.exec()
def show_qrcode(self, data, title=None, parent=None, *,
help_text=None, show_copy_text_btn=False):
if not data:
return
if title is None:
title = _("QR code")
d = QRDialog(
data=data,
parent=parent or self,
title=title,
help_text=help_text,
show_copy_text_btn=show_copy_text_btn,
config=self.config,
)
d.exec()
@protected
def show_private_key(self, address, password):
if not address:
return
try:
pk = self.wallet.export_private_key(address, password)
except Exception as e:
self.logger.exception('')
self.show_message(repr(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, config=self.config)
keys_e.addCopyButton()
vbox.addWidget(keys_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.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()), validate=True)
verified = bitcoin.verify_usermessage_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()
message_e.setAcceptRichText(False)
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 = ScanShowQRTextEdit(config=self.config)
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
ciphertext = encrypted_e.toPlainText()
task = partial(self.wallet.decrypt_message, pubkey_e.text(), ciphertext, 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.thread.add(task, on_success=setText)
def do_encrypt(self, message_e, pubkey_e, encrypted_e):
from electrum import crypto
message = message_e.toPlainText()
message = message.encode('utf-8')
try:
public_key = ecc.ECPubkey(bfh(pubkey_e.text()))
except BaseException as e:
self.logger.exception('Invalid Public key')
self.show_warning(_('Invalid Public key'))
return
encrypted = crypto.ecies_encrypt_message(public_key, 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()
message_e.setAcceptRichText(False)
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()
encrypted_e.setAcceptRichText(False)
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 tx_from_text(self, data: Union[str, bytes]) -> Union[None, 'PartialTransaction', 'Transaction']:
from electrum.transaction import tx_from_any
try:
return tx_from_any(data)
except BaseException as e:
self.show_critical(_("Electrum was unable to parse your transaction") + ":\n" + repr(e))
return
def import_channel_backup(self, encrypted: str):
if not self.question('Import channel backup?'):
return
if not self.wallet.lnworker:
self.show_error(_('Lightning is disabled'))
return
try:
self.wallet.lnworker.import_channel_backup(encrypted)
except Exception as e:
self.show_error("failed to import backup" + '\n' + str(e))
return
def read_tx_from_qrcode(self):
def cb(success: bool, error: str, data):
if not success:
if error:
self.show_error(error)
return
if not data:
return
# if the user scanned a bitcoin URI
if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
self.handle_payment_identifier(data)
return
if data.lower().startswith('channel_backup:'):
self.import_channel_backup(data)
return
# else if the user scanned an offline signed tx
tx = self.tx_from_text(data)
if not tx:
return
self.show_transaction(tx)
scan_qrcode_from_camera(parent=self.top_level_window(), config=self.config, callback=cb)
def read_tx_from_file(self) -> Optional[Transaction]:
fileName = getOpenFileName(
parent=self,
title=_("Select your transaction file"),
filter=TRANSACTION_FILE_EXTENSION_FILTER_ANY,
config=self.config,
)
if not fileName:
return
file_content = None # type: None | str | bytes
# 1. try to open file as "text"
try:
with open(fileName, "r", encoding="ascii") as f:
file_content = f.read() # type: str
except (ValueError, IOError, os.error) as reason:
pass
else:
assert isinstance(file_content, str), f"expected str, got {type(file_content)}"
file_content = file_content.strip() # for text, we can safely strip leading/trailing whitespaces
# 2. try to open file as "binary"
if file_content is None:
try:
with open(fileName, "rb") as f:
file_content = f.read() # type: bytes
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"))
if file_content is None:
return None
return self.tx_from_text(file_content)
def do_process_from_text(self):
text = text_dialog(
parent=self,
title=_('Input raw transaction'),
header_layout=_("Transaction:"),
ok_label=_("Load transaction"),
config=self.config,
)
if not text:
return
tx = self.tx_from_text(text)
if tx:
self.show_transaction(tx)
def do_process_from_text_channel_backup(self):
text = text_dialog(
parent=self,
title=_('Input channel backup'),
header_layout=_("Channel Backup:"),
ok_label=_("Load backup"),
config=self.config,
)
if not text:
return
if text.startswith('channel_backup:'):
self.import_channel_backup(text)
def do_process_from_file(self):
tx = self.read_tx_from_file()
if tx:
self.show_transaction(tx)
def do_process_from_txid(self, *, parent: QWidget = None, txid: str = None):
if parent is None:
parent = self
from electrum import transaction
if txid is None:
txid, ok = QInputDialog.getText(parent, _('Lookup transaction'), _('Transaction ID') + ':')
if not ok:
txid = None
if not txid:
return
txid = str(txid).strip()
tx = self.wallet.adb.get_transaction(txid)
if tx is None:
raw_tx = self._fetch_tx_from_network(txid, parent=parent)
if not raw_tx:
return
tx = transaction.Transaction(raw_tx)
self.show_transaction(tx)
def _fetch_tx_from_network(self, txid: str, *, parent: QWidget = None) -> Optional[str]:
if not self.network:
self.show_message(_("You are offline."), parent=parent)
return
try:
raw_tx = self.network.run_from_another_thread(
self.network.get_transaction(txid, timeout=10))
except UntrustedServerReturnedError as e:
self.logger.info(f"Error getting transaction from network: {repr(e)}")
self.show_message(
_("Error getting transaction from network") + ":\n" + e.get_message_for_gui(),
parent=parent,
)
return
except Exception as e:
self.show_message(
_("Error getting transaction from network") + ":\n" + repr(e),
parent=parent,
)
return
return raw_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 = f'electrum-private-keys-{self.wallet.basename()}.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)
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 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(repr(e))
return
self.show_message(_("Private keys exported."))
def do_export_privkeys(self, fileName, pklist, is_csv):
with open(fileName, "w+") as f:
os_chmod(fileName, 0o600) # set restrictive perms *before* we write data
if is_csv:
transaction = csv.writer(f)
transaction.writerow(["address", "private_key"])
for addr, pk in pklist.items():
transaction.writerow(["%34s"%addr,pk])
else:
f.write(json.dumps(pklist, indent = 4))
def do_import_labels(self):
def on_import():
self.need_update.set()
import_meta_gui(self, _('labels'), self.wallet.import_labels, on_import)
def do_export_labels(self):
export_meta_gui(self, _('labels'), self.wallet.export_labels)
def import_invoices(self):
import_meta_gui(self, _('invoices'), self.wallet.import_invoices, self.send_tab.invoice_list.update)
def export_invoices(self):
export_meta_gui(self, _('invoices'), self.wallet.export_invoices)
def import_requests(self):
import_meta_gui(self, _('requests'), self.wallet.import_requests, self.receive_tab.request_list.update)
def export_requests(self):
export_meta_gui(self, _('requests'), self.wallet.export_requests)
def import_contacts(self):
import_meta_gui(self, _('contacts'), self.contacts.import_file, self.contact_list.update)
def export_contacts(self):
export_meta_gui(self, _('contacts'), self.contacts.export_file)
def sweep_key_dialog(self):
if not self.network:
self.show_error(_("You are offline."))
return
d = WindowModalDialog(self, title=_('Sweep private keys'))
d.setMinimumSize(600, 300)
vbox = QVBoxLayout(d)
hbox_top = QHBoxLayout()
hbox_top.addWidget(QLabel(_("Enter private keys to sweep coins from:")))
hbox_top.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignmentFlag.AlignRight)
vbox.addLayout(hbox_top)
keys_e = ScanQRTextEdit(allow_multi=True, config=self.config)
keys_e.setTabChangesFocus(True)
vbox.addWidget(keys_e)
vbox.addWidget(QLabel(_("Send to address") + ":"))
addresses = self.wallet.get_unused_addresses()
if not addresses:
addresses = self.wallet.get_receiving_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(*, raise_on_error=False) -> Sequence[str]:
text = str(keys_e.toPlainText())
return keystore.get_private_keys(text, raise_on_error=raise_on_error)
def on_edit():
valid_privkeys = False
try:
valid_privkeys = bool(get_pk(raise_on_error=True))
except Exception as e:
button.setToolTip(f'{_("Error")}: {repr(e)}')
else:
button.setToolTip('')
button.setEnabled(get_address() is not None and valid_privkeys)
on_address = lambda text: address_e.setStyleSheet((ColorScheme.DEFAULT if get_address() else ColorScheme.RED).as_stylesheet())
keys_e.textChanged.connect(on_edit)
address_e.textChanged.connect(on_edit)
address_e.textChanged.connect(on_address)
on_address(str(address_e.text()))
if not d.exec():
return
# user pressed "sweep"
addr = get_address()
try:
self.wallet.check_address_for_corruption(addr)
except InternalAddressCorruption as e:
self.show_error(str(e))
raise
privkeys = get_pk()
def on_success(result):
coins, keypairs = result
outputs = [PartialTxOutput.from_address_and_value(addr, value='!')]
self.warn_if_watching_only()
self.send_tab.pay_onchain_dialog(
outputs, external_keypairs=keypairs, get_coins=lambda *args, **kwargs: coins)
def on_failure(exc_info):
self.on_error(exc_info)
msg = _('Preparing sweep transaction...')
task = lambda: self.network.run_from_another_thread(
sweep_preparations(privkeys, self.network))
WaitingDialog(self, msg, task, on_success, on_failure)
def _do_import(self, title, header_layout, func):
text = text_dialog(
parent=self,
title=title,
header_layout=header_layout,
ok_label=_('Import'),
allow_multi=True,
config=self.config,
)
if not text:
return
keys = str(text).split()
good_inputs, bad_inputs = func(keys)
if good_inputs:
msg = '\n'.join(good_inputs[:10])
if len(good_inputs) > 10: msg += '\n...'
self.show_message(_("The following addresses were added")
+ f' ({len(good_inputs)}):\n' + msg)
if bad_inputs:
msg = "\n".join(f"{key[:10]}... ({msg})" for key, msg in bad_inputs[:10])
if len(bad_inputs) > 10: msg += '\n...'
self.show_error(_("The following inputs could not be imported")
+ f' ({len(bad_inputs)}):\n' + msg)
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_addresses)
@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.AlignmentFlag.AlignRight)
self._do_import(title, header_layout, lambda x: self.wallet.import_private_keys(x, password))
def refresh_amount_edits(self):
edits = self.send_tab.amount_e, self.receive_tab.receive_amount_e
amounts = [edit.get_amount() for edit in edits]
for edit, amount in zip(edits, amounts):
edit.setAmount(amount)
def update_fiat(self):
b = self.fx and self.fx.is_enabled()
self.send_tab.fiat_send_e.setVisible(b)
self.receive_tab.fiat_receive_e.setVisible(b)
self.history_model.refresh('update_fiat')
self.history_list.update_toolbar_menu()
self.address_list.refresh_headers()
self.address_list.update()
self.update_status()
def settings_dialog(self):
from .settings_dialog import SettingsDialog
d = SettingsDialog(self, self.config)
d.exec()
if self.fx:
self.fx.trigger_update()
run_hook('close_settings_dialog')
if d.need_restart:
self.show_warning(_('Please restart Electrum to activate the new GUI settings'), title=_('Success'))
else:
# Some values might need to be updated if settings have changed.
# For example 'Can send' in the lightning tab will change if the fees config is changed.
self.refresh_tabs()
def _show_closing_warnings(self) -> bool:
"""Show any closing warnings and return True if the user chose to quit anyway."""
warnings: Set[str] = set()
for cb in self.closing_warning_callbacks:
if warning := cb():
warnings.add(warning)
for warning in list(warnings)[:3]:
warning = ''.join([
_("Are you sure you want to close Electrum?"),
'\n\n',
_("An ongoing operation requires you to stay online."),
'\n',
warning
])
result = self.question(
msg=warning,
icon=QMessageBox.Icon.Warning,
title=_("Warning"),
)
if not result:
break
else:
# user chose to cancel all warnings or there were no warnings
return True
return False
def register_closing_warning_callback(self, callback: Callable[[], Optional[str]]) -> None:
"""
Registers a callback that will be called when the wallet is closed. If the callback
returns a string it will be shown to the user as a warning to prevent them closing the wallet.
"""
assert not inspect.iscoroutinefunction(callback)
def warning_callback() -> Optional[str]:
try:
return callback()
except Exception:
self.logger.exception("Error in closing warning callback")
return None
self.logger.debug(f"registering wallet closing warning callback")
self.closing_warning_callbacks.append(warning_callback)
def _check_ongoing_force_closures(self) -> Optional[str]:
from electrum.lnutil import MIN_FINAL_CLTV_DELTA_ACCEPTED
if not self.wallet.has_lightning():
return None
if not self.network:
return None
force_closes = self.wallet.lnworker.lnwatcher.get_pending_force_closes()
if not force_closes:
return
# fixme: this is inaccurate, we need local_height - cltv_of_htlc
cltv_delta = MIN_FINAL_CLTV_DELTA_ACCEPTED
msg = '\n\n'.join([
_("Pending channel force-close"),
messages.MSG_FORCE_CLOSE_WARNING.format(cltv_delta),
])
return msg
def _check_ongoing_submarine_swaps_callback(self) -> Optional[str]:
"""Callback that will return a warning string if there are unconfirmed swap funding txs."""
from electrum.submarine_swaps import MIN_FINAL_CLTV_DELTA_FOR_CLIENT, LOCKTIME_DELTA_REFUND
if not (self.wallet.has_lightning() and self.wallet.lnworker.swap_manager):
return None
if not self.network:
return None
ongoing_swaps = self.wallet.lnworker.swap_manager.get_pending_swaps()
if not ongoing_swaps:
return None
is_forward = any(not swap.is_reverse for swap in ongoing_swaps)
if is_forward:
# fixme: this is inaccurate, we need local_height - cltv_of_htlc
delta = MIN_FINAL_CLTV_DELTA_FOR_CLIENT
warning = messages.MSG_FORWARD_SWAP_WARNING.format(delta)
else:
locktime = min(swap.locktime for swap in ongoing_swaps)
delta = locktime - self.wallet.adb.get_local_height()
warning = messages.MSG_REVERSE_SWAP_WARNING.format(delta)
return "\n\n".join((
_("Pending submarine swap"),
warning,
))
def closeEvent(self, event):
# note that closeEvent is NOT called if the user quits with Ctrl-C
if not self._show_closing_warnings():
event.ignore()
return
self.clean_up()
event.accept()
def clean_up(self):
if self._cleaned_up:
return
self._cleaned_up = True
if self.thread:
self.thread.stop()
self.thread = None
with self._coroutines_scheduled_lock:
coro_keys = list(self._coroutines_scheduled.keys())
for fut in coro_keys:
fut.cancel()
self.wallet.txbatcher.set_password_future(None)
self.unregister_callbacks()
self.config.GUI_QT_WINDOW_IS_MAXIMIZED = self.isMaximized()
self.save_notes_text()
if not self.isMaximized():
g = self.geometry()
self.wallet.db.put(
"winpos-qt", [g.left(),g.top(), g.width(),g.height()])
if self.qr_window:
self.qr_window.close()
self.close_wallet()
if self._update_check_thread:
self._update_check_thread.stop()
if self.tray:
self.tray = None
self.timer.stop()
self.gui_object.close_window(self)
def cpfp_dialog(self, parent_tx: Transaction) -> None:
new_tx = self.wallet.cpfp(parent_tx, 0)
total_size = parent_tx.estimated_size() + new_tx.estimated_size()
parent_txid = parent_tx.txid()
assert parent_txid
parent_fee = self.wallet.get_tx_info(parent_tx).fee
if parent_fee is None:
self.show_error(_("Can't CPFP: unknown fee for parent transaction."))
return
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(f"{total_size} {UI_UNIT_NAME_TXSIZE_VBYTES}"), 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)
combined_fee = QLabel('')
combined_feerate = QLabel('')
def on_fee_edit(x):
fee_for_child = fee_e.get_amount()
if fee_for_child is None:
return
out_amt = max_fee - fee_for_child
out_amt_str = (self.format_amount(out_amt) + ' ' + self.base_unit()) if out_amt else ''
output_amount.setText(out_amt_str)
comb_fee = parent_fee + fee_for_child
comb_fee_str = (self.format_amount(comb_fee) + ' ' + self.base_unit()) if comb_fee else ''
combined_fee.setText(comb_fee_str)
comb_feerate = comb_fee / total_size * 1000
comb_feerate_str = self.format_fee_rate(comb_feerate) if comb_feerate else ''
combined_feerate.setText(comb_feerate_str)
fee_e.textChanged.connect(on_fee_edit)
def get_child_fee_from_total_feerate(fee_per_kb: Optional[int]) -> Optional[int]:
if fee_per_kb is None:
return None
package_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=total_size)
child_fee = package_fee - parent_fee
child_fee = min(max_fee, child_fee)
# pay at least minrelayfee for combined size:
min_child_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=self.wallet.relayfee(), size=total_size)
child_fee = max(min_child_fee, child_fee)
return child_fee
fee_policy = FeePolicy(self.config.FEE_POLICY)
suggested_feerate = fee_policy.fee_per_kb(self.network)
fee = get_child_fee_from_total_feerate(suggested_feerate)
fee_e.setAmount(fee)
grid.addWidget(QLabel(_('Fee for child') + ':'), 3, 0)
grid.addWidget(fee_e, 3, 1)
def on_rate(fee_rate):
fee = get_child_fee_from_total_feerate(fee_rate)
fee_e.setAmount(fee)
fee_slider = FeeSlider(parent=self, network=self.network, fee_policy=fee_policy, callback=on_rate)
fee_combo = FeeComboBox(fee_slider)
fee_slider.update()
grid.addWidget(fee_slider, 4, 1)
grid.addWidget(fee_combo, 4, 2)
grid.addWidget(QLabel(_('Total fee') + ':'), 5, 0)
grid.addWidget(combined_fee, 5, 1)
grid.addWidget(QLabel(_('Total feerate') + ':'), 6, 0)
grid.addWidget(combined_feerate, 6, 1)
vbox.addLayout(grid)
vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
if not d.exec():
return
fee = fee_e.get_amount()
if fee is None:
return # fee left empty, treat it as "cancel"
if fee > max_fee:
self.show_error(_('Max fee exceeded'))
return
try:
new_tx = self.wallet.cpfp(parent_tx, fee)
except CannotCPFP as e:
self.show_error(str(e))
return
self.show_transaction(new_tx)
def bump_fee_dialog(self, tx: Transaction):
if not isinstance(tx, PartialTransaction):
tx = PartialTransaction.from_tx(tx)
if not tx.add_info_from_wallet_and_network(wallet=self.wallet, show_error=self.show_error):
return
d = BumpFeeDialog(main_window=self, tx=tx)
d.run()
def dscancel_dialog(self, tx: Transaction):
if not isinstance(tx, PartialTransaction):
tx = PartialTransaction.from_tx(tx)
if not tx.add_info_from_wallet_and_network(wallet=self.wallet, show_error=self.show_error):
return
d = DSCancelDialog(main_window=self, tx=tx)
d.run()
def save_transaction_into_wallet(self, tx: Transaction):
win = self.top_level_window()
try:
if not self.wallet.adb.add_transaction(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_db()
# 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(icon_path("offline_tx.png")), None, _('Success'), msg)
return True
def show_cert_mismatch_error(self):
if self.showing_cert_mismatch_error:
return
self.showing_cert_mismatch_error = True
self.show_critical(title=_("Certificate mismatch"),
msg=_("The SSL certificate provided by the main server did not match the fingerprint passed in with the --serverfingerprint option.") + "\n\n" +
_("Electrum will now exit."))
self.showing_cert_mismatch_error = False
self.close()
def rebalance_dialog(self, chan1, chan2, amount_sat=None):
from .rebalance_dialog import RebalanceDialog
if chan1 is None or chan2 is None:
return
d = RebalanceDialog(self, chan1, chan2, amount_sat)
d.run()
def on_swap_result(self, txid: Optional[str], *, is_reverse: bool):
msg = _("Submarine swap") + ': ' + (_("Success") if txid else _("Expired")) + '\n\n'
if txid:
msg += _("Funding transaction") + ': ' + txid + '\n\n'
if is_reverse:
msg += messages.MSG_REVERSE_SWAP_FUNDING_MEMPOOL
else:
msg += messages.MSG_FORWARD_SWAP_FUNDING_MEMPOOL
self.show_message_signal.emit(msg)
else:
msg += _("Lightning funds were not received.") # FIXME should this not depend on is_reverse?
self.show_error_signal.emit(msg)
def set_payment_identifier(self, pi: str):
# delegate to send_tab
self.send_tab.set_payment_identifier(pi)
================================================
FILE: electrum/gui/qt/my_treeview.py
================================================
#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2023 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.
import enum
from decimal import Decimal
from typing import (Optional, TYPE_CHECKING, Union, List, Dict, Any,
Sequence, Iterable, Type, Callable)
from PyQt6.QtGui import (QStandardItem, QStandardItemModel,
QShowEvent, QPainter, QHelpEvent, QMouseEvent, QAction)
from PyQt6.QtCore import (Qt, QPersistentModelIndex, QModelIndex, QItemSelectionModel,
QSortFilterProxyModel, QSize, QAbstractItemModel, QEvent, QPoint)
from PyQt6.QtWidgets import (QLabel, QHBoxLayout, QAbstractItemView, QLineEdit,
QWidget, QToolButton, QTreeView, QHeaderView, QStyledItemDelegate,
QMenu, QStyleOptionViewItem)
from electrum.i18n import _
from electrum.simple_config import ConfigVarWithConfig
from electrum.gui import messages
from .util import read_QIcon
if TYPE_CHECKING:
from electrum import SimpleConfig
from .main_window import ElectrumWindow
class QMenuWithConfig(QMenu):
def __init__(self, config: 'SimpleConfig'):
QMenu.__init__(self)
self.setToolTipsVisible(True)
self.config = config
def addToggle(
self,
text: str,
callback: Callable[[], None],
*,
tooltip: Optional[str] = None,
default_state: bool = False,
) -> QAction:
m = self.addAction(text, callback)
m.setCheckable(True)
m.setChecked(default_state)
tooltip = tooltip or ""
m.setToolTip(tooltip)
return m
def addConfig(
self,
configvar: 'ConfigVarWithConfig',
*,
callback: Optional[Callable[[], None]] = None,
checked: Optional[bool] = None, # to override initial state of checkbox
short_desc: Optional[str] = None,
) -> QAction:
assert isinstance(configvar, ConfigVarWithConfig), configvar
if short_desc is None:
short_desc = configvar.get_short_desc()
assert short_desc is not None, f"short_desc missing for {configvar}"
if checked is None:
checked = bool(configvar.get())
tooltip = None
if (long_desc := configvar.get_long_desc()) is not None:
tooltip = messages.to_rtf(long_desc)
return self.addToggle(
short_desc,
lambda: self._do_toggle_config(configvar, callback=callback),
tooltip=tooltip,
default_state=checked,
)
def _do_toggle_config(
self,
configvar: 'ConfigVarWithConfig',
*,
callback: Optional[Callable[[], None]] = None,
):
b = configvar.get()
configvar.set(not b)
# call cb after configvar state is updated:
if callback:
callback()
def create_toolbar_with_menu(config: 'SimpleConfig', title):
menu = QMenuWithConfig(config)
toolbar_button = QToolButton()
toolbar_button.setText(_('Tools'))
toolbar_button.setIcon(read_QIcon("preferences.png"))
toolbar_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
toolbar_button.setMenu(menu)
toolbar_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
toolbar_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
toolbar = QHBoxLayout()
toolbar.addWidget(QLabel(title))
toolbar.addStretch()
toolbar.addWidget(toolbar_button)
return toolbar, menu
class MySortModel(QSortFilterProxyModel):
def __init__(self, parent, *, sort_role):
super().__init__(parent)
self._sort_role = sort_role
def lessThan(self, source_left: QModelIndex, source_right: QModelIndex):
parent_model = self.sourceModel() # type: QStandardItemModel
item1 = parent_model.itemFromIndex(source_left)
item2 = parent_model.itemFromIndex(source_right)
data1 = item1.data(self._sort_role)
data2 = item2.data(self._sort_role)
if data1 is not None and data2 is not None:
return data1 < data2
v1 = item1.text()
v2 = item2.text()
try:
return Decimal(v1) < Decimal(v2)
except Exception:
return v1 < v2
class ElectrumItemDelegate(QStyledItemDelegate):
def __init__(self, tv: 'MyTreeView'):
super().__init__(tv)
self.tv = tv
self.opened = None
def on_closeEditor(editor: QLineEdit, hint):
self.opened = None
self.tv.is_editor_open = False
if self.tv._pending_update:
self.tv.update()
def on_commitData(editor: QLineEdit):
new_text = editor.text()
idx = QModelIndex(self.opened)
row, col = idx.row(), idx.column()
edit_key = self.tv.get_edit_key_from_coordinate(row, col)
assert edit_key is not None, (idx.row(), idx.column())
self.tv.on_edited(idx, edit_key=edit_key, text=new_text)
self.closeEditor.connect(on_closeEditor)
self.commitData.connect(on_commitData)
def createEditor(self, parent, option, idx):
self.opened = QPersistentModelIndex(idx)
self.tv.is_editor_open = True
return super().createEditor(parent, option, idx)
def paint(self, painter: QPainter, option: QStyleOptionViewItem, idx: QModelIndex) -> None:
custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT)
if custom_data is None:
return super().paint(painter, option, idx)
else:
# let's call the default paint method first; to paint the background (e.g. selection)
super().paint(painter, option, idx)
# and now paint on top of that
custom_data.paint(painter, option.rect)
def helpEvent(self, evt: QHelpEvent, view: QAbstractItemView, option: QStyleOptionViewItem, idx: QModelIndex) -> bool:
custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT)
if custom_data is None:
return super().helpEvent(evt, view, option, idx)
else:
if evt.type() == QEvent.Type.ToolTip:
if custom_data.show_tooltip(evt):
return True
return super().helpEvent(evt, view, option, idx)
def sizeHint(self, option: QStyleOptionViewItem, idx: QModelIndex) -> QSize:
custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT)
if custom_data is None:
return super().sizeHint(option, idx)
else:
default_size = super().sizeHint(option, idx)
return custom_data.sizeHint(default_size)
class MyTreeView(QTreeView):
ROLE_CLIPBOARD_DATA = Qt.ItemDataRole.UserRole + 100
ROLE_CUSTOM_PAINT = Qt.ItemDataRole.UserRole + 101
ROLE_EDIT_KEY = Qt.ItemDataRole.UserRole + 102
ROLE_FILTER_DATA = Qt.ItemDataRole.UserRole + 103
filter_columns: Iterable[int]
class BaseColumnsEnum(enum.IntEnum):
@staticmethod
def _generate_next_value_(name: str, start: int, count: int, last_values):
# this is overridden to get a 0-based counter
return count
Columns: Type[BaseColumnsEnum]
def __init__(
self,
*,
parent: Optional[QWidget] = None,
main_window: Optional['ElectrumWindow'] = None,
stretch_column: Optional[int] = None,
editable_columns: Optional[Sequence[int]] = None,
):
parent = parent or main_window
super().__init__(parent)
self.main_window = main_window
self.config = self.main_window.config if self.main_window else None
self.stretch_column = stretch_column
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.create_menu)
self.setUniformRowHeights(True)
# Control which columns are editable
if editable_columns is None:
editable_columns = []
self.editable_columns = set(editable_columns)
self.setItemDelegate(ElectrumItemDelegate(self))
self.current_filter = ""
self.is_editor_open = False
self.setRootIsDecorated(False) # remove left margin
self.toolbar_shown = False
# When figuring out the size of columns, Qt by default looks at
# the first 1000 rows (at least if resize mode is QHeaderView.ResizeToContents).
# This would be REALLY SLOW, and it's not perfect anyway.
# So to speed the UI up considerably, set it to
# only look at as many rows as currently visible.
self.header().setResizeContentsPrecision(0)
self._pending_update = False
self._forced_update = False
self._currently_open_menu = None # type: Optional[QMenu]
self._default_bg_brush = QStandardItem().background()
self.proxy = None # history, and address tabs use a proxy
def create_menu(self, position: QPoint) -> None:
pass
def open_menu(self, menu: QMenu, position) -> None:
try:
self._currently_open_menu = menu
menu.exec(self.viewport().mapToGlobal(position))
finally:
self._currently_open_menu = None
def close_menu(self):
if self._currently_open_menu:
self._currently_open_menu.close()
self._currently_open_menu = None
def set_editability(self, items):
for idx, i in enumerate(items):
i.setEditable(idx in self.editable_columns)
def selected_in_column(self, column: int):
items = self.selectionModel().selectedIndexes()
return list(x for x in items if x.column() == column)
def get_role_data_for_current_item(self, *, col, role) -> Any:
idx = self.selectionModel().currentIndex()
idx = idx.sibling(idx.row(), col)
item = self.item_from_index(idx)
if item:
return item.data(role)
def item_from_index(self, idx: QModelIndex) -> Optional[QStandardItem]:
model = self.model()
if isinstance(model, QSortFilterProxyModel):
idx = model.mapToSource(idx)
return model.sourceModel().itemFromIndex(idx)
else:
return model.itemFromIndex(idx)
def original_model(self) -> QAbstractItemModel:
model = self.model()
if isinstance(model, QSortFilterProxyModel):
return model.sourceModel()
else:
return model
def set_current_idx(self, set_current: QPersistentModelIndex):
if set_current:
assert isinstance(set_current, QPersistentModelIndex)
assert set_current.isValid()
self.selectionModel().select(QModelIndex(set_current), QItemSelectionModel.SelectionFlag.SelectCurrent)
def update_headers(self, headers: Union[List[str], Dict[int, str]]):
# headers is either a list of column names, or a dict: (col_idx->col_name)
if not isinstance(headers, dict): # convert to dict
headers = dict(enumerate(headers))
col_names = [headers[col_idx] for col_idx in sorted(headers.keys())]
self.original_model().setHorizontalHeaderLabels(col_names)
self.header().setStretchLastSection(False)
for col_idx in headers:
sm = QHeaderView.ResizeMode.Stretch if col_idx == self.stretch_column else QHeaderView.ResizeMode.ResizeToContents
self.header().setSectionResizeMode(col_idx, sm)
def keyPressEvent(self, event):
if self.itemDelegate().opened:
return
if event.key() in [Qt.Key.Key_F2, Qt.Key.Key_Return, Qt.Key.Key_Enter]:
self.on_activated(self.selectionModel().currentIndex())
return
super().keyPressEvent(event)
def mouseDoubleClickEvent(self, event: QMouseEvent):
idx: QModelIndex = self.indexAt(event.pos())
if self.proxy:
idx = self.proxy.mapToSource(idx)
if not idx.isValid():
# can happen e.g. before list is populated for the first time
return
self.on_double_click(idx)
def on_double_click(self, idx):
pass
def on_activated(self, idx):
# on 'enter' we show the menu
pt = self.visualRect(idx).bottomLeft()
pt.setX(50)
self.customContextMenuRequested.emit(pt)
def edit(self, idx, trigger=QAbstractItemView.EditTrigger.AllEditTriggers, event=None):
"""
this is to prevent:
edit: editing failed
from inside qt
"""
return super().edit(idx, trigger, event)
def on_edited(self, idx: QModelIndex, edit_key, *, text: str) -> None:
raise NotImplementedError()
def should_hide(self, row):
"""
row_num is for self.model(). So if there is a proxy, it is the row number
in that!
"""
return False
def get_text_from_coordinate(self, row, col) -> str:
idx = self.model().index(row, col)
item = self.item_from_index(idx)
return item.text()
def get_role_data_from_coordinate(self, row, col, *, role) -> Any:
idx = self.model().index(row, col)
item = self.item_from_index(idx)
role_data = item.data(role)
return role_data
def get_edit_key_from_coordinate(self, row, col) -> Any:
# overriding this might allow avoiding storing duplicate data
return self.get_role_data_from_coordinate(row, col, role=self.ROLE_EDIT_KEY)
def get_filter_data_from_coordinate(self, row, col) -> str:
filter_data = self.get_role_data_from_coordinate(row, col, role=self.ROLE_FILTER_DATA)
if filter_data:
return filter_data
txt = self.get_text_from_coordinate(row, col)
txt = txt.lower()
return txt
def hide_row(self, row_num):
"""
row_num is for self.model(). So if there is a proxy, it is the row number
in that!
"""
should_hide = self.should_hide(row_num)
if not self.current_filter and should_hide is None:
# no filters at all, neither date nor search
self.setRowHidden(row_num, QModelIndex(), False)
return
for column in self.filter_columns:
filter_data = self.get_filter_data_from_coordinate(row_num, column)
if self.current_filter in filter_data:
# the filter matched, but the date filter might apply
self.setRowHidden(row_num, QModelIndex(), bool(should_hide))
break
else:
# we did not find the filter in any columns, hide the item
self.setRowHidden(row_num, QModelIndex(), True)
def filter(self, p=None):
if p is not None:
p = p.lower()
self.current_filter = p
self.hide_rows()
def hide_rows(self):
for row in range(self.model().rowCount()):
self.hide_row(row)
def create_toolbar(self, config: 'SimpleConfig'):
return
def create_toolbar_buttons(self):
hbox = QHBoxLayout()
buttons = self.get_toolbar_buttons()
for b in buttons:
b.setVisible(False)
hbox.addWidget(b)
self.toolbar_buttons = buttons
return hbox
def create_toolbar_with_menu(self, title):
return create_toolbar_with_menu(self.config, title)
configvar_show_toolbar = None # type: Optional[ConfigVarWithConfig]
_toolbar_checkbox = None # type: Optional[QAction]
def show_toolbar(self, state: bool = None):
if state is None: # get value from config
if self.configvar_show_toolbar:
state = self.configvar_show_toolbar.get()
else:
return
assert isinstance(state, bool), state
if state == self.toolbar_shown:
return
self.toolbar_shown = state
for b in self.toolbar_buttons:
b.setVisible(state)
if not state:
self.on_hide_toolbar()
if self._toolbar_checkbox is not None:
# update the cb state now, in case the checkbox was not what triggered us
self._toolbar_checkbox.setChecked(state)
def on_hide_toolbar(self):
pass
def toggle_toolbar(self):
new_state = not self.toolbar_shown
self.show_toolbar(new_state)
if self.configvar_show_toolbar:
self.configvar_show_toolbar.set(new_state)
def add_copy_menu(self, menu: QMenu, idx) -> QMenu:
cc = menu.addMenu(_("Copy"))
for column in self.Columns:
if self.isColumnHidden(column):
continue
column_title = self.original_model().horizontalHeaderItem(column).text()
if not column_title:
continue
item_col = self.item_from_index(idx.sibling(idx.row(), column))
clipboard_data = item_col.data(self.ROLE_CLIPBOARD_DATA)
if clipboard_data is None:
clipboard_data = item_col.text().strip()
cc.addAction(column_title,
lambda text=clipboard_data, title=column_title:
self.place_text_on_clipboard(text, title=title))
return cc
def place_text_on_clipboard(self, text: str, *, title: str = None) -> None:
self.main_window.do_copy(text, title=title)
def showEvent(self, e: 'QShowEvent'):
super().showEvent(e)
if e.isAccepted() and self._pending_update:
self._forced_update = True
self.update()
self._forced_update = False
def maybe_defer_update(self) -> bool:
"""Returns whether we should defer an update/refresh."""
defer = (not self._forced_update
and (not self.isVisible() or self.is_editor_open))
# side-effect: if we decide to defer update, the state will become stale:
self._pending_update = defer
return defer
def find_row_by_key(self, key) -> Optional[int]:
for row in range(0, self.std_model.rowCount()):
item = self.std_model.item(row, 0)
if item.data(self.key_role) == key:
return row
def refresh_all(self):
if self.maybe_defer_update():
return
for row in range(0, self.std_model.rowCount()):
item = self.std_model.item(row, 0)
key = item.data(self.key_role)
self.refresh_row(key, row)
def refresh_row(self, key: str, row: int) -> None:
pass
def refresh_item(self, key):
row = self.find_row_by_key(key)
if row is not None:
self.refresh_row(key, row)
def delete_item(self, key):
row = self.find_row_by_key(key)
if row is not None:
self.std_model.takeRow(row)
self.hide_if_empty()
================================================
FILE: electrum/gui/qt/network_dialog.py
================================================
#!/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.
from enum import IntEnum
from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import (
QTreeWidget, QTreeWidgetItem, QMenu, QGridLayout, QComboBox, QLineEdit, QDialog, QVBoxLayout, QHeaderView,
QCheckBox, QTabWidget, QWidget, QLabel, QPushButton, QHBoxLayout,
QListWidget, QListWidgetItem,
)
from PyQt6.QtGui import QIntValidator
from electrum.i18n import _
from electrum import blockchain
from electrum.interface import ServerAddr, PREFERRED_NETWORK_PROTOCOL
from electrum.network import Network, ProxySettings, is_valid_host, is_valid_port
from electrum.logging import get_logger
from electrum.util import is_valid_websocket_url
from electrum.gui import messages
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .util import (
Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, PasswordLineEdit, Spinner, HelpLabel
)
_logger = get_logger(__name__)
protocol_names = ['TCP', 'SSL']
protocol_letters = 'ts'
class NetworkDialog(QDialog, QtEventListener):
def __init__(self, *, network: Network):
QDialog.__init__(self)
self.setWindowTitle(_('Network'))
self.setMinimumSize(500, 500)
self.tabs = tabs = QTabWidget()
self._blockchain_tab = ServerWidget(network)
self._proxy_tab = ProxyWidget(network)
self._nostr_tab = NostrWidget(network)
tabs.addTab(self._blockchain_tab, _('Server'))
tabs.addTab(self._nostr_tab, _('Nostr'))
tabs.addTab(self._proxy_tab, _('Proxy'))
vbox = QVBoxLayout(self)
vbox.addWidget(self.tabs)
vbox.addLayout(Buttons(CloseButton(self)))
def show(self, *, proxy_tab: bool = False):
super().show()
self.tabs.setCurrentWidget(self._proxy_tab if proxy_tab else self._blockchain_tab)
class NodesListWidget(QTreeWidget):
"""List of connected servers."""
SERVER_ADDR_ROLE = Qt.ItemDataRole.UserRole + 100
CHAIN_ID_ROLE = Qt.ItemDataRole.UserRole + 101
ITEMTYPE_ROLE = Qt.ItemDataRole.UserRole + 102
class ItemType(IntEnum):
CHAIN = 0
CONNECTED_SERVER = 1
DISCONNECTED_SERVER = 2
TOPLEVEL = 3
followServer = pyqtSignal([ServerAddr], arguments=['server'])
followChain = pyqtSignal([str], arguments=['chain_id'])
setServer = pyqtSignal([str], arguments=['server'])
def __init__(self, *, network: Network):
QTreeWidget.__init__(self)
self.setHeaderLabels([_('Server'), _('Height')])
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.create_menu)
self.network = network
def create_menu(self, position):
item = self.currentItem()
if not item:
return
item_type = item.data(0, self.ITEMTYPE_ROLE)
menu = QMenu()
if item_type in [self.ItemType.CONNECTED_SERVER, self.ItemType.DISCONNECTED_SERVER]:
server = item.data(0, self.SERVER_ADDR_ROLE) # type: ServerAddr
if item_type == self.ItemType.CONNECTED_SERVER:
def do_follow_server():
self.followServer.emit(server)
menu.addAction(read_QIcon("chevron-right.png"), _("Use as server"), do_follow_server)
elif item_type == self.ItemType.DISCONNECTED_SERVER:
def do_set_server():
self.setServer.emit(str(server))
menu.addAction(read_QIcon("chevron-right.png"), _("Use as server"), do_set_server)
def set_bookmark(*, add: bool):
self.network.set_server_bookmark(server, add=add)
self.update()
if self.network.is_server_bookmarked(server):
menu.addAction(read_QIcon("bookmark_remove.png"), _("Remove from bookmarks"), lambda: set_bookmark(add=False))
else:
menu.addAction(read_QIcon("bookmark_add.png"), _("Bookmark this server"), lambda: set_bookmark(add=True))
elif item_type == self.ItemType.CHAIN:
chain_id = item.data(0, self.CHAIN_ID_ROLE)
def do_follow_chain():
self.followChain.emit(chain_id)
menu.addAction(_("Follow this branch"), do_follow_chain)
else:
return
menu.exec(self.viewport().mapToGlobal(position))
def keyPressEvent(self, event):
if event.key() in [Qt.Key.Key_F2, Qt.Key.Key_Return, Qt.Key.Key_Enter]:
self.on_activated(self.currentItem(), self.currentColumn())
else:
QTreeWidget.keyPressEvent(self, event)
def on_activated(self, item, column):
# on 'enter' we show the menu
pt = self.visualItemRect(item).bottomLeft()
pt.setX(50)
self.customContextMenuRequested.emit(pt)
def update(self):
self.clear()
network = self.network
# connected servers
connected_servers_item = QTreeWidgetItem([_("Connected nodes"), ''])
connected_servers_item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.TOPLEVEL)
chains = network.get_blockchains()
n_chains = len(chains)
for chain_id, interfaces in chains.items():
b = blockchain.blockchains.get(chain_id)
if b is None:
continue
name = b.get_name()
if n_chains > 1:
x = QTreeWidgetItem([name + '@%d'%b.get_max_forkpoint(), '%d'%b.height()])
x.setData(0, self.ITEMTYPE_ROLE, self.ItemType.CHAIN)
x.setData(0, self.CHAIN_ID_ROLE, b.get_id())
else:
x = connected_servers_item
for i in interfaces:
item = QTreeWidgetItem([f"{i.server.to_friendly_name()}", '%d'%i.tip])
item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.CONNECTED_SERVER)
item.setData(0, self.SERVER_ADDR_ROLE, i.server)
item.setToolTip(0, str(i.server))
if i == network.interface:
item.setIcon(0, read_QIcon("chevron-right.png"))
elif network.is_server_bookmarked(i.server):
item.setIcon(0, read_QIcon("bookmark.png"))
x.addChild(item)
if n_chains > 1:
connected_servers_item.addChild(x)
# disconnected servers
disconnected_servers_item = QTreeWidgetItem([_("Other known servers"), ""])
disconnected_servers_item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.TOPLEVEL)
for server in network.get_disconnected_server_addrs():
item = QTreeWidgetItem([server.to_friendly_name(), ""])
item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.DISCONNECTED_SERVER)
item.setData(0, self.SERVER_ADDR_ROLE, server)
if network.is_server_bookmarked(server):
item.setIcon(0, read_QIcon("bookmark.png"))
disconnected_servers_item.addChild(item)
self.addTopLevelItem(connected_servers_item)
self.addTopLevelItem(disconnected_servers_item)
connected_servers_item.setExpanded(True)
for i in range(connected_servers_item.childCount()):
connected_servers_item.child(i).setExpanded(True)
disconnected_servers_item.setExpanded(True)
# headers
h = self.header()
h.setStretchLastSection(False)
h.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
h.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
super().update()
class ProxyWidget(QWidget):
PROXY_MODES = {
'socks4': 'SOCKS4',
'socks5': 'SOCKS5/TOR'
}
torProbeFinished = pyqtSignal([str, int], arguments=['host', 'port'])
def __init__(self, network: Network, parent=None):
super().__init__(parent)
self.network = network
self.config = network.config
fixed_width_port = 6 * char_width_in_lineedit()
# proxy setting.
self.proxy_cb = QCheckBox(_('Use proxy'))
self.proxy_mode = QComboBox()
for k, v in self.PROXY_MODES.items():
self.proxy_mode.addItem(v, k)
self.proxy_mode.setCurrentIndex(1)
self.proxy_host = QLineEdit()
self.proxy_port = QLineEdit()
self.proxy_port.setFixedWidth(fixed_width_port)
self.proxy_port_validator = QIntValidator(1, 65535)
self.proxy_port.setValidator(self.proxy_port_validator)
self.proxy_user = QLineEdit()
self.proxy_user.setPlaceholderText(_("Proxy username"))
self.proxy_password = PasswordLineEdit()
self.proxy_password.setPlaceholderText(_("Proxy password"))
grid = QGridLayout(self)
grid.setSpacing(8)
grid.addWidget(self.proxy_cb, 0, 0, 1, 4)
proxy_helpbutton = HelpButton(
_('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.'))
grid.addWidget(proxy_helpbutton, 0, 4, alignment=Qt.AlignmentFlag.AlignRight)
grid.addWidget(self.proxy_mode, 1, 0, 1, 1)
grid.addWidget(self.proxy_host, 1, 1, 1, 3)
grid.addWidget(self.proxy_port, 1, 4, 1, 1)
grid.addWidget(self.proxy_user, 2, 1, 1, 2)
grid.addWidget(self.proxy_password, 2, 3, 1, 2)
detect_l = QHBoxLayout()
self.detect_button = QPushButton(_('Detect Tor proxy'))
self.spinner = Spinner()
self.spinner.setMargin(5)
detect_l.addWidget(self.detect_button)
detect_l.addWidget(self.spinner)
grid.addLayout(detect_l, 3, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignLeft)
spacer = QVBoxLayout()
spacer.addStretch(1)
grid.addLayout(spacer, 4, 0, 1, 5)
self.update_from_config()
self.update()
# connect signal handlers after init from config
self.proxy_cb.stateChanged.connect(self.on_proxy_enable_toggle)
self.proxy_mode.currentIndexChanged.connect(self.on_proxy_settings_changed)
self.proxy_host.editingFinished.connect(self.on_proxy_settings_changed)
self.proxy_port.editingFinished.connect(self.on_proxy_settings_changed)
self.proxy_user.editingFinished.connect(self.on_proxy_settings_changed)
self.proxy_password.editingFinished.connect(self.on_proxy_settings_changed)
self.detect_button.clicked.connect(self.detect_tor)
self.torProbeFinished.connect(self.on_tor_probe_finished)
def update(self):
enabled = self.proxy_cb.isChecked() and self.config.cv.NETWORK_PROXY.is_modifiable()
for item in [
self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password,
self.detect_button
]:
item.setEnabled(enabled)
if not self.proxy_port.hasAcceptableInput() and not is_valid_port(self.proxy_port.text()):
return
if not is_valid_host(self.proxy_host.text()):
return
net_params = self.network.get_parameters()
proxy = self.get_proxy_settings()
net_params = net_params._replace(proxy=proxy)
self.network.run_from_another_thread(self.network.set_parameters(net_params))
def update_from_config(self):
proxy = ProxySettings.from_config(self.config)
self.proxy_cb.setChecked(proxy.enabled)
self.proxy_mode.setCurrentText(self.PROXY_MODES.get(proxy.mode))
self.proxy_host.setText(proxy.host)
self.proxy_port.setText(proxy.port)
self.proxy_user.setText(proxy.user)
self.proxy_password.setText(proxy.password)
if not self.config.cv.NETWORK_PROXY.is_modifiable():
for w in [
self.proxy_cb, self.proxy_mode, self.proxy_host, self.proxy_port,
self.proxy_user, self.proxy_password, self.detect_button
]:
w.setEnabled(False)
def on_proxy_enable_toggle(self):
# probe if enabled and no pre-existing settings
# if self.proxy_cb.isChecked() and (not self.proxy_host.text() or not self.proxy_port.text()):
# self.detect_tor()
self.update()
def on_proxy_settings_changed(self):
self.update()
def get_proxy_settings(self) -> ProxySettings:
proxy = ProxySettings()
proxy.enabled = self.proxy_cb.isChecked()
proxy.mode = self.proxy_mode.currentData()
proxy.host = self.proxy_host.text()
proxy.port = self.proxy_port.text()
proxy.user = self.proxy_user.text()
proxy.password = self.proxy_password.text()
return proxy
def detect_tor(self):
self.detect_button.setEnabled(False)
self.spinner.setVisible(True)
ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal
@pyqtSlot(str, int)
def on_tor_probe_finished(self, host: str, port: int):
self.detect_button.setEnabled(True)
self.spinner.setVisible(False)
if host:
self.proxy_mode.setCurrentIndex(1)
self.proxy_host.setText(host)
self.proxy_port.setText(str(port))
self.update()
class ConnectMode(IntEnum):
AUTOCONNECT = 0
MANUAL = 1
ONESERVER = 2
class ServerWidget(QWidget, QtEventListener):
CONNECT_MODES = {
ConnectMode.AUTOCONNECT: messages.MSG_CONNECTMODE_AUTOCONNECT,
ConnectMode.MANUAL: messages.MSG_CONNECTMODE_MANUAL,
ConnectMode.ONESERVER: messages.MSG_CONNECTMODE_ONESERVER,
}
server_e_valid = pyqtSignal(bool)
def __init__(self, network: Network, parent=None):
super().__init__(parent)
self.network = network
self.config = network.config
self.setLayout(QVBoxLayout())
grid = QGridLayout()
self.connect_combo = QComboBox()
for i, v in sorted(self.CONNECT_MODES.items()):
self.connect_combo.addItem(v, i)
self.connect_combo.currentIndexChanged.connect(self.on_server_settings_changed)
grid.addWidget(QLabel(_('Connection mode') + ':'), 0, 0)
msg = (
f"""
{messages.MSG_CONNECTMODE_SERVER_HELP}
"""
)
grid.addWidget(HelpButton(msg), 0, 4)
grid.addWidget(self.connect_combo, 0, 1, 1, 3)
self.server_e = QLineEdit()
self.server_e.textChanged.connect(self.validate_server_e)
self.server_e.editingFinished.connect(self.on_server_settings_changed)
grid.addWidget(QLabel(_('Server') + ':'), 1, 0)
grid.addWidget(self.server_e, 1, 1, 1, 3)
grid.addWidget(HelpButton(messages.MSG_CONNECTMODE_SERVER_HELP), 1, 4)
self.status_label_header = QLabel(_('Status') + ':')
self.status_label = QLabel('')
self.status_label_helpbutton = HelpButton(messages.MSG_CONNECTMODE_NODES_HELP)
grid.addWidget(self.status_label_header, 2, 0)
grid.addWidget(self.status_label, 2, 1, 1, 3)
grid.addWidget(self.status_label_helpbutton, 2, 4)
msg = _('This is the height of your local copy of the blockchain.')
self.height_label_header = QLabel(_('Blockchain') + ':')
self.height_label = QLabel('')
self.height_label_helpbutton = HelpButton(msg)
grid.addWidget(self.height_label_header, 3, 0)
grid.addWidget(self.height_label, 3, 1)
grid.addWidget(self.height_label_helpbutton, 3, 4)
self.split_label = QLabel('')
grid.addWidget(self.split_label, 4, 1, 1, 3)
self.layout().addLayout(grid)
self.nodes_list_widget = NodesListWidget(network=self.network)
self.nodes_list_widget.followServer.connect(self.follow_server)
self.nodes_list_widget.followChain.connect(self.follow_branch)
def do_set_server(server):
self.server_e.setText(server)
if self.is_auto_connect():
# switch to manual mode as the user manually selected a server
self.set_connect_mode(ConnectMode.MANUAL, block_signals=True)
self.on_server_settings_changed()
self.nodes_list_widget.setServer.connect(do_set_server)
self.layout().addWidget(self.nodes_list_widget)
self.nodes_list_widget.update()
self.register_callbacks()
self.destroyed.connect(lambda: self.unregister_callbacks())
def showEvent(self, event):
# gets called every time the ServerWidget is shown, when opening it and when
# switching between the tabs.
super().showEvent(event)
_logger.debug(f"showing ServerWidget")
# If the user entered garbage the previous time the ServerWidget was open this will restore
# it back to the current config
self.update_from_config()
self.update()
@qt_event_listener
def on_event_network_updated(self):
self.nodes_list_widget.update() # NOTE: move event handling to widget itself?
self.update()
def is_auto_connect(self):
return self.connect_combo.currentIndex() == ConnectMode.AUTOCONNECT
def is_one_server(self):
return self.connect_combo.currentIndex() == ConnectMode.ONESERVER
def set_connect_mode(self, connect_mode: ConnectMode, *, block_signals = False):
# if block_signals = True the on_server_settings_changed won't get called when changing the index
assert isinstance(connect_mode, ConnectMode), connect_mode
self.connect_combo.blockSignals(block_signals)
self.connect_combo.setCurrentIndex(connect_mode)
self.connect_combo.blockSignals(False)
def on_server_settings_changed(self):
if not self.network._was_started:
self.update()
return
current_net_params = self.network.get_parameters()
new_server = ServerAddr.from_str_with_inference(self.server_e.text().strip())
new_server = new_server or current_net_params.server # keep existing server while input is invalid
settings_changed = False
if new_server != current_net_params.server:
settings_changed = True
if self.is_auto_connect() != current_net_params.auto_connect:
settings_changed = True
if self.is_one_server() != current_net_params.oneserver:
settings_changed = True
if settings_changed:
_logger.debug(
f"ServerWidget.on_server_settings_changed:\n"
f"[server: {current_net_params.server} -> {new_server}]\n"
f"[auto_connect: {current_net_params.auto_connect} -> {self.is_auto_connect()}]\n"
f"[oneserver: {current_net_params.oneserver} -> {self.is_one_server()}]"
)
self.set_server(
new_server,
auto_connect=self.is_auto_connect(),
one_server=self.is_one_server(),
)
self.update()
def update(self):
self.server_e.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable() and not self.is_auto_connect())
if self.is_auto_connect():
self.server_e.clear()
elif not self.server_e.text():
self.server_e.setText(self.config.NETWORK_SERVER or "")
for item in [
self.status_label_header, self.status_label, self.status_label_helpbutton,
self.height_label_header, self.height_label, self.height_label_helpbutton]:
item.setVisible(self.network._was_started)
self.validate_server_e()
msg = _('Fork detection disabled') if self.is_one_server() else ''
if self.network._was_started:
# Network was started, so we don't run in initial setup wizard.
# behavior in this case is to apply changes immediately.
# Also, we show block height and potential chain tips
height_str = _('{} blocks').format(self.network.get_local_height())
self.height_label.setText(height_str)
self.status_label.setText(self.network.get_status())
chains = self.network.get_blockchains()
if len(chains) > 1:
chain = self.network.blockchain()
forkpoint = chain.get_max_forkpoint()
name = chain.get_name()
msg = _('Fork detected at block {0}').format(forkpoint) + '\n'
if self.is_auto_connect():
msg += _('You are following branch {}').format(name)
else:
msg += _('Your server is on branch {0} ({1} blocks)').format(name, chain.get_branch_size())
self.split_label.setText(msg)
def validate_server_e(self):
if not self.server_e.isEnabled():
self.server_e.setStyleSheet("")
self.server_e_valid.emit(True)
return
server = ServerAddr.from_str_with_inference(self.server_e.text())
self.server_e.setStyleSheet("background-color: rgba(255, 0, 0, 0.2);" if not server else "")
self.server_e_valid.emit(server is not None)
def update_from_config(self):
auto_connect = self.config.NETWORK_AUTO_CONNECT
one_server = self.config.NETWORK_ONESERVER
v = ConnectMode.AUTOCONNECT if auto_connect else ConnectMode.ONESERVER if one_server else ConnectMode.MANUAL
self.set_connect_mode(v)
server = self.config.NETWORK_SERVER
self.server_e.setText(server)
self.server_e.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable() and not auto_connect)
self.nodes_list_widget.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable())
_logger.debug(f"update from config: done")
def follow_branch(self, chain_id):
self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id))
# follow_chain_given_id connects to random interface, so set connect_mode back to AUTOCONNECT
self.set_connect_mode(ConnectMode.AUTOCONNECT, block_signals=True)
self.update()
def follow_server(self, server: ServerAddr):
try:
self.network.follow_chain_given_server(server)
except KeyError:
_logger.debug(f"follow_server: cannot follow, not connected to {server.net_addr_str()}.")
return
self.server_e.setText(str(server))
if self.is_auto_connect():
# the user manually selected a server, so the ConnectMode gets set to MANUAL
self.set_connect_mode(ConnectMode.MANUAL, block_signals=True)
self.set_server(
server=server,
auto_connect=False,
one_server=self.is_one_server(),
)
self.update()
def set_server(self, server: ServerAddr, *, auto_connect: bool, one_server: bool):
current_net_params = self.network.get_parameters()
new_net_params = current_net_params._replace(
server=server,
auto_connect=auto_connect,
oneserver=one_server,
)
_logger.debug(f"set_server: {new_net_params=}")
self.network.run_from_another_thread(self.network.set_parameters(new_net_params))
class NostrWidget(QWidget, QtEventListener):
def __init__(self, network: Network, parent=None):
super().__init__(parent)
self.network = network
self.config = network.config
vbox = QVBoxLayout()
self.setLayout(vbox)
grid = QGridLayout()
nostr_relays_label = QLabel(self.config.cv.NOSTR_RELAYS.get_short_desc())
nostr_helpbutton = HelpButton(self.config.cv.NOSTR_RELAYS.get_long_desc())
grid.addWidget(nostr_relays_label, 0, 0)
grid.addWidget(nostr_helpbutton, 0, 1)
vbox.addLayout(grid)
self.relays_list = QListWidget()
self.relay_edit = QLineEdit()
self.relay_edit.textChanged.connect(self.on_relay_edited)
vbox.addWidget(self.relays_list)
vbox.addStretch()
self.add_button = QPushButton(_('Add'))
self.add_button.clicked.connect(self.add_relay)
self.add_button.setEnabled(False)
remove_button = QPushButton(_('Remove'))
remove_button.clicked.connect(self.remove_relay)
reset_button = QPushButton(_('Reset'))
reset_button.clicked.connect(self.reset_relays)
buttons = Buttons(self.relay_edit, self.add_button, remove_button, reset_button)
vbox.addLayout(buttons)
self.update_list()
def on_relay_edited(self, text):
self.add_button.setEnabled(is_valid_websocket_url(text))
def update_list(self):
self.relays_list.clear()
for relay in self.config.get_nostr_relays():
item = QListWidgetItem(relay)
self.relays_list.addItem(item)
def add_relay(self):
relay = self.relay_edit.text()
self.config.add_nostr_relay(relay)
self.update_list()
def remove_relay(self):
item = self.relays_list.currentItem()
if item is None:
return
self.config.remove_nostr_relay(item.text())
self.update_list()
def reset_relays(self):
self.config.NOSTR_RELAYS = None
self.update_list()
================================================
FILE: electrum/gui/qt/new_channel_dialog.py
================================================
from typing import TYPE_CHECKING, Optional
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton, QComboBox, QLineEdit, QHBoxLayout
import electrum_ecc as ecc
from electrum.i18n import _
from electrum.lnutil import MIN_FUNDING_SAT
from electrum.lnworker import hardcoded_trampoline_nodes
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates
from electrum.fee_policy import FeePolicy
from electrum.lntransport import extract_nodeid, ConnStringFormatError
from .util import (WindowModalDialog, Buttons, OkButton, CancelButton,
EnterButton, WWLabel, char_width_in_lineedit)
from .amountedit import BTCAmountEdit
from .my_treeview import create_toolbar_with_menu
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class NewChannelDialog(WindowModalDialog):
def __init__(self, window: 'ElectrumWindow', amount_sat: Optional[int] = None, min_amount_sat: Optional[int] = None):
WindowModalDialog.__init__(self, window, _('Open Channel'))
self.window = window
self.network = window.network
self.config = window.config
self.lnworker = self.window.wallet.lnworker
self.trampolines = hardcoded_trampoline_nodes()
self.trampoline_names = list(self.trampolines.keys())
self.min_amount_sat = min_amount_sat or MIN_FUNDING_SAT
vbox = QVBoxLayout(self)
toolbar, menu = create_toolbar_with_menu(self.config, '')
menu.addConfig(
self.config.cv.LIGHTNING_USE_RECOVERABLE_CHANNELS,
checked=self.lnworker.has_recoverable_channels(),
).setEnabled(self.lnworker.can_have_recoverable_channels())
vbox.addLayout(toolbar)
msg = _('Choose a remote node and an amount to fund the channel.')
msg += '\n' + _('Minimum required amount: {}').format(self.window.format_amount_and_units(self.min_amount_sat))
vbox.addWidget(WWLabel(msg))
if self.network.channel_db:
vbox.addWidget(QLabel(_('Enter Remote Node ID or connection string or invoice')))
self.remote_nodeid = QLineEdit()
self.remote_nodeid.setMinimumWidth(700)
self.remote_nodeid.textChanged.connect(self.maybe_enable_ok_button)
self.suggest_button = QPushButton(self, text=_('Suggest Peer'))
self.suggest_button.clicked.connect(self.on_suggest)
else:
self.trampoline_combo = QComboBox()
self.trampoline_combo.addItems(self.trampoline_names)
# index 1 is "Electrum trampoline" on mainnet, this defaults to -1 if 1 is not available
self.trampoline_combo.setCurrentIndex(1)
self.trampoline_combo.currentIndexChanged.connect(self.maybe_enable_ok_button)
self.amount_e = BTCAmountEdit(self.window.get_decimal_point)
self.amount_e.setAmount(amount_sat)
self.amount_e.textChanged.connect(self.maybe_enable_ok_button)
btn_width = 10 * char_width_in_lineedit()
self.min_button = EnterButton(_("Min"), self.spend_min)
self.min_button.setEnabled(bool(self.min_amount_sat))
self.min_button.setFixedWidth(btn_width)
self.max_button = EnterButton(_("Max"), self.spend_max)
self.max_button.setFixedWidth(btn_width)
self.max_button.setCheckable(True)
self.clear_button = QPushButton(self, text=_('Clear'))
self.clear_button.clicked.connect(self.on_clear)
self.clear_button.setFixedWidth(btn_width)
h = QGridLayout()
if self.network.channel_db:
h.addWidget(QLabel(_('Remote Node ID')), 0, 0)
h.addWidget(self.remote_nodeid, 0, 1, 1, 4)
h.addWidget(self.suggest_button, 0, 5)
else:
h.addWidget(QLabel(_('Remote Node')), 0, 0)
h.addWidget(self.trampoline_combo, 0, 1, 1, 4)
h.addWidget(QLabel('Amount'), 2, 0)
amt_hbox = QHBoxLayout()
amt_hbox.setContentsMargins(0, 0, 0, 0)
amt_hbox.addWidget(self.amount_e)
amt_hbox.addWidget(self.min_button)
amt_hbox.addWidget(self.max_button)
amt_hbox.addWidget(self.clear_button)
amt_hbox.addStretch()
h.addLayout(amt_hbox, 2, 1, 1, 4)
vbox.addLayout(h)
vbox.addStretch()
self.ok_button = OkButton(self)
self.ok_button.setDefault(True)
self.maybe_enable_ok_button()
vbox.addLayout(Buttons(CancelButton(self), self.ok_button))
def maybe_enable_ok_button(self):
enable = True
if self.network.channel_db:
try:
extract_nodeid(str(self.remote_nodeid.text()).strip())
except ConnStringFormatError:
enable = False
else:
try:
self.trampoline_names[self.trampoline_combo.currentIndex()]
except IndexError:
enable = False
if not self.amount_e.get_amount():
enable = False
self.ok_button.setEnabled(enable)
def on_suggest(self):
self.network.start_gossip()
nodeid = (self.lnworker.suggest_peer() or b"").hex()
if not nodeid:
self.remote_nodeid.setText("")
self.remote_nodeid.setPlaceholderText(
_("Couldn't find suitable peer yet, try again later.")
)
else:
self.remote_nodeid.setText(nodeid)
self.remote_nodeid.repaint() # macOS hack for #6269
def on_clear(self):
self.amount_e.setText('')
self.amount_e.setFrozen(False)
self.amount_e.repaint() # macOS hack for #6269
if self.network.channel_db:
self.remote_nodeid.setText('')
self.remote_nodeid.repaint() # macOS hack for #6269
self.max_button.setChecked(False)
self.max_button.repaint() # macOS hack for #6269
def spend_min(self):
self.max_button.setChecked(False)
self.amount_e.setFrozen(False)
self.amount_e.setAmount(self.min_amount_sat)
def spend_max(self):
self.amount_e.setFrozen(self.max_button.isChecked())
if not self.max_button.isChecked():
return
dummy_nodeid = ecc.GENERATOR.get_public_key_bytes(compressed=True)
make_tx = self.window.mktx_for_open_channel(funding_sat='!', node_id=dummy_nodeid)
try:
tx = make_tx(FeePolicy(self.config.FEE_POLICY))
except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
self.max_button.setChecked(False)
self.amount_e.setFrozen(False)
self.window.show_error(str(e))
return
amount = tx.output_value()
amount = min(amount, self.config.LIGHTNING_MAX_FUNDING_SAT)
self.amount_e.setAmount(amount)
def run(self):
if not self.exec():
return
if self.max_button.isChecked() and self.amount_e.get_amount() < self.config.LIGHTNING_MAX_FUNDING_SAT:
# if 'max' enabled and amount is strictly less than max allowed,
# that means we have fewer coins than max allowed, and hence we can
# spend all coins
funding_sat = '!'
else:
funding_sat = self.amount_e.get_amount()
if not funding_sat:
return
if funding_sat != '!':
if self.min_amount_sat and funding_sat < self.min_amount_sat:
self.window.show_error(_('Amount too low'))
return
if self.network.channel_db:
connect_str = str(self.remote_nodeid.text()).strip()
else:
name = self.trampoline_names[self.trampoline_combo.currentIndex()]
connect_str = str(self.trampolines[name])
if not connect_str:
return
self.window.open_channel(connect_str, funding_sat, 0)
return True
================================================
FILE: electrum/gui/qt/password_dialog.py
================================================
#!/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.
import re
import math
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import QLabel, QGridLayout, QVBoxLayout, QCheckBox
from electrum.i18n import _
from electrum.plugin import run_hook
from .util import icon_path, WindowModalDialog, OkButton, CancelButton, Buttons, PasswordLineEdit
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)
MSG_ENTER_PASSWORD = _("Choose a password to encrypt your wallet keys.") + '\n'\
+ _("Leave this field empty if you want to disable encryption.")
class PasswordLayout(object):
titles = [_("Enter Password"), _("Change Password"), _("Enter Passphrase")]
def __init__(self, msg, kind, OK_button, wallet=None):
self.wallet = wallet
self.pw = PasswordLineEdit()
self.new_pw = PasswordLineEdit()
self.conf_pw = PasswordLineEdit()
self.kind = kind
self.OK_button = OK_button
vbox = QVBoxLayout()
label = QLabel(msg + "\n")
label.setWordWrap(True)
self.grid = 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.AlignmentFlag.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() and not wallet.storage.is_encrypted_with_hw_device():
grid.addWidget(QLabel(_('Current Password:')), 0, 0)
grid.addWidget(self.pw, 0, 1)
lockfile = "lock.png"
else:
lockfile = "unlock.png"
logo.setPixmap(QPixmap(icon_path(lockfile))
.scaledToWidth(36, mode=Qt.TransformationMode.SmoothTransformation))
self.new_password_label = QLabel(msgs[0])
grid.addWidget(self.new_password_label, 1, 0)
grid.addWidget(self.new_pw, 1, 1)
self.confirm_password_label = QLabel(msgs[1])
grid.addWidget(self.confirm_password_label, 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)
def enable_OK():
ok = self.new_pw.text() == self.conf_pw.text()
OK_button.setEnabled(ok)
self.new_pw.textChanged.connect(enable_OK)
self.conf_pw.textChanged.connect(enable_OK)
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") + ": " + "" + strength + "")
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
def clear_password_fields(self):
for field in [self.pw, self.new_pw, self.conf_pw]:
field.clear()
class PasswordLayoutForHW(PasswordLayout):
def __init__(self, msg, kind, OK_button, wallet=None):
PasswordLayout.__init__(self, msg, kind, OK_button, wallet=wallet)
self.encrypt_cb = QCheckBox(_('Encrypt wallet file using hardware wallet device'))
self.encrypt_cb.setToolTip(_('If you enable this setting, you will need your hardware device to open your wallet.'))
self.encrypt_cb.stateChanged.connect(self.on_encrypt_cb)
self.grid.addWidget(self.encrypt_cb, 4, 0, 1, 2)
self.encrypt_cb.setChecked(wallet.storage.is_encrypted_with_hw_device() if wallet else True)
def on_encrypt_cb(self, checked):
checked = bool(checked)
self.new_pw.setVisible(not checked)
self.conf_pw.setVisible(not checked)
self.new_password_label.setVisible(not checked)
self.confirm_password_label.setVisible(not checked)
def should_encrypt_storage_with_xpub(self):
return self.encrypt_cb.isChecked()
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))
def create_password_layout(self, wallet, is_encrypted, OK_button):
raise NotImplementedError()
class NewPasswordDialog(WindowModalDialog):
def __init__(self, parent, msg):
self.msg = msg
WindowModalDialog.__init__(self, parent)
OK_button = OkButton(self)
self.playout = PasswordLayout(
msg=self.msg,
kind=PW_CHANGE,
OK_button=OK_button,
wallet=None)
self.setWindowTitle(self.playout.title())
vbox = QVBoxLayout(self)
vbox.addLayout(self.playout.layout())
vbox.addStretch(1)
vbox.addLayout(Buttons(CancelButton(self), OK_button))
def run(self):
try:
if not self.exec():
return None
return self.playout.new_password()
finally:
self.playout.clear_password_fields()
class ChangePasswordDialogForSW(ChangePasswordDialogBase):
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(
msg=msg,
kind=PW_CHANGE,
OK_button=OK_button,
wallet=wallet)
def run(self):
try:
if not self.exec():
return False, None, None, None
return True, self.playout.old_password(), self.playout.new_password(), True
finally:
self.playout.clear_password_fields()
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:
if wallet.storage.is_encrypted_with_hw_device():
msg = _('Your wallet file is encrypted with your hardware device.')
else:
msg = _('Your wallet file is password-encrypted.')
self.playout = PasswordLayoutForHW(
msg=msg,
kind=PW_CHANGE,
OK_button=OK_button,
wallet=wallet)
def run(self):
if not self.exec():
return False, None, None, None
return True, self.playout.old_password(), self.playout.new_password(), self.playout.should_encrypt_storage_with_xpub()
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 = PasswordLineEdit()
label = QLabel(msg)
label.setWordWrap(True)
vbox = QVBoxLayout()
vbox.addWidget(label)
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):
try:
if not self.exec():
return
return self.pw.text()
finally:
self.pw.clear()
================================================
FILE: electrum/gui/qt/paytoedit.py
================================================
#!/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.
from functools import partial
from typing import Optional, TYPE_CHECKING, Union
from PyQt6.QtCore import Qt, QTimer, QSize, QStringListModel
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtGui import QFontMetrics, QFont, QContextMenuEvent
from PyQt6.QtWidgets import QTextEdit, QWidget, QLineEdit, QStackedLayout, QCompleter
from electrum.payment_identifier import PaymentIdentifier
from electrum.logging import Logger
from electrum.util import EventListener, event_listener
from . import util
from .util import MONOSPACE_FONT, GenericInputHandler, ColorScheme, add_input_actions_to_context_menu
if TYPE_CHECKING:
from .send_tab import SendTab
frozen_style = "QWidget {border:none;}"
normal_style = "QPlainTextEdit { }"
class InvalidPaymentIdentifier(Exception):
pass
class ResizingTextEdit(QTextEdit):
textReallyChanged = pyqtSignal()
resized = pyqtSignal()
def __init__(self):
QTextEdit.__init__(self)
self._text = ''
self.setAcceptRichText(False)
self.textChanged.connect(self.on_text_changed)
document = self.document()
fontMetrics = QFontMetrics(document.defaultFont())
self.fontSpacing = fontMetrics.lineSpacing()
margins = self.contentsMargins()
documentMargin = document.documentMargin()
self.verticalMargins = margins.top() + margins.bottom()
self.verticalMargins += self.frameWidth() * 2
self.verticalMargins += documentMargin * 2
self.heightMin = self.fontSpacing + self.verticalMargins
self.heightMax = (self.fontSpacing * 10) + self.verticalMargins
self.update_size()
def on_text_changed(self):
# QTextEdit emits spurious textChanged events
if self.toPlainText() != self._text:
self._text = self.toPlainText()
self.textReallyChanged.emit()
self.update_size()
def update_size(self):
docLineCount = self.document().lineCount()
docHeight = max(3, docLineCount) * self.fontSpacing
h = docHeight + self.verticalMargins
h = min(max(h, self.heightMin), self.heightMax)
self.setMinimumHeight(int(h))
self.setMaximumHeight(int(h))
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax)
self.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
self.resized.emit()
def sizeHint(self) -> QSize:
return QSize(0, self.minimumHeight())
class PayToEdit(QWidget, Logger, GenericInputHandler, EventListener):
paymentIdentifierChanged = pyqtSignal()
textChanged = pyqtSignal()
def __init__(self, send_tab: 'SendTab'):
QWidget.__init__(self, parent=send_tab)
Logger.__init__(self)
GenericInputHandler.__init__(self)
self._text = ''
self._layout = QStackedLayout()
self.setLayout(self._layout)
self.send_tab = send_tab
def text_edit_changed():
text = self.text_edit.toPlainText()
if self._text != text:
# sync and emit
self._text = text
self.line_edit.setText(text)
self.textChanged.emit()
def text_edit_resized():
self.update_height()
def line_edit_changed():
text = self.line_edit.text()
if self._text != text:
# sync and emit
self._text = text
self.text_edit.setPlainText(text)
self.textChanged.emit()
self.line_edit = QLineEdit()
self.line_edit.textChanged.connect(line_edit_changed)
self.text_edit = ResizingTextEdit()
self.text_edit.setTabChangesFocus(True)
self.text_edit.textReallyChanged.connect(text_edit_changed)
self.text_edit.resized.connect(text_edit_resized)
def on_completed(item: str):
text = self._completer_contacts[1][self._completer_contacts[0].index(item)]
self.try_payment_identifier(text)
self.completer.popup().hide()
self.completer = QCompleter()
self.completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.completer.setFilterMode(Qt.MatchFlag.MatchContains)
self.completer.activated.connect(on_completed)
self.update_completer()
self.line_edit.setCompleter(self.completer)
self.textChanged.connect(self._handle_text_change)
self._layout.addWidget(self.line_edit)
self._layout.addWidget(self.text_edit)
self.multiline = False
self._is_paytomany = False
self.line_edit.setFont(QFont(MONOSPACE_FONT))
self.text_edit.setFont(QFont(MONOSPACE_FONT))
self.send_tab = send_tab
self.config = send_tab.config
# button handlers
self.on_qr_from_camera_input_btn = partial(
self.input_qr_from_camera,
config=self.config,
allow_multi=False,
show_error=self.send_tab.show_error,
setText=self.try_payment_identifier,
parent=self.send_tab.window,
)
self.on_qr_from_screenshot_input_btn = partial(
self.input_qr_from_screenshot,
allow_multi=False,
show_error=self.send_tab.show_error,
setText=self.try_payment_identifier,
)
self.on_qr_from_file_input_btn = partial(
self.input_qr_from_file,
allow_multi=False,
config=self.config,
show_error=self.send_tab.show_error,
setText=self.try_payment_identifier,
)
self.on_input_file = partial(
self.input_file,
config=self.config,
show_error=self.send_tab.show_error,
setText=self.try_payment_identifier,
)
self.text_edit.contextMenuEvent = partial(self.custom_context_menu_event, tl_edit=self.text_edit)
self.line_edit.contextMenuEvent = partial(self.custom_context_menu_event, tl_edit=self.line_edit)
self.edit_timer = QTimer(self)
self.edit_timer.setSingleShot(True)
self.edit_timer.setInterval(1000)
self.edit_timer.timeout.connect(self._on_edit_timer)
self.payment_identifier = None # type: Optional[PaymentIdentifier]
self.register_callbacks()
self.destroyed.connect(lambda: self.unregister_callbacks())
def custom_context_menu_event(self, e: 'QContextMenuEvent', *, tl_edit: Union[QTextEdit, QLineEdit]) -> None:
m = tl_edit.createStandardContextMenu()
m.addSeparator()
add_input_actions_to_context_menu(self, m)
m.exec(e.globalPos())
@event_listener
def on_event_contacts_updated(self):
self.update_completer()
def update_completer(self):
self._completer_contacts = [], []
for k, v in self.send_tab.wallet.contacts.items():
self._completer_contacts[0].append(f'{v[1]} <{k}>')
self._completer_contacts[1].append(k)
self.completer.setModel(QStringListModel(self._completer_contacts[0]))
@property
def multiline(self):
return self._multiline
@multiline.setter
def multiline(self, b: bool) -> None:
if b is None:
return
self._multiline = b
self._layout.setCurrentWidget(self.text_edit if b else self.line_edit)
self.update_height()
def update_height(self) -> None:
h = self._layout.currentWidget().sizeHint().height()
self.setMaximumHeight(h)
def setText(self, text: str) -> None:
if self._text != text:
self.line_edit.setText(text)
self.text_edit.setText(text)
def setFocus(self, reason=Qt.FocusReason.OtherFocusReason) -> None:
if self.multiline:
self.text_edit.setFocus(reason)
else:
self.line_edit.setFocus(reason)
def setToolTip(self, tt: str) -> None:
self.line_edit.setToolTip(tt)
self.text_edit.setToolTip(tt)
def try_payment_identifier(self, text) -> None:
'''set payment identifier only if valid, else exception'''
pi = PaymentIdentifier(self.send_tab.wallet, text)
if not pi.is_valid():
raise InvalidPaymentIdentifier('Invalid payment identifier')
self.set_payment_identifier(text)
def set_payment_identifier(self, text) -> None:
if self.payment_identifier and self.payment_identifier.text == text.strip():
# no change.
return
self.payment_identifier = PaymentIdentifier(self.send_tab.wallet, text)
# toggle to multiline if payment identifier is a multiline
if self.payment_identifier.is_multiline() and not self._is_paytomany:
self.set_paytomany(True)
# if payment identifier gets set externally, we want to update the edit control
# Note: this triggers the change handler, but we shortcut if it's the same payment identifier
self.setText(text)
self.paymentIdentifierChanged.emit()
def set_paytomany(self, b):
self._is_paytomany = b
self.multiline = b
self.send_tab.paytomany_menu.setChecked(b)
def toggle_paytomany(self) -> None:
self.set_paytomany(not self._is_paytomany)
def is_paytomany(self):
return self._is_paytomany
def setReadOnly(self, b: bool) -> None:
self.line_edit.setReadOnly(b)
self.text_edit.setReadOnly(b)
def isReadOnly(self):
return self.line_edit.isReadOnly()
def setStyleSheet(self, stylesheet: str) -> None:
self.line_edit.setStyleSheet(stylesheet)
self.text_edit.setStyleSheet(stylesheet)
def setFrozen(self, b) -> None:
self.setReadOnly(b)
self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '')
def isFrozen(self):
return self.isReadOnly()
def do_clear(self) -> None:
self.set_paytomany(False)
self.setText('')
self.setToolTip('')
self.payment_identifier = None
def setGreen(self) -> None:
self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True))
def setExpired(self) -> None:
self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True))
def _handle_text_change(self) -> None:
if self.isFrozen():
# if editor is frozen, we ignore text changes as they might not be a payment identifier
# but a user friendly representation.
return
# pushback timer if timer active or PI needs resolving
pi = PaymentIdentifier(self.send_tab.wallet, self._text)
if not pi.is_valid() or pi.need_resolve() or self.edit_timer.isActive():
self.edit_timer.start()
else:
self.set_payment_identifier(self._text)
def _on_edit_timer(self) -> None:
if not self.isFrozen():
self.set_payment_identifier(self._text)
================================================
FILE: electrum/gui/qt/plugins_dialog.py
================================================
from typing import TYPE_CHECKING, Optional
from functools import partial
import shutil
import os
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QHBoxLayout, QGridLayout, QPushButton, QWidget, QScrollArea, \
QFormLayout, QFileDialog, QMenu, QApplication, QMessageBox
from PyQt6.QtCore import QTimer
from electrum.i18n import _
from electrum.gui import messages
from electrum.logging import get_logger
from .util import (WindowModalDialog, Buttons, CloseButton, WWLabel, insert_spaces, MessageBoxMixin,
EnterButton, read_QIcon_from_bytes, IconLabel, RunCoroutineDialog, read_QIcon,
webopen)
if TYPE_CHECKING:
from . import ElectrumGui
from electrum_ecc import ECPrivkey
from electrum.simple_config import SimpleConfig
from electrum.plugin import Plugins
class PluginDialog(WindowModalDialog):
def __init__(self, name, metadata, status_button: Optional['PluginStatusButton'], window: 'PluginsDialog'):
display_name = metadata.get('fullname', '')
author = metadata.get('author', '')
description = metadata.get('description', '')
requires = metadata.get('requires')
version = metadata.get('version')
zip_hash = metadata.get('zip_hash_sha256', None)
icon_path = metadata.get('icon')
WindowModalDialog.__init__(self, window, 'Plugin')
self.setMinimumSize(400, 250)
self.window = window
self.metadata = metadata
self.plugins = self.window.plugins
self.name = name
self.status_button = status_button
p = self.plugins.get(name) # is enabled
vbox = QVBoxLayout(self)
name_label = IconLabel(text=display_name, reverse=True)
if icon_path:
name_label.icon_size = 64
icon = read_QIcon_from_bytes(self.plugins.read_file(name, icon_path))
name_label.setIcon(icon)
vbox.addWidget(name_label)
vbox.addStretch()
vbox.addWidget(WWLabel(description))
vbox.addStretch()
form = QFormLayout(None)
if author:
form.addRow(QLabel(_('Author') + ':'), QLabel(author))
if version:
form.addRow(QLabel(_('Version') + ':'), QLabel(version))
if zip_hash:
form.addRow(QLabel('Hash [sha256]:'), WWLabel(insert_spaces(zip_hash, 8)))
if requires:
msg = '\n'.join(map(lambda x: x[1], requires))
form.addRow(QLabel(_('Requires') + ':'), WWLabel(msg))
vbox.addLayout(form)
vbox.addStretch()
close_button = CloseButton(self)
close_button.setText(_('Close'))
buttons = [close_button]
p = self.plugins.get(name)
is_enabled = p and p.is_enabled()
is_external = self.plugins.is_external(name)
if is_external:
is_authorized = self.plugins.is_authorized(name)
if status_button is not None:
# status_button is None when called from add_external_plugin
remove_button = QPushButton('')
remove_button.clicked.connect(self.do_remove)
remove_button.setText(_('Remove'))
buttons.insert(0, remove_button)
if not is_authorized:
auth_button = QPushButton('Install')
auth_button.clicked.connect(self.do_authorize)
buttons.insert(0, auth_button)
else:
toggle_button = QPushButton('')
toggle_button.setText(_('Disable') if is_enabled else _('Enable'))
toggle_button.clicked.connect(self.do_toggle)
buttons.insert(0, toggle_button)
# add settings button
if p and p.requires_settings() and p.is_enabled():
settings_button = EnterButton(
_('Settings'),
partial(p.settings_dialog, self))
buttons.insert(1, settings_button)
# add buttons
vbox.addLayout(Buttons(*buttons))
def do_toggle(self):
if not self.plugins.is_available(self.name):
msg = "\n".join([
_('This plugin requires installation of additional dependencies.'),
_('For Electrum to recognize external packages, you need to run it from source.')
])
self.window.show_message(msg)
return
self.close()
self.window.do_toggle(self.name, self.status_button)
def do_remove(self):
self.window.uninstall_plugin(self.name)
self.close()
def do_authorize(self):
assert not self.plugins.is_authorized(self.name)
privkey = self.window.get_plugins_privkey()
if not privkey:
return
filename = self.plugins.zip_plugin_path(self.name)
self.window.plugins.authorize_plugin(self.name, filename, privkey)
self.window.plugins.enable(self.name)
d = self.plugins.get_metadata(self.name)
if details := d.get('registers_keystore'):
self.plugins.register_keystore(self.name, details)
if self.status_button:
self.status_button.update()
self.accept()
class PluginStatusButton(QPushButton):
def __init__(self, window: 'PluginsDialog', name: str):
QPushButton.__init__(self, '')
self.window = window
self.plugins = window.plugins
self.name = name
self.clicked.connect(self.show_plugin_dialog)
self.update()
def show_plugin_dialog(self):
metadata = self.plugins.descriptions[self.name]
d = PluginDialog(self.name, metadata, self, self.window)
d.exec()
def update(self):
from .util import ColorScheme
p = self.plugins.get(self.name)
plugin_is_loaded = p is not None
enabled = not plugin_is_loaded or (plugin_is_loaded and p.can_user_disable())
self.setEnabled(enabled)
if p is not None and p.is_enabled():
text, color = _('Enabled'), ColorScheme.BLUE
else:
text, color = _('Disabled'), ColorScheme.RED
self.setStyleSheet(color.as_stylesheet())
self.setText(text)
class PluginsDialog(WindowModalDialog, MessageBoxMixin):
_logger = get_logger(__name__)
def __init__(self, config: 'SimpleConfig', plugins: 'Plugins', *, gui_object: Optional['ElectrumGui'] = None):
WindowModalDialog.__init__(self, None, _('Electrum Plugins'))
self.gui_object = gui_object
self.config = config
self.plugins = plugins
vbox = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setEnabled(True)
scroll.setWidgetResizable(True)
scroll.setMinimumSize(400, 250)
scroll_w = QWidget()
scroll.setWidget(scroll_w)
self.grid = QGridLayout()
self.grid.setColumnStretch(0, 1)
scroll_w.setLayout(self.grid)
vbox.addWidget(scroll)
add_button = QPushButton(_('Add'))
add_button.setMinimumWidth(40) # looks better on windows, no difference on linux
add_button.clicked.connect(self.add_plugin_dialog)
website_button = QPushButton(read_QIcon('globe.png'), _('Help'))
website_button.setToolTip(_('Visit plugins website'))
website_button.clicked.connect(lambda: webopen('https://plugins.electrum.org/'))
hbox = QHBoxLayout()
hbox.addWidget(website_button)
hbox.addStretch(1)
hbox.addWidget(add_button)
hbox.addWidget(CloseButton(self))
vbox.addLayout(hbox)
self.show_list()
def get_plugins_privkey(self) -> Optional['ECPrivkey']:
pubkey, salt = self.plugins.get_pubkey_bytes()
if not pubkey:
self.init_plugins_password()
return None
# ask for url and password, same window
pw = self.password_dialog(msg=messages.MSG_THIRD_PARTY_PLUGIN_WARNING)
if not pw:
return None
privkey = self.plugins.derive_privkey(pw, salt)
if pubkey != privkey.get_public_key_bytes():
keyfile_path, _keyfile_help = self.plugins.get_keyfile_path(None)
while True:
exit_dialog = True
auto_reset_btn = QPushButton(_('Try Auto-Reset'))
def on_try_auto_reset_clicked():
nonlocal exit_dialog
if not self.plugins.try_auto_key_reset():
self.show_error(_("Auto-Reset not possible. Delete the file manually."))
exit_dialog = False
else:
self.show_message(_("Auto-Reset successful. You can now setup a new password."))
auto_reset_btn.clicked.connect(on_try_auto_reset_clicked)
buttons = [
QMessageBox.StandardButton.Ok,
(auto_reset_btn, QMessageBox.ButtonRole.ActionRole, 0),
]
if self.show_error(
''.join([
_('Incorrect password.'), '\n\n',
_('Your plugin authorization password is required to install plugins.'), ' ',
_('If you need to reset it, remove the following file:'), '\n\n',
keyfile_path
]),
buttons=buttons
) or exit_dialog:
break
return None
return privkey
def init_plugins_password(self):
from .password_dialog import NewPasswordDialog
msg = ' '.join([
_('In order to install third-party plugins, you need to choose a plugin authorization password.'),
_('Its purpose is to prevent unauthorized users (or malware) from installing plugins.'),
])
d = NewPasswordDialog(self, msg=msg)
pw = d.run()
if not pw:
return
key_hex = self.plugins.create_new_key(pw)
keyfile_path, keyfile_help = self.plugins.get_keyfile_path(key_hex)
msg = '\n\n'.join([
_('Your plugins key is:'), key_hex,
_('This key has been copied to your clipboard. Please save it in:'),
keyfile_path,
keyfile_help,
'',
])
clipboard = QApplication.clipboard()
clipboard.setText(key_hex)
while True:
exit_dialog = True
# the button has to be recreated inside the loop, as qt destroys it when the dialog is closed
auto_setup_btn = QPushButton(_('Try Auto-Setup'))
def on_auto_setup_clicked():
nonlocal exit_dialog
if not self.plugins.try_auto_key_setup(key_hex):
self.show_error(_("Auto-Setup not possible. Try the manual setup."))
exit_dialog = False
else:
self.show_message(_("Auto-Setup successful. You can now install plugins."))
auto_setup_btn.clicked.connect(on_auto_setup_clicked)
# on windows, the auto-setup button is shown right of the ok button,
# apparently due to OS conventions
buttons = [
(auto_setup_btn, QMessageBox.ButtonRole.ActionRole, 0),
QMessageBox.StandardButton.Ok,
]
if self.show_message(msg, buttons=buttons) or exit_dialog:
break
def add_plugin_dialog(self):
pubkey, salt = self.plugins.get_pubkey_bytes()
if not pubkey:
self.init_plugins_password()
return
filename, __ = QFileDialog.getOpenFileName(self, _("Select your plugin zipfile"), "", "*.zip")
if not filename:
return
plugins_dir = self.plugins.get_external_plugin_dir()
path = os.path.join(plugins_dir, os.path.basename(filename))
if os.path.exists(path):
self.show_warning(_('Plugin already installed.'))
return
try:
shutil.copyfile(filename, path)
except OSError as e:
self.show_error(_("Could not copy plugin file {} into directory {}:\n\n{}").format(
filename,
path,
str(e)
))
return
self._try_add_external_plugin_from_path(path)
def _try_add_external_plugin_from_path(self, path: str):
try:
success = self.add_external_plugin(path)
except Exception as e:
self._logger.exception("")
self.show_error(f"{e}")
success = False
if not success:
try:
os.unlink(path)
except FileNotFoundError:
self._logger.debug("", exc_info=True)
def add_external_plugin(self, path):
manifest = self.plugins.read_manifest(path)
name = manifest['name']
self.plugins.external_plugin_metadata[name] = manifest
d = PluginDialog(name, manifest, None, self)
if not d.exec():
self.plugins.external_plugin_metadata.pop(name)
return False
if self.gui_object:
self.gui_object.reload_windows()
self.show_list()
return True
def show_list(self):
descriptions = self.plugins.descriptions
descriptions = sorted(descriptions.items())
grid = self.grid
# clear existing items
for i in reversed(range(grid.count())):
grid.itemAt(i).widget().setParent(None)
# populate
i = 0
for name, metadata in descriptions:
i += 1
if self.plugins.is_internal(name) and self.plugins.is_auto_loaded(name):
continue
display_name = metadata.get('fullname')
if not display_name:
continue
label = IconLabel(text=display_name, reverse=True)
icon_path = metadata.get('icon')
if icon_path:
icon = read_QIcon_from_bytes(self.plugins.read_file(name, icon_path))
label.setIcon(icon)
label.status_button = PluginStatusButton(self, name)
grid.addWidget(label, i, 0)
grid.addWidget(label.status_button, i, 1)
# add stretch
grid.setRowStretch(i + 1, 1)
def do_toggle(self, name, status_button):
p = self.plugins.get(name)
is_enabled = p and p.is_enabled()
if is_enabled:
self.plugins.disable(name)
else:
self.plugins.enable(name)
if status_button:
status_button.update()
if self.gui_object:
self.gui_object.reload_windows()
self.bring_to_front()
def uninstall_plugin(self, name):
if not self.question(_('Remove plugin \'{}\'?').format(name)):
return
self.plugins.uninstall(name)
if self.gui_object:
self.gui_object.reload_windows()
self.show_list()
self.bring_to_front()
def bring_to_front(self):
def _bring_self_to_front():
self.activateWindow()
self.setFocus()
QTimer.singleShot(100, _bring_self_to_front)
================================================
FILE: electrum/gui/qt/qrcodewidget.py
================================================
from typing import Optional
import qrcode
import qrcode.exceptions
import PyQt6.QtGui as QtGui
from PyQt6.QtCore import QRect
from PyQt6.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, QPushButton, QWidget
from electrum.i18n import _
from electrum.simple_config import SimpleConfig
from electrum.gui.common_qt.util import draw_qr
from .util import WindowModalDialog, WWLabel, getSaveFileName
class QrCodeDataOverflow(qrcode.exceptions.DataOverflowError):
pass
class QRCodeWidget(QWidget):
MIN_BOXSIZE = 2 # min size in pixels of single black/white unit box of the qr code
def __init__(self, data=None, *, manual_size: bool = False):
QWidget.__init__(self)
self.data = None
self.qr = None
self._framesize = None # type: Optional[int]
self._manual_size = manual_size
self.setData(data)
def setData(self, data):
if data:
qr = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_L,
border=1,
)
try:
qr.add_data(data)
qr_matrix = qr.get_matrix() # test that data fits in QR code
except (ValueError, qrcode.exceptions.DataOverflowError) as e:
raise QrCodeDataOverflow() from e
self.qr = qr
self.data = data
if not self._manual_size:
k = len(qr_matrix)
size = min(k * 5, 150 + k * self.MIN_BOXSIZE)
self.setMinimumSize(size, size)
else:
self.qr = None
self.data = None
self.update()
def paintEvent(self, e):
if not self.data:
return
draw_qr(
qr=self.qr,
paint_device=self,
is_enabled=self.isEnabled(),
min_boxsize=self.MIN_BOXSIZE,
)
def grab(self) -> QtGui.QPixmap:
"""Overrides QWidget.grab to only include the QR code itself,
excluding horizontal/vertical stretch.
"""
fsize = self._framesize
if fsize is None:
fsize = -1
rect = QRect(0, 0, fsize, fsize)
return QWidget.grab(self, rect)
class QRDialog(WindowModalDialog):
def __init__(
self,
*,
data,
parent=None,
title="",
show_text=False,
help_text=None,
show_copy_text_btn=False,
config: SimpleConfig,
):
WindowModalDialog.__init__(self, parent, title)
self.config = config
vbox = QVBoxLayout()
qrw = QRCodeWidget(data, manual_size=False)
vbox.addWidget(qrw, 1)
help_text = data if show_text else help_text
if help_text:
text_label = WWLabel()
text_label.setText(help_text)
vbox.addWidget(text_label)
hbox = QHBoxLayout()
hbox.addStretch(1)
def print_qr():
filename = getSaveFileName(
parent=self,
title=_("Select where to save file"),
filename="qrcode.png",
config=self.config,
)
if not filename:
return
p = qrw.grab()
p.save(filename, 'png')
self.show_message(_("QR code saved to file") + " " + filename)
def copy_image_to_clipboard():
p = qrw.grab()
QApplication.clipboard().setPixmap(p)
self.show_message(_("QR code copied to clipboard"))
def copy_text_to_clipboard():
QApplication.clipboard().setText(data)
self.show_message(_("Text copied to clipboard"))
b = QPushButton(_("Copy Image"))
hbox.addWidget(b)
b.clicked.connect(copy_image_to_clipboard)
if show_copy_text_btn:
b = QPushButton(_("Copy Text"))
hbox.addWidget(b)
b.clicked.connect(copy_text_to_clipboard)
b = QPushButton(_("Save"))
hbox.addWidget(b)
b.clicked.connect(print_qr)
b = QPushButton(_("Close"))
hbox.addWidget(b)
b.clicked.connect(self.accept)
b.setDefault(True)
vbox.addLayout(hbox)
self.setLayout(vbox)
# note: the word-wrap on the text_label is causing layout sizing issues.
# see https://stackoverflow.com/a/25661985 and https://bugreports.qt.io/browse/QTBUG-37673
# workaround:
self.setMinimumSize(self.sizeHint())
================================================
FILE: electrum/gui/qt/qrreader/__init__.py
================================================
# Copyright (C) 2021 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
#
# We have two toolchains to scan qr codes:
# 1. access camera via QtMultimedia, take picture, feed picture to zbar
# 2. let zbar handle whole flow (including accessing the camera)
#
# notes:
# - zbar needs to be compiled with platform-dependent extra config options to be able
# to access the camera
# - zbar fails to access the camera on macOS
# - qtmultimedia seems to support more cameras on Windows than zbar
# - qtmultimedia is often not packaged with PyQt
# in particular, on debian, you need both "python3-pyqt6" and "python3-pyqt6.qtmultimedia"
# - older versions of qtmultimedia don't seem to work reliably
#
# Considering the above, we use QtMultimedia for Windows and macOS, as there
# most users run our binaries where we can make sure the packaged versions work well.
# On Linux where many people run from source, we use zbar.
#
# Note: this module is safe to import on all platforms.
import sys
from typing import Callable, Optional, TYPE_CHECKING, Mapping, Sequence
from PyQt6.QtWidgets import QMessageBox, QWidget
from PyQt6.QtGui import QImage, QPainter, QColor
from PyQt6.QtCore import QRect, QCoreApplication
from PyQt6 import QtCore
from electrum.i18n import _
from electrum.util import UserFacingException
from electrum.logging import get_logger
from electrum.qrreader import get_qr_reader, QrCodeResult, MissingQrDetectionLib
from electrum.gui.qt.util import MessageBoxMixin, custom_message_box
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
_logger = get_logger(__name__)
def scan_qrcode_from_camera(
*,
parent: Optional[QWidget],
config: 'SimpleConfig',
callback: Callable[[bool, str, Optional[str]], None],
) -> None:
"""Scans QR code using camera. It handles requesting camera access permission from the OS if needed."""
assert parent is None or isinstance(parent, QWidget), f"parent should be a QWidget, not {parent!r}"
def do_scan():
_scan_qrcode_from_camera(parent=parent, config=config, callback=callback)
if _has_camera_permission():
do_scan()
else:
# Request permission now. This is only a thing on macOS atm.
# Note: this assumes we are running on the main thread. Permissions can only be requested from the main thread.
app = QCoreApplication.instance()
app.requestPermission(QtCore.QCameraPermission(), lambda _x: do_scan())
def scan_qr_from_image(image: QImage) -> Sequence[QrCodeResult]:
"""Might raise exception: MissingQrDetectionLib."""
qr_reader = get_qr_reader()
for attempt in range(4):
image_y800 = image.convertToFormat(QImage.Format.Format_Grayscale8)
res = qr_reader.read_qr_code(
image_y800.constBits().__int__(),
image_y800.sizeInBytes(),
image_y800.bytesPerLine(),
image_y800.width(),
image_y800.height(),
)
if res:
break
# zbar doesn't like qr codes that are too large in relation to the whole image
image = _reduce_qr_code_density(image)
return res
def _reduce_qr_code_density(image: QImage) -> QImage:
""" Reduces the size of the qr code relative to the whole image. """
new_image = QImage(image.width(), image.height(), QImage.Format.Format_RGB32)
new_image.fill(QColor(255, 255, 255)) # Fill white
painter = QPainter(new_image)
source_rect = QRect(0, 0, image.width(), image.height())
target_rect = QRect(0, 0, int(image.width() * 0.75), int(image.height() * 0.75))
painter.drawImage(target_rect, image, source_rect)
painter.end()
return new_image
def find_system_cameras() -> Mapping[str, str]:
"""Returns a camera_description -> camera_path map."""
if sys.platform == 'darwin' or sys.platform in ('windows', 'win32'):
try:
from .qtmultimedia import find_system_cameras
except (ImportError, RuntimeError) as e:
_logger.exception('error importing .qtmultimedia')
return {}
else:
return find_system_cameras()
else: # desktop Linux and similar
from electrum import qrscanner
return qrscanner.find_system_cameras()
# --- Internals below (not part of external API)
def _scan_qrcode_using_zbar(
*,
parent: Optional[QWidget],
config: 'SimpleConfig',
callback: Callable[[bool, str, Optional[str]], None],
) -> None:
from electrum import qrscanner
data = None
try:
data = qrscanner.scan_barcode(config.get_video_device())
except UserFacingException as e:
success = False
error = str(e)
except BaseException as e:
_logger.exception('camera error')
success = False
error = repr(e)
else:
success = True
error = ""
if data is None:
# probably user cancelled
success = False
callback(success, error, data)
# Use a global to prevent multiple QR dialogs created simultaneously
_qr_dialog = None
def _scan_qrcode_using_qtmultimedia(
*,
parent: Optional[QWidget],
config: 'SimpleConfig',
callback: Callable[[bool, str, Optional[str]], None],
) -> None:
try:
from .qtmultimedia import QrReaderCameraDialog, CameraError
except (ImportError, RuntimeError) as e:
icon = QMessageBox.Icon.Warning
title = _("QR Reader Error")
message = _("QR reader failed to load. This may happen if "
"you are using an older version of PyQt.") + "\n\n" + str(e)
_logger.exception(message)
if isinstance(parent, MessageBoxMixin):
parent.msg_box(title=title, text=message, icon=icon, parent=None)
else:
custom_message_box(title=title, text=message, icon=icon, parent=parent)
return
global _qr_dialog
if _qr_dialog:
_logger.warning("QR dialog is already presented, ignoring.")
return
_qr_dialog = None
try:
_qr_dialog = QrReaderCameraDialog(parent=parent, config=config)
def _on_qr_reader_finished(success: bool, error: str, data):
global _qr_dialog
if _qr_dialog:
_qr_dialog.deleteLater()
_qr_dialog = None
callback(success, error, data)
_qr_dialog.qr_finished.connect(_on_qr_reader_finished)
_qr_dialog.start_scan(config.get_video_device())
except (MissingQrDetectionLib, CameraError) as e:
_qr_dialog = None
callback(False, str(e), None)
except Exception as e:
_logger.exception('camera error')
_qr_dialog = None
callback(False, repr(e), None)
def _scan_qrcode_from_camera(
*,
parent: Optional[QWidget],
config: 'SimpleConfig',
callback: Callable[[bool, str, Optional[str]], None],
) -> None:
"""Scans QR code using camera."""
assert parent is None or isinstance(parent, QWidget), f"parent should be a QWidget, not {parent!r}"
if not _has_camera_permission():
callback(False, _("Missing camera permission."), None)
return
if sys.platform == 'darwin' or sys.platform in ('windows', 'win32'):
_scan_qrcode_using_qtmultimedia(parent=parent, config=config, callback=callback)
else: # desktop Linux and similar
_scan_qrcode_using_zbar(parent=parent, config=config, callback=callback)
def _has_camera_permission() -> bool:
if not hasattr(QtCore, "QCameraPermission"): # requires Qt 6.5+
_logger.info(f"QtCore does not support QCameraPermission. This requires Qt 6.5+")
return True # hope for the best
app = QCoreApplication.instance()
permission_status = app.checkPermission(QtCore.QCameraPermission())
return permission_status == QtCore.Qt.PermissionStatus.Granted
================================================
FILE: electrum/gui/qt/qrreader/qtmultimedia/__init__.py
================================================
#!/usr/bin/env python3
#
# Copyright (C) 2019 Axel Gembe
# Copyright (c) 2024 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.
#
# -----
#
# Note: This module is risky to import. At the very least, ImportError and
# RuntimeError needs to be handled at import time!
from typing import Mapping
from .camera_dialog import (QrReaderCameraDialog, CameraError, NoCamerasFound,
get_camera_path)
from .validator import (QrReaderValidatorResult, AbstractQrReaderValidator,
QrReaderValidatorCounting, QrReaderValidatorColorizing,
QrReaderValidatorStrong, QrReaderValidatorCounted)
def find_system_cameras() -> Mapping[str, str]:
"""Returns a camera_description -> camera_path map."""
from PyQt6.QtMultimedia import QMediaDevices
system_cameras = QMediaDevices.videoInputs()
return {cam.description(): get_camera_path(cam) for cam in system_cameras}
================================================
FILE: electrum/gui/qt/qrreader/qtmultimedia/camera_dialog.py
================================================
#!/usr/bin/env python3
#
# Copyright (C) 2019 Axel Gembe
# Copyright (c) 2024 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.
import time
import math
import sys
import os
from typing import List, Optional
from PyQt6.QtMultimedia import QMediaDevices, QCamera, QMediaCaptureSession, QCameraDevice
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QCheckBox, QPushButton, QLabel, QWidget
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtCore import QSize, QRect, Qt, pyqtSignal, PYQT_VERSION
from electrum.simple_config import SimpleConfig
from electrum.i18n import _
from electrum.qrreader import get_qr_reader, QrCodeResult, MissingQrDetectionLib
from electrum.logging import Logger
from electrum.gui.qt.util import MessageBoxMixin, FixedAspectRatioLayout, ImageGraphicsEffect
from .video_widget import QrReaderVideoWidget
from .video_overlay import QrReaderVideoOverlay
from .video_surface import QrReaderVideoSurface
from .crop_blur_effect import QrReaderCropBlurEffect
from .validator import AbstractQrReaderValidator, QrReaderValidatorCounted, QrReaderValidatorResult
class CameraError(RuntimeError):
''' Base class of the camera-related error conditions. '''
class NoCamerasFound(CameraError):
''' Raised by start_scan if no usable cameras were found. Interested
code can catch this specific exception.'''
def get_camera_path(cam: 'QCameraDevice') -> str:
return bytes(cam.id()).decode('ascii')
class QrReaderCameraDialog(Logger, MessageBoxMixin, QDialog):
"""
Dialog for reading QR codes from a camera
"""
# Try to crop so we have minimum 512 dimensions
SCAN_SIZE: int = 512
qr_finished = pyqtSignal(bool, str, object)
def __init__(self, parent: Optional[QWidget], *, config: SimpleConfig):
''' Note: make sure parent is a "top_level_window()" as per
MessageBoxMixin API else bad things can happen on macOS. '''
QDialog.__init__(self, parent=parent)
Logger.__init__(self)
self.validator: AbstractQrReaderValidator = None
self.frame_id: int = 0
self.qr_crop: QRect = None
self.qrreader_res: List[QrCodeResult] = []
self.validator_res: QrReaderValidatorResult = None
self.last_stats_time: float = 0.0
self.frame_counter: int = 0
self.qr_frame_counter: int = 0
self.last_qr_scan_ts: float = 0.0
self.camera: QCamera = None
self.media_capture_session: QMediaCaptureSession = None
self._error_message: str = None
self._ok_done: bool = False
self.camera_sc_conn = None
self.resolution: QSize = None
self.config = config
# Try to get the QR reader for this system
self.qrreader = get_qr_reader()
# Set up the window, add the maximize button
flags = self.windowFlags()
flags = flags | Qt.WindowType.WindowMaximizeButtonHint
self.setWindowFlags(flags)
self.setWindowTitle(_("Scan QR Code"))
self.setWindowModality(Qt.WindowModality.WindowModal if parent else Qt.WindowModality.ApplicationModal)
# Create video widget and fixed aspect ratio layout to contain it
self.video_widget = QrReaderVideoWidget()
self.video_overlay = QrReaderVideoOverlay()
self.video_layout = FixedAspectRatioLayout()
self.video_layout.addWidget(self.video_widget)
self.video_layout.addWidget(self.video_overlay)
# Create root layout and add the video widget layout to it
vbox = QVBoxLayout()
self.setLayout(vbox)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addLayout(self.video_layout)
# Create a layout for the controls
controls_layout = QHBoxLayout()
controls_layout.addStretch(2)
controls_layout.setContentsMargins(10, 10, 10, 10)
controls_layout.setSpacing(10)
vbox.addLayout(controls_layout)
# Flip horizontally checkbox with default coming from global config
self.flip_x = QCheckBox()
self.flip_x.setText(_("&Flip horizontally"))
self.flip_x.setChecked(self.config.QR_READER_FLIP_X)
self.flip_x.stateChanged.connect(self._on_flip_x_changed)
controls_layout.addWidget(self.flip_x)
close_but = QPushButton(_("&Close"))
close_but.clicked.connect(self.reject)
controls_layout.addWidget(close_but)
# Create the video surface and receive events when new frames arrive
self.video_surface = QrReaderVideoSurface(self)
self.video_surface.frame_available.connect(self._on_frame_available)
# Create the crop blur effect
self.crop_blur_effect = QrReaderCropBlurEffect(self)
self.image_effect = ImageGraphicsEffect(self, self.crop_blur_effect)
# Note these should stay as queued connections because we use the idiom
# self.reject() and self.accept() in this class to kill the scan --
# and we do it from within callback functions. If you don't use
# queued connections here, bad things can happen.
self.finished.connect(self._boilerplate_cleanup, Qt.ConnectionType.QueuedConnection)
self.finished.connect(self._on_finished, Qt.ConnectionType.QueuedConnection)
def _on_flip_x_changed(self, _state: int):
self.config.QR_READER_FLIP_X = self.flip_x.isChecked()
@staticmethod
def _get_crop(resolution: QSize, scan_size: int) -> QRect:
"""
Returns a QRect that is scan_size x scan_size in the middle of the resolution
"""
scan_pos_x = (resolution.width() - scan_size) // 2
scan_pos_y = (resolution.height() - scan_size) // 2
return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size)
def start_scan(self, device: str = ''):
"""
Scans a QR code from the given camera device.
If no QR code is found the returned string will be empty.
If the camera is not found or can't be opened NoCamerasFound will be raised.
"""
self.validator = QrReaderValidatorCounted()
device_info = None
for camera in QMediaDevices.videoInputs():
if get_camera_path(camera) == device:
device_info = camera
break
if not device_info:
self.logger.info('Failed to open selected camera, trying to use default camera')
device_info = QMediaDevices.defaultVideoInput()
if not device_info or device_info.isNull():
raise NoCamerasFound(_("Cannot start QR scanner, no usable camera found."))
self._init_stats()
self.qrreader_res = []
self.validator_res = None
self._ok_done = False
self._error_message = None
if self.camera:
self.logger.info("Warning: start_scan already called for this instance.")
self.camera = QCamera(device_info)
self.camera.start()
self.camera.errorOccurred.connect(self._on_camera_error) # log the errors we get, if any, for debugging
self.media_capture_session = QMediaCaptureSession()
self.media_capture_session.setCamera(self.camera)
self.media_capture_session.setVideoSink(self.video_surface)
self.open()
def _set_resolution(self, resolution: QSize):
self.resolution = resolution
self.qr_crop = self._get_crop(resolution, self.SCAN_SIZE)
# Initialize the video widget
#self.video_widget.setMinimumSize(resolution) # <-- on macOS this makes it fixed size for some reason.
self.resize(720, 540)
self.video_overlay.set_crop(self.qr_crop)
self.video_overlay.set_resolution(resolution)
self.video_layout.set_aspect_ratio(resolution.width() / resolution.height())
# Set up the crop blur effect
self.crop_blur_effect.setCrop(self.qr_crop)
def _on_camera_error(self, error: QCamera.Error, error_str: str):
self.logger.info(f"QCamera error: {error}. {error_str}")
def accept(self):
self._ok_done = True # immediately blocks further processing
super().accept()
def reject(self):
self._ok_done = True # immediately blocks further processing
super().reject()
def _boilerplate_cleanup(self):
self._close_camera()
if self.isVisible():
self.close()
def _close_camera(self):
if self.camera:
self.camera.stop()
self.camera = None
def _on_finished(self, code):
res = ( (code == QDialog.DialogCode.Accepted
and self.validator_res and self.validator_res.accepted
and self.validator_res.simple_result)
or '' )
self.validator = None
self.logger.info(f'closed {res}')
self.qr_finished.emit(code == QDialog.DialogCode.Accepted, self._error_message, res)
def _on_frame_available(self, frame: QImage):
if self._ok_done:
return
self.frame_id += 1
self._set_resolution(frame.size())
flip_x = self.flip_x.isChecked()
# Only QR scan every QR_SCAN_PERIOD secs
qr_scanned = time.time() - self.last_qr_scan_ts >= self.qrreader.interval()
if qr_scanned:
self.last_qr_scan_ts = time.time()
# Crop the frame so we only scan a SCAN_SIZE rect
frame_cropped = frame.copy(self.qr_crop)
# Convert to Y800 / GREY FourCC (single 8-bit channel)
# This creates a copy, so we don't need to keep the frame around anymore
frame_y800 = frame_cropped.convertToFormat(QImage.Format.Format_Grayscale8)
# Read the QR codes from the frame
self.qrreader_res = self.qrreader.read_qr_code(
frame_y800.constBits().__int__(),
frame_y800.sizeInBytes(),
frame_y800.bytesPerLine(),
frame_y800.width(),
frame_y800.height(),
self.frame_id,
)
# Call the validator to see if the scanned results are acceptable
self.validator_res = self.validator.validate_results(self.qrreader_res)
# Update the video overlay with the results
self.video_overlay.set_results(self.qrreader_res, flip_x, self.validator_res)
# Close the dialog if the validator accepted the result
if self.validator_res.accepted:
self.accept()
return
# Apply the crop blur effect
if self.image_effect:
frame = self.image_effect.apply(frame)
# If horizontal flipping is enabled, only flip the display
if flip_x:
frame = frame.mirrored(True, False)
# Display the frame in the widget
self.video_widget.setPixmap(QPixmap.fromImage(frame))
self._update_stats(qr_scanned)
def _init_stats(self):
self.last_stats_time = time.perf_counter()
self.frame_counter = 0
self.qr_frame_counter = 0
def _update_stats(self, qr_scanned):
self.frame_counter += 1
if qr_scanned:
self.qr_frame_counter += 1
now = time.perf_counter()
last_stats_delta = now - self.last_stats_time
if last_stats_delta > 1.0: # stats every 1.0 seconds
fps = self.frame_counter / last_stats_delta
qr_fps = self.qr_frame_counter / last_stats_delta
#if self.validator is not None:
# self.validator.strong_count = math.ceil(qr_fps / 3) # 1/3 of a second's worth of qr frames determines strong_count
stats_format = 'running at {} FPS, scanner at {} FPS'
self.logger.info(stats_format.format(fps, qr_fps))
self.frame_counter = 0
self.qr_frame_counter = 0
self.last_stats_time = now
================================================
FILE: electrum/gui/qt/qrreader/qtmultimedia/crop_blur_effect.py
================================================
#!/usr/bin/env python3
#
# Electron Cash - lightweight Bitcoin client
# Copyright (C) 2019 Axel Gembe
#
# 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 PyQt6.QtWidgets import QGraphicsBlurEffect, QGraphicsEffect
from PyQt6.QtGui import QPainter, QTransform, QRegion
from PyQt6.QtCore import QObject, QRect, QPoint, Qt
class QrReaderCropBlurEffect(QGraphicsBlurEffect):
CROP_OFFSET_ENABLED = False
CROP_OFFSET = QPoint(5, 5)
BLUR_DARKEN = 0.25
BLUR_RADIUS = 8
def __init__(self, parent: QObject, crop: QRect = None):
super().__init__(parent)
self.crop = crop
self.setBlurRadius(self.BLUR_RADIUS)
def setCrop(self, crop: QRect = None):
self.crop = crop
def draw(self, painter: QPainter):
assert self.crop, 'crop must be set'
# Compute painter regions for the crop and the blur
all_region = QRegion(painter.viewport())
crop_region = QRegion(self.crop)
blur_region = all_region.subtracted(crop_region)
# Let the QGraphicsBlurEffect only paint in blur_region
painter.setClipRegion(blur_region)
# Fill with black and set opacity so that the blurred region is drawn darker
if self.BLUR_DARKEN > 0.0:
painter.fillRect(painter.viewport(), Qt.GlobalColor.black)
painter.setOpacity(1 - self.BLUR_DARKEN)
# Draw the blur effect
super().draw(painter)
# Restore clipping and opacity
painter.setClipping(False)
painter.setOpacity(1.0)
# Get the source pixmap
pixmap, offset = self.sourcePixmap(Qt.CoordinateSystem.DeviceCoordinates, QGraphicsEffect.PixmapPadMode.NoPad)
painter.setWorldTransform(QTransform())
# Get the source by adding the offset to the crop location
source = self.crop
if self.CROP_OFFSET_ENABLED:
source = source.translated(self.CROP_OFFSET)
painter.drawPixmap(self.crop.topLeft() + offset, pixmap, source)
================================================
FILE: electrum/gui/qt/qrreader/qtmultimedia/validator.py
================================================
#!/usr/bin/env python3
#
# Electron Cash - lightweight Bitcoin client
# Copyright (C) 2019 Axel Gembe
#
# 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 typing import List, Dict, Callable, Any
from abc import ABC, abstractmethod
from PyQt6.QtGui import QColor
from PyQt6.QtCore import Qt
from electrum.i18n import _
from electrum.qrreader import QrCodeResult
from electrum.gui.qt.util import ColorScheme, QColorLerp
class QrReaderValidatorResult():
"""
Result of a QR code validator
"""
def __init__(self):
self.accepted: bool = False
self.message: str = None
self.message_color: QColor = None
self.simple_result : str = None
self.result_usable: Dict[QrCodeResult, bool] = {}
self.result_colors: Dict[QrCodeResult, QColor] = {}
self.result_messages: Dict[QrCodeResult, str] = {}
self.selected_results: List[QrCodeResult] = []
class AbstractQrReaderValidator(ABC):
"""
Abstract base class for QR code result validators.
"""
@abstractmethod
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
"""
Checks a list of QR code results for usable codes.
"""
class QrReaderValidatorCounting(AbstractQrReaderValidator):
"""
This QR code result validator doesn't directly accept any results but maintains a dictionary
of detection counts in `result_counts`.
"""
result_counts: Dict[QrCodeResult, int] = {}
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
res = QrReaderValidatorResult()
for result in results:
# Increment the detection count
if result not in self.result_counts:
self.result_counts[result] = 0
self.result_counts[result] += 1
# Search for missing results, iterate over a copy because the loop might modify the dict
for result in self.result_counts.copy():
# Count down missing results
if result in results:
continue
self.result_counts[result] -= 2
# When the count goes to zero, remove
if self.result_counts[result] < 1:
del self.result_counts[result]
return res
class QrReaderValidatorColorizing(QrReaderValidatorCounting):
"""
This QR code result validator doesn't directly accept any results but colorizes the results
based on the counts maintained by `QrReaderValidatorCounting`.
"""
WEAK_COLOR: QColor = QColor(Qt.GlobalColor.red)
STRONG_COLOR: QColor = QColor(Qt.GlobalColor.green)
strong_count: int = 2 # FIXME: make this time based rather than framect based
# note: we set a low strong_count to ~disable this mechanism and make QR codes
# much easier to scan (but potentially with some false positives)
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
res = super().validate_results(results)
# Colorize the QR code results by their detection counts
for result in results:
# Enforce strong_count as upper limit
self.result_counts[result] = min(self.result_counts[result], self.strong_count)
# Interpolate between WEAK_COLOR and STRONG_COLOR based on count / strong_count
lerp_factor = (self.result_counts[result] - 1) / self.strong_count
lerped_color = QColorLerp(self.WEAK_COLOR, self.STRONG_COLOR, lerp_factor)
res.result_colors[result] = lerped_color
return res
class QrReaderValidatorStrong(QrReaderValidatorColorizing):
"""
This QR code result validator doesn't directly accept any results but passes every strong
detection in the return values `selected_results`.
"""
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
res = super().validate_results(results)
for result in results:
if self.result_counts[result] >= self.strong_count:
res.selected_results.append(result)
break
return res
class QrReaderValidatorCounted(QrReaderValidatorStrong):
"""
This QR code result validator accepts a result as soon as there is at least `minimum` and at
most `maximum` QR code(s) with strong detection.
"""
def __init__(self, minimum: int = 1, maximum: int = 1):
super().__init__()
self.minimum = minimum
self.maximum = maximum
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
res = super().validate_results(results)
num_results = len(res.selected_results)
if num_results < self.minimum:
if num_results > 0:
res.message = _('Too few QR codes detected.')
res.message_color = ColorScheme.RED.as_color()
elif num_results > self.maximum:
res.message = _('Too many QR codes detected.')
res.message_color = ColorScheme.RED.as_color()
else:
res.accepted = True
res.simple_result = (results and results[0].data) or '' # hack added by calin just to take the first one
return res
================================================
FILE: electrum/gui/qt/qrreader/qtmultimedia/video_overlay.py
================================================
#!/usr/bin/env python3
#
# Electron Cash - lightweight Bitcoin client
# Copyright (C) 2019 Axel Gembe
#
# 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 typing import List
from PyQt6.QtWidgets import QWidget
from PyQt6.QtGui import QPainter, QPaintEvent, QPen, QPainterPath, QColor, QTransform
from PyQt6.QtCore import QPoint, QSize, QRect, QRectF, Qt
from electrum.qrreader import QrCodeResult
from .validator import QrReaderValidatorResult
class QrReaderVideoOverlay(QWidget):
"""
Overlays the QR scanner results over the video
"""
BG_RECT_PADDING = 10
BG_RECT_CORNER_RADIUS = 10.0
BG_RECT_OPACITY = 0.75
def __init__(self, parent: QWidget = None):
super().__init__(parent)
self.results = []
self.flip_x = False
self.validator_results = None
self.crop = None
self.resolution = None
self.qr_outline_pen = QPen()
self.qr_outline_pen.setColor(Qt.GlobalColor.red)
self.qr_outline_pen.setWidth(3)
self.qr_outline_pen.setStyle(Qt.PenStyle.DotLine)
self.text_pen = QPen()
self.text_pen.setColor(Qt.GlobalColor.black)
self.bg_rect_pen = QPen()
self.bg_rect_pen.setColor(Qt.GlobalColor.black)
self.bg_rect_pen.setStyle(Qt.PenStyle.DotLine)
self.bg_rect_fill = QColor(255, 255, 255, int(255 * self.BG_RECT_OPACITY))
def set_results(self, results: List[QrCodeResult], flip_x: bool,
validator_results: QrReaderValidatorResult):
self.results = results
self.flip_x = flip_x
self.validator_results = validator_results
self.update()
def set_crop(self, crop: QRect):
self.crop = crop
def set_resolution(self, resolution: QSize):
self.resolution = resolution
def paintEvent(self, _event: QPaintEvent):
if not self.crop or not self.resolution:
return
painter = QPainter(self)
# Keep a backup of the transform and create a new one
transform = painter.worldTransform()
# Set scaling transform
transform = transform.scale(self.width() / self.resolution.width(),
self.height() / self.resolution.height())
# Compute the transform to flip the coordinate system on the x axis
transform_flip = QTransform()
if self.flip_x:
transform_flip = transform_flip.translate(self.resolution.width(), 0.0)
transform_flip = transform_flip.scale(-1.0, 1.0)
# Small helper for tuple to QPoint
def toqp(point):
return QPoint(point[0], point[1])
# Starting from here we care about AA
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Draw all the QR code results
for res in self.results:
painter.setWorldTransform(transform_flip * transform, False)
# Draw lines between all of the QR code points
pen = QPen(self.qr_outline_pen)
if res in self.validator_results.result_colors:
pen.setColor(self.validator_results.result_colors[res])
painter.setPen(pen)
num_points = len(res.points)
for i in range(0, num_points):
i_n = i + 1
line_from = toqp(res.points[i])
line_from += self.crop.topLeft()
line_to = toqp(res.points[i_n] if i_n < num_points else res.points[0])
line_to += self.crop.topLeft()
painter.drawLine(line_from, line_to)
# Draw the QR code data
# Note that we reset the world transform to only the scaled transform
# because otherwise the text could be flipped. We only use transform_flip
# to map the center point of the result.
painter.setWorldTransform(transform, False)
font_metrics = painter.fontMetrics()
data_metrics = QSize(font_metrics.horizontalAdvance(res.data), font_metrics.capHeight())
center_pos = toqp(res.center)
center_pos += self.crop.topLeft()
center_pos = transform_flip.map(center_pos)
text_offset = QPoint(data_metrics.width(), data_metrics.height())
text_offset = text_offset / 2
text_offset.setX(-text_offset.x())
center_pos += text_offset
padding = self.BG_RECT_PADDING
bg_rect_pos = center_pos - QPoint(padding, data_metrics.height() + padding)
bg_rect_size = data_metrics + (QSize(padding, padding) * 2)
bg_rect = QRect(bg_rect_pos, bg_rect_size)
bg_rect_path = QPainterPath()
radius = self.BG_RECT_CORNER_RADIUS
bg_rect_path.addRoundedRect(QRectF(bg_rect), radius, radius, Qt.SizeMode.AbsoluteSize)
painter.setPen(self.bg_rect_pen)
painter.fillPath(bg_rect_path, self.bg_rect_fill)
painter.drawPath(bg_rect_path)
painter.setPen(self.text_pen)
painter.drawText(center_pos, res.data)
================================================
FILE: electrum/gui/qt/qrreader/qtmultimedia/video_surface.py
================================================
#!/usr/bin/env python3
#
# Copyright (C) 2019 Axel Gembe
# Copyright (c) 2024 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 typing import List
from PyQt6.QtMultimedia import (QVideoFrame, QVideoFrameFormat, QVideoSink)
from PyQt6.QtGui import QImage
from PyQt6.QtCore import QObject, pyqtSignal
from electrum.i18n import _
from electrum.logging import get_logger
_logger = get_logger(__name__)
class QrReaderVideoSurface(QVideoSink):
"""
Receives QVideoFrames from QCamera, converts them into a QImage, flips the X and Y axis if
necessary and sends them to listeners via the frame_available event.
"""
def __init__(self, parent: QObject = None):
super().__init__(parent)
self.videoFrameChanged.connect(self._on_new_frame)
def _on_new_frame(self, frame: QVideoFrame) -> None:
if not frame.isValid():
return
image_format = QVideoFrameFormat.imageFormatFromPixelFormat(frame.pixelFormat())
if image_format == QVideoFrameFormat.PixelFormat.Format_Invalid:
_logger.info(_('QR code scanner for video frame with invalid pixel format'))
return
if not frame.map(QVideoFrame.MapMode.ReadOnly):
_logger.info(_('QR code scanner failed to map video frame'))
return
try:
img = frame.toImage()
# Check whether we need to flip the image on any axis
surface_format = frame.surfaceFormat()
flip_x = surface_format.isMirrored()
flip_y = surface_format.scanLineDirection() == QVideoFrameFormat.Direction.BottomToTop
# Mirror the image if needed
if flip_x or flip_y:
img = img.mirrored(flip_x, flip_y)
# Create a copy of the image so the original frame data can be freed
img = img.copy()
finally:
frame.unmap()
self.frame_available.emit(img)
frame_available = pyqtSignal(QImage)
================================================
FILE: electrum/gui/qt/qrreader/qtmultimedia/video_widget.py
================================================
#!/usr/bin/env python3
#
# Electron Cash - lightweight Bitcoin client
# Copyright (C) 2019 Axel Gembe
#
# 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 PyQt6.QtWidgets import QWidget
from PyQt6.QtGui import QPixmap, QPainter, QPaintEvent
class QrReaderVideoWidget(QWidget):
"""
Simple widget for drawing a pixmap
"""
USE_BILINEAR_FILTER = True
def __init__(self, parent: QWidget = None):
super().__init__(parent)
self.pixmap = None
def paintEvent(self, _event: QPaintEvent):
if not self.pixmap:
return
painter = QPainter(self)
if self.USE_BILINEAR_FILTER:
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawPixmap(self.rect(), self.pixmap, self.pixmap.rect())
def setPixmap(self, pixmap: QPixmap):
self.pixmap = pixmap
self.update()
================================================
FILE: electrum/gui/qt/qrtextedit.py
================================================
from functools import partial
from typing import Callable
from electrum.i18n import _
from electrum.plugin import run_hook
from electrum.simple_config import SimpleConfig
from .util import ButtonsTextEdit, MessageBoxMixin, ColorScheme, read_QIcon
from .util import get_icon_camera, get_icon_qrcode, add_input_actions_to_context_menu
class ShowQRTextEdit(ButtonsTextEdit):
def __init__(self, text=None, *, config: SimpleConfig):
ButtonsTextEdit.__init__(self, text)
self.setReadOnly(True)
self.add_qr_show_button(config=config)
run_hook('show_text_edit', self)
def contextMenuEvent(self, e):
m = self.createStandardContextMenu()
m.addAction(get_icon_qrcode(), _("Show as QR code"), self.on_qr_show_btn)
m.exec(e.globalPos())
class ScanQRTextEdit(ButtonsTextEdit, MessageBoxMixin):
def __init__(
self, text="", allow_multi: bool = False,
*,
config: SimpleConfig,
setText: Callable[[str], None] = None,
is_payto = False,
):
ButtonsTextEdit.__init__(self, text)
self.setReadOnly(False)
self.on_qr_from_camera_input_btn = partial(
self.input_qr_from_camera,
config=config,
allow_multi=allow_multi,
show_error=self.show_error,
setText=setText,
)
self.on_qr_from_screenshot_input_btn = partial(
self.input_qr_from_screenshot,
allow_multi=allow_multi,
show_error=self.show_error,
setText=setText,
)
self.on_qr_from_file_input_btn = partial(
self.input_qr_from_file,
allow_multi=allow_multi,
config=config,
show_error=self.show_error,
setText=setText,
)
self.on_input_file = partial(
self.input_file,
config=config,
show_error=self.show_error,
setText=setText,
)
# for send tab, buttons are available in the toolbar
if not is_payto:
self.add_input_buttons(config, allow_multi, setText)
run_hook('scan_text_edit', self)
def add_input_buttons(self, config, allow_multi, setText):
self.add_menu_button(
options=[
("picture_in_picture.png", _("Read QR code from screen"), self.on_qr_from_screenshot_input_btn),
("qr_file.png", _("Read QR code from file"), self.on_qr_from_file_input_btn),
("file.png", _("Read text from file"), self.on_input_file),
],
)
self.add_qr_input_from_camera_button(config=config, show_error=self.show_error, allow_multi=allow_multi, setText=setText)
def contextMenuEvent(self, e):
m = self.createStandardContextMenu()
m.addSeparator()
add_input_actions_to_context_menu(self, m)
m.exec(e.globalPos())
class ScanShowQRTextEdit(ScanQRTextEdit):
def __init__(self, *args, config: SimpleConfig, **kwargs):
ScanQRTextEdit.__init__(self, *args, **kwargs, config=config)
self.add_qr_show_button(config=config)
run_hook('show_text_edit', self)
def contextMenuEvent(self, e):
m = self.createStandardContextMenu()
m.addSeparator()
add_input_actions_to_context_menu(self, m)
m.addAction(get_icon_qrcode(), _("Show as QR code"), self.on_qr_show_btn)
m.exec(e.globalPos())
================================================
FILE: electrum/gui/qt/qrwindow.py
================================================
#!/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.
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QHBoxLayout, QWidget
from .qrcodewidget import QRCodeWidget
from electrum.i18n import _
class QR_Window(QWidget):
def __init__(self, win):
QWidget.__init__(self)
self.main_window = win
self.setWindowTitle('Electrum - '+_('Payment Request'))
self.setMinimumSize(800, 800)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
main_box = QHBoxLayout()
self.qrw = QRCodeWidget()
main_box.addWidget(self.qrw, 1)
self.setLayout(main_box)
def closeEvent(self, event):
self.main_window.receive_tab.qr_menu_action.setChecked(False)
================================================
FILE: electrum/gui/qt/rate_limiter.py
================================================
# Copyright (c) 2019 Calin Culianu
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
from functools import wraps
import threading
import time
import weakref
from PyQt6.QtCore import QObject, QTimer
from electrum.logging import Logger, get_logger
_logger = get_logger(__name__)
class RateLimiter(Logger):
''' Manages the state of a @rate_limited decorated function, collating
multiple invocations. This class is not intended to be used directly. Instead,
use the @rate_limited decorator (for instance methods).
This state instance gets inserted into the instance attributes of the target
object wherever a @rate_limited decorator appears.
The inserted attribute is named "__FUNCNAME__RateLimiter". '''
# some defaults
last_ts = 0.0
timer = None
saved_args = (tuple(),dict())
ctr = 0
def __init__(self, rate, ts_after, obj, func):
self.n = func.__name__
self.qn = func.__qualname__
self.rate = rate
self.ts_after = ts_after
self.obj = weakref.ref(obj) # keep a weak reference to the object to prevent cycles
self.func = func
Logger.__init__(self)
#self.logger.debug(f"*** Created: {func=},{obj=},{rate=}")
def diagnostic_name(self):
return "{}:{}".format("rate_limited",self.qn)
def kill_timer(self):
if self.timer:
#self.logger.debug("deleting timer")
try:
self.timer.stop()
self.timer.deleteLater()
except RuntimeError as e:
if 'c++ object' in str(e).lower():
# This can happen if the attached object which actually owns
# QTimer is deleted by Qt before this call path executes.
# This call path may be executed from a queued connection in
# some circumstances, hence the crazyness (I think).
self.logger.debug("advisory: QTimer was already deleted by Qt, ignoring...")
else:
raise
finally:
self.timer = None
@classmethod
def attr_name(cls, func): return "__{}__{}".format(func.__name__, cls.__name__)
@classmethod
def invoke(cls, rate, ts_after, func, args, kwargs):
''' Calls _invoke() on an existing RateLimiter object (or creates a new
one for the given function on first run per target object instance). '''
assert args and isinstance(args[0], object), "@rate_limited decorator may only be used with object instance methods"
assert threading.current_thread() is threading.main_thread(), "@rate_limited decorator may only be used with functions called in the main thread"
obj = args[0]
a_name = cls.attr_name(func)
#_logger.debug(f"*** {a_name=}, {obj=}")
rl = getattr(obj, a_name, None) # we hide the RateLimiter state object in an attribute (name based on the wrapped function name) in the target object
if rl is None:
# must be the first invocation, create a new RateLimiter state instance.
rl = cls(rate, ts_after, obj, func)
setattr(obj, a_name, rl)
return rl._invoke(args, kwargs)
def _invoke(self, args, kwargs):
self._push_args(args, kwargs) # since we're collating, save latest invocation's args unconditionally. any future invocation will use the latest saved args.
self.ctr += 1 # increment call counter
#self.logger.debug(f"args_saved={args}, kwarg_saved={kwargs}")
if not self.timer: # check if there's a pending invocation already
now = time.time()
diff = float(self.rate) - (now - self.last_ts)
if diff <= 0:
# Time since last invocation was greater than self.rate, so call the function directly now.
#self.logger.debug("calling directly")
return self._doIt()
else:
# Time since last invocation was less than self.rate, so defer to the future with a timer.
self.timer = QTimer(self.obj() if isinstance(self.obj(), QObject) else None)
self.timer.timeout.connect(self._doIt)
#self.timer.destroyed.connect(lambda x=None,qn=self.qn: print(qn,"Timer deallocated"))
self.timer.setSingleShot(True)
self.timer.start(int(diff*1e3))
#self.logger.debug("deferring")
else:
# We had a timer active, which means as future call will occur. So return early and let that call happen in the future.
# Note that a side-effect of this aborted invocation was to update self.saved_args.
pass
#self.logger.debug("ignoring (already scheduled)")
def _pop_args(self):
args, kwargs = self.saved_args # grab the latest collated invocation's args. this attribute is always defined.
self.saved_args = (tuple(),dict()) # clear saved args immediately
return args, kwargs
def _push_args(self, args, kwargs):
self.saved_args = (args, kwargs)
def _doIt(self):
#self.logger.debug("called!")
t0 = time.time()
args, kwargs = self._pop_args()
#self.logger.debug(f"args_actually_used={args}, kwarg_actually_used={kwargs}")
ctr0 = self.ctr # read back current call counter to compare later for reentrancy detection
retval = self.func(*args, **kwargs) # and.. call the function. use latest invocation's args
was_reentrant = self.ctr != ctr0 # if ctr is not the same, func() led to a call this function!
del args, kwargs # deref args right away (allow them to get gc'd)
tf = time.time()
time_taken = tf-t0
if self.ts_after:
self.last_ts = tf
else:
if time_taken > float(self.rate):
self.logger.debug(f"method took too long: {time_taken} > {self.rate}. Fudging timestamps to compensate.")
self.last_ts = tf # Hmm. This function takes longer than its rate to complete. so mark its last run time as 'now'. This breaks the rate but at least prevents this function from starving the CPU (benforces a delay).
else:
self.last_ts = t0 # Function takes less than rate to complete, so mark its t0 as when we entered to keep the rate constant.
if self.timer: # timer is not None if and only if we were a delayed (collated) invocation.
if was_reentrant:
# we got a reentrant call to this function as a result of calling func() above! re-schedule the timer.
self.logger.debug("*** detected a re-entrant call, re-starting timer")
time_left = float(self.rate) - (tf - self.last_ts)
self.timer.start(time_left*1e3)
else:
# We did not get a reentrant call, so kill the timer so subsequent calls can schedule the timer and/or call func() immediately.
self.kill_timer()
elif was_reentrant:
self.logger.debug("*** detected a re-entrant call")
return retval
class RateLimiterClassLvl(RateLimiter):
''' This RateLimiter object is used if classlevel=True is specified to the
@rate_limited decorator. It inserts the __RateLimiterClassLvl state object
on the class level and collates calls for all instances to not exceed rate.
Each instance is guaranteed to receive at least 1 call and to have multiple
calls updated with the latest args for the final call. So for instance:
a.foo(1)
a.foo(2)
b.foo(10)
b.foo(3)
Would collate to a single 'class-level' call using 'rate':
a.foo(2) # latest arg taken, collapsed to 1 call
b.foo(3) # latest arg taken, collapsed to 1 call
'''
@classmethod
def invoke(cls, rate, ts_after, func, args, kwargs):
assert args and not isinstance(args[0], type), "@rate_limited decorator may not be used with static or class methods"
obj = args[0]
objcls = obj.__class__
args = list(args)
args.insert(0, objcls) # prepend obj class to trick super.invoke() into making this state object be class-level.
return super(RateLimiterClassLvl, cls).invoke(rate, ts_after, func, args, kwargs)
def _push_args(self, args, kwargs):
objcls, obj = args[0:2]
args = args[2:]
self.saved_args[obj] = (args, kwargs)
def _pop_args(self):
weak_dict = self.saved_args
self.saved_args = weakref.WeakKeyDictionary()
return (weak_dict,),dict()
def _call_func_for_all(self, weak_dict):
for ref in weak_dict.keyrefs():
obj = ref()
if obj:
args,kwargs = weak_dict[obj]
obj_name = obj.diagnostic_name() if hasattr(obj, "diagnostic_name") else obj
#self.logger.debug(f"calling for {obj_name}, timer={bool(self.timer)}")
self.func_target(obj, *args, **kwargs)
def __init__(self, rate, ts_after, obj, func):
# note: obj here is really the __class__ of the obj because we prepended the class in our custom invoke() above.
super().__init__(rate, ts_after, obj, func)
self.func_target = func
self.func = self._call_func_for_all
self.saved_args = weakref.WeakKeyDictionary() # we don't use a simple arg tuple, but instead an instance -> args,kwargs dictionary to store collated calls, per instance collated
def rate_limited(rate, *, classlevel=False, ts_after=False):
""" A Function decorator for rate-limiting GUI event callbacks. Argument
rate in seconds is the minimum allowed time between subsequent calls of
this instance of the function. Calls that arrive more frequently than
rate seconds will be collated into a single call that is deferred onto
a QTimer. It is preferable to use this decorator on QObject subclass
instance methods. This decorator is particularly useful in limiting
frequent calls to GUI update functions.
params:
rate - calls are collated to not exceed rate (in seconds)
classlevel - if True, specify that the calls should be collated at
1 per `rate` secs. for *all* instances of a class, otherwise
calls will be collated on a per-instance basis.
ts_after - if True, mark the timestamp of the 'last call' AFTER the
target method completes. That is, the collation of calls will
ensure at least `rate` seconds will always elapse between
subsequent calls. If False, the timestamp is taken right before
the collated calls execute (thus ensuring a fixed period for
collated calls).
TL;DR: ts_after=True : `rate` defines the time interval you want
from last call's exit to entry into next
call.
ts_adter=False: `rate` defines the time between each
call's entry.
(See on_fx_quotes & on_fx_history in main_window.py for example usages
of this decorator). """
def wrapper0(func):
@wraps(func)
def wrapper(*args, **kwargs):
if classlevel:
return RateLimiterClassLvl.invoke(rate, ts_after, func, args, kwargs)
return RateLimiter.invoke(rate, ts_after, func, args, kwargs)
return wrapper
return wrapper0
================================================
FILE: electrum/gui/qt/rbf_dialog.py
================================================
# Copyright (C) 2021 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
from typing import TYPE_CHECKING
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QLabel, QGridLayout, QHBoxLayout, QComboBox
from .util import ColorScheme
from electrum.i18n import _
from electrum.transaction import PartialTransaction
from electrum.wallet import CannotRBFTx, BumpFeeStrategy
if TYPE_CHECKING:
from .main_window import ElectrumWindow
from .confirm_tx_dialog import TxEditor, TxSizeLabel, HelpLabel
class _BaseRBFDialog(TxEditor):
def __init__(
self,
*,
main_window: 'ElectrumWindow',
tx: PartialTransaction,
title: str):
self.wallet = main_window.wallet
self.old_tx = tx
self.message = ''
self.old_fee = self.old_tx.get_fee()
self.old_tx_size = tx.estimated_size()
self.old_fee_rate = old_fee_rate = self.old_fee / self.old_tx_size # sat/vbyte
output_value = sum([txo.value for txo in tx.outputs() if not txo.is_mine])
if output_value == 0:
output_value = tx.output_value()
TxEditor.__init__(
self,
window=main_window,
title=title,
make_tx=self.rbf_func,
output_value=output_value,
)
self.fee_e.setFrozen(True) # disallow setting absolute fee for now, as wallet.bump_fee can only target feerate
new_fee_rate = self.old_fee_rate + max(1, self.old_fee_rate // 20)
self.feerate_e.setAmount(new_fee_rate)
self.update()
self.fee_slider.deactivate()
def create_grid(self):
self.method_label = QLabel(_('Method') + ':')
self.method_combo = QComboBox()
self._strategies, def_strat_idx = self.wallet.get_bumpfee_strategies_for_tx(tx=self.old_tx)
self.method_combo.addItems([strat.text() for strat in self._strategies])
self.method_combo.setCurrentIndex(def_strat_idx)
self.method_combo.currentIndexChanged.connect(self.trigger_update)
self.method_combo.setFocusPolicy(Qt.FocusPolicy.NoFocus)
old_size_label = TxSizeLabel()
old_size_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
old_size_label.setAmount(self.old_tx_size)
old_size_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
current_fee_hbox = QHBoxLayout()
current_fee_hbox.addWidget(QLabel(self.main_window.format_fee_rate(1000 * self.old_fee_rate)))
current_fee_hbox.addWidget(old_size_label)
current_fee_hbox.addWidget(QLabel(self.main_window.format_amount_and_units(self.old_fee)))
current_fee_hbox.addStretch()
grid = QGridLayout()
grid.addWidget(self.method_label, 0, 0)
grid.addWidget(self.method_combo, 0, 1)
grid.addWidget(QLabel(_('Current fee') + ':'), 1, 0)
grid.addLayout(current_fee_hbox, 1, 1, 1, 3)
grid.addWidget(QLabel(_('New fee') + ':'), 2, 0)
grid.addLayout(self.fee_hbox, 2, 1, 1, 3)
grid.addWidget(HelpLabel(_("Fee target") + ": ", self.fee_combo.help_msg), 4, 0)
grid.addLayout(self.fee_target_hbox, 4, 1, 1, 3)
grid.setColumnStretch(4, 1)
# locktime
grid.addWidget(self.locktime_label, 5, 0)
grid.addWidget(self.locktime_e, 5, 1, 1, 2)
return grid
def run(self) -> None:
if not self.exec():
return
if self.is_preview:
self.main_window.show_transaction(self.tx)
return
def sign_done(success):
if success:
self.main_window.broadcast_or_show(self.tx)
self.main_window.sign_tx(
self.tx,
callback=sign_done,
external_keypairs={})
def update_tx(self):
fee_rate = self.feerate_e.get_amount()
if fee_rate is None:
self.tx = None
self.error = _('No fee rate')
elif fee_rate <= self.old_fee_rate:
self.tx = None
self.error = _("The new fee rate needs to be higher than the old fee rate.")
else:
try:
self.tx = self.make_tx(fee_rate)
except CannotRBFTx as e:
self.tx = None
self.error = str(e)
def get_messages(self):
messages = super().get_messages()
if not self.tx:
return
delta = self.tx.get_fee() - self.old_tx.get_fee()
if self._strategies[self.method_combo.currentIndex()] == BumpFeeStrategy.PRESERVE_PAYMENT:
msg = _("You will pay {} more.").format(self.main_window.format_amount_and_units(delta))
elif self._strategies[self.method_combo.currentIndex()] == BumpFeeStrategy.DECREASE_PAYMENT:
msg = _("The recipient will receive {} less.").format(self.main_window.format_amount_and_units(delta))
else:
raise Exception(f"unknown strategy: {self=}")
messages.insert(0, msg)
return messages
class BumpFeeDialog(_BaseRBFDialog):
help_text = _("Increase your transaction's fee to improve its position in mempool.")
def __init__(
self,
*,
main_window: 'ElectrumWindow',
tx: PartialTransaction,
):
_BaseRBFDialog.__init__(
self,
main_window=main_window,
tx=tx,
title=_('Bump Fee'))
def rbf_func(self, fee_rate, *, confirmed_only=False):
return self.wallet.bump_fee(
tx=self.old_tx,
new_fee_rate=fee_rate,
coins=self.main_window.get_coins(nonlocal_only=True, confirmed_only=confirmed_only),
strategy=self._strategies[self.method_combo.currentIndex()],
)
class DSCancelDialog(_BaseRBFDialog):
help_text = _(
"Cancel an unconfirmed transaction by replacing it with "
"a higher-fee transaction that spends back to your wallet.")
def __init__(
self,
*,
main_window: 'ElectrumWindow',
tx: PartialTransaction,
):
_BaseRBFDialog.__init__(
self,
main_window=main_window,
tx=tx,
title=_('Cancel transaction'))
self.method_label.setVisible(False)
self.method_combo.setVisible(False)
def rbf_func(self, fee_rate, *, confirmed_only=False):
return self.wallet.dscancel(tx=self.old_tx, new_fee_rate=fee_rate)
================================================
FILE: electrum/gui/qt/rebalance_dialog.py
================================================
from typing import TYPE_CHECKING
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton
from electrum.i18n import _
from electrum.lnchannel import Channel
from .util import WindowModalDialog, Buttons, OkButton, CancelButton, WWLabel
from .amountedit import BTCAmountEdit
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class RebalanceDialog(WindowModalDialog):
def __init__(self, window: 'ElectrumWindow', chan1: Channel, chan2: Channel, amount_sat):
WindowModalDialog.__init__(self, window, _("Rebalance channels"))
self.window = window
self.wallet = window.wallet
self.chan1 = chan1
self.chan2 = chan2
vbox = QVBoxLayout(self)
vbox.addWidget(WWLabel(_('Rebalance your channels in order to increase your sending or receiving capacity') + ':'))
grid = QGridLayout()
self.amount_e = BTCAmountEdit(self.window.get_decimal_point)
self.amount_e.setAmount(amount_sat)
self.amount_e.textChanged.connect(self.on_amount)
self.rev_button = QPushButton(u'\U000021c4')
self.rev_button.clicked.connect(self.on_reverse)
self.max_button = QPushButton('Max')
self.max_button.clicked.connect(self.on_max)
self.label1 = QLabel('')
self.label2 = QLabel('')
self.ok_button = OkButton(self)
self.ok_button.setEnabled(False)
grid.addWidget(QLabel(_("From channel")), 0, 0)
grid.addWidget(self.label1, 0, 1)
grid.addWidget(QLabel(_("To channel")), 1, 0)
grid.addWidget(self.label2, 1, 1)
grid.addWidget(QLabel(_("Amount")), 2, 0)
grid.addWidget(self.amount_e, 2, 1)
grid.addWidget(self.max_button, 2, 2)
grid.addWidget(self.rev_button, 0, 2)
vbox.addLayout(grid)
vbox.addLayout(Buttons(CancelButton(self), self.ok_button))
self.update()
def on_reverse(self, x):
a, b = self.chan1, self.chan2
self.chan1, self.chan2 = b, a
self.amount_e.setAmount(None)
self.update()
def on_amount(self, x):
self.update()
def on_max(self, x):
n_sat = self.wallet.lnworker.num_sats_can_rebalance(self.chan1, self.chan2)
self.amount_e.setAmount(n_sat)
def update(self):
self.label1.setText(self.chan1.short_id_for_GUI())
self.label2.setText(self.chan2.short_id_for_GUI())
amount_sat = self.amount_e.get_amount()
b = bool(amount_sat) and self.wallet.lnworker.num_sats_can_rebalance(self.chan1, self.chan2) >= amount_sat
self.ok_button.setEnabled(b)
def run(self):
if not self.exec():
return
amount_msat = self.amount_e.get_amount() * 1000
coro = self.wallet.lnworker.rebalance_channels(self.chan1, self.chan2, amount_msat=amount_msat)
self.window.run_coroutine_from_thread(coro, _('Rebalancing channels'))
self.window.receive_tab.update_current_request() # this will gray out the button
================================================
FILE: electrum/gui/qt/receive_tab.py
================================================
# Copyright (C) 2022 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
from typing import Optional, TYPE_CHECKING
from PyQt6.QtGui import QFont, QCursor, QMouseEvent
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QTextEdit,
QHBoxLayout, QPushButton, QWidget, QSizePolicy, QFrame)
from electrum.i18n import _
from electrum.util import InvoiceError, ChoiceItem
from electrum.invoices import pr_expiration_values
from electrum.logging import Logger
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .qrcodewidget import QRCodeWidget
from .util import read_QIcon, WWLabel, MessageBoxMixin, MONOSPACE_FONT, get_icon_qrcode
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class ReceiveTab(QWidget, MessageBoxMixin, Logger):
# strings updated by update_current_request
addr = ''
lnaddr = ''
URI = ''
address_help = ''
URI_help = ''
ln_help = ''
def __init__(self, window: 'ElectrumWindow'):
QWidget.__init__(self, window)
Logger.__init__(self)
self.window = window
self.wallet = window.wallet
self.fx = window.fx
self.config = window.config
# 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_message_e = SizedFreezableLineEdit(width=400)
grid.addWidget(QLabel(_('Description')), 0, 0)
grid.addWidget(self.receive_message_e, 0, 1, 1, 4)
self.receive_amount_e = BTCAmountEdit(self.window.get_decimal_point)
grid.addWidget(QLabel(_('Requested amount')), 1, 0)
grid.addWidget(self.receive_amount_e, 1, 1)
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, 1, 2, Qt.AlignmentFlag.AlignLeft)
self.window.connect_fields(self.receive_amount_e, self.fiat_receive_e)
self.expiry_button = QPushButton('')
self.expiry_button.clicked.connect(self.expiry_dialog)
grid.addWidget(QLabel(_('Expiry')), 2, 0)
grid.addWidget(self.expiry_button, 2, 1)
self.clear_invoice_button = QPushButton(_('Clear'))
self.clear_invoice_button.clicked.connect(self.do_clear)
text = _('Onchain') if self.wallet.has_lightning() else _('Request')
self.create_onchain_invoice_button = QPushButton(text)
self.create_onchain_invoice_button.setIcon(read_QIcon("bitcoin.png"))
self.create_onchain_invoice_button.clicked.connect(lambda: self.create_invoice(False))
self.create_lightning_invoice_button = QPushButton(_('Lightning'))
self.create_lightning_invoice_button.setIcon(read_QIcon("lightning.png"))
self.create_lightning_invoice_button.clicked.connect(lambda: self.create_invoice(True))
self.create_lightning_invoice_button.setVisible(self.wallet.has_lightning())
self.receive_buttons = buttons = QHBoxLayout()
buttons.addWidget(self.clear_invoice_button)
buttons.addStretch(1)
buttons.addWidget(self.create_onchain_invoice_button)
buttons.addWidget(self.create_lightning_invoice_button)
grid.addLayout(buttons, 4, 1, 1, -1)
self.receive_e = QTextEdit()
self.receive_e.setFont(QFont(MONOSPACE_FONT))
self.receive_e.setReadOnly(True)
self.receive_e.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
self.receive_e.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
self.receive_e.textChanged.connect(self.update_receive_widgets)
self.receive_qr = QRCodeWidget(manual_size=True)
self.receive_help_text = WWLabel('')
self.receive_help_text.setLayout(QHBoxLayout())
self.receive_rebalance_button = QPushButton('Rebalance')
self.receive_rebalance_button.suggestion = None
self.receive_zeroconf_button = QPushButton(_('Accept'))
self.receive_zeroconf_button.clicked.connect(self.on_accept_zeroconf)
def on_receive_rebalance():
if self.receive_rebalance_button.suggestion:
chan1, chan2, delta = self.receive_rebalance_button.suggestion
self.window.rebalance_dialog(chan1, chan2, amount_sat=delta)
self.receive_rebalance_button.clicked.connect(on_receive_rebalance)
self.receive_swap_button = QPushButton('Swap')
self.receive_swap_button.suggestion = None
def on_receive_swap():
if self.receive_swap_button.suggestion:
chan, swap_recv_amount_sat = self.receive_swap_button.suggestion
self.window.run_swap_dialog(is_reverse=True, recv_amount_sat_or_max=swap_recv_amount_sat, channels=[chan])
self.receive_swap_button.clicked.connect(on_receive_swap)
buttons = QHBoxLayout()
buttons.addWidget(self.receive_rebalance_button)
buttons.addWidget(self.receive_swap_button)
buttons.addWidget(self.receive_zeroconf_button)
vbox = QVBoxLayout()
vbox.addWidget(self.receive_help_text)
vbox.addLayout(buttons)
self.receive_help_widget = FramedWidget()
self.receive_help_widget.setVisible(False)
self.receive_help_widget.setLayout(vbox)
self.receive_widget = ReceiveWidget(
self, self.receive_e, self.receive_qr, self.receive_help_widget)
#self.receive_widget.mouseReleaseEvent = lambda x: self.toggle_receive_qr()
receive_widget_sp = QSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding)
receive_widget_sp.setRetainSizeWhenHidden(True)
self.receive_widget.setSizePolicy(receive_widget_sp)
self.receive_widget.setVisible(False)
self.receive_requests_label = QLabel(_('Requests'))
# with QDarkStyle, this label may partially cover the qrcode widget.
# setMaximumWidth prevents that
self.receive_requests_label.setMaximumWidth(400)
from .request_list import RequestList
self.request_list = RequestList(self)
# toolbar
self.toolbar, menu = self.request_list.create_toolbar_with_menu('')
self.toggle_qr_button = QPushButton('')
self.toggle_qr_button.setIcon(get_icon_qrcode())
self.toggle_qr_button.setToolTip(_('Switch between text and QR code view'))
self.toggle_qr_button.clicked.connect(self.toggle_receive_qr)
self.toggle_qr_button.setEnabled(False)
self.toolbar.insertWidget(2, self.toggle_qr_button)
# menu
self.qr_menu_action = menu.addToggle(_("Show detached QR code window"), self.window.toggle_qr_window)
menu.addAction(_("Import requests"), self.window.import_requests)
menu.addAction(_("Export requests"), self.window.export_requests)
menu.addAction(_("Delete expired requests"), self.request_list.delete_expired_requests)
self.toolbar_menu = menu
# layout
vbox_g = QVBoxLayout()
vbox_g.addLayout(grid)
vbox_g.addStretch()
hbox = QHBoxLayout()
hbox.addLayout(vbox_g)
hbox.addStretch()
hbox.addWidget(self.receive_widget, 1)
self.searchable_list = self.request_list
vbox = QVBoxLayout(self)
vbox.addLayout(self.toolbar)
vbox.addLayout(hbox)
vbox.addStretch()
vbox.addWidget(self.receive_requests_label)
vbox.addWidget(self.request_list)
vbox.setStretchFactor(hbox, 40)
vbox.setStretchFactor(self.request_list, 60)
self.request_list.update() # after parented and put into a layout, can update without flickering
self.update_expiry_text()
def update_expiry_text(self):
expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS
text = pr_expiration_values()[expiry]
self.expiry_button.setText(text)
def expiry_dialog(self):
msg = ''.join([
_('Expiration period of your request.'), ' ',
_('This information is seen by the recipient if you send them a signed payment request.'),
'\n\n',
_('For on-chain requests, the address gets reserved until expiration. After that, it might get reused.'), ' ',
_('The bitcoin address never expires and will always be part of this electrum wallet.'), ' ',
_('You can reuse a bitcoin address any number of times but it is not good for your privacy.'),
'\n\n',
_('For Lightning requests, payments will not be accepted after the expiration.'),
])
expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS
choices = [ChoiceItem(key=exptime, label=label)
for (exptime, label) in pr_expiration_values().items()]
v = self.window.query_choice(msg, choices, title=_('Expiry'), default_key=expiry)
if v is None:
return
self.config.WALLET_PAYREQ_EXPIRY_SECONDS = v
self.update_expiry_text()
def on_tab_changed(self):
text, data, help_text, title = self.get_tab_data()
self.window.do_copy(text, title=title)
self.update_receive_qr_window()
def do_copy(self, e: 'QMouseEvent'):
if e.button() != Qt.MouseButton.LeftButton:
return
text, data, help_text, title = self.get_tab_data()
self.window.do_copy(text, title=title)
def toggle_receive_qr(self):
b = not self.config.GUI_QT_RECEIVE_TAB_QR_VISIBLE
self.config.GUI_QT_RECEIVE_TAB_QR_VISIBLE = b
self.update_receive_widgets()
def update_receive_widgets(self):
b = self.config.GUI_QT_RECEIVE_TAB_QR_VISIBLE
self.receive_widget.update_visibility(b)
def update_current_request(self):
if len(self.request_list.selectionModel().selectedRows(0)) > 1:
key = None
else:
key = self.request_list.get_current_key()
req = self.wallet.get_request(key) if key else None
if req is None:
self.receive_e.setText('')
self.addr = self.URI = self.lnaddr = ''
self.address_help = self.URI_help = self.ln_help = ''
return
help_texts = self.wallet.get_help_texts_for_receive_request(req)
self.addr = (req.get_address() or '') if not help_texts.address_is_error else ''
self.URI = (self.wallet.get_request_URI(req) or '') if not help_texts.URI_is_error else ''
self.lnaddr = self.wallet.get_bolt11_invoice(req) if not help_texts.ln_is_error else ''
self.address_help = help_texts.address_help
self.URI_help = help_texts.URI_help
self.ln_help = help_texts.ln_help
can_rebalance = help_texts.can_rebalance()
can_swap = help_texts.can_swap()
can_zeroconf = help_texts.can_zeroconf()
self.receive_rebalance_button.suggestion = help_texts.ln_rebalance_suggestion
self.receive_swap_button.suggestion = help_texts.ln_swap_suggestion
self.receive_rebalance_button.setVisible(can_rebalance)
self.receive_swap_button.setVisible(can_swap)
self.receive_rebalance_button.setEnabled(can_rebalance and self.window.num_tasks() == 0)
self.receive_swap_button.setEnabled(can_swap and self.window.num_tasks() == 0)
self.receive_zeroconf_button.setVisible(can_zeroconf)
self.receive_zeroconf_button.setEnabled(can_zeroconf)
text, data, help_text, title = self.get_tab_data()
self.receive_e.setText(text)
self.receive_qr.setData(data)
self.receive_help_text.setText(help_text)
for w in [self.receive_e, self.receive_qr]:
w.setEnabled(bool(text) and (not help_text or can_zeroconf))
w.setToolTip(help_text)
# macOS hack (similar to #4777)
self.receive_e.repaint()
# always show
if can_zeroconf:
# show the help message if zeroconf so user can first accept it and still sees the invoice
# after accepting
self.receive_widget.show_help()
self.receive_widget.setVisible(True)
self.toggle_qr_button.setEnabled(True)
self.update_receive_qr_window()
def on_accept_zeroconf(self):
self.receive_zeroconf_button.setVisible(False)
self.update_receive_widgets()
def get_tab_data(self):
if self.URI:
out = self.URI, self.URI, self.URI_help, _('Bitcoin URI')
elif self.addr:
out = self.addr, self.addr, self.address_help, _('Address')
else:
# encode lightning invoices as uppercase so QR encoding can use
# alphanumeric mode; resulting in smaller QR codes
out = self.lnaddr, self.lnaddr.upper(), self.ln_help, _('Lightning Request')
return out
def update_receive_qr_window(self):
if self.window.qr_window and self.window.qr_window.isVisible():
text, data, help_text, title = self.get_tab_data()
self.window.qr_window.qrw.setData(data)
def create_invoice(self, is_lightning: bool):
amount_sat = self.receive_amount_e.get_amount()
message = self.receive_message_e.text()
expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS
if is_lightning:
address = None
else:
if amount_sat and amount_sat < self.wallet.dust_threshold():
self.show_error(_('Amount too small to be received onchain'))
return
address = self.get_bitcoin_address_for_request(amount_sat)
if not address:
return
self.window.address_list.update()
# generate even if we cannot receive
try:
key = self.wallet.create_request(amount_sat, message, expiry, address)
except InvoiceError as e:
self.show_error(_('Error creating payment request') + ':\n' + str(e))
return
except Exception as e:
self.logger.exception('Error adding payment request')
self.show_error(_('Error adding payment request') + ':\n' + repr(e))
return
assert key is not None
self.window.address_list.refresh_all()
self.request_list.update()
self.request_list.set_current_key(key)
# clear request fields
self.receive_amount_e.setText('')
self.receive_message_e.setText('')
# copy current tab to clipboard
self.on_tab_changed()
def get_bitcoin_address_for_request(self, amount) -> Optional[str]:
addr = self.wallet.get_unused_address()
if addr is None:
if not self.wallet.is_deterministic(): # imported wallet
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.'), '\n\n',
_('Creating a new payment request will reuse one of your addresses and overwrite an existing request. Continue anyway?'),
]
if not self.question(''.join(msg)):
return
addr = self.wallet.get_receiving_address()
else: # deterministic wallet
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)
return addr
def do_clear(self):
self.receive_e.setText('')
self.addr = self.URI = self.lnaddr = ''
self.address_help = self.URI_help = self.ln_help = ''
self.receive_widget.setVisible(False)
self.toggle_qr_button.setEnabled(False)
self.receive_message_e.setText('')
self.receive_amount_e.setAmount(None)
self.request_list.clearSelection()
class ReceiveWidget(QWidget):
min_size = QSize(200, 200)
def __init__(self, receive_tab: 'ReceiveTab', textedit: QWidget, qr: QWidget, help_widget: QWidget):
QWidget.__init__(self)
self.textedit = textedit
self.qr = qr
self.help_widget = help_widget
self.setMinimumSize(self.min_size)
for w in [textedit, qr]:
w.mousePressEvent = receive_tab.do_copy
w.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
textedit.setFocusPolicy(Qt.FocusPolicy.NoFocus)
if isinstance(help_widget, QLabel):
help_widget.setFrameStyle(QFrame.Shape.StyledPanel)
help_widget.setStyleSheet("QLabel {border:1px solid gray; border-radius:2px; }")
hbox = QHBoxLayout()
hbox.addStretch()
hbox.addWidget(textedit)
hbox.addWidget(help_widget)
hbox.addWidget(qr)
vbox = QVBoxLayout()
vbox.addLayout(hbox)
vbox.addStretch()
self.setLayout(vbox)
def update_visibility(self, is_qr):
if str(self.textedit.toPlainText()):
self.help_widget.setVisible(False)
self.textedit.setVisible(not is_qr)
self.qr.setVisible(is_qr)
else:
self.show_help()
def show_help(self):
self.help_widget.setVisible(True)
self.textedit.setVisible(False)
self.qr.setVisible(False)
def resizeEvent(self, e):
# keep square aspect ratio when resized
size = e.size()
margin = 10
x = min(size.height(), size.width()) - margin
for w in [self.textedit, self.qr, self.help_widget]:
w.setFixedWidth(x)
w.setFixedHeight(x)
return super().resizeEvent(e)
class FramedWidget(QFrame):
def __init__(self):
QFrame.__init__(self)
self.setFrameStyle(QFrame.Shape.StyledPanel)
self.setStyleSheet("FramedWidget {border:1px solid gray; border-radius:2px; }")
================================================
FILE: electrum/gui/qt/request_list.py
================================================
#!/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 enum
from typing import Optional, TYPE_CHECKING
from PyQt6.QtGui import QStandardItemModel, QStandardItem
from PyQt6.QtWidgets import QMenu, QAbstractItemView
from PyQt6.QtCore import Qt, QItemSelectionModel, QModelIndex
from electrum.i18n import _
from electrum.util import format_time
from electrum.plugin import run_hook
from .util import pr_icons, read_QIcon
from .my_treeview import MyTreeView, MySortModel
if TYPE_CHECKING:
from .receive_tab import ReceiveTab
ROLE_REQUEST_TYPE = Qt.ItemDataRole.UserRole
ROLE_KEY = Qt.ItemDataRole.UserRole + 1
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 2
class RequestList(MyTreeView):
key_role = ROLE_KEY
class Columns(MyTreeView.BaseColumnsEnum):
DATE = enum.auto()
DESCRIPTION = enum.auto()
AMOUNT = enum.auto()
STATUS = enum.auto()
ADDRESS = enum.auto()
LN_RHASH = enum.auto()
headers = {
Columns.DATE: _('Date'),
Columns.DESCRIPTION: _('Description'),
Columns.AMOUNT: _('Amount'),
Columns.STATUS: _('Status'),
Columns.ADDRESS: _('Address'),
Columns.LN_RHASH: 'LN RHASH',
}
filter_columns = [
Columns.DATE, Columns.DESCRIPTION, Columns.AMOUNT,
Columns.ADDRESS, Columns.LN_RHASH,
]
def __init__(self, receive_tab: 'ReceiveTab'):
window = receive_tab.window
super().__init__(
main_window=window,
stretch_column=self.Columns.DESCRIPTION,
)
self.wallet = window.wallet
self.receive_tab = receive_tab
self.std_model = QStandardItemModel(self)
self.proxy = MySortModel(self, sort_role=ROLE_SORT_ORDER)
self.proxy.setSourceModel(self.std_model)
self.setModel(self.proxy)
self.setSortingEnabled(True)
self.selectionModel().currentRowChanged.connect(self.item_changed)
self.selectionModel().selectionChanged.connect(self.selection_changed)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
def set_current_key(self, key):
for i in range(self.model().rowCount()):
item = self.model().index(i, self.Columns.DATE)
row_key = item.data(ROLE_KEY)
if key == row_key:
self.selectionModel().setCurrentIndex(
item, QItemSelectionModel.SelectionFlag.SelectCurrent | QItemSelectionModel.SelectionFlag.Rows)
break
def get_current_key(self):
return self.get_role_data_for_current_item(col=self.Columns.DATE, role=ROLE_KEY)
def selection_changed(self, selected, deselected):
self.receive_tab.update_current_request()
def item_changed(self, idx: Optional[QModelIndex]):
if idx is None:
self.receive_tab.update_current_request()
return
if not idx.isValid():
return
item = self.item_from_index(idx.siblingAtColumn(self.Columns.DATE))
key = item.data(ROLE_KEY)
req = self.wallet.get_request(key)
if req is None:
self.update()
self.receive_tab.update_current_request()
def clearSelection(self):
super().clearSelection()
self.selectionModel().clearCurrentIndex()
def refresh_row(self, key, row):
assert row is not None
model = self.std_model
request = self.wallet.get_request(key)
if request is None:
return
status_item = model.item(row, self.Columns.STATUS)
status = self.wallet.get_invoice_status(request)
status_str = request.get_status_str(status)
status_item.setText(status_str)
status_item.setIcon(read_QIcon(pr_icons.get(status)))
def update(self):
current_key = self.get_current_key()
# not calling maybe_defer_update() as it interferes with conditional-visibility
self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change
self.std_model.clear()
self.update_headers(self.__class__.headers)
self.set_visibility_of_columns()
for req in self.wallet.get_unpaid_requests():
key = req.get_id()
status = self.wallet.get_invoice_status(req)
status_str = req.get_status_str(status)
timestamp = req.get_time()
amount = req.get_amount_sat()
message = req.get_message()
date = format_time(timestamp)
amount_str = self.main_window.format_amount(amount) if amount else ""
amount_str_nots = self.main_window.format_amount(amount, add_thousands_sep=False) if amount else ""
labels = [""] * len(self.Columns)
labels[self.Columns.DATE] = date
labels[self.Columns.DESCRIPTION] = message
labels[self.Columns.AMOUNT] = amount_str
labels[self.Columns.STATUS] = status_str
labels[self.Columns.ADDRESS] = req.get_address() or ""
labels[self.Columns.LN_RHASH] = req.rhash if req.is_lightning() else ""
items = [QStandardItem(e) for e in labels]
self.set_editability(items)
#items[self.Columns.DATE].setData(request_type, ROLE_REQUEST_TYPE)
items[self.Columns.DATE].setData(key, ROLE_KEY)
items[self.Columns.DATE].setData(timestamp, ROLE_SORT_ORDER)
items[self.Columns.DATE].setIcon(read_QIcon("lightning" if req.is_lightning() else "bitcoin"))
items[self.Columns.AMOUNT].setData(amount_str_nots.strip(), self.ROLE_CLIPBOARD_DATA)
items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status)))
self.std_model.insertRow(self.std_model.rowCount(), items)
self.filter()
self.proxy.setDynamicSortFilter(True)
# sort requests by date
self.sortByColumn(self.Columns.DATE, Qt.SortOrder.DescendingOrder)
self.hide_if_empty()
if current_key is not None:
self.set_current_key(current_key)
def hide_if_empty(self):
b = self.std_model.rowCount() > 0
self.setVisible(b)
self.receive_tab.receive_requests_label.setVisible(b)
if not b:
# list got hidden, so selected item should also be cleared:
self.item_changed(None)
def create_menu(self, position):
items = self.selected_in_column(0)
if len(items) > 1:
keys = [item.data(ROLE_KEY) for item in items]
menu = QMenu(self)
menu.addAction(_("Delete requests"), lambda: self.delete_requests(keys))
menu.exec(self.viewport().mapToGlobal(position))
return
idx = self.indexAt(position)
item = self.item_from_index(idx.siblingAtColumn(self.Columns.DATE))
if not item:
return
key = item.data(ROLE_KEY)
req = self.wallet.get_request(key)
if req is None:
self.update()
return
menu = QMenu(self)
copy_menu = self.add_copy_menu(menu, idx)
if req.get_address():
copy_menu.addAction(_("Address"), lambda: self.main_window.do_copy(req.get_address(), title='Bitcoin Address'))
if URI := self.wallet.get_request_URI(req):
copy_menu.addAction(_("Bitcoin URI"), lambda: self.main_window.do_copy(URI, title='Bitcoin URI'))
if req.is_lightning():
copy_menu.addAction(_("Lightning Request"), lambda: self.main_window.do_copy(self.wallet.get_bolt11_invoice(req), title='Lightning Request'))
#if 'view_url' in req:
# menu.addAction(_("View in web browser"), lambda: webopen(req['view_url']))
menu.addAction(_("Delete"), lambda: self.delete_requests([key]))
run_hook('receive_list_menu', self.main_window, menu, key)
self.open_menu(menu, position)
def delete_requests(self, keys):
self.wallet.delete_requests(keys)
self.update()
self.receive_tab.do_clear()
def delete_expired_requests(self):
keys = self.wallet.delete_expired_requests()
self.update()
self.receive_tab.do_clear()
def set_visibility_of_columns(self):
def set_visible(col: int, b: bool):
self.showColumn(col) if b else self.hideColumn(col)
set_visible(self.Columns.ADDRESS, False)
set_visible(self.Columns.LN_RHASH, False)
================================================
FILE: electrum/gui/qt/seed_dialog.py
================================================
#!/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 typing import TYPE_CHECKING
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import (QVBoxLayout, QCheckBox, QHBoxLayout, QLineEdit,
QLabel, QCompleter, QDialog, QStyledItemDelegate,
QWidget, QPushButton)
from electrum.i18n import _
from electrum.mnemonic import Mnemonic, calc_seed_type, is_any_2fa_seed_type
from electrum import old_mnemonic
from electrum import slip39
from electrum.util import ChoiceItem
from .util import (
Buttons, OkButton, WWLabel, ButtonsTextEdit, icon_path, EnterButton,
CloseButton, WindowModalDialog, ColorScheme, font_height, ChoiceWidget,
)
from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit
from .completion_text_edit import CompletionTextEdit
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
MSG_PASSPHRASE_WARN_ISSUE4566 = _("Warning") + ": "\
+ _("You have multiple consecutive whitespaces or leading/trailing "
"whitespaces in your passphrase.") + " " \
+ _("This is discouraged.") + " " \
+ _("Due to a bug, old versions of Electrum will NOT be creating the "
"same wallet as newer versions or other software.")
def seed_warning_msg(seed):
return ''.join([
"
",
_("Please save these {0} words on paper (order is important). "),
_("This seed will allow you to recover your wallet in case "
"of computer failure."),
"
",
"" + _("WARNING") + ":",
"
",
"
" + _("Never disclose your seed.") + "
",
"
" + _("Never type it on a website.") + "
",
"
" + _("Do not store it electronically.") + "
",
"
"
]).format(len(seed.split()))
class SeedWidget(QWidget):
updated = pyqtSignal()
validChanged = pyqtSignal([bool], arguments=['valid'])
def __init__(
self,
seed=None,
title=None,
icon=True,
msg=None,
options=None,
is_seed=None, # only used for electrum seeds
passphrase=None,
parent=None,
for_seed_words=True,
*,
config: 'SimpleConfig',
):
QWidget.__init__(self, parent)
vbox = QVBoxLayout()
self.setLayout(vbox)
self.options = options
self.config = config
self.msg = msg
if options:
self.seed_types = [
ChoiceItem(key=stype, label=label) for stype, label in (
('electrum', 'Electrum'),
('bip39', _('BIP39 seed')),
('slip39', _('SLIP39 seed')),
)
if stype in self.options
]
assert len(self.seed_types)
self.seed_type = self.seed_types[0].key
else:
self.seed_type = 'electrum'
self.is_seed = is_seed
if title:
vbox.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(config=self.config)
self.seed_e.addCopyButton()
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.seed_e.textChanged.connect(self.on_edit)
self.initialize_completer()
self.seed_e.setMaximumHeight(max(75, 5 * font_height()))
hbox = QHBoxLayout()
if icon:
logo = QLabel()
logo.setPixmap(QPixmap(icon_path("seed.png"))
.scaledToWidth(64, mode=Qt.TransformationMode.SmoothTransformation))
logo.setMaximumWidth(60)
hbox.addWidget(logo)
hbox.addWidget(self.seed_e)
vbox.addLayout(hbox)
hbox = QHBoxLayout()
hbox.addStretch(1)
self.seed_type_label = QLabel('')
hbox.addWidget(self.seed_type_label)
# options
self.is_ext = False
if options:
opt_button = EnterButton(_('Options'), self.seed_options)
hbox.addWidget(opt_button)
vbox.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)
vbox.addLayout(hbox)
# slip39 shares
self.slip39_mnemonic_index = 0
self.slip39_mnemonics = [""]
self.slip39_seed = None
self.slip39_current_mnemonic_invalid = None
hbox = QHBoxLayout()
hbox.addStretch(1)
self.prev_share_btn = QPushButton(_("Previous share"))
self.prev_share_btn.clicked.connect(self.on_prev_share)
hbox.addWidget(self.prev_share_btn)
self.next_share_btn = QPushButton(_("Next share"))
self.next_share_btn.clicked.connect(self.on_next_share)
hbox.addWidget(self.next_share_btn)
self.update_share_buttons()
vbox.addLayout(hbox)
vbox.addStretch(1)
self.seed_status = WWLabel('')
vbox.addWidget(self.seed_status)
self.seed_warning = WWLabel('')
if msg:
self.seed_warning.setText(seed_warning_msg(seed))
else:
self.update_seed_warning()
vbox.addWidget(self.seed_warning)
def seed_options(self):
dialog = QDialog()
dialog.setWindowTitle(_("Seed Options"))
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)
def on_selected(idx):
self.seed_type = seed_type_choice.selected_key
self.slip39_current_mnemonic_invalid = None
self.seed_status.setText('')
self.update_seed_warning()
self.on_edit()
self.update_share_buttons()
self.initialize_completer()
if len(self.seed_types) > 1:
seed_type_choice = ChoiceWidget(message=_('Seed type'), choices=self.seed_types, default_key=self.seed_type)
seed_type_choice.itemSelected.connect(on_selected)
vbox.addWidget(seed_type_choice)
vbox.addLayout(Buttons(OkButton(dialog)))
if not dialog.exec():
return None
if 'ext' in self.options:
self.is_ext = cb_ext.isChecked()
if len(self.seed_types) > 1:
self.seed_type = seed_type_choice.selected_key
self.update_seed_warning()
self.updated.emit()
def update_seed_warning(self):
if self.msg:
return
if self.seed_type == 'bip39':
message = ' '.join([
'' + _('Warning') + ': ',
_('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.'),
])
elif self.seed_type == 'slip39':
message = ' '.join([
'' + _('Warning') + ': ',
_('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'),
_('However, we do not generate SLIP39 seeds.'),
])
else:
message = ''
self.seed_warning.setText(message)
def initialize_completer(self):
if self.seed_type != 'slip39':
bip39_english_list = Mnemonic('en').wordlist
old_list = old_mnemonic.wordlist
only_old_list = set(old_list) - set(bip39_english_list)
self.wordlist = list(bip39_english_list) + list(only_old_list) # concat both lists
self.wordlist.sort()
class CompleterDelegate(QStyledItemDelegate):
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
# Some people complained that due to merging the two word lists,
# it is difficult to restore from a metal backup, as they planned
# to rely on the "4 letter prefixes are unique in bip39 word list" property.
# So we color words that are only in old list.
if option.text in only_old_list:
# yellow bg looks ~ok on both light/dark theme, regardless if (un)selected
option.backgroundBrush = ColorScheme.YELLOW.as_color(background=True)
delegate = CompleterDelegate(self.seed_e)
else:
self.wordlist = list(slip39.get_wordlist())
delegate = None
self.completer = QCompleter(self.wordlist)
if delegate:
self.completer.popup().setItemDelegate(delegate)
self.seed_e.set_completer(self.completer)
def get_seed_words(self):
return self.seed_e.text().split()
def get_seed(self):
if self.seed_type != 'slip39':
return ' '.join(self.get_seed_words())
else:
return self.slip39_seed
def on_edit(self):
s = ' '.join(self.get_seed_words())
if self.seed_type == 'bip39':
from electrum.keystore import bip39_is_checksum_valid
is_checksum, is_wordlist = bip39_is_checksum_valid(s)
label = ''
valid = bool(s)
if valid:
label = ('' if is_checksum else _('BIP39 checksum failed')) if is_wordlist else _('Unknown BIP39 wordlist')
elif self.seed_type == 'slip39':
self.slip39_mnemonics[self.slip39_mnemonic_index] = s
try:
slip39.decode_mnemonic(s)
except slip39.Slip39Error as e:
share_status = str(e)
current_mnemonic_invalid = True
else:
share_status = _('Valid.')
current_mnemonic_invalid = False
label = _('SLIP39 share') + ' #%d: %s' % (self.slip39_mnemonic_index + 1, share_status)
# No need to process mnemonics if the current mnemonic remains invalid after editing.
if not (self.slip39_current_mnemonic_invalid and current_mnemonic_invalid):
self.slip39_seed, seed_status = slip39.process_mnemonics(self.slip39_mnemonics)
self.seed_status.setText(seed_status)
self.slip39_current_mnemonic_invalid = current_mnemonic_invalid
valid = self.slip39_seed is not None
self.update_share_buttons()
else:
valid = self.is_seed(s)
t = calc_seed_type(s)
label = _('Seed Type') + ': ' + t if t else ''
if t and not valid: # electrum seed, but does not conform to dialog rules
wiztype_fullname = _('Wallet with two-factor authentication') if is_any_2fa_seed_type(t) else _("Standard wallet")
msg = ' '.join([
'' + _('Warning') + ': ',
_("Looks like you have entered a valid seed of type '{}' but this dialog does not support such seeds.").format(t),
_("If unsure, try restoring as '{}'.").format(wiztype_fullname),
])
self.seed_warning.setText(msg)
else:
self.seed_warning.setText("")
self.seed_type_label.setText(label)
self.validChanged.emit(valid)
# disable suggestions if user already typed an unknown word
for word in self.get_seed_words()[:-1]:
if word not in self.wordlist:
self.seed_e.disable_suggestions()
return
self.seed_e.enable_suggestions()
def update_share_buttons(self):
if self.seed_type != 'slip39':
self.prev_share_btn.hide()
self.next_share_btn.hide()
return
finished = self.slip39_seed is not None
self.prev_share_btn.show()
self.next_share_btn.show()
self.prev_share_btn.setEnabled(self.slip39_mnemonic_index != 0)
self.next_share_btn.setEnabled(
# already pressed "prev" and undoing that:
self.slip39_mnemonic_index < len(self.slip39_mnemonics) - 1
# finished entering latest share and starting new one:
or (bool(self.seed_e.text().strip()) and not self.slip39_current_mnemonic_invalid and not finished)
)
def on_prev_share(self):
if not self.slip39_mnemonics[self.slip39_mnemonic_index]:
del self.slip39_mnemonics[self.slip39_mnemonic_index]
self.slip39_mnemonic_index -= 1
self.seed_e.setText(self.slip39_mnemonics[self.slip39_mnemonic_index])
self.slip39_current_mnemonic_invalid = None
def on_next_share(self):
if not self.slip39_mnemonics[self.slip39_mnemonic_index]:
del self.slip39_mnemonics[self.slip39_mnemonic_index]
else:
self.slip39_mnemonic_index += 1
if len(self.slip39_mnemonics) <= self.slip39_mnemonic_index:
self.slip39_mnemonics.append("")
self.seed_e.setFocus()
self.seed_e.setText(self.slip39_mnemonics[self.slip39_mnemonic_index])
self.slip39_current_mnemonic_invalid = None
class KeysWidget(QWidget):
validChanged = pyqtSignal([bool], arguments=['valid'])
def __init__(
self,
parent=None,
header_layout=None,
is_valid=None,
allow_multi=False,
*,
config: 'SimpleConfig',
):
QWidget.__init__(self, parent)
vbox = QVBoxLayout()
self.setLayout(vbox)
self.is_valid = is_valid
self.text_e = ScanQRTextEdit(allow_multi=allow_multi, config=config)
self.text_e.textChanged.connect(self.on_edit)
if isinstance(header_layout, str):
vbox.addWidget(WWLabel(header_layout))
else:
vbox.addLayout(header_layout)
vbox.addWidget(self.text_e)
def get_text(self):
return self.text_e.text()
def on_edit(self):
try:
valid = self.is_valid(self.get_text())
except Exception as e:
valid = False
self.validChanged.emit(valid)
class SeedDialog(WindowModalDialog):
def __init__(self, parent, seed, passphrase, *, config: 'SimpleConfig'):
WindowModalDialog.__init__(self, parent, ('Electrum - ' + _('Seed')))
self.setMinimumWidth(400)
vbox = QVBoxLayout(self)
title = _("Your wallet generation seed is:")
seed_widget = SeedWidget(title=title, seed=seed, msg=True, passphrase=passphrase, config=config)
vbox.addWidget(seed_widget)
vbox.addLayout(Buttons(CloseButton(self)))
================================================
FILE: electrum/gui/qt/send_tab.py
================================================
# Copyright (C) 2022 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
from decimal import Decimal
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Union, Mapping
import urllib.parse
from PyQt6.QtCore import pyqtSignal, QPoint, Qt
from PyQt6.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout,
QWidget, QToolTip, QPushButton, QApplication)
from electrum.i18n import _
from electrum.logging import Logger
from electrum.bitcoin import DummyAddress
from electrum.plugin import run_hook
from electrum.util import (
NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend, UserCancelled, ChoiceItem,
UserFacingException,
)
from electrum.lnutil import RECEIVED
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput
from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.payment_identifier import (PaymentIdentifierType, PaymentIdentifier,
invoice_from_payment_identifier,
payment_identifier_from_invoice, PaymentIdentifierState)
from electrum.submarine_swaps import SwapServerError
from electrum.fee_policy import FeePolicy, FixedFeePolicy
from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .paytoedit import InvalidPaymentIdentifier
from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit,
get_icon_camera, read_QIcon, ColorScheme, IconLabel, Spinner, Buttons, WWLabel,
add_input_actions_to_context_menu, WindowModalDialog, OkButton, CancelButton)
from .invoice_list import InvoiceList
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class SendTab(QWidget, MessageBoxMixin, Logger):
resolve_done_signal = pyqtSignal(object)
finalize_done_signal = pyqtSignal(object)
notify_merchant_done_signal = pyqtSignal(object)
def __init__(self, window: 'ElectrumWindow'):
QWidget.__init__(self, window)
Logger.__init__(self)
self.app = QApplication.instance()
self.window = window
self.wallet = window.wallet
self.fx = window.fx
self.config = window.config
self.network = window.network
self.format_amount_and_units = window.format_amount_and_units
self.format_amount = window.format_amount
self.base_unit = window.base_unit
self.pending_invoice = None
# 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.window.get_decimal_point)
self.payto_e = PayToEdit(self)
msg = (_("Recipient of the funds.")
+ "\n\n"
+ _("This field can contain:") + "\n"
+ _("- a Bitcoin address or BIP21 URI") + "\n"
+ _("- a Lightning invoice") + "\n"
+ _("- a label from your list of contacts") + "\n"
+ _("- an openalias") + "\n"
+ _("- an arbitrary on-chain script, e.g.:") + " script(OP_RETURN deadbeef)" + "\n"
+ "\n"
+ _("You can also pay to many outputs in a single transaction, "
"specifying one output per line.") + "\n" + _("Format: address, amount") + "\n"
+ _("To set the amount to 'max', use the '!' special character.") + "\n"
+ _("Integers weights can also be used in conjunction with '!', "
"e.g. set one amount to '2!' and another to '3!' to split your coins 40-60."))
self.payto_label = HelpLabel(_('Pay to'), msg)
grid.addWidget(self.payto_label, 0, 0, Qt.AlignmentFlag.AlignLeft)
grid.addWidget(self.payto_e, 0, 1, 1, 4)
#completer = QCompleter()
#completer.setCaseSensitivity(False)
#self.payto_e.set_completer(completer)
#completer.setModel(self.window.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, 1, 0)
self.message_e = SizedFreezableLineEdit(width=600)
grid.addWidget(self.message_e, 1, 1, 1, 4)
msg = _('Comment for recipient')
self.comment_label = HelpLabel(_('Comment'), msg)
grid.addWidget(self.comment_label, 2, 0)
self.comment_e = SizedFreezableLineEdit(width=600)
grid.addWidget(self.comment_e, 2, 1, 1, 4)
self.comment_label.hide()
self.comment_e.hide()
msg = (_('The amount to be received by the recipient.') + ' '
+ _('Fees are paid by the sender.') + '\n\n'
+ _('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, 3, 0)
amount_widgets = QHBoxLayout()
amount_widgets.addWidget(self.amount_e)
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)
amount_widgets.addWidget(self.fiat_send_e)
self.amount_e.frozen.connect(
lambda: self.fiat_send_e.setFrozen(self.amount_e.isReadOnly()))
self.window.connect_fields(self.amount_e, self.fiat_send_e)
self.max_button = EnterButton(_("Max"), self.spend_max)
btn_width = 10 * char_width_in_lineedit()
self.max_button.setFixedWidth(btn_width)
self.max_button.setCheckable(True)
self.max_button.setEnabled(False)
amount_widgets.addWidget(self.max_button)
amount_widgets.addStretch(1)
grid.addLayout(amount_widgets, 3, 1, 1, -1)
invoice_error_icon = read_QIcon("warning.png")
self.invoice_error = IconLabel(reverse=True, hide_if_empty=True)
self.invoice_error.setIcon(invoice_error_icon)
grid.addWidget(self.invoice_error, 3, 4, Qt.AlignmentFlag.AlignRight)
self.paste_button = QPushButton(_('Paste'))
self.paste_button.clicked.connect(self.do_paste)
self.paste_button.setIcon(read_QIcon('copy.png'))
self.paste_button.setToolTip(_('Paste invoice from clipboard'))
self.paste_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.spinner = Spinner()
grid.addWidget(self.spinner, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight)
self.save_button = EnterButton(_("Save"), self.do_save_invoice)
self.save_button.setEnabled(False)
self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice)
self.send_button.setEnabled(False)
self.clear_button = EnterButton(_("Clear"), self.do_clear)
#buttons1 = QHBoxLayout()
#buttons1.addWidget(self.paste_button)
#buttons1.addWidget(self.clear_button)
#buttons1.addStretch(1)
#grid.addLayout(buttons1, 0, 1, 1, 4)
buttons = QHBoxLayout()
buttons.addWidget(self.paste_button)
buttons.addWidget(self.clear_button)
buttons.addStretch(1)
buttons.addWidget(self.save_button)
buttons.addWidget(self.send_button)
grid.addLayout(buttons, 6, 1, 1, 4)
self.amount_e.shortcut.connect(self.spend_max)
def reset_max(text):
self.max_button.setChecked(False)
self.amount_e.textChanged.connect(self.on_amount_changed)
self.amount_e.textEdited.connect(reset_max)
self.fiat_send_e.textEdited.connect(reset_max)
self.invoices_label = QLabel(_('Invoices'))
self.invoice_list = InvoiceList(self)
self.toolbar, menu = self.invoice_list.create_toolbar_with_menu('')
add_input_actions_to_context_menu(self.payto_e, menu)
self.paytomany_menu = menu.addToggle(_("&Pay to many"), self.toggle_paytomany)
menu.addSeparator()
menu.addAction(_("Import invoices"), self.window.import_invoices)
menu.addAction(_("Export invoices"), self.window.export_invoices)
vbox0 = QVBoxLayout()
vbox0.addLayout(grid)
hbox = QHBoxLayout()
hbox.addLayout(vbox0)
hbox.addStretch(1)
vbox = QVBoxLayout(self)
vbox.addLayout(self.toolbar)
vbox.addLayout(hbox)
vbox.addStretch(1)
vbox.addWidget(self.invoices_label)
vbox.addWidget(self.invoice_list)
vbox.setStretchFactor(self.invoice_list, 1000)
self.searchable_list = self.invoice_list
self.invoice_list.update() # after parented and put into a layout, can update without flickering
run_hook('create_send_tab', grid)
self.resolve_done_signal.connect(self.on_resolve_done)
self.finalize_done_signal.connect(self.on_finalize_done)
self.notify_merchant_done_signal.connect(self.on_notify_merchant_done)
self.payto_e.paymentIdentifierChanged.connect(self._handle_payment_identifier)
self.setTabOrder(self.send_button, self.invoice_list)
def on_amount_changed(self, text):
# FIXME: implement full valid amount check to enable/disable Pay button
pi = self.payto_e.payment_identifier
if not pi:
self.send_button.setEnabled(False)
return
pi_error = pi.is_error() if pi.is_valid() else False
is_spk_script = pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address
valid_amount = is_spk_script or bool(self.amount_e.get_amount())
ready_to_finalize = not pi.need_resolve()
self.send_button.setEnabled(pi.is_valid() and not pi_error and valid_amount and ready_to_finalize)
def do_paste(self):
self.logger.debug('do_paste')
try:
self.payto_e.try_payment_identifier(self.app.clipboard().text())
except InvalidPaymentIdentifier as e:
self.show_error(_('Invalid payment identifier on clipboard'))
def set_payment_identifier(self, text):
self.logger.debug('set_payment_identifier')
try:
self.payto_e.try_payment_identifier(text)
except InvalidPaymentIdentifier as e:
self.show_error(_('Invalid payment identifier'))
def spend_max(self):
pi = self.payto_e.payment_identifier
if pi is None or pi.type == PaymentIdentifierType.UNKNOWN:
return
elif pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE,
PaymentIdentifierType.BIP21, PaymentIdentifierType.OPENALIAS]:
# clear the amount field once it is clear this PI is not eligible for '!'
self.amount_e.clear()
return
if pi.type == PaymentIdentifierType.BIP21:
assert 'amount' not in pi.bip21
if run_hook('abort_send', self):
return
outputs = pi.get_onchain_outputs('!')
if not outputs:
return
make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
fee_policy=fee_policy,
coins=self.window.get_coins(),
outputs=outputs,
is_sweep=False)
try:
try:
tx = make_tx(FeePolicy(self.config.FEE_POLICY))
except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
# Check if we had enough funds excluding fees,
# if so, still provide opportunity to set lower fees.
tx = make_tx(FixedFeePolicy(0))
except NotEnoughFunds as e:
self.max_button.setChecked(False)
text = self.wallet.get_text_not_enough_funds_mentioning_frozen(for_amount='!')
self.show_error(text)
return
self.max_button.setChecked(True)
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)
# show tooltip explaining max amount
mining_fee = tx.get_fee()
mining_fee_str = self.format_amount_and_units(mining_fee)
msg = _("Mining fee: {} (can be adjusted on next screen)").format(mining_fee_str)
if x_fee_amount:
twofactor_fee_str = self.format_amount_and_units(x_fee_amount)
msg += "\n" + _("2fa fee: {} (for the next batch of transactions)").format(twofactor_fee_str)
frozen_bal = self.wallet.get_frozen_balance_str()
if frozen_bal:
msg += "\n" + _("Some coins are frozen: {} (can be unfrozen in the Addresses or in the Coins tab)").format(frozen_bal)
QToolTip.showText(self.max_button.mapToGlobal(QPoint(0, 0)), msg)
# TODO: instead of passing outputs, use an invoice instead (like pay_lightning_invoice)
# so we have more context (we cannot rely on send_tab field contents or payment identifier
# as this method is called from other places as well).
def pay_onchain_dialog(
self,
outputs: List[PartialTxOutput],
*,
nonlocal_only=False,
external_keypairs: Mapping[bytes, bytes] = None,
get_coins: Callable[..., Sequence[PartialTxInput]] = None,
invoice: Optional[Invoice] = None
) -> None:
# trustedcoin requires this
if run_hook('abort_send', self):
return
is_sweep = bool(external_keypairs)
# we call get_coins inside make_tx, so that inputs can be changed dynamically
if get_coins is None:
get_coins = self.window.get_coins
def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
coins = get_coins(nonlocal_only=nonlocal_only, confirmed_only=confirmed_only)
return self.wallet.make_unsigned_transaction(
fee_policy=fee_policy,
coins=coins,
outputs=outputs,
base_tx=base_tx,
is_sweep=is_sweep,
send_change_to_lightning=self.config.WALLET_SEND_CHANGE_TO_LIGHTNING,
merge_duplicate_outputs=self.config.WALLET_MERGE_DUPLICATE_OUTPUTS,
)
output_values = [x.value for x in outputs]
is_max = any(parse_max_spend(outval) for outval in output_values)
output_value = '!' if is_max else sum(output_values)
# To find batching candidates, we need to know our available UTXOs.
# Ideally should use same set of coins make_tx() will use.
# note: - prone to races: coins set might change due to new txs between now and make_tx() call
# - make_tx() might pass different params to get_coins()
# - to mitigate, we prefer to be more restrictive. hence confirmed_only=True
coins_conservative = get_coins(nonlocal_only=True, confirmed_only=True)
candidates = self.wallet.get_candidates_for_batching(outputs, coins=coins_conservative)
tx, is_preview, paid_with_swap = self.window.confirm_tx_dialog(
make_tx,
output_value,
payee_outputs=[o for o in outputs if not o.is_change],
batching_candidates=candidates,
)
if tx is None:
if paid_with_swap:
self.do_clear()
# user cancelled or paid with swap
return
if is_preview:
self.window.show_transaction(
tx,
external_keypairs=external_keypairs,
invoice=invoice,
show_sign_button=self.wallet.wallet_type != '2fa',
show_broadcast_button=self.wallet.wallet_type != '2fa',
)
return
self.save_pending_invoice()
def sign_done(success):
if success:
self.window.broadcast_or_show(tx, invoice=invoice)
self.window.sign_tx(
tx,
callback=sign_done,
external_keypairs=external_keypairs)
def do_clear(self):
self.logger.debug('do_clear')
self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False)
self.max_button.setChecked(False)
self.payto_e.do_clear()
for w in [self.comment_e, self.comment_label]:
w.setVisible(False)
for w in [self.message_e, self.amount_e, self.fiat_send_e, self.comment_e]:
w.setText('')
w.setToolTip('')
for w in [self.save_button, self.send_button]:
w.setEnabled(False)
self.window.update_status()
self.paytomany_menu.setChecked(self.payto_e.multiline)
self.invoice_error.setText('')
run_hook('do_clear', self)
def prepare_for_send_tab_network_lookup(self):
for btn in [self.save_button, self.send_button, self.clear_button]:
btn.setEnabled(False)
self.spinner.setVisible(True)
def payment_request_error(self, error):
self.show_message(error)
self.do_clear()
def set_field_validated(self, w, *, validated: Optional[bool] = None):
if validated is not None:
w.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True) if validated else ColorScheme.RED.as_stylesheet(True))
def lock_fields(
self, *,
lock_recipient: Optional[bool] = None,
lock_amount: Optional[bool] = None,
lock_max: Optional[bool] = None,
lock_description: Optional[bool] = None
) -> None:
self.logger.debug(f'locking fields, r={lock_recipient}, a={lock_amount}, m={lock_max}, d={lock_description}')
if lock_recipient is not None:
self.payto_e.setFrozen(lock_recipient)
if lock_amount is not None:
self.amount_e.setFrozen(lock_amount)
if lock_max is not None:
self.max_button.setEnabled(not lock_max)
if lock_max is True:
self.max_button.setChecked(False)
if lock_description is not None:
self.message_e.setFrozen(lock_description)
def update_fields(self):
self.logger.debug('update_fields')
pi = self.payto_e.payment_identifier
self.clear_button.setEnabled(True)
if pi.is_multiline():
self.lock_fields(lock_recipient=False, lock_amount=True, lock_max=True, lock_description=False)
self.set_field_validated(self.payto_e, validated=pi.is_valid()) # TODO: validated used differently here than openalias
self.save_button.setEnabled(pi.is_valid())
self.send_button.setEnabled(pi.is_valid())
self.payto_e.setToolTip(pi.get_error() if not pi.is_valid() else '')
if pi.is_valid():
self.handle_multiline(pi.multiline_outputs)
return
if not pi.is_valid():
self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False)
self.save_button.setEnabled(False)
self.send_button.setEnabled(False)
return
lock_recipient = pi.type in [PaymentIdentifierType.LNURL, PaymentIdentifierType.LNURLW,
PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR,
PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BIP70,
PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve()
lock_amount = pi.is_amount_locked()
lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21]
self.lock_fields(lock_recipient=lock_recipient,
lock_amount=lock_amount,
lock_max=lock_max,
lock_description=False)
if lock_recipient:
fields = pi.get_fields_for_GUI()
if fields.recipient:
self.payto_e.setText(fields.recipient)
if fields.description:
self.message_e.setText(fields.description)
self.lock_fields(lock_description=True)
if fields.amount:
self.amount_e.setAmount(fields.amount)
for w in [self.comment_e, self.comment_label]:
w.setVisible(bool(fields.comment))
if fields.comment:
self.comment_e.setToolTip(_('Max comment length: {} characters').format(fields.comment))
self.set_field_validated(self.payto_e, validated=fields.validated)
# LNURLp amount range
if fields.amount_range:
amin, amax = fields.amount_range
self.amount_e.setToolTip(_('Amount must be between {} and {} sat.').format(amin, amax))
else:
self.amount_e.setToolTip('')
# resolve '!' in amount editor if it was set before PI
if not lock_max and self.amount_e.text() == '!':
self.spend_max()
elif lock_max and self.amount_e.text() == '!':
self.amount_e.clear()
pi_unusable = pi.is_error() or (not self.wallet.has_lightning() and not pi.is_onchain())
is_spk_script = pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address
amount_valid = is_spk_script or bool(self.amount_e.get_amount())
self.send_button.setEnabled(not pi_unusable and amount_valid and not pi.has_expired())
self.save_button.setEnabled(not pi_unusable and not is_spk_script and not pi.has_expired() and \
pi.type not in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR])
self.invoice_error.setText(_('Expired') if pi.has_expired() else '')
def _handle_payment_identifier(self):
self.update_fields()
if not self.payto_e.payment_identifier.is_valid():
self.logger.debug(f'PI error: {self.payto_e.payment_identifier.error}')
return
if self.payto_e.payment_identifier.need_resolve():
self.prepare_for_send_tab_network_lookup()
self.payto_e.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit)
def on_resolve_done(self, pi: 'PaymentIdentifier'):
# TODO: resolve can happen while typing, we don't want message dialogs to pop up
# currently we don't set error for emaillike recipients to avoid just that
self.logger.debug('payment identifier resolve done')
self.spinner.setVisible(False)
if pi.error:
self.show_error(pi.error)
self.do_clear()
return
if pi.type == PaymentIdentifierType.LNURLW:
assert pi.state == PaymentIdentifierState.LNURLW_FINALIZE, \
f"Detected LNURLW but not ready to finalize? {pi=}"
self.do_clear()
self.request_lnurl_withdraw_dialog(pi.lnurl_data)
return
# if openalias add openalias to contacts
if pi.type == PaymentIdentifierType.OPENALIAS:
key = pi.emaillike if pi.emaillike else pi.domainlike
pi.contacts[key] = ('openalias', pi.openalias_data.get('name'))
self.update_fields()
def get_message(self):
return self.message_e.text()
def read_invoice(self) -> Optional[Invoice]:
if self.check_payto_line_and_show_errors():
return
amount_sat = self.read_amount()
invoice = invoice_from_payment_identifier(
self.payto_e.payment_identifier, self.wallet, amount_sat, self.get_message())
if not invoice:
self.show_error('error getting invoice' + self.payto_e.payment_identifier.error)
return
if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain():
self.show_error(_('Lightning is disabled'))
if self.wallet.get_invoice_status(invoice) == PR_PAID:
# fixme: this is only for bip70 and lightning
self.show_error(_('Invoice already paid'))
return
#if not invoice.is_lightning():
# if self.check_onchain_outputs_and_show_errors(outputs):
# return
return invoice
def do_save_invoice(self):
self.pending_invoice = self.read_invoice()
if not self.pending_invoice:
return
self.save_pending_invoice()
def save_pending_invoice(self):
if not self.pending_invoice:
return
self.do_clear()
self.wallet.save_invoice(self.pending_invoice)
self.invoice_list.update()
self.pending_invoice = None
def get_amount(self) -> int:
# must not be None
return self.amount_e.get_amount() or 0
def on_finalize_done(self, pi: PaymentIdentifier):
self.spinner.setVisible(False)
self.update_fields()
if pi.error:
self.show_error(pi.error)
return
invoice = pi.bolt11
self.pending_invoice = invoice
self.logger.debug(f'after finalize invoice: {invoice!r}')
self.do_pay_invoice(invoice)
def do_pay_or_get_invoice(self):
pi = self.payto_e.payment_identifier
if pi.need_finalize():
self.prepare_for_send_tab_network_lookup()
pi.finalize(amount_sat=self.get_amount(), comment=self.comment_e.text(),
on_finished=self.finalize_done_signal.emit)
return
self.pending_invoice = self.read_invoice()
if not self.pending_invoice:
return
self.do_pay_invoice(self.pending_invoice)
def pay_multiple_invoices(self, invoices):
outputs = []
for invoice in invoices:
outputs += invoice.outputs
self.pay_onchain_dialog(outputs)
def do_edit_invoice(self, invoice: 'Invoice'): # FIXME broken
assert not bool(invoice.get_amount_sat())
text = invoice.lightning_invoice if invoice.is_lightning() else invoice.get_address()
self.set_payment_identifier(text)
self.amount_e.setFocus()
# disable save button, because it would create a new invoice
self.save_button.setEnabled(False)
def do_pay_invoice(self, invoice: 'Invoice'):
if not bool(invoice.get_amount_sat()):
pi = self.payto_e.payment_identifier
if pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address:
pass
else:
self.show_error(_('No amount'))
return
if invoice.is_lightning():
self.pay_lightning_invoice(invoice)
else:
self.pay_onchain_dialog(invoice.outputs, invoice=invoice)
def read_amount(self) -> Union[int, str]:
amount = '!' if self.max_button.isChecked() else self.get_amount()
return amount
def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool:
"""Returns whether there are errors with outputs.
Also shows error dialog to user if so.
"""
if not outputs:
self.show_error(_('No outputs'))
return True
for o in outputs:
if o.scriptpubkey is None:
self.show_error(_('Bitcoin Address is None'))
return True
if o.value is None:
self.show_error(_('Invalid Amount'))
return True
return False # no errors
def check_payto_line_and_show_errors(self) -> bool:
"""Returns whether there are errors.
Also shows error dialog to user if so.
"""
error = self.payto_e.payment_identifier.get_error()
if error:
if not self.payto_e.payment_identifier.is_multiline():
err = error
self.show_warning(
_("Failed to parse 'Pay to' line") + ":\n" +
f"{err.line_content[:40]}...\n\n"
f"{err.exc!r}")
else:
self.show_warning(
_("Invalid Lines found:") + "\n\n" + error)
#'\n'.join([_("Line #") +
# f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})"
# for err in errors]))
return True
warning = self.payto_e.payment_identifier.warning
if warning:
warning += '\n' + _('Do you wish to continue?')
if not self.question(warning):
return True
if self.payto_e.payment_identifier.has_expired():
self.show_error(_('Payment request has expired'))
return True
return False # no errors
def pay_lightning_invoice(self, invoice: Invoice):
amount_sat = invoice.get_amount_sat()
if amount_sat is None:
raise Exception("missing amount for LN invoice")
# note: lnworker might be None if LN is disabled,
# in which case we should still offer the user to pay onchain.
lnworker = self.wallet.lnworker
if lnworker is None or not lnworker.can_pay_invoice(invoice):
coins = self.window.get_coins(nonlocal_only=True)
can_pay_with_new_channel = False
can_pay_with_swap = False
can_rebalance = False
if lnworker:
can_pay_with_new_channel = lnworker.suggest_funding_amount(amount_sat, coins=coins)
can_pay_with_swap = lnworker.suggest_swap_to_send(amount_sat, coins=coins)
rebalance_suggestion = lnworker.suggest_rebalance_to_send(amount_sat)
can_rebalance = bool(rebalance_suggestion) and self.window.num_tasks() == 0
choices = [] # type: List[ChoiceItem]
if can_rebalance:
msg = ''.join([
_('Rebalance existing channels'), '\n',
_('Move funds between your channels in order to increase your sending capacity.')
])
choices.append(ChoiceItem(key='rebalance', label=msg))
if can_pay_with_new_channel:
msg = ''.join([
_('Open a new channel'), '\n',
_('You will be able to pay once the channel is open.')
])
choices.append(ChoiceItem(key='new_channel', label=msg))
if can_pay_with_swap:
msg = ''.join([
_('Swap onchain funds for lightning funds'), '\n',
_('You will be able to pay once the swap is confirmed.')
])
choices.append(ChoiceItem(key='swap', label=msg))
msg = _('You cannot pay that invoice using Lightning.')
if lnworker and lnworker.channels:
num_sats_can_send = int(lnworker.num_sats_can_send())
msg += '\n' + _('Your channels can send {}.').format(self.format_amount(num_sats_can_send) + ' ' + self.base_unit())
if not choices:
self.window.show_error(msg)
return
r = self.window.query_choice(msg, choices)
if r is not None:
self.save_pending_invoice()
if r == 'rebalance':
chan1, chan2, delta = rebalance_suggestion
self.window.rebalance_dialog(chan1, chan2, amount_sat=delta)
elif r == 'new_channel':
amount_sat, min_amount_sat = can_pay_with_new_channel
self.window.new_channel_dialog(amount_sat=amount_sat, min_amount_sat=min_amount_sat)
elif r == 'swap':
chan, swap_recv_amount_sat = can_pay_with_swap
self.window.run_swap_dialog(is_reverse=False, recv_amount_sat_or_max=swap_recv_amount_sat, channels=[chan])
elif r == 'onchain':
self.pay_onchain_dialog(invoice.get_outputs(), nonlocal_only=True, invoice=invoice)
return
assert lnworker is not None
# FIXME this is currently lying to user as we truncate to satoshis
amount_msat = invoice.get_amount_msat()
msg = _("Pay lightning invoice?") + '\n\n' + _("This will send {}?").format(self.format_amount_and_units(Decimal(amount_msat)/1000))
if not self.question(msg):
return
self.save_pending_invoice()
coro = lnworker.pay_invoice(invoice, amount_msat=amount_msat)
self.window.run_coroutine_from_thread(coro, _('Sending payment'))
def broadcast_transaction(self, tx: Transaction, *, invoice: Invoice = None):
if hasattr(tx, 'swap_payment_hash'):
sm = self.wallet.lnworker.swap_manager
swap = sm.get_swap(tx.swap_payment_hash)
with sm.create_transport() as transport:
coro = sm.wait_for_htlcs_and_broadcast(
transport=transport, swap=swap, invoice=tx.swap_invoice, tx=tx)
try:
funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting lightning payment...'))
except UserCancelled:
sm.cancel_normal_swap(swap)
return
self.window.on_swap_result(funding_txid, is_reverse=False)
def broadcast_thread():
# non-GUI thread
if invoice and invoice.has_expired():
return False, _("Invoice has expired")
try:
self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
except TxBroadcastError as e:
return False, e.get_message_for_gui()
except BestEffortRequestFailed as e:
return False, repr(e)
# success
if invoice and invoice.bip70:
payment_identifier = payment_identifier_from_invoice(invoice)
# FIXME: this should move to backend
if payment_identifier and payment_identifier.need_merchant_notify():
refund_address = self.wallet.get_receiving_address()
payment_identifier.notify_merchant(
tx=tx,
refund_address=refund_address,
on_finished=self.notify_merchant_done_signal.emit
)
return True, tx.txid()
# Capture current TL window; override might be removed on return
parent = self.window.top_level_window(lambda win: isinstance(win, MessageBoxMixin))
# FIXME: move to backend and let Abstract_Wallet set broadcasting state, not gui
self.wallet.set_broadcasting(tx, broadcasting_status=PR_BROADCASTING)
def broadcast_done(result):
# GUI thread
if result:
success, msg = result
if success:
parent.show_message(_('Payment sent.') + '\n' + msg)
self.invoice_list.update()
self.wallet.set_broadcasting(tx, broadcasting_status=PR_BROADCAST)
else:
msg = msg or ''
parent.show_error(msg)
self.wallet.set_broadcasting(tx, broadcasting_status=None)
WaitingDialog(self, _('Broadcasting transaction...'),
broadcast_thread, broadcast_done, self.window.on_error)
def on_notify_merchant_done(self, pi: PaymentIdentifier):
if pi.is_error():
self.logger.debug(f'merchant notify error: {pi.get_error()}')
else:
self.logger.debug(f'merchant notify result: {pi.merchant_ack_status}: {pi.merchant_ack_message}')
# TODO: show user? if we broadcasted the tx successfully, do we care?
# BitPay complains with a NAK if tx is RbF
def toggle_paytomany(self):
self.payto_e.toggle_paytomany()
if self.payto_e.is_paytomany():
message = '\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.window.show_tooltip_after_delay(message)
self.payto_label.setAlignment(Qt.AlignmentFlag.AlignTop)
self.payto_label.setText(_('Pay to many'))
else:
self.payto_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.payto_label.setText(_('Pay to'))
def payto_contacts(self, labels):
paytos = [self.window.get_contact_payto(label) for label in labels]
self.window.show_send_tab()
self.do_clear()
if len(paytos) == 1:
self.logger.debug('payto_e setText 1')
self.payto_e.setText(paytos[0])
self.amount_e.setFocus()
else:
self.payto_e.setFocus()
text = "\n".join([payto + ", 0" for payto in paytos])
self.logger.debug('payto_e setText n')
self.payto_e.setText(text)
self.payto_e.setFocus()
def handle_multiline(self, outputs):
total = 0
for output in outputs:
if parse_max_spend(output.value):
self.max_button.setChecked(True) # TODO: remove and let spend_max set this?
self.spend_max()
return
else:
total += output.value
self.amount_e.setAmount(total if outputs else None)
def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data):
if not self.wallet.has_lightning():
self.show_error(
_("Cannot request lightning withdrawal, wallet has no lightning channels.")
)
return
dialog = WindowModalDialog(self, _("Lightning Withdrawal"))
dialog.setMinimumWidth(400)
vbox = QVBoxLayout()
dialog.setLayout(vbox)
grid = QGridLayout()
grid.setSpacing(8)
grid.setColumnStretch(3, 1) # Make the last column stretch
row = 0
# provider url
domain_label = QLabel(_("Provider") + ":")
domain_text = WWLabel(urllib.parse.urlparse(lnurl_data.callback_url).netloc)
grid.addWidget(domain_label, row, 0)
grid.addWidget(domain_text, row, 1, 1, 3)
row += 1
if lnurl_data.default_description:
desc_label = QLabel(_("Description") + ":")
desc_text = WWLabel(lnurl_data.default_description)
grid.addWidget(desc_label, row, 0)
grid.addWidget(desc_text, row, 1, 1, 3)
row += 1
min_amount = max(lnurl_data.min_withdrawable_sat, 1)
max_amount = min(
lnurl_data.max_withdrawable_sat,
int(self.wallet.lnworker.num_sats_can_receive())
)
min_text = self.format_amount_and_units(lnurl_data.min_withdrawable_sat)
if min_amount > int(self.wallet.lnworker.num_sats_can_receive()):
self.show_error("".join([
_("Too little incoming liquidity to satisfy this withdrawal request."), "\n\n",
_("Can receive: {}").format(
self.format_amount_and_units(self.wallet.lnworker.num_sats_can_receive()),
), "\n",
_("Minimum withdrawal amount: {}").format(min_text), "\n\n",
_("Do a submarine swap in the 'Channels' tab to get more incoming liquidity.")
]))
return
is_fixed_amount = lnurl_data.min_withdrawable_sat == lnurl_data.max_withdrawable_sat
# Range information (only for non-fixed amounts)
if not is_fixed_amount:
range_label_text = QLabel(_("Range") + ":")
range_value = QLabel("{} - {}".format(
min_text,
self.format_amount_and_units(lnurl_data.max_withdrawable_sat)
))
grid.addWidget(range_label_text, row, 0)
grid.addWidget(range_value, row, 1, 1, 2)
row += 1
# Amount section
amount_label = QLabel(_("Amount") + ":")
amount_edit = BTCAmountEdit(self.window.get_decimal_point, max_amount=max_amount)
amount_edit.setAmount(max_amount)
grid.addWidget(amount_label, row, 0)
grid.addWidget(amount_edit, row, 1)
if is_fixed_amount:
# Fixed amount, just show the amount
amount_edit.setDisabled(True)
else:
# Range, show max button
max_button = EnterButton(_("Max"), lambda: amount_edit.setAmount(max_amount))
btn_width = 10 * char_width_in_lineedit()
max_button.setFixedWidth(btn_width)
grid.addWidget(max_button, row, 2)
row += 1
# Warning for insufficient liquidity
if lnurl_data.max_withdrawable_sat > int(self.wallet.lnworker.num_sats_can_receive()):
warning_text = WWLabel(
_("The maximum withdrawable amount is larger than what your channels can receive. "
"You may need to do a submarine swap to increase your incoming liquidity.")
)
warning_text.setStyleSheet("color: orange;")
grid.addWidget(warning_text, row, 0, 1, 4)
row += 1
vbox.addLayout(grid)
# Buttons
request_button = OkButton(dialog, _("Request Withdrawal"))
cancel_button = CancelButton(dialog)
vbox.addLayout(Buttons(cancel_button, request_button))
# Show dialog and handle result
if dialog.exec():
if is_fixed_amount:
amount_sat = lnurl_data.max_withdrawable_sat
else:
amount_sat = amount_edit.get_amount()
if not amount_sat or not (min_amount <= int(amount_sat) <= max_amount):
self.show_error(_("Enter a valid amount. You entered: {}").format(amount_sat))
return
else:
return
try:
key = self.wallet.create_request(
amount_sat=amount_sat,
message=lnurl_data.default_description,
exp_delay=120,
address=None,
)
req = self.wallet.get_request(key)
info = self.wallet.lnworker.get_payment_info(req.payment_hash, direction=RECEIVED)
_lnaddr, b11_invoice = self.wallet.lnworker.get_bolt11_invoice(
payment_info=info,
message=req.get_message(),
fallback_address=None,
)
except Exception as e:
self.logger.exception('')
self.show_error(
f"{_('Failed to create payment request for withdrawal')}: {str(e)}"
)
return
coro = request_lnurl_withdraw_callback(
callback_url=lnurl_data.callback_url,
k1=lnurl_data.k1,
bolt_11=b11_invoice
)
try:
self.window.run_coroutine_dialog(coro, _("Requesting lightning withdrawal..."))
except LNURLError as e:
self.show_error(f"{_('Failed to request withdrawal')}:\n{str(e)}")
except UserCancelled:
pass
================================================
FILE: electrum/gui/qt/settings_dialog.py
================================================
#!/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 ast
import sys
from typing import TYPE_CHECKING, Dict
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (QComboBox, QTabWidget, QDialog, QSpinBox, QCheckBox, QLabel,
QVBoxLayout, QGridLayout, QLineEdit, QWidget, QHBoxLayout, QSlider)
from electrum.i18n import _, get_gui_lang_names
from electrum import util
from electrum.util import base_units_list, event_listener
from electrum.gui.common_qt.util import QtEventListener
from electrum.gui import messages
from .util import ColorScheme, HelpLabel, Buttons, CloseButton
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig, ConfigVarWithConfig
from .main_window import ElectrumWindow
def checkbox_from_configvar(cv: 'ConfigVarWithConfig') -> QCheckBox:
short_desc = cv.get_short_desc()
assert short_desc is not None, f"short_desc missing for {cv}"
cb = QCheckBox(short_desc)
if (long_desc := cv.get_long_desc()) is not None:
cb.setToolTip(messages.to_rtf(long_desc))
return cb
class SettingsDialog(QDialog, QtEventListener):
def __init__(self, window: 'ElectrumWindow', config: 'SimpleConfig'):
QDialog.__init__(self)
self.setWindowTitle(_('Preferences'))
self.setMinimumWidth(500)
self.config = config
self.network = window.network
self.app = window.app
self.need_restart = False
self.fx = window.fx
self.wallet = window.wallet
self.register_callbacks()
self.app.alias_received_signal.connect(self.set_alias_color)
vbox = QVBoxLayout()
tabs = QTabWidget()
# language
lang_label = HelpLabel.from_configvar(self.config.cv.LOCALIZATION_LANGUAGE)
lang_combo = QComboBox()
_languages = get_gui_lang_names()
lang_combo.addItems(list(_languages.values()))
lang_keys = list(_languages.keys())
lang_cur_setting = self.config.LOCALIZATION_LANGUAGE
try:
index = lang_keys.index(lang_cur_setting)
except ValueError: # not in list
index = 0
lang_combo.setCurrentIndex(index)
if not self.config.cv.LOCALIZATION_LANGUAGE.is_modifiable():
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.LOCALIZATION_LANGUAGE:
self.config.LOCALIZATION_LANGUAGE = lang_request
self.need_restart = True
lang_combo.currentIndexChanged.connect(on_lang)
nz_label = HelpLabel.from_configvar(self.config.cv.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT)
nz = QSpinBox()
nz.setMinimum(0)
nz.setMaximum(self.config.BTC_AMOUNTS_DECIMAL_POINT)
nz.setValue(self.config.num_zeros)
if not self.config.cv.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT.is_modifiable():
for w in [nz, nz_label]: w.setEnabled(False)
def on_nz():
value = nz.value()
if self.config.num_zeros != value:
self.config.num_zeros = value
self.config.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT = value
self.app.refresh_tabs_signal.emit()
self.app.update_status_signal.emit()
nz.valueChanged.connect(on_nz)
# lightning
trampoline_cb = checkbox_from_configvar(self.config.cv.LIGHTNING_USE_GOSSIP)
trampoline_cb.setChecked(not self.config.LIGHTNING_USE_GOSSIP)
def on_trampoline_checked(_x):
use_trampoline = trampoline_cb.isChecked()
if not use_trampoline:
if not window.question('\n'.join([
_("Are you sure you want to disable trampoline?"),
_("Without this option, Electrum will need to sync with the Lightning network on every start."),
_("This may impact the reliability of your payments."),
]), parent=self):
trampoline_cb.setCheckState(Qt.CheckState.Checked)
return
self.config.LIGHTNING_USE_GOSSIP = not use_trampoline
if not use_trampoline:
self.network.start_gossip()
else:
self.network.run_from_another_thread(
self.network.stop_gossip())
util.trigger_callback('ln_gossip_sync_progress')
# FIXME: update all wallet windows
util.trigger_callback('channels_updated', self.wallet)
trampoline_cb.stateChanged.connect(on_trampoline_checked)
lnfee_hlabel = HelpLabel.from_configvar(self.config.cv.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
lnfee_map = [500, 1_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000]
def lnfee_update_vlabel(fee_val: int):
lnfee_vlabel.setText(_("{}% of payment").format(f"{fee_val / 10 ** 4:.2f}"))
def lnfee_slider_moved():
pos = lnfee_slider.sliderPosition()
fee_val = lnfee_map[pos]
lnfee_update_vlabel(fee_val)
self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS = fee_val
lnfee_slider = QSlider(Qt.Orientation.Horizontal)
lnfee_slider.setRange(0, len(lnfee_map)-1)
lnfee_slider.setTracking(True)
try:
lnfee_spos = lnfee_map.index(self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
except ValueError:
lnfee_spos = 0
lnfee_slider.setSliderPosition(lnfee_spos)
lnfee_vlabel = QLabel("")
lnfee_update_vlabel(self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
lnfee_slider.valueChanged.connect(lnfee_slider_moved)
lnfee_hbox = QHBoxLayout()
lnfee_hbox.setContentsMargins(0, 0, 0, 0)
lnfee_hbox.addWidget(lnfee_vlabel)
lnfee_hbox.addWidget(lnfee_slider)
lnfee_hbox_w = QWidget()
lnfee_hbox_w.setLayout(lnfee_hbox)
alias_label = HelpLabel.from_configvar(self.config.cv.OPENALIAS_ID)
alias = self.config.OPENALIAS_ID
self.alias_e = QLineEdit(alias)
self.set_alias_color()
self.alias_e.editingFinished.connect(self.on_alias_edit)
msat_cb = checkbox_from_configvar(self.config.cv.BTC_AMOUNTS_PREC_POST_SAT)
msat_cb.setChecked(self.config.BTC_AMOUNTS_PREC_POST_SAT > 0)
def on_msat_checked(_x):
prec = 3 if msat_cb.isChecked() else 0
if self.config.amt_precision_post_satoshi != prec:
self.config.amt_precision_post_satoshi = prec
self.config.BTC_AMOUNTS_PREC_POST_SAT = prec
self.app.refresh_tabs_signal.emit()
msat_cb.stateChanged.connect(on_msat_checked)
# units
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.config.get_base_unit()))
def on_unit(x, nz):
unit_result = units[unit_combo.currentIndex()]
if self.config.get_base_unit() == unit_result:
return
self.config.set_base_unit(unit_result)
nz.setMaximum(self.config.BTC_AMOUNTS_DECIMAL_POINT)
self.app.refresh_tabs_signal.emit()
self.app.update_status_signal.emit()
self.app.refresh_amount_edits_signal.emit()
unit_combo.currentIndexChanged.connect(lambda x: on_unit(x, nz))
thousandsep_cb = checkbox_from_configvar(self.config.cv.BTC_AMOUNTS_ADD_THOUSANDS_SEP)
thousandsep_cb.setChecked(self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP)
def on_set_thousandsep(_x):
checked = thousandsep_cb.isChecked()
if self.config.amt_add_thousands_sep != checked:
self.config.amt_add_thousands_sep = checked
self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP = checked
self.app.refresh_tabs_signal.emit()
thousandsep_cb.stateChanged.connect(on_set_thousandsep)
qr_combo = QComboBox()
qr_combo.addItem("Default", "default")
qr_label = HelpLabel.from_configvar(self.config.cv.VIDEO_DEVICE_PATH)
from .qrreader import find_system_cameras
system_cameras = find_system_cameras()
for cam_desc, cam_path in system_cameras.items():
qr_combo.addItem(cam_desc, cam_path)
index = qr_combo.findData(self.config.VIDEO_DEVICE_PATH)
qr_combo.setCurrentIndex(index)
def on_video_device(x):
self.config.VIDEO_DEVICE_PATH = qr_combo.itemData(x)
qr_combo.currentIndexChanged.connect(on_video_device)
colortheme_combo = QComboBox()
colortheme_combo.addItem(_('Light'), 'default')
colortheme_combo.addItem(_('Dark'), 'dark')
index = colortheme_combo.findData(self.config.GUI_QT_COLOR_THEME)
colortheme_combo.setCurrentIndex(index)
colortheme_label = QLabel(self.config.cv.GUI_QT_COLOR_THEME.get_short_desc() + ':')
def on_colortheme(x):
self.config.GUI_QT_COLOR_THEME = colortheme_combo.itemData(x)
self.need_restart = True
colortheme_combo.currentIndexChanged.connect(on_colortheme)
updatecheck_cb = checkbox_from_configvar(self.config.cv.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS)
updatecheck_cb.setChecked(self.config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS)
def on_set_updatecheck(_x):
self.config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS = updatecheck_cb.isChecked()
updatecheck_cb.stateChanged.connect(on_set_updatecheck)
filelogging_cb = checkbox_from_configvar(self.config.cv.WRITE_LOGS_TO_DISK)
filelogging_cb.setChecked(self.config.WRITE_LOGS_TO_DISK)
def on_set_filelogging(_x):
self.config.WRITE_LOGS_TO_DISK = filelogging_cb.isChecked()
self.need_restart = True
filelogging_cb.stateChanged.connect(on_set_filelogging)
screenshot_protection_cb = checkbox_from_configvar(
self.config.cv.GUI_QT_SCREENSHOT_PROTECTION
)
screenshot_protection_cb.setChecked(self.config.GUI_QT_SCREENSHOT_PROTECTION)
if sys.platform not in ['windows', 'win32']:
screenshot_protection_cb.setChecked(False)
screenshot_protection_cb.setDisabled(True)
screenshot_protection_cb.setToolTip(_("This option is only available on Windows"))
def on_set_screenshot_protection(_x):
self.config.GUI_QT_SCREENSHOT_PROTECTION = screenshot_protection_cb.isChecked()
self.need_restart = True
screenshot_protection_cb.stateChanged.connect(on_set_screenshot_protection)
block_explorers = sorted(util.block_explorer_info().keys())
BLOCK_EX_CUSTOM_ITEM = _("Custom URL")
if BLOCK_EX_CUSTOM_ITEM in block_explorers: # malicious translation?
block_explorers.remove(BLOCK_EX_CUSTOM_ITEM)
block_explorers.append(BLOCK_EX_CUSTOM_ITEM)
block_ex_label = HelpLabel.from_configvar(self.config.cv.BLOCK_EXPLORER)
block_ex_combo = QComboBox()
block_ex_custom_e = QLineEdit(str(self.config.BLOCK_EXPLORER_CUSTOM or ''))
block_ex_combo.addItems(block_explorers)
block_ex_combo.setCurrentIndex(
block_ex_combo.findText(util.block_explorer(self.config) or BLOCK_EX_CUSTOM_ITEM))
def showhide_block_ex_custom_e():
block_ex_custom_e.setVisible(block_ex_combo.currentText() == BLOCK_EX_CUSTOM_ITEM)
showhide_block_ex_custom_e()
def on_be_combo(x):
if block_ex_combo.currentText() == BLOCK_EX_CUSTOM_ITEM:
on_be_edit()
else:
be_result = block_explorers[block_ex_combo.currentIndex()]
self.config.BLOCK_EXPLORER_CUSTOM = None
self.config.BLOCK_EXPLORER = be_result
showhide_block_ex_custom_e()
block_ex_combo.currentIndexChanged.connect(on_be_combo)
def on_be_edit():
val = block_ex_custom_e.text()
try:
val = ast.literal_eval(val) # to also accept tuples
except Exception:
pass
self.config.BLOCK_EXPLORER_CUSTOM = val
block_ex_custom_e.editingFinished.connect(on_be_edit)
block_ex_hbox = QHBoxLayout()
block_ex_hbox.setContentsMargins(0, 0, 0, 0)
block_ex_hbox.setSpacing(0)
block_ex_hbox.addWidget(block_ex_combo)
block_ex_hbox.addWidget(block_ex_custom_e)
block_ex_hbox_w = QWidget()
block_ex_hbox_w.setLayout(block_ex_hbox)
# Fiat Currency
self.history_rates_cb = checkbox_from_configvar(self.config.cv.FX_HISTORY_RATES)
ccy_combo = QComboBox()
ex_combo = QComboBox()
def update_currencies():
if not self.fx:
return
h = self.config.FX_HISTORY_RATES
currencies = sorted(self.fx.get_currencies(h))
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_exchanges():
if not self.fx: return
b = self.fx.is_enabled()
ex_combo.setEnabled(b)
if b:
h = self.config.FX_HISTORY_RATES
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.blockSignals(True)
ex_combo.clear()
ex_combo.addItems(sorted(exchanges))
ex_combo.setCurrentIndex(ex_combo.findText(self.fx.config_exchange()))
ex_combo.blockSignals(False)
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_exchanges()
self.app.update_fiat_signal.emit()
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)
self.app.update_fiat_signal.emit()
def on_history_rates(_x):
self.config.FX_HISTORY_RATES = self.history_rates_cb.isChecked()
if not self.fx:
return
update_exchanges()
window.app.update_fiat_signal.emit()
update_currencies()
update_exchanges()
ccy_combo.currentIndexChanged.connect(on_currency)
self.history_rates_cb.setChecked(self.config.FX_HISTORY_RATES)
self.history_rates_cb.stateChanged.connect(on_history_rates)
ex_combo.currentIndexChanged.connect(on_exchange)
gui_widgets = []
gui_widgets.append((lang_label, lang_combo))
gui_widgets.append((colortheme_label, colortheme_combo))
gui_widgets.append((block_ex_label, block_ex_hbox_w))
units_widgets = []
units_widgets.append((unit_label, unit_combo))
units_widgets.append((nz_label, nz))
units_widgets.append((msat_cb, None))
units_widgets.append((thousandsep_cb, None))
lightning_widgets = []
lightning_widgets.append((trampoline_cb, None))
lightning_widgets.append((lnfee_hlabel, lnfee_hbox_w))
fiat_widgets = []
fiat_widgets.append((QLabel(_('Fiat currency')), ccy_combo))
fiat_widgets.append((QLabel(_('Source')), ex_combo))
fiat_widgets.append((self.history_rates_cb, None))
misc_widgets = []
misc_widgets.append((updatecheck_cb, None))
misc_widgets.append((filelogging_cb, None))
misc_widgets.append((screenshot_protection_cb, None))
misc_widgets.append((alias_label, self.alias_e))
misc_widgets.append((qr_label, qr_combo))
tabs_info = [
(gui_widgets, _('Appearance')),
(units_widgets, _('Units')),
(fiat_widgets, _('Fiat')),
(lightning_widgets, _('Lightning')),
(misc_widgets, _('Misc')),
]
for widgets, name in tabs_info:
tab = QWidget()
tab_vbox = QVBoxLayout(tab)
grid = QGridLayout()
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)
tab_vbox.addLayout(grid)
tab_vbox.addStretch(1)
tabs.addTab(tab, name)
vbox.addWidget(tabs)
vbox.addStretch(1)
vbox.addLayout(Buttons(CloseButton(self)))
self.setLayout(vbox)
@event_listener
def on_event_alias_received(self):
self.app.alias_received_signal.emit()
def set_alias_color(self):
if not self.config.OPENALIAS_ID:
self.alias_e.setStyleSheet("")
return
if self.wallet.contacts.alias_info:
self.alias_e.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True))
else:
self.alias_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True))
def on_alias_edit(self):
self.alias_e.setStyleSheet("")
alias = str(self.alias_e.text())
self.config.OPENALIAS_ID = alias
if alias:
self.wallet.contacts.fetch_openalias(self.config)
def closeEvent(self, event):
self.unregister_callbacks()
try:
self.app.alias_received_signal.disconnect(self.set_alias_color)
except TypeError:
pass # 'method' object is not connected
event.accept()
================================================
FILE: electrum/gui/qt/stylesheet_patcher.py
================================================
"""This is used to patch the QApplication style sheet.
It reads the current stylesheet, appends our modifications and sets the new stylesheet.
"""
import sys
from PyQt6 import QtWidgets
CUSTOM_PATCH_FOR_DARK_THEME = '''
/* PayToEdit text was being clipped */
QAbstractScrollArea {
padding: 0px;
}
/* In History tab, labels while edited were being clipped (Windows) */
QAbstractItemView QLineEdit {
padding: 0px;
show-decoration-selected: 1;
}
/* Checked item in dropdowns have way too much height...
see #6281 and https://github.com/ColinDuquesnoy/QDarkStyleSheet/issues/200
*/
QComboBox::item:checked {
font-weight: bold;
max-height: 30px;
}
'''
CUSTOM_PATCH_FOR_DEFAULT_THEME_MACOS = '''
/* On macOS, main window status bar icons have ugly frame (see #6300) */
StatusBarButton {
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
margin: 0px;
padding: 2px;
}
StatusBarButton:checked {
background-color: transparent;
border: 1px solid #1464A0;
}
StatusBarButton:checked:disabled {
border: 1px solid #14506E;
}
StatusBarButton:pressed {
margin: 1px;
background-color: transparent;
border: 1px solid #1464A0;
}
StatusBarButton:disabled {
border: none;
}
StatusBarButton:hover {
border: 1px solid #148CD2;
}
'''
def patch_qt_stylesheet(use_dark_theme: bool) -> None:
custom_patch = ""
if use_dark_theme:
custom_patch = CUSTOM_PATCH_FOR_DARK_THEME
else: # default theme (typically light)
if sys.platform == 'darwin':
custom_patch = CUSTOM_PATCH_FOR_DEFAULT_THEME_MACOS
app = QtWidgets.QApplication.instance()
style_sheet = app.styleSheet() + custom_patch
app.setStyleSheet(style_sheet)
================================================
FILE: electrum/gui/qt/swap_dialog.py
================================================
import enum
from typing import TYPE_CHECKING, Optional, Union, Tuple, Sequence, Callable
from PyQt6.QtCore import pyqtSignal, Qt, QTimer
from PyQt6.QtGui import QIcon, QPixmap, QColor
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton
from PyQt6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView
from electrum_aionostr.util import from_nip19
from electrum.i18n import _
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, UserCancelled, trigger_callback
from electrum.bitcoin import DummyAddress
from electrum.transaction import PartialTxOutput, PartialTransaction
from electrum.fee_policy import FeePolicy
from electrum.submarine_swaps import NostrTransport
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from electrum.gui import messages
from . import util
from .util import (WindowModalDialog, Buttons, OkButton, CancelButton,
EnterButton, ColorScheme, WWLabel, read_QIcon, IconLabel, char_width_in_lineedit,
pubkey_to_q_icon)
from .amountedit import BTCAmountEdit
from .fee_slider import FeeSlider, FeeComboBox
from .my_treeview import create_toolbar_with_menu, MyTreeView
if TYPE_CHECKING:
from .main_window import ElectrumWindow
from electrum.submarine_swaps import SwapServerTransport, SwapOffer
from electrum.lnchannel import Channel
from electrum.simple_config import SimpleConfig
CANNOT_RECEIVE_WARNING = _(
"""The requested amount is higher than what you can receive in your currently open channels.
If you continue, your funds will be locked until the remote server can find a path to pay you.
If the swap cannot be performed after 24h, you will be refunded.
Do you want to continue?"""
)
ROLE_NPUB = Qt.ItemDataRole.UserRole + 1000
class InvalidSwapParameters(Exception): pass
class SwapProvidersButton(QPushButton):
def __init__(
self,
transport_getter: Callable[[], Optional['SwapServerTransport']],
config: 'SimpleConfig',
main_window: 'ElectrumWindow',
):
"""parent must have a transport() method"""
QPushButton.__init__(self)
self.config = config
self.transport_getter = transport_getter
self.main_window = main_window
self.clicked.connect(self.choose_swap_server)
self.fetching = False
self.update()
def update(self):
if self.fetching:
self.setEnabled(False)
self.setText(_("Fetching..."))
self.setVisible(True)
return
transport = self.transport_getter()
if not isinstance(transport, NostrTransport):
# HTTPTransport or no Network, not showing server selection button
self.setEnabled(False)
self.setVisible(False)
return
self.setEnabled(True)
self.setVisible(True)
offer_count = len(transport.get_recent_offers())
button_text = f' {offer_count} ' + (_('swap providers') if offer_count != 1 else _('swap provider'))
self.setText(button_text)
# update icon
if self.config.SWAPSERVER_NPUB:
pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex()
self.setIcon(pubkey_to_q_icon(pubkey))
def choose_swap_server(self) -> None:
transport = self.transport_getter()
assert isinstance(transport, NostrTransport), transport
self.main_window.choose_swapserver_dialog(transport) # type: ignore
self.update()
trigger_callback('swap_provider_changed')
class SwapDialog(WindowModalDialog, QtEventListener):
def __init__(
self,
window: 'ElectrumWindow',
transport: 'SwapServerTransport',
is_reverse: Optional[bool] = None,
recv_amount_sat_or_max: Optional[Union[int, str]] = None, # sat or '!'
channels: Optional[Sequence['Channel']] = None,
):
WindowModalDialog.__init__(self, window, _('Submarine Swap'))
self.window = window
self.config = window.config
self.lnworker = self.window.wallet.lnworker
self.swap_manager = self.lnworker.swap_manager
self.network = window.network
self.channels = channels
self.is_reverse = is_reverse if is_reverse is not None else True
vbox = QVBoxLayout(self)
self.transport = transport
self.server_button = SwapProvidersButton(lambda: self.transport, self.config, self.window)
self.description_label = WWLabel(self.get_description())
self.send_amount_e = BTCAmountEdit(self.window.get_decimal_point)
self.recv_amount_e = BTCAmountEdit(self.window.get_decimal_point)
self.max_button = EnterButton(_("Max"), self.spend_max)
btn_width = 10 * char_width_in_lineedit()
self.max_button.setFixedWidth(btn_width)
self.max_button.setCheckable(True)
self.toggle_button = QPushButton(' \U000021c4 ') # whitespace to force larger min width
self.toggle_button.setEnabled(is_reverse is None)
# send_follows is used to know whether the send amount field / receive
# amount field should be adjusted after the fee slider was moved
self.send_follows = False
self.send_amount_e.follows = False
self.recv_amount_e.follows = False
self.toggle_button.clicked.connect(self.toggle_direction)
# textChanged is triggered for both user and automatic action
self.send_amount_e.textChanged.connect(self.on_send_edited)
self.recv_amount_e.textChanged.connect(self.on_recv_edited)
# textEdited is triggered only for user editing of the fields
self.send_amount_e.textEdited.connect(self.uncheck_max)
self.recv_amount_e.textEdited.connect(self.uncheck_max)
self.fee_policy = FeePolicy(self.config.FEE_POLICY)
self.fee_slider = FeeSlider(parent=self, network=self.network, fee_policy=self.fee_policy, callback=self.fee_slider_callback)
self.fee_combo = FeeComboBox(self.fee_slider)
self.fee_target_label = QLabel()
self._set_fee_slider_visibility(is_visible=not self.is_reverse)
self.swap_limits_label = QLabel()
self.fee_label = QLabel()
self.server_fee_label = QLabel()
self.last_server_mining_fee_sat = None
h = QGridLayout()
h.addWidget(self.description_label, 0, 0, 1, 3)
h.addWidget(self.toggle_button, 0, 3)
self.send_label = IconLabel(text=_('You send')+':')
self.recv_label = IconLabel(text=_('You receive')+':')
h.addWidget(self.send_label, 1, 0)
h.addWidget(self.send_amount_e, 1, 1)
h.addWidget(self.max_button, 1, 2)
h.addWidget(self.recv_label, 2, 0)
h.addWidget(self.recv_amount_e, 2, 1)
h.addWidget(QLabel(_('Swap limits')+':'), 4, 0)
h.addWidget(self.swap_limits_label, 4, 1, 1, 2)
h.addWidget(QLabel(_('Server fee')+':'), 5, 0)
h.addWidget(self.server_fee_label, 5, 1, 1, 2)
h.addWidget(QLabel(_('Mining fee')+':'), 6, 0)
h.addWidget(self.fee_label, 6, 1, 1, 2)
h.addWidget(self.fee_slider, 7, 1)
h.addWidget(self.fee_combo, 7, 2)
h.addWidget(self.fee_target_label, 7, 0)
h.addWidget(QLabel(''), 8, 0)
vbox.addLayout(h)
vbox.addStretch()
self.ok_button = OkButton(self)
self.ok_button.setDefault(True)
self.ok_button.setEnabled(False)
buttons = Buttons(CancelButton(self), self.ok_button)
vbox.addLayout(buttons)
buttons.insertWidget(0, self.server_button)
if recv_amount_sat_or_max:
assert isinstance(recv_amount_sat_or_max, (int, str)), f"invalid {type(recv_amount_sat_or_max)=}"
self.init_recv_amount(recv_amount_sat_or_max)
self.update()
self.needs_tx_update = True
self.timer = QTimer(self)
self.timer.setInterval(500)
self.timer.setSingleShot(False)
self.timer.timeout.connect(self.timer_actions)
self.timer.start()
self.fee_slider.update()
self.register_callbacks()
def closeEvent(self, event):
self.unregister_callbacks()
event.accept()
@qt_event_listener
def on_event_fee_histogram(self, *args):
self.update_send_receive()
@qt_event_listener
def on_event_fee(self, *args):
self.update_send_receive()
@qt_event_listener
def on_event_swap_offers_changed(self, recent_offers: Sequence['SwapOffer']):
self.server_button.update()
if not self.ok_button.isEnabled():
# only update the dialog with the new offer if the user hasn't entered an amount yet.
# if the user has already entered an amount we prefer the swap to fail due to outdated
# fees than the possibility of a swap happening with fees the user hasn't seen
# due to an update happening just before the user initiated the swap
self.update()
@qt_event_listener
def on_event_swap_provider_changed(self):
self.update()
self.update_send_receive()
def timer_actions(self):
if self.needs_tx_update:
self.update_tx()
self.update_ok_button()
self.needs_tx_update = False
def init_recv_amount(self, recv_amount_sat):
if recv_amount_sat == '!':
self.max_button.setChecked(True)
self.spend_max()
else:
recv_amount_sat = max(recv_amount_sat, self.swap_manager.get_min_amount())
self.recv_amount_e.setAmount(recv_amount_sat)
def fee_slider_callback(self, fee_rate):
self.config.FEE_POLICY = self.fee_policy.get_descriptor()
if not self.is_reverse:
self.fee_target_label.setText(self.fee_policy.get_target_text())
self.update_send_receive()
self.update()
def _set_fee_slider_visibility(self, *, is_visible: bool):
if is_visible:
self.fee_slider.setEnabled(True)
self.fee_combo.setEnabled(True)
self.fee_target_label.setText(self.fee_policy.get_target_text())
else:
self.fee_slider.setEnabled(False)
self.fee_combo.setEnabled(False)
# show the eta of the swap claim
self.fee_target_label.setText(FeePolicy(self.config.FEE_POLICY_SWAPS).get_target_text())
def toggle_direction(self):
self.is_reverse = not self.is_reverse
self._set_fee_slider_visibility(is_visible=not self.is_reverse)
self.send_amount_e.setAmount(None)
self.recv_amount_e.setAmount(None)
self.max_button.setChecked(False)
self.update()
def spend_max(self):
if self.max_button.isChecked():
if self.is_reverse:
self._spend_max_reverse_swap()
else:
# spend_max_forward_swap will be called in update_tx
pass
else:
self.send_amount_e.setAmount(None)
self.needs_tx_update = True
def uncheck_max(self):
self.max_button.setChecked(False)
self.update()
def _spend_max_forward_swap(self, tx: Optional[PartialTransaction]) -> None:
if tx:
amount = tx.output_value_for_address(DummyAddress.SWAP)
self.send_amount_e.setAmount(amount)
else:
self.send_amount_e.setAmount(None)
self.max_button.setChecked(False)
def _spend_max_reverse_swap(self) -> None:
amount = min(self.lnworker.num_sats_can_send(), self.swap_manager.get_provider_max_forward_amount())
amount = int(amount) # round down msats
self.send_amount_e.setAmount(amount)
def on_send_edited(self):
if self.send_amount_e.follows:
return
self.send_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
send_amount = self.send_amount_e.get_amount()
recv_amount = self.swap_manager.get_recv_amount(send_amount, is_reverse=self.is_reverse)
if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
# cannot send this much on lightning
recv_amount = None
if (not self.is_reverse) and recv_amount and recv_amount > self.lnworker.num_sats_can_receive():
# cannot receive this much on lightning
recv_amount = None
self.recv_amount_e.follows = True
self.recv_amount_e.setAmount(recv_amount)
self.recv_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
self.recv_amount_e.follows = False
self.send_follows = False
self.needs_tx_update = True
def on_recv_edited(self):
if self.recv_amount_e.follows:
return
self.recv_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
recv_amount = self.recv_amount_e.get_amount()
send_amount = self.swap_manager.get_send_amount(recv_amount, is_reverse=self.is_reverse)
if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
send_amount = None
self.send_amount_e.follows = True
self.send_amount_e.setAmount(send_amount)
self.send_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
self.send_amount_e.follows = False
self.send_follows = True
self.needs_tx_update = True
def update_send_receive(self):
self.on_recv_edited() if self.send_follows else self.on_send_edited()
def update(self):
sm = self.swap_manager
w_base_unit = self.window.base_unit()
send_icon = read_QIcon("lightning.png" if self.is_reverse else "bitcoin.png")
self.send_label.setIcon(send_icon)
recv_icon = read_QIcon("lightning.png" if not self.is_reverse else "bitcoin.png")
self.recv_label.setIcon(recv_icon)
self.description_label.setText(self.get_description())
self.description_label.repaint() # macOS hack for #6269
min_swap_limit, max_swap_limit = self.get_client_swap_limits_sat()
if max_swap_limit == 0:
swap_name = _("reverse") if self.is_reverse else _("forward")
swap_limit_str = _("No {} swap possible with this provider").format(swap_name)
else:
swap_limit_str = (f"{self.window.format_amount(min_swap_limit)} - "
f"{self.window.format_amount(max_swap_limit)} {w_base_unit}")
self.swap_limits_label.setText(swap_limit_str)
self.swap_limits_label.repaint() # macOS hack for #6269
self.last_server_mining_fee_sat = sm.mining_fee
server_fee_str = '%.2f'%sm.percentage + '% + ' + self.window.format_amount(sm.mining_fee) + ' ' + w_base_unit
self.server_fee_label.setText(server_fee_str)
self.server_fee_label.repaint() # macOS hack for #6269
self.needs_tx_update = True
def get_client_swap_limits_sat(self) -> Tuple[int, int]:
"""Returns the (min, max) client swap limits in sat."""
sm = self.swap_manager
if self.is_reverse:
lower_limit = sm.get_min_amount()
upper_limit = sm.client_max_amount_reverse_swap() or 0
else:
lower_limit = sm.get_send_amount(sm.get_min_amount(), is_reverse=False) or sm.get_min_amount()
upper_limit = sm.client_max_amount_forward_swap() or 0
if lower_limit > upper_limit:
# if the max possible amount is below the lower limit no swap is possible
lower_limit, upper_limit = 0, 0
return lower_limit, upper_limit
def update_fee(self, tx: Optional[PartialTransaction]) -> None:
"""Updates self.fee_label. No other side-effects."""
if self.is_reverse:
sm = self.swap_manager
fee = sm.get_fee_for_txbatcher()
else:
fee = tx.get_fee() if tx else None
fee_text = self.window.format_amount(fee) + ' ' + self.window.base_unit() if fee else _("no input")
self.fee_label.setText(fee_text)
self.fee_label.repaint() # macOS hack for #6269
def run(self, transport: 'SwapServerTransport') -> bool:
"""Can raise InvalidSwapParameters."""
if not self.exec():
return False
if self.is_reverse:
lightning_amount = self.send_amount_e.get_amount()
onchain_amount = self.recv_amount_e.get_amount()
if lightning_amount is None or onchain_amount is None:
return False
sm = self.swap_manager
coro = sm.reverse_swap(
transport=transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_fee_for_txbatcher(),
prepayment_sat=2 * self.last_server_mining_fee_sat,
)
try:
# we must not leave the context, so we use run_couroutine_dialog
funding_txid = self.window.run_coroutine_dialog(coro, _('Initiating swap...'))
except Exception as e:
self.window.show_error(f"Reverse swap failed: {str(e)}")
return False
self.window.on_swap_result(funding_txid, is_reverse=True)
return True
else:
lightning_amount = self.recv_amount_e.get_amount()
onchain_amount = self.send_amount_e.get_amount()
if lightning_amount is None or onchain_amount is None:
return False
if lightning_amount > self.lnworker.num_sats_can_receive():
if not self.window.question(CANNOT_RECEIVE_WARNING):
return False
self.window.protect(self.do_normal_swap, (transport, lightning_amount, onchain_amount))
return True
def update_tx(self) -> None:
if self.is_reverse:
self.update_fee(None)
return
is_max = self.max_button.isChecked()
if is_max:
tx = self._create_tx_safe('!')
self._spend_max_forward_swap(tx)
else:
onchain_amount = self.send_amount_e.get_amount()
tx = self._create_tx_safe(onchain_amount)
self.update_fee(tx)
def _create_tx(self, onchain_amount: Union[int, str, None]) -> PartialTransaction:
assert not self.is_reverse
if onchain_amount is None:
raise InvalidSwapParameters("onchain_amount is None")
coins = self.window.get_coins()
if onchain_amount == '!':
max_amount = sum(c.value_sats() for c in coins)
max_swap_amount = self.swap_manager.client_max_amount_forward_swap()
if max_swap_amount is None:
raise InvalidSwapParameters("swap_manager.client_max_amount_forward_swap() is None")
if max_amount > max_swap_amount:
onchain_amount = max_swap_amount
outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]
try:
tx = self.window.wallet.make_unsigned_transaction(
fee_policy=self.fee_policy,
coins=coins,
outputs=outputs,
send_change_to_lightning=False,
)
except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
raise InvalidSwapParameters(str(e)) from e
return tx
def _create_tx_safe(self, onchain_amount: Union[int, str, None]) -> Optional[PartialTransaction]:
try:
return self._create_tx(onchain_amount=onchain_amount)
except InvalidSwapParameters:
return None
def update_ok_button(self):
"""Updates self.ok_button. No other side-effects."""
send_amount = self.send_amount_e.get_amount()
recv_amount = self.recv_amount_e.get_amount()
self.ok_button.setEnabled(bool(send_amount) and bool(recv_amount))
async def _do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
dummy_tx = self._create_tx(onchain_amount)
assert dummy_tx
sm = self.swap_manager
swap, invoice = await sm.request_normal_swap(
transport=transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount,
channels=self.channels,
)
self._current_swap = swap
tx = sm.create_funding_tx(swap, dummy_tx, password=password)
txid = await sm.wait_for_htlcs_and_broadcast(transport=transport, swap=swap, invoice=invoice, tx=tx)
return txid
def do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
self._current_swap = None
coro = self._do_normal_swap(transport, lightning_amount, onchain_amount, password)
try:
funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting swap payment...'))
except UserCancelled:
self.swap_manager.cancel_normal_swap(self._current_swap)
self.window.show_message(_('Swap cancelled'))
return
except Exception as e:
self.window.show_error(str(e))
return
self.window.on_swap_result(funding_txid, is_reverse=False)
def get_description(self):
onchain_funds = "onchain"
lightning_funds = "lightning"
return "Send {fromType}, receive {toType}.\nThis will increase your lightning {capacityType} capacity.\n".format(
fromType=lightning_funds if self.is_reverse else onchain_funds,
toType=onchain_funds if self.is_reverse else lightning_funds,
capacityType="receiving" if self.is_reverse else "sending",
)
class SwapServerDialog(WindowModalDialog, QtEventListener):
class Columns(MyTreeView.BaseColumnsEnum):
PUBKEY = enum.auto()
FEE = enum.auto()
MAX_FORWARD = enum.auto()
MAX_REVERSE = enum.auto()
LAST_SEEN = enum.auto()
headers = {
Columns.PUBKEY: _("Pubkey"),
Columns.FEE: _("Fee"),
Columns.MAX_FORWARD: _('Max Forward'),
Columns.MAX_REVERSE: _('Max Reverse'),
Columns.LAST_SEEN: _("Last seen"),
}
def __init__(self, window: 'ElectrumWindow', servers: Sequence['SwapOffer']):
WindowModalDialog.__init__(self, window, _('Choose Swap Provider'))
self.window = window
self.config = window.config
msg = '\n'.join([
_("Please choose a provider from this list."),
_("Note that fees and liquidity may be updated frequently.")
])
self.servers_list = QTreeWidget()
col_names = [self.headers[col_idx] for col_idx in sorted(self.headers.keys())]
self.servers_list.setHeaderLabels(col_names)
self.servers_list.header().setStretchLastSection(False)
for col_idx in range(len(self.Columns)):
sm = QHeaderView.ResizeMode.Stretch if col_idx == self.Columns.PUBKEY else QHeaderView.ResizeMode.ResizeToContents
self.servers_list.header().setSectionResizeMode(col_idx, sm)
self.update_servers_list(servers)
vbox = QVBoxLayout()
self.setLayout(vbox)
vbox.addWidget(WWLabel(msg))
vbox.addWidget(self.servers_list, stretch=1)
vbox.addSpacing(10)
self.ok_button = OkButton(self)
vbox.addLayout(Buttons(CancelButton(self), self.ok_button))
self.setMinimumWidth(650)
self.register_callbacks()
def run(self):
if self.exec() != 1:
return None
if item := self.servers_list.currentItem():
return item.data(self.Columns.PUBKEY, ROLE_NPUB)
return None
def closeEvent(self, event):
self.unregister_callbacks()
event.accept()
@qt_event_listener
def on_event_swap_offers_changed(self, recent_offers: Sequence['SwapOffer']):
self.update_servers_list(recent_offers)
def update_servers_list(self, servers: Sequence['SwapOffer']):
self.servers_list.clear()
from electrum.util import age
items = []
for x in servers:
labels = [""] * len(self.Columns)
labels[self.Columns.PUBKEY] = x.server_pubkey
labels[self.Columns.FEE] = f"{x.pairs.percentage}% + {x.pairs.mining_fee} sats"
labels[self.Columns.MAX_FORWARD] = self.window.format_amount(x.pairs.max_forward) + ' ' + self.window.base_unit()
labels[self.Columns.MAX_REVERSE] = self.window.format_amount(x.pairs.max_reverse) + ' ' + self.window.base_unit()
labels[self.Columns.LAST_SEEN] = age(x.timestamp)
item = QTreeWidgetItem(labels)
item.setData(self.Columns.PUBKEY, ROLE_NPUB, x.server_npub)
item.setIcon(self.Columns.PUBKEY, pubkey_to_q_icon(x.server_pubkey))
items.append(item)
self.servers_list.insertTopLevelItems(0, items)
================================================
FILE: electrum/gui/qt/transaction_dialog.py
================================================
#!/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 asyncio
import concurrent.futures
import copy
import datetime
import time
from typing import TYPE_CHECKING, Optional, List, Union, Mapping, Callable
from functools import partial
from decimal import Decimal
from PyQt6.QtCore import QSize, Qt, QUrl, QPoint, pyqtSignal
from PyQt6.QtGui import QTextCharFormat, QBrush, QFont, QPixmap, QTextCursor, QAction
from PyQt6.QtWidgets import (QDialog, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QWidget,
QToolButton, QMenu, QTextBrowser,
QSizePolicy)
import qrcode
from qrcode import exceptions
from electrum import bitcoin
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, DummyAddress
from electrum.i18n import _
from electrum.plugin import run_hook
from electrum.transaction import SerializationError, Transaction, PartialTransaction, TxOutpoint, TxinDataFetchProgress
from electrum.logging import get_logger
from electrum.util import (ShortID, get_asyncio_loop, UI_UNIT_NAME_TXSIZE_VBYTES, delta_time_str,
UserCancelled)
from electrum.network import Network
from electrum.wallet import TxSighashRiskLevel, TxSighashDanger
from .util import (MessageBoxMixin, read_QIcon, Buttons, icon_path,
MONOSPACE_FONT, ColorScheme, ButtonsLineEdit, ShowQRLineEdit, text_dialog,
char_width_in_lineedit, TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE,
TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX,
TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX,
getSaveFileName, ColorSchemeItem,
get_icon_qrcode, VLine, WaitingDialog)
from .rate_limiter import rate_limited
from .my_treeview import create_toolbar_with_menu, QMenuWithConfig
if TYPE_CHECKING:
from .main_window import ElectrumWindow
from electrum.wallet import Abstract_Wallet
from electrum.invoices import Invoice
_logger = get_logger(__name__)
dialogs = [] # Otherwise python randomly garbage collects the dialogs...
class TxSizeLabel(QLabel):
def setAmount(self, byte_size):
text = ""
if byte_size:
text = f"x {byte_size} {UI_UNIT_NAME_TXSIZE_VBYTES} ="
self.setText(text)
class TxFiatLabel(QLabel):
def setAmount(self, fiat_fee):
self.setText(('≈ %s' % fiat_fee) if fiat_fee else '')
class QTextBrowserWithDefaultSize(QTextBrowser):
def __init__(self, width: int = 0, height: int = 0):
self._width = width
self._height = height
QTextBrowser.__init__(self)
self.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
def sizeHint(self):
return QSize(self._width, self._height)
class TxInOutWidget(QWidget):
def __init__(self, main_window: 'ElectrumWindow', wallet: 'Abstract_Wallet'):
QWidget.__init__(self)
self.wallet = wallet
self.main_window = main_window
self.tx = None # type: Optional[Transaction]
self.inputs_header = QLabel()
self.inputs_textedit = QTextBrowserWithDefaultSize(750, 100)
self.inputs_textedit.setOpenLinks(False) # disable automatic link opening
self.inputs_textedit.anchorClicked.connect(self._open_internal_link) # send links to our handler
self.inputs_textedit.setTextInteractionFlags(
self.inputs_textedit.textInteractionFlags() | Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard)
self.inputs_textedit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.inputs_textedit.customContextMenuRequested.connect(self.on_context_menu_for_inputs)
self.sighash_label = QLabel()
self.sighash_label.setStyleSheet('font-weight: bold')
self.sighash_danger = TxSighashDanger()
self.inputs_warning_icon = QLabel()
pixmap = QPixmap(icon_path("warning"))
pixmap_size = round(2 * char_width_in_lineedit())
pixmap = pixmap.scaled(pixmap_size, pixmap_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
self.inputs_warning_icon.setPixmap(pixmap)
self.inputs_warning_icon.setVisible(False)
self.inheader_hbox = QHBoxLayout()
self.inheader_hbox.setContentsMargins(0, 0, 0, 0)
self.inheader_hbox.addWidget(self.inputs_header)
self.inheader_hbox.addStretch(2)
self.inheader_hbox.addWidget(self.sighash_label)
self.inheader_hbox.addWidget(self.inputs_warning_icon)
self.txo_color_recv = TxOutputColoring(
legend=_("Wallet Address"), color=ColorScheme.GREEN, tooltip=_("Wallet receiving address"))
self.txo_color_change = TxOutputColoring(
legend=_("Change Address"), color=ColorScheme.YELLOW, tooltip=_("Wallet change address"))
self.txo_color_accounting = TxOutputColoring(
legend=_("Accounting Address"), color=ColorScheme.ORANGE, tooltip=_("Address from which funds were swept to your wallet."))
self.txo_color_2fa = TxOutputColoring(
legend=_("TrustedCoin (2FA) batch fee"), color=ColorScheme.BLUE, tooltip=_("TrustedCoin (2FA) fee for the next batch of transactions"))
self.txo_color_swap = TxOutputColoring(
legend=_("Submarine swap address"), color=ColorScheme.BLUE, tooltip=_("Submarine swap address"))
self.outputs_header = QLabel()
self.outputs_textedit = QTextBrowserWithDefaultSize(750, 100)
self.outputs_textedit.setOpenLinks(False) # disable automatic link opening
self.outputs_textedit.anchorClicked.connect(self._open_internal_link) # send links to our handler
self.outputs_textedit.setTextInteractionFlags(
self.outputs_textedit.textInteractionFlags() | Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard)
self.outputs_textedit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.outputs_textedit.customContextMenuRequested.connect(self.on_context_menu_for_outputs)
outheader_hbox = QHBoxLayout()
outheader_hbox.setContentsMargins(0, 0, 0, 0)
outheader_hbox.addWidget(self.outputs_header)
outheader_hbox.addStretch(2)
outheader_hbox.addWidget(self.txo_color_recv.legend_label)
outheader_hbox.addWidget(self.txo_color_change.legend_label)
outheader_hbox.addWidget(self.txo_color_2fa.legend_label)
outheader_hbox.addWidget(self.txo_color_swap.legend_label)
outheader_hbox.addWidget(self.txo_color_accounting.legend_label)
vbox = QVBoxLayout()
vbox.addLayout(self.inheader_hbox)
vbox.addWidget(self.inputs_textedit)
vbox.addLayout(outheader_hbox)
vbox.addWidget(self.outputs_textedit)
self.setLayout(vbox)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def update(self, tx: Optional[Transaction]):
self.tx = tx
if tx is None:
self.inputs_header.setText('')
self.inputs_textedit.setText('')
self.outputs_header.setText('')
self.outputs_textedit.setText('')
return
inputs_header_text = _("Inputs") + ' (%d)'%len(self.tx.inputs())
self.inputs_header.setText(inputs_header_text)
ext = QTextCharFormat() # "external"
lnk = QTextCharFormat()
lnk.setToolTip(_('Click to open, right-click for menu'))
lnk.setAnchor(True)
lnk.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap = False, False, False, False
tf_used_accounting = False
def addr_text_format(addr: str) -> QTextCharFormat:
nonlocal tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap, tf_used_accounting
sm = self.wallet.lnworker.swap_manager if self.wallet.lnworker else None
if self.wallet.is_mine(addr):
if self.wallet.is_change(addr):
tf_used_change = True
fmt = QTextCharFormat(self.txo_color_change.text_char_format)
else:
tf_used_recv = True
fmt = QTextCharFormat(self.txo_color_recv.text_char_format)
fmt.setAnchorHref(addr)
fmt.setToolTip(_('Click to open, right-click for menu'))
fmt.setAnchor(True)
fmt.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
return fmt
elif sm and sm.is_lockup_address_for_a_swap(addr) or addr == DummyAddress.SWAP:
tf_used_swap = True
return self.txo_color_swap.text_char_format
elif self.wallet.is_billing_address(addr):
tf_used_2fa = True
return self.txo_color_2fa.text_char_format
elif self.wallet.is_accounting_address(addr):
tf_used_accounting = True
return self.txo_color_accounting.text_char_format
return ext
def insert_tx_io(
*,
cursor: QTextCursor,
txio_idx: int,
is_coinbase: bool,
tcf_shortid: QTextCharFormat = None,
short_id: str,
addr: Optional[str],
value: Optional[int],
):
tcf_ext = QTextCharFormat(ext)
tcf_addr = addr_text_format(addr)
if tcf_shortid is None:
tcf_shortid = tcf_ext
a_name = f"txio_idx {txio_idx}"
for tcf in (tcf_ext, tcf_shortid, tcf_addr): # used by context menu creation
tcf.setAnchorNames([a_name])
if is_coinbase:
cursor.insertText('coinbase', tcf_ext)
else:
# short_id
cursor.insertText(short_id, tcf_shortid)
cursor.insertText(" " * max(0, 15 - len(short_id)), tcf_ext) # padding
cursor.insertText('\t', tcf_ext)
# addr
if addr is None:
address_str = ''
elif len(addr) <= 42:
address_str = addr
else:
address_str = addr[0:30] + '…' + addr[-11:]
cursor.insertText(address_str, tcf_addr)
cursor.insertText(" " * max(0, 42 - len(address_str)), tcf_ext) # padding
cursor.insertText('\t', tcf_ext)
# value
value_str = self.main_window.format_amount(value, whitespaces=True)
cursor.insertText(value_str, tcf_ext)
cursor.insertBlock()
i_text = self.inputs_textedit
i_text.clear()
i_text.setFont(QFont(MONOSPACE_FONT))
i_text.setReadOnly(True)
cursor = i_text.textCursor()
for txin_idx, txin in enumerate(self.tx.inputs()):
addr = self.wallet.adb.get_txin_address(txin)
txin_value = self.wallet.adb.get_txin_value(txin)
tcf_shortid = QTextCharFormat(lnk)
tcf_shortid.setAnchorHref(txin.prevout.txid.hex())
insert_tx_io(
cursor=cursor, is_coinbase=txin.is_coinbase_input(), txio_idx=txin_idx,
tcf_shortid=tcf_shortid,
short_id=str(txin.short_id), addr=addr, value=txin_value,
)
if isinstance(self.tx, PartialTransaction):
self.sighash_danger = self.wallet.check_sighash(self.tx)
if self.sighash_danger.risk_level >= TxSighashRiskLevel.WEIRD_SIGHASH:
self.sighash_label.setText(self.sighash_danger.short_message)
self.inputs_warning_icon.setVisible(True)
self.inputs_warning_icon.setToolTip(self.sighash_danger.get_long_message())
self.outputs_header.setText(_("Outputs") + ' (%d)'%len(self.tx.outputs()))
o_text = self.outputs_textedit
o_text.clear()
o_text.setFont(QFont(MONOSPACE_FONT))
o_text.setReadOnly(True)
tx_height, tx_pos = None, None
tx_hash = self.tx.txid()
if tx_hash:
tx_mined_info = self.wallet.adb.get_tx_height(tx_hash)
tx_height = tx_mined_info.height()
tx_pos = tx_mined_info.txpos
cursor = o_text.textCursor()
for txout_idx, o in enumerate(self.tx.outputs()):
if tx_height is not None and tx_pos is not None and tx_pos >= 0:
short_id = ShortID.from_components(tx_height, tx_pos, txout_idx)
elif tx_hash:
short_id = TxOutpoint(bytes.fromhex(tx_hash), txout_idx).short_name()
else:
short_id = f"unknown:{txout_idx}"
addr = o.get_ui_address_str()
spender_txid = None # type: Optional[str]
if tx_hash:
spender_txid = self.wallet.db.get_spent_outpoint(tx_hash, txout_idx)
tcf_shortid = None
if spender_txid:
tcf_shortid = QTextCharFormat(lnk)
tcf_shortid.setAnchorHref(spender_txid)
insert_tx_io(
cursor=cursor, is_coinbase=False, txio_idx=txout_idx,
tcf_shortid=tcf_shortid,
short_id=str(short_id), addr=addr, value=o.value,
)
self.txo_color_recv.legend_label.setVisible(tf_used_recv)
self.txo_color_change.legend_label.setVisible(tf_used_change)
self.txo_color_2fa.legend_label.setVisible(tf_used_2fa)
self.txo_color_swap.legend_label.setVisible(tf_used_swap)
self.txo_color_accounting.legend_label.setVisible(tf_used_accounting)
def _open_internal_link(self, target):
"""Accepts either a str txid, str address, or a QUrl which should be
of the bare form "txid" and/or "address" -- used by the clickable
links in the inputs/outputs QTextBrowsers"""
if isinstance(target, QUrl):
target = target.toString(QUrl.UrlFormattingOption.None_)
assert target
if bitcoin.is_address(target):
# target was an address, open address dialog
self.main_window.show_address(target, parent=self)
else:
# target was a txid, open new tx dialog
self.main_window.do_process_from_txid(txid=target, parent=self)
def on_context_menu_for_inputs(self, pos: QPoint):
i_text = self.inputs_textedit
global_pos = i_text.viewport().mapToGlobal(pos)
cursor = i_text.cursorForPosition(pos)
charFormat = cursor.charFormat()
name = charFormat.anchorNames() and charFormat.anchorNames()[0]
if not name:
menu = i_text.createStandardContextMenu()
menu.exec(global_pos)
return
menu = QMenu()
show_list = []
copy_list = []
# figure out which input they right-clicked on. input lines have an anchor named "txio_idx N"
txin_idx = int(name.split()[1]) # split "txio_idx N", translate N -> int
txin = self.tx.inputs()[txin_idx]
menu.addAction(_("Tx Input #{}").format(txin_idx)).setDisabled(True)
menu.addSeparator()
if txin.is_coinbase_input():
menu.addAction(_("Coinbase Input")).setDisabled(True)
else:
show_list += [(_("Show Prev Tx"), lambda: self._open_internal_link(txin.prevout.txid.hex()))]
copy_list += [(_("Copy Outpoint"), lambda: self.main_window.do_copy(txin.prevout.to_str()))]
addr = self.wallet.adb.get_txin_address(txin)
if addr:
if self.wallet.is_mine(addr):
show_list += [(_("Address Details"), lambda: self.main_window.show_address(addr, parent=self))]
copy_list += [(_("Copy Address"), lambda: self.main_window.do_copy(addr))]
txin_value = self.wallet.adb.get_txin_value(txin)
if txin_value:
value_str = self.main_window.format_amount(txin_value, add_thousands_sep=False)
copy_list += [(_("Copy Amount"), lambda: self.main_window.do_copy(value_str))]
for item in show_list:
menu.addAction(*item)
if show_list and copy_list:
menu.addSeparator()
for item in copy_list:
menu.addAction(*item)
menu.addSeparator()
std_menu = i_text.createStandardContextMenu()
menu.addActions(std_menu.actions())
menu.exec(global_pos)
def on_context_menu_for_outputs(self, pos: QPoint):
o_text = self.outputs_textedit
global_pos = o_text.viewport().mapToGlobal(pos)
cursor = o_text.cursorForPosition(pos)
charFormat = cursor.charFormat()
name = charFormat.anchorNames() and charFormat.anchorNames()[0]
if not name:
menu = o_text.createStandardContextMenu()
menu.exec(global_pos)
return
menu = QMenu()
show_list = []
copy_list = []
# figure out which output they right-clicked on. output lines have an anchor named "txio_idx N"
txout_idx = int(name.split()[1]) # split "txio_idx N", translate N -> int
menu.addAction(_("Tx Output #{}").format(txout_idx)).setDisabled(True)
menu.addSeparator()
if tx_hash := self.tx.txid():
outpoint = TxOutpoint(bytes.fromhex(tx_hash), txout_idx)
copy_list += [(_("Copy Outpoint"), lambda: self.main_window.do_copy(outpoint.to_str()))]
if addr := self.tx.outputs()[txout_idx].address:
if self.wallet.is_mine(addr):
show_list += [(_("Address Details"), lambda: self.main_window.show_address(addr, parent=self))]
copy_list += [(_("Copy Address"), lambda: self.main_window.do_copy(addr))]
else:
spk = self.tx.outputs()[txout_idx].scriptpubkey
copy_list += [(_("Copy scriptPubKey"), lambda: self.main_window.do_copy(spk.hex()))]
txout_value = self.tx.outputs()[txout_idx].value
value_str = self.main_window.format_amount(txout_value, add_thousands_sep=False)
copy_list += [(_("Copy Amount"), lambda: self.main_window.do_copy(value_str))]
for item in show_list:
menu.addAction(*item)
if show_list and copy_list:
menu.addSeparator()
for item in copy_list:
menu.addAction(*item)
run_hook('transaction_dialog_address_menu', menu, addr, self.wallet)
menu.addSeparator()
std_menu = o_text.createStandardContextMenu()
menu.addActions(std_menu.actions())
menu.exec(global_pos)
def show_transaction(
tx: Transaction,
*,
parent: 'ElectrumWindow',
prompt_if_unsaved: bool = False,
prompt_if_complete_unsaved: bool = True,
external_keypairs: Mapping[bytes, bytes] = None,
invoice: 'Invoice' = None,
on_closed: Callable[[Optional[Transaction]], None] = None,
show_sign_button: bool = True,
show_broadcast_button: bool = True,
):
try:
d = TxDialog(
tx,
parent=parent,
prompt_if_unsaved=prompt_if_unsaved,
prompt_if_complete_unsaved=prompt_if_complete_unsaved,
external_keypairs=external_keypairs,
invoice=invoice,
on_closed=on_closed,
)
if not show_sign_button:
d.sign_button.setVisible(False)
if not show_broadcast_button:
d.broadcast_button.setVisible(False)
except SerializationError as e:
_logger.exception('unable to deserialize the transaction')
parent.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e))
except UserCancelled:
return
else:
d.show()
class TxDialog(QDialog, MessageBoxMixin):
throttled_update_sig = pyqtSignal() # emit from thread to do update in main thread
def __init__(
self,
tx: Transaction,
*,
parent: 'ElectrumWindow',
prompt_if_unsaved: bool,
prompt_if_complete_unsaved: bool = True,
external_keypairs: Mapping[bytes, bytes] = None,
invoice: 'Invoice' = None,
on_closed: Callable[[Optional[Transaction]], None] = None,
):
'''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)
self.tx = None # type: Optional[Transaction]
self.external_keypairs = external_keypairs
self.main_window = parent
self.config = parent.config
self.wallet = parent.wallet
self.invoice = invoice
self.prompt_if_unsaved = prompt_if_unsaved
self.prompt_if_complete_unsaved = prompt_if_complete_unsaved
self.on_closed = on_closed
self.saved = False
self.desc = None
if txid := tx.txid():
self.desc = self.wallet.get_label_for_txid(txid) or None
if not self.desc and self.invoice:
self.desc = self.invoice.get_message()
self.setMinimumWidth(640)
self.psbt_only_widgets = [] # type: List[Union[QWidget, QAction]]
vbox = QVBoxLayout()
self.setLayout(vbox)
toolbar, menu = create_toolbar_with_menu(self.config, '')
menu.addConfig(
self.config.cv.GUI_QT_TX_DIALOG_FETCH_TXIN_DATA,
callback=self.maybe_fetch_txin_data)
vbox.addLayout(toolbar)
vbox.addWidget(QLabel(_("Transaction ID:")))
self.tx_hash_e = ShowQRLineEdit('', self.config, title=_('Transaction ID'))
vbox.addWidget(self.tx_hash_e)
self.tx_desc_label = QLabel(_("Description:"))
vbox.addWidget(self.tx_desc_label)
self.tx_desc = ButtonsLineEdit('')
self.tx_desc.editingFinished.connect(self.store_tx_label)
self.tx_desc.addCopyButton()
vbox.addWidget(self.tx_desc)
self.add_tx_stats(vbox)
vbox.addSpacing(10)
self.io_widget = TxInOutWidget(self.main_window, self.wallet)
vbox.addWidget(self.io_widget)
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(_("Add to History"))
b.clicked.connect(self.save)
self.cancel_button = b = QPushButton(_("Close"))
b.clicked.connect(self.close)
b.setDefault(True)
self.export_actions_menu = export_actions_menu = QMenuWithConfig(config=self.config)
self.add_export_actions_to_menu(export_actions_menu)
export_actions_menu.addSeparator()
export_option = export_actions_menu.addConfig(
self.config.cv.GUI_QT_TX_DIALOG_EXPORT_STRIP_SENSITIVE_METADATA)
self.psbt_only_widgets.append(export_option)
export_option = export_actions_menu.addConfig(
self.config.cv.GUI_QT_TX_DIALOG_EXPORT_INCLUDE_GLOBAL_XPUBS)
self.psbt_only_widgets.append(export_option)
if self.wallet.has_support_for_slip_19_ownership_proofs():
export_option = export_actions_menu.addAction(
_('Include SLIP-19 ownership proofs'),
self._add_slip_19_ownership_proofs_to_tx)
export_option.setToolTip(_("Some cosigners (e.g. Trezor) might require this for coinjoins."))
self._export_option_slip19 = export_option
export_option.setCheckable(True)
export_option.setChecked(False)
self.psbt_only_widgets.append(export_option)
self.export_actions_button = QToolButton()
self.export_actions_button.setText(_("Share"))
self.export_actions_button.setMenu(export_actions_menu)
self.export_actions_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
partial_tx_actions_menu = QMenu()
ptx_merge_sigs_action = QAction(_("Merge signatures from"), self)
ptx_merge_sigs_action.triggered.connect(self.merge_sigs)
partial_tx_actions_menu.addAction(ptx_merge_sigs_action)
self._ptx_join_txs_action = QAction(_("Join inputs/outputs"), self)
self._ptx_join_txs_action.triggered.connect(self.join_tx_with_another)
partial_tx_actions_menu.addAction(self._ptx_join_txs_action)
self.partial_tx_actions_button = QToolButton()
self.partial_tx_actions_button.setText(_("Combine"))
self.partial_tx_actions_button.setMenu(partial_tx_actions_menu)
self.partial_tx_actions_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.psbt_only_widgets.append(self.partial_tx_actions_button)
# Action buttons
self.buttons = [self.partial_tx_actions_button, self.sign_button, self.broadcast_button, self.cancel_button]
# Transaction sharing buttons
self.sharing_buttons = [self.export_actions_button, self.save_button]
run_hook('transaction_dialog', self)
self.hbox = hbox = QHBoxLayout()
hbox.addLayout(Buttons(*self.sharing_buttons))
hbox.addStretch(1)
hbox.addLayout(Buttons(*self.buttons))
vbox.addLayout(hbox)
dialogs.append(self)
self._fetch_txin_data_fut = None # type: Optional[concurrent.futures.Future]
self._fetch_txin_data_progress = None # type: Optional[TxinDataFetchProgress]
self.throttled_update_sig.connect(self._throttled_update, Qt.ConnectionType.QueuedConnection)
self.set_tx(tx)
self.update()
self.set_title()
def store_tx_label(self):
text = self.tx_desc.text()
if self.wallet.set_label(self.tx.txid(), text):
self.main_window.history_list.update()
self.main_window.utxo_list.update()
self.main_window.labels_changed_signal.emit()
def set_tx(self, tx: 'Transaction'):
# 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)
# 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,
# or that a beyond-gap-limit address is is_mine.
# note: this might fetch prev txs over the network.
tx.add_info_from_wallet(self.wallet)
# FIXME for PSBTs, we do a blocking fetch, as the missing data might be needed for e.g. signing
# - otherwise, the missing data is for display-completeness only, e.g. fee, input addresses (we do it async)
if not tx.is_complete() and tx.is_missing_info_from_network():
self.main_window.run_coroutine_dialog(
tx.add_info_from_network(self.wallet.network, timeout=10),
_("Adding info to tx, from network..."),
)
else:
self.maybe_fetch_txin_data()
def do_broadcast(self):
self.main_window.push_top_level_window(self)
self.main_window.send_tab.save_pending_invoice()
try:
self.main_window.broadcast_transaction(self.tx, invoice=self.invoice)
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
if self._fetch_txin_data_fut:
self._fetch_txin_data_fut.cancel()
self._fetch_txin_data_fut = None
if self.on_closed:
self.on_closed(self.tx)
def reject(self):
# Override escape-key to close normally (and invoke closeEvent)
self.close()
def add_export_actions_to_menu(self, menu: QMenu) -> None:
def gettx() -> Transaction:
if not isinstance(self.tx, PartialTransaction):
return self.tx
tx = copy.deepcopy(self.tx)
if self.config.GUI_QT_TX_DIALOG_EXPORT_INCLUDE_GLOBAL_XPUBS:
Network.run_from_another_thread(
tx.prepare_for_export_for_hardware_device(self.wallet))
if self.config.GUI_QT_TX_DIALOG_EXPORT_STRIP_SENSITIVE_METADATA:
tx.prepare_for_export_for_coinjoin()
return tx
action = QAction(_("Copy to clipboard"), self)
action.triggered.connect(lambda: self.copy_to_clipboard(tx=gettx()))
menu.addAction(action)
action = QAction(get_icon_qrcode(), _("Show as QR code"), self)
action.triggered.connect(lambda: self.show_qr(tx=gettx()))
menu.addAction(action)
action = QAction(_("Save to file"), self)
action.triggered.connect(lambda: self.export_to_file(tx=gettx()))
menu.addAction(action)
def _add_slip_19_ownership_proofs_to_tx(self):
assert isinstance(self.tx, PartialTransaction)
def on_success(result):
self._export_option_slip19.setEnabled(False)
self.main_window.pop_top_level_window(self)
def on_failure(exc_info):
self._export_option_slip19.setChecked(False)
self.main_window.on_error(exc_info)
self.main_window.pop_top_level_window(self)
task = partial(self.wallet.add_slip_19_ownership_proofs_to_tx, self.tx)
msg = _('Adding SLIP-19 ownership proofs to transaction...')
self.main_window.push_top_level_window(self)
WaitingDialog(self, msg, task, on_success, on_failure)
def copy_to_clipboard(self, *, tx: Transaction = None):
if tx is None:
tx = self.tx
self.main_window.do_copy(str(tx), title=_("Transaction"))
def show_qr(self, *, tx: Transaction = None):
if tx is None:
tx = self.tx
qr_data, is_complete = tx.to_qr_data()
help_text = None
if not is_complete:
help_text = _(
"""Warning: Some data (prev txs / "full utxos") was left """
"""out of the QR code as it would not fit. This might cause issues if signing offline. """
"""As a workaround, try exporting the tx as file or text instead.""")
try:
self.main_window.show_qrcode(qr_data, _("Transaction"), parent=self, help_text=help_text)
except qrcode.exceptions.DataOverflowError:
self.show_error(_('Failed to display QR code.') + '\n' +
_('Transaction is too large in size.'))
except Exception as e:
self.show_error(_('Failed to display QR code.') + '\n' + repr(e))
def sign(self):
def sign_done(success):
if self.tx.is_complete() and self.prompt_if_complete_unsaved:
self.prompt_if_unsaved = True
self.saved = False
self.update()
self.main_window.pop_top_level_window(self)
if self.io_widget.sighash_danger.needs_confirm():
if not self.question(
msg='\n'.join([
self.io_widget.sighash_danger.get_long_message(),
'',
_('Are you sure you want to sign this transaction?')
]),
title=self.io_widget.sighash_danger.short_message,
):
return
self.sign_button.setDisabled(True)
self.main_window.push_top_level_window(self)
self.main_window.sign_tx(self.tx, callback=sign_done, external_keypairs=self.external_keypairs)
def save(self):
self.main_window.push_top_level_window(self)
if self.main_window.save_transaction_into_wallet(self.tx):
self.store_tx_label()
self.save_button.setDisabled(True)
self.saved = True
self.main_window.pop_top_level_window(self)
def export_to_file(self, *, tx: Transaction = None):
if tx is None:
tx = self.tx
if isinstance(tx, PartialTransaction):
tx.finalize_psbt()
txid = tx.txid()
suffix = txid[0:8] if txid is not None else time.strftime('%Y%m%d-%H%M')
if tx.is_complete():
extension = 'txn'
default_filter = TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX
else:
extension = 'psbt'
default_filter = TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX
name = f'{self.wallet.basename()}-{suffix}.{extension}'
fileName = getSaveFileName(
parent=self,
title=_("Select where to save your transaction"),
filename=name,
filter=TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE,
default_extension=extension,
default_filter=default_filter,
config=self.config,
)
if not fileName:
return
if tx.is_complete(): # network tx hex
with open(fileName, "w+") as f:
network_tx_hex = tx.serialize_to_network()
f.write(network_tx_hex + '\n')
else: # if partial: PSBT bytes
assert isinstance(tx, PartialTransaction)
with open(fileName, "wb+") as f:
f.write(tx.serialize_as_bytes())
self.show_message(_("Transaction exported successfully"))
self.saved = True
def merge_sigs(self):
if not isinstance(self.tx, PartialTransaction):
return
text = text_dialog(
parent=self,
title=_('Input raw transaction'),
header_layout=_("Transaction to merge signatures from") + ":",
ok_label=_("Load transaction"),
config=self.config,
)
if not text:
return
tx = self.main_window.tx_from_text(text)
if not tx:
return
try:
self.tx.combine_with_other_psbt(tx)
except Exception as e:
self.show_error(_("Error combining partial transactions") + ":\n" + repr(e))
return
self.update()
def join_tx_with_another(self):
if not isinstance(self.tx, PartialTransaction):
return
text = text_dialog(
parent=self,
title=_('Input raw transaction'),
header_layout=_("Transaction to join with") + " (" + _("add inputs and outputs") + "):",
ok_label=_("Load transaction"),
config=self.config,
)
if not text:
return
tx = self.main_window.tx_from_text(text)
if not tx:
return
try:
self.tx.join_with_other_psbt(tx, config=self.config)
except Exception as e:
self.show_error(_("Error joining partial transactions") + ":\n" + repr(e))
return
self.update()
@rate_limited(0.5, ts_after=True)
def _throttled_update(self):
self.update()
def update(self):
if self.tx is None:
return
self.io_widget.update(self.tx)
desc = self.desc
base_unit = self.main_window.base_unit()
format_amount = self.main_window.format_amount
format_fiat_and_units = self.main_window.format_fiat_and_units
tx_details = self.wallet.get_tx_info(self.tx)
tx_mined_status = tx_details.tx_mined_status
exp_n = tx_details.mempool_depth_bytes
amount, fee = tx_details.amount, tx_details.fee
size = self.tx.estimated_size()
txid = self.tx.txid()
fx = self.main_window.fx
tx_item_fiat = None
if txid is not None and fx.is_enabled() and amount is not None:
tx_item_fiat = self.wallet.get_tx_item_fiat(
tx_hash=txid, amount_sat=abs(amount), fx=fx, tx_fee=fee)
if self.wallet.lnworker and txid:
# if it is a group, collect ln amount
full_history = self.wallet.get_full_history()
item = full_history.get('group:' + txid)
ln_amount = item['ln_value'].value if item else None
else:
ln_amount = None
self.broadcast_button.setEnabled(tx_details.can_broadcast)
can_sign = not self.tx.is_complete() and \
(self.wallet.can_sign(self.tx) or bool(self.external_keypairs))
self.sign_button.setEnabled(can_sign and not self.io_widget.sighash_danger.needs_reject())
if sh_danger_msg := self.io_widget.sighash_danger.get_long_message():
self.sign_button.setToolTip(sh_danger_msg)
if tx_details.txid:
self.tx_hash_e.setText(tx_details.txid)
else:
# note: when not finalized, RBF and locktime changes do not trigger
# a make_tx, so the txid is unreliable, hence:
self.tx_hash_e.setText(_('Unknown'))
tx_in_db = bool(self.wallet.adb.get_transaction(txid))
if not desc and not tx_in_db:
self.tx_desc.hide()
self.tx_desc_label.hide()
else:
self.tx_desc.setText(desc)
self.tx_desc.show()
self.tx_desc_label.show()
self.status_label.setText(_('Status: {}').format(tx_details.status))
if tx_mined_status.timestamp:
time_str = datetime.datetime.fromtimestamp(tx_mined_status.timestamp).isoformat(' ')[:-3]
self.date_label.setText(_("Date: {}").format(time_str))
self.date_label.show()
elif exp_n is not None:
from electrum.fee_policy import FeePolicy
self.date_label.setText(_('Position in mempool: {}').format(FeePolicy.depth_tooltip(exp_n)))
self.date_label.show()
else:
self.date_label.hide()
if self.tx.locktime <= NLOCKTIME_BLOCKHEIGHT_MAX:
locktime_str = _('height')
else:
locktime_str = datetime.datetime.fromtimestamp(self.tx.locktime)
locktime_final_str = _("LockTime: {} ({})").format(self.tx.locktime, locktime_str)
self.locktime_final_label.setText(locktime_final_str)
nsequence_time = self.tx.get_time_based_relative_locktime()
nsequence_blocks = self.tx.get_block_based_relative_locktime()
if nsequence_time or nsequence_blocks:
if nsequence_time:
seconds = nsequence_time * 512
time_str = delta_time_str(datetime.timedelta(seconds=seconds))
else:
time_str = '{} blocks'.format(nsequence_blocks)
nsequence_str = _("Relative locktime: {}").format(time_str)
self.nsequence_label.setText(nsequence_str)
else:
self.nsequence_label.hide()
# TODO: 'Yes'/'No' might be better translatable than 'True'/'False'?
self.rbf_label.setText(_('Replace by fee: {}').format(_('True') if self.tx.is_rbf_enabled() else _('False')))
if tx_mined_status.header_hash:
self.block_height_label.setText(_("At block height: {}").format(tx_mined_status.height()))
else:
self.block_height_label.hide()
if amount is None and ln_amount is None:
amount_str = _("Transaction unrelated to your wallet")
elif amount is None:
amount_str = ''
else:
amount_str = ''
if fx.is_enabled():
if tx_item_fiat: # historical tx -> using historical price
amount_str += ' ({})'.format(tx_item_fiat['fiat_value'].to_ui_string())
elif tx_details.is_related_to_wallet: # probably "tx preview" -> using current price
amount_str += ' ({})'.format(format_fiat_and_units(abs(amount)))
amount_str = format_amount(abs(amount)) + ' ' + base_unit + amount_str
if amount > 0:
amount_str = _("Amount received: {}").format(amount_str)
else:
amount_str = _("Amount sent: {}").format(amount_str)
if amount_str:
self.amount_label.setText(amount_str)
else:
self.amount_label.hide()
size_str = _("Size: {} {}").format(size, UI_UNIT_NAME_TXSIZE_VBYTES)
if fee is None:
if prog := self._fetch_txin_data_progress:
if not prog.has_errored:
fee_str = _("Downloading input data... {}").format(f"({prog.num_tasks_done}/{prog.num_tasks_total})")
else:
fee_str = _("Downloading input data... {}").format(_("error"))
else:
fee_str = _("Fee: {}").format(_("unknown"))
else:
fee_str = _("Fee: {}").format(f'{format_amount(fee)} {base_unit}')
if fx.is_enabled():
if tx_item_fiat: # historical tx -> using historical price
fee_str += ' ({})'.format(tx_item_fiat['fiat_fee'].to_ui_string())
elif tx_details.is_related_to_wallet: # probably "tx preview" -> using current price
fee_str += ' ({})'.format(format_fiat_and_units(fee))
fee_rate = Decimal(fee) / size # sat/byte
fee_str += ' ( %s ) ' % self.main_window.format_fee_rate(fee_rate * 1000)
if isinstance(self.tx, PartialTransaction):
# 'amount' is zero for self-payments, so in that case we use sum-of-outputs
invoice_amt = abs(amount) if amount else self.tx.output_value()
fee_warning_tuple = self.wallet.get_tx_fee_warning(
invoice_amt=invoice_amt, tx_size=size, fee=fee, txid=self.tx.txid())
if fee_warning_tuple:
allow_send, long_warning, short_warning = fee_warning_tuple
fee_str += " - {header}: {body}".format(
header=_('Warning'),
body=short_warning,
color=ColorScheme.RED.as_color().name(),
)
if isinstance(self.tx, PartialTransaction):
sh_warning = self.io_widget.sighash_danger.get_long_message()
self.fee_warning_icon.setToolTip(str(sh_warning))
self.fee_warning_icon.setVisible(can_sign and bool(sh_warning))
self.fee_label.setText(fee_str)
self.size_label.setText(size_str)
if ln_amount is None or ln_amount == 0:
ln_amount_str = ''
elif ln_amount > 0:
ln_amount_str = _('Amount received in channels: {}').format(format_amount(ln_amount) + ' ' + base_unit)
else:
assert ln_amount < 0, f"{ln_amount!r}"
ln_amount_str = _('Amount withdrawn from channels: {}').format(format_amount(-ln_amount) + ' ' + base_unit)
if ln_amount_str:
self.ln_amount_label.setText(ln_amount_str)
else:
self.ln_amount_label.hide()
show_psbt_only_widgets = isinstance(self.tx, PartialTransaction)
for widget in self.psbt_only_widgets:
if isinstance(widget, QMenu):
widget.menuAction().setVisible(show_psbt_only_widgets)
else:
widget.setVisible(show_psbt_only_widgets)
if tx_details.is_lightning_funding_tx:
self._ptx_join_txs_action.setEnabled(False) # would change txid
self.save_button.setEnabled(tx_details.can_save_as_local)
if tx_details.can_save_as_local:
self.save_button.setToolTip(_("Add transaction to history, without broadcasting it"))
else:
self.save_button.setToolTip(_("Transaction already in history or not yet signed."))
run_hook('transaction_dialog_update', self)
def add_tx_stats(self, vbox):
hbox_stats = QHBoxLayout()
hbox_stats.setContentsMargins(0, 0, 0, 0)
hbox_stats_w = QWidget()
hbox_stats_w.setLayout(hbox_stats)
hbox_stats_w.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
# left column
vbox_left = QVBoxLayout()
self.status_label = TxDetailLabel()
vbox_left.addWidget(self.status_label)
self.date_label = TxDetailLabel()
vbox_left.addWidget(self.date_label)
self.amount_label = TxDetailLabel()
vbox_left.addWidget(self.amount_label)
self.ln_amount_label = TxDetailLabel()
vbox_left.addWidget(self.ln_amount_label)
fee_hbox = QHBoxLayout()
self.fee_label = TxDetailLabel()
fee_hbox.addWidget(self.fee_label)
self.fee_warning_icon = QLabel()
pixmap = QPixmap(icon_path("warning"))
pixmap_size = round(2 * char_width_in_lineedit())
pixmap = pixmap.scaled(pixmap_size, pixmap_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
self.fee_warning_icon.setPixmap(pixmap)
self.fee_warning_icon.setVisible(False)
fee_hbox.addWidget(self.fee_warning_icon)
fee_hbox.addStretch(1)
vbox_left.addLayout(fee_hbox)
vbox_left.addStretch(1)
hbox_stats.addLayout(vbox_left, 50)
# vertical line separator
hbox_stats.addWidget(VLine())
# right column
vbox_right = QVBoxLayout()
self.size_label = TxDetailLabel()
vbox_right.addWidget(self.size_label)
self.rbf_label = TxDetailLabel()
vbox_right.addWidget(self.rbf_label)
self.locktime_final_label = TxDetailLabel()
vbox_right.addWidget(self.locktime_final_label)
self.nsequence_label = TxDetailLabel()
vbox_right.addWidget(self.nsequence_label)
self.block_height_label = TxDetailLabel()
vbox_right.addWidget(self.block_height_label)
vbox_right.addStretch(1)
hbox_stats.addLayout(vbox_right, 50)
vbox.addWidget(hbox_stats_w)
# set visibility after parenting can be determined by Qt
self.rbf_label.setVisible(True)
self.locktime_final_label.setVisible(True)
def set_title(self):
txid = self.tx.txid() or ""
self.setWindowTitle(_("Transaction") + ' ' + txid)
def maybe_fetch_txin_data(self):
"""Download missing input data from the network, asynchronously.
Note: we fetch the prev txs, which allows calculating the fee and showing "input addresses".
We could also SPV-verify the tx, to fill in missing tx_mined_status (block height, blockhash, timestamp),
but this is not done currently.
"""
if not self.config.GUI_QT_TX_DIALOG_FETCH_TXIN_DATA:
return
tx = self.tx
if not tx:
return
if self._fetch_txin_data_fut is not None:
return
network = self.wallet.network
def progress_cb(prog: TxinDataFetchProgress):
self._fetch_txin_data_progress = prog
self.throttled_update_sig.emit()
async def wrapper():
try:
await tx.add_info_from_network(network, progress_cb=progress_cb)
finally:
self._fetch_txin_data_fut = None
self._fetch_txin_data_progress = None
self._fetch_txin_data_fut = asyncio.run_coroutine_threadsafe(wrapper(), get_asyncio_loop())
class TxDetailLabel(QLabel):
def __init__(self, *, word_wrap=None):
super().__init__()
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
if word_wrap is not None:
self.setWordWrap(word_wrap)
class TxOutputColoring:
# used for both inputs and outputs
def __init__(
self,
*,
legend: str,
color: ColorSchemeItem,
tooltip: str,
):
self.color = color.as_color(background=True)
self.legend_label = QLabel("{box_char} = {label}".format(
color=self.color.name(),
box_char="█",
label=legend,
))
font = self.legend_label.font()
font.setPointSize(font.pointSize() - 1)
self.legend_label.setFont(font)
self.legend_label.setVisible(False)
self.text_char_format = QTextCharFormat()
self.text_char_format.setBackground(QBrush(self.color))
self.text_char_format.setToolTip(tooltip)
================================================
FILE: electrum/gui/qt/update_checker.py
================================================
# Copyright (C) 2019 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
import asyncio
import base64
from typing import Optional
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtWidgets import QVBoxLayout, QLabel, QProgressBar, QHBoxLayout, QPushButton, QDialog
from electrum import version
from electrum import constants
from electrum.bitcoin import verify_usermessage_with_address
from electrum.i18n import _
from electrum.util import make_aiohttp_session
from electrum.logging import Logger
from electrum.network import Network
from electrum._vendor.distutils.version import StrictVersion
class UpdateCheck(QDialog, Logger):
url = "https://electrum.org/version"
download_url = "https://electrum.org/#download"
VERSION_ANNOUNCEMENT_SIGNING_KEYS = (
"13xjmVAB1EATPP8RshTE8S8sNwwSUM9p1P", # ThomasV (since 3.3.4)
"1Nxgk6NTooV4qZsX5fdqQwrLjYcsQZAfTg", # ghost43 (since 4.1.2)
)
def __init__(self, *, latest_version=None):
QDialog.__init__(self)
self.setWindowTitle('Electrum - ' + _('Update Check'))
self.content = QVBoxLayout()
self.content.setContentsMargins(*[10]*4)
self.heading_label = QLabel()
self.content.addWidget(self.heading_label)
self.detail_label = QLabel()
self.detail_label.setTextInteractionFlags(Qt.TextInteractionFlag.LinksAccessibleByMouse)
self.detail_label.setOpenExternalLinks(True)
self.content.addWidget(self.detail_label)
self.pb = QProgressBar()
self.pb.setMaximum(0)
self.pb.setMinimum(0)
self.content.addWidget(self.pb)
versions = QHBoxLayout()
versions.addWidget(QLabel(_("Current version: {}").format(version.ELECTRUM_VERSION)))
self.latest_version_label = QLabel(_("Latest version: {}").format(" "))
versions.addWidget(self.latest_version_label)
self.content.addLayout(versions)
self.update_view(latest_version)
self.update_check_thread = UpdateCheckThread()
self.update_check_thread.checked.connect(self.on_version_retrieved)
self.update_check_thread.failed.connect(self.on_retrieval_failed)
self.update_check_thread.start()
close_button = QPushButton(_("Close"))
close_button.clicked.connect(self.close)
self.content.addWidget(close_button)
self.setLayout(self.content)
self.show()
def on_version_retrieved(self, version):
self.update_view(version)
def on_retrieval_failed(self):
self.heading_label.setText('
' + _("Update check failed") + '
')
self.detail_label.setText(_("Sorry, but we were unable to check for updates. Please try again later."))
self.pb.hide()
@staticmethod
def is_newer(latest_version):
return latest_version > StrictVersion(version.ELECTRUM_VERSION)
def update_view(self, latest_version=None):
if latest_version:
self.pb.hide()
self.latest_version_label.setText(_("Latest version: {}").format(latest_version))
if self.is_newer(latest_version):
self.heading_label.setText('
' + _("There is a new update available") + '
')
url = "{u}".format(u=UpdateCheck.download_url)
self.detail_label.setText(_("You can download the new version from {}.").format(url))
else:
self.heading_label.setText('
' + _("Already up to date") + '
')
self.detail_label.setText(_("You are already on the latest version of Electrum."))
else:
self.heading_label.setText('
' + _("Checking for updates...") + '
')
self.detail_label.setText(_("Please wait while Electrum checks for available updates."))
class UpdateCheckThread(QThread, Logger):
checked = pyqtSignal(object)
failed = pyqtSignal()
def __init__(self):
QThread.__init__(self)
Logger.__init__(self)
self.network = Network.get_instance()
self._fut = None # type: Optional[asyncio.Future]
async def get_update_info(self):
# note: Use long timeout here as it is not critical that we get a response fast,
# and it's bad not to get an update notification just because we did not wait enough.
async with make_aiohttp_session(proxy=self.network.proxy, timeout=120) as session:
async with session.get(UpdateCheck.url) as result:
signed_version_dict = await result.json(content_type=None)
# example signed_version_dict:
# {
# "version": "3.9.9",
# "signatures": {
# "1Lqm1HphuhxKZQEawzPse8gJtgjm9kUKT4": "IA+2QG3xPRn4HAIFdpu9eeaCYC7S5wS/sDxn54LJx6BdUTBpse3ibtfq8C43M7M1VfpGkD5tsdwl5C6IfpZD/gQ="
# }
# }
version_num = signed_version_dict['version']
sigs = signed_version_dict['signatures']
for address, sig in sigs.items():
if address not in UpdateCheck.VERSION_ANNOUNCEMENT_SIGNING_KEYS:
continue
sig = base64.b64decode(sig, validate=True)
msg = version_num.encode('utf-8')
if verify_usermessage_with_address(
address=address, sig65=sig, message=msg,
net=constants.BitcoinMainnet
):
self.logger.info(f"valid sig for version announcement '{version_num}' from address '{address}'")
break
else:
raise Exception('no valid signature for version announcement')
return StrictVersion(version_num.strip())
def run(self):
if not self.network:
self.failed.emit()
return
self._fut = asyncio.run_coroutine_threadsafe(self.get_update_info(), self.network.asyncio_loop)
try:
update_info = self._fut.result()
except Exception as e:
self.logger.info(f"got exception: '{repr(e)}'")
self.failed.emit()
else:
self.checked.emit(update_info)
def stop(self):
if self._fut:
self._fut.cancel()
self.exit()
self.wait()
================================================
FILE: electrum/gui/qt/util.py
================================================
from abc import ABC, ABCMeta
import os.path
import time
import sys
import platform
import queue
import os
import webbrowser
import ctypes
from functools import partial, lru_cache
from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Sequence, Tuple, Union)
from PyQt6 import QtCore
from PyQt6.QtGui import (QFont, QColor, QCursor, QPixmap, QImage,
QPalette, QIcon, QFontMetrics, QPainter, QContextMenuEvent, QMovie)
from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QSize, QRect, QPoint, QObject)
from PyQt6.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBoxLayout, QLineEdit,
QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton,
QFileDialog, QWidget, QToolButton, QPlainTextEdit, QApplication, QToolTip,
QGraphicsEffect, QGraphicsScene, QGraphicsPixmapItem, QLayoutItem, QLayout, QMenu,
QFrame, QAbstractButton)
from electrum.i18n import _
from electrum.util import (FileImportFailed, FileExportFailed, resource_path, EventListener,
get_logger, UserCancelled, UserFacingException, ChoiceItem)
from electrum.invoices import (PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING,
PR_UNCONFIRMED, PR_BROADCASTING, PR_BROADCAST)
from electrum.qrreader import MissingQrDetectionLib, QrCodeResult
from electrum.submarine_swaps import pubkey_to_rgb_color
from electrum.gui.common_qt.util import TaskThread
if TYPE_CHECKING:
from .main_window import ElectrumWindow
from .paytoedit import PayToEdit
from electrum.simple_config import SimpleConfig
from electrum.simple_config import ConfigVarWithConfig
if platform.system() == 'Windows':
MONOSPACE_FONT = 'Lucida Console'
elif platform.system() == 'Darwin':
MONOSPACE_FONT = 'Monaco'
else:
MONOSPACE_FONT = 'monospace'
_logger = get_logger(__name__)
dialogs = []
pr_icons = {
PR_UNKNOWN: "warning.png",
PR_UNPAID: "unpaid.png",
PR_PAID: "confirmed.png",
PR_EXPIRED: "expired.png",
PR_INFLIGHT: "unconfirmed.png",
PR_FAILED: "warning.png",
PR_ROUTING: "unconfirmed.png",
PR_UNCONFIRMED: "unconfirmed.png",
PR_BROADCASTING: "unconfirmed.png",
PR_BROADCAST: "unconfirmed.png",
}
# filter tx files in QFileDialog:
TRANSACTION_FILE_EXTENSION_FILTER_ANY = "Transaction (*.txn *.psbt);;All files (*)"
TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX = "Partial Transaction (*.psbt)"
TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX = "Complete Transaction (*.txn)"
TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE = (f"{TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX};;"
f"{TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX};;"
f"All files (*)")
class EnterButton(QPushButton):
def __init__(self, text, func):
QPushButton.__init__(self, text)
self.func = func
self.clicked.connect(func)
self._orig_text = text
def keyPressEvent(self, e):
if e.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter]:
self.func()
def restore_original_text(self):
self.setText(self._orig_text)
class ThreadedButton(QPushButton):
def __init__(self, text, task, on_success=None, on_error=None):
QPushButton.__init__(self, text)
self.task = task
self.on_success = on_success
self.on_error = on_error
self.clicked.connect(self.run_task)
def run_task(self):
self.setEnabled(False)
self.thread = TaskThread(self)
self.thread.add(self.task, self.on_success, self.done, self.on_error)
def done(self):
self.setEnabled(True)
self.thread.stop()
class WWLabel(QLabel):
"""Word-wrapping label"""
def __init__(self, text="", parent=None):
QLabel.__init__(self, text, parent)
self.setWordWrap(True)
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
class RichLabel(WWLabel):
"""Word-wrapping label with link activation"""
def __init__(self, text='', parent=None):
WWLabel.__init__(self, text, parent)
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.setOpenExternalLinks(True)
class AmountLabel(QLabel):
def __init__(self, *args, **kwargs):
QLabel.__init__(self, *args, **kwargs)
self.setFont(QFont(MONOSPACE_FONT))
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
class Spinner(QLabel):
def __init__(self, *args, **kwargs):
QLabel.__init__(self, *args, **kwargs)
self.spinner = QMovie(icon_path('spinner.gif'))
self.spinner.setScaledSize(QSize(20, 20))
self.spinner.frameChanged.connect(lambda: self.setPixmap(self.spinner.currentPixmap()))
self.setVisible(False)
def setVisible(self, visible):
if visible:
self.spinner.start()
else:
self.spinner.stop()
super().setVisible(visible)
class HelpMixin:
def __init__(self, help_text: str, *, help_title: str = None):
assert isinstance(self, QWidget), "HelpMixin must be a QWidget instance!"
self.help_text = help_text
self._help_title = help_title or _('Help')
if isinstance(self, QLabel):
self.setTextInteractionFlags(
(self.textInteractionFlags() | Qt.TextInteractionFlag.TextSelectableByMouse)
& ~Qt.TextInteractionFlag.TextSelectableByKeyboard)
def show_help(self):
custom_message_box(
icon=QMessageBox.Icon.Information,
parent=self,
title=self._help_title,
text=self.help_text,
rich_text=True,
)
class HelpLabel(HelpMixin, QLabel):
def __init__(self, text: str, help_text: str):
QLabel.__init__(self, text)
HelpMixin.__init__(self, help_text)
self.app = QCoreApplication.instance()
self.font = self.font()
@classmethod
def from_configvar(cls, cv: 'ConfigVarWithConfig') -> 'HelpLabel':
return HelpLabel(cv.get_short_desc() + ':', cv.get_long_desc())
def mouseReleaseEvent(self, x):
self.show_help()
def enterEvent(self, event):
self.font.setUnderline(True)
self.setFont(self.font)
self.app.setOverrideCursor(QCursor(Qt.CursorShape.PointingHandCursor))
return QLabel.enterEvent(self, event)
def leaveEvent(self, event):
self.font.setUnderline(False)
self.setFont(self.font)
self.app.setOverrideCursor(QCursor(Qt.CursorShape.ArrowCursor))
return QLabel.leaveEvent(self, event)
class HelpButton(HelpMixin, QToolButton):
def __init__(self, text: str):
QToolButton.__init__(self)
HelpMixin.__init__(self, text)
self.setText('?')
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setFixedWidth(round(2.2 * char_width_in_lineedit()))
self.clicked.connect(self.show_help)
class InfoButton(HelpMixin, QPushButton):
def __init__(self, text: str):
QPushButton.__init__(self, _('Info'))
HelpMixin.__init__(self, text, help_title=_('Info'))
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setFixedWidth(6 * char_width_in_lineedit())
self.clicked.connect(self.show_help)
class Buttons(QHBoxLayout):
def __init__(self, *buttons):
QHBoxLayout.__init__(self)
self.addStretch(1)
for b in buttons:
if b is None:
continue
self.addWidget(b)
class CloseButton(QPushButton):
def __init__(self, dialog):
QPushButton.__init__(self, _("Close"))
self.clicked.connect(dialog.close)
self.setDefault(True)
class CopyButton(QPushButton):
def __init__(self, text_getter, app):
QPushButton.__init__(self, _("Copy"))
self.clicked.connect(lambda: app.clipboard().setText(text_getter()))
class CopyCloseButton(QPushButton):
def __init__(self, text_getter, app, dialog):
QPushButton.__init__(self, _("Copy and Close"))
self.clicked.connect(lambda: app.clipboard().setText(text_getter()))
self.clicked.connect(dialog.close)
self.setDefault(True)
class OkButton(QPushButton):
def __init__(self, dialog, label=None):
QPushButton.__init__(self, label or _("OK"))
self.clicked.connect(dialog.accept)
self.setDefault(True)
class CancelButton(QPushButton):
def __init__(self, dialog, label=None):
QPushButton.__init__(self, label or _("Cancel"))
self.clicked.connect(dialog.reject)
class MessageBoxMixin(object):
def top_level_window_recurse(self, window=None, test_func=None):
window = window or self
classes = (WindowModalDialog, QMessageBox)
if test_func is None:
test_func = lambda x: True
for n, child in enumerate(window.children()):
# Test for visibility as old closed dialogs may not be GC-ed.
# Only accept children that confirm to test_func.
if isinstance(child, classes) and child.isVisible() \
and test_func(child):
return self.top_level_window_recurse(child, test_func=test_func)
return window
def top_level_window(self, test_func=None):
return self.top_level_window_recurse(test_func)
def question(self, msg, parent=None, title=None, icon=None, **kwargs) -> bool:
yes, no = QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No
return yes == self.msg_box(icon=icon or QMessageBox.Icon.Question,
parent=parent,
title=title or '',
text=msg,
buttons=yes | no,
defaultButton=no,
**kwargs)
def show_warning(self, msg, parent=None, title=None, **kwargs):
return self.msg_box(QMessageBox.Icon.Warning, parent,
title or _('Warning'), msg, **kwargs)
def show_error(self, msg, parent=None, **kwargs):
return self.msg_box(QMessageBox.Icon.Warning, parent,
_('Error'), msg, **kwargs)
def show_critical(self, msg, parent=None, title=None, **kwargs):
return self.msg_box(QMessageBox.Icon.Critical, parent,
title or _('Critical Error'), msg, **kwargs)
def show_message(self, msg, parent=None, title=None, icon=QMessageBox.Icon.Information, **kwargs):
return self.msg_box(icon, parent, title or _('Information'), msg, **kwargs)
def msg_box(
self,
icon: Union[QMessageBox.Icon, QPixmap],
parent: QWidget,
title: str,
text: str,
*,
buttons: Union[QMessageBox.StandardButton,
List[Union[QMessageBox.StandardButton, Tuple[QAbstractButton, QMessageBox.ButtonRole, int]]]] = QMessageBox.StandardButton.Ok,
defaultButton: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton,
rich_text: bool = False,
checkbox: Optional[bool] = None
):
parent = parent or self.top_level_window()
return custom_message_box(
icon=icon, parent=parent, title=title, text=text, buttons=buttons, defaultButton=defaultButton,
rich_text=rich_text, checkbox=checkbox
)
def query_choice(
self,
msg: Optional[str],
choices: Sequence['ChoiceItem'],
*,
title: Optional[str] = None,
default_key: Optional[Any] = None,
) -> Optional[Any]:
"""Returns ChoiceItem.key (for selected item), or None if the user cancels the dialog.
Needed by QtHandler for hardware wallets.
"""
if title is None:
title = _('Question')
dialog = WindowModalDialog(self.top_level_window(), title=title)
dialog.setMinimumWidth(400)
choice_widget = ChoiceWidget(message=msg, choices=choices, default_key=default_key)
vbox = QVBoxLayout(dialog)
vbox.addWidget(choice_widget)
cancel_button = CancelButton(dialog)
vbox.addLayout(Buttons(cancel_button, OkButton(dialog)))
cancel_button.setFocus()
if not dialog.exec():
return None
return choice_widget.selected_key
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 custom_message_box(
*,
icon: Union[QMessageBox.Icon, QPixmap],
parent: QWidget,
title: str,
text: str,
buttons: Union[QMessageBox.StandardButton,
List[Union[QMessageBox.StandardButton, Tuple[QAbstractButton, QMessageBox.ButtonRole, int]]]] = QMessageBox.StandardButton.Ok,
defaultButton: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton,
rich_text: bool = False,
checkbox: Optional[bool] = None
) -> int:
custom_buttons = []
standard_buttons = QMessageBox.StandardButton.NoButton
if buttons:
if not isinstance(buttons, list):
buttons = [buttons]
for button in buttons:
if isinstance(button, QMessageBox.StandardButton):
standard_buttons |= button
else:
custom_buttons.append(button)
if type(icon) is QPixmap:
d = QMessageBox(QMessageBox.Icon.Information, title, str(text), standard_buttons, parent)
d.setIconPixmap(icon)
else:
d = QMessageBox(icon, title, str(text), standard_buttons, parent)
for button, role, _ in custom_buttons:
d.addButton(button, role)
d.setWindowModality(Qt.WindowModality.WindowModal)
d.setDefaultButton(defaultButton)
if rich_text:
d.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.LinksAccessibleByMouse)
# set AutoText instead of RichText
# AutoText lets Qt figure out whether to render as rich text.
# e.g. if text is actually plain text and uses "\n" newlines;
# and we set RichText here, newlines would be swallowed
d.setTextFormat(Qt.TextFormat.AutoText)
else:
d.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
d.setTextFormat(Qt.TextFormat.PlainText)
if checkbox is not None:
d.setCheckBox(checkbox)
result = d.exec()
for button, _, value in custom_buttons:
if button == d.clickedButton():
return value
return result
class WindowModalDialog(QDialog, MessageBoxMixin):
'''Handy wrapper; window modal dialogs are better for our multi-window
daemon model as other wallet windows can still be accessed.'''
def __init__(self, parent, title=None):
QDialog.__init__(self, parent)
self.setWindowModality(Qt.WindowModality.WindowModal)
if title:
self.setWindowTitle(title)
class WaitingDialog(WindowModalDialog):
'''Shows a please wait dialog whilst running a task. It is not
necessary to maintain a reference to this dialog.'''
def __init__(self, parent: QWidget, message: str, task, on_success=None, on_error=None, on_cancel=None):
assert parent
if isinstance(parent, MessageBoxMixin):
parent = parent.top_level_window()
WindowModalDialog.__init__(self, parent, _("Please wait"))
self.message_label = QLabel(message)
vbox = QVBoxLayout(self)
vbox.addWidget(self.message_label)
if on_cancel:
self.cancel_button = CancelButton(self)
self.cancel_button.clicked.connect(on_cancel)
vbox.addLayout(Buttons(self.cancel_button))
self.accepted.connect(self.on_accepted)
self.show()
self.thread = TaskThread(self)
self.thread.finished.connect(self.deleteLater) # see #3956
self.thread.add(task, on_success, self.accept, on_error)
def wait(self):
self.thread.wait()
def on_accepted(self):
self.thread.stop()
def update(self, msg):
print(msg)
self.message_label.setText(msg)
class RunCoroutineDialog(WaitingDialog):
def __init__(self, parent: QWidget, message: str, coroutine):
from electrum import util
import asyncio
import concurrent.futures
loop = util.get_asyncio_loop()
assert util.get_running_loop() != loop, 'must not be called from asyncio thread'
self._exception = None
self._result = None
self._future = asyncio.run_coroutine_threadsafe(coroutine, loop)
def task():
try:
self._result = self._future.result()
except concurrent.futures.CancelledError:
self._exception = UserCancelled
except Exception as e:
self._exception = e
WaitingDialog.__init__(self, parent, message, task, on_cancel=self._future.cancel)
def run(self):
self.exec()
if self._exception:
raise self._exception
else:
return self._result
def line_dialog(parent, title, label, ok_label, default=None):
dialog = WindowModalDialog(parent, title)
dialog.setMinimumWidth(500)
l = QVBoxLayout()
dialog.setLayout(l)
l.addWidget(QLabel(label))
txt = QLineEdit()
if default:
txt.setText(default)
l.addWidget(txt)
l.addLayout(Buttons(CancelButton(dialog), OkButton(dialog, ok_label)))
if dialog.exec():
return txt.text()
def text_dialog(
*,
parent,
title,
header_layout,
ok_label,
default=None,
allow_multi=False,
config: 'SimpleConfig',
):
from .qrtextedit import ScanQRTextEdit
dialog = WindowModalDialog(parent, title)
dialog.setMinimumWidth(600)
l = QVBoxLayout()
dialog.setLayout(l)
if isinstance(header_layout, str):
l.addWidget(QLabel(header_layout))
else:
l.addLayout(header_layout)
txt = ScanQRTextEdit(allow_multi=allow_multi, config=config)
if default:
txt.setText(default)
l.addWidget(txt)
l.addLayout(Buttons(CancelButton(dialog), OkButton(dialog, ok_label)))
if dialog.exec():
return txt.toPlainText()
class ChoiceWidget(QWidget):
"""Renders a list of ChoiceItems as a radiobuttons group.
Callers can pre-select an item by key, through the 'default_key' parameter.
The selected item is made available by index (selected_index),
by key (selected_key) and by Choice (selected_item).
"""
itemSelected = pyqtSignal([int], arguments=['index'])
def __init__(
self,
*,
message: Optional[str] = None,
choices: Sequence[ChoiceItem] = None,
default_key: Optional[Any] = None,
):
QWidget.__init__(self)
vbox = QVBoxLayout()
self.setLayout(vbox)
if choices is None:
choices = []
self.selected_index = -1 # type: int
self.selected_item = None # type: Optional[ChoiceItem]
self.selected_key = None # type: Optional[Any]
self.choices = choices # type: Sequence[ChoiceItem]
if message and len(message) > 50:
vbox.addWidget(WWLabel(message))
message = ""
gb2 = QGroupBox(message)
vbox.addWidget(gb2)
vbox2 = QVBoxLayout()
gb2.setLayout(vbox2)
self.group = group = QButtonGroup()
assert isinstance(choices, list)
for i, c in enumerate(choices):
assert isinstance(c, ChoiceItem), f"{c=!r}"
button = QRadioButton(gb2)
button.setText(c.label)
vbox2.addWidget(button)
group.addButton(button)
group.setId(button, i)
if (i == 0 and default_key is None) or c.key == default_key:
self.selected_index = i
self.selected_item = c
self.selected_key = c.key
button.setChecked(True)
group.buttonClicked.connect(self.on_selected)
def on_selected(self, button):
self.selected_index = self.group.id(button)
self.selected_item = self.choices[self.selected_index]
self.selected_key = self.choices[self.selected_index].key
self.itemSelected.emit(self.selected_index)
def select(self, key):
for i, c in enumerate(self.choices):
if key == c.key:
self.group.button(i).click()
class ResizableStackedWidget(QWidget):
"""Simple alternative to QStackedWidget, as QStackedWidget always resizes to the largest
widget in the stack, leaving ugly scrollbars where they're not needed."""
def __init__(self, parent):
super().__init__(parent)
self.setLayout(QVBoxLayout())
self.widgets = []
self.current_index = -1
def sizeHint(self) -> QSize:
if not self.count() or not self.currentWidget():
return super().sizeHint()
return self.currentWidget().sizeHint()
def addWidget(self, widget: QWidget) -> int:
self.widgets.append(widget)
self.layout().addWidget(widget)
if len(self.widgets) == 1: # first widget?
self.current_index = 0
self.showCurrentWidget()
return len(self.widgets) - 1
def removeWidget(self, widget: QWidget):
i = self.widgets.index(widget)
self.widgets.remove(widget)
self.layout().removeWidget(widget)
if self.current_index >= i:
self.current_index -= 1
if self.current_index == self.count() - 1:
self.showCurrentWidget()
def setCurrentIndex(self, index: int):
assert isinstance(index, int)
assert 0 <= index < len(self.widgets), f'invalid widget index {index}'
self.current_index = index
self.showCurrentWidget()
def currentWidget(self) -> Optional[QWidget]:
if self.current_index < 0:
return None
return self.widgets[self.current_index]
def showCurrentWidget(self):
if not self.widgets:
return
for i, k in enumerate(self.widgets):
if i == self.current_index:
k.show()
else:
k.hide()
def count(self) -> int:
return len(self.widgets)
class VLine(QFrame):
"""Vertical line separator"""
def __init__(self):
super(VLine, self).__init__()
self.setFrameShape(QFrame.Shape.VLine)
self.setFrameShadow(QFrame.Shadow.Sunken)
self.setLineWidth(1)
def address_field(addresses, *, btn_text: str = None):
if btn_text is None:
btn_text = _('Get wallet address')
hbox = QHBoxLayout()
address_e = QLineEdit()
if addresses and len(addresses) > 0:
address_e.setText(addresses[0])
else:
addresses = []
def func():
try:
i = addresses.index(str(address_e.text())) + 1
i = i % len(addresses)
address_e.setText(addresses[i])
except ValueError:
# the user might have changed address_e to an
# address not in the wallet (or to something that isn't an address)
if addresses and len(addresses) > 0:
address_e.setText(addresses[0])
button = QPushButton(btn_text)
button.clicked.connect(func)
hbox.addWidget(button)
hbox.addWidget(address_e)
return hbox, address_e
def filename_field(parent, config, defaultname, select_msg):
vbox = QVBoxLayout()
vbox.addWidget(QLabel(_("Format")))
gb = QGroupBox("format", parent)
b1 = QRadioButton(gb)
b1.setText(_("CSV"))
b1.setChecked(True)
b2 = QRadioButton(gb)
b2.setText(_("json"))
vbox.addWidget(b1)
vbox.addWidget(b2)
hbox = QHBoxLayout()
directory = config.IO_DIRECTORY
path = os.path.join(directory, defaultname)
filename_e = QLineEdit()
filename_e.setText(path)
def func():
text = filename_e.text()
_filter = "*.csv" if defaultname.endswith(".csv") else "*.json" if defaultname.endswith(".json") else None
p = getSaveFileName(
parent=None,
title=select_msg,
filename=text,
filter=_filter,
config=config,
)
if p:
filename_e.setText(p)
button = QPushButton(_('File'))
button.clicked.connect(func)
hbox.addWidget(button)
hbox.addWidget(filename_e)
vbox.addLayout(hbox)
def set_csv(v):
text = filename_e.text()
text = text.replace(".json",".csv") if v else text.replace(".csv",".json")
filename_e.setText(text)
b1.clicked.connect(lambda: set_csv(True))
b2.clicked.connect(lambda: set_csv(False))
return vbox, filename_e, b1
def get_icon_qrcode() -> QIcon:
name = "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png"
return read_QIcon(name)
def get_icon_camera() -> QIcon:
name = "camera_white.png" if ColorScheme.dark_scheme else "camera_dark.png"
return read_QIcon(name)
def pubkey_to_q_icon(server_pubkey: str) -> QIcon:
color = QColor(*pubkey_to_rgb_color(server_pubkey))
color_pixmap = QPixmap(100, 100)
color_pixmap.fill(color)
return QIcon(color_pixmap)
def add_input_actions_to_context_menu(gih: 'GenericInputHandler', m: QMenu) -> None:
if gih.on_qr_from_camera_input_btn:
m.addAction(get_icon_camera(), _("Read QR code with camera"), gih.on_qr_from_camera_input_btn)
if gih.on_qr_from_screenshot_input_btn:
m.addAction(read_QIcon("picture_in_picture.png"), _("Read QR code from screen"), gih.on_qr_from_screenshot_input_btn)
if gih.on_qr_from_file_input_btn:
m.addAction(read_QIcon("qr_file.png"), _("Read QR code from file"), gih.on_qr_from_file_input_btn)
if gih.on_input_file:
m.addAction(read_QIcon("file.png"), _("Read text from file"), gih.on_input_file)
def scan_qr_from_screenshot() -> QrCodeResult:
from .qrreader import scan_qr_from_image
screenshots = [screen.grabWindow(0).toImage()
for screen in QApplication.instance().screens()]
if all(screen.allGray() for screen in screenshots):
raise UserFacingException(_("Failed to take screenshot."))
scanned_qr = None
for screenshot in screenshots:
try:
scan_result = scan_qr_from_image(screenshot)
except MissingQrDetectionLib as e:
raise UserFacingException(_("Unable to scan image.") + "\n" + repr(e))
if len(scan_result) > 0:
if (scanned_qr is not None) or len(scan_result) > 1:
raise UserFacingException(_("More than one QR code was found on the screen."))
scanned_qr = scan_result
if scanned_qr is None:
raise UserFacingException(_("No QR code was found on the screen."))
assert len(scanned_qr) == 1, f"{len(scanned_qr)=}, expected 1"
return scanned_qr[0]
class GenericInputHandler:
on_qr_from_camera_input_btn: Callable[[], None] = None
on_qr_from_screenshot_input_btn: Callable[[], None] = None
on_qr_from_file_input_btn: Callable[[], None] = None
on_input_file: Callable[[], None] = None
def input_qr_from_camera(
self,
*,
config: 'SimpleConfig',
allow_multi: bool = False,
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
parent: QWidget = None,
) -> None:
if setText is None:
setText = self.setText
def cb(success: bool, error: str, data: Optional[str]):
if not success:
if error:
show_error(error)
return
if not data:
data = ''
try:
if allow_multi:
text = self.text()
if data in text:
return
if text and not text.endswith('\n'):
text += '\n'
text += data
text += '\n'
setText(text)
else:
new_text = data
setText(new_text)
except Exception as e:
show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e))
from .qrreader import scan_qrcode_from_camera
if parent is None:
parent = self if isinstance(self, QWidget) else None
scan_qrcode_from_camera(parent=parent, config=config, callback=cb)
def input_qr_from_screenshot(
self,
*,
allow_multi: bool = False,
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
) -> None:
if setText is None:
setText = self.setText
try:
scanned_qr = scan_qr_from_screenshot()
except UserFacingException as e:
show_error(str(e))
return
data = scanned_qr.data
try:
if allow_multi:
text = self.text()
if data in text:
return
if text and not text.endswith('\n'):
text += '\n'
text += data
text += '\n'
setText(text)
else:
new_text = data
setText(new_text)
except Exception as e:
show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e))
def input_file(
self,
*,
config: 'SimpleConfig',
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
) -> None:
if setText is None:
setText = self.setText
fileName = getOpenFileName(
parent=None,
title='select file',
# trying to open non-text things like pdfs makes electrum freeze
filter="Text files (*.txt *.csv);;All files (*)",
config=config,
)
if not fileName:
return
try:
try:
with open(fileName, "r") as f:
data = f.read()
except UnicodeError as e:
with open(fileName, "rb") as f:
data = f.read()
data = data.hex()
except BaseException as e:
show_error(_('Error opening file') + ':\n' + repr(e))
else:
try:
setText(data)
except Exception as e:
show_error(_('Invalid payment identifier in file') + ':\n' + repr(e))
def input_qr_from_file(
self,
*,
allow_multi: bool = False,
config: 'SimpleConfig',
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
):
from .qrreader import scan_qr_from_image
if setText is None:
setText = self.setText
file_name = getOpenFileName(
parent=None,
title=_("Select image file"),
config=config,
filter="Image files (*.png *.jpg *.jpeg *.bmp);;",
)
if not file_name:
return
image = QImage(file_name)
if image.isNull():
show_error(_("Failed to open image file."))
return
try:
scan_result: Sequence[QrCodeResult] = scan_qr_from_image(image)
except MissingQrDetectionLib as e:
show_error(_("Unable to scan image.") + "\n" + repr(e))
return
if len(scan_result) < 1:
show_error(_("No QR code was found in the image."))
return
if len(scan_result) > 1 and not allow_multi:
show_error(_("More than one QR code was found in the image."))
return
if len(scan_result) > 1:
result_text = "\n".join([r.data for r in scan_result])
else:
result_text = scan_result[0].data
try:
setText(result_text)
except Exception as e:
show_error(_("Couldn't set result") + ':\n' + repr(e))
def input_paste_from_clipboard(
self,
*,
setText: Callable[[str], None] = None,
) -> None:
if setText is None:
setText = self.setText
app = QApplication.instance()
setText(app.clipboard().text())
class OverlayControlMixin(GenericInputHandler):
STYLE_SHEET_COMMON = '''
QPushButton { border-width: 1px; padding: 0px; margin: 0px; }
'''
STYLE_SHEET_LIGHT = '''
QPushButton { border: 1px solid transparent; }
QPushButton:hover { border: 1px solid #3daee9; }
'''
def __init__(self, middle: bool = False):
GenericInputHandler.__init__(self)
assert isinstance(self, QWidget)
assert isinstance(self, OverlayControlMixin) # only here for type-hints in IDE
self.middle = middle
self.overlay_widget = QWidget(self)
style_sheet = self.STYLE_SHEET_COMMON
if not ColorScheme.dark_scheme:
style_sheet = style_sheet + self.STYLE_SHEET_LIGHT
self.overlay_widget.setStyleSheet(style_sheet)
self.overlay_layout = QHBoxLayout(self.overlay_widget)
self.overlay_layout.setContentsMargins(0, 0, 0, 0)
self.overlay_layout.setSpacing(1)
self._updateOverlayPos()
def resizeEvent(self, e):
super().resizeEvent(e)
self._updateOverlayPos()
def _updateOverlayPos(self):
frame_width = self.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth)
overlay_size = self.overlay_widget.sizeHint()
x = self.rect().right() - frame_width - overlay_size.width()
y = self.rect().bottom() - overlay_size.height()
middle = self.middle
if hasattr(self, 'document'):
# Keep the buttons centered if we have less than 2 lines in the editor
line_spacing = QFontMetrics(self.document().defaultFont()).lineSpacing()
if self.rect().height() < (line_spacing * 2):
middle = True
y = (y / 2) + frame_width if middle else y - frame_width
if hasattr(self, 'verticalScrollBar') and self.verticalScrollBar().isVisible():
scrollbar_width = self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent)
x -= scrollbar_width
self.overlay_widget.move(int(x), int(y))
def addWidget(self, widget: QWidget):
# The old code positioned the items the other way around, so we just insert at position 0 instead
self.overlay_layout.insertWidget(0, widget)
def addButton(self, icon: QIcon, on_click, tooltip: str) -> QPushButton:
button = QPushButton(self.overlay_widget)
button.setToolTip(tooltip)
button.setIcon(icon)
button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
button.clicked.connect(on_click)
self.addWidget(button)
return button
def addCopyButton(self):
def on_copy():
app = QApplication.instance()
app.clipboard().setText(self.text())
QToolTip.showText(QCursor.pos(), _("Text copied to clipboard"), self)
self.addButton(read_QIcon("copy.png"), on_copy, _("Copy to clipboard"))
def addPasteButton(
self,
*,
setText: Callable[[str], None] = None,
):
input_paste_from_clipboard = partial(
self.input_paste_from_clipboard,
setText=setText,
)
self.addButton(read_QIcon("copy.png"), input_paste_from_clipboard, _("Paste from clipboard"))
def add_qr_show_button(self, *, config: 'SimpleConfig', title: Optional[str] = None):
if title is None:
title = _("QR code")
def qr_show():
from .qrcodewidget import QRDialog
try:
s = str(self.text())
except Exception:
s = self.text()
if not s:
return
QRDialog(
data=s,
parent=self,
title=title,
config=config,
).exec()
self.addButton(get_icon_qrcode(), qr_show, _("Show as QR code"))
# side-effect: we export this method:
self.on_qr_show_btn = qr_show
def add_qr_input_from_camera_button(
self,
*,
config: 'SimpleConfig',
allow_multi: bool = False,
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
):
input_qr_from_camera = partial(
self.input_qr_from_camera,
config=config,
allow_multi=allow_multi,
show_error=show_error,
setText=setText,
)
self.addButton(get_icon_camera(), input_qr_from_camera, _("Read QR code with camera"))
# side-effect: we export these methods:
self.on_qr_from_camera_input_btn = input_qr_from_camera
def add_file_input_button(
self,
*,
config: 'SimpleConfig',
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
) -> None:
input_file = partial(
self.input_file,
config=config,
show_error=show_error,
setText=setText,
)
self.addButton(read_QIcon("file.png"), input_file, _("Read file"))
def add_menu_button(
self,
*,
options: Sequence[Tuple[Optional[Union[str, QIcon]], str, Callable[[], None]]], # list of (icon, text, cb)
icon: Optional[QIcon] = None,
tooltip: Optional[str] = None,
):
if icon is None:
icon_name = "menu_vertical_white.png" if ColorScheme.dark_scheme else "menu_vertical.png"
icon = read_QIcon(icon_name)
if tooltip is None:
tooltip = _("Other options")
btn = self.addButton(icon, lambda: None, tooltip)
menu = QMenu()
for opt_icon, opt_text, opt_cb in options:
if opt_icon is None:
menu.addAction(opt_text, opt_cb)
else:
opt_icon = read_QIcon(opt_icon) if isinstance(opt_icon, str) else opt_icon
menu.addAction(opt_icon, opt_text, opt_cb)
btn.setMenu(menu)
class ButtonsLineEdit(OverlayControlMixin, QLineEdit):
def __init__(self, text=None):
QLineEdit.__init__(self, text)
OverlayControlMixin.__init__(self, middle=True)
class ShowQRLineEdit(ButtonsLineEdit):
""" read-only line with qr and copy buttons """
def __init__(self, text: str, config, title=None):
ButtonsLineEdit.__init__(self, text)
self.setReadOnly(True)
self.setFont(QFont(MONOSPACE_FONT))
self.add_qr_show_button(config=config, title=title)
self.addCopyButton()
class ButtonsTextEdit(OverlayControlMixin, QPlainTextEdit):
def __init__(self, text=None):
QPlainTextEdit.__init__(self, text)
OverlayControlMixin.__init__(self)
self.setText = self.setPlainText
self.text = self.toPlainText
class PasswordLineEdit(QLineEdit):
def __init__(self, *args, **kwargs):
QLineEdit.__init__(self, *args, **kwargs)
self.setEchoMode(QLineEdit.EchoMode.Password)
def clear(self):
# Try to actually overwrite the memory.
# This is really just a best-effort thing...
self.setText(len(self.text()) * " ")
super().clear()
class ColorSchemeItem:
def __init__(self, fg_color, bg_color):
self.colors = (fg_color, bg_color)
def _get_color(self, background):
return self.colors[(int(background) + int(ColorScheme.dark_scheme)) % 2]
def as_stylesheet(self, background=False):
css_prefix = "background-" if background else ""
color = self._get_color(background)
return "QWidget {{ {}color:{}; }}".format(css_prefix, color)
def as_color(self, background=False):
color = self._get_color(background)
return QColor(color)
class ColorScheme:
dark_scheme = False
GREEN = ColorSchemeItem("#117c11", "#8af296")
YELLOW = ColorSchemeItem("#897b2a", "#ffff00")
RED = ColorSchemeItem("#7c1111", "#f18c8c")
BLUE = ColorSchemeItem("#123b7c", "#8cb3f2")
LIGHTBLUE = ColorSchemeItem("black", "#d0f0ff")
DEFAULT = ColorSchemeItem("black", "white")
GRAY = ColorSchemeItem("gray", "gray")
ORANGE = ColorSchemeItem("#ff9b45", "#ff9b45")
@staticmethod
def has_dark_background(widget):
brightness = sum(widget.palette().color(QPalette.ColorRole.Window).getRgb()[0:3])
return brightness < (255*3/2)
@staticmethod
def update_from_widget(widget, force_dark=False):
ColorScheme.dark_scheme = bool(force_dark or ColorScheme.has_dark_background(widget))
class AcceptFileDragDrop:
def __init__(self, file_type=""):
assert isinstance(self, QWidget)
self.setAcceptDrops(True)
self.file_type = file_type
def validateEvent(self, event):
if not event.mimeData().hasUrls():
event.ignore()
return False
for url in event.mimeData().urls():
if not url.toLocalFile().endswith(self.file_type):
event.ignore()
return False
event.accept()
return True
def dragEnterEvent(self, event):
self.validateEvent(event)
def dragMoveEvent(self, event):
if self.validateEvent(event):
event.setDropAction(Qt.DropAction.CopyAction)
def dropEvent(self, event):
if self.validateEvent(event):
for url in event.mimeData().urls():
self.onFileAdded(url.toLocalFile())
def onFileAdded(self, fn):
raise NotImplementedError()
def import_meta_gui(electrum_window: 'ElectrumWindow', title, importer, on_success):
filter_ = "JSON (*.json);;All files (*)"
filename = getOpenFileName(
parent=electrum_window,
title=_("Open {} file").format(title),
filter=filter_,
config=electrum_window.config,
)
if not filename:
return
try:
importer(filename)
except FileImportFailed as e:
electrum_window.show_critical(str(e))
else:
electrum_window.show_message(_("Your {} were successfully imported").format(title))
on_success()
def export_meta_gui(electrum_window: 'ElectrumWindow', title, exporter):
filter_ = "JSON (*.json);;All files (*)"
filename = getSaveFileName(
parent=electrum_window,
title=_("Select file to save your {}").format(title),
filename='electrum_{}.json'.format(title),
filter=filter_,
config=electrum_window.config,
)
if not filename:
return
try:
exporter(filename)
except FileExportFailed as e:
electrum_window.show_critical(str(e))
else:
electrum_window.show_message(_("Your {0} were exported to '{1}'")
.format(title, str(filename)))
def getOpenFileName(*, parent, title, filter="", config: 'SimpleConfig') -> Optional[str]:
"""Custom wrapper for getOpenFileName that remembers the path selected by the user."""
directory = config.IO_DIRECTORY
fileName, __ = QFileDialog.getOpenFileName(parent, title, directory, filter)
if fileName and directory != os.path.dirname(fileName):
config.IO_DIRECTORY = os.path.dirname(fileName)
return fileName
def getSaveFileName(
*,
parent,
title,
filename,
filter="",
default_extension: str = None,
default_filter: str = None,
config: 'SimpleConfig',
) -> Optional[str]:
"""Custom wrapper for getSaveFileName that remembers the path selected by the user."""
directory = config.IO_DIRECTORY
path = os.path.join(directory, filename)
file_dialog = QFileDialog(parent, title, path, filter)
file_dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
if default_extension:
# note: on MacOS, the selected filter's first extension seems to have priority over this...
file_dialog.setDefaultSuffix(default_extension)
if default_filter:
assert default_filter in filter, f"default_filter={default_filter!r} does not appear in filter={filter!r}"
file_dialog.selectNameFilter(default_filter)
if file_dialog.exec() != QDialog.DialogCode.Accepted:
return None
selected_path = file_dialog.selectedFiles()[0]
if selected_path and directory != os.path.dirname(selected_path):
config.IO_DIRECTORY = os.path.dirname(selected_path)
return selected_path
def icon_path(icon_basename: str):
return resource_path('gui', 'icons', icon_basename)
def internal_plugin_icon_path(plugin_name, icon_basename: str):
return resource_path('plugins', plugin_name, icon_basename)
@lru_cache(maxsize=1000)
def read_QIcon(icon_basename: str) -> QIcon:
return QIcon(icon_path(icon_basename))
def read_QPixmap_from_bytes(b: bytes) -> QPixmap:
qp = QPixmap()
qp.loadFromData(b)
return qp
def read_QIcon_from_bytes(b: bytes) -> QIcon:
qp = read_QPixmap_from_bytes(b)
return QIcon(qp)
class IconLabel(QWidget):
HorizontalSpacing = 2
def __init__(self, *, text='', final_stretch=True, reverse=False, hide_if_empty=False):
super(QWidget, self).__init__()
self.hide_if_empty = hide_if_empty
size = max(16, font_height())
self.icon_size = QSize(size, size)
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
self.icon = QLabel()
self.label = QLabel(text)
self.label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
layout.addWidget(self.icon if reverse else self.label)
layout.addSpacing(self.HorizontalSpacing)
layout.addWidget(self.label if reverse else self.icon)
if final_stretch:
layout.addStretch()
self.setText(text)
def setText(self, text):
self.label.setText(text)
if self.hide_if_empty:
self.setVisible(bool(text))
def setIcon(self, icon):
self.icon.setPixmap(icon.pixmap(self.icon_size))
self.icon.repaint() # macOS hack for #6269
def char_width_in_lineedit() -> int:
char_width = QFontMetrics(QLineEdit().font()).averageCharWidth()
# 'averageCharWidth' seems to underestimate on Windows, hence 'max()'
return max(9, char_width)
def font_height(widget: QWidget = None) -> int:
if widget is None:
widget = QLabel()
return QFontMetrics(widget.font()).height()
def webopen(url: str):
if sys.platform == 'linux' and os.environ.get('APPIMAGE'):
# When on Linux webbrowser.open can fail in AppImage because it can't find the correct libdbus.
# We just fork the process and unset LD_LIBRARY_PATH before opening the URL.
# See #5425
if os.fork() == 0:
del os.environ['LD_LIBRARY_PATH']
webbrowser.open(url)
os._exit(0)
else:
webbrowser.open(url)
class FixedAspectRatioLayout(QLayout):
def __init__(self, parent: QWidget = None, aspect_ratio: float = 1.0):
super().__init__(parent)
self.aspect_ratio = aspect_ratio
self.items: List[QLayoutItem] = []
def set_aspect_ratio(self, aspect_ratio: float = 1.0):
self.aspect_ratio = aspect_ratio
self.update()
def addItem(self, item: QLayoutItem):
self.items.append(item)
def count(self) -> int:
return len(self.items)
def itemAt(self, index: int) -> QLayoutItem:
if index >= len(self.items):
return None
return self.items[index]
def takeAt(self, index: int) -> QLayoutItem:
if index >= len(self.items):
return None
return self.items.pop(index)
def _get_contents_margins_size(self) -> QSize:
margins = self.contentsMargins()
return QSize(margins.left() + margins.right(), margins.top() + margins.bottom())
def setGeometry(self, rect: QRect):
super().setGeometry(rect)
if not self.items:
return
contents = self.contentsRect()
if contents.height() > 0:
c_aratio = contents.width() / contents.height()
else:
c_aratio = 1
s_aratio = self.aspect_ratio
item_rect = QRect(QPoint(0, 0), QSize(
contents.width() if c_aratio < s_aratio else int(contents.height() * s_aratio),
contents.height() if c_aratio > s_aratio else int(contents.width() / s_aratio)
))
content_margins = self.contentsMargins()
free_space = contents.size() - item_rect.size()
for item in self.items:
if free_space.width() > 0 and not item.alignment() & Qt.AlignmentFlag.AlignLeft:
if item.alignment() & Qt.AlignmentFlag.AlignRight:
item_rect.moveRight(contents.width() + content_margins.right())
else:
item_rect.moveLeft(content_margins.left() + (free_space.width() // 2))
else:
item_rect.moveLeft(content_margins.left())
if free_space.height() > 0 and not item.alignment() & Qt.AlignmentFlag.AlignTop:
if item.alignment() & Qt.AlignmentFlag.AlignBottom:
item_rect.moveBottom(contents.height() + content_margins.bottom())
else:
item_rect.moveTop(content_margins.top() + (free_space.height() // 2))
else:
item_rect.moveTop(content_margins.top())
item.widget().setGeometry(item_rect)
def sizeHint(self) -> QSize:
result = QSize()
for item in self.items:
result = result.expandedTo(item.sizeHint())
return self._get_contents_margins_size() + result
def minimumSize(self) -> QSize:
result = QSize()
for item in self.items:
result = result.expandedTo(item.minimumSize())
return self._get_contents_margins_size() + result
def expandingDirections(self) -> Qt.Orientation:
return Qt.Orientation.Horizontal | Qt.Orientation.Vertical
def QColorLerp(a: QColor, b: QColor, t: float):
"""
Blends two QColors. t=0 returns a. t=1 returns b. t=0.5 returns evenly mixed.
"""
t = max(min(t, 1.0), 0.0)
i_t = 1.0 - t
return QColor(
int((a.red() * i_t) + (b.red() * t)),
int((a.green() * i_t) + (b.green() * t)),
int((a.blue() * i_t) + (b.blue() * t)),
int((a.alpha() * i_t) + (b.alpha() * t)),
)
class ImageGraphicsEffect(QObject):
"""
Applies a QGraphicsEffect to a QImage
"""
def __init__(self, parent: QObject, effect: QGraphicsEffect):
super().__init__(parent)
assert effect, 'effect must be set'
self.effect = effect
self.graphics_scene = QGraphicsScene()
self.graphics_item = QGraphicsPixmapItem()
self.graphics_item.setGraphicsEffect(effect)
self.graphics_scene.addItem(self.graphics_item)
def apply(self, image: QImage):
assert image, 'image must be set'
result = QImage(image.size(), QImage.Format.Format_ARGB32)
result.fill(Qt.GlobalColor.transparent)
painter = QPainter(result)
self.graphics_item.setPixmap(QPixmap.fromImage(image))
self.graphics_scene.render(painter)
self.graphics_item.setPixmap(QPixmap())
return result
def insert_spaces(text: str, every_chars: int) -> str:
'''Insert spaces at every Nth character to allow for WordWrap'''
return ' '.join(text[i:i+every_chars] for i in range(0, len(text), every_chars))
def set_windows_os_screenshot_protection_drm_flag(window: QWidget) -> None:
"""
sets the windows WDA_MONITOR flag on the window so windows prevents capturing
screenshots and microsoft recall will not be able to record the window
https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowdisplayaffinity
"""
if sys.platform not in ('win32', 'windows'):
return
try:
window_id = int(window.winId())
WDA_MONITOR = 0x01
ctypes.windll.user32.SetWindowDisplayAffinity(window_id, WDA_MONITOR)
except Exception:
_logger.exception(f"failed to set windows screenshot protection flag")
def debug_widget_layouts(gui_element: QObject):
"""Draw red borders around all widgets of given QObject for debugging.
E.g. add util.debug_widget_layouts(self) at the end of TxEditor.__init__
"""
assert isinstance(gui_element, QObject) and hasattr(gui_element, 'findChildren')
def set_border(widget):
if widget is not None:
widget.setStyleSheet(widget.styleSheet() + " * { border: 1px solid red; }")
# Apply to all child widgets recursively
for widget in gui_element.findChildren(QWidget):
set_border(widget)
class _ABCQObjectMeta(type(QObject), ABCMeta): pass
class _ABCQWidgetMeta(type(QWidget), ABCMeta): pass
class AbstractQObject(QObject, ABC, metaclass=_ABCQObjectMeta): pass
class AbstractQWidget(QWidget, ABC, metaclass=_ABCQWidgetMeta): pass
if __name__ == "__main__":
app = QApplication([])
t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done"))
t.start()
app.exec()
================================================
FILE: electrum/gui/qt/utxo_dialog.py
================================================
#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2023 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 typing import TYPE_CHECKING
import copy
from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtGui import QTextCharFormat, QFont
from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel
from electrum.i18n import _
from .util import WindowModalDialog, ColorScheme, Buttons, CloseButton, MONOSPACE_FONT, WWLabel
from .transaction_dialog import TxOutputColoring, QTextBrowserWithDefaultSize
if TYPE_CHECKING:
from electrum.transaction import PartialTxInput
from .main_window import ElectrumWindow
class UTXODialog(WindowModalDialog):
def __init__(self, window: 'ElectrumWindow', utxo: 'PartialTxInput'):
WindowModalDialog.__init__(self, window, _("Coin Privacy Analysis"))
self.main_window = window
self.config = window.config
self.wallet = window.wallet
self.utxo = utxo
self.parents_list = QTextBrowserWithDefaultSize(800, 400)
self.parents_list.setOpenLinks(False) # disable automatic link opening
self.parents_list.anchorClicked.connect(self.open_tx) # send links to our handler
self.parents_list.setFont(QFont(MONOSPACE_FONT))
self.parents_list.setReadOnly(True)
self.parents_list.setTextInteractionFlags(
self.parents_list.textInteractionFlags() |
Qt.TextInteractionFlag.LinksAccessibleByMouse |
Qt.TextInteractionFlag.LinksAccessibleByKeyboard
)
self.txo_color_parent = TxOutputColoring(
legend=_("Direct parent"), color=ColorScheme.BLUE, tooltip=_("Direct parent"))
self.txo_color_uncle = TxOutputColoring(
legend=_("Address reuse"), color=ColorScheme.RED, tooltip=_("Address reuse"))
vbox = QVBoxLayout()
vbox.addWidget(QLabel(_("Output point") + ": " + str(self.utxo.short_id)))
vbox.addWidget(QLabel(_("Amount") + ": " + self.main_window.format_amount_and_units(self.utxo.value_sats())))
self.stats_label = WWLabel()
vbox.addWidget(self.stats_label)
vbox.addWidget(self.parents_list)
legend_hbox = QHBoxLayout()
legend_hbox.setContentsMargins(0, 0, 0, 0)
legend_hbox.addStretch(2)
legend_hbox.addWidget(self.txo_color_parent.legend_label)
legend_hbox.addWidget(self.txo_color_uncle.legend_label)
vbox.addLayout(legend_hbox)
vbox.addLayout(Buttons(CloseButton(self)))
self.setLayout(vbox)
self.update()
self.main_window.labels_changed_signal.connect(self.update)
def update(self):
txid = self.utxo.prevout.txid.hex()
parents = self.wallet.get_tx_parents(txid)
num_parents = len(parents)
parents_copy = copy.deepcopy(parents)
cursor = self.parents_list.textCursor()
ext = QTextCharFormat()
if num_parents < 200:
ASCII_EDGE = '└─'
ASCII_BRANCH = '├─'
ASCII_PIPE = '│ '
ASCII_SPACE = ' '
else:
ASCII_EDGE = '└'
ASCII_BRANCH = '├'
ASCII_PIPE = '│'
ASCII_SPACE = ' '
self.parents_list.clear()
self.num_reuse = 0
def print_ascii_tree(_txid, prefix, is_last, is_uncle):
if _txid not in parents:
return
tx_mined_info = self.wallet.adb.get_tx_height(_txid)
tx_height = tx_mined_info.height()
tx_pos = tx_mined_info.txpos
key = "%dx%d"%(tx_height, tx_pos) if tx_pos is not None else _txid[0:8]
label = self.wallet.get_label_for_txid(_txid) or ""
if _txid not in parents_copy:
label = '[duplicate]'
c = '' if _txid == txid else (ASCII_EDGE if is_last else ASCII_BRANCH)
cursor.insertText(prefix + c, ext)
if is_uncle:
self.num_reuse += 1
lnk = QTextCharFormat(self.txo_color_uncle.text_char_format)
else:
lnk = QTextCharFormat(self.txo_color_parent.text_char_format)
lnk.setToolTip(_('Click to open, right-click for menu'))
lnk.setAnchorHref(_txid)
#lnk.setAnchorNames([a_name])
lnk.setAnchor(True)
lnk.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
cursor.insertText(key, lnk)
cursor.insertText(" ", ext)
cursor.insertText(label, ext)
cursor.insertBlock()
next_prefix = '' if txid == _txid else prefix + (ASCII_SPACE if is_last else ASCII_PIPE)
parents_list, uncle_list = parents_copy.pop(_txid, ([],[]))
for i, p in enumerate(parents_list + uncle_list):
is_last = (i == len(parents_list) + len(uncle_list)- 1)
is_uncle = (i > len(parents_list) - 1)
print_ascii_tree(p, next_prefix, is_last, is_uncle)
# recursively build the tree
print_ascii_tree(txid, '', False, False)
msg = _("This UTXO has {} parent transactions in your wallet.").format(num_parents)
if self.num_reuse:
msg += '\n' + _('This does not include transactions that are downstream of address reuse.')
self.stats_label.setText(msg)
self.txo_color_parent.legend_label.setVisible(True)
self.txo_color_uncle.legend_label.setVisible(bool(self.num_reuse))
# set cursor to top
cursor.setPosition(0)
self.parents_list.setTextCursor(cursor)
def open_tx(self, txid):
if isinstance(txid, QUrl):
txid = txid.toString(QUrl.UrlFormattingOption.None_)
tx = self.wallet.adb.get_transaction(txid)
if not tx:
return
self.main_window.show_transaction(tx)
================================================
FILE: electrum/gui/qt/utxo_list.py
================================================
#!/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 typing import Optional, List, Dict, Sequence, Set, TYPE_CHECKING
import enum
import copy
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QStandardItemModel, QStandardItem, QFont
from PyQt6.QtWidgets import QAbstractItemView, QMenu
from electrum.i18n import _
from electrum.bitcoin import is_address
from electrum.transaction import PartialTxInput, PartialTxOutput
from electrum.lnutil import MIN_FUNDING_SAT
from electrum.util import profiler
from electrum.plugin import run_hook
from .util import ColorScheme, MONOSPACE_FONT
from .my_treeview import MyTreeView, MySortModel
from .new_channel_dialog import NewChannelDialog
from ..messages import MSG_FREEZE_ADDRESS, MSG_FREEZE_COIN
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class UTXOList(MyTreeView):
_spend_set: Set[str] # coins selected by the user to spend from
_utxo_dict: Dict[str, PartialTxInput] # coin name -> coin
class Columns(MyTreeView.BaseColumnsEnum):
OUTPOINT = enum.auto()
ADDRESS = enum.auto()
LABEL = enum.auto()
AMOUNT = enum.auto()
PARENTS = enum.auto()
headers = {
Columns.OUTPOINT: _('Output point'),
Columns.ADDRESS: _('Address'),
Columns.PARENTS: _('Parents'),
Columns.LABEL: _('Label'),
Columns.AMOUNT: _('Amount'),
}
filter_columns = [Columns.ADDRESS, Columns.LABEL, Columns.OUTPOINT]
stretch_column = Columns.LABEL
ROLE_PREVOUT_STR = Qt.ItemDataRole.UserRole + 1000
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1001
key_role = ROLE_PREVOUT_STR
def __init__(self, main_window: 'ElectrumWindow'):
super().__init__(
main_window=main_window,
stretch_column=self.stretch_column,
)
self._spend_set = set()
self._utxo_dict = {}
self.wallet = self.main_window.wallet
self.std_model = QStandardItemModel(self)
self.proxy = MySortModel(self, sort_role=self.ROLE_SORT_ORDER)
self.proxy.setSourceModel(self.std_model)
self.setModel(self.proxy)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSortingEnabled(True)
def create_toolbar(self, config):
toolbar, menu = self.create_toolbar_with_menu('')
self.num_coins_label = toolbar.itemAt(0).widget()
menu.addAction(_('Coin control'), lambda: self.add_selection_to_coincontrol())
def cb():
self.main_window.utxo_list.refresh_all() # for coin frozen status
self.main_window.update_status() # frozen balance
menu.addConfig(config.cv.WALLET_FREEZE_REUSED_ADDRESS_UTXOS, callback=cb)
return toolbar
@profiler(min_threshold=0.05)
def update(self):
# not calling maybe_defer_update() as it interferes with coincontrol status bar
self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change
utxos = self.wallet.get_utxos()
self._maybe_reset_coincontrol(utxos)
self._utxo_dict = dict([(utxo.prevout.to_str(), utxo) for utxo in utxos])
self.std_model.clear()
self.update_headers(self.__class__.headers)
for idx, utxo in enumerate(utxos):
name = utxo.prevout.to_str()
labels = [""] * len(self.Columns)
amount_str = self.main_window.format_amount(
utxo.value_sats(), whitespaces=True)
amount_str_nots = self.main_window.format_amount(
utxo.value_sats(), whitespaces=False, add_thousands_sep=False)
labels[self.Columns.OUTPOINT] = str(utxo.short_id)
labels[self.Columns.ADDRESS] = utxo.address
labels[self.Columns.AMOUNT] = amount_str
utxo_item = [QStandardItem(x) for x in labels]
self.set_editability(utxo_item)
utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_PREVOUT_STR)
utxo_item[self.Columns.AMOUNT].setData(amount_str_nots, self.ROLE_CLIPBOARD_DATA)
utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT))
utxo_item[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT))
utxo_item[self.Columns.PARENTS].setFont(QFont(MONOSPACE_FONT))
utxo_item[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT))
self.std_model.insertRow(idx, utxo_item)
self.refresh_row(name, idx)
self.filter()
self.proxy.setDynamicSortFilter(True)
self.sortByColumn(self.Columns.OUTPOINT, Qt.SortOrder.DescendingOrder)
self.update_coincontrol_bar()
self.num_coins_label.setText(_('{} unspent transaction outputs').format(len(utxos)))
def update_coincontrol_bar(self):
# update coincontrol status bar
if bool(self._spend_set):
coins = [self._utxo_dict[x] for x in self._spend_set]
coins = self._filter_frozen_coins(coins)
amount = sum(x.value_sats() for x in coins)
amount_str = self.main_window.format_amount_and_units(amount)
num_outputs_str = _("{} outputs available ({} total)").format(len(coins), len(self._utxo_dict))
self.main_window.set_coincontrol_msg(_("Coin control active") + f': {num_outputs_str}, {amount_str}')
else:
self.main_window.set_coincontrol_msg(None)
def refresh_row(self, key, row):
assert row is not None
utxo = self._utxo_dict[key]
utxo_item = [self.std_model.item(row, col) for col in self.Columns]
txid = utxo.prevout.txid.hex()
num_parents = self.wallet.get_num_parents(txid)
utxo_item[self.Columns.PARENTS].setText('%6s'%num_parents if num_parents else '-')
label = self.wallet.get_label_for_txid(txid) or ''
utxo_item[self.Columns.LABEL].setText(label)
sort_key = (
self.wallet.adb.tx_height_to_sort_height(utxo.block_height), # sort by block height
str(utxo.short_id), # order inside block (if mined), or just txid
)
utxo_item[self.Columns.OUTPOINT].setData(sort_key, self.ROLE_SORT_ORDER)
SELECTED_TO_SPEND_TOOLTIP = _('Coin selected to be spent')
if key in self._spend_set:
tooltip = key + "\n" + SELECTED_TO_SPEND_TOOLTIP
color = ColorScheme.GREEN.as_color(True)
else:
tooltip = key
color = self._default_bg_brush
for col in utxo_item:
col.setBackground(color)
col.setToolTip(tooltip)
if self.wallet.is_frozen_address(utxo.address):
utxo_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True))
utxo_item[self.Columns.ADDRESS].setToolTip(_('Address is frozen'))
if self.wallet.is_frozen_coin(utxo):
utxo_item[self.Columns.OUTPOINT].setBackground(ColorScheme.BLUE.as_color(True))
utxo_item[self.Columns.OUTPOINT].setToolTip(f"{key}\n{_('Coin is frozen')}")
def get_selected_outpoints(self) -> List[str]:
if not self.model():
return []
items = self.selected_in_column(self.Columns.OUTPOINT)
return [x.data(self.ROLE_PREVOUT_STR) for x in items]
def _filter_frozen_coins(self, coins: List[PartialTxInput]) -> List[PartialTxInput]:
coins = [utxo for utxo in coins
if (not self.wallet.is_frozen_address(utxo.address) and
not self.wallet.is_frozen_coin(utxo))]
return coins
def are_in_coincontrol(self, coins: List[PartialTxInput]) -> bool:
return all([utxo.prevout.to_str() in self._spend_set for utxo in coins])
def add_to_coincontrol(self, coins: List[PartialTxInput]):
assert all(utxo.prevout.to_str() in self._utxo_dict for utxo in coins) # see issue 10206
coins = self._filter_frozen_coins(coins)
for utxo in coins:
self._spend_set.add(utxo.prevout.to_str())
self._refresh_coincontrol()
def remove_from_coincontrol(self, coins: List[PartialTxInput]):
for utxo in coins:
self._spend_set.remove(utxo.prevout.to_str())
self._refresh_coincontrol()
def clear_coincontrol(self):
self._spend_set.clear()
self._refresh_coincontrol()
def add_selection_to_coincontrol(self):
if bool(self._spend_set):
self.clear_coincontrol()
return
selected = self.get_selected_outpoints()
coins = [self._utxo_dict[name] for name in selected]
if not coins:
self.main_window.show_error(_('You need to select coins from the list first.\nUse ctrl+left mouse button to select multiple items'))
return
self.add_to_coincontrol(coins)
def _refresh_coincontrol(self):
self.refresh_all()
self.update_coincontrol_bar()
self.selectionModel().clearSelection()
def get_spend_list(self) -> Optional[Sequence[PartialTxInput]]:
if not bool(self._spend_set):
return None
utxos = [self._utxo_dict[x] for x in self._spend_set]
return copy.deepcopy(utxos) # copy so that side-effects don't affect utxo_dict
def _maybe_reset_coincontrol(self, current_wallet_utxos: Sequence[PartialTxInput]) -> None:
if not self._spend_set and not self._currently_open_menu:
return
utxo_set = {utxo.prevout.to_str() for utxo in current_wallet_utxos}
if self._currently_open_menu:
# if we spent one of the qt-highlighted UTXOs, close context-menu
if not all(prevout_str in utxo_set for prevout_str in self.get_selected_outpoints()):
self.close_menu()
if self._spend_set:
# if we spent one of the green-marked UTXOs, just reset selection
if not all([prevout_str in utxo_set for prevout_str in self._spend_set]):
self._spend_set.clear()
def can_swap_coins(self, coins):
# fixme: min and max_amounts are known only after first request
if self.wallet.lnworker is None:
return False
value = sum(x.value_sats() for x in coins)
min_amount = self.wallet.lnworker.swap_manager.get_min_amount()
max_amount = self.wallet.lnworker.swap_manager.client_max_amount_forward_swap()
if min_amount is None or max_amount is None:
# we need to fetch data from swap server
return True
if value < min_amount:
return False
if max_amount is None or value > max_amount:
return False
return True
def swap_coins(self, coins: list[PartialTxInput]) -> None:
assert coins, "no coins selected?"
#self.clear_coincontrol()
self.add_to_coincontrol(coins)
self.main_window.run_swap_dialog(is_reverse=False, recv_amount_sat_or_max='!')
self.clear_coincontrol()
def can_open_channel(self, coins):
if self.wallet.lnworker is None:
return False
value = sum(x.value_sats() for x in coins)
return value >= MIN_FUNDING_SAT and value <= self.config.LIGHTNING_MAX_FUNDING_SAT
def open_channel_with_coins(self, coins: list[PartialTxInput]) -> None:
assert coins, "no coins selected?"
# todo : use a single dialog in new flow
#self.clear_coincontrol()
self.add_to_coincontrol(coins)
d = NewChannelDialog(self.main_window)
d.max_button.setChecked(True)
d.max_button.setEnabled(False)
d.min_button.setEnabled(False)
d.clear_button.setEnabled(False)
d.amount_e.setFrozen(True)
d.spend_max()
d.run()
self.clear_coincontrol()
def clipboard_contains_address(self) -> bool:
text = self.main_window.app.clipboard().text()
return is_address(text)
def pay_to_clipboard_address(self, coins: list[PartialTxInput]) -> None:
assert coins, "no coins selected?"
if not self.clipboard_contains_address():
self.main_window.show_error(_('Clipboard doesn\'t contain a valid address'))
return
addr = self.main_window.app.clipboard().text()
outputs = [PartialTxOutput.from_address_and_value(addr, '!')]
#self.clear_coincontrol()
self.add_to_coincontrol(coins)
self.main_window.send_tab.pay_onchain_dialog(outputs)
self.clear_coincontrol()
def on_double_click(self, idx):
outpoint = idx.sibling(idx.row(), self.Columns.OUTPOINT).data(self.ROLE_PREVOUT_STR)
utxo = self._utxo_dict[outpoint]
self.main_window.show_utxo(utxo)
def create_menu(self, position):
selected = self.get_selected_outpoints()
coins = [self._utxo_dict[name] for name in selected]
if not coins:
return
unfrozen_coins = self._filter_frozen_coins(coins)
menu = QMenu()
menu.setSeparatorsCollapsible(True) # consecutive separators are merged together
if len(coins) == 1:
idx = self.indexAt(position)
if not idx.isValid():
return
utxo = coins[0]
txid = utxo.prevout.txid.hex()
# "Details"
tx = self.wallet.adb.get_transaction(txid)
if tx:
label = self.wallet.get_label_for_txid(txid)
menu.addAction(_("Privacy analysis"), lambda: self.main_window.show_utxo(utxo))
cc = self.add_copy_menu(menu, idx)
cc.addAction(_("Long Output point"), lambda: self.place_text_on_clipboard(utxo.prevout.to_str(), title="Long Output point"))
# fully spend
m = menu_spend = menu.addMenu(_("Fully spend") + '…')
m.setEnabled(bool(unfrozen_coins))
m = menu_spend.addAction(_("send to address in clipboard"), lambda: self.pay_to_clipboard_address(unfrozen_coins))
m.setEnabled(self.clipboard_contains_address())
m = menu_spend.addAction(_("in new channel"), lambda: self.open_channel_with_coins(unfrozen_coins))
m.setEnabled(self.can_open_channel(unfrozen_coins))
m = menu_spend.addAction(_("in submarine swap"), lambda: self.swap_coins(unfrozen_coins))
m.setEnabled(self.can_swap_coins(unfrozen_coins))
# coin control
if self.are_in_coincontrol(coins):
menu.addAction(_("Remove from coin control"), lambda: self.remove_from_coincontrol(coins))
else:
m = menu.addAction(_("Add to coin control"), lambda: self.add_to_coincontrol(coins))
m.setEnabled(bool(unfrozen_coins))
# Freeze menu
if len(coins) == 1:
utxo = coins[0]
addr = utxo.address
menu_freeze = menu.addMenu(_("Freeze"))
menu_freeze.setToolTipsVisible(True)
if not self.wallet.is_frozen_coin(utxo):
act = menu_freeze.addAction(_("Freeze Coin"), lambda: self.main_window.set_frozen_state_of_coins([utxo], True))
else:
act = menu_freeze.addAction(_("Unfreeze Coin"), lambda: self.main_window.set_frozen_state_of_coins([utxo], False))
act.setToolTip(MSG_FREEZE_COIN)
if not self.wallet.is_frozen_address(addr):
act = menu_freeze.addAction(_("Freeze Address"), lambda: self.main_window.set_frozen_state_of_addresses([addr], True))
else:
act = menu_freeze.addAction(_("Unfreeze Address"), lambda: self.main_window.set_frozen_state_of_addresses([addr], False))
act.setToolTip(MSG_FREEZE_ADDRESS)
elif len(coins) > 1: # multiple items selected
menu.addSeparator()
addrs = [utxo.address for utxo in coins]
is_coin_frozen = [self.wallet.is_frozen_coin(utxo) for utxo in coins]
is_addr_frozen = [self.wallet.is_frozen_address(utxo.address) for utxo in coins]
menu_freeze = menu.addMenu(_("Freeze"))
menu_freeze.setToolTipsVisible(True)
if not all(is_coin_frozen):
act = menu_freeze.addAction(_("Freeze Coins"), lambda: self.main_window.set_frozen_state_of_coins(coins, True))
act.setToolTip(MSG_FREEZE_COIN)
if any(is_coin_frozen):
act = menu_freeze.addAction(_("Unfreeze Coins"), lambda: self.main_window.set_frozen_state_of_coins(coins, False))
act.setToolTip(MSG_FREEZE_COIN)
if not all(is_addr_frozen):
act = menu_freeze.addAction(_("Freeze Addresses"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, True))
act.setToolTip(MSG_FREEZE_ADDRESS)
if any(is_addr_frozen):
act = menu_freeze.addAction(_("Unfreeze Addresses"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, False))
act.setToolTip(MSG_FREEZE_ADDRESS)
run_hook('qt_utxo_menu', menu, coins, self.wallet)
self.open_menu(menu, position)
def get_filter_data_from_coordinate(self, row, col):
if col == self.Columns.OUTPOINT:
return self.get_role_data_from_coordinate(row, col, role=self.ROLE_PREVOUT_STR)
return super().get_filter_data_from_coordinate(row, col)
================================================
FILE: electrum/gui/qt/wallet_info_dialog.py
================================================
# Copyright (C) 2023 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
import os
from typing import TYPE_CHECKING
from functools import partial
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QLabel, QVBoxLayout, QGridLayout,
QHBoxLayout, QPushButton, QWidget, QTabWidget)
from electrum.plugin import run_hook
from electrum.i18n import _
from electrum.wallet import Multisig_Wallet
from electrum.wizard import WizardViewState
from .main_window import protected
from electrum.gui.qt.wizard.wallet import QEKeystoreWizard
from .qrtextedit import ShowQRTextEdit
from .util import (
read_QIcon, WindowModalDialog, Buttons,
WWLabel, CloseButton, HelpButton, font_height, ShowQRLineEdit
)
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class WalletInfoDialog(WindowModalDialog):
def __init__(self, parent: QWidget, *, window: 'ElectrumWindow'):
WindowModalDialog.__init__(self, parent, _("Wallet Information"))
self.setMinimumSize(800, 100)
self.window = window
self.wallet = wallet = window.wallet
# required for @protected decorator
self._protected_requires_password = lambda: self.wallet.has_keystore_encryption() or self.wallet.storage.is_encrypted_with_user_pw()
config = window.config
vbox = QVBoxLayout()
wallet_type = wallet.db.get('wallet_type', '')
if wallet.is_watching_only():
wallet_type += ' [{}]'.format(_('watching-only'))
seed_available = _('False')
if wallet.has_seed():
seed_available = _('True')
seed_available += f" ({wallet.get_seed_type()})"
keystore_types = [k.get_type_text() for k in wallet.get_keystores()]
grid = QGridLayout()
basename = os.path.basename(wallet.storage.path)
cur_row = 0
grid.addWidget(WWLabel(_("Wallet name")+ ':'), cur_row, 0)
grid.addWidget(WWLabel(basename), cur_row, 1)
cur_row += 1
if db_metadata := wallet.db.get_db_metadata():
grid.addWidget(WWLabel(_("File created") + ':'), cur_row, 0)
grid.addWidget(WWLabel(db_metadata.to_str()), cur_row, 1)
cur_row += 1
grid.addWidget(WWLabel(_("Wallet type")+ ':'), cur_row, 0)
grid.addWidget(WWLabel(wallet_type), cur_row, 1)
cur_row += 1
grid.addWidget(WWLabel(_("Script type")+ ':'), cur_row, 0)
grid.addWidget(WWLabel(wallet.txin_type), cur_row, 1)
cur_row += 1
grid.addWidget(WWLabel(_("Seed available") + ':'), cur_row, 0)
grid.addWidget(WWLabel(str(seed_available)), cur_row, 1)
cur_row += 1
if len(keystore_types) <= 1:
grid.addWidget(WWLabel(_("Keystore type") + ':'), cur_row, 0)
ks_type = str(keystore_types[0]) if keystore_types else _('No keystore')
grid.addWidget(WWLabel(ks_type), cur_row, 1)
cur_row += 1
# lightning
grid.addWidget(WWLabel(_('Lightning') + ':'), cur_row, 0)
from .util import IconLabel
if wallet.has_lightning():
if wallet.lnworker.has_deterministic_node_id():
grid.addWidget(WWLabel(_('Enabled')), cur_row, 1)
else:
label = IconLabel(text='Enabled, non-recoverable channels')
label.setIcon(read_QIcon('cloud_no'))
grid.addWidget(label, cur_row, 1)
if wallet.get_seed_type() == 'segwit':
msg = _("Your channels cannot be recovered from seed, because they were created with an old version of Electrum. "
"This means that you must save a backup of your wallet every time you create a new channel.\n\n"
"If you want this wallet to have recoverable channels, you must close your existing channels and restore this wallet from seed")
else:
msg = _("Your channels cannot be recovered from seed. "
"This means that you must save a backup of your wallet every time you create a new channel.\n\n"
"If you want to have recoverable channels, you must create a new wallet with an Electrum seed")
grid.addWidget(HelpButton(msg), cur_row, 3)
cur_row += 1
grid.addWidget(WWLabel(_('Lightning Node ID:')), cur_row, 0)
cur_row += 1
nodeid_text = wallet.lnworker.node_keypair.pubkey.hex()
nodeid_e = ShowQRLineEdit(nodeid_text, config, title=_("Node ID"))
grid.addWidget(nodeid_e, cur_row, 0, 1, 4)
cur_row += 1
else:
if wallet.can_have_lightning():
grid.addWidget(WWLabel('Not enabled'), cur_row, 1)
button = QPushButton(_("Enable"))
button.pressed.connect(lambda: window.init_lightning_dialog(self))
grid.addWidget(button, cur_row, 3)
else:
grid.addWidget(WWLabel(_("Not available for this wallet.")), cur_row, 1)
grid.addWidget(HelpButton(_("Lightning is currently restricted to HD wallets with p2wpkh addresses.")), cur_row, 2)
cur_row += 1
vbox.addLayout(grid)
labels_clayout = None
if wallet.is_deterministic():
keystores = sorted(wallet.get_keystores(), key=lambda _ks: _ks.get_root_fingerprint() or '')
self.keystore_tabs = QTabWidget()
for idx, ks in enumerate(keystores):
ks_w = QWidget()
ks_vbox = QVBoxLayout()
ks_w.setLayout(ks_vbox)
status_label = _('This keystore is watching-only (disabled)') if ks.is_watching_only() else _('This keystore is active (enabled)')
ks_vbox.addWidget(QLabel(status_label))
label = f'{ks.label}' if hasattr(ks, 'label') and ks.label else ''
ks_vbox.addWidget(QLabel(_('Type') + ': ' + f'{ks.get_type_text()}' + ' ' + label))
mpk_text = ShowQRTextEdit(ks.get_master_public_key(), config=config)
mpk_text.setMaximumHeight(max(150, 10 * font_height()))
mpk_text.addCopyButton()
run_hook('show_xpub_button', mpk_text, ks)
ks_vbox.addWidget(WWLabel(_("Master Public Key")))
ks_vbox.addWidget(mpk_text)
der_path_hbox = QHBoxLayout()
der_path_hbox.setContentsMargins(0, 0, 0, 0)
der_path_hbox.addWidget(WWLabel(_("Derivation path") + ':'))
der_path_text = WWLabel(ks.get_derivation_prefix() or _("unknown"))
der_path_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
der_path_hbox.addWidget(der_path_text)
der_path_hbox.addStretch()
ks_vbox.addLayout(der_path_hbox)
bip32fp_hbox = QHBoxLayout()
bip32fp_hbox.setContentsMargins(0, 0, 0, 0)
bip32fp_hbox.addWidget(QLabel("BIP32 root fingerprint:"))
bip32fp_text = WWLabel(ks.get_root_fingerprint() or _("unknown"))
bip32fp_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
bip32fp_hbox.addWidget(bip32fp_text)
bip32fp_hbox.addStretch()
ks_vbox.addLayout(bip32fp_hbox)
if wallet.can_enable_disable_keystore(ks):
ks_buttons = []
if not ks.is_watching_only():
rm_keystore_button = QPushButton('Disable keystore')
rm_keystore_button.clicked.connect(partial(self.disable_keystore, ks))
ks_buttons.insert(0, rm_keystore_button)
else:
add_keystore_button = QPushButton('Enable Keystore')
add_keystore_button.clicked.connect(self.enable_keystore)
ks_buttons.insert(0, add_keystore_button)
ks_vbox.addLayout(Buttons(*ks_buttons))
tab_label = _("Cosigner") + f' {idx+1}' if len(keystores) > 1 else _("Keystore")
index = self.keystore_tabs.addTab(ks_w, tab_label)
if not ks.is_watching_only():
self.keystore_tabs.setTabIcon(index, read_QIcon('confirmed.svg'))
vbox.addWidget(self.keystore_tabs)
vbox.addStretch(1)
buttons = [CloseButton(self)]
btn_export_info = run_hook('wallet_info_buttons', window, self)
if btn_export_info is None:
btn_export_info = []
buttons = btn_export_info + buttons
btns = Buttons(*buttons)
vbox.addLayout(btns)
self.setLayout(vbox)
def disable_keystore(self, keystore):
if self.wallet.has_channels():
self.window.show_message(_('Cannot disable keystore: You have active lightning channels'))
return
msg = _('Disable keystore? This will make the keystore watching-only.')
if self.wallet.storage.is_encrypted_with_hw_device():
msg += '\n\n' + _('Note that this will disable wallet file encryption, because it uses your hardware wallet device.')
if not self.window.question(msg):
return
self.accept()
self.wallet.disable_keystore(keystore)
self.window.gui_object.reload_windows()
def enable_keystore(self, b: bool):
v = WizardViewState('keystore_type', {'wallet_type': self.window.wallet.wallet_type}, {})
dialog = QEKeystoreWizard(config=self.window.config, app=self.window.gui_object.app,
plugins=self.window.gui_object.plugins, start_viewstate=v)
result = dialog.run()
if not result:
return
keystore, is_hardware = result
for k in self.wallet.get_keystores():
if k.get_master_public_key() == keystore.get_master_public_key():
break
else:
self.window.show_error(_('Keystore not found in this wallet'))
return
self._enable_keystore(keystore, is_hardware)
@protected
def _enable_keystore(self, keystore, is_hardware, password):
self.accept()
self.wallet.enable_keystore(keystore, is_hardware, password)
self.window.gui_object.reload_windows()
================================================
FILE: electrum/gui/qt/wizard/__init__.py
================================================
================================================
FILE: electrum/gui/qt/wizard/server_connect.py
================================================
from typing import TYPE_CHECKING
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import QCheckBox, QLabel, QHBoxLayout, QVBoxLayout, QWidget
from electrum.i18n import _
from electrum.wizard import ServerConnectWizard
from electrum.gui.qt.network_dialog import ProxyWidget, ServerWidget
from electrum.gui.qt.util import icon_path
from .wizard import QEAbstractWizard, WizardComponent
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
from electrum.plugin import Plugins
from electrum.daemon import Daemon
from electrum.gui.qt import QElectrumApplication
class QEServerConnectWizard(ServerConnectWizard, QEAbstractWizard):
def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', plugins: 'Plugins', daemon: 'Daemon', parent=None):
ServerConnectWizard.__init__(self, daemon)
QEAbstractWizard.__init__(self, config, app)
self.window_title = _('Network and server configuration')
self.finish_label = _('Next')
# attach gui classes
self.navmap_merge({
'welcome': {'gui': WCWelcome},
'proxy_config': {'gui': WCProxyConfig},
'server_config': {'gui': WCServerConfig},
})
class WCWelcome(WizardComponent):
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title='Network Configuration')
self.wizard_title = _('Electrum Bitcoin Wallet')
self.first_help_label = QLabel()
self.first_help_label.setText(_("Optional settings to customize your network connection") + ":")
self.first_help_label.setWordWrap(True)
self.config_proxy_w = QCheckBox(_('Use Proxy'))
self.config_proxy_w.setChecked(False)
self.config_proxy_w.stateChanged.connect(self.on_updated)
self.config_server_w = QCheckBox(_('Select Electrum Server'))
self.config_server_w.setChecked(False)
self.config_server_w.stateChanged.connect(self.on_updated)
options_w = QWidget()
vbox = QVBoxLayout()
vbox.addWidget(self.config_proxy_w)
vbox.addWidget(self.config_server_w)
options_w.setLayout(vbox)
self.second_help_label = QLabel()
self.second_help_label.setText(
_("If you are unsure what these options are, leave them unchecked.")
)
self.second_help_label.setWordWrap(True)
self.layout().addWidget(self.first_help_label)
self.layout().addWidget(options_w)
self.layout().addWidget(self.second_help_label)
self.layout().addStretch(1)
self._valid = True
def apply(self):
self.wizard_data['use_defaults'] = not (self.config_server_w.isChecked() or self.config_proxy_w.isChecked())
self.wizard_data['want_proxy'] = self.config_proxy_w.isChecked()
self.wizard_data['autoconnect'] = not self.config_server_w.isChecked()
class WCProxyConfig(WizardComponent):
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title=_('Proxy'))
self.pw = ProxyWidget(wizard._daemon.network, self)
self.pw.proxy_cb.setChecked(True)
self.pw.proxy_host.setText('localhost')
self.pw.proxy_port.setText('9050')
self.layout().addWidget(self.pw)
self._valid = True
def apply(self):
self.wizard_data['proxy'] = self.pw.get_proxy_settings().to_dict()
class WCServerConfig(WizardComponent):
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title=_('Server'))
self.sw = ServerWidget(wizard._daemon.network, self)
self.layout().addWidget(self.sw)
self.sw.server_e_valid.connect(self.on_server_e_valid)
def on_server_e_valid(self, valid):
self.valid = valid
def apply(self):
self.wizard_data['autoconnect'] = self.sw.server_e.text().strip() == ''
self.wizard_data['server'] = self.sw.server_e.text()
self.wizard_data['one_server'] = self.wizard.config.NETWORK_ONESERVER
================================================
FILE: electrum/gui/qt/wizard/terms_of_use.py
================================================
from typing import TYPE_CHECKING
from PyQt6.QtCore import QTimer, QEvent
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import QLabel, QHBoxLayout, QScrollArea
from electrum.i18n import _
from electrum.wizard import TermsOfUseWizard
from electrum.gui.qt.util import icon_path, WWLabel
from electrum.gui import messages
from .wizard import QEAbstractWizard, WizardComponent
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
from electrum.gui.qt import QElectrumApplication
class QETermsOfUseWizard(TermsOfUseWizard, QEAbstractWizard):
def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication'):
TermsOfUseWizard.__init__(self, config)
QEAbstractWizard.__init__(self, config, app)
self.window_title = _('Terms of Use')
self.finish_label = _('I Accept')
self.title.setVisible(False)
# self.window().setMinimumHeight(565) # Enough to show the whole text without scrolling
self.next_button.setToolTip("You accept the Terms of Use by clicking this button.")
# attach gui classes
self.navmap_merge({
'terms_of_use': {'gui': WCTermsOfUseScreen, 'params': {'icon': ''}},
})
class WCTermsOfUseScreen(WizardComponent):
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title='')
self.wizard_title = _('Electrum Terms of Use')
self.img_label = QLabel()
pixmap = QPixmap(icon_path('electrum_darkblue_1.png'))
self.img_label.setPixmap(pixmap)
self.img_label2 = QLabel()
pixmap = QPixmap(icon_path('electrum_text.png'))
self.img_label2.setPixmap(pixmap)
hbox_img = QHBoxLayout()
hbox_img.addStretch(1)
hbox_img.addWidget(self.img_label)
hbox_img.addWidget(self.img_label2)
hbox_img.addStretch(1)
self.layout().addLayout(hbox_img)
self.layout().addSpacing(15)
self.tos_label = WWLabel()
self.tos_label.setText(messages.MSG_TERMS_OF_USE)
self.layout().addWidget(self.tos_label)
self._valid = True
def apply(self):
pass
================================================
FILE: electrum/gui/qt/wizard/wallet.py
================================================
from abc import ABC
import os
import sys
import threading
from typing import TYPE_CHECKING, Optional, List, Tuple
from PyQt6.QtCore import Qt, QTimer, QRect, pyqtSignal
from PyQt6.QtGui import QPen, QPainter, QPalette, QPixmap
from PyQt6.QtWidgets import (QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget,
QFileDialog, QSlider, QGridLayout, QDialog, QApplication)
from electrum.bip32 import is_bip32_derivation, BIP32Node, normalize_bip32_derivation, xpub_type
from electrum.daemon import Daemon
from electrum.i18n import _
from electrum.keystore import bip44_derivation, bip39_to_seed, purpose48_derivation, ScriptTypeNotSupported
from electrum.plugin import run_hook, HardwarePluginLibraryUnavailable
from electrum.storage import StorageReadWriteError
from electrum.util import WalletFileException, get_new_wallet_name, UserFacingException, InvalidPassword
from electrum.util import is_subpath, ChoiceItem, multisig_type, UserCancelled, standardize_path
from electrum.wallet import wallet_types
from .wizard import QEAbstractWizard, WizardComponent
from electrum.logging import get_logger, Logger
from electrum import WalletStorage, mnemonic, keystore
from electrum.wallet_db import WalletDB
from electrum.wizard import NewWalletWizard, KeystoreWizard, WizardViewState
from electrum.gui.qt.bip39_recovery_dialog import Bip39RecoveryDialog
from electrum.gui.qt.password_dialog import PasswordLayout, PW_NEW, MSG_ENTER_PASSWORD, PasswordLayoutForHW
from electrum.gui.qt.seed_dialog import SeedWidget, MSG_PASSPHRASE_WARN_ISSUE4566, KeysWidget
from electrum.gui.qt.util import (PasswordLineEdit, char_width_in_lineedit, WWLabel, InfoButton, font_height,
ChoiceWidget, MessageBoxMixin, icon_path, IconLabel, read_QIcon)
from electrum.gui.qt.plugins_dialog import PluginsDialog
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
from electrum.plugin import Plugins, DeviceInfo
from electrum.gui.qt import QElectrumApplication
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...')
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.")
class QEKeystoreWizard(KeystoreWizard, QEAbstractWizard, MessageBoxMixin):
_logger = get_logger(__name__)
def __init__(
self,
*,
config: 'SimpleConfig',
app: 'QElectrumApplication',
plugins: 'Plugins',
start_viewstate: WizardViewState = None,
):
assert 'wallet_type' in start_viewstate.wizard_data, 'wallet_type required'
QEAbstractWizard.__init__(self, config, app, start_viewstate=start_viewstate)
KeystoreWizard.__init__(self, plugins)
self.window_title = _('Extend wallet keystore')
# attach gui classes to views
self.navmap_merge({
'keystore_type': {'gui': WCExtendKeystore},
'enter_seed': {'gui': WCHaveSeed},
'enter_ext': {'gui': WCEnterExt},
'choose_hardware_device': {'gui': WCChooseHWDevice},
'script_and_derivation': {'gui': WCScriptAndDerivation},
'wallet_password': {'gui': WCWalletPassword},
'wallet_password_hardware': {'gui': WCWalletPasswordHardware},
})
def is_single_password(self):
return True
def run(self):
if self.exec() == QDialog.DialogCode.Rejected:
return
return self._result
class QENewWalletWizard(NewWalletWizard, QEAbstractWizard, MessageBoxMixin):
_logger = get_logger(__name__)
def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', plugins: 'Plugins', daemon: Daemon, path, *, start_viewstate=None):
NewWalletWizard.__init__(self, daemon, plugins)
QEAbstractWizard.__init__(self, config, app, start_viewstate=start_viewstate)
self.window_title = _('Create/Restore wallet')
self._path = standardize_path(path)
self._password = None
# attach gui classes to views
self.navmap_merge({
'wallet_name': {'gui': WCWalletName},
'hw_unlock': {'gui': WCChooseHWDevice},
'wallet_type': {'gui': WCWalletType},
'keystore_type': {'gui': WCKeystoreType},
'create_seed': {'gui': WCCreateSeed},
'create_ext': {'gui': WCEnterExt},
'confirm_seed': {'gui': WCConfirmSeed},
'confirm_ext': {'gui': WCConfirmExt},
'have_seed': {'gui': WCHaveSeed},
'have_ext': {'gui': WCEnterExt},
'choose_hardware_device': {'gui': WCChooseHWDevice},
'script_and_derivation': {'gui': WCScriptAndDerivation},
'have_master_key': {'gui': WCHaveMasterKey},
'multisig': {'gui': WCMultisig},
'multisig_cosigner_keystore': {'gui': WCCosignerKeystore},
'multisig_cosigner_key': {'gui': WCHaveMasterKey},
'multisig_cosigner_seed': {'gui': WCHaveSeed},
'multisig_cosigner_have_ext': {'gui': WCEnterExt},
'multisig_cosigner_hardware': {'gui': WCChooseHWDevice},
'multisig_cosigner_script_and_derivation': {'gui': WCScriptAndDerivation},
'imported': {'gui': WCImport},
'wallet_password': {'gui': WCWalletPassword},
'wallet_password_hardware': {'gui': WCWalletPasswordHardware}
})
# add open existing wallet from wizard
self.navmap_merge({
'wallet_name': {
'next': lambda d: 'hw_unlock' if d['wallet_needs_hw_unlock'] else 'wallet_type',
'last': lambda d: d['wallet_exists'] and not d['wallet_needs_hw_unlock']
},
})
run_hook('init_wallet_wizard', self)
@property
def path(self):
return self._path
@path.setter
def path(self, path):
self._path = path
def is_single_password(self):
# not supported on desktop
return False
def create_storage(self, single_password: str = None):
self._logger.info('Creating wallet from wizard data')
data = self.get_wizard_data()
path = os.path.join(os.path.dirname(self._daemon.config.get_wallet_path()), data['wallet_name'])
super().create_storage(path, data)
# minimally populate self after create
self._password = data['password']
self.path = path
def run_split(self, wallet_path, split_data) -> None:
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(wallet_path)
if self.question(msg):
file_list = WalletDB.split_accounts(wallet_path, split_data)
msg = _('Your accounts have been moved to') + ':\n' + '\n'.join(file_list) + '\n\n' + _(
'Do you want to delete the old file') + ':\n' + wallet_path
if self.question(msg):
os.remove(wallet_path)
self.show_warning(_('The file was removed'))
def is_finalized(self, wizard_data: dict) -> bool:
# check decryption of existing wallet and keep wizard open if incorrect.
if not wizard_data['wallet_exists'] or wizard_data['wallet_is_open']:
return True
wallet_file = wizard_data['wallet_name']
storage = WalletStorage(wallet_file)
assert storage.file_exists(), f"file {wallet_file!r} does not exist"
if not storage.is_encrypted_with_user_pw() and not storage.is_encrypted_with_hw_device():
return True
try:
storage.decrypt(wizard_data['password'])
except InvalidPassword:
if storage.is_encrypted_with_hw_device():
self.show_message('This hardware device could not decrypt this wallet. Is it the correct one?')
else:
self.show_message('Invalid password')
return False
return True
def waiting_dialog(self, task, msg, on_finished=None):
dialog = QDialog()
label = WWLabel(msg)
vbox = QVBoxLayout()
vbox.addSpacing(100)
label.setMinimumWidth(300)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
vbox.addWidget(label)
vbox.addSpacing(100)
dialog.setLayout(vbox)
dialog.setModal(True)
exc = None
def task_wrap(_task):
nonlocal exc
try:
_task()
except Exception as e:
exc = e
t = threading.Thread(target=task_wrap, args=(task,))
t.start()
dialog.show()
while True:
QApplication.processEvents()
t.join(1.0/60)
if not t.is_alive():
break
dialog.close()
if exc:
raise exc
if on_finished:
on_finished()
class WalletWizardComponent(WizardComponent, ABC):
# ^ this class only exists to help with typing
wizard: QENewWalletWizard
def __init__(self, parent: QWidget, wizard: QENewWalletWizard, **kwargs):
WizardComponent.__init__(self, parent, wizard, **kwargs)
class WCWalletName(WalletWizardComponent, Logger):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Electrum wallet'))
Logger.__init__(self)
path = wizard._path
if os.path.isdir(path):
raise Exception("wallet path cannot point to a directory")
self.wallet_exists = False
self.wallet_is_open = False
self.wallet_needs_hw_unlock = False
hbox = QHBoxLayout()
hbox.addWidget(QLabel(_('Wallet') + ':'))
self.name_e = QLineEdit()
hbox.addWidget(self.name_e)
button = QPushButton(_('Choose...'))
button_create_new = QPushButton(_('New'))
hbox.addWidget(button)
hbox.addWidget(button_create_new)
self.layout().addLayout(hbox)
outside_label = WWLabel('')
self.layout().addWidget(outside_label)
self.layout().addSpacing(50)
msg_label = WWLabel('')
self.layout().addWidget(msg_label)
hbox2 = QHBoxLayout()
self.pw_e = PasswordLineEdit('', self)
self.pw_e.setFixedWidth(17 * char_width_in_lineedit())
pw_label = QLabel(_('Password') + ':')
hbox2.addWidget(pw_label)
hbox2.addWidget(self.pw_e)
hbox2.addStretch()
self.layout().addLayout(hbox2)
self.layout().addStretch(1)
temp_storage = None # type: Optional[WalletStorage]
datadir_wallet_folder = self.wizard.config.get_datadir_wallet_path()
def relative_path(path):
new_path = path
try:
if is_subpath(path, datadir_wallet_folder):
# below datadir_wallet_path, make relative
commonpath = os.path.commonpath([path, datadir_wallet_folder])
new_path = os.path.relpath(path, commonpath)
except ValueError:
pass
return new_path
def on_choose():
_path, __ = QFileDialog.getOpenFileName(self, "Select your wallet file", datadir_wallet_folder)
if _path:
self.name_e.setText(relative_path(_path))
def on_filename(filename_or_path):
# Note: "filename" might contain ".." (etc) and hence sketchy path traversals are possible
nonlocal temp_storage
temp_storage = None
msg = None
self.wallet_exists = False
self.wallet_is_open = False
self.wallet_needs_hw_unlock = False
if filename_or_path:
# Note: if filename_or_path is a path, os.path.join will leave it unchanged
_path = os.path.join(datadir_wallet_folder, filename_or_path)
wallet_from_memory = self.wizard._daemon.get_wallet(_path)
try:
if wallet_from_memory:
temp_storage = wallet_from_memory.storage # type: Optional[WalletStorage]
self.wallet_is_open = True
else:
temp_storage = WalletStorage(_path)
self.wallet_exists = temp_storage.file_exists()
except (StorageReadWriteError, WalletFileException) as e:
msg = _('Cannot read file') + f'\n{repr(e)}'
except Exception as e:
self.logger.exception('')
msg = _('Cannot read file') + f'\n{repr(e)}'
else:
msg = ""
self.valid = temp_storage is not None
user_needs_to_enter_password = False
if temp_storage:
if not temp_storage.file_exists():
msg = _("This file does not exist.") + '\n' \
+ _("Press 'Next' to create this wallet, or choose another file.")
elif not wallet_from_memory:
if temp_storage.is_encrypted_with_user_pw():
msg = _("This file is encrypted with a password.")
user_needs_to_enter_password = True
elif temp_storage.is_encrypted_with_hw_device():
msg = _("This file is encrypted using a hardware device.") + '\n' \
+ _("Press 'Next' to choose device to decrypt.")
self.wallet_needs_hw_unlock = True
else:
msg = _("Press 'Finish' to open this wallet.")
else:
msg = _("This file is already open in memory.") + "\n" \
+ _("Press 'Finish' to create/focus window.")
if msg is None:
msg = _('Cannot read file')
if filename_or_path and os.path.isabs(relative_path(_path)):
outside_text = _('Note: this wallet file is outside the default wallets folder.')
else:
outside_text = ''
outside_label.setText(outside_text)
msg_label.setText(msg)
if user_needs_to_enter_password:
pw_label.show()
self.pw_e.show()
if not self.name_e.hasFocus():
self.pw_e.setFocus()
else:
pw_label.hide()
self.pw_e.hide()
self.on_updated()
button.clicked.connect(on_choose)
button_create_new.clicked.connect(
lambda: self.name_e.setText(get_new_wallet_name(datadir_wallet_folder))) # FIXME get_new_wallet_name might raise
self.name_e.textChanged.connect(on_filename)
self.name_e.setText(relative_path(path))
def initialFocus(self) -> Optional[QWidget]:
return self.pw_e
def apply(self):
if self.wallet_exists:
# use full path
wallet_folder = self.wizard.config.get_datadir_wallet_path()
self.wizard_data['wallet_name'] = os.path.join(wallet_folder, self.name_e.text())
else:
# FIXME: wizard_data['wallet_name'] is sometimes a full path, sometimes a basename
self.wizard_data['wallet_name'] = self.name_e.text()
self.wizard_data['wallet_exists'] = self.wallet_exists
self.wizard_data['wallet_is_open'] = self.wallet_is_open
self.wizard_data['password'] = self.pw_e.text()
self.wizard_data['wallet_needs_hw_unlock'] = self.wallet_needs_hw_unlock
class WCWalletType(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Create new wallet'))
message = _('What kind of wallet do you want to create?')
wallet_kinds = [
ChoiceItem(key='standard', label=_('Standard wallet')),
ChoiceItem(key='2fa', label=_('Wallet with two-factor authentication')),
ChoiceItem(key='multisig', label=_('Multi-signature wallet')),
ChoiceItem(key='imported', label=_('Import Bitcoin addresses or private keys')),
]
choices = [c for c in wallet_kinds if c.key in wallet_types]
self.choice_w = ChoiceWidget(message=message, choices=choices, default_key='standard')
self.layout().addWidget(self.choice_w)
self.layout().addStretch(1)
self._valid = True
def apply(self):
self.wizard_data['wallet_type'] = self.choice_w.selected_key
class WCKeystoreType(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Keystore'))
message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?')
choices = [
ChoiceItem(key='createseed', label=_('Create a new seed')),
ChoiceItem(key='haveseed', label=_('I already have a seed')),
ChoiceItem(key='masterkey', label=_('Use a master key')),
ChoiceItem(key='hardware', label=_('Use a hardware device')),
]
self.choice_w = ChoiceWidget(message=message, choices=choices)
self.layout().addWidget(self.choice_w)
self.layout().addStretch(1)
self._valid = True
def apply(self):
self.wizard_data['keystore_type'] = self.choice_w.selected_key
class WCExtendKeystore(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Keystore'))
message = _('What type of signing method do you want to add?')
choices = [
ChoiceItem(key='haveseed', label=_('Enter seed')),
ChoiceItem(key='hardware', label=_('Use a hardware device')),
]
self.choice_w = ChoiceWidget(message=message, choices=choices)
self.layout().addWidget(self.choice_w)
self.layout().addStretch(1)
self._valid = True
def apply(self):
self.wizard_data['keystore_type'] = self.choice_w.selected_key
class WCCreateSeed(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Wallet Seed'))
self._busy = True
self.seed_type = 'standard' if self.wizard.config.WIZARD_DONT_CREATE_SEGWIT else 'segwit'
self.seed_widget = None
self.seed = None
def on_ready(self):
if self.wizard_data['wallet_type'] == '2fa':
self.seed_type = '2fa_segwit'
QTimer.singleShot(1, self.create_seed)
def apply(self):
if self.seed_widget:
self.wizard_data['seed'] = self.seed
self.wizard_data['seed_type'] = self.seed_type
self.wizard_data['seed_extend'] = self.seed_widget.is_ext
self.wizard_data['seed_variant'] = 'electrum'
def create_seed(self):
self.busy = True
self.seed = mnemonic.Mnemonic('en').make_seed(seed_type=self.seed_type)
self.seed_widget = SeedWidget(
title=_('Your wallet generation seed is:'),
seed=self.seed,
options=['ext', 'electrum'],
msg=True,
parent=self,
config=self.wizard.config,
)
self.layout().addWidget(self.seed_widget)
self.layout().addStretch(1)
self.busy = False
self.valid = True
class WCConfirmSeed(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, 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.')
])
self.layout().addWidget(WWLabel(message))
self.seed_widget = SeedWidget(
is_seed=lambda x: x == self.wizard_data['seed'],
config=self.wizard.config,
)
def seed_valid_changed(valid):
self.valid = valid
self.seed_widget.validChanged.connect(seed_valid_changed)
self.layout().addWidget(self.seed_widget)
wizard.app.clipboard().clear()
def apply(self):
pass
class WCEnterExt(WalletWizardComponent, Logger):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Seed Extension'))
Logger.__init__(self)
message = '\n'.join([
_('You may extend your seed with custom words.'),
_('Your seed extension must be saved together with your seed.'),
])
warning = '\n'.join([
_('Note that this is NOT your encryption password.'),
_('If you do not know what this is, leave this field empty.'),
])
self.ext_edit = SeedExtensionEdit(self, message=message, warning=warning)
self.ext_edit.textEdited.connect(self.on_text_edited)
self.layout().addWidget(self.ext_edit)
self.layout().addStretch(1)
self.warn_label = IconLabel(reverse=True, hide_if_empty=True)
self.warn_label.setIcon(read_QIcon('warning.png'))
self.layout().addWidget(self.warn_label)
def on_ready(self):
self.validate()
def on_text_edited(self, text):
# TODO also for cosigners?
self.ext_edit.warn_issue4566 = self.wizard_data['keystore_type'] == 'haveseed' and \
self.wizard_data['seed_type'] == 'bip39'
self.validate()
def validate(self):
self.apply()
musig_valid, errortext = self.wizard.check_multisig_constraints(self.wizard_data)
self.valid = musig_valid
self.warn_label.setText(errortext)
def apply(self):
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
cosigner_data['seed_extra_words'] = self.ext_edit.text()
class WCConfirmExt(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Confirm Seed Extension'))
message = '\n'.join([
_('Your seed extension must be saved together with your seed.'),
_('Please type it here.'),
])
self.ext_edit = SeedExtensionEdit(self, message=message)
self.ext_edit.textEdited.connect(self.on_text_edited)
self.layout().addWidget(self.ext_edit)
self.layout().addStretch(1)
def on_ready(self):
self.validate()
def on_text_edited(self, *args):
self.validate()
def validate(self):
self.valid = self.ext_edit.text() == self.wizard_data['seed_extra_words']
def apply(self):
pass
class WCHaveSeed(WalletWizardComponent, Logger):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Enter Seed'))
Logger.__init__(self)
self.layout().addWidget(WWLabel(_('Please enter your seed phrase in order to restore your wallet.')))
self.warn_label = IconLabel(reverse=True, hide_if_empty=True)
self.warn_label.setIcon(read_QIcon('warning.png'))
self.seed_widget = None
self.can_passphrase = True
def on_ready(self):
options = ['ext', 'electrum', 'bip39', 'slip39']
if self.wizard_data['wallet_type'] == '2fa':
options = ['ext', 'electrum']
else:
if self.params and 'seed_options' in self.params:
options = self.params['seed_options']
self.seed_widget = SeedWidget(
is_seed=self.is_seed,
options=options,
config=self.wizard.config,
)
def seed_valid_changed(valid):
if not valid:
self.valid = valid
else:
self.validate()
self.seed_widget.validChanged.connect(seed_valid_changed)
self.seed_widget.updated.connect(self.validate)
self.layout().addWidget(self.seed_widget)
self.layout().addStretch(1)
self.layout().addWidget(self.warn_label)
def is_seed(self, x):
# really only used for electrum seeds. bip39 and slip39 are validated in SeedWidget
t = mnemonic.calc_seed_type(x)
if self.wizard_data['wallet_type'] == 'standard':
return mnemonic.is_seed(x) and not mnemonic.is_any_2fa_seed_type(t)
elif self.wizard_data['wallet_type'] == '2fa':
return mnemonic.is_any_2fa_seed_type(t)
else:
# multisig? by default, only accept modern non-2fa electrum seeds
return t in ['standard', 'segwit']
def validate(self):
# precond: only call when SeedWidget deems seed a valid seed
seed = self.seed_widget.get_seed()
seed_variant = self.seed_widget.seed_type
wallet_type = self.wizard_data['wallet_type']
seed_valid, seed_type, validation_message, self.can_passphrase = self.wizard.validate_seed(seed, seed_variant, wallet_type)
is_cosigner = self.wizard_data['wallet_type'] == 'multisig' and 'multisig_current_cosigner' in self.wizard_data
if not is_cosigner or not seed_valid:
self.valid = seed_valid
return
self.apply()
musig_valid, errortext = self.wizard.check_multisig_constraints(self.wizard_data)
if not musig_valid:
seed_valid = False
self.warn_label.setText(errortext)
self.valid = seed_valid
def apply(self):
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
cosigner_data['seed'] = self.seed_widget.get_seed()
cosigner_data['seed_variant'] = self.seed_widget.seed_type
if self.seed_widget.seed_type == 'electrum':
cosigner_data['seed_type'] = mnemonic.calc_seed_type(self.seed_widget.get_seed())
else:
cosigner_data['seed_type'] = self.seed_widget.seed_type
cosigner_data['seed_extend'] = self.seed_widget.is_ext if self.can_passphrase else False
class WCScriptAndDerivation(WalletWizardComponent, Logger):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Script type and Derivation path'))
Logger.__init__(self)
self.choice_w = None # type: ChoiceWidget
self.derivation_path_edit = None
self.warn_label = IconLabel(reverse=True, hide_if_empty=True)
self.warn_label.setIcon(read_QIcon('warning.png'))
def on_ready(self):
message1 = _('Choose the type of addresses in your wallet.')
message2 = ' '.join([
_('You can override the suggested derivation path.'),
_('If you are not sure what this is, leave this field unchanged.')
])
hide_choices = False
if self.wizard_data['wallet_type'] == 'multisig':
choices = [
# TODO: nicer to refactor 'standard' to 'p2sh', but backend wallet still uses 'standard'
ChoiceItem(key='standard', label='legacy multisig (p2sh)',
extra_data=normalize_bip32_derivation("m/45'/0")),
ChoiceItem(key='p2wsh-p2sh', label='p2sh-segwit multisig (p2wsh-p2sh)',
extra_data=purpose48_derivation(0, xtype='p2wsh-p2sh')),
ChoiceItem(key='p2wsh', label='native segwit multisig (p2wsh)',
extra_data=purpose48_derivation(0, xtype='p2wsh')),
]
if 'multisig_current_cosigner' in self.wizard_data:
# get script type of first cosigner
ks = self.wizard.keystore_from_data(self.wizard_data['wallet_type'], self.wizard_data)
default_choice = xpub_type(ks.get_master_public_key())
hide_choices = True
else:
default_choice = 'p2wsh'
else:
default_choice = 'p2wpkh'
choices = [
# TODO: nicer to refactor 'standard' to 'p2pkh', but backend wallet still uses 'standard'
ChoiceItem(key='standard', label='legacy (p2pkh)',
extra_data=bip44_derivation(0, bip43_purpose=44)),
ChoiceItem(key='p2wpkh-p2sh', label='p2sh-segwit (p2wpkh-p2sh)',
extra_data=bip44_derivation(0, bip43_purpose=49)),
ChoiceItem(key='p2wpkh', label='native segwit (p2wpkh)',
extra_data=bip44_derivation(0, bip43_purpose=84)),
]
if self.wizard_data['wallet_type'] == 'standard' and not self.wizard_data['keystore_type'] == 'hardware':
button = QPushButton(_("Detect Existing Accounts"))
passphrase = self.wizard_data['seed_extra_words'] if self.wizard_data['seed_extend'] else ''
if self.wizard_data['seed_variant'] == 'bip39':
root_seed = bip39_to_seed(self.wizard_data['seed'], passphrase=passphrase)
elif self.wizard_data['seed_variant'] == 'slip39':
root_seed = self.wizard_data['seed'].decrypt(passphrase)
def get_account_xpub(account_path):
root_node = BIP32Node.from_rootseed(root_seed, xtype="standard")
account_node = root_node.subkey_at_private_derivation(account_path)
account_xpub = account_node.to_xpub()
return account_xpub
def on_account_select(account):
script_type = account["script_type"]
if script_type == "p2pkh":
script_type = "standard"
self.choice_w.select(script_type)
self.derivation_path_edit.setText(account["derivation_path"])
button.clicked.connect(lambda: Bip39RecoveryDialog(self, get_account_xpub, on_account_select))
self.layout().addWidget(button, alignment=Qt.AlignmentFlag.AlignLeft)
self.layout().addWidget(QLabel(_("Or")))
def on_choice_click(index):
self.derivation_path_edit.setText(self.choice_w.selected_item.extra_data)
self.choice_w = ChoiceWidget(message=message1, choices=choices, default_key=default_choice)
self.choice_w.itemSelected.connect(on_choice_click)
if not hide_choices:
self.layout().addWidget(self.choice_w)
self.layout().addWidget(WWLabel(message2))
self.derivation_path_edit = QLineEdit()
self.derivation_path_edit.textChanged.connect(self.validate)
self.layout().addWidget(self.derivation_path_edit)
on_choice_click(self.choice_w.selected_index) # set default value for derivation path
self.layout().addStretch(1)
self.layout().addWidget(self.warn_label)
def validate(self):
self.apply()
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
valid = is_bip32_derivation(cosigner_data['derivation_path'])
if valid:
valid, errortext = self.wizard.check_multisig_constraints(self.wizard_data)
if not valid:
self.logger.error(errortext)
self.warn_label.setText(errortext)
else:
self.warn_label.setText(_('Invalid derivation path'))
self.valid = valid
def apply(self):
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
cosigner_data['script_type'] = self.choice_w.selected_key
cosigner_data['derivation_path'] = str(self.derivation_path_edit.text())
class WCCosignerKeystore(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard)
message = _('Add a cosigner to your multi-sig wallet')
choices = [
ChoiceItem(key='masterkey', label=_('Enter cosigner key')),
ChoiceItem(key='haveseed', label=_('Enter cosigner seed')),
ChoiceItem(key='hardware', label=_('Cosign with hardware device')),
]
self.choice_w = ChoiceWidget(message=message, choices=choices)
self.layout().addWidget(self.choice_w)
self.cosigner = 0
self.participants = 0
self._valid = True
def on_ready(self):
self.participants = self.wizard_data['multisig_participants']
# cosigner index is determined here and put on the wizard_data dict in apply()
# as this page is the start for each additional cosigner
self.cosigner = 2 + len(self.wizard_data['multisig_cosigner_data'])
self.wizard_data['multisig_current_cosigner'] = self.cosigner
self.title = _("Add Cosigner {}").format(self.wizard_data['multisig_current_cosigner'])
# different from old wizard: master public key for sharing is now shown on this page
self.layout().addSpacing(20)
self.layout().addWidget(WWLabel(_('Below is your master public key. Please share it with your cosigners')))
seed_widget = SeedWidget(
self.wizard_data['multisig_master_pubkey'],
icon=False,
for_seed_words=False,
config=self.wizard.config,
)
self.layout().addWidget(seed_widget)
self.layout().addStretch(1)
def apply(self):
self.wizard_data['cosigner_keystore_type'] = self.choice_w.selected_key
self.wizard_data['multisig_current_cosigner'] = self.cosigner
self.wizard_data['multisig_cosigner_data'][str(self.cosigner)] = {
'keystore_type': self.choice_w.selected_key
}
class WCHaveMasterKey(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Create keystore from a master key'))
self.keys_widget = None
self.message_create = ' '.join([
_("To create a watching-only wallet, please enter your master public key (xpub/ypub/zpub)."),
_("To create a spending wallet, please enter a master private key (xprv/yprv/zprv).")
])
self.message_multisig = ' '.join([
_('Please enter your master private key (xprv).'),
_('You can also enter a public key (xpub) here, but be aware you will then create a watch-only wallet if all cosigners are added using public keys'),
])
self.message_cosign = ' '.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.')
])
self.header_layout = QHBoxLayout()
self.label = WWLabel()
self.label.setMinimumWidth(400)
self.header_layout.addWidget(self.label)
self.warn_label = IconLabel(reverse=True, hide_if_empty=True)
self.warn_label.setIcon(read_QIcon('warning.png'))
def on_ready(self):
if self.wizard_data['wallet_type'] == 'standard':
self.label.setText(self.message_create)
def is_valid(x) -> bool:
self.apply()
key_valid, message = self.wizard.validate_master_key(x, self.wizard_data['wallet_type'])
self.warn_label.setText(message)
return key_valid
elif self.wizard_data['wallet_type'] == 'multisig':
if 'multisig_current_cosigner' in self.wizard_data:
self.title = _("Add Cosigner {}").format(self.wizard_data['multisig_current_cosigner'])
self.label.setText(self.message_cosign)
else:
self.label.setText(self.message_multisig)
def is_valid(x) -> bool:
self.apply()
key_valid, message = self.wizard.validate_master_key(x, self.wizard_data['wallet_type'])
if not key_valid:
self.warn_label.setText(message)
return False
musig_valid, errortext = self.wizard.check_multisig_constraints(self.wizard_data)
self.warn_label.setText(errortext)
if not musig_valid:
return False
return True
else:
raise Exception(f"unexpected wallet type: {self.wizard_data['wallet_type']}")
self.keys_widget = KeysWidget(parent=self, header_layout=self.header_layout, is_valid=is_valid,
allow_multi=False, config=self.wizard.config)
def key_valid_changed(valid):
self.valid = valid
self.keys_widget.validChanged.connect(key_valid_changed)
self.layout().addWidget(self.keys_widget)
self.layout().addStretch()
self.layout().addWidget(self.warn_label)
def apply(self):
text = self.keys_widget.get_text()
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
cosigner_data['master_key'] = text
class WCMultisig(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Multi-Signature Wallet'))
def on_m(m):
m_label.setText(_('Require {0} signatures').format(m))
cw.set_m(m)
backup_warning_label.setVisible(cw.m != cw.n)
def on_n(n):
n_label.setText(_('From {0} cosigners').format(n))
cw.set_n(n)
m_edit.setMaximum(n)
backup_warning_label.setVisible(cw.m != cw.n)
backup_warning_label = WWLabel(_('Warning: to be able to restore a multisig wallet, '
'you should include the master public key for each cosigner '
'in all of your backups.'))
cw = CosignWidget(2, 2)
m_label = QLabel()
n_label = QLabel()
m_edit = QSlider(Qt.Orientation.Horizontal, self)
m_edit.setMinimum(1)
m_edit.setMaximum(2)
m_edit.setValue(2)
m_edit.valueChanged.connect(on_m)
on_m(m_edit.value())
n_edit = QSlider(Qt.Orientation.Horizontal, self)
n_edit.setMinimum(2)
n_edit.setMaximum(15)
n_edit.setValue(2)
n_edit.valueChanged.connect(on_n)
on_n(n_edit.value())
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)
self.layout().addWidget(cw)
self.layout().addWidget(WWLabel(_('Choose the number of signatures needed to unlock funds in your wallet:')))
self.layout().addLayout(grid)
self.layout().addSpacing(2 * char_width_in_lineedit())
self.layout().addWidget(backup_warning_label)
self.layout().addStretch(1)
self.n_edit = n_edit
self.m_edit = m_edit
self._valid = True
def apply(self):
self.wizard_data['multisig_participants'] = int(self.n_edit.value())
self.wizard_data['multisig_signatures'] = int(self.m_edit.value())
self.wizard_data['multisig_cosigner_data'] = {}
class WCImport(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Import Bitcoin Addresses or Private Keys'))
message = _(
'Enter a list of Bitcoin addresses (this will create a watching-only wallet), or a list of private keys.')
header_layout = QHBoxLayout()
label = WWLabel(message)
label.setMinimumWidth(400)
header_layout.addWidget(label)
header_layout.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignmentFlag.AlignRight)
def is_valid(x) -> bool:
return keystore.is_address_list(x) or keystore.is_private_key_list(x, raise_on_error=True)
self.keys_widget = KeysWidget(header_layout=header_layout, is_valid=is_valid,
allow_multi=True, config=self.wizard.config)
def key_valid_changed(valid):
self.valid = valid
self.keys_widget.validChanged.connect(key_valid_changed)
self.layout().addWidget(self.keys_widget)
def apply(self):
text = self.keys_widget.get_text()
if keystore.is_address_list(text):
self.wizard_data['address_list'] = text
elif keystore.is_private_key_list(text):
self.wizard_data['private_key_list'] = text
class WCWalletPassword(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Wallet Password'))
# TODO: PasswordLayout assumes a button, refactor PasswordLayout
# for now, fake next_button.setEnabled
class Hack:
def setEnabled(self2, b):
self.valid = b
self.next_button = Hack()
self.pw_layout = PasswordLayout(
msg=MSG_ENTER_PASSWORD,
kind=PW_NEW,
OK_button=self.next_button,
)
self.layout().addLayout(self.pw_layout.layout())
self.layout().addStretch(1)
def initialFocus(self) -> Optional[QWidget]:
return self.pw_layout.new_pw
def apply(self):
self.wizard_data['password'] = self.pw_layout.new_password()
self.wizard_data['encrypt'] = True
class SeedExtensionEdit(QWidget):
def __init__(self, parent, *, message: str = None, warning: str = None, warn_issue4566: bool = False):
super().__init__(parent)
self.warn_issue4566 = warn_issue4566
layout = QVBoxLayout()
self.setLayout(layout)
if message:
layout.addWidget(WWLabel(message))
self.line = QLineEdit()
layout.addWidget(self.line)
def f(text):
if self.warn_issue4566:
text_whitespace_normalised = ' '.join(text.split())
warn_issue4566_label.setVisible(text != text_whitespace_normalised)
self.line.textEdited.connect(f)
if warning:
layout.addWidget(WWLabel(warning))
warn_issue4566_label = WWLabel(MSG_PASSPHRASE_WARN_ISSUE4566)
warn_issue4566_label.setVisible(False)
layout.addWidget(warn_issue4566_label)
# expose textEdited signal and text() func to widget
self.textEdited = self.line.textEdited
self.text = self.line.text
class CosignWidget(QWidget):
def __init__(self, m, n):
QWidget.__init__(self)
self.size = max(120, 9 * font_height())
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.ColorRole.Window)
pen = QPen(bgcolor, 7, Qt.PenStyle.SolidLine)
qp = QPainter()
qp.begin(self)
qp.setPen(pen)
qp.setRenderHint(QPainter.RenderHint.Antialiasing)
qp.setBrush(Qt.GlobalColor.gray)
for i in range(self.n):
alpha = int(16 * 360 * i/self.n)
alpha2 = int(16 * 360 * 1/self.n)
qp.setBrush(Qt.GlobalColor.green if i < self.m else Qt.GlobalColor.gray)
qp.drawPie(self.R, alpha, alpha2)
qp.end()
class WCChooseHWDevice(WalletWizardComponent, Logger):
scanFailed = pyqtSignal([str, str], arguments=['code', 'message'])
scanComplete = pyqtSignal()
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Choose Hardware Device'))
Logger.__init__(self)
self.scanFailed.connect(self.on_scan_failed)
self.scanComplete.connect(self.on_scan_complete)
self.plugins = wizard.plugins
self.config = wizard.config
self.error_l = WWLabel()
self.error_l.setVisible(False)
self.device_list = QWidget()
self.device_list_layout = QVBoxLayout()
self.device_list.setLayout(self.device_list_layout)
self.choice_w = None # type: ChoiceWidget
self.rescan_button = QPushButton(_('Rescan devices'))
self.rescan_button.clicked.connect(self.on_rescan)
self.add_plugin_button = QPushButton(_('Add plugin'))
self.add_plugin_button.clicked.connect(self.on_add_plugin)
hbox = QHBoxLayout()
hbox.addStretch(1)
hbox.addWidget(self.rescan_button)
hbox.addWidget(self.add_plugin_button)
hbox.addStretch(1)
self.layout().addWidget(self.error_l)
self.layout().addWidget(self.device_list)
self.layout().addStretch(1)
self.layout().addLayout(hbox)
self.layout().addStretch(1)
def on_ready(self):
self.scan_devices()
def on_rescan(self):
self.scan_devices()
def on_add_plugin(self):
d = PluginsDialog(self.config, self.plugins)
d.exec()
self.scan_devices()
def on_scan_failed(self, code, message):
self.error_l.setText(message)
self.error_l.setVisible(True)
self.device_list.setVisible(False)
self.valid = False
def on_scan_complete(self):
self.error_l.setVisible(False)
self.device_list.setVisible(True)
choices = [] # type: List[ChoiceItem]
for name, info in self.devices:
state = _("initialized") if info.initialized else _("wiped")
label = info.label or _("An unnamed {}").format(name)
try:
transport_str = info.device.transport_ui_string[:20]
except Exception:
transport_str = 'unknown transport'
descr = f"{label} [{info.model_name or name}, {state}, {transport_str}]"
choices.append(ChoiceItem(key=(name, info), label=descr))
msg = _('Select a device') + ':'
if self.choice_w:
self.device_list_layout.removeWidget(self.choice_w)
self.choice_w = ChoiceWidget(message=msg, choices=choices)
self.device_list_layout.addWidget(self.choice_w)
self.valid = True
if self.valid:
self.wizard.next_button.setFocus()
else:
self.rescan_button.setFocus()
def scan_devices(self):
self.valid = False
self.busy_msg = _('Scanning devices...')
self.busy = True
def scan_task():
# check available plugins
supported_plugins = self.plugins.get_hardware_support()
devices = [] # type: List[Tuple[str, DeviceInfo]]
devmgr = self.plugins.device_manager
debug_msg = ''
def failed_getting_device_infos(name, e):
nonlocal debug_msg
err_str_oneline = ' // '.join(str(e).splitlines())
self.logger.warning(f'error getting device infos for {name}: {err_str_oneline}')
_indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True))
debug_msg += f' {name}: (error getting device infos)\n{_indented_error_msg}\n'
# scan devices
try:
# scanned_devices = self.run_task_without_blocking_gui(task=devmgr.scan_devices,
# msg=_("Scanning devices..."))
scanned_devices = devmgr.scan_devices()
except BaseException as e:
self.logger.info('error scanning devices: {}'.format(repr(e)))
debug_msg = ' {}:\n {}'.format(_('Error scanning devices'), e)
else:
for splugin in supported_plugins:
name, plugin = splugin.name, splugin.plugin
# plugin init errored?
if not plugin:
e = splugin.exception
indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True))
debug_msg += f' {name}: (error during plugin init)\n'
debug_msg += ' {}\n'.format(_('You might have an incompatible library.'))
debug_msg += f'{indented_error_msg}\n'
continue
# see if plugin recognizes 'scanned_devices'
try:
# FIXME: side-effect: this sets client.handler
device_infos = devmgr.list_pairable_device_infos(
handler=None, plugin=plugin, devices=scanned_devices, include_failing_clients=True)
except HardwarePluginLibraryUnavailable as e:
failed_getting_device_infos(name, e)
continue
except BaseException as e:
self.logger.exception('')
failed_getting_device_infos(name, e)
continue
device_infos_failing = list(filter(lambda di: di.exception is not None, device_infos))
for di in device_infos_failing:
failed_getting_device_infos(name, di.exception)
device_infos_working = list(filter(lambda di: di.exception is None, device_infos))
devices += list(map(lambda x: (name, x), device_infos_working))
if not debug_msg:
debug_msg = ' {}'.format(_('No exceptions encountered.'))
if not devices:
msg = (_('No hardware device detected.') + '\n\n')
if sys.platform == 'win32':
msg += _('If your device is not detected on Windows, go to "Settings", "Devices", "Connected devices", '
'and do "Remove device". Then, plug your device again.') + '\n'
msg += _('While this is less than ideal, it might help if you run Electrum as Administrator.') + '\n'
else:
msg += _('On Linux, you might have to add a new permission to your udev rules.') + '\n'
msg += '\n\n'
msg += _('Debug message') + '\n' + debug_msg
self.scanFailed.emit('no_devices', msg)
self.busy = False
return
# select device
self.devices = devices
self.scanComplete.emit()
self.busy = False
t = threading.Thread(target=scan_task, daemon=True)
t.start()
def apply(self):
if self.choice_w:
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
cosigner_data['hardware_device'] = self.choice_w.selected_key
class WCWalletPasswordHardware(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Encrypt using hardware'))
self.plugins = wizard.plugins
# TODO: PasswordLayout assumes a button, refactor PasswordLayout
# for now, fake next_button.setEnabled
class Hack:
def setEnabled(self2, b):
self.valid = b
self.next_button = Hack()
self.playout = PasswordLayoutForHW(
MSG_HW_STORAGE_ENCRYPTION,
kind=PW_NEW,
OK_button=self.next_button,
)
self.layout().addLayout(self.playout.layout())
self.layout().addStretch(1)
self._hw_password = None # type: Optional[str]
self._valid = False
def on_ready(self):
_name, info = self.wizard_data['hardware_device']
device_id = info.device.id_
client = self.plugins.device_manager.client_by_id(device_id, scan_now=False)
if client is None:
self.valid = False
self.error = _("Client for hardware device was unpaired.")
return
def retrieve_password_task():
try:
self._hw_password = client.get_password_for_storage_encryption()
self.valid = True
except UserFacingException as e:
self.error = str(e)
self.valid = False
finally:
self.busy = False
self.busy = True
t = threading.Thread(target=retrieve_password_task, daemon=True)
t.start()
def apply(self):
if not self.valid:
return
self.wizard_data['encrypt'] = True
if self.playout.should_encrypt_storage_with_xpub():
self.wizard_data['xpub_encrypt'] = True
assert self._hw_password
self.wizard_data['password'] = self._hw_password
else:
self.wizard_data['xpub_encrypt'] = False
self.wizard_data['password'] = self.playout.new_password()
class WCHWUnlock(WalletWizardComponent, Logger):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Unlocking hardware'))
Logger.__init__(self)
self.plugins = wizard.plugins
self.plugin = None
self._busy = True
self.password = None
ok_icon = QLabel()
ok_icon.setPixmap(QPixmap(icon_path('confirmed.png')).scaledToWidth(48, mode=Qt.TransformationMode.SmoothTransformation))
ok_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.ok_l = WWLabel(_('Hardware successfully unlocked'))
self.ok_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout().addStretch(1)
self.layout().addWidget(ok_icon)
self.layout().addWidget(self.ok_l)
self.layout().addStretch(1)
def on_ready(self):
_name, _info = self.wizard_data['hardware_device']
self.plugin = self.plugins.get_plugin(_info.plugin_name)
self.title = _('Unlocking {} ({})').format(_info.model_name, _info.label)
device_id = _info.device.id_
client = self.plugins.device_manager.client_by_id(device_id, scan_now=False)
if client is None:
self.error = _("Client for hardware device was unpaired.")
self.busy = False
self.validate()
return
client.handler = self.plugin.create_handler(self.wizard)
def unlock_task(client):
try:
self.password = client.get_password_for_storage_encryption()
except UserCancelled as e:
self.error = repr(e)
except Exception as e:
self.error = repr(e) # TODO: handle user interaction exceptions (e.g. invalid pin) more gracefully
self.logger.exception(repr(e))
self.busy = False
self.validate()
t = threading.Thread(target=unlock_task, args=(client,), daemon=True)
t.start()
def validate(self):
self.valid = False
if self.password and not self.error:
if not self.check_hw_decrypt():
self.error = _('This hardware device could not decrypt this wallet. Is it the correct one?')
else:
self.apply()
self.valid = True
if self.valid:
self.wizard.requestNext.emit() # via signal, so it triggers Next/Finish on GUI thread after on_updated()
def check_hw_decrypt(self):
wallet_file = self.wizard_data['wallet_name']
storage = WalletStorage(wallet_file)
if not storage.is_encrypted_with_hw_device():
return True
try:
storage.decrypt(self.password)
except InvalidPassword:
return False
return True
def apply(self):
if self.valid:
self.wizard_data['password'] = self.password
class WCHWXPub(WalletWizardComponent, Logger):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Retrieving extended public key from hardware'))
Logger.__init__(self)
self.plugins = wizard.plugins
self.plugin = None
self._busy = True
self.xpub = None
self.root_fingerprint = None
self.label = None
self.soft_device_id = None
ok_icon = QLabel()
ok_icon.setPixmap(QPixmap(icon_path('confirmed.png')).scaledToWidth(48, mode=Qt.TransformationMode.SmoothTransformation))
ok_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.ok_l = WWLabel(_('Hardware keystore added to wallet'))
self.ok_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout().addStretch(1)
self.layout().addWidget(ok_icon)
self.layout().addWidget(self.ok_l)
self.layout().addStretch(1)
def on_ready(self):
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
_name, _info = cosigner_data['hardware_device']
self.plugin = self.plugins.get_plugin(_info.plugin_name)
self.title = _('Retrieving extended public key from {} ({})').format(_info.model_name, _info.label)
device_id = _info.device.id_
client = self.plugins.device_manager.client_by_id(device_id, scan_now=False)
if client is None:
self.error = _("Client for hardware device was unpaired.")
self.busy = False
self.validate()
return
if not client.handler:
client.handler = self.plugin.create_handler(self.wizard)
xtype = cosigner_data['script_type']
derivation = cosigner_data['derivation_path']
def get_xpub_task(_client, _derivation, _xtype):
try:
self.xpub = self.get_xpub_from_client(_client, _derivation, _xtype)
self.root_fingerprint = _client.request_root_fingerprint_from_device()
self.label = _client.label()
self.soft_device_id = _client.get_soft_device_id()
except UserFacingException as e:
self.error = str(e)
self.logger.error(repr(e))
except Exception as e:
self.error = repr(e) # TODO: handle user interaction exceptions (e.g. invalid pin) more gracefully
self.logger.exception(repr(e))
if self.xpub:
self.logger.debug(f'Done retrieve xpub: {self.xpub[:10]}...{self.xpub[-5:]}')
self.busy = False
self.validate()
t = threading.Thread(target=get_xpub_task, args=(client, derivation, xtype), daemon=True)
t.start()
def get_xpub_from_client(self, client, derivation, xtype): # override for HWW specific client if needed
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
_name, _info = cosigner_data['hardware_device']
if xtype not in self.plugin.SUPPORTED_XTYPES:
raise ScriptTypeNotSupported(_('This type of script is not supported with {}').format(_info.model_name))
return client.get_xpub(derivation, xtype)
def validate(self):
if self.xpub and not self.error:
self.apply()
valid, error = self.wizard.check_multisig_constraints(self.wizard_data)
if not valid:
self.error = '\n'.join([
_('Could not add hardware keystore to wallet'),
error
])
self.valid = valid
else:
self.valid = False
if self.valid:
self.wizard.requestNext.emit() # via signal, so it triggers Next/Finish on GUI thread after on_updated()
def apply(self):
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
_name, _info = cosigner_data['hardware_device']
cosigner_data['hw_type'] = _info.plugin_name
cosigner_data['master_key'] = self.xpub
cosigner_data['root_fingerprint'] = self.root_fingerprint
cosigner_data['label'] = self.label
cosigner_data['soft_device_id'] = self.soft_device_id
class WCHWUninitialized(WalletWizardComponent):
def __init__(self, parent, wizard):
WalletWizardComponent.__init__(self, parent, wizard, title=_('Hardware not initialized'))
def on_ready(self):
cosigner_data = self.wizard.current_cosigner(self.wizard_data)
_name, _info = cosigner_data['hardware_device']
w_icon = QLabel()
w_icon.setPixmap(QPixmap(icon_path('warning.png')).scaledToWidth(48, mode=Qt.TransformationMode.SmoothTransformation))
w_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
label = WWLabel(_('This {} is not initialized. Use manufacturer tooling to initialize the device.').format(_info.model_name))
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout().addStretch(1)
self.layout().addWidget(w_icon)
self.layout().addWidget(label)
self.layout().addStretch(1)
def apply(self):
pass
================================================
FILE: electrum/gui/qt/wizard/wizard.py
================================================
import copy
import threading
from abc import abstractmethod
from typing import TYPE_CHECKING, Optional
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot, QSize, QMetaObject
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import (QDialog, QPushButton, QWidget, QLabel, QVBoxLayout, QScrollArea,
QHBoxLayout, QLayout)
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.gui.qt.util import Buttons, icon_path, MessageBoxMixin, WWLabel, ResizableStackedWidget, AbstractQWidget
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
from electrum.gui.qt import QElectrumApplication
from electrum.wizard import WizardViewState
class QEAbstractWizard(QDialog, MessageBoxMixin):
""" Concrete subclasses of QEAbstractWizard must also inherit from a concrete AbstractWizard subclass.
QEAbstractWizard forms the base for all QtWidgets GUI based wizards, while AbstractWizard defines
the base for non-gui wizard flow navigation functionality.
"""
_logger = get_logger(__name__)
requestNext = pyqtSignal()
requestPrev = pyqtSignal()
def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', *, start_viewstate: 'WizardViewState' = None):
QDialog.__init__(self, None)
self.app = app
self.config = config
# compat
self.gui_thread = threading.current_thread()
self.setMinimumSize(600, 400)
self.title = QLabel()
self.window_title = ''
self.finish_label = _('Finish')
self.main_widget = ResizableStackedWidget(self)
self.back_button = QPushButton(_("Back"), self)
self.back_button.clicked.connect(self.on_back_button_clicked)
self.back_button.setEnabled(False)
self.back_button.setDefault(False)
self.back_button.setAutoDefault(False)
self.next_button = QPushButton(_("Next"), self)
self.next_button.clicked.connect(self.on_next_button_clicked)
self.next_button.setEnabled(False)
self.next_button.setDefault(True)
self.next_button.setAutoDefault(True)
self.requestPrev.connect(self.on_back_button_clicked)
self.requestNext.connect(self.on_next_button_clicked)
self.logo = QLabel()
please_wait_layout = QVBoxLayout()
please_wait_layout.addStretch(1)
self.please_wait_l = QLabel(_("Please wait..."))
self.please_wait_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
please_wait_layout.addWidget(self.please_wait_l)
please_wait_layout.addStretch(1)
self.please_wait = QWidget()
self.please_wait.setVisible(False)
self.please_wait.setLayout(please_wait_layout)
error_layout = QVBoxLayout()
error_layout.addStretch(1)
error_icon = QLabel()
error_icon.setPixmap(QPixmap(icon_path('warning.png')).scaledToWidth(48, mode=Qt.TransformationMode.SmoothTransformation))
error_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
error_layout.addWidget(error_icon)
self.error_msg = WWLabel()
self.error_msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
error_layout.addWidget(self.error_msg)
error_layout.addStretch(1)
self.error = QWidget()
self.error.setVisible(False)
self.error.setLayout(error_layout)
outer_vbox = QVBoxLayout(self)
inner_vbox = QVBoxLayout()
inner_vbox.addWidget(self.title)
inner_vbox.addWidget(self.main_widget)
inner_vbox.addWidget(self.please_wait)
inner_vbox.addWidget(self.error)
scroll_widget = QWidget()
scroll_widget.setLayout(inner_vbox)
scroll = QScrollArea()
scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus)
scroll.setWidget(scroll_widget)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.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.setTabOrder(self.back_button, self.next_button)
self.icon_filename = None
self.set_icon('electrum.png')
self.start_viewstate = start_viewstate
self.show()
self.raise_()
QMetaObject.invokeMethod(self, 'strt', Qt.ConnectionType.QueuedConnection) # call strt after subclass constructor(s)
def sizeHint(self) -> QSize:
return QSize(600, 400)
@pyqtSlot()
def strt(self):
viewstate = self.start_wizard(start_viewstate=self.start_viewstate)
self.load_next_component(viewstate.view, viewstate.wizard_data, viewstate.params)
self.set_default_focus()
# TODO: re-test if needed on macOS
self.refresh_gui() # Need for QT on MacOSX. Lame.
def refresh_gui(self):
# For some reason, to refresh the GUI this needs to be called twice
self.app.processEvents()
self.app.processEvents()
def load_next_component(self, view, wdata=None, params=None):
if wdata is None:
wdata = {}
if params is None:
params = {}
comp = self.view_to_component(view)
try:
self._logger.debug(f'load_next_component: {comp!r}')
page = comp(self.main_widget, self)
except Exception as e:
self._logger.error(f'not a class: {comp!r}')
raise e
page.wizard_data = copy.deepcopy(wdata)
page.params = params
page.on_ready() # call before component emits any signals
page.updated.connect(self.on_page_updated)
# add to stack and update wizard
page.apply()
self.main_widget.setCurrentIndex(self.main_widget.addWidget(page))
self.update()
@pyqtSlot(object)
def on_page_updated(self, page):
page.apply()
if page == self.main_widget.currentWidget():
self.update()
def set_icon(self, filename):
prior_filename, self.icon_filename = self.icon_filename, filename
self.logo.setPixmap(QPixmap(icon_path(filename))
.scaledToWidth(60, mode=Qt.TransformationMode.SmoothTransformation))
return prior_filename
def set_default_focus(self):
page = self.main_widget.currentWidget()
control = page.initialFocus()
if control and control.isVisible() and control.isEnabled():
control.setFocus()
else:
self.next_button.setFocus()
def can_go_back(self) -> bool:
return len(self._stack) > 0
def update(self):
page = self.main_widget.currentWidget()
self.setWindowTitle(page.wizard_title if page.wizard_title else self.window_title)
self.title.setText(f'{page.title}' if page.title else '')
self.back_button.setText(_('Back') if self.can_go_back() else _('Cancel'))
self.back_button.setEnabled(not page.busy)
self.next_button.setText(_('Next') if not self.is_last(page.wizard_data) else self.finish_label)
self.next_button.setEnabled(not page.busy and page.valid)
self.main_widget.setVisible(not page.busy and not bool(page.error))
self.please_wait.setVisible(page.busy)
self.please_wait_l.setText(page.busy_msg if page.busy_msg else _("Please wait..."))
self.error_msg.setText(str(page.error))
self.error.setVisible(not page.busy and bool(page.error))
icon = page.params.get('icon', icon_path('electrum.png'))
if icon:
if icon != self.icon_filename:
self.set_icon(icon)
self.logo.setVisible(True)
else:
self.logo.setVisible(False)
def on_back_button_clicked(self):
if self.can_go_back():
self.prev()
widget = self.main_widget.currentWidget()
self.main_widget.removeWidget(widget)
widget.deleteLater()
self.update()
else:
self.close()
def on_next_button_clicked(self):
page = self.main_widget.currentWidget()
page.apply()
wd = page.wizard_data.copy()
if self.is_last(wd):
self.submit(wd)
if self.is_finalized(wd):
self.accept()
else:
self.prev() # rollback the submit above
else:
view = self.submit(wd)
try:
self.load_next_component(view.view, view.wizard_data, view.params)
self.set_default_focus()
except Exception as e:
self.prev() # rollback the submit above
raise e
def start_wizard(self, *, start_viewstate: Optional['WizardViewState'] = None) -> 'WizardViewState':
self.start(start_viewstate=start_viewstate)
return self._current
def view_to_component(self, view) -> QWidget:
return self.navmap[view]['gui']
def submit(self, wizard_data) -> 'WizardViewState':
wdata = wizard_data.copy()
view = self.resolve_next(self._current.view, wdata)
return view
def prev(self) -> dict:
viewstate = self.resolve_prev()
return viewstate.wizard_data
def is_last(self, wizard_data: dict) -> bool:
wdata = wizard_data.copy()
return self.is_last_view(self._current.view, wdata)
def is_finalized(self, wizard_data: dict) -> bool:
''' Final check before closing the wizard. '''
return True
class WizardComponent(AbstractQWidget):
updated = pyqtSignal(object)
def __init__(self, parent: QWidget, wizard: QEAbstractWizard, *, title: str = None, layout: QLayout = None):
super().__init__(parent)
self.setLayout(layout if layout else QVBoxLayout(self))
self.wizard_data = {}
self.title = title if title is not None else 'No title'
self.wizard_title = None
self.busy_msg = ''
self.wizard = wizard
self._error = ''
self._valid = False
self._busy = False
@property
def valid(self):
return self._valid
@valid.setter
def valid(self, is_valid):
if self._valid != is_valid:
self._valid = is_valid
self.on_updated()
@property
def busy(self):
return self._busy
@busy.setter
def busy(self, is_busy):
if self._busy != is_busy:
self._busy = is_busy
self.on_updated()
@property
def error(self):
return self._error
@error.setter
def error(self, error):
if self._error != error:
self._error = error
self.on_updated()
@abstractmethod
def apply(self):
# called to apply UI component values to wizard_data
pass
def on_ready(self):
# called when wizard_data is available
pass
@pyqtSlot()
def on_updated(self, *args):
try:
self.updated.emit(self)
except RuntimeError:
pass
def initialFocus(self) -> Optional[QWidget]:
"""Override to specify a control that should receive initial focus"""
return None
================================================
FILE: electrum/gui/stdio.py
================================================
from decimal import Decimal
import getpass
import datetime
import logging
from typing import Optional
from electrum.gui import BaseElectrumGui
from electrum import util
from electrum import WalletStorage, Wallet
from electrum.wallet import Abstract_Wallet
from electrum.wallet_db import WalletDB
from electrum.util import format_satoshis, EventListener, event_listener
from electrum.bitcoin import is_address, COIN
from electrum.transaction import PartialTxOutput
from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.fee_policy import FixedFeePolicy
_ = lambda x:x # i18n
# minimal fdisk like gui for console usage
# written by rofl0r, with some bits stolen from the text gui (ncurses)
class ElectrumGui(BaseElectrumGui, EventListener):
def __init__(self, *, config, daemon, plugins):
BaseElectrumGui.__init__(self, config=config, daemon=daemon, plugins=plugins)
self.network = daemon.network
storage = WalletStorage(config.get_wallet_path())
password = None
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)
del storage
self.wallet = self.daemon.load_wallet(config.get_wallet_path(), password)
self.contacts = self.wallet.contacts
self.done = 0
self.last_balance = ""
self.str_recipient = ""
self.str_description = ""
self.str_amount = ""
self.str_fee = ""
self.register_callbacks()
self.commands = [_("[h] - displays this help text"), \
_("[i] - display transaction history"), \
_("[o] - enter payment order"), \
_("[p] - print stored payment order"), \
_("[s] - send stored payment order"), \
_("[r] - show own receipt addresses"), \
_("[c] - display contacts"), \
_("[b] - print server banner"), \
_("[q] - quit")]
self.num_commands = len(self.commands)
@event_listener
def on_event_wallet_updated(self, wallet):
self.updated()
@event_listener
def on_event_network_updated(self):
self.updated()
@event_listener
def on_event_banner(self, *args):
self.print_banner()
def main_command(self):
self.print_balance()
c = input("enter command: ")
if c == "h" : self.print_commands()
elif c == "i" : self.print_history()
elif c == "o" : self.enter_order()
elif c == "p" : self.print_order()
elif c == "s" : self.send_order()
elif c == "r" : self.print_addresses()
elif c == "c" : self.print_contacts()
elif c == "b" : self.print_banner()
elif c == "n" : self.network_dialog()
elif c == "e" : self.settings_dialog()
elif c == "q" : self.done = 1
else: self.print_commands()
def updated(self):
s = self.get_balance()
if s != self.last_balance:
print(s)
self.last_balance = s
return True
def print_commands(self):
self.print_list(self.commands, "Available commands")
def print_history(self):
width = [20, 40, 14, 14]
delta = (80 - 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"
messages = []
domain = self.wallet.get_addresses()
for hist_item in reversed(self.wallet.adb.get_history(domain)):
if hist_item.tx_mined_status.conf:
timestamp = hist_item.tx_mined_status.timestamp
try:
time_str = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3]
except Exception:
time_str = "unknown"
else:
time_str = 'unconfirmed'
label = self.wallet.get_label_for_txid(hist_item.txid)
messages.append(format_str % (
time_str, label,
format_satoshis(hist_item.delta, whitespaces=True),
format_satoshis(hist_item.balance, whitespaces=True)))
self.print_list(messages[::-1], format_str%(_("Date"), _("Description"), _("Amount"), _("Balance")))
def print_balance(self):
print(self.get_balance())
def get_balance(self):
network = self.wallet.network
if network and network.is_connected():
if not self.wallet.is_up_to_date():
msg = _("Synchronizing...")
else:
c, u, x = self.wallet.get_balance()
msg = _("Balance")+": {} ".format(Decimal(c) / COIN)
if u:
msg += " [{} unconfirmed]".format(Decimal(u) / COIN)
if x:
msg += " [{} unmatured]".format(Decimal(x) / COIN)
else:
msg = _("Not connected")
return msg
def print_contacts(self):
messages = map(lambda x: "%20s %45s "%(x[0], x[1][1]), self.contacts.items())
self.print_list(messages, "%19s %25s "%("Key", "Value"))
def print_addresses(self):
messages = map(lambda addr: "%30s %30s "%(addr, self.wallet.get_label_for_address(addr)), self.wallet.get_addresses())
self.print_list(messages, "%19s %25s "%("Address", "Label"))
def print_order(self):
print("send order to " + self.str_recipient + ", amount: " + self.str_amount \
+ "\nfee: " + self.str_fee + ", desc: " + self.str_description)
def enter_order(self):
self.str_recipient = input("Pay to: ")
self.str_description = input("Description : ")
self.str_amount = input("Amount: ")
self.str_fee = input("Fee: ")
def send_order(self):
self.do_send()
def print_banner(self):
for i, x in enumerate(self.wallet.network.banner.split('\n')):
print(x)
def print_list(self, lst, firstline):
lst = list(lst)
self.maxpos = len(lst)
if not self.maxpos: return
print(firstline)
for i in range(self.maxpos):
msg = lst[i] if i < len(lst) else ""
print(msg)
def main(self):
self.daemon.start_network()
while self.done == 0:
self.main_command()
def do_send(self):
if not is_address(self.str_recipient):
print(_('Invalid Bitcoin address'))
return
try:
amount = int(Decimal(self.str_amount) * COIN)
except Exception:
print(_('Invalid Amount'))
return
try:
fee = int(Decimal(self.str_fee) * COIN)
except Exception:
print(_('Invalid Fee'))
return
if self.wallet.has_password():
password = self.password_dialog()
if not password:
return
else:
password = None
c = ""
while c != "y":
c = input("ok to send (y/n)?")
if c == "n": return
try:
tx = self.wallet.make_unsigned_transaction(
outputs=[PartialTxOutput.from_address_and_value(self.str_recipient, amount)],
fee_policy=FixedFeePolicy(fee),
)
self.wallet.sign_transaction(tx, password)
except Exception as e:
print(repr(e))
return
if self.str_description:
self.wallet.set_label(tx.txid(), self.str_description)
print(_("Please wait..."))
try:
self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
except TxBroadcastError as e:
msg = e.get_message_for_gui()
print(msg)
except BestEffortRequestFailed as e:
msg = repr(e)
print(msg)
else:
print(_('Payment sent.'))
#self.do_clear()
#self.update_contacts_tab()
def network_dialog(self):
print("use 'electrum setconfig server/proxy' to change your network settings")
return True
def settings_dialog(self):
print("use 'electrum setconfig' to change your settings")
return True
def password_dialog(self):
return getpass.getpass()
# XXX unused
def run_receive_tab(self, c):
#if c == 10:
# out = self.run_popup('Address', ["Edit label", "Freeze", "Prioritize"])
return
def run_contacts_tab(self, c):
pass
================================================
FILE: electrum/gui/text.py
================================================
import tty
import sys
import curses
import datetime
import locale
from decimal import Decimal
import getpass
from typing import TYPE_CHECKING, Optional
# 3rd-party dependency:
try:
import pyperclip
except ImportError: # only use vendored lib as fallback, to allow Linux distros to bring their own
from electrum._vendor import pyperclip
from electrum.gui import BaseElectrumGui
from electrum.bip21 import parse_bip21_URI
from electrum.util import format_time
from electrum.util import EventListener, event_listener
from electrum.bitcoin import is_address, address_to_script
from electrum.transaction import PartialTxOutput
from electrum.wallet import Wallet, Abstract_Wallet
from electrum.wallet_db import WalletDB
from electrum.storage import WalletStorage
from electrum.network import NetworkParameters, TxBroadcastError, BestEffortRequestFailed, ProxySettings
from electrum.interface import ServerAddr
from electrum.invoices import Invoice
from electrum.fee_policy import FeePolicy
if TYPE_CHECKING:
from electrum.daemon import Daemon
from electrum.simple_config import SimpleConfig
from electrum.plugin import Plugins
_ = lambda x:x # i18n
# ascii key codes
KEY_BACKSPACE = 8
KEY_ESC = 27
KEY_DELETE = 127
def parse_bip21(text):
try:
return parse_bip21_URI(text)
except Exception:
return
def parse_bolt11(text):
from electrum.lnaddr import lndecode
try:
return lndecode(text)
except Exception:
return
class ElectrumGui(BaseElectrumGui, EventListener):
def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
BaseElectrumGui.__init__(self, config=config, daemon=daemon, plugins=plugins)
self.network = daemon.network
storage = WalletStorage(config.get_wallet_path())
password = None
if not storage.file_exists():
print("Wallet not found. try 'electrum create'")
exit()
if storage.is_encrypted():
password = getpass.getpass('Password:', stream=None)
del storage
self.wallet = self.daemon.load_wallet(config.get_wallet_path(), password)
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)
curses.halfdelay(1)
self.stdscr.keypad(True)
self.stdscr.border(0)
self.maxy, self.maxx = self.stdscr.getmaxyx()
self.set_cursor(0)
self.w = curses.newwin(10, 50, 5, 5)
self.lightning_invoice = None
self.tab = 0
self.pos = 0
self.popup_pos = 0
self.str_recipient = ""
self.str_description = ""
self.str_amount = ""
self.history = None
self.txid = []
self.str_recv_description = ""
self.str_recv_amount = ""
self.str_recv_expiry = ""
self.channel_ids = []
self.requests = []
self.register_callbacks()
self.tab_names = [_("History"), _("Send"), _("Receive"), _("Addresses"), _("Coins"), _("Channels"), _("Contacts"), _("Banner")]
self.num_tabs = len(self.tab_names)
self.need_update = False
def stop(self):
self.tab = -1
@event_listener
def on_event_wallet_updated(self, wallet):
self.need_update = True
@event_listener
def on_event_network_updated(self):
self.need_update = True
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) -> str:
self.set_cursor(1)
curses.echo()
self.stdscr.addstr(y, x, " "*20, curses.A_REVERSE)
s = self.stdscr.getstr(y,x).decode()
curses.noecho()
self.set_cursor(0)
return s
def update(self):
self.update_history()
if self.tab == 0:
self.print_history()
self.refresh()
self.need_update = False
def print_button(self, x, y, text, pos):
self.stdscr.addstr(x, y, text, curses.A_REVERSE if self.pos%self.max_pos==pos else curses.color_pair(2))
def print_edit_line(self, y, x, label, text, index, size):
text += " "*(size - len(text))
self.stdscr.addstr(y, x, label)
self.stdscr.addstr(y, x + 13, text, curses.A_REVERSE if self.pos%self.max_pos==index else curses.color_pair(1))
def print_history(self):
x = 2
self.history_format_str = self.format_column_width(x, [-20, '*', 15, 15])
if self.history is None:
self.update_history()
self.print_list(2, x, self.history[::-1], headers=self.history_format_str%(_("Date"), _("Description"), _("Amount"), _("Balance")))
def update_history(self):
width = [20, 40, 14, 14]
delta = (self.maxx - sum(width) - 4)/3
domain = self.wallet.get_addresses()
self.history = []
self.txid = []
balance_sat = 0
for item in self.wallet.get_full_history().values():
amount_sat = item['value'].value
balance_sat += amount_sat
if item.get('lightning'):
timestamp = item['timestamp']
label = self.wallet.get_label_for_rhash(item['payment_hash'])
self.txid.insert(0, item['payment_hash'])
else:
conf = item['confirmations']
timestamp = item['timestamp'] if conf > 0 else 0
label = self.wallet.get_label_for_txid(item['txid'])
self.txid.insert(0, item['txid'])
if timestamp:
time_str = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3]
else:
time_str = 'unconfirmed'
if len(label) > 40:
label = label[0:37] + '...'
self.history.append(self.history_format_str % (
time_str, label,
self.config.format_amount(amount_sat, whitespaces=True),
self.config.format_amount(balance_sat, whitespaces=True)))
def print_clipboard(self):
return
c = pyperclip.paste()
if c:
if len(c) > 20:
c = c[0:20] + '...'
self.stdscr.addstr(self.maxy -1, self.maxx // 3, ' ' + _('Clipboard') + ': ' + c + ' ')
def print_balance(self):
if not self.network:
msg = _("Offline")
elif self.network.is_connected():
if not self.wallet.is_up_to_date():
msg = _("Synchronizing...")
else:
balance = self.wallet.get_balances_for_piechart().total()
msg = _("Balance") + ': ' + self.config.format_amount_and_units(balance)
else:
msg = _("Not connected")
msg = ' ' + msg + ' '
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_REVERSE if self.tab == i else 0)
self.stdscr.addstr(self.maxy -1, self.maxx-30, ' ' + ' '.join([_("Settings"), _("Network"), _("Quit")]) + ' ')
def print_receive_tab(self):
self.stdscr.clear()
self.buttons = {}
self.max_pos = 6 + len(list(self.wallet.get_unpaid_requests()))
self.index = 0
self.add_edit_line(3, 2, _("Description"), self.str_recv_description, 40)
self.add_edit_line(5, 2, _("Amount"), self.str_recv_amount, 15)
self.stdscr.addstr(5, 31, self.config.get_base_unit())
self.add_edit_line(7, 2, _("Expiry"), self.str_recv_expiry, 15)
self.add_button(9, 15, _("[Clear]"), self.do_clear_request)
self.add_button(9, 25, _("[Onchain]"), lambda: self.do_create_request(lightning=False))
self.add_button(9, 35, _("[Lightning]"), lambda: self.do_create_request(lightning=True))
self.print_requests_list(13, 2, offset_pos=6)
return
def run_receive_tab(self, c):
if self.pos == 0:
self.str_recv_description = self.edit_str(self.str_recv_description, c)
elif self.pos == 1:
self.str_recv_amount = self.edit_str(self.str_recv_amount, c)
elif self.pos in self.buttons and c == ord("\n"):
self.buttons[self.pos]()
elif self.pos >= 6 and c == ord("\n"):
key = self.requests[self.pos - 6]
self.show_request(key)
def question(self, msg):
out = self.run_popup(msg, ["No", "Yes"]).get('button')
return out == "Yes"
def show_invoice_menu(self):
key = self.invoices[self.pos - 7]
invoice = self.wallet.get_invoice(key)
out = self.run_popup('Invoice', ["Pay", "Delete"]).get('button')
if out == "Pay":
self.do_pay_invoice(invoice)
elif out == "Delete":
self.wallet.delete_invoice(key)
self.max_pos -= 1
def format_column_width(self, offset, width):
delta = self.maxx -2 -offset - sum([abs(x) for x in width if x != '*'])
fmt = ''
for w in width:
if w == '*':
fmt += "%-" + "%d"%delta + "s"
else:
fmt += "%" + "%d"%w + "s"
return fmt
def print_invoices_list(self, y, x, offset_pos):
messages = []
invoices = []
fmt = self.format_column_width(x, [-20, '*', 15, 25])
headers = fmt % ("Date", "Description", "Amount", "Status")
for req in self.wallet.get_unpaid_invoices():
key = req.get_id()
status = self.wallet.get_invoice_status(req)
status_str = req.get_status_str(status)
timestamp = req.get_time()
date = format_time(timestamp)
amount = req.get_amount_sat()
message = req.get_message()
amount_str = self.config.format_amount(amount) if amount else ""
labels = []
messages.append(fmt % (date, message, amount_str, status_str))
invoices.append(key)
self.invoices = invoices
self.print_list(y, x, messages, headers=headers, offset_pos=offset_pos)
def print_requests_list(self, y, x, offset_pos):
messages = []
requests = []
fmt = self.format_column_width(x, [-20, '*', 15, 25])
headers = fmt % ("Date", "Description", "Amount", "Status")
for req in self.wallet.get_unpaid_requests():
key = req.get_id()
status = self.wallet.get_invoice_status(req)
status_str = req.get_status_str(status)
timestamp = req.get_time()
date = format_time(timestamp)
amount = req.get_amount_sat()
message = req.get_message()
amount_str = self.config.format_amount(amount) if amount else ""
labels = []
messages.append(fmt % (date, message, amount_str, status_str))
requests.append(key)
self.requests = requests
self.print_list(y, x, messages, headers=headers, offset_pos=offset_pos)
def print_contacts(self):
messages = list(map(lambda x: "%20s %45s "%(x[0], x[1][1]), self.contacts.items()))
self.print_list(2, 1, messages, "%19s %15s "%("Key", "Value"))
def print_addresses(self):
x = 2
fmt = self.format_column_width(x, [-50, '*', 15])
messages = [ fmt % (
addr,
self.wallet.get_label_for_address(addr),
self.config.format_amount(sum(self.wallet.get_addr_balance(addr)), whitespaces=True)
) for addr in self.wallet.get_addresses() ]
self.print_list(2, x, messages, fmt % ("Address", "Description", "Balance"))
def print_utxos(self):
x = 2
fmt = self.format_column_width(x, [-70, '*', 15])
utxos = self.wallet.get_utxos()
messages = [ fmt % (
utxo.prevout.to_str(),
self.wallet.get_label_for_txid(utxo.prevout.txid.hex()),
self.config.format_amount(utxo.value_sats(), whitespaces=True)
) for utxo in utxos]
self.print_list(2, x, sorted(messages), fmt % ("Outpoint", "Description", "Balance"))
def print_channels(self):
if not self.wallet.lnworker:
return
fmt = "%-35s %-10s %-30s"
channels = self.wallet.lnworker.get_channel_objects()
messages = []
channel_ids = []
for chan in channels.values():
channel_ids.append(chan.short_id_for_GUI())
messages.append(fmt % (chan.short_id_for_GUI(), self.config.format_amount(chan.get_capacity()), chan.get_state().name))
self.channel_ids = channel_ids
self.print_list(2, 1, messages, fmt % ("Scid", "Capacity", "State"))
def print_send_tab(self):
self.stdscr.clear()
self.buttons = {}
self.max_pos = 7 + len(list(self.wallet.get_unpaid_invoices()))
self.index = 0
self.add_edit_line(3, 2, _("Pay to"), self.str_recipient, 40)
self.add_edit_line(5, 2, _("Description"), self.str_description, 40)
self.add_edit_line(7, 2, _("Amount"), self.str_amount, 15)
self.stdscr.addstr(7, 31, self.config.get_base_unit())
self.add_button(9, 15, _("[Paste]"), self.do_paste)
self.add_button(9, 25, _("[Clear]"), self.do_clear)
self.add_button(9, 35, _("[Save]"), self.do_save_invoice)
self.add_button(9, 44, _("[Pay]"), self.do_pay)
#
self.print_invoices_list(13, 2, offset_pos=7)
def add_edit_line(self, y, x, title, data, length):
self.print_edit_line(y, x, title, data, self.index, length)
self.index += 1
def add_button(self, y, x, title, action):
self.print_button(y, x, title, self.index)
self.buttons[self.index] = action
self.index += 1
def print_banner(self):
if self.network and self.network.banner:
banner = self.network.banner
banner = banner.replace('\r', '')
self.print_list(2, 1, banner.split('\n'))
def get_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')
return lines
def print_qr(self, w, y, x, lines):
try:
for i, l in enumerate(lines):
l = l.encode("utf-8")
w.addstr(y + i, x, l, curses.color_pair(3))
except curses.error:
m = 'error. screen too small?'
m = m.encode(self.encoding)
w.addstr(y, x, m, 0)
def print_list(self, y, x, lst, headers=None, offset_pos=0):
self.list_length = len(lst)
if not self.list_length:
return
if headers:
headers += " "*(self.maxx -2 - len(headers))
self.stdscr.addstr(y, x, headers, curses.A_BOLD)
for i in range(self.maxy - 2 - y):
msg = lst[i] if i < self.list_length else ""
msg += " "*(self.maxx - 2 - len(msg))
m = msg[0:self.maxx - 2]
m = m.encode(self.encoding)
selected = self.pos >= offset_pos and (i == ((self.pos - offset_pos) % self.list_length))
self.stdscr.addstr(i+y+1, x, m, curses.A_REVERSE if selected else 0)
self.max_pos = self.list_length + offset_pos
def refresh(self):
if self.tab == -1:
return
self.stdscr.border(0)
self.print_balance()
self.print_clipboard()
self.stdscr.refresh()
def increase_cursor(self, delta):
self.pos += delta
self.pos = max(0, self.pos)
self.pos = min(self.pos, self.max_pos - 1)
def getch(self, redraw=False):
while True:
c = self.stdscr.getch()
if c != -1:
return c
if self.need_update and redraw:
self.update()
if self.tab == -1:
return KEY_ESC
def main_command(self):
c = self.getch(redraw=True)
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 in [curses.KEY_DOWN, ord("\t")]:
self.increase_cursor(1)
elif c == curses.KEY_UP:
self.increase_cursor(-1)
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
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):
# Get txid from cursor position
if c == ord("\n"):
out = self.run_popup('', ['Transaction ID:', self.txid[self.pos]])
def edit_str(self, target, c, is_num=False):
if target is None:
target = ''
# detect backspace
cc = curses.unctrl(c).decode()
if c in [KEY_BACKSPACE, KEY_DELETE, curses.KEY_BACKSPACE] and target:
target = target[:-1]
elif not is_num or cc in '0123456789.':
target += cc
return target
def run_send_tab(self, c):
self.pos = self.pos % self.max_pos
if self.pos == 0:
self.str_recipient = self.edit_str(self.str_recipient, c)
elif self.pos == 1:
self.str_description = self.edit_str(self.str_description, c)
elif self.pos == 2:
self.str_amount = self.edit_str(self.str_amount, c, True)
elif self.pos in self.buttons and c == ord("\n"):
self.buttons[self.pos]()
elif self.pos >= 7 and c == ord("\n"):
self.show_invoice_menu()
def run_contacts_tab(self, c):
if c == ord("\n") 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.contacts[key] = ('address', s)
elif out == "Delete":
self.contacts.pop(key)
self.pos = 0
def run_addresses_tab(self, c):
pass
def run_utxos_tab(self, c):
pass
def run_channels_tab(self, c):
if c == ord("\n") and self.channel_ids:
out = self.run_popup('Channel Details', ['Short channel ID:', self.channel_ids[self.pos]])
def run_banner_tab(self, c):
self.show_message(repr(c))
pass
def main(self):
self.daemon.start_network()
tty.setraw(sys.stdin)
try:
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_tab, self.run_receive_tab)
self.run_tab(3, self.print_addresses, self.run_addresses_tab)
self.run_tab(4, self.print_utxos, self.run_utxos_tab)
self.run_tab(5, self.print_channels, self.run_channels_tab)
self.run_tab(6, self.print_contacts, self.run_contacts_tab)
self.run_tab(7, self.print_banner, self.run_banner_tab)
except curses.error as e:
raise Exception("Error with curses. Is your screen too small?") from e
finally:
tty.setcbreak(sys.stdin)
curses.nocbreak()
self.stdscr.keypad(False)
curses.echo()
curses.endwin()
def do_clear(self):
self.str_amount = ''
self.str_recipient = ''
self.str_fee = ''
self.str_description = ''
def do_create_request(self, lightning: bool):
amount_sat = self.parse_amount(self.str_recv_amount) or 0
if not lightning:
if amount_sat and amount_sat < self.wallet.dust_threshold():
self.show_message(_('Amount too low'))
return
address = self.wallet.get_unused_address()
if not address:
self.show_message(_('No more unused address'))
return
else:
if not self.wallet.has_lightning():
self.show_message(_('Lightning is disabled on this wallet'))
return
address = None
message = self.str_recv_description
expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS
key = self.wallet.create_request(amount_sat, message, expiry, address)
self.do_clear_request()
self.pos = self.max_pos
self.show_request(key)
def do_clear_request(self):
self.str_recv_amount = ""
self.str_recv_description = ""
def do_paste(self):
text = pyperclip.paste()
text = text.strip()
if not text:
return
if is_address(text):
self.str_recipient = text
self.lightning_invoice = None
elif out := parse_bip21(text):
amount_sat = out.get('amount')
self.str_amount = self.config.format_amount(amount_sat) if amount_sat is not None else ''
self.str_recipient = out.get('address') or ''
self.str_description = out.get('message') or ''
self.lightning_invoice = None
elif lnaddr := parse_bolt11(text):
amount_sat = lnaddr.get_amount_sat()
self.str_recipient = lnaddr.pubkey.serialize().hex()
self.str_description = lnaddr.get_description()
self.str_amount = self.config.format_amount(amount_sat) if amount_sat is not None else ''
self.lightning_invoice = text
else:
self.show_message(_('Could not parse clipboard text') + '\n\n' + text[0:20] + '...')
def parse_amount(self, text):
try:
x = Decimal(text)
except Exception:
return None
power = pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT)
return int(power * x)
def read_invoice(self):
if self.lightning_invoice:
invoice = Invoice.from_bech32(self.lightning_invoice)
if invoice.amount_msat is None:
amount_sat = self.parse_amount(self.str_amount)
if amount_sat:
invoice.set_amount_msat(int(amount_sat * 1000))
else:
self.show_message(_('No amount'))
return None
elif is_address(self.str_recipient):
amount_sat = self.parse_amount(self.str_amount)
if not amount_sat:
self.show_message(_('No amount'))
return None
scriptpubkey = address_to_script(self.str_recipient)
outputs = [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount_sat)]
invoice = self.wallet.create_invoice(
outputs=outputs,
message=self.str_description,
pr=None,
URI=None)
else:
self.show_message(_('Invalid Bitcoin address'))
return None
return invoice
def do_save_invoice(self):
invoice = self.read_invoice()
if not invoice:
return
self.save_pending_invoice(invoice)
def save_pending_invoice(self, invoice):
self.do_clear()
self.wallet.save_invoice(invoice)
self.pending_invoice = None
def do_pay(self):
invoice = self.read_invoice()
if not invoice:
return
self.do_pay_invoice(invoice)
def do_pay_invoice(self, invoice):
if invoice.is_lightning():
self.pay_lightning_invoice(invoice)
else:
self.pay_onchain_dialog(invoice)
def pay_lightning_invoice(self, invoice):
amount_msat = invoice.get_amount_msat()
msg = _("Pay lightning invoice?")
#+ '\n\n' + _("This will send {}?").format(self.format_amount_and_units(Decimal(amount_msat)/1000))
if not self.question(msg):
return
self.save_pending_invoice(invoice)
coro = self.wallet.lnworker.pay_invoice(invoice, amount_msat=amount_msat)
#self.window.run_coroutine_from_thread(coro, _('Sending payment'))
self.show_message(_("Please wait..."), getchar=False)
try:
self.network.run_from_another_thread(coro)
except Exception as e:
self.show_message(str(e))
else:
self.show_message(_('Payment sent.'))
def pay_onchain_dialog(self, invoice):
if self.wallet.has_password():
password = self.password_dialog()
if not password:
return
else:
password = None
fee_policy = FeePolicy(self.config.FEE_POLICY)
try:
tx = self.wallet.make_unsigned_transaction(
outputs=invoice.outputs,
fee_policy=fee_policy,
)
self.wallet.sign_transaction(tx, password)
except Exception as e:
self.show_message(repr(e))
return
if self.str_description:
self.wallet.set_label(tx.txid(), self.str_description)
self.save_pending_invoice(invoice)
self.show_message(_("Please wait..."), getchar=False)
try:
self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
except TxBroadcastError as e:
msg = e.get_message_for_gui()
self.show_message(msg)
except BestEffortRequestFailed as e:
msg = repr(e)
self.show_message(msg)
else:
self.show_message(_('Payment sent.'))
self.do_clear()
#self.update_contacts_tab()
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.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
net_params = self.network.get_parameters()
server_addr = net_params.server
proxy_config, auto_connect = net_params.proxy, net_params.auto_connect
srv = 'auto-connect' if auto_connect else str(self.network.default_server)
out = self.run_dialog('Network', [
{'label': 'server', 'type': 'str', 'value': srv},
{'label': 'proxy', 'type': 'str', 'value': self.config.NETWORK_PROXY},
{'label': 'proxy user', 'type': 'str', 'value': self.config.NETWORK_PROXY_USER},
{'label': 'proxy pass', 'type': 'str', 'value': self.config.NETWORK_PROXY_PASSWORD},
], buttons=1)
if out:
self.show_message(repr(proxy_config))
if out.get('server'):
server_str = out.get('server')
auto_connect = server_str == 'auto-connect'
if not auto_connect:
try:
server_addr = ServerAddr.from_str(server_str)
except Exception:
self.show_message("Error:" + server_str + "\nIn doubt, type \"auto-connect\"")
return False
if out.get('server') or out.get('proxy') or out.get('proxy user') or out.get('proxy pass'):
if out.get('proxy'):
new_proxy_config = ProxySettings()
new_proxy_config.deserialize_proxy_cfgstr(out.get('proxy'))
new_proxy_config.user = out.get('proxy user', proxy_config.user)
new_proxy_config.password = out.get('proxy pass', proxy_config.password)
new_proxy_config.enabled = True
else:
new_proxy_config = proxy_config
net_params = NetworkParameters(
server=server_addr,
proxy=new_proxy_config,
auto_connect=auto_connect)
self.network.run_from_another_thread(self.network.set_parameters(net_params))
def settings_dialog(self):
from electrum.fee_policy import FeePolicy
out = self.run_dialog('Settings', [
{'label':'Fee policy', 'type':'str', 'value': self.config.FEE_POLICY}
], buttons = 1)
if out:
if descr := out.get('Fee policy'):
fee_policy = FeePolicy(descr)
self.config.FEE_POLICY = fee_policy.get_descriptor()
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), 68, 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.getch()
if c in [ord('q'), KEY_ESC]:
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 == ord("\n"):
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
def print_textbox(self, w, y, x, _text, highlighted):
width = 60
for i in range(len(_text)//width + 1):
s = _text[i*width:(i+1)*width]
w.addstr(y+i, x, s, curses.A_REVERSE if highlighted else curses.A_NORMAL)
return i
def show_request(self, key):
req = self.wallet.get_request(key)
addr = req.get_address() or ''
URI = self.wallet.get_request_URI(req) or ''
lnaddr = self.wallet.get_bolt11_invoice(req) or ''
w = curses.newwin(self.maxy - 2, self.maxx - 2, 1, 1)
pos = 2
text = URI or addr or lnaddr
data = URI or addr or lnaddr.upper()
while True:
w.clear()
w.border(0)
w.addstr(0, 2, ' ' + _('Payment Request') + ' ')
y = 2
if URI:
w.addstr(y, 2, "URI")
h = self.print_textbox(w, y, 13, URI, False)
elif addr:
w.addstr(y, 2, "Address")
h = self.print_textbox(w, y, 13, addr, False)
elif lnaddr:
w.addstr(y, 2, "Lightning")
h = self.print_textbox(w, y, 13, lnaddr, False)
else:
return
y += h + 2
lines = self.get_qr(data)
qr_width = len(lines) * 2
x = self.maxx - qr_width
if x > 60:
self.print_qr(w, 1, x, lines)
else:
w.addstr(y, 35, "(Window too small for QR code)")
w.addstr(y, 13, "[Copy]", curses.A_REVERSE if pos==0 else curses.color_pair(2))
w.addstr(y, 23, "[Delete]", curses.A_REVERSE if pos==1 else curses.color_pair(2))
w.addstr(y, 35, "[Close]", curses.A_REVERSE if pos==2 else curses.color_pair(2))
w.refresh()
c = self.getch()
if c in [curses.KEY_UP, curses.KEY_LEFT]:
pos -= 1
elif c in [curses.KEY_DOWN, curses.KEY_RIGHT, ord("\t")]:
pos += 1
elif c == ord("\n"):
if pos == 0:
pyperclip.copy(text)
self.show_message('Text copied to clipboard')
elif pos == 1:
if self.question("Delete Request?"):
self.wallet.delete_request(key)
self.max_pos -= 1
break
elif pos == 2:
break
else:
break
pos = pos % 3
self.stdscr.refresh()
return
================================================
FILE: electrum/harden_memory_linux.py
================================================
# Copyright (C) 2020 cptpcrd
# Copyright (C) 2025 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
#
# based on https://github.com/cptpcrd/pyprctl/blob/578ed3e81066a8a61dede912454d5eeaef37eeea/pyprctl/ffi.py#L28
#
# This module tries to restrict the ability of other processes to access the memory of our process.
# Traditionally, on Linux, one process can access the memory of another arbitrary process
# if both are running as the same user (uid). (Root can ofc access the memory of ~any process)
# Programs can opt-out from this by setting prctl(PR_SET_DUMPABLE, 0);
#
# Besides PR_SET_DUMPABLE, there are ways to globally restrict this for all processes:
# 1. The Yama (Linux Security Module) ptrace scope can be used to reduce these permissions
# This runtime kernel parameter can be set to the following options:
# 0 - Default attach security permissions.
# 1 - Restricted attach. Only child processes plus normal permissions.
# 2 - Admin-only attach. Only executables with CAP_SYS_PTRACE.
# 3 - No attach. No process may call ptrace at all. Irrevocable.
# # Note: The default value of kernel.yama.ptrace_scope is distro-specific.
# # See `$ cat /proc/sys/kernel/yama/ptrace_scope`.
# # - ubuntu 22.04 sets it to 1 (see /etc/sysctl.d/10-ptrace.conf),
# # - debian 12 sets it to 0
# # - manjaro sets it to 1
# 2. SELinux: ptrace can be restricted by setting the selinux deny_ptrace boolean.
#
# For a quick test on your system, try:
# $ cat /proc/$$/mem > /dev/null
# cat: /proc/4907/mem: Permission denied
# Getting "Permission denied" means access failed, "Input/output error" means access succeeded.
import ctypes
import ctypes.util
import os
import sys
from typing import Optional
from .logging import get_logger
_logger = get_logger(__name__)
PR_GET_DUMPABLE = 3
PR_SET_DUMPABLE = 4
_libc = None # type: Optional[ctypes.CDLL]
def _load_libc():
global _libc
if _libc is not None:
return
#assert sys.platform == "linux", sys.platform
# note: find_library can raise FileNotFoundError(OSError), see https://github.com/python/cpython/issues/93094
_libc_path = ctypes.util.find_library("c")
_libc = ctypes.CDLL(_libc_path, use_errno=True)
_libc.prctl.argtypes = (ctypes.c_int, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong)
_libc.prctl.restype = ctypes.c_int
def set_dumpable(flag: bool) -> None:
"""Set the "dumpable" attribute on the current process.
This controls whether a core dump will be produced if the process receives a signal whose
default behavior is to produce a core dump.
In addition, processes that are not dumpable cannot be attached with ptrace() PTRACE_ATTACH.
In effect, another process running as the same user as us can read our memory if we are dumpable.
"""
_load_libc()
res = _libc.prctl(PR_SET_DUMPABLE, int(bool(flag)), 0, 0, 0)
if res < 0:
eno = ctypes.get_errno()
raise OSError(eno, os.strerror(eno), None, None, None)
def set_dumpable_safe(flag: bool) -> None:
try:
_load_libc()
except Exception as e:
_logger.exception("error loading libc")
return
assert _libc is not None
try:
set_dumpable(flag)
except OSError as e:
_logger.error(f"libc.prctl(PR_SET_DUMPABLE, {flag}) errored: {e}")
def get_dumpable() -> bool:
_load_libc()
res = _libc.prctl(PR_GET_DUMPABLE, 0, 0, 0, 0)
if res < 0:
eno = ctypes.get_errno()
raise OSError(eno, os.strerror(eno), None, None, None)
return res != 0
================================================
FILE: electrum/hw_wallet/__init__.py
================================================
from .plugin import HW_PluginBase, HardwareClientBase, HardwareHandlerBase
from .cmdline import CmdLineHandler
================================================
FILE: electrum/hw_wallet/cmdline.py
================================================
#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2025 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 electrum.util import print_stderr, raw_input
from electrum.logging import get_logger
from .plugin import HardwareHandlerBase
_logger = get_logger(__name__)
class CmdLineHandler(HardwareHandlerBase):
def get_passphrase(self, msg, confirm):
import getpass
print_stderr(msg)
return getpass.getpass('')
def get_pin(self, msg, *, show_strength=True):
t = {'a': '7', 'b': '8', 'c': '9', 'd': '4', 'e': '5', 'f': '6', 'g': '1', 'h': '2', 'i': '3'}
t.update({str(i): str(i) for i in range(1, 10)}) # sneakily also support numpad-conversion
print_stderr(msg)
print_stderr("a b c\nd e f\ng h i\n-----")
o = raw_input()
try:
return ''.join(map(lambda x: t[x], o))
except KeyError as e:
raise Exception("Character {} not in matrix!".format(e)) from e
def prompt_auth(self, msg):
import getpass
print_stderr(msg)
response = getpass.getpass('')
if len(response) == 0:
return None
return response
def yes_no_question(self, msg):
print_stderr(msg)
return raw_input() in 'yY'
def stop(self):
pass
def show_message(self, msg, on_cancel=None):
print_stderr(msg)
def show_error(self, msg, blocking=False):
print_stderr(msg)
def update_status(self, b):
_logger.info(f'hw device status {b}')
def finished(self):
pass
================================================
FILE: electrum/hw_wallet/plugin.py
================================================
#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2025 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 abc import abstractmethod, ABC
from typing import TYPE_CHECKING, Sequence, Optional, Type, Iterable, Any
from electrum.plugin import (BasePlugin, hook, Device, DeviceMgr,
assert_runs_in_hwd_thread, runs_in_hwd_thread)
from electrum.i18n import _
from electrum.bitcoin import is_address, opcodes
from electrum.util import versiontuple, UserFacingException, ChoiceItem
from electrum.transaction import TxOutput, PartialTransaction
from electrum.bip32 import BIP32Node
from electrum.storage import get_derivation_used_for_hw_device_encryption
from electrum.keystore import Xpub, Hardware_KeyStore
if TYPE_CHECKING:
import threading
from electrum.plugin import DeviceInfo
from electrum.wallet import Abstract_Wallet
from electrum.wizard import AbstractWizard
class HW_PluginBase(BasePlugin, ABC):
keystore_class: Type['Hardware_KeyStore']
libraries_available: bool
SUPPORTED_XTYPES = ()
# define supported library versions: minimum_library <= x < maximum_library
minimum_library = (0,)
maximum_library = (float('inf'),)
DEVICE_IDS: Iterable[Any]
def __init__(self, parent, config, name):
BasePlugin.__init__(self, parent, config, name)
self.device = self.keystore_class.device
self.keystore_class.plugin = self
self._ignore_outdated_fw = False
def is_enabled(self):
return True
def device_manager(self) -> 'DeviceMgr':
return self.parent.device_manager
def create_device_from_hid_enumeration(self, d: dict, *, product_key) -> Optional['Device']:
# note: id_ needs to be unique between simultaneously connected devices,
# and ideally unchanged while a device is connected.
# Older versions of hid don't provide interface_number
interface_number = d.get('interface_number', -1)
usage_page = d['usage_page']
# id_=str(d['path']) in itself might be sufficient, but this had to be touched
# a number of times already, so let's just go for the overkill approach:
id_ = f"{d['path']},{d['serial_number']},{interface_number},{usage_page}"
device = Device(path=d['path'],
interface_number=interface_number,
id_=id_,
product_key=product_key,
usage_page=usage_page,
transport_ui_string='hid')
return device
@hook
def close_wallet(self, wallet: 'Abstract_Wallet'):
for keystore in wallet.get_keystores():
if isinstance(keystore, self.keystore_class):
self.device_manager().unpair_pairing_code(keystore.pairing_code())
if keystore.thread:
keystore.thread.stop()
def get_client(self, keystore: 'Hardware_KeyStore', force_pair: bool = True, *,
devices: Sequence['Device'] = None,
allow_user_interaction: bool = True) -> Optional['HardwareClientBase']:
devmgr = self.device_manager()
handler = keystore.handler
client = devmgr.client_for_keystore(self, handler, keystore, force_pair,
devices=devices,
allow_user_interaction=allow_user_interaction)
return client
def show_address(self, wallet: 'Abstract_Wallet', address, keystore: 'Hardware_KeyStore' = None):
pass # implemented in child classes
def show_address_helper(self, wallet, address, keystore=None):
if keystore is None:
keystore = wallet.get_keystore()
if not is_address(address):
keystore.handler.show_error(_('Invalid Bitcoin Address'))
return False
if not wallet.is_mine(address):
keystore.handler.show_error(_('Address not in wallet.'))
return False
if type(keystore) != self.keystore_class:
return False
return True
def get_library_version(self) -> str:
"""Returns the version of the 3rd party python library
for the hw wallet. For example '0.9.0'
Returns 'unknown' if library is found but cannot determine version.
Raises 'ImportError' if library is not found.
Raises 'LibraryFoundButUnusable' if found but there was some problem (includes version num).
"""
raise NotImplementedError()
def check_libraries_available(self) -> bool:
def version_str(t):
return ".".join(str(i) for i in t)
try:
# this might raise ImportError or LibraryFoundButUnusable
library_version = self.get_library_version()
# if no exception so far, we might still raise LibraryFoundButUnusable
if (library_version == 'unknown'
or versiontuple(library_version) < self.minimum_library
or versiontuple(library_version) >= self.maximum_library):
raise LibraryFoundButUnusable(library_version=library_version)
except ImportError as e:
self.libraries_available_message = (
_("Missing libraries for {}.").format(self.name)
+ f"\n {e!r}"
)
return False
except LibraryFoundButUnusable as e:
library_version = e.library_version
self.libraries_available_message = (
_("Library version for '{}' is incompatible.").format(self.name)
+ '\nInstalled: {}, Needed: {} <= x < {}'
.format(library_version, version_str(self.minimum_library), version_str(self.maximum_library)))
self.logger.warning(self.libraries_available_message)
return False
return True
def get_library_not_available_message(self) -> str:
if hasattr(self, 'libraries_available_message'):
message = self.libraries_available_message
else:
message = _("Missing libraries for {}.").format(self.name)
message += '\n' + _("Make sure you install it with python3")
return message
def set_ignore_outdated_fw(self):
self._ignore_outdated_fw = True
def is_outdated_fw_ignored(self) -> bool:
return self._ignore_outdated_fw
def create_client(self, device: 'Device',
handler: Optional['HardwareHandlerBase']) -> Optional['HardwareClientBase']:
raise NotImplementedError()
def create_handler(self, window) -> 'HardwareHandlerBase':
# note: in Qt GUI, 'window' is either an ElectrumWindow or an QENewWalletWizard
raise NotImplementedError()
def can_recognize_device(self, device: Device) -> bool:
"""Whether the plugin thinks it can handle the given device.
Used for filtering all connected hardware devices to only those by this vendor.
"""
return device.product_key in self.DEVICE_IDS
@abstractmethod
def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet: bool) -> str:
"""Return view name for device
"""
pass
@hook
def init_wallet_wizard(self, wizard: 'AbstractWizard') -> None:
self.extend_wizard(wizard)
@abstractmethod
def extend_wizard(self, wizard: 'AbstractWizard') -> None:
pass
class HardwareClientBase(ABC):
handler = None # type: Optional['HardwareHandlerBase']
def __init__(self, *, plugin: 'HW_PluginBase'):
assert_runs_in_hwd_thread()
self.plugin = plugin
def device_manager(self) -> 'DeviceMgr':
return self.plugin.device_manager()
@abstractmethod
def is_pairable(self) -> bool:
pass
@abstractmethod
def close(self):
pass
def timeout(self, cutoff) -> None: # noqa: B027
pass
@abstractmethod
def is_initialized(self) -> bool:
"""True if initialized, False if wiped."""
pass
def label(self) -> Optional[str]:
"""The name given by the user to the device.
Note: labels are shown to the user to help distinguish their devices,
and they are also used as a fallback to distinguish devices programmatically.
So ideally, different devices would have different labels.
"""
# When returning a constant here (i.e. not implementing the method in the way
# it is supposed to work), make sure the return value is in electrum.plugin.PLACEHOLDER_HW_CLIENT_LABELS
return " "
def get_soft_device_id(self) -> Optional[str]:
"""An id-like string that is used to distinguish devices programmatically.
This is a long term id for the device, that does not change between reconnects.
This method should not prompt the user, i.e. no user interaction, as it is used
during USB device enumeration (called for each unpaired device).
Stored in the wallet file.
"""
root_fp = self.request_root_fingerprint_from_device()
return root_fp
@abstractmethod
def has_usable_connection_with_device(self) -> bool:
pass
@abstractmethod
def get_xpub(self, bip32_path: str, xtype) -> str:
pass
@runs_in_hwd_thread
def request_root_fingerprint_from_device(self) -> str:
# digitalbitbox (at least) does not reveal xpubs corresponding to unhardened paths
# so ask for a direct child, and read out fingerprint from that:
child_of_root_xpub = self.get_xpub("m/0'", xtype='standard')
root_fingerprint = BIP32Node.from_xkey(child_of_root_xpub).fingerprint.hex().lower()
return root_fingerprint
@runs_in_hwd_thread
def get_password_for_storage_encryption(self) -> str:
# note: using a different password based on hw device type is highly undesirable! see #5993
derivation = get_derivation_used_for_hw_device_encryption()
xpub = self.get_xpub(derivation, "standard")
password = Xpub.get_pubkey_from_xpub(xpub, ()).hex()
return password
def device_model_name(self) -> Optional[str]:
"""Return the name of the model of this device, which might be displayed in the UI.
E.g. for Trezor, "Trezor One" or "Trezor T".
If this method is not defined for a plugin, the plugin name is used as default
"""
return self.plugin.name
class HardwareClientDummy(HardwareClientBase):
"""Hw device we recognize but do not support.
E.g. for Ledger HW.1 devices that we used to support in the past, but no longer do.
This allows showing an error message to the user.
"""
def __init__(self, *, plugin: 'HW_PluginBase', error_text: str):
HardwareClientBase.__init__(self, plugin=plugin)
self.error_text = error_text
def get_xpub(self, bip32_path: str, xtype) -> str:
raise Exception(self.error_text)
def is_pairable(self) -> bool:
return False
def close(self):
pass
def is_initialized(self) -> bool:
"""True if initialized, False if wiped."""
return True
def label(self) -> Optional[str]:
return "dummy_client"
def has_usable_connection_with_device(self) -> bool:
return True
class HardwareHandlerBase:
"""An interface between the GUI and the device handling logic for handling I/O."""
win = None
device: str
def get_wallet(self) -> Optional['Abstract_Wallet']:
if self.win is not None:
if hasattr(self.win, 'wallet'):
return self.win.wallet
def get_gui_thread(self) -> Optional['threading.Thread']:
if self.win is not None:
if hasattr(self.win, 'gui_thread'):
return self.win.gui_thread
def update_status(self, paired: bool) -> None:
pass
def query_choice(self, msg: str, choices: Sequence[ChoiceItem]) -> Optional[Any]:
"""Returns ChoiceItem.key (for selected item), or None if the user cancels the dialog."""
raise NotImplementedError()
def yes_no_question(self, msg: str) -> bool:
raise NotImplementedError()
def show_message(self, msg: str, on_cancel=None) -> None:
raise NotImplementedError()
def show_error(self, msg: str, blocking: bool = False) -> None:
raise NotImplementedError()
def finished(self) -> None:
pass
def get_word(self, msg: str) -> str:
raise NotImplementedError()
def get_passphrase(self, msg: str, confirm: bool) -> Optional[str]:
raise NotImplementedError()
def get_pin(self, msg: str, *, show_strength: bool = True) -> str:
raise NotImplementedError()
def is_any_tx_output_on_change_branch(tx: PartialTransaction) -> bool:
return any([txout.is_change for txout in tx.outputs()])
def trezor_validate_op_return_output_and_get_data(output: TxOutput) -> bytes:
validate_op_return_output(output)
script = output.scriptpubkey
if not (script[0] == opcodes.OP_RETURN and
script[1] == len(script) - 2 and script[1] <= 75):
raise UserFacingException(_("Only OP_RETURN scripts, with one constant push, are supported."))
return script[2:]
def validate_op_return_output(output: TxOutput, *, max_size: int = None) -> None:
script = output.scriptpubkey
if script[0] != opcodes.OP_RETURN:
raise UserFacingException(_("Only OP_RETURN scripts are supported."))
if max_size is not None and len(script) > max_size:
raise UserFacingException(_("OP_RETURN payload too large." + "\n"
+ f"(scriptpubkey size {len(script)} > {max_size})"))
if output.value != 0:
raise UserFacingException(_("Amount for OP_RETURN output must be zero."))
def only_hook_if_libraries_available(func):
# note: this decorator must wrap @hook, not the other way around,
# as 'hook' uses the name of the function it wraps
def wrapper(self: 'HW_PluginBase', *args, **kwargs):
if not self.libraries_available: return None
return func(self, *args, **kwargs)
return wrapper
class LibraryFoundButUnusable(Exception):
def __init__(self, library_version='unknown'):
self.library_version = library_version
class OutdatedHwFirmwareException(UserFacingException):
def text_ignore_old_fw_and_continue(self) -> str:
suffix = (_("The firmware of your hardware device is too old. "
"If possible, you should upgrade it. "
"You can ignore this error and try to continue, however things are likely to break.") + "\n\n" +
_("Ignore and continue?"))
if str(self):
return str(self) + "\n\n" + suffix
else:
return suffix
class OperationCancelled(UserFacingException):
"""Emitted when an operation is cancelled by user on a HW device
"""
pass
================================================
FILE: electrum/hw_wallet/qt.py
================================================
#!/usr/bin/env python3
# -*- 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.
import threading
from functools import partial
from typing import TYPE_CHECKING, Union, Optional, Sequence
from PyQt6.QtCore import QObject, pyqtSignal, Qt
from PyQt6.QtWidgets import QVBoxLayout, QLineEdit, QHBoxLayout, QLabel, QMenu
from electrum.i18n import _
from electrum.logging import Logger
from electrum.util import UserCancelled, UserFacingException, ChoiceItem
from electrum.plugin import hook
from electrum.gui.common_qt.util import TaskThread
from electrum.gui.qt.password_dialog import PasswordLayout, PW_PASSPHRASE
from electrum.gui.qt.util import (
read_QIcon, WWLabel, OkButton, WindowModalDialog, Buttons, CancelButton, char_width_in_lineedit, PasswordLineEdit,
read_QIcon_from_bytes
)
from electrum.gui.qt.main_window import StatusBarButton
from .plugin import OutdatedHwFirmwareException, HW_PluginBase, HardwareHandlerBase
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
from electrum.keystore import Hardware_KeyStore
from electrum.gui.qt import ElectrumWindow
from electrum.gui.qt.wizard.wallet import QENewWalletWizard
# The trickiest thing about this handler was getting windows properly
# parented on macOS.
class QtHandlerBase(HardwareHandlerBase, QObject, Logger):
"""An interface between the GUI (here, QT) and the device handling
logic for handling I/O."""
passphrase_signal = pyqtSignal(object, object)
message_signal = pyqtSignal(object, object)
error_signal = pyqtSignal(object, object)
word_signal = pyqtSignal(object)
clear_signal = pyqtSignal()
query_signal = pyqtSignal(object, object)
yes_no_signal = pyqtSignal(object)
status_signal = pyqtSignal(object)
def __init__(self, win: Union['ElectrumWindow', 'QENewWalletWizard'], device: str):
QObject.__init__(self)
Logger.__init__(self)
assert win.gui_thread == threading.current_thread(), 'must be called from GUI thread'
self.clear_signal.connect(self.clear_dialog)
self.error_signal.connect(self.error_dialog)
self.message_signal.connect(self.message_dialog)
self.passphrase_signal.connect(self.passphrase_dialog)
self.word_signal.connect(self.word_dialog)
self.query_signal.connect(self.win_query_choice)
self.yes_no_signal.connect(self.win_yes_no_question)
self.status_signal.connect(self._update_status)
self.win = win
self.device = device
self.dialog = None
self.done = threading.Event()
def top_level_window(self):
return self.win.top_level_window()
def update_status(self, paired):
self.status_signal.emit(paired)
def _update_status(self, paired):
if hasattr(self, 'button'):
button = self.button
icon_bytes = button.icon_paired if paired else button.icon_unpaired
icon = read_QIcon_from_bytes(icon_bytes)
button.setIcon(icon)
def query_choice(self, msg: str, choices: Sequence[ChoiceItem]):
self.done.clear()
self.query_signal.emit(msg, choices)
self.done.wait()
return self.choice
def yes_no_question(self, msg):
self.done.clear()
self.yes_no_signal.emit(msg)
self.done.wait()
return self.ok
def show_message(self, msg, on_cancel=None):
self.message_signal.emit(msg, on_cancel)
def show_error(self, msg, blocking=False):
self.done.clear()
self.error_signal.emit(msg, blocking)
if blocking:
self.done.wait()
def finished(self):
self.clear_signal.emit()
def get_word(self, msg):
self.done.clear()
self.word_signal.emit(msg)
self.done.wait()
return self.word
def get_passphrase(self, msg, confirm):
self.done.clear()
self.passphrase_signal.emit(msg, confirm)
self.done.wait()
return self.passphrase
def passphrase_dialog(self, msg, confirm):
# If confirm is true, require the user to enter the passphrase twice
parent = self.top_level_window()
d = WindowModalDialog(parent, _("Enter Passphrase"))
if confirm:
OK_button = OkButton(d)
playout = PasswordLayout(msg=msg, kind=PW_PASSPHRASE, OK_button=OK_button)
vbox = QVBoxLayout()
vbox.addLayout(playout.layout())
vbox.addLayout(Buttons(CancelButton(d), OK_button))
d.setLayout(vbox)
passphrase = playout.new_password() if d.exec() else None
else:
pw = PasswordLineEdit()
pw.setMinimumWidth(200)
vbox = QVBoxLayout()
vbox.addWidget(WWLabel(msg))
vbox.addWidget(pw)
vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
d.setLayout(vbox)
passphrase = pw.text() if d.exec() else None
self.passphrase = passphrase
self.done.set()
def word_dialog(self, msg):
dialog = WindowModalDialog(self.top_level_window(), "")
hbox = QHBoxLayout(dialog)
hbox.addWidget(QLabel(msg))
text = QLineEdit()
text.setMaximumWidth(12 * char_width_in_lineedit())
text.returnPressed.connect(dialog.accept)
hbox.addWidget(text)
hbox.addStretch(1)
dialog.exec() # Firmware cannot handle cancellation
self.word = text.text()
self.done.set()
MESSAGE_DIALOG_TITLE = None # type: Optional[str]
def message_dialog(self, msg, on_cancel=None):
self.clear_dialog()
title = self.MESSAGE_DIALOG_TITLE
if title is None:
title = _('Please check your {} device').format(self.device)
self.dialog = dialog = WindowModalDialog(self.top_level_window(), title)
label = QLabel(msg)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
vbox = QVBoxLayout(dialog)
vbox.addWidget(label)
if on_cancel:
dialog.rejected.connect(on_cancel)
vbox.addLayout(Buttons(CancelButton(dialog)))
dialog.show()
def error_dialog(self, msg, blocking):
self.win.show_error(msg, parent=self.top_level_window())
if blocking:
self.done.set()
def clear_dialog(self):
if self.dialog:
self.dialog.accept()
self.dialog = None
def win_query_choice(self, msg: str, choices: Sequence[ChoiceItem]):
try:
self.choice = self.win.query_choice(msg, choices)
except UserCancelled:
self.choice = None
self.done.set()
def win_yes_no_question(self, msg):
self.ok = self.win.question(msg)
self.done.set()
class QtPluginBase(object):
@hook
def load_wallet(self: Union['QtPluginBase', HW_PluginBase], wallet: 'Abstract_Wallet', window: 'ElectrumWindow'):
relevant_keystores = [keystore for keystore in wallet.get_keystores()
if isinstance(keystore, self.keystore_class)]
if not relevant_keystores:
return
for keystore in relevant_keystores:
if not self.libraries_available:
message = keystore.plugin.get_library_not_available_message()
window.show_error(message)
return
tooltip = self.device + '\n' + (keystore.label or 'unnamed')
cb = partial(self._on_status_bar_button_click, window=window, keystore=keystore)
sb = window.statusBar()
icon = read_QIcon_from_bytes(self.read_file(self.icon_unpaired))
button = StatusBarButton(icon, tooltip, cb, sb.height())
button.icon_paired = self.read_file(self.icon_paired)
button.icon_unpaired = self.read_file(self.icon_unpaired)
sb.addPermanentWidget(button)
handler = self.create_handler(window)
handler.button = button
keystore.handler = handler
keystore.thread = TaskThread(window, on_error=partial(self.on_task_thread_error, window, keystore))
self.add_show_address_on_hw_device_button_for_receive_addr(wallet, keystore, window)
# Trigger pairings
devmgr = self.device_manager()
trigger_pairings = partial(devmgr.trigger_pairings, relevant_keystores, allow_user_interaction=True)
some_keystore = relevant_keystores[0]
some_keystore.thread.add(trigger_pairings)
def _on_status_bar_button_click(self, *, window: 'ElectrumWindow', keystore: 'Hardware_KeyStore'):
try:
self.show_settings_dialog(window=window, keystore=keystore)
except (UserFacingException, UserCancelled) as e:
exc_info = (type(e), e, e.__traceback__)
self.on_task_thread_error(window=window, keystore=keystore, exc_info=exc_info)
def on_task_thread_error(self: Union['QtPluginBase', HW_PluginBase], window: 'ElectrumWindow',
keystore: 'Hardware_KeyStore', exc_info):
e = exc_info[1]
if isinstance(e, OutdatedHwFirmwareException):
if window.question(e.text_ignore_old_fw_and_continue(), title=_("Outdated device firmware")):
self.set_ignore_outdated_fw()
# will need to re-pair
devmgr = self.device_manager()
def re_pair_device():
device_id = self.choose_device(window, keystore)
devmgr.unpair_id(device_id)
self.get_client(keystore)
keystore.thread.add(re_pair_device)
return
else:
window.on_error(exc_info)
def choose_device(self: Union['QtPluginBase', HW_PluginBase], window: 'ElectrumWindow',
keystore: 'Hardware_KeyStore') -> Optional[str]:
"""This dialog box should be usable even if the user has
forgotten their PIN or it is in bootloader mode."""
assert window.gui_thread != threading.current_thread(), 'must not be called from GUI thread'
device_id = self.device_manager().id_by_pairing_code(keystore.pairing_code())
if not device_id:
try:
info = self.device_manager().select_device(self, keystore.handler, keystore)
except UserCancelled:
return
device_id = info.device.id_
return device_id
def show_settings_dialog(self, window: 'ElectrumWindow', keystore: 'Hardware_KeyStore') -> None:
# default implementation (if no dialog): just try to connect to device
def connect():
device_id = self.choose_device(window, keystore)
keystore.thread.add(connect)
def add_show_address_on_hw_device_button_for_receive_addr(
self,
wallet: 'Abstract_Wallet',
keystore: 'Hardware_KeyStore',
main_window: 'ElectrumWindow'
):
plugin = keystore.plugin
receive_tab = main_window.receive_tab
def show_address():
addr = str(receive_tab.addr)
keystore.thread.add(partial(plugin.show_address, wallet, addr, keystore))
dev_name = f"{plugin.device} ({keystore.label})"
receive_tab.toolbar_menu.addAction(read_QIcon("eye1.png"), _("Show address on {}").format(dev_name), show_address)
def create_handler(self, window: Union['ElectrumWindow', 'QENewWalletWizard']) -> 'QtHandlerBase':
raise NotImplementedError()
def _add_menu_action(self, menu: QMenu, address: str, wallet: 'Abstract_Wallet'):
if not wallet.is_mine(address):
return
for keystore in wallet.get_keystores():
if type(keystore) == self.keystore_class:
def show_address(keystore=keystore):
keystore.thread.add(partial(self.show_address, wallet, address, keystore=keystore))
device_name = "{} ({})".format(self.device, keystore.label)
menu.addAction(read_QIcon("eye1.png"), _("Show address on {}").format(device_name), show_address)
================================================
FILE: electrum/hw_wallet/trezor_qt_pinmatrix.py
================================================
# from https://github.com/trezor/trezor-firmware/blob/3f1d2059ca140788dab8726778f05cedbea20bc4/python/src/trezorlib/qt/pinmatrix.py
#
# This file is part of the Trezor project.
#
# Copyright (C) 2012-2022 SatoshiLabs and contributors
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3
# as published by the Free Software Foundation.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the License along with this library.
# If not, see .
import math
from typing import Any
from PyQt6.QtCore import QRegularExpression, Qt
from PyQt6.QtGui import QRegularExpressionValidator
from PyQt6.QtWidgets import (
QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget
)
class PinButton(QPushButton):
def __init__(self, password: QLineEdit, encoded_value: int) -> None:
super(PinButton, self).__init__("?")
self.password = password
self.encoded_value = encoded_value
self.clicked.connect(self._pressed)
def _pressed(self) -> None:
self.password.setText(self.password.text() + str(self.encoded_value))
self.password.setFocus()
class PinMatrixWidget(QWidget):
"""
Displays widget with nine blank buttons and password box.
Encodes button clicks into sequence of numbers for passing
into PinAck messages of Trezor.
show_strength=True may be useful for entering new PIN
"""
def __init__(self, show_strength: bool = True, parent: Any = None) -> None:
super(PinMatrixWidget, self).__init__(parent)
self.password = QLineEdit()
self.password.setValidator(QRegularExpressionValidator(QRegularExpression("[1-9]+"), None))
self.password.setEchoMode(QLineEdit.EchoMode.Password)
self.password.textChanged.connect(self._password_changed)
self.strength = QLabel()
self.strength.setMinimumWidth(75)
self.strength.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._set_strength(0)
grid = QGridLayout()
grid.setSpacing(0)
for y in range(3)[::-1]:
for x in range(3):
button = PinButton(self.password, x + y * 3 + 1)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
grid.addWidget(button, 3 - y, x)
hbox = QHBoxLayout()
hbox.addWidget(self.password)
if show_strength:
hbox.addWidget(self.strength)
vbox = QVBoxLayout()
vbox.addLayout(grid)
vbox.addLayout(hbox)
self.setLayout(vbox)
def _set_strength(self, strength: float) -> None:
if strength < 3000:
self.strength.setText("weak")
self.strength.setStyleSheet("QLabel { color : #d00; }")
elif strength < 60000:
self.strength.setText("fine")
self.strength.setStyleSheet("QLabel { color : #db0; }")
elif strength < 360000:
self.strength.setText("strong")
self.strength.setStyleSheet("QLabel { color : #0a0; }")
else:
self.strength.setText("ULTIMATE")
self.strength.setStyleSheet("QLabel { color : #000; font-weight: bold;}")
def _password_changed(self, password: Any) -> None:
self._set_strength(self.get_strength())
def get_strength(self) -> float:
digits = len(set(str(self.password.text())))
strength = math.factorial(9) / math.factorial(9 - digits)
return strength
def get_value(self) -> str:
return self.password.text()
================================================
FILE: electrum/i18n.py
================================================
#!/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 functools
import json
import os
import string
from typing import Optional
import gettext
from .logging import get_logger
_logger = get_logger(__name__)
LOCALE_DIR = os.path.join(os.path.dirname(__file__), 'locale', 'locale')
def _get_null_translations():
"""Returns a gettext Translations obj with translations explicitly disabled."""
return gettext.translation('electrum', fallback=True, class_=gettext.NullTranslations)
# Set initial default language to None. i.e. translations explicitly disabled.
# The main script or GUIs can call set_language to enable translations.
_language = _get_null_translations()
def _ensure_translation_keeps_format_string_syntax_similar(translator):
"""This checks that the source string is syntactically similar to the translated string.
If not, translations are rejected by falling back to the source string.
"""
sf = string.Formatter()
@functools.wraps(translator)
def safe_translator(msg: str, **kwargs):
translation = translator(msg, **kwargs)
parsed1 = list(sf.parse(msg)) # iterable of tuples (literal_text, field_name, format_spec, conversion)
try:
parsed2 = list(sf.parse(translation))
except ValueError: # malformed format string in translation
_logger.warning(
f"rejected translation string: failed to parse. original={msg!r}. {translation=!r}",
only_once=True)
return msg
# num of replacement fields must match:
if len(parsed1) != len(parsed2):
_logger.warning(
f"rejected translation string: num replacement fields mismatch. original={msg!r}. {translation=!r}",
only_once=True)
return msg
# set of "field_name"s must not change. (re-ordering is explicitly allowed):
field_names1 = set(tupl[1] for tupl in parsed1)
field_names2 = set(tupl[1] for tupl in parsed2)
if field_names1 != field_names2:
_logger.warning(
f"rejected translation string: set of field_names mismatch. original={msg!r}. {translation=!r}",
only_once=True)
return msg
# checks done.
return translation
return safe_translator
# note: do not use old-style (%) formatting inside translations,
# as syntactically incorrectly translated strings often raise exceptions (see #3237).
# e.g. consider _("Connected to %d nodes.") % n # <- raises. do NOT use
# >>> "Connecté aux noeuds" % n
# TypeError: not all arguments converted during string formatting
# note: f-strings cannot be translated! see https://stackoverflow.com/q/49797658
# So this does NOT work: _(f"My name: {name}") # <- cannot be translated. do NOT use
# instead use .format: _("My name: {}").format(name) # <- works. prefer this way.
# note: positional and keyword-based substitution also works with str.format().
# These give more flexibility to translators: it allows reordering the substituted values.
# However, only if the translators understand and use it correctly!
# _("time left: {0} minutes, {1} seconds").format(t//60, t%60) # <- works. ok to use
# _("time left: {mins} minutes, {secs} seconds").format(mins=t//60, secs=t%60) # <- works, but too complex
@_ensure_translation_keeps_format_string_syntax_similar
def _(msg: str, *, context=None) -> str:
if msg == "":
return "" # empty string must not be translated. see #7158
if context:
contexts = [context]
if context[-1] != "|": # try with both "|" suffix and without
contexts.append(context + "|")
else:
contexts.append(context[:-1])
for ctx in contexts:
out = _language.pgettext(ctx, msg)
if out != msg: # found non-trivial translation
return out
# else try without context
return _language.gettext(msg)
def set_language(x: Optional[str]) -> None:
_logger.info(f"setting language to {x!r}")
global _language
if not x:
return
if x.startswith("en_"):
# Setting the language to "English" is a protected special-case:
# we disable all translations and use the source strings.
_language = _get_null_translations()
else:
_language = gettext.translation('electrum', LOCALE_DIR, fallback=True, languages=[x])
# note: The values (human-visible lang names) should be either in English or in their own lang,
# but NOT translated to the currently selected lang.
# e.g. "fr_FR" we could show as either "French" or "Francais", or even as "French - Francais",
# but it is evil to show it as "Franzosisch". How am I supposed to switch back to English from Korean??? :)
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', # selecting this guarantees seeing the untranslated source strings
'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': 'Portuguese (Brazil)',
'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',
}
assert '' in languages
def get_gui_lang_names(*, show_completion_percent: bool = True) -> dict[str, str]:
"""Returns a lang_code -> lang_name mapping, sorted.
If show_completion_percent is True, lang_name includes a % estimate for translation completeness.
"""
# calc catalog sizes
if show_completion_percent:
stats = _get_stats()
# sort ("Default" first, then "English", then lexicographically sorted names)
languages_copy = languages.copy()
lang_pair_default = ("", languages_copy.pop("")) # pop "Default"
lang_pair_english = ("en_UK", languages_copy.pop("en_UK")) # pop "English"
lang_pairs_sorted = sorted(languages_copy.items(), key=lambda x: x[1])
# fancy names
gui_lang_names = {} # type: dict[str, str]
gui_lang_names[lang_pair_default[0]] = lang_pair_default[1]
gui_lang_names[lang_pair_english[0]] = lang_pair_english[1]
for lang_code, lang_name in lang_pairs_sorted:
if show_completion_percent and stats:
source_str_cnt = max(stats["source_string_count"], 1) # avoid div-by-zero
try:
lang_data = stats["translations"][lang_code]
except KeyError as e:
_logger.warning(f"missing language from stats.json: {e!r}")
catalog_percent = "??"
else:
translated_str_cnt = lang_data["string_count"]
catalog_percent = round(100 * translated_str_cnt / source_str_cnt)
gui_lang_names[lang_code] = f"{lang_name} ({catalog_percent}%)"
else:
gui_lang_names[lang_code] = lang_name
return gui_lang_names
_stats = None
def _get_stats() -> dict:
global _stats
if _stats is None:
fname = f"{LOCALE_DIR}/stats.json"
try:
with open(fname, "r", encoding="utf-8") as f:
text = f.read()
except OSError as e: # we tolerate the file missing
# This can happen e.g. when running from git clone if user did not run build_locale.sh.
_logger.info(f"failed to open stats file {fname!r} - built locale (translations) missing??: {e!r}")
_stats = {}
else: # found file. if it is there, it MUST parse correctly
_stats = json.loads(text)
return _stats
================================================
FILE: electrum/interface.py
================================================
#!/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 ssl
import sys
import time
import traceback
import asyncio
import socket
from typing import Tuple, Union, List, TYPE_CHECKING, Optional, Set, NamedTuple, Any, Sequence, Dict
from collections import defaultdict
from ipaddress import IPv4Network, IPv6Network, ip_address, IPv6Address, IPv4Address
import itertools
import logging
import hashlib
import functools
import random
import enum
import aiorpcx
from aiorpcx import RPCSession, Notification, NetAddress, NewlineFramer
from aiorpcx.curio import timeout_after, TaskTimeout
from aiorpcx.jsonrpc import JSONRPC, CodeMessageError
from aiorpcx.rawsocket import RSClient, RSTransport
import certifi
from .util import (ignore_exceptions, log_exceptions, bfh, ESocksProxy,
is_integer, is_non_negative_integer, is_hash256_str, is_hex_str,
is_int_or_float, is_non_negative_int_or_float, OldTaskGroup,
send_exception_to_crash_reporter, error_text_str_to_safe_str, versiontuple)
from . import util
from . import x509
from . import pem
from . import version
from . import blockchain
from .blockchain import Blockchain, HEADER_SIZE, CHUNK_SIZE
from . import bitcoin
from .bitcoin import DummyAddress, DummyAddressUsedInTxException
from . import constants
from .i18n import _
from .logging import Logger
from .transaction import Transaction
from .fee_policy import FEE_ETA_TARGETS
from .lrucache import LRUCache
if TYPE_CHECKING:
from .network import Network
from .simple_config import SimpleConfig
ca_path = certifi.where()
BUCKET_NAME_OF_ONION_SERVERS = 'onion'
KNOWN_ELEC_PROTOCOL_TRANSPORTS = {'t', 's'}
PREFERRED_NETWORK_PROTOCOL = 's'
assert PREFERRED_NETWORK_PROTOCOL in KNOWN_ELEC_PROTOCOL_TRANSPORTS
MAX_NUM_HEADERS_PER_REQUEST = 2016
assert MAX_NUM_HEADERS_PER_REQUEST >= CHUNK_SIZE
class NetworkTimeout:
# seconds
class Generic:
NORMAL = 30
RELAXED = 45
MOST_RELAXED = 600
class Urgent(Generic):
NORMAL = 10
RELAXED = 20
MOST_RELAXED = 60
def assert_non_negative_integer(val: Any) -> None:
if not is_non_negative_integer(val):
raise RequestCorrupted(f'{val!r} should be a non-negative integer')
def assert_integer(val: Any) -> None:
if not is_integer(val):
raise RequestCorrupted(f'{val!r} should be an integer')
def assert_int_or_float(val: Any) -> None:
if not is_int_or_float(val):
raise RequestCorrupted(f'{val!r} should be int or float')
def assert_non_negative_int_or_float(val: Any) -> None:
if not is_non_negative_int_or_float(val):
raise RequestCorrupted(f'{val!r} should be a non-negative int or float')
def assert_hash256_str(val: Any) -> None:
if not is_hash256_str(val):
raise RequestCorrupted(f'{val!r} should be a hash256 str')
def assert_hex_str(val: Any) -> None:
if not is_hex_str(val):
raise RequestCorrupted(f'{val!r} should be a hex str')
def assert_dict_contains_field(d: Any, *, field_name: str) -> Any:
if not isinstance(d, dict):
raise RequestCorrupted(f'{d!r} should be a dict')
if field_name not in d:
raise RequestCorrupted(f'required field {field_name!r} missing from dict')
return d[field_name]
def assert_list_or_tuple(val: Any) -> None:
if not isinstance(val, (list, tuple)):
raise RequestCorrupted(f'{val!r} should be a list or tuple')
def protocol_tuple(s: Any) -> tuple[int, ...]:
"""Converts a protocol version number, such as "1.0" to a tuple (1, 0).
If the version number is bad, (0, ) indicating version 0 is returned.
"""
try:
assert isinstance(s, str)
return versiontuple(s)
except Exception:
return (0, )
class ChainResolutionMode(enum.Enum):
CATCHUP = enum.auto()
BACKWARD = enum.auto()
BINARY = enum.auto()
FORK = enum.auto()
NO_FORK = enum.auto()
class NotificationSession(RPCSession):
def __init__(self, *args, interface: 'Interface', **kwargs):
super(NotificationSession, self).__init__(*args, **kwargs)
self.subscriptions = defaultdict(list)
self.cache = {}
self._msg_counter = itertools.count(start=1)
self.interface = interface
self.taskgroup = interface.taskgroup
self.cost_hard_limit = 0 # disable aiorpcx resource limits
async def handle_request(self, request):
self.maybe_log(f"--> {request}")
try:
if isinstance(request, Notification):
params, result = request.args[:-1], request.args[-1]
key = self.get_hashable_key_for_rpc_call(request.method, params)
if key in self.subscriptions:
self.cache[key] = result
for queue in self.subscriptions[key]:
await queue.put(request.args)
else:
raise Exception(f'unexpected notification')
else:
raise Exception(f'unexpected request. not a notification')
except Exception as e:
self.interface.logger.info(f"error handling request {request}. exc: {repr(e)}")
await self.close()
async def send_request(self, *args, timeout=None, **kwargs):
# note: semaphores/timeouts/backpressure etc are handled by
# aiorpcx. the timeout arg here in most cases should not be set
msg_id = next(self._msg_counter)
self.maybe_log(f"<-- {args} {kwargs} (id: {msg_id})")
try:
# note: RPCSession.send_request raises TaskTimeout in case of a timeout.
# TaskTimeout is a subclass of CancelledError, which is *suppressed* in TaskGroups
response = await util.wait_for2(
super().send_request(*args, **kwargs),
timeout)
except (TaskTimeout, asyncio.TimeoutError) as e:
self.maybe_log(f"--> request timed out: {args} (id: {msg_id})")
raise RequestTimedOut(f'request timed out: {args} (id: {msg_id})') from e
except CodeMessageError as e:
self.maybe_log(f"--> {repr(e)} (id: {msg_id})")
raise
except BaseException as e: # cancellations, etc. are useful for debugging
self.maybe_log(f"--> {repr(e)} (id: {msg_id})")
raise
else:
self.maybe_log(f"--> {response} (id: {msg_id})")
return response
def set_default_timeout(self, timeout):
assert hasattr(self, "sent_request_timeout") # in base class
self.sent_request_timeout = timeout
assert hasattr(self, "max_send_delay") # in base class
self.max_send_delay = timeout
async def subscribe(self, method: str, params: List, queue: asyncio.Queue):
# note: until the cache is written for the first time,
# each 'subscribe' call might make a request on the network.
key = self.get_hashable_key_for_rpc_call(method, params)
self.subscriptions[key].append(queue)
if key in self.cache:
result = self.cache[key]
else:
result = await self.send_request(method, params)
self.cache[key] = result
await queue.put(params + [result])
def unsubscribe(self, queue):
"""Unsubscribe a callback to free object references to enable GC."""
# note: we can't unsubscribe from the server, so we keep receiving
# subsequent notifications
for v in self.subscriptions.values():
if queue in v:
v.remove(queue)
@classmethod
def get_hashable_key_for_rpc_call(cls, method, params):
"""Hashable index for subscriptions and cache"""
return str(method) + repr(params)
def maybe_log(self, msg: str) -> None:
if not self.interface: return
if self.interface.debug or self.interface.network.debug:
self.interface.logger.debug(msg)
def default_framer(self):
# overridden so that max_size can be customized
max_size = self.interface.network.config.NETWORK_MAX_INCOMING_MSG_SIZE
assert max_size > 500_000, f"{max_size=} (< 500_000) is too small"
return NewlineFramer(max_size=max_size)
async def close(self, *, force_after: int = None):
"""Closes the connection and waits for it to be closed.
We try to flush buffered data to the wire, which can take some time.
"""
if force_after is None:
# We give up after a while and just abort the connection.
# Note: specifically if the server is running Fulcrum, waiting seems hopeless,
# the connection must be aborted (see https://github.com/cculianu/Fulcrum/issues/76)
# Note: if the ethernet cable was pulled or wifi disconnected, that too might
# wait until this timeout is triggered
force_after = 1 # seconds
await super().close(force_after=force_after)
class NetworkException(Exception): pass
class GracefulDisconnect(NetworkException):
log_level = logging.INFO
def __init__(self, *args, log_level=None, **kwargs):
Exception.__init__(self, *args, **kwargs)
if log_level is not None:
self.log_level = log_level
class RequestTimedOut(GracefulDisconnect):
def __str__(self):
return _("Network request timed out.")
class RequestCorrupted(Exception): pass
class ErrorParsingSSLCert(Exception): pass
class ErrorGettingSSLCertFromServer(Exception): pass
class ErrorSSLCertFingerprintMismatch(Exception): pass
class InvalidOptionCombination(Exception): pass
class ConnectError(NetworkException): pass
class TxBroadcastError(NetworkException):
def get_message_for_gui(self):
raise NotImplementedError()
class TxBroadcastHashMismatch(TxBroadcastError):
def get_message_for_gui(self):
return "{}\n{}\n\n{}" \
.format(_("The server returned an unexpected transaction ID when broadcasting the transaction."),
_("Consider trying to connect to a different server, or updating Electrum."),
str(self))
class TxBroadcastServerReturnedError(TxBroadcastError):
def get_message_for_gui(self):
return "{}\n{}\n\n{}" \
.format(_("The server returned an error when broadcasting the transaction."),
_("Consider trying to connect to a different server, or updating Electrum."),
str(self))
class TxBroadcastUnknownError(TxBroadcastError):
def get_message_for_gui(self):
return "{}\n{}" \
.format(_("Unknown error when broadcasting the transaction."),
_("Consider trying to connect to a different server, or updating Electrum."))
class _RSClient(RSClient):
async def create_connection(self):
try:
return await super().create_connection()
except OSError as e:
# note: using "from e" here will set __cause__ of ConnectError
raise ConnectError(e) from e
class PaddedRSTransport(RSTransport):
"""A raw socket transport that provides basic countermeasures against traffic analysis
by padding the jsonrpc payload with whitespaces to have ~uniform-size TCP packets.
(it is assumed that a network observer does not see plaintext transport contents,
due to it being wrapped e.g. in TLS)
"""
MIN_PACKET_SIZE = 1024
WAIT_FOR_BUFFER_GROWTH_SECONDS = 1.0
# (unpadded) amount of bytes sent instantly before beginning with polling.
# This makes the initial handshake where a few small messages are exchanged faster.
WARMUP_BUDGET_SIZE = 1024
session: Optional['RPCSession']
def __init__(self, *args, **kwargs):
RSTransport.__init__(self, *args, **kwargs)
self._sbuffer = bytearray() # "send buffer"
self._sbuffer_task = None # type: Optional[asyncio.Task]
self._sbuffer_has_data_evt = asyncio.Event()
self._last_send = time.monotonic()
self._force_send = False # type: bool
# note: this does not call super().write() but is a complete reimplementation
async def write(self, message):
await self._can_send.wait()
if self.is_closing():
return
framed_message = self._framer.frame(message)
self._sbuffer += framed_message
self._sbuffer_has_data_evt.set()
self._maybe_consume_sbuffer()
def _maybe_consume_sbuffer(self) -> None:
"""Maybe take some data from sbuffer and send it on the wire."""
if not self._can_send.is_set() or self.is_closing():
return
buf = self._sbuffer
if not buf:
return
# if there is enough data in the buffer, or if we haven't sent in a while, send now:
if not (
self._force_send
or len(buf) >= self.MIN_PACKET_SIZE
or self._last_send + self.WAIT_FOR_BUFFER_GROWTH_SECONDS < time.monotonic()
or self.session.send_size < self.WARMUP_BUDGET_SIZE
):
return
assert buf[-2:] in (b"}\n", b"]\n"), f"unexpected json-rpc terminator: {buf[-2:]=!r}"
# either (1) pad length to next power of two, to create "lsize" packet:
payload_lsize = len(buf)
total_lsize = max(self.MIN_PACKET_SIZE, 2 ** (payload_lsize.bit_length()))
npad_lsize = total_lsize - payload_lsize
# or if that wasted a lot of bandwidth with padding, (2) defer sending some messages
# and create a packet with half that size ("ssize", s for small)
total_ssize = max(self.MIN_PACKET_SIZE, total_lsize // 2)
payload_ssize = buf.rfind(b"\n", 0, total_ssize)
if payload_ssize != -1:
payload_ssize += 1 # for "\n" char
npad_ssize = total_ssize - payload_ssize
else:
npad_ssize = float("inf")
# decide between (1) and (2):
if self._force_send or npad_lsize <= npad_ssize:
# (1) create "lsize" packet: consume full buffer
npad = npad_lsize
p_idx = payload_lsize
else:
# (2) create "ssize" packet: consume some, but defer some for later
npad = npad_ssize
p_idx = payload_ssize
# pad by adding spaces near end
# self.session.maybe_log(
# f"PaddedRSTransport. calling low-level write(). "
# f"chose between (lsize:{payload_lsize}+{npad_lsize}, ssize:{payload_ssize}+{npad_ssize}). "
# f"won: {'tie' if npad_lsize == npad_ssize else 'lsize' if npad_lsize < npad_ssize else 'ssize'}."
# )
json_rpc_terminator = buf[p_idx-2:p_idx]
assert json_rpc_terminator in (b"}\n", b"]\n"), f"unexpected {json_rpc_terminator=!r}"
buf2 = buf[:p_idx-2] + (npad * b" ") + json_rpc_terminator
self._asyncio_transport.write(buf2)
self._last_send = time.monotonic()
del self._sbuffer[:p_idx]
if not self._sbuffer:
self._sbuffer_has_data_evt.clear()
async def _poll_sbuffer(self):
while not self.is_closing():
await self._can_send.wait()
await self._sbuffer_has_data_evt.wait() # to avoid busy-waiting
self._maybe_consume_sbuffer()
# If there is still data in the buffer, sleep until it would time out.
# note: If the transport is ~idle, when we wake up, we will send the current buf data,
# but if busy, we might wake up to completely new buffer contents. Either is fine.
if len(self._sbuffer) > 0:
timeout_abs = self._last_send + self.WAIT_FOR_BUFFER_GROWTH_SECONDS
timeout_rel = max(0.0, timeout_abs - time.monotonic())
await asyncio.sleep(timeout_rel)
def connection_made(self, transport: asyncio.BaseTransport):
super().connection_made(transport)
if isinstance(self.session, NotificationSession):
coro = self.session.taskgroup.spawn(self._poll_sbuffer())
self._sbuffer_task = self.loop.create_task(coro)
else:
# This a short-lived "fetch_certificate"-type session.
# No polling here, we always force-empty the buffer.
self._force_send = True
async def close(self, *args, **kwargs):
'''Close the connection and return when closed.'''
# Flush buffer before disconnecting. This makes ReplyAndDisconnect work:
self._force_send = True
self._maybe_consume_sbuffer()
await super().close(*args, **kwargs)
class ServerAddr:
def __init__(self, host: str, port: Union[int, str], *, protocol: str = None):
assert isinstance(host, str), repr(host)
if protocol is None:
protocol = 's'
if not host:
raise ValueError('host must not be empty')
if host[0] == '[' and host[-1] == ']': # IPv6
host = host[1:-1]
try:
net_addr = NetAddress(host, port) # this validates host and port
except Exception as e:
raise ValueError(f"cannot construct ServerAddr: invalid host or port (host={host}, port={port})") from e
if protocol not in KNOWN_ELEC_PROTOCOL_TRANSPORTS:
raise ValueError(f"invalid network protocol: {protocol}")
self.host = str(net_addr.host) # canonical form (if e.g. IPv6 address)
self.port = int(net_addr.port)
self.protocol = protocol
self._net_addr_str = str(net_addr)
@classmethod
def from_str(cls, s: str) -> 'ServerAddr':
"""Constructs a ServerAddr or raises ValueError."""
# host might be IPv6 address, hence do rsplit:
host, port, protocol = str(s).rsplit(':', 2)
return ServerAddr(host=host, port=port, protocol=protocol)
@classmethod
def from_str_with_inference(cls, s: str) -> Optional['ServerAddr']:
"""Construct ServerAddr from str, guessing missing details.
Does not raise - just returns None if guessing failed.
Ongoing compatibility not guaranteed.
"""
if not s:
return None
host = ""
if s[0] == "[" and "]" in s: # IPv6 address
host_end = s.index("]")
host = s[1:host_end]
s = s[host_end+1:]
items = str(s).rsplit(':', 2)
if len(items) < 2:
return None # although maybe we could guess the port too?
host = host or items[0]
port = items[1]
if len(items) >= 3:
protocol = items[2]
else:
protocol = PREFERRED_NETWORK_PROTOCOL
try:
return ServerAddr(host=host, port=port, protocol=protocol)
except ValueError:
return None
def to_friendly_name(self) -> str:
# note: this method is closely linked to from_str_with_inference
if self.protocol == 's': # hide trailing ":s"
return self.net_addr_str()
return str(self)
def __str__(self):
return '{}:{}'.format(self.net_addr_str(), self.protocol)
def to_json(self) -> str:
return str(self)
def __repr__(self):
return f''
def net_addr_str(self) -> str:
return self._net_addr_str
def __eq__(self, other):
if not isinstance(other, ServerAddr):
return False
return (self.host == other.host
and self.port == other.port
and self.protocol == other.protocol)
def __ne__(self, other):
return not (self == other)
def __hash__(self):
return hash((self.host, self.port, self.protocol))
def _get_cert_path_for_host(*, config: 'SimpleConfig', host: str) -> str:
filename = host
try:
ip = ip_address(host)
except ValueError:
pass
else:
if isinstance(ip, IPv6Address):
filename = f"ipv6_{ip.packed.hex()}"
return os.path.join(config.path, 'certs', filename)
class Interface(Logger):
def __init__(self, *, network: 'Network', server: ServerAddr):
assert isinstance(server, ServerAddr), f"expected ServerAddr, got {type(server)}"
self.ready = network.asyncio_loop.create_future()
self.got_disconnected = asyncio.Event()
self._blockchain_updated = asyncio.Event()
self.server = server
Logger.__init__(self)
assert network.config.path
self.cert_path = _get_cert_path_for_host(config=network.config, host=self.host)
self.blockchain = None # type: Optional[Blockchain]
self._requested_chunks = set() # type: Set[int]
self.network = network
self.session = None # type: Optional[NotificationSession]
self._ipaddr_bucket = None
# Set up proxy.
# - for servers running on localhost, the proxy is not used. If user runs their own server
# on same machine, this lets them enable the proxy (which is used for e.g. FX rates).
# note: we could maybe relax this further and bypass the proxy for all private
# addresses...? e.g. 192.168.x.x
if util.is_localhost(server.host):
self.logger.info(f"looks like localhost: not using proxy for this server")
self.proxy = None
else:
self.proxy = ESocksProxy.from_network_settings(network)
# Latest block header and corresponding height, as claimed by the server.
# Note that these values are updated before they are verified.
# Especially during initial header sync, verification can take a long time.
# Failing verification will get the interface closed.
self.tip_header = None # type: Optional[dict]
self.tip = 0
self._headers_cache = {} # type: Dict[int, bytes]
self._rawtx_cache = LRUCache(maxsize=20) # type: LRUCache[str, bytes] # txid->rawtx
self.fee_estimates_eta = {} # type: Dict[int, int]
self.active_protocol_tuple = (0,) # type: Optional[tuple[int, ...]]
# Dump network messages (only for this interface). Set at runtime from the console.
self.debug = False
self.taskgroup = OldTaskGroup()
async def spawn_task():
task = await self.network.taskgroup.spawn(self.run())
task.set_name(f"interface::{str(server)}")
asyncio.run_coroutine_threadsafe(spawn_task(), self.network.asyncio_loop)
@property
def host(self):
return self.server.host
@property
def port(self):
return self.server.port
@property
def protocol(self):
return self.server.protocol
def diagnostic_name(self):
return self.server.net_addr_str()
def __str__(self):
return f""
async def is_server_ca_signed(self, ca_ssl_context: ssl.SSLContext) -> bool:
"""Given a CA enforcing SSL context, returns True if the connection
can be established. Returns False if the server has a self-signed
certificate but otherwise is okay. Any other failures raise.
"""
try:
await self.open_session(ssl_context=ca_ssl_context, exit_early=True)
except ConnectError as e:
cause = e.__cause__
if (isinstance(cause, ssl.SSLCertVerificationError)
and cause.reason == 'CERTIFICATE_VERIFY_FAILED'
and cause.verify_code == 18): # "self signed certificate"
# Good. We will use this server as self-signed.
return False
# Not good. Cannot use this server.
raise
# Good. We will use this server as CA-signed.
return True
async def _try_saving_ssl_cert_for_first_time(self, ca_ssl_context: ssl.SSLContext) -> None:
ca_signed = await self.is_server_ca_signed(ca_ssl_context)
if ca_signed:
if self._get_expected_fingerprint():
raise InvalidOptionCombination("cannot use --serverfingerprint with CA signed servers")
with open(self.cert_path, 'w') as f:
# empty file means this is CA signed, not self-signed
f.write('')
else:
await self._save_certificate()
def _is_saved_ssl_cert_available(self):
if not os.path.exists(self.cert_path):
return False
with open(self.cert_path, 'r') as f:
contents = f.read()
if contents == '': # CA signed
if self._get_expected_fingerprint():
raise InvalidOptionCombination("cannot use --serverfingerprint with CA signed servers")
return True
# pinned self-signed cert
try:
b = pem.dePem(contents, 'CERTIFICATE')
except SyntaxError as e:
self.logger.info(f"error parsing already saved cert: {e}")
raise ErrorParsingSSLCert(e) from e
try:
x = x509.X509(b)
except Exception as e:
self.logger.info(f"error parsing already saved cert: {e}")
raise ErrorParsingSSLCert(e) from e
try:
x.check_date()
except x509.CertificateError as e:
self.logger.info(f"certificate has expired: {e}")
os.unlink(self.cert_path) # delete pinned cert only in this case
return False
self._verify_certificate_fingerprint(bytes(b))
return True
async def _get_ssl_context(self) -> Optional[ssl.SSLContext]:
if self.protocol != 's':
# using plaintext TCP
return None
# see if we already have cert for this server; or get it for the first time
ca_sslc = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path)
if not self._is_saved_ssl_cert_available():
try:
await self._try_saving_ssl_cert_for_first_time(ca_sslc)
except (OSError, ConnectError, aiorpcx.socks.SOCKSError) as e:
raise ErrorGettingSSLCertFromServer(e) from e
# now we have a file saved in our certificate store
siz = os.stat(self.cert_path).st_size
if siz == 0:
# CA signed cert
sslc = ca_sslc
else:
# pinned self-signed cert
sslc = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=self.cert_path)
# note: Flag "ssl.VERIFY_X509_STRICT" is enabled by default in python 3.13+ (disabled in older versions).
# We explicitly disable it as it breaks lots of servers.
sslc.verify_flags &= ~ssl.VERIFY_X509_STRICT
sslc.check_hostname = False
return sslc
def handle_disconnect(func):
@functools.wraps(func)
async def wrapper_func(self: 'Interface', *args, **kwargs):
try:
return await func(self, *args, **kwargs)
except GracefulDisconnect as e:
self.logger.log(e.log_level, f"disconnecting due to {repr(e)}")
except aiorpcx.jsonrpc.RPCError as e:
self.logger.warning(f"disconnecting due to {repr(e)}")
self.logger.debug(f"(disconnect) trace for {repr(e)}", exc_info=True)
finally:
self.got_disconnected.set()
# Make sure taskgroup gets cleaned-up. This explicit clean-up is needed here
# in case the "with taskgroup" ctx mgr never got a chance to run:
await self.taskgroup.cancel_remaining()
await self.network.connection_down(self)
# if was not 'ready' yet, schedule waiting coroutines:
self.ready.cancel()
return wrapper_func
@ignore_exceptions # do not kill network.taskgroup
@log_exceptions
@handle_disconnect
async def run(self):
try:
ssl_context = await self._get_ssl_context()
except (ErrorParsingSSLCert, ErrorGettingSSLCertFromServer) as e:
self.logger.info(f'disconnecting due to: {repr(e)}')
return
try:
await self.open_session(ssl_context=ssl_context)
except (asyncio.CancelledError, ConnectError, aiorpcx.socks.SOCKSError) as e:
# make SSL errors for main interface more visible (to help servers ops debug cert pinning issues)
if (isinstance(e, ConnectError) and isinstance(e.__cause__, ssl.SSLError)
and self.is_main_server() and not self.network.auto_connect):
self.logger.warning(f'Cannot connect to main server due to SSL error '
f'(maybe cert changed compared to "{self.cert_path}"). Exc: {repr(e)}')
else:
self.logger.info(f'disconnecting due to: {repr(e)}')
return
def _mark_ready(self) -> None:
if self.ready.cancelled():
raise GracefulDisconnect('conn establishment was too slow; *ready* future was cancelled')
if self.ready.done():
return
assert self.tip_header
chain = blockchain.check_header(self.tip_header)
if not chain:
self.blockchain = blockchain.get_best_chain()
else:
self.blockchain = chain
assert self.blockchain is not None
self.logger.info(f"set blockchain with height {self.blockchain.height()}")
self.ready.set_result(1)
def is_connected_and_ready(self) -> bool:
return self.ready.done() and not self.got_disconnected.is_set()
async def _save_certificate(self) -> None:
if not os.path.exists(self.cert_path):
# we may need to retry this a few times, in case the handshake hasn't completed
for _ in range(10):
dercert = await self._fetch_certificate()
if dercert:
self.logger.info("succeeded in getting cert")
self._verify_certificate_fingerprint(dercert)
with open(self.cert_path, 'w') as f:
cert = ssl.DER_cert_to_PEM_cert(dercert)
# workaround android bug
cert = re.sub("([^\n])-----END CERTIFICATE-----","\\1\n-----END CERTIFICATE-----",cert)
f.write(cert)
# even though close flushes, we can't fsync when closed.
# and we must flush before fsyncing, cause flush flushes to OS buffer
# fsync writes to OS buffer to disk
f.flush()
os.fsync(f.fileno())
break
await asyncio.sleep(1)
else:
raise GracefulDisconnect("could not get certificate after 10 tries")
async def _fetch_certificate(self) -> bytes:
sslc = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
sslc.check_hostname = False
sslc.verify_mode = ssl.CERT_NONE
async with _RSClient(
session_factory=RPCSession,
host=self.host, port=self.port,
ssl=sslc,
proxy=self.proxy,
transport=PaddedRSTransport,
) as session:
asyncio_transport = session.transport._asyncio_transport # type: asyncio.BaseTransport
ssl_object = asyncio_transport.get_extra_info("ssl_object") # type: ssl.SSLObject
return ssl_object.getpeercert(binary_form=True)
def _get_expected_fingerprint(self) -> Optional[str]:
if self.is_main_server():
return self.network.config.NETWORK_SERVERFINGERPRINT
return None
def _verify_certificate_fingerprint(self, certificate: bytes) -> None:
expected_fingerprint = self._get_expected_fingerprint()
if not expected_fingerprint:
return
fingerprint = hashlib.sha256(certificate).hexdigest()
fingerprints_match = fingerprint.lower() == expected_fingerprint.lower()
if not fingerprints_match:
util.trigger_callback('cert_mismatch')
raise ErrorSSLCertFingerprintMismatch('Refusing to connect to server due to cert fingerprint mismatch')
self.logger.info("cert fingerprint verification passed")
async def _maybe_warm_headers_cache(self, *, from_height: int, to_height: int, mode: ChainResolutionMode) -> None:
"""Populate header cache for block heights in range [from_height, to_height]."""
assert from_height <= to_height, (from_height, to_height)
assert to_height - from_height < MAX_NUM_HEADERS_PER_REQUEST
if all(height in self._headers_cache for height in range(from_height, to_height+1)):
# cache already has all requested headers
return
# use lower timeout as we usually have network.bhi_lock here
timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
count = to_height - from_height + 1
headers = await self.get_block_headers(start_height=from_height, count=count, timeout=timeout, mode=mode)
for idx, raw_header in enumerate(headers):
header_height = from_height + idx
self._headers_cache[header_height] = raw_header
async def get_block_header(self, height: int, *, mode: ChainResolutionMode) -> dict:
if not is_non_negative_integer(height):
raise Exception(f"{repr(height)} is not a block height")
#self.logger.debug(f'get_block_header() {height} in {mode=}')
# use lower timeout as we usually have network.bhi_lock here
timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
if raw_header := self._headers_cache.get(height):
return blockchain.deserialize_header(raw_header, height)
self.logger.info(f'requesting block header {height} in {mode=}')
res = await self.session.send_request('blockchain.block.header', [height], timeout=timeout)
return blockchain.deserialize_header(bytes.fromhex(res), height)
async def get_block_headers(
self,
*,
start_height: int,
count: int,
timeout=None,
mode: Optional[ChainResolutionMode] = None,
) -> Sequence[bytes]:
"""Request a number of consecutive block headers, starting at `start_height`.
`count` is the num of requested headers, BUT note the server might return fewer than this
(if range would extend beyond its tip).
note: the returned headers are not verified or parsed at all.
"""
if not is_non_negative_integer(start_height):
raise Exception(f"{repr(start_height)} is not a block height")
if not is_non_negative_integer(count) or not (0 < count <= MAX_NUM_HEADERS_PER_REQUEST):
raise Exception(f"{repr(count)} not an int in range ]0, {MAX_NUM_HEADERS_PER_REQUEST}]")
self.logger.info(
f"requesting block headers: [{start_height}, {start_height+count-1}], {count=}"
+ (f" (in {mode=})" if mode is not None else "")
)
res = await self.session.send_request('blockchain.block.headers', [start_height, count], timeout=timeout)
# check response
assert_dict_contains_field(res, field_name='count')
assert_dict_contains_field(res, field_name='max')
assert_non_negative_integer(res['count'])
assert_non_negative_integer(res['max'])
if self.active_protocol_tuple >= (1, 6):
hex_headers_list = assert_dict_contains_field(res, field_name='headers')
assert_list_or_tuple(hex_headers_list)
for item in hex_headers_list:
assert_hex_str(item)
if len(item) != HEADER_SIZE * 2:
raise RequestCorrupted(f"invalid header size. got {len(item)//2}, expected {HEADER_SIZE}")
if len(hex_headers_list) != res['count']:
raise RequestCorrupted(f"{len(hex_headers_list)=} != {res['count']=}")
headers = list(bfh(hex_header) for hex_header in hex_headers_list)
else: # proto 1.4
hex_headers_concat = assert_dict_contains_field(res, field_name='hex')
assert_hex_str(hex_headers_concat)
if len(hex_headers_concat) != HEADER_SIZE * 2 * res['count']:
raise RequestCorrupted('inconsistent chunk hex and count')
headers = list(util.chunks(bfh(hex_headers_concat), size=HEADER_SIZE))
# we never request more than MAX_NUM_HEADERS_IN_REQUEST headers, but we enforce those fit in a single response
if res['max'] < MAX_NUM_HEADERS_PER_REQUEST:
raise RequestCorrupted(f"server uses too low 'max' count for block.headers: {res['max']} < {MAX_NUM_HEADERS_PER_REQUEST}")
if res['count'] > count:
raise RequestCorrupted(f"asked for {count} headers but got more: {res['count']}")
elif res['count'] < count:
# we only tolerate getting fewer headers if it is due to reaching the tip
end_height = start_height + res['count'] - 1
if end_height < self.tip: # still below tip. why did server not send more?!
raise RequestCorrupted(
f"asked for {count} headers but got fewer: {res['count']}. ({start_height=}, {self.tip=})")
# checks done.
return headers
async def request_chunk_below_max_checkpoint(
self,
*,
height: int,
) -> None:
if not is_non_negative_integer(height):
raise Exception(f"{repr(height)} is not a block height")
assert height <= constants.net.max_checkpoint(), f"{height=} must be <= cp={constants.net.max_checkpoint()}"
index = height // CHUNK_SIZE
if index in self._requested_chunks:
return None
self.logger.debug(f"requesting chunk from height {height}")
try:
self._requested_chunks.add(index)
headers = await self.get_block_headers(start_height=index * CHUNK_SIZE, count=CHUNK_SIZE)
finally:
self._requested_chunks.discard(index)
conn = self.blockchain.connect_chunk(index, data=b"".join(headers))
if not conn:
raise RequestCorrupted(f"chunk ({index=}, for {height=}) does not connect to blockchain")
return None
async def _fast_forward_chain(
self,
*,
height: int, # usually local chain tip + 1
tip: int, # server tip. we should not request past this.
) -> int:
"""Request some headers starting at `height` to grow the blockchain of this interface.
Returns number of headers we managed to connect, starting at `height`.
"""
if not is_non_negative_integer(height):
raise Exception(f"{repr(height)} is not a block height")
if not is_non_negative_integer(tip):
raise Exception(f"{repr(tip)} is not a block height")
if not (height > constants.net.max_checkpoint()
or height == 0 == constants.net.max_checkpoint()):
raise Exception(f"{height=} must be > cp={constants.net.max_checkpoint()}")
assert height <= tip, f"{height=} must be <= {tip=}"
# Request a few chunks of headers concurrently.
# tradeoffs:
# - more chunks: higher memory requirements
# - more chunks: higher concurrency => syncing needs fewer network round-trips
# - if a chunk does not connect, bandwidth for all later chunks is wasted
async with OldTaskGroup() as group:
tasks = [] # type: List[Tuple[int, asyncio.Task[Sequence[bytes]]]]
index0 = height // CHUNK_SIZE
for chunk_cnt in range(10):
index = index0 + chunk_cnt
start_height = index * CHUNK_SIZE
if start_height > tip:
break
end_height = min(start_height + CHUNK_SIZE - 1, tip)
size = end_height - start_height + 1
tasks.append((index, await group.spawn(self.get_block_headers(start_height=start_height, count=size))))
# try to connect chunks
num_headers = 0
for index, task in tasks:
headers = task.result()
conn = self.blockchain.connect_chunk(index, data=b"".join(headers))
if not conn:
break
num_headers += len(headers)
# We started at a chunk boundary, instead of requested `height`. Need to correct for that.
offset = height - index0 * CHUNK_SIZE
return max(0, num_headers - offset)
def is_main_server(self) -> bool:
return (self.network.interface == self or
self.network.interface is None and self.network.default_server == self.server)
async def open_session(
self,
*,
ssl_context: Optional[ssl.SSLContext],
exit_early: bool = False,
):
session_factory = lambda *args, iface=self, **kwargs: NotificationSession(*args, **kwargs, interface=iface)
async with _RSClient(
session_factory=session_factory,
host=self.host, port=self.port,
ssl=ssl_context,
proxy=self.proxy,
transport=PaddedRSTransport,
) as session:
start = time.perf_counter()
self.session = session # type: NotificationSession
self.session.set_default_timeout(self.network.get_network_timeout_seconds(NetworkTimeout.Generic))
client_prange = [version.PROTOCOL_VERSION_MIN, version.PROTOCOL_VERSION_MAX]
try:
ver = await session.send_request('server.version', [self.client_name(), client_prange])
except aiorpcx.jsonrpc.RPCError as e:
raise GracefulDisconnect(e) # probably 'unsupported protocol version'
if exit_early:
return
self.active_protocol_tuple = protocol_tuple(ver[1])
client_pmin = protocol_tuple(client_prange[0])
client_pmax = protocol_tuple(client_prange[1])
if not (client_pmin <= self.active_protocol_tuple <= client_pmax):
raise GracefulDisconnect(f'server violated protocol-version-negotiation. '
f'we asked for {client_prange!r}, they sent {ver[1]!r}')
if not self.network.check_interface_against_healthy_spread_of_connected_servers(self):
raise GracefulDisconnect(f'too many connected servers already '
f'in bucket {self.bucket_based_on_ipaddress()}')
try:
features = await session.send_request('server.features')
server_genesis_hash = assert_dict_contains_field(features, field_name='genesis_hash')
except (aiorpcx.jsonrpc.RPCError, RequestCorrupted) as e:
raise GracefulDisconnect(e)
if server_genesis_hash != constants.net.GENESIS:
raise GracefulDisconnect(f'server on different chain: {server_genesis_hash=}. ours: {constants.net.GENESIS}')
self.logger.info(f"connection established. version: {ver}, handshake duration: {(time.perf_counter() - start) * 1000:.2f} ms")
try:
async with self.taskgroup as group:
await group.spawn(self.ping)
await group.spawn(self.request_fee_estimates)
await group.spawn(self.run_fetch_blocks)
await group.spawn(self.monitor_connection)
except aiorpcx.jsonrpc.RPCError as e:
if e.code in (
JSONRPC.EXCESSIVE_RESOURCE_USAGE,
JSONRPC.SERVER_BUSY,
JSONRPC.METHOD_NOT_FOUND,
JSONRPC.INTERNAL_ERROR,
):
log_level = logging.WARNING if self.is_main_server() else logging.INFO
raise GracefulDisconnect(e, log_level=log_level) from e
raise
finally:
self.got_disconnected.set() # set this ASAP, ideally before any awaits
async def monitor_connection(self):
while True:
await asyncio.sleep(1)
# If the session/transport is no longer open, we disconnect.
# e.g. if the remote cleanly sends EOF, we would handle that here.
# note: If the user pulls the ethernet cable or disconnects wifi,
# ideally we would detect that here, so that the GUI/etc can reflect that.
# - On Android, this seems to work reliably , where asyncio.BaseProtocol.connection_lost()
# gets called with e.g. ConnectionAbortedError(103, 'Software caused connection abort').
# - On desktop Linux/Win, it seems BaseProtocol.connection_lost() is not called in such cases.
# Hence, in practice the connection issue will only be detected the next time we try
# to send a message (plus timeout), which can take minutes...
if not self.session or self.session.is_closing():
raise GracefulDisconnect('session was closed')
async def ping(self):
# We periodically send a "ping" msg to make sure the server knows we are still here.
# Adding a bit of randomness generates some noise against traffic analysis.
while True:
await asyncio.sleep(random.random() * 300)
await self.session.send_request('server.ping')
await self._maybe_send_noise()
async def _maybe_send_noise(self):
while random.random() < 0.2:
await asyncio.sleep(random.random())
await self.session.send_request('server.ping')
async def request_fee_estimates(self):
while True:
async with OldTaskGroup() as group:
fee_tasks = []
for i in FEE_ETA_TARGETS[0:-1]:
fee_tasks.append((i, await group.spawn(self.get_estimatefee(i))))
for nblock_target, task in fee_tasks:
fee = task.result()
if fee < 0: continue
assert isinstance(fee, int)
self.fee_estimates_eta[nblock_target] = fee
self.network.update_fee_estimates()
await asyncio.sleep(60)
async def close(self, *, force_after: int = None):
"""Closes the connection and waits for it to be closed.
We try to flush buffered data to the wire, which can take some time.
"""
if self.session:
await self.session.close(force_after=force_after)
# monitor_connection will cancel tasks
async def run_fetch_blocks(self):
header_queue = asyncio.Queue()
await self.session.subscribe('blockchain.headers.subscribe', [], header_queue)
while True:
item = await header_queue.get()
raw_header = item[0]
height = raw_header['height']
header_bytes = bfh(raw_header['hex'])
header_dict = blockchain.deserialize_header(header_bytes, height)
self.tip_header = header_dict
self.tip = height
if self.tip < constants.net.max_checkpoint():
raise GracefulDisconnect(
f"server tip below max checkpoint. ({self.tip} < {constants.net.max_checkpoint()})")
self._mark_ready()
self._headers_cache.clear() # tip changed, so assume anything could have happened with chain
self._headers_cache[height] = header_bytes
try:
blockchain_updated = await self._process_header_at_tip()
finally:
self._headers_cache.clear() # to reduce memory usage
# header processing done
if self.is_main_server() or blockchain_updated:
self.logger.info(f"new chain tip. {height=}")
if blockchain_updated:
util.trigger_callback('blockchain_updated')
self._blockchain_updated.set()
self._blockchain_updated.clear()
util.trigger_callback('network_updated')
await self.network.switch_unwanted_fork_interface()
await self.network.switch_lagging_interface()
await self.taskgroup.spawn(self._maybe_send_noise())
async def _process_header_at_tip(self) -> bool:
"""Returns:
False - boring fast-forward: we already have this header as part of this blockchain from another interface,
True - new header we didn't have, or reorg
"""
height, header = self.tip, self.tip_header
async with self.network.bhi_lock:
if self.blockchain.height() >= height and self.blockchain.check_header(header):
# another interface amended the blockchain
return False
await self.sync_until(height)
return True
async def sync_until(
self,
height: int,
*,
next_height: Optional[int] = None, # sync target. typically the tip, except in unit tests
) -> Tuple[ChainResolutionMode, int]:
if next_height is None:
next_height = self.tip
last = None # type: Optional[ChainResolutionMode]
while last is None or height <= next_height:
prev_last, prev_height = last, height
if next_height > height + 144:
# We are far from the tip.
# It is more efficient to process headers in large batches (CPU/disk_usage/logging).
# (but this wastes a little bandwidth, if we are not on a chunk boundary)
num_headers = await self._fast_forward_chain(
height=height, tip=next_height)
if num_headers == 0:
if height <= constants.net.max_checkpoint():
raise GracefulDisconnect('server chain conflicts with checkpoints or genesis')
last, height = await self.step(height)
continue
# report progress to gui/etc
util.trigger_callback('blockchain_updated')
self._blockchain_updated.set()
self._blockchain_updated.clear()
util.trigger_callback('network_updated')
height += num_headers
assert height <= next_height+1, (height, self.tip)
last = ChainResolutionMode.CATCHUP
else:
# We are close to the tip, so process headers one-by-one.
# (note: due to headers_cache, to save network latency, this can still batch-request headers)
last, height = await self.step(height)
assert (prev_last, prev_height) != (last, height), 'had to prevent infinite loop in interface.sync_until'
return last, height
async def step(
self,
height: int,
) -> Tuple[ChainResolutionMode, int]:
assert 0 <= height <= self.tip, (height, self.tip)
await self._maybe_warm_headers_cache(
from_height=height,
to_height=min(self.tip, height+MAX_NUM_HEADERS_PER_REQUEST-1),
mode=ChainResolutionMode.CATCHUP,
)
header = await self.get_block_header(height, mode=ChainResolutionMode.CATCHUP)
chain = blockchain.check_header(header)
if chain:
self.blockchain = chain
# note: there is an edge case here that is not handled.
# we might know the blockhash (enough for check_header) but
# not have the header itself. e.g. regtest chain with only genesis.
# this situation resolves itself on the next block
return ChainResolutionMode.CATCHUP, height+1
can_connect = blockchain.can_connect(header)
if not can_connect:
self.logger.info(f"can't connect new block: {height=}")
height, header, bad, bad_header = await self._search_headers_backwards(height, header=header)
chain = blockchain.check_header(header)
can_connect = blockchain.can_connect(header)
assert chain or can_connect
if can_connect:
height += 1
self.blockchain = can_connect
self.blockchain.save_header(header)
return ChainResolutionMode.CATCHUP, height
good, bad, bad_header = await self._search_headers_binary(height, bad, bad_header, chain)
return await self._resolve_potential_chain_fork_given_forkpoint(good, bad, bad_header)
async def _search_headers_binary(
self,
height: int,
bad: int,
bad_header: dict,
chain: Optional[Blockchain],
) -> Tuple[int, int, dict]:
assert bad == bad_header['block_height']
_assert_header_does_not_check_against_any_chain(bad_header)
self.blockchain = chain
good = height
while True:
assert 0 <= good < bad, (good, bad)
height = (good + bad) // 2
self.logger.info(f"binary step. good {good}, bad {bad}, height {height}")
if bad - good + 1 <= MAX_NUM_HEADERS_PER_REQUEST: # if interval is small, trade some bandwidth for lower latency
await self._maybe_warm_headers_cache(
from_height=good, to_height=bad, mode=ChainResolutionMode.BINARY)
header = await self.get_block_header(height, mode=ChainResolutionMode.BINARY)
chain = blockchain.check_header(header)
if chain:
self.blockchain = chain
good = height
else:
bad = height
bad_header = header
if good + 1 == bad:
break
if not self.blockchain.can_connect(bad_header, check_height=False):
raise Exception('unexpected bad header during binary: {}'.format(bad_header))
_assert_header_does_not_check_against_any_chain(bad_header)
self.logger.info(f"binary search exited. good {good}, bad {bad}. {chain=}")
return good, bad, bad_header
async def _resolve_potential_chain_fork_given_forkpoint(
self,
good: int,
bad: int,
bad_header: dict,
) -> Tuple[ChainResolutionMode, int]:
assert good + 1 == bad
assert bad == bad_header['block_height']
_assert_header_does_not_check_against_any_chain(bad_header)
# 'good' is the height of a block 'good_header', somewhere in self.blockchain.
# bad_header connects to good_header; bad_header itself is NOT in self.blockchain.
bh = self.blockchain.height()
assert bh >= good, (bh, good)
if bh == good:
height = good + 1
self.logger.info(f"catching up from {height}")
return ChainResolutionMode.NO_FORK, height
# this is a new fork we don't yet have
height = bad + 1
self.logger.info(f"new fork at bad height {bad}")
b = self.blockchain.fork(bad_header) # type: Blockchain
self.blockchain = b
assert b.forkpoint == bad
return ChainResolutionMode.FORK, height
async def _search_headers_backwards(
self,
height: int,
*,
header: dict,
) -> Tuple[int, dict, int, dict]:
async def iterate():
nonlocal height, header
checkp = False
if height <= constants.net.max_checkpoint():
height = constants.net.max_checkpoint()
checkp = True
header = await self.get_block_header(height, mode=ChainResolutionMode.BACKWARD)
chain = blockchain.check_header(header)
can_connect = blockchain.can_connect(header)
if chain or can_connect:
return False
if checkp:
raise GracefulDisconnect("server chain conflicts with checkpoints")
return True
bad, bad_header = height, header
_assert_header_does_not_check_against_any_chain(bad_header)
with blockchain.blockchains_lock: chains = list(blockchain.blockchains.values())
local_max = max([0] + [x.height() for x in chains])
height = min(local_max + 1, height - 1)
assert height >= 0
await self._maybe_warm_headers_cache(
from_height=max(0, height-10), to_height=height, mode=ChainResolutionMode.BACKWARD)
delta = 2
while await iterate():
bad, bad_header = height, header
height -= delta
delta *= 2
_assert_header_does_not_check_against_any_chain(bad_header)
self.logger.info(f"exiting backward mode at {height}")
return height, header, bad, bad_header
@classmethod
def client_name(cls) -> str:
return f'electrum/{version.ELECTRUM_VERSION}'
def is_tor(self):
return self.host.endswith('.onion')
def ip_addr(self) -> Optional[str]:
session = self.session
if not session: return None
peer_addr = session.remote_address()
if not peer_addr: return None
return str(peer_addr.host)
def bucket_based_on_ipaddress(self) -> str:
def do_bucket():
if self.is_tor():
return BUCKET_NAME_OF_ONION_SERVERS
try:
ip_addr = ip_address(self.ip_addr()) # type: Union[IPv4Address, IPv6Address]
except ValueError:
return ''
if not ip_addr:
return ''
if ip_addr.is_loopback: # localhost is exempt
return ''
if ip_addr.version == 4:
slash16 = IPv4Network(ip_addr).supernet(prefixlen_diff=32-16)
return str(slash16)
elif ip_addr.version == 6:
slash48 = IPv6Network(ip_addr).supernet(prefixlen_diff=128-48)
return str(slash48)
return ''
if not self._ipaddr_bucket:
self._ipaddr_bucket = do_bucket()
return self._ipaddr_bucket
async def get_merkle_for_transaction(self, tx_hash: str, tx_height: int) -> dict:
if not is_hash256_str(tx_hash):
raise Exception(f"{repr(tx_hash)} is not a txid")
if not is_non_negative_integer(tx_height):
raise Exception(f"{repr(tx_height)} is not a block height")
# do request
res = await self.session.send_request('blockchain.transaction.get_merkle', [tx_hash, tx_height])
# check response
block_height = assert_dict_contains_field(res, field_name='block_height')
merkle = assert_dict_contains_field(res, field_name='merkle')
pos = assert_dict_contains_field(res, field_name='pos')
# note: tx_height was just a hint to the server, don't enforce the response to match it
assert_non_negative_integer(block_height)
assert_non_negative_integer(pos)
assert_list_or_tuple(merkle)
for item in merkle:
assert_hash256_str(item)
return res
async def get_transaction(self, tx_hash: str, *, timeout=None) -> str:
if not is_hash256_str(tx_hash):
raise Exception(f"{repr(tx_hash)} is not a txid")
if rawtx_bytes := self._rawtx_cache.get(tx_hash):
return rawtx_bytes.hex()
raw = await self.session.send_request('blockchain.transaction.get', [tx_hash], timeout=timeout)
# validate response
if not is_hex_str(raw):
raise RequestCorrupted(f"received garbage (non-hex) as tx data (txid {tx_hash}): {raw!r}")
tx = Transaction(raw)
try:
tx.deserialize() # see if raises
except Exception as e:
raise RequestCorrupted(f"cannot deserialize received transaction (txid {tx_hash})") from e
if tx.txid() != tx_hash:
raise RequestCorrupted(f"received tx does not match expected txid {tx_hash} (got {tx.txid()})")
self._rawtx_cache[tx_hash] = bytes.fromhex(raw)
return raw
async def broadcast_transaction(self, tx: 'Transaction', *, timeout=None) -> None:
"""caller should handle TxBroadcastError and RequestTimedOut"""
txid_calc = tx.txid()
assert txid_calc is not None
rawtx = tx.serialize()
assert is_hex_str(rawtx)
if timeout is None:
timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
if any(DummyAddress.is_dummy_address(txout.address) for txout in tx.outputs()):
raise DummyAddressUsedInTxException("tried to broadcast tx with dummy address!")
try:
out = await self.session.send_request('blockchain.transaction.broadcast', [rawtx], timeout=timeout)
# note: both 'out' and exception messages are untrusted input from the server
except (RequestTimedOut, asyncio.CancelledError, asyncio.TimeoutError):
raise # pass-through
except aiorpcx.jsonrpc.CodeMessageError as e:
self.logger.info(f"broadcast_transaction error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
raise TxBroadcastServerReturnedError(sanitize_tx_broadcast_response(e.message)) from e
except BaseException as e: # intentional BaseException for sanity!
self.logger.info(f"broadcast_transaction error2 [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
send_exception_to_crash_reporter(e)
raise TxBroadcastUnknownError() from e
if out != txid_calc:
self.logger.info(f"unexpected txid for broadcast_transaction [DO NOT TRUST THIS MESSAGE]: "
f"{error_text_str_to_safe_str(out)} != {txid_calc}. tx={str(tx)}")
raise TxBroadcastHashMismatch(_("Server returned unexpected transaction ID."))
# broadcast succeeded.
# We now cache the rawtx, for *this interface only*. The tx likely touches some ismine addresses, affecting
# the status of a scripthash we are subscribed to. Caching here will save a future get_transaction RPC.
self._rawtx_cache[txid_calc] = bytes.fromhex(rawtx)
async def broadcast_txpackage(self, txs: Sequence['Transaction']) -> bool:
assert self.active_protocol_tuple >= (1, 6), f"server using old protocol: {self.active_protocol_tuple}"
rawtxs = [tx.serialize() for tx in txs]
assert all(is_hex_str(rawtx) for rawtx in rawtxs)
assert all(tx.txid() is not None for tx in txs)
timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
for tx in txs:
if any(DummyAddress.is_dummy_address(txout.address) for txout in tx.outputs()):
raise DummyAddressUsedInTxException("tried to broadcast tx with dummy address!")
try:
res = await self.session.send_request('blockchain.transaction.broadcast_package', [rawtxs], timeout=timeout)
except aiorpcx.jsonrpc.CodeMessageError as e:
self.logger.info(f"broadcast_txpackage error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. {rawtxs=}")
return False
success = assert_dict_contains_field(res, field_name='success')
if not success:
errors = assert_dict_contains_field(res, field_name='errors')
self.logger.info(f"broadcast_txpackage error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(errors))}. {rawtxs=}")
return False
assert success
# broadcast succeeded.
# We now cache the rawtx, for *this interface only*. The tx likely touches some ismine addresses, affecting
# the status of a scripthash we are subscribed to. Caching here will save a future get_transaction RPC.
for tx, rawtx in zip(txs, rawtxs):
self._rawtx_cache[tx.txid()] = bytes.fromhex(rawtx)
return True
async def get_history_for_scripthash(self, sh: str) -> List[dict]:
if not is_hash256_str(sh):
raise Exception(f"{repr(sh)} is not a scripthash")
# do request
res = await self.session.send_request('blockchain.scripthash.get_history', [sh])
# check response
assert_list_or_tuple(res)
prev_height = 1
for tx_item in res:
height = assert_dict_contains_field(tx_item, field_name='height')
assert_dict_contains_field(tx_item, field_name='tx_hash')
assert_integer(height)
if height < -1:
raise RequestCorrupted(f'{height!r} is not a valid block height')
assert_hash256_str(tx_item['tx_hash'])
if height in (-1, 0):
assert_dict_contains_field(tx_item, field_name='fee')
assert_non_negative_integer(tx_item['fee'])
prev_height = float("inf") # this ensures confirmed txs can't follow mempool txs
else:
# check monotonicity of heights
if height < prev_height:
raise RequestCorrupted(f'heights of confirmed txs must be in increasing order')
prev_height = height
if self.active_protocol_tuple >= (1, 6):
# enforce order of mempool txs
mempool_txs = [tx_item for tx_item in res if tx_item['height'] <= 0]
if mempool_txs != sorted(mempool_txs, key=lambda x: (-x['height'], bytes.fromhex(x['tx_hash']))):
raise RequestCorrupted(f'mempool txs not in canonical order')
hashes = set(map(lambda item: item['tx_hash'], res))
if len(hashes) != len(res):
# Either server is sending garbage... or maybe if server is race-prone
# a recently mined tx could be included in both last block and mempool?
# Still, it's simplest to just disregard the response.
raise RequestCorrupted(f"server history has non-unique txids for sh={sh}")
return res
async def listunspent_for_scripthash(self, sh: str) -> List[dict]:
if not is_hash256_str(sh):
raise Exception(f"{repr(sh)} is not a scripthash")
# do request
res = await self.session.send_request('blockchain.scripthash.listunspent', [sh])
# check response
assert_list_or_tuple(res)
for utxo_item in res:
assert_dict_contains_field(utxo_item, field_name='tx_pos')
assert_dict_contains_field(utxo_item, field_name='value')
assert_dict_contains_field(utxo_item, field_name='tx_hash')
assert_dict_contains_field(utxo_item, field_name='height')
assert_non_negative_integer(utxo_item['tx_pos'])
assert_non_negative_integer(utxo_item['value'])
assert_non_negative_integer(utxo_item['height'])
assert_hash256_str(utxo_item['tx_hash'])
return res
async def get_balance_for_scripthash(self, sh: str) -> dict:
if not is_hash256_str(sh):
raise Exception(f"{repr(sh)} is not a scripthash")
# do request
res = await self.session.send_request('blockchain.scripthash.get_balance', [sh])
# check response
assert_dict_contains_field(res, field_name='confirmed')
assert_dict_contains_field(res, field_name='unconfirmed')
assert_non_negative_integer(res['confirmed'])
assert_integer(res['unconfirmed'])
return res
async def get_txid_from_txpos(self, tx_height: int, tx_pos: int, merkle: bool):
if not is_non_negative_integer(tx_height):
raise Exception(f"{repr(tx_height)} is not a block height")
if not is_non_negative_integer(tx_pos):
raise Exception(f"{repr(tx_pos)} should be non-negative integer")
# do request
res = await self.session.send_request(
'blockchain.transaction.id_from_pos',
[tx_height, tx_pos, merkle],
)
# check response
if merkle:
assert_dict_contains_field(res, field_name='tx_hash')
assert_dict_contains_field(res, field_name='merkle')
assert_hash256_str(res['tx_hash'])
assert_list_or_tuple(res['merkle'])
for node_hash in res['merkle']:
assert_hash256_str(node_hash)
else:
assert_hash256_str(res)
return res
async def get_fee_histogram(self) -> Sequence[Tuple[Union[float, int], int]]:
# do request
res = await self.session.send_request('mempool.get_fee_histogram')
# check response
assert_list_or_tuple(res)
prev_fee = float('inf')
for fee, s in res:
assert_non_negative_int_or_float(fee)
assert_non_negative_integer(s)
if fee >= prev_fee: # check monotonicity
raise RequestCorrupted(f'fees must be in decreasing order')
prev_fee = fee
return res
async def get_server_banner(self) -> str:
# do request
res = await self.session.send_request('server.banner')
# check response
if not isinstance(res, str):
raise RequestCorrupted(f'{res!r} should be a str')
return res
async def get_donation_address(self) -> str:
# do request
res = await self.session.send_request('server.donation_address')
# check response
if not res: # ignore empty string
return ''
if not isinstance(res, str):
raise RequestCorrupted(f'{res!r} should be a str')
address = res.removeprefix('bitcoin:')
if not bitcoin.is_address(address):
# note: do not hard-fail -- allow server to use future-type
# bitcoin address we do not recognize
self.logger.info(f"invalid donation address from server: {repr(res)}")
return ''
return address
async def get_relay_fee(self) -> int:
"""Returns the min relay feerate in sat/kbyte."""
# do request
if self.active_protocol_tuple >= (1, 6):
res = await self.session.send_request('mempool.get_info')
minrelaytxfee = assert_dict_contains_field(res, field_name='minrelaytxfee')
else:
minrelaytxfee = await self.session.send_request('blockchain.relayfee')
# check response
assert_non_negative_int_or_float(minrelaytxfee)
relayfee = int(minrelaytxfee * bitcoin.COIN)
relayfee = max(0, relayfee)
return relayfee
async def get_estimatefee(self, num_blocks: int) -> int:
"""Returns a feerate estimate for getting confirmed within
num_blocks blocks, in sat/kbyte.
Returns -1 if the server could not provide an estimate.
"""
if not is_non_negative_integer(num_blocks):
raise Exception(f"{repr(num_blocks)} is not a num_blocks")
# do request
try:
res = await self.session.send_request('blockchain.estimatefee', [num_blocks])
except aiorpcx.jsonrpc.ProtocolError as e:
# The protocol spec says the server itself should already have returned -1
# if it cannot provide an estimate, however apparently "electrs" does not conform
# and sends an error instead. Convert it here:
if "cannot estimate fee" in e.message:
res = -1
else:
raise
except aiorpcx.jsonrpc.RPCError as e:
# The protocol spec says the server itself should already have returned -1
# if it cannot provide an estimate. "Fulcrum" often sends:
# aiorpcx.jsonrpc.RPCError: (-32603, 'internal error: bitcoind request timed out')
if e.code == JSONRPC.INTERNAL_ERROR:
res = -1
else:
raise
# check response
if res != -1:
assert_non_negative_int_or_float(res)
res = int(res * bitcoin.COIN)
return res
def _assert_header_does_not_check_against_any_chain(header: dict) -> None:
chain_bad = blockchain.check_header(header)
if chain_bad:
raise Exception('bad_header must not check!')
def sanitize_tx_broadcast_response(server_msg) -> str:
# Unfortunately, bitcoind and hence the Electrum protocol doesn't return a useful error code.
# So, we use substring matching to grok the error message.
# server_msg is untrusted input so it should not be shown to the user. see #4968
server_msg = str(server_msg)
server_msg = server_msg.replace("\n", r"\n")
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/script/script_error.cpp
script_error_messages = {
r"Script evaluated without error but finished with a false/empty top stack element",
r"Script failed an OP_VERIFY operation",
r"Script failed an OP_EQUALVERIFY operation",
r"Script failed an OP_CHECKMULTISIGVERIFY operation",
r"Script failed an OP_CHECKSIGVERIFY operation",
r"Script failed an OP_NUMEQUALVERIFY operation",
r"Script is too big",
r"Push value size limit exceeded",
r"Operation limit exceeded",
r"Stack size limit exceeded",
r"Signature count negative or greater than pubkey count",
r"Pubkey count negative or limit exceeded",
r"Opcode missing or not understood",
r"Attempted to use a disabled opcode",
r"Operation not valid with the current stack size",
r"Operation not valid with the current altstack size",
r"OP_RETURN was encountered",
r"Invalid OP_IF construction",
r"Negative locktime",
r"Locktime requirement not satisfied",
r"Signature hash type missing or not understood",
r"Non-canonical DER signature",
r"Data push larger than necessary",
r"Only push operators allowed in signatures",
r"Non-canonical signature: S value is unnecessarily high",
r"Dummy CHECKMULTISIG argument must be zero",
r"OP_IF/NOTIF argument must be minimal",
r"Signature must be zero for failed CHECK(MULTI)SIG operation",
r"NOPx reserved for soft-fork upgrades",
r"Witness version reserved for soft-fork upgrades",
r"Taproot version reserved for soft-fork upgrades",
r"OP_SUCCESSx reserved for soft-fork upgrades",
r"Public key version reserved for soft-fork upgrades",
r"Public key is neither compressed or uncompressed",
r"Stack size must be exactly one after execution",
r"Extra items left on stack after execution",
r"Witness program has incorrect length",
r"Witness program was passed an empty witness",
r"Witness program hash mismatch",
r"Witness requires empty scriptSig",
r"Witness requires only-redeemscript scriptSig",
r"Witness provided for non-witness script",
r"Using non-compressed keys in segwit",
r"Invalid Schnorr signature size",
r"Invalid Schnorr signature hash type",
r"Invalid Schnorr signature",
r"Invalid Taproot control block size",
r"Too much signature validation relative to witness weight",
r"OP_CHECKMULTISIG(VERIFY) is not available in tapscript",
r"OP_IF/NOTIF argument must be minimal in tapscript",
r"Using OP_CODESEPARATOR in non-witness script",
r"Signature is found in scriptCode",
}
for substring in script_error_messages:
if substring in server_msg:
return substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/validation.cpp
# grep "REJECT_"
# grep "TxValidationResult"
# should come after script_error.cpp (due to e.g. "non-mandatory-script-verify-flag")
validation_error_messages = {
r"coinbase": None,
r"tx-size-small": None,
r"non-final": None,
r"txn-already-in-mempool": None,
r"txn-mempool-conflict": None,
r"txn-already-known": None,
r"non-BIP68-final": None,
r"bad-txns-nonstandard-inputs": None,
r"bad-witness-nonstandard": None,
r"bad-txns-too-many-sigops": None,
r"mempool min fee not met":
("mempool min fee not met\n" +
_("Your transaction is paying a fee that is so low that the bitcoin node cannot "
"fit it into its mempool. The mempool is already full of hundreds of megabytes "
"of transactions that all pay higher fees. Try to increase the fee.")),
r"min relay fee not met": None,
r"absurdly-high-fee": None,
r"max-fee-exceeded": None,
r"too-long-mempool-chain": None,
r"bad-txns-spends-conflicting-tx": None,
r"insufficient fee": ("insufficient fee\n" +
_("Your transaction is trying to replace another one in the mempool but it "
"does not meet the rules to do so. Try to increase the fee.")),
r"too many potential replacements": None,
r"replacement-adds-unconfirmed": None,
r"mempool full": None,
r"non-mandatory-script-verify-flag": None,
r"mandatory-script-verify-flag-failed": None,
r"Transaction check failed": None,
}
for substring in validation_error_messages:
if substring in server_msg:
msg = validation_error_messages[substring]
return msg if msg else substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/rpc/rawtransaction.cpp
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/util/error.cpp
# https://github.com/bitcoin/bitcoin/blob/3f83c744ac28b700090e15b5dda2260724a56f49/src/common/messages.cpp#L126
# grep "RPC_TRANSACTION"
# grep "RPC_DESERIALIZATION_ERROR"
# grep "TransactionError"
rawtransaction_error_messages = {
r"Missing inputs": None,
r"Inputs missing or spent": None,
r"transaction already in block chain": None,
r"Transaction already in block chain": None,
r"Transaction outputs already in utxo set": None,
r"TX decode failed": None,
r"Peer-to-peer functionality missing or disabled": None,
r"Transaction rejected by AcceptToMemoryPool": None,
r"AcceptToMemoryPool failed": None,
r"Transaction rejected by mempool": None,
r"Mempool internal error": None,
r"Fee exceeds maximum configured by user": None,
r"Unspendable output exceeds maximum configured by user": None,
r"Transaction rejected due to invalid package": None,
}
for substring in rawtransaction_error_messages:
if substring in server_msg:
msg = rawtransaction_error_messages[substring]
return msg if msg else substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/consensus/tx_verify.cpp
# https://github.com/bitcoin/bitcoin/blob/c7ad94428ab6f54661d7a5441e1fdd0ebf034903/src/consensus/tx_check.cpp
# grep "REJECT_"
# grep "TxValidationResult"
tx_verify_error_messages = {
r"bad-txns-vin-empty": None,
r"bad-txns-vout-empty": None,
r"bad-txns-oversize": None,
r"bad-txns-vout-negative": None,
r"bad-txns-vout-toolarge": None,
r"bad-txns-txouttotal-toolarge": None,
r"bad-txns-inputs-duplicate": None,
r"bad-cb-length": None,
r"bad-txns-prevout-null": None,
r"bad-txns-inputs-missingorspent":
("bad-txns-inputs-missingorspent\n" +
_("You might have a local transaction in your wallet that this transaction "
"builds on top. You need to either broadcast or remove the local tx.")),
r"bad-txns-premature-spend-of-coinbase": None,
r"bad-txns-inputvalues-outofrange": None,
r"bad-txns-in-belowout": None,
r"bad-txns-fee-outofrange": None,
}
for substring in tx_verify_error_messages:
if substring in server_msg:
msg = tx_verify_error_messages[substring]
return msg if msg else substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/policy/policy.cpp
# grep "reason ="
# should come after validation.cpp (due to "tx-size" vs "tx-size-small")
# should come after script_error.cpp (due to e.g. "version")
policy_error_messages = {
r"version": _("Transaction uses non-standard version."),
r"tx-size": _("The transaction was rejected because it is too large (in bytes)."),
r"scriptsig-size": None,
r"scriptsig-not-pushonly": None,
r"scriptpubkey":
("scriptpubkey\n" +
_("Some of the outputs pay to a non-standard script.")),
r"bare-multisig": None,
r"dust":
(_("Transaction could not be broadcast due to dust outputs.\n"
"Some of the outputs are too small in value, probably lower than 1000 satoshis.\n"
"Check the units, make sure you haven't confused e.g. mBTC and BTC.")),
r"multi-op-return": _("The transaction was rejected because it contains multiple OP_RETURN outputs."),
}
for substring in policy_error_messages:
if substring in server_msg:
msg = policy_error_messages[substring]
return msg if msg else substring
# otherwise:
return _("Unknown error")
def check_cert(host, cert):
try:
b = pem.dePem(cert, 'CERTIFICATE')
x = x509.X509(b)
except Exception:
traceback.print_exc(file=sys.stdout)
return
try:
x.check_date()
expired = False
except Exception:
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()
================================================
FILE: electrum/invoices.py
================================================
import time
from typing import TYPE_CHECKING, List, Optional, Union, Dict, Any, Sequence
from decimal import Decimal
import attr
from .json_db import StoredObject, stored_in
from .i18n import _
from .util import age, InvoiceError, format_satoshis
from .bip21 import create_bip21_uri
from .lnutil import hex_to_bytes
from .lnaddr import lndecode, LnAddr
from . import constants
from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from .bitcoin import address_to_script
from .transaction import PartialTxOutput
from .crypto import sha256d
if TYPE_CHECKING:
from .paymentrequest import PaymentRequest
# convention: 'invoices' = outgoing , 'request' = incoming
# status of payment requests
PR_UNPAID = 0 # if onchain: invoice amt not reached by txs in mempool+chain. if LN: invoice not paid.
PR_EXPIRED = 1 # invoice is unpaid and expiry time reached
PR_UNKNOWN = 2 # e.g. invoice not found
PR_PAID = 3 # if onchain: paid and mined (1 conf). if LN: invoice is paid.
PR_INFLIGHT = 4 # only for LN. payment attempt in progress
PR_FAILED = 5 # only for LN. we attempted to pay it, but all attempts failed
PR_ROUTING = 6 # only for LN. *unused* atm.
PR_UNCONFIRMED = 7 # only onchain. invoice is satisfied but tx is not mined yet.
PR_BROADCASTING = 8 # onchain, tx is being broadcast
PR_BROADCAST = 9 # onchain, tx was broadcast, is not yet in our history
pr_color = {
PR_UNPAID: (.7, .7, .7, 1),
PR_PAID: (.2, .9, .2, 1),
PR_UNKNOWN: (.7, .7, .7, 1),
PR_EXPIRED: (.9, .2, .2, 1),
PR_INFLIGHT: (.9, .6, .3, 1),
PR_FAILED: (.9, .2, .2, 1),
PR_ROUTING: (.9, .6, .3, 1),
PR_BROADCASTING: (.9, .6, .3, 1),
PR_BROADCAST: (.9, .6, .3, 1),
PR_UNCONFIRMED: (.9, .6, .3, 1),
}
def pr_tooltips():
return {
PR_UNPAID: _('Unpaid'),
PR_PAID: _('Paid'),
PR_UNKNOWN: _('Unknown'),
PR_EXPIRED: _('Expired'),
PR_INFLIGHT: _('In progress'),
PR_BROADCASTING: _('Broadcasting'),
PR_BROADCAST: _('Broadcast successfully'),
PR_FAILED: _('Failed'),
PR_ROUTING: _('Computing route...'),
PR_UNCONFIRMED: _('Unconfirmed'),
}
def pr_expiration_values():
return {
0: _('Never'),
10*60: _('10 minutes'),
60*60: _('1 hour'),
24*60*60: _('1 day'),
7*24*60*60: _('1 week'),
}
PR_DEFAULT_EXPIRATION_WHEN_CREATING = 24*60*60 # 1 day
assert PR_DEFAULT_EXPIRATION_WHEN_CREATING in pr_expiration_values()
def _decode_outputs(outputs) -> Optional[List[PartialTxOutput]]:
if outputs is None:
return None
ret = []
for output in outputs:
if not isinstance(output, PartialTxOutput):
output = PartialTxOutput.from_legacy_tuple(*output)
ret.append(output)
return ret
# hack: BOLT-11 is not really clear on what an expiry of 0 means.
# It probably interprets it as 0 seconds, so already expired...
# Our higher level invoices code however uses 0 for "never".
# Hence set some high expiration here
LN_EXPIRY_NEVER = 100 * 365 * 24 * 60 * 60 # 100 years
@attr.s
class BaseInvoice(StoredObject):
"""
Base class for Invoice and Request
In the code, we use 'invoice' for outgoing payments, and 'request' for incoming payments.
TODO this class is getting too complicated for "attrs"... maybe we should rewrite it without.
"""
# mandatory fields
amount_msat = attr.ib( # can be '!' or None
kw_only=True, on_setattr=attr.setters.validate) # type: Optional[Union[int, str]]
message = attr.ib(type=str, kw_only=True)
time = attr.ib( # timestamp of the invoice
type=int, kw_only=True, validator=attr.validators.instance_of(int), on_setattr=attr.setters.validate)
exp = attr.ib( # expiration delay (relative). 0 means never
type=int, kw_only=True, validator=attr.validators.instance_of(int), on_setattr=attr.setters.validate)
# optional fields.
# an request (incoming) can be satisfied onchain, using lightning or using a swap
# an invoice (outgoing) is constructed from a source: bip21, bip70, lnaddr
# onchain only
outputs = attr.ib(kw_only=True, converter=_decode_outputs) # type: Optional[List[PartialTxOutput]]
height = attr.ib( # only for receiving
type=int, kw_only=True, validator=attr.validators.instance_of(int), on_setattr=attr.setters.validate)
bip70 = attr.ib(type=str, kw_only=True) # type: Optional[str]
#bip70_requestor = attr.ib(type=str, kw_only=True) # type: Optional[str]
def is_lightning(self) -> bool:
raise NotImplementedError()
def get_address(self) -> Optional[str]:
"""returns the first address, to be displayed in GUI"""
raise NotImplementedError()
@property
def rhash(self) -> str:
raise NotImplementedError()
def get_status_str(self, status):
status_str = pr_tooltips()[status]
if status == PR_UNPAID:
if self.exp > 0 and self.exp != LN_EXPIRY_NEVER:
expiration = self.get_expiration_date()
status_str = _('Expires') + ' ' + age(expiration, include_seconds=True)
return status_str
def get_outputs(self) -> Sequence[PartialTxOutput]:
outputs = self.outputs or []
if not outputs:
address = self.get_address()
amount = self.get_amount_sat()
if address and amount is not None:
outputs = [PartialTxOutput.from_address_and_value(address, int(amount))]
return outputs
def get_expiration_date(self):
# 0 means never
return self.exp + self.time if self.exp else 0
@staticmethod
def _get_cur_time(): # for unit tests
return time.time()
def has_expired(self) -> bool:
exp = self.get_expiration_date()
return bool(exp) and exp < self._get_cur_time()
def get_amount_msat(self) -> Union[int, str, None]:
return self.amount_msat
def get_time(self):
return self.time
def get_message(self):
return self.message
def get_amount_sat(self) -> Union[int, str, None]:
"""
Returns an integer satoshi amount, or '!' or None.
Callers who need msat precision should call get_amount_msat()
"""
amount_msat = self.amount_msat
if amount_msat in [None, "!"]:
return amount_msat
return int(amount_msat // 1000)
def set_amount_msat(self, amount_msat: Union[int, str]) -> None:
"""The GUI uses this to fill the amount for a zero-amount invoice."""
if amount_msat == "!":
amount_sat = amount_msat
else:
assert isinstance(amount_msat, int), f"{amount_msat=!r}"
assert amount_msat >= 0, amount_msat
amount_sat = (amount_msat // 1000) + int(amount_msat % 1000 > 0) # round up
if outputs := self.outputs:
assert len(self.outputs) == 1, len(self.outputs)
self.outputs = [PartialTxOutput(scriptpubkey=outputs[0].scriptpubkey, value=amount_sat)]
self.amount_msat = amount_msat
@amount_msat.validator
def _validate_amount(self, attribute, value):
if value is None:
return
if isinstance(value, int):
if not (0 <= value <= TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN * 1000):
raise InvoiceError(f"amount is out-of-bounds: {value!r} msat")
elif isinstance(value, str):
if value != '!':
raise InvoiceError(f"unexpected amount: {value!r}")
else:
raise InvoiceError(f"unexpected amount: {value!r}")
@classmethod
def from_bech32(cls, invoice: str) -> 'Invoice':
"""Constructs Invoice object from BOLT-11 string.
Might raise InvoiceError.
"""
try:
lnaddr = lndecode(invoice)
except Exception as e:
raise InvoiceError(e) from e
amount_msat = lnaddr.get_amount_msat()
timestamp = lnaddr.date
exp_delay = lnaddr.get_expiry()
message = lnaddr.get_description()
return Invoice(
message=message,
amount_msat=amount_msat,
time=timestamp,
exp=exp_delay,
outputs=None,
bip70=None,
height=0,
lightning_invoice=invoice,
)
@classmethod
def from_bip70_payreq(cls, pr: 'PaymentRequest', *, height: int = 0) -> 'Invoice':
return Invoice(
amount_msat=pr.get_amount()*1000,
message=pr.get_memo(),
time=pr.get_time(),
exp=pr.get_expiration_date() - pr.get_time(),
outputs=pr.get_outputs(),
bip70=pr.raw.hex(),
height=height,
lightning_invoice=None,
)
def get_id(self) -> str:
if self.is_lightning():
return self.rhash
else: # on-chain
return get_id_from_onchain_outputs(outputs=self.get_outputs(), timestamp=self.time)
def as_dict(self, status):
d = {
'is_lightning': self.is_lightning(),
'amount_BTC': format_satoshis(self.get_amount_sat()),
'message': self.message,
'timestamp': self.get_time(),
'expiry': self.exp,
'status': status,
'status_str': self.get_status_str(status),
'id': self.get_id(),
'amount_sat': self.get_amount_sat(),
}
if self.is_lightning():
d['amount_msat'] = self.get_amount_msat()
return d
@stored_in('invoices')
@attr.s
class Invoice(BaseInvoice):
lightning_invoice = attr.ib(type=str, kw_only=True) # type: Optional[str]
__lnaddr = None
_broadcasting_status = None # can be None or PR_BROADCASTING or PR_BROADCAST
def is_lightning(self):
return self.lightning_invoice is not None
def get_broadcasting_status(self):
return self._broadcasting_status
def get_address(self) -> Optional[str]:
address = None
if self.outputs:
address = self.outputs[0].address if len(self.outputs) > 0 else None
if not address and self.is_lightning():
address = self._lnaddr.get_fallback_address() or None
return address
@property
def _lnaddr(self) -> LnAddr:
if self.__lnaddr is None:
self.__lnaddr = lndecode(self.lightning_invoice)
return self.__lnaddr
@property
def rhash(self) -> str:
assert self.is_lightning()
return self._lnaddr.paymenthash.hex()
@lightning_invoice.validator
def _validate_invoice_str(self, attribute, value):
if value is not None:
lnaddr = lndecode(value) # this checks the str can be decoded
self.__lnaddr = lnaddr # save it, just to avoid having to recompute later
def can_be_paid_onchain(self) -> bool:
if self.is_lightning():
return bool(self._lnaddr.get_fallback_address()) or (bool(self.outputs))
else:
return True
def to_debug_json(self) -> Dict[str, Any]:
d = self.to_json()
d["lnaddr"] = self._lnaddr.to_debug_json()
return d
@stored_in('payment_requests')
@attr.s
class Request(BaseInvoice):
payment_hash = attr.ib(type=bytes, kw_only=True, converter=hex_to_bytes) # type: Optional[bytes]
def is_lightning(self):
return self.payment_hash is not None
def get_address(self) -> Optional[str]:
address = None
if self.outputs:
address = self.outputs[0].address if len(self.outputs) > 0 else None
return address
@property
def rhash(self) -> str:
assert self.is_lightning()
return self.payment_hash.hex()
def get_bip21_URI(
self,
*,
lightning_invoice: Optional[str] = None,
) -> Optional[str]:
addr = self.get_address()
amount = self.get_amount_sat()
message = self.message
if amount is None and not message:
return
if amount:
amount = int(amount)
extra = {}
if self.time and self.exp:
extra['time'] = str(int(self.time))
extra['exp'] = str(int(self.exp))
if lightning_invoice:
extra['lightning'] = lightning_invoice
if not addr and lightning_invoice:
return "bitcoin:?lightning="+lightning_invoice
if not addr and not lightning_invoice:
return None
uri = create_bip21_uri(addr, amount, message, extra_query_params=extra)
return str(uri)
def get_id_from_onchain_outputs(outputs: Sequence[PartialTxOutput], *, timestamp: int) -> str:
outputs_str = "\n".join(f"{txout.scriptpubkey.hex()}, {txout.value}" for txout in outputs)
return sha256d(outputs_str + "%d" % timestamp).hex()[0:10]
================================================
FILE: electrum/json_db.py
================================================
#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2019 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.
import threading
import copy
import json
from typing import TYPE_CHECKING, Optional, Sequence, List, Union, Any
import jsonpatch
import jsonpointer
from . import util
from .util import WalletFileException, profiler, sticky_property
from .logging import Logger
if TYPE_CHECKING:
from .storage import WalletStorage
# We monkeypatch exceptions in the jsonpatch package to ensure they do not contain secrets from the DB.
# We often log exceptions and offer to send them to the crash reporter, so they must not contain secrets.
jsonpointer.JsonPointerException.__str__ = lambda self: """(JPE) 'redacted'"""
jsonpointer.JsonPointerException.__repr__ = lambda self: """"""
setattr(jsonpointer.JsonPointerException, '__cause__', sticky_property(None))
setattr(jsonpointer.JsonPointerException, '__context__', sticky_property(None))
setattr(jsonpointer.JsonPointerException, '__suppress_context__', sticky_property(True))
jsonpatch.JsonPatchException.__str__ = lambda self: """(JPE) 'redacted'"""
jsonpatch.JsonPatchException.__repr__ = lambda self: """"""
setattr(jsonpatch.JsonPatchException, '__cause__', sticky_property(None))
setattr(jsonpatch.JsonPatchException, '__context__', sticky_property(None))
setattr(jsonpatch.JsonPatchException, '__suppress_context__', sticky_property(True))
def modifier(func):
def wrapper(self, *args, **kwargs):
with self.lock:
self._modified = True
return func(self, *args, **kwargs)
return wrapper
def locked(func):
def wrapper(self, *args, **kwargs):
with self.lock:
return func(self, *args, **kwargs)
return wrapper
registered_names = {}
registered_dicts = {}
registered_dict_keys = {}
registered_parent_keys = {}
def register_dict(name, method, _type):
registered_dicts[name] = method, _type
def register_name(name, method, _type):
registered_names[name] = method, _type
def register_dict_key(name, method):
registered_dict_keys[name] = method
def register_parent_key(name, method):
registered_parent_keys[name] = method
def stored_as(name, _type=dict):
""" decorator that indicates the storage key of a stored object"""
def decorator(func):
registered_names[name] = func, _type
return func
return decorator
def stored_in(name, _type=dict):
""" decorator that indicates the storage key of an element in a StoredDict"""
def decorator(func):
registered_dicts[name] = func, _type
return func
return decorator
_FLEX_KEY = str | int | None
def key_path(path: Sequence[_FLEX_KEY], key: _FLEX_KEY) -> str:
def to_str(x: _FLEX_KEY) -> str:
assert isinstance(x, _FLEX_KEY), repr(x)
assert x is not None
if isinstance(x, int):
return str(int(x))
else:
assert isinstance(x, str), f"unexpected key type for: {x!r}"
return x
items = [to_str(x) for x in path]
if key is not None:
items.append(to_str(key))
return '/'.join(items)
class BaseStoredObject:
_db: 'JsonDB' = None
_key: _FLEX_KEY = None
_parent: Optional['BaseStoredObject'] = None
_lock: threading.RLock = None
def set_db(self, db):
self._db = db
self._lock = self._db.lock if self._db else threading.RLock()
def set_parent(self, *, key: _FLEX_KEY, parent: Optional['BaseStoredObject']) -> None:
assert (key == "") == (parent is None), f"{key=!r}, {parent=!r}"
assert isinstance(key, _FLEX_KEY), repr(key)
self._key = key
self._parent = parent
@property
def lock(self):
return self._lock
@property
def path(self) -> Sequence[_FLEX_KEY] | None:
# return None iff we are pruned from root
x = self
s = [x._key]
while x._parent is not None:
x = x._parent
s = [x._key] + s
if x._key != '':
return None
assert self._db is not None
return s
def db_add(self, key: _FLEX_KEY, value) -> None:
assert isinstance(key, _FLEX_KEY), repr(key)
if self.path:
self._db.add(self.path, key, value)
def db_replace(self, key: _FLEX_KEY, value) -> None:
assert isinstance(key, _FLEX_KEY), repr(key)
if self.path:
self._db.replace(self.path, key, value)
def db_remove(self, key: _FLEX_KEY) -> None:
assert isinstance(key, _FLEX_KEY), repr(key)
if self.path:
self._db.remove(self.path, key)
class StoredObject(BaseStoredObject):
"""for attr.s objects """
def __setattr__(self, key: str, value):
assert isinstance(key, str), repr(key)
if self.path and not key.startswith('_'):
if value != getattr(self, key):
self.db_replace(key, value)
object.__setattr__(self, key, value)
def to_json(self):
d = dict(vars(self))
# don't expose/store private stuff
d = {k: v for k, v in d.items()
if not k.startswith('_')}
return d
_RaiseKeyError = object() # singleton for no-default behavior
class StoredDict(dict, BaseStoredObject):
def __init__(self, data: dict, db: 'JsonDB'):
self.set_db(db)
# recursively convert dicts to StoredDict
for k, v in list(data.items()):
self.__setitem__(k, v)
@locked
def __setitem__(self, key: _FLEX_KEY, v) -> None:
assert isinstance(key, _FLEX_KEY), repr(key)
is_new = key not in self
# early return to prevent unnecessary disk writes
if not is_new and self._db and json.dumps(v, cls=self._db.encoder) == json.dumps(self[key], cls=self._db.encoder):
return
# convert dict to StoredDict.
if type(v) == dict and (self._db is None or self._db._should_convert_to_stored_dict(key)):
v = StoredDict(v, self._db)
# convert list to StoredList
elif type(v) == list:
v = StoredList(v, self._db)
# reject sets. they do not work well with jsonpatch
elif isinstance(v, set):
raise Exception(f"Do not store sets inside jsondb. path={self.path!r}")
# set db for StoredObject, because it is not set in the constructor
if isinstance(v, StoredObject):
v.set_db(self._db)
# set parent
if isinstance(v, BaseStoredObject):
v.set_parent(key=key, parent=self)
# set item
dict.__setitem__(self, key, v)
self.db_add(key, v) if is_new else self.db_replace(key, v)
@locked
def __delitem__(self, key: _FLEX_KEY) -> None:
assert isinstance(key, _FLEX_KEY), repr(key)
r = self.get(key, None)
dict.__delitem__(self, key)
self.db_remove(key)
if isinstance(r, BaseStoredObject):
r._parent = None
@locked
def pop(self, key: _FLEX_KEY, v=_RaiseKeyError) -> Any:
assert isinstance(key, _FLEX_KEY), repr(key)
if key not in self:
if v is _RaiseKeyError:
raise KeyError(key)
else:
return v
r = dict.pop(self, key)
self.db_remove(key)
if isinstance(r, BaseStoredObject):
r._parent = None
return r
def setdefault(self, key: _FLEX_KEY, default = None, /):
assert isinstance(key, _FLEX_KEY), repr(key)
if key not in self:
self.__setitem__(key, default)
return self[key]
class StoredList(list, BaseStoredObject):
def __init__(self, data, db: 'JsonDB'):
list.__init__(self, data)
self.set_db(db)
@locked
def append(self, item):
n = len(self)
list.append(self, item)
self.db_add('%d'%n, item)
@locked
def remove(self, item):
n = self.index(item)
list.remove(self, item)
self.db_remove('%d'%n)
@locked
def clear(self):
list.clear(self)
self.db_replace(None, [])
class JsonDB(Logger):
def __init__(
self,
s: str,
*,
storage: Optional['WalletStorage'] = None,
encoder=None,
upgrader=None,
):
Logger.__init__(self)
self.lock = threading.RLock()
self.storage = storage
self.encoder = encoder
self.pending_changes = [] # type: List[str]
self._modified = False
# load data
data = self.load_data(s)
if upgrader:
data, was_upgraded = upgrader(data)
self._modified |= was_upgraded
# convert json to python objects
data = self._convert_dict([], data)
# convert dict to StoredDict
self.data = StoredDict(data, self)
self.data.set_parent(key='', parent=None)
# write file in case there was a db upgrade
if self.storage and self.storage.file_exists():
self.write_and_force_consolidation()
def load_data(self, s: str) -> dict:
if s == '':
return {}
try:
data = json.loads('[' + s + ']')
data, patches = data[0], data[1:]
except Exception:
if r := self.maybe_load_ast_data(s):
data, patches = r, []
elif r := self.maybe_load_incomplete_data(s):
data, patches = r, []
else:
raise WalletFileException("Cannot read wallet file. (parsing failed)")
if not isinstance(data, dict):
raise WalletFileException("Malformed wallet file (not dict)")
if patches:
# apply patches
self.logger.info('found %d patches'%len(patches))
patch = jsonpatch.JsonPatch(patches)
data = patch.apply(data)
self.set_modified(True)
return data
def maybe_load_ast_data(self, s):
""" for old wallets """
try:
import ast
d = ast.literal_eval(s)
labels = d.get('labels', {})
except Exception as e:
return
data = {}
for key, value in d.items():
try:
json.dumps(key)
json.dumps(value)
except Exception:
self.logger.info(f'Failed to convert label to json format: {key}')
continue
data[key] = value
return data
def maybe_load_incomplete_data(self, s):
n = s.count('{') - s.count('}')
i = len(s)
while n > 0 and i > 0:
i = i - 1
if s[i] == '{':
n = n - 1
if s[i] == '}':
n = n + 1
if n == 0:
s = s[0:i]
assert s[-2:] == ',\n'
self.logger.info('found incomplete data {s[i:]}')
return self.load_data(s[0:-2])
def set_modified(self, b):
with self.lock:
self._modified = b
def modified(self):
return self._modified
@locked
def add_patch(self, patch):
self.pending_changes.append(json.dumps(patch, cls=self.encoder))
self.set_modified(True)
def add(self, path, key: _FLEX_KEY, value) -> None:
assert isinstance(key, _FLEX_KEY), repr(key)
self.add_patch({'op': 'add', 'path': key_path(path, key), 'value': value})
def replace(self, path, key: _FLEX_KEY, value) -> None:
assert isinstance(key, _FLEX_KEY), repr(key)
self.add_patch({'op': 'replace', 'path': key_path(path, key), 'value': value})
def remove(self, path, key: _FLEX_KEY) -> None:
assert isinstance(key, _FLEX_KEY), repr(key)
self.add_patch({'op': 'remove', 'path': key_path(path, key)})
@locked
def get(self, key, default=None):
v = self.data.get(key)
if v is None:
v = default
return v
@modifier
def put(self, key, value):
try:
json.dumps(key, cls=self.encoder)
json.dumps(value, cls=self.encoder)
except Exception:
self.logger.info(f"json error: cannot save {repr(key)} ({repr(value)})")
return False
if value is not None:
if self.data.get(key) != value:
self.data[key] = copy.deepcopy(value)
return True
elif key in self.data:
self.data.pop(key)
return True
return False
@locked
def get_dict(self, name) -> dict:
# Warning: interacts un-intuitively with 'put': certain parts
# of 'data' will have pointers saved as separate variables.
if name not in self.data:
self.data[name] = {}
return self.data[name]
@locked
def get_stored_item(self, key, default) -> dict:
if key not in self.data:
self.data[key] = default
return self.data[key]
@locked
def dump(self, *, human_readable: bool = True) -> str:
"""Serializes the DB as a string.
'human_readable': makes the json indented and sorted, but this is ~2x slower
"""
return json.dumps(
self.data,
indent=4 if human_readable else None,
sort_keys=bool(human_readable),
cls=self.encoder,
)
def _should_convert_to_stored_dict(self, key) -> bool:
return True
def _convert_dict_key(self, path: List[str]) -> _FLEX_KEY:
"""Maybe convert key from str to python type (typically int or IntEnum)"""
assert all(isinstance(x, str) for x in path), repr(path)
key = path[-1]
parent_key = path[-2] if len(path) > 1 else None
gp_key = path[-3] if len(path) > 2 else None
if parent_key and parent_key in registered_dict_keys:
convert_key = registered_dict_keys[parent_key]
elif gp_key and gp_key in registered_parent_keys:
convert_key = registered_parent_keys.get(gp_key)
else:
convert_key = None
if convert_key:
key = convert_key(key)
assert isinstance(key, _FLEX_KEY), f"unexpected type for {key=!r} at {path=}"
return key
def _convert_dict_value(self, path: List[str], v) -> Any:
assert all(isinstance(x, str) for x in path), repr(path)
key = path[-1]
if key in registered_dicts:
constructor, _type = registered_dicts[key]
if _type == dict:
v = dict((k, constructor(**x)) for k, x in v.items())
elif _type == tuple:
v = dict((k, constructor(*x)) for k, x in v.items())
else:
v = dict((k, constructor(x)) for k, x in v.items())
elif key in registered_names:
constructor, _type = registered_names[key]
if _type == dict:
v = constructor(**v)
else:
v = constructor(v)
if isinstance(v, dict):
v = self._convert_dict(path, v)
return v
def _convert_dict(self, path: List[str], data: dict):
# recursively convert json dict to StoredDict
assert all(isinstance(x, str) for x in path), repr(path)
d = {}
for k, v in list(data.items()):
child_path = path + [k]
k = self._convert_dict_key(child_path)
v = self._convert_dict_value(child_path, v)
d[k] = v
return d
@locked
def write(self):
if self.storage.should_do_full_write_next():
self.write_and_force_consolidation()
else:
self._append_pending_changes()
@locked
def _append_pending_changes(self):
if threading.current_thread().daemon:
raise Exception('daemon thread cannot write db')
if not self.pending_changes:
self.logger.info('no pending changes')
return
self.logger.info(f'appending {len(self.pending_changes)} pending changes')
s = ''.join([',\n' + x for x in self.pending_changes])
self.storage.append(s)
self.pending_changes = []
@locked
@profiler
def write_and_force_consolidation(self):
if threading.current_thread().daemon:
raise Exception('daemon thread cannot write db')
if not self.modified():
return
json_str = self.dump(human_readable=not self.storage.is_encrypted())
self.storage.write(json_str)
self.pending_changes = []
self.set_modified(False)
================================================
FILE: electrum/keystore.py
================================================
#!/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
import hashlib
import re
import copy
from typing import Tuple, TYPE_CHECKING, Union, Sequence, Optional, Dict, List, NamedTuple, Any, Type
from functools import wraps
from abc import ABC, abstractmethod
import electrum_ecc as ecc
from electrum_ecc import string_to_number
from . import bitcoin, constants, bip32
from .bitcoin import deserialize_privkey, serialize_privkey, BaseDecodeError
from .transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, TxInput
from .bip32 import (convert_bip32_strpath_to_intpath, BIP32_PRIME,
is_xpub, is_xprv, BIP32Node, normalize_bip32_derivation,
convert_bip32_intpath_to_strpath, is_xkey_consistent_with_key_origin_info,
KeyOriginInfo)
from .descriptor import PubkeyProvider
from . import crypto
from .crypto import (pw_decode, pw_encode, sha256, sha256d, PW_HASH_VERSION_LATEST,
SUPPORTED_PW_HASH_VERSIONS, UnsupportedPasswordHashVersion, hash_160,
CiphertextFormatError)
from .util import (InvalidPassword, WalletFileException,
BitcoinException, bfh, inv_dict, is_hex_str)
from .mnemonic import Mnemonic, Wordlist, calc_seed_type, is_seed
from .plugin import run_hook
from .logging import Logger
from .lrucache import LRUCache
if TYPE_CHECKING:
from .gui.common_qt.util import TaskThread
from .hw_wallet import HW_PluginBase, HardwareClientBase, HardwareHandlerBase
from .wallet_db import WalletDB
from .plugin import Device
class CannotDerivePubkey(Exception): pass
class ScriptTypeNotSupported(Exception): pass
def also_test_none_password(check_password_fn):
"""Decorator for check_password, simply to give a friendlier exception if
check_password(x) is called on a keystore that does not have a password set.
"""
@wraps(check_password_fn)
def wrapper(self: 'Software_KeyStore', *args):
password = args[0]
try:
return check_password_fn(self, password)
except (CiphertextFormatError, InvalidPassword) as e:
if password is not None:
try:
check_password_fn(self, None)
except Exception:
pass
else:
raise InvalidPassword("password given but keystore has no password") from e
raise
return wrapper
class KeyStore(Logger, ABC):
type: str
def __init__(self):
Logger.__init__(self)
self.is_requesting_to_be_rewritten_to_wallet_file = False # type: bool
def has_seed(self) -> bool:
return False
def is_watching_only(self) -> bool:
return False
def can_import(self) -> bool:
return False
def get_type_text(self) -> str:
return f'{self.type}'
@abstractmethod
def may_have_password(self) -> bool:
"""Returns whether the keystore can be encrypted with a password."""
pass
def _get_tx_derivations(self, tx: 'PartialTransaction') -> Dict[bytes, Union[Sequence[int], str]]:
keypairs = {}
for txin in tx.inputs():
keypairs.update(self._get_txin_derivations(txin))
return keypairs
def _get_txin_derivations(self, txin: 'PartialTxInput') -> Dict[bytes, Union[Sequence[int], str]]:
if txin.is_complete():
return {}
keypairs = {}
for pubkey in txin.pubkeys:
if pubkey in txin.sigs_ecdsa:
# this pubkey already signed
continue
derivation = self.get_pubkey_derivation(pubkey, txin)
if not derivation:
continue
keypairs[pubkey] = derivation
return keypairs
def can_sign(self, tx: 'Transaction', *, ignore_watching_only: bool = False) -> bool:
"""Returns whether this keystore could sign *something* in this tx."""
if not ignore_watching_only and self.is_watching_only():
return False
if not isinstance(tx, PartialTransaction):
return False
return bool(self._get_tx_derivations(tx))
def can_sign_txin(self, txin: 'TxInput', *, ignore_watching_only: bool = False) -> bool:
"""Returns whether this keystore could sign this txin."""
if not ignore_watching_only and self.is_watching_only():
return False
if not isinstance(txin, PartialTxInput):
return False
return bool(self._get_txin_derivations(txin))
def ready_to_sign(self) -> bool:
return not self.is_watching_only()
@abstractmethod
def dump(self) -> dict[str, Any]:
pass
@abstractmethod
def is_deterministic(self) -> bool:
pass
@abstractmethod
def sign_message(
self,
sequence: 'AddressIndexGeneric',
message: str,
password,
*,
script_type: Optional[str] = None,
) -> bytes:
pass
@abstractmethod
def decrypt_message(self, sequence: 'AddressIndexGeneric', message, password) -> bytes:
pass
@abstractmethod
def sign_transaction(self, tx: 'PartialTransaction', password) -> None:
pass
@abstractmethod
def get_pubkey_derivation(self, pubkey: bytes,
txinout: Union['PartialTxInput', 'PartialTxOutput'],
*, only_der_suffix=True) \
-> Union[Sequence[int], str, None]:
"""Returns either a derivation int-list if the pubkey can be HD derived from this keystore,
the pubkey itself (hex) if the pubkey belongs to the keystore but not HD derived,
or None if the pubkey is unrelated.
"""
pass
@abstractmethod
def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]:
pass
def find_my_pubkey_in_txinout(
self, txinout: Union['PartialTxInput', 'PartialTxOutput'],
*, only_der_suffix: bool = False
) -> Tuple[Optional[bytes], Optional[List[int]]]:
# note: we assume that this cosigner only has one pubkey in this txin/txout
for pubkey in txinout.bip32_paths:
path = self.get_pubkey_derivation(pubkey, txinout, only_der_suffix=only_der_suffix)
if path and not isinstance(path, (str, bytes)):
return pubkey, list(path)
return None, None
def can_have_deterministic_lightning_xprv(self) -> bool:
return False
def has_support_for_slip_19_ownership_proofs(self) -> bool:
return False
def add_slip_19_ownership_proofs_to_tx(self, tx: 'PartialTransaction', *, password) -> None:
raise NotImplementedError()
class Software_KeyStore(KeyStore):
def __init__(self, d: dict):
KeyStore.__init__(self)
self.pw_hash_version = d.get('pw_hash_version', 1)
if self.pw_hash_version not in SUPPORTED_PW_HASH_VERSIONS:
raise UnsupportedPasswordHashVersion(self.pw_hash_version)
def may_have_password(self):
return not self.is_watching_only()
def sign_message(self, sequence, message, password, *, script_type=None) -> bytes:
privkey, compressed = self.get_private_key(sequence, password)
key = ecc.ECPrivkey(privkey)
return bitcoin.ecdsa_sign_usermessage(key, message, is_compressed=compressed)
def decrypt_message(self, sequence, message, password) -> bytes:
privkey, compressed = self.get_private_key(sequence, password)
ec = ecc.ECPrivkey(privkey)
decrypted = crypto.ecies_decrypt_message(ec, 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 = {}
pubkey_to_deriv_map = self._get_tx_derivations(tx)
for pubkey, deriv in pubkey_to_deriv_map.items():
privkey, is_compressed = self.get_private_key(deriv, password)
keypairs[pubkey] = privkey
# Sign
if keypairs:
tx.sign(keypairs)
@abstractmethod
def update_password(self, old_password, new_password) -> None:
pass
@abstractmethod
def check_password(self, password: Optional[str]) -> None:
"""Raises InvalidPassword if password is not correct"""
pass
@abstractmethod
def get_private_key(self, sequence: 'AddressIndexGeneric', password) -> Tuple[bytes, bool]:
"""Returns (privkey, is_compressed)"""
pass
class Imported_KeyStore(Software_KeyStore):
# keystore for imported private keys
type = 'imported'
def __init__(self, d: dict):
Software_KeyStore.__init__(self, d)
self.keypairs = d.get('keypairs', {}) # type: Dict[str, str]
def is_deterministic(self):
return False
def dump(self):
return {
'type': self.type,
'keypairs': self.keypairs,
'pw_hash_version': self.pw_hash_version,
}
def can_import(self):
return True
@also_test_none_password
def check_password(self, password):
pubkey = list(self.keypairs.keys())[0]
self.get_private_key(pubkey, password)
def import_privkey(self, sec: str, password) -> Tuple[str, str]:
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, version=self.pw_hash_version)
return txin_type, pubkey
def delete_imported_key(self, key: str) -> None:
self.keypairs.pop(key)
def get_private_key(self, pubkey: str, password):
sec = pw_decode(self.keypairs[pubkey], password, version=self.pw_hash_version)
try:
txin_type, privkey, compressed = deserialize_privkey(sec)
except BaseDecodeError as e:
raise InvalidPassword() from e
if pubkey != ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed):
raise InvalidPassword()
return privkey, compressed
def get_pubkey_derivation(self, pubkey, txin, *, only_der_suffix=True):
if pubkey.hex() in self.keypairs:
return pubkey.hex()
return None
def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]:
if sequence in self.keypairs:
return PubkeyProvider(
origin=None,
pubkey=sequence,
deriv_path=None,
)
return None
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, version=self.pw_hash_version)
c = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
self.keypairs[k] = c
self.pw_hash_version = PW_HASH_VERSION_LATEST
class Deterministic_KeyStore(Software_KeyStore):
def __init__(self, d: dict):
Software_KeyStore.__init__(self, d)
self.seed = d.get('seed', '') # only electrum seeds
self.passphrase = d.get('passphrase', '')
self._seed_type = d.get('seed_type', None) # only electrum seeds
def is_deterministic(self):
return True
def dump(self):
d = {
'type': self.type,
'pw_hash_version': self.pw_hash_version,
}
if self.seed:
d['seed'] = self.seed
if self.passphrase:
d['passphrase'] = self.passphrase
if self._seed_type:
d['seed_type'] = self._seed_type
return d
def has_seed(self):
return bool(self.seed)
def get_seed_type(self) -> Optional[str]:
return self._seed_type
def is_watching_only(self):
return not self.has_seed()
@abstractmethod
def format_seed(self, seed: str) -> str:
pass
def add_seed(self, seed: str) -> None:
if self.seed:
raise Exception("a seed exists")
self.seed = self.format_seed(seed)
self._seed_type = calc_seed_type(seed) or None
def get_seed(self, password) -> str:
if not self.has_seed():
raise Exception("This wallet has no seed words")
return pw_decode(self.seed, password, version=self.pw_hash_version)
def get_passphrase(self, password) -> str:
if self.passphrase:
return pw_decode(self.passphrase, password, version=self.pw_hash_version)
else:
return ''
class MasterPublicKeyMixin(ABC):
def __init__(self):
self._pubkey_cache = LRUCache(maxsize=10**4) # type: LRUCache[Sequence[int], bytes] # path->pubkey
@abstractmethod
def get_master_public_key(self) -> str:
pass
@abstractmethod
def get_derivation_prefix(self) -> Optional[str]:
"""Returns to bip32 path from some root node to self.xpub
Note that the return value might be None; if it is unknown.
"""
pass
@abstractmethod
def get_root_fingerprint(self) -> Optional[str]:
"""Returns the bip32 fingerprint of the top level node.
This top level node is the node at the beginning of the derivation prefix,
i.e. applying the derivation prefix to it will result self.xpub
Note that the return value might be None; if it is unknown.
"""
pass
@abstractmethod
def get_fp_and_derivation_to_be_used_in_partial_tx(
self,
der_suffix: Sequence[int],
*,
only_der_suffix: bool,
) -> Tuple[bytes, Sequence[int]]:
"""Returns fingerprint and derivation path corresponding to a derivation suffix.
The fingerprint is either the root fp or the intermediate fp, depending on what is available
and 'only_der_suffix', and the derivation path is adjusted accordingly.
"""
pass
def get_key_origin_info(self) -> Optional[KeyOriginInfo]:
return None
def derive_pubkey(self, for_change: int, n: int) -> bytes:
key = (for_change, n)
if key not in self._pubkey_cache:
self._pubkey_cache[key] = self._derive_pubkey(*key)
return self._pubkey_cache[key]
@abstractmethod
def _derive_pubkey(self, for_change: int, n: int) -> bytes:
"""Returns pubkey at given path.
May raise CannotDerivePubkey.
"""
pass
def get_pubkey_derivation(
self,
pubkey: bytes,
txinout: Union['PartialTxInput', 'PartialTxOutput'],
*,
only_der_suffix=True,
) -> Union[Sequence[int], str, None]:
EXPECTED_DER_SUFFIX_LEN = 2
def test_der_suffix_against_pubkey(der_suffix: Sequence[int], pubkey: bytes) -> bool:
if len(der_suffix) != EXPECTED_DER_SUFFIX_LEN:
return False
try:
if pubkey != self.derive_pubkey(*der_suffix):
return False
except CannotDerivePubkey:
return False
return True
if pubkey not in txinout.bip32_paths:
return None
fp_found, path_found = txinout.bip32_paths[pubkey]
der_suffix = None
full_path = None
# 1. try fp against our root
ks_root_fingerprint_hex = self.get_root_fingerprint()
ks_der_prefix_str = self.get_derivation_prefix()
ks_der_prefix = convert_bip32_strpath_to_intpath(ks_der_prefix_str) if ks_der_prefix_str else None
if (ks_root_fingerprint_hex is not None and ks_der_prefix is not None and
fp_found.hex() == ks_root_fingerprint_hex):
if path_found[:len(ks_der_prefix)] == ks_der_prefix:
der_suffix = path_found[len(ks_der_prefix):]
if not test_der_suffix_against_pubkey(der_suffix, pubkey):
der_suffix = None
# 2. try fp against our intermediate fingerprint
if (der_suffix is None and isinstance(self, Xpub) and
fp_found == self.get_bip32_node_for_xpub().calc_fingerprint_of_this_node()):
der_suffix = path_found
if not test_der_suffix_against_pubkey(der_suffix, pubkey):
der_suffix = None
# 3. hack/bruteforce: ignore fp and check pubkey anyway
# This is only to resolve the following scenario/problem:
# problem: if we don't know our root fp, but tx contains root fp and full path,
# we will miss the pubkey (false negative match). Though it might still work
# within gap limit due to tx.add_info_from_wallet overwriting the fields.
# Example: keystore has intermediate xprv without root fp; tx contains root fp and full path.
if der_suffix is None:
der_suffix = path_found[-EXPECTED_DER_SUFFIX_LEN:]
if not test_der_suffix_against_pubkey(der_suffix, pubkey):
der_suffix = None
# if all attempts/methods failed, we give up now:
if der_suffix is None:
return None
if ks_der_prefix is not None:
full_path = ks_der_prefix + list(der_suffix)
return der_suffix if only_der_suffix else full_path
class Xpub(MasterPublicKeyMixin):
def __init__(self, *, derivation_prefix: str = None, root_fingerprint: str = None):
MasterPublicKeyMixin.__init__(self)
self.xpub = None
self.xpub_receive = None
self.xpub_change = None
self._xpub_bip32_node = None # type: Optional[BIP32Node]
# "key origin" info (subclass should persist these):
self._derivation_prefix = derivation_prefix # type: Optional[str]
self._root_fingerprint = root_fingerprint # type: Optional[str]
def get_master_public_key(self):
return self.xpub
def get_bip32_node_for_xpub(self) -> Optional[BIP32Node]:
if self._xpub_bip32_node is None:
if self.xpub is None:
return None
self._xpub_bip32_node = BIP32Node.from_xkey(self.xpub)
return self._xpub_bip32_node
def get_derivation_prefix(self) -> Optional[str]:
if self._derivation_prefix is None:
return None
return normalize_bip32_derivation(self._derivation_prefix)
def get_root_fingerprint(self) -> Optional[str]:
return self._root_fingerprint
def get_fp_and_derivation_to_be_used_in_partial_tx(
self,
der_suffix: Sequence[int],
*,
only_der_suffix: bool,
) -> Tuple[bytes, Sequence[int]]:
fingerprint_hex = self.get_root_fingerprint()
der_prefix_str = self.get_derivation_prefix()
if not only_der_suffix and fingerprint_hex is not None and der_prefix_str is not None:
# use root fp, and true full path
fingerprint_bytes = bfh(fingerprint_hex)
der_prefix_ints = convert_bip32_strpath_to_intpath(der_prefix_str)
else:
# use intermediate fp, and claim der suffix is the full path
fingerprint_bytes = self.get_bip32_node_for_xpub().calc_fingerprint_of_this_node()
der_prefix_ints = convert_bip32_strpath_to_intpath('m')
der_full = der_prefix_ints + list(der_suffix)
return fingerprint_bytes, der_full
def get_xpub_to_be_used_in_partial_tx(self, *, only_der_suffix: bool) -> str:
assert self.xpub
fp_bytes, der_full = self.get_fp_and_derivation_to_be_used_in_partial_tx(der_suffix=[],
only_der_suffix=only_der_suffix)
bip32node = self.get_bip32_node_for_xpub()
depth = len(der_full)
child_number_int = der_full[-1] if len(der_full) >= 1 else 0
child_number_bytes = child_number_int.to_bytes(length=4, byteorder="big")
fingerprint = bytes(4) if depth == 0 else bip32node.fingerprint
bip32node = bip32node._replace(
depth=depth,
fingerprint=fingerprint,
child_number=child_number_bytes,
# only put plain xpubs (not ypub/zpub) in PSBTs:
xtype="standard",
)
return bip32node.to_xpub()
def get_key_origin_info(self) -> Optional[KeyOriginInfo]:
fp_bytes, der_full = self.get_fp_and_derivation_to_be_used_in_partial_tx(
der_suffix=[], only_der_suffix=False)
origin = KeyOriginInfo(fingerprint=fp_bytes, path=der_full)
return origin
def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]:
strpath = convert_bip32_intpath_to_strpath(sequence)
strpath = strpath[1:] # cut leading "m"
bip32node = self.get_bip32_node_for_xpub()
return PubkeyProvider(
origin=self.get_key_origin_info(),
pubkey=bip32node._replace(xtype="standard").to_xkey(),
deriv_path=strpath,
)
def add_key_origin_from_root_node(self, *, derivation_prefix: str, root_node: BIP32Node) -> None:
assert self.xpub
# try to derive ourselves from what we were given
child_node1 = root_node.subkey_at_private_derivation(derivation_prefix)
child_pubkey_bytes1 = child_node1.eckey.get_public_key_bytes(compressed=True)
child_node2 = self.get_bip32_node_for_xpub()
child_pubkey_bytes2 = child_node2.eckey.get_public_key_bytes(compressed=True)
if child_pubkey_bytes1 != child_pubkey_bytes2:
raise Exception("(xpub, derivation_prefix, root_node) inconsistency")
self.add_key_origin(derivation_prefix=derivation_prefix,
root_fingerprint=root_node.calc_fingerprint_of_this_node().hex().lower())
def add_key_origin(self, *, derivation_prefix: str = None, root_fingerprint: str = None) -> None:
assert self.xpub
if not (root_fingerprint is None or (is_hex_str(root_fingerprint) and len(root_fingerprint) == 8)):
raise Exception("root fp must be 8 hex characters")
derivation_prefix = normalize_bip32_derivation(derivation_prefix)
if not is_xkey_consistent_with_key_origin_info(self.xpub,
derivation_prefix=derivation_prefix,
root_fingerprint=root_fingerprint):
raise Exception("xpub inconsistent with provided key origin info")
if root_fingerprint is not None:
self._root_fingerprint = root_fingerprint
if derivation_prefix is not None:
self._derivation_prefix = derivation_prefix
self.is_requesting_to_be_rewritten_to_wallet_file = True
def _derive_pubkey(self, for_change: int, n: int) -> bytes:
for_change = int(for_change)
if for_change not in (0, 1):
raise CannotDerivePubkey("forbidden path")
xpub = self.xpub_change if for_change else self.xpub_receive
if xpub is None:
rootnode = self.get_bip32_node_for_xpub()
xpub = rootnode.subkey_at_public_derivation((for_change,)).to_xpub()
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(cls, xpub: str, sequence) -> bytes:
node = BIP32Node.from_xkey(xpub).subkey_at_public_derivation(sequence)
return node.eckey.get_public_key_bytes(compressed=True)
class BIP32_KeyStore(Xpub, Deterministic_KeyStore):
type = 'bip32'
def __init__(self, d: dict):
Xpub.__init__(self, derivation_prefix=d.get('derivation'), root_fingerprint=d.get('root_fingerprint'))
Deterministic_KeyStore.__init__(self, d)
self.xpub = d.get('xpub')
self.xprv = d.get('xprv')
def watching_only_keystore(self):
return BIP32_KeyStore({
'xpub': self.xpub,
'root_fingerprint': self.get_root_fingerprint(),
'derivation': self.get_derivation_prefix(),
})
def format_seed(self, seed):
return ' '.join(seed.split())
def dump(self):
d = Deterministic_KeyStore.dump(self)
d['xpub'] = self.xpub
d['xprv'] = self.xprv
d['derivation'] = self.get_derivation_prefix()
d['root_fingerprint'] = self.get_root_fingerprint()
return d
def get_master_private_key(self, password) -> str:
return pw_decode(self.xprv, password, version=self.pw_hash_version)
@also_test_none_password
def check_password(self, password):
xprv = pw_decode(self.xprv, password, version=self.pw_hash_version)
try:
bip32node = BIP32Node.from_xkey(xprv)
except BaseDecodeError as e:
raise InvalidPassword() from e
if bip32node.chaincode != self.get_bip32_node_for_xpub().chaincode:
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, version=PW_HASH_VERSION_LATEST)
if self.passphrase:
decoded = self.get_passphrase(old_password)
self.passphrase = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
if self.xprv is not None:
b = pw_decode(self.xprv, old_password, version=self.pw_hash_version)
self.xprv = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
self.pw_hash_version = PW_HASH_VERSION_LATEST
def is_watching_only(self):
return self.xprv is None
def add_xpub(self, xpub: str) -> None:
assert is_xpub(xpub)
self.xpub = xpub
root_fingerprint, derivation_prefix = bip32.root_fp_and_der_prefix_from_xkey(xpub)
self.add_key_origin(derivation_prefix=derivation_prefix, root_fingerprint=root_fingerprint)
def add_xprv(self, xprv: str) -> None:
assert is_xprv(xprv)
self.xprv = xprv
self.add_xpub(bip32.xpub_from_xprv(xprv))
def add_xprv_from_seed(self, bip32_seed: bytes, *, xtype: str, derivation: str) -> None:
rootnode = BIP32Node.from_rootseed(bip32_seed, xtype=xtype)
node = rootnode.subkey_at_private_derivation(derivation)
self.add_xprv(node.to_xprv())
self.add_key_origin_from_root_node(derivation_prefix=derivation, root_node=rootnode)
def get_private_key(self, sequence: Sequence[int], password):
xprv = self.get_master_private_key(password)
node = BIP32Node.from_xkey(xprv).subkey_at_private_derivation(sequence)
pk = node.eckey.get_secret_bytes()
return pk, True
def can_have_deterministic_lightning_xprv(self):
if (self.get_seed_type() == 'segwit'
and self.get_bip32_node_for_xpub().xtype == 'p2wpkh'):
return True
return False
def get_lightning_xprv(self, password) -> str:
assert self.can_have_deterministic_lightning_xprv()
xprv = self.get_master_private_key(password)
rootnode = BIP32Node.from_xkey(xprv)
node = rootnode.subkey_at_private_derivation("m/67'/")
return node.to_xprv()
class Old_KeyStore(MasterPublicKeyMixin, Deterministic_KeyStore):
type = 'old'
def __init__(self, d: dict):
MasterPublicKeyMixin.__init__(self)
Deterministic_KeyStore.__init__(self, d)
self.mpk = d.get('mpk') # type: Optional[str]
self._root_fingerprint = None
def watching_only_keystore(self):
return Old_KeyStore({'mpk': self.mpk})
def _get_hex_seed(self, password) -> str:
if not is_hex_str(self.seed) and password is None:
raise InvalidPassword()
hex_str = pw_decode(self.seed, password, version=self.pw_hash_version)
assert is_hex_str(hex_str), f"expected hex str, got {type(hex_str)} with {len(hex_str)=}"
return hex_str
def dump(self):
d = Deterministic_KeyStore.dump(self)
d['mpk'] = self.mpk
return d
def add_seed(self, seed):
Deterministic_KeyStore.add_seed(self, seed)
hex_seed = self._get_hex_seed(None)
self.mpk = self.mpk_from_seed(hex_seed)
def add_master_public_key(self, mpk: str) -> None:
self.mpk = mpk
def format_seed(self, seed):
"""Returns seed in hex format.
seed: either in hex or as mnemonic words
"""
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
hex_seed = self._get_hex_seed(password)
return ' '.join(old_mnemonic.mn_encode(hex_seed))
@classmethod
def mpk_from_seed(cls, hex_seed: str) -> str:
secexp = cls.stretch_key(hex_seed)
privkey = ecc.ECPrivkey.from_secret_scalar(secexp)
return privkey.get_public_key_hex(compressed=False)[2:]
@classmethod
def stretch_key(cls, hex_seed: str) -> int:
assert is_hex_str(hex_seed), f"expected hex str, got {type(hex_seed)} with {len(hex_seed)=}"
encoded_hex_seed = hex_seed.encode('ascii')
x = encoded_hex_seed
for i in range(100000):
x = hashlib.sha256(x + encoded_hex_seed).digest()
return string_to_number(x)
@classmethod
def get_sequence(cls, mpk: str, for_change: int, n: int) -> int:
return string_to_number(sha256d(("%d:%d:"%(n, for_change)).encode('ascii') + bfh(mpk)))
@classmethod
def get_pubkey_from_mpk(cls, mpk: str, for_change: int, n: int) -> bytes:
z = cls.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_bytes(compressed=False)
def _derive_pubkey(self, for_change, n) -> bytes:
for_change = int(for_change)
if for_change not in (0, 1):
raise CannotDerivePubkey("forbidden path")
return self.get_pubkey_from_mpk(self.mpk, for_change, n)
def _get_private_key_from_stretched_exponent(self, for_change: int, n: int, secexp: int) -> bytes:
secexp = (secexp + self.get_sequence(self.mpk, for_change, n)) % ecc.CURVE_ORDER
pk = int.to_bytes(secexp, length=32, byteorder='big', signed=False)
return pk
def get_private_key(self, sequence: Sequence[int], password):
hex_seed = self._get_hex_seed(password)
secexp = self.stretch_key(hex_seed)
self._check_seed(hex_seed, secexp=secexp)
for_change, n = sequence
assert isinstance(for_change, int), type(for_change)
assert isinstance(n, int), type(n)
pk = self._get_private_key_from_stretched_exponent(for_change, n, secexp)
return pk, False
def _check_seed(self, hex_seed: str, *, secexp: int = None) -> None:
if secexp is None:
secexp = self.stretch_key(hex_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):
raise InvalidPassword()
@also_test_none_password
def check_password(self, password):
hex_seed = self._get_hex_seed(password)
self._check_seed(hex_seed)
def get_master_public_key(self):
return self.mpk
def get_derivation_prefix(self) -> str:
return 'm'
def get_root_fingerprint(self) -> str:
if self._root_fingerprint is None:
master_public_key = ecc.ECPubkey(bfh('04'+self.mpk))
xfp = hash_160(master_public_key.get_public_key_bytes(compressed=True))[0:4]
self._root_fingerprint = xfp.hex().lower()
return self._root_fingerprint
def get_fp_and_derivation_to_be_used_in_partial_tx(
self,
der_suffix: Sequence[int],
*,
only_der_suffix: bool,
) -> Tuple[bytes, Sequence[int]]:
fingerprint_hex = self.get_root_fingerprint()
der_prefix_str = self.get_derivation_prefix()
fingerprint_bytes = bfh(fingerprint_hex)
der_prefix_ints = convert_bip32_strpath_to_intpath(der_prefix_str)
der_full = der_prefix_ints + list(der_suffix)
return fingerprint_bytes, der_full
def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]:
return PubkeyProvider(
origin=None,
pubkey=self.derive_pubkey(*sequence).hex(),
deriv_path=None,
)
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, version=self.pw_hash_version)
self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
self.pw_hash_version = PW_HASH_VERSION_LATEST
class Hardware_KeyStore(Xpub, KeyStore):
hw_type: str
device: str
plugin: 'HW_PluginBase'
thread: Optional['TaskThread'] = None
type = 'hardware'
def __init__(self, d):
Xpub.__init__(self, derivation_prefix=d.get('derivation'), root_fingerprint=d.get('root_fingerprint'))
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') # type: Optional[str]
self.soft_device_id = d.get('soft_device_id') # type: Optional[str]
self.handler = None # type: Optional[HardwareHandlerBase]
run_hook('init_keystore', self)
def watching_only_keystore(self):
return BIP32_KeyStore({
'xpub': self.xpub,
'root_fingerprint': self.get_root_fingerprint(),
'derivation': self.get_derivation_prefix(),
})
def set_label(self, label: Optional[str]) -> None:
self.label = label
def may_have_password(self):
return False
def is_deterministic(self):
return True
def get_type_text(self) -> str:
return f'hw[{self.hw_type}]'
def dump(self):
return {
'type': self.type,
'hw_type': self.hw_type,
'xpub': self.xpub,
'derivation': self.get_derivation_prefix(),
'root_fingerprint': self.get_root_fingerprint(),
'label': self.label,
'soft_device_id': self.soft_device_id,
}
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_client(
self,
force_pair: bool = True,
*,
devices: Sequence['Device'] = None,
allow_user_interaction: bool = True,
) -> Optional['HardwareClientBase']:
return self.plugin.get_client(
self,
force_pair=force_pair,
devices=devices,
allow_user_interaction=allow_user_interaction,
)
def get_password_for_storage_encryption(self) -> str:
client = self.get_client()
return client.get_password_for_storage_encryption()
def has_usable_connection_with_device(self) -> bool:
# we try to create a client even if there isn't one already,
# but do not prompt the user if auto-select fails:
client = self.get_client(
force_pair=True,
allow_user_interaction=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 opportunistically_fill_in_missing_info_from_device(self, client: 'HardwareClientBase'):
assert client is not None
if self._root_fingerprint is None:
self._root_fingerprint = client.request_root_fingerprint_from_device()
self.is_requesting_to_be_rewritten_to_wallet_file = True
if self.label != client.label():
self.label = client.label()
self.is_requesting_to_be_rewritten_to_wallet_file = True
if self.soft_device_id != client.get_soft_device_id():
self.soft_device_id = client.get_soft_device_id()
self.is_requesting_to_be_rewritten_to_wallet_file = True
def pairing_code(self) -> Optional[str]:
"""Used by the DeviceMgr to keep track of paired hw devices."""
if not self.soft_device_id:
return None
return f"{self.plugin.name}/{self.soft_device_id}"
KeyStoreWithMPK = Union[KeyStore, MasterPublicKeyMixin] # intersection really...
AddressIndexGeneric = Union[Sequence[int], str] # can be hex pubkey str
def bip39_normalize_passphrase(passphrase: str):
return normalize('NFKD', passphrase or '')
def bip39_to_seed(mnemonic: str, *, passphrase: Optional[str]) -> bytes:
import hashlib
passphrase = passphrase or ""
PBKDF2_ROUNDS = 2048
mnemonic = normalize('NFKD', ' '.join(mnemonic.split()))
passphrase = bip39_normalize_passphrase(passphrase)
return hashlib.pbkdf2_hmac('sha512', mnemonic.encode('utf-8'),
b'mnemonic' + passphrase.encode('utf-8'), iterations = PBKDF2_ROUNDS)
def bip39_is_checksum_valid(
mnemonic: str,
*,
wordlist: Wordlist = None,
) -> Tuple[bool, bool]:
"""Test checksum of bip39 mnemonic assuming English wordlist.
Returns tuple (is_checksum_valid, is_wordlist_valid)
"""
words = [normalize('NFKD', word) for word in mnemonic.split()]
words_len = len(words)
if wordlist is None:
wordlist = Wordlist.from_file("english.txt")
n = len(wordlist)
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
checksum_length = 11 * words_len // 33 # num bits
entropy_length = 32 * checksum_length # num bits
entropy = i >> checksum_length
checksum = i % 2**checksum_length
entropy_bytes = int.to_bytes(entropy, length=entropy_length//8, byteorder="big")
hashed = int.from_bytes(sha256(entropy_bytes), byteorder="big")
calculated_checksum = hashed >> (256 - checksum_length)
return checksum == calculated_checksum, True
def from_bip43_rootseed(
root_seed: bytes,
*,
derivation: str,
xtype: Optional[str] = None,
):
k = BIP32_KeyStore({})
if xtype is None:
xtype = xtype_from_derivation(derivation)
k.add_xprv_from_seed(root_seed, xtype=xtype, derivation=derivation)
return k
PURPOSE48_SCRIPT_TYPES = {
'p2wsh-p2sh': 1, # specifically multisig
'p2wsh': 2, # specifically multisig
}
PURPOSE48_SCRIPT_TYPES_INV = inv_dict(PURPOSE48_SCRIPT_TYPES)
def xtype_from_derivation(derivation: str) -> str:
"""Returns the script type to be used for this derivation."""
bip32_indices = convert_bip32_strpath_to_intpath(derivation)
if len(bip32_indices) >= 1:
if bip32_indices[0] == 84 + BIP32_PRIME:
return 'p2wpkh'
elif bip32_indices[0] == 49 + BIP32_PRIME:
return 'p2wpkh-p2sh'
elif bip32_indices[0] == 44 + BIP32_PRIME:
return 'standard'
elif bip32_indices[0] == 45 + BIP32_PRIME:
return 'standard'
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'
hw_keystores = {} # type: Dict[str, Type[Hardware_KeyStore]]
def register_keystore(hw_type: str, constructor: Type[Hardware_KeyStore]) -> None:
hw_keystores[hw_type] = constructor
def hardware_keystore(d) -> Hardware_KeyStore:
hw_type = d['hw_type']
if hw_type in hw_keystores:
constructor = hw_keystores[hw_type]
return constructor(d)
raise WalletFileException(f'unknown hardware type: {hw_type}. '
f'hw_keystores: {list(hw_keystores)}')
def load_keystore(db: 'WalletDB', name: str) -> KeyStore:
# deepcopy object to avoid keeping a pointer to db.data
# note: this is needed as type(wallet.db.get("keystore")) != StoredDict
d = copy.deepcopy(db.get(name, {}))
t = d.get('type')
if not t:
raise WalletFileException(
'Wallet format requires update.\n'
'Cannot find keystore for name {}'.format(name))
keystore_constructors = {ks.type: ks for ks in [Old_KeyStore, Imported_KeyStore, BIP32_KeyStore]}
keystore_constructors['hardware'] = hardware_keystore
try:
ks_constructor = keystore_constructors[t]
except KeyError:
raise WalletFileException(f'Unknown type {t} for keystore named {name}')
k = ks_constructor(d)
return k
def is_old_mpk(mpk: str) -> bool:
try:
int(mpk, 16) # test if hex string
except Exception:
return False
if len(mpk) != 128:
return False
try:
ecc.ECPubkey(bfh('04' + mpk))
except Exception:
return False
return True
def is_address_list(text: str) -> bool:
parts = text.split()
return bool(parts) and all(bitcoin.is_address(x) for x in parts)
def get_private_keys(text: str, *, allow_spaces_inside_key=True, raise_on_error=False) -> Sequence[str]:
if allow_spaces_inside_key: # see #1612
parts = text.split('\n')
parts = map(lambda x: ''.join(x.split()), parts)
parts = list(filter(bool, parts))
else:
parts = text.split()
if bool(parts) and all(bitcoin.is_private_key(x, raise_on_error=raise_on_error) for x in parts):
return parts
return []
def is_private_key_list(text: str, *, allow_spaces_inside_key: bool = True, raise_on_error: bool = False) -> bool:
return bool(get_private_keys(text,
allow_spaces_inside_key=allow_spaces_inside_key,
raise_on_error=raise_on_error))
def is_master_key(x: str) -> bool:
return is_old_mpk(x) or is_bip32_key(x)
def is_bip32_key(x: str) -> bool:
return is_xprv(x) or is_xpub(x)
def bip44_derivation(account_id: int, bip43_purpose: int = 44) -> str:
coin = constants.net.BIP44_COIN_TYPE
der = "m/%d'/%d'/%d'" % (bip43_purpose, coin, int(account_id))
return normalize_bip32_derivation(der)
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))
der = "m/%d'/%d'/%d'/%d'" % (bip43_purpose, coin, account_id, script_type_int)
return normalize_bip32_derivation(der)
def from_seed(seed: str, *, passphrase: Optional[str], for_multisig: bool = False) -> Union[BIP32_KeyStore, Old_KeyStore]:
passphrase = passphrase or ""
t = calc_seed_type(seed)
if t == 'old':
if passphrase:
raise Exception("'old'-type electrum seed cannot have passphrase")
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=passphrase)
if t == 'standard':
der = "m/"
xtype = 'standard'
else:
der = "m/1'/" if for_multisig else "m/0'/"
xtype = 'p2wsh' if for_multisig else 'p2wpkh'
keystore.add_xprv_from_seed(bip32_seed, xtype=xtype, derivation=der)
else:
raise BitcoinException('Unexpected seed type {}'.format(repr(t)))
return keystore
def from_private_key_list(text: str) -> Imported_KeyStore:
keystore = Imported_KeyStore({})
for x in get_private_keys(text):
keystore.import_privkey(x, None)
return keystore
def from_old_mpk(mpk: str) -> Old_KeyStore:
keystore = Old_KeyStore({})
keystore.add_master_public_key(mpk)
return keystore
def from_xpub(xpub: str) -> BIP32_KeyStore:
k = BIP32_KeyStore({})
k.add_xpub(xpub)
return k
def from_xprv(xprv: str) -> BIP32_KeyStore:
k = BIP32_KeyStore({})
k.add_xprv(xprv)
return k
def from_master_key(text: str) -> Union[BIP32_KeyStore, Old_KeyStore]:
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
================================================
FILE: electrum/lnaddr.py
================================================
#! /usr/bin/env python3
# This was forked from https://github.com/rustyrussell/lightning-payencode/tree/acc16ec13a3fa1dc16c07af6ec67c261bd8aff23
import io
import re
import time
from hashlib import sha256
from binascii import hexlify
from decimal import Decimal
from typing import Optional, TYPE_CHECKING, Type, Dict, Any, Sequence, Tuple
import random
import electrum_ecc as ecc
from .bitcoin import hash160_to_b58_address, b58_address_to_hash160, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from .segwit_addr import bech32_encode, bech32_decode, CHARSET, CHARSET_INVERSE, convertbits
from . import segwit_addr
from . import constants
from .constants import AbstractNet
from .bitcoin import COIN
if TYPE_CHECKING:
from .lnutil import LnFeatures
class LnInvoiceException(Exception): pass
class LnDecodeException(LnInvoiceException): pass
class LnEncodeException(LnInvoiceException): pass
# BOLT #11:
#
# A writer MUST encode `amount` as a positive decimal integer with no
# leading zeroes, SHOULD use the shortest representation possible.
def shorten_amount(amount):
""" Given an amount in bitcoin, shorten it
"""
# Convert to pico initially
amount = int(amount * 10**12)
units = ['p', 'n', 'u', 'm']
for unit in units:
if amount % 1000 == 0:
amount //= 1000
else:
break
else:
unit = ''
return str(amount) + unit
def unshorten_amount(amount) -> Decimal:
""" Given a shortened amount, convert it into a decimal
"""
# BOLT #11:
# The following `multiplier` letters are defined:
#
#* `m` (milli): multiply by 0.001
#* `u` (micro): multiply by 0.000001
#* `n` (nano): multiply by 0.000000001
#* `p` (pico): multiply by 0.000000000001
units = {
'p': 10**12,
'n': 10**9,
'u': 10**6,
'm': 10**3,
}
unit = str(amount)[-1]
# BOLT #11:
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
# anything except a `multiplier` in the table above.
if not re.fullmatch("\\d+[pnum]?", str(amount)):
raise LnDecodeException("Invalid amount '{}'".format(amount))
if unit in units.keys():
return Decimal(amount[:-1]) / units[unit]
else:
return Decimal(amount)
def encode_fallback_addr(fallback: str, net: Type[AbstractNet]) -> Sequence[int]:
"""Encode all supported fallback addresses."""
wver, wprog_ints = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, fallback)
if wver is not None:
wprog = bytes(wprog_ints)
else:
addrtype, addr = b58_address_to_hash160(fallback)
if addrtype == net.ADDRTYPE_P2PKH:
wver = 17
elif addrtype == net.ADDRTYPE_P2SH:
wver = 18
else:
raise LnEncodeException(f"Unknown address type {addrtype} for {net}")
wprog = addr
data5 = convertbits(wprog, 8, 5)
assert data5 is not None
return tagged5('f', [wver] + list(data5))
def parse_fallback_addr(data5: Sequence[int], net: Type[AbstractNet]) -> Optional[str]:
wver = data5[0]
data8 = bytes(convertbits(data5[1:], 5, 8, False))
if wver == 17:
addr = hash160_to_b58_address(data8, net.ADDRTYPE_P2PKH)
elif wver == 18:
addr = hash160_to_b58_address(data8, net.ADDRTYPE_P2SH)
elif wver <= 16:
addr = segwit_addr.encode_segwit_address(net.SEGWIT_HRP, wver, data8)
else:
return None
return addr
def tagged5(char: str, data5: Sequence[int]) -> Sequence[int]:
assert len(data5) < (1 << 10)
return [CHARSET_INVERSE[char], len(data5) >> 5, len(data5) & 31] + data5
def tagged8(char: str, data8: Sequence[int]) -> Sequence[int]:
return tagged5(char, convertbits(data8, 8, 5))
def int_to_data5(val: int, *, bit_len: int = None) -> Sequence[int]:
"""Represent big-endian number with as many 0-31 values as it takes.
If `bit_len` is set, use exactly bit_len//5 values (left-padded with zeroes).
"""
if bit_len is not None:
assert bit_len % 5 == 0, bit_len
if val.bit_length() > bit_len:
raise ValueError(f"{val=} too big for {bit_len=!r}")
ret = []
while val != 0:
ret.append(val % 32)
val //= 32
if bit_len is not None:
ret.extend([0] * (len(ret) - bit_len // 5))
ret.reverse()
return ret
def int_from_data5(data5: Sequence[int]) -> int:
total = 0
for v in data5:
total = 32 * total + v
return total
def pull_tagged(data5: bytearray) -> Tuple[str, Sequence[int]]:
"""Try to pull out tagged data: returns tag, tagged data. Mutates data in-place."""
if len(data5) < 3:
raise ValueError("Truncated field")
length = data5[1] * 32 + data5[2]
if length > len(data5) - 3:
raise ValueError(
"Truncated {} field: expected {} values".format(CHARSET[data5[0]], length))
ret = (CHARSET[data5[0]], data5[3:3+length])
del data5[:3 + length] # much faster than: data5=data5[offset:]
return ret
def lnencode(addr: 'LnAddr', privkey) -> str:
if addr.amount:
amount = addr.net.BOLT11_HRP + shorten_amount(addr.amount)
else:
amount = addr.net.BOLT11_HRP if addr.net else ''
hrp = 'ln' + amount
# Start with the timestamp
data5 = int_to_data5(addr.date, bit_len=35)
tags_set = set()
# Payment hash
assert addr.paymenthash is not None
data5 += tagged8('p', addr.paymenthash)
tags_set.add('p')
if addr.payment_secret is not None:
data5 += tagged8('s', addr.payment_secret)
tags_set.add('s')
for k, v in addr.tags:
# BOLT #11:
#
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
if k in ('d', 'h', 'n', 'x', 'p', 's', '9'):
if k in tags_set:
raise LnEncodeException("Duplicate '{}' tag".format(k))
if k == 'r':
route = bytearray()
for step in v:
pubkey, scid, feebase, feerate, cltv = step
route += pubkey
route += scid
route += int.to_bytes(feebase, length=4, byteorder="big", signed=False)
route += int.to_bytes(feerate, length=4, byteorder="big", signed=False)
route += int.to_bytes(cltv, length=2, byteorder="big", signed=False)
data5 += tagged8('r', route)
elif k == 't':
pubkey, feebase, feerate, cltv = v
route = bytearray()
route += pubkey
route += int.to_bytes(feebase, length=4, byteorder="big", signed=False)
route += int.to_bytes(feerate, length=4, byteorder="big", signed=False)
route += int.to_bytes(cltv, length=2, byteorder="big", signed=False)
data5 += tagged8('t', route)
elif k == 'f':
if v is not None:
data5 += encode_fallback_addr(v, addr.net)
elif k == 'd':
# truncate to max length: 1024*5 bits = 639 bytes
data5 += tagged8('d', v.encode()[0:639])
elif k == 'x':
expirybits = int_to_data5(v)
data5 += tagged5('x', expirybits)
elif k == 'h':
data5 += tagged8('h', sha256(v.encode('utf-8')).digest())
elif k == 'n':
data5 += tagged8('n', v)
elif k == 'c':
finalcltvbits = int_to_data5(v)
data5 += tagged5('c', finalcltvbits)
elif k == '9':
if v == 0:
continue
feature_bits = int_to_data5(v)
data5 += tagged5('9', feature_bits)
else:
# FIXME: Support unknown tags?
raise LnEncodeException("Unknown tag {}".format(k))
tags_set.add(k)
# BOLT #11:
#
# A writer MUST include either a `d` or `h` field, and MUST NOT include
# both.
if 'd' in tags_set and 'h' in tags_set:
raise ValueError("Cannot include both 'd' and 'h'")
if 'd' not in tags_set and 'h' not in tags_set:
raise ValueError("Must include either 'd' or 'h'")
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
msg = hrp.encode("ascii") + bytes(convertbits(data5, 5, 8))
msg32 = sha256(msg).digest()
privkey = ecc.ECPrivkey(privkey)
sig = privkey.ecdsa_sign_recoverable(msg32, is_compressed=False)
recovery_flag = bytes([sig[0] - 27])
sig = bytes(sig[1:]) + recovery_flag
sig = bytes(convertbits(sig, 8, 5, False))
data5 += sig
return bech32_encode(segwit_addr.Encoding.BECH32, hrp, data5)
class LnAddr(object):
def __init__(self, *, paymenthash: bytes = None, amount=None, net: Type[AbstractNet] = None, tags=None, date=None,
payment_secret: bytes = None):
self.date = int(time.time()) if not date else int(date)
self.tags = [] if not tags else tags
self.unknown_tags = []
self.paymenthash = paymenthash
self.payment_secret = payment_secret
self.signature = None
self.pubkey = None
self.net = constants.net if net is None else net # type: Type[AbstractNet]
self._amount = amount # type: Optional[Decimal] # in bitcoins
@property
def amount(self) -> Optional[Decimal]:
return self._amount
@amount.setter
def amount(self, value):
if not (isinstance(value, Decimal) or value is None):
raise LnInvoiceException(f"amount must be Decimal or None, not {value!r}")
if value is None:
self._amount = None
return
assert isinstance(value, Decimal)
if value.is_nan() or not (0 <= value <= TOTAL_COIN_SUPPLY_LIMIT_IN_BTC):
raise LnInvoiceException(f"amount is out-of-bounds: {value!r} BTC")
if value * 10**12 % 10:
# max resolution is millisatoshi
raise LnInvoiceException(f"Cannot encode {value!r}: too many decimal places")
self._amount = value
def get_amount_sat(self) -> Optional[Decimal]:
# note that this has msat resolution potentially
if self.amount is None:
return None
return self.amount * COIN
def get_routing_info(self, tag):
# note: tag will be 't' for trampoline
r_tags = list(filter(lambda x: x[0] == tag, self.tags))
# strip the tag type, it's implicitly 'r' now
r_tags = list(map(lambda x: x[1], r_tags))
# if there are multiple hints, we will use the first one that works,
# from a random permutation
random.shuffle(r_tags)
return r_tags
@staticmethod
def format_bolt11_routing_info_as_human_readable(r_tags, *, has_explicit_r_tagtype: bool = False):
"""Converts the node-id bytes->hex, and the SCID bytes->"AAAxBBBxCC", e.g. for logging."""
from .util import format_short_id
r_tags2 = []
for r_tag in r_tags:
if has_explicit_r_tagtype:
(tagtype, path) = r_tag
assert tagtype == "r", f"found unexpected {tagtype=}"
else:
path = r_tag
path2 = [
(edge[0].hex(), format_short_id(edge[1]), edge[2], edge[3], edge[4])
for edge in path]
r_tag2 = (tagtype, path2) if has_explicit_r_tagtype else path2
r_tags2.append(r_tag2)
return r_tags2
def get_amount_msat(self) -> Optional[int]:
if self.amount is None:
return None
return int(self.amount * COIN * 1000)
def get_features(self) -> 'LnFeatures':
from .lnutil import LnFeatures
return LnFeatures(self.get_tag('9') or 0)
def validate_and_compare_features(self, myfeatures: 'LnFeatures') -> None:
"""Raises IncompatibleOrInsaneFeatures.
note: these checks are not done by the parser (in lndecode), as then when we started requiring a new feature,
old saved already paid invoices could no longer be parsed.
"""
from .lnutil import validate_features, ln_compare_features
invoice_features = self.get_features()
validate_features(invoice_features)
ln_compare_features(myfeatures.for_invoice(), invoice_features)
def __str__(self):
return "LnAddr[{}, amount={}{} tags=[{}]]".format(
hexlify(self.pubkey.serialize()).decode('utf-8') if self.pubkey else None,
self.amount, self.net.BOLT11_HRP,
", ".join([k + '=' + str(v) for k, v in self.tags])
)
def get_min_final_cltv_delta(self) -> int:
cltv = self.get_tag('c')
if cltv is None:
return 18
return int(cltv)
def get_tag(self, tag):
for k, v in self.tags:
if k == tag:
return v
return None
def get_description(self) -> str:
return self.get_tag('d') or ''
def get_fallback_address(self) -> str:
return self.get_tag('f') or ''
def get_expiry(self) -> int:
exp = self.get_tag('x')
if exp is None:
exp = 3600
return int(exp)
def is_expired(self) -> bool:
now = time.time()
# BOLT-11 does not specify what expiration of '0' means.
# we treat it as 0 seconds here (instead of never)
return now > self.get_expiry() + self.date
def to_debug_json(self) -> Dict[str, Any]:
d = {
'pubkey': self.pubkey.serialize().hex(),
'amount_BTC': str(self.amount),
'rhash': self.paymenthash.hex(),
'payment_secret': self.payment_secret.hex() if self.payment_secret else None,
'description': self.get_description(),
'exp': self.get_expiry(),
'time': self.date,
'min_final_cltv_delta': self.get_min_final_cltv_delta(),
'features': self.get_features().get_names(),
'tags': self.tags,
'unknown_tags': self.unknown_tags,
}
if ln_routing_info := self.get_routing_info('r'):
d['r_tags'] = self.format_bolt11_routing_info_as_human_readable(ln_routing_info)
return d
class SerializableKey:
def __init__(self, pubkey):
self.pubkey = pubkey
def serialize(self):
return self.pubkey.get_public_key_bytes(True)
def lndecode(invoice: str, *, verbose=False, net=None) -> LnAddr:
"""Parses a string into an LnAddr object.
Can raise LnDecodeException or IncompatibleOrInsaneFeatures.
"""
if net is None:
net = constants.net
decoded_bech32 = bech32_decode(invoice, ignore_long_length=True)
hrp = decoded_bech32.hrp
data5 = decoded_bech32.data # "5" as in list of 5-bit integers
if decoded_bech32.encoding is None:
raise LnDecodeException("Bad bech32 checksum")
if decoded_bech32.encoding != segwit_addr.Encoding.BECH32:
raise LnDecodeException("Bad bech32 encoding: must be using vanilla BECH32")
# BOLT #11:
#
# A reader MUST fail if it does not understand the `prefix`.
if not hrp.startswith('ln'):
raise LnDecodeException("Does not start with ln")
if not hrp[2:].startswith(net.BOLT11_HRP):
raise LnDecodeException(f"Wrong Lightning invoice HRP {hrp[2:]}, should be {net.BOLT11_HRP}")
# Final signature 65 bytes, split it off.
if len(data5) < 65*8//5:
raise LnDecodeException("Too short to contain signature")
sigdecoded = bytes(convertbits(data5[-65*8//5:], 5, 8, False))
data5 = data5[:-65*8//5]
data5_remaining = bytearray(data5) # note: bytearray is faster than list of ints
addr = LnAddr()
addr.pubkey = None
addr.net = net
amountstr = hrp[2+len(net.BOLT11_HRP):]
# BOLT #11:
#
# A reader SHOULD indicate if amount is unspecified, otherwise it MUST
# multiply `amount` by the `multiplier` value (if any) to derive the
# amount required for payment.
if amountstr != '':
addr.amount = unshorten_amount(amountstr)
addr.date = int_from_data5(data5_remaining[:7])
data5_remaining = data5_remaining[7:]
while data5_remaining:
tag, tagdata = pull_tagged(data5_remaining) # mutates arg
# BOLT #11:
#
# A reader MUST skip over unknown fields, an `f` field with unknown
# `version`, or a `p`, `h`, or `n` field which does not have
# `data_length` 52, 52, or 53 respectively.
data_length = len(tagdata)
if tag == 'r':
# BOLT #11:
#
# * `r` (3): `data_length` variable. One or more entries
# containing extra routing information for a private route;
# there may be more than one `r` field, too.
# * `pubkey` (264 bits)
# * `short_channel_id` (64 bits)
# * `feebase` (32 bits, big-endian)
# * `feerate` (32 bits, big-endian)
# * `cltv_expiry_delta` (16 bits, big-endian)
tagdata = convertbits(tagdata, 5, 8, False)
if not tagdata:
continue
route = []
with io.BytesIO(bytes(tagdata)) as s:
while True:
pubkey = s.read(33)
scid = s.read(8)
feebase = s.read(4)
feerate = s.read(4)
cltv = s.read(2)
if len(cltv) != 2:
break # EOF
feebase = int.from_bytes(feebase, byteorder="big")
feerate = int.from_bytes(feerate, byteorder="big")
cltv = int.from_bytes(cltv, byteorder="big")
route.append((pubkey, scid, feebase, feerate, cltv))
if route:
addr.tags.append(('r',route))
elif tag == 't':
tagdata = convertbits(tagdata, 5, 8, False)
if not tagdata:
continue
route = []
with io.BytesIO(bytes(tagdata)) as s:
pubkey = s.read(33)
feebase = s.read(4)
feerate = s.read(4)
cltv = s.read(2)
if len(cltv) == 2: # no EOF
feebase = int.from_bytes(feebase, byteorder="big")
feerate = int.from_bytes(feerate, byteorder="big")
cltv = int.from_bytes(cltv, byteorder="big")
route.append((pubkey, feebase, feerate, cltv))
addr.tags.append(('t', route))
elif tag == 'f':
fallback = parse_fallback_addr(tagdata, addr.net)
if fallback:
addr.tags.append(('f', fallback))
else:
# Incorrect version.
addr.unknown_tags.append((tag, tagdata))
continue
elif tag == 'd':
addr.tags.append(('d', bytes(convertbits(tagdata, 5, 8, False)).decode('utf-8')))
elif tag == 'h':
if data_length != 52:
addr.unknown_tags.append((tag, tagdata))
continue
addr.tags.append(('h', bytes(convertbits(tagdata, 5, 8, False))))
elif tag == 'x':
addr.tags.append(('x', int_from_data5(tagdata)))
elif tag == 'p':
if data_length != 52:
addr.unknown_tags.append((tag, tagdata))
continue
addr.paymenthash = bytes(convertbits(tagdata, 5, 8, False))
elif tag == 's':
if data_length != 52:
addr.unknown_tags.append((tag, tagdata))
continue
addr.payment_secret = bytes(convertbits(tagdata, 5, 8, False))
elif tag == 'n':
if data_length != 53:
addr.unknown_tags.append((tag, tagdata))
continue
pubkeybytes = bytes(convertbits(tagdata, 5, 8, False))
addr.pubkey = pubkeybytes
elif tag == 'c':
addr.tags.append(('c', int_from_data5(tagdata)))
elif tag == '9':
features = int_from_data5(tagdata)
addr.tags.append(('9', features))
# note: The features are not validated here in the parser,
# instead, validation is done just before we try paying the invoice (in lnworker._check_bolt11_invoice).
# Context: invoice parsing happens when opening a wallet. If there was a backwards-incompatible
# change to a feature, and we raised, some existing wallets could not be opened. Such a change
# can happen to features not-yet-merged-to-BOLTs (e.g. trampoline feature bit was moved and reused).
else:
addr.unknown_tags.append((tag, tagdata))
if verbose:
print('hex of signature data (32 byte r, 32 byte s): {}'
.format(hexlify(sigdecoded[0:64])))
print('recovery flag: {}'.format(sigdecoded[64]))
data8 = bytes(convertbits(data5, 5, 8, True))
print('hex of data for signing: {}'
.format(hexlify(hrp.encode("ascii") + data8)))
print('SHA256 of above: {}'.format(sha256(hrp.encode("ascii") + data8).hexdigest()))
# BOLT #11:
#
# A reader MUST check that the `signature` is valid (see the `n` tagged
# field specified below).
addr.signature = sigdecoded[:65]
hrp_hash = sha256(hrp.encode("ascii") + bytes(convertbits(data5, 5, 8, True))).digest()
if addr.pubkey: # Specified by `n`
# BOLT #11:
#
# A reader MUST use the `n` field to validate the signature instead of
# performing signature recovery if a valid `n` field is provided.
if not ecc.ECPubkey(addr.pubkey).ecdsa_verify(sigdecoded[:64], hrp_hash):
raise LnDecodeException("bad signature")
pubkey_copy = addr.pubkey
class WrappedBytesKey:
serialize = lambda: pubkey_copy
addr.pubkey = WrappedBytesKey
else: # Recover pubkey from signature.
addr.pubkey = SerializableKey(ecc.ECPubkey.from_ecdsa_sig64(sigdecoded[:64], sigdecoded[64], hrp_hash))
return addr
================================================
FILE: electrum/lnchannel.py
================================================
# 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.
import dataclasses
import enum
from collections import defaultdict
from enum import IntEnum, Enum
from typing import (
Optional, Dict, List, Tuple, NamedTuple,
Iterable, Sequence, TYPE_CHECKING, Iterator, Union, Mapping)
import time
import threading
from abc import ABC, abstractmethod
import itertools
from aiorpcx import NetAddress
import attr
import electrum_ecc as ecc
from electrum_ecc import ECPubkey
from . import constants, util
from .util import bfh, chunks, TxMinedInfo, error_text_bytes_to_safe_str
from .bitcoin import redeem_script_to_address
from .crypto import sha256, sha256d
from .transaction import Transaction, PartialTransaction, TxInput, Sighash
from .logging import Logger
from .lntransport import LNPeerAddr
from .lnonion import OnionRoutingFailure
from . import lnutil
from .lnutil import (Outpoint, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, ChannelConstraints,
get_per_commitment_secret_from_seed, secret_to_pubkey, derive_privkey, make_closing_tx,
sign_and_get_sig_string, RevocationStore, derive_blinded_pubkey, Direction, derive_pubkey,
make_htlc_tx_with_open_channel, make_commitment, UpdateAddHtlc,
funding_output_script, SENT, RECEIVED, LOCAL, REMOTE, HTLCOwner, make_commitment_outputs,
ScriptHtlc, PaymentFailure, calc_fees_for_commitment_tx, RemoteMisbehaving, make_htlc_output_witness_script,
ShortChannelID, map_htlcs_to_ctx_output_idxs,
fee_for_htlc_output, offered_htlc_trim_threshold_sat,
received_htlc_trim_threshold_sat, make_commitment_output_to_remote_address, FIXED_ANCHOR_SAT,
ChannelType, LNProtocolWarning, ZEROCONF_TIMEOUT)
from .lnsweep import sweep_our_ctx, sweep_their_ctx
from .lnsweep import sweep_their_htlctx_justice, sweep_our_htlctx, SweepInfo, MaybeSweepInfo
from .lnsweep import sweep_their_ctx_to_remote_backup
from .lnhtlc import HTLCManager
from .lnmsg import encode_msg, decode_msg
from .address_synchronizer import TX_HEIGHT_LOCAL
from .lnutil import CHANNEL_OPENING_TIMEOUT_BLOCKS, CHANNEL_OPENING_TIMEOUT_SEC
from .lnutil import ChannelBackupStorage, ImportedChannelBackupStorage, OnchainChannelBackupStorage
from .lnutil import format_short_channel_id
from .fee_policy import FEERATE_PER_KW_MIN_RELAY_LIGHTNING
if TYPE_CHECKING:
from .lnworker import LNWallet
from .json_db import StoredDict
# channel flags
CF_ANNOUNCE_CHANNEL = 0x01
# lightning channel states
# Note: these states are persisted by name (for a given channel) in the wallet file,
# so consider doing a wallet db upgrade when changing them.
class ChannelState(IntEnum):
PREOPENING = 0 # Initial negotiation. Channel will not be reestablished
OPENING = 1 # Channel will be reestablished. (per BOLT2)
# - Funding node: has received funding_signed (can broadcast the funding tx)
# - Non-funding node: has sent the funding_signed message.
FUNDED = 2 # Funding tx was mined (requires min_depth and tx verification)
OPEN = 3 # both parties have sent funding_locked
SHUTDOWN = 4 # shutdown has been sent.
CLOSING = 5 # closing negotiation done. we have a fully signed tx.
FORCE_CLOSING = 6 # *we* force-closed, and closing tx is unconfirmed. Note that if the
# remote force-closes then we remain OPEN until it gets mined -
# the server could be lying to us with a fake tx.
REQUESTED_FCLOSE = 7 # Chan is open, but we have tried to request the *remote* to force-close
WE_ARE_TOXIC = 8 # Chan is open, but we have lost state and the remote proved this.
# The remote must force-close, it is *not* safe for us to do so.
CLOSED = 9 # closing tx has been mined
REDEEMED = 10 # we can stop watching
class PeerState(IntEnum):
DISCONNECTED = 0
REESTABLISHING = 1
GOOD = 2
BAD = 3
cs = ChannelState
state_transitions = [
(cs.PREOPENING, cs.OPENING),
(cs.OPENING, cs.FUNDED),
(cs.FUNDED, cs.OPEN),
(cs.OPENING, cs.SHUTDOWN),
(cs.FUNDED, cs.SHUTDOWN),
(cs.OPEN, cs.SHUTDOWN),
(cs.SHUTDOWN, cs.SHUTDOWN), # if we reestablish
(cs.SHUTDOWN, cs.CLOSING),
(cs.CLOSING, cs.CLOSING),
# we can force close almost any time
(cs.OPENING, cs.FORCE_CLOSING),
(cs.FUNDED, cs.FORCE_CLOSING),
(cs.OPEN, cs.FORCE_CLOSING),
(cs.SHUTDOWN, cs.FORCE_CLOSING),
(cs.CLOSING, cs.FORCE_CLOSING),
(cs.REQUESTED_FCLOSE, cs.FORCE_CLOSING),
# we can request a force-close almost any time
(cs.OPENING, cs.REQUESTED_FCLOSE),
(cs.FUNDED, cs.REQUESTED_FCLOSE),
(cs.OPEN, cs.REQUESTED_FCLOSE),
(cs.SHUTDOWN, cs.REQUESTED_FCLOSE),
(cs.CLOSING, cs.REQUESTED_FCLOSE),
(cs.REQUESTED_FCLOSE, cs.REQUESTED_FCLOSE),
# we can get force closed almost any time
(cs.OPENING, cs.CLOSED),
(cs.FUNDED, cs.CLOSED),
(cs.OPEN, cs.CLOSED),
(cs.SHUTDOWN, cs.CLOSED),
(cs.CLOSING, cs.CLOSED),
(cs.REQUESTED_FCLOSE, cs.CLOSED),
(cs.WE_ARE_TOXIC, cs.CLOSED),
# during channel_reestablish, we might realise we have lost state
(cs.OPENING, cs.WE_ARE_TOXIC),
(cs.FUNDED, cs.WE_ARE_TOXIC),
(cs.OPEN, cs.WE_ARE_TOXIC),
(cs.SHUTDOWN, cs.WE_ARE_TOXIC),
(cs.REQUESTED_FCLOSE, cs.WE_ARE_TOXIC),
(cs.WE_ARE_TOXIC, cs.WE_ARE_TOXIC),
#
(cs.FORCE_CLOSING, cs.FORCE_CLOSING), # allow multiple attempts
(cs.FORCE_CLOSING, cs.CLOSED),
(cs.FORCE_CLOSING, cs.REDEEMED),
(cs.CLOSED, cs.REDEEMED),
(cs.OPENING, cs.REDEEMED), # channel never funded (dropped from mempool)
(cs.PREOPENING, cs.REDEEMED), # channel never funded
]
del cs # delete as name is ambiguous without context
class ChanCloseOption(Enum):
COOP_CLOSE = enum.auto()
LOCAL_FCLOSE = enum.auto()
REQUEST_REMOTE_FCLOSE = enum.auto()
class RevokeAndAck(NamedTuple):
per_commitment_secret: bytes
next_per_commitment_point: bytes
class RemoteCtnTooFarInFuture(Exception): pass
def htlcsum(htlcs: Iterable[UpdateAddHtlc]):
return sum([x.amount_msat for x in htlcs])
def now():
return int(time.time())
class HTLCWithStatus(NamedTuple):
channel_id: bytes
htlc: UpdateAddHtlc
direction: Direction
status: str
class AbstractChannel(Logger, ABC):
storage: Union['StoredDict', dict]
config: Dict[HTLCOwner, Union[LocalConfig, RemoteConfig]]
lnworker: 'LNWallet'
channel_id: bytes
short_channel_id: Optional[ShortChannelID] = None
funding_outpoint: Outpoint
node_id: bytes # note that it might not be the full 33 bytes; for OCB it is only the prefix
should_request_force_close: bool = False
_state: ChannelState
_who_closed: Optional[int] = None # HTLCOwner (1 or -1). 0 means "unknown"
def set_short_channel_id(self, short_id: ShortChannelID) -> None:
self.short_channel_id = short_id
self.storage["short_channel_id"] = short_id
def get_id_for_log(self) -> str:
scid = self.short_channel_id
if scid:
return str(scid)
return self.channel_id.hex()
def short_id_for_GUI(self) -> str:
return format_short_channel_id(self.short_channel_id)
def diagnostic_name(self):
return self.get_id_for_log()
def set_state(self, state: ChannelState, *, force: bool = False) -> None:
"""Set on-chain state.
`force` can be set while debugging from the console to allow illegal transitions.
"""
old_state = self._state
if not force and (old_state, state) not in state_transitions:
raise Exception(f"Transition not allowed: {old_state.name} -> {state.name}")
self.logger.debug(f'Setting channel state: {old_state.name} -> {state.name}')
self._state = state
self.storage['state'] = self._state.name
self.lnworker.channel_state_changed(self)
def get_state(self) -> ChannelState:
return self._state
def is_funded(self) -> bool:
return self.get_state() >= ChannelState.FUNDED
def is_open(self) -> bool:
return self.get_state() == ChannelState.OPEN
def is_closed(self) -> bool:
# the closing txid has been saved
return self.get_state() >= ChannelState.CLOSING
def is_closed_or_closing(self):
# related: self.get_state_for_GUI
return self.is_closed() or self.unconfirmed_closing_txid is not None
def is_redeemed(self) -> bool:
return self.get_state() == ChannelState.REDEEMED
def need_to_subscribe(self) -> bool:
"""Whether lnwatcher/synchronizer need to be watching this channel."""
if not self.is_redeemed():
return True
# Chan already deeply closed. Still, if some txs are missing, we should sub.
# check we have funding tx
# note: tx might not be directly related to the wallet, e.g. chan opened by remote
if (funding_item := self.get_funding_height()) is None:
return True
funding_txid, funding_height, funding_timestamp = funding_item
if self.lnworker.wallet.adb.get_transaction(funding_txid) is None:
return True
# check we have closing tx
# note: tx might not be directly related to the wallet, e.g. local-fclose
if (closing_item := self.get_closing_height()) is None:
return True
closing_txid, closing_height, closing_timestamp = closing_item
if self.lnworker.wallet.adb.get_transaction(closing_txid) is None:
return True
return False
@abstractmethod
def get_close_options(self) -> Sequence[ChanCloseOption]:
pass
def save_funding_height(self, *, txid: str, height: int, timestamp: Optional[int]) -> None:
self.storage['funding_height'] = txid, height, timestamp
def get_funding_height(self) -> Optional[Tuple[str, int, Optional[int]]]:
return self.storage.get('funding_height')
def delete_funding_height(self):
self.storage.pop('funding_height', None)
def save_closing_height(self, *, txid: str, height: int, timestamp: Optional[int]) -> None:
self.storage['closing_height'] = txid, height, timestamp
def get_closing_height(self) -> Optional[Tuple[str, int, Optional[int]]]:
return self.storage.get('closing_height')
def delete_closing_height(self):
self.storage.pop('closing_height', None)
def create_sweeptxs_for_our_ctx(self, ctx: Transaction) -> Dict[str, MaybeSweepInfo]:
return sweep_our_ctx(chan=self, ctx=ctx)
def create_sweeptxs_for_their_ctx(self, ctx: Transaction) -> Dict[str, MaybeSweepInfo]:
return sweep_their_ctx(chan=self, ctx=ctx)
def is_backup(self) -> bool:
return False
def get_local_scid_alias(self, *, create_new_if_needed: bool = False) -> Optional[bytes]:
return None
def get_remote_scid_alias(self) -> Optional[bytes]:
return None
def get_remote_peer_sent_error(self) -> Optional[str]:
return None
def get_ctx_sweep_info(self, ctx: Transaction) -> Tuple[bool, Dict[str, MaybeSweepInfo]]:
our_sweep_info = self.create_sweeptxs_for_our_ctx(ctx)
their_sweep_info = self.create_sweeptxs_for_their_ctx(ctx)
if our_sweep_info:
sweep_info = our_sweep_info
who_closed = LOCAL
elif their_sweep_info:
sweep_info = their_sweep_info
who_closed = REMOTE
else:
sweep_info = {}
who_closed = 0
if self._who_closed != who_closed: # mostly here to limit log spam
self._who_closed = who_closed
if who_closed == LOCAL:
self.logger.info(f'we (local) force closed')
elif who_closed == REMOTE:
self.logger.info(f'they (remote) force closed.')
else:
self.logger.info(f'not sure who closed. maybe co-op close?')
is_local_ctx = who_closed == LOCAL
return is_local_ctx, sweep_info
def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, MaybeSweepInfo]:
return {}
def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None:
return
def update_onchain_state(self, *, funding_txid: str, funding_height: TxMinedInfo,
closing_txid: str, closing_height: TxMinedInfo, keep_watching: bool) -> None:
# note: state transitions are irreversible, but
# save_funding_height, save_closing_height are reversible
if funding_height.height() == TX_HEIGHT_LOCAL:
self.update_unfunded_state()
elif closing_height.height() == TX_HEIGHT_LOCAL:
self.update_funded_state(
funding_txid=funding_txid,
funding_height=funding_height)
else:
self.update_closed_state(
funding_txid=funding_txid,
funding_height=funding_height,
closing_txid=closing_txid,
closing_height=closing_height,
keep_watching=keep_watching)
def update_unfunded_state(self) -> None:
self.delete_funding_height()
self.delete_closing_height()
state = self.get_state()
if state in [ChannelState.PREOPENING, ChannelState.OPENING, ChannelState.FORCE_CLOSING]:
if self.is_initiator():
# set channel state to REDEEMED so that it can be removed manually
# to protect ourselves against a server lying by omission,
# we check that funding_inputs have been double spent and deeply mined
inputs = self.storage.get('funding_inputs', [])
if not inputs:
self.logger.info(f'channel funding inputs are not provided')
self.set_state(ChannelState.REDEEMED)
for i in inputs:
spender_txid = self.lnworker.wallet.db.get_spent_outpoint(*i)
if spender_txid is None:
continue
if spender_txid != self.funding_outpoint.txid:
tx_mined_height = self.lnworker.wallet.adb.get_tx_height(spender_txid)
if tx_mined_height.conf > lnutil.REDEEM_AFTER_DOUBLE_SPENT_DELAY:
self.logger.info(f'channel is double spent {inputs}')
self.set_state(ChannelState.REDEEMED)
break
elif self.has_funding_timed_out():
self.logger.warning(f"dropping incoming channel, funding tx not found in mempool")
self.lnworker.remove_channel(self.channel_id)
elif self.is_zeroconf() and state in [ChannelState.OPEN, ChannelState.CLOSING, ChannelState.FORCE_CLOSING]:
chan_age = now() - self.storage['init_timestamp']
# handling zeroconf channels with no funding tx, can happen if broadcasting fails on LSP side
# or if the LSP did double spent the funding tx/never published it intentionally
# only remove a timed out OPEN channel if we are connected to the network to prevent removing it if we went
# offline before seeing the funding tx
if state != ChannelState.OPEN or chan_age > ZEROCONF_TIMEOUT and self.lnworker.network.is_connected():
# we delete the channel if its in closing state (either initiated manually by client or by LSP on failure)
# or if the channel is not seeing any funding tx after 10 minutes to prevent further usage (limit damage)
self.set_state(ChannelState.REDEEMED, force=True)
local_balance_sat = int(self.balance(LOCAL) // 1000)
if local_balance_sat > 0:
self.logger.warning(
f"we may have been scammed out of {local_balance_sat} sat by our "
f"JIT provider: {self.lnworker.config.ZEROCONF_TRUSTED_NODE} or he didn't use our preimage")
self.lnworker.config.ZEROCONF_TRUSTED_NODE = ''
# FIXME this is broken: lnwatcher.unwatch_channel does not exist
self.lnworker.lnwatcher.unwatch_channel(self.get_funding_address(), self.funding_outpoint.to_str())
# remove remaining local transactions from the wallet, this will also remove child transactions (closing tx)
self.lnworker.lnwatcher.adb.remove_transaction(self.funding_outpoint.txid)
self.lnworker.remove_channel(self.channel_id)
def update_funded_state(self, *, funding_txid: str, funding_height: TxMinedInfo) -> None:
self.save_funding_height(txid=funding_txid, height=funding_height.height(), timestamp=funding_height.timestamp)
self.delete_closing_height()
if funding_height.conf>0:
self.set_short_channel_id(ShortChannelID.from_components(
funding_height.height(), funding_height.txpos, self.funding_outpoint.output_index))
elif self.has_funding_timed_out():
self.logger.warning("dropping incoming channel, funding tx took too long to confirm")
self.lnworker.remove_channel(self.channel_id)
return
if self.get_state() == ChannelState.OPENING:
if self.is_funding_tx_mined(funding_height):
self.set_state(ChannelState.FUNDED)
elif self.is_zeroconf() and funding_height.conf >= 3 and not self.should_request_force_close:
if not self.is_funding_tx_mined(funding_height):
# funding tx is invalid (invalid amount or address) we need to get rid of the channel again
self.should_request_force_close = True
if peer := self.lnworker.lnpeermgr.get_peer_by_pubkey(self.node_id):
# reconnect to trigger force close request
peer.close_and_cleanup()
else:
# remove zeroconf flag as we are now confirmed, this is to prevent an electrum server causing
# us to remove a channel later in update_unfunded_state by omitting its funding tx
self.remove_zeroconf_flag()
def update_closed_state(self, *, funding_txid: str, funding_height: TxMinedInfo,
closing_txid: str, closing_height: TxMinedInfo, keep_watching: bool) -> None:
self.save_funding_height(txid=funding_txid, height=funding_height.height(), timestamp=funding_height.timestamp)
self.save_closing_height(txid=closing_txid, height=closing_height.height(), timestamp=closing_height.timestamp)
if funding_height.conf>0:
self.set_short_channel_id(ShortChannelID.from_components(
funding_height.height(), funding_height.txpos, self.funding_outpoint.output_index))
if self.get_state() < ChannelState.CLOSED:
conf = closing_height.conf
if conf > 0:
self.set_state(ChannelState.CLOSED)
self.lnworker.wallet.txbatcher.set_password_future(None)
else:
# we must not trust the server with unconfirmed transactions,
# because the state transition is irreversible. if the remote
# force closed, we remain OPEN until the closing tx is confirmed
self.unconfirmed_closing_txid = closing_txid
util.trigger_callback('channel', self.lnworker.wallet, self)
if self.get_state() == ChannelState.CLOSED and not keep_watching:
self.set_state(ChannelState.REDEEMED)
if self.is_backup():
# auto-remove redeemed backups
self.lnworker.remove_channel_backup(self.channel_id)
@abstractmethod
def is_initiator(self) -> bool:
pass
@abstractmethod
def is_public(self) -> bool:
pass
@abstractmethod
def is_zeroconf(self) -> bool:
pass
@abstractmethod
def remove_zeroconf_flag(self) -> None:
pass
@abstractmethod
def is_funding_tx_mined(self, funding_height: TxMinedInfo) -> bool:
pass
@abstractmethod
def get_funding_address(self) -> str:
pass
def get_funding_tx(self) -> Optional[Transaction]:
funding_txid = self.funding_outpoint.txid
return self.lnworker.lnwatcher.adb.get_transaction(funding_txid)
@abstractmethod
def get_sweep_address(self) -> str:
"""Returns a wallet address we can use to sweep coins to.
It could be something static to the channel (fixed for its lifecycle),
or it might just ask the wallet now for an unused address.
"""
pass
def get_state_for_GUI(self) -> str:
cs = self.get_state()
if cs <= ChannelState.OPEN and self.unconfirmed_closing_txid:
return 'FORCE-CLOSING'
return cs.name
@abstractmethod
def get_oldest_unrevoked_ctn(self, subject: HTLCOwner) -> int:
pass
@abstractmethod
def included_htlcs(self, subject: HTLCOwner, direction: Direction, ctn: int = None) -> Sequence[UpdateAddHtlc]:
pass
@abstractmethod
def funding_txn_minimum_depth(self) -> int:
pass
@abstractmethod
def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int:
"""This balance (in msat) only considers HTLCs that have been settled by ctn.
It disregards reserve, fees, and pending HTLCs (in both directions).
"""
pass
@abstractmethod
def balance_minus_outgoing_htlcs(self, whose: HTLCOwner, *,
ctx_owner: HTLCOwner = HTLCOwner.LOCAL,
ctn: int = None) -> int:
"""This balance (in msat), which includes the value of
pending outgoing HTLCs, is used in the UI.
"""
pass
@abstractmethod
def is_frozen_for_sending(self) -> bool:
"""Whether the user has marked this channel as frozen for sending.
Frozen channels are not supposed to be used for new outgoing payments.
(note that payment-forwarding ignores this option)
"""
pass
@abstractmethod
def is_frozen_for_receiving(self) -> bool:
"""Whether the user has marked this channel as frozen for receiving.
Frozen channels are not supposed to be used for new incoming payments.
(note that payment-forwarding ignores this option)
"""
pass
@abstractmethod
def get_local_pubkey(self) -> bytes:
"""Returns our node ID."""
pass
@abstractmethod
def get_capacity(self) -> Optional[int]:
"""Returns channel capacity in satoshis, or None if unknown."""
pass
@abstractmethod
def can_be_deleted(self) -> bool:
pass
@abstractmethod
def has_funding_timed_out(self) -> bool:
pass
@abstractmethod
def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]:
"""Returns a list of addrs that the wallet should not use, to avoid address-reuse.
Typically, these addresses are wallet.is_mine, but that is not guaranteed,
in which case the wallet can just ignore those.
"""
pass
def has_anchors(self) -> bool:
pass
class ChannelBackup(AbstractChannel):
"""
current capabilities:
- detect force close
- request force close
- sweep my ctx to_local
future:
- will need to sweep their ctx to_remote
"""
def __init__(self, cb: ChannelBackupStorage, *, lnworker: 'LNWallet'):
self.name = None
self.cb = cb
self.is_imported = isinstance(self.cb, ImportedChannelBackupStorage)
self.storage = {} # dummy storage
self._state = ChannelState.OPENING
self.node_id = cb.node_id if self.is_imported else cb.node_id_prefix
self.channel_id = cb.channel_id()
self.funding_outpoint = cb.funding_outpoint()
self.lnworker = lnworker
self.short_channel_id = None
Logger.__init__(self)
self.config = {}
if self.is_imported:
assert isinstance(cb, ImportedChannelBackupStorage)
self.init_config(cb)
self.unconfirmed_closing_txid = None # not a state, only for GUI
def init_config(self, cb: ImportedChannelBackupStorage):
local_payment_pubkey = cb.local_payment_pubkey
if local_payment_pubkey is None:
self.logger.warning(
f"local_payment_pubkey missing from (old-type) channel backup. "
f"You should export and re-import a newer backup.")
multisig_funding_keypair = None
if multisig_funding_secret := cb.multisig_funding_privkey:
multisig_funding_keypair = Keypair(
privkey=multisig_funding_secret,
pubkey=ecc.ECPrivkey(multisig_funding_secret).get_public_key_bytes(),
)
self.config[LOCAL] = LocalConfig.from_seed(
channel_seed=cb.channel_seed,
to_self_delay=cb.local_delay,
# there are three cases of backups:
# 1. legacy: payment_basepoint will be derived
# 2. static_remotekey: to_remote sweep not necessary due to wallet address
# 3. anchor outputs: sweep to_remote by deriving the key from the funding pubkeys
static_remotekey=local_payment_pubkey,
multisig_key=multisig_funding_keypair,
# dummy values
static_payment_key=None,
dust_limit_sat=None,
max_htlc_value_in_flight_msat=None,
max_accepted_htlcs=None,
initial_msat=None,
reserve_sat=None,
funding_locked_received=False,
current_commitment_signature=None,
current_htlc_signatures=b'',
htlc_minimum_msat=1,
upfront_shutdown_script='',
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
)
self.config[REMOTE] = RemoteConfig(
# payment_basepoint needed to deobfuscate ctn in our_ctx
payment_basepoint=OnlyPubkeyKeypair(cb.remote_payment_pubkey),
# revocation_basepoint is used to claim to_local in our ctx
revocation_basepoint=OnlyPubkeyKeypair(cb.remote_revocation_pubkey),
to_self_delay=cb.remote_delay,
# dummy values
multisig_key=OnlyPubkeyKeypair(None),
htlc_basepoint=OnlyPubkeyKeypair(None),
delayed_basepoint=OnlyPubkeyKeypair(None),
dust_limit_sat=None,
max_htlc_value_in_flight_msat=None,
max_accepted_htlcs=None,
initial_msat = None,
reserve_sat = None,
htlc_minimum_msat=None,
next_per_commitment_point=None,
current_per_commitment_point=None,
upfront_shutdown_script='',
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
)
def can_be_deleted(self):
return self.is_imported or self.is_redeemed()
def has_funding_timed_out(self):
return False
def get_capacity(self):
lnwatcher = self.lnworker.lnwatcher
if lnwatcher:
# fixme: we should probably not call that method here
return lnwatcher.adb.get_tx_delta(self.funding_outpoint.txid, self.cb.funding_address)
return None
def is_backup(self):
return True
def create_sweeptxs_for_their_ctx(self, ctx):
funding_tx = self.get_funding_tx()
assert funding_tx
return sweep_their_ctx_to_remote_backup(chan=self, ctx=ctx, funding_tx=funding_tx)
def create_sweeptxs_for_our_ctx(self, ctx):
if self.is_imported:
return sweep_our_ctx(chan=self, ctx=ctx)
else:
return {}
def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, MaybeSweepInfo]:
return {}
def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None:
return None
def get_funding_address(self):
return self.cb.funding_address
def is_initiator(self):
return self.cb.is_initiator
def is_public(self):
return False
def get_oldest_unrevoked_ctn(self, who):
return -1
def included_htlcs(self, subject, direction, ctn=None):
return []
def funding_txn_minimum_depth(self):
return 1
def is_funding_tx_mined(self, funding_height):
return funding_height.conf > 1
def balance_minus_outgoing_htlcs(self, whose: HTLCOwner, *, ctx_owner: HTLCOwner = HTLCOwner.LOCAL, ctn: int = None):
return 0
def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int:
return 0
def is_frozen_for_sending(self) -> bool:
return False
def is_frozen_for_receiving(self) -> bool:
return False
def get_sweep_address(self) -> str:
return self.lnworker.wallet.get_new_sweep_address_for_channel()
def has_anchors(self) -> Optional[bool]:
return None
def is_zeroconf(self) -> bool:
return False
def remove_zeroconf_flag(self) -> None:
pass
def get_local_pubkey(self) -> bytes:
cb = self.cb
assert isinstance(cb, ChannelBackupStorage)
if isinstance(cb, ImportedChannelBackupStorage):
return ecc.ECPrivkey(cb.privkey).get_public_key_bytes(compressed=True)
if isinstance(cb, OnchainChannelBackupStorage):
return self.lnworker.node_keypair.pubkey
raise NotImplementedError(f"unexpected cb type: {type(cb)}")
def get_close_options(self) -> Sequence[ChanCloseOption]:
ret = []
if self.get_state() == ChannelState.FUNDED:
ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
return ret
def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]:
if self.is_imported:
# For v1 imported cbs, we have the local_payment_pubkey, which is
# directly used as p2wpkh() of static_remotekey channels.
# (for v0 imported cbs, the correct local_payment_pubkey is missing, and so
# we might calculate a different address here, which might not be wallet.is_mine,
# but that should be harmless)
our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
to_remote_address = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
return [to_remote_address]
else: # on-chain backup
return []
class Channel(AbstractChannel):
# note: try to avoid naming ctns/ctxs/etc as "current" and "pending".
# they are ambiguous. Use "oldest_unrevoked" or "latest" or "next".
# TODO enforce this ^
# our forwarding parameters for forwarding HTLCs through this channel
forwarding_cltv_delta = 144
forwarding_fee_base_msat = 1000
forwarding_fee_proportional_millionths = 1
def __repr__(self):
return "Channel(%s)"%self.get_id_for_log()
def __init__(
self,
state: 'StoredDict', *,
name=None,
lnworker: 'LNWallet',
initial_feerate=None,
jit_opening_fee: Optional[int] = None,
):
self.jit_opening_fee = jit_opening_fee
self.name = name
self.channel_id = bfh(state["channel_id"])
self.short_channel_id = ShortChannelID.normalize(state["short_channel_id"])
Logger.__init__(self) # should be after short_channel_id is set
self.lnworker = lnworker
self.storage = state
self.db_lock = self.storage.lock
self.config = {}
self.config[LOCAL] = state["local_config"]
self.config[REMOTE] = state["remote_config"]
self.constraints = state["constraints"] # type: ChannelConstraints
self.funding_outpoint = state["funding_outpoint"]
self.node_id = bfh(state["node_id"])
self.onion_keys = state['onion_keys'] # type: Dict[int, bytes]
self.data_loss_protect_remote_pcp = state['data_loss_protect_remote_pcp']
self.hm = HTLCManager(log=state['log'], initiator = LOCAL if self.constraints.is_initiator else REMOTE, initial_feerate=initial_feerate)
self.unfulfilled_htlcs = state["unfulfilled_htlcs"] # type: Dict[int, Optional[str]]
# ^ htlc_id -> onion_packet_hex
self._state = ChannelState[state['state']]
self.peer_state = PeerState.DISCONNECTED
self._outgoing_channel_update = None # type: Optional[bytes]
self.revocation_store = RevocationStore(state["revocation_store"])
self._can_send_ctx_updates = True # type: bool
self._receive_fail_reasons = {} # type: Dict[int, (bytes, OnionRoutingFailure)]
self.unconfirmed_closing_txid = None # not a state, only for GUI
self.sent_channel_ready = False # no need to persist this, because channel_ready is re-sent in channel_reestablish
self.sent_announcement_signatures = False
self.htlc_settle_time = {}
def get_local_scid_alias(self, *, create_new_if_needed: bool = False) -> Optional[bytes]:
"""Get scid_alias to be used for *outgoing* HTLCs.
(called local as we choose the value)
"""
if alias := self.storage.get('local_scid_alias'):
return bytes.fromhex(alias)
elif create_new_if_needed:
# deterministic, same secrecy level as wallet master pubkey
wallet_fingerprint = bytes(self.lnworker.wallet.get_fingerprint(), "utf8")
alias = sha256(wallet_fingerprint + self.channel_id)[0:8]
self.storage['local_scid_alias'] = alias.hex()
return alias
return None
def save_remote_scid_alias(self, alias: bytes):
self.storage['alias'] = alias.hex()
def get_remote_scid_alias(self) -> Optional[bytes]:
"""Get scid_alias to be used for *incoming* HTLCs.
(called remote as the remote chooses the value)
"""
alias = self.storage.get('alias')
return bytes.fromhex(alias) if alias else None
def get_scid_or_local_alias(self):
return self.short_channel_id or self.get_local_scid_alias()
def has_onchain_backup(self):
return self.storage.get('has_onchain_backup', False)
def can_be_deleted(self) -> bool:
if self.has_funding_timed_out():
return True
return self.is_redeemed()
def has_funding_timed_out(self):
if self.is_initiator() or self.is_funded():
return False
if self.lnworker.network.blockchain().is_tip_stale() or not self.lnworker.wallet.is_up_to_date():
return False
init_height = self.storage.get('init_height', 0)
init_timestamp = self.storage.get('init_timestamp', 0)
age_blocks = self.lnworker.network.get_local_height() - init_height
age_sec = now() - init_timestamp
# some channels might not have init_height set so we check both time and block based timeouts
return age_blocks > CHANNEL_OPENING_TIMEOUT_BLOCKS and age_sec > CHANNEL_OPENING_TIMEOUT_SEC
def get_capacity(self):
return self.constraints.capacity
def is_public(self):
return bool(self.constraints.flags & CF_ANNOUNCE_CHANNEL)
def is_initiator(self):
return self.constraints.is_initiator
def is_active(self):
return self.get_state() == ChannelState.OPEN and self.peer_state == PeerState.GOOD
def funding_txn_minimum_depth(self):
return self.constraints.funding_txn_minimum_depth
def diagnostic_name(self):
if self.name:
return str(self.name)
return super().diagnostic_name()
def set_onion_key(self, key: int, value: bytes):
self.onion_keys[key] = value
def pop_onion_key(self, key: int) -> bytes:
return self.onion_keys.pop(key)
def set_data_loss_protect_remote_pcp(self, key, value):
self.data_loss_protect_remote_pcp[key] = value
def get_data_loss_protect_remote_pcp(self, key):
return self.data_loss_protect_remote_pcp.get(key)
def get_local_pubkey(self) -> bytes:
return self.lnworker.node_keypair.pubkey
def set_remote_update(self, payload: dict) -> None:
"""Save the ChannelUpdate message for the incoming direction of this channel.
This message contains info we need to populate private route hints when
creating invoices.
"""
assert payload['short_channel_id'] in [self.short_channel_id, self.get_local_scid_alias()]
from .channel_db import ChannelDB
ChannelDB.verify_channel_update(payload, start_node=self.node_id)
raw = payload['raw']
self.storage['remote_update'] = raw.hex()
def get_remote_update(self) -> Optional[bytes]:
return bfh(self.storage.get('remote_update')) if self.storage.get('remote_update') else None
def add_or_update_peer_addr(self, peer: LNPeerAddr) -> None:
if 'peer_network_addresses' not in self.storage:
self.storage['peer_network_addresses'] = {}
self.storage['peer_network_addresses'][peer.net_addr_str()] = now()
def get_peer_addresses(self) -> Iterator[LNPeerAddr]:
# sort by timestamp: most recent first
addrs = sorted(self.storage.get('peer_network_addresses', {}).items(),
key=lambda x: x[1], reverse=True)
for net_addr_str, ts in addrs:
net_addr = NetAddress.from_string(net_addr_str)
yield LNPeerAddr(host=str(net_addr.host), port=net_addr.port, pubkey=self.node_id)
def save_remote_peer_sent_error(self, original_error: bytes):
# We save the original arbitrary text(/bytes) error, as received.
# The length is only implicitly limited by the BOLT-08 max msg size.
# Receiving an error usually results in the channel getting closed, so
# there is likely no need to store multiple errors. We only store one, and overwrite.
self.storage['remote_peer_sent_error'] = original_error.hex()
def get_remote_peer_sent_error(self) -> Optional[str]:
original_error = self.storage.get('remote_peer_sent_error')
if not original_error:
return None
err_bytes = bytes.fromhex(original_error)
safe_str = error_text_bytes_to_safe_str(err_bytes) # note: truncates
return safe_str
def get_outgoing_gossip_channel_update(self, *, scid: ShortChannelID = None) -> bytes:
"""
scid: to be put into the channel_update message instead of the real scid, as this might be an scid alias
"""
if self._outgoing_channel_update is not None and scid is None:
return self._outgoing_channel_update
if scid is None:
scid = self.short_channel_id
sorted_node_ids = list(sorted([self.node_id, self.get_local_pubkey()]))
channel_flags = b'\x00' if sorted_node_ids[0] == self.get_local_pubkey() else b'\x01'
htlc_maximum_msat = min(self.config[REMOTE].max_htlc_value_in_flight_msat, 1000 * self.constraints.capacity)
chan_upd = encode_msg(
"channel_update",
short_channel_id=scid,
channel_flags=channel_flags,
message_flags=b'\x01',
cltv_expiry_delta=self.forwarding_cltv_delta,
htlc_minimum_msat=self.config[REMOTE].htlc_minimum_msat,
htlc_maximum_msat=htlc_maximum_msat,
fee_base_msat=self.forwarding_fee_base_msat,
fee_proportional_millionths=self.forwarding_fee_proportional_millionths,
chain_hash=constants.net.rev_genesis_bytes(),
timestamp=now(),
)
sighash = sha256d(chan_upd[2 + 64:])
sig = ecc.ECPrivkey(self.lnworker.node_keypair.privkey).ecdsa_sign(sighash, sigencode=ecc.ecdsa_sig64_from_r_and_s)
message_type, payload = decode_msg(chan_upd)
payload['signature'] = sig
chan_upd = encode_msg(message_type, **payload)
self._outgoing_channel_update = chan_upd
return chan_upd
def construct_channel_announcement_without_sigs(self) -> Tuple[bytes, bool]:
bitcoin_keys = [
self.config[REMOTE].multisig_key.pubkey,
self.config[LOCAL].multisig_key.pubkey]
node_ids = [self.node_id, self.get_local_pubkey()]
is_reverse = node_ids[0] > node_ids[1]
if is_reverse:
node_ids.reverse()
bitcoin_keys.reverse()
chan_ann = encode_msg(
"channel_announcement",
len=0,
features=b'',
chain_hash=constants.net.rev_genesis_bytes(),
short_channel_id=self.short_channel_id,
node_id_1=node_ids[0],
node_id_2=node_ids[1],
bitcoin_key_1=bitcoin_keys[0],
bitcoin_key_2=bitcoin_keys[1],
)
return chan_ann, is_reverse
def get_channel_announcement_hash(self):
chan_ann, _ = self.construct_channel_announcement_without_sigs()
return sha256d(chan_ann[256+2:])
def is_static_remotekey_enabled(self) -> bool:
channel_type = ChannelType(self.storage.get('channel_type'))
return bool(channel_type & ChannelType.OPTION_STATIC_REMOTEKEY)
def is_zeroconf(self) -> bool:
channel_type = ChannelType(self.storage.get('channel_type'))
return bool(channel_type & ChannelType.OPTION_ZEROCONF)
def remove_zeroconf_flag(self) -> None:
if not self.is_zeroconf():
return
channel_type = ChannelType(self.storage.get('channel_type'))
self.storage['channel_type'] = channel_type & ~ChannelType.OPTION_ZEROCONF
def get_sweep_address(self) -> str:
# TODO: in case of unilateral close with pending HTLCs, this address will be reused
if self.has_anchors():
addr = self.lnworker.wallet.get_new_sweep_address_for_channel()
elif self.is_static_remotekey_enabled():
our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
addr = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
assert self.lnworker.wallet.is_mine(addr)
return addr
def has_anchors(self) -> bool:
channel_type = ChannelType(self.storage.get('channel_type'))
return bool(channel_type & ChannelType.OPTION_ANCHORS_ZERO_FEE_HTLC_TX)
def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]:
assert self.is_static_remotekey_enabled()
our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
to_remote_address = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
return [to_remote_address]
def get_feerate(self, subject: HTLCOwner, *, ctn: int) -> int:
# returns feerate in sat/kw
return self.hm.get_feerate(subject, ctn)
def get_oldest_unrevoked_feerate(self, subject: HTLCOwner) -> int:
return self.hm.get_feerate_in_oldest_unrevoked_ctx(subject)
def get_latest_feerate(self, subject: HTLCOwner) -> int:
return self.hm.get_feerate_in_latest_ctx(subject)
def get_next_feerate(self, subject: HTLCOwner) -> int:
return self.hm.get_feerate_in_next_ctx(subject)
def get_payments(self, status=None) -> Mapping[bytes, List[HTLCWithStatus]]:
out = defaultdict(list)
for direction, htlc in self.hm.all_htlcs_ever():
htlc_proposer = LOCAL if direction is SENT else REMOTE
if self.hm.was_htlc_failed(htlc_id=htlc.htlc_id, htlc_proposer=htlc_proposer):
_status = 'failed'
elif self.hm.was_htlc_preimage_released(htlc_id=htlc.htlc_id, htlc_proposer=htlc_proposer):
_status = 'settled'
else:
_status = 'inflight'
if status and status != _status:
continue
htlc_with_status = HTLCWithStatus(
channel_id=self.channel_id, htlc=htlc, direction=direction, status=_status)
out[htlc.payment_hash].append(htlc_with_status)
return out
def open_with_first_pcp(self, remote_pcp: bytes, remote_sig: bytes) -> None:
with self.db_lock:
self.config[REMOTE].current_per_commitment_point = remote_pcp
self.config[REMOTE].next_per_commitment_point = None
self.config[LOCAL].current_commitment_signature = remote_sig
self.hm.channel_open_finished()
self.peer_state = PeerState.GOOD
def get_state_for_GUI(self):
cs_name = super().get_state_for_GUI()
if self.is_closed() or self.unconfirmed_closing_txid:
return cs_name
ps = self.peer_state
if ps != PeerState.GOOD:
return ps.name
return cs_name
def set_can_send_ctx_updates(self, b: bool) -> None:
self._can_send_ctx_updates = b
def can_update_ctx(self, *, proposer: HTLCOwner) -> bool:
"""Whether proposer is allowed to send commitment_signed, revoke_and_ack,
and update_* messages.
"""
if self.get_state() not in (ChannelState.OPEN, ChannelState.SHUTDOWN):
return False
if self.peer_state != PeerState.GOOD:
return False
if proposer == LOCAL:
if not self._can_send_ctx_updates:
return False
return True
def can_send_update_add_htlc(self) -> bool:
return self.can_update_ctx(proposer=LOCAL) and self.is_open()
def is_frozen_for_sending(self) -> bool:
if self.lnworker.uses_trampoline() and not self.lnworker.is_trampoline_peer(self.node_id):
return True
return self.storage.get('frozen_for_sending', False)
def set_frozen_for_sending(self, b: bool) -> None:
self.storage['frozen_for_sending'] = bool(b)
util.trigger_callback('channel', self.lnworker.wallet, self)
def is_frozen_for_receiving(self) -> bool:
if self.lnworker.uses_trampoline() and not self.lnworker.is_trampoline_peer(self.node_id):
return True
return self.storage.get('frozen_for_receiving', False)
def set_frozen_for_receiving(self, b: bool) -> None:
self.storage['frozen_for_receiving'] = bool(b)
util.trigger_callback('channel', self.lnworker.wallet, self)
def _assert_can_add_htlc(self, *, htlc_proposer: HTLCOwner, amount_msat: int,
ignore_min_htlc_value: bool = False) -> None:
"""Raises PaymentFailure if the htlc_proposer cannot add this new HTLC.
(this is relevant both for forwarding and endpoint)
"""
htlc_receiver = htlc_proposer.inverted()
# note: all these tests are about the *receiver's* *next* commitment transaction,
# and the constraints are the ones imposed by their config
ctn = self.get_next_ctn(htlc_receiver)
chan_config = self.config[htlc_receiver]
if self.get_state() != ChannelState.OPEN:
raise PaymentFailure(f"Channel not open. {self.get_state()!r}")
if not self.can_update_ctx(proposer=htlc_proposer):
raise PaymentFailure(f"cannot update channel. {self.get_state()!r} {self.peer_state!r}")
if htlc_proposer == LOCAL:
if not self.can_send_update_add_htlc():
raise PaymentFailure('Channel cannot add htlc')
# check htlc raw value
if not ignore_min_htlc_value:
if amount_msat <= 0:
raise PaymentFailure("HTLC value must be positive")
if amount_msat < chan_config.htlc_minimum_msat:
# todo: for incoming htlcs this could be handled more gracefully with `amount_below_minimum`
raise PaymentFailure(f'HTLC value too small: {amount_msat} msat')
if self.htlc_slots_left(htlc_proposer) == 0:
raise PaymentFailure('Too many HTLCs already in channel')
if amount_msat > self.remaining_max_inflight(htlc_receiver, strict=False):
raise PaymentFailure(
f'HTLC value sum (sum of pending htlcs plus new htlc) '
f'would exceed max allowed: {chan_config.max_htlc_value_in_flight_msat/1000} sat')
# check proposer can afford htlc
max_can_send_msat = self.available_to_spend(htlc_proposer)
if max_can_send_msat < amount_msat:
raise PaymentFailure(f'Not enough balance. can send: {max_can_send_msat}, tried: {amount_msat}')
def htlc_slots_left(self, htlc_proposer: HTLCOwner) -> int:
# check "max_accepted_htlcs"
htlc_receiver = htlc_proposer.inverted()
ctn = self.get_next_ctn(htlc_receiver)
chan_config = self.config[htlc_receiver]
# If proposer is LOCAL we apply stricter checks as that is behaviour we can control.
# This should lead to fewer disagreements (i.e. channels failing).
strict = (htlc_proposer == LOCAL)
if not strict:
# this is the loose check BOLT-02 specifies:
return chan_config.max_accepted_htlcs - len(self.hm.htlcs_by_direction(htlc_receiver, direction=RECEIVED, ctn=ctn))
else:
# however, c-lightning is a lot stricter, so extra checks:
# https://github.com/ElementsProject/lightning/blob/4dcd4ca1556b13b6964a10040ba1d5ef82de4788/channeld/full_channel.c#L581
max_concurrent_htlcs = min(
self.config[htlc_proposer].max_accepted_htlcs,
self.config[htlc_receiver].max_accepted_htlcs)
return max_concurrent_htlcs - len(self.hm.htlcs(htlc_receiver, ctn=ctn))
def remaining_max_inflight(self, htlc_receiver: HTLCOwner, *, strict: bool) -> int:
"""
Checks max_htlc_value_in_flight_msat
strict = False -> how much we can accept according to BOLT2
strict = True -> how much the remote will accept to send to us (Eclair has stricter rules)
"""
ctn = self.get_next_ctn(htlc_receiver)
current_htlc_sum = htlcsum(self.hm.htlcs_by_direction(htlc_receiver, direction=RECEIVED, ctn=ctn).values())
max_inflight = self.config[htlc_receiver].max_htlc_value_in_flight_msat
if strict and htlc_receiver == LOCAL:
# in order to send, eclair applies both local and remote max values
# https://github.com/ACINQ/eclair/blob/9b0c00a2a28d3ba6c7f3d01fbd2d8704ebbdc75d/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala#L503
max_inflight = min(
self.config[LOCAL].max_htlc_value_in_flight_msat,
self.config[REMOTE].max_htlc_value_in_flight_msat
)
return max_inflight - current_htlc_sum
def can_pay(self, amount_msat: int, *, check_frozen=False) -> bool:
"""Returns whether we can add an HTLC of given value."""
if check_frozen and self.is_frozen_for_sending():
return False
try:
self._assert_can_add_htlc(htlc_proposer=LOCAL, amount_msat=amount_msat)
except PaymentFailure:
return False
return True
def can_receive(self, amount_msat: int, *, check_frozen=False,
ignore_min_htlc_value: bool = False) -> bool:
"""Returns whether the remote can add an HTLC of given value."""
if check_frozen and self.is_frozen_for_receiving():
return False
try:
self._assert_can_add_htlc(
htlc_proposer=REMOTE,
amount_msat=amount_msat,
ignore_min_htlc_value=ignore_min_htlc_value)
except PaymentFailure:
return False
return True
def should_try_to_reestablish_peer(self) -> bool:
if self.peer_state != PeerState.DISCONNECTED:
return False
if self.should_request_force_close:
return True
return ChannelState.PREOPENING < self._state < ChannelState.CLOSING
def get_funding_address(self):
script = funding_output_script(self.config[LOCAL], self.config[REMOTE])
return redeem_script_to_address('p2wsh', script)
def add_htlc(self, htlc: UpdateAddHtlc) -> UpdateAddHtlc:
"""Adds a new LOCAL HTLC to the channel.
Action must be initiated by LOCAL.
"""
assert isinstance(htlc, UpdateAddHtlc)
self._assert_can_add_htlc(htlc_proposer=LOCAL, amount_msat=htlc.amount_msat)
if htlc.htlc_id is None:
htlc = dataclasses.replace(htlc, htlc_id=self.hm.get_next_htlc_id(LOCAL))
with self.db_lock:
self.hm.send_htlc(htlc)
self.logger.info("add_htlc")
return htlc
def receive_htlc(self, htlc: UpdateAddHtlc, onion_packet:bytes = None) -> UpdateAddHtlc:
"""Adds a new REMOTE HTLC to the channel.
Action must be initiated by REMOTE.
"""
assert isinstance(htlc, UpdateAddHtlc)
try:
self._assert_can_add_htlc(htlc_proposer=REMOTE, amount_msat=htlc.amount_msat)
except PaymentFailure as e:
raise RemoteMisbehaving(e) from e
if htlc.htlc_id is None: # used in unit tests
htlc = dataclasses.replace(htlc, htlc_id=self.hm.get_next_htlc_id(REMOTE))
with self.db_lock:
self.hm.recv_htlc(htlc)
if onion_packet:
self.unfulfilled_htlcs[htlc.htlc_id] = onion_packet.hex()
self.logger.info("receive_htlc")
return htlc
def sign_next_commitment(self) -> Tuple[bytes, Sequence[bytes]]:
"""Returns signatures for our next remote commitment tx.
Action must be initiated by LOCAL.
Finally, the next remote ctx becomes the latest remote ctx.
"""
# TODO: when more channel types are supported, this method should depend on channel type
next_remote_ctn = self.get_next_ctn(REMOTE)
self.logger.info(f"sign_next_commitment. ctn={next_remote_ctn}")
assert not self.is_closed(), self.get_state()
pending_remote_commitment = self.get_next_commitment(REMOTE)
sig_64 = sign_and_get_sig_string(pending_remote_commitment, self.config[LOCAL], self.config[REMOTE])
self.logger.debug(f"sign_next_commitment. {pending_remote_commitment.serialize()=}. {sig_64.hex()=}")
their_remote_htlc_privkey_number = derive_privkey(
int.from_bytes(self.config[LOCAL].htlc_basepoint.privkey, 'big'),
self.config[REMOTE].next_per_commitment_point)
their_remote_htlc_privkey = their_remote_htlc_privkey_number.to_bytes(32, 'big')
htlcsigs = []
htlc_to_ctx_output_idx_map = map_htlcs_to_ctx_output_idxs(chan=self,
ctx=pending_remote_commitment,
pcp=self.config[REMOTE].next_per_commitment_point,
subject=REMOTE,
ctn=next_remote_ctn)
for (direction, htlc), (ctx_output_idx, htlc_relative_idx) in htlc_to_ctx_output_idx_map.items():
_script, htlc_tx = make_htlc_tx_with_open_channel(chan=self,
pcp=self.config[REMOTE].next_per_commitment_point,
subject=REMOTE,
ctn=next_remote_ctn,
htlc_direction=direction,
commit=pending_remote_commitment,
ctx_output_idx=ctx_output_idx,
htlc=htlc)
if self.has_anchors():
# we send a signature with the following sighash flags
# for the peer to be able to replace inputs and outputs
htlc_tx.inputs()[0].sighash = Sighash.ANYONECANPAY | Sighash.SINGLE
sig = htlc_tx.sign_txin(0, their_remote_htlc_privkey)
htlc_sig = ecc.ecdsa_sig64_from_der_sig(sig[:-1])
htlcsigs.append((ctx_output_idx, htlc_sig))
htlcsigs.sort()
htlcsigs = [x[1] for x in htlcsigs]
with self.db_lock:
self.hm.send_ctx()
return sig_64, htlcsigs
def receive_new_commitment(self, sig: bytes, htlc_sigs: Sequence[bytes]) -> None:
"""Processes signatures for our next local commitment tx, sent by the REMOTE.
Action must be initiated by REMOTE.
If all checks pass, the next local ctx becomes the latest local ctx.
"""
# TODO in many failure cases below, we should "fail" the channel (force-close)
# TODO: when more channel types are supported, this method should depend on channel type
next_local_ctn = self.get_next_ctn(LOCAL)
self.logger.info(f"receive_new_commitment. ctn={next_local_ctn}, len(htlc_sigs)={len(htlc_sigs)}")
assert not self.is_closed(), self.get_state()
assert len(htlc_sigs) == 0 or type(htlc_sigs[0]) is bytes
pending_local_commitment = self.get_next_commitment(LOCAL)
pre_hash = pending_local_commitment.serialize_preimage(0)
msg_hash = sha256d(pre_hash)
if not ECPubkey(self.config[REMOTE].multisig_key.pubkey).ecdsa_verify(sig, msg_hash):
raise LNProtocolWarning(
f'failed verifying signature for our updated commitment transaction. '
f'sig={sig.hex()}. '
f'msg_hash={msg_hash.hex()}. '
f'pubkey={self.config[REMOTE].multisig_key.pubkey}. '
f'ctx={pending_local_commitment.serialize()} '
)
htlc_sigs_string = b''.join(htlc_sigs)
_secret, pcp = self.get_secret_and_point(subject=LOCAL, ctn=next_local_ctn)
htlc_to_ctx_output_idx_map = map_htlcs_to_ctx_output_idxs(chan=self,
ctx=pending_local_commitment,
pcp=pcp,
subject=LOCAL,
ctn=next_local_ctn)
if len(htlc_to_ctx_output_idx_map) != len(htlc_sigs):
raise LNProtocolWarning(f'htlc sigs failure. recv {len(htlc_sigs)} sigs, expected {len(htlc_to_ctx_output_idx_map)}')
for (direction, htlc), (ctx_output_idx, htlc_relative_idx) in htlc_to_ctx_output_idx_map.items():
htlc_sig = htlc_sigs[htlc_relative_idx]
self._verify_htlc_sig(htlc=htlc,
htlc_sig=htlc_sig,
htlc_direction=direction,
pcp=pcp,
ctx=pending_local_commitment,
ctx_output_idx=ctx_output_idx,
ctn=next_local_ctn)
with self.db_lock:
self.hm.recv_ctx()
self.config[LOCAL].current_commitment_signature=sig
self.config[LOCAL].current_htlc_signatures=htlc_sigs_string
def _verify_htlc_sig(self, *, htlc: UpdateAddHtlc, htlc_sig: bytes, htlc_direction: Direction,
pcp: bytes, ctx: Transaction, ctx_output_idx: int, ctn: int) -> None:
_script, htlc_tx = make_htlc_tx_with_open_channel(chan=self,
pcp=pcp,
subject=LOCAL,
ctn=ctn,
htlc_direction=htlc_direction,
commit=ctx,
ctx_output_idx=ctx_output_idx,
htlc=htlc)
if self.has_anchors():
# peer sent us a signature for our ctx using anchor sighash flags
htlc_tx.inputs()[0].sighash = Sighash.ANYONECANPAY | Sighash.SINGLE
pre_hash = htlc_tx.serialize_preimage(0)
msg_hash = sha256d(pre_hash)
remote_htlc_pubkey = derive_pubkey(self.config[REMOTE].htlc_basepoint.pubkey, pcp)
if not ECPubkey(remote_htlc_pubkey).ecdsa_verify(htlc_sig, msg_hash):
raise LNProtocolWarning(
f'failed verifying HTLC signatures: {htlc=}, {htlc_direction=}. '
f'htlc_tx={htlc_tx.serialize()}. '
f'htlc_sig={htlc_sig.hex()}. '
f'remote_htlc_pubkey={remote_htlc_pubkey.hex()}. '
f'msg_hash={msg_hash.hex()}. '
f'ctx={ctx.serialize()}. '
f'ctx_output_idx={ctx_output_idx}. '
f'ctn={ctn}. '
)
def get_remote_htlc_sig_for_htlc(self, *, htlc_relative_idx: int) -> bytes:
data = self.config[LOCAL].current_htlc_signatures
htlc_sigs = list(chunks(data, 64))
htlc_sig = htlc_sigs[htlc_relative_idx]
remote_sighash = Sighash.ALL if not self.has_anchors() else Sighash.ANYONECANPAY | Sighash.SINGLE
remote_htlc_sig = ecc.ecdsa_der_sig_from_ecdsa_sig64(htlc_sig) + Sighash.to_sigbytes(remote_sighash)
return remote_htlc_sig
def revoke_current_commitment(self):
self.logger.info("revoke_current_commitment")
assert not self.is_closed(), self.get_state()
new_ctn = self.get_latest_ctn(LOCAL)
new_ctx = self.get_latest_commitment(LOCAL)
if not self.signature_fits(new_ctx):
# this should never fail; as receive_new_commitment already did this test
raise Exception("refusing to revoke as remote sig does not fit")
with self.db_lock:
self.hm.send_rev()
last_secret, last_point = self.get_secret_and_point(LOCAL, new_ctn - 1)
next_secret, next_point = self.get_secret_and_point(LOCAL, new_ctn + 1)
return RevokeAndAck(last_secret, next_point)
def receive_revocation(self, revocation: RevokeAndAck):
self.logger.info("receive_revocation")
assert not self.is_closed(), self.get_state()
new_ctn = self.get_latest_ctn(REMOTE)
cur_point = self.config[REMOTE].current_per_commitment_point
derived_point = ecc.ECPrivkey(revocation.per_commitment_secret).get_public_key_bytes(compressed=True)
if cur_point != derived_point:
raise Exception('revoked secret not for current point')
with self.db_lock:
self.revocation_store.add_next_entry(revocation.per_commitment_secret)
##### start applying fee/htlc changes
self.hm.recv_rev()
self.config[REMOTE].current_per_commitment_point=self.config[REMOTE].next_per_commitment_point
self.config[REMOTE].next_per_commitment_point=revocation.next_per_commitment_point
assert new_ctn == self.get_oldest_unrevoked_ctn(REMOTE)
# lnworker callbacks
sent = self.hm.sent_in_ctn(new_ctn)
for htlc in sent:
self.lnworker.htlc_fulfilled(self, htlc.payment_hash, htlc.htlc_id)
failed = self.hm.failed_in_ctn(new_ctn)
for htlc in failed:
try:
error_bytes, failure_message = self._receive_fail_reasons.pop(htlc.htlc_id)
except KeyError:
error_bytes, failure_message = None, None
self.lnworker.htlc_failed(self, htlc.payment_hash, htlc.htlc_id, error_bytes, failure_message)
def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None:
from . import lnutil
from .crypto import ripemd
from .transaction import match_script_against_template, script_GetOp
from .lnonion import OnionRoutingFailure, OnionFailureCode
witness = txin.witness_elements()
witness_script = witness[-1]
script_ops = [x for x in script_GetOp(witness_script)]
if match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_OFFERED_HTLC, debug=False) \
or match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_OFFERED_HTLC_ANCHORS, debug=False):
ripemd_payment_hash = script_ops[21][1]
elif match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_RECEIVED_HTLC, debug=False) \
or match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_RECEIVED_HTLC_ANCHORS, debug=False):
ripemd_payment_hash = script_ops[14][1]
else:
return
found = {}
for direction, htlc in itertools.chain(
self.hm.get_htlcs_in_oldest_unrevoked_ctx(REMOTE),
self.hm.get_htlcs_in_latest_ctx(REMOTE)):
if ripemd(htlc.payment_hash) == ripemd_payment_hash:
is_sent = direction == RECEIVED
found[htlc.htlc_id] = (htlc, is_sent)
for direction, htlc in itertools.chain(
self.hm.get_htlcs_in_oldest_unrevoked_ctx(LOCAL),
self.hm.get_htlcs_in_latest_ctx(LOCAL)):
if ripemd(htlc.payment_hash) == ripemd_payment_hash:
is_sent = direction == SENT
found[htlc.htlc_id] = (htlc, is_sent)
if not found:
return
if len(witness) == 5: # HTLC success tx
preimage = witness[3]
elif len(witness) == 3: # spending offered HTLC directly from ctx
preimage = witness[1]
else:
preimage = None # HTLC timeout tx
if preimage:
assert ripemd(sha256(preimage)) == ripemd_payment_hash
payment_hash = sha256(preimage)
if self.lnworker.get_preimage(payment_hash) is not None:
return
# ^ note: log message text grepped for in regtests
self.logger.info(f"found preimage in witness of length {len(witness)}, for {payment_hash.hex()}")
# Mark the htlc as fulfilled or failed.
# If we forwarded this, this ensures that the success/failure is propagated back on the incoming channel.
# FIXME we only look at outgoing htlcs that have a corresponding output in the commitment tx,
# however we should also look at those that do not. E.g. a small value htlc might not create an output
# but we should still propagate back success or failure on the incoming link. And it is not just about
# small value htlcs: even a large htlc might not appear in the outgoing channel's ctx, e.g. maybe it was
# not committed yet - we should still make sure it gets removed on the incoming channel. (see #9631)
if preimage:
self.lnworker.save_preimage(payment_hash, preimage, mark_as_public=True)
for htlc, is_sent in found.values():
if is_sent:
self.lnworker.htlc_fulfilled(self, payment_hash, htlc.htlc_id)
else:
# htlc timeout tx
if not is_deeply_mined:
return
failure = OnionRoutingFailure(code=OnionFailureCode.PERMANENT_CHANNEL_FAILURE, data=b'')
for htlc, is_sent in found.values():
if is_sent:
self.logger.info(f'htlc timeout tx: failing htlc {is_sent}')
self.lnworker.htlc_failed(
self,
payment_hash=htlc.payment_hash,
htlc_id=htlc.htlc_id,
error_bytes=None,
failure_message=failure)
def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int:
assert type(whose) is HTLCOwner
initial = self.config[whose].initial_msat
return self.hm.get_balance_msat(whose=whose,
ctx_owner=ctx_owner,
ctn=ctn,
initial_balance_msat=initial)
def balance_minus_outgoing_htlcs(self, whose: HTLCOwner, *, ctx_owner: HTLCOwner = HTLCOwner.LOCAL,
ctn: int = None) -> int:
assert type(whose) is HTLCOwner
if ctn is None:
ctn = self.get_next_ctn(ctx_owner)
committed_balance = self.balance(whose, ctx_owner=ctx_owner, ctn=ctn)
direction = RECEIVED if whose != ctx_owner else SENT
balance_in_htlcs = self.balance_tied_up_in_htlcs_by_direction(ctx_owner, ctn=ctn, direction=direction)
return committed_balance - balance_in_htlcs
def balance_tied_up_in_htlcs_by_direction(self, ctx_owner: HTLCOwner = LOCAL, *, ctn: int = None,
direction: Direction):
# in msat
if ctn is None:
ctn = self.get_next_ctn(ctx_owner)
return htlcsum(self.hm.htlcs_by_direction(ctx_owner, direction, ctn).values())
def has_unsettled_htlcs(self) -> bool:
return len(self.hm.htlcs(LOCAL)) + len(self.hm.htlcs(REMOTE)) > 0
def available_to_spend(self, subject: HTLCOwner) -> int:
"""The usable balance of 'subject' in msat, after taking reserve and fees (and anchors) into
consideration. Note that fees (and hence the result) fluctuate even without user interaction.
"""
assert type(subject) is HTLCOwner
sender = subject
receiver = subject.inverted()
initiator = LOCAL if self.constraints.is_initiator else REMOTE # the initiator/funder pays on-chain fees
def consider_ctx(*, ctx_owner: HTLCOwner, is_htlc_dust: bool) -> int:
ctn = self.get_next_ctn(ctx_owner)
sender_balance_msat = self.balance_minus_outgoing_htlcs(whose=sender, ctx_owner=ctx_owner, ctn=ctn)
receiver_balance_msat = self.balance_minus_outgoing_htlcs(whose=receiver, ctx_owner=ctx_owner, ctn=ctn)
sender_reserve_msat = self.config[receiver].reserve_sat * 1000
receiver_reserve_msat = self.config[sender].reserve_sat * 1000
num_htlcs_in_ctx = len(self.included_htlcs(ctx_owner, SENT, ctn=ctn) + self.included_htlcs(ctx_owner, RECEIVED, ctn=ctn))
feerate = self.get_feerate(ctx_owner, ctn=ctn)
ctx_fees_msat = calc_fees_for_commitment_tx(
num_htlcs=num_htlcs_in_ctx,
feerate=feerate,
is_local_initiator=self.constraints.is_initiator,
round_to_sat=False,
has_anchors=self.has_anchors()
)
htlc_fee_msat = fee_for_htlc_output(feerate=feerate)
htlc_trim_func = received_htlc_trim_threshold_sat if ctx_owner == receiver else offered_htlc_trim_threshold_sat
htlc_trim_threshold_msat = htlc_trim_func(dust_limit_sat=self.config[ctx_owner].dust_limit_sat, feerate=feerate, has_anchors=self.has_anchors()) * 1000
# the sender cannot spend below its reserve
max_send_msat = sender_balance_msat - sender_reserve_msat
# reserve a fee spike buffer
# see https://github.com/lightningnetwork/lightning-rfc/pull/740
if sender == initiator == LOCAL:
fee_spike_buffer = calc_fees_for_commitment_tx(
num_htlcs=num_htlcs_in_ctx + int(not is_htlc_dust) + 1,
feerate=2 * feerate,
is_local_initiator=self.constraints.is_initiator,
round_to_sat=False,
has_anchors=self.has_anchors())[sender]
max_send_msat -= fee_spike_buffer
# we can't enforce the fee spike buffer on the remote party
elif sender == initiator == REMOTE:
max_send_msat -= ctx_fees_msat[sender]
# initiator pays for anchor outputs
if sender == initiator and self.has_anchors():
max_send_msat -= 2 * FIXED_ANCHOR_SAT * 1000
# handle the transaction fees for the HTLC transaction
if is_htlc_dust:
# nobody pays additional HTLC transaction fees
return min(max_send_msat, htlc_trim_threshold_msat - 1)
else:
# somebody has to pay for the additional HTLC transaction fees
if sender == initiator:
return max_send_msat - htlc_fee_msat
else:
# check if the receiver can afford to pay for the HTLC transaction fees
new_receiver_balance = receiver_balance_msat - receiver_reserve_msat - ctx_fees_msat[receiver] - htlc_fee_msat
if self.has_anchors():
new_receiver_balance -= 2 * FIXED_ANCHOR_SAT * 1000
if new_receiver_balance < 0:
return 0
return max_send_msat
max_send_msat = min(
max(
consider_ctx(ctx_owner=receiver, is_htlc_dust=True),
consider_ctx(ctx_owner=receiver, is_htlc_dust=False),
),
max(
consider_ctx(ctx_owner=sender, is_htlc_dust=True),
consider_ctx(ctx_owner=sender, is_htlc_dust=False),
),
)
max_send_msat = min(max_send_msat, self.remaining_max_inflight(receiver, strict=True))
if self.htlc_slots_left(sender) == 0:
max_send_msat = 0
max_send_msat = max(max_send_msat, 0)
return max_send_msat
def included_htlcs(self, subject: HTLCOwner, direction: Direction, ctn: int = None, *,
feerate: int = None) -> List[UpdateAddHtlc]:
"""Returns list of non-dust HTLCs for subject's commitment tx at ctn,
filtered by direction (of HTLCs).
"""
assert type(subject) is HTLCOwner
assert type(direction) is Direction
if ctn is None:
ctn = self.get_oldest_unrevoked_ctn(subject)
if feerate is None:
feerate = self.get_feerate(subject, ctn=ctn)
conf = self.config[subject]
if direction == RECEIVED:
threshold_sat = received_htlc_trim_threshold_sat(dust_limit_sat=conf.dust_limit_sat, feerate=feerate, has_anchors=self.has_anchors())
else:
threshold_sat = offered_htlc_trim_threshold_sat(dust_limit_sat=conf.dust_limit_sat, feerate=feerate, has_anchors=self.has_anchors())
htlcs = self.hm.htlcs_by_direction(subject, direction, ctn=ctn).values()
return list(filter(lambda htlc: htlc.amount_msat // 1000 >= threshold_sat, htlcs))
def get_secret_and_point(self, subject: HTLCOwner, ctn: int) -> Tuple[Optional[bytes], bytes]:
assert type(subject) is HTLCOwner
assert ctn >= 0, ctn
offset = ctn - self.get_oldest_unrevoked_ctn(subject)
if subject == REMOTE:
if offset > 1:
raise RemoteCtnTooFarInFuture(f"offset: {offset}")
conf = self.config[REMOTE]
if offset == 1:
secret = None
point = conf.next_per_commitment_point
elif offset == 0:
secret = None
point = conf.current_per_commitment_point
else:
secret = self.revocation_store.retrieve_secret(RevocationStore.START_INDEX - ctn)
point = secret_to_pubkey(int.from_bytes(secret, 'big'))
else:
secret = get_per_commitment_secret_from_seed(self.config[LOCAL].per_commitment_secret_seed, RevocationStore.START_INDEX - ctn)
point = secret_to_pubkey(int.from_bytes(secret, 'big'))
return secret, point
def get_secret_and_commitment(self, subject: HTLCOwner, *, ctn: int) -> Tuple[Optional[bytes], PartialTransaction]:
secret, point = self.get_secret_and_point(subject, ctn)
ctx = self.make_commitment(subject, point, ctn)
return secret, ctx
def get_commitment(self, subject: HTLCOwner, *, ctn: int) -> PartialTransaction:
secret, ctx = self.get_secret_and_commitment(subject, ctn=ctn)
return ctx
def get_next_commitment(self, subject: HTLCOwner) -> PartialTransaction:
ctn = self.get_next_ctn(subject)
return self.get_commitment(subject, ctn=ctn)
def get_latest_commitment(self, subject: HTLCOwner) -> PartialTransaction:
ctn = self.get_latest_ctn(subject)
return self.get_commitment(subject, ctn=ctn)
def get_oldest_unrevoked_commitment(self, subject: HTLCOwner) -> PartialTransaction:
ctn = self.get_oldest_unrevoked_ctn(subject)
return self.get_commitment(subject, ctn=ctn)
def create_sweeptxs_for_watchtower(self, ctn: int) -> List[Transaction]:
from .lnsweep import sweep_their_ctx_watchtower
from .fee_policy import FeePolicy
from .transaction import PartialTxOutput, PartialTransaction
secret, ctx = self.get_secret_and_commitment(REMOTE, ctn=ctn)
txs = []
txins = sweep_their_ctx_watchtower(self, ctx, secret)
fee_policy = FeePolicy('eta:2')
for txin in txins:
output_idx = txin.prevout.out_idx
value = ctx.outputs()[output_idx].value
tx_size_bytes = 121
fee = fee_policy.estimate_fee(tx_size_bytes, network=self.lnworker.network, allow_fallback_to_static_rates=True)
outvalue = value - fee
sweep_outputs = [PartialTxOutput.from_address_and_value(self.get_sweep_address(), outvalue)]
sweep_tx = PartialTransaction.from_io([txin], sweep_outputs, version=2)
sig = sweep_tx.sign_txin(0, txin.privkey)
txin.witness = txin.make_witness(sig)
txs.append(sweep_tx)
return txs
def get_oldest_unrevoked_ctn(self, subject: HTLCOwner) -> int:
return self.hm.ctn_oldest_unrevoked(subject)
def get_latest_ctn(self, subject: HTLCOwner) -> int:
return self.hm.ctn_latest(subject)
def get_next_ctn(self, subject: HTLCOwner) -> int:
return self.hm.ctn_latest(subject) + 1
def total_msat(self, direction: Direction) -> int:
"""Return the cumulative total msat amount received/sent so far."""
assert type(direction) is Direction
return htlcsum(self.hm.all_settled_htlcs_ever_by_direction(LOCAL, direction))
def settle_htlc(self, preimage: bytes, htlc_id: int) -> None:
"""Settle/fulfill a pending received HTLC.
Action must be initiated by LOCAL.
"""
self.logger.info("settle_htlc")
assert self.can_update_ctx(proposer=LOCAL), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}"
htlc = self.hm.get_htlc_by_id(REMOTE, htlc_id)
if htlc.payment_hash != sha256(preimage):
raise Exception("incorrect preimage for HTLC")
assert htlc_id not in self.hm.log[REMOTE]['settles']
self.hm.send_settle(htlc_id)
self.htlc_settle_time[htlc_id] = now()
self.lnworker.save_preimage(htlc.payment_hash, preimage, mark_as_public=True)
def get_payment_hash(self, htlc_id: int) -> bytes:
htlc = self.hm.get_htlc_by_id(LOCAL, htlc_id)
return htlc.payment_hash
def receive_htlc_settle(self, preimage: bytes, htlc_id: int) -> None:
"""Settle/fulfill a pending offered HTLC.
Action must be initiated by REMOTE.
"""
self.logger.info("receive_htlc_settle")
assert self.can_update_ctx(proposer=REMOTE), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}"
htlc = self.hm.get_htlc_by_id(LOCAL, htlc_id)
if htlc.payment_hash != sha256(preimage):
raise RemoteMisbehaving("received incorrect preimage for HTLC")
assert htlc_id not in self.hm.log[LOCAL]['settles']
with self.db_lock:
self.hm.recv_settle(htlc_id)
self.lnworker.save_preimage(htlc.payment_hash, preimage, mark_as_public=True)
def fail_htlc(self, htlc_id: int) -> None:
"""Fail a pending received HTLC.
Action must be initiated by LOCAL.
"""
self.logger.info("fail_htlc")
assert self.can_update_ctx(proposer=LOCAL), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}"
with self.db_lock:
self.hm.send_fail(htlc_id)
def receive_fail_htlc(self, htlc_id: int, *,
error_bytes: Optional[bytes],
reason: Optional[OnionRoutingFailure] = None) -> None:
"""Fail a pending offered HTLC.
Action must be initiated by REMOTE.
"""
self.logger.info("receive_fail_htlc")
assert self.can_update_ctx(proposer=REMOTE), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}"
with self.db_lock:
self.hm.recv_fail(htlc_id)
self._receive_fail_reasons[htlc_id] = (error_bytes, reason)
def get_next_fee(self, subject: HTLCOwner) -> int:
return self.constraints.capacity - sum(x.value for x in self.get_next_commitment(subject).outputs())
def get_latest_fee(self, subject: HTLCOwner) -> int:
return self.constraints.capacity - sum(x.value for x in self.get_latest_commitment(subject).outputs())
def update_fee(self, feerate: int, from_us: bool) -> None:
# feerate uses sat/kw
if self.constraints.is_initiator != from_us:
raise Exception(f"Cannot update_fee: wrong initiator. us: {from_us}")
if feerate < FEERATE_PER_KW_MIN_RELAY_LIGHTNING:
raise Exception(f"Cannot update_fee: feerate lower than min relay fee. {feerate} sat/kw. us: {from_us}")
sender = LOCAL if from_us else REMOTE
ctx_owner = -sender
ctn = self.get_next_ctn(ctx_owner)
sender_balance_msat = self.balance_minus_outgoing_htlcs(whose=sender, ctx_owner=ctx_owner, ctn=ctn)
sender_reserve_msat = self.config[-sender].reserve_sat * 1000
num_htlcs_in_ctx = len(self.included_htlcs(ctx_owner, SENT, ctn=ctn, feerate=feerate) +
self.included_htlcs(ctx_owner, RECEIVED, ctn=ctn, feerate=feerate))
ctx_fees_msat = calc_fees_for_commitment_tx(
num_htlcs=num_htlcs_in_ctx,
feerate=feerate,
is_local_initiator=self.constraints.is_initiator,
has_anchors=self.has_anchors()
)
remainder = sender_balance_msat - sender_reserve_msat - ctx_fees_msat[sender]
if remainder < 0:
raise Exception(f"Cannot update_fee. {sender} tried to update fee but they cannot afford it. "
f"Their balance would go below reserve: {remainder} msat missing.")
assert self.can_update_ctx(proposer=LOCAL if from_us else REMOTE), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}. {from_us=}"
with self.db_lock:
if from_us:
self.hm.send_update_fee(feerate)
else:
self.hm.recv_update_fee(feerate)
def make_commitment(self, subject: HTLCOwner, this_point: bytes, ctn: int) -> PartialTransaction:
assert type(subject) is HTLCOwner
feerate = self.get_feerate(subject, ctn=ctn)
other = subject.inverted()
local_msat = self.balance(subject, ctx_owner=subject, ctn=ctn)
remote_msat = self.balance(other, ctx_owner=subject, ctn=ctn)
received_htlcs = self.hm.htlcs_by_direction(subject, RECEIVED, ctn).values()
sent_htlcs = self.hm.htlcs_by_direction(subject, SENT, ctn).values()
remote_msat -= htlcsum(received_htlcs)
local_msat -= htlcsum(sent_htlcs)
assert remote_msat >= 0
assert local_msat >= 0
# same htlcs as before, but now without dust.
received_htlcs = self.included_htlcs(subject, RECEIVED, ctn)
sent_htlcs = self.included_htlcs(subject, SENT, ctn)
this_config = self.config[subject]
other_config = self.config[-subject]
other_htlc_pubkey = derive_pubkey(other_config.htlc_basepoint.pubkey, this_point)
this_htlc_pubkey = derive_pubkey(this_config.htlc_basepoint.pubkey, this_point)
other_revocation_pubkey = derive_blinded_pubkey(other_config.revocation_basepoint.pubkey, this_point)
htlcs = [] # type: List[ScriptHtlc]
for is_received_htlc, htlc_list in zip((True, False), (received_htlcs, sent_htlcs)):
for htlc in htlc_list:
htlcs.append(ScriptHtlc(make_htlc_output_witness_script(
is_received_htlc=is_received_htlc,
remote_revocation_pubkey=other_revocation_pubkey,
remote_htlc_pubkey=other_htlc_pubkey,
local_htlc_pubkey=this_htlc_pubkey,
payment_hash=htlc.payment_hash,
cltv_abs=htlc.cltv_abs,
has_anchors=self.has_anchors()), htlc))
# note: maybe flip initiator here for fee purposes, we want LOCAL and REMOTE
# in the resulting dict to correspond to the to_local and to_remote *outputs* of the ctx
onchain_fees = calc_fees_for_commitment_tx(
num_htlcs=len(htlcs),
feerate=feerate,
is_local_initiator=self.constraints.is_initiator == (subject == LOCAL),
has_anchors=self.has_anchors(),
)
assert self.is_static_remotekey_enabled()
payment_pubkey = other_config.payment_basepoint.pubkey
return make_commitment(
ctn=ctn,
local_funding_pubkey=this_config.multisig_key.pubkey,
remote_funding_pubkey=other_config.multisig_key.pubkey,
remote_payment_pubkey=payment_pubkey,
funder_payment_basepoint=self.config[LOCAL if self.constraints.is_initiator else REMOTE].payment_basepoint.pubkey,
fundee_payment_basepoint=self.config[LOCAL if not self.constraints.is_initiator else REMOTE].payment_basepoint.pubkey,
revocation_pubkey=other_revocation_pubkey,
delayed_pubkey=derive_pubkey(this_config.delayed_basepoint.pubkey, this_point),
to_self_delay=other_config.to_self_delay,
funding_txid=self.funding_outpoint.txid,
funding_pos=self.funding_outpoint.output_index,
funding_sat=self.constraints.capacity,
local_amount=local_msat,
remote_amount=remote_msat,
dust_limit_sat=this_config.dust_limit_sat,
fees_per_participant=onchain_fees,
htlcs=htlcs,
has_anchors=self.has_anchors()
)
def make_closing_tx(self, local_script: bytes, remote_script: bytes,
fee_sat: int, *, drop_remote = False) -> Tuple[bytes, PartialTransaction]:
""" cooperative close """
_, outputs = make_commitment_outputs(
fees_per_participant={
LOCAL: fee_sat * 1000 if self.constraints.is_initiator else 0,
REMOTE: fee_sat * 1000 if not self.constraints.is_initiator else 0,
},
local_amount_msat=self.balance(LOCAL),
remote_amount_msat=self.balance(REMOTE) if not drop_remote else 0,
local_script=local_script,
remote_script=remote_script,
htlcs=[],
dust_limit_sat=self.config[LOCAL].dust_limit_sat,
has_anchors=self.has_anchors(),
local_anchor_script=None,
remote_anchor_script=None,
)
closing_tx = make_closing_tx(self.config[LOCAL].multisig_key.pubkey,
self.config[REMOTE].multisig_key.pubkey,
funding_txid=self.funding_outpoint.txid,
funding_pos=self.funding_outpoint.output_index,
funding_sat=self.constraints.capacity,
outputs=outputs)
der_sig = closing_tx.sign_txin(0, self.config[LOCAL].multisig_key.privkey)
sig = ecc.ecdsa_sig64_from_der_sig(der_sig[:-1])
return sig, closing_tx
def signature_fits(self, tx: PartialTransaction) -> bool:
remote_sig = self.config[LOCAL].current_commitment_signature
pre_hash = tx.serialize_preimage(0)
msg_hash = sha256d(pre_hash)
assert remote_sig
res = ECPubkey(self.config[REMOTE].multisig_key.pubkey).ecdsa_verify(remote_sig, msg_hash)
return res
def force_close_tx(self) -> PartialTransaction:
tx = self.get_latest_commitment(LOCAL)
assert self.signature_fits(tx)
tx.sign({self.config[LOCAL].multisig_key.pubkey: self.config[LOCAL].multisig_key.privkey})
remote_sig = self.config[LOCAL].current_commitment_signature
remote_sig = ecc.ecdsa_der_sig_from_ecdsa_sig64(remote_sig) + Sighash.to_sigbytes(Sighash.ALL)
tx.add_signature_to_txin(txin_idx=0,
signing_pubkey=self.config[REMOTE].multisig_key.pubkey,
sig=remote_sig)
assert tx.is_complete()
return tx
def get_close_options(self) -> Sequence[ChanCloseOption]:
# This method is used both in the GUI, and in lnpeer.schedule_force_closing
# in the latter case, the result does not depend on peer_state
ret = []
if not self.is_closed() and self.peer_state == PeerState.GOOD:
# If there are unsettled HTLCs, although is possible to cooperatively close,
# we choose not to expose that option in the GUI, because it is very likely
# that HTLCs will take a long time to settle (submarine swap, or stuck payment),
# and the close dialog would be taking a very long time to finish
if not self.has_unsettled_htlcs():
ret.append(ChanCloseOption.COOP_CLOSE)
ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
if self.get_state() == ChannelState.WE_ARE_TOXIC:
ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
if not self.is_closed() or self.get_state() == ChannelState.REQUESTED_FCLOSE:
ret.append(ChanCloseOption.LOCAL_FCLOSE)
assert not (self.get_state() == ChannelState.WE_ARE_TOXIC and ChanCloseOption.LOCAL_FCLOSE in ret), "local force-close unsafe if we are toxic"
return ret
def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, MaybeSweepInfo]:
# look at the output address, check if it matches
d = sweep_their_htlctx_justice(self, ctx, htlc_tx)
d2 = sweep_our_htlctx(self, ctx, htlc_tx)
d.update(d2)
return d
def has_pending_changes(self, subject: HTLCOwner) -> bool:
next_htlcs = self.hm.get_htlcs_in_next_ctx(subject)
latest_htlcs = self.hm.get_htlcs_in_latest_ctx(subject)
return not (next_htlcs == latest_htlcs and self.get_next_feerate(subject) == self.get_latest_feerate(subject))
def should_be_closed_due_to_expiring_htlcs(self, local_height: int) -> bool:
htlcs_we_could_reclaim = {} # type: Dict[Tuple[Direction, int], UpdateAddHtlc]
# If there is a received HTLC for which we already released the preimage
# but the remote did not revoke yet, and the CLTV of this HTLC is dangerously close
# to the present, then unilaterally close channel
recv_htlc_deadline_delta = lnutil.NBLOCK_DEADLINE_DELTA_BEFORE_EXPIRY_FOR_RECEIVED_HTLCS
for sub, dir, ctn in ((LOCAL, RECEIVED, self.get_latest_ctn(LOCAL)),
(REMOTE, SENT, self.get_oldest_unrevoked_ctn(REMOTE)),
(REMOTE, SENT, self.get_latest_ctn(REMOTE)),):
for htlc_id, htlc in self.hm.htlcs_by_direction(subject=sub, direction=dir, ctn=ctn).items():
if not self.hm.was_htlc_preimage_released(htlc_id=htlc_id, htlc_proposer=REMOTE):
continue
if htlc.cltv_abs - recv_htlc_deadline_delta > local_height:
continue
# Do not force-close if we just sent fulfill_htlc and have not received revack yet
if htlc_id in self.htlc_settle_time and now() - self.htlc_settle_time[htlc_id] < 30:
continue
htlcs_we_could_reclaim[(RECEIVED, htlc_id)] = htlc
# If there is an offered HTLC which has already expired (+ some grace period after), we
# will unilaterally close the channel and time out the HTLC
offered_htlc_deadline_delta = lnutil.NBLOCK_DEADLINE_DELTA_AFTER_EXPIRY_FOR_OFFERED_HTLCS
for sub, dir, ctn in ((LOCAL, SENT, self.get_latest_ctn(LOCAL)),
(REMOTE, RECEIVED, self.get_oldest_unrevoked_ctn(REMOTE)),
(REMOTE, RECEIVED, self.get_latest_ctn(REMOTE)),):
for htlc_id, htlc in self.hm.htlcs_by_direction(subject=sub, direction=dir, ctn=ctn).items():
if htlc.cltv_abs + offered_htlc_deadline_delta > local_height:
continue
htlcs_we_could_reclaim[(SENT, htlc_id)] = htlc
# Note: previously we used a threshold concept, "min_value_worth_closing_channel_over_sat", and
# only force-closed the channel if the total value of these expiring htlcs was large enough.
# However, if we are forwarding, and an outgoing htlc expires, we should always close
# the outgoing channel (regardless of htlc value), so that we can propagate back the
# removal of the htlc in the incoming channel.
return len(htlcs_we_could_reclaim) > 0
def is_funding_tx_mined(self, funding_height):
funding_txid = self.funding_outpoint.txid
funding_idx = self.funding_outpoint.output_index
conf = funding_height.conf
if conf < self.funding_txn_minimum_depth():
#self.logger.info(f"funding tx is still not at sufficient depth. actual depth: {conf}")
return False
assert conf > 0 or self.is_zeroconf()
# check funding_tx amount and script
funding_tx = self.lnworker.lnwatcher.adb.get_transaction(funding_txid)
if not funding_tx:
self.logger.info(f"no funding_tx {funding_txid}")
return False
outp = funding_tx.outputs()[funding_idx]
redeem_script = funding_output_script(self.config[REMOTE], self.config[LOCAL])
funding_address = redeem_script_to_address('p2wsh', redeem_script)
funding_sat = self.constraints.capacity
if not (outp.address == funding_address and outp.value == funding_sat):
self.logger.info('funding outpoint mismatch')
return False
return True
================================================
FILE: electrum/lnhtlc.py
================================================
from copy import deepcopy
from typing import Sequence, Tuple, Dict, TYPE_CHECKING, Set
from .lnutil import SENT, RECEIVED, LOCAL, REMOTE, HTLCOwner, UpdateAddHtlc, Direction, FeeUpdate
from .util import bfh, with_lock
if TYPE_CHECKING:
from .json_db import StoredDict
LOG_TEMPLATE = {
'adds': {}, # "side who offered htlc" -> htlc_id -> htlc
'locked_in': {}, # "side who offered htlc" -> action -> htlc_id -> whose ctx -> ctn
'settles': {}, # "side who offered htlc" -> action -> htlc_id -> whose ctx -> ctn
'fails': {}, # "side who offered htlc" -> action -> htlc_id -> whose ctx -> ctn
'fee_updates': {}, # "side who initiated fee update" -> index -> list of FeeUpdates
'revack_pending': False,
'next_htlc_id': 0,
'ctn': -1, # oldest unrevoked ctx of sub
}
class HTLCManager:
def __init__(self, log: 'StoredDict', *, initiator=None, initial_feerate=None):
if len(log) == 0:
# note: "htlc_id" keys in dict are str! but due to json_db magic they can *almost* be treated as int...
log[LOCAL] = deepcopy(LOG_TEMPLATE)
log[REMOTE] = deepcopy(LOG_TEMPLATE)
log[LOCAL]['unacked_updates'] = {}
log[LOCAL]['was_revoke_last'] = False
# maybe bootstrap fee_updates if initial_feerate was provided
if initial_feerate is not None:
assert type(initial_feerate) is int
assert initiator in [LOCAL, REMOTE]
log[initiator]['fee_updates'][0] = FeeUpdate(rate=initial_feerate, ctn_local=0, ctn_remote=0)
self.log = log
# We need a lock as many methods of HTLCManager are accessed by both the asyncio thread and the GUI.
# lnchannel sometimes calls us with Channel.db_lock (== log.lock) already taken,
# and we ourselves often take log.lock (via StoredDict.__getitem__).
# Hence, to avoid deadlocks, we reuse this same lock.
self.lock = log.lock
self._init_maybe_active_htlc_ids()
@with_lock
def ctn_latest(self, sub: HTLCOwner) -> int:
"""Return the ctn for the latest (newest that has a valid sig) ctx of sub"""
return self.ctn_oldest_unrevoked(sub) + int(self.is_revack_pending(sub))
def ctn_oldest_unrevoked(self, sub: HTLCOwner) -> int:
"""Return the ctn for the oldest unrevoked ctx of sub"""
return self.log[sub]['ctn']
def is_revack_pending(self, sub: HTLCOwner) -> bool:
"""Returns True iff sub was sent commitment_signed but they did not
send revoke_and_ack yet (sub has multiple unrevoked ctxs)
"""
return self.log[sub]['revack_pending']
def _set_revack_pending(self, sub: HTLCOwner, pending: bool) -> None:
self.log[sub]['revack_pending'] = pending
def get_next_htlc_id(self, sub: HTLCOwner) -> int:
return self.log[sub]['next_htlc_id']
##### Actions on channel:
@with_lock
def channel_open_finished(self):
self.log[LOCAL]['ctn'] = 0
self.log[REMOTE]['ctn'] = 0
self._set_revack_pending(LOCAL, False)
self._set_revack_pending(REMOTE, False)
@with_lock
def send_htlc(self, htlc: UpdateAddHtlc) -> UpdateAddHtlc:
htlc_id = htlc.htlc_id
if htlc_id != self.get_next_htlc_id(LOCAL):
raise Exception(f"unexpected local htlc_id. next should be "
f"{self.get_next_htlc_id(LOCAL)} but got {htlc_id}")
self.log[LOCAL]['adds'][htlc_id] = htlc
self.log[LOCAL]['locked_in'][htlc_id] = {LOCAL: None, REMOTE: self.ctn_latest(REMOTE)+1}
self.log[LOCAL]['next_htlc_id'] += 1
self._maybe_active_htlc_ids[LOCAL].add(htlc_id)
return htlc
@with_lock
def recv_htlc(self, htlc: UpdateAddHtlc) -> None:
htlc_id = htlc.htlc_id
if htlc_id != self.get_next_htlc_id(REMOTE):
raise Exception(f"unexpected remote htlc_id. next should be "
f"{self.get_next_htlc_id(REMOTE)} but got {htlc_id}")
self.log[REMOTE]['adds'][htlc_id] = htlc
self.log[REMOTE]['locked_in'][htlc_id] = {LOCAL: self.ctn_latest(LOCAL)+1, REMOTE: None}
self.log[REMOTE]['next_htlc_id'] += 1
self._maybe_active_htlc_ids[REMOTE].add(htlc_id)
@with_lock
def send_settle(self, htlc_id: int) -> None:
next_ctn = self.ctn_latest(REMOTE) + 1
if not self.is_htlc_active_at_ctn(ctx_owner=REMOTE, ctn=next_ctn, htlc_proposer=REMOTE, htlc_id=htlc_id):
raise Exception(f"(local) cannot remove htlc that is not there...")
self.log[REMOTE]['settles'][htlc_id] = {LOCAL: None, REMOTE: next_ctn}
@with_lock
def recv_settle(self, htlc_id: int) -> None:
next_ctn = self.ctn_latest(LOCAL) + 1
if not self.is_htlc_active_at_ctn(ctx_owner=LOCAL, ctn=next_ctn, htlc_proposer=LOCAL, htlc_id=htlc_id):
raise Exception(f"(remote) cannot remove htlc that is not there...")
self.log[LOCAL]['settles'][htlc_id] = {LOCAL: next_ctn, REMOTE: None}
@with_lock
def send_fail(self, htlc_id: int) -> None:
next_ctn = self.ctn_latest(REMOTE) + 1
if not self.is_htlc_active_at_ctn(ctx_owner=REMOTE, ctn=next_ctn, htlc_proposer=REMOTE, htlc_id=htlc_id):
raise Exception(f"(local) cannot remove htlc that is not there...")
self.log[REMOTE]['fails'][htlc_id] = {LOCAL: None, REMOTE: next_ctn}
@with_lock
def recv_fail(self, htlc_id: int) -> None:
next_ctn = self.ctn_latest(LOCAL) + 1
if not self.is_htlc_active_at_ctn(ctx_owner=LOCAL, ctn=next_ctn, htlc_proposer=LOCAL, htlc_id=htlc_id):
raise Exception(f"(remote) cannot remove htlc that is not there...")
self.log[LOCAL]['fails'][htlc_id] = {LOCAL: next_ctn, REMOTE: None}
@with_lock
def send_update_fee(self, feerate: int) -> None:
fee_update = FeeUpdate(rate=feerate,
ctn_local=None, ctn_remote=self.ctn_latest(REMOTE) + 1)
self._new_feeupdate(fee_update, subject=LOCAL)
@with_lock
def recv_update_fee(self, feerate: int) -> None:
fee_update = FeeUpdate(rate=feerate,
ctn_local=self.ctn_latest(LOCAL) + 1, ctn_remote=None)
self._new_feeupdate(fee_update, subject=REMOTE)
@with_lock
def _new_feeupdate(self, fee_update: FeeUpdate, subject: HTLCOwner) -> None:
# overwrite last fee update if not yet committed to by anyone; otherwise append
d = self.log[subject]['fee_updates']
#assert type(d) is StoredDict
n = len(d)
last_fee_update = d[n-1]
if (last_fee_update.ctn_local is None or last_fee_update.ctn_local > self.ctn_latest(LOCAL)) \
and (last_fee_update.ctn_remote is None or last_fee_update.ctn_remote > self.ctn_latest(REMOTE)):
d[n-1] = fee_update
else:
d[n] = fee_update
@with_lock
def send_ctx(self) -> None:
assert self.ctn_latest(REMOTE) == self.ctn_oldest_unrevoked(REMOTE), (self.ctn_latest(REMOTE), self.ctn_oldest_unrevoked(REMOTE))
self._set_revack_pending(REMOTE, True)
self.log[LOCAL]['was_revoke_last'] = False
@with_lock
def recv_ctx(self) -> None:
assert self.ctn_latest(LOCAL) == self.ctn_oldest_unrevoked(LOCAL), (self.ctn_latest(LOCAL), self.ctn_oldest_unrevoked(LOCAL))
self._set_revack_pending(LOCAL, True)
@with_lock
def send_rev(self) -> None:
self.log[LOCAL]['ctn'] += 1
self._set_revack_pending(LOCAL, False)
self.log[LOCAL]['was_revoke_last'] = True
# htlcs
for htlc_id in self._maybe_active_htlc_ids[REMOTE]:
ctns = self.log[REMOTE]['locked_in'][htlc_id]
if ctns[REMOTE] is None and ctns[LOCAL] <= self.ctn_latest(LOCAL):
ctns[REMOTE] = self.ctn_latest(REMOTE) + 1
for log_action in ('settles', 'fails'):
for htlc_id in self._maybe_active_htlc_ids[LOCAL]:
ctns = self.log[LOCAL][log_action].get(htlc_id, None)
if ctns is None: continue
if ctns[REMOTE] is None and ctns[LOCAL] <= self.ctn_latest(LOCAL):
ctns[REMOTE] = self.ctn_latest(REMOTE) + 1
self._update_maybe_active_htlc_ids()
# fee updates
for k, fee_update in list(self.log[REMOTE]['fee_updates'].items()):
if fee_update.ctn_remote is None and fee_update.ctn_local <= self.ctn_latest(LOCAL):
fee_update.ctn_remote = self.ctn_latest(REMOTE) + 1
@with_lock
def recv_rev(self) -> None:
self.log[REMOTE]['ctn'] += 1
self._set_revack_pending(REMOTE, False)
# htlcs
for htlc_id in self._maybe_active_htlc_ids[LOCAL]:
ctns = self.log[LOCAL]['locked_in'][htlc_id]
if ctns[LOCAL] is None and ctns[REMOTE] <= self.ctn_latest(REMOTE):
ctns[LOCAL] = self.ctn_latest(LOCAL) + 1
for log_action in ('settles', 'fails'):
for htlc_id in self._maybe_active_htlc_ids[REMOTE]:
ctns = self.log[REMOTE][log_action].get(htlc_id, None)
if ctns is None: continue
if ctns[LOCAL] is None and ctns[REMOTE] <= self.ctn_latest(REMOTE):
ctns[LOCAL] = self.ctn_latest(LOCAL) + 1
self._update_maybe_active_htlc_ids()
# fee updates
for k, fee_update in list(self.log[LOCAL]['fee_updates'].items()):
if fee_update.ctn_local is None and fee_update.ctn_remote <= self.ctn_latest(REMOTE):
fee_update.ctn_local = self.ctn_latest(LOCAL) + 1
# no need to keep local update raw msgs anymore, they have just been ACKed.
self.log[LOCAL]['unacked_updates'].pop(self.log[REMOTE]['ctn'], None)
@with_lock
def _update_maybe_active_htlc_ids(self) -> None:
# - Loosely, we want a set that contains the htlcs that are
# not "removed and revoked from all ctxs of both parties". (self._maybe_active_htlc_ids)
# It is guaranteed that those htlcs are in the set, but older htlcs might be there too:
# there is a sanity margin of 1 ctn -- this relaxes the care needed re order of method calls.
# - balance_delta is in sync with maybe_active_htlc_ids. When htlcs are removed from the latter,
# balance_delta is updated to reflect that htlc.
sanity_margin = 1
for htlc_proposer in (LOCAL, REMOTE):
for log_action in ('settles', 'fails'):
for htlc_id in list(self._maybe_active_htlc_ids[htlc_proposer]):
ctns = self.log[htlc_proposer][log_action].get(htlc_id, None)
if ctns is None: continue
if (ctns[LOCAL] is not None
and ctns[LOCAL] <= self.ctn_oldest_unrevoked(LOCAL) - sanity_margin
and ctns[REMOTE] is not None
and ctns[REMOTE] <= self.ctn_oldest_unrevoked(REMOTE) - sanity_margin):
self._maybe_active_htlc_ids[htlc_proposer].remove(htlc_id)
if log_action == 'settles':
htlc = self.log[htlc_proposer]['adds'][htlc_id] # type: UpdateAddHtlc
self._balance_delta -= htlc.amount_msat * htlc_proposer
@with_lock
def _init_maybe_active_htlc_ids(self):
# first idx is "side who offered htlc":
self._maybe_active_htlc_ids = {LOCAL: set(), REMOTE: set()} # type: Dict[HTLCOwner, Set[int]]
# add all htlcs
self._balance_delta = 0 # the balance delta of LOCAL since channel open
for htlc_proposer in (LOCAL, REMOTE):
for htlc_id in self.log[htlc_proposer]['adds']:
self._maybe_active_htlc_ids[htlc_proposer].add(htlc_id)
# remove old htlcs
self._update_maybe_active_htlc_ids()
@with_lock
def discard_unsigned_remote_updates(self):
"""Discard updates sent by the remote, that the remote itself
did not yet sign (i.e. there was no corresponding commitment_signed msg)
"""
# htlcs added
for htlc_id, ctns in list(self.log[REMOTE]['locked_in'].items()):
if ctns[LOCAL] > self.ctn_latest(LOCAL):
del self.log[REMOTE]['locked_in'][htlc_id]
del self.log[REMOTE]['adds'][htlc_id]
self._maybe_active_htlc_ids[REMOTE].discard(htlc_id)
if self.log[REMOTE]['locked_in']:
self.log[REMOTE]['next_htlc_id'] = max([int(x) for x in self.log[REMOTE]['locked_in'].keys()]) + 1
else:
self.log[REMOTE]['next_htlc_id'] = 0
# htlcs removed
for log_action in ('settles', 'fails'):
for htlc_id, ctns in list(self.log[LOCAL][log_action].items()):
if ctns[LOCAL] > self.ctn_latest(LOCAL):
del self.log[LOCAL][log_action][htlc_id]
# fee updates
for k, fee_update in list(self.log[REMOTE]['fee_updates'].items()):
if fee_update.ctn_local > self.ctn_latest(LOCAL):
self.log[REMOTE]['fee_updates'].pop(k)
@with_lock
def store_local_update_raw_msg(self, raw_update_msg: bytes, *, is_commitment_signed: bool) -> None:
"""We need to be able to replay unacknowledged updates we sent to the remote
in case of disconnections. Hence, raw update and commitment_signed messages
are stored temporarily (until they are acked)."""
# self.log[LOCAL]['unacked_updates'][ctn_idx] is a list of raw messages
# containing some number of updates and then a single commitment_signed
if is_commitment_signed:
ctn_idx = self.ctn_latest(REMOTE)
else:
ctn_idx = self.ctn_latest(REMOTE) + 1
l = self.log[LOCAL]['unacked_updates'].get(ctn_idx, [])
l.append(raw_update_msg.hex())
self.log[LOCAL]['unacked_updates'][ctn_idx] = l
@with_lock
def get_unacked_local_updates(self) -> Dict[int, Sequence[bytes]]:
#return self.log[LOCAL]['unacked_updates']
return {ctn: [bfh(msg) for msg in messages]
for ctn, messages in self.log[LOCAL]['unacked_updates'].items()}
@with_lock
def was_revoke_last(self) -> bool:
"""Whether we sent a revoke_and_ack after the last commitment_signed we sent."""
return self.log[LOCAL].get('was_revoke_last') or False
##### Queries re HTLCs:
def get_htlc_by_id(self, htlc_proposer: HTLCOwner, htlc_id: int) -> UpdateAddHtlc:
return self.log[htlc_proposer]['adds'][htlc_id]
@with_lock
def is_htlc_active_at_ctn(self, *, ctx_owner: HTLCOwner, ctn: int,
htlc_proposer: HTLCOwner, htlc_id: int) -> bool:
htlc_id = int(htlc_id)
if htlc_id >= self.get_next_htlc_id(htlc_proposer):
return False
settles = self.log[htlc_proposer]['settles']
fails = self.log[htlc_proposer]['fails']
ctns = self.log[htlc_proposer]['locked_in'][htlc_id]
if ctns[ctx_owner] is not None and ctns[ctx_owner] <= ctn:
not_settled = htlc_id not in settles or settles[htlc_id][ctx_owner] is None or settles[htlc_id][ctx_owner] > ctn
not_failed = htlc_id not in fails or fails[htlc_id][ctx_owner] is None or fails[htlc_id][ctx_owner] > ctn
if not_settled and not_failed:
return True
return False
@with_lock
def is_htlc_irrevocably_added_yet(
self,
*,
ctx_owner: HTLCOwner = None,
htlc_proposer: HTLCOwner,
htlc_id: int,
) -> bool:
"""Returns whether `add_htlc` was irrevocably committed to `ctx_owner's` ctx.
If `ctx_owner` is None, both parties' ctxs are checked.
"""
in_local = self._is_htlc_irrevocably_added_yet(
ctx_owner=LOCAL, htlc_proposer=htlc_proposer, htlc_id=htlc_id)
in_remote = self._is_htlc_irrevocably_added_yet(
ctx_owner=REMOTE, htlc_proposer=htlc_proposer, htlc_id=htlc_id)
if ctx_owner is None:
return in_local and in_remote
elif ctx_owner == LOCAL:
return in_local
elif ctx_owner == REMOTE:
return in_remote
else:
raise Exception(f"unexpected ctx_owner: {ctx_owner!r}")
@with_lock
def _is_htlc_irrevocably_added_yet(
self,
*,
ctx_owner: HTLCOwner,
htlc_proposer: HTLCOwner,
htlc_id: int,
) -> bool:
if htlc_id >= self.get_next_htlc_id(htlc_proposer):
return False
ctns = self.log[htlc_proposer]['locked_in'][htlc_id]
if ctns[ctx_owner] is None:
return False
return ctns[ctx_owner] <= self.ctn_oldest_unrevoked(ctx_owner)
@with_lock
def is_htlc_irrevocably_removed_yet(
self,
*,
ctx_owner: HTLCOwner = None,
htlc_proposer: HTLCOwner,
htlc_id: int,
) -> bool:
"""Returns whether the removal of an htlc was irrevocably committed to `ctx_owner's` ctx.
The removal can either be a fulfill/settle or a fail; they are not distinguished.
If `ctx_owner` is None, both parties' ctxs are checked.
"""
in_local = self._is_htlc_irrevocably_removed_yet(
ctx_owner=LOCAL, htlc_proposer=htlc_proposer, htlc_id=htlc_id)
in_remote = self._is_htlc_irrevocably_removed_yet(
ctx_owner=REMOTE, htlc_proposer=htlc_proposer, htlc_id=htlc_id)
if ctx_owner is None:
return in_local and in_remote
elif ctx_owner == LOCAL:
return in_local
elif ctx_owner == REMOTE:
return in_remote
else:
raise Exception(f"unexpected ctx_owner: {ctx_owner!r}")
@with_lock
def _is_htlc_irrevocably_removed_yet(
self,
*,
ctx_owner: HTLCOwner,
htlc_proposer: HTLCOwner,
htlc_id: int,
) -> bool:
if htlc_id >= self.get_next_htlc_id(htlc_proposer):
return False
if htlc_id in self.log[htlc_proposer]['settles']:
ctn_of_settle = self.log[htlc_proposer]['settles'][htlc_id][ctx_owner]
else:
ctn_of_settle = None
if htlc_id in self.log[htlc_proposer]['fails']:
ctn_of_fail = self.log[htlc_proposer]['fails'][htlc_id][ctx_owner]
else:
ctn_of_fail = None
ctn_of_rm = ctn_of_settle or ctn_of_fail or None
if ctn_of_rm is None:
return False
return ctn_of_rm <= self.ctn_oldest_unrevoked(ctx_owner)
@with_lock
def htlcs_by_direction(self, subject: HTLCOwner, direction: Direction,
ctn: int = None) -> Dict[int, UpdateAddHtlc]:
"""Return the dict of received or sent (depending on direction) HTLCs
in subject's ctx at ctn, keyed by htlc_id.
direction is relative to subject!
"""
assert type(subject) is HTLCOwner
assert type(direction) is Direction
if ctn is None:
ctn = self.ctn_oldest_unrevoked(subject)
d = {}
# subject's ctx
# party is the proposer of the HTLCs
party = subject if direction == SENT else subject.inverted()
if ctn >= self.ctn_oldest_unrevoked(subject):
considered_htlc_ids = self._maybe_active_htlc_ids[party]
else: # ctn is too old; need to consider full log (slow...)
considered_htlc_ids = self.log[party]['locked_in']
for htlc_id in considered_htlc_ids:
htlc_id = int(htlc_id)
if self.is_htlc_active_at_ctn(ctx_owner=subject, ctn=ctn, htlc_proposer=party, htlc_id=htlc_id):
d[htlc_id] = self.log[party]['adds'][htlc_id]
return d
@with_lock
def htlcs(self, subject: HTLCOwner, ctn: int = None) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
"""Return the list of HTLCs in subject's ctx at ctn."""
assert type(subject) is HTLCOwner
if ctn is None:
ctn = self.ctn_oldest_unrevoked(subject)
l = []
l += [(SENT, x) for x in self.htlcs_by_direction(subject, SENT, ctn).values()]
l += [(RECEIVED, x) for x in self.htlcs_by_direction(subject, RECEIVED, ctn).values()]
return l
@with_lock
def get_htlcs_in_oldest_unrevoked_ctx(self, subject: HTLCOwner) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
assert type(subject) is HTLCOwner
ctn = self.ctn_oldest_unrevoked(subject)
return self.htlcs(subject, ctn)
@with_lock
def get_htlcs_in_latest_ctx(self, subject: HTLCOwner) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
assert type(subject) is HTLCOwner
ctn = self.ctn_latest(subject)
return self.htlcs(subject, ctn)
@with_lock
def get_htlcs_in_next_ctx(self, subject: HTLCOwner) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
assert type(subject) is HTLCOwner
ctn = self.ctn_latest(subject) + 1
return self.htlcs(subject, ctn)
def was_htlc_preimage_released(self, *, htlc_id: int, htlc_proposer: HTLCOwner) -> bool:
settles = self.log[htlc_proposer]['settles']
if htlc_id not in settles:
return False
return settles[htlc_id][htlc_proposer] is not None
def was_htlc_failed(self, *, htlc_id: int, htlc_proposer: HTLCOwner) -> bool:
"""Returns whether an HTLC has been (or will be if we already know) failed."""
fails = self.log[htlc_proposer]['fails']
if htlc_id not in fails:
return False
return fails[htlc_id][htlc_proposer] is not None
@with_lock
def all_settled_htlcs_ever_by_direction(self, subject: HTLCOwner, direction: Direction,
ctn: int = None) -> Sequence[UpdateAddHtlc]:
"""Return the list of all HTLCs that have been ever settled in subject's
ctx up to ctn, filtered to only "direction".
"""
assert type(subject) is HTLCOwner
if ctn is None:
ctn = self.ctn_oldest_unrevoked(subject)
# subject's ctx
# party is the proposer of the HTLCs
party = subject if direction == SENT else subject.inverted()
d = []
for htlc_id, ctns in self.log[party]['settles'].items():
if ctns[subject] is not None and ctns[subject] <= ctn:
d.append(self.log[party]['adds'][htlc_id])
return d
@with_lock
def all_settled_htlcs_ever(self, subject: HTLCOwner, ctn: int = None) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
"""Return the list of all HTLCs that have been ever settled in subject's
ctx up to ctn.
"""
assert type(subject) is HTLCOwner
if ctn is None:
ctn = self.ctn_oldest_unrevoked(subject)
sent = [(SENT, x) for x in self.all_settled_htlcs_ever_by_direction(subject, SENT, ctn)]
received = [(RECEIVED, x) for x in self.all_settled_htlcs_ever_by_direction(subject, RECEIVED, ctn)]
return sent + received
@with_lock
def all_htlcs_ever(self) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
sent = [(SENT, htlc) for htlc in self.log[LOCAL]['adds'].values()]
received = [(RECEIVED, htlc) for htlc in self.log[REMOTE]['adds'].values()]
return sent + received
@with_lock
def get_balance_msat(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None,
initial_balance_msat: int) -> int:
"""Returns the balance of 'whose' in 'ctx' at 'ctn'.
Only HTLCs that have been settled by that ctn are counted.
"""
if ctn is None:
ctn = self.ctn_oldest_unrevoked(ctx_owner)
balance = initial_balance_msat
if ctn >= self.ctn_oldest_unrevoked(ctx_owner):
balance += self._balance_delta * whose
considered_sent_htlc_ids = self._maybe_active_htlc_ids[whose]
considered_recv_htlc_ids = self._maybe_active_htlc_ids[-whose]
else: # ctn is too old; need to consider full log (slow...)
considered_sent_htlc_ids = self.log[whose]['settles']
considered_recv_htlc_ids = self.log[-whose]['settles']
# sent htlcs
for htlc_id in considered_sent_htlc_ids:
ctns = self.log[whose]['settles'].get(htlc_id, None)
if ctns is None:
continue
if ctns[ctx_owner] is not None and ctns[ctx_owner] <= ctn:
htlc = self.log[whose]['adds'][htlc_id]
balance -= htlc.amount_msat
# recv htlcs
for htlc_id in considered_recv_htlc_ids:
ctns = self.log[-whose]['settles'].get(htlc_id, None)
if ctns is None:
continue
if ctns[ctx_owner] is not None and ctns[ctx_owner] <= ctn:
htlc = self.log[-whose]['adds'][htlc_id]
balance += htlc.amount_msat
return balance
@with_lock
def _get_htlcs_that_got_removed_exactly_at_ctn(
self, ctn: int, *, ctx_owner: HTLCOwner, htlc_proposer: HTLCOwner, log_action: str,
) -> Sequence[UpdateAddHtlc]:
if ctn >= self.ctn_oldest_unrevoked(ctx_owner):
considered_htlc_ids = self._maybe_active_htlc_ids[htlc_proposer]
else: # ctn is too old; need to consider full log (slow...)
considered_htlc_ids = self.log[htlc_proposer][log_action]
htlcs = []
for htlc_id in considered_htlc_ids:
ctns = self.log[htlc_proposer][log_action].get(htlc_id, None)
if ctns is None:
continue
if ctns[ctx_owner] == ctn:
htlcs.append(self.log[htlc_proposer]['adds'][htlc_id])
return htlcs
def received_in_ctn(self, local_ctn: int) -> Sequence[UpdateAddHtlc]:
"""
received htlcs that became fulfilled when we send a revocation.
we check only local, because they are committed in the remote ctx first.
"""
return self._get_htlcs_that_got_removed_exactly_at_ctn(local_ctn,
ctx_owner=LOCAL,
htlc_proposer=REMOTE,
log_action='settles')
def sent_in_ctn(self, remote_ctn: int) -> Sequence[UpdateAddHtlc]:
"""
sent htlcs that became fulfilled when we received a revocation
we check only remote, because they are committed in the local ctx first.
"""
return self._get_htlcs_that_got_removed_exactly_at_ctn(remote_ctn,
ctx_owner=REMOTE,
htlc_proposer=LOCAL,
log_action='settles')
def failed_in_ctn(self, remote_ctn: int) -> Sequence[UpdateAddHtlc]:
"""
sent htlcs that became failed when we received a revocation
we check only remote, because they are committed in the local ctx first.
"""
return self._get_htlcs_that_got_removed_exactly_at_ctn(remote_ctn,
ctx_owner=REMOTE,
htlc_proposer=LOCAL,
log_action='fails')
##### Queries re Fees:
# note: feerates are in sat/kw everywhere in this file
@with_lock
def get_feerate(self, subject: HTLCOwner, ctn: int) -> int:
"""Return feerate (sat/kw) used in subject's commitment txn at ctn."""
ctn = max(0, ctn) # FIXME rm this
# only one party can update fees; use length of logs to figure out which:
assert not (len(self.log[LOCAL]['fee_updates']) > 0 and len(self.log[REMOTE]['fee_updates']) > 0)
fee_log = self.log[LOCAL]['fee_updates'] # type: Sequence[FeeUpdate]
if len(self.log[REMOTE]['fee_updates']) > 0:
fee_log = self.log[REMOTE]['fee_updates']
# binary search
left = 0
right = len(fee_log)
while True:
i = (left + right) // 2
ctn_at_i = fee_log[i].ctn_local if subject == LOCAL else fee_log[i].ctn_remote
if right - left <= 1:
break
if ctn_at_i is None: # Nones can only be on the right end
right = i
continue
if ctn_at_i <= ctn: # among equals, we want the rightmost
left = i
else:
right = i
assert ctn_at_i <= ctn
return fee_log[i].rate
def get_feerate_in_oldest_unrevoked_ctx(self, subject: HTLCOwner) -> int:
return self.get_feerate(subject=subject, ctn=self.ctn_oldest_unrevoked(subject))
def get_feerate_in_latest_ctx(self, subject: HTLCOwner) -> int:
return self.get_feerate(subject=subject, ctn=self.ctn_latest(subject))
def get_feerate_in_next_ctx(self, subject: HTLCOwner) -> int:
return self.get_feerate(subject=subject, ctn=self.ctn_latest(subject) + 1)
================================================
FILE: electrum/lnmsg.py
================================================
import os
import csv
import io
from typing import Callable, Tuple, Any, Dict, List, Sequence, Union, Optional, Mapping
from types import MappingProxyType
from collections import OrderedDict
from .lnutil import OnionFailureCodeMetaFlag
class FailedToParseMsg(Exception):
msg_type_int: Optional[int] = None
msg_type_name: Optional[str] = None
class UnknownMsgType(FailedToParseMsg): pass
class UnknownOptionalMsgType(UnknownMsgType): pass
class UnknownMandatoryMsgType(UnknownMsgType): pass
class MalformedMsg(FailedToParseMsg): pass
class UnknownMsgFieldType(MalformedMsg): pass
class UnexpectedEndOfStream(MalformedMsg): pass
class FieldEncodingNotMinimal(MalformedMsg): pass
class UnknownMandatoryTLVRecordType(MalformedMsg): pass
class MsgTrailingGarbage(MalformedMsg): pass
class MsgInvalidFieldOrder(MalformedMsg): pass
class UnexpectedFieldSizeForEncoder(MalformedMsg): pass
def _num_remaining_bytes_to_read(fd: io.BytesIO) -> int:
cur_pos = fd.tell()
end_pos = fd.seek(0, io.SEEK_END)
fd.seek(cur_pos)
return end_pos - cur_pos
def _assert_can_read_at_least_n_bytes(fd: io.BytesIO, n: int) -> None:
# note: it's faster to read n bytes and then check if we read n, than
# to assert we can read at least n and then read n bytes.
nremaining = _num_remaining_bytes_to_read(fd)
if nremaining < n:
raise UnexpectedEndOfStream(f"wants to read {n} bytes but only {nremaining} bytes left")
def write_bigsize_int(i: int) -> bytes:
assert i >= 0, i
if i < 0xfd:
return int.to_bytes(i, length=1, byteorder="big", signed=False)
elif i < 0x1_0000:
return b"\xfd" + int.to_bytes(i, length=2, byteorder="big", signed=False)
elif i < 0x1_0000_0000:
return b"\xfe" + int.to_bytes(i, length=4, byteorder="big", signed=False)
else:
return b"\xff" + int.to_bytes(i, length=8, byteorder="big", signed=False)
def read_bigsize_int(fd: io.BytesIO) -> Optional[int]:
try:
first = fd.read(1)[0]
except IndexError:
return None # end of file
if first < 0xfd:
return first
elif first == 0xfd:
buf = fd.read(2)
if len(buf) != 2:
raise UnexpectedEndOfStream()
val = int.from_bytes(buf, byteorder="big", signed=False)
if not (0xfd <= val < 0x1_0000):
raise FieldEncodingNotMinimal()
return val
elif first == 0xfe:
buf = fd.read(4)
if len(buf) != 4:
raise UnexpectedEndOfStream()
val = int.from_bytes(buf, byteorder="big", signed=False)
if not (0x1_0000 <= val < 0x1_0000_0000):
raise FieldEncodingNotMinimal()
return val
elif first == 0xff:
buf = fd.read(8)
if len(buf) != 8:
raise UnexpectedEndOfStream()
val = int.from_bytes(buf, byteorder="big", signed=False)
if not (0x1_0000_0000 <= val):
raise FieldEncodingNotMinimal()
return val
raise Exception()
# TODO: maybe if field_type is not "byte", we could return a list of type_len sized chunks?
# if field_type is a numeric, we could return a list of ints?
def _read_primitive_field(
*,
fd: io.BytesIO,
field_type: str,
count: Union[int, str]
) -> Union[bytes, int]:
if not fd:
raise Exception()
if isinstance(count, int):
assert count >= 0, f"{count!r} must be non-neg int"
elif count == "...":
pass
else:
raise Exception(f"unexpected field count: {count!r}")
if count == 0:
return b""
type_len = None
if field_type == 'byte':
type_len = 1
elif field_type in ('u8', 'u16', 'u32', 'u64'):
if field_type == 'u8':
type_len = 1
elif field_type == 'u16':
type_len = 2
elif field_type == 'u32':
type_len = 4
else:
assert field_type == 'u64'
type_len = 8
assert count == 1, count
buf = fd.read(type_len)
if len(buf) != type_len:
raise UnexpectedEndOfStream()
return int.from_bytes(buf, byteorder="big", signed=False)
elif field_type in ('tu16', 'tu32', 'tu64'):
if field_type == 'tu16':
type_len = 2
elif field_type == 'tu32':
type_len = 4
else:
assert field_type == 'tu64'
type_len = 8
assert count == 1, count
raw = fd.read(type_len)
if len(raw) > 0 and raw[0] == 0x00:
raise FieldEncodingNotMinimal()
return int.from_bytes(raw, byteorder="big", signed=False)
elif field_type == 'bigsize':
assert count == 1, count
val = read_bigsize_int(fd)
if val is None:
raise UnexpectedEndOfStream()
return val
elif field_type == 'chain_hash':
type_len = 32
elif field_type == 'channel_id':
type_len = 32
elif field_type == 'sha256':
type_len = 32
elif field_type == 'signature':
type_len = 64
elif field_type == 'point':
type_len = 33
elif field_type == 'short_channel_id':
type_len = 8
elif field_type == 'sciddir_or_pubkey':
buf = fd.read(1)
if buf[0] in [0, 1]:
type_len = 9
elif buf[0] in [2, 3]:
type_len = 33
else:
raise Exception(f"invalid sciddir_or_pubkey, prefix byte not in range 0-3")
buf += fd.read(type_len - 1)
if len(buf) != type_len:
raise UnexpectedEndOfStream()
return buf
if count == "...":
total_len = -1 # read all
else:
if type_len is None:
raise UnknownMsgFieldType(f"unknown field type: {field_type!r}")
total_len = count * type_len
buf = fd.read(total_len)
if total_len >= 0 and len(buf) != total_len:
raise UnexpectedEndOfStream()
return buf
# TODO: maybe for "value" we could accept a list with len "count" of appropriate items
def _write_primitive_field(
*,
fd: io.BytesIO,
field_type: str,
count: Union[int, str],
value: Union[bytes, int]
) -> None:
if not fd:
raise Exception()
if isinstance(count, int):
assert count >= 0, f"{count!r} must be non-neg int"
elif count == "...":
pass
else:
raise Exception(f"unexpected field count: {count!r}")
if count == 0:
return
type_len = None
if field_type == 'byte':
type_len = 1
elif field_type == 'u8':
type_len = 1
elif field_type == 'u16':
type_len = 2
elif field_type == 'u32':
type_len = 4
elif field_type == 'u64':
type_len = 8
elif field_type in ('tu16', 'tu32', 'tu64'):
if field_type == 'tu16':
type_len = 2
elif field_type == 'tu32':
type_len = 4
else:
assert field_type == 'tu64'
type_len = 8
assert count == 1, count
if isinstance(value, int):
value = int.to_bytes(value, length=type_len, byteorder="big", signed=False)
if not isinstance(value, (bytes, bytearray)):
raise Exception(f"can only write bytes into fd. got: {value!r}")
while len(value) > 0 and value[0] == 0x00:
value = value[1:]
nbytes_written = fd.write(value)
if nbytes_written != len(value):
raise Exception(f"tried to write {len(value)} bytes, but only wrote {nbytes_written}!?")
return
elif field_type == 'bigsize':
assert count == 1, count
if isinstance(value, int):
value = write_bigsize_int(value)
if not isinstance(value, (bytes, bytearray)):
raise Exception(f"can only write bytes into fd. got: {value!r}")
nbytes_written = fd.write(value)
if nbytes_written != len(value):
raise Exception(f"tried to write {len(value)} bytes, but only wrote {nbytes_written}!?")
return
elif field_type == 'chain_hash':
type_len = 32
elif field_type == 'channel_id':
type_len = 32
elif field_type == 'sha256':
type_len = 32
elif field_type == 'signature':
type_len = 64
elif field_type == 'point':
type_len = 33
elif field_type == 'short_channel_id':
type_len = 8
elif field_type == 'sciddir_or_pubkey':
assert isinstance(value, bytes)
if value[0] in [0, 1]:
type_len = 9 # short_channel_id
elif value[0] in [2, 3]:
type_len = 33 # point
else:
raise Exception(f"invalid sciddir_or_pubkey, prefix byte not in range 0-3")
total_len = -1
if count != "...":
if type_len is None:
raise UnknownMsgFieldType(f"unknown field type: {field_type!r}")
total_len = count * type_len
if isinstance(value, int) and (count == 1 or field_type == 'byte'):
value = int.to_bytes(value, length=total_len, byteorder="big", signed=False)
if not isinstance(value, (bytes, bytearray)):
raise Exception(f"can only write bytes into fd. got: {value!r}")
if count != "..." and total_len != len(value):
raise UnexpectedFieldSizeForEncoder(f"expected: {total_len}, got {len(value)}")
nbytes_written = fd.write(value)
if nbytes_written != len(value):
raise Exception(f"tried to write {len(value)} bytes, but only wrote {nbytes_written}!?")
def _read_tlv_record(*, fd: io.BytesIO) -> Tuple[int, bytes]:
if not fd: raise Exception()
tlv_type = _read_primitive_field(fd=fd, field_type="bigsize", count=1)
tlv_len = _read_primitive_field(fd=fd, field_type="bigsize", count=1)
tlv_val = _read_primitive_field(fd=fd, field_type="byte", count=tlv_len)
return tlv_type, tlv_val
def _write_tlv_record(*, fd: io.BytesIO, tlv_type: int, tlv_val: bytes) -> None:
if not fd: raise Exception()
tlv_len = len(tlv_val)
_write_primitive_field(fd=fd, field_type="bigsize", count=1, value=tlv_type)
_write_primitive_field(fd=fd, field_type="bigsize", count=1, value=tlv_len)
_write_primitive_field(fd=fd, field_type="byte", count=tlv_len, value=tlv_val)
def _resolve_field_count(field_count_str: str, *, vars_dict: Mapping, allow_any=False) -> Union[int, str]:
"""Returns an evaluated field count, typically an int.
If allow_any is True, the return value can be a str with value=="...".
"""
if field_count_str == "":
field_count = 1
elif field_count_str == "...":
if not allow_any:
raise Exception("field count is '...' but allow_any is False")
return field_count_str
else:
try:
field_count = int(field_count_str)
except ValueError:
field_count = vars_dict[field_count_str]
if isinstance(field_count, (bytes, bytearray)):
field_count = int.from_bytes(field_count, byteorder="big")
assert isinstance(field_count, int)
return field_count
def _parse_msgtype_intvalue_for_onion_wire(value: str) -> int:
msg_type_int = 0
for component in value.split("|"):
try:
msg_type_int |= int(component)
except ValueError:
msg_type_int |= OnionFailureCodeMetaFlag[component]
return msg_type_int
class LNSerializer:
def __init__(self, *, name: str = 'peer_wire'):
# TODO msg_type could be 'int' everywhere...
self.msg_scheme_from_type = {} # type: Dict[bytes, List[Sequence[str]]]
self.msg_type_from_name = {} # type: Dict[str, bytes]
self.in_tlv_stream_get_tlv_record_scheme_from_type = {} # type: Dict[str, Dict[int, List[Sequence[str]]]]
self.in_tlv_stream_get_record_type_from_name = {} # type: Dict[str, Dict[str, int]]
self.in_tlv_stream_get_record_name_from_type = {} # type: Dict[str, Dict[int, str]]
self.subtypes = {} # type: Dict[str, Dict[str, Sequence[str]]]
path = os.path.join(os.path.dirname(__file__), "lnwire", name + ".csv")
with open(path, newline='') as f:
csvreader = csv.reader(f)
for row in csvreader:
#print(f">>> {row!r}")
if row[0] == "msgtype":
# msgtype,,[,