diff --git a/allways/chain_providers/base.py b/allways/chain_providers/base.py index e36dbf8..af5292d 100644 --- a/allways/chain_providers/base.py +++ b/allways/chain_providers/base.py @@ -123,6 +123,16 @@ def verify_transaction( ) return None + # A self-transfer (sender == recipient) is never a real swap leg: the + # paying and receiving parties are always distinct. Rejecting it blocks + # same-wallet A->A volume fakes; A->B self-flow is bounded economically. + if tx_info.sender and tx_info.sender == expected_recipient: + bt.logging.warning( + f'verify_transaction: self-transfer on tx {tx_hash[:16]}... ' + f'(sender == recipient {expected_recipient}) — rejecting' + ) + return None + return tx_info @abstractmethod diff --git a/allways/validator/axon_handlers.py b/allways/validator/axon_handlers.py index 0fb2a0c..302a2bb 100644 --- a/allways/validator/axon_handlers.py +++ b/allways/validator/axon_handlers.py @@ -375,6 +375,15 @@ async def handle_swap_reserve( f'miner_from={commitment.from_address} miner_to={commitment.to_address}' ) + # A user address equal to one of the miner's committed addresses makes + # a swap leg a self-transfer (A->A) that delivers nothing. Reject early + # so a self-flow operator can't even hold the reservation. + if synapse.from_address in (commitment.from_address, commitment.to_address): + reject_synapse( + synapse, 'Source address matches the miner commitment — self-transfers are not valid swaps', ctx + ) + return synapse + # Gate the user's quote against the rate read at reserve time, and # reject a tao_amount that doesn't match the submitted from/to legs. expected_to_amount = recompute_reserve_amounts( @@ -631,6 +640,15 @@ async def handle_swap_confirm( reject_synapse(synapse, 'Invalid destination address format', ctx) return synapse + # Reject self-transfer legs (A->A): a user address equal to the miner's + # deposit or payout address delivers nothing. verify_transaction also + # guards this on-chain; failing here avoids creating the swap at all. + if synapse.from_address == miner_from_address or synapse.to_address == miner_fulfillment_address: + reject_synapse( + synapse, 'User address matches the miner commitment — self-transfers are not valid swaps', ctx + ) + return synapse + # Defend against user-snipes-miner by passing expected_sender: a user # could otherwise reserve a miner and claim any third-party tx of the # right amount to the miner's address. The base provider wraps this diff --git a/tests/test_self_transfer_guard.py b/tests/test_self_transfer_guard.py new file mode 100644 index 0000000..4cad591 --- /dev/null +++ b/tests/test_self_transfer_guard.py @@ -0,0 +1,89 @@ +"""verify_transaction must reject self-transfers (sender == recipient). + +A same-wallet A->A send is never a real swap leg — the paying and receiving +parties are always distinct addresses. An operator who lines up a miner's +committed address with the user's receive address could otherwise fulfill a +swap with a self-send and manufacture fake volume. A->B self-flow between two +operator-owned wallets is indistinguishable on-chain and is left to economic / +reward-side limits, so it still passes here. +""" + +from typing import Optional + +from allways.chain_providers.base import ChainProvider, TransactionInfo +from allways.chains import CHAIN_TAO, ChainDefinition + + +class FakeProvider(ChainProvider): + """Returns a single canned tx; only the base post-checks are exercised.""" + + def __init__(self, tx: TransactionInfo): + self._tx = tx + + def get_chain(self) -> ChainDefinition: + return CHAIN_TAO + + def check_connection(self, **kwargs) -> None: ... + + def fetch_matching_tx( + self, tx_hash, expected_recipient, expected_amount, block_hint=0, max_scan_blocks=150 + ) -> Optional[TransactionInfo]: + return self._tx + + def get_current_block_height(self) -> Optional[int]: + return 100 + + def get_balance(self, address: str) -> int: + return 0 + + def is_valid_address(self, address: str) -> bool: + return True + + def sign_from_proof(self, address, message, key=None) -> str: + return '' + + def verify_from_proof(self, address, message, signature) -> bool: + return True + + def send_amount(self, to_address, amount, from_address=None): + return None + + +def _tx(sender: str, recipient: str) -> TransactionInfo: + return TransactionInfo( + tx_hash='0xabc', confirmed=True, sender=sender, recipient=recipient, amount=100, confirmations=6 + ) + + +def test_self_transfer_is_rejected(): + provider = FakeProvider(_tx(sender='5Aaa', recipient='5Aaa')) + result = provider.verify_transaction( + tx_hash='0xabc', + expected_recipient='5Aaa', + expected_amount=100, + expected_sender='5Aaa', + ) + assert result is None + + +def test_cross_party_transfer_passes(): + provider = FakeProvider(_tx(sender='5Miner', recipient='5User')) + result = provider.verify_transaction( + tx_hash='0xabc', + expected_recipient='5User', + expected_amount=100, + expected_sender='5Miner', + ) + assert result is not None + assert result.sender == '5Miner' + + +def test_self_transfer_rejected_even_without_expected_sender(): + # The dest-confirm path pins expected_sender, but guard A->A regardless. + provider = FakeProvider(_tx(sender='5Aaa', recipient='5Aaa')) + result = provider.verify_transaction( + tx_hash='0xabc', + expected_recipient='5Aaa', + expected_amount=100, + ) + assert result is None