312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""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() == []
|