diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index ee34a69ec..bd02ef034 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -75,12 +75,19 @@ def create_new_account_with_address( def is_valid_address(self, address: str) -> bool: return is_address(address) - def get_account(self, account_address: str) -> CurrentState | None: - """Private method to retrieve an account from the data base""" + @staticmethod + def _normalize_address(account_address: str) -> str: + """Normalize to the checksum form used as the primary key, matching + get_account()/create_new_account_with_address(). Falls back to the + original value if checksumming fails.""" try: - normalized = to_checksum_address(account_address) + return to_checksum_address(account_address) except Exception: - normalized = account_address + return account_address + + def get_account(self, account_address: str) -> CurrentState | None: + """Private method to retrieve an account from the data base""" + normalized = self._normalize_address(account_address) account = ( self.session.query(CurrentState) .filter(CurrentState.id == normalized) @@ -114,16 +121,12 @@ def debit_account_balance(self, account_address: str, amount: int) -> bool: """Atomic conditional debit. Returns False if insufficient balance.""" if amount <= 0: return True - try: - account_address = to_checksum_address(account_address) - except Exception: - pass result = self.session.execute( text( "UPDATE current_state SET balance = balance - :amount " "WHERE id = :addr AND balance >= :amount" ), - {"amount": amount, "addr": account_address}, + {"amount": amount, "addr": self._normalize_address(account_address)}, ) return result.rowcount > 0 @@ -131,10 +134,7 @@ def credit_account_balance(self, account_address: str, amount: int): """Atomic credit. Creates account if it doesn't exist.""" if amount <= 0: return - try: - account_address = to_checksum_address(account_address) - except Exception: - pass + account_address = self._normalize_address(account_address) self.session.execute( text( "INSERT INTO current_state (id, data, balance) " @@ -158,6 +158,7 @@ def credit_tx_value_once( Sets value_credited=true atomically. Returns True if credit was applied.""" if amount <= 0: return False + target_address = self._normalize_address(target_address) # Ensure target account exists self.session.execute( text( diff --git a/tests/unit/test_accounts_manager_normalization.py b/tests/unit/test_accounts_manager_normalization.py new file mode 100644 index 000000000..b7678b45b --- /dev/null +++ b/tests/unit/test_accounts_manager_normalization.py @@ -0,0 +1,77 @@ +"""Unit tests for AccountsManager address normalization in raw-SQL paths. + +debit_account_balance / credit_account_balance / credit_tx_value_once use +raw SQL and previously received the address verbatim, while ORM paths +(get_account / create_new_account_with_address) store rows keyed by +to_checksum_address(). A lowercase address therefore created a duplicate +account row and split balances. These tests exercise the fix with a mocked +SQLAlchemy session — no database required. +""" + +from unittest.mock import MagicMock + +from eth_utils import to_checksum_address + +from backend.database_handler.accounts_manager import AccountsManager + +RAW_ADDRESS = "0x71c7656ec7ab88b098defb751b7401b5f6d8976f" # lowercase on purpose +CHECKSUMMED = to_checksum_address(RAW_ADDRESS) +assert RAW_ADDRESS != CHECKSUMMED # fixture sanity: casing must differ + + +def _executed_params(session: MagicMock) -> list[dict]: + return [call.args[1] for call in session.execute.call_args_list] + + +def test_debit_normalizes_address(): + session = MagicMock() + session.execute.return_value = MagicMock(rowcount=1) + assert AccountsManager(session).debit_account_balance(RAW_ADDRESS, 5) is True + + (params,) = [p for p in _executed_params(session) if p.get("addr")] + assert params["addr"] == CHECKSUMMED + + +def test_credit_normalizes_address_before_insert_and_update(): + session = MagicMock() + AccountsManager(session).credit_account_balance(RAW_ADDRESS, 7) + + params = [p for p in _executed_params(session) if p.get("addr")] + assert len(params) == 2 # INSERT ... ON CONFLICT + UPDATE + assert all(p["addr"] == CHECKSUMMED for p in params) + + +def test_credit_tx_value_once_normalizes_target(): + session = MagicMock() + # value_credited update must match so the credit path is taken + session.execute.return_value = MagicMock(rowcount=1) + + manager = AccountsManager(session) + trx = manager.credit_tx_value_once("0x12" + "34" * 16, RAW_ADDRESS, 3) + + assert trx is True + params = [p for p in _executed_params(session) if p.get("addr")] + assert params + assert all(p["addr"] == CHECKSUMMED for p in params) + + +def test_credit_zero_or_negative_amount_is_noop(): + session = MagicMock() + manager = AccountsManager(session) + + manager.credit_account_balance(RAW_ADDRESS, 0) + manager.credit_account_balance(RAW_ADDRESS, -10) + assert manager.debit_account_balance(RAW_ADDRESS, 0) is True + + session.execute.assert_not_called() + + +def test_normalize_fallback_keeps_original(): + """A value that to_checksum_address rejects must be used unchanged, + preserving the pre-existing behavior for non-address identifiers.""" + session = MagicMock() + session.execute.return_value = MagicMock(rowcount=1) + AccountsManager(session).debit_account_balance("not-an-address", 5) + + (params,) = [p for p in _executed_params(session) if p.get("addr")] + assert params["addr"] == "not-an-address"