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)
reconcile_once abandoned a row when the *active* server's history lacked its txid:
after 120s for `building` (which a restart onto a different server makes likely),
after 6h for `pending`, and all at once after a forward clock jump. It re-checked the
status but not the txid, so it raced the bumper, and a `failed` row was never looked at
again.
- The reconciler asks every server (ElectrumListener.get_histories, active + others) for
the history of the row's address and looks for every txid of the row. Present on any
server -> promoted (adopting the version found). Absent is only "no server of the
quorum knows it" and needs the active server plus the quorum minimum of the others to
have answered; even then nothing is released without proof:
(a) an input spent in a block by a transaction that is none of the row's versions,
agreed by a strict majority of a quorum of the other servers (confirmed_spender), or
(b) a rebroadcast of the latest raw tx to every server (rebroadcast_everywhere)
refused by all of them, explicitly, for missing/spent inputs.
Accepted or "already known" by any server means alive; a fee refusal is not proof
(`building` -> `pending` for the bumper, `pending` untouched); too few answers or
anything else leaves the row alone. failure_reason names the proof.
- The write re-reads status *and* current_txid under begin_immediate (TX-26).
- pending_transactions.failed_at (set by _abandon and _release_failed_withdrawal) starts
a 14-day watch: a failed row that a majority of the other servers (at least the quorum
minimum) list in a block becomes `confirmed`, writes pending_tx_resurrected once and
pauses the lottery with paused_reason `resurrected_tx`; linked rows are not corrected.
The confirmation is by quorum of histories for now; merkle proofs come with B-83.
- round_config.paused_reason (migration b7d2e41c9a05, with the failed_at one): `operator`
from POST /admin/pause (which accepts an optional `note` and never overwrites an
automatic reason), cleared by resume, which logs the reason it lifted; exposed on
/admin/config and GET /rounds/current. rounds/pause.py:pause_automatically is the
system's way in.
- PendingTransactionReconciler takes the listener as `network`; with none it can promote
but never abandon.
Tests changed because the spec changed: the three reconciler tests that expect an
abandonment (and the one in test_tx_versions) now supply a network that proves the
transaction is gone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f936d6660c
commit
a4b160e240
29 files changed
+1280
-201
No files matched your search
+26
-4
@@ -13,6 +13,7 @@ from app.config import settings
|
||||
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
from app.wallet.hd import derive_user_wif
|
||||
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB
|
||||
@@ -56,6 +57,7 @@ class RoundConfigResponse(BaseModel):
|
||||
rbf_timeout_seconds: int
|
||||
draw_animation_seconds: int
|
||||
paused: bool
|
||||
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
|
||||
|
||||
|
||||
class RoundConfigUpdate(BaseModel):
|
||||
@@ -93,6 +95,7 @@ class RoundConfigUpdate(BaseModel):
|
||||
def _config_response(config) -> RoundConfigResponse:
|
||||
fields = {field: getattr(config, field) for field in _CONFIG_FIELDS}
|
||||
fields["paused"] = config.paused
|
||||
fields["paused_reason"] = config.paused_reason
|
||||
return RoundConfigResponse(**fields)
|
||||
|
||||
|
||||
@@ -128,14 +131,29 @@ async def update_config(
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
class PauseRequest(BaseModel):
|
||||
note: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
|
||||
async def pause_lottery(
|
||||
body: PauseRequest | None = None, session: AsyncSession = Depends(get_session)
|
||||
) -> RoundConfigResponse:
|
||||
"""Maintenance switch: the round in progress (if any) still closes, draws,
|
||||
and pays out its winner normally — only opening the *next* round is
|
||||
suppressed until /admin/resume is called (rounds/service.py)."""
|
||||
suppressed until /admin/resume is called (rounds/service.py).
|
||||
|
||||
B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason
|
||||
already in force stays — it is the one that needs a look. The audit row always
|
||||
says `operator` for this call."""
|
||||
config = await get_round_config(session)
|
||||
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
|
||||
config.paused_reason = OPERATOR_REASON
|
||||
config.paused = True
|
||||
await write_audit_log(session, "lottery_paused", {})
|
||||
payload = {"reason": OPERATOR_REASON}
|
||||
if body is not None and body.note:
|
||||
payload["note"] = body.note
|
||||
await write_audit_log(session, "lottery_paused", payload)
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
|
||||
@@ -143,8 +161,12 @@ async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundCo
|
||||
@router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
|
||||
config = await get_round_config(session)
|
||||
# B-78 (AUD-12): the reason the resume took away, so the log says what the operator
|
||||
# agreed to resume (None when the lottery was not paused).
|
||||
lifted = config.paused_reason if config.paused else None
|
||||
config.paused = False
|
||||
await write_audit_log(session, "lottery_resumed", {})
|
||||
config.paused_reason = None
|
||||
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ class CurrentRoundResponse(BaseModel):
|
||||
draw_waiting_since: str | None = None
|
||||
chain_tip_height: int | None = None
|
||||
lottery_paused: bool = False
|
||||
paused_reason: str | None = None # B-78 (DAT-69): operator / resurrected_tx / ..., None if not paused
|
||||
# B-66: false while the instance is missing configuration a round cannot run
|
||||
# without (today: fee_address) — no round will open until it's set, so this is
|
||||
# the difference between "wait, the next round is coming" and "nothing is coming
|
||||
@@ -147,6 +148,7 @@ async def current_round(
|
||||
draw_animation_seconds=config.draw_animation_seconds,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
paused_reason=config.paused_reason,
|
||||
lottery_configured=rounds_can_open(config),
|
||||
)
|
||||
|
||||
@@ -225,6 +227,7 @@ async def current_round(
|
||||
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
paused_reason=config.paused_reason,
|
||||
lottery_configured=rounds_can_open(config),
|
||||
user_played=user_played,
|
||||
)
|
||||
Reference in new issue
Block a user