Summary
BitcoinProvider.is_valid_address validates an address's format but not its network. A provider configured for mainnet (BTC_NETWORK=mainnet, or the lightweight default) accepts testnet (tb1q…, m…, n…), testnet4, and regtest (bcrt1q…) addresses as "valid." This check at validator confirm and at the CLI pre-check is the only gate that runs before the user's source funds are locked and before a miner moves real funds. Because it passes a wrong-network destination, the swap is initiated, the miner sends real BTC to the scriptPubKey decoded from that address (which re-encodes to a different address on the live network), and then the validator's destination verification can never match it — stranding the miner's funds.
Current behavior
allways/chain_providers/bitcoin.py:
def is_valid_address(self, address: str) -> bool:
"""Validate BTC address format without RPC (embit decode; all types incl. Taproot)."""
if not address or not isinstance(address, str):
return False
try:
return address_to_scriptpubkey(address) is not None
except Exception:
return False
address_to_scriptpubkey decodes addresses of any network, so this returns True regardless of whether the address matches self.network. is_valid_address never references self.network.
The miner's fulfillment path has no network guard. send_dest_funds calls provider.send_amount(swap.user_to_address, …) directly (allways/miner/fulfillment.py), and send_amount_lightweight builds the output straight from the user string:
to_script = address_to_scriptpubkey(to_address) # send_amount_lightweight
...
tx.vout.append(TransactionOutput(amount, to_script))
For a testnet/regtest to_address this produces the scriptPubKey whose witness program / hash160 is shared with the mainnet equivalent, and the tx is broadcast on the live (mainnet) network. The funds land at the mainnet re-encoding the user never saw.
(Pre-existing, not introduced by #453 — the prior hand-rolled validator also accepted testnet base58 version bytes and tb1/bcrt1 bech32 prefixes. #453 only changed the decoder.)
What actually happens downstream (corrected)
There is a downstream check, but it is an exact-string recipient match, not a network check, and it fires only after the miner has already sent real funds:
- The validator verifies the destination tx with
expected_recipient = swap.user_to_address (allways/validator/chain_verification.py), i.e. the original tb1q… string the user entered.
api_verify_transaction compares vout.scriptpubkey_address == expected_recipient with no mainnet normalization (to_mainnet_address is only used in the proof-signing path, not in tx verification).
- A mainnet Esplora returns the re-encoded
bc1q… for that vout, so bc1q… == tb1q… is False → destination verification fails.
So this is not a silent, successfully-completed misdirected swap. The concrete failure mode is: the miner sends real BTC to the re-encoded address, the validator can never match the tx back to the swap, and the swap does not confirm. The miner is out real funds with no on-chain credit — the code already anticipates this state with a warning ("funds may have been sent without on-chain credit", cleanup_stale_sends in fulfillment.py).
Why it matters
The destination address is user-entered for a TAO→BTC swap. A wrong-network paste (e.g. a testnet address from the user's wallet on a mainnet deployment) currently:
- Passes the CLI pre-check (
swap.py) and validator confirm (handle_swap_confirm's to_provider.is_valid_address(synapse.to_address)) — format only.
- Locks the user's source funds and reserves a miner.
- Has the miner broadcast real BTC to
address_to_scriptpubkey(wrong_net_address), whose scriptPubKey re-encodes to a different address on the live network.
- Fails the validator's exact-string destination match, so the swap never confirms.
The cheapest, correct place to stop this is the is_valid_address gate at step 1, before any funds move. The misdirected BTC may be recoverable when the address came from the user's own wallet (the re-encoded mainnet address shares the same key), but only if the miner notices, identifies the re-encoded address, and can spend it; when the wrong-network address belongs to a third party the user does not hold the key for, the funds are unrecoverable. Either way the right outcome is to reject the swap up front with a clear "that's a testnet address" message.
Reproduction
Unit level — directly reproducible, no node or funds:
from embit.script import address_to_scriptpubkey
from embit.networks import NETWORKS
# A mainnet-configured provider validating these destinations:
for a in [
'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', # testnet P2WPKH
'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn', # testnet P2PKH
'bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080', # regtest P2WPKH
]:
spk = address_to_scriptpubkey(a)
print(a, '-> valid:', spk is not None, '-> mainnet re-encode:', spk.address(NETWORKS['main']))
Output:
tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx -> valid: True -> mainnet re-encode: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn -> valid: True -> mainnet re-encode: 14JetYAhLevTRaePcAAX1vRMwaJt2QGRzp
bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080 -> valid: True -> mainnet re-encode: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
is_valid_address (which is just address_to_scriptpubkey(addr) is not None) returns True for all three on a mainnet provider.
End-to-end (miner send → failed verification): not reproduced here. That requires a live miner, validator, real BTC, and a live broadcast. The send-and-strand path above was confirmed by reading the code (fulfillment.py → send_amount_lightweight → chain_verification.py), not by running a swap. Treat it as a code-traced consequence, not a reproduced run.
Suggested fix
Enforce the network in is_valid_address: detect the address's network from its prefix/version (the codebase already does prefix detection in detect_address_type) and compare to self.network, rejecting on mismatch. Format validation (including Taproot/bech32m via embit) stays as-is for the matching-network case.
Network → prefixes:
- mainnet:
bc1…, 1…, 3…
- testnet / testnet4:
tb1…, m…, n…, 2…
- regtest:
bcrt1…, plus the testnet base58 version bytes (m…, n…, 2…)
Implementation notes:
- Treat
testnet and testnet4 as one network class for matching — they share address prefixes.
- Be deliberate about regtest base58: Bitcoin Core regtest reuses testnet's base58 version bytes, so a regtest provider must accept
m/n/2 as well as bcrt1….
Tests
Unit-testable as red/green, no judgment call:
- On a
BTC_NETWORK=mainnet provider, assert is_valid_address('tb1q…'), is_valid_address('mipc…'), is_valid_address('bcrt1q…') return False, while a valid mainnet address returns True.
- Mirror for a testnet-configured provider.
Note — this is not purely additive. The existing tests/test_bitcoin_signing.py::test_accepts_all_standard_types builds a default (mainnet) lightweight provider and its VALID list includes tb1p…, bcrt1q…, and bcrt1p…, asserting they are accepted. That test encodes the current accept-everything behavior and must be updated to split addresses by network, not just extended.
Scope / risk
- Small, self-contained change to one function, plus updating one existing test and adding new ones.
- Tightens an accept that should never have passed; the only addresses that flip accepted→rejected are wrong-network ones, which cannot be fulfilled correctly at the intended address anyway.
Environment
- File:
allways/chain_providers/bitcoin.py (is_valid_address).
- Affected flows: CLI swap pre-check (
allways/cli/swap_commands/swap.py) and validator handle_swap_confirm destination validation (allways/validator/axon_handlers.py), TAO→BTC. Miner send path with no network guard: allways/miner/fulfillment.py → send_amount_lightweight. Downstream string-match verification: allways/validator/chain_verification.py → api_verify_transaction.
Summary
BitcoinProvider.is_valid_addressvalidates an address's format but not its network. A provider configured for mainnet (BTC_NETWORK=mainnet, or the lightweight default) accepts testnet (tb1q…,m…,n…), testnet4, and regtest (bcrt1q…) addresses as "valid." This check at validator confirm and at the CLI pre-check is the only gate that runs before the user's source funds are locked and before a miner moves real funds. Because it passes a wrong-network destination, the swap is initiated, the miner sends real BTC to the scriptPubKey decoded from that address (which re-encodes to a different address on the live network), and then the validator's destination verification can never match it — stranding the miner's funds.Current behavior
allways/chain_providers/bitcoin.py:address_to_scriptpubkeydecodes addresses of any network, so this returnsTrueregardless of whether the address matchesself.network.is_valid_addressnever referencesself.network.The miner's fulfillment path has no network guard.
send_dest_fundscallsprovider.send_amount(swap.user_to_address, …)directly (allways/miner/fulfillment.py), andsend_amount_lightweightbuilds the output straight from the user string:For a testnet/regtest
to_addressthis produces the scriptPubKey whose witness program / hash160 is shared with the mainnet equivalent, and the tx is broadcast on the live (mainnet) network. The funds land at the mainnet re-encoding the user never saw.(Pre-existing, not introduced by #453 — the prior hand-rolled validator also accepted testnet base58 version bytes and
tb1/bcrt1bech32 prefixes. #453 only changed the decoder.)What actually happens downstream (corrected)
There is a downstream check, but it is an exact-string recipient match, not a network check, and it fires only after the miner has already sent real funds:
expected_recipient = swap.user_to_address(allways/validator/chain_verification.py), i.e. the originaltb1q…string the user entered.api_verify_transactioncomparesvout.scriptpubkey_address == expected_recipientwith no mainnet normalization (to_mainnet_addressis only used in the proof-signing path, not in tx verification).bc1q…for that vout, sobc1q… == tb1q…isFalse→ destination verification fails.So this is not a silent, successfully-completed misdirected swap. The concrete failure mode is: the miner sends real BTC to the re-encoded address, the validator can never match the tx back to the swap, and the swap does not confirm. The miner is out real funds with no on-chain credit — the code already anticipates this state with a warning ("funds may have been sent without on-chain credit",
cleanup_stale_sendsinfulfillment.py).Why it matters
The destination address is user-entered for a TAO→BTC swap. A wrong-network paste (e.g. a testnet address from the user's wallet on a mainnet deployment) currently:
swap.py) and validator confirm (handle_swap_confirm'sto_provider.is_valid_address(synapse.to_address)) — format only.address_to_scriptpubkey(wrong_net_address), whose scriptPubKey re-encodes to a different address on the live network.The cheapest, correct place to stop this is the
is_valid_addressgate at step 1, before any funds move. The misdirected BTC may be recoverable when the address came from the user's own wallet (the re-encoded mainnet address shares the same key), but only if the miner notices, identifies the re-encoded address, and can spend it; when the wrong-network address belongs to a third party the user does not hold the key for, the funds are unrecoverable. Either way the right outcome is to reject the swap up front with a clear "that's a testnet address" message.Reproduction
Unit level — directly reproducible, no node or funds:
Output:
is_valid_address(which is justaddress_to_scriptpubkey(addr) is not None) returnsTruefor all three on a mainnet provider.End-to-end (miner send → failed verification): not reproduced here. That requires a live miner, validator, real BTC, and a live broadcast. The send-and-strand path above was confirmed by reading the code (
fulfillment.py→send_amount_lightweight→chain_verification.py), not by running a swap. Treat it as a code-traced consequence, not a reproduced run.Suggested fix
Enforce the network in
is_valid_address: detect the address's network from its prefix/version (the codebase already does prefix detection indetect_address_type) and compare toself.network, rejecting on mismatch. Format validation (including Taproot/bech32m via embit) stays as-is for the matching-network case.Network → prefixes:
bc1…,1…,3…tb1…,m…,n…,2…bcrt1…, plus the testnet base58 version bytes (m…,n…,2…)Implementation notes:
testnetandtestnet4as one network class for matching — they share address prefixes.m/n/2as well asbcrt1….Tests
Unit-testable as red/green, no judgment call:
BTC_NETWORK=mainnetprovider, assertis_valid_address('tb1q…'),is_valid_address('mipc…'),is_valid_address('bcrt1q…')returnFalse, while a valid mainnet address returnsTrue.Note — this is not purely additive. The existing
tests/test_bitcoin_signing.py::test_accepts_all_standard_typesbuilds a default (mainnet) lightweight provider and itsVALIDlist includestb1p…,bcrt1q…, andbcrt1p…, asserting they are accepted. That test encodes the current accept-everything behavior and must be updated to split addresses by network, not just extended.Scope / risk
Environment
allways/chain_providers/bitcoin.py(is_valid_address).allways/cli/swap_commands/swap.py) and validatorhandle_swap_confirmdestination validation (allways/validator/axon_handlers.py), TAO→BTC. Miner send path with no network guard:allways/miner/fulfillment.py→send_amount_lightweight. Downstream string-match verification:allways/validator/chain_verification.py→api_verify_transaction.