"""B-82 (DEP-03, DEP-04, DEP-12, DEP-14, ELC-17, DAT-10, DAT-62, API-31): a deposit is credited at `deposit_confirmations` (default 6), a coinbase output at its maturity of 120, and only an output of a platform transaction at 1 — the deposit being the one input a user can double-spend with a private chain. The real listener is driven here: a fake active connection, fake "other" servers for the quorum, real transactions so the coinbase check reads (and hash-checks) an actual raw tx. """ import pytest from embit import script from embit.transaction import Transaction, TransactionInput, TransactionOutput from sqlalchemy import select from app.db.models import AuditLog, PendingTransaction, PendingTransactionTxid, RoundConfig, User, UtxoEvent from app.deposits.service import ( COINBASE_MATURITY, DepositPolicy, classify_new_deposits, find_new_credit_candidates, ) from app.electrum.listener import ElectrumListener from app.wallet.balance import compute_pending_balance from tests.unit.test_electrum_listener import _ENDPOINTS, session_factory # noqa: F401 (fixture) _ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd" _OUT = script.Script.from_address(_ADDRESS) def _plain_tx(seed: int = 1) -> Transaction: return Transaction( vin=[TransactionInput(bytes([seed]) * 32, 0)], vout=[TransactionOutput(1_000_000_000, _OUT)] ) def _coinbase_tx() -> Transaction: return Transaction(vin=[TransactionInput(bytes(32), 0xFFFFFFFF)], vout=[TransactionOutput(1_000_000_000, _OUT)]) def _entry(tx: Transaction, height: int, value: int = 1_000_000_000) -> dict: return {"tx_hash": tx.txid().hex(), "tx_pos": 0, "height": height, "value": value} class _Active: """The listener's active connection: the address' coins, and raw transactions by txid.""" def __init__(self, entries, raws: dict[str, str] | None = None): self.entries = entries self.raws = raws or {} self.asked_for: list[str] = [] async def listunspent(self, scripthash): return self.entries async def get_transaction(self, txid, verbose=False): self.asked_for.append(txid) return self.raws[txid] class _Other: def __init__(self, entries): self._entries = entries async def connect(self): pass async def listunspent(self, scripthash): return self._entries async def close(self): pass async def _user(session_factory, *, deposit_confirmations: int | None = None) -> int: async with session_factory() as session: if deposit_confirmations is not None: session.add(RoundConfig(fee_address="", deposit_confirmations=deposit_confirmations)) user = User(username="alice", password_hash="x", derivation_index=0, address=_ADDRESS) session.add(user) await session.commit() return user.id def _listener(session_factory, active: _Active, others_entries, *, tip: int) -> ElectrumListener: others = {host: _Other(others_entries[host]) for host in ("second.example", "third.example")} listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS) listener.client = active listener.tip_height = tip return listener async def _balance(session_factory, user_id: int) -> int: async with session_factory() as session: return (await session.get(User, user_id)).cached_balance_sats def _same(entries): return {"second.example": entries, "third.example": entries} # --- the thresholds (DEP-03) ----------------------------------------------------------------------- async def test_a_deposit_is_credited_exactly_at_six_confirmations(session_factory): user_id = await _user(session_factory) tx = _plain_tx() entry = _entry(tx, height=100) # confirmations = tip - 100 + 1 active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()}) at_five = _listener(session_factory, active, _same([entry]), tip=104) # 5 confirmations await at_five.refresh_user(user_id, "sh") assert await _balance(session_factory, user_id) == 0 assert at_five.pending_deposit_sats(user_id) == 1_000_000_000 # waiting, and shown as such at_six = _listener(session_factory, active, _same([entry]), tip=105) # 6 confirmations await at_six.refresh_user(user_id, "sh") assert await _balance(session_factory, user_id) == 1_000_000_000 assert at_six.pending_deposit_sats(user_id) == 0 async def test_deposit_confirmations_is_read_from_the_configuration(session_factory): user_id = await _user(session_factory, deposit_confirmations=2) tx = _plain_tx() entry = _entry(tx, height=100) active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()}) await _listener(session_factory, active, _same([entry]), tip=100).refresh_user(user_id, "sh") # 1 conf assert await _balance(session_factory, user_id) == 0 await _listener(session_factory, active, _same([entry]), tip=101).refresh_user(user_id, "sh") # 2 confs assert await _balance(session_factory, user_id) == 1_000_000_000 async def test_an_immature_coinbase_is_not_credited_until_it_has_120_confirmations(session_factory): user_id = await _user(session_factory) tx = _coinbase_tx() entry = _entry(tx, height=100) txid = tx.txid().hex() active = _Active([entry], {txid: tx.serialize().hex()}) # plenty for a deposit (50 confirmations), not for a coinbase immature = _listener(session_factory, active, _same([entry]), tip=149) await immature.refresh_user(user_id, "sh") assert await _balance(session_factory, user_id) == 0 assert immature.pending_deposit_sats(user_id) == 1_000_000_000 assert txid in active.asked_for # it was recognised as a coinbase from the raw transaction one_short = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 2) await one_short.refresh_user(user_id, "sh") assert await _balance(session_factory, user_id) == 0 mature = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 1) await mature.refresh_user(user_id, "sh") # exactly 120 confirmations assert await _balance(session_factory, user_id) == 1_000_000_000 async def test_a_coinbase_is_not_mistaken_for_a_plain_deposit_when_the_server_cannot_say(session_factory): """If the raw transaction cannot be fetched, or hashes to something else than the txid, the output waits — a server cannot make an immature coinbase look mature by lying.""" user_id = await _user(session_factory) tx, other = _coinbase_tx(), _plain_tx(9) entry = _entry(tx, height=100) missing = _Active([entry], {}) # KeyError on get_transaction await _listener(session_factory, missing, _same([entry]), tip=160).refresh_user(user_id, "sh") assert await _balance(session_factory, user_id) == 0 swapped = _Active([entry], {tx.txid().hex(): other.serialize().hex()}) # a different transaction listener = _listener(session_factory, swapped, _same([entry]), tip=160) assert await listener.is_coinbase(tx.txid().hex()) is None await listener.refresh_user(user_id, "sh") assert await _balance(session_factory, user_id) == 0 async def test_an_output_of_a_platform_transaction_is_credited_at_one_confirmation(session_factory): """The change of a bet, a withdrawal or a payout: the platform built the transaction, so nobody can double-spend its inputs — including a version replaced by an RBF bump.""" user_id = await _user(session_factory) current, replaced = _plain_tx(2), _plain_tx(3) async with session_factory() as session: row = PendingTransaction( kind="bet", user_id=user_id, current_txid=current.txid().hex(), fee_rate_sat_vb=1, raw_tx_hex=current.serialize().hex(), status="pending", ) session.add(row) await session.flush() session.add(PendingTransactionTxid(pending_transaction_id=row.id, txid=replaced.txid().hex(), raw_hex="00")) await session.commit() entries = [_entry(current, height=100), _entry(replaced, height=100)] active = _Active(entries, {}) await _listener(session_factory, active, _same(entries), tip=100).refresh_user(user_id, "sh") # 1 conf assert active.asked_for == [] # no coinbase lookup either: a platform output is not a deposit assert await _balance(session_factory, user_id) == 2_000_000_000 async def test_dust_is_never_a_waiting_deposit(session_factory): user_id = await _user(session_factory) tx = _plain_tx() entry = _entry(tx, height=100, value=293) listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100) await listener.refresh_user(user_id, "sh") assert listener.pending_deposit_sats(user_id) == 0 and await _balance(session_factory, user_id) == 0 # --- the quorum counts confirmations on our tip (ELC-17, DEP-04) ------------------------------------- async def test_the_other_servers_must_place_the_coin_deep_enough_too(session_factory): """Our active server says 6 confirmations; two others put the same coin one block higher (5 confirmations on our tip): the credit is withheld, not lost.""" user_id = await _user(session_factory) tx = _plain_tx() raws = {tx.txid().hex(): tx.serialize().hex()} ours = _entry(tx, height=100) shallower = _entry(tx, height=101) listener = _listener(session_factory, _Active([ours], raws), _same([shallower]), tip=105) await listener.refresh_user(user_id, "sh") assert await _balance(session_factory, user_id) == 0 agreeing = _listener(session_factory, _Active([ours], raws), _same([ours]), tip=105) await agreeing.refresh_user(user_id, "sh") assert await _balance(session_factory, user_id) == 1_000_000_000 async def test_corroborate_utxo_credit_compares_heights_on_our_tip(session_factory): listener = _listener(session_factory, _Active([]), _same([{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 5}]), tip=105) assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=6) is True assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=7) is False # without a requirement only "confirmed" matters, as before assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5) is True # --- classification and the displayed balance (DEP-12 (b), DEP-14) ----------------------------------- async def test_classify_new_deposits_splits_credit_from_waiting(): async def never(_txid): raise AssertionError("no coinbase lookup expected") entries = [ {"tx_hash": "01" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # platform, 1 conf {"tx_hash": "02" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # deposit, 1 conf: waits {"tx_hash": "03" * 32, "tx_pos": 0, "height": 96, "value": 1_000}, # deposit, 5 confs: waits {"tx_hash": "04" * 32, "tx_pos": 0, "height": 500, "value": 1_000}, # not on our chain yet: waits ] policy = DepositPolicy(deposit_confirmations=6, platform_txids={"01" * 32}) credit, waiting = await classify_new_deposits(entries, 100, policy, never) assert [(e["tx_hash"], required) for e, required in credit] == [("01" * 32, 1)] assert [e["tx_hash"] for e in waiting] == ["02" * 32, "03" * 32, "04" * 32] async def test_the_displayed_balance_includes_waiting_deposits_but_the_spendable_one_does_not(session_factory): user_id = await _user(session_factory) async with session_factory() as session: user = await session.get(User, user_id) assert await compute_pending_balance(session, user) == (0, False) shown, has_pending = await compute_pending_balance(session, user, pending_deposit_sats=7_000) assert (shown, has_pending) == (7_000, True) assert user.cached_balance_sats == 0 # DEP-13: never spendable until credited async def test_a_waiting_deposit_that_vanishes_stops_being_shown(session_factory): """DEP-14: the set is replaced at every refresh, so a deposit dropped by a reorganisation is no longer shown.""" user_id = await _user(session_factory) tx = _plain_tx() entry = _entry(tx, height=100) listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100) await listener.refresh_user(user_id, "sh") assert listener.pending_deposit_sats(user_id) == 1_000_000_000 listener.client = _Active([]) # gone from the chain await listener.refresh_user(user_id, "sh") assert listener.pending_deposit_sats(user_id) == 0 async def test_new_blocks_refresh_the_users_that_have_a_waiting_deposit(session_factory, monkeypatch): """No scripthash notification announces that a deposit matured (its history did not change), so each block refreshes only the users with a waiting deposit, one task at a time.""" import asyncio listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) listener.client = object() listener._scripthash_to_user = {"sh1": 1, "sh2": 2} listener._pending_deposits = {1: [{"value": 5}]} started: list[int] = [] release = asyncio.Event() async def fake_refresh(user_id, scripthash): started.append(user_id) await release.wait() monkeypatch.setattr(listener, "refresh_user", fake_refresh) listener._refresh_waiting_deposits() listener._refresh_waiting_deposits() # a second block while the first refresh is still running await asyncio.sleep(0) assert started == [1] # user 2 has nothing waiting; user 1 is not refreshed twice at once release.set() await asyncio.sleep(0.01) listener._refresh_waiting_deposits() await asyncio.sleep(0) assert started == [1, 1] async def test_find_new_credit_candidates_still_lists_every_new_confirmed_output(session_factory): """The read half is unchanged: thresholds are applied by classify_new_deposits, between the read and the write (no session open across the coinbase lookup, B-18).""" user_id = await _user(session_factory) entries = [{"tx_hash": "05" * 32, "tx_pos": 0, "height": 100, "value": 1_000}] async with session_factory() as session: assert await find_new_credit_candidates(session, user_id, entries) == entries assert (await session.scalars(select(UtxoEvent))).all() == [] assert (await session.scalars(select(AuditLog))).all() == []