electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit 6058829870fde0ef17b2e08a567110ecc381ab94
parent 5f527720cf2ae4c7aef1cfdcf4244dbceb54a5bc
Author: ThomasV <thomasv@electrum.org>
Date:   Sun, 31 May 2020 12:49:49 +0200

Use attr.s classes for invoices and requests:
 - storage upgrade
 - fixes #6192
 - add can_pay_invoice, can_receive_invoice to lnworker

Diffstat:
Melectrum/commands.py | 41++++++++++++++++++++++-------------------
Melectrum/daemon.py | 8++++----
Melectrum/gui/kivy/main_window.py | 13++++++-------
Melectrum/gui/kivy/uix/dialogs/invoice_dialog.py | 17+++++++++--------
Melectrum/gui/kivy/uix/dialogs/request_dialog.py | 17+++++++++--------
Melectrum/gui/kivy/uix/screens.py | 67+++++++++++++++++++++++++++++++++++--------------------------------
Melectrum/gui/kivy/uix/ui_screens/receive.kv | 4++--
Melectrum/gui/kivy/uix/ui_screens/send.kv | 4++--
Melectrum/gui/qt/invoice_list.py | 42++++++++++++++++++++++--------------------
Melectrum/gui/qt/main_window.py | 37+++++++++++++++++--------------------
Melectrum/gui/qt/request_list.py | 52+++++++++++++++++++++++++++-------------------------
Melectrum/gui/qt/util.py | 2+-
Aelectrum/invoices.py | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Melectrum/lnaddr.py | 15---------------
Melectrum/lnchannel.py | 3++-
Melectrum/lnworker.py | 23+++++++++++------------
Melectrum/paymentrequest.py | 2+-
Melectrum/util.py | 61+------------------------------------------------------------
Melectrum/wallet.py | 282+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Melectrum/wallet_db.py | 54+++++++++++++++++++++++++++++++++++++++++++-----------
20 files changed, 489 insertions(+), 370 deletions(-)

diff --git a/electrum/commands.py b/electrum/commands.py @@ -47,7 +47,7 @@ from .bip32 import BIP32Node from .i18n import _ from .transaction import (Transaction, multisig_script, TxOutput, PartialTransaction, PartialTxOutput, tx_from_any, PartialTxInput, TxOutpoint) -from .paymentrequest import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED +from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED from .synchronizer import Notifier from .wallet import Abstract_Wallet, create_new_wallet, restore_wallet_from_text, Deterministic_Wallet from .address_synchronizer import TX_HEIGHT_LOCAL @@ -59,7 +59,7 @@ from .lnpeer import channel_id_from_funding_tx from .plugin import run_hook from .version import ELECTRUM_VERSION from .simple_config import SimpleConfig -from .lnaddr import parse_lightning_invoice +from .invoices import LNInvoice if TYPE_CHECKING: @@ -761,19 +761,13 @@ class Commands: decrypted = wallet.decrypt_message(pubkey, encrypted, password) return decrypted.decode('utf-8') - def _format_request(self, out): - from .util import get_request_status - out['amount_BTC'] = format_satoshis(out.get('amount')) - out['status'], out['status_str'] = get_request_status(out) - return out - @command('w') async def getrequest(self, key, wallet: Abstract_Wallet = None): """Return a payment request""" r = wallet.get_request(key) if not r: raise Exception("Request not found") - return self._format_request(r) + return wallet.export_request(r) #@command('w') #async def ackrequest(self, serialized): @@ -783,8 +777,6 @@ class Commands: @command('w') async def list_requests(self, pending=False, expired=False, paid=False, wallet: Abstract_Wallet = None): """List the payment requests you made.""" - out = wallet.get_sorted_requests() - out = list(map(self._format_request, out)) if pending: f = PR_UNPAID elif expired: @@ -793,9 +785,10 @@ class Commands: f = PR_PAID else: f = None + out = wallet.get_sorted_requests() if f is not None: - out = list(filter(lambda x: x.get('status')==f, out)) - return out + out = list(filter(lambda x: x.status==f, out)) + return [wallet.export_request(x) for x in out] @command('w') async def createnewaddress(self, wallet: Abstract_Wallet = None): @@ -847,14 +840,13 @@ class Commands: expiration = int(expiration) if expiration else None req = wallet.make_payment_request(addr, amount, memo, expiration) wallet.add_payment_request(req) - out = wallet.get_request(addr) - return self._format_request(out) + return wallet.export_request(req) @command('wn') async def add_lightning_request(self, amount, memo='', expiration=3600, wallet: Abstract_Wallet = None): amount_sat = int(satoshis(amount)) key = await wallet.lnworker._add_request_coro(amount_sat, memo, expiration) - return wallet.get_request(key) + return wallet.get_formatted_request(key) @command('w') async def addtransaction(self, tx, wallet: Abstract_Wallet = None): @@ -996,14 +988,24 @@ class Commands: @command('') async def decode_invoice(self, invoice): - return parse_lightning_invoice(invoice) + from .lnaddr import lndecode + lnaddr = lndecode(invoice) + return { + 'pubkey': lnaddr.pubkey.serialize().hex(), + 'amount_BTC': lnaddr.amount, + 'rhash': lnaddr.paymenthash.hex(), + 'description': lnaddr.get_description(), + 'exp': lnaddr.get_expiry(), + 'time': lnaddr.date, + #'tags': str(lnaddr.tags), + } @command('wn') async def lnpay(self, invoice, attempts=1, timeout=30, wallet: Abstract_Wallet = None): lnworker = wallet.lnworker lnaddr = lnworker._check_invoice(invoice, None) payment_hash = lnaddr.paymenthash - wallet.save_invoice(parse_lightning_invoice(invoice)) + wallet.save_invoice(LNInvoice.from_bech32(invoice)) success, log = await lnworker._pay(invoice, attempts=attempts) return { 'payment_hash': payment_hash.hex(), @@ -1061,7 +1063,8 @@ class Commands: @command('w') async def list_invoices(self, wallet: Abstract_Wallet = None): - return wallet.get_invoices() + l = wallet.get_invoices() + return [wallet.export_invoice(x) for x in l] @command('wn') async def close_channel(self, channel_point, force=False, wallet: Abstract_Wallet = None): diff --git a/electrum/daemon.py b/electrum/daemon.py @@ -46,7 +46,7 @@ from aiorpcx import TaskGroup from . import util from .network import Network from .util import (json_decode, to_bytes, to_string, profiler, standardize_path, constant_time_compare) -from .util import PR_PAID, PR_EXPIRED, get_request_status +from .invoices import PR_PAID, PR_EXPIRED from .util import log_exceptions, ignore_exceptions, randrange from .wallet import Wallet, Abstract_Wallet from .storage import WalletStorage @@ -344,13 +344,13 @@ class PayServer(Logger): async def get_request(self, r): key = r.query_string - request = self.wallet.get_request(key) + request = self.wallet.get_formatted_request(key) return web.json_response(request) async def get_bip70_request(self, r): from .paymentrequest import make_request key = r.match_info['key'] - request = self.wallet.get_request(key) + request = self.wallet.get_formatted_request(key) if not request: return web.HTTPNotFound() pr = make_request(self.config, request) @@ -360,7 +360,7 @@ class PayServer(Logger): ws = web.WebSocketResponse() await ws.prepare(request) key = request.query_string - info = self.wallet.get_request(key) + info = self.wallet.get_formatted_request(key) if not info: await ws.send_str('unknown invoice') await ws.close() diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py @@ -16,7 +16,8 @@ from electrum.plugin import run_hook from electrum import util from electrum.util import (profiler, InvalidPassword, send_exception_to_crash_reporter, format_satoshis, format_satoshis_plain, format_fee_satoshis, - PR_PAID, PR_FAILED, maybe_extract_bolt11_invoice) + maybe_extract_bolt11_invoice) +from electrum.invoices import PR_PAID, PR_FAILED from electrum import blockchain from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed from electrum.interface import PREFERRED_NETWORK_PROTOCOL, ServerAddr @@ -242,7 +243,7 @@ class ElectrumWindow(App): req = self.wallet.get_invoice(key) if req is None: return - status = req['status'] + status = self.wallet.get_invoice_status(req) # todo: update single item self.update_tab('send') if self.invoice_popup and self.invoice_popup.key == key: @@ -393,7 +394,7 @@ class ElectrumWindow(App): if pr.verify(self.wallet.contacts): key = pr.get_id() invoice = self.wallet.get_invoice(key) # FIXME wrong key... - if invoice and invoice['status'] == PR_PAID: + if invoice and self.wallet.get_invoice_status(invoice) == PR_PAID: self.show_error("invoice already paid") self.send_screen.do_clear() elif pr.has_expired(): @@ -451,9 +452,7 @@ class ElectrumWindow(App): def show_request(self, is_lightning, key): from .uix.dialogs.request_dialog import RequestDialog - request = self.wallet.get_request(key) - data = request['invoice'] if is_lightning else request['URI'] - self.request_popup = RequestDialog('Request', data, key, is_lightning=is_lightning) + self.request_popup = RequestDialog('Request', key) self.request_popup.open() def show_invoice(self, is_lightning, key): @@ -461,7 +460,7 @@ class ElectrumWindow(App): invoice = self.wallet.get_invoice(key) if not invoice: return - data = invoice['invoice'] if is_lightning else key + data = invoice.invoice if is_lightning else key self.invoice_popup = InvoiceDialog('Invoice', data, key) self.invoice_popup.open() diff --git a/electrum/gui/kivy/uix/dialogs/invoice_dialog.py b/electrum/gui/kivy/uix/dialogs/invoice_dialog.py @@ -7,8 +7,8 @@ from kivy.app import App from kivy.clock import Clock from electrum.gui.kivy.i18n import _ -from electrum.util import pr_tooltips, pr_color, get_request_status -from electrum.util import PR_UNKNOWN, PR_UNPAID, PR_FAILED, PR_TYPE_LN +from electrum.invoices import pr_tooltips, pr_color +from electrum.invoices import PR_UNKNOWN, PR_UNPAID, PR_FAILED, PR_TYPE_LN if TYPE_CHECKING: from electrum.gui.kivy.main_window import ElectrumWindow @@ -92,16 +92,17 @@ class InvoiceDialog(Factory.Popup): self.title = title self.data = data self.key = key - r = self.app.wallet.get_invoice(key) - self.amount = r.get('amount') - self.description = r.get('message') or r.get('memo','') - self.is_lightning = r.get('type') == PR_TYPE_LN + invoice = self.app.wallet.get_invoice(key) + self.amount = invoice.amount + self.description = invoice.message + self.is_lightning = invoice.type == PR_TYPE_LN self.update_status() self.log = self.app.wallet.lnworker.logs[self.key] if self.is_lightning else [] def update_status(self): - req = self.app.wallet.get_invoice(self.key) - self.status, self.status_str = get_request_status(req) + invoice = self.app.wallet.get_invoice(self.key) + self.status = self.app.wallet.get_invoice_status(invoice) + self.status_str = invoice.get_status_str(self.status) self.status_color = pr_color[self.status] self.can_pay = self.status in [PR_UNPAID, PR_FAILED] if self.can_pay and self.is_lightning and self.app.wallet.lnworker: diff --git a/electrum/gui/kivy/uix/dialogs/request_dialog.py b/electrum/gui/kivy/uix/dialogs/request_dialog.py @@ -7,8 +7,8 @@ from kivy.app import App from kivy.clock import Clock from electrum.gui.kivy.i18n import _ -from electrum.util import pr_tooltips, pr_color, get_request_status -from electrum.util import PR_UNKNOWN, PR_UNPAID, PR_FAILED, PR_TYPE_LN +from electrum.invoices import pr_tooltips, pr_color +from electrum.invoices import PR_UNKNOWN, PR_UNPAID, PR_FAILED, PR_TYPE_LN if TYPE_CHECKING: from ...main_window import ElectrumWindow @@ -86,17 +86,17 @@ Builder.load_string(''' class RequestDialog(Factory.Popup): - def __init__(self, title, data, key, *, is_lightning=False): + def __init__(self, title, key): self.status = PR_UNKNOWN Factory.Popup.__init__(self) self.app = App.get_running_app() # type: ElectrumWindow self.title = title - self.data = data self.key = key r = self.app.wallet.get_request(key) - self.amount = r.get('amount') - self.description = r.get('message', '') - self.is_lightning = r.get('type') == PR_TYPE_LN + self.is_lightning = r.is_lightning() + self.data = r.invoice if self.is_lightning else self.app.wallet.get_request_URI(r) + self.amount = r.amount + self.description = r.message self.update_status() def on_open(self): @@ -109,7 +109,8 @@ class RequestDialog(Factory.Popup): def update_status(self): req = self.app.wallet.get_request(self.key) - self.status, self.status_str = get_request_status(req) + self.status = self.app.wallet.get_request_status(self.key) + self.status_str = req.get_status_str(self.status) self.status_color = pr_color[self.status] if self.status == PR_UNPAID and self.is_lightning and self.app.wallet.lnworker: if self.amount and self.amount > self.app.wallet.lnworker.num_sats_can_receive(): diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py @@ -24,17 +24,17 @@ from kivy.utils import platform from kivy.logger import Logger from electrum.util import profiler, parse_URI, format_time, InvalidPassword, NotEnoughFunds, Fiat -from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING +from electrum.invoices import (PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING, + PR_PAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT, + LNInvoice, pr_expiration_values) from electrum import bitcoin, constants from electrum.transaction import Transaction, tx_from_any, PartialTransaction, PartialTxOutput -from electrum.util import (parse_URI, InvalidBitcoinURI, PR_PAID, PR_UNKNOWN, PR_EXPIRED, - PR_INFLIGHT, TxMinedInfo, get_request_status, pr_expiration_values, - maybe_extract_bolt11_invoice) +from electrum.util import parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_bolt11_invoice from electrum.plugin import run_hook from electrum.wallet import InternalAddressCorruption from electrum import simple_config from electrum.simple_config import FEERATE_WARNING_HIGH_FEE, FEE_RATIO_HIGH_WARNING -from electrum.lnaddr import lndecode, parse_lightning_invoice +from electrum.lnaddr import lndecode from electrum.lnutil import RECEIVED, SENT, PaymentFailure from .dialogs.question import Question @@ -225,26 +225,27 @@ class SendScreen(CScreen): self.app.show_invoice(obj.is_lightning, obj.key) def get_card(self, item): - invoice_type = item['type'] - status, status_str = get_request_status(item) # convert to str - if invoice_type == PR_TYPE_LN: - key = item['rhash'] + status = self.app.wallet.get_invoice_status(item) + status_str = item.get_status_str(status) + is_lightning = item.type == PR_TYPE_LN + if is_lightning: + key = item.rhash log = self.app.wallet.lnworker.logs.get(key) - if item['status'] == PR_INFLIGHT and log: + if status == PR_INFLIGHT and log: status_str += '... (%d)'%len(log) - elif invoice_type == PR_TYPE_ONCHAIN: - key = item['id'] + is_bip70 = False else: - raise Exception('unknown invoice type') + key = item.id + is_bip70 = bool(item.bip70) return { - 'is_lightning': invoice_type == PR_TYPE_LN, - 'is_bip70': 'bip70' in item, + 'is_lightning': is_lightning, + 'is_bip70': is_bip70, 'screen': self, 'status': status, 'status_str': status_str, 'key': key, - 'memo': item['message'], - 'amount': self.app.format_amount_and_units(item['amount'] or 0), + 'memo': item.message, + 'amount': self.app.format_amount_and_units(item.amount or 0), } def do_clear(self): @@ -300,7 +301,7 @@ class SendScreen(CScreen): return message = self.message if self.is_lightning: - return parse_lightning_invoice(address) + return LNInvoice.from_bech32(address) else: # on-chain if self.payment_request: outputs = self.payment_request.get_outputs() @@ -329,26 +330,27 @@ class SendScreen(CScreen): self.do_pay_invoice(invoice) def do_pay_invoice(self, invoice): - if invoice['type'] == PR_TYPE_LN: + if invoice.is_lightning(): self._do_pay_lightning(invoice) return - elif invoice['type'] == PR_TYPE_ONCHAIN: + else: do_pay = lambda rbf: self._do_pay_onchain(invoice, rbf) if self.app.electrum_config.get('use_rbf'): d = Question(_('Should this transaction be replaceable?'), do_pay) d.open() else: do_pay(False) - else: - raise Exception('unknown invoice type') def _do_pay_lightning(self, invoice): attempts = 10 - threading.Thread(target=self.app.wallet.lnworker.pay, args=(invoice['invoice'], invoice['amount'], attempts)).start() + threading.Thread( + target=self.app.wallet.lnworker.pay, + args=(invoice.invoice, invoice.amount), + kwargs={'attempts':10}).start() def _do_pay_onchain(self, invoice, rbf): # make unsigned transaction - outputs = invoice['outputs'] # type: List[PartialTxOutput] + outputs = invoice.outputs # type: List[PartialTxOutput] coins = self.app.wallet.get_spendable_coins(None) try: tx = self.app.wallet.make_unsigned_transaction(coins=coins, outputs=outputs) @@ -405,7 +407,7 @@ class SendScreen(CScreen): def callback(c): if c: for req in invoices: - key = req['key'] + key = req.rhash if req.is_lightning() else req.get_address() self.app.wallet.delete_invoice(key) self.update() n = len(invoices) @@ -477,16 +479,17 @@ class ReceiveScreen(CScreen): self.app.show_request(lightning, key) def get_card(self, req): - is_lightning = req.get('type') == PR_TYPE_LN + is_lightning = req.is_lightning() if not is_lightning: - address = req['address'] + address = req.get_address() key = address else: - key = req['rhash'] - address = req['invoice'] - amount = req.get('amount') - description = req.get('message') or req.get('memo', '') # TODO: a db upgrade would be needed to simplify that. - status, status_str = get_request_status(req) + key = req.rhash + address = req.invoice + amount = req.amount + description = req.message + status = self.app.wallet.get_request_status(key) + status_str = req.get_status_str(status) ci = {} ci['screen'] = self ci['address'] = address diff --git a/electrum/gui/kivy/uix/ui_screens/receive.kv b/electrum/gui/kivy/uix/ui_screens/receive.kv @@ -1,6 +1,6 @@ #:import _ electrum.gui.kivy.i18n._ -#:import pr_color electrum.util.pr_color -#:import PR_UNKNOWN electrum.util.PR_UNKNOWN +#:import pr_color electrum.invoices.pr_color +#:import PR_UNKNOWN electrum.invoices.PR_UNKNOWN #:import Factory kivy.factory.Factory #:import Decimal decimal.Decimal #:set btc_symbol chr(171) diff --git a/electrum/gui/kivy/uix/ui_screens/send.kv b/electrum/gui/kivy/uix/ui_screens/send.kv @@ -1,6 +1,6 @@ #:import _ electrum.gui.kivy.i18n._ -#:import pr_color electrum.util.pr_color -#:import PR_UNKNOWN electrum.util.PR_UNKNOWN +#:import pr_color electrum.invoices.pr_color +#:import PR_UNKNOWN electrum.invoices.PR_UNKNOWN #:import Factory kivy.factory.Factory #:import Decimal decimal.Decimal #:set btc_symbol chr(171) diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py @@ -32,9 +32,8 @@ from PyQt5.QtWidgets import QAbstractItemView from PyQt5.QtWidgets import QMenu, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView from electrum.i18n import _ -from electrum.util import format_time, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED -from electrum.util import get_request_status -from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN +from electrum.util import format_time +from electrum.invoices import Invoice, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_TYPE_ONCHAIN, PR_TYPE_LN from electrum.lnutil import PaymentAttemptLog from .util import (MyTreeView, read_QIcon, MySortModel, @@ -77,7 +76,7 @@ class InvoiceList(MyTreeView): self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.update() - def update_item(self, key, req): + def update_item(self, key, invoice: Invoice): model = self.std_model for row in range(0, model.rowCount()): item = model.item(row, 0) @@ -86,7 +85,8 @@ class InvoiceList(MyTreeView): else: return status_item = model.item(row, self.Columns.STATUS) - status, status_str = get_request_status(req) + status = self.parent.wallet.get_invoice_status(invoice) + status_str = invoice.get_status_str(status) if self.parent.wallet.lnworker: log = self.parent.wallet.lnworker.logs.get(key) if log and status == PR_INFLIGHT: @@ -100,21 +100,21 @@ class InvoiceList(MyTreeView): self.std_model.clear() self.update_headers(self.__class__.headers) for idx, item in enumerate(self.parent.wallet.get_invoices()): - invoice_type = item['type'] - if invoice_type == PR_TYPE_LN: - key = item['rhash'] + if item.type == PR_TYPE_LN: + key = item.rhash icon_name = 'lightning.png' - elif invoice_type == PR_TYPE_ONCHAIN: - key = item['id'] + elif item.type == PR_TYPE_ONCHAIN: + key = item.id icon_name = 'bitcoin.png' - if item.get('bip70'): + if item.bip70: icon_name = 'seal.png' else: raise Exception('Unsupported type') - status, status_str = get_request_status(item) - message = item['message'] - amount = item['amount'] - timestamp = item.get('time', 0) + status = self.parent.wallet.get_invoice_status(item) + status_str = item.get_status_str(status) + message = item.message + amount = item.amount + timestamp = item.time or 0 date_str = format_time(timestamp) if timestamp else _('Unknown') amount_str = self.parent.format_amount(amount, whitespaces=True) labels = [date_str, message, amount_str, status_str] @@ -123,7 +123,7 @@ class InvoiceList(MyTreeView): items[self.Columns.DATE].setIcon(read_QIcon(icon_name)) items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status))) items[self.Columns.DATE].setData(key, role=ROLE_REQUEST_ID) - items[self.Columns.DATE].setData(invoice_type, role=ROLE_REQUEST_TYPE) + items[self.Columns.DATE].setData(item.type, role=ROLE_REQUEST_TYPE) items[self.Columns.DATE].setData(timestamp, role=ROLE_SORT_ORDER) self.std_model.insertRow(idx, items) self.filter() @@ -143,11 +143,12 @@ class InvoiceList(MyTreeView): export_meta_gui(self.parent, _('invoices'), self.parent.invoices.export_file) def create_menu(self, position): + wallet = self.parent.wallet items = self.selected_in_column(0) if len(items)>1: keys = [ item.data(ROLE_REQUEST_ID) for item in items] - invoices = [ self.parent.wallet.get_invoice(key) for key in keys] - can_batch_pay = all([ invoice['status'] == PR_UNPAID and invoice['type'] == PR_TYPE_ONCHAIN for invoice in invoices]) + invoices = [ wallet.invoices.get(key) for key in keys] + can_batch_pay = all([i.type == PR_TYPE_ONCHAIN and wallet.get_invoice_status(i) == PR_UNPAID for i in invoices]) menu = QMenu(self) if can_batch_pay: menu.addAction(_("Batch pay invoices"), lambda: self.parent.pay_multiple_invoices(invoices)) @@ -164,9 +165,10 @@ class InvoiceList(MyTreeView): self.add_copy_menu(menu, idx) invoice = self.parent.wallet.get_invoice(key) menu.addAction(_("Details"), lambda: self.parent.show_invoice(key)) - if invoice['status'] == PR_UNPAID: + status = wallet.get_invoice_status(invoice) + if status == PR_UNPAID: menu.addAction(_("Pay"), lambda: self.parent.do_pay_invoice(invoice)) - if invoice['status'] == PR_FAILED: + if status == PR_FAILED: menu.addAction(_("Retry"), lambda: self.parent.do_pay_invoice(invoice)) if self.parent.wallet.lnworker: log = self.parent.wallet.lnworker.logs.get(key) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py @@ -62,7 +62,8 @@ from electrum.util import (format_time, format_satoshis, format_fee_satoshis, get_new_wallet_name, send_exception_to_crash_reporter, InvalidBitcoinURI, maybe_extract_bolt11_invoice, NotEnoughFunds, NoDynamicFeeEstimates, MultipleSpendMaxTxOutputs) -from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING +from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING +from electrum.invoices import PR_PAID, PR_FAILED, pr_expiration_values, LNInvoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) from electrum.address_synchronizer import AddTransactionException @@ -73,10 +74,7 @@ from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed, from electrum.exchange_rate import FxThread from electrum.simple_config import SimpleConfig from electrum.logging import Logger -from electrum.util import PR_PAID, PR_FAILED -from electrum.util import pr_expiration_values from electrum.lnutil import ln_dummy_address -from electrum.lnaddr import parse_lightning_invoice from .exception_window import Exception_Hook from .amountedit import AmountEdit, BTCAmountEdit, FreezableLineEdit, FeerateEdit @@ -1192,7 +1190,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.receive_message_e.setText('') # copy to clipboard r = self.wallet.get_request(key) - content = r.get('invoice', '') if is_lightning else r.get('address', '') + content = r.invoice if r.is_lightning() else r.get_address() title = _('Invoice') if is_lightning else _('Address') self.do_copy(content, title=title) @@ -1231,7 +1229,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def export_payment_request(self, addr): r = self.wallet.receive_requests.get(addr) pr = paymentrequest.serialize_request(r).SerializeToString() - name = r['id'] + '.bip70' + name = r.id + '.bip70' fileName = self.getSaveFileName(_("Select where to save your payment request"), name, "*.bip70") if fileName: with open(fileName, "wb+") as f: @@ -1505,21 +1503,21 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if self.check_send_tab_payto_line_and_show_errors(): return if not self._is_onchain: - invoice = self.payto_e.lightning_invoice - if not invoice: + invoice_str = self.payto_e.lightning_invoice + if not invoice_str: return if not self.wallet.lnworker: self.show_error(_('Lightning is disabled')) return - invoice_dict = parse_lightning_invoice(invoice) - if invoice_dict.get('amount') is None: + invoice = LNInvoice.from_bech32(invoice_str) + if invoice.amount is None: amount = self.amount_e.get_amount() if amount: - invoice_dict['amount'] = amount + invoice.amount = amount else: self.show_error(_('No amount')) return - return invoice_dict + return invoice else: outputs = self.read_outputs() if self.check_send_tab_onchain_outputs_and_show_errors(outputs): @@ -1547,15 +1545,14 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def pay_multiple_invoices(self, invoices): outputs = [] for invoice in invoices: - outputs += invoice['outputs'] + outputs += invoice.outputs self.pay_onchain_dialog(self.get_coins(), outputs) def do_pay_invoice(self, invoice): - if invoice['type'] == PR_TYPE_LN: - self.pay_lightning_invoice(invoice['invoice'], invoice['amount']) - elif invoice['type'] == PR_TYPE_ONCHAIN: - outputs = invoice['outputs'] - self.pay_onchain_dialog(self.get_coins(), outputs) + if invoice.type == PR_TYPE_LN: + self.pay_lightning_invoice(invoice.invoice, invoice.amount) + elif invoice.type == PR_TYPE_ONCHAIN: + self.pay_onchain_dialog(self.get_coins(), invoice.outputs) else: raise Exception('unknown invoice type') @@ -1775,7 +1772,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return key = pr.get_id() invoice = self.wallet.get_invoice(key) - if invoice and invoice['status'] == PR_PAID: + if invoice and self.wallet.get_invoice_status() == PR_PAID: self.show_message("invoice already paid") self.do_clear() self.payment_request = None @@ -1970,7 +1967,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if invoice is None: self.show_error('Cannot find payment request in wallet.') return - bip70 = invoice.get('bip70') + bip70 = invoice.bip70 if bip70: pr = paymentrequest.PaymentRequest(bytes.fromhex(bip70)) pr.verify(self.contacts) diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py @@ -31,8 +31,8 @@ from PyQt5.QtWidgets import QMenu, QAbstractItemView from PyQt5.QtCore import Qt, QItemSelectionModel, QModelIndex from electrum.i18n import _ -from electrum.util import format_time, get_request_status -from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN +from electrum.util import format_time +from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN from electrum.plugin import run_hook from .util import MyTreeView, pr_icons, read_QIcon, webopen, MySortModel @@ -90,18 +90,17 @@ class RequestList(MyTreeView): return # TODO use siblingAtColumn when min Qt version is >=5.11 item = self.item_from_index(idx.sibling(idx.row(), self.Columns.DATE)) - request_type = item.data(ROLE_REQUEST_TYPE) key = item.data(ROLE_KEY) req = self.wallet.get_request(key) if req is None: self.update() return - if request_type == PR_TYPE_LN: - self.parent.receive_payreq_e.setText(req.get('invoice')) - self.parent.receive_address_e.setText(req.get('invoice')) + if req.is_lightning(): + self.parent.receive_payreq_e.setText(req.invoice) + self.parent.receive_address_e.setText(req.invoice) else: - self.parent.receive_payreq_e.setText(req.get('URI')) - self.parent.receive_address_e.setText(req['address']) + self.parent.receive_payreq_e.setText(self.parent.wallet.get_request_URI(req)) + self.parent.receive_address_e.setText(req.get_address()) self.parent.receive_payreq_e.repaint() # macOS hack (similar to #4777) self.parent.receive_address_e.repaint() # macOS hack (similar to #4777) @@ -119,7 +118,8 @@ class RequestList(MyTreeView): key = date_item.data(ROLE_KEY) req = self.wallet.get_request(key) if req: - status, status_str = get_request_status(req) + status = self.parent.wallet.get_request_status(key) + status_str = req.get_status_str(status) status_item.setText(status_str) status_item.setIcon(read_QIcon(pr_icons.get(status))) @@ -130,20 +130,22 @@ class RequestList(MyTreeView): self.std_model.clear() self.update_headers(self.__class__.headers) for req in self.wallet.get_sorted_requests(): - status, status_str = get_request_status(req) - request_type = req['type'] - timestamp = req.get('time', 0) - amount = req.get('amount') - message = req.get('message') or req.get('memo') + key = req.rhash if req.is_lightning() else req.id + status = self.parent.wallet.get_request_status(key) + status_str = req.get_status_str(status) + request_type = req.type + timestamp = req.time + amount = req.amount + message = req.message date = format_time(timestamp) amount_str = self.parent.format_amount(amount) if amount else "" labels = [date, message, amount_str, status_str] - if request_type == PR_TYPE_LN: - key = req['rhash'] + if req.is_lightning(): + key = req.rhash icon = read_QIcon("lightning.png") tooltip = 'lightning request' - elif request_type == PR_TYPE_ONCHAIN: - key = req['address'] + else: + key = req.get_address() icon = read_QIcon("bitcoin.png") tooltip = 'onchain request' items = [QStandardItem(e) for e in labels] @@ -182,20 +184,20 @@ class RequestList(MyTreeView): if not item: return key = item.data(ROLE_KEY) - request_type = item.data(ROLE_REQUEST_TYPE) req = self.wallet.get_request(key) if req is None: self.update() return menu = QMenu(self) self.add_copy_menu(menu, idx) - if request_type == PR_TYPE_LN: - menu.addAction(_("Copy Request"), lambda: self.parent.do_copy(req['invoice'], title='Lightning Request')) + if req.is_lightning(): + menu.addAction(_("Copy Request"), lambda: self.parent.do_copy(req.invoice, title='Lightning Request')) else: - menu.addAction(_("Copy Request"), lambda: self.parent.do_copy(req['URI'], title='Bitcoin URI')) - menu.addAction(_("Copy Address"), lambda: self.parent.do_copy(req['address'], title='Bitcoin Address')) - if 'view_url' in req: - menu.addAction(_("View in web browser"), lambda: webopen(req['view_url'])) + URI = self.wallet.get_request_URI(req) + menu.addAction(_("Copy Request"), lambda: self.parent.do_copy(URI, title='Bitcoin URI')) + menu.addAction(_("Copy Address"), lambda: self.parent.do_copy(req.get_address(), title='Bitcoin Address')) + #if 'view_url' in req: + # menu.addAction(_("View in web browser"), lambda: webopen(req['view_url'])) menu.addAction(_("Delete"), lambda: self.parent.delete_requests([key])) run_hook('receive_list_menu', menu, key) menu.exec_(self.viewport().mapToGlobal(position)) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py @@ -26,7 +26,7 @@ from PyQt5.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, from electrum.i18n import _, languages from electrum.util import FileImportFailed, FileExportFailed, make_aiohttp_session, resource_path -from electrum.util import PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING +from electrum.invoices import PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING if TYPE_CHECKING: from .main_window import ElectrumWindow diff --git a/electrum/invoices.py b/electrum/invoices.py @@ -0,0 +1,115 @@ +import attr +import time + +from .json_db import StoredObject +from .i18n import _ +from .util import age +from .lnaddr import lndecode +from . import constants +from .bitcoin import COIN +from .transaction import PartialTxOutput + +# convention: 'invoices' = outgoing , 'request' = incoming + +# types of payment requests +PR_TYPE_ONCHAIN = 0 +PR_TYPE_LN = 2 + +# status of payment requests +PR_UNPAID = 0 +PR_EXPIRED = 1 +PR_UNKNOWN = 2 # sent but not propagated +PR_PAID = 3 # send and propagated +PR_INFLIGHT = 4 # unconfirmed +PR_FAILED = 5 +PR_ROUTING = 6 + +pr_color = { + PR_UNPAID: (.7, .7, .7, 1), + PR_PAID: (.2, .9, .2, 1), + PR_UNKNOWN: (.7, .7, .7, 1), + PR_EXPIRED: (.9, .2, .2, 1), + PR_INFLIGHT: (.9, .6, .3, 1), + PR_FAILED: (.9, .2, .2, 1), + PR_ROUTING: (.9, .6, .3, 1), +} + +pr_tooltips = { + PR_UNPAID:_('Pending'), + PR_PAID:_('Paid'), + PR_UNKNOWN:_('Unknown'), + PR_EXPIRED:_('Expired'), + PR_INFLIGHT:_('In progress'), + PR_FAILED:_('Failed'), + PR_ROUTING: _('Computing route...'), +} + +PR_DEFAULT_EXPIRATION_WHEN_CREATING = 24*60*60 # 1 day +pr_expiration_values = { + 0: _('Never'), + 10*60: _('10 minutes'), + 60*60: _('1 hour'), + 24*60*60: _('1 day'), + 7*24*60*60: _('1 week'), +} +assert PR_DEFAULT_EXPIRATION_WHEN_CREATING in pr_expiration_values + +outputs_decoder = lambda _list: [PartialTxOutput.from_legacy_tuple(*x) for x in _list] + +@attr.s +class Invoice(StoredObject): + type = attr.ib(type=int) + message = attr.ib(type=str) + amount = attr.ib(type=int) + exp = attr.ib(type=int) + time = attr.ib(type=int) + + def is_lightning(self): + return self.type == PR_TYPE_LN + + def get_status_str(self, status): + status_str = pr_tooltips[status] + if status == PR_UNPAID: + if self.exp > 0: + expiration = self.exp + self.time + status_str = _('Expires') + ' ' + age(expiration, include_seconds=True) + else: + status_str = _('Pending') + return status_str + +@attr.s +class OnchainInvoice(Invoice): + id = attr.ib(type=str) + outputs = attr.ib(type=list, converter=outputs_decoder) + bip70 = attr.ib(type=str) # may be None + requestor = attr.ib(type=str) # may be None + + def get_address(self): + assert len(self.outputs) == 1 + return self.outputs[0].address + +@attr.s +class LNInvoice(Invoice): + rhash = attr.ib(type=str) + invoice = attr.ib(type=str) + + @classmethod + def from_bech32(klass, invoice: str): + lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) + amount = int(lnaddr.amount * COIN) if lnaddr.amount else None + return LNInvoice( + type = PR_TYPE_LN, + amount = amount, + message = lnaddr.get_description(), + time = lnaddr.date, + exp = lnaddr.get_expiry(), + rhash = lnaddr.paymenthash.hex(), + invoice = invoice, + ) + + +def invoice_from_json(x: dict) -> Invoice: + if x.get('type') == PR_TYPE_LN: + return LNInvoice(**x) + else: + return OnchainInvoice(**x) diff --git a/electrum/lnaddr.py b/electrum/lnaddr.py @@ -13,7 +13,6 @@ from .bitcoin import hash160_to_b58_address, b58_address_to_hash160 from .segwit_addr import bech32_encode, bech32_decode, CHARSET from . import constants from . import ecc -from .util import PR_TYPE_LN from .bitcoin import COIN @@ -470,20 +469,6 @@ def lndecode(invoice: str, *, verbose=False, expected_hrp=None) -> LnAddr: -def parse_lightning_invoice(invoice): - lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) - amount = int(lnaddr.amount * COIN) if lnaddr.amount else None - return { - 'type': PR_TYPE_LN, - 'invoice': invoice, - 'amount': amount, - 'message': lnaddr.get_description(), - 'time': lnaddr.date, - 'exp': lnaddr.get_expiry(), - 'pubkey': lnaddr.pubkey.serialize().hex(), - 'rhash': lnaddr.paymenthash.hex(), - } - if __name__ == '__main__': # run using # python3 -m electrum.lnaddr <invoice> <expected hrp> diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py @@ -35,7 +35,8 @@ import attr from . import ecc from . import constants, util -from .util import bfh, bh2u, chunks, TxMinedInfo, PR_PAID +from .util import bfh, bh2u, chunks, TxMinedInfo +from .invoices import PR_PAID from .bitcoin import redeem_script_to_address from .crypto import sha256, sha256d from .transaction import Transaction, PartialTransaction, TxInput diff --git a/electrum/lnworker.py b/electrum/lnworker.py @@ -24,8 +24,8 @@ from aiorpcx import run_in_thread from . import constants, util from . import keystore from .util import profiler -from .util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING -from .util import PR_TYPE_LN, NetworkRetryManager +from .invoices import PR_TYPE_LN, PR_UNPAID, PR_EXPIRED, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, LNInvoice +from .util import NetworkRetryManager from .lnutil import LN_MAX_FUNDING_SAT from .keystore import BIP32_KeyStore from .bitcoin import COIN @@ -1102,15 +1102,7 @@ class LNWallet(LNWorker): payment_secret=derive_payment_secret_from_payment_preimage(payment_preimage)) invoice = lnencode(lnaddr, self.node_keypair.privkey) key = bh2u(lnaddr.paymenthash) - req = { - 'type': PR_TYPE_LN, - 'amount': amount_sat, - 'time': lnaddr.date, - 'exp': expiry, - 'message': message, - 'rhash': key, - 'invoice': invoice - } + req = LNInvoice.from_bech32(invoice) self.save_preimage(payment_hash, payment_preimage) self.save_payment_info(info) self.wallet.add_payment_request(req) @@ -1145,7 +1137,8 @@ class LNWallet(LNWorker): info = self.get_payment_info(payment_hash) return info.status if info else PR_UNPAID - def get_invoice_status(self, key): + def get_invoice_status(self, invoice): + key = invoice.rhash log = self.logs[key] if key in self.is_routing: return PR_ROUTING @@ -1285,6 +1278,12 @@ class LNWallet(LNWorker): return Decimal(max(chan.available_to_spend(REMOTE) if chan.is_open() else 0 for chan in self.channels.values()))/1000 if self.channels else 0 + def can_pay_invoice(self, invoice): + return invoice.amount <= self.num_sats_can_send() + + def can_receive_invoice(self, invoice): + return invoice.amount <= self.num_sats_can_receive() + async def close_channel(self, chan_id): chan = self._channels[chan_id] peer = self._peers[chan.node_id] diff --git a/electrum/paymentrequest.py b/electrum/paymentrequest.py @@ -41,7 +41,7 @@ except ImportError: from . import bitcoin, ecc, util, transaction, x509, rsakey from .util import bh2u, bfh, export_meta, import_meta, make_aiohttp_session -from .util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_UNKNOWN, PR_INFLIGHT +from .invoices import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_UNKNOWN, PR_INFLIGHT from .crypto import sha256 from .bitcoin import address_to_script from .transaction import PartialTxOutput diff --git a/electrum/util.py b/electrum/util.py @@ -43,6 +43,7 @@ from typing import NamedTuple, Optional import ssl import ipaddress import random +import attr import aiohttp from aiohttp_socks import ProxyConnector, ProxyType @@ -77,66 +78,6 @@ base_units_list = ['BTC', 'mBTC', 'bits', 'sat'] # list(dict) does not guarante DECIMAL_POINT_DEFAULT = 5 # mBTC -# types of payment requests -PR_TYPE_ONCHAIN = 0 -PR_TYPE_LN = 2 - -# status of payment requests -PR_UNPAID = 0 -PR_EXPIRED = 1 -PR_UNKNOWN = 2 # sent but not propagated -PR_PAID = 3 # send and propagated -PR_INFLIGHT = 4 # unconfirmed -PR_FAILED = 5 -PR_ROUTING = 6 - -pr_color = { - PR_UNPAID: (.7, .7, .7, 1), - PR_PAID: (.2, .9, .2, 1), - PR_UNKNOWN: (.7, .7, .7, 1), - PR_EXPIRED: (.9, .2, .2, 1), - PR_INFLIGHT: (.9, .6, .3, 1), - PR_FAILED: (.9, .2, .2, 1), - PR_ROUTING: (.9, .6, .3, 1), -} - -pr_tooltips = { - PR_UNPAID:_('Pending'), - PR_PAID:_('Paid'), - PR_UNKNOWN:_('Unknown'), - PR_EXPIRED:_('Expired'), - PR_INFLIGHT:_('In progress'), - PR_FAILED:_('Failed'), - PR_ROUTING: _('Computing route...'), -} - -PR_DEFAULT_EXPIRATION_WHEN_CREATING = 24*60*60 # 1 day -pr_expiration_values = { - 0: _('Never'), - 10*60: _('10 minutes'), - 60*60: _('1 hour'), - 24*60*60: _('1 day'), - 7*24*60*60: _('1 week'), -} -assert PR_DEFAULT_EXPIRATION_WHEN_CREATING in pr_expiration_values - - -def get_request_status(req): - status = req['status'] - exp = req.get('exp', 0) or 0 - if req.get('type') == PR_TYPE_LN and exp == 0: - status = PR_EXPIRED # for BOLT-11 invoices, exp==0 means 0 seconds - if req['status'] == PR_UNPAID and exp > 0 and req['time'] + req['exp'] < time.time(): - status = PR_EXPIRED - status_str = pr_tooltips[status] - if status == PR_UNPAID: - if exp > 0: - expiration = exp + req['time'] - status_str = _('Expires') + ' ' + age(expiration, include_seconds=True) - else: - status_str = _('Pending') - return status, status_str - class UnknownBaseUnit(Exception): pass diff --git a/electrum/wallet.py b/electrum/wallet.py @@ -52,10 +52,10 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, WalletFileException, BitcoinException, MultipleSpendMaxTxOutputs, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex) -from .util import PR_TYPE_ONCHAIN, PR_TYPE_LN, get_backup_dir +from .util import get_backup_dir from .simple_config import SimpleConfig -from .bitcoin import (COIN, is_address, address_to_script, - is_minikey, relayfee, dust_threshold) +from .bitcoin import COIN, TYPE_ADDRESS +from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold from .crypto import sha256d from . import keystore from .keystore import load_keystore, Hardware_KeyStore, KeyStore, KeyStoreWithMPK, AddressIndexGeneric @@ -68,7 +68,8 @@ from .transaction import (Transaction, TxInput, UnknownTxinType, TxOutput, from .plugin import run_hook from .address_synchronizer import (AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE) -from .util import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT +from .invoices import Invoice, OnchainInvoice, invoice_from_json +from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT, PR_TYPE_ONCHAIN, PR_TYPE_LN from .contacts import Contacts from .interface import NetworkException from .mnemonic import Mnemonic @@ -660,39 +661,43 @@ class Abstract_Wallet(AddressSynchronizer, ABC): amount = '!' else: amount = sum(x.value for x in outputs) - invoice = { - 'type': PR_TYPE_ONCHAIN, - 'message': message, - 'outputs': outputs, - 'amount': amount, - } + outputs = [x.to_legacy_tuple() for x in outputs] if pr: - invoice['bip70'] = pr.raw.hex() - invoice['time'] = pr.get_time() - invoice['exp'] = pr.get_expiration_date() - pr.get_time() - invoice['requestor'] = pr.get_requestor() - invoice['message'] = pr.get_memo() - elif URI: - timestamp = URI.get('time') - if timestamp: invoice['time'] = timestamp - exp = URI.get('exp') - if exp: invoice['exp'] = exp - if 'time' not in invoice: - invoice['time'] = int(time.time()) + invoice = OnchainInvoice( + type = PR_TYPE_ONCHAIN, + amount = amount, + outputs = outputs, + message = pr.get_memo(), + id = pr.get_id(), + time = pr.get_time(), + exp = pr.get_expiration_date() - pr.get_time(), + bip70 = pr.raw.hex() if pr else None, + requestor = pr.get_requestor(), + ) + else: + invoice = OnchainInvoice( + type = PR_TYPE_ONCHAIN, + amount = amount, + outputs = outputs, + message = message, + id = bh2u(sha256(repr(outputs))[0:16]), + time = URI.get('time') if URI else int(time.time()), + exp = URI.get('exp') if URI else 0, + bip70 = None, + requestor = None, + ) return invoice - def save_invoice(self, invoice): - invoice_type = invoice['type'] + def save_invoice(self, invoice: Invoice): + invoice_type = invoice.type if invoice_type == PR_TYPE_LN: - key = invoice['rhash'] + key = invoice.rhash elif invoice_type == PR_TYPE_ONCHAIN: + key = invoice.id if self.is_onchain_invoice_paid(invoice): self.logger.info("saving invoice... but it is already paid!") - key = bh2u(sha256(repr(invoice))[0:16]) - invoice['id'] = key - outputs = invoice['outputs'] # type: List[PartialTxOutput] with self.transaction_lock: - for txout in outputs: + for txout in invoice.outputs: self._invoices_from_scriptpubkey_map[txout.scriptpubkey].add(key) else: raise Exception('Unsupported invoice type') @@ -704,26 +709,13 @@ class Abstract_Wallet(AddressSynchronizer, ABC): self.save_db() def get_invoices(self): - out = [self.get_invoice(key) for key in self.invoices.keys()] - out = list(filter(None, out)) - out.sort(key=operator.itemgetter('time')) + out = list(self.invoices.values()) + #out = list(filter(None, out)) filter out ln + out.sort(key=lambda x:x.time) return out def get_invoice(self, key): - if key not in self.invoices: - return - # convert StoredDict to dict - item = dict(self.invoices[key]) - request_type = item.get('type') - if request_type == PR_TYPE_ONCHAIN: - item['status'] = PR_PAID if self.is_onchain_invoice_paid(item) else PR_UNPAID - elif self.lnworker and request_type == PR_TYPE_LN: - item['status'] = self.lnworker.get_invoice_status(key) - else: - return - # unique handle - item['key'] = key - return item + return self.invoices.get(key) def _get_relevant_invoice_keys_for_tx(self, tx: Transaction) -> Set[str]: relevant_invoice_keys = set() @@ -736,16 +728,15 @@ class Abstract_Wallet(AddressSynchronizer, ABC): # scriptpubkey -> list(invoice_keys) self._invoices_from_scriptpubkey_map = defaultdict(set) # type: Dict[bytes, Set[str]] for invoice_key, invoice in self.invoices.items(): - if invoice.get('type') == PR_TYPE_ONCHAIN: - outputs = invoice['outputs'] # type: List[PartialTxOutput] - for txout in outputs: + if invoice.type == PR_TYPE_ONCHAIN: + for txout in invoice.outputs: self._invoices_from_scriptpubkey_map[txout.scriptpubkey].add(invoice_key) - def _is_onchain_invoice_paid(self, invoice: dict) -> Tuple[bool, Sequence[str]]: + def _is_onchain_invoice_paid(self, invoice: Invoice) -> Tuple[bool, Sequence[str]]: """Returns whether on-chain invoice is satisfied, and list of relevant TXIDs.""" - assert invoice.get('type') == PR_TYPE_ONCHAIN + assert invoice.type == PR_TYPE_ONCHAIN invoice_amounts = defaultdict(int) # type: Dict[bytes, int] # scriptpubkey -> value_sats - for txo in invoice['outputs']: # type: PartialTxOutput + for txo in invoice.outputs: # type: PartialTxOutput invoice_amounts[txo.scriptpubkey] += 1 if txo.value == '!' else txo.value relevant_txs = [] with self.transaction_lock: @@ -762,7 +753,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): return False, [] return True, relevant_txs - def is_onchain_invoice_paid(self, invoice: dict) -> bool: + def is_onchain_invoice_paid(self, invoice: Invoice) -> bool: return self._is_onchain_invoice_paid(invoice)[0] def _maybe_set_tx_label_based_on_invoices(self, tx: Transaction) -> bool: @@ -1550,7 +1541,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def get_unused_addresses(self) -> Sequence[str]: domain = self.get_receiving_addresses() - in_use_by_request = [k for k in self.receive_requests.keys() if self.get_request_status(k)[0] != PR_EXPIRED] + in_use_by_request = [k for k in self.receive_requests.keys() if self.get_request_status(k) != PR_EXPIRED] # we should index receive_requests by id return [addr for addr in domain if not self.is_used(addr) and addr not in in_use_by_request] @@ -1608,60 +1599,84 @@ class Abstract_Wallet(AddressSynchronizer, ABC): return True, conf return False, None - def get_request_URI(self, addr): - req = self.receive_requests[addr] + def get_request_URI(self, req: Invoice): + addr = req.get_address() message = self.labels.get(addr, '') - amount = req['amount'] + amount = req.amount extra_query_params = {} - if req.get('time'): - extra_query_params['time'] = str(int(req.get('time'))) - if req.get('exp'): - extra_query_params['exp'] = str(int(req.get('exp'))) - if req.get('name') and req.get('sig'): - sig = bfh(req.get('sig')) - sig = bitcoin.base_encode(sig, base=58) - extra_query_params['name'] = req['name'] - extra_query_params['sig'] = sig + if req.time: + extra_query_params['time'] = str(int(req.time)) + if req.exp: + extra_query_params['exp'] = str(int(req.exp)) + #if req.get('name') and req.get('sig'): + # sig = bfh(req.get('sig')) + # sig = bitcoin.base_encode(sig, base=58) + # extra_query_params['name'] = req['name'] + # extra_query_params['sig'] = sig uri = create_bip21_uri(addr, amount, message, extra_query_params=extra_query_params) return str(uri) - def get_request_status(self, address): - r = self.receive_requests.get(address) + def check_expired_status(self, r, status): + if r.is_lightning() and r.exp == 0: + status = PR_EXPIRED # for BOLT-11 invoices, exp==0 means 0 seconds + if status == PR_UNPAID and r.exp > 0 and r.time + r.exp < time.time(): + status = PR_EXPIRED + return status + + def get_invoice_status(self, invoice): + if invoice.is_lightning(): + status = self.lnworker.get_invoice_status(invoice) + else: + status = PR_PAID if self.is_onchain_invoice_paid(invoice) else PR_UNPAID + return self.check_expired_status(invoice, status) + + def get_request_status(self, key): + r = self.get_request(key) if r is None: return PR_UNKNOWN - amount = r.get('amount', 0) or 0 - timestamp = r.get('time', 0) - if timestamp and type(timestamp) != int: - timestamp = 0 - exp = r.get('exp', 0) or 0 - paid, conf = self.get_payment_status(address, amount) - if not paid: - if exp > 0 and time.time() > timestamp + exp: - status = PR_EXPIRED - else: - status = PR_UNPAID + if r.is_lightning(): + status = self.lnworker.get_payment_status(bfh(r.rhash)) else: - status = PR_PAID - return status, conf + paid, conf = self.get_payment_status(r.get_address(), r.amount) + status = PR_PAID if paid else PR_UNPAID + return self.check_expired_status(r, status) def get_request(self, key): - req = self.receive_requests.get(key) - if not req: - return - # convert StoredDict to dict - req = dict(req) - _type = req.get('type') - if _type == PR_TYPE_ONCHAIN: - addr = req['address'] - req['URI'] = self.get_request_URI(addr) - status, conf = self.get_request_status(addr) - req['status'] = status - if conf is not None: - req['confirmations'] = conf - elif self.lnworker and _type == PR_TYPE_LN: - req['status'] = self.lnworker.get_payment_status(bfh(key)) + return self.receive_requests.get(key) + + def get_formatted_request(self, key): + x = self.receive_requests.get(key) + if x: + return self.export_request(x) + + def export_request(self, x): + key = x.rhash if x.is_lightning() else x.get_address() + status = self.get_request_status(key) + status_str = x.get_status_str(status) + is_lightning = x.is_lightning() + d = { + 'is_lightning': is_lightning, + 'amount': x.amount, + 'amount_BTC': format_satoshis(x.amount), + 'message': x.message, + 'timestamp': x.time, + 'expiration': x.exp, + 'status': status, + 'status_str': status_str, + } + if is_lightning: + d['rhash'] = x.rhash + d['invoice'] = x.invoice + if self.lnworker and status == PR_UNPAID: + d['can_receive'] = self.lnworker.can_receive_invoice(x) else: - return + #key = x.id + addr = x.get_address() + paid, conf = self.get_payment_status(addr, x.amount) + d['address'] = addr + d['URI'] = self.get_request_URI(x) + if conf is not None: + d['confirmations'] = conf # add URL if we are running a payserver payserver = self.config.get_netaddress('payserver_address') if payserver: @@ -1669,32 +1684,58 @@ class Abstract_Wallet(AddressSynchronizer, ABC): use_ssl = bool(self.config.get('ssl_keyfile')) protocol = 'https' if use_ssl else 'http' base = '%s://%s:%d'%(protocol, payserver.host, payserver.port) - req['view_url'] = base + root + '/pay?id=' + key - if use_ssl and 'URI' in req: + d['view_url'] = base + root + '/pay?id=' + key + if use_ssl and 'URI' in d: request_url = base + '/bip70/' + key + '.bip70' - req['bip70_url'] = request_url - return req + d['bip70_url'] = request_url + return d + + def export_invoice(self, x): + status = self.get_invoice_status(x) + status_str = x.get_status_str(status) + is_lightning = x.is_lightning() + d = { + 'is_lightning': is_lightning, + 'amount': x.amount, + 'amount_BTC': format_satoshis(x.amount), + 'message': x.message, + 'timestamp': x.time, + 'expiration': x.exp, + 'status': status, + 'status_str': status_str, + } + if is_lightning: + d['invoice'] = x.invoice + if status == PR_UNPAID: + d['can_pay'] = self.lnworker.can_pay_invoice(x) + else: + d['outputs'] = [y.to_legacy_tuple() for y in x.outputs] + if x.bip70: + d['bip70'] = x.bip70 + d['requestor'] = x.requestor + return d def receive_tx_callback(self, tx_hash, tx, tx_height): super().receive_tx_callback(tx_hash, tx, tx_height) for txo in tx.outputs(): addr = self.get_txout_address(txo) if addr in self.receive_requests: - status, conf = self.get_request_status(addr) + status = self.get_request_status(addr) util.trigger_callback('request_status', addr, status) - def make_payment_request(self, addr, amount, message, expiration): + def make_payment_request(self, address, amount, message, expiration): timestamp = int(time.time()) - _id = bh2u(sha256d(addr + "%d"%timestamp))[0:10] - return { - 'type': PR_TYPE_ONCHAIN, - 'time':timestamp, - 'amount':amount, - 'exp':expiration, - 'address':addr, - 'memo':message, - 'id':_id, - } + _id = bh2u(sha256d(address + "%d"%timestamp))[0:10] + return OnchainInvoice( + type = PR_TYPE_ONCHAIN, + outputs = [(TYPE_ADDRESS, address, amount)], + message = message, + time = timestamp, + amount = amount, + exp = expiration, + id = _id, + bip70 = None, + requestor = None) def sign_payment_request(self, key, alias, alias_addr, password): req = self.receive_requests.get(key) @@ -1706,20 +1747,17 @@ class Abstract_Wallet(AddressSynchronizer, ABC): self.receive_requests[key] = req def add_payment_request(self, req): - if req['type'] == PR_TYPE_ONCHAIN: - addr = req['address'] + if not req.is_lightning(): + addr = req.get_address() if not bitcoin.is_address(addr): raise Exception(_('Invalid Bitcoin address.')) if not self.is_mine(addr): raise Exception(_('Address not in wallet.')) key = addr - message = req['memo'] - elif req['type'] == PR_TYPE_LN: - key = req['rhash'] - message = req['message'] + message = req.message else: - raise Exception('Unknown request type') - amount = req.get('amount') + key = req.rhash + message = req.message self.receive_requests[key] = req self.set_label(key, message) # should be a default label return req @@ -1748,7 +1786,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): """ sorted by timestamp """ out = [self.get_request(x) for x in self.receive_requests.keys()] out = [x for x in out if x is not None] - out.sort(key=operator.itemgetter('time')) + out.sort(key=lambda x: x.time) return out @abstractmethod diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py @@ -32,7 +32,8 @@ from typing import Dict, Optional, List, Tuple, Set, Iterable, NamedTuple, Seque import binascii from . import util, bitcoin -from .util import profiler, WalletFileException, multisig_type, TxMinedInfo, bfh, PR_TYPE_ONCHAIN +from .util import profiler, WalletFileException, multisig_type, TxMinedInfo, bfh +from .invoices import PR_TYPE_ONCHAIN, invoice_from_json from .keystore import bip44_derivation from .transaction import Transaction, TxOutpoint, tx_from_any, PartialTransaction, PartialTxOutput from .logging import Logger @@ -50,7 +51,7 @@ if TYPE_CHECKING: OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 28 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 29 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -174,6 +175,7 @@ class WalletDB(JsonDB): self._convert_version_26() self._convert_version_27() self._convert_version_28() + self._convert_version_29() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure self._after_upgrade_tasks() @@ -605,6 +607,41 @@ class WalletDB(JsonDB): c['local_config']['channel_seed'] = None self.data['seed_version'] = 28 + def _convert_version_29(self): + if not self._is_upgrade_method_needed(28, 28): + return + requests = self.data.get('payment_requests', {}) + invoices = self.data.get('invoices', {}) + for d in [invoices, requests]: + for key, r in list(d.items()): + _type = r.get('type', 0) + item = { + 'type': _type, + 'message': r.get('message') or r.get('memo', ''), + 'amount': r.get('amount'), + 'exp': r.get('exp', 0), + 'time': r.get('time', 0), + } + if _type == PR_TYPE_ONCHAIN: + address = r.pop('address', None) + if address: + outputs = [(0, address, r.get('amount'))] + else: + outputs = r.get('outputs') + item.update({ + 'outputs': outputs, + 'id': r.get('id'), + 'bip70': r.get('bip70'), + 'requestor': r.get('requestor'), + }) + else: + item.update({ + 'rhash': r['rhash'], + 'invoice': r['invoice'], + }) + d[key] = item + self.data['seed_version'] = 29 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return @@ -1072,15 +1109,6 @@ class WalletDB(JsonDB): if spending_txid not in self.transactions: self.logger.info("removing unreferenced spent outpoint") d.pop(prevout_n) - # convert invoices - # TODO invoices being these contextual dicts even internally, - # where certain keys are only present depending on values of other keys... - # it's horrible. we need to change this, at least for the internal representation, - # to something that can be typed. - self.invoices = self.get_dict('invoices') - for invoice_key, invoice in self.invoices.items(): - if invoice.get('type') == PR_TYPE_ONCHAIN: - invoice['outputs'] = [PartialTxOutput.from_legacy_tuple(*output) for output in invoice.get('outputs')] @modifier def clear_history(self): @@ -1097,6 +1125,10 @@ class WalletDB(JsonDB): if key == 'transactions': # note: for performance, "deserialize=False" so that we will deserialize these on-demand v = dict((k, tx_from_any(x, deserialize=False)) for k, x in v.items()) + if key == 'invoices': + v = dict((k, invoice_from_json(x)) for k, x in v.items()) + if key == 'payment_requests': + v = dict((k, invoice_from_json(x)) for k, x in v.items()) elif key == 'adds': v = dict((k, UpdateAddHtlc.from_tuple(*x)) for k, x in v.items()) elif key == 'fee_updates':