From a8298d9712708e74848676fea3e4a80290e2b141 Mon Sep 17 00:00:00 2001 From: Taran Pal Singh <36taransingh5@gmail.com> Date: Fri, 23 Oct 2026 16:44:32 +0100 Subject: [PATCH 1/2] Accept uniformly uppercase SegWit addresses Normalize only uniformly cased ASCII SegWit strings, retaining rejection of mixed case and leaving legacy Base58 validation unchanged. --- src/validators/crypto_addresses/btc_address.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/validators/crypto_addresses/btc_address.py b/src/validators/crypto_addresses/btc_address.py index f781f3b9..8eeb428c 100644 --- a/src/validators/crypto_addresses/btc_address.py +++ b/src/validators/crypto_addresses/btc_address.py @@ -48,9 +48,10 @@ def btc_address(value: str, /): if not value: return False - return ( - # segwit pattern - re.compile(r"^(bc|tb)[0-3][02-9ac-hj-np-z]{14,74}$").fullmatch(value) - if value[:2] in ("bc", "tb") - else _validate_old_btc_address(value) - ) + if value[:2].lower() in ("bc", "tb"): + # BIP 173 allows uniform upper/lowercase, but never mixed case. + if not value.isascii() or not (value.islower() or value.isupper()): + return False + return re.fullmatch(r"(bc|tb)[0-3][02-9ac-hj-np-z]{14,74}", value.lower()) + + return _validate_old_btc_address(value) From 7c0897afe9ebebb1fa025edd3848a7234f80404a Mon Sep 17 00:00:00 2001 From: Taran Pal Singh <36taransingh5@gmail.com> Date: Fri, 23 Oct 2026 16:45:55 +0100 Subject: [PATCH 2/2] Test SegWit case handling and ASCII boundaries Cover uniformly uppercase and lowercase BIP 173 examples, mixed-case rejection, trailing newlines and Unicode case lookalikes. --- tests/crypto_addresses/test_btc_address.py | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/crypto_addresses/test_btc_address.py b/tests/crypto_addresses/test_btc_address.py index 96647eab..66603ef2 100644 --- a/tests/crypto_addresses/test_btc_address.py +++ b/tests/crypto_addresses/test_btc_address.py @@ -41,3 +41,48 @@ def test_returns_true_on_valid_btc_address(value: str): def test_returns_failed_validation_on_invalid_btc_address(value: str): """Test returns failed validation on invalid btc address.""" assert isinstance(btc_address(value), ValidationError) + + +@pytest.mark.parametrize( + "value", + [ + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3", + "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", + ], +) +def test_segwit_accepts_uniform_case(value: str): + """Accept lowercase and uppercase mainnet and testnet SegWit addresses.""" + assert btc_address(value) is True + assert btc_address(value.upper()) is True + + +@pytest.mark.parametrize( + "value", + [ + "BC1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + "bc1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4", + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".replace("q", "Q", 1), + "TB1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + "tb1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KXPJZSX", + "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sL5k7", + ], +) +def test_segwit_rejects_mixed_case(value: str): + """Do not accept a case change confined to the prefix or data part.""" + assert isinstance(btc_address(value), ValidationError) + + +@pytest.mark.parametrize( + "value", + [ + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4\n", + "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4\n", + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".replace("k", "\u212a"), + "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4".replace("K", "\u212a"), + ], +) +def test_segwit_rejects_non_ascii_and_trailing_characters(value: str): + """Case normalization must not admit Unicode lookalikes or trailing text.""" + assert isinstance(btc_address(value), ValidationError)