diff --git a/jmbitcoin/jmbitcoin/bip21.py b/jmbitcoin/jmbitcoin/bip21.py index 58a726d..910cc3e 100644 --- a/jmbitcoin/jmbitcoin/bip21.py +++ b/jmbitcoin/jmbitcoin/bip21.py @@ -4,7 +4,7 @@ # this are expected to do address validation independently anyway. from jmbitcoin import amount_to_sat -from urllib.parse import parse_qs, urlparse +from urllib.parse import quote, parse_qs, urlencode, urlparse from url_decode import urldecode import re @@ -15,7 +15,12 @@ def is_bip21_uri(uri): def is_bip21_amount_str(amount): - return re.compile("^[0-9]{1,8}(\.[0-9]{1,8})?$").match(amount) != None + return re.compile("^[0-9]{1,8}(\.[0-9]{1,8})?$").match(str(amount)) != None + + +def validate_bip21_amount(amount): + if not is_bip21_amount_str(amount): + raise ValueError("Invalid BTC amount " + str(amount)) def decode_bip21_uri(uri): @@ -31,11 +36,18 @@ def decode_bip21_uri(uri): " in BIP21 URI.") if key == 'amount': amount_str = params['amount'][0] - if not is_bip21_amount_str(amount_str): - raise ValueError("Invalid BTC amount " + amount_str + - " vs. " + str(amount_to_sat(amount_str)) + " sat") + validate_bip21_amount(amount_str) # Convert amount to sats, as used internally by JM result['amount'] = amount_to_sat(amount_str + "btc") else: result[key] = urldecode(params[key][0]) return result + + +def encode_bip21_uri(address, params): + uri = 'bitcoin:' + address + if len(params) > 0: + if 'amount' in params: + validate_bip21_amount(params['amount']) + uri += '?' + urlencode(params, quote_via=quote) + return uri diff --git a/jmbitcoin/setup.py b/jmbitcoin/setup.py index dd9b055..9b6be4d 100644 --- a/jmbitcoin/setup.py +++ b/jmbitcoin/setup.py @@ -10,4 +10,5 @@ setup(name='joinmarketbitcoin', license='GPL', packages=['jmbitcoin'], install_requires=['future', 'coincurve', 'urldecode'], + python_requires='>=3.5', zip_safe=False) diff --git a/jmbitcoin/test/test_bip21.py b/jmbitcoin/test/test_bip21.py index 3faf4ca..181e34e 100644 --- a/jmbitcoin/test/test_bip21.py +++ b/jmbitcoin/test/test_bip21.py @@ -2,7 +2,7 @@ import jmbitcoin as btc import pytest -def test_bip21(): +def test_bip21_decode(): # These should raise exception because of not being valid BIP21 URI's with pytest.raises(ValueError): @@ -56,3 +56,61 @@ def test_bip21(): assert(parsed['somethingyoudontunderstand'] == '50') assert(parsed['somethingelseyoudontget'] == '999') + +def test_bip21_encode(): + assert( + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', {}) == + 'bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W' + ) + assert( + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'label': 'Luke-Jr' + }) == + 'bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?label=Luke-Jr' + ) + assert( + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'amount': 20.3, + 'label': 'Luke-Jr' + }) == + 'bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?amount=20.3&label=Luke-Jr' + ) + assert( + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'amount': 50, + 'label': 'Luke-Jr', + 'message': 'Donation for project xyz' + }) == + 'bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz' + ) + assert( + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'req-somethingyoudontunderstand': 50, + 'req-somethingelseyoudontget': 999 + }) == + 'bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?req-somethingyoudontunderstand=50&req-somethingelseyoudontget=999' + ) + assert( + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'somethingyoudontunderstand': 50, + 'somethingelseyoudontget': 999 + }) == + 'bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?somethingyoudontunderstand=50&somethingelseyoudontget=999' + ) + # Invalid amounts must raise ValueError + with pytest.raises(ValueError): + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'amount': '' + }) + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'amount': 'XYZ' + }) + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'amount': '100\'000' + }) + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'amount': '100,000' + }) + btc.encode_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', { + 'amount': '100000000' + }) diff --git a/jmclient/jmclient/maker.py b/jmclient/jmclient/maker.py index 5a3c6f3..c19c5a4 100644 --- a/jmclient/jmclient/maker.py +++ b/jmclient/jmclient/maker.py @@ -328,10 +328,16 @@ class P2EPMaker(Maker): self.receiving_amount) + " satoshis.") self.user_info("The sender also needs to know your ephemeral " "nickname: " + jm_single().nickname) - self.user_info("This information has been stored in a file payjoin.txt;" + receive_uri = btc.encode_bip21_uri(self.destination_addr, { + 'amount': btc.sat_to_btc(self.receiving_amount), + 'jmnick': jm_single().nickname + }) + self.user_info("Receive URI: " + receive_uri) + self.user_info("This information has also been stored in a file payjoin.txt;" " send it to your counterparty when you are ready.") with open("payjoin.txt", "w") as f: f.write("Payjoin transfer details:\n\n") + f.write("Receive URI: " + receive_uri + "\n") f.write("Address: " + self.destination_addr + "\n") f.write("Amount (in sats): " + str(self.receiving_amount) + "\n") f.write("Receiver nick: " + jm_single().nickname + "\n") diff --git a/scripts/sendpayment.py b/scripts/sendpayment.py index 5fc2c54..ed6d6c5 100644 --- a/scripts/sendpayment.py +++ b/scripts/sendpayment.py @@ -74,6 +74,8 @@ def main(): parser.error("Given BIP21 URI does not contain amount.") sys.exit(EXIT_ARGERROR) destaddr = parsed['address'] + if 'jmnick' in parsed: + options.p2ep = parsed['jmnick'] else: amount = btc.amount_to_sat(args[1]) if amount == 0: