diff --git a/src/aleph/db/accessors/balances.py b/src/aleph/db/accessors/balances.py index 77f9945d2..499371609 100644 --- a/src/aleph/db/accessors/balances.py +++ b/src/aleph/db/accessors/balances.py @@ -281,11 +281,16 @@ def _consume_address_credits( in place. Emission order matches the historical FIFO: ``(message_timestamp, credit_ref, - credit_index) ASC``. ``message_timestamp`` is the cutoff for "still valid": - only lots with ``expiration_date IS NULL OR expiration_date > message_timestamp`` - are eligible. Using the message timestamp (not wall-clock now) keeps eager - writes consistent with the repair replay, which uses the historical timestamp - when reconstructing state from ``credit_history``. + credit_index) ASC``. ``message_timestamp`` bounds eligibility on both ends: + a lot is drainable only if it was granted at or before the expense + (``lot.message_timestamp <= message_timestamp``) and is not yet expired + (``expiration_date IS NULL OR expiration_date > message_timestamp``). The + lower bound prevents a backdated expense from draining a grant that did + not yet exist at the expense's instant; messages can arrive out of order + in the P2P pipeline, so a future-dated grant may already be in the cache + when an older expense lands. Using the message timestamp (not wall-clock + now) keeps eager writes consistent with the repair replay, which walks + history chronologically and enforces both bounds by construction. Lots are locked ``FOR UPDATE`` to serialise concurrent writers for the same address. Over-draw silently drops the excess, matching the prior FIFO @@ -300,6 +305,7 @@ def _consume_address_credits( .where( AlephCreditBalanceDb.address == address, AlephCreditBalanceDb.amount_remaining > 0, + AlephCreditBalanceDb.message_timestamp <= message_timestamp, ( AlephCreditBalanceDb.expiration_date.is_(None) | (AlephCreditBalanceDb.expiration_date > message_timestamp) diff --git a/tests/db/test_credit_balances.py b/tests/db/test_credit_balances.py index 31e65e2b2..f4e8c08df 100644 --- a/tests/db/test_credit_balances.py +++ b/tests/db/test_credit_balances.py @@ -619,6 +619,48 @@ def test_balance_fix_doesnt_affect_valid_credits(session_factory: DbSessionFacto assert balance > 0 +def test_backdated_expense_cannot_drain_future_grant( + session_factory: DbSessionFactory, +): + """A backdated expense must not deduct from a lot whose grant happened + after the expense's ``message_timestamp``. + + Messages can arrive out of order in the P2P pipeline, so the eager writer + may see a future-dated grant already in the lot cache when an older + expense lands. The drain filter must exclude any lot whose + ``message_timestamp`` is strictly greater than the expense's, matching + what the repair replay would derive from credit_history. + """ + + grant_timestamp = dt.datetime(2024, 6, 1, 12, 0, 0, tzinfo=dt.timezone.utc) + expense_timestamp = dt.datetime(2024, 5, 1, 12, 0, 0, tzinfo=dt.timezone.utc) + + with session_factory() as session: + _give_credits( + session=session, + address="0xbackdated", + amount=1000, + expiration_ms=None, + msg_hash="future_grant", + msg_ts=grant_timestamp, + ) + session.commit() + + update_credit_balances_expense( + session=session, + credits_list=[ + {"address": "0xbackdated", "amount": 400, "ref": "backdated_expense"} + ], + message_hash="backdated_expense_msg", + message_timestamp=expense_timestamp, + ) + session.commit() + + # The grant survives in full (1000 * 10_000 precision multiplier). + balance = get_credit_balance(session, "0xbackdated") + assert balance == 10_000_000 + + def test_fifo_scenario_1_non_expiring_first_equals_0_remaining( session_factory: DbSessionFactory, ): diff --git a/tests/message_processing/test_process_posts.py b/tests/message_processing/test_process_posts.py index 25d931214..d73492a5b 100644 --- a/tests/message_processing/test_process_posts.py +++ b/tests/message_processing/test_process_posts.py @@ -200,7 +200,10 @@ async def test_credit_transfer_non_whitelisted_sender( ) with session_factory() as session: - # Give the regular sender some credits via a whitelisted distribution + # Seed the sender before the transfer message's ``time`` field + # (1651050219.0 = 2022-04-27 09:43:39 UTC) so the lot's + # message_timestamp precedes the transfer's, otherwise the eager-write + # drain correctly refuses to deduct from a future-dated grant. update_credit_balances_distribution( session=session, credits_list=[ @@ -218,7 +221,7 @@ async def test_credit_transfer_non_whitelisted_sender( token="ALEPH", chain="ETH", message_hash="init_dist_hash_abc", - message_timestamp=dt.datetime(2023, 1, 1, tzinfo=dt.timezone.utc), + message_timestamp=dt.datetime(2022, 1, 1, tzinfo=dt.timezone.utc), ) session.commit()