poll_once marked a transaction confirmed on one server's `height > 0`, and reinstate_reappeared_utxos restored a coin written off as spent outside the platform on the active server's word alone, so a hostile active server could report a never-relayed payout as confirmed (winner unpaid, round closed) or put a spent coin back into a balance. - electrum/merkle.py: pure merkle-branch verification against a block header. - ElectrumListener.confirmation_proven(txid, height): the *other* servers are asked for blockchain.transaction.get_merkle; a strict majority of at least the quorum minimum must return a proof that recomputes to the merkle root of that block's header (block height and position checked too). The header is the tip the listener holds, or for an older block the one a majority of the quorum agrees on and that meets its own target; a height above our tip cannot be proven. Quorum rules are _corroborate_majority's. - poll_once takes the listener as `network`: the active server's history only nominates a candidate (txid, height); the row confirms only when the proof holds, and stays pending otherwise. With no network nothing confirms. ConfirmationPoller wires it. - The reconciler's late-confirmation check of failed rows (B-78) now puts each claimed (txid, height) to the same proof instead of counting histories. - refresh_user splits reinstatement like a credit: find_reinstate_candidates (read-only; same outpoint, recorded amount, confirmed) -> corroborate_utxo_reinstatement (same quorum as a credit) -> reinstate_reappeared_utxos writes only what was corroborated, rechecking the mark under the lock. Nothing is committed in the read phase any more. - Broadcast fan-out, the third part of the finding, shipped with B-76. Tests changed because the spec changed: every poll_once call now passes a network that proves (FakeNetwork) — the confirmation tests are otherwise unchanged; the failed-row "quorum does not confirm" test now asserts the claim goes to the proof. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
449 lines
21 KiB
Python
449 lines
21 KiB
Python
"""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, ScriptedNetwork, 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, FakeNetwork()) == 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, FakeNetwork()) == 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, FakeNetwork()) == 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, ScriptedNetwork.proof_of_absence())
|
|
|
|
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
|