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>
263 lines
11 KiB
Python
263 lines
11 KiB
Python
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from embit import script
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import OperationalError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.errors import ApiError
|
|
from app.audit.log import write_audit_log
|
|
from app.db.base import begin_immediate
|
|
from app.db.models import PendingTransaction, Round, User, UtxoEvent, Withdrawal
|
|
from app.electrum.client import ElectrumClient
|
|
from app.rounds.config import get_round_config
|
|
from app.rounds.events import broadcaster
|
|
from app.tx.versions import record_version
|
|
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
|
|
from app.wallet.address import is_valid_plm_address
|
|
from app.wallet.balance import compute_pending_balance, recompute_balance
|
|
from app.wallet.hd import derive_pool_address, derive_user_key
|
|
from app.wallet.psbt_builder import (
|
|
BuiltTransaction,
|
|
InsufficientFundsError,
|
|
Utxo,
|
|
build_signed_transaction,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WithdrawalError(ApiError):
|
|
pass
|
|
|
|
|
|
async def request_withdrawal(
|
|
session: AsyncSession,
|
|
client: ElectrumClient,
|
|
user: User,
|
|
external_address: str,
|
|
amount_sats: int,
|
|
network: TransactionNetwork | None = None,
|
|
) -> Withdrawal:
|
|
# Checked before anything else: an address from another chain parses fine as a
|
|
# witness program (see wallet/address.py), so without this the tx would build,
|
|
# broadcast and be irrecoverable rather than fail.
|
|
if not is_valid_plm_address(external_address):
|
|
raise WithdrawalError("invalid_address", "not a valid PLM bech32 address")
|
|
|
|
# Withdrawing to your own deposit address is a no-op that costs a network fee,
|
|
# and it breaks two things that assume the recipient and the change are
|
|
# distinguishable by address: the RBF bump would shrink the recipient output
|
|
# instead of the change (tx/broadcast.py:_find_change_output), and
|
|
# compute_pending_balance would count the amount twice (B-17).
|
|
if external_address == user.address:
|
|
raise WithdrawalError(
|
|
"withdrawal_to_own_address",
|
|
"that is your own deposit address — withdraw to an external wallet instead",
|
|
)
|
|
|
|
# B-88 (DAT-74, TX-28): the write lock before the first read that decides
|
|
# anything, so the UTXOs selected below are the ones phase 1 then marks spent.
|
|
# Taken only after the checks that need no database at all, so a malformed
|
|
# request never queues for it. No lock within busy_timeout means nothing has been
|
|
# built or broadcast: the caller is asked to retry.
|
|
try:
|
|
await begin_immediate(session)
|
|
except OperationalError as exc:
|
|
raise WithdrawalError(
|
|
"service_busy", "the service is busy right now, nothing was sent — please try again"
|
|
) from exc
|
|
|
|
config = await get_round_config(session)
|
|
|
|
# B-80 (WDR-14): never to an address of the platform itself. Coins sent to a round's
|
|
# pool address land among that round's UTXOs, and to the reserve or fee address they
|
|
# leave the user's balance for good; the amount would be spent from the balance and
|
|
# then belong to nobody. Compared lower-cased, since bech32 is case-insensitive.
|
|
platform_addresses = {derive_pool_address().lower(), config.fee_address.strip().lower()}
|
|
platform_addresses.update(
|
|
address.lower() for address in await session.scalars(select(Round.pool_address).distinct()) if address
|
|
)
|
|
platform_addresses.discard("")
|
|
if external_address.strip().lower() in platform_addresses:
|
|
raise WithdrawalError(
|
|
"withdrawal_to_platform_address",
|
|
"that is an address of the platform itself — withdraw to an external wallet instead",
|
|
)
|
|
|
|
if amount_sats < config.bet_amount_sats:
|
|
raise WithdrawalError(
|
|
"amount_below_minimum",
|
|
f"amount below the minimum of {config.bet_amount_sats} sats",
|
|
minimum_sats=config.bet_amount_sats,
|
|
)
|
|
|
|
unspent = (
|
|
await session.scalars(
|
|
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
|
|
)
|
|
).all()
|
|
confirmed_sats = sum(u.amount_sats for u in unspent)
|
|
if confirmed_sats < amount_sats:
|
|
# B-37: cached_balance_sats (== confirmed_sats here) can understate the real
|
|
# balance by a whole unconfirmed change output right after a bet/withdrawal —
|
|
# the UI shows pending_balance_sats instead (compute_pending_balance), which
|
|
# can cover an amount this check would otherwise reject as flatly
|
|
# "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.
|
|
# 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",
|
|
"the requested amount is covered by your pending balance, which has not confirmed yet",
|
|
pending_sats=pending_inclusive_sats - confirmed_sats,
|
|
)
|
|
raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats)
|
|
|
|
user_key = derive_user_key(user.derivation_index)
|
|
from_script = script.p2wpkh(user_key.to_public())
|
|
utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent]
|
|
|
|
try:
|
|
built = build_signed_transaction(
|
|
signing_key=user_key,
|
|
from_script=from_script,
|
|
utxos=utxos,
|
|
to_address=external_address,
|
|
amount_sats=amount_sats,
|
|
change_address=user.address,
|
|
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
|
# B-62: "withdraw everything" asks for the whole confirmed balance, which
|
|
# would leave no change output and therefore nothing bump_fee could
|
|
# shrink — the one tx shape RBF cannot rescue, and the UI's default
|
|
# withdrawal path at that. Move a dust limit less instead of producing an
|
|
# unbumpable transaction; amount_sent_sats below records what actually
|
|
# went out, which is already how a fee-deducted withdrawal is reported.
|
|
reduce_amount_to_keep_change=True,
|
|
)
|
|
except InsufficientFundsError as exc:
|
|
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
|
|
|
|
# Persist the intent before broadcasting, and only promote the rows once the
|
|
# network has accepted the tx — same two-phase shape as place_bet (B-08).
|
|
spent_by_key = {(u.txid, u.vout): u for u in unspent}
|
|
for spent in built.spent_utxos:
|
|
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
|
|
await recompute_balance(session, user.id)
|
|
|
|
withdrawal = Withdrawal(
|
|
user_id=user.id,
|
|
external_address=external_address,
|
|
amount_requested_sats=amount_sats,
|
|
amount_sent_sats=built.recipient_sats,
|
|
txid=built.txid,
|
|
status="building",
|
|
)
|
|
session.add(withdrawal)
|
|
await session.flush()
|
|
pending = PendingTransaction(
|
|
kind="withdrawal",
|
|
withdrawal_id=withdrawal.id,
|
|
user_id=user.id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
|
raw_tx_hex=built.raw_hex,
|
|
status="building",
|
|
)
|
|
session.add(pending)
|
|
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
|
|
await session.commit()
|
|
|
|
# B-76 (TX-04, TX-20): only a verified refusal releases the coins. A timeout, a
|
|
# drop after the write, or a refusal nobody could verify may mean the transaction
|
|
# left: the withdrawal would then be paid out while its row said "failed" and its
|
|
# coins were spendable again. Those outcomes leave the rows "building".
|
|
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
|
|
if verdict.outcome is BroadcastOutcome.REFUSED:
|
|
try:
|
|
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, verdict.reason)
|
|
except OperationalError:
|
|
# B-88: the release could not be written (database busy). The rows stay
|
|
# "building", the state committed before the broadcast, and the
|
|
# reconciler resolves them against the chain.
|
|
await session.rollback()
|
|
logger.error(
|
|
"could not release the failed withdrawal %s: database busy, left to the reconciler", built.txid
|
|
)
|
|
raise WithdrawalError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
|
|
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
|
|
logger.warning(
|
|
"withdrawal %s: broadcast outcome uncertain (%s), left 'building' for the reconciler",
|
|
built.txid,
|
|
verdict.reason,
|
|
)
|
|
await session.refresh(withdrawal)
|
|
broadcaster.publish()
|
|
return withdrawal
|
|
|
|
# B-88: after the broadcast nothing is undone. Without the lock the rows stay
|
|
# "building" and the reconciler promotes them; the caller sees the real status.
|
|
try:
|
|
await begin_immediate(session)
|
|
except OperationalError:
|
|
await session.rollback()
|
|
logger.error(
|
|
"withdrawal %s broadcast but the database is busy: left 'building' for the reconciler", built.txid
|
|
)
|
|
await session.refresh(withdrawal)
|
|
return withdrawal
|
|
withdrawal.status = "broadcast"
|
|
pending.status = "pending"
|
|
await write_audit_log(
|
|
session,
|
|
"withdrawal_sent",
|
|
{"txid": built.txid, "amount_sent_sats": built.recipient_sats, "external_address": external_address},
|
|
user_id=user.id,
|
|
)
|
|
|
|
await session.commit()
|
|
await session.refresh(withdrawal)
|
|
broadcaster.publish() # balance just went "pending" — nudge the dashboard to refetch
|
|
return withdrawal
|
|
|
|
|
|
async def _release_failed_withdrawal(
|
|
session: AsyncSession,
|
|
withdrawal: Withdrawal,
|
|
pending: PendingTransaction,
|
|
built: BuiltTransaction,
|
|
user_id: int,
|
|
reason: str,
|
|
) -> None:
|
|
"""Nothing reached the chain, so free the reserved UTXOs and restore the balance.
|
|
The Withdrawal row is kept (marked "failed") rather than deleted: unlike a bet, a
|
|
withdrawal is an instruction the user gave, and they should be able to see that it
|
|
didn't go through."""
|
|
await begin_immediate(session) # B-88
|
|
for spent in built.spent_utxos:
|
|
row = await session.scalar(
|
|
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
|
|
)
|
|
if row is not None:
|
|
row.spent_txid = None
|
|
withdrawal.status = "failed"
|
|
withdrawal.txid = None
|
|
pending.status = "failed"
|
|
pending.failure_reason = reason[:128]
|
|
pending.failed_at = datetime.now(timezone.utc) # B-78 (DAT-36): starts the 14-day watch
|
|
await recompute_balance(session, user_id)
|
|
await write_audit_log(
|
|
session,
|
|
"withdrawal_broadcast_failed",
|
|
{"txid": built.txid, "reason": reason[:200]},
|
|
user_id=user_id,
|
|
)
|
|
await session.commit()
|
|
broadcaster.publish() # the reserved UTXOs are spendable again — refetch the balance (B-49)
|