tpaytoedit.py - electrum - Electrum Bitcoin wallet
HTML git clone https://git.parazyd.org/electrum
DIR Log
DIR Files
DIR Refs
DIR Submodules
---
tpaytoedit.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