Files
plm-lottery/tests/unit/test_deposit_confirmations.py
T

312 lines
14 KiB
Python
Raw Normal View History

"""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() == []