Reject withdrawal addresses that aren't PLM

embit's Script.from_address accepts a well-formed bech32 address from any
chain: a Bitcoin bc1... parses into a perfectly valid witness program. So a
withdrawal to a BTC address built, signed and broadcast normally on PLM, and
the funds landed on a script nobody holds the key for — silently, with no
error anywhere. A malformed address fared slightly better only in that it
crashed the request with an unhandled 500.

is_valid_plm_address checks the HRP as well as the parse, and runs first in
request_withdrawal, before a single UTXO is touched. It matches what the
withdrawal form already told the user (bech32 plm1q... only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 21:45:07 +02:00
co-authored by Claude Opus 5
parent 0cf35147ad
commit 5c9ccc0344
4 changed files with 66 additions and 0 deletions
+25
View File
@@ -97,3 +97,28 @@ async def test_withdrawal_rejects_insufficient_balance(session_factory):
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="insufficient balance"):
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
@pytest.mark.parametrize(
"address",
[
"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", # valid bech32, wrong chain
"plm1qbogus", # right HRP, broken checksum
"not-an-address",
],
)
async def test_withdrawal_rejects_non_plm_address(session_factory, address):
"""The bc1 case is the one that matters: embit parses it into a perfectly
valid witness program, so without the HRP check the withdrawal would build,
sign and broadcast on PLM, sending the funds somewhere nobody holds a key
for. It has to fail before a single UTXO is touched."""
user_id = await _make_funded_user(session_factory, 3, 2_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError) as exc_info:
await request_withdrawal(session, client, user, address, BET_AMOUNT_SATS)
assert exc_info.value.code == "invalid_address"
assert not client.broadcasted