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
@@ -3,7 +3,7 @@ import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import http_error
|
||||
@@ -47,6 +47,7 @@ _CONFIG_FIELDS = (
|
||||
"fee_rate_sat_vb",
|
||||
"rbf_timeout_seconds",
|
||||
"draw_animation_seconds",
|
||||
"deposit_confirmations", # B-82 (DAT-62)
|
||||
"draw_confirmations", # B-73 (DAT-62)
|
||||
)
|
||||
|
||||
@@ -59,6 +60,7 @@ class RoundConfigResponse(BaseModel):
|
||||
fee_rate_sat_vb: int
|
||||
rbf_timeout_seconds: int
|
||||
draw_animation_seconds: int
|
||||
deposit_confirmations: int # B-82 (DAT-62)
|
||||
draw_confirmations: int # B-73 (DAT-62)
|
||||
paused: bool
|
||||
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
|
||||
@@ -81,6 +83,7 @@ class RoundConfigUpdate(BaseModel):
|
||||
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
|
||||
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
|
||||
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
|
||||
deposit_confirmations: int | None = Field(default=None, ge=1, le=100) # B-82 (DAT-62)
|
||||
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
|
||||
|
||||
@field_validator("fee_address")
|
||||
@@ -186,6 +189,9 @@ async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundC
|
||||
lifted = config.paused_reason if config.paused else None
|
||||
config.paused = False
|
||||
config.paused_reason = None
|
||||
# B-82 (DAT-68, API-32): a payout held because the winner's bet left the chain is released
|
||||
# for a new attempt, which verifies the coins again, in the same transaction as the resume.
|
||||
await session.execute(update(Round).where(Round.payout_blocked_at.is_not(None)).values(payout_blocked_at=None))
|
||||
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
@@ -287,6 +293,7 @@ class AdminRoundResponse(BaseModel):
|
||||
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
|
||||
draw_height: int | None # B-73
|
||||
bump_reserve_sats: int | None # B-79 (API-36)
|
||||
payout_blocked_at: str | None # B-82 (API-36, DAT-68)
|
||||
|
||||
|
||||
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
|
||||
@@ -318,6 +325,7 @@ async def list_rounds(
|
||||
server_seed_hash=r.server_seed_hash,
|
||||
draw_height=r.draw_height,
|
||||
bump_reserve_sats=r.bump_reserve_sats,
|
||||
payout_blocked_at=isoformat_utc(r.payout_blocked_at),
|
||||
)
|
||||
for r in rounds
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -31,10 +31,15 @@ class MeResponse(BaseModel):
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MeResponse:
|
||||
pending_balance_sats, has_pending = await compute_pending_balance(session, user)
|
||||
# B-82 (DEP-12 (b)): deposits confirmed but short of their threshold, kept in memory by
|
||||
# the listener (absent in an app without one, e.g. a test of this route alone).
|
||||
listener = getattr(request.app.state, "electrum_listener", None)
|
||||
waiting_sats = listener.pending_deposit_sats(user.id) if listener is not None else 0
|
||||
pending_balance_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
|
||||
return MeResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
|
||||
@@ -114,6 +114,9 @@ class Round(Base):
|
||||
# the reserve address and is what an RBF bump shrinks, so this is never rewritten by a bump).
|
||||
bump_reserve_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
|
||||
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
# B-82 (DAT-68): set when the verification before the payout finds the *winner's* bet gone
|
||||
# from the chain; while it is set no payout is built. Cleared by POST /admin/resume.
|
||||
payout_blocked_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
|
||||
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
|
||||
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
|
||||
@@ -184,6 +187,9 @@ class RoundConfig(Base):
|
||||
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
|
||||
# applies from the next round.
|
||||
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
|
||||
# B-82 (DAT-62, DEP-03): confirmations after which an output paying a user is credited
|
||||
# as a deposit (platform transactions keep 1, coinbase outputs 120).
|
||||
deposit_confirmations: Mapped[int] = mapped_column(default=6, server_default="6")
|
||||
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
|
||||
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
|
||||
# Maintenance switch: when true, the round currently in progress still runs to
|
||||
|
||||
+85
-1
@@ -1,17 +1,33 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import UtxoEvent
|
||||
from app.db.models import PendingTransaction, PendingTransactionTxid, RoundConfig, UtxoEvent
|
||||
from app.rounds.events import broadcaster
|
||||
from app.wallet.balance import recompute_balance
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# B-82 (DEP-03): confirmations a coinbase output needs before it can be spent, PLM's
|
||||
# COINBASE_MATURITY (palladiumcore/src/consensus/consensus.h). A coinbase paying a user's
|
||||
# address is credited no earlier than this, whatever deposit_confirmations says.
|
||||
COINBASE_MATURITY = 120
|
||||
|
||||
# Confirmations an output created by a transaction of the platform itself — the change of
|
||||
# a bet or a withdrawal, a payout — needs: the platform built it, nobody else could have
|
||||
# double-spent its inputs (DEP-03).
|
||||
PLATFORM_TRANSACTION_CONFIRMATIONS = 1
|
||||
|
||||
# Default of round_config.deposit_confirmations, used only when the config row does not
|
||||
# exist yet (get_round_config creates it with the same value).
|
||||
DEFAULT_DEPOSIT_CONFIRMATIONS = 6
|
||||
|
||||
|
||||
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
|
||||
"""The subset of `entries` that would actually credit something: confirmed
|
||||
@@ -41,6 +57,74 @@ async def find_new_credit_candidates(session: AsyncSession, user_id: int, entrie
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DepositPolicy:
|
||||
"""What decides how many confirmations an incoming output needs (DEP-03), read from the
|
||||
database before any network call: the configured deposit threshold and which of the
|
||||
transactions involved are the platform's own."""
|
||||
|
||||
deposit_confirmations: int = DEFAULT_DEPOSIT_CONFIRMATIONS
|
||||
platform_txids: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
async def read_deposit_policy(session: AsyncSession, txids: set[str]) -> DepositPolicy:
|
||||
"""The deposit threshold and, among `txids`, the ones created by a platform transaction:
|
||||
any txid of a `pending_transactions` row, replaced RBF versions included (B-77)."""
|
||||
configured = await session.scalar(select(RoundConfig.deposit_confirmations))
|
||||
platform: set[str] = set()
|
||||
if txids:
|
||||
platform |= set(
|
||||
(await session.scalars(select(PendingTransactionTxid.txid).where(PendingTransactionTxid.txid.in_(txids))))
|
||||
)
|
||||
platform |= set(
|
||||
(await session.scalars(select(PendingTransaction.current_txid).where(PendingTransaction.current_txid.in_(txids))))
|
||||
)
|
||||
return DepositPolicy(
|
||||
deposit_confirmations=configured if configured else DEFAULT_DEPOSIT_CONFIRMATIONS, platform_txids=platform
|
||||
)
|
||||
|
||||
|
||||
async def classify_new_deposits(
|
||||
new_entries: list[dict],
|
||||
tip_height: int,
|
||||
policy: DepositPolicy,
|
||||
is_coinbase: Callable[[str], Awaitable[bool | None]],
|
||||
) -> tuple[list[tuple[dict, int]], list[dict]]:
|
||||
"""Split the new, confirmed, non-dust entries of `find_new_credit_candidates` into the
|
||||
ones that may now be credited — as `(entry, required_confirmations)`, the number the
|
||||
quorum is then asked to confirm (ELC-17) — and the ones still **waiting** for
|
||||
confirmations (B-82, DEP-03, DEP-14).
|
||||
|
||||
Confirmations are `tip_height - height + 1`. The threshold is 1 for an output of a
|
||||
platform transaction, 120 (`COINBASE_MATURITY`) for a coinbase output, and
|
||||
`deposit_confirmations` for anything else; a coinbase is only looked for once the plain
|
||||
threshold is met, since below it the output waits anyway. `is_coinbase` answers from the
|
||||
raw transaction (hash-checked by the caller) and may return None when it cannot tell, in
|
||||
which case the output waits and is asked again at the next refresh. No database session
|
||||
is involved: this runs between the read and the write phases, where the network is used.
|
||||
"""
|
||||
candidates: list[tuple[dict, int]] = []
|
||||
waiting: list[dict] = []
|
||||
for entry in new_entries:
|
||||
confirmations = tip_height - entry["height"] + 1
|
||||
if entry["tx_hash"] in policy.platform_txids:
|
||||
required = PLATFORM_TRANSACTION_CONFIRMATIONS
|
||||
else:
|
||||
required = policy.deposit_confirmations
|
||||
if required <= confirmations < COINBASE_MATURITY:
|
||||
# Plain threshold met, maturity not: only now is it worth knowing whether
|
||||
# this is a coinbase output (which would still be immature).
|
||||
coinbase = await is_coinbase(entry["tx_hash"])
|
||||
if coinbase is None or coinbase:
|
||||
waiting.append(entry)
|
||||
continue
|
||||
if confirmations < required:
|
||||
waiting.append(entry)
|
||||
continue
|
||||
candidates.append((entry, required))
|
||||
return candidates, waiting
|
||||
|
||||
|
||||
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
|
||||
"""Insert utxo_events for newly-confirmed entries from an Electrum
|
||||
`listunspent` response (idempotent on txid+vout), refresh the user's cached
|
||||
|
||||
+145
-7
@@ -12,11 +12,13 @@ from app.audit.log import write_audit_log
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import User
|
||||
from app.deposits.service import (
|
||||
classify_new_deposits,
|
||||
credit_confirmed_utxos,
|
||||
find_new_credit_candidates,
|
||||
find_reinstate_candidates,
|
||||
find_utxos_missing_from,
|
||||
mark_utxos_spent_externally,
|
||||
read_deposit_policy,
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
from app.config import MIN_QUORUM_RESPONSES
|
||||
@@ -120,6 +122,15 @@ class ElectrumListener:
|
||||
# B-86: when the last header was accepted (monotonic seconds), None until one is.
|
||||
self._last_header_at: float | None = None
|
||||
self.client: ElectrumClient | None = None
|
||||
# B-82 (DEP-14): per user, the deposits seen at the last refresh that are confirmed
|
||||
# but still short of their threshold — what the displayed balance shows as pending.
|
||||
# Memory only: it moves no money and is rebuilt by the first refresh after a restart.
|
||||
self._pending_deposits: dict[int, list[dict]] = {}
|
||||
# txid -> is it a coinbase (DEP-03). A transaction never changes, so this is safe to
|
||||
# keep; bounded so a long-lived process does not grow it without limit.
|
||||
self._coinbase_cache: dict[str, bool] = {}
|
||||
# Users with a refresh already running because of a new block (see _consume_headers).
|
||||
self._block_refreshes: set[int] = set()
|
||||
|
||||
@property
|
||||
def current_endpoint(self) -> ElectrumEndpoint | None:
|
||||
@@ -819,7 +830,9 @@ class ElectrumListener:
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
|
||||
|
||||
async def corroborate_utxo_credit(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
|
||||
async def corroborate_utxo_credit(
|
||||
self, scripthash: str, txid: str, vout: int, value: int, required_confirmations: int | None = None
|
||||
) -> bool:
|
||||
"""B-59: the mirror of corroborate_utxo_spent, for money going the other
|
||||
way. A candidate external spend could not reduce a balance without a
|
||||
quorum, but `value` and `height` for a *credit* were taken from the single
|
||||
@@ -841,8 +854,11 @@ class ElectrumListener:
|
||||
only credited once the quorum agrees.
|
||||
"""
|
||||
|
||||
# B-82 (ELC-17): the other server must also place the coin deep enough — at a height
|
||||
# that gives it `required_confirmations` counted on *our* tip, not on its own.
|
||||
max_height = None if required_confirmations is None else self.tip_height - required_confirmations + 1
|
||||
return await self._corroborate_unspent_confirmed(
|
||||
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}"
|
||||
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}", max_height=max_height
|
||||
)
|
||||
|
||||
async def corroborate_utxo_reinstatement(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
|
||||
@@ -856,10 +872,11 @@ class ElectrumListener:
|
||||
)
|
||||
|
||||
async def _corroborate_unspent_confirmed(
|
||||
self, scripthash: str, txid: str, vout: int, value: int, description: str
|
||||
self, scripthash: str, txid: str, vout: int, value: int, description: str, max_height: int | None = None
|
||||
) -> bool:
|
||||
"""Do the other servers list this outpoint as unspent, for the same amount, and
|
||||
confirmed (height > 0)? Shared by credits (B-59) and reinstatements (B-83)."""
|
||||
confirmed (height > 0, and no higher than `max_height` when one is given)? Shared by
|
||||
credits (B-59, B-82) and reinstatements (B-83)."""
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
|
||||
client = self._client_factory(endpoint)
|
||||
@@ -873,6 +890,7 @@ class ElectrumListener:
|
||||
and e.get("tx_pos") == vout
|
||||
and e.get("value") == value
|
||||
and (e.get("height") or 0) > 0
|
||||
and (max_height is None or (e.get("height") or 0) <= max_height)
|
||||
for e in entries
|
||||
)
|
||||
except Exception:
|
||||
@@ -882,6 +900,59 @@ class ElectrumListener:
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda agrees: agrees, description)
|
||||
|
||||
async def verify_pool_outpoints(
|
||||
self, scripthash: str, outpoints: list[tuple[str, int, int]]
|
||||
) -> dict[tuple[str, int], bool] | None:
|
||||
"""Which of a round's bet outputs (`(txid, vout, amount)`) are really there, at the
|
||||
round's pool address, according to the *other* servers (B-82, ELC-27, RND-39).
|
||||
|
||||
Every other server lists the address' unspent coins; each answer says, per outpoint,
|
||||
*present* (listed, same amount, confirmed), *absent* (not listed, or listed with
|
||||
another amount) or neither (listed but unconfirmed — a block that left the chain
|
||||
may well come back, which is no reason to throw a bet out). An outpoint is present
|
||||
or absent when a strict majority of the answers say so. The result maps every
|
||||
outpoint to True (present) or False (absent); it is None — nothing concluded — when
|
||||
fewer than the quorum minimum (ELC-14) of servers answered or an outpoint has no
|
||||
majority either way, so a participant is only ever excluded on the quorum's word and
|
||||
the caller simply tries again later. With ALLOW_NO_QUORUM=true and no other server
|
||||
configured the active server answers instead (development only)."""
|
||||
|
||||
async def _ask(client: ElectrumClient) -> dict[tuple[str, int], tuple[int, int]] | None:
|
||||
entries = await client.listunspent(scripthash)
|
||||
return {(e["tx_hash"], e["tx_pos"]): (e["value"], e.get("height") or 0) for e in entries}
|
||||
|
||||
answers = [a for a in await self._ask_other_servers(_ask) if a is not None]
|
||||
if not self._other_endpoints() and self._allow_no_quorum:
|
||||
answers = [a for a in [await self._ask_active_server(_ask)] if a is not None]
|
||||
elif len(answers) < max(self._minimum_other_answers(), 1):
|
||||
logger.warning(
|
||||
"could not verify the pool coins at %s: %s answer(s) from the other servers, %s needed",
|
||||
scripthash,
|
||||
len(answers),
|
||||
max(self._minimum_other_answers(), 1),
|
||||
)
|
||||
return None
|
||||
if not answers:
|
||||
return None
|
||||
|
||||
result: dict[tuple[str, int], bool] = {}
|
||||
for txid, vout, amount in outpoints:
|
||||
present = absent = 0
|
||||
for answer in answers:
|
||||
listed = answer.get((txid, vout))
|
||||
if listed is None or listed[0] != amount:
|
||||
absent += 1
|
||||
elif listed[1] > 0:
|
||||
present += 1
|
||||
if present * 2 > len(answers):
|
||||
result[(txid, vout)] = True
|
||||
elif absent * 2 > len(answers):
|
||||
result[(txid, vout)] = False
|
||||
else:
|
||||
logger.warning("the servers do not agree whether %s:%s is still on the chain", txid, vout)
|
||||
return None
|
||||
return result
|
||||
|
||||
# --- Confirmations proven by merkle branch (B-83, TX-22, ELC-26) ---------------------
|
||||
|
||||
async def _header_at(self, height: int) -> str | None:
|
||||
@@ -942,6 +1013,63 @@ class ElectrumListener:
|
||||
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
|
||||
# refetch instead of waiting for their next poll.
|
||||
broadcaster.publish()
|
||||
self._refresh_waiting_deposits()
|
||||
|
||||
def _refresh_waiting_deposits(self) -> None:
|
||||
"""A deposit that is waiting for confirmations (DEP-14) does not change its address'
|
||||
status when the next block arrives — the history is the same, only the tip moved — so
|
||||
no scripthash notification will ever announce that it has matured. Refresh the users
|
||||
that have one at each new block, one task per user at a time, instead of leaving it to
|
||||
the 300s sweep (B-82, DEP-09)."""
|
||||
if self.client is None:
|
||||
return
|
||||
scripthash_of = {user_id: scripthash for scripthash, user_id in self._scripthash_to_user.items()}
|
||||
for user_id, waiting in list(self._pending_deposits.items()):
|
||||
scripthash = scripthash_of.get(user_id)
|
||||
if not waiting or scripthash is None or user_id in self._block_refreshes:
|
||||
continue
|
||||
self._block_refreshes.add(user_id)
|
||||
task = asyncio.create_task(self._refresh_after_block(user_id, scripthash))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
async def _refresh_after_block(self, user_id: int, scripthash: str) -> None:
|
||||
try:
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
except Exception:
|
||||
logger.exception("refreshing user_id=%s after a new block failed", user_id)
|
||||
finally:
|
||||
self._block_refreshes.discard(user_id)
|
||||
|
||||
def pending_deposit_sats(self, user_id: int) -> int:
|
||||
"""Total of the user's deposits that are confirmed but short of their threshold
|
||||
(DEP-12 (b), DEP-14); 0 when there are none or nothing has been refreshed yet."""
|
||||
return sum(entry["value"] for entry in self._pending_deposits.get(user_id, []))
|
||||
|
||||
async def is_coinbase(self, txid: str) -> bool | None:
|
||||
"""Is `txid` a coinbase transaction (DEP-03)? Read from the raw transaction, which is
|
||||
accepted only if it hashes to `txid` — so the server cannot misdescribe it — and
|
||||
whose single input has the null outpoint. None when it could not be fetched or did
|
||||
not match; the caller then simply asks again at the next refresh."""
|
||||
cached = self._coinbase_cache.get(txid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if self.client is None:
|
||||
return None
|
||||
try:
|
||||
raw = await self.client.get_transaction(txid, verbose=False)
|
||||
tx = Transaction.parse(bytes.fromhex(raw))
|
||||
except Exception:
|
||||
logger.warning("could not read transaction %s to tell whether it is a coinbase", txid)
|
||||
return None
|
||||
if tx.txid().hex() != txid:
|
||||
logger.warning("transaction %s: the server returned one that hashes to %s", txid, tx.txid().hex())
|
||||
return None
|
||||
result = len(tx.vin) == 1 and tx.vin[0].txid == bytes(32) and tx.vin[0].vout == 0xFFFFFFFF
|
||||
if len(self._coinbase_cache) >= 10_000:
|
||||
self._coinbase_cache.clear()
|
||||
self._coinbase_cache[txid] = result
|
||||
return result
|
||||
|
||||
async def _consume_scripthash(self, queue: asyncio.Queue) -> None:
|
||||
while True:
|
||||
@@ -960,7 +1088,8 @@ class ElectrumListener:
|
||||
entries = await self.client.listunspent(scripthash)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
credit_candidates = await find_new_credit_candidates(session, user_id, entries)
|
||||
new_entries = await find_new_credit_candidates(session, user_id, entries)
|
||||
policy = await read_deposit_policy(session, {entry["tx_hash"] for entry in new_entries})
|
||||
# B-83 (DEP-02): candidates only — nothing is written before the quorum agrees.
|
||||
reinstate_candidates = await find_reinstate_candidates(session, user_id, entries)
|
||||
candidates = [
|
||||
@@ -968,11 +1097,20 @@ class ElectrumListener:
|
||||
for row in await find_utxos_missing_from(session, user_id, entries)
|
||||
]
|
||||
|
||||
# B-82 (DEP-03, DEP-14): which of the new outputs have reached their threshold; the
|
||||
# others are remembered as pending deposits (memory only) and shown as such.
|
||||
credit_candidates, waiting_deposits = await classify_new_deposits(
|
||||
new_entries, self.tip_height, policy, self.is_coinbase
|
||||
)
|
||||
self._pending_deposits[user_id] = waiting_deposits
|
||||
if not waiting_deposits:
|
||||
self._pending_deposits.pop(user_id, None)
|
||||
|
||||
corroborated_credits = [
|
||||
entry
|
||||
for entry in credit_candidates
|
||||
for entry, required in credit_candidates
|
||||
if await self.corroborate_utxo_credit(
|
||||
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
|
||||
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"], required_confirmations=required
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
+141
-8
@@ -22,6 +22,7 @@ from app.rounds.draw import (
|
||||
server_seed_commitment,
|
||||
)
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.pause import pause_automatically
|
||||
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
|
||||
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
|
||||
from app.tx.versions import record_version
|
||||
@@ -437,6 +438,8 @@ class RoundScheduler:
|
||||
either way it's due immediately.
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
if await session.scalar(select(Round.payout_blocked_at).where(Round.id == round_id)) is not None:
|
||||
return # B-82 (DAT-68): held until an operator resumes the lottery
|
||||
last_failure_at = await session.scalar(
|
||||
select(AuditLog.created_at)
|
||||
.where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id)
|
||||
@@ -477,6 +480,11 @@ class RoundScheduler:
|
||||
logger.warning("round %s payout deferred: database busy", round_id)
|
||||
return
|
||||
round_ = await session.get(Round, round_id)
|
||||
if round_.payout_blocked_at is not None:
|
||||
# B-82 (DAT-68): held since the winner's own bet left the chain. Nothing is built
|
||||
# until an operator resumes the lottery, which clears the mark (and the next
|
||||
# attempt verifies the coins again).
|
||||
return
|
||||
already_in_flight = await session.scalar(
|
||||
select(PendingTransaction).where(
|
||||
PendingTransaction.round_id == round_id,
|
||||
@@ -504,6 +512,18 @@ class RoundScheduler:
|
||||
winner_user_id = round_.winner_user_id
|
||||
winner = await session.get(User, winner_user_id)
|
||||
winner_address = winner.address if winner is not None else None
|
||||
# B-82 (RND-22, RND-39): the bets the payout may spend and must first verify — the
|
||||
# round's confirmed participants, each with the output that pays the pool.
|
||||
bet_rows = [
|
||||
(p.id, p.user_id, p.bet_txid, p.bet_vout, p.bet_amount_sats)
|
||||
for p in (
|
||||
await session.scalars(
|
||||
select(RoundParticipant)
|
||||
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
|
||||
.order_by(RoundParticipant.draw_position)
|
||||
)
|
||||
).all()
|
||||
]
|
||||
await session.commit() # get_round_config may have created the row
|
||||
|
||||
if not fee_address:
|
||||
@@ -517,8 +537,10 @@ class RoundScheduler:
|
||||
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
|
||||
return
|
||||
|
||||
winner_sats = winner_share(pool_amount_sats)
|
||||
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
|
||||
# A round opened before the per-round pool address (B-80) pays from the shared reserve
|
||||
# address, whose coins are not the bets of one round: it keeps the old way of choosing
|
||||
# them. Every other round is verified and paid from its own bets (RND-22, RND-39).
|
||||
legacy_pool = round_pool_address == derive_pool_address()
|
||||
|
||||
# --- Phase 2: build (network read only, no DB write yet) -----------------
|
||||
try:
|
||||
@@ -528,12 +550,33 @@ class RoundScheduler:
|
||||
pool_key = derive_round_pool_key(round_id, round_pool_address)
|
||||
pool_script_obj = script.p2wpkh(pool_key.to_public())
|
||||
pool_address = pool_script_obj.address(network=PLM_MAINNET)
|
||||
entries = await client.listunspent(address_to_scripthash(pool_address))
|
||||
utxos = [
|
||||
Utxo(e["tx_hash"], e["tx_pos"], e["value"])
|
||||
for e in entries
|
||||
if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
|
||||
]
|
||||
if not legacy_pool:
|
||||
# B-82 (RND-39): with the quorum, before anything is built — bets that left the
|
||||
# chain drop out and the split is computed on what is really there.
|
||||
verified = await self._verify_round_pool(
|
||||
round_id, pool_address, winner_user_id, bet_rows, pool_amount_sats
|
||||
)
|
||||
if verified is None:
|
||||
return # held, unverifiable or database busy — already recorded
|
||||
bet_rows, pool_amount_sats = verified
|
||||
winner_sats = winner_share(pool_amount_sats)
|
||||
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
|
||||
if legacy_pool:
|
||||
entries = await client.listunspent(address_to_scripthash(pool_address))
|
||||
utxos = [
|
||||
Utxo(e["tx_hash"], e["tx_pos"], e["value"])
|
||||
for e in entries
|
||||
if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
|
||||
]
|
||||
else:
|
||||
# B-82 (RND-22): exactly the verified bet outputs of this round, nothing else
|
||||
# that happens to sit at the address, and none already claimed by an unresolved
|
||||
# payout.
|
||||
utxos = [
|
||||
Utxo(txid, vout, amount)
|
||||
for _id, _user_id, txid, vout, amount in bet_rows
|
||||
if (txid, vout) not in reserved_outpoints
|
||||
]
|
||||
|
||||
built = build_payout_transaction(
|
||||
signing_key=pool_key,
|
||||
@@ -634,6 +677,96 @@ class RoundScheduler:
|
||||
|
||||
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
|
||||
|
||||
async def _verify_round_pool(
|
||||
self,
|
||||
round_id: int,
|
||||
pool_address: str,
|
||||
winner_user_id: int | None,
|
||||
bet_rows: list[tuple[int, int, str, int, int]],
|
||||
pool_amount_sats: int | None,
|
||||
) -> tuple[list[tuple[int, int, str, int, int]], int] | None:
|
||||
"""B-82 (RND-39, DAT-67, DAT-68, ELC-27): before a payout is built, check with the quorum
|
||||
that each confirmed participant's bet output is still at the round's pool address.
|
||||
|
||||
Returns the participants that are really there with the pool amount recomputed on them
|
||||
(the same pair when nothing was missing), or None when this attempt must stop: the
|
||||
quorum could not be reached (`payout_failed`, `pool verification unavailable`), the
|
||||
database was busy, or the **winner's own bet is gone** — then the payout is held
|
||||
(`payout_blocked`, the lottery pauses) and only an operator's resume tries again.
|
||||
|
||||
Participants whose bet is gone become `reorged_out` and leave the pool, one
|
||||
`participant_reorged_out` row each, in one transaction with the rewritten
|
||||
`pool_amount_sats` (which can only go down). The winner's own row is left `confirmed`
|
||||
in the held case, precisely so that the verification repeats after the resume."""
|
||||
verdicts = await self._listener.verify_pool_outpoints(
|
||||
address_to_scripthash(pool_address), [(txid, vout, amount) for _i, _u, txid, vout, amount in bet_rows]
|
||||
)
|
||||
if verdicts is None:
|
||||
logger.error("round %s payout deferred: the pool coins could not be verified with the quorum", round_id)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "pool verification unavailable")
|
||||
return None
|
||||
gone = [row for row in bet_rows if not verdicts[(row[2], row[3])]]
|
||||
if not gone:
|
||||
return bet_rows, pool_amount_sats
|
||||
|
||||
winner_gone = next((row for row in gone if row[1] == winner_user_id), None)
|
||||
async with self._session_factory() as session:
|
||||
try:
|
||||
await begin_immediate(session) # B-88
|
||||
except OperationalError:
|
||||
logger.warning("round %s payout deferred: database busy while excluding reorged-out bets", round_id)
|
||||
return None
|
||||
round_ = await session.get(Round, round_id)
|
||||
excluded = [row for row in gone if row is not winner_gone]
|
||||
for participant_id, user_id, txid, vout, amount in excluded:
|
||||
participant = await session.get(RoundParticipant, participant_id)
|
||||
if participant is None or participant.status != "confirmed":
|
||||
continue
|
||||
participant.status = "reorged_out"
|
||||
await session.flush()
|
||||
remaining = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
new_pool = sum(p.bet_amount_sats for p in remaining)
|
||||
if pool_amount_sats is not None:
|
||||
new_pool = min(new_pool, pool_amount_sats) # DAT-67: only ever reduced
|
||||
round_.pool_amount_sats = new_pool
|
||||
for _participant_id, user_id, txid, vout, amount in excluded:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"participant_reorged_out",
|
||||
{"txid": txid, "vout": vout, "amount_sats": amount, "pool_amount_sats": new_pool},
|
||||
user_id=user_id,
|
||||
round_id=round_id,
|
||||
)
|
||||
if winner_gone is not None:
|
||||
_pid, user_id, txid, vout, amount = winner_gone
|
||||
round_.payout_blocked_at = datetime.now(timezone.utc)
|
||||
await write_audit_log(
|
||||
session,
|
||||
"payout_blocked",
|
||||
{"txid": txid, "vout": vout, "amount_sats": amount},
|
||||
user_id=user_id,
|
||||
round_id=round_id,
|
||||
)
|
||||
logger.error(
|
||||
"round %s payout held: the winner's bet %s:%s is no longer on the chain; lottery paused",
|
||||
round_id,
|
||||
txid,
|
||||
vout,
|
||||
)
|
||||
await pause_automatically(session, "payout_blocked") # same transaction (AUD-44)
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
if winner_gone is not None:
|
||||
return None
|
||||
gone_ids = {row[0] for row in gone}
|
||||
return [row for row in bet_rows if row[0] not in gone_ids], new_pool
|
||||
|
||||
async def _log_payout_failure(self, round_id: int, winner_user_id: int | None, reason: str) -> None:
|
||||
"""Leaves an operator-visible trace in the audit log for a round stuck in
|
||||
"paying_out" — the logs alone don't show up in /admin. Called from every
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
|
||||
<label for="admin-draw-confirmations">Conferme sul blocco di estrazione (1–20, dal round successivo)</label>
|
||||
<input id="admin-draw-confirmations" inputmode="numeric" placeholder="es. 3">
|
||||
<label for="admin-deposit-confirmations">Conferme per accreditare un deposito (1–100; le uscite coinbase ne richiedono comunque 120)</label>
|
||||
<input id="admin-deposit-confirmations" inputmode="numeric" placeholder="es. 6">
|
||||
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
|
||||
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
|
||||
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
|
||||
|
||||
@@ -151,6 +151,7 @@ async function adminLoadConfig() {
|
||||
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
||||
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
|
||||
document.getElementById('admin-draw-confirmations').value = data.draw_confirmations;
|
||||
document.getElementById('admin-deposit-confirmations').value = data.deposit_confirmations;
|
||||
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
|
||||
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
|
||||
renderMaintenanceState(data.paused);
|
||||
@@ -211,6 +212,7 @@ async function adminSave() {
|
||||
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
|
||||
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
|
||||
draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10),
|
||||
deposit_confirmations: parseInt(document.getElementById('admin-deposit-confirmations').value, 10),
|
||||
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
|
||||
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
|
||||
};
|
||||
|
||||
+7
-7
@@ -87,7 +87,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'Not a win this time.',
|
||||
|
||||
'deposit.balanceTitle': 'Internal balance',
|
||||
'deposit.balanceHint': 'Updated after 1 network confirmation',
|
||||
'deposit.balanceHint': 'Updated once the network has confirmed your deposit (a few blocks)',
|
||||
'deposit.refreshBtn': 'Refresh',
|
||||
'deposit.refreshAria': 'Refresh balance',
|
||||
'deposit.addressTitle': 'Deposit address',
|
||||
@@ -258,7 +258,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'Non hai vinto questa volta.',
|
||||
|
||||
'deposit.balanceTitle': 'Saldo interno',
|
||||
'deposit.balanceHint': 'Aggiornato dopo 1 conferma sulla rete',
|
||||
'deposit.balanceHint': 'Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)',
|
||||
'deposit.refreshBtn': 'Aggiorna',
|
||||
'deposit.refreshAria': 'Aggiorna saldo',
|
||||
'deposit.addressTitle': 'Indirizzo di deposito',
|
||||
@@ -426,7 +426,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'Esta vez no has ganado.',
|
||||
|
||||
'deposit.balanceTitle': 'Saldo interno',
|
||||
'deposit.balanceHint': 'Actualizado tras 1 confirmación en la red',
|
||||
'deposit.balanceHint': 'Se actualiza cuando la red confirma tu depósito (unos bloques)',
|
||||
'deposit.refreshBtn': 'Actualizar',
|
||||
'deposit.refreshAria': 'Actualizar saldo',
|
||||
'deposit.addressTitle': 'Dirección de depósito',
|
||||
@@ -594,7 +594,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': "Pas de gain cette fois-ci.",
|
||||
|
||||
'deposit.balanceTitle': 'Solde interne',
|
||||
'deposit.balanceHint': 'Mis à jour après 1 confirmation sur le réseau',
|
||||
'deposit.balanceHint': 'Mis à jour une fois le dépôt confirmé par le réseau (quelques blocs)',
|
||||
'deposit.refreshBtn': 'Actualiser',
|
||||
'deposit.refreshAria': 'Actualiser le solde',
|
||||
'deposit.addressTitle': 'Adresse de dépôt',
|
||||
@@ -762,7 +762,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'Diesmal kein Gewinn.',
|
||||
|
||||
'deposit.balanceTitle': 'Internes Guthaben',
|
||||
'deposit.balanceHint': 'Aktualisiert nach 1 Netzwerkbestätigung',
|
||||
'deposit.balanceHint': 'Aktualisiert, sobald das Netzwerk die Einzahlung bestätigt hat (einige Blöcke)',
|
||||
'deposit.refreshBtn': 'Aktualisieren',
|
||||
'deposit.refreshAria': 'Guthaben aktualisieren',
|
||||
'deposit.addressTitle': 'Einzahlungsadresse',
|
||||
@@ -930,7 +930,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'На этот раз без выигрыша.',
|
||||
|
||||
'deposit.balanceTitle': 'Внутренний баланс',
|
||||
'deposit.balanceHint': 'Обновляется после 1 подтверждения в сети',
|
||||
'deposit.balanceHint': 'Обновляется, когда сеть подтвердит депозит (несколько блоков)',
|
||||
'deposit.refreshBtn': 'Обновить',
|
||||
'deposit.refreshAria': 'Обновить баланс',
|
||||
'deposit.addressTitle': 'Адрес для депозита',
|
||||
@@ -1098,7 +1098,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': '这次没有中奖。',
|
||||
|
||||
'deposit.balanceTitle': '内部余额',
|
||||
'deposit.balanceHint': '在网络确认 1 次后更新',
|
||||
'deposit.balanceHint': '网络确认存款后更新(需要数个区块)',
|
||||
'deposit.refreshBtn': '刷新',
|
||||
'deposit.refreshAria': '刷新余额',
|
||||
'deposit.addressTitle': '存款地址',
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
<div class="dash-panel active" id="panel-deposit">
|
||||
<div class="card">
|
||||
<h2 data-i18n="deposit.balanceTitle">Saldo interno</h2>
|
||||
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato dopo 1 conferma sulla rete</p>
|
||||
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)</p>
|
||||
<div class="row-between">
|
||||
<div><span class="balance-value mono" id="dash-balance">—</span> <span class="balance-unit">PLM</span></div>
|
||||
<button class="secondary" onclick="refreshMe()" id="refresh-btn" data-i18n-aria-label="deposit.refreshAria" aria-label="Aggiorna saldo">
|
||||
|
||||
@@ -18,7 +18,9 @@ async def recompute_balance(session: AsyncSession, user_id: int) -> int:
|
||||
return user.cached_balance_sats
|
||||
|
||||
|
||||
async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[int, bool]:
|
||||
async def compute_pending_balance(
|
||||
session: AsyncSession, user: User, pending_deposit_sats: int = 0
|
||||
) -> tuple[int, bool]:
|
||||
"""Balance including the user's own change still in flight.
|
||||
|
||||
Placing a bet or a withdrawal spends whatever UTXOs cover the amount — often
|
||||
@@ -47,6 +49,11 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
||||
the same sats are already inside cached_balance_sats — double-counting the
|
||||
change unless excluded below.
|
||||
|
||||
B-82 (DEP-12 (b), DEP-14): `pending_deposit_sats` is the total of the user's deposits
|
||||
that are confirmed but still short of their threshold (the listener keeps them, in
|
||||
memory); they are added to what is shown, and make `has_pending` true, exactly like
|
||||
change in flight — without ever being spendable.
|
||||
|
||||
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
|
||||
caller whether this differs from the confirmed-only balance at all.
|
||||
"""
|
||||
@@ -79,4 +86,4 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
||||
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
|
||||
pending_change_sats += out.value
|
||||
|
||||
return user.cached_balance_sats + pending_change_sats, bool(pending)
|
||||
return user.cached_balance_sats + pending_change_sats + pending_deposit_sats, bool(pending) or pending_deposit_sats > 0
|
||||
@@ -108,7 +108,10 @@ async def request_withdrawal(
|
||||
# "insufficient". Distinguish "you don't have the money" from "your money
|
||||
# hasn't confirmed yet" so the error doesn't contradict what the user is
|
||||
# looking at on screen.
|
||||
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user)
|
||||
# B-82 (DEP-13): the displayed balance includes deposits still short of their
|
||||
# threshold, so a withdrawal they would cover gets "wait for the confirmation".
|
||||
waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0
|
||||
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
|
||||
if has_pending and pending_inclusive_sats >= amount_sats:
|
||||
raise WithdrawalError(
|
||||
"balance_pending_confirmation",
|
||||
|
||||
Reference in new issue
Block a user