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"]
path = contrib/deterministic-build/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
install:
- git fetch --all --tags
script: ./contrib/build-osx/make_osx
script: ./contrib/osx/make_osx
after_script: ls -lah dist && md5 dist/*
after_success: true
- stage: release check

6
README.rst

@ -32,7 +32,7 @@ Qt interface, install the Qt dependencies::
sudo apt-get install python3-pyqt5
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'
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]
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
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
--------
See `contrib/build-osx/`.
See `contrib/osx/`.
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)
* 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: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 \
make=4.1-9.1ubuntu1 \
mingw-w64=5.0.3-1 \

2
contrib/make_apk

@ -2,6 +2,8 @@
pushd ./electrum/gui/kivy/
make theming
if [[ -n "$1" && "$1" == "release" ]] ; then
echo -n Keystore 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
./contrib/build-osx/make_osx
./contrib/osx/make_osx
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:
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`
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"
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..."
curl https://homebrew.bintray.com/bottles/libusb-1.0.22.el_capitan.bottle.tar.gz | \
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"
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
make
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..."
@ -88,7 +117,7 @@ for d in ~/Library/Python/ ~/.pyenv .; do
done
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"
plutil -insert 'CFBundleURLTypes' \
@ -96,5 +125,14 @@ plutil -insert 'CFBundleURLTypes' \
-- dist/$PACKAGE.app/Contents/Info.plist \
|| 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"
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('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
binaries = [(electrum + "contrib/build-osx/libusb-1.0.dylib", ".")]
binaries += [(electrum + "contrib/build-osx/libsecp256k1.0.dylib", ".")]
binaries = [(electrum + "contrib/osx/libusb-1.0.dylib", ".")]
binaries += [(electrum + "contrib/osx/libsecp256k1.0.dylib", ".")]
# Workaround for "Retro Look":
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
dnspython
jsonrpclib-pelix
qdarkstyle<3.0
qdarkstyle<2.6
aiorpcx>=0.9,<0.11
aiohttp
aiohttp_socks

15
electrum/address_synchronizer.py

@ -717,12 +717,15 @@ class AddressSynchronizer(PrintError):
return None
if hasattr(tx, '_cached_fee'):
return tx._cached_fee
is_relevant, is_mine, v, fee = self.get_wallet_delta(tx)
if fee is None:
txid = tx.txid()
fee = self.tx_fees.get(txid)
if fee is not None:
tx._cached_fee = fee
with self.lock, self.transaction_lock:
is_relevant, is_mine, v, fee = self.get_wallet_delta(tx)
if fee is None:
txid = tx.txid()
fee = self.tx_fees.get(txid)
# 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
def get_addr_io(self, address):

7
electrum/base_wizard.py

@ -200,7 +200,7 @@ class BaseWizard(object):
self.storage.put('keystore', k.dump())
w = Imported_Wallet(self.storage)
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)
else:
return self.terminate()
@ -283,7 +283,9 @@ class BaseWizard(object):
for name, info in devices:
state = _("initialized") if info.initialized else _("wiped")
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))
msg = _('Select a device') + ':'
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,
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)
if encrypt_storage:
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)))
blockchains = {} # type: Dict[int, Blockchain]
blockchains_lock = threading.Lock()
def read_blockchains(config: 'SimpleConfig') -> Dict[int, 'Blockchain']:
blockchains[0] = Blockchain(config, 0, None)
# key: blockhash hex at forkpoint
# the chain at some key is the best chain that includes the given hash
blockchains = {} # type: Dict[str, Blockchain]
blockchains_lock = threading.RLock()
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')
util.make_dir(fdir)
l = filter(lambda x: x.startswith('fork_'), os.listdir(fdir))
l = sorted(l, key = lambda x: int(x.split('_')[1]))
for filename in l:
forkpoint = int(filename.split('_')[2])
parent_id = int(filename.split('_')[1])
b = Blockchain(config, forkpoint, parent_id)
h = b.read_header(b.forkpoint)
if b.parent().can_connect(h, check_height=False):
blockchains[b.forkpoint] = b
# files are named as: fork2_{forkpoint}_{prev_hash}_{first_hash}
l = filter(lambda x: x.startswith('fork2_') and '.' not in x, os.listdir(fdir))
l = sorted(l, key=lambda x: int(x.split('_')[1])) # sort by forkpoint
def delete_chain(filename, reason):
util.print_error(f"[blockchain] deleting chain {filename}: {reason}")
os.unlink(os.path.join(fdir, filename))
def instantiate_chain(filename):
__, forkpoint, prev_hash, first_hash = filename.split('_')
forkpoint = int(forkpoint)
prev_hash = (64-len(prev_hash)) * "0" + prev_hash # left-pad with zeroes
first_hash = (64-len(first_hash)) * "0" + first_hash
# forks below the max checkpoint are not allowed
if forkpoint <= constants.net.max_checkpoint():
delete_chain(filename, "deleting fork below max checkpoint")
return
# find parent (sorting by forkpoint guarantees it's already instantiated)
for parent in blockchains.values():
if parent.check_hash(forkpoint - 1, prev_hash):
break
else:
util.print_error("cannot connect", filename)
return blockchains
delete_chain(filename, "cannot find parent for chain")
return
b = Blockchain(config=config,
forkpoint=forkpoint,
parent=parent,
forkpoint_hash=first_hash,
prev_hash=prev_hash)
# consistency checks
h = b.read_header(b.forkpoint)
if first_hash != hash_header(h):
delete_chain(filename, "incorrect first hash for chain")
return
if not b.parent.can_connect(h, check_height=False):
delete_chain(filename, "cannot connect chain to parent")
return
chain_id = b.get_id()
assert first_hash == chain_id, (first_hash, chain_id)
blockchains[chain_id] = b
for filename in l:
instantiate_chain(filename)
def get_best_chain() -> 'Blockchain':
return blockchains[constants.net.GENESIS]
# block hash -> chain work; up to and including that block
_CHAINWORK_CACHE = {
"0000000000000000000000000000000000000000000000000000000000000000": 0, # virtual block at height -1
} # type: Dict[str, int]
class Blockchain(util.PrintError):
@ -106,15 +161,20 @@ class Blockchain(util.PrintError):
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.forkpoint = forkpoint
self.checkpoints = constants.net.CHECKPOINTS
self.parent_id = parent_id
assert parent_id != forkpoint
self.forkpoint = forkpoint # height of first header
self.parent = parent
self._forkpoint_hash = forkpoint_hash # blockhash at forkpoint. "first hash"
self._prev_hash = prev_hash # blockhash immediately before forkpoint
self.lock = threading.RLock()
with self.lock:
self.update_size()
self.update_size()
def with_lock(func):
def func_wrapper(self, *args, **kwargs):
@ -122,12 +182,13 @@ class Blockchain(util.PrintError):
return func(self, *args, **kwargs)
return func_wrapper
def parent(self) -> 'Blockchain':
return blockchains[self.parent_id]
@property
def checkpoints(self):
return constants.net.CHECKPOINTS
def get_max_child(self) -> Optional[int]:
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
def get_max_forkpoint(self) -> int:
@ -137,11 +198,12 @@ class Blockchain(util.PrintError):
mc = self.get_max_child()
return mc if mc is not None else self.forkpoint
@with_lock
def get_branch_size(self) -> int:
return self.height() - self.get_max_forkpoint() + 1
def get_name(self) -> str:
return self.get_hash(self.get_max_forkpoint()).lstrip('00')[0:10]
return self.get_hash(self.get_max_forkpoint()).lstrip('0')[0:10]
def check_header(self, header: dict) -> bool:
header_hash = hash_header(header)
@ -159,24 +221,38 @@ class Blockchain(util.PrintError):
return False
def fork(parent, header: dict) -> 'Blockchain':
if not parent.can_connect(header, check_height=False):
raise Exception("forking header does not connect to parent chain")
forkpoint = header.get('block_height')
self = Blockchain(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()
self.save_header(header)
# put into global dict. note that in some cases
# save_header might have already put it there but that's OK
chain_id = self.get_id()
with blockchains_lock:
blockchains[chain_id] = self
return self
@with_lock
def height(self) -> int:
return self.forkpoint + self.size() - 1
@with_lock
def size(self) -> int:
with self.lock:
return self._size
return self._size
@with_lock
def update_size(self) -> None:
p = self.path()
self._size = os.path.getsize(p)//HEADER_SIZE if os.path.exists(p) else 0
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)
if expected_header_hash and 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')))
if constants.net.TESTNET:
return
bits = self.target_to_bits(target)
bits = cls.target_to_bits(target)
if 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')
@ -207,21 +283,26 @@ class Blockchain(util.PrintError):
self.verify_header(header, prev_hash, target, expected_header_hash)
prev_hash = hash_header(header)
@with_lock
def path(self):
d = util.get_headers_dir(self.config)
if self.parent_id is None:
if self.parent is None:
filename = 'blockchain_headers'
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)
return os.path.join(d, filename)
@with_lock
def save_chunk(self, index: int, chunk: bytes):
assert index >= 0, index
chunk_within_checkpoint_region = index < len(self.checkpoints)
# chunks in checkpoint region are the responsibility of the 'main chain'
if chunk_within_checkpoint_region and self.parent_id is not None:
main_chain = blockchains[0]
if chunk_within_checkpoint_region and self.parent is not None:
main_chain = get_best_chain()
main_chain.save_chunk(index, chunk)
return
@ -236,18 +317,36 @@ class Blockchain(util.PrintError):
self.write(chunk, delta_bytes, truncate)
self.swap_with_parent()
@with_lock
def swap_with_parent(self) -> None:
if self.parent_id is None:
return
parent_branch_size = self.parent().height() - self.forkpoint + 1
if parent_branch_size >= self.size():
return
self.print_error("swap", self.forkpoint, self.parent_id)
parent_id = self.parent_id
forkpoint = self.forkpoint
parent = self.parent()
parent_lock = self.parent.lock if self.parent is not None else threading.Lock()
with parent_lock, self.lock, blockchains_lock: # this order should not deadlock
# do the swap; possibly multiple ones
cnt = 0
while self._swap_with_parent():
cnt += 1
if cnt > len(blockchains): # make sure we are making progress
raise Exception(f'swapping fork with parent too many times: {cnt}')
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())
child_old_name = self.path()
with open(self.path(), 'rb') as f:
my_data = f.read()
self.assert_headers_file_available(parent.path())
@ -256,24 +355,24 @@ class Blockchain(util.PrintError):
parent_data = f.read(parent_branch_size*HEADER_SIZE)
self.write(parent_data, 0)
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
self.parent_id = parent.parent_id; parent.parent_id = parent_id
self.forkpoint = parent.forkpoint; parent.forkpoint = forkpoint
self._size = parent._size; parent._size = parent_branch_size
# move files
for b in chains:
if b in [self, parent]: continue
if b.old_path != b.path():
self.print_error("renaming", b.old_path, b.path())
os.rename(b.old_path, b.path())
self.parent, parent.parent = parent.parent, self # type: Optional[Blockchain], Optional[Blockchain]
self.forkpoint, parent.forkpoint = parent.forkpoint, self.forkpoint
self._forkpoint_hash, parent._forkpoint_hash = parent._forkpoint_hash, hash_raw_header(bh2u(parent_data[:HEADER_SIZE]))
self._prev_hash, parent._prev_hash = parent._prev_hash, self._prev_hash
# parent's new name
os.replace(child_old_name, parent.path())
self.update_size()
parent.update_size()
# update pointers
with blockchains_lock:
blockchains[self.forkpoint] = self
blockchains[parent.forkpoint] = parent
blockchains.pop(child_old_id, None)
blockchains.pop(parent_old_id, None)
blockchains[self.get_id()] = self
blockchains[parent.get_id()] = parent
return True
def get_id(self) -> str:
return self._forkpoint_hash
def assert_headers_file_available(self, path):
if os.path.exists(path):
@ -283,36 +382,36 @@ class Blockchain(util.PrintError):
else:
raise FileNotFoundError('Cannot find headers file but headers_dir is there. Should be at {}'.format(path))
@with_lock
def write(self, data: bytes, offset: int, truncate: bool=True) -> None:
filename = self.path()
with self.lock:
self.assert_headers_file_available(filename)
with open(filename, 'rb+') as f:
if truncate and offset != self._size * HEADER_SIZE:
f.seek(offset)
f.truncate()
self.assert_headers_file_available(filename)
with open(filename, 'rb+') as f:
if truncate and offset != self._size * HEADER_SIZE:
f.seek(offset)
f.write(data)
f.flush()
os.fsync(f.fileno())
self.update_size()
f.truncate()
f.seek(offset)
f.write(data)
f.flush()
os.fsync(f.fileno())
self.update_size()
@with_lock
def save_header(self, header: dict) -> None:
delta = header.get('block_height') - self.forkpoint
data = bfh(serialize_header(header))
# headers are only _appended_ to the end:
assert delta == self.size()
assert delta == self.size(), (delta, self.size())
assert len(data) == HEADER_SIZE
self.write(data, delta*HEADER_SIZE)
self.swap_with_parent()
@with_lock
def read_header(self, height: int) -> Optional[dict]:
assert self.parent_id != self.forkpoint
if height < 0:
return
if height < self.forkpoint:
return self.parent().read_header(height)
return self.parent.read_header(height)
if height > self.height():
return
delta = height - self.forkpoint
@ -372,16 +471,18 @@ class Blockchain(util.PrintError):
new_target = self.bits_to_target(self.target_to_bits(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
if not (bitsN >= 0x03 and bitsN <= 0x1d):
if not (0x03 <= bitsN <= 0x1d):
raise Exception("First part of bits should be in [0x03, 0x1d]")
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]")
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:]
while c[:2] == '00' and len(c) > 6:
c = c[2:]
@ -391,6 +492,40 @@ class Blockchain(util.PrintError):
bitsBase >>= 8
return bitsN << 24 | bitsBase
def chainwork_of_header_at_height(self, height: int) -> int:
"""work done by single header at given height"""
chunk_idx = height // 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:
if header is None:
return False
@ -417,6 +552,7 @@ class Blockchain(util.PrintError):
return True
def connect_chunk(self, idx: int, hexdata: str) -> bool:
assert idx >= 0, idx
try:
data = bfh(hexdata)
self.verify_chunk(idx, data)
@ -424,7 +560,7 @@ class Blockchain(util.PrintError):
self.save_chunk(idx, data)
return True
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
def get_checkpoints(self):

3
electrum/commands.py

@ -176,7 +176,7 @@ class Commands:
storage.put('keystore', k.dump())
wallet = Imported_Wallet(storage)
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
if not good_inputs:
raise Exception("None of the given privkeys can be imported")
@ -191,6 +191,7 @@ class Commands:
storage.put('wallet_type', 'standard')
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.synchronize()

3
electrum/contacts.py

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

95
electrum/crypto.py

@ -32,6 +32,7 @@ from typing import Union
import pyaes
from .util import assert_bytes, InvalidPassword, to_bytes, to_string
from .i18n import _
try:
@ -90,37 +91,103 @@ def aes_decrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes:
raise InvalidPassword()
def EncodeAES(secret: bytes, msg: bytes) -> bytes:
def EncodeAES_base64(secret: bytes, msg: bytes) -> bytes:
"""Returns base64 encoded ciphertext."""
e = EncodeAES_bytes(secret, msg)
return base64.b64encode(e)
def EncodeAES_bytes(secret: bytes, msg: bytes) -> bytes:
assert_bytes(msg)
iv = bytes(os.urandom(16))
ct = aes_encrypt_with_iv(secret, iv, msg)
e = iv + ct
return base64.b64encode(e)
return iv + ct
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))
iv, e = e[:16], e[16:]
def DecodeAES_bytes(secret: bytes, ciphertext: bytes) -> bytes:
assert_bytes(ciphertext)
iv, e = ciphertext[:16], ciphertext[16:]
s = aes_decrypt_with_iv(secret, iv, e)
return s
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:
return data
secret = sha256d(password)
return EncodeAES(secret, to_bytes(data, "utf8")).decode('utf8')
if version not in KNOWN_PW_HASH_VERSIONS:
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:
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:
d = to_string(DecodeAES(secret, data), "utf8")
except Exception:
raise InvalidPassword()
d = to_string(DecodeAES_bytes(secret, data_bytes), "utf8")
except Exception as e:
raise InvalidPassword() from e
return d

6
electrum/exchange_rate.py

@ -464,9 +464,13 @@ class FxThread(ThreadJob):
d = get_exchanges_by_ccy(history)
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):
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:
rounded_amount = round(amount, prec)
except decimal.InvalidOperation:

2
electrum/gui/kivy/Makefile

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

167
electrum/gui/kivy/Readme.md

@ -3,147 +3,62 @@
The Kivy GUI is used with Electrum on Android devices.
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:
```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
```
```
$ sudo docker build -t electrum-android-builder-img electrum/gui/kivy/tools
```
## 4. Install buildozer
4.1 Buildozer is a frontend to p4a. Luckily we don't need to patch it:
3. Prepare pure python dependencies
```sh
cd /opt
git clone https://github.com/kivy/buildozer
cd buildozer
sudo python3 setup.py install
```
```
$ sudo ./contrib/make_packages
```
4.2 Install additional dependencies:
4. Build binaries
```sh
sudo apt-get install python-pip
```
(from [buildozer docs](https://buildozer.readthedocs.io/en/latest/installation.html#targeting-android))
```sh
sudo pip install --upgrade cython==0.21
sudo dpkg --add-architecture i386
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
```
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
```
```
$ sudo docker run -it --rm \
--name electrum-android-builder-cont \
-v $PWD:/home/user/wspace/electrum \
-v ~/.keystore:/home/user/.keystore \
--workdir /home/user/wspace/electrum \
electrum-android-builder-img \
./contrib/make_apk
```
This mounts the project dir inside the container,
and so the modifications will affect it, e.g. `.buildozer` folder
will be created.
## 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
ln -sf ~/.buildozer/android/platform/android-sdk-24/tools ~/.buildozer/android/platform/android-sdk-24/tools.save
```
### 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}`
### 2. Try and fail:
```sh
contrib/make_apk
### How do I get an interactive shell inside docker?
```
During this build attempt, buildozer downloaded some tools,
e.g. those needed in the next step.
## 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
$ sudo docker run -it --rm \
-v $PWD:/home/user/wspace/electrum \
--workdir /home/user/wspace/electrum \
electrum-android-builder-img
```
# 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()
def cb(name):
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():
self.network.run_from_another_thread(self.network.follow_chain_given_id(index))
names = [blockchain.blockchains[b].get_name() for b in chains]
self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id))
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:
cur_chain = self.network.blockchain().get_name()
ChoiceDialog(_('Choose your chain'), names, cur_chain, cb).open()
@ -171,6 +173,7 @@ class ElectrumWindow(App):
def on_history(self, d):
Logger.info("on_history")
self.wallet.clear_coin_price_cache()
self._trigger_update_history()
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
# (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.)
#android.sdk_path =
android.sdk_path = /opt/android/android-sdk
# (str) Android entry point, default is ok for Kivy-based app
#android.entrypoint = org.renpy.android.PythonActivity

2
electrum/gui/qt/__init__.py

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

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.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
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.setSortingEnabled(True)
self.update()
def on_permit_edit(self, item, column):
# openalias items shouldn't be editable
return item.text(1) != "openalias"
def on_edited(self, idx, user_role, text):
_type, prior_name = self.parent.contacts.pop(user_role)
def on_edited(self, item, column, prior):
if column == 0: # Remove old contact if renamed
self.parent.contacts.pop(prior)
self.parent.set_contact(item.text(0), item.text(1))
# TODO when min Qt >= 5.11, use siblingAtColumn
col_1_sibling = idx.sibling(idx.row(), 1)
col_1_item = self.model().itemFromIndex(col_1_sibling)
self.parent.set_contact(text, col_1_item.text())
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):
export_meta_gui(self.parent, _('contacts'), self.parent.contacts.export_file)
def create_menu(self, position):
menu = QMenu()
selected = self.selectedItems()
if not selected:
selected = self.selected_in_column(0)
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(_("Import file"), lambda: self.import_contacts())
menu.addAction(_("Export file"), lambda: self.export_contacts())
else:
names = [item.text(0) for item in selected]
keys = [item.text(1) for item in selected]
column = self.currentColumn()
column_title = self.headerItem().text(column)
column_data = '\n'.join([item.text(column) for item in selected])
column = idx.column()
column_title = self.model().horizontalHeaderItem(column).text()
column_data = '\n'.join(self.model().itemFromIndex(idx).text() for idx in selected)
menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data))
if column in self.editable_columns:
item = self.currentItem()
menu.addAction(_("Edit {}").format(column_title), lambda: self.editItem(item, column))
menu.addAction(_("Pay to"), lambda: self.parent.payto_contacts(keys))
menu.addAction(_("Delete"), lambda: self.parent.delete_contacts(keys))
URLs = [block_explorer_URL(self.config, 'addr', key) for key in filter(is_address, keys)]
item = self.model().itemFromIndex(idx)
if item.isEditable():
# would not be editable if openalias
persistent = QPersistentModelIndex(idx)
menu.addAction(_("Edit {}").format(column_title), lambda p=persistent: self.edit(QModelIndex(p)))
menu.addAction(_("Pay to"), lambda: self.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:
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))
def on_update(self):
item = self.currentItem()
current_key = item.data(0, Qt.UserRole) if item else None
self.clear()
def update(self):
current_key = self.current_item_user_role(col=0)
self.model().clear()
self.update_headers([_('Name'), _('Address')])
set_current = None
for key in sorted(self.parent.contacts.keys()):
_type, name = self.parent.contacts[key]
item = QTreeWidgetItem([name, key])
item.setData(0, Qt.UserRole, key)
self.addTopLevelItem(item)
contact_type, name = self.parent.contacts[key]
items = [QStandardItem(x) for x in (name, key)]
items[0].setEditable(contact_type != 'openalias')
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:
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)

396
electrum/gui/qt/history_list.py

@ -27,10 +27,11 @@ import webbrowser
import datetime
from datetime import date
from typing import TYPE_CHECKING
from collections import OrderedDict
from electrum.address_synchronizer import TX_HEIGHT_LOCAL
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 *
@ -57,38 +58,109 @@ TX_ICONS = [
"confirmed.png",
]
class HistoryList(MyTreeWidget, AcceptFileDragDrop):
filter_columns = [2, 3, 4] # Date, Description, Amount
class HistorySortModel(QSortFilterProxyModel):
def lessThan(self, source_left: QModelIndex, source_right: QModelIndex):
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):
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")
self.refresh_headers()
self.setColumnHidden(1, True)
self.setSortingEnabled(True)
self.sortByColumn(0, Qt.AscendingOrder)
self.start_timestamp = None
self.end_timestamp = None
self.years = []
self.create_toolbar_buttons()
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):
return str(datetime.date(d.year, d.month, d.day)) if d else _('None')
def refresh_headers(self):
headers = ['', '', _('Date'), _('Description'), _('Amount'), _('Balance')]
headers = ['', _('Date'), _('Description'), _('Amount'), _('Balance')]
fx = self.parent.fx
if fx and fx.show_history():
headers.extend(['%s '%fx.ccy + _('Value')])
self.editable_columns |= {6}
self.editable_columns |= {5}
if fx.get_history_capital_gains_config():
headers.extend(['%s '%fx.ccy + _('Acquisition price')])
headers.extend(['%s '%fx.ccy + _('Capital Gains')])
else:
self.editable_columns -= {6}
self.update_headers(headers)
self.editable_columns -= {5}
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):
'''Replaced in address_dialog.py'''
@ -109,13 +181,11 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
year = int(s)
except:
return
start_date = datetime.datetime(year, 1, 1)
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_timestamp = start_date = datetime.datetime(year, 1, 1)
self.end_timestamp = end_date = datetime.datetime(year+1, 1, 1)
self.start_button.setText(_('From') + ' ' + self.format_date(start_date))
self.end_button.setText(_('To') + ' ' + self.format_date(end_date))
self.update()
self.hide_rows()
def create_toolbar_buttons(self):
self.period_combo = QComboBox()
@ -134,18 +204,18 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
def on_hide_toolbar(self):
self.start_timestamp = None
self.end_timestamp = None
self.update()
self.hide_rows()
def save_toolbar_state(self, state, config):
config.set_key('show_toolbar_history', state)
def select_start_date(self):
self.start_timestamp = self.select_date(self.start_button)
self.update()
self.hide_rows()
def select_end_date(self):
self.end_timestamp = self.select_date(self.end_button)
self.update()
self.hide_rows()
def select_date(self, button):
d = WindowModalDialog(self, _("Select date"))
@ -165,7 +235,7 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
return None
date = d.date.toPyDate()
button.setText(self.format_date(date))
return time.mktime(date.timetuple())
return datetime.datetime(date.year, date.month, date.day)
def show_summary(self):
h = self.summary
@ -213,94 +283,167 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
_("Perhaps some dependencies are missing...") + " (matplotlib?)")
return
try:
plt = plot_history(self.transactions)
plt = plot_history(list(self.transactions.values()))
plt.show()
except NothingToPlotException as 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
def on_update(self):
def update(self):
self.wallet = self.parent.wallet # type: Abstract_Wallet
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)
self.transactions = r['transactions']
self.summary = r['summary']
if not self.years and self.transactions:
start_date = self.transactions[0].get('date') or date.today()
end_date = self.transactions[-1].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)
item = self.currentItem()
current_tx = item.data(0, Qt.UserRole) if item else None
self.clear()
if fx: fx.history_used_spot = False
blue_brush = QBrush(QColor("#1E1EFF"))
red_brush = QBrush(QColor("#BC1E1E"))
monospace_font = QFont(MONOSPACE_FONT)
for tx_item in self.transactions:
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 = ['', tx_hash, status_str, label, v_str, balance_str]
fiat_value = None
if value is not None and fx and fx.show_history():
fiat_value = tx_item['fiat_value'].value
value_str = fx.format_fiat(fiat_value)
entry.append(value_str)
# fixme: should use is_mine
if value < 0:
entry.append(fx.format_fiat(tx_item['acquisition_price'].value))
entry.append(fx.format_fiat(tx_item['capital_gain'].value))
item = SortableTreeWidgetItem(entry)
item.setIcon(0, icon)
item.setToolTip(0, str(conf) + " confirmation" + ("s" if conf != 1 else ""))
item.setData(0, SortableTreeWidgetItem.DataRole, (status, conf))
if has_invoice:
item.setIcon(3, self.icon_cache.get(":icons/seal"))
for i in range(len(entry)):
if i>3:
item.setTextAlignment(i, Qt.AlignRight | Qt.AlignVCenter)
if i!=2:
item.setFont(i, monospace_font)
if value and value < 0:
item.setForeground(3, red_brush)
item.setForeground(4, red_brush)
if fiat_value and not tx_item['fiat_default']:
item.setForeground(6, blue_brush)
if tx_hash:
item.setData(0, Qt.UserRole, tx_hash)
self.insertTopLevelItem(0, item)
if current_tx == tx_hash:
self.setCurrentItem(item)
def on_edited(self, item, column, prior):
'''Called only when the text actually changes'''
key = item.data(0, Qt.UserRole)
text = item.text(column)
r = self.wallet.get_full_history(domain=self.get_domain(), from_timestamp=None, to_timestamp=None, fx=fx)
seen = set()
history = fx.show_history()
tx_list = list(self.transactions.values())
if r['transactions'] == tx_list:
return
if r['transactions'][:-1] == tx_list:
print_error('history_list: one new transaction')
row = r['transactions'][-1]
txid = row['txid']
if txid not in self.transactions:
self.transactions[txid] = row
self.transactions.move_to_end(txid, last=True)
self.insert_tx(row)
return
else:
print_error('history_list: tx added but txid is already in list (weird), txid: ', txid)
for idx, row in enumerate(r['transactions']):
txid = row['txid']
seen.add(txid)
if txid not in self.transactions:
self.transactions[txid] = row
self.transactions.move_to_end(txid, last=True)
self.insert_tx(row)
continue
old = self.transactions[txid]
if old == row:
continue
self.update_item(txid, self.parent.wallet.get_tx_height(txid))
if history:
self.update_fiat(txid, row)
balance_str = self.parent.format_amount(row['balance'].value, whitespaces=True)
self.txid_to_items[txid][4].setText(balance_str)
self.txid_to_items[txid][4].setData(row['balance'].value, self.SORT_ROLE)
old.clear()
old.update(**row)
removed = 0
l = list(enumerate(self.transactions.keys()))
for idx, txid in l:
if txid not in seen:
del self.transactions[txid]
del self.txid_to_items[txid]
items = self.std_model.takeRow(idx - removed)
removed_txid = items[0].data(self.TX_HASH_ROLE)
assert removed_txid == txid, (idx, removed)
removed += 1
self.apply_filter()
def update_fiat(self, txid, row):
cap_gains = self.parent.fx.get_history_capital_gains_config()
items = self.txid_to_items[txid]
self.ensure_fields_available(items, 7 if cap_gains else 5, txid)
if not row['fiat_default'] and row['fiat_value']:
items[5].setForeground(self.blue_brush)
value_str = self.parent.fx.format_fiat(row['fiat_value'].value)
items[5].setText(value_str)
items[5].setData(row['fiat_value'].value, self.SORT_ROLE)
# fixme: should use is_mine
if row['value'].value < 0 and cap_gains:
acq = row['acquisition_price'].value
items[6].setText(self.parent.fx.format_fiat(acq))
items[6].setData(acq, self.SORT_ROLE)
cg = row['capital_gain'].value
items[7].setText(self.parent.fx.format_fiat(cg))
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
if column == 3:
if column == 2:
self.parent.wallet.set_label(key, text)
self.update_labels()
self.parent.update_completions()
elif column == 6:
self.parent.wallet.set_fiat_value(key, self.parent.fx.ccy, text)
self.on_update()
def on_doubleclick(self, item, column):
if self.permit_edit(item, column):
super(HistoryList, self).on_doubleclick(item, column)
elif column == 5:
tx_item = self.transactions[key]
self.parent.wallet.set_fiat_value(key, self.parent.fx.ccy, text, self.parent.fx, tx_item['value'].value)
value = tx_item['value'].value
if value is not None:
fee = tx_item['fee']
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:
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)
def show_transaction(self, tx_hash):
@ -311,13 +454,13 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
self.parent.show_transaction(tx, label)
def update_labels(self):
root = self.invisibleRootItem()
child_count = root.childCount()
root = self.std_model.invisibleRootItem()
child_count = root.rowCount()
for i in range(child_count):
item = root.child(i)
txid = item.data(0, Qt.UserRole)
item = root.child(i, 2)
txid = item.data(self.TX_HASH_ROLE)
label = self.wallet.get_label(txid)
item.setText(3, label)
item.setText(label)
def update_item(self, tx_hash, tx_mined_status):
if self.wallet is None:
@ -325,31 +468,30 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop):
conf = tx_mined_status.conf
status, status_str = self.wallet.get_tx_status(tx_hash, tx_mined_status)
icon = self.icon_cache.get(":icons/" + TX_ICONS[status])
items = self.findItems(tx_hash, Qt.MatchExactly, column=1)
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:
if tx_hash not in self.txid_to_items:
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)
if not tx:
return
if column is 0:
column_title = "ID"
assert tx, "create_menu: no tx"
if column == 0:
column_title = _('Transaction ID')
column_data = tx_hash
else:
column_title = self.headerItem().text(column)
column_data = item.text(column)
column_title = self.std_model.horizontalHeaderItem(column).text()
column_data = item.text()
tx_URL = block_explorer_URL(self.config, 'tx', tx_hash)
height = self.wallet.get_tx_height(tx_hash).height
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(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data))
for c in self.editable_columns:
menu.addAction(_("Edit {}").format(self.headerItem().text(c)),
lambda bound_c=c: self.editItem(item, bound_c))
label = self.std_model.horizontalHeaderItem(c).text()
# 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))
if is_unconfirmed and tx:
# 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."))
def do_export_history(self, file_name, is_csv):
history = self.transactions
history = self.transactions.values()
lines = []
if is_csv:
for item in history:

4
electrum/gui/qt/installwizard.py

@ -432,7 +432,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
return slayout.is_ext
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)
playout.encrypt_cb.setChecked(True)
self.exec_layout(playout.layout())
@ -446,7 +446,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
@wizard_dialog
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)
self.exec_layout(playout.layout())
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 *
class InvoiceList(MyTreeWidget):
class InvoiceList(MyTreeView):
filter_columns = [0, 1, 2, 3] # Date, Requestor, Description, Amount
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.header().setSectionResizeMode(1, QHeaderView.Interactive)
self.setColumnWidth(1, 200)
self.setModel(QStandardItemModel(self))
self.update()
def on_update(self):
def update(self):
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:
key = pr.get_id()
status = self.parent.invoices.get_status(key)
requestor = pr.get_requestor()
exp = pr.get_expiration_date()
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,'')])
item.setIcon(4, self.icon_cache.get(pr_icons.get(status)))
item.setData(0, Qt.UserRole, key)
item.setFont(1, QFont(MONOSPACE_FONT))
item.setFont(3, QFont(MONOSPACE_FONT))
labels = [date_str, requestor, pr.memo, self.parent.format_amount(pr.get_amount(), whitespaces=True), pr_tooltips.get(status,'')]
item = [QStandardItem(e) for e in labels]
item[4].setIcon(self.icon_cache.get(pr_icons.get(status)))
item[0].setData(Qt.UserRole, key)
item[1].setFont(QFont(MONOSPACE_FONT))
item[3].setFont(QFont(MONOSPACE_FONT))
self.addTopLevelItem(item)
self.setCurrentItem(self.topLevelItem(0))
self.selectionModel().select(self.model().index(0,0), QItemSelectionModel.SelectCurrent)
self.setVisible(len(inv_list))
self.parent.invoices_label.setVisible(len(inv_list))
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):
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()
def on_history(self, b):
self.wallet.clear_coin_price_cache()
self.new_fx_history_signal.emit()
def setup_exception_hook(self):
@ -352,8 +353,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
if self.config.is_dynfee():
self.fee_slider.update()
self.do_update_fee()
# todo: update only unconfirmed tx
self.history_list.update()
self.history_list.update_on_new_fee_histogram()
else:
self.print_error("unexpected network_qt signal:", event, args)
@ -378,9 +378,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
def load_wallet(self, wallet):
wallet.thread = TaskThread(self, self.on_error)
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()
# 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
@ -1110,9 +1107,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
self.from_label = QLabel(_('From'))
grid.addWidget(self.from_label, 3, 0)
self.from_list = MyTreeWidget(self, self.from_list_menu, ['',''])
self.from_list.setHeaderHidden(True)
self.from_list.setMaximumHeight(80)
self.from_list = FromList(self, self.from_list_menu)
grid.addWidget(self.from_list, 3, 1, 1, -1)
self.set_pay_from([])

24
electrum/gui/qt/network_dialog.py

@ -82,8 +82,8 @@ class NodesListWidget(QTreeWidget):
server = item.data(1, Qt.UserRole)
menu.addAction(_("Use as server"), lambda: self.parent.follow_server(server))
else:
index = item.data(1, Qt.UserRole)
menu.addAction(_("Follow this branch"), lambda: self.parent.follow_branch(index))
chain_id = item.data(1, Qt.UserRole)
menu.addAction(_("Follow this branch"), lambda: self.parent.follow_branch(chain_id))
menu.exec_(self.viewport().mapToGlobal(position))
def keyPressEvent(self, event):
@ -100,25 +100,25 @@ class NodesListWidget(QTreeWidget):
def update(self, network: Network):
self.clear()
self.addChild = self.addTopLevelItem
chains = network.get_blockchains()
n_chains = len(chains)
for k, items in chains.items():
b = blockchain.blockchains[k]
for chain_id, interfaces in chains.items():
b = blockchain.blockchains.get(chain_id)
if b is None: continue
name = b.get_name()
if n_chains >1:
if n_chains > 1:
x = QTreeWidgetItem([name + '@%d'%b.get_max_forkpoint(), '%d'%b.height()])
x.setData(0, Qt.UserRole, 1)
x.setData(1, Qt.UserRole, b.forkpoint)
x.setData(1, Qt.UserRole, b.get_id())
else:
x = self
for i in items:
for i in interfaces:
star = ' *' if i == network.interface else ''
item = QTreeWidgetItem([i.host + star, '%d'%i.tip])
item.setData(0, Qt.UserRole, 0)
item.setData(1, Qt.UserRole, i.server)
x.addChild(item)
if n_chains>1:
x.addTopLevelItem(item)
if n_chains > 1:
self.addTopLevelItem(x)
x.setExpanded(True)
@ -410,8 +410,8 @@ class NetworkChoiceLayout(object):
self.set_protocol(p)
self.set_server()
def follow_branch(self, index):
self.network.run_from_another_thread(self.network.follow_chain_given_id(index))
def follow_branch(self, chain_id):
self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id))
self.update()
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")]
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.pw = QLineEdit()
@ -169,12 +169,9 @@ class PasswordLayout(object):
class PasswordLayoutForHW(object):
def __init__(self, wallet, msg, kind, OK_button):
def __init__(self, msg, wallet=None):
self.wallet = wallet
self.kind = kind
self.OK_button = OK_button
vbox = QVBoxLayout()
label = QLabel(msg + "\n")
label.setWordWrap(True)
@ -254,9 +251,11 @@ class ChangePasswordDialogForSW(ChangePasswordDialogBase):
else:
msg = _('Your wallet is password protected and encrypted.')
msg += ' ' + _('Use this dialog to change your password.')
self.playout = PasswordLayout(
wallet, msg, PW_CHANGE, OK_button,
force_disable_encrypt_cb=not wallet.can_have_keystore_encryption())
self.playout = PasswordLayout(msg=msg,
kind=PW_CHANGE,
OK_button=OK_button,
wallet=wallet,
force_disable_encrypt_cb=not wallet.can_have_keystore_encryption())
def run(self):
if not self.exec_():
@ -276,7 +275,7 @@ class ChangePasswordDialogForHW(ChangePasswordDialogBase):
msg = _('Your wallet file is encrypted.')
msg += '\n' + _('Note: If you enable this setting, you will need your hardware device to open your wallet.')
msg += '\n' + _('Use this dialog to toggle encryption.')
self.playout = PasswordLayoutForHW(wallet, msg, PW_CHANGE, OK_button)
self.playout = PasswordLayoutForHW(msg)
def run(self):
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
# SOFTWARE.
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import QTreeWidgetItem, QMenu
from PyQt5.QtGui import QStandardItemModel, QStandardItem
from PyQt5.QtWidgets import QMenu
from PyQt5.QtCore import Qt
from electrum.i18n import _
from electrum.util import format_time, age
from electrum.plugin import run_hook
from electrum.paymentrequest import PR_UNKNOWN
from .util import MyTreeWidget, pr_tooltips, pr_icons
from .util import MyTreeView, pr_tooltips, pr_icons
class RequestList(MyTreeWidget):
class RequestList(MyTreeView):
filter_columns = [0, 1, 2, 3, 4] # Date, Account, Address, Description, Amount
def __init__(self, parent):
MyTreeWidget.__init__(self, parent, self.create_menu, [_('Date'), _('Address'), '', _('Description'), _('Amount'), _('Status')], 3)
self.currentItemChanged.connect(self.item_changed)
self.itemClicked.connect(self.item_changed)
super().__init__(parent, self.create_menu, 3, editable_columns=[])
self.setModel(QStandardItemModel(self))
self.setSortingEnabled(True)
self.setColumnWidth(0, 180)
self.hideColumn(1)
self.update()
self.selectionModel().currentRowChanged.connect(self.item_changed)
def item_changed(self, item):
if item is None:
return
if not item.isSelected():
return
addr = str(item.text(1))
def item_changed(self, idx):
# TODO use siblingAtColumn when min Qt version is >=5.11
addr = self.model().itemFromIndex(idx.sibling(idx.row(), 1)).text()
req = self.wallet.receive_requests.get(addr)
if req is None:
self.update()
return
expires = age(req['time'] + req['exp']) if req.get('exp') else _('Never')
amount = req['amount']
message = self.wallet.labels.get(addr, '')
message = req['memo']
self.parent.receive_address_e.setText(addr)
self.parent.receive_message_e.setText(message)
self.parent.receive_amount_e.setAmount(amount)
@ -68,7 +64,7 @@ class RequestList(MyTreeWidget):
self.parent.expires_label.setText(expires)
self.parent.new_request_button.setEnabled(True)
def on_update(self):
def update(self):
self.wallet = self.parent.wallet
# hide receive tab if no receive requests available
b = len(self.wallet.receive_requests) > 0
@ -86,8 +82,9 @@ class RequestList(MyTreeWidget):
self.parent.set_receive_address(addr)
self.parent.new_request_button.setEnabled(addr != current_address)
# clear the list and fill it again
self.clear()
self.model().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):
address = req['address']
if address not in domain:
@ -95,35 +92,40 @@ class RequestList(MyTreeWidget):
timestamp = req.get('time', 0)
amount = req.get('amount')
expiration = req.get('exp', None)
message = req.get('memo', '')
message = req['memo']
date = format_time(timestamp)
status = req.get('status')
signature = req.get('sig')
requestor = req.get('name', '')
amount_str = self.parent.format_amount(amount) if amount else ""
item = QTreeWidgetItem([date, address, '', message, amount_str, pr_tooltips.get(status,'')])
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:
item.setIcon(2, self.icon_cache.get(":icons/seal.png"))
item.setToolTip(2, 'signed by '+ requestor)
items[2].setIcon(self.icon_cache.get(":icons/seal.png"))
items[2].setToolTip('signed by '+ requestor)
if status is not PR_UNKNOWN:
item.setIcon(6, self.icon_cache.get(pr_icons.get(status)))
self.addTopLevelItem(item)
items[5].setIcon(self.icon_cache.get(pr_icons.get(status)))
items[3].setData(address, Qt.UserRole)
self.model().insertRow(self.model().rowCount(), items)
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:
return
addr = str(item.text(1))
addr = item.text()
req = self.wallet.receive_requests.get(addr)
if req is None:
self.update()
return
column = self.currentColumn()
column_title = self.headerItem().text(column)
column_data = item.text(column)
column = idx.column()
column_title = self.model().horizontalHeaderItem(column).text()
column_data = item.text()
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(_("Save as BIP70 file"), lambda: self.parent.export_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
from functools import partial
from typing import NamedTuple, Callable, Optional
from abc import abstractmethod
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
from electrum.i18n import _
from electrum.i18n import _, languages
from electrum.util import FileImportFailed, FileExportFailed
from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_EXPIRED
@ -398,20 +399,16 @@ class ElectrumItemDelegate(QStyledItemDelegate):
def createEditor(self, 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,
editable_columns=None):
QTreeWidget.__init__(self, parent)
def __init__(self, parent, create_menu, stretch_column=None, editable_columns=None):
super().__init__(parent)
self.parent = parent
self.config = self.parent.config
self.stretch_column = stretch_column
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(create_menu)
self.setUniformRowHeights(True)
# extend the syntax for consistency
self.addChild = self.addTopLevelItem
self.insertChild = self.insertTopLevelItem
self.icon_cache = IconCache()
@ -424,127 +421,143 @@ class MyTreeWidget(QTreeWidget):
editable_columns = set(editable_columns)
self.editable_columns = editable_columns
self.setItemDelegate(ElectrumItemDelegate(self))
self.itemDoubleClicked.connect(self.on_doubleclick)
self.update_headers(headers)
self.current_filter = ""
self.setRootIsDecorated(False) # remove left margin
self.toolbar_shown = False
def update_headers(self, headers):
self.setColumnCount(len(headers))
self.setHeaderLabels(headers)
def set_editability(self, items):
for idx, i in enumerate(items):
i.setEditable(idx in self.editable_columns)
def selected_in_column(self, column: int):
items = self.selectionModel().selectedIndexes()
return list(x for x in items if x.column() == column)
def 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)
for col in range(len(headers)):
sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents
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):
if event.key() in [ Qt.Key_F2, Qt.Key_Return ] and self.editor is None:
self.on_activated(self.currentItem(), self.currentColumn())
else:
QTreeWidget.keyPressEvent(self, 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)
self.on_activated(self.selectionModel().currentIndex())
return
super().keyPressEvent(event)
def on_activated(self, item, column):
def on_activated(self, idx):
# on 'enter' we show the menu
pt = self.visualItemRect(item).bottomLeft()
pt = self.visualRect(idx).bottomLeft()
pt.setX(50)
self.customContextMenuRequested.emit(pt)
def createEditor(self, parent, option, index):
def createEditor(self, parent, option, idx):
self.editor = QStyledItemDelegate.createEditor(self.itemDelegate(),
parent, option, index)
self.editor.editingFinished.connect(self.editing_finished)
parent, option, idx)
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
def editing_finished(self):
# 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:
(item, column, prior_text) = self.editing_itemcol
if self.editor.text() == prior_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)
def edit(self, idx, trigger=QAbstractItemView.AllEditTriggers, event=None):
"""
this is to prevent:
edit: editing failed
from inside qt
"""
return super().edit(idx, trigger, event)
def on_edited(self, idx: QModelIndex, user_role, text):
self.parent.wallet.set_label(user_role, text)
self.parent.history_list.update_labels()
self.parent.update_completions()
def update(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)
def apply_filter(self):
if 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
def get_leaves(self, root):
child_count = root.childCount()
if child_count == 0:
yield root
for i in range(child_count):
item = root.child(i)
for x in self.get_leaves(item):
yield x
def item_from_coordinate(self, row_num, column):
if isinstance(self.model(), QSortFilterProxyModel):
idx = self.model().mapToSource(self.model().index(row_num, column))
return self.model().sourceModel().itemFromIndex(idx)
else:
idx = self.model().index(row_num, column)
return self.model().itemFromIndex(idx)
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):
columns = self.__class__.filter_columns
p = p.lower()
self.current_filter = p
for item in self.get_leaves(self.invisibleRootItem()):
item.setHidden(all([item.text(column).lower().find(p) == -1
for column in columns]))
self.hide_rows()
def hide_rows(self):
for row in range(self.model().rowCount()):
self.hide_row(row)
def create_toolbar(self, config=None):
hbox = QHBoxLayout()
@ -790,22 +803,6 @@ def get_parent_main_window(widget):
return widget
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:
def __init__(self):
@ -817,6 +814,26 @@ class IconCache:
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__":
app = QApplication([])
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
# SOFTWARE.
from typing import Optional, List
from electrum.i18n import _
from .util import *
class UTXOList(MyTreeWidget):
filter_columns = [0, 2] # Address, Label
class UTXOList(MyTreeView):
filter_columns = [0, 1] # Address, Label
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.setSortingEnabled(True)
self.update()
def get_name(self, x):
return x.get('prevout_hash') + ":%d"%x.get('prevout_n')
def on_update(self):
def update(self):
self.wallet = self.parent.wallet
item = self.currentItem()
self.clear()
self.utxos = self.wallet.get_utxos()
for x in self.utxos:
utxos = self.wallet.get_utxos()
self.utxo_dict = {}
self.model().clear()
self.update_headers([ _('Address'), _('Label'), _('Amount'), _('Height'), _('Output point')])
for idx, x in enumerate(utxos):
address = x.get('address')
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'))
amount = self.parent.format_amount(x['value'], whitespaces=True)
utxo_item = SortableTreeWidgetItem([address, label, amount, '%d'%height, name[0:10] + '...' + name[-2:]])
utxo_item.setFont(0, QFont(MONOSPACE_FONT))
utxo_item.setFont(2, QFont(MONOSPACE_FONT))
utxo_item.setFont(4, QFont(MONOSPACE_FONT))
utxo_item.setData(0, Qt.UserRole, name)
labels = [address, label, amount, '%d'%height, name[0:10] + '...' + name[-2:]]
utxo_item = [QStandardItem(x) for x in labels]
self.set_editability(utxo_item)
utxo_item[0].setFont(QFont(MONOSPACE_FONT))
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):
utxo_item.setBackground(0, ColorScheme.BLUE.as_color(True))
self.addChild(utxo_item)
utxo_item[0].setBackground(ColorScheme.BLUE.as_color(True))
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):
selected = [x.data(0, Qt.UserRole) for x in self.selectedItems()]
selected = self.selected_column_0_user_roles()
if not selected:
return
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))
if len(selected) == 1:
txid = selected[0].split(':')[0]
tx = self.wallet.transactions.get(txid)
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))
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)
return s
def update(self, event):
def update(self, event, *args):
self.update_history()
if self.tab == 0:
self.print_history()

65
electrum/interface.py

@ -28,7 +28,7 @@ import ssl
import sys
import traceback
import asyncio
from typing import Tuple, Union, List, TYPE_CHECKING
from typing import Tuple, Union, List, TYPE_CHECKING, Optional
from collections import defaultdict
import aiorpcx
@ -140,14 +140,14 @@ def serialize_server(host: str, port: Union[str, int], protocol: str) -> str:
class Interface(PrintError):
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.got_disconnected = asyncio.Future()
self.server = server
self.host, self.port, self.protocol = deserialize_server(self.server)
self.port = int(self.port)
self.config_path = config_path
self.cert_path = os.path.join(self.config_path, 'certs', self.host)
assert network.config.path
self.cert_path = os.path.join(network.config.path, 'certs', self.host)
self.blockchain = None
self._requested_chunks = set()
self.network = network
@ -281,7 +281,7 @@ class Interface(PrintError):
assert self.tip_header
chain = blockchain.check_header(self.tip_header)
if not chain:
self.blockchain = blockchain.blockchains[0]
self.blockchain = blockchain.get_best_chain()
else:
self.blockchain = chain
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.
bh = self.blockchain.height()
assert bh >= good
assert bh >= good, (bh, good)
if bh == good:
height = good + 1
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
height = bad + 1
branch = blockchain.blockchains.get(bad)
if branch is not None:
# Conflict!! As our fork handling is not completely general,
# we need to delete another fork to save this one.
# Note: This could be a potential DOS vector against Electrum.
# However, mining blocks that satisfy the difficulty requirements
# 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))
self.print_error(f"new fork at bad height {bad}")
forkfun = self.blockchain.fork if 'mock' not in bad_header else bad_header['mock']['fork']
b = forkfun(bad_header) # type: Blockchain
self.blockchain = b
assert b.forkpoint == bad
return 'fork', height
async def _search_headers_backwards(self, height, header):
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,
is_xpub, is_xprv)
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,
BitcoinException, bh2u, bfh, print_error, inv_dict)
from .mnemonic import Mnemonic, load_wordlist
@ -92,8 +92,9 @@ class KeyStore(PrintError):
class Software_KeyStore(KeyStore):
def __init__(self):
def __init__(self, d):
KeyStore.__init__(self)
self.pw_hash_version = d.get('pw_hash_version', 1)
def may_have_password(self):
return not self.is_watching_only()
@ -122,6 +123,12 @@ class Software_KeyStore(KeyStore):
if 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):
# keystore for imported private keys
@ -129,7 +136,7 @@ class Imported_KeyStore(Software_KeyStore):
type = 'imported'
def __init__(self, d):
Software_KeyStore.__init__(self)
Software_KeyStore.__init__(self, d)
self.keypairs = d.get('keypairs', {})
def is_deterministic(self):
@ -142,6 +149,7 @@ class Imported_KeyStore(Software_KeyStore):
return {
'type': self.type,
'keypairs': self.keypairs,
'pw_hash_version': self.pw_hash_version,
}
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,
# and the privkey will encode a txin_type but that txin_type cannot be trusted.
# Removing keys complicates this further.
self.keypairs[pubkey] = pw_encode(serialized_privkey, password)
self.keypairs[pubkey] = pw_encode(serialized_privkey, password, version=self.pw_hash_version)
return txin_type, pubkey
def delete_imported_key(self, key):
self.keypairs.pop(key)
def get_private_key(self, pubkey, password):
sec = pw_decode(self.keypairs[pubkey], password)
sec = pw_decode(self.keypairs[pubkey], password, version=self.pw_hash_version)
txin_type, privkey, compressed = deserialize_privkey(sec)
# this checks the password
if pubkey != ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed):
@ -189,16 +197,17 @@ class Imported_KeyStore(Software_KeyStore):
if new_password == '':
new_password = None
for k, v in self.keypairs.items():
b = pw_decode(v, old_password)
c = pw_encode(b, new_password)
b = pw_decode(v, old_password, version=self.pw_hash_version)
c = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
self.keypairs[k] = c
self.pw_hash_version = PW_HASH_VERSION_LATEST
class Deterministic_KeyStore(Software_KeyStore):
def __init__(self, d):
Software_KeyStore.__init__(self)
Software_KeyStore.__init__(self, d)
self.seed = d.get('seed', '')
self.passphrase = d.get('passphrase', '')
@ -206,12 +215,14 @@ class Deterministic_KeyStore(Software_KeyStore):
return True
def dump(self):
d = {}
d = {
'type': self.type,
'pw_hash_version': self.pw_hash_version,
}
if self.seed:
d['seed'] = self.seed
if self.passphrase:
d['passphrase'] = self.passphrase
d['type'] = self.type
return d
def has_seed(self):
@ -226,10 +237,13 @@ class Deterministic_KeyStore(Software_KeyStore):
self.seed = self.format_seed(seed)
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):
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:
@ -312,10 +326,10 @@ class BIP32_KeyStore(Deterministic_KeyStore, Xpub):
return d
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):
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]:
raise InvalidPassword()
@ -325,13 +339,14 @@ class BIP32_KeyStore(Deterministic_KeyStore, Xpub):
new_password = None
if self.has_seed():
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:
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:
b = pw_decode(self.xprv, old_password)
self.xprv = pw_encode(b, new_password)
b = pw_decode(self.xprv, old_password, version=self.pw_hash_version)
self.xprv = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
self.pw_hash_version = PW_HASH_VERSION_LATEST
def is_watching_only(self):
return self.xprv is None
@ -362,7 +377,7 @@ class Old_KeyStore(Deterministic_KeyStore):
self.mpk = d.get('mpk')
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):
d = Deterministic_KeyStore.dump(self)
@ -484,8 +499,9 @@ class Old_KeyStore(Deterministic_KeyStore):
if new_password == '':
new_password = None
if self.has_seed():
decoded = pw_decode(self.seed, old_password)
self.seed = pw_encode(decoded, new_password)
decoded = pw_decode(self.seed, old_password, version=self.pw_hash_version)
self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
self.pw_hash_version = PW_HASH_VERSION_LATEST

45
electrum/network.py

@ -44,6 +44,7 @@ from .util import PrintError, print_error, log_exceptions, ignore_exceptions, bf
from .bitcoin import COIN
from . import constants
from . import blockchain
from . import bitcoin
from .blockchain import Blockchain, HEADER_SIZE
from .interface import Interface, serialize_server, deserialize_server, RequestTimedOut
from .version import PROTOCOL_VERSION
@ -177,10 +178,10 @@ class Network(PrintError):
if config is None:
config = {} # Do not use mutables as default values!
self.config = SimpleConfig(config) if isinstance(config, dict) else config # type: SimpleConfig
blockchain.blockchains = blockchain.read_blockchains(self.config)
self.print_error("blockchains", list(blockchain.blockchains))
blockchain.read_blockchains(self.config)
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_index = 0
self._blockchain = blockchain.get_best_chain()
# Server for addresses and transactions
self.default_server = self.config.get('server', None)
# Sanitize default server
@ -321,7 +322,11 @@ class Network(PrintError):
self.banner = await session.send_request('server.banner')
self.notify('banner')
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():
self.server_peers = parse_servers(await session.send_request('server.peers.subscribe'))
self.notify('servers')
@ -559,17 +564,24 @@ class Network(PrintError):
filtered = list(filter(lambda iface: iface.blockchain.check_hash(pref_height, pref_hash),
interfaces))
if filtered:
self.print_error("switching to preferred fork")
chosen_iface = random.choice(filtered)
await self.switch_to_interface(chosen_iface.server)
return
# try to switch to longest chain
if self.blockchain().parent_id is None:
return # already on longest chain
filtered = list(filter(lambda iface: iface.blockchain.parent_id is None,
else:
self.print_error("tried to switch to preferred fork but no interfaces are on it")
# try to switch to best chain
if self.blockchain().parent is None:
return # already on best chain
filtered = list(filter(lambda iface: iface.blockchain.parent is None,
interfaces))
if filtered:
self.print_error("switching to best chain")
chosen_iface = random.choice(filtered)
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):
"""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
@log_exceptions
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
try:
await asyncio.wait_for(interface.ready, timeout)
@ -661,7 +673,7 @@ class Network(PrintError):
self.trigger_callback('network_updated')
async def _init_headers_file(self):
b = blockchain.blockchains[0]
b = blockchain.get_best_chain()
filename = b.path()
length = HEADER_SIZE * len(constants.net.CHECKPOINTS) * 2016
if not os.path.exists(filename) or os.path.getsize(filename) < length:
@ -739,8 +751,8 @@ class Network(PrintError):
def blockchain(self) -> Blockchain:
interface = self.interface
if interface and interface.blockchain is not None:
self._blockchain_index = interface.blockchain.forkpoint
return blockchain.blockchains[self._blockchain_index]
self._blockchain = interface.blockchain
return self._blockchain
def get_blockchains(self):
out = {} # blockchain_id -> list(interfaces)
@ -752,13 +764,6 @@ class Network(PrintError):
out[chain_id] = r
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):
height = chain.get_max_forkpoint()
header_hash = chain.get_hash(height)
@ -768,7 +773,7 @@ class Network(PrintError):
}
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)
if not bc:
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_xpub != expected_xpub)):
# 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?!" %
expected_xfp)
@ -454,9 +456,12 @@ class Coldcard_KeyStore(Hardware_KeyStore):
# inputs section
for txin in inputs:
utxo = txin['prev_tx'].outputs()[txin['prevout_n']]
spendable = txin['prev_tx'].serialize_output(utxo)
write_kv(PSBT_IN_WITNESS_UTXO, spendable)
if Transaction.is_segwit_input(txin):
utxo = txin['prev_tx'].outputs()[txin['prevout_n']]
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)

8
electrum/plugins/digitalbitbox/digitalbitbox.py

@ -4,7 +4,7 @@
#
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,
is_address)
from electrum.bip32 import serialize_xpub, deserialize_xpub
@ -396,10 +396,10 @@ class DigitalBitbox_Client():
reply = ""
try:
secret = sha256d(self.password)
msg = EncodeAES(secret, msg)
msg = EncodeAES_base64(secret, msg)
reply = self.hid_send_plain(msg)
if 'ciphertext' in reply:
reply = DecodeAES(secret, ''.join(reply["ciphertext"]))
reply = DecodeAES_base64(secret, ''.join(reply["ciphertext"]))
reply = to_string(reply, 'utf8')
reply = json.loads(reply)
if 'error' in reply:
@ -716,7 +716,7 @@ class DigitalBitboxPlugin(HW_PluginBase):
key_s = base64.b64decode(self.digitalbitbox_config['encryptionprivkey'])
args = 'c=data&s=0&dt=0&uuid=%s&pl=%s' % (
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:
requests.post(url, args)

14
electrum/plugins/hw_wallet/qt.py

@ -27,7 +27,8 @@
import threading
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.i18n import _
@ -114,11 +115,16 @@ class QtHandlerBase(QObject, PrintError):
def passphrase_dialog(self, msg, confirm):
# If confirm is true, require the user to enter the passphrase twice
parent = self.top_level_window()
d = WindowModalDialog(parent, _("Enter Passphrase"))
if confirm:
d = PasswordDialog(parent, None, msg, PW_PASSPHRASE)
confirmed, p, passphrase = d.run()
OK_button = OkButton(d)
playout = PasswordLayout(msg=msg, kind=PW_PASSPHRASE, OK_button=OK_button)
vbox = QVBoxLayout()
vbox.addLayout(playout.layout())
vbox.addLayout(Buttons(CancelButton(d), OK_button))
d.setLayout(vbox)
passphrase = playout.new_password() if d.exec_() else None
else:
d = WindowModalDialog(parent, _("Enter Passphrase"))
pw = QLineEdit()
pw.setEchoMode(2)
pw.setMinimumWidth(200)

5
electrum/plugins/labels/labels.py

@ -73,7 +73,10 @@ class LabelsPlugin(BasePlugin):
url = 'https://' + self.target_host + url
async with make_aiohttp_session(self.proxy) as session:
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):
wallet_data = self.wallets.get(wallet, None)

25
electrum/qrscanner.py

@ -40,7 +40,7 @@ except BaseException:
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:
raise RuntimeError("Cannot start QR scanner; zbar not available.")
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)
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():
device_root = "/sys/class/video4linux"
devices = {} # Name -> device

7
electrum/storage.py

@ -122,12 +122,7 @@ class JsonDB(PrintError):
os.fsync(f.fileno())
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
try:
os.rename(temp_path, self.path)
except:
os.remove(self.path)
os.rename(temp_path, self.path)
os.replace(temp_path, self.path)
os.chmod(self.path, mode)
self.print_error("saved", self.path)
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,
xpub_from_xprv, xpub_type, is_xprv, is_bip32_derivation,
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.ecc import number_to_string, string_to_number
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.keystore import xtype_from_derivation
@ -219,23 +219,26 @@ class Test_bitcoin(SequentialTestCase):
"""Make sure AES is homomorphic."""
payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
password = u'secret'
enc = crypto.pw_encode(payload, password)
dec = crypto.pw_decode(enc, password)
self.assertEqual(dec, payload)
for version in KNOWN_PW_HASH_VERSIONS:
enc = crypto.pw_encode(payload, password, version=version)
dec = crypto.pw_decode(enc, password, version=version)
self.assertEqual(dec, payload)
@needs_test_with_all_aes_implementations
def test_aes_encode_without_password(self):
"""When not passed a password, pw_encode is noop on the payload."""
payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
enc = crypto.pw_encode(payload, None)
self.assertEqual(payload, enc)
for version in KNOWN_PW_HASH_VERSIONS:
enc = crypto.pw_encode(payload, None, version=version)
self.assertEqual(payload, enc)
@needs_test_with_all_aes_implementations
def test_aes_deencode_without_password(self):
"""When not passed a password, pw_decode is noop on the payload."""
payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
enc = crypto.pw_decode(payload, None)
self.assertEqual(payload, enc)
for version in KNOWN_PW_HASH_VERSIONS:
enc = crypto.pw_decode(payload, None, version=version)
self.assertEqual(payload, enc)
@needs_test_with_all_aes_implementations
def test_aes_decode_with_invalid_password(self):
@ -243,8 +246,10 @@ class Test_bitcoin(SequentialTestCase):
payload = u"blah"
password = u"uber secret"
wrong_password = u"not the password"
enc = crypto.pw_encode(payload, password)
self.assertRaises(Exception, crypto.pw_decode, enc, wrong_password)
for version in KNOWN_PW_HASH_VERSIONS:
enc = crypto.pw_encode(payload, password, version=version)
with self.assertRaises(InvalidPassword):
crypto.pw_decode(enc, wrong_password, version=version)
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',

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 import blockchain
from electrum.interface import Interface
from electrum.crypto import sha256
from electrum.util import bh2u
class MockTaskGroup:
async def spawn(self, x): return
@ -17,10 +20,14 @@ class MockNetwork:
class MockInterface(Interface):
def __init__(self, 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.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.blockchain._size = self.tip + 1
async def get_block_header(self, height, assert_mode):
assert self.q.qsize() > 0, (height, assert_mode)
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': 6, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}})
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)
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': 6, 'mock': {'binary':1,'check':lambda x: True, 'connect': lambda x: True}})
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)
def test_can_connect_during_backward(self):
@ -87,7 +94,10 @@ class TestNetwork(unittest.TestCase):
self.assertEqual(self.interface.q.qsize(), 0)
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):
blockchain.blockchains = {}

72
electrum/tests/test_wallet.py

@ -3,9 +3,16 @@ import tempfile
import sys
import os
import json
from decimal import Decimal
from unittest import TestCase
import time
from io import StringIO
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
@ -64,7 +71,70 @@ class TestWalletStorage(WalletTestCase):
storage.put(key, value)
storage.write()
contents = ""
with open(self.wallet_path, "r") as f:
contents = f.read()
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 json
import time
from typing import NamedTuple, Optional
import aiohttp
from aiohttp_socks import SocksConnector, SocksVer
@ -129,31 +130,15 @@ class UserCancelled(Exception):
'''An exception that is suppressed from the user'''
pass
class Satoshis(object):
__slots__ = ('value',)
def __new__(cls, value):
self = super(Satoshis, cls).__new__(cls)
self.value = value
return self
def __repr__(self):
return 'Satoshis(%d)'%self.value
class Satoshis(NamedTuple):
value: int
def __str__(self):
return format_satoshis(self.value) + " BTC"
class Fiat(object):
__slots__ = ('value', 'ccy')
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__()
class Fiat(NamedTuple):
value: Optional[Decimal]
ccy: str
def __str__(self):
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
APK_VERSION = '3.2.3.1' # read by buildozer.spec
ELECTRUM_VERSION = '3.3.0' # version of the client package
APK_VERSION = '3.3.0.0' # read by buildozer.spec
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.contacts = Contacts(self.storage)
self.coin_price_cache = {}
self._coin_price_cache = {}
def load_and_cleanup(self):
self.load_keystore()
@ -247,24 +247,37 @@ class Abstract_Wallet(AddressSynchronizer):
self.storage.put('labels', self.labels)
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:
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, {})
if d and txid in d:
d.pop(txid)
else:
return
# avoid saving empty dict
return True
else:
try:
Decimal(text)
except:
return
if ccy not in self.fiat_value:
self.fiat_value[ccy] = {}
self.fiat_value[ccy][txid] = text
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)
return reset
def get_fiat_value(self, txid, ccy):
fiat_value = self.fiat_value.get(ccy, {}).get(txid)
@ -423,21 +436,11 @@ class Abstract_Wallet(AddressSynchronizer):
income += value
# fiat computations
if fx and fx.is_enabled() and fx.get_history_config():
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 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
fiat_fields = self.get_tx_item_fiat(tx_hash, value, fx, tx_fee)
fiat_value = fiat_fields['fiat_value'].value
item.update(fiat_fields)
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)
capital_gains += cg
capital_gains += fiat_fields['capital_gain'].value
fiat_expenditures += -fiat_value
else:
fiat_income += fiat_value
@ -478,6 +481,27 @@ class Abstract_Wallet(AddressSynchronizer):
'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):
label = self.labels.get(tx_hash, '')
if label is '':
@ -1154,6 +1178,9 @@ class Abstract_Wallet(AddressSynchronizer):
total_price += self.coin_price(ser.split(':')[0], price_func, ccy, v)
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):
"""
Acquisition price of a coin.
@ -1162,13 +1189,12 @@ class Abstract_Wallet(AddressSynchronizer):
if txin_value is None:
return Decimal('NaN')
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:
return result
if self.txi.get(txid, {}) != {}:
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
else:
fiat_value = self.get_fiat_value(txid, ccy)
@ -1353,8 +1379,8 @@ class Imported_Wallet(Simple_Wallet):
def get_public_key(self, address):
return self.addresses[address].get('pubkey')
def import_private_keys(self, keys: List[str], password: Optional[str]) -> Tuple[List[str],
List[Tuple[str, str]]]:
def import_private_keys(self, keys: List[str], password: Optional[str],
write_to_disk=True) -> Tuple[List[str], List[Tuple[str, str]]]:
good_addr = [] # type: List[str]
bad_keys = [] # type: List[Tuple[str, str]]
for key in keys:
@ -1372,7 +1398,7 @@ class Imported_Wallet(Simple_Wallet):
self.add_address(addr)
self.save_keystore()
self.save_addresses()
self.save_transactions(write=True)
self.save_transactions(write=write_to_disk)
return good_addr, bad_keys
def import_private_key(self, key: str, password: Optional[str]) -> str:

Loading…
Cancel
Save