Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
A user could bet and then double-spend the 1-conference deposit that funded the bet with a private chain: the bet left the chain but its participant stayed `confirmed`, and the payout failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs were also credited at 1 confirmation (maturity is 120). Deposits - `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the threshold for a deposit; an output of a platform transaction (any txid of a pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120, recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached). `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no session is open across the lookup. - The quorum must also place the coin deep enough on our own tip (ELC-17). - Confirmed outputs short of their threshold are pending deposits (memory only, replaced at every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal message), never spendable. No scripthash notification announces a maturing deposit, so each new block refreshes only the users that have one, one task per user (DEP-09 now says so). Payout - `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same transaction, and the 70/30 split is computed on what is left. - Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93, with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears the mark and the next attempt verifies again. /admin/rounds returns it. - Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server `listunspent` choice, since that address holds no single round's bets. Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says "1 confirmation" (7 languages, same keys); guides updated. Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0 now set a tip past every threshold; the payout fixtures seed confirmed participants (the new source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the verification, and test_round_pool_address asserts the quorum is asked about the round's address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with the split recomputed, winner gone held and paused, held payouts untouched until the resume, re-verification after it, no quorum, a payout with 1 and with 400 participants against reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored coinbase, no height bound, no verification and no winner hold each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
daa85cf4f0
commit
54dbffb552
42 files changed
+1360
-270
No files matched your search
@@ -19,6 +19,12 @@ class FakeNetwork:
|
||||
self.asked: list[str] = []
|
||||
self.fanned_out: list[str] = []
|
||||
self.proofs_asked: list[tuple[str, int]] = []
|
||||
# B-82: what `verify_pool_outpoints` answers. Every bet output is present unless listed
|
||||
# in `pool_absent`; `pool_unavailable` models a quorum that could not be reached.
|
||||
self.pool_absent: set[tuple[str, int]] = set()
|
||||
self.pool_unavailable = False
|
||||
self.verified_scripthashes: list[str] = []
|
||||
self.verified_outpoints: list[list[tuple[str, int, int]]] = []
|
||||
|
||||
async def transaction_known(self, txid: str) -> bool | None:
|
||||
self.asked.append(txid)
|
||||
@@ -31,6 +37,16 @@ class FakeNetwork:
|
||||
self.proofs_asked.append((txid, height))
|
||||
return self.proven
|
||||
|
||||
async def verify_pool_outpoints(self, scripthash, outpoints):
|
||||
self.verified_scripthashes.append(scripthash)
|
||||
self.verified_outpoints.append(list(outpoints))
|
||||
hook = getattr(self, "on_verify", None)
|
||||
if hook is not None:
|
||||
await hook()
|
||||
if self.pool_unavailable:
|
||||
return None
|
||||
return {(txid, vout): (txid, vout) not in self.pool_absent for txid, vout, _amount in outpoints}
|
||||
|
||||
|
||||
MISSING_INPUTS = "bad-txns-inputs-missingorspent"
|
||||
|
||||
|
||||
@@ -265,6 +265,8 @@ async def test_config_update_without_changes_logs_nothing(client):
|
||||
{"rbf_timeout_seconds": 1},
|
||||
{"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip
|
||||
{"draw_confirmations": 21},
|
||||
{"deposit_confirmations": 0}, # B-82: a deposit credited with no confirmation at all
|
||||
{"deposit_confirmations": 101},
|
||||
],
|
||||
)
|
||||
async def test_config_rejects_unusable_values(client, payload):
|
||||
@@ -466,3 +468,47 @@ async def test_config_still_accepts_a_payable_change_and_unrelated_edits(client)
|
||||
assert resp.status_code == 200
|
||||
# an unrelated edit is never blocked by the payability check
|
||||
assert (await client.put("/admin/config", headers=headers, json={"draw_animation_seconds": 5})).status_code == 200
|
||||
|
||||
|
||||
async def test_deposit_confirmations_is_configurable_between_1_and_100(client): # B-82 (DAT-62, API-31)
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["deposit_confirmations"] == 6
|
||||
for value in (1, 100, 12):
|
||||
resp = await client.put("/admin/config", headers=headers, json={"deposit_confirmations": value})
|
||||
assert resp.status_code == 200 and resp.json()["deposit_confirmations"] == value
|
||||
audit = (await client.get("/admin/audit-log", headers=headers)).json()
|
||||
changes = [e["payload"]["deposit_confirmations"] for e in audit if e["event_type"] == "config_updated"]
|
||||
assert {"from": 100, "to": 12} in changes
|
||||
|
||||
|
||||
async def test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain(client): # B-82 (DAT-68, API-32)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import base as db_base
|
||||
from app.db.models import AuditLog, Round
|
||||
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
|
||||
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
session.add(Round(status="paying_out", payout_blocked_at=datetime.now(timezone.utc)))
|
||||
session.add(Round(status="closed"))
|
||||
await session.commit()
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
from app.rounds.pause import pause_automatically
|
||||
|
||||
await pause_automatically(session, "payout_blocked")
|
||||
await session.commit()
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["paused_reason"] == "payout_blocked"
|
||||
listed = (await client.get("/admin/rounds", headers=headers)).json() # API-36: the mark is visible
|
||||
assert sorted(r["payout_blocked_at"] is not None for r in listed) == [False, True]
|
||||
|
||||
resp = await client.post("/admin/resume", headers=headers)
|
||||
assert resp.status_code == 200 and resp.json()["paused"] is False and resp.json()["paused_reason"] is None
|
||||
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
assert (await session.scalars(select(Round.payout_blocked_at))).all() == [None, None]
|
||||
resumed = [e for e in (await session.scalars(select(AuditLog))).all() if e.event_type == "lottery_resumed"]
|
||||
assert resumed and '"payout_blocked"' in resumed[0].payload_json
|
||||
@@ -366,6 +366,7 @@ async def _flagged_user(session_factory) -> int:
|
||||
|
||||
def _reinstate_listener(session_factory, others: dict[str, _UnspentServer], active_entries):
|
||||
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
|
||||
listener.tip_height = 1000 # B-82: far past every credit threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(active_entries)
|
||||
return listener
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""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() == []
|
||||
@@ -405,6 +405,7 @@ async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(sessio
|
||||
}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
@@ -431,6 +432,7 @@ async def test_refresh_user_does_not_mark_when_corroboration_fails(session_facto
|
||||
{"first.example": [], "second.example": still_there, "third.example": still_there}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(_UNRELATED_ENTRY)
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
@@ -719,6 +721,7 @@ async def test_refresh_user_does_not_credit_a_utxo_only_our_own_server_reports(s
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
# Our own connection reports the tracked UTXO plus a phantom one nobody else has.
|
||||
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(
|
||||
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
|
||||
)
|
||||
@@ -742,6 +745,7 @@ async def test_refresh_user_credits_once_the_others_corroborate(session_factory)
|
||||
{"first.example": [], "second.example": _PHANTOM, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(
|
||||
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
|
||||
)
|
||||
|
||||
@@ -203,3 +203,13 @@ def test_the_bump_reserve_column_exists_at_head_and_is_nullable(migrated_db): #
|
||||
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
|
||||
assert "bump_reserve_sats" in rounds and rounds["bump_reserve_sats"][3] == 0
|
||||
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
|
||||
|
||||
|
||||
def test_deposit_confirmations_and_payout_blocked_columns_exist_at_head(migrated_db): # B-82 (DAT-62, DAT-68)
|
||||
with sqlite3.connect(migrated_db) as conn:
|
||||
config = {r[1]: r for r in conn.execute("PRAGMA table_info(round_config)")}
|
||||
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
|
||||
assert config["deposit_confirmations"][3] == 1 and config["deposit_confirmations"][4] in ("6", "'6'")
|
||||
assert "payout_blocked_at" in rounds and rounds["payout_blocked_at"][3] == 0
|
||||
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
|
||||
assert _index_sql(migrated_db, "ix_users_username_lower") is not None
|
||||
@@ -0,0 +1,319 @@
|
||||
"""B-82 (RND-22, RND-39, DAT-11, DAT-67, DAT-68, ELC-27, AUD-44, AUD-45): the coins of a round
|
||||
are verified with the quorum before a payout is built.
|
||||
|
||||
A user can bet and then — with a private chain, or just by being unlucky in a reorganisation —
|
||||
get the deposit that funded the bet erased. The bet leaves the chain but its participant stayed
|
||||
`confirmed`, so the payout looked for coins that no longer existed and failed "insufficient pool
|
||||
UTXOs" for ever, with every other player's money stuck. Now the bets that are gone leave the
|
||||
round, the 70/30 split is computed on what is really there, and a winner whose own bet is gone
|
||||
holds the payout and pauses the lottery.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import MIN_QUORUM_RESPONSES
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
|
||||
from app.electrum.client import ElectrumEndpoint
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from app.rounds.service import payout_config_problem, winner_share
|
||||
from app.wallet.hd import derive_pool_address
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import (
|
||||
DUST_LIMIT_SATS,
|
||||
MAX_PARTICIPANTS_PER_ROUND,
|
||||
MAX_TX_INPUTS,
|
||||
estimate_vsize,
|
||||
payout_bump_reserve,
|
||||
)
|
||||
from tests.unit.test_scheduler import ( # noqa: F401 (fixture and helpers)
|
||||
_FEE_ADDRESS,
|
||||
_WINNER_ADDRESS,
|
||||
FakePayoutClient,
|
||||
FakePayoutListener,
|
||||
_payout_bet_txid as _bet_txid,
|
||||
_seed_paying_out_round,
|
||||
payout_session_factory,
|
||||
)
|
||||
|
||||
_BET = 999_000_000 # the net amount of a 10 PLM bet, roughly
|
||||
|
||||
|
||||
def _scheduler(session_factory, listener=None):
|
||||
listener = listener or FakePayoutListener(FakePayoutClient([]))
|
||||
return RoundScheduler(session_factory, listener), listener
|
||||
|
||||
|
||||
async def _state(session_factory):
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, 1)
|
||||
participants = {
|
||||
p.bet_txid: p.status for p in (await session.scalars(select(RoundParticipant))).all()
|
||||
}
|
||||
events = [(e.event_type, e.payload_json) for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()]
|
||||
pending = (await session.scalars(select(PendingTransaction))).all()
|
||||
config = await session.scalar(select(RoundConfig))
|
||||
return round_, participants, events, pending, config
|
||||
|
||||
|
||||
def _names(events):
|
||||
return [name for name, _ in events]
|
||||
|
||||
|
||||
# --- everything is there -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else(payout_session_factory):
|
||||
"""RND-22: other coins at the pool address are not touched — the active connection is not
|
||||
even asked for the address' coins, and the inputs are the participants' `bet_txid:bet_vout`."""
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
stray = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 10, "value": 5_000_000_000}]
|
||||
listener.client = FakePayoutClient(stray)
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
assert listener.client.broadcasted
|
||||
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
|
||||
assert sorted((v.txid.hex(), v.vout) for v in tx.vin) == sorted((_bet_txid(i), 0) for i in range(3))
|
||||
assert listener.verified_scripthashes == [address_to_scripthash(derive_pool_address(1))]
|
||||
assert [(t, v, a) for t, v, a in listener.verified_outpoints[0]] == [(_bet_txid(i), 0, _BET) for i in range(3)]
|
||||
round_, participants, events, pending, _ = await _state(payout_session_factory)
|
||||
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 3 * _BET
|
||||
assert "participant_reorged_out" not in _names(events) and "payout_blocked" not in _names(events)
|
||||
|
||||
|
||||
# --- a bet that is gone (RND-39, DAT-67, AUD-45) ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there(payout_session_factory):
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET + 1, _BET + 2, _BET + 3])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
gone = (_bet_txid(2), 0) # a loser's bet, double-spent away
|
||||
listener.pool_absent = {gone}
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, participants, events, pending, _ = await _state(payout_session_factory)
|
||||
remaining = _BET + (_BET + 1) + (_BET + 3)
|
||||
assert participants[gone[0]] == "reorged_out"
|
||||
assert [s for t, s in participants.items() if t != gone[0]] == ["confirmed"] * 3
|
||||
assert round_.pool_amount_sats == remaining # DAT-67: rewritten, and only ever reduced
|
||||
row = next(p for n, p in events if n == "participant_reorged_out")
|
||||
assert f'"txid": "{gone[0]}"' in row and f'"pool_amount_sats": {remaining}' in row
|
||||
# the transaction spends the three bets that are there, and splits exactly their sum
|
||||
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
|
||||
assert gone not in {(v.txid.hex(), v.vout) for v in tx.vin}
|
||||
assert len(tx.vin) == 3
|
||||
fee = estimate_vsize(3, 3)
|
||||
reserve = payout_bump_reserve(3)
|
||||
assert tx.vout[0].value == winner_share(remaining) - fee == round_.winner_amount_sats
|
||||
assert tx.vout[1].value == remaining - winner_share(remaining) - reserve == round_.fee_amount_sats
|
||||
assert tx.vout[2].value == reserve == round_.bump_reserve_sats
|
||||
assert sum(o.value for o in tx.vout) + fee == remaining
|
||||
|
||||
|
||||
async def test_several_bets_gone_at_once_each_get_a_row_in_one_transaction(payout_session_factory):
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET] * 5)
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_absent = {(_bet_txid(1), 0), (_bet_txid(4), 0)}
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, participants, events, _, _ = await _state(payout_session_factory)
|
||||
assert sorted(t for t, s in participants.items() if s == "reorged_out") == sorted([_bet_txid(1), _bet_txid(4)])
|
||||
assert _names(events).count("participant_reorged_out") == 2
|
||||
assert round_.pool_amount_sats == 3 * _BET
|
||||
|
||||
|
||||
# --- the winner's own bet is gone (DAT-68, AUD-44) -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery(payout_session_factory):
|
||||
winner_id = await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_absent = {(_bet_txid(0), 0), (_bet_txid(2), 0)} # the winner's, and one more
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, participants, events, pending, config = await _state(payout_session_factory)
|
||||
assert listener.client.broadcasted == [] and pending == [] # nothing built, nothing sent
|
||||
assert round_.payout_blocked_at is not None and round_.status == "paying_out"
|
||||
assert (config.paused, config.paused_reason) == (True, "payout_blocked")
|
||||
names = _names(events)
|
||||
assert "payout_blocked" in names and "payout_failed" not in names # not a failure, not a retry clock
|
||||
assert ("lottery_paused", '{"reason": "payout_blocked"}') in events
|
||||
# the other missing bet is excluded now; the winner's stays `confirmed` so that it is
|
||||
# verified again after the resume (OPS-30)
|
||||
assert participants[_bet_txid(2)] == "reorged_out" and participants[_bet_txid(0)] == "confirmed"
|
||||
blocked = next(p for n, p in events if n == "payout_blocked")
|
||||
assert f'"txid": "{_bet_txid(0)}"' in blocked
|
||||
|
||||
|
||||
async def test_a_held_payout_is_not_attempted_again_until_the_lottery_is_resumed(payout_session_factory):
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_absent = {(_bet_txid(0), 0)}
|
||||
await scheduler._trigger_payout(1)
|
||||
asked = len(listener.verified_scripthashes)
|
||||
|
||||
for _ in range(3): # three scheduler steps over the held round
|
||||
await scheduler._retry_payout_if_due(1)
|
||||
await scheduler._trigger_payout(1)
|
||||
assert len(listener.verified_scripthashes) == asked # no verification, no build, no failure rows
|
||||
_, _, events, pending, _ = await _state(payout_session_factory)
|
||||
assert pending == [] and "payout_failed" not in _names(events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("still_gone", [True, False])
|
||||
async def test_after_the_resume_the_verification_repeats(payout_session_factory, still_gone):
|
||||
"""DAT-68: the operator's resume clears the mark; the next attempt verifies again. If the
|
||||
bet is still gone the payout is held and the lottery paused again (a new row); if it is
|
||||
back, the payout goes out."""
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_absent = {(_bet_txid(0), 0)}
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
async with payout_session_factory() as session: # what POST /admin/resume does
|
||||
round_ = await session.get(Round, 1)
|
||||
round_.payout_blocked_at = None
|
||||
(await session.scalar(select(RoundConfig))).paused = False
|
||||
await session.commit()
|
||||
|
||||
if not still_gone:
|
||||
listener.pool_absent = set()
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, _, events, pending, config = await _state(payout_session_factory)
|
||||
if still_gone:
|
||||
assert _names(events).count("payout_blocked") == 2
|
||||
assert round_.payout_blocked_at is not None and config.paused is True and pending == []
|
||||
else:
|
||||
assert round_.payout_blocked_at is None and len(pending) == 1 and "payout_sent" in _names(events)
|
||||
|
||||
|
||||
# --- no quorum (RND-25, ELC-27) ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_without_a_quorum_nothing_changes_and_the_attempt_is_a_payout_failed(payout_session_factory):
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_unavailable = True
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, participants, events, pending, config = await _state(payout_session_factory)
|
||||
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 2 * _BET
|
||||
assert pending == [] and listener.client.broadcasted == []
|
||||
assert [p for n, p in events if n == "payout_failed"] and "pool verification unavailable" in dict(events)["payout_failed"]
|
||||
assert config.paused is False and round_.payout_blocked_at is None
|
||||
|
||||
|
||||
# --- the extremes of RND-40, through the scheduler ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_round_of_one_participant_at_the_smallest_viable_bet_is_paid(payout_session_factory):
|
||||
"""The smallest net bet RND-40 allows (the config's own bound) still yields a payout whose
|
||||
three outputs are all at least dust."""
|
||||
bet = next(b for b in range(10_000, 200_000) if payout_config_problem(b + estimate_vsize(MAX_TX_INPUTS, 2), 1) is None)
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[bet])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
|
||||
assert len(tx.vin) == 1 and len(tx.vout) == 3
|
||||
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
|
||||
assert sum(o.value for o in tx.vout) + estimate_vsize(1, 3) == bet
|
||||
|
||||
|
||||
async def test_a_full_round_is_paid_with_the_reserve_fee_and_dust_all_covered(payout_session_factory):
|
||||
bet = 1_000_000_000 - estimate_vsize(MAX_TX_INPUTS, 2)
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[bet] * MAX_PARTICIPANTS_PER_ROUND)
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
|
||||
assert len(tx.vin) == MAX_PARTICIPANTS_PER_ROUND and len(tx.vout) == 3
|
||||
assert tx.vout[2].value == payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND)
|
||||
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
|
||||
assert sum(o.value for o in tx.vout) + estimate_vsize(MAX_PARTICIPANTS_PER_ROUND, 3) == bet * MAX_PARTICIPANTS_PER_ROUND
|
||||
|
||||
|
||||
# --- the quorum's verdict itself (ELC-27) -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
_ENDPOINTS = [
|
||||
ElectrumEndpoint("first.example", 50002, True),
|
||||
ElectrumEndpoint("second.example", 50002, True),
|
||||
ElectrumEndpoint("third.example", 50002, True),
|
||||
ElectrumEndpoint("fourth.example", 50002, True),
|
||||
]
|
||||
_OUTPOINT = ("aa" * 32, 0, 1_000)
|
||||
|
||||
|
||||
class _Server:
|
||||
def __init__(self, entries):
|
||||
self._entries = entries
|
||||
|
||||
async def connect(self):
|
||||
if self._entries is None:
|
||||
raise ConnectionRefusedError("down")
|
||||
|
||||
async def listunspent(self, scripthash):
|
||||
return self._entries
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _listener(answers: dict[str, object], session_factory=None):
|
||||
servers = {host: _Server(entries) for host, entries in answers.items()}
|
||||
return ElectrumListener(lambda endpoint: servers[endpoint.host], session_factory, _ENDPOINTS)
|
||||
|
||||
|
||||
def _listed(value=1_000, height=50):
|
||||
return [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": height, "value": value}]
|
||||
|
||||
|
||||
async def test_a_strict_majority_decides_each_outpoint():
|
||||
# first.example is the active endpoint (never asked); second, third, fourth vote
|
||||
present = _listener({"second.example": _listed(), "third.example": _listed(), "fourth.example": []})
|
||||
assert await present.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): True}
|
||||
absent = _listener({"second.example": _listed(), "third.example": [], "fourth.example": []})
|
||||
assert await absent.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
|
||||
|
||||
|
||||
async def test_another_amount_counts_as_absent_and_a_mempool_coin_as_neither():
|
||||
other_amount = _listener({h: _listed(value=999) for h in ("second.example", "third.example", "fourth.example")})
|
||||
assert await other_amount.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
|
||||
unconfirmed = _listener({h: _listed(height=0) for h in ("second.example", "third.example", "fourth.example")})
|
||||
assert await unconfirmed.verify_pool_outpoints("sh", [_OUTPOINT]) is None # may be mined again: no verdict
|
||||
|
||||
|
||||
async def test_too_few_answers_or_a_tie_conclude_nothing():
|
||||
assert MIN_QUORUM_RESPONSES == 2
|
||||
one_answer = _listener({"second.example": [], "third.example": None, "fourth.example": None})
|
||||
assert await one_answer.verify_pool_outpoints("sh", [_OUTPOINT]) is None
|
||||
tie = _listener({"second.example": _listed(), "third.example": [], "fourth.example": None})
|
||||
assert await tie.verify_pool_outpoints("sh", [_OUTPOINT]) is None
|
||||
# nothing at all: the active server is never a substitute for the quorum
|
||||
nobody = _listener({h: None for h in ("second.example", "third.example", "fourth.example")})
|
||||
assert await nobody.verify_pool_outpoints("sh", [_OUTPOINT]) is None
|
||||
|
||||
|
||||
async def test_the_active_server_is_not_asked_and_cannot_vouch_for_a_bet():
|
||||
class _Hostile(_Server):
|
||||
async def listunspent(self, scripthash):
|
||||
raise AssertionError("the active server must not be asked")
|
||||
|
||||
servers = {"first.example": _Hostile(_listed()), "second.example": _Server([]), "third.example": _Server([]), "fourth.example": _Server([])}
|
||||
listener = ElectrumListener(lambda endpoint: servers[endpoint.host], None, _ENDPOINTS)
|
||||
listener._endpoint_index = 0
|
||||
listener.client = object()
|
||||
assert await listener.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
|
||||
@@ -183,7 +183,11 @@ async def _payout(payout_session_factory, pool_address: str, round_id: int = 1):
|
||||
client = _RecordingPayoutClient(
|
||||
[{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS}]
|
||||
)
|
||||
await RoundScheduler(payout_session_factory, FakePayoutListener(client))._trigger_payout(round_id)
|
||||
listener = FakePayoutListener(client)
|
||||
await RoundScheduler(payout_session_factory, listener)._trigger_payout(round_id)
|
||||
# B-82: a round with its own address asks the quorum (the listener), not the active
|
||||
# connection, which is only asked for a round paid from the shared reserve address.
|
||||
client.scripthashes = client.scripthashes + listener.verified_scripthashes
|
||||
return client
|
||||
|
||||
|
||||
|
||||
@@ -120,7 +120,15 @@ async def payout_session_factory(tmp_path, monkeypatch):
|
||||
hd._account_key = None
|
||||
|
||||
|
||||
async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
|
||||
def _payout_bet_txid(index: int) -> str:
|
||||
return f"{index + 0x33:064x}"
|
||||
|
||||
|
||||
async def _seed_paying_out_round(session_factory, round_id: int = 1, bets: list[int] | None = None) -> int:
|
||||
"""A round in `paying_out` whose confirmed participants — the first one is the winner —
|
||||
each placed a bet of the given net amount (B-82: the payout spends exactly those bet
|
||||
outputs, `bet_txid:0`, so the pool is their sum)."""
|
||||
bets = bets if bets is not None else [_POOL_AMOUNT_SATS]
|
||||
async with session_factory() as session:
|
||||
winner = User(username="winner", password_hash="x", derivation_index=0, address=_WINNER_ADDRESS)
|
||||
session.add(winner)
|
||||
@@ -130,11 +138,27 @@ async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
|
||||
Round(
|
||||
id=round_id,
|
||||
status="paying_out",
|
||||
pool_amount_sats=_POOL_AMOUNT_SATS,
|
||||
pool_amount_sats=sum(bets),
|
||||
winner_user_id=winner.id,
|
||||
pool_address=derive_pool_address(round_id), # B-80
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
for index, amount in enumerate(bets):
|
||||
user = winner
|
||||
if index:
|
||||
user = User(
|
||||
username=f"bettor{index}", password_hash="x", derivation_index=index,
|
||||
address=derive_pool_address(7000 + index),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=round_id, user_id=user.id, bet_amount_sats=amount, bet_txid=_payout_bet_txid(index),
|
||||
bet_vout=0, status="confirmed", draw_position=index,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return winner.id
|
||||
|
||||
@@ -206,18 +230,14 @@ async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_in
|
||||
so a round past MAX_TX_INPUTS participants could not be paid at all — the build
|
||||
failed with too_many_inputs, the round stayed "paying_out" retrying every 60s,
|
||||
and no new round could ever open behind it. It must now broadcast normally."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
|
||||
participants = MAX_TX_INPUTS + 1
|
||||
bet_sats = _POOL_AMOUNT_SATS // participants
|
||||
entries = [
|
||||
{"tx_hash": f"{i:064x}", "tx_pos": 0, "height": 10, "value": bet_sats}
|
||||
for i in range(participants)
|
||||
]
|
||||
# The pool's total must cover the round's recorded pool_amount_sats, exactly as
|
||||
# on-chain: integer division above leaves a remainder, so top the last one up.
|
||||
entries[-1]["value"] += _POOL_AMOUNT_SATS - bet_sats * participants
|
||||
client = FakePayoutClient(entries)
|
||||
bets = [bet_sats] * participants
|
||||
# The pool's total is the round's recorded pool_amount_sats: integer division above
|
||||
# leaves a remainder, so top the last one up.
|
||||
bets[-1] += _POOL_AMOUNT_SATS - bet_sats * participants
|
||||
await _seed_paying_out_round(payout_session_factory, bets=bets)
|
||||
client = FakePayoutClient([])
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
@@ -671,7 +671,11 @@ def _payout_scheduler(session_factory, client):
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from tests.unit.test_scheduler import FakePayoutListener
|
||||
|
||||
return RoundScheduler(session_factory, FakePayoutListener(client))
|
||||
listener = FakePayoutListener(client)
|
||||
# B-82: the network phase of a payout is the quorum's verification of the round's coins,
|
||||
# so that is where a test injects "the lock is lost while the network is being used".
|
||||
listener.on_verify = getattr(client, "_hook_listunspent", None)
|
||||
return RoundScheduler(session_factory, listener)
|
||||
|
||||
|
||||
class _PayoutClient:
|
||||
|
||||
@@ -176,3 +176,16 @@ async def test_me_created_at_is_utc_stamped(client):
|
||||
assert resp.status_code == 200
|
||||
created_at = resp.json()["created_at"]
|
||||
assert created_at.endswith("+00:00") or created_at.endswith("Z")
|
||||
|
||||
|
||||
async def test_me_shows_waiting_deposits_in_the_pending_balance_only(client): # B-82 (DEP-12 (b), DEP-14)
|
||||
token = await _register(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = (await client.get("/users/me", headers=headers)).json()
|
||||
assert (me["balance_sats"], me["pending_balance_sats"], me["has_pending"]) == (0, 0, False)
|
||||
|
||||
listener = client._transport.app.state.electrum_listener
|
||||
listener._pending_deposits[me["id"]] = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 5, "value": 250_000_000}]
|
||||
shown = (await client.get("/users/me", headers=headers)).json()
|
||||
assert shown["balance_sats"] == 0 # not spendable
|
||||
assert shown["pending_balance_sats"] == 250_000_000 and shown["has_pending"] is True
|
||||
Reference in new issue
Block a user