Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)

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>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-16 13:55:51 +02:00
1 parent 473d541ab5
commit daa85cf4f0
27 files changed
+684 -139

No files matched your search

+131
View File
@@ -568,3 +568,134 @@ async def test_bump_fee_is_a_noop_when_the_row_is_gone(session_factory):
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)