diff --git a/electrum/electrumabc/tests/test_bip21.py b/electrum/electrumabc/tests/test_bip21.py --- a/electrum/electrumabc/tests/test_bip21.py +++ b/electrum/electrumabc/tests/test_bip21.py @@ -22,7 +22,7 @@ def test_address(self): self._do_test( "ecash:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma", - {"address": "15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"}, + {"addresses": ["15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"]}, ) def test_testnet(self): @@ -35,31 +35,34 @@ self.assertEqual( parse_URI("ectest:qrh3ethkfms79tlcw7m736t38hp9kg5f7gzncerkcg", net=TestNet), - {"address": "qrh3ethkfms79tlcw7m736t38hp9kg5f7gzncerkcg"}, + {"addresses": ["qrh3ethkfms79tlcw7m736t38hp9kg5f7gzncerkcg"]}, ) self.assertEqual( parse_URI("qrh3ethkfms79tlcw7m736t38hp9kg5f7gzncerkcg", net=TestNet), - {"address": "qrh3ethkfms79tlcw7m736t38hp9kg5f7gzncerkcg"}, + {"addresses": ["qrh3ethkfms79tlcw7m736t38hp9kg5f7gzncerkcg"]}, ) def test_only_address(self): self._do_test( "15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma", - {"address": "15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"}, + {"addresses": ["15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"]}, ) def test_address_label(self): self._do_test( "ecash:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?label=electrum%20test", - {"address": "15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma", "label": "electrum test"}, + { + "addresses": ["15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"], + "label": "electrum test", + }, ) def test_address_message(self): self._do_test( "ecash:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?message=electrum%20test", { - "address": "15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma", + "addresses": ["15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"], "message": "electrum test", }, ) @@ -67,14 +70,14 @@ def test_address_amount(self): self._do_test( "ecash:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?amount=1.03", - {"address": "15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma", "amount": 103}, + {"addresses": ["15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"], "amounts": [103]}, ) def test_address_request_url(self): self._do_test( "ecash:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?r=http://domain.tld/page?h%3D2a8628fc2fbe", { - "address": "15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma", + "addresses": ["15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"], "r": "http://domain.tld/page?h=2a8628fc2fbe", }, ) @@ -82,7 +85,7 @@ def test_ignore_args(self): self._do_test( "ecash:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?test=test", - {"address": "15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma", "test": "test"}, + {"addresses": ["15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"], "test": "test"}, ) def test_multiple_args(self): @@ -94,8 +97,8 @@ "test=none&" "r=http://domain.tld/page", { - "address": "15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma", - "amount": 1004, + "addresses": ["15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma"], + "amounts": [1004], "label": "electrum-test", "message": "electrum test", "r": "http://domain.tld/page", @@ -117,27 +120,27 @@ Exception, parse_URI, "notvalid:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma" ) - def test_parameter_polution(self): - # amount specified twice + def test_parameter_pollution(self): + # label specified twice self.assertRaises( - Exception, + DuplicateKeyInURIError, parse_URI, - "ecash:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?amount=0.0003&label=test&" - "amount=30.0", + "ecash:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?label=spam&amount=0.0003&" + "label=foo", ) def test_op_return(self): self._do_test( "ecash:qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme?op_return_raw=04deadbeef", { - "address": "qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme", + "addresses": ["qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme"], "op_return_raw": "04deadbeef", }, ) self._do_test( "ecash:qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme?op_return=payment%20for%20invoice%20%2342-1337", { - "address": "qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme", + "addresses": ["qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme"], "op_return": "payment for invoice #42-1337", }, ) @@ -152,11 +155,63 @@ self._do_test( "ecash:qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme?op_return=spam&op_return_raw=04deadbeef", { - "address": "qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme", + "addresses": ["qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme"], "op_return": "spam", }, ) + self._do_test( + "ecash:?op_return_raw=04deadbeef", + {"op_return_raw": "04deadbeef"}, + ) + + def test_multiple_outputs(self): + self._do_test( + "ecash:qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme?amount=100&" + "op_return_raw=0401020304&" + "addr=qz252dlyuzfqk7k35f57csamlgxc23ahz5accatyk9&amount=200&" + "addr=qzrseeup3rhehuaf9e6nr3sgm6t5eegufuuht750at&amount=300", + { + "addresses": [ + "qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme", + "qz252dlyuzfqk7k35f57csamlgxc23ahz5accatyk9", + "qzrseeup3rhehuaf9e6nr3sgm6t5eegufuuht750at", + ], + "amounts": [10_000, 20_000, 30_000], + "op_return_raw": "0401020304", + }, + ) + + def test_inconsistent_multiple_outputs(self): + # amount specified twice for single address + self.assertRaises( + BadURIParameter, + parse_URI, + "ecash:qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme?amount=40.00&label=test&" + "amount=30.00", + ) + # multiple addresses, not enough amounts + self.assertRaises( + BadURIParameter, + parse_URI, + "ecash:qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme?amount=40.00&" + "addr=qz252dlyuzfqk7k35f57csamlgxc23ahz5accatyk9", + ) + self.assertRaises( + BadURIParameter, + parse_URI, + "ecash:qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme?" + "addr=qz252dlyuzfqk7k35f57csamlgxc23ahz5accatyk9", + ) + # 2 addresses, 3 amounts + self.assertRaises( + BadURIParameter, + parse_URI, + "ecash:qrh3ethkfms79tlcw7m736t38hp9kg5f7gycxeymme?amount=40.00&" + "addr=qz252dlyuzfqk7k35f57csamlgxc23ahz5accatyk9&amount=30.00&" + "amount=20.00", + ) + class TestCreateURI(unittest.TestCase): def _do_test(self, args, kwargs, expected_uri: str): diff --git a/electrum/electrumabc/web.py b/electrum/electrumabc/web.py --- a/electrum/electrumabc/web.py +++ b/electrum/electrumabc/web.py @@ -243,7 +243,7 @@ if ":" not in uri: # Test it's valid Address.from_string(uri, net=net) - return {"address": uri} + return {"addresses": [uri]} u = urllib.parse.urlparse(uri) # The scheme always comes back in lower case @@ -262,28 +262,44 @@ pq = urllib.parse.parse_qs(u.query, keep_blank_values=True) for k, v in pq.items(): - if len(v) != 1: + if len(v) != 1 and k not in ["addr", "amount"]: raise DuplicateKeyInURIError(_("Duplicate key in URI"), k) - out = {k: v[0] for k, v in pq.items()} + out = {k: v[0] for k, v in pq.items() if k not in ("addr", "amount")} + + addresses = [] if address: - # validate + addresses.append(address) + # There are maybe more addresses in the URI provided with the addr param + addresses += pq.get("addr", []) + # validate all addresses + for addr in addresses: try: - Address.from_string(address, net=net) + Address.from_string(addr, net=net) except Exception as e: raise BadURIParameter("address", e) from e - out["address"] = address + if addresses: + out["addresses"] = addresses + + amounts = pq.get("amount", []) + if len(addresses) != 1 and len(amounts) != len(addresses): + raise BadURIParameter( + "mismatching number of addresses and amounts in multi outputs URI" + ) + if len(addresses) == 1 and len(amounts) > 1: + raise BadURIParameter("Single output URI must have 0 or 1 amount") - if "amount" in out: + if amounts: + out["amounts"] = [] + for am in amounts: try: - am = out["amount"] m = re.match(r"([0-9.]+)X([0-9]{2})", am) if m: k = int(m.group(2)) - 2 amount = decimal.Decimal(m.group(1)) * int(pow(10, k)) else: amount = decimal.Decimal(am) * int(bitcoin.CASH) - out["amount"] = int(amount) + out["amounts"].append(int(amount)) except (ValueError, decimal.InvalidOperation, TypeError) as e: raise BadURIParameter("amount", e) from e @@ -329,8 +345,8 @@ if strict: accept_keys = { "r", - "address", - "amount", + "addresses", + "amounts", "label", "message", "op_return", diff --git a/electrum/electrumabc_gui/qt/main_window.py b/electrum/electrumabc_gui/qt/main_window.py --- a/electrum/electrumabc_gui/qt/main_window.py +++ b/electrum/electrumabc_gui/qt/main_window.py @@ -2946,8 +2946,19 @@ if r: self.prepare_for_payment_request() return - address = out.get("address") - amount = out.get("amount") + addresses = out.get("addresses", []) + amounts = out.get("amounts", []) + if (len(addresses) == 1 and len(amounts) > 1) or ( + len(addresses) != 1 and len(addresses) != len(amounts) + ): + ShowPopupLabel( + name="`Pay to` error", + text=_("Inconsistent number of addresses and amounts in ecash URI:") + + f" {len(addresses)} addresses and {len(amounts)} amounts", + target=self.payto_e, + timeout=5000, + ) + return label = out.get("label") message = out.get("message") op_return = out.get("op_return") @@ -2956,16 +2967,33 @@ # use label as description (not BIP21 compliant) if label and not message: message = label - if address or URI.strip().lower().split(":", 1)[0] in web.parseable_schemes(): + if len(amounts) == 1: + self.amount_e.setAmount(amounts[0]) + self.amount_e.textEdited.emit("") + + if len(addresses) == 1: # if address, set the payto field to the address. + self.payto_e.setText(addresses[0]) + elif ( + len(addresses) == 0 + and URI.strip().lower().split(":", 1)[0] in web.parseable_schemes() + ): # if *not* address, then we set the payto field to the empty string # only IFF it was ecash:, see issue Electron-Cash#1131. - self.payto_e.setText(address or "") + self.payto_e.setText("") + elif len(addresses) > 1: + # For multiple outputs, we fill the payto field with the expected CSV + # string. Note that amounts are in sats and we convert them to XEC. + assert len(addresses) == len(amounts) + self.payto_e.setText( + "\n".join( + f"{addr}, {format_satoshis_plain(amount, self.get_decimal_point())}" + for addr, amount in zip(addresses, amounts) + ) + ) + if message: self.message_e.setText(message) - if amount: - self.amount_e.setAmount(amount) - self.amount_e.textEdited.emit("") if op_return: self.message_opreturn_e.setText(op_return) self.message_opreturn_e.setHidden(False)