Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions allways/chain_providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions allways/validator/axon_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions tests/test_self_transfer_guard.py
Original file line number Diff line number Diff line change
@@ -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