From 6b2a248bb58c58bd824e60232954cc8bb903e207 Mon Sep 17 00:00:00 2001 From: Kristaps Kaupe Date: Thu, 20 Jul 2023 00:36:40 +0300 Subject: [PATCH 1/3] Add typehints to BIP21 code --- jmbitcoin/jmbitcoin/bip21.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/jmbitcoin/jmbitcoin/bip21.py b/jmbitcoin/jmbitcoin/bip21.py index c618254a2..5aea31b95 100644 --- a/jmbitcoin/jmbitcoin/bip21.py +++ b/jmbitcoin/jmbitcoin/bip21.py @@ -4,25 +4,26 @@ # this are expected to do address validation independently anyway. from jmbitcoin import amount_to_sat +from typing import Dict, List, Tuple, Union from urllib.parse import parse_qs, quote, unquote_plus, urlencode, urlparse import re -def is_bip21_uri(uri): +def is_bip21_uri(uri: str) -> bool: parsed = urlparse(uri) return parsed.scheme.lower() == 'bitcoin' and parsed.path != '' -def is_bip21_amount_str(amount): +def _is_bip21_amount_str(amount: str) -> bool: return re.compile(r"^[0-9]{1,8}(\.[0-9]{1,8})?$").match(str(amount)) != None -def validate_bip21_amount(amount): - if not is_bip21_amount_str(amount): +def _validate_bip21_amount(amount: str) -> None: + if not _is_bip21_amount_str(amount): raise ValueError("Invalid BTC amount " + str(amount)) -def decode_bip21_uri(uri): +def decode_bip21_uri(uri: str) -> Dict[str, Union[str, int]]: if not is_bip21_uri(uri): raise ValueError("Not a valid BIP21 URI: " + uri) result = {} @@ -35,7 +36,7 @@ def decode_bip21_uri(uri): " in BIP21 URI.") if key == 'amount': amount_str = params['amount'][0] - validate_bip21_amount(amount_str) + _validate_bip21_amount(amount_str) # Convert amount to sats, as used internally by JM result['amount'] = amount_to_sat(amount_str + "btc") else: @@ -43,10 +44,12 @@ def decode_bip21_uri(uri): return result -def encode_bip21_uri(address, params, safe=""): +def encode_bip21_uri(address: str, + params: Union[dict, List[Tuple[str, Union[float, int, str]]]], + safe: str = "") -> str: uri = 'bitcoin:' + address if len(params) > 0: if 'amount' in params: - validate_bip21_amount(params['amount']) + _validate_bip21_amount(params['amount']) uri += '?' + urlencode(params, safe=safe, quote_via=quote) return uri From cd1f394381f03f077d6e03ba98ae4e7f891795ad Mon Sep 17 00:00:00 2001 From: Kristaps Kaupe Date: Thu, 20 Jul 2023 00:47:56 +0300 Subject: [PATCH 2/3] Add test coverage for is_bip21_uri() --- jmbitcoin/test/test_bip21.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/jmbitcoin/test/test_bip21.py b/jmbitcoin/test/test_bip21.py index 711b219f5..87027ca9e 100644 --- a/jmbitcoin/test/test_bip21.py +++ b/jmbitcoin/test/test_bip21.py @@ -2,6 +2,21 @@ import pytest +def test_is_bip21_uri(): + # invalid URIs + assert(not btc.is_bip21_uri('')) + assert(not btc.is_bip21_uri('nfdjksnfjkdsnfjkds')) + assert(not btc.is_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W')) + assert(not btc.is_bip21_uri('175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?amount=20.3')) + assert(not btc.is_bip21_uri('bitcoin:')) + assert(not btc.is_bip21_uri('bitcoin:?amount=20.3')) + # valid URIs + assert(btc.is_bip21_uri('bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W')) + assert(btc.is_bip21_uri('BITCOIN:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W')) + assert(btc.is_bip21_uri('BitCoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W')) + assert(btc.is_bip21_uri('bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?label=Luke-Jr')) + + def test_bip21_decode(): # These should raise exception because of not being valid BIP21 URI's From 88bd45b02e1e5dd9184c469107ebe9f7e2db9908 Mon Sep 17 00:00:00 2001 From: Kristaps Kaupe Date: Thu, 20 Jul 2023 01:57:52 +0300 Subject: [PATCH 3/3] Parse URI params in guaranteed order, for duplicates, last one wins --- jmbitcoin/jmbitcoin/bip21.py | 13 ++++++------- jmbitcoin/test/test_bip21.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/jmbitcoin/jmbitcoin/bip21.py b/jmbitcoin/jmbitcoin/bip21.py index 5aea31b95..e92cbafda 100644 --- a/jmbitcoin/jmbitcoin/bip21.py +++ b/jmbitcoin/jmbitcoin/bip21.py @@ -5,7 +5,7 @@ from jmbitcoin import amount_to_sat from typing import Dict, List, Tuple, Union -from urllib.parse import parse_qs, quote, unquote_plus, urlencode, urlparse +from urllib.parse import parse_qsl, quote, unquote_plus, urlencode, urlparse import re @@ -29,18 +29,17 @@ def decode_bip21_uri(uri: str) -> Dict[str, Union[str, int]]: result = {} parsed = urlparse(uri) result['address'] = parsed.path - params = parse_qs(parsed.query) - for key in params: + params = parse_qsl(parsed.query) + for key, value in params: if key.startswith('req-'): raise ValueError("Unknown required parameter " + key + " in BIP21 URI.") if key == 'amount': - amount_str = params['amount'][0] - _validate_bip21_amount(amount_str) + _validate_bip21_amount(value) # Convert amount to sats, as used internally by JM - result['amount'] = amount_to_sat(amount_str + "btc") + result['amount'] = amount_to_sat(value + "btc") else: - result[key] = unquote_plus(params[key][0]) + result[key] = unquote_plus(value) return result diff --git a/jmbitcoin/test/test_bip21.py b/jmbitcoin/test/test_bip21.py index 87027ca9e..b902741d5 100644 --- a/jmbitcoin/test/test_bip21.py +++ b/jmbitcoin/test/test_bip21.py @@ -75,6 +75,17 @@ def test_bip21_decode(): assert(parsed['somethingyoudontunderstand'] == '50') assert(parsed['somethingelseyoudontget'] == '999') + # Test multiple amount parameters, last value should win. + parsed = btc.decode_bip21_uri( + 'bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?amount=20.3&amount=50&label=Luke-Jr') + assert(parsed['address'] == '175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W') + assert(parsed['amount'] == 5000000000) + assert(parsed['label'] == 'Luke-Jr') + # Here are two amount parameters, first valid, second not valid, so URI is not valid. + with pytest.raises(ValueError): + btc.decode_bip21_uri( + 'bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?amount=20.3&amount=100,000&label=Luke-Jr') + def test_bip21_encode(): assert(