Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)

The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.

- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
  master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
  `draw_confirmations` is copied from round_config. The plaintext column is written only by
  the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
  bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
  lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
  `closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
  reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
  `draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
  asks every server, the active one included, for the header at that exact height (valid
  work, >= 2 other answers, strict majority of all); no agreement means the same height is
  asked again and `draw_header_corroboration_failed` is written once per distinct reported
  hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
  to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
  one transaction; a round closing with no participants reveals too. A round opened before
  this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
  the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
  /admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
  draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
  (RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.

Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 16:29:09 +02:00
1 parent 82d078e2d6
commit db24287ea5
42 files changed
+1715 -620

No files matched your search

+327 -112
View File
@@ -399,165 +399,380 @@ async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_
assert pending.status == "pending"
# --- B-28: the draw must not seed itself from an uncorroborated header -----------
# --- B-73 (RND-14 … RND-20, RND-33, RND-34): the commit-reveal draw ------------------------
_TIP = 100
class CorroboratingListener:
"""A fake listener whose tip advances the moment a corroboration attempt
fails, simulating a further block arriving — lets tests drive
_wait_for_next_block's retry loop deterministically without real sleeps."""
class DrawListener:
"""What the draw asks of the listener: a tip that tests move, and the quorum's answer for a
height (`blocks`: height -> hash, or None for "no agreement"). Every question is
recorded so a test can assert it was always about the *fixed* draw height."""
def __init__(self, *, responses: dict[int, bool], advance_to: dict[int, tuple[int, str]] | None = None):
self.tip_height, self.tip_header_hex = next(iter(responses)), "aa"
self._responses = dict(responses)
self._advance_to = advance_to or {}
self.corroboration_calls: list[int] = []
client = object()
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
self.corroboration_calls.append(height)
result = self._responses[height]
if not result and height in self._advance_to:
self.tip_height, self.tip_header_hex = self._advance_to[height]
return result
def __init__(self, tip_height: int = _TIP, blocks: dict[int, str | None] | None = None):
self.tip_height = tip_height
self.tip_header_hex = "00"
self.blocks = blocks if blocks is not None else {}
self.asked: list[int] = []
self.reported: dict[int, str | None] = {}
async def agreed_block_hash(self, height: int) -> tuple[str | None, str | None]:
self.asked.append(height)
return self.blocks.get(height), self.reported.get(height, self.blocks.get(height))
async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(session_factory):
listener = CorroboratingListener(responses={101: True})
scheduler = RoundScheduler(session_factory, listener)
height, block_hash = await scheduler._wait_for_next_block(
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
)
assert height == 101
assert listener.corroboration_calls == [101]
def _bet_txid(i: int) -> str:
return f"{(0xA0 + i * 7) % 256:02x}" * 32
async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_factory):
"""B-28: an uncorroborated header must never be used — the wait keeps going
until a later block's header *is* corroborated, logging why each time."""
listener = CorroboratingListener(
responses={101: False, 102: True}, advance_to={101: (102, "bb")}
)
scheduler = RoundScheduler(session_factory, listener)
height, block_hash = await scheduler._wait_for_next_block(
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
)
assert height == 102
assert listener.corroboration_calls == [101, 102]
async def _seed_closing_round(session_factory, *, n: int = 3, draw_confirmations: int = 3, txids=None) -> int:
"""A real round opened by `open_new_round_if_needed` (so it carries its real encrypted
secret), closed, with `n` confirmed participants whose txids are NOT in broadcast order."""
from app.rounds.service import open_new_round_if_needed
async with session_factory() as session:
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert events == ["draw_header_corroboration_failed"]
session.add(RoundConfig(fee_address=_FEE_ADDRESS, draw_confirmations=draw_confirmations))
await session.commit()
round_ = await open_new_round_if_needed(session)
await session.commit()
round_.status = "closing"
round_.closed_at = datetime.now(timezone.utc)
txids = txids or [_bet_txid(i) for i in range(n)]
for i in range(n):
user = User(username=f"player{i}", password_hash="x", derivation_index=i, address=_address(i))
session.add(user)
await session.flush()
session.add(
RoundParticipant(
round_id=round_.id,
user_id=user.id,
bet_amount_sats=1_000_000_000 - 5_000 * i,
bet_txid=txids[i],
bet_vout=0,
# broadcast order is the *reverse* of txid order for some of them, so an
# implementation still sorting by broadcast_at would get a different list
broadcast_at=datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=i),
status="confirmed",
)
)
await session.commit()
return round_.id
# --- B-63: an unknown tip at closing time must not become the draw's seed --------
def _address(i: int) -> str:
return derive_pool_address(1000 + i) # any distinct, valid PLM addresses
class LateTipListener:
"""A listener that doesn't know the tip yet and learns it only once asked —
the state the old code could observe while `client` already looked alive."""
async def _enter_drawing_only(scheduler) -> list[int]:
"""Run `_close_and_draw` up to (not including) the draw itself."""
entered: list[int] = []
def __init__(self, *, learns: tuple[int, str], then_advances_to: tuple[int, str]):
self.tip_height = 0
self.tip_header_hex = None
self._learns = learns
self._then_advances_to = then_advances_to
self.corroboration_calls: list[int] = []
async def record(round_id: int) -> None:
entered.append(round_id)
def learn_tip(self) -> None:
self.tip_height, self.tip_header_hex = self._learns
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
self.corroboration_calls.append(height)
return True
scheduler._complete_draw = record
return entered
async def test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block(
session_factory, monkeypatch
): # B-63
"""A tip_at_close of 0 means the tip was *unknown* when the round closed, not
that the chain was at height zero. The first header we then learn describes a
block that may well predate the close — whose hash was public while bets were
still open — so it must become the baseline, never the seed: the draw waits for a
block strictly after it."""
import app.rounds.scheduler as scheduler_module
listener = LateTipListener(learns=(500, "aa"), then_advances_to=(501, "bb"))
async def test_closing_with_participants_commits_the_draw_and_enters_drawing(session_factory):
"""B-73 (RND-14, RND-33, DAT-65, AUD-37): in one transaction the list is ordered by bet txid
and numbered, its fingerprint and the draw height (tip + 1) are stored, and the round
enters `drawing` — before any block of the draw exists."""
round_id = await _seed_closing_round(session_factory, n=3)
listener = DrawListener(tip_height=_TIP)
scheduler = RoundScheduler(session_factory, listener)
entered = await _enter_drawing_only(scheduler)
async def fake_sleep(_seconds):
# First sleep: the tip becomes known (height 500, the pre-close block).
# Second: a genuinely new block arrives on top of it.
if listener.tip_height == 0:
listener.learn_tip()
else:
listener.tip_height, listener.tip_header_hex = listener._then_advances_to
await scheduler._close_and_draw(round_id)
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
assert entered == [round_id]
async with session_factory() as session:
round_ = await session.get(Round, round_id)
assert round_.status == "drawing"
assert round_.draw_height == _TIP + 1
rows = (
await session.execute(
select(RoundParticipant.draw_position, RoundParticipant.bet_txid, RoundParticipant.user_id)
.where(RoundParticipant.round_id == round_id)
.order_by(RoundParticipant.draw_position)
)
).all()
assert [r.draw_position for r in rows] == [0, 1, 2]
assert [r.bet_txid for r in rows] == sorted(_bet_txid(i) for i in range(3)) # ascending txid
users = {u.id: u.address for u in (await session.scalars(select(User))).all()}
# an independent recomputation of the fingerprint (RND-14)
import hashlib
height, _block_hash = await scheduler._wait_for_next_block(
round_id=1, tip_at_close=0, waiting_since=datetime.now(timezone.utc)
)
text = "".join(f"{r.bet_txid}:0:{users[r.user_id]}\n" for r in rows)
assert round_.participants_hash == hashlib.sha256(text.encode()).hexdigest()
assert round_.pool_amount_sats == sum(1_000_000_000 - 5_000 * i for i in range(3))
assert round_.drawing_started_at is not None
assert height == 501 # the block *after* the one we first learned about
assert listener.corroboration_calls == [501] # 500 was never even a candidate
committed = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_committed"))).all()
assert len(committed) == 1 and committed[0].round_id == round_id
import json
assert json.loads(committed[0].payload_json) == {
"participants_hash": round_.participants_hash,
"draw_height": _TIP + 1,
"participants": 3,
"server_seed_hash": round_.server_seed_hash,
}
async def test_participants_with_equal_broadcast_times_are_still_ordered_by_txid(session_factory):
"""RND-14 / DAT-66: the order is a function of public data only. The former order by
`broadcast_at` had no tie-break: bets stamped with the same instant could be drawn in
either order, so the draw could not be reproduced."""
txids = ["cc" * 32, "aa" * 32, "bb" * 32]
round_id = await _seed_closing_round(session_factory, n=3, txids=txids)
async with session_factory() as session:
for p in (await session.scalars(select(RoundParticipant))).all():
p.broadcast_at = datetime(2026, 1, 1, tzinfo=timezone.utc) # all identical
await session.commit()
scheduler = RoundScheduler(session_factory, DrawListener())
await _enter_drawing_only(scheduler)
await scheduler._close_and_draw(round_id)
async with session_factory() as session:
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert events == ["draw_baseline_tip_unknown"] # explainable from /admin
ordered = (
await session.scalars(
select(RoundParticipant.bet_txid)
.where(RoundParticipant.round_id == round_id)
.order_by(RoundParticipant.draw_position)
)
).all()
assert ordered == sorted(txids)
# --- B-36: a stalled draw must be visible, not a silent frozen wait --------------
async def test_the_draw_height_is_fixed_when_entering_drawing_and_never_moves(session_factory):
"""RND-33: tip at closing + 1, stored on the round — the chain advancing afterwards does
not change it."""
round_id = await _seed_closing_round(session_factory)
listener = DrawListener(tip_height=250)
scheduler = RoundScheduler(session_factory, listener)
await _enter_drawing_only(scheduler)
await scheduler._close_and_draw(round_id)
listener.tip_height = 400
async with session_factory() as session:
assert (await session.get(Round, round_id)).draw_height == 251
class StallingListener:
"""A tip that never advances until the test decides it should — used to drive
_wait_for_next_block's stall-detection past _DRAW_STALL_THRESHOLD_SECONDS
without a real 6-minute wait."""
async def test_an_unknown_tip_keeps_the_round_closing_and_writes_one_audit_row(session_factory):
"""RND-19 / AUD-25: a tip of 0 is "unknown", not a base for the draw height. The round
stays `closing` and is retried at the next step; the audit row is written once."""
round_id = await _seed_closing_round(session_factory)
listener = DrawListener(tip_height=0)
scheduler = RoundScheduler(session_factory, listener)
entered = await _enter_drawing_only(scheduler)
def __init__(self):
self.tip_height = 100
self.tip_header_hex = None
await scheduler._close_and_draw(round_id)
await scheduler._close_and_draw(round_id)
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
return True
assert entered == []
async with session_factory() as session:
round_ = await session.get(Round, round_id)
assert round_.status == "closing" and round_.draw_height is None
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_baseline_tip_unknown"))).all()
assert len(rows) == 1 and rows[0].payload_json == "{}"
listener.tip_height = 80 # the height becomes known: the next step enters drawing
await scheduler._close_and_draw(round_id)
async with session_factory() as session:
assert (await session.get(Round, round_id)).draw_height == 81
async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(session_factory, monkeypatch):
async def test_a_round_without_participants_closes_and_reveals_its_secret(session_factory):
"""DAT-64: nothing was drawn, so the secret is revealed at the close."""
round_id = await _seed_closing_round(session_factory, n=0)
scheduler = RoundScheduler(session_factory, DrawListener())
await scheduler._close_and_draw(round_id)
async with session_factory() as session:
round_ = await session.get(Round, round_id)
assert round_.status == "closed"
assert round_.server_seed is not None
from app.rounds.draw import server_seed_commitment
assert server_seed_commitment(round_.server_seed) == round_.server_seed_hash
async def test_a_round_opened_before_the_commitment_existed_gets_a_secret_when_it_enters_drawing(session_factory):
"""Upgrade path: a round with no `server_seed_enc` (opened before B-73) must not wedge — it
is given a secret as it enters `drawing`, still before any block of the draw exists."""
round_id = await _seed_closing_round(session_factory)
async with session_factory() as session:
round_ = await session.get(Round, round_id)
round_.server_seed_enc = round_.server_seed_hash = None
await session.commit()
scheduler = RoundScheduler(session_factory, DrawListener())
await _enter_drawing_only(scheduler)
await scheduler._close_and_draw(round_id)
async with session_factory() as session:
round_ = await session.get(Round, round_id)
assert round_.status == "drawing" and round_.server_seed_enc and round_.server_seed_hash
assert round_.server_seed is None
def _fake_clock(monkeypatch, listener, on_sleep=None):
"""Drive `_wait_for_draw_block` without real time: `datetime.now` is a clock that only
`asyncio.sleep` advances, and `on_sleep(now)` lets the test move the chain."""
import app.rounds.scheduler as scheduler_module
listener = StallingListener()
scheduler = RoundScheduler(session_factory, listener)
start = datetime.now(timezone.utc)
class _FakeClock:
class _Clock:
now = start
def fake_now(tz=None):
return _FakeClock.now
monkeypatch.setattr(
scheduler_module, "datetime", type("_D", (), {"now": staticmethod(lambda tz=None: _Clock.now)})
)
async def fake_sleep(seconds: float) -> None:
_FakeClock.now += timedelta(seconds=seconds)
# Past the stall threshold, but before it would repeat: unblock the wait
# by making a (corroborated) block appear, so the test terminates.
if _FakeClock.now >= start + timedelta(seconds=scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + 30):
listener.tip_height = 101
listener.tip_header_hex = "aa"
_Clock.now += timedelta(seconds=seconds)
if on_sleep is not None:
on_sleep(_Clock.now - start)
monkeypatch.setattr(scheduler_module, "datetime", type("_D", (), {"now": staticmethod(fake_now)}))
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
return start
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100, waiting_since=start)
assert height == 101
async def test_the_draw_waits_for_the_confirmations_on_the_draw_block(session_factory, monkeypatch):
"""RND-15: draw_height 101 with 3 confirmations needs a chain at 103 or more. The quorum is
not even asked before that — and then it is asked about 101, not about the tip."""
listener = DrawListener(tip_height=101, blocks={101: "ab" * 32})
scheduler = RoundScheduler(session_factory, listener)
def on_sleep(elapsed):
if elapsed >= timedelta(seconds=10):
listener.tip_height = 102
if elapsed >= timedelta(seconds=20):
listener.tip_height = 103
start = _fake_clock(monkeypatch, listener, on_sleep)
block_hash = await scheduler._wait_for_draw_block(1, 101, 3, start)
assert block_hash == "ab" * 32
assert listener.asked == [101] # only once the chain reached 103 — and about the fixed height
async def test_a_disagreement_asks_the_same_height_again_and_is_audited_once_per_hash(session_factory, monkeypatch):
"""RND-16 / AUD-26: no agreement means waiting and asking for the *same* height — never
moving to another block. The audit row is written once per distinct hash the active server
reports, not on every request."""
listener = DrawListener(tip_height=105, blocks={101: None})
listener.reported[101] = "ee" * 32
scheduler = RoundScheduler(session_factory, listener)
def on_sleep(elapsed):
if elapsed >= timedelta(seconds=30):
listener.blocks[101] = "cd" * 32 # the quorum finally agrees
start = _fake_clock(monkeypatch, listener, on_sleep)
block_hash = await scheduler._wait_for_draw_block(1, 101, 3, start)
assert block_hash == "cd" * 32
assert set(listener.asked) == {101} and len(listener.asked) > 2
async with session_factory() as session:
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_header_corroboration_failed"))).all()
import json
assert [json.loads(r.payload_json) for r in rows] == [{"height": 101, "reported_hash": "ee" * 32}]
# a different hash reported later is a new fact worth a row of its own
listener.blocks[101] = None
listener.reported[101] = "ff" * 32
def on_sleep_again(elapsed):
if elapsed >= timedelta(seconds=15):
listener.blocks[101] = "cd" * 32
_fake_clock(monkeypatch, listener, on_sleep_again)
await scheduler._wait_for_draw_block(1, 101, 3, datetime.now(timezone.utc))
async with session_factory() as session:
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_header_corroboration_failed"))).all()
assert [json.loads(r.payload_json)["reported_hash"] for r in rows] == ["ee" * 32, "ff" * 32]
async def test_a_block_relayer_cannot_choose_the_block_that_seeds_the_draw(session_factory, monkeypatch):
"""B-73: the active server announces tip 104 with a block of its choosing (and withholds
the real one) while the quorum has nothing yet for the fixed draw height 101: the draw
must not take 104's hash, nor 102's — it waits for the quorum at 101, however long."""
listener = DrawListener(tip_height=104, blocks={101: None, 102: "11" * 32, 104: "44" * 32})
scheduler = RoundScheduler(session_factory, listener)
def on_sleep(elapsed):
if elapsed >= timedelta(seconds=60):
listener.blocks[101] = "01" * 32
start = _fake_clock(monkeypatch, listener, on_sleep)
block_hash = await scheduler._wait_for_draw_block(1, 101, 3, start)
assert block_hash == "01" * 32 # the hash at 101, whatever the others looked like
assert set(listener.asked) == {101}
async def test_the_stall_clock_runs_only_while_the_chain_does_not_advance(session_factory, monkeypatch):
"""RND-20: with 3 confirmations a round waits ~3 blocks. A chain producing a block every
120s must not look stalled (the old rule counted from the start and fired in almost every
round)."""
listener = DrawListener(tip_height=101, blocks={101: "ab" * 32})
scheduler = RoundScheduler(session_factory, listener)
def on_sleep(elapsed):
listener.tip_height = 101 + int(elapsed.total_seconds() // 300) # a block every 300s
start = _fake_clock(monkeypatch, listener, on_sleep)
await scheduler._wait_for_draw_block(1, 101, 3, start) # needs tip 103: ~600s > 360s overall
async with session_factory() as session:
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
assert len(entries) == 1
assert entries[0].round_id == 1
assert (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all() == []
async def test_a_chain_that_stops_advancing_writes_draw_stalled_and_repeats(session_factory, monkeypatch):
"""RND-20 / AUD-27: payload {draw_height, current_tip_height, elapsed_seconds}; one row per
threshold for as long as the stall lasts."""
import app.rounds.scheduler as scheduler_module
listener = DrawListener(tip_height=101, blocks={101: "ab" * 32})
scheduler = RoundScheduler(session_factory, listener)
threshold = scheduler_module._DRAW_STALL_THRESHOLD_SECONDS
def on_sleep(elapsed):
if elapsed >= timedelta(seconds=2 * threshold + 30):
listener.tip_height = 103
start = _fake_clock(monkeypatch, listener, on_sleep)
await scheduler._wait_for_draw_block(1, 101, 3, start)
async with session_factory() as session:
import json
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
payloads = [json.loads(r.payload_json) for r in rows]
assert len(payloads) == 2
assert all(p["draw_height"] == 101 and p["current_tip_height"] == 101 for p in payloads)
assert payloads[0]["elapsed_seconds"] >= threshold
async def test_a_quorum_that_never_agrees_writes_draw_stalled(session_factory, monkeypatch):
"""RND-20: "oppure il quorum continua a non concordare" — the confirmations are there but
the servers disagree for the whole threshold."""
import app.rounds.scheduler as scheduler_module
listener = DrawListener(tip_height=110, blocks={101: None})
scheduler = RoundScheduler(session_factory, listener)
threshold = scheduler_module._DRAW_STALL_THRESHOLD_SECONDS
def on_sleep(elapsed):
if elapsed >= timedelta(seconds=threshold + 20):
listener.blocks[101] = "ab" * 32
start = _fake_clock(monkeypatch, listener, on_sleep)
await scheduler._wait_for_draw_block(1, 101, 3, start)
async with session_factory() as session:
assert len((await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()) == 1
async def test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check(session_factory): # B-53