Browse Source

Merge branch 'master' into trezor-0.11

3.3.3.1
matejcik 6 years ago
parent
commit
8973bb6f71
  1. 3
      .gitmodules
  2. 2
      .travis.yml
  3. 6
      README.rst
  4. 12
      RELEASE-NOTES
  5. 12
      contrib/build-osx/base.sh
  6. 2
      contrib/build-wine/docker/Dockerfile
  7. 2
      contrib/make_apk
  8. 1
      contrib/osx/CalinsQRReader
  9. 4
      contrib/osx/README.md
  10. 35
      contrib/osx/base.sh
  11. 0
      contrib/osx/cdrkit-deterministic.patch
  12. 44
      contrib/osx/make_osx
  13. 7
      contrib/osx/osx.spec
  14. 0
      contrib/osx/package.sh
  15. 2
      contrib/requirements/requirements.txt
  16. 15
      electrum/address_synchronizer.py
  17. 7
      electrum/base_wizard.py
  18. 298
      electrum/blockchain.py
  19. 3
      electrum/commands.py
  20. 3
      electrum/contacts.py
  21. 95
      electrum/crypto.py
  22. 6
      electrum/exchange_rate.py
  23. 2
      electrum/gui/kivy/Makefile
  24. 167
      electrum/gui/kivy/Readme.md
  25. 9
      electrum/gui/kivy/main_window.py
  26. 142
      electrum/gui/kivy/tools/Dockerfile
  27. 4
      electrum/gui/kivy/tools/buildozer.spec
  28. 2
      electrum/gui/qt/__init__.py
  29. 82
      electrum/gui/qt/address_list.py
  30. 80
      electrum/gui/qt/contact_list.py
  31. 396
      electrum/gui/qt/history_list.py
  32. 4
      electrum/gui/qt/installwizard.py
  33. 28
      electrum/gui/qt/invoice_list.py
  34. 11
      electrum/gui/qt/main_window.py
  35. 24
      electrum/gui/qt/network_dialog.py
  36. 17
      electrum/gui/qt/password_dialog.py
  37. 68
      electrum/gui/qt/request_list.py
  38. 243
      electrum/gui/qt/util.py
  39. 64
      electrum/gui/qt/utxo_list.py
  40. 2
      electrum/gui/text.py
  41. 65
      electrum/interface.py
  42. 58
      electrum/keystore.py
  43. 45
      electrum/network.py
  44. 11
      electrum/plugins/coldcard/coldcard.py
  45. 8
      electrum/plugins/digitalbitbox/digitalbitbox.py
  46. 14
      electrum/plugins/hw_wallet/qt.py
  47. 5
      electrum/plugins/labels/labels.py
  48. 25
      electrum/qrscanner.py
  49. 7
      electrum/storage.py
  50. 27
      electrum/tests/test_bitcoin.py
  51. 239
      electrum/tests/test_blockchain.py
  52. 20
      electrum/tests/test_network.py
  53. 72
      electrum/tests/test_wallet.py
  54. 27
      electrum/util.py
  55. 4
      electrum/version.py
  56. 88
      electrum/wallet.py

3
.gitmodules

@ -4,3 +4,6 @@
[submodule "contrib/deterministic-build/electrum-locale"] [submodule "contrib/deterministic-build/electrum-locale"]
path = contrib/deterministic-build/electrum-locale path = contrib/deterministic-build/electrum-locale
url = https://github.com/spesmilo/electrum-locale url = https://github.com/spesmilo/electrum-locale
[submodule "contrib/CalinsQRReader"]
path = contrib/osx/CalinsQRReader
url = https://github.com/spesmilo/CalinsQRReader

2
.travis.yml

@ -47,7 +47,7 @@ jobs:
python: false python: false
install: install:
- git fetch --all --tags - git fetch --all --tags
script: ./contrib/build-osx/make_osx script: ./contrib/osx/make_osx
after_script: ls -lah dist && md5 dist/* after_script: ls -lah dist && md5 dist/*
after_success: true after_success: true
- stage: release check - stage: release check

6
README.rst

@ -32,7 +32,7 @@ Qt interface, install the Qt dependencies::
sudo apt-get install python3-pyqt5 sudo apt-get install python3-pyqt5
If you downloaded the official package (tar.gz), you can run If you downloaded the official package (tar.gz), you can run
Electrum from its root directory, without installing it on your Electrum from its root directory without installing it on your
system; all the python dependencies are included in the 'packages' system; all the python dependencies are included in the 'packages'
directory. To run Electrum from its root directory, just do:: directory. To run Electrum from its root directory, just do::
@ -44,7 +44,7 @@ You can also install Electrum on your system, by running this command::
python3 -m pip install .[fast] python3 -m pip install .[fast]
This will download and install the Python dependencies used by This will download and install the Python dependencies used by
Electrum, instead of using the 'packages' directory. Electrum instead of using the 'packages' directory.
The 'fast' extra contains some optional dependencies that we think The 'fast' extra contains some optional dependencies that we think
are often useful but they are not strictly needed. are often useful but they are not strictly needed.
@ -101,7 +101,7 @@ This directory contains the python dependencies used by Electrum.
Mac OS X / macOS Mac OS X / macOS
-------- --------
See `contrib/build-osx/`. See `contrib/osx/`.
Windows Windows
------- -------

12
RELEASE-NOTES

@ -1,3 +1,15 @@
# Release 3.3 - (Hodler's Edition)
* The network layer has been rewritten using asyncio.
* Follow blockchain that has the most work, not length.
* New wallet creation defaults to native segwit (bech32).
* RBF batching (option): If the wallet has an unconfirmed RBF
transaction, new payments will be added to that transaction,
instead of creating new transactions.
* OSX: support QR code scanner.
* Android APK: Use API 28, and do not use external storage.
# Release 3.2.3 - (September 3, 2018) # Release 3.2.3 - (September 3, 2018)
* hardware wallet: the Safe-T mini from Archos is now supported. * hardware wallet: the Safe-T mini from Archos is now supported.

12
contrib/build-osx/base.sh

@ -1,12 +0,0 @@
#!/usr/bin/env bash
RED='\033[0;31m'
BLUE='\033[0,34m'
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
}

2
contrib/build-wine/docker/Dockerfile

@ -20,7 +20,7 @@ RUN dpkg --add-architecture i386 && \
wine-stable-i386:i386=3.0.1~bionic \ wine-stable-i386:i386=3.0.1~bionic \
wine-stable:amd64=3.0.1~bionic \ wine-stable:amd64=3.0.1~bionic \
winehq-stable:amd64=3.0.1~bionic \ winehq-stable:amd64=3.0.1~bionic \
git=1:2.17.1-1ubuntu0.3 \ git=1:2.17.1-1ubuntu0.4 \
p7zip-full=16.02+dfsg-6 \ p7zip-full=16.02+dfsg-6 \
make=4.1-9.1ubuntu1 \ make=4.1-9.1ubuntu1 \
mingw-w64=5.0.3-1 \ mingw-w64=5.0.3-1 \

2
contrib/make_apk

@ -2,6 +2,8 @@
pushd ./electrum/gui/kivy/ pushd ./electrum/gui/kivy/
make theming
if [[ -n "$1" && "$1" == "release" ]] ; then if [[ -n "$1" && "$1" == "release" ]] ; then
echo -n Keystore Password: echo -n Keystore Password:
read -s password read -s password

1
contrib/osx/CalinsQRReader

@ -0,0 +1 @@
Subproject commit 20189155a461cf7fbad14357e58fbc8e7c964608

4
contrib/build-osx/README.md → contrib/osx/README.md

@ -14,7 +14,7 @@ Before starting, make sure that the Xcode command line tools are installed (e.g.
cd electrum cd electrum
./contrib/build-osx/make_osx ./contrib/osx/make_osx
This creates a folder named Electrum.app. This creates a folder named Electrum.app.
@ -33,4 +33,4 @@ Copy the Electrum.app directory over and install the dependencies, e.g.:
Then you can just invoke `package.sh` with the path to the app: Then you can just invoke `package.sh` with the path to the app:
cd electrum cd electrum
./contrib/build-osx/package.sh ~/Electrum.app/ ./contrib/osx/package.sh ~/Electrum.app/

35
contrib/osx/base.sh

@ -0,0 +1,35 @@
#!/usr/bin/env bash
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"
}
function DoCodeSignMaybe { # ARGS: infoName fileOrDirName codesignIdentity
infoName="$1"
file="$2"
identity="$3"
deep=""
if [ -z "$identity" ]; then
# we are ok with them not passing anything; master script calls us unconditionally even if no identity is specified
return
fi
if [ -d "$file" ]; then
deep="--deep"
fi
if [ -z "$infoName" ] || [ -z "$file" ] || [ -z "$identity" ] || [ ! -e "$file" ]; then
fail "Argument error to internal function DoCodeSignMaybe()"
fi
info "Code signing ${infoName}..."
codesign -f -v $deep -s "$identity" "$file" || fail "Could not code sign ${infoName}"
}

0
contrib/build-osx/cdrkit-deterministic.patch → contrib/osx/cdrkit-deterministic.patch

44
contrib/build-osx/make_osx → contrib/osx/make_osx

@ -16,6 +16,25 @@ export PYTHONHASHSEED=22
VERSION=`git describe --tags --dirty --always` VERSION=`git describe --tags --dirty --always`
which brew > /dev/null 2>&1 || fail "Please install brew from https://brew.sh/ to continue" 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 and xcode command line tools to continue"
# Code Signing: See https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html
APP_SIGN=""
if [ -n "$1" ]; 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
codesign -s "$1" --dryrun -f ./CODESIGN_TEST > /dev/null 2>&1
res=$?
rm -f ./CODESIGN_TEST
if ((res)); then
fail "Code signing identity \"$1\" appears to be invalid."
fi
unset res
APP_SIGN="$1"
info "Code signing enabled using identity \"$APP_SIGN\""
else
warn "Code signing DISABLED. Specify a valid macOS Developer identity installed on the system as the first argument to this script to enable signing."
fi
info "Installing Python $PYTHON_VERSION" info "Installing Python $PYTHON_VERSION"
export PATH="~/.pyenv/bin:~/.pyenv/shims:~/Library/Python/3.6/bin:$PATH" export PATH="~/.pyenv/bin:~/.pyenv/shims:~/Library/Python/3.6/bin:$PATH"
@ -53,7 +72,8 @@ cp ./contrib/deterministic-build/electrum-icons/icons_rc.py ./electrum/gui/qt
info "Downloading libusb..." info "Downloading libusb..."
curl https://homebrew.bintray.com/bottles/libusb-1.0.22.el_capitan.bottle.tar.gz | \ curl https://homebrew.bintray.com/bottles/libusb-1.0.22.el_capitan.bottle.tar.gz | \
tar xz --directory $BUILDDIR tar xz --directory $BUILDDIR
cp $BUILDDIR/libusb/1.0.22/lib/libusb-1.0.dylib contrib/build-osx cp $BUILDDIR/libusb/1.0.22/lib/libusb-1.0.dylib contrib/osx
DoCodeSignMaybe "libusb" "contrib/osx/libusb-1.0.dylib" "$APP_SIGN" # If APP_SIGN is empty will be a noop
info "Building libsecp256k1" info "Building libsecp256k1"
brew install autoconf automake libtool brew install autoconf automake libtool
@ -65,7 +85,16 @@ git clean -f -x -q
./configure --enable-module-recovery --enable-experimental --enable-module-ecdh --disable-jni ./configure --enable-module-recovery --enable-experimental --enable-module-ecdh --disable-jni
make make
popd popd
cp $BUILDDIR/secp256k1/.libs/libsecp256k1.0.dylib contrib/build-osx cp $BUILDDIR/secp256k1/.libs/libsecp256k1.0.dylib contrib/osx
DoCodeSignMaybe "libsecp256k1" "contrib/osx/libsecp256k1.0.dylib" "$APP_SIGN" # If APP_SIGN is empty will be a noop
info "Building CalinsQRReader..."
d=contrib/osx/CalinsQRReader
pushd $d
rm -fr build
xcodebuild || fail "Could not build CalinsQRReader"
popd
DoCodeSignMaybe "CalinsQRReader.app" "${d}/build/Release/CalinsQRReader.app" "$APP_SIGN" # If APP_SIGN is empty will be a noop
info "Installing requirements..." info "Installing requirements..."
@ -88,7 +117,7 @@ for d in ~/Library/Python/ ~/.pyenv .; do
done done
info "Building binary" info "Building binary"
pyinstaller --noconfirm --ascii --clean --name $VERSION contrib/build-osx/osx.spec || fail "Could not build binary" pyinstaller --noconfirm --ascii --clean --name $VERSION contrib/osx/osx.spec || fail "Could not build binary"
info "Adding bitcoin URI types to Info.plist" info "Adding bitcoin URI types to Info.plist"
plutil -insert 'CFBundleURLTypes' \ plutil -insert 'CFBundleURLTypes' \
@ -96,5 +125,14 @@ plutil -insert 'CFBundleURLTypes' \
-- dist/$PACKAGE.app/Contents/Info.plist \ -- dist/$PACKAGE.app/Contents/Info.plist \
|| fail "Could not add keys to Info.plist. Make sure the program 'plutil' exists and is installed." || fail "Could not add keys to Info.plist. Make sure the program 'plutil' exists and is installed."
DoCodeSignMaybe "app bundle" "dist/${PACKAGE}.app" "$APP_SIGN" # If APP_SIGN is empty will be a noop
info "Creating .DMG" info "Creating .DMG"
hdiutil create -fs HFS+ -volname $PACKAGE -srcfolder dist/$PACKAGE.app dist/electrum-$VERSION.dmg || fail "Could not create .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" "$APP_SIGN" # If APP_SIGN is empty will be a noop
if [ -z "$APP_SIGN" ]; then
warn "App was built successfully but was not code signed. Users may get security warnings from macOS."
warn "Specify a valid code signing identity as the first argument to this script to enable code signing."
fi

7
contrib/build-osx/osx.spec → contrib/osx/osx.spec

@ -41,9 +41,12 @@ datas += collect_data_files('btchip')
datas += collect_data_files('keepkeylib') datas += collect_data_files('keepkeylib')
datas += collect_data_files('ckcc') datas += collect_data_files('ckcc')
# Add the QR Scanner helper app
datas += [(electrum + "contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app", "./contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app")]
# Add libusb so Trezor and Safe-T mini will work # Add libusb so Trezor and Safe-T mini will work
binaries = [(electrum + "contrib/build-osx/libusb-1.0.dylib", ".")] binaries = [(electrum + "contrib/osx/libusb-1.0.dylib", ".")]
binaries += [(electrum + "contrib/build-osx/libsecp256k1.0.dylib", ".")] binaries += [(electrum + "contrib/osx/libsecp256k1.0.dylib", ".")]
# Workaround for "Retro Look": # Workaround for "Retro Look":
binaries += [b for b in collect_dynamic_libs('PyQt5') if 'macstyle' in b[0]] binaries += [b for b in collect_dynamic_libs('PyQt5') if 'macstyle' in b[0]]

0
contrib/build-osx/package.sh → contrib/osx/package.sh

2
contrib/requirements/requirements.txt

@ -5,7 +5,7 @@ qrcode
protobuf protobuf
dnspython dnspython
jsonrpclib-pelix jsonrpclib-pelix
qdarkstyle<3.0 qdarkstyle<2.6
aiorpcx>=0.9,<0.11 aiorpcx>=0.9,<0.11
aiohttp aiohttp
aiohttp_socks aiohttp_socks

15
electrum/address_synchronizer.py

@ -717,12 +717,15 @@ class AddressSynchronizer(PrintError):
return None return None
if hasattr(tx, '_cached_fee'): if hasattr(tx, '_cached_fee'):
return tx._cached_fee return tx._cached_fee
is_relevant, is_mine, v, fee = self.get_wallet_delta(tx) with self.lock, self.transaction_lock:
if fee is None: is_relevant, is_mine, v, fee = self.get_wallet_delta(tx)
txid = tx.txid() if fee is None:
fee = self.tx_fees.get(txid) txid = tx.txid()
if fee is not None: fee = self.tx_fees.get(txid)
tx._cached_fee = fee # cache fees. if wallet is synced, cache all;
# otherwise only cache non-None, as None can still change while syncing
if self.up_to_date or fee is not None:
tx._cached_fee = fee
return fee return fee
def get_addr_io(self, address): def get_addr_io(self, address):

7
electrum/base_wizard.py

@ -200,7 +200,7 @@ class BaseWizard(object):
self.storage.put('keystore', k.dump()) self.storage.put('keystore', k.dump())
w = Imported_Wallet(self.storage) w = Imported_Wallet(self.storage)
keys = keystore.get_private_keys(text) keys = keystore.get_private_keys(text)
good_inputs, bad_inputs = w.import_private_keys(keys, None) good_inputs, bad_inputs = w.import_private_keys(keys, None, write_to_disk=False)
self.keystores.append(w.keystore) self.keystores.append(w.keystore)
else: else:
return self.terminate() return self.terminate()
@ -283,7 +283,9 @@ class BaseWizard(object):
for name, info in devices: for name, info in devices:
state = _("initialized") if info.initialized else _("wiped") state = _("initialized") if info.initialized else _("wiped")
label = info.label or _("An unnamed {}").format(name) label = info.label or _("An unnamed {}").format(name)
descr = f"{label} [{name}, {state}, {info.device.transport_ui_string}]" try: transport_str = info.device.transport_ui_string[:20]
except: transport_str = 'unknown transport'
descr = f"{label} [{name}, {state}, {transport_str}]"
choices.append(((name, info), descr)) choices.append(((name, info), descr))
msg = _('Select a device') + ':' msg = _('Select a device') + ':'
self.choice_dialog(title=title, message=msg, choices=choices, run_next= lambda *args: self.on_device(*args, purpose=purpose)) self.choice_dialog(title=title, message=msg, choices=choices, run_next= lambda *args: self.on_device(*args, purpose=purpose))
@ -508,6 +510,7 @@ class BaseWizard(object):
def on_password(self, password, *, encrypt_storage, def on_password(self, password, *, encrypt_storage,
storage_enc_version=STO_EV_USER_PW, encrypt_keystore): storage_enc_version=STO_EV_USER_PW, encrypt_keystore):
assert not self.storage.file_exists(), "file was created too soon! plaintext keys might have been written to disk"
self.storage.set_keystore_encryption(bool(password) and encrypt_keystore) self.storage.set_keystore_encryption(bool(password) and encrypt_keystore)
if encrypt_storage: if encrypt_storage:
self.storage.set_password(password, enc_version=storage_enc_version) self.storage.set_password(password, enc_version=storage_enc_version)

298
electrum/blockchain.py

@ -79,26 +79,81 @@ def hash_raw_header(header: str) -> str:
return hash_encode(sha256d(bfh(header))) return hash_encode(sha256d(bfh(header)))
blockchains = {} # type: Dict[int, Blockchain] # key: blockhash hex at forkpoint
blockchains_lock = threading.Lock() # the chain at some key is the best chain that includes the given hash
blockchains = {} # type: Dict[str, Blockchain]
blockchains_lock = threading.RLock()
def read_blockchains(config: 'SimpleConfig') -> Dict[int, 'Blockchain']:
blockchains[0] = Blockchain(config, 0, None)
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):
util.print_error("[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') fdir = os.path.join(util.get_headers_dir(config), 'forks')
util.make_dir(fdir) util.make_dir(fdir)
l = filter(lambda x: x.startswith('fork_'), os.listdir(fdir)) # files are named as: fork2_{forkpoint}_{prev_hash}_{first_hash}
l = sorted(l, key = lambda x: int(x.split('_')[1])) l = filter(lambda x: x.startswith('fork2_') and '.' not in x, os.listdir(fdir))
for filename in l: l = sorted(l, key=lambda x: int(x.split('_')[1])) # sort by forkpoint
forkpoint = int(filename.split('_')[2])
parent_id = int(filename.split('_')[1]) def delete_chain(filename, reason):
b = Blockchain(config, forkpoint, parent_id) util.print_error(f"[blockchain] deleting chain {filename}: {reason}")
h = b.read_header(b.forkpoint) os.unlink(os.path.join(fdir, filename))
if b.parent().can_connect(h, check_height=False):
blockchains[b.forkpoint] = b 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: else:
util.print_error("cannot connect", filename) delete_chain(filename, "cannot find parent for chain")
return blockchains 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]
class Blockchain(util.PrintError): class Blockchain(util.PrintError):
@ -106,15 +161,20 @@ class Blockchain(util.PrintError):
Manages blockchain headers and their verification Manages blockchain headers and their verification
""" """
def __init__(self, config: SimpleConfig, forkpoint: int, parent_id: Optional[int]): 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}")
self.config = config self.config = config
self.forkpoint = forkpoint self.forkpoint = forkpoint # height of first header
self.checkpoints = constants.net.CHECKPOINTS self.parent = parent
self.parent_id = parent_id self._forkpoint_hash = forkpoint_hash # blockhash at forkpoint. "first hash"
assert parent_id != forkpoint self._prev_hash = prev_hash # blockhash immediately before forkpoint
self.lock = threading.RLock() self.lock = threading.RLock()
with self.lock: self.update_size()
self.update_size()
def with_lock(func): def with_lock(func):
def func_wrapper(self, *args, **kwargs): def func_wrapper(self, *args, **kwargs):
@ -122,12 +182,13 @@ class Blockchain(util.PrintError):
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
return func_wrapper return func_wrapper
def parent(self) -> 'Blockchain': @property
return blockchains[self.parent_id] def checkpoints(self):
return constants.net.CHECKPOINTS
def get_max_child(self) -> Optional[int]: def get_max_child(self) -> Optional[int]:
with blockchains_lock: chains = list(blockchains.values()) with blockchains_lock: chains = list(blockchains.values())
children = list(filter(lambda y: y.parent_id==self.forkpoint, chains)) children = list(filter(lambda y: y.parent==self, chains))
return max([x.forkpoint for x in children]) if children else None return max([x.forkpoint for x in children]) if children else None
def get_max_forkpoint(self) -> int: def get_max_forkpoint(self) -> int:
@ -137,11 +198,12 @@ class Blockchain(util.PrintError):
mc = self.get_max_child() mc = self.get_max_child()
return mc if mc is not None else self.forkpoint return mc if mc is not None else self.forkpoint
@with_lock
def get_branch_size(self) -> int: def get_branch_size(self) -> int:
return self.height() - self.get_max_forkpoint() + 1 return self.height() - self.get_max_forkpoint() + 1
def get_name(self) -> str: def get_name(self) -> str:
return self.get_hash(self.get_max_forkpoint()).lstrip('00')[0:10] return self.get_hash(self.get_max_forkpoint()).lstrip('0')[0:10]
def check_header(self, header: dict) -> bool: def check_header(self, header: dict) -> bool:
header_hash = hash_header(header) header_hash = hash_header(header)
@ -159,24 +221,38 @@ class Blockchain(util.PrintError):
return False return False
def fork(parent, header: dict) -> 'Blockchain': 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') forkpoint = header.get('block_height')
self = Blockchain(parent.config, forkpoint, parent.forkpoint) self = Blockchain(config=parent.config,
forkpoint=forkpoint,
parent=parent,
forkpoint_hash=hash_header(header),
prev_hash=parent.get_hash(forkpoint-1))
open(self.path(), 'w+').close() open(self.path(), 'w+').close()
self.save_header(header) 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 return self
@with_lock
def height(self) -> int: def height(self) -> int:
return self.forkpoint + self.size() - 1 return self.forkpoint + self.size() - 1
@with_lock
def size(self) -> int: def size(self) -> int:
with self.lock: return self._size
return self._size
@with_lock
def update_size(self) -> None: def update_size(self) -> None:
p = self.path() p = self.path()
self._size = os.path.getsize(p)//HEADER_SIZE if os.path.exists(p) else 0 self._size = os.path.getsize(p)//HEADER_SIZE if os.path.exists(p) else 0
def verify_header(self, header: dict, prev_hash: str, target: int, expected_header_hash: str=None) -> None: @classmethod
def verify_header(cls, header: dict, prev_hash: str, target: int, expected_header_hash: str=None) -> None:
_hash = hash_header(header) _hash = hash_header(header)
if expected_header_hash and expected_header_hash != _hash: if expected_header_hash and expected_header_hash != _hash:
raise Exception("hash mismatches with expected: {} vs {}".format(expected_header_hash, _hash)) raise Exception("hash mismatches with expected: {} vs {}".format(expected_header_hash, _hash))
@ -184,7 +260,7 @@ class Blockchain(util.PrintError):
raise Exception("prev hash mismatch: %s vs %s" % (prev_hash, header.get('prev_block_hash'))) raise Exception("prev hash mismatch: %s vs %s" % (prev_hash, header.get('prev_block_hash')))
if constants.net.TESTNET: if constants.net.TESTNET:
return return
bits = self.target_to_bits(target) bits = cls.target_to_bits(target)
if bits != header.get('bits'): if bits != header.get('bits'):
raise Exception("bits mismatch: %s vs %s" % (bits, header.get('bits'))) raise Exception("bits mismatch: %s vs %s" % (bits, header.get('bits')))
block_hash_as_num = int.from_bytes(bfh(_hash), byteorder='big') block_hash_as_num = int.from_bytes(bfh(_hash), byteorder='big')
@ -207,21 +283,26 @@ class Blockchain(util.PrintError):
self.verify_header(header, prev_hash, target, expected_header_hash) self.verify_header(header, prev_hash, target, expected_header_hash)
prev_hash = hash_header(header) prev_hash = hash_header(header)
@with_lock
def path(self): def path(self):
d = util.get_headers_dir(self.config) d = util.get_headers_dir(self.config)
if self.parent_id is None: if self.parent is None:
filename = 'blockchain_headers' filename = 'blockchain_headers'
else: else:
basename = 'fork_%d_%d' % (self.parent_id, self.forkpoint) 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) filename = os.path.join('forks', basename)
return os.path.join(d, filename) return os.path.join(d, filename)
@with_lock @with_lock
def save_chunk(self, index: int, chunk: bytes): def save_chunk(self, index: int, chunk: bytes):
assert index >= 0, index
chunk_within_checkpoint_region = index < len(self.checkpoints) chunk_within_checkpoint_region = index < len(self.checkpoints)
# chunks in checkpoint region are the responsibility of the 'main chain' # chunks in checkpoint region are the responsibility of the 'main chain'
if chunk_within_checkpoint_region and self.parent_id is not None: if chunk_within_checkpoint_region and self.parent is not None:
main_chain = blockchains[0] main_chain = get_best_chain()
main_chain.save_chunk(index, chunk) main_chain.save_chunk(index, chunk)
return return
@ -236,18 +317,36 @@ class Blockchain(util.PrintError):
self.write(chunk, delta_bytes, truncate) self.write(chunk, delta_bytes, truncate)
self.swap_with_parent() self.swap_with_parent()
@with_lock
def swap_with_parent(self) -> None: def swap_with_parent(self) -> None:
if self.parent_id is None: parent_lock = self.parent.lock if self.parent is not None else threading.Lock()
return with parent_lock, self.lock, blockchains_lock: # this order should not deadlock
parent_branch_size = self.parent().height() - self.forkpoint + 1 # do the swap; possibly multiple ones
if parent_branch_size >= self.size(): cnt = 0
return while self._swap_with_parent():
self.print_error("swap", self.forkpoint, self.parent_id) cnt += 1
parent_id = self.parent_id if cnt > len(blockchains): # make sure we are making progress
forkpoint = self.forkpoint raise Exception(f'swapping fork with parent too many times: {cnt}')
parent = self.parent()
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.print_error("swap", 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()) self.assert_headers_file_available(self.path())
child_old_name = self.path()
with open(self.path(), 'rb') as f: with open(self.path(), 'rb') as f:
my_data = f.read() my_data = f.read()
self.assert_headers_file_available(parent.path()) self.assert_headers_file_available(parent.path())
@ -256,24 +355,24 @@ class Blockchain(util.PrintError):
parent_data = f.read(parent_branch_size*HEADER_SIZE) parent_data = f.read(parent_branch_size*HEADER_SIZE)
self.write(parent_data, 0) self.write(parent_data, 0)
parent.write(my_data, (forkpoint - parent.forkpoint)*HEADER_SIZE) parent.write(my_data, (forkpoint - parent.forkpoint)*HEADER_SIZE)
# store file path
with blockchains_lock: chains = list(blockchains.values())
for b in chains:
b.old_path = b.path()
# swap parameters # swap parameters
self.parent_id = parent.parent_id; parent.parent_id = parent_id self.parent, parent.parent = parent.parent, self # type: Optional[Blockchain], Optional[Blockchain]
self.forkpoint = parent.forkpoint; parent.forkpoint = forkpoint self.forkpoint, parent.forkpoint = parent.forkpoint, self.forkpoint
self._size = parent._size; parent._size = parent_branch_size self._forkpoint_hash, parent._forkpoint_hash = parent._forkpoint_hash, hash_raw_header(bh2u(parent_data[:HEADER_SIZE]))
# move files self._prev_hash, parent._prev_hash = parent._prev_hash, self._prev_hash
for b in chains: # parent's new name
if b in [self, parent]: continue os.replace(child_old_name, parent.path())
if b.old_path != b.path(): self.update_size()
self.print_error("renaming", b.old_path, b.path()) parent.update_size()
os.rename(b.old_path, b.path())
# update pointers # update pointers
with blockchains_lock: blockchains.pop(child_old_id, None)
blockchains[self.forkpoint] = self blockchains.pop(parent_old_id, None)
blockchains[parent.forkpoint] = parent 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): def assert_headers_file_available(self, path):
if os.path.exists(path): if os.path.exists(path):
@ -283,36 +382,36 @@ class Blockchain(util.PrintError):
else: else:
raise FileNotFoundError('Cannot find headers file but headers_dir is there. Should be at {}'.format(path)) 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) -> None: def write(self, data: bytes, offset: int, truncate: bool=True) -> None:
filename = self.path() filename = self.path()
with self.lock: self.assert_headers_file_available(filename)
self.assert_headers_file_available(filename) with open(filename, 'rb+') as f:
with open(filename, 'rb+') as f: if truncate and offset != self._size * HEADER_SIZE:
if truncate and offset != self._size * HEADER_SIZE:
f.seek(offset)
f.truncate()
f.seek(offset) f.seek(offset)
f.write(data) f.truncate()
f.flush() f.seek(offset)
os.fsync(f.fileno()) f.write(data)
self.update_size() f.flush()
os.fsync(f.fileno())
self.update_size()
@with_lock @with_lock
def save_header(self, header: dict) -> None: def save_header(self, header: dict) -> None:
delta = header.get('block_height') - self.forkpoint delta = header.get('block_height') - self.forkpoint
data = bfh(serialize_header(header)) data = bfh(serialize_header(header))
# headers are only _appended_ to the end: # headers are only _appended_ to the end:
assert delta == self.size() assert delta == self.size(), (delta, self.size())
assert len(data) == HEADER_SIZE assert len(data) == HEADER_SIZE
self.write(data, delta*HEADER_SIZE) self.write(data, delta*HEADER_SIZE)
self.swap_with_parent() self.swap_with_parent()
@with_lock
def read_header(self, height: int) -> Optional[dict]: def read_header(self, height: int) -> Optional[dict]:
assert self.parent_id != self.forkpoint
if height < 0: if height < 0:
return return
if height < self.forkpoint: if height < self.forkpoint:
return self.parent().read_header(height) return self.parent.read_header(height)
if height > self.height(): if height > self.height():
return return
delta = height - self.forkpoint delta = height - self.forkpoint
@ -372,16 +471,18 @@ class Blockchain(util.PrintError):
new_target = self.bits_to_target(self.target_to_bits(new_target)) new_target = self.bits_to_target(self.target_to_bits(new_target))
return new_target return new_target
def bits_to_target(self, bits: int) -> int: @classmethod
def bits_to_target(cls, bits: int) -> int:
bitsN = (bits >> 24) & 0xff bitsN = (bits >> 24) & 0xff
if not (bitsN >= 0x03 and bitsN <= 0x1d): if not (0x03 <= bitsN <= 0x1d):
raise Exception("First part of bits should be in [0x03, 0x1d]") raise Exception("First part of bits should be in [0x03, 0x1d]")
bitsBase = bits & 0xffffff bitsBase = bits & 0xffffff
if not (bitsBase >= 0x8000 and bitsBase <= 0x7fffff): if not (0x8000 <= bitsBase <= 0x7fffff):
raise Exception("Second part of bits should be in [0x8000, 0x7fffff]") raise Exception("Second part of bits should be in [0x8000, 0x7fffff]")
return bitsBase << (8 * (bitsN-3)) return bitsBase << (8 * (bitsN-3))
def target_to_bits(self, target: int) -> int: @classmethod
def target_to_bits(cls, target: int) -> int:
c = ("%064x" % target)[2:] c = ("%064x" % target)[2:]
while c[:2] == '00' and len(c) > 6: while c[:2] == '00' and len(c) > 6:
c = c[2:] c = c[2:]
@ -391,6 +492,40 @@ class Blockchain(util.PrintError):
bitsBase >>= 8 bitsBase >>= 8
return bitsN << 24 | bitsBase 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 // 2016 - 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 // 2016 * 2016 - 1
cached_height = last_retarget
while _CHAINWORK_CACHE.get(self.get_hash(cached_height)) is None:
if cached_height <= -1:
break
cached_height -= 2016
assert cached_height >= -1, cached_height
running_total = _CHAINWORK_CACHE[self.get_hash(cached_height)]
while cached_height < last_retarget:
cached_height += 2016
work_in_single_header = self.chainwork_of_header_at_height(cached_height)
work_in_chunk = 2016 * work_in_single_header
running_total += work_in_chunk
_CHAINWORK_CACHE[self.get_hash(cached_height)] = running_total
cached_height += 2016
work_in_single_header = self.chainwork_of_header_at_height(cached_height)
work_in_last_partial_chunk = (height % 2016 + 1) * work_in_single_header
return running_total + work_in_last_partial_chunk
def can_connect(self, header: dict, check_height: bool=True) -> bool: def can_connect(self, header: dict, check_height: bool=True) -> bool:
if header is None: if header is None:
return False return False
@ -417,6 +552,7 @@ class Blockchain(util.PrintError):
return True return True
def connect_chunk(self, idx: int, hexdata: str) -> bool: def connect_chunk(self, idx: int, hexdata: str) -> bool:
assert idx >= 0, idx
try: try:
data = bfh(hexdata) data = bfh(hexdata)
self.verify_chunk(idx, data) self.verify_chunk(idx, data)
@ -424,7 +560,7 @@ class Blockchain(util.PrintError):
self.save_chunk(idx, data) self.save_chunk(idx, data)
return True return True
except BaseException as e: except BaseException as e:
self.print_error('verify_chunk %d failed'%idx, str(e)) self.print_error(f'verify_chunk idx {idx} failed: {repr(e)}')
return False return False
def get_checkpoints(self): def get_checkpoints(self):

3
electrum/commands.py

@ -176,7 +176,7 @@ class Commands:
storage.put('keystore', k.dump()) storage.put('keystore', k.dump())
wallet = Imported_Wallet(storage) wallet = Imported_Wallet(storage)
keys = keystore.get_private_keys(text) keys = keystore.get_private_keys(text)
good_inputs, bad_inputs = wallet.import_private_keys(keys, password) good_inputs, bad_inputs = wallet.import_private_keys(keys, None, write_to_disk=False)
# FIXME tell user about bad_inputs # FIXME tell user about bad_inputs
if not good_inputs: if not good_inputs:
raise Exception("None of the given privkeys can be imported") raise Exception("None of the given privkeys can be imported")
@ -191,6 +191,7 @@ class Commands:
storage.put('wallet_type', 'standard') storage.put('wallet_type', 'standard')
wallet = Wallet(storage) wallet = Wallet(storage)
assert not storage.file_exists(), "file was created too soon! plaintext keys might have been written to disk"
wallet.update_password(old_pw=None, new_pw=password, encrypt_storage=encrypt_file) wallet.update_password(old_pw=None, new_pw=password, encrypt_storage=encrypt_file)
wallet.synchronize() wallet.synchronize()

3
electrum/contacts.py

@ -65,8 +65,9 @@ class Contacts(dict):
def pop(self, key): def pop(self, key):
if key in self.keys(): if key in self.keys():
dict.pop(self, key) res = dict.pop(self, key)
self.save() self.save()
return res
def resolve(self, k): def resolve(self, k):
if bitcoin.is_address(k): if bitcoin.is_address(k):

95
electrum/crypto.py

@ -32,6 +32,7 @@ from typing import Union
import pyaes import pyaes
from .util import assert_bytes, InvalidPassword, to_bytes, to_string from .util import assert_bytes, InvalidPassword, to_bytes, to_string
from .i18n import _
try: try:
@ -90,37 +91,103 @@ def aes_decrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes:
raise InvalidPassword() raise InvalidPassword()
def EncodeAES(secret: bytes, msg: bytes) -> bytes: def EncodeAES_base64(secret: bytes, msg: bytes) -> bytes:
"""Returns base64 encoded ciphertext.""" """Returns base64 encoded ciphertext."""
e = EncodeAES_bytes(secret, msg)
return base64.b64encode(e)
def EncodeAES_bytes(secret: bytes, msg: bytes) -> bytes:
assert_bytes(msg) assert_bytes(msg)
iv = bytes(os.urandom(16)) iv = bytes(os.urandom(16))
ct = aes_encrypt_with_iv(secret, iv, msg) ct = aes_encrypt_with_iv(secret, iv, msg)
e = iv + ct return iv + ct
return base64.b64encode(e)
def DecodeAES_base64(secret: bytes, ciphertext_b64: Union[bytes, str]) -> bytes:
ciphertext = bytes(base64.b64decode(ciphertext_b64))
return DecodeAES_bytes(secret, ciphertext)
def DecodeAES(secret: bytes, ciphertext_b64: Union[bytes, str]) -> bytes:
e = bytes(base64.b64decode(ciphertext_b64)) def DecodeAES_bytes(secret: bytes, ciphertext: bytes) -> bytes:
iv, e = e[:16], e[16:] assert_bytes(ciphertext)
iv, e = ciphertext[:16], ciphertext[16:]
s = aes_decrypt_with_iv(secret, iv, e) s = aes_decrypt_with_iv(secret, iv, e)
return s return s
def pw_encode(data: str, password: Union[bytes, str]) -> str: PW_HASH_VERSION_LATEST = 2
KNOWN_PW_HASH_VERSIONS = (1, 2)
assert PW_HASH_VERSION_LATEST in KNOWN_PW_HASH_VERSIONS
class UnexpectedPasswordHashVersion(InvalidPassword):
def __init__(self, version):
self.version = version
def __str__(self):
return "{unexpected}: {version}\n{please_update}".format(
unexpected=_("Unexpected password hash version"),
version=self.version,
please_update=_('You are most likely using an outdated version of Electrum. Please update.'))
def _hash_password(password: Union[bytes, str], *, version: int, salt: bytes) -> bytes:
pw = to_bytes(password, 'utf8')
if version == 1:
return sha256d(pw)
elif version == 2:
if not isinstance(salt, bytes) or len(salt) < 16:
raise Exception('too weak salt', salt)
return hashlib.pbkdf2_hmac(hash_name='sha256',
password=pw,
salt=b'ELECTRUM_PW_HASH_V2'+salt,
iterations=50_000)
else:
assert version not in KNOWN_PW_HASH_VERSIONS
raise UnexpectedPasswordHashVersion(version)
def pw_encode(data: str, password: Union[bytes, str, None], *, version: int) -> str:
if not password: if not password:
return data return data
secret = sha256d(password) if version not in KNOWN_PW_HASH_VERSIONS:
return EncodeAES(secret, to_bytes(data, "utf8")).decode('utf8') raise UnexpectedPasswordHashVersion(version)
# derive key from password
if version == 1:
salt = b''
elif version == 2:
salt = bytes(os.urandom(16))
else:
assert False, version
secret = _hash_password(password, version=version, salt=salt)
# encrypt given data
e = EncodeAES_bytes(secret, to_bytes(data, "utf8"))
# return base64(salt + encrypted data)
ciphertext = salt + e
ciphertext_b64 = base64.b64encode(ciphertext)
return ciphertext_b64.decode('utf8')
def pw_decode(data: str, password: Union[bytes, str]) -> str: def pw_decode(data: str, password: Union[bytes, str, None], *, version: int) -> str:
if password is None: if password is None:
return data return data
secret = sha256d(password) if version not in KNOWN_PW_HASH_VERSIONS:
raise UnexpectedPasswordHashVersion(version)
data_bytes = bytes(base64.b64decode(data))
# derive key from password
if version == 1:
salt = b''
elif version == 2:
salt, data_bytes = data_bytes[:16], data_bytes[16:]
else:
assert False, version
secret = _hash_password(password, version=version, salt=salt)
# decrypt given data
try: try:
d = to_string(DecodeAES(secret, data), "utf8") d = to_string(DecodeAES_bytes(secret, data_bytes), "utf8")
except Exception: except Exception as e:
raise InvalidPassword() raise InvalidPassword() from e
return d return d

6
electrum/exchange_rate.py

@ -464,9 +464,13 @@ class FxThread(ThreadJob):
d = get_exchanges_by_ccy(history) d = get_exchanges_by_ccy(history)
return d.get(ccy, []) return d.get(ccy, [])
@staticmethod
def remove_thousands_separator(text):
return text.replace(',', '') # FIXME use THOUSAND_SEPARATOR in util
def ccy_amount_str(self, amount, commas): def ccy_amount_str(self, amount, commas):
prec = CCY_PRECISIONS.get(self.ccy, 2) prec = CCY_PRECISIONS.get(self.ccy, 2)
fmt_str = "{:%s.%df}" % ("," if commas else "", max(0, prec)) fmt_str = "{:%s.%df}" % ("," if commas else "", max(0, prec)) # FIXME use util.THOUSAND_SEPARATOR and util.DECIMAL_POINT
try: try:
rounded_amount = round(amount, prec) rounded_amount = round(amount, prec)
except decimal.InvalidOperation: except decimal.InvalidOperation:

2
electrum/gui/kivy/Makefile

@ -11,7 +11,7 @@ prepare:
@cp tools/buildozer.spec ../../../buildozer.spec @cp tools/buildozer.spec ../../../buildozer.spec
# copy electrum to main.py # copy electrum to main.py
@cp ../../../run_electrum ../../../main.py @cp ../../../run_electrum ../../../main.py
@-if [ ! -d "../../.buildozer" ];then \ @-if [ ! -d "../../../.buildozer" ];then \
cd ../../..; buildozer android debug;\ cd ../../..; buildozer android debug;\
cp -f electrum/gui/kivy/tools/blacklist.txt .buildozer/android/platform/python-for-android/src/blacklist.txt;\ cp -f electrum/gui/kivy/tools/blacklist.txt .buildozer/android/platform/python-for-android/src/blacklist.txt;\
rm -rf ./.buildozer/android/platform/python-for-android/dist;\ rm -rf ./.buildozer/android/platform/python-for-android/dist;\

167
electrum/gui/kivy/Readme.md

@ -3,147 +3,62 @@
The Kivy GUI is used with Electrum on Android devices. The Kivy GUI is used with Electrum on Android devices.
To generate an APK file, follow these instructions. To generate an APK file, follow these instructions.
Recommended env: Ubuntu 18.04 ## Android binary with Docker
## 1. Preliminaries This assumes an Ubuntu host, but it should not be too hard to adapt to another
similar system. The docker commands should be executed in the project's root
folder.
Make sure the current user can write `/opt` (e.g. `sudo chown username: /opt`). 1. Install Docker
We assume that you already got Electrum to run from source on this machine, ```
hence have e.g. `git`, `python3-pip` and `python3-setuptools`. $ 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
```
## 2. Install kivy 2. Build image
Install kivy for python3 as described [here](https://kivy.org/docs/installation/installation-linux.html). ```
So for example: $ sudo docker build -t electrum-android-builder-img electrum/gui/kivy/tools
```sh ```
sudo add-apt-repository ppa:kivy-team/kivy
sudo apt-get install python3-kivy
```
## 3. Install python-for-android (p4a)
p4a is used to package Electrum, Python, SDL and a bootstrap Java app into an APK file.
We need some functionality not in p4a master, so for the time being we have our own fork.
Something like this should work:
```sh
cd /opt
git clone https://github.com/kivy/python-for-android
cd python-for-android
git remote add sombernight https://github.com/SomberNight/python-for-android
git fetch --all
git checkout f74226666af69f9915afaee9ef9292db85a6c617
```
## 4. Install buildozer 3. Prepare pure python dependencies
4.1 Buildozer is a frontend to p4a. Luckily we don't need to patch it:
```sh ```
cd /opt $ sudo ./contrib/make_packages
git clone https://github.com/kivy/buildozer ```
cd buildozer
sudo python3 setup.py install
```
4.2 Install additional dependencies: 4. Build binaries
```sh ```
sudo apt-get install python-pip $ sudo docker run -it --rm \
``` --name electrum-android-builder-cont \
-v $PWD:/home/user/wspace/electrum \
(from [buildozer docs](https://buildozer.readthedocs.io/en/latest/installation.html#targeting-android)) -v ~/.keystore:/home/user/.keystore \
```sh --workdir /home/user/wspace/electrum \
sudo pip install --upgrade cython==0.21 electrum-android-builder-img \
sudo dpkg --add-architecture i386 ./contrib/make_apk
sudo apt-get update ```
sudo apt-get install build-essential ccache git libncurses5:i386 libstdc++6:i386 libgtk2.0-0:i386 libpangox-1.0-0:i386 libpangoxft-1.0-0:i386 libidn11:i386 python2.7 python2.7-dev openjdk-8-jdk unzip zlib1g-dev zlib1g:i386 This mounts the project dir inside the container,
``` and so the modifications will affect it, e.g. `.buildozer` folder
will be created.
4.3 Download Android NDK
```sh
cd /opt
wget https://dl.google.com/android/repository/android-ndk-r14b-linux-x86_64.zip
unzip android-ndk-r14b-linux-x86_64.zip
```
## 5. Some more dependencies
```sh
python3 -m pip install colorama appdirs sh jinja2 cython==0.29
sudo apt-get install autotools-dev autoconf libtool pkg-config python3.7
```
## 6. Create the UI Atlas
In the `electrum/gui/kivy` directory of Electrum, run `make theming`.
## 7. Download Electrum dependencies
```sh
sudo contrib/make_packages
```
## 8. Try building the APK and fail 5. The generated binary is in `./bin`.
### 1. Try and fail:
```sh
contrib/make_apk
```
Symlink android tools: ## FAQ
```sh ### I changed something but I don't see any differences on the phone. What did I do wrong?
ln -sf ~/.buildozer/android/platform/android-sdk-24/tools ~/.buildozer/android/platform/android-sdk-24/tools.save You probably need to clear the cache: `rm -rf .buildozer/android/platform/build/{build,dists}`
```
### 2. Try and fail:
```sh ### How do I get an interactive shell inside docker?
contrib/make_apk
``` ```
$ sudo docker run -it --rm \
During this build attempt, buildozer downloaded some tools, -v $PWD:/home/user/wspace/electrum \
e.g. those needed in the next step. --workdir /home/user/wspace/electrum \
electrum-android-builder-img
## 9. Update the Android SDK build tools
### Method 1: Using the GUI
Start the Android SDK manager in GUI mode:
~/.buildozer/android/platform/android-sdk-24/tools/android
Check the latest SDK available and install it
("Android SDK Tools" and "Android SDK Platform-tools").
Close the SDK manager. Repeat until there is no newer version.
Reopen the SDK manager, and install the latest build tools
("Android SDK Build-tools"), 28.0.3 at the time of writing.
Install "Android 9">"SDK Platform".
Install "Android Support Repository" from the SDK manager (under "Extras").
### Method 2: Using the command line:
Repeat the following command until there is nothing to install:
~/.buildozer/android/platform/android-sdk-24/tools/android update sdk -u -t tools,platform-tools
Install Build Tools, android API 19 and Android Support Library:
~/.buildozer/android/platform/android-sdk-24/tools/android update sdk -u -t build-tools-28.0.3,android-28,extra-android-m2repository
(FIXME: build-tools is not getting installed?! use GUI for now.)
## 10. Build the APK
```sh
contrib/make_apk
``` ```
# 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}`

9
electrum/gui/kivy/main_window.py

@ -126,10 +126,12 @@ class ElectrumWindow(App):
chains = self.network.get_blockchains() chains = self.network.get_blockchains()
def cb(name): def cb(name):
with blockchain.blockchains_lock: blockchain_items = list(blockchain.blockchains.items()) with blockchain.blockchains_lock: blockchain_items = list(blockchain.blockchains.items())
for index, b in blockchain_items: for chain_id, b in blockchain_items:
if name == b.get_name(): if name == b.get_name():
self.network.run_from_another_thread(self.network.follow_chain_given_id(index)) self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id))
names = [blockchain.blockchains[b].get_name() for b in chains] chain_objects = [blockchain.blockchains.get(chain_id) for chain_id in chains]
chain_objects = filter(lambda b: b is not None, chain_objects)
names = [b.get_name() for b in chain_objects]
if len(names) > 1: if len(names) > 1:
cur_chain = self.network.blockchain().get_name() cur_chain = self.network.blockchain().get_name()
ChoiceDialog(_('Choose your chain'), names, cur_chain, cb).open() ChoiceDialog(_('Choose your chain'), names, cur_chain, cb).open()
@ -171,6 +173,7 @@ class ElectrumWindow(App):
def on_history(self, d): def on_history(self, d):
Logger.info("on_history") Logger.info("on_history")
self.wallet.clear_coin_price_cache()
self._trigger_update_history() self._trigger_update_history()
def on_fee_histogram(self, *args): def on_fee_histogram(self, *args):

142
electrum/gui/kivy/tools/Dockerfile

@ -0,0 +1,142 @@
# based on https://github.com/kivy/python-for-android/blob/master/Dockerfile
FROM ubuntu:18.04
ENV ANDROID_HOME="/opt/android"
RUN apt -y update -qq \
&& apt -y install -qq --no-install-recommends curl unzip git python3-pip python3-setuptools \
&& apt -y autoremove \
&& apt -y clean
ENV ANDROID_NDK_HOME="${ANDROID_HOME}/android-ndk"
ENV ANDROID_NDK_VERSION="14b"
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-x86_64.zip"
ENV ANDROID_NDK_DL_URL="https://dl.google.com/android/repository/${ANDROID_NDK_ARCHIVE}"
# download and install Android NDK
RUN curl --location --progress-bar \
"${ANDROID_NDK_DL_URL}" \
--output "${ANDROID_NDK_ARCHIVE}" \
&& 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}"
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="4333796"
ENV ANDROID_SDK_TOOLS_ARCHIVE="sdk-tools-linux-${ANDROID_SDK_TOOLS_VERSION}.zip"
ENV ANDROID_SDK_TOOLS_DL_URL="https://dl.google.com/android/repository/${ANDROID_SDK_TOOLS_ARCHIVE}"
# download and install Android SDK
RUN curl --location --progress-bar \
"${ANDROID_SDK_TOOLS_DL_URL}" \
--output "${ANDROID_SDK_TOOLS_ARCHIVE}" \
&& 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"
# accept Android licenses (JDK necessary!)
RUN apt -y update -qq \
&& apt -y install -qq --no-install-recommends openjdk-8-jdk \
&& apt -y autoremove \
&& apt -y clean
RUN yes | "${ANDROID_SDK_HOME}/tools/bin/sdkmanager" --licenses > /dev/null
# download platforms, API, build tools
RUN "${ANDROID_SDK_HOME}/tools/bin/sdkmanager" "platforms;android-24" && \
"${ANDROID_SDK_HOME}/tools/bin/sdkmanager" "platforms;android-28" && \
"${ANDROID_SDK_HOME}/tools/bin/sdkmanager" "build-tools;28.0.3" && \
"${ANDROID_SDK_HOME}/tools/bin/sdkmanager" "extras;android;m2repository" && \
chmod +x "${ANDROID_SDK_HOME}/tools/bin/avdmanager"
ENV USER="user"
ENV HOME_DIR="/home/${USER}"
ENV WORK_DIR="${HOME_DIR}/wspace" \
PATH="${HOME_DIR}/.local/bin:${PATH}"
# install system dependencies
RUN apt -y update -qq \
&& apt -y install -qq --no-install-recommends \
python virtualenv python-pip wget lbzip2 patch sudo \
software-properties-common
# install kivy
RUN add-apt-repository ppa:kivy-team/kivy \
&& apt -y update -qq \
&& apt -y install -qq --no-install-recommends python3-kivy \
&& apt -y autoremove \
&& apt -y clean
RUN python3 -m pip install image
# build dependencies
# https://buildozer.readthedocs.io/en/latest/installation.html#android-on-ubuntu-16-04-64bit
RUN dpkg --add-architecture i386 \
&& apt -y update -qq \
&& apt -y install -qq --no-install-recommends \
build-essential ccache git python2.7 python2.7-dev \
libncurses5:i386 libstdc++6:i386 libgtk2.0-0:i386 \
libpangox-1.0-0:i386 libpangoxft-1.0-0:i386 libidn11:i386 \
zip zlib1g-dev zlib1g:i386 \
&& apt -y autoremove \
&& apt -y clean
# specific recipes dependencies (e.g. libffi requires autoreconf binary)
RUN apt -y update -qq \
&& apt -y install -qq --no-install-recommends \
autoconf automake cmake gettext libltdl-dev libtool pkg-config \
python3.7 \
&& apt -y autoremove \
&& apt -y clean
# prepare non root env
RUN useradd --create-home --shell /bin/bash ${USER}
# with sudo access and no password
RUN usermod -append --groups sudo ${USER}
RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
WORKDIR ${WORK_DIR}
# user needs ownership/write access to these directories
RUN chown --recursive ${USER} ${WORK_DIR} ${ANDROID_SDK_HOME}
RUN chown ${USER} /opt
USER ${USER}
RUN pip install --upgrade cython==0.29
RUN python3 -m pip install --upgrade cython==0.29
# install buildozer
RUN cd /opt \
&& git clone https://github.com/kivy/buildozer \
&& cd buildozer \
&& python3 -m pip install -e .
# install python-for-android
RUN cd /opt \
&& git clone https://github.com/kivy/python-for-android \
&& cd python-for-android \
&& git remote add sombernight https://github.com/SomberNight/python-for-android \
&& git fetch --all \
&& git checkout f74226666af69f9915afaee9ef9292db85a6c617 \
&& python3 -m pip install -e .
# build env vars
ENV USE_SDK_WRAPPER=1
ENV GRADLE_OPTS="-Xmx1536M -Dorg.gradle.jvmargs='-Xmx1536M'"

4
electrum/gui/kivy/tools/buildozer.spec

@ -70,10 +70,10 @@ android.ndk = 14b
android.private_storage = True android.private_storage = True
# (str) Android NDK directory (if empty, it will be automatically downloaded.) # (str) Android NDK directory (if empty, it will be automatically downloaded.)
android.ndk_path = /opt/android-ndk-r14b android.ndk_path = /opt/android/android-ndk
# (str) Android SDK directory (if empty, it will be automatically downloaded.) # (str) Android SDK directory (if empty, it will be automatically downloaded.)
#android.sdk_path = android.sdk_path = /opt/android/android-sdk
# (str) Android entry point, default is ok for Kivy-based app # (str) Android entry point, default is ok for Kivy-based app
#android.entrypoint = org.renpy.android.PythonActivity #android.entrypoint = org.renpy.android.PythonActivity

2
electrum/gui/qt/__init__.py

@ -87,7 +87,7 @@ class ElectrumGui(PrintError):
@profiler @profiler
def __init__(self, config, daemon, plugins): def __init__(self, config, daemon, plugins):
set_language(config.get('language')) set_language(config.get('language', get_default_language()))
# Uncomment this call to verify objects are being properly # Uncomment this call to verify objects are being properly
# GC-ed when windows are closed # GC-ed when windows are closed
#network.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer, #network.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer,

82
electrum/gui/qt/address_list.py

@ -31,13 +31,11 @@ from electrum.bitcoin import is_address
from .util import * from .util import *
class AddressList(MyTreeView):
class AddressList(MyTreeWidget):
filter_columns = [0, 1, 2, 3] # Type, Address, Label, Balance filter_columns = [0, 1, 2, 3] # Type, Address, Label, Balance
def __init__(self, parent=None): def __init__(self, parent=None):
MyTreeWidget.__init__(self, parent, self.create_menu, [], 2) super().__init__(parent, self.create_menu, 2)
self.refresh_headers()
self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.show_change = 0 self.show_change = 0
@ -50,6 +48,8 @@ class AddressList(MyTreeWidget):
self.used_button.currentIndexChanged.connect(self.toggle_used) self.used_button.currentIndexChanged.connect(self.toggle_used)
for t in [_('All'), _('Unused'), _('Funded'), _('Used')]: for t in [_('All'), _('Unused'), _('Funded'), _('Used')]:
self.used_button.addItem(t) self.used_button.addItem(t)
self.setModel(QStandardItemModel(self))
self.update()
def get_toolbar_buttons(self): def get_toolbar_buttons(self):
return QLabel(_("Filter:")), self.change_button, self.used_button return QLabel(_("Filter:")), self.change_button, self.used_button
@ -82,18 +82,19 @@ class AddressList(MyTreeWidget):
self.show_used = state self.show_used = state
self.update() self.update()
def on_update(self): def update(self):
self.wallet = self.parent.wallet self.wallet = self.parent.wallet
item = self.currentItem() current_address = self.current_item_user_role(col=2)
current_address = item.data(0, Qt.UserRole) if item else None
if self.show_change == 1: if self.show_change == 1:
addr_list = self.wallet.get_receiving_addresses() addr_list = self.wallet.get_receiving_addresses()
elif self.show_change == 2: elif self.show_change == 2:
addr_list = self.wallet.get_change_addresses() addr_list = self.wallet.get_change_addresses()
else: else:
addr_list = self.wallet.get_addresses() addr_list = self.wallet.get_addresses()
self.clear() self.model().clear()
self.refresh_headers()
fx = self.parent.fx fx = self.parent.fx
set_address = None
for address in addr_list: for address in addr_list:
num = self.wallet.get_address_history_len(address) num = self.wallet.get_address_history_len(address)
label = self.wallet.labels.get(address, '') label = self.wallet.labels.get(address, '')
@ -111,61 +112,66 @@ class AddressList(MyTreeWidget):
if fx and fx.get_fiat_address_config(): if fx and fx.get_fiat_address_config():
rate = fx.exchange_rate() rate = fx.exchange_rate()
fiat_balance = fx.value_str(balance, rate) fiat_balance = fx.value_str(balance, rate)
address_item = SortableTreeWidgetItem(['', address, label, balance_text, fiat_balance, "%d"%num]) labels = ['', address, label, balance_text, fiat_balance, "%d"%num]
address_item = [QStandardItem(e) for e in labels]
else: else:
address_item = SortableTreeWidgetItem(['', address, label, balance_text, "%d"%num]) labels = ['', address, label, balance_text, "%d"%num]
address_item = [QStandardItem(e) for e in labels]
# align text and set fonts # align text and set fonts
for i in range(address_item.columnCount()): for i, item in enumerate(address_item):
address_item.setTextAlignment(i, Qt.AlignVCenter) item.setTextAlignment(Qt.AlignVCenter)
if i not in (0, 2): if i not in (0, 2):
address_item.setFont(i, QFont(MONOSPACE_FONT)) item.setFont(QFont(MONOSPACE_FONT))
item.setEditable(i in self.editable_columns)
if fx and fx.get_fiat_address_config(): if fx and fx.get_fiat_address_config():
address_item.setTextAlignment(4, Qt.AlignRight | Qt.AlignVCenter) address_item[4].setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
# setup column 0 # setup column 0
if self.wallet.is_change(address): if self.wallet.is_change(address):
address_item.setText(0, _('change')) address_item[0].setText(_('change'))
address_item.setBackground(0, ColorScheme.YELLOW.as_color(True)) address_item[0].setBackground(ColorScheme.YELLOW.as_color(True))
else: else:
address_item.setText(0, _('receiving')) address_item[0].setText(_('receiving'))
address_item.setBackground(0, ColorScheme.GREEN.as_color(True)) address_item[0].setBackground(ColorScheme.GREEN.as_color(True))
address_item.setData(0, Qt.UserRole, address) # column 0; independent from address column address_item[2].setData(address, Qt.UserRole)
# setup column 1 # setup column 1
if self.wallet.is_frozen(address): if self.wallet.is_frozen(address):
address_item.setBackground(1, ColorScheme.BLUE.as_color(True)) address_item[1].setBackground(ColorScheme.BLUE.as_color(True))
if self.wallet.is_beyond_limit(address): if self.wallet.is_beyond_limit(address):
address_item.setBackground(1, ColorScheme.RED.as_color(True)) address_item[1].setBackground(ColorScheme.RED.as_color(True))
# add item # add item
self.addChild(address_item) count = self.model().rowCount()
self.model().insertRow(count, address_item)
address_idx = self.model().index(count, 2)
if address == current_address: if address == current_address:
self.setCurrentItem(address_item) set_address = QPersistentModelIndex(address_idx)
self.set_current_idx(set_address)
def create_menu(self, position): def create_menu(self, position):
from electrum.wallet import Multisig_Wallet from electrum.wallet import Multisig_Wallet
is_multisig = isinstance(self.wallet, Multisig_Wallet) is_multisig = isinstance(self.wallet, Multisig_Wallet)
can_delete = self.wallet.can_delete_address() can_delete = self.wallet.can_delete_address()
selected = self.selectedItems() selected = self.selected_in_column(1)
multi_select = len(selected) > 1 multi_select = len(selected) > 1
addrs = [item.text(1) for item in selected] addrs = [self.model().itemFromIndex(item).text() for item in selected]
if not addrs:
return
if not multi_select: if not multi_select:
item = self.itemAt(position) idx = self.indexAt(position)
col = self.currentColumn() col = idx.column()
item = self.model().itemFromIndex(idx)
if not item: if not item:
return return
addr = addrs[0] addr = addrs[0]
if not is_address(addr):
item.setExpanded(not item.isExpanded())
return
menu = QMenu() menu = QMenu()
if not multi_select: if not multi_select:
column_title = self.headerItem().text(col) addr_column_title = self.model().horizontalHeaderItem(2).text()
copy_text = item.text(col) addr_idx = idx.sibling(idx.row(), 2)
column_title = self.model().horizontalHeaderItem(col).text()
copy_text = self.model().itemFromIndex(idx).text()
menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(copy_text)) menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(copy_text))
menu.addAction(_('Details'), lambda: self.parent.show_address(addr)) menu.addAction(_('Details'), lambda: self.parent.show_address(addr))
if col in self.editable_columns: persistent = QPersistentModelIndex(addr_idx)
menu.addAction(_("Edit {}").format(column_title), lambda: self.editItem(item, col)) menu.addAction(_("Edit {}").format(addr_column_title), lambda p=persistent: self.edit(QModelIndex(p)))
menu.addAction(_("Request payment"), lambda: self.parent.receive_at(addr)) menu.addAction(_("Request payment"), lambda: self.parent.receive_at(addr))
if self.wallet.can_export(): if self.wallet.can_export():
menu.addAction(_("Private key"), lambda: self.parent.show_private_key(addr)) menu.addAction(_("Private key"), lambda: self.parent.show_private_key(addr))
@ -189,7 +195,3 @@ class AddressList(MyTreeWidget):
run_hook('receive_menu', menu, addrs, self.wallet) run_hook('receive_menu', menu, addrs, self.wallet)
menu.exec_(self.viewport().mapToGlobal(position)) menu.exec_(self.viewport().mapToGlobal(position))
def on_permit_edit(self, item, column):
# labels for headings, e.g. "receiving" or "used" should not be editable
return item.childCount() == 0

80
electrum/gui/qt/contact_list.py

@ -34,67 +34,81 @@ from electrum.bitcoin import is_address
from electrum.util import block_explorer_URL from electrum.util import block_explorer_URL
from electrum.plugin import run_hook from electrum.plugin import run_hook
from .util import MyTreeWidget, import_meta_gui, export_meta_gui from .util import MyTreeView, import_meta_gui, export_meta_gui
class ContactList(MyTreeWidget): class ContactList(MyTreeView):
filter_columns = [0, 1] # Key, Value filter_columns = [0, 1] # Key, Value
def __init__(self, parent): def __init__(self, parent):
MyTreeWidget.__init__(self, parent, self.create_menu, [_('Name'), _('Address')], 0, [0]) super().__init__(parent, self.create_menu, stretch_column=0, editable_columns=[0])
self.setModel(QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.update()
def on_permit_edit(self, item, column): def on_edited(self, idx, user_role, text):
# openalias items shouldn't be editable _type, prior_name = self.parent.contacts.pop(user_role)
return item.text(1) != "openalias"
def on_edited(self, item, column, prior): # TODO when min Qt >= 5.11, use siblingAtColumn
if column == 0: # Remove old contact if renamed col_1_sibling = idx.sibling(idx.row(), 1)
self.parent.contacts.pop(prior) col_1_item = self.model().itemFromIndex(col_1_sibling)
self.parent.set_contact(item.text(0), item.text(1))
self.parent.set_contact(text, col_1_item.text())
def import_contacts(self): def import_contacts(self):
import_meta_gui(self.parent, _('contacts'), self.parent.contacts.import_file, self.on_update) import_meta_gui(self.parent, _('contacts'), self.parent.contacts.import_file, self.update)
def export_contacts(self): def export_contacts(self):
export_meta_gui(self.parent, _('contacts'), self.parent.contacts.export_file) export_meta_gui(self.parent, _('contacts'), self.parent.contacts.export_file)
def create_menu(self, position): def create_menu(self, position):
menu = QMenu() menu = QMenu()
selected = self.selectedItems() selected = self.selected_in_column(0)
if not selected: selected_keys = []
for idx in selected:
sel_key = self.model().itemFromIndex(idx).data(Qt.UserRole)
selected_keys.append(sel_key)
idx = self.indexAt(position)
if not selected or not idx.isValid():
menu.addAction(_("New contact"), lambda: self.parent.new_contact_dialog()) menu.addAction(_("New contact"), lambda: self.parent.new_contact_dialog())
menu.addAction(_("Import file"), lambda: self.import_contacts()) menu.addAction(_("Import file"), lambda: self.import_contacts())
menu.addAction(_("Export file"), lambda: self.export_contacts()) menu.addAction(_("Export file"), lambda: self.export_contacts())
else: else:
names = [item.text(0) for item in selected] column = idx.column()
keys = [item.text(1) for item in selected] column_title = self.model().horizontalHeaderItem(column).text()
column = self.currentColumn() column_data = '\n'.join(self.model().itemFromIndex(idx).text() for idx in selected)
column_title = self.headerItem().text(column)
column_data = '\n'.join([item.text(column) for item in selected])
menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data))
if column in self.editable_columns: if column in self.editable_columns:
item = self.currentItem() item = self.model().itemFromIndex(idx)
menu.addAction(_("Edit {}").format(column_title), lambda: self.editItem(item, column)) if item.isEditable():
menu.addAction(_("Pay to"), lambda: self.parent.payto_contacts(keys)) # would not be editable if openalias
menu.addAction(_("Delete"), lambda: self.parent.delete_contacts(keys)) persistent = QPersistentModelIndex(idx)
URLs = [block_explorer_URL(self.config, 'addr', key) for key in filter(is_address, keys)] menu.addAction(_("Edit {}").format(column_title), lambda p=persistent: self.edit(QModelIndex(p)))
menu.addAction(_("Pay to"), lambda: self.parent.payto_contacts(selected_keys))
menu.addAction(_("Delete"), lambda: self.parent.delete_contacts(selected_keys))
URLs = [block_explorer_URL(self.config, 'addr', key) for key in filter(is_address, selected_keys)]
if URLs: if URLs:
menu.addAction(_("View on block explorer"), lambda: map(webbrowser.open, URLs)) menu.addAction(_("View on block explorer"), lambda: map(webbrowser.open, URLs))
run_hook('create_contact_menu', menu, selected) run_hook('create_contact_menu', menu, selected_keys)
menu.exec_(self.viewport().mapToGlobal(position)) menu.exec_(self.viewport().mapToGlobal(position))
def on_update(self): def update(self):
item = self.currentItem() current_key = self.current_item_user_role(col=0)
current_key = item.data(0, Qt.UserRole) if item else None self.model().clear()
self.clear() self.update_headers([_('Name'), _('Address')])
set_current = None
for key in sorted(self.parent.contacts.keys()): for key in sorted(self.parent.contacts.keys()):
_type, name = self.parent.contacts[key] contact_type, name = self.parent.contacts[key]
item = QTreeWidgetItem([name, key]) items = [QStandardItem(x) for x in (name, key)]
item.setData(0, Qt.UserRole, key) items[0].setEditable(contact_type != 'openalias')
self.addTopLevelItem(item) items[1].setEditable(False)
items[0].setData(key, Qt.UserRole)
row_count = self.model().rowCount()
self.model().insertRow(row_count, items)
if key == current_key: if key == current_key:
self.setCurrentItem(item) idx = self.model().index(row_count, 0)
set_current = QPersistentModelIndex(idx)
self.set_current_idx(set_current)
run_hook('update_contacts_tab', self) run_hook('update_contacts_tab', self)

396
electrum/gui/qt/history_list.py

@ -27,10 +27,11 @@ import webbrowser
import datetime import datetime
from datetime import date from datetime import date
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from collections import OrderedDict
from electrum.address_synchronizer import TX_HEIGHT_LOCAL from electrum.address_synchronizer import TX_HEIGHT_LOCAL
from electrum.i18n import _ from electrum.i18n import _
from electrum.util import block_explorer_URL, profiler, print_error, TxMinedStatus from electrum.util import block_explorer_URL, profiler, print_error, TxMinedStatus, Fiat
from .util import * from .util import *
@ -57,38 +58,109 @@ TX_ICONS = [
"confirmed.png", "confirmed.png",
] ]
class HistorySortModel(QSortFilterProxyModel):
class HistoryList(MyTreeWidget, AcceptFileDragDrop): def lessThan(self, source_left: QModelIndex, source_right: QModelIndex):
filter_columns = [2, 3, 4] # Date, Description, Amount item1 = self.sourceModel().itemFromIndex(source_left)
item2 = self.sourceModel().itemFromIndex(source_right)
data1 = item1.data(HistoryList.SORT_ROLE)
data2 = item2.data(HistoryList.SORT_ROLE)
if data1 is not None and data2 is not None:
return data1 < data2
return item1.text() < item2.text()
class HistoryList(MyTreeView, AcceptFileDragDrop):
filter_columns = [1, 2, 3] # Date, Description, Amount
TX_HASH_ROLE = Qt.UserRole
SORT_ROLE = Qt.UserRole + 1
def should_hide(self, proxy_row):
if self.start_timestamp and self.end_timestamp:
item = self.item_from_coordinate(proxy_row, 0)
txid = item.data(self.TX_HASH_ROLE)
date = self.transactions[txid]['date']
if date:
in_interval = self.start_timestamp <= date <= self.end_timestamp
if not in_interval:
return True
return False
def __init__(self, parent=None): def __init__(self, parent=None):
MyTreeWidget.__init__(self, parent, self.create_menu, [], 3) super().__init__(parent, self.create_menu, 2)
self.std_model = QStandardItemModel(self)
self.proxy = HistorySortModel(self)
self.proxy.setSourceModel(self.std_model)
self.setModel(self.proxy)
self.txid_to_items = {}
self.transactions = OrderedDict()
self.summary = {}
self.blue_brush = QBrush(QColor("#1E1EFF"))
self.red_brush = QBrush(QColor("#BC1E1E"))
self.monospace_font = QFont(MONOSPACE_FONT)
self.config = parent.config
AcceptFileDragDrop.__init__(self, ".txn") AcceptFileDragDrop.__init__(self, ".txn")
self.refresh_headers()
self.setColumnHidden(1, True)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.sortByColumn(0, Qt.AscendingOrder)
self.start_timestamp = None self.start_timestamp = None
self.end_timestamp = None self.end_timestamp = None
self.years = [] self.years = []
self.create_toolbar_buttons() self.create_toolbar_buttons()
self.wallet = None self.wallet = None
root = self.std_model.invisibleRootItem()
self.wallet = self.parent.wallet # type: Abstract_Wallet
fx = self.parent.fx
r = self.wallet.get_full_history(domain=self.get_domain(), from_timestamp=None, to_timestamp=None, fx=fx)
self.transactions.update([(x['txid'], x) for x in r['transactions']])
self.summary = r['summary']
if not self.years and self.transactions:
start_date = next(iter(self.transactions.values())).get('date') or date.today()
end_date = next(iter(reversed(self.transactions.values()))).get('date') or date.today()
self.years = [str(i) for i in range(start_date.year, end_date.year + 1)]
self.period_combo.insertItems(1, self.years)
if fx: fx.history_used_spot = False
self.refresh_headers()
for tx_item in self.transactions.values():
self.insert_tx(tx_item)
self.sortByColumn(0, Qt.AscendingOrder)
#def on_activated(self, idx: QModelIndex):
# # TODO use siblingAtColumn when min Qt version is >=5.11
# self.edit(idx.sibling(idx.row(), 2))
def format_date(self, d): def format_date(self, d):
return str(datetime.date(d.year, d.month, d.day)) if d else _('None') return str(datetime.date(d.year, d.month, d.day)) if d else _('None')
def refresh_headers(self): def refresh_headers(self):
headers = ['', '', _('Date'), _('Description'), _('Amount'), _('Balance')] headers = ['', _('Date'), _('Description'), _('Amount'), _('Balance')]
fx = self.parent.fx fx = self.parent.fx
if fx and fx.show_history(): if fx and fx.show_history():
headers.extend(['%s '%fx.ccy + _('Value')]) headers.extend(['%s '%fx.ccy + _('Value')])
self.editable_columns |= {6} self.editable_columns |= {5}
if fx.get_history_capital_gains_config(): if fx.get_history_capital_gains_config():
headers.extend(['%s '%fx.ccy + _('Acquisition price')]) headers.extend(['%s '%fx.ccy + _('Acquisition price')])
headers.extend(['%s '%fx.ccy + _('Capital Gains')]) headers.extend(['%s '%fx.ccy + _('Capital Gains')])
else: else:
self.editable_columns -= {6} self.editable_columns -= {5}
self.update_headers(headers) col_count = self.std_model.columnCount()
diff = col_count-len(headers)
grew = False
if col_count > len(headers):
if diff == 2:
self.std_model.removeColumns(6, diff)
else:
assert diff in [1, 3]
self.std_model.removeColumns(5, diff)
for items in self.txid_to_items.values():
while len(items) > col_count:
items.pop()
elif col_count < len(headers):
grew = True
self.std_model.clear()
self.txid_to_items.clear()
self.transactions.clear()
self.summary.clear()
self.update_headers(headers, self.std_model)
def get_domain(self): def get_domain(self):
'''Replaced in address_dialog.py''' '''Replaced in address_dialog.py'''
@ -109,13 +181,11 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
year = int(s) year = int(s)
except: except:
return return
start_date = datetime.datetime(year, 1, 1) self.start_timestamp = start_date = datetime.datetime(year, 1, 1)
end_date = datetime.datetime(year+1, 1, 1) self.end_timestamp = end_date = datetime.datetime(year+1, 1, 1)
self.start_timestamp = time.mktime(start_date.timetuple())
self.end_timestamp = time.mktime(end_date.timetuple())
self.start_button.setText(_('From') + ' ' + self.format_date(start_date)) self.start_button.setText(_('From') + ' ' + self.format_date(start_date))
self.end_button.setText(_('To') + ' ' + self.format_date(end_date)) self.end_button.setText(_('To') + ' ' + self.format_date(end_date))
self.update() self.hide_rows()
def create_toolbar_buttons(self): def create_toolbar_buttons(self):
self.period_combo = QComboBox() self.period_combo = QComboBox()
@ -134,18 +204,18 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
def on_hide_toolbar(self): def on_hide_toolbar(self):
self.start_timestamp = None self.start_timestamp = None
self.end_timestamp = None self.end_timestamp = None
self.update() self.hide_rows()
def save_toolbar_state(self, state, config): def save_toolbar_state(self, state, config):
config.set_key('show_toolbar_history', state) config.set_key('show_toolbar_history', state)
def select_start_date(self): def select_start_date(self):
self.start_timestamp = self.select_date(self.start_button) self.start_timestamp = self.select_date(self.start_button)
self.update() self.hide_rows()
def select_end_date(self): def select_end_date(self):
self.end_timestamp = self.select_date(self.end_button) self.end_timestamp = self.select_date(self.end_button)
self.update() self.hide_rows()
def select_date(self, button): def select_date(self, button):
d = WindowModalDialog(self, _("Select date")) d = WindowModalDialog(self, _("Select date"))
@ -165,7 +235,7 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
return None return None
date = d.date.toPyDate() date = d.date.toPyDate()
button.setText(self.format_date(date)) button.setText(self.format_date(date))
return time.mktime(date.timetuple()) return datetime.datetime(date.year, date.month, date.day)
def show_summary(self): def show_summary(self):
h = self.summary h = self.summary
@ -213,94 +283,167 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
_("Perhaps some dependencies are missing...") + " (matplotlib?)") _("Perhaps some dependencies are missing...") + " (matplotlib?)")
return return
try: try:
plt = plot_history(self.transactions) plt = plot_history(list(self.transactions.values()))
plt.show() plt.show()
except NothingToPlotException as e: except NothingToPlotException as e:
self.parent.show_message(str(e)) self.parent.show_message(str(e))
def insert_tx(self, tx_item):
fx = self.parent.fx
tx_hash = tx_item['txid']
height = tx_item['height']
conf = tx_item['confirmations']
timestamp = tx_item['timestamp']
value = tx_item['value'].value
balance = tx_item['balance'].value
label = tx_item['label']
tx_mined_status = TxMinedStatus(height, conf, timestamp, None)
status, status_str = self.wallet.get_tx_status(tx_hash, tx_mined_status)
has_invoice = self.wallet.invoices.paid.get(tx_hash)
icon = self.icon_cache.get(":icons/" + TX_ICONS[status])
v_str = self.parent.format_amount(value, is_diff=True, whitespaces=True)
balance_str = self.parent.format_amount(balance, whitespaces=True)
entry = ['', status_str, label, v_str, balance_str]
fiat_value = None
item = [QStandardItem(e) for e in entry]
item[3].setData(value, self.SORT_ROLE)
item[4].setData(balance, self.SORT_ROLE)
if has_invoice:
item[2].setIcon(self.icon_cache.get(":icons/seal"))
for i in range(len(entry)):
self.set_item_properties(item[i], i, tx_hash)
if value and value < 0:
item[2].setForeground(self.red_brush)
item[3].setForeground(self.red_brush)
self.txid_to_items[tx_hash] = item
self.update_item(tx_hash, self.parent.wallet.get_tx_height(tx_hash))
source_row_idx = self.std_model.rowCount()
self.std_model.insertRow(source_row_idx, item)
new_idx = self.std_model.index(source_row_idx, 0)
history = self.parent.fx.show_history()
if history:
self.update_fiat(tx_hash, tx_item)
self.hide_row(self.proxy.mapFromSource(new_idx).row())
def set_item_properties(self, item, i, tx_hash):
if i>2:
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
if i!=1:
item.setFont(self.monospace_font)
item.setEditable(i in self.editable_columns)
item.setData(tx_hash, self.TX_HASH_ROLE)
def ensure_fields_available(self, items, idx, txid):
while len(items) < idx + 1:
row = list(self.transactions.keys()).index(txid)
qidx = self.std_model.index(row, len(items))
assert qidx.isValid(), (self.std_model.columnCount(), idx)
item = self.std_model.itemFromIndex(qidx)
self.set_item_properties(item, len(items), txid)
items.append(item)
@profiler @profiler
def on_update(self): def update(self):
self.wallet = self.parent.wallet # type: Abstract_Wallet self.wallet = self.parent.wallet # type: Abstract_Wallet
fx = self.parent.fx fx = self.parent.fx
r = self.wallet.get_full_history(domain=self.get_domain(), from_timestamp=self.start_timestamp, to_timestamp=self.end_timestamp, fx=fx) r = self.wallet.get_full_history(domain=self.get_domain(), from_timestamp=None, to_timestamp=None, fx=fx)
self.transactions = r['transactions'] seen = set()
self.summary = r['summary'] history = fx.show_history()
if not self.years and self.transactions: tx_list = list(self.transactions.values())
start_date = self.transactions[0].get('date') or date.today() if r['transactions'] == tx_list:
end_date = self.transactions[-1].get('date') or date.today() return
self.years = [str(i) for i in range(start_date.year, end_date.year + 1)] if r['transactions'][:-1] == tx_list:
self.period_combo.insertItems(1, self.years) print_error('history_list: one new transaction')
item = self.currentItem() row = r['transactions'][-1]
current_tx = item.data(0, Qt.UserRole) if item else None txid = row['txid']
self.clear() if txid not in self.transactions:
if fx: fx.history_used_spot = False self.transactions[txid] = row
blue_brush = QBrush(QColor("#1E1EFF")) self.transactions.move_to_end(txid, last=True)
red_brush = QBrush(QColor("#BC1E1E")) self.insert_tx(row)
monospace_font = QFont(MONOSPACE_FONT) return
for tx_item in self.transactions: else:
tx_hash = tx_item['txid'] print_error('history_list: tx added but txid is already in list (weird), txid: ', txid)
height = tx_item['height'] for idx, row in enumerate(r['transactions']):
conf = tx_item['confirmations'] txid = row['txid']
timestamp = tx_item['timestamp'] seen.add(txid)
value = tx_item['value'].value if txid not in self.transactions:
balance = tx_item['balance'].value self.transactions[txid] = row
label = tx_item['label'] self.transactions.move_to_end(txid, last=True)
tx_mined_status = TxMinedStatus(height, conf, timestamp, None) self.insert_tx(row)
status, status_str = self.wallet.get_tx_status(tx_hash, tx_mined_status) continue
has_invoice = self.wallet.invoices.paid.get(tx_hash) old = self.transactions[txid]
icon = self.icon_cache.get(":icons/" + TX_ICONS[status]) if old == row:
v_str = self.parent.format_amount(value, is_diff=True, whitespaces=True) continue
balance_str = self.parent.format_amount(balance, whitespaces=True) self.update_item(txid, self.parent.wallet.get_tx_height(txid))
entry = ['', tx_hash, status_str, label, v_str, balance_str] if history:
fiat_value = None self.update_fiat(txid, row)
if value is not None and fx and fx.show_history(): balance_str = self.parent.format_amount(row['balance'].value, whitespaces=True)
fiat_value = tx_item['fiat_value'].value self.txid_to_items[txid][4].setText(balance_str)
value_str = fx.format_fiat(fiat_value) self.txid_to_items[txid][4].setData(row['balance'].value, self.SORT_ROLE)
entry.append(value_str) old.clear()
# fixme: should use is_mine old.update(**row)
if value < 0: removed = 0
entry.append(fx.format_fiat(tx_item['acquisition_price'].value)) l = list(enumerate(self.transactions.keys()))
entry.append(fx.format_fiat(tx_item['capital_gain'].value)) for idx, txid in l:
item = SortableTreeWidgetItem(entry) if txid not in seen:
item.setIcon(0, icon) del self.transactions[txid]
item.setToolTip(0, str(conf) + " confirmation" + ("s" if conf != 1 else "")) del self.txid_to_items[txid]
item.setData(0, SortableTreeWidgetItem.DataRole, (status, conf)) items = self.std_model.takeRow(idx - removed)
if has_invoice: removed_txid = items[0].data(self.TX_HASH_ROLE)
item.setIcon(3, self.icon_cache.get(":icons/seal")) assert removed_txid == txid, (idx, removed)
for i in range(len(entry)): removed += 1
if i>3: self.apply_filter()
item.setTextAlignment(i, Qt.AlignRight | Qt.AlignVCenter)
if i!=2: def update_fiat(self, txid, row):
item.setFont(i, monospace_font) cap_gains = self.parent.fx.get_history_capital_gains_config()
if value and value < 0: items = self.txid_to_items[txid]
item.setForeground(3, red_brush) self.ensure_fields_available(items, 7 if cap_gains else 5, txid)
item.setForeground(4, red_brush) if not row['fiat_default'] and row['fiat_value']:
if fiat_value and not tx_item['fiat_default']: items[5].setForeground(self.blue_brush)
item.setForeground(6, blue_brush) value_str = self.parent.fx.format_fiat(row['fiat_value'].value)
if tx_hash: items[5].setText(value_str)
item.setData(0, Qt.UserRole, tx_hash) items[5].setData(row['fiat_value'].value, self.SORT_ROLE)
self.insertTopLevelItem(0, item) # fixme: should use is_mine
if current_tx == tx_hash: if row['value'].value < 0 and cap_gains:
self.setCurrentItem(item) acq = row['acquisition_price'].value
items[6].setText(self.parent.fx.format_fiat(acq))
def on_edited(self, item, column, prior): items[6].setData(acq, self.SORT_ROLE)
'''Called only when the text actually changes''' cg = row['capital_gain'].value
key = item.data(0, Qt.UserRole) items[7].setText(self.parent.fx.format_fiat(cg))
text = item.text(column) items[7].setData(cg, self.SORT_ROLE)
def update_on_new_fee_histogram(self):
pass
# TODO update unconfirmed tx'es
def on_edited(self, index, user_role, text):
row, column = index.row(), index.column()
item = self.item_from_coordinate(row, column)
key = item.data(self.TX_HASH_ROLE)
# fixme # fixme
if column == 3: if column == 2:
self.parent.wallet.set_label(key, text) self.parent.wallet.set_label(key, text)
self.update_labels() self.update_labels()
self.parent.update_completions() self.parent.update_completions()
elif column == 6: elif column == 5:
self.parent.wallet.set_fiat_value(key, self.parent.fx.ccy, text) tx_item = self.transactions[key]
self.on_update() self.parent.wallet.set_fiat_value(key, self.parent.fx.ccy, text, self.parent.fx, tx_item['value'].value)
value = tx_item['value'].value
def on_doubleclick(self, item, column): if value is not None:
if self.permit_edit(item, column): fee = tx_item['fee']
super(HistoryList, self).on_doubleclick(item, column) fiat_fields = self.parent.wallet.get_tx_item_fiat(key, value, self.parent.fx, fee.value if fee else None)
tx_item.update(fiat_fields)
self.update_fiat(key, tx_item)
else: else:
tx_hash = item.data(0, Qt.UserRole) assert False
def mouseDoubleClickEvent(self, event: QMouseEvent):
idx = self.indexAt(event.pos())
item = self.item_from_coordinate(idx.row(), idx.column())
if not item or item.isEditable():
super().mouseDoubleClickEvent(event)
elif item:
tx_hash = item.data(self.TX_HASH_ROLE)
self.show_transaction(tx_hash) self.show_transaction(tx_hash)
def show_transaction(self, tx_hash): def show_transaction(self, tx_hash):
@ -311,13 +454,13 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
self.parent.show_transaction(tx, label) self.parent.show_transaction(tx, label)
def update_labels(self): def update_labels(self):
root = self.invisibleRootItem() root = self.std_model.invisibleRootItem()
child_count = root.childCount() child_count = root.rowCount()
for i in range(child_count): for i in range(child_count):
item = root.child(i) item = root.child(i, 2)
txid = item.data(0, Qt.UserRole) txid = item.data(self.TX_HASH_ROLE)
label = self.wallet.get_label(txid) label = self.wallet.get_label(txid)
item.setText(3, label) item.setText(label)
def update_item(self, tx_hash, tx_mined_status): def update_item(self, tx_hash, tx_mined_status):
if self.wallet is None: if self.wallet is None:
@ -325,31 +468,30 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
conf = tx_mined_status.conf conf = tx_mined_status.conf
status, status_str = self.wallet.get_tx_status(tx_hash, tx_mined_status) status, status_str = self.wallet.get_tx_status(tx_hash, tx_mined_status)
icon = self.icon_cache.get(":icons/" + TX_ICONS[status]) icon = self.icon_cache.get(":icons/" + TX_ICONS[status])
items = self.findItems(tx_hash, Qt.MatchExactly, column=1) if tx_hash not in self.txid_to_items:
if items:
item = items[0]
item.setIcon(0, icon)
item.setData(0, SortableTreeWidgetItem.DataRole, (status, conf))
item.setText(2, status_str)
def create_menu(self, position):
self.selectedIndexes()
item = self.currentItem()
if not item:
return
column = self.currentColumn()
tx_hash = item.data(0, Qt.UserRole)
if not tx_hash:
return return
items = self.txid_to_items[tx_hash]
items[0].setIcon(icon)
items[0].setToolTip(str(conf) + _(" confirmation" + ("s" if conf != 1 else "")))
items[0].setData((status, conf), self.SORT_ROLE)
items[1].setText(status_str)
def create_menu(self, position: QPoint):
org_idx: QModelIndex = self.indexAt(position)
idx = self.proxy.mapToSource(org_idx)
item: QStandardItem = self.std_model.itemFromIndex(idx)
assert item, 'create_menu: index not found in model'
tx_hash = idx.data(self.TX_HASH_ROLE)
column = idx.column()
assert tx_hash, "create_menu: no tx hash"
tx = self.wallet.transactions.get(tx_hash) tx = self.wallet.transactions.get(tx_hash)
if not tx: assert tx, "create_menu: no tx"
return if column == 0:
if column is 0: column_title = _('Transaction ID')
column_title = "ID"
column_data = tx_hash column_data = tx_hash
else: else:
column_title = self.headerItem().text(column) column_title = self.std_model.horizontalHeaderItem(column).text()
column_data = item.text(column) column_data = item.text()
tx_URL = block_explorer_URL(self.config, 'tx', tx_hash) tx_URL = block_explorer_URL(self.config, 'tx', tx_hash)
height = self.wallet.get_tx_height(tx_hash).height height = self.wallet.get_tx_height(tx_hash).height
is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx)
@ -360,8 +502,10 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
menu.addAction(_("Remove"), lambda: self.remove_local_tx(tx_hash)) menu.addAction(_("Remove"), lambda: self.remove_local_tx(tx_hash))
menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data))
for c in self.editable_columns: for c in self.editable_columns:
menu.addAction(_("Edit {}").format(self.headerItem().text(c)), label = self.std_model.horizontalHeaderItem(c).text()
lambda bound_c=c: self.editItem(item, bound_c)) # TODO use siblingAtColumn when min Qt version is >=5.11
persistent = QPersistentModelIndex(org_idx.sibling(org_idx.row(), c))
menu.addAction(_("Edit {}").format(label), lambda p=persistent: self.edit(QModelIndex(p)))
menu.addAction(_("Details"), lambda: self.show_transaction(tx_hash)) menu.addAction(_("Details"), lambda: self.show_transaction(tx_hash))
if is_unconfirmed and tx: if is_unconfirmed and tx:
# note: the current implementation of RBF *needs* the old tx fee # note: the current implementation of RBF *needs* the old tx fee
@ -430,7 +574,7 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
self.parent.show_message(_("Your wallet history has been successfully exported.")) self.parent.show_message(_("Your wallet history has been successfully exported."))
def do_export_history(self, file_name, is_csv): def do_export_history(self, file_name, is_csv):
history = self.transactions history = self.transactions.values()
lines = [] lines = []
if is_csv: if is_csv:
for item in history: for item in history:

4
electrum/gui/qt/installwizard.py

@ -432,7 +432,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
return slayout.is_ext return slayout.is_ext
def pw_layout(self, msg, kind, force_disable_encrypt_cb): def pw_layout(self, msg, kind, force_disable_encrypt_cb):
playout = PasswordLayout(None, msg, kind, self.next_button, playout = PasswordLayout(msg=msg, kind=kind, OK_button=self.next_button,
force_disable_encrypt_cb=force_disable_encrypt_cb) force_disable_encrypt_cb=force_disable_encrypt_cb)
playout.encrypt_cb.setChecked(True) playout.encrypt_cb.setChecked(True)
self.exec_layout(playout.layout()) self.exec_layout(playout.layout())
@ -446,7 +446,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
@wizard_dialog @wizard_dialog
def request_storage_encryption(self, run_next): def request_storage_encryption(self, run_next):
playout = PasswordLayoutForHW(None, MSG_HW_STORAGE_ENCRYPTION, PW_NEW, self.next_button) playout = PasswordLayoutForHW(MSG_HW_STORAGE_ENCRYPTION)
playout.encrypt_cb.setChecked(True) playout.encrypt_cb.setChecked(True)
self.exec_layout(playout.layout()) self.exec_layout(playout.layout())
return playout.encrypt_cb.isChecked() return playout.encrypt_cb.isChecked()

28
electrum/gui/qt/invoice_list.py

@ -29,36 +29,40 @@ from electrum.util import format_time
from .util import * from .util import *
class InvoiceList(MyTreeWidget): class InvoiceList(MyTreeView):
filter_columns = [0, 1, 2, 3] # Date, Requestor, Description, Amount filter_columns = [0, 1, 2, 3] # Date, Requestor, Description, Amount
def __init__(self, parent): def __init__(self, parent):
MyTreeWidget.__init__(self, parent, self.create_menu, [_('Expires'), _('Requestor'), _('Description'), _('Amount'), _('Status')], 2) super().__init__(parent, self.create_menu, 2)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.header().setSectionResizeMode(1, QHeaderView.Interactive)
self.setColumnWidth(1, 200) self.setColumnWidth(1, 200)
self.setModel(QStandardItemModel(self))
self.update()
def on_update(self): def update(self):
inv_list = self.parent.invoices.unpaid_invoices() inv_list = self.parent.invoices.unpaid_invoices()
self.clear() self.model().clear()
self.update_headers([_('Expires'), _('Requestor'), _('Description'), _('Amount'), _('Status')])
self.header().setSectionResizeMode(1, QHeaderView.Interactive)
for pr in inv_list: for pr in inv_list:
key = pr.get_id() key = pr.get_id()
status = self.parent.invoices.get_status(key) status = self.parent.invoices.get_status(key)
requestor = pr.get_requestor() requestor = pr.get_requestor()
exp = pr.get_expiration_date() exp = pr.get_expiration_date()
date_str = format_time(exp) if exp else _('Never') date_str = format_time(exp) if exp else _('Never')
item = QTreeWidgetItem([date_str, requestor, pr.memo, self.parent.format_amount(pr.get_amount(), whitespaces=True), pr_tooltips.get(status,'')]) labels = [date_str, requestor, pr.memo, self.parent.format_amount(pr.get_amount(), whitespaces=True), pr_tooltips.get(status,'')]
item.setIcon(4, self.icon_cache.get(pr_icons.get(status))) item = [QStandardItem(e) for e in labels]
item.setData(0, Qt.UserRole, key) item[4].setIcon(self.icon_cache.get(pr_icons.get(status)))
item.setFont(1, QFont(MONOSPACE_FONT)) item[0].setData(Qt.UserRole, key)
item.setFont(3, QFont(MONOSPACE_FONT)) item[1].setFont(QFont(MONOSPACE_FONT))
item[3].setFont(QFont(MONOSPACE_FONT))
self.addTopLevelItem(item) self.addTopLevelItem(item)
self.setCurrentItem(self.topLevelItem(0)) self.selectionModel().select(self.model().index(0,0), QItemSelectionModel.SelectCurrent)
self.setVisible(len(inv_list)) self.setVisible(len(inv_list))
self.parent.invoices_label.setVisible(len(inv_list)) self.parent.invoices_label.setVisible(len(inv_list))
def import_invoices(self): def import_invoices(self):
import_meta_gui(self.parent, _('invoices'), self.parent.invoices.import_file, self.on_update) import_meta_gui(self.parent, _('invoices'), self.parent.invoices.import_file, self.update)
def export_invoices(self): def export_invoices(self):
export_meta_gui(self.parent, _('invoices'), self.parent.invoices.export_file) export_meta_gui(self.parent, _('invoices'), self.parent.invoices.export_file)

11
electrum/gui/qt/main_window.py

@ -222,6 +222,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
self.fetch_alias() self.fetch_alias()
def on_history(self, b): def on_history(self, b):
self.wallet.clear_coin_price_cache()
self.new_fx_history_signal.emit() self.new_fx_history_signal.emit()
def setup_exception_hook(self): def setup_exception_hook(self):
@ -352,8 +353,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
if self.config.is_dynfee(): if self.config.is_dynfee():
self.fee_slider.update() self.fee_slider.update()
self.do_update_fee() self.do_update_fee()
# todo: update only unconfirmed tx self.history_list.update_on_new_fee_histogram()
self.history_list.update()
else: else:
self.print_error("unexpected network_qt signal:", event, args) self.print_error("unexpected network_qt signal:", event, args)
@ -378,9 +378,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
def load_wallet(self, wallet): def load_wallet(self, wallet):
wallet.thread = TaskThread(self, self.on_error) wallet.thread = TaskThread(self, self.on_error)
self.update_recently_visited(wallet.storage.path) self.update_recently_visited(wallet.storage.path)
# update(==init) all tabs; expensive for large wallets..
# so delay it somewhat, hence __init__ can finish and the window can appear sooner
QTimer.singleShot(50, self.update_tabs)
self.need_update.set() 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 # 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 # update menus
@ -1110,9 +1107,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
self.from_label = QLabel(_('From')) self.from_label = QLabel(_('From'))
grid.addWidget(self.from_label, 3, 0) grid.addWidget(self.from_label, 3, 0)
self.from_list = MyTreeWidget(self, self.from_list_menu, ['','']) self.from_list = FromList(self, self.from_list_menu)
self.from_list.setHeaderHidden(True)
self.from_list.setMaximumHeight(80)
grid.addWidget(self.from_list, 3, 1, 1, -1) grid.addWidget(self.from_list, 3, 1, 1, -1)
self.set_pay_from([]) self.set_pay_from([])

24
electrum/gui/qt/network_dialog.py

@ -82,8 +82,8 @@ class NodesListWidget(QTreeWidget):
server = item.data(1, Qt.UserRole) server = item.data(1, Qt.UserRole)
menu.addAction(_("Use as server"), lambda: self.parent.follow_server(server)) menu.addAction(_("Use as server"), lambda: self.parent.follow_server(server))
else: else:
index = item.data(1, Qt.UserRole) chain_id = item.data(1, Qt.UserRole)
menu.addAction(_("Follow this branch"), lambda: self.parent.follow_branch(index)) menu.addAction(_("Follow this branch"), lambda: self.parent.follow_branch(chain_id))
menu.exec_(self.viewport().mapToGlobal(position)) menu.exec_(self.viewport().mapToGlobal(position))
def keyPressEvent(self, event): def keyPressEvent(self, event):
@ -100,25 +100,25 @@ class NodesListWidget(QTreeWidget):
def update(self, network: Network): def update(self, network: Network):
self.clear() self.clear()
self.addChild = self.addTopLevelItem
chains = network.get_blockchains() chains = network.get_blockchains()
n_chains = len(chains) n_chains = len(chains)
for k, items in chains.items(): for chain_id, interfaces in chains.items():
b = blockchain.blockchains[k] b = blockchain.blockchains.get(chain_id)
if b is None: continue
name = b.get_name() name = b.get_name()
if n_chains >1: if n_chains > 1:
x = QTreeWidgetItem([name + '@%d'%b.get_max_forkpoint(), '%d'%b.height()]) x = QTreeWidgetItem([name + '@%d'%b.get_max_forkpoint(), '%d'%b.height()])
x.setData(0, Qt.UserRole, 1) x.setData(0, Qt.UserRole, 1)
x.setData(1, Qt.UserRole, b.forkpoint) x.setData(1, Qt.UserRole, b.get_id())
else: else:
x = self x = self
for i in items: for i in interfaces:
star = ' *' if i == network.interface else '' star = ' *' if i == network.interface else ''
item = QTreeWidgetItem([i.host + star, '%d'%i.tip]) item = QTreeWidgetItem([i.host + star, '%d'%i.tip])
item.setData(0, Qt.UserRole, 0) item.setData(0, Qt.UserRole, 0)
item.setData(1, Qt.UserRole, i.server) item.setData(1, Qt.UserRole, i.server)
x.addChild(item) x.addTopLevelItem(item)
if n_chains>1: if n_chains > 1:
self.addTopLevelItem(x) self.addTopLevelItem(x)
x.setExpanded(True) x.setExpanded(True)
@ -410,8 +410,8 @@ class NetworkChoiceLayout(object):
self.set_protocol(p) self.set_protocol(p)
self.set_server() self.set_server()
def follow_branch(self, index): def follow_branch(self, chain_id):
self.network.run_from_another_thread(self.network.follow_chain_given_id(index)) self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id))
self.update() self.update()
def follow_server(self, server): def follow_server(self, server):

17
electrum/gui/qt/password_dialog.py

@ -60,7 +60,7 @@ class PasswordLayout(object):
titles = [_("Enter Password"), _("Change Password"), _("Enter Passphrase")] titles = [_("Enter Password"), _("Change Password"), _("Enter Passphrase")]
def __init__(self, wallet, msg, kind, OK_button, force_disable_encrypt_cb=False): def __init__(self, msg, kind, OK_button, wallet=None, force_disable_encrypt_cb=False):
self.wallet = wallet self.wallet = wallet
self.pw = QLineEdit() self.pw = QLineEdit()
@ -169,12 +169,9 @@ class PasswordLayout(object):
class PasswordLayoutForHW(object): class PasswordLayoutForHW(object):
def __init__(self, wallet, msg, kind, OK_button): def __init__(self, msg, wallet=None):
self.wallet = wallet self.wallet = wallet
self.kind = kind
self.OK_button = OK_button
vbox = QVBoxLayout() vbox = QVBoxLayout()
label = QLabel(msg + "\n") label = QLabel(msg + "\n")
label.setWordWrap(True) label.setWordWrap(True)
@ -254,9 +251,11 @@ class ChangePasswordDialogForSW(ChangePasswordDialogBase):
else: else:
msg = _('Your wallet is password protected and encrypted.') msg = _('Your wallet is password protected and encrypted.')
msg += ' ' + _('Use this dialog to change your password.') msg += ' ' + _('Use this dialog to change your password.')
self.playout = PasswordLayout( self.playout = PasswordLayout(msg=msg,
wallet, msg, PW_CHANGE, OK_button, kind=PW_CHANGE,
force_disable_encrypt_cb=not wallet.can_have_keystore_encryption()) OK_button=OK_button,
wallet=wallet,
force_disable_encrypt_cb=not wallet.can_have_keystore_encryption())
def run(self): def run(self):
if not self.exec_(): if not self.exec_():
@ -276,7 +275,7 @@ class ChangePasswordDialogForHW(ChangePasswordDialogBase):
msg = _('Your wallet file is encrypted.') msg = _('Your wallet file is encrypted.')
msg += '\n' + _('Note: If you enable this setting, you will need your hardware device to open your wallet.') msg += '\n' + _('Note: If you enable this setting, you will need your hardware device to open your wallet.')
msg += '\n' + _('Use this dialog to toggle encryption.') msg += '\n' + _('Use this dialog to toggle encryption.')
self.playout = PasswordLayoutForHW(wallet, msg, PW_CHANGE, OK_button) self.playout = PasswordLayoutForHW(msg)
def run(self): def run(self):
if not self.exec_(): if not self.exec_():

68
electrum/gui/qt/request_list.py

@ -23,43 +23,39 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.
from PyQt5.QtGui import * from PyQt5.QtGui import QStandardItemModel, QStandardItem
from PyQt5.QtCore import * from PyQt5.QtWidgets import QMenu
from PyQt5.QtWidgets import QTreeWidgetItem, QMenu from PyQt5.QtCore import Qt
from electrum.i18n import _ from electrum.i18n import _
from electrum.util import format_time, age from electrum.util import format_time, age
from electrum.plugin import run_hook from electrum.plugin import run_hook
from electrum.paymentrequest import PR_UNKNOWN from electrum.paymentrequest import PR_UNKNOWN
from .util import MyTreeWidget, pr_tooltips, pr_icons from .util import MyTreeView, pr_tooltips, pr_icons
class RequestList(MyTreeView):
class RequestList(MyTreeWidget):
filter_columns = [0, 1, 2, 3, 4] # Date, Account, Address, Description, Amount filter_columns = [0, 1, 2, 3, 4] # Date, Account, Address, Description, Amount
def __init__(self, parent): def __init__(self, parent):
MyTreeWidget.__init__(self, parent, self.create_menu, [_('Date'), _('Address'), '', _('Description'), _('Amount'), _('Status')], 3) super().__init__(parent, self.create_menu, 3, editable_columns=[])
self.currentItemChanged.connect(self.item_changed) self.setModel(QStandardItemModel(self))
self.itemClicked.connect(self.item_changed)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.setColumnWidth(0, 180) self.setColumnWidth(0, 180)
self.hideColumn(1) self.update()
self.selectionModel().currentRowChanged.connect(self.item_changed)
def item_changed(self, item): def item_changed(self, idx):
if item is None: # TODO use siblingAtColumn when min Qt version is >=5.11
return addr = self.model().itemFromIndex(idx.sibling(idx.row(), 1)).text()
if not item.isSelected():
return
addr = str(item.text(1))
req = self.wallet.receive_requests.get(addr) req = self.wallet.receive_requests.get(addr)
if req is None: if req is None:
self.update() self.update()
return return
expires = age(req['time'] + req['exp']) if req.get('exp') else _('Never') expires = age(req['time'] + req['exp']) if req.get('exp') else _('Never')
amount = req['amount'] amount = req['amount']
message = self.wallet.labels.get(addr, '') message = req['memo']
self.parent.receive_address_e.setText(addr) self.parent.receive_address_e.setText(addr)
self.parent.receive_message_e.setText(message) self.parent.receive_message_e.setText(message)
self.parent.receive_amount_e.setAmount(amount) self.parent.receive_amount_e.setAmount(amount)
@ -68,7 +64,7 @@ class RequestList(MyTreeWidget):
self.parent.expires_label.setText(expires) self.parent.expires_label.setText(expires)
self.parent.new_request_button.setEnabled(True) self.parent.new_request_button.setEnabled(True)
def on_update(self): def update(self):
self.wallet = self.parent.wallet self.wallet = self.parent.wallet
# hide receive tab if no receive requests available # hide receive tab if no receive requests available
b = len(self.wallet.receive_requests) > 0 b = len(self.wallet.receive_requests) > 0
@ -86,8 +82,9 @@ class RequestList(MyTreeWidget):
self.parent.set_receive_address(addr) self.parent.set_receive_address(addr)
self.parent.new_request_button.setEnabled(addr != current_address) self.parent.new_request_button.setEnabled(addr != current_address)
# clear the list and fill it again self.model().clear()
self.clear() self.update_headers([_('Date'), _('Address'), '', _('Description'), _('Amount'), _('Status')])
self.hideColumn(1) # hide address column
for req in self.wallet.get_sorted_requests(self.config): for req in self.wallet.get_sorted_requests(self.config):
address = req['address'] address = req['address']
if address not in domain: if address not in domain:
@ -95,35 +92,40 @@ class RequestList(MyTreeWidget):
timestamp = req.get('time', 0) timestamp = req.get('time', 0)
amount = req.get('amount') amount = req.get('amount')
expiration = req.get('exp', None) expiration = req.get('exp', None)
message = req.get('memo', '') message = req['memo']
date = format_time(timestamp) date = format_time(timestamp)
status = req.get('status') status = req.get('status')
signature = req.get('sig') signature = req.get('sig')
requestor = req.get('name', '') requestor = req.get('name', '')
amount_str = self.parent.format_amount(amount) if amount else "" amount_str = self.parent.format_amount(amount) if amount else ""
item = QTreeWidgetItem([date, address, '', message, amount_str, pr_tooltips.get(status,'')]) labels = [date, address, '', message, amount_str, pr_tooltips.get(status,'')]
items = [QStandardItem(e) for e in labels]
self.set_editability(items)
if signature is not None: if signature is not None:
item.setIcon(2, self.icon_cache.get(":icons/seal.png")) items[2].setIcon(self.icon_cache.get(":icons/seal.png"))
item.setToolTip(2, 'signed by '+ requestor) items[2].setToolTip('signed by '+ requestor)
if status is not PR_UNKNOWN: if status is not PR_UNKNOWN:
item.setIcon(6, self.icon_cache.get(pr_icons.get(status))) items[5].setIcon(self.icon_cache.get(pr_icons.get(status)))
self.addTopLevelItem(item) items[3].setData(address, Qt.UserRole)
self.model().insertRow(self.model().rowCount(), items)
def create_menu(self, position): def create_menu(self, position):
item = self.itemAt(position) idx = self.indexAt(position)
# TODO use siblingAtColumn when min Qt version is >=5.11
item = self.model().itemFromIndex(idx.sibling(idx.row(), 1))
if not item: if not item:
return return
addr = str(item.text(1)) addr = item.text()
req = self.wallet.receive_requests.get(addr) req = self.wallet.receive_requests.get(addr)
if req is None: if req is None:
self.update() self.update()
return return
column = self.currentColumn() column = idx.column()
column_title = self.headerItem().text(column) column_title = self.model().horizontalHeaderItem(column).text()
column_data = item.text(column) column_data = item.text()
menu = QMenu(self) menu = QMenu(self)
menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) if column != 2:
menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data))
menu.addAction(_("Copy URI"), lambda: self.parent.view_and_paste('URI', '', self.parent.get_request_URI(addr))) menu.addAction(_("Copy URI"), lambda: self.parent.view_and_paste('URI', '', self.parent.get_request_URI(addr)))
menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr)) menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr))
menu.addAction(_("Delete"), lambda: self.parent.delete_payment_request(addr)) menu.addAction(_("Delete"), lambda: self.parent.delete_payment_request(addr))

243
electrum/gui/qt/util.py

@ -5,12 +5,13 @@ import platform
import queue import queue
from functools import partial from functools import partial
from typing import NamedTuple, Callable, Optional from typing import NamedTuple, Callable, Optional
from abc import abstractmethod
from PyQt5.QtGui import * from PyQt5.QtGui import *
from PyQt5.QtCore import * from PyQt5.QtCore import *
from PyQt5.QtWidgets import * from PyQt5.QtWidgets import *
from electrum.i18n import _ from electrum.i18n import _, languages
from electrum.util import FileImportFailed, FileExportFailed from electrum.util import FileImportFailed, FileExportFailed
from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_EXPIRED from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_EXPIRED
@ -398,20 +399,16 @@ class ElectrumItemDelegate(QStyledItemDelegate):
def createEditor(self, parent, option, index): def createEditor(self, parent, option, index):
return self.parent().createEditor(parent, option, index) return self.parent().createEditor(parent, option, index)
class MyTreeWidget(QTreeWidget): class MyTreeView(QTreeView):
def __init__(self, parent, create_menu, headers, stretch_column=None, def __init__(self, parent, create_menu, stretch_column=None, editable_columns=None):
editable_columns=None): super().__init__(parent)
QTreeWidget.__init__(self, parent)
self.parent = parent self.parent = parent
self.config = self.parent.config self.config = self.parent.config
self.stretch_column = stretch_column self.stretch_column = stretch_column
self.setContextMenuPolicy(Qt.CustomContextMenu) self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(create_menu) self.customContextMenuRequested.connect(create_menu)
self.setUniformRowHeights(True) self.setUniformRowHeights(True)
# extend the syntax for consistency
self.addChild = self.addTopLevelItem
self.insertChild = self.insertTopLevelItem
self.icon_cache = IconCache() self.icon_cache = IconCache()
@ -424,127 +421,143 @@ class MyTreeWidget(QTreeWidget):
editable_columns = set(editable_columns) editable_columns = set(editable_columns)
self.editable_columns = editable_columns self.editable_columns = editable_columns
self.setItemDelegate(ElectrumItemDelegate(self)) self.setItemDelegate(ElectrumItemDelegate(self))
self.itemDoubleClicked.connect(self.on_doubleclick)
self.update_headers(headers)
self.current_filter = "" self.current_filter = ""
self.setRootIsDecorated(False) # remove left margin self.setRootIsDecorated(False) # remove left margin
self.toolbar_shown = False self.toolbar_shown = False
def update_headers(self, headers): def set_editability(self, items):
self.setColumnCount(len(headers)) for idx, i in enumerate(items):
self.setHeaderLabels(headers) 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 current_item_user_role(self, col) -> Optional[QStandardItem]:
idx = self.selectionModel().currentIndex()
idx = idx.sibling(idx.row(), col)
item = self.model().itemFromIndex(idx)
if item:
return item.data(Qt.UserRole)
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.SelectCurrent)
def update_headers(self, headers, model=None):
if model is None:
model = self.model()
model.setHorizontalHeaderLabels(headers)
self.header().setStretchLastSection(False) self.header().setStretchLastSection(False)
for col in range(len(headers)): for col in range(len(headers)):
sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents
self.header().setSectionResizeMode(col, sm) self.header().setSectionResizeMode(col, sm)
def editItem(self, item, column):
if column in self.editable_columns:
try:
self.editing_itemcol = (item, column, item.text(column))
# Calling setFlags causes on_changed events for some reason
item.setFlags(item.flags() | Qt.ItemIsEditable)
QTreeWidget.editItem(self, item, column)
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
except RuntimeError:
# (item) wrapped C/C++ object has been deleted
pass
def keyPressEvent(self, event): def keyPressEvent(self, event):
if event.key() in [ Qt.Key_F2, Qt.Key_Return ] and self.editor is None: if event.key() in [ Qt.Key_F2, Qt.Key_Return ] and self.editor is None:
self.on_activated(self.currentItem(), self.currentColumn()) self.on_activated(self.selectionModel().currentIndex())
else: return
QTreeWidget.keyPressEvent(self, event) super().keyPressEvent(event)
def permit_edit(self, item, column):
return (column in self.editable_columns
and self.on_permit_edit(item, column))
def on_permit_edit(self, item, column):
return True
def on_doubleclick(self, item, column):
if self.permit_edit(item, column):
self.editItem(item, column)
def on_activated(self, item, column): def on_activated(self, idx):
# on 'enter' we show the menu # on 'enter' we show the menu
pt = self.visualItemRect(item).bottomLeft() pt = self.visualRect(idx).bottomLeft()
pt.setX(50) pt.setX(50)
self.customContextMenuRequested.emit(pt) self.customContextMenuRequested.emit(pt)
def createEditor(self, parent, option, index): def createEditor(self, parent, option, idx):
self.editor = QStyledItemDelegate.createEditor(self.itemDelegate(), self.editor = QStyledItemDelegate.createEditor(self.itemDelegate(),
parent, option, index) parent, option, idx)
self.editor.editingFinished.connect(self.editing_finished) item = self.item_from_coordinate(idx.row(), idx.column())
user_role = item.data(Qt.UserRole)
assert user_role is not None
prior_text = item.text()
def editing_finished():
# Long-time QT bug - pressing Enter to finish editing signals
# editingFinished twice. If the item changed the sequence is
# Enter key: editingFinished, on_change, editingFinished
# Mouse: on_change, editingFinished
# This mess is the cleanest way to ensure we make the
# on_edited callback with the updated item
if self.editor is None:
return
if self.editor.text() == prior_text:
self.editor = None # Unchanged - ignore any 2nd call
return
if item.text() == prior_text:
return # Buggy first call on Enter key, item not yet updated
if not idx.isValid():
return
self.on_edited(idx, user_role, self.editor.text())
self.editor = None
self.editor.editingFinished.connect(editing_finished)
return self.editor return self.editor
def editing_finished(self): def edit(self, idx, trigger=QAbstractItemView.AllEditTriggers, event=None):
# Long-time QT bug - pressing Enter to finish editing signals """
# editingFinished twice. If the item changed the sequence is this is to prevent:
# Enter key: editingFinished, on_change, editingFinished edit: editing failed
# Mouse: on_change, editingFinished from inside qt
# This mess is the cleanest way to ensure we make the """
# on_edited callback with the updated item return super().edit(idx, trigger, event)
if self.editor:
(item, column, prior_text) = self.editing_itemcol def on_edited(self, idx: QModelIndex, user_role, text):
if self.editor.text() == prior_text: self.parent.wallet.set_label(user_role, text)
self.editor = None # Unchanged - ignore any 2nd call
elif item.text(column) == prior_text:
pass # Buggy first call on Enter key, item not yet updated
else:
# What we want - the updated item
self.on_edited(*self.editing_itemcol)
self.editor = None
# Now do any pending updates
if self.editor is None and self.pending_update:
self.pending_update = False
self.on_update()
def on_edited(self, item, column, prior):
'''Called only when the text actually changes'''
key = item.data(0, Qt.UserRole)
text = item.text(column)
self.parent.wallet.set_label(key, text)
self.parent.history_list.update_labels() self.parent.history_list.update_labels()
self.parent.update_completions() self.parent.update_completions()
def update(self): def apply_filter(self):
# Defer updates if editing
if self.editor:
self.pending_update = True
else:
self.setUpdatesEnabled(False)
scroll_pos = self.verticalScrollBar().value()
self.on_update()
self.setUpdatesEnabled(True)
# To paint the list before resetting the scroll position
self.parent.app.processEvents()
self.verticalScrollBar().setValue(scroll_pos)
if self.current_filter: if self.current_filter:
self.filter(self.current_filter) self.filter(self.current_filter)
def on_update(self): @abstractmethod
def should_hide(self, row):
"""
row_num is for self.model(). So if there is a proxy, it is the row number
in that!
"""
pass pass
def get_leaves(self, root): def item_from_coordinate(self, row_num, column):
child_count = root.childCount() if isinstance(self.model(), QSortFilterProxyModel):
if child_count == 0: idx = self.model().mapToSource(self.model().index(row_num, column))
yield root return self.model().sourceModel().itemFromIndex(idx)
for i in range(child_count): else:
item = root.child(i) idx = self.model().index(row_num, column)
for x in self.get_leaves(item): return self.model().itemFromIndex(idx)
yield x
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:
item = self.item_from_coordinate(row_num, column)
txt = item.text().lower()
if self.current_filter in txt:
# 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, show the item
self.setRowHidden(row_num, QModelIndex(), True)
def filter(self, p): def filter(self, p):
columns = self.__class__.filter_columns
p = p.lower() p = p.lower()
self.current_filter = p self.current_filter = p
for item in self.get_leaves(self.invisibleRootItem()): self.hide_rows()
item.setHidden(all([item.text(column).lower().find(p) == -1
for column in columns])) def hide_rows(self):
for row in range(self.model().rowCount()):
self.hide_row(row)
def create_toolbar(self, config=None): def create_toolbar(self, config=None):
hbox = QHBoxLayout() hbox = QHBoxLayout()
@ -790,22 +803,6 @@ def get_parent_main_window(widget):
return widget return widget
return None return None
class SortableTreeWidgetItem(QTreeWidgetItem):
DataRole = Qt.UserRole + 1
def __lt__(self, other):
column = self.treeWidget().sortColumn()
if None not in [x.data(column, self.DataRole) for x in [self, other]]:
# We have set custom data to sort by
return self.data(column, self.DataRole) < other.data(column, self.DataRole)
try:
# Is the value something numeric?
return float(self.text(column)) < float(other.text(column))
except ValueError:
# If not, we will just do string comparison
return self.text(column) < other.text(column)
class IconCache: class IconCache:
def __init__(self): def __init__(self):
@ -817,6 +814,26 @@ class IconCache:
return self.__cache[file_name] return self.__cache[file_name]
def get_default_language():
name = QLocale.system().name()
return name if name in languages else 'en_UK'
class FromList(QTreeWidget):
def __init__(self, parent, create_menu):
super().__init__(parent)
self.setHeaderHidden(True)
self.setMaximumHeight(300)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(create_menu)
self.setUniformRowHeights(True)
# remove left margin
self.setRootIsDecorated(False)
self.setColumnCount(2)
self.header().setStretchLastSection(False)
sm = QHeaderView.ResizeToContents
self.header().setSectionResizeMode(0, sm)
self.header().setSectionResizeMode(1, sm)
if __name__ == "__main__": if __name__ == "__main__":
app = QApplication([]) app = QApplication([])
t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done")) t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done"))

64
electrum/gui/qt/utxo_list.py

@ -23,58 +23,66 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.
from typing import Optional, List
from electrum.i18n import _ from electrum.i18n import _
from .util import * from .util import *
class UTXOList(MyTreeView):
class UTXOList(MyTreeWidget): filter_columns = [0, 1] # Address, Label
filter_columns = [0, 2] # Address, Label
def __init__(self, parent=None): def __init__(self, parent=None):
MyTreeWidget.__init__(self, parent, self.create_menu, [ _('Address'), _('Label'), _('Amount'), _('Height'), _('Output point')], 1) super().__init__(parent, self.create_menu, 1)
self.setModel(QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.update()
def get_name(self, x): def update(self):
return x.get('prevout_hash') + ":%d"%x.get('prevout_n')
def on_update(self):
self.wallet = self.parent.wallet self.wallet = self.parent.wallet
item = self.currentItem() utxos = self.wallet.get_utxos()
self.clear() self.utxo_dict = {}
self.utxos = self.wallet.get_utxos() self.model().clear()
for x in self.utxos: self.update_headers([ _('Address'), _('Label'), _('Amount'), _('Height'), _('Output point')])
for idx, x in enumerate(utxos):
address = x.get('address') address = x.get('address')
height = x.get('height') height = x.get('height')
name = self.get_name(x) name = x.get('prevout_hash') + ":%d"%x.get('prevout_n')
self.utxo_dict[name] = x
label = self.wallet.get_label(x.get('prevout_hash')) label = self.wallet.get_label(x.get('prevout_hash'))
amount = self.parent.format_amount(x['value'], whitespaces=True) amount = self.parent.format_amount(x['value'], whitespaces=True)
utxo_item = SortableTreeWidgetItem([address, label, amount, '%d'%height, name[0:10] + '...' + name[-2:]]) labels = [address, label, amount, '%d'%height, name[0:10] + '...' + name[-2:]]
utxo_item.setFont(0, QFont(MONOSPACE_FONT)) utxo_item = [QStandardItem(x) for x in labels]
utxo_item.setFont(2, QFont(MONOSPACE_FONT)) self.set_editability(utxo_item)
utxo_item.setFont(4, QFont(MONOSPACE_FONT)) utxo_item[0].setFont(QFont(MONOSPACE_FONT))
utxo_item.setData(0, Qt.UserRole, name) utxo_item[2].setFont(QFont(MONOSPACE_FONT))
utxo_item[4].setFont(QFont(MONOSPACE_FONT))
utxo_item[0].setData(name, Qt.UserRole)
if self.wallet.is_frozen(address): if self.wallet.is_frozen(address):
utxo_item.setBackground(0, ColorScheme.BLUE.as_color(True)) utxo_item[0].setBackground(ColorScheme.BLUE.as_color(True))
self.addChild(utxo_item) self.model().insertRow(idx, utxo_item)
def selected_column_0_user_roles(self) -> Optional[List[str]]:
if not self.model():
return None
items = self.selected_in_column(0)
if not items:
return None
return [x.data(Qt.UserRole) for x in items]
def create_menu(self, position): def create_menu(self, position):
selected = [x.data(0, Qt.UserRole) for x in self.selectedItems()] selected = self.selected_column_0_user_roles()
if not selected: if not selected:
return return
menu = QMenu() menu = QMenu()
coins = filter(lambda x: self.get_name(x) in selected, self.utxos) coins = (self.utxo_dict[name] for name in selected)
menu.addAction(_("Spend"), lambda: self.parent.spend_coins(coins)) menu.addAction(_("Spend"), lambda: self.parent.spend_coins(coins))
if len(selected) == 1: if len(selected) == 1:
txid = selected[0].split(':')[0] txid = selected[0].split(':')[0]
tx = self.wallet.transactions.get(txid) tx = self.wallet.transactions.get(txid)
if tx: if tx:
menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx)) label = self.wallet.get_label(txid) or None # Prefer None if empty (None hides the Description: field in the window)
menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx, label))
menu.exec_(self.viewport().mapToGlobal(position)) menu.exec_(self.viewport().mapToGlobal(position))
def on_permit_edit(self, item, column):
# disable editing fields in this tab (labels)
return False

2
electrum/gui/text.py

@ -91,7 +91,7 @@ class ElectrumGui:
self.set_cursor(0) self.set_cursor(0)
return s return s
def update(self, event): def update(self, event, *args):
self.update_history() self.update_history()
if self.tab == 0: if self.tab == 0:
self.print_history() self.print_history()

65
electrum/interface.py

@ -28,7 +28,7 @@ import ssl
import sys import sys
import traceback import traceback
import asyncio import asyncio
from typing import Tuple, Union, List, TYPE_CHECKING from typing import Tuple, Union, List, TYPE_CHECKING, Optional
from collections import defaultdict from collections import defaultdict
import aiorpcx import aiorpcx
@ -140,14 +140,14 @@ def serialize_server(host: str, port: Union[str, int], protocol: str) -> str:
class Interface(PrintError): class Interface(PrintError):
verbosity_filter = 'i' verbosity_filter = 'i'
def __init__(self, network: 'Network', server: str, config_path, proxy: dict): def __init__(self, network: 'Network', server: str, proxy: Optional[dict]):
self.ready = asyncio.Future() self.ready = asyncio.Future()
self.got_disconnected = asyncio.Future() self.got_disconnected = asyncio.Future()
self.server = server self.server = server
self.host, self.port, self.protocol = deserialize_server(self.server) self.host, self.port, self.protocol = deserialize_server(self.server)
self.port = int(self.port) self.port = int(self.port)
self.config_path = config_path assert network.config.path
self.cert_path = os.path.join(self.config_path, 'certs', self.host) self.cert_path = os.path.join(network.config.path, 'certs', self.host)
self.blockchain = None self.blockchain = None
self._requested_chunks = set() self._requested_chunks = set()
self.network = network self.network = network
@ -281,7 +281,7 @@ class Interface(PrintError):
assert self.tip_header assert self.tip_header
chain = blockchain.check_header(self.tip_header) chain = blockchain.check_header(self.tip_header)
if not chain: if not chain:
self.blockchain = blockchain.blockchains[0] self.blockchain = blockchain.get_best_chain()
else: else:
self.blockchain = chain self.blockchain = chain
assert self.blockchain is not None assert self.blockchain is not None
@ -502,7 +502,7 @@ class Interface(PrintError):
# bad_header connects to good_header; bad_header itself is NOT in self.blockchain. # bad_header connects to good_header; bad_header itself is NOT in self.blockchain.
bh = self.blockchain.height() bh = self.blockchain.height()
assert bh >= good assert bh >= good, (bh, good)
if bh == good: if bh == good:
height = good + 1 height = good + 1
self.print_error("catching up from {}".format(height)) self.print_error("catching up from {}".format(height))
@ -510,53 +510,12 @@ class Interface(PrintError):
# this is a new fork we don't yet have # this is a new fork we don't yet have
height = bad + 1 height = bad + 1
branch = blockchain.blockchains.get(bad) self.print_error(f"new fork at bad height {bad}")
if branch is not None: forkfun = self.blockchain.fork if 'mock' not in bad_header else bad_header['mock']['fork']
# Conflict!! As our fork handling is not completely general, b = forkfun(bad_header) # type: Blockchain
# we need to delete another fork to save this one. self.blockchain = b
# Note: This could be a potential DOS vector against Electrum. assert b.forkpoint == bad
# However, mining blocks that satisfy the difficulty requirements return 'fork', height
# is assumed to be expensive; especially as forks below the max
# checkpoint are ignored.
self.print_error("new fork at bad height {}. conflict!!".format(bad))
assert self.blockchain != branch
ismocking = type(branch) is dict
if ismocking:
self.print_error("TODO replace blockchain")
return 'fork_conflict', height
self.print_error('forkpoint conflicts with existing fork', branch.path())
self._raise_if_fork_conflicts_with_default_server(branch)
await self._disconnect_from_interfaces_on_conflicting_blockchain(branch)
branch.write(b'', 0)
branch.save_header(bad_header)
self.blockchain = branch
return 'fork_conflict', height
else:
# No conflict. Just save the new fork.
self.print_error("new fork at bad height {}. NO conflict.".format(bad))
forkfun = self.blockchain.fork if 'mock' not in bad_header else bad_header['mock']['fork']
b = forkfun(bad_header)
with blockchain.blockchains_lock:
assert bad not in blockchain.blockchains, (bad, list(blockchain.blockchains))
blockchain.blockchains[bad] = b
self.blockchain = b
assert b.forkpoint == bad
return 'fork_noconflict', height
def _raise_if_fork_conflicts_with_default_server(self, chain_to_delete: Blockchain) -> None:
main_interface = self.network.interface
if not main_interface: return
if main_interface == self: return
chain_of_default_server = main_interface.blockchain
if not chain_of_default_server: return
if chain_to_delete == chain_of_default_server:
raise GracefulDisconnect('refusing to overwrite blockchain of default server')
async def _disconnect_from_interfaces_on_conflicting_blockchain(self, chain: Blockchain) -> None:
ifaces = await self.network.disconnect_from_interfaces_on_given_blockchain(chain)
if not ifaces: return
servers = [interface.server for interface in ifaces]
self.print_error("forcing disconnect of other interfaces: {}".format(servers))
async def _search_headers_backwards(self, height, header): async def _search_headers_backwards(self, height, header):
async def iterate(): async def iterate():

58
electrum/keystore.py

@ -35,7 +35,7 @@ from .bip32 import (bip32_public_derivation, deserialize_xpub, CKD_pub,
bip32_private_key, bip32_derivation, BIP32_PRIME, bip32_private_key, bip32_derivation, BIP32_PRIME,
is_xpub, is_xprv) is_xpub, is_xprv)
from .ecc import string_to_number, number_to_string from .ecc import string_to_number, number_to_string
from .crypto import pw_decode, pw_encode, sha256d from .crypto import (pw_decode, pw_encode, sha256d, PW_HASH_VERSION_LATEST)
from .util import (PrintError, InvalidPassword, hfu, WalletFileException, from .util import (PrintError, InvalidPassword, hfu, WalletFileException,
BitcoinException, bh2u, bfh, print_error, inv_dict) BitcoinException, bh2u, bfh, print_error, inv_dict)
from .mnemonic import Mnemonic, load_wordlist from .mnemonic import Mnemonic, load_wordlist
@ -92,8 +92,9 @@ class KeyStore(PrintError):
class Software_KeyStore(KeyStore): class Software_KeyStore(KeyStore):
def __init__(self): def __init__(self, d):
KeyStore.__init__(self) KeyStore.__init__(self)
self.pw_hash_version = d.get('pw_hash_version', 1)
def may_have_password(self): def may_have_password(self):
return not self.is_watching_only() return not self.is_watching_only()
@ -122,6 +123,12 @@ class Software_KeyStore(KeyStore):
if keypairs: if keypairs:
tx.sign(keypairs) tx.sign(keypairs)
def update_password(self, old_password, new_password):
raise NotImplementedError() # implemented by subclasses
def check_password(self, password):
raise NotImplementedError() # implemented by subclasses
class Imported_KeyStore(Software_KeyStore): class Imported_KeyStore(Software_KeyStore):
# keystore for imported private keys # keystore for imported private keys
@ -129,7 +136,7 @@ class Imported_KeyStore(Software_KeyStore):
type = 'imported' type = 'imported'
def __init__(self, d): def __init__(self, d):
Software_KeyStore.__init__(self) Software_KeyStore.__init__(self, d)
self.keypairs = d.get('keypairs', {}) self.keypairs = d.get('keypairs', {})
def is_deterministic(self): def is_deterministic(self):
@ -142,6 +149,7 @@ class Imported_KeyStore(Software_KeyStore):
return { return {
'type': self.type, 'type': self.type,
'keypairs': self.keypairs, 'keypairs': self.keypairs,
'pw_hash_version': self.pw_hash_version,
} }
def can_import(self): def can_import(self):
@ -161,14 +169,14 @@ class Imported_KeyStore(Software_KeyStore):
# there will only be one pubkey-privkey pair for it in self.keypairs, # 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. # and the privkey will encode a txin_type but that txin_type cannot be trusted.
# Removing keys complicates this further. # Removing keys complicates this further.
self.keypairs[pubkey] = pw_encode(serialized_privkey, password) self.keypairs[pubkey] = pw_encode(serialized_privkey, password, version=self.pw_hash_version)
return txin_type, pubkey return txin_type, pubkey
def delete_imported_key(self, key): def delete_imported_key(self, key):
self.keypairs.pop(key) self.keypairs.pop(key)
def get_private_key(self, pubkey, password): def get_private_key(self, pubkey, password):
sec = pw_decode(self.keypairs[pubkey], password) sec = pw_decode(self.keypairs[pubkey], password, version=self.pw_hash_version)
txin_type, privkey, compressed = deserialize_privkey(sec) txin_type, privkey, compressed = deserialize_privkey(sec)
# this checks the password # this checks the password
if pubkey != ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed): if pubkey != ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed):
@ -189,16 +197,17 @@ class Imported_KeyStore(Software_KeyStore):
if new_password == '': if new_password == '':
new_password = None new_password = None
for k, v in self.keypairs.items(): for k, v in self.keypairs.items():
b = pw_decode(v, old_password) b = pw_decode(v, old_password, version=self.pw_hash_version)
c = pw_encode(b, new_password) c = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
self.keypairs[k] = c self.keypairs[k] = c
self.pw_hash_version = PW_HASH_VERSION_LATEST
class Deterministic_KeyStore(Software_KeyStore): class Deterministic_KeyStore(Software_KeyStore):
def __init__(self, d): def __init__(self, d):
Software_KeyStore.__init__(self) Software_KeyStore.__init__(self, d)
self.seed = d.get('seed', '') self.seed = d.get('seed', '')
self.passphrase = d.get('passphrase', '') self.passphrase = d.get('passphrase', '')
@ -206,12 +215,14 @@ class Deterministic_KeyStore(Software_KeyStore):
return True return True
def dump(self): def dump(self):
d = {} d = {
'type': self.type,
'pw_hash_version': self.pw_hash_version,
}
if self.seed: if self.seed:
d['seed'] = self.seed d['seed'] = self.seed
if self.passphrase: if self.passphrase:
d['passphrase'] = self.passphrase d['passphrase'] = self.passphrase
d['type'] = self.type
return d return d
def has_seed(self): def has_seed(self):
@ -226,10 +237,13 @@ class Deterministic_KeyStore(Software_KeyStore):
self.seed = self.format_seed(seed) self.seed = self.format_seed(seed)
def get_seed(self, password): def get_seed(self, password):
return pw_decode(self.seed, password) return pw_decode(self.seed, password, version=self.pw_hash_version)
def get_passphrase(self, password): def get_passphrase(self, password):
return pw_decode(self.passphrase, password) if self.passphrase else '' if self.passphrase:
return pw_decode(self.passphrase, password, version=self.pw_hash_version)
else:
return ''
class Xpub: class Xpub:
@ -312,10 +326,10 @@ class BIP32_KeyStore(Deterministic_KeyStore, Xpub):
return d return d
def get_master_private_key(self, password): def get_master_private_key(self, password):
return pw_decode(self.xprv, password) return pw_decode(self.xprv, password, version=self.pw_hash_version)
def check_password(self, password): def check_password(self, password):
xprv = pw_decode(self.xprv, password) xprv = pw_decode(self.xprv, password, version=self.pw_hash_version)
if deserialize_xprv(xprv)[4] != deserialize_xpub(self.xpub)[4]: if deserialize_xprv(xprv)[4] != deserialize_xpub(self.xpub)[4]:
raise InvalidPassword() raise InvalidPassword()
@ -325,13 +339,14 @@ class BIP32_KeyStore(Deterministic_KeyStore, Xpub):
new_password = None new_password = None
if self.has_seed(): if self.has_seed():
decoded = self.get_seed(old_password) decoded = self.get_seed(old_password)
self.seed = pw_encode(decoded, new_password) self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
if self.passphrase: if self.passphrase:
decoded = self.get_passphrase(old_password) decoded = self.get_passphrase(old_password)
self.passphrase = pw_encode(decoded, new_password) self.passphrase = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
if self.xprv is not None: if self.xprv is not None:
b = pw_decode(self.xprv, old_password) b = pw_decode(self.xprv, old_password, version=self.pw_hash_version)
self.xprv = pw_encode(b, new_password) 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): def is_watching_only(self):
return self.xprv is None return self.xprv is None
@ -362,7 +377,7 @@ class Old_KeyStore(Deterministic_KeyStore):
self.mpk = d.get('mpk') self.mpk = d.get('mpk')
def get_hex_seed(self, password): def get_hex_seed(self, password):
return pw_decode(self.seed, password).encode('utf8') return pw_decode(self.seed, password, version=self.pw_hash_version).encode('utf8')
def dump(self): def dump(self):
d = Deterministic_KeyStore.dump(self) d = Deterministic_KeyStore.dump(self)
@ -484,8 +499,9 @@ class Old_KeyStore(Deterministic_KeyStore):
if new_password == '': if new_password == '':
new_password = None new_password = None
if self.has_seed(): if self.has_seed():
decoded = pw_decode(self.seed, old_password) decoded = pw_decode(self.seed, old_password, version=self.pw_hash_version)
self.seed = pw_encode(decoded, new_password) self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
self.pw_hash_version = PW_HASH_VERSION_LATEST

45
electrum/network.py

@ -44,6 +44,7 @@ from .util import PrintError, print_error, log_exceptions, ignore_exceptions, bf
from .bitcoin import COIN from .bitcoin import COIN
from . import constants from . import constants
from . import blockchain from . import blockchain
from . import bitcoin
from .blockchain import Blockchain, HEADER_SIZE from .blockchain import Blockchain, HEADER_SIZE
from .interface import Interface, serialize_server, deserialize_server, RequestTimedOut from .interface import Interface, serialize_server, deserialize_server, RequestTimedOut
from .version import PROTOCOL_VERSION from .version import PROTOCOL_VERSION
@ -177,10 +178,10 @@ class Network(PrintError):
if config is None: if config is None:
config = {} # Do not use mutables as default values! config = {} # Do not use mutables as default values!
self.config = SimpleConfig(config) if isinstance(config, dict) else config # type: SimpleConfig self.config = SimpleConfig(config) if isinstance(config, dict) else config # type: SimpleConfig
blockchain.blockchains = blockchain.read_blockchains(self.config) blockchain.read_blockchains(self.config)
self.print_error("blockchains", list(blockchain.blockchains)) self.print_error("blockchains", list(map(lambda b: b.forkpoint, blockchain.blockchains.values())))
self._blockchain_preferred_block = self.config.get('blockchain_preferred_block', None) # type: Optional[Dict] self._blockchain_preferred_block = self.config.get('blockchain_preferred_block', None) # type: Optional[Dict]
self._blockchain_index = 0 self._blockchain = blockchain.get_best_chain()
# Server for addresses and transactions # Server for addresses and transactions
self.default_server = self.config.get('server', None) self.default_server = self.config.get('server', None)
# Sanitize default server # Sanitize default server
@ -321,7 +322,11 @@ class Network(PrintError):
self.banner = await session.send_request('server.banner') self.banner = await session.send_request('server.banner')
self.notify('banner') self.notify('banner')
async def get_donation_address(): async def get_donation_address():
self.donation_address = await session.send_request('server.donation_address') addr = await session.send_request('server.donation_address')
if not bitcoin.is_address(addr):
self.print_error(f"invalid donation address from server: {addr}")
addr = ''
self.donation_address = addr
async def get_server_peers(): async def get_server_peers():
self.server_peers = parse_servers(await session.send_request('server.peers.subscribe')) self.server_peers = parse_servers(await session.send_request('server.peers.subscribe'))
self.notify('servers') self.notify('servers')
@ -559,17 +564,24 @@ class Network(PrintError):
filtered = list(filter(lambda iface: iface.blockchain.check_hash(pref_height, pref_hash), filtered = list(filter(lambda iface: iface.blockchain.check_hash(pref_height, pref_hash),
interfaces)) interfaces))
if filtered: if filtered:
self.print_error("switching to preferred fork")
chosen_iface = random.choice(filtered) chosen_iface = random.choice(filtered)
await self.switch_to_interface(chosen_iface.server) await self.switch_to_interface(chosen_iface.server)
return return
# try to switch to longest chain else:
if self.blockchain().parent_id is None: self.print_error("tried to switch to preferred fork but no interfaces are on it")
return # already on longest chain # try to switch to best chain
filtered = list(filter(lambda iface: iface.blockchain.parent_id is None, if self.blockchain().parent is None:
return # already on best chain
filtered = list(filter(lambda iface: iface.blockchain.parent is None,
interfaces)) interfaces))
if filtered: if filtered:
self.print_error("switching to best chain")
chosen_iface = random.choice(filtered) chosen_iface = random.choice(filtered)
await self.switch_to_interface(chosen_iface.server) await self.switch_to_interface(chosen_iface.server)
else:
# FIXME switch to best available?
self.print_error("tried to switch to best chain but no interfaces are on it")
async def switch_to_interface(self, server: str): async def switch_to_interface(self, server: str):
"""Switch to server as our main interface. If no connection exists, """Switch to server as our main interface. If no connection exists,
@ -637,7 +649,7 @@ class Network(PrintError):
@ignore_exceptions # do not kill main_taskgroup @ignore_exceptions # do not kill main_taskgroup
@log_exceptions @log_exceptions
async def _run_new_interface(self, server): async def _run_new_interface(self, server):
interface = Interface(self, server, self.config.path, self.proxy) interface = Interface(self, server, self.proxy)
timeout = 10 if not self.proxy else 20 timeout = 10 if not self.proxy else 20
try: try:
await asyncio.wait_for(interface.ready, timeout) await asyncio.wait_for(interface.ready, timeout)
@ -661,7 +673,7 @@ class Network(PrintError):
self.trigger_callback('network_updated') self.trigger_callback('network_updated')
async def _init_headers_file(self): async def _init_headers_file(self):
b = blockchain.blockchains[0] b = blockchain.get_best_chain()
filename = b.path() filename = b.path()
length = HEADER_SIZE * len(constants.net.CHECKPOINTS) * 2016 length = HEADER_SIZE * len(constants.net.CHECKPOINTS) * 2016
if not os.path.exists(filename) or os.path.getsize(filename) < length: if not os.path.exists(filename) or os.path.getsize(filename) < length:
@ -739,8 +751,8 @@ class Network(PrintError):
def blockchain(self) -> Blockchain: def blockchain(self) -> Blockchain:
interface = self.interface interface = self.interface
if interface and interface.blockchain is not None: if interface and interface.blockchain is not None:
self._blockchain_index = interface.blockchain.forkpoint self._blockchain = interface.blockchain
return blockchain.blockchains[self._blockchain_index] return self._blockchain
def get_blockchains(self): def get_blockchains(self):
out = {} # blockchain_id -> list(interfaces) out = {} # blockchain_id -> list(interfaces)
@ -752,13 +764,6 @@ class Network(PrintError):
out[chain_id] = r out[chain_id] = r
return out return out
async def disconnect_from_interfaces_on_given_blockchain(self, chain: Blockchain) -> Sequence[Interface]:
chain_id = chain.forkpoint
ifaces = self.get_blockchains().get(chain_id) or []
for interface in ifaces:
await self.connection_down(interface.server)
return ifaces
def _set_preferred_chain(self, chain: Blockchain): def _set_preferred_chain(self, chain: Blockchain):
height = chain.get_max_forkpoint() height = chain.get_max_forkpoint()
header_hash = chain.get_hash(height) header_hash = chain.get_hash(height)
@ -768,7 +773,7 @@ class Network(PrintError):
} }
self.config.set_key('blockchain_preferred_block', self._blockchain_preferred_block) self.config.set_key('blockchain_preferred_block', self._blockchain_preferred_block)
async def follow_chain_given_id(self, chain_id: int) -> None: async def follow_chain_given_id(self, chain_id: str) -> None:
bc = blockchain.blockchains.get(chain_id) bc = blockchain.blockchains.get(chain_id)
if not bc: if not bc:
raise Exception('blockchain {} not found'.format(chain_id)) raise Exception('blockchain {} not found'.format(chain_id))

11
electrum/plugins/coldcard/coldcard.py

@ -118,6 +118,8 @@ class CKCCClient:
or (self.dev.master_fingerprint != expected_xfp) or (self.dev.master_fingerprint != expected_xfp)
or (self.dev.master_xpub != expected_xpub)): or (self.dev.master_xpub != expected_xpub)):
# probably indicating programing error, not hacking # probably indicating programing error, not hacking
print_error("[coldcard]", f"xpubs. reported by device: {self.dev.master_xpub}. "
f"stored in file: {expected_xpub}")
raise RuntimeError("Expecting 0x%08x but that's not whats connected?!" % raise RuntimeError("Expecting 0x%08x but that's not whats connected?!" %
expected_xfp) expected_xfp)
@ -454,9 +456,12 @@ class Coldcard_KeyStore(Hardware_KeyStore):
# inputs section # inputs section
for txin in inputs: for txin in inputs:
utxo = txin['prev_tx'].outputs()[txin['prevout_n']] if Transaction.is_segwit_input(txin):
spendable = txin['prev_tx'].serialize_output(utxo) utxo = txin['prev_tx'].outputs()[txin['prevout_n']]
write_kv(PSBT_IN_WITNESS_UTXO, spendable) spendable = txin['prev_tx'].serialize_output(utxo)
write_kv(PSBT_IN_WITNESS_UTXO, spendable)
else:
write_kv(PSBT_IN_NON_WITNESS_UTXO, str(txin['prev_tx']))
pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin) pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin)

8
electrum/plugins/digitalbitbox/digitalbitbox.py

@ -4,7 +4,7 @@
# #
try: try:
from electrum.crypto import sha256d, EncodeAES, DecodeAES from electrum.crypto import sha256d, EncodeAES_base64, DecodeAES_base64
from electrum.bitcoin import (TYPE_ADDRESS, push_script, var_int, public_key_to_p2pkh, from electrum.bitcoin import (TYPE_ADDRESS, push_script, var_int, public_key_to_p2pkh,
is_address) is_address)
from electrum.bip32 import serialize_xpub, deserialize_xpub from electrum.bip32 import serialize_xpub, deserialize_xpub
@ -396,10 +396,10 @@ class DigitalBitbox_Client():
reply = "" reply = ""
try: try:
secret = sha256d(self.password) secret = sha256d(self.password)
msg = EncodeAES(secret, msg) msg = EncodeAES_base64(secret, msg)
reply = self.hid_send_plain(msg) reply = self.hid_send_plain(msg)
if 'ciphertext' in reply: if 'ciphertext' in reply:
reply = DecodeAES(secret, ''.join(reply["ciphertext"])) reply = DecodeAES_base64(secret, ''.join(reply["ciphertext"]))
reply = to_string(reply, 'utf8') reply = to_string(reply, 'utf8')
reply = json.loads(reply) reply = json.loads(reply)
if 'error' in reply: if 'error' in reply:
@ -716,7 +716,7 @@ class DigitalBitboxPlugin(HW_PluginBase):
key_s = base64.b64decode(self.digitalbitbox_config['encryptionprivkey']) key_s = base64.b64decode(self.digitalbitbox_config['encryptionprivkey'])
args = 'c=data&s=0&dt=0&uuid=%s&pl=%s' % ( args = 'c=data&s=0&dt=0&uuid=%s&pl=%s' % (
self.digitalbitbox_config['comserverchannelid'], self.digitalbitbox_config['comserverchannelid'],
EncodeAES(key_s, json.dumps(payload).encode('ascii')).decode('ascii'), EncodeAES_base64(key_s, json.dumps(payload).encode('ascii')).decode('ascii'),
) )
try: try:
requests.post(url, args) requests.post(url, args)

14
electrum/plugins/hw_wallet/qt.py

@ -27,7 +27,8 @@
import threading import threading
from PyQt5.Qt import QVBoxLayout, QLabel from PyQt5.Qt import QVBoxLayout, QLabel
from electrum.gui.qt.password_dialog import PasswordDialog, PW_PASSPHRASE
from electrum.gui.qt.password_dialog import PasswordLayout, PW_PASSPHRASE
from electrum.gui.qt.util import * from electrum.gui.qt.util import *
from electrum.i18n import _ from electrum.i18n import _
@ -114,11 +115,16 @@ class QtHandlerBase(QObject, PrintError):
def passphrase_dialog(self, msg, confirm): def passphrase_dialog(self, msg, confirm):
# If confirm is true, require the user to enter the passphrase twice # If confirm is true, require the user to enter the passphrase twice
parent = self.top_level_window() parent = self.top_level_window()
d = WindowModalDialog(parent, _("Enter Passphrase"))
if confirm: if confirm:
d = PasswordDialog(parent, None, msg, PW_PASSPHRASE) OK_button = OkButton(d)
confirmed, p, passphrase = d.run() 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: else:
d = WindowModalDialog(parent, _("Enter Passphrase"))
pw = QLineEdit() pw = QLineEdit()
pw.setEchoMode(2) pw.setEchoMode(2)
pw.setMinimumWidth(200) pw.setMinimumWidth(200)

5
electrum/plugins/labels/labels.py

@ -73,7 +73,10 @@ class LabelsPlugin(BasePlugin):
url = 'https://' + self.target_host + url url = 'https://' + self.target_host + url
async with make_aiohttp_session(self.proxy) as session: async with make_aiohttp_session(self.proxy) as session:
async with session.post(url, json=data) as result: async with session.post(url, json=data) as result:
return await result.json() try:
return await result.json()
except Exception as e:
raise Exception('Could not decode: ' + await result.text()) from e
async def push_thread(self, wallet): async def push_thread(self, wallet):
wallet_data = self.wallets.get(wallet, None) wallet_data = self.wallets.get(wallet, None)

25
electrum/qrscanner.py

@ -40,7 +40,7 @@ except BaseException:
libzbar = None libzbar = None
def scan_barcode(device='', timeout=-1, display=True, threaded=False, try_again=True): def scan_barcode_ctypes(device='', timeout=-1, display=True, threaded=False, try_again=True):
if libzbar is None: if libzbar is None:
raise RuntimeError("Cannot start QR scanner; zbar not available.") raise RuntimeError("Cannot start QR scanner; zbar not available.")
libzbar.zbar_symbol_get_data.restype = ctypes.c_char_p libzbar.zbar_symbol_get_data.restype = ctypes.c_char_p
@ -69,6 +69,29 @@ def scan_barcode(device='', timeout=-1, display=True, threaded=False, try_again=
data = libzbar.zbar_symbol_get_data(symbol) data = libzbar.zbar_symbol_get_data(symbol)
return data.decode('utf8') return data.decode('utf8')
def scan_barcode_osx(*args_ignored, **kwargs_ignored):
import subprocess
# NOTE: This code needs to be modified if the positions of this file changes with respect to the helper app!
# This assumes the built macOS .app bundle which ends up putting the helper app in
# .app/contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app.
root_ec_dir = os.path.abspath(os.path.dirname(__file__) + "/../")
prog = root_ec_dir + "/" + "contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app/Contents/MacOS/CalinsQRReader"
if not os.path.exists(prog):
raise RuntimeError("Cannot start QR scanner; helper app not found.")
data = ''
try:
# This will run the "CalinsQRReader" helper app (which also gets bundled with the built .app)
# Just like the zbar implementation -- the main app will hang until the QR window returns a QR code
# (or is closed). Communication with the subprocess is done via stdout.
# See contrib/CalinsQRReader for the helper app source code.
with subprocess.Popen([prog], stdout=subprocess.PIPE) as p:
data = p.stdout.read().decode('utf-8').strip()
return data
except OSError as e:
raise RuntimeError("Cannot start camera helper app; {}".format(e.strerror))
scan_barcode = scan_barcode_osx if sys.platform == 'darwin' else scan_barcode_ctypes
def _find_system_cameras(): def _find_system_cameras():
device_root = "/sys/class/video4linux" device_root = "/sys/class/video4linux"
devices = {} # Name -> device devices = {} # Name -> device

7
electrum/storage.py

@ -122,12 +122,7 @@ class JsonDB(PrintError):
os.fsync(f.fileno()) os.fsync(f.fileno())
mode = os.stat(self.path).st_mode if os.path.exists(self.path) else stat.S_IREAD | stat.S_IWRITE mode = os.stat(self.path).st_mode if os.path.exists(self.path) else stat.S_IREAD | stat.S_IWRITE
# perform atomic write on POSIX systems os.replace(temp_path, self.path)
try:
os.rename(temp_path, self.path)
except:
os.remove(self.path)
os.rename(temp_path, self.path)
os.chmod(self.path, mode) os.chmod(self.path, mode)
self.print_error("saved", self.path) self.print_error("saved", self.path)
self.modified = False self.modified = False

27
electrum/tests/test_bitcoin.py

@ -11,11 +11,11 @@ from electrum.bitcoin import (public_key_to_p2pkh, address_from_private_key,
from electrum.bip32 import (bip32_root, bip32_public_derivation, bip32_private_derivation, from electrum.bip32 import (bip32_root, bip32_public_derivation, bip32_private_derivation,
xpub_from_xprv, xpub_type, is_xprv, is_bip32_derivation, xpub_from_xprv, xpub_type, is_xprv, is_bip32_derivation,
is_xpub, convert_bip32_path_to_list_of_uint32) is_xpub, convert_bip32_path_to_list_of_uint32)
from electrum.crypto import sha256d from electrum.crypto import sha256d, KNOWN_PW_HASH_VERSIONS
from electrum import ecc, crypto, constants from electrum import ecc, crypto, constants
from electrum.ecc import number_to_string, string_to_number from electrum.ecc import number_to_string, string_to_number
from electrum.transaction import opcodes from electrum.transaction import opcodes
from electrum.util import bfh, bh2u from electrum.util import bfh, bh2u, InvalidPassword
from electrum.storage import WalletStorage from electrum.storage import WalletStorage
from electrum.keystore import xtype_from_derivation from electrum.keystore import xtype_from_derivation
@ -219,23 +219,26 @@ class Test_bitcoin(SequentialTestCase):
"""Make sure AES is homomorphic.""" """Make sure AES is homomorphic."""
payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0' payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
password = u'secret' password = u'secret'
enc = crypto.pw_encode(payload, password) for version in KNOWN_PW_HASH_VERSIONS:
dec = crypto.pw_decode(enc, password) enc = crypto.pw_encode(payload, password, version=version)
self.assertEqual(dec, payload) dec = crypto.pw_decode(enc, password, version=version)
self.assertEqual(dec, payload)
@needs_test_with_all_aes_implementations @needs_test_with_all_aes_implementations
def test_aes_encode_without_password(self): def test_aes_encode_without_password(self):
"""When not passed a password, pw_encode is noop on the payload.""" """When not passed a password, pw_encode is noop on the payload."""
payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0' payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
enc = crypto.pw_encode(payload, None) for version in KNOWN_PW_HASH_VERSIONS:
self.assertEqual(payload, enc) enc = crypto.pw_encode(payload, None, version=version)
self.assertEqual(payload, enc)
@needs_test_with_all_aes_implementations @needs_test_with_all_aes_implementations
def test_aes_deencode_without_password(self): def test_aes_deencode_without_password(self):
"""When not passed a password, pw_decode is noop on the payload.""" """When not passed a password, pw_decode is noop on the payload."""
payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0' payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
enc = crypto.pw_decode(payload, None) for version in KNOWN_PW_HASH_VERSIONS:
self.assertEqual(payload, enc) enc = crypto.pw_decode(payload, None, version=version)
self.assertEqual(payload, enc)
@needs_test_with_all_aes_implementations @needs_test_with_all_aes_implementations
def test_aes_decode_with_invalid_password(self): def test_aes_decode_with_invalid_password(self):
@ -243,8 +246,10 @@ class Test_bitcoin(SequentialTestCase):
payload = u"blah" payload = u"blah"
password = u"uber secret" password = u"uber secret"
wrong_password = u"not the password" wrong_password = u"not the password"
enc = crypto.pw_encode(payload, password) for version in KNOWN_PW_HASH_VERSIONS:
self.assertRaises(Exception, crypto.pw_decode, enc, wrong_password) enc = crypto.pw_encode(payload, password, version=version)
with self.assertRaises(InvalidPassword):
crypto.pw_decode(enc, wrong_password, version=version)
def test_sha256d(self): def test_sha256d(self):
self.assertEqual(b'\x95MZI\xfdp\xd9\xb8\xbc\xdb5\xd2R&x)\x95\x7f~\xf7\xfalt\xf8\x84\x19\xbd\xc5\xe8"\t\xf4', self.assertEqual(b'\x95MZI\xfdp\xd9\xb8\xbc\xdb5\xd2R&x)\x95\x7f~\xf7\xfalt\xf8\x84\x19\xbd\xc5\xe8"\t\xf4',

239
electrum/tests/test_blockchain.py

@ -0,0 +1,239 @@
import shutil
import tempfile
import os
from electrum import constants, blockchain
from electrum.simple_config import SimpleConfig
from electrum.blockchain import Blockchain, deserialize_header, hash_header
from electrum.util import bh2u, bfh, make_dir
from . import SequentialTestCase
class TestBlockchain(SequentialTestCase):
HEADERS = {
'A': deserialize_header(bfh("0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000"), 0),
'B': deserialize_header(bfh("0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f186c8dfd970a4545f79916bc1d75c9d00432f57c89209bf3bb115b7612848f509c25f45bffff7f2000000000"), 1),
'C': deserialize_header(bfh("00000020686bdfc6a3db73d5d93e8c9663a720a26ecb1ef20eb05af11b36cdbc57c19f7ebf2cbf153013a1c54abaf70e95198fcef2f3059cc6b4d0f7e876808e7d24d11cc825f45bffff7f2000000000"), 2),
'D': deserialize_header(bfh("00000020122baa14f3ef54985ae546d1611559e3f487bd2a0f46e8dbb52fbacc9e237972e71019d7feecd9b8596eca9a67032c5f4641b23b5d731dc393e37de7f9c2f299e725f45bffff7f2000000000"), 3),
'E': deserialize_header(bfh("00000020f8016f7ef3a17d557afe05d4ea7ab6bde1b2247b7643896c1b63d43a1598b747a3586da94c71753f27c075f57f44faf913c31177a0957bbda42e7699e3a2141aed25f45bffff7f2001000000"), 4),
'F': deserialize_header(bfh("000000201d589c6643c1d121d73b0573e5ee58ab575b8fdf16d507e7e915c5fbfbbfd05e7aee1d692d1615c3bdf52c291032144ce9e3b258a473c17c745047f3431ff8e2ee25f45bffff7f2000000000"), 5),
'O': deserialize_header(bfh("00000020b833ed46eea01d4c980f59feee44a66aa1162748b6801029565d1466790c405c3a141ce635cbb1cd2b3a4fcdd0a3380517845ba41736c82a79cab535d31128066526f45bffff7f2001000000"), 6),
'P': deserialize_header(bfh("00000020abe8e119d1877c9dc0dc502d1a253fb9a67967c57732d2f71ee0280e8381ff0a9690c2fe7c1a4450c74dc908fe94dd96c3b0637d51475e9e06a78e944a0c7fe28126f45bffff7f2000000000"), 7),
'Q': deserialize_header(bfh("000000202ce41d94eb70e1518bc1f72523f84a903f9705d967481e324876e1f8cf4d3452148be228a4c3f2061bafe7efdfc4a8d5a94759464b9b5c619994d45dfcaf49e1a126f45bffff7f2000000000"), 8),
'R': deserialize_header(bfh("00000020552755b6c59f3d51e361d16281842a4e166007799665b5daed86a063dd89857415681cb2d00ff889193f6a68a93f5096aeb2d84ca0af6185a462555822552221a626f45bffff7f2000000000"), 9),
'S': deserialize_header(bfh("00000020a13a491cbefc93cd1bb1938f19957e22a134faf14c7dee951c45533e2c750f239dc087fc977b06c24a69c682d1afd1020e6dc1f087571ccec66310a786e1548fab26f45bffff7f2000000000"), 10),
'T': deserialize_header(bfh("00000020dbf3a9b55dfefbaf8b6e43a89cf833fa2e208bbc0c1c5d76c0d71b9e4a65337803b243756c25053253aeda309604363460a3911015929e68705bd89dff6fe064b026f45bffff7f2002000000"), 11),
'U': deserialize_header(bfh("000000203d0932b3b0c78eccb39a595a28ae4a7c966388648d7783fd1305ec8d40d4fe5fd67cb902a7d807cee7676cb543feec3e053aa824d5dfb528d5b94f9760313d9db726f45bffff7f2001000000"), 12),
'G': deserialize_header(bfh("00000020b833ed46eea01d4c980f59feee44a66aa1162748b6801029565d1466790c405c3a141ce635cbb1cd2b3a4fcdd0a3380517845ba41736c82a79cab535d31128066928f45bffff7f2001000000"), 6),
'H': deserialize_header(bfh("00000020e19e687f6e7f83ca394c114144dbbbc4f3f9c9450f66331a125413702a2e1a719690c2fe7c1a4450c74dc908fe94dd96c3b0637d51475e9e06a78e944a0c7fe26a28f45bffff7f2002000000"), 7),
'I': deserialize_header(bfh("0000002009dcb3b158293c89d7cf7ceeb513add122ebc3880a850f47afbb2747f5e48c54148be228a4c3f2061bafe7efdfc4a8d5a94759464b9b5c619994d45dfcaf49e16a28f45bffff7f2000000000"), 8),
'J': deserialize_header(bfh("000000206a65f3bdd3374a5a6c4538008ba0b0a560b8566291f9ef4280ab877627a1742815681cb2d00ff889193f6a68a93f5096aeb2d84ca0af6185a462555822552221c928f45bffff7f2000000000"), 9),
'K': deserialize_header(bfh("00000020bb3b421653548991998f96f8ba486b652fdb07ca16e9cee30ece033547cd1a6e9dc087fc977b06c24a69c682d1afd1020e6dc1f087571ccec66310a786e1548fca28f45bffff7f2000000000"), 10),
'L': deserialize_header(bfh("00000020c391d74d37c24a130f4bf4737932bdf9e206dd4fad22860ec5408978eb55d46303b243756c25053253aeda309604363460a3911015929e68705bd89dff6fe064ca28f45bffff7f2000000000"), 11),
'M': deserialize_header(bfh("000000206a65f3bdd3374a5a6c4538008ba0b0a560b8566291f9ef4280ab877627a1742815681cb2d00ff889193f6a68a93f5096aeb2d84ca0af6185a4625558225522214229f45bffff7f2000000000"), 9),
'N': deserialize_header(bfh("00000020383dab38b57f98aa9b4f0d5ff868bc674b4828d76766bf048296f4c45fff680a9dc087fc977b06c24a69c682d1afd1020e6dc1f087571ccec66310a786e1548f4329f45bffff7f2003000000"), 10),
'X': deserialize_header(bfh("0000002067f1857f54b7fef732cb4940f7d1b339472b3514660711a820330fd09d8fba6b03b243756c25053253aeda309604363460a3911015929e68705bd89dff6fe0649b29f45bffff7f2002000000"), 11),
'Y': deserialize_header(bfh("00000020db33c9768a9e5f7c37d0f09aad88d48165946c87d08f7d63793f07b5c08c527fd67cb902a7d807cee7676cb543feec3e053aa824d5dfb528d5b94f9760313d9d9b29f45bffff7f2000000000"), 12),
'Z': deserialize_header(bfh("0000002047822b67940e337fda38be6f13390b3596e4dea2549250256879722073824e7f0f2596c29203f8a0f71ae94193092dc8f113be3dbee4579f1e649fa3d6dcc38c622ef45bffff7f2003000000"), 13),
}
# tree of headers:
# - M <- N <- X <- Y <- Z
# /
# - G <- H <- I <- J <- K <- L
# /
# A <- B <- C <- D <- E <- F <- O <- P <- Q <- R <- S <- T <- U
@classmethod
def setUpClass(cls):
super().setUpClass()
constants.set_regtest()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
constants.set_mainnet()
def setUp(self):
super().setUp()
self.data_dir = tempfile.mkdtemp()
make_dir(os.path.join(self.data_dir, 'forks'))
self.config = SimpleConfig({'electrum_path': self.data_dir})
blockchain.blockchains = {}
def tearDown(self):
super().tearDown()
shutil.rmtree(self.data_dir)
def _append_header(self, chain: Blockchain, header: dict):
self.assertTrue(chain.can_connect(header))
chain.save_header(header)
def test_forking_and_swapping(self):
blockchain.blockchains[constants.net.GENESIS] = chain_u = Blockchain(
config=self.config, forkpoint=0, parent=None,
forkpoint_hash=constants.net.GENESIS, prev_hash=None)
open(chain_u.path(), 'w+').close()
self._append_header(chain_u, self.HEADERS['A'])
self._append_header(chain_u, self.HEADERS['B'])
self._append_header(chain_u, self.HEADERS['C'])
self._append_header(chain_u, self.HEADERS['D'])
self._append_header(chain_u, self.HEADERS['E'])
self._append_header(chain_u, self.HEADERS['F'])
self._append_header(chain_u, self.HEADERS['O'])
self._append_header(chain_u, self.HEADERS['P'])
self._append_header(chain_u, self.HEADERS['Q'])
self._append_header(chain_u, self.HEADERS['R'])
chain_l = chain_u.fork(self.HEADERS['G'])
self._append_header(chain_l, self.HEADERS['H'])
self._append_header(chain_l, self.HEADERS['I'])
self._append_header(chain_l, self.HEADERS['J'])
# do checks
self.assertEqual(2, len(blockchain.blockchains))
self.assertEqual(1, len(os.listdir(os.path.join(self.data_dir, "forks"))))
self.assertEqual(0, chain_u.forkpoint)
self.assertEqual(None, chain_u.parent)
self.assertEqual(constants.net.GENESIS, chain_u._forkpoint_hash)
self.assertEqual(None, chain_u._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "blockchain_headers"), chain_u.path())
self.assertEqual(10 * 80, os.stat(chain_u.path()).st_size)
self.assertEqual(6, chain_l.forkpoint)
self.assertEqual(chain_u, chain_l.parent)
self.assertEqual(hash_header(self.HEADERS['G']), chain_l._forkpoint_hash)
self.assertEqual(hash_header(self.HEADERS['F']), chain_l._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "forks", "fork2_6_5c400c7966145d56291080b6482716a16aa644eefe590f984c1da0ee46ed33b8_711a2e2a701354121a33660f45c9f9f3c4bbdb4441114c39ca837f6e7f689ee1"), chain_l.path())
self.assertEqual(4 * 80, os.stat(chain_l.path()).st_size)
self._append_header(chain_l, self.HEADERS['K'])
# chains were swapped, do checks
self.assertEqual(2, len(blockchain.blockchains))
self.assertEqual(1, len(os.listdir(os.path.join(self.data_dir, "forks"))))
self.assertEqual(6, chain_u.forkpoint)
self.assertEqual(chain_l, chain_u.parent)
self.assertEqual(hash_header(self.HEADERS['O']), chain_u._forkpoint_hash)
self.assertEqual(hash_header(self.HEADERS['F']), chain_u._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "forks", "fork2_6_5c400c7966145d56291080b6482716a16aa644eefe590f984c1da0ee46ed33b8_aff81830e28e01ef7d23277c56779a6b93f251a2d50dcc09d7c87d119e1e8ab"), chain_u.path())
self.assertEqual(4 * 80, os.stat(chain_u.path()).st_size)
self.assertEqual(0, chain_l.forkpoint)
self.assertEqual(None, chain_l.parent)
self.assertEqual(constants.net.GENESIS, chain_l._forkpoint_hash)
self.assertEqual(None, chain_l._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "blockchain_headers"), chain_l.path())
self.assertEqual(11 * 80, os.stat(chain_l.path()).st_size)
for b in (chain_u, chain_l):
self.assertTrue(all([b.can_connect(b.read_header(i), False) for i in range(b.height())]))
self._append_header(chain_u, self.HEADERS['S'])
self._append_header(chain_u, self.HEADERS['T'])
self._append_header(chain_u, self.HEADERS['U'])
self._append_header(chain_l, self.HEADERS['L'])
chain_z = chain_l.fork(self.HEADERS['M'])
self._append_header(chain_z, self.HEADERS['N'])
self._append_header(chain_z, self.HEADERS['X'])
self._append_header(chain_z, self.HEADERS['Y'])
self._append_header(chain_z, self.HEADERS['Z'])
# chain_z became best chain, do checks
self.assertEqual(3, len(blockchain.blockchains))
self.assertEqual(2, len(os.listdir(os.path.join(self.data_dir, "forks"))))
self.assertEqual(0, chain_z.forkpoint)
self.assertEqual(None, chain_z.parent)
self.assertEqual(constants.net.GENESIS, chain_z._forkpoint_hash)
self.assertEqual(None, chain_z._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "blockchain_headers"), chain_z.path())
self.assertEqual(14 * 80, os.stat(chain_z.path()).st_size)
self.assertEqual(9, chain_l.forkpoint)
self.assertEqual(chain_z, chain_l.parent)
self.assertEqual(hash_header(self.HEADERS['J']), chain_l._forkpoint_hash)
self.assertEqual(hash_header(self.HEADERS['I']), chain_l._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "forks", "fork2_9_2874a1277687ab8042eff9916256b860a5b0a08b0038456c5a4a37d3bdf3656a_6e1acd473503ce0ee3cee916ca07db2f656b48baf8968f999189545316423bbb"), chain_l.path())
self.assertEqual(3 * 80, os.stat(chain_l.path()).st_size)
self.assertEqual(6, chain_u.forkpoint)
self.assertEqual(chain_z, chain_u.parent)
self.assertEqual(hash_header(self.HEADERS['O']), chain_u._forkpoint_hash)
self.assertEqual(hash_header(self.HEADERS['F']), chain_u._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "forks", "fork2_6_5c400c7966145d56291080b6482716a16aa644eefe590f984c1da0ee46ed33b8_aff81830e28e01ef7d23277c56779a6b93f251a2d50dcc09d7c87d119e1e8ab"), chain_u.path())
self.assertEqual(7 * 80, os.stat(chain_u.path()).st_size)
for b in (chain_u, chain_l, chain_z):
self.assertTrue(all([b.can_connect(b.read_header(i), False) for i in range(b.height())]))
self.assertEqual(constants.net.GENESIS, chain_z.get_hash(0))
self.assertEqual(hash_header(self.HEADERS['F']), chain_z.get_hash(5))
self.assertEqual(hash_header(self.HEADERS['G']), chain_z.get_hash(6))
self.assertEqual(hash_header(self.HEADERS['I']), chain_z.get_hash(8))
self.assertEqual(hash_header(self.HEADERS['M']), chain_z.get_hash(9))
self.assertEqual(hash_header(self.HEADERS['Z']), chain_z.get_hash(13))
def test_doing_multiple_swaps_after_single_new_header(self):
blockchain.blockchains[constants.net.GENESIS] = chain_u = Blockchain(
config=self.config, forkpoint=0, parent=None,
forkpoint_hash=constants.net.GENESIS, prev_hash=None)
open(chain_u.path(), 'w+').close()
self._append_header(chain_u, self.HEADERS['A'])
self._append_header(chain_u, self.HEADERS['B'])
self._append_header(chain_u, self.HEADERS['C'])
self._append_header(chain_u, self.HEADERS['D'])
self._append_header(chain_u, self.HEADERS['E'])
self._append_header(chain_u, self.HEADERS['F'])
self._append_header(chain_u, self.HEADERS['O'])
self._append_header(chain_u, self.HEADERS['P'])
self._append_header(chain_u, self.HEADERS['Q'])
self._append_header(chain_u, self.HEADERS['R'])
self._append_header(chain_u, self.HEADERS['S'])
self.assertEqual(1, len(blockchain.blockchains))
self.assertEqual(0, len(os.listdir(os.path.join(self.data_dir, "forks"))))
chain_l = chain_u.fork(self.HEADERS['G'])
self._append_header(chain_l, self.HEADERS['H'])
self._append_header(chain_l, self.HEADERS['I'])
self._append_header(chain_l, self.HEADERS['J'])
self._append_header(chain_l, self.HEADERS['K'])
# now chain_u is best chain, but it's tied with chain_l
self.assertEqual(2, len(blockchain.blockchains))
self.assertEqual(1, len(os.listdir(os.path.join(self.data_dir, "forks"))))
chain_z = chain_l.fork(self.HEADERS['M'])
self._append_header(chain_z, self.HEADERS['N'])
self._append_header(chain_z, self.HEADERS['X'])
self.assertEqual(3, len(blockchain.blockchains))
self.assertEqual(2, len(os.listdir(os.path.join(self.data_dir, "forks"))))
# chain_z became best chain, do checks
self.assertEqual(0, chain_z.forkpoint)
self.assertEqual(None, chain_z.parent)
self.assertEqual(constants.net.GENESIS, chain_z._forkpoint_hash)
self.assertEqual(None, chain_z._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "blockchain_headers"), chain_z.path())
self.assertEqual(12 * 80, os.stat(chain_z.path()).st_size)
self.assertEqual(9, chain_l.forkpoint)
self.assertEqual(chain_z, chain_l.parent)
self.assertEqual(hash_header(self.HEADERS['J']), chain_l._forkpoint_hash)
self.assertEqual(hash_header(self.HEADERS['I']), chain_l._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "forks", "fork2_9_2874a1277687ab8042eff9916256b860a5b0a08b0038456c5a4a37d3bdf3656a_6e1acd473503ce0ee3cee916ca07db2f656b48baf8968f999189545316423bbb"), chain_l.path())
self.assertEqual(2 * 80, os.stat(chain_l.path()).st_size)
self.assertEqual(6, chain_u.forkpoint)
self.assertEqual(chain_z, chain_u.parent)
self.assertEqual(hash_header(self.HEADERS['O']), chain_u._forkpoint_hash)
self.assertEqual(hash_header(self.HEADERS['F']), chain_u._prev_hash)
self.assertEqual(os.path.join(self.data_dir, "forks", "fork2_6_5c400c7966145d56291080b6482716a16aa644eefe590f984c1da0ee46ed33b8_aff81830e28e01ef7d23277c56779a6b93f251a2d50dcc09d7c87d119e1e8ab"), chain_u.path())
self.assertEqual(5 * 80, os.stat(chain_u.path()).st_size)
self.assertEqual(constants.net.GENESIS, chain_z.get_hash(0))
self.assertEqual(hash_header(self.HEADERS['F']), chain_z.get_hash(5))
self.assertEqual(hash_header(self.HEADERS['G']), chain_z.get_hash(6))
self.assertEqual(hash_header(self.HEADERS['I']), chain_z.get_hash(8))
self.assertEqual(hash_header(self.HEADERS['M']), chain_z.get_hash(9))
self.assertEqual(hash_header(self.HEADERS['X']), chain_z.get_hash(11))
for b in (chain_u, chain_l, chain_z):
self.assertTrue(all([b.can_connect(b.read_header(i), False) for i in range(b.height())]))

20
electrum/tests/test_network.py

@ -6,6 +6,9 @@ from electrum import constants
from electrum.simple_config import SimpleConfig from electrum.simple_config import SimpleConfig
from electrum import blockchain from electrum import blockchain
from electrum.interface import Interface from electrum.interface import Interface
from electrum.crypto import sha256
from electrum.util import bh2u
class MockTaskGroup: class MockTaskGroup:
async def spawn(self, x): return async def spawn(self, x): return
@ -17,10 +20,14 @@ class MockNetwork:
class MockInterface(Interface): class MockInterface(Interface):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
super().__init__(MockNetwork(), 'mock-server:50000:t', self.config.electrum_path(), None) network = MockNetwork()
network.config = config
super().__init__(network, 'mock-server:50000:t', None)
self.q = asyncio.Queue() self.q = asyncio.Queue()
self.blockchain = blockchain.Blockchain(self.config, 2002, None) self.blockchain = blockchain.Blockchain(config=self.config, forkpoint=0,
parent=None, forkpoint_hash=constants.net.GENESIS, prev_hash=None)
self.tip = 12 self.tip = 12
self.blockchain._size = self.tip + 1
async def get_block_header(self, height, assert_mode): async def get_block_header(self, height, assert_mode):
assert self.q.qsize() > 0, (height, assert_mode) assert self.q.qsize() > 0, (height, assert_mode)
item = await self.q.get() item = await self.q.get()
@ -56,7 +63,7 @@ class TestNetwork(unittest.TestCase):
self.interface.q.put_nowait({'block_height': 5, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}}) self.interface.q.put_nowait({'block_height': 5, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}})
self.interface.q.put_nowait({'block_height': 6, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}}) self.interface.q.put_nowait({'block_height': 6, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}})
ifa = self.interface ifa = self.interface
self.assertEqual(('fork_noconflict', 8), asyncio.get_event_loop().run_until_complete(ifa.sync_until(8, next_height=7))) self.assertEqual(('fork', 8), asyncio.get_event_loop().run_until_complete(ifa.sync_until(8, next_height=7)))
self.assertEqual(self.interface.q.qsize(), 0) self.assertEqual(self.interface.q.qsize(), 0)
def test_fork_conflict(self): def test_fork_conflict(self):
@ -70,7 +77,7 @@ class TestNetwork(unittest.TestCase):
self.interface.q.put_nowait({'block_height': 5, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}}) self.interface.q.put_nowait({'block_height': 5, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}})
self.interface.q.put_nowait({'block_height': 6, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}}) self.interface.q.put_nowait({'block_height': 6, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}})
ifa = self.interface ifa = self.interface
self.assertEqual(('fork_conflict', 8), asyncio.get_event_loop().run_until_complete(ifa.sync_until(8, next_height=7))) self.assertEqual(('fork', 8), asyncio.get_event_loop().run_until_complete(ifa.sync_until(8, next_height=7)))
self.assertEqual(self.interface.q.qsize(), 0) self.assertEqual(self.interface.q.qsize(), 0)
def test_can_connect_during_backward(self): def test_can_connect_during_backward(self):
@ -87,7 +94,10 @@ class TestNetwork(unittest.TestCase):
self.assertEqual(self.interface.q.qsize(), 0) self.assertEqual(self.interface.q.qsize(), 0)
def mock_fork(self, bad_header): def mock_fork(self, bad_header):
return blockchain.Blockchain(self.config, bad_header['block_height'], None) forkpoint = bad_header['block_height']
b = blockchain.Blockchain(config=self.config, forkpoint=forkpoint, parent=None,
forkpoint_hash=bh2u(sha256(str(forkpoint))), prev_hash=bh2u(sha256(str(forkpoint-1))))
return b
def test_chain_false_during_binary(self): def test_chain_false_during_binary(self):
blockchain.blockchains = {} blockchain.blockchains = {}

72
electrum/tests/test_wallet.py

@ -3,9 +3,16 @@ import tempfile
import sys import sys
import os import os
import json import json
from decimal import Decimal
from unittest import TestCase
import time
from io import StringIO from io import StringIO
from electrum.storage import WalletStorage, FINAL_SEED_VERSION from electrum.storage import WalletStorage, FINAL_SEED_VERSION
from electrum.wallet import Abstract_Wallet
from electrum.exchange_rate import ExchangeBase, FxThread
from electrum.util import TxMinedStatus
from electrum.bitcoin import COIN
from . import SequentialTestCase from . import SequentialTestCase
@ -64,7 +71,70 @@ class TestWalletStorage(WalletTestCase):
storage.put(key, value) storage.put(key, value)
storage.write() storage.write()
contents = ""
with open(self.wallet_path, "r") as f: with open(self.wallet_path, "r") as f:
contents = f.read() contents = f.read()
self.assertEqual(some_dict, json.loads(contents)) self.assertEqual(some_dict, json.loads(contents))
class FakeExchange(ExchangeBase):
def __init__(self, rate):
super().__init__(lambda self: None, lambda self: None)
self.quotes = {'TEST': rate}
class FakeFxThread:
def __init__(self, exchange):
self.exchange = exchange
self.ccy = 'TEST'
remove_thousands_separator = staticmethod(FxThread.remove_thousands_separator)
timestamp_rate = FxThread.timestamp_rate
ccy_amount_str = FxThread.ccy_amount_str
history_rate = FxThread.history_rate
class FakeWallet:
def __init__(self, fiat_value):
super().__init__()
self.fiat_value = fiat_value
self.transactions = self.verified_tx = {'abc': 'Tx'}
def get_tx_height(self, txid):
# because we use a current timestamp, and history is empty,
# FxThread.history_rate will use spot prices
return TxMinedStatus(height=10, conf=10, timestamp=time.time(), header_hash='def')
default_fiat_value = Abstract_Wallet.default_fiat_value
price_at_timestamp = Abstract_Wallet.price_at_timestamp
class storage:
put = lambda self, x: None
txid = 'abc'
ccy = 'TEST'
class TestFiat(TestCase):
def setUp(self):
self.value_sat = COIN
self.fiat_value = {}
self.wallet = FakeWallet(fiat_value=self.fiat_value)
self.fx = FakeFxThread(FakeExchange(Decimal('1000.001')))
default_fiat = Abstract_Wallet.default_fiat_value(self.wallet, txid, self.fx, self.value_sat)
self.assertEqual(Decimal('1000.001'), default_fiat)
self.assertEqual('1,000.00', self.fx.ccy_amount_str(default_fiat, commas=True))
def test_save_fiat_and_reset(self):
self.assertEqual(False, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '1000.01', self.fx, self.value_sat))
saved = self.fiat_value[ccy][txid]
self.assertEqual('1,000.01', self.fx.ccy_amount_str(Decimal(saved), commas=True))
self.assertEqual(True, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '', self.fx, self.value_sat))
self.assertNotIn(txid, self.fiat_value[ccy])
# even though we are not setting it to the exact fiat value according to the exchange rate, precision is truncated away
self.assertEqual(True, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '1,000.002', self.fx, self.value_sat))
def test_too_high_precision_value_resets_with_no_saved_value(self):
self.assertEqual(True, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '1,000.001', self.fx, self.value_sat))
def test_empty_resets(self):
self.assertEqual(True, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '', self.fx, self.value_sat))
self.assertNotIn(ccy, self.fiat_value)
def test_save_garbage(self):
self.assertEqual(False, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, 'garbage', self.fx, self.value_sat))
self.assertNotIn(ccy, self.fiat_value)

27
electrum/util.py

@ -39,6 +39,7 @@ import urllib.request, urllib.parse, urllib.error
import builtins import builtins
import json import json
import time import time
from typing import NamedTuple, Optional
import aiohttp import aiohttp
from aiohttp_socks import SocksConnector, SocksVer from aiohttp_socks import SocksConnector, SocksVer
@ -129,31 +130,15 @@ class UserCancelled(Exception):
'''An exception that is suppressed from the user''' '''An exception that is suppressed from the user'''
pass pass
class Satoshis(object): class Satoshis(NamedTuple):
__slots__ = ('value',) value: int
def __new__(cls, value):
self = super(Satoshis, cls).__new__(cls)
self.value = value
return self
def __repr__(self):
return 'Satoshis(%d)'%self.value
def __str__(self): def __str__(self):
return format_satoshis(self.value) + " BTC" return format_satoshis(self.value) + " BTC"
class Fiat(object): class Fiat(NamedTuple):
__slots__ = ('value', 'ccy') value: Optional[Decimal]
ccy: str
def __new__(cls, value, ccy):
self = super(Fiat, cls).__new__(cls)
self.ccy = ccy
self.value = value
return self
def __repr__(self):
return 'Fiat(%s)'% self.__str__()
def __str__(self): def __str__(self):
if self.value is None or self.value.is_nan(): if self.value is None or self.value.is_nan():

4
electrum/version.py

@ -1,5 +1,5 @@
ELECTRUM_VERSION = '3.2.3' # version of the client package ELECTRUM_VERSION = '3.3.0' # version of the client package
APK_VERSION = '3.2.3.1' # read by buildozer.spec APK_VERSION = '3.3.0.0' # read by buildozer.spec
PROTOCOL_VERSION = '1.4' # protocol version requested PROTOCOL_VERSION = '1.4' # protocol version requested

88
electrum/wallet.py

@ -182,7 +182,7 @@ class Abstract_Wallet(AddressSynchronizer):
self.invoices = InvoiceStore(self.storage) self.invoices = InvoiceStore(self.storage)
self.contacts = Contacts(self.storage) self.contacts = Contacts(self.storage)
self.coin_price_cache = {} self._coin_price_cache = {}
def load_and_cleanup(self): def load_and_cleanup(self):
self.load_keystore() self.load_keystore()
@ -247,24 +247,37 @@ class Abstract_Wallet(AddressSynchronizer):
self.storage.put('labels', self.labels) self.storage.put('labels', self.labels)
return changed return changed
def set_fiat_value(self, txid, ccy, text): def set_fiat_value(self, txid, ccy, text, fx, value_sat):
if txid not in self.transactions: if txid not in self.transactions:
return return
if not text: # since fx is inserting the thousands separator,
# and not util, also have fx remove it
text = fx.remove_thousands_separator(text)
def_fiat = self.default_fiat_value(txid, fx, value_sat)
formatted = fx.ccy_amount_str(def_fiat, commas=False)
def_fiat_rounded = Decimal(formatted)
reset = not text
if not reset:
try:
text_dec = Decimal(text)
text_dec_rounded = Decimal(fx.ccy_amount_str(text_dec, commas=False))
reset = text_dec_rounded == def_fiat_rounded
except:
# garbage. not resetting, but not saving either
return False
if reset:
d = self.fiat_value.get(ccy, {}) d = self.fiat_value.get(ccy, {})
if d and txid in d: if d and txid in d:
d.pop(txid) d.pop(txid)
else: else:
return # avoid saving empty dict
return True
else: else:
try: if ccy not in self.fiat_value:
Decimal(text) self.fiat_value[ccy] = {}
except: self.fiat_value[ccy][txid] = text
return
if ccy not in self.fiat_value:
self.fiat_value[ccy] = {}
self.fiat_value[ccy][txid] = text
self.storage.put('fiat_value', self.fiat_value) self.storage.put('fiat_value', self.fiat_value)
return reset
def get_fiat_value(self, txid, ccy): def get_fiat_value(self, txid, ccy):
fiat_value = self.fiat_value.get(ccy, {}).get(txid) fiat_value = self.fiat_value.get(ccy, {}).get(txid)
@ -423,21 +436,11 @@ class Abstract_Wallet(AddressSynchronizer):
income += value income += value
# fiat computations # fiat computations
if fx and fx.is_enabled() and fx.get_history_config(): if fx and fx.is_enabled() and fx.get_history_config():
fiat_value = self.get_fiat_value(tx_hash, fx.ccy) fiat_fields = self.get_tx_item_fiat(tx_hash, value, fx, tx_fee)
fiat_default = fiat_value is None fiat_value = fiat_fields['fiat_value'].value
fiat_rate = self.price_at_timestamp(tx_hash, fx.timestamp_rate) item.update(fiat_fields)
fiat_value = fiat_value if fiat_value is not None else value / Decimal(COIN) * fiat_rate
fiat_fee = tx_fee / Decimal(COIN) * fiat_rate if tx_fee is not None else None
item['fiat_value'] = Fiat(fiat_value, fx.ccy)
item['fiat_fee'] = Fiat(fiat_fee, fx.ccy) if fiat_fee else None
item['fiat_default'] = fiat_default
if value < 0: if value < 0:
acquisition_price = - value / Decimal(COIN) * self.average_price(tx_hash, fx.timestamp_rate, fx.ccy) capital_gains += fiat_fields['capital_gain'].value
liquidation_price = - fiat_value
item['acquisition_price'] = Fiat(acquisition_price, fx.ccy)
cg = liquidation_price - acquisition_price
item['capital_gain'] = Fiat(cg, fx.ccy)
capital_gains += cg
fiat_expenditures += -fiat_value fiat_expenditures += -fiat_value
else: else:
fiat_income += fiat_value fiat_income += fiat_value
@ -478,6 +481,27 @@ class Abstract_Wallet(AddressSynchronizer):
'summary': summary 'summary': summary
} }
def default_fiat_value(self, tx_hash, fx, value_sat):
return value_sat / Decimal(COIN) * self.price_at_timestamp(tx_hash, fx.timestamp_rate)
def get_tx_item_fiat(self, tx_hash, value, fx, tx_fee):
item = {}
fiat_value = self.get_fiat_value(tx_hash, fx.ccy)
fiat_default = fiat_value is None
fiat_rate = self.price_at_timestamp(tx_hash, fx.timestamp_rate)
fiat_value = fiat_value if fiat_value is not None else self.default_fiat_value(tx_hash, fx, value)
fiat_fee = tx_fee / Decimal(COIN) * fiat_rate if tx_fee is not None else None
item['fiat_value'] = Fiat(fiat_value, fx.ccy)
item['fiat_fee'] = Fiat(fiat_fee, fx.ccy) if fiat_fee else None
item['fiat_default'] = fiat_default
if value < 0:
acquisition_price = - value / Decimal(COIN) * self.average_price(tx_hash, fx.timestamp_rate, fx.ccy)
liquidation_price = - fiat_value
item['acquisition_price'] = Fiat(acquisition_price, fx.ccy)
cg = liquidation_price - acquisition_price
item['capital_gain'] = Fiat(cg, fx.ccy)
return item
def get_label(self, tx_hash): def get_label(self, tx_hash):
label = self.labels.get(tx_hash, '') label = self.labels.get(tx_hash, '')
if label is '': if label is '':
@ -1154,6 +1178,9 @@ class Abstract_Wallet(AddressSynchronizer):
total_price += self.coin_price(ser.split(':')[0], price_func, ccy, v) total_price += self.coin_price(ser.split(':')[0], price_func, ccy, v)
return total_price / (input_value/Decimal(COIN)) return total_price / (input_value/Decimal(COIN))
def clear_coin_price_cache(self):
self._coin_price_cache = {}
def coin_price(self, txid, price_func, ccy, txin_value): def coin_price(self, txid, price_func, ccy, txin_value):
""" """
Acquisition price of a coin. Acquisition price of a coin.
@ -1162,13 +1189,12 @@ class Abstract_Wallet(AddressSynchronizer):
if txin_value is None: if txin_value is None:
return Decimal('NaN') return Decimal('NaN')
cache_key = "{}:{}:{}".format(str(txid), str(ccy), str(txin_value)) cache_key = "{}:{}:{}".format(str(txid), str(ccy), str(txin_value))
result = self.coin_price_cache.get(cache_key, None) result = self._coin_price_cache.get(cache_key, None)
if result is not None: if result is not None:
return result return result
if self.txi.get(txid, {}) != {}: if self.txi.get(txid, {}) != {}:
result = self.average_price(txid, price_func, ccy) * txin_value/Decimal(COIN) result = self.average_price(txid, price_func, ccy) * txin_value/Decimal(COIN)
if not result.is_nan(): self._coin_price_cache[cache_key] = result
self.coin_price_cache[cache_key] = result
return result return result
else: else:
fiat_value = self.get_fiat_value(txid, ccy) fiat_value = self.get_fiat_value(txid, ccy)
@ -1353,8 +1379,8 @@ class Imported_Wallet(Simple_Wallet):
def get_public_key(self, address): def get_public_key(self, address):
return self.addresses[address].get('pubkey') return self.addresses[address].get('pubkey')
def import_private_keys(self, keys: List[str], password: Optional[str]) -> Tuple[List[str], def import_private_keys(self, keys: List[str], password: Optional[str],
List[Tuple[str, str]]]: write_to_disk=True) -> Tuple[List[str], List[Tuple[str, str]]]:
good_addr = [] # type: List[str] good_addr = [] # type: List[str]
bad_keys = [] # type: List[Tuple[str, str]] bad_keys = [] # type: List[Tuple[str, str]]
for key in keys: for key in keys:
@ -1372,7 +1398,7 @@ class Imported_Wallet(Simple_Wallet):
self.add_address(addr) self.add_address(addr)
self.save_keystore() self.save_keystore()
self.save_addresses() self.save_addresses()
self.save_transactions(write=True) self.save_transactions(write=write_to_disk)
return good_addr, bad_keys return good_addr, bad_keys
def import_private_key(self, key: str, password: Optional[str]) -> str: def import_private_key(self, key: str, password: Optional[str]) -> str:

Loading…
Cancel
Save