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)
The poller and the reconciler matched current_txid only. If an earlier version of a bumped transaction confirmed it was never seen, and after 6h the row was abandoned: a confirmed bet's participant deleted, a sent withdrawal marked failed, a paid payout rebuilt. bump_fee also broadcast in phase 2 and persisted in phase 3 unconditionally, so a crash or a `database is locked` in between left a transaction on the network that the DB did not know, and an abandon or a confirmation in the meantime was overwritten. - New table pending_transaction_txids (migration a3f5c2d9b7e1; txid unique, index on the owning row; existing rows backfilled with current_txid + raw and the txid their last bump replaced, raw_hex empty). Written with the row by place_bet, request_withdrawal and _trigger_payout; removed with a bet row that a verified refusal deletes (DAT-38). - bump_fee in four phases: read, build and sign, commit the replacement as a candidate version, broadcast (verdict of B-76, fanned out when accepted; an explicit refusal raises RbfError and leaves the row), then switch the row over with `UPDATE ... WHERE status='pending' AND current_txid=<replaced>`. Zero rows matched is a logged conflict: nothing is overwritten and the candidate stays recorded. An uncertain broadcast adopts the replacement. A retry rebuilds the same candidate without tripping the unique txid. - poll_once and reconcile_once look for every txid of a row. When the version found is not the current one, broadcast.adopt_version makes it current (raw tx included) and retargets spent_txid, bet_txid, withdrawals.txid and payout_txid as a bump does, before the confirmation handler runs. _release_inputs frees inputs claimed by any version of the row. - RbfBumper takes the listener as `network`. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b6ddb019b
commit
f936d6660c
23 files changed
+836
-130
No files matched your search
@@ -137,21 +137,6 @@ the intermediate headers (`blockchain.block.headers`) and validate the chain; on
|
||||
linkage mismatch walk back up to 10 headers to find the fork point and follow the
|
||||
reorg instead of ending the session (reorg handling: B-85).
|
||||
|
||||
### B-77 — Only the latest txid is tracked, and `bump_fee` broadcasts before it persists
|
||||
|
||||
The poller and the reconciler match `current_txid` only (`replaced_by_txid` keeps
|
||||
one step and nothing reads it). If an earlier version of a bumped tx confirms, it is
|
||||
never seen; after 6h the row is abandoned — a confirmed bet's participant deleted, a
|
||||
sent withdrawal marked failed, a paid payout rebuilt. `bump_fee` broadcasts in phase
|
||||
2 and persists in phase 3, unconditionally, so a crash or a `database is locked` in
|
||||
between, or an abandon that ran meanwhile, desynchronises DB and chain.
|
||||
|
||||
Fix: a `pending_transaction_txids` table holding every txid (and raw tx) a row ever
|
||||
had; a confirmation of *any* of them confirms the row and retargets every reference
|
||||
to it. The replacement is written (as a candidate) before it is broadcast; phase 3
|
||||
updates with `WHERE status='pending' AND current_txid=<old>` and treats zero rows as
|
||||
a conflict to reconcile.
|
||||
|
||||
### B-78 — Abandonment rests on one server's silence
|
||||
|
||||
`reconcile_once` abandons when the active server's `get_history` lacks the txid —
|
||||
|
||||
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## Project status
|
||||
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 463 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 480 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
|
||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
||||
|
||||
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
|
||||
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
|
||||
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
|
||||
|
||||
python -m pytest # all 463 tests
|
||||
python -m pytest # all 480 tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
||||
```
|
||||
@@ -132,7 +132,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|
||||
| `app/deposits/` | crediting / external-spend detection / reinstatement (`service.py`), periodic sweep (`reconcile.py`) |
|
||||
| `app/bets/`, `app/withdrawals/` | build+broadcast services and their confirmation handlers |
|
||||
| `app/rounds/` | `scheduler.py` (close/draw/payout), `service.py` (open/active-round rules), `draw.py` (header math + winner pick), `config.py`, `events.py` (SSE pub/sub) |
|
||||
| `app/tx/` | `broadcast.py` (RBF bumper), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) |
|
||||
| `app/tx/` | `broadcast.py` (RBF bumper), `outcome.py` (broadcast verdict, B-76), `versions.py` (every txid of a row, B-77), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) |
|
||||
| `app/static/` | the two SPAs (`index.html`/`app.js`/`style.css`, `admin.html`/`admin.js`/`admin.css`) + `i18n.js` |
|
||||
|
||||
## Background tasks
|
||||
@@ -217,7 +217,7 @@ Everything that spends money is written **before** it is broadcast and resolved
|
||||
|
||||
**SQLite transactions are real ones (B-88).** `_register_sqlite_pragmas` sets `synchronous=FULL` and switches pysqlite's implicit `BEGIN` off (`isolation_level = None`); an engine `begin` event emits `BEGIN` itself, so every read is inside a transaction. Every transaction that moves money or fixes its state opens with `await begin_immediate(session)` (`app/db/base.py`) — `BEGIN IMMEDIATE`, i.e. the write lock *before* the first read (it commits the caller's read-only transaction first and refuses a session with unflushed changes). A lock not granted within `busy_timeout` raises `OperationalError`: **before** a broadcast that means "refuse" (`service_busy`, 503, nothing built or sent); **after** one nothing is undone — the row stays `building` and the reconciler resolves it. New money-moving write code must call `begin_immediate` first, and tests of that must use a file DB whose engine went through `_register_sqlite_pragmas` (an in-memory engine without it never emits `BEGIN IMMEDIATE`).
|
||||
|
||||
`UtxoEvent.spent_txid` must always equal the tx's *current* txid, so `bump_fee` retargets it along with `RoundParticipant.bet_txid`, `Withdrawal.txid` and `Round.payout_txid` on every bump. `broadcast_at` is the *first* broadcast and is never rewritten (the reconciler's abandon clock measures from it); `last_broadcast_at` is what a bump updates and `should_bump` reads. Confirmation handlers key off immutable ids (`round_id`/`user_id`, `withdrawal_id`), never the txid, which changes under them.
|
||||
`UtxoEvent.spent_txid` must always equal the tx's *current* txid, so `bump_fee` retargets it along with `RoundParticipant.bet_txid`, `Withdrawal.txid` and `Round.payout_txid` on every bump. **Every version a row ever had is tracked (B-77):** `pending_transaction_txids` (`app/tx/versions.py`) holds each txid with its raw tx, written with the row and, for an RBF replacement, *before* it is broadcast; the poller and the reconciler look for all of them, and when the network holds a version other than the current one `broadcast.py:adopt_version` makes it current and retargets everything. `bump_fee` ends with a conditional `UPDATE … WHERE status='pending' AND current_txid=<replaced>`; zero rows is a logged conflict, never an overwrite. `broadcast_at` is the *first* broadcast and is never rewritten (the reconciler's abandon clock measures from it); `last_broadcast_at` is what a bump updates and `should_bump` reads. Confirmation handlers key off immutable ids (`round_id`/`user_id`, `withdrawal_id`), never the txid, which changes under them.
|
||||
|
||||
**Payouts retry, and are guarded against paying twice.** Every tick re-examines a `paying_out` round: `_retry_payout_if_due` throttles to one attempt per 60s using the latest `payout_failed` audit entry as its clock (a build failure leaves no DB row to throttle on), and every early return in `_trigger_payout` writes one, so `/admin` shows *why* a round is stuck. Before building, `_trigger_payout` refuses if a `building`/`pending` payout already exists for the round, and `_reserved_payout_outpoints` excludes pool UTXOs claimed by any unresolved payout — without both, a retry would pay the winner twice.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
463 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
480 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||
automated integration tests against a live Electrum connection — mainnet
|
||||
|
||||
@@ -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.versions import delete_versions, record_version
|
||||
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
|
||||
@@ -148,6 +149,7 @@ async def place_bet(
|
||||
session.add(participant)
|
||||
pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)
|
||||
session.add(pending)
|
||||
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
|
||||
|
||||
# B-53: the deadline check at the top of this function happened before the UTXO
|
||||
# scan and the signing above, so re-check it here against the clock as it is now —
|
||||
@@ -258,6 +260,7 @@ async def _release_failed_bet(
|
||||
if row is not None:
|
||||
row.spent_txid = None
|
||||
await session.delete(participant)
|
||||
await delete_versions(session, pending.id) # B-77 (DAT-38): the versions go with the row
|
||||
await session.delete(pending)
|
||||
await recompute_balance(session, user_id)
|
||||
await write_audit_log(
|
||||
|
||||
@@ -200,6 +200,27 @@ class PendingTransaction(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class PendingTransactionTxid(Base):
|
||||
"""Every version a PendingTransaction has ever had (B-77, DAT-70, DAT-71): the first
|
||||
one, each RBF replacement and each candidate written before a bump is broadcast.
|
||||
|
||||
The network may confirm *any* of them — the original while a bump was in flight, a
|
||||
replacement the process crashed before recording — so the poller and the reconciler
|
||||
look for every txid of a row, not only `current_txid`. Rows are never modified or
|
||||
deleted (except with the `bet` row `_release_failed_bet` removes); `txid` is unique
|
||||
over the whole table, so a txid belongs to exactly one platform transaction.
|
||||
`raw_hex` is empty only for a `replaced_by_txid` reconstructed by the migration,
|
||||
whose raw transaction is no longer available."""
|
||||
|
||||
__tablename__ = "pending_transaction_txids"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
pending_transaction_id: Mapped[int] = mapped_column(ForeignKey("pending_transactions.id"), index=True)
|
||||
txid: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
raw_hex: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
|
||||
|
||||
class Withdrawal(Base):
|
||||
__tablename__ = "withdrawals"
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
scheduler = RoundScheduler(AsyncSessionLocal, listener)
|
||||
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client)
|
||||
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client)
|
||||
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
|
||||
# Resolves in-flight transactions against the chain — the piece that lets the
|
||||
# system recover on its own from a broadcast that never confirmed (B-04/B-08).
|
||||
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client)
|
||||
|
||||
@@ -18,6 +18,7 @@ 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.tx.versions import record_version
|
||||
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
|
||||
@@ -493,6 +494,7 @@ class RoundScheduler:
|
||||
status="building",
|
||||
)
|
||||
session.add(pending)
|
||||
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
|
||||
+110
-19
@@ -6,13 +6,23 @@ from embit import script
|
||||
from embit.psbt import PSBT
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
from embit.finalizer import finalize_psbt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
|
||||
from app.db.models import (
|
||||
PendingTransaction,
|
||||
PendingTransactionTxid,
|
||||
Round,
|
||||
RoundParticipant,
|
||||
User,
|
||||
UtxoEvent,
|
||||
Withdrawal,
|
||||
)
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
|
||||
from app.tx.versions import record_version
|
||||
from app.wallet.hd import derive_round_pool_key, derive_user_key
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
|
||||
@@ -93,28 +103,42 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None:
|
||||
|
||||
|
||||
async def bump_fee(
|
||||
session_factory: async_sessionmaker, client: ElectrumClient, pending_id: int
|
||||
session_factory: async_sessionmaker,
|
||||
client: ElectrumClient,
|
||||
pending_id: int,
|
||||
network: TransactionNetwork | None = None,
|
||||
) -> str | None:
|
||||
"""Rebuild pending_transaction `pending_id`'s transaction with a higher fee
|
||||
(same inputs, same recipient outputs, the extra fee taken from the change
|
||||
output) and rebroadcast. Returns the new txid, or None if there was nothing
|
||||
to do (the row is gone or already left "pending" — a normal race with
|
||||
confirmation, not an error).
|
||||
confirmation, not an error, or a conflict recorded below).
|
||||
|
||||
Three phases, so no DB session is held across the network calls this needs
|
||||
Four phases, so no DB session is held across the network calls this needs
|
||||
(one get_transaction per input, then a broadcast) — the same shape used
|
||||
elsewhere for exactly this reason (B-18, rounds/scheduler.py:_trigger_payout;
|
||||
B-31, electrum/listener.py:refresh_user) and now here too (B-40): read what's
|
||||
needed and close the session, do the chain work, then reopen to persist.
|
||||
|
||||
B-77 (TX-12, TX-13, DAT-34): the replacement is **written before it is broadcast**.
|
||||
It used to be sent in the chain phase and recorded afterwards, unconditionally, so
|
||||
a crash or a `database is locked` in between left a transaction on the network
|
||||
that the database did not know, and an abandon or a confirmation that happened in
|
||||
the meantime was silently overwritten. Now the signed replacement is committed as
|
||||
a *candidate* version (pending_transaction_txids) first; a candidate that is never
|
||||
broadcast is harmless, and one that is — and then confirms — is recognised like any
|
||||
other version. After the broadcast the row is switched over with a conditional
|
||||
UPDATE (status still pending, current_txid still the one replaced); zero rows
|
||||
matched is a conflict: the row is left alone, the candidate stays recorded, and
|
||||
the reconciler resolves it.
|
||||
|
||||
Only handles the common case: exactly one change output paying back to the
|
||||
tx's own sender address, large enough to absorb the increase. If there's no
|
||||
such output (e.g. an exact-amount bet with no change), this raises RbfError —
|
||||
bumping such a tx would require selecting additional inputs, which isn't
|
||||
implemented for the MVP; it needs manual operator intervention. Also raises
|
||||
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
|
||||
(B-32) — the reconciler abandons it if it never confirms (B-27), instead of
|
||||
this retrying an ever-higher fee forever.
|
||||
(B-32), and when the network explicitly refuses the replacement (TX-24).
|
||||
"""
|
||||
# --- Phase 1: read what's needed, close the session before any network call ---
|
||||
async with session_factory() as session:
|
||||
@@ -131,9 +155,10 @@ async def bump_fee(
|
||||
kind = pending.kind
|
||||
current_fee_rate = pending.fee_rate_sat_vb
|
||||
raw_tx_hex = pending.raw_tx_hex
|
||||
old_txid = pending.current_txid
|
||||
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id, pending.round_id)
|
||||
|
||||
# --- Phase 2: chain reads, signing, and the broadcast — no DB session open ----
|
||||
# --- Phase 2: chain reads and signing — no DB session open, nothing sent yet ---
|
||||
old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex))
|
||||
input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin]
|
||||
total_in = sum(input_amounts)
|
||||
@@ -178,27 +203,69 @@ async def bump_fee(
|
||||
|
||||
raw_hex = final_tx.serialize().hex()
|
||||
new_txid = final_tx.txid().hex()
|
||||
await client.broadcast(raw_hex)
|
||||
|
||||
# --- Phase 3: persist the outcome ----------------------------------------------
|
||||
# --- Phase 3: record the replacement as a candidate, *then* broadcast ----------
|
||||
async with session_factory() as session:
|
||||
await begin_immediate(session) # B-88
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
old_txid = pending.current_txid
|
||||
pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced
|
||||
pending.current_txid = new_txid
|
||||
pending.raw_tx_hex = raw_hex
|
||||
if pending is None or pending.status != "pending" or pending.current_txid != old_txid:
|
||||
logger.info("pending_transaction %s changed while its bump was being built; skipping", pending_id)
|
||||
return None
|
||||
await record_version(session, pending, new_txid, raw_hex) # B-77 (TX-12, DAT-71)
|
||||
await session.commit()
|
||||
|
||||
verdict = await broadcast_with_verdict(client, network, raw_hex, new_txid) # B-76: also fans out
|
||||
if verdict.outcome is BroadcastOutcome.REFUSED:
|
||||
# The network explicitly refused the replacement and no server knows it: the row
|
||||
# is unchanged and keeps the version that is on the network (TX-24). The
|
||||
# candidate stays recorded — it was never relayed, so nobody will confirm it.
|
||||
raise RbfError(f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}")
|
||||
# ACCEPTED, or UNCERTAIN: the replacement may well be on the network, so the row
|
||||
# follows it (TX-12); the old version stays recorded and is still recognised should
|
||||
# it be the one that confirms.
|
||||
|
||||
# --- Phase 4: switch the row over, conditionally --------------------------------
|
||||
async with session_factory() as session:
|
||||
await begin_immediate(session) # B-88
|
||||
# The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum
|
||||
# floor above raised fee_delta past the naive target, the tx now pays more
|
||||
# than target_fee_rate implied. Recording the true rate keeps the next bump's
|
||||
# arithmetic honest instead of drifting from what's really being paid.
|
||||
pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize
|
||||
pending.attempt_count += 1
|
||||
# last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first*
|
||||
# broadcast, since reconcile.py's abandon-after-N-hours grace period is measured
|
||||
# from it — overwriting it here used to reset that clock on every bump, so a
|
||||
# repeatedly-bumped-but-never-mined tx was never abandoned.
|
||||
pending.last_broadcast_at = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
update(PendingTransaction)
|
||||
.where(
|
||||
PendingTransaction.id == pending_id,
|
||||
PendingTransaction.status == "pending",
|
||||
PendingTransaction.current_txid == old_txid,
|
||||
)
|
||||
.values(
|
||||
replaced_by_txid=old_txid, # points backwards: what current_txid replaced
|
||||
current_txid=new_txid,
|
||||
raw_tx_hex=raw_hex,
|
||||
fee_rate_sat_vb=(old_fee + fee_delta) // vsize,
|
||||
attempt_count=PendingTransaction.attempt_count + 1,
|
||||
last_broadcast_at=datetime.now(timezone.utc),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
# Confirmed, abandoned or already re-bumped while we were broadcasting. The
|
||||
# row is not overwritten; the candidate is still recorded, and confirmation
|
||||
# and reconciliation look for it like any other version (TX-13).
|
||||
await session.rollback()
|
||||
logger.warning(
|
||||
"bump of pending_transaction %s: the row changed during the broadcast (replaced %s by %s); "
|
||||
"left to the reconciler",
|
||||
pending_id,
|
||||
old_txid,
|
||||
new_txid,
|
||||
)
|
||||
return None
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
await _retarget_txid_references(session, pending, old_txid, new_txid)
|
||||
await session.commit()
|
||||
|
||||
@@ -206,6 +273,29 @@ async def bump_fee(
|
||||
return new_txid
|
||||
|
||||
|
||||
async def adopt_version(session: AsyncSession, pending: PendingTransaction, txid: str) -> None:
|
||||
"""The network confirmed (or holds) a version of this row other than the current one
|
||||
— an earlier version of a bumped transaction, or a candidate whose switch-over never
|
||||
happened (B-77, TX-19, DAT-14). `current_txid` and `raw_tx_hex` become that
|
||||
version's, and the txid is propagated everywhere it is recorded, exactly as after a
|
||||
bump; without it the inputs of a replaced transaction would no longer be
|
||||
recognised as the row's (DAT-13)."""
|
||||
if txid == pending.current_txid:
|
||||
return
|
||||
version = await session.scalar(
|
||||
select(PendingTransactionTxid).where(
|
||||
PendingTransactionTxid.txid == txid, PendingTransactionTxid.pending_transaction_id == pending.id
|
||||
)
|
||||
)
|
||||
old_txid = pending.current_txid
|
||||
pending.replaced_by_txid = old_txid
|
||||
pending.current_txid = txid
|
||||
if version is not None and version.raw_hex:
|
||||
pending.raw_tx_hex = version.raw_hex # empty only for a migration-reconstructed txid
|
||||
await _retarget_txid_references(session, pending, old_txid, txid)
|
||||
logger.warning("pending_transaction %s: version %s found on the network, replacing %s", pending.id, txid, old_txid)
|
||||
|
||||
|
||||
async def _retarget_txid_references(
|
||||
session: AsyncSession, pending: PendingTransaction, old_txid: str, new_txid: str
|
||||
) -> None:
|
||||
@@ -247,9 +337,10 @@ async def _retarget_txid_references(
|
||||
|
||||
|
||||
class RbfBumper:
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client):
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client, network: TransactionNetwork | None = None):
|
||||
self._session_factory = session_factory
|
||||
self._get_client = get_client
|
||||
self._network = network # B-76/B-77: verifies and fans out a replacement
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
@@ -274,7 +365,7 @@ class RbfBumper:
|
||||
|
||||
for pending_id in due_ids:
|
||||
try:
|
||||
await bump_fee(self._session_factory, client, pending_id)
|
||||
await bump_fee(self._session_factory, client, pending_id, network=self._network)
|
||||
except RbfError:
|
||||
logger.exception("could not bump pending_transaction %s", pending_id)
|
||||
except Exception:
|
||||
|
||||
+15
-2
@@ -10,7 +10,9 @@ from app.db.models import PendingTransaction
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.events import broadcaster
|
||||
from app.tx.broadcast import adopt_version
|
||||
from app.tx.pending_address import own_address_for
|
||||
from app.tx.versions import txids_by_row
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,6 +49,9 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
|
||||
# by scripthash below — the payout rows of one round share its pool
|
||||
# address (B-80), so this also avoids asking the server the same history twice.
|
||||
scripthash_by_id: dict[int, str] = {}
|
||||
# B-77 (TX-06, TX-19): every txid a row ever had, not only the current one — an
|
||||
# earlier version of a bumped transaction may be the one that confirms.
|
||||
txids_by_id = await txids_by_row(session, {pending_id: txid for pending_id, txid, *_ in candidates})
|
||||
for pending_id, _txid, kind, user_id, round_id in candidates:
|
||||
try:
|
||||
address = await own_address_for(session, kind, user_id, round_id)
|
||||
@@ -74,11 +79,15 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
|
||||
logger.warning("could not fetch history for pending_transaction %s (txid %s)", pending_id, txid, exc_info=True)
|
||||
continue
|
||||
|
||||
entry = next((e for e in history_cache[scripthash] if e.get("tx_hash") == txid), None)
|
||||
known_txids = txids_by_id[pending_id]
|
||||
# height > 0 means confirmed at that height; 0 or absent means still in
|
||||
# the mempool (or the server doesn't know this txid at all yet) — either
|
||||
# way, not confirmed, so keep waiting.
|
||||
if entry is None or entry.get("height", 0) <= 0:
|
||||
entry = next(
|
||||
(e for e in history_cache[scripthash] if e.get("tx_hash") in known_txids and e.get("height", 0) > 0),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
async with session_factory() as session:
|
||||
@@ -86,6 +95,10 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
if row is None or row.status != "pending":
|
||||
continue
|
||||
if entry["tx_hash"] != row.current_txid:
|
||||
# The confirmed version is not the current one (B-77, DAT-14): realign
|
||||
# the row and everything that recorded its txid *before* the handler runs.
|
||||
await adopt_version(session, row, entry["tx_hash"])
|
||||
row.status = "confirmed"
|
||||
handler = _handlers.get(kind)
|
||||
if handler is not None:
|
||||
|
||||
+18
-7
@@ -39,7 +39,9 @@ from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.events import broadcaster
|
||||
from app.tx.broadcast import adopt_version
|
||||
from app.tx.pending_address import own_address_for
|
||||
from app.tx.versions import txids_by_row, txids_of
|
||||
from app.wallet.balance import recompute_balance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -98,6 +100,8 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl
|
||||
)
|
||||
).all()
|
||||
candidates = []
|
||||
# B-77 (TX-15, TX-19): a row's every txid, not only the current one.
|
||||
txids_by_id = await txids_by_row(session, {row.id: row.current_txid for row in rows})
|
||||
for row in rows:
|
||||
if not _is_due(row, now):
|
||||
continue
|
||||
@@ -107,15 +111,16 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl
|
||||
except Exception:
|
||||
logger.exception("could not derive the address for pending_transaction %s", row.id)
|
||||
continue
|
||||
candidates.append((row.id, row.status, row.current_txid, scripthash))
|
||||
candidates.append((row.id, row.status, row.current_txid, scripthash, txids_by_id[row.id]))
|
||||
|
||||
resolved = 0
|
||||
history_cache: dict[str, list[dict]] = {}
|
||||
for row_id, status, txid, scripthash in candidates:
|
||||
for row_id, status, txid, scripthash, known_txids in candidates:
|
||||
try:
|
||||
if scripthash not in history_cache:
|
||||
history_cache[scripthash] = await client.get_history(scripthash)
|
||||
exists = any(entry.get("tx_hash") == txid for entry in history_cache[scripthash])
|
||||
found = [entry["tx_hash"] for entry in history_cache[scripthash] if entry.get("tx_hash") in known_txids]
|
||||
exists = bool(found)
|
||||
except Exception:
|
||||
# Transport/server problem — say nothing about this tx and try again on
|
||||
# the next pass rather than abandoning a tx that may be perfectly alive.
|
||||
@@ -128,6 +133,11 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl
|
||||
if row is None or row.status != status:
|
||||
continue # something else moved it while we were asking
|
||||
if exists:
|
||||
if txid not in found:
|
||||
# Another version of the row is the one on the network (B-77, TX-19):
|
||||
# the row follows it, so the inputs and the domain row point at it.
|
||||
await adopt_version(session, row, found[0])
|
||||
resolved += 1
|
||||
if row.status == "building":
|
||||
await _promote(session, row)
|
||||
resolved += 1
|
||||
@@ -183,7 +193,7 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
|
||||
row.status = "failed"
|
||||
row.failure_reason = reason[:128]
|
||||
|
||||
released = await _release_inputs(session, row)
|
||||
released = await _release_inputs(session, row, await txids_of(session, row))
|
||||
|
||||
if row.kind == "bet":
|
||||
participant = await session.scalar(
|
||||
@@ -241,7 +251,7 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
|
||||
)
|
||||
|
||||
|
||||
async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int:
|
||||
async def _release_inputs(session: AsyncSession, row: PendingTransaction, own_txids: set[str] | None = None) -> int:
|
||||
"""Clear spent_txid on every UTXO this transaction consumed, so the balance
|
||||
counts them again. The inputs come from the stored raw tx, which is kept current
|
||||
across RBF bumps, so this works for a bumped tx too."""
|
||||
@@ -257,8 +267,9 @@ async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int
|
||||
select(UtxoEvent).where(UtxoEvent.txid == vin.txid.hex(), UtxoEvent.vout == vin.vout)
|
||||
)
|
||||
# Only release what this tx actually reserved: if another tx has since spent
|
||||
# the same UTXO, its claim is the live one and must not be cleared.
|
||||
if utxo is not None and utxo.spent_txid == row.current_txid:
|
||||
# the same UTXO, its claim is the live one and must not be cleared. "This tx"
|
||||
# is any version of the row (B-77, DAT-13), not only its current one.
|
||||
if utxo is not None and utxo.spent_txid in (own_txids or {row.current_txid}):
|
||||
utxo.spent_txid = None
|
||||
released += 1
|
||||
return released
|
||||
@@ -0,0 +1,53 @@
|
||||
"""The versions of a pending transaction (B-77, DAT-70, DAT-71, TX-19).
|
||||
|
||||
A bumped transaction has several txids and any of them may be the one that confirms.
|
||||
Everything that looks a transaction up on the chain asks for all of them."""
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import PendingTransaction, PendingTransactionTxid
|
||||
|
||||
|
||||
async def record_version(session: AsyncSession, pending: PendingTransaction, txid: str, raw_hex: str) -> None:
|
||||
"""Write `txid` as a version of `pending` in the caller's transaction. Idempotent for
|
||||
the same pair (a bump retried after an uncertain broadcast rebuilds the same
|
||||
transaction); a txid already held by another row is an error — it is unique across
|
||||
the platform."""
|
||||
if pending.id is None:
|
||||
await session.flush()
|
||||
existing = await session.scalar(select(PendingTransactionTxid).where(PendingTransactionTxid.txid == txid))
|
||||
if existing is not None:
|
||||
if existing.pending_transaction_id != pending.id:
|
||||
raise ValueError(f"txid {txid} already belongs to pending_transaction {existing.pending_transaction_id}")
|
||||
return
|
||||
session.add(PendingTransactionTxid(pending_transaction_id=pending.id, txid=txid, raw_hex=raw_hex))
|
||||
|
||||
|
||||
async def txids_of(session: AsyncSession, pending: PendingTransaction) -> set[str]:
|
||||
"""Every txid of the row: all its recorded versions plus `current_txid` (a row
|
||||
written before the table existed, or by a test, may have no version row for it)."""
|
||||
rows = await session.scalars(
|
||||
select(PendingTransactionTxid.txid).where(PendingTransactionTxid.pending_transaction_id == pending.id)
|
||||
)
|
||||
return {pending.current_txid, *rows.all()}
|
||||
|
||||
|
||||
async def txids_by_row(session: AsyncSession, current: dict[int, str]) -> dict[int, set[str]]:
|
||||
"""`txids_of` for many rows at once; `current` maps row id -> current_txid."""
|
||||
result = {row_id: {txid} for row_id, txid in current.items()}
|
||||
if not result:
|
||||
return result
|
||||
rows = await session.execute(
|
||||
select(PendingTransactionTxid.pending_transaction_id, PendingTransactionTxid.txid).where(
|
||||
PendingTransactionTxid.pending_transaction_id.in_(list(result))
|
||||
)
|
||||
)
|
||||
for row_id, txid in rows.all():
|
||||
result[row_id].add(txid)
|
||||
return result
|
||||
|
||||
|
||||
async def delete_versions(session: AsyncSession, pending_id: int) -> None:
|
||||
"""The versions go with a `bet` row that a verified refusal deletes (DAT-38)."""
|
||||
await session.execute(delete(PendingTransactionTxid).where(PendingTransactionTxid.pending_transaction_id == pending_id))
|
||||
@@ -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.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
|
||||
@@ -166,6 +167,7 @@ async def request_withdrawal(
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-31 — Tutto ciò che spende denaro si scrive prima di inviarlo
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-77)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Denaro in movimento
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -20,5 +20,4 @@ riconciliatore e risponde **202** ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)
|
||||
|
||||
*Codice:* `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:bump_fee` · `app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* `tests/unit/test_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-77), per le sostituzioni RBF · *Oggi:*
|
||||
`bump_fee` invia la sostituzione prima di salvarla.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-51 — Nel dubbio non si liberano mai i fondi
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-77, B-78)
|
||||
- **Stato:** Accettata — implementazione da completare (B-78)
|
||||
- **Area:** Denaro in movimento
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -37,7 +37,7 @@ pausa la lotteria ([ADR-28](ADR-28-pausa-manutenzione-ferma-round-successivo.md)
|
||||
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_is_due` · `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_broadcast_outcome.py`
|
||||
*Stato:* da implementare (B-77, B-78) · *Oggi:* l'abbandono avviene
|
||||
quando la cronologia del solo server attivo non contiene il txid corrente,
|
||||
dopo 120s per `building` e 6 ore per `pending`; si segue solo l'ultimo txid;
|
||||
le righe `failed` non sono più controllate.
|
||||
*Stato:* da implementare (B-78) · *Oggi:* l'abbandono avviene
|
||||
quando la cronologia del solo server attivo non contiene alcun txid della
|
||||
riga, dopo 120s per `building` e 6 ore per `pending`; le righe `failed` non
|
||||
sono più controllate.
|
||||
@@ -564,8 +564,8 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||||
([LIM-01](limiti-noti.md#robustezza-del-ciclo-di-vita)).
|
||||
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
|
||||
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
|
||||
lavorano sulla tabella `pending_transactions` (e, dal piano, sulla tabella
|
||||
di tutti i txid di ogni riga, B-77); il riconciliatore parte subito perché
|
||||
lavorano sulla tabella `pending_transactions` (e sulla tabella
|
||||
di tutti i txid di ogni riga, `pending_transaction_txids`); il riconciliatore parte subito perché
|
||||
un crash durante un invio lascia proprio una riga `building` da risolvere
|
||||
al riavvio. Regole in
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
|
||||
|
||||
@@ -349,8 +349,7 @@ L'**invio** di una transazione non è una corroborazione ma usa gli stessi
|
||||
server: dopo che il server attivo l'ha accettata, la transazione grezza viene
|
||||
inviata anche a ogni server di riserva, al meglio. La regola è in
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) (B-76,
|
||||
B-83); oggi l'invio va anche a tutti gli altri server, salvo la sostituzione
|
||||
RBF di `bump_fee` (B-77).
|
||||
B-83).
|
||||
|
||||
## Riconnessione
|
||||
|
||||
|
||||
+15
-40
@@ -42,8 +42,7 @@ Cosa **non** c'è qui, e dove trovarlo:
|
||||
|
||||
## Diagramma delle relazioni
|
||||
|
||||
Dieci tabelle applicative nello schema atteso (nove oggi: manca
|
||||
`pending_transaction_txids`, B-77). `round_config` non ha relazioni: è una
|
||||
Dieci tabelle applicative. `round_config` non ha relazioni: è una
|
||||
riga singola di parametri. Gli indirizzi della piattaforma che non
|
||||
appartengono a un utente sono derivati dalla master key, e i loro UTXO si
|
||||
leggono dalla rete, non da una tabella:
|
||||
@@ -67,7 +66,7 @@ erDiagram
|
||||
rounds |o--o{ pending_transactions : "bet / payout"
|
||||
withdrawals |o--o{ pending_transactions : "withdrawal"
|
||||
users |o--o{ pending_transactions : "bet / withdrawal"
|
||||
pending_transactions ||--|{ pending_transaction_txids : "ogni versione (da implementare)"
|
||||
pending_transactions ||--|{ pending_transaction_txids : "ogni versione"
|
||||
users |o--o{ bug_reports : "segnala"
|
||||
users |o--o{ audit_log : "riguarda"
|
||||
rounds |o--o{ audit_log : "riguarda"
|
||||
@@ -233,7 +232,7 @@ erDiagram
|
||||
([DAT-52]). Nessuna chiave esterna ha `ON DELETE`. Le sole cancellazioni
|
||||
fisiche nel codice sono quelle di una scommessa fallita (la riga
|
||||
`round_participants` e la sua `pending_transactions`, con le sue versioni in
|
||||
`pending_transaction_txids` nello schema atteso,
|
||||
`pending_transaction_txids`,
|
||||
[DAT-24], [DAT-38], [DAT-70]); nessun'altra tabella perde righe, quindi i riferimenti
|
||||
restano validi per costruzione, non per vincolo.
|
||||
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
|
||||
@@ -402,9 +401,7 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
|
||||
stesso output risulta impegnato da un'altra transazione, quella
|
||||
rivendicazione è quella valida e non va toccata.
|
||||
*Codice:* `app/tx/reconcile.py:_release_inputs` ·
|
||||
*Test:* `tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-77), per le versioni precedenti · *Oggi:* il
|
||||
confronto è solo con `current_txid`.
|
||||
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_tx_versions.py`
|
||||
|
||||
- **[DAT-14]** Per ogni output impegnato, `spent_txid` è sempre il txid
|
||||
**corrente** della transazione che lo spende, cioè il `current_txid` della sua
|
||||
@@ -419,9 +416,7 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee` ·
|
||||
`app/tx/broadcast.py:_retarget_txid_references` ·
|
||||
`app/tx/confirmation.py:poll_once` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
*Stato:* da implementare (B-77) · *Oggi:* solo il bump riallinea; la
|
||||
conferma di una versione precedente non viene riconosciuta.
|
||||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
|
||||
|
||||
### `rounds`
|
||||
|
||||
@@ -904,7 +899,7 @@ riconciliatore (vedi
|
||||
| `broadcast_at` | DateTime | no | `utcnow` | Prima trasmissione, vedi [DAT-35] | ORM, alla creazione |
|
||||
| `last_broadcast_at` | DateTime | no | `utcnow` | Ultima trasmissione, vedi [DAT-35] | ORM; `bump_fee` |
|
||||
| `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-33] | vedi diagramma |
|
||||
| `replaced_by_txid` | String(64) | sì | `NULL` | Il txid **sostituito** dall'ultimo bump (nonostante il nome, punta all'indietro). Nello schema atteso la cronologia completa è in `pending_transaction_txids` ([DAT-70]) e questa colonna resta solo informativa | `bump_fee` |
|
||||
| `replaced_by_txid` | String(64) | sì | `NULL` | Il txid **sostituito** dall'ultimo bump (nonostante il nome, punta all'indietro). La cronologia completa è in `pending_transaction_txids` ([DAT-70]) e questa colonna resta solo informativa | `bump_fee` |
|
||||
| `attempt_count` | Integer | no | `1` | 1 + numero di bump riusciti | `bump_fee` |
|
||||
| `failure_reason` | String(128) | sì | `NULL` | Perché la transazione è fallita; solo con `status = failed` | `_abandon`; `_release_failed_withdrawal` |
|
||||
| `failed_at` | DateTime | sì | `NULL` | Istante del passaggio a `failed`; da lì si contano i 14 giorni di sorveglianza ([DAT-72]) — *da implementare (B-78)* | `_abandon`; `_release_failed_withdrawal` |
|
||||
@@ -995,12 +990,7 @@ Le funzioni citate sono `app/bets/service.py:place_bet`,
|
||||
|
||||
`fee_rate_sat_vb` registra la commissione effettivamente pagata dalla nuova
|
||||
versione, non quella obiettivo.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py`
|
||||
*Stato:* da implementare (B-77) · *Oggi:* la nuova versione è trasmessa
|
||||
nella fase 2 prima di esistere nel database, e la fase 3 riscrive la riga
|
||||
**senza ricontrollarne** stato e `current_txid`: un abbandono o una
|
||||
conferma avvenuti nel frattempo vengono sovrascritti. `replaced_by_txid`
|
||||
conserva solo il txid immediatamente precedente.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
|
||||
|
||||
- **[DAT-35]** `broadcast_at` è la **prima** trasmissione e non viene mai
|
||||
riscritta: è il riferimento da cui il riconciliatore misura i propri tempi
|
||||
@@ -1062,7 +1052,7 @@ Le funzioni citate sono `app/bets/service.py:place_bet`,
|
||||
|
||||
### `pending_transaction_txids`
|
||||
|
||||
*Tabella da implementare (B-77).* Ogni versione che una riga
|
||||
Ogni versione che una riga
|
||||
`pending_transactions` abbia mai avuto: la prima, ogni sostituzione RBF
|
||||
trasmessa e ogni candidata scritta prima di un bump. Serve perché una
|
||||
qualunque di queste versioni può essere quella che la rete conferma.
|
||||
@@ -1086,12 +1076,9 @@ qualunque di queste versioni può essere quella che la rete conferma.
|
||||
`current_txid`: la conferma di uno qualunque conferma la riga ([DAT-14]).
|
||||
La riga `bet` cancellata da `_release_failed_bet` ([DAT-38]) porta con sé
|
||||
le proprie versioni, nella stessa transazione.
|
||||
*Codice:* `app/db/models.py` · `app/tx/broadcast.py:bump_fee` ·
|
||||
*Codice:* `app/db/models.py:PendingTransactionTxid` · `app/tx/versions.py` · `app/tx/broadcast.py:bump_fee` ·
|
||||
`app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-77) · *Oggi:* la tabella non esiste; poller e
|
||||
riconciliatore confrontano solo `current_txid`, e `replaced_by_txid`
|
||||
ricorda un solo passo indietro.
|
||||
*Test:* `tests/unit/test_tx_versions.py`, `tests/unit/test_migrations.py`
|
||||
|
||||
- **[DAT-71]** Una versione **candidata** è una riga di questa tabella il cui
|
||||
txid non è (ancora) `current_txid`: `bump_fee` l'ha scritta ma la fase 3 non
|
||||
@@ -1102,9 +1089,7 @@ qualunque di queste versioni può essere quella che la rete conferma.
|
||||
Il riconciliatore, quando ritrasmette, usa il `raw_tx_hex` corrente, non
|
||||
quello di una candidata.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-77) · *Oggi:* non esistono candidate: la
|
||||
sostituzione esiste nel database solo dopo il suo broadcast riuscito.
|
||||
*Test:* `tests/unit/test_tx_versions.py`
|
||||
|
||||
### `withdrawals`
|
||||
|
||||
@@ -1240,8 +1225,8 @@ Tabella di servizio di Alembic: una riga con la revisione applicata
|
||||
| `ix_rounds_single_active` | `rounds` | `UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out')` | al massimo un round attivo, [DAT-17] (B-09) |
|
||||
| unico (`round_id`, `user_id`) | `round_participants` | `UNIQUE` | una scommessa per utente per round, [DAT-23] |
|
||||
| unico (`round_id`, `draw_position`) | `round_participants` | `UNIQUE` (i `NULL` non collidono) | una posizione di estrazione per partecipante, [DAT-66] — *da implementare (B-81)* |
|
||||
| unico `txid` | `pending_transaction_txids` | `UNIQUE` | un txid appartiene a una sola transazione, [DAT-70] — *da implementare (B-77)* |
|
||||
| `ix_pending_transaction_txids_pending_transaction_id` | `pending_transaction_txids` | indice | versioni di una transazione — *da implementare (B-77)* |
|
||||
| unico `txid` | `pending_transaction_txids` | `UNIQUE` | un txid appartiene a una sola transazione, [DAT-70] |
|
||||
| `ix_pending_transaction_txids_pending_transaction_id` | `pending_transaction_txids` | indice | versioni di una transazione |
|
||||
| `ix_round_participants_round_id` | `round_participants` | indice | partecipanti di un round |
|
||||
| `ix_round_participants_user_id` | `round_participants` | indice | round di un utente (`last-round-result`) |
|
||||
| `ix_withdrawals_user_id` | `withdrawals` | indice | prelievi di un utente |
|
||||
@@ -1267,12 +1252,7 @@ letti con una scansione completa.
|
||||
`app/withdrawals/service.py:request_withdrawal` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py`
|
||||
*Stato:* da implementare (B-77), per le sostituzioni RBF · *Oggi:*
|
||||
l'invariante vale per la prima versione; una sostituzione RBF viene
|
||||
trasmessa nella fase 2 di `bump_fee` e scritta nel database solo nella
|
||||
fase 3, quindi un arresto o un `database is locked` fra le due lascia sulla
|
||||
rete una transazione che il database non conosce.
|
||||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_tx_versions.py`
|
||||
|
||||
- **[DAT-45]** **Ogni versione è riconoscibile, una sola è corrente.** Per ogni
|
||||
transazione non terminale, `pending_transactions.current_txid` coincide con
|
||||
@@ -1287,12 +1267,7 @@ letti con una scansione completa.
|
||||
txid.
|
||||
*Codice:* `app/tx/broadcast.py:_retarget_txid_references` ·
|
||||
`app/tx/broadcast.py:bump_fee` · `app/tx/confirmation.py:poll_once` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
*Stato:* da implementare (B-77) · *Oggi:* l'aggiornamento dei riferimenti
|
||||
avviene in una transazione del database separata dal broadcast e non
|
||||
condizionata, e i txid precedenti sono dimenticati (salvo l'ultimo, in
|
||||
`replaced_by_txid`, che nessuno legge): se conferma una versione
|
||||
precedente, poller e riconciliatore non la riconoscono.
|
||||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
|
||||
|
||||
- **[DAT-46]** **Il montepremi di ogni round si conserva, separato dagli
|
||||
altri.** Per un round pagato, con i soli partecipanti rimasti `confirmed`
|
||||
|
||||
+2
-2
@@ -615,8 +615,8 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
indirizzo secondo il **solo server attivo**: mai partita (riga `building` da
|
||||
oltre 120s) o scomparsa dal mempool (riga `pending` da oltre 6 ore dal primo
|
||||
invio). Non è una prova: un server diverso da quello che ha ricevuto l'invio
|
||||
può non conoscerla, e una versione precedente di una transazione accelerata
|
||||
con RBF non viene cercata (B-77).
|
||||
può non conoscerla (si cercano tutte le versioni di una transazione
|
||||
accelerata con RBF, ma solo sul server attivo).
|
||||
**Effetto e cosa fare.** Il sistema ha già annullato tutto: UTXO liberati e
|
||||
saldo ricalcolato; per una scommessa il partecipante è rimosso (l'utente può
|
||||
riscommettere se il round è ancora aperto); per un prelievo la richiesta è
|
||||
|
||||
+3
-3
@@ -225,7 +225,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
| B-74 | Header accettati senza controllo della difficoltà minima; un salto di più blocchi non viene collegato; una riorganizzazione di un blocco chiude la sessione | Critica | **aperto** | — | ELC |
|
||||
| B-75 | Una risposta Electrum oltre 64 KiB chiude la connessione, e la cronologia dell'indirizzo del montepremi la supera dopo ~800 transazioni | Critica | *da registrare* | `test_electrum_client.py`, `test_deposits.py`, `test_psbt_builder.py` | ELC, TX |
|
||||
| B-76 | Un invio ambiguo (timeout, connessione caduta) viene annullato come se fosse rifiutato | Critica | *da registrare* | `test_broadcast_outcome.py`, `test_electrum_client.py` | TX, BET, WDR |
|
||||
| B-77 | Si ricorda solo l'ultimo txid di una transazione accelerata, e la sostituzione viene inviata prima di essere salvata | Critica | **aperto** | — | TX |
|
||||
| B-77 | Si ricorda solo l'ultimo txid di una transazione accelerata, e la sostituzione viene inviata prima di essere salvata | Critica | *da registrare* | `test_tx_versions.py`, `test_migrations.py`, `test_broadcast.py` | TX |
|
||||
| B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | **aperto** | — | TX, ELC |
|
||||
| B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | **aperto** | — | RND, TX |
|
||||
| B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | *da registrare* | `test_round_pool_address.py`, `test_pending_address.py`, `test_scheduler.py` | RND, BET, WDR |
|
||||
@@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
|
||||
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
|
||||
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76.
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77.
|
||||
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
|
||||
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (7 critici,
|
||||
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (6 critici,
|
||||
7 alti, 6 medi), da implementare nell'ordine di
|
||||
[piano-correzioni.md](piano-correzioni.md).
|
||||
- **Prossimo numero libero:** `B-99`.
|
||||
@@ -69,11 +69,8 @@ stateDiagram-v2
|
||||
la versione trovata in catena non è quella corrente, `current_txid` e
|
||||
`raw_tx_hex` diventano quelli della versione trovata e il txid viene
|
||||
propagato ovunque sia registrato, come dopo un RBF ([TX-12]).
|
||||
*Codice:* `app/tx/confirmation.py:poll_once`, `app/tx/reconcile.py:reconcile_once`, `app/tx/broadcast.py:_retarget_txid_references` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-77) · *Oggi:* si conserva solo il txid corrente
|
||||
(più il precedente in `replaced_by_txid`, che nessuno legge); se conferma
|
||||
una versione precedente a un RBF non viene riconosciuta.
|
||||
*Codice:* `app/tx/versions.py`, `app/tx/confirmation.py:poll_once`, `app/tx/reconcile.py:reconcile_once`, `app/tx/broadcast.py:adopt_version`, `app/tx/broadcast.py:_retarget_txid_references` ·
|
||||
*Test:* `tests/unit/test_tx_versions.py`, `tests/unit/test_migrations.py`
|
||||
- **[TX-28]** Ogni transazione del database che sposta denaro — la scrittura
|
||||
di `building` ([TX-01]), la promozione dopo l'invio, l'annullamento dopo un
|
||||
rifiuto, le due scritture dell'RBF ([TX-12]), la promozione e l'abbandono
|
||||
@@ -147,9 +144,7 @@ monete:
|
||||
questi invii viene solo registrato nei log, non cambia lo stato della riga
|
||||
e non ritarda la risposta all'utente.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.fan_out_transaction`, `app/tx/outcome.py:broadcast_with_verdict`, `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_broadcast_outcome.py`
|
||||
*Stato:* da implementare (B-77), per la sostituzione RBF · *Oggi:* la
|
||||
sostituzione di `bump_fee` viene inviata solo al server attivo.
|
||||
*Test:* `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_tx_versions.py`
|
||||
- **[TX-05]** Se l'invio di un pagamento non è accettato — rifiuto esplicito
|
||||
o esito incerto — la riga resta `building` e si scrive `payout_failed`
|
||||
(`broadcast rejected`): è il riconciliatore a decidere, interrogando la
|
||||
@@ -172,10 +167,9 @@ monete:
|
||||
per indirizzo a ogni giro; un indirizzo che non si riesce a leggere non
|
||||
blocca gli altri.
|
||||
*Codice:* `app/tx/confirmation.py:poll_once`, `app/tx/pending_address.py:own_address_for` ·
|
||||
*Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_round_pool_address.py`
|
||||
*Stato:* da implementare (B-77) · *Oggi:* cerca solo `current_txid`,
|
||||
nella cronologia dell'indirizzo del montepremi del round per i pagamenti; un'altezza maggiore di 0 riportata dal solo server
|
||||
attivo basta a confermare ([TX-22]).
|
||||
*Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_round_pool_address.py`, `tests/unit/test_tx_versions.py`
|
||||
*Stato:* da implementare (B-83) · *Oggi:* un'altezza maggiore di 0
|
||||
riportata dal solo server attivo basta a confermare ([TX-22]).
|
||||
- **[TX-22]** **Conferma verificata.** Un candidato ([TX-06]) è confermato
|
||||
solo quando il quorum dei server
|
||||
([connessione-electrum.md](connessione-electrum.md) (B-84)) restituisce per
|
||||
@@ -277,11 +271,7 @@ sostituibilità fin dal primo invio.
|
||||
round (`payout_txid`). Se la sostituzione è rifiutata esplicitamente la
|
||||
riga non cambia ([TX-24]).
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_retarget_txid_references` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
*Stato:* da implementare (B-77) · *Oggi:* la sostituzione viene inviata
|
||||
senza essere registrata, e la riga viene aggiornata solo dopo l'invio, senza
|
||||
condizioni: un crash o un `database is locked` in mezzo lascia in rete una
|
||||
transazione che il database non conosce.
|
||||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
|
||||
- **[TX-13]** L'RBF non tocca una riga che è cambiata sotto di lui. Prima di
|
||||
costruire la sostituzione, se la riga non esiste più o non è più
|
||||
`pending` (ad esempio è appena stata confermata), non si fa nulla. Dopo
|
||||
@@ -291,11 +281,7 @@ sostituibilità fin dal primo invio.
|
||||
candidata resta registrata — la conferma e la riconciliazione la cercano
|
||||
comunque ([TX-19]) — e il conflitto viene scritto nei log.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
*Stato:* da implementare (B-77) · *Oggi:* il controllo avviene solo prima
|
||||
della costruzione; dopo l'invio la riga viene aggiornata senza ricontrollare
|
||||
né lo stato né `current_txid`, anche se nel frattempo è stata confermata o
|
||||
abbandonata.
|
||||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
|
||||
- **[TX-24]** Un'accelerazione impossibile ([TX-11], o la riga già a
|
||||
`MAX_FEE_RATE_SAT_VB`) o una sostituzione rifiutata esplicitamente dalla
|
||||
rete scrivono un evento `rbf_bump_failed` ([audit-log.md](audit-log.md)
|
||||
@@ -342,9 +328,9 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
|
||||
([TX-17]). Un **errore di rete** non porta a nulla: la riga resta com'è
|
||||
fino al giro successivo.
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once`, `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
|
||||
*Test:* `tests/unit/test_reconcile.py`
|
||||
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_tx_versions.py`
|
||||
*Stato:* da implementare (B-78) · *Oggi:* si interroga solo il server
|
||||
attivo e solo per `current_txid`, e un txid assente dalla sua cronologia è
|
||||
attivo, e un txid assente dalla sua cronologia è
|
||||
trattato come "sconosciuto alla rete": dopo un riavvio su un server diverso
|
||||
una riga `building` viene abbandonata dopo 120s anche se la transazione è
|
||||
nella mempool degli altri nodi (che la tengono fino a 336 ore con le
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""add pending_transaction_txids
|
||||
|
||||
Revision ID: a3f5c2d9b7e1
|
||||
Revises: 477bddae1a84
|
||||
Create Date: 2026-10-14 15:02:41.118204
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a3f5c2d9b7e1'
|
||||
down_revision: Union[str, Sequence[str], None] = '477bddae1a84'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema.
|
||||
|
||||
B-77 (DAT-70): every version a pending transaction has ever had. A new table, so
|
||||
nothing here rebuilds an existing one and the expression indexes (B-97) are not at
|
||||
risk. Rows that already exist are backfilled with what they still remember: their
|
||||
`current_txid` with its raw transaction and, when it differs, the `replaced_by_txid`
|
||||
of their last bump with an empty `raw_hex` (that raw transaction was overwritten).
|
||||
"""
|
||||
op.create_table(
|
||||
"pending_transaction_txids",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("pending_transaction_id", sa.Integer(), nullable=False),
|
||||
sa.Column("txid", sa.String(length=64), nullable=False),
|
||||
sa.Column("raw_hex", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["pending_transaction_id"], ["pending_transactions.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("txid"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_pending_transaction_txids_pending_transaction_id"),
|
||||
"pending_transaction_txids",
|
||||
["pending_transaction_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO pending_transaction_txids (pending_transaction_id, txid, raw_hex, created_at)
|
||||
SELECT id, current_txid, raw_tx_hex, broadcast_at FROM pending_transactions
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO pending_transaction_txids (pending_transaction_id, txid, raw_hex, created_at)
|
||||
SELECT id, replaced_by_txid, '', broadcast_at FROM pending_transactions
|
||||
WHERE replaced_by_txid IS NOT NULL
|
||||
AND replaced_by_txid != current_txid
|
||||
AND replaced_by_txid NOT IN (SELECT txid FROM pending_transaction_txids)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(op.f("ix_pending_transaction_txids_pending_transaction_id"), table_name="pending_transaction_txids")
|
||||
op.drop_table("pending_transaction_txids")
|
||||
@@ -74,3 +74,53 @@ def test_the_migrated_database_still_enforces_both_invariants(migrated_db):
|
||||
conn.execute(f"INSERT INTO users {columns} VALUES ('Alice', 'x', 0, 'plm1qa', 0, 0, '2026-01-01 00:00:00')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(f"INSERT INTO users {columns} VALUES ('aLICE', 'x', 1, 'plm1qb', 0, 0, '2026-01-01 00:00:00')")
|
||||
|
||||
|
||||
def _alembic(db_path: Path, *args: str) -> None:
|
||||
env = {**os.environ, "DATABASE_URL": f"sqlite+aiosqlite:///{db_path}"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "alembic", *args], cwd=_ROOT, env=env, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
assert result.returncode == 0, f"alembic {' '.join(args)} failed:\n{result.stderr[-3000:]}"
|
||||
|
||||
|
||||
def test_pending_transaction_txids_table_matches_the_model(migrated_db):
|
||||
"""B-77 (DAT-70): the migrated table has the model's columns, the unique txid and the
|
||||
index on the owning row."""
|
||||
from app.db.models import PendingTransactionTxid
|
||||
|
||||
with sqlite3.connect(migrated_db) as conn:
|
||||
columns = {row[1]: row for row in conn.execute("PRAGMA table_info(pending_transaction_txids)")}
|
||||
sql = conn.execute("SELECT sql FROM sqlite_master WHERE name = 'pending_transaction_txids'").fetchone()[0]
|
||||
indexes = {row[1] for row in conn.execute("PRAGMA index_list(pending_transaction_txids)")}
|
||||
assert set(columns) == {c.name for c in PendingTransactionTxid.__table__.columns}
|
||||
assert "unique" in sql.lower()
|
||||
assert "ix_pending_transaction_txids_pending_transaction_id" in indexes
|
||||
|
||||
|
||||
def test_existing_pending_transactions_are_backfilled_with_the_txids_they_remember(tmp_path):
|
||||
"""B-77 (DAT-70): upgrading a database that already has rows records each one's
|
||||
current txid with its raw transaction, and the txid a bump replaced with an empty
|
||||
raw_hex — and the expression indexes survive the step."""
|
||||
db_path = tmp_path / "old.db"
|
||||
_alembic(db_path, "upgrade", "477bddae1a84")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
columns = (
|
||||
"(kind, current_txid, fee_rate_sat_vb, raw_tx_hex, broadcast_at, last_broadcast_at, status, "
|
||||
"replaced_by_txid, attempt_count, updated_at)"
|
||||
)
|
||||
stamp = "'2026-01-01 00:00:00'"
|
||||
conn.execute(
|
||||
f"INSERT INTO pending_transactions {columns} VALUES "
|
||||
f"('bet', 'cc', 2, 'rawcc', {stamp}, {stamp}, 'pending', 'bb', 2, {stamp})"
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT INTO pending_transactions {columns} VALUES "
|
||||
f"('withdrawal', 'dd', 1, 'rawdd', {stamp}, {stamp}, 'building', NULL, 1, {stamp})"
|
||||
)
|
||||
_alembic(db_path, "upgrade", "head")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
rows = sorted(conn.execute("SELECT pending_transaction_id, txid, raw_hex FROM pending_transaction_txids"))
|
||||
assert rows == [(1, "bb", ""), (1, "cc", "rawcc"), (2, "dd", "rawdd")]
|
||||
assert _index_sql(db_path, "ix_rounds_single_active") is not None
|
||||
assert _index_sql(db_path, "ix_users_username_lower") is not None
|
||||
@@ -0,0 +1,448 @@
|
||||
"""B-77 (TX-19, TX-12, TX-13, TX-21, TX-06, TX-15, DAT-13, DAT-14, DAT-34, DAT-38, DAT-70, DAT-71):
|
||||
every version of a bumped transaction is tracked, and an RBF replacement is written
|
||||
before it is broadcast.
|
||||
|
||||
Before the fix only `current_txid` was recognised: if an earlier version of a bumped
|
||||
transaction confirmed, nothing noticed, and six hours later the reconciler abandoned the
|
||||
row — a confirmed bet's participant deleted, a sent withdrawal marked failed, a paid
|
||||
payout rebuilt. And `bump_fee` broadcast first and persisted afterwards, unconditionally.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from embit import script
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
import app.bets.confirmation # noqa: F401 (registers the "bet" handler)
|
||||
import app.rounds.confirmation # noqa: F401 (registers the "payout" handler)
|
||||
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" handler)
|
||||
from app.bets.service import place_bet
|
||||
from app.db.models import (
|
||||
AuditLog,
|
||||
PendingTransaction,
|
||||
PendingTransactionTxid,
|
||||
Round,
|
||||
RoundParticipant,
|
||||
User,
|
||||
UtxoEvent,
|
||||
Withdrawal,
|
||||
)
|
||||
from app.tx import broadcast as broadcast_module
|
||||
from app.tx.broadcast import RbfError, bump_fee
|
||||
from app.tx.confirmation import poll_once
|
||||
from app.tx.reconcile import reconcile_once
|
||||
from app.wallet.hd import derive_pool_address, derive_user_address, derive_user_key
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import Utxo, build_signed_transaction
|
||||
from app.withdrawals.service import request_withdrawal
|
||||
from tests.unit.fake_network import FakeNetwork, rpc_error
|
||||
from tests.unit.test_bets import _make_funded_user
|
||||
from tests.unit.test_bets import session_factory as bets_session_factory # noqa: F401 (fixture)
|
||||
from tests.unit.test_broadcast import FakeClient as ChainClient, session_factory # noqa: F401 (fixture)
|
||||
|
||||
_UTXO_TXID = "11" * 32
|
||||
_UTXO_AMOUNT = 150_000_000
|
||||
|
||||
|
||||
async def _seed_pending_bet(session_factory, *, age_seconds: int = 1000) -> dict:
|
||||
"""A user, a round, a participant and a `pending` bet row, with its first version
|
||||
recorded the way place_bet records it."""
|
||||
signer = derive_user_key(0)
|
||||
address = derive_user_address(0)
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=script.p2wpkh(signer.to_public()),
|
||||
utxos=[Utxo(_UTXO_TXID, 0, _UTXO_AMOUNT)],
|
||||
to_address=derive_pool_address(1),
|
||||
amount_sats=10_000_000,
|
||||
change_address=address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
broadcast_at = datetime.now(timezone.utc) - timedelta(seconds=age_seconds)
|
||||
async with session_factory() as session:
|
||||
user = User(username="alice", password_hash="x", derivation_index=0, address=address)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
session.add(Round(id=1, status="open", pool_address=derive_pool_address(1)))
|
||||
session.add(
|
||||
UtxoEvent(user_id=user.id, txid=_UTXO_TXID, vout=0, amount_sats=_UTXO_AMOUNT, confirmed_height=5, spent_txid=built.txid)
|
||||
)
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=1, user_id=user.id, bet_amount_sats=10_000_000, bet_txid=built.txid, status="broadcast"
|
||||
)
|
||||
)
|
||||
pending = PendingTransaction(
|
||||
kind="bet",
|
||||
round_id=1,
|
||||
user_id=user.id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="pending",
|
||||
broadcast_at=broadcast_at,
|
||||
last_broadcast_at=broadcast_at,
|
||||
)
|
||||
session.add(pending)
|
||||
await session.flush()
|
||||
session.add(PendingTransactionTxid(pending_transaction_id=pending.id, txid=built.txid, raw_hex=built.raw_hex))
|
||||
await session.commit()
|
||||
return {"pending_id": pending.id, "user_id": user.id, "first_txid": built.txid, "first_raw": built.raw_hex}
|
||||
|
||||
|
||||
class _Chain(ChainClient):
|
||||
"""prevout lookups for bump_fee, plus a scriptable broadcast and history."""
|
||||
|
||||
def __init__(self, on_broadcast=None, fail_with=None):
|
||||
super().__init__({_UTXO_TXID: _UTXO_AMOUNT})
|
||||
self.on_broadcast = on_broadcast
|
||||
self.fail_with = fail_with
|
||||
self.history: list[dict] = []
|
||||
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
if self.on_broadcast is not None:
|
||||
await self.on_broadcast(raw_tx_hex)
|
||||
if self.fail_with is not None:
|
||||
raise self.fail_with
|
||||
return await super().broadcast(raw_tx_hex)
|
||||
|
||||
async def get_history(self, scripthash: str) -> list[dict]:
|
||||
return self.history
|
||||
|
||||
|
||||
async def _versions(session_factory, pending_id):
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(PendingTransactionTxid)
|
||||
.where(PendingTransactionTxid.pending_transaction_id == pending_id)
|
||||
.order_by(PendingTransactionTxid.id)
|
||||
)
|
||||
).all()
|
||||
return [(r.txid, r.raw_hex) for r in rows]
|
||||
|
||||
|
||||
# --- the first version is written with the row (DAT-70) --------------------------------
|
||||
|
||||
|
||||
async def test_a_bet_records_its_first_version_and_a_refusal_deletes_it_with_the_row(bets_session_factory):
|
||||
user_id = await _make_funded_user(bets_session_factory, 4, 3_000_000_000)
|
||||
|
||||
class _Ok:
|
||||
async def broadcast(self, raw):
|
||||
return "x"
|
||||
|
||||
async with bets_session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, _Ok(), user, network=FakeNetwork())
|
||||
async with bets_session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
version = (await session.scalars(select(PendingTransactionTxid))).one()
|
||||
assert (version.pending_transaction_id, version.txid) == (pending.id, participant.bet_txid)
|
||||
assert version.raw_hex == pending.raw_tx_hex
|
||||
|
||||
other = await _make_funded_user(bets_session_factory, 5, 3_000_000_000)
|
||||
|
||||
class _Refuse:
|
||||
async def broadcast(self, raw):
|
||||
raise rpc_error("min relay fee not met")
|
||||
|
||||
from app.bets.service import BetError
|
||||
|
||||
async with bets_session_factory() as session:
|
||||
user = await session.get(User, other)
|
||||
with pytest.raises(BetError):
|
||||
await place_bet(session, _Refuse(), user, network=FakeNetwork(known=False))
|
||||
async with bets_session_factory() as session:
|
||||
# DAT-38: the refused bet's row and its versions are gone; the first bet's stay.
|
||||
assert len((await session.scalars(select(PendingTransactionTxid))).all()) == 1
|
||||
|
||||
|
||||
async def test_a_withdrawal_records_its_first_version(bets_session_factory):
|
||||
user_id = await _make_funded_user(bets_session_factory, 7, 3_000_000_000)
|
||||
|
||||
class _Ok:
|
||||
async def broadcast(self, raw):
|
||||
return "x"
|
||||
|
||||
async with bets_session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
withdrawal = await request_withdrawal(
|
||||
session, _Ok(), user, "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx", 1_000_000_000, network=FakeNetwork()
|
||||
)
|
||||
async with bets_session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
version = (await session.scalars(select(PendingTransactionTxid))).one()
|
||||
assert (version.txid, version.raw_hex) == (withdrawal.txid, pending.raw_tx_hex)
|
||||
|
||||
|
||||
async def test_a_payout_records_its_first_version(payout_session_factory):
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from tests.unit.test_scheduler import FakePayoutClient, FakePayoutListener, _POOL_AMOUNT_SATS, _seed_paying_out_round
|
||||
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
entries = [{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
await RoundScheduler(payout_session_factory, FakePayoutListener(FakePayoutClient(entries)))._trigger_payout(1)
|
||||
async with payout_session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
version = (await session.scalars(select(PendingTransactionTxid))).one()
|
||||
assert (version.txid, version.raw_hex) == (pending.current_txid, pending.raw_tx_hex)
|
||||
|
||||
|
||||
from tests.unit.test_scheduler import payout_session_factory # noqa: E402,F401 (fixture)
|
||||
|
||||
|
||||
# --- bump_fee: write first, then broadcast (TX-12, DAT-34) ------------------------------
|
||||
|
||||
|
||||
async def test_the_replacement_is_recorded_as_a_candidate_before_it_is_broadcast(session_factory):
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
seen: dict = {}
|
||||
|
||||
async def observe(raw_tx_hex: str) -> None:
|
||||
# An independent session, mid-broadcast: what has been committed so far.
|
||||
async with session_factory() as probe:
|
||||
seen["versions"] = [
|
||||
v.txid for v in (await probe.scalars(select(PendingTransactionTxid))).all()
|
||||
]
|
||||
seen["current"] = (await probe.get(PendingTransaction, seeded["pending_id"])).current_txid
|
||||
|
||||
client = _Chain(on_broadcast=observe)
|
||||
new_txid = await bump_fee(session_factory, client, seeded["pending_id"])
|
||||
|
||||
assert new_txid is not None and new_txid != seeded["first_txid"]
|
||||
assert seen["current"] == seeded["first_txid"] # the row had not been switched yet...
|
||||
assert new_txid in seen["versions"] # ...but the replacement was already on record
|
||||
assert [txid for txid, _ in await _versions(session_factory, seeded["pending_id"])] == [seeded["first_txid"], new_txid]
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, seeded["pending_id"])
|
||||
assert row.current_txid == new_txid and row.replaced_by_txid == seeded["first_txid"]
|
||||
assert row.attempt_count == 2 and row.fee_rate_sat_vb == 2
|
||||
participant = (await session.scalars(select(RoundParticipant))).one()
|
||||
utxo = (await session.scalars(select(UtxoEvent))).one()
|
||||
assert participant.bet_txid == utxo.spent_txid == new_txid
|
||||
|
||||
|
||||
async def test_every_bump_adds_a_version_and_all_of_them_stay(session_factory):
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
client = _Chain()
|
||||
second = await bump_fee(session_factory, client, seeded["pending_id"])
|
||||
third = await bump_fee(session_factory, client, seeded["pending_id"])
|
||||
assert [txid for txid, _ in await _versions(session_factory, seeded["pending_id"])] == [
|
||||
seeded["first_txid"],
|
||||
second,
|
||||
third,
|
||||
]
|
||||
# DAT-70: the raw transaction of every version is kept.
|
||||
assert all(raw for _, raw in await _versions(session_factory, seeded["pending_id"]))
|
||||
|
||||
|
||||
async def test_a_crash_between_the_broadcast_and_the_switch_over_leaves_the_replacement_on_record(
|
||||
session_factory, monkeypatch
|
||||
):
|
||||
"""The old phase 3 ran after the broadcast and was unconditional, so a crash or a
|
||||
`database is locked` there left a transaction on the network the DB did not know. Now
|
||||
the candidate is already committed, and the confirmation poller recognises it."""
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
real = broadcast_module.begin_immediate
|
||||
calls = {"n": 0}
|
||||
|
||||
async def flaky(session):
|
||||
# begin_immediate calls in bump_fee: the candidate write (1), then the switch-over
|
||||
# (2). Failing the second is the lock timing out right after the broadcast.
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 2:
|
||||
raise OperationalError("BEGIN IMMEDIATE", {}, Exception("database is locked"))
|
||||
await real(session)
|
||||
|
||||
monkeypatch.setattr(broadcast_module, "begin_immediate", flaky)
|
||||
client = _Chain()
|
||||
with pytest.raises(OperationalError):
|
||||
await bump_fee(session_factory, client, seeded["pending_id"])
|
||||
monkeypatch.setattr(broadcast_module, "begin_immediate", real)
|
||||
|
||||
assert len(client.broadcasted) == 1 # the replacement is on the network
|
||||
versions = await _versions(session_factory, seeded["pending_id"])
|
||||
assert len(versions) == 2 # ...and in the database
|
||||
candidate = versions[1][0]
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, seeded["pending_id"])
|
||||
assert row.current_txid == seeded["first_txid"] # not switched over
|
||||
|
||||
# The network confirms the candidate: the poller recognises it and realigns the row.
|
||||
client.history = [{"tx_hash": candidate, "height": 101}]
|
||||
assert await poll_once(session_factory, client) == 1
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, seeded["pending_id"])
|
||||
assert row.status == "confirmed" and row.current_txid == candidate
|
||||
assert (await session.scalars(select(UtxoEvent))).one().spent_txid == candidate
|
||||
participant = (await session.scalars(select(RoundParticipant))).one()
|
||||
assert participant.bet_txid == candidate and participant.status == "confirmed"
|
||||
|
||||
|
||||
async def test_a_bump_retried_after_a_lost_switch_over_reuses_the_candidate(session_factory, monkeypatch):
|
||||
"""The same replacement is rebuilt (deterministic signing): recording it again must
|
||||
not trip the unique txid."""
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
real = broadcast_module.begin_immediate
|
||||
calls = {"n": 0}
|
||||
|
||||
async def flaky(session):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 2:
|
||||
raise OperationalError("BEGIN IMMEDIATE", {}, Exception("database is locked"))
|
||||
await real(session)
|
||||
|
||||
monkeypatch.setattr(broadcast_module, "begin_immediate", flaky)
|
||||
with pytest.raises(OperationalError):
|
||||
await bump_fee(session_factory, _Chain(), seeded["pending_id"])
|
||||
monkeypatch.setattr(broadcast_module, "begin_immediate", real)
|
||||
|
||||
new_txid = await bump_fee(session_factory, _Chain(), seeded["pending_id"])
|
||||
assert new_txid is not None
|
||||
assert len(await _versions(session_factory, seeded["pending_id"])) == 2
|
||||
|
||||
|
||||
# --- the conditional update (TX-13) ------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_row_that_changed_during_the_broadcast_is_not_overwritten(session_factory):
|
||||
"""An abandon or a confirmation that lands between the broadcast and the switch-over
|
||||
used to be silently overwritten. Now zero matched rows is a conflict: the row stays as
|
||||
it is, the candidate stays recorded."""
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
|
||||
async def confirmed_meanwhile(raw_tx_hex: str) -> None:
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, seeded["pending_id"])
|
||||
row.status = "confirmed"
|
||||
await session.commit()
|
||||
|
||||
result = await bump_fee(session_factory, _Chain(on_broadcast=confirmed_meanwhile), seeded["pending_id"])
|
||||
|
||||
assert result is None
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, seeded["pending_id"])
|
||||
assert row.status == "confirmed"
|
||||
assert row.current_txid == seeded["first_txid"] and row.attempt_count == 1
|
||||
assert (await session.scalars(select(UtxoEvent))).one().spent_txid == seeded["first_txid"]
|
||||
assert len(await _versions(session_factory, seeded["pending_id"])) == 2 # the candidate stays
|
||||
|
||||
|
||||
async def test_a_row_rebumped_during_the_broadcast_is_not_overwritten(session_factory):
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
|
||||
async def rebumped_meanwhile(raw_tx_hex: str) -> None:
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, seeded["pending_id"])
|
||||
row.current_txid = "ee" * 32
|
||||
await session.commit()
|
||||
|
||||
assert await bump_fee(session_factory, _Chain(on_broadcast=rebumped_meanwhile), seeded["pending_id"]) is None
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(PendingTransaction, seeded["pending_id"])).current_txid == "ee" * 32
|
||||
|
||||
|
||||
async def test_an_explicitly_refused_replacement_leaves_the_row_alone(session_factory):
|
||||
"""TX-24: a verified refusal raises RbfError and the row keeps the version that is
|
||||
on the network."""
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
client = _Chain(fail_with=rpc_error("insufficient fee, rejecting replacement"))
|
||||
with pytest.raises(RbfError, match="refused the replacement"):
|
||||
await bump_fee(session_factory, client, seeded["pending_id"], network=FakeNetwork(known=False))
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, seeded["pending_id"])
|
||||
assert row.current_txid == seeded["first_txid"] and row.attempt_count == 1
|
||||
|
||||
|
||||
async def test_an_uncertain_replacement_is_adopted_and_an_accepted_one_is_fanned_out(session_factory):
|
||||
from app.electrum.client import ElectrumError
|
||||
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
network = FakeNetwork(known=False)
|
||||
new_txid = await bump_fee(
|
||||
session_factory, _Chain(fail_with=ElectrumError("connection closed")), seeded["pending_id"], network=network
|
||||
)
|
||||
assert new_txid is not None and network.fanned_out == [] # uncertain: not fanned out, but adopted
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(PendingTransaction, seeded["pending_id"])).current_txid == new_txid
|
||||
|
||||
ok_network = FakeNetwork()
|
||||
client = _Chain()
|
||||
third = await bump_fee(session_factory, client, seeded["pending_id"], network=ok_network)
|
||||
assert third is not None and ok_network.fanned_out == client.broadcasted # TX-21
|
||||
|
||||
|
||||
# --- an earlier version confirming (TX-19, DAT-14) -----------------------------------------
|
||||
|
||||
|
||||
async def test_the_confirmation_of_an_earlier_version_confirms_the_row_and_retargets_everything(session_factory):
|
||||
"""The case that used to be missed: v1 is bumped to v2 and v3, then *v1* confirms.
|
||||
Only v3 was looked for, so the row stayed pending, and six hours later was abandoned
|
||||
— the participant of a confirmed bet deleted."""
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
client = _Chain()
|
||||
await bump_fee(session_factory, client, seeded["pending_id"])
|
||||
third = await bump_fee(session_factory, client, seeded["pending_id"])
|
||||
|
||||
client.history = [{"tx_hash": seeded["first_txid"], "height": 77}]
|
||||
assert await poll_once(session_factory, client) == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, seeded["pending_id"])
|
||||
assert row.status == "confirmed"
|
||||
assert row.current_txid == seeded["first_txid"] != third
|
||||
assert row.raw_tx_hex == seeded["first_raw"]
|
||||
assert (await session.scalars(select(UtxoEvent))).one().spent_txid == seeded["first_txid"]
|
||||
participant = (await session.scalars(select(RoundParticipant))).one()
|
||||
assert participant.bet_txid == seeded["first_txid"] and participant.status == "confirmed"
|
||||
|
||||
|
||||
async def test_an_unrelated_txid_in_the_history_does_not_confirm_the_row(session_factory):
|
||||
seeded = await _seed_pending_bet(session_factory)
|
||||
client = _Chain()
|
||||
client.history = [{"tx_hash": "ab" * 32, "height": 5}]
|
||||
assert await poll_once(session_factory, client) == 0
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(PendingTransaction, seeded["pending_id"])).status == "pending"
|
||||
|
||||
|
||||
# --- the reconciler (TX-15, DAT-13) ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_reconciler_does_not_abandon_a_row_whose_earlier_version_is_on_the_network(session_factory):
|
||||
"""Before the fix a row older than 6h whose *current* txid the server did not list
|
||||
was abandoned — even though an earlier version of it was in the mempool or mined."""
|
||||
seeded = await _seed_pending_bet(session_factory, age_seconds=7 * 3600)
|
||||
client = _Chain()
|
||||
await bump_fee(session_factory, client, seeded["pending_id"])
|
||||
client.history = [{"tx_hash": seeded["first_txid"], "height": 0}] # only the old version is known
|
||||
|
||||
await reconcile_once(session_factory, client)
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, seeded["pending_id"])
|
||||
assert row.status == "pending"
|
||||
assert row.current_txid == seeded["first_txid"] # the row follows the version on the network
|
||||
assert (await session.scalars(select(RoundParticipant))).one().bet_txid == seeded["first_txid"]
|
||||
assert "pending_tx_abandoned" not in [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
|
||||
|
||||
async def test_abandoning_a_row_releases_inputs_claimed_by_any_of_its_versions(session_factory):
|
||||
"""DAT-13: an input still marked with an earlier version's txid is this row's claim
|
||||
too, so abandoning the row frees it."""
|
||||
seeded = await _seed_pending_bet(session_factory, age_seconds=7 * 3600)
|
||||
client = _Chain()
|
||||
await bump_fee(session_factory, client, seeded["pending_id"])
|
||||
async with session_factory() as session: # simulate a claim left on the earlier version
|
||||
(await session.scalars(select(UtxoEvent))).one().spent_txid = seeded["first_txid"]
|
||||
await session.commit()
|
||||
client.history = [] # nobody knows any version
|
||||
|
||||
await reconcile_once(session_factory, client)
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(PendingTransaction, seeded["pending_id"])).status == "failed"
|
||||
assert (await session.scalars(select(UtxoEvent))).one().spent_txid is None
|
||||
Reference in new issue
Block a user