Skip to content

enhancement: is_valid_address should reject BTC addresses from a different network than BTC_NETWORK #460

@cleanjunc

Description

@cleanjunc

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 Falsedestination 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:

  1. Passes the CLI pre-check (swap.py) and validator confirm (handle_swap_confirm's to_provider.is_valid_address(synapse.to_address)) — format only.
  2. Locks the user's source funds and reserves a miner.
  3. 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.
  4. 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.pysend_amount_lightweightchain_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.pysend_amount_lightweight. Downstream string-match verification: allways/validator/chain_verification.pyapi_verify_transaction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions