Skip to content

Commit 2e097c6

Browse files
committed
fix: prevent backdated expense from draining future-dated grant
The eager-write drain in _consume_address_credits filtered lots by the expense's expiration bound but not by its lower-bound message_timestamp. Messages can arrive out of order in the P2P pipeline, so an older expense could land after a future-dated grant has already been cached as a lot, at which point the eager writer would happily deduct from that lot. The repair replay enforces the lower bound by walking credit_history chronologically, so the next restart would silently correct the eager state. Add the missing ``AlephCreditBalanceDb.message_timestamp <= message_timestamp`` predicate so the eager writer matches what the replay derives, and cover it with a regression test that processes a backdated expense against a future-dated grant.
1 parent 2176c27 commit 2e097c6

2 files changed

Lines changed: 53 additions & 5 deletions

File tree

src/aleph/db/accessors/balances.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,16 @@ def _consume_address_credits(
281281
in place.
282282
283283
Emission order matches the historical FIFO: ``(message_timestamp, credit_ref,
284-
credit_index) ASC``. ``message_timestamp`` is the cutoff for "still valid":
285-
only lots with ``expiration_date IS NULL OR expiration_date > message_timestamp``
286-
are eligible. Using the message timestamp (not wall-clock now) keeps eager
287-
writes consistent with the repair replay, which uses the historical timestamp
288-
when reconstructing state from ``credit_history``.
284+
credit_index) ASC``. ``message_timestamp`` bounds eligibility on both ends:
285+
a lot is drainable only if it was granted at or before the expense
286+
(``lot.message_timestamp <= message_timestamp``) and is not yet expired
287+
(``expiration_date IS NULL OR expiration_date > message_timestamp``). The
288+
lower bound prevents a backdated expense from draining a grant that did
289+
not yet exist at the expense's instant; messages can arrive out of order
290+
in the P2P pipeline, so a future-dated grant may already be in the cache
291+
when an older expense lands. Using the message timestamp (not wall-clock
292+
now) keeps eager writes consistent with the repair replay, which walks
293+
history chronologically and enforces both bounds by construction.
289294
290295
Lots are locked ``FOR UPDATE`` to serialise concurrent writers for the same
291296
address. Over-draw silently drops the excess, matching the prior FIFO
@@ -300,6 +305,7 @@ def _consume_address_credits(
300305
.where(
301306
AlephCreditBalanceDb.address == address,
302307
AlephCreditBalanceDb.amount_remaining > 0,
308+
AlephCreditBalanceDb.message_timestamp <= message_timestamp,
303309
(
304310
AlephCreditBalanceDb.expiration_date.is_(None)
305311
| (AlephCreditBalanceDb.expiration_date > message_timestamp)

tests/db/test_credit_balances.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,48 @@ def test_balance_fix_doesnt_affect_valid_credits(session_factory: DbSessionFacto
619619
assert balance > 0
620620

621621

622+
def test_backdated_expense_cannot_drain_future_grant(
623+
session_factory: DbSessionFactory,
624+
):
625+
"""A backdated expense must not deduct from a lot whose grant happened
626+
after the expense's ``message_timestamp``.
627+
628+
Messages can arrive out of order in the P2P pipeline, so the eager writer
629+
may see a future-dated grant already in the lot cache when an older
630+
expense lands. The drain filter must exclude any lot whose
631+
``message_timestamp`` is strictly greater than the expense's, matching
632+
what the repair replay would derive from credit_history.
633+
"""
634+
635+
grant_timestamp = dt.datetime(2024, 6, 1, 12, 0, 0, tzinfo=dt.timezone.utc)
636+
expense_timestamp = dt.datetime(2024, 5, 1, 12, 0, 0, tzinfo=dt.timezone.utc)
637+
638+
with session_factory() as session:
639+
_give_credits(
640+
session=session,
641+
address="0xbackdated",
642+
amount=1000,
643+
expiration_ms=None,
644+
msg_hash="future_grant",
645+
msg_ts=grant_timestamp,
646+
)
647+
session.commit()
648+
649+
update_credit_balances_expense(
650+
session=session,
651+
credits_list=[
652+
{"address": "0xbackdated", "amount": 400, "ref": "backdated_expense"}
653+
],
654+
message_hash="backdated_expense_msg",
655+
message_timestamp=expense_timestamp,
656+
)
657+
session.commit()
658+
659+
# The grant survives in full (1000 * 10_000 precision multiplier).
660+
balance = get_credit_balance(session, "0xbackdated")
661+
assert balance == 10_000_000
662+
663+
622664
def test_fifo_scenario_1_non_expiring_first_equals_0_remaining(
623665
session_factory: DbSessionFactory,
624666
):

0 commit comments

Comments
 (0)