From e392197ab94715815c00503d8fd5cb881d0a26c3 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 15 Mar 2022 13:03:34 +0100 Subject: [PATCH] wallet_db upgrade: - unify lightning and onchain invoices, with optional fields for bip70 and lightning - add receive_address fields to submarine swaps --- electrum/commands.py | 6 +- electrum/gui/qt/invoice_list.py | 6 +- electrum/gui/qt/lightning_tx_dialog.py | 5 +- electrum/gui/qt/main_window.py | 33 +++-- electrum/gui/qt/request_list.py | 14 +- electrum/invoices.py | 174 +++++++++++-------------- electrum/lnworker.py | 24 +++- electrum/paymentrequest.py | 6 +- electrum/submarine_swaps.py | 9 +- electrum/wallet.py | 86 +++++------- electrum/wallet_db.py | 50 ++++++- 11 files changed, 214 insertions(+), 199 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 4b1abc77a..84b593481 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -60,7 +60,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 .invoices import LNInvoice +from .invoices import Invoice from . import submarine_swaps @@ -1066,7 +1066,7 @@ class Commands: @command('') async def decode_invoice(self, invoice: str): - invoice = LNInvoice.from_bech32(invoice) + invoice = Invoice.from_bech32(invoice) return invoice.to_debug_json() @command('wnl') @@ -1074,7 +1074,7 @@ class Commands: lnworker = wallet.lnworker lnaddr = lnworker._check_invoice(invoice) payment_hash = lnaddr.paymenthash - wallet.save_invoice(LNInvoice.from_bech32(invoice)) + wallet.save_invoice(Invoice.from_bech32(invoice)) success, log = await lnworker.pay_invoice(invoice, attempts=attempts) return { 'payment_hash': payment_hash.hex(), diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py index c788627ef..0d66af220 100644 --- a/electrum/gui/qt/invoice_list.py +++ b/electrum/gui/qt/invoice_list.py @@ -33,7 +33,7 @@ from PyQt5.QtWidgets import QMenu, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QH from electrum.i18n import _ 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.invoices import Invoice, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED from electrum.lnutil import HtlcLog from .util import MyTreeView, read_QIcon, MySortModel, pr_icons @@ -116,7 +116,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(item.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() @@ -136,7 +136,7 @@ class InvoiceList(MyTreeView): if len(items)>1: keys = [item.data(ROLE_REQUEST_ID) for item in items] 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]) + can_batch_pay = all([not i.is_lightning() and wallet.get_invoice_status(i) == PR_UNPAID for i in invoices]) menu = QMenu(self) if can_batch_pay: menu.addAction(_("Batch pay invoices") + "...", lambda: self.parent.pay_multiple_invoices(invoices)) diff --git a/electrum/gui/qt/lightning_tx_dialog.py b/electrum/gui/qt/lightning_tx_dialog.py index 52423182c..b9bd6529c 100644 --- a/electrum/gui/qt/lightning_tx_dialog.py +++ b/electrum/gui/qt/lightning_tx_dialog.py @@ -31,7 +31,6 @@ from PyQt5.QtGui import QFont from PyQt5.QtWidgets import QVBoxLayout, QLabel, QGridLayout from electrum.i18n import _ -from electrum.invoices import LNInvoice from .util import WindowModalDialog, ButtonsLineEdit, ColorScheme, Buttons, CloseButton, MONOSPACE_FONT from .qrtextedit import ShowQRTextEdit @@ -55,8 +54,8 @@ class LightningTxDialog(WindowModalDialog): invoice = (self.parent.wallet.get_invoice(self.payment_hash) or self.parent.wallet.get_request(self.payment_hash)) if invoice: - assert isinstance(invoice, LNInvoice), f"{self.invoice!r}" - self.invoice = invoice.invoice + assert invoice.is_lightning(), f"{self.invoice!r}" + self.invoice = invoice.lightning_invoice else: self.invoice = '' diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 7866eb370..f1cdb292a 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -66,8 +66,8 @@ from electrum.util import (format_time, NoDynamicFeeEstimates, AddTransactionException, BITCOIN_BIP21_URI_SCHEME, InvoiceError, parse_max_spend) -from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING, Invoice -from electrum.invoices import PR_PAID, PR_FAILED, pr_expiration_values, LNInvoice, OnchainInvoice +from electrum.invoices import PR_DEFAULT_EXPIRATION_WHEN_CREATING, Invoice +from electrum.invoices import PR_PAID, PR_FAILED, pr_expiration_values, Invoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) from electrum.wallet import (Multisig_Wallet, CannotBumpFee, Abstract_Wallet, @@ -1290,7 +1290,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.receive_message_e.setText('') # copy to clipboard r = self.wallet.get_request(key) - content = r.invoice if r.is_lightning() else r.get_address() + content = r.lightning_invoice if r.is_lightning() else r.get_address() title = _('Invoice') if is_lightning else _('Address') self.do_copy(content, title=title) @@ -1311,7 +1311,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if not self.question(_("Warning: The next address will not be recovered automatically if you restore your wallet from seed; you may need to add it manually.\n\nThis occurs because you have too many unused addresses in your wallet. To avoid this situation, use the existing addresses first.\n\nCreate anyway?")): return addr = self.wallet.create_new_address(False) - req = self.wallet.make_payment_request(addr, amount, message, expiration) + timestamp = int(time.time()) + req = self.wallet.make_payment_request(amount, message, timestamp, expiration, address=addr) try: self.wallet.add_payment_request(req) except Exception as e: @@ -1649,8 +1650,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if not self.wallet.has_lightning(): self.show_error(_('Lightning is disabled')) return - invoice = LNInvoice.from_bech32(invoice_str) - if invoice.get_amount_msat() is None: + invoice = Invoice.from_bech32(invoice_str) + if invoice.amount_msat is None: amount_sat = self.amount_e.get_amount() if amount_sat: invoice.amount_msat = int(amount_sat * 1000) @@ -1698,14 +1699,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.pay_onchain_dialog(self.get_coins(), outputs) def do_pay_invoice(self, invoice: 'Invoice'): - if invoice.type == PR_TYPE_LN: - assert isinstance(invoice, LNInvoice) - self.pay_lightning_invoice(invoice.invoice, amount_msat=invoice.get_amount_msat()) - elif invoice.type == PR_TYPE_ONCHAIN: - assert isinstance(invoice, OnchainInvoice) - self.pay_onchain_dialog(self.get_coins(), invoice.outputs) + if invoice.is_lightning(): + self.pay_lightning_invoice(invoice.lightning_invoice, amount_msat=invoice.get_amount_msat()) else: - raise Exception('unknown invoice type') + self.pay_onchain_dialog(self.get_coins(), invoice.outputs) def get_coins(self, *, nonlocal_only=False) -> Sequence[PartialTxInput]: coins = self.get_manually_selected_coins() @@ -2177,8 +2174,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.contact_list.update() self.update_completions() - def show_onchain_invoice(self, invoice: OnchainInvoice): - amount_str = self.format_amount(invoice.amount_sat) + ' ' + self.base_unit() + def show_onchain_invoice(self, invoice: Invoice): + amount_str = self.format_amount(invoice.get_amount_sat()) + ' ' + self.base_unit() d = WindowModalDialog(self, _("Onchain Invoice")) vbox = QVBoxLayout(d) grid = QGridLayout() @@ -2226,8 +2223,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): vbox.addLayout(buttons) d.exec_() - def show_lightning_invoice(self, invoice: LNInvoice): - lnaddr = lndecode(invoice.invoice) + def show_lightning_invoice(self, invoice: Invoice): + lnaddr = lndecode(invoice.lightning_invoice) d = WindowModalDialog(self, _("Lightning Invoice")) vbox = QVBoxLayout(d) grid = QGridLayout() @@ -2250,7 +2247,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): vbox.addLayout(grid) invoice_e = ShowQRTextEdit(config=self.config) invoice_e.addCopyButton(self.app) - invoice_e.setText(invoice.invoice) + invoice_e.setText(invoice.lightning_invoice) vbox.addWidget(invoice_e) vbox.addLayout(Buttons(CloseButton(d),)) d.exec_() diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py index 4b3f1be04..082995445 100644 --- a/electrum/gui/qt/request_list.py +++ b/electrum/gui/qt/request_list.py @@ -32,7 +32,6 @@ from PyQt5.QtCore import Qt, QItemSelectionModel, QModelIndex from electrum.i18n import _ from electrum.util import format_time -from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN, LNInvoice, OnchainInvoice from electrum.plugin import run_hook from electrum.invoices import Invoice @@ -100,8 +99,8 @@ class RequestList(MyTreeView): self.update() return if req.is_lightning(): - self.parent.receive_payreq_e.setText(req.invoice) # TODO maybe prepend "lightning:" ?? - self.parent.receive_address_e.setText(req.invoice) + self.parent.receive_payreq_e.setText(req.lightning_invoice) # TODO maybe prepend "lightning:" ?? + self.parent.receive_address_e.setText(req.lightning_invoice) else: self.parent.receive_payreq_e.setText(self.parent.wallet.get_request_URI(req)) self.parent.receive_address_e.setText(req.get_address()) @@ -133,10 +132,9 @@ class RequestList(MyTreeView): key = self.wallet.get_key_for_receive_request(req) status = self.parent.wallet.get_request_status(key) status_str = req.get_status_str(status) - request_type = req.type - timestamp = req.time + timestamp = req.get_time() amount = req.get_amount_sat() - message = req.message + message = req.get_message() date = format_time(timestamp) amount_str = self.parent.format_amount(amount) if amount else "" labels = [date, message, amount_str, status_str] @@ -148,7 +146,7 @@ class RequestList(MyTreeView): tooltip = 'onchain request' items = [QStandardItem(e) for e in labels] self.set_editability(items) - items[self.Columns.DATE].setData(request_type, ROLE_REQUEST_TYPE) + #items[self.Columns.DATE].setData(request_type, ROLE_REQUEST_TYPE) items[self.Columns.DATE].setData(key, ROLE_KEY) items[self.Columns.DATE].setData(timestamp, ROLE_SORT_ORDER) items[self.Columns.DATE].setIcon(icon) @@ -190,7 +188,7 @@ class RequestList(MyTreeView): menu = QMenu(self) self.add_copy_menu(menu, idx) if req.is_lightning(): - menu.addAction(_("Copy Request"), lambda: self.parent.do_copy(req.invoice, title='Lightning Request')) + menu.addAction(_("Copy Request"), lambda: self.parent.do_copy(req.lightning_invoice, title='Lightning Request')) else: URI = self.wallet.get_request_URI(req) menu.addAction(_("Copy Request"), lambda: self.parent.do_copy(URI, title='Bitcoin URI')) diff --git a/electrum/invoices.py b/electrum/invoices.py index a2a53dc1d..68febe271 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -17,10 +17,6 @@ if TYPE_CHECKING: # convention: 'invoices' = outgoing , 'request' = incoming -# types of payment requests -PR_TYPE_ONCHAIN = 0 -PR_TYPE_LN = 2 - # status of payment requests PR_UNPAID = 0 # if onchain: invoice amt not reached by txs in mempool+chain. if LN: invoice not paid. PR_EXPIRED = 1 # invoice is unpaid and expiry time reached @@ -65,6 +61,8 @@ assert PR_DEFAULT_EXPIRATION_WHEN_CREATING in pr_expiration_values def _decode_outputs(outputs) -> List[PartialTxOutput]: + if outputs is None: + return None ret = [] for output in outputs: if not isinstance(output, PartialTxOutput): @@ -79,93 +77,74 @@ def _decode_outputs(outputs) -> List[PartialTxOutput]: # Hence set some high expiration here LN_EXPIRY_NEVER = 100 * 365 * 24 * 60 * 60 # 100 years + + @attr.s class Invoice(StoredObject): - type = attr.ib(type=int, kw_only=True) - message: str - exp: int - time: int + # mandatory fields + amount_msat = attr.ib(kw_only=True) # type: Optional[Union[int, str]] # can be '!' or None + message = attr.ib(type=str, kw_only=True) + time = attr.ib(type=int, kw_only=True, validator=attr.validators.instance_of(int)) # timestamp of the invoice + exp = attr.ib(type=int, kw_only=True, validator=attr.validators.instance_of(int)) # expiration delay (relative). 0 means never + + # optional fields. + # an request (incoming) can be satisfied onchain, using lightning or using a swap + # an invoice (outgoing) is constructed from a source: bip21, bip70, lnaddr + + # onchain only + outputs = attr.ib(kw_only=True, converter=_decode_outputs) # type: List[PartialTxOutput] + height = attr.ib(type=int, kw_only=True, validator=attr.validators.instance_of(int)) # only for receiving + bip70 = attr.ib(type=str, kw_only=True) # type: Optional[str] + #bip70_requestor = attr.ib(type=str, kw_only=True) # type: Optional[str] + + # lightning only + lightning_invoice = attr.ib(type=str, kw_only=True) + + __lnaddr = None def is_lightning(self): - return self.type == PR_TYPE_LN + return self.lightning_invoice is not None def get_status_str(self, status): status_str = pr_tooltips[status] if status == PR_UNPAID: if self.exp > 0 and self.exp != LN_EXPIRY_NEVER: - expiration = self.exp + self.time + expiration = self.get_expiration_date() status_str = _('Expires') + ' ' + age(expiration, include_seconds=True) return status_str - def get_amount_sat(self) -> Union[int, Decimal, str, None]: - """Returns a decimal satoshi amount, or '!' or None.""" - raise NotImplementedError() - - @classmethod - def from_json(cls, x: dict) -> 'Invoice': - # note: these raise if x has extra fields - if x.get('type') == PR_TYPE_LN: - return LNInvoice(**x) - else: - return OnchainInvoice(**x) - - -@attr.s -class OnchainInvoice(Invoice): - message = attr.ib(type=str, kw_only=True) - amount_sat = attr.ib(kw_only=True) # type: Union[int, str] # in satoshis. can be '!' - exp = attr.ib(type=int, kw_only=True, validator=attr.validators.instance_of(int)) - time = attr.ib(type=int, kw_only=True, validator=attr.validators.instance_of(int)) - id = attr.ib(type=str, kw_only=True) - outputs = attr.ib(kw_only=True, converter=_decode_outputs) # type: List[PartialTxOutput] - bip70 = attr.ib(type=str, kw_only=True) # type: Optional[str] - requestor = attr.ib(type=str, kw_only=True) # type: Optional[str] - height = attr.ib(type=int, kw_only=True, validator=attr.validators.instance_of(int)) - def get_address(self) -> str: """returns the first address, to be displayed in GUI""" return self.outputs[0].address - def get_amount_sat(self) -> Union[int, str]: - return self.amount_sat or 0 + def get_expiration_date(self): + # 0 means never + return self.exp + self.time if self.exp else 0 - @amount_sat.validator - def _validate_amount(self, attribute, value): - if isinstance(value, int): - if not (0 <= value <= TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN): - raise InvoiceError(f"amount is out-of-bounds: {value!r} sat") - elif isinstance(value, str): - if value != '!': - raise InvoiceError(f"unexpected amount: {value!r}") - else: - raise InvoiceError(f"unexpected amount: {value!r}") + def get_amount_msat(self): + return self.amount_msat - @classmethod - def from_bip70_payreq(cls, pr: 'PaymentRequest', height:int) -> 'OnchainInvoice': - return OnchainInvoice( - type=PR_TYPE_ONCHAIN, - amount_sat=pr.get_amount(), - outputs=pr.get_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(), - requestor=pr.get_requestor(), - height=height, - ) + def get_time(self): + return self.time -@attr.s -class LNInvoice(Invoice): - invoice = attr.ib(type=str) - amount_msat = attr.ib(kw_only=True) # type: Optional[int] # needed for zero amt invoices + def get_message(self): + return self.message - __lnaddr = None + def get_amount_sat(self) -> Union[int, str]: + """ + Returns an integer satoshi amount, or '!' or None. + Callers who need msat precision should call get_amount_msat() + """ + amount_msat = self.amount_msat + if amount_msat is None: + return None + return int(amount_msat / 1000) - @invoice.validator + @lightning_invoice.validator def _validate_invoice_str(self, attribute, value): - lndecode(value) # this checks the str can be decoded + if value is not None: + lndecode(value) # this checks the str can be decoded @amount_msat.validator def _validate_amount(self, attribute, value): @@ -174,45 +153,25 @@ class LNInvoice(Invoice): if isinstance(value, int): if not (0 <= value <= TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN * 1000): raise InvoiceError(f"amount is out-of-bounds: {value!r} msat") + elif isinstance(value, str): + if value != '!': + raise InvoiceError(f"unexpected amount: {value!r}") else: raise InvoiceError(f"unexpected amount: {value!r}") @property def _lnaddr(self) -> LnAddr: if self.__lnaddr is None: - self.__lnaddr = lndecode(self.invoice) + self.__lnaddr = lndecode(self.lightning_invoice) return self.__lnaddr @property def rhash(self) -> str: return self._lnaddr.paymenthash.hex() - def get_amount_msat(self) -> Optional[int]: - amount_btc = self._lnaddr.amount - amount = int(amount_btc * COIN * 1000) if amount_btc else None - return amount or self.amount_msat - - def get_amount_sat(self) -> Union[Decimal, None]: - amount_msat = self.get_amount_msat() - if amount_msat is None: - return None - return Decimal(amount_msat) / 1000 - - @property - def exp(self) -> int: - return self._lnaddr.get_expiry() - - @property - def time(self) -> int: - return self._lnaddr.date - - @property - def message(self) -> str: - return self._lnaddr.get_description() - @classmethod - def from_bech32(cls, invoice: str) -> 'LNInvoice': - """Constructs LNInvoice object from BOLT-11 string. + def from_bech32(cls, invoice: str) -> 'Invoice': + """Constructs Invoice object from BOLT-11 string. Might raise InvoiceError. """ try: @@ -220,10 +179,31 @@ class LNInvoice(Invoice): except Exception as e: raise InvoiceError(e) from e amount_msat = lnaddr.get_amount_msat() - return LNInvoice( - type=PR_TYPE_LN, - invoice=invoice, + timestamp = lnaddr.date + exp_delay = lnaddr.get_expiry() + message = lnaddr.get_description() + return Invoice( + message=message, amount_msat=amount_msat, + time=timestamp, + exp=exp_delay, + outputs=None, + bip70=None, + height=0, + lightning_invoice=invoice, + ) + + @classmethod + def from_bip70_payreq(cls, pr: 'PaymentRequest', height:int) -> 'Invoice': + return Invoice( + amount_msat=pr.get_amount()*1000, + message=pr.get_memo(), + time=pr.get_time(), + exp=pr.get_expiration_date() - pr.get_time(), + outputs=pr.get_outputs(), + bip70=pr.raw.hex(), + height=height, + lightning_invoice=None, ) def to_debug_json(self) -> Dict[str, Any]: diff --git a/electrum/lnworker.py b/electrum/lnworker.py index e682730af..55e741d3f 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -27,7 +27,7 @@ from aiorpcx import run_in_thread, NetAddress, ignore_after from . import constants, util from . import keystore from .util import profiler, chunks, OldTaskGroup -from .invoices import PR_TYPE_LN, PR_UNPAID, PR_EXPIRED, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, LNInvoice, LN_EXPIRY_NEVER +from .invoices import Invoice, PR_UNPAID, PR_EXPIRED, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, LN_EXPIRY_NEVER from .util import NetworkRetryManager, JsonRPCClient from .lnutil import LN_MAX_FUNDING_SAT from .keystore import BIP32_KeyStore @@ -1784,16 +1784,24 @@ class LNWallet(LNWorker): return lnaddr, invoice def add_request(self, amount_sat: Optional[int], message, expiry: int) -> str: + # passed expiry is relative, it is absolute in the lightning invoice amount_msat = amount_sat * 1000 if amount_sat is not None else None + timestamp = int(time.time()) lnaddr, invoice = self.create_invoice( amount_msat=amount_msat, message=message, expiry=expiry, write_to_disk=False, ) - key = bh2u(lnaddr.paymenthash) - req = LNInvoice.from_bech32(invoice) - self.wallet.add_payment_request(req, write_to_disk=False) + req = self.wallet.make_payment_request( + amount_sat, + message, + timestamp, + expiry, + address=None, + lightning_invoice=invoice + ) + key = self.wallet.add_payment_request(req, write_to_disk=False) self.wallet.set_label(key, message) self.wallet.save_db() return key @@ -1856,7 +1864,7 @@ class LNWallet(LNWorker): info = self.get_payment_info(payment_hash) return info.status if info else PR_UNPAID - def get_invoice_status(self, invoice: LNInvoice) -> int: + def get_invoice_status(self, invoice: Invoice) -> int: key = invoice.rhash log = self.logs[key] if key in self.inflight_payments: @@ -2073,10 +2081,12 @@ class LNWallet(LNWorker): can_receive = max([c.available_to_spend(REMOTE) for c in channels]) if channels else 0 return Decimal(can_receive) / 1000 - def can_pay_invoice(self, invoice: LNInvoice) -> bool: + def can_pay_invoice(self, invoice: Invoice) -> bool: + assert invoice.is_lightning() return invoice.get_amount_sat() <= self.num_sats_can_send() - def can_receive_invoice(self, invoice: LNInvoice) -> bool: + def can_receive_invoice(self, invoice: Invoice) -> bool: + assert invoice.is_lightning() return invoice.get_amount_sat() <= self.num_sats_can_receive() async def close_channel(self, chan_id): diff --git a/electrum/paymentrequest.py b/electrum/paymentrequest.py index 7d2f63243..d6ac9ca15 100644 --- a/electrum/paymentrequest.py +++ b/electrum/paymentrequest.py @@ -41,7 +41,7 @@ except ImportError: from . import bitcoin, constants, ecc, util, transaction, x509, rsakey from .util import bh2u, bfh, make_aiohttp_session -from .invoices import OnchainInvoice +from .invoices import Invoice from .crypto import sha256 from .bitcoin import address_to_script from .transaction import PartialTxOutput @@ -324,7 +324,7 @@ class PaymentRequest: return False, error -def make_unsigned_request(req: 'OnchainInvoice'): +def make_unsigned_request(req: 'Invoice'): addr = req.get_address() time = req.time exp = req.exp @@ -465,7 +465,7 @@ def serialize_request(req): # FIXME this is broken return pr -def make_request(config: 'SimpleConfig', req: 'OnchainInvoice'): +def make_request(config: 'SimpleConfig', req: 'Invoice'): pr = make_unsigned_request(req) key_path = config.get('ssl_keyfile') cert_path = config.get('ssl_certfile') diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 67004608a..297fd1478 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -90,6 +90,7 @@ class SwapData(StoredObject): prepay_hash = attr.ib(type=Optional[bytes], converter=hex_to_bytes) privkey = attr.ib(type=bytes, converter=hex_to_bytes) lockup_address = attr.ib(type=str) + receive_address = attr.ib(type=str) funding_txid = attr.ib(type=Optional[str]) spending_txid = attr.ib(type=Optional[str]) is_redeemed = attr.ib(type=bool) @@ -213,7 +214,6 @@ class SwapManager(Logger): if amount_sat < dust_threshold(): self.logger.info('utxo value below dust threshold') continue - address = self.wallet.get_receiving_address() if swap.is_reverse: # successful reverse swap preimage = swap.preimage locktime = 0 @@ -224,7 +224,8 @@ class SwapManager(Logger): txin=txin, witness_script=swap.redeem_script, preimage=preimage, - address=address, + privkey=swap.privkey, + address=swap.receive_address, amount_sat=amount_sat, locktime=locktime, ) @@ -330,6 +331,7 @@ class SwapManager(Logger): tx.set_rbf(True) # note: rbf must not decrease payment self.wallet.sign_transaction(tx, password) # save swap data in wallet in case we need a refund + receive_address = self.wallet.get_receiving_address() swap = SwapData( redeem_script = redeem_script, locktime = locktime, @@ -338,6 +340,7 @@ class SwapManager(Logger): prepay_hash = None, lockup_address = lockup_address, onchain_amount = expected_onchain_amount_sat, + receive_address = receive_address, lightning_amount = lightning_amount_sat, is_reverse = False, is_redeemed = False, @@ -429,6 +432,7 @@ class SwapManager(Logger): raise Exception(f"rswap check failed: invoice_amount ({invoice_amount}) " f"not what we requested ({lightning_amount_sat})") # save swap data to wallet file + receive_address = self.wallet.get_receiving_address() swap = SwapData( redeem_script = redeem_script, locktime = locktime, @@ -437,6 +441,7 @@ class SwapManager(Logger): prepay_hash = prepay_hash, lockup_address = lockup_address, onchain_amount = onchain_amount, + receive_address = receive_address, lightning_amount = lightning_amount_sat, is_reverse = True, is_redeemed = False, diff --git a/electrum/wallet.py b/electrum/wallet.py index aafdfa35e..6804de20f 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -73,8 +73,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 .invoices import Invoice, OnchainInvoice, LNInvoice -from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED, PR_TYPE_ONCHAIN, PR_TYPE_LN +from .invoices import Invoice +from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED from .contacts import Contacts from .interface import NetworkException from .mnemonic import Mnemonic @@ -756,7 +756,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def create_invoice(self, *, outputs: List[PartialTxOutput], message, pr, URI) -> Invoice: height=self.get_local_height() if pr: - return OnchainInvoice.from_bip70_payreq(pr, height) + return Invoice.from_bip70_payreq(pr, height) amount = 0 for x in outputs: if parse_max_spend(x.value): @@ -771,25 +771,21 @@ class Abstract_Wallet(AddressSynchronizer, ABC): exp = URI.get('exp') timestamp = timestamp or int(time.time()) exp = exp or 0 - _id = bh2u(sha256d(repr(outputs) + "%d"%timestamp))[0:10] - invoice = OnchainInvoice( - type=PR_TYPE_ONCHAIN, - amount_sat=amount, - outputs=outputs, + invoice = Invoice( + amount_msat=amount*1000, message=message, - id=_id, time=timestamp, exp=exp, + outputs=outputs, bip70=None, - requestor=None, height=height, + lightning_invoice=None, ) return invoice def save_invoice(self, invoice: Invoice) -> None: key = self.get_key_for_outgoing_invoice(invoice) if not invoice.is_lightning(): - assert isinstance(invoice, OnchainInvoice) if self.is_onchain_invoice_paid(invoice, 0): self.logger.info("saving invoice... but it is already paid!") with self.transaction_lock: @@ -821,7 +817,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def import_requests(self, path): data = read_json_file(path) for x in data: - req = Invoice.from_json(x) + req = Invoice(**x) self.add_payment_request(req) def export_requests(self, path): @@ -830,7 +826,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def import_invoices(self, path): data = read_json_file(path) for x in data: - invoice = Invoice.from_json(x) + invoice = Invoice(**x) self.save_invoice(invoice) def export_invoices(self, path): @@ -846,27 +842,25 @@ class Abstract_Wallet(AddressSynchronizer, ABC): relevant_invoice_keys.add(invoice_key) return relevant_invoice_keys - def get_relevant_invoices_for_tx(self, tx: Transaction) -> Sequence[OnchainInvoice]: + def get_relevant_invoices_for_tx(self, tx: Transaction) -> Sequence[Invoice]: invoice_keys = self._get_relevant_invoice_keys_for_tx(tx) invoices = [self.get_invoice(key) for key in invoice_keys] invoices = [inv for inv in invoices if inv] # filter out None for inv in invoices: - assert isinstance(inv, OnchainInvoice), f"unexpected type {type(inv)}" + assert isinstance(inv, Invoice), f"unexpected type {type(inv)}" return invoices def _prepare_onchain_invoice_paid_detection(self): # 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.type == PR_TYPE_ONCHAIN: - assert isinstance(invoice, OnchainInvoice) + if not invoice.is_lightning(): for txout in invoice.outputs: self._invoices_from_scriptpubkey_map[txout.scriptpubkey].add(invoice_key) def _is_onchain_invoice_paid(self, invoice: Invoice, conf: int) -> Tuple[bool, Sequence[str]]: """Returns whether on-chain invoice is satisfied, and list of relevant TXIDs.""" - assert invoice.type == PR_TYPE_ONCHAIN - assert isinstance(invoice, OnchainInvoice) + assert not invoice.is_lightning() invoice_amounts = defaultdict(int) # type: Dict[bytes, int] # scriptpubkey -> value_sats for txo in invoice.outputs: # type: PartialTxOutput invoice_amounts[txo.scriptpubkey] += 1 if parse_max_spend(txo.value) else txo.value @@ -2100,7 +2094,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def delete_address(self, address: str) -> None: raise Exception("this wallet cannot delete addresses") - def get_onchain_request_status(self, r: OnchainInvoice) -> Tuple[bool, Optional[int]]: + def get_onchain_request_status(self, r: Invoice) -> Tuple[bool, Optional[int]]: address = r.get_address() amount = r.get_amount_sat() received, sent = self.get_addr_io(address) @@ -2121,14 +2115,13 @@ class Abstract_Wallet(AddressSynchronizer, ABC): return True, conf return False, None - def get_request_URI(self, req: OnchainInvoice) -> str: + def get_request_URI(self, req: Invoice) -> str: addr = req.get_address() message = self.get_label(addr) - amount = req.amount_sat + amount = req.get_amount_sat() extra_query_params = {} - if req.time: + if req.time and req.exp: 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')) @@ -2139,9 +2132,9 @@ class Abstract_Wallet(AddressSynchronizer, ABC): return str(uri) def check_expired_status(self, r: Invoice, 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(): + #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.get_expiration_date() and r.get_expiration_date() < time.time(): status = PR_EXPIRED return status @@ -2162,10 +2155,8 @@ class Abstract_Wallet(AddressSynchronizer, ABC): if r is None: return PR_UNKNOWN if r.is_lightning(): - assert isinstance(r, LNInvoice) status = self.lnworker.get_payment_status(bfh(r.rhash)) if self.lnworker else PR_UNKNOWN else: - assert isinstance(r, OnchainInvoice) paid, conf = self.get_onchain_request_status(r) if not paid: status = PR_UNPAID @@ -2192,20 +2183,18 @@ class Abstract_Wallet(AddressSynchronizer, ABC): 'is_lightning': is_lightning, 'amount_BTC': format_satoshis(x.get_amount_sat()), 'message': x.message, - 'timestamp': x.time, - 'expiration': x.exp, + 'timestamp': x.get_time(), + 'expiration': x.get_expiry(), 'status': status, 'status_str': status_str, } if is_lightning: - assert isinstance(x, LNInvoice) d['rhash'] = x.rhash d['invoice'] = x.invoice d['amount_msat'] = x.get_amount_msat() if self.lnworker and status == PR_UNPAID: d['can_receive'] = self.lnworker.can_receive_invoice(x) else: - assert isinstance(x, OnchainInvoice) paid, conf = self.get_onchain_request_status(x) d['amount_sat'] = x.get_amount_sat() d['address'] = x.get_address() @@ -2239,13 +2228,11 @@ class Abstract_Wallet(AddressSynchronizer, ABC): 'status_str': status_str, } if is_lightning: - assert isinstance(x, LNInvoice) d['invoice'] = x.invoice d['amount_msat'] = x.get_amount_msat() if self.lnworker and status == PR_UNPAID: d['can_pay'] = self.lnworker.can_pay_invoice(x) else: - assert isinstance(x, OnchainInvoice) amount_sat = x.get_amount_sat() assert isinstance(amount_sat, (int, str, type(None))) d['amount_sat'] = amount_sat @@ -2281,29 +2268,28 @@ class Abstract_Wallet(AddressSynchronizer, ABC): status = self.get_request_status(addr) util.trigger_callback('request_status', self, addr, status) - def make_payment_request(self, address, amount_sat, message, expiration): + def make_payment_request(self, amount_sat, message, timestamp, expiration, address=None, lightning_invoice=None): # TODO maybe merge with wallet.create_invoice()... # note that they use incompatible "id" amount_sat = amount_sat or 0 - timestamp = int(time.time()) - _id = bh2u(sha256d(address + "%d"%timestamp))[0:10] + #_id = bh2u(sha256d(address + "%d"%timestamp))[0:10] expiration = expiration or 0 - return OnchainInvoice( - type=PR_TYPE_ONCHAIN, - outputs=[PartialTxOutput.from_address_and_value(address, amount_sat)], + outputs=[PartialTxOutput.from_address_and_value(address, amount_sat)] if address else [] + return Invoice( + outputs=outputs, message=message, time=timestamp, - amount_sat=amount_sat, + amount_msat=amount_sat*1000, exp=expiration, - id=_id, - bip70=None, - requestor=None, height=self.get_local_height(), + bip70=None, + lightning_invoice=lightning_invoice, ) def sign_payment_request(self, key, alias, alias_addr, password): # FIXME this is broken + raise req = self.receive_requests.get(key) - assert isinstance(req, OnchainInvoice) + assert not req.is_lightning() alias_privkey = self.export_private_key(alias_addr, password) pr = paymentrequest.make_unsigned_request(req) paymentrequest.sign_request_with_alias(pr, alias, alias_privkey) @@ -2316,17 +2302,14 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def get_key_for_outgoing_invoice(cls, invoice: Invoice) -> str: """Return the key to use for this invoice in self.invoices.""" if invoice.is_lightning(): - assert isinstance(invoice, LNInvoice) key = invoice.rhash else: - assert isinstance(invoice, OnchainInvoice) - key = invoice.id + key = bh2u(sha256d(repr(invoice.outputs) + "%d"%invoice.time))[0:10] return key def get_key_for_receive_request(self, req: Invoice, *, sanity_checks: bool = False) -> str: """Return the key to use for this invoice in self.receive_requests.""" if not req.is_lightning(): - assert isinstance(req, OnchainInvoice) addr = req.get_address() if sanity_checks: if not bitcoin.is_address(addr): @@ -2335,7 +2318,6 @@ class Abstract_Wallet(AddressSynchronizer, ABC): raise Exception(_('Address not in wallet.')) key = addr else: - assert isinstance(req, LNInvoice) key = req.rhash return key @@ -2346,7 +2328,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): self.set_label(key, message) # should be a default label if write_to_disk: self.save_db() - return req + return key def delete_request(self, key): """ lightning or on-chain """ diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index 152b642a9..6c84c7291 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -53,7 +53,7 @@ if TYPE_CHECKING: OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 44 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 45 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -193,6 +193,7 @@ class WalletDB(JsonDB): self._convert_version_42() self._convert_version_43() self._convert_version_44() + self._convert_version_45() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure self._after_upgrade_tasks() @@ -864,6 +865,49 @@ class WalletDB(JsonDB): item['channel_type'] = channel_type self.data['seed_version'] = 44 + def _convert_version_45(self): + from .lnaddr import lndecode + if not self._is_upgrade_method_needed(44, 44): + return + swaps = self.data.get('submarine_swaps', {}) + for key, item in swaps.items(): + item['receive_address'] = None + # note: we set height to zero + # the new key for all requests is a wallet address, not done here + for name in ['invoices', 'payment_requests']: + invoices = self.data.get(name, {}) + for key, item in invoices.items(): + is_lightning = item['type'] == 2 + lightning_invoice = item['invoice'] if is_lightning else None + outputs = item['outputs'] if not is_lightning else None + bip70 = item['bip70'] if not is_lightning else None + if is_lightning: + lnaddr = lndecode(item['invoice']) + amount_msat = lnaddr.get_amount_msat() + timestamp = lnaddr.date + exp_delay = lnaddr.get_expiry() + message = lnaddr.get_description() + height = 0 + else: + amount_sat = item['amount_sat'] + amount_msat = amount_sat * 1000 if amount_sat not in [None, '!'] else amount_sat + message = item['message'] + timestamp = item['time'] + exp_delay = item['exp'] + height = item['height'] + + invoices[key] = { + 'amount_msat':amount_msat, + 'message':message, + 'time':timestamp, + 'exp':exp_delay, + 'height':height, + 'outputs':outputs, + 'bip70':bip70, + 'lightning_invoice':lightning_invoice, + } + self.data['seed_version'] = 45 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return @@ -1350,9 +1394,9 @@ class WalletDB(JsonDB): # 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()) + v = dict((k, Invoice(**x)) for k, x in v.items()) if key == 'payment_requests': - v = dict((k, Invoice.from_json(x)) for k, x in v.items()) + v = dict((k, Invoice(**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':