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)
place_bet and request_withdrawal released everything on any exception from the broadcast, including the 15s timeout and a connection dropped after the write. The tx may have propagated: a bet's sats landed in the pool with no participant, a withdrawal left while its row said failed and its coins were spendable again. - client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain ElectrumError. - tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused / uncertain. A refusal is verified first: ElectrumListener.transaction_known asks blockchain.transaction.get of the active server and every other one (10s each) — any server returning the tx means accepted; the active server plus MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps the reduced rule); too few answers is uncertain. "already known" wording is only a shortcut that skips the check. With no network to ask, a refusal is uncertain. - place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal releases; uncertain leaves the rows building for the reconciler, publishes SSE and POST /bets and POST /withdrawals answer 202 with status building; "already known" promotes a payout (payout_sent) like any accepted broadcast. - An accepted transaction is fanned out to every other server (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF replacement in bump_fee is left for B-77, which rewrites that function. Tests changed because the spec changed: the existing "refused broadcast" tests raised a bare RuntimeError (now an uncertain outcome) and are made explicit refusals (rpc_error) with a network that denies the tx; direct route calls pass a Response; FakePayoutListener gains the network surface. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13cb66cc06
commit
2b6ddb019b
27 files changed
+965
-150
No files matched your search
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -21,6 +21,7 @@ class BetResponse(BaseModel):
|
||||
@router.post("", response_model=BetResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bet(
|
||||
request: Request,
|
||||
response: Response,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BetResponse:
|
||||
@@ -34,7 +35,7 @@ async def create_bet(
|
||||
|
||||
async with request.app.state.user_locks.acquire(user.id):
|
||||
try:
|
||||
participant = await place_bet(session, listener.client, user)
|
||||
participant = await place_bet(session, listener.client, user, network=listener)
|
||||
except BetError as exc:
|
||||
# A rejected broadcast isn't the client's fault — it's the network
|
||||
# refusing our transaction, so it answers 502 rather than 400 (B-07).
|
||||
@@ -47,6 +48,11 @@ async def create_bet(
|
||||
)
|
||||
raise from_api_error(code, exc) from exc
|
||||
|
||||
if participant.status == "building":
|
||||
# B-76 (TX-20, BET-23): the outcome of the broadcast is uncertain — the tx may
|
||||
# already be on the network — so the bet is accepted for processing, not
|
||||
# confirmed as sent, and the reconciler settles it.
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
return BetResponse(
|
||||
round_id=participant.round_id,
|
||||
bet_txid=participant.bet_txid,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -27,6 +27,7 @@ class WithdrawalResponse(BaseModel):
|
||||
async def create_withdrawal(
|
||||
body: WithdrawalRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WithdrawalResponse:
|
||||
@@ -41,7 +42,7 @@ async def create_withdrawal(
|
||||
async with request.app.state.user_locks.acquire(user.id):
|
||||
try:
|
||||
withdrawal = await request_withdrawal(
|
||||
session, listener.client, user, body.external_address, body.amount_sats
|
||||
session, listener.client, user, body.external_address, body.amount_sats, network=listener
|
||||
)
|
||||
except WithdrawalError as exc:
|
||||
code = (
|
||||
@@ -53,6 +54,10 @@ async def create_withdrawal(
|
||||
)
|
||||
raise from_api_error(code, exc) from exc
|
||||
|
||||
if withdrawal.status == "building":
|
||||
# B-76 (TX-20, WDR-16): uncertain broadcast — accepted for processing, the
|
||||
# reconciler decides whether the transaction left.
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
return WithdrawalResponse(
|
||||
txid=withdrawal.txid,
|
||||
amount_requested_sats=withdrawal.amount_requested_sats,
|
||||
|
||||
+23
-9
@@ -14,6 +14,7 @@ from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
|
||||
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
|
||||
from app.wallet.balance import recompute_balance
|
||||
from app.wallet.hd import derive_user_key
|
||||
from app.wallet.psbt_builder import (
|
||||
@@ -37,7 +38,12 @@ class _RoundClosedDuringBuild(Exception):
|
||||
being built. Never leaves place_bet — it becomes a `round_closing` BetError."""
|
||||
|
||||
|
||||
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
|
||||
async def place_bet(
|
||||
session: AsyncSession, client: ElectrumClient, user: User, network: TransactionNetwork | None = None
|
||||
) -> RoundParticipant:
|
||||
"""`network` (the ElectrumListener) is what verifies a refused broadcast and fans an
|
||||
accepted one out (B-76); without it a refusal can never be proven, so it never
|
||||
releases anything."""
|
||||
# B-88 (DAT-74, BET-11): take SQLite's write lock before the first read, so the
|
||||
# balance, the free UTXOs, the participants and the round's status read below are
|
||||
# the state the phase-1 write lands on — a plain BEGIN only starts at the first
|
||||
@@ -184,21 +190,29 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
raise _service_busy() from exc
|
||||
|
||||
# --- Phase 2: broadcast, then promote both rows to their live state ---------
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception as exc:
|
||||
# The node refused it (fee too low, dust, mempool conflict, or simply an
|
||||
# unreachable server) — nothing is on-chain, so undo phase 1 completely and
|
||||
# give the user a translatable failure instead of a bare 500 (B-07).
|
||||
# B-76 (TX-04, TX-20): only a *verified* refusal undoes phase 1. A timeout, a
|
||||
# dropped connection or a refusal nobody could verify leaves the tx possibly on the
|
||||
# network, and releasing the coins then sent the bet's sats into the pool with no
|
||||
# participant behind them — so those rows stay "building" for the reconciler.
|
||||
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
|
||||
if verdict.outcome is BroadcastOutcome.REFUSED:
|
||||
# The network refused it (fee too low, dust, mempool conflict) and no server
|
||||
# knows it — nothing is on-chain, so undo phase 1 completely and give the user
|
||||
# a translatable failure instead of a bare 500 (B-07).
|
||||
try:
|
||||
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
|
||||
await _release_failed_bet(session, participant, pending, built, user.id, verdict.reason)
|
||||
except OperationalError:
|
||||
# B-88: the database is busy, so the release could not be written. The
|
||||
# rows stay "building" — the state phase 1 committed — and the
|
||||
# reconciler resolves them against the chain.
|
||||
await session.rollback()
|
||||
logger.error("could not release the failed bet %s: database busy, left to the reconciler", built.txid)
|
||||
raise BetError("broadcast_failed", f"the network refused the transaction: {exc}") from exc
|
||||
raise BetError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
|
||||
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
|
||||
logger.warning("bet %s: broadcast outcome uncertain (%s), left 'building' for the reconciler", built.txid, verdict.reason)
|
||||
await session.refresh(participant) # reports the status the DB really holds: building
|
||||
broadcaster.publish() # an in-flight bet counts towards the round's figures
|
||||
return participant
|
||||
|
||||
# B-88: after the broadcast nothing may be undone. If the lock cannot be had the
|
||||
# rows stay "building" (the tx is on the network) and the reconciler promotes
|
||||
|
||||
+22
-1
@@ -128,6 +128,20 @@ class ElectrumError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ElectrumRpcError(ElectrumError):
|
||||
"""The server *answered* with a JSON-RPC `error` object (B-76, TX-04). It is the
|
||||
only failure that says anything about the request itself: a timeout, a dropped
|
||||
connection or a write error (plain ElectrumError) leaves it unknown whether the
|
||||
server ever acted on it. `args[0]` is the error object as the server sent it."""
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
detail = self.args[0] if self.args else ""
|
||||
if isinstance(detail, dict):
|
||||
return str(detail.get("message", detail))
|
||||
return str(detail)
|
||||
|
||||
|
||||
class ElectrumClient:
|
||||
"""Minimal asyncio Electrum protocol client: line-delimited JSON-RPC over TLS.
|
||||
|
||||
@@ -266,6 +280,13 @@ class ElectrumClient:
|
||||
async def get_transaction(self, txid: str, verbose: bool = False) -> object:
|
||||
return await self.request("blockchain.transaction.get", [txid, verbose])
|
||||
|
||||
async def get_merkle(self, txid: str, height: int) -> dict:
|
||||
"""The merkle branch proving `txid` is in the block at `height` (B-83, ELC-26)."""
|
||||
return await self.request("blockchain.transaction.get_merkle", [txid, height])
|
||||
|
||||
async def get_block_header(self, height: int) -> str:
|
||||
return await self.request("blockchain.block.header", [height])
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
"""Frame the stream into lines by hand rather than with StreamReader.readline:
|
||||
readline raises on a line over the limit, and that exception used to end this
|
||||
@@ -350,7 +371,7 @@ class ElectrumClient:
|
||||
if future.done():
|
||||
return
|
||||
if message.get("error"):
|
||||
future.set_exception(ElectrumError(message["error"]))
|
||||
future.set_exception(ElectrumRpcError(message["error"]))
|
||||
else:
|
||||
future.set_result(message.get("result"))
|
||||
elif "method" in message:
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
@@ -14,7 +15,7 @@ from app.deposits.service import (
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
from app.config import MIN_QUORUM_RESPONSES
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint, ElectrumRpcError
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.draw import (
|
||||
HeaderValidationError,
|
||||
@@ -403,6 +404,99 @@ class ElectrumListener:
|
||||
agreements = sum(1 for result in responded if agrees(result))
|
||||
return agreements * 2 > len(responded)
|
||||
|
||||
# --- Asking every server (B-76, TX-04, TX-21) ---------------------------------
|
||||
|
||||
def _other_endpoints(self) -> list[ElectrumEndpoint]:
|
||||
return [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
|
||||
|
||||
def _minimum_other_answers(self) -> int:
|
||||
"""How many *other* servers must answer before their silence or agreement
|
||||
counts as the network's: MIN_QUORUM_RESPONSES, or the reduced development rule
|
||||
of ALLOW_NO_QUORUM (one answer, none if no other server is configured)."""
|
||||
if not self._allow_no_quorum:
|
||||
return MIN_QUORUM_RESPONSES
|
||||
return 1 if self._other_endpoints() else 0
|
||||
|
||||
async def _ask_other_servers(self, ask: Callable[[ElectrumClient], "asyncio.Future"]) -> list:
|
||||
"""`ask(client)` on a fresh connection to every endpoint but the active one, in
|
||||
parallel, each bounded by _CORROBORATION_TIMEOUT_SECONDS; a server that cannot be
|
||||
reached or does not answer in time yields None. `ask` itself decides what an
|
||||
answer is (it may return None for an unusable one)."""
|
||||
|
||||
async def _one(endpoint: ElectrumEndpoint):
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return list(await asyncio.gather(*(_one(endpoint) for endpoint in self._other_endpoints())))
|
||||
|
||||
async def _ask_active_server(self, ask: Callable[[ElectrumClient], "asyncio.Future"]):
|
||||
client = self.client
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def transaction_known(self, txid: str) -> bool | None:
|
||||
"""Does the network know `txid`? Asked of the active server and every other
|
||||
configured one with `blockchain.transaction.get` (B-76, TX-04):
|
||||
|
||||
* True — at least one server returns the transaction: it is on the network,
|
||||
whatever the server that refused it said;
|
||||
* False — every server that answered says it does not know it, and the active
|
||||
server and the minimum number of the others (see _minimum_other_answers) are
|
||||
among them: nobody has it;
|
||||
* None — too few answers to tell. Nothing may be released on this.
|
||||
"""
|
||||
|
||||
async def _ask(client: ElectrumClient) -> bool | None:
|
||||
try:
|
||||
raw = await client.get_transaction(txid, False)
|
||||
except ElectrumRpcError:
|
||||
return False # an explicit "no such transaction"
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
if isinstance(raw, str) and Transaction.parse(bytes.fromhex(raw)).txid().hex() == txid:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return None # a reply that is not this transaction proves nothing either way
|
||||
|
||||
active = await self._ask_active_server(_ask)
|
||||
others = await self._ask_other_servers(_ask)
|
||||
if active is True or any(answer is True for answer in others):
|
||||
return True
|
||||
answered_others = [answer for answer in others if answer is not None]
|
||||
if active is False and len(answered_others) >= self._minimum_other_answers():
|
||||
return False
|
||||
return None
|
||||
|
||||
def fan_out_transaction(self, raw_tx_hex: str) -> None:
|
||||
"""Send an accepted transaction to every other configured server too (B-76,
|
||||
TX-21): a second route onto the network in case the active server's own relay
|
||||
is weak. Best effort and off the caller's path — a failure is only logged."""
|
||||
|
||||
async def _send() -> None:
|
||||
async def _broadcast(client: ElectrumClient) -> str:
|
||||
return await client.broadcast(raw_tx_hex)
|
||||
|
||||
results = await self._ask_other_servers(_broadcast)
|
||||
logger.info(
|
||||
"fanned a transaction out to %s other server(s), %s answered", len(results), sum(r is not None for r in results)
|
||||
)
|
||||
|
||||
task = asyncio.create_task(_send())
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
"""B-28: is `expected_hash` — the header our own active connection
|
||||
reported for `height` — corroborated by other configured servers before
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.rounds.config import get_round_config
|
||||
from app.rounds.draw import draw_winner, header_hex_to_block_hash
|
||||
from app.rounds.events import broadcaster
|
||||
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.wallet.hd import derive_round_pool_key
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
|
||||
@@ -496,13 +497,14 @@ class RoundScheduler:
|
||||
pending_id = pending.id
|
||||
|
||||
# --- Phase 4: broadcast, then promote the pending row --------------------
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception:
|
||||
# The row stays "building": the reconciler will ask the chain about it
|
||||
# and, finding nothing, abandon it and clear payout_txid (B-25) — instead
|
||||
# of the round being stuck with a payout_txid that never went anywhere.
|
||||
logger.exception("round %s payout broadcast failed", round_id)
|
||||
# B-76 (TX-05): the same three outcomes as a bet's broadcast. Accepted — the
|
||||
# server took it, or refused with "already known" / a server of the network
|
||||
# holds it — promotes the row; a refusal and an uncertain outcome both leave it
|
||||
# "building" with a payout_failed entry, since which of the two it really was
|
||||
# is the reconciler's call, made against the chain (TX-16, TX-17).
|
||||
verdict = await broadcast_with_verdict(client, self._listener, built.raw_hex, built.txid)
|
||||
if verdict.outcome is not BroadcastOutcome.ACCEPTED:
|
||||
logger.error("round %s payout broadcast not accepted (%s): %s", round_id, verdict.outcome.value, verdict.reason)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected")
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""What a broadcast attempt amounted to (B-76, TX-04, TX-20, TX-21).
|
||||
|
||||
Money may only be released on proof that a transaction never left. The text of an
|
||||
Electrum error cannot give that proof (servers word "already in the mempool" in many
|
||||
ways and there is no standard code), and a timeout or a dropped connection says
|
||||
nothing about whether the server acted before it went quiet. So a broadcast ends in
|
||||
one of three ways, and only one of them frees coins:
|
||||
|
||||
ACCEPTED the server answered with the txid, or refused but at least one server of
|
||||
the network (the active one or any other) returns the transaction;
|
||||
REFUSED the server answered with an explicit JSON-RPC error *and* the active
|
||||
server plus the quorum minimum of the others all say they do not know the
|
||||
transaction;
|
||||
UNCERTAIN anything else — a timeout, a dropped connection, a write error, a
|
||||
refusal whose verification could not get enough answers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import NamedTuple, Protocol
|
||||
|
||||
from app.electrum.client import ElectrumRpcError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Messages by which a node says it already has the transaction. Recognising one skips
|
||||
# the verification below; *not* recognising a message never releases anything, it only
|
||||
# costs one more round of questions (TX-04).
|
||||
_ALREADY_KNOWN_MARKERS = (
|
||||
"already known",
|
||||
"already in mempool",
|
||||
"already in the mempool",
|
||||
"txn-already-known",
|
||||
"txn-already-in-mempool",
|
||||
"already in block chain",
|
||||
"already in blockchain",
|
||||
)
|
||||
|
||||
|
||||
class BroadcastOutcome(Enum):
|
||||
ACCEPTED = "accepted"
|
||||
REFUSED = "refused"
|
||||
UNCERTAIN = "uncertain"
|
||||
|
||||
|
||||
class BroadcastVerdict(NamedTuple):
|
||||
outcome: BroadcastOutcome
|
||||
reason: str # the server's own wording or the failure's, for logs and audit rows
|
||||
|
||||
|
||||
class TransactionNetwork(Protocol):
|
||||
"""The part of ElectrumListener a broadcast needs (see electrum/listener.py)."""
|
||||
|
||||
async def transaction_known(self, txid: str) -> bool | None: ...
|
||||
|
||||
def fan_out_transaction(self, raw_tx_hex: str) -> None: ...
|
||||
|
||||
|
||||
def is_already_known(message: str) -> bool:
|
||||
lowered = message.lower()
|
||||
return any(marker in lowered for marker in _ALREADY_KNOWN_MARKERS)
|
||||
|
||||
|
||||
async def broadcast_with_verdict(client, network: TransactionNetwork | None, raw_tx_hex: str, txid: str) -> BroadcastVerdict:
|
||||
"""Broadcast `raw_tx_hex` through the active `client` and say what happened.
|
||||
|
||||
With no `network` to ask, an explicit refusal cannot be verified and therefore
|
||||
comes back UNCERTAIN: the doubt never frees coins. An accepted transaction is
|
||||
sent to every other server too (best effort, TX-21)."""
|
||||
try:
|
||||
await client.broadcast(raw_tx_hex)
|
||||
except ElectrumRpcError as exc:
|
||||
reason = exc.message
|
||||
if is_already_known(reason):
|
||||
known: bool | None = True
|
||||
elif network is None:
|
||||
known = None
|
||||
else:
|
||||
known = await network.transaction_known(txid)
|
||||
if known is True:
|
||||
_fan_out(network, raw_tx_hex)
|
||||
return BroadcastVerdict(BroadcastOutcome.ACCEPTED, reason)
|
||||
if known is False:
|
||||
return BroadcastVerdict(BroadcastOutcome.REFUSED, reason)
|
||||
logger.warning("broadcast of %s was refused (%s) but the refusal could not be verified", txid, reason)
|
||||
return BroadcastVerdict(BroadcastOutcome.UNCERTAIN, reason)
|
||||
except Exception as exc:
|
||||
# Timeout, dropped connection, write error: the server may have relayed it.
|
||||
logger.warning("broadcast of %s ended without an answer (%r): outcome uncertain", txid, exc)
|
||||
return BroadcastVerdict(BroadcastOutcome.UNCERTAIN, str(exc) or type(exc).__name__)
|
||||
_fan_out(network, raw_tx_hex)
|
||||
return BroadcastVerdict(BroadcastOutcome.ACCEPTED, "")
|
||||
|
||||
|
||||
def _fan_out(network: TransactionNetwork | None, raw_tx_hex: str) -> None:
|
||||
if network is None:
|
||||
return
|
||||
try:
|
||||
network.fan_out_transaction(raw_tx_hex)
|
||||
except Exception:
|
||||
logger.exception("could not start the fan-out of an accepted transaction")
|
||||
@@ -12,6 +12,7 @@ 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.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
|
||||
@@ -31,7 +32,12 @@ class WithdrawalError(ApiError):
|
||||
|
||||
|
||||
async def request_withdrawal(
|
||||
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
|
||||
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,
|
||||
@@ -162,11 +168,14 @@ async def request_withdrawal(
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception as exc:
|
||||
# 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, str(exc))
|
||||
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
|
||||
@@ -175,9 +184,16 @@ async def request_withdrawal(
|
||||
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: {exc}"
|
||||
) from exc
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user