1#!/usr/local/bin/python3.8 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 26import re 27import decimal 28from decimal import Decimal 29from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING 30 31from PyQt5.QtGui import QFontMetrics, QFont 32 33from electrum import bitcoin 34from electrum.util import bfh, maybe_extract_bolt11_invoice, BITCOIN_BIP21_URI_SCHEME 35from electrum.transaction import PartialTxOutput 36from electrum.bitcoin import opcodes, construct_script 37from electrum.logging import Logger 38from electrum.lnaddr import LnDecodeException 39 40from .qrtextedit import ScanQRTextEdit 41from .completion_text_edit import CompletionTextEdit 42from . import util 43from .util import MONOSPACE_FONT 44 45if TYPE_CHECKING: 46 from .main_window import ElectrumWindow 47 48 49RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' 50 51frozen_style = "QWidget {border:none;}" 52normal_style = "QPlainTextEdit { }" 53 54 55class PayToLineError(NamedTuple): 56 line_content: str 57 exc: Exception 58 idx: int = 0 # index of line 59 is_multiline: bool = False 60 61 62class 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, *, callback=None): 264 def _on_qr_success(data): 265 if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): 266 self.win.pay_to_URI(data) 267 # TODO: update fee 268 super(PayToEdit, self).qr_input(callback=_on_qr_success) 269 270 def resolve(self): 271 self.is_alias = False 272 if self.hasFocus(): 273 return 274 if self.is_multiline(): # only supports single line entries atm 275 return 276 if self.is_pr: 277 return 278 key = str(self.toPlainText()) 279 key = key.strip() # strip whitespaces 280 if key == self.previous_payto: 281 return 282 self.previous_payto = key 283 if not (('.' in key) and (not '<' in key) and (not ' ' in key)): 284 return 285 parts = key.split(sep=',') # assuming single line 286 if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): 287 return 288 try: 289 data = self.win.contacts.resolve(key) 290 except Exception as e: 291 self.logger.info(f'error resolving address/alias: {repr(e)}') 292 return 293 if not data: 294 return 295 self.is_alias = True 296 297 address = data.get('address') 298 name = data.get('name') 299 new_url = key + ' <' + address + '>' 300 self.setText(new_url) 301 self.previous_payto = new_url 302 303 #if self.win.config.get('openalias_autoadd') == 'checked': 304 self.win.contacts[key] = ('openalias', name) 305 self.win.contact_list.update() 306 307 self.setFrozen(True) 308 if data.get('type') == 'openalias': 309 self.validated = data.get('validated') 310 if self.validated: 311 self.setGreen() 312 else: 313 self.setExpired() 314 else: 315 self.validated = None 316