2026-09-29 16:26:01 +02:00
|
|
|
import logging
|
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
|
|
|
from datetime import datetime, timezone
|
2026-09-29 16:26:01 +02:00
|
|
|
|
2026-07-21 10:26:25 +02:00
|
|
|
from embit import script
|
|
|
|
|
from sqlalchemy import select
|
2026-09-29 16:26:01 +02:00
|
|
|
from sqlalchemy.exc import OperationalError
|
2026-07-21 10:26:25 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-07-26 21:44:39 +02:00
|
|
|
from app.api.errors import ApiError
|
2026-07-21 10:26:25 +02:00
|
|
|
from app.audit.log import write_audit_log
|
2026-09-29 16:26:01 +02:00
|
|
|
from app.db.base import begin_immediate
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
from app.db.models import PendingTransaction, Round, User, UtxoEvent, Withdrawal
|
2026-07-21 10:26:25 +02:00
|
|
|
from app.electrum.client import ElectrumClient
|
2026-07-21 15:05:40 +02:00
|
|
|
from app.rounds.config import get_round_config
|
2026-07-23 10:52:07 +02:00
|
|
|
from app.rounds.events import broadcaster
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
from app.tx.versions import record_version
|
Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
2026-10-14 14:22:20 +02:00
|
|
|
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
|
2026-07-26 21:45:07 +02:00
|
|
|
from app.wallet.address import is_valid_plm_address
|
2026-07-27 14:23:00 +02:00
|
|
|
from app.wallet.balance import compute_pending_balance, recompute_balance
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
from app.wallet.hd import derive_pool_address, derive_user_key
|
2026-07-27 00:31:24 +02:00
|
|
|
from app.wallet.psbt_builder import (
|
|
|
|
|
BuiltTransaction,
|
|
|
|
|
InsufficientFundsError,
|
|
|
|
|
Utxo,
|
|
|
|
|
build_signed_transaction,
|
|
|
|
|
)
|
2026-07-21 10:26:25 +02:00
|
|
|
|
|
|
|
|
|
2026-09-29 16:26:01 +02:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 21:44:39 +02:00
|
|
|
class WithdrawalError(ApiError):
|
2026-07-21 10:26:25 +02:00
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def request_withdrawal(
|
Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
2026-10-14 14:22:20 +02:00
|
|
|
session: AsyncSession,
|
|
|
|
|
client: ElectrumClient,
|
|
|
|
|
user: User,
|
|
|
|
|
external_address: str,
|
|
|
|
|
amount_sats: int,
|
|
|
|
|
network: TransactionNetwork | None = None,
|
2026-07-21 10:26:25 +02:00
|
|
|
) -> Withdrawal:
|
2026-07-26 21:45:07 +02:00
|
|
|
# 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")
|
|
|
|
|
|
2026-07-27 00:31:24 +02:00
|
|
|
# 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",
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-29 16:26:01 +02:00
|
|
|
# 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
|
|
|
|
|
|
2026-07-21 15:05:40 +02:00
|
|
|
config = await get_round_config(session)
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
|
|
|
|
|
# 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",
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-22 19:25:15 +02:00
|
|
|
if amount_sats < config.bet_amount_sats:
|
2026-07-26 21:44:39 +02:00
|
|
|
raise WithdrawalError(
|
|
|
|
|
"amount_below_minimum",
|
|
|
|
|
f"amount below the minimum of {config.bet_amount_sats} sats",
|
|
|
|
|
minimum_sats=config.bet_amount_sats,
|
|
|
|
|
)
|
2026-07-21 10:26:25 +02:00
|
|
|
|
|
|
|
|
unspent = (
|
|
|
|
|
await session.scalars(
|
|
|
|
|
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
|
|
|
|
|
)
|
|
|
|
|
).all()
|
2026-07-27 14:23:00 +02:00
|
|
|
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.
|
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)
2026-10-16 14:25:35 +02:00
|
|
|
# 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)
|
2026-07-27 14:23:00 +02:00
|
|
|
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,
|
|
|
|
|
)
|
2026-07-26 21:44:39 +02:00
|
|
|
raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats)
|
2026-07-21 10:26:25 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-07-21 15:05:40 +02:00
|
|
|
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
2026-08-03 23:36:55 +02:00
|
|
|
# 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,
|
2026-07-21 10:26:25 +02:00
|
|
|
)
|
|
|
|
|
except InsufficientFundsError as exc:
|
2026-07-27 23:30:06 +02:00
|
|
|
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
|
2026-07-21 10:26:25 +02:00
|
|
|
|
2026-07-27 00:31:24 +02:00
|
|
|
# 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).
|
2026-07-21 10:26:25 +02:00
|
|
|
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,
|
2026-07-27 00:31:24 +02:00
|
|
|
status="building",
|
2026-07-21 10:26:25 +02:00
|
|
|
)
|
|
|
|
|
session.add(withdrawal)
|
|
|
|
|
await session.flush()
|
2026-07-27 00:31:24 +02:00
|
|
|
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",
|
2026-07-21 10:26:25 +02:00
|
|
|
)
|
2026-07-27 00:31:24 +02:00
|
|
|
session.add(pending)
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
|
2026-07-27 00:31:24 +02:00
|
|
|
await session.commit()
|
|
|
|
|
|
Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
2026-10-14 14:22:20 +02:00
|
|
|
# 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:
|
2026-09-29 16:26:01 +02:00
|
|
|
try:
|
Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
2026-10-14 14:22:20 +02:00
|
|
|
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, verdict.reason)
|
2026-09-29 16:26:01 +02:00
|
|
|
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
|
|
|
|
|
)
|
Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
2026-10-14 14:22:20 +02:00
|
|
|
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
|
2026-07-27 00:31:24 +02:00
|
|
|
|
2026-09-29 16:26:01 +02:00
|
|
|
# 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
|
2026-07-27 00:31:24 +02:00
|
|
|
withdrawal.status = "broadcast"
|
|
|
|
|
pending.status = "pending"
|
2026-07-21 10:26:25 +02:00
|
|
|
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)
|
2026-07-23 10:52:07 +02:00
|
|
|
broadcaster.publish() # balance just went "pending" — nudge the dashboard to refetch
|
2026-07-21 10:26:25 +02:00
|
|
|
return withdrawal
|
2026-07-27 00:31:24 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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."""
|
2026-09-29 16:26:01 +02:00
|
|
|
await begin_immediate(session) # B-88
|
2026-07-27 00:31:24 +02:00
|
|
|
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]
|
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
|
|
|
pending.failed_at = datetime.now(timezone.utc) # B-78 (DAT-36): starts the 14-day watch
|
2026-07-27 00:31:24 +02:00
|
|
|
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()
|
2026-07-27 23:35:10 +02:00
|
|
|
broadcaster.publish() # the reserved UTXOs are spendable again — refetch the balance (B-49)
|