From 295d1910638681a1659ebe78ca00249df4eab75b Mon Sep 17 00:00:00 2001 From: anderdc Date: Mon, 1 Jun 2026 15:21:19 -0500 Subject: [PATCH 1/2] Reject self-transfer (A->A) swap legs A swap leg's payer and payee are always distinct parties, so a tx whose sender equals the recipient delivers nothing. Without a guard, an operator who lines up the miner's committed address with the user's address could fulfill a swap with a same-wallet self-send and manufacture fake volume. - verify_transaction: reject when sender == expected_recipient (covers both source-confirm and dest-confirm legs at the on-chain choke point). - handle_swap_reserve / handle_swap_confirm: reject when a user address equals the miner's committed deposit/payout address, so a self-flow operator can't even hold a reservation or create the swap. A->B self-flow between two operator-owned wallets is indistinguishable on-chain and still passes; it pays real fees and is bounded on the reward side. --- allways/chain_providers/base.py | 10 ++++ allways/validator/axon_handlers.py | 14 ++++++ tests/test_self_transfer_guard.py | 81 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 tests/test_self_transfer_guard.py 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..ddf2eda 100644 --- a/allways/validator/axon_handlers.py +++ b/allways/validator/axon_handlers.py @@ -375,6 +375,13 @@ 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 +638,13 @@ 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..0b3d7eb --- /dev/null +++ b/tests/test_self_transfer_guard.py @@ -0,0 +1,81 @@ +"""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.chains import CHAIN_TAO, ChainDefinition +from allways.chain_providers.base import ChainProvider, TransactionInfo + + +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 From f4e235f33ce366ece776424d653f5092f527ef84 Mon Sep 17 00:00:00 2001 From: anderdc <61125407+anderdc@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:09:04 +0000 Subject: [PATCH 2/2] style: auto-fix pre-commit hooks --- allways/validator/axon_handlers.py | 8 ++++++-- tests/test_self_transfer_guard.py | 24 ++++++++++++++++-------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/allways/validator/axon_handlers.py b/allways/validator/axon_handlers.py index ddf2eda..302a2bb 100644 --- a/allways/validator/axon_handlers.py +++ b/allways/validator/axon_handlers.py @@ -379,7 +379,9 @@ async def handle_swap_reserve( # 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) + 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 @@ -642,7 +644,9 @@ async def handle_swap_confirm( # 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) + 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 diff --git a/tests/test_self_transfer_guard.py b/tests/test_self_transfer_guard.py index 0b3d7eb..4cad591 100644 --- a/tests/test_self_transfer_guard.py +++ b/tests/test_self_transfer_guard.py @@ -10,8 +10,8 @@ from typing import Optional -from allways.chains import CHAIN_TAO, ChainDefinition from allways.chain_providers.base import ChainProvider, TransactionInfo +from allways.chains import CHAIN_TAO, ChainDefinition class FakeProvider(ChainProvider): @@ -25,8 +25,9 @@ def get_chain(self) -> ChainDefinition: 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]: + 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]: @@ -49,14 +50,17 @@ def send_amount(self, to_address, amount, from_address=None): def _tx(sender: str, recipient: str) -> TransactionInfo: - return TransactionInfo(tx_hash='0xabc', confirmed=True, sender=sender, - recipient=recipient, amount=100, confirmations=6) + 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, + tx_hash='0xabc', + expected_recipient='5Aaa', + expected_amount=100, expected_sender='5Aaa', ) assert result is None @@ -65,7 +69,9 @@ def test_self_transfer_is_rejected(): 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, + tx_hash='0xabc', + expected_recipient='5User', + expected_amount=100, expected_sender='5Miner', ) assert result is not None @@ -76,6 +82,8 @@ 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, + tx_hash='0xabc', + expected_recipient='5Aaa', + expected_amount=100, ) assert result is None