electrum

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

paytoedit.py (10710B)


      1 #!/usr/bin/env python
      2 #
      3 # Electrum - lightweight Bitcoin client
      4 # Copyright (C) 2012 thomasv@gitorious
      5 #
      6 # Permission is hereby granted, free of charge, to any person
      7 # obtaining a copy of this software and associated documentation files
      8 # (the "Software"), to deal in the Software without restriction,
      9 # including without limitation the rights to use, copy, modify, merge,
     10 # publish, distribute, sublicense, and/or sell copies of the Software,
     11 # and to permit persons to whom the Software is furnished to do so,
     12 # subject to the following conditions:
     13 #
     14 # The above copyright notice and this permission notice shall be
     15 # included in all copies or substantial portions of the Software.
     16 #
     17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
     18 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
     19 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
     20 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
     21 # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
     22 # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
     23 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
     24 # SOFTWARE.
     25 
     26 import re
     27 import decimal
     28 from decimal import Decimal
     29 from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING
     30 
     31 from PyQt5.QtGui import QFontMetrics, QFont
     32 
     33 from electrum import bitcoin
     34 from electrum.util import bfh, maybe_extract_bolt11_invoice, BITCOIN_BIP21_URI_SCHEME
     35 from electrum.transaction import PartialTxOutput
     36 from electrum.bitcoin import opcodes, construct_script
     37 from electrum.logging import Logger
     38 from electrum.lnaddr import LnDecodeException
     39 
     40 from .qrtextedit import ScanQRTextEdit
     41 from .completion_text_edit import CompletionTextEdit
     42 from . import util
     43 from .util import MONOSPACE_FONT
     44 
     45 if TYPE_CHECKING:
     46     from .main_window import ElectrumWindow
     47 
     48 
     49 RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>'
     50 
     51 frozen_style = "QWidget {border:none;}"
     52 normal_style = "QPlainTextEdit { }"
     53 
     54 
     55 class PayToLineError(NamedTuple):
     56     line_content: str
     57     exc: Exception
     58     idx: int = 0  # index of line
     59     is_multiline: bool = False
     60 
     61 
     62 class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
     63 
     64     def __init__(self, win: 'ElectrumWindow'):
     65         CompletionTextEdit.__init__(self)
     66         ScanQRTextEdit.__init__(self, config=win.config)
     67         Logger.__init__(self)
     68         self.win = win
     69         self.amount_edit = win.amount_e
     70         self.setFont(QFont(MONOSPACE_FONT))
     71         self.document().contentsChanged.connect(self.update_size)
     72         self.heightMin = 0
     73         self.heightMax = 150
     74         self.c = None
     75         self.textChanged.connect(self.check_text)
     76         self.outputs = []  # type: List[PartialTxOutput]
     77         self.errors = []  # type: List[PayToLineError]
     78         self.is_pr = False
     79         self.is_alias = False
     80         self.update_size()
     81         self.payto_scriptpubkey = None  # type: Optional[bytes]
     82         self.lightning_invoice = None
     83         self.previous_payto = ''
     84 
     85     def setFrozen(self, b):
     86         self.setReadOnly(b)
     87         self.setStyleSheet(frozen_style if b else normal_style)
     88         for button in self.buttons:
     89             button.setHidden(b)
     90 
     91     def setGreen(self):
     92         self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True))
     93 
     94     def setExpired(self):
     95         self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True))
     96 
     97     def parse_address_and_amount(self, line) -> PartialTxOutput:
     98         try:
     99             x, y = line.split(',')
    100         except ValueError:
    101             raise Exception("expected two comma-separated values: (address, amount)") from None
    102         scriptpubkey = self.parse_output(x)
    103         amount = self.parse_amount(y)
    104         return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)
    105 
    106     def parse_output(self, x) -> bytes:
    107         try:
    108             address = self.parse_address(x)
    109             return bfh(bitcoin.address_to_script(address))
    110         except Exception:
    111             pass
    112         try:
    113             script = self.parse_script(x)
    114             return bfh(script)
    115         except Exception:
    116             pass
    117         raise Exception("Invalid address or script.")
    118 
    119     def parse_script(self, x):
    120         script = ''
    121         for word in x.split():
    122             if word[0:3] == 'OP_':
    123                 opcode_int = opcodes[word]
    124                 script += construct_script([opcode_int])
    125             else:
    126                 bfh(word)  # to test it is hex data
    127                 script += construct_script([word])
    128         return script
    129 
    130     def parse_amount(self, x):
    131         x = x.strip()
    132         if not x:
    133             raise Exception("Amount is empty")
    134         if x == '!':
    135             return '!'
    136         p = pow(10, self.amount_edit.decimal_point())
    137         try:
    138             return int(p * Decimal(x))
    139         except decimal.InvalidOperation:
    140             raise Exception("Invalid amount")
    141 
    142     def parse_address(self, line):
    143         r = line.strip()
    144         m = re.match('^'+RE_ALIAS+'$', r)
    145         address = str(m.group(2) if m else r)
    146         assert bitcoin.is_address(address)
    147         return address
    148 
    149     def check_text(self):
    150         self.errors = []
    151         if self.is_pr:
    152             return
    153         # filter out empty lines
    154         lines = [i for i in self.lines() if i]
    155 
    156         self.payto_scriptpubkey = None
    157         self.lightning_invoice = None
    158         self.outputs = []
    159 
    160         if len(lines) == 1:
    161             data = lines[0]
    162             # try bip21 URI
    163             if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
    164                 self.win.pay_to_URI(data)
    165                 return
    166             # try LN invoice
    167             bolt11_invoice = maybe_extract_bolt11_invoice(data)
    168             if bolt11_invoice is not None:
    169                 try:
    170                     self.win.parse_lightning_invoice(bolt11_invoice)
    171                 except LnDecodeException as e:
    172                     self.errors.append(PayToLineError(line_content=data, exc=e))
    173                 else:
    174                     self.lightning_invoice = bolt11_invoice
    175                 return
    176             # try "address, amount" on-chain format
    177             try:
    178                 self._parse_as_multiline(lines, raise_errors=True)
    179             except Exception as e:
    180                 pass
    181             else:
    182                 return
    183             # try address/script
    184             try:
    185                 self.payto_scriptpubkey = self.parse_output(data)
    186             except Exception as e:
    187                 self.errors.append(PayToLineError(line_content=data, exc=e))
    188             else:
    189                 self.win.set_onchain(True)
    190                 self.win.lock_amount(False)
    191                 return
    192         else:
    193             # there are multiple lines
    194             self._parse_as_multiline(lines, raise_errors=False)
    195 
    196     def _parse_as_multiline(self, lines, *, raise_errors: bool):
    197         outputs = []  # type: List[PartialTxOutput]
    198         total = 0
    199         is_max = False
    200         for i, line in enumerate(lines):
    201             try:
    202                 output = self.parse_address_and_amount(line)
    203             except Exception as e:
    204                 if raise_errors:
    205                     raise
    206                 else:
    207                     self.errors.append(PayToLineError(
    208                         idx=i, line_content=line.strip(), exc=e, is_multiline=True))
    209                     continue
    210             outputs.append(output)
    211             if output.value == '!':
    212                 is_max = True
    213             else:
    214                 total += output.value
    215         if outputs:
    216             self.win.set_onchain(True)
    217 
    218         self.win.max_button.setChecked(is_max)
    219         self.outputs = outputs
    220         self.payto_scriptpubkey = None
    221 
    222         if self.win.max_button.isChecked():
    223             self.win.spend_max()
    224         else:
    225             self.amount_edit.setAmount(total if outputs else None)
    226         self.win.lock_amount(self.win.max_button.isChecked() or bool(outputs))
    227 
    228     def get_errors(self) -> Sequence[PayToLineError]:
    229         return self.errors
    230 
    231     def get_destination_scriptpubkey(self) -> Optional[bytes]:
    232         return self.payto_scriptpubkey
    233 
    234     def get_outputs(self, is_max):
    235         if self.payto_scriptpubkey:
    236             if is_max:
    237                 amount = '!'
    238             else:
    239                 amount = self.amount_edit.get_amount()
    240             self.outputs = [PartialTxOutput(scriptpubkey=self.payto_scriptpubkey, value=amount)]
    241 
    242         return self.outputs[:]
    243 
    244     def lines(self):
    245         return self.toPlainText().split('\n')
    246 
    247     def is_multiline(self):
    248         return len(self.lines()) > 1
    249 
    250     def paytomany(self):
    251         self.setText("\n\n\n")
    252         self.update_size()
    253 
    254     def update_size(self):
    255         lineHeight = QFontMetrics(self.document().defaultFont()).height()
    256         docHeight = self.document().size().height()
    257         h = round(docHeight * lineHeight + 11)
    258         h = min(max(h, self.heightMin), self.heightMax)
    259         self.setMinimumHeight(h)
    260         self.setMaximumHeight(h)
    261         self.verticalScrollBar().hide()
    262 
    263     def qr_input(self):
    264         data = super(PayToEdit,self).qr_input()
    265         if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
    266             self.win.pay_to_URI(data)
    267             # TODO: update fee
    268 
    269     def resolve(self):
    270         self.is_alias = False
    271         if self.hasFocus():
    272             return
    273         if self.is_multiline():  # only supports single line entries atm
    274             return
    275         if self.is_pr:
    276             return
    277         key = str(self.toPlainText())
    278         key = key.strip()  # strip whitespaces
    279         if key == self.previous_payto:
    280             return
    281         self.previous_payto = key
    282         if not (('.' in key) and (not '<' in key) and (not ' ' in key)):
    283             return
    284         parts = key.split(sep=',')  # assuming single line
    285         if parts and len(parts) > 0 and bitcoin.is_address(parts[0]):
    286             return
    287         try:
    288             data = self.win.contacts.resolve(key)
    289         except Exception as e:
    290             self.logger.info(f'error resolving address/alias: {repr(e)}')
    291             return
    292         if not data:
    293             return
    294         self.is_alias = True
    295 
    296         address = data.get('address')
    297         name = data.get('name')
    298         new_url = key + ' <' + address + '>'
    299         self.setText(new_url)
    300         self.previous_payto = new_url
    301 
    302         #if self.win.config.get('openalias_autoadd') == 'checked':
    303         self.win.contacts[key] = ('openalias', name)
    304         self.win.contact_list.update()
    305 
    306         self.setFrozen(True)
    307         if data.get('type') == 'openalias':
    308             self.validated = data.get('validated')
    309             if self.validated:
    310                 self.setGreen()
    311             else:
    312                 self.setExpired()
    313         else:
    314             self.validated = None