diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 000000000..25bc3d76d --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,8 @@ +version = 1 + +[[analyzers]] +name = "python" +enabled = true + + [analyzers.meta] + runtime_version = "3.x.x" diff --git a/contrib/slp_validation_proxy_server/README.md b/contrib/slp_validation_proxy_server/README.md new file mode 100644 index 000000000..96a2733e9 --- /dev/null +++ b/contrib/slp_validation_proxy_server/README.md @@ -0,0 +1,65 @@ +# SLP Token Validation Proxy Server + +Electron-Cash-SLP can be run in daemon mode to serve token validation requests using the JSON RPC interface. The following instructions explain how to set this service up using Ubuntu with nginx reverse proxy server. + +## Docker + +The validation server can be run as a docker container. See the `docker` folder for Dockerfiles for both mainnet and testnet. + +For mainnet SLP validator use: +- `docker build -t ec-slp .` +- `docker run -d -p 5111:5111 --restart "always" ec-slp` +- Test: `curl --data-binary '{"jsonrpc": "2.0", "id":"testing", "method": "slpvalidate", "params": ["2504b5b6a6ec42b040a71abce1acd71592f7e2a3e33ffa9c415f91a6b76deb45", false, false] }' -H 'content-type: text/plain;' 0.0.0.0:5111` + +For testnet SLP validator use: +- `docker build -t ec-slp-testnet .` +- `docker run -d -p 5112:5112 --restart "always" ec-slp-test` +- Test: `curl --data-binary '{"jsonrpc": "2.0", "id":"testing", "method": "slpvalidate", "params": ["5e9454840d838c81ac0c41f0754df239f1c1012623161359fbf6e22599605c25", false, false] }' -H 'content-type: text/plain;' 0.0.0.0:5112` + +Skip steps 1 & 2 if you're using docker. + +## 1) Initial Server Config Steps + +1) Setup an Ubuntu vps. + +2) Clone this project into the home directory (i.e. `~/`) & cd into the `Electron-Cash-SLP` directory. + +3) Run the proper Electron Cash installation commands as described in the README for this project. + +4) Open `~/.electron-cash/config` and set `rpcport` to some constant port number & `rpcpassword=""`. + +5) Run `./elctron-cash create` to create a new wallet file. This wallet should not be used to store any funds, it is only used to store SLP validation data for cache purposes. + +## 2) Creating a persistent service with systemd + +1) Copy the file named `slpvalidate.service` into `/lib/systemd/system/` directory. Make sure the paths within the `slpvalidate.serice` file match the location of your Electron-Cash-SLP directory. + +2) Run `sudo systemctl enable slpvalidate` + +3) Run `sudo systemctl start slpvalidate` + +4) Check that the service is running via `sudo systemctl status slpvalidate` + +## 3) Setting up the reverse proxy server for this validation service. + +1) Setup an nginx server per these instructions: https://linuxize.com/post/how-to-install-nginx-on-ubuntu-18-04/ + +2) Do an initial Setup for SSL via "Let's Enctypy" using these instructions but for your desired domain: https://linuxize.com/post/secure-nginx-with-let-s-encrypt-on-ubuntu-18-04/ + +3) Use the Nginx Server block file named `simpleledger.info`. Update the contents of the file to reflect your specific domain / sub-domain. Rename the file to reflect your specific domain / sub-domain. Then copy this file into your `/etc/nginx/sites-available/` directory. + +4) Run `sudo ln -s /etc/nginx/sites-available/ /etc/nginx/sites-enabled/` + +5) Check that the syntax is all good: `sudo nginx -t` + +6) Restart Nginx: `sudo systemctl restart nginx` + +7) Test that the service is working via: `curl --data-binary '{"jsonrpc": "2.0", "id":"testing", "method": "slpvalidate", "params": ["2504b5b6a6ec42b040a71abce1acd71592f7e2a3e33ffa9c415f91a6b76deb45", false, false] }' -H 'content-type: text/plain;' https://validate.simpleledger.info`. Replace `validate.simpleledger.info` with your own domain. + +## Other notes & warnings + +* You can speed up your SLP validation server by also installing ElectrumX side-by-side and connecting to it directly. + +* Running multiple instances of EC SLP using a load balancer can be accomplished using `electron-cash daemon --dir=`, where the directory is just a copy of the `~/.electron-cash` directory. + +* It should be noted that the Electron-Cash-SLP validator was not designed to be operated as a long running server application. In this type of operation the validator object may cause memory useage issues as the number of validation requests increases. Each time a new validation is performed the DAG and validation results are added to memory for caching purposes. Also, if the Electron-Cash-SLP daemon is not shut down via command-line, then the previously calculated validation results may not be written to the wallet file for future use. Future improvements to the validator will need to fix these issues so that the validator can be run safely as a long running process. \ No newline at end of file diff --git a/contrib/slp_validation_proxy_server/docker/mainnet/Dockerfile b/contrib/slp_validation_proxy_server/docker/mainnet/Dockerfile new file mode 100644 index 000000000..422b94469 --- /dev/null +++ b/contrib/slp_validation_proxy_server/docker/mainnet/Dockerfile @@ -0,0 +1,26 @@ +FROM ubuntu:18.04 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update +RUN apt-get install -y git curl nano gnupg wget +RUN apt-get install -y python3-pip python3-setuptools expect + +RUN git clone -b 3.4.4 https://github.com/simpleledger/Electron-Cash-SLP.git && \ + cd Electron-Cash-SLP && \ + python3 setup.py install && \ + apt-get -y install protobuf-compiler && \ + protoc --proto_path=lib/ --python_out=lib/ lib/paymentrequest.proto + +COPY ./config /Electron-Cash-SLP/config +COPY ./create_wallet.sh /Electron-Cash-SLP/create_wallet.sh +COPY ./start_daemon.sh /Electron-Cash-SLP/start_daemon.sh + +WORKDIR /Electron-Cash-SLP + +RUN ./create_wallet.sh + +EXPOSE 5112 + +#CMD ["/bin/bash"] +CMD ["./start_daemon.sh"] \ No newline at end of file diff --git a/contrib/slp_validation_proxy_server/docker/mainnet/config b/contrib/slp_validation_proxy_server/docker/mainnet/config new file mode 100644 index 000000000..729f0dd8a --- /dev/null +++ b/contrib/slp_validation_proxy_server/docker/mainnet/config @@ -0,0 +1,7 @@ +{ + "config_version": 2, + "rpcpassword": "", + "rpcuser": "user", + "rpcport": 5111, + "rpchost": "0.0.0.0" +} \ No newline at end of file diff --git a/contrib/slp_validation_proxy_server/docker/mainnet/create_wallet.sh b/contrib/slp_validation_proxy_server/docker/mainnet/create_wallet.sh new file mode 100644 index 000000000..d5edeaf65 --- /dev/null +++ b/contrib/slp_validation_proxy_server/docker/mainnet/create_wallet.sh @@ -0,0 +1,6 @@ +#!/usr/bin/expect -f +set timeout 10 +spawn ./electron-cash create --dir=/Electron-Cash-SLP +expect "Password (hit return if you do not wish to encrypt your wallet):" +send "\r" +expect eof \ No newline at end of file diff --git a/contrib/slp_validation_proxy_server/docker/mainnet/start_daemon.sh b/contrib/slp_validation_proxy_server/docker/mainnet/start_daemon.sh new file mode 100644 index 000000000..f2758ac8a --- /dev/null +++ b/contrib/slp_validation_proxy_server/docker/mainnet/start_daemon.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -m +./electron-cash daemon --dir=/Electron-Cash-SLP & +sleep 5 +./electron-cash daemon --dir=/Electron-Cash-SLP load_wallet +fg %1 \ No newline at end of file diff --git a/contrib/slp_validation_proxy_server/docker/testnet/Dockerfile b/contrib/slp_validation_proxy_server/docker/testnet/Dockerfile new file mode 100644 index 000000000..d56b32beb --- /dev/null +++ b/contrib/slp_validation_proxy_server/docker/testnet/Dockerfile @@ -0,0 +1,26 @@ +FROM ubuntu:18.04 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update +RUN apt-get install -y git curl nano gnupg wget +RUN apt-get install -y python3-pip python3-setuptools expect + +RUN git clone -b 3.4.4 https://github.com/simpleledger/Electron-Cash-SLP.git && \ + cd Electron-Cash-SLP && \ + python3 setup.py install && \ + apt-get -y install protobuf-compiler && \ + protoc --proto_path=lib/ --python_out=lib/ lib/paymentrequest.proto + +COPY ./config /Electron-Cash-SLP/testnet/config +COPY ./create_wallet.sh /Electron-Cash-SLP/create_wallet.sh +COPY ./start_daemon.sh /Electron-Cash-SLP/start_daemon.sh + +WORKDIR /Electron-Cash-SLP + +RUN ./create_wallet.sh + +EXPOSE 5112 + +#CMD ["/bin/bash"] +CMD ["./start_daemon.sh"] \ No newline at end of file diff --git a/contrib/slp_validation_proxy_server/docker/testnet/config b/contrib/slp_validation_proxy_server/docker/testnet/config new file mode 100644 index 000000000..17661c7fc --- /dev/null +++ b/contrib/slp_validation_proxy_server/docker/testnet/config @@ -0,0 +1,7 @@ +{ + "config_version": 2, + "rpcpassword": "", + "rpcuser": "user", + "rpcport": 5112, + "rpchost": "0.0.0.0" +} \ No newline at end of file diff --git a/contrib/slp_validation_proxy_server/docker/testnet/create_wallet.sh b/contrib/slp_validation_proxy_server/docker/testnet/create_wallet.sh new file mode 100644 index 000000000..26dc224e3 --- /dev/null +++ b/contrib/slp_validation_proxy_server/docker/testnet/create_wallet.sh @@ -0,0 +1,6 @@ +#!/usr/bin/expect -f +set timeout 10 +spawn ./electron-cash create --testnet --dir=/Electron-Cash-SLP +expect "Password (hit return if you do not wish to encrypt your wallet):" +send "\r" +expect eof \ No newline at end of file diff --git a/contrib/slp_validation_proxy_server/docker/testnet/start_daemon.sh b/contrib/slp_validation_proxy_server/docker/testnet/start_daemon.sh new file mode 100644 index 000000000..5eca75978 --- /dev/null +++ b/contrib/slp_validation_proxy_server/docker/testnet/start_daemon.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -m +./electron-cash daemon --testnet --dir=/Electron-Cash-SLP & +sleep 5 +./electron-cash daemon --testnet --dir=/Electron-Cash-SLP load_wallet +fg %1 \ No newline at end of file diff --git a/contrib/slp_validation_proxy_server/simpleledger.info b/contrib/slp_validation_proxy_server/simpleledger.info new file mode 100644 index 000000000..6af897f0b --- /dev/null +++ b/contrib/slp_validation_proxy_server/simpleledger.info @@ -0,0 +1,37 @@ +server { + listen 80; + listen [::]:80; + + server_name validate.simpleledger.info; + + include snippets/letsencrypt.conf; + return 301 https://$host$request_uri; +} + +log_format my_tracking $request_body; + +server { + listen 443 ssl http2; + server_name validate.simpleledger.info; + + ssl_certificate /etc/letsencrypt/live/validate.simpleledger.info/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/validate.simpleledger.info/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/validate.simpleledger.info/chain.pem; + include snippets/ssl.conf; + include snippets/letsencrypt.conf; + + location / { + if ($request_method != POST) { + return 405; + } + + proxy_pass http://127.0.0.1:5111; + + add_header X-Frame-Options ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + + access_log /var/log/nginx/simpleledger.info.access.log my_tracking; + error_log /var/log/nginx/simpleledger.info.error.log; + } diff --git a/contrib/slp_validation_proxy_server/slpvalidate.service b/contrib/slp_validation_proxy_server/slpvalidate.service new file mode 100644 index 000000000..2f9a6cac9 --- /dev/null +++ b/contrib/slp_validation_proxy_server/slpvalidate.service @@ -0,0 +1,13 @@ +[Unit] +Description=SLP Token Validation Service +After=network.target + +[Service] +User=ubuntu +ExecStartPre=/bin/sleep 2.0 +ExecStart=/home/ubuntu/Electron-Cash-SLP/electron-cash daemon -v +ExecStartPost=/bin/sleep 2.0 +ExecStartPost=/home/ubuntu/Electron-Cash-SLP/electron-cash daemon load_wallet + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/contrib/zclassic/osx.spec b/contrib/zclassic/osx.spec index 766a4fbbe..0f99dfa7f 100644 --- a/contrib/zclassic/osx.spec +++ b/contrib/zclassic/osx.spec @@ -124,7 +124,7 @@ exe = EXE(pyz, upx=False, console=False, icon='icons/electrum-zclassic.ico', - name=os.path.join('build/electrum-zclassic/electrum-zclassic', cmdline_name)) + name=os.path.join('build/electrum-zclassic/electrum-zslp', cmdline_name)) # trezorctl separate bin tctl_a = Analysis([os.path.join(PY36BINDIR, 'trezorctl')], @@ -148,10 +148,10 @@ coll = COLLECT(exe, tctl_exe, a.datas, strip=False, upx=False, - name=os.path.join('dist', 'electrum-zclassic')) + name=os.path.join('dist', 'electrum-zslp')) app = BUNDLE(coll, - name=os.path.join('dist', 'Electrum-Zclassic.app'), - appname="Electrum-Zclassic", + name=os.path.join('dist', 'Electrum-ZSLP.app'), + appname="Electrum-ZSLP", icon='electrum-zclassic.icns', version = 'ELECTRUM_VERSION') diff --git a/contrib/zclassic/requirements.txt b/contrib/zclassic/requirements.txt index 5cb8a59b1..de5a1d635 100644 --- a/contrib/zclassic/requirements.txt +++ b/contrib/zclassic/requirements.txt @@ -11,5 +11,5 @@ PySocks==1.6.7 qrcode==5.3 requests==2.18.4 six==1.11.0 -urllib3==1.22 +urllib3==1.24.2 pyblake2==1.1.2 diff --git a/contrib/zclassic/travis/travis-build-osx.sh b/contrib/zclassic/travis/travis-build-osx.sh index 601bc3771..16241b0d1 100755 --- a/contrib/zclassic/travis/travis-build-osx.sh +++ b/contrib/zclassic/travis/travis-build-osx.sh @@ -40,9 +40,9 @@ cp contrib/zclassic/pyi_tctl_runtimehook.py . pyinstaller \ -y \ - --name electrum-zclassic-$ELECTRUM_ZCL_VERSION.bin \ + --name electrum-zslp-$ELECTRUM_ZCL_VERSION.bin \ osx.spec -sudo hdiutil create -fs HFS+ -volname "Electrum-Zclassic" \ - -srcfolder dist/Electrum-Zclassic.app \ - dist/electrum-zclassic-$ELECTRUM_ZCL_VERSION-macosx.dmg +sudo hdiutil create -fs HFS+ -volname "Electrum-ZSLP" \ + -srcfolder dist/Electrum-ZSLP.app \ + dist/electrum-zslp-$ELECTRUM_ZCL_VERSION-macosx.dmg diff --git a/electrum-zclassic.desktop b/electrum-zclassic.desktop index c1f7d9889..b340277d5 100644 --- a/electrum-zclassic.desktop +++ b/electrum-zclassic.desktop @@ -10,7 +10,8 @@ Icon=electrum-zclassic.png Name[en_US]=Electrum-Zclassic Wallet Name=Electrum-Zclassic Wallet Categories=Finance;Network; -StartupNotify=false +StartupNotify=true +StartupWMClass=Electrum Zclassic Terminal=false Type=Application MimeType=x-scheme-handler/zclassic; diff --git a/electrum-zclassic b/electrum-zslp similarity index 99% rename from electrum-zclassic rename to electrum-zslp index 561e2fef4..17eb123a8 100755 --- a/electrum-zclassic +++ b/electrum-zslp @@ -157,8 +157,8 @@ def run_non_RPC(config): elif cmdname == 'create': password = password_dialog() passphrase = config.get('passphrase', '') - seed_type = 'standard' - seed = Mnemonic('en').make_seed(seed_type) + #seed_type = 'standard' <--- This is no longer applicable with SLP version since it only uses BIP-39 + seed = Mnemonic('en').make_seed() k = keystore.from_seed(seed, passphrase, False) storage.put('keystore', k.dump()) storage.put('wallet_type', 'standard') diff --git a/gui/kivy/main_window.py b/gui/kivy/main_window.py index d58741469..a8bd44670 100644 --- a/gui/kivy/main_window.py +++ b/gui/kivy/main_window.py @@ -965,7 +965,7 @@ def show_private_key(addr, pk_label, password): if not self.wallet.can_export(): return try: - key = str(self.wallet.export_private_key(addr, password)[0]) + key = self.wallet.export_private_key(addr, password) pk_label.data = key except InvalidPassword: self.show_error("Invalid PIN") diff --git a/gui/kivy/uix/screens.py b/gui/kivy/uix/screens.py index 57499b056..f4290ec2b 100644 --- a/gui/kivy/uix/screens.py +++ b/gui/kivy/uix/screens.py @@ -321,7 +321,7 @@ def get_new_address(self): self.clear() addr = self.app.wallet.get_unused_address() if addr is None: - addr = self.app.wallet.get_receiving_address() or '' + addr = self.app.wallet.get_receiving_address_text() b = False else: b = True diff --git a/gui/qt/__init__.py b/gui/qt/__init__.py index 853a6d9a8..b463c9e6f 100644 --- a/gui/qt/__init__.py +++ b/gui/qt/__init__.py @@ -72,8 +72,11 @@ def __init__(self, windows): def eventFilter(self, obj, event): if event.type() == QtCore.QEvent.FileOpen: if len(self.windows) >= 1: - self.windows[0].pay_to_URI(event.url().toEncoded()) - return True + try: + self.windows[0].pay_to_URI(event.url().toEncoded()) + return True + except: + pass return False @@ -97,7 +100,7 @@ def __init__(self, config, daemon, plugins): if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"): QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) if hasattr(QGuiApplication, 'setDesktopFileName'): - QGuiApplication.setDesktopFileName('electrum-zclassic.desktop') + QGuiApplication.setDesktopFileName('electrum-zclassic-slp.desktop') self.config = config self.daemon = daemon self.plugins = plugins @@ -111,7 +114,7 @@ def __init__(self, config, daemon, plugins): # init tray self.dark_icon = self.config.get("dark_icon", False) self.tray = QSystemTrayIcon(self.tray_icon(), None) - self.tray.setToolTip('Electrum-Zclassic') + self.tray.setToolTip('Electrum-ZSLP') self.tray.activated.connect(self.tray_activated) self.build_tray_menu() self.tray.show() @@ -133,7 +136,7 @@ def build_tray_menu(self): submenu.addAction(_("Close"), window.close) m.addAction(_("Dark/Light"), self.toggle_tray_icon) m.addSeparator() - m.addAction(_("Exit Electrum-Zclassic"), self.close) + m.addAction(_("Exit Electrum-ZSLP"), self.close) def tray_icon(self): if self.dark_icon: @@ -165,7 +168,7 @@ def new_window(self, path, uri=None): def show_network_dialog(self, parent): if not self.daemon.network: - parent.show_warning(_('You are using Electrum-Zclassic in offline mode; restart Electrum-Zclassic if you want to get connected'), title=_('Offline')) + parent.show_warning(_('You are using Electrum-ZSLP in offline mode; restart Electrum-ZSLP if you want to get connected'), title=_('Offline')) return if self.nd: self.nd.on_update() @@ -197,7 +200,7 @@ def start_new_window(self, path, uri): return if not wallet: storage = WalletStorage(path, manual_upgrades=True) - wizard = InstallWizard(self.config, self.app, self.plugins, storage) + wizard = InstallWizard(self.config, self.app, self.plugins, storage, 'New/Restore Wallet') try: wallet = wizard.run_and_get_wallet(self.daemon.get_wallet) except UserCancelled: @@ -256,6 +259,12 @@ def init_network(self): wizard.init_network(self.daemon.network) wizard.terminate() + def warn_if_no_network(self, parent): + if not self.daemon.network: + self.warning(message=_('You are using Electrum-ZSLP in offline mode; restart Electrum-ZSLP if you want to get connected'), title=_('Offline'), parent=parent) + return True + return False + def main(self): try: self.init_network() diff --git a/gui/qt/address_list.py b/gui/qt/address_list.py index a4592e1e0..90c7bb3f1 100644 --- a/gui/qt/address_list.py +++ b/gui/qt/address_list.py @@ -25,15 +25,15 @@ import webbrowser from electrum_zclassic.i18n import _ -from electrum_zclassic.util import block_explorer_URL +from electrum_zclassic.web import block_explorer_URL from electrum_zclassic.plugins import run_hook -from electrum_zclassic.bitcoin import is_address +from electrum_zclassic.address import Address from .util import * class AddressList(MyTreeWidget): - filter_columns = [0, 1, 2, 3] # Type, Address, Label, Balance + filter_columns = [0, 1, 2] # Address, Label, Balance def __init__(self, parent=None): MyTreeWidget.__init__(self, parent, self.create_menu, [], 2) @@ -63,7 +63,7 @@ def save_toolbar_state(self, state, config): config.set_key('show_toolbar_addresses', state) def refresh_headers(self): - headers = [_('Type'), _('Address'), _('Label'), _('Balance')] + headers = [ _('Address'), _('Index'), _('Label'), _('Balance')] fx = self.parent.fx if fx and fx.get_fiat_address_config(): headers.extend([_(fx.get_currency()+' Balance')]) @@ -83,59 +83,115 @@ def toggle_used(self, state): self.update() def on_update(self): + def item_path(item): # Recursively builds the path for an item eg 'parent_name/item_name' + return item.text(0) if not item.parent() else item_path(item.parent()) + "/" + item.text(0) + def remember_expanded_items(root): + # Save the set of expanded items... so that address list updates don't annoyingly collapse + # our tree list widget due to the update. This function recurses. Pass self.invisibleRootItem(). + expanded_item_names = set() + for i in range(0, root.childCount()): + it = root.child(i) + if it and it.childCount(): + if it.isExpanded(): + expanded_item_names.add(item_path(it)) + expanded_item_names |= remember_expanded_items(it) # recurse + return expanded_item_names + def restore_expanded_items(root, expanded_item_names): + # Recursively restore the expanded state saved previously. Pass self.invisibleRootItem(). + for i in range(0, root.childCount()): + it = root.child(i) + if it and it.childCount(): + restore_expanded_items(it, expanded_item_names) # recurse, do leaves first + old = bool(it.isExpanded()) + new = bool(item_path(it) in expanded_item_names) + if old != new: + it.setExpanded(new) self.wallet = self.parent.wallet - item = self.currentItem() - current_address = item.data(0, Qt.UserRole) if item else None - if self.show_change == 1: - addr_list = self.wallet.get_receiving_addresses() - elif self.show_change == 2: - addr_list = self.wallet.get_change_addresses() - else: - addr_list = self.wallet.get_addresses() + had_item_count = self.topLevelItemCount() + sels = self.selectedItems() + addresses_to_re_select = {item.data(0, Qt.UserRole) for item in sels} + expanded_item_names = remember_expanded_items(self.invisibleRootItem()) + del sels # avoid keeping reference to about-to-be delete C++ objects self.clear() - for address in addr_list: - num = len(self.wallet.get_address_history(address)) - is_used = self.wallet.is_used(address) - label = self.wallet.labels.get(address, '') - c, u, x = self.wallet.get_addr_balance(address) - balance = c + u + x - if self.show_used == 1 and (balance or is_used): - continue - if self.show_used == 2 and balance == 0: - continue - if self.show_used == 3 and not is_used: - continue - balance_text = self.parent.format_amount(balance, whitespaces=True) + # Note we take a shallow list-copy because we want to avoid + # race conditions with the wallet while iterating here. The wallet may + # touch/grow the returned lists at any time if a history comes (it + # basically returns a reference to its own internal lists). The wallet + # may then, in another thread such as the Synchronizer thread, grow + # the receiving or change addresses on Deterministic wallets. While + # probably safe in a language like Python -- and especially since + # the lists only grow at the end, we want to avoid bad habits. + # The performance cost of the shallow copy below is negligible for 10k+ + # addresses even on huge wallets because, I suspect, internally CPython + # does this type of operation extremely cheaply (probably returning + # some copy-on-write-semantics handle to the same list). + receiving_addresses = list(self.wallet.get_receiving_addresses()) + change_addresses = list(self.wallet.get_change_addresses()) + + if self.parent.fx and self.parent.fx.get_fiat_address_config(): fx = self.parent.fx - 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]) - for i in range(6): - if i > 2: - address_item.setTextAlignment(i, Qt.AlignRight) - address_item.setFont(i, QFont(MONOSPACE_FONT)) - else: - address_item = SortableTreeWidgetItem(['', address, label, balance_text, "%d"%num]) - for i in range(5): - if i > 2: - address_item.setTextAlignment(i, Qt.AlignRight) - address_item.setFont(i, QFont(MONOSPACE_FONT)) - if self.wallet.is_change(address): - address_item.setText(0, _('change')) - address_item.setBackground(0, ColorScheme.YELLOW.as_color(True)) + else: + fx = None + account_item = self + sequences = [0,1] if change_addresses else [0] + items_to_re_select = [] + for is_change in sequences: + if len(sequences) > 1: + name = _("Receiving") if not is_change else _("Change") + seq_item = QTreeWidgetItem( [ name, '', '', '', ''] ) + account_item.addChild(seq_item) + if not is_change and not had_item_count: # first time we create this widget, auto-expand the default address list + seq_item.setExpanded(True) + expanded_item_names.add(item_path(seq_item)) else: - address_item.setText(0, _('receiving')) - address_item.setBackground(0, ColorScheme.GREEN.as_color(True)) - address_item.setFont(1, QFont(MONOSPACE_FONT)) - address_item.setData(0, Qt.UserRole, address) # column 0; independent from address column - if self.wallet.is_frozen(address): - address_item.setBackground(1, ColorScheme.BLUE.as_color(True)) - if self.wallet.is_beyond_limit(address): - address_item.setBackground(1, ColorScheme.RED.as_color(True)) - self.addChild(address_item) - if address == current_address: - self.setCurrentItem(address_item) + seq_item = account_item + used_item = QTreeWidgetItem( [ _("Used"), '', '', '', ''] ) + used_flag = False + addr_list = change_addresses if is_change else receiving_addresses + for n, address in enumerate(addr_list): + num = len(self.wallet.get_address_history(address)) + is_used = self.wallet.is_used(address) + balance = sum(self.wallet.get_addr_balance(address)) + address_text = address.to_ui_string() + label = self.wallet.labels.get(address.to_storage_string(),'') + balance_text = self.parent.format_amount(balance, whitespaces=True) + columns = [address_text, str(n), label, balance_text, str(num)] + if fx: + rate = fx.exchange_rate() + fiat_balance = fx.value_str(balance, rate) + columns.insert(4, fiat_balance) + address_item = SortableTreeWidgetItem(columns) + address_item.setTextAlignment(3, Qt.AlignRight) + address_item.setFont(3, QFont(MONOSPACE_FONT)) + if fx: + address_item.setTextAlignment(4, Qt.AlignRight) + address_item.setFont(4, QFont(MONOSPACE_FONT)) + + address_item.setFont(0, QFont(MONOSPACE_FONT)) + address_item.setData(0, Qt.UserRole, address) + address_item.setData(0, Qt.UserRole+1, True) # label can be edited + if self.wallet.is_frozen(address): + address_item.setBackground(0, QColor('lightblue')) + if self.wallet.is_beyond_limit(address, is_change): + address_item.setBackground(0, QColor('red')) + if is_used: + if not used_flag: + seq_item.insertChild(0, used_item) + used_flag = True + used_item.addChild(address_item) + else: + seq_item.addChild(address_item) + if address in addresses_to_re_select: + items_to_re_select.append(address_item) + + for item in items_to_re_select: + # NB: Need to select the item at the end becasue internally Qt does some index magic + # to pick out the selected item and the above code mutates the TreeList, invalidating indices + # and other craziness, which might produce UI glitches. See #1042 + item.setSelected(True) + + # Now, at the very end, enforce previous UI state with respect to what was expanded or not. See #1042 + restore_expanded_items(self.invisibleRootItem(), expanded_item_names) def create_menu(self, position): from electrum_zclassic.wallet import Multisig_Wallet @@ -143,7 +199,7 @@ def create_menu(self, position): can_delete = self.wallet.can_delete_address() selected = self.selectedItems() multi_select = len(selected) > 1 - addrs = [item.text(1) for item in selected] + addrs = [item.data(0, Qt.UserRole) for item in selected] if not addrs: return if not multi_select: @@ -152,7 +208,7 @@ def create_menu(self, position): if not item: return addr = addrs[0] - if not is_address(addr): + if not isinstance(addr, Address): item.setExpanded(not item.isExpanded()) return @@ -181,7 +237,7 @@ def create_menu(self, position): else: menu.addAction(_("Unfreeze"), lambda: self.parent.set_frozen_state([addr], False)) - coins = self.wallet.get_utxos(addrs) + coins = self.wallet.get_spendable_coins(domain = addrs, config = self.config) if coins: menu.addAction(_("Spend from"), lambda: self.parent.spend_coins(coins)) diff --git a/gui/qt/amountedit.py b/gui/qt/amountedit.py index 19adc315b..dcda410d5 100644 --- a/gui/qt/amountedit.py +++ b/gui/qt/amountedit.py @@ -5,7 +5,7 @@ from PyQt5.QtWidgets import (QLineEdit, QStyle, QStyleOptionFrame) from decimal import Decimal -from electrum_zclassic.util import format_satoshis_plain +from electrum_zclassic.util import format_satoshis_plain, format_satoshis_plain_nofloat, get_satoshis_nofloat class MyLineEdit(QLineEdit): @@ -73,6 +73,35 @@ def setAmount(self, x): self.setText("%d"%x) +class SLPAmountEdit(AmountEdit): + + def __init__(self, token_name, token_decimals, is_int = False, parent=None): + AmountEdit.__init__(self, self._base_unit, is_int, parent) + self.set_token(token_name, token_decimals) + + def set_token(self, token_name, token_decimals): + self.token_name = token_name + self.token_decimals = token_decimals + + def _base_unit(self,): + return self.token_name + + def decimal_point(self,): + return self.token_decimals + + def get_amount(self): + try: + return get_satoshis_nofloat(str(self.text()), self.decimal_point()) + except: + return None + + def setAmount(self, amount): + if amount is None: + self.setText(" ") # Space forces repaint in case units changed + else: + self.setText(format_satoshis_plain_nofloat(amount, self.decimal_point())) + + class BTCAmountEdit(AmountEdit): def __init__(self, decimal_point, is_int = False, parent=None): diff --git a/gui/qt/bfp_download_file_dialog.py b/gui/qt/bfp_download_file_dialog.py new file mode 100644 index 000000000..7b5141983 --- /dev/null +++ b/gui/qt/bfp_download_file_dialog.py @@ -0,0 +1,342 @@ +import copy +import datetime +from functools import partial +import json +import threading +import html + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electrum_zclassic.address import Address, PublicKey +from electrum_zclassic.bitcoin import base_encode +from electrum_zclassic.i18n import _ +from electrum_zclassic.plugins import run_hook + +from electrum_zclassic.util import bfh +from .util import * + +from electrum_zclassic.util import format_satoshis_nofloat +from electrum_zclassic.transaction import Transaction +from electrum_zclassic.bitcoinfiles import BfpMessage, BfpUnsupportedBfpMsgType, BfpInvalidOutputMessage, parseOpreturnToChunks + +dialogs = [] # Otherwise python randomly garbage collects the dialogs... + +class BfpDownloadFileDialog(QDialog, MessageBoxMixin): + + got_network_response_meta_sig = pyqtSignal() + got_network_response_chunk_sig = pyqtSignal(dict, int) + + @pyqtSlot() + def got_network_response_slot(self): + self.download_finished = True + + resp = self.json_response + if resp.get('error'): + return self.fail_metadata_info("Download metadata error!\n%r"%(resp['error'].get('message'))) + raw = resp.get('result') + + tx = Transaction(raw) + self.handle_metadata_tx(tx) + + @pyqtSlot(dict, int) + def got_network_response_chunk_slot(self, response, chunk_index): + if response.get('error'): + return self.fail_metadata_info("Download chunk data error!\n%r"%(response['error'].get('message'))) + raw = response.get('result') + + tx = Transaction(raw) + self.handle_chunk_tx(tx, chunk_index) + + def __init__(self, main_window): + # We want to be a top-level window + QDialog.__init__(self, parent=None) + + self.main_window = main_window + self.wallet = main_window.wallet + self.network = main_window.network + self.app = main_window.app + + if self.main_window.gui_object.warn_if_no_network(self.main_window): + return + + self.setWindowTitle(_("Download File via BFP")) + + vbox = QVBoxLayout() + self.setLayout(vbox) + + vbox.addWidget(QLabel("Upload and download documents using the Bitcoin Files Protocol (bitcoinfiles.com)")) + + vbox.addWidget(QLabel(_('File URI (e.g., bitcoinfile:):'))) + self.file_id_e = ButtonsLineEdit() + self.file_id_e.setFixedWidth(550) + vbox.addWidget(self.file_id_e) + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + hbox.addWidget(QLabel(_('File metadata information:'))) + + self.get_info_button = b = QPushButton(_("Get Info")) + b.clicked.connect(self.download_metadata_info) + hbox.addWidget(b) + + self.download_button = b = QPushButton(_("Download File")) + b.clicked.connect(self.download_file) + b.setDisabled(True) + hbox.addWidget(b) + + self.view_tx_button = b = QPushButton(_("View Metadata Tx")) + b.clicked.connect(self.view_tx) + b.setDisabled(True) + hbox.addWidget(b) + hbox.addStretch(1) + + self.file_info_e = QTextBrowser() + #self.token_info_e.setReadOnly(True) + self.file_info_e.setOpenExternalLinks(True) + self.file_info_e.setFixedWidth(600) + self.file_info_e.setMinimumHeight(250) + vbox.addWidget(self.file_info_e) + + self.progress = QProgressBar(self) + self.progress.setGeometry(200, 80, 250, 20) + self.progress.setHidden(True) + vbox.addWidget(self.progress) + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + self.cancel_button = b = QPushButton(_("Cancel")) + self.cancel_button.setAutoDefault(False) + self.cancel_button.setDefault(False) + b.clicked.connect(self.close) + hbox.addWidget(self.cancel_button) + + self.got_network_response_meta_sig.connect(self.got_network_response_slot, Qt.QueuedConnection) + self.got_network_response_chunk_sig.connect(self.got_network_response_chunk_slot, Qt.QueuedConnection) + self.update() + + dialogs.append(self) + self.show() + + self.file_metadata_tx = None + + def closeEvent(self, event): + event.accept() + dialogs.remove(self) + + def download_file(self): + self.txn_downloads = [] + self.file = None + self.chunk_count = self.file_metadata_message.op_return_fields['chunk_count'] + self.progress.setMaximum(self.chunk_count) + self.progress.setMinimum(0) + self.progress.setValue(0) + + metadata_chunk_is_empty = self.file_metadata_message.op_return_fields['chunk_data'] == b'' + + if self.chunk_count > 0: + if not metadata_chunk_is_empty: + self.txn_downloads.append({ 'txid': self.file_metadata_tx.txid(), 'data': self.file_metadata_message.op_return_fields['chunk_data'] }) + + if self.chunk_count > 1 or (self.chunk_count == 1 and metadata_chunk_is_empty): + self.txn_downloads.append({ 'txid': self.file_metadata_tx.inputs()[0]['prevout_hash'], 'data': None} ) + assert self.file_metadata_tx.inputs()[0]['prevout_n'] == 1 + + index = len(self.txn_downloads)-1 + self.file_info_e.textCursor().insertText("Downloading file...") + self.file_info_e.textCursor().insertBlock() + if self.chunk_count > 1 or (self.chunk_count == 1 and metadata_chunk_is_empty): + self.download_chunk_data(self.txn_downloads[index]['txid'], index) + else: + self.build_file() + else: + raise Exception("This file does not contain any data.") + + def download_chunk_data(self, txid, chunk_index): + try: + tx = self.wallet.transactions[txid] + except KeyError: + def callback(response): + self.got_network_response_chunk_sig.emit(response, chunk_index) + requests = [ ('blockchain.transaction.get', [txid]), ] + self.network.send(requests, callback) + else: + self.handle_chunk_tx(tx, chunk_index) + + def build_file(self): + self.progress.setHidden(True) + self.txn_downloads.reverse() + self.file = b'' + for d in self.txn_downloads: + self.file += d['data'] + + self.file_info_e.textCursor().insertText("File download complete.") + self.file_info_e.textCursor().insertBlock() + + import hashlib + readable_hash = hashlib.sha256(self.file).hexdigest() + metadata_hash = self.file_metadata_message.op_return_fields['file_sha256'].hex() + if metadata_hash == '': + self.file_info_e.textCursor().insertText("Info: No file hash provided in metadata.") + elif metadata_hash == readable_hash: + self.file_info_e.textCursor().insertText("Success: Hash of file download matches its own metadata.") + else: + self.file_info_e.textCursor().insertText("Failure: Hash of file download does not match its own metadata.") + self.show_error("Aborting file save.\n\nThe hash provided in the file's metadata does not match the downloaded file data.") + return + + self.file_info_e.textCursor().insertBlock() + filename = self.file_metadata_message.op_return_fields['filename'].decode('utf8') + ext = self.file_metadata_message.op_return_fields['fileext'].decode('utf8') + try: + filenameext = filename + ext if ext[0] == '.' else filename + "." + ext + except IndexError: + filenameext = "" + name = QFileDialog.getSaveFileName(self, 'Save File', filenameext)[0] + if name != '': + file = open(name,'wb') + file.write(self.file) + file.close() + + def handle_chunk_tx(self, tx, chunk_index): + try: + data = parseOpreturnToChunks(tx.outputs()[0][1].to_script(), allow_op_0 = False, allow_op_number = False) + except Exception as e: + raise e + return self.fail_metadata_info(_("This chunk does not contain any data")) + + if len(data) != 1: + return self.fail_metadata_info(_("This chunk does not contain any data")) + + self.progress.setValue(self.progress.value() + 1) + self.progress.setVisible(True) + self.txn_downloads[chunk_index]['data'] = data[0] + if chunk_index < self.chunk_count - 1: + self.txn_downloads.append({ 'txid': tx.inputs()[0]['prevout_hash'], 'data': None }) + assert tx.inputs()[0]['prevout_n'] == 1 + index = len(self.txn_downloads)-1 + self.download_chunk_data(self.txn_downloads[index]['txid'], index) + else: + self.build_file() + + def download_metadata_info(self): + txid = self.file_id_e.text() + txid = txid.replace('bitcoinfile:', '') + txid = txid.replace('bitcoinfiles:', '') + self.file_info_e.setText("Downloading...") + self.download_button.setDisabled(True) + self.view_tx_button.setDisabled(True) + + try: + tx = self.wallet.transactions[txid] + except KeyError: + def callback(response): + self.json_response = response + self.got_network_response_meta_sig.emit() + + requests = [ ('blockchain.transaction.get', [txid]), ] + self.network.send(requests, callback) + else: + self.handle_metadata_tx(tx) + + def handle_metadata_tx(self, tx): + self.file_metadata_tx = tx + self.view_tx_button.setDisabled(False) + + txid = tx.txid() + file_id = self.file_id_e.text().strip() + file_id = file_id.replace('bitcoinfile:', '') + file_id = file_id.replace('bitcoinfiles:', '') + if file_id and txid != file_id: + return self.fail_metadata_info(_('TXID does not match file ID!')) + + try: + bfpMsg = BfpMessage.parseBfpScriptOutput(tx.outputs()[0][1]) + except BfpUnsupportedBfpMsgType as e: + return self.fail_metadata_info(_("Unsupported SLP token version/type - %r.")%(e.args[0],)) + except BfpInvalidOutputMessage as e: + return self.fail_metadata_info(_("This transaction does not contain a valid BFP message.\nReason: %r.")%(e.args,)) + if bfpMsg.msg_type != 1: + return self.fail_metadata_info(_("This is a BFP transaction, however it is not a downloadable file.")) + + f_fieldnames = QTextCharFormat() + f_fieldnames.setFont(QFont(MONOSPACE_FONT)) + f_normal = QTextCharFormat() + + self.file_info_e.clear() + cursor = self.file_info_e.textCursor() + + fields = [ + ('filename', _('name'), 'utf8', None), + ('fileext', _('extension'), 'utf8', None), + ('size', _('bytes'), 'int', None), + ('uri', _('external uri'), 'utf8', 'html'), + ('chunk_count', _('chunks'), 'int', None), + ('file_sha256', _('sha256'), 'hex', None), + ('prev_file_sha256', _('supercedes a file having sha256'), 'hex', None) + ] + + cursor.insertText(_('File Metadata:')) + cursor.insertBlock() + for k,n,e,f in fields: + data = bfpMsg.op_return_fields[k] + if e == 'hex': + friendlystring = None + elif e == 'int': + if data != b'': + friendlystring = str(data) + data = friendlystring + else: + # Attempt to make a friendly string, or fail to hex + try: + # Ascii only + friendlystring = data.decode(e) # raises UnicodeDecodeError with bytes > 127. + + # Count ugly characters (that need escaping in python strings' repr()) + uglies = 0 + for b in data: + if b < 0x20 or b == 0x7f: + uglies += 1 + # Less than half of characters may be ugly. + if 2*uglies >= len(data): + friendlystring = None + except UnicodeDecodeError: + friendlystring = None + + if len(data) == 0: + showstr = '(empty)' + f=None + elif friendlystring is None: + showstr = data.hex() + f=None + else: + showstr = repr(friendlystring) + + cursor.insertText(''*(10 - len(n)) + n + ': ', f_fieldnames) + if f == 'html': + enc_url = html.escape(friendlystring) + enc_text = html.escape(showstr) + cursor.insertHtml('%s'%(enc_url, enc_url, enc_text)) + else: + cursor.insertText(showstr, f_normal) + cursor.insertBlock() + cursor.insertBlock() + #cursor.insertBlock() + + self.file_metadata_message = bfpMsg + self.download_button.setEnabled(True) + self.download_button.setDefault(True) + + def fail_metadata_info(self, message): + self.file_info_e.setText(message) + self.file_id_e.setReadOnly(False) + self.get_info_button.setDisabled(False) + + def view_tx(self,): + self.main_window.show_transaction(self.file_metadata_tx) + + def update(self): + return \ No newline at end of file diff --git a/gui/qt/bfp_upload_file_dialog.py b/gui/qt/bfp_upload_file_dialog.py new file mode 100644 index 000000000..89111fdf2 --- /dev/null +++ b/gui/qt/bfp_upload_file_dialog.py @@ -0,0 +1,486 @@ +import copy +import datetime +import time +from functools import partial +import json +import threading +import sys +from pathlib import Path +from os.path import basename, splitext + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electrum_zclassic.address import Address, PublicKey, Base58Error +from electrum_zclassic.bitcoin import base_encode, TYPE_ADDRESS, TYPE_SCRIPT +from electrum_zclassic.i18n import _ +from electrum_zclassic.plugins import run_hook + +from .util import * + +from electrum_zclassic.util import bfh, format_satoshis_nofloat, format_satoshis_plain_nofloat, NotEnoughFunds, ExcessiveFee, InvalidPassword +from electrum_zclassic.transaction import Transaction + +from electrum_zclassic import bitcoinfiles + +from .transaction_dialog import show_transaction +from electrum_zclassic.slp_checker import SlpTransactionChecker + +from electrum_zclassic.bitcoinfiles import * + +dialogs = [] # Otherwise python randomly garbage collects the dialogs... + + +class BitcoinFilesUploadDialog(QDialog, MessageBoxMixin): + + def __init__(self, parent, file_receiver=None, show_on_create=False, screen_name="Upload Token Document"): + # We want to be a top-level window + QDialog.__init__(self, parent) + + # check parent window type + self.parent = parent + from .slp_create_token_genesis_dialog import SlpCreateTokenGenesisDialog + from .main_window import ElectrumWindow + if isinstance(parent, SlpCreateTokenGenesisDialog): + self.main_window = parent.main_window + self.wallet = parent.main_window.wallet + self.network = parent.main_window.network + elif isinstance(parent, ElectrumWindow): + self.main_window = parent + self.wallet = parent.wallet + self.network = parent.network + else: + raise Exception("Parent must be of type ElectrumWindow or SlpCreateTokenGenesisDialog") + + if self.main_window.gui_object.warn_if_no_network(self.main_window): + return + + self.file_receiver = file_receiver + self.metadata = None + self.filename = None + self.is_dirty = False + self.password = None + + self.setWindowTitle(_(screen_name)) + + vbox = QVBoxLayout() + self.setLayout(vbox) + + vbox.addWidget(QLabel("Upload and download documents using the Bitcoin Files Protocol (bitcoinfiles.com)")) + + # Select File + self.select_file_button = b = QPushButton(_("Select File...")) + b.setAutoDefault(True) + b.setDefault(True) + b.clicked.connect(self.select_file) + vbox.addWidget(self.select_file_button) + + grid = QGridLayout() + grid.setColumnStretch(1, 1) + vbox.addLayout(grid) + row = 0 + + # Local file path + grid.addWidget(QLabel(_('Local Path:')), row, 0) + self.path = QLineEdit("") + self.path.setReadOnly(True) + self.path.setFixedWidth(570) + grid.addWidget(self.path, row, 1) + row += 1 + + # Estimated Fees + grid.addWidget(QLabel(_('Upload Cost (satoshis):')), row, 0) + self.upload_cost_label = QLabel("") + grid.addWidget(self.upload_cost_label, row, 1) + row += 1 + + # File hash + grid.addWidget(QLabel(_('File sha256 (auto-populated):')), row, 0) + self.hash = QLineEdit("") + self.hash.setReadOnly(True) + self.hash.setFixedWidth(570) + self.hash.setInputMask("HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH") + grid.addWidget(self.hash, row, 1) + row += 1 + + # Previous file hash + grid.addWidget(QLabel(_('Previous file sha256 (manual entry):')), row, 0) + self.prev_hash = QLineEdit("") + self.prev_hash.setReadOnly(False) + self.prev_hash.setFixedWidth(570) + self.prev_hash.setInputMask("HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH") + self.prev_hash.textChanged.connect(self.make_dirty) + grid.addWidget(self.prev_hash, row, 1) + row += 1 + + # Originating address checkbox + self.org_addr_cb = cb = QCheckBox(_('Upload file using a specific wallet address')) + self.org_addr_cb.setChecked(False) + grid.addWidget(self.org_addr_cb, row, 1) + cb.clicked.connect(self.toggle_org_addr) + row += 1 + + # Specific file origination address + self.org_add_label = QLabel(_('Originating Address for Upload:')) + self.org_add_label.setHidden(True) + grid.addWidget(self.org_add_label, row, 0) + self.file_org_addr_e = QLineEdit("") + self.file_org_addr_e.setHidden(True) + self.file_org_addr_e.setReadOnly(False) + self.file_org_addr_e.setFixedWidth(570) + self.file_org_addr_e.textChanged.connect(self.make_dirty) + grid.addWidget(self.file_org_addr_e, row, 1) + row += 1 + + # File Receiver Checkbox + self.receiver_addr_cb = cb = QCheckBox(_('Send file to a BCH address')) + self.receiver_addr_cb.setChecked(False) + grid.addWidget(self.receiver_addr_cb, row, 1) + cb.clicked.connect(self.toggle_receiver_addr) + row += 1 + + # Specific file receiver + self.file_receiver_label = QLabel(_('Receiver Address:')) + self.file_receiver_label.setHidden(True) + grid.addWidget(self.file_receiver_label, row, 0) + self.file_receiver_e = QLineEdit("") + self.file_receiver_e.setHidden(True) + self.file_receiver_e.setReadOnly(False) + self.file_receiver_e.setFixedWidth(570) + self.file_receiver_e.textChanged.connect(self.make_dirty) + grid.addWidget(self.file_receiver_e, row, 1) + row += 1 + + # File path + grid.addWidget(QLabel(_('URI after upload (auto-populated):')), row, 0) + self.bitcoinfileAddr_label = QLineEdit("") + self.bitcoinfileAddr_label.setReadOnly(True) + self.bitcoinfileAddr_label.setFixedWidth(570) + grid.addWidget(self.bitcoinfileAddr_label, row, 1) + + self.progress_label = QLabel("") + vbox.addWidget(self.progress_label) + + self.progress = QProgressBar(self) + self.progress.setHidden(True) + self.progress.setGeometry(200, 80, 250, 20) + vbox.addWidget(self.progress) + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + self.cancel_button = b = QPushButton(_("Cancel")) + self.cancel_button.setAutoDefault(False) + self.cancel_button.setDefault(False) + b.clicked.connect(self.close) + b.setDefault(False) + hbox.addWidget(self.cancel_button) + + hbox.addStretch(1) + + self.sign_button = b = QPushButton(_("Sign")) + self.sign_button.setAutoDefault(False) + self.sign_button.setDefault(False) + self.sign_button.setDisabled(True) + b.clicked.connect(self.sign_txns) + b.setDefault(False) + hbox.addWidget(self.sign_button) + + self.upload_button = b = QPushButton(_("Upload")) + self.upload_button.setAutoDefault(False) + self.upload_button.setDefault(False) + self.upload_button.setDisabled(True) + b.clicked.connect(self.upload) + b.setDefault(False) + hbox.addWidget(self.upload_button) + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + warnpm = QIcon(":icons/warning.png").pixmap(20,20) + + l = QLabel(); l.setPixmap(warnpm) + hbox.addWidget(l) + hbox.addWidget(QLabel(_(' WARNING: The selected file will be uploaded to the blockchain and be permanently part of the public record.'))) + l = QLabel(); l.setPixmap(warnpm) + hbox.addStretch(1) + hbox.addWidget(l) + + # check if self.password is needed for wallet + if parent.wallet.has_password(): + from .password_dialog import PasswordDialog + parent = parent + d = PasswordDialog(parent, None) + self.password = d.run() + + if show_on_create: + self.setModal(True) + self.show() + + def toggle_org_addr(self): + self.file_org_addr_e.setVisible(not self.file_org_addr_e.isVisible()) + self.org_add_label.setVisible(not self.org_add_label.isVisible()) + if not self.file_org_addr_e.isVisible(): + self.file_org_addr_e.setText('') + + def toggle_receiver_addr(self): + self.file_receiver_e.setVisible(not self.file_receiver_e.isVisible()) + self.file_receiver_label.setVisible(not self.file_receiver_label.isVisible()) + if not self.file_receiver_e.isVisible(): + self.file_receiver_e.setText('') + + def make_dirty(self): + self.is_dirty = True + self.upload_button.setDisabled(True) + self.progress.setValue(0) + self.tx_batch = [] + self.tx_batch_signed_count = 0 + self.chunks_processed = 0 + self.chunks_total = 0 + self.final_metadata_txn_created = False + if self.filename != '' and self.filename != None: + self.sign_button.setEnabled(True) + self.sign_button.setDefault(True) + else: + self.select_file_button.setDefault(True) + self.path.setText('') + self.hash.setText('') + self.upload_cost_label.setText('') + + def sign_txns(self): + + # set all file Metadata to None for now... UI needs updated for this + self.metadata = { 'filename': None, 'fileext': None, 'filesize': None, 'file_sha256': None, 'prev_file_sha256': None, 'uri': None } + self.metadata['prev_file_sha256'] = self.prev_hash.text() + + if self.prev_hash.text() != '': + if len(self.prev_hash.text()) != 64: + self.show_message(_("Previous document hash must be a 32 byte hexidecimal string or left empty.")) + return + + if self.file_org_addr_e.text() != '': + try: + Address.from_string(self.file_org_addr_e.text()) + except Base58Error: + self.show_message(_("Originating address checksum fails.")) + return + + if self.file_receiver_e.text() != '': + try: + #addr = Address.from_string(self.file_receiver_e.text()) + Address.from_string(self.file_receiver_e.text()) + except Base58Error: + self.show_message(_("Receiver address checksum fails.")) + return + + self.file_receiver = Address.from_string(self.file_receiver_e.text()) + else: + self.file_receiver = None + + if self.filename != '': + self.select_file_button.setDefault(False) + with open(self.filename,"rb") as f: + + # clear fields before re-populating + self.hash.setText('') + self.path.setText('') + self.upload_cost_label.setText('') + self.bitcoinfileAddr_label.setText('') + + bytes = f.read() + if len(bytes) > 5261: + self.show_error("Files cannot be larger than 5.261kB in size.") + return + import hashlib + readable_hash = hashlib.sha256(bytes).hexdigest() + self.hash.setText(readable_hash) + self.path.setText(self.filename) + self.metadata['filesize'] = len(bytes) + try: + self.metadata['filename'] = basename(self.filename).split(os.extsep, 1)[0] + self.metadata['fileext'] = basename(self.filename).split(os.extsep, 1)[1] + except IndexError: + pass + self.metadata['file_sha256'] = readable_hash + cost = calculateUploadCost(len(bytes), self.metadata) + self.upload_cost_label.setText(str(cost)) + if(self.org_addr_cb.isChecked and self.file_org_addr_e.text() != ''): + addr = Address.from_string(self.file_org_addr_e.text()) + elif self.parent.wallet.get_unused_address(): + addr = self.parent.wallet.get_unused_address() + else: + addr = self.parent.wallet.get_addresses()[0] + + # # IMPORTANT: set wallet.send_slpTokenId to None to guard tokens during this transaction + if self.main_window.is_slp_wallet: + self.main_window.token_type_combo.setCurrentIndex(0) + assert self.main_window.slp_token_id is None + + try: + self.tx_batch.append(getFundingTxn(self.parent.wallet, addr, cost, self.parent.config)) + self.progress_label.setText('') + except NotEnoughFunds: + self.show_message("Insufficient funds.\n\nYou must have a CONFIRMED balance of at least: " + str(cost) + " satoshis.") + self.progress_label.setText('') + self.filename = None + self.make_dirty() + return + + # Rewind and put file into chunks + f.seek(0, 0) + chunks = [] + while True: + b = f.read(220) + if b == b'': break + try: + chunks.append(b) + self.chunks_total += 1 + except ValueError: + break + + min_len = 223 - len(make_bitcoinfile_metadata_opreturn(1, 0, None, self.metadata['filename'], self.metadata['fileext'], self.metadata['filesize'], self.metadata['file_sha256'], self.metadata['prev_file_sha256'], self.metadata['uri'])[1].to_script()) + + # determine if the metadata txn data chunk will be empty for progress bar accuracy + if len(bytes) < 220: + chunk_count_adder = 1 if len(bytes) > min_len else 0 + else: + chunk_count_adder = 1 if min_len - (len(bytes) % 220) < 0 else 0 + + self.progress.setMaximum(len(chunks) + chunk_count_adder + 1) + self.progress.setMinimum(0) + self.progress.setVisible(True) + self.progress_label.setText("Signing 1 of " + str(len(chunks) + chunk_count_adder + 1) + " transactions") + + # callback to recursive sign next txn or finish + def sign_done(success): + if success: + self.tx_batch_signed_count += 1 + self.progress.setValue(self.tx_batch_signed_count) + self.activateWindow() + self.raise_() + self.progress_label.setText("Signing " + str(self.tx_batch_signed_count + 1) + " of " + str(len(chunks) + chunk_count_adder + 1) + " transactions") + if self.chunks_processed <= self.chunks_total and not self.final_metadata_txn_created: + try: + chunk_bytes = chunks[self.chunks_processed] + except IndexError: + chunk_bytes = None + # try: + txn, self.final_metadata_txn_created = getUploadTxn(self.parent.wallet, self.tx_batch[self.chunks_processed], self.chunks_processed, self.chunks_total, chunk_bytes, self.parent.config, self.metadata, self.file_receiver) + self.tx_batch.append(txn) + # except NotEnoughFunds as e: + # self.show_message("Insufficient funds for file chunk #" + str(self.chunks_processed + 1)) + # return + self.chunks_processed += 1 + + if self.tx_batch_signed_count < len(self.tx_batch): + self.sign_tx_with_password(self.tx_batch[self.tx_batch_signed_count], sign_done, self.password) + else: + uri = "bitcoinfile:" + self.tx_batch[len(self.tx_batch)-1].txid() + self.bitcoinfileAddr_label.setText(uri) + self.progress_label.setText("Signing complete. Ready to upload.") + self.progress.setHidden(True) + self.is_dirty = False + self.progress.setValue(0) + self.sign_button.setDisabled(True) + self.upload_button.setEnabled(True) + self.upload_button.setDefault(True) + self.activateWindow() + self.raise_() + self.sign_tx_with_password(self.tx_batch[0], sign_done, self.password) + + def sign_tx_with_password(self, tx, callback, password): + '''Sign the transaction in a separate thread. When done, calls + the callback with a success code of True or False. + ''' + + # check transaction SLP validity before signing + try: + assert SlpTransactionChecker.check_tx_slp(self.wallet, tx, coins_to_burn=None, require_tx_in_wallet=False) + except (Exception, AssertionError) as e: + self.show_error(str(e)) + return + + # call hook to see if plugin needs gui interaction + run_hook('sign_tx', self, tx) + + def on_signed(result): + callback(True) + def on_failed(exc_info): + self.on_error(exc_info) + callback(False) + + if self.main_window.tx_external_keypairs: + task = partial(Transaction.sign, tx, self.main_window.tx_external_keypairs) + else: + task = partial(self.wallet.sign_transaction, tx, password) + WaitingDialog(self, _('Signing transaction...'), task, on_signed, on_failed) + + def select_file(self): + if self.wallet.has_password(): + if self.password == None: + x = self.show_message("Incorrect password.") + self.close() + return + try: + self.wallet.check_password(self.password) + except InvalidPassword: + x = self.show_message("Incorrect password.") + self.close() + return + + self.progress.setValue(0) + self.tx_batch = [] + self.tx_batch_signed_count = 0 + self.chunks_processed = 0 + self.chunks_total = 0 + self.final_metadata_txn_created = False + options = QFileDialog.Options() + options |= QFileDialog.DontUseNativeDialog + home = str(Path.home()) + self.filename, _ = QFileDialog.getOpenFileName(self, "Select File to Upload", home, "All Files (*)", options=options) + self.sign_txns() + + def upload(self): + if not self.is_dirty: + self.progress_label.setText("Broadcasting 1 of " + str(len(self.tx_batch)) + " transactions") + self.progress.setVisible(True) + self.progress.setMinimum(0) + self.progress.setMaximum(len(self.tx_batch)) + broadcast_count = 0 + # Broadcast all transaction to the nexwork + for tx in self.tx_batch: + tx_desc = None + status, msg = self.network.broadcast(tx) + # print(status) + # print(msg) + if status == False: + self.show_error(msg) + self.show_error("Upload failed. Try again.") + return + + broadcast_count += 1 + time.sleep(0.1) + self.progress_label.setText("Broadcasting " + str(broadcast_count) + " of " + str(len(self.tx_batch)) + " transactions") + self.progress.setValue(broadcast_count) + QApplication.processEvents() + + self.progress_label.setText("Broadcasting complete.") + self.progress.setHidden(True) + try: + self.parent.token_dochash_e.setText(self.hash.text()) + self.parent.token_url_e.setText(self.bitcoinfileAddr_label.text()) + except AttributeError: + pass + + self.show_message("File upload complete.") + self.close() + + def closeEvent(self, event): + event.accept() + self.parent.raise_() + self.parent.activateWindow() + try: + dialogs.remove(self) + except ValueError: + pass \ No newline at end of file diff --git a/gui/qt/contact_list.py b/gui/qt/contact_list.py index 02a07973d..4be79d632 100644 --- a/gui/qt/contact_list.py +++ b/gui/qt/contact_list.py @@ -25,8 +25,8 @@ import webbrowser from electrum_zclassic.i18n import _ -from electrum_zclassic.bitcoin import is_address -from electrum_zclassic.util import block_explorer_URL +from electrum_zclassic.address import Address +from electrum_zclassic.web import block_explorer_URL from electrum_zclassic.plugins import run_hook from PyQt5.QtGui import * from PyQt5.QtCore import * @@ -77,9 +77,10 @@ def create_menu(self, position): 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)] + URLs = [block_explorer_URL(self.config, 'addr', Address.from_string(key)) + for key in keys if Address.is_valid(key)] if URLs: - menu.addAction(_("View on block explorer"), lambda: map(webbrowser.open, URLs)) + menu.addAction(_("View on block explorer"), lambda: [webbrowser.open(URL) for URL in URLs]) run_hook('create_contact_menu', menu, selected) menu.exec_(self.viewport().mapToGlobal(position)) diff --git a/gui/qt/exception_window.py b/gui/qt/exception_window.py index 229e118a1..2d33a0a6f 100644 --- a/gui/qt/exception_window.py +++ b/gui/qt/exception_window.py @@ -28,6 +28,7 @@ import os import sys import subprocess +import html import requests from PyQt5.QtCore import QObject @@ -47,7 +48,7 @@

Additional information

    -
  • Electrum-Zclassic version: {app_version}
  • +
  • Electrum-ZSLP version: {app_version}
  • Operating system: {os}
  • Wallet type: {wallet_type}
  • Locale: {locale}
  • @@ -181,7 +182,7 @@ def get_additional_info(self): def get_report_string(self): info = self.get_additional_info() - info["traceback"] = "".join(traceback.format_exception(*self.exc_args)) + info["traceback"] = html.escape("".join(traceback.format_exception(*self.exc_args)), quote=False) return issue_template.format(**info) @staticmethod @@ -192,9 +193,9 @@ def get_git_version(): return str(version, "utf8").strip() -def _show_window(*args): +def _show_window(main_window, exctype, value, tb): if not Exception_Window._active_window: - Exception_Window._active_window = Exception_Window(*args) + Exception_Window._active_window = Exception_Window(main_window, exctype, value, tb) class Exception_Hook(QObject): @@ -211,5 +212,8 @@ def __init__(self, main_window, *args, **kwargs): sys.excepthook = self.handler self._report_exception.connect(_show_window) - def handler(self, *args): - self._report_exception.emit(self.main_window, *args) + def handler(self, exctype, value, tb): + if exctype is KeyboardInterrupt or exctype is SystemExit: + sys.__excepthook__(exctype, value, tb) + else: + self._report_exception.emit(self.main_window, exctype, value, tb) diff --git a/gui/qt/history_list.py b/gui/qt/history_list.py index 44a79258e..90221dd1a 100644 --- a/gui/qt/history_list.py +++ b/gui/qt/history_list.py @@ -29,7 +29,8 @@ from electrum_zclassic.wallet import AddTransactionException, TX_HEIGHT_LOCAL from .util import * from electrum_zclassic.i18n import _ -from electrum_zclassic.util import block_explorer_URL, profiler +from electrum_zclassic.util import profiler +from electrum_zclassic.web import block_explorer_URL try: from electrum_zclassic.plot import plot_history, NothingToPlotException @@ -38,16 +39,16 @@ # note: this list needs to be kept in sync with another in kivy TX_ICONS = [ - "unconfirmed.png", - "warning.png", - "unconfirmed.png", + "unconfirmed.svg", + "warning.svg", + "unconfirmed.svg", "offline_tx.png", - "clock1.png", - "clock2.png", - "clock3.png", - "clock4.png", - "clock5.png", - "confirmed.png", + "clock1.svg", + "clock2.svg", + "clock3.svg", + "clock4.svg", + "clock5.svg", + "confirmed.svg", ] @@ -289,7 +290,9 @@ def on_doubleclick(self, item, column): else: tx_hash = item.data(0, Qt.UserRole) tx = self.wallet.transactions.get(tx_hash) - self.parent.show_transaction(tx) + if tx: + label = self.wallet.get_label(tx_hash) or None + self.parent.show_transaction(tx, label) def update_labels(self): root = self.invisibleRootItem() diff --git a/gui/qt/installwizard.py b/gui/qt/installwizard.py index b07798739..f15e69d6f 100644 --- a/gui/qt/installwizard.py +++ b/gui/qt/installwizard.py @@ -94,16 +94,16 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): accept_signal = pyqtSignal() synchronized_signal = pyqtSignal(str) - def __init__(self, config, app, plugins, storage): + def __init__(self, config, app, plugins, storage, partial_title='Install Wizard'): BaseWizard.__init__(self, config, storage) QDialog.__init__(self, None) - self.setWindowTitle('Electrum-Zclassic - ' + _('Install Wizard')) + self.setWindowTitle('Electrum-ZSLP - ' + _(partial_title)) self.app = app self.config = config # Set for base base class self.plugins = plugins self.language_for_seed = config.get('language') - self.setMinimumSize(600, 400) + self.setMinimumSize(600, 450) self.accept_signal.connect(self.accept) self.title = QLabel() self.main_widget = QWidget() @@ -160,6 +160,7 @@ def run_and_get_wallet(self, get_wallet_from_daemon): self.msg_label = QLabel('') vbox.addWidget(self.msg_label) + hbox2 = QHBoxLayout() self.pw_e = QLineEdit('', self) self.pw_e.setFixedWidth(150) @@ -169,7 +170,22 @@ def run_and_get_wallet(self, get_wallet_from_daemon): hbox2.addWidget(self.pw_e) hbox2.addStretch() vbox.addLayout(hbox2) - self.set_layout(vbox, title=_('Electrum-Zclassic wallet')) + + logo = QLabel() + logo.setPixmap(QPixmap(":icons/slp_logo_hollow.png").scaledToWidth(52)) + logo.setMaximumWidth(52) + vbox.addWidget(QLabel(_("
    NOTE: This version of Electrum Zclassic is ZSLP token aware."))) + vbox.addWidget(logo) + vbox.addWidget(QLabel(_("New wallets ZSLP use m/44'/465'/0' as the address derivation path.") + '\n' \ + + _("Funds will not be accessible with non-ZSLP versions of Electrum Zclassic."))) + + + vbox.addWidget(QLabel(_("To avoid losing ZSLP tokens, you should avoid opening a wallet on") + '\n' \ + + _("wallet software not aware of ZSLP tokens."))) + + vbox.addWidget(QLabel(_("For more information visit: https://zslp.org"))) + + self.set_layout(vbox, title=_('Electrum ZSLP wallet')) wallet_folder = os.path.dirname(self.storage.path) @@ -194,7 +210,7 @@ def on_filename(filename): if self.storage: if not self.storage.file_exists(): msg =_("This file does not exist.") + '\n' \ - + _("Press 'Next' to create this wallet, or choose another file.") + + _("Press 'Next' to create this ZSLP wallet, or choose another file.") pw = False elif not wallet_from_memory: if self.storage.is_encrypted_with_user_pw(): diff --git a/gui/qt/invoice_list.py b/gui/qt/invoice_list.py index 7d121e5ac..1b0718cb1 100644 --- a/gui/qt/invoice_list.py +++ b/gui/qt/invoice_list.py @@ -54,8 +54,13 @@ def on_update(self): item.setFont(3, QFont(MONOSPACE_FONT)) self.addTopLevelItem(item) self.setCurrentItem(self.topLevelItem(0)) - self.setVisible(len(inv_list)) - self.parent.invoices_label.setVisible(len(inv_list)) + self.chkVisible(inv_list) + + def chkVisible(self, inv_list=None): + inv_list = inv_list or self.parent.invoices.unpaid_invoices() + b = len(inv_list) > 0 and self.parent.isVisible() + self.setVisible(b) + self.parent.invoices_label.setVisible(b) def import_invoices(self): import_meta_gui(self.parent, _('invoices'), self.parent.invoices.import_file, self.on_update) diff --git a/gui/qt/main_window.py b/gui/qt/main_window.py index b5b3c0192..af94db9ab 100644 --- a/gui/qt/main_window.py +++ b/gui/qt/main_window.py @@ -22,6 +22,8 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. + +import re import sys, time, threading import os, json, traceback import shutil @@ -40,14 +42,18 @@ from PyQt5.QtWidgets import * from electrum_zclassic import keystore, simple_config -from electrum_zclassic.bitcoin import COIN, is_address, TYPE_ADDRESS +from electrum_zclassic.address import Address, ScriptOutput, AddressError +from electrum_zclassic.bitcoin import COIN, is_address, TYPE_ADDRESS, TYPE_SCRIPT from electrum_zclassic import constants from electrum_zclassic.plugins import run_hook from electrum_zclassic.i18n import _ from electrum_zclassic.util import (format_time, format_satoshis, PrintError, - format_satoshis_plain, NotEnoughFunds, + format_satoshis_plain, format_satoshis_plain_nofloat, + NotEnoughFunds, NotEnoughFundsSlp, NotEnoughUnfrozenFundsSlp, ExcessiveFee, UserCancelled, NoDynamicFeeEstimates, profiler, - export_meta, import_meta, bh2u, bfh, InvalidPassword) + export_meta, import_meta, bh2u, bfh, InvalidPassword, + Weak) +import electrum_zclassic.web as web from electrum_zclassic import Transaction from electrum_zclassic import util, bitcoin, commands, coinchooser from electrum_zclassic import paymentrequest @@ -60,6 +66,14 @@ from .fee_slider import FeeSlider from .util import * +import electrum_zclassic.slp as slp +from electrum_zclassic.slp_coinchooser import SlpCoinChooser +from electrum_zclassic.slp_checker import SlpTransactionChecker +from .amountedit import SLPAmountEdit +from electrum_zclassic.util import format_satoshis_nofloat +from .slp_create_token_genesis_dialog import SlpCreateTokenGenesisDialog +from .bfp_download_file_dialog import BfpDownloadFileDialog +from .bfp_upload_file_dialog import BitcoinFilesUploadDialog class StatusBarButton(QPushButton): def __init__(self, icon, tooltip, func): @@ -85,6 +99,7 @@ def keyPressEvent(self, e): class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): + # Note: self.clean_up_connections automatically detects signals named XXX_signal and disconnects them on window close. payment_request_ok_signal = pyqtSignal() payment_request_error_signal = pyqtSignal() notify_transactions_signal = pyqtSignal() @@ -94,16 +109,21 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): alias_received_signal = pyqtSignal() computing_privkeys_signal = pyqtSignal() show_privkeys_signal = pyqtSignal() + cashaddr_toggled_signal = pyqtSignal() + slp_validity_signal = pyqtSignal(object, object) + history_updated_signal = pyqtSignal() + on_timer_signal = pyqtSignal() # functions wanting to be executed from timer_actions should connect to this signal, preferably via Qt.DirectConnection def __init__(self, gui_object, wallet): QMainWindow.__init__(self) self.gui_object = gui_object + self.wallet = wallet self.config = config = gui_object.config + self.is_slp_wallet = "slp_" in self.wallet.storage.get('wallet_type', '') self._old_excepthook = None self.setup_exception_hook() - self.network = gui_object.daemon.network self.fx = gui_object.daemon.fx self.invoices = wallet.invoices @@ -116,11 +136,20 @@ def __init__(self, gui_object, wallet): self.checking_accounts = False self.qr_window = None self.not_enough_funds = False + self.not_enough_funds_slp = False + self.not_enough_unfrozen_funds_slp = False + self.op_return_toolong = False self.pluginsdialog = None self.require_fee_update = False self.tx_notifications = [] self.tl_windows = [] self.tx_external_keypairs = {} + self._tx_dialogs = Weak.Set() + self._slp_dialogs = Weak.Set() + self.tx_update_mgr = TxUpdateMgr(self) # manages network callbacks for 'new_transaction' and 'verified2', and collates GUI updates from said callbacks as a performance optimization + # self.is_schnorr_enabled = self.wallet.is_schnorr_enabled # This is a function -- Support for plugins that may be using the 4.0.3 & 4.0.4 API -- this function used to live in this class, before being moved to Abstract_Wallet. + self.send_tab_opreturn_widgets, self.receive_tab_opreturn_widgets = [], [] # defaults to empty list + self._shortcuts = Weak.Set() # keep track of shortcuts and disable them on close self.create_status_bar() self.need_update = threading.Event() @@ -137,22 +166,32 @@ def __init__(self, gui_object, wallet): self.utxo_tab = self.create_utxo_tab() self.console_tab = self.create_console_tab() self.contacts_tab = self.create_contacts_tab() + self.slp_mgt_tab = self.create_slp_mgt_tab() + self.converter_tab = self.create_converter_tab() + self.slp_history_tab = self.create_slp_history_tab() + self.slp_token_id = None tabs.addTab(self.create_history_tab(), QIcon(":icons/tab_history.png"), _('History')) tabs.addTab(self.send_tab, QIcon(":icons/tab_send.png"), _('Send')) tabs.addTab(self.receive_tab, QIcon(":icons/tab_receive.png"), _('Receive')) + # clears/inits the opreturn widgets + self.on_toggled_opreturn(bool(self.config.get('enable_opreturn'))) - def add_optional_tab(tabs, tab, icon, description, name): + def add_optional_tab(tabs, tab, icon, description, name, default=False): tab.tab_icon = icon tab.tab_description = description tab.tab_pos = len(tabs) tab.tab_name = name - if self.config.get('show_{}_tab'.format(name), False): + if self.config.get('show_{}_tab'.format(name), default): tabs.addTab(tab, icon, description.replace("&", "")) add_optional_tab(tabs, self.addresses_tab, QIcon(":icons/tab_addresses.png"), _("&Addresses"), "addresses") add_optional_tab(tabs, self.utxo_tab, QIcon(":icons/tab_coins.png"), _("Co&ins"), "utxo") add_optional_tab(tabs, self.contacts_tab, QIcon(":icons/tab_contacts.png"), _("Con&tacts"), "contacts") + add_optional_tab(tabs, self.converter_tab, QIcon(":icons/tab_converter.svg"), _("Address Converter"), "converter", True) add_optional_tab(tabs, self.console_tab, QIcon(":icons/tab_console.png"), _("Con&sole"), "console") + if self.is_slp_wallet: + add_optional_tab(tabs, self.slp_mgt_tab, QIcon(":icons/tab_slp_icon.png"), _("Tokens"), "tokens") + add_optional_tab(tabs, self.slp_history_tab, QIcon(":icons/tab_slp_icon.png"), _("ZSLP History"), "zslp_history", True) tabs.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.setCentralWidget(tabs) @@ -177,6 +216,7 @@ def add_optional_tab(tabs, tab, icon, description, name): self.payment_request_error_signal.connect(self.payment_request_error) self.notify_transactions_signal.connect(self.notify_transactions) self.history_list.setFocus(True) + self.slp_history_list.setFocus(True) # network callbacks if self.network: @@ -198,19 +238,40 @@ def add_optional_tab(tabs, tab, icon, description, name): # update fee slider in case we missed the callback self.fee_slider.update() self.load_wallet(wallet) + self.connect_slots(gui_object.timer) self.fetch_alias() + def update_token_type_combo(self): + self.token_type_combo.clear() + self.receive_token_type_combo.clear() + self.token_type_combo.addItem(QIcon(':icons/tab_coins.png'), 'None', None) + self.receive_token_type_combo.addItem(QIcon(':icons/tab_coins.png'), 'None', None) + + try: + token_types = self.wallet.token_types + except AttributeError: + pass + else: + sorted_items = sorted(token_types.items(), key=lambda x:x[1]['name']) + for token_id, i in sorted_items: + if i['decimals'] != '?': + self.token_type_combo.addItem(QIcon(':icons/tab_slp_icon.png'),i['name'], token_id) + self.receive_token_type_combo.addItem(QIcon(':icons/tab_slp_icon.png'),i['name'], token_id) + def on_history(self, b): self.new_fx_history_signal.emit() def setup_exception_hook(self): Exception_Hook(self) + @rate_limited(3.0) # Rate limit to no more than once every 3 seconds def on_fx_history(self): + if self.cleaned_up: return self.history_list.refresh_headers() self.history_list.update() self.address_list.update() + self.history_updated_signal.emit() # inform things like address_dialog that there's a new history def on_quotes(self, b): self.new_fx_quotes_signal.emit() @@ -226,8 +287,17 @@ def on_fx_quotes(self): if self.fx.history_used_spot: self.history_list.update() - def toggle_tab(self, tab): - show = not self.config.get('show_{}_tab'.format(tab.tab_name), False) + def toggle_tab(self, tab, forceStatus = 0): + + # forceStatus = 0 , do nothing + # forceStatus = 1 , force Show + # forceStatus = 2 , force hide + if forceStatus==1: + show=True + elif forceStatus==2: + show=False + else: + show = not self.config.get('show_{}_tab'.format(tab.tab_name), False) self.config.set_key('show_{}_tab'.format(tab.tab_name), show) item_text = (_("Hide") if show else _("Show")) + " " + tab.tab_description tab.menu_action.setText(item_text) @@ -299,6 +369,7 @@ def on_network(self, event, *args): self.print_error("unexpected network message:", event, args) def on_network_qt(self, event, args=None): + if self.cleaned_up: return # Handle a network message in the GUI thread if event == 'status': self.update_status() @@ -306,6 +377,7 @@ def on_network_qt(self, event, args=None): self.console.showMessage(args[0]) elif event == 'verified': self.history_list.update_item(*args) + self.slp_history_list.update_item_netupdate(*args) elif event == 'fee': if self.config.is_dynfee(): self.fee_slider.update() @@ -338,16 +410,14 @@ def close_wallet(self): @profiler def load_wallet(self, wallet): - wallet.thread = TaskThread(self, self.on_error) - self.wallet = wallet + wallet.thread = TaskThread(self, self.on_error, name = self.wallet.diagnostic_name() + '/Wallet') + self.wallet.ui_emit_validity_updated = self.slp_validity_signal.emit self.update_recently_visited(wallet.storage.path) # address used to create a dummy transaction and estimate transaction fee self.history_list.update() self.address_list.update() self.utxo_list.update() self.need_update.set() - # Once GUI has been initialized check if we want to announce something since the callback has been called before the GUI was initialized - self.notify_transactions() # update menus self.seed_menu.setEnabled(self.wallet.has_seed()) self.update_lock_icon() @@ -355,13 +425,36 @@ def load_wallet(self, wallet): self.update_console() self.clear_receive_tab() self.request_list.update() + + if self.is_slp_wallet: + self.slp_history_list.update() + self.token_list.update() + self.update_token_type_combo() + self.tabs.show() self.init_geometry() if self.config.get('hide_gui') and self.gui_object.tray.isVisible(): self.hide() else: self.show() + if self._is_invalid_testnet_wallet(): + self.gui_object.daemon.stop_wallet(self.wallet.storage.path) + self._rebuild_history_action.setEnabled(False) + self._warn_if_invalid_testnet_wallet() self.watching_only_changed() + self.history_updated_signal.emit() # inform things like address_dialog that there's a new history + if self.is_slp_wallet: + self.toggle_cashaddr(1, True) + self.toggle_tab(self.slp_mgt_tab, 1) + self.toggle_tab(self.slp_history_tab, 1) + else: + self.toggle_cashaddr(0, True) + self.update_receive_address_widget() + self.address_list.update() + self.utxo_list.update() + self.slp_mgt_tab.update() + self.slp_history_tab.update() + self.update_cashaddr_icon() run_hook('load_wallet', wallet, self) def init_geometry(self): @@ -375,7 +468,7 @@ def init_geometry(self): self.setGeometry(100, 100, 840, 400) def watching_only_changed(self): - name = "Electrum-Zclassic Testnet" if constants.net.TESTNET else "Electrum-Zclassic" + name = "Electrum-ZSLP Testnet" if constants.net.TESTNET else "Electrum-ZSLP" title = '%s %s - %s' % (name, self.wallet.electrum_version, self.wallet.basename()) extra = [self.wallet.storage.get('wallet_type', '?')] @@ -398,6 +491,37 @@ def warn_if_watching_only(self): ]) self.show_warning(msg, title=_('Information')) + def _is_invalid_testnet_wallet(self): + if not constants.net.TESTNET: + return False + is_old_bad = False + xkey = ((hasattr(self.wallet, 'get_master_public_key') and self.wallet.get_master_public_key()) + or None) + if xkey: + from electrum_zclassic.bitcoin import deserialize_xpub, InvalidXKeyFormat + try: + xp = deserialize_xpub(xkey) + except InvalidXKeyFormat: + is_old_bad = True + return is_old_bad + + def _warn_if_invalid_testnet_wallet(self): + ''' This was added after the upgrade from the bad xpub testnet wallets + to the good tpub testnet wallet format in version 3.3.6. See #1164. + We warn users if they are using the bad wallet format and instruct + them on how to upgrade their wallets.''' + is_old_bad = self._is_invalid_testnet_wallet() + if is_old_bad: + msg = ' '.join([ + _("This testnet wallet has an invalid master key format."), + _("(Old versions of Electron Cash before 3.3.6 produced invalid testnet wallets)."), + '

    ', + _("In order to use this wallet without errors with this version of EC, please re-generate this wallet from seed."), + "

    ~SPV stopped~" + ]) + self.show_critical(msg, title=_('Invalid Master Key'), rich_text=True) + return is_old_bad + def open_wallet(self): try: wallet_folder = self.get_wallet_folder() @@ -508,26 +632,34 @@ def init_menubar(self): wallet_menu.addAction(_("Find"), self.toggle_search).setShortcut(QKeySequence("Ctrl+F")) def add_toggle_action(view_menu, tab): - is_shown = self.config.get('show_{}_tab'.format(tab.tab_name), False) - item_name = (_("Hide") if is_shown else _("Show")) + " " + tab.tab_description + is_shown = self.tabs.indexOf(tab) > -1 + item_format = _("Hide {tab_description}") if is_shown else _("Show {tab_description}") + item_name = item_format.format(tab_description=tab.tab_description) tab.menu_action = view_menu.addAction(item_name, lambda: self.toggle_tab(tab)) view_menu = menubar.addMenu(_("&View")) add_toggle_action(view_menu, self.addresses_tab) add_toggle_action(view_menu, self.utxo_tab) add_toggle_action(view_menu, self.contacts_tab) + add_toggle_action(view_menu, self.converter_tab) add_toggle_action(view_menu, self.console_tab) + if self.is_slp_wallet: + add_toggle_action(view_menu, self.slp_mgt_tab) + add_toggle_action(view_menu, self.slp_history_tab) tools_menu = menubar.addMenu(_("&Tools")) # Settings / Preferences are all reserved keywords in macOS using this as work around - tools_menu.addAction(_("Electrum-Zclassic preferences") if sys.platform == 'darwin' else _("Preferences"), self.settings_dialog) + tools_menu.addAction(_("Electrum-ZSLP Preferences") if sys.platform == 'darwin' else _("Preferences"), self.settings_dialog) tools_menu.addAction(_("&Network"), lambda: self.gui_object.show_network_dialog(self)) tools_menu.addAction(_("&Plugins"), self.plugins_dialog) tools_menu.addSeparator() tools_menu.addAction(_("&Sign/verify message"), self.sign_verify_message) tools_menu.addAction(_("&Encrypt/decrypt message"), self.encrypt_message) tools_menu.addSeparator() + # tools_menu.addAction(_("Upload a file using BFP"), lambda: BitcoinFilesUploadDialog(self, None, True, "Upload a File Using BFP")) + # tools_menu.addAction(_("Download a file using BFP"), lambda: BfpDownloadFileDialog(self,)) + # tools_menu.addSeparator() paytomany_menu = tools_menu.addAction(_("&Pay to many"), self.paytomany) @@ -567,7 +699,7 @@ def donate_to_server(self): self.show_error(_('No donation address for this server')) def show_about(self): - QMessageBox.about(self, "Electrum-Zclassic", + QMessageBox.about(self, "Electrum-ZSLP", _("Version")+" %s" % (self.wallet.electrum_version) + "\n\n" + _("Electrum-Zclassic focus is speed, with low resource usage and simplifying ZClassic. You do not need to perform regular backups, because your wallet can be recovered from a secret phrase that you can memorize or write on paper. Startup times are instant because it operates in conjunction with high-performance servers that handle the most complicated parts of the ZClassic system." + "\n\n" + _("Uses icons from the Icons8 icon pack (icons8.com)."))) @@ -579,7 +711,7 @@ def show_report_bug(self): _("Before reporting a bug, upgrade to the most recent version of Electrum-Zclassic (latest release or git HEAD), and include the version number in your report."), _("Try to explain not only what the bug is, but how it occurs.") ]) - self.show_message(msg, title="Electrum-Zclassic - " + _("Reporting Bugs")) + self.show_message(msg, title="Electrum-ZSLP - " + _("Reporting Bugs")) def notify_transactions(self): if not self.network or not self.network.is_connected(): @@ -590,28 +722,47 @@ def notify_transactions(self): num_txns = len(self.tx_notifications) if num_txns >= 3: total_amount = 0 + tokens_included = set() for tx in self.tx_notifications: is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) if v > 0: total_amount += v - self.notify(_("{} new transactions received: Total amount received in the new transactions {}") - .format(num_txns, self.format_amount_and_units(total_amount))) + if self.is_slp_wallet: + try: + tti = self.wallet.tx_tokinfo[tx.txid()] + tokens_included.add(self.wallet.token_types.get(tti['token_id'],{}).get('name','unknown')) + except KeyError: + pass + if tokens_included: + tokstring = _('. Tokens included: ') + ', '.join(sorted(tokens_included)) + else: + tokstring = '' + self.notify(_("{} new transactions received: Total amount received in the new transactions {}{}") + .format(num_txns, self.format_amount_and_units(total_amount),tokstring)) self.tx_notifications = [] else: for tx in self.tx_notifications: if tx: self.tx_notifications.remove(tx) is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) + if self.config.get('enable_slp'): + try: + tti = self.wallet.tx_tokinfo[tx.txid()] + tokstring = _(". Token included: ") + self.wallet.token_types.get(tti['token_id'],{}).get('name','unknown') + except KeyError: + tokstring = "" + else: + tokstring = "" if v > 0: - self.notify(_("New transaction received: {}").format(self.format_amount_and_units(v))) + self.notify(_("New transaction received: {}{}").format(self.format_amount_and_units(v), tokstring)) def notify(self, message): if self.tray: try: # this requires Qt 5.9 - self.tray.showMessage("Electrum-Zclassic", message, QIcon(":icons/electrum_dark_icon"), 20000) + self.tray.showMessage("Electrum-ZSLP", message, QIcon(":icons/electrum_dark_icon"), 20000) except TypeError: - self.tray.showMessage("Electrum-Zclassic", message, QSystemTrayIcon.Information, 20000) + self.tray.showMessage("Electrum-ZSLP", message, QSystemTrayIcon.Information, 20000) @@ -721,6 +872,7 @@ def update_status(self): elif self.network.is_connected(): server_height = self.network.get_server_height() server_lag = self.network.get_local_height() - server_height + num_chains = len(self.network.get_blockchains()) # Server height can be 0 after switching to a new server # until we get a headers subscription request response. # Display the synchronizing message in that case. @@ -729,10 +881,23 @@ def update_status(self): icon = QIcon(":icons/status_waiting.png") elif server_lag > 1: text = _("Server is lagging ({} blocks)").format(server_lag) - icon = QIcon(":icons/status_lagging.png") + icon = QIcon(":icons/status_lagging.png") if num_chains <= 1 else QIcon(":icons/status_lagging_fork.png") else: + text = "" + if not self.is_slp_wallet: + text += "Tokens Disabled - " + else: + token_id = self.slp_token_id + try: + d = self.wallet.token_types[token_id] + except (AttributeError, KeyError): + pass + else: + bal = format_satoshis_nofloat(self.wallet.get_slp_token_balance(token_id, { 'user_config': { 'confirmed_only': False } })[0], + decimal_point=d['decimals'],) + text += "%s Token Balance: %s; "%(d['name'], bal) c, u, x = self.wallet.get_balance() - text = _("Balance" ) + ": %s "%(self.format_amount_and_units(c)) + text += _("Zclassic Balance" ) + ": %s "%(self.format_amount_and_units(c)) if u: text += " [%s unconfirmed]"%(self.format_amount(u, True).strip()) if x: @@ -743,15 +908,17 @@ def update_status(self): text += self.fx.get_fiat_status_text(c + u + x, self.base_unit(), self.get_decimal_point()) or '' if not self.network.proxy: - icon = QIcon(":icons/status_connected.png") + icon = QIcon(":icons/status_connected.png") if num_chains <= 1 else QIcon(":icons/status_connected_fork.png") else: - icon = QIcon(":icons/status_connected_proxy.png") + icon = QIcon(":icons/status_connected_proxy.png") if num_chains <= 1 else QIcon(":icons/status_connected_proxy_fork.png") else: text = _("Not connected") icon = QIcon(":icons/status_disconnected.png") self.tray.setToolTip("%s (%s)" % (text, self.wallet.basename())) self.balance_label.setText(text) + addr_format = self.config.get('addr_format', 0) + self.setAddrFormatText(addr_format) self.status_button.setIcon( icon ) @@ -768,25 +935,64 @@ def update_tabs(self): self.contact_list.update() self.invoice_list.update() self.update_completions() + if self.is_slp_wallet: + self.slp_history_list.update() + self.token_list.update() def create_history_tab(self): from .history_list import HistoryList self.history_list = l = HistoryList(self) l.searchable_list = l - l.setObjectName("history_container") - toolbar = l.create_toolbar(self.config) - toolbar_shown = self.config.get('show_toolbar_history', False) - l.show_toolbar(toolbar_shown) - return self.create_list_tab(l, toolbar) + return l + + def create_slp_history_tab(self): + from .slp_history_list import HistoryList + self.slp_history_list = l = HistoryList(self) + return self.create_list_tab(l) - def show_address(self, addr): + def show_address(self, addr, *, parent=None): + parent = parent or self.top_level_window() from . import address_dialog - d = address_dialog.AddressDialog(self, addr) + d = address_dialog.AddressDialog(self, addr, windowParent=parent) d.exec_() def show_transaction(self, tx, tx_desc = None): '''tx_desc is set only for txs created in the Send tab''' - show_transaction(tx, self, tx_desc) + d = show_transaction(tx, self, tx_desc) + self._tx_dialogs.add(d) + + def addr_toggle_slp(self, force_slp=False): + + def present_slp(): + self.toggle_cashaddr(1, True) + self.receive_slp_token_type_label.setDisabled(False) + self.receive_slp_amount_e.setDisabled(False) + self.receive_slp_amount_label.setDisabled(False) + + if force_slp: + present_slp() + return + + if Address.FMT_UI == Address.FMT_SLPADDR: + self.toggle_cashaddr(0, True) + self.receive_token_type_combo.setCurrentIndex(0) + else: + present_slp() + + def on_toggled_opreturn(self, b): + ''' toggles opreturn-related widgets for both the receive and send + tabs''' + b = bool(b) + self.config.set_key('enable_opreturn', b) + # send tab + if not b: + self.message_opreturn_e.setText("") + self.op_return_toolong = False + for x in self.send_tab_opreturn_widgets: + x.setVisible(b) + # receive tab + for x in self.receive_tab_opreturn_widgets: + x.setVisible(b) def create_receive_tab(self): # A 4-column grid layout. All the stretch is in the last column. @@ -795,30 +1001,90 @@ def create_receive_tab(self): grid.setSpacing(8) grid.setColumnStretch(3, 1) + self.receive_address = None self.receive_address_e = ButtonsLineEdit() self.receive_address_e.addCopyButton(self.app) self.receive_address_e.setReadOnly(True) msg = _('Zclassic address where the payment should be received. Note that each payment request uses a different Zclassic address.') - self.receive_address_label = HelpLabel(_('Receiving address'), msg) + label = HelpLabel(_('&Receiving address'), msg) + label.setBuddy(self.receive_address_e) self.receive_address_e.textChanged.connect(self.update_receive_qr) - self.receive_address_e.setFocusPolicy(Qt.ClickFocus) - grid.addWidget(self.receive_address_label, 0, 0) + self.cashaddr_toggled_signal.connect(self.update_receive_address_widget) + grid.addWidget(label, 0, 0) grid.addWidget(self.receive_address_e, 0, 1, 1, -1) + if self.is_slp_wallet: + self.show_slp_addr_btn = QPushButton(_('Show Token Address')) + self.show_slp_addr_btn.clicked.connect(self.addr_toggle_slp) + grid.addWidget(self.show_slp_addr_btn, 1, 1) + self.receive_message_e = QLineEdit() - grid.addWidget(QLabel(_('Description')), 1, 0) - grid.addWidget(self.receive_message_e, 1, 1, 1, -1) + label = QLabel(_('&Description')) + label.setBuddy(self.receive_message_e) + grid.addWidget(label, 2, 0) + grid.addWidget(self.receive_message_e, 2, 1, 1, -1) self.receive_message_e.textChanged.connect(self.update_receive_qr) + # OP_RETURN requests + self.receive_opreturn_e = QLineEdit() + msg = _("You may optionally append an OP_RETURN message to the payment URI and/or QR you generate.\n\nNote: Not all wallets yet support OP_RETURN parameters, so make sure the other party's wallet supports OP_RETURN URIs.") + self.receive_opreturn_label = label = HelpLabel(_('&OP_RETURN'), msg) + label.setBuddy(self.receive_opreturn_e) + self.receive_opreturn_rawhex_cb = QCheckBox(_('Raw &hex script')) + self.receive_opreturn_rawhex_cb.setToolTip(_('If unchecked, the textbox contents are UTF8-encoded into a single-push script: OP_RETURN PUSH <text>. If checked, the text contents will be interpreted as a raw hexadecimal script to be appended after the OP_RETURN opcode: OP_RETURN <script>.')) + grid.addWidget(label, 3, 0) + grid.addWidget(self.receive_opreturn_e, 3, 1, 1, 3) + grid.addWidget(self.receive_opreturn_rawhex_cb, 3, 4, Qt.AlignLeft) + self.receive_opreturn_e.textChanged.connect(self.update_receive_qr) + self.receive_opreturn_rawhex_cb.clicked.connect(self.update_receive_qr) + self.receive_tab_opreturn_widgets = [ + self.receive_opreturn_e, + self.receive_opreturn_rawhex_cb, + self.receive_opreturn_label, + ] + + msg = _('Select the ZSLP token to Request.') + self.receive_token_type_combo = QComboBox() + if ColorScheme.dark_scheme and sys.platform == 'darwin': + # Hack/Workaround to QDarkStyle bugs; see https://github.com/ColinDuquesnoy/QDarkStyleSheet/issues/169#issuecomment-494647801 + self.receive_token_type_combo.setItemDelegate(QStyledItemDelegate(self.receive_token_type_combo)) + self.receive_token_type_combo.setFixedWidth(200) + self.receive_token_type_combo.currentIndexChanged.connect(self.on_slptok_receive) + #self.receive_token_type_combo.currentIndexChanged.connect(self.update_buttons_on_seed) # update 'CoinText' button, etc + self.receive_slp_token_type_label = HelpLabel(_('Token Type'), msg) + grid.addWidget(self.receive_slp_token_type_label, 4, 0) + grid.addWidget(self.receive_token_type_combo, 4, 1) + + self.receive_slp_amount_e = SLPAmountEdit('tokens', 0) + self.receive_slp_amount_e.setFixedWidth(self.receive_token_type_combo.width()) + self.receive_slp_amount_label = QLabel(_('Req. token amount')) + grid.addWidget(self.receive_slp_amount_label, 5, 0) + grid.addWidget(self.receive_slp_amount_e, 5, 1) + self.receive_slp_amount_e.textChanged.connect(self.update_receive_qr) + self.receive_amount_e = BTCAmountEdit(self.get_decimal_point) - grid.addWidget(QLabel(_('Requested amount')), 2, 0) - grid.addWidget(self.receive_amount_e, 2, 1) + self.receive_amount_e.setFixedWidth(self.receive_token_type_combo.width()) + self.receive_amount_label = QLabel(_('Requested &amount')) + self.receive_amount_label.setBuddy(self.receive_amount_e) + grid.addWidget(self.receive_amount_label, 6, 0) + grid.addWidget(self.receive_amount_e, 6, 1) self.receive_amount_e.textChanged.connect(self.update_receive_qr) + if Address.FMT_UI != Address.FMT_SLPADDR: + self.receive_token_type_combo.setDisabled(True) + self.receive_slp_token_type_label.setDisabled(True) + self.receive_slp_amount_e.setDisabled(True) + self.receive_slp_amount_label.setDisabled(True) + else: + self.receive_token_type_combo.setDisabled(False) + self.receive_slp_token_type_label.setDisabled(False) + self.receive_slp_amount_e.setDisabled(False) + self.receive_slp_amount_label.setDisabled(False) + self.fiat_receive_e = AmountEdit(self.fx.get_currency if self.fx else '') if not self.fx or not self.fx.is_enabled(): self.fiat_receive_e.setVisible(False) - grid.addWidget(self.fiat_receive_e, 2, 2, Qt.AlignLeft) + grid.addWidget(self.fiat_receive_e, 6, 2, Qt.AlignLeft) self.connect_fields(self, self.receive_amount_e, self.fiat_receive_e, None) self.expires_combo = QComboBox() @@ -831,35 +1097,44 @@ def create_receive_tab(self): _('Expired requests have to be deleted manually from your list, in order to free the corresponding Zclassic addresses.'), _('The Zclassic address never expires and will always be part of this electrum-zclassic wallet.'), ]) - grid.addWidget(HelpLabel(_('Request expires'), msg), 3, 0) - grid.addWidget(self.expires_combo, 3, 1) + label = HelpLabel(_('Request &expires'), msg) + label.setBuddy(self.expires_combo) + grid.addWidget(label, 7, 0) + grid.addWidget(self.expires_combo, 7, 1) self.expires_label = QLineEdit('') self.expires_label.setReadOnly(1) - self.expires_label.setFocusPolicy(Qt.NoFocus) self.expires_label.hide() - grid.addWidget(self.expires_label, 3, 1) + grid.addWidget(self.expires_label, 7, 1) - self.save_request_button = QPushButton(_('Save')) + self.save_request_button = QPushButton(_('&Save')) self.save_request_button.clicked.connect(self.save_payment_request) - self.new_request_button = QPushButton(_('New')) + self.new_request_button = QPushButton(_('&Clear')) self.new_request_button.clicked.connect(self.new_payment_request) - self.receive_qr = QRCodeWidget(fixedSize=200) - self.receive_qr.mouseReleaseEvent = lambda x: self.toggle_qr_window() - self.receive_qr.enterEvent = lambda x: self.app.setOverrideCursor(QCursor(Qt.PointingHandCursor)) - self.receive_qr.leaveEvent = lambda x: self.app.setOverrideCursor(QCursor(Qt.ArrowCursor)) + weakSelf = Weak.ref(self) + + class MyQRCodeWidget(QRCodeWidget): + def mouseReleaseEvent(self, e): + ''' to make the QRWidget clickable ''' + weakSelf() and weakSelf().toggle_qr_window() + + self.receive_qr = MyQRCodeWidget(fixedSize=200) + self.receive_qr.setCursor(QCursor(Qt.PointingHandCursor)) self.receive_buttons = buttons = QHBoxLayout() - buttons.addStretch(1) buttons.addWidget(self.save_request_button) buttons.addWidget(self.new_request_button) - grid.addLayout(buttons, 4, 1, 1, 2) + buttons.addStretch(1) + grid.addLayout(buttons, 8, 1, 1, -1) - self.receive_requests_label = QLabel(_('Requests')) + self.receive_requests_label = QLabel(_('Re&quests')) from .request_list import RequestList self.request_list = RequestList(self) + self.request_list.chkVisible() + + self.receive_requests_label.setBuddy(self.request_list) # layout vbox_g = QVBoxLayout() @@ -868,9 +1143,52 @@ def create_receive_tab(self): hbox = QHBoxLayout() hbox.addLayout(vbox_g) - hbox.addWidget(self.receive_qr) + vbox2 = QVBoxLayout() + vbox2.setContentsMargins(0,0,0,0) + vbox2.setSpacing(4) + vbox2.addWidget(self.receive_qr, Qt.AlignHCenter|Qt.AlignTop) + self.receive_qr.setToolTip(_('Receive request QR code (click for details)')) + but = uribut = QPushButton(_('Copy &URI')) + def on_copy_uri(): + if self.receive_qr.data: + uri = str(self.receive_qr.data) + self.copy_to_clipboard(uri, _('Receive request URI copied to clipboard'), uribut) + but.clicked.connect(on_copy_uri) + but.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + but.setToolTip(_('Click to copy the receive request URI to the clipboard')) + vbox2.addWidget(but) + vbox2.setAlignment(but, Qt.AlignHCenter|Qt.AlignVCenter) + + hbox.addLayout(vbox2) + + class ReceiveTab(QWidget): + def showEvent(self, e): + super().showEvent(e) + if e.isAccepted(): + slf = weakSelf() + if slf: + slf.check_and_reset_receive_address_if_needed() + if self.main_window.is_slp_wallet: + c, u, x = self.main_window.wallet.get_balance() + bal = c + u - self.main_window.wallet.get_slp_locked_balance() + if bal < 1000: +# if not self.low_balance_warning_shown: +# self.main_window.show_warning("Low BCH balance.\n\nCreating and sending SLP tokens requires Bitcoin Cash to cover transaction fees. We recommend a minimum of 0.0001 BCH to get started.\n\nSend BCH to the address displayed in the 'Receive' tab.") + self.main_window.toggle_cashaddr(0, True) + self.low_balance_warning_shown = False + else: + self.main_window.toggle_cashaddr(1, True) + if Address.FMT_UI == Address.FMT_SLPADDR: + self.main_window.show_slp_addr_btn.setText("Show Zclassic Address") + else: + self.main_window.show_slp_addr_btn.setText("Show Token Address") + else: + self.main_window.toggle_cashaddr(0, True) - w = QWidget() + + w = ReceiveTab() + w.low_balance_warning_shown = False + w.main_window = self w.searchable_list = self.request_list vbox = QVBoxLayout(w) vbox.addLayout(hbox) @@ -885,13 +1203,14 @@ def create_receive_tab(self): def delete_payment_request(self, addr): self.wallet.remove_payment_request(addr, self.config) self.request_list.update() + self.address_list.update() self.clear_receive_tab() def get_request_URI(self, addr): req = self.wallet.receive_requests[addr] - message = self.wallet.labels.get(addr, '') + message = self.wallet.labels.get(addr.to_storage_string(), '') amount = req['amount'] - URI = util.create_URI(addr, amount, message) + URI = web.create_URI(addr, amount, message) if req.get('time'): URI += "&time=%d"%req.get('time') if req.get('exp'): @@ -925,7 +1244,7 @@ def sign_payment_request(self, addr): return def save_payment_request(self): - addr = str(self.receive_address_e.text()) + addr = Address.from_string(self.receive_address_e.text()) amount = self.receive_amount_e.get_amount() message = self.receive_message_e.text() if not message and not amount: @@ -959,7 +1278,7 @@ def view_and_paste(self, title, msg, data): dialog.exec_() def export_payment_request(self, addr): - r = self.wallet.receive_requests.get(addr) + r = self.wallet.receive_requests[addr] pr = paymentrequest.serialize_request(r).SerializeToString() name = r['id'] + '.bip70' fileName = self.getSaveFileName(_("Select where to save your payment request"), name, "*.bip70") @@ -990,17 +1309,48 @@ def new_payment_request(self): self.receive_message_e.setFocus(1) def set_receive_address(self, addr): - self.receive_address_e.setText(addr) + self.receive_address = addr self.receive_message_e.setText('') self.receive_amount_e.setAmount(None) + self.update_receive_address_widget() + + def update_receive_address_widget(self): + text = '' + if self.receive_address: + text = self.receive_address.to_full_ui_string() + self.receive_address_e.setText(text) + + @rate_limited(0.250, ts_after=True) # this function potentially re-computes the QR widget, so it's rate limited to once every 250ms + def check_and_reset_receive_address_if_needed(self): + ''' Check to make sure the receive tab is kosher and doesn't contain + an already-used address. This should be called from the showEvent + for the tab. ''' + if not self.wallet.use_change or self.cleaned_up: + # if they don't care about change addresses, they are ok + # with re-using addresses, so skip this check. + return + # ok, they care about anonymity, so make sure the receive address + # is always an unused address. + if (not self.receive_address # this should always be defined but check anyway + or self.receive_address in self.wallet.frozen_addresses # make sure it's not frozen + or (self.wallet.get_address_history(self.receive_address) # make a new address if it has a history + and not self.wallet.get_payment_request(self.receive_address, self.config))): # and if they aren't actively editing one in the request_list widget + addr = self.wallet.get_unused_address(frozen_ok=False) # try unused, not frozen + if addr is None: + if self.wallet.is_deterministic(): + # creae a new one if deterministic + addr = self.wallet.create_new_address(False) + else: + # otherwise give up and just re-use one. + addr = self.wallet.get_receiving_address() + self.receive_address = addr + self.update_receive_address_widget() def clear_receive_tab(self): - addr = self.wallet.get_receiving_address() or '' - self.receive_address_e.setText(addr) - self.receive_message_e.setText('') - self.receive_amount_e.setAmount(None) self.expires_label.hide() self.expires_combo.show() + self.request_list.setCurrentItem(None) + self.set_receive_address(self.wallet.get_receiving_address(frozen_ok=False)) def toggle_qr_window(self): from . import qrwindow @@ -1031,19 +1381,85 @@ def receive_at(self, addr): self.new_request_button.setEnabled(True) def update_receive_qr(self): - addr = str(self.receive_address_e.text()) amount = self.receive_amount_e.get_amount() message = self.receive_message_e.text() self.save_request_button.setEnabled((amount is not None) or (message != "")) - uri = util.create_URI(addr, amount, message) + uri = web.create_URI(self.receive_address, amount, message) self.receive_qr.setData(uri) if self.qr_window and self.qr_window.isVisible(): - self.qr_window.set_content(addr, amount, message, uri) + self.qr_window.set_content(self.receive_address_e.text(), amount, message, uri) def set_feerounding_text(self, num_satoshis_added): self.feerounding_text = (_('Additional {} satoshis are going to be added.') .format(num_satoshis_added)) + def on_slptok(self): + self.slp_token_id = self.token_type_combo.currentData() + self.payto_e.check_text() + self.slp_amount_e.setText("") + if self.slp_token_id is None: + self.amount_e.setDisabled(False) + self.amount_label.setDisabled(False) + self.max_button.setDisabled(False) + self.fiat_send_e.setDisabled(False) + self.slp_extra_zcl_cb.setHidden(True) + self.slp_amount_e.setDisabled(True) + self.slp_max_button.setDisabled(True) + self.slp_amount_label.setDisabled(True) + self.message_opreturn_e.setEnabled(True) + self.opreturn_rawhex_cb.setEnabled(True) + self.opreturn_label.setEnabled(True) + else: + self.slp_extra_zcl_cb.setHidden(False) + self.slp_extra_zcl_cb.setChecked(False) + self.slp_extra_zcl_cb.clicked.emit() + self.slp_amount_e.setDisabled(False) + self.slp_max_button.setDisabled(False) + self.slp_amount_label.setDisabled(False) + tok = self.wallet.token_types[self.slp_token_id] + self.slp_amount_e.set_token(tok['name'][:6],tok['decimals']) + self.message_opreturn_e.setEnabled(False) + self.message_opreturn_e.setText('') + self.opreturn_rawhex_cb.setEnabled(False) + self.opreturn_label.setEnabled(False) + self.update_status() + self.do_update_fee() + + def on_slptok_receive(self): + self.receive_slp_amount_e.setText("") + self.receive_amount_e.setText("") + slp_token_id = self.receive_token_type_combo.currentData() + self.update_receive_qr() + if slp_token_id is None: + self.receive_slp_amount_e.setDisabled(True) + self.receive_slp_amount_label.setDisabled(True) + self.receive_amount_e.setDisabled(False) + self.receive_amount_label.setDisabled(False) + self.fiat_receive_e.setDisabled(False) + else: + self.addr_toggle_slp(True) + self.receive_slp_amount_e.setDisabled(False) + self.receive_slp_amount_label.setDisabled(False) + self.receive_amount_e.setDisabled(True) + self.receive_amount_label.setDisabled(True) + self.fiat_receive_e.setDisabled(True) + tok = self.wallet.token_types[slp_token_id] + self.receive_slp_amount_e.set_token(tok['name'][:6],tok['decimals']) + + def on_slp_extra_zcl(self): + if self.slp_extra_zcl_cb.isChecked(): + self.amount_e.setDisabled(False) + self.amount_label.setDisabled(False) + self.max_button.setDisabled(False) + self.fiat_send_e.setDisabled(False) + else: + self.amount_e.setText('') + self.max_button.setChecked(False) + self.amount_e.setDisabled(True) + self.amount_label.setDisabled(True) + self.max_button.setDisabled(True) + self.fiat_send_e.setDisabled(True) + def create_send_tab(self): # A 4-column grid layout. All the stretch is in the last column. # The exchange rate plugin adds a fiat widget in column 2 @@ -1054,218 +1470,303 @@ def create_send_tab(self): from .paytoedit import PayToEdit self.amount_e = BTCAmountEdit(self.get_decimal_point) self.payto_e = PayToEdit(self) + self.payto_e.parent = self + + self.slp_send_tab_widgets = [] + if self.is_slp_wallet: + self.slp_amount_e = SLPAmountEdit('tokens', 0) + self.token_type_combo = QComboBox() + if ColorScheme.dark_scheme and sys.platform == 'darwin': + # Hack/Workaround to QDarkStyle bugs; see https://github.com/ColinDuquesnoy/QDarkStyleSheet/issues/169#issuecomment-494647801 + self.token_type_combo.setItemDelegate(QStyledItemDelegate(self.token_type_combo)) + self.token_type_combo.setFixedWidth(200) + self.token_type_combo.currentIndexChanged.connect(self.on_slptok) + self.token_type_combo.currentIndexChanged.connect(self.update_buttons_on_seed) # update 'CoinText' button, etc + self.slp_send_tab_widgets += [ + self.slp_amount_e, self.token_type_combo + ] + msg = _('Recipient of the funds.') + '\n\n'\ + _('You may enter a Zclassic address, a label from your list of contacts (a list of completions will be proposed), or an alias (email-like address that forwards to a Zclassic address)') - payto_label = HelpLabel(_('Pay to'), msg) + self.payto_label = payto_label = HelpLabel(_('Pay &to'), msg) + payto_label.setBuddy(self.payto_e) + qmark_help_but = HelpButton(msg) + # self.payto_e.addWidget(qmark_help_but, index=0) grid.addWidget(payto_label, 1, 0) grid.addWidget(self.payto_e, 1, 1, 1, -1) - completer = QCompleter() + completer = QCompleter(self.payto_e) completer.setCaseSensitivity(False) self.payto_e.set_completer(completer) completer.setModel(self.completions) msg = _('Description of the transaction (not mandatory).') + '\n\n'\ + _('The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.') - description_label = HelpLabel(_('Description'), msg) + description_label = HelpLabel(_('&Description'), msg) grid.addWidget(description_label, 2, 0) self.message_e = MyLineEdit() + description_label.setBuddy(self.message_e) grid.addWidget(self.message_e, 2, 1, 1, -1) - self.from_label = QLabel(_('From')) - grid.addWidget(self.from_label, 3, 0) + msg_opreturn = ( _('OP_RETURN data (optional).') + '\n\n' + + _('Posts a PERMANENT note to the Zclassic Blockchain as part of this transaction.') + + '\n\n' + _('If you specify OP_RETURN text, you may leave the \'Pay to\' field blank.') ) + self.opreturn_label = HelpLabel(_('OP_RETURN'), msg_opreturn) + grid.addWidget(self.opreturn_label, 3, 0) + self.message_opreturn_e = MyLineEdit() + self.opreturn_label.setBuddy(self.message_opreturn_e) + hbox = QHBoxLayout() + hbox.addWidget(self.message_opreturn_e) + self.opreturn_rawhex_cb = QCheckBox(_('&Raw hex script')) + self.opreturn_rawhex_cb.setToolTip(_('If unchecked, the textbox contents are UTF8-encoded into a single-push script: OP_RETURN PUSH <text>. If checked, the text contents will be interpreted as a raw hexadecimal script to be appended after the OP_RETURN opcode: OP_RETURN <script>.')) + hbox.addWidget(self.opreturn_rawhex_cb) + grid.addWidget(self.message_opreturn_e, 3 , 1, 1, -1) + + self.send_tab_opreturn_widgets = [ + self.message_opreturn_e, + self.opreturn_rawhex_cb, + self.opreturn_label, + ] + + self.from_label = QLabel(_('&From')) + grid.addWidget(self.from_label, 4, 0) self.from_list = MyTreeWidget(self, self.from_list_menu, ['','']) + self.from_label.setBuddy(self.from_list) self.from_list.setHeaderHidden(True) self.from_list.setMaximumHeight(80) - grid.addWidget(self.from_list, 3, 1, 1, -1) + grid.addWidget(self.from_list, 4, 1, 1, -1) self.set_pay_from([]) - msg = _('Amount to be sent.') + '\n\n' \ + if self.is_slp_wallet: + msg = _('Token Amount to be sent.') + '\n\n' \ + + _("To enable make sure 'Address Mode' is set to ZSLP.") + '\n\n' \ + + _('The amount will be displayed in red if you do not have enough funds in your wallet.') + ' ' \ + + _('Note that if you have frozen some of your addresses, the available funds will be lower than your total balance.') + '\n\n' \ + + _('Keyboard shortcut: type "!" to send all your coins.') + self.slp_amount_label = HelpLabel(_('Token Amount'), msg) + + msg = _('Select the ZSLP token to send.') + self.slp_token_type_label = HelpLabel(_('Token Type'), msg) + grid.addWidget(self.slp_token_type_label, 5, 0) + grid.addWidget(self.token_type_combo, 5, 1) + + grid.addWidget(self.slp_amount_label, 6, 0) + hbox = QHBoxLayout() + self.amount_e.setMinimumWidth(195) + self.slp_amount_e.setMinimumWidth(195) + self.slp_amount_e.textEdited.connect(self.update_fee) + hbox.addWidget(self.slp_amount_e) + + self.slp_max_button = EnterButton(_("Max"), self.slp_spend_max) + hbox.addWidget(self.slp_max_button) + grid.addLayout(hbox, 6, 1) + + self.slp_extra_zcl_cb = QCheckBox(_('Also send ZCL?')) + self.slp_extra_zcl_cb.clicked.connect(self.on_slp_extra_zcl) + self.slp_extra_zcl_cb.setHidden(True) + grid.addWidget(self.slp_extra_zcl_cb, 6, 2) + + self.slp_send_tab_widgets += [ + self.slp_max_button, self.slp_extra_zcl_cb + ] + + msg = _('ZCL amount to be sent.') + '\n\n' \ + _('The amount will be displayed in red if you do not have enough funds in your wallet.') + ' ' \ + _('Note that if you have frozen some of your addresses, the available funds will be lower than your total balance.') + '\n\n' \ + _('Keyboard shortcut: type "!" to send all your coins.') - amount_label = HelpLabel(_('Amount'), msg) - grid.addWidget(amount_label, 4, 0) - grid.addWidget(self.amount_e, 4, 1) + self.amount_label = HelpLabel(_('ZCL &Amount'), msg) + self.amount_label.setBuddy(self.amount_e) + grid.addWidget(self.amount_label, 7, 0) + hbox = QHBoxLayout() + hbox.addWidget(self.amount_e) + + self.max_button = EnterButton(_("&Max"), self.spend_max) + self.max_button.setCheckable(True) + hbox.addWidget(self.max_button) + grid.addLayout(hbox, 7, 1) self.fiat_send_e = AmountEdit(self.fx.get_currency if self.fx else '') if not self.fx or not self.fx.is_enabled(): self.fiat_send_e.setVisible(False) - grid.addWidget(self.fiat_send_e, 4, 2) + grid.addWidget(self.fiat_send_e, 7, 2) self.amount_e.frozen.connect( lambda: self.fiat_send_e.setFrozen(self.amount_e.isReadOnly())) - self.max_button = EnterButton(_("Max"), self.spend_max) - self.max_button.setFixedWidth(140) - grid.addWidget(self.max_button, 4, 3) - hbox = QHBoxLayout() - hbox.addStretch(1) - grid.addLayout(hbox, 4, 4) - msg = _('Zclassic transactions are in general not free. A transaction fee is paid by the sender of the funds.') + '\n\n'\ + _('The amount of fee can be decided freely by the sender. However, transactions with low fees take more time to be processed.') + '\n\n'\ + _('A suggested fee is automatically added to this field. You may override it. The suggested fee increases with the size of the transaction.') - self.fee_e_label = HelpLabel(_('Fee'), msg) + self.fee_e_label = HelpLabel(_('F&ee'), msg) def fee_cb(dyn, pos, fee_rate): if dyn: - if self.config.use_mempool_fees(): - self.config.set_key('depth_level', pos, False) - else: - self.config.set_key('fee_level', pos, False) + self.config.set_key('fee_level', pos, False) else: self.config.set_key('fee_per_kb', fee_rate, False) - - if fee_rate: - self.feerate_e.setAmount(fee_rate) - else: - self.feerate_e.setAmount(None) - self.fee_e.setModified(False) - - self.fee_slider.activate() - self.spend_max() if self.is_max else self.update_fee() + self.spend_max() if self.max_button.isChecked() else self.update_fee() self.fee_slider = FeeSlider(self, self.config, fee_cb) + self.fee_e_label.setBuddy(self.fee_slider) self.fee_slider.setFixedWidth(140) - def on_fee_or_feerate(edit_changed, editing_finished): - edit_other = self.feerate_e if edit_changed == self.fee_e else self.fee_e - if editing_finished: - if not edit_changed.get_amount(): - # This is so that when the user blanks the fee and moves on, - # we go back to auto-calculate mode and put a fee back. - edit_changed.setModified(False) - else: - # edit_changed was edited just now, so make sure we will - # freeze the correct fee setting (this) - edit_other.setModified(False) - self.fee_slider.deactivate() - self.update_fee() - - class TxSizeLabel(QLabel): - def setAmount(self, byte_size): - self.setText(('x %s bytes =' % byte_size) if byte_size else '') - - self.size_e = TxSizeLabel() - self.size_e.setAlignment(Qt.AlignCenter) - self.size_e.setAmount(0) - self.size_e.setFixedWidth(140) - self.size_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) - - self.feerate_e = FeerateEdit(lambda: 0) - self.feerate_e.setAmount(self.config.fee_per_kb()) - self.feerate_e.textEdited.connect(partial(on_fee_or_feerate, self.feerate_e, False)) - self.feerate_e.editingFinished.connect(partial(on_fee_or_feerate, self.feerate_e, True)) + self.fee_custom_lbl = HelpLabel(self.get_custom_fee_text(), + _('This is the fee rate that will be used for this transaction.') + + "\n\n" + _('It is calculated from the Custom Fee Rate in preferences, but can be overridden from the manual fee edit on this form (if enabled).') + + "\n\n" + _('Generally, a fee of 1.0 sats/B is a good minimal rate to ensure your transaction will make it into the next block.')) + self.fee_custom_lbl.setFixedWidth(140) - self.fee_e = BTCAmountEdit(self.get_decimal_point) - self.fee_e.textEdited.connect(partial(on_fee_or_feerate, self.fee_e, False)) - self.fee_e.editingFinished.connect(partial(on_fee_or_feerate, self.fee_e, True)) - - def feerounding_onclick(): - text = (self.feerounding_text + '\n\n' + - _('To somewhat protect your privacy, Electrum-Zclassic tries to create change with similar precision to other outputs.') + ' ' + - _('At most 100 satoshis might be lost due to this rounding.') + ' ' + - _("You can disable this setting in '{}'.").format(_('Preferences')) + '\n' + - _('Also, dust is not kept as change, but added to the fee.')) - QMessageBox.information(self, 'Fee rounding', text) - - self.feerounding_icon = QPushButton(QIcon(':icons/info.png'), '') - self.feerounding_icon.setFixedWidth(30) - self.feerounding_icon.setFlat(True) - self.feerounding_icon.clicked.connect(feerounding_onclick) - self.feerounding_icon.setVisible(False) + self.fee_slider_mogrifier() + self.fee_e = BTCAmountEdit(self.get_decimal_point) + if not self.config.get('show_fee', False): + self.fee_e.setVisible(False) + self.fee_e.textEdited.connect(self.update_fee) + # This is so that when the user blanks the fee and moves on, + # we go back to auto-calculate mode and put a fee back. + self.fee_e.editingFinished.connect(self.update_fee) self.connect_fields(self, self.amount_e, self.fiat_send_e, self.fee_e) - vbox_feelabel = QVBoxLayout() - vbox_feelabel.addWidget(self.fee_e_label) - vbox_feelabel.addStretch(1) - grid.addLayout(vbox_feelabel, 5, 0) - - self.fee_adv_controls = QWidget() - hbox = QHBoxLayout(self.fee_adv_controls) - hbox.setContentsMargins(0, 0, 0, 0) - hbox.addWidget(self.feerate_e) - hbox.addWidget(self.size_e) + grid.addWidget(self.fee_e_label, 9, 0) + hbox = QHBoxLayout() + hbox.addWidget(self.fee_slider) + # hbox.addWidget(self.fee_custom_lbl) hbox.addWidget(self.fee_e) - hbox.addWidget(self.feerounding_icon, Qt.AlignLeft) hbox.addStretch(1) + grid.addLayout(hbox, 9, 1) - vbox_feecontrol = QVBoxLayout() - vbox_feecontrol.addWidget(self.fee_adv_controls) - vbox_feecontrol.addWidget(self.fee_slider) - - grid.addLayout(vbox_feecontrol, 5, 1, 1, -1) - - if not self.config.get('show_fee', True): - self.fee_adv_controls.setVisible(False) - - self.preview_button = EnterButton(_("Preview"), self.do_preview) + self.preview_button = EnterButton(_("&Preview"), self.do_preview) self.preview_button.setToolTip(_('Display the details of your transaction before signing it.')) - self.send_button = EnterButton(_("Send"), self.do_send) - self.clear_button = EnterButton(_("Clear"), self.do_clear) + self.send_button = EnterButton(_("&Send"), self.do_send) + self.clear_button = EnterButton(_("&Clear"), self.do_clear) buttons = QHBoxLayout() - buttons.addStretch(1) buttons.addWidget(self.clear_button) buttons.addWidget(self.preview_button) buttons.addWidget(self.send_button) - grid.addLayout(buttons, 6, 1, 1, 3) + buttons.addStretch(1) + grid.addLayout(buttons, 11, 1, 1, 3) + + self.payto_e.textChanged.connect(self.update_buttons_on_seed) self.amount_e.shortcut.connect(self.spend_max) self.payto_e.textChanged.connect(self.update_fee) self.amount_e.textEdited.connect(self.update_fee) - - def reset_max(t): - self.is_max = False - self.max_button.setEnabled(not bool(t)) + self.message_opreturn_e.textEdited.connect(self.update_fee) + self.message_opreturn_e.textChanged.connect(self.update_fee) + self.message_opreturn_e.editingFinished.connect(self.update_fee) + self.opreturn_rawhex_cb.stateChanged.connect(self.update_fee) + + def reset_max(text): + self.max_button.setChecked(False) + if not self.slp_token_id: + enabled = not bool(text) and not self.amount_e.isReadOnly() + self.max_button.setEnabled(enabled) self.amount_e.textEdited.connect(reset_max) self.fiat_send_e.textEdited.connect(reset_max) def entry_changed(): - text = "" - - amt_color = ColorScheme.DEFAULT - fee_color = ColorScheme.DEFAULT - feerate_color = ColorScheme.DEFAULT + if self.is_slp_wallet: + hasError = entry_changed_slp() + if hasError == False: + entry_changed_zcl() + else: + entry_changed_zcl() + def entry_changed_zcl(): + text = "" if self.not_enough_funds: amt_color, fee_color = ColorScheme.RED, ColorScheme.RED - feerate_color = ColorScheme.RED - text = _( "Not enough funds" ) + text = _( "Not enough ZCL" ) c, u, x = self.wallet.get_frozen_balance() if c+u+x: text += ' (' + self.format_amount(c+u+x).strip() + ' ' + self.base_unit() + ' ' +_("are frozen") + ')' + slp = self.wallet.get_slp_locked_balance() + if slp > 0: + text += " (" + self.format_amount(slp).strip() + " ZCL held in tokens)" + extra = run_hook("not_enough_funds_extra", self) + if isinstance(extra, str) and extra: + text += " ({})".format(extra) - # blue color denotes auto-filled values elif self.fee_e.isModified(): - feerate_color = ColorScheme.BLUE - elif self.feerate_e.isModified(): - fee_color = ColorScheme.BLUE + amt_color, fee_color = ColorScheme.DEFAULT, ColorScheme.DEFAULT elif self.amount_e.isModified(): - fee_color = ColorScheme.BLUE - feerate_color = ColorScheme.BLUE + amt_color, fee_color = ColorScheme.DEFAULT, ColorScheme.BLUE else: - amt_color = ColorScheme.BLUE - fee_color = ColorScheme.BLUE - feerate_color = ColorScheme.BLUE + amt_color, fee_color = ColorScheme.BLUE, ColorScheme.BLUE + opret_color = ColorScheme.DEFAULT + if self.op_return_toolong: + opret_color = ColorScheme.RED + text = _("OP_RETURN message too large, needs to be no longer than 220 bytes") + (", " if text else "") + text self.statusBar().showMessage(text) self.amount_e.setStyleSheet(amt_color.as_stylesheet()) self.fee_e.setStyleSheet(fee_color.as_stylesheet()) - self.feerate_e.setStyleSheet(feerate_color.as_stylesheet()) + self.message_opreturn_e.setStyleSheet(opret_color.as_stylesheet()) self.amount_e.textChanged.connect(entry_changed) self.fee_e.textChanged.connect(entry_changed) - self.feerate_e.textChanged.connect(entry_changed) + self.message_opreturn_e.textChanged.connect(entry_changed) + self.message_opreturn_e.textEdited.connect(entry_changed) + self.message_opreturn_e.editingFinished.connect(entry_changed) + self.opreturn_rawhex_cb.stateChanged.connect(entry_changed) + if self.is_slp_wallet: + self.slp_amount_e.textChanged.connect(entry_changed) + self.slp_amount_e.editingFinished.connect(entry_changed) + + def entry_changed_slp(): + if self.token_type_combo.currentData(): + text = "" + name = self.wallet.token_types.get(self.slp_token_id)['name'] + decimals = self.wallet.token_types.get(self.slp_token_id)['decimals'] + if self.not_enough_funds_slp or self.not_enough_unfrozen_funds_slp: + bal_avail, x, x, x, frozen_amt = self.wallet.get_slp_token_balance(self.slp_token_id, { 'user_config': { 'confirmed_only': False }}) + del x + if self.not_enough_funds_slp: + amt_color = ColorScheme.RED + text = "Not enough " + \ + name + " tokens (" + \ + format_satoshis_plain_nofloat(bal_avail, decimals) + " valid" + if self.config.get('confirmed_only', False): + conf_bal_avail = self.wallet.get_slp_token_balance(self.slp_token_id, self.config)[0] + unconf_bal = bal_avail - conf_bal_avail + if unconf_bal > 0: + text += ", " + format_satoshis_plain_nofloat(unconf_bal, decimals) + " unconfirmed)" + else: + text += ")" + else: + text += ")" + elif self.not_enough_unfrozen_funds_slp: + amt_color = ColorScheme.RED + text = "Not enough unfrozen " + name + " tokens (" + \ + format_satoshis_plain_nofloat(bal_avail, decimals) + " valid, " + \ + format_satoshis_plain_nofloat(frozen_amt, decimals) + " frozen)" + elif self.slp_amount_e.isModified(): + amt_color = ColorScheme.DEFAULT + else: + amt_color = ColorScheme.BLUE + + try: + if self.slp_amount_e.get_amount() > (2 ** 64) - 1: + amt_color = ColorScheme.RED + maxqty = format_satoshis_plain_nofloat((2 ** 64) - 1, self.wallet.token_types.get(self.slp_token_id)['decimals']) + text = _('Token output quantity is too large. Maximum {maxqty}.').format(maxqty=maxqty) + except TypeError: + pass + + self.statusBar().showMessage(text) + self.slp_amount_e.setStyleSheet(amt_color.as_stylesheet()) + if text != "": + return True + return False self.invoices_label = QLabel(_('Invoices')) from .invoice_list import InvoiceList self.invoice_list = InvoiceList(self) + self.invoice_list.chkVisible() vbox0 = QVBoxLayout() vbox0.addLayout(grid) hbox = QHBoxLayout() hbox.addLayout(vbox0) + w = QWidget() vbox = QVBoxLayout(w) vbox.addLayout(hbox) @@ -1278,7 +1779,11 @@ def entry_changed(): return w def spend_max(self): - self.is_max = True + self.max_button.setChecked(True) + self.do_update_fee() + + def slp_spend_max(self): + self.slp_amount_e.setAmount(self.wallet.get_slp_token_balance(self.slp_token_id, self.config)[3]) self.do_update_fee() def update_fee(self): @@ -1290,92 +1795,154 @@ def get_payto_or_dummy(self): return r return (TYPE_ADDRESS, self.wallet.dummy_address()) + def get_custom_fee_text(self, fee_rate = None): + if not self.config.has_custom_fee_rate(): + return "" + else: + if fee_rate is None: fee_rate = self.config.custom_fee_rate() / 1000.0 + return str(round(fee_rate*100)/100) + " sats/B" + + @staticmethod + def output_for_opreturn_stringdata(op_return): + if not isinstance(op_return, str): + raise OPReturnError('OP_RETURN parameter needs to be of type str!') + pushes = op_return.split('') + script = "OP_RETURN" + for data in pushes: + if data.startswith(""): + data = data.replace("", "") + elif data.startswith(""): + pass + else: + data = data.encode('utf-8').hex() + script = script + " " + data + scriptBuffer = ScriptOutput.from_string(script) + if len(scriptBuffer.script) > 223: + raise OPReturnTooLarge(_("OP_RETURN message too large, needs to be under 220 bytes")) + amount = 0 + return (TYPE_SCRIPT, scriptBuffer, amount) + + @staticmethod + def output_for_opreturn_rawhex(op_return): + if not isinstance(op_return, str): + raise OPReturnError('OP_RETURN parameter needs to be of type str!') + if op_return == 'empty': + op_return = '' + try: + op_return_script = b'\x6a' + bytes.fromhex(op_return.strip()) + except ValueError: + raise OPReturnError(_('OP_RETURN script expected to be hexadecimal bytes')) + if len(op_return_script) > 223: + raise OPReturnTooLarge(_("OP_RETURN script too large, needs to be under 223 bytes")) + amount = 0 + return (TYPE_SCRIPT, ScriptOutput(op_return_script), amount) + def do_update_fee(self): '''Recalculate the fee. If the fee was manually input, retain it, but still build the TX to see if there are enough funds. ''' - freeze_fee = self.is_send_fee_frozen() - freeze_feerate = self.is_send_feerate_frozen() - amount = '!' if self.is_max else self.amount_e.get_amount() - if amount is None: - if not freeze_fee: - self.fee_e.setAmount(None) - self.not_enough_funds = False - self.statusBar().showMessage('') - else: - fee_estimator = self.get_send_fee_estimator() - outputs = self.payto_e.get_outputs(self.is_max) - if not outputs: - _type, addr = self.get_payto_or_dummy() - outputs = [(_type, addr, amount)] - is_sweep = bool(self.tx_external_keypairs) - make_tx = lambda fee_est: \ - self.wallet.make_unsigned_transaction( - self.get_coins(), outputs, self.config, - fixed_fee=fee_est, is_sweep=is_sweep) - try: - tx = make_tx(fee_estimator) - self.not_enough_funds = False - except (NotEnoughFunds, NoDynamicFeeEstimates) as e: + zcl_outputs = [] + token_output_amts = [] + self.not_enough_funds = False + self.not_enough_funds_slp = False + self.not_enough_unfrozen_funds_slp = False + freeze_fee = (self.fee_e.isModified() + and (self.fee_e.text() or self.fee_e.hasFocus())) + amount = '!' if self.max_button.isChecked() else self.amount_e.get_amount() + fee_rate = None + if self.is_slp_wallet: + slp_amount = self.slp_amount_e.get_amount() + if amount is None and slp_amount is None: if not freeze_fee: self.fee_e.setAmount(None) - if not freeze_feerate: - self.feerate_e.setAmount(None) - self.feerounding_icon.setVisible(False) - - if isinstance(e, NotEnoughFunds): - self.not_enough_funds = True - elif isinstance(e, NoDynamicFeeEstimates): - try: - tx = make_tx(0) - size = tx.estimated_size() - self.size_e.setAmount(size) - except BaseException: - pass + self.statusBar().showMessage('') return - except BaseException: - traceback.print_exc(file=sys.stderr) + else: + if amount is None: + if not freeze_fee: + self.fee_e.setAmount(None) + self.statusBar().showMessage('') return - size = tx.estimated_size() - self.size_e.setAmount(size) + try: + selected_slp_coins = [] + if self.slp_token_id: + amt = slp_amount or 0 + selected_slp_coins, slp_op_return_msg = SlpCoinChooser.select_coins(self.wallet, self.slp_token_id, amt, self.config) + if slp_op_return_msg: + zcl_outputs = [ slp_op_return_msg ] + token_output_amts = slp.SlpMessage.parseSlpOutputScript(zcl_outputs[0][1]).op_return_fields['token_output'] + for amt in token_output_amts: + # just grab a dummy address for this fee calculation - safe for imported_privkey wallets + zcl_outputs.append((TYPE_ADDRESS, self.wallet.get_addresses()[0], 546)) + + zcl_payto_outputs = self.payto_e.get_outputs(self.max_button.isChecked()) + if zcl_payto_outputs and zcl_payto_outputs[0][2]: + zcl_outputs.extend(zcl_payto_outputs) + elif self.slp_token_id and amount and not zcl_payto_outputs: + _type, addr = self.get_payto_or_dummy() + zcl_outputs.append((_type, addr, amount)) + if not zcl_outputs: + _type, addr = self.get_payto_or_dummy() + zcl_outputs.append((_type, addr, amount)) + + if not self.slp_token_id: + opreturn_message = self.message_opreturn_e.text() if self.config.get('enable_opreturn') else None + if (opreturn_message != '' and opreturn_message is not None): + if self.opreturn_rawhex_cb.isChecked(): + zcl_outputs.insert(0, self.output_for_opreturn_rawhex(opreturn_message)) + else: + zcl_outputs.insert(0, self.output_for_opreturn_stringdata(opreturn_message)) + + fee = self.fee_e.get_amount() if freeze_fee else None + tx = self.wallet.make_unsigned_transaction(self.get_coins(isInvoice = False), zcl_outputs, self.config, fee, mandatory_coins=selected_slp_coins) + if self.slp_token_id: + self.wallet.check_sufficient_slp_balance(slp.SlpMessage.parseSlpOutputScript(slp_op_return_msg[1]), self.config) + self.not_enough_funds = False + self.op_return_toolong = False + except NotEnoughFunds: + self.not_enough_funds = True + if not freeze_fee: + self.fee_e.setAmount(None) + return + except NotEnoughFundsSlp: + self.not_enough_funds_slp = True + if not freeze_fee: + self.fee_e.setAmount(None) + return + except NotEnoughUnfrozenFundsSlp: + self.not_enough_unfrozen_funds_slp = True + if not freeze_fee: + self.fee_e.setAmount(None) + return + except OPReturnTooLarge: + self.op_return_toolong = True + return + except OPReturnError as e: + self.statusBar().showMessage(str(e)) + return + except BaseException: + return + + if not freeze_fee: + fee = None if self.not_enough_funds else tx.get_fee() + if not self.slp_token_id or len(token_output_amts) > 0: + self.fee_e.setAmount(fee) - fee = tx.get_fee() - fee = None if self.not_enough_funds else fee + if self.max_button.isChecked(): + amount = tx.output_value() + if self.is_slp_wallet: + amount = tx.output_value() - len(token_output_amts) * 546 + self.amount_e.setAmount(amount) + if fee is not None: + fee_rate = fee / tx.estimated_size() + self.fee_slider_mogrifier(self.get_custom_fee_text(fee_rate)) - # Displayed fee/fee_rate values are set according to user input. - # Due to rounding or dropping dust in CoinChooser, - # actual fees often differ somewhat. - if freeze_feerate or self.fee_slider.is_active(): - displayed_feerate = self.feerate_e.get_amount() - if displayed_feerate: - displayed_feerate = displayed_feerate - else: - # fallback to actual fee - displayed_feerate = fee // size if fee is not None else None - self.feerate_e.setAmount(displayed_feerate) - displayed_fee = round(displayed_feerate * size / 1000) if displayed_feerate is not None else None - self.fee_e.setAmount(displayed_fee) - else: - if freeze_fee: - displayed_fee = self.fee_e.get_amount() - else: - # fallback to actual fee if nothing is frozen - displayed_fee = fee - self.fee_e.setAmount(displayed_fee) - displayed_fee = displayed_fee if displayed_fee else 0 - displayed_feerate = round(displayed_fee * 1000 / size) if displayed_fee is not None else None - self.feerate_e.setAmount(displayed_feerate) - - # show/hide fee rounding icon - feerounding = (fee - displayed_fee) if fee else 0 - self.set_feerounding_text(feerounding) - self.feerounding_icon.setToolTip(self.feerounding_text) - self.feerounding_icon.setVisible(bool(feerounding)) - - if self.is_max: - amount = tx.output_value() - self.amount_e.setAmount(amount) + def fee_slider_mogrifier(self, text = None): + fee_slider_hidden = self.config.has_custom_fee_rate() + self.fee_slider.setHidden(fee_slider_hidden) + self.fee_custom_lbl.setHidden(not fee_slider_hidden) + if text is not None: self.fee_custom_lbl.setText(text) def from_list_delete(self, item): i = self.from_list.indexOfTopLevelItem(item) @@ -1399,8 +1966,8 @@ def redraw_from_list(self): self.from_list.setHidden(len(self.pay_from) == 0) def format(x): - h = x.get('prevout_hash') - return h[0:10] + '...' + h[-10:] + ":%d"%x.get('prevout_n') + u'\t' + "%s"%x.get('address') + h = x['prevout_hash'] + return '{}...{}:{:d}\t{}'.format(h[0:10], h[-10:], x['prevout_n'], x['address']) for item in self.pay_from: self.from_list.addTopLevelItem(QTreeWidgetItem( [format(item), self.format_amount(item['value']) ])) @@ -1457,20 +2024,62 @@ def get_send_fee_estimator(self): fee_estimator = None return fee_estimator - def read_send_tab(self): + def read_send_tab(self, preview=False): + zcl_outputs = [] + selected_slp_coins = [] + opreturn_message = self.message_opreturn_e.text() if self.config.get('enable_opreturn') else None + if self.slp_token_id: + if self.slp_amount_e.get_amount() == 0 or self.slp_amount_e.get_amount() is None: + self.show_message(_("No ZSLP token amount provided.")) + return + try: + """ Guard against multiline 'Pay To' field """ + if self.payto_e.is_multiline(): + self.show_error(_("Too many receivers listed.\n\nCurrently this wallet only supports a single ZSLP token receiver.")) + return + """ Guard against bad address encoding """ + if not self.payto_e.payto_address: + self.show_error(_("The ZSLP address provided is not encoded properly.")) + return + """ Require SLPADDR prefix in 'Pay To' field. """ + if constants.net.SLPADDR_PREFIX not in self.payto_e.address_string_for_slp_check: + self.show_error(_("Address provided is not in ZSLP Address format.\n\nThe address should be encoded using 'zslp:' or 'zslptest:' URI prefix.")) + return + amt = self.slp_amount_e.get_amount() + selected_slp_coins, slp_op_return_msg = SlpCoinChooser.select_coins(self.wallet, self.slp_token_id, amt, self.config) + if slp_op_return_msg: + zcl_outputs = [ slp_op_return_msg ] + except OPReturnTooLarge as e: + self.show_error(str(e)) + return + except OPReturnError as e: + self.show_error(str(e)) + return + except (NotEnoughFundsSlp, NotEnoughUnfrozenFundsSlp) as e: + self.show_error(str(e)) + return + + isInvoice = False + if self.payment_request and self.payment_request.has_expired(): self.show_error(_('Payment request has expired')) return label = self.message_e.text() if self.payment_request: - outputs = self.payment_request.get_outputs() + if self.slp_token_id: + self.show_error('BIP-70 Payment requests are not yet working for ZSLP tokens.') + return + isInvoice = True + outputs.extend(self.payment_request.get_outputs()) else: errors = self.payto_e.get_errors() if errors: - self.show_warning(_("Invalid Lines found:") + "\n\n" + '\n'.join([ _("Line #") + str(x[0]+1) + ": " + x[1] for x in errors])) + self.show_warning(_("Invalid lines found:") + "\n\n" + '\n'.join([ _("Line #") + str(x[0]+1) + ": " + x[1] for x in errors])) return - outputs = self.payto_e.get_outputs(self.is_max) + if self.slp_token_id: + _type, _addr = self.payto_e.payto_address + zcl_outputs.append((_type, _addr, 546)) if self.payto_e.is_alias and self.payto_e.validated is False: alias = self.payto_e.toPlainText() @@ -1480,24 +2089,69 @@ def read_send_tab(self): if not self.question(msg): return - if not outputs: - self.show_error(_('No outputs')) - return + coins = self.get_coins(isInvoice=isInvoice) + + """ SLP: Add an additional token change output """ + if self.slp_token_id: + change_addr = None + token_outputs = slp.SlpMessage.parseSlpOutputScript(zcl_outputs[0][1]).op_return_fields['token_output'] + if len(token_outputs) > 1 and len(zcl_outputs) < len(token_outputs): + """ start of logic copied from wallet.py """ + addrs = self.wallet.get_change_addresses()[-self.wallet.gap_limit_for_change:] + if self.wallet.use_change and addrs: + # New change addresses are created only after a few + # confirmations. Select the unused addresses within the + # gap limit; if none take one at random + change_addrs = [addr for addr in addrs if + self.wallet.get_num_tx(addr) == 0] + if not change_addrs: + import random + change_addrs = [random.choice(addrs)] + change_addr = change_addrs[0] + elif len(change_addrs) > 1: + change_addr = change_addrs[1] + else: + change_addr = change_addrs[0] + elif coins: + change_addr = coins[0]['address'] + else: + change_addr = self.wallet.get_addresses()[0] + zcl_outputs.append((TYPE_ADDRESS, change_addr, 546)) - for _type, addr, amount in outputs: - if addr is None: - self.show_error(_('Zclassic Address is None')) + # add normal BCH amounts + if not self.payment_request and self.amount_e.get_amount(): + zcl_outputs.extend(self.payto_e.get_outputs(self.max_button.isChecked())) + + """ Only Allow OP_RETURN if SLP is disabled. """ + if not self.slp_token_id: + try: + # handle op_return if specified and enabled + opreturn_message = self.message_opreturn_e.text() + if opreturn_message: + if self.opreturn_rawhex_cb.isChecked(): + zcl_outputs.append(self.output_for_opreturn_rawhex(opreturn_message)) + else: + zcl_outputs.append(self.output_for_opreturn_stringdata(opreturn_message)) + except OPReturnTooLarge as e: + self.show_error(str(e)) return - if _type == TYPE_ADDRESS and not bitcoin.is_address(addr): - self.show_error(_('Invalid Zclassic Address')) + except OPReturnError as e: + self.show_error(str(e)) return + + + if not zcl_outputs: + self.show_error(_('Enter receiver address (No ZCL outputs).')) + return + + for _type, addr, amount in zcl_outputs: if amount is None: self.show_error(_('Invalid Amount')) return - fee_estimator = self.get_send_fee_estimator() - coins = self.get_coins() - return outputs, fee_estimator, label, coins + freeze_fee = self.fee_e.isVisible() and self.fee_e.isModified() and (self.fee_e.text() or self.fee_e.hasFocus()) + fee = self.fee_e.get_amount() if freeze_fee else None + return zcl_outputs, fee, label, coins, selected_slp_coins def do_preview(self): self.do_send(preview = True) @@ -1505,46 +2159,67 @@ def do_preview(self): def do_send(self, preview = False): if run_hook('abort_send', self): return - r = self.read_send_tab() + + r = self.read_send_tab(preview=preview) + if not r: return - outputs, fee_estimator, tx_desc, coins = r + outputs, fee, tx_desc, coins, slp_coins = r + + if self.slp_token_id: + try: + self.wallet.check_sufficient_slp_balance(slp.SlpMessage.parseSlpOutputScript(outputs[0][1]), self.config) + except slp.SlpInvalidOutputMessage: + self.show_message(_("No token outputs available.\n\nIf you have unconfirmed tokens wait 1 confirmation or turn off 'Spend only confirmed coins' in preferences, and try again.")) + return + except NotEnoughFundsSlp: + self.show_message(_("Token balance too low.")) + return + except NotEnoughUnfrozenFundsSlp: + self.show_message(_("Unfrozen ZSLP token balance is too low. Unfreeze some of the token coins associated with with this token.")) + return + try: - is_sweep = bool(self.tx_external_keypairs) - tx = self.wallet.make_unsigned_transaction( - coins, outputs, self.config, fixed_fee=fee_estimator, - is_sweep=is_sweep) + tx = self.wallet.make_unsigned_transaction(coins, outputs, self.config, fee, mandatory_coins=slp_coins) except NotEnoughFunds: - self.show_message(_("Insufficient funds")) + self.show_message(_("Insufficient ZCL balance")) + return + except ExcessiveFee: + self.show_message(_("Your fee is too high. Max is 50 sat/byte.")) return except BaseException as e: - traceback.print_exc(file=sys.stdout) + traceback.print_exc(file=sys.stderr) self.show_message(str(e)) return - amount = tx.output_value() if self.is_max else sum(map(lambda x:x[2], outputs)) + amount = tx.output_value() if self.max_button.isChecked() else sum(map(lambda x:x[2], outputs)) fee = tx.get_fee() - if fee < self.wallet.relayfee() * tx.estimated_size() / 1000: - self.show_error('\n'.join([ - _("This transaction requires a higher fee, or it will not be propagated by your current server"), - _("Try to raise your transaction fee, or use a server with a lower relay fee.") - ])) - return + # if fee < self.wallet.relayfee() * tx.estimated_size() / 1000: + # self.show_error('\n'.join([ + # _("This transaction requires a higher fee, or it will not be propagated by your current server"), + # _("Try to raise your transaction fee, or use a server with a lower relay fee.") + # ])) + # return if preview: self.show_transaction(tx, tx_desc) return - if not self.network: - self.show_error(_("You can't broadcast a transaction without a live network connection.")) - return - # confirmation dialog - msg = [ - _("Amount to be sent") + ": " + self.format_amount_and_units(amount), - _("Mining fee") + ": " + self.format_amount_and_units(fee), - ] + if self.slp_token_id: + slp_amt_str = format_satoshis_plain_nofloat(self.slp_amount_e.get_amount(), self.wallet.token_types.get(self.slp_token_id)['decimals']) + slp_name = self.wallet.token_types[self.slp_token_id]['name'] + msg = [ + _("BCH amount to be sent") + ": " + self.format_amount_and_units(amount), + "\nToken amount to be sent" + ": " + slp_amt_str + " " + slp_name, + _("\nMining fee") + ": " + self.format_amount_and_units(fee), + ] + else: + msg = [ + _("Amount to be sent") + ": " + self.format_amount_and_units(amount), + _("Mining fee") + ": " + self.format_amount_and_units(fee), + ] x_fee = run_hook('get_tx_extra_fee', self.wallet, tx) if x_fee: @@ -1552,17 +2227,25 @@ def do_send(self, preview = False): msg.append( _("Additional fees") + ": " + self.format_amount_and_units(x_fee_amount) ) confirm_rate = simple_config.FEERATE_WARNING_HIGH_FEE - if fee > confirm_rate * tx.estimated_size() / 1000: - msg.append(_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")) + + # if fee > confirm_rate * tx.estimated_size() / 1000: + # msg.append(_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")) + + if (fee < (tx.estimated_size())): + msg.append(_('\nWarning') + ': ' + _("You're using a fee of less than 1.0 sats/B. It may take a very long time to confirm.")) + tx.ephemeral['warned_low_fee_already'] = True + + if self.config.get('enable_opreturn') and self.message_opreturn_e.text(): + msg.append(_("\nYou are using an OP_RETURN message. This gets written permanently written to the blockchain.")) - if self.wallet.has_keystore_encryption(): + if self.wallet.has_password(): msg.append("") - msg.append(_("Enter your password to proceed")) + msg.append(_("\nEnter your password to proceed")) password = self.password_dialog('\n'.join(msg)) if not password: return else: - msg.append(_('Proceed?')) + msg.append(_('\nProceed?')) password = None if not self.question('\n'.join(msg)): return @@ -1570,21 +2253,28 @@ def do_send(self, preview = False): def sign_done(success): if success: if not tx.is_complete(): - self.show_transaction(tx) + self.show_transaction(tx, tx_desc) self.do_clear() else: self.broadcast_transaction(tx, tx_desc) self.sign_tx_with_password(tx, sign_done, password) @protected - def sign_tx(self, tx, callback, password): - self.sign_tx_with_password(tx, callback, password) + def sign_tx(self, tx, callback, password, *, slp_coins_to_burn=None): + self.sign_tx_with_password(tx, callback, password, slp_coins_to_burn=slp_coins_to_burn) - def sign_tx_with_password(self, tx, callback, password): + def sign_tx_with_password(self, tx, callback, password, *, slp_coins_to_burn=None): '''Sign the transaction in a separate thread. When done, calls the callback with a success code of True or False. ''' + # check transaction SLP validity before signing + try: + assert SlpTransactionChecker.check_tx_slp(self.wallet, tx, coins_to_burn=slp_coins_to_burn) + except (Exception, AssertionError) as e: + self.show_warning(str(e)) + return + def on_signed(result): callback(True) def on_failed(exc_info): @@ -1651,8 +2341,14 @@ def query_choice(self, msg, choices): return clayout.selected_index() def lock_amount(self, b): - self.amount_e.setFrozen(b) - self.max_button.setEnabled(not b) + ''' + This if-statement was added for SLP around the following two lines + in order to keep the amount field locked and Max button disabled + when the payto field is edited when a token is selected. + ''' + if self.token_type_combo.currentData(): + self.amount_e.setFrozen(True) + self.max_button.setEnabled(False) def prepare_for_payment_request(self): self.show_send_tab() @@ -1703,9 +2399,9 @@ def pay_to_URI(self, URI): if not URI: return try: - out = util.parse_URI(URI, self.on_pr) + out = web.parse_URI(URI, self.on_pr) except BaseException as e: - self.show_error(_('Invalid zclassic URI:') + '\n' + str(e)) + self.show_error(_('Invalid Address URI:') + '\n' + str(e)) return self.show_send_tab() r = out.get('r') @@ -1718,35 +2414,80 @@ def pay_to_URI(self, URI): amount = out.get('amount') label = out.get('label') message = out.get('message') + op_return = out.get('op_return') + op_return_raw = out.get('op_return_raw') + # use label as description (not BIP21 compliant) if label and not message: message = label if address: - self.payto_e.setText(address) + self.payto_e.setText(URI.split('?')[0]) if message: self.message_e.setText(message) if amount: self.amount_e.setAmount(amount) self.amount_e.textEdited.emit("") - + if op_return: + self.message_opreturn_e.setText(op_return) + self.message_opreturn_e.setHidden(False) + self.opreturn_rawhex_cb.setHidden(False) + self.opreturn_rawhex_cb.setChecked(False) + self.opreturn_label.setHidden(False) + elif op_return_raw is not None: + # 'is not None' allows blank value. + # op_return_raw is secondary precedence to op_return + if not op_return_raw: + op_return_raw='empty' + self.message_opreturn_e.setText(op_return_raw) + self.message_opreturn_e.setHidden(False) + self.opreturn_rawhex_cb.setHidden(False) + self.opreturn_rawhex_cb.setChecked(True) + self.opreturn_label.setHidden(False) + elif not self.config.get('enable_opreturn'): + self.message_opreturn_e.setText('') + self.message_opreturn_e.setHidden(True) + self.opreturn_label.setHidden(True) def do_clear(self): - self.is_max = False - self.not_enough_funds = False - self.payment_request = None - self.payto_e.is_pr = False - for e in [self.payto_e, self.message_e, self.amount_e, self.fiat_send_e, - self.fee_e, self.feerate_e]: - e.setText('') - e.setFrozen(False) - self.fee_slider.activate() - self.feerate_e.setAmount(self.config.fee_per_kb()) - self.size_e.setAmount(0) - self.feerounding_icon.setVisible(False) - self.set_pay_from([]) - self.tx_external_keypairs = {} - self.update_status() - run_hook('do_clear', self) + """ + If SLP token is not selected proceed as normal, otherwise see + the else-statement below which provides modified "do_clear" behavior + after a payment is sent + """ + if self.token_type_combo.currentData() is None: + self.is_max = False + self.not_enough_funds = False + self.not_enough_funds_slp = False + self.not_enough_unfrozen_funds_slp = False + self.op_return_toolong = False + self.payment_request = None + self.payto_e.is_pr = False + for e in [self.payto_e, self.message_e, self.amount_e, self.fiat_send_e, self.fee_e, self.message_opreturn_e]: + e.setText('') + e.setFrozen(False) + self.max_button.setDisabled(False) + self.set_pay_from([]) + self.tx_external_keypairs = {} + self.update_status() + self.slp_amount_e.setText('') + run_hook('do_clear', self) + else: + self.not_enough_funds = False + self.not_enough_funds_slp = False + self.not_enough_unfrozen_funds_slp = False + self.payment_request = None + self.payto_e.is_pr = False + for e in [self.payto_e, self.message_e, self.message_opreturn_e]: + e.setText('') + e.setFrozen(False) + self.max_button.setDisabled(True) + self.set_pay_from([]) + self.tx_external_keypairs = {} + self.message_opreturn_e.setVisible(self.config.get('enable_opreturn', False)) + self.opreturn_label.setVisible(self.config.get('enable_opreturn', False)) + self.update_status() + self.slp_amount_e.setText('') + #run_hook('do_clear', self) def set_frozen_state(self, addrs, freeze): self.wallet.set_frozen_state(addrs, freeze) @@ -1754,32 +2495,140 @@ def set_frozen_state(self, addrs, freeze): self.utxo_list.update() self.update_fee() - def create_list_tab(self, l, toolbar=None): + def set_frozen_coin_state(self, utxos, freeze): + self.wallet.set_frozen_coin_state(utxos, freeze) + self.utxo_list.update() + self.update_fee() + + def create_converter_tab(self): + + source_address = QLineEdit() + zclassic_address = ButtonsLineEdit() + zclassic_address.addCopyButton(self.app) + zclassic_address.setReadOnly(True) + slp_address = ButtonsLineEdit() + slp_address.setReadOnly(True) + slp_address.addCopyButton(self.app) + widgets = [ + (zclassic_address, Address.FMT_ZCLASSIC), + (slp_address, Address.FMT_SLPADDR) + ] + + def convert_address(): + try: + addr = Address.from_string(source_address.text().strip()) + except: + addr = None + for widget, fmt in widgets: + if addr: + widget.setText(addr.to_full_string(fmt)) + else: + widget.setText('') + + source_address.textChanged.connect(convert_address) + + w = QWidget() + grid = QGridLayout() + grid.setSpacing(15) + grid.setColumnStretch(1, 2) + grid.setColumnStretch(2, 1) + + label = QLabel(_('&Address to convert')) + label.setBuddy(source_address) + grid.addWidget(label, 0, 0) + grid.addWidget(source_address, 0, 1) + + label = QLabel(_('&Zclassic address')) + label.setBuddy(zclassic_address) + grid.addWidget(label, 1, 0) + grid.addWidget(zclassic_address, 1, 1) + + grid.addWidget(QLabel(_('ZSLP address')), 3, 0) + grid.addWidget(slp_address, 3, 1) + w.setLayout(grid) + + label = WWLabel(_( + "This tool helps convert between address formats for Zclassic addresses. " + )) + + vbox = QVBoxLayout() + vbox.addWidget(label) + vbox.addWidget(w) + vbox.addStretch(1) + w = QWidget() + w.setLayout(vbox) + + return w + + def create_list_tab(self, l, list_header=None): + class ListTab(QWidget): + def showEvent(self, e): + super().showEvent(e) + if self.main_window.is_slp_wallet: + self.main_window.toggle_cashaddr(1, True) + else: + self.main_window.toggle_cashaddr(0, True) + + w = ListTab() + w.main_window = self w.searchable_list = l vbox = QVBoxLayout() w.setLayout(vbox) vbox.setContentsMargins(0, 0, 0, 0) vbox.setSpacing(0) - if toolbar: - vbox.addLayout(toolbar) + if list_header: + hbox = QHBoxLayout() + for b in list_header: + hbox.addWidget(b) + hbox.addStretch() + vbox.addLayout(hbox) vbox.addWidget(l) return w def create_addresses_tab(self): from .address_list import AddressList self.address_list = l = AddressList(self) - l.setObjectName("addresses_container") - toolbar = l.create_toolbar(self.config) - toolbar_shown = self.config.get('show_toolbar_addresses', False) - l.show_toolbar(toolbar_shown) - return self.create_list_tab(l, toolbar) + self.cashaddr_toggled_signal.connect(l.update) + return self.create_list_tab(l) def create_utxo_tab(self): from .utxo_list import UTXOList self.utxo_list = l = UTXOList(self) + self.cashaddr_toggled_signal.connect(l.update) return self.create_list_tab(l) + def create_slp_mgt_tab(self): + self.create_token_dialog = None + from .slp_mgt import SlpMgt + self.token_list = l = SlpMgt(self) + w = self.create_list_tab(l) + vbox = w.layout() + vbox.setSpacing(10) + create_button = b = QPushButton(_("Create New Token")) + create_button.setAutoDefault(False) + create_button.setDefault(False) + b.clicked.connect(self.show_create_token_dialog) + vbox.addWidget(create_button) + w.setLayout(vbox) + return w + + def show_create_token_dialog(self): + c, u, x = self.wallet.get_balance() + bal = c + u - self.wallet.get_slp_locked_balance() + if bal < 1000: + self.receive_tab.low_balance_warning_shown = True + self.show_warning("Low ZCL balance.\n\nBefore creating a new token you must add Zclassic to this wallet. We recommend a minimum of 0.0001 ZCL to get started.\n\nSend ZCL to the address displayed in the 'Receive' tab.") + self.show_receive_tab() + self.toggle_cashaddr(1, True) + return + try: + self.create_token_dialog.show() + self.create_token_dialog.raise_() + self.create_token_dialog.activateWindow() + except AttributeError: + self.create_token_dialog = d = SlpCreateTokenGenesisDialog(self,) + def create_contacts_tab(self): from .contact_list import ContactList self.contact_list = l = ContactList(self) @@ -1791,11 +2640,11 @@ def remove_address(self, addr): self.need_update.set() # history, addresses, coins self.clear_receive_tab() - def get_coins(self): + def get_coins(self, isInvoice = False, *, slpTokenId = None): if self.pay_from: return self.pay_from else: - return self.wallet.get_spendable_coins(None, self.config) + return self.wallet.get_spendable_coins(None, self.config, isInvoice) def spend_coins(self, coins): self.set_pay_from(coins) @@ -1845,6 +2694,48 @@ def delete_contacts(self, labels): self.contact_list.update() self.update_completions() + def add_token_type(self, token_class, token_id, token_name, decimals_divisibility, *, error_callback=None, show_errors=True, allow_overwrite=False): + # FIXME: are both args error_callback and show_errors both necessary? + # Maybe so if we want the default to be self.show_error... + + if not show_errors: + # setting error_callback to None will suppress errors being shown + # iff show_errors is False + error_callback = None + if error_callback is None and show_errors: + # They asked for errors but supplied no callback. Use the standard + # one for main_window + error_callback = self.show_error + + # The below call checks sanity and calls error_callback for us + # with an error message argument on failure, returning False. + # On success it will add the token, write to wallet storage, + # and potentially kick off the verifier. + if not self.wallet.add_token_safe( + token_class, token_id, token_name, decimals_divisibility, + error_callback=error_callback, allow_overwrite=allow_overwrite, + write_storage=True): + return False + + # Great success! Update GUI. + self.token_list.update() + self.update_token_type_combo() + self.slp_history_list.update() + return True + + def delete_slp_token(self, token_ids): + if not self.question(_("Remove {} from your list of tokens?") + .format(" + ".join(token_ids))): + return + + for tid in token_ids: + self.wallet.token_types.pop(tid) + + self.token_list.update() + self.update_token_type_combo() + self.slp_history_list.update() + self.wallet.save_transactions(True) + def show_invoice(self, key): pr = self.invoices.get(key) if pr is None: @@ -1933,22 +2824,35 @@ def create_status_bar(self): qtVersion = qVersion() self.balance_label = QLabel("") - self.balance_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - self.balance_label.setStyleSheet("""QLabel { padding: 0 }""") sb.addWidget(self.balance_label) + self._search_box_spacer = QWidget() + self._search_box_spacer.setFixedWidth(6) # 6 px spacer self.search_box = QLineEdit() + self.search_box.setPlaceholderText(_("Search wallet, {key}F to hide").format(key='Ctrl+' if sys.platform != 'darwin' else '⌘')) self.search_box.textChanged.connect(self.do_search) self.search_box.hide() - sb.addPermanentWidget(self.search_box) + sb.addPermanentWidget(self.search_box, 1) + + self.addr_format_label = QLabel("") + sb.addPermanentWidget(self.addr_format_label) self.lock_icon = QIcon() self.password_button = StatusBarButton(self.lock_icon, _("Password"), self.change_password_dialog ) sb.addPermanentWidget(self.password_button) + self.addr_converter_button = StatusBarButton( + self.cashaddr_icon(), + _("Toggle Zclassic Display"), + self.toggle_cashaddr_status_bar + ) + sb.addPermanentWidget(self.addr_converter_button) + sb.addPermanentWidget(StatusBarButton(QIcon(":icons/preferences.png"), _("Preferences"), self.settings_dialog ) ) self.seed_button = StatusBarButton(QIcon(":icons/seed.png"), _("Seed"), self.show_seed_dialog ) sb.addPermanentWidget(self.seed_button) + weekSelf = Weak(self) + gui_object = self.gui_object self.status_button = StatusBarButton(QIcon(":icons/status_disconnected.png"), _("Network"), lambda: self.gui_object.show_network_dialog(self)) sb.addPermanentWidget(self.status_button) run_hook('create_status_bar', sb) @@ -2087,10 +2991,13 @@ def remove_wallet(self): def _delete_wallet(self, password): wallet_path = self.wallet.storage.path basename = os.path.basename(wallet_path) - self.gui_object.daemon.stop_wallet(wallet_path) + r = self.gui_object.daemon.delete_wallet(wallet_path) # implicitly also calls stop_wallet self.close() - os.unlink(wallet_path) - self.show_error("Wallet removed:" + basename) + self.update_recently_visited(wallet_path) # this ensures it's deleted from the menu + if r: + self.show_error(_("Wallet removed: {}").format(basename)) + else: + self.show_error(_("Wallet file not found: {}").format(basename)) @protected def show_seed_dialog(self, password): @@ -2119,7 +3026,7 @@ def show_private_key(self, address, password): if not address: return try: - pk, redeem_script = self.wallet.export_private_key(address, password) + pk = self.wallet.export_private_key(address, password) except Exception as e: traceback.print_exc(file=sys.stdout) self.show_message(str(e)) @@ -2134,11 +3041,10 @@ def show_private_key(self, address, password): keys_e = ShowQRTextEdit(text=pk) keys_e.addCopyButton(self.app) vbox.addWidget(keys_e) - if redeem_script: - vbox.addWidget(QLabel(_("Redeem Script") + ':')) - rds_e = ShowQRTextEdit(text=redeem_script) - rds_e.addCopyButton(self.app) - vbox.addWidget(rds_e) + vbox.addWidget(QLabel(_("Redeem Script") + ':')) + rds_e = ShowQRTextEdit(text=address.to_script().hex()) + rds_e.addCopyButton(self.app) + vbox.addWidget(rds_e) vbox.addLayout(Buttons(CloseButton(d))) d.setLayout(vbox) d.exec_() @@ -2153,21 +3059,22 @@ def show_private_key(self, address, password): def do_sign(self, address, message, signature, password): address = address.text().strip() message = message.toPlainText().strip() - if not bitcoin.is_address(address): + try: + addr = Address.from_string(address) + except: self.show_message(_('Invalid Zclassic address.')) return if self.wallet.is_watching_only(): self.show_message(_('This is a watching-only wallet.')) return - if not self.wallet.is_mine(address): + if not self.wallet.is_mine(addr): self.show_message(_('Address not in wallet.')) return - txin_type = self.wallet.get_txin_type(address) - if txin_type not in ['p2pkh']: + if addr.kind != addr.ADDR_P2PKH: self.show_message(_('Cannot sign messages with this type of address:') + \ ' ' + txin_type + '\n\n' + self.msg_sign) return - task = partial(self.wallet.sign_message, address, message, password) + task = partial(self.wallet.sign_message, addr, message, password) def show_signed_message(sig): try: @@ -2195,7 +3102,7 @@ def do_verify(self, address, message, signature): else: self.show_error(_("Wrong signature")) - def sign_verify_message(self, address=''): + def sign_verify_message(self, address=None): d = WindowModalDialog(self, _('Sign/verify Message')) d.setMinimumSize(610, 290) @@ -2207,7 +3114,7 @@ def sign_verify_message(self, address=''): layout.setRowStretch(2,3) address_e = QLineEdit() - address_e.setText(address) + address_e.setText(address.to_ui_string() if address else '') layout.addWidget(QLabel(_('Address')), 2, 0) layout.addWidget(address_e, 2, 1) @@ -2259,7 +3166,7 @@ def do_encrypt(self, message_e, pubkey_e, encrypted_e): traceback.print_exc(file=sys.stdout) self.show_warning(str(e)) - def encrypt_message(self, address=''): + def encrypt_message(self, address=None): d = WindowModalDialog(self, _('Encrypt/decrypt Message')) d.setMinimumSize(610, 490) @@ -2273,6 +3180,8 @@ def encrypt_message(self, address=''): pubkey_e = QLineEdit() if address: pubkey = self.wallet.get_public_key(address) + if not isinstance(pubkey, str): + pubkey = pubkey.to_ui_string() pubkey_e.setText(pubkey) layout.addWidget(QLabel(_('Public key')), 2, 0) layout.addWidget(pubkey_e, 2, 1) @@ -2323,7 +3232,7 @@ def read_tx_from_qrcode(self): if not data: return # if the user scanned a zclassic URI - if str(data).startswith("zclassic:"): + if data.lower().startswith("zclassic:") or data.lower().startswith(constants.net.SLPADDR_PREFIX + ':'): self.pay_to_URI(data) return # else if the user scanned an offline signed tx @@ -2416,7 +3325,7 @@ def privkeys_thread(): time.sleep(0.1) if done or cancelled: break - privkey = self.wallet.export_private_key(addr, password)[0] + privkey = self.wallet.export_private_key(addr, password) private_keys[addr] = privkey self.computing_privkeys_signal.emit() if not cancelled: @@ -2424,7 +3333,8 @@ def privkeys_thread(): self.show_privkeys_signal.emit() def show_privkeys(): - s = "\n".join( map( lambda x: x[0] + "\t"+ x[1], private_keys.items())) + s = "\n".join('{:45} {}'.format(addr.to_ui_string(), privkey) + for addr, privkey in private_keys.items()) e.setText(s) b.setEnabled(True) self.show_privkeys_signal.disconnect() @@ -2597,6 +3507,48 @@ def update_fiat(self): self.address_list.update() self.update_status() + def cashaddr_icon(self): + if self.config.get('addr_format', 0) == 1: + return QIcon(":icons/tab_converter.svg") + elif self.config.get('addr_format', 0)==2: + return QIcon(":icons/tab_converter_slp.svg") + else: + return QIcon(":icons/tab_converter_bw.svg") + + def update_cashaddr_icon(self): + self.addr_converter_button.setIcon(self.cashaddr_icon()) + + def toggle_cashaddr_status_bar(self): + self.toggle_cashaddr(self.config.get('addr_format', 2)) + + def toggle_cashaddr_settings(self,state): + self.toggle_cashaddr(state, True) + + def toggle_cashaddr(self, format, specified = False): + #Gui toggle should just increment, if "specified" is True it is being set from preferences, so leave the value as is. + if specified==False: + if self.is_slp_wallet: + max_format=1 + else: + max_format=0 + format+=1 + if format > max_format: + format=0 + self.config.set_key('addr_format', format) + Address.show_cashaddr(format) + self.setAddrFormatText(format) + for window in self.gui_object.windows: + window.cashaddr_toggled_signal.emit() + + def setAddrFormatText(self, format): + try: + if format == 0: + self.addr_format_label.setText("Addr Format: Zclassic") + else: + self.addr_format_label.setText("Addr Format: ZSLP") + except AttributeError: + pass + def settings_dialog(self): self.need_restart = False d = WindowModalDialog(self, _('Preferences')) @@ -2607,6 +3559,17 @@ def settings_dialog(self): tx_widgets = [] id_widgets = [] + addr_format_choices = ["Zclassic Format","ZSLP Format"] + addr_format_dict={'Zclassic Format':0,'ZSLP Format':1} + msg = _('Choose which format the wallet displays for Zclassic addresses') + addr_format_label = HelpLabel(_('Address Format') + ':', msg) + addr_format_combo = QComboBox() + addr_format_combo.addItems(addr_format_choices) + addr_format_combo.setCurrentIndex(self.config.get("addr_format", 0)) + addr_format_combo.currentIndexChanged.connect(self.toggle_cashaddr_settings) + + gui_widgets.append((addr_format_label,addr_format_combo)) + # language lang_help = _('Select which language is used in the GUI (after restart).') lang_label = HelpLabel(_('Language') + ':', lang_help) @@ -2753,12 +3716,12 @@ def on_unit(x, nz): unit_combo.currentIndexChanged.connect(lambda x: on_unit(x, nz)) gui_widgets.append((unit_label, unit_combo)) - block_explorers = sorted(util.block_explorer_info().keys()) + block_explorers = sorted(web.block_explorer_info().keys()) msg = _('Choose which online block explorer to use for functions that open a web browser') block_ex_label = HelpLabel(_('Online Block Explorer') + ':', msg) block_ex_combo = QComboBox() block_ex_combo.addItems(block_explorers) - block_ex_combo.setCurrentIndex(block_ex_combo.findText(util.block_explorer(self.config))) + block_ex_combo.setCurrentIndex(block_ex_combo.findText(web.block_explorer(self.config))) def on_be(x): be_result = block_explorers[block_ex_combo.currentIndex()] self.config.set_key('block_explorer', be_result, True) @@ -2859,6 +3822,56 @@ def on_outrounding(x): ccy_combo = QComboBox() ex_combo = QComboBox() + def on_opret(x): + self.config.set_key('enable_opreturn', bool(x)) + if not x: + self.message_opreturn_e.setText("") + self.op_return_toolong = False + self.message_opreturn_e.setHidden(not x) + self.opreturn_rawhex_cb.setHidden(not x) + self.opreturn_label.setHidden(not x) + + enable_opreturn = bool(self.config.get('enable_opreturn')) + opret_cb = QCheckBox(_('Enable OP_RETURN output')) + opret_cb.setToolTip(_('Enable posting messages with OP_RETURN.')) + opret_cb.setChecked(enable_opreturn) + opret_cb.stateChanged.connect(on_opret) + tx_widgets.append((opret_cb,None)) + + def on_slptok_pref(x): + x = bool(x) + self.config.set_key('enable_slp', x) + + wallet = self.wallet + + self.slp_amount_e.setHidden(not x) + self.slp_max_button.setHidden(not x) + self.token_type_combo.setHidden(not x) + self.slp_amount_label.setHidden(not x) + self.slp_token_type_label.setHidden(not x) + + if x: + self.toggle_tab(self.slp_mgt_tab, 1) + self.toggle_tab(self.slp_history_tab, 1) + opret_cb.setChecked(False) + opret_cb.setDisabled(True) + self.config.set_key('enable_opreturn',False) + self.toggle_cashaddr(2, True) + else: + self.toggle_tab(self.slp_mgt_tab, 2) + self.toggle_tab(self.slp_history_tab, 2) + opret_cb.setEnabled(True) + self.slp_amount_e.setAmount(0) + self.slp_amount_e.setText("") + self.token_type_combo.setCurrentIndex(0) + self.toggle_cashaddr(1, True) + + # wallet.activate_slp() if x else pass + + self.update_token_type_combo() + self.update_cashaddr_icon() + self.update_tabs() + def update_currencies(): if not self.fx: return currencies = sorted(self.fx.get_currencies(self.fx.get_history_config())) @@ -2916,6 +3929,7 @@ def on_history(checked): self.fx.set_history_config(checked) update_exchanges() self.history_list.refresh_headers() + self.slp_history_list.refresh_headers() if self.fx.is_enabled() and checked: # reset timeout to get historical rates self.fx.timeout = 0 @@ -2996,10 +4010,29 @@ def closeEvent(self, event): self.clean_up() event.accept() + def clean_up_connections(self): + def _disconnect_signals(): + for attr_name in dir(self): + if attr_name.endswith("_signal"): + sig = getattr(self, attr_name) + if isinstance(sig, pyqtBoundSignal): + try: + sig.disconnect() + #self.print_error("Disconnected signal:",attr_name) + except TypeError: # no connections + pass + def _disconnect_network_callbacks(): + if self.network: + self.network.unregister_callback(self.on_network) + self.network.unregister_callback(self.on_quotes) + self.network.unregister_callback(self.on_history) + # / + _disconnect_network_callbacks() + _disconnect_signals() + def clean_up(self): self.wallet.thread.stop() - if self.network: - self.network.unregister_callback(self.on_network) + self.clean_up_connections() self.config.set_key("is_maximized", self.isMaximized()) if not self.isMaximized(): g = self.geometry() @@ -3091,3 +4124,193 @@ def save_transaction_into_wallet(self, tx): self.need_update.set() self.msg_box(QPixmap(":icons/offline_tx.png"), None, _('Success'), _("Transaction added to wallet history")) return True + + def copy_to_clipboard(self, text, tooltip=None, widget=None): + tooltip = tooltip or _("Text copied to clipboard") + widget = widget or self + qApp.clipboard().setText(text) + QToolTip.showText(QCursor.pos(), tooltip, widget) + +class TxUpdateMgr(QObject, PrintError): + ''' Manages new transaction notifications and transaction verified + notifications from the network thread. It collates them and sends them to + the appropriate GUI controls in the main_window in an efficient manner. ''' + def __init__(self, main_window_parent): + assert isinstance(main_window_parent, ElectrumWindow), "TxUpdateMgr must be constructed with an ElectrumWindow as its parent" + super().__init__(main_window_parent) + self.cleaned_up = False + self.lock = threading.Lock() # used to lock thread-shared attrs below + # begin thread-shared attributes + self.notif_q = [] + self.verif_q = [] + self.need_process_v, self.need_process_n = False, False + # /end thread-shared attributes + self.weakParent = Weak.ref(main_window_parent) + main_window_parent.history_updated_signal.connect(self.verifs_get_and_clear, Qt.DirectConnection) # immediately clear verif_q on history update because it would be redundant to keep the verify queue around after a history list update + main_window_parent.on_timer_signal.connect(self.do_check, Qt.DirectConnection) # hook into main_window's timer_actions function + self.full_hist_refresh_timer = QTimer(self) + self.full_hist_refresh_timer.setInterval(1000); self.full_hist_refresh_timer.setSingleShot(False) + self.full_hist_refresh_timer.timeout.connect(self.schedule_full_hist_refresh_maybe) + + def diagnostic_name(self): + return ((self.weakParent() and self.weakParent().diagnostic_name()) or "???") + "." + __class__.__name__ + + def clean_up(self): + self.cleaned_up = True + main_window_parent = self.weakParent() # weak -> strong ref + if main_window_parent: + try: main_window_parent.history_updated_signal.disconnect(self.verifs_get_and_clear) + except TypeError: pass + try: main_window_parent.on_timer_signal.disconnect(self.do_check) + except TypeError: pass + + def do_check(self): + ''' Called from timer_actions in main_window to check if notifs or + verifs need to update the GUI. + - Checks the need_process_[v|n] flags + - If either flag is set, call the @rate_limited process_verifs + and/or process_notifs functions which update GUI parent in a + rate-limited (collated) fashion (for decent GUI responsiveness). ''' + with self.lock: + bV, bN = self.need_process_v, self.need_process_n + self.need_process_v, self.need_process_n = False, False + if bV: self.process_verifs() # rate_limited call (1 per second) + if bN: self.process_notifs() # rate_limited call (1 per 15 seconds) + + def verifs_get_and_clear(self): + ''' Clears the verif_q. This is called from the network + thread for the 'verified2' event as well as from the below + update_verifs (GUI thread), hence the lock. ''' + with self.lock: + ret = self.verif_q + self.verif_q = [] + self.need_process_v = False + return ret + + def notifs_get_and_clear(self): + with self.lock: + ret = self.notif_q + self.notif_q = [] + self.need_process_n = False + return ret + + def verif_add(self, args): + # args: [wallet, tx_hash, height, conf, timestamp] + # filter out tx's not for this wallet + parent = self.weakParent() + if not parent or parent.cleaned_up: + return + if args[0] is parent.wallet: + with self.lock: + self.verif_q.append(args[1:]) + self.need_process_v = True + + def notif_add(self, args): + parent = self.weakParent() + if not parent or parent.cleaned_up: + return + tx, wallet = args + # filter out tx's not for this wallet + if wallet is parent.wallet: + with self.lock: + self.notif_q.append(tx) + self.need_process_n = True + + @rate_limited(1.0, ts_after=True) + def process_verifs(self): + ''' Update history list with tx's from verifs_q, but limit the + GUI update rate to once per second. ''' + parent = self.weakParent() + if not parent or parent.cleaned_up: + return + items = self.verifs_get_and_clear() + if items: + t0 = time.time() + parent.history_list.setUpdatesEnabled(False) + parent.slp_history_list.setUpdatesEnabled(False) + had_sorting = [ parent.history_list.isSortingEnabled(), + parent.slp_history_list.isSortingEnabled() ] + if had_sorting[0]: + parent.history_list.setSortingEnabled(False) + if had_sorting[1]: + parent.slp_history_list.setSortingEnabled(False) + n_updates = 0 + for item in items: + did_update = parent.history_list.update_item(*item) + parent.slp_history_list.update_item_netupdate(*item) + n_updates += 1 if did_update else 0 + self.print_error("Updated {}/{} verified txs in GUI in {:0.2f} ms" + .format(n_updates, len(items), (time.time()-t0)*1e3)) + if had_sorting[0]: + parent.history_list.setSortingEnabled(True) + if had_sorting[1]: + parent.slp_history_list.setSortingEnabled(True) + parent.slp_history_list.setUpdatesEnabled(True) + parent.history_list.setUpdatesEnabled(True) + parent.update_status() + if parent.history_list.has_unknown_balances: + self.print_error("History tab: 'Unknown' balances detected, will schedule a GUI refresh after wallet settles") + self._full_refresh_ctr = 0 + self.full_hist_refresh_timer.start() + + _full_refresh_ctr = 0 + def schedule_full_hist_refresh_maybe(self): + ''' self.full_hist_refresh_timer timeout slot. May schedule a full + history refresh after wallet settles if we have "Unknown" balances. ''' + parent = self.weakParent() + if self._full_refresh_ctr > 60: + # Too many retries. Give up. + self.print_error("History tab: Full refresh scheduler timed out.. wallet hasn't settled in 1 minute. Giving up.") + self.full_hist_refresh_timer.stop() + elif parent and parent.history_list.has_unknown_balances: + # Still have 'Unknown' balance. Check if wallet is settled. + if self.need_process_v or not parent.wallet.is_fully_settled_down(): + # Wallet not fully settled down yet... schedule this function to run later + self.print_error("History tab: Wallet not yet settled.. will try again in 1 second...") + else: + # Wallet has settled. Schedule an update. Note this function may be called again + # in 1 second to check if the 'Unknown' situation has corrected itself. + self.print_error("History tab: Wallet has settled down, latching need_update to true") + parent.need_update.set() + self._full_refresh_ctr += 1 + else: + # No more polling is required. 'Unknown' balance disappeared from + # GUI (or parent window was just closed). + self.full_hist_refresh_timer.stop() + self._full_refresh_ctr = 0 + + @rate_limited(5.0, classlevel=True) + def process_notifs(self): + parent = self.weakParent() + if not parent or parent.cleaned_up: + return + if parent.network: + n_ok = 0 + txns = self.notifs_get_and_clear() + if txns and parent.wallet.storage.get('gui_notify_tx', True): + # Combine the transactions + total_amount = 0 + tokens_included = set() + for tx in txns: + if tx: + is_relevant, is_mine, v, fee = parent.wallet.get_wallet_delta(tx) + if is_relevant: + total_amount += v + n_ok += 1 + if parent.is_slp_wallet: + try: + tti = parent.wallet.get_slp_token_info(tx.txid()) + tokens_included.add(parent.wallet.token_types.get(tti['token_id'],{}).get('name','unknown')) + except KeyError: + pass + if tokens_included: + tokstring = _('. Tokens included: ') + ', '.join(sorted(tokens_included)) + else: + tokstring = '' + if total_amount > 0: + self.print_error("Notifying GUI %d tx"%(n_ok)) + if n_ok > 1: + parent.notify(_("{} new transactions: {}{}") + .format(n_ok, parent.format_amount_and_units(total_amount, is_diff=True), tokstring)) + else: + parent.notify(_("New transaction: {}{}").format(parent.format_amount_and_units(total_amount, is_diff=True), tokstring)) diff --git a/gui/qt/paytoedit.py b/gui/qt/paytoedit.py index 5c1bceda9..a53c76785 100644 --- a/gui/qt/paytoedit.py +++ b/gui/qt/paytoedit.py @@ -28,8 +28,9 @@ import re from decimal import Decimal -from electrum_zclassic import bitcoin +from electrum_zclassic import bitcoin, constants from electrum_zclassic.util import bfh +from electrum_zclassic.address import Address, ScriptOutput, AddressError from .qrtextedit import ScanQRTextEdit from .completion_text_edit import CompletionTextEdit @@ -60,7 +61,7 @@ def __init__(self, win): self.scan_f = win.pay_to_URI self.update_size() self.payto_address = None - + self.address_string_for_slp_check = '' self.previous_payto = '' def setFrozen(self, b): @@ -86,22 +87,7 @@ def parse_output(self, x): address = self.parse_address(x) return bitcoin.TYPE_ADDRESS, address except: - script = self.parse_script(x) - return bitcoin.TYPE_SCRIPT, script - - def parse_script(self, x): - from electrum_zclassic.transaction import opcodes, push_script - script = '' - for word in x.split(): - if word[0:3] == 'OP_': - assert word in opcodes.lookup - opcode_int = opcodes.lookup[word] - assert opcode_int < 256 # opcode is single-byte - script += bitcoin.int_to_hex(opcode_int) - else: - bfh(word) # to test it is hex data - script += push_script(word) - return script + return bitcoin.TYPE_SCRIPT, ScriptOutput.from_string(x) def parse_amount(self, x): if x.strip() == '!': @@ -112,9 +98,8 @@ def parse_amount(self, x): def parse_address(self, line): r = line.strip() m = re.match('^'+RE_ALIAS+'$', r) - address = str(m.group(2) if m else r) - assert bitcoin.is_address(address) - return address + address = m.group(2) if m else r + return Address.from_string(address) def check_text(self): self.errors = [] @@ -127,13 +112,25 @@ def check_text(self): self.payto_address = None if len(lines) == 1: data = lines[0] - if data.startswith("zclassic:"): - self.scan_f(data) + if ':' in data and '?' in data and len(data) > 35: + try: + self.scan_f(data) + except AddressError as e: + self.errors.append((0, str(e))) + else: + return + elif ':' not in data and len(data) > 35: + self.setText(Address.prefix_from_address_string(data) + ':' + data) return + try: + self.parse_address(data) + except Exception as e: + self.errors.append((0, str(e))) try: self.payto_address = self.parse_output(data) + self.address_string_for_slp_check = data except: - pass + self.address_string_for_slp_check = '' if self.payto_address: self.win.lock_amount(False) return @@ -142,8 +139,9 @@ def check_text(self): for i, line in enumerate(lines): try: _type, to_address, amount = self.parse_address_and_amount(line) - except: - self.errors.append((i, line.strip())) + except Exception as e: + if len(self.errors) < 1: + self.errors.append((i, line.strip())) continue outputs.append((_type, to_address, amount)) @@ -159,7 +157,13 @@ def check_text(self): if self.win.is_max: self.win.do_update_fee() else: - self.amount_edit.setAmount(total if outputs else None) + """ + The following line is commented out for SLP. + If this line is not commented out then the amount field will + always be reset to 0 when the address text is edited. + For SLP, an amount of 546 sat should be left in the amount_e field. + """ + #self.amount_edit.setAmount(total if outputs else None) self.win.lock_amount(total or len(lines)>1) def get_errors(self): @@ -202,7 +206,7 @@ def update_size(self): def qr_input(self): data = super(PayToEdit,self).qr_input() - if data.startswith("zclassic:"): + if data and (data.startswith("zclassic:") or data.startswith(constants.net.SLPADDR_PREFIX + ":")): self.scan_f(data) # TODO: update fee diff --git a/gui/qt/qrwindow.py b/gui/qt/qrwindow.py index d3d299037..2a5ee6a47 100644 --- a/gui/qt/qrwindow.py +++ b/gui/qt/qrwindow.py @@ -23,30 +23,18 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import platform - from PyQt5.QtCore import Qt -from PyQt5.QtGui import * -from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QWidget +from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget from electrum_zclassic_gui.qt.qrcodewidget import QRCodeWidget from electrum_zclassic.i18n import _ -if platform.system() == 'Windows': - MONOSPACE_FONT = 'Lucida Console' -elif platform.system() == 'Darwin': - MONOSPACE_FONT = 'Monaco' -else: - MONOSPACE_FONT = 'monospace' - -column_index = 4 - class QR_Window(QWidget): def __init__(self, win): QWidget.__init__(self) self.win = win - self.setWindowTitle('Electrum-Zclassic - '+_('Payment Request')) + self.setWindowTitle('Electrum-ZSLP - '+_('Payment Request')) self.setMinimumSize(800, 250) self.address = '' self.label = '' @@ -60,30 +48,30 @@ def __init__(self, win): vbox = QVBoxLayout() main_box.addLayout(vbox) + main_box.addStretch(1) - self.address_label = QLabel("") - #self.address_label.setFont(QFont(MONOSPACE_FONT)) + self.address_label = WWLabel() + self.address_label.setTextInteractionFlags(Qt.TextSelectableByMouse) vbox.addWidget(self.address_label) - self.label_label = QLabel("") - vbox.addWidget(self.label_label) + self.msg_label = WWLabel() + self.msg_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + vbox.addWidget(self.msg_label) - self.amount_label = QLabel("") + self.amount_label = WWLabel() + self.amount_label.setTextInteractionFlags(Qt.TextSelectableByMouse) vbox.addWidget(self.amount_label) vbox.addStretch(1) self.setLayout(main_box) - def set_content(self, address, amount, message, url): - address_text = "%s" % address if address else "" + def set_content(self, address_text, amount, message, url): self.address_label.setText(address_text) if amount: - amount = self.win.format_amount(amount) - amount_text = "%s %s " % (amount, self.win.base_unit()) + amount_text = '{} {}'.format(self.win.format_amount(amount), self.win.base_unit()) else: amount_text = '' self.amount_label.setText(amount_text) - label_text = "%s" % message if message else "" - self.label_label.setText(label_text) + self.msg_label.setText(message) self.qrw.setData(url) diff --git a/gui/qt/request_list.py b/gui/qt/request_list.py index bfdc52100..d7b66e2b6 100644 --- a/gui/qt/request_list.py +++ b/gui/qt/request_list.py @@ -23,6 +23,7 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from electrum_zclassic.address import Address from electrum_zclassic.i18n import _ from electrum_zclassic.util import format_time, age from electrum_zclassic.plugins import run_hook @@ -44,44 +45,77 @@ def __init__(self, parent): self.setSortingEnabled(True) self.setColumnWidth(0, 180) self.hideColumn(1) + self.wallet = parent.wallet def item_changed(self, item): if item is None: return if not item.isSelected(): return - addr = str(item.text(1)) - req = self.wallet.receive_requests[addr] + addr = item.data(0, Qt.UserRole) + req = self.wallet.receive_requests.get(addr) + if not req: + return expires = age(req['time'] + req['exp']) if req.get('exp') else _('Never') amount = req['amount'] - message = self.wallet.labels.get(addr, '') - self.parent.receive_address_e.setText(addr) + opr = req.get('op_return') or req.get('op_return_raw') + opr_is_raw = bool(req.get('op_return_raw')) + message = self.wallet.labels.get(addr.to_storage_string(), '') + self.parent.receive_address = addr + self.parent.receive_address_e.setText(addr.to_full_ui_string()) self.parent.receive_message_e.setText(message) - self.parent.receive_amount_e.setAmount(amount) + if req.get('token_id', None): + self.parent.toggle_cashaddr(2, True) + self.parent.receive_slp_token_type_label.setDisabled(False) + self.parent.receive_slp_amount_e.setDisabled(False) + self.parent.receive_slp_amount_label.setDisabled(False) + index = 0 + while index < self.parent.receive_token_type_combo.count(): + self.parent.receive_token_type_combo.setCurrentIndex(index) + if self.parent.receive_token_type_combo.currentData() == req['token_id']: + break + index += 1 + self.parent.receive_amount_e.setText("") + self.parent.receive_slp_amount_e.setText(str(amount)) + else: + self.parent.toggle_cashaddr(1, True) + self.parent.receive_token_type_combo.setCurrentIndex(0) + self.parent.receive_slp_token_type_label.setDisabled(True) + self.parent.receive_slp_amount_e.setDisabled(True) + self.parent.receive_slp_amount_label.setDisabled(True) + self.parent.receive_slp_amount_e.setText("") + self.parent.receive_amount_e.setAmount(amount) self.parent.expires_combo.hide() self.parent.expires_label.show() self.parent.expires_label.setText(expires) + self.parent.receive_opreturn_rawhex_cb.setChecked(opr_is_raw) + self.parent.receive_opreturn_e.setText(opr or '') self.parent.new_request_button.setEnabled(True) - def on_update(self): - self.wallet = self.parent.wallet + def chkVisible(self): # hide receive tab if no receive requests available - b = len(self.wallet.receive_requests) > 0 + b = hasattr(self, 'wallet') and len(self.wallet.receive_requests) > 0 and self.parent.isVisible() self.setVisible(b) self.parent.receive_requests_label.setVisible(b) if not b: self.parent.expires_label.hide() self.parent.expires_combo.show() + def on_update(self): + self.wallet = self.parent.wallet + self.chkVisible() + # update the receive address if necessary - current_address = self.parent.receive_address_e.text() + current_address_string = self.parent.receive_address_e.text() + current_address = Address.from_string(current_address_string) domain = self.wallet.get_receiving_addresses() addr = self.wallet.get_unused_address() if not current_address in domain and addr: self.parent.set_receive_address(addr) - self.parent.new_request_button.setEnabled(addr != current_address) # clear the list and fill it again + item = self.currentItem() + prev_sel = item.data(0, Qt.UserRole) if item else None self.clear() for req in self.wallet.get_sorted_requests(self.config): address = req['address'] @@ -95,21 +129,31 @@ def on_update(self): 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,'')]) + token_id = req.get('token_id', None) + if token_id: + amount_str = str(amount) if amount else "" + item = QTreeWidgetItem([date, address.to_ui_string(), '', message, + amount_str, _(pr_tooltips.get(status,''))]) + else: + amount_str = self.parent.format_amount(amount) if amount else "" + item = QTreeWidgetItem([date, address.to_ui_string(), '', message, + amount_str, pr_tooltips.get(status,'')]) + item.setData(0, Qt.UserRole, address) if signature is not None: item.setIcon(2, self.icon_cache.get(":icons/seal.png")) item.setToolTip(2, 'signed by '+ requestor) if status is not PR_UNKNOWN: item.setIcon(6, self.icon_cache.get(pr_icons.get(status))) self.addTopLevelItem(item) - + if prev_sel == address: + self.setCurrentItem(item) def create_menu(self, position): item = self.itemAt(position) if not item: return - addr = str(item.text(1)) + self.setCurrentItem(item) # sometimes it's not the current item. + addr = item.data(0, Qt.UserRole) req = self.wallet.receive_requests[addr] column = self.currentColumn() column_title = self.headerItem().text(column) diff --git a/gui/qt/seed_dialog.py b/gui/qt/seed_dialog.py index ddba92ada..6e65ecbab 100644 --- a/gui/qt/seed_dialog.py +++ b/gui/qt/seed_dialog.py @@ -44,6 +44,7 @@ def seed_warning_msg(seed): "
  • " + _("Never disclose your seed.") + "
  • ", "
  • " + _("Never type it on a website.") + "
  • ", "
  • " + _("Do not store it electronically.") + "
  • ", + "
  • " + _("Do not use this seed on a non-ZSLP wallet as it may result in lost tokens.") + "
  • ", "
" ]).format(len(seed.split())) @@ -60,7 +61,8 @@ def seed_options(self): cb_ext = QCheckBox(_('Extend this seed with custom words')) cb_ext.setChecked(self.is_ext) vbox.addWidget(cb_ext) - if 'bip39' in self.options: + ''' + if 'bip39' in self.options: # SLP hack -- never allow user to uncheck this def f(b): self.is_seed = (lambda x: bool(x)) if b else self.saved_is_seed self.is_bip39 = b @@ -76,20 +78,25 @@ def f(b): else: msg = '' self.seed_warning.setText(msg) - cb_bip39 = QCheckBox(_('BIP39 seed')) - cb_bip39.toggled.connect(f) - cb_bip39.setChecked(self.is_bip39) - vbox.addWidget(cb_bip39) + #cb_bip39 = QCheckBox(_('BIP39 seed')) + #cb_bip39.toggled.connect(f) + #cb_bip39.setChecked(self.is_bip39) + #vbox.addWidget(cb_bip39) + ''' vbox.addLayout(Buttons(OkButton(dialog))) + dialog.setWindowModality(Qt.WindowModal) if not dialog.exec_(): return None self.is_ext = cb_ext.isChecked() if 'ext' in self.options else False - self.is_bip39 = cb_bip39.isChecked() if 'bip39' in self.options else False + self.is_bip39 = True #Hard coded for SLP #cb_bip39.isChecked() if 'bip39' in self.options else False def __init__(self, seed=None, title=None, icon=True, msg=None, options=None, is_seed=None, passphrase=None, parent=None): QVBoxLayout.__init__(self) self.parent = parent self.options = options + self.is_bip39 = True # Hard-coded for SLP + self.is_bip39_145 = False # Hard-coded for SLP + self.is_seed = is_seed = lambda x: bool(x) # Hard-coded for SLP if title: self.addWidget(WWLabel(title)) self.seed_e = CompletionTextEdit() @@ -146,27 +153,39 @@ def get_seed(self): text = self.seed_e.text() return ' '.join(text.split()) - def on_edit(self): + @staticmethod + def _slp_custom_chk(s, is_seed): from electrum_zclassic.bitcoin import seed_type - s = self.get_seed() - b = self.is_seed(s) - if not self.is_bip39: - t = seed_type(s) - label = _('Seed Type') + ': ' + t if t else '' + from electrum_zclassic.keystore import bip39_is_checksum_valid + is_checksum, is_wordlist = bip39_is_checksum_valid(s) + if not is_seed: + return '', 'no seed', False, False, False + if not is_wordlist: + return '', 'unknown wordlist', is_checksum, is_wordlist, False else: - from electrum_zclassic.keystore import bip39_is_checksum_valid - is_checksum, is_wordlist = bip39_is_checksum_valid(s) - status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist' - label = 'BIP39' + ' (%s)'%status - self.seed_type_label.setText(label) - self.parent.next_button.setEnabled(b) + if is_checksum: + return 'BIP39', 'checksum: ok', is_checksum, is_wordlist, False + else: + try: + st = seed_type(s) + if st in ('old', 'standard'): + return 'Electrum Zclassic regular seed', 'not ZSLP', is_checksum, is_wordlist, True + except: + # seed_type may raise i think + pass + return 'BIP39', 'checksum: failed', is_checksum, is_wordlist, False - # to account for bip39 seeds - for word in self.get_seed().split(" ")[:-1]: - if word not in self.wordlist: - self.seed_e.disable_suggestions() - return - self.seed_e.enable_suggestions() + def on_edit(self): + # NOTE: this has been heavily modified for SLP -- it completely + # does not support non-BIP39 seeds (Electron Cash standard + old seeds) + # When merging SLP into mainline in the future -- this function + # will need to be resurrected with the original Electron Cash logic + s = self.get_seed() + b = self.is_seed(s) # this is just a test for non-empty string on SLP + label, status, is_checksum, is_wordlist, is_electrum_seed = self._slp_custom_chk(s, b) + label_text = label + (' ' if label else '') + ('(%s)'%status) + self.seed_type_label.setText(label_text) + self.parent.next_button.setEnabled(is_checksum) # only allow "Next" button if checksum is good. Note this is different behavior than Electron Cash and Electrum which allows bad checksum biip39 class KeysLayout(QVBoxLayout): def __init__(self, parent=None, title=None, is_valid=None, allow_multi=False): @@ -189,7 +208,7 @@ def on_edit(self): class SeedDialog(WindowModalDialog): def __init__(self, parent, seed, passphrase): - WindowModalDialog.__init__(self, parent, ('Electrum-Zclassic - ' + _('Seed'))) + WindowModalDialog.__init__(self, parent, ('Electrum ZSLP - ' + _('Seed'))) self.setMinimumWidth(400) vbox = QVBoxLayout(self) title = _("Your wallet generation seed is:") diff --git a/gui/qt/slp_add_token_dialog.py b/gui/qt/slp_add_token_dialog.py new file mode 100644 index 000000000..48dd191d5 --- /dev/null +++ b/gui/qt/slp_add_token_dialog.py @@ -0,0 +1,411 @@ + +import copy +import datetime +from functools import partial +import json +import threading +import html +import traceback + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electrum_zclassic import constants +from electrum_zclassic.address import Address, PublicKey +from electrum_zclassic.bitcoin import base_encode +from electrum_zclassic.i18n import _ +from electrum_zclassic.plugins import run_hook + +from electrum_zclassic.util import bfh +from .util import * + +from electrum_zclassic.util import format_satoshis_nofloat, finalization_print_error +from electrum_zclassic.transaction import Transaction +from electrum_zclassic.slp import SlpMessage, SlpUnsupportedSlpTokenType, SlpInvalidOutputMessage + +dialogs = [] # Otherwise python randomly garbage collects the dialogs... + +class SlpAddTokenDialog(QDialog, MessageBoxMixin): + + got_network_response_sig = pyqtSignal() + + @pyqtSlot() + def got_network_response_slot(self): + self.download_finished = True + + resp = self.json_response + if resp.get('error'): + return self.fail_genesis_info("Download error!\n%r"%(resp['error'].get('message'))) + raw = resp.get('result') + + tx = Transaction(raw) + self.handle_genesis_tx(tx) + + def __init__(self, main_window, token_id_hex=None, token_name=None, allow_overwrite=False, add_callback=None): + # We want to be a top-level window + QDialog.__init__(self, parent=None) + from .main_window import ElectrumWindow + + assert isinstance(main_window, ElectrumWindow) + main_window._slp_dialogs.add(self) + finalization_print_error(self) # Track object lifecycle + + self.provided_token_name = token_name + self.allow_overwrite = allow_overwrite + self.add_callback = add_callback + self.main_window = main_window + self.wallet = main_window.wallet + self.network = main_window.network + self.app = main_window.app + + if self.main_window.gui_object.warn_if_no_network(self.main_window): + return + + if self.provided_token_name: + self.setWindowTitle(_("ZSLP Token Details")) + else: + self.setWindowTitle(_("Add ZSLP Token")) + + vbox = QVBoxLayout() + self.setLayout(vbox) + + vbox.addWidget(QLabel(_('Token ID:'))) + + + self.token_id_e = ButtonsLineEdit() + if token_id_hex is not None: + self.token_id_e.addCopyButton(self.app) + vbox.addWidget(self.token_id_e) + + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + hbox.addWidget(QLabel(_('Genesis transaction information:'))) + + self.get_info_button = b = QPushButton(_("Download")) + b.clicked.connect(self.download_info) + hbox.addWidget(b) + + self.load_tx_menu_button = b = QPushButton(_("Load...")) + menu = QMenu() + menu.addAction(_("&From file"), self.do_process_from_file) + menu.addAction(_("&From text"), self.do_process_from_text) + menu.addAction(_("&From QR code"), self.read_tx_from_qrcode) + b.setMenu(menu) + hbox.addWidget(b) + + self.view_tx_button = b = QPushButton(_("View Tx")) + b.clicked.connect(self.view_tx) + b.setDisabled(True) + hbox.addWidget(b) + + hbox.addStretch(1) + + self.token_info_e = QTextBrowser() + self.token_info_e.setOpenExternalLinks(True) + self.token_info_e.setMinimumHeight(100) + vbox.addWidget(self.token_info_e) + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + warnpm = QIcon(":icons/warning.png").pixmap(20,20) + + l = QLabel(); l.setPixmap(warnpm) + hbox.addWidget(l) + hbox.addWidget(QLabel(_('Avoid counterfeits—carefully compare the token ID with a trusted source.'))) + l = QLabel(); l.setPixmap(warnpm) + hbox.addWidget(l) + + if self.provided_token_name is None: + namelabel = QLabel(_('To use tokens with this ID, assign it a name.')) + namelabel.setAlignment(Qt.AlignRight) + vbox.addWidget(namelabel) + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + self.cancel_button = b = QPushButton(_("Cancel")) + self.cancel_button.setAutoDefault(False) + self.cancel_button.setDefault(False) + b.clicked.connect(self.close) + b.setDefault(True) + hbox.addWidget(self.cancel_button) + + hbox.addStretch(1) + + hbox.addWidget(QLabel(_('Name in wallet:'))) + self.token_name_e = QLineEdit() + self.token_name_e.setFixedWidth(200) + if self.provided_token_name is not None: + self.token_name_e.setText(self.provided_token_name) + hbox.addWidget(self.token_name_e) + + + self.add_button = b = QPushButton(_("Add") if self.provided_token_name is None else _("Change")) + b.clicked.connect(self.add_token) + self.add_button.setAutoDefault(True) + self.add_button.setDefault(True) + b.setDisabled(True) + hbox.addWidget(self.add_button) + + if token_id_hex is not None: + self.token_id_e.setText(token_id_hex) + self.download_info() + + self.got_network_response_sig.connect(self.got_network_response_slot, Qt.QueuedConnection) + self.update() + + dialogs.append(self) + self.show() + + self.token_name_e.setFocus() + + def closeEvent(self, event): + super().closeEvent(event) + if event.isAccepted(): + try: self.got_network_response_sig.disconnect() # prevent future asynch responses from doing anything if we are closed. + except TypeError: pass # not connected + def remove_self(): + try: dialogs.remove(self) + except ValueError: pass # wasn't in list. + QTimer.singleShot(0, remove_self) # need to do this some time later. Doing it from within this function causes crashes. See #35 + + def download_info(self): + txid = self.token_id_e.text() + + self.token_id_e.setReadOnly(True) + self.token_info_e.setText("Downloading...") + self.get_info_button.setDisabled(True) + self.load_tx_menu_button.setDisabled(True) + self.view_tx_button.setDisabled(True) + + try: + tx = self.wallet.transactions[txid] + except KeyError: + def callback(response): + self.json_response = response + self.got_network_response_sig.emit() + + requests = [ ('blockchain.transaction.get', [txid]), ] + self.network.send(requests, callback) + else: + self.handle_genesis_tx(tx) + + def handle_genesis_tx(self, tx): + self.token_id_e.setReadOnly(True) + self.get_info_button.setDisabled(True) + self.load_tx_menu_button.setDisabled(True) + + self.newtoken_genesis_tx = tx + self.view_tx_button.setDisabled(False) + + txid = tx.txid() + token_id = self.token_id_e.text().strip().lower() # tolerate user to paste of uppercase hex with whitespace around it + if token_id and txid != token_id: + return self.fail_genesis_info(_('TXID does not match token ID!')) + self.newtoken_token_id = txid + self.token_id_e.setText(self.newtoken_token_id) + + try: + slpMsg = SlpMessage.parseSlpOutputScript(tx.outputs()[0][1]) + except SlpUnsupportedSlpTokenType as e: + return self.fail_genesis_info(_("Unsupported ZSLP token version/type - %r.")%(e.args[0],)) + except SlpInvalidOutputMessage as e: + return self.fail_genesis_info(_("This transaction does not contain a valid ZSLP message.\nReason: %r.")%(e.args,)) + if slpMsg.transaction_type != 'GENESIS': + return self.fail_genesis_info(_("This is an ZSLP transaction, however it is not a genesis transaction.")) + + + f_fieldnames = QTextCharFormat() + f_fieldnames.setFont(QFont(MONOSPACE_FONT)) + f_normal = QTextCharFormat() + + self.token_info_e.clear() + cursor = self.token_info_e.textCursor() + + fields = [ + ('ticker', _('ticker'), 'utf8', None), + ('token_name', _('name'), 'utf8', None), + ('token_doc_url', _('doc url'), 'ascii', 'html'), + ('token_doc_hash', _('doc hash'), 'hex', None), + ] + + cursor.insertText(_('Issuer-declared strings in genesis:')) + cursor.insertBlock() + for k,n,e,f in fields: + data = slpMsg.op_return_fields[k] + if e == 'hex': + friendlystring = None + else: + # Attempt to make a friendly string, or fail to hex + try: + # Ascii only + friendlystring = data.decode(e) # raises UnicodeDecodeError with bytes > 127. + + # Count ugly characters (that need escaping in python strings' repr()) + uglies = 0 + for b in data: + if b < 0x20 or b == 0x7f: + uglies += 1 + # Less than half of characters may be ugly. + if 2*uglies >= len(data): + friendlystring = None + except UnicodeDecodeError: + friendlystring = None + + if len(data) == 0: + showstr = '(empty)' + f=None + elif friendlystring is None: + showstr = data.hex() + f=None + else: + showstr = repr(friendlystring) + + cursor.insertText(' '*(10 - len(n)) + n + ': ', f_fieldnames) + if f == 'html': + enc_url = html.escape(friendlystring) + enc_text = html.escape(showstr) + cursor.insertHtml('%s'%(enc_url, enc_url, enc_text)) + else: + cursor.insertText(showstr, f_normal) + cursor.insertBlock() + + # try to auto-fill name input + name_ext = '' + for key in [ 'ticker', 'token_name' ]: + if self.token_name_e.text() == '' and slpMsg.op_return_fields[key] != b'': + base_name = slpMsg.op_return_fields[key].decode("utf-8") + for k,v in self.wallet.token_types.copy().items(): + if v['name'] == base_name: + name_ext = "-" + self.token_id_e.text()[:3] + self.token_name_e.setText(base_name + name_ext) + break + + self.newtoken_decimals = slpMsg.op_return_fields['decimals'] + cursor.insertText(_('Decimals:') + ' ' + str(self.newtoken_decimals)) + cursor.insertBlock() + + numtokens = format_satoshis_nofloat(slpMsg.op_return_fields['initial_token_mint_quantity'], + num_zeros=self.newtoken_decimals, + decimal_point=self.newtoken_decimals,) + mbv = slpMsg.op_return_fields['mint_baton_vout'] + if mbv is None or mbv > len(tx.outputs()): + issuance_type = _('Initial issuance type: fixed supply') + else: + issuance_type = _('Initial issuance type: flexible supply') + + cursor.insertText(_('Initial issuance:') + ' ' + numtokens) + cursor.insertBlock() + cursor.insertText(issuance_type) + + #cursor.insertBlock() + + self.newtoken_genesis_message = slpMsg + + self.add_button.setDisabled(False) + self.activateWindow() + self.raise_() + + def fail_genesis_info(self, message): + self.token_info_e.setText(message) + self.add_button.setDisabled(True) + self.token_id_e.setReadOnly(False) + self.get_info_button.setDisabled(False) + self.load_tx_menu_button.setDisabled(False) + + def view_tx(self,): + self.main_window.show_transaction(self.newtoken_genesis_tx) + + def add_token(self): + # Make sure to throw an error dialog if name exists, hash exists, ... + token_name = self.token_name_e.text() + ow = (self.provided_token_name is not None) or self.allow_overwrite + token_class = 'SLP%d'%(self.newtoken_genesis_message.token_type,) + ret = self.main_window.add_token_type(token_class, self.newtoken_token_id, token_name, self.newtoken_decimals, + error_callback = self.show_error, allow_overwrite=ow) + if ret: + self.add_button.setDisabled(True) + self.close() + if self.add_callback: + self.add_callback() + else: + # couldn't add for some reason... + pass + + + ### Ripped and modified from main_window.py --- load transaction manually! + + def user_loaded_transaction(self, tx): + self.handle_genesis_tx(tx) + + def tx_from_text(self, txt): + from electrum_zclassic.transaction import tx_from_str + try: + txt_tx = tx_from_str(txt) + tx = Transaction(txt_tx, sign_schnorr=self.wallet.is_schnorr_enabled()) + tx.deserialize() + return tx + except: + traceback.print_exc(file=sys.stdout) + self.show_critical(_("Electron Cash was unable to parse your transaction")) + return + + def read_tx_from_qrcode(self): + from electrum_zclassic import qrscanner + try: + data = qrscanner.scan_barcode(self.main_window.config.get_video_device()) + except BaseException as e: + self.show_error(str(e)) + return + if not data: + return + # if the user scanned a bitcoincash URI + if data.lower().startswith('zclassic:') or data.lower().startswith(constants.net.SLPADDR_PREFIX + ':'): + self.show_error(_("This is not a transaction.")) + return + # else if the user scanned an offline signed tx + data = bh2u(bitcoin.base_decode(data, length=None, base=43)) + tx = self.tx_from_text(data) + if not tx: + return + self.user_loaded_transaction(tx) + + def read_tx_from_file(self): + fileName, __ = QFileDialog.getOpenFileName(self,_("Select your transaction file"), '', "*.txn") + if not fileName: + return + try: + with open(fileName, "r") as f: + file_content = f.read() + except (ValueError, IOError, os.error) as reason: + self.show_critical(_("Electron Cash was unable to open your transaction file") + "\n" + str(reason), title=_("Unable to read file or no transaction found")) + return + file_content = file_content.strip() + tx = self.tx_from_text(file_content) + # Older saved transaction do not include this key. + return tx + + def do_process_from_text(self): + from electrum_zclassic.transaction import SerializationError + text = text_dialog(self, _('Input raw transaction'), _("Transaction:"), _("Load transaction")) + if not text: + return + try: + tx = self.tx_from_text(text) + if tx: + self.user_loaded_transaction(tx) + except SerializationError as e: + self.show_critical(_("Electron Cash was unable to deserialize the transaction:") + "\n" + str(e)) + + def do_process_from_file(self): + from electrum_zclassic.transaction import SerializationError + try: + tx = self.read_tx_from_file() + if tx: + self.user_loaded_transaction(tx) + except SerializationError as e: + self.show_critical(_("Electron Cash was unable to deserialize the transaction:") + "\n" + str(e)) diff --git a/gui/qt/slp_burn_token_dialog.py b/gui/qt/slp_burn_token_dialog.py new file mode 100644 index 000000000..98f3ecc4b --- /dev/null +++ b/gui/qt/slp_burn_token_dialog.py @@ -0,0 +1,284 @@ +import copy +import datetime +from functools import partial +import json +import threading +import sys, traceback + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electrum_zclassic.address import Address, PublicKey +from electrum_zclassic.bitcoin import base_encode, TYPE_ADDRESS +from electrum_zclassic.i18n import _ +from electrum_zclassic.plugins import run_hook + +from .util import * + +from electrum_zclassic.util import bfh, format_satoshis_nofloat, format_satoshis_plain_nofloat, NotEnoughFunds, ExcessiveFee #, finalization_print_error +from electrum_zclassic.transaction import Transaction +from electrum_zclassic.slp import SlpMessage, SlpNoMintingBatonFound, SlpUnsupportedSlpTokenType, SlpInvalidOutputMessage, buildSendOpReturnOutput_V1 + +from .amountedit import SLPAmountEdit +from .transaction_dialog import show_transaction + +dialogs = [] + +class SlpBurnTokenDialog(QDialog, MessageBoxMixin): + + def __init__(self, main_window, token_id_hex, token_name): + QDialog.__init__(self, parent=None) + from .main_window import ElectrumWindow + + assert isinstance(main_window, ElectrumWindow) + main_window._slp_dialogs.add(self) + # finalization_print_error(self) # Track object lifecycle + + self.main_window = main_window + self.wallet = main_window.wallet + self.network = main_window.network + self.app = main_window.app + + if self.main_window.gui_object.warn_if_no_network(self.main_window): + return + + self.baton_txo = None + try: + self.baton_txo = self.main_window.wallet.get_slp_token_baton(token_id_hex) + except SlpNoMintingBatonFound: + pass + + self.setWindowTitle(_("Burn Tokens")) + + vbox = QVBoxLayout() + self.setLayout(vbox) + + grid = QGridLayout() + grid.setColumnStretch(1, 1) + vbox.addLayout(grid) + row = 0 + + grid.addWidget(QLabel(_('Name:')), row, 0) + + self.token_name = QLineEdit() + self.token_name.setFixedWidth(490) + self.token_name.setText(token_name) + self.token_name.setDisabled(True) + grid.addWidget(self.token_name, row, 1) + row += 1 + + msg = _('Unique identifier for the token.') + grid.addWidget(HelpLabel(_('Token ID:'), msg), row, 0) + + self.token_id_e = QLineEdit() + self.token_id_e.setFixedWidth(490) + self.token_id_e.setText(token_id_hex) + self.token_id_e.setDisabled(True) + grid.addWidget(self.token_id_e, row, 1) + row += 1 + + msg = _('The number of decimal places used in the token quantity.') + grid.addWidget(HelpLabel(_('Decimals:'), msg), row, 0) + self.token_dec = QDoubleSpinBox() + decimals = self.main_window.wallet.token_types.get(token_id_hex)['decimals'] + self.token_dec.setRange(0, 9) + self.token_dec.setValue(decimals) + self.token_dec.setDecimals(0) + self.token_dec.setFixedWidth(50) + self.token_dec.setDisabled(True) + grid.addWidget(self.token_dec, row, 1) + row += 1 + + hbox = QHBoxLayout() + msg = _('The number of tokens to be destroyed for this token.') + grid.addWidget(HelpLabel(_('Burn Amount:'), msg), row, 0) + name = self.main_window.wallet.token_types.get(token_id_hex)['name'] + self.token_qty_e = SLPAmountEdit(name, int(decimals)) + self.token_qty_e.setFixedWidth(200) + #self.token_qty_e.textChanged.connect(self.check_token_qty) + hbox.addWidget(self.token_qty_e) + + self.max_button = EnterButton(_("Max"), self.burn_max) + self.max_button.setFixedWidth(140) + #self.max_button.setCheckable(True) + hbox.addWidget(self.max_button) + hbox.addStretch(1) + grid.addLayout(hbox, row, 1) + row += 1 + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + self.token_burn_baton_cb = cb = QCheckBox(_("Burn Minting Baton")) + self.token_burn_baton_cb.setChecked(False) + self.token_burn_baton_cb.setDisabled(True) + grid.addWidget(self.token_burn_baton_cb, row, 0) + if self.baton_txo != None: + self.token_burn_baton_cb.setDisabled(False) + + self.token_burn_invalid_cb = cb = QCheckBox(_("Burn invalid ZSLP transactions for this token")) + self.token_burn_invalid_cb.setChecked(True) + grid.addWidget(self.token_burn_invalid_cb, row, 1) + row += 1 + + self.cancel_button = b = QPushButton(_("Cancel")) + self.cancel_button.setAutoDefault(True) + self.cancel_button.setDefault(True) + b.clicked.connect(self.close) + b.setDefault(True) + hbox.addWidget(self.cancel_button) + + hbox.addStretch(1) + + self.preview_button = EnterButton(_("Preview"), self.do_preview) + self.burn_button = b = QPushButton(_("Burn Tokens")) + b.clicked.connect(self.burn_token) + self.burn_button.setAutoDefault(False) + self.burn_button.setDefault(False) + hbox.addWidget(self.preview_button) + hbox.addWidget(self.burn_button) + + dialogs.append(self) + self.show() + self.token_qty_e.setFocus() + + def burn_max(self): + #self.max_button.setChecked(True) + self.token_qty_e.setAmount(self.wallet.get_slp_token_balance(self.token_id_e.text(), self.main_window.config)[3]) + + def do_preview(self): + self.burn_token(preview = True) + + def burn_token(self, preview=False): + unfrozen_token_qty = self.wallet.get_slp_token_balance(self.token_id_e.text(), self.main_window.config)[3] + burn_amt = self.token_qty_e.get_amount() + if burn_amt == None or burn_amt == 0: + self.show_message(_("Invalid token quantity entered.")) + return + if burn_amt > unfrozen_token_qty: + self.show_message(_("Cannot burn more tokens than the unfrozen amount available.")) + return + + reply = QMessageBox.question(self, "Continue?", "Destroy " + self.token_qty_e.text() + " " + self.token_name.text() + " tokens?", QMessageBox.Yes, QMessageBox.No) + if reply == QMessageBox.Yes: + pass + else: + return + + outputs = [] + slp_coins = self.wallet.get_slp_utxos( + self.token_id_e.text(), + domain=None, exclude_frozen=True, confirmed_only=self.main_window.config.get('confirmed_only', False), + slp_include_invalid=self.token_burn_invalid_cb.isChecked(), slp_include_baton=self.token_burn_baton_cb.isChecked()) + + addr = self.wallet.get_unused_address(frozen_ok=False) + if addr is None: + if not self.wallet.is_deterministic(): + addr = self.wallet.get_receiving_address() + else: + addr = self.wallet.create_new_address(True) + + try: + selected_slp_coins = [] + if burn_amt < unfrozen_token_qty: + total_amt_added = 0 + for coin in slp_coins: + if coin['token_value'] != "MINT_BATON" and coin['token_validation_state'] == 1: + if coin['token_value'] >= burn_amt: + selected_slp_coins.append(coin) + total_amt_added+=coin['token_value'] + break + if total_amt_added < burn_amt: + for coin in slp_coins: + if coin['token_value'] != "MINT_BATON" and coin['token_validation_state'] == 1: + if total_amt_added < burn_amt: + selected_slp_coins.append(coin) + total_amt_added+=coin['token_value'] + if total_amt_added > burn_amt: + token_type = self.wallet.token_types[self.token_id_e.text()]['class'] + slp_op_return_msg = buildSendOpReturnOutput_V1(self.token_id_e.text(), [total_amt_added - burn_amt], token_type) + outputs.append(slp_op_return_msg) + outputs.append((TYPE_ADDRESS, addr, 546)) + else: + for coin in slp_coins: + if coin['token_value'] != "MINT_BATON" and coin['token_validation_state'] == 1: + selected_slp_coins.append(coin) + + except OPReturnTooLarge: + self.show_message(_("Optional string text causiing OP_RETURN greater than 223 bytes.")) + return + except Exception as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + + if self.token_burn_baton_cb.isChecked(): + for coin in slp_coins: + if coin['token_value'] == "MINT_BATON" and coin['token_validation_state'] == 1: + selected_slp_coins.append(coin) + + if self.token_burn_invalid_cb.isChecked(): + for coin in slp_coins: + if coin['token_validation_state'] != 1: + selected_slp_coins.append(coin) + + bch_change = sum(c['value'] for c in selected_slp_coins) + outputs.append((TYPE_ADDRESS, addr, bch_change)) + + coins = self.main_window.get_coins() + fixed_fee = None + + try: + tx = self.main_window.wallet.make_unsigned_transaction(coins, outputs, self.main_window.config, fixed_fee, None, mandatory_coins=selected_slp_coins) + except NotEnoughFunds: + self.show_message(_("Insufficient funds")) + return + except ExcessiveFee: + self.show_message(_("Your fee is too high. Max is 50 sat/byte.")) + return + except BaseException as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + + if preview: + show_transaction(tx, self.main_window, None, False, self, slp_coins_to_burn=selected_slp_coins) + return + + msg = [] + + if self.main_window.wallet.has_password(): + msg.append("") + msg.append(_("Enter your password to proceed")) + password = self.main_window.password_dialog('\n'.join(msg)) + if not password: + return + else: + password = None + + tx_desc = None + + def sign_done(success): + if success: + if not tx.is_complete(): + show_transaction(tx, self.main_window, None, False, self) + self.main_window.do_clear() + else: + self.main_window.broadcast_transaction(tx, tx_desc) + + self.main_window.sign_tx_with_password(tx, sign_done, password, slp_coins_to_burn=selected_slp_coins) + + self.burn_button.setDisabled(True) + self.close() + + def closeEvent(self, event): + super().closeEvent(event) + event.accept() + def remove_self(): + try: dialogs.remove(self) + except ValueError: pass # wasn't in list. + QTimer.singleShot(0, remove_self) # need to do this some time later. Doing it from within this function causes crashes. See #35 + + def update(self): + return diff --git a/gui/qt/slp_create_token_genesis_dialog.py b/gui/qt/slp_create_token_genesis_dialog.py new file mode 100644 index 000000000..2962a0acd --- /dev/null +++ b/gui/qt/slp_create_token_genesis_dialog.py @@ -0,0 +1,637 @@ +import copy +import datetime +from functools import partial +import json +import threading +import sys, traceback + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electrum_zclassic.address import Address, PublicKey +from electrum_zclassic.bitcoin import base_encode, TYPE_ADDRESS +from electrum_zclassic.i18n import _ +from electrum_zclassic.plugins import run_hook + +from .util import * + +from electrum_zclassic.util import bfh, format_satoshis_nofloat, format_satoshis_plain_nofloat, NotEnoughFunds, ExcessiveFee, finalization_print_error +from electrum_zclassic.transaction import Transaction +from electrum_zclassic.slp import SlpMessage, SlpUnsupportedSlpTokenType, SlpInvalidOutputMessage, buildGenesisOpReturnOutput_V1, buildSendOpReturnOutput_V1 +from electrum_zclassic.slp_checker import SlpTransactionChecker +from .amountedit import SLPAmountEdit +from .transaction_dialog import show_transaction + +from .bfp_upload_file_dialog import BitcoinFilesUploadDialog + +from electrum_zclassic import constants + +dialogs = [] # Otherwise python randomly garbage collects the dialogs... + +def get_nft_parent_coin(nft_parent_id, main_window): + # get nft_parent's coins + nft_parent_coins = main_window.wallet.get_slp_utxos( + nft_parent_id, + domain=None, + exclude_frozen=True, + confirmed_only=main_window.config.get('confirmed_only', False), + slp_include_invalid=False, + slp_include_baton=False + ) + + # determine if parent coin has qty 1 to burn + selected_coin = None + for coin in nft_parent_coins: + if coin['token_value'] == 1: + selected_coin = coin + break + + return selected_coin + +class SlpCreateTokenGenesisDialog(QDialog, MessageBoxMixin): + + def __init__(self, main_window, *, nft_parent_id=None): + #self.provided_token_name = token_name + # We want to be a top-level window + QDialog.__init__(self, parent=None) + from .main_window import ElectrumWindow + + assert isinstance(main_window, ElectrumWindow) + main_window._slp_dialogs.add(self) + finalization_print_error(self) # track object lifecycle + + self.main_window = main_window + self.wallet = main_window.wallet + self.config = main_window.config + self.network = main_window.network + self.app = main_window.app + self.nft_parent_id = nft_parent_id + if nft_parent_id != None: + self.token_type = 65 + else: + self.token_type = 1 + + if self.main_window.gui_object.warn_if_no_network(self.main_window): + return + + self.setWindowTitle(_("Create a New Token")) + + vbox = QVBoxLayout() + self.setLayout(vbox) + + grid = QGridLayout() + grid.setColumnStretch(1, 1) + vbox.addLayout(grid) + row = 0 + + msg = _('An optional name string embedded in the token genesis transaction.') + grid.addWidget(HelpLabel(_('Token Name (optional):'), msg), row, 0) + self.token_name_e = QLineEdit() + grid.addWidget(self.token_name_e, row, 1) + row += 1 + + msg = _('An optional ticker symbol string embedded into the token genesis transaction.') + grid.addWidget(HelpLabel(_('Ticker Symbol (optional):'), msg), row, 0) + self.token_ticker_e = QLineEdit() + self.token_ticker_e.setFixedWidth(110) + self.token_ticker_e.textChanged.connect(self.upd_token) + grid.addWidget(self.token_ticker_e, row, 1) + row += 1 + + msg = _('An optional URL string embedded into the token genesis transaction.') + grid.addWidget(HelpLabel(_('Document URL (optional):'), msg), row, 0) + self.token_url_e = QLineEdit() + self.token_url_e.setFixedWidth(560) + self.token_url_e.textChanged.connect(self.upd_token) + grid.addWidget(self.token_url_e, row, 1) + row += 1 + + msg = _('An optional hash hexidecimal bytes embedded into the token genesis transaction for hashing') \ + + 'the document file contents at the URL provided above.' + grid.addWidget(HelpLabel(_('Document Hash (optional):'), msg), row, 0) + self.token_dochash_e = QLineEdit() + self.token_dochash_e.setInputMask("HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH") + self.token_dochash_e.setFixedWidth(560) + self.token_dochash_e.textChanged.connect(self.upd_token) + grid.addWidget(self.token_dochash_e, row, 1) + row += 1 + + msg = _('Sets the number of decimals of divisibility for this token (embedded into genesis).') \ + + '\n\n' \ + + _('Each 1 token is divisible into 10^(decimals) base units, and internally in the protocol') \ + + _('the token amounts are represented as 64-bit integers measured in these base units.') + grid.addWidget(HelpLabel(_('Decimal Places:'), msg), row, 0) + self.token_ds_e = QDoubleSpinBox() + self.token_ds_e.setRange(0, 9) + self.token_ds_e.setDecimals(0) + self.token_ds_e.setFixedWidth(50) + self.token_ds_e.valueChanged.connect(self.upd_token) + grid.addWidget(self.token_ds_e, row, 1) + row += 1 + + msg = _('The number of tokens created during token genesis transaction,') \ + + _('send to the receiver address provided below.') + if nft_parent_id == None: + self.token_qty_label = HelpLabel(_('Token Quantity:'), msg) + else: + self.token_qty_label = HelpLabel(_('Number of Child NFTs:'), msg) + grid.addWidget(self.token_qty_label, row, 0) + self.token_qty_e = SLPAmountEdit('', 0) + self.token_qty_e.setFixedWidth(200) + self.token_qty_e.textChanged.connect(self.check_token_qty) + grid.addWidget(self.token_qty_e, row, 1) + row += 1 + + msg = _('The \'simpleledger:\' formatted bitcoin address for the genesis receiver of all genesis tokens.') + grid.addWidget(HelpLabel(_('Token Receiver Address:'), msg), row, 0) + self.token_pay_to_e = ButtonsLineEdit() + self.token_pay_to_e.setFixedWidth(560) + grid.addWidget(self.token_pay_to_e, row, 1) + try: + slpAddr = self.wallet.get_unused_address().to_slpaddr() + self.token_pay_to_e.setText(Address.prefix_from_address_string(slpAddr) + ":" + slpAddr) + except: + pass + row += 1 + + self.token_fixed_supply_cb = cb = QCheckBox(_('Fixed Supply')) + self.token_fixed_supply_cb.setChecked(True) + grid.addWidget(self.token_fixed_supply_cb, row, 1) + cb.clicked.connect(self.show_mint_baton_address) + row += 1 + + self.token_nft_parent_cb = cb = QCheckBox(_('Is NFT Parent?')) + self.token_nft_parent_cb.setChecked(False) + grid.addWidget(self.token_nft_parent_cb, row, 1) + cb.clicked.connect(self.token_nft_parent_cb_update) + row += 1 + + if self.nft_parent_id: + self.token_nft_parent_cb.setDisabled(True) + self.token_fixed_supply_cb.setDisabled(True) + self.token_qty_e.setAmount(1) + self.token_qty_e.setDisabled(True) + self.token_ds_e.setDisabled(True) + + msg = _('The \'simpleledger:\' formatted bitcoin address for the "minting baton" receiver.') + '\n\n' \ + + _('After the genesis transaction, further unlimited minting operations can be performed by the owner of') \ + + ' the "minting baton" transaction output. This baton can be repeatedly used for minting operations but' \ + + ' it cannot be duplicated.' + self.token_baton_label = HelpLabel(_('Address for Baton:'), msg) + self.token_baton_label.setHidden(True) + grid.addWidget(self.token_baton_label, row, 0) + self.token_baton_to_e = ButtonsLineEdit() + self.token_baton_to_e.setFixedWidth(560) + self.token_baton_to_e.setHidden(True) + grid.addWidget(self.token_baton_to_e, row, 1) + row += 1 + + if nft_parent_id: + nft_parent_coin = get_nft_parent_coin(nft_parent_id, main_window) + if not get_nft_parent_coin(nft_parent_id, main_window): + hbox2 = QHBoxLayout() + vbox.addLayout(hbox2) + warnpm = QIcon(":icons/warning.png").pixmap(20,20) + self.warn1 = l = QLabel(); l.setPixmap(warnpm) + hbox2.addWidget(l) + self.warn_msg = msg = QLabel(_(" NOTE: The parent token needs to be split before a new NFT can be created, click 'Prepare Group Parent'.")) + hbox2.addWidget(msg) + self.warn2 = l = QLabel(); l.setPixmap(warnpm) + hbox2.addStretch(1) + hbox2.addWidget(l) + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + self.cancel_button = b = QPushButton(_("Cancel")) + self.cancel_button.setAutoDefault(False) + self.cancel_button.setDefault(False) + b.clicked.connect(self.close) + hbox.addWidget(self.cancel_button) + + hbox.addStretch(1) + + # self.hash_button = b = QPushButton(_("Compute Document Hash...")) + # self.hash_button.setAutoDefault(False) + # self.hash_button.setDefault(False) + # b.clicked.connect(self.hash_file) + # b.setDefault(True) + # hbox.addWidget(self.hash_button) + + # self.tok_doc_button = b = QPushButton(_("Upload a Token Document...")) + # self.tok_doc_button.setAutoDefault(False) + # self.tok_doc_button.setDefault(False) + # b.clicked.connect(self.show_upload) + # b.setDefault(True) + # hbox.addWidget(self.tok_doc_button) + + self.preview_button = EnterButton(_("Preview"), self.do_preview) + hbox.addWidget(self.preview_button) + + self.create_button = b = QPushButton(_("Create New Token")) #if self.provided_token_name is None else _("Change")) + b.clicked.connect(self.create_token) + self.create_button.setAutoDefault(True) + self.create_button.setDefault(True) + hbox.addWidget(self.create_button) + + if nft_parent_id: + self.create_button.setText("Create NFT") + if not nft_parent_coin: + self.create_button.setHidden(True) + self.prepare_parent_bttn = QPushButton(_("Prepare Group Parent")) + self.prepare_parent_bttn.clicked.connect(self.prepare_nft_parent) + self.prepare_parent_bttn.setAutoDefault(True) + self.prepare_parent_bttn.setDefault(True) + hbox.addWidget(self.prepare_parent_bttn) + self.token_name_e.setDisabled(True) + self.token_ticker_e.setDisabled(True) + self.token_url_e.setDisabled(True) + self.token_dochash_e.setDisabled(True) + self.token_pay_to_e.setDisabled(True) + self.token_baton_to_e.setDisabled(True) + + dialogs.append(self) + self.show() + self.token_name_e.setFocus() + + def show_upload(self): + d = BitcoinFilesUploadDialog(self) + dialogs.append(d) + d.setModal(True) + d.show() + + def do_preview(self): + if self.nft_parent_id and not get_nft_parent_coin(self.nft_parent_id, self.main_window): + self.prepare_nft_parent(preview=True) + return + self.create_token(preview=True) + + def hash_file(self): + options = QFileDialog.Options() + options |= QFileDialog.DontUseNativeDialog + filename, _ = QFileDialog.getOpenFileName(self,"Compute SHA256 For File", "","All Files (*)", options=options) + if filename != '': + with open(filename,"rb") as f: + bytes = f.read() # read entire file as bytes + import hashlib + readable_hash = hashlib.sha256(bytes).hexdigest() + self.token_dochash_e.setText(readable_hash) + + def upd_token(self,): + self.token_qty_e.set_token(self.token_ticker_e.text(), int(self.token_ds_e.value())) + + # force update (will truncate excess decimals) + self.token_qty_e.numbify() + self.token_qty_e.update() + self.check_token_qty() + + def show_mint_baton_address(self): + self.token_baton_to_e.setHidden(self.token_fixed_supply_cb.isChecked()) + self.token_baton_label.setHidden(self.token_fixed_supply_cb.isChecked()) + + def token_nft_parent_cb_update(self): + if self.token_nft_parent_cb.isChecked(): + self.token_type = 129 + self.token_ds_e.setDisabled(True) + self.token_ds_e.setValue(0) + self.upd_token() + else: + self.token_type = 1 + self.token_ds_e.setDisabled(False) + + def parse_address(self, address): + if constants.net.SLPADDR_PREFIX not in address: + address = constants.net.SLPADDR_PREFIX + ":" + address + return Address.from_string(address) + + def prepare_nft_parent(self, preview=False): + + self.show_message("An initial preparation transaction is required before a new NFT can be created. This ensures only 1 parent token is burned in the NFT Genesis transaction.\n\nAfter this is transaction is broadcast you can proceed to fill out the NFT details and then click 'Create NFT'.") + + # IMPORTANT: set wallet.sedn_slpTokenId to None to guard tokens during this transaction + self.main_window.token_type_combo.setCurrentIndex(0) + assert self.main_window.slp_token_id == None + + coins = self.main_window.get_coins() + fee = None + + try: + selected_coin = None + nft_parent_coins = self.main_window.wallet.get_slp_utxos( + self.nft_parent_id, + domain=None, + exclude_frozen=True, + confirmed_only=self.main_window.config.get('confirmed_only', False), + slp_include_invalid=False, + slp_include_baton=False + ) + for coin in nft_parent_coins: + if coin['token_value'] > 1: + selected_coin = coin + break + + if selected_coin['token_value'] < 19: + slp_qtys = [1] * selected_coin['token_value'] + elif selected_coin['token_value'] >= 19: + slp_qtys = [1] * 18 + slp_qtys.append(selected_coin['token_value'] - 18) + outputs = [] + try: + slp_op_return_msg = buildSendOpReturnOutput_V1(self.nft_parent_id, slp_qtys, token_type=129) + outputs.append(slp_op_return_msg) + except OPReturnTooLarge: + self.show_message(_("Optional string text causiing OP_RETURN greater than 223 bytes.")) + return + except Exception as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + try: + addr = self.parse_address(self.token_pay_to_e.text()) + for i in slp_qtys: + outputs.append((TYPE_ADDRESS, addr, 546)) + except: + self.show_message(_("Must have Receiver Address in simpleledger format.")) + return + if selected_coin: + tx = self.main_window.wallet.make_unsigned_transaction(coins, + outputs, self.main_window.config, fee, None, mandatory_coins=[selected_coin]) + else: + self.show_message(_("Unable to select a parent coin to prepare.")) + return + except NotEnoughFunds: + self.show_message(_("Insufficient funds")) + return + except ExcessiveFee: + self.show_message(_("Your fee is too high. Max is 50 sat/byte.")) + return + except BaseException as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + if preview: + show_transaction(tx, self.main_window, None, False, self, slp_coins_to_burn=[selected_coin]) + return + + msg = [] + + if self.main_window.wallet.has_password(): + msg.append("") + msg.append(_("Enter your password to proceed")) + password = self.main_window.password_dialog('\n'.join(msg)) + if not password: + return + else: + password = None + tx_desc = None + + def sign_done(success): + if success: + if not tx.is_complete(): + show_transaction(tx, self.main_window, None, False, self) + self.main_window.do_clear() + else: + token_id = tx.txid() + if self.token_name_e.text() == '': + wallet_name = tx.txid()[0:5] + else: + wallet_name = self.token_name_e.text()[0:20] + # Check for duplication error + d = self.wallet.token_types.get(token_id) + for tid, d in self.wallet.token_types.items(): + if d['name'] == wallet_name and tid != token_id: + wallet_name = wallet_name + "-" + token_id[:3] + break + self.broadcast_transaction(tx, self.token_name_e.text(), wallet_name, is_nft_prep=True) + self.token_name_e.setDisabled(False) + self.token_ticker_e.setDisabled(False) + self.token_url_e.setDisabled(False) + self.token_dochash_e.setDisabled(False) + self.token_pay_to_e.setDisabled(False) + self.token_baton_to_e.setDisabled(False) + self.warn1.setHidden(True) + self.warn2.setHidden(True) + self.warn_msg.setHidden(True) + + self.sign_tx_with_password(tx, sign_done, password, slp_coins_to_burn=[selected_coin]) + + def create_token(self, preview=False): + token_name = self.token_name_e.text() if self.token_name_e.text() != '' else None + ticker = self.token_ticker_e.text() if self.token_ticker_e.text() != '' else None + token_document_url = self.token_url_e.text() if self.token_url_e.text() != '' else None + token_document_hash_hex = self.token_dochash_e.text() if self.token_dochash_e.text() != '' else None + decimals = int(self.token_ds_e.value()) + mint_baton_vout = 2 if self.token_baton_to_e.text() != '' and not self.token_fixed_supply_cb.isChecked() else None + + init_mint_qty = self.token_qty_e.get_amount() + if init_mint_qty is None: + self.show_message(_("Invalid token quantity entered.")) + return + if init_mint_qty > (2 ** 64) - 1: + maxqty = format_satoshis_plain_nofloat((2 ** 64) - 1, decimals) + self.show_message(_("Token output quantity is too large. Maximum %s.")%(maxqty,)) + return + + if token_document_hash_hex != None: + if len(token_document_hash_hex) != 64: + self.show_message(_("Token document hash must be a 32 byte hexidecimal string or left empty.")) + return + + outputs = [] + try: + slp_op_return_msg = buildGenesisOpReturnOutput_V1(ticker, token_name, token_document_url, + token_document_hash_hex, decimals, mint_baton_vout, + init_mint_qty, token_type=self.token_type) + outputs.append(slp_op_return_msg) + except OPReturnTooLarge: + self.show_message(_("Optional string text causiing OP_RETURN greater than 223 bytes.")) + return + except Exception as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + + try: + addr = self.parse_address(self.token_pay_to_e.text()) + outputs.append((TYPE_ADDRESS, addr, 546)) + except: + self.show_message(_("Must have Receiver Address in simpleledger format.")) + return + + if not self.token_fixed_supply_cb.isChecked() and not self.nft_parent_id: + try: + addr = self.parse_address(self.token_baton_to_e.text()) + outputs.append((TYPE_ADDRESS, addr, 546)) + except: + self.show_message(_("Must have Baton Address in simpleledger format.")) + return + + # IMPORTANT: set wallet.sedn_slpTokenId to None to guard tokens during this transaction + self.main_window.token_type_combo.setCurrentIndex(0) + assert self.main_window.slp_token_id == None + + coins = self.main_window.get_coins() + fee = None + + try: + selected_coin = None + if self.nft_parent_id: + selected_coin = get_nft_parent_coin(self.nft_parent_id, self.main_window) + if selected_coin: + tx = self.main_window.wallet.make_unsigned_transaction(coins, + outputs, self.main_window.config, fee, None, mandatory_coins=[selected_coin]) + else: + raise Exception('Must have a parent NFT coin with value of 1 first.') + else: + tx = self.main_window.wallet.make_unsigned_transaction(coins, + outputs, self.main_window.config, fee, None) + except NotEnoughFunds: + self.show_message(_("Insufficient funds")) + return + except ExcessiveFee: + self.show_message(_("Your fee is too high. Max is 50 sat/byte.")) + return + except BaseException as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + + if preview: + show_transaction(tx, self.main_window, None, False, self, slp_coins_to_burn=[selected_coin]) + return + + msg = [] + + if self.main_window.wallet.has_password(): + msg.append("") + msg.append(_("Enter your password to proceed")) + password = self.main_window.password_dialog('\n'.join(msg)) + if not password: + return + else: + password = None + tx_desc = None + + def sign_done(success): + if success: + if not tx.is_complete(): + show_transaction(tx, self.main_window, None, False, self) + self.main_window.do_clear() + else: + token_id = tx.txid() + if self.token_name_e.text() == '': + wallet_name = tx.txid()[0:5] + else: + wallet_name = self.token_name_e.text()[0:20] + # Check for duplication error + d = self.wallet.token_types.get(token_id) + for tid, d in self.wallet.token_types.items(): + if d['name'] == wallet_name and tid != token_id: + wallet_name = wallet_name + "-" + token_id[:3] + break + self.broadcast_transaction(tx, self.token_name_e.text(), wallet_name) + self.sign_tx_with_password(tx, sign_done, password, slp_coins_to_burn=[selected_coin]) + + def sign_tx_with_password(self, tx, callback, password, *, slp_coins_to_burn=None): + '''Sign the transaction in a separate thread. When done, calls + the callback with a success code of True or False. + ''' + + # check transaction SLP validity before signing + try: + assert SlpTransactionChecker.check_tx_slp(self.wallet, tx, coins_to_burn=slp_coins_to_burn) + except (Exception, AssertionError) as e: + self.show_warning(str(e)) + return + + # call hook to see if plugin needs gui interaction + run_hook('sign_tx', self, tx) + + def on_signed(result): + callback(True) + + def on_failed(exc_info): + self.main_window.on_error(exc_info) + callback(False) + + if self.main_window.tx_external_keypairs: + task = partial(Transaction.sign, tx, self.main_window.tx_external_keypairs) + else: + task = partial(self.wallet.sign_transaction, tx, password) + WaitingDialog(self, _('Signing transaction...'), task, on_signed, on_failed) + + def broadcast_transaction(self, tx, token_name, token_wallet_name, is_nft_prep=False): + # Capture current TL window; override might be removed on return + parent = self.top_level_window() + if self.main_window.gui_object.warn_if_no_network(self): + # Don't allow a useless broadcast when in offline mode. Previous to this we were getting an exception on broadcast. + return + elif not self.network.is_connected(): + # Don't allow a potentially very slow broadcast when obviously not connected. + #parent.show_error(_("Not connected")) + return + + def broadcast_thread(): + # non-GUI thread + status = False + msg = "Failed" + status, msg = self.network.broadcast(tx) + return status, msg + + def broadcast_done(result): + # GUI thread + if result: + status, msg = result + if status: + token_id = msg + if is_nft_prep: + parent.show_message("Transaction Id: " + token_id + "\n\nReady to create NFT, please click 'Create NFT'") + self.create_button.setHidden(False) + self.create_button.setDefault(True) + self.prepare_parent_bttn.setHidden(True) + else: + self.main_window.add_token_type('SLP%d'%(self.token_type,), token_id, token_wallet_name, int(self.token_ds_e.value()), allow_overwrite=True) + if tx.is_complete(): + self.wallet.set_label(token_id, "ZSLP Token Created: " + token_wallet_name) + if token_name == '': + parent.show_message("ZSLP Token Created.\n\nName in wallet: " + token_wallet_name + "\nTokenId: " + token_id) + elif token_name != token_wallet_name: + parent.show_message("ZSLP Token Created.\n\nName in wallet: " + token_wallet_name + "\nName on blockchain: " + token_name + "\nTokenId: " + token_id) + else: + parent.show_message("ZSLP Token Created.\n\nName: " + token_name + "\nToken ID: " + token_id) + else: + if msg.startswith("error: "): + msg = msg.split(" ", 1)[-1] # take the last part, sans the "error: " prefix + self.show_error(msg) + if not is_nft_prep: + self.close() + if self.nft_parent_id and is_nft_prep: + msg = 'Preparing for a new NFT...' + elif self.nft_parent_id: + msg = 'Creating NFT Token...' + else: + msg = 'Creating ZSLP Token...' + WaitingDialog(self, msg, broadcast_thread, broadcast_done, None) + + + def closeEvent(self, event): + super().closeEvent(event) + event.accept() + self.main_window.create_token_dialog = None + def remove_self(): + try: dialogs.remove(self) + except ValueError: pass # wasn't in list. + QTimer.singleShot(0, remove_self) # need to do this some time later. Doing it from within this function causes crashes. See #35 + + def update(self): + return + + def check_token_qty(self): + try: + if self.token_qty_e.get_amount() > 18446744073709551615: + self.token_qty_e.setAmount(18446744073709551615) + #if not self.token_fixed_supply_cb.isChecked(): + # self.show_warning(_("If you issue this much, users will may find it awkward to transfer large amounts, as each transaction output may only take up to ~" + str(self.token_qty_e.text()) + " tokens, thus requiring multiple outputs for very large amounts.")) + except: + pass diff --git a/gui/qt/slp_create_token_mint_dialog.py b/gui/qt/slp_create_token_mint_dialog.py new file mode 100644 index 000000000..6ca959ee6 --- /dev/null +++ b/gui/qt/slp_create_token_mint_dialog.py @@ -0,0 +1,303 @@ +import copy +import datetime +from functools import partial +import json +import threading +import sys +import traceback +import math + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electrum_zclassic.address import Address, PublicKey +from electrum_zclassic.bitcoin import base_encode, TYPE_ADDRESS +from electrum_zclassic.i18n import _ +from electrum_zclassic.plugins import run_hook + +from .util import * + +from electrum_zclassic.util import bfh, format_satoshis_nofloat, format_satoshis_plain_nofloat, NotEnoughFunds, ExcessiveFee, PrintError #, finalization_print_error +from electrum_zclassic.transaction import Transaction +from electrum_zclassic.slp import SlpMessage, SlpNoMintingBatonFound, SlpUnsupportedSlpTokenType, SlpInvalidOutputMessage, buildMintOpReturnOutput_V1 + +from .amountedit import SLPAmountEdit +from .transaction_dialog import show_transaction + +from electrum_zclassic import constants + +dialogs = [] # Otherwise python randomly garbage collects the dialogs... + +class SlpCreateTokenMintDialog(QDialog, MessageBoxMixin, PrintError): + + def __init__(self, main_window, token_id_hex): + # We want to be a top-level window + QDialog.__init__(self, parent=None) + from .main_window import ElectrumWindow + + assert isinstance(main_window, ElectrumWindow) + main_window._slp_dialogs.add(self) + # finalization_print_error(self) # Track object lifecycle + + self.main_window = main_window + self.wallet = main_window.wallet + self.network = main_window.network + self.app = main_window.app + + if self.main_window.gui_object.warn_if_no_network(self.main_window): + return + + self.setWindowTitle(_("Mint Additional Tokens")) + + vbox = QVBoxLayout() + self.setLayout(vbox) + + grid = QGridLayout() + grid.setColumnStretch(1, 1) + vbox.addLayout(grid) + row = 0 + + msg = _('Unique identifier for the token.') + grid.addWidget(HelpLabel(_('Token ID:'), msg), row, 0) + + self.token_id_e = QLineEdit() + self.token_id_e.setFixedWidth(490) + self.token_id_e.setText(token_id_hex) + self.token_id_e.setDisabled(True) + grid.addWidget(self.token_id_e, row, 1) + row += 1 + + msg = _('The number of decimal places used in the token quantity.') + grid.addWidget(HelpLabel(_('Decimals:'), msg), row, 0) + self.token_dec = QDoubleSpinBox() + decimals = self.main_window.wallet.token_types.get(token_id_hex)['decimals'] + self.token_dec.setRange(0, 9) + self.token_dec.setValue(decimals) + self.token_dec.setDecimals(0) + self.token_dec.setFixedWidth(50) + self.token_dec.setDisabled(True) + grid.addWidget(self.token_dec, row, 1) + row += 1 + + msg = _('The number of tokens created during token minting transaction, send to the receiver address provided below.') + grid.addWidget(HelpLabel(_('Additional Token Quantity:'), msg), row, 0) + name = self.main_window.wallet.token_types.get(token_id_hex)['name'] + self.token_qty_e = SLPAmountEdit(name, int(decimals)) + self.token_qty_e.setFixedWidth(200) + self.token_qty_e.textChanged.connect(self.check_token_qty) + grid.addWidget(self.token_qty_e, row, 1) + row += 1 + + msg = _('The simpleledger formatted bitcoin address for the genesis receiver of all genesis tokens.') + grid.addWidget(HelpLabel(_('Token Receiver Address:'), msg), row, 0) + self.token_pay_to_e = ButtonsLineEdit() + self.token_pay_to_e.setFixedWidth(490) + grid.addWidget(self.token_pay_to_e, row, 1) + row += 1 + + msg = _('The simpleledger formatted bitcoin address for the genesis baton receiver.') + self.token_baton_label = HelpLabel(_('Mint Baton Address:'), msg) + grid.addWidget(self.token_baton_label, row, 0) + self.token_baton_to_e = ButtonsLineEdit() + self.token_baton_to_e.setFixedWidth(490) + grid.addWidget(self.token_baton_to_e, row, 1) + row += 1 + + self.token_fixed_supply_cb = cb = QCheckBox(_('Permanently end issuance')) + self.token_fixed_supply_cb.setChecked(False) + grid.addWidget(self.token_fixed_supply_cb, row, 0) + cb.clicked.connect(self.show_mint_baton_address) + row += 1 + + hbox = QHBoxLayout() + vbox.addLayout(hbox) + + self.cancel_button = b = QPushButton(_("Cancel")) + self.cancel_button.setAutoDefault(False) + self.cancel_button.setDefault(False) + b.clicked.connect(self.close) + b.setDefault(True) + hbox.addWidget(self.cancel_button) + + hbox.addStretch(1) + + self.preview_button = EnterButton(_("Preview"), self.do_preview) + self.mint_button = b = QPushButton(_("Create Additional Tokens")) + b.clicked.connect(self.mint_token) + self.mint_button.setAutoDefault(True) + self.mint_button.setDefault(True) + hbox.addWidget(self.preview_button) + hbox.addWidget(self.mint_button) + + dialogs.append(self) + self.show() + self.token_qty_e.setFocus() + + def do_preview(self): + self.mint_token(preview = True) + + def show_mint_baton_address(self): + self.token_baton_to_e.setHidden(self.token_fixed_supply_cb.isChecked()) + self.token_baton_label.setHidden(self.token_fixed_supply_cb.isChecked()) + + def parse_address(self, address): + if constants.net.SLPADDR_PREFIX not in address: + address = constants.net.SLPADDR_PREFIX + ":" + address + return Address.from_string(address) + + def mint_token(self, preview=False): + decimals = int(self.token_dec.value()) + mint_baton_vout = 2 if self.token_baton_to_e.text() != '' and not self.token_fixed_supply_cb.isChecked() else None + init_mint_qty = self.token_qty_e.get_amount() + if init_mint_qty is None: + self.show_message(_("Invalid token quantity entered.")) + return + if init_mint_qty > (2 ** 64) - 1: + maxqty = format_satoshis_plain_nofloat((2 ** 64) - 1, decimals) + self.show_message(_("Token output quantity is too large. Maximum %s.")%(maxqty,)) + return + + outputs = [] + try: + token_id_hex = self.token_id_e.text() + token_type = self.wallet.token_types[token_id_hex]['class'] + slp_op_return_msg = buildMintOpReturnOutput_V1(token_id_hex, mint_baton_vout, init_mint_qty, token_type) + outputs.append(slp_op_return_msg) + except OPReturnTooLarge: + self.show_message(_("Optional string text causiing OP_RETURN greater than 223 bytes.")) + return + except Exception as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + + try: + addr = self.parse_address(self.token_pay_to_e.text()) + outputs.append((TYPE_ADDRESS, addr, 546)) + except: + self.show_message(_("Enter a Mint Receiver Address in ZSLP address format.")) + return + + if not self.token_fixed_supply_cb.isChecked(): + try: + addr = self.parse_address(self.token_baton_to_e.text()) + outputs.append((TYPE_ADDRESS, addr, 546)) + except: + self.show_message(_("Enter a Baton Address in ZSLP address format.")) + return + + # IMPORTANT: set wallet.sedn_slpTokenId to None to guard tokens during this transaction + self.main_window.token_type_combo.setCurrentIndex(0) + assert self.main_window.slp_token_id == None + + coins = self.main_window.get_coins() + fee = None + + try: + baton_input = self.main_window.wallet.get_slp_token_baton(self.token_id_e.text()) + except SlpNoMintingBatonFound as e: + self.show_message(_("No baton exists for this token.")) + return + + desired_fee_rate = 1.0 # sats/B, just init this value for paranoia + try: + tx = self.main_window.wallet.make_unsigned_transaction(coins, outputs, self.main_window.config, fee, None) + desired_fee_rate = tx.get_fee() / tx.estimated_size() # remember the fee coin chooser & wallet gave us as a fee rate so we may use it below after adding baton to adjust fee downward to this rate. + except NotEnoughFunds: + self.show_message(_("Insufficient funds")) + return + except ExcessiveFee: + self.show_message(_("Your fee is too high. Max is 50 sat/byte.")) + return + except BaseException as e: + traceback.print_exc(file=sys.stdout) + self.show_message(str(e)) + return + + # Find & Add baton to tx inputs + try: + baton_utxo = self.main_window.wallet.get_slp_token_baton(self.token_id_e.text()) + except SlpNoMintingBatonFound: + self.show_message(_("There is no minting baton found for this token.")) + return + + tx.add_inputs([baton_utxo]) + for txin in tx._inputs: + self.main_window.wallet.add_input_info(txin) + + def tx_adjust_change_amount_based_on_baton_amount(tx, desired_fee_rate): + ''' adjust change amount (based on amount added from baton) ''' + if len(tx._outputs) not in (3,4): + # no change, or a tx shape we don't know about + self.print_error(f"Unkown tx shape, not adjusting fee!") + return + chg = tx._outputs[-1] # change is always the last output due to BIP_LI01 sorting + assert len(chg) == 3, "Expected tx output to be of length 3" + if not self.main_window.wallet.is_mine(chg[1]): + self.print_error(f"Unkown change address {chg[1]}, not adjusting fee!") + return + chg_amt = chg[2] + if chg_amt <= 546: + # if change is 546, then the BIP_LI01 sorting doesn't guarantee + # change output is at the end.. so we don't know which was + # changed based on the heuristics this code relies on.. so.. + # Abort! Abort! + self.print_error("Could not determine change output, not adjusting fee!") + return + curr_fee, curr_size = tx.get_fee(), tx.estimated_size() + fee_rate = curr_fee / curr_size + diff = math.ceil((fee_rate - desired_fee_rate) * curr_size) + if diff > 0: + tx._outputs[-1] = (chg[0], chg[1], chg[2] + diff) # adjust the output + self.print_error(f"Added {diff} sats to change to maintain fee rate of {desired_fee_rate:0.2f}, new fee: {tx.get_fee()}") + + tx_adjust_change_amount_based_on_baton_amount(tx, desired_fee_rate) + + if preview: + show_transaction(tx, self.main_window, None, False, self) + return + + msg = [] + + if self.main_window.wallet.has_password(): + msg.append("") + msg.append(_("Enter your password to proceed")) + password = self.main_window.password_dialog('\n'.join(msg)) + if not password: + return + else: + password = None + + tx_desc = None + + def sign_done(success): + if success: + if not tx.is_complete(): + show_transaction(tx, self.main_window, None, False, self) + self.main_window.do_clear() + else: + self.main_window.broadcast_transaction(tx, tx_desc) + + self.main_window.sign_tx_with_password(tx, sign_done, password) + + self.mint_button.setDisabled(True) + self.close() + + def closeEvent(self, event): + super().closeEvent(event) + event.accept() + def remove_self(): + try: dialogs.remove(self) + except ValueError: pass # wasn't in list. + QTimer.singleShot(0, remove_self) # need to do this some time later. Doing it from within this function causes crashes. See #35 + + def update(self): + return + + def check_token_qty(self): + try: + if self.token_qty_e.get_amount() > (10 ** 19): + self.show_warning(_('If you issue this much, users will may find it awkward to transfer large amounts as each transaction output may only take up to ~2 x 10^(19-decimals) tokens, thus requiring multiple outputs for very large amounts.')) + except: + pass diff --git a/gui/qt/slp_history_list.py b/gui/qt/slp_history_list.py new file mode 100644 index 000000000..e6a6b4e5b --- /dev/null +++ b/gui/qt/slp_history_list.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2015 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +import webbrowser + +from .util import * +import electrum_zclassic.web as web +from electrum_zclassic.i18n import _ +from electrum_zclassic.util import timestamp_to_datetime, profiler +from electrum_zclassic.util import format_satoshis_nofloat + +from .slp_add_token_dialog import SlpAddTokenDialog + +from locale import localeconv +from collections import defaultdict + +TX_ICONS = [ + "warning.png", + "warning.png", + "unconfirmed.svg", + "unconfirmed.svg", + "clock1.svg", + "clock2.svg", + "clock3.svg", + "clock4.svg", + "clock5.svg", + "confirmed.svg", +] + + +class HistoryList(MyTreeWidget): + filter_columns = [2, 3, 4] # Date, Description, Amount + + + def slp_validity_slot(self, txid, validity): + # This gets pinged by the SLP validator when a validation job finishes. + # (see lib/wallet.py : slp_check_validation() ) + if validity in (2,3): + # If validator found 'invalid', then we need to update balances, + # which requires recalculating / refreshing the whole list. + self.update() + else: + # If validator found 'valid' then the balances are OK, so just + # update the relevant items (note: may be multiple matches.). + items = self._allitems.get(txid, []) + for item in items: + self.update_item_state(item) + + def __init__(self, parent=None): + MyTreeWidget.__init__(self, parent, self.create_menu, [], 4, deferred_updates=True) + self.slp_validity_signal = parent.slp_validity_signal + self.slp_validity_signal.connect(self.slp_validity_slot, Qt.QueuedConnection) + self.editable_columns=[] + self.refresh_headers() + self.setColumnHidden(1, True) + self.setSortingEnabled(True) + self.sortByColumn(0, Qt.AscendingOrder) + self.wallet = None + self._allitems = defaultdict(list) + + def refresh_headers(self): + headers = [ '', '',_('Date'), _('Amount'), _('Token') ] + + self.update_headers(headers) + + def get_domain(self): + '''Replaced in address_dialog.py''' + return self.wallet.get_addresses() + + # @rate_limited(1.0, classlevel=True, ts_after=True) # We rate limit the history list refresh no more than once every second, app-wide + def update(self): + if self.parent and self.parent.cleaned_up: + # short-cut return if window was closed and wallet is stopped + return + super().update() + + @profiler + def on_update(self): + self.wallet = self.parent.wallet + h = self.wallet.get_history(self.get_domain()) + slp_history =self.wallet.get_slp_history() + + item = self.currentItem() + current_tx = item.data(0, Qt.UserRole) if item else None + self.clear() + + self._allitems.clear() + for h_item in slp_history: + tx_hash, height, conf, timestamp, delta, token_id = h_item + status, status_str = self.wallet.get_tx_status(tx_hash, height, conf, timestamp) + + entry = ['', tx_hash, status_str, '', ''] + item = SortableTreeWidgetItem(entry) + item.setData(0, SortableTreeWidgetItem.DataRole, (status, conf)) + item.setData(0, Qt.UserRole, tx_hash) + item.setData(4, Qt.UserRole, token_id) + item.setData(3, Qt.UserRole, delta) + + item.setTextAlignment(3, Qt.AlignRight) + item.setFont(3, QFont(MONOSPACE_FONT)) + + self.update_item_state(item) + + self.insertTopLevelItem(0, item) + if current_tx == tx_hash: + self.setCurrentItem(item) + + self._allitems[tx_hash].append(item) + + def on_doubleclick(self, item, column): + tx_hash = item.data(0, Qt.UserRole) + tx = self.wallet.transactions.get(tx_hash) + self.parent.show_transaction(tx) + + def update_item_state(self, item): + tx_hash = item.data(0, Qt.UserRole) + status, conf = item.data(0, SortableTreeWidgetItem.DataRole) + token_id = item.data(4, Qt.UserRole) + delta = item.data(3, Qt.UserRole) + + try: + validity = self.wallet.get_slp_token_info(tx_hash)['validity'] + except KeyError: # Can happen if non-token tx (if burning tokens) + validity = None + + try: + tinfo = self.wallet.token_types[token_id] + except KeyError: + unktoken = True + tokenname = _("%.4s... (unknown - right click to add)"%(token_id,)) + deltastr = '%+d'%(delta,) + else: + if tinfo['decimals'] == '?': + unktoken = True + tokenname = _("%.4s... (unknown - right click to add)"%(token_id,)) + deltastr = '%+d'%(delta,) + else: + unktoken = False + tokenname=tinfo['name'] + deltastr = format_satoshis_nofloat(delta, is_diff=True, decimal_point=tinfo['decimals'],) + + # right-pad so the decimal points line up + # (note that because zeros are stripped, we have to locate decimal point here) + dp = localeconv()['decimal_point'] + d1,d2 = deltastr.rsplit(dp,1) + deltastr += "\u2014"*(9-len(d2)) # \u2014 is long dash + + if unktoken and validity in (None,0,1): + # If a token is not in our list of known token_ids, warn the user. + icon=QIcon(":icons/warning.png") + icontooltip = _("Unknown token ID") + elif validity == 0: + # For in-progress validation, always show gears regardless of confirmation status. + icon=QIcon(":icons/unconfirmed.svg") + icontooltip = _("ZSLP unvalidated") + elif validity in (None,2,3): + icon=QIcon(":icons/expired.svg") + if validity is None: + icontooltip = "non-ZSLP (tokens burned!)" + else: + icontooltip = "ZSLP invalid (tokens burned!)" + elif validity == 4: + icon=QIcon(":icons/expired.svg") + icontooltip = "Bad NFT1 Parent" + elif validity == 1: + # For SLP valid known txes, show the confirmation status (gears, few-confirmations, or green check) + icon = QIcon(":icons/" + TX_ICONS[status]) + icontooltip = _("ZSLP valid; ") + str(conf) + " confirmation" + ("s" if conf != 1 else "") + else: + raise ValueError(validity) + + if unktoken: + item.setForeground(3, QBrush(QColor("#888888"))) + item.setForeground(4, QBrush(QColor("#888888"))) + elif delta < 0: + item.setForeground(3, QBrush(QColor("#BC1E1E"))) + + item.setIcon(0, icon) + item.setToolTip(0, icontooltip) + item.setText(4, tokenname) + item.setText(3, deltastr) + + def update_item_netupdate(self, tx_hash, height, conf, timestamp): + wallet = getattr(self,'wallet', None) + if not wallet: + return + items = self._allitems.get(tx_hash, []) + if not items: + return + status, status_str = wallet.get_tx_status(tx_hash, height, conf, timestamp) + for item in items: + item.setData(0, SortableTreeWidgetItem.DataRole, (status, conf)) + item.setText(2, status_str) + self.update_item_state(item) + + def create_menu(self, position): + item = self.currentItem() + if not item: + return + column = self.currentColumn() + tx_hash = item.data(0, Qt.UserRole) + token_id = item.data(4, Qt.UserRole) + if not tx_hash: + return + if column is 0: + column_title = "ID" + column_data = tx_hash + else: + column_title = self.headerItem().text(column) + column_data = item.text(column) + + tx_URL = web.block_explorer_URL(self.config, 'tx', tx_hash) + height, conf, timestamp = self.wallet.get_tx_height(tx_hash) + tx = self.wallet.transactions.get(tx_hash) + is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) + is_unconfirmed = height <= 0 + pr_key = self.wallet.invoices.paid.get(tx_hash) + + menu = QMenu() + + if not self.wallet.token_types.get(token_id): + menu.addAction(_("Add this token"), lambda: SlpAddTokenDialog(self.parent, token_id_hex = token_id)) + elif self.wallet.token_types.get(token_id)['decimals'] == '?': + menu.addAction(_("Add this token"), lambda: SlpAddTokenDialog(self.parent, token_id_hex = token_id, allow_overwrite=True)) + + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) + if column in self.editable_columns: + # We grab a fresh reference to the current item, as it has been deleted in a reported issue. + menu.addAction(_("Edit {}").format(column_title), + lambda: self.currentItem() and self.editItem(self.currentItem(), column)) + + menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx)) + if is_unconfirmed and tx: + child_tx = self.wallet.cpfp(tx, 0) + if child_tx: + menu.addAction(_("Child pays for parent"), lambda: self.parent.cpfp(tx, child_tx)) + if pr_key: + menu.addAction(QIcon(":icons/seal.svg"), _("View invoice"), lambda: self.parent.show_invoice(pr_key)) + if tx_URL: + menu.addAction(_("View on block explorer"), lambda: webbrowser.open(tx_URL)) + menu.exec_(self.viewport().mapToGlobal(position)) diff --git a/gui/qt/slp_mgt.py b/gui/qt/slp_mgt.py new file mode 100644 index 000000000..96d52427a --- /dev/null +++ b/gui/qt/slp_mgt.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2015 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import webbrowser + +from electrum_zclassic.i18n import _ +import electrum_zclassic.web as web +from electrum_zclassic.address import Address +from electrum_zclassic.plugins import run_hook +from electrum_zclassic.util import FileImportFailed +from PyQt5.QtGui import * +from PyQt5.QtCore import * +from PyQt5.QtWidgets import ( + QAbstractItemView, QFileDialog, QMenu, QTreeWidgetItem) +from .util import * + +from electrum_zclassic.util import format_satoshis_nofloat +from .slp_add_token_dialog import SlpAddTokenDialog +from .slp_create_token_genesis_dialog import SlpCreateTokenGenesisDialog +from .slp_create_token_mint_dialog import SlpCreateTokenMintDialog +from .slp_burn_token_dialog import SlpBurnTokenDialog + +from electrum_zclassic.slp import SlpNoMintingBatonFound + +class SlpMgt(MyTreeWidget): + filter_columns = [0, 1,2] # Key, Value + + def slp_validity_slot(self, txid, validity): + self.update() + + def __init__(self, parent): + MyTreeWidget.__init__(self, parent, self.create_menu, [_('Token ID'), _('Token Name'), _('Dec.'),_('Balance'),_('Baton'), _('Token Type')], 0, [0], deferred_updates=True) + self.slp_validity_signal = parent.slp_validity_signal + self.slp_validity_signal.connect(self.slp_validity_slot, Qt.QueuedConnection) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setSortingEnabled(True) + self.editable_columns=[1] + self.sortByColumn(1, Qt.AscendingOrder) + + def on_permit_edit(self, item, column): + # openalias items shouldn't be editable + return item.text(1) != "openalias" + + def on_edited(self, item, column, prior): + token_id = item.text(0) + d = self.parent.wallet.token_types[token_id] + + if self.parent.add_token_type(d['class'], token_id, item.text(1), d['decimals'], allow_overwrite=True): + # successfully changed + pass + else: + # revert back to original + item.setText(1,d['name']) + + def on_doubleclick(self, item, column): + if self.permit_edit(item, column): + self.editItem(item, column) + else: + token_id = item.data(0, Qt.UserRole) + if('unknown-' in item.text(1)): + SlpAddTokenDialog(self.parent, token_id_hex = token_id, allow_overwrite=True) + else: + SlpAddTokenDialog(self.parent, token_id_hex = token_id, token_name=item.text(1)) + + def create_menu(self, position): + menu = QMenu() + selected = self.selectedItems() + if len(selected) == 1: + keys = [item.text(0) for item in selected] + if self.parent.wallet.token_types[keys[0]]['decimals'] == "?": + menu.addAction(_("Add this token"), lambda: SlpAddTokenDialog(self.parent, token_id_hex = keys[0], allow_overwrite=True)) + menu.addAction(_("Remove this token"), lambda: self.parent.delete_slp_token(keys)) + else: + if self.parent.wallet.token_types[keys[0]]['class'] == 'SLP129': + menu.addAction(_("Create new NFT"), lambda: SlpCreateTokenGenesisDialog(self.parent, nft_parent_id=keys[0])) + menu.addSeparator() + try: + self.parent.wallet.get_slp_token_baton(keys[0]) + menu.addAction(_("Mint Tool"), lambda: SlpCreateTokenMintDialog(self.parent, keys[0])) + except SlpNoMintingBatonFound: + pass + column = self.currentColumn() + column_title = self.headerItem().text(column) + column_data = '\n'.join([item.text(column) for item in selected]) + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) + menu.addAction(_("Remove this token"), lambda: self.parent.delete_slp_token(keys)) + if self.currentItem(): + menu.addAction(_("View Token Details"), lambda: self.onViewTokenDetails()) + menu.addSeparator() + menu.addAction(_("Burn Tool"), lambda: self.onBurnDialog()) + menu.addSeparator() + + menu.addAction(_("Add existing token"), lambda: SlpAddTokenDialog(self.parent,)) + menu.addAction(_("Create a new token"), lambda: SlpCreateTokenGenesisDialog(self.parent,)) + + run_hook('create_contact_menu', menu, selected) + menu.exec_(self.viewport().mapToGlobal(position)) + + + def onViewTokenDetails(self): + current = self.currentItem() + if current: + SlpAddTokenDialog(self.parent, token_id_hex = current.data(0, Qt.UserRole), token_name=current.text(1)) + + def onBurnDialog(self): + current = self.currentItem() + if current: + SlpBurnTokenDialog(self.parent, token_id_hex = current.data(0, Qt.UserRole), token_name=current.text(1)) + + def get_balance_from_token_id(self,slpTokenId): + # implement by looking at UTXO for this token! + # for now return dummy value. + bal=self.parent.wallet.get_slp_token_balance(slpTokenId, self.parent.config)[0] + return bal + + # @rate_limited(.333, classlevel=True, ts_after=True) # We rate limit the slp mgt refresh no more than 3 times every second, app-wide + def update(self): + if self.parent and self.parent.cleaned_up: + # short-cut return if window was closed and wallet is stopped + return + super().update() + + def on_update(self): + self.clear() + + tokens = self.parent.wallet.token_types.copy() + for token_id, i in tokens.items(): + name = i["name"] + decimals = i["decimals"] + calculated_balance= self.get_balance_from_token_id(token_id) + if decimals != "?": + balancestr = format_satoshis_nofloat(calculated_balance, decimal_point=decimals, num_zeros=decimals) + balancestr += ' '*(9-decimals) + else: + balancestr = "double-click to add" + + typestr = "?" + if i['class'] == "SLP1": + typestr = "Type 1" + elif i['class'] == "SLP65": + typestr = "NFT1 Child" + elif i['class'] == "SLP129": + typestr = "NFT1 Parent" + + try: + self.parent.wallet.get_slp_token_baton(token_id) + item = QTreeWidgetItem([str(token_id),str(name),str(decimals),balancestr,"★", typestr]) + except SlpNoMintingBatonFound: + item = QTreeWidgetItem([str(token_id),str(name),str(decimals),balancestr,"", typestr]) + + squishyfont = QFont(MONOSPACE_FONT) + squishyfont.setStretch(85) + item.setFont(0, squishyfont) + #item.setTextAlignment(2, Qt.AlignRight) + item.setTextAlignment(3, Qt.AlignRight) + item.setFont(3, QFont(MONOSPACE_FONT)) + item.setData(0, Qt.UserRole, token_id) + if decimals == "?": + item.setForeground(0, QBrush(QColor("#BC1E1E"))) + item.setForeground(1, QBrush(QColor("#BC1E1E"))) + item.setForeground(2, QBrush(QColor("#BC1E1E"))) + item.setForeground(3, QBrush(QColor("#BC1E1E"))) + item.setForeground(4, QBrush(QColor("#BC1E1E"))) + item.setForeground(5, QBrush(QColor("#BC1E1E"))) + if i["class"] == "SLP129": + for _token_id, _i in self.parent.wallet.token_types.items(): + if _i["class"] == "SLP65" and _i.get("group_id", None) == token_id: + name = _i["name"] + decimals = _i["decimals"] + calculated_balance= self.get_balance_from_token_id(_token_id) + if decimals != "?": + balancestr = format_satoshis_nofloat(calculated_balance, decimal_point=decimals, num_zeros=decimals) + balancestr += ' '*(9-decimals) + else: + balancestr = "double-click to add" + _nft_item = QTreeWidgetItem([str(_token_id),str(name),str(decimals),balancestr,"", "NFT1 Child"]) + squishyfont = QFont(MONOSPACE_FONT) + squishyfont.setStretch(85) + _nft_item.setFont(0, squishyfont) + #item.setTextAlignment(2, Qt.AlignRight) + _nft_item.setTextAlignment(3, Qt.AlignRight) + _nft_item.setFont(3, QFont(MONOSPACE_FONT)) + _nft_item.setData(0, Qt.UserRole, _token_id) + if decimals == "?": + _nft_item.setForeground(0, QBrush(QColor("#BC1E1E"))) + _nft_item.setForeground(1, QBrush(QColor("#BC1E1E"))) + _nft_item.setForeground(2, QBrush(QColor("#BC1E1E"))) + _nft_item.setForeground(3, QBrush(QColor("#BC1E1E"))) + item.addChild(_nft_item) + self.addTopLevelItem(item) + elif i["class"] == "SLP65" and i.get("group_id", "?") == "?": + self.addTopLevelItem(item) + elif i["class"] == "SLP1": + self.addTopLevelItem(item) + self.expandAll() diff --git a/gui/qt/transaction_dialog.py b/gui/qt/transaction_dialog.py index d63f56673..d84859b50 100644 --- a/gui/qt/transaction_dialog.py +++ b/gui/qt/transaction_dialog.py @@ -31,6 +31,7 @@ from PyQt5.QtGui import * from PyQt5.QtWidgets import * +from electrum_zclassic.address import Address, PublicKey from electrum_zclassic.bitcoin import base_encode from electrum_zclassic.i18n import _ from electrum_zclassic.plugins import run_hook @@ -45,20 +46,21 @@ dialogs = [] # Otherwise python randomly garbage collects the dialogs... -def show_transaction(tx, parent, desc=None, prompt_if_unsaved=False): +def show_transaction(tx, parent, desc=None, prompt_if_unsaved=False, window_to_close_on_broadcast=None, *, slp_coins_to_burn=None): try: - d = TxDialog(tx, parent, desc, prompt_if_unsaved) + d = TxDialog(tx, parent, desc, prompt_if_unsaved, window_to_close_on_broadcast, slp_coins_to_burn=slp_coins_to_burn) except SerializationError as e: traceback.print_exc(file=sys.stderr) parent.show_critical(_("Electrum-Zclassic was unable to deserialize the transaction:") + "\n" + str(e)) else: dialogs.append(d) d.show() + return d class TxDialog(QDialog, MessageBoxMixin): - def __init__(self, tx, parent, desc, prompt_if_unsaved): + def __init__(self, tx, parent, desc, prompt_if_unsaved, window_to_close_on_broadcast=None, *, slp_coins_to_burn=None): '''Transactions in the wallet will show their description. Pass desc to give a description for txs not yet in the wallet. ''' @@ -75,8 +77,11 @@ def __init__(self, tx, parent, desc, prompt_if_unsaved): self.main_window = parent self.wallet = parent.wallet self.prompt_if_unsaved = prompt_if_unsaved + self.window_to_close_on_broadcast = window_to_close_on_broadcast self.saved = False self.desc = desc + self.slp_coins_to_burn = slp_coins_to_burn + Weak.finalization_print_error(self) # track object lifecycle self.setMinimumWidth(750) self.setWindowTitle(_("Transaction")) @@ -150,6 +155,8 @@ def __init__(self, tx, parent, desc, prompt_if_unsaved): self.update() def do_broadcast(self): + if self.window_to_close_on_broadcast: + self.window_to_close_on_broadcast.close() self.main_window.push_top_level_window(self) try: self.main_window.broadcast_transaction(self.tx, self.desc) @@ -190,8 +197,8 @@ def sign_done(success): self.sign_button.setDisabled(True) self.main_window.push_top_level_window(self) - self.main_window.sign_tx(self.tx, sign_done) - + self.main_window.sign_tx(self.tx, sign_done, on_pw_cancel=cleanup, + slp_coins_to_burn=self.slp_coins_to_burn) def save(self): if self.main_window.save_transaction_into_wallet(self.tx): self.save_button.setDisabled(True) @@ -218,6 +225,11 @@ def update(self): (self.wallet.can_sign(self.tx) or bool(self.main_window.tx_external_keypairs)) self.sign_button.setEnabled(can_sign) self.tx_hash_e.setText(tx_hash or _('Unknown')) + if fee is None: + try: + fee = self.tx.get_fee() # Try and compute fee. We don't always have 'value' in all the inputs though. :/ + except KeyError: # Value key missing from an input + pass if desc is None: self.tx_desc.hide() else: @@ -243,12 +255,15 @@ def update(self): amount_str = _("Amount sent:") + ' %s'% format_amount(-amount) + ' ' + base_unit size_str = _("Size:") + ' %d bytes'% size fee_str = _("Fee") + ': %s' % (format_amount(fee) + ' ' + base_unit if fee is not None else _('unknown')) + dusty_fee = self.tx.ephemeral.get('dust_to_fee', 0) if fee is not None: fee_rate = fee/size*1000 fee_str += ' ( %s ) ' % self.main_window.format_fee_rate(fee_rate) confirm_rate = simple_config.FEERATE_WARNING_HIGH_FEE if fee_rate > confirm_rate: fee_str += ' - ' + _('Warning') + ': ' + _("high fee") + '!' + if dusty_fee: + fee_str += ' ' + (_("( %s in dust was added to fee )") % format_amount(dusty_fee)) + '' self.amount_label.setText(amount_str) self.fee_label.setText(fee_str) self.size_label.setText(size_str) @@ -268,7 +283,7 @@ def add_io(self, vbox): chg.setToolTip(_("Wallet change address")) def text_format(addr): - if self.wallet.is_mine(addr): + if isinstance(addr, Address) and self.wallet.is_mine(addr): return chg if self.wallet.is_change(addr) else rec return ext @@ -288,14 +303,14 @@ def format_amount(amt): prevout_n = x.get('prevout_n') cursor.insertText(prevout_hash[0:8] + '...', ext) cursor.insertText(prevout_hash[-8:] + ":%-4d " % prevout_n, ext) - addr = x.get('address') - if addr == "(pubkey)": - _addr = self.wallet.get_txin_address(x) - if _addr: - addr = _addr + addr = x['address'] + if isinstance(addr, PublicKey): + addr = addr.toAddress() if addr is None: - addr = _('unknown') - cursor.insertText(addr, text_format(addr)) + addr_text = _('unknown') + else: + addr_text = addr.to_ui_string() + cursor.insertText(addr_text, text_format(addr)) if x.get('value'): cursor.insertText(format_amount(x['value']), ext) cursor.insertBlock() @@ -308,9 +323,14 @@ def format_amount(amt): o_text.setMaximumHeight(100) cursor = o_text.textCursor() for addr, v in self.tx.get_outputs(): - cursor.insertText(addr, text_format(addr)) + addrstr = addr.to_ui_string() + cursor.insertText(addrstr, text_format(addr)) if v is not None: - cursor.insertText('\t', ext) + if len(addrstr) > 42: # for long outputs, make a linebreak. + cursor.insertBlock() + addrstr = " ^^^" + cursor.insertText(addrstr, ext) + cursor.insertText(' '*(43 - len(addrstr)), ext) cursor.insertText(format_amount(v), ext) cursor.insertBlock() vbox.addWidget(o_text) diff --git a/gui/qt/util.py b/gui/qt/util.py index e79b03ad1..bf1b8b6da 100644 --- a/gui/qt/util.py +++ b/gui/qt/util.py @@ -3,15 +3,16 @@ import sys import platform import queue +import threading from collections import namedtuple -from functools import partial +from functools import partial, wraps from PyQt5.QtGui import * from PyQt5.QtCore import * from PyQt5.QtWidgets import * from electrum_zclassic.i18n import _ -from electrum_zclassic.util import FileImportFailed, FileExportFailed +from electrum_zclassic.util import FileImportFailed, FileExportFailed, PrintError, Weak, finalization_print_error from electrum_zclassic.paymentrequest import PR_UNPAID, PR_PAID, PR_EXPIRED @@ -29,9 +30,9 @@ dialogs = [] pr_icons = { - PR_UNPAID:":icons/unpaid.png", - PR_PAID:":icons/confirmed.png", - PR_EXPIRED:":icons/expired.png" + PR_UNPAID:":icons/unpaid.svg", + PR_PAID:":icons/confirmed.svg", + PR_EXPIRED:":icons/expired.svg" } pr_tooltips = { @@ -388,7 +389,7 @@ def createEditor(self, parent, option, index): class MyTreeWidget(QTreeWidget): def __init__(self, parent, create_menu, headers, stretch_column=None, - editable_columns=None): + editable_columns=None, *, deferred_updates=False): QTreeWidget.__init__(self, parent) self.parent = parent self.config = self.parent.config @@ -628,11 +629,14 @@ class TaskThread(QThread): Task = namedtuple("Task", "task cb_success cb_done cb_error") doneSig = pyqtSignal(object, object, object) - def __init__(self, parent, on_error=None): - super(TaskThread, self).__init__(parent) + def __init__(self, parent, on_error=None, *, name=None): + QThread.__init__(self, parent) + if name is not None: + self.setObjectName(name) self.on_error = on_error self.tasks = queue.Queue() self.doneSig.connect(self.on_done) + Weak.finalization_print_error(self) # track task thread lifecycle in debug log self.start() def add(self, task, on_success=None, on_done=None, on_error=None): @@ -798,6 +802,235 @@ def get(self, file_name): self.__cache[file_name] = QIcon(file_name) return self.__cache[file_name] +class OPReturnError(Exception): + """ thrown when the OP_RETURN for a tx not of the right format """ + +class OPReturnTooLarge(OPReturnError): + """ thrown when the OP_RETURN for a tx is >220 bytes """ + +class RateLimiter(PrintError): + ''' Manages the state of a @rate_limited decorated function, collating + multiple invocations. This class is not intented to be used directly. Instead, + use the @rate_limited decorator (for instance methods). + + This state instance gets inserted into the instance attributes of the target + object wherever a @rate_limited decorator appears. + + The inserted attribute is named "__FUNCNAME__RateLimiter". ''' + # some defaults + last_ts = 0.0 + timer = None + saved_args = (tuple(),dict()) + ctr = 0 + + def __init__(self, rate, ts_after, obj, func): + self.n = func.__name__ + self.qn = func.__qualname__ + self.rate = rate + self.ts_after = ts_after + self.obj = Weak.ref(obj) # keep a weak reference to the object to prevent cycles + self.func = func + #self.print_error("*** Created: func=",func,"obj=",obj,"rate=",rate) + + def diagnostic_name(self): + return "{}:{}".format("rate_limited",self.qn) + + def kill_timer(self): + if self.timer: + #self.print_error("deleting timer") + try: + self.timer.stop() + self.timer.deleteLater() + except RuntimeError as e: + if 'c++ object' in str(e).lower(): + # This can happen if the attached object which actually owns + # QTimer is deleted by Qt before this call path executes. + # This call path may be executed from a queued connection in + # some circumstances, hence the crazyness (I think). + self.print_error("advisory: QTimer was already deleted by Qt, ignoring...") + else: + raise + finally: + self.timer = None + + @classmethod + def attr_name(cls, func): return "__{}__{}".format(func.__name__, cls.__name__) + + @classmethod + def invoke(cls, rate, ts_after, func, args, kwargs): + ''' Calls _invoke() on an existing RateLimiter object (or creates a new + one for the given function on first run per target object instance). ''' + assert args and isinstance(args[0], object), "@rate_limited decorator may only be used with object instance methods" + assert threading.current_thread() is threading.main_thread(), "@rate_limited decorator may only be used with functions called in the main thread" + obj = args[0] + a_name = cls.attr_name(func) + #print_error("*** a_name =",a_name,"obj =",obj) + rl = getattr(obj, a_name, None) # we hide the RateLimiter state object in an attribute (name based on the wrapped function name) in the target object + if rl is None: + # must be the first invocation, create a new RateLimiter state instance. + rl = cls(rate, ts_after, obj, func) + setattr(obj, a_name, rl) + return rl._invoke(args, kwargs) + + def _invoke(self, args, kwargs): + self._push_args(args, kwargs) # since we're collating, save latest invocation's args unconditionally. any future invocation will use the latest saved args. + self.ctr += 1 # increment call counter + #self.print_error("args_saved",args,"kwarg_saved",kwargs) + if not self.timer: # check if there's a pending invocation already + now = time.time() + diff = float(self.rate) - (now - self.last_ts) + if diff <= 0: + # Time since last invocation was greater than self.rate, so call the function directly now. + #self.print_error("calling directly") + return self._doIt() + else: + # Time since last invocation was less than self.rate, so defer to the future with a timer. + self.timer = QTimer(self.obj() if isinstance(self.obj(), QObject) else None) + self.timer.timeout.connect(self._doIt) + #self.timer.destroyed.connect(lambda x=None,qn=self.qn: print(qn,"Timer deallocated")) + self.timer.setSingleShot(True) + self.timer.start(diff*1e3) + #self.print_error("deferring") + else: + # We had a timer active, which means as future call will occur. So return early and let that call happenin the future. + # Note that a side-effect of this aborted invocation was to update self.saved_args. + pass + #self.print_error("ignoring (already scheduled)") + + def _pop_args(self): + args, kwargs = self.saved_args # grab the latest collated invocation's args. this attribute is always defined. + self.saved_args = (tuple(),dict()) # clear saved args immediately + return args, kwargs + + def _push_args(self, args, kwargs): + self.saved_args = (args, kwargs) + + def _doIt(self): + #self.print_error("called!") + t0 = time.time() + args, kwargs = self._pop_args() + #self.print_error("args_actually_used",args,"kwarg_actually_used",kwargs) + ctr0 = self.ctr # read back current call counter to compare later for reentrancy detection + retval = self.func(*args, **kwargs) # and.. call the function. use latest invocation's args + was_reentrant = self.ctr != ctr0 # if ctr is not the same, func() led to a call this function! + del args, kwargs # deref args right away (allow them to get gc'd) + tf = time.time() + time_taken = tf-t0 + if self.ts_after: + self.last_ts = tf + else: + if time_taken > float(self.rate): + self.print_error("method took too long: {} > {}. Fudging timestamps to compensate.".format(time_taken, self.rate)) + self.last_ts = tf # Hmm. This function takes longer than its rate to complete. so mark its last run time as 'now'. This breaks the rate but at least prevents this function from starving the CPU (benforces a delay). + else: + self.last_ts = t0 # Function takes less than rate to complete, so mark its t0 as when we entered to keep the rate constant. + + if self.timer: # timer is not None if and only if we were a delayed (collated) invocation. + if was_reentrant: + # we got a reentrant call to this function as a result of calling func() above! re-schedule the timer. + self.print_error("*** detected a re-entrant call, re-starting timer") + time_left = float(self.rate) - (tf - self.last_ts) + self.timer.start(time_left*1e3) + else: + # We did not get a reentrant call, so kill the timer so subsequent calls can schedule the timer and/or call func() immediately. + self.kill_timer() + elif was_reentrant: + self.print_error("*** detected a re-entrant call") + + return retval + + +class RateLimiterClassLvl(RateLimiter): + ''' This RateLimiter object is used if classlevel=True is specified to the + @rate_limited decorator. It inserts the __RateLimiterClassLvl state object + on the class level and collates calls for all instances to not exceed rate. + + Each instance is guaranteed to receive at least 1 call and to have multiple + calls updated with the latest args for the final call. So for instance: + + a.foo(1) + a.foo(2) + b.foo(10) + b.foo(3) + + Would collate to a single 'class-level' call using 'rate': + + a.foo(2) # latest arg taken, collapsed to 1 call + b.foo(3) # latest arg taken, collapsed to 1 call + + ''' + + @classmethod + def invoke(cls, rate, ts_after, func, args, kwargs): + assert args and not isinstance(args[0], type), "@rate_limited decorator may not be used with static or class methods" + obj = args[0] + objcls = obj.__class__ + args = list(args) + args.insert(0, objcls) # prepend obj class to trick super.invoke() into making this state object be class-level. + return super(RateLimiterClassLvl, cls).invoke(rate, ts_after, func, args, kwargs) + + def _push_args(self, args, kwargs): + objcls, obj = args[0:2] + args = args[2:] + self.saved_args[obj] = (args, kwargs) + + def _pop_args(self): + weak_dict = self.saved_args + self.saved_args = Weak.KeyDictionary() + return (weak_dict,),dict() + + def _call_func_for_all(self, weak_dict): + for ref in weak_dict.keyrefs(): + obj = ref() + if obj: + args,kwargs = weak_dict[obj] + #self.print_error("calling for",obj.diagnostic_name() if hasattr(obj, "diagnostic_name") else obj,"timer=",bool(self.timer)) + self.func_target(obj, *args, **kwargs) + + def __init__(self, rate, ts_after, obj, func): + # note: obj here is really the __class__ of the obj because we prepended the class in our custom invoke() above. + super().__init__(rate, ts_after, obj, func) + self.func_target = func + self.func = self._call_func_for_all + self.saved_args = Weak.KeyDictionary() # we don't use a simple arg tuple, but instead an instance -> args,kwargs dictionary to store collated calls, per instance collated + +def rate_limited(rate, *, classlevel=False, ts_after=False): + """ A Function decorator for rate-limiting GUI event callbacks. Argument + rate in seconds is the minimum allowed time between subsequent calls of + this instance of the function. Calls that arrive more frequently than + rate seconds will be collated into a single call that is deferred onto + a QTimer. It is preferable to use this decorator on QObject subclass + instance methods. This decorator is particularly useful in limiting + frequent calls to GUI update functions. + + params: + rate - calls are collated to not exceed rate (in seconds) + classlevel - if True, specify that the calls should be collated at + 1 per `rate` secs. for *all* instances of a class, otherwise + calls will be collated on a per-instance basis. + ts_after - if True, mark the timestamp of the 'last call' AFTER the + target method completes. That is, the collation of calls will + ensure at least `rate` seconds will always elapse between + subsequent calls. If False, the timestamp is taken right before + the collated calls execute (thus ensuring a fixed period for + collated calls). + TL;DR: ts_after=True : `rate` defines the time interval you want + from last call's exit to entry into next + call. + ts_adter=False: `rate` defines the time between each + call's entry. + + (See on_fx_quotes & on_fx_history in main_window.py for example usages + of this decorator). """ + def wrapper0(func): + @wraps(func) + def wrapper(*args, **kwargs): + if classlevel: + return RateLimiterClassLvl.invoke(rate, ts_after, func, args, kwargs) + return RateLimiter.invoke(rate, ts_after, func, args, kwargs) + return wrapper + return wrapper0 + if __name__ == "__main__": app = QApplication([]) diff --git a/gui/qt/utxo_list.py b/gui/qt/utxo_list.py index 91ddc1b79..b4cc11951 100644 --- a/gui/qt/utxo_list.py +++ b/gui/qt/utxo_list.py @@ -33,46 +33,121 @@ def __init__(self, parent=None): MyTreeWidget.__init__(self, parent, self.create_menu, [ _('Address'), _('Label'), _('Amount'), _('Height'), _('Output point')], 1) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSortingEnabled(True) + # force attributes to always be defined, even if None, at construction. + self.wallet = self.parent.wallet if hasattr(self.parent, 'wallet') else None + self.utxos = list() def get_name(self, x): return x.get('prevout_hash') + ":%d"%x.get('prevout_n') def on_update(self): - self.wallet = self.parent.wallet - item = self.currentItem() + prev_selection = self.get_selected() # cache previous selection, if any self.clear() + self.wallet = self.parent.wallet self.utxos = self.wallet.get_utxos() for x in self.utxos: address = x.get('address') + address_text = address.to_ui_string() height = x.get('height') name = self.get_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 = SortableTreeWidgetItem([address_text, label, amount, '%d'%height, name[0:10] + '...' + name[-2:]]) + utxo_item.DataRole = Qt.UserRole+100 # set this here to avoid sorting based on Qt.UserRole+1 for i in range(5): utxo_item.setFont(i, QFont(MONOSPACE_FONT)) utxo_item.setTextAlignment(2, Qt.AlignRight) utxo_item.setTextAlignment(3, Qt.AlignRight) utxo_item.setData(0, Qt.UserRole, name) - if self.wallet.is_frozen(address): + a_frozen = self.wallet.is_frozen(address) + c_frozen = x['is_frozen_coin'] + if a_frozen and not c_frozen: + # address is frozen, coin is not frozen + # emulate the "Look" off the address_list .py's frozen entry + utxo_item.setBackground(0, QColor('lightblue')) + elif c_frozen and not a_frozen: + # coin is frozen, address is not frozen utxo_item.setBackground(0, ColorScheme.BLUE.as_color(True)) + elif c_frozen and a_frozen: + # both coin and address are frozen so color-code it to indicate that. + utxo_item.setBackground(0, QColor('lightblue')) + utxo_item.setForeground(0, QColor('#3399ff')) + # save the address-level-frozen and coin-level-frozen flags to the data item for retrieval later in create_menu() below. + utxo_item.setData(0, Qt.UserRole+1, "{}{}".format(("a" if a_frozen else ""), ("c" if c_frozen else ""))) self.addChild(utxo_item) + if name in prev_selection: + # NB: This needs to be here after the item is added to the widget. See #979. + utxo_item.setSelected(True) # restore previous selection + + def get_selected(self): + return { x.data(0, Qt.UserRole) : x.data(0, Qt.UserRole+1) # dict of "name" -> frozen flags string (eg: "ac") + for x in self.selectedItems() } def create_menu(self, position): - selected = [x.data(0, Qt.UserRole) for x in self.selectedItems()] + selected = self.get_selected() if not selected: return menu = QMenu() coins = filter(lambda x: self.get_name(x) in selected, self.utxos) - - menu.addAction(_("Spend"), lambda: self.parent.spend_coins(coins)) + spendable_coins = list(filter(lambda x: not selected.get(self.get_name(x), ''), coins)) + # Unconditionally add the "Spend" option but leave it disabled if there are no spendable_coins + menu.addAction(_("Spend"), lambda: self.parent.spend_coins(spendable_coins)).setEnabled(bool(spendable_coins)) if len(selected) == 1: - txid = selected[0].split(':')[0] + # single selection, offer them the "Details" option and also coin/address "freeze" status, if any + txid = list(selected.keys())[0].split(':')[0] + frozen_flags = list(selected.values())[0] tx = self.wallet.transactions.get(txid) - menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx)) - + if tx: + label = self.wallet.get_label(txid) or None + menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx, label)) + act = None + needsep = True + if 'c' in frozen_flags: + menu.addSeparator() + menu.addAction(_("Coin is frozen"), lambda: None).setEnabled(False) + menu.addAction(_("Unfreeze Coin"), lambda: self.set_frozen_coins(list(selected.keys()), False)) + menu.addSeparator() + needsep = False + else: + menu.addAction(_("Freeze Coin"), lambda: self.set_frozen_coins(list(selected.keys()), True)) + if 'a' in frozen_flags: + if needsep: menu.addSeparator() + menu.addAction(_("Address is frozen"), lambda: None).setEnabled(False) + menu.addAction(_("Unfreeze Address"), lambda: self.set_frozen_addresses_for_coins(list(selected.keys()), False)) + else: + menu.addAction(_("Freeze Address"), lambda: self.set_frozen_addresses_for_coins(list(selected.keys()), True)) + else: + # multi-selection + menu.addSeparator() + if any(['c' not in flags for flags in selected.values()]): + # they have some coin-level non-frozen in the selection, so add the menu action "Freeze coins" + menu.addAction(_("Freeze Coins"), lambda: self.set_frozen_coins(list(selected.keys()), True)) + if any(['c' in flags for flags in selected.values()]): + # they have some coin-level frozen in the selection, so add the menu action "Unfreeze coins" + menu.addAction(_("Unfreeze Coins"), lambda: self.set_frozen_coins(list(selected.keys()), False)) + if any(['a' not in flags for flags in selected.values()]): + # they have some address-level non-frozen in the selection, so add the menu action "Freeze addresses" + menu.addAction(_("Freeze Addresses"), lambda: self.set_frozen_addresses_for_coins(list(selected.keys()), True)) + if any(['a' in flags for flags in selected.values()]): + # they have some address-level frozen in the selection, so add the menu action "Unfreeze addresses" + menu.addAction(_("Unfreeze Addresses"), lambda: self.set_frozen_addresses_for_coins(list(selected.keys()), False)) + menu.exec_(self.viewport().mapToGlobal(position)) def on_permit_edit(self, item, column): # disable editing fields in this tab (labels) return False + + def set_frozen_coins(self, coins, b): + if self.parent: + self.parent.set_frozen_coin_state(coins, b) + + def set_frozen_addresses_for_coins(self, coins, b): + if not self.parent: return + addrs = set() + for utxo in self.utxos: + name = self.get_name(utxo) + if name in coins: + addrs.add(utxo['address']) + if addrs: + self.parent.set_frozen_state(list(addrs), b) diff --git a/gui/text.py b/gui/text.py index 06e1380d7..e23760702 100644 --- a/gui/text.py +++ b/gui/text.py @@ -149,7 +149,7 @@ def print_balance(self): self.stdscr.addstr(self.maxy -1, self.maxx-30, ' '.join([_("Settings"), _("Network"), _("Quit")])) def print_receive(self): - addr = self.wallet.get_receiving_address() + addr = self.wallet.get_receiving_address_text() self.stdscr.addstr(2, 1, "Address: "+addr) self.print_qr(addr) diff --git a/icons.qrc b/icons.qrc index f34988349..d3c5aa1cd 100644 --- a/icons.qrc +++ b/icons.qrc @@ -1,16 +1,18 @@ icons/electrum-zclassic.png - icons/clock1.png - icons/clock2.png - icons/clock3.png - icons/clock4.png - icons/clock5.png - icons/confirmed.png + icons/clock1.svg + icons/clock2.svg + icons/clock3.svg + icons/clock4.svg + icons/clock5.svg + icons/confirmed.svg icons/copy.png icons/digitalbitbox.png + icons/tab_slp_icon.png + icons/slp_logo_hollow.png icons/digitalbitbox_unpaired.png - icons/expired.png + icons/expired.svg icons/electrum_light_icon.png icons/electrum_dark_icon.png icons/file.png @@ -29,15 +31,21 @@ icons/preferences.png icons/seed.png icons/status_connected.png + icons/status_connected_fork.svg icons/status_connected_proxy.png + icons/status_connected_proxy_fork.svg icons/status_disconnected.png icons/status_waiting.png icons/status_lagging.png + icons/status_lagging_fork.svg icons/seal.png icons/tab_addresses.png icons/tab_coins.png icons/tab_console.png icons/tab_contacts.png + icons/tab_converter.svg + icons/tab_converter_bw.svg + icons/tab_converter_slp.svg icons/tab_history.png icons/tab_receive.png icons/tab_send.png @@ -45,8 +53,8 @@ icons/speaker.png icons/trezor_unpaired.png icons/trezor.png - icons/unconfirmed.png - icons/unpaid.png + icons/unconfirmed.svg + icons/unpaid.svg icons/unlock.png icons/warning.png icons/zoom.png diff --git a/icons/clock1.png b/icons/clock1.png deleted file mode 100644 index 448e47f94..000000000 Binary files a/icons/clock1.png and /dev/null differ diff --git a/icons/clock1.svg b/icons/clock1.svg new file mode 100644 index 000000000..a4d7858cf --- /dev/null +++ b/icons/clock1.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/clock2.png b/icons/clock2.png deleted file mode 100644 index c1a6e99f7..000000000 Binary files a/icons/clock2.png and /dev/null differ diff --git a/icons/clock2.svg b/icons/clock2.svg new file mode 100644 index 000000000..4ac61c63b --- /dev/null +++ b/icons/clock2.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/clock3.png b/icons/clock3.png deleted file mode 100644 index e429a402c..000000000 Binary files a/icons/clock3.png and /dev/null differ diff --git a/icons/clock3.svg b/icons/clock3.svg new file mode 100644 index 000000000..97369aac4 --- /dev/null +++ b/icons/clock3.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/clock4.png b/icons/clock4.png deleted file mode 100644 index ba036f47d..000000000 Binary files a/icons/clock4.png and /dev/null differ diff --git a/icons/clock4.svg b/icons/clock4.svg new file mode 100644 index 000000000..60d21eaaa --- /dev/null +++ b/icons/clock4.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/clock5.png b/icons/clock5.png deleted file mode 100644 index 411d7a78a..000000000 Binary files a/icons/clock5.png and /dev/null differ diff --git a/icons/clock5.svg b/icons/clock5.svg new file mode 100644 index 000000000..729df2885 --- /dev/null +++ b/icons/clock5.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/confirmed.png b/icons/confirmed.png deleted file mode 100644 index 901be189a..000000000 Binary files a/icons/confirmed.png and /dev/null differ diff --git a/icons/confirmed.svg b/icons/confirmed.svg new file mode 100644 index 000000000..710b3f8c3 --- /dev/null +++ b/icons/confirmed.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/expired.png b/icons/expired.png deleted file mode 100644 index 6400b8ba6..000000000 Binary files a/icons/expired.png and /dev/null differ diff --git a/icons/expired.svg b/icons/expired.svg new file mode 100644 index 000000000..81761ce96 --- /dev/null +++ b/icons/expired.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/slp_logo_hollow.png b/icons/slp_logo_hollow.png new file mode 100644 index 000000000..049daf5c9 Binary files /dev/null and b/icons/slp_logo_hollow.png differ diff --git a/icons/status_connected_fork.svg b/icons/status_connected_fork.svg new file mode 100644 index 000000000..a1a7483ab --- /dev/null +++ b/icons/status_connected_fork.svg @@ -0,0 +1,209 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Lapo Calamandrei + + + + + + + + record + media + + + + + Jakub Steiner + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/status_connected_proxy_fork.svg b/icons/status_connected_proxy_fork.svg new file mode 100644 index 000000000..62ec44a09 --- /dev/null +++ b/icons/status_connected_proxy_fork.svg @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Lapo Calamandrei + + + + + + + + record + media + + + + + Jakub Steiner + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/status_lagging_fork.svg b/icons/status_lagging_fork.svg new file mode 100644 index 000000000..62329c8d1 --- /dev/null +++ b/icons/status_lagging_fork.svg @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Lapo Calamandrei + + + + + + + + record + media + + + + + Jakub Steiner + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/tab_converter.svg b/icons/tab_converter.svg new file mode 100644 index 000000000..0a1ff052f --- /dev/null +++ b/icons/tab_converter.svg @@ -0,0 +1,257 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + Cash Addr Final Blue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/tab_converter_bw.svg b/icons/tab_converter_bw.svg new file mode 100644 index 000000000..05ba85c05 --- /dev/null +++ b/icons/tab_converter_bw.svg @@ -0,0 +1,688 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cash Addr Final Grey + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/tab_converter_slp.svg b/icons/tab_converter_slp.svg new file mode 100644 index 000000000..aeae93f1f --- /dev/null +++ b/icons/tab_converter_slp.svg @@ -0,0 +1,261 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + Cash Addr Final Green + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/tab_slp_icon.png b/icons/tab_slp_icon.png new file mode 100644 index 000000000..3a7b39ee1 Binary files /dev/null and b/icons/tab_slp_icon.png differ diff --git a/icons/unconfirmed.png b/icons/unconfirmed.png deleted file mode 100644 index f8d87ed5f..000000000 Binary files a/icons/unconfirmed.png and /dev/null differ diff --git a/icons/unpaid.png b/icons/unpaid.png deleted file mode 100644 index 579ec4eb5..000000000 Binary files a/icons/unpaid.png and /dev/null differ diff --git a/icons/unpaid.svg b/icons/unpaid.svg new file mode 100644 index 000000000..a94821c0c --- /dev/null +++ b/icons/unpaid.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/address.py b/lib/address.py new file mode 100644 index 000000000..c0a68d87b --- /dev/null +++ b/lib/address.py @@ -0,0 +1,812 @@ +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2017 The Electron Cash Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# Many of the functions in this file are copied from ElectrumX + +from collections import namedtuple +import hashlib +import struct + +from . import cashaddr, constants +from enum import IntEnum + +_sha256 = hashlib.sha256 +_new_hash = hashlib.new +hex_to_bytes = bytes.fromhex + + +class AddressError(Exception): + '''Exception used for Address errors.''' + +class ScriptError(Exception): + '''Exception used for Script errors.''' + + +# Derived from Zclassic script.h +class OpCodes(IntEnum): + # push value + OP_0 = 0x00 + OP_FALSE = OP_0 + OP_PUSHDATA1 = 0x4c + OP_PUSHDATA2 = 0x4d + OP_PUSHDATA4 = 0x4e + OP_1NEGATE = 0x4f + OP_RESERVED = 0x50 + OP_1 = 0x51 + OP_TRUE=OP_1 + OP_2 = 0x52 + OP_3 = 0x53 + OP_4 = 0x54 + OP_5 = 0x55 + OP_6 = 0x56 + OP_7 = 0x57 + OP_8 = 0x58 + OP_9 = 0x59 + OP_10 = 0x5a + OP_11 = 0x5b + OP_12 = 0x5c + OP_13 = 0x5d + OP_14 = 0x5e + OP_15 = 0x5f + OP_16 = 0x60 + + # control + OP_NOP = 0x61 + OP_VER = 0x62 + OP_IF = 0x63 + OP_NOTIF = 0x64 + OP_VERIF = 0x65 + OP_VERNOTIF = 0x66 + OP_ELSE = 0x67 + OP_ENDIF = 0x68 + OP_VERIFY = 0x69 + OP_RETURN = 0x6a + + # stack ops + OP_TOALTSTACK = 0x6b + OP_FROMALTSTACK = 0x6c + OP_2DROP = 0x6d + OP_2DUP = 0x6e + OP_3DUP = 0x6f + OP_2OVER = 0x70 + OP_2ROT = 0x71 + OP_2SWAP = 0x72 + OP_IFDUP = 0x73 + OP_DEPTH = 0x74 + OP_DROP = 0x75 + OP_DUP = 0x76 + OP_NIP = 0x77 + OP_OVER = 0x78 + OP_PICK = 0x79 + OP_ROLL = 0x7a + OP_ROT = 0x7b + OP_SWAP = 0x7c + OP_TUCK = 0x7d + + # splice ops + OP_CAT = 0x7e + OP_SUBSTR = 0x7f + OP_LEFT = 0x80 + OP_RIGHT = 0x81 + OP_SIZE = 0x82 + + # bit logic + OP_INVERT = 0x83 + OP_AND = 0x84 + OP_OR = 0x85 + OP_XOR = 0x86 + OP_EQUAL = 0x87 + OP_EQUALVERIFY = 0x88 + OP_RESERVED1 = 0x89 + OP_RESERVED2 = 0x8a + + # numeric + OP_1ADD = 0x8b + OP_1SUB = 0x8c + OP_2MUL = 0x8d + OP_2DIV = 0x8e + OP_NEGATE = 0x8f + OP_ABS = 0x90 + OP_NOT = 0x91 + OP_0NOTEQUAL = 0x92 + + OP_ADD = 0x93 + OP_SUB = 0x94 + OP_MUL = 0x95 + OP_DIV = 0x96 + OP_MOD = 0x97 + OP_LSHIFT = 0x98 + OP_RSHIFT = 0x99 + + OP_BOOLAND = 0x9a + OP_BOOLOR = 0x9b + OP_NUMEQUAL = 0x9c + OP_NUMEQUALVERIFY = 0x9d + OP_NUMNOTEQUAL = 0x9e + OP_LESSTHAN = 0x9f + OP_GREATERTHAN = 0xa0 + OP_LESSTHANOREQUAL = 0xa1 + OP_GREATERTHANOREQUAL = 0xa2 + OP_MIN = 0xa3 + OP_MAX = 0xa4 + + OP_WITHIN = 0xa5 + + # crypto + OP_RIPEMD160 = 0xa6 + OP_SHA1 = 0xa7 + OP_SHA256 = 0xa8 + OP_HASH160 = 0xa9 + OP_HASH256 = 0xaa + OP_CODESEPARATOR = 0xab + OP_CHECKSIG = 0xac + OP_CHECKSIGVERIFY = 0xad + OP_CHECKMULTISIG = 0xae + OP_CHECKMULTISIGVERIFY = 0xaf + + # expansion + OP_NOP1 = 0xb0 + OP_NOP2 = 0xb1 + OP_CHECKLOCKTIMEVERIFY = OP_NOP2 + OP_NOP3 = 0xb2 + OP_NOP4 = 0xb3 + OP_NOP5 = 0xb4 + OP_NOP6 = 0xb5 + OP_NOP7 = 0xb6 + OP_NOP8 = 0xb7 + OP_NOP9 = 0xb8 + OP_NOP10 = 0xb9 + + + # template matching params + OP_SMALLINTEGER = 0xfa + OP_PUBKEYS = 0xfb + OP_PUBKEYHASH = 0xfd + OP_PUBKEY = 0xfe + + OP_INVALIDOPCODE = 0xff + + +P2PKH_prefix = bytes([OpCodes.OP_DUP, OpCodes.OP_HASH160, 20]) +P2PKH_suffix = bytes([OpCodes.OP_EQUALVERIFY, OpCodes.OP_CHECKSIG]) + +P2SH_prefix = bytes([OpCodes.OP_HASH160, 20]) +P2SH_suffix = bytes([OpCodes.OP_EQUAL]) + +# Utility functions + +def to_bytes(x): + '''Convert to bytes which is hashable.''' + if isinstance(x, bytes): + return x + if isinstance(x, bytearray): + return bytes(x) + raise TypeError('{} is not bytes ({})'.format(x, type(x))) + +def hash_to_hex_str(x): + '''Convert a big-endian binary hash to displayed hex string. + + Display form of a binary hash is reversed and converted to hex. + ''' + return bytes(reversed(x)).hex() + +def hex_str_to_hash(x): + '''Convert a displayed hex string to a binary hash.''' + return bytes(reversed(hex_to_bytes(x))) + +def bytes_to_int(be_bytes): + '''Interprets a big-endian sequence of bytes as an integer''' + return int.from_bytes(be_bytes, 'big') + +def int_to_bytes(value): + '''Converts an integer to a big-endian sequence of bytes''' + return value.to_bytes((value.bit_length() + 7) // 8, 'big') + +def sha256(x): + '''Simple wrapper of hashlib sha256.''' + return _sha256(x).digest() + +def double_sha256(x): + '''SHA-256 of SHA-256, as used extensively in bitcoin.''' + return sha256(sha256(x)) + +def ripemd160(x): + '''Simple wrapper of hashlib ripemd160.''' + h = _new_hash('ripemd160') + h.update(x) + return h.digest() + +def hash160(x): + '''RIPEMD-160 of SHA-256. + + Used to make bitcoin addresses from pubkeys.''' + return ripemd160(sha256(x)) + + +class UnknownAddress(object): + + def to_ui_string(self): + return '' + + def __str__(self): + return self.to_ui_string() + + def __repr__(self): + return '' + + +class PublicKey(namedtuple("PublicKeyTuple", "pubkey")): + + @classmethod + def from_pubkey(cls, pubkey): + '''Create from a public key expressed as binary bytes.''' + cls.validate(pubkey) + return cls(to_bytes(pubkey)) + + @classmethod + def from_string(cls, string): + '''Create from a hex string.''' + return cls.from_pubkey(hex_to_bytes(string)) + + @classmethod + def validate(cls, pubkey, req_compressed=False): + if not isinstance(pubkey, (bytes, bytearray)): + raise AddressError('pubkey must be of bytes type, not {}'.format(type(pubkey))) + if len(pubkey) == 33 and pubkey[0] in (2, 3): + return # Compressed + if len(pubkey) == 65 and pubkey[0] == 4: + if not req_compressed: + return + raise AddressError('compressed public keys are required') + raise AddressError('invalid pubkey {}'.format(pubkey)) + + def to_Address(self): + '''Convert to an Address object.''' + return Address(hash160(self.pubkey), cls.ADDR_P2PKH) + + def to_ui_string(self): + '''Convert to a hexadecimal string.''' + return self.pubkey.hex() + + def to_script(self): + return Script.P2PK_script(self.pubkey) + + def __str__(self): + return self.to_ui_string() + + def __repr__(self): + return ''.format(self.__str__()) + + +class ScriptOutput(namedtuple("ScriptAddressTuple", "script")): + + @classmethod + def from_string(self, string): + '''Instantiate from a mixture of opcodes and raw data.''' + script = bytearray() + for word in string.split(): + if word.startswith('OP_'): + try: + opcode = OpCodes[word] + except KeyError: + raise AddressError('unknown opcode {}'.format(word)) + script.append(opcode) + elif word.lower().startswith(''): + script.extend([ OpCodes.OP_PUSHDATA1, OpCodes.OP_0 ]) + else: + import binascii + script.extend(Script.push_data(binascii.unhexlify(word))) + return ScriptOutput(bytes(script)) + + def to_ui_string(self, hex_only = False): + '''Convert to user-readable OP-codes (plus text), eg OP_RETURN (12) "Hello there!" + Or, to a hexadecimal string if that fails. + Note that this function is the inverse of from_string() only if called with hex_only = True!''' + if self.script and not hex_only: + try: + ret = '' + ops = Script.get_ops(self.script) + def lookup(x): + try: + return OpCodes(x).name + except ValueError: + return '('+str(x)+')' + for op in ops: + if ret: ret += ", " + if isinstance(op, tuple): + if op[1] is None: + ret += "" + else: + if hex_only: + friendlystring = None + else: + # Attempt to make a friendly string, or fail to hex + try: + # Ascii only + friendlystring = op[1].decode('ascii') # raises UnicodeDecodeError with bytes > 127. + + # Count ugly characters (that need escaping in python strings' repr()) + uglies = 0 + for b in op[1]: + if b < 0x20 or b == 0x7f: + uglies += 1 + # Less than half of characters may be ugly. + if 2*uglies >= len(op[1]): + friendlystring = None + except UnicodeDecodeError: + friendlystring = None + + if friendlystring is None: + ret += lookup(op[0]) + " " + op[1].hex() + else: + ret += lookup(op[0]) + " " + repr(friendlystring) + elif isinstance(op, int): + ret += lookup(op) + else: + ret += '[' + (op.hex() if isinstance(op, bytes) else str(op)) + ']' + return ret + except ScriptError: + # Truncated script -- so just default to normal 'hex' encoding below. + pass + return self.script.hex() + + def to_asm(self): + '''Convert to user-readable OP-codes (plus text), eg OP_RETURN (12) Hello there! + Or, to a hexadecimal string if that fails. + Note that this function is the inverse of from_string() only if called with hex_only = True!''' + if self.script: + try: + ret = '' + ops = Script.get_ops(self.script) + def lookup(x): + if not (x > OpCodes.OP_0 and x < OpCodes.OP_1NEGATE): # only display non-PUSHDATA opcodes 'NOT 0 < x < 79' + try: return OpCodes(x).name + except ValueError: return '' + return None + for op in ops: + if ret: ret += " " + if isinstance(op, tuple): + if lookup(op[0]) is not None: + ret += lookup(op[0]) + " " + op[1].hex() + elif op[1] is None: + ret += "" + else: + ret += op[1].hex() + elif isinstance(op, int): + ret += str(lookup(op)) # FIXME: Handle possible None return from lookup here! -Calin + else: + ret += '[' + (op.hex() if isinstance(op, bytes) else str(op)) + ']' + return ret + except ScriptError: + raise Exception("Truncated script.") + # Truncated script -- so just default to normal 'hex' encoding below. + pass + raise Exception("There is no script object to convert to ASM format.") + + def to_script(self): + return self.script + + def __str__(self): + return self.to_ui_string(True) + + def __repr__(self): + return ''.format(self.__str__()) + + +# A namedtuple for easy comparison and unique hashing +class Address(namedtuple("AddressTuple", "hash160 kind")): + + # Address kinds + ADDR_P2PKH = 0 + ADDR_P2SH = 1 + + # Address formats + FMT_ZCLASSIC = 0 + FMT_SLPADDR = 1 + + _NUM_FMTS = 2 # <-- Be sure to update this to be 1+ last format above! + + # Default to CashAddr using 'zslp' or 'zslptest' prefix + FMT_UI = FMT_SLPADDR + + def __new__(cls, hash160, kind): + assert kind in (cls.ADDR_P2PKH, cls.ADDR_P2SH) + hash160 = to_bytes(hash160) + assert len(hash160) == 20 + ret = super().__new__(cls, hash160, kind) + ret._addr2str_cache = [None] * cls._NUM_FMTS + return ret + + @classmethod + def show_cashaddr(cls, format): + if format == 1: + cls.FMT_UI = cls.FMT_SLPADDR + else: + cls.FMT_UI = cls.FMT_ZCLASSIC + + @classmethod + def from_slpaddr_string(cls, string, *, net=None): + '''Construct from a slpaddress string.''' + if net is None: net = constants.net + prefix = net.SLPADDR_PREFIX + if string.upper() == string: + prefix = prefix.upper() + if ':' not in string: + string = ':'.join([prefix, string]) + addr_prefix, kind, addr_hash = cashaddr.decode(string) + if addr_prefix != prefix: + raise AddressError('address has unexpected prefix {}' + .format(addr_prefix)) + if kind == cashaddr.PUBKEY_TYPE: + return cls(addr_hash, cls.ADDR_P2PKH) + elif kind == cashaddr.SCRIPT_TYPE: + return cls(addr_hash, cls.ADDR_P2SH) + else: + raise AddressError('address has unexpected kind {}'.format(kind)) + + @classmethod + def from_string(cls, string, *, net=None): + '''Construct from an address string.''' + if net is None: net = constants.net + if len(string) > 35: + try: + return cls.from_slpaddr_string(string, net=net) + except ValueError as e: + raise AddressError(str(e)) + + try: + raw = Base58.decode_check(string) + except Base58Error as e: + raise AddressError(str(e)) + + # Require version byte(s) plus hash160. + if len(raw) != 22: + raise AddressError('invalid address: {}'.format(string)) + + verbyte, hash160 = raw[0:2], raw[2:] + if verbyte in [net.ADDRTYPE_P2PKH]: + kind = cls.ADDR_P2PKH + elif verbyte in [net.ADDRTYPE_P2SH]: + kind = cls.ADDR_P2SH + else: + raise AddressError('unknown version byte: {}'.format(verbyte)) + + return cls(hash160, kind) + + @classmethod + def prefix_from_address_string(cls, string): + '''Get address prefix from address string which may be missing the prefix.''' + if len(string) > 35: + try: + cls.from_slpaddr_string(string) + return constants.net.SLPADDR_PREFIX + except: + pass + return '' + + @classmethod + def is_valid(cls, string, *, net=None): + if net is None: net = constants.net + try: + cls.from_string(string, net=net) + return True + except Exception: + return False + + @classmethod + def from_strings(cls, strings, *, net=None): + '''Construct a list from an iterable of strings.''' + if net is None: net = constants.net + return [cls.from_string(string, net=net) for string in strings] + + @classmethod + def from_pubkey(cls, pubkey): + '''Returns a P2PKH address from a public key. The public key can + be bytes or a hex string.''' + if isinstance(pubkey, str): + pubkey = hex_to_bytes(pubkey) + PublicKey.validate(pubkey) + return cls(hash160(pubkey), cls.ADDR_P2PKH) + + @classmethod + def from_P2PKH_hash(cls, hash160): + '''Construct from a P2PKH hash160.''' + return cls(hash160, cls.ADDR_P2PKH) + + @classmethod + def from_P2SH_hash(cls, hash160): + '''Construct from a P2PKH hash160.''' + return cls(hash160, cls.ADDR_P2SH) + + @classmethod + def from_multisig_script(cls, script): + return cls(hash160(script), cls.ADDR_P2SH) + + @classmethod + def to_strings(cls, fmt, addrs): + '''Construct a list of strings from an iterable of Address objects.''' + return [addr.to_string(fmt) for addr in addrs] + + def to_slpaddr(self, *, net=None): + if net is None: net = constants.net + if self.kind == self.ADDR_P2PKH: + kind = cashaddr.PUBKEY_TYPE + else: + kind = cashaddr.SCRIPT_TYPE + return cashaddr.encode(net.SLPADDR_PREFIX, kind, self.hash160) + + def to_string(self, fmt, *, net=None): + '''Converts to a string of the given format.''' + if net is None: net = constants.net + if net is constants.net: + try: + cached = self._addr2str_cache[fmt] + if cached: + return cached + except (IndexError, TypeError): + raise AddressError('unrecognised format') + + try: + cached = None + + if fmt == self.FMT_SLPADDR: + cached = self.to_slpaddr(net=net) + return cached + + if fmt == self.FMT_ZCLASSIC: + if self.kind == self.ADDR_P2PKH: + verbyte = net.ADDRTYPE_P2PKH + else: + verbyte = net.ADDRTYPE_P2SH + else: + # This should never be reached due to cache-lookup check above. But leaving it in as it's a harmless sanity check. + raise AddressError('unrecognised format') + + cached = Base58.encode_check(verbyte + self.hash160) + return cached + finally: + if cached and net is constants.net: + self._addr2str_cache[fmt] = cached + + def to_full_string(self, fmt, *, net=None): + '''Convert to text, with a URI prefix for cashaddr format.''' + if net is None: net = constants.net + text = self.to_string(fmt, net=net) + if fmt == self.FMT_SLPADDR: + text = ':'.join([net.SLPADDR_PREFIX, text]) + return text + + def to_ui_string(self, *, net=None): + '''Convert to text in the current UI format choice.''' + if net is None: net = constants.net + return self.to_string(self.FMT_UI, net=net) + + def to_full_ui_string(self, *, net=None): + '''Convert to text, with a URI prefix if cashaddr.''' + if net is None: net = constants.net + return self.to_full_string(self.FMT_UI, net=net) + + def to_URI_components(self, *, net=None): + '''Returns a (scheme, path) pair for building a URI.''' + if net is None: net = constants.net + scheme = "" + scheme2 = net.SLPADDR_PREFIX + path = self.to_ui_string(net=net) + if self.FMT_UI == self.FMT_SLPADDR: + scheme = scheme2 + return scheme, path + + def to_storage_string(self, *, net=None): + '''Convert to text in the storage format.''' + if net is None: net = constants.net + return self.to_string(self.FMT_ZCLASSIC, net=net) + + def to_script(self): + '''Return a binary script to pay to the address.''' + if self.kind == self.ADDR_P2PKH: + return Script.P2PKH_script(self.hash160) + else: + return Script.P2SH_script(self.hash160) + + def to_script_hex(self): + '''Return a script to pay to the address as a hex string.''' + return self.to_script().hex() + + def to_scripthash(self): + '''Returns the hash of the script in binary.''' + return sha256(self.to_script()) + + def to_scripthash_hex(self): + '''Like other bitcoin hashes this is reversed when written in hex.''' + return hash_to_hex_str(self.to_scripthash()) + + def __str__(self): + return self.to_ui_string() + + def __repr__(self): + return '
'.format(self.__str__()) + + +class Script(object): + + @classmethod + def P2SH_script(cls, hash160): + assert len(hash160) == 20 + return P2SH_prefix + hash160 + P2SH_suffix + + @classmethod + def P2PKH_script(cls, hash160): + assert len(hash160) == 20 + return P2PKH_prefix + hash160 + P2PKH_suffix + + @classmethod + def P2PK_script(cls, pubkey): + return cls.push_data(pubkey) + bytes([OpCodes.OP_CHECKSIG]) + + @classmethod + def multisig_script(cls, m, pubkeys): + '''Returns the script for a pay-to-multisig transaction.''' + n = len(pubkeys) + if not 1 <= m <= n <= 15: + raise ScriptError('{:d} of {:d} multisig script not possible' + .format(m, n)) + for pubkey in pubkeys: + PublicKey.validate(pubkey, req_compressed=True) + # See https://bitcoin.org/en/developer-guide + # 2 of 3 is: OP_2 pubkey1 pubkey2 pubkey3 OP_3 OP_CHECKMULTISIG + return (bytes([OpCodes.OP_1 + m - 1]) + + b''.join(cls.push_data(pubkey) for pubkey in pubkeys) + + bytes([OpCodes.OP_1 + n - 1, OpCodes.OP_CHECKMULTISIG])) + + @classmethod + def push_data(cls, data): + '''Returns the opcodes to push the data on the stack.''' + assert isinstance(data, (bytes, bytearray)) + + n = len(data) + if n < OpCodes.OP_PUSHDATA1: + return bytes([n]) + data + if n < 256: + return bytes([OpCodes.OP_PUSHDATA1, n]) + data + if n < 65536: + return bytes([OpCodes.OP_PUSHDATA2]) + struct.pack(' len(script): + raise IndexError + if dlen > 0: + op = (op, script[n:n + dlen]) + else: + op = (op, None) + n += dlen + + ops.append(op) + except Exception: + # Truncated script; e.g. tx_hash + # ebc9fa1196a59e192352d76c0f6e73167046b9d37b8302b6bb6968dfd279b767 + raise ScriptError('truncated script') + + return ops + + +class Base58Error(Exception): + '''Exception used for Base58 errors.''' + + +class Base58(object): + '''Class providing base 58 functionality.''' + + chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + assert len(chars) == 58 + cmap = {c: n for n, c in enumerate(chars)} + + @staticmethod + def char_value(c): + val = Base58.cmap.get(c) + if val is None: + raise Base58Error('invalid base 58 character "{}"'.format(c)) + return val + + @staticmethod + def decode(txt): + """Decodes txt into a big-endian bytearray.""" + if not isinstance(txt, str): + raise TypeError('a string is required') + + if not txt: + raise Base58Error('string cannot be empty') + + value = 0 + for c in txt: + value = value * 58 + Base58.char_value(c) + + result = int_to_bytes(value) + + # Prepend leading zero bytes if necessary + count = 0 + for c in txt: + if c != '1': + break + count += 1 + if count: + result = bytes(count) + result + + return result + + @staticmethod + def encode(be_bytes): + """Converts a big-endian bytearray into a base58 string.""" + value = bytes_to_int(be_bytes) + + txt = '' + while value: + value, mod = divmod(value, 58) + txt += Base58.chars[mod] + + for byte in be_bytes: + if byte != 0: + break + txt += '1' + + return txt[::-1] + + @staticmethod + def decode_check(txt): + '''Decodes a Base58Check-encoded string to a payload. The version + prefixes it.''' + be_bytes = Base58.decode(txt) + result, check = be_bytes[:-4], be_bytes[-4:] + if check != double_sha256(result)[:4]: + raise Base58Error('invalid base 58 checksum for {}'.format(txt)) + return result + + @staticmethod + def encode_check(payload): + """Encodes a payload bytearray (which includes the version byte(s)) + into a Base58Check string.""" + be_bytes = payload + double_sha256(payload)[:4] + return Base58.encode(be_bytes) diff --git a/lib/base_wizard.py b/lib/base_wizard.py index f0ecc73a1..76b04c65a 100644 --- a/lib/base_wizard.py +++ b/lib/base_wizard.py @@ -30,7 +30,7 @@ from . import bitcoin from . import keystore from .keystore import bip44_derivation -from .wallet import Imported_Wallet, Standard_Wallet, Multisig_Wallet, wallet_types +from .wallet import Imported_Wallet, Standard_Wallet, Slp_Standard_Wallet, Multisig_Wallet, wallet_types from .storage import STO_EV_USER_PW, STO_EV_XPUB_PW, get_derivation_used_for_hw_device_encryption from .i18n import _ from .util import UserCancelled, InvalidPassword @@ -88,20 +88,20 @@ def new(self): _("What kind of wallet do you want to create?") ]) wallet_kinds = [ - ('standard', _("Standard wallet")), - ('multisig', _("Multi-signature wallet")), - ('imported', _("Import Zclassic addresses or private keys")), + ('zslp_standard', _("Standard wallet")), + ('zslp_multisig', _("Multi-signature wallet")), + ('zslp_imported', _("Import Zclassic addresses or private keys")), ] choices = [pair for pair in wallet_kinds if pair[0] in wallet_types] self.choice_dialog(title=title, message=message, choices=choices, run_next=self.on_wallet_type) def on_wallet_type(self, choice): self.wallet_type = choice - if choice == 'standard': + if choice == 'zslp_standard': action = 'choose_keystore' - elif choice == 'multisig': + elif choice == 'zslp_multisig': action = 'choose_multisig' - elif choice == 'imported': + elif choice == 'zslp_imported': action = 'import_addresses_or_keys' self.run(action) @@ -114,26 +114,28 @@ def on_multisig(m, n): self.multisig_dialog(run_next=on_multisig) def choose_keystore(self): - assert self.wallet_type in ['standard', 'multisig'] + assert self.wallet_type in ['zslp_standard', 'zslp_multisig'] i = len(self.keystores) title = _('Add cosigner') + ' (%d of %d)'%(i+1, self.n) if self.wallet_type=='multisig' else _('Keystore') - if self.wallet_type =='standard' or i==0: + if self.wallet_type =='zslp_standard' or i==0: message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?') choices = [ ('choose_seed_type', _('Create a new seed')), ('restore_from_seed', _('I already have a seed')), ('restore_from_key', _('Use a master key')), ] - if not self.is_kivy: - choices.append(('choose_hw_device', _('Use a hardware device'))) + # Disable for SLP + # if not self.is_kivy: + # choices.append(('choose_hw_device', _('Use a hardware device'))) else: message = _('Add a cosigner to your multi-sig wallet') choices = [ ('restore_from_key', _('Enter cosigner key')), ('restore_from_seed', _('Enter cosigner seed')), ] - if not self.is_kivy: - choices.append(('choose_hw_device', _('Cosign with hardware device'))) + # Disable for SLP + # if not self.is_kivy: + # choices.append(('choose_hw_device', _('Cosign with hardware device'))) self.choice_dialog(title=title, message=message, choices=choices, run_next=self.run) @@ -163,7 +165,7 @@ def on_import(self, text): return self.run('create_wallet') def restore_from_key(self): - if self.wallet_type == 'standard': + if self.wallet_type == 'zslp_standard': v = keystore.is_master_key title = _("Create keystore from a master key") message = ' '.join([ @@ -281,7 +283,8 @@ def derivation_dialog(self, f): default = bip44_derivation(0, bip43_purpose=44) message = '\n'.join([ _('Enter your wallet derivation here.'), - _('If you are not sure what this is, leave this field unchanged.') + _('If you are not sure what this is, leave this field unchanged.'), + _("If you want the wallet to use SLP addresses use m/44'/465'/0'") ]) presets = ( ('legacy BIP44', bip44_derivation(0, bip43_purpose=44)), @@ -331,8 +334,7 @@ def passphrase_dialog(self, run_next): def restore_from_seed(self): self.opt_bip39 = True self.opt_ext = True - is_cosigning_seed = lambda x: bitcoin.seed_type(x) in ['standard'] - test = bitcoin.is_seed if self.wallet_type == 'standard' else is_cosigning_seed + test = bitcoin.is_seed if self.wallet_type == 'zslp_standard' else bitcoin.is_new_seed self.restore_seed_dialog(run_next=self.on_restore_seed, test=test) def on_restore_seed(self, seed, is_bip39, is_ext): @@ -365,14 +367,14 @@ def on_keystore(self, k): if has_xpub: from .bitcoin import xpub_type t1 = xpub_type(k.xpub) - if self.wallet_type == 'standard': + if self.wallet_type == 'zslp_standard': if has_xpub and t1 not in ['standard']: self.show_error(_('Wrong key type') + ' %s'%t1) self.run('choose_keystore') return self.keystores.append(k) self.run('create_wallet') - elif self.wallet_type == 'multisig': + elif self.wallet_type == 'slp_multisig': assert has_xpub if t1 not in ['standard']: self.show_error(_('Wrong key type') + ' %s'%t1) @@ -442,10 +444,12 @@ def on_password(self, password, *, encrypt_storage, if k.may_have_password(): k.update_password(None, password) if self.wallet_type == 'standard': + raise Exception('Wallet type is not handled in this version') + elif self.wallet_type == 'zslp_standard': self.storage.put('seed_type', self.seed_type) keys = self.keystores[0].dump() self.storage.put('keystore', keys) - self.wallet = Standard_Wallet(self.storage) + self.wallet = Slp_Standard_Wallet(self.storage) self.run('create_addresses') elif self.wallet_type == 'multisig': for i, k in enumerate(self.keystores): @@ -474,13 +478,13 @@ def choose_seed_type(self): ] self.choice_dialog(title=title, message=message, choices=choices, run_next=self.run) - def create_standard_seed(self): self.create_seed('standard') + def create_standard_seed(self): self.create_seed('bip39') def create_seed(self, seed_type): from . import mnemonic self.seed_type = seed_type - seed = mnemonic.Mnemonic('en').make_seed(self.seed_type) - self.opt_bip39 = False + seed = mnemonic.Mnemonic('en').make_seed() # self.seed_type) + self.opt_bip39 = True f = lambda x: self.request_passphrase(seed, x) self.show_seed_dialog(run_next=f, seed_text=seed) diff --git a/lib/bitcoin.py b/lib/bitcoin.py index 5b7b5ce4f..afbeffec3 100644 --- a/lib/bitcoin.py +++ b/lib/bitcoin.py @@ -223,6 +223,8 @@ def seed_type(x): return 'old' elif is_new_seed(x): return 'standard' + else: #TODO: ADD CHECK FOR VALID BIP39 HERE + return 'bip39' return '' is_seed = lambda x: bool(seed_type(x)) @@ -848,6 +850,14 @@ def serialize_xpub(xtype, c, cK, depth=0, fingerprint=b'\x00'*4, + bytes([depth]) + fingerprint + child_number + c + cK return EncodeBase58Check(xpub) +class InvalidXKey(BaseException): + pass + +class InvalidXKeyFormat(InvalidXKey): + pass + +class InvalidXKeyLength(InvalidXKey): + pass def deserialize_xkey(xkey, prv, *, net=None): if net is None: diff --git a/lib/bitcoinfiles.py b/lib/bitcoinfiles.py new file mode 100644 index 000000000..a35630379 --- /dev/null +++ b/lib/bitcoinfiles.py @@ -0,0 +1,409 @@ +""" +Creates and parses transactions that hold file data chunk at vout0 OP_RETURN. Multi-chunk uploads are handled using + vout1 as a pointer to the location of the next file chunk. Visit http://bitcoinfiles.com for more info. + +Multi-part file chunks are committed to the blockchain as transactions using the following sequence: +1. File is partitioned into 220 byte chunks. +2. The last file chunk remainder will be placed within the Metadata OP_RETURN message if there is sufficient room +3. The file is identified using the txid of the txn containing the Metadata OP_RETURN message (longest point in txn chain) +4. The chunks are resolved by traversing a chain of transactions from the Metadata txn backwards until the first data chunk is found + +Max message length to fit in 223 byte op_return relay limit: 204 bytes +""" +from .address import Address, ScriptOutput +from . import util +from . import bitcoin + +from .transaction import Transaction +from .bitcoin import TYPE_SCRIPT, TYPE_ADDRESS +from .address import Script, ScriptError, OpCodes +from enum import Enum +from .network import Network + +lokad_id = b"BFP\x00" + +class BfpParsingError(Exception): + pass + +class BfpUnsupportedBfpMsgType(BfpParsingError): + # Cannot parse OP_RETURN due to unrecognized version + # (may or may not be valid) + pass + +class BfpOpreturnError(Exception): + pass + + +# Exceptions during creation of SLP message. +class BfpSerializingError(Exception): + pass + +class BfpInvalidOutputMessage(BfpParsingError): + # This exception (and subclasses) marks a message as definitely invalid + # under SLP consensus rules. (either malformed SLP or just not SLP) + pass + +def make_bitcoinfile_chunk_opreturn(data: bytes): + pushes = [] + + # file chunk data + if data is None: + pushes.append(b'') + else: + if not isinstance(data, (bytes, bytearray)): + raise BfpSerializingError() + pushes.append(data) + + return chunksToOpreturnOutput(pushes) + +def make_bitcoinfile_metadata_opreturn(version: int, chunk_count: int, data: bytes = None, filename = None, fileext = None, filesize: int = None, filehash: bytes = None, prev_filehash: bytes = None, fileuri = None): + pushes = [] + + # lokad id + pushes.append(lokad_id) + + # version/type + pushes.append(version.to_bytes(1,'big')) + + # file chunk count + pushes.append(chunk_count.to_bytes(1,'big')) + + #filename + if filename is None or filename is '': + pushes.append(b'') + else: + pushes.append(filename.encode('utf-8')) + + # fileext + if fileext is None or fileext is '': + pushes.append(b'') + else: + pushes.append(fileext.encode('utf-8')) + + # filesize + if filesize is None: + pushes.append(b'') + else: + pushes.append(filesize.to_bytes(2,'big')) + + # filehash sha256 + if filehash is None or filehash is '': + pushes.append(b'') + else: + hashbytes = bytes.fromhex(filehash) + if len(hashbytes) not in (0, 32): + raise BfpSerializingError() + pushes.append(hashbytes) + + # previous sha256 filehash + if prev_filehash is None or prev_filehash is '': + pushes.append(b'') + else: + hashbytes = bytes.fromhex(prev_filehash) + if len(hashbytes) not in (0, 32): + raise BfpSerializingError() + pushes.append(hashbytes) + + # external URI + if fileuri is None or fileuri is '': + pushes.append(b'') + else: + pushes.append(fileuri.encode('utf-8')) + + # file chunk data + if data is None: + pushes.append(b'') + else: + if not isinstance(data, (bytes, bytearray)): + raise BfpSerializingError() + pushes.append(data) + + return chunksToOpreturnOutput(pushes) + +# utility for creation: use smallest push except not any of: op_0, op_1negate, op_1 to op_16 +def pushChunk(chunk: bytes) -> bytes: # allow_op_0 = False, allow_op_number = False + length = len(chunk) + if length == 0: + return b'\x4c\x00' + chunk + elif length < 76: + return bytes((length,)) + chunk + elif length < 256: + return bytes((0x4c,length,)) + chunk + elif length < 65536: # shouldn't happen but eh + return b'\x4d' + length.to_bytes(2, 'little') + chunk + elif length < 4294967296: # shouldn't happen but eh + return b'\x4e' + length.to_bytes(4, 'little') + chunk + else: + raise ValueError() + +def chunksToOpreturnOutput(chunks: [bytes]) -> tuple: + script = bytearray([0x6a,]) # start with OP_RETURN + for c in chunks: + script.extend(pushChunk(c)) + + if len(script) > 223: + raise OPReturnTooLarge('OP_RETURN message too large, cannot be larger than 223 bytes') + + return (TYPE_SCRIPT, ScriptOutput(bytes(script)), 0) + +def parseOpreturnToChunks(script: bytes, *, allow_op_0: bool, allow_op_number: bool): + """Extract pushed bytes after opreturn. Returns list of bytes() objects, + one per push. + + Strict refusal of non-push opcodes; bad scripts throw BfpOpreturnError.""" + try: + ops = Script.get_ops(script) + except ScriptError as e: + raise BfpOpreturnError('Script error') from e + + if ops[0] != OpCodes.OP_RETURN: + raise BfpOpreturnError('No OP_RETURN') + + chunks = [] + for opitem in ops[1:]: + op, data = opitem if isinstance(opitem, tuple) else (opitem, None) + if op > OpCodes.OP_16: + raise BfpOpreturnError('Non-push opcode') + if op > OpCodes.OP_PUSHDATA4: + if op == 80: + raise BfpOpreturnError('Non-push opcode') + if not allow_op_number: + raise BfpOpreturnError('OP_1NEGATE to OP_16 not allowed') + if op == OpCodes.OP_1NEGATE: + data = [0x81] + else: # OP_1 - OP_16 + data = [op-80] + if op == OpCodes.OP_0 and not allow_op_0: + raise BfpOpreturnError('OP_0 not allowed') + chunks.append(b'' if data is None else bytes(data)) + return chunks + +def parseChunkToInt(intBytes: bytes, minByteLen: int, maxByteLen: int, raise_on_Null: bool = False): + # Parse data as unsigned-big-endian encoded integer. + # For empty data different possibilities may occur: + # minByteLen <= 0 : return 0 + # raise_on_Null == False and minByteLen > 0: return None + # raise_on_Null == True and minByteLen > 0: raise BfpInvalidOutput + if len(intBytes) >= minByteLen and len(intBytes) <= maxByteLen: + return int.from_bytes(intBytes, 'big', signed=False) + if len(intBytes) == 0 and not raise_on_Null: + return None + raise BfpInvalidOutputMessage('File is not stored on the blockchain, or field has wrong length in BFP message.') + +def getUploadTxn(wallet, prev_tx, chunk_index, chunk_count, chunk_data, config, metadata, file_receiver: Address): + """ + NOTE: THIS METHOD ONLY WORKS WITH 1 TRANSACTION CURRENTLY, LIMITS SIZE TO 223 BYTES + """ + + # this flag is returned to indicate which upload message was used in txn + is_metadata_txn = False + + assert wallet.txin_type == 'p2pkh' + + if chunk_index == 0: + out_type, address, amount = prev_tx.outputs()[0] + assert out_type == 0 + vout = 0 + else: + out_type, address, amount = prev_tx.outputs()[1] + assert out_type == 0 + vout = 1 + + coins = [{ + 'address': address, + 'value': amount, + 'prevout_n': int(vout), + 'prevout_hash': prev_tx.txid(), + 'height': 0, + 'coinbase': False + }] + + final_op_return_no_chunk = make_bitcoinfile_metadata_opreturn(1, chunk_count, None, metadata['filename'], metadata['fileext'], metadata['filesize'], metadata['file_sha256'], metadata['prev_file_sha256'], metadata['uri']) + if chunk_data == None: + chunk_length = 0 + else: + chunk_length = len(chunk_data) + + # Check for scenario where last chunk can fit into Metadata message. Chunk may be data or None. + if chunk_index >= chunk_count - 1 and chunk_can_fit_in_final_opreturn(final_op_return_no_chunk, chunk_length): + is_metadata_txn = True + op_return = make_bitcoinfile_metadata_opreturn(1, chunk_count, chunk_data, metadata['filename'], metadata['fileext'], metadata['filesize'], metadata['file_sha256'], metadata['prev_file_sha256'], metadata['uri']) + miner_fee = estimate_miner_fee(1, 1, len(op_return[1].to_script())) + dust_output = (amount - miner_fee) if (amount - miner_fee) >= 546 else 546 + address = file_receiver if file_receiver != None else address + assert isinstance(address, Address) + askedoutputs = [ op_return, (TYPE_ADDRESS, address, dust_output) ] + + # Check for scenarios where Metadata message should not be used + else: + op_return = make_bitcoinfile_chunk_opreturn(chunk_data) + miner_fee = estimate_miner_fee(1, 1, len(op_return[1].to_script())) + dust_output = (amount - miner_fee) if (amount - miner_fee) >= 546 else 546 + askedoutputs = [ op_return, (TYPE_ADDRESS, address, dust_output) ] + + fee = None + change_addr = None + + tx = wallet.make_unsigned_transaction_for_bitcoinfiles(coins, askedoutputs, config, fee, change_addr) + + # unfortunately, the outputs might be in wrong order due to BIPLI01 + # output sorting, so we remake it. + outputs = tx.outputs() + outputs = askedoutputs + [o for o in outputs if o not in askedoutputs] + tx = Transaction.from_io(tx.inputs(), outputs, tx.locktime) + return tx, is_metadata_txn + +def chunk_can_fit_in_final_opreturn(final_op_return_no_chunk, chunk_data_length:int = 0): + if chunk_data_length == 0: + return True + op_return_min_length = len(final_op_return_no_chunk[1].to_script()) + op_return_capacity = 223 - op_return_min_length + if op_return_capacity >= chunk_data_length: + return True + return False + +def get_push_data_length(data_count): + if data_count > 75: + return data_count + 1 + else: + return data_count + 2 + +def estimate_miner_fee(p2pkh_input_count, p2pkh_output_count, opreturn_size, feerate = 1): + bytecount = (p2pkh_input_count * 148) + (p2pkh_output_count * 35) + opreturn_size + 22 + return bytecount * feerate + +def getFundingTxn(wallet, address, amount, config): + + assert wallet.txin_type == 'p2pkh' + + askedoutputs = [ (TYPE_ADDRESS, address, amount), ] + + # set config key 'confirmed_only' temporarily to True + domain = None + org_confirmed_only = config.get('confirmed_only', False) + config.set_key('confirmed_only', True) + + try: + coins = wallet.get_spendable_coins(domain, config) + fee = None + change_addr = None + tx = wallet.make_unsigned_transaction(coins, askedoutputs, config, fee, change_addr) + except util.NotEnoughFunds as e: + raise e + finally: + # Change 'confirmed_only' key back to original setting + config.set_key('confirmed_only', org_confirmed_only) + + # unfortunately, the outputs might be in wrong order due to BIPLI01 + # output sorting, so we remake it. + outputs = tx.outputs() + outputs = askedoutputs + [o for o in outputs if o not in askedoutputs] + tx = Transaction.from_io(tx.inputs(), outputs, tx.locktime) + + return tx + +def calculateUploadCost(file_size, metadata, fee_rate = 1): + byte_count = file_size + + whole_chunks_count = int(file_size / 220) + last_chunk_size = file_size % 220 + + if last_chunk_size > 0: + chunk_count = whole_chunks_count + last_chunk_size + else: + chunk_count = whole_chunks_count + + # cost of final transaction's op_return w/o any chunkdata + final_op_return_no_chunk = make_bitcoinfile_metadata_opreturn(1, chunk_count, None, metadata['filename'], metadata['fileext'], metadata['filesize'], metadata['file_sha256'], metadata['prev_file_sha256'], metadata['uri']) + byte_count += len(final_op_return_no_chunk[1].to_script()) + + # cost of final transaction's input/outputs + byte_count += 35 + byte_count += 148 + 1 + + # cost of chunk trasnsaction op_returns + byte_count += (whole_chunks_count + 1) * 3 + + if not chunk_can_fit_in_final_opreturn(final_op_return_no_chunk, last_chunk_size): + # add fees for an extra chunk transaction input/output + byte_count += 149 + 35 + # opcode cost for chunk op_return + byte_count += 16 + + # output p2pkh + byte_count += 35 * (whole_chunks_count) + + # dust input bytes (this is the initial payment for the file upload) + byte_count += (148 + 1) * whole_chunks_count + + # other unaccounted per txn + byte_count += 22 * (whole_chunks_count + 1) + + # dust output to be passed along each txn + dust_amount = 546 + + return byte_count * fee_rate + dust_amount + +class BfpMessage: + lokad_id = lokad_id + + def __init__(self): + self.msg_type = None + self.op_return_fields = {} + + def __repr__(self,): + return "<%s msg_type=%d %r %r>"%(type(self).__qualname__, self.msg_type, self.op_return_fields) + + # This method attempts to parse a ScriptOutput object as an BFP message. + # Bad scripts will throw a subclass of BfpParsingError; any other exception indicates a bug in this code. + # - Unrecognized SLP versions will throw BfpUnsupportedSlpTokenType. + # - It is a STRICT parser -- consensus-invalid messages will throw BfpInvalidOutputMessage. + # - Non-SLP scripts will also throw BfpInvalidOutputMessage. + @staticmethod + def parseBfpScriptOutput(outputScript: ScriptOutput): + bfpMsg = BfpMessage() + try: + chunks = parseOpreturnToChunks(outputScript.to_script(), allow_op_0 = False, allow_op_number = False) + except BfpOpreturnError as e: + raise BfpInvalidOutputMessage('Bad OP_RETURN', *e.args) from e + + if len(chunks) == 0: + raise BfpInvalidOutputMessage('Empty OP_RETURN') + + if chunks[0] != lokad_id: + raise BfpInvalidOutputMessage('Not BFP') + + if len(chunks) == 1: + raise BfpInvalidOutputMessage('Missing msg_type') + + bfpMsg.msg_type = parseChunkToInt(chunks[1], 1, 1, True) + if bfpMsg.msg_type != 1: + raise BfpUnsupportedBfpMsgType(bfpMsg.msg_type) + + if bfpMsg.msg_type == 1: + + if len(chunks) != 10: + raise BfpInvalidOutputMessage('On-Chain file BFP message with incorrect number of parameters') + + try: + bfpMsg.op_return_fields['chunk_count'] = parseChunkToInt(chunks[2], 1, 1, True) + except: + raise BfpInvalidOutputMessage('Bad chunk count') + + bfpMsg.op_return_fields['filename'] = chunks[3] + bfpMsg.op_return_fields['fileext'] = chunks[4] + bfpMsg.op_return_fields['size'] = parseChunkToInt(chunks[5], 0, 2, False) + + bfpMsg.op_return_fields['file_sha256'] = chunks[6] + if len(bfpMsg.op_return_fields['file_sha256']) not in (0, 32): + raise BfpInvalidOutputMessage('File Hash is incorrect length for sha256') + + bfpMsg.op_return_fields['prev_file_sha256'] = chunks[7] + if len(bfpMsg.op_return_fields['prev_file_sha256']) not in (0, 32): + raise BfpInvalidOutputMessage('Previous hash is incorrect length for sha256') + + bfpMsg.op_return_fields['uri'] = chunks[8] + bfpMsg.op_return_fields['chunk_data'] = chunks[9] + else: + raise BfpInvalidOutputMessage('Not a BFP metadata message') + return bfpMsg diff --git a/lib/blockchain.py b/lib/blockchain.py index eebcf9afc..a6210cc36 100644 --- a/lib/blockchain.py +++ b/lib/blockchain.py @@ -21,11 +21,11 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import os +import sys import threading from time import sleep from . import util -from . import bitcoin from . import constants from .bitcoin import * diff --git a/lib/caches.py b/lib/caches.py new file mode 100644 index 000000000..8fee77858 --- /dev/null +++ b/lib/caches.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +# +# Electron Cash - A Bitcoin Cash SPV Wallet +# +# This file Copyright (C) 2019 Calin Culianu +# License: MIT License +# +import time +import threading +import queue +import weakref +import math +from collections import defaultdict +from .util import PrintError, print_error + +class ExpiringCache: + ''' A fast cache useful for storing tens of thousands of lightweight items. + + Use this class to cache the results of functions or other computations + when: + + 1. Many identical items are repetitively created (or many duplicate + computations are repetitively performed) during normal app + execution, and it makes sense to cache them. + 2. The creation of said items is more computationally expensive than + accessing this cache. + 3. The memory tradeoff is acceptable. (As with all caches, you are + trading CPU cost for memory cost). + + An example of this is UI code or string formatting code that refreshes the + display with (mostly) the same output over and over again. In that case it + may make more sense to just cache the output items (such as the formatted + amount results from format_satoshis), rather than regenerate them, as a + performance tweak. + + ExpiringCache automatically has old items expire if `maxlen' is exceeded. + + Or, alternatively, if `timeout' is not None (and a positive nonzero number) + items are auto-removed if they are older than `timeout' seconds (even if + `maxlen' was otherwise not exceeded). Note that the actual timeout used + may be rounded up to match the tick granularity of the cache manager (see + below). + + Items are timestamped with a 'tick count' (granularity of 10 seconds per + tick). Their timestamp is updated each time they are accessed via `get' (so + that only the oldest items that are least useful are the first to expire on + cache overflow). + + get() and put() are fast. A background thread is used to safely + expire items when the cache overflows (so that get and put never stall + to manage the cache's size and/or to flush old items). This background + thread runs every 10 seconds -- so caches may temporarily overflow past + their maxlen for up to 10 seconds. ''' + def __init__(self, *, maxlen=10000, name="An Unnamed Cache", timeout=None): + assert maxlen > 0 + timeout = (isinstance(timeout, (float, int)) and timeout > 0.0 and timeout) or None + self.timeout_ticks = timeout and math.ceil(timeout/_ExpiringCacheMgr.tick_interval) + self.maxlen = maxlen + self.name = name + self.d = dict() + _ExpiringCacheMgr.add_cache(self) + def get(self, key, default=None): + res = self.d.get(key) + if res is not None: + # cache hit + res[0] = _ExpiringCacheMgr.tick # update tick access time for this cache hit + return res[1] + # cache miss + return default + def put(self, key, value): + self.d[key] = [_ExpiringCacheMgr.tick, value] + def size_bytes(self): + ''' Returns the cache's memory usage in bytes. This is done by doing a + deep, recursive examination of the cache contents. ''' + return get_object_size( + self.d.copy() # prevent iterating over a mutating dict. + ) + def copy_dict(self): + ''' Returns a copy of the cache contents. Useful for seriliazing + or otherwise examining the cache. The returned dict format is: + d[item_key] -> [tick, item_value]''' + return self.d.copy() + def __len__(self): + return len(self.d) + def __repr__(self): + name, address, length, maxlen, timeout = ( + self.name, '0x{:x}'.format(id(self)), len(self), self.maxlen, + ('{:1.1f}'.format(float(self.timeout_ticks * _ExpiringCacheMgr.tick_interval)) + if self.timeout_ticks + else self.timeout_ticks) + ) + return (f'<{__class__.__name__} "{name}" at {address}, {length} item{"s" if length != 1 else ""} (maxlen={maxlen} timeout={timeout})>') + +class _ExpiringCacheMgr(PrintError): + '''Do not use this class directly. Instead just create ExpiringCache + instances and that will handle the creation of this object automatically + and its lifecycle. + + This is a singleton that manages the ExpiringCaches. It creates a thread + that wakes up every tick_interval seconds and expires old items from + overflowing extant caches. + + Note that after the last cache is gc'd the manager thread will exit and + this singleton object also will expire and clean itself up automatically.''' + + # This lock is used to lock _instance and self.caches. + # NOTE: This lock *must* be a recursive lock as the gc callback function + # may end up executing in the same thread as our add_cache() method, + # due to the way Python GC works! + _lock = threading.RLock() + _instance = None + tick = 0 + tick_interval = 10.0 # seconds; we wake up this often to update 'tick' and also to expire old items for overflowing caches + debug = False # If true we print to console when caches expire and go away + + def __init__(self, add_iter=None): + cls = type(self) + assert not cls._instance, "_ExpiringCacheMgr is a singleton" + super().__init__() + cls._instance = self + self.q = queue.Queue() + self.caches = weakref.WeakSet() + if add_iter: + self.caches.update(add_iter) + self.livect = len(self.caches) # this is updated by add_cache and on_cache_gc below. + self.thread = threading.Thread(target=self.mgr_thread, daemon=True) + self.thread.start() + + @classmethod + def add_cache(cls, *caches): + assert caches + new_caches = caches + with cls._lock: + slf = cls._instance + if not slf: + slf = cls(caches) + assert slf == cls._instance + else: + new_caches = [c for c in caches if c not in slf.caches] + slf.caches.update(new_caches) + for cache in new_caches: + # add finalizer for each new cache + weakref.finalize(cache, cls.on_cache_gc, cache.name) + slf.livect = len(slf.caches) + + @classmethod + def on_cache_gc(cls, name): + assert cls._instance + thread2join = None + with cls._lock: + slf = cls._instance + slf.livect -= 1 # we need to keep this counter because the weak set doesn't have the correct length at this point yet. + if cls.debug: + slf.print_error("Cache '{}' has been gc'd, {} still alive".format(name, slf.livect)) + if not slf.livect: # all caches have been gc'd, kill the thread + if cls.debug: + slf.print_error("No more caches, stopping manager thread and removing singleton") + need2join = slf.thread.is_alive() + slf.q.put(None) # signal thread to stop + if need2join: + thread2join = slf.thread + elif cls.debug: + slf.print_error("Warning: Cache thread was stoppped before we had a chance to kill it") + cls._instance = None # kill self. + if thread2join and thread2join is not threading.current_thread(): + # we do this here as defensive programming to avoid deadlocks in case + # thread ends up taking locks in some future implementation. + thread2join.join() + + def mgr_thread(self): + cls = type(self) + #self.print_error("thread started") + try: + while True: + try: + x = self.q.get(timeout=self.tick_interval) + return # we got a stop signal + except queue.Empty: + # normal condition, we slept with nothing to do + pass + cls.tick += 1 + for c in tuple(self.caches): # prevent cache from dying while we iterate + # 1. timeout check (off by default unless client code specified a timeout) + if c.timeout_ticks and len(c.d) and 0 == (cls.tick % c.timeout_ticks): + # expire timed-out items first, if any. This check only runs every timeout_ticks ticks. + t0 = time.time() + num = cls._remove_timed_out_items(c.d, cls.tick - c.timeout_ticks) + tf = time.time() + if num: + self.print_error("{}: flushed {} timed-out items in {:.02f} msec".format(c.name, num, (tf-t0)*1e3)) + # 2. maxlen check (always on) + len_c = len(c.d) # capture length here as c.d may mutate and grow while this code executes. + if len_c > c.maxlen: + t0 = time.time() + num = cls._try_to_expire_old_items(c.d, len_c - c.maxlen) + tf = time.time() + self.print_error("{}: flushed {} items in {:.02f} msec".format(c.name, num, (tf-t0)*1e3)) + finally: + if cls.debug: + self.print_error("thread exit") + + @classmethod + def _try_to_expire_old_items(cls, d_orig, num): + d = d_orig.copy() # yes, this is slow but this makes it so we don't need locks. + if len(d) < num or num <= 0: + # cache modified from underneath our feet. We abort gracefully and complain. + print_error(f'[{__class__.__name__}] Cache data may have been removed by another thread. Aborting flush operation and will try again later...') + return 0 + + # bin the cache.dict items by 'tick' (when they were last accessed) + bins = defaultdict(list) + for k,v in d.items(): + tick = v[0] + bins[tick].append(k) + del d + + # Now, expire the old items starting with the oldest until we + # expire num items. Note that during this loop it's possible + # for items to get their timestamp updated by ExpiringCache.get(). + # This loop will not detect that situation and will expire them anyway. + # This is fine, because it's a corner case and in the interests of + # keeping this code as simple as possible, we don't bother to guard + # against that. + ct = 0 + sorted_bin_keys = sorted(bins.keys()) + while ct < num and bins: + tick = sorted_bin_keys[0] + for key in bins[tick]: + # KeyError here should never happen in normal use, but it + # may if client code is messing with the .d dict. + try: del d_orig[key] # despite appearances, this is atomic (thread-safe) + except KeyError: pass + ct += 1 + if ct >= num: + break + else: + del bins[tick] + del sorted_bin_keys[0] + return ct + + @classmethod + def _remove_timed_out_items(cls, d_orig, tick_cutoff): + d = d_orig.copy() # yes, this is slow but this makes it so we don't need locks. + if not len(d) or tick_cutoff < 0: + # cache modified from underneath our feet. We abort gracefully and complain. + print_error(f'[{__class__.__name__}] Cache data may have been removed by another thread. Aborting flush operation and will try again later...') + return 0 + + # scan the cache.dict for items whose 'tick' is older than tick_cutoff + ct = 0 + for k,v in d.items(): + tick = v[0] + if tick < tick_cutoff: + try: del d_orig[k] # despite appearances, this is atomic (thread-safe) + except KeyError: pass + ct += 1 + return ct + + +def get_object_size(obj_0): + ''' Debug tool -- returns the amount of memory taken by an object in bytes + by deeply examining its contents recursively (more accurate than + sys.getsizeof as a result). ''' + import sys + import warnings + from numbers import Number + from collections import Set, Mapping, deque + + try: # Python 2 + zero_depth_bases = (basestring, Number, xrange, bytearray) + iteritems = 'iteritems' + except NameError: # Python 3 + zero_depth_bases = (str, bytes, Number, range, bytearray) + iteritems = 'items' + + def getsize(obj_0): + """Recursively iterate to sum size of object & members.""" + _seen_ids = set() + def inner(obj): + obj_id = id(obj) + if obj_id in _seen_ids: + return 0 + _seen_ids.add(obj_id) + size = sys.getsizeof(obj) + if isinstance(obj, zero_depth_bases): + pass # bypass remaining control flow and return + elif isinstance(obj, (tuple, list, Set, deque)): + size += sum(inner(i) for i in obj) + elif isinstance(obj, Mapping) or hasattr(obj, iteritems): + try: + size += sum(inner(k) + inner(v) for k, v in getattr(obj, iteritems)()) + except Exception as e: + warnings.warn(f"warning: unable to process object '{obj}' due to exception: {repr(e)}", RuntimeWarning, stacklevel=2) + # Check for custom object instances - may subclass above too + if hasattr(obj, '__dict__'): + size += inner(vars(obj)) + if hasattr(obj, '__slots__'): # can have __slots__ with __dict__ + size += sum(inner(getattr(obj, s)) for s in obj.__slots__ if hasattr(obj, s)) + return size + return inner(obj_0) + return getsize(obj_0) diff --git a/lib/cashaddr.py b/lib/cashaddr.py new file mode 100644 index 000000000..e8033c5bf --- /dev/null +++ b/lib/cashaddr.py @@ -0,0 +1,201 @@ +# Copyright (c) 2017 Pieter Wuille +# Copyright (c) 2017 Shammah Chancellor, Neil Booth +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + +def _polymod(values): + """Internal function that computes the cashaddr checksum.""" + c = 1 + for d in values: + c0 = c >> 35 + c = ((c & 0x07ffffffff) << 5) ^ d + if (c0 & 0x01): + c ^= 0x98f2bc8e61 + if (c0 & 0x02): + c ^= 0x79b76d99e2 + if (c0 & 0x04): + c ^= 0xf33e5fb3c4 + if (c0 & 0x08): + c ^= 0xae2eabe2a8 + if (c0 & 0x10): + c ^= 0x1e4f43e470 + retval= c ^ 1 + return retval + +def _prefix_expand(prefix): + """Expand the prefix into values for checksum computation.""" + retval = bytearray(ord(x) & 0x1f for x in prefix) + # Append null separator + retval.append(0) + return retval + +def _create_checksum(prefix, data): + """Compute the checksum values given prefix and data.""" + values = _prefix_expand(prefix) + data + bytes(8) + polymod = _polymod(values) + # Return the polymod expanded into eight 5-bit elements + return bytes((polymod >> 5 * (7 - i)) & 31 for i in range(8)) + +def _convertbits(data, frombits, tobits, pad=True): + """General power-of-2 base conversion.""" + acc = 0 + bits = 0 + ret = bytearray() + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + acc = ((acc << frombits) | value ) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + + if pad and bits: + ret.append((acc << (tobits - bits)) & maxv) + + return ret + +def _pack_addr_data(kind, addr_hash): + """Pack addr data with version byte""" + version_byte = kind << 3 + + offset = 1 + encoded_size = 0 + if len(addr_hash) >= 40: + offset = 2 + encoded_size |= 0x04 + encoded_size |= (len(addr_hash) - 20 * offset) // (4 * offset) + + # invalid size? + if ((len(addr_hash) - 20 * offset) % (4 * offset) != 0 + or not 0 <= encoded_size <= 7): + raise ValueError('invalid address hash size {}'.format(addr_hash)) + + version_byte |= encoded_size + + data = bytes([version_byte]) + addr_hash + return _convertbits(data, 8, 5, True) + + +def _decode_payload(addr): + """Validate a cashaddr string. + + Throws CashAddr.Error if it is invalid, otherwise returns the + triple + + (prefix, payload) + + without the checksum. + """ + lower = addr.lower() + if lower != addr and addr.upper() != addr: + raise ValueError('mixed case in address: {}'.format(addr)) + + parts = lower.split(':', 1) + if len(parts) != 2: + raise ValueError("address missing ':' separator: {}".format(addr)) + + prefix, payload = parts + if not prefix: + raise ValueError('address prefix is missing: {}'.format(addr)) + if not all(33 <= ord(x) <= 126 for x in prefix): + raise ValueError('invalid address prefix: {}'.format(prefix)) + if not (8 <= len(payload) <= 124): + raise ValueError('address payload has invalid length: {}' + .format(len(addr))) + try: + data = bytes(_CHARSET.find(x) for x in payload) + except ValueError: + raise ValueError('invalid characters in address: {}' + .format(payload)) + + if _polymod(_prefix_expand(prefix) + data): + raise ValueError('invalid checksum in address: {}'.format(addr)) + + if lower != addr: + prefix = prefix.upper() + + # Drop the 40 bit checksum + return prefix, data[:-8] + +# +# External Interface +# + +PUBKEY_TYPE = 0 +SCRIPT_TYPE = 1 + +def decode(address): + '''Given a cashaddr address, return a triple + + (prefix, kind, hash) + ''' + if not isinstance(address, str): + raise TypeError('address must be a string') + + prefix, payload = _decode_payload(address) + + # Ensure there isn't extra padding + extrabits = len(payload) * 5 % 8 + if extrabits >= 5: + raise ValueError('excess padding in address {}'.format(address)) + + # Ensure extrabits are zeros + if payload[-1] & ((1 << extrabits) - 1): + raise ValueError('non-zero padding in address {}'.format(address)) + + decoded = _convertbits(payload, 5, 8, False) + version = decoded[0] + addr_hash = bytes(decoded[1:]) + size = (version & 0x03) * 4 + 20 + # Double the size, if the 3rd bit is on. + if version & 0x04: + size <<= 1 + if size != len(addr_hash): + raise ValueError('address hash has length {} but expected {}' + .format(len(addr_hash), size)) + + kind = version >> 3 + if kind not in (SCRIPT_TYPE, PUBKEY_TYPE): + raise ValueError('unrecognised address type {}'.format(kind)) + + return prefix, kind, addr_hash + + +def encode(prefix, kind, addr_hash): + """Encode a cashaddr address without prefix and separator.""" + if not isinstance(prefix, str): + raise TypeError('prefix must be a string') + + if not isinstance(addr_hash, (bytes, bytearray)): + raise TypeError('addr_hash must be binary bytes') + + if kind not in (SCRIPT_TYPE, PUBKEY_TYPE): + raise ValueError('unrecognised address type {}'.format(kind)) + + payload = _pack_addr_data(kind, addr_hash) + checksum = _create_checksum(prefix, payload) + return ''.join([_CHARSET[d] for d in (payload + checksum)]) + + +def encode_full(prefix, kind, addr_hash): + """Encode a full cashaddr address, with prefix and separator.""" + return ':'.join([prefix, encode(prefix, kind, addr_hash)]) diff --git a/lib/coinchooser.py b/lib/coinchooser.py index dd9a4ea10..a7719c07f 100644 --- a/lib/coinchooser.py +++ b/lib/coinchooser.py @@ -179,10 +179,10 @@ def change_outputs(self, tx, change_addrs, fee_estimator, dust_threshold): self.print_error('change:', change) if dust: self.print_error('not keeping dust', dust) - return change + return change, dust def make_tx(self, coins, outputs, change_addrs, fee_estimator, - dust_threshold): + dust_threshold, *, mandatory_coins=[]): """Select unspent coins to spend to pay outputs. If the change is greater than dust_threshold (after adding the change output to the transaction) it is kept, otherwise none is sent and it is @@ -191,12 +191,18 @@ def make_tx(self, coins, outputs, change_addrs, fee_estimator, Note: fee_estimator expects virtual bytes """ + # Remove mandatory_coin items from coin chooser's list + for c in mandatory_coins: + for coin in coins.copy(): + if coin['prevout_hash'] == c['prevout_hash'] and coin['prevout_n'] == c['prevout_n']: + coins.remove(coin) + # Deterministic randomness from coins utxos = [c['prevout_hash'] + str(c['prevout_n']) for c in coins] self.p = PRNG(''.join(sorted(utxos))) # Copy the outputs so when adding change we don't modify "outputs" - tx = Transaction.from_io([], outputs[:]) + tx = Transaction.from_io([], outputs) # Weight of the transaction with no inputs and no change # Note: this will use legacy tx serialization. The only side effect # should be that the marker and flag are excluded, which is @@ -214,8 +220,10 @@ def get_tx_weight(buckets): def sufficient_funds(buckets): '''Given a list of buckets, return True if it has enough value to pay for the transaction''' - total_input = sum(bucket.value for bucket in buckets) - total_weight = get_tx_weight(buckets) + mandatory_coins_bucket = self.bucketize_coins(mandatory_coins) + mandatory_input = sum(coin.value for coin in mandatory_coins_bucket) + total_input = sum(bucket.value for bucket in buckets) + mandatory_input + total_weight = get_tx_weight(buckets + mandatory_coins_bucket) return total_input >= spent_amount + fee_estimator_w(total_weight) # Collect the coins into buckets, choose a subset of the buckets @@ -223,8 +231,10 @@ def sufficient_funds(buckets): buckets = self.choose_buckets(buckets, sufficient_funds, self.penalty_func(tx)) + tx.add_inputs(mandatory_coins) tx.add_inputs([coin for b in buckets for coin in b.coins]) - tx_weight = get_tx_weight(buckets) + slp_weight = get_tx_weight(self.bucketize_coins(mandatory_coins)) + tx_weight = get_tx_weight(buckets) + slp_weight # change is sent back to sending address unless specified if not change_addrs: @@ -236,8 +246,9 @@ def sufficient_funds(buckets): # This takes a count of change outputs and returns a tx fee output_weight = 4 * Transaction.estimated_output_size(change_addrs[0]) fee = lambda count: fee_estimator_w(tx_weight + count * output_weight) - change = self.change_outputs(tx, change_addrs, fee, dust_threshold) + change, dust = self.change_outputs(tx, change_addrs, fee, dust_threshold) tx.add_outputs(change) + tx.ephemeral['dust_to_fee'] = dust self.print_error("using %d inputs" % len(tx.inputs())) self.print_error("using buckets:", [bucket.desc for bucket in buckets]) @@ -248,6 +259,10 @@ def choose_buckets(self, buckets, sufficient_funds, penalty_func): raise NotImplemented('To be subclassed') +# class CoinChooserSlp(CoinChooserBase): +# def choose_buckets(self, buckets, sufficient_funds, penalty_func): + + class CoinChooserRandom(CoinChooserBase): def bucket_candidates_any(self, buckets, sufficient_funds): @@ -301,7 +316,6 @@ def bucket_candidates_prefer_confirmed(self, buckets, sufficient_funds): bucket_sets = [conf_buckets, unconf_buckets, other_buckets] already_selected_buckets = [] - for bkts_choose_from in bucket_sets: try: def sfunds(bkts): diff --git a/lib/commands.py b/lib/commands.py index 5fb6027dc..33617ef0d 100644 --- a/lib/commands.py +++ b/lib/commands.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # Electrum - lightweight ZClassic client # Copyright (C) 2011 thomasv@gitorious @@ -145,6 +145,11 @@ def password(self, password=None, new_password=None): self.wallet.storage.write() return {'password':self.wallet.has_password()} + @command('w') + def get(self, key): + """Return item from wallet storage""" + return self.wallet.storage.get(key) + @command('') def getconfig(self, key): """Return a configuration variable. """ @@ -278,12 +283,14 @@ def unfreeze(self, address): @command('wp') def getprivatekeys(self, address, password=None): """Get private keys of addresses. You may pass a single wallet address, or a list of wallet addresses.""" + def get_pk(address): + addr = Address.from_string(address.strip()) + return self.wallet.export_private_key(addr, password) + if isinstance(address, str): - address = address.strip() - if is_address(address): - return self.wallet.export_private_key(address, password)[0] - domain = address - return [self.wallet.export_private_key(address, password)[0] for address in domain] + return get_pk(address) + else: + return [get_pk(addr) for addr in address] @command('w') def ismine(self, address): @@ -521,6 +528,44 @@ def gettransaction(self, txid): raise Exception("Unknown transaction") return tx.as_dict() + @command('wn') + def slpvalidate(self, txid, debug, reset): # Wish I could make debug, reset as optional but EC console doesn't allow. >_> + """ + (Temporary crude command) + SLP-validate a transaction. Will run in main thread so this will block + until finished! + """ + + from . import slp_validator_0x01 + from queue import Queue, Empty + + q = Queue() + + if self.wallet and txid in self.wallet.transactions: + tx = self.wallet.transactions[txid] + else: + raw = self.network.synchronous_get(('blockchain.transaction.get', [txid])) + if raw: + tx = Transaction(raw) + else: + raise BaseException("Unknown transaction") + + if debug: + print("Debug info will be printed to stderr.") + job = slp_validator_0x01.make_job(tx, self.wallet, self.network, + debug=2, reset=reset) + job.add_callback(q.put, way='weakmethod') + try: + q.get(timeout=3) + except Empty: + print("Validation job taking too long. Returning now as to not freeze UI for too long!") + print("(returned job is still running in background)") + return job + + n = next(iter(job.nodes.values())) + validity_name = job.graph.validator.validity_states[n.validity] + return validity_name + @command('') def encrypt(self, pubkey, message): """Encrypt a message with a public key. Use quotes if the message contains whitespaces.""" @@ -643,7 +688,7 @@ def callback(x): util.print_error('Got Response for %s' % address) except BaseException as e: util.print_error(str(e)) - h = self.network.addr_to_scripthash(address) + h = Address.from_string(address).address_to_scripthash_hex() self.network.send([('blockchain.scripthash.subscribe', [h])], callback) return True diff --git a/lib/constants.py b/lib/constants.py index d8d90bc9f..be4b08d1e 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -43,6 +43,7 @@ class BitcoinMainnet: WIF_PREFIX = 0x80 ADDRTYPE_P2PKH = bytes.fromhex('1CB8') ADDRTYPE_P2SH = bytes.fromhex('1CBD') + SLPADDR_PREFIX = 'zslp' GENESIS = "0007104ccda289427919efc39dc9e4d499804b7bebc22df55f8b834301260602" DEFAULT_PORTS = {'t': '50001', 's': '50002'} DEFAULT_SERVERS = read_json('servers.json', {}) @@ -63,6 +64,7 @@ class BitcoinTestnet: WIF_PREFIX = 0xEF ADDRTYPE_P2PKH = bytes.fromhex('1D25') ADDRTYPE_P2SH = bytes.fromhex('1CBA') + SLPADDR_PREFIX = 'zslptest' GENESIS = "03e1c4bb705c871bf9bfda3e74b7f8f86bff267993c215a89d5795e3708e5e1f" DEFAULT_PORTS = {'t': '51021', 's': '51022'} DEFAULT_SERVERS = read_json('servers_testnet.json', {}) diff --git a/lib/daemon.py b/lib/daemon.py index 51d52d725..3e23f4546 100644 --- a/lib/daemon.py +++ b/lib/daemon.py @@ -213,13 +213,13 @@ def run_daemon(self, config_options): def run_gui(self, config_options): config = SimpleConfig(config_options) if self.gui: - #if hasattr(self.gui, 'new_window'): - # path = config.get_wallet_path() - # self.gui.new_window(path, config.get('url')) - # response = "ok" - #else: - # response = "error: current GUI does not support multiple windows" - response = "error: Electrum GUI already running" + if hasattr(self.gui, 'new_window'): + config.open_last_wallet() + path = config.get_wallet_path() + self.gui.new_window(path, config.get('url')) + response = "ok" + else: + response = "error: current GUI does not support multiple windows" else: response = "Error: Electrum is running in daemon mode. Please stop the daemon first." return response @@ -254,6 +254,13 @@ def add_wallet(self, wallet): def get_wallet(self, path): return self.wallets.get(path) + def delete_wallet(self, path): + self.stop_wallet(path) + if os.path.exists(path): + os.unlink(path) + return True + return False + def stop_wallet(self, path): wallet = self.wallets.pop(path) wallet.stop_threads() diff --git a/lib/enum.py b/lib/enum.py new file mode 100644 index 000000000..cbc5f5076 --- /dev/null +++ b/lib/enum.py @@ -0,0 +1,46 @@ +# Copyright (c) 2016, Neil Booth +# +# All rights reserved. +# +# See the file "LICENCE" for information about the copyright +# and warranty status of this software. + +# enum-like type +# From the Python Cookbook, downloaded from http://code.activestate.com/recipes/67107/ +class EnumException(Exception): + pass + + +class Enumeration: + def __init__(self, name, enumList): + self.__doc__ = name + lookup = { } + reverseLookup = { } + i = 0 + uniqueNames = [ ] + uniqueValues = [ ] + for x in enumList: + if isinstance(x, tuple): + x, i = x + if not isinstance(x, str): + raise EnumException("enum name is not a string: " + x) + if not isinstance(i, int): + raise EnumException("enum value is not an integer: " + i) + if x in uniqueNames: + raise EnumException("enum name is not unique: " + x) + if i in uniqueValues: + raise EnumException("enum value is not unique for " + x) + uniqueNames.append(x) + uniqueValues.append(i) + lookup[x] = i + reverseLookup[i] = x + i = i + 1 + self.lookup = lookup + self.reverseLookup = reverseLookup + + def __getattr__(self, attr): + if attr not in self.lookup: + raise AttributeError + return self.lookup[attr] + def whatis(self, value): + return self.reverseLookup[value] diff --git a/lib/keystore.py b/lib/keystore.py index 99ac1174e..0c46533fe 100644 --- a/lib/keystore.py +++ b/lib/keystore.py @@ -128,7 +128,7 @@ def get_master_public_key(self): def dump(self): return { - 'type': 'imported', + 'type': 'bip32', 'keypairs': self.keypairs, } @@ -707,7 +707,7 @@ def is_private_key_list(text): def bip44_derivation(account_id, bip43_purpose=44): - coin = 1 if constants.net.TESTNET else 147 + coin = 1 if constants.net.TESTNET else 465 return "m/%d'/%d'/%d'" % (bip43_purpose, coin, int(account_id)) def from_seed(seed, passphrase, is_p2sh): @@ -723,6 +723,14 @@ def from_seed(seed, passphrase, is_p2sh): der = "m/" xtype = 'standard' keystore.add_xprv_from_seed(bip32_seed, xtype, der) + elif t == 'bip39': + keystore = BIP32_KeyStore({}) + keystore.add_seed(seed) + keystore.passphrase = passphrase + bip32_seed = Mnemonic.mnemonic_to_seed(seed, passphrase) + der = "m/44'/465'/0'" + xtype = 'standard' + keystore.add_xprv_from_seed(bip32_seed, xtype, der) else: raise BitcoinException('Unexpected seed type {}'.format(t)) return keystore diff --git a/lib/mnemonic.py b/lib/mnemonic.py index 45a8ebde0..54e35fe79 100644 --- a/lib/mnemonic.py +++ b/lib/mnemonic.py @@ -22,7 +22,7 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import os +import os, sys import hmac import math import hashlib @@ -31,10 +31,10 @@ import ecdsa import pbkdf2 +import binascii from .util import print_error from .bitcoin import is_old_seed, is_new_seed -from . import version # http://www.asahi-net.or.jp/~ax2s-kmtn/ref/unicode/e_asia.html CJK_INTERVALS = [ @@ -116,7 +116,7 @@ def load_wordlist(filename): class Mnemonic(object): - # Seed derivation no longer follows BIP39 + # Seed derivation follows BIP39 # Mnemonic phrase uses a hash based checksum, instead of a wordlist-dependent checksum def __init__(self, lang=None): @@ -126,12 +126,44 @@ def __init__(self, lang=None): self.wordlist = load_wordlist(filename) print_error("wordlist has %d words"%len(self.wordlist)) + @classmethod + def _get_directory(cls): + return os.path.join(os.path.dirname(__file__), 'wordlist') + + @classmethod + def list_languages(cls): + return [f.split('.')[0] for f in os.listdir(cls._get_directory()) if f.endswith('.txt')] + + @classmethod + def normalize_string(cls, txt): + if isinstance(txt, str if sys.version < '3' else bytes): + utxt = txt.decode('utf8') + elif isinstance(txt, unicode if sys.version < '3' else str): # noqa: F821 + utxt = txt + else: + raise TypeError("String value expected") + + return unicodedata.normalize('NFKD', utxt) + + @classmethod + def detect_language(cls, code): + code = cls.normalize_string(code) + first = code.split(' ')[0] + languages = cls.list_languages() + + for lang in languages: + mnemo = cls(lang) + if first in mnemo.wordlist: + return lang + + raise ConfigurationError("Language not detected") + @classmethod def mnemonic_to_seed(self, mnemonic, passphrase): PBKDF2_ROUNDS = 2048 mnemonic = normalize_text(mnemonic) passphrase = normalize_text(passphrase) - return pbkdf2.PBKDF2(mnemonic, 'electrum' + passphrase, iterations = PBKDF2_ROUNDS, macmodule = hmac, digestmodule = hashlib.sha512).read(64) + return pbkdf2.PBKDF2(mnemonic, 'mnemonic' + passphrase, iterations = PBKDF2_ROUNDS, macmodule = hmac, digestmodule = hashlib.sha512).read(64) def mnemonic_encode(self, i): n = len(self.wordlist) @@ -142,11 +174,6 @@ def mnemonic_encode(self, i): words.append(self.wordlist[x]) return ' '.join(words) - def get_suggestions(self, prefix): - for w in self.wordlist: - if w.startswith(prefix): - yield w - def mnemonic_decode(self, seed): n = len(self.wordlist) words = seed.split() @@ -157,27 +184,23 @@ def mnemonic_decode(self, seed): i = i*n + k return i - def make_seed(self, seed_type='standard', num_bits=132): - prefix = version.seed_prefix(seed_type) - # increase num_bits in order to obtain a uniform distribution for the last word - bpw = math.log(len(self.wordlist), 2) - # rounding - n = int(math.ceil(num_bits/bpw) * bpw) - print_error("make_seed. prefix: '%s'"%prefix, "entropy: %d bits"%n) - entropy = 1 - while entropy < pow(2, n - bpw): - # try again if seed would not contain enough words - entropy = ecdsa.util.randrange(pow(2, n)) - nonce = 0 - while True: - nonce += 1 - i = entropy + nonce - seed = self.mnemonic_encode(i) - if i != self.mnemonic_decode(seed): - raise Exception('Cannot extract same entropy from mnemonic!') - if is_old_seed(seed): - continue - if is_new_seed(seed, prefix): - break - print_error('%d words'%len(seed.split())) - return seed + def make_seed(self, num_bits=128, custom_entropy=1): + if num_bits not in [128, 160, 192, 224, 256]: + raise ValueError('Strength should be one of the following [128, 160, 192, 224, 256], but it is not (%d).' % num_bits) + return self.to_mnemonic(os.urandom(num_bits // 8)) + + def to_mnemonic(self, data): + if len(data) not in [16, 20, 24, 28, 32]: + raise ValueError('Data length should be one of the following: [16, 20, 24, 28, 32], but it is not (%d).' % len(data)) + h = hashlib.sha256(data).hexdigest() + b = bin(int(binascii.hexlify(data), 16))[2:].zfill(len(data) * 8) + \ + bin(int(h, 16))[2:].zfill(256)[:len(data) * 8 // 32] + result = [] + for i in range(len(b) // 11): + idx = int(b[i * 11:(i + 1) * 11], 2) + result.append(self.wordlist[idx]) + if self.detect_language(' '.join(result)) == 'japanese': # Japanese must be joined by ideographic space. + result_phrase = u'\u3000'.join(result) + else: + result_phrase = ' '.join(result) + return result_phrase diff --git a/lib/network.py b/lib/network.py index 2cab5edb2..26b5b5b32 100644 --- a/lib/network.py +++ b/lib/network.py @@ -187,6 +187,8 @@ def __init__(self, config=None): if not self.default_server: self.default_server = pick_random_server() self.lock = threading.Lock() + self.pending_sends_lock = threading.Lock() + self.pending_sends = [] self.message_id = 0 self.debug = False @@ -209,7 +211,6 @@ def __init__(self, config=None): # subscriptions and requests self.subscribed_addresses = set() - self.h2addr = {} # Requests from client we've not seen a response to self.unanswered_requests = {} # retry times @@ -349,7 +350,7 @@ def get_status_value(self, key): return value def notify(self, key): - if key in ['status', 'updated']: + if key in ['updated']: self.trigger_callback(key) else: self.trigger_callback(key, self.get_status_value(key)) @@ -630,29 +631,13 @@ def process_responses(self, interface): # Response is now in canonical form self.process_response(interface, response, callbacks) - def addr_to_scripthash(self, addr): - h = bitcoin.address_to_scripthash(addr) - if h not in self.h2addr: - self.h2addr[h] = addr - return h - - def overload_cb(self, callback): - def cb2(x): - x2 = x.copy() - p = x2.pop('params') - addr = self.h2addr[p[0]] - x2['params'] = [addr] - callback(x2) - return cb2 - - def subscribe_to_addresses(self, addresses, callback): - hashes = [self.addr_to_scripthash(addr) for addr in addresses] - msgs = [('blockchain.scripthash.subscribe', [x]) for x in hashes] - self.send(msgs, self.overload_cb(callback)) - - def request_address_history(self, address, callback): - h = self.addr_to_scripthash(address) - self.send([('blockchain.scripthash.get_history', [h])], self.overload_cb(callback)) + def subscribe_to_scripthashes(self, scripthashes, callback): + msgs = [('blockchain.scripthash.subscribe', [sh]) + for sh in scripthashes] + self.send(msgs, callback) + + def request_scripthash_history(self, sh, callback): + self.send([('blockchain.scripthash.get_history', [sh])], callback) def send(self, messages, callback): '''Messages is a list of (method, params) tuples''' @@ -689,6 +674,16 @@ def process_pending_sends(self): message_id = self.queue_request(method, params) self.unanswered_requests[message_id] = method, params, callback + def _cancel_pending_sends(self, callback): + ct = 0 + with self.pending_sends_lock: + for item in self.pending_sends.copy(): + messages, _callback = item + if callback == _callback: + self.pending_sends.remove(item) + ct += 1 + return ct + def unsubscribe(self, callback): '''Unsubscribe a callback to free object references to enable GC.''' # Note: we can't unsubscribe from the server, so if we receive @@ -699,6 +694,23 @@ def unsubscribe(self, callback): if callback in v: v.remove(callback) + def cancel_requests(self, callback): + '''Remove a callback to free object references to enable GC. + It is advised that this function only be called from the network thread + to avoid race conditions.''' + # If the interface ends up answering these requests, they will just + # be safely ignored. This is better than the alternative which is to + # keep references to an object that declared itself defunct. + ct = 0 + for message_id, client_req in self.unanswered_requests.copy().items(): + if callback == client_req[2]: + self.unanswered_requests.pop(message_id, None) # guard against race conditions here. Note: this usually is called from the network thread but who knows what future programmers may do. :) + ct += 1 + ct2 = self._cancel_pending_sends(callback) + if ct or ct2: + qname = getattr(callback, '__qualname__', repr(callback)) + self.print_error("Removed {} unanswered client requests and {} pending sends for callback: {}".format(ct, ct2, qname)) + def connection_down(self, server): '''A connection to server either went down, or was never made. We distinguish by whether it is in self.interfaces.''' diff --git a/lib/paymentrequest.py b/lib/paymentrequest.py index 932d8f71d..6fd32621e 100644 --- a/lib/paymentrequest.py +++ b/lib/paymentrequest.py @@ -303,7 +303,7 @@ def make_unsigned_request(req): if amount is None: amount = 0 memo = req['memo'] - script = bfh(Transaction.pay_script(TYPE_ADDRESS, addr)) + script = bfh(Transaction.pay_script(addr)) outputs = [(script, amount)] pd = pb2.PaymentDetails() for script, amount in outputs: diff --git a/lib/servers.json b/lib/servers.json index 009d1ec7b..72377b813 100644 --- a/lib/servers.json +++ b/lib/servers.json @@ -1,5 +1,5 @@ { - "178.128.27.40": { + "electrum.800crypto.club": { "pruning": "-", "s": "50002", "t": "50001", diff --git a/lib/servers_testnet.json b/lib/servers_testnet.json index 3e6cc52ce..66502bbe7 100644 --- a/lib/servers_testnet.json +++ b/lib/servers_testnet.json @@ -1,7 +1,7 @@ { - "127.0.0.1": { + "167.71.201.233": { "pruning": "-", - "s": "51022", + "s": "50002", "t": "51021", "version": "1.2" } diff --git a/lib/simple_config.py b/lib/simple_config.py index 8a80b5f74..12faa1349 100644 --- a/lib/simple_config.py +++ b/lib/simple_config.py @@ -271,14 +271,16 @@ def get_session_timeout(self): def open_last_wallet(self): if self.get('wallet_path') is None: - last_wallet = self.get('gui_last_wallet') + last_wallet = self.get('gui_last_wallet_slp') if last_wallet is not None and os.path.exists(last_wallet): self.cmdline_options['default_wallet_path'] = last_wallet def save_last_wallet(self, wallet): if self.get('wallet_path') is None: path = wallet.storage.path - self.set_key('gui_last_wallet', path) + self.set_key('gui_last_wallet_slp', path) + if not wallet.is_slp: + self.set_key('gui_last_wallet', path) def impose_hard_limits_on_fee(func): def get_fee_within_limits(self, *args, **kwargs): @@ -424,6 +426,10 @@ def static_fee_index(self, value): def has_fee_etas(self): return len(self.fee_estimates) == 4 + def custom_fee_rate(self): + f = self.get('customfee') + return f + def has_fee_mempool(self): return bool(self.mempool_fees) @@ -456,6 +462,17 @@ def fee_per_kb(self, dyn=None, mempool=None): fee_rate = self.get('fee_per_kb', FEERATE_FALLBACK_STATIC_FEE) return fee_rate + def has_custom_fee_rate(self): + i = -1 + # Defensive programming below.. to ensure the custom fee rate is valid ;) + # This function mainly controls the appearance (or disappearance) of the fee slider in the send tab in Qt GUI + # It is tied to the GUI preferences option 'Custom fee rate'. + try: + i = int(self.custom_fee_rate()) + except (ValueError, TypeError): + pass + return i >= 0 + def estimate_fee(self, size): fee_per_kb = self.fee_per_kb() if fee_per_kb is None: diff --git a/lib/slp.py b/lib/slp.py new file mode 100644 index 000000000..2e304cb67 --- /dev/null +++ b/lib/slp.py @@ -0,0 +1,531 @@ +from .transaction import Transaction +from .address import ScriptOutput +from .bitcoin import TYPE_SCRIPT +from .address import Script, ScriptError, OpCodes +from enum import Enum + +lokad_id = b"SLP\x00" + +def int_2_bytes_bigendian(number: int, byte_length: int = None): + number = int(number) + if byte_length is None: # autosize + byte_length = (number.bit_length()+7)//8 + # Raises OverflowError if number is too big for this length + return number.to_bytes(byte_length, 'big') + + +class OpreturnError(Exception): + pass + +def parseOpreturnToChunks(script: bytes, *, allow_op_0: bool, allow_op_number: bool): + """Extract pushed bytes after opreturn. Returns list of bytes() objects, + one per push. + + Strict refusal of non-push opcodes; bad scripts throw OpreturnError.""" + try: + ops = Script.get_ops(script) + except ScriptError as e: + raise OpreturnError('Script error') from e + + if not ops or ops[0] != OpCodes.OP_RETURN: + raise OpreturnError('No OP_RETURN') + + chunks = [] + for opitem in ops[1:]: + op, data = opitem if isinstance(opitem, tuple) else (opitem, None) + if op > OpCodes.OP_16: + raise OpreturnError('Non-push opcode') + if op > OpCodes.OP_PUSHDATA4: + if op == 80: + raise OpreturnError('Non-push opcode') + if not allow_op_number: + raise OpreturnError('OP_1NEGATE to OP_16 not allowed') + if op == OpCodes.OP_1NEGATE: + data = [0x81] + else: # OP_1 - OP_16 + data = [op-80] + if op == OpCodes.OP_0 and not allow_op_0: + raise OpreturnError('OP_0 not allowed') + chunks.append(b'' if data is None else bytes(data)) + return chunks + + +# Exceptions caused by malformed or unexpected data found in parsing. +class SlpParsingError(Exception): + pass + +class SlpUnsupportedSlpTokenType(SlpParsingError): + # Cannot parse OP_RETURN due to unrecognized version + # (may or may not be valid) + pass + +class SlpInvalidOutputMessage(SlpParsingError): + # This exception (and subclasses) marks a message as definitely invalid + # under SLP consensus rules. (either malformed SLP or just not SLP) + pass + + +# Exceptions during creation of SLP message. +class SlpSerializingError(Exception): + pass + +class OPReturnTooLarge(SlpSerializingError): + pass + +# Other exceptions +class SlpNoMintingBatonFound(Exception): + pass + + +# This class represents a parsed op_return message that can be used by validator to look at SLP messages +class SlpMessage: + lokad_id = lokad_id + + def __init__(self): + self.token_type = None + self.transaction_type = None + self.op_return_fields = {} + + def __repr__(self,): + return "<%s token_type=%d %r %r>"%(type(self).__qualname__, self.token_type, self.transaction_type, self.op_return_fields) + + # This method attempts to parse a ScriptOutput object as an SLP message. + # Bad scripts will throw a subclass of SlpParsingError; any other exception indicates a bug in this code. + # - Unrecognized SLP versions will throw SlpUnsupportedSlpTokenType. + # - It is a STRICT parser -- consensus-invalid messages will throw SlpInvalidOutputMessage. + # - Non-SLP scripts will also throw SlpInvalidOutputMessage. + @staticmethod + def parseSlpOutputScript(outputScript: ScriptOutput): + slpMsg = SlpMessage() + try: + chunks = parseOpreturnToChunks(outputScript.to_script(), allow_op_0 = False, allow_op_number = False) + except OpreturnError as e: + raise SlpInvalidOutputMessage('Bad OP_RETURN', *e.args) from e + + if len(chunks) == 0: + raise SlpInvalidOutputMessage('Empty OP_RETURN') + + if chunks[0] != lokad_id: + raise SlpInvalidOutputMessage('Not SLP') + + if len(chunks) == 1: + raise SlpInvalidOutputMessage('Missing token_type') + + # check if the token version is supported + # 1 = type 1 + # 65 = type 1 as NFT child + # 129 = type 1 as NFT parent + slpMsg.token_type = SlpMessage.parseChunkToInt(chunks[1], 1, 2, True) + if slpMsg.token_type not in [1, 65, 129]: + raise SlpUnsupportedSlpTokenType(slpMsg.token_type) + + if slpMsg.token_type == 65: + nft_flag = slpMsg.op_return_fields['nft_flag'] = "NFT_CHILD" + elif slpMsg.token_type == 129: + nft_flag = slpMsg.op_return_fields['nft_flag'] = "NFT_PARENT" + else: + nft_flag = slpMsg.op_return_fields['nft_flag'] = None + + if len(chunks) == 2: + raise SlpInvalidOutputMessage('Missing SLP command') + + # (the following logic is all for version 1) + try: + slpMsg.transaction_type = chunks[2].decode('ascii') + except UnicodeDecodeError: + # This can occur if bytes > 127 present. + raise SlpInvalidOutputMessage('Bad transaction type') + + # switch statement to handle different on transaction type + if slpMsg.transaction_type == 'GENESIS': + if len(chunks) != 10: + raise SlpInvalidOutputMessage('GENESIS with incorrect number of parameters') + # keep ticker, token name, document url, document hash as bytes + # (their textual encoding is not relevant for SLP consensus) + # but do enforce consensus length limits + slpMsg.op_return_fields['ticker'] = chunks[3] + slpMsg.op_return_fields['token_name'] = chunks[4] + slpMsg.op_return_fields['token_doc_url'] = chunks[5] + slpMsg.op_return_fields['token_doc_hash'] = chunks[6] + if len(slpMsg.op_return_fields['token_doc_hash']) not in (0, 32): + raise SlpInvalidOutputMessage('Token document hash is incorrect length') + + # decimals -- one byte in range 0-9 + slpMsg.op_return_fields['decimals'] = SlpMessage.parseChunkToInt(chunks[7], 1, 1, True) + if slpMsg.op_return_fields['decimals'] > 9: + raise SlpInvalidOutputMessage('Too many decimals') + + ## handle baton for additional minting, but may be empty + v = slpMsg.op_return_fields['mint_baton_vout'] = SlpMessage.parseChunkToInt(chunks[8], 1, 1) + if v is not None and v < 2: + raise SlpInvalidOutputMessage('Mint baton cannot be on vout=0 or 1') + + # handle initial token quantity issuance + slpMsg.op_return_fields['initial_token_mint_quantity'] = SlpMessage.parseChunkToInt(chunks[9], 8, 8, True) + if nft_flag == 'NFT_CHILD': + if slpMsg.op_return_fields['decimals'] != 0: + raise SlpInvalidOutputMessage('NFT1 child token must have divisibility set to 0 decimal places.') + if v is not None: + raise SlpInvalidOutputMessage('Cannot have a minting baton in a NFT_CHILD token.') + if slpMsg.op_return_fields['initial_token_mint_quantity'] != 1: + raise SlpInvalidOutputMessage('NFT1 child token must have GENESIS quantity of 1.') + elif slpMsg.transaction_type == 'SEND': + if len(chunks) < 4: + raise SlpInvalidOutputMessage('SEND with too few parameters') + if len(chunks[3]) != 32: + raise SlpInvalidOutputMessage('token_id is wrong length') + slpMsg.op_return_fields['token_id_hex'] = chunks[3].hex() + + # Note that we put an explicit 0 for ['token_output'][0] since it + # corresponds to vout=0, which is the OP_RETURN tx output. + # ['token_output'][1] is the first token output given by the SLP + # message, i.e., the number listed as `token_output_quantity1` in the + # spec, which goes to tx output vout=1. + slpMsg.op_return_fields['token_output'] = (0,) + \ + tuple( SlpMessage.parseChunkToInt(field, 8, 8, True) for field in chunks[4:] ) + # maximum 19 allowed token outputs, plus 1 for the explicit [0] we inserted. + if len(slpMsg.op_return_fields['token_output']) < 2: + raise SlpInvalidOutputMessage('Missing output amounts') + if len(slpMsg.op_return_fields['token_output']) > 20: + raise SlpInvalidOutputMessage('More than 19 output amounts') + elif slpMsg.transaction_type == 'MINT': + if nft_flag == 'NFT_CHILD': + raise SlpInvalidOutputMessage('Cannot have MINT with NFT_CHILD') + if len(chunks) != 6: + raise SlpInvalidOutputMessage('MINT with incorrect number of parameters') + if len(chunks[3]) != 32: + raise SlpInvalidOutputMessage('token_id is wrong length') + slpMsg.op_return_fields['token_id_hex'] = chunks[3].hex() + v = slpMsg.op_return_fields['mint_baton_vout'] = SlpMessage.parseChunkToInt(chunks[4], 1, 1) + if v is not None and v < 2: + raise SlpInvalidOutputMessage('Mint baton cannot be on vout=0 or 1') + slpMsg.op_return_fields['additional_token_quantity'] = SlpMessage.parseChunkToInt(chunks[5], 8, 8, True) + elif slpMsg.transaction_type == 'COMMIT': + # We don't know how to handle this right now, just return slpMsg of 'COMMIT' type + slpMsg.op_return_fields['info'] = 'slp.py not parsing yet \xaf\\_(\u30c4)_/\xaf' + else: + raise SlpInvalidOutputMessage('Bad transaction type') + return slpMsg + + @staticmethod + def parseChunkToInt(intBytes: bytes, minByteLen: int, maxByteLen: int, raise_on_Null: bool = False): + # Parse data as unsigned-big-endian encoded integer. + # For empty data different possibilities may occur: + # minByteLen <= 0 : return 0 + # raise_on_Null == False and minByteLen > 0: return None + # raise_on_Null == True and minByteLen > 0: raise SlpInvalidOutputMessage + if len(intBytes) >= minByteLen and len(intBytes) <= maxByteLen: + return int.from_bytes(intBytes, 'big', signed=False) + if len(intBytes) == 0 and not raise_on_Null: + return None + raise SlpInvalidOutputMessage('Field has wrong length') + + + + + + +### +# SLP message creation functions below. +# Various exceptions can occur: +# SlpSerializingError / subclass if bad values. +# UnicodeDecodeError if strings are weird (in GENESIS only). +### + + +# utility for creation: use smallest push except not any of: op_0, op_1negate, op_1 to op_16 +def pushChunk(chunk: bytes) -> bytes: # allow_op_0 = False, allow_op_number = False + length = len(chunk) + if length == 0: + return b'\x4c\x00' + chunk + elif length < 76: + return bytes((length,)) + chunk + elif length < 256: + return bytes((0x4c,length,)) + chunk + elif length < 65536: # shouldn't happen but eh + return b'\x4d' + length.to_bytes(2, 'little') + chunk + elif length < 4294967296: # shouldn't happen but eh + return b'\x4e' + length.to_bytes(4, 'little') + chunk + else: + raise ValueError() + +# utility for creation +def chunksToOpreturnOutput(chunks: [bytes]) -> tuple: + script = bytearray([0x6a,]) # start with OP_RETURN + for c in chunks: + script.extend(pushChunk(c)) + + if len(script) > 223: + raise OPReturnTooLarge('OP_RETURN message too large, cannot be larger than 223 bytes') + + return (TYPE_SCRIPT, ScriptOutput(bytes(script)), 0) + + +# Type 1 Token GENESIS Message +def buildGenesisOpReturnOutput_V1(ticker: str, token_name: str, token_document_url: str, token_document_hash_hex: str, decimals: int, baton_vout: int, initial_token_mint_quantity: int, token_type: int = 1) -> tuple: + chunks = [] + script = bytearray((0x6a,)) # OP_RETURN + + # lokad id + chunks.append(lokad_id) + + # token version/type + if token_type in [1, 'SLP1']: + chunks.append(b'\x01') + elif token_type in [65, 'SLP65']: + chunks.append(b'\x41') + elif token_type in [129, 'SLP129']: + chunks.append(b'\x81') + else: + raise Exception('Unsupported token type') + + # transaction type + chunks.append(b'GENESIS') + + # ticker (can be None) + if ticker is None: + tickerb = b'' + else: + tickerb = ticker.encode('utf-8') + chunks.append(tickerb) + + # name (can be None) + if token_name is None: + chunks.append(b'') + else: + chunks.append(token_name.encode('utf-8')) + + # doc_url (can be None) + if token_document_url is None: + chunks.append(b'') + else: + chunks.append(token_document_url.encode('ascii')) + + # doc_hash (can be None) + if token_document_hash_hex is None: + chunks.append(b'') + else: + dochash = bytes.fromhex(token_document_hash_hex) + if len(dochash) not in (0,32): + raise SlpSerializingError() + chunks.append(dochash) + + # decimals + decimals = int(decimals) + if decimals > 9 or decimals < 0: + raise SlpSerializingError() + chunks.append(bytes((decimals,))) + + # baton vout + if baton_vout is None: + chunks.append(b'') + else: + if baton_vout < 2: + raise SlpSerializingError() + chunks.append(bytes((baton_vout,))) + + # init quantity + qb = int(initial_token_mint_quantity).to_bytes(8,'big') + chunks.append(qb) + + return chunksToOpreturnOutput(chunks) + +# Type 1 Token GENESIS Message +def buildGenesisOpReturnOutput_V1_UnitTests_V_X(ticker: str, token_name: str, token_document_url: str, token_document_hash_hex: str, decimals: int, baton_vout: int, initial_token_mint_quantity: int, version: bytes) -> tuple: + chunks = [] + script = bytearray((0x6a,)) # OP_RETURN + + # lokad id + chunks.append(lokad_id) + + # token version/type + chunks.append(version) # b'\x02') + + # transaction type + chunks.append(b'GENESIS') + + # ticker (can be None) + if ticker is None: + tickerb = b'' + else: + tickerb = ticker.encode('utf-8') + chunks.append(tickerb) + + # name (can be None) + if token_name is None: + chunks.append(b'') + else: + chunks.append(token_name.encode('utf-8')) + + # doc_url (can be None) + if token_document_url is None: + chunks.append(b'') + else: + chunks.append(token_document_url.encode('ascii')) + + # doc_hash (can be None) + if token_document_hash_hex is None: + chunks.append(b'') + else: + dochash = bytes.fromhex(token_document_hash_hex) + if len(dochash) not in (0,32): + raise SlpSerializingError() + chunks.append(dochash) + + # decimals + decimals = int(decimals) + if decimals > 9 or decimals < 0: + raise SlpSerializingError() + chunks.append(bytes((decimals,))) + + # baton vout + if baton_vout is None: + chunks.append(b'') + else: + if baton_vout < 2: + raise SlpSerializingError() + chunks.append(bytes((baton_vout,))) + + # init quantity + qb = int(initial_token_mint_quantity).to_bytes(8,'big') + chunks.append(qb) + + return chunksToOpreturnOutput(chunks) + +# Type 1 Token MINT Message +def buildMintOpReturnOutput_V1(token_id_hex: str, baton_vout: int, token_mint_quantity: int, token_type: int = 1) -> tuple: + chunks = [] + + # lokad id + chunks.append(lokad_id) + + # token version/type + if token_type in [1, 'SLP1']: + chunks.append(b'\x01') + elif token_type in [129, 'SLP129']: + chunks.append(b'\x81') + else: + raise Exception('Unsupported token type') + + # transaction type + chunks.append(b'MINT') + + # token id + tokenId = bytes.fromhex(token_id_hex) + if len(tokenId) != 32: + raise SlpSerializingError() + chunks.append(tokenId) + + # baton vout + if baton_vout is None: + chunks.append(b'') + else: + if baton_vout < 2: + raise SlpSerializingError() + chunks.append(bytes((baton_vout,))) + + # init quantity + qb = int(token_mint_quantity).to_bytes(8,'big') + chunks.append(qb) + + return chunksToOpreturnOutput(chunks) + +# Type 2 Token MINT Message +def buildMintOpReturnOutput_V1_UnitTests_V_X(token_id_hex: str, baton_vout: int, token_mint_quantity: int, version: bytes) -> tuple: + chunks = [] + + # lokad id + chunks.append(lokad_id) + + # token version/type + chunks.append(version) # b'\x02') + + # transaction type + chunks.append(b'MINT') + + # token id + tokenId = bytes.fromhex(token_id_hex) + if len(tokenId) != 32: + raise SlpSerializingError() + chunks.append(tokenId) + + # baton vout + if baton_vout is None: + chunks.append(b'') + else: + if baton_vout < 2: + raise SlpSerializingError() + chunks.append(bytes((baton_vout,))) + + # init quantity + qb = int(token_mint_quantity).to_bytes(8,'big') + chunks.append(qb) + + return chunksToOpreturnOutput(chunks) + +# Type 1 Token SEND Message +def buildSendOpReturnOutput_V1(token_id_hex: str, output_qty_array: [int], token_type: int = 1) -> tuple: + chunks = [] + + # lokad id + chunks.append(lokad_id) + + # token version/type + if token_type in [1, 'SLP1']: + chunks.append(b'\x01') + elif token_type in [65, 'SLP65']: + chunks.append(b'\x41') + elif token_type in [129, 'SLP129']: + chunks.append(b'\x81') + else: + raise Exception('Unsupported token type') + + # transaction type + chunks.append(b'SEND') + + # token id + tokenId = bytes.fromhex(token_id_hex) + if len(tokenId) != 32: + raise SlpSerializingError() + chunks.append(tokenId) + + # output quantities + if len(output_qty_array) < 1: + raise SlpSerializingError("Cannot have less than 1 SLP Token output.") + if len(output_qty_array) > 19: + raise SlpSerializingError("Cannot have more than 19 SLP Token outputs.") + for qty in output_qty_array: + qb = int(qty).to_bytes(8,'big') + chunks.append(qb) + + return chunksToOpreturnOutput(chunks) + +# Type 2 Token SEND Message +def buildSendOpReturnOutput_V1_UnitTests_V_X(token_id_hex: str, output_qty_array: [int], version: bytes) -> tuple: + chunks = [] + + # lokad id + chunks.append(lokad_id) + + # token version/type + chunks.append(version) #b'\x02') + + # transaction type + chunks.append(b'SEND') + + # token id + tokenId = bytes.fromhex(token_id_hex) + if len(tokenId) != 32: + raise SlpSerializingError() + chunks.append(tokenId) + + # output quantities + if len(output_qty_array) < 1: + raise SlpSerializingError("Cannot have less than 1 SLP Token output.") + if len(output_qty_array) > 19: + raise SlpSerializingError("Cannot have more than 19 SLP Token outputs.") + for qty in output_qty_array: + qb = int(qty).to_bytes(8,'big') + chunks.append(qb) + + return chunksToOpreturnOutput(chunks) diff --git a/lib/slp_checker.py b/lib/slp_checker.py new file mode 100644 index 000000000..484316e26 --- /dev/null +++ b/lib/slp_checker.py @@ -0,0 +1,301 @@ +from electrum_zclassic.util import NotEnoughFundsSlp, NotEnoughUnfrozenFundsSlp, print_error +from electrum_zclassic import slp +from electrum_zclassic.slp import SlpParsingError, SlpInvalidOutputMessage, SlpUnsupportedSlpTokenType +from electrum_zclassic.transaction import Transaction +from electrum_zclassic.address import Address + +class SlpTransactionChecker: + @staticmethod + def check_tx_slp(wallet, tx, *, coins_to_burn=None, require_tx_in_wallet=True): + + # Step 1) Double check all input transactions have been added to wallet._slp_txo + if require_tx_in_wallet: + for txo in tx.inputs(): + addr = txo['address'] + prev_out = txo['prevout_hash'] + prev_n = txo['prevout_n'] + with wallet.lock: + try: + input_tx = wallet.transactions[prev_out] + except KeyError: + raise Exception('Wallet has not downloaded this transaction') + else: + try: + slp_msg = slp.SlpMessage.parseSlpOutputScript(input_tx.outputs()[0][1]) + except SlpInvalidOutputMessage: + pass + except SlpUnsupportedSlpTokenType: + raise UnsupportedSlpTokenType('Transaction contains an unsupported SLP' \ + + ' input type') + else: + if slp_msg.transaction_type == 'SEND': + if prev_n >= len(slp_msg.op_return_fields['token_output']): + continue + elif slp_msg.transaction_type in ['GENESIS', 'MINT']: + if slp_msg.op_return_fields['mint_baton_vout'] and \ + prev_n not in [1, slp_msg.op_return_fields['mint_baton_vout']]: + continue + elif not slp_msg.op_return_fields['mint_baton_vout'] and prev_n != 1: + continue + elif slp_msg.transaction_type == 'MINT' and \ + prev_n == 1 and \ + slp_msg.op_return_fields['additional_token_quantity'] == 0: + continue + try: + with wallet.lock: + assert wallet._slp_txo[addr][prev_out][prev_n] + except (KeyError, AssertionError): + raise SlpMissingInputRecord('Transaction contains an SLP input that is' \ + + ' unknown to this wallet (missing from slp_txo).') + + # Step 2) Get SLP metadata in current transaction + try: + slp_msg = slp.SlpMessage.parseSlpOutputScript(tx.outputs()[0][1]) + except SlpParsingError: + slp_msg = None + + # Step 3a) If non-SLP check for SLP inputs (only allow + # spending slp inputs specified in 'coins_to_burn') + if not slp_msg: + for txo in tx.inputs(): + addr = txo['address'] + prev_out = txo['prevout_hash'] + prev_n = txo['prevout_n'] + slp_txo = None + with wallet.lock: + try: + slp_txo = wallet._slp_txo[addr][prev_out][prev_n] + except KeyError: + pass + if slp_txo: + is_burn_allowed = False + if coins_to_burn: + for c in coins_to_burn: + if c['prevout_hash'] == prev_out and c['prevout_n'] == prev_n: + is_burn_allowed = True + c['is_in_txn'] = True + + if not is_burn_allowed: + print_error("SLP check failed for non-SLP transaction" \ + + " which contains SLP inputs.") + raise NonSlpTransactionHasSlpInputs('Non-SLP transaction contains unspecified SLP inputs.') + + # Check that all coins within 'coins_to_burn' are included in burn transaction + if coins_to_burn: + for coin in coins_to_burn: + try: + if coin['is_in_txn']: + continue + except KeyError: + raise MissingCoinToBeBurned('Transaction is missing SLP required inputs that were' \ + + ' for this burn transaction.') + + # Step 3b) If SLP, check quantities and token id of inputs match output requirements + elif slp_msg: + if slp_msg.transaction_type == 'SEND': + tid = slp_msg.op_return_fields['token_id_hex'] + # raise an Exception if: + # - [X] input quantity is greater than output quanitity (except if 'coins_to_burn') + # - [X] input quantity is less than output quanitity + # - [X] slp input does not match tokenId + # - [X] make sure outpoint is provided for every slp output and is P2PKH or P2SH + # - [ ] the proper token type is not respected in the output op_return message + slp_outputs = slp_msg.op_return_fields['token_output'] + input_slp_qty = 0 + for txo in tx.inputs(): + addr = txo['address'] + prev_out = txo['prevout_hash'] + prev_n = txo['prevout_n'] + with wallet.lock: + try: + slp_input = wallet._slp_txo[addr][prev_out][prev_n] + except KeyError: + pass + else: + input_slp_qty += slp_input['qty'] + if slp_input['token_id'] != tid: + print_error("SLP check failed for SEND due to incorrect" \ + + " tokenId in txn input") + raise SlpWrongTokenID('Transaction contains SLP inputs' \ + + ' with incorrect token id.') + + if input_slp_qty < sum(slp_outputs): + print_error("SLP check failed for SEND due to insufficient SLP inputs") + raise SlpInputsTooLow('Transaction SLP outputs exceed SLP inputs') + elif not coins_to_burn and input_slp_qty > sum(slp_outputs): + print_error("SLP check failed for SEND due to SLP inputs too high") + raise SlpInputsTooHigh('Transaction SLP inputs exceed SLP outputs.') + + for i, out in enumerate(slp_msg.op_return_fields['token_output']): + try: + out = tx.outputs()[i] + except IndexError: + print_error("Transaction is missing vout for MINT operation" \ + + " token receiver") + raise MissingTokenReceiverOutpoint('Transaction is missing' \ + + ' a required SLP output.') + else: + if i == 0: + assert out[0] == 2 + elif out[1].kind not in [Address.ADDR_P2PKH, Address.ADDR_P2SH]: + print_error("Transaction token receiver vout is not P2PKH or P2SH") + raise BadSlpOutpointType('Tranaction SLP output must be p2pkh' \ + + ' or p2sh output type.') + elif slp_msg.transaction_type == 'MINT': + tid = slp_msg.op_return_fields['token_id_hex'] + # raise an Exception if: + # - [X] Any non-baton SLP input is found + # - [X] Baton has wrong token ID + # - [ ] Minting transaction is being made from NFT child type baton + for txo in tx.inputs(): + addr = txo['address'] + prev_out = txo['prevout_hash'] + prev_n = txo['prevout_n'] + with wallet.lock: + try: + slp_input = wallet._slp_txo[addr][prev_out][prev_n] + except KeyError: + pass + else: + if slp_input['qty'] != 'MINT_BATON': + print_error("Non-baton SLP input found in MINT") + raise SlpNonMintInput('MINT transaction contains non-baton SLP input.') + if slp_input['token_id'] != tid: + print_error("SLP check failed for MINT due to incorrect" \ + + " tokenId in baton") + raise SlpWrongTokenID('MINT transaction contains baton with incorrect' \ + + ' token id.') + elif slp_msg.transaction_type == 'GENESIS': + # raise an Exception if: + # - [ ] NFT Child has quantity that is !== 1 + # - [ ] Allow 0x01 or 0x81 qty == 0 + # - [ ] NFT Child has minting baton vout specified + # - [ ] NFT Child does not grant exception for burning a coin in vin=0 + # - [ ] NFT Child does not have a valid Type 0x81 coin in vin=0 + for txo in tx.inputs(): + addr = txo['address'] + prev_out = txo['prevout_hash'] + prev_n = txo['prevout_n'] + with wallet.lock: + try: + slp_input = wallet._slp_txo[addr][prev_out][prev_n] + except KeyError: + pass + else: + is_burn_allowed = False + if coins_to_burn: + for c in coins_to_burn: + if c['prevout_hash'] == prev_out and c['prevout_n'] == prev_n: + is_burn_allowed = True + c['is_in_txn'] = True + + if not is_burn_allowed: + print_error("SLP check failed for SLP GENESIS transaction" \ + + " which contains SLP inputs.") + raise NonSlpTransactionHasSlpInputs('Genesis transaction contains unspecified SLP inputs.') + + if slp_msg.transaction_type in ['GENESIS', 'MINT']: + # raise an Exception if: + # - [X] New baton outpoint is not P2PKH or P2SH type for Genesis or Mint + # - [X] Mint receiver has outpoint and is p2pkh or p2sh + if slp_msg.op_return_fields['mint_baton_vout']: + try: + out = tx.outputs()[slp_msg.op_return_fields['mint_baton_vout']] + except IndexError: + print_error("Transaction is missing baton vout for MINT operation") + raise MissingMintBatonOutpoint('Transaction is missing baton' \ + + ' vout for MINT operation') + else: + if out[1].kind not in [Address.ADDR_P2PKH, Address.ADDR_P2SH]: + print_error("Transaction baton receiver vout is not P2PKH or P2SH") + raise BadSlpOutpointType('Transaction baton receiver vout is not P2PKH' \ + + ' or P2SH output type') + + try: + out = tx.outputs()[1] + except IndexError: + print_error("Transaction is missing vout for MINT operation token receiver") + raise MissingTokenReceiverOutpoint('Transaction is missing vout for MINT' \ + + ' operation token receiver') + else: + if out[1].kind not in [Address.ADDR_P2PKH, Address.ADDR_P2SH]: + print_error("Transaction token receiver vout is not P2PKH or P2SH") + raise BadSlpOutpointType('Transaction token receiver vout is not P2PKH' \ + + ' or P2SH output type') + + # return True if this check passes + print_error("Final SLP check passed") + return True + + ''' + Unit Testing Plan: + - [ ] Verify Non-SLP transaction with SLP inputs raises exception, use Burn Tool to burn ALL of a coin since that will produce a non-SLP output with SLP inputs + - requires removing "slp_coins_to_burn" param from "slp_burn_token_dialog.py" broadcast_transaction() + - [ ] Verify SLP transaction with too high of SLP inputs raises exception, use Burn Tool to burn with token change, since that will have more inputs than outputs. + - requires removing "slp_coins_to_burn" param from "slp_burn_token_dialog.py" broadcast_transaction() + - [ ] Verify token receiver outpoints are of p2pkh or p2sh type + - [ ] Verify baton receiver outpoints are of p2pkh or p2sh type + - [ ] Test SLP transaction with wrong SLP inputs throws + - [ ] Test SLP transaction with insufficient inputs throws + - [ ] Check BURN dialog + - [ ] Check BURN preview/broadcast + - [ ] Check MINT dialog + - [ ] Check MINT preview/broadcast + - [ ] Check SEND + - [ ] Check SEND preview/broadcast + - [ ] Check GENESIS + - [ ] Check GENESIS preview/broadcast + - [ ] Check BCH send + - [ ] Check BCH preview/broadcast + ''' + +# Exceptions caused by malformed or unexpected data found in parsing. +class SlpTransactionValidityError(Exception): + pass + +class SlpMissingInputRecord(SlpTransactionValidityError): + pass + +class NonSlpTransactionHasSlpInputs(SlpTransactionValidityError): + # Cannot have SLP inputs in non-SLP transaction + pass + +class GenesisHasSlpInputs(SlpTransactionValidityError): + # Genesis cannot have SLP inputs unless specified + pass + +class SlpWrongTokenID(SlpTransactionValidityError): + # Wrong Token ID in input + pass + +class SlpInputsTooLow(SlpTransactionValidityError): + # SLP input quantity too low in SEND transaction + pass + +class SlpInputsTooHigh(SlpTransactionValidityError): + # SLP input quantity too high in SEND transaction + pass + +class MissingCoinToBeBurned(SlpTransactionValidityError): + # SLP input quantity too high in SEND transaction + pass + +class SlpNonMintInput(SlpTransactionValidityError): + # SLP MINT has non-baton SLP input + pass + +class MissingMintBatonOutpoint(SlpTransactionValidityError): + # SLP MINT transaction missing baton outpoint + pass + +class MissingTokenReceiverOutpoint(Exception): + # SLP transaction missing token receiver outpoint + pass + +class BadSlpOutpointType(Exception): + # Outpoint not P2PKH or P2SH type + pass + +class UnsupportedSlpTokenType(Exception): + # Input contains an unsupported SLP token type + pass diff --git a/lib/slp_coinchooser.py b/lib/slp_coinchooser.py new file mode 100644 index 000000000..d863c802e --- /dev/null +++ b/lib/slp_coinchooser.py @@ -0,0 +1,42 @@ +from electrum_zclassic.util import NotEnoughFundsSlp, NotEnoughUnfrozenFundsSlp +from electrum_zclassic import slp + +class SlpCoinChooser: + + @staticmethod + def select_coins(wallet, token_id, amount, config, isInvoice=False, *, domain=None): + amt = amount or 0 + valid_bal, _, _, unfrozen_bal, _ = wallet.get_slp_token_balance(token_id, config) + + if amt > valid_bal: + raise NotEnoughFundsSlp("Not enough token funds.") + if valid_bal >= amt > unfrozen_bal: + raise NotEnoughUnfrozenFundsSlp("Not enough unfrozen token funds.") + + slp_coins = wallet.get_slp_spendable_coins(token_id, domain, config, isInvoice) + slp_coins = sorted(slp_coins, key=lambda k: -k['token_value']) + + selected_slp_coins = [] + total_amt_added = 0 + for coin in slp_coins: + if total_amt_added < amt: + selected_slp_coins.append(coin) + total_amt_added += coin['token_value'] + else: + break + + token_outputs_amts = [] + slp_op_return_msg = None + if total_amt_added > 0: + token_outputs_amts.append(amt) + token_change = total_amt_added - amt + if token_change > 0: + token_outputs_amts.append(token_change) + token_type = wallet.token_types[token_id]['class'] + slp_op_return_msg = slp.buildSendOpReturnOutput_V1(token_id, token_outputs_amts, token_type) + + if selected_slp_coins: + assert slp_op_return_msg + + return (selected_slp_coins, slp_op_return_msg) + diff --git a/lib/slp_dagging.py b/lib/slp_dagging.py new file mode 100644 index 000000000..d7d721448 --- /dev/null +++ b/lib/slp_dagging.py @@ -0,0 +1,1205 @@ +""" +Breadth-first DAG digger for colored coins. + +We do a breadth-first DAG traversal starting with the transaction-of-interest +at the source, and digging into ancestors layer by layer. Along the way we +prune off some connections, invalidate+disconnect branches, etc., +so our 'search DAG' is a subset of the transaction DAG. To hold this +dynamically changing search DAG, we have a TokenGraph class. + +(It's much simpler to run a full node and validate as transactions appear, but +we have no such luxury in a light wallet.) + + +Threading +========= + +The TokenGraph and Node objects are not threadsafe. It is fine however to +have different graphs/nodes being worked on by different threads (see +slp_validator_0x01). +""" + +import sys +import threading +import queue +import traceback +import weakref +import collections +from abc import ABC, abstractmethod +from .transaction import Transaction +from .util import PrintError + +INF_DEPTH=2147483646 # 'infinity' value for node depths. 2**31 - 2 + +from . import slp_graph_search # thread doesn't start until instantiation, one thread per search job, w/ shared txn cache + +class hardref: + # a proper reference that mimics weakref interface + __slots__ = ('_obj') + def __init__(self,obj): + self._obj = obj + def __call__(self,): + return self._obj + + +class DoubleLoadException(Exception): + pass + +class ValidatorGeneric(ABC): + """ + The specific colored coin implementation will need to make a 'validator' + object according to this template. + + Implementations should: + - Define `get_info`, `check_needed`, and `validate` methods. + - Define `validity_states` dictionary. + - Set `prevalidation` to one of the following: + False - only call validate() once per tx, when all inputs are concluded. + True - call validate() repeatedly, starting when all inputs are downloaded. + (only useful if this can provide early validity conclusions) + """ + + prevalidation = False + + validity_states = { + 0: 'Unknown', + 1: 'Valid', + 2: 'Invalid', + } + + @abstractmethod + def get_info(self, tx): + """ This will be called with a Transaction object; use it to extract + all information necessary during the validation process (after call, + the Transaction object will be forgotten). + + Allowed return values: + + ('prune', validity) -- prune this tx immediately, remember only validity. + (vin_mask, myinfo, outputs) + -- information for active, *potentially* valid tx. + + The list `vin_mask = (True, False, False, True, ...)` tells which tx + inputs are to be considered for validation. + + The list `outputs = (out_1, out_2, ...)` provides info that is needed + to validate descendant transactions. (e.g., how many tokens). + + `vin_mask` and `outputs` must have lengths matching the tx inputs/outputs. + + See `validate` for how these are used. + + + Pruning is done by replacing node references with prunednodes[validity] . + These will provide None as info for children. + """ + + @abstractmethod + def check_needed(self, myinfo, out_n): + """ + As each input gets downloaded and its get_info() gets computed, we + check whether it is still relevant for validation. + + (This is used to disconnect unimportant branches.) + + Here we pass in `myinfo` from the tx, and `out_n` from the input + tx's get_info(); if it was pruned then `out_n` will be None. + """ + + @abstractmethod + def validate(self, myinfo, inputs_info): + """ + Run validation. Only gets called after filtering through check_needed. + + `myinfo` is direct from get_info(). + + `input_info` is a list with same length as `vins` from get_info() + + [(vin_0, validity_0, out_n_0), + (vin_1, validity_1, out_n_1), + ... + ] + + out_n_0 is the info from 0'th input's get_info() function, + but may be None if pruned/invalid. + + Return: + None if undecided, or + (keepinfo, validity) if final judgement. + + keepinfo may be: + False - prune, just save validity judgement. + True - save info and validity. + validity may be: + 1 - valid + 2 - invalid + + Typically (False, 2) and (True, 1) but you *could* use (True, 2) + if it's necessary for children to know info from invalid parents. + """ + + +######## +# Validation jobbing mechanics (downloading txes, building graph +######## + +def emptygetter(i): + raise KeyError + +class ValidationJob: + """ + Manages a job whose actions are held in mainloop(). + + This implementation does a basic breadth-first search. + """ + download_timeout = 5 + downloads = 0 + + currentdepth = 0 + debugging_graph_state = False + + stopping = False + running = False + stop_reason = None + has_never_run = True + + def __init__(self, graph, txid, network, + fetch_hook=None, + validitycache=None, + download_limit=None, depth_limit=None, + debug=False, ref=None): + """ + graph should be a TokenGraph instance with the appropriate validator. + + txid is the root of the graph to be validated. + txids is a list of the desired transactions. + + network is a lib.network.Network object, will be used to download when + transactions can't be found in the cache. + + fetch_hook (optional) called as fetch_hook({txid0,txid1,...},depth) whenever + a set of transactions is loaded into the graph (from cache or network) + at a given depth level. It should return a list of matching Transaction + objects, for known txids (e.g., from wallet or elsewhere), + but also can do other things (like fetching proxy results). Any txids + that are not returned will be fetched by network. + + validitycache (optional) invoked as validitycache[txid_hex], + and should raise KeyError, otherwise return a validity value + that will be passed to load_tx. + + download_limit is enforced by stopping search when the `downloads` + attribute exceeds this limit. (may exceed it by several, since + downloads are requested in parallel) + + depth_limit sets the maximum graph depth to dig to. + """ + self.ref = ref and weakref.ref(ref) + self.graph = graph + self.root_txid = txid + self.txids = tuple([txid]) + self.network = network + self.fetch_hook = fetch_hook + self.graph_search_job = None + self.validitycache = {} if validitycache is None else validitycache + self.download_limit = download_limit + if depth_limit is None: + self.depth_limit = INF_DEPTH - 1 + else: + self.depth_limit = depth_limit + self.callbacks = [] + + self.debug = debug + + self.exited = threading.Event() + + self._statelock = threading.Lock() + + def __repr__(self,): + if self.running: + state = 'running' + else: + try: + state = 'stopped:%r'%(self.stop_reason,) + except AttributeError: + state = 'waiting' + return "<%s object (%s) for txids=%r ref=%r>"%(type(self).__qualname__, state, self.txids, self.ref and self.ref()) + + def belongs_to(self, ref): + return ref is (self.ref and self.ref()) + + def has_txid(self, txid): + return txid in self.txids + + ## Job state management + + def run(self,): + """ Wrapper for mainloop() to manage run state. """ + with self._statelock: + if self.running: + raise RuntimeError("Job running already", self) + self.stopping = False + self.paused = False + self.running = True + self.stop_reason = None + self.has_never_run = False + try: + retval = self.mainloop() + return retval + except: + retval = 'crashed' + raise + finally: + self.exited.set() + with self._statelock: + self.stop_reason = retval + self.running = False + self.stopping = False + cbl = tuple(self.callbacks) # make copy while locked -- prevents double-callbacks + for cbr in cbl: + cb = cbr() # callbacks is a list of indirect references (may be weakrefs) + if cb is not None: + cb(self) + + def stop(self,): + """ Call from another thread, to request stopping (this function + returns immediately, however it may take time to finish the current + set of micro-tasks.) + + If not running then this is ignored and False returned. + Otherwise, True is returned.""" + with self._statelock: + if self.running: + self.stopping = True + return True + else: + return False + + def pause(self): + with self._statelock: + if self.running: + self.paused = True + return True + else: + return False + + #@property + #def runstatus(self,): + #with self._statelock: + #if self.stopping: + #return "stopping" + #elif self.running: + #return "running" + #elif self.paused: + #return "paused" + #else: + #return "stopped" + + def add_callback(self, cb, way='direct', allow_run_cb_now=True): + """ + Callback will be called with cb(job) upon stopping. May be called + more than once if job is restarted. + + If job has run and is now stopped, this will be called immediately + (in calling thread) so as to guarantee it runs at least once. + + `way` may be + - 'direct': store direct reference to `cb`. + - 'weak' : store weak reference to `cb` + - 'weakmethod' : store WeakMethod reference to `cb`. + + (Use 'weakmethod' for bound methods! See weakref documentation. + """ + if way == 'direct': + cbr = hardref(cb) + elif way == 'weak': + cbr = weakref.ref(cb) + elif way == 'weakmethod': + cbr = weakref.WeakMethod(cb) + else: + raise ValueError(way) + with self._statelock: + self.callbacks.append(cbr) + if self.running or self.has_never_run: + # We are waiting to run first time, or currently running. + run_cb_now = False + else: + # We have run and we are now stopped. + run_cb_now = True + if run_cb_now and allow_run_cb_now: + cb(self) + + ## Validation logic (breadth-first traversal) + + @property + def nodes(self,): + # get target nodes + return {t:self.graph.get_node(t) for t in self.txids} + + def mainloop(self,): + """ Breadth-first search """ + + target_nodes = list(self.nodes.values()) + + self.graph.debugging = bool(self.debug) + if self.debug == 2: + # enable printing whole graph state for every step. + self.debugging_graph_state = True + + self.graph.root.set_parents(target_nodes) + self.graph.run_sched() + + def skip_callback(txid): + print("########################################## SKIPPING " + txid + " ###########################################") + node = self.graph.get_node(txid) + node.set_validity(False,2) + + # temp for debugging + # f = open("dag-"+self.txids[0][0:5]+".txt","a") + # f.write(txid+","+str(self.currentdepth)+",false,\n") + + def dl_callback(tx): + #will be called by self.get_txes + txid = tx.txid_fast() + + # temp for debugging + # f = open("dag-"+self.txids[0][0:5]+".txt","a") + # f.write(txid+","+str(self.currentdepth)+",true,\n") + + node = self.graph.get_node(txid) + try: + val = self.validitycache[txid] + except KeyError: + val = None + try: + node.load_tx(tx, cached_validity=val) + except DoubleLoadException: + pass + + while True: + if self.stopping: + self.graph.debug("stop requested") + return "stopped" + + if self.paused: + self.graph.debug("pause requested") + return "paused" + + if not any(n.active for n in target_nodes): + # Normal finish - the targets are known. + self.graph.debug("target transactions finished") + return True + + if self.download_limit is not None and self.downloads >= self.download_limit: + self.graph.debug("hit the download limit.") + return "download limit reached" + + + # fetch all finite-depth nodes + waiting = self.graph.get_waiting(maxdepth=self.depth_limit - 1) + if len(waiting) == 0: # No waiting nodes at all ==> completed. + # This really shouldn't happen + self.graph.debug("exhausted graph without conclusion.") + return "inconclusive" + + # select all waiting txes at or below the current depth + interested_txids = {n.txid for n in waiting + if (n.depth <= self.currentdepth)} + if len(interested_txids) == 0: + # current depth exhausted, so move up + self.currentdepth += 1 + if self.currentdepth > self.depth_limit: + self.graph.debug("reached depth stop.") + return "depth limit reached" + self.graph.debug("moving to depth = %d", self.currentdepth) + continue + + # Download and load up results; this is the main command that + # will take time in this loop. + txids_missing = self.get_txes(interested_txids, dl_callback, skip_callback) + + # do graph maintenance (ping() validation, depth recalculations) + self.graph.run_sched() + + # print entire graph (could take a lot of time!) + if self.debugging_graph_state: + self.graph.debug("Active graph state:") + n_active = 0 + for txid,n in self.graph._nodes.items(): + if not n.active: + continue + self.graph.debug(" %.10s...[%8s] depth=%s"%(txid, n.status, str(n.depth) if n.depth != INF_DEPTH else 'INF_DEPTH')) + n_active += 1 + if n_active == 0: + self.graph.debug(" (empty)") + + txids_gotten = interested_txids.difference(txids_missing) + if len(txids_gotten) == 0: + return "missing txes" + raise RuntimeError('loop ended') + + + def get_txes(self, txid_iterable, dl_callback, skip_callback, errors='print'): + """ + Get multiple txes 'in parallel' (requests all sent at once), and + block while waiting. We first take txes via fetch_hook, and only if + missing do we then we ask the network. + + As they are received, we call `dl_callback(tx)` in the current thread. + + Returns a set of txids that could not be obtained, for whatever + reason. + + `errors` may be 'ignore' or 'raise' or 'print'. + """ + + txid_set = set(txid_iterable) + #search_id = ''.join(list(self.txids)) + "_" + str(self.currentdepth) + # first try to get from cache + if self.fetch_hook: + txns_cache = self.fetch_hook(txid_set, self) + cached = list(txns_cache) + for tx in cached: + # remove known txes from list + txid = tx.txid_fast() + txid_set.remove(txid) + else: + cached = [] + + # Graph Search Hack + # ===== + # Here we determine if missing txids can just be inferred to be invalid + # because they are not currently in graph search results. The benefit is to + # prevent network calls to fetch non-contributing/invalid txns. + # + # This optimization requires all cache item source are equal to "graph_search" + # + if self.graph_search_job and self.graph_search_job.search_success: + for tx in cached: + dl_callback(tx) + for txid in txid_set: + skip_callback(txid) + txid_set.clear() + return txid_set + + # build requests list from remaining txids. + requests = [] + if self.network: + for txid in sorted(txid_set): + requests.append(('blockchain.transaction.get', [txid])) + + if len(requests) > 0: + q = queue.Queue() + self.network.send(requests, q.put) + + # Now that the net request is going, start processing cached txes. + for tx in cached: + dl_callback(tx) + + # And start processing downloaded txes: + for _ in requests: # fetch as many responses as were requested. + try: + resp = q.get(True, self.download_timeout) + except queue.Empty: # timeout + break + if resp.get('error'): + if errors=="print": + print("Tx request error:", resp.get('error'), file=sys.stderr) + elif errors=="raise": + raise RuntimeError("Tx request error", resp.get('error')) + else: + raise ValueError(errors) + continue + raw = resp.get('result') + self.downloads += 1 + tx = Transaction(raw) + txid = tx.txid_fast() + try: + txid_set.remove(txid) + except KeyError: + if errors=="print": + print("Received un-requested txid! Ignoring.", txid, file=sys.stderr) + elif errors=="raise": + raise RuntimeError("Received un-requested txid!", txid) + else: + raise ValueError(errors) + else: + dl_callback(tx) + + return txid_set + + +class ValidationJobManager(PrintError): + """ + A single thread that processes validation jobs sequentially. + """ + def __init__(self, threadname="ValidationJobManager", graph_context=None, exit_when_done=False): + # --- + self.graph_context = graph_context + self.jobs_lock = threading.Lock() + self.job_current = None + self.jobs_pending = [] # list of jobs waiting to run. + self.jobs_finished = weakref.WeakSet() # set of jobs finished normally. + self.jobs_stopped = weakref.WeakSet() # set of jobs stopped by calling .stop(), or that terminated abnormally with an error and/or crash + self.jobs_paused = [] # list of jobs that stopped by calling .pause() + self.all_jobs = weakref.WeakSet() + self.wakeup = threading.Event() # for kicking the mainloop to wake up if it has fallen asleep + self.exited = threading.Event() # for synchronously waiting for jobmgr to exit + # --- + + self._exit_when_done = exit_when_done + + self._killing = False # set by .kill() + + # Kick off the thread + self.thread = threading.Thread(target=self.mainloop, name=threadname, daemon=True) + self.thread.start() + + @property + def threadname(self): + return (self.thread and self.thread.name) or '' + + def diagnostic_name(self): return self.threadname + + def add_job(self, job): + """ Throws ValueError if job is already pending. """ + with self.jobs_lock: + if job in self.all_jobs: + raise ValueError + self.all_jobs.add(job) + self.jobs_pending.append(job) + self.wakeup.set() + + def _stop_all_common(self, job): + ''' Private method, properly stops a job (even if paused or pending), + checking the appropriate lists. Returns 1 on success or 0 if job was + not found in the appropriate lists.''' + if job.stop(): + return True + else: + # Job wasn't running -- try and remove it from the + # pending and paused lists + try: + self.jobs_pending.remove(job) + return True + except ValueError: + pass + try: + self.jobs_paused.remove(job) + return True + except ValueError: + pass + return False + + def stop_all_for(self, ref): + ret = [] + with self.jobs_lock: + for job in list(self.all_jobs): + if job.belongs_to(ref): + if self._stop_all_common(job): + ret.append(job) + return ret + + def stop_all_with_txid(self, txid): + ret = [] + with self.jobs_lock: + for job in list(self.all_jobs): + if job.has_txid(txid): + if self._stop_all_common(job): + ret.append(job) + return ret + + def pause_job(self, job): + """ + Returns True if job was running or pending. + Returns False otherwise. + """ + with self.jobs_lock: + if job is self.job_current: + if job.pause(): + return True + else: + # rare situation + # - running job just stopped. + return False + else: + try: + self.jobs_pending.remove(job) + except ValueError: + return False + else: + self.jobs_paused.append(job) + return True + + def unpause_job(self, job): + """ Take a paused job and put it back into pending. + + Throws ValueError if job is not in paused list. """ + with self.jobs_lock: + self.jobs_paused.remove(job) + self.jobs_pending.append(job) + self.wakeup.set() + + def kill(self, ): + """Request to stop running job (if any) and to after end thread. + Irreversible.""" + self._killing = True + self.wakeup.set() + try: + self.job_current.stop() + except: + pass + self.graph_context = None + + def mainloop(self,): + ran_ctr = 0 + try: + if threading.current_thread() is not self.thread: + raise RuntimeError('wrong thread') + while True: + if self._killing: + return + with self.jobs_lock: + self.wakeup.clear() + has_paused_jobs = bool(len(self.jobs_paused)) + try: + self.job_current = self.jobs_pending.pop(0) + except IndexError: + # prepare to sleep, outside lock + self.job_current = None + if self.job_current is None: + if self._exit_when_done and not has_paused_jobs and ran_ctr: + # we already finished our enqueued jobs, nothing is paused, so just exit since _exit_when_done == True + return # exit thread when done + self.wakeup.wait() + continue + + try: + retval = self.job_current.run() + ran_ctr += 1 + except BaseException as e: + # NB: original code used print here rather than self.print_error + # for unconditional printing even if not running with -v. + # We preserve that behavior, for now. + print("vvvvv validation job error traceback", file=sys.stderr) + traceback.print_exc() + print("^^^^^ validation job %r error traceback"%(self.job_current,), file=sys.stderr) + self.jobs_stopped.add(self.job_current) + else: + with self.jobs_lock: + if retval is True: + self.jobs_finished.add(self.job_current) + elif retval == 'paused': + self.jobs_paused.append(self.job_current) + else: + self.jobs_stopped.add(self.job_current) + self.job_current = None + except: + traceback.print_exc() + print("Thread %s crashed :("%(self.thread.name,), file=sys.stderr) + finally: + self.exited.set() + self.print_error("Thread exited") + + +######## +# Graph stuff below +######## + +class TokenGraph: + """ Used with Node class to hold a dynamic DAG structure, used while + traversing the transaction DAG. This dynamic DAG holds dependencies + among *active* transactions (nonzero contributions with unknown validity) + and so it's a subset of the transactions DAG. + + Why dynamic? As we go deeper we add connections, sometimes adding + connections between previously-unconnected parts. We can also remove + connections as needed for pruning. + + The terms "parent" and "child" refer to the ancestry of a tx -- child + transactions contain (in inputs) a set of pointers to their parents. + + A key concept is the maintenance of a 'depth' value for each active node, + which represents the shortest directed path from root to node. The depth + is used to prioritize downloading in a breadth-first search. + Nodes that are inactive or disconnected from root are assigned depth=INF_DEPTH. + + Graph updating occurs in three phases: + Phase 1: Waiting nodes brought online with load_tx(). + Phase 2: Children get notified of parents' updates via ping(), which may + further alter graph (as validity conclusions get reached). + Phase 3: Depths updated via recalc_depth(). + + At the end of Phase 3, the graph is stabilized with correct depth values. + + `root` is a special origin node fixed at depth=-1, with no children. + The actual transaction(s) under consideration get added as parents of + this root and hence they are depth=0. + + Rather than call-based recursion (cascades of notifications running up and + down the DAG) we use a task scheduler, provided by `add_ping()`, + `add_recalc_depth()` and `run_sched()`. + """ + debugging = False + + def __init__(self, validator): + self.validator = validator + + self._nodes = dict() # txid -> Node + + self.root = NodeRoot(self) + + self._waiting_nodes = [] + + # requested callbacks + self._sched_ping = set() + self._sched_recalc_depth = set() + + # create singletons for pruning + self.prunednodes = {v:NodeInactive(v, None) for v in validator.validity_states.keys()} + + # Threading rule: we never call node functions while locked. + # self._lock = ... # threading not enabled. + + def reset(self, ): + # copy nodes and reset self + prevnodes = self._nodes + TokenGraph.__init__(self, self.validator) + + # nuke Connections to encourage prompt GC + for n in prevnodes.values(): + try: + n.conn_children = [] + n.conn_parents = [] + except: + pass + + def debug(self, formatstr, *args): + if self.debugging: + print("DEBUG-DAG: " + formatstr%args, file=sys.stderr) + + def get_node(self, txid): + # with self._lock: + try: + node = self._nodes[txid] + except KeyError: + node = Node(txid, self) + self._nodes[txid] = node + self._waiting_nodes.append(node) + return node + + def replace_node(self, txid, replacement): + self._nodes[txid] = replacement # threadsafe + + def add_ping(self, node): + self._sched_ping.add(node) # threadsafe + def add_recalc_depth(self, node, depthpriority): + # currently ignoring depthpriority + self._sched_recalc_depth.add(node) # threadsafe + + def run_sched(self): + """ run the pings scheduled by add_ping() one at a time, until the + schedule list is empty (note: things can get added/re-added during run). + + then do the same for stuff added by add_recalc_depth(). + + TODO: consider making this depth prioritized to reduce redundant work. + """ + # should be threadsafe without lock (pop() is atomic) + while True: + try: + node = self._sched_ping.pop() + except KeyError: + return + node.ping() + while True: + try: + node = self._sched_recalc_depth.pop() + except KeyError: + return + node.recalc_depth() + + def get_waiting(self, maxdepth=INF_DEPTH): + """ Return a list of waiting nodes (that haven't had load_tx called + yet). Optional parameter specifying maximum depth. """ + # with self._lock: + # First, update the _waiting_nodes list. + waiting_actual = [node for node in self._waiting_nodes if node.waiting] + + # This is needed to handle an edge case in NFT1 validation + # this occurs when the child genesis is paused and is also the root_txid of the job + from .slp_validator_0x01_nft1 import Validator_NFT1 + if isinstance(self.validator, Validator_NFT1) and len(waiting_actual) == 0: + waiting_actual.extend([conn.parent for conn in self.root.conn_parents if conn.parent.waiting]) + + self._waiting_nodes = waiting_actual + + if maxdepth == INF_DEPTH: + return list(waiting_actual) # return copy + else: + return [node for node in waiting_actual + if node.depth <= maxdepth] + + def get_active(self): + return [node for node in self._nodes.values() if node.active] + + + def finalize_from_proxy(self, proxy_results): + """ + Iterate over remaining active nodes and set their validity to the proxy result, + starting from the deepest ones and moving up. + """ + active = self.get_active() + active = sorted(active, key = lambda x: x.depth, reverse=True) + + for n in active: + if not n.active or n.depth == INF_DEPTH: + # some nodes may switch to inactive or lose depth while we are updating; skip them + continue + txid = n.txid + try: + proxyval = proxy_results[txid] + except KeyError: + self.debug("Cannot find proxy validity for %.10s..."%(txid,)) + continue + self.debug("Using proxy validity (%r) for %.10s..."%(proxyval, txid,)) + + # every step: + n.set_validity(*proxyval) + self.run_sched() + + + +class Connection: + # Connection represents a tx output <-> tx input connection + # (we don't used namedtuple since we want 'parent' to be modifiable.) + __slots__ = ('parent', 'child', 'vout', 'vin', 'checked') + def __init__(self, parent,child,vout,vin): + self.parent = parent + self.child = child + self.vout = vout + self.vin = vin + self.checked = False + + +class Node: + """ + Nodes keep essential info about txes involved in the validation DAG. + They have a list of Connections to parents (inputs) and to children + (outputs). + + Connections to children are used to notify (via ping()) when: + - Node data became available (changed from waiting to live) + - Node conclusion reached (changed from active to inactive) + - Connection pruned (parent effectively inactive) + + Connections to parents are used to notify them when our depth gets + updated. + + When our node is active, it can either be in waiting state where the + transaction data is not yet available, or in a live state. + + The node becomes inactive when a conclusion is reached: either + pruned, invalid, or valid. When this occurs, the node replaces itself + with a NodeInactive object (more compact). + """ + def __init__(self, txid, graph): + self.txid = txid + self.graph = graph + self.conn_children = list() + self.conn_parents = () + self.depth = INF_DEPTH + self.waiting = True + self.active = True + self.validity = 0 # 0 - unknown, 1 - valid, 2 - invalid + self.myinfo = None # self-info from get_info(). + self.outputs = None # per-output info from get_info(). None if waiting/pruned/invalid. + # self._lock = ... # threading not enabled. + + @property + def status(self): + if self.waiting: + return 'waiting' + if self.active: + return 'live' + else: + return 'inactive' + + def __repr__(self,): + return "<%s %s txid=%r>"%(type(self).__qualname__, self.status, self.txid) + + + ## Child connection adding/removing + + def add_child(self, connection): + """ Called by children to subscribe notifications. + + (If inactive, a ping will be scheduled.) + """ + # with self._lock: + if not self.active: + connection.parent = self.replacement + self.graph.add_ping(connection.child) + return + if connection.parent is not self: + raise RuntimeError('mismatch') + + self.conn_children.append(connection) + newdepth = min(1 + connection.child.depth, + INF_DEPTH) + olddepth = self.depth + if newdepth < olddepth: + # found a shorter path from root + self.depth = newdepth + for c in self.conn_parents: + if c.parent.depth == 1 + olddepth: + # parent may have been hanging off our depth value. + self.graph.add_recalc_depth(c.parent, newdepth) + return + + def del_child(self, connection): + """ called by children to remove connection + """ + # with self._lock: + self.conn_children.remove(connection) + + if self.depth <= connection.child.depth+1: + self.graph.add_recalc_depth(self, self.depth) + + + ## Loading of info + + def load_tx(self, tx, cached_validity = None): + """ Convert 'waiting' transaction to live one. """ + # with self._lock: + if not self.waiting: + raise DoubleLoadException(self) + + if tx.txid_fast() != self.txid: + raise ValueError("TXID mismatch", tx.txid_fast(), self.txid) + + validator = self.graph.validator + ret = validator.get_info(tx) + + if len(ret) == 2: + self.graph.debug("%.10s... judged upon loading: %s", + self.txid, self.graph.validator.validity_states.get(ret[1],ret[1])) + if ret[0] != 'prune': + raise ValueError(ret) + return self._inactivate_self(False, ret[1]) + + vin_mask, self.myinfo, self.outputs = ret + + if len(self.outputs) != len(tx.outputs()): + raise ValueError("output length mismatch") + + if cached_validity is not None: + self.graph.debug("%.10s... cached judgement: %s", + self.txid, self.graph.validator.validity_states.get(cached_validity,cached_validity)) + return self._inactivate_self(True, cached_validity) + + # at this point we have exhausted options for inactivation. + # build connections to parents + txinputs = tx.inputs() + if len(vin_mask) != len(txinputs): + raise ValueError("input length mismatch") + + conn_parents = [] + for vin, (mask, inp) in enumerate(zip(vin_mask, txinputs)): + if not mask: + continue + txid = inp['prevout_hash'] + vout = inp['prevout_n'] + + p = self.graph.get_node(txid) + c = Connection(p,self,vout,vin) + p.add_child(c) + conn_parents.append(c) + self.conn_parents = conn_parents + + self.waiting = False + + self.graph.add_ping(self) + if len(self.conn_parents) != 0: + # (no parents? children will be pinged after validation) + for c in self.conn_children: + self.graph.add_ping(c.child) + + def load_pruned(self, cached_validity): + # with self._lock: + if not self.waiting: + raise DoubleLoadException(self) + + self.graph.debug("%.10s... load pruned: %s", + self.txid, self.graph.validator.validity_states.get(cached_validity,cached_validity)) + + return self._inactivate_self(False, cached_validity) + + def set_validity(self, keepinfo, validity): + # with self._lock: + self._inactivate_self(keepinfo, validity) + + ## Internal utility stuff + + def _inactivate_self(self, keepinfo, validity): + # Replace self with NodeInactive instance according to keepinfo and validity + # no thread locking here, this only gets called internally. + + if keepinfo: + replacement = NodeInactive(validity, self.outputs) + else: + replacement = self.graph.prunednodes[validity] # use singletons + + # replace self in lookups + self.graph.replace_node(self.txid, replacement) + + # unsubscribe from parents & forget + for c in self.conn_parents: + c.parent.del_child(c) + self.conn_parents = () + + # replace self in child connections & forget + for c in self.conn_children: + c.parent = replacement + c.checked = False + self.graph.add_ping(c.child) + self.conn_children = () + + # At this point all permanent refs to us should be gone and we will soon be deleted. + # Temporary refs may remain, for which we mimic the replacement. + self.waiting = False + self.active = False + self.depth = replacement.depth + self.validity = replacement.validity + self.outputs = replacement.outputs + self.replacement = replacement + + def recalc_depth(self): + # with self._lock: + if not self.active: + return + depths = [c.child.depth for c in self.conn_children] + depths.append(INF_DEPTH-1) + newdepth = 1 + min(depths) + olddepth = self.depth + if newdepth != olddepth: + self.depth = newdepth + depthpriority = 1 + min(olddepth, newdepth) + for c in self.conn_parents: + self.graph.add_recalc_depth(c.parent, depthpriority) + + def get_out_info(self, c): + # Get info for the connection and check if connection is needed. + # Returns None if validator's check_needed returns False. + # with self._lock: + try: + out = self.outputs[c.vout] + except TypeError: # outputs is None or vout is None + out = None + + if not c.checked and not self.waiting: + if c.child.graph.validator.check_needed(c.child.myinfo, out): + c.checked = True + else: + return None + + return (self.active, self.waiting, c.vin, self.validity, out) + + def ping(self, ): + """ handle notification status update on one or more parents """ + # with self._lock: + + if not self.active: + return + validator = self.graph.validator + + # get info, discarding unneeded parents. + pinfo = [] + for c in tuple(self.conn_parents): + info = c.parent.get_out_info(c) + if info is None: + c.parent.del_child(c) + self.conn_parents.remove(c) + else: + pinfo.append(info) + + anyactive = any(info[0] for info in pinfo) + + if validator.prevalidation: + if any(info[1] for info in pinfo): + return + else: + if anyactive: + return + + valinfo = [info[2:] for info in pinfo] + ret = validator.validate(self.myinfo, valinfo) + + if ret is None: # undecided + from .slp_validator_0x01_nft1 import Validator_NFT1 + if isinstance(validator, Validator_NFT1): + self.waiting = True + return + if not anyactive: + raise RuntimeError("Undecided with finalized parents", + self.txid, self.myinfo, valinfo) + return + else: # decided + self.graph.debug("%.10s... judgement based on inputs: %s", + self.txid, self.graph.validator.validity_states.get(ret[1],ret[1])) + self._inactivate_self(*ret) + + +class NodeRoot: # Special root, only one of these is created per TokenGraph. + depth = -1 + + def __init__(self, graph): + self.graph = graph + self.conn_parents = [] + def set_parents(self, parent_nodes): + # Remove existing parent connections + for c in tuple(self.conn_parents): + c.parent.del_child(c) + self.conn_parents.remove(c) + # Add new ones + for p in parent_nodes: + c = Connection(p, self, None, None) + p.add_child(c) + self.conn_parents.append(c) + return c + def ping(self,): + pass + + +# container used to replace Node with static result +class NodeInactive(collections.namedtuple('anon_namedtuple', + ['validity', 'outputs'])): + __slots__ = () # no dict needed + active = False + waiting = False + depth = INF_DEPTH + txid = None + status = "inactive" + + def get_out_info(self, c): + # Get info for the connection and check if connection is needed. + # Returns None if validator's check_needed returns False. + try: + out = self.outputs[c.vout] + except TypeError: # outputs is None or vout is None + out = None + + if not c.checked: + if c.child.graph.validator.check_needed(c.child.myinfo, out): + c.checked = True + else: + return None + + return (False, False, c.vin, self.validity, out) + + def load_tx(self, tx, cached_validity = None): + raise DoubleLoadException(self) + def add_child(self, connection): # refuse connection and ping + connection.child.graph.add_ping(connection.child) + def del_child(self, connection): pass + def recalc_depth(self): pass diff --git a/lib/slp_graph_search.py b/lib/slp_graph_search.py new file mode 100644 index 000000000..9d5063cab --- /dev/null +++ b/lib/slp_graph_search.py @@ -0,0 +1,204 @@ +""" + +Background search and batch download for graph transactions. + +This is used by slp_validator_0x01.py. + +To generate proto files use the following command: +python3 -m grpc_tools.protoc --proto_path=lib/ --python_out=lib/ --grpc_python_out=lib/ lib/slp_graphsearchrpc.proto + +""" + +import sys +import time +import threading +import queue +import traceback +import weakref +import collections +import json +import base64 +import requests +import codecs +from .transaction import Transaction +from .caches import ExpiringCache + +class SlpdbErrorNoSearchData(Exception): + pass + +class GraphSearchJob: + def __init__(self, txid, valjob_ref): + self.root_txid = txid + self.valjob = valjob_ref + + # metadata fetched from back end + self.depth_map = None + self.total_depth = None + self.txn_count_total = None + + # job status info + self.search_started = False + self.search_success = None + self.job_complete = False + self.exit_msg = None + self.depth_completed = 0 + self.depth_current_query = None + self.txn_count_progress = 0 + self.last_search_url = '(url empty)' + + # ctl + self.waiting_to_cancel = False + self.cancel_callback = None + self.fetch_retries = 0 + + # host for graph search + self.host = self.valjob.network.slp_gs_host + + def sched_cancel(self, callback=None, reason='job canceled'): + self.exit_msg = reason + if self.job_complete: + return + if not self.waiting_to_cancel: + self.waiting_to_cancel = True + self.cancel_callback = callback + return + + def _cancel(self): + self.job_complete = True + self.search_success = False + if self.cancel_callback: + self.cancel_callback(self) + + def set_success(self): + self.search_success = True + self.job_complete = True + + def set_failed(self, reason=None): + self.search_started = True + self.search_success = False + self.job_complete = True + self.exit_msg = reason + +class SlpGraphSearchManager: + """ + A single thread that processes graph search requests sequentially. + """ + def __init__(self, threadname="GraphSearch"): + # holds the job history and status + self.search_jobs = dict() + self.lock = threading.Lock() + + # Create a single use queue on a new thread + self.search_queue = queue.Queue() # TODO: make this a PriorityQueue based on dag size + + self.threadname = threadname + self.search_thread = threading.Thread(target=self.mainloop, name=self.threadname+'/search', daemon=True) + self.search_thread.start() + + def new_search(self, valjob_ref): + """ + Starts a new thread to fetch GS metadata for a job. + Depending on the metadata results the job may end up being added to the GS queue. + + Returns weakref of the new GS job object if new job is created. + """ + txid = valjob_ref.root_txid + with self.lock: + if txid not in self.search_jobs.keys(): + job = GraphSearchJob(txid, valjob_ref) + self.search_jobs[txid] = job + self.search_queue.put(job) + else: + job = self.search_jobs[txid] + return job + return None + + def restart_search(self, job): + def callback(job): + with self.lock: + self.search_jobs.pop(job.root_txid, None) + self.new_search(job.valjob) + job = None + if not job.job_complete: + job.sched_cancel(callback, reason='job restarted') + else: + callback(job) + + def mainloop(self,): + try: + while True: + job = self.search_queue.get(block=True) + job.search_started = True + if not job.valjob.running and not job.valjob.has_never_run: + job.set_failed('validation finished') + continue + try: + # search_query is a recursive call, most time will be spent here + self.search_query(job) + except Exception as e: + print("error in graph search query", e, file=sys.stderr) + job.set_failed(str(e)) + finally: + print("[SLP Graph Search] Error: mainloop exited.", file=sys.stderr) + + def search_query(self, job): + if job.waiting_to_cancel: + job._cancel() + return + if not job.valjob.running and not job.valjob.has_never_run: + job.set_failed('validation finished') + return + print('Requesting txid from gs++: ' + job.root_txid) + txid = codecs.encode(codecs.decode(job.root_txid,'hex')[::-1], 'hex').decode() + print('Requesting txid from gs++ (reversed): ' + txid) + + query_json = { "txid": txid } # TODO: handle 'validity_cache' exclusion from graph search (NOTE: this will impact total dl count) + reqresult = requests.post(job.valjob.network.slp_gs_host + "/v1/graphsearch/graphsearch", json=query_json, timeout=60) + try: + txns = json.loads(reqresult.content.decode('utf-8'))['txdata'] + except: + m = json.loads(reqresult.content) + if m["error"]: + raise Exception(m["error"]) + raise Exception(m) + for txn in txns: + job.txn_count_progress += 1 + tx = Transaction(base64.b64decode(txn).hex()) + SlpGraphSearchManager.tx_cache_put(tx) + job.set_success() + print("[SLP Graph Search] job success.") + + # This cache stores foreign (non-wallet) tx's we fetched from the network + # for the purposes of the "fetch_input_data" mechanism. Its max size has + # been thoughtfully calibrated to provide a decent tradeoff between + # memory consumption and UX. + # + # In even aggressive/pathological cases this cache won't ever exceed + # 100MB even when full. [see ExpiringCache.size_bytes() to test it]. + # This is acceptable considering this is Python + Qt and it eats memory + # anyway.. and also this is 2019 ;). Note that all tx's in this cache + # are in the non-deserialized state (hex encoded bytes only) as a memory + # savings optimization. Please maintain that invariant if you modify this + # code, otherwise the cache may grow to 10x memory consumption if you + # put deserialized tx's in here. + _fetched_tx_cache = ExpiringCache(maxlen=100000, name="GraphSearchTxnFetchCache") + + @classmethod + def tx_cache_get(cls, txid: str) -> object: + ''' Attempts to retrieve txid from the tx cache that this class + keeps in-memory. Returns None on failure. The returned tx is + not deserialized, and is a copy of the one in the cache. ''' + tx = cls._fetched_tx_cache.get(txid) + if tx is not None and tx.raw: + # make sure to return a copy of the transaction from the cache + # so that if caller does .deserialize(), *his* instance will + # use up 10x memory consumption, and not the cached instance which + # should just be an undeserialized raw tx. + return Transaction(tx.raw) + return None + + @classmethod + def tx_cache_put(cls, tx: bytes, txid: str = None): + ''' Puts a non-deserialized copy of tx into the tx_cache. ''' + txid = txid or Transaction._txid(tx.raw) # optionally, caller can pass-in txid to save CPU time for hashing + cls._fetched_tx_cache.put(txid, tx) diff --git a/lib/slp_proxying.py b/lib/slp_proxying.py new file mode 100644 index 000000000..8b363e88c --- /dev/null +++ b/lib/slp_proxying.py @@ -0,0 +1,104 @@ +""" +Background jobber to query an SLP proxy server. + +Proxy queries take the form of + +request: "give me SLP validity results for [txid0, txid1, txid2, txid3, ...]" +response: "true, false, false, true, ..." + +This is used by slp_validator_0x01.py. +""" + +import sys +import threading +import queue +import traceback +import weakref +import collections +import requests + +from .slp_dagging import INF_DEPTH + +# Endpoint hardcoded for now: +# https://tokengraph.network/verify/3979a8338e63883c865088e8f544a7b026ed4860c061e83c5ec158bf41492a74,... +# missing txes + +class ProxyQuerier: + """ + A single thread that processes proxy requests sequentially. + + (if more proxies are added, this should be split to abstract class) + """ + + def __init__(self, threadname="ProxyQuerier"): + # --- + self.queue = queue.Queue() + + self.pastresults = dict() + + # Kick off the thread + self.thread = threading.Thread(target=self.mainloop, name=threadname, daemon=True) + self.thread.start() + + def mainloop(self,): + try: + while True: + try: + job = self.queue.get(timeout=60) + except queue.Empty: + continue + txids, callback = job + + # query just the keys we don't yet know + #known = txids.intersection(self.pastresults.keys()) + #unk = txids.difference(known) + unk = txids.difference(self.pastresults.keys()) + + try: + qresults = self.query(unk) + except Exception as e: + # If query dies, keep going. + + print("error in proxy query", e, file=sys.stderr) + pass +# traceback.print_exc() + else: + # Got answer - update list. + self.pastresults.update(qresults) + + # Now construct results -- combines new and past results. + results = {} + for t in txids: + try: + results[t] = self.pastresults[t] + except KeyError: + pass + callback(txids, results) + finally: + print("Proxy thread died!", file=sys.stderr) + + def add_job(self,txids, callback): + """ Callback called as `callback(txids, results)` + where txids is set and results is txid-keyed dict. """ + txids = frozenset(txids) + self.queue.put((txids, callback)) + return txids + + def query(self,txids): + requrl = 'https://tokengraph.network/verify/' + ','.join(sorted(txids)) +# print(requrl, file=sys.stderr) + reqresult = requests.get(requrl, timeout=3) + resp = reqresult.json()['response'] + # response from tokengraph will be a list of records: + # - Record with errors = null : SLP-VALID + # - Record with errors = [stuff] : SLP-INVALID + # - Missing record : txid not found + ret = {} + for d in resp: + isvalid = (not d['errors']) + txid = d['tx'] + ret[txid] = isvalid + return ret + +tokengraph_proxy = ProxyQuerier() + diff --git a/lib/slp_validator_0x01.py b/lib/slp_validator_0x01.py new file mode 100644 index 000000000..75de85c58 --- /dev/null +++ b/lib/slp_validator_0x01.py @@ -0,0 +1,442 @@ +""" +Validate SLP token transactions with declared version 0x01. + +This uses the graph searching mechanism from slp_dagging.py +""" + +import threading +import queue +from typing import Tuple, List +import weakref + +from .transaction import Transaction +from .simple_config import get_config +from . import slp +from .slp import SlpMessage, SlpParsingError, SlpUnsupportedSlpTokenType, SlpInvalidOutputMessage +from .slp_dagging import TokenGraph, ValidationJob, ValidationJobManager, ValidatorGeneric +from .bitcoin import TYPE_SCRIPT +from .util import print_error, PrintError + +from . import slp_proxying # loading this module starts a thread. +from .slp_graph_search import SlpGraphSearchManager # thread is started upon instantiation + +class GraphContext(PrintError): + ''' Instance of the DAG cache. Uses a single per-instance + ValidationJobManager to validate SLP tokens if is_parallel=False. + + If is_parallel=True, will create 1 job manager (thread) per tokenid it is + validating. ''' + + def __init__(self, name='GraphContext', is_parallel=False, use_graph_search=False): + # Global db for shared graphs (each token_id_hex has its own graph). + self.graph_db_lock = threading.Lock() + self.graph_db = dict() # token_id_hex -> TokenGraph + self.is_parallel = is_parallel + self.job_mgrs = weakref.WeakValueDictionary() # token_id_hex -> ValidationJobManager (only used if is_parallel, otherwise self.job_mgr is used) + self.name = name + self.graph_search_mgr = SlpGraphSearchManager() if use_graph_search else None + self._setup_job_mgr() + + def diagnostic_name(self): + return self.name + + def _setup_job_mgr(self): + if self.is_parallel: + self.job_mgr = None + else: + self.job_mgr = self._new_job_mgr() + + def _new_job_mgr(self, suffix='') -> ValidationJobManager: + ret = ValidationJobManager(threadname=f'{self.name}/ValidationJobManager{suffix}', exit_when_done=self.is_parallel) + weakref.finalize(ret, print_error, f'[{ret.threadname}] finalized') # track object lifecycle + return ret + + def _get_or_make_mgr(self, token_id_hex: str) -> ValidationJobManager: + ''' Helper: This must be called with self.graph_db_lock held. + Creates a new job manager for token_id_hex if is_parallel=True and one + doesn't already exist, and returns it. + + Returns self.job_mgr if is_parallel=False. ''' + job_mgr = self.job_mgr or self.job_mgrs.get(token_id_hex) or self._new_job_mgr(token_id_hex[:4]) + if job_mgr is not self.job_mgr: + # was an is_parallel setup + assert not self.job_mgr and self.is_parallel and job_mgr + self.job_mgrs[token_id_hex] = job_mgr + return job_mgr + + def get_graph(self, token_id_hex) -> Tuple[TokenGraph, ValidationJobManager]: + ''' Returns an existing or new graph for a particular token. + A new job manager is created for that token if self.is_parallel=True, + otherwise the shared job manager is used.''' + with self.graph_db_lock: + try: + return self.graph_db[token_id_hex], self._get_or_make_mgr(token_id_hex) + except KeyError: + pass + + val = Validator_SLP1(token_id_hex) + + graph = TokenGraph(val) + + self.graph_db[token_id_hex] = graph + + return graph, self._get_or_make_mgr(token_id_hex) + + def kill_graph(self, token_id_hex): + ''' Reset a graph. This will stop all the jobs for that token_id_hex. ''' + with self.graph_db_lock: + try: + graph = self.graph_db.pop(token_id_hex) + job_mgr = self.job_mgrs.pop(token_id_hex, None) + except KeyError: + return + if job_mgr: + assert job_mgr is not self.job_mgr + job_mgr.kill() + elif self.job_mgr: + # todo: see if we can put this in the above 'with' block (while + # holding locks). I was hesitant to do so for fear of deadlocks. + self.job_mgr.stop_all_with_txid(token_id_hex) + + graph.reset() + + def kill(self): + ''' Kills all jobs and resets this instance to the state it had + when freshly constructed ''' + with self.graph_db_lock: + for token_id_hex, graph in self.graph_db.items(): + graph.reset() + job_mgr = self.job_mgrs.pop(token_id_hex, None) + if job_mgr: job_mgr.kill() + self.graph_db.clear() + self.job_mgrs.clear() + if self.job_mgr: + self.job_mgr.kill() + self._setup_job_mgr() # re-create a new, clean instance, if needed + + def setup_job(self, tx, reset=False) -> Tuple[TokenGraph, ValidationJobManager]: + """ Perform setup steps before validation for a given transaction. """ + slpMsg = SlpMessage.parseSlpOutputScript(tx.outputs()[0][1]) + + if slpMsg.transaction_type == 'GENESIS': + token_id_hex = tx.txid_fast() + elif slpMsg.transaction_type in ('MINT', 'SEND'): + token_id_hex = slpMsg.op_return_fields['token_id_hex'] + else: + return None + + if reset and not self.is_parallel: + try: + self.kill_graph(token_id_hex) + except KeyError: + pass + + graph, job_mgr = self.get_graph(token_id_hex) + + return graph, job_mgr + + @staticmethod + def get_validation_config(): + config = get_config() + try: + limit_dls = config.get('slp_validator_download_limit', None) + limit_depth = config.get('slp_validator_depth_limit', None) + proxy_enable = config.get('slp_validator_proxy_enabled', False) + except NameError: # in daemon mode (no GUI) 'config' is not defined + limit_dls = None + limit_depth = None + proxy_enable = False + + return limit_dls, limit_depth, proxy_enable + + @staticmethod + def get_gs_config(): + config = get_config() + try: + gs_enable = config.get('slp_validator_graphsearch_enabled', False) + gs_host = config.get('slpdb_host', None) + except NameError: # in daemon mode (no GUI) 'config' is not defined + gs_enable = False + gs_host = None + + return gs_enable, gs_host + + + def make_job(self, tx, wallet, network, *, debug=False, reset=False, callback_done=None, **kwargs) -> ValidationJob: + """ + Basic validation job maker for a single transaction. + Creates job and starts it running in the background thread. + Returns job, or None if it was not a validatable type. + + Note that the app-global 'config' object from simpe_config should be + defined before this is called. + """ + limit_dls, limit_depth, proxy_enable = self.get_validation_config() + gs_enable, gs_host = self.get_gs_config() + network.slpdb_host = gs_host + + try: + graph, job_mgr = self.setup_job(tx, reset=reset) + except (SlpParsingError, IndexError): + return + + txid = tx.txid_fast() + + num_proxy_requests = 0 + proxyqueue = queue.Queue() + + def proxy_cb(txids, results): + newres = {} + # convert from 'true/false' to (True,1) or (False,3) + for t,v in results.items(): + if v: + newres[t] = (True, 1) + else: + newres[t] = (True, 3) + proxyqueue.put(newres) + + def fetch_hook(txids, val_job): + l = [] + nonlocal gs_enable, gs_host + if gs_enable and gs_host and self.graph_search_mgr: + if val_job.root_txid not in self.graph_search_mgr.search_jobs.keys(): + search_job = self.graph_search_mgr.new_search(val_job) + val_job.graph_search_job = search_job if search_job else None + else: + gs_enable, gs_host = self.get_gs_config() + network.slpdb_host = gs_host + + for txid in txids: + txn = SlpGraphSearchManager.tx_cache_get(txid) + if txn: + l.append(txn) + else: + try: + l.append(wallet.transactions[txid]) + except KeyError: + pass + return l + + def done_callback(job): + # wait for proxy stuff to roll in + results = {} + try: + for _ in range(num_proxy_requests): + r = proxyqueue.get(timeout=5) + results.update(r) + except queue.Empty: + pass + + if proxy_enable: + graph.finalize_from_proxy(results) + + # Do consistency check here + # XXXXXXX + + # Save validity + for t,n in job.nodes.items(): + val = n.validity + if val != 0: + wallet.slpv1_validity[t] = val + + + job = ValidationJob(graph, txid, network, + fetch_hook=fetch_hook, + validitycache=wallet.slpv1_validity, + download_limit=limit_dls, + depth_limit=limit_depth, + debug=debug, ref=wallet, + **kwargs) + job.add_callback(done_callback) + + job_mgr.add_job(job) + + return job + + def stop_all_for_wallet(self, wallet, timeout=None) -> List[ValidationJob]: + ''' Stops all extant jobs for a particular wallet. This method is + intended to be called on wallet close so that all the work that + particular wallet enqueued can get cleaned up. This method properly + supports both is_parallel and single mode. Will return all the jobs + that matched as a list or the empty list if no jobs matched. + + Optional arg timeout, if not None and positive, will make this function + wait for the jobs to complete for up to timeout seconds per job.''' + jobs = [] + if self.job_mgr: + # single job manager mode + jobs = self.job_mgr.stop_all_for(wallet) + else: + # multi job-manager mode, iterate over all extant job managers + with self.graph_db_lock: + for txid, job_mgr in dict(self.job_mgrs).items(): + jobs += job_mgr.stop_all_for(wallet) + if timeout is not None and timeout > 0: + for job in jobs: + if job.running: + job.exited.wait(timeout=timeout) or self.print_error(f"Warning: Job {job} wait timed out (timeout={timeout})") + return jobs + + +# App-wide instance. Wallets share the results of the DAG lookups. +# This instance is shared so that we don't redundantly verify tokens for each +# wallet, but rather do it app-wide. Note that when wallet instances close +# while a verification is in progress, all extant jobs for that wallet are +# stopped -- ultimately stopping the entire DAG lookup for that token if all +# wallets verifying a token are closed. The next time a wallet containing that +# token is opened, however, the validation continues where it left off. +shared_context = GraphContext(is_parallel=True, use_graph_search=True) # <-- Set is_parallel=True if you want 1 thread per token (tokens validate in parallel). Otherwise there is 1 validator thread app-wide and tokens validate in series. + +class Validator_SLP1(ValidatorGeneric): + prevalidation = True # indicate we want to check validation when some inputs still active. + + validity_states = { + 0: 'Unknown', + 1: 'Valid', + 2: 'Invalid: not SLP / malformed SLP', + 3: 'Invalid: insufficient valid inputs', + 4: 'Invalid: token type different than required' + } + + def __init__(self, token_id_hex, *, enforced_token_type=1): + self.token_id_hex = token_id_hex + self.token_type = enforced_token_type + + def get_info(self, tx, *, diff_testing_mode=False): + """ + Enforce internal consensus rules (check all rules that don't involve + information from inputs). + + Prune if mismatched token_id_hex from this validator or SLP version other than 1. + + diff_testing_mode, allows None for token_type and token_id_hex for fuzzer testing + """ + txouts = tx.outputs() + if len(txouts) < 1: + return ('prune', 2) # not SLP -- no outputs! + + # We take for granted that parseSlpOutputScript here will catch all + # consensus-invalid op_return messages. In this procedure we check the + # remaining internal rules, having to do with the overall transaction. + try: + slpMsg = SlpMessage.parseSlpOutputScript(txouts[0][1]) + except SlpUnsupportedSlpTokenType as e: + # for unknown types: pruning as unknown has similar effect as pruning + # invalid except it tells the validity cacher to not remember this + # tx as 'bad' + return ('prune', 0) + except SlpInvalidOutputMessage as e: + return ('prune', 2) + + # Parse the SLP + if slpMsg.token_type not in [1,129]: + return ('prune', 0) + + # Check that the correct token_type is enforced (type 0x01 or 0x81) + if diff_testing_mode and self.token_type is not None and self.token_type != slpMsg.token_type: + return ('prune', 4) + elif not diff_testing_mode and self.token_type != slpMsg.token_type: + return ('prune', 4) + + if slpMsg.transaction_type == 'SEND': + token_id_hex = slpMsg.op_return_fields['token_id_hex'] + + # need to examine all inputs + vin_mask = (True,)*len(tx.inputs()) + + # myinfo is the output sum + # Note: according to consensus rules, we compute sum before truncating extra outputs. +# print("DEBUG SLP:getinfo %.10s outputs: %r"%(tx.txid(), slpMsg.op_return_fields['token_output'])) + myinfo = sum(slpMsg.op_return_fields['token_output']) + + # outputs straight from the token amounts + outputs = slpMsg.op_return_fields['token_output'] + elif slpMsg.transaction_type == 'GENESIS': + token_id_hex = tx.txid_fast() + + vin_mask = (False,)*len(tx.inputs()) # don't need to examine any inputs. + + myinfo = 'GENESIS' + + # place 'MINT' as baton signifier on the designated output + mintvout = slpMsg.op_return_fields['mint_baton_vout'] + if mintvout is None: + outputs = [None,None] + else: + outputs = [None]*(mintvout) + ['MINT'] + outputs[1] = slpMsg.op_return_fields['initial_token_mint_quantity'] + elif slpMsg.transaction_type == 'MINT': + token_id_hex = slpMsg.op_return_fields['token_id_hex'] + + vin_mask = (True,)*len(tx.inputs()) # need to examine all vins, even for baton. + + myinfo = 'MINT' + + # place 'MINT' as baton signifier on the designated output + mintvout = slpMsg.op_return_fields['mint_baton_vout'] + if mintvout is None: + outputs = [None,None] + else: + outputs = [None]*(mintvout) + ['MINT'] + outputs[1] = slpMsg.op_return_fields['additional_token_quantity'] + elif slpMsg.transaction_type == 'COMMIT': + return ('prune', 0) + + if diff_testing_mode and self.token_id_hex is not None and token_id_hex != self.token_id_hex: + return ('prune', 0) # mismatched token_id_hex + elif not diff_testing_mode and token_id_hex != self.token_id_hex: + return ('prune', 0) + + # truncate / expand outputs list to match tx outputs length + outputs = tuple(outputs[:len(txouts)]) + outputs = outputs + (None,)*(len(txouts) - len(outputs)) + + return vin_mask, myinfo, outputs + + + def check_needed(self, myinfo, out_n): + if myinfo == 'MINT': + # mints are only interested in the baton input + return (out_n == 'MINT') + if myinfo == 'GENESIS': + # genesis shouldn't have any parents, so this should not happen. + raise RuntimeError('Unexpected', out_n) + + # TRAN txes are only interested in integer, non-zero input contributions. + if out_n is None or out_n == 'MINT': + return False + else: + return (out_n > 0) + + + def validate(self, myinfo, inputs_info): + if myinfo == 'GENESIS': + if len(inputs_info) != 0: + raise RuntimeError('Unexpected', inputs_info) + return (True, 1) # genesis is always valid. + elif myinfo == 'MINT': + if not all(inp[2] == 'MINT' for inp in inputs_info): + raise RuntimeError('non-MINT inputs should have been pruned!', inputs_info) + if len(inputs_info) == 0: + return (False, 3) # no baton? invalid. + if any(inp[1] == 1 for inp in inputs_info): + # Why we use 'any' here: + # multiple 'valid' baton inputs are possible with double spending. + # technically 'valid' though miners will never confirm. + return (True, 1) + if all(inp[1] in [2,3,4] for inp in inputs_info): + return (False, 3) + return None + else: + # TRAN --- myinfo is an integer sum(outs) + + # Check whether from the unknown + valid inputs there could be enough to satisfy outputs. + insum_all = sum(inp[2] for inp in inputs_info if inp[1] <= 1) + if insum_all < myinfo: + return (False, 3) + + # Check whether the known valid inputs provide enough tokens to satisfy outputs: + insum_valid = sum(inp[2] for inp in inputs_info if inp[1] == 1) + if insum_valid >= myinfo: + return (True, 1) + return None diff --git a/lib/slp_validator_0x01_nft1.py b/lib/slp_validator_0x01_nft1.py new file mode 100644 index 000000000..00644923f --- /dev/null +++ b/lib/slp_validator_0x01_nft1.py @@ -0,0 +1,446 @@ +""" +Validate SLP token transactions with declared version 0x65. + +This uses the graph searching mechanism from slp_dagging.py +""" + +import threading +import queue +from typing import Tuple +import warnings +import weakref + +from .transaction import Transaction +from . import slp +from .slp import SlpMessage, SlpParsingError, SlpUnsupportedSlpTokenType, SlpInvalidOutputMessage +from .slp_dagging import TokenGraph, ValidationJob, ValidationJobManager, ValidatorGeneric +from .bitcoin import TYPE_SCRIPT +from .util import print_error +from .slp_validator_0x01 import Validator_SLP1, GraphContext + +from . import slp_proxying # loading this module starts a thread. +from . import slp_graph_search # thread doesn't start until instantiation, one thread per search job, w/ shared txn cache + +class GraphContext_NFT1(GraphContext): + ''' Instance of the NFT1 DAG cache. Uses a single per-instance + ValidationJobManager to validate SLP tokens. ''' + + def __init__(self, name="GraphContext_NFT1"): #, is_parallel=False): # NFT1 has not been tested with is_parallel=True + super().__init__(name=name) #, is_parallel=is_parallel) + + def _new_job_mgr(self, suffix='') -> ValidationJobManager: + ret = ValidationJobManager(threadname=f'{self.name}/ValidationJobManager{suffix}', graph_context=self, exit_when_done=False) #self.is_parallel) + weakref.finalize(ret, print_error, f'{ret.threadname} finalized') + return ret + + def get_graph(self, token_id_hex, token_type) -> Tuple[TokenGraph, ValidationJobManager]: + with self.graph_db_lock: + try: + return self.graph_db[token_id_hex], self._get_or_make_mgr(token_id_hex) + except KeyError: + pass + + if token_type == 129: + val = Validator_SLP1(token_id_hex, enforced_token_type=129) + elif token_type == 65: + val = Validator_NFT1(token_id_hex, self.job_mgr) + + graph = TokenGraph(val) + + self.graph_db[token_id_hex] = graph + + return graph, self._get_or_make_mgr(token_id_hex) + + + def setup_job(self, tx, reset=False) -> Tuple[TokenGraph, ValidationJobManager]: + """ Perform setup steps before validation for a given transaction. """ + slpMsg = SlpMessage.parseSlpOutputScript(tx.outputs()[0][1]) + + if slpMsg.token_type not in [65, 129]: + raise SlpParsingError("NFT1 invalid if parent or child transaction is of SLP type " + str(slpMsg.token_type)) + + if slpMsg.transaction_type == 'GENESIS': + token_id_hex = tx.txid_fast() + elif slpMsg.transaction_type in ('MINT', 'SEND'): + token_id_hex = slpMsg.op_return_fields['token_id_hex'] + else: + return None + + if reset: + try: + self.kill_graph(token_id_hex) + except KeyError: + pass + + graph, job_mgr = self.get_graph(token_id_hex, slpMsg.token_type) + + return graph, job_mgr + + + def make_job(self, tx, wallet, network, nft_type, *, debug=False, reset=False, callback_done=None, **kwargs) -> ValidationJob: + """ + Basic validation job maker for a single transaction. + Creates job and starts it running in the background thread. + Returns job, or None if it was not a validatable type. + + Note that the app-global 'config' object from simpe_config should be + defined before this is called. + """ + limit_dls, limit_depth, proxy_enable = self.get_validation_config() + + try: + graph, job_mgr = self.setup_job(tx, reset=reset) + except (SlpParsingError, IndexError): + return + + # fixme -- wouldn't subsequent wallet instances clobber previous ones?! + # graph.validator.wallet = wallet + # graph.validator.network = network + + txid = tx.txid_fast() + + num_proxy_requests = 0 + proxyqueue = queue.Queue() + + def proxy_cb(txids, results): + newres = {} + # convert from 'true/false' to (True,1) or (False,3) + for t,v in results.items(): + if v: + newres[t] = (True, 1) + else: + newres[t] = (True, 3) + proxyqueue.put(newres) + + def fetch_hook(txids, val_job): + l = [] + for txid in txids: + try: + l.append(wallet.transactions[txid]) + except KeyError: + pass + if proxy_enable: + proxy.add_job(txids, proxy_cb) + nonlocal num_proxy_requests + num_proxy_requests += 1 + return l + + def done_callback(job): + # wait for proxy stuff to roll in + results = {} + try: + for _ in range(num_proxy_requests): + r = proxyqueue.get(timeout=5) + results.update(r) + except queue.Empty: + pass + + if proxy_enable: + graph.finalize_from_proxy(results) + + # Do consistency check here + # XXXXXXX + + # Save validity + for t,n in job.nodes.items(): + val = n.validity + if val != 0: + wallet.slpv1_validity[t] = val + + if nft_type == 'SLP65': + job = ValidationJobNFT1Child(graph, txid, network, + fetch_hook=fetch_hook, + validitycache=None, #wallet.slpv1_validity, + download_limit=limit_dls, + depth_limit=limit_depth, + debug=debug, + was_reset=reset, + ref=wallet, + **kwargs) + elif nft_type == 'SLP129': + job = ValidationJob(graph, txid, network, + fetch_hook=fetch_hook, + validitycache=None, #wallet.slpv1_validity, + download_limit=limit_dls, + depth_limit=limit_depth, + debug=debug, + ref=wallet, + **kwargs) + else: + raise RuntimeError('Invalid NFT type provided.') + + job.add_callback(done_callback) + job_mgr.add_job(job) + return job + +class ValidationJobNFT1Child(ValidationJob): + def __init__(self, graph, txids, network, + fetch_hook=None, + validitycache=None, + download_limit=None, depth_limit=None, + debug=False, was_reset=False, ref=None): + self.was_reset = was_reset + self.genesis_tx = None + self.nft_parent_tx = None + self.nft_parent_validity = 0 + super().__init__(graph, txids, network, fetch_hook, validitycache, download_limit, depth_limit, debug, ref) + +# App-wide instance. Wallets share the results of the DAG lookups. +# This instance is shared so that we don't redundantly verify tokens for each +# wallet, but rather do it app-wide. Note that when wallet instances close +# while a verification is in progress, all extant jobs for that wallet are +# stopped -- ultimately stopping the entire DAG lookup for that token if all +# wallets verifying a token are closed. The next time a wallet containing that +# token is opened, however, the validation continues where it left off. +shared_context_nft1 = GraphContext_NFT1() + +class Validator_NFT1(ValidatorGeneric): + prevalidation = True # indicate we want to check validation when some inputs still active. + + validity_states = { + 0: 'Unknown', + 1: 'Valid', + 2: 'Invalid: not SLP / malformed SLP', + 3: 'Invalid: insufficient valid inputs', + 4: 'Invalid: bad parent for child NFT' + } + + def __init__(self, token_id_hex, jobmgr): + self.token_id_hex = token_id_hex + self.validation_jobmgr = jobmgr + + def get_info(self,tx): + """ + Enforce internal consensus rules (check all rules that don't involve + information from inputs). + + Prune if mismatched token_id_hex from this validator or SLP version other than 65. + """ + txouts = tx.outputs() + if len(txouts) < 1: + return ('prune', 2) # not SLP -- no outputs! + + # We take for granted that parseSlpOutputScript here will catch all + # consensus-invalid op_return messages. In this procedure we check the + # remaining internal rules, having to do with the overall transaction. + try: + slpMsg = SlpMessage.parseSlpOutputScript(txouts[0][1]) + except SlpUnsupportedSlpTokenType as e: + # for unknown types: pruning as unknown has similar effect as pruning + # invalid except it tells the validity cacher to not remember this + # tx as 'bad' + return ('prune', 0) + except SlpInvalidOutputMessage as e: + return ('prune', 2) + + # Parse the SLP + if slpMsg.token_type not in [65]: + return ('prune', 0) + + if slpMsg.transaction_type == 'SEND': + token_id_hex = slpMsg.op_return_fields['token_id_hex'] + + # need to examine all inputs + vin_mask = (True,)*len(tx.inputs()) + + # myinfo is the output sum + # Note: according to consensus rules, we compute sum before truncating extra outputs. + # print("DEBUG SLP:getinfo %.10s outputs: %r"%(tx.txid(), slpMsg.op_return_fields['token_output'])) + myinfo = sum(slpMsg.op_return_fields['token_output']) + + # Cannot have more than 1 SLP output w/ child NFT (vout 0 op_return msg & vout 1 qty) + if len(slpMsg.op_return_fields['token_output']) != 2: + return ('prune', 2) + + # Cannot have quantity other than 1 as output at vout 1 + if slpMsg.op_return_fields['token_output'][1] != 1: + return ('prune', 2) + + # outputs straight from the token amounts + outputs = slpMsg.op_return_fields['token_output'] + elif slpMsg.transaction_type == 'GENESIS': + token_id_hex = tx.txid_fast() + + vin_mask = (False,)*len(tx.inputs()) # don't need to examine any inputs. + + myinfo = 'GENESIS' + + mintvout = slpMsg.op_return_fields['mint_baton_vout'] + if mintvout is not None: + return ('prune', 2) + decimals = slpMsg.op_return_fields['decimals'] + if decimals != 0: + return ('prune', 2) + outputs = [None,None] + outputs[1] = slpMsg.op_return_fields['initial_token_mint_quantity'] + if outputs[1] > 1: + return ('prune', 2) + elif slpMsg.transaction_type == 'MINT': + return ('prune', 2) + elif slpMsg.transaction_type == 'COMMIT': + return ('prune', 0) + + if token_id_hex != self.token_id_hex: + return ('prune', 0) # mismatched token_id_hex + + # truncate / expand outputs list to match tx outputs length + outputs = tuple(outputs[:len(txouts)]) + outputs = outputs + (None,)*(len(txouts) - len(outputs)) + + return vin_mask, myinfo, outputs + + + def check_needed(self, myinfo, out_n): + if myinfo == 'GENESIS': + # genesis shouldn't have any parents, so this should not happen. + raise RuntimeError('Unexpected', out_n) + + # TRAN txes are only interested in integer, non-zero input contributions. + if out_n is None or out_n == 'MINT': + return False + else: + return (out_n > 0) + + def download_nft_genesis(self, nft_child_job, done_callback): + def dl_cb(resp): + if resp.get('error'): + raise Exception(resp['error'].get('message')) + raw = resp.get('result') + tx = Transaction(raw) + assert tx.txid_fast() == self.token_id_hex + txid = self.token_id_hex + wallet = nft_child_job.ref() + with wallet.lock: + if not wallet.transactions.get(txid, None): + wallet.transactions[txid] = tx + if not wallet.tx_tokinfo.get(txid, None): + from .slp import SlpMessage + slpMsg = SlpMessage.parseSlpOutputScript(tx.outputs()[0][1]) + tti = { 'type':'SLP%d'%(slpMsg.token_type,), + 'transaction_type':slpMsg.transaction_type, + 'token_id': txid, + 'validity': 0, + } + wallet.tx_tokinfo[txid] = tti + wallet.save_transactions() + nft_child_job.genesis_tx = tx + if done_callback: + done_callback() + requests = [('blockchain.transaction.get', [self.token_id_hex]), ] + nft_child_job.network.send(requests, dl_cb) + + def download_nft_parent_tx(self, nft_child_job, done_callback): + def dl_cb(resp): + if resp.get('error'): + raise Exception(resp['error'].get('message')) + raw = resp.get('result') + tx = Transaction(raw) + txid = tx.txid_fast() + wallet = nft_child_job.ref() + with wallet.lock: + if not wallet.transactions.get(txid, None): + wallet.transactions[txid] = tx + if not wallet.tx_tokinfo.get(txid, None): + slpMsg = SlpMessage.parseSlpOutputScript(tx.outputs()[0][1]) + tti = { 'type':'SLP%d'%(slpMsg.token_type,), + 'transaction_type':slpMsg.transaction_type, + 'validity': 0, + } + if slpMsg.transaction_type == 'GENESIS': + tti['token_id'] = txid + else: + tti['token_id'] = slpMsg.op_return_fields['token_id_hex'] + wallet.tx_tokinfo[txid] = tti + wallet.save_transactions() + nft_child_job.nft_parent_tx = tx + if done_callback: + done_callback() + nft_parent_txid = nft_child_job.genesis_tx.inputs()[0]['prevout_hash'] + requests = [('blockchain.transaction.get', [nft_parent_txid]), ] + nft_child_job.network.send(requests, dl_cb) + + def start_NFT_parent_job(self, nft_child_job, done_callback): + wallet = nft_child_job.ref() + network = nft_child_job.network + def callback(job): + (txid,node), = job.nodes.items() + val = node.validity + group_id = wallet.tx_tokinfo[nft_child_job.nft_parent_tx.txid_fast()]['token_id'] + if not wallet.token_types.get(group_id, None): + name = wallet.token_types[nft_child_job.genesis_tx.txid_fast()]['name'] + '-parent' + #decimals = SlpMessage.parseSlpOutputScript(wallet.transactions[group_id].outputs()[0][1]).op_return_fields['decimals'] + parent_entry = dict({'class':'SLP129','name':name,'decimals':0}) # TODO: handle case where decimals is not 0 + wallet.add_token_type(group_id, parent_entry) + with wallet.lock: + wallet.token_types[nft_child_job.genesis_tx.txid_fast()]['group_id'] = group_id + wallet.tx_tokinfo[nft_child_job.nft_parent_tx.txid_fast()]['validity'] = val + wallet.tx_tokinfo[nft_child_job.genesis_tx.txid_fast()]['validity'] = val + wallet.save_transactions() + ui_cb = wallet.ui_emit_validity_updated + if ui_cb: + ui_cb(txid, val) + ui_cb(nft_child_job.genesis_tx.txid_fast(), val) + if done_callback: + done_callback(val) + + tx = nft_child_job.nft_parent_tx + job = self.validation_jobmgr.graph_context and self.validation_jobmgr.graph_context.make_job(tx, wallet, network, nft_type='SLP129', debug=nft_child_job.debug, reset=nft_child_job.was_reset) + if job is not None: + job.add_callback(callback) + elif self.validation_jobmgr.graph_context is None: + # FIXME? + warnings.warn("Graph Context is None, JobManager was killed") + else: + with wallet.lock: + wallet.tx_tokinfo[nft_child_job.genesis_tx.txid_fast()]['validity'] = 4 + wallet.save_transactions() + ui_cb = wallet.ui_emit_validity_updated + if ui_cb: + ui_cb(nft_child_job.genesis_tx.txid_fast(), 4) + if done_callback: + done_callback(4) + + def validate_NFT_parent(self, nft_child_job, myinfo): + def restart_nft_job(val): + nft_child_job.nft_parent_validity = val + self.validation_jobmgr.unpause_job(nft_child_job) + #self.nft_child_job = None # release reference + + def start_nft_parent_validation(): + self.start_NFT_parent_job(nft_child_job, done_callback=restart_nft_job) + + def start_dl_nft_parent(): + self.download_nft_parent_tx(nft_child_job, done_callback=start_nft_parent_validation) + + self.validation_jobmgr.pause_job(nft_child_job) + self.download_nft_genesis(nft_child_job, start_dl_nft_parent) + + def validate(self, myinfo, inputs_info): + nft_child_job = self.validation_jobmgr.job_current + + # NFT requires parent validation pre-valid phase + if nft_child_job.nft_parent_tx is None: + self.validate_NFT_parent(nft_child_job, myinfo) + return None + + if myinfo == 'GENESIS': + if len(inputs_info) != 0: + raise RuntimeError('Unexpected', inputs_info) + if nft_child_job.nft_parent_validity == 1: + return (True, 1) + elif nft_child_job.nft_parent_validity > 1: + return (False, nft_child_job.nft_parent_validity) + return None + else: + # TRAN --- myinfo is an integer sum(outs) + + # Check whether from the unknown + valid inputs there could be enough to satisfy outputs. + insum_all = sum(inp[2] for inp in inputs_info if inp[1] <= 1) + if insum_all < myinfo: + return (False, 3) + + # Check whether the known valid inputs provide enough tokens to satisfy outputs: + insum_valid = sum(inp[2] for inp in inputs_info if inp[1] == 1) + if insum_valid >= myinfo: + return (True, 1) + return None diff --git a/lib/storage.py b/lib/storage.py index f9a62cfa3..31f0b9b5f 100644 --- a/lib/storage.py +++ b/lib/storage.py @@ -90,6 +90,9 @@ def __init__(self, path, manual_upgrades=False): def load_data(self, s): try: self.data = json.loads(s) + + # Sanity check: wallet should be a quack like a dict. This throws if not. + self.data.get("dummy") except: try: d = ast.literal_eval(s) @@ -262,14 +265,20 @@ def _write(self): f.flush() 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 + default_mode = stat.S_IREAD | stat.S_IWRITE try: - os.rename(temp_path, self.path) - except: - os.remove(self.path) - os.rename(temp_path, self.path) + mode = os.stat(self.path).st_mode if self.file_exists() else default_mode + except FileNotFoundError: + mode = default_mode + self._file_exists = False + + if not self.file_exists(): + # See: https://github.com/spesmilo/electrum/issues/5082 + assert not os.path.exists(self.path) + os.replace(temp_path, self.path) os.chmod(self.path, mode) + self.raw = s + self._file_exissts = True self.print_error("saved", self.path) self.modified = False diff --git a/lib/synchronizer.py b/lib/synchronizer.py index f5cecd92c..8c2dc97e8 100644 --- a/lib/synchronizer.py +++ b/lib/synchronizer.py @@ -22,10 +22,11 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. + from threading import Lock import hashlib +import traceback -# from .bitcoin import Hash, hash_encode from .transaction import Transaction from .util import ThreadJob, bh2u @@ -48,7 +49,8 @@ def __init__(self, wallet, network): # Entries are (tx_hash, tx_height) tuples self.requested_tx = {} self.requested_histories = {} - self.requested_addrs = set() + self.requested_hashes = set() + self.h2addr = {} self.lock = Lock() self.initialized = False @@ -62,7 +64,7 @@ def parse_response(self, response): def is_up_to_date(self): return (not self.requested_tx and not self.requested_histories - and not self.requested_addrs) + and not self.requested_hashes) def release(self): self.network.unsubscribe(self.on_address_status) @@ -73,9 +75,11 @@ def add(self, address): self.new_addresses.add(address) def subscribe_to_addresses(self, addresses): - if addresses: - self.requested_addrs |= addresses - self.network.subscribe_to_addresses(addresses, self.on_address_status) + hashes = [addr.to_scripthash_hex() for addr in addresses] + # Keep a hash -> address mapping + self.h2addr.update({h:addr for h, addr in zip(hashes, addresses)}) + self.network.subscribe_to_scripthashes(hashes, self.on_address_status) + self.requested_hashes |= set(hashes) def get_status(self, h): if not h: @@ -91,15 +95,17 @@ def on_address_status(self, response): params, result = self.parse_response(response) if not params: return - addr = params[0] + scripthash = params[0] + addr = self.h2addr.get(scripthash, None) + if not addr: + return # Bad server response? history = self.wallet.history.get(addr, []) if self.get_status(history) != result: - if self.requested_histories.get(addr) is None: - self.requested_histories[addr] = result - self.network.request_address_history(addr, self.on_address_history) + if self.requested_histories.get(scripthash) is None: + self.requested_histories[scripthash] = result + self.network.request_scripthash_history(scripthash, self.on_address_history) # remove addr from list only after it is added to requested_histories - if addr in self.requested_addrs: # Notifications won't be in - self.requested_addrs.remove(addr) + self.requested_hashes.discard(scripthash) # Notifications won't be in def on_address_history(self, response): if self.wallet.synchronizer is None and self.initialized: @@ -107,8 +113,12 @@ def on_address_history(self, response): params, result = self.parse_response(response) if not params: return - addr = params[0] - server_status = self.requested_histories.get(addr) + scripthash = params[0] + addr = self.h2addr.get(scripthash, None) + if not addr or not scripthash in self.requested_histories: + return # Bad server response? + # Remove request; this allows up_to_date to be True + server_status = self.requested_histories.pop(scripthash) if server_status is None: self.print_error("receiving history (unsolicited)", addr, len(result)) return @@ -132,8 +142,6 @@ def on_address_history(self, response): self.wallet.receive_history_callback(addr, hist, tx_fees) # Request transactions we don't have self.request_missing_txs(hist) - # Remove request; this allows up_to_date to be True - self.requested_histories.pop(addr) def tx_response(self, response): if self.wallet.synchronizer is None and self.initialized: @@ -147,6 +155,7 @@ def tx_response(self, response): try: tx.deserialize() except Exception: + traceback.print_exc() self.print_msg("cannot deserialize transaction, skipping", tx_hash) return tx_height = self.requested_tx.pop(tx_hash) @@ -177,17 +186,13 @@ def initialize(self): addresses, and request any transactions in its address history we don't have. ''' + # FIXME: encapsulation for history in self.wallet.history.values(): - # Old electrum servers returned ['*'] when all history for - # the address was pruned. This no longer happens but may - # remain in old wallets. - if history == ['*']: - continue self.request_missing_txs(history) if self.requested_tx: self.print_error("missing tx", self.requested_tx) - self.subscribe_to_addresses(set(self.wallet.get_addresses())) + self.subscribe_to_addresses(self.wallet.get_addresses()) self.initialized = True def run(self): diff --git a/lib/tests/test_cashaddrenc.py b/lib/tests/test_cashaddrenc.py new file mode 100644 index 000000000..0d93f16fa --- /dev/null +++ b/lib/tests/test_cashaddrenc.py @@ -0,0 +1,183 @@ +#!/usr/bin/python3 + +# Copyright (c) 2017 Pieter Wuille +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +"""Reference tests for cashaddr adresses""" + +import binascii +import unittest +import random +from .. import cashaddr + + +BCH_PREFIX = "bitcoincash" +BCH_TESTNET_PREFIX = "bchtest" + +VALID_PUBKEY_ADDRESSES = [ + "bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a", + "bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy", + "bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r" +] + +VALID_SCRIPT_ADDRESSES = [ + "bitcoincash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h829pq", + "bitcoincash:pr95sy3j9xwd2ap32xkykttr4cvcu7as4yc93ky28e", + "bitcoincash:pqq3728yw0y47sqn6l2na30mcw6zm78dzq5ucqzc37" +] + +VALID_HASHES = [ + bytes([ 118, 160, 64, 83, 189, 160, 168, 139, 218, 81, + 119, 184, 106, 21, 195, 178, 159, 85, 152, 115 ]), + bytes([ 203, 72, 18, 50, 41, 156, 213, 116, 49, 81, + 172, 75, 45, 99, 174, 25, 142, 123, 176, 169 ]), + bytes([ 1, 31, 40, 228, 115, 201, 95, 64, 19, 215, + 213, 62, 197, 251, 195, 180, 45, 248, 237, 16 ]), +] + + +class TestCashAddrAddress(unittest.TestCase): + """Unit test class for cashaddr addressess.""" + + # Valid address sizes from the cashaddr spec + valid_sizes = [160, 192, 224, 256, 320, 384, 448, 512] + + def test_encode_bad_inputs(self): + with self.assertRaises(TypeError): + cashaddr.encode_full(2, cashaddr.PUBKEY_TYPE, bytes(20)) + with self.assertRaises(TypeError): + cashaddr.encode_full(BCH_PREFIX, cashaddr.PUBKEY_TYPE, '0' * 40) + with self.assertRaises(ValueError): + cashaddr.encode_full(BCH_PREFIX, 15, bytes(20)) + + def test_encode_decode(self): + """Test whether valid addresses encode and decode properly, for all + valid hash sizes. + """ + for prefix in (BCH_PREFIX, BCH_TESTNET_PREFIX): + for bits_size in self.valid_sizes: + size = bits_size // 8 + # Convert to a valid number of bytes for a hash + hashbytes = bytes(random.randint(0, 255) for i in range(size)) + addr = cashaddr.encode_full(prefix, cashaddr.PUBKEY_TYPE, + hashbytes) + rprefix, kind, addr_hash = cashaddr.decode(addr) + self.assertEqual(rprefix, prefix) + self.assertEqual(kind, cashaddr.PUBKEY_TYPE) + self.assertEqual(addr_hash, hashbytes) + + def test_bad_encode_size(self): + """Test that bad sized hashes fail to encode.""" + for bits_size in self.valid_sizes: + size = bits_size // 8 + # Make size invalid + size += 1 + # Convert to a valid number of bytes for a hash + hashbytes = bytes(random.randint(0, 255) for i in range(size)) + with self.assertRaises(ValueError): + cashaddr.encode_full(BCH_PREFIX, cashaddr.PUBKEY_TYPE, + hashbytes) + + def test_decode_bad_inputs(self): + with self.assertRaises(TypeError): + cashaddr.decode(b'foobar') + + def test_bad_decode_size(self): + """Test that addresses with invalid sizes fail to decode.""" + for bits_size in self.valid_sizes: + size = bits_size // 8 + # Convert to a valid number of bytes for a hash + hashbytes = bytes(random.randint(0, 255) for i in range(size)) + payload = cashaddr._pack_addr_data(cashaddr.PUBKEY_TYPE, hashbytes) + # Add some more 5-bit data after size has been encoded + payload += bytes(random.randint(0, 15) for i in range(3)) + # Add checksum + payload += cashaddr._create_checksum(BCH_PREFIX, payload) + addr = BCH_PREFIX + ':' + ''.join(cashaddr._CHARSET[d] for d in payload) + # Check decode fails. This can trigger the length mismatch, + # excess padding, or non-zero padding errors + with self.assertRaises(ValueError): + cashaddr.decode(addr) + + def test_address_case(self): + prefix, kind, hash160 = cashaddr.decode("bitcoincash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h829pq") + assert prefix == "bitcoincash" + prefix, kind, hash160 = cashaddr.decode("BITCOINCASH:PPM2QSZNHKS23Z7629MMS6S4CWEF74VCWVN0H829PQ") + assert prefix == "BITCOINCASH" + with self.assertRaises(ValueError): + cashaddr.decode("bitcoincash:PPM2QSZNHKS23Z7629MMS6S4CWEF74VCWVN0H829PQ") + with self.assertRaises(ValueError): + cashaddr.decode("bitcoincash:ppm2qsznhks23z7629mmS6s4cwef74vcwvn0h829pq") + + def test_prefix(self): + with self.assertRaises(ValueError): + cashaddr.decode(":ppm2qsznhks23z7629mms6s4cwef74vcwvn0h82") + with self.assertRaises(ValueError): + cashaddr.decode("ppm2qsznhks23z7629mms6s4cwef74vcwvn0h82") + with self.assertRaises(ValueError): + cashaddr.decode("bitcoin cash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h82") + with self.assertRaises(ValueError): + cashaddr.decode("bitcoin cash:ab") + # b is invalid + with self.assertRaises(ValueError): + cashaddr.decode("bitcoincash:ppm2qsznbks23z7629mms6s4cwef74vcwvn0h82") + + def test_bad_decode_checksum(self): + """Test whether addresses with invalid checksums fail to decode.""" + for bits_size in self.valid_sizes: + size = bits_size // 8 + # Convert to a valid number of bytes for a hash + hashbytes = bytes(random.randint(0, 255) for i in range(size)) + addr = cashaddr.encode_full(BCH_PREFIX, cashaddr.PUBKEY_TYPE, + hashbytes) + addrlist = list(addr) + # Inject an error + values = list(cashaddr._CHARSET) + while True: + pos = random.randint(0, len(addr) - 1) + choice = random.choice(values) + if choice != addrlist[pos] and addrlist[pos] in values: + addrlist[pos] = choice + break + + mangled_addr = ''.join(addrlist) + with self.assertRaises(ValueError) as e: + cashaddr.decode(mangled_addr) + self.assertTrue('invalid checksum' in e.exception.args[0]) + + def test_valid_scripthash(self): + """Test whether valid P2PK addresses decode to the correct output.""" + for (address, hashbytes) in zip(VALID_SCRIPT_ADDRESSES, VALID_HASHES): + rprefix, kind, addr_hash = cashaddr.decode(address) + self.assertEqual(rprefix, BCH_PREFIX) + self.assertEqual(kind, cashaddr.SCRIPT_TYPE) + self.assertEqual(addr_hash, hashbytes) + + def test_valid_pubkeys(self): + """Test whether valid P2SH addresses decode to the correct output.""" + for (address, hashbytes) in zip(VALID_PUBKEY_ADDRESSES, VALID_HASHES): + rprefix, kind, addr_hash = cashaddr.decode(address) + self.assertEqual(rprefix, BCH_PREFIX) + self.assertEqual(kind, cashaddr.PUBKEY_TYPE) + self.assertEqual(addr_hash, hashbytes) + +if __name__ == '__main__': + unittest.main() diff --git a/lib/tests/test_slp_consensus.py b/lib/tests/test_slp_consensus.py new file mode 100644 index 000000000..985cd49b1 --- /dev/null +++ b/lib/tests/test_slp_consensus.py @@ -0,0 +1,244 @@ +import unittest +from pprint import pprint +from queue import Queue, Empty + +from time import sleep +from lib.slp_validator_0x01_nft1 import ValidationJobNFT1Child + +from lib.address import ScriptOutput + +from lib.transaction import Transaction + +import json + +from lib import slp +from lib import slp_validator_0x01 +from lib import slp_validator_0x01_nft1 +from lib.storage import WalletStorage +from lib.wallet import Slp_ImportedAddressWallet + +import requests +import os + +scripttests_local = os.path.abspath('../slp-unit-test-data/script_tests.json') +scripttests_url = 'https://raw.githubusercontent.com/simpleledger/slp-unit-test-data/master/script_tests.json' #https://simpleledger.cash/slp-unit-test-data/script_tests.json' + +txintests_local = os.path.abspath('../slp-unit-test-data/tx_input_tests.json') +txintests_url = 'https://raw.githubusercontent.com/simpleledger/slp-unit-test-data/master/tx_input_tests.json' #'https://simpleledger.cash/slp-unit-test-data/tx_input_tests.json' + +errorcodes = { + # no-error maps to None + + # various script format errors + ('Bad OP_RETURN', 'Script error'): 1, + # disallowed opcodes + ('Bad OP_RETURN', 'Non-push opcode'): 2, + ('Bad OP_RETURN', 'OP_1NEGATE to OP_16 not allowed'): 2, + ('Bad OP_RETURN', 'OP_0 not allowed'): 2, + + # not OP_RETURN script / not SLP + # (note in some implementations, parsers should never be given such non-SLP scripts in the first place. In such implementations, error code 3 tests may be skipped.) + ('Bad OP_RETURN', 'No OP_RETURN'): 3, + ('Empty OP_RETURN', ): 3, + ('Not SLP',): 3, + + # 10- field bytesize is wrong + ('Field has wrong length', ): 10, + ('Ticker too long', ): 10, + ('Token document hash is incorrect length',): 10, + ('token_id is wrong length',): 10, + + # 11- improper value + ('Too many decimals',): 11, + ('Bad transaction type',): 11, + ('Mint baton cannot be on vout=0 or 1',): 11, + + # 12- missing field / too few fields + ('Missing output amounts', ): 12, + ('Missing token_type', ): 12, + ('Missing SLP command', ): 12, + ('GENESIS with incorrect number of parameters', ): 12, + ('SEND with too few parameters', ): 12, + ('MINT with incorrect number of parameters', ): 12, + + # specific + ('More than 19 output amounts',): 21, + + # specific - NFT1 GENESIS + ('Cannot have a minting baton in a NFT_CHILD token.', ): 22, + ('NFT1 child token must have divisibility set to 0 decimal places.', ): 22, + ('NFT1 child token must have GENESIS quantity of 1.', ): 22, + + #SlpUnsupportedSlpTokenType : 255 below +} + +# We need a mock network because of the NFT1 validator +class MockNetwork: + def __init__(self, txes): + self.txes = txes + def send(self, req, dl_cb): + try: + result = {'result':self.txes[req[0][1][0]].raw} + except KeyError: + result = {'error':{'message':'unknown txid ' + req[0][1][0] + ' ' + str(self.txes)}} + sleep(0.001) + dl_cb(result) + +class SLPConsensusTests(unittest.TestCase): + def test_opreturns(self): + try: + with open(scripttests_local) as f: + testlist = json.load(f) + print("Got script tests from %s; will not download."%(scripttests_local,)) + except IOError: + print("Couldn't get script tests from %s; downloading from %s..."%(scripttests_local,scripttests_url)) + testlist = requests.get(scripttests_url).json() + + print("Starting %d tests on SLP's OP_RETURN parser"%len(testlist)) + for d in testlist: + description = d['msg'] + scripthex = d['script'] + code = d['code'] + if scripthex is None: + continue + if hasattr(code, '__iter__'): + expected_codes = tuple(code) + else: + expected_codes = (code, ) + + with self.subTest(description=description, script=scripthex): + sco = ScriptOutput(bytes.fromhex(scripthex)) + try: + msg = slp.SlpMessage.parseSlpOutputScript(sco) + except Exception as e: + if isinstance(e, slp.SlpInvalidOutputMessage): + emsg = e.args + if errorcodes[emsg] not in expected_codes: + raise AssertionError("Invalidity reason %r (code: %d) not in expected reasons %r"%(emsg, errorcodes[emsg], expected_codes)) + elif isinstance(e, slp.SlpUnsupportedSlpTokenType): + if 255 not in expected_codes: + raise AssertionError("SlpUnsupportedSlpTokenType exception raised (code 255) but not in expected reasons (%r)"%(expected_codes,)) + else: + raise + else: + # no exception + if None not in expected_codes: + raise AssertionError("Script was found valid but should have been invalid, for a reason code in %r."%(expected_codes,)) + + pass + + + def test_inputs(self): + try: + with open(txintests_local) as f: + testlist = json.load(f) + print("Got script tests from %s; will not download."%(txintests_local,)) + except IOError: + print("Couldn't get script tests from %s; downloading from %s..."%(txintests_local,txintests_url)) + testlist = requests.get(txintests_url).json() + + print("Starting %d tests on SLP's input validation"%len(testlist)) + for test in testlist: + description = test['description'] + + given_validity = {} + #should_validity = {} + txes = {} + for d in test['when']: + tx = Transaction(d['tx']) + txid = tx.txid() + txes[txid] = tx + if d['valid'] is True: + given_validity[txid] = 1 + elif d['valid'] is False: + given_validity[txid] = 2 + else: + raise ValueError(d['valid']) + + for d in test['should']: + tx = Transaction(d['tx']) + txid = tx.txid() + txes[txid] = tx + d['txid'] = txid + #if d['valid'] is True: + #should_validity[txid] = 1 + #elif d['valid'] is False: + #should_validity[txid] = 2 + #else: + #raise ValueError(d['valid']) + + graph_context, graph_context_nft1 = slp_validator_0x01.GraphContext(), slp_validator_0x01_nft1.GraphContext_NFT1() + + for i, d in enumerate(test['should']): + txid = d['txid'] + with self.subTest(description=description, i=i): + try: + slp_msg = slp.SlpMessage.parseSlpOutputScript(txes[txid].outputs()[0][1]) + if slp_msg.token_type == 1: + graph, job_mgr = graph_context.setup_job(txes[txid], reset=True) + elif slp_msg.token_type == 65 or slp_msg.token_type == 129: + graph, job_mgr = graph_context_nft1.setup_job(txes[txid], reset=True) + else: + raise slp.SlpUnsupportedSlpTokenType(slp_msg.token_type) + except slp.SlpInvalidOutputMessage: # If output 0 is not OP_RETURN + self.assertEqual(d['valid'], False) + continue + except slp.SlpUnsupportedSlpTokenType: + self.assertEqual(d['valid'], False) + continue + + def fetch_hook(txids, job): + l = [] + for txid in txids: + try: + l.append(txes[txid]) + except KeyError: + #raise Exception('KEY ERROR ' + txid) + pass + ### Call proxy here! + return l + + if slp_msg.token_type == 1: + job = slp_validator_0x01.ValidationJob(graph, txid, None, + fetch_hook = fetch_hook, validitycache=given_validity) + elif slp_msg.token_type == 65: + network = MockNetwork(txes) + storage = WalletStorage(os.path.curdir, manual_upgrades=True, in_memory_only=True) + wallet = Slp_ImportedAddressWallet(storage) + wallet.slp_graph_0x01_nft = graph_context_nft1 + #raise Exception(txid) + job = slp_validator_0x01_nft1.ValidationJobNFT1Child(graph, txid, network, + fetch_hook=fetch_hook, validitycache=None, ref=wallet) + elif slp_msg.token_type == 129: + job = slp_validator_0x01_nft1.ValidationJob(graph, txid, None, + fetch_hook=fetch_hook, validitycache=given_validity) + #if txid == '8a08b78ae434de0b1a26e56ae7e78bb11b20f8240eb3d97371fd46a609df7fc3': + #graph.debugging = True + #job.debugging_graph_state = True + q = Queue() + job.add_callback(q.put) + job_mgr.add_job(job) + while True: + try: + q.get(timeout=3) # unlimited timeout + except Empty: + raise RuntimeError("Timeout during validation unit test") + # if isinstance(job, ValidationJobNFT1Child) and not job.paused:# and job.stop_reason != 'inconclusive': + # raise Exception(job.stop_reason) + if not job.paused and not job.running: # and job.stop_reason != 'inconclusive': + n = next(iter(job.nodes.values())) + if d['valid'] is True: + self.assertEqual(n.validity, 1) + elif d['valid'] is False: + if test.get('allow_inconclusive', False): # "allow_inconclusive" allows for ending with an "unvalidated" state for harder corner-cases + self.assertIn(n.validity, (0,2,3,4)) + else: + self.assertIn(n.validity, (2,3,4)) + else: + raise ValueError(d['valid']) + break + else: + if len(job.callbacks) > 1: + raise Exception("shouldn't have more than 1 callback") + job.callbacks.clear() + job.add_callback(q.put, allow_run_cb_now=False) diff --git a/lib/tests/test_transaction.py b/lib/tests/test_transaction.py index bed59b363..4e302cb56 100644 --- a/lib/tests/test_transaction.py +++ b/lib/tests/test_transaction.py @@ -1,6 +1,7 @@ import unittest from lib import transaction +from lib.address import Address from lib.bitcoin import TYPE_ADDRESS from lib.keystore import xpubkey_to_address from lib.util import bh2u, bfh @@ -57,7 +58,7 @@ def test_tx_unsigned(self): 'overwintered': False, 'inputs': [{ 'type': 'p2pkh', - 'address': 't1LvhooU7zQuqEtjZZN83EL8QSBUkd8WkHR', + 'address': Address.from_string('t1LvhooU7zQuqEtjZZN83EL8QSBUkd8WkHR'), 'num_sig': 1, 'prevout_hash': '3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a', 'prevout_n': 0, @@ -68,7 +69,7 @@ def test_tx_unsigned(self): 'x_pubkeys': ['ff0488b21e03ef2afea18000000089689bff23e1e7fb2f161daa37270a97a3d8c2e537584b2d304ecb47b86d21fc021b010d3bd425f8cf2e04824bfdf1f1f5ff1d51fadd9a41f9e3fb8dd3403b1bfe00000000']}], 'lockTime': 0, 'outputs': [{ - 'address': 't1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z', + 'address': Address.from_string('t1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z'), 'prevout_n': 0, 'scriptPubKey': '76a914230ac37834073a42146f11ef8414ae929feaafc388ac', 'type': TYPE_ADDRESS, @@ -80,12 +81,12 @@ def test_tx_unsigned(self): self.assertEqual(tx.deserialize(), None) self.assertEqual(tx.as_dict(), {'hex': unsigned_blob, 'complete': False, 'final': True}) - self.assertEqual(tx.get_outputs(), [('t1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z', 1000000)]) - self.assertEqual(tx.get_output_addresses(), ['t1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z']) + self.assertEqual(tx.get_outputs(), [(Address.from_string('t1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z'), 1000000)]) + self.assertEqual(tx.get_output_addresses(), [Address.from_string('t1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z')]) - self.assertTrue(tx.has_address('t1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z')) - self.assertTrue(tx.has_address('t1LvhooU7zQuqEtjZZN83EL8QSBUkd8WkHR')) - self.assertFalse(tx.has_address('t1VHL1RP9LS7otTAqQJqFncJvfMUkwHriZr')) + self.assertTrue(tx.has_address(Address.from_string('t1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z'))) + self.assertTrue(tx.has_address(Address.from_string('t1LvhooU7zQuqEtjZZN83EL8QSBUkd8WkHR'))) + self.assertFalse(tx.has_address(Address.from_string('t1VHL1RP9LS7otTAqQJqFncJvfMUkwHriZr'))) self.assertEqual(tx.serialize(), unsigned_blob) @@ -102,7 +103,7 @@ def test_tx_signed(self): 'overwintered': False, 'inputs': [{ 'type': 'p2pkh', - 'address': 't1LvhooU7zQuqEtjZZN83EL8QSBUkd8WkHR', + 'address': Address.from_string('t1LvhooU7zQuqEtjZZN83EL8QSBUkd8WkHR'), 'num_sig': 1, 'prevout_hash': '3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a', 'prevout_n': 0, @@ -113,7 +114,7 @@ def test_tx_signed(self): 'x_pubkeys': ['02e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae35cdb84d2f6']}], 'lockTime': 0, 'outputs': [{ - 'address': 't1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z', + 'address': Address.from_string('t1M4tYuzKx46ARb7hDcdnMAjkx8Acdrbd9Z'), 'prevout_n': 0, 'scriptPubKey': '76a914230ac37834073a42146f11ef8414ae929feaafc388ac', 'type': TYPE_ADDRESS, diff --git a/lib/tests/test_wallet_vertical.py b/lib/tests/test_wallet_vertical.py index 260bf9a94..e3e3fa4ca 100644 --- a/lib/tests/test_wallet_vertical.py +++ b/lib/tests/test_wallet_vertical.py @@ -1,6 +1,7 @@ import unittest from unittest import mock +from lib.address import Address import lib.bitcoin as bitcoin import lib.keystore as keystore import lib.storage as storage @@ -74,8 +75,8 @@ def test_electrum_seed_standard(self, mock_write): w = WalletIntegrityHelper.create_standard_wallet(ks) self.assertEqual(w.txin_type, 'p2pkh') - self.assertEqual(w.get_receiving_addresses()[0], 't1WuqhS6byNzoKu3LJrpfv2BGp7oiATxzyn') - self.assertEqual(w.get_change_addresses()[0], 't1fvzMLWaFGdJnsJ7K5Ju1jMuC3bKexciyL') + self.assertEqual(w.get_receiving_addresses()[0], Address.from_string('t1WuqhS6byNzoKu3LJrpfv2BGp7oiATxzyn')) + self.assertEqual(w.get_change_addresses()[0], Address.from_string('t1fvzMLWaFGdJnsJ7K5Ju1jMuC3bKexciyL')) @mock.patch.object(storage.WalletStorage, '_write') def test_electrum_seed_old(self, mock_write): @@ -92,8 +93,8 @@ def test_electrum_seed_old(self, mock_write): w = WalletIntegrityHelper.create_standard_wallet(ks) self.assertEqual(w.txin_type, 'p2pkh') - self.assertEqual(w.get_receiving_addresses()[0], 't1YAqEWYrfi9CbW5LgmayAvjDE5T5MgaYiD') - self.assertEqual(w.get_change_addresses()[0], 't1cJ799hEFa5AHmB3ReeDo3Rr2X4quderf4') + self.assertEqual(w.get_receiving_addresses()[0], Address.from_string('t1YAqEWYrfi9CbW5LgmayAvjDE5T5MgaYiD')) + self.assertEqual(w.get_change_addresses()[0], Address.from_string('t1cJ799hEFa5AHmB3ReeDo3Rr2X4quderf4')) @mock.patch.object(storage.WalletStorage, '_write') def test_bip39_seed_bip44_standard(self, mock_write): @@ -110,8 +111,8 @@ def test_bip39_seed_bip44_standard(self, mock_write): w = WalletIntegrityHelper.create_standard_wallet(ks) self.assertEqual(w.txin_type, 'p2pkh') - self.assertEqual(w.get_receiving_addresses()[0], 't1fiRjRCGirFb2PprWzYdXjukAAuYB89Gzz') - self.assertEqual(w.get_change_addresses()[0], 't1SVeFNqPq3SzuuzWmbJjj6TX9W8kv7eWJL') + self.assertEqual(w.get_receiving_addresses()[0], Address.from_string('t1fiRjRCGirFb2PprWzYdXjukAAuYB89Gzz')) + self.assertEqual(w.get_change_addresses()[0], Address.from_string('t1SVeFNqPq3SzuuzWmbJjj6TX9W8kv7eWJL')) @mock.patch.object(storage.WalletStorage, '_write') def test_electrum_multisig_seed_standard(self, mock_write): @@ -132,8 +133,8 @@ def test_electrum_multisig_seed_standard(self, mock_write): w = WalletIntegrityHelper.create_multisig_wallet(ks1, ks2) self.assertEqual(w.txin_type, 'p2sh') - self.assertEqual(w.get_receiving_addresses()[0], 't3eXoCQLLLZnf95PhrrQhoDTsGdR8FwLhXe') - self.assertEqual(w.get_change_addresses()[0], 't3RoTuEy2FbMgP3urUmYTFpbajjbMpECh9S') + self.assertEqual(w.get_receiving_addresses()[0], Address.from_string('t3eXoCQLLLZnf95PhrrQhoDTsGdR8FwLhXe')) + self.assertEqual(w.get_change_addresses()[0], Address.from_string('t3RoTuEy2FbMgP3urUmYTFpbajjbMpECh9S')) @mock.patch.object(storage.WalletStorage, '_write') def test_bip39_multisig_seed_bip45_standard(self, mock_write): @@ -154,6 +155,6 @@ def test_bip39_multisig_seed_bip45_standard(self, mock_write): w = WalletIntegrityHelper.create_multisig_wallet(ks1, ks2) self.assertEqual(w.txin_type, 'p2sh') - self.assertEqual(w.get_receiving_addresses()[0], 't3YmFV8iPfehb2aVAmod4bEqFVwQTGf4j1i') - self.assertEqual(w.get_change_addresses()[0], 't3ND13q6EWVnYUme5Ko6VAYxPbP6bae2RcH') + self.assertEqual(w.get_receiving_addresses()[0], Address.from_string('t3YmFV8iPfehb2aVAmod4bEqFVwQTGf4j1i')) + self.assertEqual(w.get_change_addresses()[0], Address.from_string('t3ND13q6EWVnYUme5Ko6VAYxPbP6bae2RcH')) diff --git a/lib/transaction.py b/lib/transaction.py index 76ce0b658..db34a985b 100644 --- a/lib/transaction.py +++ b/lib/transaction.py @@ -29,8 +29,9 @@ from .util import print_error, profiler -from . import bitcoin from .bitcoin import * +from .address import (PublicKey, Address, Script, ScriptOutput, hash160, UnknownAddress, OpCodes, + P2PKH_prefix, P2PKH_suffix, P2SH_prefix, P2SH_suffix) import struct import traceback import sys @@ -170,47 +171,6 @@ def _write_num(self, format, num): self.write(s) -# enum-like type -# From the Python Cookbook, downloaded from http://code.activestate.com/recipes/67107/ -class EnumException(Exception): - pass - - -class Enumeration: - def __init__(self, name, enumList): - self.__doc__ = name - lookup = { } - reverseLookup = { } - i = 0 - uniqueNames = [ ] - uniqueValues = [ ] - for x in enumList: - if isinstance(x, tuple): - x, i = x - if not isinstance(x, str): - raise EnumException("enum name is not a string: " + x) - if not isinstance(i, int): - raise EnumException("enum value is not an integer: " + i) - if x in uniqueNames: - raise EnumException("enum name is not unique: " + x) - if i in uniqueValues: - raise EnumException("enum value is not unique for " + x) - uniqueNames.append(x) - uniqueValues.append(i) - lookup[x] = i - reverseLookup[i] = x - i = i + 1 - self.lookup = lookup - self.reverseLookup = reverseLookup - - def __getattr__(self, attr): - if attr not in self.lookup: - raise AttributeError - return self.lookup[attr] - def whatis(self, value): - return self.reverseLookup[value] - - # This function comes from bitcointools, bct-LICENSE.txt. def long_hex(bytes): return bytes.encode('hex_codec') @@ -222,31 +182,6 @@ def short_hex(bytes): return t return t[0:4]+"..."+t[-4:] - - -opcodes = Enumeration("Opcodes", [ - ("OP_0", 0), ("OP_PUSHDATA1",76), "OP_PUSHDATA2", "OP_PUSHDATA4", "OP_1NEGATE", "OP_RESERVED", - "OP_1", "OP_2", "OP_3", "OP_4", "OP_5", "OP_6", "OP_7", - "OP_8", "OP_9", "OP_10", "OP_11", "OP_12", "OP_13", "OP_14", "OP_15", "OP_16", - "OP_NOP", "OP_VER", "OP_IF", "OP_NOTIF", "OP_VERIF", "OP_VERNOTIF", "OP_ELSE", "OP_ENDIF", "OP_VERIFY", - "OP_RETURN", "OP_TOALTSTACK", "OP_FROMALTSTACK", "OP_2DROP", "OP_2DUP", "OP_3DUP", "OP_2OVER", "OP_2ROT", "OP_2SWAP", - "OP_IFDUP", "OP_DEPTH", "OP_DROP", "OP_DUP", "OP_NIP", "OP_OVER", "OP_PICK", "OP_ROLL", "OP_ROT", - "OP_SWAP", "OP_TUCK", "OP_CAT", "OP_SUBSTR", "OP_LEFT", "OP_RIGHT", "OP_SIZE", "OP_INVERT", "OP_AND", - "OP_OR", "OP_XOR", "OP_EQUAL", "OP_EQUALVERIFY", "OP_RESERVED1", "OP_RESERVED2", "OP_1ADD", "OP_1SUB", "OP_2MUL", - "OP_2DIV", "OP_NEGATE", "OP_ABS", "OP_NOT", "OP_0NOTEQUAL", "OP_ADD", "OP_SUB", "OP_MUL", "OP_DIV", - "OP_MOD", "OP_LSHIFT", "OP_RSHIFT", "OP_BOOLAND", "OP_BOOLOR", - "OP_NUMEQUAL", "OP_NUMEQUALVERIFY", "OP_NUMNOTEQUAL", "OP_LESSTHAN", - "OP_GREATERTHAN", "OP_LESSTHANOREQUAL", "OP_GREATERTHANOREQUAL", "OP_MIN", "OP_MAX", - "OP_WITHIN", "OP_RIPEMD160", "OP_SHA1", "OP_SHA256", "OP_HASH160", - "OP_HASH256", "OP_CODESEPARATOR", "OP_CHECKSIG", "OP_CHECKSIGVERIFY", "OP_CHECKMULTISIG", - "OP_CHECKMULTISIGVERIFY", - ("OP_NOP1", 0xB0), - ("OP_CHECKLOCKTIMEVERIFY", 0xB1), ("OP_CHECKSEQUENCEVERIFY", 0xB2), - "OP_NOP4", "OP_NOP5", "OP_NOP6", "OP_NOP7", "OP_NOP8", "OP_NOP9", "OP_NOP10", - ("OP_INVALIDOPCODE", 0xFF), -]) - - def script_GetOp(_bytes): i = 0 while i < len(_bytes): @@ -254,15 +189,15 @@ def script_GetOp(_bytes): opcode = _bytes[i] i += 1 - if opcode <= opcodes.OP_PUSHDATA4: + if opcode <= OpCodes.OP_PUSHDATA4: nSize = opcode - if opcode == opcodes.OP_PUSHDATA1: + if opcode == OpCodes.OP_PUSHDATA1: nSize = _bytes[i] i += 1 - elif opcode == opcodes.OP_PUSHDATA2: + elif opcode == OpCodes.OP_PUSHDATA2: (nSize,) = struct.unpack_from(' 0: result += " " - if opcode <= opcodes.OP_PUSHDATA4: + if opcode <= OpCodes.OP_PUSHDATA4: result += "%d:"%(opcode,) result += short_hex(vch) else: @@ -291,7 +226,7 @@ def match_decoded(decoded, to_match): if len(decoded) != len(to_match): return False; for i in range(len(decoded)): - if to_match[i] == opcodes.OP_PUSHDATA4 and decoded[i][0] <= opcodes.OP_PUSHDATA4 and decoded[i][0]>0: + if to_match[i] == OpCodes.OP_PUSHDATA4 and decoded[i][0] <= OpCodes.OP_PUSHDATA4 and decoded[i][0]>0: continue # Opcodes below OP_PUSHDATA4 all just push data onto stack, and are equivalent. if to_match[i] != decoded[i][0]: return False @@ -309,21 +244,25 @@ def safe_parse_pubkey(x): def parse_scriptSig(d, _bytes): try: - decoded = [ x for x in script_GetOp(_bytes) ] + decoded = list(script_GetOp(_bytes)) except Exception as e: # coinbase transactions raise an exception print_error("parse_scriptSig: cannot find address in input script (coinbase?)", bh2u(_bytes)) return - match = [ opcodes.OP_PUSHDATA4 ] + # added to suppress print_error statements during lib/test_slp_consensus.py (uses 'fake' transactions that have empty scriptSig) + if len(decoded) == 0: + return + + match = [ OpCodes.OP_PUSHDATA4 ] if match_decoded(decoded, match): item = decoded[0][1] if item[0] != 0: # assert item[0] == 0x30 # pay-to-pubkey d['type'] = 'p2pk' - d['address'] = "(pubkey)" + d['address'] = UnknownAddress() d['signatures'] = [bh2u(item)] d['num_sig'] = 1 d['x_pubkeys'] = ["(pubkey)"] @@ -333,10 +272,11 @@ def parse_scriptSig(d, _bytes): # p2pkh TxIn transactions push a signature # (71-73 bytes) and then their public key # (33 or 65 bytes) onto the stack: - match = [ opcodes.OP_PUSHDATA4, opcodes.OP_PUSHDATA4 ] + match = [ OpCodes.OP_PUSHDATA4, OpCodes.OP_PUSHDATA4 ] if match_decoded(decoded, match): sig = bh2u(decoded[0][1]) - x_pubkey = bh2u(decoded[1][1]) + pubkey = decoded[1][1] + x_pubkey = pubkey.hex() try: signatures = parse_sig([sig]) pubkey, address = xpubkey_to_address(x_pubkey) @@ -349,11 +289,11 @@ def parse_scriptSig(d, _bytes): d['x_pubkeys'] = [x_pubkey] d['num_sig'] = 1 d['pubkeys'] = [pubkey] - d['address'] = address + d['address'] = Address.from_string(address) return # p2sh transaction, m of n - match = [ opcodes.OP_0 ] + [ opcodes.OP_PUSHDATA4 ] * (len(decoded) - 1) + match = [ OpCodes.OP_0 ] + [ OpCodes.OP_PUSHDATA4 ] * (len(decoded) - 1) if match_decoded(decoded, match): x_sig = [bh2u(x[1]) for x in decoded[1:-1]] try: @@ -371,7 +311,7 @@ def parse_scriptSig(d, _bytes): d['x_pubkeys'] = x_pubkeys d['pubkeys'] = pubkeys d['redeemScript'] = redeemScript - d['address'] = hash160_to_p2sh(hash_160(bfh(redeemScript))) + d['address'] = Address.from_P2SH_hash(hash160(redeemScript)) return print_error("parse_scriptSig: cannot find address in input script (unknown)", @@ -381,42 +321,43 @@ def parse_scriptSig(d, _bytes): def parse_redeemScript(s): dec2 = [ x for x in script_GetOp(s) ] try: - m = dec2[0][0] - opcodes.OP_1 + 1 - n = dec2[-2][0] - opcodes.OP_1 + 1 + m = dec2[0][0] - OpCodes.OP_1 + 1 + n = dec2[-2][0] - OpCodes.OP_1 + 1 except IndexError: raise NotRecognizedRedeemScript() - op_m = opcodes.OP_1 + m - 1 - op_n = opcodes.OP_1 + n - 1 - match_multisig = [ op_m ] + [opcodes.OP_PUSHDATA4]*n + [ op_n, opcodes.OP_CHECKMULTISIG ] + op_m = OpCodes.OP_1 + m - 1 + op_n = OpCodes.OP_1 + n - 1 + match_multisig = [ op_m ] + [OpCodes.OP_PUSHDATA4]*n + [ op_n, OpCodes.OP_CHECKMULTISIG ] if not match_decoded(dec2, match_multisig): raise NotRecognizedRedeemScript() x_pubkeys = [bh2u(x[1]) for x in dec2[1:-2]] pubkeys = [safe_parse_pubkey(x) for x in x_pubkeys] - redeemScript = multisig_script(pubkeys, m) + redeemScript = Script.multisig_script(m, [bytes.fromhex(p) for p in pubkeys]) return m, n, x_pubkeys, pubkeys, redeemScript def get_address_from_output_script(_bytes, *, net=None): - decoded = [x for x in script_GetOp(_bytes)] + scriptlen = len(_bytes) - # The Genesis Block, self-payments, and pay-by-IP-address payments look like: - # 65 BYTES:... CHECKSIG - match = [ opcodes.OP_PUSHDATA4, opcodes.OP_CHECKSIG ] - if match_decoded(decoded, match): - return TYPE_PUBKEY, bh2u(decoded[0][1]) + if scriptlen == 23 and _bytes.startswith(P2SH_prefix) and _bytes.endswith(P2SH_suffix): + # Pay-to-script-hash + return TYPE_ADDRESS, Address.from_P2SH_hash(_bytes[2:22]) - # Pay-by-ZClassic-address TxOuts look like: - # DUP HASH160 20 BYTES:... EQUALVERIFY CHECKSIG - match = [ opcodes.OP_DUP, opcodes.OP_HASH160, opcodes.OP_PUSHDATA4, opcodes.OP_EQUALVERIFY, opcodes.OP_CHECKSIG ] - if match_decoded(decoded, match): - return TYPE_ADDRESS, hash160_to_p2pkh(decoded[2][1], net=net) + if scriptlen == 25 and _bytes.startswith(P2PKH_prefix) and _bytes.endswith(P2PKH_suffix): + # Pay-to-pubkey-hash + return TYPE_ADDRESS, Address.from_P2PKH_hash(_bytes[3:23]) - # p2sh - match = [ opcodes.OP_HASH160, opcodes.OP_PUSHDATA4, opcodes.OP_EQUAL ] - if match_decoded(decoded, match): - return TYPE_ADDRESS, hash160_to_p2sh(decoded[1][1], net=net) + if scriptlen == 35 and _bytes[0] == 33 and _bytes[1] in (2,3) and _bytes[34] == opcodes.OP_CHECKSIG: + # Pay-to-pubkey (compressed) + return TYPE_PUBKEY, PublicKey.from_pubkey(_bytes[1:34]) - return TYPE_SCRIPT, bh2u(_bytes) + if scriptlen == 67 and _bytes[0] == 65 and _bytes[1] == 4 and _bytes[66] == opcodes.OP_CHECKSIG: + # Pay-to-pubkey (uncompressed) + return TYPE_PUBKEY, PublicKey.from_pubkey(_bytes[1:66]) + + # note: we don't recognize bare multisigs. + + return TYPE_SCRIPT, ScriptOutput(bytes(_bytes)) def parse_input(vds): @@ -438,15 +379,12 @@ def parse_input(vds): d['scriptSig'] = bh2u(scriptSig) else: d['type'] = 'unknown' - if scriptSig: - d['scriptSig'] = bh2u(scriptSig) - try: - parse_scriptSig(d, scriptSig) - except BaseException: - traceback.print_exc(file=sys.stderr) - print_error('failed to parse scriptSig', bh2u(scriptSig)) - else: - d['scriptSig'] = '' + d['scriptSig'] = bh2u(scriptSig) + try: + parse_scriptSig(d, scriptSig) + except BaseException: + traceback.print_exc(file=sys.stderr) + print_error('failed to parse scriptSig', bh2u(scriptSig)) return d @@ -541,8 +479,8 @@ def multisig_script(public_keys, m): n = len(public_keys) assert n <= 15 assert m <= n - op_m = format(opcodes.OP_1 + m - 1, 'x') - op_n = format(opcodes.OP_1 + n - 1, 'x') + op_m = format(OpCodes.OP_1 + m - 1, 'x') + op_n = format(OpCodes.OP_1 + n - 1, 'x') keylist = [op_push(len(k)//2) + k for k in public_keys] return op_m + ''.join(keylist) + op_n + 'ae' @@ -580,6 +518,12 @@ def __init__(self, raw): self.joinSplitSig = None self.bindingSig = None + # Ephemeral meta-data used internally to keep track of interesting things. + # This is currently written-to by coinchooser to tell UI code about 'dust_to_fee', which + # is change that's too small to go to change outputs (below dust threshold) and needed + # to go to the fee. Values in this dict are advisory only and may or may not always be there! + self.ephemeral = dict() + def update(self, raw): self.raw = raw self._inputs = None @@ -641,12 +585,12 @@ def update_signatures(self, raw): def deserialize(self): if self.raw is None: return - #self.raw = self.serialize() if self._inputs is not None: return d = deserialize(self.raw) self._inputs = d['inputs'] self._outputs = [(x['type'], x['address'], x['value']) for x in d['outputs']] + assert all(isinstance(output[1], (PublicKey, Address, ScriptOutput)) for output in self._outputs) self.locktime = d['lockTime'] self.version = d['version'] self.overwintered = d['overwintered'] @@ -663,22 +607,16 @@ def deserialize(self): @classmethod def from_io(klass, inputs, outputs, locktime=0): + assert all(isinstance(output[1], (PublicKey, Address, ScriptOutput)) for output in outputs) self = klass(None) self._inputs = inputs - self._outputs = outputs + self._outputs = outputs.copy() self.locktime = locktime return self @classmethod - def pay_script(self, output_type, addr): - if output_type == TYPE_SCRIPT: - return addr - elif output_type == TYPE_ADDRESS: - return bitcoin.address_to_script(addr) - elif output_type == TYPE_PUBKEY: - return bitcoin.public_key_to_p2pk_script(addr) - else: - raise TypeError('Unknown output type') + def pay_script(self, output): + return output.to_script().hex() @classmethod def estimate_pubkey_size_from_x_pubkey(cls, x_pubkey): @@ -765,15 +703,19 @@ def is_txin_complete(cls, txin): @classmethod def get_preimage_script(self, txin): pubkeys, x_pubkeys = self.get_sorted_pubkeys(txin) - if txin['type'] == 'p2pkh': - return bitcoin.address_to_script(txin['address']) - elif txin['type'] in ['p2sh']: + _type = txin['type'] + if _type == 'p2pkh': + return txin['address'].to_script().hex() + elif _type == 'p2sh': return multisig_script(pubkeys, txin['num_sig']) - elif txin['type'] == 'p2pk': + elif _type == 'p2pk': pubkey = pubkeys[0] - return bitcoin.public_key_to_p2pk_script(pubkey) + return public_key_to_p2pk_script(pubkey) + elif _type == 'unknown': + # this approach enables most P2SH smart contracts (but take care if using OP_CODESEPARATOR) + return txin['scriptCode'] else: - raise TypeError('Unknown txin type', txin['type']) + raise RuntimeError('Unknown txin type', _type) @classmethod def serialize_outpoint(self, txin): @@ -800,12 +742,12 @@ def serialize_input(self, txin, script): def BIP_LI01_sort(self): # See https://github.com/kristovatlas/rfc/blob/master/bips/bip-li01.mediawiki self._inputs.sort(key = lambda i: (i['prevout_hash'], i['prevout_n'])) - self._outputs.sort(key = lambda o: (o[2], self.pay_script(o[0], o[1]))) + self._outputs.sort(key = lambda o: (o[2], self.pay_script(o[1]))) def serialize_output(self, output): output_type, addr, amount = output s = int_to_hex(amount, 8) - script = self.pay_script(output_type, addr) + script = self.pay_script(addr) s += var_int(len(script)//2) s += script return s @@ -899,13 +841,30 @@ def txid(self): if not self.is_complete(): return None ser = self.serialize() - return bh2u(Hash(bfh(ser))[::-1]) + return self._txid(ser) + + def txid_fast(self): + ''' Returns the txid by immediately calculating it from self.raw, + which is faster than calling txid() which does a full re-serialize + each time. Note this should only be used for tx's that you KNOW are + complete and that don't contain our funny serialization hacks. + + (The is_complete check is also not performed here because that + potentially can lead to unwanted tx deserialization). ''' + if self.raw: + return self._txid(self.raw) + return self.txid() + + @staticmethod + def _txid(raw_hex : str) -> str: + return bh2u(Hash(bfh(raw_hex))[::-1]) def add_inputs(self, inputs): self._inputs.extend(inputs) self.raw = None def add_outputs(self, outputs): + assert all(isinstance(output[1], (PublicKey, Address, ScriptOutput)) for output in outputs) self._outputs.extend(outputs) self.raw = None @@ -942,9 +901,9 @@ def estimated_input_weight(cls, txin): @classmethod def estimated_output_size(cls, address): """Return an estimate of serialized output size in bytes.""" - script = bitcoin.address_to_script(address) + script = address.to_script() # 8 byte value + 1 byte script len + script - return 9 + len(script) // 2 + return 9 + len(script) @classmethod def virtual_size_from_weight(cls, weight): @@ -1001,7 +960,7 @@ def sign(self, keypairs): pre_hash = Hash(bfh(self.serialize_preimage(i))) pkey = regenerate_key(sec) secexp = pkey.secret - private_key = bitcoin.MySigningKey.from_secret_exponent(secexp, curve = SECP256k1) + private_key = MySigningKey.from_secret_exponent(secexp, curve = SECP256k1) public_key = private_key.get_verifying_key() sig = private_key.sign_digest_deterministic(pre_hash, hashfunc=hashlib.sha256, sigencode = ecdsa.util.sigencode_der_canonize) if not public_key.verify_digest(sig, pre_hash, sigdecode = ecdsa.util.sigdecode_der): @@ -1016,13 +975,7 @@ def sign(self, keypairs): def get_outputs(self): """convert pubkeys to addresses""" o = [] - for type, x, v in self.outputs(): - if type == TYPE_ADDRESS: - addr = x - elif type == TYPE_PUBKEY: - addr = bitcoin.public_key_to_p2pkh(bfh(x)) - else: - addr = 'SCRIPT ' + x + for type, addr, v in self.outputs(): o.append((addr,v)) # consider using yield (addr, v) return o diff --git a/lib/util.py b/lib/util.py index 9c57eeaac..ea933a56d 100644 --- a/lib/util.py +++ b/lib/util.py @@ -20,21 +20,25 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. + import binascii -import os, sys, re, json +import os, sys, re, json, time from collections import defaultdict from datetime import datetime from decimal import Decimal +import decimal import traceback +import inspect, weakref import urllib import threading import hmac - +from .address import Address +from . import constants from .i18n import _ - import urllib.request, urllib.parse, urllib.error import queue +from locale import localeconv def inv_dict(d): return {v: k for k, v in d.items()} @@ -47,6 +51,11 @@ def normalize_version(v): class NotEnoughFunds(Exception): pass +class NotEnoughFundsSlp(Exception): pass + +class NotEnoughUnfrozenFundsSlp(Exception): pass + +class ExcessiveFee(Exception): pass class NoDynamicFeeEstimates(Exception): def __str__(self): @@ -284,14 +293,13 @@ def constant_time_compare(val1, val2): # decorator that prints execution time def profiler(func): - def do_profile(func, args, kw_args): - n = func.__name__ + def do_profile(args, kw_args): t0 = time.time() o = func(*args, **kw_args) t = time.time() - t0 - print_error("[profiler]", n, "%.4f"%t) + print_error("[profiler]", func.__qualname__, "%.4f"%t) return o - return lambda *args, **kw_args: do_profile(func, args, kw_args) + return lambda *args, **kw_args: do_profile(args, kw_args) def android_headers_file_name(): @@ -371,6 +379,9 @@ def to_bytes(something, encoding='utf8'): """ cast string to bytes() like object, but for python2 support it's bytearray copy """ + # Dirty fix for address coming here TODOTTT: check with latest implementation + if isinstance(something, Address): + something = str(something) if isinstance(something, bytes): return something if isinstance(something, str): @@ -384,7 +395,6 @@ def to_bytes(something, encoding='utf8'): bfh = bytes.fromhex hfu = binascii.hexlify - def bh2u(x): """ str with hex representation of a bytes-like object @@ -421,7 +431,6 @@ def format_satoshis_plain(x, decimal_point = 8): def format_satoshis(x, is_diff=False, num_zeros = 0, decimal_point = 8, whitespaces=False): - from locale import localeconv if x is None: return 'unknown' x = int(x) # Some callers pass Decimal @@ -442,6 +451,97 @@ def format_satoshis(x, is_diff=False, num_zeros = 0, decimal_point = 8, whitespa result = " " * (15 - len(result)) + result return result +def format_satoshis_plain_nofloat(x, decimal_point = 8): + """Display a satoshi amount scaled. Always uses a '.' as a decimal + point and has no thousands separator. + Does not use any floating point representation internally, so no rounding ever occurs. + """ + x = int(x) + xstr = str(abs(x)) + + if decimal_point > 0: + integer_part = xstr[:-decimal_point] + fract_part = xstr[-decimal_point:] + fract_part = '0'*(decimal_point - len(fract_part)) + fract_part # add leading zeros + fract_part = fract_part.rstrip('0') # snip off trailing zeros + else: + integer_part = xstr + fract_part = '' + if not integer_part: + integer_part = '0' + if x < 0: + integer_part = '-' + integer_part + + if fract_part: + return integer_part + '.' + fract_part + else: + return integer_part + +def format_satoshis_nofloat(x, num_zeros=0, decimal_point=8, precision=None, is_diff=False, whitespaces=False): + """ Format the quantity x/10**decimal_point, for integer x. + Does not use any floating point representation internally, so no rounding ever occurs when precision is None. + Don't pass values other than nonnegative integers for decimal_point or num_zeros or precision. + Undefined things will occur. + `whitespaces` may be passed as an integer or True (the latter defaulting to 15, as in format_satoshis). + """ + if x is None: + return 'unknown' + if precision is not None: + x = round(int(x), precision - decimal_point) + else: + x = int(x) + + xstr = str(abs(x)) + + if decimal_point > 0: + integer_part = xstr[:-decimal_point] + fract_part = xstr[-decimal_point:] + + fract_part = '0'*(decimal_point - len(fract_part)) + fract_part # add leading zeros + fract_part = fract_part.rstrip('0') # snip off trailing zeros + else: + integer_part = xstr + fract_part = '' + if not integer_part: + integer_part = '0' + if x < 0: # put the sign on + integer_part = '-' + integer_part + elif is_diff: + integer_part = '+' + integer_part + + fract_part += "0" * (num_zeros - len(fract_part)) # restore desired minimum number of fractional figures + + dp = localeconv()['decimal_point'] + result = integer_part + dp + fract_part + + if whitespaces is True: + whitespaces = 15 + if whitespaces: + result += " " * (decimal_point - len(fract_part)) + result = " " * (whitespaces - len(result)) + result + + return result + +def get_satoshis_nofloat(s, decimal_point=8): + """ Convert a decimal string to integer. + e.g., "5.6663" to 566630000 when decimal_point = 8 + Does not round, ever. If too many fractional digits are provided + (even zeros) then ValueError is raised. + """ + dec = decimal.Decimal(s) + dtup = dec.as_tuple() + + if dtup.exponent < -decimal_point: + raise ValueError('Too many fractional digits', s, decimal_point) + + # Create context with right amount of precision; we want to raise Inexact + # just in case any rounding occurs (still, it should never happen!). + C = decimal.Context(prec=len(dtup.digits), traps=[decimal.Inexact]) + + res = int(C.to_integral_exact(C.scaleb(dec, decimal_point))) + + return res + def timestamp_to_datetime(timestamp): if timestamp is None: return None @@ -505,123 +605,6 @@ def time_difference(distance_in_time, include_seconds): else: return "over %d years" % (round(distance_in_minutes / 525600)) -mainnet_block_explorers = { - 'zeltrez.io': ('https://explorer.zcl.zeltrez.io/', - {'tx': 'tx/', 'addr': 'address/'}) -} - -testnet_block_explorers = { - 'testnet.z.cash': ('https://explorer.testnet.z.cash/', - {'tx': 'tx/', 'addr': 'address/'}), - 'system default': ('blockchain:/', - {'tx': 'tx/', 'addr': 'address/'}), -} - -def block_explorer_info(): - from . import constants - return testnet_block_explorers if constants.net.TESTNET else mainnet_block_explorers - -def block_explorer(config): - return config.get('block_explorer', 'zeltrez.io') - -def block_explorer_tuple(config): - return block_explorer_info().get(block_explorer(config)) - -def block_explorer_URL(config, kind, item): - be_tuple = block_explorer_tuple(config) - if not be_tuple: - return - kind_str = be_tuple[1].get(kind) - if not kind_str: - return - url_parts = [be_tuple[0], kind_str, item] - return ''.join(url_parts) - -# URL decode -#_ud = re.compile('%([0-9a-hA-H]{2})', re.MULTILINE) -#urldecode = lambda x: _ud.sub(lambda m: chr(int(m.group(1), 16)), x) - -def parse_URI(uri, on_pr=None): - from . import bitcoin - from .bitcoin import COIN - - if ':' not in uri: - if not bitcoin.is_address(uri): - raise Exception("Not a Zclassic address") - return {'address': uri} - - u = urllib.parse.urlparse(uri) - if u.scheme != 'zclassic': - raise Exception("Not a Zclassic URI") - address = u.path - - # python for android fails to parse query - if address.find('?') > 0: - address, query = u.path.split('?') - pq = urllib.parse.parse_qs(query) - else: - pq = urllib.parse.parse_qs(u.query) - - for k, v in pq.items(): - if len(v)!=1: - raise Exception('Duplicate Key', k) - - out = {k: v[0] for k, v in pq.items()} - if address: - if not bitcoin.is_address(address): - raise Exception("Invalid Zclassic address:" + address) - out['address'] = address - if 'amount' in out: - am = out['amount'] - m = re.match('([0-9\.]+)X([0-9])', am) - if m: - k = int(m.group(2)) - 8 - amount = Decimal(m.group(1)) * pow( Decimal(10) , k) - else: - amount = Decimal(am) * COIN - out['amount'] = int(amount) - if 'message' in out: - out['message'] = out['message'] - out['memo'] = out['message'] - if 'time' in out: - out['time'] = int(out['time']) - if 'exp' in out: - out['exp'] = int(out['exp']) - if 'sig' in out: - out['sig'] = bh2u(bitcoin.base_decode(out['sig'], None, base=58)) - - r = out.get('r') - sig = out.get('sig') - name = out.get('name') - if on_pr and (r or (name and sig)): - def get_payment_request_thread(): - from . import paymentrequest as pr - if name and sig: - s = pr.serialize_request(out).SerializeToString() - request = pr.PaymentRequest(s) - else: - request = pr.get_payment_request(r) - if on_pr: - on_pr(request) - t = threading.Thread(target=get_payment_request_thread) - t.setDaemon(True) - t.start() - - return out - - -def create_URI(addr, amount, message): - from . import bitcoin - if not bitcoin.is_address(addr): - return "" - query = [] - if amount: - query.append('amount=%s'%format_satoshis_plain(amount)) - if message: - query.append('message=%s'%urllib.parse.quote(message)) - p = urllib.parse.ParseResult(scheme='zclassic', netloc='', path=addr, params='', query='&'.join(query), fragment='') - return urllib.parse.urlunparse(p) - # Python bug (http://bugs.python.org/issue1927) causes raw_input # to be redirected improperly between stdin/stderr on Unix systems @@ -652,10 +635,7 @@ class timeout(Exception): pass import socket -import json import ssl -import time - class SocketPipe: def __init__(self, socket): @@ -807,3 +787,116 @@ def export_meta(meta, fileName): except (IOError, os.error) as e: traceback.print_exc(file=sys.stderr) raise FileExportFailed(e) + +class Weak: + ''' + Weak reference factory. Create either a weak proxy to a bound method + or a weakref.proxy, depending on whether this factory class's __new__ is + invoked with a bound method or a regular function/object as its first + argument. + + If used with an object/function reference this factory just creates a + weakref.proxy and returns that. + + myweak = Weak(myobj) + type(myweak) == weakref.proxy # <-- True + + The interesting usage is when this factory is used with a bound method + instance. In which case it returns a MethodProxy which behaves like + a proxy to a bound method in that you can call the MethodProxy object + directly: + + mybound = Weak(someObj.aMethod) + mybound(arg1, arg2) # <-- invokes someObj.aMethod(arg1, arg2) + + This is unlike regular weakref.WeakMethod which is not a proxy and requires + unsightly `foo()(args)`, or perhaps `foo() and foo()(args)` idioms. + + Also note that no exception is raised with MethodProxy instances when + calling them on dead references. + + Instead, if the weakly bound method is no longer alive (because its object + died), the situation is ignored as if no method were called (with an + optional print facility provided to print debug information in such a + situation). + + The optional `print_func` class attribute can be set in MethodProxy + globally or for each instance specifically in order to specify a debug + print function (which will receive exactly two arguments: the + MethodProxy instance and an info string), so you can track when your weak + bound method is being called after its object died (defaults to + `print_error`). + + Note you may specify a second postional argument to this factory, + `callback`, which is identical to the `callback` argument in the weakref + documentation and will be called on target object finalization + (destruction). + + This usage/idiom is intented to be used with Qt's signal/slots mechanism + to allow for Qt bound signals to not prevent target objects from being + garbage collected due to reference cycles -- hence the permissive, + exception-free design.''' + + def __new__(cls, obj_or_bound_method, *args, **kwargs): + if inspect.ismethod(obj_or_bound_method): + # is a method -- use our custom proxy class + return cls.MethodProxy(obj_or_bound_method, *args, **kwargs) + else: + # Not a method, just return a weakref.proxy + return weakref.proxy(obj_or_bound_method, *args, **kwargs) + + ref = weakref.ref # alias for convenience so you don't have to import weakref + Set = weakref.WeakSet # alias for convenience + ValueDictionary = weakref.WeakValueDictionary # alias for convenience + KeyDictionary = weakref.WeakKeyDictionary # alias for convenience + Method = weakref.WeakMethod # alias + finalize = weakref.finalize # alias + + _weak_refs_for_print_error = defaultdict(list) + @staticmethod + def finalization_print_error(obj, msg=None): + ''' Supply a message to be printed via print_error when obj is + finalized (Python GC'd). This is useful for debugging memory leaks. ''' + assert not isinstance(obj, type), "finaliztion_print_error can only be used on instance objects!" + if msg is None: + if isinstance(obj, PrintError): + name = obj.diagnostic_name() + else: + name = obj.__class__.__qualname__ + msg = "[{}] finalized".format(name) + def finalizer(x): + wrs = Weak._weak_refs_for_print_error + msgs = wrs.get(x, []) + for m in msgs: + print_error(m) + wrs.pop(x, None) + wr = Weak.ref(obj, finalizer) + Weak._weak_refs_for_print_error[wr].append(msg) + + + class MethodProxy(weakref.WeakMethod): + ''' Direct-use of this class is discouraged (aside from assigning to + its print_func attribute). Instead use of the wrapper class 'Weak' + defined in the enclosing scope is encouraged. ''' + + print_func = lambda x, this, info: print_error(this, info) # <--- set this attribute if needed, either on the class or instance level, to control debug printing behavior. None is ok here. + + def __init__(self, meth, *args, **kwargs): + super().__init__(meth, *args, **kwargs) + # teehee.. save some information about what to call this thing for debug print purposes + self.qname, self.sname = meth.__qualname__, str(meth.__self__) + + def __call__(self, *args, **kwargs): + ''' Either directly calls the method for you or prints debug info + if the target object died ''' + meth = super().__call__() # if dead, None is returned + if meth: # could also do callable() as the test but hopefully this is sightly faster + return meth(*args,**kwargs) + elif callable(self.print_func): + self.print_func(self, "MethodProxy for '{}' called on a dead reference. Referent was: {})".format(self.qname, + self.sname)) + +# Export this method to the top level for convenience. People reading code +# may wonder 'Why Weak.finaliztion_print_error'?. The fact that this relies on +# weak refs is an implementation detail, really. +finalization_print_error = Weak.finalization_print_error diff --git a/lib/verifier.py b/lib/verifier.py index c2c1e523f..76d1af92d 100644 --- a/lib/verifier.py +++ b/lib/verifier.py @@ -35,8 +35,30 @@ def __init__(self, network, wallet): # Keyed by tx hash. Value is None if the merkle branch was # requested, and the merkle root once it has been verified self.merkle_roots = {} + self.requested_merkle = set() # txid set of pending requests + self.qbusy = False + self.cleaned_up = False + self._need_release = False + + def _release(self): + ''' Called from the Network (DaemonThread) -- to prevent race conditions + with network, we remove data structures related to the network and + unregister ourselves as a job from within the Network thread itself. ''' + self._need_release = False + self.cleaned_up = True + self.network.cancel_requests(self.verify_merkle) + self.network.remove_jobs([self]) + + def release(self): + ''' Called from main thread, enqueues a 'release' to happen in the + Network thread. ''' + self._need_release = True def run(self): + if self._need_release: + self._release() + if self.cleaned_up: + return interface = self.network.interface if not interface: return diff --git a/lib/version.py b/lib/version.py index ef3b03470..1d5d3abf2 100644 --- a/lib/version.py +++ b/lib/version.py @@ -1,4 +1,4 @@ -ELECTRUM_VERSION = 'v3.2.4' # version of the client package +ELECTRUM_VERSION = 'v1.1.1' # version of the client package PROTOCOL_VERSION = '1.2' # protocol version requested # The hash of the mnemonic seed must begin with this diff --git a/lib/wallet.py b/lib/wallet.py index 9cdd395f7..45b533b37 100644 --- a/lib/wallet.py +++ b/lib/wallet.py @@ -34,6 +34,7 @@ import json import copy import errno +import re import traceback from functools import partial from collections import defaultdict @@ -44,13 +45,15 @@ import sys from .i18n import _ -from .util import (NotEnoughFunds, PrintError, UserCancelled, profiler, +from .util import (NotEnoughFunds, NotEnoughFundsSlp, PrintError, UserCancelled, profiler, format_satoshis, NoDynamicFeeEstimates, TimeoutException, - WalletFileException, BitcoinException) + WalletFileException, BitcoinException, finalization_print_error, is_verbose) +from .address import Address, Script from .bitcoin import * from .version import * from .keystore import load_keystore, Hardware_KeyStore +from . import constants from .storage import multisig_type, STO_EV_PLAINTEXT, STO_EV_USER_PW, STO_EV_XPUB_PW from . import transaction @@ -66,6 +69,9 @@ from .paymentrequest import InvoiceStore from .contacts import Contacts +from .slp import SlpMessage, SlpUnsupportedSlpTokenType, SlpParsingError, SlpNoMintingBatonFound, OpreturnError +from . import slp_validator_0x01, slp_validator_0x01_nft1 + TX_STATUS = [ _('Unconfirmed'), _('Unconfirmed parent'), @@ -183,10 +189,29 @@ class Abstract_Wallet(PrintError): def __init__(self, storage): self.electrum_version = ELECTRUM_VERSION self.storage = storage + self.thread = None # this is used by the qt main_window to store a QThread. We just make sure it's always defined as an attribute here. self.network = None # verifier (SPV) and synchronizer are started in start_threads self.synchronizer = None self.verifier = None + self.ui_emit_validity_updated = None # Qt GUI attaches a signal to this attribute -- see slp_check_validation + self.slp_graph_0x01, self.slp_graph_0x01_nft = None, None + + # Cache of Address -> (c,u,x) balance. This cache is used by + # get_addr_balance to significantly speed it up (it is called a lot). + # Cache entries are invalidated when tx's are seen involving this + # address (address history chages). Entries to this cache are added + # only inside get_addr_balance. + # Note that this data structure is touched by the network and GUI + # thread concurrently without the use of locks, because Python GIL + # allows us to get away with such things. As such do not iterate over + # this dict, but simply add/remove items to/from it in 1-liners (which + # Python's GIL makes thread-safe implicitly). + self._addr_bal_cache = {} + + # We keep a set of the wallet and receiving addresses so that is_mine() + # checks are O(logN) rather than O(N). This creates/resets that cache. + self.invalidate_address_set_cache() self.gap_limit_for_change = 6 # constant @@ -198,10 +223,22 @@ def __init__(self, storage): self.use_change = storage.get('use_change', True) self.multiple_change = storage.get('multiple_change', False) self.labels = storage.get('labels', {}) - self.frozen_addresses = set(storage.get('frozen_addresses',[])) - self.history = storage.get('addr_history',{}) # address -> list(txid, height) + # Frozen addresses + frozen_addresses = storage.get('frozen_addresses',[]) + self.frozen_addresses = set(Address.from_string(addr) for addr in frozen_addresses) + # Frozen coins (UTXOs) -- note that we have 2 independent levels of "freezing": address-level and coin-level. + # The two types of freezing are flagged independently of each other and 'spendable' is defined as a coin that satisfies + # BOTH levels of freezing. + self.frozen_coins = set(storage.get('frozen_coins', [])) + # address -> list(txid, height) + history = storage.get('addr_history', {}) + self.history = self.to_Address_dict(history) + self.fiat_value = storage.get('fiat_value', {}) - self.receive_requests = storage.get('payment_requests', {}) + requests = storage.get('payment_requests', {}) + for key, req in requests.items(): + req['address'] = Address.from_string(key) + self.receive_requests = {req['address']: req for req in requests.values()} # Verified transactions. Each value is a (height, timestamp, block_pos) tuple. Access with self.lock. self.verified_tx = storage.get('verified_tx3', {}) @@ -212,7 +249,7 @@ def __init__(self, storage): self.load_keystore() self.load_addresses() - self.test_addresses_sanity() + # self.test_addresses_sanity() self.load_transactions() self.check_history() self.load_unverified_transactions() @@ -235,6 +272,26 @@ def __init__(self, storage): self.coin_price_cache = {} + # Print debug message on finalization + finalization_print_error(self, "[{}/{}] finalized".format(__class__.__name__, self.diagnostic_name())) + + @property + def is_slp(self): + ''' Note that the various Slp_* classes explicitly write to storage + to set the proper wallet_type on construction unconditionally, so + this should always be valid for SLP wallets. ''' + return "slp_" in self.storage.get('wallet_type', '') + + @classmethod + def to_Address_dict(cls, d): + '''Convert a dict of strings to a dict of Adddress objects.''' + return {Address.from_string(text): value for text, value in d.items()} + + @classmethod + def from_Address_dict(cls, d): + '''Convert a dict of Address objects to a dict of strings.''' + return {addr.to_string(Address.FMT_ZCLASSIC): value + for addr, value in d.items()} def diagnostic_name(self): return self.basename() @@ -247,11 +304,16 @@ def get_master_public_key(self): @profiler def load_transactions(self): - self.txi = self.storage.get('txi', {}) - self.txo = self.storage.get('txo', {}) + txi = self.storage.get('txi', {}) + self.txi = {tx_hash: self.to_Address_dict(value) + for tx_hash, value in txi.items()} + txo = self.storage.get('txo', {}) + self.txo = {tx_hash: self.to_Address_dict(value) + for tx_hash, value in txo.items()} self.tx_fees = self.storage.get('tx_fees', {}) self.pruned_txo = self.storage.get('pruned_txo', {}) tx_list = self.storage.get('transactions', {}) + self.transactions = {} for tx_hash, raw in tx_list.items(): tx = Transaction(raw) @@ -260,6 +322,21 @@ def load_transactions(self): and (tx_hash not in self.pruned_txo.values()): self.print_error("removing unreferenced tx", tx_hash) self.transactions.pop(tx_hash) + + self.slpv1_validity = self.storage.get('slpv1_validity', {}) + self.token_types = self.storage.get('token_types', {}) + self.tx_tokinfo = self.storage.get('tx_tokinfo', {}) + + # load up slp_txo as defaultdict-of-defaultdict-of-dicts + self._slp_txo = defaultdict(lambda: defaultdict(dict)) + for addr, addrdict in self.to_Address_dict(self.storage.get('slp_txo', {})).items(): + for txid, txdict in addrdict.items(): + # need to do this iteration since json stores int keys as decimal strings. + self._slp_txo[addr][txid] = {int(idx):d for idx,d in txdict.items()} + + ok = self.storage.get('slp_data_version', False) + if ok != 3: + self.rebuild_slp() @profiler def load_local_history(self): @@ -281,11 +358,159 @@ def save_transactions(self, write=False): for k,v in self.transactions.items(): tx[k] = str(v) self.storage.put('transactions', tx) - self.storage.put('txi', self.txi) - self.storage.put('txo', self.txo) + txi = {tx_hash: self.from_Address_dict(value) + for tx_hash, value in self.txi.items()} + txo = {tx_hash: self.from_Address_dict(value) + for tx_hash, value in self.txo.items()} + self.storage.put('txi', txi) + self.storage.put('txo', txo) self.storage.put('tx_fees', self.tx_fees) self.storage.put('pruned_txo', self.pruned_txo) - self.storage.put('addr_history', self.history) + history = self.from_Address_dict(self.history) + self.storage.put('addr_history', history) + + ### SLP stuff + self.storage.put('slpv1_validity', self.slpv1_validity) + self.storage.put('token_types', self.token_types) + self.storage.put('slp_txo', self.from_Address_dict(self._slp_txo)) + self.storage.put('tx_tokinfo', self.tx_tokinfo) + + self.storage.put('slp_data_version', 3) + + if write: + self.storage.write() + + def activate_slp(self): + # This gets called in two situations: + # - Upon wallet startup once GUI is loaded, it checks config to see if SLP should be enabled. + # - During wallet operation, SLP can be freely enabled/disabled by user. + with self.transaction_lock: + for tx_hash, tti in self.tx_tokinfo.items(): + # Fire up validation on unvalidated txes + try: + tx = self.transactions[tx_hash] + self.slp_check_validation(tx_hash, tx) + except KeyError: + continue + + _add_token_hex_re = re.compile('^[a-f0-9]{64}$') + def add_token_type(self, token_id, entry): + if not isinstance(token_id, str) or not self._add_token_hex_re.match(token_id): + # Paranoia: we enforce canonical hex string as lowercase to avoid + # problems with the same token-id being added as upper or lowercase + # by client code. This is because token_id becomes a dictionary key + # in various places and it not being identical would create chaos. + raise ValueError('token_id must be a lowercase hex string of exactly 64 characters!') + with self.transaction_lock: + self.token_types[token_id] = dict(entry) + self.storage.put('token_types', self.token_types) + for tx_hash, tti in self.tx_tokinfo.items(): + # Fire up validation on unvalidated txes of matching token_id + try: + if tti['token_id'] == token_id: + tx = self.transactions[tx_hash] + self.slp_check_validation(tx_hash, tx) + except KeyError: # This catches the case where tx_tokinfo was set to {} + continue + + def add_token_safe(self, token_class: str, token_id: str, token_name: str, + decimals_divisibility: int, + *, error_callback=None, allow_overwrite=False, + write_storage=True) -> bool: + ''' This code was refactored from main_window.py to allow other + subsystems (eg CLI/RPC, other platforms, etc) to add tokens. + This function does some minimal sanity checks and returns True + on success or False on failure. The optional error_callback + is called on False return. The callback takes a single translated string + argument which is an error message (suitable for display to the user). + + On success (True) return, this method ends up calling + self.add_token_type(), and also will end up saving the changes to + wallet storage if write_storage=True (the default). + + This function is thread-safe. ''' + + token_name = token_name.strip() + token_id = token_id.strip().lower() + + # Check for duplication error + d = self.token_types.get(token_id) + if d is not None and not allow_overwrite: + if error_callback: + error_callback(_('Token with this hash id already exists')) + return False + for tid, d in self.token_types.copy().items(): # <-- must take a snapshot-copy here since we aren't holding locks and other threads may modify this dict as we iterate + if d['name'] == token_name and tid != token_id: + token_name = token_name + "-" + token_id[:3] + break + + #Hash id validation + gothex = self._add_token_hex_re.match(token_id) + if not gothex: + if error_callback: + error_callback(_('Invalid token_id hash')) + return False + + #token name validation + if len(token_name) < 1 or len(token_name) > 20: + if error_callback: + error_callback(_('Token name should be 1-20 characters')) + return False + + + new_entry = { + 'class' : token_class, + 'name' : token_name, + 'decimals' : decimals_divisibility, + } + + if token_class == "SLP65": + new_entry['group_id'] = "?" + + self.add_token_type(token_id, new_entry) + self.save_transactions(bool(write_storage)) + return True + + def add_token_from_genesis_tx(self, tx_or_raw, *, error_callback=None, allow_overwrite=True) -> SlpMessage: + ''' Returns None on failure, optionally calling error_callback + with a translated UI-suitable error message. Returns a valid + SlpMessage object on success. In exceptional circumstances (garbage + inputs), may raise. + + Note that unlike the other add_token_* functions, this version defaults + to allow_overwrite = True.''' + tx = tx_or_raw + if not isinstance(tx, Transaction): + tx = Transaction(tx) + + def fail(msg): + if error_callback: + error_callback(msg) + return None + + token_id = tx.txid() + + try: + slpMsg = SlpMessage.parseSlpOutputScript(tx.outputs()[0][1]) + except SlpUnsupportedSlpTokenType as e: + return fail(_("Unsupported SLP token version/type - %r.")%(e.args[0],)) + except SlpInvalidOutputMessage as e: + return fail(_("This transaction does not contain a valid SLP message.\nReason: %r.")%(e.args,)) + if slpMsg.transaction_type != 'GENESIS': + return fail(_("This is an SLP transaction, however it is not a genesis transaction.")) + + token_name = slpMsg.op_return_fields['ticker'].decode('utf-8') or slpMsg.op_return_fields['token_name'].decode('utf-8') + decimals = slpMsg.op_return_fields['decimals'] + token_class = 'SLP%d' % (slpMsg.token_type,) + + if self.add_token_safe(token_class, token_id, token_name, decimals, error_callback=fail, allow_overwrite=allow_overwrite): + return slpMsg + else: + return None + + def save_verified_tx(self, write=False): + with self.lock: + self.storage.put('verified_tx3', self.verified_tx) if write: self.storage.write() @@ -297,7 +522,9 @@ def clear_history(self): self.tx_fees = {} self.pruned_txo = {} self.spent_outpoints = {} + self._addr_bal_cache = {} self.history = {} + self.tx_addr_hist = defaultdict(set) self.verified_tx = {} self.transactions = {} self.save_transactions() @@ -338,13 +565,20 @@ def basename(self): return os.path.basename(self.storage.path) def save_addresses(self): - self.storage.put('addresses', {'receiving':self.receiving_addresses, 'change':self.change_addresses}) + addr_dict = { + 'receiving': [addr.to_storage_string() + for addr in self.receiving_addresses], + 'change': [addr.to_storage_string() + for addr in self.change_addresses], + } + self.storage.put('addresses', addr_dict) def load_addresses(self): d = self.storage.get('addresses', {}) - if type(d) != dict: d={} - self.receiving_addresses = d.get('receiving', []) - self.change_addresses = d.get('change', []) + if not isinstance(d, dict): + d = {} + self.receiving_addresses = Address.from_strings(d.get('receiving', [])) + self.change_addresses = Address.from_strings(d.get('change', [])) def test_addresses_sanity(self): addrs = self.get_receiving_addresses() @@ -368,6 +602,8 @@ def is_up_to_date(self): with self.lock: return self.up_to_date def set_label(self, name, text = None): + if isinstance(name, Address): + name = name.to_storage_string() changed = False old_text = self.labels.get(name) if text: @@ -410,16 +646,61 @@ def get_fiat_value(self, txid, ccy): except: return + def invalidate_address_set_cache(self): + ''' This should be called from functions that add/remove addresses + from the wallet to ensure the address set caches are empty, in + particular from ImportedWallets which may add/delete addresses + thus the length check in is_mine() may not be accurate. + Deterministic wallets can neglect to call this function since their + address sets only grow and never shrink and thus the length check + of is_mine below is sufficient.''' + self._recv_address_set_cached, self._change_address_set_cached = frozenset(), frozenset() + def is_mine(self, address): - return address in self.get_addresses() + ''' Note this method assumes that the entire address set is + composed of self.get_change_addresses() + self.get_receiving_addresses(). + In subclasses, if that is not the case -- REIMPLEMENT this method! ''' + assert not isinstance(address, str) + # assumption here is get_receiving_addresses and get_change_addresses + # are cheap constant-time operations returning a list reference. + # If that is not the case -- reimplement this function. + ra, ca = self.get_receiving_addresses(), self.get_change_addresses() + # Detect if sets changed (addresses added/removed). + # Note the functions that add/remove addresses should invalidate this + # cache using invalidate_address_set_cache() above. + if len(ra) != len(self._recv_address_set_cached): + # re-create cache if lengths don't match + self._recv_address_set_cached = frozenset(ra) + if len(ca) != len(self._change_address_set_cached): + # re-create cache if lengths don't match + self._change_address_set_cached = frozenset(ca) + # Do a 2 x O(logN) lookup using sets rather than 2 x O(N) lookups + # if we were to use the address lists (this was the previous way). + # For small wallets it doesn't matter -- but for wallets with 5k or 10k + # addresses, it starts to add up siince is_mine() is called frequently + # especially while downloading address history. + return (address in self._recv_address_set_cached + or address in self._change_address_set_cached) def is_change(self, address): - if not self.is_mine(address): - return False - return self.get_address_index(address)[0] + assert not isinstance(address, str) + ca = self.get_change_addresses() + if len(ca) != len(self._change_address_set_cached): + # re-create cache if lengths don't match + self._change_address_set_cached = frozenset(ca) + return address in self._change_address_set_cached def get_address_index(self, address): - raise NotImplementedError() + try: + return False, self.receiving_addresses.index(address) + except ValueError: + pass + try: + return True, self.change_addresses.index(address) + except ValueError: + pass + assert not isinstance(address, str) + raise Exception("Address {} not found".format(address)) def get_redeem_script(self, address): return None @@ -429,10 +710,7 @@ def export_private_key(self, address, password): return [] index = self.get_address_index(address) pk, compressed = self.keystore.get_private_key(index, password) - txin_type = self.get_txin_type(address) - redeem_script = self.get_redeem_script(address) - serialized_privkey = bitcoin.serialize_privkey(pk, compressed, txin_type) - return serialized_privkey, redeem_script + return bitcoin.serialize_privkey(pk, compressed, self.txin_type) def get_public_keys(self, address): return [self.get_public_key(address)] @@ -472,6 +750,8 @@ def undo_verifications(self, blockchain, height): if not header or header.get('timestamp') != timestamp: self.verified_tx.pop(tx_hash, None) txs.add(tx_hash) + if txs: + self._addr_bal_cache = {} # this is probably not necessary -- as the receive_history_callback will invalidate bad cache items -- but just to be paranoid we clear the whole balance cache on reorg anyway as a safety measure return txs def get_local_height(self): @@ -512,6 +792,7 @@ def get_num_tx(self, address): return len(self.history.get(address, [])) def get_tx_delta(self, tx_hash, address): + assert isinstance(address, Address) "effect of tx on address" # pruned if tx_hash in self.pruned_txo.values(): @@ -547,7 +828,7 @@ def get_wallet_delta(self, tx): is_partial = False v_in = v_out = v_out_mine = 0 for item in tx.inputs(): - addr = item.get('address') + addr = item['address'] if addr in addresses: is_mine = True is_relevant = True @@ -652,10 +933,45 @@ def get_addr_io(self, address): sent[txi] = height return received, sent - def get_addr_utxo(self, address): + def get_slp_token_info(self, tokenid): + with self.lock: + return self.tx_tokinfo[tokenid] + + def get_slp_token_baton(self, slpTokenId): + # look for our minting baton + with self.lock: + for addr, addrdict in self._slp_txo.items(): + for txid, txdict in addrdict.items(): + for idx, txo in txdict.items(): + if txo['qty'] == 'MINT_BATON' and txo['token_id'] == slpTokenId: + try: + coins = self.get_slp_utxos(slpTokenId, domain = None, exclude_frozen = False, confirmed_only = False, slp_include_baton=True) + baton_utxo = [ utxo for utxo in coins if utxo['prevout_hash'] == txid and utxo['prevout_n'] == idx and self.tx_tokinfo[txid]['validity'] == 1][0] + except IndexError: + continue + return baton_utxo + raise SlpNoMintingBatonFound() + + # This method is updated for SLP to prevent tokens from being spent + # in normal txn or txns with token_id other than the one specified + def get_addr_utxo(self, address, *, exclude_slp = True): coins, spent = self.get_addr_io(address) + # removes spent coins for txi in spent: coins.pop(txi) + # cleanup/detect if the 'frozen coin' was spent and remove it from the frozen coin set + self.frozen_coins.discard(txi) + + """ + SLP -- removes ALL SLP UTXOs that are either unrelated, or unvalidated + """ + if exclude_slp: + with self.lock: + addrdict = self._slp_txo.get(address,{}) + for txid, txdict in addrdict.items(): + for idx, txo in txdict.items(): + coins.pop(txid + ":" + str(idx), None) + out = {} for txo, v in coins.items(): tx_height, value, is_cb = v @@ -666,22 +982,85 @@ def get_addr_utxo(self, address): 'prevout_n':int(prevout_n), 'prevout_hash':prevout_hash, 'height':tx_height, - 'coinbase':is_cb + 'coinbase':is_cb, + 'is_frozen_coin':txo in self.frozen_coins } out[txo] = x return out + """ SLP -- keeps ONLY SLP UTXOs that are either unrelated, or unvalidated """ + def get_slp_addr_utxo(self, address, slpTokenId, slp_include_invalid=False, slp_include_baton=False, ): + with self.lock: + coins, spent = self.get_addr_io(address) + # removes spent coins + for txi in spent: + coins.pop(txi) + # cleanup/detect if the 'frozen coin' was spent and remove it from the frozen coin set + self.frozen_coins.discard(txi) + + addrdict = self._slp_txo.get(address,{}) + for coin in coins.copy().items(): + if coin != None: + txid = coin[0].split(":")[0] + idx = coin[0].split(":")[1] + try: + slp_txo = addrdict[txid][int(idx)] + slp_tx_info = self.tx_tokinfo[txid] + # handle special burning modes + if slp_txo['token_id'] == slpTokenId: + # allow inclusion and possible burning of a valid minting baton + if slp_include_baton and slp_txo['qty'] == "MINT_BATON" and slp_tx_info['validity'] == 1: + #coin.burn = True + continue + # allow inclusion and possible burning of invalid SLP txos + if slp_include_invalid and slp_tx_info['validity'] != 0: + #coin.burn = True + continue + # normal remove any txos that are not valid for this token ID + if slp_txo['token_id'] != slpTokenId or slp_tx_info['validity'] != 1 or slp_txo['qty'] == "MINT_BATON": + coins.pop(coin[0], None) + except KeyError: + coins.pop(coin[0], None) + + out = {} + for txo, v in coins.items(): + tx_height, value, is_cb = v + prevout_hash, prevout_n = txo.split(':') + x = { + 'address': address, + 'value': value, + 'prevout_n': int(prevout_n), + 'prevout_hash': prevout_hash, + 'height': tx_height, + 'coinbase': is_cb, + 'is_frozen_coin': txo in self.frozen_coins, + 'token_value': self._slp_txo[address][prevout_hash][int(prevout_n)]['qty'], + 'token_validation_state': self.tx_tokinfo[prevout_hash]['validity'] + } + out[txo] = x + return out + # return the total amount ever received by an address def get_addr_received(self, address): received, sent = self.get_addr_io(address) return sum([v for height, v, is_cb in received.values()]) # return the balance of a bitcoin address: confirmed and matured, unconfirmed, unmatured - def get_addr_balance(self, address): + # Note that 'exclude_frozen_coins = True' only checks for coin-level freezing, not address-level. + def get_addr_balance(self, address, exclude_frozen_coins = False): + assert isinstance(address, Address) + if not exclude_frozen_coins: # we do not use the cache when excluding frozen coins as frozen status is a dynamic quantity that can change at any time in the UI + cached = self._addr_bal_cache.get(address) + if cached is not None: + return cached received, sent = self.get_addr_io(address) c = u = x = 0 + had_cb = False local_height = self.get_local_height() for txo, (tx_height, v, is_cb) in received.items(): + if exclude_frozen_coins and txo in self.frozen_coins: + continue + had_cb = had_cb or is_cb # remember if this address has ever seen a coinbase txo if is_cb and tx_height + COINBASE_MATURITY > local_height: x += v elif tx_height > 0: @@ -693,21 +1072,92 @@ def get_addr_balance(self, address): c -= v else: u -= v - return c, u, x - - def get_spendable_coins(self, domain, config): + result = c, u, x + if not exclude_frozen_coins and not had_cb: + # Cache the results. + # Cache needs to be invalidated if a transaction is added to/ + # removed from addr history. (See self._addr_bal_cache calls + # related to this littered throughout this file). + # + # Note that as a performance tweak we don't ever cache balances for + # addresses involving coinbase coins. The rationale being as + # follows: Caching of balances of the coinbase addresses involves + # a dynamic quantity: maturity of the coin (which considers the + # ever-changing block height). + # + # There wasn't a good place in this codebase to signal the maturity + # happening (and thus invalidate the cache entry for the exact + # address that holds the coinbase coin in question when a new + # block is found that matures a coinbase coin). + # + # In light of that fact, a possible approach would be to invalidate + # this entire cache when a new block arrives (this is what Electrum + # does). However, for Electron Cash with its focus on many addresses + # for future privacy features such as integrated CashShuffle -- + # being notified in the wallet and invalidating the *entire* cache + # whenever a new block arrives (which is the exact time you do + # the most GUI refreshing and calling of this function) seems a bit + # heavy-handed, just for sake of the (relatively rare, for the + # average user) coinbase-carrying addresses. + # + # It's not a huge performance hit for the coinbase addresses to + # simply not cache their results, and have this function recompute + # their balance on each call, when you consider that as a + # consequence of this policy, all the other addresses that are + # non-coinbase can benefit from a cache that stays valid for longer + # than 1 block (so long as their balances haven't changed). + self._addr_bal_cache[address] = result + return result + + def get_spendable_coins(self, domain, config, isInvoice = False): confirmed_only = config.get('confirmed_only', False) + if (isInvoice): + confirmed_only = True return self.get_utxos(domain, exclude_frozen=True, mature=True, confirmed_only=confirmed_only) - def get_utxos(self, domain = None, exclude_frozen = False, mature = False, confirmed_only = False): + def get_slp_spendable_coins(self, slpTokenId, domain, config, isInvoice = False): + confirmed_only = config.get('confirmed_only', False) + if (isInvoice): + confirmed_only = True + return self.get_slp_utxos(slpTokenId, domain=domain, exclude_frozen=True, confirmed_only=confirmed_only) + + def get_slp_coins(self, slpTokenId, domain, config, isInvoice = False): + confirmed_only = config.get('confirmed_only', False) + if (isInvoice): + confirmed_only = True + return self.get_slp_utxos(slpTokenId, domain=domain, exclude_frozen=False, confirmed_only=confirmed_only) + + def get_slp_token_balance(self, slpTokenId, config): + valid_token_bal = 0 + unvalidated_token_bal = 0 + invalid_token_bal = 0 + unfrozen_valid_token_bal = 0 + slp_coins = self.get_slp_coins(slpTokenId, None, config) + for coin in slp_coins: + txid = coin['prevout_hash'] + validity = self.tx_tokinfo[txid]['validity'] + if validity == 1: # Valid DAG + valid_token_bal += coin['token_value'] + if not coin['is_frozen_coin'] and coin['address'] not in self.frozen_addresses: + unfrozen_valid_token_bal += coin['token_value'] + elif validity > 1: # Invalid DAG (2=bad slpmessage, 3=inputs lack enough tokens / missing mint baton, 4=change token_type or bad NFT parent) + invalid_token_bal += coin['token_value'] + elif validity == 0: # Unknown DAG status (should be in processing queue) + unvalidated_token_bal += coin['token_value'] + return (valid_token_bal, unvalidated_token_bal, invalid_token_bal, unfrozen_valid_token_bal, valid_token_bal - unfrozen_valid_token_bal) + + def get_utxos(self, domain = None, exclude_frozen = False, mature = False, confirmed_only = False, exclude_slp = True): + ''' Note that exclude_frozen = True checks for BOTH address-level and coin-level frozen status. ''' coins = [] if domain is None: domain = self.get_addresses() if exclude_frozen: domain = set(domain) - self.frozen_addresses for addr in domain: - utxos = self.get_addr_utxo(addr) + utxos = self.get_addr_utxo(addr, exclude_slp=exclude_slp) for x in utxos.values(): + if exclude_frozen and x['is_frozen_coin']: + continue if confirmed_only and x['height'] <= 0: continue if mature and x['coinbase'] and x['height'] + COINBASE_MATURITY > self.get_local_height(): @@ -716,6 +1166,24 @@ def get_utxos(self, domain = None, exclude_frozen = False, mature = False, confi continue return coins + def get_slp_utxos(self, slpTokenId, *, domain = None, exclude_frozen = False, confirmed_only = False, slp_include_invalid=False, slp_include_baton=False): + ''' Note that exclude_frozen = True checks for BOTH address-level and coin-level frozen status. ''' + coins = [] + if domain is None: + domain = self.get_addresses() + if exclude_frozen: + domain = set(domain) - self.frozen_addresses + for addr in domain: + utxos = self.get_slp_addr_utxo(addr, slpTokenId, slp_include_invalid=slp_include_invalid, slp_include_baton=slp_include_baton) + for x in utxos.values(): + if exclude_frozen and x['is_frozen_coin']: + continue + if confirmed_only and x['height'] <= 0: + continue + coins.append(x) + continue + return coins + def dummy_address(self): return self.get_receiving_addresses()[0] @@ -726,20 +1194,46 @@ def get_addresses(self): return out def get_frozen_balance(self): - return self.get_balance(self.frozen_addresses) - - def get_balance(self, domain=None): + if not self.frozen_coins: + # performance short-cut -- get the balance of the frozen address set only IFF we don't have any frozen coins + return self.get_balance(self.frozen_addresses) + # otherwise, do this more costly calculation... + cc_no_f, uu_no_f, xx_no_f = self.get_balance(None, exclude_frozen_coins = True, exclude_frozen_addresses = True) + cc_all, uu_all, xx_all = self.get_balance(None, exclude_frozen_coins = False, exclude_frozen_addresses = False) + return (cc_all-cc_no_f), (uu_all-uu_no_f), (xx_all-xx_no_f) + + def get_slp_locked_balance(self): + bch = 0 + with self.lock: + for addr, addrdict in self._slp_txo.items(): + _, spent = self.get_addr_io(addr) + for txid, txdict in addrdict.items(): + for idx, txo in txdict.items(): + if (txid + ":" + str(idx)) in spent: + continue + try: + for i, a, _ in self.txo[txid][addr]: + if i == idx: + bch+=a + except KeyError: + pass + return bch + + def get_balance(self, domain=None, exclude_frozen_coins=False, exclude_frozen_addresses=False): if domain is None: domain = self.get_addresses() + if exclude_frozen_addresses: + domain = set(domain) - self.frozen_addresses cc = uu = xx = 0 for addr in domain: - c, u, x = self.get_addr_balance(addr) + c, u, x = self.get_addr_balance(addr, exclude_frozen_coins) cc += c uu += u xx += x return cc, uu, xx def get_address_history(self, addr): + assert isinstance(addr, Address) h = [] # we need self.transaction_lock but get_tx_height will take self.lock # so we need to take that too here, to enforce order of locks @@ -770,6 +1264,7 @@ def _remove_tx_from_local_history(self, txid): def get_txin_address(self, txi): addr = txi.get('address') + assert isinstance(addr, Address) if addr != "(pubkey)": return addr prevout_hash = txi.get('prevout_hash') @@ -782,13 +1277,7 @@ def get_txin_address(self, txi): return addr def get_txout_address(self, txo): - _type, x, v = txo - if _type == TYPE_ADDRESS: - addr = x - elif _type == TYPE_PUBKEY: - addr = bitcoin.public_key_to_p2pkh(bfh(x)) - else: - addr = None + _type, addr, v = txo return addr def get_conflicting_transactions(self, tx): @@ -830,7 +1319,7 @@ def add_transaction(self, tx_hash, tx): # being is_mine, as we roll the gap_limit forward is_coinbase = len(tx.inputs()) and tx.inputs()[0]['type'] == 'coinbase' tx_height = self.get_tx_height(tx_hash)[0] - is_mine = any([self.is_mine(txin['address']) for txin in tx.inputs()]) + is_mine = any([self.is_mine(txin.get('address')) for txin in tx.inputs()]) # do not save if tx is local and not mine if tx_height == TX_HEIGHT_LOCAL and not is_mine: # FIXME the test here should be for "not all is_mine"; cannot detect conflict in some cases @@ -870,12 +1359,12 @@ def add_transaction(self, tx_hash, tx): # add inputs self.txi[tx_hash] = d = {} for txi in tx.inputs(): - addr = self.get_txin_address(txi) + addr = txi.get('address') if txi['type'] != 'coinbase': prevout_hash = txi['prevout_hash'] prevout_n = txi['prevout_n'] ser = prevout_hash + ':%d'%prevout_n - if addr and self.is_mine(addr): + if self.is_mine(addr): # we only track is_mine spends self.spent_outpoints[ser] = tx_hash # find value from prev output @@ -888,16 +1377,18 @@ def add_transaction(self, tx_hash, tx): break else: self.pruned_txo[ser] = tx_hash + self._addr_bal_cache.pop(addr, None) # invalidate cache entry + # add outputs self.txo[tx_hash] = d = {} for n, txo in enumerate(tx.outputs()): - v = txo[2] ser = tx_hash + ':%d'%n - addr = self.get_txout_address(txo) - if addr and self.is_mine(addr): - if d.get(addr) is None: + _type, addr, v = txo + if self.is_mine(addr): + if not addr in d: d[addr] = [] d[addr].append((n, v, is_coinbase)) + self._addr_bal_cache.pop(addr, None) # invalidate cache entry # give v to txi that spends me next_tx = self.pruned_txo.get(ser) if next_tx is not None: @@ -909,9 +1400,168 @@ def add_transaction(self, tx_hash, tx): self._add_tx_to_local_history(next_tx) # add to local history self._add_tx_to_local_history(tx_hash) + # save self.transactions[tx_hash] = tx - return True + + ### SLP: Handle incoming SLP transaction outputs here + self.handleSlpTransaction(tx_hash, tx) + + """ + Callers are expected to take lock(s). We take no locks + """ + def handleSlpTransaction(self, tx_hash, tx): + txouts = tx.outputs() + + try: + slpMsg = SlpMessage.parseSlpOutputScript(txouts[0][1]) + except SlpUnsupportedSlpTokenType as e: + token_type = 'SLP%d'%(e.args[0],) + for i, (_type, addr, _) in enumerate(txouts): + if _type == TYPE_ADDRESS and self.is_mine(addr): + self._slp_txo[addr][tx_hash][i] = { + 'type': token_type, + 'qty': None, + 'token_id': None, + } + return + except (SlpParsingError, IndexError, OpreturnError): + return + + if slpMsg.transaction_type == 'SEND': + token_id_hex = slpMsg.op_return_fields['token_id_hex'] + # truncate outputs list + amounts = slpMsg.op_return_fields['token_output'][:len(txouts)] + for i, qty in enumerate(amounts): + _type, addr, _ = txouts[i] + if _type == TYPE_ADDRESS and qty > 0 and self.is_mine(addr): + self._slp_txo[addr][tx_hash][i] = { + 'type': 'SLP%d'%(slpMsg.token_type,), + 'token_id': token_id_hex, + 'qty': qty, + } + elif slpMsg.transaction_type == 'GENESIS': + token_id_hex = tx_hash + try: + _type, addr, _ = txouts[1] + if _type == TYPE_ADDRESS: + if slpMsg.op_return_fields['initial_token_mint_quantity'] > 0 and self.is_mine(addr): + self._slp_txo[addr][tx_hash][1] = { + 'type': 'SLP%d'%(slpMsg.token_type,), + 'token_id': token_id_hex, + 'qty': slpMsg.op_return_fields['initial_token_mint_quantity'], + } + if slpMsg.op_return_fields['mint_baton_vout'] is not None: + i = slpMsg.op_return_fields['mint_baton_vout'] + _type, addr, _ = txouts[i] + if _type == TYPE_ADDRESS: + self._slp_txo[addr][tx_hash][i] = { + 'type': 'SLP%d'%(slpMsg.token_type,), + 'token_id': token_id_hex, + 'qty': 'MINT_BATON', + } + except IndexError: # if too few outputs (compared to mint_baton_vout) + pass + elif slpMsg.transaction_type == "MINT": + token_id_hex = slpMsg.op_return_fields['token_id_hex'] + try: + _type, addr, _ = txouts[1] + if _type == TYPE_ADDRESS: + if slpMsg.op_return_fields['additional_token_quantity'] > 0 and self.is_mine(addr): + self._slp_txo[addr][tx_hash][1] = { + 'type': 'SLP%d'%(slpMsg.token_type,), + 'token_id': token_id_hex, + 'qty': slpMsg.op_return_fields['additional_token_quantity'], + } + if slpMsg.op_return_fields['mint_baton_vout'] is not None: + i = slpMsg.op_return_fields['mint_baton_vout'] + _type, addr, _ = txouts[i] + if _type == TYPE_ADDRESS: + self._slp_txo[addr][tx_hash][i] = { + 'type': 'SLP%d'%(slpMsg.token_type,), + 'token_id': token_id_hex, + 'qty': 'MINT_BATON', + } + except IndexError: # if too few outputs (compared to mint_baton_vout) + pass + elif slpMsg.transaction_type == 'COMMIT': + # ignore COMMs, they aren't producing any tokens. + return + else: + raise RuntimeError(slpMsg.transaction_type) + + # On receiving a new SEND, MINT, or GENESIS always add entry to token_types if wallet hasn't seen tokenId yet + if slpMsg.transaction_type in [ 'SEND', 'MINT', 'GENESIS' ]: + if slpMsg.transaction_type == 'GENESIS': + tokenid = tx_hash + else: + tokenid = slpMsg.op_return_fields['token_id_hex'] + new_token = True + for k, v in self.tx_tokinfo.items(): + try: + if v['token_id'] == tokenid: + new_token = False + except KeyError: + pass + if new_token and tokenid not in self.token_types: + tty = { 'class': 'SLP%d'%(slpMsg.token_type,), + 'decimals': "?", + 'name': 'unknown-' + tokenid[:3] + } + if slpMsg.token_type == 65: + tty['group_id'] = "?" + self.token_types[tokenid] = tty + + # Always add entry to tx_tokinfo + tti = { 'type':'SLP%d'%(slpMsg.token_type,), + 'transaction_type':slpMsg.transaction_type, + 'token_id': token_id_hex, + 'validity': 0, + } + self.tx_tokinfo[tx_hash] = tti + + if self.is_slp: # Only start up validation if SLP enabled + self.slp_check_validation(tx_hash, tx) + + def slp_check_validation(self, tx_hash, tx): + """ Callers are expected to take lock(s). We take no locks """ + tti = self.tx_tokinfo[tx_hash] + try: + is_new = self.token_types[tti['token_id']]['decimals'] == '?' + except: + is_new = False + if tti['validity'] == 0 and tti['token_id'] in self.token_types and not is_new and tti['type'] in ['SLP1','SLP65','SLP129']: + def callback(job): + (txid,node), = job.nodes.items() + val = node.validity + tti['validity'] = val + ui_cb = self.ui_emit_validity_updated + if ui_cb: + ui_cb(txid, val) + + if tti['type'] in ['SLP1']: + job = self.slp_graph_0x01.make_job(tx, self, self.network, + debug=2 if is_verbose else 1, # set debug=2 here to see the verbose dag when running with -v + reset=False) + elif tti['type'] in ['SLP65','SLP129']: + job = self.slp_graph_0x01_nft.make_job(tx, self, self.network, nft_type=tti['type'], + debug=2 if is_verbose else 1, # set debug=2 here to see the verbose dag when running with -v + reset=False) + + if job is not None: + job.add_callback(callback) + # This was commented out because it spammed the log so badly + # it impacted performance. SLP validation can create a *lot* of jobs! + #finalization_print_error(job, f"[{self.basename()}] Job for {tx_hash} type {tti['type']} finalized") + + def rebuild_slp(self,): + """Wipe away old SLP transaction data and rerun on the entire tx set. + """ + with self.lock: + self._slp_txo = defaultdict(lambda: defaultdict(dict)) + self.tx_tokinfo = {} + for txid, tx in self.transactions.items(): + self.handleSlpTransaction(txid, tx) def remove_transaction(self, tx_hash): @@ -938,6 +1588,7 @@ def remove_transaction(self, tx_hash): ser, v = item prev_hash, prev_n = ser.split(':') if prev_hash == tx_hash: + self._addr_bal_cache.pop(addr, None) # invalidate cache entry l.remove(item) self.pruned_txo[ser] = next_tx if l == []: @@ -945,14 +1596,25 @@ def remove_transaction(self, tx_hash): else: dd[addr] = l + # invalidate addr_bal_cache for outputs involving this tx + d = self.txo.get(tx_hash, {}) + for addr in d: + self._addr_bal_cache.pop(addr, None) # invalidate cache entry + self.txi.pop(tx_hash, None) self.txo.pop(tx_hash, None) + self.tx_fees.pop(tx_hash, None) + self.tx_tokinfo[tx_hash] = {} + + for addr, addrdict in self._slp_txo.items(): + if tx_hash in addrdict: addrdict[tx_hash] = {} def receive_tx_callback(self, tx_hash, tx, tx_height): self.add_unverified_tx(tx_hash, tx_height) self.add_transaction(tx_hash, tx) def receive_history_callback(self, addr, hist, tx_fees): + assert isinstance(addr, Address) with self.lock: old_hist = self.get_address_history(addr) for tx_hash, height in old_hist: @@ -966,6 +1628,7 @@ def receive_history_callback(self, addr, hist, tx_fees): if self.txi[tx_hash] == {}: # FIXME the test here should be for "not all is_mine"; cannot detect conflict in some cases self.remove_transaction(tx_hash) + self._addr_bal_cache.pop(addr, None) # unconditionally invalidate cache entry self.history[addr] = hist for tx_hash, tx_height in hist: @@ -979,6 +1642,69 @@ def receive_history_callback(self, addr, hist, tx_fees): # Store fees self.tx_fees.update(tx_fees) + def get_slp_history(self, domain=None, validities_considered=(None,0,1)): + history = [] + histories = self.get_slp_histories(domain=domain, validities_considered=validities_considered) + # Take separate token histories and flatten them, then sort them. + for token_id,t_history in histories.items(): + for tx_hash, height, conf, timestamp, delta in t_history: + history.append((tx_hash, height, conf, timestamp, delta, token_id)) + history.sort(key = lambda x: self.get_txpos(x[0]), reverse=True) + + return history + + def get_slp_histories(self, domain=None, validities_considered=(0,1)): + # Based on get_history. + # We return a dict of histories, one history per token_id. + + # get domain + if domain is None: + domain = self.get_addresses() + + #1. Big iteration to find all deltas and put them in the right place. + token_tx_deltas = defaultdict(lambda: defaultdict(int)) # defaultdict of defaultdicts of ints :) + for addr in domain: + h = self.get_address_history(addr) + with self.lock: + addrslptxo = self._slp_txo[addr] + + for tx_hash, height in h: + if tx_hash in self.pruned_txo.values(): + continue + tti = self.tx_tokinfo.get(tx_hash) + if tti and tti['validity'] in validities_considered: + txdict = addrslptxo.get(tx_hash,{}) + + for idx,d in txdict.items(): + if isinstance(d['qty'],int): + token_tx_deltas[d['token_id']][tx_hash] += d['qty'] # received! + + # scan over all txi's, trying to find if they were tokens, which tokens, and how much + # (note that non-SLP txes can spend (burn) SLP --- and SLP of tokenA can burn tokenB) + for n, _ in self.txi.get(tx_hash, {}).get(addr, ()): + prevtxid, prevout_str = n.rsplit(':',1) + tti = self.tx_tokinfo.get(prevtxid) + if not (tti and tti['validity'] in validities_considered): + continue + prevout = int(prevout_str) + + d = addrslptxo.get(prevtxid,{}).get(prevout,{}) + if isinstance(d.get('qty',None),int): + token_tx_deltas[d['token_id']][tx_hash] -= d['qty'] # received! + + # 2. create history (no sorting needed since balances won't be computed) + histories = {} + for token_id, tx_deltas in token_tx_deltas.items(): + history = histories[token_id] = [] + for tx_hash in tx_deltas: + delta = tx_deltas[tx_hash] + height, conf, timestamp = self.get_tx_height(tx_hash) + history.append((tx_hash, height, conf, timestamp, delta)) + + # 3. At this point we could compute running balances, but let's not. + + return histories + def get_history(self, domain=None): # get domain if domain is None: @@ -1143,7 +1869,7 @@ def get_default_label(self, tx_hash): d = self.txo.get(tx_hash, {}) labels = [] for addr in d.keys(): - label = self.labels.get(addr) + label = self.labels.get(addr.to_storage_string()) if label: labels.append(label) return ', '.join(labels) @@ -1190,15 +1916,22 @@ def relayfee(self): def dust_threshold(self): return dust_threshold(self.network) + def check_sufficient_slp_balance(self, slpMessage, config): + if self.is_slp: + if slpMessage.transaction_type == 'SEND': + total_token_out = sum(slpMessage.op_return_fields['token_output']) + valid_token_balance, _, _, valid_unfrozen_token_balance, _ = self.get_slp_token_balance(slpMessage.op_return_fields['token_id_hex'], config) + if total_token_out > valid_token_balance: + raise NotEnoughFundsSlp() + elif total_token_out > valid_unfrozen_token_balance: + raise NotEnoughUnfrozenFundsSlp() + def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, - change_addr=None, is_sweep=False): + change_addr=None, *, mandatory_coins=[]): # check outputs i_max = None for i, o in enumerate(outputs): _type, data, value = o - if _type == TYPE_ADDRESS: - if not is_address(data): - raise Exception("Invalid Zclassic address: {}".format(data)) if value == '!': if i_max is not None: raise Exception("More than one output set to spend max") @@ -1211,9 +1944,11 @@ def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, if fixed_fee is None and config.fee_per_kb() is None: raise NoDynamicFeeEstimates() - if not is_sweep: - for item in inputs: - self.add_input_info(item) + for item in inputs: + self.add_input_info(item) + + for item in mandatory_coins: + self.add_input_info(item) # change address if change_addr: @@ -1229,8 +1964,9 @@ def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, if not change_addrs: change_addrs = [random.choice(addrs)] else: - # coin_chooser will set change address - change_addrs = [] + change_addrs = [inputs[0]['address']] + + assert all(isinstance(addr, Address) for addr in change_addrs) # Fee estimator if fixed_fee is None: @@ -1247,22 +1983,112 @@ def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, max_change = self.max_change_outputs if self.multiple_change else 1 coin_chooser = coinchooser.get_coin_chooser(config) tx = coin_chooser.make_tx(inputs, outputs, change_addrs[:max_change], - fee_estimator, self.dust_threshold()) + fee_estimator, self.dust_threshold(), mandatory_coins=mandatory_coins) else: - # FIXME?? this might spend inputs with negative effective value... + inputs = mandatory_coins + inputs sendable = sum(map(lambda x:x['value'], inputs)) _type, data, value = outputs[i_max] outputs[i_max] = (_type, data, 0) - tx = Transaction.from_io(inputs, outputs[:]) + tx = Transaction.from_io(inputs, outputs) fee = fee_estimator(tx.estimated_size()) amount = max(0, sendable - tx.output_value() - fee) outputs[i_max] = (_type, data, amount) - tx = Transaction.from_io(inputs, outputs[:]) + tx = Transaction.from_io(inputs, outputs) + + # If user tries to send too big of a fee (more than 50 sat/byte), stop them from shooting themselves in the foot + tx_in_bytes=tx.estimated_size() + fee_in_satoshis=tx.get_fee() + sats_per_byte=fee_in_satoshis/tx_in_bytes + if (sats_per_byte > 50): + raise ExcessiveFee() + return # Sort the inputs and outputs deterministically - tx.BIP_LI01_sort() + if not mandatory_coins: + tx.BIP_LI01_sort() + + # Timelock tx to current height. + locktime = self.get_local_height() + if locktime == -1: # We have no local height data (no headers synced). + locktime = 0 + tx.locktime = locktime + run_hook('make_unsigned_transaction', self, tx) + return tx + + def make_unsigned_transaction_for_bitcoinfiles(self, inputs, outputs, config, fixed_fee=None, change_addr=None): + # check outputs + i_max = None + for i, o in enumerate(outputs): + _type, data, value = o + if value == '!': + if i_max is not None: + raise BaseException("More than one output set to spend max") + i_max = i + + # Avoid index-out-of-range with inputs[0] below + if not inputs: + raise NotEnoughFunds() + + if fixed_fee is None and config.fee_per_kb() is None: + raise BaseException('Dynamic fee estimates not available') + + for item in inputs: + self.add_input_info_for_bitcoinfiles(item) + + # change address + if change_addr: + change_addrs = [change_addr] + else: + addrs = self.get_change_addresses()[-self.gap_limit_for_change:] + if self.use_change and addrs: + # New change addresses are created only after a few + # confirmations. Select the unused addresses within the + # gap limit; if none take one at random + change_addrs = [addr for addr in addrs if + self.get_num_tx(addr) == 0] + if not change_addrs: + change_addrs = [random.choice(addrs)] + else: + change_addrs = [inputs[0]['address']] + + assert all(isinstance(addr, Address) for addr in change_addrs) + + # Fee estimator + if fixed_fee is None: + fee_estimator = config.estimate_fee + else: + fee_estimator = lambda size: fixed_fee + + if i_max is None: + # Let the coin chooser select the coins to spend + max_change = self.max_change_outputs if self.multiple_change else 1 + coin_chooser = coinchooser.CoinChooserPrivacy() + # determine if this transaction should utilize all available inputs + tx = coin_chooser.make_tx(inputs, outputs, change_addrs[:max_change], + fee_estimator, self.dust_threshold()) + else: + sendable = sum(map(lambda x:x['value'], inputs)) + _type, data, value = outputs[i_max] + outputs[i_max] = (_type, data, 0) + tx = Transaction.from_io(inputs, outputs) + fee = fee_estimator(tx.estimated_size()) + amount = max(0, sendable - tx.output_value() - fee) + outputs[i_max] = (_type, data, amount) + tx = Transaction.from_io(inputs, outputs) + + # If user tries to send too big of a fee (more than 50 sat/byte), stop them from shooting themselves in the foot + tx_in_bytes=tx.estimated_size() + fee_in_satoshis=tx.get_fee() + sats_per_byte=fee_in_satoshis/tx_in_bytes + if (sats_per_byte > 50): + raise ExcessiveFee() + return + # Timelock tx to current height. - tx.locktime = self.get_local_height() + locktime = self.get_local_height() + if locktime == -1: # We have no local height data (no headers synced). + locktime = 0 + tx.locktime = locktime run_hook('make_unsigned_transaction', self, tx) return tx @@ -1273,10 +2099,26 @@ def mktx(self, outputs, password, config, fee=None, change_addr=None, domain=Non return tx def is_frozen(self, addr): + ''' Address-level frozen query. Note: this is set/unset independent of 'coin' level freezing. ''' + assert isinstance(addr, Address) return addr in self.frozen_addresses + def is_frozen_coin(self, utxo): + ''' 'coin' level frozen query. `utxo' is a prevout:n string, or a dict as returned from get_utxos(). + Note: this is set/unset independent of 'address' level freezing. ''' + assert isinstance(utxo, (str, dict)) + if isinstance(utxo, dict): + ret = ("{}:{}".format(utxo['prevout_hash'], utxo['prevout_n'])) in self.frozen_coins + if ret != utxo['is_frozen_coin']: + self.print_error("*** WARNING: utxo has stale is_frozen_coin flag") + utxo['is_frozen_coin'] = ret # update stale flag + return ret + return utxo in self.frozen_coins + def set_frozen_state(self, addrs, freeze): - '''Set frozen state of the addresses to FREEZE, True or False''' + '''Set frozen state of the addresses to FREEZE, True or False + Note that address-level freezing is set/unset independent of coin-level freezing, however both must + be satisfied for a coin to be defined as spendable.. ''' if all(self.is_mine(addr) for addr in addrs): if freeze: self.frozen_addresses |= set(addrs) @@ -1286,6 +2128,32 @@ def set_frozen_state(self, addrs, freeze): return True return False + def set_frozen_coin_state(self, utxos, freeze): + ''' Set frozen state of the COINS to FREEZE, True or False. + utxos is a (possibly mixed) list of either "prevout:n" strings and/or coin-dicts as returned from get_utxos(). + Note that if passing prevout:n strings as input, 'is_mine()' status is not checked for the specified coin. + Also note that coin-level freezing is set/unset independent of address-level freezing, however both must + be satisfied for a coin to be defined as spendable. ''' + ok = 0 + for utxo in utxos: + if isinstance(utxo, str): + if freeze: + self.frozen_coins |= { utxo } + else: + self.frozen_coins -= { utxo } + ok += 1 + elif isinstance(utxo, dict) and self.is_mine(utxo['address']): + txo = "{}:{}".format(utxo['prevout_hash'], utxo['prevout_n']) + if freeze: + self.frozen_coins |= { txo } + else: + self.frozen_coins -= { txo } + utxo['is_frozen_coin'] = bool(freeze) + ok += 1 + if ok: + self.storage.put('frozen_coins', list(self.frozen_coins)) + return ok + def load_unverified_transactions(self): # review transactions that are in the history for addr, hist in self.history.items(): @@ -1293,11 +2161,27 @@ def load_unverified_transactions(self): # add it in case it was previously unconfirmed self.add_unverified_tx(tx_hash, tx_height) + def _slp_callback_on_status(self, event, *args): + if self.is_slp and args[0] == 'connected': + self.activate_slp() + def start_threads(self, network): self.network = network if self.network is not None: + if self.is_slp: + # Note: it's important that SLP data structures are defined + # before the network (SPV/Synchronizer) callbacks are installed + # otherwise we may receive a tx from the network thread + # before SLP objects are properly constructed. + self.slp_graph_0x01 = slp_validator_0x01.shared_context + self.slp_graph_0x01_nft = slp_validator_0x01_nft1.shared_context_nft1 + self.activate_slp() + self.network.register_callback(self._slp_callback_on_status, ['status']) + self.load_unverified_transactions() self.verifier = SPV(self.network, self) self.synchronizer = Synchronizer(self, network) + finalization_print_error(self.verifier, "[{}.{}] finalized".format(self.diagnostic_name(), self.verifier.diagnostic_name())) + finalization_print_error(self.synchronizer, "[{}.{}] finalized".format(self.diagnostic_name(), self.synchronizer.diagnostic_name())) network.add_jobs([self.verifier, self.synchronizer]) else: self.verifier = None @@ -1305,15 +2189,32 @@ def start_threads(self, network): def stop_threads(self): if self.network: - self.network.remove_jobs([self.synchronizer, self.verifier]) + # Note: syncrhonizer and verifier will remove themselves from the + # network thread the next time they run, as a result of the below + # release() calls. + # It is done this way (as opposed to an immediate clean-up here) + # because these objects need to do thier clean-up actions in a + # thread-safe fashion from within the thread where they normally + # operate on their data structures. self.synchronizer.release() + self.verifier.release() self.synchronizer = None self.verifier = None # Now no references to the synchronizer or verifier # remain so they will be GC-ed + if self.is_slp: + # NB: it's important this be done here after network + # callbacks are torn down in the above lines. + self.network.unregister_callback(self._slp_callback_on_status) + jobs_stopped = self.slp_graph_0x01.stop_all_for_wallet(self, timeout=2.0) + self.print_error("Stopped", len(jobs_stopped), "slp_0x01 jobs") + #jobs_stopped = self.slp_graph_0x01_nft.stop_all_for_wallet(self) + #self.print_error("Stopped", len(jobs_stopped), "slp_0x01_nft jobs") + self.slp_graph_0x01_nft.kill() + self.slp_graph_0x01, self.slp_graph_0x01_nft = None, None self.storage.put('stored_height', self.get_local_height()) self.save_transactions() - self.storage.put('verified_tx3', self.verified_tx) + self.save_verified_tx() self.storage.write() def wait_until_synchronized(self, callback=None): @@ -1345,6 +2246,7 @@ def can_export(self): return not self.is_watching_only() and hasattr(self.keystore, 'get_private_key') def is_used(self, address): + assert isinstance(address, Address) h = self.history.get(address,[]) if len(h) == 0: return False @@ -1352,10 +2254,12 @@ def is_used(self, address): return c + u + x == 0 def is_empty(self, address): + assert isinstance(address, Address) c, u, x = self.get_addr_balance(address) return c+u+x == 0 def address_is_old(self, address, age_limit=2): + assert isinstance(address, Address) age = -1 h = self.history.get(address, []) for tx_hash, tx_height in h: @@ -1367,12 +2271,37 @@ def address_is_old(self, address, age_limit=2): age = tx_age return age > age_limit + def cpfp(self, tx, fee): + txid = tx.txid() + for i, o in enumerate(tx.outputs()): + otype, address, value = o + if otype == TYPE_ADDRESS and self.is_mine(address): + break + else: + return + coins = self.get_addr_utxo(address) + item = coins.get(txid+':%d'%i) + if not item: + return + self.add_input_info(item) + inputs = [item] + outputs = [(TYPE_ADDRESS, address, value - fee)] + locktime = self.get_local_height() + # note: no need to call tx.BIP_LI01_sort() here - single input/output + return Transaction.from_io(inputs, outputs, locktime=locktime) + def add_input_info(self, txin): address = txin['address'] if self.is_mine(address): txin['type'] = self.get_txin_type(address) self.add_input_sig_info(txin, address) + def add_input_info_for_bitcoinfiles(self, txin): + address = txin['address'] + if self.is_mine(address): + txin['type'] = self.get_txin_type(address) + self.add_input_sig_info(txin, address) + def can_sign(self, tx): if tx.is_complete(): return False @@ -1427,30 +2356,28 @@ def sign_transaction(self, tx, password): except UserCancelled: continue - def get_unused_addresses(self): + def get_unused_addresses(self, *, for_change=False, frozen_ok=True): # fixme: use slots from expired requests - domain = self.get_receiving_addresses() - return [addr for addr in domain if not self.history.get(addr) - and addr not in self.receive_requests.keys()] - - def get_unused_address(self): - addrs = self.get_unused_addresses() + with self.lock: + domain = self.get_receiving_addresses() if not for_change else (self.get_change_addresses() or self.get_receiving_addresses()) + return [addr for addr in domain + if not self.get_address_history(addr) + and addr not in self.receive_requests + and (frozen_ok or addr not in self.frozen_addresses)] + + def get_unused_address(self, *, for_change=False, frozen_ok=True): + addrs = self.get_unused_addresses(for_change=for_change, frozen_ok=frozen_ok) if addrs: return addrs[0] - def get_receiving_address(self): - # always return an address - domain = self.get_receiving_addresses() + def get_receiving_address(self, *, frozen_ok=True): + '''Returns a receiving address or None.''' + domain = self.get_unused_addresses(frozen_ok=frozen_ok) if not domain: - return - choice = domain[0] - for addr in domain: - if not self.history.get(addr): - if addr not in self.receive_requests.keys(): - return addr - else: - choice = addr - return choice + domain = [a for a in self.get_receiving_addresses() + if frozen_ok or a not in self.frozen_addresses] + if domain: + return domain[0] def get_payment_status(self, address, amount): local_height = self.get_local_height() @@ -1474,11 +2401,19 @@ def get_payment_status(self, address, amount): return False, None def get_payment_request(self, addr, config): + assert isinstance(addr, Address) r = self.receive_requests.get(addr) if not r: return out = copy.copy(r) - out['URI'] = 'zclassic:' + addr + '?amount=' + format_satoshis(out.get('amount')) + addr_text = addr.to_ui_string() + amount_text = format_satoshis(r['amount']) + if addr.FMT_UI == addr.FMT_ZCLASSIC: + out['URI'] = 'zclassic:{}?amount={}'.format(addr_text, amount_text) + elif addr.FMT_UI == addr.FMT_SLPADDR: + token_id = "" + out['URI'] = '{}:{}?amount={}&token={}'.format(constants.net.SLPADDR_PREFIX, + addr_text, amount_text, token_id) status, conf = self.get_request_status(addr) out['status'] = status if conf is not None: @@ -1486,7 +2421,7 @@ def get_payment_request(self, addr, config): # check if bip70 file exists rdir = config.get('requests_dir') if rdir: - key = out.get('id', addr) + key = out.get('id', addr.to_storage_string()) path = os.path.join(rdir, 'req', key[0], key[1], key) if os.path.exists(path): baseurl = 'file://' + rdir @@ -1537,37 +2472,49 @@ def get_request_status(self, key): return status, conf def make_payment_request(self, addr, amount, message, expiration): + assert isinstance(addr, Address) timestamp = int(time.time()) - _id = bh2u(Hash(addr + "%d"%timestamp))[0:10] - r = {'time':timestamp, 'amount':amount, 'exp':expiration, 'address':addr, 'memo':message, 'id':_id} - return r + _id = bh2u(Hash(addr.to_storage_string() + "%d" % timestamp))[0:10] + return { + 'time':timestamp, + 'amount':amount, + 'exp':expiration, + 'address':addr, + 'memo':message, + 'id':_id + } + + def serialize_request(self, r): + result = r.copy() + result['address'] = r['address'].to_storage_string() + return result + + def save_payment_requests(self): + requests = {addr.to_ui_string() : value.copy().pop('address') for addr, value in self.receive_requests.items()} + self.storage.put('payment_requests', requests) def sign_payment_request(self, key, alias, alias_addr, password): req = self.receive_requests.get(key) - alias_privkey = self.export_private_key(alias_addr, password)[0] + alias_privkey = self.export_private_key(alias_addr, password) pr = paymentrequest.make_unsigned_request(req) paymentrequest.sign_request_with_alias(pr, alias, alias_privkey) req['name'] = pr.pki_data req['sig'] = bh2u(pr.signature) self.receive_requests[key] = req - self.storage.put('payment_requests', self.receive_requests) + self.save_payment_requests() def add_payment_request(self, req, config): addr = req['address'] - if not bitcoin.is_address(addr): - raise Exception(_('Invalid Zclassic address.')) - if not self.is_mine(addr): - raise Exception(_('Address not in wallet.')) - - amount = req.get('amount') - message = req.get('memo') + addr_text = addr.to_storage_string() + amount = req['amount'] + message = req['memo'] self.receive_requests[addr] = req - self.storage.put('payment_requests', self.receive_requests) - self.set_label(addr, message) # should be a default label + self.save_payment_requests() + self.set_label(addr_text, message) # should be a default label rdir = config.get('requests_dir') if rdir and amount is not None: - key = req.get('id', addr) + key = req.get('id', addr_text) pr = paymentrequest.make_request(config, req) path = os.path.join(rdir, 'req', key[0], key[1], key) if not os.path.exists(path): @@ -1580,9 +2527,9 @@ def add_payment_request(self, req, config): f.write(pr.SerializeToString()) # reload req = self.get_payment_request(addr, config) + req['address'] = req['address'].to_ui_string() with open(os.path.join(path, key + '.json'), 'w', encoding='utf-8') as f: f.write(json.dumps(req)) - return req def remove_payment_request(self, addr, config): if addr not in self.receive_requests: @@ -1595,18 +2542,27 @@ def remove_payment_request(self, addr, config): n = os.path.join(rdir, 'req', key[0], key[1], key, key + s) if os.path.exists(n): os.unlink(n) - self.storage.put('payment_requests', self.receive_requests) + self.save_payment_requests() return True def get_sorted_requests(self, config): - def f(addr): - try: - return self.get_address_index(addr) - except: - return - keys = map(lambda x: (f(x), x), self.receive_requests.keys()) - sorted_keys = sorted(filter(lambda x: x[0] is not None, keys)) - return [self.get_payment_request(x[1], config) for x in sorted_keys] + m = map(lambda x: self.get_payment_request(x, config), self.receive_requests.keys()) + try: + def f(x): + try: + addr = x['address'] + return self.get_address_index(addr) or addr + except: + return addr + return sorted(m, key=f) + except TypeError: + # See issue #1231 -- can get inhomogenous results in the above + # sorting function due to the 'or addr' possible return. + # This can happen if addresses for some reason drop out of wallet + # while, say, the history rescan is running and it can't yet find + # an address index for an address. In that case we will + # return an unsorted list to the caller. + return list(m) def get_fingerprint(self): raise NotImplementedError() @@ -1621,6 +2577,9 @@ def can_delete_address(self): return False def add_address(self, address): + assert isinstance(address, Address) + self._addr_bal_cache.pop(address, None) # paranoia, not really necessary -- just want to maintain the invariant that when we modify address history below we invalidate cache. + self.invalidate_address_set_cache() if address not in self.history: self.history[address] = [] if self.synchronizer: @@ -1835,7 +2794,7 @@ def is_change(self, address): def get_master_public_keys(self): return [] - def is_beyond_limit(self, address): + def is_beyond_limit(self, address, is_change): return False def is_mine(self, address): @@ -1859,12 +2818,13 @@ def import_address(self, address): if address in self.addresses: return '' self.addresses[address] = {} - self.storage.put('addresses', self.addresses) + self.save_addresses() self.storage.write() self.add_address(address) return address def delete_address(self, address): + assert isinstance(address, Address) if address not in self.addresses: return @@ -1887,6 +2847,7 @@ def delete_address(self, address): self.verified_tx.pop(tx_hash, None) self.unverified_tx.pop(tx_hash, None) self.transactions.pop(tx_hash, None) + self._addr_bal_cache.pop(address, None) # not strictly necessary, above calls also have this side-effect. but here to be safe. :) # FIXME: what about pruned_txo? self.storage.put('verified_tx3', self.verified_tx) @@ -1911,7 +2872,7 @@ def delete_address(self, address): else: self.keystore.delete_imported_key(pubkey) self.save_keystore() - self.storage.put('addresses', self.addresses) + self.save_addresses() self.storage.write() @@ -2017,8 +2978,9 @@ def change_gap_limit(self, value): def num_unused_trailing_addresses(self, addresses): k = 0 - for a in addresses[::-1]: - if self.history.get(a):break + for addr in reversed(addresses): + if addr in self.history: + break k = k + 1 return k @@ -2029,30 +2991,21 @@ def min_acceptable_gap(self): addresses = self.get_receiving_addresses() k = self.num_unused_trailing_addresses(addresses) for a in addresses[0:-k]: - if self.history.get(a): + if a in self.history: n = 0 else: n += 1 if n > nmax: nmax = n return nmax + 1 - def load_addresses(self): - super().load_addresses() - self._addr_to_addr_index = {} # key: address, value: (is_change, index) - for i, addr in enumerate(self.receiving_addresses): - self._addr_to_addr_index[addr] = (False, i) - for i, addr in enumerate(self.change_addresses): - self._addr_to_addr_index[addr] = (True, i) - def create_new_address(self, for_change=False): - assert type(for_change) is bool + for_change = bool(for_change) with self.lock: addr_list = self.change_addresses if for_change else self.receiving_addresses n = len(addr_list) x = self.derive_pubkeys(for_change, n) address = self.pubkeys_to_address(x) addr_list.append(address) - self._addr_to_addr_index[address] = (for_change, n) self.save_addresses() self.add_address(address) return address @@ -2074,7 +3027,7 @@ def synchronize(self): self.synchronize_sequence(False) self.synchronize_sequence(True) - def is_beyond_limit(self, address): + def is_beyond_limit(self, address, is_change): is_change, i = self.get_address_index(address) addr_list = self.get_change_addresses() if is_change else self.get_receiving_addresses() limit = self.gap_limit_for_change if is_change else self.gap_limit @@ -2086,12 +3039,6 @@ def is_beyond_limit(self, address): return False return True - def is_mine(self, address): - return address in self._addr_to_addr_index - - def get_address_index(self, address): - return self._addr_to_addr_index[address] - def get_master_public_keys(self): return [self.get_master_public_key()] @@ -2145,9 +3092,18 @@ def derive_pubkeys(self, c, i): class Standard_Wallet(Simple_Deterministic_Wallet): wallet_type = 'standard' + def __init__(self, storage): + super().__init__(storage) def pubkeys_to_address(self, pubkey): - return bitcoin.pubkey_to_address(self.txin_type, pubkey) + return Address.from_pubkey(pubkey) + + +class Slp_Standard_Wallet(Standard_Wallet): + wallet_type = 'zslp_standard' + def __init__(self, storage): + storage.put('wallet_type', self.wallet_type) + super().__init__(storage) class Multisig_Wallet(Deterministic_Wallet): @@ -2167,11 +3123,12 @@ def get_public_keys(self, address): return self.get_pubkeys(*sequence) def pubkeys_to_address(self, pubkeys): + pubkeys = [bytes.fromhex(pubkey) for pubkey in pubkeys] redeem_script = self.pubkeys_to_redeem_script(pubkeys) - return bitcoin.redeem_script_to_address(self.txin_type, redeem_script) + return Address.from_multisig_script(redeem_script) def pubkeys_to_redeem_script(self, pubkeys): - return transaction.multisig_script(sorted(pubkeys), self.m) + return Script.multisig_script(self.m, sorted(pubkeys)) def get_redeem_script(self, address): pubkeys = self.get_public_keys(address) @@ -2246,13 +3203,14 @@ def add_input_sig_info(self, txin, address): txin['num_sig'] = self.m -wallet_types = ['standard', 'multisig', 'imported'] +wallet_types = ['standard', 'zslp_standard', 'multisig', 'zslp_multisig', 'imported', 'zslp_imported'] def register_wallet_type(category): wallet_types.append(category) wallet_constructors = { 'standard': Standard_Wallet, + 'zslp_standard': Slp_Standard_Wallet, 'old': Standard_Wallet, 'xpub': Standard_Wallet, 'imported': Imported_Wallet @@ -2268,6 +3226,9 @@ class Wallet(object): type when passed a WalletStorage instance.""" def __new__(self, storage): + # Convert 'bip39-slp' wallet type to 'zslp_standard' wallet type + if storage.get('wallet_type', '') == 'bip39-slp' or storage.get('wallet_type', '') == 'standard_slp': + storage.put('wallet_type', 'zslp_standard') wallet_type = storage.get('wallet_type') WalletClass = Wallet.wallet_class(wallet_type) wallet = WalletClass(storage) diff --git a/lib/web.py b/lib/web.py new file mode 100644 index 000000000..b79f03525 --- /dev/null +++ b/lib/web.py @@ -0,0 +1,162 @@ + +# Electrum - lightweight Bitcoin client +# Copyright (C) 2011 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from decimal import Decimal +import os +import re +import shutil +import urllib +import threading + +from .address import Address +from . import bitcoin +from . import constants +from .util import format_satoshis_plain, bh2u + +mainnet_block_explorers = { + 'zeltrez.io': ('https://explorer.zcl.zeltrez.io/', + {'tx': 'tx/', 'addr': 'address/'}) +} + +testnet_block_explorers = { + 'testnet.z.cash': ('https://explorer.testnet.z.cash/', + {'tx': 'tx/', 'addr': 'address/'}), + 'system default': ('blockchain:/', + {'tx': 'tx/', 'addr': 'address/'}), +} + +def block_explorer_info(): + from . import constants + return testnet_block_explorers if constants.net.TESTNET else mainnet_block_explorers + +def block_explorer(config): + return config.get('block_explorer', 'zeltrez.io') + +def block_explorer_tuple(config): + return block_explorer_info().get(block_explorer(config)) + +def block_explorer_URL(config, kind, item): + be_tuple = block_explorer_tuple(config) + if not be_tuple: + return + kind_str = be_tuple[1].get(kind) + if not kind_str: + return + url_parts = [be_tuple[0], kind_str, item] + return ''.join(url_parts) + +# URL decode +#_ud = re.compile('%([0-9a-hA-H]{2})', re.MULTILINE) +#urldecode = lambda x: _ud.sub(lambda m: chr(int(m.group(1), 16)), x) + +def parse_URI(uri, on_pr=None): + from .bitcoin import COIN + + if ':' not in uri: + Address.from_string(uri) + return {'address': uri} + + if 'zclassic' not in uri and constants.net.SLPADDR_PREFIX not in uri: + raise Exception("Not a URI starting with 'zclassic:' or '{}:'".format(constants.net.SLPADDR_PREFIX)) + + u = urllib.parse.urlparse(uri) + address = u.path + + # python for android fails to parse query + if address.find('?') > 0: + address, query = u.path.split('?') + pq = urllib.parse.parse_qs(query, keep_blank_values=True) + else: + pq = urllib.parse.parse_qs(u.query, keep_blank_values=True) + + for k, v in pq.items(): + if len(v)!=1: + raise Exception('Duplicate Key', k) + + out = {k: v[0] for k, v in pq.items()} + if address: + if not bitcoin.is_address(address): + raise Exception("Invalid Zclassic address:" + address) + out['address'] = address + if 'amount' in out: + am = out['amount'] + m = re.match('([0-9\.]+)X([0-9])', am) + if m: + k = int(m.group(2)) - 8 + amount = Decimal(m.group(1)) * pow( Decimal(10) , k) + else: + amount = Decimal(am) * COIN + out['amount'] = int(amount) + if 'message' in out: + out['message'] = out['message'] + out['memo'] = out['message'] + if 'time' in out: + out['time'] = int(out['time']) + if 'exp' in out: + out['exp'] = int(out['exp']) + if 'sig' in out: + out['sig'] = bh2u(bitcoin.base_decode(out['sig'], None, base=58)) + + r = out.get('r') + sig = out.get('sig') + name = out.get('name') + if on_pr and (r or (name and sig)): + def get_payment_request_thread(): + from . import paymentrequest as pr + if name and sig: + s = pr.serialize_request(out).SerializeToString() + request = pr.PaymentRequest(s) + else: + request = pr.get_payment_request(r) + if on_pr: + on_pr(request) + t = threading.Thread(target=get_payment_request_thread) + t.setDaemon(True) + t.start() + + return out + + +def create_URI(addr, amount, message, *, op_return=None, op_return_raw=None, token_id=None): + if not isinstance(addr, Address): + return "" + if op_return is not None and op_return_raw is not None: + raise ValueError('Must specify exactly one of op_return or \ + op_return_hex as kwargs to create_URI') + scheme, path = addr.to_URI_components() + query = [] + if token_id: + query.append('amount=%s-%s'%( amount, token_id )) + if amount: + query.append('amount=%s'%format_satoshis_plain(amount)) + if message: + query.append('message=%s'%urllib.parse.quote(message)) + if op_return: + query.append(f'op_return={str(op_return)}') + if op_return_raw: + query.append(f'op_return_raw={str(op_return_raw)}') + p = urllib.parse.ParseResult(scheme=scheme, + netloc='', path=path, params='', + query='&'.join(query), fragment='') + return urllib.parse.urlunparse(p) diff --git a/lib/websockets.py b/lib/websockets.py index 23a1dd05d..6237abf97 100644 --- a/lib/websockets.py +++ b/lib/websockets.py @@ -32,6 +32,7 @@ sys.exit("install SimpleWebSocketServer") from . import util +from .address import Address request_queue = queue.Queue() @@ -84,7 +85,7 @@ def reading_thread(self): l = self.subscriptions.get(addr, []) l.append((ws, amount)) self.subscriptions[addr] = l - h = self.network.addr_to_scripthash(addr) + h = Address.from_string(addr).to_scripthash_hex() self.network.send([('blockchain.scripthash.subscribe', [h])], self.response_queue.put)