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:
1 parent
473d541ab5
commit
daa85cf4f0
27 files changed
+684
-139
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user