Skip to content

Commit

Permalink
Merge #1510: Improve BIP21 parser, add typehints and improve test cov…
Browse files Browse the repository at this point in the history
…erage

88bd45b Parse URI params in guaranteed order, for duplicates, last one wins (Kristaps Kaupe)
cd1f394 Add test coverage for is_bip21_uri() (Kristaps Kaupe)
6b2a248 Add typehints to BIP21 code (Kristaps Kaupe)

Pull request description:

  First commit adds typehints to BIP21 code, which now I try to do with any code I seriously touch.

  Second commit adds test coverage for `is_bip21_uri()` public function.

  Third commit makes sure we parse URI parameters in guaranteed order, if there are duplicates, last one wins. See bitcoin/bitcoin#27928 for context.

ACKs for top commit:
  AdamISZ:
    tACK 88bd45b

Tree-SHA512: fcc0055dc2e12cff7d9246c6bd0207c7481da1070ca0d15b478456a1a5b57db61820fba3764ce5d9947565ded724fec2ef3c9856ab5055b5d2b0244395517afa
  • Loading branch information
kristapsk committed Aug 19, 2023
2 parents a9fc75e + 88bd45b commit 3e4a69b
Show file tree
Hide file tree
Showing 2 changed files with 42 additions and 14 deletions.
30 changes: 16 additions & 14 deletions jmbitcoin/jmbitcoin/bip21.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,49 +4,51 @@
# this are expected to do address validation independently anyway.

from jmbitcoin import amount_to_sat
from urllib.parse import parse_qs, quote, unquote_plus, urlencode, urlparse
from typing import Dict, List, Tuple, Union
from urllib.parse import parse_qsl, 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 = {}
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


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
26 changes: 26 additions & 0 deletions jmbitcoin/test/test_bip21.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,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(
Expand Down

0 comments on commit 3e4a69b

Please sign in to comment.