Files
plm-lottery/app/withdrawals/service.py
T
davideandClaude Opus 5 d528c5b475 Let the system recover from a broadcast that never confirms
The code treated a broadcast as final: money moved on-chain and the DB was
updated on the assumption it would either confirm or be fee-bumped until it
did. Neither is guaranteed, and every way that assumption broke was permanent
(BUGS.md B-02, B-03, B-04, B-07, B-08, B-20, B-21).

Persist before broadcasting. place_bet and request_withdrawal now write their
rows in a "building" state and commit, then broadcast, then promote to
broadcast/pending in a second commit. Before, a failure or crash between the
broadcast and the commit left the coins irreversibly spent with no trace: no
participant (so no entry in the draw), no pending row (so no RBF and no
confirmation tracking), and the UTXOs not even marked spent, so the next bet
would try to double-spend them. A refused broadcast now releases the reserved
UTXOs, restores the balance, removes the participant (or marks the withdrawal
failed), audit-logs it, and answers a translatable broadcast_failed — as 502,
since the network refused it, not the caller, where it used to be an opaque 500.

Reconcile what's in flight against the chain. New PendingTransactionReconciler
(app/tx/reconcile.py, every 120s and once at startup) asks whether each
non-terminal tx exists: present -> promote, gone -> mark failed with a reason,
release the inputs, roll the domain row back, audit-log it. Grace periods differ
by state (120s for "building", 6h for "pending", so the RBF bumper gets its
attempts first). It is deliberately biased to inaction: only a server that
positively doesn't know the tx counts as absent, and a transport failure never
abandons anything, because releasing a UTXO whose tx is actually alive would
invite a double-spend. Verified against the live server, which answers "No such
mempool or blockchain transaction" for an unknown txid.

Stop keying on a value that changes. An RBF bump changes the txid, and
_on_bet_confirmed looked the participant up by bet_txid — so a bumped bet
confirmed under a txid no participant carried, the row stayed "broadcast"
forever, and the scheduler waited on it forever: the round could never close and
the lottery stopped. Handlers now resolve by immutable ids (round_id/user_id,
withdrawal_id), and bump_fee retargets every stored txid — bet_txid,
Withdrawal.txid, Round.payout_txid and UtxoEvent.spent_txid — plus records the
previous one in replaced_by_txid, which was never written at all.

One bad row no longer blocks the rest. The confirmation poller's per-tx lookup
is guarded: a txid the server can't resolve used to abort the whole pass, so
nothing confirmed again until an operator intervened. It also selects plain
columns instead of hydrating entities that outlive their session.

Tests: 6 reconciler cases including "a broken connection must not release coins";
the bet-ordering test probes committed state from an independent session during
the broadcast, and caught a real mistake in the first draft of this change (the
_pending_transaction helper still hardcoded status="pending", so rows were born
already-broadcast and would have got the 6-hour grace instead of 120s).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:31:24 +02:00

161 lines
5.8 KiB
Python

from embit import script
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiError
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, 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.wallet.address import is_valid_plm_address
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import (
BuiltTransaction,
InsufficientFundsError,
Utxo,
build_signed_transaction,
)
class WithdrawalError(ApiError):
pass
async def request_withdrawal(
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
) -> 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",
)
config = await get_round_config(session)
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()
if sum(u.amount_sats for u in unspent) < amount_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,
)
except InsufficientFundsError as exc:
raise WithdrawalError(exc.code, str(exc)) 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 session.commit()
try:
await client.broadcast(built.raw_hex)
except Exception as exc:
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, str(exc))
raise WithdrawalError(
"broadcast_failed", f"the network refused the transaction: {exc}"
) from exc
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."""
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]
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()