The pool normally holds exactly the round's bet outputs and the payout spent them all for exactly the pool amount, so it had no change output: bump_fee could never accelerate it, and a payout stuck in a mempool (never abandoned while a server knows it) kept the round in `paying_out` and the whole lottery stopped. - build_payout_transaction always has three outputs: winner, fee address, and a bump reserve of max(DUST_LIMIT_SATS, estimate_vsize(inputs, 3) * 10 sat/vB) paid to the reserve address m/84'/746'/0'/1/0. The reserve comes out of the platform's 30%, the network fee still out of the winner's 70%. Input value beyond winner share + commission (only rounds opened before the per-round pool address) is folded into the reserve output if >= dust, left to the network fee otherwise. `change_address` is replaced by `reserve_address`. - `rounds.bump_reserve_sats` (Alembic e5a91c3d7b26, plain add_column) is written with the payout intent before the broadcast; /admin/rounds returns it. - bump_fee takes the increase from the reserve output for a payout (_find_reducible_output; a payout already in flight with a change to the pool still falls back to that), never from the winner or fee-address outputs. - payout_config_problem / rounds_can_open (RND-40): a bet amount and fee rate under which a round of 1 or of MAX_PARTICIPANTS_PER_ROUND participants could not cover the network fee, the reserve and dust outputs opens no round (lottery_configured=false); PUT /admin/config refuses such a bet amount or fee rate with 422 config_not_payable, judged on the resulting values, since the payout reads the fee rate live. Ambiguity resolved: the reserve output and any excess input are one output (RND-23 says one output to the reserve address), and bump_reserve_sats records that output's initial value. DAT-22's "abandon clears all amounts" stays with B-96. Tests changed because the spec changed: the four test_payout_builder tests asserted the old two/three-output shape with change to the pool and the fee address receiving the whole 30%; the whitespace fee_address test now spells out the amounts rounds_can_open reads. New: reserve sizing, payout with 1 and with 400 participants (inputs = outputs + fee to the satoshi), exact commission/reserve/dust boundary, excess folded or left to the fee, a real payout bumped out of its reserve with the other outputs byte-identical and bumped five times, the old reserve-less shape still raising RbfError, the scheduler output addresses and recorded amounts, payability boundaries found independently by bisection, no round / unconfigured flag for an unpayable config, a round in progress surviving it, PUT /admin/config refusals and the migration column. Mutation-checked: dropping the reserve lookup in bump_fee and the payability check in rounds_can_open each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
702 lines
29 KiB
Python
702 lines
29 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from embit import script
|
|
from embit.bip32 import HDKey
|
|
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app.config import settings
|
|
from app.db.base import Base
|
|
from app.db.models import PendingTransaction, User
|
|
from app.tx.broadcast import RbfError, bump_fee, should_bump
|
|
from app.wallet.plm_network import PLM_MAINNET
|
|
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, Utxo, build_signed_transaction, estimate_vsize
|
|
|
|
|
|
def _key(seed_byte: int) -> HDKey:
|
|
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
|
|
return root.derive("m/84h/746h/0h/0/0")
|
|
|
|
|
|
def test_should_bump_false_before_timeout():
|
|
pending = PendingTransaction(
|
|
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
|
|
broadcast_at=datetime.now(timezone.utc), last_broadcast_at=datetime.now(timezone.utc),
|
|
)
|
|
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
|
|
|
|
|
def test_should_bump_true_after_timeout():
|
|
pending = PendingTransaction(
|
|
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is True
|
|
|
|
|
|
def test_should_bump_false_when_not_pending():
|
|
pending = PendingTransaction(
|
|
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="confirmed",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
|
|
|
|
|
def test_should_bump_measures_from_last_broadcast_not_first(monkeypatch):
|
|
"""B-27 regression: a tx first broadcast long ago, but bumped recently, must not
|
|
be due for another bump yet — should_bump has to look at last_broadcast_at, not
|
|
the original broadcast_at, or every tick would try to re-bump it."""
|
|
pending = PendingTransaction(
|
|
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=10_000),
|
|
last_broadcast_at=datetime.now(timezone.utc),
|
|
)
|
|
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
|
|
|
|
|
class FakeClient:
|
|
"""B-40: _prevout_amount now asks for the raw (non-verbose) transaction and
|
|
reads its output value as an integer via embit, rather than a verbose reply's
|
|
float "value" field — so this fake must hand back a real, parseable raw tx
|
|
whose vout[0] carries the requested amount (every test here spends vout 0 of
|
|
its fixture UTXO)."""
|
|
|
|
def __init__(self, prevout_values: dict[str, int]):
|
|
self._prevout_values = prevout_values
|
|
self.broadcasted: list[str] = []
|
|
|
|
async def get_transaction(self, txid: str, verbose: bool = False) -> str:
|
|
assert verbose is False
|
|
fake_prevout_tx = Transaction(
|
|
vin=[TransactionInput(b"\x00" * 32, 0)],
|
|
vout=[TransactionOutput(self._prevout_values[txid], script.Script(b"\x00\x14" + b"\x00" * 20))],
|
|
)
|
|
return fake_prevout_tx.serialize().hex()
|
|
|
|
async def broadcast(self, raw_tx_hex: str) -> str:
|
|
self.broadcasted.append(raw_tx_hex)
|
|
return "network-txid"
|
|
|
|
|
|
@pytest.fixture
|
|
async def session_factory(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
|
monkeypatch.setattr(
|
|
settings,
|
|
"xprv_encryption_key",
|
|
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
|
|
)
|
|
from app.wallet import hd
|
|
|
|
hd._account_key = None
|
|
hd.generate_master_key()
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield async_sessionmaker(engine, expire_on_commit=False)
|
|
await engine.dispose()
|
|
hd._account_key = None
|
|
|
|
|
|
async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
|
|
from app.wallet.hd import derive_user_address, derive_user_key
|
|
|
|
signer = derive_user_key(0)
|
|
my_address = derive_user_address(0)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(99).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 150_000_000
|
|
utxo_txid = "11" * 32
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
|
to_address=to_address,
|
|
amount_sats=10_000_000,
|
|
change_address=my_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
async with session_factory() as session:
|
|
user = User(username="alice", password_hash="x", derivation_index=0, address=my_address)
|
|
session.add(user)
|
|
await session.commit()
|
|
pending = PendingTransaction(
|
|
kind="bet",
|
|
user_id=user.id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=1,
|
|
raw_tx_hex=built.raw_hex,
|
|
status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
client = FakeClient({utxo_txid: utxo_amount})
|
|
|
|
new_txid = await bump_fee(session_factory, client, pending_id)
|
|
|
|
assert client.broadcasted
|
|
assert new_txid != built.txid
|
|
|
|
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
|
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
|
old_change = next(o.value for o in old_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
|
|
new_change = next(o.value for o in new_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
|
|
assert new_change < old_change # fee bump came out of the change output
|
|
|
|
async with session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
assert row.current_txid == new_txid
|
|
assert row.fee_rate_sat_vb == 2
|
|
assert row.attempt_count == 2
|
|
|
|
|
|
async def test_bump_fee_leaves_broadcast_at_untouched(session_factory):
|
|
"""B-27 regression: bump_fee must only ever update last_broadcast_at. Before
|
|
this, it overwrote broadcast_at on every bump — the same field
|
|
tx/reconcile.py's abandon-after-N-hours grace period measures from — so a
|
|
repeatedly-bumped-but-never-mined tx reset that clock forever and was never
|
|
abandoned."""
|
|
from app.wallet.hd import derive_user_address, derive_user_key
|
|
|
|
signer = derive_user_key(0)
|
|
my_address = derive_user_address(0)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 150_000_000
|
|
utxo_txid = "33" * 32
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
|
to_address=to_address,
|
|
amount_sats=10_000_000,
|
|
change_address=my_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
original_broadcast_at = datetime.now(timezone.utc) - timedelta(days=1)
|
|
async with session_factory() as session:
|
|
user = User(username="carol", password_hash="x", derivation_index=0, address=my_address)
|
|
session.add(user)
|
|
await session.commit()
|
|
pending = PendingTransaction(
|
|
kind="bet",
|
|
user_id=user.id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=1,
|
|
raw_tx_hex=built.raw_hex,
|
|
status="pending",
|
|
broadcast_at=original_broadcast_at,
|
|
last_broadcast_at=original_broadcast_at,
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
client = FakeClient({utxo_txid: utxo_amount})
|
|
before_bump = datetime.now(timezone.utc)
|
|
|
|
await bump_fee(session_factory, client, pending_id)
|
|
|
|
async with session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
assert row.broadcast_at.replace(tzinfo=timezone.utc) == original_broadcast_at
|
|
assert row.last_broadcast_at.replace(tzinfo=timezone.utc) >= before_bump
|
|
|
|
|
|
async def test_bump_fee_raises_when_no_change_output(session_factory):
|
|
"""The guard still matters after B-62 even though the builder no longer produces
|
|
this shape: a single-output transaction broadcast before that change can still be
|
|
sitting in `pending` across the deploy, and it must fail loudly rather than
|
|
silently shrink the recipient's output. Hence a hand-built tx here — the point is
|
|
exactly that build_signed_transaction won't make one any more."""
|
|
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
|
|
|
from app.wallet.hd import derive_user_address, derive_user_key
|
|
from app.wallet.psbt_builder import RBF_SEQUENCE
|
|
|
|
signer = derive_user_key(0)
|
|
my_address = derive_user_address(0)
|
|
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 10_000_000 # entirely consumed by the single recipient output
|
|
utxo_txid = "22" * 32
|
|
legacy_tx = Transaction(
|
|
vin=[TransactionInput(bytes.fromhex(utxo_txid), 0, sequence=RBF_SEQUENCE)],
|
|
vout=[TransactionOutput(utxo_amount - 141, script.Script.from_address(to_address))],
|
|
)
|
|
built = SimpleNamespace(raw_hex=legacy_tx.serialize().hex(), txid=legacy_tx.txid().hex())
|
|
|
|
async with session_factory() as session:
|
|
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
|
|
session.add(user)
|
|
await session.commit()
|
|
pending = PendingTransaction(
|
|
kind="bet",
|
|
user_id=user.id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=1,
|
|
raw_tx_hex=built.raw_hex,
|
|
status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
client = FakeClient({utxo_txid: utxo_amount})
|
|
|
|
with pytest.raises(RbfError):
|
|
await bump_fee(session_factory, client, pending_id)
|
|
|
|
|
|
async def test_bump_fee_retargets_every_stored_txid(session_factory):
|
|
"""B-02/B-20: a bump changes the txid, and everything that recorded the old one
|
|
has to follow — the participant's bet_txid (whose staleness used to wedge the
|
|
round forever), the UTXO's spent_txid (which the reconciler matches on), and
|
|
replaced_by_txid, which was never written at all."""
|
|
from app.db.models import Round, RoundParticipant, UtxoEvent
|
|
from app.wallet.hd import derive_user_address, derive_user_key
|
|
|
|
signer = derive_user_key(0)
|
|
my_address = derive_user_address(0)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 150_000_000
|
|
utxo_txid = "22" * 32
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
|
to_address=to_address,
|
|
amount_sats=10_000_000,
|
|
change_address=my_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
async with session_factory() as session:
|
|
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
|
|
session.add(user)
|
|
session.add(Round(id=1, status="open"))
|
|
await session.flush()
|
|
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=built.recipient_sats,
|
|
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=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
new_txid = await bump_fee(session_factory, FakeClient({utxo_txid: utxo_amount}), pending_id)
|
|
|
|
async with session_factory() as session:
|
|
from sqlalchemy import select
|
|
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
assert row.current_txid == new_txid
|
|
assert row.replaced_by_txid == built.txid # points backwards at what it replaced
|
|
|
|
participant = (await session.scalars(select(RoundParticipant))).one()
|
|
assert participant.bet_txid == new_txid
|
|
|
|
utxo = (await session.scalars(select(UtxoEvent))).one()
|
|
assert utxo.spent_txid == new_txid
|
|
|
|
|
|
# --- B-32: the bump delta must always meet BIP125's relay-mandated minimum, and
|
|
# escalation must stop at a ceiling instead of retrying forever. ------------------
|
|
|
|
|
|
async def test_bump_fee_meets_bip125_minimum_when_old_fee_already_exceeds_target(session_factory):
|
|
"""old_fee (as bump_fee computes it from the actual prevout amounts) can end
|
|
up higher than vsize * target_fee_rate — e.g. because dust change was folded
|
|
into the original fee (wallet/psbt_builder.py's DUST_LIMIT_SATS handling).
|
|
The naive `target_fee - old_fee` goes negative in that case; the previous
|
|
fallback was a flat 1-satoshi total bump, nowhere near BIP125 rule 4's
|
|
required minimum, so the node rejected it every time and — since bump_fee
|
|
raised before touching `pending` — the next tick retried identically every
|
|
30 seconds, forever. Simulated here by reporting a prevout inflated beyond
|
|
what was actually spent, which has the same effect on old_fee as dust
|
|
absorption would."""
|
|
from app.wallet.hd import derive_user_address, derive_user_key
|
|
|
|
signer = derive_user_key(0)
|
|
my_address = derive_user_address(0)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(96).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 150_000_000
|
|
utxo_txid = "55" * 32
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
|
to_address=to_address,
|
|
amount_sats=10_000_000,
|
|
change_address=my_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
async with session_factory() as session:
|
|
user = User(username="dave", password_hash="x", derivation_index=0, address=my_address)
|
|
session.add(user)
|
|
await session.commit()
|
|
pending = PendingTransaction(
|
|
kind="bet",
|
|
user_id=user.id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=1,
|
|
raw_tx_hex=built.raw_hex,
|
|
status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
# Reports a prevout inflated well beyond what was actually spent — has the
|
|
# same effect on old_fee as dust absorption would have: old_fee ends up far
|
|
# above vsize * target_fee_rate (target_fee_rate = 2 here).
|
|
inflated_excess = 50_000
|
|
client = FakeClient({utxo_txid: utxo_amount + inflated_excess})
|
|
|
|
new_txid = await bump_fee(session_factory, client, pending_id)
|
|
|
|
assert client.broadcasted
|
|
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
|
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
|
old_change = next(o.value for o in old_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
|
|
new_change = next(o.value for o in new_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
|
|
|
|
vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout))
|
|
min_valid_delta = vsize * 1 # BIP125 rule 4's floor at a 1 sat/vB incremental relay fee
|
|
assert min_valid_delta > 1 # meaningfully more than the old flat "1 satoshi" fallback
|
|
assert old_change - new_change == min_valid_delta
|
|
|
|
async with session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
old_fee_as_bump_fee_computed_it = built.fee_sats + inflated_excess
|
|
expected_rate = (old_fee_as_bump_fee_computed_it + min_valid_delta) // vsize
|
|
assert row.fee_rate_sat_vb == expected_rate
|
|
assert row.fee_rate_sat_vb > 2 # the actual rate, not the naive (and too-low) target
|
|
|
|
|
|
async def test_bump_fee_refuses_once_at_the_max_fee_rate(session_factory):
|
|
"""Without a ceiling, a stuck transaction's fee rate climbed by 1 sat/vB every
|
|
30 seconds forever, eating further and further into the user's change."""
|
|
from app.wallet.hd import derive_user_address, derive_user_key
|
|
|
|
signer = derive_user_key(0)
|
|
my_address = derive_user_address(0)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(95).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 150_000_000
|
|
utxo_txid = "66" * 32
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
|
to_address=to_address,
|
|
amount_sats=10_000_000,
|
|
change_address=my_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
async with session_factory() as session:
|
|
user = User(username="erin", password_hash="x", derivation_index=0, address=my_address)
|
|
session.add(user)
|
|
await session.commit()
|
|
pending = PendingTransaction(
|
|
kind="bet",
|
|
user_id=user.id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=MAX_FEE_RATE_SAT_VB,
|
|
raw_tx_hex=built.raw_hex,
|
|
status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
client = FakeClient({utxo_txid: utxo_amount})
|
|
|
|
with pytest.raises(RbfError):
|
|
await bump_fee(session_factory, client, pending_id)
|
|
|
|
assert not client.broadcasted
|
|
|
|
|
|
# --- B-40: bump_fee must not hold a DB session open across its network calls,
|
|
# and a row that's no longer pending by the time it runs is a quiet no-op. -------
|
|
|
|
|
|
async def test_bump_fee_holds_no_session_open_during_network_calls(session_factory):
|
|
"""The get_transaction-per-input reads and the broadcast must happen with no
|
|
DB session held open — the same shape used elsewhere for this reason (B-18,
|
|
electrum/listener.py's refresh_user for B-31) — otherwise a session sits
|
|
idle in the pool for the whole duration of what can be several slow network
|
|
round-trips."""
|
|
from app.wallet.hd import derive_user_address, derive_user_key
|
|
|
|
signer = derive_user_key(0)
|
|
my_address = derive_user_address(0)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(94).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 150_000_000
|
|
utxo_txid = "77" * 32
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
|
to_address=to_address,
|
|
amount_sats=10_000_000,
|
|
change_address=my_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
async with session_factory() as session:
|
|
user = User(username="frank", password_hash="x", derivation_index=0, address=my_address)
|
|
session.add(user)
|
|
await session.commit()
|
|
pending = PendingTransaction(
|
|
kind="bet",
|
|
user_id=user.id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=1,
|
|
raw_tx_hex=built.raw_hex,
|
|
status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
open_count = {"n": 0}
|
|
|
|
class _TrackedSession:
|
|
def __init__(self, inner):
|
|
self._inner = inner
|
|
|
|
async def __aenter__(self):
|
|
result = await self._inner.__aenter__()
|
|
open_count["n"] += 1
|
|
return result
|
|
|
|
async def __aexit__(self, *exc):
|
|
open_count["n"] -= 1
|
|
return await self._inner.__aexit__(*exc)
|
|
|
|
def tracking_session_factory():
|
|
return _TrackedSession(session_factory())
|
|
|
|
class TrackingClient(FakeClient):
|
|
async def get_transaction(self, txid, verbose=False):
|
|
assert open_count["n"] == 0, "a session was held open during a network call"
|
|
return await super().get_transaction(txid, verbose)
|
|
|
|
async def broadcast(self, raw_tx_hex):
|
|
assert open_count["n"] == 0, "a session was held open during the broadcast"
|
|
return await super().broadcast(raw_tx_hex)
|
|
|
|
client = TrackingClient({utxo_txid: utxo_amount})
|
|
await bump_fee(tracking_session_factory, client, pending_id)
|
|
|
|
assert client.broadcasted
|
|
assert open_count["n"] == 0 # nothing left open afterwards either
|
|
|
|
|
|
async def test_bump_fee_is_a_noop_when_no_longer_pending(session_factory):
|
|
"""A row can legitimately confirm (or otherwise leave "pending") between
|
|
being read as due and RbfBumper actually attempting the bump — a normal
|
|
race, not an error. Must return quietly rather than raising or touching
|
|
the network."""
|
|
async with session_factory() as session:
|
|
user = User(username="grace", password_hash="x", derivation_index=0, address="plm1qxxx")
|
|
session.add(user)
|
|
await session.commit()
|
|
pending = PendingTransaction(
|
|
kind="bet",
|
|
user_id=user.id,
|
|
current_txid="already-confirmed-txid",
|
|
fee_rate_sat_vb=1,
|
|
raw_tx_hex="00",
|
|
status="confirmed",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
client = FakeClient({})
|
|
|
|
result = await bump_fee(session_factory, client, pending_id)
|
|
|
|
assert result is None
|
|
assert not client.broadcasted
|
|
|
|
|
|
async def test_bump_fee_is_a_noop_when_the_row_is_gone(session_factory):
|
|
client = FakeClient({})
|
|
result = await bump_fee(session_factory, client, 999_999)
|
|
assert result is None
|
|
assert not client.broadcasted
|
|
|
|
|
|
async def _seed_payout(session_factory, *, n: int = 3, bet: int = 1_000_000_000, fee_rate: int = 1):
|
|
"""A real payout (the production builder, the round's own key, the reserve address) in
|
|
`pending`, plus the prevout amounts a fake chain answers with."""
|
|
from app.db.models import Round
|
|
from app.rounds.service import winner_share
|
|
from app.wallet.hd import derive_pool_address, derive_round_pool_key
|
|
from app.wallet.psbt_builder import build_payout_transaction
|
|
|
|
pool_address = derive_pool_address(1)
|
|
pool_key = derive_round_pool_key(1, pool_address)
|
|
winner_address = script.p2wpkh(_key(81).to_public()).address(network=PLM_MAINNET)
|
|
fee_address = script.p2wpkh(_key(82).to_public()).address(network=PLM_MAINNET)
|
|
utxos = [Utxo(f"{i + 1:064x}", 0, bet) for i in range(n)]
|
|
pool = bet * n
|
|
built = build_payout_transaction(
|
|
signing_key=pool_key,
|
|
from_script=script.p2wpkh(pool_key.to_public()),
|
|
utxos=utxos,
|
|
winner_address=winner_address,
|
|
winner_share_sats=winner_share(pool),
|
|
fee_address=fee_address,
|
|
commission_sats=pool - winner_share(pool),
|
|
reserve_address=derive_pool_address(),
|
|
fee_rate_sat_vb=fee_rate,
|
|
)
|
|
async with session_factory() as session:
|
|
session.add(Round(id=1, status="paying_out", pool_address=pool_address, payout_txid=built.txid))
|
|
pending = PendingTransaction(
|
|
kind="payout",
|
|
round_id=1,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=fee_rate,
|
|
raw_tx_hex=built.raw_hex,
|
|
status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
return built, pending_id, {u.txid: u.amount_sats for u in utxos}
|
|
|
|
|
|
async def test_a_payout_is_bumped_out_of_its_reserve_output_and_nothing_else(session_factory):
|
|
"""B-79 (RND-27, RND-38, TX-11): the payout used to have no output to shrink — it spent
|
|
exactly the bet outputs for exactly the pool — so it could never be accelerated. Now the
|
|
replacement spends the same coins, leaves the winner's and the fee address's outputs
|
|
untouched, and takes the whole increase from the reserve output."""
|
|
from app.wallet.hd import derive_pool_address
|
|
|
|
built, pending_id, prevouts = await _seed_payout(session_factory, n=3)
|
|
client = FakeClient(prevouts)
|
|
|
|
new_txid = await bump_fee(session_factory, client, pending_id)
|
|
|
|
assert new_txid is not None and new_txid != built.txid
|
|
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
|
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
|
assert [(v.txid, v.vout) for v in new_tx.vin] == [(v.txid, v.vout) for v in old_tx.vin]
|
|
assert len(new_tx.vout) == len(old_tx.vout) == 3
|
|
# outputs 0 (winner) and 1 (fee address) are byte-for-byte the same ...
|
|
assert new_tx.vout[0].serialize() == old_tx.vout[0].serialize()
|
|
assert new_tx.vout[1].serialize() == old_tx.vout[1].serialize()
|
|
# ... and the reserve output paid for the whole increase
|
|
assert new_tx.vout[2].script_pubkey.address(network=PLM_MAINNET) == derive_pool_address()
|
|
increase = (sum(o.value for o in old_tx.vout) - sum(o.value for o in new_tx.vout))
|
|
assert increase > 0 and new_tx.vout[2].value == old_tx.vout[2].value - increase
|
|
assert new_tx.vout[2].value >= 294
|
|
|
|
from app.db.models import Round
|
|
|
|
async with session_factory() as session:
|
|
assert (await session.get(Round, 1)).payout_txid == new_txid
|
|
|
|
|
|
async def test_a_payout_can_be_bumped_repeatedly_while_the_reserve_lasts(session_factory):
|
|
"""The reserve is sized for 10 sat/vB, so the first several bumps (1 sat/vB each) all
|
|
fit — the failure it fixes was the very first one."""
|
|
built, pending_id, prevouts = await _seed_payout(session_factory, n=2)
|
|
client = FakeClient(prevouts)
|
|
for _ in range(5):
|
|
# past the bump timeout every round: pretend the last broadcast was long ago
|
|
async with session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
row.last_broadcast_at = datetime.now(timezone.utc) - timedelta(seconds=5000)
|
|
await session.commit()
|
|
assert await bump_fee(session_factory, client, pending_id) is not None
|
|
assert len(client.broadcasted) == 5
|
|
last = Transaction.parse(bytes.fromhex(client.broadcasted[-1]))
|
|
assert last.vout[2].value >= 294
|
|
|
|
|
|
async def test_a_payout_without_a_reserve_output_still_cannot_be_bumped(session_factory):
|
|
"""What the old shape looked like: nothing to shrink, so RbfError — the reason the reserve
|
|
exists. (A payout already in flight when this shipped keeps that fate.)"""
|
|
from app.db.models import Round
|
|
from app.wallet.hd import derive_pool_address, derive_round_pool_key
|
|
|
|
pool_address = derive_pool_address(1)
|
|
pool_key = derive_round_pool_key(1, pool_address)
|
|
winner = script.p2wpkh(_key(83).to_public()).address(network=PLM_MAINNET)
|
|
fee = script.p2wpkh(_key(84).to_public()).address(network=PLM_MAINNET)
|
|
tx_in = TransactionInput(bytes.fromhex("aa" * 32), 0, sequence=0xFFFFFFFD)
|
|
from embit.psbt import PSBT
|
|
from embit.finalizer import finalize_psbt
|
|
|
|
tx = Transaction(
|
|
vin=[tx_in],
|
|
vout=[
|
|
TransactionOutput(699_000_000, script.Script.from_address(winner)),
|
|
TransactionOutput(299_999_000, script.Script.from_address(fee)),
|
|
],
|
|
)
|
|
psbt = PSBT(tx)
|
|
psbt.inputs[0].witness_utxo = TransactionOutput(1_000_000_000, script.p2wpkh(pool_key.to_public()))
|
|
psbt.sign_with(pool_key)
|
|
final = finalize_psbt(psbt)
|
|
async with session_factory() as session:
|
|
session.add(Round(id=1, status="paying_out", pool_address=pool_address))
|
|
session.add(
|
|
PendingTransaction(
|
|
kind="payout", round_id=1, current_txid=final.txid().hex(), fee_rate_sat_vb=1,
|
|
raw_tx_hex=final.serialize().hex(), status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
)
|
|
await session.commit()
|
|
pending_id = (await session.scalars(__import__("sqlalchemy").select(PendingTransaction))).one().id
|
|
with pytest.raises(RbfError):
|
|
await bump_fee(session_factory, FakeClient({"aa" * 32: 1_000_000_000}), pending_id)
|