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
@@ -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