Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
"""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
|
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
|
|
|
from tests.unit.fake_network import FakeNetwork, ScriptedNetwork, rpc_error
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
from 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}]
|
2026-10-14 15:29:30 +02:00
|
|
|
assert await poll_once(session_factory, client, FakeNetwork()) == 1
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
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}]
|
2026-10-14 15:29:30 +02:00
|
|
|
assert await poll_once(session_factory, client, FakeNetwork()) == 1
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
|
|
|
|
|
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}]
|
2026-10-14 15:29:30 +02:00
|
|
|
assert await poll_once(session_factory, client, FakeNetwork()) == 0
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
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
|
|
|
|
|
|
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
|
|
|
await reconcile_once(session_factory, client, ScriptedNetwork.proof_of_absence())
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
|
|
|
|
|
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
|